test(context): guard XMM-not-saved assumption against preemptive switch

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.
This commit is contained in:
smarm-dev
2026-05-28 15:09:07 +00:00
parent 7d44b20baf
commit 14dc7a79cf
+69
View File
@@ -64,3 +64,72 @@ fn check_is_a_noop_when_timeslice_not_expired() {
}); });
assert_eq!(count.load(Ordering::Relaxed), 1_000); assert_eq!(count.load(Ordering::Relaxed), 1_000);
} }
// ---------------------------------------------------------------------------
// XMM-not-saved assumption (context.rs): the context switch saves no SSE
// state. The justification is that every yield crosses a Rust `call` boundary
// (SysV AMD64: XMM0-15 caller-saved), so the compiler spills live XMM before
// the switch and reloads after. The non-obvious path is PREEMPTION: `check!()`
// inlines `maybe_preempt`, so the yield can fire mid-floating-point-loop, not
// at a syntactic call site. This is adversarial because the yield is buried
// inside the actor's own hot loop — but `switch_to_scheduler` is still an
// `extern "C"` call, so the spill still happens. Verified by disasm: the FP
// accumulators are spilled to (%rsp) immediately before the preempt path and
// reloaded after. This test guards against a future change to the yield path
// that bypasses that call boundary (which WOULD require saving XMM).
#[test]
fn live_xmm_survives_preemptive_switch() {
// A floating-point loop with several live f64 accumulators the compiler
// wants resident in XMM across iterations. We force a timeslice expiry on
// a chosen iteration so a preemptive switch_to_scheduler fires while XMM
// is live, then compare against the same workload run with no preemption.
#[inline(never)]
fn fp_workload(preempt_at: u64) -> f64 {
let mut a = 1.0000001_f64;
let mut b = 0.9999999_f64;
let mut acc = 0.0_f64;
for i in 0..50_000u64 {
a = a * 1.0000003 + 0.0000001;
b = b * 0.9999997 + 0.0000002;
acc += a - b + (i as f64) * 1e-9;
if i == preempt_at {
smarm::preempt::expire_timeslice_for_test();
}
smarm::check!();
}
acc
}
// Reference: a preempt_at past the loop end => check!() never yields.
let reference = fp_workload(u64::MAX);
let got = Arc::new(AtomicU64::new(0));
let g = got.clone();
run(move || {
// A second actor so the scheduler has somewhere to switch on yield.
let _other = spawn(|| {
for _ in 0..8 {
smarm::preempt::expire_timeslice_for_test();
smarm::check!();
}
});
let h = spawn(move || {
// Yield from several distinct points so the switch lands on
// different live-XMM states.
let mut total = 0.0_f64;
for k in 0..8 {
total += fp_workload(k * 6_000 + 100);
}
g.store(total.to_bits(), Ordering::SeqCst);
});
h.join().unwrap();
});
let reference_total: f64 = (0..8).map(|_| reference).sum();
let got = f64::from_bits(got.load(Ordering::SeqCst));
assert_eq!(
got.to_bits(),
reference_total.to_bits(),
"XMM state diverged across preemptive switch: got {got:.12}, want {reference_total:.12}"
);
}