1mod detailed;
3pub use detailed::{DetailedBenchReport, run_bench_detailed};
4
5use std::time::{Duration, Instant};
6
7#[derive(Debug, Clone)]
8pub struct BenchResult {
9 pub name: String,
10 pub total_requests: u64,
11 pub total_duration: Duration,
12 pub avg_latency_us: f64,
13 pub p50_latency_us: f64,
14 pub p95_latency_us: f64,
15 pub p99_latency_us: f64,
16 pub throughput_rps: f64,
17}
18
19impl BenchResult {
20 pub fn print(&self) {
21 println!("=== {} ===", self.name);
22 println!(" requests: {}", self.total_requests);
23 println!(" duration: {:.2?}", self.total_duration);
24 println!(" throughput: {:.0} req/s", self.throughput_rps);
25 println!(" avg: {:.0} µs", self.avg_latency_us);
26 println!(" p50: {:.0} µs", self.p50_latency_us);
27 println!(" p95: {:.0} µs", self.p95_latency_us);
28 println!(" p99: {:.0} µs", self.p99_latency_us);
29 }
30}
31
32pub async fn run_bench<F, Fut>(name: &str, concurrency: usize, total: u64, f: F) -> BenchResult
33where
34 F: Fn() -> Fut + Send + Sync + 'static,
35 Fut: std::future::Future<Output = ()> + Send,
36{
37 run_bench_with_warmup(name, concurrency, total, 0, f).await
38}
39
40pub async fn run_bench_with_warmup<F, Fut>(
46 name: &str,
47 concurrency: usize,
48 total: u64,
49 warmup: u64,
50 f: F,
51) -> BenchResult
52where
53 F: Fn() -> Fut + Send + Sync + 'static,
54 Fut: std::future::Future<Output = ()> + Send,
55{
56 if concurrency == 0 || total == 0 {
57 return BenchResult {
58 name: name.to_string(),
59 total_requests: 0,
60 total_duration: Duration::ZERO,
61 avg_latency_us: 0.0,
62 p50_latency_us: 0.0,
63 p95_latency_us: 0.0,
64 p99_latency_us: 0.0,
65 throughput_rps: 0.0,
66 };
67 }
68 let mut latencies = Vec::with_capacity(total as usize);
69 let chunk_size = total / concurrency as u64;
70 let remainder = total % concurrency as u64;
71 let warmup_chunk = warmup / concurrency as u64;
72 let warmup_remainder = warmup % concurrency as u64;
73 let mut handles = Vec::with_capacity(concurrency);
74 let shared_f = std::sync::Arc::new(f);
75 let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(concurrency + 1));
78
79 for i in 0..concurrency {
80 let f = std::sync::Arc::clone(&shared_f);
81 let barrier = std::sync::Arc::clone(&barrier);
82 let n = chunk_size + u64::from((i as u64) < remainder);
84 let wn = warmup_chunk + u64::from((i as u64) < warmup_remainder);
85 handles.push(tokio::spawn(async move {
86 for _ in 0..wn {
88 f().await;
89 }
90 barrier.wait().await;
91 let mut lats = Vec::with_capacity(n as usize);
92 for _ in 0..n {
93 let t0 = Instant::now();
94 f().await;
95 lats.push(t0.elapsed().as_micros() as f64);
96 }
97 lats
98 }));
99 }
100
101 let _ = tokio::time::timeout(Duration::from_secs(30), barrier.wait())
103 .await
104 .unwrap_or_else(|_| panic!("bench workers failed to reach steady state (warmup panic?)"));
105 let start = Instant::now();
106
107 for handle in handles {
108 if let Ok(lats) = handle.await {
109 latencies.extend(lats);
110 }
111 }
112
113 latencies.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
114 let total_duration = start.elapsed();
115 let count = latencies.len();
116 let avg = if count > 0 {
117 latencies.iter().sum::<f64>() / count as f64
118 } else {
119 0.0
120 };
121 let p50 = if count > 0 { latencies[count / 2] } else { 0.0 };
122 let p95 = if count > 0 {
123 latencies[(count as f64 * 0.95) as usize]
124 } else {
125 0.0
126 };
127 let p99 = if count > 0 {
128 latencies[(count as f64 * 0.99) as usize]
129 } else {
130 0.0
131 };
132
133 BenchResult {
134 name: name.to_string(),
135 total_requests: count as u64,
136 total_duration,
137 avg_latency_us: avg,
138 p50_latency_us: p50,
139 p95_latency_us: p95,
140 p99_latency_us: p99,
141 throughput_rps: count as f64 / total_duration.as_secs_f64(),
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use std::sync::Arc;
149 use std::sync::atomic::{AtomicUsize, Ordering};
150
151 #[tokio::test]
152 async fn bench_simple() {
153 let result = run_bench("noop", 2, 20, || async {}).await;
154 assert_eq!(result.total_requests, 20);
155 assert!(result.throughput_rps > 0.0);
156 }
157
158 #[tokio::test]
159 async fn bench_even_split() {
160 let result = run_bench("even", 5, 20, || async {}).await;
161 assert_eq!(result.total_requests, 20);
162 }
163
164 #[tokio::test]
165 async fn bench_distributes_remainder() {
166 let result = run_bench("rem", 3, 10, || async {}).await;
167 assert_eq!(result.total_requests, 10);
168 assert!(result.p50_latency_us >= 0.0);
169 assert!(result.p95_latency_us >= 0.0);
170 assert!(result.p99_latency_us >= 0.0);
171 }
172
173 #[tokio::test]
174 async fn bench_concurrency_greater_than_total() {
175 let result = run_bench("over", 10, 3, || async {}).await;
176 assert_eq!(result.total_requests, 3);
177 assert!(result.p50_latency_us >= 0.0);
178 assert!(result.p95_latency_us >= 0.0);
179 }
180
181 #[tokio::test]
182 async fn bench_zero_total() {
183 let result = run_bench("zero", 4, 0, || async {}).await;
184 assert_eq!(result.total_requests, 0);
185 assert_eq!(result.p50_latency_us, 0.0);
186 assert_eq!(result.p95_latency_us, 0.0);
187 assert_eq!(result.p99_latency_us, 0.0);
188 }
189
190 #[tokio::test]
191 async fn bench_zero_concurrency() {
192 let result = run_bench("noconc", 0, 10, || async {}).await;
193 assert_eq!(result.total_requests, 0);
194 }
195
196 #[tokio::test]
198 async fn warmup_excluded_from_stats() {
199 let calls = Arc::new(AtomicUsize::new(0));
200 let f = {
201 let calls = Arc::clone(&calls);
202 move || {
203 let calls = Arc::clone(&calls);
204 async move {
205 calls.fetch_add(1, Ordering::SeqCst);
206 }
207 }
208 };
209 let result = run_bench_with_warmup("warm", 2, 10, 6, f).await;
210 assert_eq!(
211 result.total_requests, 10,
212 "measured phase must exclude warmup"
213 );
214 assert_eq!(
215 calls.load(Ordering::SeqCst),
216 16,
217 "warmup requests must actually run"
218 );
219 }
220
221 #[tokio::test]
223 async fn warmup_removes_cold_start_from_p99() {
224 let first = Arc::new(AtomicUsize::new(0));
225 let f = {
226 let first = Arc::clone(&first);
227 move || {
228 let first = Arc::clone(&first);
229 async move {
230 if first.fetch_add(1, Ordering::SeqCst) == 0 {
231 tokio::time::sleep(Duration::from_millis(50)).await;
232 }
233 }
234 }
235 };
236 let result = run_bench_with_warmup("warm", 1, 20, 5, f).await;
237 assert_eq!(result.total_requests, 20);
238 assert!(
239 result.p99_latency_us < 45_000.0,
240 "cold start leaked into p99: {} µs",
241 result.p99_latency_us
242 );
243 assert!(
244 result.avg_latency_us < 45_000.0,
245 "cold start leaked into avg: {} µs",
246 result.avg_latency_us
247 );
248 }
249
250 #[tokio::test]
252 async fn warmup_zero_is_noop() {
253 let result = run_bench_with_warmup("zero-warm", 3, 12, 0, || async {}).await;
254 assert_eq!(result.total_requests, 12);
255 }
256
257 #[test]
258 fn bench_result_print() {
259 let r = BenchResult {
260 name: "test".into(),
261 total_requests: 100,
262 total_duration: Duration::from_secs(1),
263 avg_latency_us: 500.0,
264 p50_latency_us: 450.0,
265 p95_latency_us: 700.0,
266 p99_latency_us: 900.0,
267 throughput_rps: 100.0,
268 };
269 r.print();
270 }
271
272 #[tokio::test]
274 async fn p95_between_p50_and_p99() {
275 let result = run_bench("pct", 1, 20, || async {
276 tokio::time::sleep(Duration::from_millis(1)).await;
277 })
278 .await;
279 assert_eq!(result.total_requests, 20);
280 assert!(
281 result.p50_latency_us <= result.p95_latency_us,
282 "p95 must be >= p50: p50={} p95={}",
283 result.p50_latency_us,
284 result.p95_latency_us
285 );
286 assert!(
287 result.p95_latency_us <= result.p99_latency_us,
288 "p95 must be <= p99: p95={} p99={}",
289 result.p95_latency_us,
290 result.p99_latency_us
291 );
292 }
293}