diff --git a/core/src/layout_diagnostics.rs b/core/src/layout_diagnostics.rs index d5d8823..7f22d98 100644 --- a/core/src/layout_diagnostics.rs +++ b/core/src/layout_diagnostics.rs @@ -54,10 +54,11 @@ pub(crate) enum Counter { TextRenders, TextShapeHits, TextShapes, + GlyphPlacements, } impl Counter { - const COUNT: usize = Self::TextShapes as usize + 1; + const COUNT: usize = Self::GlyphPlacements as usize + 1; const NAMES: [&'static str; Self::COUNT] = [ "updates", @@ -89,6 +90,7 @@ impl Counter { "text renders", "text shape hits", "text shapes", + "glyph placements", ]; } diff --git a/core/src/primitive/text.rs b/core/src/primitive/text.rs index 7c23c72..f83419e 100644 --- a/core/src/primitive/text.rs +++ b/core/src/primitive/text.rs @@ -7,7 +7,10 @@ use parley::{ Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, }; -use std::hash::{DefaultHasher, Hash, Hasher}; +use std::{ + collections::VecDeque, + hash::{DefaultHasher, Hash, Hasher}, +}; use swash::{ FontRef, scale::{Render, ScaleContext, Source, StrikeWith}, @@ -19,8 +22,31 @@ pub struct TextData { pub layout_ctx: LayoutContext, scale_ctx: ScaleContext, pub atlas: GlyphAtlas, + spare: VecDeque, } +/// One shaping of some text, and the glyphs placed from it. A buffer holds +/// the one it is drawn as; these are the ones it had before, kept because a +/// container measures a child by drawing it in a box it may not keep, and so +/// comes back to widths it has already asked for. +struct Shaping { + /// A shaping is a function of these three and nothing else, so no widget + /// or buffer identity is involved and two texts of the same words share + /// an answer. + text: String, + key: LayoutKey, + layout: Layout, + placed: Option, +} + +/// How many to keep. Bounding the whole cache rather than each buffer is +/// what makes this a fixed cost instead of one a tree of ten thousand texts +/// pays ten thousand times; the re-asks come from laying out one subtree, so +/// they are close together and few are needed. On `tests/revision_cost.rs` +/// under `SWEEP=1`, the case that cannot hit across frames, 32 is not enough +/// (6.6 ms) and 64 is (4.4 ms). +const SPARE_SHAPINGS: usize = 128; + impl Default for TextData { fn default() -> Self { Self { @@ -28,6 +54,7 @@ impl Default for TextData { layout_ctx: LayoutContext::new(), scale_ctx: ScaleContext::new(), atlas: GlyphAtlas::default(), + spare: VecDeque::new(), } } } @@ -83,6 +110,9 @@ pub struct TextBuffer { text: String, layout: Layout, layout_key: Option, + /// The glyphs placed from `layout`, so drawing this text again at the + /// width it already has places them once. + placed: Option, } #[derive(PartialEq)] @@ -97,6 +127,7 @@ impl TextBuffer { text: text.into(), layout: Layout::new(), layout_key: None, + placed: None, } } @@ -121,15 +152,28 @@ impl TextBuffer { if text != self.text { self.text = text; self.layout_key = None; + self.placed = None; } } /// Invalidates the layout and returns the underlying string for editing. pub fn edit(&mut self) -> &mut String { self.layout_key = None; + self.placed = None; &mut self.text } + /// The glyphs of the shaping it is drawn as, once they are placed. + pub fn rendered(&self) -> Option<&RenderedText> { + self.placed.as_ref() + } + + /// The width its shaping wraps at, and `None` where it does not wrap or + /// has not been shaped. + pub fn wrap_width(&self) -> Option { + self.layout_key.as_ref()?.max_width + } + pub fn size(&self) -> Vec2 { Vec2::new(self.layout.width(), self.layout.height()) } @@ -144,6 +188,23 @@ impl TextBuffer { diag::bump(Counter::TextShapeHits); return; } + let kept = data.take_shaping(&self.text, &layout_key); + if let Some(key) = self.layout_key.take() { + data.keep_shaping(Shaping { + text: self.text.clone(), + key, + layout: std::mem::replace(&mut self.layout, Layout::new()), + placed: self.placed.take(), + }); + } + if let Some(shaping) = kept { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::TextShapeHits); + self.layout = shaping.layout; + self.placed = shaping.placed; + self.layout_key = Some(shaping.key); + return; + } #[cfg(feature = "layout-diagnostics")] diag::bump(Counter::TextShapes); #[cfg(feature = "layout-diagnostics")] @@ -273,26 +334,48 @@ pub struct RenderedText { } impl TextData { - pub fn render( + /// The shaping for this text at this width, taken out of what is kept. + fn take_shaping(&mut self, text: &str, key: &LayoutKey) -> Option { + // From the newest, since a re-ask is usually of something recent. + let at = self + .spare + .iter() + .rposition(|spare| spare.key == *key && spare.text == text)?; + self.spare.remove(at) + } + + fn keep_shaping(&mut self, shaping: Shaping) { + if self.spare.len() >= SPARE_SHAPINGS { + self.spare.pop_front(); + } + self.spare.push_back(shaping); + } + + pub fn render<'b>( &mut self, - buffer: &mut TextBuffer, + buffer: &'b mut TextBuffer, attrs: &TextAttrs, width: Option, - ) -> RenderedText { + ) -> &'b 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 = { - #[cfg(feature = "layout-diagnostics")] - let _place = diag::timer(TimerKind::GlyphPlacement); - self.place(buffer) + let placed = match buffer.placed.take() { + Some(placed) => placed, + None => { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::GlyphPlacements); + #[cfg(feature = "layout-diagnostics")] + let _place = diag::timer(TimerKind::GlyphPlacement); + RenderedText { + glyphs: self.place(buffer), + size: buffer.size(), + color: attrs.color, + } + } }; - RenderedText { - glyphs, - size: buffer.size(), - color: attrs.color, - } + buffer.placed.insert(placed) } } diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index e1a7248..24c5a00 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -239,12 +239,12 @@ impl<'a> Painter<'a> { } } - pub fn render_text( + pub fn render_text<'b>( &mut self, - buffer: &mut TextBuffer, + buffer: &'b mut TextBuffer, attrs: &TextAttrs, width: Option, - ) -> RenderedText { + ) -> &'b RenderedText { #[cfg(feature = "layout-diagnostics")] diag::render_text(self.id, self.rsc.widgets().label(self.id), width); let ui = self.rsc.ui_mut(); diff --git a/src/widget/text/edit.rs b/src/widget/text/edit.rs index 2391cab..5eafe59 100644 --- a/src/widget/text/edit.rs +++ b/src/widget/text/edit.rs @@ -130,7 +130,6 @@ impl<'a> TextEditCtx<'a> { pub fn set(&mut self, text: &str) { let text = self.string(text); self.text.view.buf.set_text(text); - self.text.view.buf.changed = true; self.text.selection = None; } @@ -177,7 +176,6 @@ impl<'a> TextEditCtx<'a> { }; let at = at.min(self.text.view.buf.text().len()); self.text.view.buf.edit().insert_str(at, text); - self.text.view.buf.changed = true; self.set_caret(at + text.len()); } @@ -190,7 +188,6 @@ impl<'a> TextEditCtx<'a> { } let range = sel.text_range(); self.text.view.buf.edit().replace_range(range.clone(), ""); - self.text.view.buf.changed = true; self.set_caret(range.start); true } @@ -268,7 +265,6 @@ impl<'a> TextEditCtx<'a> { fn delete_range(&mut self, start: usize, end: usize) { self.text.view.buf.edit().replace_range(start..end, ""); - self.text.view.buf.changed = true; self.set_caret(start); } diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index 49a3fde..9c1b142 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -14,11 +14,8 @@ pub struct Text { } pub struct TextView { - pub attrs: MutDetect, - pub buf: MutDetect, - // cache - tex: Option, - width: Option, + pub attrs: TextAttrs, + pub buf: TextBuffer, pub hint: Option, } @@ -28,19 +25,13 @@ impl TextView { } pub fn wrap_width(&self) -> Option { - self.width + self.buf.wrap_width() } } impl TextView { pub fn new(buf: TextBuffer, attrs: TextAttrs, hint: Option) -> Self { - Self { - attrs: attrs.into(), - buf: buf.into(), - tex: None, - width: None, - hint, - } + Self { attrs, buf, hint } } /// region where the text should be draw @@ -52,22 +43,20 @@ impl TextView { .align(self.align) } + /// The text shaped for the width it is drawn in. The buffer keeps its + /// answers under the attrs too, so changing those asks a new question + /// rather than invalidating anything. fn render(&mut self, painter: &mut Painter) -> &RenderedText { let width = if self.attrs.wrap { Some(painter.px_len(Axis::X)) } else { None }; - if width != self.width || self.tex.is_none() || self.attrs.changed || self.buf.changed { - self.width = width; - self.tex = Some(painter.render_text(&mut self.buf, &self.attrs, width)); - self.attrs.changed = false; - self.buf.changed = false; - } - self.tex.as_ref().unwrap() + painter.render_text(&mut self.buf, &self.attrs, width) } + pub fn tex(&self) -> Option<&RenderedText> { - self.tex.as_ref() + self.buf.rendered() } /// Draws the text, and says where the glyphs went and what they use. pub fn draw(&mut self, painter: &mut Painter) -> (UiRegion, Size) { diff --git a/tests/layout_diagnostics.rs b/tests/layout_diagnostics.rs index 3748198..ac404ab 100644 --- a/tests/layout_diagnostics.rs +++ b/tests/layout_diagnostics.rs @@ -109,10 +109,15 @@ fn warm(seed: u64, depth: usize) -> (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(); + // 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, total {:.1} ms", + "{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::(), ); #[cfg(feature = "layout-diagnostics")] diff --git a/tests/revision_cost.rs b/tests/revision_cost.rs new file mode 100644 index 0000000..2007362 --- /dev/null +++ b/tests/revision_cost.rs @@ -0,0 +1,203 @@ +//! What a resize frame costs and what it holds, on a tree the revision before +//! #16 also builds. +//! +//! Deliberately written in the API subset `43ce8c7` and this branch share, so +//! the same source can be dropped into an old worktree and measured there: +//! that is the only like-for-like comparison with the code the retained +//! layout replaced. The random tree cannot carry one, because the generator +//! itself changed with the work. +//! +//! ROWS=40 FRAMES=500 cargo test --release --test revision_cost \ +//! -- --ignored --nocapture resize_cost +//! ROWS=2000 cargo test --release --test revision_cost \ +//! -- --ignored --nocapture text_memory +//! +//! Wall time on this machine varies with CPU frequency; take the number from +//! `perf stat -e instructions:u` on the test binary directly. + +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(Len::abs(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)); + } + + // Two widths in turn is the friendly case for anything that remembers an + // answer, so `SWEEP=1` never repeats one -- a drag rather than a toggle. + let sweep = env("SWEEP", 0_usize) != 0; + 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); + } + 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. + for _ in 0..10 { + let _ = h.rsc.widgets_mut().get_dyn_mut(paragraphs[0]); + h.frame(); + } + report("after settling"); +}