Skip to main content

martensite_devtools/
tracy.rs

1//! Tracy profiler span instrumentation.
2//!
3//! Martensite instruments layout, paint, and reactive dispatch with profiling
4//! spans so that frame bottlenecks can be located without external tooling.
5//! Because the crate is `#![forbid(unsafe_code)]`, it cannot link the native
6//! Tracy client library (which is inherently `unsafe`). Instead, this module
7//! provides a safe, allocation-free re-implementation of the Tracy span API
8//! that records region durations with [`std::time::Instant`] into a
9//! thread-local ring buffer. The recorded data feeds the in-app diagnostic
10//! HUD and can be queried programmatically.
11//!
12//! The instrumentation is designed for sub-microsecond overhead: a span
13//! begin/end pair performs two [`Instant::now()`] calls and a fixed-size
14//! array write, with no heap allocation. The DevTools overhead gate
15//! (§5.3 of the v0.9.0 milestone) requires that active profiling contributes
16//! `< 0.1ms` per 60fps frame; see the `tracy_overhead_under_100us_per_frame`
17//! test for the exit-criterion verification.
18//!
19//! # Examples
20//!
21//! ```
22//! use martensite_devtools::tracy;
23//!
24//! // Scoped span: records its duration on drop.
25//! let _guard = tracy::span("layout_pass");
26//! // ... layout work ...
27//!
28//! // Manual span lifecycle.
29//! let span = tracy::TracySpan::begin("paint_encode");
30//! // ... paint work ...
31//! span.end();
32//!
33//! // Frame and plot markers.
34//! tracy::frame_mark();
35//! tracy::plot("gpu_wait_ms", 0.42);
36//! ```
37
38use std::cell::RefCell;
39use std::time::Instant;
40
41/// Number of span records retained in the thread-local ring buffer.
42///
43/// This is sized to comfortably hold the spans emitted by a single frame
44/// (layout, paint, gpu wait, reactive dispatch, ...) with headroom, so the
45/// HUD can inspect the most recent frame without growing unbounded.
46const SPAN_RING_SIZE: usize = 256;
47
48/// Number of distinct plot slots retained per thread.
49const PLOT_SLOTS: usize = 32;
50
51/// A single recorded profiling span.
52///
53/// This is a `Copy` value stored in the thread-local ring buffer so that the
54/// HUD can iterate over recent spans without allocation.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct SpanRecord {
57    /// The static name label of the span.
58    pub name: &'static str,
59    /// The measured duration of the span, in nanoseconds.
60    pub duration_ns: u64,
61}
62
63/// A slot for a named plot value.
64#[derive(Debug, Clone, Copy, PartialEq)]
65struct PlotEntry {
66    /// The static name label of the plot, or `None` if the slot is free.
67    name: Option<&'static str>,
68    /// The last recorded value for this plot.
69    value: f64,
70}
71
72impl PlotEntry {
73    /// Create an empty (free) plot slot.
74    const fn empty() -> Self {
75        Self {
76            name: None,
77            value: 0.0,
78        }
79    }
80}
81
82/// Thread-local profiling buffer holding recent span records, plot values,
83/// and a frame counter.
84///
85/// All storage is fixed-size arrays, so recording a span or plot never
86/// allocates.
87#[derive(Debug)]
88struct ProfileBuffer {
89    spans: [SpanRecord; SPAN_RING_SIZE],
90    span_index: usize,
91    span_count: usize,
92    plots: [PlotEntry; PLOT_SLOTS],
93    frame_count: u64,
94}
95
96impl ProfileBuffer {
97    /// Create a new empty profiling buffer.
98    fn new() -> Self {
99        Self {
100            spans: [SpanRecord {
101                name: "",
102                duration_ns: 0,
103            }; SPAN_RING_SIZE],
104            span_index: 0,
105            span_count: 0,
106            plots: [PlotEntry::empty(); PLOT_SLOTS],
107            frame_count: 0,
108        }
109    }
110
111    /// Record a completed span into the ring buffer, overwriting the oldest
112    /// entry when full.
113    #[inline]
114    fn record_span(&mut self, name: &'static str, duration_ns: u64) {
115        self.spans[self.span_index] = SpanRecord { name, duration_ns };
116        self.span_index = (self.span_index + 1) % SPAN_RING_SIZE;
117        if self.span_count < SPAN_RING_SIZE {
118            self.span_count += 1;
119        }
120    }
121
122    /// Record or update a named plot value.
123    #[inline]
124    fn record_plot(&mut self, name: &'static str, value: f64) {
125        // Linear scan over a small fixed array; cheaper than a hashmap for the
126        // expected number of distinct plots.
127        for entry in self.plots.iter_mut() {
128            if entry.name == Some(name) {
129                entry.value = value;
130                return;
131            }
132        }
133        // Not found: claim the first free slot.
134        for entry in self.plots.iter_mut() {
135            if entry.name.is_none() {
136                entry.name = Some(name);
137                entry.value = value;
138                return;
139            }
140        }
141        // All slots occupied: overwrite the first slot (oldest heuristic).
142        self.plots[0].name = Some(name);
143        self.plots[0].value = value;
144    }
145
146    /// Increment the per-thread frame counter.
147    #[inline]
148    fn mark_frame(&mut self) {
149        self.frame_count += 1;
150    }
151
152    /// Return the most recently recorded duration for the named span, if any.
153    fn last_span_duration(&self, name: &'static str) -> Option<u64> {
154        // Walk the ring backward from the most recent write.
155        if self.span_count == 0 {
156            return None;
157        }
158        for i in (0..self.span_count).rev() {
159            let idx = (self.span_index + SPAN_RING_SIZE - 1 - i) % SPAN_RING_SIZE;
160            if self.spans[idx].name == name {
161                return Some(self.spans[idx].duration_ns);
162            }
163        }
164        None
165    }
166
167    /// Return the last recorded value for the named plot, if any.
168    fn plot_value(&self, name: &'static str) -> Option<f64> {
169        self.plots
170            .iter()
171            .find(|e| e.name == Some(name))
172            .map(|e| e.value)
173    }
174
175    /// Return the number of span records currently held in the ring buffer.
176    fn span_record_count(&self) -> usize {
177        self.span_count
178    }
179
180    /// Return the per-thread frame counter.
181    fn frame_count(&self) -> u64 {
182        self.frame_count
183    }
184}
185
186thread_local! {
187    static PROFILE: RefCell<ProfileBuffer> = RefCell::new(ProfileBuffer::new());
188}
189
190/// A profiling span that records the duration of a code region.
191///
192/// When Tracy is not available, this is a zero-cost no-op backed by
193/// [`std::time::Instant`]. Call [`TracySpan::begin`] to start timing a region
194/// and [`TracySpan::end`] to record the elapsed duration into the thread-local
195/// ring buffer. For scoped (RAII) spans, prefer the [`span`] function which
196/// returns a [`TracySpanGuard`].
197///
198/// # Examples
199///
200/// ```
201/// use martensite_devtools::tracy::TracySpan;
202///
203/// let span = TracySpan::begin("encode_paint_list");
204/// // ... work ...
205/// span.end();
206/// ```
207pub struct TracySpan {
208    /// The static name label of the span.
209    name: &'static str,
210    /// The instant at which the span began, or `None` if already ended.
211    /// Uses `Cell` so that `end()` can consume the start time without
212    /// requiring `&mut self`, making double-`end()` a safe no-op.
213    start: std::cell::Cell<Option<Instant>>,
214}
215
216impl TracySpan {
217    /// Begin a new profiling span with the given static name.
218    ///
219    /// The start time is captured immediately via [`Instant::now`].
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// use martensite_devtools::tracy::TracySpan;
225    ///
226    /// let span = TracySpan::begin("layout_pass");
227    /// span.end();
228    /// ```
229    #[inline]
230    pub fn begin(name: &'static str) -> Self {
231        Self {
232            name,
233            start: std::cell::Cell::new(Some(Instant::now())),
234        }
235    }
236
237    /// Record the elapsed duration of this span into the thread-local ring
238    /// buffer.
239    ///
240    /// Calling `end` more than once is a no-op: subsequent calls find no start
241    /// time and record nothing.
242    ///
243    /// # Examples
244    ///
245    /// ```
246    /// use martensite_devtools::tracy::TracySpan;
247    ///
248    /// let span = TracySpan::begin("paint_pass");
249    /// span.end();
250    /// // A second end is a no-op.
251    /// span.end();
252    /// ```
253    #[inline]
254    pub fn end(&self) {
255        if let Some(start) = self.start.take() {
256            let duration_ns = start.elapsed().as_nanos() as u64;
257            PROFILE.with(|p| p.borrow_mut().record_span(self.name, duration_ns));
258        }
259    }
260}
261
262/// RAII guard for a scoped profiling span.
263///
264/// Created by [`span`]; the span is recorded into the thread-local ring
265/// buffer when the guard is dropped.
266///
267/// # Examples
268///
269/// ```
270/// use martensite_devtools::tracy;
271///
272/// fn do_work() {
273///     let _guard = tracy::span("work_region");
274///     // ... work ...
275/// }
276///
277/// do_work();
278/// ```
279pub struct TracySpanGuard {
280    span: TracySpan,
281}
282
283impl Drop for TracySpanGuard {
284    #[inline]
285    fn drop(&mut self) {
286        self.span.end();
287    }
288}
289
290/// Create a scoped profiling span that records its duration on drop.
291///
292/// This is the primary entry point for instrumenting a code region. The
293/// returned [`TracySpanGuard`] records the span into the thread-local ring
294/// buffer when it goes out of scope.
295///
296/// # Examples
297///
298/// ```
299/// use martensite_devtools::tracy;
300///
301/// {
302///     let _g = tracy::span("scoped_region");
303///     // ... work ...
304/// } // span recorded here
305/// ```
306#[inline]
307pub fn span(name: &'static str) -> TracySpanGuard {
308    TracySpanGuard {
309        span: TracySpan::begin(name),
310    }
311}
312
313/// Emit a frame marker for Tracy's frame profiling.
314///
315/// Increments the per-thread frame counter. Pair this with one call per
316/// rendered frame so frame boundaries can be correlated with span timings.
317///
318/// # Examples
319///
320/// ```
321/// use martensite_devtools::tracy;
322///
323/// tracy::frame_mark();
324/// ```
325#[inline]
326pub fn frame_mark() {
327    PROFILE.with(|p| p.borrow_mut().mark_frame());
328}
329
330/// Record a plot point for Tracy's value plots.
331///
332/// Stores the latest value for the named plot in a fixed-size thread-local
333/// slot. Repeated calls with the same name update the existing slot.
334///
335/// # Examples
336///
337/// ```
338/// use martensite_devtools::tracy;
339///
340/// tracy::plot("gpu_wait_ms", 0.42);
341/// tracy::plot("gpu_wait_ms", 0.51);
342/// ```
343#[inline]
344pub fn plot(name: &'static str, value: f64) {
345    PROFILE.with(|p| p.borrow_mut().record_plot(name, value));
346}
347
348/// Return the most recently recorded duration (in nanoseconds) for the named
349/// span on the current thread, if any.
350///
351/// # Examples
352///
353/// ```
354/// use martensite_devtools::tracy;
355///
356/// {
357///     let _g = tracy::span("query_region");
358/// }
359/// let dur = tracy::last_span_duration("query_region");
360/// assert!(dur.is_some());
361/// ```
362pub fn last_span_duration(name: &'static str) -> Option<u64> {
363    PROFILE.with(|p| p.borrow().last_span_duration(name))
364}
365
366/// Return the last recorded value for the named plot on the current thread,
367/// if any.
368///
369/// # Examples
370///
371/// ```
372/// use martensite_devtools::tracy;
373///
374/// tracy::plot("fps", 59.9);
375/// assert_eq!(tracy::plot_value("fps"), Some(59.9));
376/// ```
377pub fn plot_value(name: &'static str) -> Option<f64> {
378    PROFILE.with(|p| p.borrow().plot_value(name))
379}
380
381/// Return the number of span records currently held in the current thread's
382/// ring buffer.
383///
384/// # Examples
385///
386/// ```
387/// use martensite_devtools::tracy;
388///
389/// {
390///     let _g = tracy::span("count_region");
391/// }
392/// assert!(tracy::span_record_count() >= 1);
393/// ```
394pub fn span_record_count() -> usize {
395    PROFILE.with(|p| p.borrow().span_record_count())
396}
397
398/// Return the per-thread frame counter value.
399///
400/// # Examples
401///
402/// ```
403/// use martensite_devtools::tracy;
404///
405/// let before = tracy::frame_count();
406/// tracy::frame_mark();
407/// assert_eq!(tracy::frame_count(), before + 1);
408/// ```
409pub fn frame_count() -> u64 {
410    PROFILE.with(|p| p.borrow().frame_count())
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn span_records_duration() {
419        let name = "span_records_duration";
420        {
421            let _g = span(name);
422        }
423        let dur = last_span_duration(name);
424        assert!(dur.is_some(), "span should be recorded");
425        // Duration is u64 so it is always non-negative; just sanity-check the
426        // upper bound.
427        assert!(
428            dur.unwrap() < 1_000_000_000,
429            "duration should be under a generous 1s upper bound, got {}",
430            dur.unwrap()
431        );
432    }
433
434    #[test]
435    fn manual_span_end_records() {
436        let name = "manual_span_end_records";
437        let s = TracySpan::begin(name);
438        s.end();
439        let dur = last_span_duration(name);
440        assert!(dur.is_some(), "manual span should be recorded");
441        assert!(
442            dur.unwrap() < 1_000_000_000,
443            "duration should be under a generous 1s upper bound, got {}",
444            dur.unwrap()
445        );
446    }
447
448    #[test]
449    fn double_end_is_noop() {
450        // This test verifies that calling `end()` twice on the same span
451        // is a no-op. We use `span_record_count()` to verify that the
452        // second `end()` call does not record an additional span.
453        let name = "double_end_is_noop_unique_4f7a";
454        let before = span_record_count();
455        let s = TracySpan::begin(name);
456        s.end();
457        let after_first = span_record_count();
458        // First end records exactly one span.
459        assert_eq!(after_first, before + 1, "first end should record one span");
460        // Second end is a no-op: start was already taken.
461        s.end();
462        let after_second = span_record_count();
463        assert_eq!(
464            after_second, after_first,
465            "second end should not record another span"
466        );
467        // The recorded span should be queryable.
468        let dur = last_span_duration(name);
469        assert!(dur.is_some(), "span should be recorded");
470
471        // Now verify that a second span with the same name records
472        // correctly and has a measurable duration.
473        let s2 = TracySpan::begin(name);
474        // Busy-wait for a deterministic amount well above timer resolution.
475        let start = std::time::Instant::now();
476        while start.elapsed() < std::time::Duration::from_millis(2) {
477            std::hint::spin_loop();
478        }
479        s2.end();
480        let after_third = span_record_count();
481        assert_eq!(
482            after_third,
483            after_second + 1,
484            "second span should record one span"
485        );
486        // Second end of s2 is a no-op.
487        s2.end();
488        assert_eq!(
489            span_record_count(),
490            after_third,
491            "second end of s2 should not record"
492        );
493    }
494
495    #[test]
496    fn frame_mark_increments_counter() {
497        let before = frame_count();
498        frame_mark();
499        frame_mark();
500        assert_eq!(frame_count(), before + 2);
501    }
502
503    #[test]
504    fn plot_records_and_updates() {
505        plot("plot_test_a", 1.0);
506        assert_eq!(plot_value("plot_test_a"), Some(1.0));
507        plot("plot_test_a", 2.5);
508        assert_eq!(plot_value("plot_test_a"), Some(2.5));
509    }
510
511    #[test]
512    fn plot_missing_returns_none() {
513        assert!(plot_value("definitely_not_a_plot_xyz").is_none());
514    }
515
516    #[test]
517    fn missing_span_returns_none() {
518        assert!(last_span_duration("definitely_not_a_span_xyz").is_none());
519    }
520
521    #[test]
522    fn ring_buffer_overwrites_oldest() {
523        // Record more spans than the ring size to verify no panic and that
524        // recent spans are still queryable.
525        for i in 0..(SPAN_RING_SIZE + 10) {
526            // Use a couple of distinct names.
527            let _g = span("ring_span_a");
528            let _g2 = span("ring_span_b");
529            let _ = i;
530        }
531        // The most recent span should be queryable.
532        assert!(last_span_duration("ring_span_b").is_some());
533        assert!(span_record_count() <= SPAN_RING_SIZE);
534    }
535
536    #[test]
537    fn plot_slots_evict_when_full() {
538        // Fill all plot slots plus extras; should not panic and the most
539        // recently written should be queryable.
540        for i in 0..(PLOT_SLOTS + 5) {
541            // Generate distinct static names by interning via leak is not
542            // possible without unsafe; instead reuse a small set of names.
543            plot("plot_evict", i as f64);
544        }
545        assert_eq!(plot_value("plot_evict"), Some((PLOT_SLOTS + 4) as f64));
546    }
547
548    /// DevTools Overhead Gate (§5.3): active Tracy profiling instrumentation
549    /// must contribute `< 0.1ms` overhead per 60fps frame.
550    ///
551    /// This measures the cost of a representative frame's instrumentation:
552    /// one scoped span (begin + end), a frame mark, and a plot point, across
553    /// 60 frames, and asserts the per-frame overhead is below 100µs.
554    #[test]
555    #[ignore = "wall-clock performance gate; run manually with --ignored --test-threads=1"]
556    fn tracy_overhead_under_100us_per_frame() {
557        // Warm up the thread-local to avoid first-access cost in the
558        // measurement window.
559        {
560            let _g = span("warmup");
561        }
562        frame_mark();
563        plot("warmup_plot", 0.0);
564
565        const FRAMES: u32 = 60;
566        let start = Instant::now();
567        for _ in 0..FRAMES {
568            let _g = span("overhead_frame");
569            frame_mark();
570            plot("overhead_plot", 1.0);
571        }
572        let elapsed = start.elapsed();
573        let per_frame_ns = elapsed.as_nanos() / FRAMES as u128;
574        // 0.1ms = 100_000 ns. We use a generous 100us gate per the spec.
575        assert!(
576            per_frame_ns < 100_000,
577            "Tracy overhead {per_frame_ns}ns/frame exceeds the 100us (100_000ns) gate"
578        );
579    }
580}