perf(scheduler): fold timer+IO drain into one lock, skip clock read when no timers

The single-scheduler hot path acquired the shared mutex three times per
yield: once to drain timers, once to drain IO completions, and once to pop
the next runnable actor. It also called Instant::now() every loop iteration
to feed timers.pop_due(), even though the pure-yield / pure-compute workloads
never arm a timer.

Confirmed with llmdbg before touching anything: breakpointing
switch_to_scheduler and instruction-counting one scheduler-side yield cycle on
the ctx_switch_probe showed ~6950 instructions / 68 calls of bookkeeping
between consecutive actor resumes, dominated by lock acquisition and the
per-iteration clock read — not by the naked-asm switch itself (~16 insns).
The te!() trace macro compiles to () without the smarm-trace feature, so it
was ruled out as a contributor.

This collapses the two drain `with_shared` calls into one lock hold and reads
the clock only when the timer heap is non-empty. IO completions are still
drained unconditionally while the IO subsystem is live, because the IO/pool
threads push completions onto their own mutex (not `shared`), so the scheduler
cannot know in advance whether any arrived — it must look; that look is a
single empty-VecDeque check on the hot path. No change to timer or IO
semantics; the asm switch is untouched.

Measured on this box (single core, nproc==1), medians over the bench harness,
re-baselined locally first:
  yield_many       smarm 1-thread  38459 -> 29943 us  (-22.1%)
  yield_in_hot_loop smarm 1-thread 166220 -> 122629 us (-26.2%)
  ping_pong_oneshot smarm 1-thread  1619 ->  1367 us  (-15.6%)
  chained_spawn    smarm 1-thread    481 ->   399 us  (-17.0%)
Run-to-run spread on yield_many is ~3-4% (29565-30634), so the 22% move is
well outside the noise. Gap to tokio current_thread closes from ~2.4x to ~1.95x.

All tests green: cargo test --test context (4) plus the full suite (93 total).
This commit is contained in:
smarm-dev
2026-05-28 06:29:58 +00:00
parent 6d17254ae3
commit 7d44b20baf
+25 -8
View File
@@ -594,10 +594,31 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
// losers skip immediately and proceed to step 2.
// ----------------------------------------------------------------
if let Ok(_drain_guard) = inner.drain_lock.try_lock() {
let now = std::time::Instant::now();
// Drain due timers.
let due = inner.with_shared(|s| s.timers.pop_due(now));
// Drain due timers and IO completions in a SINGLE shared-lock
// acquisition. Previously this took two separate `with_shared`
// calls (one for timers, one for IO) plus an unconditional
// `Instant::now()` every loop iteration. On the pure-yield /
// pure-compute hot path there are never any timers, so the clock
// read and the extra lock were pure per-yield overhead. We now
// read the clock only when the timer heap is non-empty and fold
// both drains into one lock hold.
//
// IO completions are still drained unconditionally while the IO
// subsystem is present: the IO/pool threads push completions onto
// their own mutex (not `shared`), so the scheduler can't cheaply
// know in advance whether any arrived — it must look. That look is
// a single empty-VecDeque check on the empty path.
let (due, completions) = inner.with_shared(|s| {
let due = if s.timers.is_empty() {
Vec::new()
} else {
s.timers.pop_due(std::time::Instant::now())
};
let completions = s.io.as_mut()
.map(|io| io.drain_completions())
.unwrap_or_default();
(due, completions)
});
for entry in due {
match entry.reason {
crate::timer::Reason::Sleep => {
@@ -631,10 +652,6 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
}
}
// Drain IO completions.
let completions = inner.with_shared(|s| {
s.io.as_mut().map(|io| io.drain_completions()).unwrap_or_default()
});
for completion in completions {
match completion {
crate::io::Completion::Blocking { pid, result } => {