From 7d44b20baf50d82673644d0c2e60e0685f45a70b Mon Sep 17 00:00:00 2001 From: smarm-dev Date: Thu, 28 May 2026 06:29:58 +0000 Subject: [PATCH] perf(scheduler): fold timer+IO drain into one lock, skip clock read when no timers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/runtime.rs | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/runtime.rs b/src/runtime.rs index e1a2541..19e8e0d 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -594,10 +594,31 @@ fn schedule_loop(inner: &Arc, 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, 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 } => {