Files
smarm/ROADMAP_v0.5.md
T
Claude 1b3b618aa7 feat(runtime): phase 3 — pluggable run queue (rq-mutex / rq-mpmc / rq-striped)
src/run_queue.rs: the run queue extracted behind a compile-time-selected
type alias; mutually-exclusive cargo features with compile_error! guards
(zero or >1 selected). No runtime dispatch. All variants compile in every
build so their unit tests always run; the feature only picks the alias.

- rq-mutex (default): Mutex<VecDeque>, the control/baseline.
- rq-mpmc: hand-rolled Vyukov bounded MPMC ring, per-cell sequence numbers,
  padded enqueue/dequeue counters. Strict FIFO, lock-free.
- rq-striped: M Vyukov rings (M = thread_count rounded up to pow2),
  fetch-add ticket distribution, probe-from-home on push. Relaxed FIFO with
  stripe-bounded skew; Σcapacity ≈ 2×max_actors so the probe terminates.

Capacity soundness: occupancy ≤ max_actors by the at-most-once-enqueued
invariant + slab cap, rings sized ≥ that bound, so push is infallible; a
full ring panics as a double-enqueue invariant violation rather than spin.

Two contracts the extraction made explicit (documented + debug-asserted):
- Queue ops require preemption disabled: a producer suspended between
  claiming a cell and publishing its sequence stalls every consumer behind
  it — livelock, since the suspended actor's own resume entry is behind the
  hole. Structurally guaranteed since the phase-2 with_runtime NoPreempt fix.
- pop()==None is a snapshot, not a fence. Termination is counter-first:
  every queue entry's target stays Queued (hence live) until that entry is
  popped, so live==0 alone implies nothing actionable is or can be queued;
  argument rewritten at the schedule_loop site. SharedState and with_shared
  are deleted — nothing global is mutex-guarded on the run path under the
  ring variants.

Validated: 22 suites green per variant (release for all three; debug with
live asserts for all three), ring unit tests (FIFO, lap wraparound,
4p/4c exactly-once, skewed drain) in every build, both compile_error!
guards verified to fire.
2026-06-09 20:34:32 +00:00

