From 480f0bc99f5a80e28647528a50525c2d059ead58 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Mon, 14 Sep 2026 16:42:03 -0400 Subject: [PATCH] Retain opt-in layout performance diagnostics --- Cargo.toml | 3 + core/Cargo.toml | 3 + core/src/layout_diagnostics.rs | 331 +++++++++++++++++++++++++++++++++ core/src/lib.rs | 3 + core/src/primitive/text.rs | 18 +- core/src/ui/painter.rs | 31 ++- core/src/ui/render_state.rs | 96 ++++++++-- tests/layout_diagnostics.rs | 150 +++++++++++++++ 8 files changed, 613 insertions(+), 22 deletions(-) create mode 100644 core/src/layout_diagnostics.rs create mode 100644 tests/layout_diagnostics.rs diff --git a/Cargo.toml b/Cargo.toml index f9b643a..1839b80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,9 @@ name = "iris" version.workspace = true edition.workspace = true +[features] +layout-diagnostics = ["iris-core/layout-diagnostics"] + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] diff --git a/core/Cargo.toml b/core/Cargo.toml index 2e399ad..1c7d6ed 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -3,6 +3,9 @@ name = "iris-core" version.workspace = true edition.workspace = true +[features] +layout-diagnostics = [] + [dependencies] wgpu = { workspace = true } bytemuck ={ workspace = true } diff --git a/core/src/layout_diagnostics.rs b/core/src/layout_diagnostics.rs new file mode 100644 index 0000000..e5ebebb --- /dev/null +++ b/core/src/layout_diagnostics.rs @@ -0,0 +1,331 @@ +//! Opt-in counters and coarse timers for explaining CPU layout cost. +//! +//! Enable the `layout-diagnostics` feature. With it disabled, none of the +//! instrumentation is compiled into Iris. The retained rig in +//! `tests/layout_diagnostics.rs` is the ordinary entry point. +//! +//! Timers are inclusive: `update total` contains `full layout` or +//! `incremental layout`, and `text render` contains shaping and glyph +//! placement. They locate cost within one instrumented run and must not be +//! added together. Use an uninstrumented build under `perf` for final CPU +//! totals; counting every primitive and distinct widget deliberately perturbs +//! the instrumented run. + +use crate::WidgetId; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + fmt::Write, + time::Instant, +}; + +#[derive(Clone, Copy)] +pub(crate) enum Counter { + Updates, + ResizeDependents, + DrawRequests, + WidgetDraws, + PlaceCalls, + SizeReads, + HintHits, + HintMisses, + ReuseAttempts, + ReuseExact, + ReuseMoved, + ReuseDirty, + ReuseWrongParent, + ReuseUnslotted, + ReuseOwnResize, + ReuseDescendantResize, + ResizeChecks, + ResizeCheckChildren, + QueuePops, + DepthSteps, + EagerReaderRedraws, + LocalRedraws, + SizeChanges, + ReaderEdges, + PrimitiveWrites, + TextRenders, + TextShapeHits, + TextShapes, +} + +impl Counter { + const COUNT: usize = Self::TextShapes as usize + 1; + + const NAMES: [&'static str; Self::COUNT] = [ + "updates", + "resize dependents", + "draw requests", + "widget draws", + "place calls", + "draw-result size reads", + "hint hits", + "hint misses", + "reuse attempts", + "reuse exact", + "reuse moved", + "reuse: dirty", + "reuse: wrong parent", + "reuse: unslotted", + "reuse: own resize", + "reuse: descendant resize", + "resize checks", + "resize children checked", + "redraw queue pops", + "depth steps", + "eager reader redraws", + "local redraws", + "size changes", + "reader edges", + "primitive writes", + "text renders", + "text shape hits", + "text shapes", + ]; +} + +#[derive(Clone, Copy)] +pub(crate) enum TimerKind { + Update, + FullLayout, + ResizeMarking, + IncrementalLayout, + TextRender, + TextShape, + GlyphPlacement, +} + +impl TimerKind { + const COUNT: usize = Self::GlyphPlacement as usize + 1; + + const NAMES: [&'static str; Self::COUNT] = [ + "update total", + "full layout", + "resize marking", + "incremental layout", + "text render", + "text shape", + "glyph placement", + ]; +} + +#[derive(Clone)] +pub struct Report { + counters: [u64; Counter::COUNT], + nanos: [u64; TimerKind::COUNT], + distinct_widgets: usize, + distinct_text_widgets: usize, + hot_widgets: Vec, + hot_text: Vec, +} + +impl Default for Report { + fn default() -> Self { + Self { + counters: [0; Counter::COUNT], + nanos: [0; TimerKind::COUNT], + distinct_widgets: 0, + distinct_text_widgets: 0, + hot_widgets: Vec::new(), + hot_text: Vec::new(), + } + } +} + +impl Report { + pub fn counters(&self) -> impl Iterator + '_ { + Counter::NAMES.into_iter().zip(self.counters) + } + + /// Inclusive elapsed time accumulated for each targeted operation. + pub fn timings_ns(&self) -> impl Iterator + '_ { + TimerKind::NAMES.into_iter().zip(self.nanos) + } + + pub fn distinct_widgets(&self) -> usize { + self.distinct_widgets + } + + pub fn distinct_text_widgets(&self) -> usize { + self.distinct_text_widgets + } + + pub fn hot_widgets(&self) -> &[Callsite] { + &self.hot_widgets + } + + pub fn hot_text(&self) -> &[Callsite] { + &self.hot_text + } + + /// Formats nonzero totals divided by `frames`. + pub fn per_frame(&self, frames: usize) -> String { + let divisor = frames.max(1) as f64; + let mut out = String::new(); + for (name, value) in self.counters() { + if value != 0 { + let _ = writeln!(out, " {name:<27} {:>12.2}", value as f64 / divisor); + } + } + if self.distinct_widgets != 0 { + let _ = writeln!( + out, + " {:<27} {:>12}", + "distinct widgets", self.distinct_widgets + ); + } + if self.distinct_text_widgets != 0 { + let _ = writeln!( + out, + " {:<27} {:>12}", + "distinct text widgets", self.distinct_text_widgets + ); + } + for (name, nanos) in self.timings_ns() { + if nanos != 0 { + let ms = nanos as f64 / divisor / 1_000_000.0; + let _ = writeln!(out, " {name:<27} {ms:>12.3} ms"); + } + } + if !self.hot_widgets.is_empty() { + let _ = writeln!(out, " hottest widget draws:"); + for callsite in &self.hot_widgets { + let calls = callsite.calls as f64 / divisor; + let _ = writeln!( + out, + " {calls:>9.2} {:?} {}", + callsite.id, callsite.label + ); + } + } + if !self.hot_text.is_empty() { + let _ = writeln!(out, " hottest text renders:"); + for callsite in &self.hot_text { + let calls = callsite.calls as f64 / divisor; + let _ = writeln!( + out, + " {calls:>9.2} {:>3} widths {:?} {}", + callsite.distinct_widths, callsite.id, callsite.label + ); + } + } + out + } +} + +#[derive(Clone)] +pub struct Callsite { + pub id: WidgetId, + pub label: String, + pub calls: u64, + pub distinct_widths: usize, +} + +#[derive(Default)] +struct Calls { + label: String, + count: u64, + widths: HashSet>, +} + +#[derive(Default)] +struct Current { + report: Report, + widgets: HashMap, + text_widgets: HashMap, +} + +thread_local! { + static CURRENT: RefCell = RefCell::new(Current::default()); +} + +pub(crate) fn bump(counter: Counter) { + CURRENT.with_borrow_mut(|current| current.report.counters[counter as usize] += 1); +} + +pub(crate) fn draw_widget(id: WidgetId, label: &str) { + CURRENT.with_borrow_mut(|current| { + let calls = current.widgets.entry(id).or_default(); + if calls.label.is_empty() { + calls.label = label.to_owned(); + } + calls.count += 1; + }); +} + +pub(crate) fn render_text(id: WidgetId, label: &str, width: Option) { + CURRENT.with_borrow_mut(|current| { + let calls = current.text_widgets.entry(id).or_default(); + if calls.label.is_empty() { + calls.label = label.to_owned(); + } + calls.count += 1; + calls.widths.insert(width.map(f32::to_bits)); + }); +} + +pub(crate) struct Timer { + kind: TimerKind, + start: Instant, +} + +pub(crate) fn timer(kind: TimerKind) -> Timer { + Timer { + kind, + start: Instant::now(), + } +} + +impl Drop for Timer { + fn drop(&mut self) { + let nanos = self.start.elapsed().as_nanos().min(u64::MAX as u128) as u64; + CURRENT.with_borrow_mut(|current| current.report.nanos[self.kind as usize] += nanos); + } +} + +/// Takes all diagnostics accumulated on this thread and resets them. +pub fn take() -> Report { + CURRENT.with_borrow_mut(|current| { + current.report.distinct_widgets = current.widgets.len(); + current.report.distinct_text_widgets = current.text_widgets.len(); + current.report.hot_widgets = hottest(¤t.widgets); + current.report.hot_text = hottest(¤t.text_widgets); + let report = std::mem::take(&mut current.report); + current.widgets.clear(); + current.text_widgets.clear(); + report + }) +} + +fn hottest(calls: &HashMap) -> Vec { + let mut calls: Vec<_> = calls + .iter() + .map(|(&id, calls)| Callsite { + id, + label: calls.label.clone(), + calls: calls.count, + distinct_widths: calls.widths.len(), + }) + .collect(); + calls.sort_by(|a, b| b.calls.cmp(&a.calls).then_with(|| a.label.cmp(&b.label))); + calls.truncate(8); + calls +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn taking_a_report_resets_its_counters() { + let _ = take(); + bump(Counter::Updates); + bump(Counter::Updates); + + let report = take(); + assert_eq!(report.counters().next(), Some(("updates", 2))); + assert!(take().counters().all(|(_, count)| count == 0)); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 1feb954..6c40607 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -10,6 +10,9 @@ #![feature(coerce_unsized)] #![feature(option_into_flat_iter)] +#[cfg(feature = "layout-diagnostics")] +pub mod layout_diagnostics; + mod attr; mod event; mod num; diff --git a/core/src/primitive/text.rs b/core/src/primitive/text.rs index 11284f6..7c23c72 100644 --- a/core/src/primitive/text.rs +++ b/core/src/primitive/text.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "layout-diagnostics")] +use crate::layout_diagnostics::{self as diag, Counter, TimerKind}; use crate::{ Align, GlyphAtlas, GlyphEntry, GlyphKey, PlacedGlyph, RegionAlign, UiColor, util::Vec2, }; @@ -138,8 +140,14 @@ impl TextBuffer { max_width: width, }; if self.layout_key.as_ref() == Some(&layout_key) { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::TextShapeHits); return; } + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::TextShapes); + #[cfg(feature = "layout-diagnostics")] + let _shape = diag::timer(TimerKind::TextShape); let mut builder = data .layout_ctx .ranged_builder(&mut data.font_ctx, &self.text, 1.0, true); @@ -271,8 +279,16 @@ impl TextData { attrs: &TextAttrs, width: Option, ) -> RenderedText { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::TextRenders); + #[cfg(feature = "layout-diagnostics")] + let _render = diag::timer(TimerKind::TextRender); buffer.shape(self, attrs, width); - let glyphs = self.place(buffer); + let glyphs = { + #[cfg(feature = "layout-diagnostics")] + let _place = diag::timer(TimerKind::GlyphPlacement); + self.place(buffer) + }; RenderedText { glyphs, size: buffer.size(), diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index e5098ce..9388acd 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "layout-diagnostics")] +use crate::layout_diagnostics::{self as diag, Counter}; use crate::{ Axis, Len, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId, @@ -37,6 +39,8 @@ impl<'a> Painter<'a> { /// Takes the kind, for a caller writing many of one primitive. fn write(&mut self, kind: PrimitiveKind

