Changing any primitive re-uploaded every primitive. Measured over the
bench fixture by the new arena_churn rig: 758 MB across a fling and
1.2 GB across 401 streamed deltas, p50 3.0 MB per streamed frame.
Three separate things were wrong, and only the first is what it looked
like from the outside.
ArrBuf reallocated on every length change. A fresh Buffer's contents are
undefined, so adding one glyph -- which a streamed reply does constantly
-- forced a full rewrite, and no partial upload could have been correct
in the first place. It has a capacity now, growing geometrically and
never shrinking, and update() answers whether the Buffer identity moved
so a caller can rebuild its bind group and force the whole range dirty.
That alone took the glyph array from 95% re-uploaded to 3%, and stopped
primitive_group being rebuilt on every frame the arena changed.
A redraw freed its primitives and pushed new ones. Freed slots are not
reusable until the end of the frame -- a layer's draw order still names
them -- and Painter::draw_twice is how a container learns a child's
size, so with containers nested the arena's high-water was the transient
push count rather than the live one: 17 million pushes across 401
deltas, 127,443 slots for 11,569 live primitives, growing linearly with
the transcript. A redraw now gets its old handles back as a recycle pool
(Painter::take_recycled, Primitives::recycle) and writes into the slots
it already holds; the pool is consumed in order and whatever the draw
does not claim is freed when it ends. The arena is exactly the live
count now. The CPU frame improved with it, from p50 2.20ms to 1.39ms on
the stream run, because the freeing and the draw-order renumbering went
away.
Nothing tracked which entries changed. util::Dirty is a bitset per
uploaded array, coalesced into ranges at a 1 KiB gap. Marking is O(1)
and allocation-free; reading it back is one word per 64 entries. Both
alternatives were measured and rejected: a min..max span is nearly the
whole buffer, since a frame's changes land in 5-20 scattered runs, and a
Vec of indices would mean an allocation and a sort per frame at several
thousand marks. It replaces Primitives::updated -- one bool that covered
the instances and the per-primitive data together, so rewriting a rect's
region re-uploaded every glyph -- and TrackedArena::changed.
The trap only the rig could catch: writing an entry is not changing it.
Recycling rewrote every glyph of every moved row with identical bytes,
marking 73% of the glyph array against 0.6% genuinely changed, because
what moves is the instance's region and not the glyph. PrimitiveVec::set
and Primitives::set_instance compare before marking.
Every array now uploads within a hair of its floor: fling instances 3.4%
against 3.3%, fling glyphs 0.9% against 0.8%, stream glyphs 0.6% against
0.6%. Stream instances are at 72.7%, which *is* the floor and is a
layout question rather than an upload one -- the list is pinned to the
newest end, so a growing reply moves every row, and that should be one
move_offsets write rather than a redraw. Noted in RUST.md as the next
thing.
Also: draw_inner's four old_* parameters become one Retained struct, so
the recycle pool is a field rather than an eleventh positional argument
next to three others of the same shape; and free_primitive is the one
place a slot and its draw-order position are retired together.
The rigs move to scripts/rigs/ui-profile, a crate of their own so a
rig's dependencies stay out of the app's -- arena_churn needs bytemuck,
which nothing in ai-app does. arena_churn prints floor, uploaded and
whole side by side per array, because any two of those alone are
misleading and the 122x over-marking above was invisible until all three
were on screen together.
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>
Iris asked to look into incremental text rendering, hoping parley
supported it. It does not, by design: a `Layout` re-linebreaks and
re-aligns freely but "if the text content or the styles applied to that
content change then a new `Layout` must be created", its LRU cache holds
harfrust's per-font shaper data rather than shaped runs, and its own
`PlainEditor` rebuilds the whole layout from the whole buffer on every
keystroke.
The app already does what incremental layout would buy: `RowBlocks::
apply_delta` keeps one `TextEdit` per markdown block and re-shapes only
the one a delta landed in. Re-splitting the markdown to find it is 18µs
at 18,000 characters; comparing the blocks is 470ns.
What is left is one `TextBuffer::shape` of that block, linear in its
length at ~0.23ms per 1,000 characters here -- and the bench fixture's
streamed message is 14,888 characters in a *single* block, a run-on
paragraph with no blank line in it, so every delta reshapes all of it.
That is 3.5ms of the measured 3.86ms frame.
Real replies are not that: across 7,706 top-level blocks from 3,675 real
assistant messages on this machine (lengths only, no content copied
anywhere), p50 147 characters, p90 449, p99 836, largest 1,580, nothing
above 4,000; code fences p50 126, largest 589. At those sizes a reshape
is 48µs to 372µs here, roughly 0.12-0.93ms on the phone -- inside a
120Hz budget with no incremental anything.
So the recommendation is not to build it, and to give the fixture's
streamed message the paragraph structure a real reply has instead. Three
runs added to `frame_profile.rs` so none of this is re-derived: what
reshaping a growing message costs (including at the sizes real replies
reach), where a delta's cost is, and what the fixture actually streams.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Iris: "the documentation is also pretty crazy too. Can you go through it
and remove everything that's already done and decided? There's entire md
files iirc for projects already complete. And many with checkboxes already
ticked off that just fill up context."
docs/RUST.md 8503 -> 905 the framework bake-off (options,
recommendation, twelve closed
experiment boxes) and two superseded
"where things stand" sections, out;
what the experiments settled kept as
one line each
docs/IRIS_TODO.md 1383 -> 229 fifty closed items and six
phone-report sections whose defects
are all fixed
docs/LAYOUT.md 1116 -> 829 the pre-implementation framing: the
old trait, the checklist, the
migration list, the pass conditions
docs/TEXTURES.md 496 -> 240 the prior-art survey, the proposal
and its review, all implemented
docs/REVIEW-*.md 673 -> 0 two completed review passes; the two
findings left open on purpose (mask
hit-testing, the phone's font set)
moved into RUST.md
What survives a prune is what cannot be cheaply re-derived: measurements
(the APK-size table, the phone bench reports), dead ends, invariants and
their reasons, and the design of what exists now rather than the route to
it. AGENTS.md now says that, so the next session prunes as it goes rather
than appending; docs/IRIS_TODO.md's header says items are deleted when
they land rather than ticked.
Deleting the two review files left eighteen citations dangling in code
comments that state their reason inline and cited the file for provenance
only — those now read "(review, 2026-09-06)" and carry no dead pointer.
The emulator's measured GPU capabilities moved to the this-machine-android
skill, where machine facts belong. IRIS.md and DECISIONS.md are dated
records and were not rewritten; each gained one note that paths in older
entries predate the 2026-09-08 crate merge, pointing at the mapping.
Not touched, deliberately: docs/DECISIONS.md's entries (that file *is* the
queue of things for Iris to review, so deleting decided items would remove
what it exists for) and iris/readme.md and iris/TODO, which are hers.
Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in every workspace, and every remaining docs/*.md cross-reference
resolves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Iris: "the organization of the rust rewrite is a mess right now... there
shouldn't be anything related to the app inside of iris. Iris is supposed
to be the UI framework alone." And, on the crate count: "I'm confused why
the app only code needs more than one crate though."
Nine cargo workspaces become three, and the port's project code -- which
sat in five places, four of them inside the framework -- becomes one crate,
`ai-app`, in `app-rust/`:
client-core -> app-rust/src/client
iris/transcript-ui -> app-rust/src/ui
iris/transcript-fixture -> app-rust/src/ui/fixture.rs + tests/ + touch/
iris/desktop-app -> app-rust/src/desktop + src/bin_desktop.rs
iris/android-app -> app-rust/src/android + android-project/
android-shell -> app-rust/src/shell
iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now
mentions no session, transcript, setup or server anywhere.
Only two of the old splits had a reason that survived reading. event-model
stays a crate at the repo root because server/ depends on it too, so a
crate is what makes the backend and the app agree by construction. The two
Android .so names looked like a hard constraint -- a package produces one
library artifact -- until P2 turned out to already plan merging those two
Android apps into one; both faces now come out of libai_app.so, picked
apart by features so `--no-default-features --features shell` keeps wgpu,
parley and iris out of the Compose app's APK. docs/RUST.md's "One app
crate" has the rest, including what each remaining feature is for.
DECISIONS.md and SUBAGENTS.md move into docs/ with everything else.
Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so,
build-apk.sh produces an APK that installs and launches on this checkout's
emulator (Gl ... virgl, as expected), and the phone-sized headless
screenshot renders the transcript unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>