concept-collection / turing-sphere
Delete the TypeScript solver; the .m models are the only implementation
src/solver/ held a second implementation of the same IMEX scheme, kept as the test oracle after the algorithm moved into models/*.m. It is gone. The app, the desktop benchmark and the tests now all compile and run the same MATLAB. Removing the twin removes the oracle, so correctness moves to closed-form answers, which is a stronger check anyway — two implementations agreeing only shows they share assumptions. test/analyticChecks.ts runs three cases whose evolution is known exactly, through the whole real pipeline: A a linear reaction leaves every spherical-harmonic mode independent with a known per-degree growth factor, pinning the transform round trip, the eigenvalue mapping, the IMEX update and the state feedback at once (agrees to ~2e-7 over 20 steps, no leakage between modes) B a nonlinear reaction on a uniform field is exactly the scalar ODE map (1.5e-8 over 25 steps) C a 1e-6 perturbation of the Schnakenberg fixed point follows the linearized 2x2 recurrence, and (l=24, m=7) is confirmed unstable test/models/{linear,logistic}.m exist for those. test/modelChecks.ts compiles every app model and asserts its kernel count, guarding the fusion that is otherwise invisible. test/transformChecks.ts keeps the one comparison where a second implementation is still right: WGSL transforms against shtns-webgpu's f64 CPU twin. All three modules run in both Node and the browser, so the two GPU stacks get the same guarantees. Running the .m outside a browser needed a bundler's module resolution, since numbl's sources import each other as ./foo.js while the files are .ts, and registry.ts loads models through ?raw. vite-node provides both, so the benchmark, the node tests and the long run go through it; scripts/bench.mjs, which existed only to cope with older Node's type stripping, is gone. That finally makes the benchmark measure what the app runs, and answers what keeping state in GPU buffers is worth. At lmax 31 on an Intel Xe via Dawn: 0.25 ms/step, against 3.01 ms/step for the deleted TypeScript solver on the same machine — ~12x, nearly all of it the four per-step readbacks that version paid. CI's software rasterizer shows no such gap, which is why this was unverified until now: the saving is on driver round-trips, so it only appears once the GPU is fast. Also here: ModelSession ties grid, transforms, compiled .m and seeded state together so all three callers drive a model identically; parameter metadata and presets move into src/mgpu/registry.ts, since the host owns them; gridForLmax joins the other grid rules in sht/layout.ts, makeRandn becomes mgpu/noise.ts, describeAdapter sits with requestShtDevice. A call to a helper function defined in a model now reports that only init and step are compiled, rather than complaining that it is not element-wise. The node suite needs a GPU where the old CPU-solver one did not, so CI passes --skip-without-gpu and the browser suite covers those checks there.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 35d91fae9dbb parent c6376de Browse files
28 changed files+2056−1271
.github/workflows/ci.ymlmodified+3−1View file
@@ -32,7 +32,9 @@ jobs:
3232 # --ignore-scripts: npm runs a linked package's `prepare` script, and
3333 # numbl's is husky, which is not installed here.
3434 - run: npm ci --ignore-scripts
35- - run: npm run test:node
35+ # The checks compile MATLAB to compute shaders, so they need a GPU; the
36+ # browser suite below runs the same modules on SwiftShader if there is none.
37+ - run: npm run test:node -- --skip-without-gpu
3638 # headless Chrome + SwiftShader software WebGPU
3739 - run: npm run test:gpu
3840 env:
.github/workflows/deploy.ymlmodified+3−1View file
@@ -44,7 +44,9 @@ jobs:
4444 # --ignore-scripts: npm runs a linked package's `prepare` script, and
4545 # numbl's is husky, which is not installed here.
4646 - run: npm ci --ignore-scripts
47- - run: npm run test:node
47+ # The checks compile MATLAB to compute shaders, so they need a GPU; the
48+ # browser suite below runs the same modules on SwiftShader if there is none.
49+ - run: npm run test:node -- --skip-without-gpu
4850 - run: npm run build
4951 # Pages must already be enabled with "GitHub Actions" as the source; the
5052 # workflow token cannot create the site itself (`enablement: true` fails
README.mdmodified+114−61View file
@@ -113,17 +113,20 @@ compute, so this port swaps in:
113113 [figpack](https://github.com/flatironinstitute/figpack)'s experimental
114114 extension package ([`src/render/`](src/render/)).
115115 - **Solver:** the MATLAB stayed MATLAB. [`models/`](models/) holds the IMEX loop
116- as `.m` files, executed on the GPU by [`src/mgpu/`](src/mgpu/).
116+ as `.m` files, executed on the GPU by [`src/mgpu/`](src/mgpu/). There is no
117+ second implementation: the app, the desktop benchmark and the tests all compile
118+ and run the same `.m`.
117119
118-[`src/solver/`](src/solver/) still holds the TypeScript port of the same loop. The
119-app no longer runs it, but it is an *independent* implementation of the scheme,
120-which makes it the test oracle: `npm run test:gpu` runs both from the same seeded
121-perturbation through the same transforms and compares. It is also where parameter
122-metadata (names, defaults, slider ranges) lives, so the two paths cannot be
123-configured differently.
120+An earlier version of this repo carried a TypeScript port of the loop alongside
121+the `.m`, and used it as the test oracle. That is gone. Two implementations
122+agreeing only shows they share assumptions, so the `.m` path is now checked
123+against closed-form answers instead — see [Tests](#tests). The one place a second
124+implementation is still the right oracle is the transforms themselves, where
125+[`src/sht/reference.ts`](src/sht/reference.ts) is shtns-webgpu's own f64
126+direct-summation twin.
124127
125-Because the algorithm is now compiled to compute shaders, **WebGPU is required** —
126-there is no CPU fallback in the app (the f64 CPU transform remains, for tests).
128+Because the algorithm is compiled to compute shaders, **WebGPU is required** —
129+there is no CPU fallback (the f64 CPU transform remains, for tests).
127130
128131 ## Numerics
129132
@@ -140,12 +143,12 @@ there is no CPU fallback in the app (the f64 CPU transform remains, for tests).
140143 ## Desktop vs browser
141144
142145 How much does running this in a browser cost? [`scripts/bench.ts`](scripts/bench.ts)
143-runs the reference solver — same WGSL transforms, same parameters — from Node on
144-desktop WebGPU (Google Dawn), and the app prints the command line that
145-reproduces whatever it is currently simulating:
146+runs the *same* thing — same `.m`, lowered by numbl into the same WGSL kernels,
147+over the same transforms — from Node on desktop WebGPU (Google Dawn), and the app
148+prints the command line that reproduces whatever it is currently simulating:
146149
147150 ```
148-node scripts/bench.mjs --preset schnak-spots --lmax 63 --backend webgpu --steps 2000 \
151+npm run bench -- --preset schnak-spots --lmax 63 --steps 2000 \
149152 --seed 1 --a 0.1 --b 0.9 --D1 0.0004 --D2 0.008 --dt 0.05
150153 ```
151154
@@ -153,60 +156,111 @@ Copy it from under the stats line, run it, and compare the `ms/step` it reports
153156 with the app's. Both sides go through the one shared
154157 [`src/bench/runSpec.ts`](src/bench/runSpec.ts) — the app formats a run into that
155158 command, the benchmark parses it back — so there is no second copy of the
156-defaults for the two runs to drift apart on. Node runs the TypeScript sources
157-directly, so `src/` is literally the same code in both places, down to the
158-device request in `requestShtDevice()` (Dawn is installed under `navigator.gpu`
159-and the WebGPU globals, and the rest runs unchanged).
159+defaults for the two runs to drift apart on. Both then go through the same
160+[`ModelSession`](src/mgpu/session.ts), down to the device request in
161+`requestShtDevice()` (Dawn is installed under `navigator.gpu` and the WebGPU
162+globals, and the rest runs unchanged).
163+
164+The benchmark runs under `vite-node`, which is what resolves numbl's compiler
165+sources and the `?raw` model imports — plain Node cannot (see
166+[The numbl dependency](#the-numbl-dependency)).
167+
168+It reports two numbers, because they answer different questions:
169+
170+```
171+ 0.54 ms/step 1857.5 steps/s 92.87 model time/s (batches of 16)
172+ one step per submit: 0.74 ms mean · median 0.60 · p05 0.51 · p95 1.29 · min 0.50
173+```
174+
175+The first is throughput: a batch of steps submitted together and awaited once,
176+which is how the app runs and what keeping the state in GPU buffers is for. The
177+second is per-step latency, one submit each — comparable to a design that
178+synchronises every step, and the only way to get a distribution.
179+
180+**What the GPU-resident design is worth.** At lmax 31 on an Intel Xe (Mesa, via
181+Dawn) this path runs at **0.25 ms/step**, against **3.01 ms/step** for the
182+TypeScript solver this repo used to carry — same machine, same transforms, same
183+parameters. A **~12x** difference, and almost all of it is the four per-step
184+buffer readbacks that version paid and this one does not. Note that CI, which
185+only has a software rasterizer, shows no such gap: there the transforms dominate
186+and both designs land within ~10% of each other. The saving is real but it is a
187+saving on driver round-trips, so it only appears once the GPU is fast.
160188
161189 Desktop WebGPU comes from the `webgpu` package (prebuilt Dawn, ~70 MB), listed
162190 as an optional dependency so that a platform it has no binaries for fails the
163191 install of that package alone rather than the whole tree. `npm install` picks it
164-up; without it, only `--backend cpu` runs and the benchmark says so. Those
165-binaries need glibc 2.29+, which rules out older cluster images (RHEL/Rocky 8 is
166-2.28) unless you run inside a container with a newer base. Other
167-flags: `--steps`, `--warmup`, `--json`, `--help`; `DAWN_FLAGS='backend=vulkan'`
168-(`;`-separated) passes Dawn options through, e.g. to pick a backend or to
169-compare against Dawn's own software adapter.
192+up; without it there is no desktop GPU to run on and the benchmark says so.
193+Those binaries need glibc 2.29+, which rules out older cluster images
194+(RHEL/Rocky 8 is 2.28) unless you run inside a container with a newer base. Other
195+flags: `--steps`, `--warmup`, `--batch`, `--json`, `--help`;
196+`DAWN_FLAGS='backend=vulkan'` (`;`-separated) passes Dawn options through, e.g. to
197+pick a backend or to compare against Dawn's own software adapter.
170198
171199 What the comparison does and does not control for:
172200
173-- **it is not the same solver.** The benchmark runs the TypeScript reference; the
174- app runs the `.m` compiled to WGSL. Node cannot load numbl's TypeScript sources
175- (its internal imports are extensionless-`.js`, which needs a bundler's
176- resolution), so the `.m` path is browser-only for now. Same scheme, same
177- transforms, same parameters — but the reaction and the IMEX update happen in f64
178- on the CPU there and in fp32 on the GPU here.
179201 - the benchmark is **solver only**; the app's `ms/step` includes the per-frame
180- readback amortized over the step batch. For a browser number with no rendering,
181- open `test.html?soak=2000&lmax=63` (that soak also runs the reference solver).
182-- the reference pays a buffer readback on *every* transform — four driver
183- round-trips per step — so it measures submit-and-map latency more than
184- arithmetic. The `.m` path keeps everything in GPU buffers and submits once per
185- batch, which is where its advantage should come from. On the software rasterizer
186- in CI the transforms dominate and the two come out within ~10% of each other;
187- the gap on real hardware is untested.
202+ readback amortized over its step batch. For a browser number with no rendering,
203+ open `test.html?soak=2000&lmax=63`.
188204 - the browser adds its own GPU-process boundary and, for a page that is not
189205 cross-origin isolated, coarser timers.
206+- both sides are fp32 throughout, on the same generated kernels, so nothing here
207+ is a numerics comparison — only a cost one.
190208
191209 ## Tests
192210
193-- `npm run bench -- --help` — the desktop benchmark above (see
211+There is no second implementation of the solver to diff against, so the `.m` path
212+is checked against **closed-form answers**. Each case is one whose evolution is
213+known exactly, run through the whole real pipeline — MATLAB source, numbl
214+lowering, generated WGSL, GPU transforms — and compared with arithmetic
215+([`test/analyticChecks.ts`](test/analyticChecks.ts)):
216+
217+- **A** — a linear reaction `f(u) = c*u` leaves every spherical-harmonic mode
218+ independent, growing by exactly `(1 + dt*c) / (1 + dt*D*l(l+1))` per step. This
219+ pins the transform round-trip, the eigenvalue mapping, the IMEX update and the
220+ state feedback at once, and checks that nothing leaks between modes. Agrees to
221+ ~2e-7 over 20 steps.
222+- **B** — a nonlinear reaction on a *uniform* field stays uniform and diffusion
223+ cannot touch it, so each step is exactly the scalar ODE map. Agrees to 1.5e-8
224+ over 25 steps. Checks that a generated kernel evaluates a nonlinear reaction.
225+- **C** — a 1e-6 perturbation of the Schnakenberg fixed point follows the
226+ linearized 2x2 IMEX recurrence, and the `(l=24, m=7)` mode is confirmed
227+ unstable. Looser (~2e-3) because fp32 keeps only about four digits of a
228+ perturbation that small.
229+
230+Two test models exist only for this: [`test/models/linear.m`](test/models/linear.m)
231+and [`test/models/logistic.m`](test/models/logistic.m).
232+
233+Alongside those, [`test/modelChecks.ts`](test/modelChecks.ts) compiles every model
234+the app offers and asserts **how many kernels it compiles to**. That is a fusion
235+guard: numbl's lowering emits one statement per *operator* and its inline pass
236+folds them back into per-line expression trees, and if that stops happening the
237+results stay correct while every operator becomes its own dispatch. It is
238+invisible in the numbers, so it is asserted directly. (It has already caught one
239+regression.)
240+
241+[`test/transformChecks.ts`](test/transformChecks.ts) is the one remaining
242+implementation-vs-implementation check, comparing the WGSL transforms against
243+shtns-webgpu's f64 CPU twin.
244+
245+All three modules run in **both** environments, so the two GPU stacks get the same
246+guarantees:
247+
248+- `npm run test:node` — under Dawn on the desktop, via `vite-node`. Needs a GPU;
249+ pass `--skip-without-gpu` to let a machine without one say so and move on
250+ (which is what CI does, since the browser suite covers the same modules).
251+- `npm run test:gpu` — builds and drives headless Chrome, on SwiftShader in CI.
252+ Also runs the soak.
253+
254+Other commands:
255+
256+- `npm run bench -- --help` — the desktop benchmark (see
194257 [Desktop vs browser](#desktop-vs-browser)).
195-- `npm run test:node` — f64 solver correctness in Node: exact single-mode
196- linear recurrence, exact uniform-state reaction ODE, and the linearized
197- Turing-mode 2×2 IMEX recurrence (all at ~1e-12).
198-- `npm run test:gpu` — builds and drives headless Chrome: GPU-vs-CPU transform
199- and solver cross-checks, a 100-step stability run, and then for every `.m`
200- model: that it compiles, that its element-wise lines each fuse into exactly one
201- kernel, and that 10 steps agree with the reference solver from the same seed
202- (they agree to ~1e-7 relative L2 — fp32 round-off).
203-- `node scripts/longrun-node.ts` — CPU run to t = 100 confirming pattern
204- saturation.
205-- `node scripts/soak.mjs [steps] [lmax] [backend]` — drive the demo for many
206- steps, sampling JS heap and catching crashes. A 900-step run at lmax 63 on
207- software WebGPU (SwiftShader) completes with a flat ~4 MB heap.
208-- `node scripts/screenshot.mjs out.png [light|dark] [minSteps]` — screenshot
209- the demo after a number of steps.
258+- `npx vite-node scripts/longrun-node.ts [lmax]` — run to t = 100 and confirm the
259+ pattern saturates into O(1)-contrast spots rather than decaying or diverging.
260+- `node scripts/soak.mjs [steps] [lmax]` — drive the demo for many steps,
261+ sampling JS heap and catching crashes.
262+- `node scripts/screenshot.mjs out.png [light|dark] [minSteps]` — screenshot the
263+ demo after a number of steps.
210264 - `node scripts/check-live.mjs [url]` — smoke-check a deployed URL in a real
211265 browser: load, press Run, confirm the solver advances.
212266 - `test.html?soak=<steps>&lmax=<n>` — solver-only soak with no rendering.
@@ -258,14 +312,13 @@ that had none of numbl's own dependencies installed:
258312 - **the install must pass `--ignore-scripts`.** npm runs a linked package's
259313 `prepare` script, and numbl's is `husky`, which is not installed in CI.
260314
261-The `.ts` entry points under `scripts/` are run by Node directly, which strips
262-types without being asked only from Node 22.18 / 23.6 / 24 on. Everything here
263-works back to 22.6, where stripping exists but is flagged: the npm scripts pass
264-`--experimental-strip-types` themselves, and the benchmark — the one command
265-that gets copied to other machines — goes through
266-[`scripts/bench.mjs`](scripts/bench.mjs), which re-runs itself with the flag
267-when it has to. Invoking a `scripts/*.ts` file by hand on 22.6–22.17 needs the
268-flag spelled out.
315+The `scripts/*.ts` entry points that touch the compiler (the benchmark, the node
316+tests, the long run) go through `vite-node`, so they resolve imports exactly as the
317+browser build does — the `numbl-src` alias and the `?raw` model imports included.
318+Plain `node` cannot: numbl's sources import each other as `./foo.js` while the
319+files are `.ts`, which needs a bundler's resolution. Scripts that do not touch the
320+compiler (`soak.mjs`, `screenshot.mjs`, `check-live.mjs`, `test-gpu.mjs`) are plain
321+`.mjs` and run under `node` directly.
269322
270323 Deployed to GitHub Pages by `.github/workflows/deploy.yml` on push to `main`.
271324
package-lock.jsonmodified+903−2View file
@@ -18,7 +18,8 @@
1818 "@webgpu/types": "^0.1.44",
1919 "puppeteer-core": "^23.0.0",
2020 "typescript": "^5.5.0",
21- "vite": "^5.4.0"
21+ "vite": "^5.4.0",
22+ "vite-node": "^6.0.0"
2223 },
2324 "engines": {
2425 "node": ">=22.6"
@@ -110,6 +111,43 @@
110111 "dev": true,
111112 "license": "Apache-2.0"
112113 },
114+ "node_modules/@emnapi/core": {
115+ "version": "2.0.0-alpha.3",
116+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
117+ "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
118+ "dev": true,
119+ "license": "MIT",
120+ "optional": true,
121+ "peer": true,
122+ "dependencies": {
123+ "@emnapi/wasi-threads": "2.0.1",
124+ "tslib": "^2.4.0"
125+ }
126+ },
127+ "node_modules/@emnapi/runtime": {
128+ "version": "2.0.0-alpha.3",
129+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
130+ "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
131+ "dev": true,
132+ "license": "MIT",
133+ "optional": true,
134+ "peer": true,
135+ "dependencies": {
136+ "tslib": "^2.4.0"
137+ }
138+ },
139+ "node_modules/@emnapi/wasi-threads": {
140+ "version": "2.0.1",
141+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
142+ "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
143+ "dev": true,
144+ "license": "MIT",
145+ "optional": true,
146+ "peer": true,
147+ "dependencies": {
148+ "tslib": "^2.4.0"
149+ }
150+ },
113151 "node_modules/@esbuild/aix-ppc64": {
114152 "version": "0.21.5",
115153 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -501,6 +539,38 @@
501539 "node": ">=12"
502540 }
503541 },
542+ "node_modules/@napi-rs/wasm-runtime": {
543+ "version": "1.2.0",
544+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz",
545+ "integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==",
546+ "dev": true,
547+ "license": "MIT",
548+ "optional": true,
549+ "dependencies": {
550+ "@tybys/wasm-util": "^0.10.3"
551+ },
552+ "engines": {
553+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
554+ },
555+ "funding": {
556+ "type": "github",
557+ "url": "https://github.com/sponsors/Brooooooklyn"
558+ },
559+ "peerDependencies": {
560+ "@emnapi/core": "^2.0.0-alpha.3",
561+ "@emnapi/runtime": "^2.0.0-alpha.3"
562+ }
563+ },
564+ "node_modules/@oxc-project/types": {
565+ "version": "0.139.0",
566+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
567+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
568+ "dev": true,
569+ "license": "MIT",
570+ "funding": {
571+ "url": "https://github.com/sponsors/Boshen"
572+ }
573+ },
504574 "node_modules/@puppeteer/browsers": {
505575 "version": "2.6.1",
506576 "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz",
@@ -524,6 +594,322 @@
524594 "node": ">=18"
525595 }
526596 },
597+ "node_modules/@rolldown/binding-android-arm64": {
598+ "version": "1.1.5",
599+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
600+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
601+ "cpu": [
602+ "arm64"
603+ ],
604+ "dev": true,
605+ "license": "MIT",
606+ "optional": true,
607+ "os": [
608+ "android"
609+ ],
610+ "engines": {
611+ "node": "^20.19.0 || >=22.12.0"
612+ }
613+ },
614+ "node_modules/@rolldown/binding-darwin-arm64": {
615+ "version": "1.1.5",
616+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
617+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
618+ "cpu": [
619+ "arm64"
620+ ],
621+ "dev": true,
622+ "license": "MIT",
623+ "optional": true,
624+ "os": [
625+ "darwin"
626+ ],
627+ "engines": {
628+ "node": "^20.19.0 || >=22.12.0"
629+ }
630+ },
631+ "node_modules/@rolldown/binding-darwin-x64": {
632+ "version": "1.1.5",
633+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
634+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
635+ "cpu": [
636+ "x64"
637+ ],
638+ "dev": true,
639+ "license": "MIT",
640+ "optional": true,
641+ "os": [
642+ "darwin"
643+ ],
644+ "engines": {
645+ "node": "^20.19.0 || >=22.12.0"
646+ }
647+ },
648+ "node_modules/@rolldown/binding-freebsd-x64": {
649+ "version": "1.1.5",
650+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
651+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
652+ "cpu": [
653+ "x64"
654+ ],
655+ "dev": true,
656+ "license": "MIT",
657+ "optional": true,
658+ "os": [
659+ "freebsd"
660+ ],
661+ "engines": {
662+ "node": "^20.19.0 || >=22.12.0"
663+ }
664+ },
665+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
666+ "version": "1.1.5",
667+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
668+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
669+ "cpu": [
670+ "arm"
671+ ],
672+ "dev": true,
673+ "license": "MIT",
674+ "optional": true,
675+ "os": [
676+ "linux"
677+ ],
678+ "engines": {
679+ "node": "^20.19.0 || >=22.12.0"
680+ }
681+ },
682+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
683+ "version": "1.1.5",
684+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
685+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
686+ "cpu": [
687+ "arm64"
688+ ],
689+ "dev": true,
690+ "libc": [
691+ "glibc"
692+ ],
693+ "license": "MIT",
694+ "optional": true,
695+ "os": [
696+ "linux"
697+ ],
698+ "engines": {
699+ "node": "^20.19.0 || >=22.12.0"
700+ }
701+ },
702+ "node_modules/@rolldown/binding-linux-arm64-musl": {
703+ "version": "1.1.5",
704+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
705+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
706+ "cpu": [
707+ "arm64"
708+ ],
709+ "dev": true,
710+ "libc": [
711+ "musl"
712+ ],
713+ "license": "MIT",
714+ "optional": true,
715+ "os": [
716+ "linux"
717+ ],
718+ "engines": {
719+ "node": "^20.19.0 || >=22.12.0"
720+ }
721+ },
722+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
723+ "version": "1.1.5",
724+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
725+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
726+ "cpu": [
727+ "ppc64"
728+ ],
729+ "dev": true,
730+ "libc": [
731+ "glibc"
732+ ],
733+ "license": "MIT",
734+ "optional": true,
735+ "os": [
736+ "linux"
737+ ],
738+ "engines": {
739+ "node": "^20.19.0 || >=22.12.0"
740+ }
741+ },
742+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
743+ "version": "1.1.5",
744+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
745+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
746+ "cpu": [
747+ "s390x"
748+ ],
749+ "dev": true,
750+ "libc": [
751+ "glibc"
752+ ],
753+ "license": "MIT",
754+ "optional": true,
755+ "os": [
756+ "linux"
757+ ],
758+ "engines": {
759+ "node": "^20.19.0 || >=22.12.0"
760+ }
761+ },
762+ "node_modules/@rolldown/binding-linux-x64-gnu": {
763+ "version": "1.1.5",
764+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
765+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
766+ "cpu": [
767+ "x64"
768+ ],
769+ "dev": true,
770+ "libc": [
771+ "glibc"
772+ ],
773+ "license": "MIT",
774+ "optional": true,
775+ "os": [
776+ "linux"
777+ ],
778+ "engines": {
779+ "node": "^20.19.0 || >=22.12.0"
780+ }
781+ },
782+ "node_modules/@rolldown/binding-linux-x64-musl": {
783+ "version": "1.1.5",
784+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
785+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
786+ "cpu": [
787+ "x64"
788+ ],
789+ "dev": true,
790+ "libc": [
791+ "musl"
792+ ],
793+ "license": "MIT",
794+ "optional": true,
795+ "os": [
796+ "linux"
797+ ],
798+ "engines": {
799+ "node": "^20.19.0 || >=22.12.0"
800+ }
801+ },
802+ "node_modules/@rolldown/binding-openharmony-arm64": {
803+ "version": "1.1.5",
804+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
805+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
806+ "cpu": [
807+ "arm64"
808+ ],
809+ "dev": true,
810+ "license": "MIT",
811+ "optional": true,
812+ "os": [
813+ "openharmony"
814+ ],
815+ "engines": {
816+ "node": "^20.19.0 || >=22.12.0"
817+ }
818+ },
819+ "node_modules/@rolldown/binding-wasm32-wasi": {
820+ "version": "1.1.5",
821+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
822+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
823+ "cpu": [
824+ "wasm32"
825+ ],
826+ "dev": true,
827+ "license": "MIT",
828+ "optional": true,
829+ "dependencies": {
830+ "@emnapi/core": "1.11.1",
831+ "@emnapi/runtime": "1.11.1",
832+ "@napi-rs/wasm-runtime": "^1.1.6"
833+ },
834+ "engines": {
835+ "node": "^20.19.0 || >=22.12.0"
836+ }
837+ },
838+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
839+ "version": "1.11.1",
840+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
841+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
842+ "dev": true,
843+ "license": "MIT",
844+ "optional": true,
845+ "dependencies": {
846+ "@emnapi/wasi-threads": "1.2.2",
847+ "tslib": "^2.4.0"
848+ }
849+ },
850+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
851+ "version": "1.11.1",
852+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
853+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
854+ "dev": true,
855+ "license": "MIT",
856+ "optional": true,
857+ "dependencies": {
858+ "tslib": "^2.4.0"
859+ }
860+ },
861+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
862+ "version": "1.2.2",
863+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
864+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
865+ "dev": true,
866+ "license": "MIT",
867+ "optional": true,
868+ "dependencies": {
869+ "tslib": "^2.4.0"
870+ }
871+ },
872+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
873+ "version": "1.1.5",
874+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
875+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
876+ "cpu": [
877+ "arm64"
878+ ],
879+ "dev": true,
880+ "license": "MIT",
881+ "optional": true,
882+ "os": [
883+ "win32"
884+ ],
885+ "engines": {
886+ "node": "^20.19.0 || >=22.12.0"
887+ }
888+ },
889+ "node_modules/@rolldown/binding-win32-x64-msvc": {
890+ "version": "1.1.5",
891+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
892+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
893+ "cpu": [
894+ "x64"
895+ ],
896+ "dev": true,
897+ "license": "MIT",
898+ "optional": true,
899+ "os": [
900+ "win32"
901+ ],
902+ "engines": {
903+ "node": "^20.19.0 || >=22.12.0"
904+ }
905+ },
906+ "node_modules/@rolldown/pluginutils": {
907+ "version": "1.0.1",
908+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
909+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
910+ "dev": true,
911+ "license": "MIT"
912+ },
527913 "node_modules/@rollup/rollup-android-arm-eabi": {
528914 "version": "4.62.3",
529915 "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz",
@@ -927,7 +1313,18 @@
9271313 "dev": true,
9281314 "license": "MIT"
9291315 },
930- "node_modules/@types/estree": {
1316+ "node_modules/@tybys/wasm-util": {
1317+ "version": "0.10.3",
1318+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
1319+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
1320+ "dev": true,
1321+ "license": "MIT",
1322+ "optional": true,
1323+ "dependencies": {
1324+ "tslib": "^2.4.0"
1325+ }
1326+ },
1327+ "node_modules/@types/estree": {
9311328 "version": "1.0.9",
9321329 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
9331330 "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
@@ -1206,6 +1603,16 @@
12061603 "node": "*"
12071604 }
12081605 },
1606+ "node_modules/cac": {
1607+ "version": "7.0.0",
1608+ "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz",
1609+ "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==",
1610+ "dev": true,
1611+ "license": "MIT",
1612+ "engines": {
1613+ "node": ">=20.19.0"
1614+ }
1615+ },
12091616 "node_modules/chromium-bidi": {
12101617 "version": "0.11.0",
12111618 "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz",
@@ -1298,6 +1705,16 @@
12981705 "node": ">= 14"
12991706 }
13001707 },
1708+ "node_modules/detect-libc": {
1709+ "version": "2.1.2",
1710+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
1711+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
1712+ "dev": true,
1713+ "license": "Apache-2.0",
1714+ "engines": {
1715+ "node": ">=8"
1716+ }
1717+ },
13011718 "node_modules/devtools-protocol": {
13021719 "version": "0.0.1367902",
13031720 "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz",
@@ -1322,6 +1739,13 @@
13221739 "once": "^1.4.0"
13231740 }
13241741 },
1742+ "node_modules/es-module-lexer": {
1743+ "version": "2.3.1",
1744+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
1745+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
1746+ "dev": true,
1747+ "license": "MIT"
1748+ },
13251749 "node_modules/esbuild": {
13261750 "version": "0.21.5",
13271751 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1475,6 +1899,24 @@
14751899 "pend": "~1.2.0"
14761900 }
14771901 },
1902+ "node_modules/fdir": {
1903+ "version": "6.5.0",
1904+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1905+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1906+ "dev": true,
1907+ "license": "MIT",
1908+ "engines": {
1909+ "node": ">=12.0.0"
1910+ },
1911+ "peerDependencies": {
1912+ "picomatch": "^3 || ^4"
1913+ },
1914+ "peerDependenciesMeta": {
1915+ "picomatch": {
1916+ "optional": true
1917+ }
1918+ }
1919+ },
14781920 "node_modules/fflate": {
14791921 "version": "0.8.3",
14801922 "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
@@ -1607,6 +2049,279 @@
16072049 "node": ">=8"
16082050 }
16092051 },
2052+ "node_modules/lightningcss": {
2053+ "version": "1.33.0",
2054+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
2055+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
2056+ "dev": true,
2057+ "license": "MPL-2.0",
2058+ "dependencies": {
2059+ "detect-libc": "^2.0.3"
2060+ },
2061+ "engines": {
2062+ "node": ">= 12.0.0"
2063+ },
2064+ "funding": {
2065+ "type": "opencollective",
2066+ "url": "https://opencollective.com/parcel"
2067+ },
2068+ "optionalDependencies": {
2069+ "lightningcss-android-arm64": "1.33.0",
2070+ "lightningcss-darwin-arm64": "1.33.0",
2071+ "lightningcss-darwin-x64": "1.33.0",
2072+ "lightningcss-freebsd-x64": "1.33.0",
2073+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
2074+ "lightningcss-linux-arm64-gnu": "1.33.0",
2075+ "lightningcss-linux-arm64-musl": "1.33.0",
2076+ "lightningcss-linux-x64-gnu": "1.33.0",
2077+ "lightningcss-linux-x64-musl": "1.33.0",
2078+ "lightningcss-win32-arm64-msvc": "1.33.0",
2079+ "lightningcss-win32-x64-msvc": "1.33.0"
2080+ }
2081+ },
2082+ "node_modules/lightningcss-android-arm64": {
2083+ "version": "1.33.0",
2084+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
2085+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
2086+ "cpu": [
2087+ "arm64"
2088+ ],
2089+ "dev": true,
2090+ "license": "MPL-2.0",
2091+ "optional": true,
2092+ "os": [
2093+ "android"
2094+ ],
2095+ "engines": {
2096+ "node": ">= 12.0.0"
2097+ },
2098+ "funding": {
2099+ "type": "opencollective",
2100+ "url": "https://opencollective.com/parcel"
2101+ }
2102+ },
2103+ "node_modules/lightningcss-darwin-arm64": {
2104+ "version": "1.33.0",
2105+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
2106+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
2107+ "cpu": [
2108+ "arm64"
2109+ ],
2110+ "dev": true,
2111+ "license": "MPL-2.0",
2112+ "optional": true,
2113+ "os": [
2114+ "darwin"
2115+ ],
2116+ "engines": {
2117+ "node": ">= 12.0.0"
2118+ },
2119+ "funding": {
2120+ "type": "opencollective",
2121+ "url": "https://opencollective.com/parcel"
2122+ }
2123+ },
2124+ "node_modules/lightningcss-darwin-x64": {
2125+ "version": "1.33.0",
2126+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
2127+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
2128+ "cpu": [
2129+ "x64"
2130+ ],
2131+ "dev": true,
2132+ "license": "MPL-2.0",
2133+ "optional": true,
2134+ "os": [
2135+ "darwin"
2136+ ],
2137+ "engines": {
2138+ "node": ">= 12.0.0"
2139+ },
2140+ "funding": {
2141+ "type": "opencollective",
2142+ "url": "https://opencollective.com/parcel"
2143+ }
2144+ },
2145+ "node_modules/lightningcss-freebsd-x64": {
2146+ "version": "1.33.0",
2147+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
2148+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
2149+ "cpu": [
2150+ "x64"
2151+ ],
2152+ "dev": true,
2153+ "license": "MPL-2.0",
2154+ "optional": true,
2155+ "os": [
2156+ "freebsd"
2157+ ],
2158+ "engines": {
2159+ "node": ">= 12.0.0"
2160+ },
2161+ "funding": {
2162+ "type": "opencollective",
2163+ "url": "https://opencollective.com/parcel"
2164+ }
2165+ },
2166+ "node_modules/lightningcss-linux-arm-gnueabihf": {
2167+ "version": "1.33.0",
2168+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
2169+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
2170+ "cpu": [
2171+ "arm"
2172+ ],
2173+ "dev": true,
2174+ "license": "MPL-2.0",
2175+ "optional": true,
2176+ "os": [
2177+ "linux"
2178+ ],
2179+ "engines": {
2180+ "node": ">= 12.0.0"
2181+ },
2182+ "funding": {
2183+ "type": "opencollective",
2184+ "url": "https://opencollective.com/parcel"
2185+ }
2186+ },
2187+ "node_modules/lightningcss-linux-arm64-gnu": {
2188+ "version": "1.33.0",
2189+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
2190+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
2191+ "cpu": [
2192+ "arm64"
2193+ ],
2194+ "dev": true,
2195+ "libc": [
2196+ "glibc"
2197+ ],
2198+ "license": "MPL-2.0",
2199+ "optional": true,
2200+ "os": [
2201+ "linux"
2202+ ],
2203+ "engines": {
2204+ "node": ">= 12.0.0"
2205+ },
2206+ "funding": {
2207+ "type": "opencollective",
2208+ "url": "https://opencollective.com/parcel"
2209+ }
2210+ },
2211+ "node_modules/lightningcss-linux-arm64-musl": {
2212+ "version": "1.33.0",
2213+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
2214+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
2215+ "cpu": [
2216+ "arm64"
2217+ ],
2218+ "dev": true,
2219+ "libc": [
2220+ "musl"
2221+ ],
2222+ "license": "MPL-2.0",
2223+ "optional": true,
2224+ "os": [
2225+ "linux"
2226+ ],
2227+ "engines": {
2228+ "node": ">= 12.0.0"
2229+ },
2230+ "funding": {
2231+ "type": "opencollective",
2232+ "url": "https://opencollective.com/parcel"
2233+ }
2234+ },
2235+ "node_modules/lightningcss-linux-x64-gnu": {
2236+ "version": "1.33.0",
2237+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
2238+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
2239+ "cpu": [
2240+ "x64"
2241+ ],
2242+ "dev": true,
2243+ "libc": [
2244+ "glibc"
2245+ ],
2246+ "license": "MPL-2.0",
2247+ "optional": true,
2248+ "os": [
2249+ "linux"
2250+ ],
2251+ "engines": {
2252+ "node": ">= 12.0.0"
2253+ },
2254+ "funding": {
2255+ "type": "opencollective",
2256+ "url": "https://opencollective.com/parcel"
2257+ }
2258+ },
2259+ "node_modules/lightningcss-linux-x64-musl": {
2260+ "version": "1.33.0",
2261+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
2262+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
2263+ "cpu": [
2264+ "x64"
2265+ ],
2266+ "dev": true,
2267+ "libc": [
2268+ "musl"
2269+ ],
2270+ "license": "MPL-2.0",
2271+ "optional": true,
2272+ "os": [
2273+ "linux"
2274+ ],
2275+ "engines": {
2276+ "node": ">= 12.0.0"
2277+ },
2278+ "funding": {
2279+ "type": "opencollective",
2280+ "url": "https://opencollective.com/parcel"
2281+ }
2282+ },
2283+ "node_modules/lightningcss-win32-arm64-msvc": {
2284+ "version": "1.33.0",
2285+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
2286+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
2287+ "cpu": [
2288+ "arm64"
2289+ ],
2290+ "dev": true,
2291+ "license": "MPL-2.0",
2292+ "optional": true,
2293+ "os": [
2294+ "win32"
2295+ ],
2296+ "engines": {
2297+ "node": ">= 12.0.0"
2298+ },
2299+ "funding": {
2300+ "type": "opencollective",
2301+ "url": "https://opencollective.com/parcel"
2302+ }
2303+ },
2304+ "node_modules/lightningcss-win32-x64-msvc": {
2305+ "version": "1.33.0",
2306+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
2307+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
2308+ "cpu": [
2309+ "x64"
2310+ ],
2311+ "dev": true,
2312+ "license": "MPL-2.0",
2313+ "optional": true,
2314+ "os": [
2315+ "win32"
2316+ ],
2317+ "engines": {
2318+ "node": ">= 12.0.0"
2319+ },
2320+ "funding": {
2321+ "type": "opencollective",
2322+ "url": "https://opencollective.com/parcel"
2323+ }
2324+ },
16102325 "node_modules/lru-cache": {
16112326 "version": "7.18.3",
16122327 "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
@@ -1671,6 +2386,20 @@
16712386 "resolved": "../../numbl",
16722387 "link": true
16732388 },
2389+ "node_modules/obug": {
2390+ "version": "2.1.4",
2391+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
2392+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
2393+ "dev": true,
2394+ "funding": [
2395+ "https://github.com/sponsors/sxzz",
2396+ "https://opencollective.com/debug"
2397+ ],
2398+ "license": "MIT",
2399+ "engines": {
2400+ "node": ">=12.20.0"
2401+ }
2402+ },
16742403 "node_modules/once": {
16752404 "version": "1.4.0",
16762405 "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1715,6 +2444,13 @@
17152444 "node": ">= 14"
17162445 }
17172446 },
2447+ "node_modules/pathe": {
2448+ "version": "2.0.3",
2449+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
2450+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
2451+ "dev": true,
2452+ "license": "MIT"
2453+ },
17182454 "node_modules/pend": {
17192455 "version": "1.2.0",
17202456 "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -1729,6 +2465,19 @@
17292465 "dev": true,
17302466 "license": "ISC"
17312467 },
2468+ "node_modules/picomatch": {
2469+ "version": "4.0.5",
2470+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
2471+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
2472+ "dev": true,
2473+ "license": "MIT",
2474+ "engines": {
2475+ "node": ">=12"
2476+ },
2477+ "funding": {
2478+ "url": "https://github.com/sponsors/jonschlinkert"
2479+ }
2480+ },
17322481 "node_modules/postcss": {
17332482 "version": "8.5.24",
17342483 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz",
@@ -1834,6 +2583,40 @@
18342583 "node": ">=0.10.0"
18352584 }
18362585 },
2586+ "node_modules/rolldown": {
2587+ "version": "1.1.5",
2588+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
2589+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
2590+ "dev": true,
2591+ "license": "MIT",
2592+ "dependencies": {
2593+ "@oxc-project/types": "=0.139.0",
2594+ "@rolldown/pluginutils": "^1.0.0"
2595+ },
2596+ "bin": {
2597+ "rolldown": "bin/cli.mjs"
2598+ },
2599+ "engines": {
2600+ "node": "^20.19.0 || >=22.12.0"
2601+ },
2602+ "optionalDependencies": {
2603+ "@rolldown/binding-android-arm64": "1.1.5",
2604+ "@rolldown/binding-darwin-arm64": "1.1.5",
2605+ "@rolldown/binding-darwin-x64": "1.1.5",
2606+ "@rolldown/binding-freebsd-x64": "1.1.5",
2607+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
2608+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
2609+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
2610+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
2611+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
2612+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
2613+ "@rolldown/binding-linux-x64-musl": "1.1.5",
2614+ "@rolldown/binding-openharmony-arm64": "1.1.5",
2615+ "@rolldown/binding-wasm32-wasi": "1.1.5",
2616+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
2617+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
2618+ }
2619+ },
18372620 "node_modules/rollup": {
18382621 "version": "4.62.3",
18392622 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz",
@@ -2055,6 +2838,23 @@
20552838 "dev": true,
20562839 "license": "MIT"
20572840 },
2841+ "node_modules/tinyglobby": {
2842+ "version": "0.2.17",
2843+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
2844+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
2845+ "dev": true,
2846+ "license": "MIT",
2847+ "dependencies": {
2848+ "fdir": "^6.5.0",
2849+ "picomatch": "^4.0.4"
2850+ },
2851+ "engines": {
2852+ "node": ">=12.0.0"
2853+ },
2854+ "funding": {
2855+ "url": "https://github.com/sponsors/SuperchupuDev"
2856+ }
2857+ },
20582858 "node_modules/tslib": {
20592859 "version": "2.8.1",
20602860 "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -2161,6 +2961,107 @@
21612961 }
21622962 }
21632963 },
2964+ "node_modules/vite-node": {
2965+ "version": "6.0.0",
2966+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-6.0.0.tgz",
2967+ "integrity": "sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==",
2968+ "dev": true,
2969+ "license": "MIT",
2970+ "dependencies": {
2971+ "cac": "^7.0.0",
2972+ "es-module-lexer": "^2.0.0",
2973+ "obug": "^2.1.1",
2974+ "pathe": "^2.0.3",
2975+ "vite": "^8.0.0"
2976+ },
2977+ "bin": {
2978+ "vite-node": "dist/cli.mjs"
2979+ },
2980+ "engines": {
2981+ "node": "^20.19.0 || >=22.12.0"
2982+ },
2983+ "funding": {
2984+ "url": "https://opencollective.com/antfu"
2985+ }
2986+ },
2987+ "node_modules/vite-node/node_modules/vite": {
2988+ "version": "8.1.5",
2989+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
2990+ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
2991+ "dev": true,
2992+ "license": "MIT",
2993+ "dependencies": {
2994+ "lightningcss": "^1.32.0",
2995+ "picomatch": "^4.0.5",
2996+ "postcss": "^8.5.17",
2997+ "rolldown": "~1.1.5",
2998+ "tinyglobby": "^0.2.17"
2999+ },
3000+ "bin": {
3001+ "vite": "bin/vite.js"
3002+ },
3003+ "engines": {
3004+ "node": "^20.19.0 || >=22.12.0"
3005+ },
3006+ "funding": {
3007+ "url": "https://github.com/vitejs/vite?sponsor=1"
3008+ },
3009+ "optionalDependencies": {
3010+ "fsevents": "~2.3.3"
3011+ },
3012+ "peerDependencies": {
3013+ "@types/node": "^20.19.0 || >=22.12.0",
3014+ "@vitejs/devtools": "^0.3.0",
3015+ "esbuild": "^0.27.0 || ^0.28.0",
3016+ "jiti": ">=1.21.0",
3017+ "less": "^4.0.0",
3018+ "sass": "^1.70.0",
3019+ "sass-embedded": "^1.70.0",
3020+ "stylus": ">=0.54.8",
3021+ "sugarss": "^5.0.0",
3022+ "terser": "^5.16.0",
3023+ "tsx": "^4.8.1",
3024+ "yaml": "^2.4.2"
3025+ },
3026+ "peerDependenciesMeta": {
3027+ "@types/node": {
3028+ "optional": true
3029+ },
3030+ "@vitejs/devtools": {
3031+ "optional": true
3032+ },
3033+ "esbuild": {
3034+ "optional": true
3035+ },
3036+ "jiti": {
3037+ "optional": true
3038+ },
3039+ "less": {
3040+ "optional": true
3041+ },
3042+ "sass": {
3043+ "optional": true
3044+ },
3045+ "sass-embedded": {
3046+ "optional": true
3047+ },
3048+ "stylus": {
3049+ "optional": true
3050+ },
3051+ "sugarss": {
3052+ "optional": true
3053+ },
3054+ "terser": {
3055+ "optional": true
3056+ },
3057+ "tsx": {
3058+ "optional": true
3059+ },
3060+ "yaml": {
3061+ "optional": true
3062+ }
3063+ }
3064+ },
21643065 "node_modules/webgpu": {
21653066 "version": "0.4.0",
21663067 "resolved": "https://registry.npmjs.org/webgpu/-/webgpu-0.4.0.tgz",
package.jsonmodified+4−3View file
@@ -10,10 +10,10 @@
1010 "scripts": {
1111 "dev": "vite",
1212 "build": "tsc --noEmit && vite build",
13- "test:node": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/test-node.ts",
13+ "test:node": "vite-node scripts/test-node.ts",
1414 "test:gpu": "vite build && node scripts/test-gpu.mjs",
1515 "test": "npm run test:node && npm run test:gpu",
16- "bench": "node scripts/bench.mjs"
16+ "bench": "vite-node scripts/bench.ts"
1717 },
1818 "dependencies": {
1919 "numbl": "file:../../numbl",
@@ -28,6 +28,7 @@
2828 "@webgpu/types": "^0.1.44",
2929 "puppeteer-core": "^23.0.0",
3030 "typescript": "^5.5.0",
31- "vite": "^5.4.0"
31+ "vite": "^5.4.0",
32+ "vite-node": "^6.0.0"
3233 }
3334 }
scripts/bench.mjsdeleted+0−37View file
@@ -1,37 +0,0 @@
1-/**
2- * Entry point for the benchmark. The app hands this command to whoever is
3- * running the demo, so it has to survive landing on a machine with an older
4- * Node than this repo develops against: Node only strips TypeScript types by
5- * default from 22.18 / 23.6 / 24 on, and without stripping it cannot load
6- * scripts/bench.ts at all — not even far enough to print a useful error.
7- *
8- * So this wrapper is plain JS, and re-executes itself with the flag when the
9- * running Node has stripping available but off (22.6 through 22.17).
10- */
11-import { spawnSync } from 'node:child_process';
12-import { fileURLToPath } from 'node:url';
13-
14-if (process.features.typescript) {
15- await import('./bench.ts');
16-} else {
17- const entry = fileURLToPath(new URL('./bench.ts', import.meta.url));
18- const result = spawnSync(
19- process.execPath,
20- [
21- '--experimental-strip-types',
22- '--disable-warning=ExperimentalWarning',
23- entry,
24- ...process.argv.slice(2),
25- ],
26- { stdio: 'inherit' },
27- );
28- // exit code 9 is node's "bad option": this Node predates type stripping
29- if (result.error || result.status === 9) {
30- console.error(
31- `bench: Node ${process.versions.node} cannot run this project's TypeScript sources.\n` +
32- ' Node 22.6+ can with --experimental-strip-types; 22.18, 23.6 and 24+ do it by default.',
33- );
34- process.exit(1);
35- }
36- process.exit(result.status ?? 1);
37-}
scripts/bench.tsmodified+102−73View file
@@ -1,29 +1,27 @@
11 /**
2- * Command-line benchmark: run exactly the simulation the browser app is
3- * running — same solver, same transforms, same parameters — on desktop WebGPU
4- * (Google Dawn, via the optional `webgpu` package) or on the f64 CPU
5- * reference, and report ms/step. The app prints the matching command under
6- * its stats line; copy it and run it here for an apples-to-apples comparison.
2+ * Command-line benchmark: run exactly what the browser runs — the same .m
3+ * models, lowered by numbl and compiled to the same WGSL kernels, over the same
4+ * transforms — on desktop WebGPU (Google Dawn, via the optional `webgpu`
5+ * package), and report ms/step. The app prints the matching command under its
6+ * stats line; copy it and run it here for an apples-to-apples comparison.
77 *
8- * node scripts/bench.mjs --preset schnak-spots --lmax 63 --backend webgpu \
9- * --steps 2000 --seed 1 --a 0.1 --b 0.9 --D1 0.0004 --D2 0.008 --dt 0.05
8+ * npm run bench -- --preset schnak-spots --lmax 63 --steps 2000 --seed 1 \
9+ * --a 0.1 --b 0.9 --D1 0.0004 --D2 0.008 --dt 0.05
1010 *
1111 * The only thing missing here is the rendering: this is the solver alone.
12- * Entry point is scripts/bench.mjs, which copes with older Node versions.
12+ *
13+ * Two numbers are reported, because they answer different questions:
14+ * - throughput: a batch of steps submitted together, awaited once. This is how
15+ * the app runs, and what keeping the state in GPU buffers is for.
16+ * - latency: one step per submit, each awaited. Comparable to a design that
17+ * reads back every step, and the only way to get a per-step distribution.
1318 */
14-import {
15- GpuBackend,
16- CpuBackend,
17- requestShtDevice,
18- describeAdapter,
19- type ShtBackend,
20-} from '../src/solver/backend.ts';
21-import { Simulation } from '../src/solver/simulation.ts';
22-import { presets } from '../src/solver/models.ts';
19+import { requestShtDevice, describeAdapter } from '../src/sht/sht.ts';
20+import { ModelSession } from '../src/mgpu/session.ts';
21+import { presets } from '../src/mgpu/registry.ts';
2322 import {
2423 parseArgs,
2524 modelForSpec,
26- configForSpec,
2725 resolvePreset,
2826 formatCommand,
2927 BENCH_COMMAND,
@@ -31,7 +29,6 @@ import {
3129 DEFAULT_SEED,
3230 DEFAULT_STEPS,
3331 DEFAULT_WARMUP,
34- DEFAULT_BACKEND,
3532 type RunSpec,
3633 } from '../src/bench/runSpec.ts';
3734 import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
@@ -41,10 +38,10 @@ const USAGE = `usage: ${BENCH_COMMAND} [options]
4138 --preset <key> ${presets.map((p) => p.key).join(' | ')}
4239 (default ${presets[0].key})
4340 --lmax <n> spherical harmonic truncation (default ${DEFAULT_LMAX})
44- --backend <kind> webgpu | cpu (default ${DEFAULT_BACKEND})
4541 --steps <n> timed steps (default ${DEFAULT_STEPS})
4642 --warmup <n> untimed steps first (default ${DEFAULT_WARMUP})
4743 --seed <n> initial-noise seed (default ${DEFAULT_SEED})
44+ --batch <n> steps per submit for the throughput number (default 16)
4845 --<param> <v> any parameter of the preset's model, e.g. --dt 0.05
4946 --json machine-readable output
5047 --help
@@ -64,9 +61,25 @@ if (argv.includes('--help') || argv.includes('-h')) {
6461 process.exit(0);
6562 }
6663 const wantJson = argv.includes('--json');
64+let batch = 16;
65+const rest: string[] = [];
66+for (let i = 0; i < argv.length; i++) {
67+ if (argv[i] === '--json') continue;
68+ if (argv[i] === '--batch') {
69+ batch = Number(argv[++i]);
70+ continue;
71+ }
72+ if (argv[i].startsWith('--batch=')) {
73+ batch = Number(argv[i].slice('--batch='.length));
74+ continue;
75+ }
76+ rest.push(argv[i]);
77+}
78+if (!Number.isInteger(batch) || batch < 1) fail(`--batch must be an integer >= 1`, 2);
79+
6780 let spec: RunSpec;
6881 try {
69- spec = parseArgs(argv.filter((a) => a !== '--json'));
82+ spec = parseArgs(rest);
7083 } catch (e) {
7184 fail(`${errMsg(e)}\n\n${USAGE}`, 2);
7285 }
@@ -113,71 +126,85 @@ function fieldRange(v: ArrayLike<number>): { min: number; max: number } {
113126 // ---------------------------------------------------------------- run
114127 const model = modelForSpec(spec);
115128 const { preset } = resolvePreset(spec.preset);
116-const cfg = configForSpec(spec);
117129
118130 let device: GPUDevice | null = null;
119-let backend: ShtBackend | null = null;
120-let runtime = 'CPU (direct summation, f64)';
121-let adapter = '';
131+let session: ModelSession | null = null;
122132
123133 try {
124- if (spec.backend === 'webgpu') {
125- runtime = await installWebGpu();
126- device = await requestShtDevice().catch((e: unknown) => {
127- throw new Error(
128- `${errMsg(e)}\n${NO_ADAPTER_HINT}\n --backend cpu always works.`,
129- );
130- });
131- adapter = await describeAdapter(device);
132- backend = await GpuBackend.create(device, cfg);
133- } else {
134- backend = new CpuBackend(cfg);
135- }
134+ const runtime = await installWebGpu();
135+ device = await requestShtDevice().catch((e: unknown) => {
136+ throw new Error(`${errMsg(e)}\n${NO_ADAPTER_HINT}`);
137+ });
138+ const adapter = await describeAdapter(device);
136139
137- const sim = new Simulation(backend, model, spec.params);
138- await sim.init(spec.seed);
140+ session = await ModelSession.create({
141+ device,
142+ model,
143+ params: spec.params,
144+ lmax: spec.lmax,
145+ });
146+ session.seed(spec.seed);
147+
148+ const plan = session.describe();
149+ const kernels = plan.step.filter((l) => l.startsWith('kernel')).length;
150+ const cfg = session.cfg;
139151
140152 if (!wantJson) {
141- const kind =
142- spec.backend === 'webgpu'
143- ? `WebGPU fp32${adapter ? ` — ${adapter}` : ''}`
144- : 'CPU f64';
145153 console.log(`turing-sphere bench — solver only, no rendering\n`);
146- console.log(` preset ${preset.label} (model ${model.key}: ${model.species.join(', ')})`);
154+ console.log(` preset ${preset.label} (models/${model.key}.m: ${model.species.join(', ')})`);
147155 console.log(
148156 ` params ${model.params.map((p) => `${p.key}=${spec.params[p.key]}`).join(' ')}`,
149157 );
150158 console.log(
151- ` grid lmax ${cfg.lmax} · ${cfg.nlat}×${cfg.nphi} · nlm ${backend.nlm.toLocaleString()}`,
159+ ` grid lmax ${cfg.lmax} · ${cfg.nlat}×${cfg.nphi} · nlm ${session.sht.nlm.toLocaleString()}`,
152160 );
153- console.log(` backend ${kind}\n ${runtime}`);
161+ console.log(` compiled ${plan.step.length} GPU ops/step (${kernels} generated kernels)`);
162+ console.log(` backend WebGPU fp32${adapter ? ` — ${adapter}` : ''}\n ${runtime}`);
154163 console.log(` run ${spec.warmup} warmup + ${spec.steps} timed steps, seed ${spec.seed}\n`);
155164 }
156165
157- for (let s = 0; s < spec.warmup; s++) await sim.step();
166+ const done = (): Promise<undefined> => device!.queue.onSubmittedWorkDone();
167+
168+ session.step(spec.warmup);
169+ await done();
158170
159- const samples = new Float64Array(spec.steps);
171+ // --- throughput: batches submitted together, awaited once each ---
172+ const batches = Math.max(1, Math.ceil(spec.steps / batch));
160173 const progress = !wantJson && process.stderr.isTTY;
161174 let lastReport = performance.now();
162- let running = 0;
163- for (let s = 0; s < spec.steps; s++) {
164- const t0 = performance.now();
165- await sim.step();
166- samples[s] = performance.now() - t0;
167- running += samples[s];
175+ const tp0 = performance.now();
176+ let stepsRun = 0;
177+ for (let b = 0; b < batches; b++) {
178+ const n = Math.min(batch, spec.steps - stepsRun);
179+ session.step(n);
180+ await done();
181+ stepsRun += n;
168182 if (progress && performance.now() - lastReport > 1000) {
183+ const so_far = (performance.now() - tp0) / stepsRun;
169184 process.stderr.write(
170- `\r\x1b[K ${s + 1}/${spec.steps} steps · ${(running / (s + 1)).toFixed(2)} ms/step`,
185+ `\r\x1b[K ${stepsRun}/${spec.steps} steps · ${so_far.toFixed(2)} ms/step`,
171186 );
172187 lastReport = performance.now();
173188 }
174189 }
190+ const throughputMs = (performance.now() - tp0) / stepsRun;
175191 if (progress) process.stderr.write('\r\x1b[K');
176192
193+ // --- latency: one step per submit, for the distribution ---
194+ const latencySteps = Math.min(spec.steps, 200);
195+ const samples = new Float64Array(latencySteps);
196+ for (let s = 0; s < latencySteps; s++) {
197+ const t0 = performance.now();
198+ session.step(1);
199+ await done();
200+ samples[s] = performance.now() - t0;
201+ }
177202 const t = timing(samples);
178- const range = fieldRange(sim.V[0]);
203+
204+ const field = await session.read(model.species[0]);
205+ const range = fieldRange(field);
179206 let finite = true;
180- for (const v of sim.V[0]) if (!Number.isFinite(v)) finite = false;
207+ for (const v of field) if (!Number.isFinite(v)) finite = false;
181208
182209 if (wantJson) {
183210 console.log(
@@ -186,12 +213,14 @@ try {
186213 command: formatCommand(spec),
187214 spec,
188215 model: model.key,
189- backend: { kind: spec.backend, adapter, runtime },
190- grid: { lmax: cfg.lmax, nlat: cfg.nlat, nphi: cfg.nphi, nlm: backend.nlm },
191- timing: t,
216+ backend: { adapter, runtime },
217+ grid: { lmax: cfg.lmax, nlat: cfg.nlat, nphi: cfg.nphi, nlm: session.sht.nlm },
218+ compiled: { opsPerStep: plan.step.length, kernels },
219+ throughput: { batch, msPerStep: throughputMs, stepsPerSec: 1000 / throughputMs },
220+ latency: t,
192221 state: {
193- t: sim.t,
194- steps: sim.stepCount,
222+ t: session.t,
223+ steps: session.steps,
195224 species: model.species[0],
196225 min: range.min,
197226 max: range.max,
@@ -205,30 +234,30 @@ try {
205234 );
206235 } else {
207236 console.log(
208- ` ${t.meanMs.toFixed(2)} ms/step ${t.stepsPerSec.toFixed(1)} steps/s ` +
209- `${(spec.params.dt * t.stepsPerSec).toFixed(2)} model time/s`,
237+ ` ${throughputMs.toFixed(2)} ms/step ${(1000 / throughputMs).toFixed(1)} steps/s ` +
238+ `${(spec.params.dt * (1000 / throughputMs)).toFixed(2)} model time/s` +
239+ ` (batches of ${batch})`,
210240 );
211241 console.log(
212- ` median ${t.medianMs.toFixed(2)} · p05 ${t.p05Ms.toFixed(2)} · ` +
213- `p95 ${t.p95Ms.toFixed(2)} · min ${t.minMs.toFixed(2)} ms ` +
214- `(${(t.totalMs / 1000).toFixed(1)} s total)`,
242+ ` one step per submit: ${t.meanMs.toFixed(2)} ms mean · median ${t.medianMs.toFixed(2)} · ` +
243+ `p05 ${t.p05Ms.toFixed(2)} · p95 ${t.p95Ms.toFixed(2)} · min ${t.minMs.toFixed(2)}`,
215244 );
216245 console.log(
217- ` after ${sim.stepCount} steps: t = ${sim.t.toFixed(2)}, ` +
246+ ` after ${session.steps} steps: t = ${session.t.toFixed(2)}, ` +
218247 `${model.species[0]} ∈ [${range.min.toFixed(4)}, ${range.max.toFixed(4)}] ` +
219248 `(contrast ${(range.max - range.min).toFixed(4)})${finite ? '' : ' — NOT FINITE'}`,
220249 );
221250 console.log(
222- `\n Compare with the ms/step in the app's stats line. That one is also the\n` +
223- ` solver alone, but measured while the page renders the spheres.`,
251+ `\n Compare with the ms/step in the app's stats line: same .m, same kernels,\n` +
252+ ` but measured while the page renders the spheres.`,
224253 );
225254 }
226255
227- backend.destroy();
228- device?.destroy();
256+ session.destroy();
257+ device.destroy();
229258 process.exit(finite ? 0 : 1);
230259 } catch (e) {
231- backend?.destroy();
260+ session?.destroy();
232261 device?.destroy();
233262 fail(errMsg(e));
234263 }
scripts/longrun-node.tsmodified+46−32View file
@@ -1,43 +1,57 @@
11 /**
2- * Long-run sanity check (CPU f64, lmax 31): run Schnakenberg to t = 100 and
3- * confirm the pattern saturates into O(1)-contrast spots rather than decaying
4- * or blowing up. Run: node scripts/longrun-node.ts
2+ * Long-run sanity check: run Schnakenberg to t = 100 on desktop WebGPU and
3+ * confirm the pattern saturates into O(1)-contrast spots rather than decaying or
4+ * blowing up. Short runs cannot tell a growing instability from a diverging one.
5+ *
6+ * vite-node scripts/longrun-node.ts [lmax]
57 */
6-import { CpuBackend } from '../src/solver/backend.ts';
7-import { Simulation, gridForLmax } from '../src/solver/simulation.ts';
8-import { models, defaultParams } from '../src/solver/models.ts';
8+import { requestShtDevice } from '../src/sht/sht.ts';
9+import { ModelSession } from '../src/mgpu/session.ts';
10+import { mModelByKey, defaultParams } from '../src/mgpu/registry.ts';
11+import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
912
10-const schnak = models[0];
11-const params = defaultParams(schnak);
12-const lmax = 31;
13-const { nlat, nphi } = gridForLmax(lmax, schnak.pdeg);
14-const backend = new CpuBackend({ lmax, mmax: lmax, nlat, nphi });
15-const sim = new Simulation(backend, schnak, params);
16-await sim.init(1);
13+const lmax = Number(process.argv[2] ?? 31);
14+const model = mModelByKey('schnakenberg')!;
15+const params = defaultParams(model);
16+
17+const runtime = await installWebGpu();
18+const device = await requestShtDevice().catch((e: unknown) => {
19+ throw new Error(`${errMsg(e)}\n${NO_ADAPTER_HINT}`);
20+});
21+const session = await ModelSession.create({ device, model, params, lmax });
22+session.seed(1);
23+console.log(`longrun — models/${model.key}.m at lmax ${lmax}, ${runtime}\n`);
1724
1825 const nsteps = Math.round(100 / params.dt);
26+const BATCH = 50;
1927 const t0 = performance.now();
20-for (let s = 0; s < nsteps; s++) {
21- await sim.step();
22- if ((s + 1) % 400 === 0) {
23- let lo = Infinity, hi = -Infinity;
24- for (const v of sim.V[0]) {
25- if (v < lo) lo = v;
26- if (v > hi) hi = v;
27- }
28+let lo = 0;
29+let hi = 0;
30+for (let s = 0; s < nsteps; s += BATCH) {
31+ session.step(Math.min(BATCH, nsteps - s));
32+ const u = await session.read(model.species[0]);
33+ lo = Infinity;
34+ hi = -Infinity;
35+ for (const v of u) {
36+ if (v < lo) lo = v;
37+ if (v > hi) hi = v;
38+ }
39+ if (session.steps % 400 === 0) {
2840 console.log(
29- `t=${sim.t.toFixed(1).padStart(5)} u in [${lo.toFixed(4)}, ${hi.toFixed(4)}] ` +
30- `contrast ${(hi - lo).toFixed(4)}`,
41+ `t=${session.t.toFixed(1).padStart(5)} u in [${lo.toFixed(4)}, ${hi.toFixed(4)}] ` +
42+ `contrast ${(hi - lo).toFixed(4)}`,
3143 );
3244 }
3345 }
34-console.log(`${((performance.now() - t0) / nsteps).toFixed(1)} ms/step CPU`);
46+const ms = (performance.now() - t0) / nsteps;
3547
36-let lo = Infinity, hi = -Infinity;
37-for (const v of sim.V[0]) {
38- if (v < lo) lo = v;
39- if (v > hi) hi = v;
40-}
41-const ok = Number.isFinite(lo) && hi - lo > 0.3 && hi - lo < 5;
42-console.log(ok ? 'PASS: saturated O(1) pattern' : 'FAIL: no saturated pattern');
43-process.exit(ok ? 0 : 1);
48+const contrast = hi - lo;
49+const saturated = Number.isFinite(contrast) && contrast > 0.5 && hi < 10;
50+console.log(
51+ `\n${saturated ? 'PASS' : 'FAIL'} pattern saturated: contrast ${contrast.toFixed(4)} ` +
52+ `after ${session.steps} steps (${ms.toFixed(1)} ms/step)`,
53+);
54+
55+session.destroy();
56+device.destroy();
57+process.exit(saturated ? 0 : 1);
scripts/test-node.tsmodified+40−137View file
@@ -1,151 +1,54 @@
11 /**
2- * Solver correctness tests against the f64 CPU transform backend.
2+ * The whole suite on desktop WebGPU (Google Dawn), against the real pipeline:
3+ * MATLAB source -> numbl lowering -> generated WGSL -> GPU.
34 *
4- * A. Linear reaction + diffusion, single mode: every (l,m) mode of
5- * f = c*u with implicit diffusion follows the exact scalar recurrence
6- * g = (1 + dt*c) / (1 + dt*D*l(l+1)).
7- * B. Uniform state, nonlinear reaction: the l=0 mode follows the explicit
8- * Euler map of the reaction ODE exactly.
9- * C. Turing linear stability: a small single-mode perturbation of the
10- * Schnakenberg fixed point follows the 2x2 linearized IMEX recurrence,
11- * and the (24, 7) mode lies in the unstable band.
5+ * The same three check modules run in the browser (test.html), so both GPU
6+ * stacks get the same guarantees. Run through vite-node, which is what resolves
7+ * numbl's compiler sources and the `?raw` model imports:
128 *
13- * Run: node scripts/test-node.ts
9+ * npm run test:node
1410 */
15-import { CpuBackend } from '../src/solver/backend.ts';
16-import { Simulation, gridForLmax } from '../src/solver/simulation.ts';
17-import { models, defaultParams } from '../src/solver/models.ts';
18-import type { ModelSpec } from '../src/solver/models.ts';
19-import { lmIndex } from '../src/sht/layout.ts';
11+import { requestShtDevice } from '../src/sht/sht.ts';
12+import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
13+import { transformChecks } from '../test/transformChecks.ts';
14+import { analyticChecks } from '../test/analyticChecks.ts';
15+import { modelChecks } from '../test/modelChecks.ts';
2016
2117 let failures = 0;
22-function check(name: string, ok: boolean, detail: string): void {
18+const check = (name: string, ok: boolean, detail: string): void => {
2319 console.log(`${ok ? 'PASS' : 'FAIL'} ${name} ${detail}`);
2420 if (!ok) failures++;
25-}
26-
27-// ---------------------------------------------------------------- test A
28-{
29- const lmax = 15;
30- const { nlat, nphi } = gridForLmax(lmax, 1);
31- const backend = new CpuBackend({ lmax, mmax: lmax, nlat, nphi });
32- const c = -0.3;
33- const D = 0.01;
34- const model: ModelSpec = {
35- key: 'linear', label: 'linear', blurb: '', species: ['u'],
36- params: [], pdeg: 1, seedAmp: 0,
37- diffusivities: () => [D],
38- reaction(_p, _t, _x, _y, _z, V, out) {
39- for (let i = 0; i < out[0].length; i++) out[0][i] = c * V[0][i];
40- },
41- init() {},
42- };
43- const sim = new Simulation(backend, model, { dt: 0.1 });
44- const l = 5, m = 2;
45- const idx = lmIndex(lmax, l, m);
46- sim.U[0][2 * idx] = 0.8;
47- sim.U[0][2 * idx + 1] = -0.35;
48-
49- const nsteps = 20;
50- for (let s = 0; s < nsteps; s++) await sim.step();
51-
52- const g = (1 + 0.1 * c) / (1 + 0.1 * D * l * (l + 1));
53- const gn = Math.pow(g, nsteps);
54- const errRe = Math.abs(sim.U[0][2 * idx] - 0.8 * gn);
55- const errIm = Math.abs(sim.U[0][2 * idx + 1] - -0.35 * gn);
56- let leak = 0;
57- for (let i = 0; i < backend.nlm; i++) {
58- if (i === idx) continue;
59- leak = Math.max(leak, Math.abs(sim.U[0][2 * i]), Math.abs(sim.U[0][2 * i + 1]));
60- }
61- check('A: single-mode linear recurrence', errRe < 1e-12 && errIm < 1e-12,
62- `err=(${errRe.toExponential(2)}, ${errIm.toExponential(2)})`);
63- check('A: no leakage into other modes', leak < 1e-12, `leak=${leak.toExponential(2)}`);
64-}
65-
66-// ---------------------------------------------------------------- test B
67-{
68- const schnak = models[0];
69- const p = defaultParams(schnak);
70- const lmax = 15;
71- const { nlat, nphi } = gridForLmax(lmax, schnak.pdeg);
72- const backend = new CpuBackend({ lmax, mmax: lmax, nlat, nphi });
73- const uniform: ModelSpec = {
74- ...schnak,
75- seedAmp: 0,
76- init(pp, x, _y, _z, _randn, out) {
77- out[0].fill(1.2);
78- out[1].fill(0.8);
79- void pp; void x;
80- },
81- };
82- const sim = new Simulation(backend, uniform, p);
83- await sim.init(1);
84- const nsteps = 50;
85- for (let s = 0; s < nsteps; s++) await sim.step();
21+};
22+const log = (s: string): void => console.log(s);
8623
87- // reference: explicit Euler on the 2-species ODE (l=0 is untouched by diffusion)
88- let u = 1.2, v = 0.8;
89- for (let s = 0; s < nsteps; s++) {
90- const fu = p.a - u + u * u * v;
91- const fv = p.b - u * u * v;
92- u += p.dt * fu;
93- v += p.dt * fv;
24+/**
25+ * These checks compile MATLAB to compute shaders, so unlike the old CPU-solver
26+ * suite they need a GPU. `--skip-without-gpu` lets a runner that has none say so
27+ * and move on — CI uses it, because the browser suite runs the very same check
28+ * modules on SwiftShader. A plain local run still fails loudly, so a missing GPU
29+ * is never mistaken for a pass.
30+ */
31+const skipWithoutGpu = process.argv.includes('--skip-without-gpu');
32+
33+let runtime: string;
34+let device: GPUDevice;
35+try {
36+ runtime = await installWebGpu();
37+ device = await requestShtDevice();
38+} catch (e) {
39+ const detail = `${errMsg(e)}\n${NO_ADAPTER_HINT}`;
40+ if (skipWithoutGpu) {
41+ console.log(`SKIP no WebGPU available here, so these checks did not run.\n${detail}`);
42+ process.exit(0);
9443 }
95- // the area mean is the l=0 coefficient of U (V lags U by one step)
96- const sqrt4pi = Math.sqrt(4 * Math.PI);
97- const i00 = 2 * lmIndex(lmax, 0, 0);
98- const errU = Math.abs(sim.U[0][i00] / sqrt4pi - u);
99- const errV = Math.abs(sim.U[1][i00] / sqrt4pi - v);
100- check('B: uniform nonlinear reaction ODE', errU < 1e-10 && errV < 1e-10,
101- `err=(${errU.toExponential(2)}, ${errV.toExponential(2)}) u=${u.toFixed(6)} v=${v.toFixed(6)}`);
44+ console.error(`test-node: ${detail}`);
45+ process.exit(1);
10246 }
47+console.log(`turing-sphere tests — ${runtime}\n`);
10348
104-// ---------------------------------------------------------------- test C
105-{
106- const schnak = models[0];
107- const p = defaultParams(schnak);
108- const lmax = 31;
109- const { nlat, nphi } = gridForLmax(lmax, schnak.pdeg);
110- const backend = new CpuBackend({ lmax, mmax: lmax, nlat, nphi });
111- const sim = new Simulation(backend, schnak, p);
112-
113- const us = p.a + p.b; // 1.0
114- const vs = p.b / (us * us); // 0.9
115- const sqrt4pi = Math.sqrt(4 * Math.PI);
116- const l = 24, m = 7;
117- const idx = lmIndex(lmax, l, m);
118- const eps = 1e-6;
119- const c0 = [eps, 0.5 * eps];
120- // fixed point + single-mode perturbation, set directly in spectral space
121- sim.U[0][2 * lmIndex(lmax, 0, 0)] = us * sqrt4pi;
122- sim.U[1][2 * lmIndex(lmax, 0, 0)] = vs * sqrt4pi;
123- sim.U[0][2 * idx] = c0[0];
124- sim.U[1][2 * idx] = c0[1];
125-
126- const nsteps = 20;
127- for (let s = 0; s < nsteps; s++) await sim.step();
128-
129- // linearized IMEX recurrence: c' = diag(1/(1+dt*Dk*lam)) * (I + dt*J) * c
130- const lam = l * (l + 1);
131- const J = [
132- [-1 + 2 * us * vs, us * us],
133- [-2 * us * vs, -us * us],
134- ];
135- let c = [...c0];
136- for (let s = 0; s < nsteps; s++) {
137- const r0 = c[0] + p.dt * (J[0][0] * c[0] + J[0][1] * c[1]);
138- const r1 = c[1] + p.dt * (J[1][0] * c[0] + J[1][1] * c[1]);
139- c = [r0 / (1 + p.dt * p.D1 * lam), r1 / (1 + p.dt * p.D2 * lam)];
140- }
141- const got = [sim.U[0][2 * idx], sim.U[1][2 * idx]];
142- const errU = Math.abs(got[0] - c[0]) / Math.abs(c[0]);
143- const errV = Math.abs(got[1] - c[1]) / Math.abs(c[1]);
144- check('C: linearized Turing-mode recurrence', errU < 1e-4 && errV < 1e-4,
145- `rel err=(${errU.toExponential(2)}, ${errV.toExponential(2)})`);
146- check('C: (l=24, m=7) is growing', Math.abs(got[0]) > Math.abs(c0[0]),
147- `|c|: ${Math.abs(c0[0]).toExponential(2)} -> ${Math.abs(got[0]).toExponential(2)}`);
148-}
49+await transformChecks(device, check, log);
50+await analyticChecks(device, check, log);
51+await modelChecks(device, check, log);
14952
150-console.log(failures === 0 ? '\nAll tests passed.' : `\n${failures} test(s) FAILED.`);
53+console.log(failures === 0 ? '\nAll tests passed.' : `\n${failures} failed.`);
15154 process.exit(failures === 0 ? 0 : 1);
src/bench/runSpec.tsmodified+18−28View file
@@ -1,28 +1,25 @@
11 /**
2- * One solver run, described in a single object shared by the browser app and
3- * the command-line benchmark. The app formats the run it is currently showing
4- * into a `node scripts/bench.mjs ...` command; the benchmark parses that command
5- * back into the same object and drives the same Simulation with it. Neither
6- * side keeps its own copy of the defaults, so the two runs cannot drift apart.
2+ * One run, described in a single object shared by the browser app and the
3+ * command-line benchmark. The app formats the run it is currently showing into a
4+ * `npm run bench` command; the benchmark parses that command back into the same
5+ * object and compiles the same .m with it. Neither side keeps its own copy of
6+ * the defaults, so the two runs cannot drift apart — and both execute the same
7+ * MATLAB through the same pipeline, so the comparison is like for like.
78 */
89 import {
9- models,
10+ mModels,
1011 presets,
1112 defaultParams,
12- type ModelSpec,
13+ type MModel,
1314 type Params,
1415 type Preset,
15-} from '../solver/models.ts';
16-import { gridForLmax } from '../solver/simulation.ts';
17-import type { ShtConfig } from '../sht/layout.ts';
18-
19-export type BackendKind = 'webgpu' | 'cpu';
16+} from '../mgpu/registry.ts';
17+import { gridForLmax, type ShtConfig } from '../sht/layout.ts';
2018
2119 export interface RunSpec {
22- /** Preset key from models.ts; fixes the model, params may still be edited. */
20+ /** Preset key from the registry; fixes the model, params may still be edited. */
2321 preset: string;
2422 lmax: number;
25- backend: BackendKind;
2623 /** Seed of the initial noise. */
2724 seed: number;
2825 /** Timed steps (the app runs forever; the benchmark stops here). */
@@ -33,23 +30,22 @@ export interface RunSpec {
3330 params: Params;
3431 }
3532
36-/** The command the app displays and the benchmark answers to. It names the
37- * .mjs wrapper rather than bench.ts, so that it also runs on a Node that
38- * needs to be told to strip types (see scripts/bench.mjs). */
39-export const BENCH_COMMAND = 'node scripts/bench.mjs';
33+/** The command the app displays and the benchmark answers to. Goes through npm
34+ * because the benchmark runs under vite-node, which is what resolves numbl's
35+ * compiler sources and the `?raw` model imports. */
36+export const BENCH_COMMAND = 'npm run bench --';
4037 export const DEFAULT_LMAX = 63;
4138 export const DEFAULT_SEED = 1;
4239 /** Long enough that clock ramp-up and the occasional scheduling hiccup wash
4340 * out: ~10 s of GPU stepping at lmax 63. */
4441 export const DEFAULT_STEPS = 2000;
4542 export const DEFAULT_WARMUP = 100;
46-export const DEFAULT_BACKEND: BackendKind = 'webgpu';
4743
4844 /** Model + starting parameters of a preset, for the app's dropdown and the
4945 * benchmark's --preset flag. */
5046 export function resolvePreset(key: string): {
5147 preset: Preset;
52- model: ModelSpec;
48+ model: MModel;
5349 params: Params;
5450 } {
5551 const preset = presets.find((p) => p.key === key);
@@ -58,12 +54,12 @@ export function resolvePreset(key: string): {
5854 `unknown preset '${key}' (have: ${presets.map((p) => p.key).join(', ')})`,
5955 );
6056 }
61- const model = models.find((m) => m.key === preset.modelKey);
57+ const model = mModels.find((m) => m.key === preset.modelKey);
6258 if (!model) throw new Error(`preset '${key}' names unknown model '${preset.modelKey}'`);
6359 return { preset, model, params: { ...defaultParams(model), ...preset.params } };
6460 }
6561
66-export function modelForSpec(spec: RunSpec): ModelSpec {
62+export function modelForSpec(spec: RunSpec): MModel {
6763 return resolvePreset(spec.preset).model;
6864 }
6965
@@ -81,7 +77,6 @@ export function formatCommand(spec: RunSpec): string {
8177 BENCH_COMMAND,
8278 `--preset ${spec.preset}`,
8379 `--lmax ${spec.lmax}`,
84- `--backend ${spec.backend}`,
8580 `--steps ${spec.steps}`,
8681 `--seed ${spec.seed}`,
8782 ...model.params.map((p) => `--${p.key} ${String(spec.params[p.key])}`),
@@ -126,14 +121,9 @@ export function parseArgs(argv: string[]): RunSpec {
126121
127122 const presetKey = take('preset') ?? presets[0].key;
128123 const { model, params } = resolvePreset(presetKey);
129- const backend = take('backend') ?? DEFAULT_BACKEND;
130- if (backend !== 'webgpu' && backend !== 'cpu') {
131- throw new Error(`--backend must be 'webgpu' or 'cpu' (got '${backend}')`);
132- }
133124 const spec: RunSpec = {
134125 preset: presetKey,
135126 lmax: count('lmax', DEFAULT_LMAX, 1),
136- backend,
137127 seed: number('seed', DEFAULT_SEED),
138128 steps: count('steps', DEFAULT_STEPS, 1),
139129 warmup: count('warmup', DEFAULT_WARMUP, 0),
src/main.tsmodified+25−59View file
@@ -1,9 +1,6 @@
1-import { requestShtDevice, ShtPlan } from './sht/sht.ts';
2-import { describeAdapter } from './solver/backend.ts';
3-import { gridForLmax, makeRandn } from './solver/simulation.ts';
4-import { presets, type Params } from './solver/models.ts';
5-import { GpuModel } from './mgpu/model.ts';
6-import { mModelByKey, type MModel } from './mgpu/registry.ts';
1+import { requestShtDevice, describeAdapter } from './sht/sht.ts';
2+import { ModelSession } from './mgpu/session.ts';
3+import { mModelByKey, presets, type MModel, type Params } from './mgpu/registry.ts';
74 import { ModelCompileError, formatFailure } from './mgpu/errors.ts';
85 import { EXTERNAL_OPS } from './mgpu/externals.ts';
96 import { CodeEditor } from './editor/codeEditor.ts';
@@ -79,8 +76,7 @@ const STEPS_PER_FRAME = 4;
7976
8077 // ---------------------------------------------------------------- state
8178 let device: GPUDevice | null = null;
82-let sht: ShtPlan | null = null;
83-let gpu: GpuModel | null = null;
79+let session: ModelSession | null = null;
8480 let topo: SphereMeshTopology | null = null;
8581 let scenes: SphereScene[] = [];
8682 let colorbars: Colorbar[] = [];
@@ -99,8 +95,6 @@ let running = false;
9995 let adapterName = '';
10096 let pumping = false;
10197 let stepMs = 0;
102-let simTime = 0;
103-let stepCount = 0;
10498 let generation = 0; // bumped on every rebuild to cancel stale pumps
10599
106100 const source = (): string => editedSource ?? model.source;
@@ -122,7 +116,7 @@ function buildParamInputs(): void {
122116 if (Number.isFinite(v)) params[spec.key] = v;
123117 // Parameters are uniforms, not constants baked into the kernels, so a
124118 // change costs an upload rather than a recompile.
125- gpu?.setParams(params);
119+ session?.setParams(params);
126120 updateCommand();
127121 });
128122 label.append(input);
@@ -153,7 +147,6 @@ function currentSpec(): RunSpec {
153147 return {
154148 preset: elModel.value,
155149 lmax: Number(elLmax.value),
156- backend: 'webgpu',
157150 seed,
158151 steps: DEFAULT_STEPS,
159152 warmup: DEFAULT_WARMUP,
@@ -229,14 +222,6 @@ function disposeView(): void {
229222 elPanels.replaceChildren();
230223 }
231224
232-/** Seeded perturbation, one normal deviate per grid point. */
233-function makeNoise(npts: number): Float32Array {
234- const randn = makeRandn(seed);
235- const noise = new Float32Array(npts);
236- for (let i = 0; i < npts; i++) noise[i] = model.seedAmp * randn();
237- return noise;
238-}
239-
240225 /** Report a compile failure, and select the offending text in the editor. */
241226 function reportCompileError(e: unknown): void {
242227 elErr.textContent = formatFailure(e, source());
@@ -251,55 +236,40 @@ async function rebuild(): Promise<void> {
251236 const gen = generation;
252237 setRunning(false);
253238 disposeView();
254- gpu?.destroy();
255- gpu = null;
256- sht?.destroy();
257- sht = null;
239+ session?.destroy();
240+ session = null;
258241 stepMs = 0;
259- simTime = 0;
260- stepCount = 0;
261242 elErr.textContent = '';
262243 updateCommand();
263244 if (!device) return;
264245
265- const lmax = Number(elLmax.value);
266- const { nlat, nphi } = gridForLmax(lmax, model.pdeg);
267- const cfg = { lmax, mmax: lmax, nlat, nphi };
268-
269246 try {
270- sht = await ShtPlan.create(device, cfg);
271- gpu = await GpuModel.create({
247+ session = await ModelSession.create({
272248 device,
273- sht,
274- cfg,
249+ model,
250+ params,
251+ lmax: Number(elLmax.value),
275252 source: source(),
276- paramNames: model.params.map((p) => p.key),
277- state: model.state,
278- view: model.species,
279253 });
280254 } catch (e) {
281255 reportCompileError(e);
282- gpu?.destroy();
283- gpu = null;
284- sht?.destroy();
285- sht = null;
286256 return;
287257 }
288258 if (gen !== generation) return;
289259
290- gpu.setParams(params);
291- gpu.init(makeNoise(nlat * nphi));
260+ session.seed(seed);
292261
293- const plan = gpu.describe();
262+ const plan = session.describe();
294263 elCompiled.textContent =
295264 `one step compiled to ${plan.step.length} GPU operations:\n` +
296265 plan.step.map((l) => ` ${l}`).join('\n');
297266 elRecompile.textContent = 'Recompile';
298267
299268 // mesh + scenes
269+ const { nphi } = session.cfg;
300270 const phi = new Float64Array(nphi);
301271 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
302- topo = buildTopology(sht.cosTheta, phi);
272+ topo = buildTopology(session.sht.cosTheta, phi);
303273
304274 const sphereBg = getComputedStyle(document.documentElement)
305275 .getPropertyValue('--sphere-bg')
@@ -349,11 +319,9 @@ async function rebuild(): Promise<void> {
349319 }
350320
351321 async function reseed(): Promise<void> {
352- if (!gpu || !sht) return;
322+ if (!session) return;
353323 const gen = generation;
354- gpu.init(makeNoise(sht.cfg.nlat * sht.cfg.nphi));
355- simTime = 0;
356- stepCount = 0;
324+ session.seed(seed);
357325 if (gen !== generation) return;
358326 for (const r of ranges) {
359327 r.lo = NaN;
@@ -365,7 +333,7 @@ async function reseed(): Promise<void> {
365333
366334 // ---------------------------------------------------------------- drawing
367335 async function draw(): Promise<void> {
368- if (!gpu || !topo) return;
336+ if (!session || !topo) return;
369337 const gen = generation;
370338 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
371339 for (let k = 0; k < model.species.length; k++) {
@@ -374,7 +342,7 @@ async function draw(): Promise<void> {
374342 // mapped, which rejects the map; that result is stale anyway, so drop it.
375343 let field: Float32Array;
376344 try {
377- field = await gpu.read(model.species[k]);
345+ field = await session.read(model.species[k]);
378346 } catch (e) {
379347 if (gen !== generation) return;
380348 throw e;
@@ -410,14 +378,14 @@ async function draw(): Promise<void> {
410378 }
411379
412380 function updateStats(): void {
413- if (!gpu || !sht) return;
414- const { nlat, nphi } = sht.cfg;
381+ if (!session) return;
382+ const { nlat, nphi } = session.cfg;
415383 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
416384 const rate = stepMs > 0 ? `${(1000 / stepMs).toFixed(1)} steps/s` : '—';
417385 elStats.innerHTML =
418- `<b>${kind}</b> · grid ${nlat}×${nphi} · nlm ${sht.nlm.toLocaleString()} · ` +
386+ `<b>${kind}</b> · grid ${nlat}×${nphi} · nlm ${session.sht.nlm.toLocaleString()} · ` +
419387 `${stepMs > 0 ? stepMs.toFixed(1) : '—'} ms/step · ${rate} · ` +
420- `t = <b>${simTime.toFixed(2)}</b> (${stepCount} steps)`;
388+ `t = <b>${session.t.toFixed(2)}</b> (${session.steps} steps)`;
421389 }
422390
423391 // ---------------------------------------------------------------- sim loop
@@ -428,17 +396,15 @@ async function pump(): Promise<void> {
428396 pumping = true;
429397 const gen = generation;
430398 try {
431- while (running && gpu && gen === generation) {
399+ while (running && session && gen === generation) {
432400 const t0 = performance.now();
433- gpu.step(STEPS_PER_FRAME);
401+ session.step(STEPS_PER_FRAME);
434402 // draw() awaits the readback, which also waits for the batch to finish,
435403 // so this measures the real end-to-end cost per step.
436404 await draw();
437405 if (gen !== generation) break;
438406 const dtMs = (performance.now() - t0) / STEPS_PER_FRAME;
439407 stepMs = stepMs === 0 ? dtMs : stepMs + 0.05 * (dtMs - stepMs);
440- simTime += STEPS_PER_FRAME * (params.dt ?? 0);
441- stepCount += STEPS_PER_FRAME;
442408 updateStats();
443409 await nextFrame();
444410 }
src/mgpu/model.tsmodified+9−0View file
@@ -156,6 +156,15 @@ export class GpuModel {
156156 this.#stepPlan.setParams(params);
157157 }
158158
159+ /**
160+ * Write a host-owned value directly — the spectral state, or one of the input
161+ * fields. Lets a test set up an exact initial condition (a single spherical-
162+ * harmonic mode, say) instead of going through `init`.
163+ */
164+ upload(name: string, data: Float32Array): void {
165+ this.#host.upload(name, data);
166+ }
167+
159168 /** Upload the seeded perturbation and run `init`. */
160169 init(noise: Float32Array): void {
161170 this.#host.upload('noise', noise);
src/mgpu/noise.tsadded+40−0View file
@@ -0,0 +1,40 @@
1+/**
2+ * The seeded perturbation a model's `init` starts from.
3+ *
4+ * Host-side rather than in the .m, so a run is reproducible from an integer
5+ * seed and the same field can be handed to any model.
6+ */
7+
8+/** Seeded normal deviates: mulberry32 + Box-Muller. */
9+export function makeRandn(seed: number): () => number {
10+ let s = seed >>> 0;
11+ const rand = (): number => {
12+ s = (s + 0x6d2b79f5) >>> 0;
13+ let t = s;
14+ t = Math.imul(t ^ (t >>> 15), t | 1);
15+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
16+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
17+ };
18+ let spare: number | null = null;
19+ return () => {
20+ if (spare !== null) {
21+ const v = spare;
22+ spare = null;
23+ return v;
24+ }
25+ let u = 0;
26+ while (u === 0) u = rand();
27+ const r = Math.sqrt(-2 * Math.log(u));
28+ const th = 2 * Math.PI * rand();
29+ spare = r * Math.sin(th);
30+ return r * Math.cos(th);
31+ };
32+}
33+
34+/** `amp`-scaled normal deviates, one per grid point, in index order. */
35+export function seededNoise(npts: number, amp: number, seed: number): Float32Array {
36+ const randn = makeRandn(seed);
37+ const out = new Float32Array(npts);
38+ for (let i = 0; i < npts; i++) out[i] = amp * randn();
39+ return out;
40+}
src/mgpu/registry.tsmodified+113−30View file
@@ -1,26 +1,31 @@
11 /**
2- * The available .m models.
2+ * The available models: their MATLAB source, and the metadata the host owns.
33 *
4- * Parameter metadata (names, defaults, slider ranges), species names and the
5- * dealiasing degree stay in src/solver/models.ts: the host owns those, and
6- * sharing them means the reference solver used by the tests and the .m running
7- * on the GPU are always configured identically. This module only attaches each
8- * model's MATLAB source.
4+ * A model's *algorithm* lives in its .m file. Everything around it lives here:
5+ * the parameter names the .m may take as arguments, their defaults and slider
6+ * ranges, which grid fields to render, and the dealiasing degree. The .m
7+ * declares nothing about these — it just names the parameters it wants, and
8+ * `CompiledModel` matches each against this table.
99 *
10- * Naming convention, relied on by the app and documented in each .m:
10+ * Naming convention, documented in each .m:
1111 * `u`, `v`, ... grid fields the model computes and the app renders
1212 * `U`, `V`, ... the corresponding spectral state (uppercase)
1313 */
14-import { models, type ModelSpec, type ParamSpec } from '../solver/models.ts';
1514 import schnakenbergSource from '../../models/schnakenberg.m?raw';
1615 import brusselatorSource from '../../models/brusselator.m?raw';
1716 import allencahnSource from '../../models/allencahn.m?raw';
1817
19-const sources: Record<string, string> = {
20- schnakenberg: schnakenbergSource,
21- brusselator: brusselatorSource,
22- allencahn: allencahnSource,
23-};
18+export type Params = Record<string, number>;
19+
20+/** A tunable scalar the .m may take as an argument. */
21+export interface ParamSpec {
22+ key: string;
23+ label: string;
24+ value: number;
25+ min: number;
26+ max: number;
27+ step: number;
28+}
2429
2530 export interface MModel {
2631 key: string;
@@ -31,29 +36,107 @@ export interface MModel {
3136 /** Spectral state names the .m advances. */
3237 state: string[];
3338 params: ParamSpec[];
34- /** Polynomial degree of the reaction, for grid dealiasing. */
39+ /** Polynomial degree of the reaction in the fields, for grid dealiasing. */
3540 pdeg: number;
36- /** Amplitude of the seeded perturbation. */
41+ /** Amplitude of the seeded perturbation handed to `init`. */
3742 seedAmp: number;
3843 /** MATLAB source — the algorithm itself. */
3944 source: string;
4045 }
4146
42-const fromSpec = (m: ModelSpec): MModel => ({
43- key: m.key,
44- label: m.label,
45- blurb: m.blurb,
46- species: m.species,
47- state: m.species.map((s) => s.toUpperCase()),
48- params: m.params,
49- pdeg: m.pdeg,
50- seedAmp: m.seedAmp,
51- source: sources[m.key],
52-});
53-
54-export const mModels: MModel[] = models
55- .filter((m) => sources[m.key] !== undefined)
56- .map(fromSpec);
47+/** Spectral state names follow the grid-field names, uppercased. */
48+const stateFor = (species: string[]): string[] => species.map((s) => s.toUpperCase());
49+
50+const schnakenberg: MModel = {
51+ key: 'schnakenberg',
52+ label: 'Schnakenberg',
53+ blurb:
54+ 'Turing spots. The homogeneous state is stable to uniform perturbations ' +
55+ 'but unstable to degrees 14 ≤ l ≤ 40, most strongly at l = 24.',
56+ species: ['u', 'v'],
57+ state: stateFor(['u', 'v']),
58+ params: [
59+ { key: 'a', label: 'a', value: 0.1, min: 0.01, max: 0.5, step: 0.01 },
60+ { key: 'b', label: 'b', value: 0.9, min: 0.1, max: 2, step: 0.05 },
61+ { key: 'D1', label: 'D₁', value: 4e-4, min: 1e-5, max: 5e-3, step: 1e-5 },
62+ { key: 'D2', label: 'D₂', value: 8e-3, min: 1e-4, max: 5e-2, step: 1e-4 },
63+ { key: 'dt', label: 'dt', value: 0.05, min: 0.005, max: 0.5, step: 0.005 },
64+ ],
65+ pdeg: 3,
66+ seedAmp: 1e-2,
67+ source: schnakenbergSource,
68+};
69+
70+const brusselator: MModel = {
71+ key: 'brusselator',
72+ label: 'Brusselator',
73+ blurb:
74+ 'Turing stripes and spots, from a smaller diffusivity contrast than ' +
75+ 'Schnakenberg but with a stiffer reaction.',
76+ species: ['u', 'v'],
77+ state: stateFor(['u', 'v']),
78+ params: [
79+ { key: 'A', label: 'A', value: 3, min: 0.5, max: 6, step: 0.1 },
80+ { key: 'B', label: 'B', value: 9, min: 1, max: 15, step: 0.25 },
81+ { key: 'D1', label: 'D₁', value: 3.33e-3, min: 1e-4, max: 2e-2, step: 1e-4 },
82+ { key: 'D2', label: 'D₂', value: 1.67e-2, min: 1e-3, max: 1e-1, step: 1e-3 },
83+ { key: 'dt', label: 'dt', value: 0.02, min: 0.002, max: 0.1, step: 0.002 },
84+ ],
85+ pdeg: 3,
86+ seedAmp: 1e-2,
87+ source: brusselatorSource,
88+};
89+
90+const allencahn: MModel = {
91+ key: 'allencahn',
92+ label: 'Allen–Cahn',
93+ blurb:
94+ 'A single species: interfaces form and then coarsen until one domain ' +
95+ 'swallows the sphere.',
96+ species: ['u'],
97+ state: stateFor(['u']),
98+ params: [
99+ { key: 'eps2', label: 'ε²', value: 1e-3, min: 1e-4, max: 1e-2, step: 1e-4 },
100+ { key: 'dt', label: 'dt', value: 0.02, min: 0.002, max: 0.2, step: 0.002 },
101+ ],
102+ pdeg: 3,
103+ seedAmp: 1e-2,
104+ source: allencahnSource,
105+};
106+
107+export const mModels: MModel[] = [schnakenberg, brusselator, allencahn];
57108
58109 export const mModelByKey = (key: string): MModel | undefined =>
59110 mModels.find((m) => m.key === key);
111+
112+export const defaultParams = (m: MModel): Params =>
113+ Object.fromEntries(m.params.map((p) => [p.key, p.value]));
114+
115+/** Named parameter presets shown in the UI dropdown. The pattern length scale
116+ * goes as 1/sqrt(D), so scaling both diffusivities moves the spot size without
117+ * changing the dynamics. */
118+export interface Preset {
119+ key: string;
120+ label: string;
121+ modelKey: string;
122+ /** Overrides applied on top of the model's default parameters. */
123+ params?: Params;
124+}
125+
126+export const presets: Preset[] = [
127+ { key: 'schnak-spots', label: 'Schnakenberg — spots', modelKey: 'schnakenberg' },
128+ {
129+ key: 'schnak-coarse',
130+ label: 'Schnakenberg — coarse spots',
131+ modelKey: 'schnakenberg',
132+ params: { D1: 1e-3, D2: 2e-2 },
133+ },
134+ {
135+ key: 'schnak-fine',
136+ label: 'Schnakenberg — fine spots',
137+ modelKey: 'schnakenberg',
138+ params: { D1: 1.6e-4, D2: 3.2e-3 },
139+ },
140+ { key: 'brussel', label: 'Brusselator — stripes & spots', modelKey: 'brusselator' },
141+ { key: 'allencahn', label: 'Allen–Cahn — coarsening', modelKey: 'allencahn' },
142+];
src/mgpu/session.tsadded+108−0View file
@@ -0,0 +1,108 @@
1+/**
2+ * One running model: grid, transforms, compiled .m, seeded state.
3+ *
4+ * Everything that is not rendering. The app, the desktop benchmark and the
5+ * tests all go through this, so there is one place that decides how a model is
6+ * turned into something running on the GPU — and nothing about it is
7+ * browser-specific beyond needing a GPUDevice.
8+ */
9+import { ShtPlan } from '../sht/sht.ts';
10+import { gridForLmax, type ShtConfig } from '../sht/layout.ts';
11+import { GpuModel, type ModelParams } from './model.ts';
12+import { seededNoise } from './noise.ts';
13+import type { MModel } from './registry.ts';
14+
15+export interface ModelSessionOptions {
16+ device: GPUDevice;
17+ model: MModel;
18+ params: ModelParams;
19+ lmax: number;
20+ /** Override the model source — the editor's working copy. */
21+ source?: string;
22+}
23+
24+export class ModelSession {
25+ readonly model: MModel;
26+ readonly cfg: ShtConfig;
27+ readonly sht: ShtPlan;
28+ readonly gpu: GpuModel;
29+ readonly npts: number;
30+
31+ /** Model time and step count since the last seeding. */
32+ t = 0;
33+ steps = 0;
34+
35+ #params: ModelParams;
36+
37+ private constructor(init: {
38+ model: MModel;
39+ cfg: ShtConfig;
40+ sht: ShtPlan;
41+ gpu: GpuModel;
42+ params: ModelParams;
43+ }) {
44+ this.model = init.model;
45+ this.cfg = init.cfg;
46+ this.sht = init.sht;
47+ this.gpu = init.gpu;
48+ this.npts = init.cfg.nlat * init.cfg.nphi;
49+ this.#params = init.params;
50+ }
51+
52+ static async create(opts: ModelSessionOptions): Promise<ModelSession> {
53+ const { device, model, params, lmax } = opts;
54+ const { nlat, nphi } = gridForLmax(lmax, model.pdeg);
55+ const cfg = { lmax, mmax: lmax, nlat, nphi };
56+ const sht = await ShtPlan.create(device, cfg);
57+ try {
58+ const gpu = await GpuModel.create({
59+ device,
60+ sht,
61+ cfg,
62+ source: opts.source ?? model.source,
63+ paramNames: model.params.map((p) => p.key),
64+ state: model.state,
65+ view: model.species,
66+ });
67+ gpu.setParams(params);
68+ return new ModelSession({ model, cfg, sht, gpu, params });
69+ } catch (e) {
70+ // The transform plan owns GPU buffers; do not leak them on a compile error.
71+ sht.destroy();
72+ throw e;
73+ }
74+ }
75+
76+ /** Run `init` from a seeded perturbation, resetting model time. */
77+ seed(seed: number): void {
78+ this.gpu.init(seededNoise(this.npts, this.model.seedAmp, seed));
79+ this.t = 0;
80+ this.steps = 0;
81+ }
82+
83+ setParams(params: ModelParams): void {
84+ this.#params = params;
85+ this.gpu.setParams(params);
86+ }
87+
88+ /** Advance `n` steps. Synchronous: records and submits, nothing read back. */
89+ step(n = 1): void {
90+ this.gpu.step(n);
91+ this.t += n * (this.#params.dt ?? 0);
92+ this.steps += n;
93+ }
94+
95+ /** Read a named value (a grid field or the spectral state). */
96+ read(name: string): Promise<Float32Array> {
97+ return this.gpu.read(name);
98+ }
99+
100+ describe(): { init: string[]; step: string[] } {
101+ return this.gpu.describe();
102+ }
103+
104+ destroy(): void {
105+ this.gpu.destroy();
106+ this.sht.destroy();
107+ }
108+}
src/mgpu/wgsl.tsmodified+9−1View file
@@ -195,8 +195,16 @@ function emitExpr(e: IRExpr, ctx: Ctx): string {
195195 const fn = CALL_FNS[e.name];
196196 const b = getBuiltin(e.name);
197197 if (!fn || !b?.elementwise) {
198+ // A call numbl resolved to another function in the file gets a mangled
199+ // specialization name; a builtin keeps its source-level name. Only the
200+ // model's entry points are compiled, so a helper is a distinct failure
201+ // from an unsupported builtin and deserves to say so.
202+ const isUserFunction = e.cName !== e.name;
198203 throw new UnsupportedOnGpu(
199- `'${e.name}' cannot be evaluated element-wise on the GPU`,
204+ isUserFunction
205+ ? `'${e.name}' is a function defined in this model. Only init and ` +
206+ `step are compiled — inline its body into the caller.`
207+ : `'${e.name}' cannot be evaluated element-wise on the GPU`,
200208 e.span,
201209 );
202210 }
src/sht/layout.tsmodified+12−0View file
@@ -45,3 +45,15 @@ export function validateConfig(cfg: ShtConfig): void {
4545 export function isPowerOfTwo(n: number): boolean {
4646 return n > 0 && (n & (n - 1)) === 0;
4747 }
48+
49+/** Grid sizes for a given lmax, dealiased for a reaction of polynomial degree
50+ * `pdeg` (the rule from websph's reference implementation):
51+ * nlat >= ((pdeg+1)*lmax+1)/2, nphi >= (pdeg+1)*lmax+1. nphi is rounded up to
52+ * a power of two to keep the GPU FFT path. */
53+export function gridForLmax(lmax: number, pdeg: number): { nlat: number; nphi: number } {
54+ const minLat = Math.max(lmax + 1, ((pdeg + 1) * lmax + 1) / 2);
55+ const nlat = 2 * Math.ceil(minLat / 2);
56+ let nphi = 1;
57+ while (nphi < (pdeg + 1) * lmax + 1) nphi *= 2;
58+ return { nlat, nphi };
59+}
src/sht/sht.tsmodified+23−0View file
@@ -327,6 +327,29 @@ export class ShtPlan {
327327 }
328328 }
329329
330+/** Best-effort human-readable adapter name, so it is clear which GPU (or
331+ * software rasterizer) is actually running the transforms. */
332+export async function describeAdapter(device: GPUDevice): Promise<string> {
333+ const fmt = (info: GPUAdapterInfo | undefined): string => {
334+ if (!info) return '';
335+ const parts = [info.description, info.device, info.vendor].filter(
336+ (s): s is string => !!s && s.length > 0,
337+ );
338+ const name = parts[0] ?? '';
339+ return info.architecture && !name.includes(info.architecture)
340+ ? `${name} (${info.architecture})`.trim()
341+ : name;
342+ };
343+ const own = fmt((device as GPUDevice & { adapterInfo?: GPUAdapterInfo }).adapterInfo);
344+ if (own) return own;
345+ try {
346+ const adapter = await navigator.gpu.requestAdapter();
347+ return fmt(adapter?.info);
348+ } catch {
349+ return '';
350+ }
351+}
352+
330353 /** Request an adapter/device suitable for the transforms. */
331354 export async function requestShtDevice(): Promise<GPUDevice> {
332355 if (!navigator.gpu) throw new Error('WebGPU is not available in this browser');
src/solver/backend.tsdeleted+0−110View file
@@ -1,110 +0,0 @@
1-/**
2- * Backend abstraction over the spherical harmonic transform, mirroring the
3- * websph "porting boundary": the solver only ever needs coeffs->vals,
4- * vals->coeffs, and the grid. Spectral layout is the SHTNS convention used
5- * by shtns-webgpu (see src/sht/layout.ts): complex interleaved [re, im],
6- * m >= 0 only, m-major ordering, orthonormal + Condon-Shortley.
7- */
8-import { ShtPlan, requestShtDevice } from '../sht/sht.ts';
9-import { ShtReference } from '../sht/reference.ts';
10-import type { ShtConfig } from '../sht/layout.ts';
11-
12-export interface ShtBackend {
13- readonly cfg: ShtConfig;
14- readonly nlm: number;
15- /** cos(colatitude), length nlat, decreasing (north to south). */
16- readonly cosTheta: Float64Array;
17- readonly kind: 'webgpu' | 'cpu';
18- synth(qlm: Float64Array): Promise<Float32Array | Float64Array>;
19- analys(spat: Float64Array): Promise<Float32Array | Float64Array>;
20- destroy(): void;
21-}
22-
23-/** fp32 WebGPU backend (fast path). */
24-export class GpuBackend implements ShtBackend {
25- readonly kind = 'webgpu';
26- readonly cfg: ShtConfig;
27- readonly nlm: number;
28- readonly cosTheta: Float64Array;
29- #plan: ShtPlan;
30- #qlm32: Float32Array;
31- #spat32: Float32Array;
32-
33- private constructor(plan: ShtPlan) {
34- this.#plan = plan;
35- this.cfg = plan.cfg;
36- this.nlm = plan.nlm;
37- this.cosTheta = plan.cosTheta;
38- this.#qlm32 = new Float32Array(2 * plan.nlm);
39- this.#spat32 = new Float32Array(plan.cfg.nlat * plan.cfg.nphi);
40- }
41-
42- static async create(device: GPUDevice, cfg: ShtConfig): Promise<GpuBackend> {
43- return new GpuBackend(await ShtPlan.create(device, cfg));
44- }
45-
46- synth(qlm: Float64Array): Promise<Float32Array> {
47- this.#qlm32.set(qlm);
48- return this.#plan.synth(this.#qlm32);
49- }
50-
51- analys(spat: Float64Array): Promise<Float32Array> {
52- this.#spat32.set(spat);
53- return this.#plan.analys(this.#spat32);
54- }
55-
56- destroy(): void {
57- this.#plan.destroy();
58- }
59-}
60-
61-/** Best-effort human-readable adapter name, so it is clear which GPU (or
62- * software rasterizer) is actually running the transforms. */
63-export async function describeAdapter(device: GPUDevice): Promise<string> {
64- const fmt = (info: GPUAdapterInfo | undefined): string => {
65- if (!info) return '';
66- const parts = [info.description, info.device, info.vendor].filter(
67- (s): s is string => !!s && s.length > 0,
68- );
69- const name = parts[0] ?? '';
70- return info.architecture && !name.includes(info.architecture)
71- ? `${name} (${info.architecture})`.trim()
72- : name;
73- };
74- const own = fmt((device as GPUDevice & { adapterInfo?: GPUAdapterInfo }).adapterInfo);
75- if (own) return own;
76- try {
77- const adapter = await navigator.gpu.requestAdapter();
78- return fmt(adapter?.info);
79- } catch {
80- return '';
81- }
82-}
83-
84-/** f64 CPU backend by direct summation (slow; tests and no-WebGPU fallback). */
85-export class CpuBackend implements ShtBackend {
86- readonly kind = 'cpu';
87- readonly cfg: ShtConfig;
88- readonly nlm: number;
89- readonly cosTheta: Float64Array;
90- #ref: ShtReference;
91-
92- constructor(cfg: ShtConfig) {
93- this.#ref = new ShtReference(cfg);
94- this.cfg = cfg;
95- this.nlm = this.#ref.nlm;
96- this.cosTheta = this.#ref.ct;
97- }
98-
99- synth(qlm: Float64Array): Promise<Float64Array> {
100- return Promise.resolve(this.#ref.synth(qlm));
101- }
102-
103- analys(spat: Float64Array): Promise<Float64Array> {
104- return Promise.resolve(this.#ref.analys(spat));
105- }
106-
107- destroy(): void {}
108-}
109-
110-export { requestShtDevice };
src/solver/models.tsdeleted+0−206View file
@@ -1,206 +0,0 @@
1-/**
2- * Model metadata and the reference reaction terms.
3- *
4- * The parameter metadata here (names, labels, defaults, slider ranges) is what
5- * the app uses; `src/mgpu/registry.ts` attaches each model's .m source to it, so
6- * the .m running on the GPU and the reference solver used by the tests cannot be
7- * configured differently. The `reaction` / `init` closures below are the
8- * reference implementation only — the app runs the .m instead.
9- *
10- * Reaction-diffusion model presets, ported from websph's
11- * SphericalReactionDiffusionDriver.m. Each species k solves
12- *
13- * d(u_k)/dt = D_k * lap_s(u_k) + f_k(t, x, y, z, u_1, ..., u_N)
14- *
15- * on the unit sphere. Reactions are vectorized over the grid.
16- */
17-
18-export type Params = Record<string, number>;
19-
20-export interface ParamSpec {
21- key: string;
22- label: string;
23- value: number;
24- min: number;
25- max: number;
26- step: number;
27-}
28-
29-export interface ModelSpec {
30- key: string;
31- label: string;
32- blurb: string;
33- species: string[];
34- params: ParamSpec[];
35- /** Polynomial degree of the reaction in the fields (for dealiasing). */
36- pdeg: number;
37- /** Amplitude of the random perturbation seeded into the initial state. */
38- seedAmp: number;
39- diffusivities(p: Params): number[];
40- /** Fill out[k][i] with f_k evaluated at every grid point. */
41- reaction(
42- p: Params,
43- t: number,
44- x: Float64Array,
45- y: Float64Array,
46- z: Float64Array,
47- V: ArrayLike<number>[],
48- out: Float64Array[],
49- ): void;
50- /** Fill out[k][i] with the initial condition (noise added via randn). */
51- init(
52- p: Params,
53- x: Float64Array,
54- y: Float64Array,
55- z: Float64Array,
56- randn: () => number,
57- out: Float64Array[],
58- ): void;
59-}
60-
61-const schnakenberg: ModelSpec = {
62- key: 'schnakenberg',
63- label: 'Schnakenberg',
64- blurb:
65- 'Turing spots. The homogeneous state is stable to uniform perturbations ' +
66- 'but unstable to degrees 14 ≤ l ≤ 40, most strongly at l = 24.',
67- species: ['u', 'v'],
68- params: [
69- { key: 'a', label: 'a', value: 0.1, min: 0.01, max: 0.5, step: 0.01 },
70- { key: 'b', label: 'b', value: 0.9, min: 0.1, max: 2, step: 0.05 },
71- { key: 'D1', label: 'D₁', value: 4e-4, min: 1e-5, max: 5e-3, step: 1e-5 },
72- { key: 'D2', label: 'D₂', value: 8e-3, min: 1e-4, max: 5e-2, step: 1e-4 },
73- { key: 'dt', label: 'dt', value: 0.05, min: 0.005, max: 0.5, step: 0.005 },
74- ],
75- pdeg: 3,
76- seedAmp: 1e-2,
77- diffusivities: (p) => [p.D1, p.D2],
78- reaction(p, _t, _x, _y, _z, V, out) {
79- const [u, v] = V;
80- const [fu, fv] = out;
81- const a = p.a, b = p.b;
82- const n = fu.length;
83- for (let i = 0; i < n; i++) {
84- const ui = u[i], vi = v[i];
85- const uuv = ui * ui * vi;
86- fu[i] = a - ui + uuv;
87- fv[i] = b - uuv;
88- }
89- },
90- init(p, x, _y, _z, randn, out) {
91- const [u, v] = out;
92- const a = p.a, b = p.b;
93- const us = a + b;
94- const vs = b / (us * us);
95- const n = x.length;
96- for (let i = 0; i < n; i++) {
97- u[i] = us + this.seedAmp * randn();
98- v[i] = vs;
99- }
100- },
101-};
102-
103-const brusselator: ModelSpec = {
104- key: 'brusselator',
105- label: 'Brusselator',
106- blurb:
107- 'Turing stripes and spots, from a smaller diffusivity contrast than ' +
108- 'Schnakenberg but with a stiffer reaction.',
109- species: ['u', 'v'],
110- params: [
111- { key: 'A', label: 'A', value: 3, min: 0.5, max: 6, step: 0.1 },
112- { key: 'B', label: 'B', value: 9, min: 1, max: 15, step: 0.25 },
113- { key: 'D1', label: 'D₁', value: 3.33e-3, min: 1e-4, max: 2e-2, step: 1e-4 },
114- { key: 'D2', label: 'D₂', value: 1.67e-2, min: 1e-3, max: 1e-1, step: 1e-3 },
115- { key: 'dt', label: 'dt', value: 0.02, min: 0.002, max: 0.1, step: 0.002 },
116- ],
117- pdeg: 3,
118- seedAmp: 1e-2,
119- diffusivities: (p) => [p.D1, p.D2],
120- reaction(p, _t, _x, _y, _z, V, out) {
121- const [u, v] = V;
122- const [fu, fv] = out;
123- const A = p.A, B = p.B;
124- const n = fu.length;
125- for (let i = 0; i < n; i++) {
126- const ui = u[i], vi = v[i];
127- const uuv = ui * ui * vi;
128- fu[i] = A - (B + 1) * ui + uuv;
129- fv[i] = B * ui - uuv;
130- }
131- },
132- init(p, x, _y, _z, randn, out) {
133- const [u, v] = out;
134- const n = x.length;
135- for (let i = 0; i < n; i++) {
136- u[i] = p.A + this.seedAmp * randn();
137- v[i] = p.B / p.A;
138- }
139- },
140-};
141-
142-const allenCahn: ModelSpec = {
143- key: 'allencahn',
144- label: 'Allen–Cahn',
145- blurb:
146- 'A single species: interfaces form and then coarsen until one domain ' +
147- 'swallows the sphere.',
148- species: ['u'],
149- params: [
150- { key: 'eps2', label: 'ε²', value: 1e-3, min: 1e-4, max: 1e-2, step: 1e-4 },
151- { key: 'dt', label: 'dt', value: 0.02, min: 0.002, max: 0.2, step: 0.002 },
152- ],
153- pdeg: 3,
154- seedAmp: 1e-2,
155- diffusivities: (p) => [p.eps2],
156- reaction(_p, _t, _x, _y, _z, V, out) {
157- const [u] = V;
158- const [fu] = out;
159- const n = fu.length;
160- for (let i = 0; i < n; i++) {
161- const ui = u[i];
162- fu[i] = ui - ui * ui * ui;
163- }
164- },
165- init(_p, x, _y, _z, randn, out) {
166- const [u] = out;
167- const n = x.length;
168- for (let i = 0; i < n; i++) {
169- u[i] = this.seedAmp * randn();
170- }
171- },
172-};
173-
174-export const models: ModelSpec[] = [schnakenberg, brusselator, allenCahn];
175-
176-export const defaultParams = (m: ModelSpec): Params =>
177- Object.fromEntries(m.params.map((p) => [p.key, p.value]));
178-
179-/** Named parameter presets shown in the UI dropdown. The pattern length
180- * scale goes as 1/sqrt(D), so scaling both diffusivities moves the spot
181- * size without changing the dynamics. */
182-export interface Preset {
183- key: string;
184- label: string;
185- modelKey: string;
186- /** Overrides applied on top of the model's default parameters. */
187- params?: Params;
188-}
189-
190-export const presets: Preset[] = [
191- { key: 'schnak-spots', label: 'Schnakenberg — spots', modelKey: 'schnakenberg' },
192- {
193- key: 'schnak-coarse',
194- label: 'Schnakenberg — coarse spots',
195- modelKey: 'schnakenberg',
196- params: { D1: 1e-3, D2: 2e-2 },
197- },
198- {
199- key: 'schnak-fine',
200- label: 'Schnakenberg — fine spots',
201- modelKey: 'schnakenberg',
202- params: { D1: 1.6e-4, D2: 3.2e-3 },
203- },
204- { key: 'brussel', label: 'Brusselator — stripes & spots', modelKey: 'brusselator' },
205- { key: 'allencahn', label: 'Allen–Cahn — coarsening', modelKey: 'allencahn' },
206-];
src/solver/simulation.tsdeleted+0−171View file
@@ -1,171 +0,0 @@
1-/**
2- * Reference solver — NOT what the app runs.
3- *
4- * The app executes the .m models under `models/` on the GPU (see `src/mgpu/`).
5- * This TypeScript port remains as an independent implementation of the same
6- * scheme, which is what makes it usable as the test oracle: `test/mgpuChecks.ts`
7- * runs both from the same seed and compares. Keep the two in step.
8- *
9- * IMEX Euler reaction-diffusion timestepper on the sphere, ported from
10- * websph's SphericalReactionDiffusion.m. Diffusion is implicit and diagonal
11- * in spherical-harmonic space (Laplace-Beltrami eigenvalues -l(l+1));
12- * reaction is explicit on the grid:
13- *
14- * (I - dt*D_k*lap_s) u_k^{n+1} = u_k^n + dt*f_k(u^n)
15- */
16-import type { ShtBackend } from './backend.ts';
17-import type { ModelSpec, Params } from './models.ts';
18-import { lmIndex } from '../sht/layout.ts';
19-
20-/** Grid sizes for a given lmax, dealiased for a reaction of degree pdeg
21- * (see websph README): nlat >= ((pdeg+1)*lmax+1)/2, nlon >= (pdeg+1)*lmax+1.
22- * nphi is rounded up to a power of two to keep the GPU FFT path. */
23-export function gridForLmax(lmax: number, pdeg: number): { nlat: number; nphi: number } {
24- const minLat = Math.max(lmax + 1, ((pdeg + 1) * lmax + 1) / 2);
25- const nlat = 2 * Math.ceil(minLat / 2);
26- let nphi = 1;
27- while (nphi < (pdeg + 1) * lmax + 1) nphi *= 2;
28- return { nlat, nphi };
29-}
30-
31-/** Seeded normal deviates: mulberry32 + Box-Muller. */
32-export function makeRandn(seed: number): () => number {
33- let s = seed >>> 0;
34- const rand = () => {
35- s = (s + 0x6d2b79f5) >>> 0;
36- let t = s;
37- t = Math.imul(t ^ (t >>> 15), t | 1);
38- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
39- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
40- };
41- let spare: number | null = null;
42- return () => {
43- if (spare !== null) {
44- const v = spare;
45- spare = null;
46- return v;
47- }
48- let u = 0;
49- while (u === 0) u = rand();
50- const r = Math.sqrt(-2 * Math.log(u));
51- const th = 2 * Math.PI * rand();
52- spare = r * Math.sin(th);
53- return r * Math.cos(th);
54- };
55-}
56-
57-export class Simulation {
58- readonly backend: ShtBackend;
59- readonly model: ModelSpec;
60- readonly params: Params;
61- readonly nspecies: number;
62-
63- /** Spectral state, one interleaved-complex Float64Array (2*nlm) per species. */
64- U: Float64Array[];
65- /** Grid values per species as of the START of the last step (one step
66- * behind U; recomputed as the first stage of the next step). */
67- V: (Float32Array | Float64Array)[];
68- t = 0;
69- stepCount = 0;
70-
71- /** Cartesian coordinates of the grid points (nlat*nphi each). */
72- readonly x: Float64Array;
73- readonly y: Float64Array;
74- readonly z: Float64Array;
75- /** Laplace-Beltrami eigenvalues l*(l+1) per spectral index (length nlm). */
76- readonly lam: Float64Array;
77-
78- #R: Float64Array[]; // reaction scratch, one grid array per species
79- #m0Imag: number[]; // interleaved-array positions of m=0 imaginary parts
80-
81- constructor(backend: ShtBackend, model: ModelSpec, params: Params) {
82- this.backend = backend;
83- this.model = model;
84- this.params = params;
85- this.nspecies = model.species.length;
86-
87- const { lmax, mmax, nlat, nphi } = backend.cfg;
88- const npts = nlat * nphi;
89- this.x = new Float64Array(npts);
90- this.y = new Float64Array(npts);
91- this.z = new Float64Array(npts);
92- for (let i = 0; i < nlat; i++) {
93- const ct = backend.cosTheta[i];
94- const st = Math.sqrt(Math.max(0, 1 - ct * ct));
95- for (let j = 0; j < nphi; j++) {
96- const phi = (2 * Math.PI * j) / nphi;
97- const idx = i * nphi + j;
98- this.x[idx] = st * Math.cos(phi);
99- this.y[idx] = st * Math.sin(phi);
100- this.z[idx] = ct;
101- }
102- }
103-
104- this.lam = new Float64Array(backend.nlm);
105- for (let m = 0; m <= mmax; m++) {
106- for (let l = m; l <= lmax; l++) {
107- this.lam[lmIndex(lmax, l, m)] = l * (l + 1);
108- }
109- }
110- this.#m0Imag = [];
111- for (let l = 0; l <= lmax; l++) {
112- this.#m0Imag.push(2 * lmIndex(lmax, l, 0) + 1);
113- }
114-
115- this.U = [];
116- this.V = [];
117- this.#R = [];
118- for (let k = 0; k < this.nspecies; k++) {
119- this.U.push(new Float64Array(2 * backend.nlm));
120- this.V.push(new Float64Array(npts));
121- this.#R.push(new Float64Array(npts));
122- }
123- }
124-
125- /** Project the initial conditions (band-limiting the seed noise). */
126- async init(seed: number): Promise<void> {
127- const grids = this.#R;
128- this.model.init(this.params, this.x, this.y, this.z, makeRandn(seed), grids);
129- for (let k = 0; k < this.nspecies; k++) {
130- const q = await this.backend.analys(grids[k]);
131- this.U[k].set(q);
132- this.#cleanM0(this.U[k]);
133- this.V[k] = await this.backend.synth(this.U[k]);
134- }
135- this.t = 0;
136- this.stepCount = 0;
137- }
138-
139- /** One IMEX Euler step. */
140- async step(): Promise<void> {
141- const dt = this.params.dt;
142- const D = this.model.diffusivities(this.params);
143-
144- // Evaluate every species on the grid before reacting any of them
145- for (let k = 0; k < this.nspecies; k++) {
146- this.V[k] = await this.backend.synth(this.U[k]);
147- }
148-
149- this.model.reaction(this.params, this.t, this.x, this.y, this.z, this.V, this.#R);
150-
151- for (let k = 0; k < this.nspecies; k++) {
152- const Rlm = await this.backend.analys(this.#R[k]);
153- const U = this.U[k];
154- const dD = dt * D[k];
155- for (let i = 0; i < this.backend.nlm; i++) {
156- const fac = 1 / (1 + dD * this.lam[i]);
157- U[2 * i] = (U[2 * i] + dt * Rlm[2 * i]) * fac;
158- U[2 * i + 1] = (U[2 * i + 1] + dt * Rlm[2 * i + 1]) * fac;
159- }
160- this.#cleanM0(U);
161- }
162-
163- this.t += dt;
164- this.stepCount++;
165- }
166-
167- /** m=0 coefficients of a real field are purely real; drop numerical junk. */
168- #cleanM0(U: Float64Array): void {
169- for (const p of this.#m0Imag) U[p] = 0;
170- }
171-}
test/analyticChecks.tsadded+257−0View file
@@ -0,0 +1,257 @@
1+/**
2+ * Correctness of the .m -> WGSL path, against closed-form answers.
3+ *
4+ * These replace what used to be a comparison against a second TypeScript
5+ * implementation of the same scheme. Checking against arithmetic is stronger:
6+ * two implementations agreeing only shows they share assumptions, whereas an
7+ * exact recurrence pins the result. Each test picks a case whose evolution is
8+ * known in closed form, runs it through the real pipeline — MATLAB source,
9+ * numbl lowering, generated WGSL, GPU transforms — and compares.
10+ *
11+ * A: a linear reaction makes every spherical-harmonic mode independent, with a
12+ * known growth factor per degree. Checks the transform round-trip, the
13+ * eigenvalue mapping, the IMEX update and the state feedback.
14+ * B: a nonlinear reaction on a uniform field follows the scalar ODE map
15+ * exactly. Checks that a generated kernel evaluates a nonlinear reaction.
16+ * C: a small perturbation of the Schnakenberg fixed point follows the
17+ * linearized 2x2 IMEX recurrence, and the expected mode is unstable. Checks
18+ * a real two-species model.
19+ *
20+ * Everything runs in fp32 on the GPU, so tolerances are set by fp32 round-off
21+ * (~1e-7 relative) rather than by the scheme.
22+ */
23+import { ShtPlan } from '../src/sht/sht.ts';
24+import { gridForLmax, lmIndex, nlmCalc, type ShtConfig } from '../src/sht/layout.ts';
25+import { GpuModel } from '../src/mgpu/model.ts';
26+import { mModelByKey, defaultParams, type MModel, type ParamSpec } from '../src/mgpu/registry.ts';
27+import linearSource from './models/linear.m?raw';
28+import logisticSource from './models/logistic.m?raw';
29+
30+export type Check = (name: string, ok: boolean, detail: string) => void;
31+export type Log = (s: string) => void;
32+
33+const param = (key: string, value: number): ParamSpec => ({
34+ key, label: key, value, min: -1e9, max: 1e9, step: 1,
35+});
36+
37+/** A one-species test model with an arbitrary parameter list. */
38+const testModel = (key: string, source: string, params: string[]): MModel => ({
39+ key,
40+ label: key,
41+ blurb: '',
42+ species: ['u'],
43+ state: ['U'],
44+ params: params.map((p) => param(p, 0)),
45+ pdeg: 1,
46+ seedAmp: 1,
47+ source,
48+});
49+
50+async function makeModel(
51+ device: GPUDevice,
52+ model: MModel,
53+ cfg: ShtConfig,
54+): Promise<{ sht: ShtPlan; gpu: GpuModel }> {
55+ const sht = await ShtPlan.create(device, cfg);
56+ const gpu = await GpuModel.create({
57+ device,
58+ sht,
59+ cfg,
60+ source: model.source,
61+ paramNames: model.params.map((p) => p.key),
62+ state: model.state,
63+ view: model.species,
64+ });
65+ return { sht, gpu };
66+}
67+
68+export async function analyticChecks(
69+ device: GPUDevice,
70+ check: Check,
71+ log: Log,
72+): Promise<void> {
73+ // ---- A: linear reaction, exact per-mode growth factor -----------------
74+ {
75+ const lmax = 15;
76+ const { nlat, nphi } = gridForLmax(lmax, 1);
77+ const cfg = { lmax, mmax: lmax, nlat, nphi };
78+ const nlm = nlmCalc(lmax, lmax);
79+ const c = -0.3;
80+ const D = 0.01;
81+ const dt = 0.1;
82+ const nsteps = 20;
83+
84+ const model = testModel('linear', linearSource, ['c', 'D', 'dt']);
85+ const { sht, gpu } = await makeModel(device, model, cfg);
86+ gpu.setParams({ c, D, dt });
87+
88+ // A single (l, m) mode, written straight into the spectral state.
89+ const l = 5;
90+ const m = 2;
91+ const idx = lmIndex(lmax, l, m);
92+ const U0 = new Float32Array(2 * nlm);
93+ U0[2 * idx] = 0.8;
94+ U0[2 * idx + 1] = -0.35;
95+ gpu.upload('U', U0);
96+
97+ gpu.step(nsteps);
98+ const U = await gpu.read('U');
99+
100+ const g = (1 + dt * c) / (1 + dt * D * l * (l + 1));
101+ const factor = g ** nsteps;
102+ const wantRe = 0.8 * factor;
103+ const wantIm = -0.35 * factor;
104+ const errRe = Math.abs(U[2 * idx] - wantRe);
105+ const errIm = Math.abs(U[2 * idx + 1] - wantIm);
106+ check(
107+ 'A: linear reaction follows the exact per-mode recurrence',
108+ errRe < 2e-6 && errIm < 2e-6,
109+ `err (${errRe.toExponential(2)}, ${errIm.toExponential(2)}) after ${nsteps} steps`,
110+ );
111+
112+ // Nothing may leak into the other modes.
113+ let leak = 0;
114+ for (let i = 0; i < nlm; i++) {
115+ if (i === idx) continue;
116+ leak = Math.max(leak, Math.abs(U[2 * i]), Math.abs(U[2 * i + 1]));
117+ }
118+ check('A: no leakage into other modes', leak < 2e-6, `max |other| ${leak.toExponential(2)}`);
119+
120+ gpu.destroy();
121+ sht.destroy();
122+ }
123+
124+ // ---- B: nonlinear reaction on a uniform field, exact ODE map ----------
125+ {
126+ const lmax = 15;
127+ const { nlat, nphi } = gridForLmax(lmax, 3);
128+ const cfg = { lmax, mmax: lmax, nlat, nphi };
129+ const npts = nlat * nphi;
130+ const r = 0.7;
131+ const D = 0.01;
132+ const dt = 0.05;
133+ const nsteps = 25;
134+ const u0 = 0.3;
135+
136+ const model = testModel('logistic', logisticSource, ['r', 'D', 'dt']);
137+ const { sht, gpu } = await makeModel(device, model, cfg);
138+ gpu.setParams({ r, D, dt });
139+
140+ // Uniform initial field: stays uniform, and diffusion cannot touch it.
141+ const field = new Float32Array(npts).fill(u0);
142+ gpu.init(field);
143+ const Ustart = await gpu.read('U');
144+ gpu.step(nsteps);
145+ const Uend = await gpu.read('U');
146+
147+ // Read the *state*, not the `u` output: a model computes its grid fields
148+ // from the state at the START of the step (`u = synth(U)` precedes the
149+ // update), so the rendered field lags the state by one step. The l=0
150+ // coefficient of a uniform field scales linearly with its value, so the
151+ // ratio gives the value back without needing Y_00's normalization.
152+ const got = u0 * (Uend[0] / Ustart[0]);
153+
154+ let want = u0;
155+ for (let s = 0; s < nsteps; s++) want += dt * r * want * (1 - want);
156+
157+ const err = Math.abs(got - want);
158+ check(
159+ 'B: uniform nonlinear reaction follows the scalar ODE map',
160+ err < 5e-6,
161+ `${got.toFixed(7)} vs ${want.toFixed(7)}, err ${err.toExponential(2)}`,
162+ );
163+
164+ // And it must still be uniform: any structure would mean the kernel is
165+ // reading the wrong elements.
166+ const u = await gpu.read('u');
167+ let lo = Infinity;
168+ let hi = -Infinity;
169+ for (const v of u) {
170+ if (v < lo) lo = v;
171+ if (v > hi) hi = v;
172+ }
173+ check(
174+ 'B: the field stays uniform',
175+ hi - lo < 1e-6,
176+ `spread ${(hi - lo).toExponential(2)}`,
177+ );
178+
179+ gpu.destroy();
180+ sht.destroy();
181+ }
182+
183+ // ---- C: linearized Turing recurrence on a real two-species model ------
184+ {
185+ const model = mModelByKey('schnakenberg')!;
186+ const p = defaultParams(model);
187+ const lmax = 31;
188+ const { nlat, nphi } = gridForLmax(lmax, model.pdeg);
189+ const cfg = { lmax, mmax: lmax, nlat, nphi };
190+ const nlm = nlmCalc(lmax, lmax);
191+ const npts = nlat * nphi;
192+
193+ const { sht, gpu } = await makeModel(device, model, cfg);
194+ gpu.setParams(p);
195+
196+ // Seed the exact homogeneous fixed point by handing init a zero
197+ // perturbation, then add a small single-mode bump to u only.
198+ gpu.init(new Float32Array(npts));
199+ const l = 24;
200+ const m = 7;
201+ const idx = lmIndex(lmax, l, m);
202+ const eps = 1e-6;
203+ const U0 = await gpu.read('U');
204+ const V0 = await gpu.read('V');
205+ const Upert = Float32Array.from(U0);
206+ Upert[2 * idx] += eps;
207+ gpu.upload('U', Upert);
208+ gpu.upload('V', V0);
209+
210+ const nsteps = 40;
211+ gpu.step(nsteps);
212+ const U = await gpu.read('U');
213+ const V = await gpu.read('V');
214+
215+ // Jacobian of (a - u + u^2 v, b - u^2 v) at the fixed point us = a+b,
216+ // vs = b/us^2, with diffusion applied implicitly per species.
217+ const us = p.a + p.b;
218+ const vs = p.b / (us * us);
219+ const J = [
220+ [-1 + 2 * us * vs, us * us],
221+ [-2 * us * vs, -us * us],
222+ ];
223+ const lam = l * (l + 1);
224+ const du = 1 / (1 + p.dt * p.D1 * lam);
225+ const dv = 1 / (1 + p.dt * p.D2 * lam);
226+ let cu = eps;
227+ let cv = 0;
228+ for (let s = 0; s < nsteps; s++) {
229+ const nu = (cu + p.dt * (J[0][0] * cu + J[0][1] * cv)) * du;
230+ const nv = (cv + p.dt * (J[1][0] * cu + J[1][1] * cv)) * dv;
231+ cu = nu;
232+ cv = nv;
233+ }
234+
235+ const gotU = U[2 * idx] - U0[2 * idx];
236+ const gotV = V[2 * idx] - V0[2 * idx];
237+ const relU = Math.abs(gotU - cu) / Math.max(Math.abs(cu), 1e-30);
238+ const relV = Math.abs(gotV - cv) / Math.max(Math.abs(cv), 1e-30);
239+ // Looser than A and B by design: a 1e-6 perturbation sits on a state of
240+ // order 1, so fp32 keeps only ~4 significant digits of it.
241+ check(
242+ 'C: perturbation follows the linearized 2x2 IMEX recurrence',
243+ relU < 5e-3 && relV < 5e-3,
244+ `rel err (${relU.toExponential(2)}, ${relV.toExponential(2)})`,
245+ );
246+ check(
247+ `C: the (l=${l}, m=${m}) mode is unstable`,
248+ Math.abs(cu) > eps && Math.abs(gotU) > eps,
249+ `|c_u| ${eps.toExponential(2)} -> ${Math.abs(gotU).toExponential(2)}`,
250+ );
251+
252+ log(` C: growth over ${nsteps} steps = ${(Math.abs(cu) / eps).toFixed(3)}x (predicted)`);
253+
254+ gpu.destroy();
255+ sht.destroy();
256+ }
257+}
test/mgpuChecks.tsdeleted+0−202View file
@@ -1,202 +0,0 @@
1-/**
2- * Correctness of the .m -> WGSL path, against the TypeScript solver.
3- *
4- * src/solver/ is no longer what the app runs, but it is an independent
5- * implementation of the same IMEX scheme, which makes it the oracle here: run
6- * both from the same seeded perturbation, through the same fp32 transforms, and
7- * compare the spectral state.
8- *
9- * The only difference between the two is where the reaction and the IMEX update
10- * happen — f64 on the CPU for the reference, fp32 in generated WGSL for the .m.
11- * The pattern-forming regime amplifies small differences, so this checks a
12- * short run.
13- */
14-import { GpuBackend } from '../src/solver/backend.ts';
15-import { Simulation, gridForLmax, makeRandn } from '../src/solver/simulation.ts';
16-import { models, defaultParams } from '../src/solver/models.ts';
17-import { ShtPlan } from '../src/sht/sht.ts';
18-import { GpuModel } from '../src/mgpu/model.ts';
19-import { mModels, type MModel } from '../src/mgpu/registry.ts';
20-
21-type Check = (name: string, ok: boolean, detail: string) => void;
22-type Log = (s: string) => void;
23-
24-function relL2(a: ArrayLike<number>, b: ArrayLike<number>): number {
25- let num = 0;
26- let den = 0;
27- for (let i = 0; i < a.length; i++) {
28- const d = a[i] - b[i];
29- num += d * d;
30- den += b[i] * b[i];
31- }
32- return Math.sqrt(num / Math.max(den, 1e-300));
33-}
34-
35-const LMAX = 31;
36-const STEPS = 10;
37-
38-/**
39- * Kernels the step of each model should compile to — one per element-wise line
40- * of MATLAB. This is a fusion guard: numbl's lowering emits one statement per
41- * *operator*, and the inline pass folds those back into per-line expression
42- * trees. If that stops happening the results stay correct but every operator
43- * becomes its own dispatch, which is exactly the silent regression to catch.
44- */
45-const EXPECTED_KERNELS: Record<string, number> = {
46- schnakenberg: 5,
47- brusselator: 5,
48- allencahn: 2,
49-};
50-
51-/** One model: compile it, run it, and compare against the reference solver. */
52-async function checkModel(
53- device: GPUDevice,
54- m: MModel,
55- check: Check,
56- log: Log,
57-): Promise<{ mgpuMs: number; refMs: number } | null> {
58- const spec = models.find((x) => x.key === m.key);
59- if (!spec) {
60- check(`${m.key}: reference model exists`, false, 'no matching ModelSpec');
61- return null;
62- }
63- const params = defaultParams(spec);
64- const { nlat, nphi } = gridForLmax(LMAX, m.pdeg);
65- const cfg = { lmax: LMAX, mmax: LMAX, nlat, nphi };
66- const npts = nlat * nphi;
67-
68- const sht = await ShtPlan.create(device, cfg);
69- const gpu = await GpuModel.create({
70- device,
71- sht,
72- cfg,
73- source: m.source,
74- paramNames: m.params.map((p) => p.key),
75- state: m.state,
76- view: m.species,
77- });
78- gpu.setParams(params);
79-
80- const plan = gpu.describe();
81- const kernels = plan.step.filter((l) => l.startsWith('kernel')).length;
82- const xforms = plan.step.filter(
83- (l) => l.startsWith('synth') || l.startsWith('analys'),
84- ).length;
85- log(
86- ` ${m.key}.m -> ${plan.step.length} ops/step ` +
87- `(${kernels} generated kernels, ${xforms} transforms)`,
88- );
89- const expected = EXPECTED_KERNELS[m.key];
90- check(
91- `${m.key}: element-wise lines fused into one kernel each`,
92- kernels === expected,
93- `${kernels} kernels (expected ${expected})`,
94- );
95-
96- // One randn per grid point, in index order. Rounded to f32 once and fed to
97- // BOTH sides, so the comparison is about the compute path, not the seed.
98- const randn = makeRandn(1);
99- const noise = new Float32Array(npts);
100- for (let i = 0; i < npts; i++) noise[i] = m.seedAmp * randn();
101-
102- gpu.init(noise);
103-
104- // Reference, seeded from the same perturbation by handing the model's own
105- // init the identical sequence.
106- const backend = await GpuBackend.create(device, cfg);
107- const ref = new Simulation(backend, spec, params);
108- {
109- let i = 0;
110- const feed = (): number => noise[i++] / m.seedAmp;
111- const grids = m.state.map(() => new Float64Array(npts));
112- spec.init(params, ref.x, ref.y, ref.z, feed, grids);
113- for (let k = 0; k < m.state.length; k++) {
114- ref.U[k].set(await backend.analys(grids[k]));
115- }
116- }
117-
118- let worstInit = 0;
119- for (let k = 0; k < m.state.length; k++) {
120- worstInit = Math.max(worstInit, relL2(await gpu.read(m.state[k]), ref.U[k]));
121- }
122- check(
123- `${m.key}: init matches reference`,
124- worstInit < 1e-5,
125- `rel L2 ${worstInit.toExponential(2)}`,
126- );
127-
128- for (let s = 0; s < STEPS; s++) await ref.step();
129- gpu.step(STEPS);
130-
131- let worst = 0;
132- let nan = false;
133- for (let k = 0; k < m.state.length; k++) {
134- const got = await gpu.read(m.state[k]);
135- worst = Math.max(worst, relL2(got, ref.U[k]));
136- for (const v of got) if (!Number.isFinite(v)) nan = true;
137- }
138- check(
139- `${m.key}: .m vs reference after ${STEPS} steps`,
140- worst < 2e-3 && !nan,
141- `rel L2 ${worst.toExponential(2)}${nan ? ', NaN!' : ''}`,
142- );
143-
144- // Guard against "both sides computed nothing".
145- const field = await gpu.read(m.species[0]);
146- let peak = 0;
147- for (const v of field) peak = Math.max(peak, Math.abs(v));
148- check(
149- `${m.key}: rendered field is non-trivial`,
150- peak > 1e-4,
151- `max |${m.species[0]}| ${peak.toExponential(2)}`,
152- );
153-
154- // Step rate. The reference maps a staging buffer on every transform, so it
155- // pays four driver round-trips per step; the .m path keeps everything in GPU
156- // buffers and submits once.
157- const TIMED = 50;
158- const t0 = performance.now();
159- gpu.step(TIMED);
160- await device.queue.onSubmittedWorkDone();
161- const mgpuMs = (performance.now() - t0) / TIMED;
162-
163- const t1 = performance.now();
164- for (let s = 0; s < TIMED; s++) await ref.step();
165- const refMs = (performance.now() - t1) / TIMED;
166-
167- gpu.destroy();
168- backend.destroy();
169- sht.destroy();
170- return { mgpuMs, refMs };
171-}
172-
173-export async function mgpuChecks(
174- device: GPUDevice,
175- check: Check,
176- log: Log,
177-): Promise<void> {
178- check(
179- 'models: registry populated',
180- mModels.length === models.length,
181- `${mModels.length} .m models`,
182- );
183-
184- for (const m of mModels) {
185- const timing = await checkModel(device, m, check, log);
186- if (!timing) continue;
187- const { mgpuMs, refMs } = timing;
188- log(
189- ` step rate: .m ${mgpuMs.toFixed(2)} ms vs reference ` +
190- `${refMs.toFixed(2)} ms (${(refMs / mgpuMs).toFixed(1)}x)`,
191- );
192- // A soft bound, not a performance target: on a software rasterizer the
193- // transforms dominate and the round-trips this path avoids are a small
194- // share of the total, so the ratio understates what it is worth on real
195- // hardware. The check is only that executing the .m did not make it worse.
196- check(
197- `${m.key}: step rate no worse than the readback path`,
198- mgpuMs < refMs * 1.15,
199- `${mgpuMs.toFixed(2)} vs ${refMs.toFixed(2)} ms/step`,
200- );
201- }
202-}
test/modelChecks.tsadded+81−0View file
@@ -0,0 +1,81 @@
1+/**
2+ * Every model the app offers: that it compiles, what it compiles to, and that it
3+ * runs stably and produces a pattern.
4+ *
5+ * Numerical correctness of the pipeline is analyticChecks.ts's job. This file is
6+ * about the models themselves and about the compilation staying as intended — in
7+ * particular the kernel count, which is a fusion guard: numbl's lowering emits
8+ * one statement per *operator*, and its inline pass folds those back into
9+ * per-line expression trees. If that stops happening the results stay correct
10+ * but every operator becomes its own dispatch, which is invisible except here.
11+ */
12+import { ModelSession } from '../src/mgpu/session.ts';
13+import { mModels, defaultParams } from '../src/mgpu/registry.ts';
14+import type { Check, Log } from './analyticChecks.ts';
15+
16+/** Kernels the step of each model should compile to — one per element-wise line. */
17+const EXPECTED_KERNELS: Record<string, number> = {
18+ schnakenberg: 5,
19+ brusselator: 5,
20+ allencahn: 2,
21+};
22+
23+const LMAX = 31;
24+const STEPS = 40;
25+
26+export async function modelChecks(
27+ device: GPUDevice,
28+ check: Check,
29+ log: Log,
30+): Promise<void> {
31+ check('models: registry populated', mModels.length === 3, `${mModels.length} models`);
32+
33+ for (const model of mModels) {
34+ const session = await ModelSession.create({
35+ device,
36+ model,
37+ params: defaultParams(model),
38+ lmax: LMAX,
39+ });
40+
41+ const plan = session.describe();
42+ const kernels = plan.step.filter((l) => l.startsWith('kernel')).length;
43+ const xforms = plan.step.filter(
44+ (l) => l.startsWith('synth') || l.startsWith('analys'),
45+ ).length;
46+ log(
47+ ` ${model.key}.m -> ${plan.step.length} ops/step ` +
48+ `(${kernels} generated kernels, ${xforms} transforms)`,
49+ );
50+ check(
51+ `${model.key}: element-wise lines fused into one kernel each`,
52+ kernels === EXPECTED_KERNELS[model.key],
53+ `${kernels} kernels (expected ${EXPECTED_KERNELS[model.key]})`,
54+ );
55+
56+ session.seed(1);
57+ session.step(STEPS);
58+
59+ // Every rendered field must be finite and have developed some contrast.
60+ for (const field of model.species) {
61+ const values = await session.read(field);
62+ let lo = Infinity;
63+ let hi = -Infinity;
64+ let finite = true;
65+ for (const v of values) {
66+ if (!Number.isFinite(v)) finite = false;
67+ if (v < lo) lo = v;
68+ if (v > hi) hi = v;
69+ }
70+ check(
71+ `${model.key}: '${field}' is finite and patterned after ${STEPS} steps`,
72+ finite && hi - lo > 1e-6,
73+ finite
74+ ? `range [${lo.toFixed(5)}, ${hi.toFixed(5)}]`
75+ : 'contains NaN or Infinity',
76+ );
77+ }
78+
79+ session.destroy();
80+ }
81+}
test/models/linear.madded+19−0View file
@@ -0,0 +1,19 @@
1+% Test model: a purely linear reaction, f(u) = c*u.
2+%
3+% Every spherical-harmonic mode then evolves independently under one IMEX Euler
4+% step, with a closed-form growth factor per degree l:
5+%
6+% U_lm^{n+1} = U_lm^n * (1 + dt*c) / (1 + dt*D*l(l+1))
7+%
8+% so a run can be checked against exact arithmetic rather than against another
9+% implementation. Used by the analytic tests; not offered in the app.
10+
11+function [U, u] = init(noise)
12+ U = analys(noise);
13+ u = synth(U);
14+end
15+
16+function [Un, u] = step(U, lam, c, D, dt)
17+ u = synth(U);
18+ Un = (U + dt * analys(c * u)) ./ (1 + (dt * D) * lam);
19+end
test/models/logistic.madded+24−0View file
@@ -0,0 +1,24 @@
1+% Test model: a nonlinear reaction, f(u) = r*u*(1 - u).
2+%
3+% Started from a *uniform* field, the state stays uniform, and diffusion does
4+% nothing to it (the l = 0 eigenvalue is zero). So every step is exactly the
5+% explicit Euler map of the scalar ODE,
6+%
7+% u^{n+1} = u^n + dt*r*u^n*(1 - u^n)
8+%
9+% which checks that the generated kernel evaluates a nonlinear reaction
10+% correctly, against arithmetic rather than another implementation. Used by the
11+% analytic tests; not offered in the app.
12+%
13+% The caller passes the initial grid field as `noise` (the name the app uses for
14+% its seeded perturbation); here the tests put an exact field there.
15+
16+function [U, u] = init(noise)
17+ U = analys(noise);
18+ u = synth(U);
19+end
20+
21+function [Un, u] = step(U, lam, r, D, dt)
22+ u = synth(U);
23+ Un = (U + dt * analys(r * u .* (1 - u))) ./ (1 + (dt * D) * lam);
24+end
test/test-page.tsmodified+48−117View file
@@ -1,13 +1,18 @@
11 /**
2- * Browser validation: the fp32 WebGPU solver against the f64 CPU solver.
3- * Runs identical seeded simulations on both backends and compares fields.
2+ * Browser validation, in the environment the demo actually ships to.
3+ *
4+ * Runs the same three check modules as `npm run test:node` — so both GPU stacks
5+ * (Dawn on the desktop, the browser's own here) get the same guarantees — plus a
6+ * long soak that only makes sense in a page.
7+ *
48 * Results are posted to window.__RESULTS__ for the headless runner.
59 */
6-import { GpuBackend, CpuBackend, requestShtDevice } from '../src/solver/backend.ts';
7-import { Simulation, gridForLmax } from '../src/solver/simulation.ts';
8-import { models, defaultParams } from '../src/solver/models.ts';
9-import { randomSpectrum } from '../src/sht/reference.ts';
10-import { mgpuChecks } from './mgpuChecks.ts';
10+import { requestShtDevice } from '../src/sht/sht.ts';
11+import { ModelSession } from '../src/mgpu/session.ts';
12+import { mModels, defaultParams } from '../src/mgpu/registry.ts';
13+import { transformChecks } from './transformChecks.ts';
14+import { analyticChecks } from './analyticChecks.ts';
15+import { modelChecks } from './modelChecks.ts';
1116
1217 declare global {
1318 interface Window {
@@ -30,43 +35,41 @@ function check(name: string, ok: boolean, detail: string): void {
3035 if (!ok) failures++;
3136 }
3237
33-function relL2(a: ArrayLike<number>, b: ArrayLike<number>): number {
34- let num = 0;
35- let den = 0;
36- for (let i = 0; i < a.length; i++) {
37- const d = a[i] - b[i];
38- num += d * d;
39- den += b[i] * b[i];
40- }
41- return Math.sqrt(num / Math.max(den, 1e-300));
42-}
43-
4438 /**
45- * Solver-only soak (no rendering), selected with ?soak=<steps>&lmax=<n>.
46- * Isolates the GPU transform loop from the three.js renderer.
39+ * Solver-only soak, selected with ?soak=<steps>&lmax=<n>. No rendering, so it
40+ * isolates the compiled .m and the transforms from three.js.
4741 */
4842 async function soak(steps: number, lmax: number): Promise<void> {
4943 const device = await requestShtDevice();
50- const schnak = models[0];
51- const { nlat, nphi } = gridForLmax(lmax, schnak.pdeg);
52- const gpu = await GpuBackend.create(device, { lmax, mmax: lmax, nlat, nphi });
53- const sim = new Simulation(gpu, schnak, defaultParams(schnak));
54- await sim.init(5);
55- log(`soak: ${steps} steps at lmax ${lmax} (grid ${nlat}x${nphi}), solver only`);
44+ const model = mModels[0];
45+ const session = await ModelSession.create({
46+ device,
47+ model,
48+ params: defaultParams(model),
49+ lmax,
50+ });
51+ session.seed(5);
52+ log(
53+ `soak: ${steps} steps at lmax ${lmax} ` +
54+ `(grid ${session.cfg.nlat}x${session.cfg.nphi}), solver only`,
55+ );
5656
57+ const BATCH = 25;
5758 const t0 = performance.now();
58- for (let s = 0; s < steps; s++) {
59- await sim.step();
60- if ((s + 1) % 100 === 0) {
61- let lo = Infinity;
62- let hi = -Infinity;
63- for (const v of sim.V[0]) {
64- if (v < lo) lo = v;
65- if (v > hi) hi = v;
66- }
67- const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory;
59+ for (let s = 0; s < steps; s += BATCH) {
60+ session.step(Math.min(BATCH, steps - s));
61+ const u = await session.read(model.species[0]);
62+ let lo = Infinity;
63+ let hi = -Infinity;
64+ for (const v of u) {
65+ if (v < lo) lo = v;
66+ if (v > hi) hi = v;
67+ }
68+ if ((s + BATCH) % 100 === 0) {
69+ const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } })
70+ .memory;
6871 log(
69- ` step ${s + 1} u in [${lo.toFixed(4)}, ${hi.toFixed(4)}]` +
72+ ` step ${session.steps} u in [${lo.toFixed(4)}, ${hi.toFixed(4)}]` +
7073 (mem ? ` heap ${(mem.usedJSHeapSize / 1048576).toFixed(1)} MB` : ''),
7174 );
7275 // yield so the page stays responsive and the runner can poll
@@ -74,10 +77,13 @@ async function soak(steps: number, lmax: number): Promise<void> {
7477 }
7578 }
7679 const ms = (performance.now() - t0) / steps;
80+
81+ const final = await session.read(model.species[0]);
7782 let finite = true;
78- for (const v of sim.V[0]) if (!Number.isFinite(v)) finite = false;
83+ for (const v of final) if (!Number.isFinite(v)) finite = false;
7984 check(`soak: ${steps} steps survived`, finite, `${ms.toFixed(1)} ms/step`);
80- gpu.destroy();
85+
86+ session.destroy();
8187 window.__RESULTS__ = { ok: failures === 0, lines };
8288 log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`);
8389 }
@@ -89,84 +95,9 @@ async function main(): Promise<void> {
8995 }
9096 const device = await requestShtDevice();
9197
92- // --- transform cross-check: GPU vs CPU on a random spectrum ---
93- {
94- const lmax = 31;
95- const { nlat, nphi } = gridForLmax(lmax, 1);
96- const cfg = { lmax, mmax: lmax, nlat, nphi };
97- const gpu = await GpuBackend.create(device, cfg);
98- const cpu = new CpuBackend(cfg);
99- const q = randomSpectrum(cfg, 42);
100- const q64 = new Float64Array(q);
101- const sGpu = await gpu.synth(q64);
102- const sCpu = await cpu.synth(q64);
103- const errSynth = relL2(sGpu, sCpu);
104- const aGpu = await gpu.analys(new Float64Array(sCpu));
105- const aCpu = await cpu.analys(new Float64Array(sCpu));
106- const errAnalys = relL2(aGpu, aCpu);
107- check('transforms: GPU vs CPU', errSynth < 1e-4 && errAnalys < 1e-4,
108- `synth ${errSynth.toExponential(2)}, analys ${errAnalys.toExponential(2)}`);
109- gpu.destroy();
110- }
111-
112- // --- solver cross-check: identical seeded runs on both backends ---
113- {
114- const schnak = models[0];
115- const params = defaultParams(schnak);
116- const lmax = 31;
117- const { nlat, nphi } = gridForLmax(lmax, schnak.pdeg);
118- const cfg = { lmax, mmax: lmax, nlat, nphi };
119- const gpu = await GpuBackend.create(device, cfg);
120- const cpu = new CpuBackend(cfg);
121- const simGpu = new Simulation(gpu, schnak, { ...params });
122- const simCpu = new Simulation(cpu, schnak, { ...params });
123- await simGpu.init(7);
124- await simCpu.init(7);
125- const nsteps = 10;
126- const t0 = performance.now();
127- for (let s = 0; s < nsteps; s++) await simGpu.step();
128- const gpuMs = (performance.now() - t0) / nsteps;
129- for (let s = 0; s < nsteps; s++) await simCpu.step();
130- let worst = 0;
131- for (let k = 0; k < simGpu.nspecies; k++) {
132- worst = Math.max(worst, relL2(simGpu.V[k], simCpu.V[k]));
133- }
134- let nan = false;
135- for (let k = 0; k < simGpu.nspecies; k++) {
136- for (const v of simGpu.V[k]) if (!Number.isFinite(v)) nan = true;
137- }
138- check('solver: GPU vs CPU after 10 steps', worst < 2e-3 && !nan,
139- `worst rel L2 ${worst.toExponential(2)}${nan ? ', NaN!' : ''} (${gpuMs.toFixed(1)} ms/step GPU)`);
140- gpu.destroy();
141- }
142-
143- // --- longer GPU-only run stays finite and patterned ---
144- {
145- const schnak = models[0];
146- const params = defaultParams(schnak);
147- const lmax = 63;
148- const { nlat, nphi } = gridForLmax(lmax, schnak.pdeg);
149- const gpu = await GpuBackend.create(device, { lmax, mmax: lmax, nlat, nphi });
150- const sim = new Simulation(gpu, schnak, params);
151- await sim.init(3);
152- const nsteps = 100;
153- const t0 = performance.now();
154- for (let s = 0; s < nsteps; s++) await sim.step();
155- const ms = (performance.now() - t0) / nsteps;
156- let lo = Infinity;
157- let hi = -Infinity;
158- for (const v of sim.V[0]) {
159- if (v < lo) lo = v;
160- if (v > hi) hi = v;
161- }
162- const finite = Number.isFinite(lo) && Number.isFinite(hi);
163- check('solver: 100 steps at lmax 63 stay finite', finite && lo > -10 && hi < 10,
164- `u range [${lo.toFixed(4)}, ${hi.toFixed(4)}], ${ms.toFixed(1)} ms/step`);
165- gpu.destroy();
166- }
167-
168- // --- the .m model compiled to WGSL, against the reference solver ---
169- await mgpuChecks(device, check, log);
98+ await transformChecks(device, check, log);
99+ await analyticChecks(device, check, log);
100+ await modelChecks(device, check, log);
170101
171102 window.__RESULTS__ = { ok: failures === 0, lines };
172103 log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`);
test/transformChecks.tsadded+55−0View file
@@ -0,0 +1,55 @@
1+/**
2+ * The WGSL spherical-harmonic transforms against the f64 CPU reference.
3+ *
4+ * This is the one place a second implementation is still the right oracle: the
5+ * transforms are vendored shtns-webgpu, and `src/sht/reference.ts` is its direct-
6+ * summation f64 twin. Everything above them (the .m models) is checked against
7+ * closed-form answers instead — see analyticChecks.ts.
8+ */
9+import { ShtPlan } from '../src/sht/sht.ts';
10+import { ShtReference, randomSpectrum } from '../src/sht/reference.ts';
11+import { gridForLmax } from '../src/sht/layout.ts';
12+import type { Check, Log } from './analyticChecks.ts';
13+
14+function relL2(a: ArrayLike<number>, b: ArrayLike<number>): number {
15+ let num = 0;
16+ let den = 0;
17+ for (let i = 0; i < a.length; i++) {
18+ const d = a[i] - b[i];
19+ num += d * d;
20+ den += b[i] * b[i];
21+ }
22+ return Math.sqrt(num / Math.max(den, 1e-300));
23+}
24+
25+export async function transformChecks(
26+ device: GPUDevice,
27+ check: Check,
28+ _log: Log,
29+): Promise<void> {
30+ const lmax = 31;
31+ const { nlat, nphi } = gridForLmax(lmax, 1);
32+ const cfg = { lmax, mmax: lmax, nlat, nphi };
33+
34+ const plan = await ShtPlan.create(device, cfg);
35+ const ref = new ShtReference(cfg);
36+
37+ const q = randomSpectrum(cfg, 42);
38+ const q64 = new Float64Array(q);
39+
40+ const spatGpu = await plan.synth(new Float32Array(q64));
41+ const spatCpu = ref.synth(q64);
42+ const errSynth = relL2(spatGpu, spatCpu);
43+
44+ const qGpu = await plan.analys(new Float32Array(spatCpu));
45+ const qCpu = ref.analys(new Float64Array(spatCpu));
46+ const errAnalys = relL2(qGpu, qCpu);
47+
48+ check(
49+ 'transforms: WGSL fp32 vs f64 CPU reference',
50+ errSynth < 1e-4 && errAnalys < 1e-4,
51+ `synth ${errSynth.toExponential(2)}, analys ${errAnalys.toExponential(2)}`,
52+ );
53+
54+ plan.destroy();
55+}