Skip to content

Commit f9fd5db

Browse files
committed
chore: add walltime_result from codspeed-rust
1 parent 4e745e0 commit f9fd5db

1 file changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
use anyhow::{Context, Result};
2+
use std::{
3+
io::Write,
4+
path::{Path, PathBuf},
5+
};
6+
7+
use serde::{Deserialize, Serialize};
8+
use statrs::statistics::{Data, Distribution, Max, Min, OrderStatistics};
9+
10+
const IQR_OUTLIER_FACTOR: f64 = 1.5;
11+
const STDEV_OUTLIER_FACTOR: f64 = 3.0;
12+
13+
#[derive(Debug, Serialize, Deserialize)]
14+
pub struct BenchmarkMetadata {
15+
pub name: String,
16+
pub uri: String,
17+
}
18+
19+
#[derive(Debug, Serialize, Deserialize)]
20+
struct BenchmarkStats {
21+
min_ns: f64,
22+
max_ns: f64,
23+
mean_ns: f64,
24+
stdev_ns: f64,
25+
26+
q1_ns: f64,
27+
median_ns: f64,
28+
q3_ns: f64,
29+
30+
rounds: u64,
31+
total_time: f64,
32+
iqr_outlier_rounds: u64,
33+
stdev_outlier_rounds: u64,
34+
iter_per_round: u64,
35+
warmup_iters: u64,
36+
}
37+
38+
#[derive(Debug, Serialize, Deserialize, Default)]
39+
struct BenchmarkConfig {
40+
warmup_time_ns: Option<f64>,
41+
min_round_time_ns: Option<f64>,
42+
max_time_ns: Option<f64>,
43+
max_rounds: Option<u64>,
44+
}
45+
46+
#[derive(Debug, Serialize, Deserialize)]
47+
pub struct WalltimeBenchmark {
48+
#[serde(flatten)]
49+
metadata: BenchmarkMetadata,
50+
51+
config: BenchmarkConfig,
52+
stats: BenchmarkStats,
53+
}
54+
55+
impl WalltimeBenchmark {
56+
/// Entry point called in patched integration to harvest raw walltime data
57+
///
58+
/// `CODSPEED_CARGO_WORKSPACE_ROOT` is expected to be set for this to work
59+
///
60+
/// # Arguments
61+
///
62+
/// - `scope`: The used integration, e.g. "divan" or "criterion"
63+
/// - `name`: The name of the benchmark
64+
/// - `uri`: The URI of the benchmark
65+
/// - `iters_per_round`: The number of iterations for each round (=sample_size), e.g. `[1, 2, 3]` (variable) or `[2, 2, 2, 2]` (constant).
66+
/// - `times_per_round_ns`: The measured time for each round in nanoseconds, e.g. `[1000, 2000, 3000]`
67+
/// - `max_time_ns`: The time limit for the benchmark in nanoseconds (if defined)
68+
///
69+
/// # Pseudo-code
70+
///
71+
/// ```text
72+
/// let sample_count = /* The number of executions for the same benchmark. */
73+
/// let sample_size = iters_per_round = vec![/* The number of iterations within each sample. */];
74+
/// for round in 0..sample_count {
75+
/// let times_per_round_ns = 0;
76+
/// for iteration in 0..sample_size[round] {
77+
/// run_benchmark();
78+
/// times_per_round_ns += /* measured execution time */;
79+
/// }
80+
/// }
81+
/// ```
82+
///
83+
pub fn collect_raw_walltime_results(
84+
scope: &str,
85+
name: String,
86+
uri: String,
87+
iters_per_round: Vec<u128>,
88+
times_per_round_ns: Vec<u128>,
89+
max_time_ns: Option<u128>,
90+
) {
91+
if !crate::utils::running_with_codspeed_runner() {
92+
return;
93+
}
94+
let workspace_root = std::env::var("CODSPEED_CARGO_WORKSPACE_ROOT").map(PathBuf::from);
95+
let Ok(workspace_root) = workspace_root else {
96+
eprintln!("codspeed failed to get workspace root. skipping");
97+
return;
98+
};
99+
let data = WalltimeBenchmark::from_runtime_data(
100+
name,
101+
uri,
102+
iters_per_round,
103+
times_per_round_ns,
104+
max_time_ns,
105+
);
106+
data.dump_to_results(&workspace_root, scope);
107+
}
108+
109+
pub fn from_runtime_data(
110+
name: String,
111+
uri: String,
112+
iters_per_round: Vec<u128>,
113+
times_per_round_ns: Vec<u128>,
114+
max_time_ns: Option<u128>,
115+
) -> Self {
116+
let total_time = times_per_round_ns.iter().sum::<u128>() as f64 / 1_000_000_000.0;
117+
let time_per_iteration_per_round_ns: Vec<_> = times_per_round_ns
118+
.into_iter()
119+
.zip(&iters_per_round)
120+
.map(|(time_per_round, iter_per_round)| time_per_round / iter_per_round)
121+
.map(|t| t as f64)
122+
.collect::<Vec<f64>>();
123+
124+
let mut data = Data::new(time_per_iteration_per_round_ns);
125+
let rounds = data.len() as u64;
126+
127+
let mean_ns = data.mean().unwrap();
128+
129+
let stdev_ns = if data.len() < 2 {
130+
// std_dev() returns f64::NAN if data has less than two entries, so we have to
131+
// manually handle this case.
132+
0.0
133+
} else {
134+
data.std_dev().unwrap()
135+
};
136+
137+
let q1_ns = data.quantile(0.25);
138+
let median_ns = data.median();
139+
let q3_ns = data.quantile(0.75);
140+
141+
let iqr_ns = q3_ns - q1_ns;
142+
let iqr_outlier_rounds = data
143+
.iter()
144+
.filter(|&&t| {
145+
t < q1_ns - IQR_OUTLIER_FACTOR * iqr_ns || t > q3_ns + IQR_OUTLIER_FACTOR * iqr_ns
146+
})
147+
.count() as u64;
148+
149+
let stdev_outlier_rounds = data
150+
.iter()
151+
.filter(|&&t| {
152+
t < mean_ns - STDEV_OUTLIER_FACTOR * stdev_ns
153+
|| t > mean_ns + STDEV_OUTLIER_FACTOR * stdev_ns
154+
})
155+
.count() as u64;
156+
157+
let min_ns = data.min();
158+
let max_ns = data.max();
159+
160+
// TODO(COD-1056): We currently only support single iteration count per round
161+
let iter_per_round =
162+
(iters_per_round.iter().sum::<u128>() / iters_per_round.len() as u128) as u64;
163+
let warmup_iters = 0; // FIXME: add warmup detection
164+
165+
let stats = BenchmarkStats {
166+
min_ns,
167+
max_ns,
168+
mean_ns,
169+
stdev_ns,
170+
q1_ns,
171+
median_ns,
172+
q3_ns,
173+
rounds,
174+
total_time,
175+
iqr_outlier_rounds,
176+
stdev_outlier_rounds,
177+
iter_per_round,
178+
warmup_iters,
179+
};
180+
181+
WalltimeBenchmark {
182+
metadata: BenchmarkMetadata { name, uri },
183+
config: BenchmarkConfig {
184+
max_time_ns: max_time_ns.map(|t| t as f64),
185+
..Default::default()
186+
},
187+
stats,
188+
}
189+
}
190+
191+
fn dump_to_results(&self, workspace_root: &Path, scope: &str) {
192+
let output_dir = result_dir_from_workspace_root(workspace_root).join(scope);
193+
std::fs::create_dir_all(&output_dir).unwrap();
194+
let bench_id = uuid::Uuid::new_v4().to_string();
195+
let output_path = output_dir.join(format!("{bench_id}.json"));
196+
let mut writer = std::fs::File::create(&output_path).expect("Failed to create the file");
197+
serde_json::to_writer_pretty(&mut writer, self).expect("Failed to write the data");
198+
writer.flush().expect("Failed to flush the writer");
199+
}
200+
201+
pub fn is_invalid(&self) -> bool {
202+
self.stats.min_ns < f64::EPSILON
203+
}
204+
205+
pub fn name(&self) -> &str {
206+
&self.metadata.name
207+
}
208+
}
209+
210+
#[derive(Debug, Serialize, Deserialize)]
211+
struct Instrument {
212+
#[serde(rename = "type")]
213+
type_: String,
214+
}
215+
216+
#[derive(Debug, Serialize, Deserialize)]
217+
struct Creator {
218+
name: String,
219+
version: String,
220+
pid: u32,
221+
}
222+
223+
#[derive(Debug, Serialize, Deserialize)]
224+
pub struct WalltimeResults {
225+
creator: Creator,
226+
instrument: Instrument,
227+
benchmarks: Vec<WalltimeBenchmark>,
228+
}
229+
230+
impl WalltimeResults {
231+
pub fn collect_walltime_results(workspace_root: &Path) -> Result<Self> {
232+
// retrieve data from `{workspace_root}/target/codspeed/raw_results/{scope}/*.json
233+
let benchmarks = glob::glob(&format!(
234+
"{}/**/*.json",
235+
result_dir_from_workspace_root(workspace_root)
236+
.to_str()
237+
.unwrap(),
238+
))?
239+
.map(|sample| -> Result<_> {
240+
let sample = sample?;
241+
serde_json::from_reader::<_, WalltimeBenchmark>(std::fs::File::open(&sample)?)
242+
.context("Failed to read benchmark data")
243+
})
244+
.collect::<Result<Vec<_>>>()?;
245+
246+
Ok(WalltimeResults {
247+
instrument: Instrument {
248+
type_: "walltime".to_string(),
249+
},
250+
creator: Creator {
251+
name: "codspeed-rust".to_string(),
252+
version: env!("CARGO_PKG_VERSION").to_string(),
253+
pid: std::process::id(),
254+
},
255+
benchmarks,
256+
})
257+
}
258+
259+
pub fn clear(workspace_root: &Path) -> Result<()> {
260+
let raw_results_dir = result_dir_from_workspace_root(workspace_root);
261+
std::fs::remove_dir_all(&raw_results_dir).ok(); // ignore errors when the directory does not exist
262+
std::fs::create_dir_all(&raw_results_dir)
263+
.context("Failed to create raw_results directory")?;
264+
Ok(())
265+
}
266+
267+
pub fn benchmarks(&self) -> &[WalltimeBenchmark] {
268+
&self.benchmarks
269+
}
270+
}
271+
272+
// FIXME: This assumes that the cargo target dir is `target`, and duplicates information with
273+
// `cargo-codspeed::helpers::get_codspeed_target_dir`
274+
fn result_dir_from_workspace_root(workspace_root: &Path) -> PathBuf {
275+
workspace_root
276+
.join("target")
277+
.join("codspeed")
278+
.join("walltime")
279+
.join("raw_results")
280+
}
281+
282+
#[cfg(test)]
283+
mod tests {
284+
use super::*;
285+
286+
const NAME: &str = "benchmark";
287+
const URI: &str = "test::benchmark";
288+
289+
#[test]
290+
fn test_parse_single_benchmark() {
291+
let benchmark = WalltimeBenchmark::from_runtime_data(
292+
NAME.to_string(),
293+
URI.to_string(),
294+
vec![1],
295+
vec![42],
296+
None,
297+
);
298+
assert_eq!(benchmark.stats.stdev_ns, 0.);
299+
assert_eq!(benchmark.stats.min_ns, 42.);
300+
assert_eq!(benchmark.stats.max_ns, 42.);
301+
assert_eq!(benchmark.stats.mean_ns, 42.);
302+
}
303+
304+
#[test]
305+
fn test_parse_bench_with_variable_iterations() {
306+
let iters_per_round = vec![1, 2, 3, 4, 5, 6];
307+
let total_rounds = iters_per_round.iter().sum::<u128>() as f64;
308+
309+
let benchmark = WalltimeBenchmark::from_runtime_data(
310+
NAME.to_string(),
311+
URI.to_string(),
312+
iters_per_round,
313+
vec![42, 42 * 2, 42 * 3, 42 * 4, 42 * 5, 42 * 6],
314+
None,
315+
);
316+
317+
assert_eq!(benchmark.stats.stdev_ns, 0.);
318+
assert_eq!(benchmark.stats.min_ns, 42.);
319+
assert_eq!(benchmark.stats.max_ns, 42.);
320+
assert_eq!(benchmark.stats.mean_ns, 42.);
321+
assert_eq!(
322+
benchmark.stats.total_time,
323+
42. * total_rounds / 1_000_000_000.0
324+
);
325+
}
326+
}

0 commit comments

Comments
 (0)