1use std::path::Path;
8
9use anyhow::Result;
10
11use super::perfetto::{PerfettoEvent, PerfettoExporter, PerfettoPhase, PerfettoTrace};
12use super::tracy::{TracyExporter, TracyTrace, TracyZone};
13
14#[derive(Debug, Clone, PartialEq)]
36pub struct TimingEvent {
37 pub timestamp_ns: u64,
39 pub duration_ns: u64,
41 pub thread_id: u32,
43 pub name: String,
45}
46
47impl From<&TracyZone> for TimingEvent {
48 fn from(z: &TracyZone) -> Self {
49 Self {
50 timestamp_ns: z.timestamp_ns,
51 duration_ns: z.duration_ns,
52 thread_id: z.thread_id,
53 name: z.name.clone(),
54 }
55 }
56}
57
58impl From<&PerfettoEvent> for TimingEvent {
59 fn from(e: &PerfettoEvent) -> Self {
60 Self {
61 timestamp_ns: e.timestamp_us * 1_000,
62 duration_ns: e.duration_us.unwrap_or(0) * 1_000,
63 thread_id: e.tid,
64 name: e.name.clone(),
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ExportFormat {
85 Perfetto,
87 Tracy,
89 ChromeTrace,
91 Csv,
93 Json,
95}
96
97impl ExportFormat {
98 pub fn extension(&self) -> &str {
100 match self {
101 Self::Perfetto | Self::ChromeTrace => "json",
102 Self::Tracy => "csv",
103 Self::Csv => "csv",
104 Self::Json => "json",
105 }
106 }
107}
108
109#[derive(Debug, Clone)]
128pub struct ExportConfig {
129 pub format: ExportFormat,
131 pub output_path: String,
133 pub compress: bool,
135}
136
137#[derive(Debug, Clone, PartialEq)]
143pub enum ExportError {
144 UnsupportedFormat(String),
146 IoError(String),
148 EmptyTrace,
150}
151
152impl std::fmt::Display for ExportError {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 match self {
155 Self::UnsupportedFormat(s) => write!(f, "unsupported export format: {s}"),
156 Self::IoError(s) => write!(f, "I/O error during export: {s}"),
157 Self::EmptyTrace => write!(f, "export trace is empty"),
158 }
159 }
160}
161
162impl std::error::Error for ExportError {}
163
164impl From<anyhow::Error> for ExportError {
165 fn from(e: anyhow::Error) -> Self {
166 Self::IoError(e.to_string())
167 }
168}
169
170#[derive(Debug, Default)]
179pub struct ProfilingTrace {
180 events: Vec<TimingEvent>,
181}
182
183impl ProfilingTrace {
184 pub fn new() -> Self {
186 Self::default()
187 }
188
189 pub fn add_event(&mut self, event: TimingEvent) {
191 self.events.push(event);
192 }
193
194 pub fn events(&self) -> &[TimingEvent] {
196 &self.events
197 }
198
199 pub fn len(&self) -> usize {
201 self.events.len()
202 }
203
204 pub fn is_empty(&self) -> bool {
206 self.events.is_empty()
207 }
208
209 pub fn to_perfetto(&self) -> PerfettoTrace {
211 let mut trace = PerfettoTrace::new();
212 for ev in &self.events {
213 trace.add_event(PerfettoEvent {
214 name: ev.name.clone(),
215 phase: PerfettoPhase::Complete,
216 timestamp_us: ev.timestamp_ns / 1_000,
217 duration_us: Some(ev.duration_ns / 1_000),
218 pid: 1,
219 tid: ev.thread_id,
220 args: std::collections::HashMap::new(),
221 });
222 }
223 trace
224 }
225
226 pub fn to_tracy(&self) -> TracyTrace {
228 let mut trace = TracyTrace::new();
229 for ev in &self.events {
230 trace.add_zone(TracyZone {
231 name: ev.name.clone(),
232 timestamp_ns: ev.timestamp_ns,
233 duration_ns: ev.duration_ns,
234 thread_id: ev.thread_id,
235 });
236 }
237 trace
238 }
239}
240
241impl From<&TracyTrace> for ProfilingTrace {
242 fn from(t: &TracyTrace) -> Self {
243 let events = t.zones().iter().map(TimingEvent::from).collect();
244 Self { events }
245 }
246}
247
248impl From<&PerfettoTrace> for ProfilingTrace {
249 fn from(t: &PerfettoTrace) -> Self {
256 let mut trace = Self::new();
257 for event in t.events() {
258 trace.add_event(TimingEvent::from(event));
259 }
260 trace
261 }
262}
263
264pub struct CsvExporter;
281
282impl CsvExporter {
283 pub fn export_to_csv(events: &[TimingEvent]) -> String {
286 let mut out = String::from("timestamp_ns,duration_ns,thread_id,name\n");
287 for ev in events {
288 let safe_name = ev.name.replace(',', "\\,");
289 use std::fmt::Write as _;
290 let _ = writeln!(
291 out,
292 "{},{},{},{}",
293 ev.timestamp_ns, ev.duration_ns, ev.thread_id, safe_name
294 );
295 }
296 out
297 }
298
299 pub fn export_to_file(events: &[TimingEvent], path: &Path) -> Result<()> {
301 let csv = Self::export_to_csv(events);
302 std::fs::write(path, csv.as_bytes())?;
303 Ok(())
304 }
305}
306
307pub struct JsonExporter;
326
327impl JsonExporter {
328 pub fn export_to_json(events: &[TimingEvent]) -> String {
330 use std::fmt::Write as _;
331 let mut out = String::from('[');
332 for (i, ev) in events.iter().enumerate() {
333 if i > 0 {
334 out.push(',');
335 }
336 let escaped_name = escape_json_string_local(&ev.name);
337 let _ = write!(
338 out,
339 r#"{{"timestamp_ns":{},"duration_ns":{},"thread_id":{},"name":"{}"}}"#,
340 ev.timestamp_ns, ev.duration_ns, ev.thread_id, escaped_name
341 );
342 }
343 out.push(']');
344 out
345 }
346
347 pub fn export_to_file(events: &[TimingEvent], path: &Path) -> Result<()> {
349 let json = Self::export_to_json(events);
350 std::fs::write(path, json.as_bytes())?;
351 Ok(())
352 }
353}
354
355fn escape_json_string_local(s: &str) -> String {
356 use std::fmt::Write as _;
357 let mut out = String::with_capacity(s.len());
358 for c in s.chars() {
359 match c {
360 '"' => out.push_str("\\\""),
361 '\\' => out.push_str("\\\\"),
362 '\n' => out.push_str("\\n"),
363 '\r' => out.push_str("\\r"),
364 '\t' => out.push_str("\\t"),
365 c if (c as u32) < 0x20 => {
366 let _ = write!(out, "\\u{:04x}", c as u32);
367 },
368 c => out.push(c),
369 }
370 }
371 out
372}
373
374pub struct TraceExporter;
402
403impl TraceExporter {
404 pub fn export_all(trace: &ProfilingTrace, config: &ExportConfig) -> Result<(), ExportError> {
411 if trace.is_empty() {
412 return Err(ExportError::EmptyTrace);
413 }
414 let path = Path::new(&config.output_path);
415
416 match &config.format {
417 ExportFormat::Perfetto | ExportFormat::ChromeTrace => {
418 let perf = trace.to_perfetto();
419 perf.export_to_file(path).map_err(ExportError::from)?;
420 },
421 ExportFormat::Tracy => {
422 let tracy = trace.to_tracy();
423 tracy.export_to_file(path).map_err(ExportError::from)?;
424 },
425 ExportFormat::Csv => {
426 CsvExporter::export_to_file(trace.events(), path).map_err(ExportError::from)?;
427 },
428 ExportFormat::Json => {
429 JsonExporter::export_to_file(trace.events(), path).map_err(ExportError::from)?;
430 },
431 }
432 Ok(())
433 }
434
435 pub fn export_profiler_report(
441 report: &crate::ProfilerReport,
442 config: &ExportConfig,
443 ) -> Result<(), ExportError> {
444 let path = Path::new(&config.output_path);
445 match &config.format {
446 ExportFormat::Perfetto | ExportFormat::ChromeTrace => {
447 PerfettoExporter::export_profiler_report(report, path)
448 .map_err(ExportError::from)?;
449 },
450 ExportFormat::Tracy => {
451 TracyExporter::export_profiler_report(report, path).map_err(ExportError::from)?;
452 },
453 ExportFormat::Csv | ExportFormat::Json => {
454 let events: Vec<TimingEvent> = report
455 .slowest_layers
456 .iter()
457 .enumerate()
458 .map(|(i, (name, dur))| TimingEvent {
459 timestamp_ns: i as u64 * 1_000_000,
460 duration_ns: dur.as_nanos() as u64,
461 thread_id: 0,
462 name: name.clone(),
463 })
464 .collect();
465 if events.is_empty() {
466 return Err(ExportError::EmptyTrace);
467 }
468 match &config.format {
469 ExportFormat::Csv => {
470 CsvExporter::export_to_file(&events, path).map_err(ExportError::from)?
471 },
472 _ => JsonExporter::export_to_file(&events, path).map_err(ExportError::from)?,
473 }
474 },
475 }
476 Ok(())
477 }
478}
479
480#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn perfetto_to_profiling_conversion_keeps_every_event() {
490 let mut perfetto = PerfettoTrace::new();
491 for (index, name) in ["load", "forward", "backward"].iter().enumerate() {
492 perfetto.add_event(PerfettoEvent {
493 name: (*name).to_string(),
494 phase: PerfettoPhase::Complete,
495 timestamp_us: 1_000 * (index as u64 + 1),
496 duration_us: Some(500),
497 pid: 1,
498 tid: 7,
499 args: std::collections::HashMap::new(),
500 });
501 }
502
503 let profiling = ProfilingTrace::from(&perfetto);
504 assert_eq!(
506 profiling.len(),
507 3,
508 "every event must survive the conversion"
509 );
510 let names: Vec<&str> = profiling.events().iter().map(|e| e.name.as_str()).collect();
511 assert_eq!(
512 names,
513 vec!["load", "forward", "backward"],
514 "order must be preserved"
515 );
516 assert_eq!(profiling.events()[0].thread_id, 7);
517 assert_eq!(profiling.events()[0].timestamp_ns, 1_000_000);
519 assert_eq!(profiling.events()[0].duration_ns, 500_000);
520 }
521
522 fn sample_events() -> Vec<TimingEvent> {
523 vec![
524 TimingEvent {
525 timestamp_ns: 0,
526 duration_ns: 1_000_000,
527 thread_id: 0,
528 name: "attention".to_string(),
529 },
530 TimingEvent {
531 timestamp_ns: 1_000_000,
532 duration_ns: 2_000_000,
533 thread_id: 1,
534 name: "ffn".to_string(),
535 },
536 TimingEvent {
537 timestamp_ns: 3_000_000,
538 duration_ns: 500_000,
539 thread_id: 0,
540 name: "layer_norm".to_string(),
541 },
542 ]
543 }
544
545 fn sample_trace() -> ProfilingTrace {
546 let mut t = ProfilingTrace::new();
547 for e in sample_events() {
548 t.add_event(e);
549 }
550 t
551 }
552
553 #[test]
556 fn test_csv_header() {
557 let csv = CsvExporter::export_to_csv(&[]);
558 assert_eq!(csv.trim(), "timestamp_ns,duration_ns,thread_id,name");
559 }
560
561 #[test]
562 fn test_csv_export_values() {
563 let csv = CsvExporter::export_to_csv(&sample_events());
564 assert!(csv.contains("0,1000000,0,attention"));
565 assert!(csv.contains("1000000,2000000,1,ffn"));
566 }
567
568 #[test]
569 fn test_csv_comma_escaping() {
570 let events = vec![TimingEvent {
571 timestamp_ns: 0,
572 duration_ns: 0,
573 thread_id: 0,
574 name: "op,with,commas".to_string(),
575 }];
576 let csv = CsvExporter::export_to_csv(&events);
577 assert!(csv.contains("op\\,with\\,commas"));
578 }
579
580 #[test]
581 fn test_csv_export_to_file() {
582 let path = std::env::temp_dir().join("csv_export_test.csv");
583 CsvExporter::export_to_file(&sample_events(), &path).unwrap();
584 assert!(path.exists());
585 let content = std::fs::read_to_string(&path).unwrap();
586 assert!(content.contains("attention"));
587 std::fs::remove_file(&path).ok();
588 }
589
590 #[test]
593 fn test_json_export_structure() {
594 let json = JsonExporter::export_to_json(&sample_events());
595 assert!(json.starts_with('['));
596 assert!(json.ends_with(']'));
597 assert!(json.contains("\"name\":\"attention\""));
598 assert!(json.contains("\"timestamp_ns\":0"));
599 assert!(json.contains("\"thread_id\":1"));
600 }
601
602 #[test]
603 fn test_json_export_empty() {
604 let json = JsonExporter::export_to_json(&[]);
605 assert_eq!(json, "[]");
606 }
607
608 #[test]
609 fn test_json_export_escaping() {
610 let events = vec![TimingEvent {
611 timestamp_ns: 0,
612 duration_ns: 0,
613 thread_id: 0,
614 name: "say \"hello\"".to_string(),
615 }];
616 let json = JsonExporter::export_to_json(&events);
617 assert!(json.contains("\\\"hello\\\""));
618 }
619
620 #[test]
621 fn test_json_export_to_file() {
622 let path = std::env::temp_dir().join("json_export_test.json");
623 JsonExporter::export_to_file(&sample_events(), &path).unwrap();
624 assert!(path.exists());
625 std::fs::remove_file(&path).ok();
626 }
627
628 #[test]
631 fn test_export_format_extension() {
632 assert_eq!(ExportFormat::Perfetto.extension(), "json");
633 assert_eq!(ExportFormat::ChromeTrace.extension(), "json");
634 assert_eq!(ExportFormat::Tracy.extension(), "csv");
635 assert_eq!(ExportFormat::Csv.extension(), "csv");
636 assert_eq!(ExportFormat::Json.extension(), "json");
637 }
638
639 #[test]
642 fn test_profiling_trace_to_perfetto() {
643 let trace = sample_trace();
644 let perf = trace.to_perfetto();
645 assert_eq!(perf.len(), 3);
646 }
647
648 #[test]
649 fn test_profiling_trace_to_tracy() {
650 let trace = sample_trace();
651 let tracy = trace.to_tracy();
652 assert_eq!(tracy.zones().len(), 3);
653 assert_eq!(tracy.zones()[0].name, "attention");
654 }
655
656 #[test]
657 fn test_timing_event_from_tracy_zone() {
658 let zone = TracyZone {
659 name: "test".to_string(),
660 timestamp_ns: 5_000,
661 duration_ns: 1_000,
662 thread_id: 2,
663 };
664 let ev = TimingEvent::from(&zone);
665 assert_eq!(ev.timestamp_ns, 5_000);
666 assert_eq!(ev.duration_ns, 1_000);
667 assert_eq!(ev.thread_id, 2);
668 assert_eq!(ev.name, "test");
669 }
670
671 #[test]
674 fn test_trace_exporter_csv() {
675 let trace = sample_trace();
676 let path = std::env::temp_dir().join("unified_export_csv.csv");
677 let config = ExportConfig {
678 format: ExportFormat::Csv,
679 output_path: path.to_string_lossy().into_owned(),
680 compress: false,
681 };
682 TraceExporter::export_all(&trace, &config).unwrap();
683 assert!(path.exists());
684 std::fs::remove_file(&path).ok();
685 }
686
687 #[test]
688 fn test_trace_exporter_json() {
689 let trace = sample_trace();
690 let path = std::env::temp_dir().join("unified_export_json.json");
691 let config = ExportConfig {
692 format: ExportFormat::Json,
693 output_path: path.to_string_lossy().into_owned(),
694 compress: false,
695 };
696 TraceExporter::export_all(&trace, &config).unwrap();
697 assert!(path.exists());
698 let content = std::fs::read_to_string(&path).unwrap();
699 assert!(content.contains("attention"));
700 std::fs::remove_file(&path).ok();
701 }
702
703 #[test]
704 fn test_trace_exporter_empty_returns_error() {
705 let trace = ProfilingTrace::new();
706 let path = std::env::temp_dir().join("should_not_exist.csv");
707 let config = ExportConfig {
708 format: ExportFormat::Csv,
709 output_path: path.to_string_lossy().into_owned(),
710 compress: false,
711 };
712 let result = TraceExporter::export_all(&trace, &config);
713 assert!(matches!(result, Err(ExportError::EmptyTrace)));
714 }
715
716 #[test]
717 fn test_export_error_display() {
718 assert!(ExportError::EmptyTrace.to_string().contains("empty"));
719 assert!(ExportError::UnsupportedFormat("xyz".to_string()).to_string().contains("xyz"));
720 assert!(ExportError::IoError("perm denied".to_string())
721 .to_string()
722 .contains("perm denied"));
723 }
724}