feat(scheduler): RFC 005 wake slot — per-scheduler capacity-one wake cache

A thread-local Cell<Option<Pid>> per scheduler, checked before the shared
run queue. Runtime-selected via Config { wake_slot: bool }, default OFF
until the slot shootout accepts it (one binary benches both arms).

Push policy: slot-eligible iff the wake originates from actor context
(current_pid().is_some()) — the slot push replaces run_queue.push at the
tail of the unpark protocol's Parked → Queued CAS, so at-most-once-enqueued
holds verbatim as (slot ⊕ shared queue). Scheduler-context wakes (timer/IO
drain) and spawns always go shared (spawns never reach unpark_inner at all).
Displacement is Go semantics: newest wake takes the slot, occupant pushed
shared — moved, never copied.

Pop order: slot, then shared. Slot-popped actors skip reset_timeslice() and
inherit the waker's remaining slice; a handoff chain is bounded by one
slice, after which the preempt-yield re-enqueue goes shared (a yield is not
a wake) — the one-slice starvation bound, zero new counters. Idle and
AllDone are only reachable with an empty local slot by pop order; an
occupied slot elsewhere holds a Queued (live) actor, so the counter-first
termination argument is untouched.

Observability: per-thread slot_hits / slot_displacements (reset at run()
start so post-run stats() reads are per-run), SlotPush/SlotPop trace events.

Bench plan (roadmap v0.9 item 2): rq_runtime gains the slot on/off
dimension (SMARM_BENCH_SLOT, default "0 1") — ping-pong-pairs is the
target metric, yield-storm the regression guard, spawn-storm the
neutrality check. RQCSV grows a slot column; RQSLOT lines carry the
counters; bench_rq.sh aggregates both. Tests pin the push policy through
the counters (actor-context hits, spawn/join bypass, displacement,
default-off, per-run reset).
This commit is contained in:
Claude
2026-06-11 20:20:07 +02:00
parent f09e992f32
commit 2708042990
6 changed files with 519 additions and 116 deletions
+6 -2
View File
@@ -25,8 +25,12 @@
//! - **Occupancy is bounded by `max_actors`.** A pid is in the queue at most
//! once (pushes pair 1:1 with transitions into `Queued`; only the
//! scheduler transitions `Queued → Running` — see the state-machine docs
//! in `runtime.rs`), and at most `max_actors` actors exist. The bounded
//! rings are sized ≥ `max_actors`, so **`push` is infallible**; a full
//! in `runtime.rs`), and at most `max_actors` actors exist. With the RFC
//! 005 wake slot enabled the invariant reads "in (slot ⊕ shared queue) at
//! most once" — a slot push *replaces* the queue push at the same
//! protocol point, and a displacement moves the occupant, never copies
//! it — so the bound holds verbatim. The bounded rings are sized ≥
//! `max_actors`, so **`push` is infallible**; a full
//! ring is an invariant violation and panics loudly rather than spinning.
//! - **Preemption must be disabled around every push/pop** (debug-asserted).
//! For the mutex variant this is the usual no-switch/no-unwind-under-lock