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.
This commit is contained in:
Claude (sandbox)
2026-07-12 19:10:04 +00:00
parent 1c90a4ef5e
commit 2668f4018f
8 changed files with 1386 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
//! 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}");
});
}