mirror of
https://github.com/penpot/penpot.git
synced 2026-08-28 23:59:00 +00:00
✨ Add zoom perf trace capture and its comparison script
This commit is contained in:
parent
128c0bb86e
commit
30bfc4b8c0
3
frontend/.gitignore
vendored
Normal file
3
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
# render-wasm zoom perf captures (40MB+ each)
|
||||
perf-traces/
|
||||
64
frontend/playwright/scripts/trace-compare.py
Normal file
64
frontend/playwright/scripts/trace-compare.py
Normal file
@ -0,0 +1,64 @@
|
||||
import json, sys, statistics as st
|
||||
|
||||
|
||||
def pct(v, p):
|
||||
v = sorted(v)
|
||||
return v[min(len(v) - 1, int(round((p / 100.0) * (len(v) - 1))))]
|
||||
|
||||
|
||||
WANT = {"FireAnimationFrame", "GPUTask", "PageAnimator::serviceScriptedAnimations"}
|
||||
|
||||
|
||||
def load(path):
|
||||
threads, ev, marks, dropped = {}, [], [], []
|
||||
with open(path) as fh:
|
||||
for line in fh:
|
||||
if '"cat"' not in line:
|
||||
continue
|
||||
s = line.strip().rstrip(",")
|
||||
if not s.startswith("{"):
|
||||
continue
|
||||
if '"thread_name"' in s:
|
||||
e = json.loads(s)
|
||||
threads[(e["pid"], e["tid"])] = e["args"]["name"]
|
||||
elif '"name":"DroppedFrame"' in s:
|
||||
try:
|
||||
dropped.append(json.loads(s)["ts"])
|
||||
except Exception:
|
||||
pass
|
||||
elif '"ph":"X"' in s and '"dur"' in s:
|
||||
if not any(w in s for w in
|
||||
('"FireAnimationFrame"', '"GPUTask"',
|
||||
'"serviceScriptedAnimations"')):
|
||||
continue
|
||||
e = json.loads(s)
|
||||
if e["name"] in WANT:
|
||||
ev.append((e["ts"], e["dur"], e["name"]))
|
||||
elif '"name":"set-view-box"' in s and '"ph":"b"' in s:
|
||||
marks.append(json.loads(s)["ts"])
|
||||
return ev, marks, dropped
|
||||
|
||||
|
||||
def report(tag, path):
|
||||
ev, marks, dropped = load(path)
|
||||
marks.sort()
|
||||
lo, hi = marks[0], marks[-1]
|
||||
span = (hi - lo) / 1e6
|
||||
nd = sum(1 for t in dropped if lo <= t <= hi)
|
||||
print(f"\n{'=' * 60}\n{tag}\n{'=' * 60}")
|
||||
print(f"window {span:.2f}s zooms {len(marks)} ({len(marks)/span:.1f}/s)"
|
||||
f" dropped frames {nd} ({nd/span:.1f}/s)")
|
||||
print(f"\n{'event':<38}{'n':>6}{'mean':>8}{'p50':>7}"
|
||||
f"{'p95':>8}{'p99':>8}{'max':>9}")
|
||||
for name in sorted(WANT):
|
||||
d = [dur for (ts, dur, nm) in ev if nm == name and lo <= ts <= hi]
|
||||
if not d:
|
||||
continue
|
||||
print(f"{name:<38}{len(d):>6}{st.mean(d)/1000:>8.2f}"
|
||||
f"{pct(d,50)/1000:>7.2f}{pct(d,95)/1000:>8.2f}"
|
||||
f"{pct(d,99)/1000:>8.2f}{max(d)/1000:>9.2f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for arg in sys.argv[1:]:
|
||||
report(arg.split("/")[-1].replace(".json", ""), arg)
|
||||
189
frontend/playwright/ui/render-wasm-specs/zoom-perf.spec.js
Normal file
189
frontend/playwright/ui/render-wasm-specs/zoom-perf.spec.js
Normal file
@ -0,0 +1,189 @@
|
||||
import { test } from "@playwright/test";
|
||||
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Zoom performance capture. Not an assertion test — it drives a fixed zoom
|
||||
* sequence and writes a Chrome trace, so two builds can be compared under an
|
||||
* identical interaction.
|
||||
*
|
||||
* Run one build, stash/checkout the other, run again, then diff the traces:
|
||||
*
|
||||
* PERF_LABEL=branch npx playwright test zoom-perf --project render-wasm --workers=1
|
||||
* PERF_LABEL=develop npx playwright test zoom-perf --project render-wasm --workers=1
|
||||
* python3 playwright/scripts/trace-compare.py \
|
||||
* perf-traces/zoom-perf-branch.json perf-traces/zoom-perf-develop.json
|
||||
*
|
||||
* The bundled fixtures are small (<150 shapes, most with shadows) and will not
|
||||
* reproduce the dense-tile case that `max_per_tile` flagged. For the decisive
|
||||
* comparison, save the `get-file` response of the real document under
|
||||
* playwright/data/ and point PERF_GET_FILE at it (PERF_PAGE_NAME too, if its
|
||||
* first page is not called "Page 1").
|
||||
*/
|
||||
|
||||
const LABEL = process.env.PERF_LABEL ?? "run";
|
||||
const CYCLES = Number(process.env.PERF_CYCLES ?? 6);
|
||||
const STEPS = Number(process.env.PERF_STEPS ?? 20);
|
||||
const STEP_DELAY = Number(process.env.PERF_STEP_DELAY ?? 16);
|
||||
const SETTLE = Number(process.env.PERF_SETTLE ?? 600);
|
||||
const TRACE = process.env.PERF_TRACE !== "0";
|
||||
// zoom | pan | both. Penpot zooms on ctrl+wheel and pans on plain wheel
|
||||
// (viewport/actions.cljs: `if (or ctrl? mod?) schedule-zoom! else
|
||||
// schedule-scroll!`), so both gestures reuse the same wheel mechanism.
|
||||
const MODE = process.env.PERF_MODE ?? "both";
|
||||
const PAN_DELTA = Number(process.env.PERF_PAN_DELTA ?? 200);
|
||||
// Zoom in before panning: at zoom-to-fit the whole document is on screen and
|
||||
// panning just scrolls into empty space without crossing populated tiles.
|
||||
const PAN_ZOOM_IN = Number(process.env.PERF_PAN_ZOOM_IN ?? 12);
|
||||
const TIMEOUT = Number(process.env.PERF_TIMEOUT ?? 180000);
|
||||
|
||||
// File and page ids are read out of the payload by mockGetFile, so pointing
|
||||
// PERF_GET_FILE at a different dump is the only change needed.
|
||||
// Must be a fixture with no media assets: anything needing mockFileMediaAsset
|
||||
// stalls on image fetches and `wasmSetObjectsFinished` never fires.
|
||||
const GET_FILE =
|
||||
process.env.PERF_GET_FILE ?? "render-wasm/get-file-shapes-groups-boards.json";
|
||||
const PAGE_NAME = process.env.PERF_PAGE_NAME ?? "Page 1";
|
||||
|
||||
// Excludes v8.inspector and v8.cpu_profiler on purpose: in the hand-captured
|
||||
// traces those accounted for ~400k events and inflated main-thread cost on
|
||||
// both sides.
|
||||
const CATEGORIES = [
|
||||
"__metadata",
|
||||
"devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline.frame",
|
||||
"blink.user_timing",
|
||||
"benchmark",
|
||||
"cc",
|
||||
];
|
||||
|
||||
// Headed is mandatory, not cosmetic: headless Chromium falls back to
|
||||
// SwiftShader (CPU rasterizer), which makes every GPU measurement useless.
|
||||
// Verified renderer strings on this machine:
|
||||
// headless -> ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device ...))
|
||||
// headed -> ANGLE (Intel, Mesa Intel(R) Arc(tm) Graphics (MTL), ...)
|
||||
test.use({ headless: false });
|
||||
|
||||
test.setTimeout(TIMEOUT);
|
||||
|
||||
test(`zoom perf capture [${LABEL}]`, async ({ page }) => {
|
||||
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 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);
|
||||
|
||||
// Zoom to fit so every run starts from the same viewbox.
|
||||
await page.keyboard.press("Shift+1");
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Not test-results/: Playwright wipes outputDir at the start of every run,
|
||||
// which would delete the first build's trace before the second one lands.
|
||||
const outDir = path.resolve("perf-traces");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const out = path.join(outDir, `zoom-perf-${LABEL}.json`);
|
||||
|
||||
// browser.startTracing, not a raw CDPSession: Tracing is a browser-level
|
||||
// domain, and driving it from a page session deadlocks input — the
|
||||
// dataCollected flood blocks the same connection Playwright sends keys on.
|
||||
const browser = page.context().browser();
|
||||
if (TRACE) {
|
||||
await browser.startTracing(page, {
|
||||
path: out,
|
||||
categories: CATEGORIES,
|
||||
screenshots: false,
|
||||
});
|
||||
}
|
||||
|
||||
const burst = 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); // let the full-quality pass finish
|
||||
};
|
||||
|
||||
if (MODE === "zoom" || MODE === "both") {
|
||||
// Zoom OUT first, then back in. Starting from zoom-to-fit and zooming in
|
||||
// never goes below fit scale, so scale-dependent level-of-detail paths
|
||||
// (imperceptible shadows/strokes) would never activate and the run would
|
||||
// measure nothing. Going out first spends half the phase at low scale.
|
||||
await page.keyboard.down("Control");
|
||||
for (let c = 0; c < CYCLES; c++) {
|
||||
await burst(0, 120); // zoom out, below fit
|
||||
await burst(0, -120); // zoom back in
|
||||
}
|
||||
await page.keyboard.up("Control");
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
if (MODE === "pan" || MODE === "both") {
|
||||
await page.keyboard.down("Control");
|
||||
for (let i = 0; i < PAN_ZOOM_IN; i++) {
|
||||
await page.mouse.wheel(0, -120);
|
||||
await page.waitForTimeout(STEP_DELAY);
|
||||
}
|
||||
await page.keyboard.up("Control");
|
||||
await page.waitForTimeout(SETTLE);
|
||||
|
||||
for (let c = 0; c < CYCLES; c++) {
|
||||
await burst(0, PAN_DELTA); // down
|
||||
await burst(PAN_DELTA, 0); // right
|
||||
await burst(0, -PAN_DELTA); // up
|
||||
await burst(-PAN_DELTA, 0); // left
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
let events = [];
|
||||
if (TRACE) {
|
||||
const buf = await browser.stopTracing();
|
||||
const parsed = JSON.parse(buf.toString());
|
||||
events = Array.isArray(parsed) ? parsed : parsed.traceEvents;
|
||||
}
|
||||
|
||||
// Inline summary so a single run is readable without the python analyzer.
|
||||
const durs = (name) =>
|
||||
events
|
||||
.filter((e) => e.name === name && e.ph === "X" && e.dur != null)
|
||||
.map((e) => e.dur);
|
||||
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)))];
|
||||
const mean = v.reduce((a, b) => a + b, 0) / v.length;
|
||||
return (
|
||||
`n=${v.length} mean=${(mean / 1000).toFixed(2)}ms ` +
|
||||
`p50=${(p(0.5) / 1000).toFixed(2)} p95=${(p(0.95) / 1000).toFixed(2)} ` +
|
||||
`p99=${(p(0.99) / 1000).toFixed(2)} max=${(p(1) / 1000).toFixed(2)}`
|
||||
);
|
||||
};
|
||||
const dropped = events.filter((e) => e.name === "DroppedFrame").length;
|
||||
const zooms = events.filter(
|
||||
(e) => e.name === "set-view-box" && e.ph === "b",
|
||||
).length;
|
||||
|
||||
console.log(`\n[PERF ${LABEL}] trace -> ${out}`);
|
||||
console.log(`[PERF ${LABEL}] mode=${MODE} file=${GET_FILE.split("/").pop()} ` +
|
||||
`events=${events.length} viewbox=${zooms} droppedFrames=${dropped}`);
|
||||
console.log(`[PERF ${LABEL}] FireAnimationFrame ${stat(durs("FireAnimationFrame"))}`);
|
||||
console.log(`[PERF ${LABEL}] GPUTask ${stat(durs("GPUTask"))}`);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user