Samples were taken only when maybe_preempt's cold block happened to fire in-site, so the interval between the last check and SiteGuard drop was discarded on every site entry. Measured live on the 24-core box: 22-29us lost per entry, a constant attribution efficiency of ~0.93-0.94, which under-reported every impact (+83.5% where theory says +100%; the observed shortfall fits 1/(1-pct*eff)-1 at both 25% and 50%). SiteGuard enter/drop now call site_transition(): leaving the target site flushes the pending interval into the ledger (sample-only, never spins, so safe under no-preempt regions); entering the target site re-arms the sample clock so pre-site time is never attributed (the symmetric over-attribution). Winner attribution is factored into attribute(), shared by the cold check and the flush, with the same interval clamps. Adds examples/causal_attrib_probe.rs (ground-truth in-site time vs ledger attribution, the probe that confirmed the leak) and the site_boundaries_flush_tail regression test (a site entry that never hits a cold check must still be attributed). Also gates causal_probe on smarm-causal in Cargo.toml - it never was, so featureless builds of the examples were broken.
391 lines
15 KiB
Rust
391 lines
15 KiB
Rust
//! 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}");
|
||
});
|
||
}
|
||
|
||
/// 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() {
|
||
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%)"
|
||
);
|
||
}
|