Add render counters and a repaint-count perf harness

This commit is contained in:
Elena Torro 2026-08-17 13:55:13 +02:00
parent 18941ed8e1
commit 128c0bb86e
9 changed files with 1131 additions and 1 deletions

View File

@ -0,0 +1,248 @@
"""Compare two render-budget-perf runs.
python3 playwright/scripts/render-budget-compare.py \
perf-traces/render-budget-before.json perf-traces/render-budget-after.json
Reads the JSON written by playwright/ui/render-wasm-specs/render-budget-perf.spec.js.
Two kinds of numbers, in this order of trust:
COUNTERS are exact repaint counts read out of WASM (tiles painted, shape paints,
walker visits, paragraph builds, composited pixels...). On identical gestures
they do not drift, so any delta is a real algorithmic change. This is the section
to read when judging a render-pipeline change.
SYNC / rAF rows are `_render` wall times: SYNC ran on the input/timer path
(pointerup, debounce), rAF inside a frame callback. Useful for latency, but noisy
across machines and thermal states do not conclude anything from a <15% move.
"""
import json
import sys
KEYS = ("n", "mean", "p50", "p95", "max", "total")
# n is a count, the rest are milliseconds.
LOWER_IS_BETTER = ("mean", "p50", "p95", "max", "total")
# Counters worth printing, in reading order. Everything else in the file is
# still there for ad-hoc digging.
COUNTER_ROWS = (
("tiles_painted", "tiles actually walked + composited"),
("tiles_cache_hit", "tiles served from the texture cache"),
("tiles_invalidated", "single-tile evictions"),
("tile_cache_wipes", "whole-cache wipes"),
("tiles_discarded_inflight", "partial tiles thrown away by a restart"),
("shape_paints", "render_shape calls"),
("shape_paints_direct", "of those, straight into the tile"),
("walker_visits", "tree nodes visited"),
("walker_culled", "visits that painted nothing"),
("surface_stack_composites", "full surface-stack composites"),
("surface_stack_draw_px", "pixels moved by those composites"),
("surface_stack_clear_px", "pixels cleared after them"),
("paragraph_builds", "paragraph builder groups built"),
("text_layouts", "text build + Skia layout runs"),
("doc_atlas_writes", "Current -> DocAtlas blits"),
("tile_atlas_writes", "Current -> tile atlas blits"),
("cache_surface_writes", "Current -> legacy Cache blits (dead)"),
("tile_atlas_snapshots", "full tile-atlas snapshots"),
("tile_atlas_snapshot_px", "pixels in those snapshots"),
("render_loop_starts", "renders restarted from tile zero"),
("render_loop_continues", "renders resumed"),
("partial_yields", "budget yields"),
("frame_presents", "frames presented"),
)
# Direction of "good" per counter. Default is lower-is-better (less work).
# `higher`: more of this means work was avoided. `neutral`: the number is
# diagnostic, not a score — a move is worth looking at, not celebrating.
COUNTER_DIRECTION = {
"tiles_cache_hit": "higher",
"tile_cache_hit_ratio": "higher",
"culled_ratio": "higher",
"walker_culled": "higher",
"shape_paints_direct": "neutral",
"layered_paint_ratio": "neutral",
"empty_tile_ratio": "neutral",
"frame_presents": "neutral",
"render_loop_starts": "neutral",
"render_loop_continues": "neutral",
"partial_yields": "neutral",
"doc_atlas_writes": "neutral",
"tile_atlas_writes": "neutral",
"tile_atlas_snapshots": "neutral",
"tiles_painted_per_present": "neutral",
# Should hold steady across a change that only removes redundant work; if it
# moves, something is being skipped or repainted that was not before.
"tiles_painted": "neutral",
}
RATIO_ROWS = (
("shape_paints_per_tile", "shape paints per tile painted"),
("walker_visits_per_tile", "tree visits per tile painted"),
("culled_ratio", "share of visits that painted nothing"),
("layered_paint_ratio", "share of paints needing the surface stack"),
("composite_px_per_tile", "composite px per tile (512 tile = 262144 px)"),
("paragraph_builds_per_tile", "paragraph builds per tile"),
("text_layouts_per_tile", "text layouts per tile"),
("tile_cache_hit_ratio", "cache hits / (hits + repaints)"),
("tiles_painted_per_present", "tiles painted per presented frame"),
("empty_tile_ratio", "share of tiles with no shapes (>0.5 = bad gestures)"),
)
def load(path):
with open(path) as fh:
return json.load(fh)
def get(stat, key):
if not stat or not stat.get("n"):
return 0
return stat.get(key, 0)
def pct(before, after):
"""Signed percentage change, or None when there is no baseline."""
if before == 0:
return None
return (after - before) / before * 100
def num(v):
if isinstance(v, float) and not v.is_integer():
return f"{v:.2f}"
if abs(v) >= 1e6:
return f"{v / 1e6:.1f}M"
return f"{v:g}"
def delta(before, after, key):
b, a = get(before, key), get(after, key)
if b == 0 and a == 0:
return ""
p = pct(b, a)
if p is None:
return f" (new {a:g})"
sign = "+" if p >= 0 else ""
mark = ""
if key in LOWER_IS_BETTER and abs(p) >= 10:
mark = " better" if p < 0 else " WORSE"
return f" {sign}{p:.0f}%{mark}"
def row(label, before, after):
cells = []
for k in KEYS:
b, a = get(before, k), get(after, k)
cells.append(f"{b:>9g} -> {a:<9g}")
print(f" {label:<10}" + "".join(f"{c:<22}" for c in cells))
print(f" {'':<10}" + "".join(f"{delta(before, after, k):<22}" for k in KEYS))
def counter_table(title, rows, before, after, threshold):
"""Prints one exact-count table. `before`/`after` are flat name -> number."""
if not before and not after:
return
if before.get("error") or after.get("error"):
print(f" {title}: unavailable ({before.get('error') or after.get('error')})")
return
print(f" {title}")
for key, description in rows:
b = before.get(key, 0)
a = after.get(key, 0)
if b == 0 and a == 0:
continue
p = pct(b, a)
direction = COUNTER_DIRECTION.get(key, "lower")
if p is None:
change = f"new {num(a)}"
else:
change = f"{'+' if p >= 0 else ''}{p:.0f}%"
# Counts are exact: a small move is a real move, not noise. Only
# call it out past `threshold` so the table stays readable.
if abs(p) >= threshold:
if direction == "neutral":
change += " check"
else:
improved = p < 0 if direction == "lower" else p > 0
change += " better" if improved else " WORSE"
print(
f" {key:<28}{num(b):>12} -> {num(a):<12}{change:<16}{description}"
)
print()
def main():
if len(sys.argv) != 3:
print(__doc__)
sys.exit(2)
a, b = load(sys.argv[1]), load(sys.argv[2])
print(f"\nBEFORE {a['label']:<12} rev={a['rev']:<18} {a['date']}")
print(f"AFTER {b['label']:<12} rev={b['rev']:<18} {b['date']}")
print(f"file {a['file']}")
if a["file"] != b["file"]:
print(f" !! AFTER used a different file: {b['file']}")
if a["options"] != b["options"]:
print(f" !! gesture options differ, runs are not comparable")
print(f" before {a['options']}")
print(f" after {b['options']}")
names = list(dict.fromkeys(list(a["phases"]) + list(b["phases"])))
for name in names:
pa = a["phases"].get(name, {})
pb = b["phases"].get(name, {})
sa, sb = pa.get("summary", {}), pb.get("summary", {})
print(f"\n{'=' * 72}\n{name}\n{'=' * 72}")
# A phase where WASM raised did less work than the gestures asked for,
# so its counters understate. Say so before anyone reads a delta off it.
for tag, p in (("before", pa), ("after", pb)):
errs = p.get("wasmErrors") or []
if errs:
print(f" !! [{tag}] {len(errs)} wasm error(s): {errs[0][:120]}")
counter_table(
"COUNTS (exact)",
COUNTER_ROWS,
pa.get("counters") or {},
pb.get("counters") or {},
threshold=5,
)
counter_table(
"PER-TILE RATIOS (exact)",
RATIO_ROWS,
pa.get("ratios") or {},
pb.get("ratios") or {},
threshold=5,
)
print(" TIMES (noisy)")
header = "".join(f"{k:<22}" for k in KEYS)
print(f" {'':<10}{header}")
row("SYNC", sa.get("sync"), sb.get("sync"))
row("rAF", sa.get("raf"), sb.get("raf"))
for tag, p in (("before", sa), ("after", sb)):
w = p.get("worstSync")
if w:
print(
f" worst sync [{tag}] {w['ms']}ms flags={w['flags']} "
f"frameType={w['frame']} @{w.get('caller') or '?'}"
)
sync_a, sync_b = get(sa.get("sync"), "total"), get(sb.get("sync"), "total")
raf_a, raf_b = get(sa.get("raf"), "total"), get(sb.get("raf"), "total")
print(
f" total render work {sync_a + raf_a:.1f}ms -> {sync_b + raf_b:.1f}ms"
f" (of which off the input path: "
f"{raf_a:.1f} -> {raf_b:.1f})"
)
print()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,653 @@
import { test } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
import { execSync } from "node:child_process";
import fs from "fs";
import path from "path";
/**
* Render-budget and repaint-count capture. Not an assertion test: it drives a
* fixed set of gestures and records, per phase, how long each `_render` blocked
* (split by rAF vs the input/timer path) and the exact WASM render counters
* (render-wasm/src/render/counters.rs).
*
* Read the counters first they are exact and do not drift between runs, while
* the millisecond numbers move with GPU/driver/thermal state. `empty_tile_ratio`
* says whether the gestures were over content at all.
*
* PERF_LABEL=before npx playwright test render-budget-perf \
* --project render-wasm --workers=1
* # ...change something, and rebuild the WASM...
* PERF_LABEL=after npx playwright test render-budget-perf \
* --project render-wasm --workers=1
* python3 playwright/scripts/render-budget-compare.py \
* perf-traces/render-budget-before.json perf-traces/render-budget-after.json
*
* Without a rebuild between runs the second one silently measures the old .wasm.
* PERF_GET_FILE points at any `get-file` dump under playwright/data/, as long as
* it has no media assets: those stall on image fetches and
* `wasmSetObjectsFinished` never fires.
*/
const LABEL = process.env.PERF_LABEL ?? "run";
const GET_FILE =
process.env.PERF_GET_FILE ?? "render-wasm/get-file-shadows.json";
const PAGE_NAME = process.env.PERF_PAGE_NAME ?? "Page 1";
// `+` presses after zoom-to-fit, each exactly `min(z * 1.3, 200)`
// (data/workspace/zoom.cljs) => 5 puts the document at 3.7 viewports across.
// Not ctrl+wheel: a notch is 1.68x, `schedule-zoom!` compounds notches landing in
// the same rAF, and ~10 notches hit the 200x ceiling where the rest are no-ops.
const ZOOM_STEPS = Number(process.env.PERF_ZOOM_STEPS ?? 5);
const CYCLES = Number(process.env.PERF_CYCLES ?? 4);
const STEPS = Number(process.env.PERF_STEPS ?? 16);
const STEP_DELAY = Number(process.env.PERF_STEP_DELAY ?? 16);
const SETTLE = Number(process.env.PERF_SETTLE ?? 800);
// Pan amplitude per burst, as a fraction of the viewport: 0.4 crosses a 512px
// tile boundary while staying inside a document 3.7 viewports wide.
const PAN_TRAVEL = Number(process.env.PERF_PAN_TRAVEL ?? 0.4);
const ZOOM_NOTCHES = Number(process.env.PERF_ZOOM_NOTCHES ?? 3);
// Pinned: tile counts scale with viewport and DPR, so runs at different sizes
// are not comparable.
const VIEWPORT_W = Number(process.env.PERF_VIEWPORT_W ?? 1440);
const VIEWPORT_H = Number(process.env.PERF_VIEWPORT_H ?? 900);
const DPR = Number(process.env.PERF_DPR ?? 1);
// wheel-pan | drag-pan | zoom, comma separated. Default runs all three.
const PHASES = (process.env.PERF_PHASES ?? "wheel-pan,drag-pan,zoom")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// Headed is mandatory, not cosmetic: headless Chromium falls back to
// SwiftShader (CPU rasterizer), which makes every timing below meaningless.
// Same reasoning as zoom-perf.spec.js.
test.use({
headless: false,
viewport: { width: VIEWPORT_W, height: VIEWPORT_H },
deviceScaleFactor: DPR,
});
test.setTimeout(Number(process.env.PERF_TIMEOUT ?? 300000));
/**
* Wraps the WASM `_render` export. Must be installed as an init script, before
* any page code runs.
*
* Hooks `WebAssembly.instantiate*` rather than the CLJS-side module object
* (`app.render_wasm.wasm.internal_module`): that path only resolves in a dev
* build, and `resources/public` may hold a release bundle, where shadow mangles
* every namespace. The FFI boundary is stable in both.
*
* Depth-counting rAF rather than a boolean: `request-render` schedules through
* timers/raf and a render can request the next one from inside the callback.
*/
const renderHook = () => {
const log = (globalThis.__renderLog = []);
globalThis.__renderHookInstalled = false;
let depth = 0;
const raf = globalThis.requestAnimationFrame.bind(globalThis);
globalThis.requestAnimationFrame = (cb) =>
raf((t) => {
depth++;
try {
return cb(t);
} finally {
depth--;
}
});
const seen = new WeakMap();
const wrapExports = (exports) => {
if (!exports) return exports;
// Rust `#[no_mangle] pub extern "C" fn render` lands in the instance as
// `render`; the `_render` alias is added later by the Emscripten JS glue
// when it copies exports onto Module.
const key = ["render", "_render"].find(
(k) => typeof exports[k] === "function",
);
if (!key) return exports;
if (seen.has(exports)) return seen.get(exports);
// Keep the raw exports around so the test can read the WASM render
// counters (`perf_counter_*`). Same reason the hook lives here: this is the
// only handle on the instance that survives a release build.
globalThis.__wasmExports = exports;
const orig = exports[key];
const wrapped = function (ts, flags) {
const inRaf = depth > 0;
// Stack capture only for the calls under investigation; it is not free.
const stack = inRaf ? null : new Error().stack;
const t0 = performance.now();
const r = orig.call(this, ts, flags);
log.push({
ms: +(performance.now() - t0).toFixed(2),
flags,
raf: inRaf,
frame: r,
caller: stack
?.split("\n")
.slice(2)
.find((l) => !/wrapped|renderHook/.test(l))
?.trim()
?.replace(/^at\s+/, "")
?.replace(/^.*\/(cljs-runtime|js)\//, ""),
});
return r;
};
// A plain copy, never a Proxy: wasm exports are frozen, and a `get` trap must
// return the real value for a non-configurable data property, so proxying
// `render` throws a TypeError on every call.
const copy = Object.assign(Object.create(null), exports);
copy[key] = wrapped;
seen.set(exports, copy);
globalThis.__renderHookInstalled = true;
return copy;
};
const wrapInstance = (inst) =>
inst instanceof WebAssembly.Instance
? new Proxy(inst, {
get: (t, p, r) =>
p === "exports" ? wrapExports(t.exports) : Reflect.get(t, p, r),
})
: inst;
const streaming = WebAssembly.instantiateStreaming;
if (streaming) {
WebAssembly.instantiateStreaming = (...args) =>
streaming(...args).then((res) => ({
module: res.module,
instance: wrapInstance(res.instance),
}));
}
const instantiate = WebAssembly.instantiate;
WebAssembly.instantiate = (...args) =>
instantiate(...args).then((res) =>
res instanceof WebAssembly.Instance
? wrapInstance(res)
: { module: res.module, instance: wrapInstance(res.instance) },
);
};
const drain = (page) =>
page.evaluate(() => {
const l = globalThis.__renderLog.slice();
globalThis.__renderLog.length = 0;
return l;
});
/**
* Mirrors `render::counters::NAMES` (render-wasm/src/render/counters.rs), in
* order. `readCounters` asserts the length against `perf_counter_count()`, so a
* counter added on the Rust side without updating this list fails the run
* instead of silently shifting every label.
*
* Counts, unlike the millisecond stats above, are exact: they do not move
* between runs on the same gestures, which is what makes them the primary
* signal when comparing two builds.
*/
const COUNTER_NAMES = [
"render_loop_starts",
"render_loop_continues",
"partial_yields",
"tiles_painted",
"tiles_cache_hit",
"tiles_empty_skipped",
"tiles_invalidated",
"tile_cache_wipes",
"tiles_discarded_inflight",
"walker_visits",
"walker_culled",
"shape_paints",
"shape_paints_direct",
"surface_stack_composites",
"surface_stack_draw_px",
"surface_stack_clear_px",
"paragraph_builds",
"text_layouts",
"doc_atlas_writes",
"tile_atlas_writes",
"cache_surface_writes",
"tile_atlas_snapshots",
"tile_atlas_snapshot_px",
"frame_presents",
"crop_entries_built",
"crop_blits",
"crop_rejected",
"shape_tile_updates",
];
// Emscripten exposes the Rust symbol as-is on the instance; the `_`-prefixed
// alias only exists on the Module object, which a release build mangles out of
// reach — hence the `?? _name` fallback everywhere below.
const readCounters = (page) =>
page.evaluate((names) => {
const ex = globalThis.__wasmExports;
if (!ex) return { error: "no wasm exports captured" };
const pick = (n) => ex[n] ?? ex[`_${n}`];
const get = pick("perf_counter_get");
const count = pick("perf_counter_count");
if (typeof get !== "function" || typeof count !== "function") {
return { error: "perf_counter_* exports missing — rebuild the WASM" };
}
const n = count();
if (n !== names.length) {
return {
error:
`counter count mismatch: wasm=${n} spec=${names.length}` +
"COUNTER_NAMES is out of sync with render::counters::NAMES",
};
}
const out = {};
for (let i = 0; i < n; i++) out[names[i]] = get(i);
return out;
}, COUNTER_NAMES);
const resetCounters = (page) =>
page.evaluate(() => {
const ex = globalThis.__wasmExports;
const reset = ex?.perf_counters_reset ?? ex?._perf_counters_reset;
if (typeof reset === "function") reset();
});
/** Reads the counters for a phase and zeroes them for the next one. */
const drainCounters = async (page) => {
const counters = await readCounters(page);
await resetCounters(page);
return counters;
};
// Derived ratios: the numbers that actually answer "are we painting the same
// thing more than once". Kept out of the Rust side so they can change without
// a rebuild.
const derive = (c) => {
if (!c || c.error) return null;
const div = (a, b) => (b ? +(a / b).toFixed(2) : 0);
const tiles = c.tiles_painted;
return {
// Shape paints per tile painted. Grows with how much a shape's tile
// footprint is over-estimated (margin culling) and with per-tile root
// fan-out.
shape_paints_per_tile: div(c.shape_paints, tiles),
walker_visits_per_tile: div(c.walker_visits, tiles),
// Fraction of walked nodes that painted nothing.
culled_ratio: div(c.walker_culled, c.walker_visits),
// Shapes that needed the full 1024² surface stack instead of drawing
// straight into the tile.
layered_paint_ratio: div(
c.shape_paints - c.shape_paints_direct,
c.shape_paints,
),
// Whole-surface pixels moved per tile painted (a 512² tile is 262144 px).
composite_px_per_tile: div(
c.surface_stack_draw_px + c.surface_stack_clear_px,
tiles,
),
paragraph_builds_per_tile: div(c.paragraph_builds, tiles),
text_layouts_per_tile: div(c.text_layouts, tiles),
// Cache effectiveness: hits vs repaints, and how much was thrown away.
tile_cache_hit_ratio: div(c.tiles_cache_hit, c.tiles_cache_hit + tiles),
tiles_painted_per_present: div(tiles, c.frame_presents),
// Share of visited tiles that hold no shape at all. High means the gestures
// are running over blank canvas and the phase is not measuring anything —
// check the zoom/pan amplitudes before reading anything else.
empty_tile_ratio: div(
c.tiles_empty_skipped,
c.tiles_empty_skipped + c.tiles_cache_hit + tiles,
),
};
};
const stat = (v) => {
if (!v.length) return { n: 0 };
const s = [...v].sort((a, b) => a - b);
const p = (q) => s[Math.min(s.length - 1, Math.round(q * (s.length - 1)))];
return {
n: v.length,
mean: +(v.reduce((a, b) => a + b, 0) / v.length).toFixed(2),
p50: +p(0.5).toFixed(2),
p95: +p(0.95).toFixed(2),
max: +p(1).toFixed(2),
total: +v.reduce((a, b) => a + b, 0).toFixed(1),
};
};
const summarize = (calls) => {
const sync = calls.filter((e) => !e.raf);
const rafs = calls.filter((e) => e.raf);
const byFlag = {};
for (const e of sync) (byFlag[e.flags] ??= []).push(e.ms);
const worst = [...sync].sort((a, b) => b.ms - a.ms)[0] ?? null;
return {
sync: stat(sync.map((e) => e.ms)),
raf: stat(rafs.map((e) => e.ms)),
syncByFlag: Object.fromEntries(
Object.entries(byFlag).map(([f, v]) => [f, stat(v)]),
),
worstSync: worst
? {
ms: worst.ms,
flags: worst.flags,
frame: worst.frame,
caller: worst.caller,
}
: null,
};
};
test(`render budget perf [${LABEL}]`, async ({ page }) => {
// A `_render` that raised did no work but still logs a cheap call, deflating
// the phase. Recorded per phase rather than failing: some are pre-existing.
const wasmErrors = [];
page.on("console", (msg) => {
if (msg.type() !== "error") return;
const text = msg.text();
if (/wasm-error|wasm-critical|WASM Error/.test(text)) {
wasmErrors.push(text.slice(0, 300));
}
});
page.on("pageerror", (err) => {
if (/wasm/i.test(String(err))) wasmErrors.push(String(err).slice(0, 300));
});
const drainErrors = () => wasmErrors.splice(0, wasmErrors.length);
await page.addInitScript(renderHook);
await WasmWorkspacePage.init(page);
await WasmWorkspacePage.mockConfigFlags(page, [
"enable-feature-render-wasm",
"enable-render-wasm-dpr",
]);
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile(GET_FILE);
await workspace.goToWorkspace({ pageName: PAGE_NAME });
await workspace.waitForFirstRenderWithoutUI();
// Not waitForIdle(): requestIdleCallback never fires while the progressive
// render loop keeps the main thread busy, and the test hangs to timeout.
await page.waitForTimeout(2000);
const hooked = await page.evaluate(() => globalThis.__renderHookInstalled);
if (!hooked) {
throw new Error(
"_render was never wrapped — the renderer did not instantiate through " +
"WebAssembly.instantiate/instantiateStreaming, or the export was renamed",
);
}
// Everything up to here is the load and its first full render: every visible
// tile painted from an empty cache, no gesture, no cache hits.
const loadCounters = await drainCounters(page);
if (loadCounters.error) {
throw new Error(`render counters unavailable: ${loadCounters.error}`);
}
const loadCalls = await drain(page);
const loadErrors = drainErrors();
const firstError = loadErrors.length ? `; first error: ${loadErrors[0]}` : "";
if (loadCalls.length === 0) {
throw new Error(
`no _render calls recorded during load — the hook is not intercepting ` +
`the renderer${firstError}`,
);
}
if (loadCounters.tiles_painted === 0) {
throw new Error(
"load phase painted no tiles — _render ran but the document has no " +
`shapes in any tile (fixture/page mismatch?)${firstError}`,
);
}
const box = await workspace.canvas.boundingBox();
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
await page.mouse.move(cx, cy);
// Same viewbox every run: fit the document, then zoom in a known factor
// toward the canvas centre (`increase-zoom` centres on the mouse, which is
// parked there). Content therefore surrounds the viewport on all sides and
// every gesture below stays over shapes.
await page.keyboard.press("Shift+1");
await page.waitForTimeout(1200);
// "=" rather than "+": both are bound to :increase-zoom (shortcuts.cljs), and
// "=" needs no shift modifier for mousetrap to match.
for (let i = 0; i < ZOOM_STEPS; i++) {
await page.keyboard.press("=");
await page.waitForTimeout(120);
}
await page.waitForTimeout(1500);
await drain(page); // discard zoom-in setup
await resetCounters(page);
// A plain wheel delta pans the vbox by `delta / zoom` doc units
// (`schedule-scroll!`), i.e. by `delta` screen pixels whatever the zoom. So
// amplitudes are expressed in screen pixels, derived from the viewport.
const panX = Math.round((box.width * PAN_TRAVEL) / STEPS);
const panY = Math.round((box.height * PAN_TRAVEL) / STEPS);
const wheelBurst = async (dx, dy) => {
for (let i = 0; i < STEPS; i++) {
await page.mouse.wheel(dx, dy);
await page.waitForTimeout(STEP_DELAY);
}
await page.waitForTimeout(SETTLE);
};
const runners = {
// Plain wheel pan, ending via the debounced `render-finish`. Zoom is stable,
// so `allow_stop` is false and the progressive budget never applies.
// Down/right/up/left closes the cycle, so it cannot drift off the document.
"wheel-pan": async () => {
for (let c = 0; c < CYCLES; c++) {
await wheelBurst(0, panY);
await wheelBurst(panX, 0);
await wheelBurst(0, -panY);
await wheelBurst(-panX, 0);
}
},
// Space-drag pan. Ends on pointerup via `finish-panning` ->
// maybe-view-interaction-end!, i.e. straight on the input path.
// Drags out and returns, for the same reason as the wheel cycle.
"drag-pan": async () => {
const moves = 12;
const dx = (box.width * PAN_TRAVEL) / moves;
const dy = (box.height * PAN_TRAVEL) / moves;
for (let c = 0; c < CYCLES; c++) {
await page.keyboard.down("Space");
await page.mouse.move(cx, cy);
await page.mouse.down();
for (let i = 1; i <= moves; i++) {
await page.mouse.move(
Math.round(cx - i * dx),
Math.round(cy - i * dy),
);
await page.waitForTimeout(STEP_DELAY);
}
for (let i = moves - 1; i >= 0; i--) {
await page.mouse.move(
Math.round(cx - i * dx),
Math.round(cy - i * dy),
);
await page.waitForTimeout(STEP_DELAY);
}
await page.mouse.up();
await page.keyboard.up("Space");
await page.waitForTimeout(SETTLE);
}
},
// Ctrl+wheel zoom: `zoom_changed()` makes `allow_stop` true, so this is the
// phase that exercises the progressive budget. In then out, keeping the
// working zoom as the floor so it never zooms out into empty canvas.
zoom: async () => {
const ramp = async (delta) => {
await page.keyboard.down("Control");
for (let i = 0; i < ZOOM_NOTCHES; i++) {
await page.mouse.wheel(0, delta);
await page.waitForTimeout(STEP_DELAY);
}
await page.keyboard.up("Control");
await page.waitForTimeout(SETTLE);
};
for (let c = 0; c < CYCLES; c++) {
await ramp(-120); // in
await ramp(120); // out, back to the working zoom
}
},
};
const phases = {
load: {
calls: loadCalls,
summary: summarize(loadCalls),
counters: loadCounters,
ratios: derive(loadCounters),
wasmErrors: loadErrors,
},
};
let total = loadCalls.length;
for (const name of PHASES) {
const run = runners[name];
if (!run) throw new Error(`unknown phase "${name}"`);
await run();
const calls = await drain(page);
const counters = await drainCounters(page);
total += calls.length;
phases[name] = {
calls,
summary: summarize(calls),
counters,
ratios: derive(counters),
wasmErrors: drainErrors(),
};
}
// A run that recorded nothing is not a passing run — it means the gestures
// never reached the renderer (wrong branch built, render-wasm flag off, or
// the workspace fell back to the SVG viewport). Fail loudly rather than
// writing an all-zero file that looks like a legitimate comparison baseline.
if (total === 0) {
throw new Error(
"no _render calls recorded across any phase — the build under test is " +
"probably not rendering through render-wasm",
);
}
let rev = "unknown";
try {
rev = execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim();
const dirty = execSync("git status --porcelain", {
encoding: "utf-8",
}).trim();
if (dirty) rev += "-dirty";
} catch {}
// Not test-results/: Playwright wipes outputDir at the start of every run,
// which would delete the first run's data before the second one lands.
const outDir = path.resolve("perf-traces");
fs.mkdirSync(outDir, { recursive: true });
const out = path.join(outDir, `render-budget-${LABEL}.json`);
fs.writeFileSync(
out,
JSON.stringify(
{
label: LABEL,
rev,
date: new Date().toISOString(),
file: GET_FILE,
options: {
ZOOM_STEPS,
ZOOM_NOTCHES,
CYCLES,
STEPS,
STEP_DELAY,
SETTLE,
PAN_TRAVEL,
// Tile counts scale with the viewport, so two runs are only
// comparable at the same size and DPR. The compare script refuses to
// read across a mismatch.
VIEWPORT: `${VIEWPORT_W}x${VIEWPORT_H}`,
DPR,
},
counterNames: COUNTER_NAMES,
phases,
},
null,
2,
),
);
const fmt = (s) =>
s.n
? `n=${s.n} mean=${s.mean}ms p50=${s.p50} p95=${s.p95} max=${s.max} total=${s.total}`
: "n=0";
console.log(
`\n[PERF ${LABEL}] rev=${rev} file=${GET_FILE.split("/").pop()} ` +
`viewport=${VIEWPORT_W}x${VIEWPORT_H}@${DPR}x`,
);
for (const [
name,
{ summary, counters, ratios, wasmErrors: errs },
] of Object.entries(phases)) {
console.log(`[PERF ${LABEL}] --- ${name}`);
if (errs?.length) {
console.log(
`[PERF ${LABEL}] !! ${errs.length} wasm error(s) in this phase, ` +
`counters are understated: ${errs[0]}`,
);
}
console.log(
`[PERF ${LABEL}] SYNC (blocks main thread) ${fmt(summary.sync)}`,
);
console.log(
`[PERF ${LABEL}] rAF ${fmt(summary.raf)}`,
);
if (summary.worstSync) {
const w = summary.worstSync;
console.log(
`[PERF ${LABEL}] worst sync ${w.ms}ms flags=${w.flags} ` +
`frameType=${w.frame} @${w.caller ?? "?"}`,
);
}
if (counters && !counters.error) {
console.log(
`[PERF ${LABEL}] tiles painted=${counters.tiles_painted} ` +
`cached=${counters.tiles_cache_hit} ` +
`invalidated=${counters.tiles_invalidated} ` +
`wipes=${counters.tile_cache_wipes} ` +
`discarded=${counters.tiles_discarded_inflight}`,
);
console.log(
`[PERF ${LABEL}] shape paints=${counters.shape_paints} ` +
`(${counters.shape_paints_direct} direct) ` +
`walker visits=${counters.walker_visits} ` +
`culled=${counters.walker_culled}`,
);
console.log(
`[PERF ${LABEL}] text builds=${counters.paragraph_builds} ` +
`layouts=${counters.text_layouts} | ` +
`composite px=${(counters.surface_stack_draw_px / 1e6).toFixed(1)}M ` +
`clear px=${(counters.surface_stack_clear_px / 1e6).toFixed(1)}M`,
);
console.log(
`[PERF ${LABEL}] per tile: shapes=${ratios.shape_paints_per_tile} ` +
`visits=${ratios.walker_visits_per_tile} ` +
`composite=${(ratios.composite_px_per_tile / 1e6).toFixed(2)}M px ` +
`| layered=${(ratios.layered_paint_ratio * 100).toFixed(0)}% ` +
`cache hit=${(ratios.tile_cache_hit_ratio * 100).toFixed(0)}% ` +
`empty=${(ratios.empty_tile_ratio * 100).toFixed(0)}%`,
);
if (ratios.empty_tile_ratio > 0.5) {
console.log(
`[PERF ${LABEL}] !! over half the tiles in this phase are empty — ` +
"the gestures are mostly over blank canvas, lower PERF_ZOOM_STEPS " +
"or PERF_PAN_TRAVEL, or use a denser fixture",
);
}
}
}
console.log(`[PERF ${LABEL}] wrote ${out}`);
});

View File

@ -154,6 +154,61 @@
(wasm.h/call module "_render_stats")
(js/console.warn "[debug] render-wasm module not ready or missing _render_stats"))))
;; Mirrors `render::counters::NAMES` by index (render-wasm/src/render/counters.rs).
(def ^:private wasm-perf-counter-names
["render_loop_starts"
"render_loop_continues"
"partial_yields"
"tiles_painted"
"tiles_cache_hit"
"tiles_empty_skipped"
"tiles_invalidated"
"tile_cache_wipes"
"tiles_discarded_inflight"
"walker_visits"
"walker_culled"
"shape_paints"
"shape_paints_direct"
"surface_stack_composites"
"surface_stack_draw_px"
"surface_stack_clear_px"
"paragraph_builds"
"text_layouts"
"doc_atlas_writes"
"tile_atlas_writes"
"cache_surface_writes"
"tile_atlas_snapshots"
"tile_atlas_snapshot_px"
"frame_presents"
"crop_entries_built"
"crop_blits"
"crop_rejected"
"shape_tile_updates"])
(defn ^:export wasmPerfCounters
"Snapshot of the render counters. Call `wasmPerfCountersReset` first to scope
them to one interaction."
[]
(let [module wasm/internal-module
f (when module (unchecked-get module "_perf_counter_get"))]
(if (fn? f)
(let [total (wasm.h/call module "_perf_counter_count")
result #js {}]
(dotimes [i total]
(unchecked-set result
(nth wasm-perf-counter-names i (str "counter_" i))
(wasm.h/call module "_perf_counter_get" i)))
result)
(js/console.warn "[debug] render-wasm module not ready or missing _perf_counter_get"))))
(defn ^:export wasmPerfCountersReset
[]
(let [module wasm/internal-module
f (when module (unchecked-get module "_perf_counters_reset"))]
(if (fn? f)
(wasm.h/call module "_perf_counters_reset")
(js/console.warn "[debug] render-wasm module not ready or missing _perf_counters_reset"))))
(defn ^:export wasmAtlasConsole
"Logs the current render-wasm atlas as an image in the JS console (if present)."
[]

View File

@ -1016,6 +1016,21 @@ pub extern "C" fn render_stats() {
get_render_state().print_stats();
}
#[no_mangle]
pub extern "C" fn perf_counter_count() -> u32 {
render::counters::COUNTER_COUNT as u32
}
#[no_mangle]
pub extern "C" fn perf_counter_get(index: u32) -> f64 {
render::counters::get(index as usize)
}
#[no_mangle]
pub extern "C" fn perf_counters_reset() {
render::counters::reset();
}
#[no_mangle]
pub fn free_gpu_resources() {
get_render_state().free_gpu_resources();

View File

@ -1,3 +1,4 @@
pub mod counters;
mod debug;
mod fills;
pub mod filters;
@ -22,6 +23,7 @@ use skia_safe::{self as skia, Matrix, RRect, Rect};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use counters::Counter;
use options::RenderOptions;
pub use surfaces::{SurfaceId, Surfaces};
@ -36,7 +38,7 @@ use crate::tiles::{self, PendingTiles, TileRect};
use crate::uuid::Uuid;
use crate::view::Viewbox;
use crate::wapi;
use crate::{get_gpu_state, get_resources, performance};
use crate::{count, get_gpu_state, get_resources, performance};
pub use fonts::*;
pub use images::*;
@ -956,6 +958,7 @@ impl RenderState {
/// on top of Target, then present. Backbuffer is left clean so it can be reused
/// as-is across interactive-transform frames without stale overlay pixels.
pub fn present_frame(&mut self, tree: ShapesPoolRef) {
count!(Counter::FramePresents);
self.compose_frame(tree);
self.surfaces.flush_and_submit(SurfaceId::Target);
}
@ -1122,15 +1125,23 @@ impl RenderState {
pub fn draw_shape_surface_stack_into(&mut self, shape: Option<&Shape>, target: SurfaceId) {
performance::begin_measure!("apply_drawing_to_render_canvas");
count!(Counter::SurfaceStackComposites);
let surface_px = {
let (w, h) = self.surfaces.surface_size(SurfaceId::Fills);
(w as f64) * (h as f64)
};
let paint = skia::Paint::default();
// Only draw surfaces that have content (dirty flag optimization)
if self.surfaces.is_dirty(SurfaceId::TextDropShadows) {
count!(Counter::SurfaceStackDrawPx, surface_px);
self.surfaces
.draw_into(SurfaceId::TextDropShadows, target, Some(&paint));
}
if self.surfaces.is_dirty(SurfaceId::Fills) {
count!(Counter::SurfaceStackDrawPx, surface_px);
self.surfaces
.draw_into(SurfaceId::Fills, target, Some(&paint));
}
@ -1141,16 +1152,19 @@ impl RenderState {
}
if render_overlay_below_strokes && self.surfaces.is_dirty(SurfaceId::InnerShadows) {
count!(Counter::SurfaceStackDrawPx, surface_px);
self.surfaces
.draw_into(SurfaceId::InnerShadows, target, Some(&paint));
}
if self.surfaces.is_dirty(SurfaceId::Strokes) {
count!(Counter::SurfaceStackDrawPx, surface_px);
self.surfaces
.draw_into(SurfaceId::Strokes, target, Some(&paint));
}
if !render_overlay_below_strokes && self.surfaces.is_dirty(SurfaceId::InnerShadows) {
count!(Counter::SurfaceStackDrawPx, surface_px);
self.surfaces
.draw_into(SurfaceId::InnerShadows, target, Some(&paint));
}
@ -1171,6 +1185,10 @@ impl RenderState {
}
if dirty_surfaces_to_clear != 0 {
count!(
Counter::SurfaceStackClearPx,
surface_px * dirty_surfaces_to_clear.count_ones() as f64
);
self.surfaces.apply_mut(dirty_surfaces_to_clear, |s| {
s.canvas().clear(skia::Color::TRANSPARENT);
});
@ -1339,6 +1357,8 @@ impl RenderState {
#[cfg(feature = "stats")]
self.stats.count(shape.id);
count!(Counter::ShapePaints);
let surface_ids = fills_surface_id as u32
| strokes_surface_id as u32
| innershadows_surface_id as u32
@ -1413,6 +1433,7 @@ impl RenderState {
&& target_surface != SurfaceId::Export;
if can_render_directly {
count!(Counter::ShapePaintsDirect);
let translation = self
.surfaces
.get_render_context_translation(self.render_area, scale);
@ -2230,6 +2251,7 @@ impl RenderState {
img
};
count!(Counter::CropEntriesBuilt);
self.backbuffer_crop_cache.insert(
id,
InteractiveDragCrop {
@ -2294,6 +2316,7 @@ impl RenderState {
self.surfaces.gc();
if self.current_tile.is_some() && !self.pending_nodes.is_empty() {
count!(Counter::TilesDiscardedInflight);
}
self.pending_nodes.clear();
@ -2319,6 +2342,7 @@ impl RenderState {
timestamp: i32,
sync_render: bool,
) -> Result<FrameType> {
count!(Counter::RenderLoopStarts);
self.clear(tree);
let _start = performance::begin_timed_log!("start_render_loop");
@ -2483,6 +2507,7 @@ impl RenderState {
timestamp: i32,
allow_stop: bool,
) -> Result<FrameType> {
count!(Counter::RenderLoopContinues);
performance::begin_measure!("continue_render_loop");
let timestamp = self.render_budget_start(timestamp);
let frame_type =
@ -3501,6 +3526,7 @@ impl RenderState {
};
while let Some(node_render_state) = self.pending_nodes.pop() {
count!(Counter::WalkerVisits);
let node_id = node_render_state.id;
let visited_children = node_render_state.visited_children;
let visited_mask = node_render_state.visited_mask;
@ -3593,6 +3619,7 @@ impl RenderState {
}
if !is_visible {
count!(Counter::WalkerCulled);
continue;
}
}
@ -3615,10 +3642,12 @@ impl RenderState {
);
if !use_cached && self.backbuffer_crop_cache.contains_key(&node_id) {
count!(Counter::CropRejected);
}
if use_cached {
if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) {
count!(Counter::CropBlits);
let crop_image = &crop.image;
let crop_src_selrect = crop.src_selrect;
@ -3909,6 +3938,7 @@ impl RenderState {
}
if early_return {
count!(Counter::PartialYields);
self.viewer_render_root = None;
return Ok(FrameType::Partial);
}
@ -3920,6 +3950,7 @@ impl RenderState {
// (`current_tile_had_shapes` was set when we populated pending_nodes
// for this tile).
if !is_empty || self.current_tile_had_shapes {
count!(Counter::TilesPainted);
if self.options.is_interactive_transform() {
// During drag, avoid snapshot-based caching. Draw Current directly
// into Target (and Cache) to reduce stalls.
@ -3966,6 +3997,7 @@ impl RenderState {
let Some(ids) = self.tiles.get_shapes_at(next_tile) else {
// If the tile is empty we do not need to render it.
count!(Counter::TilesEmptySkipped);
continue;
};
@ -3973,6 +4005,7 @@ impl RenderState {
if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) {
// If the tile is cached, then we do not need to
// render it.
count!(Counter::TilesCacheHit);
continue;
}
@ -4093,6 +4126,7 @@ impl RenderState {
shape: &Shape,
tree: ShapesPoolRef,
) -> HashSet<tiles::Tile> {
count!(Counter::ShapeTileUpdates);
let tile_rect = self.get_tiles_for_shape(shape, tree);
// Collect old tiles to avoid borrow conflict with remove_shape_at

View File

@ -0,0 +1,105 @@
//! Exact render counters for before/after comparisons. Read from the browser
//! through the `perf_counter_*` exports in `main.rs`; [`NAMES`] is mirrored by
//! index in `playwright/ui/render-wasm-specs/render-budget-perf.spec.js`.
#[repr(usize)]
#[derive(Copy, Clone)]
#[allow(dead_code)]
pub enum Counter {
RenderLoopStarts = 0,
RenderLoopContinues,
PartialYields,
TilesPainted,
TilesCacheHit,
TilesEmptySkipped,
TilesInvalidated,
TileCacheWipes,
TilesDiscardedInflight,
WalkerVisits,
WalkerCulled,
ShapePaints,
ShapePaintsDirect,
SurfaceStackComposites,
SurfaceStackDrawPx,
SurfaceStackClearPx,
ParagraphBuilds,
TextLayouts,
DocAtlasWrites,
TileAtlasWrites,
/// Unused since the Cache blit was removed; kept so indices stay stable.
CacheSurfaceWrites,
TileAtlasSnapshots,
TileAtlasSnapshotPx,
FramePresents,
CropEntriesBuilt,
CropBlits,
CropRejected,
ShapeTileUpdates,
}
pub const COUNTER_COUNT: usize = 28;
pub const NAMES: [&str; COUNTER_COUNT] = [
"render_loop_starts",
"render_loop_continues",
"partial_yields",
"tiles_painted",
"tiles_cache_hit",
"tiles_empty_skipped",
"tiles_invalidated",
"tile_cache_wipes",
"tiles_discarded_inflight",
"walker_visits",
"walker_culled",
"shape_paints",
"shape_paints_direct",
"surface_stack_composites",
"surface_stack_draw_px",
"surface_stack_clear_px",
"paragraph_builds",
"text_layouts",
"doc_atlas_writes",
"tile_atlas_writes",
"cache_surface_writes",
"tile_atlas_snapshots",
"tile_atlas_snapshot_px",
"frame_presents",
"crop_entries_built",
"crop_blits",
"crop_rejected",
"shape_tile_updates",
];
static mut COUNTERS: [f64; COUNTER_COUNT] = [0.0; COUNTER_COUNT];
/// `f64` so the JS side reads plain numbers; exact past any count we reach.
#[inline(always)]
pub fn add(counter: Counter, n: f64) {
unsafe {
COUNTERS[counter as usize] += n;
}
}
#[inline(always)]
pub fn get(index: usize) -> f64 {
if index >= COUNTER_COUNT {
return 0.0;
}
unsafe { COUNTERS[index] }
}
pub fn reset() {
unsafe {
COUNTERS = [0.0; COUNTER_COUNT];
}
}
#[macro_export]
macro_rules! count {
($counter:expr) => {
$crate::render::counters::add($counter, 1.0)
};
($counter:expr, $n:expr) => {
$crate::render::counters::add($counter, $n as f64)
};
}

View File

@ -5,7 +5,9 @@ use crate::{get_gpu_state, performance};
use skia_safe::{self as skia, IRect, Paint, RRect, Rect};
use super::counters::Counter;
use super::{gpu_state::GpuState, tiles, tiles::Tile, tiles::TileRect, tiles::TileViewbox};
use crate::count;
use crate::math::Point;
use base64::{engine::general_purpose, Engine as _};
@ -552,6 +554,11 @@ impl Surfaces {
) {
self.tiles.update(viewbox, tile_viewbox);
if self.tiles.needs_snapshot() || self.tile_atlas_image.is_none() {
count!(Counter::TileAtlasSnapshots);
count!(
Counter::TileAtlasSnapshotPx,
(self.tile_atlas.width() as f64) * (self.tile_atlas.height() as f64)
);
self.tile_atlas_image = Some(self.tile_atlas.image_snapshot());
self.tiles.snapshot();
}
@ -1215,6 +1222,7 @@ impl Surfaces {
let sampling = self.sampling_options;
// DocAtlas + tile atlas via Surface::draw (no image_snapshot sync).
count!(Counter::DocAtlasWrites);
let _ = self.atlas.blit_current_drawable_into_atlas(
gpu_state,
&mut self.current,
@ -1224,6 +1232,7 @@ impl Surfaces {
);
self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect);
count!(Counter::TileAtlasWrites);
let tile_ref = self.tiles.add(tile_viewbox, tile);
let dst = tile_ref.rect;
let mut current = self.current.clone();
@ -1343,6 +1352,7 @@ impl Surfaces {
}
pub fn remove_cached_tile_surface(&mut self, tile: Tile) {
count!(Counter::TilesInvalidated);
let gpu_state = get_gpu_state();
// Mark tile as invalid
// Old content stays visible until new tile overwrites it atomically,
@ -1409,6 +1419,7 @@ impl Surfaces {
/// Used by `rebuild_tiles` (full rebuild). For shallow rebuilds that preserve
/// the cache canvas for scaled previews, use `invalidate_tile_cache` instead.
pub fn remove_cached_tiles(&mut self, color: skia::Color) {
count!(Counter::TileCacheWipes);
self.tiles.clear();
self.atlas.tile_doc_rects.clear();
self.cache.canvas().clear(color);
@ -1419,6 +1430,7 @@ impl Surfaces {
/// so that `render_from_cache` can still show a scaled preview of the old
/// content while new tiles are being rendered.
pub fn invalidate_tile_cache(&mut self) {
count!(Counter::TileCacheWipes);
self.tiles.clear();
self.atlas.tile_doc_rects.clear();
self.tile_atlas_image = None;

View File

@ -1,5 +1,7 @@
use super::counters::Counter;
use super::{filters, RenderState, Shape, SurfaceId, DEFAULT_EMOJI_FONT};
use crate::{
count,
error::Result,
math::Rect,
shapes::{
@ -21,6 +23,7 @@ pub fn stroke_paragraph_builder_group_from_text(
bounds: &Rect,
use_shadow: Option<bool>,
) -> (Vec<ParagraphBuilderGroup>, Option<f32>) {
count!(Counter::ParagraphBuilds);
let fallback_fonts = get_fallback_fonts();
let fonts = get_font_collection();
let mut paragraph_group = Vec::new();

View File

@ -1,5 +1,7 @@
use crate::render::counters::Counter;
use crate::render::text::calculate_decoration_metrics;
use crate::{
count,
math::{Bounds, Matrix, Rect},
render::{default_font, DEFAULT_EMOJI_FONT},
utils::Browser,
@ -707,6 +709,7 @@ impl TextContent {
&self,
use_shadow: Option<bool>,
) -> Vec<ParagraphBuilderGroup> {
count!(Counter::ParagraphBuilds);
let fonts = get_font_collection();
let fallback_fonts = get_fallback_fonts();
let mut paragraph_group = Vec::new();
@ -742,6 +745,7 @@ impl TextContent {
/// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255).
/// Used as a clip mask for inner stroke rendering.
pub fn paragraph_builder_group_opaque(&self) -> Vec<ParagraphBuilderGroup> {
count!(Counter::ParagraphBuilds);
let fonts = get_font_collection();
let fallback_fonts = get_fallback_fonts();
let mut paragraph_group = Vec::new();
@ -1487,6 +1491,7 @@ pub fn calculate_text_layout_data(
paragraph_builder_groups: &mut [ParagraphBuilderGroup],
skip_position_data: bool,
) -> TextLayoutData {
count!(Counter::TextLayouts);
let selrect_width = shape.selrect().width();
let text_width = text_content.get_width(selrect_width);
let selrect_height = shape.selrect().height();