Skip to main content

loom_rs/mab/
calibration.rs

1//! Optional startup calibration to measure offload overhead.
2//!
3//! Calibration runs during runtime construction (if enabled) and measures
4//! the fixed overhead of offloading work to rayon. This helps the MAB make
5//! better decisions for borderline workloads.
6//!
7//! # Default Behavior
8//!
9//! Calibration is **disabled by default** for fast unit test startup.
10//! Enable it via `LoomBuilder::calibrate(true)` for production use.
11
12use serde::{Deserialize, Serialize};
13use std::time::Instant;
14
15use crate::bridge::RayonTask;
16
17/// Results from runtime calibration.
18#[derive(Clone, Debug)]
19pub struct CalibrationResult {
20    /// Measured overhead of offloading to rayon (spawn + queue + wake) in microseconds.
21    /// This is the median round-trip time for a no-op task.
22    pub offload_overhead_us: f64,
23
24    /// P50 (median) of measured samples
25    pub p50_us: f64,
26
27    /// P99 of measured samples (useful for understanding tail latency)
28    pub p99_us: f64,
29}
30
31/// Configuration for the calibration phase.
32#[derive(Clone, Debug, Serialize, Deserialize)]
33pub struct CalibrationConfig {
34    /// Whether to run calibration at startup.
35    /// Default: false (for fast unit test startup)
36    #[serde(default)]
37    pub enabled: bool,
38
39    /// Number of warmup iterations before measuring.
40    /// Default: 100
41    #[serde(default = "default_warmup_iterations")]
42    pub warmup_iterations: usize,
43
44    /// Number of measurement samples.
45    /// Default: 1000
46    #[serde(default = "default_sample_count")]
47    pub sample_count: usize,
48}
49
50fn default_warmup_iterations() -> usize {
51    100
52}
53
54fn default_sample_count() -> usize {
55    1000
56}
57
58impl Default for CalibrationConfig {
59    fn default() -> Self {
60        Self {
61            enabled: false,
62            warmup_iterations: default_warmup_iterations(),
63            sample_count: default_sample_count(),
64        }
65    }
66}
67
68impl CalibrationConfig {
69    /// Create a new calibration config with defaults.
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Enable calibration.
75    pub fn enabled(mut self) -> Self {
76        self.enabled = true;
77        self
78    }
79
80    /// Set the number of warmup iterations.
81    pub fn warmup_iterations(mut self, count: usize) -> Self {
82        self.warmup_iterations = count;
83        self
84    }
85
86    /// Set the number of measurement samples.
87    pub fn sample_count(mut self, count: usize) -> Self {
88        self.sample_count = count;
89        self
90    }
91}
92
93/// Run calibration to measure offload overhead.
94///
95/// Should be called during LoomRuntime construction if enabled.
96/// This is an async function that must be run within the tokio context.
97pub async fn calibrate(
98    rayon_pool: &rayon::ThreadPool,
99    config: &CalibrationConfig,
100) -> CalibrationResult {
101    // Warmup: let thread pools settle, populate caches
102    for _ in 0..config.warmup_iterations {
103        let (task, completion) = RayonTask::<()>::new();
104        rayon_pool.spawn(move || {
105            completion.complete(());
106        });
107        task.await;
108    }
109
110    // Measure offload overhead (no-op task round-trip)
111    let mut samples = Vec::with_capacity(config.sample_count);
112    for _ in 0..config.sample_count {
113        let start = Instant::now();
114        let (task, completion) = RayonTask::<()>::new();
115        rayon_pool.spawn(move || {
116            // Use black_box to prevent the compiler from optimizing away the task
117            std::hint::black_box(());
118            completion.complete(());
119        });
120        task.await;
121        samples.push(start.elapsed().as_secs_f64() * 1_000_000.0);
122    }
123
124    // Compute statistics
125    samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
126    let p50_us = percentile_sorted(&samples, 50.0);
127    let p99_us = percentile_sorted(&samples, 99.0);
128
129    CalibrationResult {
130        offload_overhead_us: p50_us, // Use median as the representative overhead
131        p50_us,
132        p99_us,
133    }
134}
135
136/// Calculate a percentile from a sorted slice.
137fn percentile_sorted(sorted: &[f64], pct: f64) -> f64 {
138    if sorted.is_empty() {
139        return 0.0;
140    }
141    let idx = ((pct / 100.0) * (sorted.len() - 1) as f64).round() as usize;
142    sorted[idx.min(sorted.len() - 1)]
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_calibration_config_defaults() {
151        let config = CalibrationConfig::default();
152        assert!(!config.enabled);
153        assert_eq!(config.warmup_iterations, 100);
154        assert_eq!(config.sample_count, 1000);
155    }
156
157    #[test]
158    fn test_calibration_config_builder() {
159        let config = CalibrationConfig::new()
160            .enabled()
161            .warmup_iterations(50)
162            .sample_count(500);
163
164        assert!(config.enabled);
165        assert_eq!(config.warmup_iterations, 50);
166        assert_eq!(config.sample_count, 500);
167    }
168
169    #[test]
170    fn test_percentile_sorted() {
171        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
172
173        assert!((percentile_sorted(&data, 0.0) - 0.0).abs() < 0.5);
174        assert!((percentile_sorted(&data, 50.0) - 50.0).abs() < 0.5);
175        assert!((percentile_sorted(&data, 100.0) - 99.0).abs() < 0.5);
176    }
177
178    #[test]
179    fn test_percentile_sorted_empty() {
180        let data: Vec<f64> = vec![];
181        assert_eq!(percentile_sorted(&data, 50.0), 0.0);
182    }
183
184    #[test]
185    fn test_calibration_config_serialization() {
186        let config = CalibrationConfig::default();
187        let json = serde_json::to_string(&config).unwrap();
188        let parsed: CalibrationConfig = serde_json::from_str(&json).unwrap();
189        assert_eq!(parsed.enabled, config.enabled);
190        assert_eq!(parsed.warmup_iterations, config.warmup_iterations);
191    }
192}