@graphty/webgpu-graph-algorithms 0.0.0 → 0.2.0
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/README.md +344 -23
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +32 -0
- package/dist/browser.js.map +1 -0
- package/dist/chunks/context-E6iKaeuJ.js +3136 -0
- package/dist/chunks/context-E6iKaeuJ.js.map +1 -0
- package/dist/node.d.ts +1 -0
- package/dist/node.js +131 -0
- package/dist/node.js.map +1 -0
- package/dist/src/accelerator.d.ts +26 -0
- package/dist/src/accelerator.d.ts.map +1 -0
- package/dist/src/accelerator.js +101 -0
- package/dist/src/accelerator.js.map +1 -0
- package/dist/src/algorithms/degree.d.ts +35 -0
- package/dist/src/algorithms/degree.d.ts.map +1 -0
- package/dist/src/algorithms/degree.js +119 -0
- package/dist/src/algorithms/degree.js.map +1 -0
- package/dist/src/browser/index.d.ts +23 -0
- package/dist/src/browser/index.d.ts.map +1 -0
- package/dist/src/browser/index.js +48 -0
- package/dist/src/browser/index.js.map +1 -0
- package/dist/src/constants.d.ts +92 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +92 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/context.d.ts +84 -0
- package/dist/src/context.d.ts.map +1 -0
- package/dist/src/context.js +304 -0
- package/dist/src/context.js.map +1 -0
- package/dist/src/device/acquire.d.ts +57 -0
- package/dist/src/device/acquire.d.ts.map +1 -0
- package/dist/src/device/acquire.js +232 -0
- package/dist/src/device/acquire.js.map +1 -0
- package/dist/src/device/caps.d.ts +43 -0
- package/dist/src/device/caps.d.ts.map +1 -0
- package/dist/src/device/caps.js +104 -0
- package/dist/src/device/caps.js.map +1 -0
- package/dist/src/device/error-scope.d.ts +75 -0
- package/dist/src/device/error-scope.d.ts.map +1 -0
- package/dist/src/device/error-scope.js +152 -0
- package/dist/src/device/error-scope.js.map +1 -0
- package/dist/src/device/lost.d.ts +51 -0
- package/dist/src/device/lost.d.ts.map +1 -0
- package/dist/src/device/lost.js +130 -0
- package/dist/src/device/lost.js.map +1 -0
- package/dist/src/device/webgpu-constants.d.ts +31 -0
- package/dist/src/device/webgpu-constants.d.ts.map +1 -0
- package/dist/src/device/webgpu-constants.js +31 -0
- package/dist/src/device/webgpu-constants.js.map +1 -0
- package/dist/src/errors.d.ts +56 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +57 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/index.d.ts +29 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +27 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/kernel/batch.d.ts +116 -0
- package/dist/src/kernel/batch.d.ts.map +1 -0
- package/dist/src/kernel/batch.js +335 -0
- package/dist/src/kernel/batch.js.map +1 -0
- package/dist/src/kernel/dispatch.d.ts +59 -0
- package/dist/src/kernel/dispatch.d.ts.map +1 -0
- package/dist/src/kernel/dispatch.js +139 -0
- package/dist/src/kernel/dispatch.js.map +1 -0
- package/dist/src/kernel/kernel.d.ts +84 -0
- package/dist/src/kernel/kernel.d.ts.map +1 -0
- package/dist/src/kernel/kernel.js +239 -0
- package/dist/src/kernel/kernel.js.map +1 -0
- package/dist/src/kernel/pipeline-cache.d.ts +90 -0
- package/dist/src/kernel/pipeline-cache.d.ts.map +1 -0
- package/dist/src/kernel/pipeline-cache.js +251 -0
- package/dist/src/kernel/pipeline-cache.js.map +1 -0
- package/dist/src/kernel/prelude.d.ts +35 -0
- package/dist/src/kernel/prelude.d.ts.map +1 -0
- package/dist/src/kernel/prelude.js +211 -0
- package/dist/src/kernel/prelude.js.map +1 -0
- package/dist/src/kernel/profiler.d.ts +64 -0
- package/dist/src/kernel/profiler.d.ts.map +1 -0
- package/dist/src/kernel/profiler.js +120 -0
- package/dist/src/kernel/profiler.js.map +1 -0
- package/dist/src/kernel/struct-block.d.ts +122 -0
- package/dist/src/kernel/struct-block.d.ts.map +1 -0
- package/dist/src/kernel/struct-block.js +353 -0
- package/dist/src/kernel/struct-block.js.map +1 -0
- package/dist/src/kernel/uniform-ring.d.ts +70 -0
- package/dist/src/kernel/uniform-ring.d.ts.map +1 -0
- package/dist/src/kernel/uniform-ring.js +146 -0
- package/dist/src/kernel/uniform-ring.js.map +1 -0
- package/dist/src/kernel/wgsl.d.ts +88 -0
- package/dist/src/kernel/wgsl.d.ts.map +1 -0
- package/dist/src/kernel/wgsl.js +390 -0
- package/dist/src/kernel/wgsl.js.map +1 -0
- package/dist/src/kernels.d.ts +81 -0
- package/dist/src/kernels.d.ts.map +1 -0
- package/dist/src/kernels.js +417 -0
- package/dist/src/kernels.js.map +1 -0
- package/dist/src/layouts/force-simulation.d.ts +498 -0
- package/dist/src/layouts/force-simulation.d.ts.map +1 -0
- package/dist/src/layouts/force-simulation.js +1650 -0
- package/dist/src/layouts/force-simulation.js.map +1 -0
- package/dist/src/layouts/forceatlas2.d.ts +210 -0
- package/dist/src/layouts/forceatlas2.d.ts.map +1 -0
- package/dist/src/layouts/forceatlas2.js +759 -0
- package/dist/src/layouts/forceatlas2.js.map +1 -0
- package/dist/src/layouts/inputs.d.ts +40 -0
- package/dist/src/layouts/inputs.d.ts.map +1 -0
- package/dist/src/layouts/inputs.js +185 -0
- package/dist/src/layouts/inputs.js.map +1 -0
- package/dist/src/layouts/repulsion-exact.d.ts +85 -0
- package/dist/src/layouts/repulsion-exact.d.ts.map +1 -0
- package/dist/src/layouts/repulsion-exact.js +134 -0
- package/dist/src/layouts/repulsion-exact.js.map +1 -0
- package/dist/src/layouts/seed.d.ts +56 -0
- package/dist/src/layouts/seed.d.ts.map +1 -0
- package/dist/src/layouts/seed.js +173 -0
- package/dist/src/layouts/seed.js.map +1 -0
- package/dist/src/memory/buffer-pool.d.ts +73 -0
- package/dist/src/memory/buffer-pool.d.ts.map +1 -0
- package/dist/src/memory/buffer-pool.js +170 -0
- package/dist/src/memory/buffer-pool.js.map +1 -0
- package/dist/src/memory/lease.d.ts +53 -0
- package/dist/src/memory/lease.d.ts.map +1 -0
- package/dist/src/memory/lease.js +85 -0
- package/dist/src/memory/lease.js.map +1 -0
- package/dist/src/memory/readback.d.ts +143 -0
- package/dist/src/memory/readback.d.ts.map +1 -0
- package/dist/src/memory/readback.js +375 -0
- package/dist/src/memory/readback.js.map +1 -0
- package/dist/src/memory/residency.d.ts +83 -0
- package/dist/src/memory/residency.d.ts.map +1 -0
- package/dist/src/memory/residency.js +573 -0
- package/dist/src/memory/residency.js.map +1 -0
- package/dist/src/memory/upload-plan.d.ts +101 -0
- package/dist/src/memory/upload-plan.d.ts.map +1 -0
- package/dist/src/memory/upload-plan.js +265 -0
- package/dist/src/memory/upload-plan.js.map +1 -0
- package/dist/src/node/index.d.ts +64 -0
- package/dist/src/node/index.d.ts.map +1 -0
- package/dist/src/node/index.js +183 -0
- package/dist/src/node/index.js.map +1 -0
- package/dist/src/primitives/reduce.d.ts +57 -0
- package/dist/src/primitives/reduce.d.ts.map +1 -0
- package/dist/src/primitives/reduce.js +161 -0
- package/dist/src/primitives/reduce.js.map +1 -0
- package/dist/src/primitives/segmented-reduce.d.ts +38 -0
- package/dist/src/primitives/segmented-reduce.d.ts.map +1 -0
- package/dist/src/primitives/segmented-reduce.js +211 -0
- package/dist/src/primitives/segmented-reduce.js.map +1 -0
- package/dist/src/types/accelerator.d.ts +209 -0
- package/dist/src/types/accelerator.d.ts.map +1 -0
- package/dist/src/types/accelerator.js +8 -0
- package/dist/src/types/accelerator.js.map +1 -0
- package/dist/src/types/context.d.ts +114 -0
- package/dist/src/types/context.d.ts.map +1 -0
- package/dist/src/types/context.js +7 -0
- package/dist/src/types/context.js.map +1 -0
- package/dist/src/types/layout.d.ts +95 -0
- package/dist/src/types/layout.d.ts.map +1 -0
- package/dist/src/types/layout.js +6 -0
- package/dist/src/types/layout.js.map +1 -0
- package/dist/src/types/memory.d.ts +22 -0
- package/dist/src/types/memory.d.ts.map +1 -0
- package/dist/src/types/memory.js +7 -0
- package/dist/src/types/memory.js.map +1 -0
- package/dist/src/types/options.d.ts +78 -0
- package/dist/src/types/options.d.ts.map +1 -0
- package/dist/src/types/options.js +7 -0
- package/dist/src/types/options.js.map +1 -0
- package/dist/src/types/run.d.ts +13 -0
- package/dist/src/types/run.d.ts.map +1 -0
- package/dist/src/types/run.js +6 -0
- package/dist/src/types/run.js.map +1 -0
- package/dist/src/wgsl/degree.wgsl.d.ts +10 -0
- package/dist/src/wgsl/degree.wgsl.d.ts.map +1 -0
- package/dist/src/wgsl/degree.wgsl.js +25 -0
- package/dist/src/wgsl/degree.wgsl.js.map +1 -0
- package/dist/src/wgsl/fa2-attraction.wgsl.d.ts +12 -0
- package/dist/src/wgsl/fa2-attraction.wgsl.d.ts.map +1 -0
- package/dist/src/wgsl/fa2-attraction.wgsl.js +37 -0
- package/dist/src/wgsl/fa2-attraction.wgsl.js.map +1 -0
- package/dist/src/wgsl/fa2-integrate.wgsl.d.ts +13 -0
- package/dist/src/wgsl/fa2-integrate.wgsl.d.ts.map +1 -0
- package/dist/src/wgsl/fa2-integrate.wgsl.js +69 -0
- package/dist/src/wgsl/fa2-integrate.wgsl.js.map +1 -0
- package/dist/src/wgsl/fa2-repulsion-exact.wgsl.d.ts +12 -0
- package/dist/src/wgsl/fa2-repulsion-exact.wgsl.d.ts.map +1 -0
- package/dist/src/wgsl/fa2-repulsion-exact.wgsl.js +79 -0
- package/dist/src/wgsl/fa2-repulsion-exact.wgsl.js.map +1 -0
- package/dist/src/wgsl/fa2-speed-finalize.wgsl.d.ts +15 -0
- package/dist/src/wgsl/fa2-speed-finalize.wgsl.d.ts.map +1 -0
- package/dist/src/wgsl/fa2-speed-finalize.wgsl.js +54 -0
- package/dist/src/wgsl/fa2-speed-finalize.wgsl.js.map +1 -0
- package/dist/src/wgsl/fa2-stats-finalize.wgsl.d.ts +14 -0
- package/dist/src/wgsl/fa2-stats-finalize.wgsl.d.ts.map +1 -0
- package/dist/src/wgsl/fa2-stats-finalize.wgsl.js +57 -0
- package/dist/src/wgsl/fa2-stats-finalize.wgsl.js.map +1 -0
- package/dist/src/wgsl/fa2-to-scene.wgsl.d.ts +11 -0
- package/dist/src/wgsl/fa2-to-scene.wgsl.d.ts.map +1 -0
- package/dist/src/wgsl/fa2-to-scene.wgsl.js +19 -0
- package/dist/src/wgsl/fa2-to-scene.wgsl.js.map +1 -0
- package/dist/src/wgsl/fill.wgsl.d.ts +7 -0
- package/dist/src/wgsl/fill.wgsl.d.ts.map +1 -0
- package/dist/src/wgsl/fill.wgsl.js +14 -0
- package/dist/src/wgsl/fill.wgsl.js.map +1 -0
- package/dist/src/wgsl/reduce.wgsl.d.ts +10 -0
- package/dist/src/wgsl/reduce.wgsl.d.ts.map +1 -0
- package/dist/src/wgsl/reduce.wgsl.js +63 -0
- package/dist/src/wgsl/reduce.wgsl.js.map +1 -0
- package/dist/src/wgsl/segmented-reduce.wgsl.d.ts +13 -0
- package/dist/src/wgsl/segmented-reduce.wgsl.d.ts.map +1 -0
- package/dist/src/wgsl/segmented-reduce.wgsl.js +35 -0
- package/dist/src/wgsl/segmented-reduce.wgsl.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/dist/webgpu-graph-algorithms.d.ts +1 -0
- package/dist/webgpu-graph-algorithms.js +4454 -0
- package/dist/webgpu-graph-algorithms.js.map +1 -0
- package/package.json +108 -17
- package/src/accelerator.ts +117 -0
- package/src/algorithms/degree.ts +142 -0
- package/src/browser/index.ts +57 -0
- package/src/constants.ts +116 -0
- package/src/context.ts +399 -0
- package/src/device/acquire.ts +256 -0
- package/src/device/caps.ts +122 -0
- package/src/device/error-scope.ts +171 -0
- package/src/device/lost.ts +142 -0
- package/src/device/webgpu-constants.ts +44 -0
- package/src/errors.ts +94 -0
- package/src/index.ts +102 -0
- package/src/kernel/batch.ts +427 -0
- package/src/kernel/dispatch.ts +162 -0
- package/src/kernel/kernel.ts +311 -0
- package/src/kernel/pipeline-cache.ts +288 -0
- package/src/kernel/prelude.ts +229 -0
- package/src/kernel/profiler.ts +148 -0
- package/src/kernel/struct-block.ts +439 -0
- package/src/kernel/uniform-ring.ts +184 -0
- package/src/kernel/wgsl.ts +490 -0
- package/src/kernels.ts +511 -0
- package/src/layouts/force-simulation.ts +2111 -0
- package/src/layouts/forceatlas2.ts +942 -0
- package/src/layouts/inputs.ts +252 -0
- package/src/layouts/repulsion-exact.ts +183 -0
- package/src/layouts/seed.ts +198 -0
- package/src/memory/buffer-pool.ts +204 -0
- package/src/memory/lease.ts +93 -0
- package/src/memory/readback.ts +429 -0
- package/src/memory/residency.ts +753 -0
- package/src/memory/upload-plan.ts +350 -0
- package/src/node/index.ts +230 -0
- package/src/primitives/reduce.ts +233 -0
- package/src/primitives/segmented-reduce.ts +270 -0
- package/src/types/accelerator.ts +236 -0
- package/src/types/context.ts +135 -0
- package/src/types/layout.ts +103 -0
- package/src/types/memory.ts +23 -0
- package/src/types/options.ts +84 -0
- package/src/types/run.ts +13 -0
- package/src/wgsl/degree.wgsl.ts +24 -0
- package/src/wgsl/fa2-attraction.wgsl.ts +37 -0
- package/src/wgsl/fa2-integrate.wgsl.ts +69 -0
- package/src/wgsl/fa2-repulsion-exact.wgsl.ts +78 -0
- package/src/wgsl/fa2-speed-finalize.wgsl.ts +53 -0
- package/src/wgsl/fa2-stats-finalize.wgsl.ts +57 -0
- package/src/wgsl/fa2-to-scene.wgsl.ts +19 -0
- package/src/wgsl/fill.wgsl.ts +13 -0
- package/src/wgsl/reduce.wgsl.ts +62 -0
- package/src/wgsl/segmented-reduce.wgsl.ts +35 -0
|
@@ -0,0 +1,1650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared layout state machine (spec 7.19): the buffers every model shares (positions, scenePositions, fixed,
|
|
3
|
+
* partials, state + trace), the in-flight batches and their readbacks, the settle window, the fixed mask and the
|
|
4
|
+
* setPosition override list (spec 7.12), the trace and the batch driver; consumes a ForceModel by composition (the
|
|
5
|
+
* per-model kernel sequence, buffers, overrides, per-iteration params, controller hooks and stats decoding).
|
|
6
|
+
* Positions are layout units on the device and scene units in the owner's array (spec 7.18): load() repacks
|
|
7
|
+
* (scene - center) / scale into vec4f with the mass in .w (D23); the model's toScene stage, recorded once per batch
|
|
8
|
+
* on the last iteration (PLAN DECISION 2), writes p * scale + center back into scenePositions.
|
|
9
|
+
*/
|
|
10
|
+
import { makeMask, maskTest } from "@graphty/graph-format";
|
|
11
|
+
import { FA2_DEFAULTS, FA2_FLAG_FIRST, MAX_1D_ITEMS, MAX_ITERATIONS_PER_STEP, PARTIAL_BYTES, STATE_HEADER_BYTES, UNIFORM_SLOT_BYTES, } from "../constants.js";
|
|
12
|
+
import { BufferUsage } from "../device/webgpu-constants.js";
|
|
13
|
+
import { hasErrorCode, WebGpuGraphError } from "../errors.js";
|
|
14
|
+
import { CommandBatch } from "../kernel/batch.js";
|
|
15
|
+
import { UniformRing } from "../kernel/uniform-ring.js";
|
|
16
|
+
import { graphOverrides } from "../kernels.js";
|
|
17
|
+
import { seedPositions } from "./seed.js";
|
|
18
|
+
// ============================================================ module-private helpers
|
|
19
|
+
/** The shared params fields (name, width) every model params block must declare (PLAN DECISION 4). */
|
|
20
|
+
const SHARED_PARAM_FIELDS = [
|
|
21
|
+
["n", 1],
|
|
22
|
+
["dim", 1],
|
|
23
|
+
["flags", 1],
|
|
24
|
+
["iterationIndex", 1],
|
|
25
|
+
["seed", 1],
|
|
26
|
+
["scale", 1],
|
|
27
|
+
["center", 4],
|
|
28
|
+
["settleThreshold", 1],
|
|
29
|
+
];
|
|
30
|
+
/** The state fields (name, width) the simulation writes at load() and reads after every batch (PLAN DECISION 4). */
|
|
31
|
+
const SHARED_STATE_FIELDS = [
|
|
32
|
+
["centroid", 4],
|
|
33
|
+
["min", 4],
|
|
34
|
+
["max", 4],
|
|
35
|
+
["rmsRadius", 1],
|
|
36
|
+
["radius", 1],
|
|
37
|
+
["meanDisplacement", 1],
|
|
38
|
+
["iteration", 1],
|
|
39
|
+
["settledCount", 1],
|
|
40
|
+
];
|
|
41
|
+
/** The names of the shared buffers a BufferSpec may not reuse. */
|
|
42
|
+
const SHARED_BUFFER_NAMES = ["positions", "scenePositions", "fixed", "partials", "state", "trace"];
|
|
43
|
+
/** The per-batch epilogue stage: iterations 0..k-2 stop after the stage that precedes it (PLAN DECISION 2). */
|
|
44
|
+
const EPILOGUE_STAGE = "toScene";
|
|
45
|
+
let simulationCounter = 0;
|
|
46
|
+
/**
|
|
47
|
+
* An E_INVALID_ARGUMENT with the documented details shape.
|
|
48
|
+
* @param argument - the argument name
|
|
49
|
+
* @param value - the offending value
|
|
50
|
+
* @param expected - what was expected
|
|
51
|
+
* @param message - the message
|
|
52
|
+
* @returns the error
|
|
53
|
+
*/
|
|
54
|
+
function invalidArgument(argument, value, expected, message) {
|
|
55
|
+
return new WebGpuGraphError("E_INVALID_ARGUMENT", message, { argument, value, expected });
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The E_DISPOSED of a simulation.
|
|
59
|
+
* @param label - the simulation label
|
|
60
|
+
* @returns the error
|
|
61
|
+
*/
|
|
62
|
+
function disposedError(label) {
|
|
63
|
+
return new WebGpuGraphError("E_DISPOSED", `${label} is disposed`, { label });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Coerces a caught value to an Error for Promise rejections.
|
|
67
|
+
* @param err - the caught value
|
|
68
|
+
* @returns the value when it is an Error, else a wrapping Error
|
|
69
|
+
*/
|
|
70
|
+
function asError(err) {
|
|
71
|
+
if (err instanceof Error) {
|
|
72
|
+
return err;
|
|
73
|
+
}
|
|
74
|
+
return new Error(typeof err === "string" ? err : "unknown error");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Reads a field of an option record the generic type does not name (maxIter, iterations, nodeSize).
|
|
78
|
+
* @param record - the options or patch
|
|
79
|
+
* @param key - the field name
|
|
80
|
+
* @returns the value, or undefined
|
|
81
|
+
*/
|
|
82
|
+
function optionField(record, key) {
|
|
83
|
+
return record[key];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The number of scalars of a uniform field type.
|
|
87
|
+
* @param type - the field type
|
|
88
|
+
* @returns 1, 2 or 4
|
|
89
|
+
*/
|
|
90
|
+
function fieldWidth(type) {
|
|
91
|
+
switch (type) {
|
|
92
|
+
case "u32":
|
|
93
|
+
case "i32":
|
|
94
|
+
case "f32":
|
|
95
|
+
return 1;
|
|
96
|
+
case "vec2f":
|
|
97
|
+
case "vec2u":
|
|
98
|
+
return 2;
|
|
99
|
+
case "vec4f":
|
|
100
|
+
case "vec4u":
|
|
101
|
+
return 4;
|
|
102
|
+
default:
|
|
103
|
+
throw invalidArgument("type", type, "a UniformFieldType", "unknown uniform field type");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The type of a block field, or null when the block does not declare it.
|
|
108
|
+
* @param block - the block
|
|
109
|
+
* @param field - the field name
|
|
110
|
+
* @returns the type or null
|
|
111
|
+
*/
|
|
112
|
+
function fieldTypeOrNull(block, field) {
|
|
113
|
+
for (const [name, type] of block.fields) {
|
|
114
|
+
if (name === field) {
|
|
115
|
+
return type;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The type of a block field.
|
|
122
|
+
* @param block - the block
|
|
123
|
+
* @param field - the field name
|
|
124
|
+
* @returns the type; E_INVALID_ARGUMENT when the block does not declare the field
|
|
125
|
+
*/
|
|
126
|
+
function fieldTypeOf(block, field) {
|
|
127
|
+
const type = fieldTypeOrNull(block, field);
|
|
128
|
+
if (type === null) {
|
|
129
|
+
throw invalidArgument("field", field, block.fields.map((f) => f[0]), `block ${block.name} has no field ${field}`);
|
|
130
|
+
}
|
|
131
|
+
return type;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Asserts a block declares every (name, width) pair (PLAN DECISION 4).
|
|
135
|
+
* @param block - the block
|
|
136
|
+
* @param required - the required fields
|
|
137
|
+
* @param what - "model.params" or "model.state", for the error
|
|
138
|
+
*/
|
|
139
|
+
function requireFields(block, required, what) {
|
|
140
|
+
for (const [field, width] of required) {
|
|
141
|
+
const type = fieldTypeOrNull(block, field);
|
|
142
|
+
if (type === null) {
|
|
143
|
+
throw invalidArgument(what, field, required.map((r) => r[0]), `${what} block ${block.name} must declare ${field}`);
|
|
144
|
+
}
|
|
145
|
+
if (fieldWidth(type) !== width) {
|
|
146
|
+
throw invalidArgument(what, field, `a field of width ${width}`, `${what}.${field} must have width ${width}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A stable string of an override record, for the law-change comparison of setParams.
|
|
152
|
+
* @param record - the overrides
|
|
153
|
+
* @returns the JSON of the sorted entries
|
|
154
|
+
*/
|
|
155
|
+
function stableKey(record) {
|
|
156
|
+
return JSON.stringify(Object.keys(record)
|
|
157
|
+
.sort()
|
|
158
|
+
.map((k) => [k, record[k]]));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The stats record with msPerIteration filled by the simulation (PLAN DECISION 3).
|
|
162
|
+
* @param stats - the model's record
|
|
163
|
+
* @param msPerIteration - the measured value
|
|
164
|
+
* @returns the record
|
|
165
|
+
*/
|
|
166
|
+
function withMs(stats, msPerIteration) {
|
|
167
|
+
return { ...stats, msPerIteration };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* True for a SharedArrayBuffer (the package never writes into one, spec 5.7).
|
|
171
|
+
* @param buffer - the backing buffer
|
|
172
|
+
* @returns true when shared
|
|
173
|
+
*/
|
|
174
|
+
function isSharedBuffer(buffer) {
|
|
175
|
+
return typeof SharedArrayBuffer !== "undefined" && buffer instanceof SharedArrayBuffer;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The validated scale of an option record (FA2_DEFAULTS.scale when absent).
|
|
179
|
+
* @param options - the options
|
|
180
|
+
* @returns the scale
|
|
181
|
+
*/
|
|
182
|
+
function scaleOf(options) {
|
|
183
|
+
const scale = options.scale ?? FA2_DEFAULTS.scale;
|
|
184
|
+
if (!Number.isFinite(scale) || scale <= 0) {
|
|
185
|
+
throw invalidArgument("scale", scale, "a finite number > 0", `scale must be a finite number > 0, got ${scale}`);
|
|
186
|
+
}
|
|
187
|
+
return scale;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The validated center of an option record (missing components are 0).
|
|
191
|
+
* @param options - the options
|
|
192
|
+
* @returns [x, y, z]
|
|
193
|
+
*/
|
|
194
|
+
function centerOf(options) {
|
|
195
|
+
const out = [0, 0, 0];
|
|
196
|
+
const { center } = options;
|
|
197
|
+
if (center === undefined) {
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
for (let axis = 0; axis < 3 && axis < center.length; axis++) {
|
|
201
|
+
const v = center[axis];
|
|
202
|
+
if (!Number.isFinite(v)) {
|
|
203
|
+
throw invalidArgument("center", v, "finite components", `center[${axis}] is not finite`);
|
|
204
|
+
}
|
|
205
|
+
out[axis] = v;
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The repulsion tier of a node count under a tuning (PLAN DECISION 11).
|
|
211
|
+
* @param tuning - the resolved tuning
|
|
212
|
+
* @param n - the node count
|
|
213
|
+
* @returns "exact" or "grid"
|
|
214
|
+
*/
|
|
215
|
+
function tierFor(tuning, n) {
|
|
216
|
+
if (tuning.repulsion === "exact") {
|
|
217
|
+
return "exact";
|
|
218
|
+
}
|
|
219
|
+
if (tuning.repulsion === "grid") {
|
|
220
|
+
return "grid";
|
|
221
|
+
}
|
|
222
|
+
return n <= tuning.exactMaxNodes ? "exact" : "grid";
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* A whole-buffer binding.
|
|
226
|
+
* @param buffer - the buffer
|
|
227
|
+
* @param size - its byte length
|
|
228
|
+
* @returns the binding
|
|
229
|
+
*/
|
|
230
|
+
function wholeBinding(buffer, size) {
|
|
231
|
+
return { buffer, offset: 0, size, window: null };
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The host side of the state header (PLAN DECISION 5): a shadow of every field, a queue of the fields set since
|
|
235
|
+
* the last flush, and the byte image the queued fields are copied from.
|
|
236
|
+
*/
|
|
237
|
+
class HeaderWriter {
|
|
238
|
+
/**
|
|
239
|
+
* Creates a writer over a model's state block with every field 0.
|
|
240
|
+
* @param block - the model's state block
|
|
241
|
+
*/
|
|
242
|
+
constructor(block) {
|
|
243
|
+
this.shadow = new Map();
|
|
244
|
+
this.queued = new Map();
|
|
245
|
+
this.block = block;
|
|
246
|
+
this.reset();
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Queues a field write and applies it to the shadow.
|
|
250
|
+
* @param field - the field name (E_INVALID_ARGUMENT when the block lacks it or the width differs)
|
|
251
|
+
* @param value - a number for a scalar field, an array of the vector's width otherwise
|
|
252
|
+
*/
|
|
253
|
+
set(field, value) {
|
|
254
|
+
const width = fieldWidth(fieldTypeOf(this.block, field));
|
|
255
|
+
const ok = width === 1 ? typeof value === "number" : typeof value !== "number" && value.length === width;
|
|
256
|
+
if (!ok) {
|
|
257
|
+
throw invalidArgument("value", value, `a value of width ${width}`, `state field ${field} takes a value of width ${width}`);
|
|
258
|
+
}
|
|
259
|
+
this.shadow.set(field, value);
|
|
260
|
+
this.queued.set(field, value);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The host shadow of a field: the last value set, or the last landed header's value.
|
|
264
|
+
* @param field - the field name
|
|
265
|
+
* @returns the value
|
|
266
|
+
*/
|
|
267
|
+
get(field) {
|
|
268
|
+
const value = this.shadow.get(field);
|
|
269
|
+
if (value === undefined) {
|
|
270
|
+
throw invalidArgument("field", field, this.block.fields.map((f) => f[0]), `state block has no field ${field}`);
|
|
271
|
+
}
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
/** Every field back to 0 and the queue cleared (load()). */
|
|
275
|
+
reset() {
|
|
276
|
+
this.shadow.clear();
|
|
277
|
+
this.queued.clear();
|
|
278
|
+
const zero = this.block.read(new DataView(new ArrayBuffer(this.block.byteLength)));
|
|
279
|
+
for (const [name, value] of Object.entries(zero)) {
|
|
280
|
+
this.shadow.set(name, value);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The byte image of the shadow (the block's own writer, D20).
|
|
285
|
+
* @returns block.byteLength bytes
|
|
286
|
+
*/
|
|
287
|
+
headerBytes() {
|
|
288
|
+
const bytes = new ArrayBuffer(this.block.byteLength);
|
|
289
|
+
this.block.write(new DataView(bytes), Object.fromEntries(this.shadow));
|
|
290
|
+
return bytes;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Refreshes the shadow from a landed header; a field set since the last flush keeps its queued value.
|
|
294
|
+
* @param view - the landed header
|
|
295
|
+
*/
|
|
296
|
+
absorb(view) {
|
|
297
|
+
for (const [name, value] of Object.entries(this.block.read(view))) {
|
|
298
|
+
if (!this.queued.has(name)) {
|
|
299
|
+
this.shadow.set(name, value);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The fields set since the last flush; clears the queue.
|
|
305
|
+
* @returns the field names
|
|
306
|
+
*/
|
|
307
|
+
takeQueued() {
|
|
308
|
+
const fields = [...this.queued.keys()];
|
|
309
|
+
this.queued.clear();
|
|
310
|
+
return fields;
|
|
311
|
+
}
|
|
312
|
+
/** Drops the queue (after load() wrote the whole header). */
|
|
313
|
+
clearQueued() {
|
|
314
|
+
this.queued.clear();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* A pending record whose promise the simulation settles.
|
|
319
|
+
* @param k - iterations of the batch
|
|
320
|
+
* @param generation - the generation at the step() call
|
|
321
|
+
* @returns the record
|
|
322
|
+
*/
|
|
323
|
+
function createPending(k, generation) {
|
|
324
|
+
let resolveFn = () => undefined;
|
|
325
|
+
let rejectFn = () => undefined;
|
|
326
|
+
const promise = new Promise((resolve, reject) => {
|
|
327
|
+
resolveFn = resolve;
|
|
328
|
+
rejectFn = reject;
|
|
329
|
+
});
|
|
330
|
+
return {
|
|
331
|
+
k,
|
|
332
|
+
generation,
|
|
333
|
+
promise,
|
|
334
|
+
resolve: () => {
|
|
335
|
+
resolveFn();
|
|
336
|
+
},
|
|
337
|
+
reject: (error) => {
|
|
338
|
+
rejectFn(error);
|
|
339
|
+
},
|
|
340
|
+
id: 0,
|
|
341
|
+
submitted: null,
|
|
342
|
+
stale: false,
|
|
343
|
+
startedAt: 0,
|
|
344
|
+
sceneOffset: 0,
|
|
345
|
+
stateOffset: 0,
|
|
346
|
+
profile: null,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// ============================================================ the simulation
|
|
350
|
+
/**
|
|
351
|
+
* The shared layout state machine (spec 7.19): buffers, in-flight batches, readback, settle window, fixed mask,
|
|
352
|
+
* setPosition overrides, trace, batch driver; consumes a ForceModel by composition.
|
|
353
|
+
*/
|
|
354
|
+
export class ForceSimulation {
|
|
355
|
+
/**
|
|
356
|
+
* Creates a simulation over a context and a model (state "created"; load() makes it "loaded").
|
|
357
|
+
* @param ctx - the context (ready)
|
|
358
|
+
* @param model - the force model
|
|
359
|
+
* @param options - the option record (defaults applied by the caller's factory)
|
|
360
|
+
* @param tuning - the resolved GPU tuning
|
|
361
|
+
* @param resolve - how setParams merges a patch into the current record
|
|
362
|
+
*/
|
|
363
|
+
constructor(ctx, model, options, tuning, resolve) {
|
|
364
|
+
this.overrideList = new Map();
|
|
365
|
+
this.pending = [];
|
|
366
|
+
this.stateValue = "created";
|
|
367
|
+
this.generationValue = 0;
|
|
368
|
+
this.snapshot = null;
|
|
369
|
+
this.serial = null;
|
|
370
|
+
this.owner = null;
|
|
371
|
+
this.n = 0;
|
|
372
|
+
this.scale = 1;
|
|
373
|
+
this.center = [0, 0, 0];
|
|
374
|
+
this.buffers = null;
|
|
375
|
+
this.core = null;
|
|
376
|
+
this.resources = null;
|
|
377
|
+
this.weightsUpload = null;
|
|
378
|
+
this.ready = Promise.resolve();
|
|
379
|
+
this.submitChain = Promise.resolve();
|
|
380
|
+
this.fixedWords = new Uint32Array(0);
|
|
381
|
+
this.fixedDirty = false;
|
|
382
|
+
this.iterationsSubmitted = 0;
|
|
383
|
+
this.iterationsDoneValue = 0;
|
|
384
|
+
this.settledCountValue = 0;
|
|
385
|
+
this.settledValue = false;
|
|
386
|
+
this.firstPending = true;
|
|
387
|
+
this.statsValue = null;
|
|
388
|
+
this.lastSubmittedBatchIdValue = 0;
|
|
389
|
+
/** The id of the last batch submitted BEFORE the most recent reheat() (PLAN DECISION 21); 0 = none. */
|
|
390
|
+
this.reheatedAfterBatchId = 0;
|
|
391
|
+
this.coalescedValue = 0;
|
|
392
|
+
this.torndown = false;
|
|
393
|
+
ctx.assertReady();
|
|
394
|
+
requireFields(model.params, SHARED_PARAM_FIELDS, "model.params");
|
|
395
|
+
requireFields(model.state, SHARED_STATE_FIELDS, "model.state");
|
|
396
|
+
if (model.params.byteLength > UNIFORM_SLOT_BYTES) {
|
|
397
|
+
throw invalidArgument("model.params", model.params.byteLength, `<= ${UNIFORM_SLOT_BYTES} bytes`, "the params block must fit one ring slot");
|
|
398
|
+
}
|
|
399
|
+
if (model.state.byteLength > STATE_HEADER_BYTES) {
|
|
400
|
+
throw invalidArgument("model.state", model.state.byteLength, `<= ${STATE_HEADER_BYTES} bytes`, "the state block must fit the state header");
|
|
401
|
+
}
|
|
402
|
+
if (model.stages.length === 0) {
|
|
403
|
+
throw invalidArgument("model.stages", model.stages, "at least one stage name", "a model declares its stages");
|
|
404
|
+
}
|
|
405
|
+
const maxInFlight = options.maxInFlight ?? FA2_DEFAULTS.maxInFlight;
|
|
406
|
+
if (!Number.isInteger(maxInFlight) || maxInFlight < 1) {
|
|
407
|
+
throw invalidArgument("maxInFlight", maxInFlight, "an integer >= 1", `maxInFlight must be an integer >= 1, got ${maxInFlight}`);
|
|
408
|
+
}
|
|
409
|
+
const dim = options.dim ?? FA2_DEFAULTS.dim;
|
|
410
|
+
if (dim !== 2 && dim !== 3) {
|
|
411
|
+
throw invalidArgument("dim", dim, "2 or 3", `dim must be 2 or 3, got ${String(dim)}`);
|
|
412
|
+
}
|
|
413
|
+
// scale and center are validated now so a bad option never reaches load()
|
|
414
|
+
scaleOf(options);
|
|
415
|
+
centerOf(options);
|
|
416
|
+
simulationCounter++;
|
|
417
|
+
this.ctx = ctx;
|
|
418
|
+
this.model = model;
|
|
419
|
+
this.tuning = tuning;
|
|
420
|
+
this.resolveOptions = resolve;
|
|
421
|
+
this.optionsValue = options;
|
|
422
|
+
this.maxInFlight = maxInFlight;
|
|
423
|
+
this.dimValue = dim;
|
|
424
|
+
this.tierValue = tuning.repulsion === "grid" ? "grid" : "exact";
|
|
425
|
+
this.label = `${model.kind}#${simulationCounter}`;
|
|
426
|
+
this.traceBytes = model.trace.byteLength;
|
|
427
|
+
this.writer = new HeaderWriter(model.state);
|
|
428
|
+
this.ring = new UniformRing(ctx.device, ctx.allocator, (maxInFlight + 1) * MAX_ITERATIONS_PER_STEP, `${this.label}/ring`);
|
|
429
|
+
this.unregisterLost = ctx.onLost((info) => {
|
|
430
|
+
this.onDeviceLost(info);
|
|
431
|
+
});
|
|
432
|
+
if (ctx.debug.inspect) {
|
|
433
|
+
this.inspect = (name) => this.inspectBuffer(name);
|
|
434
|
+
this.debugRunStages = (upTo) => this.runStages(upTo);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// ---------------------------------------------------------------- read-only state
|
|
438
|
+
/**
|
|
439
|
+
* "created" | "loaded" | "disposed".
|
|
440
|
+
* @returns the state
|
|
441
|
+
*/
|
|
442
|
+
get state() {
|
|
443
|
+
return this.stateValue;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* The current options record (defaults applied).
|
|
447
|
+
* @returns the record
|
|
448
|
+
*/
|
|
449
|
+
get options() {
|
|
450
|
+
return this.optionsValue;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* The repulsion tier of the current load ("exact" until P4 lifts the grid tier).
|
|
454
|
+
* @returns the tier
|
|
455
|
+
*/
|
|
456
|
+
get tier() {
|
|
457
|
+
return this.tierValue;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The generation counter bumped by every load() (stale readbacks are discarded).
|
|
461
|
+
* @internal
|
|
462
|
+
* @returns the generation
|
|
463
|
+
*/
|
|
464
|
+
get generation() {
|
|
465
|
+
return this.generationValue;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Nodes of the current load (0 before load()).
|
|
469
|
+
* @returns the node count
|
|
470
|
+
*/
|
|
471
|
+
get nodeCount() {
|
|
472
|
+
return this.n;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* 2 or 3, fixed at construction (setParams({ dim }) is rejected, spec 7.13).
|
|
476
|
+
* @returns the dimension
|
|
477
|
+
*/
|
|
478
|
+
get dim() {
|
|
479
|
+
return this.dimValue;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* iterationsDone >= the budget OR settledCount >= settleWindow of the last completed batch (spec 7.17); true for an empty graph.
|
|
483
|
+
* @returns whether the layout is settled
|
|
484
|
+
*/
|
|
485
|
+
get settled() {
|
|
486
|
+
return this.settledValue;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Batches created by step() and not yet landed (PLAN DECISION 7: counted from the step() call).
|
|
490
|
+
* @returns the count
|
|
491
|
+
*/
|
|
492
|
+
get inFlight() {
|
|
493
|
+
return this.pending.length;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Iterations landed since load() or the last reheat().
|
|
497
|
+
* @returns the count
|
|
498
|
+
*/
|
|
499
|
+
get iterationsDone() {
|
|
500
|
+
return this.iterationsDoneValue;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* The stats of the last completed batch; before one lands, the host-written header decoded through the model
|
|
504
|
+
* with an empty trace and msPerIteration null.
|
|
505
|
+
* @returns the stats
|
|
506
|
+
*/
|
|
507
|
+
get stats() {
|
|
508
|
+
if (this.statsValue !== null) {
|
|
509
|
+
return this.statsValue;
|
|
510
|
+
}
|
|
511
|
+
const header = new DataView(this.writer.headerBytes());
|
|
512
|
+
const trace = new DataView(new ArrayBuffer(0));
|
|
513
|
+
return withMs(this.model.readStats(header, trace), null);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* The override list (spec 7.12): rows whose readback is skipped while a batch older than the write is in flight.
|
|
517
|
+
* @internal
|
|
518
|
+
* @returns row -> lastSubmittedBatchId at the write
|
|
519
|
+
*/
|
|
520
|
+
get overrides() {
|
|
521
|
+
return this.overrideList;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* The last submitted batch id.
|
|
525
|
+
* @internal
|
|
526
|
+
* @returns the id (0 before any submit)
|
|
527
|
+
*/
|
|
528
|
+
get lastSubmittedBatchId() {
|
|
529
|
+
return this.lastSubmittedBatchIdValue;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Number of step() calls that returned an existing batch's promise instead of submitting (spec 7.19 item 3;
|
|
533
|
+
* read by test/helpers/frame-loop.ts).
|
|
534
|
+
* @internal
|
|
535
|
+
* @returns the count
|
|
536
|
+
*/
|
|
537
|
+
get coalesced() {
|
|
538
|
+
return this.coalescedValue;
|
|
539
|
+
}
|
|
540
|
+
// ---------------------------------------------------------------- load
|
|
541
|
+
/**
|
|
542
|
+
* Uploads the snapshot's core, seeds the NaN rows of `positions`, repacks it into layout-unit vec4f with the
|
|
543
|
+
* mass in .w, writes the initial state, starts the model's compile + bind and enters "loaded" (spec 7.19; a
|
|
544
|
+
* load() during flight bumps the generation and discards the in-flight batches). Order of the checks (PLAN
|
|
545
|
+
* DECISION 16): E_DISPOSED, the context's assertReady, E_SNAPSHOT (directed), E_TOO_LARGE (nodeCount), the
|
|
546
|
+
* positions array, the tuning, then the core upload and model.inputs() BEFORE any state is touched.
|
|
547
|
+
* PLAN DECISION 10: nodeCount 0 loads with no GPU work at all. PLAN DECISION 11: the tier is resolved here
|
|
548
|
+
* and "grid" is E_UNSUPPORTED until P4. PLAN DECISION 17: a same-size load() keeps the buffers and re-zeroes
|
|
549
|
+
* partials, the trace region and every `zero: true` model buffer.
|
|
550
|
+
* @param snapshot - an undirected snapshot
|
|
551
|
+
* @param positions - the owner's stride-3 scene-unit array (NaN rows are seeded in place)
|
|
552
|
+
*/
|
|
553
|
+
load(snapshot, positions) {
|
|
554
|
+
this.assertNotDisposed();
|
|
555
|
+
this.ctx.assertReady();
|
|
556
|
+
if (snapshot.directed) {
|
|
557
|
+
throw new WebGpuGraphError("E_SNAPSHOT", "a layout needs an undirected snapshot: pass toUndirected().snapshot", {
|
|
558
|
+
reason: "directed",
|
|
559
|
+
serial: snapshot.serial,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
const n = snapshot.nodeCount;
|
|
563
|
+
if (n > MAX_1D_ITEMS) {
|
|
564
|
+
throw new WebGpuGraphError("E_TOO_LARGE", `${n} nodes exceed ${MAX_1D_ITEMS} (the third partials level is P4)`, {
|
|
565
|
+
needed: n,
|
|
566
|
+
limit: MAX_1D_ITEMS,
|
|
567
|
+
path: "partials",
|
|
568
|
+
algorithm: this.model.kind,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
if (positions.length !== 3 * n) {
|
|
572
|
+
throw invalidArgument("positions", positions.length, 3 * n, `positions has ${positions.length} entries, expected ${3 * n}`);
|
|
573
|
+
}
|
|
574
|
+
if (isSharedBuffer(positions.buffer)) {
|
|
575
|
+
throw invalidArgument("positions", "SharedArrayBuffer", "an ArrayBuffer-backed Float32Array", "positions must not be backed by a SharedArrayBuffer");
|
|
576
|
+
}
|
|
577
|
+
const scale = scaleOf(this.optionsValue);
|
|
578
|
+
const center = centerOf(this.optionsValue);
|
|
579
|
+
const tier = tierFor(this.tuning, n);
|
|
580
|
+
if (tier === "grid") {
|
|
581
|
+
throw new WebGpuGraphError("E_UNSUPPORTED", `the grid repulsion tier lands at P4 (n = ${n}, exactMaxNodes = ${this.tuning.exactMaxNodes})`, {
|
|
582
|
+
feature: "repulsion.grid",
|
|
583
|
+
hint: 'pass repulsion: "exact" or raise exactMaxNodes',
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
const positionsBytes = 16 * n;
|
|
587
|
+
if (positionsBytes > this.ctx.caps.limits.maxBufferSize) {
|
|
588
|
+
throw new WebGpuGraphError("E_TOO_LARGE", `${positionsBytes} bytes of positions exceed maxBufferSize`, {
|
|
589
|
+
needed: positionsBytes,
|
|
590
|
+
limit: this.ctx.caps.limits.maxBufferSize,
|
|
591
|
+
path: "positions",
|
|
592
|
+
algorithm: this.model.kind,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
let core = null;
|
|
596
|
+
let inputs = null;
|
|
597
|
+
if (n > 0) {
|
|
598
|
+
core = this.ctx.residency.core(snapshot);
|
|
599
|
+
if (core.plan === "windowed") {
|
|
600
|
+
throw new WebGpuGraphError("E_TOO_LARGE", "a windowed core cannot be walked by the layout kernels until P4", {
|
|
601
|
+
needed: snapshot.arcCount,
|
|
602
|
+
limit: this.ctx.caps.limits.maxStorageBufferBindingSize,
|
|
603
|
+
path: "windowed",
|
|
604
|
+
algorithm: this.model.kind,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
inputs = this.model.inputs(snapshot, this.optionsValue);
|
|
608
|
+
if (inputs.mass.length !== n) {
|
|
609
|
+
throw invalidArgument("nodeMass", inputs.mass.length, n, `the model resolved ${inputs.mass.length} masses for ${n} nodes`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// ---- every check passed: mutate
|
|
613
|
+
this.generationValue++;
|
|
614
|
+
this.discardPending();
|
|
615
|
+
const resized = this.buffers === null || n !== this.n || snapshot.serial !== this.serial;
|
|
616
|
+
this.snapshot = snapshot;
|
|
617
|
+
this.serial = snapshot.serial;
|
|
618
|
+
this.owner = positions;
|
|
619
|
+
this.n = n;
|
|
620
|
+
this.tierValue = tier;
|
|
621
|
+
this.scale = scale;
|
|
622
|
+
this.center = center;
|
|
623
|
+
this.core = core;
|
|
624
|
+
this.iterationsSubmitted = 0;
|
|
625
|
+
this.iterationsDoneValue = 0;
|
|
626
|
+
this.settledCountValue = 0;
|
|
627
|
+
this.firstPending = true;
|
|
628
|
+
this.statsValue = null;
|
|
629
|
+
this.writer.reset();
|
|
630
|
+
if (resized) {
|
|
631
|
+
this.destroyBuffers();
|
|
632
|
+
this.overrideList.clear();
|
|
633
|
+
this.fixedWords = makeMask(n);
|
|
634
|
+
this.fixedDirty = false;
|
|
635
|
+
}
|
|
636
|
+
if (n === 0 || core === null || inputs === null) {
|
|
637
|
+
// PLAN DECISION 10: an empty graph loads with no GPU work
|
|
638
|
+
this.weightsUpload = null;
|
|
639
|
+
this.resources = null;
|
|
640
|
+
// keep the chain (PLAN DECISION 20): a bind of the previous load may still be running and the next
|
|
641
|
+
// non-empty load() must start its bind after it; its outcome is irrelevant here
|
|
642
|
+
this.ready = this.ready.catch(() => undefined);
|
|
643
|
+
this.settledValue = true;
|
|
644
|
+
this.stateValue = "loaded";
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const buffers = resized ? this.allocate(n) : this.requireBuffers();
|
|
648
|
+
if (!resized) {
|
|
649
|
+
this.clearKept(buffers);
|
|
650
|
+
}
|
|
651
|
+
this.buffers = buffers;
|
|
652
|
+
const range = this.model.kind === "fruchtermanReingold" ? "fr" : "fa2";
|
|
653
|
+
seedPositions(snapshot, positions, this.optionsValue.seed ?? null, this.dimValue, scale, center, range);
|
|
654
|
+
this.uploadPositions(buffers, positions, inputs.mass);
|
|
655
|
+
const weights = this.resolveWeightsBinding(snapshot, core, inputs.weights);
|
|
656
|
+
const overrides = { ...this.model.overrides(this.optionsValue), ...graphOverrides(core, null, weights) };
|
|
657
|
+
const resources = this.makeResources(core, weights, buffers);
|
|
658
|
+
this.resources = resources;
|
|
659
|
+
this.startBind(resources, overrides);
|
|
660
|
+
this.settledValue = false;
|
|
661
|
+
this.stateValue = "loaded";
|
|
662
|
+
}
|
|
663
|
+
// ---------------------------------------------------------------- step and the batch driver
|
|
664
|
+
/**
|
|
665
|
+
* Submits k iterations (spec 7.19 items 1-6): "created" -> E_NOT_LOADED; "disposed" -> E_DISPOSED; a released
|
|
666
|
+
* snapshot -> E_RELEASED; k outside [1, MAX_ITERATIONS_PER_STEP] -> E_INVALID_ARGUMENT; settled -> resolves at
|
|
667
|
+
* once; inFlight >= maxInFlight -> the OLDEST pending batch's promise (coalesced); else the batch is queued on
|
|
668
|
+
* the submit chain (after the bind promise and allocator.check()) and its promise resolves when its readback
|
|
669
|
+
* landed in the owner's array. The same promise object is returned for every coalesced call. PLAN DECISION 7:
|
|
670
|
+
* the batch counts in `inFlight` from this call, not from its submission.
|
|
671
|
+
* @param iterations - k (default options.iterationsPerStep, default 1)
|
|
672
|
+
* @returns resolves when the batch landed (or was discarded); rejects E_VALIDATION / E_DEVICE_LOST / E_OUT_OF_MEMORY / E_SHADER_COMPILE
|
|
673
|
+
*/
|
|
674
|
+
step(iterations) {
|
|
675
|
+
if (this.stateValue === "created") {
|
|
676
|
+
return Promise.reject(new WebGpuGraphError("E_NOT_LOADED", `${this.label}: load() first`, { state: "created" }));
|
|
677
|
+
}
|
|
678
|
+
if (this.stateValue === "disposed") {
|
|
679
|
+
return Promise.reject(disposedError(this.label));
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
this.ctx.assertReady();
|
|
683
|
+
}
|
|
684
|
+
catch (err) {
|
|
685
|
+
return Promise.reject(asError(err));
|
|
686
|
+
}
|
|
687
|
+
const { snapshot } = this;
|
|
688
|
+
if (snapshot !== null && this.ctx.residency.isReleased(snapshot.serial)) {
|
|
689
|
+
return Promise.reject(new WebGpuGraphError("E_RELEASED", `snapshot ${snapshot.serial} was released while ${this.label} used it`, {
|
|
690
|
+
serial: snapshot.serial,
|
|
691
|
+
}));
|
|
692
|
+
}
|
|
693
|
+
const k = iterations ?? this.optionsValue.iterationsPerStep ?? FA2_DEFAULTS.iterationsPerStep;
|
|
694
|
+
if (!Number.isInteger(k) || k < 1 || k > MAX_ITERATIONS_PER_STEP) {
|
|
695
|
+
return Promise.reject(invalidArgument("iterations", k, `an integer in [1, ${MAX_ITERATIONS_PER_STEP}]`, `step(${k}): iterations must be an integer in [1, ${MAX_ITERATIONS_PER_STEP}]`));
|
|
696
|
+
}
|
|
697
|
+
if (this.settledValue || this.n === 0) {
|
|
698
|
+
return Promise.resolve();
|
|
699
|
+
}
|
|
700
|
+
if (this.pending.length >= this.maxInFlight) {
|
|
701
|
+
this.coalescedValue++;
|
|
702
|
+
return this.pending[0].promise;
|
|
703
|
+
}
|
|
704
|
+
const record = createPending(k, this.generationValue);
|
|
705
|
+
this.pending.push(record);
|
|
706
|
+
this.submitChain = this.submitChain.then(() => this.submitBatch(record));
|
|
707
|
+
return record.promise;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Resolves when nothing is in flight (every pending batch landed or was discarded).
|
|
711
|
+
* @returns the promise
|
|
712
|
+
*/
|
|
713
|
+
async flush() {
|
|
714
|
+
while (this.pending.length > 0) {
|
|
715
|
+
await Promise.allSettled(this.pending.map((r) => r.promise));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Node batch driver: loops step(batch) until settled, the budget (`maxIter ?? options.maxIter`, PLAN DECISION
|
|
720
|
+
* 1) is reached or the signal aborts (E_ABORTED; the batch in flight at the abort is discarded, spec 5.7 / Q-15).
|
|
721
|
+
* @param options - maxIter, batch (default 8), signal
|
|
722
|
+
* @returns the stats of the last completed batch
|
|
723
|
+
*/
|
|
724
|
+
async run(options) {
|
|
725
|
+
this.assertNotDisposed();
|
|
726
|
+
const batch = options?.batch ?? 8;
|
|
727
|
+
if (!Number.isInteger(batch) || batch < 1 || batch > MAX_ITERATIONS_PER_STEP) {
|
|
728
|
+
throw invalidArgument("batch", batch, `an integer in [1, ${MAX_ITERATIONS_PER_STEP}]`, "run(): batch must be an integer in [1, MAX_ITERATIONS_PER_STEP]");
|
|
729
|
+
}
|
|
730
|
+
const budget = options?.maxIter ?? this.iterationBudget();
|
|
731
|
+
if (Number.isNaN(budget) || budget < 0) {
|
|
732
|
+
throw invalidArgument("maxIter", budget, "a number >= 0", "run(): maxIter must be >= 0");
|
|
733
|
+
}
|
|
734
|
+
const signal = options?.signal;
|
|
735
|
+
const onAbort = () => {
|
|
736
|
+
this.discardPending();
|
|
737
|
+
};
|
|
738
|
+
if (signal !== undefined) {
|
|
739
|
+
if (signal.aborted) {
|
|
740
|
+
throw this.abortedError();
|
|
741
|
+
}
|
|
742
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
while (!this.settled && this.iterationsDoneValue < budget) {
|
|
746
|
+
if (signal?.aborted === true) {
|
|
747
|
+
throw this.abortedError();
|
|
748
|
+
}
|
|
749
|
+
const k = Math.min(batch, budget - this.iterationsDoneValue);
|
|
750
|
+
await this.step(k);
|
|
751
|
+
}
|
|
752
|
+
if (signal?.aborted === true) {
|
|
753
|
+
throw this.abortedError();
|
|
754
|
+
}
|
|
755
|
+
return this.stats;
|
|
756
|
+
}
|
|
757
|
+
finally {
|
|
758
|
+
signal?.removeEventListener("abort", onAbort);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// ---------------------------------------------------------------- pins, drags, reheat, params
|
|
762
|
+
/**
|
|
763
|
+
* Copies the mask words (spec 7.12): E_INVALID_ARGUMENT when shorter than ceil(n / 32); the buffer is marked
|
|
764
|
+
* dirty and re-uploaded before the next submit; reheat() iff some bit went 1 -> 0 (an unpin). PLAN DECISION
|
|
765
|
+
* 18: the comparison is bit by bit below n (bits at or above n never count as an unpin).
|
|
766
|
+
* @param mask - the NodeMask (LSB-first words)
|
|
767
|
+
*/
|
|
768
|
+
setFixed(mask) {
|
|
769
|
+
this.assertLoaded();
|
|
770
|
+
const { n } = this;
|
|
771
|
+
const words = Math.ceil(n / 32);
|
|
772
|
+
if (mask.length < words) {
|
|
773
|
+
throw invalidArgument("mask", mask.length, words, `setFixed: the mask has ${mask.length} words, ${words} needed for ${n} nodes`);
|
|
774
|
+
}
|
|
775
|
+
let unpinned = false;
|
|
776
|
+
for (let w = 0; w < words && !unpinned; w++) {
|
|
777
|
+
if (this.fixedWords[w] === mask[w]) {
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
const last = Math.min(n, w * 32 + 32);
|
|
781
|
+
for (let i = w * 32; i < last; i++) {
|
|
782
|
+
if (maskTest(this.fixedWords, i) && !maskTest(mask, i)) {
|
|
783
|
+
unpinned = true;
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
this.fixedWords.set(mask.subarray(0, words));
|
|
789
|
+
this.fixedDirty = true;
|
|
790
|
+
if (unpinned) {
|
|
791
|
+
this.reheat();
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Writes a scene-unit position (spec 7.12): into the owner's array at once, into the device (layout units, z 0
|
|
796
|
+
* in 2D, 12 bytes at 16 i, queue-ordered before the next submit), into the override list keyed by the last
|
|
797
|
+
* submitted batch id, then reheat(). PLAN DECISION 18: non-finite coordinates are E_INVALID_ARGUMENT.
|
|
798
|
+
* @param index - the node index (< n)
|
|
799
|
+
* @param x - scene x
|
|
800
|
+
* @param y - scene y
|
|
801
|
+
* @param z - scene z (ignored on the device in 2D)
|
|
802
|
+
*/
|
|
803
|
+
setPosition(index, x, y, z) {
|
|
804
|
+
this.assertLoaded();
|
|
805
|
+
const { n } = this;
|
|
806
|
+
if (!Number.isInteger(index) || index < 0 || index >= n) {
|
|
807
|
+
throw invalidArgument("index", index, `an integer in [0, ${n})`, `setPosition(${index}): index out of range`);
|
|
808
|
+
}
|
|
809
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
|
810
|
+
throw invalidArgument("position", [x, y, z], "finite coordinates", "setPosition: coordinates must be finite");
|
|
811
|
+
}
|
|
812
|
+
const { owner, buffers } = this;
|
|
813
|
+
if (owner === null || buffers === null) {
|
|
814
|
+
throw new WebGpuGraphError("E_NOT_LOADED", `${this.label}: load() first`, { state: this.stateValue });
|
|
815
|
+
}
|
|
816
|
+
owner[3 * index] = x;
|
|
817
|
+
owner[3 * index + 1] = y;
|
|
818
|
+
owner[3 * index + 2] = z;
|
|
819
|
+
const [cx, cy, cz] = this.center;
|
|
820
|
+
const layout = new Float32Array([
|
|
821
|
+
(x - cx) / this.scale,
|
|
822
|
+
(y - cy) / this.scale,
|
|
823
|
+
this.dimValue === 2 ? 0 : (z - cz) / this.scale,
|
|
824
|
+
]);
|
|
825
|
+
this.ctx.device.queue.writeBuffer(buffers.positions, 16 * index, layout);
|
|
826
|
+
this.overrideList.set(index, this.lastSubmittedBatchIdValue);
|
|
827
|
+
this.reheat();
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* iterationsDone = 0, settledCount = 0 (a queued state write), model.onReheat(writer); nothing else (D8: the
|
|
831
|
+
* speed controller keeps its state). `settled` is recomputed at once (PLAN DECISION 8). Allowed before load().
|
|
832
|
+
* PLAN DECISION 21: the id of the last batch submitted so far is remembered; a batch with that id or an older
|
|
833
|
+
* one was computed from the pre-reheat counter, so its landed settledCount is taken as 0 (see onLanded) and a
|
|
834
|
+
* drag or unpin can never leave the simulation settled through a batch that was already in flight.
|
|
835
|
+
*/
|
|
836
|
+
reheat() {
|
|
837
|
+
this.assertNotDisposed();
|
|
838
|
+
this.iterationsDoneValue = 0;
|
|
839
|
+
this.settledCountValue = 0;
|
|
840
|
+
this.reheatedAfterBatchId = this.lastSubmittedBatchIdValue;
|
|
841
|
+
this.writer.set("settledCount", 0);
|
|
842
|
+
this.model.onReheat(this.writer);
|
|
843
|
+
this.settledValue = this.computeSettled();
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Live tuning (spec 3.3, 7.17): `dim` differing from the current -> E_INVALID_ARGUMENT; `maxInFlight` differing
|
|
847
|
+
* -> E_INVALID_ARGUMENT (the ring is sized by it); a non-null `nodeSize` -> E_UNSUPPORTED; the record is
|
|
848
|
+
* replaced through `resolve(patch, current)`; a change of the model's override set (a force LAW) recompiles
|
|
849
|
+
* and re-binds; scale / center changes reach the next batch through the shared params of every slot (the
|
|
850
|
+
* model's toScene reads them); then model.onSetParams(patch, writer) and reheat().
|
|
851
|
+
* @param patch - the fields to change
|
|
852
|
+
*/
|
|
853
|
+
setParams(patch) {
|
|
854
|
+
this.assertNotDisposed();
|
|
855
|
+
const current = this.optionsValue;
|
|
856
|
+
const { dim, maxInFlight } = patch;
|
|
857
|
+
if (dim !== undefined && dim !== this.dimValue) {
|
|
858
|
+
throw invalidArgument("dim", dim, this.dimValue, "dim is fixed at construction; graphty-element re-creates the engine on a view-mode switch (spec 7.13)");
|
|
859
|
+
}
|
|
860
|
+
if (maxInFlight !== undefined && maxInFlight !== this.maxInFlight) {
|
|
861
|
+
throw invalidArgument("maxInFlight", maxInFlight, this.maxInFlight, "maxInFlight is fixed at construction (the uniform ring is sized by it)");
|
|
862
|
+
}
|
|
863
|
+
const nodeSize = optionField(patch, "nodeSize");
|
|
864
|
+
if (nodeSize !== undefined && nodeSize !== null) {
|
|
865
|
+
throw new WebGpuGraphError("E_UNSUPPORTED", "nodeSize (adjustSizes) is deferred (spec 7.14)", {
|
|
866
|
+
option: "nodeSize",
|
|
867
|
+
hint: "leave nodeSize null; the size correction ships in a later slice",
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
const next = this.resolveOptions(patch, current);
|
|
871
|
+
const scale = scaleOf(next);
|
|
872
|
+
const center = centerOf(next);
|
|
873
|
+
const before = stableKey(this.model.overrides(current));
|
|
874
|
+
const after = stableKey(this.model.overrides(next));
|
|
875
|
+
this.optionsValue = next;
|
|
876
|
+
this.scale = scale;
|
|
877
|
+
this.center = center;
|
|
878
|
+
const { resources, core } = this;
|
|
879
|
+
if (this.stateValue === "loaded" && resources !== null && core !== null && before !== after) {
|
|
880
|
+
const overrides = { ...this.model.overrides(next), ...graphOverrides(core, null, resources.weights) };
|
|
881
|
+
this.startBind(resources, overrides);
|
|
882
|
+
}
|
|
883
|
+
this.model.onSetParams(patch, this.writer);
|
|
884
|
+
this.reheat();
|
|
885
|
+
}
|
|
886
|
+
// ---------------------------------------------------------------- dispose and device loss
|
|
887
|
+
/**
|
|
888
|
+
* Discards in-flight batches (their promises resolve), destroys every simulation buffer and the ring, trims
|
|
889
|
+
* the pool, unregisters the loss listener; state "disposed"; idempotent.
|
|
890
|
+
*/
|
|
891
|
+
dispose() {
|
|
892
|
+
if (this.stateValue === "disposed") {
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
this.stateValue = "disposed";
|
|
896
|
+
this.teardown();
|
|
897
|
+
}
|
|
898
|
+
// ---------------------------------------------------------------- private: the batch driver
|
|
899
|
+
/**
|
|
900
|
+
* The serialised submission of one pending batch: awaits the bind / warm promise and allocator.check(), then
|
|
901
|
+
* records and submits unless the record went stale meanwhile; wires the readback handlers. A record that went
|
|
902
|
+
* stale while waiting (a load() or dispose() superseded it) RESOLVES even when the wait rejected (PLAN
|
|
903
|
+
* DECISION 8: the rejection belongs to the superseded generation -- a bind that failed E_SHADER_COMPILE, or
|
|
904
|
+
* the ring's E_DISPOSED after dispose() tore it down under a running bind).
|
|
905
|
+
* @param record - the pending batch
|
|
906
|
+
*/
|
|
907
|
+
async submitBatch(record) {
|
|
908
|
+
try {
|
|
909
|
+
await this.ready;
|
|
910
|
+
await this.ctx.allocator.check();
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
this.finish(record);
|
|
914
|
+
if (this.isStale(record)) {
|
|
915
|
+
record.resolve();
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (hasErrorCode(err, "E_OUT_OF_MEMORY")) {
|
|
919
|
+
this.outOfMemory();
|
|
920
|
+
}
|
|
921
|
+
record.reject(asError(err));
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
if (this.isStale(record)) {
|
|
925
|
+
this.finish(record);
|
|
926
|
+
record.resolve();
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
let submitted;
|
|
930
|
+
try {
|
|
931
|
+
submitted = this.recordAndSubmit(record);
|
|
932
|
+
}
|
|
933
|
+
catch (err) {
|
|
934
|
+
this.finish(record);
|
|
935
|
+
record.reject(asError(err));
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
submitted.readback.then((bytes) => {
|
|
939
|
+
this.onLanded(record, bytes);
|
|
940
|
+
}, (err) => {
|
|
941
|
+
this.onFailed(record, err);
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Flushes the host writes, fills k ring slots, records k iterations (only the last one runs the model's toScene
|
|
946
|
+
* epilogue, PLAN DECISION 2) + the two readbacks into one CommandBatch and submits it (spec 7.19 items 4-5).
|
|
947
|
+
* @param record - the pending batch
|
|
948
|
+
* @returns the submitted batch
|
|
949
|
+
*/
|
|
950
|
+
recordAndSubmit(record) {
|
|
951
|
+
const buffers = this.requireBuffers();
|
|
952
|
+
const { k } = record;
|
|
953
|
+
const { device } = this.ctx;
|
|
954
|
+
if (this.fixedDirty) {
|
|
955
|
+
device.queue.writeBuffer(buffers.fixed, 0, this.fixedWords);
|
|
956
|
+
this.fixedDirty = false;
|
|
957
|
+
}
|
|
958
|
+
this.flushStateWrites(buffers.state);
|
|
959
|
+
const first = this.ring.reserve(k);
|
|
960
|
+
for (let i = 0; i < k; i++) {
|
|
961
|
+
const flags = i === 0 && this.firstPending ? FA2_FLAG_FIRST : 0;
|
|
962
|
+
this.ring.write(first + i, this.model.params, this.paramsForSlot(this.iterationsSubmitted + i, i, flags));
|
|
963
|
+
}
|
|
964
|
+
this.ring.flush();
|
|
965
|
+
const batch = new CommandBatch(this.ctx, `${this.label}/batch`, this.generationValue);
|
|
966
|
+
const beforeEpilogue = this.lastIterationStage();
|
|
967
|
+
for (let i = 0; i < k; i++) {
|
|
968
|
+
// the last iteration records every stage (toScene included); the others stop before the epilogue
|
|
969
|
+
this.model.recordIteration(batch, first + i, this.tierValue, i === k - 1 ? undefined : beforeEpilogue);
|
|
970
|
+
}
|
|
971
|
+
batch.endPass();
|
|
972
|
+
const scene = batch.readback(buffers.scene, 0, 12 * this.n);
|
|
973
|
+
const state = batch.readback(buffers.state, 0, STATE_HEADER_BYTES + k * this.traceBytes);
|
|
974
|
+
const { profiler } = this.ctx;
|
|
975
|
+
const profile = profiler === null ? null : profiler.resolveInto(batch);
|
|
976
|
+
const submitted = batch.submit();
|
|
977
|
+
record.submitted = submitted;
|
|
978
|
+
record.id = submitted.id;
|
|
979
|
+
record.sceneOffset = scene.offset;
|
|
980
|
+
record.stateOffset = state.offset;
|
|
981
|
+
record.profile = profile;
|
|
982
|
+
record.startedAt = performance.now();
|
|
983
|
+
this.lastSubmittedBatchIdValue = submitted.id;
|
|
984
|
+
this.iterationsSubmitted += k;
|
|
985
|
+
this.firstPending = false;
|
|
986
|
+
return submitted;
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* A landed readback (spec 7.19 item 6): stale -> discard; else the scene bytes go into the owner's array row by
|
|
990
|
+
* row (skipping overridden rows, clearing overrides older than this batch), the header refreshes the shadow,
|
|
991
|
+
* the stats are decoded through the model (msPerIteration from the profiler or the wall time, PLAN DECISION 3),
|
|
992
|
+
* iterationsDone += k and settled is recomputed -- with the landed settledCount taken as 0 when the batch was
|
|
993
|
+
* submitted before the last reheat() (PLAN DECISION 21; the header is still absorbed and the stats decoded).
|
|
994
|
+
* @param record - the pending batch
|
|
995
|
+
* @param bytes - the batch's readback bytes
|
|
996
|
+
*/
|
|
997
|
+
onLanded(record, bytes) {
|
|
998
|
+
this.finish(record);
|
|
999
|
+
const { owner } = this;
|
|
1000
|
+
if (this.isStale(record) || owner === null) {
|
|
1001
|
+
record.resolve();
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
const { k } = record;
|
|
1006
|
+
const { n } = this;
|
|
1007
|
+
const needed = Math.max(record.sceneOffset + 12 * n, record.stateOffset + STATE_HEADER_BYTES + k * this.traceBytes);
|
|
1008
|
+
if (bytes.byteLength < needed) {
|
|
1009
|
+
// a discarded readback resolves with an empty buffer
|
|
1010
|
+
record.resolve();
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
this.copyScene(owner, bytes, record);
|
|
1014
|
+
const header = new DataView(bytes, record.stateOffset, STATE_HEADER_BYTES);
|
|
1015
|
+
const trace = new DataView(bytes, record.stateOffset + STATE_HEADER_BYTES, k * this.traceBytes);
|
|
1016
|
+
this.writer.absorb(header);
|
|
1017
|
+
const settledCount = this.model.state.readField(header, "settledCount");
|
|
1018
|
+
if (typeof settledCount !== "number") {
|
|
1019
|
+
throw invalidArgument("settledCount", settledCount, "a scalar", "settledCount must be a scalar field");
|
|
1020
|
+
}
|
|
1021
|
+
const ms = this.batchMilliseconds(record, bytes);
|
|
1022
|
+
this.statsValue = withMs(this.model.readStats(header, trace), ms / k);
|
|
1023
|
+
this.iterationsDoneValue += k;
|
|
1024
|
+
// PLAN DECISION 21: a batch in flight at reheat() carries the pre-reheat settle counter
|
|
1025
|
+
this.settledCountValue = record.id <= this.reheatedAfterBatchId ? 0 : settledCount;
|
|
1026
|
+
this.settledValue = this.computeSettled();
|
|
1027
|
+
record.resolve();
|
|
1028
|
+
}
|
|
1029
|
+
catch (err) {
|
|
1030
|
+
record.reject(asError(err));
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* A failed readback: a discarded batch's E_ABORTED resolves (PLAN DECISION 8); a lost device rejects
|
|
1035
|
+
* E_DEVICE_LOST; everything else (E_VALIDATION, E_DISPOSED) rejects as is.
|
|
1036
|
+
* @param record - the pending batch
|
|
1037
|
+
* @param err - the rejection
|
|
1038
|
+
*/
|
|
1039
|
+
onFailed(record, err) {
|
|
1040
|
+
this.finish(record);
|
|
1041
|
+
if (hasErrorCode(err, "E_ABORTED") && this.isStale(record)) {
|
|
1042
|
+
record.resolve();
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (this.ctx.state === "lost" && !hasErrorCode(err, "E_DEVICE_LOST")) {
|
|
1046
|
+
record.reject(new WebGpuGraphError("E_DEVICE_LOST", "the device was lost while the batch was in flight", {
|
|
1047
|
+
reason: "unknown",
|
|
1048
|
+
message: asError(err).message,
|
|
1049
|
+
}));
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
record.reject(asError(err));
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Copies the scene bytes of a landed batch into the owner's array, honouring the override list (spec 7.12).
|
|
1056
|
+
* @param owner - the owner's array
|
|
1057
|
+
* @param bytes - the readback
|
|
1058
|
+
* @param record - the batch
|
|
1059
|
+
*/
|
|
1060
|
+
copyScene(owner, bytes, record) {
|
|
1061
|
+
const { n } = this;
|
|
1062
|
+
const scene = new Float32Array(bytes, record.sceneOffset, 3 * n);
|
|
1063
|
+
if (this.overrideList.size === 0) {
|
|
1064
|
+
owner.set(scene);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
for (let i = 0; i < n; i++) {
|
|
1068
|
+
const after = this.overrideList.get(i);
|
|
1069
|
+
if (after !== undefined && after >= record.id) {
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
owner[3 * i] = scene[3 * i];
|
|
1073
|
+
owner[3 * i + 1] = scene[3 * i + 1];
|
|
1074
|
+
owner[3 * i + 2] = scene[3 * i + 2];
|
|
1075
|
+
}
|
|
1076
|
+
for (const [i, after] of this.overrideList) {
|
|
1077
|
+
if (after < record.id) {
|
|
1078
|
+
this.overrideList.delete(i);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* The batch's duration in milliseconds: the profiler's pass timings summed when present, else wall time.
|
|
1084
|
+
* @param record - the batch
|
|
1085
|
+
* @param bytes - its readback (the profiler's resolve lands in it)
|
|
1086
|
+
* @returns milliseconds
|
|
1087
|
+
*/
|
|
1088
|
+
batchMilliseconds(record, bytes) {
|
|
1089
|
+
const { profiler } = this.ctx;
|
|
1090
|
+
if (profiler !== null && record.profile !== null) {
|
|
1091
|
+
const timings = profiler.timings(bytes, record.profile);
|
|
1092
|
+
if (timings.length > 0) {
|
|
1093
|
+
let ns = 0;
|
|
1094
|
+
for (const timing of timings) {
|
|
1095
|
+
ns += timing.ns;
|
|
1096
|
+
}
|
|
1097
|
+
return ns / 1e6;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return performance.now() - record.startedAt;
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Whether a record belongs to an earlier generation, was discarded, or the simulation left "loaded".
|
|
1104
|
+
* @param record - the batch
|
|
1105
|
+
* @returns true when its readback must be ignored
|
|
1106
|
+
*/
|
|
1107
|
+
isStale(record) {
|
|
1108
|
+
return record.stale || this.stateValue !== "loaded" || record.generation !== this.generationValue;
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Removes a record from the pending list (idempotent).
|
|
1112
|
+
* @param record - the batch
|
|
1113
|
+
*/
|
|
1114
|
+
finish(record) {
|
|
1115
|
+
const at = this.pending.indexOf(record);
|
|
1116
|
+
if (at >= 0) {
|
|
1117
|
+
this.pending.splice(at, 1);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
/** Marks every pending batch stale and discards the submitted ones (their readbacks resolve empty or E_ABORTED). */
|
|
1121
|
+
discardPending() {
|
|
1122
|
+
for (const record of this.pending) {
|
|
1123
|
+
record.stale = true;
|
|
1124
|
+
record.submitted?.discard();
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/** An OOM surfaced by allocator.check(): every simulation buffer is destroyed and the state returns to "created". */
|
|
1128
|
+
outOfMemory() {
|
|
1129
|
+
this.discardPending();
|
|
1130
|
+
this.destroyBuffers();
|
|
1131
|
+
this.ctx.allocator.reset();
|
|
1132
|
+
this.settledValue = false;
|
|
1133
|
+
this.stateValue = "created";
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* The E_ABORTED of run().
|
|
1137
|
+
* @returns the error
|
|
1138
|
+
*/
|
|
1139
|
+
abortedError() {
|
|
1140
|
+
return new WebGpuGraphError("E_ABORTED", `${this.label}: run() aborted by the signal`, {
|
|
1141
|
+
batchId: this.lastSubmittedBatchIdValue,
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
// ---------------------------------------------------------------- private: params, state writes, toScene
|
|
1145
|
+
/**
|
|
1146
|
+
* The uniform values of one iteration slot: the model's values with the shared fields on top (PLAN DECISION 6).
|
|
1147
|
+
* @param global - the global iteration index (iterationsSubmitted + i)
|
|
1148
|
+
* @param index - the slot index within the batch (the trace slot)
|
|
1149
|
+
* @param flags - FA2_FLAG_FIRST for the first iteration after load(), else 0
|
|
1150
|
+
* @returns the values
|
|
1151
|
+
*/
|
|
1152
|
+
paramsForSlot(global, index, flags) {
|
|
1153
|
+
const [cx, cy, cz] = this.center;
|
|
1154
|
+
const shared = {
|
|
1155
|
+
n: this.n,
|
|
1156
|
+
dim: this.dimValue,
|
|
1157
|
+
flags,
|
|
1158
|
+
iterationIndex: index,
|
|
1159
|
+
seed: this.seedU32(),
|
|
1160
|
+
scale: this.scale,
|
|
1161
|
+
center: [cx, cy, cz, 0],
|
|
1162
|
+
settleThreshold: this.settleThreshold(),
|
|
1163
|
+
};
|
|
1164
|
+
return { ...this.model.paramsFor(global, this.optionsValue), ...shared };
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* The option seed as a u32 (0 when unseeded), the near-field hash seed of P4.
|
|
1168
|
+
* @returns the seed
|
|
1169
|
+
*/
|
|
1170
|
+
seedU32() {
|
|
1171
|
+
const { seed } = this.optionsValue;
|
|
1172
|
+
if (seed === null || seed === undefined || !Number.isFinite(seed)) {
|
|
1173
|
+
return 0;
|
|
1174
|
+
}
|
|
1175
|
+
return Math.floor(Math.abs(seed)) % 4294967296;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Writes the queued state fields, each as one writeBuffer of its own byte range (PLAN DECISION 5).
|
|
1179
|
+
* @param state - the state buffer
|
|
1180
|
+
*/
|
|
1181
|
+
flushStateWrites(state) {
|
|
1182
|
+
const fields = this.writer.takeQueued();
|
|
1183
|
+
if (fields.length === 0) {
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
const bytes = this.writer.headerBytes();
|
|
1187
|
+
const block = this.model.state;
|
|
1188
|
+
for (const field of fields) {
|
|
1189
|
+
const offset = block.offsetOf(field);
|
|
1190
|
+
const size = 4 * fieldWidth(fieldTypeOf(block, field));
|
|
1191
|
+
this.ctx.device.queue.writeBuffer(state, offset, bytes, offset, size);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* The stage that precedes the toScene epilogue in model.stages (the `upTo` of every iteration but the last), or
|
|
1196
|
+
* undefined when the model has no epilogue or lists it first (every iteration then records every stage).
|
|
1197
|
+
* @returns the stage name or undefined
|
|
1198
|
+
*/
|
|
1199
|
+
lastIterationStage() {
|
|
1200
|
+
const at = this.model.stages.indexOf(EPILOGUE_STAGE);
|
|
1201
|
+
if (at <= 0) {
|
|
1202
|
+
return undefined;
|
|
1203
|
+
}
|
|
1204
|
+
return this.model.stages[at - 1];
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* The settle threshold of the current options (FA2_DEFAULTS when absent).
|
|
1208
|
+
* @returns the threshold
|
|
1209
|
+
*/
|
|
1210
|
+
settleThreshold() {
|
|
1211
|
+
return this.optionsValue.settleThreshold ?? FA2_DEFAULTS.settleThreshold;
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* The settle window of the current options (FA2_DEFAULTS when absent).
|
|
1215
|
+
* @returns the window
|
|
1216
|
+
*/
|
|
1217
|
+
settleWindow() {
|
|
1218
|
+
return this.optionsValue.settleWindow ?? FA2_DEFAULTS.settleWindow;
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* The iteration budget (PLAN DECISION 1): options.maxIter, else options.iterations, else no budget.
|
|
1222
|
+
* @returns the budget
|
|
1223
|
+
*/
|
|
1224
|
+
iterationBudget() {
|
|
1225
|
+
const maxIter = optionField(this.optionsValue, "maxIter");
|
|
1226
|
+
if (typeof maxIter === "number") {
|
|
1227
|
+
return maxIter;
|
|
1228
|
+
}
|
|
1229
|
+
const iterations = optionField(this.optionsValue, "iterations");
|
|
1230
|
+
if (typeof iterations === "number") {
|
|
1231
|
+
return iterations;
|
|
1232
|
+
}
|
|
1233
|
+
return Number.POSITIVE_INFINITY;
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* The settle rule of spec 7.17 over the counters of the last completed batch.
|
|
1237
|
+
* @returns whether the layout is settled
|
|
1238
|
+
*/
|
|
1239
|
+
computeSettled() {
|
|
1240
|
+
if (this.stateValue !== "loaded") {
|
|
1241
|
+
return false;
|
|
1242
|
+
}
|
|
1243
|
+
return (this.n === 0 ||
|
|
1244
|
+
this.iterationsDoneValue >= this.iterationBudget() ||
|
|
1245
|
+
this.settledCountValue >= this.settleWindow());
|
|
1246
|
+
}
|
|
1247
|
+
// ---------------------------------------------------------------- private: buffers, upload, bind
|
|
1248
|
+
/**
|
|
1249
|
+
* Allocates the shared buffers (spec 7.3 sizes) and the model's BufferSpecs through the allocator (every buffer
|
|
1250
|
+
* labelled); a `zero: true` spec must carry COPY_DST so it can be re-zeroed when kept across loads (PLAN
|
|
1251
|
+
* DECISION 17). The state buffer is STATE_HEADER_BYTES + MAX_ITERATIONS_PER_STEP x model.trace.byteLength:
|
|
1252
|
+
* contract 3.13 writes TRACE_RECORD_BYTES (32, the FA2 record) for the last factor; the model's block is the
|
|
1253
|
+
* same number for FA2 and is what the simulation decodes the trace with, so it is the general rule (PLAN
|
|
1254
|
+
* DECISION 4) and the constant is not imported here.
|
|
1255
|
+
* @param n - the node count (> 0)
|
|
1256
|
+
* @returns the buffers and the name -> Binding map
|
|
1257
|
+
*/
|
|
1258
|
+
allocate(n) {
|
|
1259
|
+
const { allocator } = this.ctx;
|
|
1260
|
+
const { label } = this;
|
|
1261
|
+
const groups = Math.ceil(n / this.ctx.workgroupSize);
|
|
1262
|
+
const traceRegionBytes = MAX_ITERATIONS_PER_STEP * this.traceBytes;
|
|
1263
|
+
const storageRw = BufferUsage.STORAGE | BufferUsage.COPY_SRC | BufferUsage.COPY_DST;
|
|
1264
|
+
const positions = allocator.createBuffer({ label: `${label}/positions`, size: 16 * n, usage: storageRw });
|
|
1265
|
+
const scene = allocator.createBuffer({
|
|
1266
|
+
label: `${label}/scenePositions`,
|
|
1267
|
+
size: 12 * n,
|
|
1268
|
+
usage: BufferUsage.STORAGE | BufferUsage.COPY_SRC,
|
|
1269
|
+
});
|
|
1270
|
+
const fixed = allocator.createBuffer({
|
|
1271
|
+
label: `${label}/fixed`,
|
|
1272
|
+
size: 4 * Math.ceil(n / 32),
|
|
1273
|
+
usage: storageRw,
|
|
1274
|
+
});
|
|
1275
|
+
const partials = allocator.createBuffer({
|
|
1276
|
+
label: `${label}/partials`,
|
|
1277
|
+
size: PARTIAL_BYTES * groups,
|
|
1278
|
+
usage: storageRw,
|
|
1279
|
+
});
|
|
1280
|
+
const state = allocator.createBuffer({
|
|
1281
|
+
label: `${label}/state`,
|
|
1282
|
+
size: STATE_HEADER_BYTES + traceRegionBytes,
|
|
1283
|
+
usage: storageRw,
|
|
1284
|
+
});
|
|
1285
|
+
const bindings = new Map();
|
|
1286
|
+
bindings.set("positions", wholeBinding(positions, 16 * n));
|
|
1287
|
+
bindings.set("scenePositions", wholeBinding(scene, 12 * n));
|
|
1288
|
+
bindings.set("fixed", wholeBinding(fixed, 4 * Math.ceil(n / 32)));
|
|
1289
|
+
bindings.set("partials", wholeBinding(partials, PARTIAL_BYTES * groups));
|
|
1290
|
+
bindings.set("state", { buffer: state, offset: 0, size: STATE_HEADER_BYTES, window: null });
|
|
1291
|
+
bindings.set("trace", { buffer: state, offset: STATE_HEADER_BYTES, size: traceRegionBytes, window: null });
|
|
1292
|
+
const model = new Map();
|
|
1293
|
+
for (const spec of this.model.buffers(n, this.dimValue)) {
|
|
1294
|
+
if (SHARED_BUFFER_NAMES.includes(spec.name) || bindings.has(spec.name)) {
|
|
1295
|
+
throw invalidArgument("BufferSpec.name", spec.name, "a name no other buffer uses", `BufferSpec "${spec.name}" reuses a buffer name`);
|
|
1296
|
+
}
|
|
1297
|
+
if (!Number.isInteger(spec.byteLength) || spec.byteLength <= 0 || spec.byteLength % 4 !== 0) {
|
|
1298
|
+
throw invalidArgument("BufferSpec.byteLength", spec.byteLength, "a positive multiple of 4", `BufferSpec "${spec.name}" has byteLength ${spec.byteLength}`);
|
|
1299
|
+
}
|
|
1300
|
+
if (spec.zero && (spec.usage & BufferUsage.COPY_DST) === 0) {
|
|
1301
|
+
throw invalidArgument("BufferSpec.usage", spec.usage, "COPY_DST on a zero: true spec", `BufferSpec "${spec.name}" is zero: true but lacks COPY_DST`);
|
|
1302
|
+
}
|
|
1303
|
+
const buffer = allocator.createBuffer({
|
|
1304
|
+
label: `${label}/${spec.name}`,
|
|
1305
|
+
size: spec.byteLength,
|
|
1306
|
+
usage: spec.usage,
|
|
1307
|
+
});
|
|
1308
|
+
model.set(spec.name, { buffer, spec });
|
|
1309
|
+
bindings.set(spec.name, wholeBinding(buffer, spec.byteLength));
|
|
1310
|
+
}
|
|
1311
|
+
return { positions, scene, fixed, partials, state, model, bindings, traceRegionBytes };
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Re-zeroes the buffers a same-size load() keeps: partials, the trace region and every `zero: true` model buffer.
|
|
1315
|
+
* @param buffers - the kept buffers
|
|
1316
|
+
*/
|
|
1317
|
+
clearKept(buffers) {
|
|
1318
|
+
const { device } = this.ctx;
|
|
1319
|
+
const encoder = device.createCommandEncoder({ label: `${this.label}/clear` });
|
|
1320
|
+
encoder.clearBuffer(buffers.partials);
|
|
1321
|
+
encoder.clearBuffer(buffers.state, STATE_HEADER_BYTES, buffers.traceRegionBytes);
|
|
1322
|
+
for (const { buffer, spec } of buffers.model.values()) {
|
|
1323
|
+
if (spec.zero) {
|
|
1324
|
+
encoder.clearBuffer(buffer);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
device.queue.submit([encoder.finish()]);
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Destroys every simulation buffer through the allocator (the ring lives on until dispose()). While batches are
|
|
1331
|
+
* still in flight the destruction is DEFERRED until their readbacks have settled: the GPU may still be copying
|
|
1332
|
+
* out of `scene` / `state` and Dawn's Metal backend (dawn-node 0.4.0 on macOS) has taken the worker process
|
|
1333
|
+
* down when a buffer with pending work was destroyed (the Vulkan backends defer internally). The simulation
|
|
1334
|
+
* drops its references at once either way, so nothing here is reachable afterwards.
|
|
1335
|
+
*/
|
|
1336
|
+
destroyBuffers() {
|
|
1337
|
+
const { buffers } = this;
|
|
1338
|
+
if (buffers === null) {
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
this.buffers = null;
|
|
1342
|
+
this.resources = null;
|
|
1343
|
+
const doomed = [buffers.positions, buffers.scene, buffers.fixed, buffers.partials, buffers.state];
|
|
1344
|
+
for (const { buffer } of buffers.model.values()) {
|
|
1345
|
+
doomed.push(buffer);
|
|
1346
|
+
}
|
|
1347
|
+
this.afterInFlight(() => {
|
|
1348
|
+
const { allocator } = this.ctx;
|
|
1349
|
+
for (const buffer of doomed) {
|
|
1350
|
+
allocator.destroy(buffer);
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Runs `action` now when no submitted batch is in flight, otherwise once every in-flight readback has settled
|
|
1356
|
+
* (resolved, discarded or rejected -- the staging slot is returned and the GPU work is done either way).
|
|
1357
|
+
* @param action - the destruction to run
|
|
1358
|
+
*/
|
|
1359
|
+
afterInFlight(action) {
|
|
1360
|
+
const waits = [];
|
|
1361
|
+
for (const record of this.pending) {
|
|
1362
|
+
if (record.submitted !== null) {
|
|
1363
|
+
waits.push(record.submitted.readback.catch(() => undefined));
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
if (waits.length === 0) {
|
|
1367
|
+
action();
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
void Promise.all(waits).then(action, action);
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* The buffers of the current load.
|
|
1374
|
+
* @returns the buffers; E_NOT_LOADED when there are none
|
|
1375
|
+
*/
|
|
1376
|
+
requireBuffers() {
|
|
1377
|
+
const { buffers } = this;
|
|
1378
|
+
if (buffers === null) {
|
|
1379
|
+
throw new WebGpuGraphError("E_NOT_LOADED", `${this.label}: load() first`, { state: this.stateValue });
|
|
1380
|
+
}
|
|
1381
|
+
return buffers;
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Repacks the seeded scene array into layout-unit vec4f (mass in .w, z = 0 in 2D), uploads it, and writes the
|
|
1385
|
+
* initial centroid / bbox / rmsRadius / radius (f64 over the f32 values uploaded) with iteration 0,
|
|
1386
|
+
* settledCount 0, meanDisplacement 0 into the state header, then model.onLoad(writer) and one whole-header
|
|
1387
|
+
* writeBuffer (spec 7.4, 7.17, 7.18).
|
|
1388
|
+
* @param buffers - the buffers
|
|
1389
|
+
* @param positions - the seeded owner array
|
|
1390
|
+
* @param mass - the resolved masses
|
|
1391
|
+
*/
|
|
1392
|
+
uploadPositions(buffers, positions, mass) {
|
|
1393
|
+
const { n, scale } = this;
|
|
1394
|
+
const [cx, cy, cz] = this.center;
|
|
1395
|
+
const packed = new Float32Array(4 * n);
|
|
1396
|
+
for (let i = 0; i < n; i++) {
|
|
1397
|
+
packed[4 * i] = (positions[3 * i] - cx) / scale;
|
|
1398
|
+
packed[4 * i + 1] = (positions[3 * i + 1] - cy) / scale;
|
|
1399
|
+
packed[4 * i + 2] = this.dimValue === 2 ? 0 : (positions[3 * i + 2] - cz) / scale;
|
|
1400
|
+
packed[4 * i + 3] = mass[i];
|
|
1401
|
+
}
|
|
1402
|
+
this.ctx.device.queue.writeBuffer(buffers.positions, 0, packed);
|
|
1403
|
+
let sx = 0;
|
|
1404
|
+
let sy = 0;
|
|
1405
|
+
let sz = 0;
|
|
1406
|
+
const lo = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
|
|
1407
|
+
const hi = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
|
|
1408
|
+
for (let i = 0; i < n; i++) {
|
|
1409
|
+
const x = packed[4 * i];
|
|
1410
|
+
const y = packed[4 * i + 1];
|
|
1411
|
+
const z = packed[4 * i + 2];
|
|
1412
|
+
sx += x;
|
|
1413
|
+
sy += y;
|
|
1414
|
+
sz += z;
|
|
1415
|
+
lo[0] = Math.min(lo[0], x);
|
|
1416
|
+
lo[1] = Math.min(lo[1], y);
|
|
1417
|
+
lo[2] = Math.min(lo[2], z);
|
|
1418
|
+
hi[0] = Math.max(hi[0], x);
|
|
1419
|
+
hi[1] = Math.max(hi[1], y);
|
|
1420
|
+
hi[2] = Math.max(hi[2], z);
|
|
1421
|
+
}
|
|
1422
|
+
const mx = sx / n;
|
|
1423
|
+
const my = sy / n;
|
|
1424
|
+
const mz = sz / n;
|
|
1425
|
+
let sumSq = 0;
|
|
1426
|
+
let maxSq = 0;
|
|
1427
|
+
for (let i = 0; i < n; i++) {
|
|
1428
|
+
const dx = packed[4 * i] - mx;
|
|
1429
|
+
const dy = packed[4 * i + 1] - my;
|
|
1430
|
+
const dz = packed[4 * i + 2] - mz;
|
|
1431
|
+
const q = dx * dx + dy * dy + dz * dz;
|
|
1432
|
+
sumSq += q;
|
|
1433
|
+
maxSq = Math.max(maxSq, q);
|
|
1434
|
+
}
|
|
1435
|
+
const { writer } = this;
|
|
1436
|
+
writer.set("iteration", 0);
|
|
1437
|
+
writer.set("settledCount", 0);
|
|
1438
|
+
writer.set("meanDisplacement", 0);
|
|
1439
|
+
writer.set("centroid", [mx, my, mz, 0]);
|
|
1440
|
+
writer.set("min", [lo[0], lo[1], lo[2], 0]);
|
|
1441
|
+
writer.set("max", [hi[0], hi[1], hi[2], 0]);
|
|
1442
|
+
writer.set("rmsRadius", Math.sqrt(sumSq / n));
|
|
1443
|
+
writer.set("radius", Math.sqrt(maxSq));
|
|
1444
|
+
this.model.onLoad(writer);
|
|
1445
|
+
this.ctx.device.queue.writeBuffer(buffers.state, 0, writer.headerBytes());
|
|
1446
|
+
writer.clearQueued();
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* The weights binding of a load (3.10): "arcs" -> core.weights, "none" -> null, "column" -> the expanded array
|
|
1450
|
+
* registered with residency.array(expanded, "weights", snapshot), the previous array object reused when the
|
|
1451
|
+
* column and its version are unchanged (so the upload cache finds it).
|
|
1452
|
+
* @param snapshot - the snapshot
|
|
1453
|
+
* @param core - its core
|
|
1454
|
+
* @param resolved - model.inputs().weights
|
|
1455
|
+
* @returns the binding for the group-0 weights slot, or null
|
|
1456
|
+
*/
|
|
1457
|
+
resolveWeightsBinding(snapshot, core, resolved) {
|
|
1458
|
+
if (resolved.source === "arcs") {
|
|
1459
|
+
this.weightsUpload = null;
|
|
1460
|
+
return core.weights;
|
|
1461
|
+
}
|
|
1462
|
+
if (resolved.source === "none" || resolved.data === null || resolved.column === null) {
|
|
1463
|
+
this.weightsUpload = null;
|
|
1464
|
+
return null;
|
|
1465
|
+
}
|
|
1466
|
+
const previous = this.weightsUpload;
|
|
1467
|
+
const reusable = previous !== null &&
|
|
1468
|
+
previous.serial === snapshot.serial &&
|
|
1469
|
+
previous.column === resolved.column &&
|
|
1470
|
+
previous.version === resolved.column.version &&
|
|
1471
|
+
previous.data.length === resolved.data.length;
|
|
1472
|
+
const data = reusable && previous !== null ? previous.data : resolved.data;
|
|
1473
|
+
const upload = this.ctx.residency.array(data, "weights", snapshot);
|
|
1474
|
+
this.weightsUpload = {
|
|
1475
|
+
serial: snapshot.serial,
|
|
1476
|
+
column: resolved.column,
|
|
1477
|
+
version: resolved.column.version,
|
|
1478
|
+
data,
|
|
1479
|
+
upload,
|
|
1480
|
+
};
|
|
1481
|
+
return upload.binding;
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* The ModelResources of a load.
|
|
1485
|
+
* @param core - the core
|
|
1486
|
+
* @param weights - the resolved weights binding
|
|
1487
|
+
* @param buffers - the buffers
|
|
1488
|
+
* @returns the resources
|
|
1489
|
+
*/
|
|
1490
|
+
makeResources(core, weights, buffers) {
|
|
1491
|
+
const { bindings } = buffers;
|
|
1492
|
+
return {
|
|
1493
|
+
device: this.ctx.device,
|
|
1494
|
+
caps: this.ctx.caps,
|
|
1495
|
+
pipelines: this.ctx.pipelines,
|
|
1496
|
+
core,
|
|
1497
|
+
perm: null,
|
|
1498
|
+
weights,
|
|
1499
|
+
n: this.n,
|
|
1500
|
+
dim: this.dimValue,
|
|
1501
|
+
tier: this.tierValue,
|
|
1502
|
+
ring: this.ring,
|
|
1503
|
+
buffer: (name) => {
|
|
1504
|
+
const binding = bindings.get(name);
|
|
1505
|
+
if (binding === undefined) {
|
|
1506
|
+
throw invalidArgument("name", name, [...bindings.keys()], `no simulation buffer named "${name}"`);
|
|
1507
|
+
}
|
|
1508
|
+
return binding;
|
|
1509
|
+
},
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Chains the compile + bind promise the next step() awaits on the PREVIOUS one (PLAN DECISION 20: binds are
|
|
1514
|
+
* serialised on `ready` for load() and setParams() alike, so a superseded load()'s bind either fails its
|
|
1515
|
+
* generation check before model.bind() or completes before its successor's bind starts -- it can never bind
|
|
1516
|
+
* the model to destroyed buffers AFTER the new bind finished). The previous promise's rejection is swallowed by
|
|
1517
|
+
* the chain (it belonged to the superseded generation); this promise's rejection is kept for the next step()
|
|
1518
|
+
* (the derived catch only silences the unhandled-rejection warning).
|
|
1519
|
+
* @param resources - the resources
|
|
1520
|
+
* @param overrides - the merged override set
|
|
1521
|
+
*/
|
|
1522
|
+
startBind(resources, overrides) {
|
|
1523
|
+
const generation = this.generationValue;
|
|
1524
|
+
const run = () => this.compileAndBind(resources, overrides, generation);
|
|
1525
|
+
const promise = this.ready.catch(() => undefined).then(run);
|
|
1526
|
+
void promise.catch(() => undefined);
|
|
1527
|
+
this.ready = promise;
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* warm(model.specs()) then model.bind(); the await re-checks that the load is current before binding.
|
|
1531
|
+
* @param resources - the resources
|
|
1532
|
+
* @param overrides - the merged override set
|
|
1533
|
+
* @param generation - the generation the bind belongs to
|
|
1534
|
+
*/
|
|
1535
|
+
async compileAndBind(resources, overrides, generation) {
|
|
1536
|
+
const subgroups = this.ctx.caps.features.has("subgroups");
|
|
1537
|
+
await this.ctx.pipelines.warm(this.model.specs(overrides, subgroups));
|
|
1538
|
+
if (this.stateValue !== "loaded" || generation !== this.generationValue) {
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
await this.model.bind(resources, overrides);
|
|
1542
|
+
}
|
|
1543
|
+
// ---------------------------------------------------------------- private: inspect and debug runs
|
|
1544
|
+
/**
|
|
1545
|
+
* inspect(name): flush, read the named buffer back, Uint32Array for "fixed" and "trace", Float32Array otherwise.
|
|
1546
|
+
* @param name - a shared or BufferSpec name
|
|
1547
|
+
* @returns the words
|
|
1548
|
+
*/
|
|
1549
|
+
async inspectBuffer(name) {
|
|
1550
|
+
this.assertLoaded();
|
|
1551
|
+
const { buffers } = this;
|
|
1552
|
+
const binding = buffers?.bindings.get(name);
|
|
1553
|
+
if (buffers === null || binding === undefined) {
|
|
1554
|
+
throw invalidArgument("name", name, buffers === null ? [] : [...buffers.bindings.keys()], `inspect("${name}"): no such buffer`);
|
|
1555
|
+
}
|
|
1556
|
+
await this.flush();
|
|
1557
|
+
const bytes = await this.ctx.readback.read(binding.buffer, binding.size, undefined, binding.offset);
|
|
1558
|
+
return name === "fixed" || name === "trace" ? new Uint32Array(bytes) : new Float32Array(bytes);
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* debugRunStages(upTo): one iteration truncated after a stage, submitted alone after flush(), awaited through
|
|
1562
|
+
* a header readback; never advances the counters or the FA2_FLAG_FIRST flag (PLAN DECISION 9).
|
|
1563
|
+
* @param upTo - a model stage name
|
|
1564
|
+
*/
|
|
1565
|
+
async runStages(upTo) {
|
|
1566
|
+
this.assertLoaded();
|
|
1567
|
+
if (!this.model.stages.includes(upTo)) {
|
|
1568
|
+
throw invalidArgument("upTo", upTo, [...this.model.stages], `debugRunStages("${upTo}"): not a stage of ${this.model.kind}`);
|
|
1569
|
+
}
|
|
1570
|
+
if (this.n === 0) {
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
await this.flush();
|
|
1574
|
+
await this.ready;
|
|
1575
|
+
await this.ctx.allocator.check();
|
|
1576
|
+
this.assertLoaded();
|
|
1577
|
+
const buffers = this.requireBuffers();
|
|
1578
|
+
const { device } = this.ctx;
|
|
1579
|
+
if (this.fixedDirty) {
|
|
1580
|
+
device.queue.writeBuffer(buffers.fixed, 0, this.fixedWords);
|
|
1581
|
+
this.fixedDirty = false;
|
|
1582
|
+
}
|
|
1583
|
+
this.flushStateWrites(buffers.state);
|
|
1584
|
+
const slot = this.ring.reserve(1);
|
|
1585
|
+
this.ring.write(slot, this.model.params, this.paramsForSlot(this.iterationsSubmitted, 0, this.firstPending ? FA2_FLAG_FIRST : 0));
|
|
1586
|
+
this.ring.flush();
|
|
1587
|
+
const batch = new CommandBatch(this.ctx, `${this.label}/debug`, this.generationValue);
|
|
1588
|
+
this.model.recordIteration(batch, slot, this.tierValue, upTo);
|
|
1589
|
+
batch.endPass();
|
|
1590
|
+
batch.readback(buffers.state, 0, STATE_HEADER_BYTES);
|
|
1591
|
+
const submitted = batch.submit();
|
|
1592
|
+
this.lastSubmittedBatchIdValue = submitted.id;
|
|
1593
|
+
await submitted.readback;
|
|
1594
|
+
}
|
|
1595
|
+
// ---------------------------------------------------------------- private: lifecycle
|
|
1596
|
+
/**
|
|
1597
|
+
* Device loss (spec 5.7): every pending promise rejects E_DEVICE_LOST, the simulation is disposed.
|
|
1598
|
+
* @param info - the loss info
|
|
1599
|
+
*/
|
|
1600
|
+
onDeviceLost(info) {
|
|
1601
|
+
if (this.stateValue === "disposed") {
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
const error = new WebGpuGraphError("E_DEVICE_LOST", `device lost (${info.reason}): ${info.message}`, {
|
|
1605
|
+
reason: info.reason,
|
|
1606
|
+
message: info.message,
|
|
1607
|
+
});
|
|
1608
|
+
const pending = this.pending.splice(0);
|
|
1609
|
+
this.stateValue = "disposed";
|
|
1610
|
+
for (const record of pending) {
|
|
1611
|
+
record.stale = true;
|
|
1612
|
+
record.reject(error);
|
|
1613
|
+
}
|
|
1614
|
+
this.teardown();
|
|
1615
|
+
}
|
|
1616
|
+
/** Releases everything once: pending batches discarded, buffers and ring destroyed, pool trimmed, listener unregistered. */
|
|
1617
|
+
teardown() {
|
|
1618
|
+
if (this.torndown) {
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
this.torndown = true;
|
|
1622
|
+
this.discardPending();
|
|
1623
|
+
this.destroyBuffers();
|
|
1624
|
+
this.afterInFlight(() => {
|
|
1625
|
+
this.ring.destroy();
|
|
1626
|
+
});
|
|
1627
|
+
if (this.ctx.state === "ready") {
|
|
1628
|
+
this.ctx.pool.trim();
|
|
1629
|
+
}
|
|
1630
|
+
this.unregisterLost();
|
|
1631
|
+
this.core = null;
|
|
1632
|
+
this.snapshot = null;
|
|
1633
|
+
this.owner = null;
|
|
1634
|
+
this.weightsUpload = null;
|
|
1635
|
+
}
|
|
1636
|
+
/** E_DISPOSED after dispose() or device loss. */
|
|
1637
|
+
assertNotDisposed() {
|
|
1638
|
+
if (this.stateValue === "disposed") {
|
|
1639
|
+
throw disposedError(this.label);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
/** E_NOT_LOADED before load(), E_DISPOSED after dispose(). */
|
|
1643
|
+
assertLoaded() {
|
|
1644
|
+
if (this.stateValue === "created") {
|
|
1645
|
+
throw new WebGpuGraphError("E_NOT_LOADED", `${this.label}: load() first`, { state: "created" });
|
|
1646
|
+
}
|
|
1647
|
+
this.assertNotDisposed();
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
//# sourceMappingURL=force-simulation.js.map
|