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}