diff --git a/moli-layout/src/pass.rs b/moli-layout/src/pass.rs index 94eb181d6c..13570e6e64 100644 --- a/moli-layout/src/pass.rs +++ b/moli-layout/src/pass.rs @@ -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::() + + world.box_cache_slots.capacity() * std::mem::size_of::(), + ); + } let phase_started = Instant::now(); let mut embedded_frames = HashMap::new(); for (index, layout_box) in world.boxes.iter().enumerate() { diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index 954526b239..c1ea007bca 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -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); } } diff --git a/moli-layout/src/taffy_tree/measurement_tests.rs b/moli-layout/src/taffy_tree/measurement_tests.rs index 5800b95134..ced4e47943 100644 --- a/moli-layout/src/taffy_tree/measurement_tests.rs +++ b/moli-layout/src/taffy_tree/measurement_tests.rs @@ -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, 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::() + + world.box_cache_slots.capacity() * std::mem::size_of::(); + 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); +} diff --git a/moli-layout/src/world.rs b/moli-layout/src/world.rs index e980d2e116..b23b7e4234 100644 --- a/moli-layout/src/world.rs +++ b/moli-layout/src/world.rs @@ -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>, - /// 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, + /// 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, /// 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, @@ -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 } diff --git a/moli-renderer-v8/src/script_vm/tests/realtime_working_sets/table.rs b/moli-renderer-v8/src/script_vm/tests/realtime_working_sets/table.rs index 42dbfb5dbc..14bba30429 100644 --- a/moli-renderer-v8/src/script_vm/tests/realtime_working_sets/table.rs +++ b/moli-renderer-v8/src/script_vm/tests/realtime_working_sets/table.rs @@ -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:?}");