Put the bench's buckets on the clock

- They closed only when a frame arrived, so a freeze could not be reported
  as one: the iPhone's fourteen frozen seconds came back as a single
  bucket at a seventh of a frame per second.
- Adds the two numbers that say it plainly: time to the first frame, and
  the longest the picture simply stopped.
- A run now ends even if no further frame ever arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-18 11:49:07 +02:00
co-authored by Claude Opus 5
parent 1e445a4d14
commit 6a4bfa1b9a
+40 -25
View File
@@ -69,6 +69,10 @@ interface Run {
buckets: Bucket[]; buckets: Bucket[];
/** Worst observed delay on a 100ms timer: the main thread's own stutter. */ /** Worst observed delay on a 100ms timer: the main thread's own stutter. */
worstTimerLagMs: number; worstTimerLagMs: number;
/** The longest the picture simply stopped, in milliseconds. */
worstFrameGapMs: number;
/** From asking for the run to the first frame on screen. */
firstFrameMs: number | null;
frames: number; frames: number;
seconds: number; seconds: number;
} }
@@ -225,8 +229,10 @@ export const BackgroundEffectsBench: FC = () => {
<div key={i} style={{ marginBlockStart: 12 }}> <div key={i} style={{ marginBlockStart: 12 }}>
<strong>{run.effect}</strong>: mean {run.meanFps.toFixed(1)}fps, worst{" "} <strong>{run.effect}</strong>: mean {run.meanFps.toFixed(1)}fps, worst{" "}
{run.worstFps.toFixed(1)}fps {run.worstFps.toFixed(1)}fps
{run.startupMs !== null && <> , started in {run.startupMs}ms</>}, main {run.startupMs !== null && <>, pipeline ready in {run.startupMs}ms</>}
thread stalled up to {run.worstTimerLagMs}ms , first frame at {run.firstFrameMs ?? "?"}ms, picture stopped for up
to {run.worstFrameGapMs}ms, main thread stalled up to{" "}
{run.worstTimerLagMs}ms
</div> </div>
))} ))}
{runs.length > 0 && ( {runs.length > 0 && (
@@ -255,16 +261,20 @@ async function countFrames(
| "worstFps" | "worstFps"
| "buckets" | "buckets"
| "worstTimerLagMs" | "worstTimerLagMs"
| "worstFrameGapMs"
| "firstFrameMs"
| "frames" | "frames"
| "seconds" | "seconds"
> >
> { > {
return new Promise((resolve) => { return new Promise((resolve) => {
const buckets: Bucket[] = []; const buckets: Bucket[] = [];
const startedAt = performance.now();
let frames = 0; let frames = 0;
let inBucket = 0; let inBucket = 0;
let started: number | null = null; let firstFrameMs: number | null = null;
let bucketStarted = 0; let lastFrameAt = startedAt;
let worstFrameGapMs = 0;
// A phone that is struggling stalls its main thread, and a timer is the // A phone that is struggling stalls its main thread, and a timer is the
// cheapest witness to that: Safari has no long-task observer. // cheapest witness to that: Safari has no long-task observer.
@@ -279,19 +289,28 @@ async function countFrames(
lastTick = now; lastTick = now;
}, 100); }, 100);
// On the clock, not on the frames. Closing a bucket only when a frame
// arrives cannot report a stretch with no frames in it at all — the first
// measurement on a phone spent fourteen seconds inside one bucket and
// reported it as a seventh of a frame per second rather than as a freeze.
const bucketer = window.setInterval(() => {
const at = Math.round((performance.now() - startedAt) / 1000);
buckets.push({ at, fps: inBucket / bucketSeconds });
inBucket = 0;
onProgress(at);
}, bucketSeconds * 1000);
const finish = (): void => { const finish = (): void => {
window.clearInterval(ticker); window.clearInterval(ticker);
const elapsed = window.clearInterval(bucketer);
started === null ? 0 : (performance.now() - started) / 1000; const elapsed = (performance.now() - startedAt) / 1000;
resolve({ resolve({
meanFps: elapsed > 0 ? frames / elapsed : 0, meanFps: elapsed > 0 ? frames / elapsed : 0,
worstFps: buckets.length worstFps: buckets.length ? Math.min(...buckets.map((b) => b.fps)) : 0,
? Math.min(...buckets.map((b) => b.fps))
: elapsed > 0
? frames / elapsed
: 0,
buckets, buckets,
worstTimerLagMs, worstTimerLagMs,
worstFrameGapMs: Math.round(worstFrameGapMs),
firstFrameMs: firstFrameMs === null ? null : Math.round(firstFrameMs),
frames, frames,
seconds: Math.round(elapsed), seconds: Math.round(elapsed),
}); });
@@ -299,37 +318,33 @@ async function countFrames(
const onFrame = (): void => { const onFrame = (): void => {
const now = performance.now(); const now = performance.now();
if (started === null) { firstFrameMs ??= now - startedAt;
started = now; worstFrameGapMs = Math.max(worstFrameGapMs, now - lastFrameAt);
bucketStarted = now; lastFrameAt = now;
}
frames += 1; frames += 1;
inBucket += 1; inBucket += 1;
if (now - bucketStarted >= bucketSeconds * 1000) { if (now - startedAt >= seconds * 1000) return finish();
buckets.push({
at: Math.round((now - started) / 1000),
fps: inBucket / ((now - bucketStarted) / 1000),
});
onProgress(Math.round((now - started) / 1000));
inBucket = 0;
bucketStarted = now;
}
if (now - started >= seconds * 1000) return finish();
element.requestVideoFrameCallback(onFrame); element.requestVideoFrameCallback(onFrame);
}; };
if (!("requestVideoFrameCallback" in element)) { if (!("requestVideoFrameCallback" in element)) {
window.clearInterval(ticker); window.clearInterval(ticker);
window.clearInterval(bucketer);
resolve({ resolve({
meanFps: 0, meanFps: 0,
worstFps: 0, worstFps: 0,
buckets: [], buckets: [],
worstTimerLagMs: 0, worstTimerLagMs: 0,
worstFrameGapMs: 0,
firstFrameMs: null,
frames: 0, frames: 0,
seconds: 0, seconds: 0,
}); });
return; return;
} }
element.requestVideoFrameCallback(onFrame); element.requestVideoFrameCallback(onFrame);
// A freeze can outlast the run: without this, a device that never presents
// another frame never finishes and reports nothing at all.
window.setTimeout(finish, seconds * 1000 + 2000);
}); });
} }