/ concept-collection / turing-sphere-2
Sign in
concept-collection / turing-sphere-2
turing-sphere-2 / scripts / compare-perf.mjs
177 lines · 7.3 KBCodeBlameHistory
2 * Why is the terminal faster than the browser?
3 *
4 * Measures the *same* solver work — same .m, same kernels, batched, nothing read
5 * back, no rendering on either side — in the terminal (Dawn, in-process) and in a
6 * real browser, and splits the result so the gap attributes itself:
7 *
8 * node scripts/compare-perf.mjs [--lmax 63] [--steps 300] [--preset schnak-spots]
9 *
10 * The browser side runs `test.html?soak=`, which has no renderer at all. So:
11 *
12 * - if the two agree, the solver is equally fast in the browser, and whatever
13 * the app shows on top of this is readback, rendering, and animation pacing.
14 * - if the browser is slower here, it is the GPU stack itself: submits crossing
15 * into the GPU process, or Metal/Vulkan execution differing between Chrome's
16 * Dawn and node-webgpu's.
17 *
18 * CPU command encoding is reported for both, because it is the one cost that can
19 * make a fast GPU irrelevant — and it is usually *cheaper* in the browser, which
20 * defers commands to the GPU process instead of validating them inline.
21 *
22 * Requires `npm run build` first, and desktop WebGPU for the terminal side.
23 */
24import { createServer } from 'node:http';
25import { readFile } from 'node:fs/promises';
26import { extname, join } from 'node:path';
27import { spawnSync } from 'node:child_process';
28import puppeteer from 'puppeteer-core';
30const argv = process.argv.slice(2);
31const flag = (name, dflt) => {
32 const i = argv.indexOf(`--${name}`);
33 if (i >= 0 && argv[i + 1] !== undefined) return argv[i + 1];
34 const eq = argv.find((a) => a.startsWith(`--${name}=`));
35 return eq ? eq.slice(name.length + 3) : dflt;
36};
37const lmax = flag('lmax', '63');
38const steps = flag('steps', '300');
39const preset = flag('preset', 'schnak-spots');
41console.log(`comparing solver rate — preset ${preset}, lmax ${lmax}, ${steps} steps\n`);
43// ---- terminal ------------------------------------------------------------
44const bench = spawnSync(
45 'npx',
46 [
47 'vite-node', 'scripts/bench.ts', '--json',
48 '--preset', preset, '--lmax', lmax, '--steps', steps, '--warmup', '30',
49 ],
50 { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 },
51);
52if (bench.status !== 0) {
53 console.error(bench.stdout ?? '');
54 console.error(bench.stderr ?? '');
55 console.error('compare-perf: the terminal run failed');
56 process.exit(1);
58const desktop = JSON.parse(bench.stdout);
60// ---- browser -------------------------------------------------------------
61const DIST = new URL('../dist/', import.meta.url).pathname;
62const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' };
63const server = createServer(async (req, res) => {
64 try {
65 const path = req.url === '/' ? '/index.html' : req.url.split('?')[0];
66 const data = await readFile(join(DIST, path));
67 res.writeHead(200, {
68 'content-type': MIME[extname(path)] ?? 'application/octet-stream',
69 });
70 res.end(data);
71 } catch {
72 res.writeHead(404);
73 res.end('not found');
74 }
75});
76await new Promise((r) => server.listen(0, '127.0.0.1', r));
77const port = server.address().port;
79const flagSets = [
80 ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu', '--enable-features=Vulkan'],
81 ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu',
82 '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader'],
83];
85let soak = null;
86let lastError = '';
87for (const args of flagSets) {
88 let browser;
89 try {
90 browser = await puppeteer.launch({
91 executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome',
92 args,
93 });
94 const page = await browser.newPage();
95 page.on('pageerror', (e) => (lastError = e.message));
96 await page.goto(`http://127.0.0.1:${port}/test.html?soak=${steps}&lmax=${lmax}`, {
97 waitUntil: 'load',
98 });
99 await page.waitForFunction(() => window.__SOAK__ !== undefined, { timeout: 600000 });
100 soak = await page.evaluate(() => window.__SOAK__);
101 await browser.close();
102 break;
103 } catch (e) {
104 lastError = e.message ?? String(e);
105 await browser?.close();
106 }
108server.close();
110if (!soak) {
111 console.error(`compare-perf: the browser run failed: ${lastError}`);
112 process.exit(1);
115// ---- report --------------------------------------------------------------
116const d = desktop.throughput;
117const row = (label, total, encode, adapter, fourier) => {
118 console.log(` ${label.padEnd(10)} ${total.toFixed(3)} ms/step` +
119 ` encoding ${encode.toFixed(3)} ms/step (${((100 * encode) / total).toFixed(0)}%)` +
120 ` ${fourier.toUpperCase()}`);
121 console.log(` ${''.padEnd(10)} ${adapter}`);
122};
123console.log('solver only, batched, nothing read back, no rendering:\n');
124row('terminal', d.msPerStep, d.encodeMsPerStep, desktop.backend.adapter, desktop.digest?.fourier ?? 'fft');
125row('browser', soak.solverMsPerStep, soak.encodeMsPerStep, soak.adapter, soak.fourier);
127const ratio = soak.solverMsPerStep / d.msPerStep;
128console.log(`\n browser / terminal = ${ratio.toFixed(2)}x`);
130// Before reading anything into the ratio: are these even the same GPU? A browser
131// quietly falling back to a software adapter is a common cause of "the browser is
132// much slower", and it makes the comparison meaningless rather than informative.
133const software = (a) => /swiftshader|llvmpipe|software|basic render/i.test(a ?? '');
134const desktopAdapter = desktop.backend.adapter ?? '';
135if (software(soak.adapter) !== software(desktopAdapter)) {
136 console.log(
137 `\n STOP these are not the same device. One side is a software renderer:\n` +
138 ` terminal: ${desktopAdapter}\n browser: ${soak.adapter}\n` +
139 ` The ratio above compares different hardware and means nothing. If it is the\n` +
140 ` browser that fell back, that IS the answer — check chrome://gpu for why\n` +
141 ` (hardware acceleration disabled, or the GPU blocklisted).`,
142 );
143} else if (desktopAdapter && soak.adapter && desktopAdapter !== soak.adapter) {
144 console.log(
145 `\n NOTE the two report different adapters, which may just be different\n` +
146 ` naming for the same GPU — but check it is not a second GPU:\n` +
147 ` terminal: ${desktopAdapter}\n browser: ${soak.adapter}`,
148 );
151if (desktop.digest && desktop.digest.fourier !== soak.fourier) {
152 console.log(
153 `\n NOTE different Fourier stage (${desktop.digest.fourier} vs ${soak.fourier}).\n` +
154 ` Those are different algorithms with different cost — that is the difference,\n` +
155 ` not a symptom of it.`,
156 );
157} else if (ratio < 1.3) {
158 console.log(
159 `\n The solver runs at the same rate in both. Anything the app shows beyond\n` +
160 ` this is its readback per species, the colormapping, competing with the\n` +
161 ` renderer for the GPU, and animation pacing — not the computation.`,
162 );
163} else {
164 console.log(
165 `\n The browser is slower at the same solver work, with no renderer involved,\n` +
166 ` so it is the GPU stack rather than anything above it: every submit crosses\n` +
167 ` into the GPU process, and Chrome's Dawn and node-webgpu's need not compile\n` +
168 ` or schedule these shaders identically. Note also that an animation-paced\n` +
169 ` page can leave the GPU in a low-power state where a continuous benchmark\n` +
170 ` boosts it; this soak hammers it continuously, so if the app is slower than\n` +
171 ` this number, that is a likely reason.`,
172 );
174console.log(
175 `\n Correctness is a separate question: scripts/compare-env.mjs checks that the\n` +
176 ` two environments compute the same state.`,
177);
moveopenescclose