Skip to main content

loam_time/
frame_trace.rs

1//! Frame histories belong to the recording thread.
2
3#[cfg(feature = "frame-trace")]
4use std::cell::RefCell;
5#[cfg(feature = "frame-trace")]
6use std::collections::VecDeque;
7use std::time::Duration;
8#[cfg(feature = "frame-trace")]
9use web_time::Instant;
10
11#[derive(Clone, Debug)]
12pub struct Section {
13    pub name: &'static str,
14    pub elapsed: Duration,
15}
16
17#[derive(Clone, Debug, Default)]
18pub struct FrameTrace {
19    pub sections: Vec<Section>,
20    /// `None` without a [`HeapSampler`].
21    pub heap_delta_bytes: Option<i64>,
22    /// `None` without a [`crate::alloc::CountingAllocator`] installed.
23    pub allocs: Option<crate::alloc::AllocDelta>,
24}
25
26impl FrameTrace {
27    /// Sum of section durations, including nested scopes and synthetic sections.
28    pub fn total(&self) -> Duration {
29        self.sections.iter().map(|s| s.elapsed).sum()
30    }
31}
32
33pub const DEFAULT_CAPACITY: usize = 120;
34
35#[cfg(feature = "frame-trace")]
36struct Tracer {
37    history: VecDeque<FrameTrace>,
38    capacity: usize,
39}
40
41#[cfg(feature = "frame-trace")]
42impl Tracer {
43    fn new(capacity: usize) -> Self {
44        Self {
45            history: VecDeque::with_capacity(capacity),
46            capacity,
47        }
48    }
49}
50
51/// Bytes; on wasm32 + Chromium, `performance.memory.usedJSHeapSize`.
52pub type HeapSampler = fn() -> Option<u64>;
53
54#[cfg(feature = "frame-trace")]
55thread_local! {
56    static TRACER: RefCell<Tracer> = RefCell::new(Tracer::new(DEFAULT_CAPACITY));
57    static CURRENT_SECTIONS: RefCell<Vec<Section>> = const { RefCell::new(Vec::new()) };
58    static LAST_FRAME_END: std::cell::Cell<Option<Instant>> = const { std::cell::Cell::new(None) };
59    static CURRENT_FRAME_START: std::cell::Cell<Option<Instant>> = const { std::cell::Cell::new(None) };
60    static CURRENT_FRAME_HEAP_START: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
61    static CURRENT_FRAME_ALLOC_START: std::cell::Cell<Option<crate::alloc::AllocSnapshot>> = const { std::cell::Cell::new(None) };
62    static HEAP_SAMPLER: std::cell::Cell<Option<HeapSampler>> = const { std::cell::Cell::new(None) };
63    static MAX_EVER: RefCell<std::collections::HashMap<&'static str, Duration>> =
64        RefCell::new(std::collections::HashMap::new());
65    static SPIKE_THRESHOLD: std::cell::Cell<Duration> =
66        const { std::cell::Cell::new(Duration::from_millis(250)) };
67    static FRAME_COUNTER: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
68}
69
70#[cfg(feature = "frame-trace")]
71#[must_use = "Scope records on drop; binding it to `_` would record immediately"]
72pub struct Scope {
73    name: &'static str,
74    start: Instant,
75    recording_thread: std::marker::PhantomData<std::rc::Rc<()>>,
76}
77
78#[cfg(feature = "frame-trace")]
79impl Drop for Scope {
80    fn drop(&mut self) {
81        let elapsed = self.start.elapsed();
82        let name = self.name;
83        CURRENT_SECTIONS.with(|s| {
84            if let Ok(mut s) = s.try_borrow_mut() {
85                s.push(Section { name, elapsed });
86            }
87        });
88    }
89}
90
91#[cfg(feature = "frame-trace")]
92#[inline]
93pub fn scope(name: &'static str) -> Scope {
94    Scope {
95        name,
96        start: Instant::now(),
97        recording_thread: std::marker::PhantomData,
98    }
99}
100
101#[cfg(feature = "frame-trace")]
102pub fn set_heap_sampler(sampler: HeapSampler) {
103    HEAP_SAMPLER.with(|s| s.set(Some(sampler)));
104}
105
106#[cfg(feature = "frame-trace")]
107pub fn begin_frame() {
108    CURRENT_FRAME_START.with(|c| c.set(Some(Instant::now())));
109    let sampler = HEAP_SAMPLER.with(|s| s.get());
110    CURRENT_FRAME_HEAP_START.with(|c| c.set(sampler.and_then(|f| f())));
111    CURRENT_FRAME_ALLOC_START.with(|c| c.set(crate::alloc::current_snapshot()));
112}
113
114/// Adds the synthetic `between-frames` and `idle` sections after the first frame.
115#[cfg(feature = "frame-trace")]
116pub fn end_frame() {
117    let now = Instant::now();
118    let last_end = LAST_FRAME_END.with(|cell| {
119        let prev = cell.get();
120        cell.set(Some(now));
121        prev
122    });
123    let frame_start = CURRENT_FRAME_START.with(|c| c.take());
124
125    let heap_start = CURRENT_FRAME_HEAP_START.with(|c| c.take());
126    let sampler = HEAP_SAMPLER.with(|s| s.get());
127    let heap_end = sampler.and_then(|f| f());
128    let heap_delta_bytes: Option<i64> = match (heap_start, heap_end) {
129        (Some(a), Some(b)) => Some((b as i64).saturating_sub(a as i64)),
130        _ => None,
131    };
132
133    let alloc_start = CURRENT_FRAME_ALLOC_START.with(|c| c.take());
134    let alloc_end = crate::alloc::current_snapshot();
135    let alloc_delta: Option<crate::alloc::AllocDelta> = match (alloc_start, alloc_end) {
136        (Some(a), Some(b)) => Some(crate::alloc::delta(a, b)),
137        _ => None,
138    };
139
140    let frame_index = FRAME_COUNTER.with(|c| {
141        let n = c.get();
142        c.set(n.wrapping_add(1));
143        n
144    });
145
146    let threshold = SPIKE_THRESHOLD.with(|c| c.get());
147    let mut sections = CURRENT_SECTIONS.with(|s| std::mem::take(&mut *s.borrow_mut()));
148
149    if let Some(last_end) = last_end {
150        let between_frames = now.saturating_duration_since(last_end);
151        sections.push(Section {
152            name: "between-frames",
153            elapsed: between_frames,
154        });
155        if let Some(frame_start) = frame_start {
156            let idle = frame_start.saturating_duration_since(last_end);
157            sections.push(Section {
158                name: "idle",
159                elapsed: idle,
160            });
161        }
162    }
163
164    // Release tracer borrows before invoking a tracing subscriber.
165    let mut over_threshold: Vec<(&'static str, Duration)> = Vec::new();
166    MAX_EVER.with(|m| {
167        let mut m = m.borrow_mut();
168        for section in &sections {
169            let entry = m.entry(section.name).or_insert(Duration::ZERO);
170            if section.elapsed > *entry {
171                *entry = section.elapsed;
172            }
173            if section.elapsed > threshold {
174                over_threshold.push((section.name, section.elapsed));
175            }
176        }
177    });
178
179    let mut reusable = TRACER.with(|t| {
180        let mut t = t.borrow_mut();
181        let reusable = if t.history.len() >= t.capacity {
182            t.history
183                .pop_front()
184                .map(|frame| frame.sections)
185                .unwrap_or_default()
186        } else {
187            Vec::new()
188        };
189        t.history.push_back(FrameTrace {
190            sections,
191            heap_delta_bytes,
192            allocs: alloc_delta,
193        });
194        reusable
195    });
196    reusable.clear();
197    CURRENT_SECTIONS.with(|current| *current.borrow_mut() = reusable);
198
199    for (name, elapsed) in over_threshold {
200        let heap_suffix = heap_delta_bytes
201            .map(|d| format!(" heap_delta={:+.2}MB", d as f64 / (1024.0 * 1024.0)))
202            .unwrap_or_default();
203        let alloc_suffix = alloc_delta
204            .map(|d| {
205                format!(
206                    " allocs={} ({:+.2}MB net)",
207                    d.alloc_count,
208                    d.net_bytes as f64 / (1024.0 * 1024.0),
209                )
210            })
211            .unwrap_or_default();
212        tracing::warn!(
213            "frame_trace spike: section='{name}' elapsed={:.1}ms frame={frame_index}{heap_suffix}{alloc_suffix}",
214            elapsed.as_secs_f32() * 1000.0,
215        );
216    }
217}
218
219#[cfg(feature = "frame-trace")]
220pub fn set_capacity(capacity: usize) {
221    TRACER.with(|t| {
222        let mut t = t.borrow_mut();
223        t.capacity = capacity.max(1);
224        while t.history.len() > t.capacity {
225            t.history.pop_front();
226        }
227    });
228}
229
230/// Oldest to newest; allocates.
231#[cfg(feature = "frame-trace")]
232pub fn history() -> Vec<FrameTrace> {
233    TRACER.with(|t| t.borrow().history.iter().cloned().collect())
234}
235
236#[cfg(feature = "frame-trace")]
237pub fn clear_history() {
238    TRACER.with(|t| t.borrow_mut().history.clear());
239}
240
241#[cfg(not(feature = "frame-trace"))]
242pub fn clear_history() {}
243
244/// `f` must not call [`end_frame`], [`set_capacity`], or [`clear_history`]; the borrow is held.
245#[cfg(feature = "frame-trace")]
246pub fn with_history<R>(f: impl FnOnce(&std::collections::VecDeque<FrameTrace>) -> R) -> R {
247    TRACER.with(|t| f(&t.borrow().history))
248}
249
250#[cfg(feature = "frame-trace")]
251pub fn last_frame() -> Option<FrameTrace> {
252    TRACER.with(|t| t.borrow().history.back().cloned())
253}
254
255/// `Duration::ZERO` for a name never seen.
256#[cfg(feature = "frame-trace")]
257pub fn max_ever(name: &'static str) -> Duration {
258    MAX_EVER.with(|m| m.borrow().get(name).copied().unwrap_or(Duration::ZERO))
259}
260
261/// Sorted descending by duration.
262#[cfg(feature = "frame-trace")]
263pub fn all_max_ever() -> Vec<(&'static str, Duration)> {
264    MAX_EVER.with(|m| {
265        let mut out: Vec<(&'static str, Duration)> =
266            m.borrow().iter().map(|(k, v)| (*k, *v)).collect();
267        out.sort_by_key(|entry| std::cmp::Reverse(entry.1));
268        out
269    })
270}
271
272#[cfg(feature = "frame-trace")]
273pub fn clear_max_ever() {
274    MAX_EVER.with(|m| m.borrow_mut().clear());
275}
276
277/// `Duration::MAX` disables the spike warning.
278#[cfg(feature = "frame-trace")]
279pub fn set_spike_threshold(threshold: Duration) {
280    SPIKE_THRESHOLD.with(|c| c.set(threshold));
281}
282
283/// Attributes the sample to the current frame, even if it arrives late.
284#[cfg(feature = "frame-trace")]
285pub fn record_external(name: &'static str, elapsed: Duration) {
286    CURRENT_SECTIONS.with(|s| {
287        if let Ok(mut s) = s.try_borrow_mut() {
288            s.push(Section { name, elapsed });
289        }
290    });
291}
292
293#[cfg(not(feature = "frame-trace"))]
294pub fn record_external(_name: &'static str, _elapsed: Duration) {}
295
296#[cfg(not(feature = "frame-trace"))]
297pub fn max_ever(_name: &'static str) -> Duration {
298    Duration::ZERO
299}
300
301#[cfg(not(feature = "frame-trace"))]
302pub fn all_max_ever() -> Vec<(&'static str, Duration)> {
303    Vec::new()
304}
305
306#[cfg(not(feature = "frame-trace"))]
307pub fn clear_max_ever() {}
308
309#[cfg(not(feature = "frame-trace"))]
310pub fn set_spike_threshold(_threshold: Duration) {}
311
312#[cfg(not(feature = "frame-trace"))]
313pub fn set_heap_sampler(_sampler: HeapSampler) {}
314
315#[derive(Clone, Debug)]
316pub struct SectionStats {
317    pub name: &'static str,
318    pub samples: usize,
319    pub mean: Duration,
320    pub p50: Duration,
321    pub p95: Duration,
322    pub p99: Duration,
323    pub max: Duration,
324}
325
326/// Nearest-rank percentile of ascending samples; empty input returns zero.
327pub fn percentile(sorted: &[Duration], percent: u8) -> Duration {
328    if sorted.is_empty() {
329        return Duration::ZERO;
330    }
331    let rank = (sorted.len() * usize::from(percent)).div_ceil(100);
332    sorted[rank.saturating_sub(1).min(sorted.len() - 1)]
333}
334
335/// Keyed by name, in descending p95 order.
336#[cfg(feature = "frame-trace")]
337pub fn aggregate() -> Vec<SectionStats> {
338    use std::collections::HashMap;
339    let mut buckets: HashMap<&'static str, Vec<Duration>> = HashMap::new();
340    with_history(|frames| {
341        for frame in frames {
342            for section in &frame.sections {
343                buckets
344                    .entry(section.name)
345                    .or_default()
346                    .push(section.elapsed);
347            }
348        }
349    });
350
351    let mut stats: Vec<SectionStats> = buckets
352        .into_iter()
353        .map(|(name, mut samples)| {
354            samples.sort();
355            let n = samples.len();
356            let mean = samples.iter().sum::<Duration>() / (n as u32).max(1);
357            SectionStats {
358                name,
359                samples: n,
360                mean,
361                p50: percentile(&samples, 50),
362                p95: percentile(&samples, 95),
363                p99: percentile(&samples, 99),
364                max: *samples.last().unwrap_or(&Duration::ZERO),
365            }
366        })
367        .collect();
368
369    stats.sort_by_key(|s| std::cmp::Reverse(s.p95));
370    stats
371}
372
373#[cfg(not(feature = "frame-trace"))]
374#[must_use]
375pub struct Scope;
376
377#[cfg(not(feature = "frame-trace"))]
378#[inline]
379pub fn scope(_name: &'static str) -> Scope {
380    Scope
381}
382
383#[cfg(not(feature = "frame-trace"))]
384pub fn end_frame() {}
385
386#[cfg(not(feature = "frame-trace"))]
387pub fn begin_frame() {}
388
389#[cfg(not(feature = "frame-trace"))]
390pub fn set_capacity(_capacity: usize) {}
391
392#[cfg(not(feature = "frame-trace"))]
393pub fn history() -> Vec<FrameTrace> {
394    Vec::new()
395}
396
397#[cfg(not(feature = "frame-trace"))]
398pub fn with_history<R>(f: impl FnOnce(&std::collections::VecDeque<FrameTrace>) -> R) -> R {
399    let empty = std::collections::VecDeque::new();
400    f(&empty)
401}
402
403#[cfg(not(feature = "frame-trace"))]
404pub fn last_frame() -> Option<FrameTrace> {
405    None
406}
407
408#[cfg(not(feature = "frame-trace"))]
409pub fn aggregate() -> Vec<SectionStats> {
410    Vec::new()
411}
412
413#[cfg(all(test, feature = "frame-trace"))]
414mod tests {
415    use super::*;
416    use std::thread::sleep;
417
418    #[test]
419    fn clearing_history_preserves_retention_capacity() {
420        set_capacity(2);
421        end_frame();
422        clear_history();
423        assert!(history().is_empty());
424        for _ in 0..3 {
425            end_frame();
426        }
427        assert_eq!(history().len(), 2);
428    }
429
430    #[test]
431    fn scope_records_elapsed_on_drop() {
432        end_frame();
433
434        {
435            let _s = scope("test-a");
436            sleep(Duration::from_millis(1));
437        }
438        end_frame();
439        let post_frame = last_frame().expect("end_frame should produce a frame");
440
441        let sections = &post_frame.sections;
442        let test_a = sections
443            .iter()
444            .find(|s| s.name == "test-a")
445            .unwrap_or_else(|| panic!("expected 'test-a' in {sections:?}"));
446        assert!(
447            test_a.elapsed >= Duration::from_millis(1),
448            "scope elapsed should be >= sleep duration, got {:?}",
449            test_a.elapsed
450        );
451    }
452
453    #[test]
454    fn heap_sampler_populates_delta_on_completed_frame() {
455        use std::sync::atomic::{AtomicU64, Ordering};
456        static FAKE_HEAP: AtomicU64 = AtomicU64::new(1_000_000);
457        fn fake_sampler() -> Option<u64> {
458            Some(FAKE_HEAP.fetch_add(4096, Ordering::SeqCst) + 4096)
459        }
460        FAKE_HEAP.store(1_000_000, Ordering::SeqCst);
461        set_heap_sampler(fake_sampler);
462        end_frame();
463        begin_frame();
464        end_frame();
465        let frame = last_frame().expect("end_frame should produce a frame");
466        let delta = frame
467            .heap_delta_bytes
468            .expect("sampler is registered; delta should be Some");
469        assert_eq!(delta, 4096, "expected one-increment delta, got {delta}");
470    }
471
472    #[test]
473    fn aggregate_uses_retained_frames_and_sorts_percentiles() {
474        set_capacity(2);
475        record_external("discarded", Duration::from_secs(10));
476        end_frame();
477        for elapsed in [10, 20] {
478            record_external("slow", Duration::from_millis(elapsed));
479            record_external("fast", Duration::from_millis(1));
480            end_frame();
481        }
482        let stats = aggregate();
483        assert!(!stats.iter().any(|row| row.name == "discarded"));
484        let slow = stats.iter().position(|row| row.name == "slow").unwrap();
485        let fast = stats.iter().position(|row| row.name == "fast").unwrap();
486        assert!(slow < fast);
487        assert_eq!(stats[slow].samples, 2);
488        assert_eq!(stats[slow].mean, Duration::from_millis(15));
489        assert_eq!(stats[slow].p50, Duration::from_millis(10));
490        assert_eq!(stats[slow].p95, Duration::from_millis(20));
491    }
492}