Skip to main content

ecat_bench/
lib.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2mod 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
40/// 与 [`run_bench`] 相同,但在测量前执行 `warmup` 次预热请求(延迟不进入
41/// 统计),让冷启动(连接建立、缓存预热等)不污染 p99/avg。
42///
43/// 稳态窗口通过屏障同步:所有 worker 完成预热后才开始计时,因此
44/// `total_duration` 与 throughput 只覆盖稳态测量阶段。
45pub 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    // concurrency+1 方:所有 worker 完成预热后(含主线程)同时释放,
76    // 主线程此刻起表,测量窗口恰好覆盖稳态阶段。
77    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        // Spread the remainder so no requests are dropped.
83        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            // 预热阶段:延迟丢弃,不计入统计
87            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    // worker 若在预热阶段 panic,屏障永远不会释放——超时失败而不是挂死
102    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    /// P2:预热请求必须真实执行但不计入统计。
197    #[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    /// P2:冷启动(首个请求 50ms)只落在预热阶段,不能污染稳态 p99/avg。
222    #[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    /// P2:warmup=0 时行为与 run_bench 等价。
251    #[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    /// P3:p95 介于 p50 与 p99 之间(确定性延迟下三分位一致)。
273    #[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}