iris: the arenas upload deltas, and stop being 11x bigger than the tree
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.
This commit is contained in:
1 parent
77cee6a8fa
commit
3c7d3db370
19 files changed
+6290
-155
No files matched your search
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+5199
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
# The UI profiling rigs: what a frame costs on the CPU, and what each GPU
|
||||
# arena costs to upload. Layer 1 of docs/RUST.md's "Three test layers" --
|
||||
# the real transcript screen over the real bench fixture, with no window,
|
||||
# no compositor and no GPU.
|
||||
#
|
||||
# **Its own crate so a rig's dependencies stay out of the app's** (Iris,
|
||||
# 2026-09-09). `bytemuck` is here because `arena_churn` reads the arenas
|
||||
# as bytes; nothing in `ai-app` needs it, and a dev-dependency there would
|
||||
# put it in the graph of every `cargo test` the app runs.
|
||||
#
|
||||
# Deliberately not a member of any workspace, for the same reason
|
||||
# `gpu-probe` is not: `iris/` is meant to stay reconcilable with the
|
||||
# upstream iris tree, and these belong to ai-app.
|
||||
[package]
|
||||
name = "ui-profile"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
ai-app = { path = "../../../app-rust" }
|
||||
iris = { path = "../../../iris" }
|
||||
bytemuck = "1"
|
||||
@@ -0,0 +1,11 @@
|
||||
# iris needs nightly (see the #![feature] list in core/src/lib.rs and src/lib.rs).
|
||||
# The pin is dated rather than "nightly" because the const-traits feature set
|
||||
# changes shape between nightlies: on 2026-09-04 the vendored January tree would
|
||||
# not parse at all, because `impl const Trait for T` had become
|
||||
# `const impl Trait for T`. A rolling channel turns that into a build that
|
||||
# breaks unattended on whatever machine Dev Updater happens to build on.
|
||||
# Advance this deliberately, with the feature list in RUST.md's I0b.
|
||||
[toolchain]
|
||||
channel = "nightly-2026-09-03"
|
||||
components = ["clippy", "rustfmt"]
|
||||
targets = ["aarch64-linux-android", "x86_64-linux-android"]
|
||||
@@ -0,0 +1 @@
|
||||
pub mod stats;
|
||||
@@ -0,0 +1,43 @@
|
||||
//! The percentile and summary printing both rigs share, so two runs'
|
||||
//! output can be read side by side.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// The `p`th percentile of `sorted`, in milliseconds. Sorts in place, so
|
||||
/// a caller keeping its samples passes a clone.
|
||||
pub fn pct(sorted: &mut [Duration], p: f64) -> f64 {
|
||||
if sorted.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
sorted.sort_unstable();
|
||||
let i = ((sorted.len() as f64 - 1.0) * p).round() as usize;
|
||||
sorted[i].as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
/// Half a 120Hz frame -- the budget these rigs judge a *CPU* sample
|
||||
/// against, since layer 1 measures only the build phase and a frame has
|
||||
/// to acquire and submit as well.
|
||||
pub const CPU_BUDGET: Duration = Duration::from_micros(8_333);
|
||||
|
||||
pub fn summarise(name: &str, samples: &[Duration]) {
|
||||
let mut v = samples.to_vec();
|
||||
let over_budget = v.iter().filter(|d| **d > CPU_BUDGET).count();
|
||||
println!(
|
||||
" {name:16} n={:<5} p50 {:.2}ms p90 {:.2}ms p99 {:.2}ms worst {:.2}ms \
|
||||
over-8.3ms {over_budget}",
|
||||
v.len(),
|
||||
pct(&mut v, 0.5),
|
||||
pct(&mut v, 0.9),
|
||||
pct(&mut v, 0.99),
|
||||
pct(&mut v, 1.0),
|
||||
);
|
||||
}
|
||||
|
||||
/// The percentile of a plain count (bytes, calls) rather than a duration.
|
||||
pub fn pct_u64(v: &mut [u64], p: f64) -> u64 {
|
||||
if v.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
v.sort_unstable();
|
||||
v[(((v.len() - 1) as f64) * p).round() as usize]
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! What each GPU arena costs to upload per frame, at layer 1 (docs/RUST.md's
|
||||
//! "Three test layers") -- the real transcript screen over the real bench
|
||||
//! fixture, with no window, no compositor and no GPU.
|
||||
//!
|
||||
//! cargo test --release --test arena_churn -- --ignored --nocapture
|
||||
//!
|
||||
//! from `scripts/rigs/ui-profile/`.
|
||||
//!
|
||||
//! It exists because the upload is the one part of a frame that layer 1
|
||||
//! *builds* and never performs, so `frame_profile.rs` cannot see it at
|
||||
//! all: the emulator's `stream: build p50` stayed at 10.5ms across a
|
||||
//! change that nearly halved layer 1's CPU frame, and nothing could say
|
||||
//! why until this could count bytes.
|
||||
//!
|
||||
//! Three numbers per array per frame, which is the point of the rig --
|
||||
//! any two of them alone are misleading:
|
||||
//!
|
||||
//! - **changed** is the floor: entries whose bytes actually differ from
|
||||
//! the previous frame, found by diffing. Nothing correct can upload
|
||||
//! less.
|
||||
//! - **uploaded** is what `iris` really writes, read from the same
|
||||
//! `Dirty` sets `UiRenderNode::update` consumes and cleared here the
|
||||
//! way an upload would clear them. Above `changed` by whatever the
|
||||
//! marking over-marks plus whatever range coalescing pulls in.
|
||||
//! - **whole** is what the old code wrote every time anything changed.
|
||||
//!
|
||||
//! `#[ignore]`d and assertion-free: it prints distributions, so
|
||||
//! `run-tests.sh` neither runs it nor can fail on it.
|
||||
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::Harness;
|
||||
use iris::prelude::{GlyphPrimitive, Primitive, RectPrimitive};
|
||||
use iris::widget::Scrollable;
|
||||
use ui_profile::stats::pct_u64;
|
||||
|
||||
/// Passes over the same content, alternating direction -- the same
|
||||
/// out-and-back `frame_profile.rs`'s fling drives, so the two rigs
|
||||
/// describe the same gesture.
|
||||
const PASSES: usize = 8;
|
||||
const VELOCITY: f32 = 12_000.0;
|
||||
const PASS_CAP_MS: u64 = 4_000;
|
||||
|
||||
/// One array's per-frame totals.
|
||||
#[derive(Default)]
|
||||
struct Tally {
|
||||
name: &'static str,
|
||||
stride: usize,
|
||||
/// The previous frame's bytes, for the diff that finds the floor.
|
||||
prev: Vec<u8>,
|
||||
changed: Vec<u64>,
|
||||
uploaded: Vec<u64>,
|
||||
whole: Vec<u64>,
|
||||
calls: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
fn new(name: &'static str, stride: usize) -> Self {
|
||||
Self {
|
||||
name,
|
||||
stride,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Records one frame, and clears the dirty set as an upload would.
|
||||
fn frame(&mut self, bytes: &[u8], ranges: Vec<std::ops::Range<usize>>) {
|
||||
let n = self.prev.len().min(bytes.len()) / self.stride;
|
||||
let mut changed = (0..n)
|
||||
.filter(|i| {
|
||||
let r = i * self.stride..(i + 1) * self.stride;
|
||||
self.prev[r.clone()] != bytes[r]
|
||||
})
|
||||
.count();
|
||||
// Everything past the old end is new, and so is dirty by
|
||||
// definition.
|
||||
changed += bytes.len() / self.stride - n;
|
||||
self.changed.push((changed * self.stride) as u64);
|
||||
self.uploaded
|
||||
.push(ranges.iter().map(|r| (r.len() * self.stride) as u64).sum());
|
||||
self.calls.push(ranges.len() as u64);
|
||||
self.whole.push(bytes.len() as u64);
|
||||
self.prev.clear();
|
||||
self.prev.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn report(&mut self) {
|
||||
let sum = |v: &[u64]| v.iter().sum::<u64>();
|
||||
let (changed, uploaded, whole) = (
|
||||
sum(&self.changed),
|
||||
sum(&self.uploaded),
|
||||
sum(&self.whole),
|
||||
);
|
||||
println!(
|
||||
" {:<10} whole {:>7.1} MB | uploaded {:>7.1} MB ({:>5.1}%) | floor {:>7.1} MB ({:>5.1}%)",
|
||||
self.name,
|
||||
whole as f64 / 1e6,
|
||||
uploaded as f64 / 1e6,
|
||||
100.0 * uploaded as f64 / whole.max(1) as f64,
|
||||
changed as f64 / 1e6,
|
||||
100.0 * changed as f64 / whole.max(1) as f64,
|
||||
);
|
||||
println!(
|
||||
" {:<10} uploaded per frame p50 {:>7} p90 {:>7} p99 {:>7} max {:>7} B | \
|
||||
writes p50 {} p90 {} max {}",
|
||||
"",
|
||||
pct_u64(&mut self.uploaded.clone(), 0.5),
|
||||
pct_u64(&mut self.uploaded.clone(), 0.9),
|
||||
pct_u64(&mut self.uploaded.clone(), 0.99),
|
||||
pct_u64(&mut self.uploaded.clone(), 1.0),
|
||||
pct_u64(&mut self.calls.clone(), 0.5),
|
||||
pct_u64(&mut self.calls.clone(), 0.9),
|
||||
pct_u64(&mut self.calls.clone(), 1.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The three arenas a transcript frame writes. Masks and move offsets are
|
||||
/// left out deliberately: they are a hundred-odd entries, so their whole
|
||||
/// buffer is smaller than one range of any of these.
|
||||
struct Arenas {
|
||||
instances: Tally,
|
||||
rects: Tally,
|
||||
glyphs: Tally,
|
||||
}
|
||||
|
||||
impl Arenas {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
instances: Tally::new("instances", 0),
|
||||
rects: Tally::new("rects", size_of::<RectPrimitive>()),
|
||||
glyphs: Tally::new("glyphs", size_of::<GlyphPrimitive>()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads this frame's dirty ranges out of the render state and clears
|
||||
/// them, exactly as `UiRenderNode::update` would on a real backend.
|
||||
fn frame(&mut self, h: &mut Harness) {
|
||||
let count = h.render.primitives.instances().len();
|
||||
let (entries, dirty) = h.render.primitives.instances_for_upload();
|
||||
// `PrimitiveInstance` is not exported, so the stride comes from
|
||||
// the slice rather than from `size_of`.
|
||||
let bytes: Vec<u8> = bytemuck::cast_slice(entries).to_vec();
|
||||
self.instances.stride = if count == 0 { 48 } else { bytes.len() / count };
|
||||
let ranges = dirty.ranges(count, 1024 / self.instances.stride);
|
||||
dirty.clear();
|
||||
self.instances.frame(&bytes, ranges);
|
||||
|
||||
let data = h.render.primitives.data_mut();
|
||||
let (entries, dirty) = RectPrimitive::vec(data).for_upload();
|
||||
let bytes: Vec<u8> = bytemuck::cast_slice(entries).to_vec();
|
||||
let ranges = dirty.ranges(entries.len(), 1024 / size_of::<RectPrimitive>());
|
||||
dirty.clear();
|
||||
self.rects.frame(&bytes, ranges);
|
||||
|
||||
let (entries, dirty) = GlyphPrimitive::vec(data).for_upload();
|
||||
let bytes: Vec<u8> = bytemuck::cast_slice(entries).to_vec();
|
||||
let ranges = dirty.ranges(entries.len(), 1024 / size_of::<GlyphPrimitive>());
|
||||
dirty.clear();
|
||||
self.glyphs.frame(&bytes, ranges);
|
||||
}
|
||||
|
||||
fn report(&mut self, h: &Harness) {
|
||||
println!(
|
||||
" arena {} slots for {} live primitives",
|
||||
h.render.primitives.instances().len(),
|
||||
h.render.primitives.live_count(),
|
||||
);
|
||||
self.instances.report();
|
||||
self.rects.report();
|
||||
self.glyphs.report();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn what_a_fling_uploads() {
|
||||
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");
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
let mut arenas = Arenas::new();
|
||||
arenas.frame(&mut h);
|
||||
|
||||
let mut t = PHONE_FRAME_MS;
|
||||
for pass in 0..PASSES {
|
||||
let velocity = if pass % 2 == 0 { VELOCITY } else { -VELOCITY };
|
||||
(opened.screen.list)(&mut h.rsc).fling(velocity);
|
||||
let id = opened.screen.list.id();
|
||||
h.rsc.ui.animate(id);
|
||||
let end = t + PASS_CAP_MS;
|
||||
while t <= end {
|
||||
t += PHONE_FRAME_MS;
|
||||
h.frame(t);
|
||||
arenas.frame(&mut h);
|
||||
if !(opened.screen.list)(&mut h.rsc).is_scrolling() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A moment at rest between passes, as a finger would leave.
|
||||
t += 200;
|
||||
}
|
||||
println!("\na fling, {PASSES} passes:");
|
||||
arenas.report(&h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn what_a_streamed_reply_uploads() {
|
||||
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");
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
let mut arenas = Arenas::new();
|
||||
arenas.frame(&mut h);
|
||||
|
||||
let mut items = opened.items;
|
||||
let mut t = PHONE_FRAME_MS;
|
||||
for event in opened.stream_tail.iter() {
|
||||
let old = items.clone();
|
||||
items = ai_app::client::transcript_fold::fold_event(&items, event);
|
||||
opened.screen.apply(&mut h.rsc, &old, &items);
|
||||
t += PHONE_FRAME_MS;
|
||||
h.frame(t);
|
||||
arenas.frame(&mut h);
|
||||
}
|
||||
println!("\na reply streaming in, one event per frame:");
|
||||
arenas.report(&h);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
//! Profiling runs rather than tests: what a frame costs on the CPU, at
|
||||
//! layer 1 (docs/RUST.md's "Three test layers") -- the real
|
||||
//! transcript screen over the real bench fixture, with no window, no
|
||||
//! compositor and no GPU, on a clock this file owns. It exists so "the
|
||||
//! fling stutters" can be attributed rather than guessed at, and it is
|
||||
//! kept between investigations rather than rewritten each time (Iris,
|
||||
//! 2026-09-09: "please keep the profiling rig around for future use").
|
||||
//!
|
||||
//! cargo test --release --test frame_profile -- --ignored --nocapture
|
||||
//!
|
||||
//! from `scripts/rigs/ui-profile/`.
|
||||
//!
|
||||
//! Two runs today: `what_a_fling_frame_costs` (scrolling over transcript
|
||||
//! that is already folded) and `what_a_streamed_event_costs` (a reply
|
||||
//! arriving into it).
|
||||
//!
|
||||
//! `#[ignore]`d because it asserts nothing -- it prints a distribution,
|
||||
//! so `run-tests.sh` neither runs it nor can fail on it. **Release, or
|
||||
//! the numbers mean nothing**: layout is dominated by text shaping, which
|
||||
//! is an order of magnitude slower unoptimised.
|
||||
//!
|
||||
//! What it cannot answer: anything about the GPU, the present queue, or
|
||||
//! the phone's own clock. It measures the CPU half of a frame, which is
|
||||
//! where `cpu_p50` in a phone bench report comes from.
|
||||
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::{Harness, TouchScript};
|
||||
use iris::prelude::*;
|
||||
use std::time::{Duration, Instant};
|
||||
use ui_profile::stats::summarise;
|
||||
|
||||
/// Passes over the same content, alternating direction. More than two
|
||||
/// because the question the rig was built for is whether a frame's cost
|
||||
/// is first-time work (which the first pass pays and the rest do not) or
|
||||
/// work repeated every time a row comes back on screen.
|
||||
const PASSES: usize = 8;
|
||||
/// The velocity `bench_client.rs`'s fling phase uses, so a number here
|
||||
/// and a number in a phone report describe the same gesture.
|
||||
const VELOCITY: f32 = 12_000.0;
|
||||
/// A fling settles on the spline's own schedule (~2s at this velocity);
|
||||
/// this only stops a pass that somehow never settles from running away.
|
||||
const PASS_CAP_MS: u64 = 4_000;
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn what_a_fling_frame_costs() {
|
||||
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");
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
|
||||
// The recorded flick first, so the velocity a real finger produces is
|
||||
// in the log beside the scripted passes below.
|
||||
let flick = TouchScript::parse(include_str!("../../../../app-rust/touch/flick-120hz.touch")).unwrap();
|
||||
h.replay(&flick);
|
||||
println!(
|
||||
"recorded flick released at {:?}px/s; scripted passes run at {VELOCITY}px/s",
|
||||
(opened.screen.list)(&mut h.rsc).fling_velocity(),
|
||||
);
|
||||
|
||||
let mut t = flick.end_ms();
|
||||
let mut all_frames = Vec::new();
|
||||
let mut all_layouts = Vec::new();
|
||||
for pass in 0..PASSES {
|
||||
// Away from the newest end on the even passes and back on the
|
||||
// odd ones, the same out-and-back the bench's fling phase drives.
|
||||
let velocity = if pass % 2 == 0 { VELOCITY } else { -VELOCITY };
|
||||
let list = (opened.screen.list)(&mut h.rsc);
|
||||
list.fling(velocity);
|
||||
let id = opened.screen.list.id();
|
||||
h.rsc.ui.animate(id);
|
||||
|
||||
let mut frames = Vec::new();
|
||||
let mut layouts = Vec::new();
|
||||
let end = t + PASS_CAP_MS;
|
||||
while t <= end {
|
||||
let at = Instant::now();
|
||||
h.frame(t);
|
||||
frames.push(at.elapsed());
|
||||
layouts.push(h.render.last_layout_duration());
|
||||
t += PHONE_FRAME_MS;
|
||||
if !(opened.screen.list)(&mut h.rsc).is_scrolling() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let laid_out = layouts
|
||||
.iter()
|
||||
.filter(|d| **d > Duration::from_micros(20))
|
||||
.count();
|
||||
println!(
|
||||
"pass {pass} ({}): {} frames, {laid_out} did layout",
|
||||
if velocity > 0.0 { "out " } else { "back" },
|
||||
frames.len(),
|
||||
);
|
||||
summarise("frame", &frames);
|
||||
all_frames.extend(frames);
|
||||
all_layouts.extend(layouts);
|
||||
// A moment at rest between passes, as a finger would leave.
|
||||
t += 200;
|
||||
}
|
||||
|
||||
println!(
|
||||
"\nall {PASSES} passes, primitives={}",
|
||||
h.render.active_primitive_count()
|
||||
);
|
||||
summarise("frame", &all_frames);
|
||||
summarise("layout", &all_layouts);
|
||||
}
|
||||
|
||||
/// The other half of a bench run, and since 2026-09-09 the expensive one:
|
||||
/// what it costs to fold one arriving event into the transcript and show
|
||||
/// it. The bench's stream phase measured `build p50 9.5ms` on Iris's
|
||||
/// phone against a fling's 0.4ms, so this is where the frame time now is.
|
||||
///
|
||||
/// Reports the fold and the widget-tree apply separately, because they
|
||||
/// are different problems with different fixes -- and reports how the
|
||||
/// cost moves as the transcript grows, which is the shape that says
|
||||
/// whether the work is per-event or per-event-times-transcript.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn what_a_streamed_event_costs() {
|
||||
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");
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
|
||||
let mut items = opened.items;
|
||||
println!(
|
||||
"backlog: {} items, then {} streamed events",
|
||||
items.len(),
|
||||
opened.stream_tail.len()
|
||||
);
|
||||
|
||||
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 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);
|
||||
apply.push(at.elapsed());
|
||||
|
||||
t += PHONE_FRAME_MS;
|
||||
let at = Instant::now();
|
||||
h.frame(t);
|
||||
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.
|
||||
if n == 0 || n == opened.stream_tail.len() - 1 {
|
||||
println!(
|
||||
" event {n:>3} of {}: items={} fold {:?} apply {:?}",
|
||||
opened.stream_tail.len(),
|
||||
items.len(),
|
||||
fold[n],
|
||||
apply[n],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// 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;
|
||||
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.
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user