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}