diff --git a/frontend/package.json b/frontend/package.json
index 4e1433e206..37cf86285a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,10 @@
"url": "https://github.com/penpot/penpot"
},
"scripts": {
+ "benchmark:renderer": "node scripts/renderer-benchmarks.js",
+ "test:renderer-benchmarks": "node --test scripts/renderer-benchmarks/tests/*.test.js",
+ "verify:renderer-benchmarks": "node scripts/renderer-benchmarks/verify.js",
+ "verify:renderer-benchmark-failures": "node scripts/renderer-benchmarks/verify-failures.js",
"build:app:assets": "node ./scripts/build-app-assets.js",
"build:fonts-preview": "node ./scripts/build-fonts-preview.js",
"build:storybook": "(cd packages/ui && pnpm run build) && pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build",
diff --git a/frontend/resources/wasm-playground/BENCHMARKS.md b/frontend/resources/wasm-playground/BENCHMARKS.md
new file mode 100644
index 0000000000..3df5a6901d
--- /dev/null
+++ b/frontend/resources/wasm-playground/BENCHMARKS.md
@@ -0,0 +1,111 @@
+# Renderer benchmarks
+
+Run from `frontend/` in the prepared Penpot development container, as the normal
+`penpot` user. Dependencies, the matching Playwright Chromium installation, and
+the static server must already exist. Start the server separately with
+`node scripts/e2e-server.js`. The runner never installs packages or browsers,
+starts services, or cleans Cargo targets.
+
+Use the Cargo cache prepared for that user. In this container, noninteractive
+shells inherit `CARGO_HOME=/opt/cargo`, while the populated cache is
+`/home/penpot/.cargo`. For those shells, prefix the command with
+`CARGO_HOME=/home/penpot/.cargo`. The runner preserves the supplied environment;
+it does not move caches or download missing crates.
+
+```sh
+pnpm run benchmark:renderer run --output /tmp/baseline.json
+pnpm run benchmark:renderer run --filter texts --seed 42 --output /tmp/texts.json
+pnpm run benchmark:renderer run --headed --filter rects/default/zoom
+pnpm run benchmark:renderer compare /tmp/baseline.json /tmp/candidate.json
+pnpm run benchmark:renderer compare /tmp/baseline.json /tmp/candidate.json --diagnostic
+pnpm run test:renderer-benchmarks
+```
+
+Each run builds the ordinary optimized frontend renderer once using
+`PENPOT_WASM_PREPARED=1`, offline Cargo, and the existing build/copy path. Setup is
+skipped, package-manager network fallback is disabled, and the emitted module is
+named `render-wasm-benchmark`. Playground assets are refreshed before launch. A
+per-run marker checks that the server points at this checkout. Concurrent builds
+or benchmark commands against the same checkout are unsupported.
+
+The default server URL is `http://localhost:3000`; override it with `--base-url`.
+Default JSON output goes to a unique file under the system temporary directory.
+Use `--warmups`, `--repetitions`, and `--timeout-ms` to override the provisional
+defaults of 3, 10, and 30000. Counts are fixed, not adaptive; failures are never
+silently replaced. A failed case stops its remaining attempts and records how
+many were unattempted, then later independent cases can continue. A lost browser
+ends the run and retains earlier results. Exit status reports execution validity,
+not a performance regression.
+
+## Workloads and state
+
+Seven versioned scenes use a seeded generator: 1000 rectangles, stars, lines, or
+shadowed rectangles; 100 text shapes; and fixed mask/clip scenes. Scene generation
+and buffer construction happen before upload timing. Text uses the renderer's
+embedded fallback font, not production font fetching. The JavaScript fixtures
+share their implementation with the interactive playground pages; `?seed=42` is
+repeatable, and numeric shape-count overrides remain available on those pages.
+
+All cases run serially in one Chromium process with a 1920x1080 CSS viewport,
+DPR 2, and product WebGL2 context options. Effective sizes, context attributes,
+browser/platform identity, seeds, and workload parameters appear in JSON.
+Software WebGL is allowed and warns; a GPU launch flag does not prove hardware
+acceleration. Fresh pages do not clear browser-process, compilation, or driver
+caches.
+
+- **Fresh module/context:** each attempt gets a new page/context and module.
+ Module initialization starts at the Emscripten factory and includes WASM
+ fetching/instantiation; the module's JavaScript import precedes timing.
+- **Context initialization:** canvas/context registration, native initialization,
+ render/browser options, and viewport sizing.
+- **Upload:** direct FFI upload, pending text layouts, background, initial view,
+ and initial tile-index setup. This is not the frontend's serialization or
+ batched asynchronous upload path.
+- **First render:** request to `ViewportReady` and final `Full`, including rAF
+ scheduling. An immediate `Full` supplies both boundaries.
+- **Warm pan/zoom:** separate case-owned instances; restore the starting view and
+ finish preparation rendering before each attempt. Caches evolve within each
+ case. Restoring the view is not a cache reset.
+
+Interactions use declared frame paths, cached rendering, a declared 100 ms
+settling interval, and product finalization/continuation flags. The interval
+models a settling case, not every possible editor gesture. Finalization time
+starts before `_set_view_end`, and that call's duration also appears separately.
+Last-input-to-`Full` additionally includes the delay. Raw render/cached calls,
+timestamps, flags, frame types, and zero-duration slices remain available.
+
+## Interpretation
+
+These are renderer submission timings. They do not measure GPU completion,
+displayed pixels, input delivery, whole-editor responsiveness, network behavior,
+or visual correctness. WASM buffer size is context, not attributed CPU/GPU memory.
+
+Reports retain means, medians, ranges, sample deviation, and unscaled median
+absolute deviation. Nominal 95% bootstrap intervals require 10 valid attempts;
+p95 requires 100 and p99 requires 500. Intervals are exploratory and conditional
+on these runs: serial correlation, cache history, system load, and thermal drift
+remain. Confirm important findings with repeated complete runs. No p-values or
+automated regression thresholds are provided.
+
+Offline comparison checks scenario/version, seeds, resolved workloads,
+preparation, environment, and metric semantics. Source/build changes are expected.
+`--diagnostic` lists incompatibilities and permits inspection without claiming
+equivalence. Dirty worktrees warn and continue, recording only SHA and dirty
+state, not diffs or artifact hashes.
+
+## Separate fixture validation
+
+```sh
+pnpm run verify:renderer-benchmarks
+pnpm run verify:renderer-benchmarks --screenshots /tmp/renderer-diagnostics
+```
+
+This unscored smoke uses the already-built benchmark artifact and Playwright
+request routing; it starts no server. It checks every workload and interaction
+through `Full`. Optional screenshots support independent manual fixture checks.
+They never enter scored runs, and smoke durations are not benchmark evidence.
+
+The implementation roadmap, pilot evidence, and remaining calibration work live
+in Serena memory `render-wasm/performance/core` and its numbered tickets. The
+2-5 minute full-suite goal excludes build time and is a target, not a guarantee
+on software-rendered systems.
diff --git a/frontend/resources/wasm-playground/benchmark.html b/frontend/resources/wasm-playground/benchmark.html
new file mode 100644
index 0000000000..f68f56ebe0
--- /dev/null
+++ b/frontend/resources/wasm-playground/benchmark.html
@@ -0,0 +1,31 @@
+
+
+
+
+ Renderer benchmarks
+
+
+
+
+
+
+
+
diff --git a/frontend/resources/wasm-playground/benchmarks/browser.js b/frontend/resources/wasm-playground/benchmarks/browser.js
new file mode 100644
index 0000000000..1b53953163
--- /dev/null
+++ b/frontend/resources/wasm-playground/benchmarks/browser.js
@@ -0,0 +1,249 @@
+import { scenarios } from "../scenes/index.js";
+import { drain, interact, restore } from "./protocol.js";
+import { WEBGL_OPTIONS, validateMetrics } from "./contract.js";
+import { summarizeRun } from "./statistics.js";
+
+let session;
+const canvas = document.querySelector("canvas");
+
+function check(owner = session) {
+ if (!owner || owner.canceled) throw new Error("Benchmark canceled");
+ if (owner !== session) throw new Error("Stale benchmark operation");
+ if (owner.lost || owner.gl?.isContextLost())
+ throw new Error("WebGL context lost");
+ if (performance.now() > owner.deadline) throw new Error("Operation timeout");
+}
+
+function runtime() {
+ const owner = session;
+ return {
+ module: owner.module,
+ now: () => performance.now(),
+ recordSlice: (slice) => owner.slices.push(slice),
+ recordCached: (slice) => owner.cachedSlices.push(slice),
+ check: () => {
+ if (owner !== session) throw new Error("Stale benchmark operation");
+ check();
+ },
+ frame: () =>
+ new Promise((resolve, reject) => {
+ owner.cancelPending = reject;
+ owner.raf = requestAnimationFrame((timestamp) => {
+ owner.cancelPending = null;
+ owner.raf = null;
+ resolve(timestamp);
+ });
+ }),
+ sleep: (ms) =>
+ new Promise((resolve, reject) => {
+ owner.cancelPending = reject;
+ owner.timer = setTimeout(() => {
+ owner.cancelPending = null;
+ resolve();
+ }, ms);
+ }),
+ };
+}
+
+function webglInfo(gl) {
+ const extension = gl.getExtension("WEBGL_debug_renderer_info");
+ const vendor = gl.getParameter(extension?.UNMASKED_VENDOR_WEBGL ?? gl.VENDOR);
+ const renderer = gl.getParameter(
+ extension?.UNMASKED_RENDERER_WEBGL ?? gl.RENDERER,
+ );
+ return {
+ vendor,
+ renderer,
+ version: gl.getParameter(gl.VERSION),
+ unmasked: Boolean(extension),
+ acceleration: /swiftshader|llvmpipe|software|softpipe/i.test(
+ `${vendor} ${renderer}`,
+ )
+ ? "software"
+ : "unknown",
+ options: gl.getContextAttributes(),
+ drawingBuffer: {
+ width: gl.drawingBufferWidth,
+ height: gl.drawingBufferHeight,
+ },
+ };
+}
+
+async function initialize(benchmarkCase, options) {
+ const scenario = scenarios.find(
+ (item) => item.id === benchmarkCase.scenarioId,
+ );
+ if (!scenario)
+ throw new Error(`Unknown scenario: ${benchmarkCase.scenarioId}`);
+ // Construction and module script import deliberately precede initialization timing.
+ const scene = scenario.createScene(
+ benchmarkCase.params,
+ benchmarkCase.sceneSeed,
+ );
+ const owner = {
+ slices: [],
+ cachedSlices: [],
+ canceled: false,
+ lost: false,
+ deadline: performance.now() + options.timeoutMs,
+ options,
+ benchmarkCase,
+ };
+ session = owner;
+ const { default: createModule } = await import(options.moduleUrl);
+ check(owner);
+ const moduleStart = performance.now();
+ owner.module = await createModule();
+ const moduleInitMs = performance.now() - moduleStart;
+ check(owner);
+ const contextStart = performance.now();
+ canvas.style.width = `${options.viewport.width}px`;
+ canvas.style.height = `${options.viewport.height}px`;
+ canvas.width = Math.floor(options.viewport.width * options.dpr);
+ canvas.height = Math.floor(options.viewport.height * options.dpr);
+ const gl = canvas.getContext("webgl2", WEBGL_OPTIONS);
+ if (!gl) throw new Error("WebGL2 is unavailable");
+ owner.gl = gl;
+ // Native initialization queries the unmasked renderer identity.
+ gl.getExtension("WEBGL_debug_renderer_info");
+ owner.onLost = (event) => {
+ event.preventDefault();
+ owner.lost = true;
+ };
+ canvas.addEventListener("webglcontextlost", owner.onLost);
+ owner.handle = owner.module.GL.registerContext(gl, { majorVersion: 2 });
+ owner.module.GL.makeContextCurrent(owner.handle);
+ owner.module._init(options.viewport.width, options.viewport.height);
+ owner.initialized = true;
+ owner.module._set_render_options(0, options.dpr);
+ owner.module._set_browser(1); // Rust Browser::Chrome
+ owner.module._resize_viewbox(options.viewport.width, options.viewport.height);
+ const contextInitMs = performance.now() - contextStart;
+ // Diagnostic queries are outside renderer and upload timing.
+ const environment = {
+ webgl: webglInfo(gl),
+ canvas: {
+ cssWidth: canvas.clientWidth,
+ cssHeight: canvas.clientHeight,
+ width: canvas.width,
+ height: canvas.height,
+ },
+ devicePixelRatio: globalThis.devicePixelRatio,
+ };
+ owner.environment = environment;
+ const uploadStart = performance.now();
+ scenario.upload(owner.module, scene);
+ owner.module._set_canvas_background(0xfffabada);
+ const view = benchmarkCase.initialView;
+ owner.module._set_view(view.scale, view.x, view.y);
+ owner.module._set_view_end(); // Bulk upload needs its initial tile index.
+ const uploadMs = performance.now() - uploadStart;
+ const first = await drain(runtime(), { flags: 0 });
+ return {
+ environment,
+ metrics: {
+ moduleInitMs,
+ contextInitMs,
+ uploadMs,
+ timeToViewportReadyMs: first.viewportReadyMs,
+ timeToFullMs: first.fullMs,
+ },
+ slices: first.slices,
+ cachedSlices: [],
+ };
+}
+
+function finish(result) {
+ const invalidMetrics = validateMetrics(result.metrics);
+ const slices = result.slices ?? [];
+ const cachedSlices = result.cachedSlices ?? [];
+ const timestamps = cachedSlices.map((slice) => slice.timestamp);
+ return {
+ ...result,
+ status: Object.keys(invalidMetrics).length ? "invalid" : "ok",
+ invalidMetrics,
+ frameCounts: {
+ Partial: slices.filter((s) => s.frameType === 1).length,
+ ViewportReady: slices.filter((s) => s.frameType === 3).length,
+ Full: slices.filter((s) => s.frameType === 2).length,
+ },
+ rafGapsMs: timestamps.slice(1).map((time, i) => time - timestamps[i]),
+ renderRafGapsMs: slices
+ .slice(1)
+ .map((slice, i) => slice.timestamp - slices[i].timestamp),
+ zeroSlices: [...slices, ...cachedSlices].filter((s) => s.durationMs === 0)
+ .length,
+ wasmMemoryBytes: session.module.HEAPU8.buffer.byteLength,
+ };
+}
+
+async function runAttempt(benchmarkCase, options) {
+ try {
+ let result;
+ if (!session) {
+ result = await initialize(benchmarkCase, options);
+ if (benchmarkCase.group === "fresh-module-context") return finish(result);
+ }
+ session.deadline = performance.now() + options.timeoutMs;
+ await restore(runtime(), benchmarkCase.initialView);
+ session.slices = [];
+ session.cachedSlices = [];
+ session.deadline = performance.now() + options.timeoutMs;
+ result = await interact(runtime(), benchmarkCase.interaction);
+ return finish({
+ ...result,
+ environment: { ...session.environment, webgl: webglInfo(session.gl) },
+ });
+ } catch (error) {
+ return {
+ status: "failed",
+ metrics: {},
+ invalidMetrics: {},
+ slices: session?.slices ?? [],
+ cachedSlices: session?.cachedSlices ?? [],
+ environment: session?.environment,
+ error: String(error?.stack ?? error),
+ };
+ }
+}
+
+function dispose() {
+ if (!session) return;
+ const owner = session;
+ owner.canceled = true;
+ owner.cancelPending?.(new Error("Benchmark canceled"));
+ cancelAnimationFrame(owner.raf);
+ clearTimeout(owner.timer);
+ canvas.removeEventListener("webglcontextlost", owner.onLost);
+ try {
+ if (owner.initialized && !owner.lost) {
+ owner.module._free_gpu_resources?.();
+ owner.module._clean_up();
+ }
+ } finally {
+ if (owner.handle !== undefined) owner.module.GL.deleteContext(owner.handle);
+ owner.gl?.getExtension("WEBGL_lose_context")?.loseContext();
+ session = null;
+ }
+}
+
+globalThis.rendererBenchmark = {
+ runAttempt,
+ dispose,
+ showResult(result) {
+ globalThis.rendererBenchmarkResult = result;
+ const report = document.querySelector("pre");
+ report.textContent = JSON.stringify(
+ {
+ scope: result.scope,
+ metadata: result.metadata,
+ warnings: result.warnings,
+ summary: result.summary ?? summarizeRun(result),
+ },
+ null,
+ 2,
+ );
+ report.hidden = false;
+ },
+};
+globalThis.rendererBenchmarkReady = true;
diff --git a/frontend/resources/wasm-playground/benchmarks/contract.js b/frontend/resources/wasm-playground/benchmarks/contract.js
new file mode 100644
index 0000000000..98a8f27331
--- /dev/null
+++ b/frontend/resources/wasm-playground/benchmarks/contract.js
@@ -0,0 +1,124 @@
+export const WEBGL_OPTIONS = Object.freeze({
+ antialias: false,
+ depth: true,
+ stencil: true,
+ alpha: true,
+ preserveDrawingBuffer: true,
+});
+
+export function deriveSeed(masterSeed, caseId) {
+ let seed = 2166136261;
+ for (const character of `${masterSeed}\0${caseId}`) {
+ seed = Math.imul(seed ^ character.charCodeAt(0), 16777619) >>> 0;
+ }
+ return seed;
+}
+
+function serializable(value, path = "case") {
+ if (value === null || typeof value === "string" || typeof value === "boolean")
+ return;
+ if (typeof value === "number" && Number.isFinite(value)) return;
+ if (Array.isArray(value)) {
+ value.forEach((v, i) => serializable(v, `${path}[${i}]`));
+ return;
+ }
+ if (value && Object.getPrototypeOf(value) === Object.prototype) {
+ for (const [key, v] of Object.entries(value))
+ serializable(v, `${path}.${key}`);
+ return;
+ }
+ throw new Error(`Non-serializable value: ${path}`);
+}
+
+function viewValid(view) {
+ return (
+ view &&
+ Number.isFinite(view.scale) &&
+ view.scale > 0 &&
+ Number.isFinite(view.x) &&
+ Number.isFinite(view.y)
+ );
+}
+
+export function collectCases(scenarios, masterSeed, filter = "") {
+ const cases = [];
+ const seen = new Set();
+ const scenarioIds = new Set();
+ for (const scenario of scenarios) {
+ if (
+ !scenario.id ||
+ scenarioIds.has(scenario.id) ||
+ !Number.isInteger(scenario.version) ||
+ typeof scenario.createScene !== "function" ||
+ typeof scenario.upload !== "function" ||
+ !Array.isArray(scenario.cases) ||
+ !scenario.cases.length
+ )
+ throw new Error("Invalid or duplicate scenario");
+ scenarioIds.add(scenario.id);
+ for (const c of scenario.cases) {
+ serializable(c);
+ const baseId = `${scenario.id}/${c.id}`;
+ if (!c.id || seen.has(baseId))
+ throw new Error(`Missing or duplicate case ID: ${baseId}`);
+ seen.add(baseId);
+ if (!c.params || !viewValid(c.initialView))
+ throw new Error(`Invalid parameters/view: ${baseId}`);
+ for (const kind of ["pan", "zoom"]) {
+ const interaction = c.interactions?.[kind];
+ if (
+ !interaction ||
+ !Array.isArray(interaction.frames) ||
+ !interaction.frames.length ||
+ !interaction.frames.every(viewValid) ||
+ !Number.isFinite(interaction.settleMs) ||
+ interaction.settleMs < 0
+ )
+ throw new Error(`Incomplete ${kind} interaction: ${baseId}`);
+ }
+ // Validate static workloads before any build or browser measurement. Browser
+ // construction still owns its descriptors and remains outside timed spans.
+ scenario.createScene(c.params, deriveSeed(masterSeed, baseId));
+ for (const kind of ["load", "pan", "zoom"]) {
+ const id = `${baseId}/${kind}`;
+ if (filter && !id.includes(filter)) continue;
+ cases.push({
+ id,
+ scenarioId: scenario.id,
+ scenarioVersion: scenario.version,
+ description: scenario.description,
+ sceneSeed: deriveSeed(masterSeed, baseId),
+ params: c.params,
+ initialView: c.initialView,
+ interaction: kind === "load" ? null : c.interactions[kind],
+ group: kind === "load" ? "fresh-module-context" : `warm-${kind}`,
+ preparation:
+ kind === "load"
+ ? "new-context-module; upload; set-view-end; full"
+ : "case-owned-context; upload; full; restore-view-and-full-before-each-attempt; evolving-caches",
+ status: "unattempted",
+ attempts: [],
+ });
+ }
+ }
+ }
+ if (!cases.length) throw new Error(`No matching cases: ${filter}`);
+ return cases;
+}
+
+export function validateMetrics(metrics) {
+ return Object.fromEntries(
+ Object.entries(metrics)
+ .filter(
+ ([key, value]) =>
+ !Number.isFinite(value) ||
+ value < 0 ||
+ (value === 0 &&
+ !["setViewEndMs", "settlingRequestedMs"].includes(key)),
+ )
+ .map(([key, value]) => [
+ key,
+ Number.isFinite(value) ? "below-timer-resolution" : "non-finite",
+ ]),
+ );
+}
diff --git a/frontend/resources/wasm-playground/benchmarks/protocol.js b/frontend/resources/wasm-playground/benchmarks/protocol.js
new file mode 100644
index 0000000000..2d3929525b
--- /dev/null
+++ b/frontend/resources/wasm-playground/benchmarks/protocol.js
@@ -0,0 +1,77 @@
+// Browser-independent protocol; the runtime owns the clock and cancellation.
+export async function drain(
+ rt,
+ { flags = 0, origin = rt.now(), immediate = false } = {},
+) {
+ const slices = [];
+ let viewportReadyMs;
+ do {
+ rt.check();
+ const timestamp = immediate ? rt.now() : await rt.frame();
+ immediate = false;
+ rt.check();
+ const start = rt.now();
+ const frameType = rt.module._render(timestamp, flags);
+ const end = rt.now();
+ slices.push({ timestamp, flags, frameType, durationMs: end - start });
+ rt.recordSlice?.(slices.at(-1));
+ rt.check();
+ if (![1, 2, 3].includes(frameType))
+ throw new Error(`Unexpected frame type: ${frameType}`);
+ if (frameType === 3 || frameType === 2) viewportReadyMs ??= end - origin;
+ if (frameType === 2)
+ return { slices, viewportReadyMs, fullMs: end - origin };
+ // The production scheduler passes the previous FrameType as flags.
+ flags = frameType;
+ } while (true);
+}
+
+export async function restore(rt, view) {
+ rt.check();
+ rt.module._set_view_start();
+ rt.module._set_view(view.scale, view.x, view.y);
+ rt.module._set_view_end();
+ return drain(rt, { flags: 4, immediate: true });
+}
+
+export async function interact(rt, interaction) {
+ rt.check();
+ const start = rt.now();
+ rt.module._set_view_start();
+ const cachedSlices = [];
+ let lastInput;
+ for (const view of interaction.frames) {
+ const timestamp = await rt.frame();
+ rt.check();
+ lastInput = rt.now();
+ rt.module._set_view(view.scale, view.x, view.y);
+ const cacheStart = rt.now();
+ rt.module._render_from_cache(0);
+ cachedSlices.push({ timestamp, durationMs: rt.now() - cacheStart });
+ rt.recordCached?.(cachedSlices.at(-1));
+ }
+ const activeEnd = rt.now();
+ await rt.sleep(interaction.settleMs);
+ rt.check();
+ const finalizationStart = rt.now();
+ rt.module._set_view_end();
+ const setViewEndMs = rt.now() - finalizationStart;
+ const result = await drain(rt, {
+ flags: 4,
+ origin: finalizationStart,
+ immediate: true,
+ });
+ return {
+ metrics: {
+ activeInteractionMs: activeEnd - start,
+ settlingRequestedMs: interaction.settleMs,
+ settlingActualMs: finalizationStart - activeEnd,
+ setViewEndMs,
+ timeToViewportReadyMs: result.viewportReadyMs,
+ timeToFullMs: result.fullMs,
+ lastInputToFullMs: rt.now() - lastInput,
+ },
+ slices: result.slices,
+ cachedSlices,
+ };
+}
diff --git a/frontend/resources/wasm-playground/benchmarks/statistics.js b/frontend/resources/wasm-playground/benchmarks/statistics.js
new file mode 100644
index 0000000000..62e3b4319c
--- /dev/null
+++ b/frontend/resources/wasm-playground/benchmarks/statistics.js
@@ -0,0 +1,269 @@
+// Shared by the benchmark page and offline Node reports. No browser work or I/O.
+export const STATISTICAL_METHOD = Object.freeze({
+ version: 1,
+ name: "percentile-bootstrap-of-medians",
+ confidence: 0.95,
+ resamples: 2000,
+ seed: 0x70656e70,
+ minimumIntervalSamples: 10,
+ minimumP95Samples: 100,
+ minimumP99Samples: 500,
+});
+
+export const STATISTICAL_WARNINGS = Object.freeze([
+ "Intervals are exploratory, nominal 95%, and conditional on these observed runs; attempts can be correlated.",
+ "Independent runs can drift with system load and thermal state. Confirm important changes with repeated complete runs.",
+]);
+
+export function statisticalMethod(options = {}) {
+ const method = { ...STATISTICAL_METHOD, ...options };
+ if (
+ !Number.isInteger(method.resamples) ||
+ method.resamples < 100 ||
+ method.resamples > 10000
+ ) {
+ throw new Error("resamples must be an integer between 100 and 10000");
+ }
+ if (
+ !Number.isInteger(method.seed) ||
+ method.seed < 0 ||
+ method.seed > 0xffffffff
+ ) {
+ throw new Error("seed must be a uint32 integer");
+ }
+ // Only the resampling count and seed are configurable; support thresholds and
+ // interval interpretation form part of the versioned statistical method.
+ return {
+ ...STATISTICAL_METHOD,
+ resamples: method.resamples,
+ seed: method.seed,
+ };
+}
+
+function randomGenerator(seed) {
+ let state = seed >>> 0;
+ return () => {
+ state = (state + 0x6d2b79f5) >>> 0;
+ let value = Math.imul(state ^ (state >>> 15), 1 | state);
+ value ^= value + Math.imul(value ^ (value >>> 7), 61 | value);
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+function quantile(sorted, probability) {
+ if (!sorted.length) return null;
+ const position = (sorted.length - 1) * probability;
+ const lower = Math.floor(position);
+ const upper = Math.ceil(position);
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
+}
+
+function sorted(values) {
+ return [...values].sort((a, b) => a - b);
+}
+
+function interval(values) {
+ const ordered = sorted(values);
+ return { low: quantile(ordered, 0.025), high: quantile(ordered, 0.975) };
+}
+
+function resampleMedian(values, random) {
+ const sample = Array.from(
+ { length: values.length },
+ () => values[Math.floor(random() * values.length)],
+ );
+ return quantile(sorted(sample), 0.5);
+}
+
+export function summarize(values, options = {}) {
+ const method = statisticalMethod(options);
+ const ordered = sorted(
+ values.filter((value) => Number.isFinite(value) && value >= 0),
+ );
+ const n = ordered.length;
+ const median = quantile(ordered, 0.5);
+ const mean = n ? ordered.reduce((sum, value) => sum + value / n, 0) : null;
+ const summary = {
+ n,
+ invalid: values.length - n,
+ mean,
+ median,
+ min: n ? ordered[0] : null,
+ max: n ? ordered[n - 1] : null,
+ standardDeviation:
+ n > 1
+ ? Math.sqrt(
+ ordered.reduce(
+ (sum, value) => sum + (value - mean) ** 2 / (n - 1),
+ 0,
+ ),
+ )
+ : null,
+ mad: n
+ ? quantile(sorted(ordered.map((value) => Math.abs(value - median))), 0.5)
+ : null,
+ medianInterval: null,
+ p95: null,
+ p99: null,
+ suppressed: {},
+ };
+ if (n >= method.minimumIntervalSamples) {
+ const random = randomGenerator(method.seed);
+ summary.medianInterval = interval(
+ Array.from({ length: method.resamples }, () =>
+ resampleMedian(ordered, random),
+ ),
+ );
+ } else {
+ summary.suppressed.medianInterval = `Requires at least ${method.minimumIntervalSamples} valid attempts.`;
+ }
+ for (const [name, threshold, probability] of [
+ ["p95", method.minimumP95Samples, 0.95],
+ ["p99", method.minimumP99Samples, 0.99],
+ ]) {
+ if (n >= threshold) summary[name] = quantile(ordered, probability);
+ else
+ summary.suppressed[name] =
+ `Requires at least ${threshold} valid attempts.`;
+ }
+ return summary;
+}
+
+export function metricNames(benchmarkCase) {
+ return [
+ ...new Set(
+ benchmarkCase.attempts.flatMap((attempt) => [
+ ...Object.keys(attempt.metrics ?? {}),
+ ...Object.keys(attempt.invalidMetrics ?? {}),
+ ]),
+ ),
+ ].sort();
+}
+
+export function observations(benchmarkCase, metric) {
+ return benchmarkCase.attempts
+ .filter((attempt) => !attempt.warmup)
+ .map((attempt) => {
+ const invalid = attempt.invalidMetrics ?? {};
+ if (
+ attempt.status === "failed" ||
+ (attempt.status === "invalid" && !Object.keys(invalid).length) ||
+ Object.hasOwn(invalid, metric)
+ )
+ return NaN;
+ return attempt.metrics?.[metric] ?? NaN;
+ });
+}
+
+export function summarizeRun(result, options = {}) {
+ return {
+ method: statisticalMethod(options),
+ warnings: [...STATISTICAL_WARNINGS],
+ cases: result.cases.map((benchmarkCase) => {
+ const measured = benchmarkCase.attempts.filter(
+ (attempt) => !attempt.warmup,
+ );
+ return {
+ id: benchmarkCase.id,
+ status: benchmarkCase.status,
+ attempts: {
+ measured: measured.length,
+ warmup: benchmarkCase.attempts.length - measured.length,
+ ok: measured.filter((attempt) => attempt.status === "ok").length,
+ invalid: measured.filter((attempt) => attempt.status === "invalid")
+ .length,
+ failed: measured.filter((attempt) => attempt.status === "failed")
+ .length,
+ },
+ metrics: Object.fromEntries(
+ metricNames(benchmarkCase).map((metric) => [
+ metric,
+ summarize(observations(benchmarkCase, metric), options),
+ ]),
+ ),
+ };
+ }),
+ };
+}
+
+export function compareObservations(
+ baselineValues,
+ candidateValues,
+ options = {},
+) {
+ const method = statisticalMethod(options);
+ const baseline = summarize(baselineValues, options);
+ const candidate = summarize(candidateValues, options);
+ const result = {
+ baseline,
+ candidate,
+ absoluteChange: null,
+ relativeChangePercent: null,
+ absoluteInterval: null,
+ relativeIntervalPercent: null,
+ suppressed: {},
+ };
+ if (!baseline.n || !candidate.n) {
+ result.suppressed.comparison = "Both runs require valid observations.";
+ return result;
+ }
+ result.absoluteChange = candidate.median - baseline.median;
+ if (baseline.median === 0)
+ result.suppressed.relativeChangePercent = "Baseline median is zero.";
+ else
+ result.relativeChangePercent =
+ (100 * result.absoluteChange) / baseline.median;
+ if (
+ baseline.n < method.minimumIntervalSamples ||
+ candidate.n < method.minimumIntervalSamples
+ ) {
+ result.suppressed.intervals = `Each run requires at least ${method.minimumIntervalSamples} valid attempts.`;
+ return result;
+ }
+ const before = baselineValues.filter(
+ (value) => Number.isFinite(value) && value >= 0,
+ );
+ const after = candidateValues.filter(
+ (value) => Number.isFinite(value) && value >= 0,
+ );
+ const random = randomGenerator(method.seed);
+ const differences = [];
+ const relative = [];
+ for (let index = 0; index < method.resamples; index++) {
+ // Independent draws preserve the unpaired nature of separate runs.
+ const first = resampleMedian(before, random);
+ const second = resampleMedian(after, random);
+ differences.push(second - first);
+ if (first !== 0) relative.push((100 * (second - first)) / first);
+ }
+ result.absoluteInterval = interval(differences);
+ if (baseline.median !== 0 && relative.length === method.resamples)
+ result.relativeIntervalPercent = interval(relative);
+ else
+ result.suppressed.relativeIntervalPercent =
+ "Baseline median or a resampled baseline median is zero.";
+ return result;
+}
+
+function display(value) {
+ return value === null ? "n/a" : value.toFixed(3);
+}
+
+export function formatSummary(summary) {
+ const lines = [
+ `Statistics: ${summary.method.name} v${summary.method.version}; ${summary.method.resamples} resamples, seed ${summary.method.seed}.`,
+ ...summary.warnings,
+ ];
+ for (const benchmarkCase of summary.cases) {
+ lines.push(`${benchmarkCase.id} (${benchmarkCase.status})`);
+ for (const [name, metric] of Object.entries(benchmarkCase.metrics)) {
+ lines.push(
+ ` ${name}: median ${display(metric.median)}, mean ${display(metric.mean)}, range ${display(metric.min)}–${display(metric.max)}, SD ${display(metric.standardDeviation)}, MAD ${display(metric.mad)}; ${metric.n} valid, ${metric.invalid} invalid`,
+ );
+ lines.push(
+ ` median interval: ${metric.medianInterval ? `[${display(metric.medianInterval.low)}, ${display(metric.medianInterval.high)}]` : metric.suppressed.medianInterval}; p95: ${metric.p95 === null ? metric.suppressed.p95 : display(metric.p95)}; p99: ${metric.p99 === null ? metric.suppressed.p99 : display(metric.p99)}`,
+ );
+ }
+ }
+ return lines.join("\n");
+}
diff --git a/frontend/resources/wasm-playground/clips.html b/frontend/resources/wasm-playground/clips.html
index ba1951e56a..6823632784 100644
--- a/frontend/resources/wasm-playground/clips.html
+++ b/frontend/resources/wasm-playground/clips.html
@@ -1,91 +1,47 @@
-
-
-
-
- WASM + WebGL2 Canvas
-
-
-
-
-
-
+ mountScenario(
+ scenario,
+ initModule,
+ document.getElementById("canvas"),
+ ).catch((error) => {
+ console.error(error);
+ const message = document.createElement("pre");
+ message.id = "error";
+ message.textContent = error.message;
+ document.body.append(message);
+ });
+
+