feat(bench): phase 4 — run-queue bench harness + shootout driver

- benches/rq_micro.rs: raw-structure microbench, threads x producer:consumer
  ratio sweep. Benches all three queue types in one binary (they compile in
  every build; only the runtime alias is feature-selected), so no rebuild
  dance. Queues sized to the op count so the occupancy contract is met.
- benches/rq_runtime.rs: whole-runtime benches with the selected variant:
  yield-storm (pure queue churn), ping-pong-pairs (park/unpark latency),
  spawn-storm (slab + free list + queue under churn). Scheduler-count sweep.
- scripts/bench_rq.sh: rebuilds rq_runtime per rq-* feature, runs rq_micro
  once, aggregates RQCSV lines into bench_results/summary.csv.
- All knobs via SMARM_BENCH_* env vars; house table format + machine lines.
- run_queue module is now #[doc(hidden)] pub (types + push/pop/len +
  MpmcRing::with_capacity) solely so the external bench binary can drive the
  raw structures.

docs(roadmap): phase 4 ticked (harness done; numbers from the 20-core box).
New fast-follow per review: assert the invariants we lean on — debug_assert!
on hot paths, loud assert!/panic on cold ones, at the point of reliance;
sweep existing code during the phase-5 audit, adopt as house style.

Validated end-to-end at smoke scale on the 1-core sandbox: full driver run,
24-row summary.csv across micro (3 structures x sweeps) and runtime
(3 variants x 3 benches x thread sweep).
This commit is contained in:
Claude
2026-06-09 20:44:10 +00:00
parent 1b3b618aa7
commit 6d9f3698d4
8 changed files with 456 additions and 25 deletions
+18 -16
View File
@@ -94,13 +94,13 @@ fn assert_no_preempt() {
// ---------------------------------------------------------------------------
#[allow(dead_code)]
pub(crate) struct MutexQueue {
pub struct MutexQueue {
q: std::sync::Mutex<std::collections::VecDeque<Pid>>,
}
#[allow(dead_code)]
impl MutexQueue {
pub(crate) fn new(_threads: usize, max_actors: usize) -> Self {
pub fn new(_threads: usize, max_actors: usize) -> Self {
Self {
// Pre-size: the queue can never outgrow the slab, and one
// allocation at init beats reallocating under the lock later.
@@ -108,17 +108,17 @@ impl MutexQueue {
}
}
pub(crate) fn push(&self, pid: Pid) {
pub fn push(&self, pid: Pid) {
assert_no_preempt();
self.q.lock().unwrap().push_back(pid);
}
pub(crate) fn pop(&self) -> Option<Pid> {
pub fn pop(&self) -> Option<Pid> {
assert_no_preempt();
self.q.lock().unwrap().pop_front()
}
pub(crate) fn len(&self) -> u64 {
pub fn len(&self) -> u64 {
self.q.lock().unwrap().len() as u64
}
}
@@ -145,7 +145,7 @@ struct Cell {
}
#[allow(dead_code)]
pub(crate) struct MpmcRing {
pub struct MpmcRing {
buf: Box<[Cell]>,
mask: usize,
enqueue_pos: CachePadded<AtomicUsize>,
@@ -159,11 +159,13 @@ unsafe impl Sync for MpmcRing {}
#[allow(dead_code)]
impl MpmcRing {
pub(crate) fn new(_threads: usize, max_actors: usize) -> Self {
pub fn new(_threads: usize, max_actors: usize) -> Self {
Self::with_capacity(max_actors)
}
fn with_capacity(min_cap: usize) -> Self {
/// Pub for the raw-structure microbench (sized to the op count so the
/// occupancy contract is trivially met there). Runtime code uses `new`.
pub fn with_capacity(min_cap: usize) -> Self {
// Occupancy ≤ max_actors (queue contract), so capacity = the next
// power of two ≥ max_actors can never overflow. (≥ 2 so mask works.)
let cap = min_cap.next_power_of_two().max(2);
@@ -181,7 +183,7 @@ impl MpmcRing {
}
}
pub(crate) fn push(&self, pid: Pid) {
pub fn push(&self, pid: Pid) {
assert_no_preempt();
assert!(
self.try_push(pid),
@@ -220,7 +222,7 @@ impl MpmcRing {
}
}
pub(crate) fn pop(&self) -> Option<Pid> {
pub fn pop(&self) -> Option<Pid> {
assert_no_preempt();
let mut pos = self.dequeue_pos.0.load(Ordering::Relaxed);
loop {
@@ -252,7 +254,7 @@ impl MpmcRing {
}
}
pub(crate) fn len(&self) -> u64 {
pub fn len(&self) -> u64 {
let e = self.enqueue_pos.0.load(Ordering::Relaxed);
let d = self.dequeue_pos.0.load(Ordering::Relaxed);
e.saturating_sub(d) as u64
@@ -276,7 +278,7 @@ impl MpmcRing {
// terminates (in practice on the first stripe).
#[allow(dead_code)]
pub(crate) struct StripedRing {
pub struct StripedRing {
stripes: Box<[MpmcRing]>,
/// Stripe count minus one (count is a power of two).
stripe_mask: usize,
@@ -286,7 +288,7 @@ pub(crate) struct StripedRing {
#[allow(dead_code)]
impl StripedRing {
pub(crate) fn new(threads: usize, max_actors: usize) -> Self {
pub fn new(threads: usize, max_actors: usize) -> Self {
// One stripe per scheduler thread, rounded up to a power of two —
// more stripes than threads buys nothing (at most `threads` ops are
// in flight) and costs pop-probe latency when mostly empty.
@@ -304,7 +306,7 @@ impl StripedRing {
}
}
pub(crate) fn push(&self, pid: Pid) {
pub fn push(&self, pid: Pid) {
assert_no_preempt();
let home = self.push_ticket.0.fetch_add(1, Ordering::Relaxed);
// Probe from the home stripe; capacity headroom (Σ ≥ 2×occupancy)
@@ -322,7 +324,7 @@ impl StripedRing {
}
}
pub(crate) fn pop(&self) -> Option<Pid> {
pub fn pop(&self) -> Option<Pid> {
assert_no_preempt();
let home = self.pop_ticket.0.fetch_add(1, Ordering::Relaxed);
for i in 0..=self.stripe_mask {
@@ -333,7 +335,7 @@ impl StripedRing {
None // snapshot miss possible across stripes; idle-retry absorbs it
}
pub(crate) fn len(&self) -> u64 {
pub fn len(&self) -> u64 {
self.stripes.iter().map(|s| s.len()).sum()
}
}