Skip to main content

trustformers_debug/export/
tracy.rs

1//! Tracy profiler offline trace export
2//!
3//! Serialises profiling data to Tracy's text-based CSV/log format for
4//! offline analysis with Tracy's viewer or compatible tools.
5//!
6//! Format reference (line-oriented, one record per line):
7//! ```text
8//! ZoneBegin,<name>,<file>,<line>,<timestamp_ns>
9//! ZoneEnd,<timestamp_ns>
10//! Message,<text>,<timestamp_ns>
11//! Plot,<name>,<value>,<timestamp_ns>
12//! ```
13//!
14//! # Example
15//!
16//! ```no_run
17//! use trustformers_debug::export::tracy::{TracyTrace, TracyZone};
18//!
19//! let mut trace = TracyTrace::new();
20//! trace.add_zone(TracyZone {
21//!     name: "attention_forward".to_string(),
22//!     timestamp_ns: 0,
23//!     duration_ns: 1_500_000,
24//!     thread_id: 1,
25//! });
26//! trace.add_message("training started", 0);
27//! trace.add_plot("loss", 0.345, 0);
28//! let path = std::path::Path::new("/tmp/trace.tracy.csv");
29//! trace.export_to_file(path).unwrap();
30//! ```
31
32use std::io::Write;
33
34use anyhow::Result;
35use serde::{Deserialize, Serialize};
36
37use crate::ProfilerReport;
38
39// ─────────────────────────────────────────────────────────────
40// Public types
41// ─────────────────────────────────────────────────────────────
42
43/// A single profiling zone recorded in a Tracy trace.
44///
45/// A zone corresponds to one timed code region (e.g., a model layer
46/// forward pass).
47///
48/// # Example
49///
50/// ```
51/// use trustformers_debug::export::tracy::TracyZone;
52///
53/// let zone = TracyZone {
54///     name: "ffn_forward".to_string(),
55///     timestamp_ns: 1_000_000,
56///     duration_ns: 500_000,
57///     thread_id: 0,
58/// };
59/// assert_eq!(zone.end_timestamp_ns(), 1_500_000);
60/// ```
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TracyZone {
63    /// Human-readable zone name.
64    pub name: String,
65    /// Start timestamp in nanoseconds.
66    pub timestamp_ns: u64,
67    /// Duration in nanoseconds.
68    pub duration_ns: u64,
69    /// Thread identifier.
70    pub thread_id: u32,
71}
72
73impl TracyZone {
74    /// Returns the end timestamp (start + duration) in nanoseconds.
75    pub fn end_timestamp_ns(&self) -> u64 {
76        self.timestamp_ns.saturating_add(self.duration_ns)
77    }
78}
79
80/// In-memory collection of Tracy trace records.
81///
82/// Holds zones, text messages, and numeric plot entries that can be
83/// exported to a CSV file understood by Tracy-compatible tooling.
84///
85/// # Example
86///
87/// ```
88/// use trustformers_debug::export::tracy::TracyTrace;
89///
90/// let trace = TracyTrace::new();
91/// assert!(trace.is_empty());
92/// ```
93#[derive(Debug, Default)]
94pub struct TracyTrace {
95    zones: Vec<TracyZone>,
96    messages: Vec<(String, u64)>,
97    plots: Vec<(String, f64, u64)>,
98}
99
100impl TracyTrace {
101    /// Creates an empty trace.
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Returns `true` if the trace contains no records of any kind.
107    pub fn is_empty(&self) -> bool {
108        self.zones.is_empty() && self.messages.is_empty() && self.plots.is_empty()
109    }
110
111    /// Returns the total number of records (zones + messages + plots).
112    pub fn total_records(&self) -> usize {
113        self.zones.len() + self.messages.len() + self.plots.len()
114    }
115
116    /// Appends a profiling zone.
117    ///
118    /// # Example
119    ///
120    /// ```
121    /// use trustformers_debug::export::tracy::{TracyTrace, TracyZone};
122    ///
123    /// let mut trace = TracyTrace::new();
124    /// trace.add_zone(TracyZone {
125    ///     name: "forward".to_string(),
126    ///     timestamp_ns: 0,
127    ///     duration_ns: 1000,
128    ///     thread_id: 0,
129    /// });
130    /// assert_eq!(trace.zones().len(), 1);
131    /// ```
132    pub fn add_zone(&mut self, zone: TracyZone) {
133        self.zones.push(zone);
134    }
135
136    /// Appends a text message with a timestamp.
137    ///
138    /// # Example
139    ///
140    /// ```
141    /// use trustformers_debug::export::tracy::TracyTrace;
142    ///
143    /// let mut trace = TracyTrace::new();
144    /// trace.add_message("epoch started", 1_000_000);
145    /// assert_eq!(trace.messages().len(), 1);
146    /// ```
147    pub fn add_message(&mut self, msg: &str, timestamp_ns: u64) {
148        self.messages.push((msg.to_string(), timestamp_ns));
149    }
150
151    /// Appends a named numeric plot value with a timestamp.
152    ///
153    /// # Example
154    ///
155    /// ```
156    /// use trustformers_debug::export::tracy::TracyTrace;
157    ///
158    /// let mut trace = TracyTrace::new();
159    /// trace.add_plot("loss", 0.42, 2_000_000);
160    /// assert_eq!(trace.plots().len(), 1);
161    /// ```
162    pub fn add_plot(&mut self, name: &str, value: f64, timestamp_ns: u64) {
163        self.plots.push((name.to_string(), value, timestamp_ns));
164    }
165
166    /// Read-only view of the recorded zones.
167    pub fn zones(&self) -> &[TracyZone] {
168        &self.zones
169    }
170
171    /// Read-only view of the recorded messages.
172    pub fn messages(&self) -> &[(String, u64)] {
173        &self.messages
174    }
175
176    /// Read-only view of the recorded plot entries.
177    pub fn plots(&self) -> &[(String, f64, u64)] {
178        &self.plots
179    }
180
181    /// Writes the trace to `path` in Tracy CSV format.
182    ///
183    /// Records are emitted in the order: zones (each zone becomes a
184    /// `ZoneBegin` / `ZoneEnd` pair), messages, then plots.
185    ///
186    /// # Errors
187    ///
188    /// Returns an error if the file cannot be created or written.
189    ///
190    /// # Example
191    ///
192    /// ```no_run
193    /// use trustformers_debug::export::tracy::TracyTrace;
194    ///
195    /// let trace = TracyTrace::new();
196    /// trace.export_to_file(std::path::Path::new("/tmp/trace.csv")).unwrap();
197    /// ```
198    pub fn export_to_file(&self, path: &std::path::Path) -> Result<()> {
199        let mut file = std::fs::File::create(path)?;
200
201        // Header comment
202        writeln!(
203            file,
204            "# TracyTrace export — generated by trustformers-debug"
205        )?;
206
207        for zone in &self.zones {
208            writeln!(
209                file,
210                "ZoneBegin,{},{},0,{}",
211                zone.name, zone.name, zone.timestamp_ns
212            )?;
213            writeln!(file, "ZoneEnd,{}", zone.end_timestamp_ns())?;
214        }
215
216        for (msg, ts) in &self.messages {
217            // Escape commas inside the message to keep CSV parseable
218            let safe_msg = msg.replace(',', "\\,");
219            writeln!(file, "Message,{},{}", safe_msg, ts)?;
220        }
221
222        for (name, value, ts) in &self.plots {
223            writeln!(file, "Plot,{},{},{}", name, value, ts)?;
224        }
225
226        tracing::debug!("Tracy trace written to {}", path.display());
227        Ok(())
228    }
229}
230
231// ─────────────────────────────────────────────────────────────
232// TracyExporter
233// ─────────────────────────────────────────────────────────────
234
235/// Converts a [`ProfilerReport`] to a [`TracyTrace`] and writes it to disk.
236///
237/// # Example
238///
239/// ```no_run
240/// use trustformers_debug::export::tracy::TracyExporter;
241/// use trustformers_debug::ProfilerReport;
242/// use std::collections::HashMap;
243/// use std::time::Duration;
244///
245/// let report = ProfilerReport {
246///     total_events: 0,
247///     total_runtime: Duration::from_millis(0),
248///     statistics: HashMap::new(),
249///     bottlenecks: vec![],
250///     slowest_layers: vec![],
251///     memory_efficiency: Default::default(),
252///     recommendations: vec![],
253/// };
254/// TracyExporter::export_profiler_report(
255///     &report,
256///     std::path::Path::new("/tmp/report.csv"),
257/// ).unwrap();
258/// ```
259pub struct TracyExporter;
260
261impl TracyExporter {
262    /// Converts a [`ProfilerReport`] into a Tracy CSV trace file.
263    ///
264    /// Slowest layers become `TracyZone`s ordered by appearance.
265    /// Recommendations are recorded as text messages.
266    /// Per-event statistics are emitted as plots.
267    ///
268    /// # Errors
269    ///
270    /// Returns an error if the file cannot be written.
271    pub fn export_profiler_report(report: &ProfilerReport, path: &std::path::Path) -> Result<()> {
272        let mut trace = TracyTrace::new();
273        let mut cursor_ns: u64 = 0;
274
275        // Zones from slowest-layer list
276        for (layer_name, duration) in &report.slowest_layers {
277            let dur_ns = duration.as_nanos() as u64;
278            trace.add_zone(TracyZone {
279                name: layer_name.clone(),
280                timestamp_ns: cursor_ns,
281                duration_ns: dur_ns,
282                thread_id: 0,
283            });
284            cursor_ns += dur_ns;
285        }
286
287        // Recommendations as messages
288        for (idx, rec) in report.recommendations.iter().enumerate() {
289            trace.add_message(rec.as_str(), idx as u64 * 1_000);
290        }
291
292        // Statistics as plots
293        for (name, stats) in &report.statistics {
294            let avg_us = stats.avg_duration.as_micros() as f64;
295            trace.add_plot(name, avg_us, cursor_ns);
296        }
297
298        trace.export_to_file(path)
299    }
300}
301
302// ─────────────────────────────────────────────────────────────
303// Tests
304// ─────────────────────────────────────────────────────────────
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use std::collections::HashMap;
310    use std::time::Duration;
311
312    #[test]
313    fn test_empty_trace() {
314        let trace = TracyTrace::new();
315        assert!(trace.is_empty());
316        assert_eq!(trace.total_records(), 0);
317    }
318
319    #[test]
320    fn test_add_zone() {
321        let mut trace = TracyTrace::new();
322        trace.add_zone(TracyZone {
323            name: "attention".to_string(),
324            timestamp_ns: 1000,
325            duration_ns: 500,
326            thread_id: 0,
327        });
328        assert_eq!(trace.zones().len(), 1);
329        assert_eq!(trace.zones()[0].end_timestamp_ns(), 1500);
330    }
331
332    #[test]
333    fn test_add_message_and_plot() {
334        let mut trace = TracyTrace::new();
335        trace.add_message("hello", 42);
336        trace.add_plot("loss", 0.5, 100);
337        assert_eq!(trace.messages().len(), 1);
338        assert_eq!(trace.plots().len(), 1);
339        assert_eq!(trace.total_records(), 2);
340    }
341
342    #[test]
343    fn test_export_to_file() {
344        let mut path = std::env::temp_dir();
345        path.push("tracy_test_trace.csv");
346
347        let mut trace = TracyTrace::new();
348        trace.add_zone(TracyZone {
349            name: "ffn".to_string(),
350            timestamp_ns: 0,
351            duration_ns: 2_000_000,
352            thread_id: 1,
353        });
354        trace.add_message("epoch start", 0);
355        trace.add_plot("loss", 0.42, 2_000_000);
356
357        trace.export_to_file(&path).unwrap();
358        assert!(path.exists());
359
360        let content = std::fs::read_to_string(&path).unwrap();
361        assert!(content.contains("ZoneBegin,ffn"));
362        assert!(content.contains("ZoneEnd,2000000"));
363        assert!(content.contains("Message,epoch start,0"));
364        assert!(content.contains("Plot,loss,0.42,2000000"));
365
366        std::fs::remove_file(&path).ok();
367    }
368
369    #[test]
370    fn test_exporter_from_profiler_report() {
371        use crate::profiler::MemoryEfficiencyAnalysis;
372
373        let mut path = std::env::temp_dir();
374        path.push("tracy_profiler_report.csv");
375
376        let report = ProfilerReport {
377            total_events: 3,
378            total_runtime: Duration::from_millis(50),
379            statistics: HashMap::new(),
380            bottlenecks: vec![],
381            slowest_layers: vec![
382                ("attn".to_string(), Duration::from_millis(20)),
383                ("ffn".to_string(), Duration::from_millis(30)),
384            ],
385            memory_efficiency: MemoryEfficiencyAnalysis::default(),
386            recommendations: vec!["Use flash attention".to_string()],
387        };
388
389        TracyExporter::export_profiler_report(&report, &path).unwrap();
390        assert!(path.exists());
391
392        let content = std::fs::read_to_string(&path).unwrap();
393        assert!(content.contains("ZoneBegin,attn"));
394        assert!(content.contains("ZoneBegin,ffn"));
395        assert!(content.contains("Message,Use flash attention"));
396
397        std::fs::remove_file(&path).ok();
398    }
399
400    #[test]
401    fn test_comma_escaping_in_message() {
402        let mut path = std::env::temp_dir();
403        path.push("tracy_comma_test.csv");
404
405        let mut trace = TracyTrace::new();
406        trace.add_message("loss, accuracy: 0.9", 0);
407        trace.export_to_file(&path).unwrap();
408
409        let content = std::fs::read_to_string(&path).unwrap();
410        // commas inside the message must be escaped
411        assert!(content.contains("loss\\, accuracy: 0.9"));
412
413        std::fs::remove_file(&path).ok();
414    }
415}