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
+177
View File
@@ -0,0 +1,177 @@
//! Causal-profiling demo (RFC 007): a pipeline where conventional profiling
//! lies and causal profiling doesn't.
//!
//! producer --(serialize ~200µs/item)--> reserve --(~400µs/item)--> notify
//! background: an actor burning CPU constantly, fully off the critical path
//!
//! `reserve` is the true bottleneck. `serialize` is hot but overlapped with
//! `reserve`'s backlog, and `background` is the hottest code in the process
//! while contributing nothing to throughput. A cycle profiler ranks them
//! background > reserve ≈ 2×serialize; the causal report instead shows
//! throughput responding to virtual speedups of `reserve` and (near-)ignoring
//! `serialize` and `background`.
//!
//! Stage cost is fixed *work* (a calibrated arithmetic loop), not fixed wall
//! time. This matters: a timed busy-wait absorbs injected causal delay into
//! its own budget and finishes on schedule regardless, making every
//! experiment read as a no-op (found live on a 24-core run: dead-flat
//! deltas). Real workloads are work-shaped, so the demo must be too.
//!
//! Run:
//! cargo run --release --example causal_pipeline --features smarm-causal
//!
//! Prints a summary, writes `profile.coz` (Coz plot-compatible), and — given
//! enough cores for the pipeline to actually run in parallel — checks the
//! expected separation and exits nonzero if it doesn't hold, so a CI box can
//! run this as a smoke test.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// LCG-mix `iters` times in dependent sequence (unvectorizable, un-elidable),
/// staying preemptible — and causal-sampleable/delayable — via `check!()`.
fn work_iters(iters: u64) {
let mut acc = 0x2545_f491_4f6c_dd1du64;
let mut i = 0u64;
while i < iters {
let chunk_end = (i + 256).min(iters);
while i < chunk_end {
acc = acc.wrapping_mul(6364136223846793005).wrapping_add(i);
i += 1;
}
std::hint::black_box(acc);
smarm::check!();
}
}
/// Measure how many `work_iters` iterations fit in a microsecond on this
/// machine, so stage costs below are meaningful in time while staying
/// work-shaped.
fn calibrate_iters_per_us() -> u64 {
let n = 8_000_000u64;
let t = Instant::now();
work_iters(n);
(n / (t.elapsed().as_micros().max(1) as u64)).max(1)
}
fn main() {
let per_us = calibrate_iters_per_us();
println!("calibration: {per_us} work iters/µs");
let work_us = move |us: u64| work_iters(us * per_us);
let mut failures: Vec<String> = Vec::new();
smarm::init(smarm::Config::default()).run(move || {
let stop = Arc::new(AtomicBool::new(false));
let (tx_ab, rx_ab) = smarm::channel::<u64>();
let (tx_bc, rx_bc) = smarm::channel::<u64>();
// Producer: hot serialization, but upstream of the bottleneck.
let stop_p = stop.clone();
let producer = smarm::spawn(move || {
let mut i = 0u64;
while !stop_p.load(Ordering::Relaxed) {
{
let _g = smarm::causal_site!("serialize");
work_us(200);
}
if tx_ab.send(i).is_err() {
break;
}
i += 1;
}
// tx_ab drops here; downstream drains and exits.
});
// Reserve: the true bottleneck (~400µs of work per item).
let reserve = smarm::spawn(move || {
while let Ok(item) = rx_ab.recv() {
{
let _g = smarm::causal_site!("reserve");
work_us(400);
}
if tx_bc.send(item).is_err() {
break;
}
}
});
// Notify: light tail stage; marks the unit of useful work.
let notify = smarm::spawn(move || {
while rx_bc.recv().is_ok() {
{
let _g = smarm::causal_site!("notify");
work_us(50);
}
smarm::progress!("orders-processed");
}
});
// Background: hottest code in the process, zero throughput relevance.
let stop_bg = stop.clone();
let background = smarm::spawn(move || {
while !stop_bg.load(Ordering::Relaxed) {
let _g = smarm::causal_site!("background-compaction");
work_us(500);
}
});
// Warm up so queues reach steady state before measuring.
smarm::sleep(Duration::from_millis(300));
let results = smarm::causal::run_experiments(&smarm::causal::ExperimentPlan {
speedups_pct: vec![0, 25, 50],
experiment: Duration::from_millis(700),
cooldown: Duration::from_millis(150),
});
stop.store(true, Ordering::Relaxed);
producer.join().unwrap();
reserve.join().unwrap();
notify.join().unwrap();
background.join().unwrap();
print!("{}", smarm::causal::render_summary(&results));
let coz = smarm::causal::render_coz(&results);
match std::fs::write("profile.coz", coz) {
Ok(()) => println!("\nwrote profile.coz"),
Err(e) => eprintln!("\nfailed to write profile.coz: {e}"),
}
// Verdict. The separation only exists when the four pipeline actors
// actually run in parallel; on a small box, report and skip.
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
if cores < 4 {
println!("verdict: SKIPPED ({cores} cores; separation needs the stages in parallel)");
return;
}
let impact = |site: &str| {
smarm::causal::impact_pct(&results, site, 25, "orders-processed")
};
let mut expect = |site: &str, ok: &dyn Fn(f64) -> bool, want: &str| match impact(site) {
Some(p) => {
let verdict = if ok(p) { "ok" } else { "FAIL" };
println!("verdict: {site} @25% -> {p:+.1}% (want {want}) {verdict}");
if !ok(p) {
failures.push(format!("{site}: {p:+.1}% (want {want})"));
}
}
None => {
println!("verdict: {site} @25% -> missing cell FAIL");
failures.push(format!("{site}: missing cell"));
}
};
expect("reserve", &|p| p > 15.0, "> +15%");
expect("serialize", &|p| p < 10.0, "< +10%");
expect("background-compaction", &|p| p < 10.0, "< +10%");
if failures.is_empty() {
println!("verdict: PASS — causal separation holds");
} else {
println!("verdict: FAIL — {}", failures.join("; "));
std::process::exit(1);
}
});
}
+145
View File
@@ -0,0 +1,145 @@
//! Diagnostic probe for RFC 007 on a target box. Measures, in order:
//! 1. TSC frequency against `Instant` (the crate assumes 3 GHz).
//! 2. TSC sanity under actor migration: distribution of wall time actually
//! spent in `burn_us(400)` across many runs — a bimodal/short tail means
//! cross-core TSC offsets are cutting burns short.
//! 3. Pipeline stage rates with no experiment running (who is the real
//! bottleneck?).
//! 4. The same rates during a 50% experiment on `background-compaction`
//! (a correct implementation must slow every stage; an off-critical-path
//! target must reduce end-to-end throughput proportionally).
//!
//! Run: cargo run --release --example causal_probe --features smarm-causal
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
fn burn_us(us: u64) {
let cycles = us * 3_000;
let start = smarm::preempt::rdtsc();
while smarm::preempt::rdtsc().saturating_sub(start) < cycles {
smarm::check!();
}
}
fn main() {
// 1. TSC calibration (plain OS thread, before the runtime starts).
let c0 = smarm::preempt::rdtsc();
let t0 = Instant::now();
std::thread::sleep(Duration::from_millis(200));
let hz = (smarm::preempt::rdtsc() - c0) as f64 / t0.elapsed().as_secs_f64();
println!("tsc_hz: {:.3e} (crate assumes 3.0e9)", hz);
smarm::init(smarm::Config::default()).run(move || {
// 2. burn_us(400) wall-time distribution inside a migrating actor.
let h = smarm::spawn(|| {
let mut samples: Vec<u64> = (0..500)
.map(|_| {
let t = Instant::now();
burn_us(400);
t.elapsed().as_micros() as u64
})
.collect();
samples.sort_unstable();
println!(
"burn_us(400) wall us: min {} p10 {} p50 {} p90 {} max {}",
samples[0], samples[50], samples[250], samples[450], samples[499]
);
});
h.join().unwrap();
// 3+4. Pipeline with per-stage counters.
let stop = Arc::new(AtomicBool::new(false));
let produced = Arc::new(AtomicU64::new(0));
let reserved = Arc::new(AtomicU64::new(0));
let notified = Arc::new(AtomicU64::new(0));
let (tx_ab, rx_ab) = smarm::channel::<u64>();
let (tx_bc, rx_bc) = smarm::channel::<u64>();
let stop_p = stop.clone();
let produced2 = produced.clone();
let producer = smarm::spawn(move || {
let mut i = 0u64;
while !stop_p.load(Ordering::Relaxed) {
{
let _g = smarm::causal_site!("serialize");
burn_us(200);
}
if tx_ab.send(i).is_err() {
break;
}
produced2.fetch_add(1, Ordering::Relaxed);
i += 1;
}
});
let reserved2 = reserved.clone();
let reserve = smarm::spawn(move || {
while let Ok(item) = rx_ab.recv() {
{
let _g = smarm::causal_site!("reserve");
burn_us(400);
}
reserved2.fetch_add(1, Ordering::Relaxed);
if tx_bc.send(item).is_err() {
break;
}
}
});
let notified2 = notified.clone();
let notify = smarm::spawn(move || {
while rx_bc.recv().is_ok() {
{
let _g = smarm::causal_site!("notify");
burn_us(50);
}
notified2.fetch_add(1, Ordering::Relaxed);
smarm::progress!("orders-processed");
}
});
let stop_bg = stop.clone();
let background = smarm::spawn(move || {
while !stop_bg.load(Ordering::Relaxed) {
let _g = smarm::causal_site!("background-compaction");
burn_us(500);
}
});
smarm::sleep(Duration::from_millis(300));
let window = |label: &str| {
let (p0, r0, n0) = (
produced.load(Ordering::Relaxed),
reserved.load(Ordering::Relaxed),
notified.load(Ordering::Relaxed),
);
let d0 = smarm::causal::global_delay_cycles();
let t = Instant::now();
smarm::sleep(Duration::from_millis(700));
let secs = t.elapsed().as_secs_f64();
println!(
"{label}: produced {:.0}/s reserved {:.0}/s notified {:.0}/s injected {:.0}ms(assumed-3GHz)",
(produced.load(Ordering::Relaxed) - p0) as f64 / secs,
(reserved.load(Ordering::Relaxed) - r0) as f64 / secs,
(notified.load(Ordering::Relaxed) - n0) as f64 / secs,
(smarm::causal::global_delay_cycles() - d0) as f64 / 3.0e9 * 1e3,
);
};
window("no-experiment ");
smarm::causal::begin_experiment_for_test("background-compaction", 50);
window("bg-comp @ 50% ");
smarm::causal::end_experiment_for_test();
window("post-experiment");
stop.store(true, Ordering::Relaxed);
producer.join().unwrap();
reserve.join().unwrap();
notify.join().unwrap();
background.join().unwrap();
});
}