Skip to main content

ecat_bench/
lib.rs

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