1use std::future::Future;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8#[derive(Debug, Clone)]
10pub struct DetailedBenchReport {
11 pub name: String,
12 pub total_requests: u64,
13 pub total_duration: Duration,
14 pub avg_latency_us: f64,
15 pub p50_latency_us: f64,
16 pub p95_latency_us: f64,
17 pub p99_latency_us: f64,
18 pub p999_latency_us: f64,
19 pub max_latency_us: f64,
20 pub throughput_rps: f64,
21 pub errors: u64,
22 pub over_threshold: u64,
23 pub threshold_us: f64,
24}
25
26impl DetailedBenchReport {
27 pub fn print(&self) {
28 println!("=== {} ===", self.name);
29 println!(" requests: {}", self.total_requests);
30 println!(" errors: {}", self.errors);
31 println!(" duration: {:.2?}", self.total_duration);
32 println!(" throughput: {:.0} req/s", self.throughput_rps);
33 println!(" avg: {:.0} µs", self.avg_latency_us);
34 println!(" p50: {:.0} µs", self.p50_latency_us);
35 println!(" p95: {:.0} µs", self.p95_latency_us);
36 println!(" p99: {:.0} µs", self.p99_latency_us);
37 println!(" p99.9: {:.0} µs", self.p999_latency_us);
38 println!(" max: {:.0} µs", self.max_latency_us);
39 println!(" >= {:.0} µs: {}", self.threshold_us, self.over_threshold);
40 }
41
42 pub fn summary(&self) -> super::BenchResult {
44 super::BenchResult {
45 name: self.name.clone(),
46 total_requests: self.total_requests,
47 total_duration: self.total_duration,
48 avg_latency_us: self.avg_latency_us,
49 p50_latency_us: self.p50_latency_us,
50 p95_latency_us: self.p95_latency_us,
51 p99_latency_us: self.p99_latency_us,
52 throughput_rps: self.throughput_rps,
53 }
54 }
55}
56
57pub async fn run_bench_detailed<F, Fut>(
60 name: &str,
61 concurrency: usize,
62 total: u64,
63 warmup: u64,
64 threshold: Duration,
65 f: F,
66) -> DetailedBenchReport
67where
68 F: Fn() -> Fut + Send + Sync + 'static,
69 Fut: Future<Output = Result<(), String>> + Send,
70{
71 if concurrency == 0 || total == 0 {
72 return DetailedBenchReport {
73 name: name.to_string(),
74 total_requests: 0,
75 total_duration: Duration::ZERO,
76 avg_latency_us: 0.0,
77 p50_latency_us: 0.0,
78 p95_latency_us: 0.0,
79 p99_latency_us: 0.0,
80 p999_latency_us: 0.0,
81 max_latency_us: 0.0,
82 throughput_rps: 0.0,
83 errors: 0,
84 over_threshold: 0,
85 threshold_us: threshold.as_micros() as f64,
86 };
87 }
88
89 let chunk_size = total / concurrency as u64;
90 let remainder = total % concurrency as u64;
91 let warmup_chunk = warmup / concurrency as u64;
92 let warmup_remainder = warmup % concurrency as u64;
93 let f = Arc::new(f);
94 let barrier = Arc::new(tokio::sync::Barrier::new(concurrency + 1));
95 let mut handles = Vec::with_capacity(concurrency);
96
97 for index in 0..concurrency {
98 let f = Arc::clone(&f);
99 let barrier = Arc::clone(&barrier);
100 let n = chunk_size + u64::from((index as u64) < remainder);
101 let planned = n;
102 let wn = warmup_chunk + u64::from((index as u64) < warmup_remainder);
103 let handle = tokio::spawn(async move {
104 for _ in 0..wn {
105 let _ = f().await;
106 }
107 barrier.wait().await;
108 let mut latencies = Vec::with_capacity(n as usize);
109 let mut errors = 0_u64;
110 for _ in 0..n {
111 let started = Instant::now();
112 if f().await.is_err() {
113 errors += 1;
114 }
115 latencies.push(started.elapsed().as_micros() as f64);
116 }
117 (latencies, errors)
118 });
119 handles.push((handle, planned));
120 }
121
122 let _ = tokio::time::timeout(Duration::from_secs(30), barrier.wait())
124 .await
125 .unwrap_or_else(|_| panic!("bench workers failed to reach steady state (warmup panic?)"));
126 let started = Instant::now();
127
128 let mut latencies = Vec::with_capacity(total as usize);
129 let mut errors = 0_u64;
130 for (handle, planned) in handles {
131 match handle.await {
132 Ok((worker_latencies, worker_errors)) => {
133 latencies.extend(worker_latencies);
134 errors += worker_errors;
135 }
136 Err(_) => errors += planned,
138 }
139 }
140 let total_duration = started.elapsed();
141 latencies.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
142
143 let count = latencies.len();
144 let threshold_us = threshold.as_micros() as f64;
145 let avg = if count == 0 {
146 0.0
147 } else {
148 latencies.iter().sum::<f64>() / count as f64
149 };
150 let pct = |p: f64| percentile(&latencies, p);
151 DetailedBenchReport {
152 name: name.to_string(),
153 total_requests: count as u64,
154 total_duration,
155 avg_latency_us: avg,
156 p50_latency_us: pct(0.50),
157 p95_latency_us: pct(0.95),
158 p99_latency_us: pct(0.99),
159 p999_latency_us: pct(0.999),
160 max_latency_us: latencies.last().copied().unwrap_or(0.0),
161 throughput_rps: if total_duration.is_zero() {
162 0.0
163 } else {
164 count as f64 / total_duration.as_secs_f64()
165 },
166 errors,
167 over_threshold: latencies
168 .iter()
169 .filter(|value| **value >= threshold_us)
170 .count() as u64,
171 threshold_us,
172 }
173}
174
175fn percentile(sorted: &[f64], p: f64) -> f64 {
177 if sorted.is_empty() {
178 return 0.0;
179 }
180 let rank = (p * sorted.len() as f64).ceil() as usize;
181 sorted[rank.saturating_sub(1).min(sorted.len() - 1)]
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use std::sync::atomic::{AtomicU64, Ordering};
188
189 #[tokio::test]
190 async fn counts_errors_and_latencies() {
191 let calls = Arc::new(AtomicU64::new(0));
192 let f = {
193 let calls = Arc::clone(&calls);
194 move || {
195 let calls = Arc::clone(&calls);
196 async move {
197 let n = calls.fetch_add(1, Ordering::SeqCst);
198 if n.is_multiple_of(3) {
199 Err("boom".to_string())
200 } else {
201 Ok(())
202 }
203 }
204 }
205 };
206 let report = run_bench_detailed("errors", 2, 30, 0, Duration::from_millis(10), f).await;
207 assert_eq!(report.total_requests, 30);
208 assert_eq!(report.errors, 10);
209 assert_eq!(report.over_threshold, 0);
210 }
211
212 #[tokio::test]
213 async fn max_and_p999_cover_tail() {
214 let calls = Arc::new(AtomicU64::new(0));
215 let f = {
216 let calls = Arc::clone(&calls);
217 move || {
218 let calls = Arc::clone(&calls);
219 async move {
220 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
221 tokio::time::sleep(Duration::from_millis(5)).await;
222 }
223 Ok::<(), String>(())
224 }
225 }
226 };
227 let report = run_bench_detailed("tail", 1, 20, 0, Duration::from_millis(1), f).await;
228 assert!(
229 report.max_latency_us >= 4_000.0,
230 "max must capture the 5ms sample: {}",
231 report.max_latency_us
232 );
233 assert!(
234 report.p999_latency_us <= report.max_latency_us,
235 "p99.9 must not exceed max"
236 );
237 assert!(report.over_threshold >= 1);
238 }
239
240 #[test]
241 fn percentile_uses_nearest_rank() {
242 let values: Vec<f64> = (1..=1000).map(f64::from).collect();
243 assert_eq!(percentile(&values, 0.50), 500.0);
244 assert_eq!(percentile(&values, 0.99), 990.0);
245 assert_eq!(percentile(&values, 0.999), 999.0);
246 assert_eq!(percentile(&[], 0.999), 0.0);
247 }
248
249 #[test]
250 fn p999_equals_max_below_1000_samples() {
251 for n in 1..1000 {
252 let values: Vec<f64> = (1..=n).map(f64::from).collect();
253 assert_eq!(
254 percentile(&values, 0.999),
255 n as f64,
256 "p99.9 must be the last sample for n={n}"
257 );
258 }
259 }
260
261 #[tokio::test]
262 async fn zero_total_returns_empty_report() {
263 let report = run_bench_detailed("zero", 4, 0, 10, Duration::from_millis(10), || async {
264 Ok(())
265 })
266 .await;
267 assert_eq!(report.total_requests, 0);
268 assert_eq!(report.errors, 0);
269 assert_eq!(report.max_latency_us, 0.0);
270 }
271}