1/**
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.
8 */
9import {
10 mModels,
11 presets,
12 defaultParams,
13 type MModel,
14 type Params,
15 type Preset,
16} from '../mgpu/registry.ts';
17import {
18 mGeometries,
19 mGeometryByKey,
20 defaultGeometryParams,
21 DEFAULT_GEOMETRY_KEY,
22 type MGeometry,
23} from '../geom/registry.ts';
24import { gridForLmax, type ShtConfig } from '../sht/layout.ts';
26export interface RunSpec {
27 /** Preset key from the registry; fixes the model, params may still be edited. */
28 preset: string;
29 lmax: number;
30 /** Seed of the initial noise. */
31 seed: number;
32 /** Timed steps (the app runs forever; the benchmark stops here). */
33 steps: number;
34 /** Untimed steps run first, so shader/pipeline warm-up is not measured. */
35 warmup: number;
36 /** Full parameter set of the preset's model, as edited. */
37 params: Params;
38 /** Geometry key from the geometry registry. */
39 geometry: string;
40 /** Full parameter set of that geometry, as edited. Written on the command
41 * line with a `g` prefix (`--gwaist`) so a shape parameter can never
42 * collide with a model one. */
43 geometryParams: Params;
44 /** Iterations of the .m's implicit solve. Structural: it is unrolled into
45 * the compiled step, so it belongs to the spec rather than to the params. */
46 niter: number;
47}
49/** Iterations of the implicit solve, everywhere that does not say otherwise:
50 * the app's `solve iters` control, `npm run bench`, and the soak. */
51export const DEFAULT_NITER = 8;
53/** Geometry + starting parameters of a geometry key. */
54export function resolveGeometry(key: string): { geometry: MGeometry; params: Params } {
55 const geometry = mGeometryByKey(key);
56 if (!geometry) {
57 throw new Error(
58 `unknown geometry '${key}' (have: ${mGeometries.map((g) => g.key).join(', ')})`,
59 );
60 }
61 return { geometry, params: defaultGeometryParams(geometry) };
62}
64export function geometryForSpec(spec: RunSpec): MGeometry {
65 return resolveGeometry(spec.geometry).geometry;
66}
68/** The command the app displays and the benchmark answers to. Goes through npm
69 * because the benchmark runs under vite-node, which is what resolves numbl's
70 * compiler sources and the `?raw` model imports. */
71export const BENCH_COMMAND = 'npm run bench --';
72export const DEFAULT_LMAX = 63;
73export const DEFAULT_SEED = 1;
74/** Long enough that clock ramp-up and the occasional scheduling hiccup wash
75 * out: ~10 s of GPU stepping at lmax 63. */
76export const DEFAULT_STEPS = 2000;
77export const DEFAULT_WARMUP = 100;
79/** Model + starting parameters of a preset, for the app's dropdown and the
80 * benchmark's --preset flag. */
81export function resolvePreset(key: string): {
82 preset: Preset;
83 model: MModel;
84 params: Params;
85} {
86 const preset = presets.find((p) => p.key === key);
87 if (!preset) {
88 throw new Error(
89 `unknown preset '${key}' (have: ${presets.map((p) => p.key).join(', ')})`,
90 );
91 }
92 const model = mModels.find((m) => m.key === preset.modelKey);
93 if (!model) throw new Error(`preset '${key}' names unknown model '${preset.modelKey}'`);
94 return { preset, model, params: { ...defaultParams(model), ...preset.params } };
95}
97export function modelForSpec(spec: RunSpec): MModel {
98 return resolvePreset(spec.preset).model;
99}
101/** Transform configuration implied by the spec (same rule as the app). */
102export function configForSpec(spec: RunSpec): ShtConfig {
103 const { nlat, nphi } = gridForLmax(spec.lmax, modelForSpec(spec).pdeg);
104 return { lmax: spec.lmax, mmax: spec.lmax, nlat, nphi };
105}
107/** The command line that reproduces this run. Every knob the app exposes is
108 * written out explicitly, so the command stays valid if a preset changes. */
109export function formatCommand(spec: RunSpec): string {
110 const model = modelForSpec(spec);
111 const geometry = geometryForSpec(spec);
112 const parts = [
113 BENCH_COMMAND,
114 `--preset ${spec.preset}`,
115 `--geometry ${spec.geometry}`,
116 `--lmax ${spec.lmax}`,
117 `--niter ${spec.niter}`,
118 `--steps ${spec.steps}`,
119 `--seed ${spec.seed}`,
120 ...model.params.map((p) => `--${p.key} ${String(spec.params[p.key])}`),
121 ...geometry.params.map((p) => `--g${p.key} ${String(spec.geometryParams[p.key])}`),
122 ];
123 if (spec.warmup !== DEFAULT_WARMUP) parts.push(`--warmup ${spec.warmup}`);
124 return parts.join(' ');
125}
127/** Inverse of formatCommand: `--key value` or `--key=value`, in any order.
128 * Throws with a usable message on anything it does not recognize. */
129export function parseArgs(argv: string[]): RunSpec {
130 const flags = new Map<string, string>();
131 for (let i = 0; i < argv.length; i++) {
132 const arg = argv[i];
133 if (!arg.startsWith('--')) throw new Error(`unexpected argument '${arg}'`);
134 const eq = arg.indexOf('=');
135 const key = eq >= 0 ? arg.slice(2, eq) : arg.slice(2);
136 const value = eq >= 0 ? arg.slice(eq + 1) : argv[++i];
137 if (value === undefined) throw new Error(`--${key} needs a value`);
138 if (!key) throw new Error(`bad option '${arg}'`);
139 flags.set(key, value);
140 }
141 const take = (key: string): string | undefined => {
142 const v = flags.get(key);
143 flags.delete(key);
144 return v;
145 };
146 const number = (key: string, dflt: number): number => {
147 const raw = take(key);
148 if (raw === undefined) return dflt;
149 const v = Number(raw);
150 if (!Number.isFinite(v)) throw new Error(`--${key} must be a number (got '${raw}')`);
151 return v;
152 };
153 const count = (key: string, dflt: number, min: number): number => {
154 const v = number(key, dflt);
155 if (!Number.isInteger(v) || v < min) {
156 throw new Error(`--${key} must be an integer >= ${min} (got '${v}')`);
157 }
158 return v;
159 };
161 const presetKey = take('preset') ?? presets[0].key;
162 const { model, params } = resolvePreset(presetKey);
163 const geometryKey = take('geometry') ?? DEFAULT_GEOMETRY_KEY;
164 const { geometry, params: geometryParams } = resolveGeometry(geometryKey);
165 const spec: RunSpec = {
166 preset: presetKey,
167 lmax: count('lmax', DEFAULT_LMAX, 1),
168 seed: number('seed', DEFAULT_SEED),
169 steps: count('steps', DEFAULT_STEPS, 1),
170 warmup: count('warmup', DEFAULT_WARMUP, 0),
171 params,
172 geometry: geometryKey,
173 geometryParams,
174 niter: count('niter', DEFAULT_NITER, 0),
175 };
176 const readInto = (into: Params, key: string, flag: string): void => {
177 const raw = take(flag);
178 if (raw === undefined) return;
179 const v = Number(raw);
180 if (!Number.isFinite(v)) throw new Error(`--${flag} must be a number (got '${raw}')`);
181 into[key] = v;
182 };
183 for (const p of model.params) readInto(params, p.key, p.key);
184 for (const p of geometry.params) readInto(geometryParams, p.key, `g${p.key}`);
185 if (flags.size) {
186 throw new Error(
187 `unknown option(s): ${[...flags.keys()].map((k) => `--${k}`).join(', ')}\n` +
188 `parameters of ${model.label}: ${model.params.map((p) => `--${p.key}`).join(' ')}\n` +
189 `parameters of ${geometry.label}: ` +
190 (geometry.params.length
191 ? geometry.params.map((p) => `--g${p.key}`).join(' ')
192 : '(none)'),
193 );
194 }
195 return spec;
196}