use serde::{Deserialize, Serialize};
use std::cell::RefCell;
use std::time::{Duration, Instant};
use thiserror::Error;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BenchSpec {
pub name: String,
pub iterations: u32,
pub warmup: u32,
}
impl BenchSpec {
pub fn new(name: impl Into<String>, iterations: u32, warmup: u32) -> Result<Self, TimingError> {
if iterations == 0 {
return Err(TimingError::NoIterations { count: iterations });
}
Ok(Self {
name: name.into(),
iterations,
warmup,
})
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BenchSample {
pub duration_ns: u64,
}
impl BenchSample {
fn from_duration(duration: Duration) -> Self {
Self {
duration_ns: duration.as_nanos() as u64,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BenchReport {
pub spec: BenchSpec,
pub samples: Vec<BenchSample>,
pub phases: Vec<SemanticPhase>,
pub timeline: Vec<HarnessTimelineSpan>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HarnessTimelineSpan {
pub phase: String,
pub start_offset_ns: u64,
pub end_offset_ns: u64,
pub iteration: Option<u32>,
}
impl BenchReport {
#[must_use]
pub fn mean_ns(&self) -> f64 {
if self.samples.is_empty() {
return 0.0;
}
let sum: u64 = self.samples.iter().map(|s| s.duration_ns).sum();
sum as f64 / self.samples.len() as f64
}
#[must_use]
pub fn median_ns(&self) -> f64 {
if self.samples.is_empty() {
return 0.0;
}
let mut sorted: Vec<u64> = self.samples.iter().map(|s| s.duration_ns).collect();
sorted.sort_unstable();
let len = sorted.len();
if len % 2 == 0 {
(sorted[len / 2 - 1] + sorted[len / 2]) as f64 / 2.0
} else {
sorted[len / 2] as f64
}
}
#[must_use]
pub fn std_dev_ns(&self) -> f64 {
if self.samples.len() < 2 {
return 0.0;
}
let mean = self.mean_ns();
let variance: f64 = self
.samples
.iter()
.map(|s| {
let diff = s.duration_ns as f64 - mean;
diff * diff
})
.sum::<f64>()
/ (self.samples.len() - 1) as f64;
variance.sqrt()
}
#[must_use]
pub fn percentile_ns(&self, p: f64) -> f64 {
if self.samples.is_empty() {
return 0.0;
}
let mut sorted: Vec<u64> = self.samples.iter().map(|s| s.duration_ns).collect();
sorted.sort_unstable();
let p = p.clamp(0.0, 100.0) / 100.0;
let index = (p * (sorted.len() - 1) as f64).round() as usize;
sorted[index.min(sorted.len() - 1)] as f64
}
#[must_use]
pub fn min_ns(&self) -> u64 {
self.samples
.iter()
.map(|s| s.duration_ns)
.min()
.unwrap_or(0)
}
#[must_use]
pub fn max_ns(&self) -> u64 {
self.samples
.iter()
.map(|s| s.duration_ns)
.max()
.unwrap_or(0)
}
#[must_use]
pub fn summary(&self) -> BenchSummary {
BenchSummary {
name: self.spec.name.clone(),
iterations: self.samples.len() as u32,
warmup: self.spec.warmup,
mean_ns: self.mean_ns(),
median_ns: self.median_ns(),
std_dev_ns: self.std_dev_ns(),
min_ns: self.min_ns(),
max_ns: self.max_ns(),
p95_ns: self.percentile_ns(95.0),
p99_ns: self.percentile_ns(99.0),
}
}
}
fn instant_offset_ns(origin: Instant, instant: Instant) -> u64 {
instant
.duration_since(origin)
.as_nanos()
.min(u128::from(u64::MAX)) as u64
}
fn push_timeline_span(
timeline: &mut Vec<HarnessTimelineSpan>,
origin: Instant,
phase: &str,
started_at: Instant,
ended_at: Instant,
iteration: Option<u32>,
) {
timeline.push(HarnessTimelineSpan {
phase: phase.to_string(),
start_offset_ns: instant_offset_ns(origin, started_at),
end_offset_ns: instant_offset_ns(origin, ended_at),
iteration,
});
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BenchSummary {
pub name: String,
pub iterations: u32,
pub warmup: u32,
pub mean_ns: f64,
pub median_ns: f64,
pub std_dev_ns: f64,
pub min_ns: u64,
pub max_ns: u64,
pub p95_ns: f64,
pub p99_ns: f64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SemanticPhase {
pub name: String,
pub duration_ns: u64,
}
#[derive(Default)]
struct SemanticPhaseCollector {
enabled: bool,
depth: usize,
phases: Vec<SemanticPhase>,
}
impl SemanticPhaseCollector {
fn reset(&mut self) {
self.enabled = false;
self.depth = 0;
self.phases.clear();
}
fn begin_measurement(&mut self) {
self.reset();
self.enabled = true;
}
fn finish(&mut self) -> Vec<SemanticPhase> {
self.enabled = false;
self.depth = 0;
std::mem::take(&mut self.phases)
}
fn enter_phase(&mut self) -> Option<bool> {
if !self.enabled {
return None;
}
let top_level = self.depth == 0;
self.depth += 1;
Some(top_level)
}
fn exit_phase(&mut self, name: &str, top_level: bool, elapsed: Duration) {
self.depth = self.depth.saturating_sub(1);
if !self.enabled || !top_level {
return;
}
let duration_ns = elapsed.as_nanos().min(u128::from(u64::MAX)) as u64;
if let Some(phase) = self.phases.iter_mut().find(|phase| phase.name == name) {
phase.duration_ns = phase.duration_ns.saturating_add(duration_ns);
} else {
self.phases.push(SemanticPhase {
name: name.to_string(),
duration_ns,
});
}
}
}
thread_local! {
static SEMANTIC_PHASE_COLLECTOR: RefCell<SemanticPhaseCollector> =
RefCell::new(SemanticPhaseCollector::default());
}
struct SemanticPhaseGuard {
name: String,
started_at: Option<Instant>,
top_level: bool,
}
impl Drop for SemanticPhaseGuard {
fn drop(&mut self) {
let Some(started_at) = self.started_at else {
return;
};
let elapsed = started_at.elapsed();
SEMANTIC_PHASE_COLLECTOR.with(|collector| {
collector
.borrow_mut()
.exit_phase(&self.name, self.top_level, elapsed);
});
}
}
fn reset_semantic_phase_collection() {
SEMANTIC_PHASE_COLLECTOR.with(|collector| collector.borrow_mut().reset());
}
fn begin_semantic_phase_collection() {
SEMANTIC_PHASE_COLLECTOR.with(|collector| collector.borrow_mut().begin_measurement());
}
fn finish_semantic_phase_collection() -> Vec<SemanticPhase> {
SEMANTIC_PHASE_COLLECTOR.with(|collector| collector.borrow_mut().finish())
}
pub fn profile_phase<T>(name: &str, f: impl FnOnce() -> T) -> T {
let guard = SEMANTIC_PHASE_COLLECTOR.with(|collector| {
let mut collector = collector.borrow_mut();
match collector.enter_phase() {
Some(top_level) => SemanticPhaseGuard {
name: name.to_string(),
started_at: Some(Instant::now()),
top_level,
},
None => SemanticPhaseGuard {
name: String::new(),
started_at: None,
top_level: false,
},
}
});
let result = f();
drop(guard);
result
}
#[derive(Debug, Error)]
pub enum TimingError {
#[error("iterations must be greater than zero (got {count}). Minimum recommended: 10")]
NoIterations {
count: u32,
},
#[error("benchmark function failed: {0}")]
Execution(String),
}
pub fn run_closure<F>(spec: BenchSpec, mut f: F) -> Result<BenchReport, TimingError>
where
F: FnMut() -> Result<(), TimingError>,
{
if spec.iterations == 0 {
return Err(TimingError::NoIterations {
count: spec.iterations,
});
}
reset_semantic_phase_collection();
let harness_origin = Instant::now();
let mut timeline = Vec::new();
for iteration in 0..spec.warmup {
let phase_start = Instant::now();
f()?;
push_timeline_span(
&mut timeline,
harness_origin,
"warmup-benchmark",
phase_start,
Instant::now(),
Some(iteration),
);
}
begin_semantic_phase_collection();
let mut samples = Vec::with_capacity(spec.iterations as usize);
for iteration in 0..spec.iterations {
let start = Instant::now();
if let Err(err) = f() {
let _ = finish_semantic_phase_collection();
return Err(err);
}
let end = Instant::now();
samples.push(BenchSample::from_duration(end.duration_since(start)));
push_timeline_span(
&mut timeline,
harness_origin,
"measured-benchmark",
start,
end,
Some(iteration),
);
}
let phases = finish_semantic_phase_collection();
Ok(BenchReport {
spec,
samples,
phases,
timeline,
})
}
pub fn run_closure_with_setup<S, T, F>(
spec: BenchSpec,
setup: S,
mut f: F,
) -> Result<BenchReport, TimingError>
where
S: FnOnce() -> T,
F: FnMut(&T) -> Result<(), TimingError>,
{
if spec.iterations == 0 {
return Err(TimingError::NoIterations {
count: spec.iterations,
});
}
reset_semantic_phase_collection();
let harness_origin = Instant::now();
let mut timeline = Vec::new();
let setup_start = Instant::now();
let input = setup();
push_timeline_span(
&mut timeline,
harness_origin,
"setup",
setup_start,
Instant::now(),
None,
);
for iteration in 0..spec.warmup {
let phase_start = Instant::now();
f(&input)?;
push_timeline_span(
&mut timeline,
harness_origin,
"warmup-benchmark",
phase_start,
Instant::now(),
Some(iteration),
);
}
begin_semantic_phase_collection();
let mut samples = Vec::with_capacity(spec.iterations as usize);
for iteration in 0..spec.iterations {
let start = Instant::now();
if let Err(err) = f(&input) {
let _ = finish_semantic_phase_collection();
return Err(err);
}
let end = Instant::now();
samples.push(BenchSample::from_duration(end.duration_since(start)));
push_timeline_span(
&mut timeline,
harness_origin,
"measured-benchmark",
start,
end,
Some(iteration),
);
}
let phases = finish_semantic_phase_collection();
Ok(BenchReport {
spec,
samples,
phases,
timeline,
})
}
pub fn run_closure_with_setup_per_iter<S, T, F>(
spec: BenchSpec,
mut setup: S,
mut f: F,
) -> Result<BenchReport, TimingError>
where
S: FnMut() -> T,
F: FnMut(T) -> Result<(), TimingError>,
{
if spec.iterations == 0 {
return Err(TimingError::NoIterations {
count: spec.iterations,
});
}
reset_semantic_phase_collection();
let harness_origin = Instant::now();
let mut timeline = Vec::new();
for iteration in 0..spec.warmup {
let setup_start = Instant::now();
let input = setup();
push_timeline_span(
&mut timeline,
harness_origin,
"fixture-setup",
setup_start,
Instant::now(),
Some(iteration),
);
let phase_start = Instant::now();
f(input)?;
push_timeline_span(
&mut timeline,
harness_origin,
"warmup-benchmark",
phase_start,
Instant::now(),
Some(iteration),
);
}
begin_semantic_phase_collection();
let mut samples = Vec::with_capacity(spec.iterations as usize);
for iteration in 0..spec.iterations {
let setup_start = Instant::now();
let input = setup(); push_timeline_span(
&mut timeline,
harness_origin,
"fixture-setup",
setup_start,
Instant::now(),
Some(iteration),
);
let start = Instant::now();
if let Err(err) = f(input) {
let _ = finish_semantic_phase_collection();
return Err(err);
}
let end = Instant::now();
samples.push(BenchSample::from_duration(end.duration_since(start)));
push_timeline_span(
&mut timeline,
harness_origin,
"measured-benchmark",
start,
end,
Some(iteration),
);
}
let phases = finish_semantic_phase_collection();
Ok(BenchReport {
spec,
samples,
phases,
timeline,
})
}
pub fn run_closure_with_setup_teardown<S, T, F, D>(
spec: BenchSpec,
setup: S,
mut f: F,
teardown: D,
) -> Result<BenchReport, TimingError>
where
S: FnOnce() -> T,
F: FnMut(&T) -> Result<(), TimingError>,
D: FnOnce(T),
{
if spec.iterations == 0 {
return Err(TimingError::NoIterations {
count: spec.iterations,
});
}
reset_semantic_phase_collection();
let harness_origin = Instant::now();
let mut timeline = Vec::new();
let setup_start = Instant::now();
let input = setup();
push_timeline_span(
&mut timeline,
harness_origin,
"setup",
setup_start,
Instant::now(),
None,
);
for iteration in 0..spec.warmup {
let phase_start = Instant::now();
f(&input)?;
push_timeline_span(
&mut timeline,
harness_origin,
"warmup-benchmark",
phase_start,
Instant::now(),
Some(iteration),
);
}
begin_semantic_phase_collection();
let mut samples = Vec::with_capacity(spec.iterations as usize);
for iteration in 0..spec.iterations {
let start = Instant::now();
if let Err(err) = f(&input) {
let _ = finish_semantic_phase_collection();
return Err(err);
}
let end = Instant::now();
samples.push(BenchSample::from_duration(end.duration_since(start)));
push_timeline_span(
&mut timeline,
harness_origin,
"measured-benchmark",
start,
end,
Some(iteration),
);
}
let phases = finish_semantic_phase_collection();
let teardown_start = Instant::now();
teardown(input);
push_timeline_span(
&mut timeline,
harness_origin,
"teardown",
teardown_start,
Instant::now(),
None,
);
Ok(BenchReport {
spec,
samples,
phases,
timeline,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runs_benchmark_collects_requested_samples() {
let spec = BenchSpec::new("noop", 3, 1).unwrap();
let report = run_closure(spec, || Ok(())).unwrap();
assert_eq!(report.samples.len(), 3);
assert_eq!(report.spec.name, "noop");
assert_eq!(report.spec.iterations, 3);
}
#[test]
fn rejects_zero_iterations() {
let result = BenchSpec::new("test", 0, 10);
assert!(matches!(
result,
Err(TimingError::NoIterations { count: 0 })
));
}
#[test]
fn allows_zero_warmup() {
let spec = BenchSpec::new("test", 5, 0).unwrap();
assert_eq!(spec.warmup, 0);
let report = run_closure(spec, || Ok(())).unwrap();
assert_eq!(report.samples.len(), 5);
}
#[test]
fn serializes_to_json() {
let spec = BenchSpec::new("test", 10, 2).unwrap();
let report = run_closure(spec, || {
profile_phase("prove", || std::thread::sleep(Duration::from_millis(1)));
Ok(())
})
.unwrap();
let json = serde_json::to_string(&report).unwrap();
let restored: BenchReport = serde_json::from_str(&json).unwrap();
assert_eq!(restored.spec.name, "test");
assert_eq!(restored.samples.len(), 10);
assert_eq!(restored.phases.len(), 1);
assert_eq!(restored.phases[0].name, "prove");
assert!(restored.phases[0].duration_ns > 0);
}
#[test]
fn profile_phase_records_only_measured_iterations() {
let spec = BenchSpec::new("semantic", 2, 1).unwrap();
let mut call_index = 0u32;
let report = run_closure(spec, || {
let phase_name = if call_index == 0 {
"warmup-only"
} else {
"prove"
};
call_index += 1;
profile_phase(phase_name, || std::thread::sleep(Duration::from_millis(1)));
Ok(())
})
.unwrap();
assert!(
!report
.phases
.iter()
.any(|phase| phase.name == "warmup-only"),
"warmup phases should not be recorded"
);
let prove = report
.phases
.iter()
.find(|phase| phase.name == "prove")
.expect("prove phase");
assert!(prove.duration_ns > 0);
}
#[test]
fn profile_phase_keeps_the_v1_model_flat() {
let spec = BenchSpec::new("semantic-flat", 1, 0).unwrap();
let report = run_closure(spec, || {
profile_phase("prove", || {
std::thread::sleep(Duration::from_millis(1));
profile_phase("inner", || std::thread::sleep(Duration::from_millis(1)));
});
Ok(())
})
.unwrap();
assert!(report.phases.iter().any(|phase| phase.name == "prove"));
assert!(
!report.phases.iter().any(|phase| phase.name == "inner"),
"nested phases should not create a second flat phase entry"
);
}
#[test]
fn run_with_setup_calls_setup_once() {
use std::sync::atomic::{AtomicU32, Ordering};
static SETUP_COUNT: AtomicU32 = AtomicU32::new(0);
static RUN_COUNT: AtomicU32 = AtomicU32::new(0);
let spec = BenchSpec::new("test", 5, 2).unwrap();
let report = run_closure_with_setup(
spec,
|| {
SETUP_COUNT.fetch_add(1, Ordering::SeqCst);
vec![1, 2, 3]
},
|data| {
RUN_COUNT.fetch_add(1, Ordering::SeqCst);
std::hint::black_box(data.len());
Ok(())
},
)
.unwrap();
assert_eq!(SETUP_COUNT.load(Ordering::SeqCst), 1); assert_eq!(RUN_COUNT.load(Ordering::SeqCst), 7); assert_eq!(report.samples.len(), 5);
}
#[test]
fn run_with_setup_per_iter_calls_setup_each_time() {
use std::sync::atomic::{AtomicU32, Ordering};
static SETUP_COUNT: AtomicU32 = AtomicU32::new(0);
let spec = BenchSpec::new("test", 3, 1).unwrap();
let report = run_closure_with_setup_per_iter(
spec,
|| {
SETUP_COUNT.fetch_add(1, Ordering::SeqCst);
vec![1, 2, 3]
},
|data| {
std::hint::black_box(data);
Ok(())
},
)
.unwrap();
assert_eq!(SETUP_COUNT.load(Ordering::SeqCst), 4); assert_eq!(report.samples.len(), 3);
}
#[test]
fn run_with_setup_teardown_calls_both() {
use std::sync::atomic::{AtomicU32, Ordering};
static SETUP_COUNT: AtomicU32 = AtomicU32::new(0);
static TEARDOWN_COUNT: AtomicU32 = AtomicU32::new(0);
let spec = BenchSpec::new("test", 3, 1).unwrap();
let report = run_closure_with_setup_teardown(
spec,
|| {
SETUP_COUNT.fetch_add(1, Ordering::SeqCst);
"resource"
},
|_resource| Ok(()),
|_resource| {
TEARDOWN_COUNT.fetch_add(1, Ordering::SeqCst);
},
)
.unwrap();
assert_eq!(SETUP_COUNT.load(Ordering::SeqCst), 1);
assert_eq!(TEARDOWN_COUNT.load(Ordering::SeqCst), 1);
assert_eq!(report.samples.len(), 3);
}
#[test]
fn bench_report_serializes_exact_harness_timeline() {
let spec = BenchSpec::new("timeline", 2, 1).unwrap();
let report = run_closure_with_setup_teardown(
spec,
|| {
std::thread::sleep(Duration::from_millis(1));
"resource"
},
|_resource| {
std::thread::sleep(Duration::from_millis(1));
Ok(())
},
|_resource| {
std::thread::sleep(Duration::from_millis(1));
},
)
.unwrap();
let json = serde_json::to_value(&report).unwrap();
assert_eq!(json["timeline"][0]["phase"], "setup");
assert_eq!(json["timeline"][1]["phase"], "warmup-benchmark");
assert_eq!(json["timeline"][2]["phase"], "measured-benchmark");
assert_eq!(json["timeline"][3]["phase"], "measured-benchmark");
assert_eq!(json["timeline"][4]["phase"], "teardown");
}
}