1use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum Scenario {
14 Tell,
16 Ask,
18 Fanout,
20 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#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Measurement {
53 pub runtime: String, pub scenario: Scenario,
55 pub config: String, pub messages: u64, 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#[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 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}