@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,4454 @@
|
|
|
1
|
+
import { W as WebGpuGraphError, M as MAX_WORKGROUPS_PER_DIM, a as WGSL_RESERVED_WORDS, S as STATE_HEADER_BYTES, B as BufferUsage, i as isWebGpuGraphError, d as deviceLostError, U as UNIFORM_SLOT_BYTES, F as FA2_DEFAULTS, b as MAX_ITERATIONS_PER_STEP, c as MAX_1D_ITEMS, h as hasErrorCode, e as FA2_FLAG_FIRST, P as PARTIAL_BYTES, L as LAYOUT_TUNING_DEFAULTS, T as TRACE_RECORD_BYTES } from "./chunks/context-E6iKaeuJ.js";
|
|
2
|
+
import { A, E, G, f, g, j, k } from "./chunks/context-E6iKaeuJ.js";
|
|
3
|
+
import { makeMask, maskTest, expandEdges } from "@graphty/graph-format";
|
|
4
|
+
function perDimension(caps) {
|
|
5
|
+
return Math.min(MAX_WORKGROUPS_PER_DIM, caps.limits.maxComputeWorkgroupsPerDimension);
|
|
6
|
+
}
|
|
7
|
+
function assertCount(argument, value) {
|
|
8
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
9
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `${argument} must be a non-negative integer, got ${value}`, {
|
|
10
|
+
argument,
|
|
11
|
+
value,
|
|
12
|
+
expected: "a non-negative integer"
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function assertWorkgroupSize(wg) {
|
|
17
|
+
let rest = wg;
|
|
18
|
+
while (Number.isInteger(rest) && rest > 1 && rest % 2 === 0) {
|
|
19
|
+
rest /= 2;
|
|
20
|
+
}
|
|
21
|
+
if (rest !== 1) {
|
|
22
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `wg must be a power of two, got ${wg}`, {
|
|
23
|
+
argument: "wg",
|
|
24
|
+
value: wg,
|
|
25
|
+
expected: "a power of two >= 1"
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function grid(groups, items, caps) {
|
|
30
|
+
const limit = perDimension(caps);
|
|
31
|
+
if (groups === 0) {
|
|
32
|
+
return { x: 0, y: 1, z: 1, items, stride: null };
|
|
33
|
+
}
|
|
34
|
+
if (groups <= limit) {
|
|
35
|
+
return { x: groups, y: 1, z: 1, items, stride: null };
|
|
36
|
+
}
|
|
37
|
+
const y = Math.ceil(groups / limit);
|
|
38
|
+
if (y > limit) {
|
|
39
|
+
throw new WebGpuGraphError(
|
|
40
|
+
"E_TOO_LARGE",
|
|
41
|
+
`${groups} workgroups exceed the 2D dispatch limit of ${limit * limit}`,
|
|
42
|
+
{
|
|
43
|
+
needed: groups,
|
|
44
|
+
limit: limit * limit,
|
|
45
|
+
path: "dispatch",
|
|
46
|
+
algorithm: null,
|
|
47
|
+
items
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return { x: limit, y, z: 1, items, stride: null };
|
|
52
|
+
}
|
|
53
|
+
function plan1d(items, wg, caps) {
|
|
54
|
+
assertCount("items", items);
|
|
55
|
+
assertWorkgroupSize(wg);
|
|
56
|
+
return grid(Math.ceil(items / wg), items, caps);
|
|
57
|
+
}
|
|
58
|
+
const TYPE_INFO = Object.freeze({
|
|
59
|
+
u32: { size: 4, align: 4, width: 1, scalar: "u32" },
|
|
60
|
+
i32: { size: 4, align: 4, width: 1, scalar: "i32" },
|
|
61
|
+
f32: { size: 4, align: 4, width: 1, scalar: "f32" },
|
|
62
|
+
vec2f: { size: 8, align: 8, width: 2, scalar: "f32" },
|
|
63
|
+
vec2u: { size: 8, align: 8, width: 2, scalar: "u32" },
|
|
64
|
+
vec4f: { size: 16, align: 16, width: 4, scalar: "f32" },
|
|
65
|
+
vec4u: { size: 16, align: 16, width: 4, scalar: "u32" }
|
|
66
|
+
});
|
|
67
|
+
const STRUCT_ALIGN = 16;
|
|
68
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
69
|
+
const U32_LIMIT = 4294967295;
|
|
70
|
+
const I32_MIN = -2147483648;
|
|
71
|
+
const I32_MAX = 2147483647;
|
|
72
|
+
function roundUp(value, align) {
|
|
73
|
+
return Math.ceil(value / align) * align;
|
|
74
|
+
}
|
|
75
|
+
function assertIdentifier(argument, name) {
|
|
76
|
+
if (!IDENTIFIER.test(name) || WGSL_RESERVED_WORDS.includes(name)) {
|
|
77
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `UniformBlock: "${name}" is not a usable WGSL identifier`, {
|
|
78
|
+
argument,
|
|
79
|
+
value: name,
|
|
80
|
+
expected: "a WGSL identifier that is not a reserved word"
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function assertScalar(field, kind, value) {
|
|
85
|
+
if (typeof value !== "number") {
|
|
86
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `UniformBlock: field "${field}" expects a number`, {
|
|
87
|
+
argument: field,
|
|
88
|
+
value,
|
|
89
|
+
expected: kind
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (kind === "u32" && !(Number.isInteger(value) && value >= 0 && value <= U32_LIMIT)) {
|
|
93
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `UniformBlock: field "${field}" is not a u32`, {
|
|
94
|
+
argument: field,
|
|
95
|
+
value,
|
|
96
|
+
expected: "an integer in [0, 4294967295]"
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (kind === "i32" && !(Number.isInteger(value) && value >= I32_MIN && value <= I32_MAX)) {
|
|
100
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `UniformBlock: field "${field}" is not an i32`, {
|
|
101
|
+
argument: field,
|
|
102
|
+
value,
|
|
103
|
+
expected: "an integer in [-2147483648, 2147483647]"
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function setScalar(view, kind, offset, value) {
|
|
108
|
+
if (kind === "u32") {
|
|
109
|
+
view.setUint32(offset, value, true);
|
|
110
|
+
} else if (kind === "i32") {
|
|
111
|
+
view.setInt32(offset, value, true);
|
|
112
|
+
} else {
|
|
113
|
+
view.setFloat32(offset, value, true);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function getScalar(view, kind, offset) {
|
|
117
|
+
if (kind === "u32") {
|
|
118
|
+
return view.getUint32(offset, true);
|
|
119
|
+
}
|
|
120
|
+
if (kind === "i32") {
|
|
121
|
+
return view.getInt32(offset, true);
|
|
122
|
+
}
|
|
123
|
+
return view.getFloat32(offset, true);
|
|
124
|
+
}
|
|
125
|
+
class UniformBlock {
|
|
126
|
+
/**
|
|
127
|
+
* Declares a block; fields are laid out in order with 16-byte alignment for vec4 / the struct, 8 for vec2, 4 for
|
|
128
|
+
* scalars; the total is padded to 16 (uniform, storage) or to `padTo` when given.
|
|
129
|
+
* PLAN DECISION: rejects an empty field list and a struct or field name that is not a WGSL identifier or is a
|
|
130
|
+
* reserved word (spec 3.6: every throwing call leaves state unchanged; a reserved name would be invalid WGSL).
|
|
131
|
+
* PLAN DECISION: optional PARAMETERS (`options?` here, `byteOffset?` of write / read / readField) are spelled
|
|
132
|
+
* `?: T` rather than the contract's `?: T | undefined` because the root ESLint rule
|
|
133
|
+
* no-duplicate-type-constituents rejects the explicit undefined on an optional parameter; every call site is
|
|
134
|
+
* identical, and optional PROPERTIES keep `?: T | undefined`.
|
|
135
|
+
* @param name - the WGSL struct name
|
|
136
|
+
* @param fields - the fields in byte order
|
|
137
|
+
* @param options - the layout tag and the optional padding
|
|
138
|
+
* @param options.layout - "uniform" (default) or "storage"; the tag documents the address space, the layout is the same
|
|
139
|
+
* @param options.padTo - the byte length to pad to (a multiple of 16 not below the natural size)
|
|
140
|
+
* @returns the block
|
|
141
|
+
*/
|
|
142
|
+
static define(name, fields, options) {
|
|
143
|
+
assertIdentifier("name", name);
|
|
144
|
+
if (fields.length === 0) {
|
|
145
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `UniformBlock ${name}: a block needs at least one field`, {
|
|
146
|
+
argument: "fields",
|
|
147
|
+
value: fields.length,
|
|
148
|
+
expected: ">= 1 field"
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const laidOut = [];
|
|
152
|
+
const table = /* @__PURE__ */ new Map();
|
|
153
|
+
let cursor = 0;
|
|
154
|
+
for (const [fieldName, type] of fields) {
|
|
155
|
+
assertIdentifier("fields", fieldName);
|
|
156
|
+
if (table.has(fieldName)) {
|
|
157
|
+
throw new WebGpuGraphError(
|
|
158
|
+
"E_INVALID_ARGUMENT",
|
|
159
|
+
`UniformBlock ${name}: duplicate field "${fieldName}"`,
|
|
160
|
+
{
|
|
161
|
+
argument: "fields",
|
|
162
|
+
value: fieldName,
|
|
163
|
+
expected: "unique field names"
|
|
164
|
+
}
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const info = TYPE_INFO[type];
|
|
168
|
+
if (info === void 0) {
|
|
169
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `UniformBlock ${name}: unknown field type "${type}"`, {
|
|
170
|
+
argument: "fields",
|
|
171
|
+
value: type,
|
|
172
|
+
expected: Object.keys(TYPE_INFO).join(" | ")
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const offset = roundUp(cursor, info.align);
|
|
176
|
+
const field = { name: fieldName, type, offset, info };
|
|
177
|
+
laidOut.push(field);
|
|
178
|
+
table.set(fieldName, field);
|
|
179
|
+
cursor = offset + info.size;
|
|
180
|
+
}
|
|
181
|
+
const natural = roundUp(cursor, STRUCT_ALIGN);
|
|
182
|
+
const padTo = options?.padTo;
|
|
183
|
+
if (padTo !== void 0 && (!Number.isInteger(padTo) || padTo % STRUCT_ALIGN !== 0 || padTo < natural)) {
|
|
184
|
+
throw new WebGpuGraphError(
|
|
185
|
+
"E_INVALID_ARGUMENT",
|
|
186
|
+
`UniformBlock ${name}: padTo ${padTo} is not a multiple of 16 >= ${natural}`,
|
|
187
|
+
{
|
|
188
|
+
argument: "padTo",
|
|
189
|
+
value: padTo,
|
|
190
|
+
expected: `a multiple of ${STRUCT_ALIGN} not below ${natural}`
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const byteLength = padTo ?? natural;
|
|
195
|
+
const lines = laidOut.map((field, index) => {
|
|
196
|
+
const last = index === laidOut.length - 1;
|
|
197
|
+
const end = field.offset + field.info.size;
|
|
198
|
+
const size = last && end < byteLength ? `@size(${byteLength - field.offset}) ` : "";
|
|
199
|
+
return ` ${size}${field.name}: ${field.type},`;
|
|
200
|
+
});
|
|
201
|
+
const wgsl = `struct ${name} {
|
|
202
|
+
${lines.join("\n")}
|
|
203
|
+
}`;
|
|
204
|
+
return new UniformBlock(name, options?.layout ?? "uniform", fields, byteLength, wgsl, laidOut, table);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Built by `define()` only.
|
|
208
|
+
* @param name - the struct name
|
|
209
|
+
* @param layout - the address-space tag
|
|
210
|
+
* @param fields - the declared fields
|
|
211
|
+
* @param byteLength - the padded byte length
|
|
212
|
+
* @param wgsl - the struct text
|
|
213
|
+
* @param laidOut - the laid-out fields in order
|
|
214
|
+
* @param table - the laid-out fields by name
|
|
215
|
+
*/
|
|
216
|
+
constructor(name, layout, fields, byteLength, wgsl, laidOut, table) {
|
|
217
|
+
this.name = name;
|
|
218
|
+
this.layout = layout;
|
|
219
|
+
this.fields = Object.freeze(fields.map(([fieldName, type]) => Object.freeze([fieldName, type])));
|
|
220
|
+
this.byteLength = byteLength;
|
|
221
|
+
this.wgsl = wgsl;
|
|
222
|
+
this.laidOut = laidOut;
|
|
223
|
+
this.table = table;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Byte offset of a field; E_INVALID_ARGUMENT for an unknown field.
|
|
227
|
+
* @param field - the field name
|
|
228
|
+
* @returns the byte offset inside the block
|
|
229
|
+
*/
|
|
230
|
+
offsetOf(field) {
|
|
231
|
+
return this.fieldLayout(field).offset;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Writes `values` at `byteOffset` (default 0); a missing field is written as 0; an unknown key is
|
|
235
|
+
* E_INVALID_ARGUMENT; a vector of the wrong width is E_INVALID_ARGUMENT. Always little-endian. Every byte of the
|
|
236
|
+
* block's region is written (padding as zero), so a written block is byte-exact.
|
|
237
|
+
* PLAN DECISION: every value is validated first (u32 / vec*u components integers in [0, 2^32 - 1], i32 in
|
|
238
|
+
* [-2^31, 2^31 - 1]) and only then is the region zeroed and written, so a rejected call leaves the view unchanged.
|
|
239
|
+
* @param view - the target view (the region must lie inside it)
|
|
240
|
+
* @param values - the field values
|
|
241
|
+
* @param byteOffset - the region's start inside the view
|
|
242
|
+
*/
|
|
243
|
+
write(view, values, byteOffset) {
|
|
244
|
+
const base = this.regionStart(view, byteOffset);
|
|
245
|
+
for (const key of Object.keys(values)) {
|
|
246
|
+
if (!this.table.has(key)) {
|
|
247
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `UniformBlock ${this.name}: unknown field "${key}"`, {
|
|
248
|
+
argument: "values",
|
|
249
|
+
value: key,
|
|
250
|
+
expected: this.laidOut.map((f2) => f2.name).join(", ")
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const components = this.laidOut.map(
|
|
255
|
+
(field) => this.components(field, values[field.name])
|
|
256
|
+
);
|
|
257
|
+
for (let at = 0; at < this.byteLength; at += 4) {
|
|
258
|
+
view.setUint32(base + at, 0, true);
|
|
259
|
+
}
|
|
260
|
+
this.laidOut.forEach((field, index) => {
|
|
261
|
+
const lanes = components[index];
|
|
262
|
+
if (lanes === null) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
lanes.forEach((component, lane) => {
|
|
266
|
+
setScalar(view, field.info.scalar, base + field.offset + 4 * lane, component);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The validated components of one field's value: null for an absent field, else `width` numbers.
|
|
272
|
+
* @param field - the laid-out field
|
|
273
|
+
* @param value - the value given for it (undefined when absent)
|
|
274
|
+
* @returns the components or null
|
|
275
|
+
*/
|
|
276
|
+
components(field, value) {
|
|
277
|
+
if (value === void 0) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
const { scalar: scalar2, width } = field.info;
|
|
281
|
+
if (width === 1) {
|
|
282
|
+
assertScalar(field.name, scalar2, value);
|
|
283
|
+
return [value];
|
|
284
|
+
}
|
|
285
|
+
if (!Array.isArray(value) || value.length !== width) {
|
|
286
|
+
throw new WebGpuGraphError(
|
|
287
|
+
"E_INVALID_ARGUMENT",
|
|
288
|
+
`UniformBlock ${this.name}: field "${field.name}" expects ${width} components`,
|
|
289
|
+
{
|
|
290
|
+
argument: field.name,
|
|
291
|
+
value,
|
|
292
|
+
expected: `an array of ${width} numbers`
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const lanes = [];
|
|
297
|
+
for (let lane = 0; lane < width; lane++) {
|
|
298
|
+
const component = value[lane];
|
|
299
|
+
assertScalar(field.name, scalar2, component);
|
|
300
|
+
lanes.push(component);
|
|
301
|
+
}
|
|
302
|
+
return lanes;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Reads every field at `byteOffset` (storage mode's reader; also used by tests on uniform blocks).
|
|
306
|
+
* @param view - the source view
|
|
307
|
+
* @param byteOffset - the region's start inside the view
|
|
308
|
+
* @returns the field values (vectors as arrays)
|
|
309
|
+
*/
|
|
310
|
+
read(view, byteOffset) {
|
|
311
|
+
const base = this.regionStart(view, byteOffset);
|
|
312
|
+
const out = {};
|
|
313
|
+
for (const field of this.laidOut) {
|
|
314
|
+
out[field.name] = this.decode(view, field, base);
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Reads one field.
|
|
320
|
+
* @param view - the source view
|
|
321
|
+
* @param field - the field name
|
|
322
|
+
* @param byteOffset - the region's start inside the view
|
|
323
|
+
* @returns the value (a number, or an array for a vector)
|
|
324
|
+
*/
|
|
325
|
+
readField(view, field, byteOffset) {
|
|
326
|
+
const layout = this.fieldLayout(field);
|
|
327
|
+
return this.decode(view, layout, this.regionStart(view, byteOffset));
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The laid-out field of a name.
|
|
331
|
+
* @param field - the field name
|
|
332
|
+
* @returns the layout
|
|
333
|
+
*/
|
|
334
|
+
fieldLayout(field) {
|
|
335
|
+
const layout = this.table.get(field);
|
|
336
|
+
if (layout === void 0) {
|
|
337
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `UniformBlock ${this.name}: unknown field "${field}"`, {
|
|
338
|
+
argument: "field",
|
|
339
|
+
value: field,
|
|
340
|
+
expected: this.laidOut.map((f2) => f2.name).join(", ")
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return layout;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Validates the region `[byteOffset, byteOffset + byteLength)` against the view.
|
|
347
|
+
* @param view - the view
|
|
348
|
+
* @param byteOffset - the requested start (default 0)
|
|
349
|
+
* @returns the start
|
|
350
|
+
*/
|
|
351
|
+
regionStart(view, byteOffset) {
|
|
352
|
+
const base = byteOffset ?? 0;
|
|
353
|
+
if (!Number.isInteger(base) || base < 0 || base + this.byteLength > view.byteLength) {
|
|
354
|
+
throw new WebGpuGraphError(
|
|
355
|
+
"E_INVALID_ARGUMENT",
|
|
356
|
+
`UniformBlock ${this.name}: region [${base}, ${base + this.byteLength}) is outside the view`,
|
|
357
|
+
{
|
|
358
|
+
argument: "byteOffset",
|
|
359
|
+
value: base,
|
|
360
|
+
expected: `0 <= byteOffset <= ${view.byteLength - this.byteLength}`
|
|
361
|
+
}
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
return base;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Decodes one field from a view.
|
|
368
|
+
* @param view - the source view
|
|
369
|
+
* @param field - the laid-out field
|
|
370
|
+
* @param base - the region start
|
|
371
|
+
* @returns the value
|
|
372
|
+
*/
|
|
373
|
+
decode(view, field, base) {
|
|
374
|
+
const { scalar: scalar2, width } = field.info;
|
|
375
|
+
if (width === 1) {
|
|
376
|
+
return getScalar(view, scalar2, base + field.offset);
|
|
377
|
+
}
|
|
378
|
+
const lanes = [];
|
|
379
|
+
for (let lane = 0; lane < width; lane++) {
|
|
380
|
+
lanes.push(getScalar(view, scalar2, base + field.offset + 4 * lane));
|
|
381
|
+
}
|
|
382
|
+
return lanes;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const degreeWgsl = (
|
|
386
|
+
/* wgsl */
|
|
387
|
+
`
|
|
388
|
+
@compute @workgroup_size(WG)
|
|
389
|
+
fn degree(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_id) lid: vec3<u32>) {
|
|
390
|
+
let row = linear_id(wid, lid.x) + P.start;
|
|
391
|
+
if (row >= P.end) { return; }
|
|
392
|
+
let i = select(row, perm[row], USE_PERM);
|
|
393
|
+
let a0 = max(rowPtr[i], P.arcBase);
|
|
394
|
+
let a1 = min(rowPtr[i + 1u], P.arcEnd);
|
|
395
|
+
var d = 0u;
|
|
396
|
+
for (var arc = a0; arc < a1; arc = arc + 1u) {
|
|
397
|
+
let nbr = colIdx[arc - P.arcBase]; // \`target\` is a WGSL reserved word (spec 16.2); never use it as an identifier
|
|
398
|
+
d = d + select(0u, 1u, nbr < P.n);
|
|
399
|
+
}
|
|
400
|
+
out[i] = select(d, out[i] + d, P.accumulate == 1u);
|
|
401
|
+
}
|
|
402
|
+
`
|
|
403
|
+
);
|
|
404
|
+
const fa2AttractionWgsl = (
|
|
405
|
+
/* wgsl */
|
|
406
|
+
`fn store_force(i: u32, f: vec3f) {
|
|
407
|
+
force[3u * i] = f.x;
|
|
408
|
+
force[3u * i + 1u] = f.y;
|
|
409
|
+
force[3u * i + 2u] = f.z;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
@compute @workgroup_size(WG)
|
|
413
|
+
fn attraction(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_id) lid: vec3<u32>) {
|
|
414
|
+
let row = linear_id(wid, lid.x) + P.tierStart;
|
|
415
|
+
if (row >= P.tierEnd) { return; } // no barrier follows in this tier (3.5 rule 1)
|
|
416
|
+
let i = select(row, perm[row], USE_PERM);
|
|
417
|
+
let pi = pos[i]; // xyz + mass in one load (D23)
|
|
418
|
+
var f = vec3f(0.0);
|
|
419
|
+
for (var a = rowPtr[i]; a < rowPtr[i + 1u]; a = a + 1u) {
|
|
420
|
+
let j = colIdx[a];
|
|
421
|
+
if (j == i) { continue; } // a self-loop exerts no force
|
|
422
|
+
var w = 1.0;
|
|
423
|
+
if (HAS_WEIGHTS) { w = weights[a]; }
|
|
424
|
+
let d = pos[j].xyz - pi.xyz; // toward j
|
|
425
|
+
let len = max(length(d), FA2_DIST_FLOOR);
|
|
426
|
+
let mag = select(w, w * log(1.0 + len) / len, LINLOG); // linear: |F| = w len; linlog: |F| = w log(1 + len)
|
|
427
|
+
f = f + d * mag;
|
|
428
|
+
}
|
|
429
|
+
if (DISTRIBUTED) { f = f / pi.w; }
|
|
430
|
+
store_force(i, f); // overwrites: attraction is the first writer of force each iteration
|
|
431
|
+
}`
|
|
432
|
+
);
|
|
433
|
+
const fa2IntegrateWgsl = (
|
|
434
|
+
/* wgsl */
|
|
435
|
+
`fn load_force(i: u32) -> vec3f { return vec3f(force[3u * i], force[3u * i + 1u], force[3u * i + 2u]); }
|
|
436
|
+
fn load_old(i: u32) -> vec3f { return vec3f(oldForce[3u * i], oldForce[3u * i + 1u], oldForce[3u * i + 2u]); }
|
|
437
|
+
fn store_old(i: u32, f: vec3f) {
|
|
438
|
+
oldForce[3u * i] = f.x;
|
|
439
|
+
oldForce[3u * i + 1u] = f.y;
|
|
440
|
+
oldForce[3u * i + 2u] = f.z;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
@compute @workgroup_size(WG)
|
|
444
|
+
fn integrate(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_id) lid: vec3<u32>) {
|
|
445
|
+
let i = linear_id(wid, lid.x);
|
|
446
|
+
var dp = vec3f(0.0);
|
|
447
|
+
var p = vec4f(0.0);
|
|
448
|
+
var free = false;
|
|
449
|
+
var valid = false;
|
|
450
|
+
if (i < P.n) {
|
|
451
|
+
valid = true;
|
|
452
|
+
let f = load_force(i);
|
|
453
|
+
p = pos[i];
|
|
454
|
+
var swing_i = p.w * length(f); // SWING_MODE 1: NetworkX's local swinging m |F| (layout.py line 1497)
|
|
455
|
+
if (SWING_MODE == 0u) { swing_i = p.w * length(f - load_old(i)); } // paper: m |F(t) - F(t-1)|, recomputed inline (7.2)
|
|
456
|
+
let factor = S.speed / (1.0 + sqrt(S.speed * swing_i));
|
|
457
|
+
let fixed = mask_bit(fixedMask[i >> 5u], i);
|
|
458
|
+
dp = select(f * factor, vec3f(0.0), fixed); // no clamp on dp (D25)
|
|
459
|
+
if (P.dim == 2u) { dp.z = 0.0; } // 2D never integrates z (7.13)
|
|
460
|
+
p = vec4f(p.xyz + dp, p.w);
|
|
461
|
+
pos[i] = p;
|
|
462
|
+
if (SWING_MODE == 0u) { store_old(i, f); } // fixed nodes too, so a later unpin sees no stale swing (7.11)
|
|
463
|
+
free = !fixed;
|
|
464
|
+
}
|
|
465
|
+
// uniform control flow from here (3.5 rule 1): partials A (sum p, sum |p - c|^2, min, max over valid rows) and C (sum |dp|, free count)
|
|
466
|
+
let c = S.centroid.xyz;
|
|
467
|
+
var sumv = vec4f(0.0);
|
|
468
|
+
var lo = vec4f(F32_MAX);
|
|
469
|
+
var hi = vec4f(-F32_MAX);
|
|
470
|
+
var dl = 0.0;
|
|
471
|
+
var fr = 0u;
|
|
472
|
+
if (valid) {
|
|
473
|
+
let q = p.xyz - c;
|
|
474
|
+
sumv = vec4f(p.xyz, dot(q, q));
|
|
475
|
+
lo = vec4f(p.xyz, 0.0);
|
|
476
|
+
hi = vec4f(p.xyz, dot(q, q)); // max.w carries max |p - c|^2 so K1 can write the exact layoutRadius (spec 3.3)
|
|
477
|
+
}
|
|
478
|
+
if (free) { dl = length(dp); fr = 1u; }
|
|
479
|
+
let tSum = wg_reduce_vec4(sumv, lid.x, 0u);
|
|
480
|
+
let tLo = wg_reduce_vec4(lo, lid.x, 1u);
|
|
481
|
+
let tHi = wg_reduce_vec4(hi, lid.x, 2u);
|
|
482
|
+
let tDl = wg_reduce_f32(dl, lid.x, 0u);
|
|
483
|
+
let tFr = wg_reduce_u32(fr, lid.x, 0u);
|
|
484
|
+
if (lid.x == 0u) {
|
|
485
|
+
let g = group_id(wid);
|
|
486
|
+
partials[g].sum = tSum;
|
|
487
|
+
partials[g].min = tLo;
|
|
488
|
+
partials[g].max = tHi;
|
|
489
|
+
partials[g].dispFree = vec2f(tDl, f32(tFr));
|
|
490
|
+
}
|
|
491
|
+
}`
|
|
492
|
+
);
|
|
493
|
+
const fa2RepulsionExactWgsl = (
|
|
494
|
+
/* wgsl */
|
|
495
|
+
`
|
|
496
|
+
var<workgroup> tile: array<vec4f, WG>; // xyz + mass, 4 KiB at WG = 256
|
|
497
|
+
|
|
498
|
+
fn load_force(i: u32) -> vec3f { return vec3f(force[3u * i], force[3u * i + 1u], force[3u * i + 2u]); }
|
|
499
|
+
fn store_force(i: u32, f: vec3f) {
|
|
500
|
+
force[3u * i] = f.x;
|
|
501
|
+
force[3u * i + 1u] = f.y;
|
|
502
|
+
force[3u * i + 2u] = f.z;
|
|
503
|
+
}
|
|
504
|
+
fn load_old(i: u32) -> vec3f { return vec3f(oldForce[3u * i], oldForce[3u * i + 1u], oldForce[3u * i + 2u]); }
|
|
505
|
+
fn gravity_force(pi: vec4f) -> vec3f { // spec 7.9: centroid (GRAVITY_CENTER 0) or origin (1); regular or strong
|
|
506
|
+
var q = pi.xyz;
|
|
507
|
+
if (GRAVITY_CENTER == 0u) { q = pi.xyz - S.centroid.xyz; }
|
|
508
|
+
if (STRONG_GRAVITY) { return -P.gravity * pi.w * q; }
|
|
509
|
+
let d = length(q);
|
|
510
|
+
if (d > FA2_DIST_FLOOR) { return -P.gravity * pi.w * q / d; }
|
|
511
|
+
return vec3f(0.0);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
@compute @workgroup_size(WG)
|
|
515
|
+
fn repulsion(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_id) lid: vec3<u32>) {
|
|
516
|
+
let i = linear_id(wid, lid.x);
|
|
517
|
+
let valid = i < P.n;
|
|
518
|
+
var pi = vec4f(0.0);
|
|
519
|
+
if (valid) { pi = pos[i]; }
|
|
520
|
+
var f = vec3f(0.0);
|
|
521
|
+
let tiles = (P.n + WG - 1u) / WG;
|
|
522
|
+
for (var t = 0u; t < tiles; t = t + 1u) {
|
|
523
|
+
let j = t * WG + lid.x;
|
|
524
|
+
if (j < P.n) { tile[lid.x] = pos[j]; } else { tile[lid.x] = vec4f(0.0); } // guarded fill; mass 0 marks the pad
|
|
525
|
+
workgroupBarrier(); // uniform: every invocation reaches it
|
|
526
|
+
for (var s = 0u; s < WG; s = s + 1u) {
|
|
527
|
+
let o = tile[s];
|
|
528
|
+
let jj = t * WG + s;
|
|
529
|
+
if (o.w > 0.0 && jj != i) { // mass > 0 for every real node, 0 for the pad
|
|
530
|
+
let d = pi.xyz - o.xyz;
|
|
531
|
+
var d2 = dot(d, d);
|
|
532
|
+
if (d2 < FA2_COINCIDENT_SQ) { // coincident: antisymmetric unit kick of magnitude k m_i m_j / 0.01 (7.2)
|
|
533
|
+
f = f + kick_dir(i, jj, P.dim) * (P.scalingRatio * pi.w * o.w / FA2_DIST_FLOOR);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
d2 = max(d2, FA2_DIST_FLOOR_SQ); // d >= 0.01
|
|
537
|
+
let k = P.scalingRatio * pi.w * o.w;
|
|
538
|
+
f = f + d * (k / d2); // |F| = k m_i m_j / d along d / d
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
workgroupBarrier();
|
|
542
|
+
}
|
|
543
|
+
// epilogue (7.9, 7.10): gravity and force += under the guard, the swing / traction reduction outside it
|
|
544
|
+
var sw = 0.0;
|
|
545
|
+
var tr = 0.0;
|
|
546
|
+
if (valid) {
|
|
547
|
+
f = f + gravity_force(pi);
|
|
548
|
+
let fnew = load_force(i) + f;
|
|
549
|
+
store_force(i, fnew);
|
|
550
|
+
if (SWING_MODE == 1u) { // NetworkX: positions and forces mixed, every node (7.2)
|
|
551
|
+
sw = pi.w * length(pi.xyz - fnew);
|
|
552
|
+
tr = 0.5 * pi.w * length(pi.xyz + fnew);
|
|
553
|
+
} else if (!mask_bit(fixedMask[i >> 5u], i)) { // paper: free nodes only (Gephi ForceAtlas2.java 283-293)
|
|
554
|
+
let fold = load_old(i);
|
|
555
|
+
sw = pi.w * length(fnew - fold);
|
|
556
|
+
tr = 0.5 * pi.w * length(fnew + fold);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
let t = wg_reduce_vec4(vec4f(sw, tr, 0.0, 0.0), lid.x, 0u); // uniform control flow: 256 -> 1
|
|
560
|
+
if (lid.x == 0u) { partials[group_id(wid)].swingTraction = t.xy; }
|
|
561
|
+
}
|
|
562
|
+
`
|
|
563
|
+
);
|
|
564
|
+
const fa2SpeedFinalizeWgsl = (
|
|
565
|
+
/* wgsl */
|
|
566
|
+
`
|
|
567
|
+
@compute @workgroup_size(WG)
|
|
568
|
+
fn speed_finalize(@builtin(local_invocation_id) lid: vec3<u32>) {
|
|
569
|
+
let groups = (P.n + WG - 1u) / WG;
|
|
570
|
+
var st = vec2f(0.0);
|
|
571
|
+
for (var g = lid.x; g < groups; g = g + WG) { st = st + partials[g].swingTraction; } // sequential per lane: deterministic
|
|
572
|
+
let t = wg_reduce_vec4(vec4f(st, 0.0, 0.0), lid.x, 0u);
|
|
573
|
+
if (lid.x == 0u) {
|
|
574
|
+
var swing = t.x;
|
|
575
|
+
var traction = t.y;
|
|
576
|
+
if (SWING_MODE == 1u) { swing = S.swing + t.x; traction = S.traction + t.y; } // NetworkX accumulates across iterations from 1
|
|
577
|
+
let n = f32(P.n);
|
|
578
|
+
let optJitter = 0.05 * sqrt(n);
|
|
579
|
+
let minJitter = sqrt(optJitter);
|
|
580
|
+
let maxJitter = 10.0;
|
|
581
|
+
let tr = max(traction, 1.0e-30); // guards the division only (7.10)
|
|
582
|
+
let other = min(maxJitter, optJitter * traction / (n * n));
|
|
583
|
+
var jitter = P.jitterTolerance * max(minJitter, other);
|
|
584
|
+
var eff = S.speedEfficiency;
|
|
585
|
+
if (swing > 2.0 * tr) { // swing / traction > 2 in the exact form (contract 4.5 CONTRACT DECISION K4-1: 2 x is exact, a WGSL f32 division is not)
|
|
586
|
+
if (eff > 0.05) { eff = eff * 0.5; } // the CPU's conditional multiply (7.2)
|
|
587
|
+
jitter = max(jitter, P.jitterTolerance);
|
|
588
|
+
}
|
|
589
|
+
let targetSpeed = select(jitter * eff * traction / swing, 1.0e30, swing == 0.0); // +Inf in the port; 1e30 gives the same min() below (\`target\` is reserved)
|
|
590
|
+
if (swing > jitter * traction) {
|
|
591
|
+
if (eff > 0.05) { eff = eff * 0.7; }
|
|
592
|
+
} else if (S.speed < 1000.0) {
|
|
593
|
+
eff = eff * 1.3;
|
|
594
|
+
}
|
|
595
|
+
S.speed = S.speed + min(targetSpeed - S.speed, 0.5 * S.speed);
|
|
596
|
+
S.speedEfficiency = eff;
|
|
597
|
+
S.swing = swing;
|
|
598
|
+
S.traction = traction;
|
|
599
|
+
T[P.iterationIndex].swing = swing;
|
|
600
|
+
T[P.iterationIndex].traction = traction;
|
|
601
|
+
T[P.iterationIndex].speed = S.speed;
|
|
602
|
+
T[P.iterationIndex].speedEfficiency = eff;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
`
|
|
606
|
+
);
|
|
607
|
+
const fa2StatsFinalizeWgsl = (
|
|
608
|
+
/* wgsl */
|
|
609
|
+
`// K1: folds the previous integrate's partials into the state block (spec 7.4); one workgroup
|
|
610
|
+
@compute @workgroup_size(WG)
|
|
611
|
+
fn stats_finalize(@builtin(local_invocation_id) lid: vec3<u32>) {
|
|
612
|
+
let groups = (P.n + WG - 1u) / WG;
|
|
613
|
+
let fold = (P.flags & FA2_FLAG_FIRST) == 0u; // the first iteration after load() keeps the host-written state
|
|
614
|
+
var sum = vec4f(0.0);
|
|
615
|
+
var lo = vec4f(F32_MAX);
|
|
616
|
+
var hi = vec4f(-F32_MAX);
|
|
617
|
+
var disp = 0.0;
|
|
618
|
+
var free = 0u;
|
|
619
|
+
if (fold) {
|
|
620
|
+
for (var g = lid.x; g < groups; g = g + WG) { // sequential per lane in index order: deterministic
|
|
621
|
+
let q = partials[g];
|
|
622
|
+
sum = sum + q.sum;
|
|
623
|
+
lo = min(lo, q.min);
|
|
624
|
+
hi = max(hi, q.max);
|
|
625
|
+
disp = disp + q.dispFree.x;
|
|
626
|
+
free = free + u32(q.dispFree.y);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
let tSum = wg_reduce_vec4(sum, lid.x, 0u);
|
|
630
|
+
let tLo = wg_reduce_vec4(lo, lid.x, 1u);
|
|
631
|
+
let tHi = wg_reduce_vec4(hi, lid.x, 2u);
|
|
632
|
+
let tDisp = wg_reduce_f32(disp, lid.x, 0u);
|
|
633
|
+
let tFree = wg_reduce_u32(free, lid.x, 0u);
|
|
634
|
+
if (lid.x == 0u) {
|
|
635
|
+
if (fold) {
|
|
636
|
+
let n = f32(P.n);
|
|
637
|
+
let c = tSum.xyz / n;
|
|
638
|
+
S.centroid = vec4f(c, 0.0);
|
|
639
|
+
S.rmsRadius = sqrt(max(tSum.w, 0.0) / n); // RMS radius about the previous centroid (7.17)
|
|
640
|
+
S.min = vec4f(tLo.xyz, 0.0);
|
|
641
|
+
S.max = vec4f(tHi.xyz, 0.0);
|
|
642
|
+
S.radius = sqrt(max(tHi.w, 0.0)); // max |p - centroid| about the same previous centroid as rmsRadius (K5 puts |q|^2 in max.w)
|
|
643
|
+
let meanDisp = select(tDisp / f32(tFree), 0.0, tFree == 0u); // all-fixed: 0, never NaN (7.4)
|
|
644
|
+
S.meanDisplacement = meanDisp;
|
|
645
|
+
S.settledCount = select(0u, S.settledCount + 1u, meanDisp <= P.settleThreshold * S.rmsRadius);
|
|
646
|
+
}
|
|
647
|
+
S.iteration = S.iteration + 1u;
|
|
648
|
+
T[P.iterationIndex].meanDisplacement = S.meanDisplacement;
|
|
649
|
+
T[P.iterationIndex].settledCount = S.settledCount;
|
|
650
|
+
T[P.iterationIndex].iteration = S.iteration;
|
|
651
|
+
}
|
|
652
|
+
}`
|
|
653
|
+
);
|
|
654
|
+
const fa2ToSceneWgsl = (
|
|
655
|
+
/* wgsl */
|
|
656
|
+
`@compute @workgroup_size(WG)
|
|
657
|
+
fn to_scene(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_id) lid: vec3<u32>) {
|
|
658
|
+
let i = linear_id(wid, lid.x);
|
|
659
|
+
if (i >= P.n) { return; }
|
|
660
|
+
let s = pos[i].xyz * P.scale + P.center.xyz;
|
|
661
|
+
scene[3u * i] = s.x;
|
|
662
|
+
scene[3u * i + 1u] = s.y;
|
|
663
|
+
scene[3u * i + 2u] = select(s.z, P.center.z, P.dim == 2u); // 2D writes z = center.z on every readback (7.13)
|
|
664
|
+
}`
|
|
665
|
+
);
|
|
666
|
+
const fillWgsl = (
|
|
667
|
+
/* wgsl */
|
|
668
|
+
`
|
|
669
|
+
@compute @workgroup_size(WG)
|
|
670
|
+
fn fill(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_id) lid: vec3<u32>) {
|
|
671
|
+
let i = linear_id(wid, lid.x);
|
|
672
|
+
if (i >= P.count) { return; }
|
|
673
|
+
dst[i] = select(P.value, i + P.value, P.mode == 1u);
|
|
674
|
+
}
|
|
675
|
+
`
|
|
676
|
+
);
|
|
677
|
+
const reduceWgsl = (
|
|
678
|
+
/* wgsl */
|
|
679
|
+
`
|
|
680
|
+
// DTYPE 0 = f32, 1 = u32, 2 = vec4f (4 words per element); OP 0 = sum, 1 = min, 2 = max; FINAL = the one-workgroup level
|
|
681
|
+
fn identity_f() -> f32 { if (OP == 1u) { return F32_MAX; } if (OP == 2u) { return -F32_MAX; } return 0.0; }
|
|
682
|
+
fn identity_u() -> u32 { if (OP == 1u) { return U32_MAX; } return 0u; } // U32_MAX from the prelude: the literal is forbidden in bodies (4.1)
|
|
683
|
+
fn comb_f(a: f32, b: f32) -> f32 { if (OP == 1u) { return min(a, b); } if (OP == 2u) { return max(a, b); } return a + b; }
|
|
684
|
+
fn comb_u(a: u32, b: u32) -> u32 { if (OP == 1u) { return min(a, b); } if (OP == 2u) { return max(a, b); } return a + b; }
|
|
685
|
+
fn comb_v(a: vec4f, b: vec4f) -> vec4f { if (OP == 1u) { return min(a, b); } if (OP == 2u) { return max(a, b); } return a + b; }
|
|
686
|
+
fn load_f(i: u32) -> f32 { return bitcast<f32>(src[i]); }
|
|
687
|
+
fn load_v(i: u32) -> vec4f {
|
|
688
|
+
return vec4f(bitcast<f32>(src[4u * i]), bitcast<f32>(src[4u * i + 1u]), bitcast<f32>(src[4u * i + 2u]), bitcast<f32>(src[4u * i + 3u]));
|
|
689
|
+
}
|
|
690
|
+
fn store_f(i: u32, v: f32) { out[i] = bitcast<u32>(v); }
|
|
691
|
+
fn store_v(i: u32, v: vec4f) {
|
|
692
|
+
out[4u * i] = bitcast<u32>(v.x);
|
|
693
|
+
out[4u * i + 1u] = bitcast<u32>(v.y);
|
|
694
|
+
out[4u * i + 2u] = bitcast<u32>(v.z);
|
|
695
|
+
out[4u * i + 3u] = bitcast<u32>(v.w);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
@compute @workgroup_size(WG)
|
|
699
|
+
fn reduce(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_id) lid: vec3<u32>) {
|
|
700
|
+
var accF = identity_f();
|
|
701
|
+
var accU = identity_u();
|
|
702
|
+
var accV = vec4f(identity_f());
|
|
703
|
+
if (FINAL) {
|
|
704
|
+
for (var i = lid.x; i < P.count; i = i + WG) { // sequential per lane in index order: deterministic
|
|
705
|
+
if (DTYPE == 0u) { accF = comb_f(accF, load_f(i)); }
|
|
706
|
+
else if (DTYPE == 1u) { accU = comb_u(accU, src[i]); }
|
|
707
|
+
else { accV = comb_v(accV, load_v(i)); }
|
|
708
|
+
}
|
|
709
|
+
} else {
|
|
710
|
+
let i = linear_id(wid, lid.x);
|
|
711
|
+
if (i < P.count) {
|
|
712
|
+
if (DTYPE == 0u) { accF = load_f(i); }
|
|
713
|
+
else if (DTYPE == 1u) { accU = src[i]; }
|
|
714
|
+
else { accV = load_v(i); }
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
// uniform control flow: the workgroup reduction of the selected dtype (DTYPE is a pipeline constant, so the branch is uniform)
|
|
718
|
+
var tF = 0.0;
|
|
719
|
+
var tU = 0u;
|
|
720
|
+
var tV = vec4f(0.0);
|
|
721
|
+
if (DTYPE == 0u) { tF = wg_reduce_f32(accF, lid.x, OP); }
|
|
722
|
+
else if (DTYPE == 1u) { tU = wg_reduce_u32(accU, lid.x, OP); }
|
|
723
|
+
else { tV = wg_reduce_vec4(accV, lid.x, OP); }
|
|
724
|
+
if (lid.x == 0u) {
|
|
725
|
+
let g = select(group_id(wid), 0u, FINAL);
|
|
726
|
+
let o = P.outOffset + g;
|
|
727
|
+
if (DTYPE == 0u) { store_f(o, tF); }
|
|
728
|
+
else if (DTYPE == 1u) { out[o] = tU; }
|
|
729
|
+
else { store_v(o, tV); }
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
`
|
|
733
|
+
);
|
|
734
|
+
const segmentedReduceWgsl = (
|
|
735
|
+
/* wgsl */
|
|
736
|
+
`
|
|
737
|
+
fn identity() -> f32 { if (OP == 1u) { return F32_MAX; } if (OP == 2u) { return -F32_MAX; } return 0.0; }
|
|
738
|
+
fn comb(a: f32, b: f32) -> f32 { if (OP == 1u) { return min(a, b); } if (OP == 2u) { return max(a, b); } return a + b; }
|
|
739
|
+
|
|
740
|
+
@compute @workgroup_size(WG)
|
|
741
|
+
fn segmented_reduce(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_id) lid: vec3<u32>) {
|
|
742
|
+
let row = linear_id(wid, lid.x) + P.start;
|
|
743
|
+
if (row >= P.end) { return; }
|
|
744
|
+
let i = select(row, perm[row], USE_PERM);
|
|
745
|
+
let a0 = max(rowPtr[i], P.arcBase);
|
|
746
|
+
let a1 = min(rowPtr[i + 1u], P.arcEnd);
|
|
747
|
+
var acc = identity();
|
|
748
|
+
for (var arc = a0; arc < a1; arc = arc + 1u) {
|
|
749
|
+
let nbr = colIdx[arc - P.arcBase]; // the neighbour index (\`target\` is a WGSL reserved word)
|
|
750
|
+
var weight = 1.0;
|
|
751
|
+
if (HAS_WEIGHTS) { weight = weights[arc - P.arcBase]; }
|
|
752
|
+
var v = 0.0;
|
|
753
|
+
//@@VALUE@@
|
|
754
|
+
acc = comb(acc, v);
|
|
755
|
+
}
|
|
756
|
+
out[i] = select(acc, comb(out[i], acc), P.accumulate == 1u);
|
|
757
|
+
}
|
|
758
|
+
`
|
|
759
|
+
);
|
|
760
|
+
const RANGE_PARAMS = UniformBlock.define("RangeParams", [
|
|
761
|
+
["start", "u32"],
|
|
762
|
+
["end", "u32"],
|
|
763
|
+
["arcBase", "u32"],
|
|
764
|
+
["arcEnd", "u32"],
|
|
765
|
+
["accumulate", "u32"],
|
|
766
|
+
["n", "u32"],
|
|
767
|
+
["pad0", "u32"],
|
|
768
|
+
["pad1", "u32"]
|
|
769
|
+
]);
|
|
770
|
+
const REDUCE_PARAMS = UniformBlock.define("ReduceParams", [
|
|
771
|
+
["count", "u32"],
|
|
772
|
+
["outOffset", "u32"],
|
|
773
|
+
["level", "u32"],
|
|
774
|
+
["pad0", "u32"]
|
|
775
|
+
]);
|
|
776
|
+
const FILL_PARAMS = UniformBlock.define("FillParams", [
|
|
777
|
+
["count", "u32"],
|
|
778
|
+
["value", "u32"],
|
|
779
|
+
["mode", "u32"],
|
|
780
|
+
["pad0", "u32"]
|
|
781
|
+
]);
|
|
782
|
+
const FA2_PARAMS = UniformBlock.define("Fa2Params", [
|
|
783
|
+
["n", "u32"],
|
|
784
|
+
["dim", "u32"],
|
|
785
|
+
["flags", "u32"],
|
|
786
|
+
["tierStart", "u32"],
|
|
787
|
+
["tierEnd", "u32"],
|
|
788
|
+
["iterationIndex", "u32"],
|
|
789
|
+
["seed", "u32"],
|
|
790
|
+
["nearMax", "u32"],
|
|
791
|
+
["scalingRatio", "f32"],
|
|
792
|
+
["gravity", "f32"],
|
|
793
|
+
["jitterTolerance", "f32"],
|
|
794
|
+
["scale", "f32"],
|
|
795
|
+
["center", "vec4f"],
|
|
796
|
+
["settleThreshold", "f32"],
|
|
797
|
+
["extentFactor", "f32"],
|
|
798
|
+
["gridMax", "u32"],
|
|
799
|
+
["levels", "u32"],
|
|
800
|
+
["pad", "vec4f"]
|
|
801
|
+
]);
|
|
802
|
+
const FA2_STATE = UniformBlock.define(
|
|
803
|
+
"Fa2State",
|
|
804
|
+
[
|
|
805
|
+
["speed", "f32"],
|
|
806
|
+
["speedEfficiency", "f32"],
|
|
807
|
+
["swing", "f32"],
|
|
808
|
+
["traction", "f32"],
|
|
809
|
+
["centroid", "vec4f"],
|
|
810
|
+
["rmsRadius", "f32"],
|
|
811
|
+
["radius", "f32"],
|
|
812
|
+
["meanDisplacement", "f32"],
|
|
813
|
+
["iteration", "u32"],
|
|
814
|
+
["min", "vec4f"],
|
|
815
|
+
["max", "vec4f"],
|
|
816
|
+
["gridMin", "vec4f"],
|
|
817
|
+
["eps", "f32"],
|
|
818
|
+
["settledCount", "u32"],
|
|
819
|
+
["outsideGrid", "u32"],
|
|
820
|
+
["maxCellOccupancy", "u32"],
|
|
821
|
+
["reserved0", "vec4f"],
|
|
822
|
+
["reserved1", "vec4f"],
|
|
823
|
+
["reserved2", "vec4f"],
|
|
824
|
+
["reserved3", "vec4f"],
|
|
825
|
+
["reserved4", "vec4f"],
|
|
826
|
+
["reserved5", "vec4f"],
|
|
827
|
+
["reserved6", "vec4f"],
|
|
828
|
+
["reserved7", "vec4f"],
|
|
829
|
+
["reserved8", "vec4f"]
|
|
830
|
+
],
|
|
831
|
+
{ layout: "storage", padTo: STATE_HEADER_BYTES }
|
|
832
|
+
);
|
|
833
|
+
const FA2_TRACE = UniformBlock.define(
|
|
834
|
+
"Fa2Trace",
|
|
835
|
+
[
|
|
836
|
+
["swing", "f32"],
|
|
837
|
+
["traction", "f32"],
|
|
838
|
+
["speed", "f32"],
|
|
839
|
+
["speedEfficiency", "f32"],
|
|
840
|
+
["meanDisplacement", "f32"],
|
|
841
|
+
["settledCount", "u32"],
|
|
842
|
+
["iteration", "u32"],
|
|
843
|
+
["pad0", "u32"]
|
|
844
|
+
],
|
|
845
|
+
{ layout: "storage" }
|
|
846
|
+
);
|
|
847
|
+
const FA2_PARTIAL = UniformBlock.define(
|
|
848
|
+
"Fa2Partial",
|
|
849
|
+
[
|
|
850
|
+
["sum", "vec4f"],
|
|
851
|
+
["min", "vec4f"],
|
|
852
|
+
["max", "vec4f"],
|
|
853
|
+
["swingTraction", "vec2f"],
|
|
854
|
+
["dispFree", "vec2f"]
|
|
855
|
+
],
|
|
856
|
+
{ layout: "storage" }
|
|
857
|
+
);
|
|
858
|
+
function decl(group, binding, name, kind, wgslType) {
|
|
859
|
+
return { group, binding, name, kind, wgslType };
|
|
860
|
+
}
|
|
861
|
+
const GRAPH_SLOTS = [
|
|
862
|
+
decl(0, 0, "rowPtr", "storage-ro", "array<u32>"),
|
|
863
|
+
decl(0, 1, "colIdx", "storage-ro", "array<u32>"),
|
|
864
|
+
decl(0, 2, "weights", "storage-ro", "array<f32>"),
|
|
865
|
+
decl(0, 3, "perm", "storage-ro", "array<u32>")
|
|
866
|
+
];
|
|
867
|
+
const DEGREE = {
|
|
868
|
+
id: "degree",
|
|
869
|
+
body: degreeWgsl,
|
|
870
|
+
entryPoint: "degree",
|
|
871
|
+
bindings: GRAPH_SLOTS.concat(decl(1, 0, "out", "storage", "array<u32>"), decl(2, 0, "P", "uniform", "RangeParams")),
|
|
872
|
+
overrideDecls: [],
|
|
873
|
+
uniforms: [RANGE_PARAMS],
|
|
874
|
+
needs: [],
|
|
875
|
+
snippetSlots: [],
|
|
876
|
+
phase: "P1"
|
|
877
|
+
};
|
|
878
|
+
const REDUCE = {
|
|
879
|
+
id: "reduce",
|
|
880
|
+
body: reduceWgsl,
|
|
881
|
+
entryPoint: "reduce",
|
|
882
|
+
bindings: [
|
|
883
|
+
decl(1, 0, "src", "storage-ro", "array<u32>"),
|
|
884
|
+
decl(1, 1, "out", "storage", "array<u32>"),
|
|
885
|
+
decl(2, 0, "P", "uniform", "ReduceParams")
|
|
886
|
+
],
|
|
887
|
+
overrideDecls: [
|
|
888
|
+
{ name: "OP", type: "u32", default: 0 },
|
|
889
|
+
{ name: "DTYPE", type: "u32", default: 0 },
|
|
890
|
+
{ name: "FINAL", type: "bool", default: false }
|
|
891
|
+
],
|
|
892
|
+
uniforms: [REDUCE_PARAMS],
|
|
893
|
+
needs: ["subgroups"],
|
|
894
|
+
snippetSlots: [],
|
|
895
|
+
phase: "P1"
|
|
896
|
+
};
|
|
897
|
+
const FILL = {
|
|
898
|
+
id: "fill",
|
|
899
|
+
body: fillWgsl,
|
|
900
|
+
entryPoint: "fill",
|
|
901
|
+
bindings: [decl(1, 0, "dst", "storage", "array<u32>"), decl(2, 0, "P", "uniform", "FillParams")],
|
|
902
|
+
overrideDecls: [],
|
|
903
|
+
uniforms: [FILL_PARAMS],
|
|
904
|
+
needs: [],
|
|
905
|
+
snippetSlots: [],
|
|
906
|
+
phase: "P1"
|
|
907
|
+
};
|
|
908
|
+
const SEGMENTED_REDUCE = {
|
|
909
|
+
id: "segmented-reduce",
|
|
910
|
+
body: segmentedReduceWgsl,
|
|
911
|
+
entryPoint: "segmented_reduce",
|
|
912
|
+
bindings: GRAPH_SLOTS.concat(decl(1, 0, "out", "storage", "array<f32>"), decl(2, 0, "P", "uniform", "RangeParams")),
|
|
913
|
+
overrideDecls: [
|
|
914
|
+
{ name: "OP", type: "u32", default: 0 },
|
|
915
|
+
{ name: "TIER", type: "u32", default: 0 }
|
|
916
|
+
],
|
|
917
|
+
uniforms: [RANGE_PARAMS],
|
|
918
|
+
needs: [],
|
|
919
|
+
snippetSlots: ["VALUE"],
|
|
920
|
+
phase: "P2"
|
|
921
|
+
};
|
|
922
|
+
const FA2_STATS_FINALIZE = {
|
|
923
|
+
id: "fa2-stats-finalize",
|
|
924
|
+
body: fa2StatsFinalizeWgsl,
|
|
925
|
+
entryPoint: "stats_finalize",
|
|
926
|
+
bindings: [
|
|
927
|
+
decl(1, 0, "partials", "storage-ro", "array<Fa2Partial>"),
|
|
928
|
+
decl(1, 1, "S", "storage", "Fa2State"),
|
|
929
|
+
decl(1, 2, "T", "storage", "array<Fa2Trace>"),
|
|
930
|
+
decl(2, 0, "P", "uniform", "Fa2Params")
|
|
931
|
+
],
|
|
932
|
+
overrideDecls: [],
|
|
933
|
+
uniforms: [FA2_PARAMS, FA2_STATE, FA2_TRACE, FA2_PARTIAL],
|
|
934
|
+
needs: ["subgroups"],
|
|
935
|
+
snippetSlots: [],
|
|
936
|
+
phase: "P3"
|
|
937
|
+
};
|
|
938
|
+
const FA2_ATTRACTION = {
|
|
939
|
+
id: "fa2-attraction",
|
|
940
|
+
body: fa2AttractionWgsl,
|
|
941
|
+
entryPoint: "attraction",
|
|
942
|
+
bindings: GRAPH_SLOTS.concat(
|
|
943
|
+
decl(1, 0, "pos", "storage-ro", "array<vec4f>"),
|
|
944
|
+
decl(1, 1, "force", "storage", "array<f32>"),
|
|
945
|
+
decl(2, 0, "P", "uniform", "Fa2Params")
|
|
946
|
+
),
|
|
947
|
+
overrideDecls: [
|
|
948
|
+
{ name: "LINLOG", type: "bool", default: false },
|
|
949
|
+
{ name: "DISTRIBUTED", type: "bool", default: false },
|
|
950
|
+
{ name: "TIER", type: "u32", default: 0 }
|
|
951
|
+
],
|
|
952
|
+
uniforms: [FA2_PARAMS],
|
|
953
|
+
needs: [],
|
|
954
|
+
snippetSlots: [],
|
|
955
|
+
phase: "P3"
|
|
956
|
+
};
|
|
957
|
+
const FA2_REPULSION_EXACT = {
|
|
958
|
+
id: "fa2-repulsion-exact",
|
|
959
|
+
body: fa2RepulsionExactWgsl,
|
|
960
|
+
entryPoint: "repulsion",
|
|
961
|
+
bindings: [
|
|
962
|
+
decl(1, 0, "pos", "storage-ro", "array<vec4f>"),
|
|
963
|
+
decl(1, 1, "S", "storage", "Fa2State"),
|
|
964
|
+
decl(1, 2, "force", "storage", "array<f32>"),
|
|
965
|
+
decl(1, 3, "oldForce", "storage-ro", "array<f32>"),
|
|
966
|
+
decl(1, 4, "fixedMask", "storage-ro", "array<u32>"),
|
|
967
|
+
decl(1, 5, "partials", "storage", "array<Fa2Partial>"),
|
|
968
|
+
decl(2, 0, "P", "uniform", "Fa2Params")
|
|
969
|
+
],
|
|
970
|
+
overrideDecls: [
|
|
971
|
+
{ name: "SWING_MODE", type: "u32", default: 0 },
|
|
972
|
+
{ name: "STRONG_GRAVITY", type: "bool", default: false },
|
|
973
|
+
{ name: "GRAVITY_CENTER", type: "u32", default: 0 }
|
|
974
|
+
],
|
|
975
|
+
uniforms: [FA2_PARAMS, FA2_STATE, FA2_PARTIAL],
|
|
976
|
+
needs: ["subgroups"],
|
|
977
|
+
snippetSlots: [],
|
|
978
|
+
phase: "P1"
|
|
979
|
+
};
|
|
980
|
+
const FA2_SPEED_FINALIZE = {
|
|
981
|
+
id: "fa2-speed-finalize",
|
|
982
|
+
body: fa2SpeedFinalizeWgsl,
|
|
983
|
+
entryPoint: "speed_finalize",
|
|
984
|
+
bindings: [
|
|
985
|
+
decl(1, 0, "partials", "storage-ro", "array<Fa2Partial>"),
|
|
986
|
+
decl(1, 1, "S", "storage", "Fa2State"),
|
|
987
|
+
decl(1, 2, "T", "storage", "array<Fa2Trace>"),
|
|
988
|
+
decl(2, 0, "P", "uniform", "Fa2Params")
|
|
989
|
+
],
|
|
990
|
+
overrideDecls: [{ name: "SWING_MODE", type: "u32", default: 0 }],
|
|
991
|
+
uniforms: [FA2_PARAMS, FA2_STATE, FA2_TRACE, FA2_PARTIAL],
|
|
992
|
+
needs: ["subgroups"],
|
|
993
|
+
snippetSlots: [],
|
|
994
|
+
phase: "P1"
|
|
995
|
+
};
|
|
996
|
+
const FA2_INTEGRATE = {
|
|
997
|
+
id: "fa2-integrate",
|
|
998
|
+
body: fa2IntegrateWgsl,
|
|
999
|
+
entryPoint: "integrate",
|
|
1000
|
+
bindings: [
|
|
1001
|
+
decl(1, 0, "force", "storage-ro", "array<f32>"),
|
|
1002
|
+
decl(1, 1, "oldForce", "storage", "array<f32>"),
|
|
1003
|
+
decl(1, 2, "fixedMask", "storage-ro", "array<u32>"),
|
|
1004
|
+
decl(1, 3, "S", "storage", "Fa2State"),
|
|
1005
|
+
decl(1, 4, "pos", "storage", "array<vec4f>"),
|
|
1006
|
+
decl(1, 5, "partials", "storage", "array<Fa2Partial>"),
|
|
1007
|
+
decl(2, 0, "P", "uniform", "Fa2Params")
|
|
1008
|
+
],
|
|
1009
|
+
overrideDecls: [{ name: "SWING_MODE", type: "u32", default: 0 }],
|
|
1010
|
+
uniforms: [FA2_PARAMS, FA2_STATE, FA2_PARTIAL],
|
|
1011
|
+
needs: ["subgroups"],
|
|
1012
|
+
snippetSlots: [],
|
|
1013
|
+
phase: "P3"
|
|
1014
|
+
};
|
|
1015
|
+
const FA2_TO_SCENE = {
|
|
1016
|
+
id: "fa2-to-scene",
|
|
1017
|
+
body: fa2ToSceneWgsl,
|
|
1018
|
+
entryPoint: "to_scene",
|
|
1019
|
+
bindings: [
|
|
1020
|
+
decl(1, 0, "pos", "storage-ro", "array<vec4f>"),
|
|
1021
|
+
decl(1, 1, "scene", "storage", "array<f32>"),
|
|
1022
|
+
decl(2, 0, "P", "uniform", "Fa2Params")
|
|
1023
|
+
],
|
|
1024
|
+
overrideDecls: [],
|
|
1025
|
+
uniforms: [FA2_PARAMS],
|
|
1026
|
+
needs: [],
|
|
1027
|
+
snippetSlots: [],
|
|
1028
|
+
phase: "P3"
|
|
1029
|
+
};
|
|
1030
|
+
const REGISTRY = Object.freeze({
|
|
1031
|
+
degree: DEGREE,
|
|
1032
|
+
reduce: REDUCE,
|
|
1033
|
+
fill: FILL,
|
|
1034
|
+
"segmented-reduce": SEGMENTED_REDUCE,
|
|
1035
|
+
"fa2-stats-finalize": FA2_STATS_FINALIZE,
|
|
1036
|
+
"fa2-attraction": FA2_ATTRACTION,
|
|
1037
|
+
"fa2-repulsion-exact": FA2_REPULSION_EXACT,
|
|
1038
|
+
"fa2-speed-finalize": FA2_SPEED_FINALIZE,
|
|
1039
|
+
"fa2-integrate": FA2_INTEGRATE,
|
|
1040
|
+
"fa2-to-scene": FA2_TO_SCENE
|
|
1041
|
+
});
|
|
1042
|
+
const bodyOverrides = /* @__PURE__ */ new Map();
|
|
1043
|
+
function entryOf(id) {
|
|
1044
|
+
const entry = REGISTRY[id];
|
|
1045
|
+
if (entry === void 0) {
|
|
1046
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `unknown kernel id "${id}"`, {
|
|
1047
|
+
argument: "id",
|
|
1048
|
+
value: id,
|
|
1049
|
+
expected: Object.keys(REGISTRY)
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
return entry;
|
|
1053
|
+
}
|
|
1054
|
+
function kernelSpec(id, overrides, snippets) {
|
|
1055
|
+
const entry = entryOf(id);
|
|
1056
|
+
return {
|
|
1057
|
+
id: entry.id,
|
|
1058
|
+
body: bodyOverrides.get(id) ?? entry.body,
|
|
1059
|
+
bindings: entry.bindings,
|
|
1060
|
+
overrideDecls: entry.overrideDecls,
|
|
1061
|
+
overrides: overrides ?? {},
|
|
1062
|
+
needs: entry.needs,
|
|
1063
|
+
uniforms: entry.uniforms,
|
|
1064
|
+
snippets
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
function graphBindings(core, perm, weights) {
|
|
1068
|
+
const colIdx = core.colIdx ?? core.rowPtr;
|
|
1069
|
+
const resolved = weights === void 0 ? core.weights : weights;
|
|
1070
|
+
return {
|
|
1071
|
+
rowPtr: core.rowPtr,
|
|
1072
|
+
colIdx,
|
|
1073
|
+
weights: resolved ?? colIdx,
|
|
1074
|
+
perm: perm ?? core.rowPtr
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
function graphOverrides(core, perm, weights) {
|
|
1078
|
+
const resolved = weights === void 0 ? core.weights : weights;
|
|
1079
|
+
return { USE_PERM: perm !== null, HAS_WEIGHTS: resolved !== null };
|
|
1080
|
+
}
|
|
1081
|
+
function checkDest(dest, n) {
|
|
1082
|
+
if (dest === void 0) {
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
if (dest instanceof Uint32Array && dest.length === n && dest.buffer instanceof ArrayBuffer) {
|
|
1086
|
+
return dest;
|
|
1087
|
+
}
|
|
1088
|
+
throw new WebGpuGraphError(
|
|
1089
|
+
"E_INVALID_ARGUMENT",
|
|
1090
|
+
`degree: dest must be a Uint32Array of length ${n} over an ArrayBuffer`,
|
|
1091
|
+
{
|
|
1092
|
+
argument: "dest",
|
|
1093
|
+
value: `${dest.constructor.name}(${dest.length})`,
|
|
1094
|
+
expected: `Uint32Array(${n}) over an ArrayBuffer`
|
|
1095
|
+
}
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
function coreOf(ctx, s) {
|
|
1099
|
+
try {
|
|
1100
|
+
return ctx.residency.core(s);
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
if (isWebGpuGraphError(error) && error.code === "E_TOO_LARGE" && error.details.path === "windowed") {
|
|
1103
|
+
throw new WebGpuGraphError(
|
|
1104
|
+
"E_TOO_LARGE",
|
|
1105
|
+
"degree: the arc arrays need a windowed upload, which P1-P3 plan but do not execute",
|
|
1106
|
+
{ ...error.details, algorithm: "degree" }
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
throw error;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
async function degree(ctx, s, options) {
|
|
1113
|
+
ctx.assertReady();
|
|
1114
|
+
const n = s.nodeCount;
|
|
1115
|
+
const dest = checkDest(options?.dest, n);
|
|
1116
|
+
if (options?.signal?.aborted) {
|
|
1117
|
+
throw new WebGpuGraphError("E_ABORTED", "degree: the signal was aborted before any work started", {});
|
|
1118
|
+
}
|
|
1119
|
+
if (n === 0) {
|
|
1120
|
+
options?.onProgress?.(1, 1);
|
|
1121
|
+
return dest ?? new Uint32Array(0);
|
|
1122
|
+
}
|
|
1123
|
+
const core = coreOf(ctx, s);
|
|
1124
|
+
if (s.arcCount === 0) {
|
|
1125
|
+
const zeros = dest ?? new Uint32Array(n);
|
|
1126
|
+
zeros.fill(0);
|
|
1127
|
+
options?.onProgress?.(1, 1);
|
|
1128
|
+
return zeros;
|
|
1129
|
+
}
|
|
1130
|
+
const byteLength = n * 4;
|
|
1131
|
+
const out = ctx.pool.acquire(
|
|
1132
|
+
byteLength,
|
|
1133
|
+
BufferUsage.STORAGE | BufferUsage.COPY_SRC | BufferUsage.COPY_DST,
|
|
1134
|
+
"degree/out"
|
|
1135
|
+
);
|
|
1136
|
+
const params = ctx.pool.acquire(
|
|
1137
|
+
RANGE_PARAMS.byteLength,
|
|
1138
|
+
BufferUsage.UNIFORM | BufferUsage.COPY_DST,
|
|
1139
|
+
"degree/params"
|
|
1140
|
+
);
|
|
1141
|
+
try {
|
|
1142
|
+
await ctx.allocator.check();
|
|
1143
|
+
const kernel = await ctx.pipelines.kernel(kernelSpec("degree", graphOverrides(core, null)));
|
|
1144
|
+
const bytes = new ArrayBuffer(RANGE_PARAMS.byteLength);
|
|
1145
|
+
RANGE_PARAMS.write(new DataView(bytes), { start: 0, end: n, arcBase: 0, arcEnd: s.arcCount, accumulate: 0, n });
|
|
1146
|
+
ctx.device.queue.writeBuffer(params, 0, bytes);
|
|
1147
|
+
const outBinding = { buffer: out, offset: 0, size: byteLength, window: null };
|
|
1148
|
+
const paramsBinding = { buffer: params, offset: 0, size: RANGE_PARAMS.byteLength, window: null };
|
|
1149
|
+
const bound = kernel.bind({ ...graphBindings(core, null), out: outBinding, P: paramsBinding });
|
|
1150
|
+
const encoder = ctx.device.createCommandEncoder({ label: "degree" });
|
|
1151
|
+
const pass = encoder.beginComputePass({ label: "degree" });
|
|
1152
|
+
kernel.dispatch(pass, bound, plan1d(n, ctx.workgroupSize, ctx.caps), [0]);
|
|
1153
|
+
pass.end();
|
|
1154
|
+
ctx.device.queue.submit([encoder.finish()]);
|
|
1155
|
+
ctx.assertReady();
|
|
1156
|
+
const result = await ctx.readback.read(out, byteLength, dest ?? void 0);
|
|
1157
|
+
ctx.assertReady();
|
|
1158
|
+
options?.onProgress?.(1, 1);
|
|
1159
|
+
return dest ?? new Uint32Array(result);
|
|
1160
|
+
} finally {
|
|
1161
|
+
ctx.pool.release(params);
|
|
1162
|
+
ctx.pool.release(out);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const LOSS_GRACE_MS = 2e3;
|
|
1166
|
+
async function settled(pending, graceMs) {
|
|
1167
|
+
let timer;
|
|
1168
|
+
const grace = new Promise((resolve) => {
|
|
1169
|
+
timer = setTimeout(resolve, graceMs);
|
|
1170
|
+
});
|
|
1171
|
+
await Promise.race([
|
|
1172
|
+
pending.then(
|
|
1173
|
+
() => void 0,
|
|
1174
|
+
() => void 0
|
|
1175
|
+
),
|
|
1176
|
+
grace
|
|
1177
|
+
]);
|
|
1178
|
+
clearTimeout(timer);
|
|
1179
|
+
}
|
|
1180
|
+
function messageOf(error) {
|
|
1181
|
+
if (error instanceof Error) {
|
|
1182
|
+
return error.message;
|
|
1183
|
+
}
|
|
1184
|
+
if (typeof error === "string") {
|
|
1185
|
+
return error;
|
|
1186
|
+
}
|
|
1187
|
+
return `non-error thrown (${typeof error})`;
|
|
1188
|
+
}
|
|
1189
|
+
class CommandBatch {
|
|
1190
|
+
/**
|
|
1191
|
+
* Creates the encoder; the id comes from host.nextBatchId().
|
|
1192
|
+
* @param host - the owner (the GpuContext)
|
|
1193
|
+
* @param label - names the encoder, its passes and the E_VALIDATION details
|
|
1194
|
+
* @param generation - the simulation generation the batch carries (default 0)
|
|
1195
|
+
*/
|
|
1196
|
+
constructor(host, label, generation) {
|
|
1197
|
+
this.requests = [];
|
|
1198
|
+
this.openPass = null;
|
|
1199
|
+
this.dispatchCount = 0;
|
|
1200
|
+
this.stagingBytes = 0;
|
|
1201
|
+
this.slot = null;
|
|
1202
|
+
this.submitted = false;
|
|
1203
|
+
this.host = host;
|
|
1204
|
+
this.id = host.nextBatchId();
|
|
1205
|
+
this.generation = generation ?? 0;
|
|
1206
|
+
this.label = label;
|
|
1207
|
+
this.encoder = host.device.createCommandEncoder({ label: `batch/${label}#${this.id}` });
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Dispatch count recorded so far (tests bound it).
|
|
1211
|
+
* @returns the number of dispatchWorkgroups / dispatchWorkgroupsIndirect calls on this batch's passes
|
|
1212
|
+
*/
|
|
1213
|
+
get dispatches() {
|
|
1214
|
+
return this.dispatchCount;
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Begins a compute pass (ending the previous one); with a profiler present the pass carries timestampWrites and
|
|
1218
|
+
* `label` names it in Profiler.resolveInto / timings.
|
|
1219
|
+
* @param label - the pass name
|
|
1220
|
+
* @returns the pass encoder kernels dispatch into
|
|
1221
|
+
*/
|
|
1222
|
+
pass(label) {
|
|
1223
|
+
this.assertOpen("pass");
|
|
1224
|
+
this.endPass();
|
|
1225
|
+
const descriptor = { label: `batch/${this.label}#${this.id}/${label}` };
|
|
1226
|
+
const timestampWrites = this.host.profiler?.beginPass(label);
|
|
1227
|
+
if (timestampWrites !== void 0) {
|
|
1228
|
+
descriptor.timestampWrites = timestampWrites;
|
|
1229
|
+
}
|
|
1230
|
+
const pass = this.encoder.beginComputePass(descriptor);
|
|
1231
|
+
this.countDispatches(pass);
|
|
1232
|
+
this.openPass = pass;
|
|
1233
|
+
return pass;
|
|
1234
|
+
}
|
|
1235
|
+
/** Ends the open pass, if any. */
|
|
1236
|
+
endPass() {
|
|
1237
|
+
if (this.openPass !== null) {
|
|
1238
|
+
this.openPass.end();
|
|
1239
|
+
this.openPass = null;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* copyBufferToBuffer between two bindings (after endPass).
|
|
1244
|
+
* @param src - the source range
|
|
1245
|
+
* @param dst - the destination range
|
|
1246
|
+
* @param byteLength - a positive multiple of 4 no larger than either binding
|
|
1247
|
+
*/
|
|
1248
|
+
copy(src, dst, byteLength) {
|
|
1249
|
+
this.assertOpen("copy");
|
|
1250
|
+
this.checkByteLength(byteLength);
|
|
1251
|
+
if (byteLength > src.size || byteLength > dst.size) {
|
|
1252
|
+
throw new WebGpuGraphError(
|
|
1253
|
+
"E_INVALID_ARGUMENT",
|
|
1254
|
+
`copy of ${byteLength} bytes exceeds a binding (src ${src.size}, dst ${dst.size})`,
|
|
1255
|
+
{
|
|
1256
|
+
argument: "byteLength",
|
|
1257
|
+
value: byteLength,
|
|
1258
|
+
expected: `<= ${Math.min(src.size, dst.size)}`
|
|
1259
|
+
}
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
this.endPass();
|
|
1263
|
+
this.encoder.copyBufferToBuffer(src.buffer, src.offset, dst.buffer, dst.offset, byteLength);
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Schedules a copy of `byteLength` bytes from `src` into the borrowed staging slot; the slot is borrowed at the
|
|
1267
|
+
* first request and re-borrowed larger when the sum of requests outgrows it (nothing is recorded before submit).
|
|
1268
|
+
* @param src - the source buffer
|
|
1269
|
+
* @param srcOffset - a non-negative multiple of 4
|
|
1270
|
+
* @param byteLength - a positive multiple of 4 with srcOffset + byteLength <= src.size
|
|
1271
|
+
* @returns the request; `offset` is where the caller reads inside the readback bytes
|
|
1272
|
+
*/
|
|
1273
|
+
readback(src, srcOffset, byteLength) {
|
|
1274
|
+
this.assertOpen("readback");
|
|
1275
|
+
this.checkByteLength(byteLength);
|
|
1276
|
+
if (!Number.isInteger(srcOffset) || srcOffset < 0 || srcOffset % 4 !== 0 || srcOffset + byteLength > src.size) {
|
|
1277
|
+
throw new WebGpuGraphError(
|
|
1278
|
+
"E_INVALID_ARGUMENT",
|
|
1279
|
+
`readback of [${srcOffset}, ${srcOffset + byteLength}) is outside the ${src.size}-byte source or not 4-aligned`,
|
|
1280
|
+
{
|
|
1281
|
+
argument: "srcOffset",
|
|
1282
|
+
value: srcOffset,
|
|
1283
|
+
expected: `a multiple of 4 with srcOffset + byteLength <= ${src.size}`
|
|
1284
|
+
}
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
const request = { src, srcOffset, byteLength, offset: this.stagingBytes };
|
|
1288
|
+
this.requests.push(request);
|
|
1289
|
+
this.stagingBytes += byteLength;
|
|
1290
|
+
this.ensureSlot(this.stagingBytes);
|
|
1291
|
+
return request;
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Records `resolveQuerySet` after ending the open pass; used by Profiler.resolveInto only.
|
|
1295
|
+
* @internal
|
|
1296
|
+
* @param querySet - the timestamp query set
|
|
1297
|
+
* @param firstQuery - first query index
|
|
1298
|
+
* @param queryCount - number of queries
|
|
1299
|
+
* @param destination - a QUERY_RESOLVE | COPY_SRC buffer written at offset 0
|
|
1300
|
+
*/
|
|
1301
|
+
resolveQuerySet(querySet, firstQuery, queryCount, destination) {
|
|
1302
|
+
this.assertOpen("resolveQuerySet");
|
|
1303
|
+
this.endPass();
|
|
1304
|
+
this.encoder.resolveQuerySet(querySet, firstQuery, queryCount, destination, 0);
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Ends passes, records the staging copies, submits, checks the pending-error slot (spec 5.7), returns the handle.
|
|
1308
|
+
* A batch can be submitted once; submit() after device loss throws E_DEVICE_LOST.
|
|
1309
|
+
* @returns the submitted-batch handle
|
|
1310
|
+
*/
|
|
1311
|
+
submit() {
|
|
1312
|
+
this.assertOpen("submit");
|
|
1313
|
+
this.submitted = true;
|
|
1314
|
+
this.endPass();
|
|
1315
|
+
const { slot } = this;
|
|
1316
|
+
const pendingBefore = this.host.takePendingError();
|
|
1317
|
+
try {
|
|
1318
|
+
this.host.assertReady();
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
this.releaseSlot();
|
|
1321
|
+
throw error;
|
|
1322
|
+
}
|
|
1323
|
+
if (slot !== null) {
|
|
1324
|
+
for (const request of this.requests) {
|
|
1325
|
+
this.encoder.copyBufferToBuffer(
|
|
1326
|
+
request.src,
|
|
1327
|
+
request.srcOffset,
|
|
1328
|
+
slot.buffer,
|
|
1329
|
+
request.offset,
|
|
1330
|
+
request.byteLength
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
const commandBuffer = this.encoder.finish({ label: `batch/${this.label}#${this.id}` });
|
|
1335
|
+
this.host.device.queue.submit([commandBuffer]);
|
|
1336
|
+
const pendingAfter = this.host.takePendingError();
|
|
1337
|
+
const pending = pendingBefore ?? pendingAfter;
|
|
1338
|
+
const state = { discarded: false };
|
|
1339
|
+
const readback = pending === null ? this.awaitReadback(slot, state) : this.rejectReadback(pending);
|
|
1340
|
+
return {
|
|
1341
|
+
id: this.id,
|
|
1342
|
+
generation: this.generation,
|
|
1343
|
+
readback,
|
|
1344
|
+
discard: () => {
|
|
1345
|
+
state.discarded = true;
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
rejectReadback(pending) {
|
|
1350
|
+
this.releaseSlot();
|
|
1351
|
+
return Promise.reject(this.attribute(pending));
|
|
1352
|
+
}
|
|
1353
|
+
async awaitReadback(slot, state) {
|
|
1354
|
+
if (slot === null) {
|
|
1355
|
+
await this.host.allocator.check();
|
|
1356
|
+
return new ArrayBuffer(0);
|
|
1357
|
+
}
|
|
1358
|
+
const total = this.stagingBytes;
|
|
1359
|
+
let unregister = () => void 0;
|
|
1360
|
+
const lost = new Promise((_resolve, reject) => {
|
|
1361
|
+
unregister = this.host.onLost((info) => {
|
|
1362
|
+
reject(deviceLostError(info));
|
|
1363
|
+
});
|
|
1364
|
+
});
|
|
1365
|
+
void lost.catch(() => void 0);
|
|
1366
|
+
const mapped = this.host.readback.mapSlot(slot, total);
|
|
1367
|
+
void mapped.catch(() => void 0);
|
|
1368
|
+
let bytes;
|
|
1369
|
+
try {
|
|
1370
|
+
await Promise.race([mapped, lost]);
|
|
1371
|
+
this.host.assertReady();
|
|
1372
|
+
bytes = state.discarded ? new ArrayBuffer(0) : slot.buffer.getMappedRange(0, total).slice(0);
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
await settled(mapped, LOSS_GRACE_MS);
|
|
1375
|
+
this.releaseSlot();
|
|
1376
|
+
throw await this.classify(error, lost);
|
|
1377
|
+
} finally {
|
|
1378
|
+
unregister();
|
|
1379
|
+
}
|
|
1380
|
+
this.releaseSlot();
|
|
1381
|
+
await this.host.allocator.check();
|
|
1382
|
+
return bytes;
|
|
1383
|
+
}
|
|
1384
|
+
async classify(error, lost) {
|
|
1385
|
+
if (isWebGpuGraphError(error)) {
|
|
1386
|
+
return error.code === "E_DEVICE_LOST" || error.code === "E_DISPOSED" ? error : this.attribute(error);
|
|
1387
|
+
}
|
|
1388
|
+
try {
|
|
1389
|
+
this.host.assertReady();
|
|
1390
|
+
} catch (state) {
|
|
1391
|
+
if (isWebGpuGraphError(state)) {
|
|
1392
|
+
return state.code === "E_DEVICE_LOST" || state.code === "E_DISPOSED" ? state : this.attribute(state);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
let timer;
|
|
1396
|
+
const grace = new Promise((resolve) => {
|
|
1397
|
+
timer = setTimeout(() => {
|
|
1398
|
+
resolve(null);
|
|
1399
|
+
}, LOSS_GRACE_MS);
|
|
1400
|
+
});
|
|
1401
|
+
const late = await Promise.race([
|
|
1402
|
+
lost.then(
|
|
1403
|
+
() => null,
|
|
1404
|
+
(reason) => reason
|
|
1405
|
+
),
|
|
1406
|
+
grace
|
|
1407
|
+
]);
|
|
1408
|
+
clearTimeout(timer);
|
|
1409
|
+
if (isWebGpuGraphError(late)) {
|
|
1410
|
+
return late;
|
|
1411
|
+
}
|
|
1412
|
+
const message = messageOf(error);
|
|
1413
|
+
return new WebGpuGraphError(
|
|
1414
|
+
"E_VALIDATION",
|
|
1415
|
+
`mapAsync failed for batch ${this.id} "${this.label}": ${message}`,
|
|
1416
|
+
{
|
|
1417
|
+
label: this.label,
|
|
1418
|
+
message,
|
|
1419
|
+
batchId: this.id
|
|
1420
|
+
}
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
attribute(error) {
|
|
1424
|
+
return new WebGpuGraphError(error.code, `${error.message} (delivered to batch ${this.id} "${this.label}")`, {
|
|
1425
|
+
...error.details,
|
|
1426
|
+
batchId: this.id,
|
|
1427
|
+
batchLabel: this.label
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
ensureSlot(byteLength) {
|
|
1431
|
+
if (this.slot !== null && this.slot.capacity >= byteLength) {
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
this.releaseSlot();
|
|
1435
|
+
this.slot = this.host.readback.borrowSlot(byteLength);
|
|
1436
|
+
}
|
|
1437
|
+
releaseSlot() {
|
|
1438
|
+
if (this.slot === null) {
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
const { slot } = this;
|
|
1442
|
+
this.slot = null;
|
|
1443
|
+
try {
|
|
1444
|
+
this.host.readback.returnSlot(slot);
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
if (!isWebGpuGraphError(error) || error.code !== "E_DISPOSED") {
|
|
1447
|
+
throw error;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
countDispatches(pass) {
|
|
1452
|
+
const direct = pass.dispatchWorkgroups.bind(pass);
|
|
1453
|
+
const indirect = pass.dispatchWorkgroupsIndirect.bind(pass);
|
|
1454
|
+
pass.dispatchWorkgroups = (x, y, z) => {
|
|
1455
|
+
this.dispatchCount += 1;
|
|
1456
|
+
direct(x, y, z);
|
|
1457
|
+
return void 0;
|
|
1458
|
+
};
|
|
1459
|
+
pass.dispatchWorkgroupsIndirect = (buffer, offset) => {
|
|
1460
|
+
this.dispatchCount += 1;
|
|
1461
|
+
indirect(buffer, offset);
|
|
1462
|
+
return void 0;
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
assertOpen(operation) {
|
|
1466
|
+
if (this.submitted) {
|
|
1467
|
+
throw new WebGpuGraphError(
|
|
1468
|
+
"E_INVALID_ARGUMENT",
|
|
1469
|
+
`${operation}() after submit() on batch ${this.id} "${this.label}"`,
|
|
1470
|
+
{
|
|
1471
|
+
argument: operation,
|
|
1472
|
+
value: this.id,
|
|
1473
|
+
expected: "a batch that has not been submitted"
|
|
1474
|
+
}
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
checkByteLength(byteLength) {
|
|
1479
|
+
if (!Number.isInteger(byteLength) || byteLength <= 0 || byteLength % 4 !== 0) {
|
|
1480
|
+
throw new WebGpuGraphError(
|
|
1481
|
+
"E_INVALID_ARGUMENT",
|
|
1482
|
+
`byteLength ${byteLength} is not a positive multiple of 4`,
|
|
1483
|
+
{
|
|
1484
|
+
argument: "byteLength",
|
|
1485
|
+
value: byteLength,
|
|
1486
|
+
expected: "a positive multiple of 4"
|
|
1487
|
+
}
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
class UniformRing {
|
|
1493
|
+
/**
|
|
1494
|
+
* Creates the ring buffer (`slots x UNIFORM_SLOT_BYTES` bytes, UNIFORM | COPY_DST) through the allocator.
|
|
1495
|
+
* @param device - the device the buffer belongs to
|
|
1496
|
+
* @param allocator - the context's OOM-scoped allocator (the buffer is destroyed through it)
|
|
1497
|
+
* @param slots - number of slots; E_INVALID_ARGUMENT unless a positive integer
|
|
1498
|
+
* @param label - the buffer label
|
|
1499
|
+
*/
|
|
1500
|
+
constructor(device, allocator, slots, label) {
|
|
1501
|
+
this.next = 0;
|
|
1502
|
+
this.dirtyLo = -1;
|
|
1503
|
+
this.dirtyHi = -1;
|
|
1504
|
+
this.destroyed = false;
|
|
1505
|
+
if (!Number.isInteger(slots) || slots < 1) {
|
|
1506
|
+
throw new WebGpuGraphError(
|
|
1507
|
+
"E_INVALID_ARGUMENT",
|
|
1508
|
+
`UniformRing "${label}": slots must be a positive integer, got ${slots}`,
|
|
1509
|
+
{
|
|
1510
|
+
argument: "slots",
|
|
1511
|
+
value: slots,
|
|
1512
|
+
expected: "an integer >= 1"
|
|
1513
|
+
}
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
this.device = device;
|
|
1517
|
+
this.allocator = allocator;
|
|
1518
|
+
this.slots = slots;
|
|
1519
|
+
this.label = label;
|
|
1520
|
+
this.shadow = new ArrayBuffer(slots * UNIFORM_SLOT_BYTES);
|
|
1521
|
+
this.view = new DataView(this.shadow);
|
|
1522
|
+
this.buffer = allocator.createBuffer({
|
|
1523
|
+
label,
|
|
1524
|
+
size: slots * UNIFORM_SLOT_BYTES,
|
|
1525
|
+
usage: BufferUsage.UNIFORM | BufferUsage.COPY_DST
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* The whole-buffer binding a kernel binds once (size = the block's byteLength; the slot is the dynamic offset).
|
|
1530
|
+
* @param block - the params block the kernel declares for this binding
|
|
1531
|
+
* @returns the binding at offset 0 of the ring buffer
|
|
1532
|
+
*/
|
|
1533
|
+
binding(block) {
|
|
1534
|
+
this.assertLive();
|
|
1535
|
+
this.assertFits(block);
|
|
1536
|
+
return { buffer: this.buffer, offset: 0, size: block.byteLength, window: null };
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Byte offset of a slot (the dynamic offset a dispatch passes for it).
|
|
1540
|
+
* @param slot - a slot index in [0, slots)
|
|
1541
|
+
* @returns slot x UNIFORM_SLOT_BYTES
|
|
1542
|
+
*/
|
|
1543
|
+
offsetOf(slot) {
|
|
1544
|
+
this.checkSlot(slot);
|
|
1545
|
+
return slot * UNIFORM_SLOT_BYTES;
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Reserves `count` contiguous slots for a batch, wrapping to 0 when the tail is too short; E_INVALID_ARGUMENT when count > slots.
|
|
1549
|
+
* @param count - slots needed, in [1, slots]
|
|
1550
|
+
* @returns the first slot of the reservation
|
|
1551
|
+
*/
|
|
1552
|
+
reserve(count) {
|
|
1553
|
+
this.assertLive();
|
|
1554
|
+
if (!Number.isInteger(count) || count < 1 || count > this.slots) {
|
|
1555
|
+
throw new WebGpuGraphError(
|
|
1556
|
+
"E_INVALID_ARGUMENT",
|
|
1557
|
+
`UniformRing "${this.label}": cannot reserve ${count} of ${this.slots} slots`,
|
|
1558
|
+
{
|
|
1559
|
+
argument: "count",
|
|
1560
|
+
value: count,
|
|
1561
|
+
expected: `an integer in [1, ${this.slots}]`
|
|
1562
|
+
}
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
if (this.next + count > this.slots) {
|
|
1566
|
+
this.next = 0;
|
|
1567
|
+
}
|
|
1568
|
+
const first = this.next;
|
|
1569
|
+
this.next += count;
|
|
1570
|
+
return first;
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Writes one block's values into a slot of the host shadow; `flush()` sends the dirty range with one writeBuffer.
|
|
1574
|
+
* @param slot - the slot to fill
|
|
1575
|
+
* @param block - the block whose layout the bytes follow
|
|
1576
|
+
* @param values - the field values (a missing field is written as 0 by the block)
|
|
1577
|
+
*/
|
|
1578
|
+
write(slot, block, values) {
|
|
1579
|
+
this.checkSlot(slot);
|
|
1580
|
+
this.assertFits(block);
|
|
1581
|
+
block.write(this.view, values, slot * UNIFORM_SLOT_BYTES);
|
|
1582
|
+
if (this.dirtyLo < 0 || slot < this.dirtyLo) {
|
|
1583
|
+
this.dirtyLo = slot;
|
|
1584
|
+
}
|
|
1585
|
+
if (slot > this.dirtyHi) {
|
|
1586
|
+
this.dirtyHi = slot;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* queue.writeBuffer of the dirty slots (called by the batch driver before submit): one call covering
|
|
1591
|
+
* [lowest dirty slot, highest dirty slot]; nothing when no slot is dirty.
|
|
1592
|
+
*/
|
|
1593
|
+
flush() {
|
|
1594
|
+
this.assertLive();
|
|
1595
|
+
if (this.dirtyLo < 0) {
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
const begin = this.dirtyLo * UNIFORM_SLOT_BYTES;
|
|
1599
|
+
const end = (this.dirtyHi + 1) * UNIFORM_SLOT_BYTES;
|
|
1600
|
+
this.device.queue.writeBuffer(this.buffer, begin, this.shadow, begin, end - begin);
|
|
1601
|
+
this.dirtyLo = -1;
|
|
1602
|
+
this.dirtyHi = -1;
|
|
1603
|
+
}
|
|
1604
|
+
/** Destroys the buffer (through the allocator); idempotent. */
|
|
1605
|
+
destroy() {
|
|
1606
|
+
if (this.destroyed) {
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
this.destroyed = true;
|
|
1610
|
+
this.allocator.destroy(this.buffer);
|
|
1611
|
+
}
|
|
1612
|
+
assertLive() {
|
|
1613
|
+
if (this.destroyed) {
|
|
1614
|
+
throw new WebGpuGraphError("E_DISPOSED", `UniformRing "${this.label}" is destroyed`, { label: this.label });
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
checkSlot(slot) {
|
|
1618
|
+
this.assertLive();
|
|
1619
|
+
if (!Number.isInteger(slot) || slot < 0 || slot >= this.slots) {
|
|
1620
|
+
throw new WebGpuGraphError(
|
|
1621
|
+
"E_INVALID_ARGUMENT",
|
|
1622
|
+
`UniformRing "${this.label}": slot ${slot} is outside [0, ${this.slots})`,
|
|
1623
|
+
{
|
|
1624
|
+
argument: "slot",
|
|
1625
|
+
value: slot,
|
|
1626
|
+
expected: `an integer in [0, ${this.slots})`
|
|
1627
|
+
}
|
|
1628
|
+
);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
assertFits(block) {
|
|
1632
|
+
if (block.byteLength > UNIFORM_SLOT_BYTES) {
|
|
1633
|
+
throw new WebGpuGraphError(
|
|
1634
|
+
"E_INVALID_ARGUMENT",
|
|
1635
|
+
`UniformRing "${this.label}": block ${block.name} is ${block.byteLength} bytes, a slot holds ${UNIFORM_SLOT_BYTES}`,
|
|
1636
|
+
{ argument: "block", value: block.name, expected: `byteLength <= ${UNIFORM_SLOT_BYTES}` }
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
const LCG_M = 34359738337;
|
|
1642
|
+
const LCG_A = 185852;
|
|
1643
|
+
const LCG_C = 1;
|
|
1644
|
+
class Lcg {
|
|
1645
|
+
/**
|
|
1646
|
+
* Creates the generator; a seed of 0, -0, NaN or null means unseeded (the port's `seed || random` quirk).
|
|
1647
|
+
* @param seed - the seed, or null for a random seed
|
|
1648
|
+
*/
|
|
1649
|
+
constructor(seed) {
|
|
1650
|
+
this.seed = seed === null || seed === 0 || Number.isNaN(seed) ? Math.floor(Math.random() * 1e6) : seed;
|
|
1651
|
+
this.state = this.seed % LCG_M;
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Next value in [0, 1).
|
|
1655
|
+
* @returns state / m after one LCG step
|
|
1656
|
+
*/
|
|
1657
|
+
next() {
|
|
1658
|
+
this.state = (LCG_A * this.state + LCG_C) % LCG_M;
|
|
1659
|
+
return this.state / LCG_M;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
function resolveCenter(center) {
|
|
1663
|
+
const out = [0, 0, 0];
|
|
1664
|
+
if (center === null) {
|
|
1665
|
+
return out;
|
|
1666
|
+
}
|
|
1667
|
+
for (let axis = 0; axis < 3 && axis < center.length; axis++) {
|
|
1668
|
+
const v = center[axis];
|
|
1669
|
+
if (!Number.isFinite(v)) {
|
|
1670
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `center[${axis}] is not finite`, {
|
|
1671
|
+
argument: "center",
|
|
1672
|
+
value: v,
|
|
1673
|
+
expected: "finite components"
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
out[axis] = v;
|
|
1677
|
+
}
|
|
1678
|
+
return out;
|
|
1679
|
+
}
|
|
1680
|
+
function seedPositions(s, positions, seed, dim, scale, center, range) {
|
|
1681
|
+
const n = s.nodeCount;
|
|
1682
|
+
if (dim !== 2 && dim !== 3) {
|
|
1683
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `dim must be 2 or 3, got ${String(dim)}`, {
|
|
1684
|
+
argument: "dim",
|
|
1685
|
+
value: dim,
|
|
1686
|
+
expected: "2 or 3"
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
if (positions.length !== 3 * n) {
|
|
1690
|
+
throw new WebGpuGraphError(
|
|
1691
|
+
"E_INVALID_ARGUMENT",
|
|
1692
|
+
`positions has ${positions.length} entries, expected ${3 * n}`,
|
|
1693
|
+
{
|
|
1694
|
+
argument: "positions",
|
|
1695
|
+
value: positions.length,
|
|
1696
|
+
expected: 3 * n
|
|
1697
|
+
}
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
if (!Number.isFinite(scale) || scale <= 0) {
|
|
1701
|
+
throw new WebGpuGraphError("E_INVALID_ARGUMENT", `scale must be a finite number > 0, got ${scale}`, {
|
|
1702
|
+
argument: "scale",
|
|
1703
|
+
value: scale,
|
|
1704
|
+
expected: "a finite number > 0"
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
const c = resolveCenter(center);
|
|
1708
|
+
const lo = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
|
|
1709
|
+
const hi = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
|
|
1710
|
+
let finiteRows = 0;
|
|
1711
|
+
let unseededRows = 0;
|
|
1712
|
+
for (let i = 0; i < n; i++) {
|
|
1713
|
+
let finite = true;
|
|
1714
|
+
for (let axis = 0; axis < dim; axis++) {
|
|
1715
|
+
const v = positions[3 * i + axis];
|
|
1716
|
+
if (Number.isFinite(v)) {
|
|
1717
|
+
if (v < lo[axis]) {
|
|
1718
|
+
lo[axis] = v;
|
|
1719
|
+
}
|
|
1720
|
+
if (v > hi[axis]) {
|
|
1721
|
+
hi[axis] = v;
|
|
1722
|
+
}
|
|
1723
|
+
} else {
|
|
1724
|
+
finite = false;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
if (finite) {
|
|
1728
|
+
finiteRows++;
|
|
1729
|
+
} else {
|
|
1730
|
+
unseededRows++;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
if (unseededRows === 0) {
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
const rangeLo = range === "fr" ? 0 : -1;
|
|
1737
|
+
const rangeHi = 1;
|
|
1738
|
+
const boxLo = [rangeLo, rangeLo, rangeLo];
|
|
1739
|
+
const boxHi = [rangeHi, rangeHi, rangeHi];
|
|
1740
|
+
if (finiteRows > 0) {
|
|
1741
|
+
for (let axis = 0; axis < dim; axis++) {
|
|
1742
|
+
if (Number.isFinite(lo[axis]) && Number.isFinite(hi[axis])) {
|
|
1743
|
+
boxLo[axis] = (lo[axis] - c[axis]) / scale;
|
|
1744
|
+
boxHi[axis] = (hi[axis] - c[axis]) / scale;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
const rng = new Lcg(seed);
|
|
1749
|
+
for (let i = 0; i < n; i++) {
|
|
1750
|
+
let unseeded = false;
|
|
1751
|
+
for (let axis = 0; axis < dim; axis++) {
|
|
1752
|
+
if (!Number.isFinite(positions[3 * i + axis])) {
|
|
1753
|
+
unseeded = true;
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
if (!unseeded) {
|
|
1757
|
+
continue;
|
|
1758
|
+
}
|
|
1759
|
+
for (let axis = 0; axis < dim; axis++) {
|
|
1760
|
+
const at = 3 * i + axis;
|
|
1761
|
+
if (!Number.isFinite(positions[at])) {
|
|
1762
|
+
const u = rng.next();
|
|
1763
|
+
const v = boxLo[axis] + u * (boxHi[axis] - boxLo[axis]);
|
|
1764
|
+
positions[at] = v * scale + c[axis];
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
if (dim === 2) {
|
|
1768
|
+
positions[3 * i + 2] = c[2];
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
const SHARED_PARAM_FIELDS = [
|
|
1773
|
+
["n", 1],
|
|
1774
|
+
["dim", 1],
|
|
1775
|
+
["flags", 1],
|
|
1776
|
+
["iterationIndex", 1],
|
|
1777
|
+
["seed", 1],
|
|
1778
|
+
["scale", 1],
|
|
1779
|
+
["center", 4],
|
|
1780
|
+
["settleThreshold", 1]
|
|
1781
|
+
];
|
|
1782
|
+
const SHARED_STATE_FIELDS = [
|
|
1783
|
+
["centroid", 4],
|
|
1784
|
+
["min", 4],
|
|
1785
|
+
["max", 4],
|
|
1786
|
+
["rmsRadius", 1],
|
|
1787
|
+
["radius", 1],
|
|
1788
|
+
["meanDisplacement", 1],
|
|
1789
|
+
["iteration", 1],
|
|
1790
|
+
["settledCount", 1]
|
|
1791
|
+
];
|
|
1792
|
+
const SHARED_BUFFER_NAMES = ["positions", "scenePositions", "fixed", "partials", "state", "trace"];
|
|
1793
|
+
const EPILOGUE_STAGE = "toScene";
|
|
1794
|
+
let simulationCounter = 0;
|
|
1795
|
+
function invalidArgument(argument, value, expected, message) {
|
|
1796
|
+
return new WebGpuGraphError("E_INVALID_ARGUMENT", message, { argument, value, expected });
|
|
1797
|
+
}
|
|
1798
|
+
function disposedError(label) {
|
|
1799
|
+
return new WebGpuGraphError("E_DISPOSED", `${label} is disposed`, { label });
|
|
1800
|
+
}
|
|
1801
|
+
function asError(err) {
|
|
1802
|
+
if (err instanceof Error) {
|
|
1803
|
+
return err;
|
|
1804
|
+
}
|
|
1805
|
+
return new Error(typeof err === "string" ? err : "unknown error");
|
|
1806
|
+
}
|
|
1807
|
+
function optionField(record, key) {
|
|
1808
|
+
return record[key];
|
|
1809
|
+
}
|
|
1810
|
+
function fieldWidth(type) {
|
|
1811
|
+
switch (type) {
|
|
1812
|
+
case "u32":
|
|
1813
|
+
case "i32":
|
|
1814
|
+
case "f32":
|
|
1815
|
+
return 1;
|
|
1816
|
+
case "vec2f":
|
|
1817
|
+
case "vec2u":
|
|
1818
|
+
return 2;
|
|
1819
|
+
case "vec4f":
|
|
1820
|
+
case "vec4u":
|
|
1821
|
+
return 4;
|
|
1822
|
+
default:
|
|
1823
|
+
throw invalidArgument("type", type, "a UniformFieldType", "unknown uniform field type");
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
function fieldTypeOrNull(block, field) {
|
|
1827
|
+
for (const [name, type] of block.fields) {
|
|
1828
|
+
if (name === field) {
|
|
1829
|
+
return type;
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
return null;
|
|
1833
|
+
}
|
|
1834
|
+
function fieldTypeOf(block, field) {
|
|
1835
|
+
const type = fieldTypeOrNull(block, field);
|
|
1836
|
+
if (type === null) {
|
|
1837
|
+
throw invalidArgument(
|
|
1838
|
+
"field",
|
|
1839
|
+
field,
|
|
1840
|
+
block.fields.map((f2) => f2[0]),
|
|
1841
|
+
`block ${block.name} has no field ${field}`
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
return type;
|
|
1845
|
+
}
|
|
1846
|
+
function requireFields(block, required, what) {
|
|
1847
|
+
for (const [field, width] of required) {
|
|
1848
|
+
const type = fieldTypeOrNull(block, field);
|
|
1849
|
+
if (type === null) {
|
|
1850
|
+
throw invalidArgument(
|
|
1851
|
+
what,
|
|
1852
|
+
field,
|
|
1853
|
+
required.map((r) => r[0]),
|
|
1854
|
+
`${what} block ${block.name} must declare ${field}`
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
if (fieldWidth(type) !== width) {
|
|
1858
|
+
throw invalidArgument(
|
|
1859
|
+
what,
|
|
1860
|
+
field,
|
|
1861
|
+
`a field of width ${width}`,
|
|
1862
|
+
`${what}.${field} must have width ${width}`
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
function stableKey(record) {
|
|
1868
|
+
return JSON.stringify(
|
|
1869
|
+
Object.keys(record).sort().map((k2) => [k2, record[k2]])
|
|
1870
|
+
);
|
|
1871
|
+
}
|
|
1872
|
+
function withMs(stats, msPerIteration) {
|
|
1873
|
+
return { ...stats, msPerIteration };
|
|
1874
|
+
}
|
|
1875
|
+
function isSharedBuffer(buffer) {
|
|
1876
|
+
return typeof SharedArrayBuffer !== "undefined" && buffer instanceof SharedArrayBuffer;
|
|
1877
|
+
}
|
|
1878
|
+
function scaleOf(options) {
|
|
1879
|
+
const scale = options.scale ?? FA2_DEFAULTS.scale;
|
|
1880
|
+
if (!Number.isFinite(scale) || scale <= 0) {
|
|
1881
|
+
throw invalidArgument("scale", scale, "a finite number > 0", `scale must be a finite number > 0, got ${scale}`);
|
|
1882
|
+
}
|
|
1883
|
+
return scale;
|
|
1884
|
+
}
|
|
1885
|
+
function centerOf(options) {
|
|
1886
|
+
const out = [0, 0, 0];
|
|
1887
|
+
const { center } = options;
|
|
1888
|
+
if (center === void 0) {
|
|
1889
|
+
return out;
|
|
1890
|
+
}
|
|
1891
|
+
for (let axis = 0; axis < 3 && axis < center.length; axis++) {
|
|
1892
|
+
const v = center[axis];
|
|
1893
|
+
if (!Number.isFinite(v)) {
|
|
1894
|
+
throw invalidArgument("center", v, "finite components", `center[${axis}] is not finite`);
|
|
1895
|
+
}
|
|
1896
|
+
out[axis] = v;
|
|
1897
|
+
}
|
|
1898
|
+
return out;
|
|
1899
|
+
}
|
|
1900
|
+
function tierFor(tuning, n) {
|
|
1901
|
+
if (tuning.repulsion === "exact") {
|
|
1902
|
+
return "exact";
|
|
1903
|
+
}
|
|
1904
|
+
if (tuning.repulsion === "grid") {
|
|
1905
|
+
return "grid";
|
|
1906
|
+
}
|
|
1907
|
+
return n <= tuning.exactMaxNodes ? "exact" : "grid";
|
|
1908
|
+
}
|
|
1909
|
+
function wholeBinding(buffer, size) {
|
|
1910
|
+
return { buffer, offset: 0, size, window: null };
|
|
1911
|
+
}
|
|
1912
|
+
class HeaderWriter {
|
|
1913
|
+
/**
|
|
1914
|
+
* Creates a writer over a model's state block with every field 0.
|
|
1915
|
+
* @param block - the model's state block
|
|
1916
|
+
*/
|
|
1917
|
+
constructor(block) {
|
|
1918
|
+
this.shadow = /* @__PURE__ */ new Map();
|
|
1919
|
+
this.queued = /* @__PURE__ */ new Map();
|
|
1920
|
+
this.block = block;
|
|
1921
|
+
this.reset();
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Queues a field write and applies it to the shadow.
|
|
1925
|
+
* @param field - the field name (E_INVALID_ARGUMENT when the block lacks it or the width differs)
|
|
1926
|
+
* @param value - a number for a scalar field, an array of the vector's width otherwise
|
|
1927
|
+
*/
|
|
1928
|
+
set(field, value) {
|
|
1929
|
+
const width = fieldWidth(fieldTypeOf(this.block, field));
|
|
1930
|
+
const ok = width === 1 ? typeof value === "number" : typeof value !== "number" && value.length === width;
|
|
1931
|
+
if (!ok) {
|
|
1932
|
+
throw invalidArgument(
|
|
1933
|
+
"value",
|
|
1934
|
+
value,
|
|
1935
|
+
`a value of width ${width}`,
|
|
1936
|
+
`state field ${field} takes a value of width ${width}`
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
this.shadow.set(field, value);
|
|
1940
|
+
this.queued.set(field, value);
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* The host shadow of a field: the last value set, or the last landed header's value.
|
|
1944
|
+
* @param field - the field name
|
|
1945
|
+
* @returns the value
|
|
1946
|
+
*/
|
|
1947
|
+
get(field) {
|
|
1948
|
+
const value = this.shadow.get(field);
|
|
1949
|
+
if (value === void 0) {
|
|
1950
|
+
throw invalidArgument(
|
|
1951
|
+
"field",
|
|
1952
|
+
field,
|
|
1953
|
+
this.block.fields.map((f2) => f2[0]),
|
|
1954
|
+
`state block has no field ${field}`
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
return value;
|
|
1958
|
+
}
|
|
1959
|
+
/** Every field back to 0 and the queue cleared (load()). */
|
|
1960
|
+
reset() {
|
|
1961
|
+
this.shadow.clear();
|
|
1962
|
+
this.queued.clear();
|
|
1963
|
+
const zero = this.block.read(new DataView(new ArrayBuffer(this.block.byteLength)));
|
|
1964
|
+
for (const [name, value] of Object.entries(zero)) {
|
|
1965
|
+
this.shadow.set(name, value);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
/**
|
|
1969
|
+
* The byte image of the shadow (the block's own writer, D20).
|
|
1970
|
+
* @returns block.byteLength bytes
|
|
1971
|
+
*/
|
|
1972
|
+
headerBytes() {
|
|
1973
|
+
const bytes = new ArrayBuffer(this.block.byteLength);
|
|
1974
|
+
this.block.write(new DataView(bytes), Object.fromEntries(this.shadow));
|
|
1975
|
+
return bytes;
|
|
1976
|
+
}
|
|
1977
|
+
/**
|
|
1978
|
+
* Refreshes the shadow from a landed header; a field set since the last flush keeps its queued value.
|
|
1979
|
+
* @param view - the landed header
|
|
1980
|
+
*/
|
|
1981
|
+
absorb(view) {
|
|
1982
|
+
for (const [name, value] of Object.entries(this.block.read(view))) {
|
|
1983
|
+
if (!this.queued.has(name)) {
|
|
1984
|
+
this.shadow.set(name, value);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* The fields set since the last flush; clears the queue.
|
|
1990
|
+
* @returns the field names
|
|
1991
|
+
*/
|
|
1992
|
+
takeQueued() {
|
|
1993
|
+
const fields = [...this.queued.keys()];
|
|
1994
|
+
this.queued.clear();
|
|
1995
|
+
return fields;
|
|
1996
|
+
}
|
|
1997
|
+
/** Drops the queue (after load() wrote the whole header). */
|
|
1998
|
+
clearQueued() {
|
|
1999
|
+
this.queued.clear();
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
function createPending(k2, generation) {
|
|
2003
|
+
let resolveFn = () => void 0;
|
|
2004
|
+
let rejectFn = () => void 0;
|
|
2005
|
+
const promise = new Promise((resolve, reject) => {
|
|
2006
|
+
resolveFn = resolve;
|
|
2007
|
+
rejectFn = reject;
|
|
2008
|
+
});
|
|
2009
|
+
return {
|
|
2010
|
+
k: k2,
|
|
2011
|
+
generation,
|
|
2012
|
+
promise,
|
|
2013
|
+
resolve: () => {
|
|
2014
|
+
resolveFn();
|
|
2015
|
+
},
|
|
2016
|
+
reject: (error) => {
|
|
2017
|
+
rejectFn(error);
|
|
2018
|
+
},
|
|
2019
|
+
id: 0,
|
|
2020
|
+
submitted: null,
|
|
2021
|
+
stale: false,
|
|
2022
|
+
startedAt: 0,
|
|
2023
|
+
sceneOffset: 0,
|
|
2024
|
+
stateOffset: 0,
|
|
2025
|
+
profile: null
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
class ForceSimulation {
|
|
2029
|
+
/**
|
|
2030
|
+
* Creates a simulation over a context and a model (state "created"; load() makes it "loaded").
|
|
2031
|
+
* @param ctx - the context (ready)
|
|
2032
|
+
* @param model - the force model
|
|
2033
|
+
* @param options - the option record (defaults applied by the caller's factory)
|
|
2034
|
+
* @param tuning - the resolved GPU tuning
|
|
2035
|
+
* @param resolve - how setParams merges a patch into the current record
|
|
2036
|
+
*/
|
|
2037
|
+
constructor(ctx, model, options, tuning, resolve) {
|
|
2038
|
+
this.overrideList = /* @__PURE__ */ new Map();
|
|
2039
|
+
this.pending = [];
|
|
2040
|
+
this.stateValue = "created";
|
|
2041
|
+
this.generationValue = 0;
|
|
2042
|
+
this.snapshot = null;
|
|
2043
|
+
this.serial = null;
|
|
2044
|
+
this.owner = null;
|
|
2045
|
+
this.n = 0;
|
|
2046
|
+
this.scale = 1;
|
|
2047
|
+
this.center = [0, 0, 0];
|
|
2048
|
+
this.buffers = null;
|
|
2049
|
+
this.core = null;
|
|
2050
|
+
this.resources = null;
|
|
2051
|
+
this.weightsUpload = null;
|
|
2052
|
+
this.ready = Promise.resolve();
|
|
2053
|
+
this.submitChain = Promise.resolve();
|
|
2054
|
+
this.fixedWords = new Uint32Array(0);
|
|
2055
|
+
this.fixedDirty = false;
|
|
2056
|
+
this.iterationsSubmitted = 0;
|
|
2057
|
+
this.iterationsDoneValue = 0;
|
|
2058
|
+
this.settledCountValue = 0;
|
|
2059
|
+
this.settledValue = false;
|
|
2060
|
+
this.firstPending = true;
|
|
2061
|
+
this.statsValue = null;
|
|
2062
|
+
this.lastSubmittedBatchIdValue = 0;
|
|
2063
|
+
this.reheatedAfterBatchId = 0;
|
|
2064
|
+
this.coalescedValue = 0;
|
|
2065
|
+
this.torndown = false;
|
|
2066
|
+
ctx.assertReady();
|
|
2067
|
+
requireFields(model.params, SHARED_PARAM_FIELDS, "model.params");
|
|
2068
|
+
requireFields(model.state, SHARED_STATE_FIELDS, "model.state");
|
|
2069
|
+
if (model.params.byteLength > UNIFORM_SLOT_BYTES) {
|
|
2070
|
+
throw invalidArgument(
|
|
2071
|
+
"model.params",
|
|
2072
|
+
model.params.byteLength,
|
|
2073
|
+
`<= ${UNIFORM_SLOT_BYTES} bytes`,
|
|
2074
|
+
"the params block must fit one ring slot"
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
if (model.state.byteLength > STATE_HEADER_BYTES) {
|
|
2078
|
+
throw invalidArgument(
|
|
2079
|
+
"model.state",
|
|
2080
|
+
model.state.byteLength,
|
|
2081
|
+
`<= ${STATE_HEADER_BYTES} bytes`,
|
|
2082
|
+
"the state block must fit the state header"
|
|
2083
|
+
);
|
|
2084
|
+
}
|
|
2085
|
+
if (model.stages.length === 0) {
|
|
2086
|
+
throw invalidArgument(
|
|
2087
|
+
"model.stages",
|
|
2088
|
+
model.stages,
|
|
2089
|
+
"at least one stage name",
|
|
2090
|
+
"a model declares its stages"
|
|
2091
|
+
);
|
|
2092
|
+
}
|
|
2093
|
+
const maxInFlight = options.maxInFlight ?? FA2_DEFAULTS.maxInFlight;
|
|
2094
|
+
if (!Number.isInteger(maxInFlight) || maxInFlight < 1) {
|
|
2095
|
+
throw invalidArgument(
|
|
2096
|
+
"maxInFlight",
|
|
2097
|
+
maxInFlight,
|
|
2098
|
+
"an integer >= 1",
|
|
2099
|
+
`maxInFlight must be an integer >= 1, got ${maxInFlight}`
|
|
2100
|
+
);
|
|
2101
|
+
}
|
|
2102
|
+
const dim = options.dim ?? FA2_DEFAULTS.dim;
|
|
2103
|
+
if (dim !== 2 && dim !== 3) {
|
|
2104
|
+
throw invalidArgument("dim", dim, "2 or 3", `dim must be 2 or 3, got ${String(dim)}`);
|
|
2105
|
+
}
|
|
2106
|
+
scaleOf(options);
|
|
2107
|
+
centerOf(options);
|
|
2108
|
+
simulationCounter++;
|
|
2109
|
+
this.ctx = ctx;
|
|
2110
|
+
this.model = model;
|
|
2111
|
+
this.tuning = tuning;
|
|
2112
|
+
this.resolveOptions = resolve;
|
|
2113
|
+
this.optionsValue = options;
|
|
2114
|
+
this.maxInFlight = maxInFlight;
|
|
2115
|
+
this.dimValue = dim;
|
|
2116
|
+
this.tierValue = tuning.repulsion === "grid" ? "grid" : "exact";
|
|
2117
|
+
this.label = `${model.kind}#${simulationCounter}`;
|
|
2118
|
+
this.traceBytes = model.trace.byteLength;
|
|
2119
|
+
this.writer = new HeaderWriter(model.state);
|
|
2120
|
+
this.ring = new UniformRing(
|
|
2121
|
+
ctx.device,
|
|
2122
|
+
ctx.allocator,
|
|
2123
|
+
(maxInFlight + 1) * MAX_ITERATIONS_PER_STEP,
|
|
2124
|
+
`${this.label}/ring`
|
|
2125
|
+
);
|
|
2126
|
+
this.unregisterLost = ctx.onLost((info) => {
|
|
2127
|
+
this.onDeviceLost(info);
|
|
2128
|
+
});
|
|
2129
|
+
if (ctx.debug.inspect) {
|
|
2130
|
+
this.inspect = (name) => this.inspectBuffer(name);
|
|
2131
|
+
this.debugRunStages = (upTo) => this.runStages(upTo);
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
// ---------------------------------------------------------------- read-only state
|
|
2135
|
+
/**
|
|
2136
|
+
* "created" | "loaded" | "disposed".
|
|
2137
|
+
* @returns the state
|
|
2138
|
+
*/
|
|
2139
|
+
get state() {
|
|
2140
|
+
return this.stateValue;
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* The current options record (defaults applied).
|
|
2144
|
+
* @returns the record
|
|
2145
|
+
*/
|
|
2146
|
+
get options() {
|
|
2147
|
+
return this.optionsValue;
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* The repulsion tier of the current load ("exact" until P4 lifts the grid tier).
|
|
2151
|
+
* @returns the tier
|
|
2152
|
+
*/
|
|
2153
|
+
get tier() {
|
|
2154
|
+
return this.tierValue;
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* The generation counter bumped by every load() (stale readbacks are discarded).
|
|
2158
|
+
* @internal
|
|
2159
|
+
* @returns the generation
|
|
2160
|
+
*/
|
|
2161
|
+
get generation() {
|
|
2162
|
+
return this.generationValue;
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* Nodes of the current load (0 before load()).
|
|
2166
|
+
* @returns the node count
|
|
2167
|
+
*/
|
|
2168
|
+
get nodeCount() {
|
|
2169
|
+
return this.n;
|
|
2170
|
+
}
|
|
2171
|
+
/**
|
|
2172
|
+
* 2 or 3, fixed at construction (setParams({ dim }) is rejected, spec 7.13).
|
|
2173
|
+
* @returns the dimension
|
|
2174
|
+
*/
|
|
2175
|
+
get dim() {
|
|
2176
|
+
return this.dimValue;
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* iterationsDone >= the budget OR settledCount >= settleWindow of the last completed batch (spec 7.17); true for an empty graph.
|
|
2180
|
+
* @returns whether the layout is settled
|
|
2181
|
+
*/
|
|
2182
|
+
get settled() {
|
|
2183
|
+
return this.settledValue;
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Batches created by step() and not yet landed (PLAN DECISION 7: counted from the step() call).
|
|
2187
|
+
* @returns the count
|
|
2188
|
+
*/
|
|
2189
|
+
get inFlight() {
|
|
2190
|
+
return this.pending.length;
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Iterations landed since load() or the last reheat().
|
|
2194
|
+
* @returns the count
|
|
2195
|
+
*/
|
|
2196
|
+
get iterationsDone() {
|
|
2197
|
+
return this.iterationsDoneValue;
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* The stats of the last completed batch; before one lands, the host-written header decoded through the model
|
|
2201
|
+
* with an empty trace and msPerIteration null.
|
|
2202
|
+
* @returns the stats
|
|
2203
|
+
*/
|
|
2204
|
+
get stats() {
|
|
2205
|
+
if (this.statsValue !== null) {
|
|
2206
|
+
return this.statsValue;
|
|
2207
|
+
}
|
|
2208
|
+
const header = new DataView(this.writer.headerBytes());
|
|
2209
|
+
const trace = new DataView(new ArrayBuffer(0));
|
|
2210
|
+
return withMs(this.model.readStats(header, trace), null);
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* The override list (spec 7.12): rows whose readback is skipped while a batch older than the write is in flight.
|
|
2214
|
+
* @internal
|
|
2215
|
+
* @returns row -> lastSubmittedBatchId at the write
|
|
2216
|
+
*/
|
|
2217
|
+
get overrides() {
|
|
2218
|
+
return this.overrideList;
|
|
2219
|
+
}
|
|
2220
|
+
/**
|
|
2221
|
+
* The last submitted batch id.
|
|
2222
|
+
* @internal
|
|
2223
|
+
* @returns the id (0 before any submit)
|
|
2224
|
+
*/
|
|
2225
|
+
get lastSubmittedBatchId() {
|
|
2226
|
+
return this.lastSubmittedBatchIdValue;
|
|
2227
|
+
}
|
|
2228
|
+
/**
|
|
2229
|
+
* Number of step() calls that returned an existing batch's promise instead of submitting (spec 7.19 item 3;
|
|
2230
|
+
* read by test/helpers/frame-loop.ts).
|
|
2231
|
+
* @internal
|
|
2232
|
+
* @returns the count
|
|
2233
|
+
*/
|
|
2234
|
+
get coalesced() {
|
|
2235
|
+
return this.coalescedValue;
|
|
2236
|
+
}
|
|
2237
|
+
// ---------------------------------------------------------------- load
|
|
2238
|
+
/**
|
|
2239
|
+
* Uploads the snapshot's core, seeds the NaN rows of `positions`, repacks it into layout-unit vec4f with the
|
|
2240
|
+
* mass in .w, writes the initial state, starts the model's compile + bind and enters "loaded" (spec 7.19; a
|
|
2241
|
+
* load() during flight bumps the generation and discards the in-flight batches). Order of the checks (PLAN
|
|
2242
|
+
* DECISION 16): E_DISPOSED, the context's assertReady, E_SNAPSHOT (directed), E_TOO_LARGE (nodeCount), the
|
|
2243
|
+
* positions array, the tuning, then the core upload and model.inputs() BEFORE any state is touched.
|
|
2244
|
+
* PLAN DECISION 10: nodeCount 0 loads with no GPU work at all. PLAN DECISION 11: the tier is resolved here
|
|
2245
|
+
* and "grid" is E_UNSUPPORTED until P4. PLAN DECISION 17: a same-size load() keeps the buffers and re-zeroes
|
|
2246
|
+
* partials, the trace region and every `zero: true` model buffer.
|
|
2247
|
+
* @param snapshot - an undirected snapshot
|
|
2248
|
+
* @param positions - the owner's stride-3 scene-unit array (NaN rows are seeded in place)
|
|
2249
|
+
*/
|
|
2250
|
+
load(snapshot, positions) {
|
|
2251
|
+
this.assertNotDisposed();
|
|
2252
|
+
this.ctx.assertReady();
|
|
2253
|
+
if (snapshot.directed) {
|
|
2254
|
+
throw new WebGpuGraphError(
|
|
2255
|
+
"E_SNAPSHOT",
|
|
2256
|
+
"a layout needs an undirected snapshot: pass toUndirected().snapshot",
|
|
2257
|
+
{
|
|
2258
|
+
reason: "directed",
|
|
2259
|
+
serial: snapshot.serial
|
|
2260
|
+
}
|
|
2261
|
+
);
|
|
2262
|
+
}
|
|
2263
|
+
const n = snapshot.nodeCount;
|
|
2264
|
+
if (n > MAX_1D_ITEMS) {
|
|
2265
|
+
throw new WebGpuGraphError(
|
|
2266
|
+
"E_TOO_LARGE",
|
|
2267
|
+
`${n} nodes exceed ${MAX_1D_ITEMS} (the third partials level is P4)`,
|
|
2268
|
+
{
|
|
2269
|
+
needed: n,
|
|
2270
|
+
limit: MAX_1D_ITEMS,
|
|
2271
|
+
path: "partials",
|
|
2272
|
+
algorithm: this.model.kind
|
|
2273
|
+
}
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
if (positions.length !== 3 * n) {
|
|
2277
|
+
throw invalidArgument(
|
|
2278
|
+
"positions",
|
|
2279
|
+
positions.length,
|
|
2280
|
+
3 * n,
|
|
2281
|
+
`positions has ${positions.length} entries, expected ${3 * n}`
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
if (isSharedBuffer(positions.buffer)) {
|
|
2285
|
+
throw invalidArgument(
|
|
2286
|
+
"positions",
|
|
2287
|
+
"SharedArrayBuffer",
|
|
2288
|
+
"an ArrayBuffer-backed Float32Array",
|
|
2289
|
+
"positions must not be backed by a SharedArrayBuffer"
|
|
2290
|
+
);
|
|
2291
|
+
}
|
|
2292
|
+
const scale = scaleOf(this.optionsValue);
|
|
2293
|
+
const center = centerOf(this.optionsValue);
|
|
2294
|
+
const tier = tierFor(this.tuning, n);
|
|
2295
|
+
if (tier === "grid") {
|
|
2296
|
+
throw new WebGpuGraphError(
|
|
2297
|
+
"E_UNSUPPORTED",
|
|
2298
|
+
`the grid repulsion tier lands at P4 (n = ${n}, exactMaxNodes = ${this.tuning.exactMaxNodes})`,
|
|
2299
|
+
{
|
|
2300
|
+
feature: "repulsion.grid",
|
|
2301
|
+
hint: 'pass repulsion: "exact" or raise exactMaxNodes'
|
|
2302
|
+
}
|
|
2303
|
+
);
|
|
2304
|
+
}
|
|
2305
|
+
const positionsBytes = 16 * n;
|
|
2306
|
+
if (positionsBytes > this.ctx.caps.limits.maxBufferSize) {
|
|
2307
|
+
throw new WebGpuGraphError("E_TOO_LARGE", `${positionsBytes} bytes of positions exceed maxBufferSize`, {
|
|
2308
|
+
needed: positionsBytes,
|
|
2309
|
+
limit: this.ctx.caps.limits.maxBufferSize,
|
|
2310
|
+
path: "positions",
|
|
2311
|
+
algorithm: this.model.kind
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
let core = null;
|
|
2315
|
+
let inputs = null;
|
|
2316
|
+
if (n > 0) {
|
|
2317
|
+
core = this.ctx.residency.core(snapshot);
|
|
2318
|
+
if (core.plan === "windowed") {
|
|
2319
|
+
throw new WebGpuGraphError(
|
|
2320
|
+
"E_TOO_LARGE",
|
|
2321
|
+
"a windowed core cannot be walked by the layout kernels until P4",
|
|
2322
|
+
{
|
|
2323
|
+
needed: snapshot.arcCount,
|
|
2324
|
+
limit: this.ctx.caps.limits.maxStorageBufferBindingSize,
|
|
2325
|
+
path: "windowed",
|
|
2326
|
+
algorithm: this.model.kind
|
|
2327
|
+
}
|
|
2328
|
+
);
|
|
2329
|
+
}
|
|
2330
|
+
inputs = this.model.inputs(snapshot, this.optionsValue);
|
|
2331
|
+
if (inputs.mass.length !== n) {
|
|
2332
|
+
throw invalidArgument(
|
|
2333
|
+
"nodeMass",
|
|
2334
|
+
inputs.mass.length,
|
|
2335
|
+
n,
|
|
2336
|
+
`the model resolved ${inputs.mass.length} masses for ${n} nodes`
|
|
2337
|
+
);
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
this.generationValue++;
|
|
2341
|
+
this.discardPending();
|
|
2342
|
+
const resized = this.buffers === null || n !== this.n || snapshot.serial !== this.serial;
|
|
2343
|
+
this.snapshot = snapshot;
|
|
2344
|
+
this.serial = snapshot.serial;
|
|
2345
|
+
this.owner = positions;
|
|
2346
|
+
this.n = n;
|
|
2347
|
+
this.tierValue = tier;
|
|
2348
|
+
this.scale = scale;
|
|
2349
|
+
this.center = center;
|
|
2350
|
+
this.core = core;
|
|
2351
|
+
this.iterationsSubmitted = 0;
|
|
2352
|
+
this.iterationsDoneValue = 0;
|
|
2353
|
+
this.settledCountValue = 0;
|
|
2354
|
+
this.firstPending = true;
|
|
2355
|
+
this.statsValue = null;
|
|
2356
|
+
this.writer.reset();
|
|
2357
|
+
if (resized) {
|
|
2358
|
+
this.destroyBuffers();
|
|
2359
|
+
this.overrideList.clear();
|
|
2360
|
+
this.fixedWords = makeMask(n);
|
|
2361
|
+
this.fixedDirty = false;
|
|
2362
|
+
}
|
|
2363
|
+
if (n === 0 || core === null || inputs === null) {
|
|
2364
|
+
this.weightsUpload = null;
|
|
2365
|
+
this.resources = null;
|
|
2366
|
+
this.ready = this.ready.catch(() => void 0);
|
|
2367
|
+
this.settledValue = true;
|
|
2368
|
+
this.stateValue = "loaded";
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
const buffers = resized ? this.allocate(n) : this.requireBuffers();
|
|
2372
|
+
if (!resized) {
|
|
2373
|
+
this.clearKept(buffers);
|
|
2374
|
+
}
|
|
2375
|
+
this.buffers = buffers;
|
|
2376
|
+
const range = this.model.kind === "fruchtermanReingold" ? "fr" : "fa2";
|
|
2377
|
+
seedPositions(snapshot, positions, this.optionsValue.seed ?? null, this.dimValue, scale, center, range);
|
|
2378
|
+
this.uploadPositions(buffers, positions, inputs.mass);
|
|
2379
|
+
const weights = this.resolveWeightsBinding(snapshot, core, inputs.weights);
|
|
2380
|
+
const overrides = { ...this.model.overrides(this.optionsValue), ...graphOverrides(core, null, weights) };
|
|
2381
|
+
const resources = this.makeResources(core, weights, buffers);
|
|
2382
|
+
this.resources = resources;
|
|
2383
|
+
this.startBind(resources, overrides);
|
|
2384
|
+
this.settledValue = false;
|
|
2385
|
+
this.stateValue = "loaded";
|
|
2386
|
+
}
|
|
2387
|
+
// ---------------------------------------------------------------- step and the batch driver
|
|
2388
|
+
/**
|
|
2389
|
+
* Submits k iterations (spec 7.19 items 1-6): "created" -> E_NOT_LOADED; "disposed" -> E_DISPOSED; a released
|
|
2390
|
+
* snapshot -> E_RELEASED; k outside [1, MAX_ITERATIONS_PER_STEP] -> E_INVALID_ARGUMENT; settled -> resolves at
|
|
2391
|
+
* once; inFlight >= maxInFlight -> the OLDEST pending batch's promise (coalesced); else the batch is queued on
|
|
2392
|
+
* the submit chain (after the bind promise and allocator.check()) and its promise resolves when its readback
|
|
2393
|
+
* landed in the owner's array. The same promise object is returned for every coalesced call. PLAN DECISION 7:
|
|
2394
|
+
* the batch counts in `inFlight` from this call, not from its submission.
|
|
2395
|
+
* @param iterations - k (default options.iterationsPerStep, default 1)
|
|
2396
|
+
* @returns resolves when the batch landed (or was discarded); rejects E_VALIDATION / E_DEVICE_LOST / E_OUT_OF_MEMORY / E_SHADER_COMPILE
|
|
2397
|
+
*/
|
|
2398
|
+
step(iterations) {
|
|
2399
|
+
if (this.stateValue === "created") {
|
|
2400
|
+
return Promise.reject(
|
|
2401
|
+
new WebGpuGraphError("E_NOT_LOADED", `${this.label}: load() first`, { state: "created" })
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2404
|
+
if (this.stateValue === "disposed") {
|
|
2405
|
+
return Promise.reject(disposedError(this.label));
|
|
2406
|
+
}
|
|
2407
|
+
try {
|
|
2408
|
+
this.ctx.assertReady();
|
|
2409
|
+
} catch (err) {
|
|
2410
|
+
return Promise.reject(asError(err));
|
|
2411
|
+
}
|
|
2412
|
+
const { snapshot } = this;
|
|
2413
|
+
if (snapshot !== null && this.ctx.residency.isReleased(snapshot.serial)) {
|
|
2414
|
+
return Promise.reject(
|
|
2415
|
+
new WebGpuGraphError(
|
|
2416
|
+
"E_RELEASED",
|
|
2417
|
+
`snapshot ${snapshot.serial} was released while ${this.label} used it`,
|
|
2418
|
+
{
|
|
2419
|
+
serial: snapshot.serial
|
|
2420
|
+
}
|
|
2421
|
+
)
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2424
|
+
const k2 = iterations ?? this.optionsValue.iterationsPerStep ?? FA2_DEFAULTS.iterationsPerStep;
|
|
2425
|
+
if (!Number.isInteger(k2) || k2 < 1 || k2 > MAX_ITERATIONS_PER_STEP) {
|
|
2426
|
+
return Promise.reject(
|
|
2427
|
+
invalidArgument(
|
|
2428
|
+
"iterations",
|
|
2429
|
+
k2,
|
|
2430
|
+
`an integer in [1, ${MAX_ITERATIONS_PER_STEP}]`,
|
|
2431
|
+
`step(${k2}): iterations must be an integer in [1, ${MAX_ITERATIONS_PER_STEP}]`
|
|
2432
|
+
)
|
|
2433
|
+
);
|
|
2434
|
+
}
|
|
2435
|
+
if (this.settledValue || this.n === 0) {
|
|
2436
|
+
return Promise.resolve();
|
|
2437
|
+
}
|
|
2438
|
+
if (this.pending.length >= this.maxInFlight) {
|
|
2439
|
+
this.coalescedValue++;
|
|
2440
|
+
return this.pending[0].promise;
|
|
2441
|
+
}
|
|
2442
|
+
const record = createPending(k2, this.generationValue);
|
|
2443
|
+
this.pending.push(record);
|
|
2444
|
+
this.submitChain = this.submitChain.then(() => this.submitBatch(record));
|
|
2445
|
+
return record.promise;
|
|
2446
|
+
}
|
|
2447
|
+
/**
|
|
2448
|
+
* Resolves when nothing is in flight (every pending batch landed or was discarded).
|
|
2449
|
+
* @returns the promise
|
|
2450
|
+
*/
|
|
2451
|
+
async flush() {
|
|
2452
|
+
while (this.pending.length > 0) {
|
|
2453
|
+
await Promise.allSettled(this.pending.map((r) => r.promise));
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
/**
|
|
2457
|
+
* Node batch driver: loops step(batch) until settled, the budget (`maxIter ?? options.maxIter`, PLAN DECISION
|
|
2458
|
+
* 1) is reached or the signal aborts (E_ABORTED; the batch in flight at the abort is discarded, spec 5.7 / Q-15).
|
|
2459
|
+
* @param options - maxIter, batch (default 8), signal
|
|
2460
|
+
* @returns the stats of the last completed batch
|
|
2461
|
+
*/
|
|
2462
|
+
async run(options) {
|
|
2463
|
+
this.assertNotDisposed();
|
|
2464
|
+
const batch = options?.batch ?? 8;
|
|
2465
|
+
if (!Number.isInteger(batch) || batch < 1 || batch > MAX_ITERATIONS_PER_STEP) {
|
|
2466
|
+
throw invalidArgument(
|
|
2467
|
+
"batch",
|
|
2468
|
+
batch,
|
|
2469
|
+
`an integer in [1, ${MAX_ITERATIONS_PER_STEP}]`,
|
|
2470
|
+
"run(): batch must be an integer in [1, MAX_ITERATIONS_PER_STEP]"
|
|
2471
|
+
);
|
|
2472
|
+
}
|
|
2473
|
+
const budget = options?.maxIter ?? this.iterationBudget();
|
|
2474
|
+
if (Number.isNaN(budget) || budget < 0) {
|
|
2475
|
+
throw invalidArgument("maxIter", budget, "a number >= 0", "run(): maxIter must be >= 0");
|
|
2476
|
+
}
|
|
2477
|
+
const signal = options?.signal;
|
|
2478
|
+
const onAbort = () => {
|
|
2479
|
+
this.discardPending();
|
|
2480
|
+
};
|
|
2481
|
+
if (signal !== void 0) {
|
|
2482
|
+
if (signal.aborted) {
|
|
2483
|
+
throw this.abortedError();
|
|
2484
|
+
}
|
|
2485
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2486
|
+
}
|
|
2487
|
+
try {
|
|
2488
|
+
while (!this.settled && this.iterationsDoneValue < budget) {
|
|
2489
|
+
if (signal?.aborted === true) {
|
|
2490
|
+
throw this.abortedError();
|
|
2491
|
+
}
|
|
2492
|
+
const k2 = Math.min(batch, budget - this.iterationsDoneValue);
|
|
2493
|
+
await this.step(k2);
|
|
2494
|
+
}
|
|
2495
|
+
if (signal?.aborted === true) {
|
|
2496
|
+
throw this.abortedError();
|
|
2497
|
+
}
|
|
2498
|
+
return this.stats;
|
|
2499
|
+
} finally {
|
|
2500
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
// ---------------------------------------------------------------- pins, drags, reheat, params
|
|
2504
|
+
/**
|
|
2505
|
+
* Copies the mask words (spec 7.12): E_INVALID_ARGUMENT when shorter than ceil(n / 32); the buffer is marked
|
|
2506
|
+
* dirty and re-uploaded before the next submit; reheat() iff some bit went 1 -> 0 (an unpin). PLAN DECISION
|
|
2507
|
+
* 18: the comparison is bit by bit below n (bits at or above n never count as an unpin).
|
|
2508
|
+
* @param mask - the NodeMask (LSB-first words)
|
|
2509
|
+
*/
|
|
2510
|
+
setFixed(mask) {
|
|
2511
|
+
this.assertLoaded();
|
|
2512
|
+
const { n } = this;
|
|
2513
|
+
const words = Math.ceil(n / 32);
|
|
2514
|
+
if (mask.length < words) {
|
|
2515
|
+
throw invalidArgument(
|
|
2516
|
+
"mask",
|
|
2517
|
+
mask.length,
|
|
2518
|
+
words,
|
|
2519
|
+
`setFixed: the mask has ${mask.length} words, ${words} needed for ${n} nodes`
|
|
2520
|
+
);
|
|
2521
|
+
}
|
|
2522
|
+
let unpinned = false;
|
|
2523
|
+
for (let w = 0; w < words && !unpinned; w++) {
|
|
2524
|
+
if (this.fixedWords[w] === mask[w]) {
|
|
2525
|
+
continue;
|
|
2526
|
+
}
|
|
2527
|
+
const last = Math.min(n, w * 32 + 32);
|
|
2528
|
+
for (let i = w * 32; i < last; i++) {
|
|
2529
|
+
if (maskTest(this.fixedWords, i) && !maskTest(mask, i)) {
|
|
2530
|
+
unpinned = true;
|
|
2531
|
+
break;
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
this.fixedWords.set(mask.subarray(0, words));
|
|
2536
|
+
this.fixedDirty = true;
|
|
2537
|
+
if (unpinned) {
|
|
2538
|
+
this.reheat();
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
/**
|
|
2542
|
+
* Writes a scene-unit position (spec 7.12): into the owner's array at once, into the device (layout units, z 0
|
|
2543
|
+
* in 2D, 12 bytes at 16 i, queue-ordered before the next submit), into the override list keyed by the last
|
|
2544
|
+
* submitted batch id, then reheat(). PLAN DECISION 18: non-finite coordinates are E_INVALID_ARGUMENT.
|
|
2545
|
+
* @param index - the node index (< n)
|
|
2546
|
+
* @param x - scene x
|
|
2547
|
+
* @param y - scene y
|
|
2548
|
+
* @param z - scene z (ignored on the device in 2D)
|
|
2549
|
+
*/
|
|
2550
|
+
setPosition(index, x, y, z) {
|
|
2551
|
+
this.assertLoaded();
|
|
2552
|
+
const { n } = this;
|
|
2553
|
+
if (!Number.isInteger(index) || index < 0 || index >= n) {
|
|
2554
|
+
throw invalidArgument(
|
|
2555
|
+
"index",
|
|
2556
|
+
index,
|
|
2557
|
+
`an integer in [0, ${n})`,
|
|
2558
|
+
`setPosition(${index}): index out of range`
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
|
2562
|
+
throw invalidArgument(
|
|
2563
|
+
"position",
|
|
2564
|
+
[x, y, z],
|
|
2565
|
+
"finite coordinates",
|
|
2566
|
+
"setPosition: coordinates must be finite"
|
|
2567
|
+
);
|
|
2568
|
+
}
|
|
2569
|
+
const { owner, buffers } = this;
|
|
2570
|
+
if (owner === null || buffers === null) {
|
|
2571
|
+
throw new WebGpuGraphError("E_NOT_LOADED", `${this.label}: load() first`, { state: this.stateValue });
|
|
2572
|
+
}
|
|
2573
|
+
owner[3 * index] = x;
|
|
2574
|
+
owner[3 * index + 1] = y;
|
|
2575
|
+
owner[3 * index + 2] = z;
|
|
2576
|
+
const [cx, cy, cz] = this.center;
|
|
2577
|
+
const layout = new Float32Array([
|
|
2578
|
+
(x - cx) / this.scale,
|
|
2579
|
+
(y - cy) / this.scale,
|
|
2580
|
+
this.dimValue === 2 ? 0 : (z - cz) / this.scale
|
|
2581
|
+
]);
|
|
2582
|
+
this.ctx.device.queue.writeBuffer(buffers.positions, 16 * index, layout);
|
|
2583
|
+
this.overrideList.set(index, this.lastSubmittedBatchIdValue);
|
|
2584
|
+
this.reheat();
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* iterationsDone = 0, settledCount = 0 (a queued state write), model.onReheat(writer); nothing else (D8: the
|
|
2588
|
+
* speed controller keeps its state). `settled` is recomputed at once (PLAN DECISION 8). Allowed before load().
|
|
2589
|
+
* PLAN DECISION 21: the id of the last batch submitted so far is remembered; a batch with that id or an older
|
|
2590
|
+
* one was computed from the pre-reheat counter, so its landed settledCount is taken as 0 (see onLanded) and a
|
|
2591
|
+
* drag or unpin can never leave the simulation settled through a batch that was already in flight.
|
|
2592
|
+
*/
|
|
2593
|
+
reheat() {
|
|
2594
|
+
this.assertNotDisposed();
|
|
2595
|
+
this.iterationsDoneValue = 0;
|
|
2596
|
+
this.settledCountValue = 0;
|
|
2597
|
+
this.reheatedAfterBatchId = this.lastSubmittedBatchIdValue;
|
|
2598
|
+
this.writer.set("settledCount", 0);
|
|
2599
|
+
this.model.onReheat(this.writer);
|
|
2600
|
+
this.settledValue = this.computeSettled();
|
|
2601
|
+
}
|
|
2602
|
+
/**
|
|
2603
|
+
* Live tuning (spec 3.3, 7.17): `dim` differing from the current -> E_INVALID_ARGUMENT; `maxInFlight` differing
|
|
2604
|
+
* -> E_INVALID_ARGUMENT (the ring is sized by it); a non-null `nodeSize` -> E_UNSUPPORTED; the record is
|
|
2605
|
+
* replaced through `resolve(patch, current)`; a change of the model's override set (a force LAW) recompiles
|
|
2606
|
+
* and re-binds; scale / center changes reach the next batch through the shared params of every slot (the
|
|
2607
|
+
* model's toScene reads them); then model.onSetParams(patch, writer) and reheat().
|
|
2608
|
+
* @param patch - the fields to change
|
|
2609
|
+
*/
|
|
2610
|
+
setParams(patch) {
|
|
2611
|
+
this.assertNotDisposed();
|
|
2612
|
+
const current = this.optionsValue;
|
|
2613
|
+
const { dim, maxInFlight } = patch;
|
|
2614
|
+
if (dim !== void 0 && dim !== this.dimValue) {
|
|
2615
|
+
throw invalidArgument(
|
|
2616
|
+
"dim",
|
|
2617
|
+
dim,
|
|
2618
|
+
this.dimValue,
|
|
2619
|
+
"dim is fixed at construction; graphty-element re-creates the engine on a view-mode switch (spec 7.13)"
|
|
2620
|
+
);
|
|
2621
|
+
}
|
|
2622
|
+
if (maxInFlight !== void 0 && maxInFlight !== this.maxInFlight) {
|
|
2623
|
+
throw invalidArgument(
|
|
2624
|
+
"maxInFlight",
|
|
2625
|
+
maxInFlight,
|
|
2626
|
+
this.maxInFlight,
|
|
2627
|
+
"maxInFlight is fixed at construction (the uniform ring is sized by it)"
|
|
2628
|
+
);
|
|
2629
|
+
}
|
|
2630
|
+
const nodeSize = optionField(patch, "nodeSize");
|
|
2631
|
+
if (nodeSize !== void 0 && nodeSize !== null) {
|
|
2632
|
+
throw new WebGpuGraphError("E_UNSUPPORTED", "nodeSize (adjustSizes) is deferred (spec 7.14)", {
|
|
2633
|
+
option: "nodeSize",
|
|
2634
|
+
hint: "leave nodeSize null; the size correction ships in a later slice"
|
|
2635
|
+
});
|
|
2636
|
+
}
|
|
2637
|
+
const next = this.resolveOptions(patch, current);
|
|
2638
|
+
const scale = scaleOf(next);
|
|
2639
|
+
const center = centerOf(next);
|
|
2640
|
+
const before = stableKey(this.model.overrides(current));
|
|
2641
|
+
const after = stableKey(this.model.overrides(next));
|
|
2642
|
+
this.optionsValue = next;
|
|
2643
|
+
this.scale = scale;
|
|
2644
|
+
this.center = center;
|
|
2645
|
+
const { resources, core } = this;
|
|
2646
|
+
if (this.stateValue === "loaded" && resources !== null && core !== null && before !== after) {
|
|
2647
|
+
const overrides = { ...this.model.overrides(next), ...graphOverrides(core, null, resources.weights) };
|
|
2648
|
+
this.startBind(resources, overrides);
|
|
2649
|
+
}
|
|
2650
|
+
this.model.onSetParams(patch, this.writer);
|
|
2651
|
+
this.reheat();
|
|
2652
|
+
}
|
|
2653
|
+
// ---------------------------------------------------------------- dispose and device loss
|
|
2654
|
+
/**
|
|
2655
|
+
* Discards in-flight batches (their promises resolve), destroys every simulation buffer and the ring, trims
|
|
2656
|
+
* the pool, unregisters the loss listener; state "disposed"; idempotent.
|
|
2657
|
+
*/
|
|
2658
|
+
dispose() {
|
|
2659
|
+
if (this.stateValue === "disposed") {
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
this.stateValue = "disposed";
|
|
2663
|
+
this.teardown();
|
|
2664
|
+
}
|
|
2665
|
+
// ---------------------------------------------------------------- private: the batch driver
|
|
2666
|
+
/**
|
|
2667
|
+
* The serialised submission of one pending batch: awaits the bind / warm promise and allocator.check(), then
|
|
2668
|
+
* records and submits unless the record went stale meanwhile; wires the readback handlers. A record that went
|
|
2669
|
+
* stale while waiting (a load() or dispose() superseded it) RESOLVES even when the wait rejected (PLAN
|
|
2670
|
+
* DECISION 8: the rejection belongs to the superseded generation -- a bind that failed E_SHADER_COMPILE, or
|
|
2671
|
+
* the ring's E_DISPOSED after dispose() tore it down under a running bind).
|
|
2672
|
+
* @param record - the pending batch
|
|
2673
|
+
*/
|
|
2674
|
+
async submitBatch(record) {
|
|
2675
|
+
try {
|
|
2676
|
+
await this.ready;
|
|
2677
|
+
await this.ctx.allocator.check();
|
|
2678
|
+
} catch (err) {
|
|
2679
|
+
this.finish(record);
|
|
2680
|
+
if (this.isStale(record)) {
|
|
2681
|
+
record.resolve();
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
if (hasErrorCode(err, "E_OUT_OF_MEMORY")) {
|
|
2685
|
+
this.outOfMemory();
|
|
2686
|
+
}
|
|
2687
|
+
record.reject(asError(err));
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
if (this.isStale(record)) {
|
|
2691
|
+
this.finish(record);
|
|
2692
|
+
record.resolve();
|
|
2693
|
+
return;
|
|
2694
|
+
}
|
|
2695
|
+
let submitted;
|
|
2696
|
+
try {
|
|
2697
|
+
submitted = this.recordAndSubmit(record);
|
|
2698
|
+
} catch (err) {
|
|
2699
|
+
this.finish(record);
|
|
2700
|
+
record.reject(asError(err));
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
submitted.readback.then(
|
|
2704
|
+
(bytes) => {
|
|
2705
|
+
this.onLanded(record, bytes);
|
|
2706
|
+
},
|
|
2707
|
+
(err) => {
|
|
2708
|
+
this.onFailed(record, err);
|
|
2709
|
+
}
|
|
2710
|
+
);
|
|
2711
|
+
}
|
|
2712
|
+
/**
|
|
2713
|
+
* Flushes the host writes, fills k ring slots, records k iterations (only the last one runs the model's toScene
|
|
2714
|
+
* epilogue, PLAN DECISION 2) + the two readbacks into one CommandBatch and submits it (spec 7.19 items 4-5).
|
|
2715
|
+
* @param record - the pending batch
|
|
2716
|
+
* @returns the submitted batch
|
|
2717
|
+
*/
|
|
2718
|
+
recordAndSubmit(record) {
|
|
2719
|
+
const buffers = this.requireBuffers();
|
|
2720
|
+
const { k: k2 } = record;
|
|
2721
|
+
const { device } = this.ctx;
|
|
2722
|
+
if (this.fixedDirty) {
|
|
2723
|
+
device.queue.writeBuffer(buffers.fixed, 0, this.fixedWords);
|
|
2724
|
+
this.fixedDirty = false;
|
|
2725
|
+
}
|
|
2726
|
+
this.flushStateWrites(buffers.state);
|
|
2727
|
+
const first = this.ring.reserve(k2);
|
|
2728
|
+
for (let i = 0; i < k2; i++) {
|
|
2729
|
+
const flags = i === 0 && this.firstPending ? FA2_FLAG_FIRST : 0;
|
|
2730
|
+
this.ring.write(first + i, this.model.params, this.paramsForSlot(this.iterationsSubmitted + i, i, flags));
|
|
2731
|
+
}
|
|
2732
|
+
this.ring.flush();
|
|
2733
|
+
const batch = new CommandBatch(this.ctx, `${this.label}/batch`, this.generationValue);
|
|
2734
|
+
const beforeEpilogue = this.lastIterationStage();
|
|
2735
|
+
for (let i = 0; i < k2; i++) {
|
|
2736
|
+
this.model.recordIteration(batch, first + i, this.tierValue, i === k2 - 1 ? void 0 : beforeEpilogue);
|
|
2737
|
+
}
|
|
2738
|
+
batch.endPass();
|
|
2739
|
+
const scene = batch.readback(buffers.scene, 0, 12 * this.n);
|
|
2740
|
+
const state = batch.readback(buffers.state, 0, STATE_HEADER_BYTES + k2 * this.traceBytes);
|
|
2741
|
+
const { profiler } = this.ctx;
|
|
2742
|
+
const profile = profiler === null ? null : profiler.resolveInto(batch);
|
|
2743
|
+
const submitted = batch.submit();
|
|
2744
|
+
record.submitted = submitted;
|
|
2745
|
+
record.id = submitted.id;
|
|
2746
|
+
record.sceneOffset = scene.offset;
|
|
2747
|
+
record.stateOffset = state.offset;
|
|
2748
|
+
record.profile = profile;
|
|
2749
|
+
record.startedAt = performance.now();
|
|
2750
|
+
this.lastSubmittedBatchIdValue = submitted.id;
|
|
2751
|
+
this.iterationsSubmitted += k2;
|
|
2752
|
+
this.firstPending = false;
|
|
2753
|
+
return submitted;
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* A landed readback (spec 7.19 item 6): stale -> discard; else the scene bytes go into the owner's array row by
|
|
2757
|
+
* row (skipping overridden rows, clearing overrides older than this batch), the header refreshes the shadow,
|
|
2758
|
+
* the stats are decoded through the model (msPerIteration from the profiler or the wall time, PLAN DECISION 3),
|
|
2759
|
+
* iterationsDone += k and settled is recomputed -- with the landed settledCount taken as 0 when the batch was
|
|
2760
|
+
* submitted before the last reheat() (PLAN DECISION 21; the header is still absorbed and the stats decoded).
|
|
2761
|
+
* @param record - the pending batch
|
|
2762
|
+
* @param bytes - the batch's readback bytes
|
|
2763
|
+
*/
|
|
2764
|
+
onLanded(record, bytes) {
|
|
2765
|
+
this.finish(record);
|
|
2766
|
+
const { owner } = this;
|
|
2767
|
+
if (this.isStale(record) || owner === null) {
|
|
2768
|
+
record.resolve();
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
try {
|
|
2772
|
+
const { k: k2 } = record;
|
|
2773
|
+
const { n } = this;
|
|
2774
|
+
const needed = Math.max(
|
|
2775
|
+
record.sceneOffset + 12 * n,
|
|
2776
|
+
record.stateOffset + STATE_HEADER_BYTES + k2 * this.traceBytes
|
|
2777
|
+
);
|
|
2778
|
+
if (bytes.byteLength < needed) {
|
|
2779
|
+
record.resolve();
|
|
2780
|
+
return;
|
|
2781
|
+
}
|
|
2782
|
+
this.copyScene(owner, bytes, record);
|
|
2783
|
+
const header = new DataView(bytes, record.stateOffset, STATE_HEADER_BYTES);
|
|
2784
|
+
const trace = new DataView(bytes, record.stateOffset + STATE_HEADER_BYTES, k2 * this.traceBytes);
|
|
2785
|
+
this.writer.absorb(header);
|
|
2786
|
+
const settledCount = this.model.state.readField(header, "settledCount");
|
|
2787
|
+
if (typeof settledCount !== "number") {
|
|
2788
|
+
throw invalidArgument("settledCount", settledCount, "a scalar", "settledCount must be a scalar field");
|
|
2789
|
+
}
|
|
2790
|
+
const ms = this.batchMilliseconds(record, bytes);
|
|
2791
|
+
this.statsValue = withMs(this.model.readStats(header, trace), ms / k2);
|
|
2792
|
+
this.iterationsDoneValue += k2;
|
|
2793
|
+
this.settledCountValue = record.id <= this.reheatedAfterBatchId ? 0 : settledCount;
|
|
2794
|
+
this.settledValue = this.computeSettled();
|
|
2795
|
+
record.resolve();
|
|
2796
|
+
} catch (err) {
|
|
2797
|
+
record.reject(asError(err));
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
/**
|
|
2801
|
+
* A failed readback: a discarded batch's E_ABORTED resolves (PLAN DECISION 8); a lost device rejects
|
|
2802
|
+
* E_DEVICE_LOST; everything else (E_VALIDATION, E_DISPOSED) rejects as is.
|
|
2803
|
+
* @param record - the pending batch
|
|
2804
|
+
* @param err - the rejection
|
|
2805
|
+
*/
|
|
2806
|
+
onFailed(record, err) {
|
|
2807
|
+
this.finish(record);
|
|
2808
|
+
if (hasErrorCode(err, "E_ABORTED") && this.isStale(record)) {
|
|
2809
|
+
record.resolve();
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
if (this.ctx.state === "lost" && !hasErrorCode(err, "E_DEVICE_LOST")) {
|
|
2813
|
+
record.reject(
|
|
2814
|
+
new WebGpuGraphError("E_DEVICE_LOST", "the device was lost while the batch was in flight", {
|
|
2815
|
+
reason: "unknown",
|
|
2816
|
+
message: asError(err).message
|
|
2817
|
+
})
|
|
2818
|
+
);
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
2821
|
+
record.reject(asError(err));
|
|
2822
|
+
}
|
|
2823
|
+
/**
|
|
2824
|
+
* Copies the scene bytes of a landed batch into the owner's array, honouring the override list (spec 7.12).
|
|
2825
|
+
* @param owner - the owner's array
|
|
2826
|
+
* @param bytes - the readback
|
|
2827
|
+
* @param record - the batch
|
|
2828
|
+
*/
|
|
2829
|
+
copyScene(owner, bytes, record) {
|
|
2830
|
+
const { n } = this;
|
|
2831
|
+
const scene = new Float32Array(bytes, record.sceneOffset, 3 * n);
|
|
2832
|
+
if (this.overrideList.size === 0) {
|
|
2833
|
+
owner.set(scene);
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
for (let i = 0; i < n; i++) {
|
|
2837
|
+
const after = this.overrideList.get(i);
|
|
2838
|
+
if (after !== void 0 && after >= record.id) {
|
|
2839
|
+
continue;
|
|
2840
|
+
}
|
|
2841
|
+
owner[3 * i] = scene[3 * i];
|
|
2842
|
+
owner[3 * i + 1] = scene[3 * i + 1];
|
|
2843
|
+
owner[3 * i + 2] = scene[3 * i + 2];
|
|
2844
|
+
}
|
|
2845
|
+
for (const [i, after] of this.overrideList) {
|
|
2846
|
+
if (after < record.id) {
|
|
2847
|
+
this.overrideList.delete(i);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
/**
|
|
2852
|
+
* The batch's duration in milliseconds: the profiler's pass timings summed when present, else wall time.
|
|
2853
|
+
* @param record - the batch
|
|
2854
|
+
* @param bytes - its readback (the profiler's resolve lands in it)
|
|
2855
|
+
* @returns milliseconds
|
|
2856
|
+
*/
|
|
2857
|
+
batchMilliseconds(record, bytes) {
|
|
2858
|
+
const { profiler } = this.ctx;
|
|
2859
|
+
if (profiler !== null && record.profile !== null) {
|
|
2860
|
+
const timings = profiler.timings(bytes, record.profile);
|
|
2861
|
+
if (timings.length > 0) {
|
|
2862
|
+
let ns = 0;
|
|
2863
|
+
for (const timing of timings) {
|
|
2864
|
+
ns += timing.ns;
|
|
2865
|
+
}
|
|
2866
|
+
return ns / 1e6;
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
return performance.now() - record.startedAt;
|
|
2870
|
+
}
|
|
2871
|
+
/**
|
|
2872
|
+
* Whether a record belongs to an earlier generation, was discarded, or the simulation left "loaded".
|
|
2873
|
+
* @param record - the batch
|
|
2874
|
+
* @returns true when its readback must be ignored
|
|
2875
|
+
*/
|
|
2876
|
+
isStale(record) {
|
|
2877
|
+
return record.stale || this.stateValue !== "loaded" || record.generation !== this.generationValue;
|
|
2878
|
+
}
|
|
2879
|
+
/**
|
|
2880
|
+
* Removes a record from the pending list (idempotent).
|
|
2881
|
+
* @param record - the batch
|
|
2882
|
+
*/
|
|
2883
|
+
finish(record) {
|
|
2884
|
+
const at = this.pending.indexOf(record);
|
|
2885
|
+
if (at >= 0) {
|
|
2886
|
+
this.pending.splice(at, 1);
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
/** Marks every pending batch stale and discards the submitted ones (their readbacks resolve empty or E_ABORTED). */
|
|
2890
|
+
discardPending() {
|
|
2891
|
+
for (const record of this.pending) {
|
|
2892
|
+
record.stale = true;
|
|
2893
|
+
record.submitted?.discard();
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
/** An OOM surfaced by allocator.check(): every simulation buffer is destroyed and the state returns to "created". */
|
|
2897
|
+
outOfMemory() {
|
|
2898
|
+
this.discardPending();
|
|
2899
|
+
this.destroyBuffers();
|
|
2900
|
+
this.ctx.allocator.reset();
|
|
2901
|
+
this.settledValue = false;
|
|
2902
|
+
this.stateValue = "created";
|
|
2903
|
+
}
|
|
2904
|
+
/**
|
|
2905
|
+
* The E_ABORTED of run().
|
|
2906
|
+
* @returns the error
|
|
2907
|
+
*/
|
|
2908
|
+
abortedError() {
|
|
2909
|
+
return new WebGpuGraphError("E_ABORTED", `${this.label}: run() aborted by the signal`, {
|
|
2910
|
+
batchId: this.lastSubmittedBatchIdValue
|
|
2911
|
+
});
|
|
2912
|
+
}
|
|
2913
|
+
// ---------------------------------------------------------------- private: params, state writes, toScene
|
|
2914
|
+
/**
|
|
2915
|
+
* The uniform values of one iteration slot: the model's values with the shared fields on top (PLAN DECISION 6).
|
|
2916
|
+
* @param global - the global iteration index (iterationsSubmitted + i)
|
|
2917
|
+
* @param index - the slot index within the batch (the trace slot)
|
|
2918
|
+
* @param flags - FA2_FLAG_FIRST for the first iteration after load(), else 0
|
|
2919
|
+
* @returns the values
|
|
2920
|
+
*/
|
|
2921
|
+
paramsForSlot(global, index, flags) {
|
|
2922
|
+
const [cx, cy, cz] = this.center;
|
|
2923
|
+
const shared = {
|
|
2924
|
+
n: this.n,
|
|
2925
|
+
dim: this.dimValue,
|
|
2926
|
+
flags,
|
|
2927
|
+
iterationIndex: index,
|
|
2928
|
+
seed: this.seedU32(),
|
|
2929
|
+
scale: this.scale,
|
|
2930
|
+
center: [cx, cy, cz, 0],
|
|
2931
|
+
settleThreshold: this.settleThreshold()
|
|
2932
|
+
};
|
|
2933
|
+
return { ...this.model.paramsFor(global, this.optionsValue), ...shared };
|
|
2934
|
+
}
|
|
2935
|
+
/**
|
|
2936
|
+
* The option seed as a u32 (0 when unseeded), the near-field hash seed of P4.
|
|
2937
|
+
* @returns the seed
|
|
2938
|
+
*/
|
|
2939
|
+
seedU32() {
|
|
2940
|
+
const { seed } = this.optionsValue;
|
|
2941
|
+
if (seed === null || seed === void 0 || !Number.isFinite(seed)) {
|
|
2942
|
+
return 0;
|
|
2943
|
+
}
|
|
2944
|
+
return Math.floor(Math.abs(seed)) % 4294967296;
|
|
2945
|
+
}
|
|
2946
|
+
/**
|
|
2947
|
+
* Writes the queued state fields, each as one writeBuffer of its own byte range (PLAN DECISION 5).
|
|
2948
|
+
* @param state - the state buffer
|
|
2949
|
+
*/
|
|
2950
|
+
flushStateWrites(state) {
|
|
2951
|
+
const fields = this.writer.takeQueued();
|
|
2952
|
+
if (fields.length === 0) {
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
const bytes = this.writer.headerBytes();
|
|
2956
|
+
const block = this.model.state;
|
|
2957
|
+
for (const field of fields) {
|
|
2958
|
+
const offset = block.offsetOf(field);
|
|
2959
|
+
const size = 4 * fieldWidth(fieldTypeOf(block, field));
|
|
2960
|
+
this.ctx.device.queue.writeBuffer(state, offset, bytes, offset, size);
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
/**
|
|
2964
|
+
* The stage that precedes the toScene epilogue in model.stages (the `upTo` of every iteration but the last), or
|
|
2965
|
+
* undefined when the model has no epilogue or lists it first (every iteration then records every stage).
|
|
2966
|
+
* @returns the stage name or undefined
|
|
2967
|
+
*/
|
|
2968
|
+
lastIterationStage() {
|
|
2969
|
+
const at = this.model.stages.indexOf(EPILOGUE_STAGE);
|
|
2970
|
+
if (at <= 0) {
|
|
2971
|
+
return void 0;
|
|
2972
|
+
}
|
|
2973
|
+
return this.model.stages[at - 1];
|
|
2974
|
+
}
|
|
2975
|
+
/**
|
|
2976
|
+
* The settle threshold of the current options (FA2_DEFAULTS when absent).
|
|
2977
|
+
* @returns the threshold
|
|
2978
|
+
*/
|
|
2979
|
+
settleThreshold() {
|
|
2980
|
+
return this.optionsValue.settleThreshold ?? FA2_DEFAULTS.settleThreshold;
|
|
2981
|
+
}
|
|
2982
|
+
/**
|
|
2983
|
+
* The settle window of the current options (FA2_DEFAULTS when absent).
|
|
2984
|
+
* @returns the window
|
|
2985
|
+
*/
|
|
2986
|
+
settleWindow() {
|
|
2987
|
+
return this.optionsValue.settleWindow ?? FA2_DEFAULTS.settleWindow;
|
|
2988
|
+
}
|
|
2989
|
+
/**
|
|
2990
|
+
* The iteration budget (PLAN DECISION 1): options.maxIter, else options.iterations, else no budget.
|
|
2991
|
+
* @returns the budget
|
|
2992
|
+
*/
|
|
2993
|
+
iterationBudget() {
|
|
2994
|
+
const maxIter = optionField(this.optionsValue, "maxIter");
|
|
2995
|
+
if (typeof maxIter === "number") {
|
|
2996
|
+
return maxIter;
|
|
2997
|
+
}
|
|
2998
|
+
const iterations = optionField(this.optionsValue, "iterations");
|
|
2999
|
+
if (typeof iterations === "number") {
|
|
3000
|
+
return iterations;
|
|
3001
|
+
}
|
|
3002
|
+
return Number.POSITIVE_INFINITY;
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* The settle rule of spec 7.17 over the counters of the last completed batch.
|
|
3006
|
+
* @returns whether the layout is settled
|
|
3007
|
+
*/
|
|
3008
|
+
computeSettled() {
|
|
3009
|
+
if (this.stateValue !== "loaded") {
|
|
3010
|
+
return false;
|
|
3011
|
+
}
|
|
3012
|
+
return this.n === 0 || this.iterationsDoneValue >= this.iterationBudget() || this.settledCountValue >= this.settleWindow();
|
|
3013
|
+
}
|
|
3014
|
+
// ---------------------------------------------------------------- private: buffers, upload, bind
|
|
3015
|
+
/**
|
|
3016
|
+
* Allocates the shared buffers (spec 7.3 sizes) and the model's BufferSpecs through the allocator (every buffer
|
|
3017
|
+
* labelled); a `zero: true` spec must carry COPY_DST so it can be re-zeroed when kept across loads (PLAN
|
|
3018
|
+
* DECISION 17). The state buffer is STATE_HEADER_BYTES + MAX_ITERATIONS_PER_STEP x model.trace.byteLength:
|
|
3019
|
+
* contract 3.13 writes TRACE_RECORD_BYTES (32, the FA2 record) for the last factor; the model's block is the
|
|
3020
|
+
* same number for FA2 and is what the simulation decodes the trace with, so it is the general rule (PLAN
|
|
3021
|
+
* DECISION 4) and the constant is not imported here.
|
|
3022
|
+
* @param n - the node count (> 0)
|
|
3023
|
+
* @returns the buffers and the name -> Binding map
|
|
3024
|
+
*/
|
|
3025
|
+
allocate(n) {
|
|
3026
|
+
const { allocator } = this.ctx;
|
|
3027
|
+
const { label } = this;
|
|
3028
|
+
const groups = Math.ceil(n / this.ctx.workgroupSize);
|
|
3029
|
+
const traceRegionBytes = MAX_ITERATIONS_PER_STEP * this.traceBytes;
|
|
3030
|
+
const storageRw = BufferUsage.STORAGE | BufferUsage.COPY_SRC | BufferUsage.COPY_DST;
|
|
3031
|
+
const positions = allocator.createBuffer({ label: `${label}/positions`, size: 16 * n, usage: storageRw });
|
|
3032
|
+
const scene = allocator.createBuffer({
|
|
3033
|
+
label: `${label}/scenePositions`,
|
|
3034
|
+
size: 12 * n,
|
|
3035
|
+
usage: BufferUsage.STORAGE | BufferUsage.COPY_SRC
|
|
3036
|
+
});
|
|
3037
|
+
const fixed = allocator.createBuffer({
|
|
3038
|
+
label: `${label}/fixed`,
|
|
3039
|
+
size: 4 * Math.ceil(n / 32),
|
|
3040
|
+
usage: storageRw
|
|
3041
|
+
});
|
|
3042
|
+
const partials = allocator.createBuffer({
|
|
3043
|
+
label: `${label}/partials`,
|
|
3044
|
+
size: PARTIAL_BYTES * groups,
|
|
3045
|
+
usage: storageRw
|
|
3046
|
+
});
|
|
3047
|
+
const state = allocator.createBuffer({
|
|
3048
|
+
label: `${label}/state`,
|
|
3049
|
+
size: STATE_HEADER_BYTES + traceRegionBytes,
|
|
3050
|
+
usage: storageRw
|
|
3051
|
+
});
|
|
3052
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
3053
|
+
bindings.set("positions", wholeBinding(positions, 16 * n));
|
|
3054
|
+
bindings.set("scenePositions", wholeBinding(scene, 12 * n));
|
|
3055
|
+
bindings.set("fixed", wholeBinding(fixed, 4 * Math.ceil(n / 32)));
|
|
3056
|
+
bindings.set("partials", wholeBinding(partials, PARTIAL_BYTES * groups));
|
|
3057
|
+
bindings.set("state", { buffer: state, offset: 0, size: STATE_HEADER_BYTES, window: null });
|
|
3058
|
+
bindings.set("trace", { buffer: state, offset: STATE_HEADER_BYTES, size: traceRegionBytes, window: null });
|
|
3059
|
+
const model = /* @__PURE__ */ new Map();
|
|
3060
|
+
for (const spec of this.model.buffers(n, this.dimValue)) {
|
|
3061
|
+
if (SHARED_BUFFER_NAMES.includes(spec.name) || bindings.has(spec.name)) {
|
|
3062
|
+
throw invalidArgument(
|
|
3063
|
+
"BufferSpec.name",
|
|
3064
|
+
spec.name,
|
|
3065
|
+
"a name no other buffer uses",
|
|
3066
|
+
`BufferSpec "${spec.name}" reuses a buffer name`
|
|
3067
|
+
);
|
|
3068
|
+
}
|
|
3069
|
+
if (!Number.isInteger(spec.byteLength) || spec.byteLength <= 0 || spec.byteLength % 4 !== 0) {
|
|
3070
|
+
throw invalidArgument(
|
|
3071
|
+
"BufferSpec.byteLength",
|
|
3072
|
+
spec.byteLength,
|
|
3073
|
+
"a positive multiple of 4",
|
|
3074
|
+
`BufferSpec "${spec.name}" has byteLength ${spec.byteLength}`
|
|
3075
|
+
);
|
|
3076
|
+
}
|
|
3077
|
+
if (spec.zero && (spec.usage & BufferUsage.COPY_DST) === 0) {
|
|
3078
|
+
throw invalidArgument(
|
|
3079
|
+
"BufferSpec.usage",
|
|
3080
|
+
spec.usage,
|
|
3081
|
+
"COPY_DST on a zero: true spec",
|
|
3082
|
+
`BufferSpec "${spec.name}" is zero: true but lacks COPY_DST`
|
|
3083
|
+
);
|
|
3084
|
+
}
|
|
3085
|
+
const buffer = allocator.createBuffer({
|
|
3086
|
+
label: `${label}/${spec.name}`,
|
|
3087
|
+
size: spec.byteLength,
|
|
3088
|
+
usage: spec.usage
|
|
3089
|
+
});
|
|
3090
|
+
model.set(spec.name, { buffer, spec });
|
|
3091
|
+
bindings.set(spec.name, wholeBinding(buffer, spec.byteLength));
|
|
3092
|
+
}
|
|
3093
|
+
return { positions, scene, fixed, partials, state, model, bindings, traceRegionBytes };
|
|
3094
|
+
}
|
|
3095
|
+
/**
|
|
3096
|
+
* Re-zeroes the buffers a same-size load() keeps: partials, the trace region and every `zero: true` model buffer.
|
|
3097
|
+
* @param buffers - the kept buffers
|
|
3098
|
+
*/
|
|
3099
|
+
clearKept(buffers) {
|
|
3100
|
+
const { device } = this.ctx;
|
|
3101
|
+
const encoder = device.createCommandEncoder({ label: `${this.label}/clear` });
|
|
3102
|
+
encoder.clearBuffer(buffers.partials);
|
|
3103
|
+
encoder.clearBuffer(buffers.state, STATE_HEADER_BYTES, buffers.traceRegionBytes);
|
|
3104
|
+
for (const { buffer, spec } of buffers.model.values()) {
|
|
3105
|
+
if (spec.zero) {
|
|
3106
|
+
encoder.clearBuffer(buffer);
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
device.queue.submit([encoder.finish()]);
|
|
3110
|
+
}
|
|
3111
|
+
/**
|
|
3112
|
+
* Destroys every simulation buffer through the allocator (the ring lives on until dispose()). While batches are
|
|
3113
|
+
* still in flight the destruction is DEFERRED until their readbacks have settled: the GPU may still be copying
|
|
3114
|
+
* out of `scene` / `state` and Dawn's Metal backend (dawn-node 0.4.0 on macOS) has taken the worker process
|
|
3115
|
+
* down when a buffer with pending work was destroyed (the Vulkan backends defer internally). The simulation
|
|
3116
|
+
* drops its references at once either way, so nothing here is reachable afterwards.
|
|
3117
|
+
*/
|
|
3118
|
+
destroyBuffers() {
|
|
3119
|
+
const { buffers } = this;
|
|
3120
|
+
if (buffers === null) {
|
|
3121
|
+
return;
|
|
3122
|
+
}
|
|
3123
|
+
this.buffers = null;
|
|
3124
|
+
this.resources = null;
|
|
3125
|
+
const doomed = [buffers.positions, buffers.scene, buffers.fixed, buffers.partials, buffers.state];
|
|
3126
|
+
for (const { buffer } of buffers.model.values()) {
|
|
3127
|
+
doomed.push(buffer);
|
|
3128
|
+
}
|
|
3129
|
+
this.afterInFlight(() => {
|
|
3130
|
+
const { allocator } = this.ctx;
|
|
3131
|
+
for (const buffer of doomed) {
|
|
3132
|
+
allocator.destroy(buffer);
|
|
3133
|
+
}
|
|
3134
|
+
});
|
|
3135
|
+
}
|
|
3136
|
+
/**
|
|
3137
|
+
* Runs `action` now when no submitted batch is in flight, otherwise once every in-flight readback has settled
|
|
3138
|
+
* (resolved, discarded or rejected -- the staging slot is returned and the GPU work is done either way).
|
|
3139
|
+
* @param action - the destruction to run
|
|
3140
|
+
*/
|
|
3141
|
+
afterInFlight(action) {
|
|
3142
|
+
const waits = [];
|
|
3143
|
+
for (const record of this.pending) {
|
|
3144
|
+
if (record.submitted !== null) {
|
|
3145
|
+
waits.push(record.submitted.readback.catch(() => void 0));
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
if (waits.length === 0) {
|
|
3149
|
+
action();
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
void Promise.all(waits).then(action, action);
|
|
3153
|
+
}
|
|
3154
|
+
/**
|
|
3155
|
+
* The buffers of the current load.
|
|
3156
|
+
* @returns the buffers; E_NOT_LOADED when there are none
|
|
3157
|
+
*/
|
|
3158
|
+
requireBuffers() {
|
|
3159
|
+
const { buffers } = this;
|
|
3160
|
+
if (buffers === null) {
|
|
3161
|
+
throw new WebGpuGraphError("E_NOT_LOADED", `${this.label}: load() first`, { state: this.stateValue });
|
|
3162
|
+
}
|
|
3163
|
+
return buffers;
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* Repacks the seeded scene array into layout-unit vec4f (mass in .w, z = 0 in 2D), uploads it, and writes the
|
|
3167
|
+
* initial centroid / bbox / rmsRadius / radius (f64 over the f32 values uploaded) with iteration 0,
|
|
3168
|
+
* settledCount 0, meanDisplacement 0 into the state header, then model.onLoad(writer) and one whole-header
|
|
3169
|
+
* writeBuffer (spec 7.4, 7.17, 7.18).
|
|
3170
|
+
* @param buffers - the buffers
|
|
3171
|
+
* @param positions - the seeded owner array
|
|
3172
|
+
* @param mass - the resolved masses
|
|
3173
|
+
*/
|
|
3174
|
+
uploadPositions(buffers, positions, mass) {
|
|
3175
|
+
const { n, scale } = this;
|
|
3176
|
+
const [cx, cy, cz] = this.center;
|
|
3177
|
+
const packed = new Float32Array(4 * n);
|
|
3178
|
+
for (let i = 0; i < n; i++) {
|
|
3179
|
+
packed[4 * i] = (positions[3 * i] - cx) / scale;
|
|
3180
|
+
packed[4 * i + 1] = (positions[3 * i + 1] - cy) / scale;
|
|
3181
|
+
packed[4 * i + 2] = this.dimValue === 2 ? 0 : (positions[3 * i + 2] - cz) / scale;
|
|
3182
|
+
packed[4 * i + 3] = mass[i];
|
|
3183
|
+
}
|
|
3184
|
+
this.ctx.device.queue.writeBuffer(buffers.positions, 0, packed);
|
|
3185
|
+
let sx = 0;
|
|
3186
|
+
let sy = 0;
|
|
3187
|
+
let sz = 0;
|
|
3188
|
+
const lo = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
|
|
3189
|
+
const hi = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
|
|
3190
|
+
for (let i = 0; i < n; i++) {
|
|
3191
|
+
const x = packed[4 * i];
|
|
3192
|
+
const y = packed[4 * i + 1];
|
|
3193
|
+
const z = packed[4 * i + 2];
|
|
3194
|
+
sx += x;
|
|
3195
|
+
sy += y;
|
|
3196
|
+
sz += z;
|
|
3197
|
+
lo[0] = Math.min(lo[0], x);
|
|
3198
|
+
lo[1] = Math.min(lo[1], y);
|
|
3199
|
+
lo[2] = Math.min(lo[2], z);
|
|
3200
|
+
hi[0] = Math.max(hi[0], x);
|
|
3201
|
+
hi[1] = Math.max(hi[1], y);
|
|
3202
|
+
hi[2] = Math.max(hi[2], z);
|
|
3203
|
+
}
|
|
3204
|
+
const mx = sx / n;
|
|
3205
|
+
const my = sy / n;
|
|
3206
|
+
const mz = sz / n;
|
|
3207
|
+
let sumSq = 0;
|
|
3208
|
+
let maxSq = 0;
|
|
3209
|
+
for (let i = 0; i < n; i++) {
|
|
3210
|
+
const dx = packed[4 * i] - mx;
|
|
3211
|
+
const dy = packed[4 * i + 1] - my;
|
|
3212
|
+
const dz = packed[4 * i + 2] - mz;
|
|
3213
|
+
const q = dx * dx + dy * dy + dz * dz;
|
|
3214
|
+
sumSq += q;
|
|
3215
|
+
maxSq = Math.max(maxSq, q);
|
|
3216
|
+
}
|
|
3217
|
+
const { writer } = this;
|
|
3218
|
+
writer.set("iteration", 0);
|
|
3219
|
+
writer.set("settledCount", 0);
|
|
3220
|
+
writer.set("meanDisplacement", 0);
|
|
3221
|
+
writer.set("centroid", [mx, my, mz, 0]);
|
|
3222
|
+
writer.set("min", [lo[0], lo[1], lo[2], 0]);
|
|
3223
|
+
writer.set("max", [hi[0], hi[1], hi[2], 0]);
|
|
3224
|
+
writer.set("rmsRadius", Math.sqrt(sumSq / n));
|
|
3225
|
+
writer.set("radius", Math.sqrt(maxSq));
|
|
3226
|
+
this.model.onLoad(writer);
|
|
3227
|
+
this.ctx.device.queue.writeBuffer(buffers.state, 0, writer.headerBytes());
|
|
3228
|
+
writer.clearQueued();
|
|
3229
|
+
}
|
|
3230
|
+
/**
|
|
3231
|
+
* The weights binding of a load (3.10): "arcs" -> core.weights, "none" -> null, "column" -> the expanded array
|
|
3232
|
+
* registered with residency.array(expanded, "weights", snapshot), the previous array object reused when the
|
|
3233
|
+
* column and its version are unchanged (so the upload cache finds it).
|
|
3234
|
+
* @param snapshot - the snapshot
|
|
3235
|
+
* @param core - its core
|
|
3236
|
+
* @param resolved - model.inputs().weights
|
|
3237
|
+
* @returns the binding for the group-0 weights slot, or null
|
|
3238
|
+
*/
|
|
3239
|
+
resolveWeightsBinding(snapshot, core, resolved) {
|
|
3240
|
+
if (resolved.source === "arcs") {
|
|
3241
|
+
this.weightsUpload = null;
|
|
3242
|
+
return core.weights;
|
|
3243
|
+
}
|
|
3244
|
+
if (resolved.source === "none" || resolved.data === null || resolved.column === null) {
|
|
3245
|
+
this.weightsUpload = null;
|
|
3246
|
+
return null;
|
|
3247
|
+
}
|
|
3248
|
+
const previous = this.weightsUpload;
|
|
3249
|
+
const reusable = previous !== null && previous.serial === snapshot.serial && previous.column === resolved.column && previous.version === resolved.column.version && previous.data.length === resolved.data.length;
|
|
3250
|
+
const data = reusable && previous !== null ? previous.data : resolved.data;
|
|
3251
|
+
const upload = this.ctx.residency.array(data, "weights", snapshot);
|
|
3252
|
+
this.weightsUpload = {
|
|
3253
|
+
serial: snapshot.serial,
|
|
3254
|
+
column: resolved.column,
|
|
3255
|
+
version: resolved.column.version,
|
|
3256
|
+
data,
|
|
3257
|
+
upload
|
|
3258
|
+
};
|
|
3259
|
+
return upload.binding;
|
|
3260
|
+
}
|
|
3261
|
+
/**
|
|
3262
|
+
* The ModelResources of a load.
|
|
3263
|
+
* @param core - the core
|
|
3264
|
+
* @param weights - the resolved weights binding
|
|
3265
|
+
* @param buffers - the buffers
|
|
3266
|
+
* @returns the resources
|
|
3267
|
+
*/
|
|
3268
|
+
makeResources(core, weights, buffers) {
|
|
3269
|
+
const { bindings } = buffers;
|
|
3270
|
+
return {
|
|
3271
|
+
device: this.ctx.device,
|
|
3272
|
+
caps: this.ctx.caps,
|
|
3273
|
+
pipelines: this.ctx.pipelines,
|
|
3274
|
+
core,
|
|
3275
|
+
perm: null,
|
|
3276
|
+
weights,
|
|
3277
|
+
n: this.n,
|
|
3278
|
+
dim: this.dimValue,
|
|
3279
|
+
tier: this.tierValue,
|
|
3280
|
+
ring: this.ring,
|
|
3281
|
+
buffer: (name) => {
|
|
3282
|
+
const binding = bindings.get(name);
|
|
3283
|
+
if (binding === void 0) {
|
|
3284
|
+
throw invalidArgument("name", name, [...bindings.keys()], `no simulation buffer named "${name}"`);
|
|
3285
|
+
}
|
|
3286
|
+
return binding;
|
|
3287
|
+
}
|
|
3288
|
+
};
|
|
3289
|
+
}
|
|
3290
|
+
/**
|
|
3291
|
+
* Chains the compile + bind promise the next step() awaits on the PREVIOUS one (PLAN DECISION 20: binds are
|
|
3292
|
+
* serialised on `ready` for load() and setParams() alike, so a superseded load()'s bind either fails its
|
|
3293
|
+
* generation check before model.bind() or completes before its successor's bind starts -- it can never bind
|
|
3294
|
+
* the model to destroyed buffers AFTER the new bind finished). The previous promise's rejection is swallowed by
|
|
3295
|
+
* the chain (it belonged to the superseded generation); this promise's rejection is kept for the next step()
|
|
3296
|
+
* (the derived catch only silences the unhandled-rejection warning).
|
|
3297
|
+
* @param resources - the resources
|
|
3298
|
+
* @param overrides - the merged override set
|
|
3299
|
+
*/
|
|
3300
|
+
startBind(resources, overrides) {
|
|
3301
|
+
const generation = this.generationValue;
|
|
3302
|
+
const run = () => this.compileAndBind(resources, overrides, generation);
|
|
3303
|
+
const promise = this.ready.catch(() => void 0).then(run);
|
|
3304
|
+
void promise.catch(() => void 0);
|
|
3305
|
+
this.ready = promise;
|
|
3306
|
+
}
|
|
3307
|
+
/**
|
|
3308
|
+
* warm(model.specs()) then model.bind(); the await re-checks that the load is current before binding.
|
|
3309
|
+
* @param resources - the resources
|
|
3310
|
+
* @param overrides - the merged override set
|
|
3311
|
+
* @param generation - the generation the bind belongs to
|
|
3312
|
+
*/
|
|
3313
|
+
async compileAndBind(resources, overrides, generation) {
|
|
3314
|
+
const subgroups = this.ctx.caps.features.has("subgroups");
|
|
3315
|
+
await this.ctx.pipelines.warm(this.model.specs(overrides, subgroups));
|
|
3316
|
+
if (this.stateValue !== "loaded" || generation !== this.generationValue) {
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3319
|
+
await this.model.bind(resources, overrides);
|
|
3320
|
+
}
|
|
3321
|
+
// ---------------------------------------------------------------- private: inspect and debug runs
|
|
3322
|
+
/**
|
|
3323
|
+
* inspect(name): flush, read the named buffer back, Uint32Array for "fixed" and "trace", Float32Array otherwise.
|
|
3324
|
+
* @param name - a shared or BufferSpec name
|
|
3325
|
+
* @returns the words
|
|
3326
|
+
*/
|
|
3327
|
+
async inspectBuffer(name) {
|
|
3328
|
+
this.assertLoaded();
|
|
3329
|
+
const { buffers } = this;
|
|
3330
|
+
const binding = buffers?.bindings.get(name);
|
|
3331
|
+
if (buffers === null || binding === void 0) {
|
|
3332
|
+
throw invalidArgument(
|
|
3333
|
+
"name",
|
|
3334
|
+
name,
|
|
3335
|
+
buffers === null ? [] : [...buffers.bindings.keys()],
|
|
3336
|
+
`inspect("${name}"): no such buffer`
|
|
3337
|
+
);
|
|
3338
|
+
}
|
|
3339
|
+
await this.flush();
|
|
3340
|
+
const bytes = await this.ctx.readback.read(binding.buffer, binding.size, void 0, binding.offset);
|
|
3341
|
+
return name === "fixed" || name === "trace" ? new Uint32Array(bytes) : new Float32Array(bytes);
|
|
3342
|
+
}
|
|
3343
|
+
/**
|
|
3344
|
+
* debugRunStages(upTo): one iteration truncated after a stage, submitted alone after flush(), awaited through
|
|
3345
|
+
* a header readback; never advances the counters or the FA2_FLAG_FIRST flag (PLAN DECISION 9).
|
|
3346
|
+
* @param upTo - a model stage name
|
|
3347
|
+
*/
|
|
3348
|
+
async runStages(upTo) {
|
|
3349
|
+
this.assertLoaded();
|
|
3350
|
+
if (!this.model.stages.includes(upTo)) {
|
|
3351
|
+
throw invalidArgument(
|
|
3352
|
+
"upTo",
|
|
3353
|
+
upTo,
|
|
3354
|
+
[...this.model.stages],
|
|
3355
|
+
`debugRunStages("${upTo}"): not a stage of ${this.model.kind}`
|
|
3356
|
+
);
|
|
3357
|
+
}
|
|
3358
|
+
if (this.n === 0) {
|
|
3359
|
+
return;
|
|
3360
|
+
}
|
|
3361
|
+
await this.flush();
|
|
3362
|
+
await this.ready;
|
|
3363
|
+
await this.ctx.allocator.check();
|
|
3364
|
+
this.assertLoaded();
|
|
3365
|
+
const buffers = this.requireBuffers();
|
|
3366
|
+
const { device } = this.ctx;
|
|
3367
|
+
if (this.fixedDirty) {
|
|
3368
|
+
device.queue.writeBuffer(buffers.fixed, 0, this.fixedWords);
|
|
3369
|
+
this.fixedDirty = false;
|
|
3370
|
+
}
|
|
3371
|
+
this.flushStateWrites(buffers.state);
|
|
3372
|
+
const slot = this.ring.reserve(1);
|
|
3373
|
+
this.ring.write(
|
|
3374
|
+
slot,
|
|
3375
|
+
this.model.params,
|
|
3376
|
+
this.paramsForSlot(this.iterationsSubmitted, 0, this.firstPending ? FA2_FLAG_FIRST : 0)
|
|
3377
|
+
);
|
|
3378
|
+
this.ring.flush();
|
|
3379
|
+
const batch = new CommandBatch(this.ctx, `${this.label}/debug`, this.generationValue);
|
|
3380
|
+
this.model.recordIteration(batch, slot, this.tierValue, upTo);
|
|
3381
|
+
batch.endPass();
|
|
3382
|
+
batch.readback(buffers.state, 0, STATE_HEADER_BYTES);
|
|
3383
|
+
const submitted = batch.submit();
|
|
3384
|
+
this.lastSubmittedBatchIdValue = submitted.id;
|
|
3385
|
+
await submitted.readback;
|
|
3386
|
+
}
|
|
3387
|
+
// ---------------------------------------------------------------- private: lifecycle
|
|
3388
|
+
/**
|
|
3389
|
+
* Device loss (spec 5.7): every pending promise rejects E_DEVICE_LOST, the simulation is disposed.
|
|
3390
|
+
* @param info - the loss info
|
|
3391
|
+
*/
|
|
3392
|
+
onDeviceLost(info) {
|
|
3393
|
+
if (this.stateValue === "disposed") {
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
const error = new WebGpuGraphError("E_DEVICE_LOST", `device lost (${info.reason}): ${info.message}`, {
|
|
3397
|
+
reason: info.reason,
|
|
3398
|
+
message: info.message
|
|
3399
|
+
});
|
|
3400
|
+
const pending = this.pending.splice(0);
|
|
3401
|
+
this.stateValue = "disposed";
|
|
3402
|
+
for (const record of pending) {
|
|
3403
|
+
record.stale = true;
|
|
3404
|
+
record.reject(error);
|
|
3405
|
+
}
|
|
3406
|
+
this.teardown();
|
|
3407
|
+
}
|
|
3408
|
+
/** Releases everything once: pending batches discarded, buffers and ring destroyed, pool trimmed, listener unregistered. */
|
|
3409
|
+
teardown() {
|
|
3410
|
+
if (this.torndown) {
|
|
3411
|
+
return;
|
|
3412
|
+
}
|
|
3413
|
+
this.torndown = true;
|
|
3414
|
+
this.discardPending();
|
|
3415
|
+
this.destroyBuffers();
|
|
3416
|
+
this.afterInFlight(() => {
|
|
3417
|
+
this.ring.destroy();
|
|
3418
|
+
});
|
|
3419
|
+
if (this.ctx.state === "ready") {
|
|
3420
|
+
this.ctx.pool.trim();
|
|
3421
|
+
}
|
|
3422
|
+
this.unregisterLost();
|
|
3423
|
+
this.core = null;
|
|
3424
|
+
this.snapshot = null;
|
|
3425
|
+
this.owner = null;
|
|
3426
|
+
this.weightsUpload = null;
|
|
3427
|
+
}
|
|
3428
|
+
/** E_DISPOSED after dispose() or device loss. */
|
|
3429
|
+
assertNotDisposed() {
|
|
3430
|
+
if (this.stateValue === "disposed") {
|
|
3431
|
+
throw disposedError(this.label);
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
/** E_NOT_LOADED before load(), E_DISPOSED after dispose(). */
|
|
3435
|
+
assertLoaded() {
|
|
3436
|
+
if (this.stateValue === "created") {
|
|
3437
|
+
throw new WebGpuGraphError("E_NOT_LOADED", `${this.label}: load() first`, { state: "created" });
|
|
3438
|
+
}
|
|
3439
|
+
this.assertNotDisposed();
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
const NODE_MASS_HINT = "write a role 'mass' node column (nodes.set(name, vec, { role: 'mass', replaceRole: true })) or pass a Float32Array; @graphty/layout's resolveNodeVector does this on the CPU path";
|
|
3443
|
+
function invalid$1(argument, value, expected, message) {
|
|
3444
|
+
return new WebGpuGraphError("E_INVALID_ARGUMENT", message, { argument, value, expected });
|
|
3445
|
+
}
|
|
3446
|
+
function checkMass(mass, source) {
|
|
3447
|
+
for (let i = 0; i < mass.length; i++) {
|
|
3448
|
+
const v = mass[i];
|
|
3449
|
+
if (!Number.isFinite(v) || v <= 0) {
|
|
3450
|
+
throw new WebGpuGraphError(
|
|
3451
|
+
"E_INVALID_ARGUMENT",
|
|
3452
|
+
`${source}: mass[${i}] = ${v} is not a finite number > 0`,
|
|
3453
|
+
{
|
|
3454
|
+
argument: "nodeMass",
|
|
3455
|
+
value: v,
|
|
3456
|
+
index: i,
|
|
3457
|
+
expected: "finite masses > 0"
|
|
3458
|
+
}
|
|
3459
|
+
);
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
function degreePlusOne(s) {
|
|
3464
|
+
const n = s.nodeCount;
|
|
3465
|
+
const degree2 = s.outDegree();
|
|
3466
|
+
const out = new Float32Array(n);
|
|
3467
|
+
for (let i = 0; i < n; i++) {
|
|
3468
|
+
out[i] = degree2[i] + 1;
|
|
3469
|
+
}
|
|
3470
|
+
return out;
|
|
3471
|
+
}
|
|
3472
|
+
function massFromColumn(s, column) {
|
|
3473
|
+
const { name } = column.meta;
|
|
3474
|
+
if (column.meta.components !== 1) {
|
|
3475
|
+
throw invalid$1(
|
|
3476
|
+
"nodeMass",
|
|
3477
|
+
name,
|
|
3478
|
+
"a one-component numeric node column",
|
|
3479
|
+
`node column "${name}" has ${column.meta.components} components; a mass column has one`
|
|
3480
|
+
);
|
|
3481
|
+
}
|
|
3482
|
+
let values;
|
|
3483
|
+
switch (column.dtype) {
|
|
3484
|
+
case "f32":
|
|
3485
|
+
case "f64": {
|
|
3486
|
+
const view = s.nodes.gpuView(name);
|
|
3487
|
+
const out2 = view instanceof Float32Array ? view : new Float32Array(view);
|
|
3488
|
+
checkMass(out2, `node column "${name}"`);
|
|
3489
|
+
return out2;
|
|
3490
|
+
}
|
|
3491
|
+
case "u32":
|
|
3492
|
+
case "i32":
|
|
3493
|
+
values = s.nodes.gpuView(name);
|
|
3494
|
+
break;
|
|
3495
|
+
case "u8":
|
|
3496
|
+
values = column.data;
|
|
3497
|
+
break;
|
|
3498
|
+
default:
|
|
3499
|
+
throw invalid$1(
|
|
3500
|
+
"nodeMass",
|
|
3501
|
+
name,
|
|
3502
|
+
"a numeric node column (f32, f64, u32, i32, u8)",
|
|
3503
|
+
`node column "${name}" is ${column.dtype}, not numeric`
|
|
3504
|
+
);
|
|
3505
|
+
}
|
|
3506
|
+
const out = new Float32Array(s.nodeCount);
|
|
3507
|
+
for (let i = 0; i < out.length; i++) {
|
|
3508
|
+
out[i] = values[i];
|
|
3509
|
+
}
|
|
3510
|
+
checkMass(out, `node column "${name}"`);
|
|
3511
|
+
return out;
|
|
3512
|
+
}
|
|
3513
|
+
function resolveNodeMass(s, spec) {
|
|
3514
|
+
const n = s.nodeCount;
|
|
3515
|
+
if (spec === null || spec === void 0) {
|
|
3516
|
+
const column = s.nodes.byRole("mass");
|
|
3517
|
+
if (column === null) {
|
|
3518
|
+
return degreePlusOne(s);
|
|
3519
|
+
}
|
|
3520
|
+
return massFromColumn(s, column);
|
|
3521
|
+
}
|
|
3522
|
+
if (spec instanceof Float32Array) {
|
|
3523
|
+
if (spec.length !== n) {
|
|
3524
|
+
throw invalid$1("nodeMass", spec.length, `${n} values`, `nodeMass has ${spec.length} values, expected ${n}`);
|
|
3525
|
+
}
|
|
3526
|
+
checkMass(spec, "nodeMass");
|
|
3527
|
+
return spec;
|
|
3528
|
+
}
|
|
3529
|
+
if (typeof spec === "string") {
|
|
3530
|
+
const column = s.nodes.get(spec);
|
|
3531
|
+
if (column === null) {
|
|
3532
|
+
throw invalid$1(
|
|
3533
|
+
"nodeMass",
|
|
3534
|
+
spec,
|
|
3535
|
+
"an existing numeric node column",
|
|
3536
|
+
`nodeMass names node column "${spec}", which the snapshot does not hold`
|
|
3537
|
+
);
|
|
3538
|
+
}
|
|
3539
|
+
return massFromColumn(s, column);
|
|
3540
|
+
}
|
|
3541
|
+
if (ArrayBuffer.isView(spec)) {
|
|
3542
|
+
throw invalid$1(
|
|
3543
|
+
"nodeMass",
|
|
3544
|
+
"typed array",
|
|
3545
|
+
"a Float32Array",
|
|
3546
|
+
"nodeMass must be a Float32Array, a column name or null"
|
|
3547
|
+
);
|
|
3548
|
+
}
|
|
3549
|
+
throw new WebGpuGraphError(
|
|
3550
|
+
"E_UNSUPPORTED",
|
|
3551
|
+
`nodeMass as a Record is not supported on the GPU path: ${NODE_MASS_HINT}`,
|
|
3552
|
+
{
|
|
3553
|
+
option: "nodeMass",
|
|
3554
|
+
hint: NODE_MASS_HINT
|
|
3555
|
+
}
|
|
3556
|
+
);
|
|
3557
|
+
}
|
|
3558
|
+
function resolveWeights(s, spec) {
|
|
3559
|
+
if (spec === true) {
|
|
3560
|
+
return { data: s.weights, source: "arcs", column: null };
|
|
3561
|
+
}
|
|
3562
|
+
if (spec === false || spec === null || spec === void 0) {
|
|
3563
|
+
return { data: null, source: "none", column: null };
|
|
3564
|
+
}
|
|
3565
|
+
if (typeof spec !== "string") {
|
|
3566
|
+
throw invalid$1(
|
|
3567
|
+
"weight",
|
|
3568
|
+
spec,
|
|
3569
|
+
"true, false, null or an edge column name",
|
|
3570
|
+
"weight must be true, false, null or an edge column name"
|
|
3571
|
+
);
|
|
3572
|
+
}
|
|
3573
|
+
const column = s.edges.get(spec);
|
|
3574
|
+
if (column === null) {
|
|
3575
|
+
throw invalid$1(
|
|
3576
|
+
"weight",
|
|
3577
|
+
spec,
|
|
3578
|
+
"an existing numeric edge column",
|
|
3579
|
+
`weight names edge column "${spec}", which the snapshot does not hold`
|
|
3580
|
+
);
|
|
3581
|
+
}
|
|
3582
|
+
if (column.meta.components !== 1) {
|
|
3583
|
+
throw invalid$1(
|
|
3584
|
+
"weight",
|
|
3585
|
+
spec,
|
|
3586
|
+
"a one-component numeric edge column",
|
|
3587
|
+
`edge column "${spec}" has ${column.meta.components} components; a weight column has one`
|
|
3588
|
+
);
|
|
3589
|
+
}
|
|
3590
|
+
switch (column.dtype) {
|
|
3591
|
+
case "f32":
|
|
3592
|
+
case "f64":
|
|
3593
|
+
case "u32":
|
|
3594
|
+
case "i32": {
|
|
3595
|
+
const view = s.edges.gpuView(spec);
|
|
3596
|
+
const expanded = expandEdges(s, view);
|
|
3597
|
+
const data = expanded instanceof Float32Array ? expanded : new Float32Array(expanded);
|
|
3598
|
+
return { data, source: "column", column };
|
|
3599
|
+
}
|
|
3600
|
+
case "string":
|
|
3601
|
+
case "list":
|
|
3602
|
+
case "json":
|
|
3603
|
+
s.edges.gpuView(spec);
|
|
3604
|
+
throw invalid$1("weight", spec, "a numeric edge column", `edge column "${spec}" is ${column.dtype}`);
|
|
3605
|
+
default:
|
|
3606
|
+
throw invalid$1(
|
|
3607
|
+
"weight",
|
|
3608
|
+
spec,
|
|
3609
|
+
"an f32 / f64 / u32 / i32 edge column",
|
|
3610
|
+
`edge column "${spec}" is ${column.dtype}: its gpuView is packed words, not per-edge values`
|
|
3611
|
+
);
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
class RepulsionExact {
|
|
3615
|
+
/**
|
|
3616
|
+
* Holds the two compiled kernels; create() is the only caller.
|
|
3617
|
+
* @param repulsion - the K3 kernel
|
|
3618
|
+
* @param speedFinalize - the K4 kernel
|
|
3619
|
+
* @param caps - the device caps the dispatch planner reads
|
|
3620
|
+
* @param overrides - the override set both kernels were compiled with
|
|
3621
|
+
*/
|
|
3622
|
+
constructor(repulsion, speedFinalize, caps, overrides) {
|
|
3623
|
+
this.boundRepulsion = null;
|
|
3624
|
+
this.boundSpeedFinalize = null;
|
|
3625
|
+
this.repulsion = repulsion;
|
|
3626
|
+
this.speedFinalize = speedFinalize;
|
|
3627
|
+
this.caps = caps;
|
|
3628
|
+
this.overrides = Object.freeze({
|
|
3629
|
+
SWING_MODE: overrides.SWING_MODE,
|
|
3630
|
+
STRONG_GRAVITY: overrides.STRONG_GRAVITY,
|
|
3631
|
+
GRAVITY_CENTER: overrides.GRAVITY_CENTER
|
|
3632
|
+
});
|
|
3633
|
+
this.finalizePlan = plan1d(speedFinalize.workgroupSize, speedFinalize.workgroupSize, caps);
|
|
3634
|
+
}
|
|
3635
|
+
/**
|
|
3636
|
+
* Compiles both kernels through the cache (the twin is selected by caps.features).
|
|
3637
|
+
* @param pipelines - the context's pipeline cache
|
|
3638
|
+
* @param caps - the device caps the dispatch planner reads
|
|
3639
|
+
* @param overrides - the SWING_MODE / STRONG_GRAVITY / GRAVITY_CENTER set of this stage
|
|
3640
|
+
* @returns the stage, ready for bind()
|
|
3641
|
+
*/
|
|
3642
|
+
static async create(pipelines, caps, overrides) {
|
|
3643
|
+
const [repulsionSpec, finalizeSpec] = RepulsionExact.specs(overrides);
|
|
3644
|
+
const repulsion = await pipelines.kernel(repulsionSpec);
|
|
3645
|
+
const speedFinalize = await pipelines.kernel(finalizeSpec);
|
|
3646
|
+
return new RepulsionExact(repulsion, speedFinalize, caps, overrides);
|
|
3647
|
+
}
|
|
3648
|
+
/**
|
|
3649
|
+
* The two specs (for warm() and the compile matrix).
|
|
3650
|
+
* @param overrides - the override set of the stage
|
|
3651
|
+
* @returns the K3 spec then the K4 spec
|
|
3652
|
+
*/
|
|
3653
|
+
static specs(overrides) {
|
|
3654
|
+
return [
|
|
3655
|
+
kernelSpec("fa2-repulsion-exact", {
|
|
3656
|
+
SWING_MODE: overrides.SWING_MODE,
|
|
3657
|
+
STRONG_GRAVITY: overrides.STRONG_GRAVITY,
|
|
3658
|
+
GRAVITY_CENTER: overrides.GRAVITY_CENTER
|
|
3659
|
+
}),
|
|
3660
|
+
kernelSpec("fa2-speed-finalize", { SWING_MODE: overrides.SWING_MODE })
|
|
3661
|
+
];
|
|
3662
|
+
}
|
|
3663
|
+
/**
|
|
3664
|
+
* Creates the bind groups once per load(): K3 takes pos, state (S), force, oldForce, fixedMask, partials and the
|
|
3665
|
+
* params slot; K4 takes partials, state (S), trace (T) and the params slot (the 3.10.1 binding names). The state
|
|
3666
|
+
* header and the trace region are two disjoint ranges of one buffer, which Kernel.bind() accepts (no writable alias).
|
|
3667
|
+
* @param resources - the buffers of spec 7.3
|
|
3668
|
+
*/
|
|
3669
|
+
bind(resources) {
|
|
3670
|
+
this.boundRepulsion = this.repulsion.bind({
|
|
3671
|
+
pos: resources.pos,
|
|
3672
|
+
S: resources.state,
|
|
3673
|
+
force: resources.force,
|
|
3674
|
+
oldForce: resources.oldForce,
|
|
3675
|
+
fixedMask: resources.fixedMask,
|
|
3676
|
+
partials: resources.partials,
|
|
3677
|
+
P: resources.params
|
|
3678
|
+
});
|
|
3679
|
+
this.boundSpeedFinalize = this.speedFinalize.bind({
|
|
3680
|
+
partials: resources.partials,
|
|
3681
|
+
S: resources.state,
|
|
3682
|
+
T: resources.trace,
|
|
3683
|
+
P: resources.params
|
|
3684
|
+
});
|
|
3685
|
+
}
|
|
3686
|
+
/**
|
|
3687
|
+
* Records K3 (plan1d(n)) then K4 (1 workgroup) with the params slot's dynamic offset.
|
|
3688
|
+
* @param pass - the open compute pass of the batch
|
|
3689
|
+
* @param n - the node count (rows [0, n) of pos / force)
|
|
3690
|
+
* @param paramsOffset - the dynamic offset of this iteration's Fa2Params slot in the uniform ring
|
|
3691
|
+
*/
|
|
3692
|
+
record(pass, n, paramsOffset) {
|
|
3693
|
+
this.recordRepulsion(pass, n, paramsOffset);
|
|
3694
|
+
this.recordSpeedFinalize(pass, paramsOffset);
|
|
3695
|
+
}
|
|
3696
|
+
/**
|
|
3697
|
+
* Records K3 only (the inspect() stage split, spec 11.9 item 2).
|
|
3698
|
+
* @param pass - the open compute pass
|
|
3699
|
+
* @param n - the node count
|
|
3700
|
+
* @param paramsOffset - the dynamic offset of the Fa2Params slot
|
|
3701
|
+
* @internal
|
|
3702
|
+
*/
|
|
3703
|
+
recordRepulsion(pass, n, paramsOffset) {
|
|
3704
|
+
const bound = this.bound(this.boundRepulsion, "recordRepulsion");
|
|
3705
|
+
const plan = plan1d(n, this.repulsion.workgroupSize, this.caps);
|
|
3706
|
+
this.repulsion.dispatch(pass, bound, plan, [paramsOffset]);
|
|
3707
|
+
}
|
|
3708
|
+
/**
|
|
3709
|
+
* Records K4 only.
|
|
3710
|
+
* @param pass - the open compute pass
|
|
3711
|
+
* @param paramsOffset - the dynamic offset of the Fa2Params slot
|
|
3712
|
+
* @internal
|
|
3713
|
+
*/
|
|
3714
|
+
recordSpeedFinalize(pass, paramsOffset) {
|
|
3715
|
+
const bound = this.bound(this.boundSpeedFinalize, "recordSpeedFinalize");
|
|
3716
|
+
this.speedFinalize.dispatch(pass, bound, this.finalizePlan, [paramsOffset]);
|
|
3717
|
+
}
|
|
3718
|
+
/**
|
|
3719
|
+
* The bound kernel of a method, or E_NOT_LOADED when bind() has not run.
|
|
3720
|
+
* @param bound - the cached BoundKernel, null before bind()
|
|
3721
|
+
* @param method - the caller's name for the message
|
|
3722
|
+
* @returns the bound kernel
|
|
3723
|
+
*/
|
|
3724
|
+
bound(bound, method) {
|
|
3725
|
+
if (bound === null) {
|
|
3726
|
+
throw new WebGpuGraphError(
|
|
3727
|
+
"E_NOT_LOADED",
|
|
3728
|
+
`RepulsionExact.${method}(): bind() has not been called for this stage`,
|
|
3729
|
+
{
|
|
3730
|
+
state: "unbound"
|
|
3731
|
+
}
|
|
3732
|
+
);
|
|
3733
|
+
}
|
|
3734
|
+
return bound;
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
const FA2_STAGES = ["K1", "K2", "K3", "K4", "K5", "toScene"];
|
|
3738
|
+
const FORCE_BYTES_PER_NODE = 12;
|
|
3739
|
+
const FILL_PARAMS_BUFFER = "fillParams";
|
|
3740
|
+
const ONE_WORKGROUP = { x: 1, y: 1, z: 1, items: 1, stride: null };
|
|
3741
|
+
const K2_DEFAULTS = { LINLOG: false, DISTRIBUTED: false, TIER: 0, USE_PERM: false, HAS_WEIGHTS: false };
|
|
3742
|
+
const K5_DEFAULTS = { SWING_MODE: 0 };
|
|
3743
|
+
const U32_MODULUS = 4294967296;
|
|
3744
|
+
const DEFAULT_RESOLVED = Object.freeze({
|
|
3745
|
+
...FA2_DEFAULTS,
|
|
3746
|
+
nodeMass: null,
|
|
3747
|
+
nodeSize: null,
|
|
3748
|
+
weight: null,
|
|
3749
|
+
center: [0, 0, 0],
|
|
3750
|
+
seed: null
|
|
3751
|
+
});
|
|
3752
|
+
function describeValue(value) {
|
|
3753
|
+
if (value === null) {
|
|
3754
|
+
return "null";
|
|
3755
|
+
}
|
|
3756
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "string") {
|
|
3757
|
+
return String(value);
|
|
3758
|
+
}
|
|
3759
|
+
if (typeof value === "undefined") {
|
|
3760
|
+
return "undefined";
|
|
3761
|
+
}
|
|
3762
|
+
if (typeof value === "object" && "length" in value && typeof value.length === "number") {
|
|
3763
|
+
return `[${value.length} values]`;
|
|
3764
|
+
}
|
|
3765
|
+
return typeof value;
|
|
3766
|
+
}
|
|
3767
|
+
function invalid(argument, value, expected) {
|
|
3768
|
+
return new WebGpuGraphError("E_INVALID_ARGUMENT", `${argument} must be ${expected}; got ${describeValue(value)}`, {
|
|
3769
|
+
argument,
|
|
3770
|
+
value,
|
|
3771
|
+
expected
|
|
3772
|
+
});
|
|
3773
|
+
}
|
|
3774
|
+
function pickNumber(name, given, fallback, check, expected) {
|
|
3775
|
+
const value = given === void 0 ? fallback : given;
|
|
3776
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !check(value)) {
|
|
3777
|
+
throw invalid(name, value, expected);
|
|
3778
|
+
}
|
|
3779
|
+
return value;
|
|
3780
|
+
}
|
|
3781
|
+
function pickBoolean(name, given, fallback) {
|
|
3782
|
+
const value = given === void 0 ? fallback : given;
|
|
3783
|
+
if (typeof value !== "boolean") {
|
|
3784
|
+
throw invalid(name, value, "a boolean");
|
|
3785
|
+
}
|
|
3786
|
+
return value;
|
|
3787
|
+
}
|
|
3788
|
+
function pickDim(given, fallback) {
|
|
3789
|
+
const value = given === void 0 ? fallback : given;
|
|
3790
|
+
if (value !== 2 && value !== 3) {
|
|
3791
|
+
throw invalid("dim", value, "2 or 3");
|
|
3792
|
+
}
|
|
3793
|
+
return value;
|
|
3794
|
+
}
|
|
3795
|
+
function pickCenter(given, fallback) {
|
|
3796
|
+
if (given === void 0) {
|
|
3797
|
+
return fallback;
|
|
3798
|
+
}
|
|
3799
|
+
const expected = "an array of 2 or 3 finite numbers";
|
|
3800
|
+
const value = given;
|
|
3801
|
+
if (typeof value !== "object" || value === null || !("length" in value)) {
|
|
3802
|
+
throw invalid("center", given, expected);
|
|
3803
|
+
}
|
|
3804
|
+
const { length } = value;
|
|
3805
|
+
if (length !== 2 && length !== 3) {
|
|
3806
|
+
throw invalid("center", given, expected);
|
|
3807
|
+
}
|
|
3808
|
+
const x = given[0];
|
|
3809
|
+
const y = given[1];
|
|
3810
|
+
const z = length === 3 ? given[2] : 0;
|
|
3811
|
+
if (typeof x !== "number" || typeof y !== "number" || typeof z !== "number" || !Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
|
3812
|
+
throw invalid("center", given, expected);
|
|
3813
|
+
}
|
|
3814
|
+
return [x, y, z];
|
|
3815
|
+
}
|
|
3816
|
+
function pickSeed(given, fallback) {
|
|
3817
|
+
if (given === void 0) {
|
|
3818
|
+
return fallback;
|
|
3819
|
+
}
|
|
3820
|
+
const value = given;
|
|
3821
|
+
if (value !== null && (typeof value !== "number" || !Number.isFinite(value))) {
|
|
3822
|
+
throw invalid("seed", given, "a finite number or null");
|
|
3823
|
+
}
|
|
3824
|
+
return value;
|
|
3825
|
+
}
|
|
3826
|
+
function isPositiveInteger(value) {
|
|
3827
|
+
return Number.isInteger(value) && value >= 1;
|
|
3828
|
+
}
|
|
3829
|
+
function seedWord(seed) {
|
|
3830
|
+
if (seed === null) {
|
|
3831
|
+
return 0;
|
|
3832
|
+
}
|
|
3833
|
+
return Math.floor(Math.abs(seed)) % U32_MODULUS;
|
|
3834
|
+
}
|
|
3835
|
+
function scalar(values, name) {
|
|
3836
|
+
const value = values[name];
|
|
3837
|
+
if (typeof value !== "number") {
|
|
3838
|
+
throw invalid(name, value, "a scalar field");
|
|
3839
|
+
}
|
|
3840
|
+
return value;
|
|
3841
|
+
}
|
|
3842
|
+
function vector(values, name) {
|
|
3843
|
+
const value = values[name];
|
|
3844
|
+
if (typeof value === "number") {
|
|
3845
|
+
throw invalid(name, value, "a vector field");
|
|
3846
|
+
}
|
|
3847
|
+
return value;
|
|
3848
|
+
}
|
|
3849
|
+
function subset(merged, defaults) {
|
|
3850
|
+
const out = {};
|
|
3851
|
+
for (const name of Object.keys(defaults)) {
|
|
3852
|
+
out[name] = name in merged ? merged[name] : defaults[name];
|
|
3853
|
+
}
|
|
3854
|
+
return out;
|
|
3855
|
+
}
|
|
3856
|
+
function repulsionOverrides(merged) {
|
|
3857
|
+
return {
|
|
3858
|
+
SWING_MODE: merged.SWING_MODE === 1 ? 1 : 0,
|
|
3859
|
+
STRONG_GRAVITY: merged.STRONG_GRAVITY === true,
|
|
3860
|
+
GRAVITY_CENTER: merged.GRAVITY_CENTER === 1 ? 1 : 0
|
|
3861
|
+
};
|
|
3862
|
+
}
|
|
3863
|
+
function resolveForceAtlas2Options(options, previous) {
|
|
3864
|
+
const o = options ?? {};
|
|
3865
|
+
const base = previous ?? DEFAULT_RESOLVED;
|
|
3866
|
+
if (previous !== void 0 && o.maxInFlight !== void 0 && o.maxInFlight !== previous.maxInFlight) {
|
|
3867
|
+
throw new WebGpuGraphError(
|
|
3868
|
+
"E_INVALID_ARGUMENT",
|
|
3869
|
+
`maxInFlight cannot change after creation (the uniform ring is sized by it): got ${describeValue(o.maxInFlight)}, current ${previous.maxInFlight}`,
|
|
3870
|
+
{ argument: "maxInFlight", value: o.maxInFlight, expected: previous.maxInFlight }
|
|
3871
|
+
);
|
|
3872
|
+
}
|
|
3873
|
+
const nodeSize = o.nodeSize === void 0 ? base.nodeSize : o.nodeSize;
|
|
3874
|
+
if (nodeSize !== null) {
|
|
3875
|
+
throw new WebGpuGraphError(
|
|
3876
|
+
"E_UNSUPPORTED",
|
|
3877
|
+
"nodeSize (the adjustSizes correction) is not supported by the GPU ForceAtlas2 yet (spec 7.14)",
|
|
3878
|
+
{ option: "nodeSize", hint: "pass nodeSize: null; the size-aware repulsion is deferred (spec 7.2, Q-25)" }
|
|
3879
|
+
);
|
|
3880
|
+
}
|
|
3881
|
+
const resolved = {
|
|
3882
|
+
maxIter: pickNumber("maxIter", o.maxIter, base.maxIter, isPositiveInteger, "an integer >= 1"),
|
|
3883
|
+
jitterTolerance: pickNumber("jitterTolerance", o.jitterTolerance, base.jitterTolerance, (v) => v > 0, "> 0"),
|
|
3884
|
+
scalingRatio: pickNumber("scalingRatio", o.scalingRatio, base.scalingRatio, (v) => v > 0, "> 0"),
|
|
3885
|
+
gravity: pickNumber("gravity", o.gravity, base.gravity, (v) => v >= 0, ">= 0"),
|
|
3886
|
+
strongGravity: pickBoolean("strongGravity", o.strongGravity, base.strongGravity),
|
|
3887
|
+
distributedAction: pickBoolean("distributedAction", o.distributedAction, base.distributedAction),
|
|
3888
|
+
linlog: pickBoolean("linlog", o.linlog, base.linlog),
|
|
3889
|
+
nodeMass: o.nodeMass === void 0 ? base.nodeMass : o.nodeMass,
|
|
3890
|
+
nodeSize,
|
|
3891
|
+
weight: o.weight === void 0 ? base.weight : o.weight,
|
|
3892
|
+
dissuadeHubs: pickBoolean("dissuadeHubs", o.dissuadeHubs, base.dissuadeHubs),
|
|
3893
|
+
dim: pickDim(o.dim, base.dim),
|
|
3894
|
+
scale: pickNumber("scale", o.scale, base.scale, (v) => v > 0, "> 0"),
|
|
3895
|
+
center: pickCenter(o.center, base.center),
|
|
3896
|
+
seed: pickSeed(o.seed, base.seed),
|
|
3897
|
+
settleThreshold: pickNumber("settleThreshold", o.settleThreshold, base.settleThreshold, (v) => v >= 0, ">= 0"),
|
|
3898
|
+
settleWindow: pickNumber(
|
|
3899
|
+
"settleWindow",
|
|
3900
|
+
o.settleWindow,
|
|
3901
|
+
base.settleWindow,
|
|
3902
|
+
isPositiveInteger,
|
|
3903
|
+
"an integer >= 1"
|
|
3904
|
+
),
|
|
3905
|
+
iterationsPerStep: pickNumber(
|
|
3906
|
+
"iterationsPerStep",
|
|
3907
|
+
o.iterationsPerStep,
|
|
3908
|
+
base.iterationsPerStep,
|
|
3909
|
+
(v) => isPositiveInteger(v) && v <= MAX_ITERATIONS_PER_STEP,
|
|
3910
|
+
`an integer in [1, ${MAX_ITERATIONS_PER_STEP}]`
|
|
3911
|
+
),
|
|
3912
|
+
maxInFlight: pickNumber("maxInFlight", o.maxInFlight, base.maxInFlight, isPositiveInteger, "an integer >= 1")
|
|
3913
|
+
};
|
|
3914
|
+
return Object.freeze(resolved);
|
|
3915
|
+
}
|
|
3916
|
+
function resolveLayoutTuning(tuning) {
|
|
3917
|
+
const t = tuning ?? {};
|
|
3918
|
+
const repulsion = t.repulsion ?? LAYOUT_TUNING_DEFAULTS.repulsion;
|
|
3919
|
+
if (repulsion !== "exact" && repulsion !== "grid" && repulsion !== "auto") {
|
|
3920
|
+
throw invalid("repulsion", repulsion, '"exact", "grid" or "auto"');
|
|
3921
|
+
}
|
|
3922
|
+
const compat = t.compat ?? LAYOUT_TUNING_DEFAULTS.compat;
|
|
3923
|
+
if (compat !== "paper" && compat !== "networkx") {
|
|
3924
|
+
throw invalid("compat", compat, '"paper" or "networkx"');
|
|
3925
|
+
}
|
|
3926
|
+
const resolved = {
|
|
3927
|
+
repulsion,
|
|
3928
|
+
exactMaxNodes: pickNumber(
|
|
3929
|
+
"exactMaxNodes",
|
|
3930
|
+
t.exactMaxNodes,
|
|
3931
|
+
LAYOUT_TUNING_DEFAULTS.exactMaxNodes,
|
|
3932
|
+
isPositiveInteger,
|
|
3933
|
+
"an integer >= 1"
|
|
3934
|
+
),
|
|
3935
|
+
nearMax: pickNumber("nearMax", t.nearMax, LAYOUT_TUNING_DEFAULTS.nearMax, isPositiveInteger, "an integer >= 1"),
|
|
3936
|
+
deterministic: pickBoolean("deterministic", t.deterministic, LAYOUT_TUNING_DEFAULTS.deterministic),
|
|
3937
|
+
gridMax2D: pickNumber(
|
|
3938
|
+
"gridMax2D",
|
|
3939
|
+
t.gridMax2D,
|
|
3940
|
+
LAYOUT_TUNING_DEFAULTS.gridMax2D,
|
|
3941
|
+
isPositiveInteger,
|
|
3942
|
+
"an integer >= 1"
|
|
3943
|
+
),
|
|
3944
|
+
gridMax3D: pickNumber(
|
|
3945
|
+
"gridMax3D",
|
|
3946
|
+
t.gridMax3D,
|
|
3947
|
+
LAYOUT_TUNING_DEFAULTS.gridMax3D,
|
|
3948
|
+
isPositiveInteger,
|
|
3949
|
+
"an integer >= 1"
|
|
3950
|
+
),
|
|
3951
|
+
extentFactor: pickNumber(
|
|
3952
|
+
"extentFactor",
|
|
3953
|
+
t.extentFactor,
|
|
3954
|
+
LAYOUT_TUNING_DEFAULTS.extentFactor,
|
|
3955
|
+
(v) => v > 0,
|
|
3956
|
+
"> 0"
|
|
3957
|
+
),
|
|
3958
|
+
compat
|
|
3959
|
+
};
|
|
3960
|
+
return Object.freeze(resolved);
|
|
3961
|
+
}
|
|
3962
|
+
class ForceAtlas2Model {
|
|
3963
|
+
/**
|
|
3964
|
+
* Creates the model for one simulation.
|
|
3965
|
+
* @param tuning - the resolved GPU-only tuning (compat selects SWING_MODE / GRAVITY_CENTER; repulsion and
|
|
3966
|
+
* exactMaxNodes the tier rule)
|
|
3967
|
+
* @param resolved - the resolved option record at creation
|
|
3968
|
+
*/
|
|
3969
|
+
constructor(tuning, resolved) {
|
|
3970
|
+
this.kind = "forceatlas2";
|
|
3971
|
+
this.stages = FA2_STAGES;
|
|
3972
|
+
this.params = FA2_PARAMS;
|
|
3973
|
+
this.state = FA2_STATE;
|
|
3974
|
+
this.trace = FA2_TRACE;
|
|
3975
|
+
this.resources = null;
|
|
3976
|
+
this.bound = null;
|
|
3977
|
+
this.resetOldForce = false;
|
|
3978
|
+
this.openPass = null;
|
|
3979
|
+
this.tuning = tuning;
|
|
3980
|
+
this.current = resolved;
|
|
3981
|
+
}
|
|
3982
|
+
/**
|
|
3983
|
+
* The SWING_MODE of this model: 1 in networkx mode (accumulated, position-mixed sums; m|F| local swing), else 0.
|
|
3984
|
+
* @returns 0 or 1
|
|
3985
|
+
*/
|
|
3986
|
+
get swingMode() {
|
|
3987
|
+
return this.tuning.compat === "networkx" ? 1 : 0;
|
|
3988
|
+
}
|
|
3989
|
+
/**
|
|
3990
|
+
* force 12n and oldForce 12n (zeroed) in BOTH swing modes (3.10.1: a writable slot is never aliased; mode 1 leaves
|
|
3991
|
+
* oldForce unread and unwritten), plus the 256-byte FillParams uniform buffer the fill dispatches read. n = 0
|
|
3992
|
+
* reports one node's worth of bytes so no zero-length buffer is ever created (spec 3.6).
|
|
3993
|
+
* @param n - the node count
|
|
3994
|
+
* @param _dim - the layout dimension (the force arrays are stride 3 in both)
|
|
3995
|
+
* @returns the three model-owned buffer specs
|
|
3996
|
+
*/
|
|
3997
|
+
buffers(n, _dim) {
|
|
3998
|
+
const bytes = Math.max(1, n) * FORCE_BYTES_PER_NODE;
|
|
3999
|
+
const usage = BufferUsage.STORAGE | BufferUsage.COPY_SRC | BufferUsage.COPY_DST;
|
|
4000
|
+
return [
|
|
4001
|
+
{ name: "force", byteLength: bytes, usage, zero: true },
|
|
4002
|
+
{ name: "oldForce", byteLength: bytes, usage, zero: true },
|
|
4003
|
+
{
|
|
4004
|
+
name: FILL_PARAMS_BUFFER,
|
|
4005
|
+
byteLength: UNIFORM_SLOT_BYTES,
|
|
4006
|
+
usage: BufferUsage.UNIFORM | BufferUsage.COPY_DST,
|
|
4007
|
+
zero: false
|
|
4008
|
+
}
|
|
4009
|
+
];
|
|
4010
|
+
}
|
|
4011
|
+
/**
|
|
4012
|
+
* { mass: resolveNodeMass(s, resolved.nodeMass), weights: resolveWeights(s, resolved.weight) } (3.13 inputs.ts),
|
|
4013
|
+
* after the tier rule of spec 7.8 / lead f: `repulsion: "grid"` is E_UNSUPPORTED { feature: "repulsion.grid" }
|
|
4014
|
+
* for any n and `"auto"` when n > exactMaxNodes (the grid tier lands in P4); `"exact"` always runs.
|
|
4015
|
+
* @param s - the snapshot being loaded
|
|
4016
|
+
* @param options - the simulation's current option record
|
|
4017
|
+
* @returns the per-load inputs
|
|
4018
|
+
*/
|
|
4019
|
+
inputs(s, options) {
|
|
4020
|
+
const resolved = resolveForceAtlas2Options(options, this.current);
|
|
4021
|
+
const { repulsion, exactMaxNodes } = this.tuning;
|
|
4022
|
+
const n = s.nodeCount;
|
|
4023
|
+
if (repulsion === "grid" || repulsion === "auto" && n > exactMaxNodes) {
|
|
4024
|
+
throw new WebGpuGraphError(
|
|
4025
|
+
"E_UNSUPPORTED",
|
|
4026
|
+
repulsion === "grid" ? 'repulsion: "grid" is not available yet (the grid tier lands in P4)' : `the graph has ${n} nodes, above exactMaxNodes ${exactMaxNodes}, and the grid tier lands in P4`,
|
|
4027
|
+
{
|
|
4028
|
+
feature: "repulsion.grid",
|
|
4029
|
+
hint: 'pass repulsion: "exact" (or raise exactMaxNodes) to run the exact tier at this size'
|
|
4030
|
+
}
|
|
4031
|
+
);
|
|
4032
|
+
}
|
|
4033
|
+
return { mass: resolveNodeMass(s, resolved.nodeMass), weights: resolveWeights(s, resolved.weight) };
|
|
4034
|
+
}
|
|
4035
|
+
/**
|
|
4036
|
+
* { LINLOG, DISTRIBUTED, TIER: 0, SWING_MODE: compat === "networkx" ? 1 : 0, STRONG_GRAVITY, GRAVITY_CENTER:
|
|
4037
|
+
* compat === "networkx" ? 1 : 0 }; USE_PERM / HAS_WEIGHTS are merged in by the simulation from ModelResources
|
|
4038
|
+
* (3.10, never from core.hasWeights). A pure query: the simulation calls it with the current AND the next record
|
|
4039
|
+
* inside setParams() to decide the recompile, so it never touches the model's record (PLAN DECISION 12).
|
|
4040
|
+
* @param options - an option record (the simulation's current one, or the next one of a setParams patch)
|
|
4041
|
+
* @returns the model's own override set
|
|
4042
|
+
*/
|
|
4043
|
+
overrides(options) {
|
|
4044
|
+
const resolved = resolveForceAtlas2Options(options, this.current);
|
|
4045
|
+
const mode = this.swingMode;
|
|
4046
|
+
return {
|
|
4047
|
+
LINLOG: resolved.linlog,
|
|
4048
|
+
DISTRIBUTED: resolved.distributedAction,
|
|
4049
|
+
TIER: 0,
|
|
4050
|
+
SWING_MODE: mode,
|
|
4051
|
+
STRONG_GRAVITY: resolved.strongGravity,
|
|
4052
|
+
GRAVITY_CENTER: mode
|
|
4053
|
+
};
|
|
4054
|
+
}
|
|
4055
|
+
/**
|
|
4056
|
+
* The seven module specs of an override set in dispatch order -- K1, K2, K3, K4, K5, toScene, fill -- each with
|
|
4057
|
+
* only the override names its entry declares (K2 also USE_PERM / HAS_WEIGHTS), for warm() and the compile matrix.
|
|
4058
|
+
* @param overrides - the merged override set (the model's plus USE_PERM / HAS_WEIGHTS)
|
|
4059
|
+
* @param _subgroups - accepted for the ForceModel interface and unused: every reducing FA2 body carries
|
|
4060
|
+
* needs: ["subgroups"] in its registry entry and the composer picks the twin from caps.features (contract 4.3)
|
|
4061
|
+
* @returns the specs
|
|
4062
|
+
*/
|
|
4063
|
+
specs(overrides, _subgroups) {
|
|
4064
|
+
const [repulsionSpec, speedSpec] = RepulsionExact.specs(repulsionOverrides(overrides));
|
|
4065
|
+
return [
|
|
4066
|
+
kernelSpec("fa2-stats-finalize"),
|
|
4067
|
+
kernelSpec("fa2-attraction", subset(overrides, K2_DEFAULTS)),
|
|
4068
|
+
repulsionSpec,
|
|
4069
|
+
speedSpec,
|
|
4070
|
+
kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS)),
|
|
4071
|
+
kernelSpec("fa2-to-scene"),
|
|
4072
|
+
kernelSpec("fill")
|
|
4073
|
+
];
|
|
4074
|
+
}
|
|
4075
|
+
/**
|
|
4076
|
+
* Compiles (through the cache) and binds every kernel against the buffers of this load(): K1, K2 (or the fill of
|
|
4077
|
+
* force when arcCount === 0), K3 + K4 through RepulsionExact, K5, toScene, and the fill of oldForce; writes the
|
|
4078
|
+
* FillParams { count: 3n, value: 0, mode: 0 } into the model's uniform buffer. With n === 0 nothing is bound.
|
|
4079
|
+
* @param resources - the graph, the shared and model buffers, the ring and the cache
|
|
4080
|
+
* @param overrides - the merged override set
|
|
4081
|
+
*/
|
|
4082
|
+
async bind(resources, overrides) {
|
|
4083
|
+
this.dropBound();
|
|
4084
|
+
this.resources = resources;
|
|
4085
|
+
const { n, pipelines, caps, core, perm, ring, device } = resources;
|
|
4086
|
+
if (n === 0) {
|
|
4087
|
+
return;
|
|
4088
|
+
}
|
|
4089
|
+
const [k1, k2, k5, toScene, fill] = await Promise.all([
|
|
4090
|
+
pipelines.kernel(kernelSpec("fa2-stats-finalize")),
|
|
4091
|
+
pipelines.kernel(kernelSpec("fa2-attraction", subset(overrides, K2_DEFAULTS))),
|
|
4092
|
+
pipelines.kernel(kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS))),
|
|
4093
|
+
pipelines.kernel(kernelSpec("fa2-to-scene")),
|
|
4094
|
+
pipelines.kernel(kernelSpec("fill"))
|
|
4095
|
+
]);
|
|
4096
|
+
const repulsion = await RepulsionExact.create(pipelines, caps, repulsionOverrides(overrides));
|
|
4097
|
+
if (this.resources !== resources) {
|
|
4098
|
+
return;
|
|
4099
|
+
}
|
|
4100
|
+
const pos = resources.buffer("positions");
|
|
4101
|
+
const scene = resources.buffer("scenePositions");
|
|
4102
|
+
const fixed = resources.buffer("fixed");
|
|
4103
|
+
const partials = resources.buffer("partials");
|
|
4104
|
+
const state = resources.buffer("state");
|
|
4105
|
+
const trace = resources.buffer("trace");
|
|
4106
|
+
const force = resources.buffer("force");
|
|
4107
|
+
const oldForce = resources.buffer("oldForce");
|
|
4108
|
+
const fillParamsBuffer = resources.buffer(FILL_PARAMS_BUFFER);
|
|
4109
|
+
const params = ring.binding(FA2_PARAMS);
|
|
4110
|
+
const fillParams = {
|
|
4111
|
+
buffer: fillParamsBuffer.buffer,
|
|
4112
|
+
offset: fillParamsBuffer.offset,
|
|
4113
|
+
size: FILL_PARAMS.byteLength,
|
|
4114
|
+
window: null
|
|
4115
|
+
};
|
|
4116
|
+
const fillBytes = new ArrayBuffer(FILL_PARAMS.byteLength);
|
|
4117
|
+
FILL_PARAMS.write(new DataView(fillBytes), { count: 3 * n, value: 0, mode: 0 });
|
|
4118
|
+
device.queue.writeBuffer(fillParamsBuffer.buffer, fillParamsBuffer.offset, fillBytes);
|
|
4119
|
+
const hasArcs = core.colIdx !== null;
|
|
4120
|
+
repulsion.bind({ pos, state, trace, force, oldForce, fixedMask: fixed, partials, params });
|
|
4121
|
+
const wg = k1.workgroupSize;
|
|
4122
|
+
this.bound = {
|
|
4123
|
+
n,
|
|
4124
|
+
plan: plan1d(n, wg, caps),
|
|
4125
|
+
fillPlan: plan1d(3 * n, wg, caps),
|
|
4126
|
+
k1,
|
|
4127
|
+
k1Bound: k1.bind({ partials, S: state, T: trace, P: params }),
|
|
4128
|
+
k2,
|
|
4129
|
+
k2Bound: hasArcs ? k2.bind({ ...graphBindings(core, perm, resources.weights), pos, force, P: params }) : null,
|
|
4130
|
+
repulsion,
|
|
4131
|
+
k5,
|
|
4132
|
+
k5Bound: k5.bind({ force, oldForce, fixedMask: fixed, S: state, pos, partials, P: params }),
|
|
4133
|
+
toScene,
|
|
4134
|
+
toSceneBound: toScene.bind({ pos, scene, P: params }),
|
|
4135
|
+
fill,
|
|
4136
|
+
fillForceBound: hasArcs ? null : fill.bind({ dst: force, P: fillParams }),
|
|
4137
|
+
fillOldBound: this.swingMode === 0 ? fill.bind({ dst: oldForce, P: fillParams }) : null
|
|
4138
|
+
};
|
|
4139
|
+
}
|
|
4140
|
+
/**
|
|
4141
|
+
* The Fa2Params values of one iteration (the simulation overwrites the shared fields n, dim, flags,
|
|
4142
|
+
* iterationIndex, seed, scale, center and settleThreshold with the same values plus the flags).
|
|
4143
|
+
* @param iteration - the trace slot of the iteration inside its batch
|
|
4144
|
+
* @param options - the simulation's current option record
|
|
4145
|
+
* @returns the uniform values
|
|
4146
|
+
*/
|
|
4147
|
+
paramsFor(iteration, options) {
|
|
4148
|
+
const { n } = this.requireResources();
|
|
4149
|
+
const resolved = resolveForceAtlas2Options(options, this.current);
|
|
4150
|
+
const { nearMax, extentFactor } = this.tuning;
|
|
4151
|
+
return {
|
|
4152
|
+
n,
|
|
4153
|
+
dim: resolved.dim,
|
|
4154
|
+
flags: 0,
|
|
4155
|
+
tierStart: 0,
|
|
4156
|
+
tierEnd: n,
|
|
4157
|
+
iterationIndex: iteration,
|
|
4158
|
+
seed: seedWord(resolved.seed),
|
|
4159
|
+
nearMax,
|
|
4160
|
+
scalingRatio: resolved.scalingRatio,
|
|
4161
|
+
gravity: resolved.gravity,
|
|
4162
|
+
jitterTolerance: resolved.jitterTolerance,
|
|
4163
|
+
scale: resolved.scale,
|
|
4164
|
+
center: [resolved.center[0], resolved.center[1], resolved.center[2], 0],
|
|
4165
|
+
settleThreshold: resolved.settleThreshold,
|
|
4166
|
+
extentFactor,
|
|
4167
|
+
gridMax: 0,
|
|
4168
|
+
levels: 0,
|
|
4169
|
+
pad: [0, 0, 0, 0]
|
|
4170
|
+
};
|
|
4171
|
+
}
|
|
4172
|
+
/**
|
|
4173
|
+
* Records one iteration into the batch: K1, K2 (or the fill of force when arcCount === 0), K3, K4, K5 in the
|
|
4174
|
+
* batch's ONE K1-K5 compute pass (opened by the first call of a batch and reused by every later call with the
|
|
4175
|
+
* same batch.id, PLAN DECISION 2), then toScene in a second pass that ends it, stopping after stage `upTo` when
|
|
4176
|
+
* given (spec 7.4; debugRunStages / inspect, spec 11.9 item 2). The simulation passes "K5" for iterations
|
|
4177
|
+
* 0..k-2 and undefined for the last, so toScene runs once per batch. The first call after load() zeroes
|
|
4178
|
+
* oldForce before K1 (paper mode). With n === 0 nothing is recorded (PLAN DECISION 9); a call before bind()
|
|
4179
|
+
* completed is E_NOT_LOADED (never a silent no-op).
|
|
4180
|
+
* @param batch - the batch being recorded
|
|
4181
|
+
* @param slot - the UniformRing slot holding this iteration's Fa2Params
|
|
4182
|
+
* @param tier - "exact" (the grid tier is E_UNSUPPORTED until P4; the simulation never passes "grid")
|
|
4183
|
+
* @param upTo - a stage name to stop after; undefined records every stage including toScene
|
|
4184
|
+
*/
|
|
4185
|
+
recordIteration(batch, slot, tier, upTo) {
|
|
4186
|
+
if (tier === "grid") {
|
|
4187
|
+
throw new WebGpuGraphError("E_UNSUPPORTED", "the grid repulsion tier lands in P4", {
|
|
4188
|
+
feature: "repulsion.grid",
|
|
4189
|
+
hint: 'pass repulsion: "exact"'
|
|
4190
|
+
});
|
|
4191
|
+
}
|
|
4192
|
+
const resources = this.requireResources();
|
|
4193
|
+
const stop = upTo === void 0 ? FA2_STAGES.length - 1 : this.stageIndex(upTo);
|
|
4194
|
+
const { bound } = this;
|
|
4195
|
+
if (bound === null) {
|
|
4196
|
+
if (resources.n === 0) {
|
|
4197
|
+
return;
|
|
4198
|
+
}
|
|
4199
|
+
throw new WebGpuGraphError(
|
|
4200
|
+
"E_NOT_LOADED",
|
|
4201
|
+
"the ForceAtlas2 model is not bound (bind() has not completed)",
|
|
4202
|
+
{
|
|
4203
|
+
state: "loaded"
|
|
4204
|
+
}
|
|
4205
|
+
);
|
|
4206
|
+
}
|
|
4207
|
+
const offset = resources.ring.offsetOf(slot);
|
|
4208
|
+
const pass = this.openPass !== null && this.openPass.id === batch.id ? this.openPass.pass : batch.pass("fa2");
|
|
4209
|
+
this.openPass = { id: batch.id, pass };
|
|
4210
|
+
if (this.resetOldForce) {
|
|
4211
|
+
this.resetOldForce = false;
|
|
4212
|
+
if (bound.fillOldBound !== null) {
|
|
4213
|
+
bound.fill.dispatch(pass, bound.fillOldBound, bound.fillPlan, [0]);
|
|
4214
|
+
}
|
|
4215
|
+
}
|
|
4216
|
+
bound.k1.dispatch(pass, bound.k1Bound, ONE_WORKGROUP, [offset]);
|
|
4217
|
+
if (stop < 1) {
|
|
4218
|
+
return;
|
|
4219
|
+
}
|
|
4220
|
+
if (bound.k2Bound !== null) {
|
|
4221
|
+
bound.k2.dispatch(pass, bound.k2Bound, bound.plan, [offset]);
|
|
4222
|
+
} else if (bound.fillForceBound !== null) {
|
|
4223
|
+
bound.fill.dispatch(pass, bound.fillForceBound, bound.fillPlan, [0]);
|
|
4224
|
+
}
|
|
4225
|
+
if (stop < 2) {
|
|
4226
|
+
return;
|
|
4227
|
+
}
|
|
4228
|
+
bound.repulsion.recordRepulsion(pass, bound.n, offset);
|
|
4229
|
+
if (stop < 3) {
|
|
4230
|
+
return;
|
|
4231
|
+
}
|
|
4232
|
+
bound.repulsion.recordSpeedFinalize(pass, offset);
|
|
4233
|
+
if (stop < 4) {
|
|
4234
|
+
return;
|
|
4235
|
+
}
|
|
4236
|
+
bound.k5.dispatch(pass, bound.k5Bound, bound.plan, [offset]);
|
|
4237
|
+
if (stop < 5) {
|
|
4238
|
+
return;
|
|
4239
|
+
}
|
|
4240
|
+
this.openPass = null;
|
|
4241
|
+
const scenePass = batch.pass("fa2-to-scene");
|
|
4242
|
+
bound.toScene.dispatch(scenePass, bound.toSceneBound, bound.plan, [offset]);
|
|
4243
|
+
}
|
|
4244
|
+
/**
|
|
4245
|
+
* speed = 1, speedEfficiency = 1, swing = 1, traction = 1 (mode 1 accumulates from 1; mode 0 overwrites them each
|
|
4246
|
+
* iteration, the initial value is irrelevant); arms the oldForce reset of the next recordIteration.
|
|
4247
|
+
* @param state - the state writer of the simulation
|
|
4248
|
+
*/
|
|
4249
|
+
onLoad(state) {
|
|
4250
|
+
state.set("speed", 1);
|
|
4251
|
+
state.set("speedEfficiency", 1);
|
|
4252
|
+
state.set("swing", 1);
|
|
4253
|
+
state.set("traction", 1);
|
|
4254
|
+
this.resetOldForce = true;
|
|
4255
|
+
}
|
|
4256
|
+
/**
|
|
4257
|
+
* Mode 0: nothing (D8). Mode 1 (networkx): swing = traction = 1 (spec 7.2 "load() and reheat() reset them to 1").
|
|
4258
|
+
* @param state - the state writer of the simulation
|
|
4259
|
+
*/
|
|
4260
|
+
onReheat(state) {
|
|
4261
|
+
if (this.swingMode === 1) {
|
|
4262
|
+
state.set("swing", 1);
|
|
4263
|
+
state.set("traction", 1);
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
/**
|
|
4267
|
+
* Resets speed / speedEfficiency to 1 only when linlog, strongGravity or distributedAction changed (spec 7.17);
|
|
4268
|
+
* a numeric tweak leaves the controller alone. The patch is validated by the same resolver the simulation uses
|
|
4269
|
+
* and applied over the record of the constructor / the previous onSetParams -- the only place `current` moves
|
|
4270
|
+
* (PLAN DECISION 12), so the comparison sees the record from BEFORE this setParams even though the simulation
|
|
4271
|
+
* already queried overrides(next).
|
|
4272
|
+
* @param patch - the setParams patch
|
|
4273
|
+
* @param state - the state writer of the simulation
|
|
4274
|
+
*/
|
|
4275
|
+
onSetParams(patch, state) {
|
|
4276
|
+
const next = resolveForceAtlas2Options(patch, this.current);
|
|
4277
|
+
const lawChanged = next.linlog !== this.current.linlog || next.strongGravity !== this.current.strongGravity || next.distributedAction !== this.current.distributedAction;
|
|
4278
|
+
this.current = next;
|
|
4279
|
+
if (lawChanged) {
|
|
4280
|
+
state.set("speed", 1);
|
|
4281
|
+
state.set("speedEfficiency", 1);
|
|
4282
|
+
}
|
|
4283
|
+
}
|
|
4284
|
+
/**
|
|
4285
|
+
* Decodes the state header and the k trace records of a completed batch (k = trace.byteLength / 32) into
|
|
4286
|
+
* ForceAtlas2Stats: the exact tier with null grid fields; msPerIteration null (the simulation owns the clock).
|
|
4287
|
+
* @param state - a DataView over the 256-byte state header
|
|
4288
|
+
* @param trace - a DataView over the k Fa2Trace records of the batch
|
|
4289
|
+
* @returns the stats
|
|
4290
|
+
*/
|
|
4291
|
+
readStats(state, trace) {
|
|
4292
|
+
const header = FA2_STATE.read(state);
|
|
4293
|
+
const centroid = vector(header, "centroid");
|
|
4294
|
+
const records = [];
|
|
4295
|
+
const count = Math.floor(trace.byteLength / TRACE_RECORD_BYTES);
|
|
4296
|
+
for (let i = 0; i < count; i++) {
|
|
4297
|
+
const record = FA2_TRACE.read(trace, i * TRACE_RECORD_BYTES);
|
|
4298
|
+
records.push({
|
|
4299
|
+
swing: scalar(record, "swing"),
|
|
4300
|
+
traction: scalar(record, "traction"),
|
|
4301
|
+
speed: scalar(record, "speed"),
|
|
4302
|
+
speedEfficiency: scalar(record, "speedEfficiency"),
|
|
4303
|
+
meanDisplacement: scalar(record, "meanDisplacement"),
|
|
4304
|
+
settledCount: scalar(record, "settledCount")
|
|
4305
|
+
});
|
|
4306
|
+
}
|
|
4307
|
+
return {
|
|
4308
|
+
iteration: scalar(header, "iteration"),
|
|
4309
|
+
meanDisplacement: scalar(header, "meanDisplacement"),
|
|
4310
|
+
rmsRadius: scalar(header, "rmsRadius"),
|
|
4311
|
+
layoutRadius: scalar(header, "radius"),
|
|
4312
|
+
centroid: [centroid[0], centroid[1], centroid[2]],
|
|
4313
|
+
repulsionTier: "exact",
|
|
4314
|
+
maxCellOccupancy: null,
|
|
4315
|
+
outsideGrid: null,
|
|
4316
|
+
msPerIteration: null,
|
|
4317
|
+
swing: scalar(header, "swing"),
|
|
4318
|
+
traction: scalar(header, "traction"),
|
|
4319
|
+
speed: scalar(header, "speed"),
|
|
4320
|
+
speedEfficiency: scalar(header, "speedEfficiency"),
|
|
4321
|
+
trace: records
|
|
4322
|
+
};
|
|
4323
|
+
}
|
|
4324
|
+
/**
|
|
4325
|
+
* The resources of the last bind(), or E_NOT_LOADED before it.
|
|
4326
|
+
* @returns the resources
|
|
4327
|
+
*/
|
|
4328
|
+
requireResources() {
|
|
4329
|
+
if (this.resources === null) {
|
|
4330
|
+
throw new WebGpuGraphError("E_NOT_LOADED", "the ForceAtlas2 model has not been bound (load() first)", {
|
|
4331
|
+
state: "created"
|
|
4332
|
+
});
|
|
4333
|
+
}
|
|
4334
|
+
return this.resources;
|
|
4335
|
+
}
|
|
4336
|
+
/**
|
|
4337
|
+
* The index of a stage name in FA2_STAGES, or E_INVALID_ARGUMENT.
|
|
4338
|
+
* @param upTo - the stage name
|
|
4339
|
+
* @returns its index
|
|
4340
|
+
*/
|
|
4341
|
+
stageIndex(upTo) {
|
|
4342
|
+
for (let i = 0; i < FA2_STAGES.length; i++) {
|
|
4343
|
+
if (FA2_STAGES[i] === upTo) {
|
|
4344
|
+
return i;
|
|
4345
|
+
}
|
|
4346
|
+
}
|
|
4347
|
+
throw invalid("upTo", upTo, FA2_STAGES.join(" | "));
|
|
4348
|
+
}
|
|
4349
|
+
/**
|
|
4350
|
+
* Drops the bind groups of the previous bind() (the buffers changed) so the cached kernels do not accumulate stale
|
|
4351
|
+
* groups across reloads; K3 / K4 live inside RepulsionExact and keep the P1-T6 behaviour. Also forgets the pass
|
|
4352
|
+
* of a batch recorded before the rebind.
|
|
4353
|
+
*/
|
|
4354
|
+
dropBound() {
|
|
4355
|
+
this.openPass = null;
|
|
4356
|
+
const { bound } = this;
|
|
4357
|
+
if (bound === null) {
|
|
4358
|
+
return;
|
|
4359
|
+
}
|
|
4360
|
+
for (const kernel of [bound.k1, bound.k2, bound.k5, bound.toScene, bound.fill]) {
|
|
4361
|
+
kernel.invalidate();
|
|
4362
|
+
}
|
|
4363
|
+
this.bound = null;
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4366
|
+
function resolvePatch(patch, current) {
|
|
4367
|
+
return resolveForceAtlas2Options(patch, resolveForceAtlas2Options(current));
|
|
4368
|
+
}
|
|
4369
|
+
function createForceAtlas2(ctx, options) {
|
|
4370
|
+
ctx.assertReady();
|
|
4371
|
+
const resolved = resolveForceAtlas2Options(options);
|
|
4372
|
+
const tuning = resolveLayoutTuning(options);
|
|
4373
|
+
const model = new ForceAtlas2Model(tuning, resolved);
|
|
4374
|
+
return new ForceSimulation(ctx, model, resolved, tuning, resolvePatch);
|
|
4375
|
+
}
|
|
4376
|
+
function copyBetweenness(defaults) {
|
|
4377
|
+
const copy = { ...defaults };
|
|
4378
|
+
if (defaults.sources !== void 0) {
|
|
4379
|
+
copy.sources = Object.freeze([...defaults.sources]);
|
|
4380
|
+
}
|
|
4381
|
+
return Object.freeze(copy);
|
|
4382
|
+
}
|
|
4383
|
+
function copyAlgorithms(algorithms) {
|
|
4384
|
+
const copy = { ...algorithms };
|
|
4385
|
+
if (algorithms.betweenness !== void 0) {
|
|
4386
|
+
copy.betweenness = copyBetweenness(algorithms.betweenness);
|
|
4387
|
+
}
|
|
4388
|
+
return Object.freeze(copy);
|
|
4389
|
+
}
|
|
4390
|
+
function freezeOptions(options) {
|
|
4391
|
+
if (options === void 0) {
|
|
4392
|
+
return Object.freeze({});
|
|
4393
|
+
}
|
|
4394
|
+
const copy = { ...options };
|
|
4395
|
+
if (options.layout !== void 0) {
|
|
4396
|
+
copy.layout = Object.freeze({ ...options.layout });
|
|
4397
|
+
}
|
|
4398
|
+
if (options.algorithms !== void 0) {
|
|
4399
|
+
copy.algorithms = copyAlgorithms(options.algorithms);
|
|
4400
|
+
}
|
|
4401
|
+
return Object.freeze(copy);
|
|
4402
|
+
}
|
|
4403
|
+
function createAccelerator(ctx, options) {
|
|
4404
|
+
ctx.assertReady();
|
|
4405
|
+
const frozen = freezeOptions(options);
|
|
4406
|
+
return {
|
|
4407
|
+
kind: "webgpu",
|
|
4408
|
+
ctx,
|
|
4409
|
+
options: frozen,
|
|
4410
|
+
/**
|
|
4411
|
+
* The ForceAtlas2 simulation with this accelerator's layout tuning (spec 3.3; contract 3.14).
|
|
4412
|
+
* @param o - the CPU option type (spec 9.3 ForceAtlas2Options); GPU tuning keys come from `options.layout`
|
|
4413
|
+
* @returns a fresh simulation in state "created"
|
|
4414
|
+
*/
|
|
4415
|
+
forceAtlas2(o) {
|
|
4416
|
+
ctx.assertReady();
|
|
4417
|
+
return createForceAtlas2(ctx, { ...o, ...frozen.layout });
|
|
4418
|
+
},
|
|
4419
|
+
/**
|
|
4420
|
+
* Destroys every device buffer recorded for the snapshot (spec 4.5); delegates to ctx.release.
|
|
4421
|
+
* @param s - the snapshot the app is done with
|
|
4422
|
+
*/
|
|
4423
|
+
release(s) {
|
|
4424
|
+
ctx.release(s);
|
|
4425
|
+
},
|
|
4426
|
+
/**
|
|
4427
|
+
* Disposes the context (spec 2.8); delegates to ctx.dispose, idempotent.
|
|
4428
|
+
*/
|
|
4429
|
+
dispose() {
|
|
4430
|
+
ctx.dispose();
|
|
4431
|
+
}
|
|
4432
|
+
};
|
|
4433
|
+
}
|
|
4434
|
+
export {
|
|
4435
|
+
A as ARC_WINDOW_ALIGN,
|
|
4436
|
+
E as EXACT_MAX_NODES,
|
|
4437
|
+
FA2_DEFAULTS,
|
|
4438
|
+
G as GpuContext,
|
|
4439
|
+
LAYOUT_TUNING_DEFAULTS,
|
|
4440
|
+
MAX_1D_ITEMS,
|
|
4441
|
+
MAX_WORKGROUPS_PER_DIM,
|
|
4442
|
+
f as PASSTHROUGH_FORMAT_CODES,
|
|
4443
|
+
g as STORAGE_ALIGN,
|
|
4444
|
+
j as WORKGROUP_SIZE,
|
|
4445
|
+
WebGpuGraphError,
|
|
4446
|
+
createAccelerator,
|
|
4447
|
+
createForceAtlas2,
|
|
4448
|
+
degree,
|
|
4449
|
+
hasErrorCode,
|
|
4450
|
+
k as isSoftwareAdapter,
|
|
4451
|
+
isWebGpuGraphError,
|
|
4452
|
+
seedPositions
|
|
4453
|
+
};
|
|
4454
|
+
//# sourceMappingURL=webgpu-graph-algorithms.js.map
|