perf: fingerprint Resize handles once per frame

Issue: #277
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-24 07:56:49 +03:00
parent 448ceea399
commit 9c5b66c3e4
8 changed files with 116 additions and 19 deletions
File diff suppressed because one or more lines are too long
+78
View File
@@ -0,0 +1,78 @@
// #277: warm Resize-layer render cost on the supported large-house ceiling.
// The deterministic snapshot-call assertion catches the original regression
// even when runner timing noise happens to keep the p95 below its budget.
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import { launch } from './serve.mjs';
const WARMUPS = 3;
const SAMPLES = 20;
const RENDER_P95_MS = 25;
const fixture = makeLargeHouseFixture();
const config = { ...fixture.config, spaces: [fixture.config.spaces[0]] };
const { page, browser } = await launch();
const result = await page.evaluate(async ({ config, warmups, samples }) => {
const card = window.__card;
card._serverCfg = structuredClone(config);
card._space = config.spaces[0].id;
card._modelCache = null;
card._cfgEpoch++;
card._setMode('plan');
card._tool = 'resize';
card.requestUpdate();
await card.updateComplete;
const view = card._viewOr(card._baseVb());
const originalSnapshot = card._rszSnapshot.bind(card);
let snapshotCalls = 0;
card._rszSnapshot = () => {
snapshotCalls++;
return originalSnapshot();
};
for (let index = 0; index < warmups; index++) card._renderResizeLayer(view);
snapshotCalls = 0;
const times = [];
for (let index = 0; index < samples; index++) {
const started = performance.now();
card._renderResizeLayer(view);
times.push(performance.now() - started);
}
card._rszSnapshot = originalSnapshot;
return {
times,
snapshotCalls,
roomCount: card._rszRooms().length,
handleCount: card._rszRooms().reduce((sum, room) => sum + room.poly.length, 0),
};
}, { config, warmups: WARMUPS, samples: SAMPLES });
await browser.close();
const quantile = (values, ratio) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
};
const render = {
min: Math.min(...result.times),
median: quantile(result.times, 0.5),
p95: quantile(result.times, 0.95),
max: Math.max(...result.times),
};
const pass = result.snapshotCalls === SAMPLES
&& result.roomCount === 20
&& result.handleCount === 80
&& render.p95 <= RENDER_P95_MS;
console.log(JSON.stringify({
issue: 277,
fixture: LARGE_HOUSE_COUNTS,
warmups: WARMUPS,
samples: SAMPLES,
roomCount: result.roomCount,
handleCount: result.handleCount,
snapshotCalls: result.snapshotCalls,
snapshotCallsPerFrame: result.snapshotCalls / SAMPLES,
render,
budgets: { renderP95Ms: RENDER_P95_MS, maxSnapshotCallsPerFrame: 1 },
pass,
}, null, 2));
if (!pass) process.exitCode = 1;
+8 -8
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -134,6 +134,8 @@ building the preview frame itself.
preview/commit/Undo, disabled accessibility, real fixture topology,
production-preflight failure and cancellation;
- `demo/benchmark_safe_resize.mjs`: same-run pointer and cached pointerup budgets;
- `demo/benchmark_safe_resize_render.mjs`: warm 20-room/80-handle layer p95
and exactly one geometry snapshot per rendered frame;
- mutation gate: eligibility, third-room, topology, jamb and commit-preflight
bypass mutants.
+3
View File
@@ -2001,6 +2001,9 @@ require hands on real hardware — they remain for the human pass.
- [ ] `npm run benchmark:safe-resize`: pointer clamp p95 ≤16 ms, ≤20% over the
same-run historical baseline (with bounded noise); cached pointerup
preflight p95 ≤75 ms; active-plan delta cache ≤4096 entries
- [ ] `npm run benchmark:safe-resize-render`: on the 20-room/80-handle floor,
a warm Resize layer takes one geometry snapshot per frame and stays at
p95 ≤25 ms
- [ ] Five #277 mutants are caught: axis eligibility, third-room cascade,
topology signature, physical jamb and commit preflight
- [ ] Device markers do not move; the room settings gear re-centres itself
+1
View File
@@ -23,6 +23,7 @@
"benchmark:optimize-geometry-preflight": "node demo/benchmark_optimize_geometry_preflight.mjs",
"benchmark:coincident-partitions": "node demo/benchmark_coincident_partitions.mjs",
"benchmark:safe-resize": "node demo/benchmark_safe_resize.mjs",
"benchmark:safe-resize-render": "node demo/benchmark_safe_resize_render.mjs",
"golden:capture": "node demo/golden/run.mjs --mode=capture",
"golden:verify": "node demo/golden/run.mjs --mode=verify",
"continuity:screencast": "node demo/screencast_visual_continuity.mjs",
+10 -3
View File
@@ -8329,8 +8329,10 @@ class HouseplanCard extends LitElement {
};
}
private _rszResolution(roomId: string, edge: number): SafeResizeResolution {
const snap = this._rszDrag?.snap || this._rszSnapshot();
private _rszResolution(
roomId: string, edge: number, renderSnapshot?: string,
): SafeResizeResolution {
const snap = this._rszDrag?.snap || renderSnapshot || this._rszSnapshot();
const key = `${this._space}|${this._cellCm}|${this._gridPitch}|${snap}`;
if (!this._rszEligibilityCache || this._rszEligibilityCache.key !== key) {
this._rszEligibilityCache = { key, values: new Map() };
@@ -8699,13 +8701,18 @@ class HouseplanCard extends LitElement {
` M 0 ${f(0.22 * s)} V ${f(s)} M ${f(-0.32 * s)} ${f(0.6 * s)} L 0 ${f(s)} L ${f(0.32 * s)} ${f(0.6 * s)}`;
const parts: TemplateResult[] = [];
const rooms = this._rszRooms();
// A geometry snapshot is a deep clone/serialization. It is the cache key
// for every handle in this frame, so compute it once per layer render —
// never once per room edge. During a drag the immutable gesture snapshot
// remains authoritative inside _rszResolution.
const renderSnapshot = this._rszDrag?.snap || this._rszSnapshot();
for (const r of rooms) {
for (let i = 0; i < r.poly.length; i++) {
const a = r.poly[i], b = r.poly[(i + 1) % r.poly.length];
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < this._gridPitch) continue;
const mx = f((a[0] + b[0]) / 2), my = f((a[1] + b[1]) / 2);
const ang = f(Math.atan2(b[1] - a[1], b[0] - a[0]) * 180 / Math.PI);
const resolution = this._rszResolution(r.id, i);
const resolution = this._rszResolution(r.id, i, renderSnapshot);
const disabled = !resolution.enabled;
const reason = disabled ? this._rszReasonText(resolution.reason) : this._t('title.markup_resize');
parts.push(svg`<circle class="rszhandle ${disabled ? 'disabled' : ''}"
+6
View File
@@ -49,3 +49,9 @@ test('#277 a lossy persistence rekey stops at the last complete preview', () =>
assert.match(card, /this\._rszPreview = previousPreview/);
assert.match(card, /g\.d = previousD/);
});
test('#277 Resize render fingerprints geometry once for the whole handle layer', () => {
assert.match(card, /const renderSnapshot = this\._rszDrag\?\.snap \|\| this\._rszSnapshot\(\)/);
assert.match(card, /this\._rszResolution\(r\.id, i, renderSnapshot\)/);
assert.doesNotMatch(card, /const resolution = this\._rszResolution\(r\.id, i\);/);
});