The bench fixture streams a reply shaped like a real one, and keeps the run-on as stress

Iris, on the two findings from the incremental-text investigation:
"let's switch to new lines for the test, and also let's keep the single
line around for stress + could be something to try to optimize later."

The streamed tail now takes a blank line every 4-12 deltas, so it is 53
markdown blocks with a longest of 502 characters instead of one block of
14,888 -- against a measured p50 of 147 and a largest-ever 1,580 over
7,706 blocks of real assistant messages. Layer 1's streaming frame went
from p50 3.86ms / p90 8.65ms / worst 10.95ms to p50 2.20 / p90 5.90 /
worst 8.78.

The run-on message is kept as the first two backlog events, 14,824
characters in one block, just under text_cap's 16 KiB so it draws in
full. The *streaming* pathology stays in frame_profile.rs rather than the
fixture: it needs a growing block, and iterating on it there costs a
second instead of a two-minute phone run.

Adding it is purely additive -- the random state is saved and restored
around those two events, so every other backlog event is byte-identical.
That is not tidiness: the first attempt shifted the backlog and broke
`a_long_press_and_drag_selects_text`, which replays a real recording at
(300, 1000) and needs the content it was recorded against to still be
there. BACKLOG_COUNT is 3202 now, in generate.py, fixture.rs and
BenchFixture.kt, which split the file by line index.

And the answer to Iris's question, which the code already had: the newest
message does *not* cap. `build_row`'s `cap` is false for the live tail
because a row that grew while capped would appear to stop growing, and a
reply growing past the cap is never caught either since it grows through
apply_delta. So a streamed block's shaping cost has no ceiling -- ~29ms
per delta at 50k characters, ~58ms at 100k.

Recorded but not chased: the emulator's `stream: build p50` did not move
(10.4 -> 10.5ms) while layer 1's frame nearly halved, so most of a
streaming frame on a GPU path is the whole-arena primitive re-upload
layer 1 never performs -- 11,568 primitives rewritten per delta, with the
fling phase as the control at 0.4ms for the same primitives moved
through move_offsets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-09 01:32:59 -04:00
1 parent 43a3a345e4
commit 77cee6a8fa
7 files changed
+3816 -3619

No files matched your search

+66 -8
View File
@@ -568,14 +568,72 @@ the blocks is 470ns. Neither is the cost.
(the largest block ever seen), or roughly 0.12-0.93ms on the phone.
Comfortably inside a 120Hz budget, with no incremental anything.
**So: do not build incremental text.** What is worth doing instead is
giving the fixture's streamed message the paragraph structure a real
reply has, so the stream phase measures something that happens. Both
apps read the same fixture, so the Compose/iris comparison stays sound,
but numbers from before and after the change are not comparable to each
other. Whether to keep a pathological block as a *labelled* stress case
alongside it is Iris's call -- the danger of the current one is only that
its number reads as "streaming costs 9.5ms" when nothing does.
**So: incremental text is not worth building** -- and Iris agreed, with
the fixture changed instead (2026-09-09: *"let's switch to new lines for
the test, and also let's keep the single line around for stress + could
be something to try to optimize later"*). What landed:
- The streamed reply gets a blank line every 4-12 deltas, so it is 53
blocks with a longest of 502 characters instead of one of 14,888. The
streaming frame went from p50 3.86ms / p90 8.65ms / worst 10.95ms to
**p50 2.20ms / p90 5.90ms / worst 8.78ms** here.
- The run-on message is kept as the first two backlog events, sized just
under `text_cap`'s 16 KiB so it draws in full. The *streaming*
pathology is kept in `frame_profile.rs` instead of the fixture, because
it needs a growing block and iterating on it there costs a second
rather than a two-minute phone run.
- Adding it is **purely additive**: the random state is saved and
restored around those two events, so every other backlog event is
byte-identical. That is not cosmetic -- `phone_screen.rs`'s
`a_long_press_and_drag_selects_text` replays a real recording at
(300, 1000) and failed the first time round, when the insertion shifted
what was under it.
- `BACKLOG_COUNT` is 3202 now, in `generate.py`, `fixture.rs` and
`BenchFixture.kt`. The split is by line index, so a stale copy opens a
different half of the file.
**The cap does not save a streamed reply, and this is worth knowing
before optimising anything here.** Iris asked whether the newest message
caps: it does not, deliberately -- `row::build_row`'s `cap` is `false`
for the live tail because a row that grew while capped would appear to
stop growing, and a reply that grows *past* the cap never gets caught
either, since it grows through `apply_delta`. So a streamed block's
shaping cost has no ceiling: at the measured ~0.23ms per 1,000
characters (about 2.5x that on the phone), a 50,000-character block
would be ~29ms per delta and a 100,000-character one ~58ms. Real replies
do not do this, which is why it is not urgent; nothing *stops* one doing
it, which is why the stress case is kept.
**What the remaining streaming cost is, and is not.** With realistic
blocks the reshape is no longer the cost: layer 1's frame went to p50
2.20ms, spread over frames that added a block (p50 3.56ms, 56 of 401)
and frames that did not (p50 1.94ms). Folding is 0.12ms and applying the
diff 0.35ms.
**But the emulator's `stream: build p50` did not move -- 10.4ms before
the fixture change, 10.5ms after** -- while layer 1's CPU frame nearly
halved. So most of a streaming frame on a real GPU path is something
layer 1 builds and never uploads, and therefore cannot time. The
candidate, and the arithmetic behind it:
- The screen holds **11,568 primitives** by the end of the stream phase.
- `UiRenderNode::update` re-uploads the *entire* instance and primitive
arenas whenever `primitives.updated` is set, which a text change sets
every delta -- about 370 KB per delta at 32 bytes an instance, before
the primitive data itself. `ArrBuf::update` also **recreates the
buffer** whenever its length changes, which adding glyphs does on
nearly every delta, and a recreated buffer means a fresh bind group
too.
- The fling phase is the control that makes this convincing: it moves
the same 11,568 primitives every frame through `move_offsets` -- a
small buffer, no arena rewrite -- and its `build p50` is **0.4ms**
against streaming's 10.5ms, on the same screen and the same content.
So the next thing to look at for streaming is **uploading only what
changed** rather than the whole arena, not anything about text. Splitting
the reply into blocks was still right -- it is what makes the fixture
representative, and it halved the CPU half -- but it was never going to
move this, and it slightly increases the primitive count.
### The Android release profile is `opt-level = 3`, not `"s"` (2026-09-09)