Coalesce resize layout diagnostics

This commit is contained in:
iris-ai committed 2026-09-14 17:05:11 -04:00
1 parent 480f0bc99f
commit 82fa6c1123
7 files changed
+303 -15

No files matched your search

+150 -1
View File
@@ -10,8 +10,12 @@
//! added together. Use an uninstrumented build under `perf` for final CPU //! added together. Use an uninstrumented build under `perf` for final CPU
//! totals; counting every primitive and distinct widget deliberately perturbs //! totals; counting every primitive and distinct widget deliberately perturbs
//! the instrumented run. //! the instrumented run.
//!
//! Call [`trace_widget`] before a frame to retain the ordered constraint,
//! reuse, size, placement, and text events for one suspicious widget. The
//! selection is a set and survives [`take`] until cleared.
use crate::WidgetId; use crate::{Axis, Len, Size, UiRegion, WidgetId, util::Vec2};
use std::{ use std::{
cell::RefCell, cell::RefCell,
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
@@ -119,6 +123,7 @@ pub struct Report {
distinct_text_widgets: usize, distinct_text_widgets: usize,
hot_widgets: Vec<Callsite>, hot_widgets: Vec<Callsite>,
hot_text: Vec<Callsite>, hot_text: Vec<Callsite>,
traces: Vec<TraceEvent>,
} }
impl Default for Report { impl Default for Report {
@@ -130,6 +135,7 @@ impl Default for Report {
distinct_text_widgets: 0, distinct_text_widgets: 0,
hot_widgets: Vec::new(), hot_widgets: Vec::new(),
hot_text: Vec::new(), hot_text: Vec::new(),
traces: Vec::new(),
} }
} }
} }
@@ -160,6 +166,11 @@ impl Report {
&self.hot_text &self.hot_text
} }
/// Ordered layout events for widgets selected with [`trace_widget`].
pub fn traces(&self) -> &[TraceEvent] {
&self.traces
}
/// Formats nonzero totals divided by `frames`. /// Formats nonzero totals divided by `frames`.
pub fn per_frame(&self, frames: usize) -> String { pub fn per_frame(&self, frames: usize) -> String {
let divisor = frames.max(1) as f64; let divisor = frames.max(1) as f64;
@@ -211,6 +222,12 @@ impl Report {
); );
} }
} }
if !self.traces.is_empty() {
let _ = writeln!(out, " targeted layout trace:");
for event in &self.traces {
let _ = writeln!(out, " {event:?}");
}
}
out out
} }
} }
@@ -223,6 +240,58 @@ pub struct Callsite {
pub distinct_widths: usize, pub distinct_widths: usize,
} }
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ReuseOutcome {
Exact,
Moved,
Dirty,
WrongParent,
Unslotted,
OwnResize,
DescendantResize,
}
/// One targeted layout event. Events are retained in execution order, making
/// repeated constraint paths visible without logging every widget globally.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TraceEvent {
DrawRequest {
id: WidgetId,
parent: Option<WidgetId>,
region: UiRegion,
pixel_size: Vec2,
slotted: bool,
},
Reuse {
id: WidgetId,
outcome: ReuseOutcome,
},
SizeReported {
id: WidgetId,
size: Size,
},
Placed {
id: WidgetId,
parent: WidgetId,
region: UiRegion,
},
SizeRead {
id: WidgetId,
reader: WidgetId,
size: Size,
},
HintRead {
id: WidgetId,
reader: WidgetId,
axis: Axis,
hint: Option<Len>,
},
TextRendered {
id: WidgetId,
width: Option<f32>,
},
}
#[derive(Default)] #[derive(Default)]
struct Calls { struct Calls {
label: String, label: String,
@@ -235,6 +304,7 @@ struct Current {
report: Report, report: Report,
widgets: HashMap<WidgetId, Calls>, widgets: HashMap<WidgetId, Calls>,
text_widgets: HashMap<WidgetId, Calls>, text_widgets: HashMap<WidgetId, Calls>,
traced: HashSet<WidgetId>,
} }
thread_local! { thread_local! {
@@ -255,6 +325,79 @@ pub(crate) fn draw_widget(id: WidgetId, label: &str) {
}); });
} }
/// Adds a widget to the targeted trace set. Selection survives [`take`]
/// until explicitly removed or cleared.
pub fn trace_widget(id: impl Into<WidgetId>) {
CURRENT.with_borrow_mut(|current| {
current.traced.insert(id.into());
});
}
pub fn untrace_widget(id: impl Into<WidgetId>) {
CURRENT.with_borrow_mut(|current| {
current.traced.remove(&id.into());
});
}
pub fn clear_traced_widgets() {
CURRENT.with_borrow_mut(|current| current.traced.clear());
}
fn trace(id: WidgetId, event: TraceEvent) {
CURRENT.with_borrow_mut(|current| {
if current.traced.contains(&id) {
current.report.traces.push(event);
}
});
}
pub(crate) fn draw_request(
id: WidgetId,
parent: Option<WidgetId>,
region: UiRegion,
pixel_size: Vec2,
slotted: bool,
) {
trace(
id,
TraceEvent::DrawRequest {
id,
parent,
region,
pixel_size,
slotted,
},
);
}
pub(crate) fn reuse(id: WidgetId, outcome: ReuseOutcome) {
trace(id, TraceEvent::Reuse { id, outcome });
}
pub(crate) fn size_reported(id: WidgetId, size: Size) {
trace(id, TraceEvent::SizeReported { id, size });
}
pub(crate) fn placed(id: WidgetId, parent: WidgetId, region: UiRegion) {
trace(id, TraceEvent::Placed { id, parent, region });
}
pub(crate) fn size_read(id: WidgetId, reader: WidgetId, size: Size) {
trace(id, TraceEvent::SizeRead { id, reader, size });
}
pub(crate) fn hint_read(id: WidgetId, reader: WidgetId, axis: Axis, hint: Option<Len>) {
trace(
id,
TraceEvent::HintRead {
id,
reader,
axis,
hint,
},
);
}
pub(crate) fn render_text(id: WidgetId, label: &str, width: Option<f32>) { pub(crate) fn render_text(id: WidgetId, label: &str, width: Option<f32>) {
CURRENT.with_borrow_mut(|current| { CURRENT.with_borrow_mut(|current| {
let calls = current.text_widgets.entry(id).or_default(); let calls = current.text_widgets.entry(id).or_default();
@@ -263,6 +406,12 @@ pub(crate) fn render_text(id: WidgetId, label: &str, width: Option<f32>) {
} }
calls.count += 1; calls.count += 1;
calls.widths.insert(width.map(f32::to_bits)); calls.widths.insert(width.map(f32::to_bits));
if current.traced.contains(&id) {
current
.report
.traces
.push(TraceEvent::TextRendered { id, width });
}
}); });
} }
+1 -1
View File
@@ -1,6 +1,6 @@
use super::*; use super::*;
#[derive(Copy, Clone, Eq, PartialEq)] #[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Axis { pub enum Axis {
X, X,
Y, Y,
+7
View File
@@ -111,6 +111,8 @@ impl<'a> Painter<'a> {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::PlaceCalls); diag::bump(Counter::PlaceCalls);
let region = region.within(&self.region); let region = region.within(&self.region);
#[cfg(feature = "layout-diagnostics")]
diag::placed(id.id(), self.id, region);
self.widget_at(id, region, true) self.widget_at(id, region, true)
} }
@@ -150,6 +152,8 @@ impl<'a> Painter<'a> {
.widgets() .widgets()
.get_dyn(id.id()) .get_dyn(id.id())
.and_then(|widget| widget.size_hint(axis)); .and_then(|widget| widget.size_hint(axis));
#[cfg(feature = "layout-diagnostics")]
diag::hint_read(id.id(), self.id, axis, hint);
match hint { match hint {
Some(hint) => { Some(hint) => {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
@@ -262,7 +266,10 @@ pub struct DrawResult<'p, 'a, W: ?Sized> {
impl<W: ?Sized> DrawResult<'_, '_, W> { impl<W: ?Sized> DrawResult<'_, '_, W> {
pub fn size(self) -> Size { pub fn size(self) -> Size {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::SizeReads); diag::bump(Counter::SizeReads);
diag::size_read(self.child.id(), self.painter.id, self.size);
}
self.painter.depend_on_size(self.child); self.painter.depend_on_size(self.child);
self.size self.size
} }
+49 -12
View File
@@ -1,5 +1,5 @@
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter, TimerKind}; use crate::layout_diagnostics::{self as diag, Counter, ReuseOutcome, TimerKind};
use crate::{ use crate::{
ActiveData, Axis, DrawLayers, IdLike, MaskIdx, MoveIdx, Moves, OnResize, Painter, PixelRegion, ActiveData, Axis, DrawLayers, IdLike, MaskIdx, MoveIdx, Moves, OnResize, Painter, PixelRegion,
Size, StrongWidget, UiRegion, UiRsc, WidgetId, Widgets, Size, StrongWidget, UiRegion, UiRsc, WidgetId, Widgets,
@@ -37,9 +37,12 @@ impl UiRenderState {
} }
pub fn resize(&mut self, size: impl Into<Vec2>) { pub fn resize(&mut self, size: impl Into<Vec2>) {
self.output_size = size.into(); let size = size.into();
if size != self.output_size {
self.output_size = size;
self.resized = true; self.resized = true;
} }
}
pub fn output_size(&self) -> Vec2 { pub fn output_size(&self) -> Vec2 {
self.output_size self.output_size
@@ -77,11 +80,17 @@ impl UiRenderState {
{ {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
let _marking = diag::timer(TimerKind::ResizeMarking); let _marking = diag::timer(TimerKind::ResizeMarking);
for (&id, active) in &self.active { let dependents: Vec<_> = self
if active.reads_output { .active
.iter()
.filter_map(|(&id, active)| active.reads_output.then_some(id))
.collect();
for id in dependents {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::ResizeDependents); diag::bump(Counter::ResizeDependents);
rsc.widgets_mut().needs_redraw.insert(id); rsc.widgets_mut().needs_redraw.insert(id);
if let Some(top) = self.mark_readers(id, rsc) {
rsc.widgets_mut().needs_redraw.insert(top);
} }
} }
} }
@@ -127,7 +136,10 @@ impl UiRenderState {
rsc: &mut dyn UiRsc, rsc: &mut dyn UiRsc,
) -> Size { ) -> Size {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::DrawRequests); diag::bump(Counter::DrawRequests);
diag::draw_request(id, parent, region, self.px_of(parent_move, region), slotted);
}
let mut old_children = old_children.unwrap_or_default(); let mut old_children = old_children.unwrap_or_default();
if self.active.contains_key(&id) { if self.active.contains_key(&id) {
if let Some(size) = self.try_reuse(id, region, parent_move, rsc) { if let Some(size) = self.try_reuse(id, region, parent_move, rsc) {
@@ -175,6 +187,8 @@ impl UiRenderState {
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id); let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
let size = widget.draw(&mut painter); let size = widget.draw(&mut painter);
drop(widget); drop(widget);
#[cfg(feature = "layout-diagnostics")]
diag::size_reported(id, size);
let Painter { let Painter {
state: _, state: _,
@@ -271,7 +285,10 @@ impl UiRenderState {
diag::bump(Counter::ReuseAttempts); diag::bump(Counter::ReuseAttempts);
if rsc.widgets().needs_redraw.contains(&id) { if rsc.widgets().needs_redraw.contains(&id) {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseDirty); diag::bump(Counter::ReuseDirty);
diag::reuse(id, ReuseOutcome::Dirty);
}
return None; return None;
} }
let active = self.active.get(&id)?; let active = self.active.get(&id)?;
@@ -279,7 +296,10 @@ impl UiRenderState {
// longer sits in, and its slot names the wrong parent. // longer sits in, and its slot names the wrong parent.
if active.parent_move != parent_move { if active.parent_move != parent_move {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseWrongParent); diag::bump(Counter::ReuseWrongParent);
diag::reuse(id, ReuseOutcome::WrongParent);
}
return None; return None;
} }
let (size, old_region, slot, old_px) = let (size, old_region, slot, old_px) =
@@ -294,7 +314,10 @@ impl UiRenderState {
} }
if !changed.iter().any(|&c| c) && old_region == region { if !changed.iter().any(|&c| c) && old_region == region {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseExact); diag::bump(Counter::ReuseExact);
diag::reuse(id, ReuseOutcome::Exact);
}
return Some(size); return Some(size);
} }
// Only a placed widget can be given a different box without drawing // Only a placed widget can be given a different box without drawing
@@ -302,7 +325,10 @@ impl UiRenderState {
// entry says where all of it went. // entry says where all of it went.
if slot == parent_move { if slot == parent_move {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseUnslotted); diag::bump(Counter::ReuseUnslotted);
diag::reuse(id, ReuseOutcome::Unslotted);
}
return None; return None;
} }
if changed.iter().any(|&c| c) { if changed.iter().any(|&c| c) {
@@ -316,12 +342,18 @@ impl UiRenderState {
// and has to lay out around what it comes to. // and has to lay out around what it comes to.
if redraws { if redraws {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseOwnResize); diag::bump(Counter::ReuseOwnResize);
diag::reuse(id, ReuseOutcome::OwnResize);
}
return None; return None;
} }
if self.redraws_under(id, changed, rsc) { if self.redraws_under(id, changed, rsc) {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseDescendantResize); diag::bump(Counter::ReuseDescendantResize);
diag::reuse(id, ReuseOutcome::DescendantResize);
}
return None; return None;
} }
} }
@@ -330,7 +362,10 @@ impl UiRenderState {
active.region = region; active.region = region;
active.px = px; active.px = px;
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseMoved); diag::bump(Counter::ReuseMoved);
diag::reuse(id, ReuseOutcome::Moved);
}
Some(size) Some(size)
} }
@@ -446,14 +481,16 @@ impl UiRenderState {
let _layout = diag::timer(TimerKind::IncrementalLayout); let _layout = diag::timer(TimerKind::IncrementalLayout);
// A reader's answer is only valid after every dirty size it reads has // A reader's answer is only valid after every dirty size it reads has
// settled. Equal-depth widgets are independent, so their order does // settled. Equal-depth widgets are independent, so their order does
// not matter. // not matter. Resize dirtiness already marks whole reader chains, so
while let Some(id) = rsc // choosing their shallowest roots coalesces descendants that share a
.widgets() // reader and gives each changing box its final constraints first.
.needs_redraw while let Some(id) = {
.iter() let dirty = rsc.widgets().needs_redraw.iter().copied();
.copied() match self.resized {
.max_by_key(|&id| self.depth(id)) true => dirty.min_by_key(|&id| self.depth(id)),
{ false => dirty.max_by_key(|&id| self.depth(id)),
}
} {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::QueuePops); diag::bump(Counter::QueuePops);
self.redraw(id, rsc); self.redraw(id, rsc);
+31 -1
View File
@@ -18,6 +18,33 @@ use iris::random::{Edits, Lens, Rng, SpanEdit, Tree, grow};
const DEPTH: usize = 4; const DEPTH: usize = 4;
const SEEDS: [u64; 6] = [1, 2, 3, 5, 8, 13]; const SEEDS: [u64; 6] = [1, 2, 3, 5, 8, 13];
const REGION_ULPS: u32 = 4;
fn ordered_bits(value: f32) -> u32 {
const SIGN: u32 = 1 << 31;
let bits = value.to_bits();
match bits & SIGN {
0 => bits | SIGN,
_ => !bits,
}
}
fn same_coordinate(got: f32, want: f32) -> bool {
got == want || ordered_bits(got).abs_diff(ordered_bits(want)) <= REGION_ULPS
}
fn same_region(got: Option<PixelRegion>, want: Option<PixelRegion>) -> bool {
match (got, want) {
(Some(got), Some(want)) => {
same_coordinate(got.top_left.x, want.top_left.x)
&& same_coordinate(got.top_left.y, want.top_left.y)
&& same_coordinate(got.bot_right.x, want.bot_right.x)
&& same_coordinate(got.bot_right.y, want.bot_right.y)
}
(None, None) => true,
_ => false,
}
}
fn plant(h: &mut Harness, seed: u64, edits: &Edits) -> Tree { fn plant(h: &mut Harness, seed: u64, edits: &Edits) -> Tree {
let (root, tree) = grow(&mut h.rsc, seed, DEPTH, edits); let (root, tree) = grow(&mut h.rsc, seed, DEPTH, edits);
@@ -138,7 +165,10 @@ fn assert_same(seed: u64, what: &str, warm: (&Harness, &Tree), cold: (&Harness,
for (i, (&w, &c)) in wt.ids.iter().zip(&ct.ids).enumerate() { for (i, (&w, &c)) in wt.ids.iter().zip(&ct.ids).enumerate() {
let (got, want) = (wh.region(&w), ch.region(&c)); let (got, want) = (wh.region(&w), ch.region(&c));
drawn += usize::from(got.is_some()); drawn += usize::from(got.is_some());
if got == want { // Equivalent composition orders can differ by a few f32 ULPs. Bound
// that representation drift directly, while whether a widget drew
// remains exact.
if same_region(got, want) {
continue; continue;
} }
wrong += 1; wrong += 1;
+47
View File
@@ -21,6 +21,53 @@ use std::time::Instant;
const OUTPUT: (f32, f32) = (1920.0, 1200.0); const OUTPUT: (f32, f32) = (1920.0, 1200.0);
#[cfg(feature = "layout-diagnostics")]
#[test]
fn a_selected_widget_retains_its_layout_events() {
use iris::core::layout_diagnostics::{self as diagnostics, TraceEvent};
diagnostics::clear_traced_widgets();
let _ = diagnostics::take();
let mut harness = Harness::new((400, 200));
let leaf = rect(Color::RED).add(&mut harness.rsc);
let other = rect(Color::BLUE).add(&mut harness.rsc);
let root = (leaf, other).span(Dir::RIGHT).add(&mut harness.rsc);
harness.set_root(root);
diagnostics::trace_widget(leaf.id());
let _ = diagnostics::take();
let _ = harness.rsc.widgets_mut().get_dyn_mut(root.id());
let _ = harness.rsc.widgets_mut().get_dyn_mut(leaf.id());
harness.frame();
let report = diagnostics::take();
assert!(
report
.traces()
.iter()
.any(|event| matches!(event, TraceEvent::Placed { id, .. } if *id == leaf.id()))
);
assert!(
report
.traces()
.iter()
.any(|event| matches!(event, TraceEvent::DrawRequest { id, .. } if *id == leaf.id()))
);
assert!(
report
.traces()
.iter()
.any(|event| matches!(event, TraceEvent::SizeRead { id, .. } if *id == leaf.id()))
);
assert!(
report
.traces()
.iter()
.any(|event| matches!(event, TraceEvent::SizeReported { id, .. } if *id == leaf.id()))
);
diagnostics::clear_traced_widgets();
}
fn env<T: std::str::FromStr>(name: &str, fallback: T) -> T { fn env<T: std::str::FromStr>(name: &str, fallback: T) -> T {
std::env::var(name) std::env::var(name)
.ok() .ok()
+18
View File
@@ -227,6 +227,24 @@ fn a_resize_redraws_what_read_the_output() {
assert_eq!(draws.get(), settled + 1); assert_eq!(draws.get(), settled + 1);
} }
#[test]
fn reporting_the_same_output_size_does_not_start_a_resize() {
let mut h = Harness::new((400, 200));
let draws = Rc::new(Cell::new(0));
let leaf = ReadsOutput {
draws: draws.clone(),
}
.add(&mut h.rsc);
h.set_root(leaf);
let settled = draws.get();
h.resize((400, 200));
assert!(!h.needs_redraw());
h.frame();
assert_eq!(draws.get(), settled);
}
#[test] #[test]
fn narrowing_the_output_reflows_text_and_relays_out_around_it() { fn narrowing_the_output_reflows_text_and_relays_out_around_it() {
let mut h = Harness::new((600, 400)); let mut h = Harness::new((600, 400));