//! Text-layout workloads with stable paragraphs for comparisons across revisions. //! PR #19's base uses the older spelling of the fixed 40-pixel width and has //! no diagnostics. The random generator changed with layout, so it cannot //! provide the same workload across the full PR. //! //! ROWS=40 FRAMES=500 cargo test --release --test revision_cost \ //! -- --ignored --nocapture resize_cost //! PHASE=edit ROWS=40 FRAMES=2000 cargo test --release --test revision_cost \ //! -- --ignored --nocapture text_updates_cost //! ROWS=2000 cargo test --release --test revision_cost \ //! -- --ignored --nocapture text_memory //! //! `text_updates_cost` selects idle, repaint, edit, or scroll with `PHASE`. //! It alternates a short suffix for edits so later frames do not get a longer //! paragraph than earlier ones. These are CPU fixtures, with no GPU submission. //! //! Use repeated `perf stat -e instructions:u` runs on the executable directly; //! process totals include font loading and the cold frame, so compare identical //! row and frame counts. Wall time on this machine is not a stable comparison. use iris::harness::Harness; use iris::prelude::*; use std::time::Instant; /// xorshift64, so one seed is one set of paragraphs on any machine. struct Rng(u64); impl Rng { fn bits(&mut self) -> u64 { self.0 ^= self.0 << 13; self.0 ^= self.0 >> 7; self.0 ^= self.0 << 17; self.0 } fn below(&mut self, n: usize) -> usize { (self.bits() % n as u64) as usize } } const WORDS: [&str; 24] = [ "wrapping", "shapes", "one", "source", "into", "as", "many", "lines", "as", "the", "box", "leaves", "room", "for", "paragraph", "height", "answer", "setting", "container", "width", "before", "knows", "measured", "again", ]; /// A run of its own words, so nothing here is fast for two texts being the /// same string. fn words(rng: &mut Rng, least: usize, most: usize) -> String { let words = least + rng.below(most - least); let mut out = String::new(); for _ in 0..words { if !out.is_empty() { out.push(' '); } out.push_str(WORDS[rng.below(WORDS.len())]); } out } const OUTPUT: (f32, f32) = (900.0, 1200.0); fn env(name: &str, fallback: T) -> T { std::env::var(name) .ok() .and_then(|value| value.parse().ok()) .unwrap_or(fallback) } /// A row of a fixed-width rect beside a column of one wrapping and one /// overflowing text: the shape that makes a container measure a child in a /// box it will not keep. fn build(h: &mut Harness, rows: usize) -> Vec { let mut rng = Rng(1); let mut paragraphs = Vec::new(); let mut col = Span::empty(Dir::DOWN); for _ in 0..rows { let mut row = Span::empty(Dir::RIGHT); row.push( rect(Color::RED) .width(LayoutLen::px(40.0)) .add_strong(&mut h.rsc), ); let mut body = Span::empty(Dir::DOWN); let para = wtext(words(&mut rng, 12, 52)) .size(16) .wrap(true) .add_strong(&mut h.rsc); paragraphs.push(para.id()); body.push(para); body.push( // Short, or its unwrapped width decides the row and the // paragraph beside it never wraps. wtext(words(&mut rng, 2, 6)) .size(16) .wrap(false) .add_strong(&mut h.rsc), ); row.push(body.add_strong(&mut h.rsc)); col.push(row.add_strong(&mut h.rsc)); } let root = col.add(&mut h.rsc); h.set_root(root); paragraphs } #[test] #[ignore = "measurement, not a check"] fn resize_cost() { let rows = env("ROWS", 40_usize); let frames = env("FRAMES", 500_usize); let mut h = Harness::new(OUTPUT); let paragraphs = build(&mut h, rows); // What it cost is only half the comparison: the old code is cheaper // partly because it wraps at the container's whole width rather than the // part left beside the rect, and draws past the edge of the output. println!("output width {}", OUTPUT.0); for (at, id) in paragraphs.iter().enumerate().take(3) { println!("paragraph {at}: {:?}", h.region(id)); } // The sweep cycles 256 widths, avoiding the two-width cache-friendly case. let sweep = env("SWEEP", 0_usize) != 0; #[cfg(feature = "layout-diagnostics")] let _ = iris::core::layout_diagnostics::take(); let mut elapsed = Vec::with_capacity(frames); for frame in 0..frames { let narrower = match sweep { true => (frame % 256) as f32, false => ((frame + 1) % 2) as f32 * 8.0, }; h.resize((OUTPUT.0 - narrower, OUTPUT.1)); let start = Instant::now(); h.frame(); elapsed.push(start.elapsed().as_secs_f64() * 1000.0); } #[cfg(feature = "layout-diagnostics")] print!( "{}", iris::core::layout_diagnostics::take().per_frame(frames) ); elapsed.sort_by(|a, b| a.partial_cmp(b).unwrap()); println!( "resize: {frames} frames, min {:.3} ms, median {:.3} ms, p99 {:.3} ms, \ max {:.3} ms, total {:.1} ms", elapsed[0], elapsed[frames / 2], elapsed[frames * 99 / 100], elapsed[frames - 1], elapsed.iter().sum::() ); } fn kb(field: &str) -> u64 { std::fs::read_to_string("/proc/self/status") .unwrap() .lines() .find(|line| line.starts_with(field)) .and_then(|line| line.split_whitespace().nth(1)?.parse().ok()) .unwrap() } fn report(label: &str) { println!( "{label:24} rss {:>7} kB peak {:>7} kB", kb("VmRSS:"), kb("VmHWM:") ); } /// Run this one on its own: the figures are the whole process's. #[test] #[ignore = "measurement, not a check"] fn text_memory() { let rows = env("ROWS", 2000_usize); report("before"); let mut h = Harness::new(OUTPUT); let paragraphs = build(&mut h, rows); report("after cold frame"); for frame in 0..40 { h.resize((OUTPUT.0 - ((frame + 1) % 2) as f32 * 8.0, OUTPUT.1)); h.frame(); } report("after 40 resizes"); // Settled: the output holds still and one leaf repaints per frame. Marked // by taking it mutably because the revision at the top of this file has no // `mark_for_redraw`, and the same source has to build against both. for _ in 0..10 { let _ = h.rsc.widgets_mut().get_dyn_mut(paragraphs[0]); h.frame(); } report("after settling"); } #[test] #[ignore = "measurement, not a check"] fn text_updates_cost() { let rows = env("ROWS", 40_usize); let frames = env("FRAMES", 1000_usize); let phase = env("PHASE", String::from("edit")); assert!(rows > 0 && frames > 0); assert!(["idle", "repaint", "edit", "scroll"].contains(&phase.as_str())); let mut h = Harness::new(OUTPUT); let mut rng = Rng(1); let mut col = Span::empty(Dir::DOWN); let first = wtext(words(&mut rng, 12, 52)) .size(16) .wrap(true) .add(&mut h.rsc); col.push(first.add_strong(&mut h.rsc)); for _ in 1..rows { col.push( wtext(words(&mut rng, 12, 52)) .size(16) .wrap(true) .add_strong(&mut h.rsc), ); } let root = col.scrollable().add(&mut h.rsc); h.set_root(root); #[cfg(feature = "layout-diagnostics")] let _ = iris::core::layout_diagnostics::take(); let original = h.rsc[first].content.to_string(); let alternate = format!("{original} another word"); let start = Instant::now(); for frame in 0..frames { match phase.as_str() { "idle" => {} "repaint" => { let _ = h.rsc.widgets_mut().get_dyn_mut(first.id()); } "edit" => { h.rsc[first].content.clear(); h.rsc[first].content.push_str(if frame % 2 == 0 { &alternate } else { &original }); } "scroll" => h.rsc[root].scroll(if frame % 2 == 0 { -12.0 } else { 12.0 }), _ => unreachable!(), } h.frame(); } println!( "{phase}: {rows} rows, {frames} frames, {:.1} ms", start.elapsed().as_secs_f64() * 1000.0 ); #[cfg(feature = "layout-diagnostics")] print!( "{}", iris::core::layout_diagnostics::take().per_frame(frames) ); }