Skip to main content

apace_dom/
profile.rs

1//! Opt-in, bounded engine phase measurements. Samples are inclusive; nested
2//! text work and parallel worker totals must not be added to wall time.
3use std::sync::{Arc, Mutex};
4use std::time::Duration;
5use web_time::Instant;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8#[repr(usize)]
9pub enum Phase {
10    Style,
11    Damage,
12    Construct,
13    PostConstruct,
14    BoxLayout,
15    Transforms,
16    PaintTree,
17    Cleanup,
18    Hover,
19    Subdocuments,
20    TextShape,
21    TextBreak,
22    TextAlign,
23}
24impl Phase {
25    pub const ALL: [Self; 13] = [
26        Self::Style,
27        Self::Damage,
28        Self::Construct,
29        Self::PostConstruct,
30        Self::BoxLayout,
31        Self::Transforms,
32        Self::PaintTree,
33        Self::Cleanup,
34        Self::Hover,
35        Self::Subdocuments,
36        Self::TextShape,
37        Self::TextBreak,
38        Self::TextAlign,
39    ];
40}
41#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42pub struct Sample {
43    pub calls: u64,
44    /// One per stage/pass; TextShape counts prepared UTF-8 bytes, not glyphs.
45    pub units: u64,
46    pub elapsed: Duration,
47    pub max: Duration,
48}
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50pub struct Snapshot([Sample; Phase::ALL.len()]);
51impl Snapshot {
52    pub fn sample(&self, phase: Phase) -> Sample {
53        self.0[phase as usize]
54    }
55    pub fn iter(&self) -> impl Iterator<Item = (Phase, Sample)> + '_ {
56        Phase::ALL
57            .into_iter()
58            .map(|phase| (phase, self.sample(phase)))
59    }
60}
61#[derive(Default)]
62struct State {
63    epoch: u64,
64    samples: Snapshot,
65}
66/// A shared fixed-size collector, safe for parallel construction workers. No
67/// node, text, frame history or application callback is retained. None on a
68/// document avoids collector allocation, locks and all profiling clock reads.
69#[derive(Clone, Default)]
70pub struct Profile(Arc<Mutex<State>>);
71impl Profile {
72    pub fn snapshot(&self) -> Snapshot {
73        self.0.lock().unwrap_or_else(|e| e.into_inner()).samples
74    }
75    /// Retire in-flight spans atomically with clearing counters. Epochs never wrap.
76    pub fn reset(&self) -> bool {
77        let mut state = self.0.lock().unwrap_or_else(|e| e.into_inner());
78        let Some(epoch) = state.epoch.checked_add(1) else {
79            return false;
80        };
81        state.epoch = epoch;
82        state.samples = Snapshot::default();
83        true
84    }
85    pub fn span(&self, phase: Phase) -> Span {
86        let epoch = self.0.lock().unwrap_or_else(|e| e.into_inner()).epoch;
87        Span {
88            profile: self.clone(),
89            epoch,
90            phase,
91            start: Instant::now(),
92            units: 1,
93        }
94    }
95}
96#[must_use]
97pub struct Span {
98    profile: Profile,
99    epoch: u64,
100    phase: Phase,
101    start: Instant,
102    units: u64,
103}
104impl Span {
105    pub fn set_work(&mut self, units: u64) {
106        self.units = units;
107    }
108}
109impl Drop for Span {
110    fn drop(&mut self) {
111        let elapsed = self.start.elapsed();
112        let mut state = self.profile.0.lock().unwrap_or_else(|e| e.into_inner());
113        if state.epoch != self.epoch {
114            return;
115        }
116        let sample = &mut state.samples.0[self.phase as usize];
117        sample.calls = sample.calls.saturating_add(1);
118        sample.units = sample.units.saturating_add(self.units);
119        sample.elapsed = sample.elapsed.saturating_add(elapsed);
120        sample.max = sample.max.max(elapsed);
121    }
122}
123
124impl crate::BaseDocument {
125    /// Attaching/detaching instrumentation never damages the document or redraws.
126    /// Child documents are independent; attach a shared handle explicitly to
127    /// aggregate them. Subdocuments measures the parent's inclusive loop only.
128    pub fn set_profile(&mut self, profile: Option<Profile>) -> Option<Profile> {
129        std::mem::replace(&mut self.profile, profile)
130    }
131    pub fn profile(&self) -> Option<Profile> {
132        self.profile.clone()
133    }
134    pub(crate) fn profile_span(&self, phase: Phase) -> Option<Span> {
135        self.profile.as_ref().map(|profile| profile.span(phase))
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    #[test]
143    fn reset_retires_old_spans_and_unwind_records_once() {
144        let profile = Profile::default();
145        let old = profile.span(Phase::Style);
146        assert!(profile.reset());
147        drop(old);
148        assert_eq!(profile.snapshot(), Snapshot::default());
149        let _ = std::panic::catch_unwind(|| {
150            let mut span = profile.span(Phase::TextShape);
151            span.set_work(7);
152            panic!("profile unwind");
153        });
154        let sample = profile.snapshot().sample(Phase::TextShape);
155        assert_eq!((sample.calls, sample.units), (1, 7));
156        assert!(sample.max <= sample.elapsed);
157    }
158    #[test]
159    fn parallel_workers_share_bounded_samples_without_losing_work() {
160        let profile = Profile::default();
161        let handles: Vec<_> = (0..4)
162            .map(|_| {
163                let profile = profile.clone();
164                std::thread::spawn(move || {
165                    for _ in 0..1_000 {
166                        let mut span = profile.span(Phase::TextShape);
167                        span.set_work(8);
168                    }
169                })
170            })
171            .collect();
172        for handle in handles {
173            handle.join().unwrap();
174        }
175        let snapshot = profile.snapshot();
176        let sample = snapshot.sample(Phase::TextShape);
177        assert_eq!((sample.calls, sample.units), (4_000, 32_000));
178        assert_eq!(snapshot.iter().count(), 13);
179        assert!(sample.max <= sample.elapsed);
180    }
181    #[test]
182    fn exhausted_epoch_is_refused_without_clearing_or_reusing_it() {
183        let profile = Profile::default();
184        profile.0.lock().unwrap().epoch = u64::MAX;
185        drop(profile.span(Phase::BoxLayout));
186        let before = profile.snapshot();
187        assert!(!profile.reset());
188        assert_eq!(profile.snapshot(), before);
189    }
190    #[test]
191    fn a_document_defaults_to_no_instrumentation_and_empty_resolve_invents_no_stages() {
192        let mut doc = crate::BaseDocument::new(crate::DocumentConfig::default());
193        assert!(doc.profile().is_none());
194        assert!(doc.profile_span(Phase::Style).is_none());
195        let profile = Profile::default();
196        assert!(doc.set_profile(Some(profile.clone())).is_none());
197        doc.resolve(0.);
198        assert_eq!(profile.snapshot(), Snapshot::default());
199        assert!(doc.set_profile(None).is_some());
200    }
201    #[test]
202    #[cfg(feature = "system-fonts")]
203    fn attachment_preserves_cached_text_and_mutation_measures_actual_parley_work() {
204        use crate::{Attribute, qual_name};
205        let mut doc = crate::BaseDocument::new(crate::DocumentConfig {
206            viewport: Some(apace_traits::shell::Viewport::new(
207                400,
208                300,
209                1.,
210                Default::default(),
211            )),
212            ..Default::default()
213        });
214        let root = doc.root_node().id;
215        let mut mutation = doc.mutate();
216        let html = mutation.create_element(qual_name!("html", html), vec![]);
217        let body = mutation.create_element(qual_name!("body", html), vec![]);
218        let paragraph = mutation.create_element(
219            qual_name!("div", html),
220            vec![Attribute {
221                name: qual_name!("style"),
222                value: "display:block;width:160px;font-size:16px".into(),
223            }],
224        );
225        let text = mutation.create_text_node("alpha beta gamma delta");
226        mutation.append_children(root, &[html]);
227        mutation.append_children(html, &[body]);
228        mutation.append_children(body, &[paragraph]);
229        mutation.append_children(paragraph, &[text]);
230        drop(mutation);
231        doc.resolve(0.);
232        let size = doc.nodes[paragraph].final_layout().size;
233        assert!(size.height > 0., "real system font/layout required");
234        let profile = Profile::default();
235        doc.set_profile(Some(profile.clone()));
236        doc.resolve(0.);
237        assert_eq!(doc.nodes[paragraph].final_layout().size, size);
238        assert_eq!(profile.snapshot().sample(Phase::Style).calls, 1);
239        assert_eq!(profile.snapshot().sample(Phase::TextShape).calls, 0);
240        assert!(profile.reset());
241        doc.mutate()
242            .set_node_text(text, "changed text needs actual shaping");
243        doc.resolve(0.);
244        let snapshot = profile.snapshot();
245        for phase in Phase::ALL {
246            assert!(snapshot.sample(phase).calls > 0, "{phase:?}");
247        }
248        assert!(
249            snapshot.sample(Phase::TextShape).units
250                >= "changed text needs actual shaping".len() as u64
251        );
252        doc.set_profile(None);
253        doc.mutate().set_node_text(text, "unprofiled update");
254        doc.resolve(0.);
255        assert_eq!(profile.snapshot(), snapshot);
256    }
257}