Skip to main content

atomr_profiler/
report.rs

1//! Shared report schema.
2//!
3//! The same structure is produced by the Python profiler so the two
4//! runtimes can be merged into a single comparison table.
5
6use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9
10/// Which workload was measured.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum Scenario {
14    /// Fire-and-forget `tell` into a null actor.
15    Tell,
16    /// Sequential `ask` round-trips (measures latency).
17    Ask,
18    /// Spawn many actors and hit each one once (actor-creation cost).
19    Fanout,
20    /// CPU-bound handler (xxHash-lite compute loop).
21    Cpu,
22}
23
24impl Scenario {
25    pub fn name(self) -> &'static str {
26        match self {
27            Scenario::Tell => "tell",
28            Scenario::Ask => "ask",
29            Scenario::Fanout => "fanout",
30            Scenario::Cpu => "cpu",
31        }
32    }
33
34    pub fn parse(s: &str) -> Option<Self> {
35        match s {
36            "tell" => Some(Scenario::Tell),
37            "ask" => Some(Scenario::Ask),
38            "fanout" => Some(Scenario::Fanout),
39            "cpu" => Some(Scenario::Cpu),
40            _ => None,
41        }
42    }
43
44    pub fn all() -> &'static [Scenario] {
45        &[Scenario::Tell, Scenario::Ask, Scenario::Fanout, Scenario::Cpu]
46    }
47}
48
49/// One scenario's measurement. Times are in nanoseconds for precision;
50/// helpers render them as human-friendly units.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Measurement {
53    pub runtime: String, // "rust" or "python"
54    pub scenario: Scenario,
55    pub config: String, // free-form (dispatcher, pool size, ...)
56    pub messages: u64,  // total messages (or actors for fanout)
57    pub elapsed_ns: u64,
58    pub throughput_msgs_per_sec: f64,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub p50_ns: Option<u64>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub p95_ns: Option<u64>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub p99_ns: Option<u64>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub rss_delta_bytes: Option<i64>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub peak_rss_bytes: Option<u64>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub cpu_delta_ns: Option<u64>,
71}
72
73impl Measurement {
74    pub fn from_throughput(
75        runtime: &str,
76        scenario: Scenario,
77        config: &str,
78        messages: u64,
79        elapsed: Duration,
80    ) -> Self {
81        let elapsed_ns = elapsed.as_nanos() as u64;
82        let throughput = if elapsed_ns == 0 { 0.0 } else { (messages as f64) * 1.0e9 / (elapsed_ns as f64) };
83        Self {
84            runtime: runtime.to_string(),
85            scenario,
86            config: config.to_string(),
87            messages,
88            elapsed_ns,
89            throughput_msgs_per_sec: throughput,
90            p50_ns: None,
91            p95_ns: None,
92            p99_ns: None,
93            rss_delta_bytes: None,
94            peak_rss_bytes: None,
95            cpu_delta_ns: None,
96        }
97    }
98
99    pub fn with_latencies(mut self, sorted: &[Duration]) -> Self {
100        use crate::metrics::percentile;
101        self.p50_ns = percentile(sorted, 50.0).map(|d| d.as_nanos() as u64);
102        self.p95_ns = percentile(sorted, 95.0).map(|d| d.as_nanos() as u64);
103        self.p99_ns = percentile(sorted, 99.0).map(|d| d.as_nanos() as u64);
104        self
105    }
106
107    pub fn with_memory(mut self, delta: Option<i64>, peak: Option<u64>) -> Self {
108        self.rss_delta_bytes = delta;
109        self.peak_rss_bytes = peak;
110        self
111    }
112
113    pub fn with_cpu(mut self, cpu: Option<Duration>) -> Self {
114        self.cpu_delta_ns = cpu.map(|d| d.as_nanos() as u64);
115        self
116    }
117}
118
119/// Top-level report — a list of measurements plus some environment metadata.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ProfilerReport {
122    pub runtime: String,
123    pub version: String,
124    pub host: String,
125    pub measurements: Vec<Measurement>,
126}
127
128impl ProfilerReport {
129    pub fn new(runtime: &str) -> Self {
130        Self {
131            runtime: runtime.to_string(),
132            version: env!("CARGO_PKG_VERSION").to_string(),
133            host: host_tag(),
134            measurements: Vec::new(),
135        }
136    }
137
138    pub fn push(&mut self, m: Measurement) {
139        self.measurements.push(m);
140    }
141
142    /// Render as a human-friendly markdown table.
143    pub fn to_markdown(&self) -> String {
144        let mut out = String::new();
145        out.push_str(&format!(
146            "# atomr profiler — {} ({})\n\nhost: `{}`\n\n",
147            self.runtime, self.version, self.host
148        ));
149        out.push_str("| scenario | config | msgs | elapsed | throughput | p50 | p95 | p99 | ΔRSS | CPU |\n");
150        out.push_str("|---|---|---|---|---|---|---|---|---|---|\n");
151        for m in &self.measurements {
152            out.push_str(&format!(
153                "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
154                m.scenario.name(),
155                m.config,
156                m.messages,
157                fmt_ns(m.elapsed_ns),
158                fmt_rate(m.throughput_msgs_per_sec),
159                fmt_opt_ns(m.p50_ns),
160                fmt_opt_ns(m.p95_ns),
161                fmt_opt_ns(m.p99_ns),
162                fmt_opt_delta(m.rss_delta_bytes),
163                fmt_opt_ns(m.cpu_delta_ns),
164            ));
165        }
166        out
167    }
168}
169
170fn host_tag() -> String {
171    let os = std::env::consts::OS;
172    let arch = std::env::consts::ARCH;
173    let cpus = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
174    format!("{os}/{arch} cpus={cpus}")
175}
176
177fn fmt_ns(ns: u64) -> String {
178    if ns >= 1_000_000_000 {
179        format!("{:.2}s", ns as f64 / 1e9)
180    } else if ns >= 1_000_000 {
181        format!("{:.2}ms", ns as f64 / 1e6)
182    } else if ns >= 1_000 {
183        format!("{:.2}µs", ns as f64 / 1e3)
184    } else {
185        format!("{ns}ns")
186    }
187}
188
189fn fmt_opt_ns(v: Option<u64>) -> String {
190    match v {
191        Some(n) => fmt_ns(n),
192        None => "n/a".to_string(),
193    }
194}
195
196fn fmt_rate(v: f64) -> String {
197    if v >= 1e6 {
198        format!("{:.2}M/s", v / 1e6)
199    } else if v >= 1e3 {
200        format!("{:.2}k/s", v / 1e3)
201    } else {
202        format!("{v:.2}/s")
203    }
204}
205
206fn fmt_opt_delta(v: Option<i64>) -> String {
207    match v {
208        Some(n) => {
209            let abs = n.unsigned_abs();
210            let pretty = if abs >= 1 << 30 {
211                format!("{:.2}GiB", abs as f64 / (1u64 << 30) as f64)
212            } else if abs >= 1 << 20 {
213                format!("{:.2}MiB", abs as f64 / (1u64 << 20) as f64)
214            } else if abs >= 1 << 10 {
215                format!("{:.2}KiB", abs as f64 / (1u64 << 10) as f64)
216            } else {
217                format!("{abs}B")
218            };
219            if n < 0 {
220                format!("-{pretty}")
221            } else {
222                format!("+{pretty}")
223            }
224        }
225        None => "n/a".to_string(),
226    }
227}