Files
smarm/tests/causal.rs
T
Claude (sandbox) 2668f4018f feat(causal): native causal profiling behind smarm-causal (RFC 007 v1)
causal_site! scoped site guards per actor slot, progress! throughput
points, and a Coz-style virtual-speedup engine hooked into
maybe_preempt's amortized cold block: target-site samples grow a global
delay ledger; bystanders spin-absorb their debt at the next causal
check, with timeslice extension so injected delay is not charged
against the slice.

Resume credit (Coz's blocked-thread rule) is gated on a causal_parked
slot bit set only by a real park: crediting on every resume made any
yield-cadence actor delay-immune and every experiment inert (found
live on a 24-core run — dead-flat deltas across all sites).

Report normalization uses a measured TSC frequency (~50ms calibration
on first use) instead of the crate-wide 3 GHz assumption, which
uniformly inflated impact numbers on a 3.7 GHz box. impact_pct() is
the machine-readable form of the summary for programmatic checks.

examples/causal_pipeline.rs burns fixed *work* (calibrated LCG loop),
not fixed wall time — a timed busy-wait absorbs injected delay into
its own budget and reads as a no-op. Self-checking: exits nonzero if
causal separation fails; skips the verdict below 4 cores. Validated
on a 24-core box: reserve (true bottleneck) +29.3%@25/+83.5%@50;
serialize and background-compaction ~0%.

Known v1 gaps (jar): timer-heap deadlines unshifted, no-check!/no-alloc
actors undelayable, multi-scheduler coherence best-effort Relaxed,
off-CPU blame punted, Instant::now() uncorrected.

Zero-cost with the feature off; clippy -D warnings clean both ways;
full suite green with and without smarm-causal.
2026-07-12 19:10:04 +00:00

335 lines
12 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 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 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() {
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() {
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}");
});
}