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.
335 lines
12 KiB
Rust
335 lines
12 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}");
|
||
});
|
||
}
|