fix(rfc-004): gate spinning on max_spinners>0, killing wake-nobody futex_wake at N=1

`spinning` was `spin_budget_cycles > 0` alone. At N=1 the pool-size cap is 0, so
spinning was enabled while no scheduler could ever enlist: n_spinning pinned at 0,
so the enqueue submit-rule guard (n_spinning==0) fired a wake-nobody futex_wake on
every push. Now gated on `budget > 0 && max_spinners > 0`; a debug_assert guards
the invariant against regression. No behavioural change (a 0-cap runtime never
parks on the futex either way) — single_thread_spinning_is_inert and the AllDone
liveness test (shutdown_releases_pinned_parked_siblings) still pass.

switch_cost N=1 (1-core sandbox): p50 ~466ns -> ~186ns. The wake-nobody syscall
was ~60% of the round-trip in wall-clock terms; the handoff's "~12%" was perf
self-time, not latency share. ROADMAP per-switch section updated to fold in the
N=1 spike correction (shim hypothesis is many-core only). Dropped the throwaway
budget_probe bench (switch_cost reproduces every state via .spin_budget_cycles/
.max_spinners and with percentiles).
This commit is contained in:
smarm-agent
2026-06-14 20:01:02 +00:00
parent debcb4e33b
commit 2f011f6ce4
2 changed files with 54 additions and 10 deletions
+26 -5
View File
@@ -540,10 +540,13 @@ pub(crate) struct RuntimeInner {
/// wait immediately (EAGAIN) instead of sleeping — the lost-wakeup guard.
/// One shared word; `futex_wake(1)` wakes exactly one parked scheduler.
pub(crate) park_seq: AtomicU32,
/// RFC 004: `spin_budget_cycles > 0`. When false the feature is fully off —
/// the idle path is the historical `thread::sleep` park, `n_spinning` and
/// `park_seq` are never touched, and the submit rule is a no-op. This is
/// what makes `spin_budget_cycles = 0` recover today's behaviour exactly.
/// RFC 004: `spin_budget_cycles > 0 && max_spinners > 0`. When false the
/// feature is fully off — the idle path is the historical `thread::sleep`
/// park, `n_spinning` and `park_seq` are never touched, and the submit rule
/// is a no-op. Note the cap term: a budget with a 0 cap (e.g. the N=1
/// pool-size gate) can enlist no spinner, so it is treated as off — this is
/// what makes `spin_budget_cycles = 0` AND the 0-cap case recover today's
/// behaviour exactly.
pub(crate) spinning: bool,
/// RFC 004: TSC cycles a scheduler searches before parking (`Config`).
pub(crate) spin_budget_cycles: u64,
@@ -593,6 +596,24 @@ impl RuntimeInner {
let slots: Box<[Slot]> = (0..max_actors).map(|_| Slot::vacant()).collect();
// Low indices on top of the stack so early spawns get low pids.
let free: Vec<u32> = (0..max_actors as u32).rev().collect();
// RFC 004: the spin/futex machinery is only live when BOTH a budget is
// set AND the cap permits at least one spinner. A budget with a 0 cap
// (e.g. the N=1 pool-size gate) can never enlist a spinner, so
// `n_spinning` is pinned at 0 forever — which would make the enqueue
// submit-rule guard (`n_spinning == 0`) fire a wake-nobody futex_wake on
// every push. Gating on the cap too keeps that path inert exactly when
// it can do no work. Equivalent to budget=0 here, with no behavioural
// change (a 0-cap runtime never parks on the futex either way).
let spinning = spin_budget_cycles > 0 && max_spinners > 0;
// Guardrail: catch any future config path that re-enables spinning while
// leaving the cap at 0 — the pathology this gate exists to prevent. If
// we are spinning, at least one spinner must be enlistable.
debug_assert!(
!spinning || max_spinners > 0,
"spinning enabled (budget={spin_budget_cycles}) with max_spinners=0: \
no scheduler can ever enlist, so every enqueue would fire a \
wake-nobody futex_wake"
);
Arc::new(Self {
run_queue: crate::run_queue::RunQueue::new(thread_count, max_actors),
slots,
@@ -601,7 +622,7 @@ impl RuntimeInner {
queue_len: AtomicU64::new(0),
n_spinning: AtomicU32::new(0),
park_seq: AtomicU32::new(0),
spinning: spin_budget_cycles > 0,
spinning,
spin_budget_cycles,
max_spinners,
timers: Mutex::new(Timers::new()),