, primitive: P, region: UiRegion) { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::PrimitiveWrites); let h = self.state.layers.write( self.layer, PrimitiveInst { @@ -104,6 +108,8 @@ impl<'a> Painter<'a> { id: &'s StrongWidget, region: UiRegion, ) -> DrawResult<'s, 'a, W> { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::PlaceCalls); let region = region.within(&self.region); self.widget_at(id, region, true) } @@ -139,9 +145,24 @@ impl<'a> Painter<'a> { /// What a child says its length is without being drawn, if it can say. /// Asking counts as reading its size. pub fn size_hint(&mut self, id: &StrongWidget, axis: Axis) -> Option { - let hint = self.rsc.widgets().get_dyn(id.id())?.size_hint(axis)?; - self.depend_on_size(id); - Some(hint) + let hint = self + .rsc + .widgets() + .get_dyn(id.id()) + .and_then(|widget| widget.size_hint(axis)); + match hint { + Some(hint) => { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::HintHits); + self.depend_on_size(id); + Some(hint) + } + None => { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::HintMisses); + None + } + } } fn depend_on_size(&mut self, child: &StrongWidget) { @@ -156,6 +177,8 @@ impl<'a> Painter<'a> { attrs: &TextAttrs, width: Option, ) -> RenderedText { + #[cfg(feature = "layout-diagnostics")] + diag::render_text(self.id, self.rsc.widgets().label(self.id), width); let ui = self.rsc.ui_mut(); ui.text.render(buffer, attrs, width) } @@ -238,6 +261,8 @@ pub struct DrawResult<'p, 'a, W: ?Sized> { impl DrawResult<'_, '_, W> { pub fn size(self) -> Size { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::SizeReads); self.painter.depend_on_size(self.child); self.size } diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index 8de4c53..751f1e9 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "layout-diagnostics")] +use crate::layout_diagnostics::{self as diag, Counter, TimerKind}; use crate::{ ActiveData, Axis, DrawLayers, IdLike, MaskIdx, MoveIdx, Moves, OnResize, Painter, PixelRegion, Size, StrongWidget, UiRegion, UiRsc, WidgetId, Widgets, @@ -44,6 +46,10 @@ impl UiRenderState { } pub fn update<'a>(&mut self, root: impl Into>, rsc: &mut dyn UiRsc) { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::Updates); + #[cfg(feature = "layout-diagnostics")] + let _update = diag::timer(TimerKind::Update); // safety mechanism for memory leaks; might wanna return a result instead so user can // decide whether to panic or not if !rsc.widgets().waiting.is_empty() { @@ -68,9 +74,15 @@ impl UiRenderState { // A region is a fraction of the output plus an offset, resolved // against the window in the shader, so a resize moves the whole // drawing on its own. Only a widget that read pixels can be wrong. - for (&id, active) in &self.active { - if active.reads_output { - rsc.widgets_mut().needs_redraw.insert(id); + { + #[cfg(feature = "layout-diagnostics")] + let _marking = diag::timer(TimerKind::ResizeMarking); + for (&id, active) in &self.active { + if active.reads_output { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::ResizeDependents); + rsc.widgets_mut().needs_redraw.insert(id); + } } } } @@ -81,6 +93,8 @@ impl UiRenderState { } fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) { + #[cfg(feature = "layout-diagnostics")] + let _layout = diag::timer(TimerKind::FullLayout); self.clear(rsc); // free all resources & cache if let Some(id) = root { @@ -112,6 +126,8 @@ impl UiRenderState { old_children: Option>, rsc: &mut dyn UiRsc, ) -> Size { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::DrawRequests); let mut old_children = old_children.unwrap_or_default(); if self.active.contains_key(&id) { if let Some(size) = self.try_reuse(id, region, parent_move, rsc) { @@ -151,6 +167,11 @@ impl UiRenderState { rsc, }; + #[cfg(feature = "layout-diagnostics")] + { + diag::bump(Counter::WidgetDraws); + diag::draw_widget(id, painter.rsc.widgets().label(id)); + } let mut widget = painter.rsc.widgets().get_dyn_dynamic(id); let size = widget.draw(&mut painter); drop(widget); @@ -246,31 +267,42 @@ impl UiRenderState { parent_move: MoveIdx, rsc: &mut dyn UiRsc, ) -> Option { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::ReuseAttempts); if rsc.widgets().needs_redraw.contains(&id) { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::ReuseDirty); return None; } let active = self.active.get(&id)?; // Drawn somewhere else in the tree: its box is in coordinates it no // longer sits in, and its slot names the wrong parent. if active.parent_move != parent_move { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::ReuseWrongParent); return None; } - let (size, old, slot, was) = (active.size, active.region, active.move_idx, active.px); + let (size, old_region, slot, old_px) = + (active.size, active.region, active.move_idx, active.px); // In pixels, because `region` is a fraction of a slot's box and that // box may be what changed -- an unchanged fraction of a box half the // size is half the widget. let px = self.px_of(parent_move, region); let mut changed = [false; 2]; for (axis, c) in AXES.into_iter().zip(changed.iter_mut()) { - *c = px.axis(axis) != was.axis(axis); + *c = px.axis(axis) != old_px.axis(axis); } - if !changed.iter().any(|&c| c) && old == region { + if !changed.iter().any(|&c| c) && old_region == region { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::ReuseExact); return Some(size); } // Only a placed widget can be given a different box without drawing // again: everything it drew is a fraction of its slot's box, so one // entry says where all of it went. if slot == parent_move { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::ReuseUnslotted); return None; } if changed.iter().any(|&c| c) { @@ -282,7 +314,14 @@ impl UiRenderState { // Anything under it that has to be drawn again is drawn by drawing // this, because whatever reads that widget's size sits in between // and has to lay out around what it comes to. - if redraws || self.redraws_under(id, changed, rsc) { + if redraws { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::ReuseOwnResize); + return None; + } + if self.redraws_under(id, changed, rsc) { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::ReuseDescendantResize); return None; } } @@ -290,6 +329,8 @@ impl UiRenderState { let active = self.active.get_mut(&id).unwrap(); active.region = region; active.px = px; + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::ReuseMoved); Some(size) } @@ -303,11 +344,15 @@ impl UiRenderState { /// change length has no descendant whose box did, and the walk stops /// there -- an 80-wide child of a widened row is not asked at all. fn redraws_under(&self, id: WidgetId, changed: [bool; 2], rsc: &dyn UiRsc) -> bool { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::ResizeChecks); let Some(active) = self.active.get(&id) else { return false; }; let size_deps = &active.size_deps; active.children.iter().any(|&child| { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::ResizeCheckChildren); let Some(data) = self.active.get(&child) else { return false; }; @@ -397,6 +442,8 @@ impl UiRenderState { } pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) { + #[cfg(feature = "layout-diagnostics")] + let _layout = diag::timer(TimerKind::IncrementalLayout); // A reader's answer is only valid after every dirty size it reads has // settled. Equal-depth widgets are independent, so their order does // not matter. @@ -407,6 +454,8 @@ impl UiRenderState { .copied() .max_by_key(|&id| self.depth(id)) { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::QueuePops); self.redraw(id, rsc); } rsc.free(); @@ -416,6 +465,8 @@ impl UiRenderState { let mut depth = 0; let mut at = Some(id); while let Some(id) = at { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::DepthSteps); at = self.active.get(&id).and_then(|active| active.parent); depth += 1; } @@ -480,6 +531,8 @@ impl UiRenderState { if (self.resized || box_changed) && let Some(top) = self.mark_readers(id, rsc) { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::EagerReaderRedraws); self.redraw(top, rsc); rsc.widgets_mut().needs_redraw.remove(&id); return; @@ -493,8 +546,10 @@ impl UiRenderState { let Some(active) = self.remove(id, false, rsc) else { return; }; + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::LocalRedraws); - let was = active.size; + let old_size = active.size; let size = self.draw_inner( active.layer, id, @@ -507,16 +562,21 @@ impl UiRenderState { rsc, ); - if size != was - && let Some(parent) = self.active.get(&id).and_then(|active| active.parent) - && self - .active - .get(&parent) - .is_some_and(|active| active.size_deps.contains(&id)) - { - // Propagate one dependency edge at a time. If drawing the reader - // does not change its own size, nothing above it can observe this. - rsc.widgets_mut().needs_redraw.insert(parent); + if size != old_size { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::SizeChanges); + if let Some(parent) = self.active.get(&id).and_then(|active| active.parent) + && self + .active + .get(&parent) + .is_some_and(|active| active.size_deps.contains(&id)) + { + // Propagate one dependency edge at a time. If drawing the reader + // does not change its own size, nothing above it can observe this. + rsc.widgets_mut().needs_redraw.insert(parent); + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::ReaderEdges); + } } } diff --git a/tests/layout_diagnostics.rs b/tests/layout_diagnostics.rs new file mode 100644 index 0000000..2c130c7 --- /dev/null +++ b/tests/layout_diagnostics.rs @@ -0,0 +1,150 @@ +//! Retained CPU-layout diagnostics on one reproducible random tree. +//! +//! Counters and phase timers: +//! +//! cargo test --release --features layout-diagnostics \ +//! --test layout_diagnostics -- --ignored --nocapture +//! +//! Uninstrumented hardware totals for one phase: +//! +//! IRIS_PHASE=resize IRIS_FRAMES=1000 perf stat \ +//! -e cycles:u,instructions:u cargo test --release \ +//! --test layout_diagnostics -- --ignored --nocapture +//! +//! `IRIS_PHASE` is `cold`, `repaint`, `size`, `scroll`, `resize`, or `all`. +//! `IRIS_SEED`, `IRIS_DEPTH`, and `IRIS_FRAMES` select the load. + +use iris::harness::Harness; +use iris::prelude::*; +use iris::random::{Edits, Tree, grow}; +use std::time::Instant; + +const OUTPUT: (f32, f32) = (1920.0, 1200.0); + +fn env(name: &str, fallback: T) -> T { + std::env::var(name) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(fallback) +} + +fn warm(seed: u64, depth: usize) -> (Harness, Tree) { + let mut harness = Harness::new(OUTPUT); + let (root, tree) = grow(&mut harness.rsc, seed, depth, &Edits::default()); + harness.state.root = Some(root); + harness.frame(); + println!( + "fixture: seed {seed}, depth {depth}, {} widgets, {} active", + tree.ids.len(), + harness.render.active_widgets() + ); + #[cfg(feature = "layout-diagnostics")] + let _ = iris::core::layout_diagnostics::take(); + (harness, tree) +} + +fn report(label: &str, mut elapsed: Vec, _harness: &Harness) { + elapsed.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let frames = elapsed.len(); + println!( + "{label}: {frames} frame(s), min {:.3} ms, median {:.3} ms, total {:.1} ms", + elapsed[0], + elapsed[frames / 2], + elapsed.iter().sum::(), + ); + #[cfg(feature = "layout-diagnostics")] + { + let diagnostics = iris::core::layout_diagnostics::take(); + print!("{}", diagnostics.per_frame(frames)); + for callsite in diagnostics.hot_text().iter().take(3) { + let mut ancestry = Vec::new(); + let mut id = Some(callsite.id); + while let Some(widget) = id { + ancestry.push(_harness.rsc.widgets().label(widget).as_str()); + id = _harness + .render + .active + .get(&widget) + .and_then(|active| active.parent); + } + println!(" text ancestry: {}", ancestry.join(" < ")); + } + } +} + +fn run( + label: &str, + frames: usize, + harness: &mut Harness, + mut change: impl FnMut(&mut Harness, usize), +) { + let mut elapsed = Vec::with_capacity(frames); + for frame in 0..frames { + change(harness, frame); + let start = Instant::now(); + harness.frame(); + elapsed.push(start.elapsed().as_secs_f64() * 1_000.0); + } + report(label, elapsed, harness); +} + +#[test] +#[ignore = "measurement, not a check"] +fn layout_cost() { + let seed = env("IRIS_SEED", 1_u64); + let depth = env("IRIS_DEPTH", 7_usize); + let frames = env("IRIS_FRAMES", 100_usize); + assert!(frames > 0, "IRIS_FRAMES must be greater than zero"); + let phase = env("IRIS_PHASE", String::from("all")); + assert!( + ["all", "cold", "repaint", "size", "scroll", "resize"].contains(&phase.as_str()), + "unknown IRIS_PHASE {phase:?}" + ); + let selected = |name| phase == "all" || phase == name; + + if selected("cold") { + let mut harness = Harness::new(OUTPUT); + let (root, tree) = grow(&mut harness.rsc, seed, depth, &Edits::default()); + harness.state.root = Some(root); + println!( + "fixture: seed {seed}, depth {depth}, {} widgets", + tree.ids.len() + ); + #[cfg(feature = "layout-diagnostics")] + let _ = iris::core::layout_diagnostics::take(); + run("cold", 1, &mut harness, |_, _| {}); + drop(tree); + } + + if selected("repaint") { + let (mut harness, tree) = warm(seed, depth); + let leaf = tree.ids[0]; + run("repaint", frames, &mut harness, move |harness, _| { + let _ = harness.rsc.widgets_mut().get_dyn_mut(leaf); + }); + } + + if selected("size") { + let (mut harness, tree) = warm(seed, depth); + let sized = tree.sized[0]; + run("size", frames, &mut harness, move |harness, frame| { + harness.rsc[sized].x = Some(Len::abs(100.0 + (frame % 2) as f32 * 40.0)); + }); + } + + if selected("scroll") { + let (mut harness, tree) = warm(seed, depth); + let scroll = tree.scrolls[0]; + run("scroll", frames, &mut harness, move |harness, frame| { + harness.rsc[scroll].scroll(if frame % 2 == 0 { 12.0 } else { -12.0 }); + }); + } + + if selected("resize") { + let (mut harness, tree) = warm(seed, depth); + run("resize", frames, &mut harness, |harness, frame| { + harness.resize((OUTPUT.0 - (frame % 2) as f32 * 8.0, OUTPUT.1)); + }); + drop(tree); + } +}