perf(editor): cache skia text measurements across reconversions

SkiaMeasure builds + lays out a fresh Paragraph per text leaf — the dominant
cost of the taffy layout pass that re-runs on every editor_state reconversion
(drag / resize / typing). Text rarely changes between reconversions, so wrap the
backend in a memo keyed on the full shaping input; it lives in the existing
thread_local so the cache spans passes. Native only — the web estimate backend
is already cheap, so it stays unwrapped.
This commit is contained in:
Kayshen-X 2026-06-19 15:13:23 +08:00
parent e7c0f3f292
commit 7b80dc51e7
3 changed files with 213 additions and 1 deletions

View file

@ -50,9 +50,15 @@ thread_local! {
}
/// Real skia paragraph shaper — native + web-skia builds (`skia-measure`, default).
/// Wrapped in a memoizing cache: paragraph shaping is the dominant layout cost,
/// and repeat reconversions (drag / resize / colour edits) re-measure identical
/// text, so the cache turns those into hash lookups. (The estimate backend below
/// is already cheap, so it is left unwrapped.)
#[cfg(feature = "skia-measure")]
fn make_measure_backend() -> Rc<dyn MeasureBackend> {
Rc::new(jian_skia::SkiaMeasure::new())
Rc::new(crate::measure_cache::CachingMeasureBackend::new(Rc::new(
jian_skia::SkiaMeasure::new(),
)))
}
/// Skia-free estimate backend — the CanvasKit web build links no jian-skia /

View file

@ -24,6 +24,11 @@ mod authored_geometry;
mod effects;
mod layout_repair;
mod layout_scene;
// Only the real-shaper (`skia-measure`) build benefits from caching; the
// estimate backend is already cheap, so the module is gated to avoid dead code
// under the CanvasKit (no-skia-measure) web build.
#[cfg(feature = "skia-measure")]
mod measure_cache;
mod path_bounds;
mod style_payload;
mod text_style;

View file

