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).
290 lines
12 KiB
Rust
290 lines
12 KiB
Rust
//! RFC 016 — runtime introspection (Chunk 1: the read primitive).
|
|
//!
|
|
//! A synchronous, internal read of the slab that returns *owned* data. This is
|
|
//! the mechanism the whole RFC hangs off: tests, the future observer
|
|
//! gen_server (Chunk 4), and a later control plane (RFC 003) are all consumers
|
|
//! of [`snapshot`] / [`actor_info`], never of the runtime internals directly.
|
|
//!
|
|
//! ## Consistency (DECISION D2 — per-slot tearing, `ps` semantics)
|
|
//!
|
|
//! [`snapshot`] is point-in-time and mildly racy *across* actors: each slot's
|
|
//! scheduling state is a lock-free word load, so an actor reported `Running`
|
|
//! may already be `Parked`, and an actor can die mid-scan. This is the cheap,
|
|
//! useful model (a coherent stop-the-world cut is expensive and rarely wanted).
|
|
//! [`actor_info`] is coherent for the single actor it names.
|
|
//!
|
|
//! ## Locking
|
|
//!
|
|
//! The lock order is **Leaf → Channel, at most one of each** (`raw_mutex.rs`);
|
|
//! cold locks, the registry, and the free list are all Leaves, so we may never
|
|
//! hold two at once. The read is therefore phased: first a single registry-leaf
|
|
//! pass for names and mailbox depth (the per-channel length read is a Channel
|
|
//! lock taken under that Leaf — legal), released before the slab scan takes any
|
|
//! per-slot cold Leaf.
|
|
|
|
use crate::pid::Pid;
|
|
use crate::registry::MailboxInfo;
|
|
use crate::runtime::{Slot, ROOT_PID};
|
|
use crate::scheduler::with_runtime;
|
|
use crate::slot_state::{
|
|
word_gen, word_state, ST_DONE, ST_PARKED, ST_QUEUED, ST_RUNNING, ST_RUNNING_NOTIFIED,
|
|
};
|
|
use std::collections::HashMap;
|
|
|
|
/// Snapshot wire-format version (DECISION D1). [`RuntimeSnapshot`] is treated as
|
|
/// a stable type from day one: it becomes the observer protocol (Chunk 4) and
|
|
/// crosses a version boundary the moment a remote observer attaches (RFC 011),
|
|
/// so the version travels with the data from the start.
|
|
pub const SNAPSHOT_FORMAT_VERSION: u16 = 1;
|
|
|
|
/// Fine-grained scheduling state, mapped from the packed slot word with no new
|
|
/// storage. `RunningNotified` collapses into `Notified` — a wake landed while
|
|
/// the actor was on-CPU and it will re-queue when it yields.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ActorState {
|
|
Queued,
|
|
Running,
|
|
Notified,
|
|
Parked,
|
|
Done,
|
|
}
|
|
|
|
/// Classify a packed state word. `None` for a Vacant slot (skipped by the scan)
|
|
/// — the only state that is not an actor.
|
|
fn classify(w: u64) -> Option<ActorState> {
|
|
Some(match word_state(w) {
|
|
ST_QUEUED => ActorState::Queued,
|
|
ST_RUNNING => ActorState::Running,
|
|
ST_RUNNING_NOTIFIED => ActorState::Notified,
|
|
ST_PARKED => ActorState::Parked,
|
|
ST_DONE => ActorState::Done,
|
|
_ => return None, // ST_VACANT
|
|
})
|
|
}
|
|
|
|
/// Owned, point-in-time view of one actor — no borrows of runtime internals, so
|
|
/// it is safe to hand to any consumer.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ActorInfo {
|
|
pub pid: Pid,
|
|
/// Registered names, inverted from the registry (usually 0 or 1).
|
|
pub names: Vec<&'static str>,
|
|
pub state: ActorState,
|
|
/// Spawn-time parent edge (DECISION D9): `spawn_under` sets it to the
|
|
/// supervisor, plain `spawn` to the spawning actor — so it is parentage,
|
|
/// not necessarily a supervision relationship. `ROOT_PID` for the run's
|
|
/// root actor and for `Done` tombstones (whose `Actor` is already gone).
|
|
pub supervisor: Pid,
|
|
pub trap_exit: bool,
|
|
pub monitors: u32,
|
|
pub links: u32,
|
|
pub joiners: u32,
|
|
/// Queued messages summed over the actor's *published* channels (register /
|
|
/// install / spawn_addr / gen_server). 0 for an actor that holds only a
|
|
/// private `channel()` receiver — those are invisible to the registry.
|
|
pub mailbox_depth: u32,
|
|
/// Timeslice overruns tallied for this incarnation (RFC 016 Chunk 2): how
|
|
/// many times the actor was preempted for exceeding its slice. Resets on
|
|
/// restart (per-incarnation, D7).
|
|
pub overruns: u64,
|
|
/// Messages this actor has received (dequeued) this incarnation (RFC 016
|
|
/// 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.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RuntimeSnapshot {
|
|
pub format_version: u16,
|
|
pub actors: Vec<ActorInfo>,
|
|
}
|
|
|
|
/// Snapshot every live (and `Done`-but-not-yet-reclaimed) actor on the slab.
|
|
/// O(n) over the slot table, running with preemption disabled (like every
|
|
/// runtime primitive) but holding no lock across the scan. Panics outside
|
|
/// `Runtime::run()`; callable from actor code and the run thread.
|
|
pub fn snapshot() -> RuntimeSnapshot {
|
|
with_runtime(|inner| {
|
|
// Phase A: one registry-leaf pass for names + mailbox depth, released
|
|
// before any cold leaf (no two Leaves at once).
|
|
let mail = inner.registry.lock().introspect_map();
|
|
|
|
// Phase B: lock-free slab scan; per-slot cold leaf only to copy cold
|
|
// fields. Tearing across slots is intentional (D2).
|
|
let mut actors = Vec::new();
|
|
for (idx, slot) in inner.slots.iter().enumerate() {
|
|
let idx = idx as u32;
|
|
if let Some(info) = read_slot(slot, idx, mail.get(&idx)) {
|
|
actors.push(info);
|
|
}
|
|
}
|
|
RuntimeSnapshot { format_version: SNAPSHOT_FORMAT_VERSION, actors }
|
|
})
|
|
}
|
|
|
|
/// Coherent view of a single actor, or `None` if the pid is stale, out of
|
|
/// range, or names a Vacant slot.
|
|
pub fn actor_info(pid: Pid) -> Option<ActorInfo> {
|
|
with_runtime(|inner| {
|
|
let slot = inner.slot_at(pid)?;
|
|
let mail = inner.registry.lock().introspect_one(pid.index());
|
|
let info = read_slot(slot, pid.index(), mail.as_ref())?;
|
|
// read_slot keys on the slab's *current* generation; reject if that
|
|
// isn't the incarnation the caller asked about.
|
|
(info.pid.generation() == pid.generation()).then_some(info)
|
|
})
|
|
}
|
|
|
|
/// Build one `ActorInfo` for slot `idx`, or `None` if Vacant or
|
|
/// racing-reclaimed. State is classified from a lock-free word load (the torn
|
|
/// read); the cold lock then pins the generation (reclaim bumps it under that
|
|
/// same lock) so the cold fields are coherent for this incarnation. `mail` is
|
|
/// this slot's registry entry, if any.
|
|
fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorInfo> {
|
|
let w = slot.state_word();
|
|
let state = classify(w)?;
|
|
let gen = word_gen(w);
|
|
let pid = Pid::new(idx, gen);
|
|
|
|
let cold = slot.cold.lock();
|
|
// If the generation moved between the lock-free load and acquiring the cold
|
|
// lock, the slot was reclaimed (and maybe reused) — drop it rather than mix
|
|
// one incarnation's state with another's cold data. (ps semantics: a racing
|
|
// actor may simply be missed mid-scan.)
|
|
if word_gen(slot.state_word()) != gen {
|
|
return None;
|
|
}
|
|
let (supervisor, trap_exit) = match cold.actor.as_ref() {
|
|
// Live incarnation: parent + trap live on the Actor.
|
|
Some(actor) => (actor.supervisor, actor.trap.is_some()),
|
|
// Done tombstone: the Actor was taken at finalize and the collections
|
|
// cleared, so report it root-less with empty counts.
|
|
None => (ROOT_PID, false),
|
|
};
|
|
let monitors = cold.monitors.len() as u32;
|
|
let links = cold.links.len() as u32;
|
|
let joiners = cold.waiters.len() as u32;
|
|
drop(cold);
|
|
|
|
// Counters are hot-region atomics, read lock-free (RFC 016 Chunk 2).
|
|
let overruns = slot.overruns();
|
|
let messages_received = slot.messages_received();
|
|
let budget_cycles = slot.budget_cycles();
|
|
|
|
// Names + depth belong to this incarnation only if the registry mailbox's
|
|
// pid matches the slab generation; a stale registry entry (dead prior
|
|
// occupant, not yet pruned) contributes nothing.
|
|
let (names, mailbox_depth) = match mail {
|
|
Some(mi) if mi.pid.generation() == gen => (mi.names.clone(), mi.depth),
|
|
_ => (Vec::new(), 0),
|
|
};
|
|
|
|
Some(ActorInfo {
|
|
pid,
|
|
names,
|
|
state,
|
|
supervisor,
|
|
trap_exit,
|
|
monitors,
|
|
links,
|
|
joiners,
|
|
mailbox_depth,
|
|
overruns,
|
|
messages_received,
|
|
budget_cycles,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Chunk 3 — tree view (pure derivation over a Chunk-1 snapshot)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One node in the parentage forest. `children` are the actors whose recorded
|
|
/// parent edge points at this node's pid.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TreeNode {
|
|
pub info: ActorInfo,
|
|
/// The actor's recorded parent was absent from the snapshot (already
|
|
/// Done/Vacant, or itself a tombstone), so it was re-rooted under the forest
|
|
/// sentinel rather than dropped — the tree stays total (DECISION D8).
|
|
pub orphaned: bool,
|
|
pub children: Vec<TreeNode>,
|
|
}
|
|
|
|
/// The parentage forest. Roots are actors parented at `ROOT_PID` (genuine
|
|
/// roots) plus re-rooted orphans. The edge is *spawned-by / parent*, not
|
|
/// necessarily supervision (DECISION D9) — see [`ActorInfo::supervisor`].
|
|
#[derive(Debug, Clone)]
|
|
pub struct RuntimeTree {
|
|
pub format_version: u16,
|
|
pub roots: Vec<TreeNode>,
|
|
}
|
|
|
|
/// Take a live [`snapshot`] and fold it into the parentage forest.
|
|
pub fn tree() -> RuntimeTree {
|
|
tree_from(snapshot())
|
|
}
|
|
|
|
/// Fold an existing snapshot into a forest by grouping each actor under its
|
|
/// parent pid — a single O(n) pass, no new reads. Exposed separately so a
|
|
/// consumer that already holds a snapshot (or a synthetic one, in tests) can
|
|
/// derive the tree without a second scan.
|
|
pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree {
|
|
let RuntimeSnapshot { format_version, actors } = snap;
|
|
|
|
let mut index_of: HashMap<Pid, usize> = HashMap::with_capacity(actors.len());
|
|
for (i, a) in actors.iter().enumerate() {
|
|
index_of.insert(a.pid, i);
|
|
}
|
|
|
|
// Group children under their present parent; everything else is a root.
|
|
// Scan order is preserved within each parent's child list.
|
|
let mut children_of: HashMap<Pid, Vec<usize>> = HashMap::new();
|
|
let mut roots: Vec<usize> = Vec::new();
|
|
let mut orphaned = vec![false; actors.len()];
|
|
for (i, a) in actors.iter().enumerate() {
|
|
let parent = a.supervisor;
|
|
if parent != ROOT_PID && index_of.contains_key(&parent) {
|
|
children_of.entry(parent).or_default().push(i);
|
|
} else {
|
|
// Parent is the forest sentinel (genuine root) or absent from the
|
|
// snapshot (orphan, D8) — either way a root of the forest.
|
|
orphaned[i] = parent != ROOT_PID;
|
|
roots.push(i);
|
|
}
|
|
}
|
|
|
|
// `take()` each actor as it is placed, which also guards against a
|
|
// (constructionally impossible) parentage cycle re-entering a node.
|
|
let mut slots: Vec<Option<ActorInfo>> = actors.into_iter().map(Some).collect();
|
|
let root_nodes = roots
|
|
.into_iter()
|
|
.filter_map(|i| build_node(i, &children_of, &orphaned, &mut slots))
|
|
.collect();
|
|
RuntimeTree { format_version, roots: root_nodes }
|
|
}
|
|
|
|
fn build_node(
|
|
i: usize,
|
|
children_of: &HashMap<Pid, Vec<usize>>,
|
|
orphaned: &[bool],
|
|
slots: &mut [Option<ActorInfo>],
|
|
) -> Option<TreeNode> {
|
|
let info = slots[i].take()?; // already placed → cycle guard / no double-attach
|
|
let children = children_of
|
|
.get(&info.pid)
|
|
.map(|kids| {
|
|
kids.iter()
|
|
.filter_map(|&c| build_node(c, children_of, orphaned, slots))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
Some(TreeNode { info, orphaned: orphaned[i], children })
|
|
}
|