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:
parent
e7c0f3f292
commit
7b80dc51e7
|
|
@ -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 /
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
201
crates/op-pen-loader/src/measure_cache.rs
Normal file
201
crates/op-pen-loader/src/measure_cache.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue