gaussian-splat-lite 1.5.0 → 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.
@@ -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
+ }