scirs2_core/profiling/tracy.rs
1//! Tracy-API-compatible profiler integration.
2//!
3//! Enable the `tracy` cargo feature to activate a **Pure Rust** trace-event
4//! recorder that captures spans, frame marks, and log messages into a
5//! process-global, in-memory event log and can export them to the
6//! [Chrome Trace Event Format](https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU)
7//! (also understood by [Perfetto](https://ui.perfetto.dev) and
8//! `chrome://tracing`) via [`TracyClient::export_chrome_trace`].
9//!
10//! This backend deliberately does **not** link the upstream C++ Tracy
11//! client: it is built entirely from `std` plus the two dependencies
12//! `scirs2-core` already carries unconditionally (`once_cell`,
13//! `parking_lot`), so enabling the `tracy` feature never triggers a C/C++
14//! compilation step. Without the feature, all types and functions compile
15//! to zero-cost no-ops with no external dependencies, exactly as before.
16//!
17//! # Important characteristics
18//!
19//! * The process-global event buffer (`TRACE_EVENTS`) grows **unbounded**
20//! for the lifetime of the process while the `tracy` feature is enabled.
21//! This backend is intended for bounded profiling sessions (start the
22//! client, do some work, export, stop the process) — not for
23//! long-running services that never restart. A ring-buffer/size-cap
24//! scheme would be a reasonable follow-up if long-lived usage is needed.
25//! * Thread identifiers (`tid` in the exported JSON) are small
26//! process-local monotonic integers assigned the first time a thread
27//! touches the profiler. They are **not** OS thread ids, and will not
28//! match what tools like `ps -T` or an external tracer report.
29//!
30//! # Usage
31//!
32//! ```rust,no_run
33//! use scirs2_core::profiling::tracy::TracyClient;
34//!
35//! let client = TracyClient::new();
36//! if client.is_active() {
37//! client.message("profiling enabled");
38//! }
39//! {
40//! let _span = client.span("my_operation");
41//! // work here
42//! } // span ends on drop
43//!
44//! // Export the recorded events (valid even when the `tracy` feature is
45//! // disabled -- it just writes an empty-but-valid trace document).
46//! let _ = client.export_chrome_trace(std::env::temp_dir().join("trace.json"));
47//! ```
48
49#[cfg(feature = "tracy")]
50use std::cell::Cell;
51#[cfg(feature = "tracy")]
52use std::sync::atomic::{AtomicU64, Ordering};
53#[cfg(feature = "tracy")]
54use std::time::Instant;
55
56#[cfg(feature = "tracy")]
57use once_cell::sync::Lazy;
58#[cfg(feature = "tracy")]
59use parking_lot::Mutex;
60
61// ---------------------------------------------------------------------------
62// Process-global trace state (only compiled when `tracy` is enabled)
63// ---------------------------------------------------------------------------
64
65/// A single Chrome Trace Event Format event.
66///
67/// See the [Chrome Trace Event Format
68/// spec](https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU)
69/// for the meaning of each field.
70#[cfg(feature = "tracy")]
71#[derive(Debug, Clone)]
72struct TraceEvent {
73 /// Event name shown in the timeline.
74 name: String,
75 /// Event category (e.g. "zone", "frame", "log").
76 category: &'static str,
77 /// Event phase: `'X'` for complete (duration) events, `'i'` for instants.
78 ph: char,
79 /// Start timestamp in microseconds, relative to process start.
80 ts_micros: f64,
81 /// Duration in microseconds, only present for complete (`'X'`) events.
82 dur_micros: Option<f64>,
83 /// Process-local, process-lifetime-stable thread id (NOT the OS tid).
84 tid: u64,
85 /// OS process id.
86 pid: u32,
87 /// Optional free-form text payload (used by [`TracyClient::message`]).
88 arg: Option<String>,
89}
90
91/// Monotonic epoch captured the first time the profiler is touched. All
92/// event timestamps are recorded relative to this instant.
93#[cfg(feature = "tracy")]
94static PROCESS_START: Lazy<Instant> = Lazy::new(Instant::now);
95
96/// Process-global, mutex-protected event log.
97///
98/// Deliberately unbounded: see the module-level documentation for the
99/// implications of long-running processes.
100#[cfg(feature = "tracy")]
101static TRACE_EVENTS: Lazy<Mutex<Vec<TraceEvent>>> = Lazy::new(|| Mutex::new(Vec::new()));
102
103/// Counter used to hand out small monotonic per-thread ids.
104#[cfg(feature = "tracy")]
105static NEXT_TID: AtomicU64 = AtomicU64::new(0);
106
107#[cfg(feature = "tracy")]
108thread_local! {
109 /// Cached process-local thread id for the current OS thread, assigned
110 /// lazily on first use.
111 static THREAD_TID: Cell<Option<u64>> = const { Cell::new(None) };
112}
113
114/// Returns a small, process-local, monotonically-assigned integer id for
115/// the calling thread. Stable for the lifetime of the thread, but **not**
116/// the OS thread id (`std::thread::ThreadId` has no stable integer
117/// accessor, so we roll our own).
118#[cfg(feature = "tracy")]
119fn current_tid() -> u64 {
120 THREAD_TID.with(|cell| {
121 if let Some(tid) = cell.get() {
122 tid
123 } else {
124 let tid = NEXT_TID.fetch_add(1, Ordering::Relaxed);
125 cell.set(Some(tid));
126 tid
127 }
128 })
129}
130
131/// Records a single event into the process-global trace buffer.
132#[cfg(feature = "tracy")]
133fn record_event(event: TraceEvent) {
134 TRACE_EVENTS.lock().push(event);
135}
136
137/// Escapes a string for embedding in a JSON string literal.
138///
139/// Escapes backslash, double quote, and control characters (`< 0x20`),
140/// using the standard short escapes for `\n`, `\r`, and `\t`, and
141/// `\u00XX` for everything else in that range.
142#[cfg(feature = "tracy")]
143fn json_escape(s: &str) -> String {
144 let mut out = String::with_capacity(s.len() + 2);
145 for c in s.chars() {
146 match c {
147 '\\' => out.push_str("\\\\"),
148 '"' => out.push_str("\\\""),
149 '\n' => out.push_str("\\n"),
150 '\r' => out.push_str("\\r"),
151 '\t' => out.push_str("\\t"),
152 c if (c as u32) < 0x20 => {
153 out.push_str(&format!("\\u{:04x}", c as u32));
154 }
155 c => out.push(c),
156 }
157 }
158 out
159}
160
161/// Serializes a single [`TraceEvent`] as a JSON object (no trailing comma).
162#[cfg(feature = "tracy")]
163fn serialize_event(event: &TraceEvent) -> String {
164 let mut obj = String::new();
165 obj.push('{');
166 obj.push_str(&format!("\"name\":\"{}\",", json_escape(&event.name)));
167 obj.push_str(&format!("\"cat\":\"{}\",", json_escape(event.category)));
168 obj.push_str(&format!("\"ph\":\"{}\",", event.ph));
169 obj.push_str(&format!("\"ts\":{},", event.ts_micros));
170 if let Some(dur) = event.dur_micros {
171 obj.push_str(&format!("\"dur\":{dur},"));
172 }
173 obj.push_str(&format!("\"pid\":{},", event.pid));
174 obj.push_str(&format!("\"tid\":{}", event.tid));
175 if event.ph == 'i' {
176 obj.push_str(",\"s\":\"g\"");
177 }
178 if let Some(arg) = &event.arg {
179 obj.push_str(&format!(
180 ",\"args\":{{\"message\":\"{}\"}}",
181 json_escape(arg)
182 ));
183 }
184 obj.push('}');
185 obj
186}
187
188/// Serializes the full Chrome Trace Event Format document for the given
189/// events (a `traceEvents` array plus the `displayTimeUnit` field).
190#[cfg(feature = "tracy")]
191fn serialize_document(events: &[TraceEvent]) -> String {
192 let mut doc = String::from("{\"traceEvents\":[");
193 for (i, event) in events.iter().enumerate() {
194 if i > 0 {
195 doc.push(',');
196 }
197 doc.push_str(&serialize_event(event));
198 }
199 doc.push_str("],\"displayTimeUnit\":\"ns\"}");
200 doc
201}
202
203/// Empty-but-valid Chrome Trace Event Format document, used when the
204/// `tracy` feature is disabled (or, trivially, when no events have been
205/// recorded yet).
206const EMPTY_TRACE_DOCUMENT: &str = "{\"traceEvents\":[],\"displayTimeUnit\":\"ns\"}";
207
208// ---------------------------------------------------------------------------
209// Tracy span RAII guard
210// ---------------------------------------------------------------------------
211
212/// A profiling span that is emitted to the trace-event log when dropped.
213///
214/// Obtain one via [`TracyClient::span`].
215pub struct TracySpan {
216 #[cfg(feature = "tracy")]
217 name: String,
218 #[cfg(feature = "tracy")]
219 start: Instant,
220 #[cfg(not(feature = "tracy"))]
221 _phantom: (),
222}
223
224#[cfg(feature = "tracy")]
225impl Drop for TracySpan {
226 fn drop(&mut self) {
227 let dur = self.start.elapsed();
228 let ts_micros = self.start.duration_since(*PROCESS_START).as_secs_f64() * 1_000_000.0;
229 record_event(TraceEvent {
230 name: std::mem::take(&mut self.name),
231 category: "zone",
232 ph: 'X',
233 ts_micros,
234 dur_micros: Some(dur.as_secs_f64() * 1_000_000.0),
235 tid: current_tid(),
236 pid: std::process::id(),
237 arg: None,
238 });
239 }
240}
241
242// ---------------------------------------------------------------------------
243// TracyClient
244// ---------------------------------------------------------------------------
245
246/// Handle to the (Pure Rust) trace-event profiler client.
247///
248/// Construct once at application start with [`TracyClient::new`] and keep
249/// the handle alive for the duration of profiling. All methods are safe
250/// no-ops when the `tracy` feature is not enabled.
251pub struct TracyClient {
252 active: bool,
253}
254
255impl TracyClient {
256 /// Initialise the profiler client.
257 ///
258 /// When the `tracy` feature is enabled this forces initialisation of
259 /// the process-global trace-event buffer and epoch clock. When the
260 /// feature is absent this is a pure no-op constructor.
261 pub fn new() -> Self {
262 #[cfg(feature = "tracy")]
263 {
264 // Force initialisation of the global statics, matching the old
265 // "starts the underlying runtime" semantics.
266 Lazy::force(&PROCESS_START);
267 Lazy::force(&TRACE_EVENTS);
268 TracyClient { active: true }
269 }
270 #[cfg(not(feature = "tracy"))]
271 {
272 TracyClient { active: false }
273 }
274 }
275
276 /// Returns `true` when profiling is active (i.e. the `tracy` feature
277 /// is enabled and the client was started successfully).
278 #[inline]
279 pub fn is_active(&self) -> bool {
280 self.active
281 }
282
283 /// Begin a named profiling zone. The returned [`TracySpan`] ends the
284 /// zone (and records a complete `'X'` trace event) when dropped.
285 ///
286 /// When the `tracy` feature is disabled this is a zero-cost no-op.
287 #[inline]
288 pub fn span(&self, name: &str) -> TracySpan {
289 #[cfg(feature = "tracy")]
290 {
291 TracySpan {
292 name: name.to_owned(),
293 start: Instant::now(),
294 }
295 }
296 #[cfg(not(feature = "tracy"))]
297 {
298 let _ = name;
299 TracySpan { _phantom: () }
300 }
301 }
302
303 /// Mark a named frame boundary.
304 ///
305 /// Recorded as an instant (`'i'`) trace event with global scope. When
306 /// the `tracy` feature is disabled this is a no-op.
307 #[inline]
308 pub fn frame_mark(&self, name: &str) {
309 #[cfg(feature = "tracy")]
310 {
311 let ts_micros =
312 Instant::now().duration_since(*PROCESS_START).as_secs_f64() * 1_000_000.0;
313 record_event(TraceEvent {
314 name: format!("frame: {name}"),
315 category: "frame",
316 ph: 'i',
317 ts_micros,
318 dur_micros: None,
319 tid: current_tid(),
320 pid: std::process::id(),
321 arg: None,
322 });
323 }
324 #[cfg(not(feature = "tracy"))]
325 let _ = name;
326 }
327
328 /// Emit a free-form message to the trace-event log.
329 ///
330 /// Recorded as an instant (`'i'`) trace event carrying the message as
331 /// an argument payload. When the `tracy` feature is disabled this is
332 /// a no-op.
333 #[inline]
334 pub fn message(&self, msg: &str) {
335 #[cfg(feature = "tracy")]
336 {
337 let ts_micros =
338 Instant::now().duration_since(*PROCESS_START).as_secs_f64() * 1_000_000.0;
339 record_event(TraceEvent {
340 name: "message".to_owned(),
341 category: "log",
342 ph: 'i',
343 ts_micros,
344 dur_micros: None,
345 tid: current_tid(),
346 pid: std::process::id(),
347 arg: Some(msg.to_owned()),
348 });
349 }
350 #[cfg(not(feature = "tracy"))]
351 let _ = msg;
352 }
353
354 /// Export all recorded trace events as a [Chrome Trace Event
355 /// Format](https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU)
356 /// JSON document, viewable at <https://ui.perfetto.dev> or
357 /// `chrome://tracing`.
358 ///
359 /// This method is always available and always safe to call: when the
360 /// `tracy` feature is disabled it writes the valid-but-empty document
361 /// `{"traceEvents":[],"displayTimeUnit":"ns"}` rather than being
362 /// conditionally compiled out, so callers never need feature guards
363 /// around the export call itself.
364 pub fn export_chrome_trace(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
365 use std::io::Write;
366
367 #[cfg(feature = "tracy")]
368 let document = {
369 let events = TRACE_EVENTS.lock().clone();
370 serialize_document(&events)
371 };
372 #[cfg(not(feature = "tracy"))]
373 let document = EMPTY_TRACE_DOCUMENT.to_owned();
374
375 let mut file = std::fs::File::create(path)?;
376 file.write_all(document.as_bytes())?;
377 Ok(())
378 }
379}
380
381impl Default for TracyClient {
382 fn default() -> Self {
383 Self::new()
384 }
385}
386
387// ---------------------------------------------------------------------------
388// Macro convenience
389// ---------------------------------------------------------------------------
390
391/// Create a Tracy span in the current scope.
392///
393/// The span ends when the binding goes out of scope.
394///
395/// # Examples
396///
397/// ```rust,no_run
398/// use scirs2_core::profiling::tracy::TracyClient;
399/// use scirs2_core::tracy_span;
400///
401/// let client = TracyClient::new();
402/// tracy_span!(client, "my_operation");
403/// // work here — span ends at end of block
404/// ```
405#[macro_export]
406macro_rules! tracy_span {
407 ($client:expr, $name:expr) => {
408 let _tracy_span_guard = $client.span($name);
409 };
410}
411
412// ---------------------------------------------------------------------------
413// Tests
414// ---------------------------------------------------------------------------
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn test_tracy_client_default_features() {
422 // Must succeed regardless of whether the `tracy` feature is enabled.
423 let client = TracyClient::new();
424
425 // Without the `tracy` feature (default), the client should be inactive.
426 #[cfg(not(feature = "tracy"))]
427 assert!(
428 !client.is_active(),
429 "TracyClient should be inactive without the tracy feature"
430 );
431
432 // With the `tracy` feature active, the client should report active.
433 #[cfg(feature = "tracy")]
434 assert!(
435 client.is_active(),
436 "TracyClient should be active with the tracy feature"
437 );
438 }
439
440 #[test]
441 fn test_tracy_span_drop() {
442 let client = TracyClient::new();
443 {
444 let _span = client.span("test_span_drop");
445 // span is live here
446 }
447 // span dropped — no panic
448 }
449
450 #[test]
451 fn test_tracy_frame_mark() {
452 let client = TracyClient::new();
453 // Must not panic regardless of feature flag.
454 client.frame_mark("test_frame");
455 }
456
457 #[test]
458 fn test_tracy_message() {
459 let client = TracyClient::new();
460 // Must not panic regardless of feature flag.
461 client.message("test message from tracy integration test");
462 }
463
464 #[test]
465 fn test_tracy_default_impl() {
466 let client = TracyClient::default();
467 // Default should produce the same result as new().
468 #[cfg(not(feature = "tracy"))]
469 assert!(!client.is_active());
470 }
471
472 #[test]
473 fn test_tracy_span_macro() {
474 let client = TracyClient::new();
475 tracy_span!(client, "macro_test_span");
476 // No panic = success
477 }
478
479 #[test]
480 fn test_export_chrome_trace_produces_valid_json_shape() {
481 let client = TracyClient::new();
482 client.message("export shape test");
483 let path = std::env::temp_dir().join(format!(
484 "scirs2_core_tracy_export_shape_{}.json",
485 std::process::id()
486 ));
487
488 client
489 .export_chrome_trace(&path)
490 .expect("export_chrome_trace should succeed");
491
492 let contents = std::fs::read_to_string(&path).expect("exported file should be readable");
493 let _ = std::fs::remove_file(&path);
494
495 assert!(
496 contents.starts_with("{\"traceEvents\":["),
497 "exported document should start with the traceEvents array: {contents}"
498 );
499 assert!(
500 contents.ends_with("],\"displayTimeUnit\":\"ns\"}"),
501 "exported document should end with the displayTimeUnit field: {contents}"
502 );
503 }
504
505 #[cfg(feature = "tracy")]
506 #[test]
507 fn test_export_chrome_trace_records_span_event() {
508 // Use a uniquely-named span so this test is robust to other tests
509 // (and doctests / concurrent test threads) sharing the same
510 // process-global event buffer.
511 let client = TracyClient::new();
512 let marker = format!(
513 "unique_span_marker_{}_{}",
514 std::process::id(),
515 current_tid()
516 );
517 {
518 let _span = client.span(&marker);
519 }
520
521 let path = std::env::temp_dir().join(format!(
522 "scirs2_core_tracy_export_span_{}_{}.json",
523 std::process::id(),
524 current_tid()
525 ));
526 client
527 .export_chrome_trace(&path)
528 .expect("export_chrome_trace should succeed");
529 let contents = std::fs::read_to_string(&path).expect("exported file should be readable");
530 let _ = std::fs::remove_file(&path);
531
532 assert!(
533 contents.contains(&marker),
534 "exported document should contain the span's name: {contents}"
535 );
536 assert!(
537 contents.contains("\"ph\":\"X\""),
538 "exported document should contain a complete-event phase marker: {contents}"
539 );
540 }
541}