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:
1 parent
43a3a345e4
commit
77cee6a8fa
7 files changed
+3816
-3619
No files matched your search
@@ -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");
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in new issue
Block a user