`monitor(pid) -> Receiver<Down>` lets any actor watch any other and
receive a single Down{pid, reason} when it terminates. Generalizes the
existing single supervisor_channel (which is just a hard-wired monitor
the parent installs at spawn).
- src/monitor.rs: Down / DownReason{Exit,Panic,NoProc} + monitor().
Payload-free: a panic payload has one owner and goes to the joiner via
JoinError, so monitors learn only that it panicked. Monitoring an
already-gone pid yields NoProc immediately.
- runtime: Slot grows `monitors: Vec<Sender<Down>>`; finalize_actor
drains and notifies them outside the shared lock (send can unpark a
receiver, which re-takes the non-reentrant mutex). Cleared in
reclaim_slot and on slot reuse at spawn.
- Registration and finalize both serialize on the shared mutex, so a
target alive at registration always delivers a real Down (no race
between liveness check and registration).
tests/monitor.rs: Exit, Panic, NoProc-on-dead-target, and fan-out to
multiple monitors. Full suite green.
The context switch in context.rs saves no SSE/XMM state, justified by
every yield crossing a Rust `call` boundary (SysV: XMM0-15 caller-saved,
so live XMM is spilled before the call). The non-obvious path is
preemption: check!() inlines maybe_preempt(), so a switch can fire
mid-floating-point-loop rather than at a visible call site.
Verified the assumption holds on that path, and *why*: switch_to_scheduler
is extern "C", so the compiler spills live XMM around it even when the
yield is buried inside an inlined preempt check. Disasm of the probe loop
shows the accumulators spilled to (%rsp) immediately before the preempt
path and reloaded after. Behavioural check: an FP loop preempted mid-flight
produces a bit-identical result to the same workload with no preemption
(abs diff = 0, debug and release). Confirmed non-vacuous under llmdbg:
switch_to_scheduler fires repeatedly during the loop.
This is a confirmation, not a fix; context.rs is unchanged. The test exists
to catch a future regression that reaches the switch without crossing the
extern "C" call boundary (e.g. fully-inlined asm with no call, or async
signal-driven preemption) — either WOULD require saving XMM.
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).
The diagram claimed the entry/ret target sits at aligned_top-8 and r15 at
aligned_top-56. The code does (top & ~15) - 8 then a -= 8 before the first
write, so entry actually lands at aligned_top-16 and r15 at aligned_top-64.
Confirmed by single-stepping the shim's ret under llmdbg: the entry slot is
at an address with %16==0, giving the required rsp%16==8 on entry. Code was
correct; only the comment was wrong (and would mislead a maintainer).
A minimal Config::exact(1) program with two actors doing a fixed number of
yield_now() calls. No I/O/timers/channels, so the instruction stream stays
centered on the naked-asm switch. Frame-pointer-friendly. Intended for
single-threaded inspection under a debugger (e.g. llmdbg).