Skip to main content

dev_stress/
lib.rs

1//! # dev-stress
2//!
3//! High-load stress testing for Rust. Concurrency, volume, saturation
4//! under pressure. Part of the `dev-*` verification suite.
5//!
6//! `dev-stress` is the answer to "does this code survive real load?"
7//! Not "is it fast" (that's `dev-bench`). Not "does it deadlock"
8//! (that's `dev-async`). Not "does it recover from failure" (that's
9//! `dev-chaos`).
10//!
11//! `dev-stress` measures how a workload behaves when the inputs scale
12//! up: thousands of concurrent operations, millions of iterations,
13//! sustained pressure. It detects throughput collapse, latency
14//! cliff-falls, and lock contention.
15//!
16//! ## Quick example
17//!
18//! ```no_run
19//! use dev_stress::{Workload, StressRun};
20//!
21//! #[derive(Clone)]
22//! struct MyWorkload;
23//! impl Workload for MyWorkload {
24//!     fn run_once(&self) {
25//!         std::hint::black_box(40 + 2);
26//!     }
27//! }
28//!
29//! let run = StressRun::new("hot_path")
30//!     .iterations(100_000)
31//!     .threads(8);
32//!
33//! let result = run.execute(&MyWorkload);
34//! let check = result.into_check_result(None);
35//! ```
36
37#![cfg_attr(docsrs, feature(doc_cfg))]
38#![warn(missing_docs)]
39#![warn(rust_2018_idioms)]
40
41use std::sync::Arc;
42use std::time::{Duration, Instant};
43
44use dev_report::{CheckResult, Severity};
45
46/// A workload that can be executed many times under stress.
47pub trait Workload: Send + Sync {
48    /// Execute one unit of work. MUST be safe to call concurrently
49    /// from multiple threads.
50    fn run_once(&self);
51}
52
53/// Configuration for a stress run.
54pub struct StressRun {
55    name: String,
56    iterations: usize,
57    threads: usize,
58}
59
60impl StressRun {
61    /// Begin building a stress run with a stable name.
62    pub fn new(name: impl Into<String>) -> Self {
63        Self {
64            name: name.into(),
65            iterations: 1_000,
66            threads: 1,
67        }
68    }
69
70    /// Total iterations across all threads.
71    pub fn iterations(mut self, n: usize) -> Self {
72        self.iterations = n;
73        self
74    }
75
76    /// Number of OS threads to run concurrently.
77    pub fn threads(mut self, n: usize) -> Self {
78        self.threads = n.max(1);
79        self
80    }
81
82    /// Execute the run. Returns a result with timing statistics.
83    pub fn execute<W: Workload + 'static>(&self, workload: &W) -> StressResult
84    where
85        W: Clone,
86    {
87        let per_thread = self.iterations / self.threads;
88        let leftover = self.iterations % self.threads;
89        let started = Instant::now();
90        let mut handles = Vec::with_capacity(self.threads);
91        let workload = Arc::new(workload.clone());
92
93        for t in 0..self.threads {
94            let count = per_thread + if t < leftover { 1 } else { 0 };
95            let w = workload.clone();
96            handles.push(std::thread::spawn(move || {
97                let start = Instant::now();
98                for _ in 0..count {
99                    w.run_once();
100                }
101                start.elapsed()
102            }));
103        }
104
105        let mut thread_times = Vec::with_capacity(self.threads);
106        for h in handles {
107            thread_times.push(h.join().unwrap());
108        }
109        let total_elapsed = started.elapsed();
110
111        StressResult {
112            name: self.name.clone(),
113            iterations: self.iterations,
114            threads: self.threads,
115            total_elapsed,
116            thread_times,
117        }
118    }
119}
120
121/// Result of a stress run.
122#[derive(Debug, Clone)]
123pub struct StressResult {
124    /// Stable name of the run.
125    pub name: String,
126    /// Iterations actually executed.
127    pub iterations: usize,
128    /// Threads used.
129    pub threads: usize,
130    /// Wall-clock time from run start to all threads finishing.
131    pub total_elapsed: Duration,
132    /// Per-thread elapsed times. Variance here indicates contention.
133    pub thread_times: Vec<Duration>,
134}
135
136impl StressResult {
137    /// Effective throughput in operations per second.
138    pub fn ops_per_sec(&self) -> f64 {
139        if self.total_elapsed.is_zero() {
140            return 0.0;
141        }
142        self.iterations as f64 / self.total_elapsed.as_secs_f64()
143    }
144
145    /// Coefficient of variation across thread times. Higher numbers
146    /// indicate worse contention or load imbalance.
147    pub fn thread_time_cv(&self) -> f64 {
148        if self.thread_times.is_empty() {
149            return 0.0;
150        }
151        let n = self.thread_times.len() as f64;
152        let mean: f64 = self
153            .thread_times
154            .iter()
155            .map(|d| d.as_secs_f64())
156            .sum::<f64>()
157            / n;
158        if mean == 0.0 {
159            return 0.0;
160        }
161        let var = self
162            .thread_times
163            .iter()
164            .map(|d| (d.as_secs_f64() - mean).powi(2))
165            .sum::<f64>()
166            / n;
167        var.sqrt() / mean
168    }
169
170    /// Convert this result into a `CheckResult`. If a baseline
171    /// throughput is provided, regression-style verdict is computed.
172    pub fn into_check_result(self, baseline_ops_per_sec: Option<f64>) -> CheckResult {
173        let ops = self.ops_per_sec();
174        let cv = self.thread_time_cv();
175        let detail = format!(
176            "iterations={}, threads={}, total={:.3}s, ops/sec={:.0}, thread_cv={:.3}",
177            self.iterations,
178            self.threads,
179            self.total_elapsed.as_secs_f64(),
180            ops,
181            cv
182        );
183        let name = format!("stress::{}", self.name);
184        match baseline_ops_per_sec {
185            None => CheckResult::pass(name).with_detail(detail),
186            Some(baseline) if ops < baseline * 0.9 => {
187                CheckResult::fail(name, Severity::Warning).with_detail(detail)
188            }
189            Some(_) => CheckResult::pass(name).with_detail(detail),
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[derive(Clone)]
199    struct Noop;
200    impl Workload for Noop {
201        fn run_once(&self) {
202            std::hint::black_box(1 + 1);
203        }
204    }
205
206    #[test]
207    fn run_completes() {
208        let run = StressRun::new("noop").iterations(1_000).threads(2);
209        let r = run.execute(&Noop);
210        assert_eq!(r.iterations, 1_000);
211        assert_eq!(r.threads, 2);
212        assert!(r.ops_per_sec() > 0.0);
213    }
214
215    #[test]
216    fn no_baseline_passes() {
217        let run = StressRun::new("noop").iterations(100).threads(1);
218        let r = run.execute(&Noop);
219        let c = r.into_check_result(None);
220        assert!(matches!(c.verdict, dev_report::Verdict::Pass));
221    }
222}