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

Use a small box index and materialize pass-local Taffy caches only for actual stores. Preserve viewport and selective invalidation semantics, avoid hidden-mode allocations, and expose optional allocation diagnostics.
This commit is contained in:
ldm0
2026-10-15 17:28:10 +08:00
parent 96cc04d1e8
commit 6ca12dfc2a
5 changed files with 136 additions and 13 deletions
+13
View File
@@ -326,6 +326,19 @@ where
let phase_started = Instant::now();
let numeric_metrics = compute_world_layout_with_scrollbars(&mut world, request.viewport);
let numeric_layout_elapsed = phase_started.elapsed();
if moli_trace::cpu_profile_enabled() {
tracing::info!(
target: "moli_cpu_profile",
stage = "layout_numeric_cache",
box_count = world.len(),
cache_count = world.box_caches.len(),
cache_capacity = world.box_caches.capacity(),
box_index_slots = world.box_cache_slots.len(),
estimated_cache_bytes = world.box_caches.capacity()
* std::mem::size_of::<taffy::Cache>()
+ world.box_cache_slots.capacity() * std::mem::size_of::<usize>(),
);
}
let phase_started = Instant::now();
let mut embedded_frames = HashMap::new();
for (index, layout_box) in world.boxes.iter().enumerate() {
+8 -7
View File
@@ -4,7 +4,7 @@ use parley::{AlignmentOptions, PositionedLayoutItem, YieldData};
use style::Atom;
use taffy::{
AbsoluteAxis, AlignContent, AlignContentKeyword, AlignmentSafety, AutoSizeBehavior,
AvailableSpace, BlockContext, BlockFormattingContext, BoxSizing, Cache, CacheTree, Clear,
AvailableSpace, BlockContext, BlockFormattingContext, BoxSizing, CacheTree, Clear,
DetailedGridInfo, Dimension, Display, FlexDirection, FloatDirection, Layout,
LayoutBlockContainer, LayoutFlexboxContainer, LayoutGridContainer, LayoutInput, LayoutOutput,
LayoutPartialTree, LeafLayoutContext, Line, MaybeMath, MaybeResolve, NodeId, Point,
@@ -1721,13 +1721,17 @@ where
if self.is_viewport_taffy_node(node_id) {
self.viewport_layout.cache.get(inputs)
} else {
self.box_caches
.get(LayoutBoxId::from_taffy(node_id).index())
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 Cache::store ignores this mode. Do not materialize
// measurement storage for an operation that cannot write it.
return;
}
if self.is_viewport_taffy_node(node_id) {
self.viewport_layout.cache.store(inputs, output);
} else {
@@ -1738,10 +1742,7 @@ where
// first-class overflow dependencies.
self.record_numeric_layout_touch(id);
}
if self.box_caches.len() <= id.index() {
self.box_caches.resize_with(self.boxes.len(), Cache::new);
}
self.box_caches[id.index()].store(inputs, output);
self.numeric_cache_mut(id).store(inputs, output);
}
}
@@ -423,3 +423,78 @@ fn numeric_cache_growth_keeps_box_viewport_and_invalidation_owners_distinct() {
assert_eq!(world.cache_get(viewport, &input), None);
assert_eq!(world.cache_get(sibling, &input), Some(sibling_output));
}
fn many_flattened_boxes() -> (LayoutWorld<u8>, LayoutBoxId, LayoutInput, LayoutOutput) {
let mut world = text_world(false);
let output = measure(&mut world, inputs(), true);
let style = world.boxes[0].style.clone();
for _ in 0..4096 {
let mut text = LayoutWorld::new_box(
None,
None,
None,
"flattened text".to_owned(),
None,
None,
None,
LayoutBoxKind::Text,
style.clone(),
Some("text".into()),
);
text.inline_flattened = true;
text.inline_context_owner = Some(world.root());
world.boxes.push(text);
}
let sibling = LayoutBoxId::from_index(world.len());
let mut extra = text_world(false).boxes.remove(0);
extra.source = Some(1);
world.boxes.push(extra);
let input = LayoutInput {
run_mode: RunMode::PerformLayout,
..inputs()
};
(world, sibling, input, output)
}
#[test]
fn numeric_cache_allocation_follows_actual_stores_in_a_large_flattened_tree() {
let (mut world, sibling, input, output) = many_flattened_boxes();
world.cache_store(world.root().to_taffy(), &input, output);
world.cache_store(sibling.to_taffy(), &input, output);
assert_eq!(
world.cache_get(world.root().to_taffy(), &input),
Some(output)
);
assert_eq!(world.cache_get(sibling.to_taffy(), &input), Some(output));
let bytes = world.box_caches.capacity() * std::mem::size_of::<taffy::Cache>()
+ world.box_cache_slots.capacity() * std::mem::size_of::<usize>();
eprintln!(
"numeric-cache boxes={} initialized={} allocated_slots={} visible_capacity_bytes={bytes}",
world.len(),
world
.box_caches
.iter()
.filter(|cache| !cache.is_empty())
.count(),
world.box_caches.capacity(),
);
// Two numeric consumers must not initialize thousands of 792-byte caches
// for text boxes whose geometry comes from their containing IFC.
assert!(bytes <= 64 * 1024, "{bytes} bytes for two numeric stores");
world.cache_clear(world.root().to_taffy());
assert_eq!(world.cache_get(world.root().to_taffy(), &input), None);
assert_eq!(world.cache_get(sibling.to_taffy(), &input), Some(output));
}
#[test]
fn numeric_hidden_cache_store_does_not_allocate_measurement_storage() {
let (mut world, _, input, output) = many_flattened_boxes();
let input = LayoutInput {
run_mode: RunMode::PerformHiddenLayout,
..input
};
world.cache_store(world.root().to_taffy(), &input, output);
assert_eq!(world.cache_get(world.root().to_taffy(), &input), None);
assert_eq!(world.box_caches.capacity(), 0);
assert_eq!(world.box_cache_slots.capacity(), 0);
}
+39 -5
View File
@@ -630,11 +630,12 @@ where
/// large empty slots out of persistent box objects. Retention extracts
/// only actual owners into the existing sparse working-set storage.
pub(crate) inline_layouts: Vec<Option<InlineFormattingContext>>,
/// Numeric caches live for this world only. Allocate dense slots on the
/// first actual store, after construction and numeric-only box growth.
/// Keeping empty caches out of LayoutBox avoids retaining their fixed
/// measurement arrays with an otherwise reusable construction graph.
/// Numeric caches live for this world only. Materialize a cache only for
/// an actual store; flattened text boxes do not need measurement arrays.
pub(crate) box_caches: Vec<Cache>,
/// Box-indexed lookup into the compact cache storage. usize::MAX means
/// that no cache has been stored for this box in the current world.
pub(crate) box_cache_slots: Vec<usize>,
/// Winning collapsed-table edges, computed by the shared table formatter
/// and consumed by paint projection. Only actual table owners have entries.
pub(crate) collapsed_table_borders: HashMap<LayoutBoxId, crate::table::CollapsedTableBorders>,
@@ -688,6 +689,7 @@ where
boxes,
inline_layouts: Vec::new(),
box_caches: Vec::new(),
box_cache_slots: Vec::new(),
collapsed_table_borders: HashMap::new(),
resolved_grid_tracks: HashMap::new(),
source_mapping,
@@ -714,7 +716,11 @@ where
}
pub(crate) fn clear_layout_caches(&mut self, id: LayoutBoxId) {
if let Some(cache) = self.box_caches.get_mut(id.index()) {
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 {
@@ -722,6 +728,34 @@ where
}
}
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() {
// A capacity hint, not eligibility: numeric growth or an
// unusual caller may still store any valid box afterwards.
// Reserve without initializing caches for unvisited boxes.
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
}
@@ -117,7 +117,7 @@ fn realtime_table_reuses_actual_box_storage_within_the_default_budget() {
const ITEMS: usize = 256;
let mut vm = table_fixture(ITEMS);
// A smaller real table admits the actual box storage without increasing
// the production limit. The 1024-item fixture covers compact fallback.
// the production limit. The 1024-item fixture covers full-scale admission.
let initial = vm.eval(READ).unwrap();
let cold = main_stats(&vm).unwrap();
assert!(cold.retained_boxes >= 2 * ITEMS, "{cold:?}");