bun-shared-memory 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +41 -28
- package/package.json +1 -1
- package/src/growable-buffer.ts +275 -47
- package/src/memory.ts +162 -21
- package/src/refcount.ts +16 -1
- package/src/worker.ts +47 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,58 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.1.7] - 2026-11-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Keep the Index Table in a single growable `SharedArrayBuffer` (capped at
|
|
13
|
+
4 GiB) instead of the chunked logic, so it stays contiguous and cheap to
|
|
14
|
+
address. `SharedMemoryOptions.indexTableMaxByteLength` configures its cap
|
|
15
|
+
(default 4 GiB).
|
|
16
|
+
- `maxTotalBytes` now caps the combined physical size of DataTable,
|
|
17
|
+
StringTable, and ArrayTable only (the Index Table is excluded). The budget
|
|
18
|
+
is enforced by an atomic `SharedBudget` counter shared across workers that
|
|
19
|
+
reserves memory before each chunk growth, so the three tables cannot exceed
|
|
20
|
+
the limit even when multiple workers write concurrently.
|
|
21
|
+
- Enforce size limits regardless of the configured values: `chunkSize` is at
|
|
22
|
+
least 4 MiB, `maxTotalBytes`/`maxByteLength` are at least 32 MiB, and
|
|
23
|
+
`chunkSize` is raised as needed so no table needs more than 1024 chunks.
|
|
24
|
+
`SharedMemory.maxByteLength` and `SharedMemory.maxTotalBytes` are now public
|
|
25
|
+
read-only fields.
|
|
26
|
+
|
|
27
|
+
### Performance
|
|
28
|
+
|
|
29
|
+
- Reclaim memory from inactive chunks after defragmentation. A
|
|
30
|
+
`SharedArrayBuffer` cannot be shrunk, so `GrowableBuffer` replaces each
|
|
31
|
+
inactive chunk with a fresh 4-byte `SharedArrayBuffer`, releasing the old
|
|
32
|
+
full-size buffer so its memory is freed. `defragment()` returns the new
|
|
33
|
+
chunk lists and the main thread broadcasts them to workers via a
|
|
34
|
+
`__chunkUpdate` message; workers apply them with `applyChunkUpdate()`. This
|
|
35
|
+
only runs during defragmentation (rare), so the hot path is unaffected.
|
|
36
|
+
- Optimize the budget write hot path: `GrowableBuffer` tracks
|
|
37
|
+
`nextChunkToActivate` (the first not-yet-active chunk) so `ensureCapacity`
|
|
38
|
+
only activates new chunks instead of re-scanning every already-active chunk
|
|
39
|
+
on each write (was O(n) per write as the dataset grew). Writes ~9% faster.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Stabilize the real-world benchmark against CPU contention. The shared-memory
|
|
44
|
+
methodology finishes in tens of milliseconds, so it is sensitive to load
|
|
45
|
+
from other processes (IDE, language servers). Each worker now runs several
|
|
46
|
+
measured samples (default 5) and reports its fastest; each methodology is
|
|
47
|
+
run several times (default 3) and the fastest run is kept. Because
|
|
48
|
+
contention can only slow a run down, the minimum is the best estimate of
|
|
49
|
+
true throughput.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- Resolve chunk offsets with an unsigned right shift (`offset >>> chunkShift`)
|
|
54
|
+
instead of a signed one. A signed 32-bit shift turns offsets at or above
|
|
55
|
+
2 GiB negative, so reads/writes past 2 GiB (e.g. in a >4 GiB chunked buffer)
|
|
56
|
+
hit the wrong chunk or threw. The Index Table and the Data/String/Array
|
|
57
|
+
tables can now be addressed at any offset below the 4 GiB single-SAB cap.
|
|
58
|
+
Reported by [mizulu](https://github.com/mizulu).
|
|
59
|
+
|
|
8
60
|
## [0.1.6] - 2026-11-02
|
|
9
61
|
|
|
10
62
|
### Added
|
|
@@ -120,6 +172,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
120
172
|
- O(1) in-place `push`/`pop` with capacity-based array growth.
|
|
121
173
|
- Benchmarks for all data types vs native.
|
|
122
174
|
|
|
175
|
+
[0.1.7]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.6...v0.1.7
|
|
123
176
|
[0.1.6]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.5...v0.1.6
|
|
124
177
|
[0.1.5]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.4...v0.1.5
|
|
125
178
|
[0.1.4]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.3...v0.1.4
|
package/README.md
CHANGED
|
@@ -37,20 +37,21 @@ is benchmarked sequentially with 4 workers. See
|
|
|
37
37
|
[`bench/real-world/README.md`](bench/real-world/README.md) for setup.
|
|
38
38
|
|
|
39
39
|
Measured on Linux (Bun 1.4.4, 100,000 requests per methodology, sorted by
|
|
40
|
-
speed; MySQL/Redis benchmarked over both TCP and Unix socket
|
|
40
|
+
speed; MySQL/Redis benchmarked over both TCP and Unix socket; each methodology
|
|
41
|
+
is the minimum over 3 runs × 5 samples to filter CPU-contention noise):
|
|
41
42
|
|
|
42
43
|
| Methodology | Transport | Requests | Elapsed (ms) | req/s | vs. shared |
|
|
43
44
|
|-------------|-----------|----------|--------------|------------|------------|
|
|
44
|
-
| `shared` | — | 100,000 |
|
|
45
|
-
| `sqlite` | — | 100,000 |
|
|
46
|
-
| `redis` | socket | 100,000 | 1,
|
|
47
|
-
| `mysql` | socket | 100,000 | 1,
|
|
48
|
-
| `redis` | tcp | 100,000 | 2,
|
|
49
|
-
| `mysql` | tcp | 100,000 |
|
|
50
|
-
|
|
51
|
-
Shared memory is the fastest by a wide margin: ~
|
|
52
|
-
read and ~
|
|
53
|
-
significantly faster than TCP for MySQL/Redis (~1.
|
|
45
|
+
| `shared` | — | 100,000 | 21 | 4,876,492 | — |
|
|
46
|
+
| `sqlite` | — | 100,000 | 188 | 531,116 | 9.2× slower |
|
|
47
|
+
| `redis` | socket | 100,000 | 1,262 | 79,229 | 61.5× slower |
|
|
48
|
+
| `mysql` | socket | 100,000 | 1,605 | 62,322 | 78.2× slower |
|
|
49
|
+
| `redis` | tcp | 100,000 | 2,817 | 35,497 | 137.4× slower |
|
|
50
|
+
| `mysql` | tcp | 100,000 | 2,976 | 33,602 | 145.1× slower |
|
|
51
|
+
|
|
52
|
+
Shared memory is the fastest by a wide margin: ~9.2× faster than a live SQLite
|
|
53
|
+
read and ~61–145× faster than live MySQL/Redis reads. Unix sockets are
|
|
54
|
+
significantly faster than TCP for MySQL/Redis (~1.8×), but both remain far
|
|
54
55
|
slower than shared memory.
|
|
55
56
|
|
|
56
57
|
## ✨ Features
|
|
@@ -305,22 +306,24 @@ A complete runnable example is in [`examples/worker/`](examples/worker/).
|
|
|
305
306
|
|
|
306
307
|
## Memory budget
|
|
307
308
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
memory can be several times that.
|
|
309
|
+
The Index Table (including each key's version counter) uses one growable
|
|
310
|
+
`SharedArrayBuffer`, capped at **4 GiB** by default (configurable via
|
|
311
|
+
`indexTableMaxByteLength`). The DataTable, StringTable, and ArrayTable use
|
|
312
|
+
chunks and can individually exceed 4 GiB. By default each table grows up to
|
|
313
|
+
its own `maxByteLength`, so the _total_ memory can be several times that.
|
|
313
314
|
|
|
314
315
|
Configure the buffers when calling `init()` — pass `initialSize` (each
|
|
315
|
-
|
|
316
|
-
(each
|
|
317
|
-
`maxTotalBytes
|
|
316
|
+
table's starting size, avoiding a grow on first use) and `maxByteLength`
|
|
317
|
+
(each table's cap). To cap the combined physical memory of DataTable,
|
|
318
|
+
StringTable, and ArrayTable, pass `maxTotalBytes`. The Index Table is excluded
|
|
319
|
+
from this budget. Its growth and chunk growth in workers reserve memory from
|
|
320
|
+
the same atomic budget before allocating, so the three tables cannot exceed it.
|
|
318
321
|
|
|
319
322
|
```ts
|
|
320
323
|
import { init } from "bun-shared-memory";
|
|
321
324
|
|
|
322
|
-
//
|
|
323
|
-
//
|
|
325
|
+
// The Index Table grows up to 4 GiB independently. DataTable, StringTable, and
|
|
326
|
+
// ArrayTable together are capped at 4 GiB.
|
|
324
327
|
await init({
|
|
325
328
|
initialSize: 64 * 1024,
|
|
326
329
|
maxByteLength: 4 * 1024 * 1024 * 1024,
|
|
@@ -328,14 +331,24 @@ await init({
|
|
|
328
331
|
});
|
|
329
332
|
```
|
|
330
333
|
|
|
331
|
-
`maxTotalBytes` caps the **sum** of
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
`maxByteLength`.
|
|
334
|
+
`maxTotalBytes` caps the **sum** of the physical sizes of DataTable,
|
|
335
|
+
StringTable, and ArrayTable, including across workers. If a write would exceed
|
|
336
|
+
the budget, a `RangeError` is thrown before the chunk grows. Without
|
|
337
|
+
`maxTotalBytes`, each table simply grows up to its own `maxByteLength`.
|
|
335
338
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
+
### Size limits
|
|
340
|
+
|
|
341
|
+
The following limits are enforced regardless of the configured values:
|
|
342
|
+
|
|
343
|
+
- **`chunkSize` is at least 4 MiB.** A smaller value is raised to 4 MiB.
|
|
344
|
+
- **`maxTotalBytes` / `maxByteLength` are at least 32 MiB.** A smaller value
|
|
345
|
+
is raised to 32 MiB.
|
|
346
|
+
- **No table uses more than 1024 chunks.** `chunkSize` is raised as needed so
|
|
347
|
+
that `ceil(maxByteLength / chunkSize) ≤ 1024` (e.g. an 8 GiB table with a
|
|
348
|
+
4 MiB chunk would need 2048 chunks, so the chunk size is raised to 8 MiB).
|
|
349
|
+
|
|
350
|
+
These keep the chunk count bounded (so the pre-allocated chunk list stays
|
|
351
|
+
small) and avoid pathological tiny chunks.
|
|
339
352
|
|
|
340
353
|
## Array capacity
|
|
341
354
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bun-shared-memory",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Share data between Bun workers/threads using SharedArrayBuffer (zero-copy, 4-buffer architecture, Atomics-based synchronization).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
package/src/growable-buffer.ts
CHANGED
|
@@ -1,52 +1,77 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared memory budget shared across multiple GrowableBuffers.
|
|
3
3
|
*
|
|
4
|
-
* Tracks the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* stays within the budget.
|
|
4
|
+
* Tracks the combined physical size of DataTable, StringTable, and ArrayTable.
|
|
5
|
+
* The counter is shared across workers so each growth reserves against the
|
|
6
|
+
* same limit.
|
|
8
7
|
*/
|
|
9
8
|
export class SharedBudget {
|
|
10
|
-
private
|
|
9
|
+
private readonly used: BigInt64Array;
|
|
11
10
|
|
|
12
|
-
constructor(
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly max: number,
|
|
13
|
+
buffer = new SharedArrayBuffer(BigInt64Array.BYTES_PER_ELEMENT),
|
|
14
|
+
) {
|
|
15
|
+
this.used = new BigInt64Array(buffer);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get buffer(): SharedArrayBuffer {
|
|
19
|
+
return this.used.buffer as SharedArrayBuffer;
|
|
20
|
+
}
|
|
13
21
|
|
|
14
22
|
/** Record a buffer's initial physical size. */
|
|
15
23
|
add(size: number): void {
|
|
16
|
-
this.
|
|
17
|
-
|
|
24
|
+
if (!this.tryReserve(size)) {
|
|
25
|
+
const total = Atomics.load(this.used, 0) + BigInt(size);
|
|
18
26
|
throw new RangeError(
|
|
19
|
-
`Shared memory budget exceeded: initial buffers total ${
|
|
27
|
+
`Shared memory budget exceeded: initial buffers total ${total} bytes but the budget is ${this.max}`,
|
|
20
28
|
);
|
|
21
29
|
}
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
/**
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
* Atomically reserve physical memory shared by all tables and workers.
|
|
34
|
+
*/
|
|
35
|
+
tryReserve(size: number): boolean {
|
|
36
|
+
if (size <= 0) return true;
|
|
37
|
+
const increment = BigInt(size);
|
|
38
|
+
for (;;) {
|
|
39
|
+
const current = Atomics.load(this.used, 0);
|
|
40
|
+
if (current + increment > BigInt(this.max)) return false;
|
|
41
|
+
if (
|
|
42
|
+
Atomics.compareExchange(this.used, 0, current, current + increment) ===
|
|
43
|
+
current
|
|
44
|
+
) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Release memory reserved for a growth that failed or a reclaimed chunk.
|
|
28
52
|
*/
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
53
|
+
release(size: number): void {
|
|
54
|
+
if (size <= 0) return;
|
|
55
|
+
const decrement = BigInt(size);
|
|
56
|
+
for (;;) {
|
|
57
|
+
const current = Atomics.load(this.used, 0);
|
|
58
|
+
const next = current > decrement ? current - decrement : 0n;
|
|
59
|
+
if (Atomics.compareExchange(this.used, 0, current, next) === current) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
34
63
|
}
|
|
35
64
|
}
|
|
36
65
|
|
|
37
66
|
/**
|
|
38
|
-
* Growable shared memory backed by one or more SharedArrayBuffers
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* grow beyond that is split into multiple chunks. Each chunk is its own
|
|
42
|
-
* SharedArrayBuffer (<= `chunkMax` bytes). Offsets are logical and continuous
|
|
43
|
-
* across chunks; access methods resolve an offset into (chunk, chunkOffset).
|
|
67
|
+
* Growable shared memory backed by one or more SharedArrayBuffers. Chunked
|
|
68
|
+
* tables split storage across SABs; the Index Table uses one growable SAB.
|
|
69
|
+
* Offsets are logical and continuous across chunks.
|
|
44
70
|
*
|
|
45
|
-
*
|
|
46
|
-
* but only 4 bytes of actual storage. A chunk is
|
|
47
|
-
* full
|
|
48
|
-
* visible across
|
|
49
|
-
* postMessage broadcast.
|
|
71
|
+
* In chunked mode, all chunks are pre-allocated with `maxByteLength =
|
|
72
|
+
* chunkSize` but only 4 bytes of actual storage. A chunk is activated (grown
|
|
73
|
+
* to its full size) on first access. Because `SharedArrayBuffer.grow()` is
|
|
74
|
+
* visible across workers, activation needs no postMessage broadcast.
|
|
50
75
|
*/
|
|
51
76
|
interface Chunk {
|
|
52
77
|
buffer: SharedArrayBuffer;
|
|
@@ -56,33 +81,87 @@ interface Chunk {
|
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
/** Round `n` up to the next power of two (chunk sizes must be powers of two). */
|
|
59
|
-
function nextPowerOfTwo(n: number): number {
|
|
84
|
+
export function nextPowerOfTwo(n: number): number {
|
|
60
85
|
if (n <= 0) return 1;
|
|
61
86
|
let p = 1;
|
|
62
87
|
while (p < n) p <<= 1;
|
|
63
88
|
return p;
|
|
64
89
|
}
|
|
65
90
|
|
|
91
|
+
export function largestPowerOfTwo(n: number): number {
|
|
92
|
+
if (n <= 1) return 1;
|
|
93
|
+
return 2 ** Math.floor(Math.log2(n));
|
|
94
|
+
}
|
|
95
|
+
|
|
66
96
|
export class GrowableBuffer {
|
|
67
97
|
private chunks: Chunk[] = [];
|
|
68
98
|
private readonly maxByteLength: number;
|
|
99
|
+
private readonly singleBuffer: boolean;
|
|
69
100
|
/** Fixed size of every chunk (the minimum growth step). */
|
|
70
101
|
readonly chunkSize: number;
|
|
71
102
|
/** log2(chunkSize), used for O(1) offset resolution via bit shifts. */
|
|
72
103
|
private readonly chunkShift: number;
|
|
73
104
|
private readonly budget?: SharedBudget;
|
|
74
105
|
private used = 0;
|
|
106
|
+
/** Smallest chunk index that is not yet active (see `activate`). */
|
|
107
|
+
private nextChunkToActivate = 0;
|
|
75
108
|
|
|
76
109
|
constructor(
|
|
77
110
|
initialSizeOrBuffer: number | SharedArrayBuffer | SharedArrayBuffer[],
|
|
78
111
|
maxByteLength: number,
|
|
79
112
|
budget?: SharedBudget,
|
|
80
113
|
chunkSize = 32 * 1024 * 1024, // 32 MiB per chunk
|
|
114
|
+
singleBuffer = false,
|
|
115
|
+
chargeInitial = true,
|
|
81
116
|
) {
|
|
82
|
-
this.
|
|
117
|
+
this.singleBuffer = singleBuffer;
|
|
83
118
|
this.budget = budget;
|
|
119
|
+
if (singleBuffer && typeof initialSizeOrBuffer !== "number") {
|
|
120
|
+
const buffer = Array.isArray(initialSizeOrBuffer)
|
|
121
|
+
? initialSizeOrBuffer[0]
|
|
122
|
+
: initialSizeOrBuffer;
|
|
123
|
+
if (
|
|
124
|
+
buffer === undefined ||
|
|
125
|
+
(Array.isArray(initialSizeOrBuffer) && initialSizeOrBuffer.length !== 1)
|
|
126
|
+
) {
|
|
127
|
+
throw new RangeError(
|
|
128
|
+
"A single-buffer GrowableBuffer requires exactly one SharedArrayBuffer",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
this.maxByteLength = Math.min(
|
|
132
|
+
buffer.maxByteLength ?? buffer.byteLength,
|
|
133
|
+
maxByteLength,
|
|
134
|
+
);
|
|
135
|
+
this.chunkSize = this.maxByteLength;
|
|
136
|
+
this.chunkShift = 0;
|
|
137
|
+
this.chunks.push({
|
|
138
|
+
buffer,
|
|
139
|
+
view: new DataView(buffer),
|
|
140
|
+
active: buffer.byteLength > 4,
|
|
141
|
+
});
|
|
142
|
+
if (chargeInitial) this.budget?.add(buffer.byteLength);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.maxByteLength = maxByteLength;
|
|
84
147
|
if (typeof initialSizeOrBuffer === "number") {
|
|
85
|
-
|
|
148
|
+
if (singleBuffer) {
|
|
149
|
+
const buffer = new SharedArrayBuffer(4, {
|
|
150
|
+
maxByteLength,
|
|
151
|
+
});
|
|
152
|
+
this.chunkSize = maxByteLength;
|
|
153
|
+
this.chunkShift = 0;
|
|
154
|
+
this.chunks.push({
|
|
155
|
+
buffer,
|
|
156
|
+
view: new DataView(buffer),
|
|
157
|
+
active: false,
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.chunkSize = Math.min(
|
|
162
|
+
nextPowerOfTwo(chunkSize),
|
|
163
|
+
largestPowerOfTwo(maxByteLength),
|
|
164
|
+
);
|
|
86
165
|
this.chunkShift = Math.log2(this.chunkSize);
|
|
87
166
|
// Pre-allocate all chunks (4 bytes each, growable to chunkSize).
|
|
88
167
|
const chunkCount = Math.max(1, Math.ceil(maxByteLength / this.chunkSize));
|
|
@@ -95,6 +174,7 @@ export class GrowableBuffer {
|
|
|
95
174
|
view: new DataView(buffer),
|
|
96
175
|
active: false,
|
|
97
176
|
});
|
|
177
|
+
if (chargeInitial) this.budget?.add(buffer.byteLength);
|
|
98
178
|
}
|
|
99
179
|
} else if (Array.isArray(initialSizeOrBuffer)) {
|
|
100
180
|
// Reconstruct from a list of chunks. Use the buffers directly (they are
|
|
@@ -110,7 +190,7 @@ export class GrowableBuffer {
|
|
|
110
190
|
view: new DataView(sab),
|
|
111
191
|
active: sab.byteLength === this.chunkSize,
|
|
112
192
|
});
|
|
113
|
-
this.budget?.add(sab.byteLength);
|
|
193
|
+
if (chargeInitial) this.budget?.add(sab.byteLength);
|
|
114
194
|
}
|
|
115
195
|
} else {
|
|
116
196
|
// Reconstruct from a single existing buffer. Use it directly so writes
|
|
@@ -124,7 +204,7 @@ export class GrowableBuffer {
|
|
|
124
204
|
view: new DataView(initialSizeOrBuffer),
|
|
125
205
|
active: initialSizeOrBuffer.byteLength === this.chunkSize,
|
|
126
206
|
});
|
|
127
|
-
this.budget?.add(initialSizeOrBuffer.byteLength);
|
|
207
|
+
if (chargeInitial) this.budget?.add(initialSizeOrBuffer.byteLength);
|
|
128
208
|
}
|
|
129
209
|
}
|
|
130
210
|
|
|
@@ -136,10 +216,53 @@ export class GrowableBuffer {
|
|
|
136
216
|
private activate(index: number): void {
|
|
137
217
|
const chunk = this.chunks[index];
|
|
138
218
|
if (chunk === undefined || chunk.active) return;
|
|
139
|
-
|
|
219
|
+
if (this.singleBuffer) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const oldSize = chunk.buffer.byteLength;
|
|
223
|
+
if (oldSize >= this.chunkSize) {
|
|
224
|
+
chunk.view = new DataView(chunk.buffer);
|
|
225
|
+
chunk.active = true;
|
|
226
|
+
this.advanceNextChunkToActivate(index);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const growth = this.chunkSize - oldSize;
|
|
230
|
+
if (this.budget && !this.budget.tryReserve(growth)) {
|
|
231
|
+
throw new RangeError(
|
|
232
|
+
`Shared memory budget exceeded: need ${growth} additional bytes`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
chunk.buffer.grow(this.chunkSize);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (chunk.buffer.byteLength >= this.chunkSize) {
|
|
239
|
+
this.budget?.release(growth);
|
|
240
|
+
chunk.view = new DataView(chunk.buffer);
|
|
241
|
+
chunk.active = true;
|
|
242
|
+
this.advanceNextChunkToActivate(index);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
this.budget?.release(growth);
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
140
248
|
chunk.view = new DataView(chunk.buffer);
|
|
141
249
|
chunk.active = true;
|
|
142
|
-
this.
|
|
250
|
+
this.advanceNextChunkToActivate(index);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Advance `nextChunkToActivate` past `index` while the following chunks are
|
|
255
|
+
* already active, so `ensureCapacity` never re-scans active chunks.
|
|
256
|
+
*/
|
|
257
|
+
private advanceNextChunkToActivate(index: number): void {
|
|
258
|
+
if (index < this.nextChunkToActivate) return;
|
|
259
|
+
this.nextChunkToActivate = index + 1;
|
|
260
|
+
while (
|
|
261
|
+
this.nextChunkToActivate < this.chunks.length &&
|
|
262
|
+
this.chunks[this.nextChunkToActivate]!.active
|
|
263
|
+
) {
|
|
264
|
+
this.nextChunkToActivate++;
|
|
265
|
+
}
|
|
143
266
|
}
|
|
144
267
|
|
|
145
268
|
/** Number of bytes written so far (used length, not buffer capacity). */
|
|
@@ -149,7 +272,9 @@ export class GrowableBuffer {
|
|
|
149
272
|
|
|
150
273
|
/** Total logical capacity across all chunks (active or not). */
|
|
151
274
|
get capacity(): number {
|
|
152
|
-
return this.
|
|
275
|
+
return this.singleBuffer
|
|
276
|
+
? this.maxByteLength
|
|
277
|
+
: this.chunks.length * this.chunkSize;
|
|
153
278
|
}
|
|
154
279
|
|
|
155
280
|
/** The list of SharedArrayBuffers backing this buffer (for export). */
|
|
@@ -157,18 +282,43 @@ export class GrowableBuffer {
|
|
|
157
282
|
return this.chunks.map((c) => c.buffer);
|
|
158
283
|
}
|
|
159
284
|
|
|
160
|
-
/** Number of chunks that have been
|
|
285
|
+
/** Number of chunks that have been grown from their initial 4-byte size. */
|
|
161
286
|
get activeChunkCount(): number {
|
|
162
287
|
return this.chunks.reduce((n, c) => n + (c.active ? 1 : 0), 0);
|
|
163
288
|
}
|
|
164
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Replace the chunk list with `newBuffers` (received from the main thread
|
|
292
|
+
* after a defragmentation broadcast). Each buffer is either a full-size
|
|
293
|
+
* active chunk or a fresh 4-byte inactive chunk. Used by workers to stay in
|
|
294
|
+
* sync with the main thread's chunk replacement.
|
|
295
|
+
*/
|
|
296
|
+
applyChunks(newBuffers: SharedArrayBuffer[]): void {
|
|
297
|
+
if (this.singleBuffer && newBuffers.length !== 1) {
|
|
298
|
+
throw new RangeError(
|
|
299
|
+
"A single-buffer GrowableBuffer requires exactly one SharedArrayBuffer",
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
this.chunks = newBuffers.map((sab) => ({
|
|
303
|
+
buffer: sab,
|
|
304
|
+
view: new DataView(sab),
|
|
305
|
+
active: this.singleBuffer
|
|
306
|
+
? sab.byteLength > 4
|
|
307
|
+
: sab.byteLength === this.chunkSize,
|
|
308
|
+
}));
|
|
309
|
+
this.nextChunkToActivate = this.firstInactiveChunk();
|
|
310
|
+
}
|
|
311
|
+
|
|
165
312
|
/**
|
|
166
313
|
* Resolve a logical offset into (chunk, offset within that chunk). All
|
|
167
314
|
* chunks are the same size, so this is O(1). Activates the chunk if it is
|
|
168
315
|
* not yet active.
|
|
169
316
|
*/
|
|
170
317
|
private resolve(offset: number): { chunk: Chunk; off: number } {
|
|
171
|
-
|
|
318
|
+
if (this.singleBuffer) {
|
|
319
|
+
return { chunk: this.chunks[0]!, off: offset };
|
|
320
|
+
}
|
|
321
|
+
const index = offset >>> this.chunkShift;
|
|
172
322
|
const chunk = this.chunks[index];
|
|
173
323
|
if (chunk === undefined) {
|
|
174
324
|
throw new RangeError(`Offset ${offset} is beyond the buffer capacity`);
|
|
@@ -184,10 +334,39 @@ export class GrowableBuffer {
|
|
|
184
334
|
`Shared buffer capacity exceeded: need ${needed} bytes but max is ${this.maxByteLength}`,
|
|
185
335
|
);
|
|
186
336
|
}
|
|
337
|
+
if (this.singleBuffer) {
|
|
338
|
+
const chunk = this.chunks[0]!;
|
|
339
|
+
const oldSize = chunk.buffer.byteLength;
|
|
340
|
+
if (needed <= oldSize) return;
|
|
341
|
+
const target = Math.min(
|
|
342
|
+
this.maxByteLength,
|
|
343
|
+
Math.max(needed, oldSize * 2),
|
|
344
|
+
);
|
|
345
|
+
let growth = target - oldSize;
|
|
346
|
+
if (this.budget && !this.budget.tryReserve(growth)) {
|
|
347
|
+
if (target === needed || !this.budget.tryReserve(needed - oldSize)) {
|
|
348
|
+
throw new RangeError(
|
|
349
|
+
`Shared memory budget exceeded: need ${needed - oldSize} additional bytes`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
growth = needed - oldSize;
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
chunk.buffer.grow(oldSize + growth);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
this.budget?.release(growth);
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
chunk.view = new DataView(chunk.buffer);
|
|
361
|
+
chunk.active = true;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
187
364
|
// All chunks are pre-allocated; just activate the ones needed to cover
|
|
188
365
|
// `needed`. grow() is visible across workers, so no broadcast is required.
|
|
366
|
+
// Start from `nextChunkToActivate` so already-active chunks are not
|
|
367
|
+
// re-scanned on every write (keeps the write hot path O(1) amortized).
|
|
189
368
|
const neededChunks = Math.ceil(needed / this.chunkSize);
|
|
190
|
-
for (let i =
|
|
369
|
+
for (let i = this.nextChunkToActivate; i < neededChunks; i++) {
|
|
191
370
|
this.activate(i);
|
|
192
371
|
}
|
|
193
372
|
}
|
|
@@ -200,15 +379,54 @@ export class GrowableBuffer {
|
|
|
200
379
|
/**
|
|
201
380
|
* Reset the used length to `n` bytes. Used by defragmentation after
|
|
202
381
|
* compacting live data to the front of the buffer. Chunks beyond the
|
|
203
|
-
* compacted region are marked inactive
|
|
204
|
-
*
|
|
382
|
+
* compacted region are marked inactive; their storage is reclaimed by
|
|
383
|
+
* `releaseInactiveChunks()`.
|
|
205
384
|
*/
|
|
206
385
|
reset(n: number): void {
|
|
207
386
|
this.used = n;
|
|
387
|
+
if (this.singleBuffer) return;
|
|
208
388
|
const neededChunks = Math.ceil(n / this.chunkSize);
|
|
209
|
-
for (let i =
|
|
210
|
-
this.chunks[i]
|
|
389
|
+
for (let i = 0; i < this.chunks.length; i++) {
|
|
390
|
+
const chunk = this.chunks[i]!;
|
|
391
|
+
chunk.active =
|
|
392
|
+
i < neededChunks && chunk.buffer.byteLength >= this.chunkSize;
|
|
211
393
|
}
|
|
394
|
+
this.nextChunkToActivate = this.firstInactiveChunk();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Replace every inactive chunk with a fresh 4-byte SharedArrayBuffer
|
|
399
|
+
* (growable to `chunkSize`), releasing the old full-size buffer so its
|
|
400
|
+
* memory can be reclaimed. Returns the new chunk list; the caller must
|
|
401
|
+
* broadcast it to workers so they see the replacement.
|
|
402
|
+
*
|
|
403
|
+
* Because a SharedArrayBuffer cannot be shrunk, reclaiming memory requires
|
|
404
|
+
* allocating a new buffer. This is only called after defragmentation (rare),
|
|
405
|
+
* so the resulting broadcast is acceptable.
|
|
406
|
+
*/
|
|
407
|
+
releaseInactiveChunks(): SharedArrayBuffer[] {
|
|
408
|
+
if (this.singleBuffer) return this.chunks.map((c) => c.buffer);
|
|
409
|
+
for (let i = 0; i < this.chunks.length; i++) {
|
|
410
|
+
const chunk = this.chunks[i]!;
|
|
411
|
+
if (chunk.active) continue;
|
|
412
|
+
// Release the old full-size buffer from the budget and replace it with
|
|
413
|
+
// a fresh 4-byte chunk.
|
|
414
|
+
this.budget?.release(Math.max(0, chunk.buffer.byteLength - 4));
|
|
415
|
+
const buffer = new SharedArrayBuffer(4, {
|
|
416
|
+
maxByteLength: this.chunkSize,
|
|
417
|
+
});
|
|
418
|
+
this.chunks[i] = { buffer, view: new DataView(buffer), active: false };
|
|
419
|
+
}
|
|
420
|
+
this.nextChunkToActivate = this.firstInactiveChunk();
|
|
421
|
+
return this.chunks.map((c) => c.buffer);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Index of the first chunk that is not active, or `chunks.length`. */
|
|
425
|
+
private firstInactiveChunk(): number {
|
|
426
|
+
for (let i = 0; i < this.chunks.length; i++) {
|
|
427
|
+
if (!this.chunks[i]!.active) return i;
|
|
428
|
+
}
|
|
429
|
+
return this.chunks.length;
|
|
212
430
|
}
|
|
213
431
|
|
|
214
432
|
// -------------------------------------------------------------------------
|
|
@@ -216,7 +434,8 @@ export class GrowableBuffer {
|
|
|
216
434
|
// -------------------------------------------------------------------------
|
|
217
435
|
|
|
218
436
|
getInt32(offset: number): number {
|
|
219
|
-
|
|
437
|
+
if (this.singleBuffer) return this.chunks[0]!.view.getInt32(offset, true);
|
|
438
|
+
const index = offset >>> this.chunkShift;
|
|
220
439
|
this.activate(index);
|
|
221
440
|
return this.chunks[index]!.view.getInt32(
|
|
222
441
|
offset & (this.chunkSize - 1),
|
|
@@ -225,7 +444,11 @@ export class GrowableBuffer {
|
|
|
225
444
|
}
|
|
226
445
|
|
|
227
446
|
setInt32(offset: number, value: number): void {
|
|
228
|
-
|
|
447
|
+
if (this.singleBuffer) {
|
|
448
|
+
this.chunks[0]!.view.setInt32(offset, value, true);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const index = offset >>> this.chunkShift;
|
|
229
452
|
this.activate(index);
|
|
230
453
|
this.chunks[index]!.view.setInt32(
|
|
231
454
|
offset & (this.chunkSize - 1),
|
|
@@ -235,13 +458,18 @@ export class GrowableBuffer {
|
|
|
235
458
|
}
|
|
236
459
|
|
|
237
460
|
getUint8(offset: number): number {
|
|
238
|
-
|
|
461
|
+
if (this.singleBuffer) return this.chunks[0]!.view.getUint8(offset);
|
|
462
|
+
const index = offset >>> this.chunkShift;
|
|
239
463
|
this.activate(index);
|
|
240
464
|
return this.chunks[index]!.view.getUint8(offset & (this.chunkSize - 1));
|
|
241
465
|
}
|
|
242
466
|
|
|
243
467
|
setUint8(offset: number, value: number): void {
|
|
244
|
-
|
|
468
|
+
if (this.singleBuffer) {
|
|
469
|
+
this.chunks[0]!.view.setUint8(offset, value);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const index = offset >>> this.chunkShift;
|
|
245
473
|
this.activate(index);
|
|
246
474
|
this.chunks[index]!.view.setUint8(offset & (this.chunkSize - 1), value);
|
|
247
475
|
}
|
package/src/memory.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
GrowableBuffer,
|
|
3
|
+
SharedBudget,
|
|
4
|
+
nextPowerOfTwo,
|
|
5
|
+
} from "./growable-buffer.ts";
|
|
2
6
|
import { Mutex } from "./mutex.ts";
|
|
3
7
|
|
|
4
8
|
/**
|
|
@@ -30,10 +34,11 @@ import { Mutex } from "./mutex.ts";
|
|
|
30
34
|
* [12..15] reserved
|
|
31
35
|
* then propertyCount * (nameRef(8 bytes) + valueSlot(16 bytes))
|
|
32
36
|
*
|
|
33
|
-
* Index Table entry (
|
|
37
|
+
* Index Table entry (16 bytes):
|
|
34
38
|
* [0..3] key string offset
|
|
35
39
|
* [4..7] key string length
|
|
36
40
|
* [8..11] data offset (into DataTable)
|
|
41
|
+
* [12..15] key version
|
|
37
42
|
*/
|
|
38
43
|
|
|
39
44
|
export const TYPE_NUMBER = 0;
|
|
@@ -51,11 +56,19 @@ const INDEX_ENTRY_SIZE = 16;
|
|
|
51
56
|
const INDEX_HEADER = 4;
|
|
52
57
|
/** Byte offset of the version field within an index entry. */
|
|
53
58
|
const INDEX_VERSION_OFFSET = 12;
|
|
59
|
+
const MAX_INDEX_TABLE_BYTES = 4 * 1024 * 1024 * 1024;
|
|
54
60
|
/** Size of the objectId header at the start of each array block. */
|
|
55
61
|
const ARRAY_HEADER = 4;
|
|
56
62
|
|
|
57
63
|
const DEFAULT_INITIAL = 1024;
|
|
58
64
|
const DEFAULT_MAX = 64 * 1024 * 1024; // 64 MiB
|
|
65
|
+
const DEFAULT_CHUNK_SIZE = 32 * 1024 * 1024; // 32 MiB per chunk
|
|
66
|
+
/** Minimum size of a single chunk (a chunk is the minimum growth step). */
|
|
67
|
+
const MIN_CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB
|
|
68
|
+
/** Minimum combined memory budget (maxTotalBytes / maxByteLength). */
|
|
69
|
+
const MIN_MAX_MEMORY = 32 * 1024 * 1024; // 32 MiB
|
|
70
|
+
/** Maximum number of chunks per table (chunkSize must be large enough). */
|
|
71
|
+
const MAX_CHUNKS_PER_TABLE = 1024;
|
|
59
72
|
const DEFAULT_INTERN_CAPACITY = 256;
|
|
60
73
|
const DEFAULT_INTERN_MAX = 1 << 16; // 65536 entries
|
|
61
74
|
|
|
@@ -172,10 +185,20 @@ export function decodeSlot(slot: ValueSlot): unknown {
|
|
|
172
185
|
|
|
173
186
|
export interface SharedMemoryOptions {
|
|
174
187
|
initialSize?: number;
|
|
188
|
+
/** Per-buffer maximum byte length (clamped to at least 32 MiB). */
|
|
175
189
|
maxByteLength?: number;
|
|
176
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* Total physical memory budget shared by DataTable, StringTable, and
|
|
192
|
+
* ArrayTable (clamped to at least 32 MiB).
|
|
193
|
+
*/
|
|
177
194
|
maxTotalBytes?: number;
|
|
178
|
-
/**
|
|
195
|
+
/** Internal worker reconstruction option; normally inferred from buffers. */
|
|
196
|
+
indexTableMaxByteLength?: number;
|
|
197
|
+
/**
|
|
198
|
+
* Size of each growable chunk (the minimum growth step). Default 32 MiB,
|
|
199
|
+
* clamped to at least 4 MiB and raised as needed so no table needs more
|
|
200
|
+
* than 1024 chunks.
|
|
201
|
+
*/
|
|
179
202
|
chunkSize?: number;
|
|
180
203
|
lock?: Mutex;
|
|
181
204
|
internCapacity?: number;
|
|
@@ -187,6 +210,7 @@ export interface SharedMemoryOptions {
|
|
|
187
210
|
arrayTable?: SharedArrayBuffer | SharedArrayBuffer[];
|
|
188
211
|
layoutVersion?: SharedArrayBuffer;
|
|
189
212
|
refCount?: SharedArrayBuffer;
|
|
213
|
+
budget?: SharedArrayBuffer;
|
|
190
214
|
};
|
|
191
215
|
}
|
|
192
216
|
|
|
@@ -197,11 +221,27 @@ function makeBuffer(
|
|
|
197
221
|
maxByteLength: number,
|
|
198
222
|
budget?: SharedBudget,
|
|
199
223
|
chunkSize?: number,
|
|
224
|
+
singleBuffer = false,
|
|
225
|
+
chargeInitial = true,
|
|
200
226
|
): GrowableBuffer {
|
|
201
227
|
if (buf === undefined) {
|
|
202
|
-
return new GrowableBuffer(
|
|
228
|
+
return new GrowableBuffer(
|
|
229
|
+
initial,
|
|
230
|
+
maxByteLength,
|
|
231
|
+
budget,
|
|
232
|
+
chunkSize,
|
|
233
|
+
singleBuffer,
|
|
234
|
+
chargeInitial,
|
|
235
|
+
);
|
|
203
236
|
}
|
|
204
|
-
return new GrowableBuffer(
|
|
237
|
+
return new GrowableBuffer(
|
|
238
|
+
buf,
|
|
239
|
+
maxByteLength,
|
|
240
|
+
budget,
|
|
241
|
+
chunkSize,
|
|
242
|
+
singleBuffer,
|
|
243
|
+
chargeInitial,
|
|
244
|
+
);
|
|
205
245
|
}
|
|
206
246
|
|
|
207
247
|
/**
|
|
@@ -213,6 +253,12 @@ export class SharedMemory {
|
|
|
213
253
|
readonly dataTable: GrowableBuffer;
|
|
214
254
|
readonly stringTable: GrowableBuffer;
|
|
215
255
|
readonly arrayTable: GrowableBuffer;
|
|
256
|
+
/** Per-buffer maximum byte length (clamped to at least 32 MiB). */
|
|
257
|
+
readonly maxByteLength: number;
|
|
258
|
+
private readonly indexTableMaxByteLength: number;
|
|
259
|
+
/** Combined memory budget for Data/String/Array tables, or null if unset. */
|
|
260
|
+
readonly maxTotalBytes: number | null;
|
|
261
|
+
private readonly budgetBuffer: SharedArrayBuffer | null;
|
|
216
262
|
/**
|
|
217
263
|
* Cached Int32Array view over the Index Table, used to read/increment each
|
|
218
264
|
* key's version cell (stored as the 4th field of each index entry). Rebuilt
|
|
@@ -262,25 +308,57 @@ export class SharedMemory {
|
|
|
262
308
|
|
|
263
309
|
constructor(options?: SharedMemoryOptions) {
|
|
264
310
|
const initial = options?.initialSize ?? DEFAULT_INITIAL;
|
|
265
|
-
// `maxTotalBytes`
|
|
266
|
-
//
|
|
267
|
-
// all buffers' physical sizes is additionally capped at maxTotalBytes.
|
|
311
|
+
// `maxTotalBytes` caps the combined physical size of DataTable,
|
|
312
|
+
// StringTable, and ArrayTable. IndexTable has its independent 4 GiB cap.
|
|
268
313
|
// Without maxTotalBytes, each buffer grows up to its own maxByteLength.
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
// exceed 4 GiB.
|
|
314
|
+
// The Index Table remains a single growable SharedArrayBuffer and is
|
|
315
|
+
// capped at 4 GiB. The other tables use chunks and can exceed that limit.
|
|
272
316
|
const maxTotal = options?.maxTotalBytes;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
maxTotal
|
|
276
|
-
|
|
317
|
+
if (
|
|
318
|
+
maxTotal !== undefined &&
|
|
319
|
+
(!Number.isSafeInteger(maxTotal) || maxTotal < 0)
|
|
320
|
+
) {
|
|
321
|
+
throw new RangeError("maxTotalBytes must be a non-negative safe integer");
|
|
322
|
+
}
|
|
323
|
+
// Enforce the minimum memory budget: maxTotalBytes / maxByteLength are
|
|
324
|
+
// never below 32 MiB.
|
|
325
|
+
const perBufferMax = Math.max(
|
|
326
|
+
maxTotal ?? options?.maxByteLength ?? DEFAULT_MAX,
|
|
327
|
+
MIN_MAX_MEMORY,
|
|
328
|
+
);
|
|
329
|
+
const indexTableMax = Math.min(
|
|
330
|
+
options?.indexTableMaxByteLength ?? options?.maxByteLength ?? DEFAULT_MAX,
|
|
331
|
+
MAX_INDEX_TABLE_BYTES,
|
|
332
|
+
);
|
|
333
|
+
// Enforce the chunk-size limits: at least 4 MiB, and large enough that no
|
|
334
|
+
// table needs more than MAX_CHUNKS_PER_TABLE chunks.
|
|
335
|
+
const userChunkSize = options?.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
336
|
+
const chunkSize = Math.max(
|
|
337
|
+
userChunkSize,
|
|
338
|
+
MIN_CHUNK_SIZE,
|
|
339
|
+
nextPowerOfTwo(perBufferMax / MAX_CHUNKS_PER_TABLE),
|
|
340
|
+
);
|
|
277
341
|
const buffers = options?.buffers;
|
|
342
|
+
// The budget is the clamped maxTotalBytes (never below MIN_MAX_MEMORY), so
|
|
343
|
+
// it stays consistent with perBufferMax.
|
|
344
|
+
const budgetMax = maxTotal !== undefined ? perBufferMax : undefined;
|
|
345
|
+
const budgetBuffer = buffers?.budget;
|
|
346
|
+
const budget =
|
|
347
|
+
budgetMax !== undefined
|
|
348
|
+
? new SharedBudget(budgetMax, budgetBuffer)
|
|
349
|
+
: undefined;
|
|
350
|
+
const chargeInitial = budgetBuffer === undefined;
|
|
351
|
+
this.maxByteLength = perBufferMax;
|
|
352
|
+
this.indexTableMaxByteLength = indexTableMax;
|
|
353
|
+
this.maxTotalBytes = budgetMax ?? null;
|
|
354
|
+
this.budgetBuffer = budget?.buffer ?? null;
|
|
278
355
|
this.indexTable = makeBuffer(
|
|
279
356
|
buffers?.indexTable,
|
|
280
357
|
initial,
|
|
281
|
-
|
|
282
|
-
|
|
358
|
+
indexTableMax,
|
|
359
|
+
undefined,
|
|
283
360
|
chunkSize,
|
|
361
|
+
true,
|
|
284
362
|
);
|
|
285
363
|
this.dataTable = makeBuffer(
|
|
286
364
|
buffers?.dataTable,
|
|
@@ -288,6 +366,8 @@ export class SharedMemory {
|
|
|
288
366
|
perBufferMax,
|
|
289
367
|
budget,
|
|
290
368
|
chunkSize,
|
|
369
|
+
false,
|
|
370
|
+
chargeInitial,
|
|
291
371
|
);
|
|
292
372
|
this.stringTable = makeBuffer(
|
|
293
373
|
buffers?.stringTable,
|
|
@@ -295,6 +375,8 @@ export class SharedMemory {
|
|
|
295
375
|
perBufferMax,
|
|
296
376
|
budget,
|
|
297
377
|
chunkSize,
|
|
378
|
+
false,
|
|
379
|
+
chargeInitial,
|
|
298
380
|
);
|
|
299
381
|
this.arrayTable = makeBuffer(
|
|
300
382
|
buffers?.arrayTable,
|
|
@@ -302,6 +384,8 @@ export class SharedMemory {
|
|
|
302
384
|
perBufferMax,
|
|
303
385
|
budget,
|
|
304
386
|
chunkSize,
|
|
387
|
+
false,
|
|
388
|
+
chargeInitial,
|
|
305
389
|
);
|
|
306
390
|
this.versionView = new Int32Array(this.indexTable.buffers[0]!);
|
|
307
391
|
this.layoutVersion = new Int32Array(
|
|
@@ -319,6 +403,24 @@ export class SharedMemory {
|
|
|
319
403
|
return this.lock.withLock(fn);
|
|
320
404
|
}
|
|
321
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Apply a buffer update received from the main thread after a
|
|
408
|
+
* defragmentation pass. Replaces each table's buffers and rebuilds the
|
|
409
|
+
* version view. Called by workers on the `__chunkUpdate` message.
|
|
410
|
+
*/
|
|
411
|
+
applyChunkUpdate(buffers: {
|
|
412
|
+
indexTable: SharedArrayBuffer[];
|
|
413
|
+
dataTable: SharedArrayBuffer[];
|
|
414
|
+
stringTable: SharedArrayBuffer[];
|
|
415
|
+
arrayTable: SharedArrayBuffer[];
|
|
416
|
+
}): void {
|
|
417
|
+
this.indexTable.applyChunks(buffers.indexTable);
|
|
418
|
+
this.dataTable.applyChunks(buffers.dataTable);
|
|
419
|
+
this.stringTable.applyChunks(buffers.stringTable);
|
|
420
|
+
this.arrayTable.applyChunks(buffers.arrayTable);
|
|
421
|
+
this.versionView = new Int32Array(this.indexTable.buffers[0]!);
|
|
422
|
+
}
|
|
423
|
+
|
|
322
424
|
/**
|
|
323
425
|
* Attach the reference-counting buffer (5th SAB) to this memory so it is
|
|
324
426
|
* included in `exportBuffers()`. No-op if a buffer is already attached.
|
|
@@ -334,8 +436,10 @@ export class SharedMemory {
|
|
|
334
436
|
* Export all shared buffers (including the lock) so they can be sent to a
|
|
335
437
|
* worker via `postMessage` and reconstructed there with `fromBuffers()`.
|
|
336
438
|
*
|
|
337
|
-
*
|
|
338
|
-
* single SharedArrayBuffer
|
|
439
|
+
* Tables are exported as lists for the chunked buffers. The Index Table is
|
|
440
|
+
* a single growable SharedArrayBuffer, exported as a one-element list. The
|
|
441
|
+
* optional shared budget counter is included so workers reserve atomically
|
|
442
|
+
* against the same three-table limit.
|
|
339
443
|
*/
|
|
340
444
|
exportBuffers(): {
|
|
341
445
|
indexTable: SharedArrayBuffer[];
|
|
@@ -345,6 +449,10 @@ export class SharedMemory {
|
|
|
345
449
|
layoutVersion: SharedArrayBuffer;
|
|
346
450
|
lock: SharedArrayBuffer;
|
|
347
451
|
refCount: SharedArrayBuffer | null;
|
|
452
|
+
maxByteLength: number;
|
|
453
|
+
indexTableMaxByteLength: number;
|
|
454
|
+
maxTotalBytes: number | null;
|
|
455
|
+
budget: SharedArrayBuffer | null;
|
|
348
456
|
} {
|
|
349
457
|
return {
|
|
350
458
|
indexTable: this.indexTable.buffers,
|
|
@@ -354,6 +462,10 @@ export class SharedMemory {
|
|
|
354
462
|
layoutVersion: this.layoutVersion.buffer as SharedArrayBuffer,
|
|
355
463
|
lock: this.lock.buffer as SharedArrayBuffer,
|
|
356
464
|
refCount: this.refCountBuffer,
|
|
465
|
+
maxByteLength: this.maxByteLength,
|
|
466
|
+
indexTableMaxByteLength: this.indexTableMaxByteLength,
|
|
467
|
+
maxTotalBytes: this.maxTotalBytes,
|
|
468
|
+
budget: this.budgetBuffer,
|
|
357
469
|
};
|
|
358
470
|
}
|
|
359
471
|
|
|
@@ -369,8 +481,22 @@ export class SharedMemory {
|
|
|
369
481
|
layoutVersion: SharedArrayBuffer;
|
|
370
482
|
lock: SharedArrayBuffer;
|
|
371
483
|
refCount?: SharedArrayBuffer | null;
|
|
484
|
+
maxByteLength?: number;
|
|
485
|
+
indexTableMaxByteLength?: number;
|
|
486
|
+
maxTotalBytes?: number | null;
|
|
487
|
+
budget?: SharedArrayBuffer | null;
|
|
372
488
|
}): SharedMemory {
|
|
489
|
+
const indexBuffer = Array.isArray(buffers.indexTable)
|
|
490
|
+
? buffers.indexTable[0]
|
|
491
|
+
: buffers.indexTable;
|
|
373
492
|
return new SharedMemory({
|
|
493
|
+
maxByteLength: buffers.maxByteLength ?? DEFAULT_MAX,
|
|
494
|
+
indexTableMaxByteLength:
|
|
495
|
+
buffers.indexTableMaxByteLength ??
|
|
496
|
+
indexBuffer?.maxByteLength ??
|
|
497
|
+
indexBuffer?.byteLength ??
|
|
498
|
+
DEFAULT_MAX,
|
|
499
|
+
maxTotalBytes: buffers.maxTotalBytes ?? undefined,
|
|
374
500
|
buffers: {
|
|
375
501
|
indexTable: buffers.indexTable,
|
|
376
502
|
dataTable: buffers.dataTable,
|
|
@@ -378,6 +504,7 @@ export class SharedMemory {
|
|
|
378
504
|
arrayTable: buffers.arrayTable,
|
|
379
505
|
layoutVersion: buffers.layoutVersion,
|
|
380
506
|
refCount: buffers.refCount ?? undefined,
|
|
507
|
+
budget: buffers.budget ?? undefined,
|
|
381
508
|
},
|
|
382
509
|
lock: new Mutex(buffers.lock),
|
|
383
510
|
});
|
|
@@ -798,7 +925,12 @@ export class SharedMemory {
|
|
|
798
925
|
*
|
|
799
926
|
* Must be called under the shared lock (or on a single thread).
|
|
800
927
|
*/
|
|
801
|
-
defragment():
|
|
928
|
+
defragment(): {
|
|
929
|
+
indexTable: SharedArrayBuffer[];
|
|
930
|
+
dataTable: SharedArrayBuffer[];
|
|
931
|
+
stringTable: SharedArrayBuffer[];
|
|
932
|
+
arrayTable: SharedArrayBuffer[];
|
|
933
|
+
} {
|
|
802
934
|
const stringRemap = new Map<number, number>();
|
|
803
935
|
const arrayRemap = new Map<number, number>();
|
|
804
936
|
const objectRemap = new Map<number, number>();
|
|
@@ -964,6 +1096,15 @@ export class SharedMemory {
|
|
|
964
1096
|
|
|
965
1097
|
// Phase 7: Invalidate all per-worker caches.
|
|
966
1098
|
this.bumpLayoutVersion();
|
|
1099
|
+
|
|
1100
|
+
// Phase 8: Reclaim memory from inactive chunks (beyond the compacted
|
|
1101
|
+
// region). The Index Table is a single growable SAB and is never replaced.
|
|
1102
|
+
return {
|
|
1103
|
+
indexTable: this.indexTable.releaseInactiveChunks(),
|
|
1104
|
+
dataTable: this.dataTable.releaseInactiveChunks(),
|
|
1105
|
+
stringTable: this.stringTable.releaseInactiveChunks(),
|
|
1106
|
+
arrayTable: this.arrayTable.releaseInactiveChunks(),
|
|
1107
|
+
};
|
|
967
1108
|
}
|
|
968
1109
|
}
|
|
969
1110
|
|
package/src/refcount.ts
CHANGED
|
@@ -161,6 +161,13 @@ export class RefCountCoordinator {
|
|
|
161
161
|
/** Free-space threshold (fraction of maxByteLength) that triggers defrag. */
|
|
162
162
|
private readonly defragThreshold: number;
|
|
163
163
|
private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
|
|
164
|
+
/** Called after a defragmentation pass with the new chunk lists. */
|
|
165
|
+
private readonly onDefragment?: (buffers: {
|
|
166
|
+
indexTable: SharedArrayBuffer[];
|
|
167
|
+
dataTable: SharedArrayBuffer[];
|
|
168
|
+
stringTable: SharedArrayBuffer[];
|
|
169
|
+
arrayTable: SharedArrayBuffer[];
|
|
170
|
+
}) => void;
|
|
164
171
|
|
|
165
172
|
constructor(
|
|
166
173
|
mem: SharedMemory,
|
|
@@ -169,9 +176,16 @@ export class RefCountCoordinator {
|
|
|
169
176
|
defragThreshold?: number;
|
|
170
177
|
autoMaintenance?: boolean;
|
|
171
178
|
maintenanceInterval?: number;
|
|
179
|
+
onDefragment?: (buffers: {
|
|
180
|
+
indexTable: SharedArrayBuffer[];
|
|
181
|
+
dataTable: SharedArrayBuffer[];
|
|
182
|
+
stringTable: SharedArrayBuffer[];
|
|
183
|
+
arrayTable: SharedArrayBuffer[];
|
|
184
|
+
}) => void;
|
|
172
185
|
} = {},
|
|
173
186
|
) {
|
|
174
187
|
this.mem = mem;
|
|
188
|
+
this.onDefragment = opts.onDefragment;
|
|
175
189
|
// Reuse the memory's shared refCount buffer if present, so the same SAB is
|
|
176
190
|
// exported to workers; otherwise create a fresh one and attach it to the
|
|
177
191
|
// memory so it can be shared.
|
|
@@ -313,7 +327,8 @@ export class RefCountCoordinator {
|
|
|
313
327
|
this.maintenanceTimer = setInterval(() => {
|
|
314
328
|
this.drainEvents();
|
|
315
329
|
if (this.needsDefrag()) {
|
|
316
|
-
this.mem.withLock(() => this.mem.defragment());
|
|
330
|
+
const buffers = this.mem.withLock(() => this.mem.defragment());
|
|
331
|
+
this.onDefragment?.(buffers);
|
|
317
332
|
}
|
|
318
333
|
}, intervalMs);
|
|
319
334
|
// Unref the timer so it does not keep the process alive. The coordinator
|
package/src/worker.ts
CHANGED
|
@@ -130,7 +130,9 @@ export function getDefaultTracker(): RefCountTracker | null {
|
|
|
130
130
|
function startCoordinator(): void {
|
|
131
131
|
if (coordinator !== null) return;
|
|
132
132
|
const mem = getDefaultMemory();
|
|
133
|
-
coordinator = new RefCountCoordinator(mem
|
|
133
|
+
coordinator = new RefCountCoordinator(mem, undefined, {
|
|
134
|
+
onDefragment: (buffers) => broadcastChunkUpdate(buffers),
|
|
135
|
+
});
|
|
134
136
|
unregisterWorkers = coordinator.autoRegister();
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -156,6 +158,12 @@ let mainListener:
|
|
|
156
158
|
}) => void)
|
|
157
159
|
| null = null;
|
|
158
160
|
|
|
161
|
+
/** Live workers on the main thread, for broadcasting chunk updates. */
|
|
162
|
+
const liveWorkers = new Set<{
|
|
163
|
+
postMessage: (msg: unknown) => void;
|
|
164
|
+
addEventListener: (type: string, fn: (e: { data: unknown }) => void) => void;
|
|
165
|
+
}>();
|
|
166
|
+
|
|
159
167
|
/** Main thread: answer each worker's buffer request. */
|
|
160
168
|
function initMain(): void {
|
|
161
169
|
const listener = (worker: {
|
|
@@ -165,6 +173,7 @@ function initMain(): void {
|
|
|
165
173
|
fn: (e: { data: unknown }) => void,
|
|
166
174
|
) => void;
|
|
167
175
|
}) => {
|
|
176
|
+
liveWorkers.add(worker);
|
|
168
177
|
worker.addEventListener("message", (e) => {
|
|
169
178
|
const msg = e.data as { __sharedMemoryRequest?: boolean } | undefined;
|
|
170
179
|
if (
|
|
@@ -191,6 +200,22 @@ function stopMain(): void {
|
|
|
191
200
|
process.off("worker", mainListener);
|
|
192
201
|
mainListener = null;
|
|
193
202
|
}
|
|
203
|
+
liveWorkers.clear();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Broadcast a chunk update (new chunk lists after a defragmentation pass) to
|
|
208
|
+
* all live workers. Workers apply it via `applyChunkUpdate()`.
|
|
209
|
+
*/
|
|
210
|
+
function broadcastChunkUpdate(buffers: {
|
|
211
|
+
indexTable: SharedArrayBuffer[];
|
|
212
|
+
dataTable: SharedArrayBuffer[];
|
|
213
|
+
stringTable: SharedArrayBuffer[];
|
|
214
|
+
arrayTable: SharedArrayBuffer[];
|
|
215
|
+
}): void {
|
|
216
|
+
for (const worker of liveWorkers) {
|
|
217
|
+
worker.postMessage({ __chunkUpdate: true, buffers });
|
|
218
|
+
}
|
|
194
219
|
}
|
|
195
220
|
|
|
196
221
|
/** Worker: request the buffers from the main thread and await the reply. */
|
|
@@ -239,6 +264,15 @@ function installMessageWrapper(): void {
|
|
|
239
264
|
__sharedMemoryInit?: boolean;
|
|
240
265
|
buffers?: Parameters<typeof SharedMemory.fromBuffers>[0];
|
|
241
266
|
}
|
|
267
|
+
| {
|
|
268
|
+
__chunkUpdate?: boolean;
|
|
269
|
+
buffers?: {
|
|
270
|
+
indexTable: SharedArrayBuffer[];
|
|
271
|
+
dataTable: SharedArrayBuffer[];
|
|
272
|
+
stringTable: SharedArrayBuffer[];
|
|
273
|
+
arrayTable: SharedArrayBuffer[];
|
|
274
|
+
};
|
|
275
|
+
}
|
|
242
276
|
| undefined;
|
|
243
277
|
if (
|
|
244
278
|
msg !== null &&
|
|
@@ -264,6 +298,18 @@ function installMessageWrapper(): void {
|
|
|
264
298
|
flush();
|
|
265
299
|
return;
|
|
266
300
|
}
|
|
301
|
+
if (
|
|
302
|
+
msg !== null &&
|
|
303
|
+
typeof msg === "object" &&
|
|
304
|
+
"__chunkUpdate" in msg &&
|
|
305
|
+
msg.buffers &&
|
|
306
|
+
hasDefaultMemory()
|
|
307
|
+
) {
|
|
308
|
+
// The main thread replaced inactive chunks after a defragmentation
|
|
309
|
+
// pass; apply the new chunk lists so this worker sees the replacement.
|
|
310
|
+
getDefaultMemory().applyChunkUpdate(msg.buffers);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
267
313
|
if (hasDefaultMemory() && hasHandler) {
|
|
268
314
|
messageHandler(e);
|
|
269
315
|
} else {
|