gaussian-splat-lite 1.5.1 → 1.5.2

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/bin/rad/splats.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  quaternion,
13
13
  } from "./math.js";
14
14
  import { Progress } from "./progress.js";
15
- import { IndexArray, PagedArray, Permutation } from "./storage.js";
15
+ import { IndexArray, PAGE_BYTES, PagedArray, Permutation } from "./storage.js";
16
16
 
17
17
  export const SH_DIMS = [9, 15, 21];
18
18
 
@@ -75,8 +75,15 @@ export class Splats {
75
75
  // Each splat's merge weight, area × opacity, which merge() reads instead
76
76
  // of two records.
77
77
  this.weight = new PagedArray(workspace, Float64Array);
78
+ // Each splat's feature size, for the passes that read nothing else of a
79
+ // record, and the bounds of the centers; see reorder().
80
+ this.sizes = new PagedArray(workspace, Float32Array);
81
+ this.bounds = undefined;
78
82
  // The two child weights of each merged splat, for mergeSh().
79
83
  this.blend = new PagedArray(workspace, Float64Array);
84
+ // Each decoded splat's row in the SH columns: as decoded, and from
85
+ // mergeSh() on, in the order merges read them; see shRow().
86
+ this.shOrder = undefined;
80
87
  this.merged = new Float64Array(16);
81
88
  this.bits = new Uint16Array(0);
82
89
  this.merger = new Merger();
@@ -133,9 +140,6 @@ export class Splats {
133
140
  read(index, output) {
134
141
  return this.records.read(index * 16, output, 16);
135
142
  }
136
- feature(index) {
137
- return this.records.get(index * 16 + 14);
138
- }
139
143
 
140
144
  // Skips empty splats and counts invalid ones in `invalid`: NaN or infinite
141
145
  // values, including a zero rotation, which the decoder normalizes to NaN.
@@ -192,6 +196,8 @@ export class Splats {
192
196
  this.sh[band].write(count * dims, rows[band]);
193
197
  }
194
198
  }
199
+ // The record's page is cached.
200
+ this.sizes.set(count, this.records.get(count * 16 + 14));
195
201
  count++;
196
202
  }
197
203
  progress.update(base + size);
