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:
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user