/ concept-collection / turing-sphere
Sign in
concept-collection / turing-sphere
290 lines · 9.7 KBCodeBlameHistory
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 *
84005eeMake the benchmark command run on older NodeJeremy Magland 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
11 * The only thing missing here is the rendering: this is the solver alone.
84005eeMake the benchmark command run on older NodeJeremy Magland 12 * Entry point is scripts/bench.mjs, which copes with older Node versions.
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';
38const USAGE = `usage: ${BENCH_COMMAND} [options]
40 --preset <key> ${presets.map((p) => p.key).join(' | ')}
41 (default ${presets[0].key})
42 --lmax <n> spherical harmonic truncation (default ${DEFAULT_LMAX})
43 --backend <kind> webgpu | cpu (default ${DEFAULT_BACKEND})
44 --steps <n> timed steps (default ${DEFAULT_STEPS})
45 --warmup <n> untimed steps first (default ${DEFAULT_WARMUP})
46 --seed <n> initial-noise seed (default ${DEFAULT_SEED})
47 --<param> <v> any parameter of the preset's model, e.g. --dt 0.05
48 --json machine-readable output
49 --help
51The browser app shows the command for whatever it is currently simulating;
52copy it from under the stats line to compare the same run here.`;
54function fail(msg: string, code = 1): never {
55 console.error(`bench: ${msg}`);
56 process.exit(code);
58const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
60// ---------------------------------------------------------------- arguments
61const argv = process.argv.slice(2);
62if (argv.includes('--help') || argv.includes('-h')) {
63 console.log(USAGE);
64 process.exit(0);
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);
74// ---------------------------------------------------------------- WebGPU
75/**
76 * Install Dawn under the globals the transform code expects (navigator.gpu,
77 * GPUBufferUsage, ...), so src/ runs here unchanged — including
78 * requestShtDevice(), which is the same device request the browser makes.
79 * The specifier is indirect so that typechecking does not require the
80 * optional package to be installed.
81 */
82async function installWebGpu(): Promise<string> {
83 const specifier = 'webgpu';
84 let mod: {
85 create: (flags: string[]) => GPU;
86 globals: Record<string, unknown>;
87 };
88 try {
89 mod = await import(specifier);
91 // Distinguish "not installed" from "installed but the prebuilt Dawn binary
92 // will not load" — the second is what a machine missing a system library
93 // looks like, and reporting it as the first sends people in circles.
94 const detail = errMsg(e);
95 if (/Cannot find (package|module) '?webgpu'?/.test(detail)) {
96 throw new Error(
97 'desktop WebGPU needs the optional `webgpu` package (prebuilt Google Dawn):\n' +
98 ' npm install webgpu\n' +
99 'It is an optionalDependency, so npm can skip it silently — `npm ls webgpu`\n' +
100 'says whether it is there. Or run with --backend cpu.',
101 );
102 }
521b449Report why the webgpu package did not load, instead of guessingJeremy Magland 104 `the \`webgpu\` package is installed but did not load:\n ${detail}\n` +
105 'That is usually the prebuilt Dawn binary missing a system library.\n' +
106 'Run with --backend cpu for the f64 CPU reference instead.',
108 }
109 Object.assign(globalThis, mod.globals);
110 // DAWN_FLAGS is ';'-separated because individual Dawn options take
111 // comma-separated lists, e.g. 'enable-dawn-features=allow_unsafe_apis,timestamp_quantization'
112 const dawnFlags = process.env.DAWN_FLAGS?.split(';').filter(Boolean) ?? [];
113 Object.defineProperty(globalThis, 'navigator', {
114 value: { gpu: mod.create(dawnFlags) },
115 configurable: true,
116 writable: true,
117 });
118 const { version } = await import(`${specifier}/package.json`, {
119 with: { type: 'json' },
120 }).then(
121 (m) => m.default as { version: string },
122 () => ({ version: '?' }),
123 );
124 return `node-webgpu ${version} (Google Dawn)`;
127// ---------------------------------------------------------------- statistics
128interface Timing {
129 meanMs: number;
130 medianMs: number;
131 p05Ms: number;
132 p95Ms: number;
133 minMs: number;
134 totalMs: number;
135 stepsPerSec: number;
138function timing(samples: Float64Array): Timing {
139 const sorted = Float64Array.from(samples).sort();
140 const q = (p: number): number =>
141 sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
142 let total = 0;
143 for (const v of samples) total += v;
144 const mean = total / samples.length;
145 return {
146 meanMs: mean,
147 medianMs: q(0.5),
148 p05Ms: q(0.05),
149 p95Ms: q(0.95),
150 minMs: sorted[0],
151 totalMs: total,
152 stepsPerSec: 1000 / mean,
153 };
156function fieldRange(v: ArrayLike<number>): { min: number; max: number } {
157 let min = Infinity;
158 let max = -Infinity;
159 for (let i = 0; i < v.length; i++) {
160 if (v[i] < min) min = v[i];
161 if (v[i] > max) max = v[i];
162 }
163 return { min, max };
166// ---------------------------------------------------------------- run
167const model = modelForSpec(spec);
168const { preset } = resolvePreset(spec.preset);
169const cfg = configForSpec(spec);
171let device: GPUDevice | null = null;
172let backend: ShtBackend | null = null;
173let runtime = 'CPU (direct summation, f64)';
174let adapter = '';
176try {
177 if (spec.backend === 'webgpu') {
178 runtime = await installWebGpu();
521b449Report why the webgpu package did not load, instead of guessingJeremy Magland 179 device = await requestShtDevice().catch((e: unknown) => {
180 throw new Error(
181 `${errMsg(e)}\n` +
182 ' Dawn reaches the GPU through Vulkan on Linux and Windows, Metal on macOS,\n' +
183 " so a headless box may have no adapter at all. DAWN_FLAGS='backend=vulkan'\n" +
184 ' makes it explain itself; --backend cpu always works.',
185 );
186 });
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 187 adapter = await describeAdapter(device);
188 backend = await GpuBackend.create(device, cfg);
189 } else {
190 backend = new CpuBackend(cfg);
191 }
193 const sim = new Simulation(backend, model, spec.params);
194 await sim.init(spec.seed);
196 if (!wantJson) {
197 const kind =
198 spec.backend === 'webgpu'
199 ? `WebGPU fp32${adapter ? ` — ${adapter}` : ''}`
200 : 'CPU f64';
201 console.log(`turing-sphere bench — solver only, no rendering\n`);
202 console.log(` preset ${preset.label} (model ${model.key}: ${model.species.join(', ')})`);
203 console.log(
204 ` params ${model.params.map((p) => `${p.key}=${spec.params[p.key]}`).join(' ')}`,
205 );
206 console.log(
207 ` grid lmax ${cfg.lmax} · ${cfg.nlat}×${cfg.nphi} · nlm ${backend.nlm.toLocaleString()}`,
208 );
209 console.log(` backend ${kind}\n ${runtime}`);
210 console.log(` run ${spec.warmup} warmup + ${spec.steps} timed steps, seed ${spec.seed}\n`);
211 }
213 for (let s = 0; s < spec.warmup; s++) await sim.step();
215 const samples = new Float64Array(spec.steps);
216 const progress = !wantJson && process.stderr.isTTY;
217 let lastReport = performance.now();
218 let running = 0;
219 for (let s = 0; s < spec.steps; s++) {
220 const t0 = performance.now();
221 await sim.step();
222 samples[s] = performance.now() - t0;
223 running += samples[s];
224 if (progress && performance.now() - lastReport > 1000) {
225 process.stderr.write(
226 `\r\x1b[K ${s + 1}/${spec.steps} steps · ${(running / (s + 1)).toFixed(2)} ms/step`,
227 );
228 lastReport = performance.now();
229 }
230 }
231 if (progress) process.stderr.write('\r\x1b[K');
233 const t = timing(samples);
234 const range = fieldRange(sim.V[0]);
235 let finite = true;
236 for (const v of sim.V[0]) if (!Number.isFinite(v)) finite = false;
238 if (wantJson) {
239 console.log(
240 JSON.stringify(
241 {
242 command: formatCommand(spec),
243 spec,
244 model: model.key,
245 backend: { kind: spec.backend, adapter, runtime },
246 grid: { lmax: cfg.lmax, nlat: cfg.nlat, nphi: cfg.nphi, nlm: backend.nlm },
247 timing: t,
248 state: {
249 t: sim.t,
250 steps: sim.stepCount,
251 species: model.species[0],
252 min: range.min,
253 max: range.max,
254 contrast: range.max - range.min,
255 finite,
256 },
257 },
258 null,
259 2,
260 ),
261 );
262 } else {
263 console.log(
264 ` ${t.meanMs.toFixed(2)} ms/step ${t.stepsPerSec.toFixed(1)} steps/s ` +
265 `${(spec.params.dt * t.stepsPerSec).toFixed(2)} model time/s`,
266 );
267 console.log(
268 ` median ${t.medianMs.toFixed(2)} · p05 ${t.p05Ms.toFixed(2)} · ` +
269 `p95 ${t.p95Ms.toFixed(2)} · min ${t.minMs.toFixed(2)} ms ` +
270 `(${(t.totalMs / 1000).toFixed(1)} s total)`,
271 );
272 console.log(
273 ` after ${sim.stepCount} steps: t = ${sim.t.toFixed(2)}, ` +
274 `${model.species[0]} ∈ [${range.min.toFixed(4)}, ${range.max.toFixed(4)}] ` +
275 `(contrast ${(range.max - range.min).toFixed(4)})${finite ? '' : ' — NOT FINITE'}`,
276 );
277 console.log(
278 `\n Compare with the ms/step in the app's stats line. That one is also the\n` +
279 ` solver alone, but measured while the page renders the spheres.`,
280 );
281 }
283 backend.destroy();
284 device?.destroy();
285 process.exit(finite ? 0 : 1);
286} catch (e) {
287 backend?.destroy();
288 device?.destroy();
289 fail(errMsg(e));
moveopenescclose