129 lines
7.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# smarm v0.5 — Runtime decomposition & run-path scaling
Goal: dismantle the single `Mutex<SharedState>` along the run path so the
runtime scales to dozens of cores, with crash isolation hardened so a torn
write or a stray cancellation can never poison shared runtime state.
Guiding rules established this cycle:
- **Lock count that matters is hot-path locks.** Cold lifecycle paths
(spawn/join/monitor/link/finalize) may keep a small per-slot lock; the run
path (yield/park/unpark/pop/resume) targets zero locks.
- **Lock ordering: io-before-shared, and slot locks are leaves** (never hold
two slot locks at once). Any new lock states its position in this order.
- **No unwind inside a runtime critical section.** The stop sentinel only
fires at lock-free observation points.
- **Never hold a thread-local guard across a possible switch point.** An
actor can be preempted at any allocation and resume on a different OS
thread; a live `RefCell` borrow (or std `MutexGuard`) then pairs its
acquire/release across two threads' locals — count underflow / UB.
`with_runtime`, `with_shared`, and every `RawMutex` guard disable
preemption for exactly this reason (phase 2 found this the hard way).
---
## Phase 1 — State decomposition: easy peel-offs ✅ DONE
Split independent concerns out of `SharedState` so the global lock guards less.
- [x] `next_monitor_id``AtomicU64` on `RuntimeInner` (lock-free id minting).
- [x] `timers` → own `Mutex<Timers>` (only drain-winner + blocking prims touch it).
- [x] `io` → own `Mutex<Option<IoThread>>` (completion queue already self-locked).
- [x] `pending_closures: Vec` → folded into `Slot::pending_closure` (per-actor data).
- [x] Termination check split: read io liveness *before* `shared`; ordering
argument documented in `schedule_loop`.
- [x] **Poison fix:** gate `check_cancelled()` in `maybe_preempt` behind
`PREEMPTION_ENABLED`, so a cancellation sentinel can never unwind while a
`std::sync::Mutex` (shared/channel) is held. Regression test:
`tests/poison_stop.rs`.
`SharedState` now holds only: `slots`, `free_list`, `run_queue`, `root_pid`.
## Phase 2 — Slot table split ✅ DONE
Make slot lookup lock-free and per-slot state independently mutable.
- [x] Fixed slab: `Box<[Slot]>`, slots never move → stable
addresses, lock-free index. **Assert on exhaustion** with a panic message
naming `Config::max_actors(n)` as the fix. Default `max_actors = 16_384`.
(Mental note: must become unbounded or configurable-bounded later — see
"Deferred" below. Do not let the fixed cap calcify into an assumption.)
- [x] Generation packed INTO the state word (better than the planned separate
`AtomicU32`): one `AtomicU64 = (gen << 32) | state`, so the gen check is
atomic with every transition — no ABA, no spurious unparks, by CAS.
- [x] Per-slot CAS state machine `Vacant/Queued/Running/RunningNotified/
Parked/Done` replacing `state` + `pending_unpark`. Also fixed a latent
lost wakeup in the Blocking-IO completion path (result set for a
still-Running actor without a flag).
- [x] `sp` → relaxed `AtomicUsize`; stop flag + first-resume closure as
`AtomicPtr`s — resume path fully lock-free.
- [x] Per-slot raw non-poisoning futex mutex (`src/raw_mutex.rs`) for the
cold collections. Guard enters `NoPreempt`.
- [x] Free list → `RawMutex<Vec<u32>>` (a leaf lock). Treiber/striped only if
the phase-4 spawn-storm bench shows contention — revisit then.
- [x] `finalize_actor` link cascade locks peers one at a time; acyclicity
argument written at the site. `link()` registers target-first with the
race argument at the site.
- [x] `live_actors` atomic; termination = io_out == 0 (read pre-queue-lock)
&& queue empty && live == 0; decrement-last ordering documented as the
correctness crux at the site.
## Phase 3 — Pluggable run queue ✅ DONE (shootout = phase 4 harness)
- [x] `RunQueue` alias in `src/run_queue.rs`, compile-time selected,
`compile_error!` guards for zero / >1 features. No runtime dispatch.
All variants compile in every build (unit tests always run); the
feature only picks the alias. Non-default variants:
`--no-default-features --features rq-…`.
- `rq-mutex` — `Mutex<VecDeque>`, the control/baseline.
- `rq-mpmc` — single hand-rolled Vyukov bounded MPMC ring
(per-cell seq numbers). Strict FIFO; "one hot cache line".
- `rq-striped` — M Vyukov rings, fetch-add ticket distribution. Relaxed
FIFO, reordering bounded by ~M. Predicted winner @20c.
- [x] Bounded rings sound via slab cap + at-most-once-enqueued (occupancy ≤
`max_actors`); mpmc capacity = next_pow2(max_actors), striped
Σcapacity ≈ 2×max_actors with probe-from-home-stripe. A full ring
panics as an invariant violation (double enqueue), never spins.
- [x] All hand-rolled, dependency-free.
- [x] Two contracts surfaced by the extraction, documented + debug-asserted
in `run_queue.rs`: queue ops require preemption disabled (a producer
suspended mid-publish stalls every consumer behind its cell — livelock);
and pop-None is a snapshot, not a fence — termination is counter-first
(`live == 0` alone implies the queue holds nothing actionable; argument
rewritten at the `schedule_loop` site).
## Phase 4 — Bench harness
- [ ] Raw-structure microbench: N threads, push/pop throughput vs thread count,
sweeping producer:consumer ratios. Isolates the data structure.
- [ ] Runtime-level: yield-storm, ping-pong-pairs, spawn-storm, all sweeping
scheduler count. Reuses existing `benches/` harness style.
- [ ] Driver script rebuilding per `rq-*` feature to compare variants in one go.
- [ ] Sandbox validates correctness via oversubscribed `Config::exact(N)` on 1
core; real contention numbers come from the 20-core box.
## Phase 5 — Safety hardening & model checking
- [ ] `loom` (dev-dependency only, x86 Linux) model tests for the slot state
machine and each ring variant.
- [ ] `NoPreempt` / no-unwind-under-lock audit across all internal critical
sections; assert the invariant in debug builds where feasible.
---
## Fast follow (post-v0.5, written down so it isn't lost)
- **Channel mutex migration.** `channel::Inner<T>` is `Arc<Mutex<_>>` of the
same poison class as the old shared lock; `recv_match` even runs a user
predicate under it. The Phase-1 `check_cancelled` gating already removes the
unwind *source* globally, so channels are poison-safe today — but phase 2
surfaced a second, sharper reason to migrate: the std `MutexGuard` is held
with preemption ENABLED, so a timeslice switch inside a channel critical
section migrates the actor and unlocks the pthread mutex from a different
OS thread — technically UB (Linux futexes happen to tolerate it). The
`RawMutex` guard disables preemption and is cross-thread-release sound by
construction. Migrate as the first post-v0.5 change.
## Deferred / explicitly out of scope for v0.5
- **Unbounded / configurable-bounded actor count.** v0.5 ships a fixed slab
with a loud assert. Revisit with a segmented slab (array of
`AtomicPtr<Segment>`, doubling segment sizes, append-only) once we actually
hit the cap or a workload demands it.
- **Idle-wakeup eventcount.** Idle scheduler threads keep the current 100µs
poll-sleep. A futex-based eventcount is a later optimization if benches show
idle latency matters.
- **User-facing safe data structures** (ArcSwap-style cells, structurally-
shared persistent maps). Context for the runtime work, not in scope here.
- **IO fd hygiene on actor death** (pre-existing v0.2 TODO in `io.rs`).