Skip to main content

aurum_core/
bench.rs

1//! Lightweight reproducible benchmark helpers (JOE-1606).
2//!
3//! Full hardware matrix runs live in `scripts/bench_smoke.sh` and produce
4//! machine-readable JSON. This module defines the schema and micro-measurements
5//! that unit tests / PR smoke can exercise without pinned model downloads.
6
7use serde::{Deserialize, Serialize};
8use std::time::{Duration, Instant};
9
10/// Named reference profile for budgets.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum BenchProfile {
14    /// Apple Silicon + Metal whisper.
15    AppleSiliconMetal,
16    /// Generic x86_64 Linux CPU.
17    LinuxX86Cpu,
18    /// Constrained mobile/low-memory.
19    Constrained,
20}
21
22impl BenchProfile {
23    pub fn as_str(self) -> &'static str {
24        match self {
25            Self::AppleSiliconMetal => "apple_silicon_metal",
26            Self::LinuxX86Cpu => "linux_x86_cpu",
27            Self::Constrained => "constrained",
28        }
29    }
30
31    pub fn detect() -> Self {
32        #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
33        {
34            return Self::AppleSiliconMetal;
35        }
36        #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
37        {
38            return Self::LinuxX86Cpu;
39        }
40        #[allow(unreachable_code)]
41        Self::Constrained
42    }
43}
44
45/// One timed sample.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct BenchSample {
48    pub name: String,
49    pub iterations: u32,
50    pub wall_ms_p50: f64,
51    pub wall_ms_p95: f64,
52    pub wall_ms_mean: f64,
53    /// Optional peak RSS estimate in bytes (0 if not measured).
54    pub peak_rss_bytes: u64,
55    pub notes: String,
56}
57
58/// Machine-readable benchmark run.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct BenchReport {
61    pub schema_version: u32,
62    pub profile: BenchProfile,
63    pub commit: String,
64    pub target: String,
65    pub features: String,
66    pub warm: bool,
67    pub samples: Vec<BenchSample>,
68}
69
70impl BenchReport {
71    pub fn new(profile: BenchProfile, warm: bool) -> Self {
72        Self {
73            schema_version: 1,
74            profile,
75            commit: std::env::var("GITHUB_SHA")
76                .or_else(|_| std::env::var("AURUM_BENCH_COMMIT"))
77                .unwrap_or_else(|_| "unknown".into()),
78            target: format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS),
79            features: std::env::var("AURUM_BENCH_FEATURES").unwrap_or_else(|_| "default".into()),
80            warm,
81            samples: Vec::new(),
82        }
83    }
84}
85
86/// Run `f` for `iters` times; return sorted wall durations.
87pub fn time_iters<F: FnMut()>(mut f: F, iters: u32) -> Vec<Duration> {
88    let mut times = Vec::with_capacity(iters as usize);
89    for _ in 0..iters {
90        let t0 = Instant::now();
91        f();
92        times.push(t0.elapsed());
93    }
94    times.sort();
95    times
96}
97
98pub fn percentile_ms(sorted: &[Duration], p: f64) -> f64 {
99    if sorted.is_empty() {
100        return 0.0;
101    }
102    let idx =
103        ((p.clamp(0.0, 1.0) * (sorted.len() as f64 - 1.0)).round() as usize).min(sorted.len() - 1);
104    sorted[idx].as_secs_f64() * 1000.0
105}
106
107pub fn mean_ms(sorted: &[Duration]) -> f64 {
108    if sorted.is_empty() {
109        return 0.0;
110    }
111    let sum: f64 = sorted.iter().map(|d| d.as_secs_f64() * 1000.0).sum();
112    sum / sorted.len() as f64
113}
114
115/// Micro-bench: ring-buffer push throughput (PR smoke safe).
116pub fn sample_pcm_push(iters: u32) -> BenchSample {
117    use crate::pcm::PcmBuffer;
118    let chunk = vec![0.01f32; 1600]; // 100 ms
119    let times = time_iters(
120        || {
121            let mut buf = PcmBuffer::dictation();
122            for _ in 0..20 {
123                let _ = buf.push(&chunk);
124            }
125            let _ = buf.rms();
126        },
127        iters,
128    );
129    BenchSample {
130        name: "pcm_ring_push_20x100ms".into(),
131        iterations: iters,
132        wall_ms_p50: percentile_ms(&times, 0.50),
133        wall_ms_p95: percentile_ms(&times, 0.95),
134        wall_ms_mean: mean_ms(&times),
135        peak_rss_bytes: 0,
136        notes: "no model download; CPU only".into(),
137    }
138}
139
140/// Micro-bench: SRT stream format for N segments.
141pub fn sample_srt_stream(segments: usize, iters: u32) -> BenchSample {
142    use crate::output::{write_result_with_limit, OutputFormat};
143    use crate::providers::{Segment, TranscriptionResult};
144    let segs: Vec<Segment> = (0..segments)
145        .map(|i| Segment {
146            start: i as f64 * 0.5,
147            end: i as f64 * 0.5 + 0.4,
148            text: format!("segment number {i}"),
149        })
150        .collect();
151    let result = TranscriptionResult::local(
152        "bench".into(),
153        segs,
154        Some("en".into()),
155        "bench".into(),
156        segments as f64,
157    );
158    let times = time_iters(
159        || {
160            let mut out = Vec::with_capacity(segments * 64);
161            let _ = write_result_with_limit(&result, OutputFormat::Srt, &mut out, 16 * 1024 * 1024);
162        },
163        iters,
164    );
165    BenchSample {
166        name: format!("srt_stream_{segments}_segments"),
167        iterations: iters,
168        wall_ms_p50: percentile_ms(&times, 0.50),
169        wall_ms_p95: percentile_ms(&times, 0.95),
170        wall_ms_mean: mean_ms(&times),
171        peak_rss_bytes: 0,
172        notes: "streaming SRT writer".into(),
173    }
174}
175
176/// Soft regression thresholds for smoke samples (profile-specific multipliers).
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct SmokeBudgets {
179    pub pcm_push_p95_ms: f64,
180    pub srt_1k_p95_ms: f64,
181}
182
183impl SmokeBudgets {
184    pub fn for_profile(p: BenchProfile) -> Self {
185        match p {
186            BenchProfile::Constrained => Self {
187                pcm_push_p95_ms: 50.0,
188                srt_1k_p95_ms: 200.0,
189            },
190            _ => Self {
191                pcm_push_p95_ms: 25.0,
192                srt_1k_p95_ms: 100.0,
193            },
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn smoke_micro_benches_run() {
204        let profile = BenchProfile::detect();
205        let mut report = BenchReport::new(profile, true);
206        report.samples.push(sample_pcm_push(5));
207        report.samples.push(sample_srt_stream(200, 3));
208        let budgets = SmokeBudgets::for_profile(profile);
209        let pcm = report
210            .samples
211            .iter()
212            .find(|s| s.name.contains("pcm"))
213            .unwrap();
214        assert!(
215            pcm.wall_ms_p95 < budgets.pcm_push_p95_ms * 20.0,
216            "pcm p95 too high: {}",
217            pcm.wall_ms_p95
218        );
219        let json = serde_json::to_string_pretty(&report).unwrap();
220        assert!(json.contains("schema_version"));
221    }
222}