Files
smarm/tests/causal.rs
T
Claude (sandbox) 04dbac1f4b feat(causal): timer-heap virtual time — deadlines chase injected delay (RFC 007)
Injected delays dilate virtual time for the workload, but timer deadlines
stayed wall-anchored: a sleep or receive-timeout fired early in virtual
terms, so timeout/retry behaviour sped up relative to the dilated world
(v1 known gap #1).

Every heap Entry now carries delay_stamp — the global delay ledger at
(re-)queue time, cfg-gated on smarm-causal. pop_due converts any debt
accrued since the stamp to wall time (tsc_hz) and shifts the effective
deadline; a not-yet-due entry is re-queued at the shifted deadline with a
fresh stamp, so it keeps chasing delay injected while it waits. seq is
preserved across re-queues, keeping send_after cancellation identity
intact (cancelled entries are discarded before any shift). Zero debt is
byte-identical to the old path; peek_deadline may under-report, costing
one spurious scheduler wake per injected chunk (documented).

This also makes the park-gated resume credit *correct* rather than
forgiving for sleepers: a sleeping actor now physically pays its debt by
sleeping longer, so the on_resume fast-forward reflects real payment
(sleeping_actor_pays_injected_delay pins this end-to-end through the
runtime).

New test hooks: inject_delay_cycles_for_test (deterministic ledger
driver, eagerly TSC-calibrating so conversion never stalls a scheduler
loop) and cycles_to_duration. The ledger is process-global, so the
delta-sensitive causal tests now serialize on a shared test mutex — they
were racy under the parallel test harness before this, in principle.
2026-07-13 07:31:08 +00:00

545 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Integration tests for `smarm-causal` — native causal profiling (RFC 007).
//!
//! Gated on the feature; run with:
//! cargo test --test causal --features smarm-causal
#![cfg(feature = "smarm-causal")]
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
/// Progress points count, and snapshots expose deltas by name.
#[test]
fn progress_point_counts() {
smarm::init(smarm::Config::exact(1)).run(|| {
let before = smarm::causal::progress_snapshot();
let h = smarm::spawn(|| {
for _ in 0..100 {
smarm::progress!("units-a");
}
for _ in 0..7 {
smarm::progress!("units-b");
}
});
h.join().unwrap();
let after = smarm::causal::progress_snapshot();
let delta = |name: &str| {
after.iter().find(|(n, _)| n == name).map(|(_, c)| *c).unwrap()
- before
.iter()
.find(|(n, _)| n == name)
.map(|(_, c)| *c)
.unwrap_or(0)
};
assert_eq!(delta("units-a"), 100);
assert_eq!(delta("units-b"), 7);
});
}
/// Site guards nest and restore, and site identity is per-actor: observable
/// from inside the actor and gone once the guard drops.
#[test]
fn site_guard_nesting_restores() {
smarm::init(smarm::Config::exact(1)).run(|| {
let h = smarm::spawn(|| {
assert_eq!(smarm::causal::current_site_name(), None);
{
let _outer = smarm::causal_site!("outer");
assert_eq!(
smarm::causal::current_site_name().as_deref(),
Some("outer")
);
{
let _inner = smarm::causal_site!("inner");
assert_eq!(
smarm::causal::current_site_name().as_deref(),
Some("inner")
);
}
assert_eq!(
smarm::causal::current_site_name().as_deref(),
Some("outer")
);
}
assert_eq!(smarm::causal::current_site_name(), None);
});
h.join().unwrap();
});
}
/// The core Coz transposition: while an experiment targets site S with a
/// nonzero speedup, work inside S grows the global delay ledger and credits
/// itself; an actor *outside* S absorbs the difference (its per-actor ledger
/// catches up to the global one) instead of running delay-free.
#[test]
fn virtual_speedup_ledger() {
let _s = serial();
let global_out = Arc::new(AtomicU64::new(0));
let absorbed_out = Arc::new(AtomicU64::new(0));
let g_out = global_out.clone();
let a_out = absorbed_out.clone();
smarm::init(smarm::Config::exact(2)).run(move || {
let stop = Arc::new(AtomicBool::new(false));
// Bystander: never in the target site; must absorb injected delay.
let stop2 = stop.clone();
let out2 = a_out.clone();
let bystander = smarm::spawn(move || {
while !stop2.load(Ordering::Relaxed) {
smarm::check!();
out2.store(
smarm::causal::my_absorbed_delay_cycles(),
Ordering::Relaxed,
);
}
});
// Target: spins inside the experiment's site.
let stop3 = stop.clone();
let target = smarm::spawn(move || {
let _g = smarm::causal_site!("hot-site");
while !stop3.load(Ordering::Relaxed) {
smarm::check!();
}
});
let base = smarm::causal::global_delay_cycles();
smarm::causal::begin_experiment_for_test("hot-site", 50);
smarm::sleep(Duration::from_millis(200));
g_out.store(
smarm::causal::global_delay_cycles() - base,
Ordering::Relaxed,
);
smarm::causal::end_experiment_for_test();
stop.store(true, Ordering::Relaxed);
bystander.join().unwrap();
target.join().unwrap();
});
let global = global_out.load(Ordering::Relaxed);
let absorbed = absorbed_out.load(Ordering::Relaxed);
// The target's site work must have grown the ledger…
assert!(global > 0, "global delay ledger never grew");
// …and the bystander must have absorbed a meaningful share of it (it can
// lag by a few check intervals; require half to be robust).
assert!(
absorbed >= global / 2,
"bystander absorbed {absorbed} of {global} global delay cycles"
);
}
/// The forgiveness rule must not leak: delay is forgiven on resume from a
/// *park* (Coz's blocked-thread rule), but an actor that merely yields on
/// timeslice expiry stays runnable and must PAY — its work rate has to drop
/// while an experiment targets someone else. (Regression: forgiving on every
/// resume made all bystanders delay-immune, so experiments moved nothing.)
#[test]
fn runnable_bystander_pays_delay() {
let _s = serial();
let baseline = Arc::new(AtomicU64::new(0));
let during = Arc::new(AtomicU64::new(0));
let b_out = baseline.clone();
let d_out = during.clone();
smarm::init(smarm::Config::exact(1)).run(move || {
let stop = Arc::new(AtomicBool::new(false));
// Target: in-site spinner; grows the ledger while the experiment runs.
let stop_t = stop.clone();
let target = smarm::spawn(move || {
let _g = smarm::causal_site!("pay-site");
while !stop_t.load(Ordering::Relaxed) {
smarm::check!();
}
});
// Bystander: pure check!-loop — yields on slice expiry, never parks.
let stop_b = stop.clone();
let iters = Arc::new(AtomicU64::new(0));
let iters2 = iters.clone();
let absorbed = Arc::new(AtomicU64::new(0));
let absorbed2 = absorbed.clone();
let bystander = smarm::spawn(move || {
while !stop_b.load(Ordering::Relaxed) {
iters2.fetch_add(1, Ordering::Relaxed);
smarm::check!();
absorbed2.store(
smarm::causal::my_absorbed_delay_cycles(),
Ordering::Relaxed,
);
}
});
let window = |out: &AtomicU64| {
let i0 = iters.load(Ordering::Relaxed);
let t = std::time::Instant::now();
smarm::sleep(Duration::from_millis(150));
let rate =
(iters.load(Ordering::Relaxed) - i0) as f64 / t.elapsed().as_secs_f64();
out.store(rate as u64, Ordering::Relaxed);
};
window(&b_out);
let d0 = smarm::causal::global_delay_cycles();
smarm::causal::begin_experiment_for_test("pay-site", 50);
window(&d_out);
smarm::causal::end_experiment_for_test();
let injected = smarm::causal::global_delay_cycles() - d0;
stop.store(true, Ordering::Relaxed);
target.join().unwrap();
bystander.join().unwrap();
// The never-parking bystander's ledger can only advance by actually
// spinning (forgiveness requires a real park), so this is no longer
// vacuous: most of the injected delay must have been paid for real.
let a = absorbed.load(Ordering::Relaxed);
assert!(injected > 0, "experiment injected nothing");
assert!(
a >= injected / 2,
"bystander spun off {a} of {injected} injected cycles"
);
});
let b = baseline.load(Ordering::Relaxed) as f64;
let d = during.load(Ordering::Relaxed) as f64;
// Time-shared single scheduler: target is on-CPU ~50% of wall, so a 50%
// virtual speedup injects ~25% of wall — expected slowdown ×0.8 (measured
// exactly that live); on real parallelism it's ×0.67. Either way <0.9.
assert!(b > 0.0, "bystander never ran");
assert!(
d < b * 0.9,
"runnable bystander did not pay: baseline {b:.0}/s, during experiment {d:.0}/s"
);
}
/// A zero-percent experiment must inject nothing: the null experiment is the
/// baseline Coz relies on, and it doubles as the overhead sanity check.
#[test]
fn zero_speedup_injects_nothing() {
let _s = serial();
smarm::init(smarm::Config::exact(2)).run(|| {
let stop = Arc::new(AtomicBool::new(false));
let stop2 = stop.clone();
let worker = smarm::spawn(move || {
let _g = smarm::causal_site!("zero-site");
while !stop2.load(Ordering::Relaxed) {
smarm::check!();
}
});
let before = smarm::causal::global_delay_cycles();
smarm::causal::begin_experiment_for_test("zero-site", 0);
smarm::sleep(Duration::from_millis(50));
smarm::causal::end_experiment_for_test();
let after = smarm::causal::global_delay_cycles();
stop.store(true, Ordering::Relaxed);
worker.join().unwrap();
assert_eq!(after, before, "0% speedup must not grow the delay ledger");
});
}
/// `impact_pct` computes the normalized throughput impact of a (site,
/// speedup) cell against that site's own 0% baseline: injected virtual delay
/// is removed from the divisor (Coz's normalization), so a bottleneck site
/// shows positive impact and a fully-overlapped one shows ~zero.
#[test]
fn impact_from_synthetic_results() {
use smarm::causal::{impact_pct, tsc_hz, ExperimentResult};
let hz = tsc_hz();
let secs = |s: f64| Duration::from_secs_f64(s);
let cycles = |s: f64| (s * hz) as u64;
let results = vec![
// Baseline: 1000 units in 1s, nothing injected.
ExperimentResult {
site: "bottleneck".into(),
speedup_pct: 0,
duration: secs(1.0),
deltas: vec![("units".into(), 1000)],
injected_cycles: 0,
},
// Bottleneck at 50%: raw count unchanged, 0.5s injected
// -> rate 1000/0.5 = 2x baseline -> +100%.
ExperimentResult {
site: "bottleneck".into(),
speedup_pct: 50,
duration: secs(1.0),
deltas: vec![("units".into(), 1000)],
injected_cycles: cycles(0.5),
},
ExperimentResult {
site: "overlapped".into(),
speedup_pct: 0,
duration: secs(1.0),
deltas: vec![("units".into(), 1000)],
injected_cycles: 0,
},
// Overlapped at 50%: everyone else (incl. the real bottleneck)
// slowed, count drops with the divisor -> ~0% impact.
ExperimentResult {
site: "overlapped".into(),
speedup_pct: 50,
duration: secs(1.0),
deltas: vec![("units".into(), 500)],
injected_cycles: cycles(0.5),
},
];
let bn = impact_pct(&results, "bottleneck", 50, "units").unwrap();
assert!((bn - 100.0).abs() < 5.0, "bottleneck impact: {bn}");
let ov = impact_pct(&results, "overlapped", 50, "units").unwrap();
assert!(ov.abs() < 5.0, "overlapped impact: {ov}");
// Missing cells yield None, not garbage.
assert!(impact_pct(&results, "nosuch", 50, "units").is_none());
assert!(impact_pct(&results, "bottleneck", 50, "nosuch").is_none());
}
/// End-to-end controller pass over a toy workload: the report must contain a
/// human summary naming every (site × speedup) cell and a Coz-compatible
/// section with experiment / throughput-point lines.
#[test]
fn controller_produces_report() {
let _s = serial();
smarm::init(smarm::Config::exact(2)).run(|| {
let stop = Arc::new(AtomicBool::new(false));
let stop2 = stop.clone();
let worker = smarm::spawn(move || {
while !stop2.load(Ordering::Relaxed) {
{
let _g = smarm::causal_site!("stage-x");
for _ in 0..50 {
smarm::check!();
}
}
smarm::progress!("items");
}
});
let results = smarm::causal::run_experiments(&smarm::causal::ExperimentPlan {
speedups_pct: vec![0, 50],
experiment: Duration::from_millis(60),
cooldown: Duration::from_millis(20),
});
stop.store(true, Ordering::Relaxed);
worker.join().unwrap();
assert!(!results.is_empty());
let summary = smarm::causal::render_summary(&results);
assert!(summary.contains("stage-x"), "summary: {summary}");
assert!(summary.contains("items"), "summary: {summary}");
let coz = smarm::causal::render_coz(&results);
assert!(coz.contains("experiment\tselected=stage-x"), "coz: {coz}");
assert!(coz.contains("throughput-point\tname=items"), "coz: {coz}");
});
}
/// A target-site entry that never hits a cold causal check must still be
/// attributed: the guard boundaries flush the pending interval on exit and
/// re-arm the clock on entry. Without the exit flush, in-site time between
/// the last check and guard drop is silently discarded — measured live at
/// ~6-7% of all target time (eff 0.93), under-reporting every impact
/// (+83.5% where theory says +100%).
#[test]
fn site_boundaries_flush_tail() {
let _s = serial();
fn tsc() -> u64 {
// SAFETY: rdtsc has no preconditions on x86_64.
unsafe { core::arch::x86_64::_rdtsc() }
}
let injected = Arc::new(AtomicU64::new(0));
let spent = Arc::new(AtomicU64::new(0));
let inj_out = injected.clone();
let spent_out = spent.clone();
smarm::init(smarm::Config::exact(1)).run(move || {
// Experiment is live before the actor ever enters the site.
smarm::causal::begin_experiment_for_test("tail-site", 50);
let base = smarm::causal::global_delay_cycles();
let a = smarm::spawn(move || {
let t0 = tsc();
{
let _g = smarm::causal_site!("tail-site");
// Burn ~20M cycles with no allocations and no `check!()`:
// the amortised cold check can never fire, so only the
// guard-boundary flush can attribute this time.
let start = tsc();
while tsc().wrapping_sub(start) < 20_000_000 {
core::hint::spin_loop();
}
}
spent_out.store(tsc().wrapping_sub(t0), Ordering::Relaxed);
});
a.join().unwrap();
smarm::causal::end_experiment_for_test();
inj_out.store(
smarm::causal::global_delay_cycles() - base,
Ordering::Relaxed,
);
});
let injected = injected.load(Ordering::Relaxed);
let spent = spent.load(Ordering::Relaxed);
// At 50% the flush should attribute ≈ half the in-guard time; allow a
// generous band for guard overhead and clock skew.
assert!(
injected >= spent * 40 / 100 && injected <= spent * 60 / 100,
"boundary flush attributed {injected} of {spent} spent cycles (want ~50%)"
);
}
// ---------------------------------------------------------------------------
// RFC 007 timer virtual time: injected delay dilates virtual time for
// everyone, so a pending timer's *effective* deadline is its raw deadline
// plus whatever delay was injected while it was armed. Without this, a
// sleep or receive-timeout fires "early" in virtual terms and timeout-driven
// behaviour speeds up relative to the dilated world.
//
// These tests (and every ledger-delta test above) share the process-global
// delay ledger, so they serialize on `serial()`.
// ---------------------------------------------------------------------------
fn serial() -> std::sync::MutexGuard<'static, ()> {
static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
M.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A pending timer must not fire at its raw deadline if delay was injected
/// after it was armed; it fires once wall time covers raw + injected.
#[test]
fn timer_deadline_shifts_with_injected_delay() {
let _s = serial();
use smarm::pid::Pid;
use smarm::timer::Timers;
use std::time::Instant;
let hz = smarm::causal::tsc_hz();
let mut t = Timers::new();
let now = Instant::now();
t.insert_sleep(now + Duration::from_millis(50), Pid::new(0, 0), 1);
// 100ms of debt lands while the timer is pending.
smarm::causal::inject_delay_cycles_for_test((hz * 0.100) as u64);
// Raw deadline passed, effective deadline not: nothing fires, entry kept.
assert!(t.pop_due(now + Duration::from_millis(60)).is_empty());
assert!(!t.is_empty(), "shifted entry must be re-queued, not dropped");
// Past raw + injected (with margin) it must fire. Chase in case a
// parallel test injected more debt meanwhile.
let mut fire_at = now + Duration::from_millis(170);
let mut fired = false;
for _ in 0..40 {
if !t.pop_due(fire_at).is_empty() {
fired = true;
break;
}
fire_at += Duration::from_millis(50);
}
assert!(fired, "timer never fired after covering injected delay");
}
/// With no debt accrued since insertion, behaviour is byte-identical to
/// before: due entries fire at their raw deadline.
#[test]
fn timer_zero_debt_pops_at_raw_deadline() {
let _s = serial();
use smarm::pid::Pid;
use smarm::timer::Timers;
use std::time::Instant;
let mut t = Timers::new();
let now = Instant::now();
t.insert_sleep(now - Duration::from_millis(1), Pid::new(0, 0), 1);
assert_eq!(t.pop_due(now).len(), 1);
assert!(t.is_empty());
}
/// Re-queueing a shifted entry must preserve its identity: a `send_after`
/// cancelled *after* being shifted past its raw deadline must still cancel
/// (return true) and must never deliver.
#[test]
fn send_after_cancel_survives_reheap() {
let _s = serial();
use smarm::pid::Pid;
use smarm::timer::Timers;
use std::sync::atomic::AtomicBool;
use std::time::Instant;
let hz = smarm::causal::tsc_hz();
let mut t = Timers::new();
let now = Instant::now();
let delivered = Arc::new(AtomicBool::new(false));
let d2 = delivered.clone();
let id = t.insert_send(
now + Duration::from_millis(20),
Pid::new(0, 0),
Box::new(move || d2.store(true, Ordering::Relaxed)),
);
smarm::causal::inject_delay_cycles_for_test((hz * 0.100) as u64);
// Raw deadline passed: shifted, not fired.
assert!(t.pop_due(now + Duration::from_millis(30)).is_empty());
// Cancellation still works on the re-queued entry.
assert!(t.cancel(id), "cancel lost track of a re-queued send_after");
// Even far past the effective deadline nothing is delivered.
for e in t.pop_due(now + Duration::from_secs(3600)) {
if let smarm::timer::Reason::Send { fire } = e.reason {
fire();
}
}
assert!(
!delivered.load(Ordering::Relaxed),
"cancelled send_after delivered after re-heap"
);
}
/// End-to-end through the runtime: an actor mid-`sleep` pays delay injected
/// while it is parked by sleeping *longer* — which is exactly what makes the
/// park-gated resume-credit fast-forward correct rather than forgiving.
#[test]
fn sleeping_actor_pays_injected_delay() {
let _s = serial();
use std::time::Instant;
let slept = Arc::new(AtomicU64::new(0));
let s_out = slept.clone();
smarm::init(smarm::Config::exact(1)).run(move || {
let hz = smarm::causal::tsc_hz();
let s2 = s_out.clone();
let sleeper = smarm::spawn(move || {
let t0 = Instant::now();
smarm::sleep(Duration::from_millis(50));
s2.store(t0.elapsed().as_millis() as u64, Ordering::Relaxed);
});
// Let the sleeper arm its timer, then inject 150ms of debt from a
// plain OS thread — no slot, so nothing spin-absorbs it here and the
// only path by which it can slow the sleeper is the timer shift.
smarm::sleep(Duration::from_millis(10));
std::thread::spawn(move || {
smarm::causal::inject_delay_cycles_for_test((hz * 0.150) as u64);
})
.join()
.unwrap();
sleeper.join().unwrap();
});
let ms = slept.load(Ordering::Relaxed);
// Raw sleep 50ms + ~150ms debt injected mid-sleep => ~200ms effective.
// Loose lower bound; the pre-fix behaviour is ~50ms.
assert!(
ms >= 150,
"sleeper paid nothing: slept {ms}ms, expected >= 150ms (raw 50 + injected 150)"
);
}