19b58d0Add a throughput benchmark scriptJeremy Magland 1// Throughput benchmark: serves the app with Vite, opens it in a headless
2// Chromium with WebGPU, and runs the crash preset at several grid spacings,
3// alone and with several concurrent runs, reporting steps per second and
4// the wall time per second of audio. The numbers describe the machine's
5// GPU as seen through Chromium; the kernel is latency-bound on small grids,
6// so concurrency and finer grids are the interesting axes.
7//
8// node scripts/bench.ts [--quick] [--preset crash-16] [--gpu-flags "..."] [--browsers N]
9//
10// --browsers N spreads the concurrent runs over N independent Chromium
11// instances (each with its own GPU process), which tells whether a plateau
12// in total throughput is the GPU's or the browser's.
14import { fileURLToPath } from "node:url";
15import { dirname, join } from "node:path";
16import { createServer } from "vite";
17import { chromium, type Browser } from "playwright";
19const root = join(dirname(fileURLToPath(import.meta.url)), "..");
20const args = process.argv.slice(2);
21const quick = args.includes("--quick");
22const preset = args.includes("--preset") ? args[args.indexOf("--preset") + 1] : "crash-16";
23const extraFlags = args.includes("--gpu-flags") ? args[args.indexOf("--gpu-flags") + 1].split(" ") : [];
24const nBrowsers = args.includes("--browsers") ? parseInt(args[args.indexOf("--browsers") + 1]) : 1;
26const ARGS = ["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-angle=vulkan", "--ignore-gpu-blocklist", "--no-sandbox", ...extraFlags];
28interface Status { phase: string; totalSteps: number; dt: number; nx: number; ny: number; wmax: number; peakPressure: number }
30interface Case { spacing: number; duration: number; concurrency: number }
32const concOnly = args.includes("--conc");
33const cases: Case[] = concOnly
34 ? [{ spacing: 0.008, duration: 2, concurrency: 4 }, { spacing: 0.008, duration: 2, concurrency: 8 }]
35 : quick
36 ? [{ spacing: 0.008, duration: 1, concurrency: 1 }, { spacing: 0.004, duration: 0.25, concurrency: 1 }, { spacing: 0.008, duration: 1, concurrency: 4 }]
37 : [
38 { spacing: 0.008, duration: 2, concurrency: 1 },
39 { spacing: 0.006, duration: 1, concurrency: 1 },
40 { spacing: 0.004, duration: 0.5, concurrency: 1 },
41 { spacing: 0.003, duration: 0.25, concurrency: 1 },
42 { spacing: 0.008, duration: 2, concurrency: 2 },
43 { spacing: 0.008, duration: 2, concurrency: 4 },
44 { spacing: 0.008, duration: 2, concurrency: 8 },
45 { spacing: 0.004, duration: 0.5, concurrency: 4 },
46 ];
48/** Runs one simulation to completion in a fresh page; resolves with its status and wall time. */
49async function runOne(browser: Browser, url: string, c: Case): Promise<{ st: Status; wall: number; adapter: string }> {
50 const page = await browser.newPage();
51 const errors: string[] = [];
52 page.on("pageerror", (e) => errors.push(e.message));
53 page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
54 await page.goto(`${url}?autostart=1&preset=${preset}&spacing=${c.spacing}&duration=${c.duration}`);
55 const t0 = performance.now();
56 let st: Status | null = null;
57 for (let i = 0; i < 4000; i++) {
58 await page.waitForTimeout(100);
59 st = await page.evaluate<Status | null>("window.steelsim ? window.steelsim.status() : null");
60 const noGpu = await page.evaluate<string | null>("window.steelsim ? window.steelsim.noGpu() : null");
61 if (noGpu) throw new Error(noGpu);
62 if (st && st.phase === "done") break;
63 }
64 const wall = (performance.now() - t0) / 1000;
65 const adapter = await page.evaluate<string>(`navigator.gpu.requestAdapter().then(a => a ? (a.info ? a.info.vendor + " " + a.info.architecture + " " + a.info.device + " " + a.info.description : "adapter") : "none")`);
66 await page.close();
67 if (errors.length) throw new Error(errors.join("\n"));
68 if (!st || st.phase !== "done") throw new Error("did not finish");
69 return { st, wall, adapter };
70}
72async function main(): Promise<void> {
73 const server = await createServer({ root, logLevel: "silent", server: { port: 0, strictPort: false } });
74 await server.listen();
75 const url = server.resolvedUrls!.local[0];
76 const browsers = await Promise.all(Array.from({ length: nBrowsers }, () => chromium.launch({ headless: true, args: ARGS })));
77 try {
78 let adapter = "";
79 console.log(`preset ${preset}, ${nBrowsers} browser instance${nBrowsers > 1 ? "s" : ""}`);
80 console.log("spacing grid dt conc audio s wall s ksteps/s (each) ksteps/s (total) wall per audio s (each)");
81 for (const c of cases) {
82 const results = await Promise.all(Array.from({ length: c.concurrency }, (_, k) => runOne(browsers[k % nBrowsers], url, c)));
83 adapter = results[0].adapter;
84 const wall = Math.max(...results.map((r) => r.wall));
85 const steps = results[0].st.totalSteps;
86 const each = steps / wall / 1e3;
87 const total = (steps * c.concurrency) / wall / 1e3;
88 const st = results[0].st;
89 console.log(
90 `${(c.spacing * 1e3).toFixed(0).padStart(4)} mm ${`${st.nx}x${st.ny}`.padEnd(9)} ${(st.dt * 1e6).toFixed(2).padStart(5)} us ${String(c.concurrency).padStart(3)} ${c.duration.toFixed(2).padStart(5)} ${wall.toFixed(1).padStart(6)} ${each.toFixed(0).padStart(8)} ${total.toFixed(0).padStart(8)} ${(wall / c.duration).toFixed(1).padStart(6)}x`,
91 );
92 if (!isFinite(st.wmax) || st.wmax > 0.05) console.log(" (diverged)");
93 }
94 console.log(`adapter: ${adapter}`);
95 } finally {
96 for (const b of browsers) await b.close();
97 await server.close();
98 }
99}
101void main();