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

+1 -1
View File
@@ -25,7 +25,7 @@ use iris::prelude::*;
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
/// checked-in file, so a mismatch would only mean the two apps' bench
/// builds open a different split of it, not a wrong-vs-right answer.
pub const BACKLOG_COUNT: usize = 3200;
pub const BACKLOG_COUNT: usize = 3202;
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
+58 -3
View File
@@ -154,13 +154,27 @@ fn what_a_streamed_event_costs() {
let mut fold = Vec::new();
let mut apply = Vec::new();
let mut frame = Vec::new();
// Split by whether the delta started a new markdown block, since that
// is the delta that builds a widget rather than re-shaping one.
let mut frame_same_block = Vec::new();
let mut frame_new_block = Vec::new();
let mut t = PHONE_FRAME_MS;
let block_count = |items: &[ai_app::client::transcript_fold::TranscriptItem]| {
use ai_app::client::markdown_blocks::split_blocks;
use ai_app::client::transcript_fold::TranscriptItem;
match items.last() {
Some(TranscriptItem::AssistantMsg { text, .. }) => split_blocks(text).len(),
_ => 0,
}
};
for (n, event) in opened.stream_tail.iter().enumerate() {
let at = Instant::now();
let blocks_before = block_count(&items);
let old = items.clone();
let at = Instant::now();
let folded = ai_app::client::transcript_fold::fold_event(&items, event);
fold.push(at.elapsed());
items = folded;
let added_block = block_count(&items) > blocks_before;
let at = Instant::now();
opened.screen.apply(&mut h.rsc, &old, &items);
@@ -169,7 +183,13 @@ fn what_a_streamed_event_costs() {
t += PHONE_FRAME_MS;
let at = Instant::now();
h.frame(t);
frame.push(at.elapsed());
let took = at.elapsed();
frame.push(took);
if added_block {
frame_new_block.push(took);
} else {
frame_same_block.push(took);
}
// Where the cost sits as the transcript grows -- one line early,
// one late, is enough to see a per-event cost from a quadratic.
@@ -187,6 +207,18 @@ fn what_a_streamed_event_costs() {
summarise("fold", &fold);
summarise("apply", &apply);
summarise("frame", &frame);
summarise("frame/same-block", &frame_same_block);
summarise("frame/new-block", &frame_new_block);
// What the GPU side has to carry, which layer 1 builds but never
// uploads and so cannot time: every primitive is re-uploaded whenever
// the arena changes, and the buffer is recreated when its length does
// (`ArrBuf::update`). Splitting the streamed reply into blocks trades
// shaping cost for more widgets, so this is the number that says
// whether that trade is free on a real GPU path.
println!(
" primitives on screen at the end: {}",
h.render.active_primitive_count()
);
}
/// What re-shaping a *growing* message costs, isolated from everything
@@ -317,18 +349,41 @@ fn where_a_streamed_deltas_cost_is() {
#[ignore]
fn what_the_fixture_streams() {
use ai_app::client::markdown_blocks::split_blocks;
use ai_app::client::transcript_fold::TranscriptItem;
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
let backlog = opened.items.clone();
let mut items = opened.items;
let before = items.len();
for event in &opened.stream_tail {
items = ai_app::client::transcript_fold::fold_event(&items, event);
}
println!("{} items -> {}", before, items.len());
// The stress message the generator plants in the backlog: one block,
// no blank line, just under `text_cap`'s MESSAGE_BYTES.
let biggest = backlog
.iter()
.filter_map(|item| match item {
TranscriptItem::AssistantMsg { text, .. } => Some(text),
_ => None,
})
.map(|text| {
let blocks = split_blocks(text);
(
text.len(),
blocks.len(),
blocks.iter().map(|b| b.source.len()).max().unwrap_or(0),
)
})
.max_by_key(|(_, _, longest)| *longest);
if let Some((chars, blocks, longest)) = biggest {
println!(
" backlog's largest single block: {longest} chars (in a {chars}-char message of {blocks} blocks)"
);
}
// The last few items are where the stream landed. Only the message
// variants matter -- those are what a delta appends to.
use ai_app::client::transcript_fold::TranscriptItem;
for item in items.iter().rev().take(4) {
let (kind, text) = match item {
TranscriptItem::AssistantMsg { text, .. } => ("AssistantMsg", text.clone()),