RFC 016 Chunk 2c: approximate per-actor time-budget (reductions-like)

Accumulate on-CPU cycles per actor as ActorInfo.budget_cycles, behind
the off-by-default budget-accounting feature (D6) — a reductions-style
work metric for relative comparison across runs.

- Approximate by design (per Mark): charge now - slice-start once at the
  yield point, reusing the timestamp reset_timeslice already sets, so
  one RDTSC per resume not two. Wake-slot resumes inherit the slice and
  so slightly over-attribute the chain's time to the woken actor — noise
  that averages out; we trade exactness for half the hot-path cost.
- Field/ActorInfo member are unconditional (keeps the snapshot shape
  stable across the feature flag, D1); only the accumulation is gated,
  so default builds are byte-identical and pay nothing. Reads return 0
  when off. Single-writer Relaxed like the other counters; reset in
  reset_counters (D7).

Matrix: default + feature-on + rq-mpmc + rq-striped + release + trace all
green; loom unaffected (feature off under --cfg loom).
This commit is contained in:
smarm-agent
2026-06-19 08:47:58 +00:00
parent e93b3120ec
commit 48d47c45c9
5 changed files with 86 additions and 0 deletions
+5
View File
@@ -10,6 +10,11 @@ unexpected_cfgs = { level = "warn", check-cfg = ["cfg(loom)"] }
[features] [features]
default = ["rq-mutex"] default = ["rq-mutex"]
smarm-trace = [] 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). # Run-queue selection: exactly one, compile-time (see src/run_queue.rs).
# Non-default variants need --no-default-features (features are additive). # Non-default variants need --no-default-features (features are additive).
rq-mutex = [] rq-mutex = []
+7
View File
@@ -91,6 +91,11 @@ pub struct ActorInfo {
/// Chunk 2) — answers "is this actor a hotspot / draining slower than its /// Chunk 2) — answers "is this actor a hotspot / draining slower than its
/// mailbox fills." Counts received, not sent (D4). Per-incarnation (D7). /// mailbox fills." Counts received, not sent (D4). Per-incarnation (D7).
pub messages_received: u64, 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. /// 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<ActorI
// Counters are hot-region atomics, read lock-free (RFC 016 Chunk 2). // Counters are hot-region atomics, read lock-free (RFC 016 Chunk 2).
let overruns = slot.overruns(); let overruns = slot.overruns();
let messages_received = slot.messages_received(); let messages_received = slot.messages_received();
let budget_cycles = slot.budget_cycles();
// Names + depth belong to this incarnation only if the registry mailbox's // Names + depth belong to this incarnation only if the registry mailbox's
// pid matches the slab generation; a stale registry entry (dead prior // pid matches the slab generation; a stale registry entry (dead prior
@@ -191,6 +197,7 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
mailbox_depth, mailbox_depth,
overruns, overruns,
messages_received, messages_received,
budget_cycles,
}) })
} }
+10
View File
@@ -166,6 +166,16 @@ pub fn reset_timeslice() {
TIMESLICE_START.with(|c| c.set(rdtsc())); TIMESLICE_START.with(|c| c.set(rdtsc()));
} }
/// Cycles elapsed since the current slice started (RFC 016 Chunk 2,
/// `budget-accounting`). Read by the scheduler right after an actor yields back,
/// on the same thread that armed `TIMESLICE_START`. Approximate for wake-slot
/// resumes, which inherit the slice (see `Slot::add_budget`).
#[cfg(feature = "budget-accounting")]
#[inline]
pub(crate) fn elapsed_slice_cycles() -> u64 {
rdtsc().saturating_sub(TIMESLICE_START.with(|c| c.get()))
}
#[inline(always)] #[inline(always)]
pub fn rdtsc() -> u64 { pub fn rdtsc() -> u64 {
unsafe { unsafe {
+33
View File
@@ -439,6 +439,12 @@ pub(crate) struct Slot {
/// own thread, increments it on the receive path (D4/D5), Relaxed load+store /// own thread, increments it on the receive path (D4/D5), Relaxed load+store
/// with no RMW; snapshot reads Relaxed. /// with no RMW; snapshot reads Relaxed.
messages_received: AtomicU64, 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`]. /// Cold lifecycle data. See [`SlotCold`].
pub(crate) cold: RawMutex<SlotCold>, pub(crate) cold: RawMutex<SlotCold>,
} }
@@ -452,6 +458,7 @@ impl Slot {
closure: AtomicPtr::new(std::ptr::null_mut()), closure: AtomicPtr::new(std::ptr::null_mut()),
overruns: AtomicU64::new(0), overruns: AtomicU64::new(0),
messages_received: AtomicU64::new(0), messages_received: AtomicU64::new(0),
budget_cycles: AtomicU64::new(0),
cold: RawMutex::new(SlotCold { cold: RawMutex::new(SlotCold {
actor: None, actor: None,
waiters: Vec::new(), waiters: Vec::new(),
@@ -511,6 +518,26 @@ impl Slot {
self.messages_received.load(Ordering::Relaxed) 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 /// Zero the per-actor introspection counters. Called at every point a slot
/// is recycled or freshly occupied (`reclaim_slot`, `install_actor`) so a /// is recycled or freshly occupied (`reclaim_slot`, `install_actor`) so a
/// reused slot never carries a previous incarnation's counts — the standing /// reused slot never carries a previous incarnation's counts — the standing
@@ -519,6 +546,7 @@ impl Slot {
pub(crate) fn reset_counters(&self) { pub(crate) fn reset_counters(&self) {
self.overruns.store(0, Ordering::Relaxed); self.overruns.store(0, Ordering::Relaxed);
self.messages_received.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 /// A pid's-eye snapshot of the slot. Cold paths re-read this under the
@@ -1530,6 +1558,11 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
unsafe { switch_to_actor() }; unsafe { switch_to_actor() };
PREEMPTION_ENABLED.with(|c| c.set(false)); 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); stats.current_pid_index.store(u32::MAX, Ordering::Relaxed);
clear_current_pid(); clear_current_pid();
crate::preempt::clear_current_stop(); crate::preempt::clear_current_stop();
+31
View File
@@ -236,6 +236,7 @@ fn tree_from_nests_children_and_reroots_orphans() {
mailbox_depth: 0, mailbox_depth: 0,
overruns: 0, overruns: 0,
messages_received: 0, messages_received: 0,
budget_cycles: 0,
}; };
let snap = RuntimeSnapshot { let snap = RuntimeSnapshot {
@@ -321,3 +322,33 @@ fn messages_received_counts_dequeues() {
h.join().unwrap(); 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();
});
}