concept-collection / turing-sphere
Stop the per-frame sync from inflating the app's solver number
Timing the solver needs queue.onSubmittedWorkDone() to know the work finished, and in a browser that is an IPC round trip into the GPU process — a few milliseconds, fixed. Charging it to one frame's four steps swamped them on a fast GPU: the app still read several times slower than the benchmark, which amortizes its sync over sixteen. The rate is now measured in a periodic burst of 32 steps with a single sync, so the fixed cost is amortized the way the benchmark's is. Those are ordinary steps; the simulation advances by them like any others. The per-frame sync is gone entirely — draw()'s readback already waits for the steps, so asking twice only added a round trip, which also makes the app slightly faster. The soak (test.html?soak=) now reports a solver rate measured the same way, with no renderer present at all. That is the number to compare against the benchmark: if they agree, the solver is fine in the browser and the rest is readback and competing with the renderer for the GPU. README gains the ladder of four measurements and what each includes, plus the remaining causes that are not measurement artifacts — the GPU-process boundary (use --batch 4 to make the desktop pay it as often), renderer contention, and an animation-paced loop leaving the GPU in a low-power state where the benchmark boosts it.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit cde22ea37ca3 parent 0f3abbd Browse files
3 changed files+97−19
README.mdmodified+34−0View file
@@ -225,6 +225,40 @@ Other things the comparison does not control for:
225225 - both sides are fp32 throughout, on the same generated kernels, so nothing here
226226 is a numerics comparison — only a cost one.
227227
228+### Why the browser is slower, and how to find out by how much
229+
230+Some gap is real and some is measurement. Four numbers, in increasing order of
231+what they include — walk down them and the gap attributes itself:
232+
233+| number | includes |
234+|---|---|
235+| `npm run bench -- --lmax 63` | desktop solver: batched steps, one sync per batch, in-process Dawn |
236+| `test.html?soak=2000&lmax=63` → `solver` | browser solver: same batching, no rendering at all |
237+| the app's `solver` | browser solver, measured in a periodic batch of 32 |
238+| the app's `ms/frame` | four steps **plus** a readback per species, colormapping and the vertex upload |
239+
240+If the soak matches the benchmark, the solver is fine in the browser and
241+everything above it is readback and rendering. If the soak is itself slower, the
242+remaining suspects are:
243+
244+- **the GPU-process boundary.** Every submit and every sync is IPC out of the
245+ renderer; Dawn in Node is in-process. This is a fixed per-batch cost, so it hurts
246+ most when the GPU is fast. `npm run bench -- --batch 4` makes the desktop pay a
247+ sync as often as the app's frame loop does, which shows how much of the gap is
248+ just amortization.
249+- **competing with the renderer.** The page draws two spheres through WebGL on the
250+ same GPU, in its own animation loop. The soak has no renderer, so comparing the
251+ soak against the app's `solver` separates contention from everything else.
252+- **clocks.** An animation-paced loop leaves the GPU idle for most of each 16 ms
253+ frame, so it may never leave its low-power state, while the benchmark hammers it
254+ continuously and boosts. On a thermally managed laptop this alone can be worth a
255+ factor of two, and it is not something the code can fix.
256+- **which browser.** WebGPU implementations differ substantially in maturity;
257+ Chrome and Safari are not interchangeable for this.
258+
259+None of these change *what* is computed — see below for how to confirm that
260+independently.
261+
228262 ### Is it really the same computation?
229263
230264 ```
src/main.tsmodified+36−15View file
@@ -74,6 +74,20 @@ const editor = new CodeEditor({
7474 * so the batch costs one submit and one readback regardless of size. */
7575 const STEPS_PER_FRAME = 4;
7676
77+/**
78+ * Steps in a solver-timing burst, and how often to run one.
79+ *
80+ * Timing the solver needs a `queue.onSubmittedWorkDone()` to know the work
81+ * finished, and in a browser that is an IPC round trip into the GPU process — a
82+ * fixed cost of a few milliseconds. Spread over one frame's four steps it would
83+ * swamp them on a fast GPU and make the solver look far slower than it is. So the
84+ * rate is measured in an occasional larger batch, where the single sync is
85+ * amortized the way the desktop benchmark amortizes its own. These are ordinary
86+ * steps: the simulation advances by them like any others.
87+ */
88+const MEASURE_BURST = 32;
89+const MEASURE_EVERY_MS = 2000;
90+
7791 // ---------------------------------------------------------------- state
7892 let device: GPUDevice | null = null;
7993 let session: ModelSession | null = null;
@@ -96,6 +110,7 @@ let adapterName = '';
96110 let pumping = false;
97111 let solverMs = 0;
98112 let frameMs = 0;
113+let lastMeasure = 0;
99114 let generation = 0; // bumped on every rebuild to cancel stale pumps
100115
101116 const source = (): string => editedSource ?? model.source;
@@ -241,6 +256,7 @@ async function rebuild(): Promise<void> {
241256 session = null;
242257 solverMs = 0;
243258 frameMs = 0;
259+ lastMeasure = 0;
244260 elErr.textContent = '';
245261 updateCommand();
246262 if (!device) return;
@@ -385,11 +401,12 @@ function updateStats(): void {
385401 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
386402 const solver =
387403 solverMs > 0
388- ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s)`
404+ ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s, ` +
405+ `batch of ${MEASURE_BURST}, no readback)`
389406 : '—';
390407 const frame =
391408 frameMs > 0
392- ? `${frameMs.toFixed(1)} ms/frame incl. readback + render`
409+ ? `${frameMs.toFixed(1)} ms/frame (${STEPS_PER_FRAME} steps + readback + render)`
393410 : '—';
394411 elStats.innerHTML =
395412 `<b>${kind}</b> · grid ${nlat}×${nphi} · nlm ${session.sht.nlm.toLocaleString()} · ` +
@@ -406,23 +423,27 @@ async function pump(): Promise<void> {
406423 const gen = generation;
407424 try {
408425 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.
426+ // Occasionally, a burst purely to measure the solver rate: many steps,
427+ // one sync, nothing read back — directly comparable to the desktop
428+ // benchmark's throughput number.
429+ if (performance.now() - lastMeasure > MEASURE_EVERY_MS) {
430+ const m0 = performance.now();
431+ session.step(MEASURE_BURST);
432+ await session.sync();
433+ if (gen !== generation) break;
434+ solverMs = (performance.now() - m0) / MEASURE_BURST;
435+ lastMeasure = performance.now();
436+ }
437+
438+ // The frame itself. No explicit sync here — draw()'s readback already
439+ // waits for the steps, so asking twice would only add a round trip.
415440 const t0 = performance.now();
416441 session.step(STEPS_PER_FRAME);
417- await session.sync();
418- if (gen !== generation) break;
419- const tSolver = performance.now();
420442 await draw();
421443 if (gen !== generation) break;
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);
444+ frameMs = frameMs === 0
445+ ? performance.now() - t0
446+ : frameMs + 0.05 * (performance.now() - t0 - frameMs);
426447 updateStats();
427448 await nextFrame();
428449 }
test/test-page.tsmodified+27−4View file
@@ -40,8 +40,12 @@ function check(name: string, ok: boolean, detail: string): void {
4040 }
4141
4242 /**
43- * Solver-only soak, selected with ?soak=<steps>&lmax=<n>. No rendering, so it
44- * isolates the compiled .m and the transforms from three.js.
43+ * Solver-only soak, selected with ?soak=<steps>&lmax=<n>.
44+ *
45+ * No three.js at all, so this is the browser's honest solver rate: the same
46+ * batched, no-readback measurement the desktop benchmark reports. If this number
47+ * matches the benchmark's but the app's frame cost does not, the difference is
48+ * the readback and competing with the renderer for the GPU, not the computation.
4549 */
4650 async function soak(steps: number, lmax: number): Promise<void> {
4751 const device = await requestShtDevice();
@@ -59,9 +63,18 @@ async function soak(steps: number, lmax: number): Promise<void> {
5963 );
6064
6165 const BATCH = 25;
66+ // Timed separately from the sampling: `solverMs` counts only submitted steps
67+ // waited for, never read back, so it is comparable to `npm run bench`.
68+ let solverMs = 0;
69+ let solverSteps = 0;
6270 const t0 = performance.now();
6371 for (let s = 0; s < steps; s += BATCH) {
64- session.step(Math.min(BATCH, steps - s));
72+ const n = Math.min(BATCH, steps - s);
73+ const b0 = performance.now();
74+ session.step(n);
75+ await session.sync();
76+ solverMs += performance.now() - b0;
77+ solverSteps += n;
6578 const u = await session.read(model.species[0]);
6679 let lo = Infinity;
6780 let hi = -Infinity;
@@ -85,7 +98,17 @@ async function soak(steps: number, lmax: number): Promise<void> {
8598 const final = await session.read(model.species[0]);
8699 let finite = true;
87100 for (const v of final) if (!Number.isFinite(v)) finite = false;
88- check(`soak: ${steps} steps survived`, finite, `${ms.toFixed(1)} ms/step`);
101+ const solverPerStep = solverMs / solverSteps;
102+ check(
103+ `soak: ${steps} steps survived`,
104+ finite,
105+ `solver ${solverPerStep.toFixed(2)} ms/step (batches of ${BATCH}, no readback), ` +
106+ `${ms.toFixed(2)} ms/step incl. sampling readback`,
107+ );
108+ log(
109+ ` compare 'solver' with the ms/step from \`npm run bench -- --lmax ${lmax}\`:\n` +
110+ ` same .m, same kernels, no rendering on either side.`,
111+ );
89112
90113 session.destroy();
91114 window.__RESULTS__ = { ok: failures === 0, lines };