summaryrefslogtreecommitdiff
path: root/apps/web/src/diagnostics
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-22 16:48:37 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-22 16:48:37 +0200
commitcceb835a84c125990ad8e49784e01a7c52636bb3 (patch)
tree8520cce9a203c72c7bcdc72ca138a36316a5a110 /apps/web/src/diagnostics
parentf1b45b4328cff6a016720ce7b38b87ac2992dc2d (diff)
downloadopencut-cceb835a84c125990ad8e49784e01a7c52636bb3.tar.gz
perf: improve rendering performance by ~20x
Diffstat (limited to 'apps/web/src/diagnostics')
-rw-r--r--apps/web/src/diagnostics/render-perf.ts182
1 files changed, 182 insertions, 0 deletions
diff --git a/apps/web/src/diagnostics/render-perf.ts b/apps/web/src/diagnostics/render-perf.ts
new file mode 100644
index 00000000..7b0b7fd9
--- /dev/null
+++ b/apps/web/src/diagnostics/render-perf.ts
@@ -0,0 +1,182 @@
+/**
+ * Lightweight rolling perf instrumentation for the render pipeline.
+ *
+ * Toggle at runtime from the devtools console:
+ * window.__renderPerf = true
+ *
+ * Every FLUSH_EVERY frames the aggregator dumps:
+ * - per-span timing summary (count / mean / p50 / p95 / max, in ms)
+ * - per-counter totals (uploads, canvas allocations by kind, etc.)
+ *
+ * Zero overhead when disabled: `isRenderPerfEnabled()` short-circuits before
+ * any recording happens, so call sites only pay for a global read.
+ */
+
+type SpanSample = number;
+
+type SpanStats = {
+ samples: SpanSample[];
+};
+
+type CounterStats = {
+ total: number;
+ frames: number;
+};
+
+const FLUSH_EVERY = 60;
+
+const spans = new Map<string, SpanStats>();
+const counters = new Map<string, CounterStats>();
+const pendingCountersThisFrame = new Map<string, number>();
+
+let framesSinceFlush = 0;
+
+declare global {
+ interface Window {
+ __renderPerf?: boolean;
+ }
+}
+
+export function isRenderPerfEnabled(): boolean {
+ return typeof window !== "undefined" && window.__renderPerf === true;
+}
+
+export function recordSpan({
+ name,
+ durationMs,
+}: {
+ name: string;
+ durationMs: number;
+}): void {
+ if (!isRenderPerfEnabled()) return;
+ let stats = spans.get(name);
+ if (!stats) {
+ stats = { samples: [] };
+ spans.set(name, stats);
+ }
+ stats.samples.push(durationMs);
+}
+
+export async function measureSpanAsync<T>({
+ name,
+ fn,
+}: {
+ name: string;
+ fn: () => Promise<T>;
+}): Promise<T> {
+ if (!isRenderPerfEnabled()) return fn();
+ const start = performance.now();
+ try {
+ return await fn();
+ } finally {
+ recordSpan({ name, durationMs: performance.now() - start });
+ }
+}
+
+export function measureSpanSync<T>({
+ name,
+ fn,
+}: {
+ name: string;
+ fn: () => T;
+}): T {
+ if (!isRenderPerfEnabled()) return fn();
+ const start = performance.now();
+ try {
+ return fn();
+ } finally {
+ recordSpan({ name, durationMs: performance.now() - start });
+ }
+}
+
+export function incrementCounter({
+ name,
+ by = 1,
+}: {
+ name: string;
+ by?: number;
+}): void {
+ if (!isRenderPerfEnabled()) return;
+ pendingCountersThisFrame.set(
+ name,
+ (pendingCountersThisFrame.get(name) ?? 0) + by,
+ );
+}
+
+/**
+ * Pulls sub-span timings recorded inside the wasm `renderFrame` call and
+ * feeds them into the aggregator as ordinary spans.
+ */
+export function recordWasmFrameProfile(
+ entries: Array<{ name: string; durationMs: number }>,
+): void {
+ if (!isRenderPerfEnabled()) return;
+ for (const entry of entries) {
+ recordSpan({ name: entry.name, durationMs: entry.durationMs });
+ }
+}
+
+/**
+ * Called once per frame by the top of the render pipeline. Rolls the
+ * pending-frame counters into the aggregate and triggers a flush on cadence.
+ */
+export function onRenderPerfFrameComplete(): void {
+ if (!isRenderPerfEnabled()) return;
+ for (const [name, count] of pendingCountersThisFrame) {
+ let stats = counters.get(name);
+ if (!stats) {
+ stats = { total: 0, frames: 0 };
+ counters.set(name, stats);
+ }
+ stats.total += count;
+ stats.frames += 1;
+ }
+ pendingCountersThisFrame.clear();
+
+ framesSinceFlush += 1;
+ if (framesSinceFlush >= FLUSH_EVERY) {
+ flush();
+ }
+}
+
+function flush(): void {
+ const spanRows: Array<Record<string, number | string>> = [];
+ for (const [name, stats] of spans) {
+ if (stats.samples.length === 0) continue;
+ const sorted = [...stats.samples].sort((a, b) => a - b);
+ const p = (q: number) =>
+ sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))];
+ const sum = sorted.reduce((acc, v) => acc + v, 0);
+ spanRows.push({
+ span: name,
+ count: sorted.length,
+ meanMs: +(sum / sorted.length).toFixed(2),
+ p50Ms: +p(0.5).toFixed(2),
+ p95Ms: +p(0.95).toFixed(2),
+ maxMs: +sorted[sorted.length - 1].toFixed(2),
+ });
+ }
+ spanRows.sort((a, b) => Number(b.meanMs) - Number(a.meanMs));
+
+ const counterRows: Array<Record<string, number | string>> = [];
+ for (const [name, stats] of counters) {
+ counterRows.push({
+ counter: name,
+ perFrame: +(stats.total / Math.max(1, stats.frames)).toFixed(2),
+ total: stats.total,
+ frames: stats.frames,
+ });
+ }
+ counterRows.sort((a, b) => Number(b.perFrame) - Number(a.perFrame));
+
+ console.groupCollapsed(
+ `[render-perf] summary over ${framesSinceFlush} frames`,
+ );
+ if (spanRows.length > 0) console.table(spanRows);
+ if (counterRows.length > 0) console.table(counterRows);
+ console.groupEnd();
+
+ spans.clear();
+ counters.clear();
+ framesSinceFlush = 0;
+}