gaussian-splat-lite 1.3.2 → 1.4.1

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +36 -2
  2. package/CONTRIBUTING.md +14 -2
  3. package/NOTICE +11 -3
  4. package/README.md +5 -7
  5. package/THIRD_PARTY_LICENSES.md +64 -6
  6. package/bin/build-lod.js +241 -0
  7. package/bin/decoder/decoder.js +340 -0
  8. package/bin/decoder/decoder_bg.wasm +0 -0
  9. package/bin/rad/grid.js +620 -0
  10. package/bin/rad/heap.js +270 -0
  11. package/bin/rad/input.js +87 -0
  12. package/bin/rad/lod.js +657 -0
  13. package/bin/rad/math.js +166 -0
  14. package/bin/rad/parallel.js +578 -0
  15. package/bin/rad/progress.js +50 -0
  16. package/bin/rad/rad.js +612 -0
  17. package/bin/rad/splats.js +432 -0
  18. package/bin/rad/storage.js +776 -0
  19. package/dist/gaussian-splat-lite.cjs +392 -794
  20. package/dist/gaussian-splat-lite.cjs.map +1 -1
  21. package/dist/gaussian-splat-lite.min.cjs +246 -308
  22. package/dist/gaussian-splat-lite.min.cjs.map +1 -1
  23. package/dist/gaussian-splat-lite.module.js +394 -796
  24. package/dist/gaussian-splat-lite.module.js.map +1 -1
  25. package/dist/gaussian-splat-lite.module.min.js +2518 -2802
  26. package/dist/gaussian-splat-lite.module.min.js.map +1 -1
  27. package/dist/types/addons/NeuralDenoiseNode.d.cts +1 -2
  28. package/dist/types/addons/NeuralDenoiseNode.d.ts +1 -2
  29. package/dist/types/addons/NeuralDenoiseXR.d.cts +31 -0
  30. package/dist/types/addons/NeuralDenoiseXR.d.ts +31 -0
  31. package/dist/types/addons/neuralDenoiseState.d.cts +22 -0
  32. package/dist/types/addons/neuralDenoiseState.d.ts +22 -0
  33. package/dist/types/addons/neuralDenoiseWeights.d.cts +41 -2
  34. package/dist/types/addons/neuralDenoiseWeights.d.ts +41 -2
  35. package/dist/types/data/RadPagedSplats.d.cts +1 -1
  36. package/dist/types/data/RadPagedSplats.d.ts +1 -1
  37. package/dist/types/index.d.cts +1 -2
  38. package/dist/types/index.d.ts +1 -2
  39. package/dist/types/loaders/rad/radFormat.d.cts +2 -2
  40. package/dist/types/loaders/rad/radFormat.d.ts +2 -2
  41. package/docs/BuildRad.md +91 -0
  42. package/docs/GaussianSplatRenderer.md +2 -2
  43. package/docs/NeuralDenoiseNode.md +32 -13
  44. package/docs/RadStreamScheduler.md +3 -1
  45. package/docs/StochasticRendering.md +20 -42
  46. package/package.json +9 -3
  47. package/dist/types/addons/TAANode.d.cts +0 -49
  48. package/dist/types/addons/TAANode.d.ts +0 -49
  49. package/dist/types/addons/TAAPass.d.cts +0 -57
  50. package/dist/types/addons/TAAPass.d.ts +0 -57
  51. package/dist/types/addons/taaShared.d.cts +0 -74
  52. package/dist/types/addons/taaShared.d.ts +0 -74
  53. package/docs/TAAPass.md +0 -184
