use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use crate::error::{Result, SiliceraError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeasurementConfig {
pub warmup: usize,
pub iterations: usize,
pub instability_iqr_ratio: f64,
pub instability_cv: f64,
pub outlier_fence: f64,
}
impl Default for MeasurementConfig {
fn default() -> Self {
Self {
warmup: 5,
iterations: 30,
instability_iqr_ratio: 0.35,
instability_cv: 0.20,
outlier_fence: 1.5,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Stability {
Stable,
Noisy,
Unstable,
}
impl Stability {
pub fn label(self) -> &'static str {
match self {
Stability::Stable => "STABLE",
Stability::Noisy => "NOISY",
Stability::Unstable => "UNSTABLE",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeasurementSummary {
pub n: usize,
pub min_ns: f64,
pub max_ns: f64,
pub mean_ns: f64,
pub median_ns: f64,
pub variance_ns2: f64,
pub stddev_ns: f64,
pub p25_ns: f64,
pub p75_ns: f64,
pub p95_ns: f64,
pub p99_ns: f64,
pub outlier_count: usize,
pub stability: Stability,
pub flags: Vec<String>,
}
impl MeasurementSummary {
pub fn from_samples(samples: &[f64], cfg: &MeasurementConfig) -> Result<Self> {
if samples.is_empty() {
return Err(SiliceraError::MeasurementUnstable(
"no samples collected".into(),
));
}
let mut sorted = samples.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = sorted.len();
let min_ns = sorted[0];
let max_ns = sorted[n - 1];
let mean_ns = sorted.iter().sum::<f64>() / n as f64;
let median_ns = percentile_sorted(&sorted, 0.50);
let p25_ns = percentile_sorted(&sorted, 0.25);
let p75_ns = percentile_sorted(&sorted, 0.75);
let p95_ns = percentile_sorted(&sorted, 0.95);
let p99_ns = percentile_sorted(&sorted, 0.99);
let variance_ns2 = if n > 1 {
sorted.iter().map(|x| {
let d = x - mean_ns;
d * d
}).sum::<f64>() / (n - 1) as f64
} else {
0.0
};
let stddev_ns = variance_ns2.sqrt();
let iqr = p75_ns - p25_ns;
let lo = p25_ns - cfg.outlier_fence * iqr;
let hi = p75_ns + cfg.outlier_fence * iqr;
let outlier_count = sorted.iter().filter(|&&x| x < lo || x > hi).count();
let mut flags = Vec::new();
let cv = if mean_ns > 0.0 {
stddev_ns / mean_ns
} else {
0.0
};
let iqr_ratio = if median_ns > 0.0 {
iqr / median_ns
} else {
0.0
};
if cv > cfg.instability_cv {
flags.push(format!("cv={cv:.3} > {}", cfg.instability_cv));
}
if iqr_ratio > cfg.instability_iqr_ratio {
flags.push(format!(
"iqr/median={iqr_ratio:.3} > {}",
cfg.instability_iqr_ratio
));
}
if outlier_count * 5 > n {
flags.push(format!("outliers={outlier_count}/{n}"));
}
let stability = if cv > cfg.instability_cv * 1.5
|| iqr_ratio > cfg.instability_iqr_ratio * 1.5
{
Stability::Unstable
} else if !flags.is_empty() {
Stability::Noisy
} else {
Stability::Stable
};
Ok(Self {
n,
min_ns,
max_ns,
mean_ns,
median_ns,
variance_ns2,
stddev_ns,
p25_ns,
p75_ns,
p95_ns,
p99_ns,
outlier_count,
stability,
flags,
})
}
pub fn score_ns(&self) -> f64 {
self.median_ns
}
}
#[derive(Debug, Clone)]
pub struct MeasurementEngine {
pub config: MeasurementConfig,
}
impl Default for MeasurementEngine {
fn default() -> Self {
Self {
config: MeasurementConfig::default(),
}
}
}
impl MeasurementEngine {
pub fn new(config: MeasurementConfig) -> Self {
Self { config }
}
pub fn measure<F>(&self, mut f: F) -> Result<MeasurementSummary>
where
F: FnMut(),
{
for _ in 0..self.config.warmup {
f();
std::hint::black_box(());
}
let mut samples = Vec::with_capacity(self.config.iterations);
for _ in 0..self.config.iterations {
let start = Instant::now();
f();
let elapsed = start.elapsed();
samples.push(duration_ns(elapsed));
std::hint::black_box(());
}
MeasurementSummary::from_samples(&samples, &self.config)
}
pub fn measure_with_output<F, T>(&self, mut f: F) -> Result<(MeasurementSummary, T)>
where
F: FnMut() -> T,
T: Clone,
{
let mut last = None;
for _ in 0..self.config.warmup {
last = Some(f());
std::hint::black_box(());
}
let mut samples = Vec::with_capacity(self.config.iterations);
for _ in 0..self.config.iterations {
let start = Instant::now();
let out = f();
let elapsed = start.elapsed();
samples.push(duration_ns(elapsed));
last = Some(out);
std::hint::black_box(());
}
let summary = MeasurementSummary::from_samples(&samples, &self.config)?;
let out = last.ok_or_else(|| {
SiliceraError::Internal("measure_with_output produced no output".into())
})?;
Ok((summary, out))
}
}
fn duration_ns(d: Duration) -> f64 {
d.as_secs_f64() * 1_000_000_000.0
}
fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
if sorted.len() == 1 {
return sorted[0];
}
let idx = p * (sorted.len() - 1) as f64;
let lo = idx.floor() as usize;
let hi = idx.ceil() as usize;
if lo == hi {
sorted[lo]
} else {
let w = idx - lo as f64;
sorted[lo] * (1.0 - w) + sorted[hi] * w
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn summary_stable_constant() {
let samples: Vec<f64> = (0..40).map(|_| 1000.0).collect();
let s = MeasurementSummary::from_samples(&samples, &MeasurementConfig::default()).unwrap();
assert_eq!(s.stability, Stability::Stable);
assert_eq!(s.median_ns, 1000.0);
}
#[test]
fn engine_runs() {
let eng = MeasurementEngine::new(MeasurementConfig {
warmup: 1,
iterations: 5,
..Default::default()
});
let mut x = 0u64;
let s = eng
.measure(|| {
x = x.wrapping_add(1);
std::hint::black_box(x);
})
.unwrap();
assert_eq!(s.n, 5);
}
}