bun-shared-memory 0.1.5 → 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.
@@ -1,67 +1,268 @@
1
1
  /**
2
2
  * Shared memory budget shared across multiple GrowableBuffers.
3
3
  *
4
- * Tracks the sum of the physical sizes of all participating buffers so a
5
- * caller can cap the *total* memory used by a set of buffers, rather than
6
- * capping each buffer independently. Growth is only allowed if the new total
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 total = 0;
9
+ private readonly used: BigInt64Array;
11
10
 
12
- constructor(private readonly max: number) {}
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.total += size;
17
- if (this.total > this.max) {
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 ${this.total} bytes but the budget is ${this.max}`,
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
- * Attempt to grow a buffer from `oldSize` to `newSize`. Returns true and
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
- tryGrow(oldSize: number, newSize: number): boolean {
30
- const newTotal = this.total - oldSize + newSize;
31
- if (newTotal > this.max) return false;
32
- this.total = newTotal;
33
- return true;
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 SharedArrayBuffer helper.
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
- * SharedArrayBuffer supports `grow(newLength)` when created with
41
- * `maxByteLength`. Growth preserves existing data, can only grow up to
42
- * `maxByteLength`, and cannot shrink. All workers sharing the buffer see the
43
- * growth immediately.
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.
44
75
  */
