Fling: the vsync clock, the frame ask, and a report that can say what it measured

Iris, from her phone: "some stuttering when flinging in particular.
Harder to notice with my finger directly moving the scroll." Her fling
phase was 103fps on a 120Hz screen at p50 6.3ms.

Two of the four things found are corrections to the instrument, not the
renderer. The swapchain acquire -- `get_current_texture`, which *blocks*
until the compositor frees an image -- was inside the span the report
called iris's CPU work, so a fling comfortably ahead of the display read
as milliseconds of being slow. A frame is now three measured parts
(`FrameParts`: build, acquire, submit), per phase as well as per run. And
nothing could say a frame was never *produced*: `late` counts frames that
cost too much, which a reader does not see, while a frame that never
happens leaves the last one up for two refreshes, which is the stutter.
`PhaseStats::missed` counts vsyncs nothing was drawn for. It closes on
the emulator: 1548 frames + 452 missed over 33.0s at 60Hz is 1980
vsyncs.

The other two are the frame loop. `Choreographer.postFrameCallback`
schedules for the next vsync after the call, and iris asked at the *end*
of the callback -- so any frame whose work ran past the boundary
registered too late and got the vsync after, one frame over budget
silently costing a second. It is asked for immediately after
`tick_animations` now, on both backends. And the fling was advanced on
`Instant::now()` rather than the vsync `do_frame` carries: frames are
presented on an even cadence whatever clock computes them, so sampling
the spline at "whenever the callback ran" moves the content unevenly with
no frame late enough to appear in any report -- and a drag never had it,
which is the asymmetry Iris described. `PointerClock` is `DeviceClock`
and the view keeps one, anchored by whichever of a touch or a frame comes
first, so a fling is advanced on the clock its velocity was measured on.

`opt-level` for the Android release build goes from "s" to 3. The table
in RUST.md picked "s" on bytes alone; over the same warm fling eight
times iris's own per-frame work is p90 0.15ms/p99 0.42ms at "s" against
p90 0.09ms/p99 0.26ms at 3, for 1.8 MB of arm64 APK.

`app-rust/tests/fling_profile.rs` is the rig that established what a
fling frame actually costs and is kept for next time (Iris: "please keep
the profiling rig around for future use"): only one frame in six lays
anything out, and the multi-millisecond spikes are all first-pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-09 00:49:28 -04:00
1 parent 4ccfda6b8e
commit 42d54eec95
16 files changed
+703 -153

No files matched your search

+65
View File
@@ -423,6 +423,71 @@ vectorisation on a renderer. Everything else is
own rather than `release`, so the desktop build is not also optimised for
size.
### The fling stutter, and what a frame report could not say (2026-09-09)
Iris, from her phone: *"I'm noticing some stuttering when flinging in
particular. Harder to notice with my finger directly moving the scroll."*
Her report had the fling phase at 3396 frames over 33.0s -- 103fps on a
120Hz screen -- with p50 6.3ms and 13.4% "late".
**The report was not measuring what its own labels claimed.** Three
things came out of chasing it, and the first two are corrections to the
instrument rather than to the renderer:
1. **The swapchain acquire was counted as iris's CPU work.**
`AndroidRenderer::draw` timed `queue.submit` + `present()` and called
everything before it `redraw_to_submit`, but `get_current_texture` --
which *blocks* until the compositor frees an image -- sits in that
span. An app comfortably ahead of the display spends most of every
frame there, so a healthy fling read as several milliseconds of iris
being slow. A frame is now three measured parts (`FrameParts`:
`build`, `acquire`, `submit`), per phase as well as per run, because
they do not divide the same way in every phase.
2. **Nothing could say a frame was never produced.** `late` counts frames
that cost more than a budget, which is not the thing a reader sees:
a frame that is late but drawn shows up on the next vsync, while a
frame that never happens leaves the previous one on screen for two
refreshes. `PhaseStats::missed` counts vsyncs nothing was drawn for,
from the gap between consecutive frame times.
3. **The frame loop asked for its next frame after doing the work.**
`Choreographer.postFrameCallback` schedules for the next vsync *after
the call*, so any frame whose work ran past the vsync boundary
registered too late for the next one and got the one after -- one
frame over budget silently cost a second frame as well. It is asked
for immediately after `tick_animations`, before the layout and the
draw.
And one that is about the animation rather than the report: **the fling
was advanced on `Instant::now()`, not on the vsync the callback carried.**
`do_frame`'s `frame_time_nanos` was discarded. Frames are *presented* on
an even cadence whatever clock they are computed on, so sampling the
spline at "whenever the callback got to run" moves the content by an
uneven distance every frame -- a shimmer with no frame late enough to
appear in any report, and it is exactly the asymmetry Iris described,
since a drag's positions come from the finger's own timestamped samples
and never had it. `sense::PointerClock` is now `sense::DeviceClock` and
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
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 Android release profile is `opt-level = 3`, not `"s"` (2026-09-09)
The table above was measured in bytes only. `"s"` costs the loop
vectorisation and inlining a renderer runs on: over the same warm fling
eight times, iris's own per-frame work is p90 0.15ms / p99 0.42ms at
`"s"` against p90 0.09ms / p99 0.26ms at `3`. The arm64 release APK goes
from 9,745,704 to 11,542,646 bytes (+1.8 MB) -- the same trade the table
refused for `"z"`, one level further up. Iris raised it herself
(*"I'd make sure it's in release mode"*); the build always was, and this
was the part of "release" that was not about speed.
### Platform fonts, not bundled ones (2026-09-07)
Iris: *"remove the font for now; just match what compose does."* The
+11
View File
@@ -90,6 +90,17 @@ layout that follows is also what keeps layout a pure function of the state
(Iris, 2026-09-08). The visible consequence, and the thing that catches a
test out: **`amt` does not move until the next draw.**
**Which clock a fling is ticked on.** The vsync the frame callback
carries, not `Instant::now()` -- on Android `do_frame`'s
`frame_time_nanos`, converted through the view's one `DeviceClock`
(`sense.rs`), which also dates every touch sample, so a fling is advanced
on the clock its own velocity was measured on. Frames are presented on an
even cadence whatever clock they are computed on, so sampling the spline
at "whenever the callback got to run" moves the content unevenly between
frames that are shown evenly -- a shimmer that no frame-time percentile
can see, since no frame was late. Found 2026-09-09; docs/RUST.md's
"The fling stutter" has the rest.
### Why a remainder was not enough
The `apply_scroll(&mut delta)` this replaced left the part it could not