The frame report says what it measured: idle is not stutter, waiting is not late

Iris's phone came back "now THAT is smooth", and reading that run against
the bench's own timings found three things the report was getting wrong
-- two of them shipped yesterday in the fix for the last three.

`missed vsyncs` counted idleness. Every gap between frames was treated as
cadence, so the bench's own pauses read as stutter: 276 for sixteen 300ms
rests between flings, 2410 for twelve hundred 50ms keystroke gaps, 821
for four hundred 50ms stream gaps -- each within a few percent of the
arithmetic. A gap now measures anything only if the frame before it had
asked for another one.

`late` counted the swapchain wait as cost. A well-paced loop spends each
frame blocked in the acquire, so its total sits at exactly one refresh
period and every frame lands on the budget boundary -- 0.4ms of work and
5.7ms of waiting is not a late frame. It is judged on `FrameParts::work`.

And the refresh rate is the larger of what the platform claims and what
the run sustained, because each can only be wrong one way.
`Display.getRefreshRate()` answered 60 for a run that drew 3405 frames in
33.1s, since a phone that varies its rate answers with whatever mode it
is in when asked. The first attempt at measuring it instead took the
fastest tenth of the gaps and reported 88Hz for this repo's 60Hz
emulator, whose app manages 54 -- a budget no frame there could meet,
invented out of the app's best moments, and caught only by running the
corrected report on the emulator before shipping it. A sustained rate is
a floor and cannot do that. Both are printed when they disagree.

Also corrected in the docs: "103fps on a 120Hz screen" divided the fling
phase by its whole duration, rests included. Both runs sustained ~120.3fps
through the motion, so the callback ordering was never costing frames --
what changed is the clock, which moves no frame count at all, which is
exactly why nothing in a report could show it.

`fling_profile.rs` is `frame_profile.rs` and gained a stream run, which
says where the frame time now is: folding an arriving event is 0.35ms and
applying the diff 0.41ms, while the frame is 3.86ms here and 9.5ms on the
phone. 401 events move the item count 652 -> 654, so nearly every one is
a delta into the same row -- the cost is re-shaping one growing message,
not `fold_event`'s per-event clone, which was the hypothesis and is what
measuring it ruled out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-09 01:08:30 -04:00
1 parent 42d54eec95
commit 9bf714fa2e
6 files changed
+457 -57

No files matched your search

+57 -1
View File
@@ -472,11 +472,67 @@ the view keeps **one**, anchored by whichever of a touch or a frame
arrives first, so a fling is advanced on the clock its velocity was
measured on.
What the CPU side is *not*: `app-rust/tests/fling_profile.rs` (AGENTS.md's
What the CPU side is *not*: `app-rust/tests/frame_profile.rs` (AGENTS.md's
rig list) puts iris's own per-frame work during a warm fling at p99
0.26ms, with only one frame in six laying anything out at all. The
multi-millisecond spikes are first-pass only.
**The result, from Iris's phone the same day: "now THAT is smooth. I
couldn't actually see any lag myself."** With three corrections to what
the report meant, found by reading that run against the bench's own
timings:
- **The frame rate never was the problem, and the first reading of it was
wrong.** "103fps on a 120Hz screen" divided the fling phase's frames by
its whole duration, which includes sixteen deliberate 300ms rests. Both
runs sustained ~120.3fps through the motion itself. So the callback
ordering was not costing frames -- what changed is the *clock*, which
moves no frame count and is the whole point: an uneven sample of an
even cadence cannot show up in any frame-time percentile.
- **`missed vsyncs` counted idleness.** Every gap was treated as cadence,
so the bench's own pauses read as stutter: 276 for sixteen 300ms rests,
2410 for twelve hundred 50ms keystroke gaps, 821 for four hundred 50ms
stream gaps -- each within a few percent of the arithmetic. A gap now
measures anything only if the frame before it had asked for another.
- **`late` counted the vsync wait as cost.** A well-paced loop spends
each frame blocked in the acquire, so its total sits at exactly one
refresh period and every frame lands on the budget boundary. It is
judged on `FrameParts::work` -- the total minus the acquire -- now.
- **The refresh rate is the larger of what the platform claims and what
the run sustained**, because each can only be wrong one way.
`Display.getRefreshRate()` answered 60 for a run that drew 3405 frames
in 33.1s, since a phone that varies its rate answers with whatever mode
it is in when asked. And `FrameReport::sustained_frame_hz` is a *floor*:
an app that cannot keep up says nothing about the panel. The first
version of it took the fastest tenth of the gaps rather than the
sustained rate and reported **88Hz for this repo's 60Hz emulator**,
whose app manages 51 -- a budget no frame there could meet, invented
out of the app's best moments, and caught only by running the corrected
report on the emulator before shipping it. The two are printed together
whenever they disagree.
The signature of the fixed loop, from that run: `build p50 0.4ms,
acquire p50 5.7ms, submit p50 1.7ms` -- four tenths of a millisecond of
work and the rest of the refresh period spent waiting its turn.
### Streaming is where the frame time is now (2026-09-09)
Measured after the fling was fixed, and it is not where it looks.
`frame_profile.rs`'s stream run: folding an arriving event is 0.35ms and
applying the diff to the widget tree is 0.41ms, while the frame that
follows is 3.86ms on this desktop and 9.5ms of `build` on Iris's phone --
over a 120Hz budget on its own. 401 streamed events move the item count
from 652 to 654, so nearly every one is a *delta into the same row*: the
cost is re-laying out and re-shaping one growing markdown message on
every delta.
`fold_event`'s `items.to_vec()` per event was the hypothesis -- it is the
exact shape of the Compose lesson in AGENTS.md's "Things that have
bitten" -- and measuring it is what ruled it out. **Not yet designed**:
making a row's text append incrementally rather than reshape touches how
`TranscriptRow` holds its shaped text, which is load-bearing enough to
raise before building.
### The Android release profile is `opt-level = 3`, not `"s"` (2026-09-09)
The table above was measured in bytes only. `"s"` costs the loop