@@ -0,0 +1,578 @@
1
+ import { availableParallelism } from "node:os";
2
+ import {
3
+ Worker,
4
+ isMainThread,
5
+ parentPort,
6
+ receiveMessageOnPort,
7
+ workerData,
8
+ } from "node:worker_threads";
9
+ import { Lookup, NONE } from "./grid.js";
10
+ import { HALF, halfBits } from "./math.js";
11
+ import {
12
+ EMPTY,
13
+ INVALID,
14
+ KEEP,
15
+ LATER,
16
+ Merger,
17
+ SH_DIMS,
18
+ Similarities,
19
+ refresh,
20
+ validRecord,
21
+ } from "./splats.js";
22
+ import { ArenaViews, Parts, SharedColumn, collect } from "./storage.js";
23
+
24
+ // A search that the main thread does: a merge failed.
25
+ export const MISS = 0xfffffffe;
26
+ // The most searches in one round.
27
+ export const ROUND = 32768;
28
+ // Control words.
29
+ const GEN = 0;
30
+ const NEXT = 1;
31
+ const COUNT = 2;
32
+ const DONE = 3;
33
+ const VERSION = 4;
34
+ const JOB = 5;
35
+ const SIZE = 6;
36
+ const DIMS = 7;
37
+ // The length + 1 of a failed worker's error message in `failure`.
38
+ const FAILED = 8;
39
+ // A copy's row stride, first source row and target window.
40
+ const STRIDE = 10;
41
+ const BASE = 11;
42
+ const START = 12;
43
+ const END = 13;
44
+ // The workers that have collected the views of released arenas.
45
+ const COLLECTED = 14;
46
+ // Jobs.
47
+ const SEARCH = 0;
48
+ const BLEND = 1;
49
+ const COPY = 2;
50
+ const CHECK = 3;
51
+ const COLLECT = 4;
52
+ const TYPES = { Float32Array, Float64Array, Uint16Array, Uint32Array };
53
+ const MAX_DIMS = SH_DIMS[SH_DIMS.length - 1];
54
+
55
+ // Memory each LOD worker thread takes and each encoding worker thread with
56
+ // its chunk, with young generations of YOUNG_MB, measured on Linux and macOS;
57
+ // what the main thread grows by during the LOD; and the chunks the main
58
+ // thread holds for the encoders.
59
+ const WORKER_BYTES = 40 * 1024 ** 2;
60
+ const ENCODER_BYTES = 52 * 1024 ** 2;
61
+ const MAIN_BYTES = 64 * 1024 ** 2;
62
+ const CHUNKS_BYTES = 24 * 1024 ** 2;
63
+ export const YOUNG_MB = 8;
64
+
65
+ // Worker threads with `bytes` each: at most 8, using at most `share` of the
66
+ // budget and half the room left besides `extra`, as measured now.
67
+ function threads(workspace, bytes, extra, share = 1 / 4) {
68
+ workspace.settle();
69
+ return Math.max(
70
+ 0,
71
+ Math.min(
72
+ availableParallelism() - 1,
73
+ 8,
74
+ Math.floor((workspace.budget * share) / bytes),
75
+ Math.floor((workspace.room - extra) / 2 / bytes),
76
+ ),
77
+ );
78
+ }
79
+
80
+ // Makes room for the encoding worker threads of `chunks` and returns how
81
+ // many there are.
82
+ export function encoders(workspace, chunks) {
83
+ const count = Math.min(
84
+ threads(workspace, ENCODER_BYTES, CHUNKS_BYTES),
85
+ chunks - 1,
86
+ );
87
+ workspace.expect(count * ENCODER_BYTES + CHUNKS_BYTES);
88
+ return count;
89
+ }
90
+
91
+ const shared = (Type, size) =>
92
+ new Type(new SharedArrayBuffer(size * Type.BYTES_PER_ELEMENT));
93
+
94
+ // Whether a state value is unchanged; lists of directories compare by item.
95
+ const same = (a, b) =>
96
+ a === b ||
97
+ (Array.isArray(a) &&
98
+ Array.isArray(b) &&
99
+ a.length === b.length &&
100
+ a.every((value, i) => value === b[i]));
101
+
102
+ // Runs jobs over the pages of shared PagedArrays in worker threads, with the
103
+ // main thread taking part: the searches and merges of a round of LOD merges,
104
+ // which the main thread then commits in order (see mergeLevels()), SH
105
+ // blending, column permutations and the checks of decoded splats. Searches
106
+ // read pages that are not cached from their files; the other jobs leave
107
+ // what is not cached to the main thread.
108
+ export class Pool {
109
+ // Workers pay off for jobs over at least a batch of `splats`.
110
+ constructor(workspace, splats) {
111
+ this.workspace = workspace;
112
+ // Once the LOD grid's entries, 64 bytes a splat, exceed three times the
113
+ // budget, reads of pages dominate: the system serves no more of them
114
+ // with more threads, and fewer threads leave more room for the cache.
115
+ // The threads' share of the budget falls from a quarter in proportion,
116
+ // to an eighth at twice that, below which it saves little.
117
+ const share = Math.max(
118
+ 1 / 8,
119
+ Math.min(1 / 4, (0.75 * workspace.budget) / (splats * 64)),
120
+ );
121
+ const count =
122
+ splats < ROUND ? 0 : threads(workspace, WORKER_BYTES, MAIN_BYTES, share);
123
+ this.shared = {
124
+ cancelled: workspace.cancelled,
125
+ control: shared(Int32Array, 16),
126
+ // Bytes that threads read from files, for Workspace.readBytes.
127
+ read: new BigInt64Array(new SharedArrayBuffer(8)),
128
+ failure: shared(Uint8Array, 1024),
129
+ // Each search's splat and its entry.
130
+ nodes: shared(Uint32Array, ROUND),
131
+ entries: shared(Uint32Array, ROUND),
132
+ // Its candidate's entry, NONE or MISS; unless a MISS, the splat's
133
+ // terms and weight and its candidate's node, the merged record and its
134
+ // Similarities terms and the blend weights.
135
+ results: shared(Uint32Array, ROUND),
136
+ // Its score; see Lookup.find().
137
+ metrics: shared(Float64Array, ROUND),
138
+ queries: shared(Float64Array, ROUND * 14),
139
+ partners: shared(Uint32Array, ROUND),
140
+ // The slot of the merged splat's cell if found, else NONE.
141
+ hints: shared(Uint32Array, ROUND),
142
+ records: shared(Float32Array, ROUND * 16),
143
+ terms: shared(Float32Array, ROUND * 13),
144
+ blends: shared(Float64Array, ROUND * 2),
145
+ // SH blending, with the children in `nodes` and `entries` and their
146
+ // weights in `blends`: whether the main thread blends a row, as a
147
+ // child is in the same batch, and the blended rows.
148
+ later: shared(Uint8Array, ROUND),
149
+ blended: shared(Uint16Array, ROUND * MAX_DIMS),
150
+ };
151
+ Object.assign(this, this.shared);
152
+ this.generation = 0;
153
+ // The workspace version and the arrays last sent, and the messages sent.
154
+ this.version = -1;
155
+ this.state = {};
156
+ this.sent = 0;
157
+ this.task = new Task(this.shared);
158
+ workspace.expect(count * WORKER_BYTES + MAIN_BYTES);
159
+ this.workers = Array.from({ length: count }, () => {
160
+ const worker = new Worker(new URL(import.meta.url), {
161
+ workerData: this.shared,
162
+ resourceLimits: { maxYoungGenerationSizeMb: YOUNG_MB },
163
+ });
164
+ worker.unref();
165
+ return worker;
166
+ });
167
+ workspace.collector = () => this.collect();
168
+ }
169
+
170
+ // Sends the workers the arrays a job reads, when they changed.
171
+ share(state) {
172
+ if (
173
+ this.workspace.version === this.version &&
174
+ Object.entries(state).every(([key, value]) =>
175
+ same(this.state[key], value),
176
+ )
177
+ )
178
+ return;
179
+ this.version = this.workspace.version;
180
+ Object.assign(this.state, state);
181
+ // All arrays, so that no thread keeps views of released arenas.
182
+ const message = {
183
+ version: ++this.sent,
184
+ arenas: this.workspace.arenas,
185
+ generation: this.workspace.generation,
186
+ ...this.state,
187
+ };
188
+ for (const worker of this.workers) worker.postMessage(message);
189
+ this.task.use(message);
190
+ }
191
+
192
+ // Searches and merges for the first `count` nodes and entries, against the
193
+ // grid as it is.
194
+ search(grid, count) {
195
+ const arrays = [grid.entries, grid.slots, grid.gone];
196
+ this.share({
197
+ entries: grid.entries.directory,
198
+ slots: grid.slots.directory,
199
+ gone: grid.gone.directory,
200
+ files: arrays.map((array) => array.files()),
201
+ capacity: grid.capacity,
202
+ step: grid.step,
203
+ });
204
+ this.run(SEARCH, count, 32);
205
+ }
206
+
207
+ // Blends the rows of `column` of the first `count` children pairs in
208
+ // `nodes` and `entries` with `blends` into `blended`, except rows marked
209
+ // `later`, and marks them 0 in `results`. Rows that are not cached are left
210
+ // to the main thread.
211
+ blend(column, count, dims) {
212
+ this.share({ column: column.directory });
213
+ Atomics.store(this.control, DIMS, dims);
214
+ this.run(BLEND, count, 512);
215
+ }
216
+
217
+ // Copies `count` rows of `source` from row `base` to rows of `output` at
218
+ // their targets + 1 in `nodes` when those lie in [start, end). Marks rows
219
+ // across pages or not cached in `later`, for the main thread.
220
+ copy(source, output, stride, count, base, start, end, words = false) {
221
+ this.share({
222
+ source: source.directory,
223
+ output: output.directory,
224
+ type: words ? "Uint32Array" : source.Type.name,
225
+ });
226
+ const control = this.control;
227
+ Atomics.store(control, STRIDE, stride);
228
+ Atomics.store(control, BASE, base);
229
+ Atomics.store(control, START, start);
230
+ Atomics.store(control, END, end);
231
+ this.run(COPY, count, 1024);
232
+ }
233
+
234
+ // Refreshes and checks `count` splats from `base` in place, setting KEEP,
235
+ // INVALID, EMPTY or LATER in `results`; see Splats.retain().
236
+ check(splats, base, count) {
237
+ this.share({
238
+ splatRecords: splats.records.directory,
239
+ splatSh: splats.sh
240
+ .slice(0, splats.maxSh)
241
+ .map((column) => column.directory),
242
+ });
243
+ Atomics.store(this.control, BASE, base);
244
+ this.run(CHECK, count, 1024);
245
+ }
246
+
247
+ // Makes the workers drop their views of arenas that the workspace
248
+ // released, and collect them.
249
+ collect() {
250
+ this.share({});
251
+ this.run(COLLECT, 0, 1);
252
+ }
253
+
254
+ run(job, count, size) {
255
+ this.workspace.check();
256
+ const gen = ++this.generation;
257
+ const control = this.control;
258
+ Atomics.store(control, JOB, job);
259
+ Atomics.store(control, SIZE, size);
260
+ Atomics.store(control, COUNT, count);
261
+ Atomics.store(control, DONE, 0);
262
+ Atomics.store(control, NEXT, (gen & 0x7fff) << 16);
263
+ Atomics.store(control, VERSION, this.sent);
264
+ Atomics.store(control, COLLECTED, 0);
265
+ Atomics.store(control, GEN, gen);
266
+ Atomics.notify(control, GEN);
267
+ this.task.begin();
268
+ this.task.work(gen);
269
+ for (;;) {
270
+ this.workspace.check();
271
+ const done = Atomics.load(control, DONE);
272
+ if (done >= count) break;
273
+ Atomics.wait(control, DONE, done, 100);
274
+ }
275
+ this.workspace.readBytes += Number(Atomics.exchange(this.read, 0, 0n));
276
+ while (job === COLLECT) {
277
+ this.workspace.check();
278
+ const collected = Atomics.load(control, COLLECTED);
279
+ // A failed worker no longer answers; its error is thrown below.
280
+ if (collected >= this.workers.length || Atomics.load(control, FAILED))
281
+ break;
282
+ Atomics.wait(control, COLLECTED, collected, 100);
283
+ }
284
+ const failed = Atomics.load(control, FAILED);
285
+ if (failed)
286
+ throw new Error(
287
+ `LOD worker failed: ${new TextDecoder().decode(this.failure.slice(0, failed - 1))}`,
288
+ );
289
+ }
290
+
291
+ // Stops the workers. Their views and the main thread's would keep arenas
292
+ // that the workspace releases.
293
+ async close() {
294
+ Atomics.store(this.control, GEN, -1);
295
+ Atomics.notify(this.control, GEN);
296
+ await Promise.all(this.workers.map((worker) => worker.terminate()));
297
+ this.task = undefined;
298
+ this.workspace.collector = undefined;
299
+ }
300
+ }
301
+
302
+ // The slot of the cell of merged center `m` that the last find visited: the
303
+ // query's cell or the cell of its best, at `b`; NONE if neither.
304
+ export function hint(lookup, m, q, b) {
305
+ const inverse = lookup.inverse;
306
+ const x = Math.floor(m[0] * inverse);
307
+ const y = Math.floor(m[1] * inverse);
308
+ const z = Math.floor(m[2] * inverse);
309
+ if (
310
+ x === Math.floor(q[0] * inverse) &&
311
+ y === Math.floor(q[1] * inverse) &&
312
+ z === Math.floor(q[2] * inverse)
313
+ )
314
+ return lookup.home;
315
+ if (
316
+ x === Math.floor(b[0] * inverse) &&
317
+ y === Math.floor(b[1] * inverse) &&
318
+ z === Math.floor(b[2] * inverse)
319
+ )
320
+ return lookup.near;
321
+ return NONE;
322
+ }
323
+
324
+ // One thread's share of the rounds.
325
+ class Task {
326
+ constructor(shared) {
327
+ Object.assign(this, shared);
328
+ this.version = -1;
329
+ this.query = new Float64Array(14);
330
+ this.other = new Float64Array(14);
331
+ this.merged = new Float64Array(16);
332
+ this.tuple = new Float64Array(13);
333
+ this.merger = new Merger();
334
+ this.similarities = new Similarities();
335
+ // For the columns of searches: entries, slots and gone.
336
+ this.parts = [Float32Array, Uint32Array, Uint32Array].map(
337
+ (Type) => new Parts(Type),
338
+ );
339
+ this.views = new ArenaViews();
340
+ }
341
+
342
+ use(state) {
343
+ this.version = state.version;
344
+ this.views.use(state.arenas, state.generation);
345
+ const views = this.views;
346
+ if (state.entries) {
347
+ const [entries, slots, gone] = [
348
+ [state.entries, Float32Array],
349
+ [state.slots, Uint32Array],
350
+ [state.gone, Uint32Array],
351
+ ].map(
352
+ ([directory, Type], i) =>
353
+ new SharedColumn(
354
+ views,
355
+ directory,
356
+ Type,
357
+ 0,
358
+ state.files[i],
359
+ this.parts[i],
360
+ ),
361
+ );
362
+ this.lookup = new Lookup(
363
+ entries,
364
+ slots,
365
+ gone,
366
+ state.capacity,
367
+ state.step,
368
+ );
369
+ }
370
+ if (state.column)
371
+ this.column = new SharedColumn(views, state.column, Uint16Array);
372
+ if (state.splatRecords) {
373
+ this.splatRecords = new SharedColumn(
374
+ views,
375
+ state.splatRecords,
376
+ Float32Array,
377
+ );
378
+ this.splatSh = state.splatSh.map(
379
+ (directory) => new SharedColumn(views, directory, Uint16Array),
380
+ );
381
+ }
382
+ if (state.source) {
383
+ const Type = TYPES[state.type];
384
+ this.source = new SharedColumn(views, state.source, Type);
385
+ this.output = new SharedColumn(views, state.output, Type);
386
+ }
387
+ }
388
+
389
+ // Forgets what searches read from files: pages change between jobs.
390
+ begin() {
391
+ for (const parts of this.parts) parts.reset();
392
+ }
393
+
394
+ // Claims chunks of the round `gen` until none are left.
395
+ work(gen) {
396
+ const control = this.control;
397
+ for (;;) {
398
+ if (this.cancelled && Atomics.load(this.cancelled, 0)) return;
399
+ const next = Atomics.load(control, NEXT);
400
+ if (next >>> 16 !== (gen & 0x7fff)) return;
401
+ const start = next & 0xffff;
402
+ if (start >= Atomics.load(control, COUNT)) return;
403
+ const size = Atomics.load(control, SIZE);
404
+ if (Atomics.compareExchange(control, NEXT, next, next + size) !== next)
405
+ continue;
406
+ const count = Atomics.load(control, COUNT);
407
+ const end = Math.min(start + size, count);
408
+ const job = Atomics.load(control, JOB);
409
+ if (job === SEARCH)
410
+ for (let t = start; t < end; t++) this.results[t] = this.search(t);
411
+ else if (job === BLEND)
412
+ this.blend(start, end, Atomics.load(control, DIMS));
413
+ else if (job === COPY) this.copy(start, end);
414
+ else {
415
+ const base = Atomics.load(control, BASE);
416
+ for (let i = start; i < end; i++)
417
+ this.results[i] = this.check(base + i);
418
+ }
419
+ // Before the chunk counts as done, so the main thread finds it.
420
+ let bytes = 0;
421
+ for (const parts of this.parts) {
422
+ bytes += parts.bytesRead;
423
+ parts.bytesRead = 0;
424
+ }
425
+ if (bytes) Atomics.add(this.read, 0, BigInt(bytes));
426
+ if (Atomics.add(control, DONE, end - start) + end - start === count)
427
+ Atomics.notify(control, DONE);
428
+ }
429
+ }
430
+
431
+ check(index) {
432
+ const { splatRecords: records, merged: record } = this;
433
+ records.missed = false;
434
+ const values = records.page(index * 16);
435
+ const offset = records.offset(index * 16);
436
+ if (records.missed) return LATER;
437
+ for (let d = 0; d < 16; d++) record[d] = values[offset + d];
438
+ refresh(record);
439
+ values[offset + 14] = record[14];
440
+ values[offset + 15] = record[15];
441
+ if (!validRecord(record)) return INVALID;
442
+ for (let band = 0; band < this.splatSh.length; band++) {
443
+ const dims = SH_DIMS[band];
444
+ const column = this.splatSh[band];
445
+ column.missed = false;
446
+ const row = column.page(index * dims);
447
+ const at = column.offset(index * dims);
448
+ if (column.missed || ((index * dims) & column.mask) > column.items - dims)
449
+ return LATER;
450
+ for (let d = 0; d < dims; d++)
451
+ if (!Number.isFinite(HALF[row[at + d]])) return INVALID;
452
+ }
453
+ return record[3] > 0 && record[14] > 0 ? KEEP : EMPTY;
454
+ }
455
+
456
+ copy(from, to) {
457
+ const { source, output, nodes, later, control } = this;
458
+ const stride = Atomics.load(control, STRIDE);
459
+ const base = Atomics.load(control, BASE);
460
+ const start = Atomics.load(control, START);
461
+ const end = Atomics.load(control, END);
462
+ const last = source.items - stride;
463
+ for (let i = from; i < to; i++) {
464
+ later[i] = 0;
465
+ const target = nodes[i] - 1;
466
+ if (target < start || target >= end) continue;
467
+ const s = (base + i) * stride;
468
+ const t = target * stride;
469
+ source.missed = false;
470
+ output.missed = false;
471
+ const values = source.page(s);
472
+ const offset = source.offset(s);
473
+ const out = output.page(t);
474
+ const at = output.offset(t);
475
+ if (
476
+ source.missed ||
477
+ output.missed ||
478
+ (s & source.mask) > last ||
479
+ (t & output.mask) > last
480
+ ) {
481
+ later[i] = 1;
482
+ continue;
483
+ }
484
+ for (let d = 0; d < stride; d++) out[at + d] = values[offset + d];
485
+ }
486
+ }
487
+
488
+ blend(start, end, dims) {
489
+ const { column, nodes, entries, blends, later, blended, results } = this;
490
+ const last = column.items - dims;
491
+ for (let i = start; i < end; i++) {
492
+ if (later[i]) continue;
493
+ const a = nodes[i] * dims;
494
+ const b = entries[i] * dims;
495
+ column.missed = false;
496
+ const left = column.page(a);
497
+ const l = column.offset(a);
498
+ const right = column.page(b);
499
+ const r = column.offset(b);
500
+ // A row across two pages is left to the main thread.
501
+ if (
502
+ column.missed ||
503
+ (a & column.mask) > last ||
504
+ (b & column.mask) > last
505
+ ) {
506
+ results[i] = MISS;
507
+ continue;
508
+ }
509
+ results[i] = 0;
510
+ const w = blends[i * 2];
511
+ const v = blends[i * 2 + 1];
512
+ for (let d = 0; d < dims; d++)
513
+ blended[i * dims + d] = halfBits(
514
+ HALF[left[l + d]] * w + HALF[right[r + d]] * v,
515
+ );
516
+ }
517
+ }
518
+
519
+ search(t) {
520
+ const { lookup, query, other, merged } = this;
521
+ lookup.read(this.entries[t], query);
522
+ const best = lookup.find(query, this.nodes[t]);
523
+ this.metrics[t] = lookup.metric;
524
+ this.queries.set(query, t * 14);
525
+ if (best === NONE) return best;
526
+ this.partners[t] = lookup.read(best, other);
527
+ try {
528
+ this.merger.run(query, other, query[13], other[13], merged);
529
+ } catch {
530
+ // The main thread merges again and reports the error.
531
+ return MISS;
532
+ }
533
+ this.records.set(merged, t * 16);
534
+ this.hints[t] = hint(lookup, merged, query, other);
535
+ this.terms.set(this.similarities.terms(merged, this.tuple), t * 13);
536
+ this.blends[t * 2] = this.merger.w;
537
+ this.blends[t * 2 + 1] = this.merger.v;
538
+ return best;
539
+ }
540
+ }
541
+
542
+ if (!isMainThread && workerData?.control) {
543
+ const task = new Task(workerData);
544
+ const control = task.control;
545
+ for (let seen = 0; ; ) {
546
+ Atomics.wait(control, GEN, seen);
547
+ const gen = Atomics.load(control, GEN);
548
+ if (gen < 0) break;
549
+ seen = gen;
550
+ try {
551
+ const version = Atomics.load(control, VERSION);
552
+ while (task.version < version) {
553
+ const received = receiveMessageOnPort(parentPort);
554
+ if (received) task.use(received.message);
555
+ }
556
+ task.begin();
557
+ if (Atomics.load(control, JOB) === COLLECT) {
558
+ collect();
559
+ Atomics.add(control, COLLECTED, 1);
560
+ Atomics.notify(control, COLLECTED);
561
+ continue;
562
+ }
563
+ task.work(gen);
564
+ } catch (error) {
565
+ // Report the error and release the round, so the main thread throws it.
566
+ // The thread ends, and its views with it, so it counts as collected.
567
+ const message = new TextEncoder().encode(String(error?.stack ?? error));
568
+ const length = Math.min(message.length, task.failure.length - 1);
569
+ task.failure.set(message.subarray(0, length));
570
+ Atomics.store(control, FAILED, length + 1);
571
+ Atomics.store(control, DONE, Atomics.load(control, COUNT));
572
+ Atomics.notify(control, DONE);
573
+ Atomics.add(control, COLLECTED, 1);
574
+ Atomics.notify(control, COLLECTED);
575
+ break;
576
+ }
577
+ }
578
+ }
@@ -0,0 +1,50 @@
1
+ import { parentPort } from "node:worker_threads";
2
+
3
+ let inline = false;
4
+
5
+ // Call update at batch boundaries, not for every splat. A completed stage is
6
+ // the only place that reports 100%; rounding cannot finish a stage early.
7
+ export class Progress {
8
+ // Keep synchronous setup steps on the same line as the stage that follows.
9
+ static inline(run) {
10
+ inline = true;
11
+ try {
12
+ return run();
13
+ } finally {
14
+ inline = false;
15
+ }
16
+ }
17
+
18
+ constructor(label, total, detail = "") {
19
+ this.label = label;
20
+ this.total = total;
21
+ this.started = performance.now();
22
+ this.updated = this.started;
23
+ this.print(0, false, detail);
24
+ }
25
+
26
+ update(completed, detail = "") {
27
+ const now = performance.now();
28
+ if (now - this.updated < 500) return;
29
+ this.updated = now;
30
+ const percent = this.total
31
+ ? Math.min(99.9, Math.floor((completed * 1000) / this.total) / 10)
32
+ : 0;
33
+ this.print(percent, false, detail);
34
+ }
35
+
36
+ finish(detail = "") {
37
+ this.print(100, true, detail);
38
+ }
39
+
40
+ print(percent, done, detail) {
41
+ const seconds = ((performance.now() - this.started) / 1000).toFixed(1);
42
+ const line = `${this.label}: ${percent.toFixed(1)}%${detail ? `, ${detail}` : ""} (${seconds}s)`;
43
+ if (parentPort)
44
+ parentPort.postMessage({
45
+ method: "progress",
46
+ args: [line, done && !inline],
47
+ });
48
+ else console.log(line);
49
+ }
50
+ }