concept-collection / turing-sphere-2
Add a desktop WebGPU benchmark and show its command in the app
scripts/bench.ts runs the same simulation as the browser app -- same Simulation, same WGSL transforms, same parameters -- from Node on desktop WebGPU (Google Dawn) or the f64 CPU reference, and reports ms/step. Node runs the TypeScript sources directly, and Dawn is installed under navigator.gpu and the WebGPU globals, so src/ is used unchanged down to requestShtDevice(). src/bench/runSpec.ts is the single description of a run: the app formats what it is currently simulating into a `node scripts/bench.ts ...` command, shown under the stats line with a Copy button, and the benchmark parses that same command back. Neither side keeps its own defaults, so the two runs cannot drift apart. Dawn comes from the optional `webgpu` package (~68 MB of prebuilt binaries); the CLI explains how to install it when missing and CI installs with --omit=optional. describeAdapter() moves from main.ts to solver/backend.ts so both entry points share it.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 15a77e22018c parent e7bcd70 Browse files
10 changed files+593−40
.github/workflows/ci.ymlmodified+1−1View file
@@ -13,7 +13,7 @@ jobs:
1313 with:
1414 node-version: 24
1515 cache: npm
16- - run: npm ci
16+ - run: npm ci --omit=optional # skip the prebuilt Dawn binaries; CI does not benchmark
1717 - run: npm run test:node
1818 # headless Chrome + SwiftShader software WebGPU
1919 - run: npm run test:gpu
.github/workflows/deploy.ymlmodified+1−1View file
@@ -25,7 +25,7 @@ jobs:
2525 with:
2626 node-version: 24
2727 cache: npm
28- - run: npm ci
28+ - run: npm ci --omit=optional # skip the prebuilt Dawn binaries; CI does not benchmark
2929 - run: npm run test:node
3030 - run: npm run build
3131 - uses: actions/configure-pages@v5
README.mdmodified+41−0View file
@@ -68,8 +68,49 @@ compute, so this port swaps in:
6868 f64 CPU path); for pattern formation from 1e-2 seeded noise this is
6969 inconsequential.
7070
71+## Desktop vs browser
72+
73+How much does running this in a browser cost? [`scripts/bench.ts`](scripts/bench.ts)
74+answers that by running the *same* code — same `Simulation`, same WGSL
75+transforms, same parameters — from Node on desktop WebGPU (Google Dawn), and
76+the app prints the command line that reproduces whatever it is currently
77+simulating:
78+
79+```
80+node scripts/bench.ts --preset schnak-spots --lmax 63 --backend webgpu --steps 2000 \
81+ --seed 1 --a 0.1 --b 0.9 --D1 0.0004 --D2 0.008 --dt 0.05
82+```
83+
84+Copy it from under the stats line, run it, and compare the `ms/step` it reports
85+with the app's. Both sides go through the one shared
86+[`src/bench/runSpec.ts`](src/bench/runSpec.ts) — the app formats a run into that
87+command, the benchmark parses it back — so there is no second copy of the
88+defaults for the two runs to drift apart on. Node runs the TypeScript sources
89+directly, so `src/` is literally the same code in both places, down to the
90+device request in `requestShtDevice()` (Dawn is installed under `navigator.gpu`
91+and the WebGPU globals, and the rest runs unchanged).
92+
93+Desktop WebGPU comes from the optional `webgpu` package (prebuilt Dawn, ~70 MB).
94+A plain `npm install` picks it up; `npm install --omit=optional` skips it and
95+leaves `--backend cpu` working. Other flags: `--steps`, `--warmup`, `--json`,
96+`--help`; `DAWN_FLAGS='backend=vulkan'` (`;`-separated) passes Dawn options
97+through, e.g. to pick a backend or compare against Dawn's own software adapter.
98+
99+What the comparison does and does not control for:
100+
101+- the benchmark is **solver only**; the app's `ms/step` excludes `draw()` but is
102+ still measured on a page that renders two spheres between steps. For a browser
103+ number with no rendering at all, open `test.html?soak=2000&lmax=63`.
104+- each step is four transforms, each ending in a buffer readback, so both sides
105+ are dominated by submit-and-map latency rather than arithmetic — this measures
106+ a driver round-trip more than it measures a GPU.
107+- the browser adds its own GPU-process boundary and, for a page that is not
108+ cross-origin isolated, coarser timers.
109+
71110 ## Tests
72111
112+- `npm run bench -- --help` — the desktop benchmark above (see
113+ [Desktop vs browser](#desktop-vs-browser)).
73114 - `npm run test:node` — f64 solver correctness in Node: exact single-mode
74115 linear recurrence, exact uniform-state reaction ODE, and the linearized
75116 Turing-mode 2×2 IMEX recurrence (all at ~1e-12).
index.htmlmodified+22−0View file
@@ -75,6 +75,21 @@
7575 .colorbar-label { font-size: 11px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
7676 .stats { margin-top: 10px; font-size: 13px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
7777 .stats b { color: var(--ink); font-weight: 600; }
78+ .cli {
79+ margin-top: 10px; border: 1px solid var(--line); border-radius: 8px;
80+ overflow: hidden;
81+ }
82+ .cli-head {
83+ display: flex; gap: 10px; align-items: center; justify-content: space-between;
84+ padding: 6px 10px; font-size: 12px; color: var(--ink-2);
85+ background: var(--sphere-bg); border-bottom: 1px solid var(--line);
86+ }
87+ .cli-head button { padding: 2px 10px; font-size: 12px; }
88+ #cmd {
89+ display: block; padding: 8px 10px; white-space: pre-wrap;
90+ font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
91+ color: var(--ink); user-select: all;
92+ }
7893 #blurb { margin-top: 4px; font-size: 13px; color: var(--ink-2); }
7994 #err { color: #b35900; white-space: pre-wrap; font-size: 13px; }
8095 </style>
@@ -116,6 +131,13 @@
116131 <div class="controls" id="params"></div>
117132 <div id="panels"></div>
118133 <p class="stats" id="stats"></p>
134+ <div class="cli">
135+ <div class="cli-head">
136+ <span>Same run on the desktop, solver only — compare its ms/step with the one above</span>
137+ <button id="copycmd" type="button">Copy</button>
138+ </div>
139+ <code id="cmd"></code>
140+ </div>
119141 <p id="blurb"></p>
120142 <p id="err"></p>
121143 </main>
package-lock.jsonmodified+18−3View file
@@ -18,6 +18,9 @@
1818 "puppeteer-core": "^23.0.0",
1919 "typescript": "^5.5.0",
2020 "vite": "^5.4.0"
21+ },
22+ "optionalDependencies": {
23+ "webgpu": "^0.4.0"
2124 }
2225 },
2326 "node_modules/@dimforge/rapier3d-compat": {
@@ -905,7 +908,7 @@
905908 "version": "0.1.71",
906909 "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
907910 "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
908- "dev": true,
911+ "devOptional": true,
909912 "license": "BSD-3-Clause"
910913 },
911914 "node_modules/agent-base": {
@@ -1186,7 +1189,7 @@
11861189 "version": "4.4.3",
11871190 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
11881191 "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1189- "dev": true,
1192+ "devOptional": true,
11901193 "license": "MIT",
11911194 "dependencies": {
11921195 "ms": "^2.1.3"
@@ -1552,7 +1555,7 @@
15521555 "version": "2.1.3",
15531556 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
15541557 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1555- "dev": true,
1558+ "devOptional": true,
15561559 "license": "MIT"
15571560 },
15581561 "node_modules/nanoid": {
@@ -2074,6 +2077,18 @@
20742077 }
20752078 }
20762079 },
2080+ "node_modules/webgpu": {
2081+ "version": "0.4.0",
2082+ "resolved": "https://registry.npmjs.org/webgpu/-/webgpu-0.4.0.tgz",
2083+ "integrity": "sha512-F5pimn3Aoi0zWjuRdiVs5TnrUwSzD2lESBohsIUsqyitWkGRQlXU2fhV6ycXlQTa1bvAf3sjqiUpBEpmSQ5ptA==",
2084+ "hasInstallScript": true,
2085+ "license": "MIT",
2086+ "optional": true,
2087+ "dependencies": {
2088+ "@webgpu/types": "^0.1.69",
2089+ "debug": "^4.4.0"
2090+ }
2091+ },
20772092 "node_modules/wrap-ansi": {
20782093 "version": "7.0.0",
20792094 "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
package.jsonmodified+5−1View file
@@ -9,11 +9,15 @@
99 "build": "tsc --noEmit && vite build",
1010 "test:node": "node scripts/test-node.ts",
1111 "test:gpu": "vite build && node scripts/test-gpu.mjs",
12- "test": "npm run test:node && npm run test:gpu"
12+ "test": "npm run test:node && npm run test:gpu",
13+ "bench": "node scripts/bench.ts"
1314 },
1415 "dependencies": {
1516 "three": "^0.183.0"
1617 },
18+ "optionalDependencies": {
19+ "webgpu": "^0.4.0"
20+ },
1721 "devDependencies": {
1822 "@types/node": "^26.1.1",
1923 "@types/three": "^0.185.1",
scripts/bench.tsadded+270−0View file
@@ -0,0 +1,270 @@
1+/**
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.
7+ *
8+ * node scripts/bench.ts --preset schnak-spots --lmax 63 --backend webgpu \
9+ * --steps 500 --seed 1 --a 0.1 --b 0.9 --D1 0.0004 --D2 0.008 --dt 0.05
10+ *
11+ * The only thing missing here is the rendering: this is the solver alone.
12+ */
13+import {
14+ GpuBackend,
15+ CpuBackend,
16+ requestShtDevice,
17+ describeAdapter,
18+ type ShtBackend,
19+} from '../src/solver/backend.ts';
20+import { Simulation } from '../src/solver/simulation.ts';
21+import { presets } from '../src/solver/models.ts';
22+import {
23+ parseArgs,
24+ modelForSpec,
25+ configForSpec,
26+ resolvePreset,
27+ formatCommand,
28+ BENCH_COMMAND,
29+ DEFAULT_LMAX,
30+ DEFAULT_SEED,
31+ DEFAULT_STEPS,
32+ DEFAULT_WARMUP,
33+ DEFAULT_BACKEND,
34+ type RunSpec,
35+} from '../src/bench/runSpec.ts';
36+
37+const USAGE = `usage: ${BENCH_COMMAND} [options]
38+
39+ --preset <key> ${presets.map((p) => p.key).join(' | ')}
40+ (default ${presets[0].key})
41+ --lmax <n> spherical harmonic truncation (default ${DEFAULT_LMAX})
42+ --backend <kind> webgpu | cpu (default ${DEFAULT_BACKEND})
43+ --steps <n> timed steps (default ${DEFAULT_STEPS})
44+ --warmup <n> untimed steps first (default ${DEFAULT_WARMUP})
45+ --seed <n> initial-noise seed (default ${DEFAULT_SEED})
46+ --<param> <v> any parameter of the preset's model, e.g. --dt 0.05
47+ --json machine-readable output
48+ --help
49+
50+The browser app shows the command for whatever it is currently simulating;
51+copy it from under the stats line to compare the same run here.`;
52+
53+function fail(msg: string, code = 1): never {
54+ console.error(`bench: ${msg}`);
55+ process.exit(code);
56+}
57+const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
58+
59+// ---------------------------------------------------------------- arguments
60+const argv = process.argv.slice(2);
61+if (argv.includes('--help') || argv.includes('-h')) {
62+ console.log(USAGE);
63+ process.exit(0);
64+}
65+const wantJson = argv.includes('--json');
66+let spec: RunSpec;
67+try {
68+ spec = parseArgs(argv.filter((a) => a !== '--json'));
69+} catch (e) {
70+ fail(`${errMsg(e)}\n\n${USAGE}`, 2);
71+}
72+
73+// ---------------------------------------------------------------- WebGPU
74+/**
75+ * Install Dawn under the globals the transform code expects (navigator.gpu,
76+ * GPUBufferUsage, ...), so src/ runs here unchanged — including
77+ * requestShtDevice(), which is the same device request the browser makes.
78+ * The specifier is indirect so that typechecking does not require the
79+ * optional package to be installed.
80+ */
81+async function installWebGpu(): Promise<string> {
82+ const specifier = 'webgpu';
83+ let mod: {
84+ create: (flags: string[]) => GPU;
85+ globals: Record<string, unknown>;
86+ };
87+ try {
88+ mod = await import(specifier);
89+ } catch {
90+ throw new Error(
91+ 'desktop WebGPU needs the optional `webgpu` package (prebuilt Google Dawn):\n' +
92+ ' npm install webgpu\n' +
93+ 'or run with --backend cpu.',
94+ );
95+ }
96+ Object.assign(globalThis, mod.globals);
97+ // DAWN_FLAGS is ';'-separated because individual Dawn options take
98+ // comma-separated lists, e.g. 'enable-dawn-features=allow_unsafe_apis,timestamp_quantization'
99+ const dawnFlags = process.env.DAWN_FLAGS?.split(';').filter(Boolean) ?? [];
100+ Object.defineProperty(globalThis, 'navigator', {
101+ value: { gpu: mod.create(dawnFlags) },
102+ configurable: true,
103+ writable: true,
104+ });
105+ const { version } = await import(`${specifier}/package.json`, {
106+ with: { type: 'json' },
107+ }).then(
108+ (m) => m.default as { version: string },
109+ () => ({ version: '?' }),
110+ );
111+ return `node-webgpu ${version} (Google Dawn)`;
112+}
113+
114+// ---------------------------------------------------------------- statistics
115+interface Timing {
116+ meanMs: number;
117+ medianMs: number;
118+ p05Ms: number;
119+ p95Ms: number;
120+ minMs: number;
121+ totalMs: number;
122+ stepsPerSec: number;
123+}
124+
125+function timing(samples: Float64Array): Timing {
126+ const sorted = Float64Array.from(samples).sort();
127+ const q = (p: number): number =>
128+ sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
129+ let total = 0;
130+ for (const v of samples) total += v;
131+ const mean = total / samples.length;
132+ return {
133+ meanMs: mean,
134+ medianMs: q(0.5),
135+ p05Ms: q(0.05),
136+ p95Ms: q(0.95),
137+ minMs: sorted[0],
138+ totalMs: total,
139+ stepsPerSec: 1000 / mean,
140+ };
141+}
142+
143+function fieldRange(v: ArrayLike<number>): { min: number; max: number } {
144+ let min = Infinity;
145+ let max = -Infinity;
146+ for (let i = 0; i < v.length; i++) {
147+ if (v[i] < min) min = v[i];
148+ if (v[i] > max) max = v[i];
149+ }
150+ return { min, max };
151+}
152+
153+// ---------------------------------------------------------------- run
154+const model = modelForSpec(spec);
155+const { preset } = resolvePreset(spec.preset);
156+const cfg = configForSpec(spec);
157+
158+let device: GPUDevice | null = null;
159+let backend: ShtBackend | null = null;
160+let runtime = 'CPU (direct summation, f64)';
161+let adapter = '';
162+
163+try {
164+ if (spec.backend === 'webgpu') {
165+ runtime = await installWebGpu();
166+ device = await requestShtDevice();
167+ adapter = await describeAdapter(device);
168+ backend = await GpuBackend.create(device, cfg);
169+ } else {
170+ backend = new CpuBackend(cfg);
171+ }
172+
173+ const sim = new Simulation(backend, model, spec.params);
174+ await sim.init(spec.seed);
175+
176+ if (!wantJson) {
177+ const kind =
178+ spec.backend === 'webgpu'
179+ ? `WebGPU fp32${adapter ? ` — ${adapter}` : ''}`
180+ : 'CPU f64';
181+ console.log(`turing-sphere bench — solver only, no rendering\n`);
182+ console.log(` preset ${preset.label} (model ${model.key}: ${model.species.join(', ')})`);
183+ console.log(
184+ ` params ${model.params.map((p) => `${p.key}=${spec.params[p.key]}`).join(' ')}`,
185+ );
186+ console.log(
187+ ` grid lmax ${cfg.lmax} · ${cfg.nlat}×${cfg.nphi} · nlm ${backend.nlm.toLocaleString()}`,
188+ );
189+ console.log(` backend ${kind}\n ${runtime}`);
190+ console.log(` run ${spec.warmup} warmup + ${spec.steps} timed steps, seed ${spec.seed}\n`);
191+ }
192+
193+ for (let s = 0; s < spec.warmup; s++) await sim.step();
194+
195+ const samples = new Float64Array(spec.steps);
196+ const progress = !wantJson && process.stderr.isTTY;
197+ let lastReport = performance.now();
198+ let running = 0;
199+ for (let s = 0; s < spec.steps; s++) {
200+ const t0 = performance.now();
201+ await sim.step();
202+ samples[s] = performance.now() - t0;
203+ running += samples[s];
204+ if (progress && performance.now() - lastReport > 1000) {
205+ process.stderr.write(
206+ `\r\x1b[K ${s + 1}/${spec.steps} steps · ${(running / (s + 1)).toFixed(2)} ms/step`,
207+ );
208+ lastReport = performance.now();
209+ }
210+ }
211+ if (progress) process.stderr.write('\r\x1b[K');
212+
213+ const t = timing(samples);
214+ const range = fieldRange(sim.V[0]);
215+ let finite = true;
216+ for (const v of sim.V[0]) if (!Number.isFinite(v)) finite = false;
217+
218+ if (wantJson) {
219+ console.log(
220+ JSON.stringify(
221+ {
222+ command: formatCommand(spec),
223+ spec,
224+ model: model.key,
225+ backend: { kind: spec.backend, adapter, runtime },
226+ grid: { lmax: cfg.lmax, nlat: cfg.nlat, nphi: cfg.nphi, nlm: backend.nlm },
227+ timing: t,
228+ state: {
229+ t: sim.t,
230+ steps: sim.stepCount,
231+ species: model.species[0],
232+ min: range.min,
233+ max: range.max,
234+ contrast: range.max - range.min,
235+ finite,
236+ },
237+ },
238+ null,
239+ 2,
240+ ),
241+ );
242+ } else {
243+ console.log(
244+ ` ${t.meanMs.toFixed(2)} ms/step ${t.stepsPerSec.toFixed(1)} steps/s ` +
245+ `${(spec.params.dt * t.stepsPerSec).toFixed(2)} model time/s`,
246+ );
247+ console.log(
248+ ` median ${t.medianMs.toFixed(2)} · p05 ${t.p05Ms.toFixed(2)} · ` +
249+ `p95 ${t.p95Ms.toFixed(2)} · min ${t.minMs.toFixed(2)} ms ` +
250+ `(${(t.totalMs / 1000).toFixed(1)} s total)`,
251+ );
252+ console.log(
253+ ` after ${sim.stepCount} steps: t = ${sim.t.toFixed(2)}, ` +
254+ `${model.species[0]} ∈ [${range.min.toFixed(4)}, ${range.max.toFixed(4)}] ` +
255+ `(contrast ${(range.max - range.min).toFixed(4)})${finite ? '' : ' — NOT FINITE'}`,
256+ );
257+ console.log(
258+ `\n Compare with the ms/step in the app's stats line. That one is also the\n` +
259+ ` solver alone, but measured while the page renders the spheres.`,
260+ );
261+ }
262+
263+ backend.destroy();
264+ device?.destroy();
265+ process.exit(finite ? 0 : 1);
266+} catch (e) {
267+ backend?.destroy();
268+ device?.destroy();
269+ fail(errMsg(e));
270+}
src/bench/runSpec.tsadded+154−0View file
@@ -0,0 +1,154 @@
1+/**
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.ts ...` 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.
7+ */
8+import {
9+ models,
10+ presets,
11+ defaultParams,
12+ type ModelSpec,
13+ type Params,
14+ 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';
20+
21+export interface RunSpec {
22+ /** Preset key from models.ts; fixes the model, params may still be edited. */
23+ preset: string;
24+ lmax: number;
25+ backend: BackendKind;
26+ /** Seed of the initial noise. */
27+ seed: number;
28+ /** Timed steps (the app runs forever; the benchmark stops here). */
29+ steps: number;
30+ /** Untimed steps run first, so shader/pipeline warm-up is not measured. */
31+ warmup: number;
32+ /** Full parameter set of the preset's model, as edited. */
33+ params: Params;
34+}
35+
36+/** The command the app displays and the benchmark answers to. */
37+export const BENCH_COMMAND = 'node scripts/bench.ts';
38+export const DEFAULT_LMAX = 63;
39+export const DEFAULT_SEED = 1;
40+/** Long enough that clock ramp-up and the occasional scheduling hiccup wash
41+ * out: ~10 s of GPU stepping at lmax 63. */
42+export const DEFAULT_STEPS = 2000;
43+export const DEFAULT_WARMUP = 100;
44+export const DEFAULT_BACKEND: BackendKind = 'webgpu';
45+
46+/** Model + starting parameters of a preset, for the app's dropdown and the
47+ * benchmark's --preset flag. */
48+export function resolvePreset(key: string): {
49+ preset: Preset;
50+ model: ModelSpec;
51+ params: Params;
52+} {
53+ const preset = presets.find((p) => p.key === key);
54+ if (!preset) {
55+ throw new Error(
56+ `unknown preset '${key}' (have: ${presets.map((p) => p.key).join(', ')})`,
57+ );
58+ }
59+ const model = models.find((m) => m.key === preset.modelKey);
60+ if (!model) throw new Error(`preset '${key}' names unknown model '${preset.modelKey}'`);
61+ return { preset, model, params: { ...defaultParams(model), ...preset.params } };
62+}
63+
64+export function modelForSpec(spec: RunSpec): ModelSpec {
65+ return resolvePreset(spec.preset).model;
66+}
67+
68+/** Transform configuration implied by the spec (same rule as the app). */
69+export function configForSpec(spec: RunSpec): ShtConfig {
70+ const { nlat, nphi } = gridForLmax(spec.lmax, modelForSpec(spec).pdeg);
71+ return { lmax: spec.lmax, mmax: spec.lmax, nlat, nphi };
72+}
73+
74+/** The command line that reproduces this run. Every knob the app exposes is
75+ * written out explicitly, so the command stays valid if a preset changes. */
76+export function formatCommand(spec: RunSpec): string {
77+ const model = modelForSpec(spec);
78+ const parts = [
79+ BENCH_COMMAND,
80+ `--preset ${spec.preset}`,
81+ `--lmax ${spec.lmax}`,
82+ `--backend ${spec.backend}`,
83+ `--steps ${spec.steps}`,
84+ `--seed ${spec.seed}`,
85+ ...model.params.map((p) => `--${p.key} ${String(spec.params[p.key])}`),
86+ ];
87+ if (spec.warmup !== DEFAULT_WARMUP) parts.push(`--warmup ${spec.warmup}`);
88+ return parts.join(' ');
89+}
90+
91+/** Inverse of formatCommand: `--key value` or `--key=value`, in any order.
92+ * Throws with a usable message on anything it does not recognize. */
93+export function parseArgs(argv: string[]): RunSpec {
94+ const flags = new Map<string, string>();
95+ for (let i = 0; i < argv.length; i++) {
96+ const arg = argv[i];
97+ if (!arg.startsWith('--')) throw new Error(`unexpected argument '${arg}'`);
98+ const eq = arg.indexOf('=');
99+ const key = eq >= 0 ? arg.slice(2, eq) : arg.slice(2);
100+ const value = eq >= 0 ? arg.slice(eq + 1) : argv[++i];
101+ if (value === undefined) throw new Error(`--${key} needs a value`);
102+ if (!key) throw new Error(`bad option '${arg}'`);
103+ flags.set(key, value);
104+ }
105+ const take = (key: string): string | undefined => {
106+ const v = flags.get(key);
107+ flags.delete(key);
108+ return v;
109+ };
110+ const number = (key: string, dflt: number): number => {
111+ const raw = take(key);
112+ if (raw === undefined) return dflt;
113+ const v = Number(raw);
114+ if (!Number.isFinite(v)) throw new Error(`--${key} must be a number (got '${raw}')`);
115+ return v;
116+ };
117+ const count = (key: string, dflt: number, min: number): number => {
118+ const v = number(key, dflt);
119+ if (!Number.isInteger(v) || v < min) {
120+ throw new Error(`--${key} must be an integer >= ${min} (got '${v}')`);
121+ }
122+ return v;
123+ };
124+
125+ const presetKey = take('preset') ?? presets[0].key;
126+ const { model, params } = resolvePreset(presetKey);
127+ const backend = take('backend') ?? DEFAULT_BACKEND;
128+ if (backend !== 'webgpu' && backend !== 'cpu') {
129+ throw new Error(`--backend must be 'webgpu' or 'cpu' (got '${backend}')`);
130+ }
131+ const spec: RunSpec = {
132+ preset: presetKey,
133+ lmax: count('lmax', DEFAULT_LMAX, 1),
134+ backend,
135+ seed: number('seed', DEFAULT_SEED),
136+ steps: count('steps', DEFAULT_STEPS, 1),
137+ warmup: count('warmup', DEFAULT_WARMUP, 0),
138+ params,
139+ };
140+ for (const p of model.params) {
141+ const raw = take(p.key);
142+ if (raw === undefined) continue;
143+ const v = Number(raw);
144+ if (!Number.isFinite(v)) throw new Error(`--${p.key} must be a number (got '${raw}')`);
145+ params[p.key] = v;
146+ }
147+ if (flags.size) {
148+ throw new Error(
149+ `unknown option(s): ${[...flags.keys()].map((k) => `--${k}`).join(', ')}\n` +
150+ `parameters of ${model.label}: ${model.params.map((p) => `--${p.key}`).join(' ')}`,
151+ );
152+ }
153+ return spec;
154+}
src/main.tsmodified+58−34View file
@@ -2,16 +2,19 @@ import {
22 GpuBackend,
33 CpuBackend,
44 requestShtDevice,
5+ describeAdapter,
56 type ShtBackend,
67 } from './solver/backend.ts';
78 import { Simulation, gridForLmax } from './solver/simulation.ts';
9+import { presets, type ModelSpec, type Params } from './solver/models.ts';
810 import {
9- models,
10- presets,
11- defaultParams,
12- type ModelSpec,
13- type Params,
14-} from './solver/models.ts';
11+ formatCommand,
12+ resolvePreset,
13+ DEFAULT_STEPS,
14+ DEFAULT_WARMUP,
15+ type BackendKind,
16+ type RunSpec,
17+} from './bench/runSpec.ts';
1518 import {
1619 buildTopology,
1720 fillFieldValues,
@@ -35,6 +38,8 @@ const elResetView = $<HTMLButtonElement>('resetview');
3538 const elParams = $('params');
3639 const elPanels = $('panels');
3740 const elStats = $('stats');
41+const elCmd = $('cmd');
42+const elCopyCmd = $<HTMLButtonElement>('copycmd');
3843 const elBlurb = $('blurb');
3944 const elErr = $('err');
4045
@@ -64,8 +69,9 @@ let colorBufs: Float32Array[] = [];
6469 let ranges: { lo: number; hi: number }[] = [];
6570 let resizeObs: ResizeObserver | null = null;
6671
67-let model: ModelSpec = models[0];
68-let params: Params = defaultParams(model);
72+const initial = resolvePreset(presets[0].key);
73+let model: ModelSpec = initial.model;
74+let params: Params = initial.params;
6975 let seed = 1;
7076 let running = false;
7177 let adapterName = '';
@@ -88,6 +94,7 @@ function buildParamInputs(): void {
8894 input.addEventListener('change', () => {
8995 const v = Number(input.value);
9096 if (Number.isFinite(v)) params[spec.key] = v;
97+ updateCommand();
9198 });
9299 label.append(input);
93100 elParams.append(label);
@@ -95,11 +102,29 @@ function buildParamInputs(): void {
95102 }
96103
97104 function applyPreset(presetKey: string): void {
98- const preset = presets.find((p) => p.key === presetKey) ?? presets[0];
99- model = models.find((m) => m.key === preset.modelKey) ?? models[0];
100- params = { ...defaultParams(model), ...preset.params };
105+ const resolved = resolvePreset(presetKey);
106+ model = resolved.model;
107+ params = resolved.params;
101108 buildParamInputs();
102109 elBlurb.textContent = model.blurb;
110+ updateCommand();
111+}
112+
113+/** The run currently on screen, as the benchmark's RunSpec. */
114+function currentSpec(): RunSpec {
115+ return {
116+ preset: elModel.value,
117+ lmax: Number(elLmax.value),
118+ backend: elBackend.value as BackendKind,
119+ seed,
120+ steps: DEFAULT_STEPS,
121+ warmup: DEFAULT_WARMUP,
122+ params,
123+ };
124+}
125+
126+function updateCommand(): void {
127+ elCmd.textContent = formatCommand(currentSpec());
103128 }
104129
105130 elModel.addEventListener('change', () => {
@@ -119,12 +144,33 @@ elRunPause.addEventListener('click', () => setRunning(!running));
119144 elReseed.addEventListener('click', () => {
120145 seed = (Math.random() * 2 ** 31) >>> 0;
121146 setRunning(false);
147+ updateCommand();
122148 void reseed();
123149 });
124150 elResetView.addEventListener('click', () => {
125151 for (const s of scenes) s.resetCamera();
126152 });
127153
154+// The command reproduces this exact run on the desktop; keep it selectable
155+// even where the clipboard API is unavailable.
156+elCopyCmd.addEventListener('click', () => {
157+ const text = elCmd.textContent ?? '';
158+ const flash = (msg: string): void => {
159+ elCopyCmd.textContent = msg;
160+ setTimeout(() => (elCopyCmd.textContent = 'Copy'), 1200);
161+ };
162+ const selectCommand = (): void => {
163+ const range = document.createRange();
164+ range.selectNodeContents(elCmd);
165+ const sel = getSelection();
166+ sel?.removeAllRanges();
167+ sel?.addRange(range);
168+ flash('Selected');
169+ };
170+ if (!navigator.clipboard) return selectCommand();
171+ navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
172+});
173+
128174 // ---------------------------------------------------------------- setup
129175 function disposeView(): void {
130176 for (const s of scenes) s.dispose();
@@ -145,6 +191,7 @@ async function rebuild(): Promise<void> {
145191 backend = null;
146192 sim = null;
147193 stepMs = 0;
194+ updateCommand();
148195
149196 const lmax = Number(elLmax.value);
150197 const { nlat, nphi } = gridForLmax(lmax, model.pdeg);
@@ -313,29 +360,6 @@ async function pump(): Promise<void> {
313360 }
314361
315362 // ---------------------------------------------------------------- boot
316-/** Best-effort human-readable adapter name, so it is clear which GPU (or
317- * software rasterizer) is actually running the transforms. */
318-async function describeAdapter(dev: GPUDevice): Promise<string> {
319- const fmt = (info: GPUAdapterInfo | undefined): string => {
320- if (!info) return '';
321- const parts = [info.description, info.device, info.vendor].filter(
322- (s): s is string => !!s && s.length > 0,
323- );
324- const name = parts[0] ?? '';
325- return info.architecture && !name.includes(info.architecture)
326- ? `${name} (${info.architecture})`.trim()
327- : name;
328- };
329- const own = fmt((dev as GPUDevice & { adapterInfo?: GPUAdapterInfo }).adapterInfo);
330- if (own) return own;
331- try {
332- const adapter = await navigator.gpu.requestAdapter();
333- return fmt(adapter?.info);
334- } catch {
335- return '';
336- }
337-}
338-
339363 async function boot(): Promise<void> {
340364 elModel.value = presets[0].key;
341365 applyPreset(presets[0].key);
src/solver/backend.tsmodified+23−0View file
@@ -58,6 +58,29 @@ export class GpuBackend implements ShtBackend {
5858 }
5959 }
6060
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+
6184 /** f64 CPU backend by direct summation (slow; tests and no-WebGPU fallback). */
6285 export class CpuBackend implements ShtBackend {
6386 readonly kind = 'cpu';