@@ -226,6 +232,7 @@ export class Splats {
226
232
  const index = this.length++;
227
233
  this.records.write(index * 16, out);
228
234
  this.weight.set(index, out[15] * out[3]);
235
+ this.sizes.set(index, out[14]);
229
236
  this.children.push(a);
230
237
  this.children.push(b);
231
238
  // SH coefficients do not affect merging; mergeSh() blends them later.
@@ -236,46 +243,105 @@ export class Splats {
236
243
  return index;
237
244
  }
238
245
 
239
- // Blends the SH coefficients of the splats merge() created from `initial`
240
- // on, in creation order, so children are done before their parents. Going
241
- // band by band keeps the random reads of children within one column.
242
- // Workers blend batches of splats whose children precede the batch; the
243
- // rest wait for their children in the batch.
246
+ // Blend each SH band in creation order, children before parents. Reorder
247
+ // leaves for sequential reads; only merged children need random reads.
248
+ // Batches use at most half the cache for child pages. Workers handle
249
+ // earlier children; children within the batch are blended below in order.
244
250
  mergeSh(initial, pool) {
251
+ if (!this.maxSh) return;
245
252
  const { nodes, entries, later, blends, blended, results } = pool;
253
+ // Each leaf occurs once, as a merge child or the lone root. Replace its
254
+ // decoded SH row with its position in merge-read order.
255
+ const reads = new IndexArray(this.workspace);
256
+ const collecting = new Progress("Collecting SH order", this.length);
257
+ for (let k = 0; k < (this.length - initial) * 2; k++) {
258
+ const child = this.children.get(k);
259
+ if (child < initial) {
260
+ reads.push(this.shOrder.get(child));
261
+ this.shOrder.set(child, reads.length - 1);
262
+ }
263
+ if ((k & 4095) === 0) collecting.update(k / 2);
264
+ }
265
+ if (this.length === initial) {
266
+ reads.push(this.shOrder.get(0));
267
+ this.shOrder.set(0, 0);
268
+ }
269
+ collecting.finish();
270
+ const leafRows = new Permutation(
271
+ this.workspace,
272
+ reads,
273
+ initial,
274
+ "Preparing SH order",
275
+ );
276
+ reads.dispose();
246
277
  // Splats per batch.
247
278
  const size = later.length;
248
279
  const rows = [this.shLeft, this.shRight];
249
280
  for (let band = 0; band < this.maxSh; band++) {
281
+ const dims = SH_DIMS[band];
282
+ const column = leafRows.apply(
283
+ this.sh[band],
284
+ dims,
285
+ pool,
286
+ false,
287
+ `Reordering SH${band + 1}`,
288
+ );
289
+ this.sh[band] = column;
250
290
  const progress = new Progress(
251
291
  `Blending SH${band + 1}`,
252
292
  this.length - initial,
253
293
  );
254
- const dims = SH_DIMS[band];
255
- const column = this.sh[band].share();
256
- for (let start = initial; start < this.length; start += size) {
257
- const count = Math.min(size, this.length - start);
294
+ // The batch that last counted each page of the column.
295
+ const counted = new Float64Array(
296
+ Math.ceil((this.length * dims) / column.items),
297
+ );
298
+ let batch = 0;
299
+ // The row of the next decoded splat that a merge reads.
300
+ let next = 0;
301
+ for (let start = initial; start < this.length; ) {
302
+ const limit = Math.min(size, this.length - start);
303
+ const pages = Math.max(
304
+ 1,
305
+ Math.floor(this.workspace.capacity / PAGE_BYTES / 2),
306
+ );
307
+ batch++;
308
+ let taken = 0;
309
+ let count = 0;
258
310
  // Load the pages of the children's rows for the workers. Consecutive
259
311
  // splats' children often share pages; a page evicted meanwhile is a
260
312
  // miss that the main thread blends.
261
313
  let left = -1;
262
314
  let right = -1;
263
- for (let i = 0; i < count; i++) {
264
- const a = this.children.get((start + i - initial) * 2);
265
- const b = this.children.get((start + i - initial) * 2 + 1);
266
- nodes[i] = a;
267
- entries[i] = b;
268
- blends[i * 2] = this.blend.get((start + i - initial) * 2);
269
- blends[i * 2 + 1] = this.blend.get((start + i - initial) * 2 + 1);
270
- later[i] = Number(a >= start || b >= start);
315
+ while (count < limit) {
316
+ const k = (start + count - initial) * 2;
317
+ const a = this.children.get(k);
318
+ const b = this.children.get(k + 1);
319
+ const ra = a < initial ? next : a;
320
+ const rb = b < initial ? next + Number(a < initial) : b;
321
+ const pa = Math.floor((ra * dims) / column.items);
322
+ const pb = Math.floor((rb * dims) / column.items);
323
+ let added = Number(ra < start && counted[pa] !== batch);
324
+ if (rb < start && counted[pb] !== batch && !(added && pb === pa))
325
+ added++;
326
+ if (count && taken + added > pages) break;
327
+ taken += added;
328
+ if (ra < start) counted[pa] = batch;
329
+ if (rb < start) counted[pb] = batch;
330
+ next += Number(a < initial) + Number(b < initial);
331
+ const i = count++;
332
+ nodes[i] = ra;
333
+ entries[i] = rb;
334
+ blends[i * 2] = this.blend.get(k);
335
+ blends[i * 2 + 1] = this.blend.get(k + 1);
336
+ later[i] = Number(ra >= start || rb >= start);
271
337
  if (later[i]) continue;
272
- if (Math.floor((a * dims) / column.items) !== left) {
273
- left = Math.floor((a * dims) / column.items);
274
- column.page(a * dims);
338
+ if (pa !== left) {
339
+ left = pa;
340
+ column.page(ra * dims);
275
341
  }
276
- if (Math.floor((b * dims) / column.items) !== right) {
277
- right = Math.floor((b * dims) / column.items);
278
- column.page(b * dims);
342
+ if (pb !== right) {
343
+ right = pb;
344
+ column.page(rb * dims);
279
345
  }
280
346
  }
281
347
  pool.blend(column, count, dims);
@@ -297,14 +363,19 @@ export class Splats {
297
363
  );
298
364
  }
299
365
  column.write(start * dims, blended, count * dims);
300
- progress.update(start + count - initial);
366
+ start += count;
367
+ progress.update(start - initial);
301
368
  }
302
369
  progress.finish();
303
370
  }
371
+ leafRows.dispose();
304
372
  this.blend.dispose();
305
373
  }
306
374
 
307
- // Moves row indices[i] of the records and SH to row i.
375
+ // Moves row indices[i] of the records to row i, and keeps `indices` for
376
+ // the SH rows, which move once merging has built the tree; see mergeSh().
377
+ // The splats' sizes and weights, and the bounds of their centers, come
378
+ // from the records while each window of them is cached.
308
379
  reorder(indices, pool) {
309
380
  const permutation = new Permutation(
310
381
  this.workspace,
@@ -312,23 +383,43 @@ export class Splats {
312
383
  this.length,
313
384
  "Preparing splat order",
314
385
  );
386
+ this.sizes.dispose();
387
+ const sizes = this.sizes;
388
+ const bounds = {
389
+ min: [0, 1, 2].map(() => Number.POSITIVE_INFINITY),
390
+ max: [0, 1, 2].map(() => Number.NEGATIVE_INFINITY),
391
+ };
392
+ const record = new Float64Array(16);
315
393
  this.records = permutation.apply(
316
394
  this.records,
317
395
  16,
318
396
  pool,
319
397
  false,
320
398
  "Reordering splats",
399
+ (records, start, end) => {
400
+ for (let i = start; i < end; i++) {
401
+ records.read(i * 16, record, 16);
402
+ for (let d = 0; d < 3; d++) {
403
+ bounds.min[d] = Math.min(bounds.min[d], record[d]);
404
+ bounds.max[d] = Math.max(bounds.max[d], record[d]);
405
+ }
406
+ this.weight.set(i, record[15] * record[3]);
407
+ sizes.set(i, record[14]);
408
+ }
409
+ },
321
410
  );
322
- for (let band = 0; band < this.maxSh; band++)
323
- this.sh[band] = permutation.apply(
324
- this.sh[band],
325
- SH_DIMS[band],
326
- pool,
327
- false,
328
- `Reordering SH${band + 1}`,
329
- );
411
+ this.bounds = bounds;
330
412
  permutation.dispose();
331
413
  this.length = indices.length;
414
+ if (this.maxSh) this.shOrder = indices;
415
+ else indices.dispose();
416
+ }
417
+
418
+ // The row of splat `index` in the SH columns after mergeSh(): merged
419
+ // splats keep theirs, and the decoded ones are in the order merges read
420
+ // them.
421
+ shRow(index) {
422
+ return index < this.shOrder.length ? this.shOrder.get(index) : index;
332
423
  }
333
424
 
334
425
  dispose() {
@@ -339,6 +430,7 @@ export class Splats {
339
430
  this.childCount,
340
431
  this.children,
341
432
  this.weight,
433
+ this.sizes,
342
434
  this.blend,
343
435
  ])
344
436
  column.dispose();
@@ -12,6 +12,7 @@ import { join } from "node:path";
12
12
  import v8 from "node:v8";
13
13
  import { runInNewContext } from "node:vm";
14
14
  import { Progress } from "./progress.js";
15
+ import { Writeback } from "./writeback.js";
15
16
 
16
17
  export const PAGE_BYTES = 65536;
17
18
  export const GIB = 1024 ** 3;
@@ -39,7 +40,7 @@ export function collect() {
39
40
  // which a SharedArrayBuffer does not on every platform. The cache takes what
40
41
  // the budget leaves after the rest of the process, measured as it runs.
41
42
  export class Workspace {
42
- constructor(budget, parent = tmpdir(), cancelled = null) {
43
+ constructor(budget, sync, parent = tmpdir(), cancelled = null) {
43
44
  this.budget = budget;
44
45
  this.parent = parent;
45
46
  this.cancelled = cancelled;
@@ -74,6 +75,9 @@ export class Workspace {
74
75
  this.nextFile = 0;
75
76
  this.readBytes = 0;
76
77
  this.writeBytes = 0;
78
+ // File writes can remain in the system's cache after writeSync returns.
79
+ // Reserve room for pending writeback inside the budget on every platform.
80
+ this.writeback = new Writeback(64 * 1024 ** 2, sync, () => this.check());
77
81
  // An earlier conversion's arenas return once collected.
78
82
  this.settle();
79
83
  }
@@ -87,13 +91,28 @@ export class Workspace {
87
91
  get capacity() {
88
92
  return Math.max(
89
93
  this.arenaBytes,
90
- this.budget - MARGIN - Math.max(this.outside, this.floor),
94
+ this.budget -
95
+ MARGIN -
96
+ this.writeback.limit -
97
+ Math.max(this.outside, this.floor),
91
98
  );
92
99
  }
93
100
 
94
101
  // Bytes the rest of the process may still grow by.
95
102
  get room() {
96
- return this.budget - MARGIN - this.arenaBytes - this.outside;
103
+ return (
104
+ this.budget -
105
+ MARGIN -
106
+ this.writeback.limit -
107
+ this.arenaBytes -
108
+ this.outside
109
+ );
110
+ }
111
+
112
+ // Rebalance the cache every 64 allocations and between streaming passes.
113
+ adjust() {
114
+ this.measure();
115
+ this.shrink();
97
116
  }
98
117
 
99
118
  // Measures the memory of the rest of the process: all of it but the
@@ -117,7 +136,10 @@ export class Workspace {
117
136
  expect(bytes) {
118
137
  this.measure();
119
138
  this.floor = this.outside + bytes;
120
- if (this.budget - MARGIN - this.floor < this.arenaBytes)
139
+ if (
140
+ this.budget - MARGIN - this.writeback.limit - this.floor <
141
+ this.arenaBytes
142
+ )
121
143
  throw new Error("Memory budget too small; increase --memory-budget");
122
144
  this.shrink();
123
145
  }
@@ -161,9 +183,32 @@ export class Workspace {
161
183
  const file = this.files.get(fd);
162
184
  this.files.delete(fd);
163
185
  try {
164
- closeSync(fd);
165
- rmSync(file);
166
- } catch {}
186
+ this.writeback.synced(fd);
187
+ } finally {
188
+ try {
189
+ closeSync(fd);
190
+ rmSync(file);
191
+ } catch {}
192
+ }
193
+ }
194
+
195
+ // Reserve writeback room before each write, including large output chunks.
196
+ // Counting whole 64 KiB pages also covers partial pages on systems whose
197
+ // native pages are larger than 4 KiB.
198
+ write(fd, bytes, position) {
199
+ for (let offset = 0; offset < bytes.byteLength; offset += 1024 * 1024) {
200
+ const part =
201
+ bytes.byteLength <= 1024 * 1024
202
+ ? bytes
203
+ : bytes.subarray(offset, offset + 1024 * 1024);
204
+ const at = position + offset;
205
+ const size =
206
+ Math.ceil(((at % PAGE_BYTES) + part.byteLength) / PAGE_BYTES) *
207
+ PAGE_BYTES;
208
+ this.writeback.prepare(size);
209
+ writeAll(fd, part, at);
210
+ this.writeback.wrote(fd, size);
211
+ }
167
212
  }
168
213
 
169
214
  evict() {
@@ -198,10 +243,7 @@ export class Workspace {
198
243
  }
199
244
 
200
245
  allocate(array, index) {
201
- if (++this.allocations % 64 === 0) {
202
- this.measure();
203
- this.shrink();
204
- }
246
+ if (++this.allocations % 64 === 0) this.adjust();
205
247
  if (this.used + PAGE_BYTES > this.capacity && this.used) this.evict();
206
248
  let slot = this.free.pop();
207
249
  if (slot === undefined) {
@@ -278,25 +320,29 @@ export class Workspace {
278
320
  }
279
321
 
280
322
  dispose() {
281
- const progress = this.directory
282
- ? new Progress("Cleaning temporary files", this.files.size + 1)
283
- : undefined;
284
- let closed = 0;
285
- for (const fd of this.files.keys()) {
286
- this.close(fd);
287
- progress?.update(++closed);
288
- }
289
- if (this.directory) removePath(this.directory, { recursive: true });
290
- this.directory = undefined;
291
- this.slots.length = 0;
292
- this.reuse.length = 0;
293
- this.free.length = 0;
294
- this.arenas.length = 0;
295
- progress?.finish();
296
- if (this.readBytes || this.writeBytes) {
297
- console.log(
298
- `Temporary array I/O: ${(this.readBytes / 1048576).toFixed(1)} MiB read, ${(this.writeBytes / 1048576).toFixed(1)} MiB written`,
299
- );
323
+ try {
324
+ this.writeback.wait(false);
325
+ } finally {
326
+ const progress = this.directory
327
+ ? new Progress("Cleaning temporary files", this.files.size + 1)
328
+ : undefined;
329
+ let closed = 0;
330
+ for (const fd of this.files.keys()) {
331
+ this.close(fd);
332
+ progress?.update(++closed);
333
+ }
334
+ if (this.directory) removePath(this.directory, { recursive: true });
335
+ this.directory = undefined;
336
+ this.slots.length = 0;
337
+ this.reuse.length = 0;
338
+ this.free.length = 0;
339
+ this.arenas.length = 0;
340
+ progress?.finish();
341
+ if (this.readBytes || this.writeBytes) {
342
+ console.log(
343
+ `Temporary array I/O: ${(this.readBytes / 1048576).toFixed(1)} MiB read, ${(this.writeBytes / 1048576).toFixed(1)} MiB written`,
344
+ );
345
+ }
300
346
  }
301
347
  }
302
348
  }
@@ -348,7 +394,7 @@ export class PagedArray {
348
394
  const stripe = block % this.stripes;
349
395
  const position = Math.floor(block / this.stripes) * PAGE_BYTES;
350
396
  this.fds[stripe] ??= this.workspace.file();
351
- writeAll(this.fds[stripe], bytes, position);
397
+ this.workspace.write(this.fds[stripe], bytes, position);
352
398
  this.sizes[stripe] = Math.max(
353
399
  this.sizes[stripe] ?? 0,
354
400
  position + PAGE_BYTES,
@@ -427,6 +473,47 @@ export class PagedArray {
427
473
  return output;
428
474
  }
429
475
 
476
+ // Read into the same array type, bypassing the cache on misses so a scan
477
+ // cannot evict cached pages.
478
+ stream(base, output, count = output.length, at = 0) {
479
+ const bytes = this.Type.BYTES_PER_ELEMENT;
480
+ let offset = 0;
481
+ while (offset < count) {
482
+ if ((++this.workspace.accesses & 4095) === 0) this.workspace.check();
483
+ const index = base + offset;
484
+ const block = Math.floor(index * this.inverse);
485
+ const within = index & this.mask;
486
+ const size = Math.min(count - offset, this.items - within);
487
+ const page = this.pages[block];
488
+ if (page)
489
+ output.set(page.values.subarray(within, within + size), at + offset);
490
+ else {
491
+ const stripe = block % this.stripes;
492
+ const position = Math.floor(block / this.stripes) * PAGE_BYTES;
493
+ const part = new Uint8Array(
494
+ output.buffer,
495
+ output.byteOffset + (at + offset) * bytes,
496
+ size * bytes,
497
+ );
498
+ // As in allocate(), a page that no file holds is zeros.
499
+ if (position >= (this.sizes[stripe] ?? 0)) part.fill(0);
500
+ else {
501
+ readAll(this.fds[stripe], part, position + within * bytes);
502
+ this.workspace.readBytes += part.byteLength;
503
+ }
504
+ }
505
+ offset += size;
506
+ }
507
+ return output;
508
+ }
509
+
510
+ // Cache the prefix before `kept`; stream the rest to preserve it for reuse.
511
+ scan(base, output, count, kept) {
512
+ const cached = Math.max(0, Math.min(count, kept - base));
513
+ this.read(base, output, cached);
514
+ return this.stream(base + cached, output, count - cached, cached);
515
+ }
516
+
430
517
  write(base, values, count = values.length) {
431
518
  let offset = 0;
432
519
  while (offset < count) {
@@ -657,8 +744,17 @@ export class Permutation {
657
744
  // rows into it; rows across pages are left to the main thread. The output
658
745
  // spreads over as many files as `source`.
659
746
  // With `words`, rows are copied as 32-bit words, which keeps any bits of
660
- // floats, and the output has word views; see PagedArray.at().
661
- apply(source, stride, pool, words = false, label = undefined) {
747
+ // floats, and the output has word views; see PagedArray.at(). `visit` is
748
+ // called with the output and each window's rows [start, end) once they are
749
+ // in place, while their pages are still cached.
750
+ apply(
751
+ source,
752
+ stride,
753
+ pool,
754
+ words = false,
755
+ label = undefined,
756
+ visit = undefined,
757
+ ) {
662
758
  const output = new PagedArray(
663
759
  this.workspace,
664
760
  source.Type,
@@ -679,6 +775,11 @@ export class Permutation {
679
775
  );
680
776
  const progress =
681
777
  label && new Progress(label, Math.ceil(this.length / window) * this.rows);
778
+ // The lowest and highest target + 1 of each block of source rows, found
779
+ // as the first window reads them: a later window that none of a block's
780
+ // rows land in skips the block.
781
+ const low = new Float64Array(Math.ceil(this.rows / block));
782
+ const high = new Float64Array(low.length);
682
783
  for (let start = 0; start < this.length; start += window) {
683
784
  const end = Math.min(this.length, start + window);
684
785
  output.pinned = [
@@ -687,8 +788,19 @@ export class Permutation {
687
788
  ];
688
789
  output.load(start * stride, end * stride, true);
689
790
  for (let base = 0; base < this.rows; base += block) {
791
+ const b = base / block;
792
+ if (start && (high[b] <= start || low[b] > end)) continue;
690
793
  const count = Math.min(block, this.rows - base);
691
794
  this.targets.read(base, targets, count);
795
+ if (!start) {
796
+ low[b] = Number.POSITIVE_INFINITY;
797
+ for (let i = 0; i < count; i++)
798
+ if (targets[i]) {
799
+ low[b] = Math.min(low[b], targets[i]);
800
+ high[b] = Math.max(high[b], targets[i]);
801
+ }
802
+ if (low[b] > end) continue;
803
+ }
692
804
  source.load(base * stride, (base + count) * stride);
693
805
  pool.copy(source, output, stride, count, base, start, end, words);
694
806
  for (let i = 0; i < count; i++) {
@@ -710,6 +822,7 @@ export class Permutation {
710
822
  }
711
823
  progress?.update(Math.floor(start / window) * this.rows + base + count);
712
824
  }
825
+ visit?.(output, start, end);
713
826
  }
714
827
  output.pinned = [0, 0];
715
828
  source.dispose();
@@ -763,7 +876,7 @@ export function readAll(fd, bytes, position) {
763
876
  return bytes;
764
877
  }
765
878
 
766
- export function writeAll(fd, bytes, position) {
879
+ function writeAll(fd, bytes, position) {
767
880
  let offset = 0;
768
881
  while (offset < bytes.byteLength) {
769
882
  const count = writeSync(
@@ -0,0 +1,100 @@
1
+ import { fdatasync } from "node:fs";
2
+ import { getSystemErrorName } from "node:util";
3
+
4
+ // The CLI thread can receive these requests while conversion runs synchronous
5
+ // code. Completion is shared so the converter need not yield its event loop.
6
+ export function syncFiles(files, state, cancelled) {
7
+ // A batch can still be in the message queue when cancellation arrives.
8
+ // Acknowledge it without submitting any more native I/O.
9
+ if (Atomics.load(cancelled, 0)) {
10
+ Atomics.store(state, 0, 0);
11
+ Atomics.notify(state, 0);
12
+ return;
13
+ }
14
+ for (const fd of files)
15
+ fdatasync(fd, (error) => {
16
+ if (error) Atomics.compareExchange(state, 1, 0, error.errno);
17
+ Atomics.sub(state, 0, 1);
18
+ Atomics.notify(state, 0);
19
+ });
20
+ }
21
+
22
+ // Bounds buffered writes that have not yet been confirmed written back. One
23
+ // batch syncs while the next fills; only the older batch is acknowledged when
24
+ // its sync completes, even if the same files have been written again.
25
+ export class Writeback {
26
+ // `check` throws once the conversion is cancelled.
27
+ constructor(limit, submit, check) {
28
+ this.limit = limit;
29
+ this.submit = submit;
30
+ this.check = check;
31
+ this.files = new Map();
32
+ this.bytes = 0;
33
+ this.pending = undefined;
34
+ // Outstanding syncs, then the first negative system error number.
35
+ this.state = new Int32Array(new SharedArrayBuffer(8));
36
+ }
37
+
38
+ complete() {
39
+ if (!this.pending || Atomics.load(this.state, 0)) return;
40
+ this.pending = undefined;
41
+ const error = Atomics.load(this.state, 1);
42
+ if (error)
43
+ throw new Error(
44
+ `Unable to write back file data: ${getSystemErrorName(error)}`,
45
+ );
46
+ }
47
+
48
+ // Cancellation interrupts conversion, but keeps the batch tracked. Cleanup
49
+ // waits without cancellation so every fd stays open until its sync returns.
50
+ wait(cancellable = true) {
51
+ if (!this.pending) return;
52
+ for (;;) {
53
+ if (cancellable) this.check();
54
+ const remaining = Atomics.load(this.state, 0);
55
+ if (!remaining) break;
56
+ Atomics.wait(this.state, 0, remaining, cancellable ? 100 : undefined);
57
+ }
58
+ this.complete();
59
+ }
60
+
61
+ // Called before writing: the write itself must fit, including while an
62
+ // earlier batch is still being written back.
63
+ prepare(bytes) {
64
+ this.check();
65
+ this.complete();
66
+ if (this.bytes + (this.pending?.bytes ?? 0) + bytes <= this.limit) return;
67
+ this.wait();
68
+ if (this.bytes + bytes > this.limit) {
69
+ this.flush();
70
+ this.wait();
71
+ }
72
+ }
73
+
74
+ wrote(fd, bytes) {
75
+ this.files.set(fd, (this.files.get(fd) ?? 0) + bytes);
76
+ this.bytes += bytes;
77
+ // Each sync costs a call per file, so batches are large; the last
78
+ // quarter of the limit takes writes while the older batch syncs.
79
+ if (!this.pending && this.bytes >= (this.limit * 3) / 4) this.flush();
80
+ }
81
+
82
+ flush() {
83
+ this.check();
84
+ const files = [...this.files.keys()];
85
+ Atomics.store(this.state, 0, files.length);
86
+ Atomics.store(this.state, 1, 0);
87
+ this.pending = { files: this.files, bytes: this.bytes };
88
+ this.submit(files, this.state);
89
+ this.files = new Map();
90
+ this.bytes = 0;
91
+ }
92
+
93
+ // Before a file is closed, its outstanding syncs must finish: otherwise a
94
+ // queued sync could operate on a different file that reuses its descriptor.
95
+ synced(fd) {
96
+ if (this.pending?.files.has(fd)) this.wait(false);
97
+ this.bytes -= this.files.get(fd) ?? 0;
98
+ this.files.delete(fd);
99
+ }
100
+ }