/ concept-collection / turing-sphere
Sign in
concept-collection / turing-sphere
Separate solver time from frame time, and add a cross-environment check
The app reported one ms/step that timed step(4) *and* draw(), then divided by four. draw() maps a buffer back per species, so that number carried two GPU->CPU round trips plus the colormapping — and in a browser a map also crosses into the GPU process. Comparing it against the benchmark's batched throughput made the browser look ~10x slower than the terminal when the solver itself was not. The per-frame costs are fixed, so the faster the GPU the worse that ratio looks. The app now reports the batch of steps alone, waited for but not read back — the number the benchmark measures — alongside a separate ms/frame that carries the readback and the rendering. Compare solver with solver. Also answers "is it actually the same computation?", which the old implementation-vs-implementation test used to answer implicitly: node scripts/compare-env.mjs [--lmax 31] [--steps 200] runs one identical spec on the desktop and in a real browser and compares the final spectral state. The pipeline is deterministic given the spec, so the two should agree to fp32 round-off; both build their spec through the same parseArgs so neither can use a different default. Intel Xe via Dawn against SwiftShader — about as different as two implementations get — agree to a relative L2 of 2e-6 over 200 steps at lmax 31. Both sides also report which Fourier stage the plan chose. ShtPlan picks FFT or DFT from device limits, and those are different algorithms that round differently, so a mismatch explains a difference in the values rather than being a symptom of one. The app's stats line prints it too.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 0f3abbd2a4f9 parent 35d91fa Browse files
7 changed files+451−24
README.mdmodified+51−6View file
@@ -196,16 +196,58 @@ flags: `--steps`, `--warmup`, `--batch`, `--json`, `--help`;
196196 `DAWN_FLAGS='backend=vulkan'` (`;`-separated) passes Dawn options through, e.g. to
197197 pick a backend or to compare against Dawn's own software adapter.
198198
199-What the comparison does and does not control for:
199+### Comparing the two honestly
200200
201-- the benchmark is **solver only**; the app's `ms/step` includes the per-frame
202- readback amortized over its step batch. For a browser number with no rendering,
203- open `test.html?soak=2000&lmax=63`.
204-- the browser adds its own GPU-process boundary and, for a page that is not
205- cross-origin isolated, coarser timers.
201+The app reports **two** numbers, and only the first is comparable to the
202+benchmark:
203+
204+```
205+solver 0.58 ms/step (1724 steps/s) · 12.4 ms/frame incl. readback + render
206+```
207+
208+`solver` is the batch of steps alone, waited for but not read back — the same
209+thing the benchmark's throughput number measures. `ms/frame` additionally carries
210+a GPU→CPU readback **per species**, the colormapping, and the vertex upload.
211+
212+Those per-frame costs are fixed: they do not shrink when the GPU gets faster. So
213+the faster your GPU, the larger the ratio between them — on a quick discrete GPU
214+it is easy for a frame to cost ten times the four steps inside it, purely because
215+a `mapAsync` round trip in a browser has to drain the queue and cross into the GPU
216+process. **That is expected, and it is not the solver being slower in the
217+browser.** Compare `solver` with the benchmark's throughput line; comparing
218+`ms/frame` against it measures the readback, not the computation.
219+
220+Other things the comparison does not control for:
221+
222+- the browser's renderer→GPU-process boundary on every submit, where Dawn in Node
223+ is in-process; and, for a page that is not cross-origin isolated, coarser
224+ `performance.now()`.
206225 - both sides are fp32 throughout, on the same generated kernels, so nothing here
207226 is a numerics comparison — only a cost one.
208227
228+### Is it really the same computation?
229+
230+```
231+node scripts/compare-env.mjs [--lmax 31] [--steps 200] [--preset schnak-spots]
232+```
233+
234+runs one identical spec on the desktop and in a real browser and compares the
235+final spectral state. The pipeline is deterministic given (model source,
236+parameters, lmax, seed, steps) — a seeded PRNG, then fixed arithmetic — so the two
237+should agree to fp32 round-off. Both sides build their spec through the same
238+`parseArgs`, so neither can quietly use a different default.
239+
240+They will *not* agree bit for bit; GPUs differ in fused-multiply-add and other
241+latitude fp32 allows. Between Intel Xe (via Dawn) and SwiftShader — about as
242+different as two implementations get — 200 steps at lmax 31 agree to a relative
243+L2 of **2e-6**.
244+
245+It also reports which **Fourier stage** each side chose. `ShtPlan` picks FFT or
246+DFT from the device's workgroup-storage and invocation limits, and those are
247+genuinely different algorithms that round differently, so a mismatch there
248+explains a difference in the values rather than being a symptom of one. The app's
249+stats line and the benchmark both print the chosen stage for the same reason.
250+
209251 ## Tests
210252
211253 There is no second implementation of the solver to diff against, so the `.m` path
@@ -263,6 +305,9 @@ Other commands:
263305 demo after a number of steps.
264306 - `node scripts/check-live.mjs [url]` — smoke-check a deployed URL in a real
265307 browser: load, press Run, confirm the solver advances.
308+- `node scripts/compare-env.mjs` — run one identical spec on the desktop and in a
309+ browser and compare the final state (see
310+ [Is it really the same computation?](#is-it-really-the-same-computation)).
266311 - `test.html?soak=<steps>&lmax=<n>` — solver-only soak with no rendering.
267312
268313 ### A note on canvas resizing
scripts/bench.tsmodified+57−8View file
@@ -31,7 +31,9 @@ import {
3131 DEFAULT_WARMUP,
3232 type RunSpec,
3333 } from '../src/bench/runSpec.ts';
34+import { digestOf, formatDigest } from '../src/mgpu/digest.ts';
3435 import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
36+import { writeFileSync } from 'node:fs';
3537
3638 const USAGE = `usage: ${BENCH_COMMAND} [options]
3739
@@ -42,6 +44,10 @@ const USAGE = `usage: ${BENCH_COMMAND} [options]
4244 --warmup <n> untimed steps first (default ${DEFAULT_WARMUP})
4345 --seed <n> initial-noise seed (default ${DEFAULT_SEED})
4446 --batch <n> steps per submit for the throughput number (default 16)
47+ --digest after timing, re-run exactly --steps steps from the seed and
48+ print a digest of the final state
49+ --dump-state <f> like --digest, and write the state to <f> as JSON, for
50+ scripts/compare-env.mjs to compare against a browser run
4551 --<param> <v> any parameter of the preset's model, e.g. --dt 0.05
4652 --json machine-readable output
4753 --help
@@ -62,18 +68,33 @@ if (argv.includes('--help') || argv.includes('-h')) {
6268 }
6369 const wantJson = argv.includes('--json');
6470 let batch = 16;
71+let dumpState: string | null = null;
72+let wantDigest = false;
6573 const rest: string[] = [];
6674 for (let i = 0; i < argv.length; i++) {
67- if (argv[i] === '--json') continue;
68- if (argv[i] === '--batch') {
69- batch = Number(argv[++i]);
75+ const a = argv[i];
76+ if (a === '--json') continue;
77+ if (a === '--digest') {
78+ wantDigest = true;
7079 continue;
7180 }
72- if (argv[i].startsWith('--batch=')) {
73- batch = Number(argv[i].slice('--batch='.length));
81+ const valued = (name: string): string | null => {
82+ if (a === `--${name}`) return argv[++i];
83+ if (a.startsWith(`--${name}=`)) return a.slice(name.length + 3);
84+ return null;
85+ };
86+ const b = valued('batch');
87+ if (b !== null) {
88+ batch = Number(b);
89+ continue;
90+ }
91+ const d = valued('dump-state');
92+ if (d !== null) {
93+ dumpState = d;
94+ wantDigest = true;
7495 continue;
7596 }
76- rest.push(argv[i]);
97+ rest.push(a);
7798 }
7899 if (!Number.isInteger(batch) || batch < 1) fail(`--batch must be an integer >= 1`, 2);
79100
@@ -159,6 +180,7 @@ try {
159180 ` grid lmax ${cfg.lmax} · ${cfg.nlat}×${cfg.nphi} · nlm ${session.sht.nlm.toLocaleString()}`,
160181 );
161182 console.log(` compiled ${plan.step.length} GPU ops/step (${kernels} generated kernels)`);
183+ console.log(` fourier ${session.sht.fourierMode.toUpperCase()} stage`);
162184 console.log(` backend WebGPU fp32${adapter ? ` — ${adapter}` : ''}\n ${runtime}`);
163185 console.log(` run ${spec.warmup} warmup + ${spec.steps} timed steps, seed ${spec.seed}\n`);
164186 }
@@ -206,6 +228,19 @@ try {
206228 let finite = true;
207229 for (const v of field) if (!Number.isFinite(v)) finite = false;
208230
231+ // A reproducible state to compare across machines: exactly `--steps` steps
232+ // from the seed, separate from the timed runs above (which step a different
233+ // number of times to measure throughput and latency).
234+ let digest = null;
235+ let state: Float32Array | null = null;
236+ if (wantDigest) {
237+ session.seed(spec.seed);
238+ session.step(spec.steps);
239+ await done();
240+ state = await session.read(model.state[0]);
241+ digest = digestOf(state, session.sht.fourierMode, adapter);
242+ }
243+
209244 if (wantJson) {
210245 console.log(
211246 JSON.stringify(
@@ -216,6 +251,7 @@ try {
216251 backend: { adapter, runtime },
217252 grid: { lmax: cfg.lmax, nlat: cfg.nlat, nphi: cfg.nphi, nlm: session.sht.nlm },
218253 compiled: { opsPerStep: plan.step.length, kernels },
254+ digest,
219255 throughput: { batch, msPerStep: throughputMs, stepsPerSec: 1000 / throughputMs },
220256 latency: t,
221257 state: {
@@ -247,10 +283,23 @@ try {
247283 `${model.species[0]} ∈ [${range.min.toFixed(4)}, ${range.max.toFixed(4)}] ` +
248284 `(contrast ${(range.max - range.min).toFixed(4)})${finite ? '' : ' — NOT FINITE'}`,
249285 );
286+ if (digest) {
287+ console.log(`\n state after ${spec.steps} steps from seed ${spec.seed}:`);
288+ console.log(` ${formatDigest(digest)}`);
289+ }
250290 console.log(
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.`,
291+ `\n The app's stats line reports the same solver number (batched steps,\n` +
292+ ` nothing read back) plus a separate ms/frame that carries the readback\n` +
293+ ` and the rendering. Compare solver with solver.`,
294+ );
295+ }
296+
297+ if (dumpState && state && digest) {
298+ writeFileSync(
299+ dumpState,
300+ JSON.stringify({ command: formatCommand(spec), spec, digest, state: [...state] }),
253301 );
302+ if (!wantJson) console.log(`\n wrote ${dumpState}`);
254303 }
255304
256305 session.destroy();
scripts/compare-env.mjsadded+169−0View file
@@ -0,0 +1,169 @@
1+/**
2+ * Is the browser computing the same thing as the terminal?
3+ *
4+ * Runs one identical spec in both — same model source, parameters, lmax, seed and
5+ * step count — and compares the final spectral state. The pipeline is
6+ * deterministic given that spec (seeded PRNG, then fixed arithmetic), so the two
7+ * should agree to fp32 round-off. They will not agree bit for bit: GPUs differ in
8+ * fused-multiply-add and other latitude fp32 allows. They should agree to far
9+ * better than any real difference in what is being computed.
10+ *
11+ * Both sides build their spec through the same parseArgs, so neither can quietly
12+ * use a different default.
13+ *
14+ * node scripts/compare-env.mjs [--lmax 31] [--steps 200] [--preset schnak-spots]
15+ *
16+ * Requires `npm run build` first (it serves dist/), and desktop WebGPU for the
17+ * terminal side.
18+ */
19+import { createServer } from 'node:http';
20+import { readFile, unlink } from 'node:fs/promises';
21+import { readFileSync } from 'node:fs';
22+import { extname, join } from 'node:path';
23+import { spawnSync } from 'node:child_process';
24+import { tmpdir } from 'node:os';
25+import puppeteer from 'puppeteer-core';
26+
27+// ---- spec, defaulted small enough to be quick in a browser ----------------
28+const argv = process.argv.slice(2);
29+const flag = (name, dflt) => {
30+ const i = argv.indexOf(`--${name}`);
31+ if (i >= 0 && argv[i + 1] !== undefined) return argv[i + 1];
32+ const eq = argv.find((a) => a.startsWith(`--${name}=`));
33+ return eq ? eq.slice(name.length + 3) : dflt;
34+};
35+const lmax = flag('lmax', '31');
36+const steps = flag('steps', '200');
37+const preset = flag('preset', 'schnak-spots');
38+const seed = flag('seed', '1');
39+const tolerance = Number(flag('tolerance', '2e-3'));
40+
41+const statePath = join(tmpdir(), `turing-sphere-desktop-${process.pid}.json`);
42+
43+// ---- desktop -------------------------------------------------------------
44+console.log(`comparing environments — preset ${preset}, lmax ${lmax}, ${steps} steps, seed ${seed}\n`);
45+console.log('desktop (Dawn):');
46+const bench = spawnSync(
47+ 'npx',
48+ [
49+ 'vite-node', 'scripts/bench.ts',
50+ '--preset', preset, '--lmax', lmax, '--seed', seed,
51+ '--steps', steps, '--warmup', '10',
52+ '--dump-state', statePath,
53+ ],
54+ { encoding: 'utf8' },
55+);
56+if (bench.status !== 0) {
57+ console.error(bench.stdout ?? '');
58+ console.error(bench.stderr ?? '');
59+ console.error('compare-env: the desktop run failed');
60+ process.exit(1);
61+}
62+const desktop = JSON.parse(readFileSync(statePath, 'utf8'));
63+console.log(` ${fmt(desktop.digest)}`);
64+console.log(` adapter: ${desktop.digest.adapter}`);
65+
66+// ---- browser -------------------------------------------------------------
67+const DIST = new URL('../dist/', import.meta.url).pathname;
68+const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' };
69+const server = createServer(async (req, res) => {
70+ try {
71+ const path = req.url === '/' ? '/index.html' : req.url.split('?')[0];
72+ const data = await readFile(join(DIST, path));
73+ res.writeHead(200, {
74+ 'content-type': MIME[extname(path)] ?? 'application/octet-stream',
75+ });
76+ res.end(data);
77+ } catch {
78+ res.writeHead(404);
79+ res.end('not found');
80+ }
81+});
82+await new Promise((r) => server.listen(0, '127.0.0.1', r));
83+const port = server.address().port;
84+
85+const flagSets = [
86+ ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu', '--enable-features=Vulkan'],
87+ ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu',
88+ '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader'],
89+];
90+
91+let browserState = null;
92+let lastError = '';
93+for (const args of flagSets) {
94+ let browser;
95+ try {
96+ browser = await puppeteer.launch({
97+ executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome',
98+ args,
99+ });
100+ const page = await browser.newPage();
101+ page.on('pageerror', (e) => (lastError = e.message));
102+ const url =
103+ `http://127.0.0.1:${port}/test.html?state=1&preset=${preset}` +
104+ `&lmax=${lmax}&seed=${seed}&steps=${steps}`;
105+ await page.goto(url, { waitUntil: 'load' });
106+ await page.waitForFunction(() => window.__STATE__ !== undefined, { timeout: 180000 });
107+ browserState = await page.evaluate(() => window.__STATE__);
108+ await browser.close();
109+ break;
110+ } catch (e) {
111+ lastError = e.message ?? String(e);
112+ await browser?.close();
113+ }
114+}
115+server.close();
116+await unlink(statePath).catch(() => {});
117+
118+if (!browserState) {
119+ console.error(`\ncompare-env: the browser run failed: ${lastError}`);
120+ process.exit(1);
121+}
122+
123+console.log('\nbrowser:');
124+console.log(` ${fmt(browserState.digest)}`);
125+console.log(` adapter: ${browserState.digest.adapter}`);
126+
127+// ---- compare -------------------------------------------------------------
128+const a = desktop.state;
129+const b = browserState.state;
130+if (a.length !== b.length) {
131+ console.error(`\nFAIL different state sizes: ${a.length} vs ${b.length}`);
132+ process.exit(1);
133+}
134+let num = 0;
135+let den = 0;
136+let worst = 0;
137+for (let i = 0; i < a.length; i++) {
138+ const d = a[i] - b[i];
139+ num += d * d;
140+ den += b[i] * b[i];
141+ worst = Math.max(worst, Math.abs(d));
142+}
143+const rel = Math.sqrt(num / Math.max(den, 1e-300));
144+
145+console.log('\ndifference:');
146+console.log(` relative L2 ${rel.toExponential(3)}`);
147+console.log(` worst element ${worst.toExponential(3)}`);
148+if (desktop.digest.fourier !== browserState.digest.fourier) {
149+ console.log(
150+ ` NOTE different Fourier stage (${desktop.digest.fourier} vs ` +
151+ `${browserState.digest.fourier}) — those are different algorithms, so they ` +
152+ `round differently. That alone can explain a difference in the values.`,
153+ );
154+}
155+
156+const ok = rel < tolerance;
157+console.log(
158+ `\n${ok ? 'PASS' : 'FAIL'} the two environments compute the same thing ` +
159+ `(relative L2 ${rel.toExponential(2)}, tolerance ${tolerance.toExponential(1)})`,
160+);
161+process.exit(ok ? 0 : 1);
162+
163+function fmt(d) {
164+ const g = (v) => v.toPrecision(9);
165+ return (
166+ `n=${d.n} min=${g(d.min)} max=${g(d.max)} mean=${g(d.mean)} rms=${g(d.rms)} ` +
167+ `fourier=${d.fourier}`
168+ );
169+}
src/main.tsmodified+26−8View file
@@ -94,7 +94,8 @@ let seed = 1;
9494 let running = false;
9595 let adapterName = '';
9696 let pumping = false;
97-let stepMs = 0;
97+let solverMs = 0;
98+let frameMs = 0;
9899 let generation = 0; // bumped on every rebuild to cancel stale pumps
99100
100101 const source = (): string => editedSource ?? model.source;
@@ -238,7 +239,8 @@ async function rebuild(): Promise<void> {
238239 disposeView();
239240 session?.destroy();
240241 session = null;
241- stepMs = 0;
242+ solverMs = 0;
243+ frameMs = 0;
242244 elErr.textContent = '';
243245 updateCommand();
244246 if (!device) return;
@@ -381,10 +383,17 @@ function updateStats(): void {
381383 if (!session) return;
382384 const { nlat, nphi } = session.cfg;
383385 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
384- const rate = stepMs > 0 ? `${(1000 / stepMs).toFixed(1)} steps/s` : '—';
386+ const solver =
387+ solverMs > 0
388+ ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s)`
389+ : '—';
390+ const frame =
391+ frameMs > 0
392+ ? `${frameMs.toFixed(1)} ms/frame incl. readback + render`
393+ : '—';
385394 elStats.innerHTML =
386395 `<b>${kind}</b> · grid ${nlat}×${nphi} · nlm ${session.sht.nlm.toLocaleString()} · ` +
387- `${stepMs > 0 ? stepMs.toFixed(1) : '—'} ms/step · ${rate} · ` +
396+ `${session.sht.fourierMode.toUpperCase()} · solver ${solver} · ${frame} · ` +
388397 `t = <b>${session.t.toFixed(2)}</b> (${session.steps} steps)`;
389398 }
390399
@@ -397,14 +406,23 @@ async function pump(): Promise<void> {
397406 const gen = generation;
398407 try {
399408 while (running && session && gen === generation) {
409+ // Two separate costs, kept separate. The solver is the batch of steps
410+ // alone, waited for but not read back — the number the desktop benchmark
411+ // reports, and the only one comparable to it. The frame additionally
412+ // carries a GPU->CPU readback per species (which in a browser crosses a
413+ // process boundary), the colormapping, and the three.js upload, and those
414+ // can easily cost more than the steps do.
400415 const t0 = performance.now();
401416 session.step(STEPS_PER_FRAME);
402- // draw() awaits the readback, which also waits for the batch to finish,
403- // so this measures the real end-to-end cost per step.
417+ await session.sync();
418+ if (gen !== generation) break;
419+ const tSolver = performance.now();
404420 await draw();
405421 if (gen !== generation) break;
406- const dtMs = (performance.now() - t0) / STEPS_PER_FRAME;
407- stepMs = stepMs === 0 ? dtMs : stepMs + 0.05 * (dtMs - stepMs);
422+ const ema = (prev: number, next: number): number =>
423+ prev === 0 ? next : prev + 0.05 * (next - prev);
424+ solverMs = ema(solverMs, (tSolver - t0) / STEPS_PER_FRAME);
425+ frameMs = ema(frameMs, performance.now() - t0);
408426 updateStats();
409427 await nextFrame();
410428 }
src/mgpu/digest.tsadded+90−0View file
@@ -0,0 +1,90 @@
1+/**
2+ * A run's final state, in a form two different machines can be compared on.
3+ *
4+ * The pipeline is deterministic given (model source, parameters, lmax, seed,
5+ * steps): the perturbation comes from a seeded PRNG, and everything after it is
6+ * fixed arithmetic. So the same spec run anywhere should land on the same state —
7+ * not bit for bit, since GPUs differ in fused-multiply-add and other latitude the
8+ * fp32 rules allow, but far closer than any real difference in what is being
9+ * computed would be.
10+ *
11+ * That makes a cross-environment comparison a genuine check that the browser and
12+ * the desktop are running the same computation, rather than something that merely
13+ * looks similar.
14+ */
15+
16+export interface StateDigest {
17+ /** Element count, so a shape mismatch is caught before the values are read. */
18+ n: number;
19+ min: number;
20+ max: number;
21+ mean: number;
22+ /** Root mean square — sensitive to every element, unlike min/max. */
23+ rms: number;
24+ /** Which Fourier stage the transform plan chose. FFT and DFT are different
25+ * algorithms and round differently, so a mismatch here explains a difference
26+ * in the values rather than being a symptom of one. */
27+ fourier: 'fft' | 'dft';
28+ /** Informational: the GPU the numbers came from. */
29+ adapter: string;
30+}
31+
32+export function digestOf(
33+ values: ArrayLike<number>,
34+ fourier: 'fft' | 'dft',
35+ adapter: string,
36+): StateDigest {
37+ let min = Infinity;
38+ let max = -Infinity;
39+ let sum = 0;
40+ let sumsq = 0;
41+ for (let i = 0; i < values.length; i++) {
42+ const v = values[i];
43+ if (v < min) min = v;
44+ if (v > max) max = v;
45+ sum += v;
46+ sumsq += v * v;
47+ }
48+ const n = values.length;
49+ return {
50+ n,
51+ min,
52+ max,
53+ mean: sum / n,
54+ rms: Math.sqrt(sumsq / n),
55+ fourier,
56+ adapter,
57+ };
58+}
59+
60+/** Relative L2 difference of two states of equal length. */
61+export function relL2(a: ArrayLike<number>, b: ArrayLike<number>): number {
62+ let num = 0;
63+ let den = 0;
64+ for (let i = 0; i < a.length; i++) {
65+ const d = a[i] - b[i];
66+ num += d * d;
67+ den += b[i] * b[i];
68+ }
69+ return Math.sqrt(num / Math.max(den, 1e-300));
70+}
71+
72+export function formatDigest(d: StateDigest): string {
73+ const g = (v: number): string => v.toPrecision(9);
74+ return (
75+ `n=${d.n} min=${g(d.min)} max=${g(d.max)} mean=${g(d.mean)} rms=${g(d.rms)} ` +
76+ `fourier=${d.fourier}`
77+ );
78+}
79+
80+/** Worst relative disagreement between two digests' scalar summaries. */
81+export function digestDrift(a: StateDigest, b: StateDigest): number {
82+ const rel = (x: number, y: number): number =>
83+ Math.abs(x - y) / Math.max(Math.abs(x), Math.abs(y), 1e-30);
84+ return Math.max(
85+ rel(a.min, b.min),
86+ rel(a.max, b.max),
87+ rel(a.mean, b.mean),
88+ rel(a.rms, b.rms),
89+ );
90+}
src/mgpu/session.tsmodified+14−1View file
@@ -22,6 +22,7 @@ export interface ModelSessionOptions {
2222 }
2323
2424 export class ModelSession {
25+ readonly device: GPUDevice;
2526 readonly model: MModel;
2627 readonly cfg: ShtConfig;
2728 readonly sht: ShtPlan;
@@ -35,12 +36,14 @@ export class ModelSession {
3536 #params: ModelParams;
3637
3738 private constructor(init: {
39+ device: GPUDevice;
3840 model: MModel;
3941 cfg: ShtConfig;
4042 sht: ShtPlan;
4143 gpu: GpuModel;
4244 params: ModelParams;
4345 }) {
46+ this.device = init.device;
4447 this.model = init.model;
4548 this.cfg = init.cfg;
4649 this.sht = init.sht;
@@ -65,7 +68,7 @@ export class ModelSession {
6568 view: model.species,
6669 });
6770 gpu.setParams(params);
68- return new ModelSession({ model, cfg, sht, gpu, params });
71+ return new ModelSession({ device, model, cfg, sht, gpu, params });
6972 } catch (e) {
7073 // The transform plan owns GPU buffers; do not leak them on a compile error.
7174 sht.destroy();
@@ -92,6 +95,16 @@ export class ModelSession {
9295 this.steps += n;
9396 }
9497
98+ /**
99+ * Wait for the submitted steps to finish, without reading anything back.
100+ * This is the honest way to time the solver: a readback would add a GPU->CPU
101+ * round trip, which in a browser also crosses a process boundary and can cost
102+ * more than the steps themselves.
103+ */
104+ sync(): Promise<undefined> {
105+ return this.device.queue.onSubmittedWorkDone();
106+ }
107+
95108 /** Read a named value (a grid field or the spectral state). */
96109 read(name: string): Promise<Float32Array> {
97110 return this.gpu.read(name);
test/test-page.tsmodified+44−1View file
@@ -7,9 +7,11 @@
77 *
88 * Results are posted to window.__RESULTS__ for the headless runner.
99 */
10-import { requestShtDevice } from '../src/sht/sht.ts';
10+import { requestShtDevice, describeAdapter } from '../src/sht/sht.ts';
1111 import { ModelSession } from '../src/mgpu/session.ts';
1212 import { mModels, defaultParams } from '../src/mgpu/registry.ts';
13+import { digestOf, formatDigest, type StateDigest } from '../src/mgpu/digest.ts';
14+import { parseArgs, modelForSpec, formatCommand } from '../src/bench/runSpec.ts';
1315 import { transformChecks } from './transformChecks.ts';
1416 import { analyticChecks } from './analyticChecks.ts';
1517 import { modelChecks } from './modelChecks.ts';
@@ -17,6 +19,8 @@ import { modelChecks } from './modelChecks.ts';
1719 declare global {
1820 interface Window {
1921 __RESULTS__?: { ok: boolean; fatal?: string; lines: string[] };
22+ /** Set by the ?state= mode, for scripts/compare-env.mjs. */
23+ __STATE__?: { digest: StateDigest; state: number[] };
2024 }
2125 }
2226
@@ -88,8 +92,47 @@ async function soak(steps: number, lmax: number): Promise<void> {
8892 log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`);
8993 }
9094
95+/**
96+ * Run one exact spec and post its final state, for scripts/compare-env.mjs to
97+ * compare against the same spec run on the desktop. Query parameters map
98+ * straight onto the benchmark's flags — `?state=1&lmax=31&steps=200` — and go
99+ * through the same parseArgs, so neither side can quietly use different
100+ * defaults.
101+ */
102+async function dumpState(q: URLSearchParams): Promise<void> {
103+ const argv: string[] = [];
104+ for (const [k, v] of q) {
105+ if (k === 'state') continue;
106+ argv.push(`--${k}`, v);
107+ }
108+ const spec = parseArgs(argv);
109+ const model = modelForSpec(spec);
110+
111+ const device = await requestShtDevice();
112+ const adapter = await describeAdapter(device);
113+ const session = await ModelSession.create({
114+ device,
115+ model,
116+ params: spec.params,
117+ lmax: spec.lmax,
118+ });
119+ session.seed(spec.seed);
120+ session.step(spec.steps);
121+ await session.sync();
122+ const state = await session.read(model.state[0]);
123+ const digest = digestOf(state, session.sht.fourierMode, adapter);
124+
125+ log(`${formatCommand(spec)}\n`);
126+ log(`state after ${spec.steps} steps from seed ${spec.seed}:`);
127+ log(` ${formatDigest(digest)}`);
128+ log(` adapter: ${adapter}`);
129+ window.__STATE__ = { digest, state: [...state] };
130+ session.destroy();
131+}
132+
91133 async function main(): Promise<void> {
92134 const q = new URLSearchParams(location.search);
135+ if (q.has('state')) return dumpState(q);
93136 if (q.has('soak')) {
94137 return soak(Number(q.get('soak')) || 500, Number(q.get('lmax')) || 63);
95138 }
moveopenescclose