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.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
10 *
11 * 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.
13 */
14import {
15 GpuBackend,
16 CpuBackend,
17 requestShtDevice,
18 describeAdapter,
19 type ShtBackend,
20} from '../src/solver/backend.ts';
21import { Simulation } from '../src/solver/simulation.ts';
22import { presets } from '../src/solver/models.ts';
23import {
24 parseArgs,
25 modelForSpec,
26 configForSpec,
27 resolvePreset,
28 formatCommand,
29 BENCH_COMMAND,
30 DEFAULT_LMAX,
31 DEFAULT_SEED,
32 DEFAULT_STEPS,
33 DEFAULT_WARMUP,
34 DEFAULT_BACKEND,
35 type RunSpec,
36} from '../src/bench/runSpec.ts';
37import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
39const USAGE = `usage: ${BENCH_COMMAND} [options]
41 --preset <key> ${presets.map((p) => p.key).join(' | ')}
42 (default ${presets[0].key})
43 --lmax <n> spherical harmonic truncation (default ${DEFAULT_LMAX})
44 --backend <kind> webgpu | cpu (default ${DEFAULT_BACKEND})
45 --steps <n> timed steps (default ${DEFAULT_STEPS})
46 --warmup <n> untimed steps first (default ${DEFAULT_WARMUP})
47 --seed <n> initial-noise seed (default ${DEFAULT_SEED})
48 --<param> <v> any parameter of the preset's model, e.g. --dt 0.05
49 --json machine-readable output
50 --help
52The browser app shows the command for whatever it is currently simulating;
53copy it from under the stats line to compare the same run here.`;
55function fail(msg: string, code = 1): never {
56 console.error(`bench: ${msg}`);
57 process.exit(code);
58}
60// ---------------------------------------------------------------- arguments
61const argv = process.argv.slice(2);
62if (argv.includes('--help') || argv.includes('-h')) {
63 console.log(USAGE);
64 process.exit(0);
65}
66const wantJson = argv.includes('--json');
67let spec: RunSpec;
68try {
69 spec = parseArgs(argv.filter((a) => a !== '--json'));
70} catch (e) {
71 fail(`${errMsg(e)}\n\n${USAGE}`, 2);
72}
74// ---------------------------------------------------------------- statistics
75interface Timing {
76 meanMs: number;
77 medianMs: number;
78 p05Ms: number;
79 p95Ms: number;
80 minMs: number;
81 totalMs: number;
82 stepsPerSec: number;
83}
85function timing(samples: Float64Array): Timing {
86 const sorted = Float64Array.from(samples).sort();
87 const q = (p: number): number =>
88 sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
89 let total = 0;
90 for (const v of samples) total += v;
91 const mean = total / samples.length;
92 return {
93 meanMs: mean,
94 medianMs: q(0.5),
95 p05Ms: q(0.05),
96 p95Ms: q(0.95),
97 minMs: sorted[0],
98 totalMs: total,
99 stepsPerSec: 1000 / mean,
100 };
101}
103function fieldRange(v: ArrayLike<number>): { min: number; max: number } {
104 let min = Infinity;
105 let max = -Infinity;
106 for (let i = 0; i < v.length; i++) {
107 if (v[i] < min) min = v[i];
108 if (v[i] > max) max = v[i];
109 }
110 return { min, max };
111}
113// ---------------------------------------------------------------- run
114const model = modelForSpec(spec);
115const { preset } = resolvePreset(spec.preset);
116const cfg = configForSpec(spec);
118let device: GPUDevice | null = null;
119let backend: ShtBackend | null = null;
120let runtime = 'CPU (direct summation, f64)';
121let adapter = '';
123try {
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 }
137 const sim = new Simulation(backend, model, spec.params);
138 await sim.init(spec.seed);
140 if (!wantJson) {
141 const kind =
142 spec.backend === 'webgpu'
143 ? `WebGPU fp32${adapter ? ` — ${adapter}` : ''}`
144 : 'CPU f64';
145 console.log(`turing-sphere bench — solver only, no rendering\n`);
146 console.log(` preset ${preset.label} (model ${model.key}: ${model.species.join(', ')})`);
147 console.log(
148 ` params ${model.params.map((p) => `${p.key}=${spec.params[p.key]}`).join(' ')}`,
149 );
150 console.log(
151 ` grid lmax ${cfg.lmax} · ${cfg.nlat}×${cfg.nphi} · nlm ${backend.nlm.toLocaleString()}`,
152 );
153 console.log(` backend ${kind}\n ${runtime}`);
154 console.log(` run ${spec.warmup} warmup + ${spec.steps} timed steps, seed ${spec.seed}\n`);
155 }
157 for (let s = 0; s < spec.warmup; s++) await sim.step();
159 const samples = new Float64Array(spec.steps);
160 const progress = !wantJson && process.stderr.isTTY;
161 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];
168 if (progress && performance.now() - lastReport > 1000) {
169 process.stderr.write(
170 `\r\x1b[K ${s + 1}/${spec.steps} steps · ${(running / (s + 1)).toFixed(2)} ms/step`,
171 );
172 lastReport = performance.now();
173 }
174 }
175 if (progress) process.stderr.write('\r\x1b[K');
177 const t = timing(samples);
178 const range = fieldRange(sim.V[0]);
179 let finite = true;
180 for (const v of sim.V[0]) if (!Number.isFinite(v)) finite = false;
182 if (wantJson) {
183 console.log(
184 JSON.stringify(
185 {
186 command: formatCommand(spec),
187 spec,
188 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,
192 state: {
193 t: sim.t,
194 steps: sim.stepCount,
195 species: model.species[0],
196 min: range.min,
197 max: range.max,
198 contrast: range.max - range.min,
199 finite,
200 },
201 },
202 null,
203 2,
204 ),
205 );
206 } else {
207 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`,
210 );
211 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)`,
215 );
216 console.log(
217 ` after ${sim.stepCount} steps: t = ${sim.t.toFixed(2)}, ` +
218 `${model.species[0]} ∈ [${range.min.toFixed(4)}, ${range.max.toFixed(4)}] ` +
219 `(contrast ${(range.max - range.min).toFixed(4)})${finite ? '' : ' — NOT FINITE'}`,
220 );
221 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.`,
224 );
225 }
227 backend.destroy();
228 device?.destroy();
229 process.exit(finite ? 0 : 1);
230} catch (e) {
231 backend?.destroy();
232 device?.destroy();
233 fail(errMsg(e));
234}