feat(causal): ledger audit — decompose the @50 injection deficit (RFC 007)
Measure-only counters for the deficit hunt (~23ms short per 700ms window at 50% on the bottleneck site; superlinear vs 25%). Nothing here changes injection or absorption; the sweep decides the fix. Buckets, windowed per cell into new ExperimentResult fields (audit snapshot taken at end() — spin/attribution freeze there, forgiveness does not): - spin_absorbed / park_forgiven: where owed delay actually went. Spin during a 0% cell is the baseline-contamination signature — leftover debt from a prior window being paid in a later one (checks gate on the experiment word, so cooldowns pay nothing and debt carries over). - drop_park / drop_yield (+counts): the deschedule path flushes no sample tail — an in-target-site park or yield silently loses [last sample -> now]; on_resume re-arms before the actor runs again. New on_deschedule hook in all three intent arms (real park; explicit/ slice-expiry yield; requeued park counts as yield — it never blocked). Slice-expiry yields sample at the descheduling checkpoint, so a fat yield bucket points at explicit yield_now or requeued parks. - discard_overmax (+count, in would-be delta terms so columns compare against injected_cycles) / discard_unarmed: the attribute() clamps, previously silent. LedgerCounters + ledger_counters() expose cumulative totals (tests, run-level prints); render_ledger_audit() is the per-cell companion to render_summary, which stays byte-identical (pinned). ExperimentResult now derives Default so literals survive future audit-field growth. Tests: +7 (spin counted, forgiveness counted, in-site park drop, in-site yield drop, overmax discard, 0%-window leftover absorption — synthesized deterministically via inject_delay_cycles_for_test with no experiment active — and the audit render). 22/22 causal.
This commit is contained in:
+208
-4
@@ -79,6 +79,31 @@ mod inner {
|
||||
/// ~3ms at 3 GHz.
|
||||
const MAX_SPIN_PER_VISIT: u64 = 10_000_000;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Ledger audit (RFC 007 deficit hunt): where injected delay is born,
|
||||
// paid, and forgiven — and where would-be attribution is silently lost
|
||||
// (deschedule tails, clamp discards). Monotone Relaxed totals, read via
|
||||
// `ledger_counters()`; `run_experiments` windows them into
|
||||
// `ExperimentResult`. Measure-only: nothing here changes injection or
|
||||
// absorption behaviour.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Cycles bystanders actually spun to pay down the global ledger.
|
||||
static SPIN_ABSORBED_CYCLES: AtomicU64 = AtomicU64::new(0);
|
||||
/// Cycles waived at wake after a real park (the blocked-thread rule).
|
||||
static PARK_FORGIVEN_CYCLES: AtomicU64 = AtomicU64::new(0);
|
||||
/// Would-be attribution lost when the target-site actor parks mid-site.
|
||||
static DROP_PARK_CYCLES: AtomicU64 = AtomicU64::new(0);
|
||||
static DROP_PARK_N: AtomicU64 = AtomicU64::new(0);
|
||||
/// Same loss at yields (explicit, slice-expiry, or a park that requeued).
|
||||
static DROP_YIELD_CYCLES: AtomicU64 = AtomicU64::new(0);
|
||||
static DROP_YIELD_N: AtomicU64 = AtomicU64::new(0);
|
||||
/// Samples discarded by the TSC-weirdness clamp, in would-be delta terms.
|
||||
static DISCARD_OVERMAX_CYCLES: AtomicU64 = AtomicU64::new(0);
|
||||
static DISCARD_OVERMAX_N: AtomicU64 = AtomicU64::new(0);
|
||||
/// In-site samples dropped because the thread's clock was unarmed.
|
||||
static DISCARD_UNARMED_N: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn sites() -> &'static Mutex<Vec<&'static str>> {
|
||||
SITES.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
@@ -256,7 +281,9 @@ mod inner {
|
||||
// and credit ourselves the same amount — the credited gap *is*
|
||||
// the virtual speedup.
|
||||
if last == 0 {
|
||||
return; // unarmed clock: no interval to attribute
|
||||
// Unarmed clock: no interval to attribute — count the loss.
|
||||
DISCARD_UNARMED_N.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
// SAFETY: `slot` is the on-CPU actor's slot (checked non-null
|
||||
// above); see `check_cancelled` for the lifetime argument.
|
||||
@@ -276,6 +303,7 @@ mod inner {
|
||||
while preempt::rdtsc().saturating_sub(start) < spin {
|
||||
core::hint::spin_loop();
|
||||
}
|
||||
SPIN_ABSORBED_CYCLES.fetch_add(spin, Ordering::Relaxed);
|
||||
unsafe { (*slot).set_causal_delay(mine.wrapping_add(spin)) };
|
||||
preempt::extend_timeslice(spin);
|
||||
// The spin is not part of the next sample interval either.
|
||||
@@ -293,7 +321,16 @@ mod inner {
|
||||
/// SAFETY: `slot` must point at the on-CPU actor's slot (the
|
||||
/// `check_cancelled` lifetime argument).
|
||||
unsafe fn attribute(slot: *const crate::runtime::Slot, interval: u64, pct: u64) {
|
||||
if interval == 0 || interval > MAX_SAMPLE_CYCLES {
|
||||
if interval == 0 {
|
||||
return; // now == last: nothing to attribute, nothing lost
|
||||
}
|
||||
if interval > MAX_SAMPLE_CYCLES {
|
||||
// TSC-weirdness clamp: the sample is discarded, not the run.
|
||||
// Count the loss in would-be delta terms so the audit's columns
|
||||
// compare directly against `injected_cycles`.
|
||||
DISCARD_OVERMAX_N.fetch_add(1, Ordering::Relaxed);
|
||||
DISCARD_OVERMAX_CYCLES
|
||||
.fetch_add(interval.saturating_mul(pct) / 100, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
let delta = interval.saturating_mul(pct) / 100;
|
||||
@@ -327,10 +364,14 @@ mod inner {
|
||||
if old == target && new != target {
|
||||
let now = preempt::rdtsc();
|
||||
let last = LAST_SAMPLE_TSC.with(|c| c.replace(now));
|
||||
if last != 0 && pct > 0 {
|
||||
if pct > 0 {
|
||||
if last != 0 {
|
||||
// SAFETY: forwarded from the guard, which holds the on-CPU
|
||||
// actor's slot for its whole life (see `SiteGuard::slot`).
|
||||
unsafe { attribute(slot, now.saturating_sub(last), pct) };
|
||||
} else {
|
||||
DISCARD_UNARMED_N.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
} else if new == target && old != target {
|
||||
LAST_SAMPLE_TSC.with(|c| c.set(preempt::rdtsc()));
|
||||
@@ -352,13 +393,55 @@ mod inner {
|
||||
pub(crate) fn on_resume(slot: &crate::runtime::Slot) {
|
||||
if slot.take_causal_parked() {
|
||||
let global = GLOBAL_DELAY.load(Ordering::Relaxed);
|
||||
if slot.causal_delay() < global {
|
||||
let mine = slot.causal_delay();
|
||||
if mine < global {
|
||||
PARK_FORGIVEN_CYCLES.fetch_add(global - mine, Ordering::Relaxed);
|
||||
slot.set_causal_delay(global);
|
||||
}
|
||||
}
|
||||
LAST_SAMPLE_TSC.with(|c| c.set(preempt::rdtsc()));
|
||||
}
|
||||
|
||||
/// Deschedule-path hook (scheduler side, same OS thread the actor just
|
||||
/// ran on). If an experiment is live and the departing actor sits in the
|
||||
/// target site, the sample tail `[last sample -> now]` is about to be
|
||||
/// lost: nothing flushes it here, and `on_resume` re-arms the clock
|
||||
/// before the actor runs again. Measure-only (RFC 007 deficit hunt) —
|
||||
/// tally the would-be attribution into the park/yield drop buckets and
|
||||
/// leave behaviour untouched. The interval is capped at
|
||||
/// MAX_SAMPLE_CYCLES: past that the flush would have discarded it anyway
|
||||
/// (counted separately). `now` includes the few hundred ns of scheduler
|
||||
/// bookkeeping since the actor actually stopped — an acceptable
|
||||
/// overcount for a diagnostic.
|
||||
///
|
||||
/// Slice-expiry yields sample at the same checkpoint that deschedules
|
||||
/// them, so their tails are ~zero by construction; a fat yield bucket
|
||||
/// therefore points at explicit `yield_now` calls or requeued parks.
|
||||
pub(crate) fn on_deschedule(slot: &crate::runtime::Slot, real_park: bool) {
|
||||
let exp = EXPERIMENT.load(Ordering::Relaxed);
|
||||
if exp == 0 {
|
||||
return;
|
||||
}
|
||||
let target = (exp >> 32) as u32;
|
||||
let pct = exp & 0xffff_ffff;
|
||||
if pct == 0 || slot.causal_site() != target {
|
||||
return;
|
||||
}
|
||||
let last = LAST_SAMPLE_TSC.with(|c| c.get());
|
||||
if last == 0 {
|
||||
return;
|
||||
}
|
||||
let interval = preempt::rdtsc().saturating_sub(last).min(MAX_SAMPLE_CYCLES);
|
||||
let would_be = interval.saturating_mul(pct) / 100;
|
||||
if real_park {
|
||||
DROP_PARK_N.fetch_add(1, Ordering::Relaxed);
|
||||
DROP_PARK_CYCLES.fetch_add(would_be, Ordering::Relaxed);
|
||||
} else {
|
||||
DROP_YIELD_N.fetch_add(1, Ordering::Relaxed);
|
||||
DROP_YIELD_CYCLES.fetch_add(would_be, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Experiments
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -376,6 +459,63 @@ mod inner {
|
||||
GLOBAL_DELAY.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Cumulative ledger-audit totals since startup (RFC 007 deficit hunt).
|
||||
/// All monotone; window a span by snapshotting before/after and taking
|
||||
/// `delta_since`. Cycle fields are in would-be-injected delta terms so
|
||||
/// they compare directly against `injected_cycles`.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct LedgerCounters {
|
||||
pub spin_absorbed_cycles: u64,
|
||||
pub park_forgiven_cycles: u64,
|
||||
pub drop_park_cycles: u64,
|
||||
pub drop_park_n: u64,
|
||||
pub drop_yield_cycles: u64,
|
||||
pub drop_yield_n: u64,
|
||||
pub discard_overmax_cycles: u64,
|
||||
pub discard_overmax_n: u64,
|
||||
pub discard_unarmed_n: u64,
|
||||
}
|
||||
|
||||
impl LedgerCounters {
|
||||
/// Field-wise difference against an earlier snapshot.
|
||||
pub fn delta_since(&self, before: &LedgerCounters) -> LedgerCounters {
|
||||
LedgerCounters {
|
||||
spin_absorbed_cycles: self
|
||||
.spin_absorbed_cycles
|
||||
.saturating_sub(before.spin_absorbed_cycles),
|
||||
park_forgiven_cycles: self
|
||||
.park_forgiven_cycles
|
||||
.saturating_sub(before.park_forgiven_cycles),
|
||||
drop_park_cycles: self.drop_park_cycles.saturating_sub(before.drop_park_cycles),
|
||||
drop_park_n: self.drop_park_n.saturating_sub(before.drop_park_n),
|
||||
drop_yield_cycles: self
|
||||
.drop_yield_cycles
|
||||
.saturating_sub(before.drop_yield_cycles),
|
||||
drop_yield_n: self.drop_yield_n.saturating_sub(before.drop_yield_n),
|
||||
discard_overmax_cycles: self
|
||||
.discard_overmax_cycles
|
||||
.saturating_sub(before.discard_overmax_cycles),
|
||||
discard_overmax_n: self.discard_overmax_n.saturating_sub(before.discard_overmax_n),
|
||||
discard_unarmed_n: self.discard_unarmed_n.saturating_sub(before.discard_unarmed_n),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the cumulative audit counters.
|
||||
pub fn ledger_counters() -> LedgerCounters {
|
||||
LedgerCounters {
|
||||
spin_absorbed_cycles: SPIN_ABSORBED_CYCLES.load(Ordering::Relaxed),
|
||||
park_forgiven_cycles: PARK_FORGIVEN_CYCLES.load(Ordering::Relaxed),
|
||||
drop_park_cycles: DROP_PARK_CYCLES.load(Ordering::Relaxed),
|
||||
drop_park_n: DROP_PARK_N.load(Ordering::Relaxed),
|
||||
drop_yield_cycles: DROP_YIELD_CYCLES.load(Ordering::Relaxed),
|
||||
drop_yield_n: DROP_YIELD_N.load(Ordering::Relaxed),
|
||||
discard_overmax_cycles: DISCARD_OVERMAX_CYCLES.load(Ordering::Relaxed),
|
||||
discard_overmax_n: DISCARD_OVERMAX_N.load(Ordering::Relaxed),
|
||||
discard_unarmed_n: DISCARD_UNARMED_N.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorbed-delay ledger of the on-CPU actor (testing/introspection).
|
||||
pub fn my_absorbed_delay_cycles() -> u64 {
|
||||
let slot = preempt::current_slot_ptr();
|
||||
@@ -430,6 +570,7 @@ mod inner {
|
||||
}
|
||||
|
||||
/// One completed experiment cell.
|
||||
#[derive(Default)]
|
||||
pub struct ExperimentResult {
|
||||
pub site: String,
|
||||
pub speedup_pct: u32,
|
||||
@@ -438,6 +579,19 @@ mod inner {
|
||||
pub deltas: Vec<(String, u64)>,
|
||||
/// Virtual delay injected during the window (cycles).
|
||||
pub injected_cycles: u64,
|
||||
// Ledger-audit deltas over the window (RFC 007 deficit hunt); see
|
||||
// `LedgerCounters` for field semantics. `spin_absorbed_cycles > 0`
|
||||
// in a 0% cell means the window paid debt left over from an earlier
|
||||
// one — the baseline-contamination signature.
|
||||
pub spin_absorbed_cycles: u64,
|
||||
pub park_forgiven_cycles: u64,
|
||||
pub drop_park_cycles: u64,
|
||||
pub drop_park_n: u64,
|
||||
pub drop_yield_cycles: u64,
|
||||
pub drop_yield_n: u64,
|
||||
pub discard_overmax_cycles: u64,
|
||||
pub discard_overmax_n: u64,
|
||||
pub discard_unarmed_n: u64,
|
||||
}
|
||||
|
||||
/// Run the plan synchronously on the calling (OS) thread: for every
|
||||
@@ -479,10 +633,16 @@ mod inner {
|
||||
for &pct in &plan.speedups_pct {
|
||||
let before = progress_snapshot();
|
||||
let injected_before = global_delay_cycles();
|
||||
let audit_before = ledger_counters();
|
||||
let t0 = Instant::now();
|
||||
begin(*sid, pct);
|
||||
controller_sleep(plan.experiment);
|
||||
end();
|
||||
// Snapshot immediately: injection and spin freeze at `end()`
|
||||
// (checks gate on the experiment word), but forgiveness does
|
||||
// not — a later snapshot would leak cooldown wakes into the
|
||||
// window.
|
||||
let audit = ledger_counters().delta_since(&audit_before);
|
||||
let elapsed = t0.elapsed();
|
||||
let after = progress_snapshot();
|
||||
let deltas = after
|
||||
@@ -502,6 +662,15 @@ mod inner {
|
||||
duration: elapsed,
|
||||
deltas,
|
||||
injected_cycles: global_delay_cycles() - injected_before,
|
||||
spin_absorbed_cycles: audit.spin_absorbed_cycles,
|
||||
park_forgiven_cycles: audit.park_forgiven_cycles,
|
||||
drop_park_cycles: audit.drop_park_cycles,
|
||||
drop_park_n: audit.drop_park_n,
|
||||
drop_yield_cycles: audit.drop_yield_cycles,
|
||||
drop_yield_n: audit.drop_yield_n,
|
||||
discard_overmax_cycles: audit.discard_overmax_cycles,
|
||||
discard_overmax_n: audit.discard_overmax_n,
|
||||
discard_unarmed_n: audit.discard_unarmed_n,
|
||||
});
|
||||
controller_sleep(plan.cooldown);
|
||||
}
|
||||
@@ -626,6 +795,41 @@ mod inner {
|
||||
s
|
||||
}
|
||||
|
||||
/// Ledger-audit companion to `render_summary` (RFC 007 deficit hunt):
|
||||
/// per cell, where the window's virtual delay went — born (injected),
|
||||
/// paid (absorbed), waived (forgiven at wake) — and the attribution the
|
||||
/// sampler lost: tails dropped at parks/yields inside the target site,
|
||||
/// plus clamp discards. Cycle columns in ms at the calibrated TSC rate.
|
||||
/// `absorbed` above `injected` in a cell (0% especially) means it paid
|
||||
/// debt left over from earlier windows.
|
||||
pub fn render_ledger_audit(results: &[ExperimentResult]) -> String {
|
||||
use std::fmt::Write;
|
||||
let hz = tsc_hz();
|
||||
let ms = |c: u64| c as f64 / hz * 1e3;
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "== smarm causal ledger audit ==");
|
||||
for r in results {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"site {:<22} @{:>2}% injected {:>7.1}ms absorbed {:>7.1}ms forgiven {:>7.1}ms \
|
||||
drop park {:>6.2}ms/{:<4} yield {:>6.2}ms/{:<4} discard >max {:>6.2}ms/{:<3} unarmed {}",
|
||||
r.site,
|
||||
r.speedup_pct,
|
||||
ms(r.injected_cycles),
|
||||
ms(r.spin_absorbed_cycles),
|
||||
ms(r.park_forgiven_cycles),
|
||||
ms(r.drop_park_cycles),
|
||||
r.drop_park_n,
|
||||
ms(r.drop_yield_cycles),
|
||||
r.drop_yield_n,
|
||||
ms(r.discard_overmax_cycles),
|
||||
r.discard_overmax_n,
|
||||
r.discard_unarmed_n
|
||||
);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Coz-compatible profile text (`profile.coz`), so Coz's existing plot
|
||||
/// tooling renders our experiments — the RFC's "don't build a UI" call.
|
||||
pub fn render_coz(results: &[ExperimentResult]) -> String {
|
||||
|
||||
+14
-1
@@ -1757,6 +1757,11 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
let gen = pid.generation();
|
||||
match intent {
|
||||
YieldIntent::Yield => {
|
||||
// RFC 007 audit: measure the sample tail this yield drops
|
||||
// (near-zero for slice-expiry yields, which sample at the
|
||||
// same checkpoint; fat for explicit yield_now in-site).
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
crate::causal::on_deschedule(slot, false);
|
||||
// Running OR RunningNotified → Queued; a notification
|
||||
// arriving mid-run coalesces into the re-queue.
|
||||
crate::te!(crate::trace::Event::Yield(pid));
|
||||
@@ -1765,6 +1770,10 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
}
|
||||
YieldIntent::Park => {
|
||||
if slot.word.park_return(gen) {
|
||||
// RFC 007 audit: an in-site park drops its sample
|
||||
// tail (nothing flushes it; on_resume re-arms).
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
crate::causal::on_deschedule(slot, true);
|
||||
// RFC 007: a real park — the eventual wake forgives
|
||||
// delay accrued while blocked (the instant-wake
|
||||
// re-queue below does NOT: that actor never blocked).
|
||||
@@ -1774,7 +1783,11 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
} else {
|
||||
// An unpark landed in the prep-to-park window; the
|
||||
// word is back to Queued — re-queue instead of
|
||||
// parking. The lost-wakeup window, closed.
|
||||
// parking. The lost-wakeup window, closed: this actor
|
||||
// never blocked, so its dropped tail counts as a
|
||||
// yield in the RFC 007 audit.
|
||||
#[cfg(feature = "smarm-causal")]
|
||||
crate::causal::on_deschedule(slot, false);
|
||||
crate::te!(crate::trace::Event::UnparkFlagConsumed(pid));
|
||||
inner.enqueue(pid);
|
||||
}
|
||||
|
||||
+257
@@ -261,6 +261,7 @@ fn impact_from_synthetic_results() {
|
||||
duration: secs(1.0),
|
||||
deltas: vec![("units".into(), 1000)],
|
||||
injected_cycles: 0,
|
||||
..Default::default()
|
||||
},
|
||||
// Bottleneck at 50%: raw count unchanged, 0.5s injected
|
||||
// -> rate 1000/0.5 = 2x baseline -> +100%.
|
||||
@@ -270,6 +271,7 @@ fn impact_from_synthetic_results() {
|
||||
duration: secs(1.0),
|
||||
deltas: vec![("units".into(), 1000)],
|
||||
injected_cycles: cycles(0.5),
|
||||
..Default::default()
|
||||
},
|
||||
ExperimentResult {
|
||||
site: "overlapped".into(),
|
||||
@@ -277,6 +279,7 @@ fn impact_from_synthetic_results() {
|
||||
duration: secs(1.0),
|
||||
deltas: vec![("units".into(), 1000)],
|
||||
injected_cycles: 0,
|
||||
..Default::default()
|
||||
},
|
||||
// Overlapped at 50%: everyone else (incl. the real bottleneck)
|
||||
// slowed, count drops with the divisor -> ~0% impact.
|
||||
@@ -286,6 +289,7 @@ fn impact_from_synthetic_results() {
|
||||
duration: secs(1.0),
|
||||
deltas: vec![("units".into(), 500)],
|
||||
injected_cycles: cycles(0.5),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let bn = impact_pct(&results, "bottleneck", 50, "units").unwrap();
|
||||
@@ -652,3 +656,256 @@ fn wall_send_after_cancels() {
|
||||
"cancelled wall send_after delivered"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ledger audit (RFC 007 deficit hunt) — measure-only counters that decompose
|
||||
// where injected delay is born, paid, forgiven, and where would-be
|
||||
// attribution is silently lost. Behaviour under test is accounting only:
|
||||
// none of these tests assert a change in injection or absorption itself.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A never-parking bystander's spin absorption lands in the audit's
|
||||
/// `spin_absorbed_cycles` — the "paid for real" bucket.
|
||||
#[test]
|
||||
fn audit_counts_spin_absorption() {
|
||||
let _s = serial();
|
||||
smarm::init(smarm::Config::exact(1)).run(|| {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_t = stop.clone();
|
||||
let target = smarm::spawn(move || {
|
||||
let _g = smarm::causal_site!("audit-spin-site");
|
||||
while !stop_t.load(Ordering::Relaxed) {
|
||||
smarm::check!();
|
||||
}
|
||||
});
|
||||
let stop_b = stop.clone();
|
||||
let bystander = smarm::spawn(move || {
|
||||
while !stop_b.load(Ordering::Relaxed) {
|
||||
smarm::check!();
|
||||
}
|
||||
});
|
||||
let c0 = smarm::causal::ledger_counters();
|
||||
let g0 = smarm::causal::global_delay_cycles();
|
||||
smarm::causal::begin_experiment_for_test("audit-spin-site", 50);
|
||||
smarm::sleep(Duration::from_millis(150));
|
||||
smarm::causal::end_experiment_for_test();
|
||||
let injected = smarm::causal::global_delay_cycles() - g0;
|
||||
let c1 = smarm::causal::ledger_counters();
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
target.join().unwrap();
|
||||
bystander.join().unwrap();
|
||||
|
||||
let spun = c1.spin_absorbed_cycles - c0.spin_absorbed_cycles;
|
||||
assert!(injected > 0, "experiment injected nothing");
|
||||
assert!(
|
||||
spun >= injected / 2,
|
||||
"audit missed spin absorption: {spun} of {injected} injected"
|
||||
);
|
||||
// Payers are bounded by the debt that exists; 2x headroom covers the
|
||||
// main actor's incidental checkpoint payments.
|
||||
assert!(
|
||||
spun <= injected.saturating_mul(2),
|
||||
"audit over-counted spin: {spun} of {injected} injected"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A wake after a real park waives the sleeper's accrued debt; the waiver is
|
||||
/// tallied in `park_forgiven_cycles` (Coz's blocked-thread rule, now visible).
|
||||
#[test]
|
||||
fn audit_counts_park_forgiveness() {
|
||||
let _s = serial();
|
||||
smarm::init(smarm::Config::exact(1)).run(|| {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_t = stop.clone();
|
||||
let target = smarm::spawn(move || {
|
||||
let _g = smarm::causal_site!("audit-forgive-site");
|
||||
while !stop_t.load(Ordering::Relaxed) {
|
||||
smarm::check!();
|
||||
}
|
||||
});
|
||||
// Parks across the whole injection window; owes everything at wake.
|
||||
let sleeper = smarm::spawn(|| smarm::sleep(Duration::from_millis(120)));
|
||||
|
||||
let c0 = smarm::causal::ledger_counters();
|
||||
let g0 = smarm::causal::global_delay_cycles();
|
||||
smarm::causal::begin_experiment_for_test("audit-forgive-site", 50);
|
||||
smarm::sleep(Duration::from_millis(60));
|
||||
smarm::causal::end_experiment_for_test();
|
||||
let injected = smarm::causal::global_delay_cycles() - g0;
|
||||
sleeper.join().unwrap();
|
||||
let c1 = smarm::causal::ledger_counters();
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
target.join().unwrap();
|
||||
|
||||
let forgiven = c1.park_forgiven_cycles - c0.park_forgiven_cycles;
|
||||
assert!(injected > 0, "experiment injected nothing");
|
||||
assert!(
|
||||
forgiven >= injected / 2,
|
||||
"audit missed park forgiveness: {forgiven} of {injected} injected"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Parking while *inside* the target site drops the sample tail
|
||||
/// `[last sample -> park]` — nothing flushes it, and `on_resume` re-arms the
|
||||
/// clock. The audit counts that loss without changing it.
|
||||
#[test]
|
||||
fn audit_counts_park_drop_inside_target_site() {
|
||||
let _s = serial();
|
||||
smarm::init(smarm::Config::exact(1)).run(|| {
|
||||
smarm::causal::begin_experiment_for_test("audit-park-drop-site", 50);
|
||||
let c0 = smarm::causal::ledger_counters();
|
||||
let h = smarm::spawn(|| {
|
||||
let _g = smarm::causal_site!("audit-park-drop-site");
|
||||
smarm::check!(); // arm an in-site sample interval
|
||||
smarm::sleep(Duration::from_millis(20)); // real park, in-guard
|
||||
});
|
||||
h.join().unwrap();
|
||||
let c1 = smarm::causal::ledger_counters();
|
||||
smarm::causal::end_experiment_for_test();
|
||||
|
||||
assert!(
|
||||
c1.drop_park_n > c0.drop_park_n,
|
||||
"in-site park recorded no drop event"
|
||||
);
|
||||
assert!(
|
||||
c1.drop_park_cycles > c0.drop_park_cycles,
|
||||
"in-site park drop carried no cycles"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// An explicit yield inside the target site loses its tail the same way; it
|
||||
/// lands in the separate yield bucket so park- and yield-losses stay apart.
|
||||
#[test]
|
||||
fn audit_counts_yield_drop_inside_target_site() {
|
||||
let _s = serial();
|
||||
smarm::init(smarm::Config::exact(1)).run(|| {
|
||||
smarm::causal::begin_experiment_for_test("audit-yield-drop-site", 50);
|
||||
let c0 = smarm::causal::ledger_counters();
|
||||
let h = smarm::spawn(|| {
|
||||
let _g = smarm::causal_site!("audit-yield-drop-site");
|
||||
smarm::check!(); // arm an in-site sample interval
|
||||
smarm::yield_now();
|
||||
smarm::check!();
|
||||
});
|
||||
h.join().unwrap();
|
||||
let c1 = smarm::causal::ledger_counters();
|
||||
smarm::causal::end_experiment_for_test();
|
||||
|
||||
assert!(
|
||||
c1.drop_yield_n > c0.drop_yield_n,
|
||||
"in-site yield recorded no drop event"
|
||||
);
|
||||
assert!(
|
||||
c1.drop_yield_cycles > c0.drop_yield_cycles,
|
||||
"in-site yield drop carried no cycles"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A single in-site interval past MAX_SAMPLE_CYCLES is discarded by the
|
||||
/// TSC-weirdness clamp; the audit counts the discard instead of losing it
|
||||
/// silently.
|
||||
#[test]
|
||||
fn audit_counts_overmax_discard() {
|
||||
let _s = serial();
|
||||
smarm::init(smarm::Config::exact(1)).run(|| {
|
||||
smarm::causal::begin_experiment_for_test("audit-overmax-site", 50);
|
||||
let c0 = smarm::causal::ledger_counters();
|
||||
let h = smarm::spawn(|| {
|
||||
let _g = smarm::causal_site!("audit-overmax-site");
|
||||
// Stay in-site past MAX_SAMPLE_CYCLES (~33ms at 3GHz) without a
|
||||
// single checkpoint: no allocs, no check!. The guard-drop flush
|
||||
// then sees one huge interval and must discard it.
|
||||
let t0 = std::time::Instant::now();
|
||||
while t0.elapsed() < Duration::from_millis(60) {
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
});
|
||||
h.join().unwrap();
|
||||
let c1 = smarm::causal::ledger_counters();
|
||||
smarm::causal::end_experiment_for_test();
|
||||
|
||||
assert!(
|
||||
c1.discard_overmax_n > c0.discard_overmax_n,
|
||||
"overmax interval recorded no discard event"
|
||||
);
|
||||
assert!(
|
||||
c1.discard_overmax_cycles > c0.discard_overmax_cycles,
|
||||
"overmax discard carried no cycles"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Debt left outstanding when a window ends is paid during the NEXT active
|
||||
/// experiment — including a 0% baseline cell, whose bystander-chase branch is
|
||||
/// live at pct=0. Synthesized deterministically: grow the ledger with no
|
||||
/// experiment active (nobody pays), then watch a 0% window absorb it.
|
||||
#[test]
|
||||
fn audit_zero_pct_window_absorbs_leftover_debt() {
|
||||
let _s = serial();
|
||||
smarm::init(smarm::Config::exact(1)).run(|| {
|
||||
let hz = smarm::causal::tsc_hz();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_b = stop.clone();
|
||||
let bystander = smarm::spawn(move || {
|
||||
while !stop_b.load(Ordering::Relaxed) {
|
||||
smarm::check!();
|
||||
}
|
||||
});
|
||||
|
||||
// No experiment active: checks early-return, debt sits unpaid.
|
||||
let debt = (hz * 0.020) as u64; // ~20ms
|
||||
smarm::causal::inject_delay_cycles_for_test(debt);
|
||||
smarm::sleep(Duration::from_millis(30));
|
||||
|
||||
let c0 = smarm::causal::ledger_counters();
|
||||
let g0 = smarm::causal::global_delay_cycles();
|
||||
smarm::causal::begin_experiment_for_test("audit-zero-pct-site", 0);
|
||||
smarm::sleep(Duration::from_millis(60));
|
||||
smarm::causal::end_experiment_for_test();
|
||||
let c1 = smarm::causal::ledger_counters();
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
bystander.join().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
smarm::causal::global_delay_cycles(),
|
||||
g0,
|
||||
"a 0% window must inject nothing"
|
||||
);
|
||||
let spun = c1.spin_absorbed_cycles - c0.spin_absorbed_cycles;
|
||||
assert!(
|
||||
spun >= debt / 2,
|
||||
"leftover debt not absorbed in the 0% window: {spun} of {debt}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The audit render lists one line per cell with the ledger columns.
|
||||
#[test]
|
||||
fn ledger_audit_renders_per_cell() {
|
||||
use smarm::causal::{render_ledger_audit, ExperimentResult};
|
||||
let results = vec![ExperimentResult {
|
||||
site: "audit-render-site".into(),
|
||||
speedup_pct: 50,
|
||||
duration: Duration::from_secs(1),
|
||||
deltas: vec![("units".into(), 10)],
|
||||
injected_cycles: 1_000_000,
|
||||
spin_absorbed_cycles: 900_000,
|
||||
park_forgiven_cycles: 50_000,
|
||||
drop_park_cycles: 30_000,
|
||||
drop_park_n: 2,
|
||||
drop_yield_cycles: 10_000,
|
||||
drop_yield_n: 1,
|
||||
discard_overmax_cycles: 5_000,
|
||||
discard_overmax_n: 1,
|
||||
discard_unarmed_n: 0,
|
||||
}];
|
||||
let s = render_ledger_audit(&results);
|
||||
assert!(s.contains("ledger audit"), "missing header: {s}");
|
||||
assert!(s.contains("audit-render-site"), "missing site line: {s}");
|
||||
assert!(s.contains("absorbed"), "missing absorbed column: {s}");
|
||||
assert!(s.contains("forgiven"), "missing forgiven column: {s}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user