perf(layout): allocate numeric caches only for stored boxes

Keep Taffy caches in pass-owned compact storage instead of every LayoutBox. Allocate entries on actual stores, preserve viewport and per-box invalidation, and skip stores that Taffy ignores for hidden layout.

Extracted from 96cc04d1e8 and 6ca12dfc2a without retained working sets, new layout policies, or renderer lifecycle changes.
This commit is contained in:
ldm0
2026-10-17 20:06:22 +08:00
committed by Donough Liu
parent be8f3f41e5
commit 412f6c5d38
3 changed files with 208 additions and 17 deletions
+15 -8
View File
@@ -113,7 +113,7 @@ impl PreparedWorldLayout {
}
self.feedback_invalidation_marks[id.index()] = true;
invalidated.push(id);
world.boxes[id.index()].clear_layout_caches();
world.clear_layout_caches(id);
if let Some(parent) = world.boxes[id.index()].layout_parent {
self.feedback_invalidation_worklist.push(parent);
}
@@ -138,8 +138,9 @@ pub(crate) fn prepare_world_layout<N>(
where
N: Copy + Debug + Eq + Hash,
{
for layout_box in &mut world.boxes {
layout_box.clear_layout_caches();
for index in 0..world.boxes.len() {
world.clear_layout_caches(LayoutBoxId::from_index(index));
let layout_box = &mut world.boxes[index];
layout_box.unrounded_layout = Layout::with_order(0);
layout_box.final_layout = Layout::with_order(0);
layout_box.layout_parent = None;
@@ -1720,13 +1721,16 @@ where
if self.is_viewport_taffy_node(node_id) {
self.viewport_layout.cache.get(inputs)
} else {
self.boxes[LayoutBoxId::from_taffy(node_id).index()]
.cache
.get(inputs)
self.numeric_cache(LayoutBoxId::from_taffy(node_id))
.and_then(|cache| cache.get(inputs))
}
}
fn cache_store(&mut self, node_id: NodeId, inputs: &LayoutInput, output: LayoutOutput) {
if inputs.run_mode == RunMode::PerformHiddenLayout {
// Taffy's store ignores this mode; avoid allocating an empty cache.
return;
}
if self.is_viewport_taffy_node(node_id) {
self.viewport_layout.cache.store(inputs, output);
} else {
@@ -1737,7 +1741,7 @@ where
// first-class overflow dependencies.
self.record_numeric_layout_touch(id);
}
self.boxes[id.index()].cache.store(inputs, output);
self.numeric_cache_mut(id).store(inputs, output);
}
}
@@ -1745,7 +1749,7 @@ where
if self.is_viewport_taffy_node(node_id) {
self.viewport_layout.cache.clear();
} else {
self.boxes[LayoutBoxId::from_taffy(node_id).index()].clear_layout_caches();
self.clear_layout_caches(LayoutBoxId::from_taffy(node_id));
}
}
}
@@ -3199,6 +3203,9 @@ fn measure_text(
}
}
#[cfg(test)]
mod cache_storage_tests;
#[cfg(test)]
mod tests {
use super::{inline_percentage_basis, round_layout_to_css_subpixels};
@@ -0,0 +1,145 @@
use super::*;
use crate::{LayoutDisplay, PaintColor, ResolvedLayoutStyle};
use taffy::WritingMode;
fn block(source: usize) -> crate::LayoutBox<usize> {
LayoutWorld::new_box(
Some(source),
None,
None,
format!("cache-block-{source}"),
None,
None,
None,
LayoutBoxKind::PrincipalBlock,
ResolvedLayoutStyle::synthetic(
LayoutDisplay::Block,
Style::default(),
PaintColor::TRANSPARENT,
),
None,
)
}
fn input() -> LayoutInput {
LayoutInput {
run_mode: RunMode::PerformLayout,
sizing_mode: SizingMode::InherentSize,
sizing_purpose: SizingPurpose::Layout,
axis: taffy::RequestedAxis::Both,
known_dimensions: Size {
width: Some(60.0),
height: Some(20.0),
},
definite_dimensions: Size {
width: Some(60.0),
height: Some(20.0),
},
parent_size: Size {
width: Some(240.0),
height: None,
},
parent_writing_mode: WritingMode::HorizontalTb,
available_space: Size {
width: AvailableSpace::Definite(60.0),
height: AvailableSpace::MaxContent,
},
block_auto_behavior: AutoSizeBehavior::FitContent,
vertical_margins_are_collapsible: taffy::Line::FALSE,
}
}
fn output(width: f32) -> LayoutOutput {
let size = Size {
width,
height: 20.0,
};
LayoutOutput::from_sizes(size, size)
}
#[test]
fn numeric_cache_growth_keeps_box_viewport_and_invalidation_owners_distinct() {
let mut world = LayoutWorld::new(block(0), false);
let input = input();
let root = world.root().to_taffy();
assert_eq!(world.cache_get(root, &input), None);
world.cache_store(root, &input, output(60.0));
assert_eq!(world.cache_get(root, &input), Some(output(60.0)));
assert_eq!(
world.cache_get(
root,
&LayoutInput {
sizing_purpose: SizingPurpose::IntrinsicContribution,
..input
}
),
None
);
// Numeric box growth reuses the old viewport ID for a real box. Its
// cache starts empty, while the viewport's own cache survives the growth.
let sibling = world.viewport_taffy_node();
world.cache_store(sibling, &input, output(70.0));
world.boxes.push(block(1));
assert_eq!(world.cache_get(sibling, &input), None);
let viewport = world.viewport_taffy_node();
assert_eq!(world.cache_get(viewport, &input), Some(output(70.0)));
world.cache_store(sibling, &input, output(80.0));
world.cache_clear(root);
assert_eq!(world.cache_get(root, &input), None);
assert_eq!(world.cache_get(sibling, &input), Some(output(80.0)));
assert_eq!(world.cache_get(viewport, &input), Some(output(70.0)));
world.cache_clear(viewport);
assert_eq!(world.cache_get(viewport, &input), None);
assert_eq!(world.cache_get(sibling, &input), Some(output(80.0)));
}
#[test]
fn numeric_cache_allocation_follows_actual_stores_in_a_large_flattened_tree() {
let mut world = LayoutWorld::new(block(0), false);
for source in 1..=4096 {
let mut text = block(source);
text.inline_flattened = true;
text.inline_context_owner = Some(world.root());
world.boxes.push(text);
}
let sibling = LayoutBoxId::from_index(world.len());
world.boxes.push(block(4097));
let input = input();
let root = world.root().to_taffy();
world.cache_store(root, &input, output(60.0));
world.cache_store(sibling.to_taffy(), &input, output(80.0));
assert_eq!(world.cache_get(root, &input), Some(output(60.0)));
assert_eq!(
world.cache_get(sibling.to_taffy(), &input),
Some(output(80.0))
);
let allocated_bytes = world.box_caches.capacity() * std::mem::size_of::<taffy::Cache>()
+ world.box_cache_slots.capacity() * std::mem::size_of::<usize>();
assert!(
allocated_bytes < 64 * 1024,
"4098 construction boxes with two stores should not allocate dense caches: {allocated_bytes} bytes"
);
// Admission hints must not prevent a later valid store on a flattened box.
let previously_unvisited = LayoutBoxId::from_index(4096).to_taffy();
world.cache_store(previously_unvisited, &input, output(90.0));
assert_eq!(
world.cache_get(previously_unvisited, &input),
Some(output(90.0))
);
}
#[test]
fn hidden_numeric_store_does_not_materialize_empty_cache_storage() {
let mut world = LayoutWorld::new(block(0), false);
let input = LayoutInput {
run_mode: RunMode::PerformHiddenLayout,
..input()
};
world.cache_store(world.root().to_taffy(), &input, output(60.0));
world.cache_store(world.viewport_taffy_node(), &input, output(70.0));
assert!(world.box_caches.is_empty());
assert!(world.box_cache_slots.is_empty());
assert_eq!(world.cache_get(world.root().to_taffy(), &input), None);
}
+48 -9
View File
@@ -279,7 +279,6 @@ pub struct LayoutBox<N> {
/// This is browser-owned canonical geometry rather than a retained Taffy
/// object. It is frozen with the rest of the layout tree for CSSOM reads.
pub(crate) resolved_grid_tracks: Option<LayoutResolvedGridTracks>,
pub(crate) cache: Cache,
pub(crate) table_measure_cache: Option<Box<crate::table::TableMeasureCache>>,
pub(crate) unrounded_layout: Layout,
pub(crate) final_layout: Layout,
@@ -551,13 +550,6 @@ impl Default for ViewportLayoutState {
}
impl<N> LayoutBox<N> {
pub(crate) fn clear_layout_caches(&mut self) {
self.cache.clear();
if let Some(cache) = &mut self.table_measure_cache {
cache.clear();
}
}
pub fn kind(&self) -> LayoutBoxKind {
self.kind
}
@@ -647,6 +639,11 @@ where
N: Copy + Debug + Eq + Hash,
{
pub(crate) boxes: Vec<LayoutBox<N>>,
/// Pass-local numeric caches, created only for boxes with actual stores.
/// Flattened text boxes need no empty Taffy measurement arrays.
pub(crate) box_caches: Vec<Cache>,
/// Box-indexed lookup into compact cache storage; usize::MAX is absent.
pub(crate) box_cache_slots: Vec<usize>,
pub(crate) source_mapping: HashMap<N, LayoutBoxId>,
pub(crate) display_contents_mapping: HashMap<N, Vec<LayoutBoxId>>,
pub(crate) root: LayoutBoxId,
@@ -674,6 +671,8 @@ where
pub(crate) fn new(root: LayoutBox<N>, root_is_document_element: bool) -> Self {
Self {
boxes: vec![root],
box_caches: Vec::new(),
box_cache_slots: Vec::new(),
source_mapping: HashMap::new(),
display_contents_mapping: HashMap::new(),
root: LayoutBoxId::from_index(0),
@@ -691,6 +690,47 @@ where
}
}
pub(crate) fn clear_layout_caches(&mut self, id: LayoutBoxId) {
if let Some(cache) = self
.box_cache_slots
.get(id.index())
.and_then(|slot| self.box_caches.get_mut(*slot))
{
cache.clear();
}
if let Some(cache) = &mut self.boxes[id.index()].table_measure_cache {
cache.clear();
}
}
pub(crate) fn numeric_cache(&self, id: LayoutBoxId) -> Option<&Cache> {
self.box_cache_slots
.get(id.index())
.and_then(|slot| self.box_caches.get(*slot))
}
pub(crate) fn numeric_cache_mut(&mut self, id: LayoutBoxId) -> &mut Cache {
self.box_cache_slots.resize(self.boxes.len(), usize::MAX);
let mut slot = self.box_cache_slots[id.index()];
if slot == usize::MAX {
if self.box_caches.is_empty() {
// Reserve without initializing caches for unvisited boxes.
// This is a hint, not an eligibility restriction: a caller
// may subsequently store any valid construction/numeric box.
let candidates = self
.boxes
.iter()
.filter(|layout_box| !layout_box.inline_flattened)
.count();
self.box_caches.reserve_exact(candidates);
}
slot = self.box_caches.len();
self.box_caches.push(Cache::new());
self.box_cache_slots[id.index()] = slot;
}
&mut self.box_caches[slot]
}
pub fn root(&self) -> LayoutBoxId {
self.root
}
@@ -1206,7 +1246,6 @@ where
collapsed_table_border_part: false,
inline_formatting_context: false,
resolved_grid_tracks: None,
cache: Cache::new(),
table_measure_cache: None,
unrounded_layout: Layout::with_order(0),
final_layout: Layout::with_order(0),