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    use std::time::Duration;
417
418    #[test]
419    fn span_records_duration() {
420        let name = "span_records_duration";
421        {
422            let _g = span(name);
423            std::thread::sleep(Duration::from_micros(100));
424        }
425        let dur = last_span_duration(name);
426        assert!(dur.is_some(), "span should be recorded");
427        assert!(
428            dur.unwrap() >= 50_000,
429            "duration should be >= ~50us, 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        std::thread::sleep(Duration::from_micros(50));
439        s.end();
440        let dur = last_span_duration(name);
441        assert!(dur.is_some());
442        assert!(dur.unwrap() >= 20_000);
443    }
444
445    #[test]
446    fn double_end_is_noop() {
447        let name = "double_end_is_noop";
448        let s = TracySpan::begin(name);
449        s.end();
450        let first = last_span_duration(name).unwrap();
451        let s2 = TracySpan::begin(name);
452        s2.end();
453        // The second end of the first span does nothing; the second span's
454        // value should be the latest recorded.
455        let second = last_span_duration(name).unwrap();
456        // Both recorded; latest is the second span.
457        assert!(second > 0);
458        let _ = first;
459    }
460
461    #[test]
462    fn frame_mark_increments_counter() {
463        let before = frame_count();
464        frame_mark();
465        frame_mark();
466        assert_eq!(frame_count(), before + 2);
467    }
468
469    #[test]
470    fn plot_records_and_updates() {
471        plot("plot_test_a", 1.0);
472        assert_eq!(plot_value("plot_test_a"), Some(1.0));
473        plot("plot_test_a", 2.5);
474        assert_eq!(plot_value("plot_test_a"), Some(2.5));
475    }
476
477    #[test]
478    fn plot_missing_returns_none() {
479        assert!(plot_value("definitely_not_a_plot_xyz").is_none());
480    }
481
482    #[test]
483    fn missing_span_returns_none() {
484        assert!(last_span_duration("definitely_not_a_span_xyz").is_none());
485    }
486
487    #[test]
488    fn ring_buffer_overwrites_oldest() {
489        // Record more spans than the ring size to verify no panic and that
490        // recent spans are still queryable.
491        for i in 0..(SPAN_RING_SIZE + 10) {
492            // Use a couple of distinct names.
493            let _g = span("ring_span_a");
494            let _g2 = span("ring_span_b");
495            let _ = i;
496        }
497        // The most recent span should be queryable.
498        assert!(last_span_duration("ring_span_b").is_some());
499        assert!(span_record_count() <= SPAN_RING_SIZE);
500    }
501
502    #[test]
503    fn plot_slots_evict_when_full() {
504        // Fill all plot slots plus extras; should not panic and the most
505        // recently written should be queryable.
506        for i in 0..(PLOT_SLOTS + 5) {
507            // Generate distinct static names by interning via leak is not
508            // possible without unsafe; instead reuse a small set of names.
509            plot("plot_evict", i as f64);
510        }
511        assert_eq!(plot_value("plot_evict"), Some((PLOT_SLOTS + 4) as f64));
512    }
513
514    /// DevTools Overhead Gate (§5.3): active Tracy profiling instrumentation
515    /// must contribute `< 0.1ms` overhead per 60fps frame.
516    ///
517    /// This measures the cost of a representative frame's instrumentation:
518    /// one scoped span (begin + end), a frame mark, and a plot point, across
519    /// 60 frames, and asserts the per-frame overhead is below 100µs.
520    #[test]
521    fn tracy_overhead_under_100us_per_frame() {
522        // Warm up the thread-local to avoid first-access cost in the
523        // measurement window.
524        {
525            let _g = span("warmup");
526        }
527        frame_mark();
528        plot("warmup_plot", 0.0);
529
530        const FRAMES: u32 = 60;
531        let start = Instant::now();
532        for _ in 0..FRAMES {
533            let _g = span("overhead_frame");
534            frame_mark();
535            plot("overhead_plot", 1.0);
536        }
537        let elapsed = start.elapsed();
538        let per_frame_ns = elapsed.as_nanos() / FRAMES as u128;
539        // 0.1ms = 100_000 ns. We use a generous 100us gate per the spec.
540        assert!(
541            per_frame_ns < 100_000,
542            "Tracy overhead {per_frame_ns}ns/frame exceeds the 100us (100_000ns) gate"
543        );
544    }
545}