Prune commentary and stale Rust port notes

This commit is contained in:
iris committed 2026-09-10 00:44:13 -04:00
1 parent 5428cd75c9
commit 25370731d0
193 files changed
+693 -16219

No files matched your search

+2 -14
View File
@@ -1,22 +1,10 @@
# 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.
# Standalone so profiling dependencies stay out of the app and iris workspaces.
[package]
name = "ui-profile"
version = "0.1.0"
edition = "2024"
[dependencies]
[dev-dependencies]
ai-app = { path = "../../../app-rust" }
iris = { path = "../../../iris" }
bytemuck = "1"
+1 -8
View File
@@ -1,11 +1,4 @@
# 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"
channel = "nightly"
components = ["clippy", "rustfmt"]
targets = ["aarch64-linux-android", "x86_64-linux-android"]
-9
View File
@@ -1,10 +1,5 @@
//! 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;
@@ -14,9 +9,6 @@ pub fn pct(sorted: &mut [Duration], p: f64) -> f64 {
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]) {
@@ -33,7 +25,6 @@ pub fn summarise(name: &str, samples: &[Duration]) {
);
}
/// 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;
+3 -51
View File
@@ -1,51 +1,17 @@
//! 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>,
@@ -62,7 +28,6 @@ impl Tally {
}
}
/// 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)
@@ -71,8 +36,6 @@ impl Tally {
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
@@ -85,11 +48,8 @@ impl Tally {
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),
);
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,
@@ -114,9 +74,6 @@ impl Tally {
}
}
/// 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,
@@ -132,15 +89,11 @@ impl Arenas {
}
}
/// 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 };
self.instances.stride = bytes.len().checked_div(count).unwrap_or(48);
let ranges = dirty.ranges(count, 1024 / self.instances.stride);
dirty.clear();
self.instances.frame(&bytes, ranges);
@@ -196,7 +149,6 @@ fn what_a_fling_uploads() {
break;
}
}
// A moment at rest between passes, as a finger would leave.
t += 200;
}
println!("\na fling, {PASSES} passes:");
+2 -93
View File
@@ -1,44 +1,11 @@
//! 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]
@@ -49,9 +16,8 @@ fn what_a_fling_frame_costs() {
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();
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",
@@ -62,8 +28,6 @@ fn what_a_fling_frame_costs() {
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);
@@ -95,7 +59,6 @@ fn what_a_fling_frame_costs() {
summarise("frame", &frames);
all_frames.extend(frames);
all_layouts.extend(layouts);
// A moment at rest between passes, as a finger would leave.
t += 200;
}
@@ -107,15 +70,6 @@ fn what_a_fling_frame_costs() {
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() {
@@ -134,8 +88,6 @@ 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;
@@ -171,8 +123,6 @@ fn what_a_streamed_event_costs() {
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 {:?}",
@@ -189,36 +139,18 @@ fn what_a_streamed_event_costs() {
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);
@@ -250,13 +182,6 @@ fn what_reshaping_a_growing_message_costs() {
);
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();
@@ -271,19 +196,11 @@ fn what_reshaping_a_growing_message_costs() {
}
}
/// 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();
@@ -292,8 +209,6 @@ fn where_a_streamed_deltas_cost_is() {
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");
}
@@ -323,8 +238,6 @@ fn where_a_streamed_deltas_cost_is() {
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() {
@@ -340,8 +253,6 @@ fn what_the_fixture_streams() {
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 {
@@ -362,8 +273,6 @@ fn what_the_fixture_streams() {
" 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()),