Skip to main content

commonware_runtime/iouring/
spinner.rs

1//! Adaptive idle spinning for the io_uring event loop.
2//!
3//! When the ring is fully idle, the loop would normally park on a futex
4//! immediately. The futex park/wake round-trip has non-trivial cost due
5//! to scheduler involvement. [`Spinner`] adds a short spin phase before
6//! parking: if new work arrives during the spin, the park is avoided
7//! entirely.
8//!
9//! The spin budget (in iteration counts) adapts based on outcomes:
10//! - Hit (work arrived during spin): budget grows (+25%).
11//! - Miss (spin expired, had to park): budget shrinks (-12.5%).
12//! - Quick wake (parked but woke fast): budget grows aggressively (+50%).
13//!
14//! A quick wake indicates the loop committed to parking just before work
15//! arrived. The observed park duration is roughly how much longer it should
16//! have spun to avoid the scheduler round-trip. The spinner tracks a running
17//! estimate of these durations as a *near-miss floor* that acts as a learned
18//! lower bound on the budget. Without it, repeated misses would decay the
19//! budget down to the configured minimum even when the workload's actual
20//! near-miss cost is higher. The floor decays gently on misses (1/16 per miss)
21//! so it doesn't stay pinned after a workload shift.
22//!
23//! A one-time calibration at construction converts user-facing microsecond
24//! configuration into iteration counts. The caller provides a probe function
25//! with the same cost profile as the real spin condition so calibration
26//! accounts for per-iteration condition overhead.
27
28use std::time::{Duration, Instant};
29
30/// Number of iterations used by the calibration loop.
31const CALIBRATION_ITERATIONS: usize = 100_000;
32
33/// Spinner configuration. Values are in microseconds.
34#[derive(Clone, Debug)]
35pub struct Config {
36    /// Initial and minimum spin budget in microseconds. The adaptive
37    /// controller grows the budget on hits and shrinks it on misses, but
38    /// never decays below this value. Set to 0 to disable spinning. Defaults
39    /// to 2.
40    pub budget_us: usize,
41    /// Maximum spin budget in microseconds. The controller never exceeds
42    /// this regardless of adaptation. Defaults to 10.
43    pub max_budget_us: usize,
44    /// Quick-wake threshold in microseconds. If the loop parks and wakes with
45    /// real work in less than this duration, the controller grows the budget
46    /// aggressively (the loop should have spun longer). Defaults to 10.
47    pub quick_wake_us: usize,
48}
49
50impl Config {
51    /// Returns a config that disables spinning entirely.
52    pub const fn disabled() -> Self {
53        Self {
54            budget_us: 0,
55            max_budget_us: 0,
56            quick_wake_us: 0,
57        }
58    }
59}
60
61impl Default for Config {
62    fn default() -> Self {
63        Self {
64            budget_us: 2,
65            max_budget_us: 10,
66            quick_wake_us: 10,
67        }
68    }
69}
70
71/// Adaptive spin budget controller.
72///
73/// Call [`Spinner::spin`] with a condition to busy-wait up to the current
74/// budget. On a miss, call [`Spinner::on_wake`] after the fallback park
75/// returns, the spinner uses the park duration to adjust its budget.
76pub struct Spinner {
77    /// Current spin budget, in iterations.
78    budget: usize,
79    /// Minimum budget, in iterations. Budget never decays below this.
80    min_budget: usize,
81    /// Maximum budget, in iterations. Budget never grows above this.
82    max_budget: usize,
83    /// Learned near-miss floor, in iterations. Tracks a weighted average of
84    /// recent quick-wake park durations. Can push the effective minimum above
85    /// `min_budget`. Decays on misses at 1/16 per miss to avoid staying pinned
86    /// after workload shifts.
87    near_miss_floor: usize,
88    /// Calibrated iterations per microsecond. Used to convert microsecond
89    /// config values into iteration counts and to convert quick-wake park
90    /// durations back into iteration counts for the near-miss floor.
91    iters_per_us: usize,
92    /// Quick-wake threshold. Parks shorter than this grow the budget.
93    quick_wake: Duration,
94}
95
96impl Spinner {
97    /// Create a new spinner. Runs a one-time calibration loop using `probe`
98    /// to measure the per-iteration cost of the real spin condition. The probe
99    /// should have the same cost as the condition passed to [`Spinner::spin`].
100    ///
101    /// Panics if `budget_us > max_budget_us`.
102    pub fn new(cfg: &Config, probe: impl Fn() -> bool) -> Self {
103        assert!(
104            cfg.budget_us <= cfg.max_budget_us,
105            "spinner budget_us ({}) must not exceed max_budget_us ({})",
106            cfg.budget_us,
107            cfg.max_budget_us,
108        );
109
110        // Disabled workers never spin, so calibration cannot affect their policy.
111        let iters_per_us = if cfg.budget_us == 0 {
112            0
113        } else {
114            calibrate(probe)
115        };
116        let min_budget = cfg.budget_us.saturating_mul(iters_per_us);
117        Self {
118            budget: min_budget,
119            min_budget,
120            max_budget: cfg.max_budget_us.saturating_mul(iters_per_us),
121            near_miss_floor: min_budget,
122            iters_per_us,
123            quick_wake: Duration::from_micros(cfg.quick_wake_us as u64),
124        }
125    }
126
127    /// Spin for up to `budget` iterations, checking `condition` each time.
128    /// Returns `true` if the condition was met (hit), `false` if the budget
129    /// expired (miss). Adapts the budget internally based on the outcome.
130    /// Returns `false` immediately without calling `condition` or adapting
131    /// state when the budget is zero (spinning disabled).
132    #[inline]
133    pub fn spin(&mut self, mut condition: impl FnMut() -> bool) -> bool {
134        if self.budget == 0 {
135            return false;
136        }
137        for _ in 0..self.budget {
138            if condition() {
139                self.on_hit();
140                return true;
141            }
142            core::hint::spin_loop();
143        }
144        self.on_miss();
145        false
146    }
147
148    /// Work arrived during spin. Reward by growing the budget.
149    #[inline]
150    fn on_hit(&mut self) {
151        // Grow by 25%, capped at max_budget.
152        self.budget = (self.budget + self.budget / 4).min(self.max_budget);
153    }
154
155    /// Spin expired without work arriving. Gently reduce the budget
156    /// and decay the near-miss floor so stale history doesn't keep the
157    /// floor pinned.
158    #[inline]
159    fn on_miss(&mut self) {
160        // Decay the learned floor by 1/16.
161        self.near_miss_floor -= self.near_miss_floor / 16;
162        // Shrink by 12.5%, clamped to the effective floor.
163        let floor = self.near_miss_floor.max(self.min_budget);
164        self.budget = self.budget.saturating_sub(self.budget / 8).max(floor);
165    }
166
167    /// Called after a futex park that actually slept. If the park was
168    /// shorter than the quick-wake threshold, grows the budget by 50% (we
169    /// should have spun longer) and updates the near-miss floor.
170    #[inline]
171    pub fn on_wake(&mut self, park_duration: Duration) {
172        if park_duration <= self.quick_wake {
173            // Grow by 50%, capped at max_budget.
174            self.budget = (self.budget + self.budget / 2).min(self.max_budget);
175            // Update the learned floor with a 1/4-weighted sample of the
176            // observed park duration, clamped so it can't exceed max_budget.
177            let park_iters = (park_duration.as_nanos() * self.iters_per_us as u128 / 1000) as usize;
178            let floor = (self.near_miss_floor * 3 + park_iters) / 4;
179            self.near_miss_floor = floor.min(self.max_budget);
180        }
181    }
182}
183
184/// Measure iterations per microsecond by running `probe` + `spin_loop()`
185/// in a tight loop. The probe should match the real spin condition's cost.
186fn calibrate(probe: impl Fn() -> bool) -> usize {
187    let start = Instant::now();
188    for _ in 0..CALIBRATION_ITERATIONS {
189        std::hint::black_box(probe());
190        core::hint::spin_loop();
191    }
192    let elapsed_us = start.elapsed().as_micros() as usize;
193    CALIBRATION_ITERATIONS / elapsed_us.max(1)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn cfg() -> Config {
201        Config {
202            budget_us: 10,
203            max_budget_us: 50,
204            quick_wake_us: 50,
205        }
206    }
207
208    #[test]
209    fn test_calibration_returns_nonzero() {
210        assert!(calibrate(|| false) > 0);
211    }
212
213    #[test]
214    fn test_spin_immediate_hit() {
215        let mut s = Spinner::new(&cfg(), || false);
216        assert!(s.spin(|| true));
217    }
218
219    #[test]
220    fn test_spin_miss() {
221        let mut s = Spinner::new(&cfg(), || false);
222        assert!(!s.spin(|| false));
223    }
224
225    #[test]
226    fn test_spin_delayed_hit() {
227        let mut s = Spinner::new(&cfg(), || false);
228        let mut count = 0usize;
229        let hit = s.spin(|| {
230            count += 1;
231            count >= 50
232        });
233        assert!(hit);
234        assert_eq!(count, 50);
235    }
236
237    #[test]
238    fn test_hit_grows_budget() {
239        let mut s = Spinner::new(&cfg(), || false);
240        let initial = s.budget;
241        s.spin(|| true);
242        assert!(s.budget > initial);
243    }
244
245    #[test]
246    fn test_miss_shrinks_budget() {
247        let mut s = Spinner::new(&cfg(), || false);
248        s.spin(|| true);
249        let after_hit = s.budget;
250        s.spin(|| false);
251        assert!(s.budget < after_hit);
252    }
253
254    #[test]
255    fn test_budget_does_not_go_below_min() {
256        let mut s = Spinner::new(&cfg(), || false);
257        for _ in 0..1000 {
258            s.spin(|| false);
259        }
260        assert!(s.budget >= s.min_budget);
261    }
262
263    #[test]
264    fn test_budget_does_not_exceed_max() {
265        let mut s = Spinner::new(&cfg(), || false);
266        for _ in 0..1000 {
267            s.spin(|| true);
268        }
269        assert!(s.budget <= s.max_budget);
270    }
271
272    #[test]
273    fn test_quick_wake_grows_budget_and_floor() {
274        let mut s = Spinner::new(&cfg(), || false);
275        assert_eq!(s.near_miss_floor, s.min_budget);
276        let before = s.near_miss_floor;
277        // Large quick-wake sample pushes the floor above min_budget.
278        s.on_wake(Duration::from_micros(30));
279        assert!(s.near_miss_floor > before);
280    }
281
282    #[test]
283    fn test_slow_block_does_not_grow() {
284        let mut s = Spinner::new(&cfg(), || false);
285        let before = s.budget;
286        s.on_wake(Duration::from_nanos(100_000));
287        assert_eq!(s.budget, before);
288    }
289
290    #[test]
291    fn test_near_miss_floor_decays_on_miss() {
292        let mut s = Spinner::new(&cfg(), || false);
293        s.on_wake(Duration::from_nanos(10_000));
294        let floor_after_wake = s.near_miss_floor;
295        assert!(floor_after_wake > 0);
296        for _ in 0..100 {
297            s.spin(|| false);
298        }
299        assert!(s.near_miss_floor < floor_after_wake);
300    }
301
302    #[test]
303    fn test_near_miss_floor_clamped_to_max() {
304        let mut s = Spinner::new(&cfg(), || false);
305        s.on_wake(Duration::from_nanos(40_000));
306        assert!(s.near_miss_floor <= s.max_budget);
307    }
308
309    #[test]
310    #[should_panic(expected = "must not exceed max_budget_us")]
311    fn test_new_panics_when_budget_exceeds_max() {
312        let _ = Spinner::new(
313            &Config {
314                budget_us: 100,
315                max_budget_us: 50,
316                quick_wake_us: 50,
317            },
318            || false,
319        );
320    }
321
322    #[test]
323    fn test_disabled_spinner() {
324        let mut s = Spinner::new(&Config::disabled(), || {
325            panic!("disabled spinning must skip calibration")
326        });
327        assert!(!s.spin(|| true));
328    }
329}