76
+ interface Chunk {
77
+ buffer: SharedArrayBuffer;
78
+ view: DataView;
79
+ /** Whether the chunk has been grown to its full size. */
80
+ active: boolean;
81
+ }
82
+
83
+ /** Round `n` up to the next power of two (chunk sizes must be powers of two). */
84
+ export function nextPowerOfTwo(n: number): number {
85
+ if (n <= 0) return 1;
86
+ let p = 1;
87
+ while (p < n) p <<= 1;
88
+ return p;
89
+ }
90
+
91
+ export function largestPowerOfTwo(n: number): number {
92
+ if (n <= 1) return 1;
93
+ return 2 ** Math.floor(Math.log2(n));
94
+ }
95
+
45
96
  export class GrowableBuffer {
46
- readonly buffer: SharedArrayBuffer;
47
- private view: DataView;
97
+ private chunks: Chunk[] = [];
48
98
  private readonly maxByteLength: number;
99
+ private readonly singleBuffer: boolean;
100
+ /** Fixed size of every chunk (the minimum growth step). */
101
+ readonly chunkSize: number;
102
+ /** log2(chunkSize), used for O(1) offset resolution via bit shifts. */
103
+ private readonly chunkShift: number;
49
104
  private readonly budget?: SharedBudget;
50
105
  private used = 0;
106
+ /** Smallest chunk index that is not yet active (see `activate`). */
107
+ private nextChunkToActivate = 0;
51
108
 
52
109
  constructor(
53
- initialSizeOrBuffer: number | SharedArrayBuffer,
110
+ initialSizeOrBuffer: number | SharedArrayBuffer | SharedArrayBuffer[],
54
111
  maxByteLength: number,
55
112
  budget?: SharedBudget,
113
+ chunkSize = 32 * 1024 * 1024, // 32 MiB per chunk
114
+ singleBuffer = false,
115
+ chargeInitial = true,
56
116
  ) {
57
- this.buffer =
58
- typeof initialSizeOrBuffer === "number"
59
- ? new SharedArrayBuffer(initialSizeOrBuffer, { maxByteLength })
117
+ this.singleBuffer = singleBuffer;
118
+ this.budget = budget;
119
+ if (singleBuffer && typeof initialSizeOrBuffer !== "number") {
120
+ const buffer = Array.isArray(initialSizeOrBuffer)
121
+ ? initialSizeOrBuffer[0]
60
122
  : initialSizeOrBuffer;
61
- this.view = new DataView(this.buffer);
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
+
62
146
  this.maxByteLength = maxByteLength;
63
- this.budget = budget;
64
- this.budget?.add(this.buffer.byteLength);
147
+ if (typeof initialSizeOrBuffer === "number") {
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
+ );
165
+ this.chunkShift = Math.log2(this.chunkSize);
166
+ // Pre-allocate all chunks (4 bytes each, growable to chunkSize).
167
+ const chunkCount = Math.max(1, Math.ceil(maxByteLength / this.chunkSize));
168
+ for (let i = 0; i < chunkCount; i++) {
169
+ const buffer = new SharedArrayBuffer(4, {
170
+ maxByteLength: this.chunkSize,
171
+ });
172
+ this.chunks.push({
173
+ buffer,
174
+ view: new DataView(buffer),
175
+ active: false,
176
+ });
177
+ if (chargeInitial) this.budget?.add(buffer.byteLength);
178
+ }
179
+ } else if (Array.isArray(initialSizeOrBuffer)) {
180
+ // Reconstruct from a list of chunks. Use the buffers directly (they are
181
+ // already shared via postMessage) so writes are visible across workers.
182
+ // All chunks are pre-allocated at 4 bytes with maxByteLength = chunkSize;
183
+ // the chunk size is derived from each buffer's maxByteLength (which is
184
+ // preserved across postMessage), not its current byteLength.
185
+ this.chunkSize = initialSizeOrBuffer[0]?.maxByteLength ?? chunkSize;
186
+ this.chunkShift = Math.log2(this.chunkSize);
187
+ for (const sab of initialSizeOrBuffer) {
188
+ this.chunks.push({
189
+ buffer: sab,
190
+ view: new DataView(sab),
191
+ active: sab.byteLength === this.chunkSize,
192
+ });
193
+ if (chargeInitial) this.budget?.add(sab.byteLength);
194
+ }
195
+ } else {
196
+ // Reconstruct from a single existing buffer. Use it directly so writes
197
+ // are shared. Its size defines the chunk size (prefer maxByteLength so
198
+ // a pre-allocated 4-byte chunk is interpreted at its full size).
199
+ this.chunkSize =
200
+ initialSizeOrBuffer.maxByteLength ?? initialSizeOrBuffer.byteLength;
201
+ this.chunkShift = Math.log2(this.chunkSize);
202
+ this.chunks.push({
203
+ buffer: initialSizeOrBuffer,
204
+ view: new DataView(initialSizeOrBuffer),
205
+ active: initialSizeOrBuffer.byteLength === this.chunkSize,
206
+ });
207
+ if (chargeInitial) this.budget?.add(initialSizeOrBuffer.byteLength);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Activate the chunk at `index` by growing it to its full size. Because
213
+ * `SharedArrayBuffer.grow()` is visible across all workers, no broadcast is
214
+ * needed. No-op if the chunk is already active.
215
+ */
216
+ private activate(index: number): void {
217
+ const chunk = this.chunks[index];
218
+ if (chunk === undefined || chunk.active) return;
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
+ }
248
+ chunk.view = new DataView(chunk.buffer);
249
+ chunk.active = true;
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
+ }
65
266
  }
66
267
 
67
268
  /** Number of bytes written so far (used length, not buffer capacity). */
@@ -69,24 +270,105 @@ export class GrowableBuffer {
69
270
  return this.used;
70
271
  }
71
272
 
273
+ /** Total logical capacity across all chunks (active or not). */
274
+ get capacity(): number {
275
+ return this.singleBuffer
276
+ ? this.maxByteLength
277
+ : this.chunks.length * this.chunkSize;
278
+ }
279
+
280
+ /** The list of SharedArrayBuffers backing this buffer (for export). */
281
+ get buffers(): SharedArrayBuffer[] {
282
+ return this.chunks.map((c) => c.buffer);
283
+ }
284
+
285
+ /** Number of chunks that have been grown from their initial 4-byte size. */
286
+ get activeChunkCount(): number {
287
+ return this.chunks.reduce((n, c) => n + (c.active ? 1 : 0), 0);
288
+ }
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
+
312
+ /**
313
+ * Resolve a logical offset into (chunk, offset within that chunk). All
314
+ * chunks are the same size, so this is O(1). Activates the chunk if it is
315
+ * not yet active.
316
+ */
317
+ private resolve(offset: number): { chunk: Chunk; off: number } {
318
+ if (this.singleBuffer) {
319
+ return { chunk: this.chunks[0]!, off: offset };
320
+ }
321
+ const index = offset >>> this.chunkShift;
322
+ const chunk = this.chunks[index];
323
+ if (chunk === undefined) {
324
+ throw new RangeError(`Offset ${offset} is beyond the buffer capacity`);
325
+ }
326
+ this.activate(index);
327
+ return { chunk, off: offset & (this.chunkSize - 1) };
328
+ }
329
+
72
330
  /** Ensure the buffer has room for at least `needed` bytes. */
73
331
  ensureCapacity(needed: number): void {
74
- if (needed <= this.buffer.byteLength) return;
75
332
  if (needed > this.maxByteLength) {
76
333
  throw new RangeError(
77
334
  `Shared buffer capacity exceeded: need ${needed} bytes but max is ${this.maxByteLength}`,
78
335
  );
79
336
  }
80
- let newSize = this.buffer.byteLength * 2;
81
- while (newSize < needed) newSize *= 2;
82
- if (newSize > this.maxByteLength) newSize = this.maxByteLength;
83
- if (this.budget && !this.budget.tryGrow(this.buffer.byteLength, newSize)) {
84
- throw new RangeError(
85
- `Shared memory budget exceeded: growing to ${newSize} bytes would exceed the total budget`,
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),
86
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
+ }
364
+ // All chunks are pre-allocated; just activate the ones needed to cover
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).
368
+ const neededChunks = Math.ceil(needed / this.chunkSize);
369
+ for (let i = this.nextChunkToActivate; i < neededChunks; i++) {
370
+ this.activate(i);
87
371
  }
88
- this.buffer.grow(newSize);
89
- this.view = new DataView(this.buffer);
90
372
  }
