feat(causal): timer-heap virtual time — deadlines chase injected delay (RFC 007)

Injected delays dilate virtual time for the workload, but timer deadlines
stayed wall-anchored: a sleep or receive-timeout fired early in virtual
terms, so timeout/retry behaviour sped up relative to the dilated world
(v1 known gap #1).

Every heap Entry now carries delay_stamp — the global delay ledger at
(re-)queue time, cfg-gated on smarm-causal. pop_due converts any debt
accrued since the stamp to wall time (tsc_hz) and shifts the effective
deadline; a not-yet-due entry is re-queued at the shifted deadline with a
fresh stamp, so it keeps chasing delay injected while it waits. seq is
preserved across re-queues, keeping send_after cancellation identity
intact (cancelled entries are discarded before any shift). Zero debt is
byte-identical to the old path; peek_deadline may under-report, costing
one spurious scheduler wake per injected chunk (documented).

This also makes the park-gated resume credit *correct* rather than
forgiving for sleepers: a sleeping actor now physically pays its debt by
sleeping longer, so the on_resume fast-forward reflects real payment
(sleeping_actor_pays_injected_delay pins this end-to-end through the
runtime).

New test hooks: inject_delay_cycles_for_test (deterministic ledger
driver, eagerly TSC-calibrating so conversion never stalls a scheduler
loop) and cycles_to_duration. The ledger is process-global, so the
delta-sensitive causal tests now serialize on a shared test mutex — they
were racy under the parallel test harness before this, in principle.
This commit is contained in:
Claude (sandbox)
2026-07-13 07:31:08 +00:00
parent d496914d40
commit 04dbac1f4b
3 changed files with 237 additions and 13 deletions
+14
View File
@@ -397,6 +397,20 @@ mod inner {
end();
}
/// Test support: grow the global delay ledger directly, as if target-site
/// samples had attributed `cycles` — deterministic driver for the timer
/// virtual-time tests. Calibrates the TSC eagerly so conversion later
/// never stalls a scheduler loop.
pub fn inject_delay_cycles_for_test(cycles: u64) {
let _ = tsc_hz();
GLOBAL_DELAY.fetch_add(cycles, Ordering::Relaxed);
}
/// Convert ledger cycles to wall time at the measured TSC rate.
pub fn cycles_to_duration(cycles: u64) -> Duration {
Duration::from_secs_f64(cycles as f64 / tsc_hz())
}
/// Controller parameters: which speedups to try per site, and the
/// experiment/cooldown windows.
pub struct ExperimentPlan {
+64 -8
View File
@@ -102,6 +102,12 @@ pub struct Entry {
seq: u64,
pub pid: Pid,
pub reason: Reason,
/// RFC 007 virtual time: the global delay ledger reading when this entry
/// was (re-)queued. `pop_due` shifts the effective deadline by any delay
/// injected since, so timers dilate together with the causally-delayed
/// workload instead of firing early in virtual terms.
#[cfg(feature = "smarm-causal")]
delay_stamp: u64,
}
impl PartialEq for Entry {
@@ -166,7 +172,14 @@ impl Timers {
let seq = self.next_seq;
self.next_seq = self.next_seq.wrapping_add(1);
self.armed.insert(seq);
self.heap.push(Reverse(Entry { deadline, seq, pid, reason: Reason::Send { fire } }));
self.heap.push(Reverse(Entry {
deadline,
seq,
pid,
reason: Reason::Send { fire },
#[cfg(feature = "smarm-causal")]
delay_stamp: crate::causal::global_delay_cycles(),
}));
TimerId(seq)
}
@@ -183,7 +196,14 @@ impl Timers {
pub fn insert(&mut self, deadline: Instant, pid: Pid, reason: Reason) {
let seq = self.next_seq;
self.next_seq = self.next_seq.wrapping_add(1);
self.heap.push(Reverse(Entry { deadline, seq, pid, reason }));
self.heap.push(Reverse(Entry {
deadline,
seq,
pid,
reason,
#[cfg(feature = "smarm-causal")]
delay_stamp: crate::causal::global_delay_cycles(),
}));
}
pub fn is_empty(&self) -> bool {
@@ -210,23 +230,59 @@ impl Timers {
/// one is silently dropped here (its `seq` was already removed from
/// `armed` by [`cancel`](Self::cancel)). Returning it removes it from
/// `armed`, so a later `cancel` of a fired timer reports `false`.
///
/// RFC 007 virtual time (feature `smarm-causal`): before an entry fires,
/// any global delay injected since it was (re-)queued is added to its
/// deadline; an entry whose *effective* deadline hasn't passed is pushed
/// back with the shifted deadline and a fresh stamp, so it keeps chasing
/// delay injected while it waits. Consequences, both benign:
/// [`peek_deadline`](Self::peek_deadline) may under-report (raw deadline
/// earlier than effective), costing at most one spurious scheduler wake
/// per injected chunk; and a shift never converts wall time — with zero
/// debt the path is byte-identical to the featureless one.
pub fn pop_due(&mut self, now: Instant) -> Vec<Entry> {
let mut out = Vec::new();
#[cfg(feature = "smarm-causal")]
let global = crate::causal::global_delay_cycles();
while let Some(r) = self.heap.peek() {
if r.0.deadline <= now {
let entry = match self.heap.pop() {
if r.0.deadline > now {
break;
}
#[allow(unused_mut)]
let mut entry = match self.heap.pop() {
Some(e) => e.0,
None => panic!("smarm: timer heap pop after peek returned None (core corrupt)"),
};
if matches!(entry.reason, Reason::Send { .. }) && !self.armed.remove(&entry.seq) {
if matches!(entry.reason, Reason::Send { .. }) && !self.armed.contains(&entry.seq) {
// Cancelled before it came due: discard, do not deliver.
// (Checked before any shift so a cancelled entry is never
// re-queued just to be discarded later.)
continue;
}
out.push(entry);
} else {
break;
#[cfg(feature = "smarm-causal")]
{
let debt = global.saturating_sub(entry.delay_stamp);
if debt > 0 {
let shifted = entry
.deadline
.checked_add(crate::causal::cycles_to_duration(debt))
.unwrap_or(entry.deadline);
if shifted > now {
// Not due in virtual time: re-queue at the shifted
// deadline, stamped, keeping `seq` (and thus `Send`
// cancellation identity) intact.
entry.deadline = shifted;
entry.delay_stamp = global;
self.heap.push(Reverse(entry));
continue;
}
}
}
if matches!(entry.reason, Reason::Send { .. }) {
self.armed.remove(&entry.seq);
}
out.push(entry);
}
out
}
}
+154
View File
@@ -73,6 +73,7 @@ fn site_guard_nesting_restores() {
/// catches up to the global one) instead of running delay-free.
#[test]
fn virtual_speedup_ledger() {
let _s = serial();
let global_out = Arc::new(AtomicU64::new(0));
let absorbed_out = Arc::new(AtomicU64::new(0));
let g_out = global_out.clone();
@@ -137,6 +138,7 @@ fn virtual_speedup_ledger() {
/// resume made all bystanders delay-immune, so experiments moved nothing.)
#[test]
fn runnable_bystander_pays_delay() {
let _s = serial();
let baseline = Arc::new(AtomicU64::new(0));
let during = Arc::new(AtomicU64::new(0));
let b_out = baseline.clone();
@@ -218,6 +220,7 @@ fn runnable_bystander_pays_delay() {
/// baseline Coz relies on, and it doubles as the overhead sanity check.
#[test]
fn zero_speedup_injects_nothing() {
let _s = serial();
smarm::init(smarm::Config::exact(2)).run(|| {
let stop = Arc::new(AtomicBool::new(false));
let stop2 = stop.clone();
@@ -299,6 +302,7 @@ fn impact_from_synthetic_results() {
/// section with experiment / throughput-point lines.
#[test]
fn controller_produces_report() {
let _s = serial();
smarm::init(smarm::Config::exact(2)).run(|| {
let stop = Arc::new(AtomicBool::new(false));
let stop2 = stop.clone();
@@ -341,6 +345,7 @@ fn controller_produces_report() {
/// (+83.5% where theory says +100%).
#[test]
fn site_boundaries_flush_tail() {
let _s = serial();
fn tsc() -> u64 {
// SAFETY: rdtsc has no preconditions on x86_64.
unsafe { core::arch::x86_64::_rdtsc() }
@@ -388,3 +393,152 @@ fn site_boundaries_flush_tail() {
"boundary flush attributed {injected} of {spent} spent cycles (want ~50%)"
);
}
// ---------------------------------------------------------------------------
// RFC 007 timer virtual time: injected delay dilates virtual time for
// everyone, so a pending timer's *effective* deadline is its raw deadline
// plus whatever delay was injected while it was armed. Without this, a
// sleep or receive-timeout fires "early" in virtual terms and timeout-driven
// behaviour speeds up relative to the dilated world.
//
// These tests (and every ledger-delta test above) share the process-global
// delay ledger, so they serialize on `serial()`.
// ---------------------------------------------------------------------------
fn serial() -> std::sync::MutexGuard<'static, ()> {
static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
M.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A pending timer must not fire at its raw deadline if delay was injected
/// after it was armed; it fires once wall time covers raw + injected.
#[test]
fn timer_deadline_shifts_with_injected_delay() {
let _s = serial();
use smarm::pid::Pid;
use smarm::timer::Timers;
use std::time::Instant;
let hz = smarm::causal::tsc_hz();
let mut t = Timers::new();
let now = Instant::now();
t.insert_sleep(now + Duration::from_millis(50), Pid::new(0, 0), 1);
// 100ms of debt lands while the timer is pending.
smarm::causal::inject_delay_cycles_for_test((hz * 0.100) as u64);
// Raw deadline passed, effective deadline not: nothing fires, entry kept.
assert!(t.pop_due(now + Duration::from_millis(60)).is_empty());
assert!(!t.is_empty(), "shifted entry must be re-queued, not dropped");
// Past raw + injected (with margin) it must fire. Chase in case a
// parallel test injected more debt meanwhile.
let mut fire_at = now + Duration::from_millis(170);
let mut fired = false;
for _ in 0..40 {
if !t.pop_due(fire_at).is_empty() {
fired = true;
break;
}
fire_at += Duration::from_millis(50);
}
assert!(fired, "timer never fired after covering injected delay");
}
/// With no debt accrued since insertion, behaviour is byte-identical to
/// before: due entries fire at their raw deadline.
#[test]
fn timer_zero_debt_pops_at_raw_deadline() {
let _s = serial();
use smarm::pid::Pid;
use smarm::timer::Timers;
use std::time::Instant;
let mut t = Timers::new();
let now = Instant::now();
t.insert_sleep(now - Duration::from_millis(1), Pid::new(0, 0), 1);
assert_eq!(t.pop_due(now).len(), 1);
assert!(t.is_empty());
}
/// Re-queueing a shifted entry must preserve its identity: a `send_after`
/// cancelled *after* being shifted past its raw deadline must still cancel
/// (return true) and must never deliver.
#[test]
fn send_after_cancel_survives_reheap() {
let _s = serial();
use smarm::pid::Pid;
use smarm::timer::Timers;
use std::sync::atomic::AtomicBool;
use std::time::Instant;
let hz = smarm::causal::tsc_hz();
let mut t = Timers::new();
let now = Instant::now();
let delivered = Arc::new(AtomicBool::new(false));
let d2 = delivered.clone();
let id = t.insert_send(
now + Duration::from_millis(20),
Pid::new(0, 0),
Box::new(move || d2.store(true, Ordering::Relaxed)),
);
smarm::causal::inject_delay_cycles_for_test((hz * 0.100) as u64);
// Raw deadline passed: shifted, not fired.
assert!(t.pop_due(now + Duration::from_millis(30)).is_empty());
// Cancellation still works on the re-queued entry.
assert!(t.cancel(id), "cancel lost track of a re-queued send_after");
// Even far past the effective deadline nothing is delivered.
for e in t.pop_due(now + Duration::from_secs(3600)) {
if let smarm::timer::Reason::Send { fire } = e.reason {
fire();
}
}
assert!(
!delivered.load(Ordering::Relaxed),
"cancelled send_after delivered after re-heap"
);
}
/// End-to-end through the runtime: an actor mid-`sleep` pays delay injected
/// while it is parked by sleeping *longer* — which is exactly what makes the
/// park-gated resume-credit fast-forward correct rather than forgiving.
#[test]
fn sleeping_actor_pays_injected_delay() {
let _s = serial();
use std::time::Instant;
let slept = Arc::new(AtomicU64::new(0));
let s_out = slept.clone();
smarm::init(smarm::Config::exact(1)).run(move || {
let hz = smarm::causal::tsc_hz();
let s2 = s_out.clone();
let sleeper = smarm::spawn(move || {
let t0 = Instant::now();
smarm::sleep(Duration::from_millis(50));
s2.store(t0.elapsed().as_millis() as u64, Ordering::Relaxed);
});
// Let the sleeper arm its timer, then inject 150ms of debt from a
// plain OS thread — no slot, so nothing spin-absorbs it here and the
// only path by which it can slow the sleeper is the timer shift.
smarm::sleep(Duration::from_millis(10));
std::thread::spawn(move || {
smarm::causal::inject_delay_cycles_for_test((hz * 0.150) as u64);
})
.join()
.unwrap();
sleeper.join().unwrap();
});
let ms = slept.load(Ordering::Relaxed);
// Raw sleep 50ms + ~150ms debt injected mid-sleep => ~200ms effective.
// Loose lower bound; the pre-fix behaviour is ~50ms.
assert!(
ms >= 150,
"sleeper paid nothing: slept {ms}ms, expected >= 150ms (raw 50 + injected 150)"
);
}