Incremental text: parley cannot, the app already does it, and the 9.5ms is the fixture

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>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-09 01:15:57 -04:00
1 parent 9bf714fa2e
commit 43a3a345e4
3 files changed
+219 -9

No files matched your search

+12 -5
View File
@@ -405,12 +405,19 @@ Each exists because something was invisible without it.
transcript -- every later pass over the same rows is p99 0.26ms. A transcript -- every later pass over the same rows is p99 0.26ms. A
**streamed event** is, and it is not where it looks: folding the event **streamed event** is, and it is not where it looks: folding the event
is 0.35ms and applying the diff to the widget tree is 0.41ms, while the is 0.35ms and applying the diff to the widget tree is 0.41ms, while the
*frame* is 3.86ms here and 9.5ms on Iris's phone. 401 streamed events *frame* is 3.86ms here and 9.5ms on Iris's phone. (The fold was the
move the item count from 652 to 654, so almost every one is a delta
into the same row -- the cost is re-laying out and re-shaping one
growing message on every delta, not the fold. (The fold was the
hypothesis, from `foldEvent`'s Compose lesson under "Things that have hypothesis, from `foldEvent`'s Compose lesson under "Things that have
bitten"; measuring it is what ruled it out.) bitten"; measuring it is what ruled it out.) That frame is one
`TextBuffer::shape` of the block a delta landed in, and **the fixture's
is 14,888 characters in a single block** -- against a largest-ever
1,580 across 7,706 blocks of real replies. So the stream phase's number
is a property of the fixture, not of streaming; docs/RUST.md's
"Incremental text" has the measurements and why parley cannot help.
The last two runs (`where_a_streamed_deltas_cost_is`,
`what_the_fixture_streams`) exist to keep that answerable: what a delta
costs to re-split and re-compare, and what the fixture actually
streams.
- **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08; - **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08;
docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader
in software -- while its GLES *is* the host's real GPU through virgl at in software -- while its GLES *is* the host's real GPU through virgl at
+159
View File
@@ -188,3 +188,162 @@ fn what_a_streamed_event_costs() {
summarise("apply", &apply); summarise("apply", &apply);
summarise("frame", &frame); summarise("frame", &frame);
} }
/// What re-shaping a *growing* message costs, isolated from everything
/// else a frame does -- the measurement that decides whether an
/// incremental-text design would pay for itself (Iris, 2026-09-09:
/// "we should definitely investigate incremental text rendering").
///
/// Grows one text buffer a delta at a time, the way a streamed reply
/// grows one row, and reports what `TextBuffer::shape` costs at each
/// length. Linear per-delta cost means the total over a reply is
/// quadratic in its length, which is the thing an incremental shaper
/// would remove.
#[test]
#[ignore]
fn what_reshaping_a_growing_message_costs() {
use iris::prelude::*;
let mut h = Harness::new(phone_size(), PHONE_SCALE);
// A reply-sized paragraph built a delta at a time. The deltas are
// words rather than characters because that is what a model streams.
const DELTA: &str = "the quick brown fox jumps over the lazy dog ";
let attrs = TextAttrs::default();
let width = Some(phone_size().x);
let mut buffer = iris::prelude::TextBuffer::new_empty();
let mut text = String::new();
let mut per_delta = Vec::new();
let mut total = Duration::ZERO;
for n in 1..=400 {
text.push_str(DELTA);
buffer.set_text(text.clone());
let at = Instant::now();
buffer.shape(&mut h.rsc.ui.text, &attrs, width, PHONE_SCALE);
let took = at.elapsed();
per_delta.push(took);
total += took;
if n % 100 == 0 {
println!(
" after {n:>3} deltas ({:>6} chars): this reshape {:?}",
text.len(),
took
);
}
}
println!(
" 400 deltas: {:?} of shaping in total, {:?} per delta at the end",
total,
per_delta.last().unwrap()
);
summarise("reshape", &per_delta);
// The same measurement at the sizes real replies actually reach.
// Measured 2026-09-09 over 7,706 top-level blocks from 3,675 real
// assistant messages on this machine: p50 147 chars, p90 449, p99
// 836, largest 1,580, and *nothing* above 4,000. The bench fixture's
// streamed message is one 14,888-character block, which is 9x the
// largest real one -- so the sizes below are what a live reshape
// actually costs and the run above is what the benchmark measures.
println!(" at the sizes real replies reach:");
for chars in [147usize, 449, 836, 1580] {
let mut sample = String::new();
while sample.len() < chars {
sample.push_str(DELTA);
}
sample.truncate(chars);
let mut buffer = iris::prelude::TextBuffer::new(sample);
let at = Instant::now();
buffer.shape(&mut h.rsc.ui.text, &attrs, width, PHONE_SCALE);
println!(" {chars:>5} chars: {:?}", at.elapsed());
}
}
/// Where a streamed delta's cost actually is, given that `RowBlocks::
/// apply_delta` already re-shapes only the block the delta landed in.
/// Three candidates, all of which scale with the *whole* message rather
/// than the delta: re-parsing the markdown to find the blocks, comparing
/// them against the ones already drawn, and re-shaping the last block.
#[test]
#[ignore]
fn where_a_streamed_deltas_cost_is() {
use ai_app::client::markdown_blocks::{common_prefix, split_blocks};
// A reply with real block structure -- paragraphs separated by blank
// lines, the way a model writes -- so the last block is one paragraph
// rather than the whole message.
const SENTENCE: &str = "The quick brown fox jumps over the lazy dog. ";
let mut src = String::new();
let mut blocks = Vec::new();
let mut split = Vec::new();
let mut compare = Vec::new();
for n in 1..=400 {
src.push_str(SENTENCE);
// A paragraph break every eight deltas, so the trailing block
// stays a normal size and only the message grows.
if n % 8 == 0 {
src.push_str("\n\n");
}
let at = Instant::now();
let new_blocks = split_blocks(&src);
split.push(at.elapsed());
let at = Instant::now();
let _ = common_prefix(&blocks, &new_blocks);
compare.push(at.elapsed());
blocks = new_blocks;
if n % 100 == 0 {
println!(
" after {n:>3} deltas ({:>6} chars, {} blocks): split {:?} compare {:?}",
src.len(),
blocks.len(),
split[n - 1],
compare[n - 1],
);
}
}
summarise("split_blocks", &split);
summarise("common_prefix", &compare);
let total: Duration = split.iter().chain(compare.iter()).sum();
println!(" 400 deltas: {total:?} in block-splitting and comparison alone");
}
/// What the bench fixture's streamed tail actually is, since the cost of
/// a delta depends entirely on how big the block it lands in gets.
#[test]
#[ignore]
fn what_the_fixture_streams() {
use ai_app::client::markdown_blocks::split_blocks;
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 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 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()),
TranscriptItem::UserMsg { text, .. } => ("UserMsg", text.clone()),
TranscriptItem::ToolRun { input, output, .. } => {
("ToolRun", format!("{input}{output}"))
}
_ => ("other", String::new()),
};
let blocks = split_blocks(&text);
println!(
" tail {kind}: {} chars in {} blocks; longest block {} chars",
text.len(),
blocks.len(),
blocks.iter().map(|b| b.source.len()).max().unwrap_or(0),
);
}
}
+48 -4
View File
@@ -528,10 +528,54 @@ every delta.
`fold_event`'s `items.to_vec()` per event was the hypothesis -- it is the `fold_event`'s `items.to_vec()` per event was the hypothesis -- it is the
exact shape of the Compose lesson in AGENTS.md's "Things that have exact shape of the Compose lesson in AGENTS.md's "Things that have
bitten" -- and measuring it is what ruled it out. **Not yet designed**: bitten" -- and measuring it is what ruled it out.
making a row's text append incrementally rather than reshape touches how
`TranscriptRow` holds its shaped text, which is load-bearing enough to ### Incremental text: parley cannot, and it turns out not to matter (2026-09-09)
raise before building.
Iris asked to investigate incremental text rendering and hoped parley
supported it. **It does not, by design.** The crate's own docs: a
`Layout` "supports re-linebreaking and re-aligning many times... but if
the text content or the styles applied to that content change then a new
`Layout` must be created". Its `LruCache` caches harfrust's per-font
shaper data, instance and plan -- not shaped runs -- and its own
`PlainEditor::update_layout` rebuilds the whole layout from the whole
buffer on **every keystroke**. So there is nothing to adopt, and adding
it would be upstream work in parley.
**And the app already does the thing incremental layout would buy.**
`RowBlocks::apply_delta` keeps one `TextEdit` per top-level markdown
block and re-shapes only the block a delta landed in; re-splitting the
markdown to find that block is 18µs at 18,000 characters and comparing
the blocks is 470ns. Neither is the cost.
**The 9.5ms is a bench-fixture artifact.** Measured with
`frame_profile.rs`:
- Re-shaping a block is linear in its length -- ~0.23ms per 1,000
characters on this desktop, so a message grown to 17,600 characters
costs 4.1ms on its *last* delta and 842ms of shaping over the whole
reply.
- The fixture's streamed message is **14,888 characters in one block** --
a synthetic run-on paragraph with no blank line in it, so every delta
reshapes all of it. That is the whole of the frame: 3.5ms of the
measured 3.86ms.
- Real replies are not like that. Over **7,706 top-level blocks from
3,675 real assistant messages** on this machine (block lengths only;
no content left the machine): p50 147 characters, p90 449, p99 836,
largest 1,580, and **nothing above 4,000**. Code fences are smaller
still -- 170 of them, p50 126, largest 589.
- At those sizes a live reshape is 48µs (p50), 208µs (p99) and 372µs
(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.
### The Android release profile is `opt-level = 3`, not `"s"` (2026-09-09) ### The Android release profile is `opt-level = 3`, not `"s"` (2026-09-09)