bun-shared-memory 0.1.6 → 0.1.8
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 +294 -51
- package/src/memory.ts +195 -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.8",
|
|
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
|
-
* updates the total if the new total stays within the budget, false
|
|
27
|
-
* otherwise (the caller must not grow).
|
|
33
|
+
* Atomically reserve physical memory shared by all tables and workers.
|
|
28
34
|
*/
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
52
|
+
*/
|
|
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
|
|
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.
|
|
39
70
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* across
|
|
44
|
-
*
|
|
45
|
-
* All chunks are pre-allocated at construction with `maxByteLength = chunkSize`
|
|
46
|
-
* but only 4 bytes of actual storage. A chunk is "activated" (grown to its
|
|
47
|
-
* full `chunkSize`) on first access. Because `SharedArrayBuffer.grow()` is
|
|
48
|
-
* visible across all workers sharing the buffer, activation needs no
|
|
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,97 @@ 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
|
+
/**
|
|
106
|
+
* Shared append cursor (used length). Stored in a SharedArrayBuffer so all
|
|
107
|
+
* workers that reconstruct this buffer from `fromBuffers()` see the same
|
|
108
|
+
* used length and append at the correct offset instead of overwriting
|
|
109
|
+
* existing data at offset 0.
|
|
110
|
+
*/
|
|
111
|
+
private readonly cursor: BigInt64Array;
|
|
112
|
+
/** Smallest chunk index that is not yet active (see `activate`). */
|
|
113
|
+
private nextChunkToActivate = 0;
|
|
75
114
|
|
|
76
115
|
constructor(
|
|
77
116
|
initialSizeOrBuffer: number | SharedArrayBuffer | SharedArrayBuffer[],
|
|
78
117
|
maxByteLength: number,
|
|
79
118
|
budget?: SharedBudget,
|
|
80
119
|
chunkSize = 32 * 1024 * 1024, // 32 MiB per chunk
|
|
120
|
+
singleBuffer = false,
|
|
121
|
+
chargeInitial = true,
|
|
122
|
+
cursorBuffer?: SharedArrayBuffer,
|
|
81
123
|
) {
|
|
82
|
-
this.
|
|
124
|
+
this.singleBuffer = singleBuffer;
|
|
83
125
|
this.budget = budget;
|
|
126
|
+
this.cursor = new BigInt64Array(
|
|
127
|
+
cursorBuffer ?? new SharedArrayBuffer(BigInt64Array.BYTES_PER_ELEMENT),
|
|
128
|
+
);
|
|
129
|
+
if (singleBuffer && typeof initialSizeOrBuffer !== "number") {
|
|
130
|
+
const buffer = Array.isArray(initialSizeOrBuffer)
|
|
131
|
+
? initialSizeOrBuffer[0]
|
|
132
|
+
: initialSizeOrBuffer;
|
|
133
|
+
if (
|
|
134
|
+
buffer === undefined ||
|
|
135
|
+
(Array.isArray(initialSizeOrBuffer) && initialSizeOrBuffer.length !== 1)
|
|
136
|
+
) {
|
|
137
|
+
throw new RangeError(
|
|
138
|
+
"A single-buffer GrowableBuffer requires exactly one SharedArrayBuffer",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
this.maxByteLength = Math.min(
|
|
142
|
+
buffer.maxByteLength ?? buffer.byteLength,
|
|
143
|
+
maxByteLength,
|
|
144
|
+
);
|
|
145
|
+
this.chunkSize = this.maxByteLength;
|
|
146
|
+
this.chunkShift = 0;
|
|
147
|
+
this.chunks.push({
|
|
148
|
+
buffer,
|
|
149
|
+
view: new DataView(buffer),
|
|
150
|
+
active: buffer.byteLength > 4,
|
|
151
|
+
});
|
|
152
|
+
if (chargeInitial) this.budget?.add(buffer.byteLength);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.maxByteLength = maxByteLength;
|
|
84
157
|
if (typeof initialSizeOrBuffer === "number") {
|
|
85
|
-
|
|
158
|
+
if (singleBuffer) {
|
|
159
|
+
const buffer = new SharedArrayBuffer(4, {
|
|
160
|
+
maxByteLength,
|
|
161
|
+
});
|
|
162
|
+
this.chunkSize = maxByteLength;
|
|
163
|
+
this.chunkShift = 0;
|
|
164
|
+
this.chunks.push({
|
|
165
|
+
buffer,
|
|
166
|
+
view: new DataView(buffer),
|
|
167
|
+
active: false,
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.chunkSize = Math.min(
|
|
172
|
+
nextPowerOfTwo(chunkSize),
|
|
173
|
+
largestPowerOfTwo(maxByteLength),
|
|
174
|
+
);
|
|
86
175
|
this.chunkShift = Math.log2(this.chunkSize);
|
|
87
176
|
// Pre-allocate all chunks (4 bytes each, growable to chunkSize).
|
|
88
177
|
const chunkCount = Math.max(1, Math.ceil(maxByteLength / this.chunkSize));
|
|
@@ -95,6 +184,7 @@ export class GrowableBuffer {
|
|
|
95
184
|
view: new DataView(buffer),
|
|
96
185
|
active: false,
|
|
97
186
|
});
|
|
187
|
+
if (chargeInitial) this.budget?.add(buffer.byteLength);
|
|
98
188
|
}
|
|
99
189
|
} else if (Array.isArray(initialSizeOrBuffer)) {
|
|
100
190
|
// Reconstruct from a list of chunks. Use the buffers directly (they are
|
|
@@ -110,7 +200,7 @@ export class GrowableBuffer {
|
|
|
110
200
|
view: new DataView(sab),
|
|
111
201
|
active: sab.byteLength === this.chunkSize,
|
|
112
202
|
});
|
|
113
|
-
this.budget?.add(sab.byteLength);
|
|
203
|
+
if (chargeInitial) this.budget?.add(sab.byteLength);
|
|
114
204
|
}
|
|
115
205
|
} else {
|
|
116
206
|
// Reconstruct from a single existing buffer. Use it directly so writes
|
|
@@ -124,7 +214,7 @@ export class GrowableBuffer {
|
|
|
124
214
|
view: new DataView(initialSizeOrBuffer),
|
|
125
215
|
active: initialSizeOrBuffer.byteLength === this.chunkSize,
|
|
126
216
|
});
|
|
127
|
-
this.budget?.add(initialSizeOrBuffer.byteLength);
|
|
217
|
+
if (chargeInitial) this.budget?.add(initialSizeOrBuffer.byteLength);
|
|
128
218
|
}
|
|
129
219
|
}
|
|
130
220
|
|
|
@@ -136,20 +226,70 @@ export class GrowableBuffer {
|
|
|
136
226
|
private activate(index: number): void {
|
|
137
227
|
const chunk = this.chunks[index];
|
|
138
228
|
if (chunk === undefined || chunk.active) return;
|
|
139
|
-
|
|
229
|
+
if (this.singleBuffer) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const oldSize = chunk.buffer.byteLength;
|
|
233
|
+
if (oldSize >= this.chunkSize) {
|
|
234
|
+
chunk.view = new DataView(chunk.buffer);
|
|
235
|
+
chunk.active = true;
|
|
236
|
+
this.advanceNextChunkToActivate(index);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const growth = this.chunkSize - oldSize;
|
|
240
|
+
if (this.budget && !this.budget.tryReserve(growth)) {
|
|
241
|
+
throw new RangeError(
|
|
242
|
+
`Shared memory budget exceeded: need ${growth} additional bytes`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
chunk.buffer.grow(this.chunkSize);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (chunk.buffer.byteLength >= this.chunkSize) {
|
|
249
|
+
this.budget?.release(growth);
|
|
250
|
+
chunk.view = new DataView(chunk.buffer);
|
|
251
|
+
chunk.active = true;
|
|
252
|
+
this.advanceNextChunkToActivate(index);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this.budget?.release(growth);
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
140
258
|
chunk.view = new DataView(chunk.buffer);
|
|
141
259
|
chunk.active = true;
|
|
142
|
-
this.
|
|
260
|
+
this.advanceNextChunkToActivate(index);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Advance `nextChunkToActivate` past `index` while the following chunks are
|
|
265
|
+
* already active, so `ensureCapacity` never re-scans active chunks.
|
|
266
|
+
*/
|
|
267
|
+
private advanceNextChunkToActivate(index: number): void {
|
|
268
|
+
if (index < this.nextChunkToActivate) return;
|
|
269
|
+
this.nextChunkToActivate = index + 1;
|
|
270
|
+
while (
|
|
271
|
+
this.nextChunkToActivate < this.chunks.length &&
|
|
272
|
+
this.chunks[this.nextChunkToActivate]!.active
|
|
273
|
+
) {
|
|
274
|
+
this.nextChunkToActivate++;
|
|
275
|
+
}
|
|
143
276
|
}
|
|
144
277
|
|
|
145
278
|
/** Number of bytes written so far (used length, not buffer capacity). */
|
|
146
279
|
get byteLength(): number {
|
|
147
|
-
return this.
|
|
280
|
+
return Number(Atomics.load(this.cursor, 0));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The shared cursor buffer (for export to workers). */
|
|
284
|
+
get cursorBuffer(): SharedArrayBuffer {
|
|
285
|
+
return this.cursor.buffer as SharedArrayBuffer;
|
|
148
286
|
}
|
|
149
287
|
|
|
150
288
|
/** Total logical capacity across all chunks (active or not). */
|
|
151
289
|
get capacity(): number {
|
|
152
|
-
return this.
|
|
290
|
+
return this.singleBuffer
|
|
291
|
+
? this.maxByteLength
|
|
292
|
+
: this.chunks.length * this.chunkSize;
|
|
153
293
|
}
|
|
154
294
|
|
|
155
295
|
/** The list of SharedArrayBuffers backing this buffer (for export). */
|
|
@@ -157,18 +297,43 @@ export class GrowableBuffer {
|
|
|
157
297
|
return this.chunks.map((c) => c.buffer);
|
|
158
298
|
}
|
|
159
299
|
|
|
160
|
-
/** Number of chunks that have been
|
|
300
|
+
/** Number of chunks that have been grown from their initial 4-byte size. */
|
|
161
301
|
get activeChunkCount(): number {
|
|
162
302
|
return this.chunks.reduce((n, c) => n + (c.active ? 1 : 0), 0);
|
|
163
303
|
}
|
|
164
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Replace the chunk list with `newBuffers` (received from the main thread
|
|
307
|
+
* after a defragmentation broadcast). Each buffer is either a full-size
|
|
308
|
+
* active chunk or a fresh 4-byte inactive chunk. Used by workers to stay in
|
|
309
|
+
* sync with the main thread's chunk replacement.
|
|
310
|
+
*/
|
|
311
|
+
applyChunks(newBuffers: SharedArrayBuffer[]): void {
|
|
312
|
+
if (this.singleBuffer && newBuffers.length !== 1) {
|
|
313
|
+
throw new RangeError(
|
|
314
|
+
"A single-buffer GrowableBuffer requires exactly one SharedArrayBuffer",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
this.chunks = newBuffers.map((sab) => ({
|
|
318
|
+
buffer: sab,
|
|
319
|
+
view: new DataView(sab),
|
|
320
|
+
active: this.singleBuffer
|
|
321
|
+
? sab.byteLength > 4
|
|
322
|
+
: sab.byteLength === this.chunkSize,
|
|
323
|
+
}));
|
|
324
|
+
this.nextChunkToActivate = this.firstInactiveChunk();
|
|
325
|
+
}
|
|
326
|
+
|
|
165
327
|
/**
|
|
166
328
|
* Resolve a logical offset into (chunk, offset within that chunk). All
|
|
167
329
|
* chunks are the same size, so this is O(1). Activates the chunk if it is
|
|
168
330
|
* not yet active.
|
|
169
331
|
*/
|
|
170
332
|
private resolve(offset: number): { chunk: Chunk; off: number } {
|
|
171
|
-
|
|
333
|
+
if (this.singleBuffer) {
|
|
334
|
+
return { chunk: this.chunks[0]!, off: offset };
|
|
335
|
+
}
|
|
336
|
+
const index = offset >>> this.chunkShift;
|
|
172
337
|
const chunk = this.chunks[index];
|
|
173
338
|
if (chunk === undefined) {
|
|
174
339
|
throw new RangeError(`Offset ${offset} is beyond the buffer capacity`);
|
|
@@ -184,31 +349,99 @@ export class GrowableBuffer {
|
|
|
184
349
|
`Shared buffer capacity exceeded: need ${needed} bytes but max is ${this.maxByteLength}`,
|
|
185
350
|
);
|
|
186
351
|
}
|
|
352
|
+
if (this.singleBuffer) {
|
|
353
|
+
const chunk = this.chunks[0]!;
|
|
354
|
+
const oldSize = chunk.buffer.byteLength;
|
|
355
|
+
if (needed <= oldSize) return;
|
|
356
|
+
const target = Math.min(
|
|
357
|
+
this.maxByteLength,
|
|
358
|
+
Math.max(needed, oldSize * 2),
|
|
359
|
+
);
|
|
360
|
+
let growth = target - oldSize;
|
|
361
|
+
if (this.budget && !this.budget.tryReserve(growth)) {
|
|
362
|
+
if (target === needed || !this.budget.tryReserve(needed - oldSize)) {
|
|
363
|
+
throw new RangeError(
|
|
364
|
+
`Shared memory budget exceeded: need ${needed - oldSize} additional bytes`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
growth = needed - oldSize;
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
chunk.buffer.grow(oldSize + growth);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
this.budget?.release(growth);
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
chunk.view = new DataView(chunk.buffer);
|
|
376
|
+
chunk.active = true;
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
187
379
|
// All chunks are pre-allocated; just activate the ones needed to cover
|
|
188
380
|
// `needed`. grow() is visible across workers, so no broadcast is required.
|
|
381
|
+
// Start from `nextChunkToActivate` so already-active chunks are not
|
|
382
|
+
// re-scanned on every write (keeps the write hot path O(1) amortized).
|
|
189
383
|
const neededChunks = Math.ceil(needed / this.chunkSize);
|
|
190
|
-
for (let i =
|
|
384
|
+
for (let i = this.nextChunkToActivate; i < neededChunks; i++) {
|
|
191
385
|
this.activate(i);
|
|
192
386
|
}
|
|
193
387
|
}
|
|
194
388
|
|
|
195
389
|
/** Mark `n` bytes as used, after writing them at the current offset. */
|
|
196
390
|
advance(n: number): void {
|
|
197
|
-
this.
|
|
391
|
+
Atomics.add(this.cursor, 0, BigInt(n));
|
|
198
392
|
}
|
|
199
393
|
|
|
200
394
|
/**
|
|
201
395
|
* Reset the used length to `n` bytes. Used by defragmentation after
|
|
202
396
|
* compacting live data to the front of the buffer. Chunks beyond the
|
|
203
|
-
* compacted region are marked inactive
|
|
204
|
-
*
|
|
397
|
+
* compacted region are marked inactive; their storage is reclaimed by
|
|
398
|
+
* `releaseInactiveChunks()`.
|
|
205
399
|
*/
|
|
206
400
|
reset(n: number): void {
|
|
207
|
-
this.
|
|
401
|
+
Atomics.store(this.cursor, 0, BigInt(n));
|
|
402
|
+
if (this.singleBuffer) return;
|
|
208
403
|
const neededChunks = Math.ceil(n / this.chunkSize);
|
|
209
|
-
for (let i =
|
|
210
|
-
this.chunks[i]
|
|
404
|
+
for (let i = 0; i < this.chunks.length; i++) {
|
|
405
|
+
const chunk = this.chunks[i]!;
|
|
406
|
+
chunk.active =
|
|
407
|
+
i < neededChunks && chunk.buffer.byteLength >= this.chunkSize;
|
|
408
|
+
}
|
|
409
|
+
this.nextChunkToActivate = this.firstInactiveChunk();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Replace every inactive chunk with a fresh 4-byte SharedArrayBuffer
|
|
414
|
+
* (growable to `chunkSize`), releasing the old full-size buffer so its
|
|
415
|
+
* memory can be reclaimed. Returns the new chunk list; the caller must
|
|
416
|
+
* broadcast it to workers so they see the replacement.
|
|
417
|
+
*
|
|
418
|
+
* Because a SharedArrayBuffer cannot be shrunk, reclaiming memory requires
|
|
419
|
+
* allocating a new buffer. This is only called after defragmentation (rare),
|
|
420
|
+
* so the resulting broadcast is acceptable.
|
|
421
|
+
*/
|
|
422
|
+
releaseInactiveChunks(): SharedArrayBuffer[] {
|
|
423
|
+
if (this.singleBuffer) return this.chunks.map((c) => c.buffer);
|
|
424
|
+
for (let i = 0; i < this.chunks.length; i++) {
|
|
425
|
+
const chunk = this.chunks[i]!;
|
|
426
|
+
if (chunk.active) continue;
|
|
427
|
+
// Release the old full-size buffer from the budget and replace it with
|
|
428
|
+
// a fresh 4-byte chunk.
|
|
429
|
+
this.budget?.release(Math.max(0, chunk.buffer.byteLength - 4));
|
|
430
|
+
const buffer = new SharedArrayBuffer(4, {
|
|
431
|
+
maxByteLength: this.chunkSize,
|
|
432
|
+
});
|
|
433
|
+
this.chunks[i] = { buffer, view: new DataView(buffer), active: false };
|
|
211
434
|
}
|
|
435
|
+
this.nextChunkToActivate = this.firstInactiveChunk();
|
|
436
|
+
return this.chunks.map((c) => c.buffer);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Index of the first chunk that is not active, or `chunks.length`. */
|
|
440
|
+
private firstInactiveChunk(): number {
|
|
441
|
+
for (let i = 0; i < this.chunks.length; i++) {
|
|
442
|
+
if (!this.chunks[i]!.active) return i;
|
|
443
|
+
}
|
|
444
|
+
return this.chunks.length;
|
|
212
445
|
}
|
|
213
446
|
|
|
214
447
|
// -------------------------------------------------------------------------
|
|
@@ -216,7 +449,8 @@ export class GrowableBuffer {
|
|
|
216
449
|
// -------------------------------------------------------------------------
|
|
217
450
|
|
|
218
451
|
getInt32(offset: number): number {
|
|
219
|
-
|
|
452
|
+
if (this.singleBuffer) return this.chunks[0]!.view.getInt32(offset, true);
|
|
453
|
+
const index = offset >>> this.chunkShift;
|
|
220
454
|
this.activate(index);
|
|
221
455
|
return this.chunks[index]!.view.getInt32(
|
|
222
456
|
offset & (this.chunkSize - 1),
|
|
@@ -225,7 +459,11 @@ export class GrowableBuffer {
|
|
|
225
459
|
}
|
|
226
460
|
|
|
227
461
|
setInt32(offset: number, value: number): void {
|
|
228
|
-
|
|
462
|
+
if (this.singleBuffer) {
|
|
463
|
+
this.chunks[0]!.view.setInt32(offset, value, true);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const index = offset >>> this.chunkShift;
|
|
229
467
|
this.activate(index);
|
|
230
468
|
this.chunks[index]!.view.setInt32(
|
|
231
469
|
offset & (this.chunkSize - 1),
|
|
@@ -235,13 +473,18 @@ export class GrowableBuffer {
|
|
|
235
473
|
}
|
|
236
474
|
|
|
237
475
|
getUint8(offset: number): number {
|
|
238
|
-
|
|
476
|
+
if (this.singleBuffer) return this.chunks[0]!.view.getUint8(offset);
|
|
477
|
+
const index = offset >>> this.chunkShift;
|
|
239
478
|
this.activate(index);
|
|
240
479
|
return this.chunks[index]!.view.getUint8(offset & (this.chunkSize - 1));
|
|
241
480
|
}
|
|
242
481
|
|
|
243
482
|
setUint8(offset: number, value: number): void {
|
|
244
|
-
|
|
483
|
+
if (this.singleBuffer) {
|
|
484
|
+
this.chunks[0]!.view.setUint8(offset, value);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const index = offset >>> this.chunkShift;
|
|
245
488
|
this.activate(index);
|
|
246
489
|
this.chunks[index]!.view.setUint8(offset & (this.chunkSize - 1), value);
|
|
247
490
|
}
|
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,13 @@ export interface SharedMemoryOptions {
|
|
|
187
210
|
arrayTable?: SharedArrayBuffer | SharedArrayBuffer[];
|
|
188
211
|
layoutVersion?: SharedArrayBuffer;
|
|
189
212
|
refCount?: SharedArrayBuffer;
|
|
213
|
+
budget?: SharedArrayBuffer;
|
|
214
|
+
cursors?: {
|
|
215
|
+
indexTable?: SharedArrayBuffer;
|
|
216
|
+
dataTable?: SharedArrayBuffer;
|
|
217
|
+
stringTable?: SharedArrayBuffer;
|
|
218
|
+
arrayTable?: SharedArrayBuffer;
|
|
219
|
+
};
|
|
190
220
|
};
|
|
191
221
|
}
|
|
192
222
|
|
|
@@ -197,11 +227,30 @@ function makeBuffer(
|
|
|
197
227
|
maxByteLength: number,
|
|
198
228
|
budget?: SharedBudget,
|
|
199
229
|
chunkSize?: number,
|
|
230
|
+
singleBuffer = false,
|
|
231
|
+
chargeInitial = true,
|
|
232
|
+
cursorBuffer?: SharedArrayBuffer,
|
|
200
233
|
): GrowableBuffer {
|
|
201
234
|
if (buf === undefined) {
|
|
202
|
-
return new GrowableBuffer(
|
|
235
|
+
return new GrowableBuffer(
|
|
236
|
+
initial,
|
|
237
|
+
maxByteLength,
|
|
238
|
+
budget,
|
|
239
|
+
chunkSize,
|
|
240
|
+
singleBuffer,
|
|
241
|
+
chargeInitial,
|
|
242
|
+
cursorBuffer,
|
|
243
|
+
);
|
|
203
244
|
}
|
|
204
|
-
return new GrowableBuffer(
|
|
245
|
+
return new GrowableBuffer(
|
|
246
|
+
buf,
|
|
247
|
+
maxByteLength,
|
|
248
|
+
budget,
|
|
249
|
+
chunkSize,
|
|
250
|
+
singleBuffer,
|
|
251
|
+
chargeInitial,
|
|
252
|
+
cursorBuffer,
|
|
253
|
+
);
|
|
205
254
|
}
|
|
206
255
|
|
|
207
256
|
/**
|
|
@@ -213,6 +262,12 @@ export class SharedMemory {
|
|
|
213
262
|
readonly dataTable: GrowableBuffer;
|
|
214
263
|
readonly stringTable: GrowableBuffer;
|
|
215
264
|
readonly arrayTable: GrowableBuffer;
|
|
265
|
+
/** Per-buffer maximum byte length (clamped to at least 32 MiB). */
|
|
266
|
+
readonly maxByteLength: number;
|
|
267
|
+
private readonly indexTableMaxByteLength: number;
|
|
268
|
+
/** Combined memory budget for Data/String/Array tables, or null if unset. */
|
|
269
|
+
readonly maxTotalBytes: number | null;
|
|
270
|
+
private readonly budgetBuffer: SharedArrayBuffer | null;
|
|
216
271
|
/**
|
|
217
272
|
* Cached Int32Array view over the Index Table, used to read/increment each
|
|
218
273
|
* key's version cell (stored as the 4th field of each index entry). Rebuilt
|
|
@@ -262,25 +317,59 @@ export class SharedMemory {
|
|
|
262
317
|
|
|
263
318
|
constructor(options?: SharedMemoryOptions) {
|
|
264
319
|
const initial = options?.initialSize ?? DEFAULT_INITIAL;
|
|
265
|
-
// `maxTotalBytes`
|
|
266
|
-
//
|
|
267
|
-
// all buffers' physical sizes is additionally capped at maxTotalBytes.
|
|
320
|
+
// `maxTotalBytes` caps the combined physical size of DataTable,
|
|
321
|
+
// StringTable, and ArrayTable. IndexTable has its independent 4 GiB cap.
|
|
268
322
|
// Without maxTotalBytes, each buffer grows up to its own maxByteLength.
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
// exceed 4 GiB.
|
|
323
|
+
// The Index Table remains a single growable SharedArrayBuffer and is
|
|
324
|
+
// capped at 4 GiB. The other tables use chunks and can exceed that limit.
|
|
272
325
|
const maxTotal = options?.maxTotalBytes;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
maxTotal
|
|
276
|
-
|
|
326
|
+
if (
|
|
327
|
+
maxTotal !== undefined &&
|
|
328
|
+
(!Number.isSafeInteger(maxTotal) || maxTotal < 0)
|
|
329
|
+
) {
|
|
330
|
+
throw new RangeError("maxTotalBytes must be a non-negative safe integer");
|
|
331
|
+
}
|
|
332
|
+
// Enforce the minimum memory budget: maxTotalBytes / maxByteLength are
|
|
333
|
+
// never below 32 MiB.
|
|
334
|
+
const perBufferMax = Math.max(
|
|
335
|
+
maxTotal ?? options?.maxByteLength ?? DEFAULT_MAX,
|
|
336
|
+
MIN_MAX_MEMORY,
|
|
337
|
+
);
|
|
338
|
+
const indexTableMax = Math.min(
|
|
339
|
+
options?.indexTableMaxByteLength ?? options?.maxByteLength ?? DEFAULT_MAX,
|
|
340
|
+
MAX_INDEX_TABLE_BYTES,
|
|
341
|
+
);
|
|
342
|
+
// Enforce the chunk-size limits: at least 4 MiB, and large enough that no
|
|
343
|
+
// table needs more than MAX_CHUNKS_PER_TABLE chunks.
|
|
344
|
+
const userChunkSize = options?.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
345
|
+
const chunkSize = Math.max(
|
|
346
|
+
userChunkSize,
|
|
347
|
+
MIN_CHUNK_SIZE,
|
|
348
|
+
nextPowerOfTwo(perBufferMax / MAX_CHUNKS_PER_TABLE),
|
|
349
|
+
);
|
|
277
350
|
const buffers = options?.buffers;
|
|
351
|
+
// The budget is the clamped maxTotalBytes (never below MIN_MAX_MEMORY), so
|
|
352
|
+
// it stays consistent with perBufferMax.
|
|
353
|
+
const budgetMax = maxTotal !== undefined ? perBufferMax : undefined;
|
|
354
|
+
const budgetBuffer = buffers?.budget;
|
|
355
|
+
const budget =
|
|
356
|
+
budgetMax !== undefined
|
|
357
|
+
? new SharedBudget(budgetMax, budgetBuffer)
|
|
358
|
+
: undefined;
|
|
359
|
+
const chargeInitial = budgetBuffer === undefined;
|
|
360
|
+
this.maxByteLength = perBufferMax;
|
|
361
|
+
this.indexTableMaxByteLength = indexTableMax;
|
|
362
|
+
this.maxTotalBytes = budgetMax ?? null;
|
|
363
|
+
this.budgetBuffer = budget?.buffer ?? null;
|
|
278
364
|
this.indexTable = makeBuffer(
|
|
279
365
|
buffers?.indexTable,
|
|
280
366
|
initial,
|
|
281
|
-
|
|
282
|
-
|
|
367
|
+
indexTableMax,
|
|
368
|
+
undefined,
|
|
283
369
|
chunkSize,
|
|
370
|
+
true,
|
|
371
|
+
true,
|
|
372
|
+
buffers?.cursors?.indexTable,
|
|
284
373
|
);
|
|
285
374
|
this.dataTable = makeBuffer(
|
|
286
375
|
buffers?.dataTable,
|
|
@@ -288,6 +377,9 @@ export class SharedMemory {
|
|
|
288
377
|
perBufferMax,
|
|
289
378
|
budget,
|
|
290
379
|
chunkSize,
|
|
380
|
+
false,
|
|
381
|
+
chargeInitial,
|
|
382
|
+
buffers?.cursors?.dataTable,
|
|
291
383
|
);
|
|
292
384
|
this.stringTable = makeBuffer(
|
|
293
385
|
buffers?.stringTable,
|
|
@@ -295,6 +387,9 @@ export class SharedMemory {
|
|
|
295
387
|
perBufferMax,
|
|
296
388
|
budget,
|
|
297
389
|
chunkSize,
|
|
390
|
+
false,
|
|
391
|
+
chargeInitial,
|
|
392
|
+
buffers?.cursors?.stringTable,
|
|
298
393
|
);
|
|
299
394
|
this.arrayTable = makeBuffer(
|
|
300
395
|
buffers?.arrayTable,
|
|
@@ -302,6 +397,9 @@ export class SharedMemory {
|
|
|
302
397
|
perBufferMax,
|
|
303
398
|
budget,
|
|
304
399
|
chunkSize,
|
|
400
|
+
false,
|
|
401
|
+
chargeInitial,
|
|
402
|
+
buffers?.cursors?.arrayTable,
|
|
305
403
|
);
|
|
306
404
|
this.versionView = new Int32Array(this.indexTable.buffers[0]!);
|
|
307
405
|
this.layoutVersion = new Int32Array(
|
|
@@ -319,6 +417,24 @@ export class SharedMemory {
|
|
|
319
417
|
return this.lock.withLock(fn);
|
|
320
418
|
}
|
|
321
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Apply a buffer update received from the main thread after a
|
|
422
|
+
* defragmentation pass. Replaces each table's buffers and rebuilds the
|
|
423
|
+
* version view. Called by workers on the `__chunkUpdate` message.
|
|
424
|
+
*/
|
|
425
|
+
applyChunkUpdate(buffers: {
|
|
426
|
+
indexTable: SharedArrayBuffer[];
|
|
427
|
+
dataTable: SharedArrayBuffer[];
|
|
428
|
+
stringTable: SharedArrayBuffer[];
|
|
429
|
+
arrayTable: SharedArrayBuffer[];
|
|
430
|
+
}): void {
|
|
431
|
+
this.indexTable.applyChunks(buffers.indexTable);
|
|
432
|
+
this.dataTable.applyChunks(buffers.dataTable);
|
|
433
|
+
this.stringTable.applyChunks(buffers.stringTable);
|
|
434
|
+
this.arrayTable.applyChunks(buffers.arrayTable);
|
|
435
|
+
this.versionView = new Int32Array(this.indexTable.buffers[0]!);
|
|
436
|
+
}
|
|
437
|
+
|
|
322
438
|
/**
|
|
323
439
|
* Attach the reference-counting buffer (5th SAB) to this memory so it is
|
|
324
440
|
* included in `exportBuffers()`. No-op if a buffer is already attached.
|
|
@@ -334,8 +450,10 @@ export class SharedMemory {
|
|
|
334
450
|
* Export all shared buffers (including the lock) so they can be sent to a
|
|
335
451
|
* worker via `postMessage` and reconstructed there with `fromBuffers()`.
|
|
336
452
|
*
|
|
337
|
-
*
|
|
338
|
-
* single SharedArrayBuffer
|
|
453
|
+
* Tables are exported as lists for the chunked buffers. The Index Table is
|
|
454
|
+
* a single growable SharedArrayBuffer, exported as a one-element list. The
|
|
455
|
+
* optional shared budget counter is included so workers reserve atomically
|
|
456
|
+
* against the same three-table limit.
|
|
339
457
|
*/
|
|
340
458
|
exportBuffers(): {
|
|
341
459
|
indexTable: SharedArrayBuffer[];
|
|
@@ -345,6 +463,16 @@ export class SharedMemory {
|
|
|
345
463
|
layoutVersion: SharedArrayBuffer;
|
|
346
464
|
lock: SharedArrayBuffer;
|
|
347
465
|
refCount: SharedArrayBuffer | null;
|
|
466
|
+
maxByteLength: number;
|
|
467
|
+
indexTableMaxByteLength: number;
|
|
468
|
+
maxTotalBytes: number | null;
|
|
469
|
+
budget: SharedArrayBuffer | null;
|
|
470
|
+
cursors: {
|
|
471
|
+
indexTable: SharedArrayBuffer;
|
|
472
|
+
dataTable: SharedArrayBuffer;
|
|
473
|
+
stringTable: SharedArrayBuffer;
|
|
474
|
+
arrayTable: SharedArrayBuffer;
|
|
475
|
+
};
|
|
348
476
|
} {
|
|
349
477
|
return {
|
|
350
478
|
indexTable: this.indexTable.buffers,
|
|
@@ -354,6 +482,16 @@ export class SharedMemory {
|
|
|
354
482
|
layoutVersion: this.layoutVersion.buffer as SharedArrayBuffer,
|
|
355
483
|
lock: this.lock.buffer as SharedArrayBuffer,
|
|
356
484
|
refCount: this.refCountBuffer,
|
|
485
|
+
maxByteLength: this.maxByteLength,
|
|
486
|
+
indexTableMaxByteLength: this.indexTableMaxByteLength,
|
|
487
|
+
maxTotalBytes: this.maxTotalBytes,
|
|
488
|
+
budget: this.budgetBuffer,
|
|
489
|
+
cursors: {
|
|
490
|
+
indexTable: this.indexTable.cursorBuffer,
|
|
491
|
+
dataTable: this.dataTable.cursorBuffer,
|
|
492
|
+
stringTable: this.stringTable.cursorBuffer,
|
|
493
|
+
arrayTable: this.arrayTable.cursorBuffer,
|
|
494
|
+
},
|
|
357
495
|
};
|
|
358
496
|
}
|
|
359
497
|
|
|
@@ -369,8 +507,28 @@ export class SharedMemory {
|
|
|
369
507
|
layoutVersion: SharedArrayBuffer;
|
|
370
508
|
lock: SharedArrayBuffer;
|
|
371
509
|
refCount?: SharedArrayBuffer | null;
|
|
510
|
+
maxByteLength?: number;
|
|
511
|
+
indexTableMaxByteLength?: number;
|
|
512
|
+
maxTotalBytes?: number | null;
|
|
513
|
+
budget?: SharedArrayBuffer | null;
|
|
514
|
+
cursors?: {
|
|
515
|
+
indexTable?: SharedArrayBuffer;
|
|
516
|
+
dataTable?: SharedArrayBuffer;
|
|
517
|
+
stringTable?: SharedArrayBuffer;
|
|
518
|
+
arrayTable?: SharedArrayBuffer;
|
|
519
|
+
};
|
|
372
520
|
}): SharedMemory {
|
|
521
|
+
const indexBuffer = Array.isArray(buffers.indexTable)
|
|
522
|
+
? buffers.indexTable[0]
|
|
523
|
+
: buffers.indexTable;
|
|
373
524
|
return new SharedMemory({
|
|
525
|
+
maxByteLength: buffers.maxByteLength ?? DEFAULT_MAX,
|
|
526
|
+
indexTableMaxByteLength:
|
|
527
|
+
buffers.indexTableMaxByteLength ??
|
|
528
|
+
indexBuffer?.maxByteLength ??
|
|
529
|
+
indexBuffer?.byteLength ??
|
|
530
|
+
DEFAULT_MAX,
|
|
531
|
+
maxTotalBytes: buffers.maxTotalBytes ?? undefined,
|
|
374
532
|
buffers: {
|
|
375
533
|
indexTable: buffers.indexTable,
|
|
376
534
|
dataTable: buffers.dataTable,
|
|
@@ -378,6 +536,8 @@ export class SharedMemory {
|
|
|
378
536
|
arrayTable: buffers.arrayTable,
|
|
379
537
|
layoutVersion: buffers.layoutVersion,
|
|
380
538
|
refCount: buffers.refCount ?? undefined,
|
|
539
|
+
budget: buffers.budget ?? undefined,
|
|
540
|
+
cursors: buffers.cursors,
|
|
381
541
|
},
|
|
382
542
|
lock: new Mutex(buffers.lock),
|
|
383
543
|
});
|
|
@@ -798,7 +958,12 @@ export class SharedMemory {
|
|
|
798
958
|
*
|
|
799
959
|
* Must be called under the shared lock (or on a single thread).
|
|
800
960
|
*/
|
|
801
|
-
defragment():
|
|
961
|
+
defragment(): {
|
|
962
|
+
indexTable: SharedArrayBuffer[];
|
|
963
|
+
dataTable: SharedArrayBuffer[];
|
|
964
|
+
stringTable: SharedArrayBuffer[];
|
|
965
|
+
arrayTable: SharedArrayBuffer[];
|
|
966
|
+
} {
|
|
802
967
|
const stringRemap = new Map<number, number>();
|
|
803
968
|
const arrayRemap = new Map<number, number>();
|
|
804
969
|
const objectRemap = new Map<number, number>();
|
|
@@ -964,6 +1129,15 @@ export class SharedMemory {
|
|
|
964
1129
|
|
|
965
1130
|
// Phase 7: Invalidate all per-worker caches.
|
|
966
1131
|
this.bumpLayoutVersion();
|
|
1132
|
+
|
|
1133
|
+
// Phase 8: Reclaim memory from inactive chunks (beyond the compacted
|
|
1134
|
+
// region). The Index Table is a single growable SAB and is never replaced.
|
|
1135
|
+
return {
|
|
1136
|
+
indexTable: this.indexTable.releaseInactiveChunks(),
|
|
1137
|
+
dataTable: this.dataTable.releaseInactiveChunks(),
|
|
1138
|
+
stringTable: this.stringTable.releaseInactiveChunks(),
|
|
1139
|
+
arrayTable: this.arrayTable.releaseInactiveChunks(),
|
|
1140
|
+
};
|
|
967
1141
|
}
|
|
968
1142
|
}
|
|
969
1143
|
|
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 {
|