91
373
 
92
374
  /** Mark `n` bytes as used, after writing them at the current offset. */
@@ -96,15 +378,129 @@ export class GrowableBuffer {
96
378
 
97
379
  /**
98
380
  * Reset the used length to `n` bytes. Used by defragmentation after
99
- * compacting live data to the front of the buffer. The underlying
100
- * SharedArrayBuffer is not shrunk (it cannot be), but the tail beyond `n`
101
- * is treated as free space for future writes.
381
+ * compacting live data to the front of the buffer. Chunks beyond the
382
+ * compacted region are marked inactive; their storage is reclaimed by
383
+ * `releaseInactiveChunks()`.
102
384
  */
103
385
  reset(n: number): void {
104
386
  this.used = n;
387
+ if (this.singleBuffer) return;
388
+ const neededChunks = Math.ceil(n / this.chunkSize);
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;
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;
430
+ }
431
+
432
+ // -------------------------------------------------------------------------
433
+ // Typed accessors (resolve offset -> chunk)
434
+ // -------------------------------------------------------------------------
435
+
436
+ getInt32(offset: number): number {
437
+ if (this.singleBuffer) return this.chunks[0]!.view.getInt32(offset, true);
438
+ const index = offset >>> this.chunkShift;
439
+ this.activate(index);
440
+ return this.chunks[index]!.view.getInt32(
441
+ offset & (this.chunkSize - 1),
442
+ true,
443
+ );
444
+ }
445
+
446
+ setInt32(offset: number, value: number): void {
447
+ if (this.singleBuffer) {
448
+ this.chunks[0]!.view.setInt32(offset, value, true);
449
+ return;
450
+ }
451
+ const index = offset >>> this.chunkShift;
452
+ this.activate(index);
453
+ this.chunks[index]!.view.setInt32(
454
+ offset & (this.chunkSize - 1),
455
+ value,
456
+ true,
457
+ );
105
458
  }
106
459
 
107
- getView(): DataView {
108
- return this.view;
460
+ getUint8(offset: number): number {
461
+ if (this.singleBuffer) return this.chunks[0]!.view.getUint8(offset);
462
+ const index = offset >>> this.chunkShift;
463
+ this.activate(index);
464
+ return this.chunks[index]!.view.getUint8(offset & (this.chunkSize - 1));
465
+ }
466
+
467
+ setUint8(offset: number, value: number): void {
468
+ if (this.singleBuffer) {
469
+ this.chunks[0]!.view.setUint8(offset, value);
470
+ return;
471
+ }
472
+ const index = offset >>> this.chunkShift;
473
+ this.activate(index);
474
+ this.chunks[index]!.view.setUint8(offset & (this.chunkSize - 1), value);
475
+ }
476
+
477
+ /** Read `length` bytes starting at `offset` into a fresh Uint8Array. */
478
+ readBytes(offset: number, length: number): Uint8Array {
479
+ const out = new Uint8Array(length);
480
+ let pos = offset;
481
+ let outPos = 0;
482
+ while (outPos < length) {
483
+ const { chunk, off } = this.resolve(pos);
484
+ const n = Math.min(length - outPos, this.chunkSize - off);
485
+ out.set(new Uint8Array(chunk.buffer, off, n), outPos);
486
+ outPos += n;
487
+ pos += n;
488
+ }
489
+ return out;
490
+ }
491
+
492
+ /** Write `bytes` starting at `offset`. */
493
+ writeBytes(offset: number, bytes: Uint8Array): void {
494
+ let pos = offset;
495
+ let srcPos = 0;
496
+ while (srcPos < bytes.byteLength) {
497
+ const { chunk, off } = this.resolve(pos);
498
+ const n = Math.min(bytes.byteLength - srcPos, this.chunkSize - off);
499
+ new Uint8Array(chunk.buffer, off, n).set(
500
+ bytes.subarray(srcPos, srcPos + n),
501
+ );
502
+ srcPos += n;
503
+ pos += n;
504
+ }
109
505
  }
110
506
  }