Shape a text once per width, not once per ask

A container measures a child by drawing it in a box it may not keep, so
one layout asks a text for a dozen widths and comes back to widths it
has already had -- the hottest text in the depth-8 tree draws 32 times.
Each ask re-ran the shaper, because the two caches in front of it held
one entry each and a trial width alternating with a final width evicts
the answer about to be wanted again. `perf record` put 63% of a resize
frame in text and 0.9% in `draw_inner`.

So keep more than one: a bounded store of shapings on `TextData`, keyed
by the text, the attrs and the width, holding the parley layout and the
glyphs placed from it. Bounding the store rather than each buffer is
what keeps it a fixed cost -- +4 MB on a tree of 4,000 texts, which is
19 MB less than the code before #16 holds after the same resizes.

`TextBuffer` now holds the glyphs of the shaping it is drawn as, which
is where `TextView::tex` was. That leaves one place to invalidate rather
than two, so the `MutDetect` flags on a view's text and attrs have no
reader and go, along with the `buf.changed = true` after every edit.

On a 40-row tree of distinct random paragraphs, 500 resize frames:
124.2M instructions per frame before, 17.7M after, and 45.9M when the
width never repeats. The five reference renders and the resize render
are byte-identical, and the 100-seed sweep passes.

`tests/revision_cost.rs` is that tree, written in the API subset
`43ce8c7` shares so the same source measures the code this replaced.
Report the worst frame and p99 beside the median, since a stutter is
what somebody sees. Count glyph placements, and count a text render per
ask rather than per shaping, so the store cannot hide how many times a
layout drew the same text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-14 18:49:45 -04:00
1 parent f1a47e9b7b
commit e5f8b6b244
7 files changed
+316 -38

No files matched your search

+3 -1
View File
@@ -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",
];
}
+91 -8
View File
@@ -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<UiColor>,
scale_ctx: ScaleContext,
pub atlas: GlyphAtlas,
spare: VecDeque<Shaping>,
}
/// 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<UiColor>,
placed: Option<RenderedText>,
}
/// 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<UiColor>,
layout_key: Option<LayoutKey>,
/// The glyphs placed from `layout`, so drawing this text again at the
/// width it already has places them once.
placed: Option<RenderedText>,
}
#[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<f32> {
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<Shaping> {
// 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<f32>,
) -> 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 = {
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);
self.place(buffer)
};
RenderedText {
glyphs,
glyphs: self.place(buffer),
size: buffer.size(),
color: attrs.color,
}
}
};
buffer.placed.insert(placed)
}
}
+3 -3
View File
@@ -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<f32>,
) -> 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();
-4
View File
@@ -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);
}
+10 -21
View File
@@ -14,11 +14,8 @@ pub struct Text {
}
pub struct TextView {
pub attrs: MutDetect<TextAttrs>,
pub buf: MutDetect<TextBuffer>,
// cache
tex: Option<RenderedText>,
width: Option<f32>,
pub attrs: TextAttrs,
pub buf: TextBuffer,
pub hint: Option<StrongWidget>,
}
@@ -28,19 +25,13 @@ impl TextView {
}
pub fn wrap_width(&self) -> Option<f32> {
self.width
self.buf.wrap_width()
}
}
impl TextView {
pub fn new(buf: TextBuffer, attrs: TextAttrs, hint: Option<StrongWidget>) -> 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) {
+6 -1
View File
@@ -109,10 +109,15 @@ fn warm(seed: u64, depth: usize) -> (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, 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::<f64>(),
);
#[cfg(feature = "layout-diagnostics")]
+203
View File
@@ -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<T: std::str::FromStr>(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<WidgetId> {
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::<f64>()
);
}
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");
}