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