A ninth sweep, over the part no earlier round named -- the widget vocabulary and the builder methods, `Widgets`, the examples, the `util` additions and the manifests -- and once more over `77ed7a2`, the eighth sweep's own commit and so itself unreviewed. A hint overrode a rule. `declared_lens` asked `rules[axis].declared()` first and fell through to the widget's own `size_hint` whenever that answered `None` -- which it does for a share, since a share is not a declaration. So a widget carrying `width(leftover(1))` and hinting a pixel length of its own was handed a box of the hint, against the rule and against the comment inside the function: "a hint still narrows the box where no rule does". `Painter::size_hint` spells the same rule-else-hint step three hundred lines up and gets it right, with the reason written on it; both read `Widgets::exact_len` now, and `declared_lens` is the part of its answer that needs nobody to divide it. `Image` is the only widget here whose hint is a declared length, and neither the tests nor the generator builds one, so nothing in this repository could reach the difference -- which is why the dump is unchanged and why the test builds a widget of its own. It records the box it was asked in: 400 with the rule and 50 without, and 50 either way before this. Marking a widget for redraw had no name. Twenty-one sites under `tests/` said it as `widgets_mut().get_dyn_mut(id);` with the widget thrown away, five with a `let _ =` in front, one with a comment explaining what the line was for, and one wrapped in a local function called `mark`. `Widgets::mark_for_redraw` says it. `revision_cost.rs` keeps the long spelling and now says why in place: it is deliberately in the API subset an old worktree also has. `assert_same_regions` could not see the defect the eighth sweep had just fixed. It zips the warm and cold id lists, so a list naming one widget twice -- which is what `width`, `sized` and `align` giving back their own argument produces -- compares fewer boxes than it lists and says nothing about it. It now rejects a repeated id and two lists of different lengths, which also checks the nine fixtures that round left alone: all eighteen cases pass. Bare pairs where the framework has named ones. `random.rs`'s `Lens` and `Aligns` were `[Option<LayoutLen>; 2]` and `[Option<AxisAlign>; 2]`, read as `[0]`/`[1]` and zipped against a hand-written `[Axis::X, Axis::Y]`. They are `SizeRules` and `Align`; `Align` took the `Index<Axis>` every other per-axis pair on this branch has, and `RegionAlign::from` does the "an axis left out is centred" step two rigs were spelling per axis. The three sites that wrote the axis pair out say `Axis::BOTH`, which is what the rest of the layout code says. `BothAxis<T>`, `AxisT`, `XAxis` and `YAxis` -- 45 lines with a const trait, two marker types and three accessors -- have no user anywhere in the workspace. They are the mechanism `impl_axis_index!` replaced, in the file this branch took `Vec2::axis`/`axis_mut` out of. Deleted, which is a drive-by in a block the branch was already rewriting; drop it if the scope matters more. Smaller things, each in its own place: `Wrapper` arrived beside core's `WidgetWrapper`, one word for a widget that wraps a child and for a dynamic borrow guard, so the alias is gone and its two uses name `DynBorrower` -- which is what they are. `Wrapper::new`, `Wrapper::empty` and its `Default` were three names for one value, two of them unused. `Arena::get_mut` was the only `pub(crate)` among `pub` siblings on a public type. `Selector` rounded the pointer onto the pixel grid to do arithmetic on two values already there, losing the precision the platform gave it for nothing; the step between the regions is taken on the grid instead. And the two `debug` profile settings carry their reason where the next reader looks rather than only in the commit that made them, one of which was about renaming `rest`. Format, clippy with and without layout-diagnostics, and the suite (132 + 19 + 13 + 4) are clean. The cold dump over 400 depth-5 trees is byte-identical to `77ed7a2` across all 34,488 boxes, and all three seed scans pass: 400 at depth 5 in 63.27s, 1,000 at depth 6 in 160.45s, 2,000 at depth 4 in 302.52s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
279 lines
9.6 KiB
Rust
279 lines
9.6 KiB
Rust
//! Retained CPU-layout diagnostics on one reproducible random tree.
|
|
//!
|
|
//! Counters and phase timers:
|
|
//!
|
|
//! cargo test --release --features layout-diagnostics \
|
|
//! --test layout_diagnostics -- --ignored --nocapture
|
|
//!
|
|
//! Build the uninstrumented test with `cargo test --release --test
|
|
//! layout_diagnostics --no-run`, then run the emitted executable directly:
|
|
//!
|
|
//! IRIS_PHASE=resize IRIS_FRAMES=10000 perf stat -r 7 \
|
|
//! -e cycles:u,instructions:u /path/to/layout_diagnostics --ignored --nocapture
|
|
//!
|
|
//! `IRIS_PHASE` is `cold`, `repaint`, `many`, `size`, `scroll`, `resize`, or
|
|
//! `all`. `IRIS_SEED`, `IRIS_DEPTH`, and `IRIS_FRAMES` select the load, and
|
|
//! `IRIS_DIRTY` how many widgets `many` marks at once.
|
|
|
|
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);
|
|
|
|
/// A scroll whose content fits is the same drawing in every box it still
|
|
/// fits in, so a longer or shorter one relays out nothing. Where the content
|
|
/// sits in that box is decided by placing its answer in the whole of it,
|
|
/// which is a fraction of the box and holds at every length -- so the
|
|
/// contract must not turn on the alignment. It did, and at the default
|
|
/// alignment, which is the middle, every box change redrew the scroll.
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
#[test]
|
|
fn a_fitting_scroll_holds_for_every_box_its_content_fits_in() {
|
|
use iris::core::layout_diagnostics as diag;
|
|
|
|
for align in [Align::TOP_LEFT, Align::CENTER, Align::BOT_RIGHT] {
|
|
let mut harness = Harness::new((400, 200));
|
|
let inner = rect(Color::RED).height(50).add(&mut harness.rsc);
|
|
harness.set_root(inner.scrollable().align(align));
|
|
harness.frame();
|
|
let _ = diag::take();
|
|
// Still far longer than the 50 the content needs.
|
|
harness.resize((400, 180));
|
|
harness.frame();
|
|
assert_eq!(diag::take().distinct_widgets(), 0, "{align:?}");
|
|
}
|
|
}
|
|
|
|
#[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).region_node().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();
|
|
|
|
harness.rsc.widgets_mut().mark_for_redraw(root.id());
|
|
harness.rsc.widgets_mut().mark_for_redraw(leaf.id());
|
|
harness.frame();
|
|
|
|
let report = diagnostics::take();
|
|
assert!(
|
|
report
|
|
.traces()
|
|
.iter()
|
|
.any(|event| matches!(event, TraceEvent::RegionNode { 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 {
|
|
std::env::var(name)
|
|
.ok()
|
|
.and_then(|value| value.parse().ok())
|
|
.unwrap_or(fallback)
|
|
}
|
|
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
fn trace_selected(tree: &Tree) {
|
|
let Ok(value) = std::env::var("IRIS_TRACE_INDEX") else {
|
|
return;
|
|
};
|
|
let index = value
|
|
.parse::<usize>()
|
|
.expect("IRIS_TRACE_INDEX must be a tree.ids index");
|
|
let id = tree.ids[index];
|
|
iris::core::layout_diagnostics::trace_widget(id);
|
|
println!("tracing tree.ids[{index}] = {id:?}");
|
|
}
|
|
|
|
#[cfg(not(feature = "layout-diagnostics"))]
|
|
fn trace_selected(_: &Tree) {}
|
|
|
|
/// The shape a cost is measured on must not depend on what layout measured,
|
|
/// or two commits are compared on two different trees. See `Edits`.
|
|
fn rig_edits() -> Edits {
|
|
Edits {
|
|
fixed_branches: true,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn warm(seed: u64, depth: usize) -> (Harness, Tree) {
|
|
let mut harness = Harness::new(OUTPUT);
|
|
let (root, tree) = grow(&mut harness.rsc, seed, depth, &rig_edits());
|
|
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<f64>, _harness: &Harness) {
|
|
elapsed.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
|
let frames = elapsed.len();
|
|
// The worst frame is the stutter somebody sees, so it goes beside the
|
|
// median; p99 says whether it is the load or a single interruption.
|
|
println!(
|
|
"{label}: {frames} frame(s), 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::<f64>(),
|
|
);
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
{
|
|
let diagnostics = iris::core::layout_diagnostics::take();
|
|
print!("{}", diagnostics.per_frame(frames));
|
|
for event in diagnostics.traces() {
|
|
println!(" {event:?}");
|
|
}
|
|
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", "many", "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, &rig_edits());
|
|
harness.state.root = Some(root);
|
|
println!(
|
|
"fixture: seed {seed}, depth {depth}, {} widgets",
|
|
tree.ids.len()
|
|
);
|
|
trace_selected(&tree);
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
let _ = iris::core::layout_diagnostics::take();
|
|
run("cold", 1, &mut harness, |_, _| {});
|
|
}
|
|
|
|
if selected("repaint") {
|
|
let (mut harness, tree) = warm(seed, depth);
|
|
trace_selected(&tree);
|
|
let leaf = tree.ids[0];
|
|
run("repaint", frames, &mut harness, move |harness, _| {
|
|
harness.rsc.widgets_mut().mark_for_redraw(leaf);
|
|
});
|
|
}
|
|
|
|
if selected("many") {
|
|
let (mut harness, tree) = warm(seed, depth);
|
|
trace_selected(&tree);
|
|
// Spread through the tree rather than taken from one subtree, so the
|
|
// dependency paths the frame settles overlap.
|
|
let wanted = env("IRIS_DIRTY", 32_usize).max(1);
|
|
let step = (tree.ids.len() / wanted).max(1);
|
|
let dirty: Vec<_> = tree.ids.iter().copied().step_by(step).collect();
|
|
println!("marking {} of {} widgets", dirty.len(), tree.ids.len());
|
|
run("many", frames, &mut harness, move |harness, _| {
|
|
for &id in &dirty {
|
|
harness.rsc.widgets_mut().mark_for_redraw(id);
|
|
}
|
|
});
|
|
}
|
|
|
|
if selected("size") {
|
|
let (mut harness, tree) = warm(seed, depth);
|
|
trace_selected(&tree);
|
|
let sized = tree.sized[0];
|
|
run("size", frames, &mut harness, move |harness, frame| {
|
|
let len = LayoutLen::px(100.0 + (frame % 2) as f32 * 40.0);
|
|
harness
|
|
.rsc
|
|
.widgets_mut()
|
|
.set_size_rule(sized, Axis::X, SizeRule::Exact(len));
|
|
});
|
|
}
|
|
|
|
if selected("scroll") {
|
|
let (mut harness, tree) = warm(seed, depth);
|
|
trace_selected(&tree);
|
|
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);
|
|
trace_selected(&tree);
|
|
run("resize", frames, &mut harness, |harness, frame| {
|
|
harness.resize((OUTPUT.0 - ((frame + 1) % 2) as f32 * 8.0, OUTPUT.1));
|
|
});
|
|
}
|
|
}
|