diff --git a/Cargo.toml b/Cargo.toml index 62f1d80..954185a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,11 @@ unexpected_cfgs = { level = "warn", check-cfg = ["cfg(loom)"] } [features] default = ["rq-mutex"] smarm-trace = [] +# RFC 016 Chunk 2: cycle-accurate per-actor time-budget accounting. Off by +# default — it costs two extra RDTSC reads per actor resume on the hot path +# (D6). The `ActorInfo.budget_cycles` field exists regardless; it just stays 0 +# unless this is enabled. +budget-accounting = [] # Run-queue selection: exactly one, compile-time (see src/run_queue.rs). # Non-default variants need --no-default-features (features are additive). rq-mutex = [] diff --git a/src/introspect.rs b/src/introspect.rs index 9e6b9a5..aea2fb3 100644 --- a/src/introspect.rs +++ b/src/introspect.rs @@ -91,6 +91,11 @@ pub struct ActorInfo { /// Chunk 2) — answers "is this actor a hotspot / draining slower than its /// mailbox fills." Counts received, not sent (D4). Per-incarnation (D7). pub messages_received: u64, + /// Approximate on-CPU cycles this incarnation has consumed (RFC 016 Chunk 2) + /// — a reductions-like work metric for relative comparison. Always 0 unless + /// the `budget-accounting` feature is enabled (it costs an RDTSC per resume, + /// D6). Per-incarnation (D7). + pub budget_cycles: u64, } /// A whole-runtime snapshot. See the module docs for the D2 tearing model. @@ -170,6 +175,7 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option) -> Option u64 { + rdtsc().saturating_sub(TIMESLICE_START.with(|c| c.get())) +} + #[inline(always)] pub fn rdtsc() -> u64 { unsafe { diff --git a/src/runtime.rs b/src/runtime.rs index 8a69979..b49f076 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -439,6 +439,12 @@ pub(crate) struct Slot { /// own thread, increments it on the receive path (D4/D5), Relaxed load+store /// with no RMW; snapshot reads Relaxed. messages_received: AtomicU64, + /// RFC 016 Chunk 2 — cumulative on-CPU cycles this incarnation has consumed + /// (the cycle-accurate "budget used", an analogue of OTP reductions). + /// Written only by the scheduler thread that ran the actor, once per resume, + /// and only when the `budget-accounting` feature is on (it costs two RDTSC + /// per resume); stays 0 otherwise. Same single-writer Relaxed discipline. + budget_cycles: AtomicU64, /// Cold lifecycle data. See [`SlotCold`]. pub(crate) cold: RawMutex, } @@ -452,6 +458,7 @@ impl Slot { closure: AtomicPtr::new(std::ptr::null_mut()), overruns: AtomicU64::new(0), messages_received: AtomicU64::new(0), + budget_cycles: AtomicU64::new(0), cold: RawMutex::new(SlotCold { actor: None, waiters: Vec::new(), @@ -511,6 +518,26 @@ impl Slot { self.messages_received.load(Ordering::Relaxed) } + /// Accumulate on-CPU cycles consumed in one resume (RFC 016 Chunk 2, + /// `budget-accounting`). Single-writer (the scheduler thread that ran the + /// actor), Relaxed load+store. Approximate by design: the figure is + /// `now − slice-start`, and a wake-slot resume inherits the waker's slice, + /// so a handed-off actor is charged a little of the chain's time — noise + /// that averages out across runs, traded for one RDTSC instead of two. + #[cfg(feature = "budget-accounting")] + #[inline] + pub(crate) fn add_budget(&self, cycles: u64) { + let v = self.budget_cycles.load(Ordering::Relaxed); + self.budget_cycles.store(v.wrapping_add(cycles), Ordering::Relaxed); + } + + /// Read the accumulated budget cycles (Relaxed). Always 0 unless the + /// `budget-accounting` feature is enabled. + #[inline] + pub(crate) fn budget_cycles(&self) -> u64 { + self.budget_cycles.load(Ordering::Relaxed) + } + /// Zero the per-actor introspection counters. Called at every point a slot /// is recycled or freshly occupied (`reclaim_slot`, `install_actor`) so a /// reused slot never carries a previous incarnation's counts — the standing @@ -519,6 +546,7 @@ impl Slot { pub(crate) fn reset_counters(&self) { self.overruns.store(0, Ordering::Relaxed); self.messages_received.store(0, Ordering::Relaxed); + self.budget_cycles.store(0, Ordering::Relaxed); } /// A pid's-eye snapshot of the slot. Cold paths re-read this under the @@ -1530,6 +1558,11 @@ fn schedule_loop(inner: &Arc, slot_idx: usize) { unsafe { switch_to_actor() }; PREEMPTION_ENABLED.with(|c| c.set(false)); + // RFC 016 Chunk 2: charge the cycles this resume consumed to the actor + // (approximate; reuses the slice-start timestamp — one RDTSC). Read + // before the next resume re-arms TIMESLICE_START. + #[cfg(feature = "budget-accounting")] + slot.add_budget(crate::preempt::elapsed_slice_cycles()); stats.current_pid_index.store(u32::MAX, Ordering::Relaxed); clear_current_pid(); crate::preempt::clear_current_stop(); diff --git a/tests/introspect.rs b/tests/introspect.rs index 1ec1978..960d029 100644 --- a/tests/introspect.rs +++ b/tests/introspect.rs @@ -236,6 +236,7 @@ fn tree_from_nests_children_and_reroots_orphans() { mailbox_depth: 0, overruns: 0, messages_received: 0, + budget_cycles: 0, }; let snap = RuntimeSnapshot { @@ -321,3 +322,33 @@ fn messages_received_counts_dequeues() { h.join().unwrap(); }); } + +#[cfg(feature = "budget-accounting")] +#[test] +fn budget_cycles_accumulate_when_enabled() { + run(|| { + let (ready_tx, ready_rx) = channel::<()>(); + let (gate_tx, gate_rx) = channel::<()>(); + let h = spawn(move || { + // A little work so the consumed slice is non-trivial, then park. + let mut acc = 0u64; + for i in 0..10_000u64 { + acc = acc.wrapping_add(i); + smarm::check!(); + } + std::hint::black_box(acc); + ready_tx.send(()).unwrap(); + gate_rx.recv().unwrap(); + }); + ready_rx.recv().unwrap(); + // Once the worker has run and yielded (here, parked), its slice is + // charged. + let info = spin_until(h.pid(), |a| a.state == ActorState::Parked); + assert!( + info.budget_cycles > 0, + "budget should accrue after the actor runs and yields" + ); + gate_tx.send(()).unwrap(); + h.join().unwrap(); + }); +}