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| {
146            Segment::from_parts_unchecked(
147                i as f64 * 0.5,
148                i as f64 * 0.5 + 0.4,
149                format!("segment number {i}"),
150            )
151        })
152        .collect();
153    let result = TranscriptionResult::local(
154        "bench".into(),
155        segs,
156        Some("en".into()),
157        "bench".into(),
158        segments as f64,
159    );
160    let times = time_iters(
161        || {
162            let mut out = Vec::with_capacity(segments * 64);
163            let _ = write_result_with_limit(&result, OutputFormat::Srt, &mut out, 16 * 1024 * 1024);
164        },
165        iters,
166    );
167    BenchSample {
168        name: format!("srt_stream_{segments}_segments"),
169        iterations: iters,
170        wall_ms_p50: percentile_ms(&times, 0.50),
171        wall_ms_p95: percentile_ms(&times, 0.95),
172        wall_ms_mean: mean_ms(&times),
173        peak_rss_bytes: 0,
174        notes: "streaming SRT writer".into(),
175    }
176}
177
178/// Soft regression thresholds for smoke samples (profile-specific multipliers).
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct SmokeBudgets {
181    pub pcm_push_p95_ms: f64,
182    pub srt_1k_p95_ms: f64,
183}
184
185impl SmokeBudgets {
186    pub fn for_profile(p: BenchProfile) -> Self {
187        match p {
188            BenchProfile::Constrained => Self {
189                pcm_push_p95_ms: 50.0,
190                srt_1k_p95_ms: 200.0,
191            },
192            _ => Self {
193                pcm_push_p95_ms: 25.0,
194                srt_1k_p95_ms: 100.0,
195            },
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn smoke_micro_benches_run() {
206        let profile = BenchProfile::detect();
207        let mut report = BenchReport::new(profile, true);
208        report.samples.push(sample_pcm_push(5));
209        report.samples.push(sample_srt_stream(200, 3));
210        let budgets = SmokeBudgets::for_profile(profile);
211        let pcm = report
212            .samples
213            .iter()
214            .find(|s| s.name.contains("pcm"))
215            .unwrap();
216        assert!(
217            pcm.wall_ms_p95 < budgets.pcm_push_p95_ms * 20.0,
218            "pcm p95 too high: {}",
219            pcm.wall_ms_p95
220        );
221        let json = serde_json::to_string_pretty(&report).unwrap();
222        assert!(json.contains("schema_version"));
223    }
224}