@ -0,0 +1,201 @@
//! Memoizing wrapper around a [`MeasureBackend`].
//!
//! `editor_state_to_layout_scene` rebuilds the layout scene whenever the
//! document is dirty, re-running the taffy flex solve — which calls
//! `MeasureBackend::measure` once per text leaf. Under the real skia shaper
//! (`SkiaMeasure`) each call builds and lays out a fresh `Paragraph`, the single
//! most expensive op in the layout pass. Text rarely changes between
//! reconversions (a drag / resize / colour edit leaves every string identical),
//! so memoizing on the full shaping input turns repeat measurements into hash
//! lookups. The backend lives in a thread-local that outlives individual passes,
//! so the cache spans reconversions.
//!
//! Measurement is a pure function of its inputs, so cached entries never go
//! stale; the cache only grows with the set of distinct (text, font, width)
//! tuples in play and is bounded by [`MAX_ENTRIES`].
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use jian_core::layout::measure::{
FontStyleKind, MeasureBackend, MeasureRequest, MeasureResult,
};
/// Cap on distinct cached measurements. A large multi-page design has at most a
/// few thousand distinct text leaves; past this, drop the whole cache rather
/// than track LRU order — a full refill is one layout pass, far cheaper than
/// per-measure bookkeeping.
const MAX_ENTRIES: usize = 8192;
/// Owned, hashable mirror of a `StyledRun`. `f32` fields are keyed by their bit
/// pattern (`to_bits`) since `f32` is not `Hash`/`Eq`; font sizes / spacings are
/// never `NaN` in practice, so distinct bit patterns map to distinct keys.
#[derive(PartialEq, Eq, Hash)]
struct RunKey {
text: String,
font_family: Option<String>,
font_size_bits: u32,
font_weight: u16,
italic: bool,
letter_spacing_bits: u32,
}
#[derive(PartialEq, Eq, Hash)]
struct MeasureKey {
runs: Vec<RunKey>,
line_height_bits: u32,
max_width_bits: Option<u32>,
}
impl MeasureKey {
fn from_request(req: &MeasureRequest<'_>) -> Self {
let runs = req
.runs
.iter()
.map(|run| RunKey {
text: run.text.to_string(),
font_family: run.font_family.map(str::to_string),
font_size_bits: run.font_size.to_bits(),
font_weight: run.font_weight,
italic: matches!(run.font_style, FontStyleKind::Italic),
letter_spacing_bits: run.letter_spacing.to_bits(),
})
.collect();
MeasureKey {
runs,
line_height_bits: req.line_height.to_bits(),
max_width_bits: req.max_width.map(f32::to_bits),
}
}
}
/// Wraps a `MeasureBackend`, memoizing results keyed on the full shaping input.
/// `&self` measurement (Taffy hands an immutable context) uses interior
/// mutability via `RefCell`, the single-threaded pattern the trait documents.
pub struct CachingMeasureBackend {
inner: Rc<dyn MeasureBackend>,
cache: RefCell<HashMap<MeasureKey, MeasureResult>>,
}
impl CachingMeasureBackend {
pub fn new(inner: Rc<dyn MeasureBackend>) -> Self {
Self {
inner,
cache: RefCell::new(HashMap::new()),
}
}
}
impl MeasureBackend for CachingMeasureBackend {
fn measure(&self, req: &MeasureRequest<'_>) -> MeasureResult {
let key = MeasureKey::from_request(req);
if let Some(hit) = self.cache.borrow().get(&key) {
return *hit;
}
let result = self.inner.measure(req);
let mut cache = self.cache.borrow_mut();
// Cheap bound: a runaway distinct-key set just resets (a full refill is
// one layout pass) rather than paying LRU bookkeeping on every measure.
if cache.len() >= MAX_ENTRIES {
cache.clear();
}
cache.insert(key, result);
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use jian_core::layout::measure::StyledRun;
/// Records how many times the wrapped backend was actually invoked, and
/// returns an input-derived result so pass-through is verifiable.
struct CountingBackend {
calls: Cell<usize>,
}
impl MeasureBackend for CountingBackend {
fn measure(&self, req: &MeasureRequest<'_>) -> MeasureResult {
self.calls.set(self.calls.get() + 1);
let width: f32 = req.runs.iter().map(|r| r.text.len() as f32).sum();
MeasureResult {
width,
height: 4.0,
line_count: 1,
baseline: 3.2,
}
}
}
fn run(text: &str, size: f32, weight: u16) -> StyledRun<'_> {
StyledRun {
text,
font_family: None,
font_size: size,
font_weight: weight,
font_style: FontStyleKind::Normal,
letter_spacing: 0.0,
}
}
#[test]
fn identical_requests_hit_the_cache() {
let counter = Rc::new(CountingBackend {
calls: Cell::new(0),
});
let backend = CachingMeasureBackend::new(counter.clone());
let runs = [run("hello", 16.0, 400)];
let req = MeasureRequest {
runs: &runs,
line_height: 0.0,
max_width: None,
};
let first = backend.measure(&req);
let second = backend.measure(&req);
assert_eq!(counter.calls.get(), 1, "second identical measure must hit the cache");
assert_eq!(first.width, second.width);
assert_eq!(first.width, 5.0, "result must pass through from the inner backend");
}
#[test]
fn distinct_inputs_each_measure_once() {
let counter = Rc::new(CountingBackend {
calls: Cell::new(0),
});
let backend = CachingMeasureBackend::new(counter.clone());
// Each axis that feeds shaping must produce a distinct cache key.
let cases: &[(&str, f32, u16, f32, Option<f32>)] = &[
("hello", 16.0, 400, 0.0, None),
("world", 16.0, 400, 0.0, None), // text differs
("hello", 18.0, 400, 0.0, None), // size differs
("hello", 16.0, 700, 0.0, None), // weight differs
("hello", 16.0, 400, 0.0, Some(80.0)), // max_width differs
("hello", 16.0, 400, 2.0, None), // line_height differs
];
for (text, size, weight, line_height, max_width) in cases {
let runs = [run(text, *size, *weight)];
let req = MeasureRequest {
runs: &runs,
line_height: *line_height,
max_width: *max_width,
};
backend.measure(&req);
// Re-measuring the same case must not add a call.
backend.measure(&req);
}
assert_eq!(
counter.calls.get(),
cases.len(),
"each distinct shaping input measures exactly once"
);
}
}