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}