sandbox-meter: a live per-process CPU energy probe for in-process work, and a real reading for a direct child #121

Merged
dcharlot merged 2 commits from sandbox-meter/process-energy into main 2026-10-01 06:02:14 -04:00
16 changed files with 853 additions and 72 deletions

View file

@ -75,6 +75,10 @@ pub struct Gateway {
runtime: Runtime,
grants: BTreeMap<String, SignedGrant>,
caller: Did,
/// `PowerMeter::detect()`, machine-wide, and deliberately not
/// `detect_in_process()`: the metered work is the upstream MCP server, a
/// separate process, and this process's own energy counter does not cover
/// a child process — over a tool call it would read the gateway waiting.
meter: PowerMeter,
log: ReceiptLog,
now: u64,

View file

@ -58,6 +58,12 @@ use std::time::{Duration, Instant};
/// for every `cargo` invocation; git and rustfmt are negligible overhead and
/// run unmetered.
struct MeterAcc {
/// `PowerMeter::detect()`, machine-wide, and deliberately not
/// `detect_in_process()`: the metered work is `cargo` and the `rustc`
/// processes it runs, and this process's own energy counter covers no
/// child process. `run_attributed`'s per-child reading does not reach
/// them either: it covers a single-process child, and `cargo` runs its
/// work in descendants.
meter: PowerMeter,
/// This run's metered windows, reset at the start of each run.
tally: EnergyTally,
@ -1497,6 +1503,8 @@ fn answers() { assert_eq!(answer(), 42); }
share: if attributed_uj > 0 { 0.25 } else { 0.0 },
wall: Duration::from_secs(1),
attributed,
// cargo runs its work in descendants: never a per-child reading.
child_energy_uj: None,
}
}

View file

@ -79,10 +79,12 @@ fixture meter, §8, is the one exception: it declares `HwShunt` only so the
vectors exercise the reading path of the signed core, and like the pack's
published steward key it attests nothing.) The sibling
**Sandbox** standard's `PowerMeter` is the reference meter: it reads vendor
energy counters (RAPL, NVML, Apple IOReport) while they are live, apportions a
reading to a process tree by CPU time (`ModelBased`), and falls back to a
wall-clock estimate, labelled `Estimator`, where no counter is live. joule-ui
defines what an interaction records; the host wires the meter it trusts.
energy counters (RAPL, NVML, Apple IOReport, and for work on its own process
the per-process CPU energy the OS attributes to it) while they are live,
apportions a reading to a process tree by CPU time (`ModelBased`), and falls
back to a wall-clock estimate, labelled `Estimator`, where no counter is
live. joule-ui defines what an interaction records; the host wires the meter
it trusts.
**Vendor energy counters.** A reading from a vendor energy counter (RAPL,
NVML, Apple IOReport, ...) MAY be graded as a hardware reading only while the

View file

@ -18,8 +18,9 @@ ed25519-dalek = { workspace = true }
[dev-dependencies]
joule-ui-core = { path = "../joule-ui-core" }
# The Sandbox standard's meter — the reference Meter edge (SPEC §5): a hardware
# reading where the host exposes a live energy counter (RAPL, NVML, or Apple
# IOReport while its CPU rail is live), its labelled estimate otherwise. A
# reading where the host exposes a live energy counter (RAPL, NVML, Apple
# IOReport while its CPU rail is live, or for the examples' in-process work this
# process's own CPU energy counter), its labelled estimate otherwise. A
# dev-dependency keeps the core decoupled while proving the edge composes.
sandbox-meter = { path = "../../../../sandbox/sandbox-rs/crates/sandbox-meter" }
# Device classes for the scripted spans `tests/span_meter.rs` replays through

View file

@ -7,11 +7,15 @@
//!
//! Each interaction is metered as one `PowerMeter` span and sealed with that
//! span's own figure and provenance (the `SpanMeter` in `support/`, the
//! adapter `joule-ui-serve` uses), and the output says which it is. With a
//! live hardware counter whose CPU rail advanced, it is `HwShunt`: the
//! counters' machine-wide delta over the interaction. With none (every Apple
//! Silicon host checked on macOS 27.0.1) it is the meter's `Estimator` figure,
//! wall-clock × TDP for the interaction's span — never 0 µJ for work that ran.
//! adapter `joule-ui-serve` uses), and the output says which it is. The
//! handlers run on this process's own thread and spawn nothing, so the meter
//! is `PowerMeter::detect_in_process()`: where this process's own CPU energy
//! counter is live (every Apple Silicon host checked, macOS 27.0.1) each
//! figure is `HwShunt`, that counter's delta over the interaction. Where it
//! is not, the meter is `detect()`'s: a live machine-wide counter's delta, or
//! the `Estimator` figure, wall-clock × TDP for the interaction's span — never
//! 0 µJ for work that ran. Interactions fire until the runtime denies one, so
//! the budget binds whatever the figures are.
//!
//! The grant is debited with the figure AND its provenance: `Authorizer::charge`
//! is handed the interaction's sealed figure with its grade, and the authorizer
@ -92,8 +96,8 @@ fn figure(e: &Energy) -> String {
const BUDGET_UJ: u64 = 15_000_000;
fn main() {
// A real signed JCP grant for the `apply` action, with a joule budget that
// a couple of real interactions will exhaust.
// A real signed JCP grant for the `apply` action, with a joule budget the
// interactions below exhaust.
let issuer = ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]);
let did = jcp_schema::Did::from_verifying_key(&issuer.verifying_key());
let grant = jcp_schema::Grant {
@ -116,7 +120,8 @@ fn main() {
};
let sg = jcp_grant::issue(&issuer, grant.clone()).expect("issue grant");
let pm = PowerMeter::detect();
// The handlers below run on this thread and spawn nothing: in-process work.
let pm = PowerMeter::detect_in_process();
println!(
"edges: JCP grant (15 J budget, real signed) + {}",
if pm.is_hardware() {
@ -133,6 +138,7 @@ fn main() {
)
}
);
let meter_sources = pm.sources();
let meter = SpanMeter::new(pm);
let mut auth = JcpAuthorizer {
runtime: jcp_runtime::Runtime::new(),
@ -144,10 +150,13 @@ fn main() {
let surface = ContentId::of_bytes(b"<Form><Slider action='apply'/></Form>");
let mut log = InteractionLog::new(ed25519_dalek::SigningKey::from_bytes(&[8u8; 32]));
// Fire interactions doing ~150ms of real work each. Each one's figure is
// debited from the grant; if the figures spend it, the real JCP runtime
// denies the next one.
for n in 1..=6u64 {
// Fire interactions doing ~150ms of real work each, until the real JCP
// runtime denies one (at most MAX_FIRES). Each one's figure is debited from
// the grant, so once the figures spend it the next one is denied. The first
// five and the last three lines are printed.
const MAX_FIRES: u64 = 200;
let mut lines = Vec::new();
for n in 1..=MAX_FIRES {
let it = run_interaction(&surface, "apply", "did:key:z6MkDemoUser", &n.to_le_bytes(), &meter, Some(&mut auth), || {
let t = Instant::now();
let mut x = n;
@ -158,13 +167,24 @@ fn main() {
}
std::hint::black_box(x).to_le_bytes().to_vec()
});
let charged = if it.verdict.is_allow() { figure(&it.energy) } else { "not run".into() };
println!(
let denied = !it.verdict.is_allow();
let charged = if denied { "not run".into() } else { figure(&it.energy) };
lines.push(format!(
" apply #{n}: {} · {charged} · grant {} µJ left",
it.verdict.tag(),
it.budget_remaining_uj.map(|b| b.to_string()).unwrap_or_else(|| "—".into()),
);
));
log.record(it);
if denied {
break;
}
}
if lines.len() <= 8 {
lines.iter().for_each(|l| println!("{l}"));
} else {
lines[..5].iter().for_each(|l| println!("{l}"));
println!(" … {} more allowed", lines.len() - 8);
lines[lines.len() - 3..].iter().for_each(|l| println!("{l}"));
}
let verified = verify_chain(log.entries()).expect("chain verifies");
@ -191,7 +211,7 @@ fn main() {
let what = if unmetered > 0 {
format!("{unmetered} had no figure, 0 µJ for work that ran")
} else if allowed.iter().all(|e| e.provenance == Provenance::HwShunt) {
format!("{sealed} µJ sealed, machine-wide hardware counter deltas (HwShunt)")
format!("{sealed} µJ sealed, hardware counter deltas (HwShunt) from {}", meter_sources.join(", "))
} else if allowed.iter().all(|e| e.provenance == Provenance::Estimator) {
format!("{sealed} µJ sealed, estimated (wall-clock × TDP over each interaction's span), not measured")
} else {

View file

@ -6,13 +6,16 @@
//! What that record is depends on the host, and the output says which. Each
//! interaction is metered as one `PowerMeter` span and sealed with that
//! span's own figure and provenance (the `SpanMeter` in `support/`, the
//! adapter `joule-ui-serve` uses). Where the meter has a live hardware counter
//! (Intel/AMD RAPL; Apple Silicon IOReport only when its CPU rail is live) and
//! the span's CPU counter advanced, the figure is `HwShunt`: the counters'
//! machine-wide delta over the interaction. Otherwise (every Apple Silicon
//! host checked on macOS 27.0.1) it is the meter's `Estimator` figure,
//! wall-clock × TDP for the interaction's own span: an estimate, labelled as
//! one, and never 0 µJ for work that ran.
//! adapter `joule-ui-serve` uses). The handlers run on this process's own
//! thread and spawn nothing, so the meter is `PowerMeter::detect_in_process()`.
//! Where this process's own CPU energy counter is live (every Apple Silicon
//! host checked, macOS 27.0.1) and advanced over the span, the figure is
//! `HwShunt`: the energy the kernel attributes to this process's threads over
//! the interaction. Where it is not, the meter is `detect()`'s: a live
//! machine-wide counter (Intel/AMD RAPL; Apple Silicon IOReport only when its
//! CPU rail is live) gives `HwShunt`, the machine-wide delta; with none, it is
//! the meter's `Estimator` figure, wall-clock × TDP for the interaction's own
//! span: an estimate, labelled as one, and never 0 µJ for work that ran.
//!
//! ```bash
//! cargo run -p joule-ui-interaction --example real_meter --release
@ -37,8 +40,10 @@ fn figure(e: &Energy) -> String {
}
fn main() {
let pm = PowerMeter::detect();
// The handlers below run on this thread and spawn nothing: in-process work.
let pm = PowerMeter::detect_in_process();
let hardware = pm.is_hardware();
let hardware_in_process = pm.sources().contains(&"process-energy");
println!(
"meter: {}",
if hardware {
@ -89,7 +94,11 @@ fn main() {
records.len()
)
} else if records.iter().all(|e| e.provenance == Provenance::HwShunt) {
" — hardware counter deltas (HwShunt), machine-wide over each interaction".to_string()
if hardware_in_process {
" — hardware counter deltas (HwShunt), this process's CPU energy over each interaction".to_string()
} else {
" — hardware counter deltas (HwShunt), machine-wide over each interaction".to_string()
}
} else if records.iter().all(|e| e.provenance == Provenance::Estimator) {
" — estimated (wall-clock × TDP over each interaction's span), not measured".to_string()
} else {

View file

@ -19,9 +19,11 @@ ed25519-dalek = { workspace = true }
# attributed to interactions fired against an unauthenticated served surface.
bs58 = "0.5"
# The reference Meter edge (SPEC §5): the live demo meters each interaction as
# a Sandbox PowerMeter span. A receipt is HwShunt only when its span was a
# hardware reading of the CPU (a live RAPL or IOReport counter, with any live
# NVML GPU reading beside it), and Estimator otherwise, with the meter's
# a Sandbox PowerMeter span, from `PowerMeter::detect_in_process()` since every
# handler runs in this process. A receipt is HwShunt only when its span was a
# hardware reading of the CPU (this process's own CPU energy counter where it
# is live; else a live RAPL or IOReport counter, with any live NVML GPU reading
# beside it), and Estimator otherwise, with the meter's
# wall-clock estimate as its figure: a GPU reading alone does not cover work
# that ran on the CPU. The joules vary with the work either way, and no
# estimate is sealed as a measurement.

View file

@ -59,8 +59,9 @@ impl Spans for PowerMeter {
/// is that span's own, which `run_interaction` asks for right after its second
/// sample. A span is `HwShunt` only when it was a hardware reading of the CPU
/// the handler ran on. It is the meter's `Estimator` figure (wall-clock × TDP,
/// never 0 for work that ran) when the host exposes no live energy counter —
/// every macOS 27.0.1 host checked, whose CPU energy rail is frozen — when the
/// never 0 for work that ran) when the meter has no live energy counter (on
/// macOS 27.0.1 the machine-wide CPU rail is frozen; the server meters with
/// this process's own counter instead, see [`served_power_meter`]) — when the
/// span's CPU counter held while another class, a GPU still compositing,
/// moved, and when the span has no CPU reading at all: an NVML-only host reads
/// the GPU alone, and joule-ui seals one figure per interaction with no field
@ -111,11 +112,22 @@ impl<M: Spans> Meter for SpanMeter<M> {
}
}
/// The meter the server seals interactions with.
/// The meter the server seals interactions with, over [`served_power_meter`].
fn served_meter(meter: PowerMeter) -> Box<dyn Meter + Send> {
Box::new(SpanMeter::new(meter))
}
/// The power meter `main` serves with: [`PowerMeter::detect_in_process`].
/// Every action handler runs in this process, on the worker thread that holds
/// the host lock (`Host::handle` calls it inside `run_interaction`), and
/// spawns nothing, so this process's own CPU energy counter covers the work,
/// where it is live. The figure is this process's over the span — the
/// handler's thread and any worker that served a socket meanwhile — never a
/// subprocess's, since there is none.
fn served_power_meter() -> PowerMeter {
PowerMeter::detect_in_process()
}
/// The figure an interaction is sealed with, from its span. joule-ui seals
/// one figure per interaction, with no field to say that figure covers part
/// of the work, so a span with no hardware reading of the CPU is not a
@ -179,7 +191,7 @@ fn main() -> std::io::Result<()> {
let host = Host::new(
"joule-ui — metered surface",
output(1.0),
served_meter(PowerMeter::detect()),
served_meter(served_power_meter()),
Box::new(|_action, inputs| {
let intensity: f64 = serde_json::from_slice(inputs).unwrap_or(1.0);
process(intensity)
@ -440,22 +452,28 @@ mod tests {
assert_eq!((b.provenance, b.measured_uj, b.estimated_uj), (Provenance::HwShunt, Some(10_500), 10_500));
}
/// The served meter on this host, end to end: an interaction that ran is
/// never sealed at 0 J, and is `HwShunt` only from a hardware meter. Every
/// macOS 27.0.1 host checked has no hardware source, so there each
/// interaction is sealed as the estimator's figure for its work.
/// The served meter on this host, end to end, as `main` builds it (the
/// in-process meter): an interaction that ran is never sealed at 0 J, and
/// is `HwShunt` only from a hardware meter. Where this process's own CPU
/// energy counter is live (every Apple Silicon host checked, macOS
/// 27.0.1) each interaction's ~24 ms of handler work is a reading of it.
#[test]
fn the_served_meter_never_seals_zero_or_an_estimate_as_a_reading() {
let pm = PowerMeter::detect();
let pm = served_power_meter();
let hardware = pm.is_hardware();
let in_process = sandbox_meter::process_energy_status() == sandbox_meter::CounterStatus::Live;
assert_eq!(pm.sources().contains(&"process-energy"), in_process, "{:?}", pm.sources());
let mut h = host(served_meter(pm));
for _ in 0..2 {
fire(&mut h);
}
for sealed in h.entries() {
let e = sealed.interaction.energy;
eprintln!("served interaction: hardware={hardware} {e:?}");
eprintln!("served interaction: hardware={hardware} in_process={in_process} {e:?}");
assert!(e.uj() > 0, "~24 ms of work sealed at 0 J: {e:?}");
if in_process {
assert_eq!(e.provenance, Provenance::HwShunt, "handler work under a live in-process counter: {e:?}");
}
match e.provenance {
Provenance::HwShunt => {
assert!(hardware, "a reading from a meter with no hardware source: {e:?}");

View file

@ -6,7 +6,7 @@ Sandbox is the **isolated-execution substrate** of the Transaction Science open
Every other sandbox in the field isolates a **CPU process** — a container, a microVM, a Linux process pinned to a core. The accelerator, where present, is an opaque passthrough that nobody meters and nobody attests. But agentic and AI work does not run on a CPU. It runs across a **fabric** — CPU, GPU, NPU, TPU, photonic, quantum. So in this standard the unit of isolation is not a process; it is a **heterogeneous unit of work**, and the accelerator is a first-class citizen of the isolation boundary: a capability you grant, a resource you meter in joules, and a line item you attest.
A Sandbox run carries exactly four guarantees: **isolation** (the unit runs at the lowest tier of an eight-rung ladder, S0–S7, that satisfies its needs, and cannot reach past it), **capability** (every resource the unit touches — each device class included — was explicitly granted; ungranted access is denied), **metering** (the run emits a signed energy receipt with per-device-class joule attribution and an honest provenance tag on every reading — and the reference meter reads vendor energy counters: Apple Silicon's energy model via IOReport, Intel/AMD RAPL via powercap, NVIDIA's total-energy counter via NVML. A reading is tagged `HwShunt` only while its counter is demonstrably live: each vendor counter is used only if every rail of it that the meter sums into one figure keeps advancing while the meter's probe burns CPU — in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms — every IOReport CPU energy rail (the rail itself, one per die on a multi-die part), every RAPL package and every NVML GPU; and a span across which the CPU counter held is never a reading. A counter that is absent, or present but not live — frozen, or republished in lumps, as the IOReport CPU rails are on macOS 27 — yields no hardware-graded figure: the span degrades to the labeled `Estimator` (wall-clock × TDP), never a fabricated measurement, and `ioreport_status()`, `rapl_status()` and `nvml_status()` say why a host is estimated. Because a live counter reports the whole SoC, the meter can **attribute** it to a single process tree by CPU-time apportionment — tagged `ModelBased`, since the reading is a measurement but the split is a model), and **attestation** (a signed statement of *what image ran, at what tier, under what grants, on what hardware, for what cost*, with a deterministic-replay journal hash where one was produced).
A Sandbox run carries exactly four guarantees: **isolation** (the unit runs at the lowest tier of an eight-rung ladder, S0–S7, that satisfies its needs, and cannot reach past it), **capability** (every resource the unit touches — each device class included — was explicitly granted; ungranted access is denied), **metering** (the run emits a signed energy receipt with per-device-class joule attribution and an honest provenance tag on every reading — and the reference meter reads vendor energy counters: Apple Silicon's energy model via IOReport, Intel/AMD RAPL via powercap, NVIDIA's total-energy counter via NVML. A reading is tagged `HwShunt` only while its counter is demonstrably live: each vendor counter is used only if every rail of it that the meter sums into one figure keeps advancing while the meter's probe burns CPU — in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms — every IOReport CPU energy rail (the rail itself, one per die on a multi-die part), every RAPL package and every NVML GPU; and a span across which the CPU counter held is never a reading. A counter that is absent, or present but not live — frozen, or republished in lumps, as the IOReport CPU rails are on macOS 27 — yields no hardware-graded figure: the span degrades to the labeled `Estimator` (wall-clock × TDP), never a fabricated measurement, and `ioreport_status()`, `rapl_status()` and `nvml_status()` say why a host is estimated. For work on the meter's own process there is one more source, kept out of the default meter: on macOS, the CPU energy the kernel attributes to the process's own threads (`proc_pid_rusage`), read through `PowerMeter::detect_in_process()` under the same liveness rule — never for a subprocess, which it does not cover; a single-process child's own figure is read from the exited child instead. Because a live counter reports the whole SoC, the meter can **attribute** it to a single process tree by CPU-time apportionment — tagged `ModelBased`, since the reading is a measurement but the split is a model), and **attestation** (a signed statement of *what image ran, at what tier, under what grants, on what hardware, for what cost*, with a deterministic-replay journal hash where one was produced).
The substrate is intentionally **ownerless**: no protocol-level token, permissive licensing on spec text and reference code, and conformance vectors so any implementer can verify canonicity without a trust relationship with the authors. A conforming host runs a unit, enforces its grants, meters its energy, and seals its attestation **without contacting any single steward's servers as a condition of operation**. Transaction Science is one steward: it publishes the spec, ships a reference implementation, and operates commercial services around the substrate — not its proprietor.

View file

@ -16,8 +16,8 @@ pub mod probe;
pub use contention::{StorageActivity, StorageMeterError};
pub use probe::{
ioreport_status, nvml_status, rapl_status, run_attributed, Attribution, CounterStatus,
IoReportStatus, Measurement, PowerMeter, CPU_RAIL_LIVENESS_BUDGET,
ioreport_status, nvml_status, process_energy_status, rapl_status, run_attributed, Attribution,
CounterStatus, IoReportStatus, Measurement, PowerMeter, CPU_RAIL_LIVENESS_BUDGET,
};
/// The receipt's JCR-1 profile tag (SPEC §6.4).

View file

@ -27,6 +27,19 @@
//! attributed runner falls back to running the command unattributed and
//! reporting `None` — a caller then keeps the machine-wide figure, honestly
//! labelled.
//!
//! **The child's own energy.** On macOS the kernel also accounts CPU energy
//! per process (`proc_pid_rusage`'s `ri_energy_nj`, the counter behind
//! [`PowerMeter::detect_in_process`](crate::probe::PowerMeter::detect_in_process)),
//! and a child's figure can be read after it exits and before it is reaped:
//! the runner waits for the exit without reaping (`waitid(P_PID, pid, WEXITED
//! | WNOWAIT)`), reads the exited child, then reaps it with `wait4` as before.
//! That figure, [`Attribution::child_energy_uj`], is a per-process reading,
//! not an apportioned model — but of the DIRECT child's own threads only. The
//! kernel folds a reaped descendant's CPU time into its parent and its energy
//! into nothing, so a child that ran its work in descendants (`cargo` running
//! `rustc`, `sh -c 'a; b'`) has no figure here, and descendants a child did
//! not reap are never covered. It is given only for a single-process child.
use std::time::Duration;
@ -54,12 +67,25 @@ pub struct Attribution {
/// window's CPU figure is not such a reading (an estimate, or zero while
/// the machine was busy).
pub attributed: bool,
/// The direct child's own CPU energy, microjoules: what the kernel
/// attributes to the child's threads (`ri_energy_nj`), read from the
/// exited child before it was reaped. A per-process `HwShunt`-class
/// reading of the `Cpu` class, given only when (a) this host's per-process
/// energy counter is live
/// ([`process_energy_status`](crate::probe::process_energy_status)), (b)
/// the child reaped no descendant that used CPU time — its figure does not
/// cover descendants, so for `cargo` (which runs `rustc`) or a shell that
/// forks its commands this is `None` — and (c) it advanced. A descendant
/// the child did not reap is never covered and leaves no trace. Always
/// `None` off macOS.
pub child_energy_uj: Option<u64>,
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
mod imp {
use super::*;
use crate::probe::PowerMeter;
use crate::probe::process::{child_reading, usage_of};
use crate::probe::{process_energy_status, PowerMeter};
use crate::{DeviceEnergy, Provenance};
use sandbox_schema::DeviceClass;
use std::ffi::CString;
@ -131,6 +157,11 @@ mod imp {
let cwd_c = CString::new(cwd.to_string_lossy().as_bytes()).unwrap();
// The per-process counter's status gates the child's own reading. Its
// probe burns CPU on this thread the first time, so it runs here,
// before the window opens, never inside it.
let process_counter = process_energy_status();
let t0 = Instant::now();
let before_e = meter.start();
let before_ticks = cpu_ticks();
@ -177,6 +208,9 @@ mod imp {
}
unsafe { libc::close(read_fd) };
// The child's own energy, from the exited child before it is reaped.
let child_energy_uj = exited_child_energy_uj(pid, process_counter);
// wait4 to reap and capture rusage (the tree's CPU time).
let mut status: libc::c_int = 0;
let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
@ -232,10 +266,31 @@ mod imp {
// `attributed_ok` already requires the machine to have been
// busy, so a zero reading here is a counter that held.
attributed: attributed_ok && cpu_is_a_reading && soc_cpu_uj > 0,
child_energy_uj,
},
))
}
/// Wait for child `pid` to exit WITHOUT reaping it (`waitid` with
/// `WNOWAIT`), so it stays readable, then read its own energy and apply
/// [`child_reading`]. `None` if the wait or the read fails. The caller
/// reaps the child afterwards, as it would have.
fn exited_child_energy_uj(pid: libc::pid_t, process_counter: crate::CounterStatus) -> Option<u64> {
loop {
// SAFETY: an all-zero `siginfo_t` is a valid out-parameter.
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
// SAFETY: `info` is writable for the call; `pid` is our child.
let rc = unsafe { libc::waitid(libc::P_PID, pid as libc::id_t, &mut info, libc::WEXITED | libc::WNOWAIT) };
if rc == 0 {
break;
}
if std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) {
return None;
}
}
child_reading(process_counter, &usage_of(pid)?)
}
fn tv_secs(tv: libc::timeval) -> f64 {
tv.tv_sec as f64 + tv.tv_usec as f64 / 1e6
}
@ -291,6 +346,7 @@ mod imp {
share: 1.0,
wall: m.wall,
attributed: false,
child_energy_uj: None,
},
))
}
@ -415,6 +471,55 @@ mod tests {
assert!(!a.attributed, "the unattributed fallback never attributes");
}
/// A single-process child's own energy is read from the exited child
/// before it is reaped: `sh -c 'awk …'` execs awk, so the child IS the
/// work. Where the per-process counter is live the figure is there and
/// plausible for 2–3 s of one core; where it is not, there is none.
#[test]
fn a_single_process_childs_own_energy_is_read_before_it_is_reaped() {
let mut meter = PowerMeter::detect();
let a = run_busy_child(&mut meter);
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
use crate::probe::{process_energy_status, CounterStatus};
let status = process_energy_status();
eprintln!(
"child energy: status={status:?} child_energy_uj={:?} proc_cpu_secs={:.3} wall={:?}",
a.child_energy_uj, a.proc_cpu_secs, a.wall
);
assert_cpu_time_registered(&a);
if status == CounterStatus::Live {
let uj = a.child_energy_uj.expect("a single-process child under a live counter has its reading");
let watts = uj as f64 / 1e6 / a.proc_cpu_secs;
assert!((100_000..=100_000_000).contains(&uj), "{uj} µJ for {:.3} s of CPU", a.proc_cpu_secs);
assert!((0.1..=20.0).contains(&watts), "{watts:.2} W per CPU-second: {a:?}");
} else {
assert_eq!(a.child_energy_uj, None, "a reading from a counter that is not live ({status:?})");
}
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
assert_eq!(a.child_energy_uj, None, "the unattributed fallback reads no child energy");
}
/// A child whose work ran in a descendant it reaped has no reading: the
/// kernel does not fold the descendant's energy into it. Here `sh` forks
/// a busy awk and waits for it (`; true` keeps sh from exec'ing it), so
/// sh's own figure — a few millijoules — would pass off a shell's start-up
/// as the energy of the work. The work happened: a fixed amount of it
/// (about a third of a second of one core, however loaded the machine),
/// whose CPU time is in the child's `wait4` rusage.
#[test]
fn a_child_whose_work_ran_in_a_descendant_has_no_reading() {
let mut meter = PowerMeter::detect();
let forked = "awk 'BEGIN { for (i = 0; i < 12000000; i++) x += i; print x }'; true";
let (ok, _out, a) =
run_attributed(&mut meter, "sh", &["-c", forked], std::path::Path::new(".")).expect("spawn");
assert!(ok, "the child exits cleanly");
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
assert!(a.proc_cpu_secs > 0.1, "setup: the descendant's work is in the tree's CPU time: {a:?}");
assert_eq!(a.child_energy_uj, None, "sh's own figure stood in for its descendant's work: {a:?}");
}
#[test]
fn attribution_is_a_proper_fraction_of_the_machine() {
// A near-idle child should claim a small share; the point is that the

View file

@ -62,17 +62,19 @@
//! batch-refreshed rail live.
//!
//! **The same rule gates every other vendor counter** the meter reads: Linux
//! RAPL's package domains and NVML's GPUs (see [`counter_run`]). A counter
//! the probe cannot see keep advancing is not a reading of the work, whatever
//! its vendor. The meter sums each counter's rails into one figure per class,
//! as it sums IOReport's CPU rails (every package into `Cpu`, every GPU into
//! `Gpu`), so each is held to the same every-rail rule. The sampling
//! RAPL's package domains and NVML's GPUs, and on macOS this process's own
//! CPU energy (see [`counter_run`]). A counter the probe cannot see keep
//! advancing is not a reading of the work, whatever its vendor. The meter
//! sums each counter's rails into one figure per class, as it sums
//! IOReport's CPU rails (every package into `Cpu`, every GPU into `Gpu`), so
//! each is held to the same every-rail rule. The sampling
//! interval is the probe's choice and is not part of the rule: NVML samples
//! at a longer tick than IOReport and RAPL ([`NVML_PROBE_TICK`]), because a
//! GPU energy counter is published at a coarser cadence than a CPU one.
// Off Apple Silicon macOS the IOReport-only items are unused (the real sampler
// lives in `ioreport`); on Linux the counter items are used by `rapl`/`nvml`.
// lives in `ioreport`); the counter items are used by `rapl`/`nvml` on Linux
// and by `process` on macOS.
#![cfg_attr(not(all(target_os = "macos", target_arch = "aarch64")), allow(dead_code))]
use super::{CounterStatus, IoReportStatus, ProbeError};
@ -125,7 +127,7 @@ pub(super) const PROBE_TICK: Duration = Duration::from_millis(25);
/// published at up to that period holds an update. A counter published more
/// slowly fails safe: declined and estimated, never over-claimed. Five
/// intervals take 750 ms, inside the 1 s budget.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // used by `rapl` and `nvml`
#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // used by `nvml`
pub(super) const NVML_PROBE_TICK: Duration = Duration::from_millis(150);
/// The most wall-clock time one tick may take. A tick ends when this thread
@ -416,7 +418,8 @@ pub(super) fn admits(status: IoReportStatus) -> bool {
// ---- Every other vendor counter: RAPL, NVML.
/// One liveness run over a vendor energy counter whose rails the meter sums
/// into one figure (Linux RAPL's package domains; NVML's GPUs), generic over
/// into one figure (Linux RAPL's package domains; NVML's GPUs; on macOS this
/// process's own CPU energy, one rail), generic over
/// everything it touches so the run is tested with scripted parts: `open`
/// opens the counter and hands back its sampler (`None`: it did not open),
/// which reads every rail's name and raw cumulative integer from one
@ -430,7 +433,6 @@ pub(super) fn admits(status: IoReportStatus) -> bool {
/// observed, over at least 300 ms, every interval read against the sample
/// before it. A counter that does not open is never sampled or ticked, and
/// gives no verdict.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // used by `rapl` and `nvml`
pub(super) fn counter_run<R>(
open: impl FnOnce() -> Option<R>,
tick: impl FnMut(),
@ -449,7 +451,6 @@ where
/// The status a counter run's verdict gives the counter. No verdict (it did
/// not open, or was never read twice) and no rail at all are `Unavailable`;
/// only every rail kept advancing is `Live`.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // used by `rapl` and `nvml`
pub(super) fn counter_status_of(verdict: Option<CpuRail>) -> CounterStatus {
match verdict {
Some(CpuRail::Live) => CounterStatus::Live,
@ -461,7 +462,6 @@ pub(super) fn counter_status_of(verdict: Option<CpuRail>) -> CounterStatus {
/// The one gate between a counter's status and its probe's use: only a
/// counter that proved live admits it. Every constructor asks this, with the
/// counter's cached status and nothing else.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // used by `rapl` and `nvml`
pub(super) fn counter_admits(status: CounterStatus) -> bool {
status == CounterStatus::Live
}
@ -470,7 +470,6 @@ pub(super) fn counter_admits(status: CounterStatus) -> bool {
/// [`counter_admits`] the `status`; otherwise `Unavailable`, naming the
/// counter (`what`) and why, and the counter is never touched. Every counter
/// probe's `given` is this and nothing else.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // used by `rapl` and `nvml`
pub(super) fn gated<P>(
what: &str,
status: CounterStatus,
@ -487,7 +486,6 @@ pub(super) fn gated<P>(
/// against. Zero if the clock cannot be read, which ends a tick at its wall
/// cap rather than never.
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // used by `rapl` and `nvml`
pub(super) fn thread_cpu() -> Duration {
let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
// SAFETY: `ts` is a valid, writable timespec for the call's duration.
@ -501,7 +499,6 @@ pub(super) fn thread_cpu() -> Duration {
/// A counter probe's real tick: [`spin_for`] `tick` of this thread's CPU
/// time, capped on a monotonic clock.
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))] // used by `rapl` and `nvml`
pub(super) fn counter_tick(tick: Duration) {
let t = std::time::Instant::now();
spin_for(tick, thread_cpu, || t.elapsed());

View file

@ -23,6 +23,15 @@
//! - **Anywhere else (or unreadable counters):** wall-clock × configured TDP —
//! `Estimator`, with `measured_uj` absent, exactly as SPEC §6.3 demands.
//!
//! Those are machine-wide counters, and [`PowerMeter::detect`] reads only
//! them, so its figures cover a subprocess's work as well as this process's.
//! For work that runs on this process's OWN threads there is one more source,
//! which `detect` never includes: **this process's CPU energy** on macOS
//! (`proc_pid_rusage`'s `ri_energy_nj`, the energy the kernel attributes to
//! this process's threads; `HwShunt`, `Cpu`), only while it keeps advancing
//! ([`process_energy_status`]), through [`PowerMeter::detect_in_process`]. It
//! does not cover child processes, so it is never the meter for a subprocess.
//!
//! A probe can degrade but it can never lie: if the hardware path fails
//! mid-span, the reading comes back `Estimator`, not a zero dressed as a
//! measurement. The same holds when the CPU energy counter did not advance
@ -36,11 +45,15 @@ use std::time::{Duration, Instant};
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
mod ioreport;
// The vendor-counter liveness rule, apart from any FFI: used by `ioreport`,
// `rapl` and `nvml`, and through [`counter_liveness`] by a caller that reads a
// counter of its own; built and tested on every platform.
// `rapl`, `nvml` and `process`, and through [`counter_liveness`] by a caller
// that reads a counter of its own; built and tested on every platform.
mod liveness;
#[cfg(target_os = "linux")]
mod nvml;
// This process's own CPU energy: never in `detect()`, only in
// `detect_in_process()`.
#[cfg(target_os = "macos")]
mod process;
// Built everywhere: its zone listing, reads and liveness run take the
// powercap root as a path, so a caller's [`RaplPackages::live_in`] and the
// tests run them on fake powercap trees on any platform. Only Linux opens the
@ -156,7 +169,8 @@ pub fn ioreport_status() -> IoReportStatus {
/// Whether a vendor energy counter the meter sums into one figure per class —
/// Linux RAPL's package domains ([`rapl_status`]), NVML's GPUs
/// ([`nvml_status`]) — is used on this host, and if not, why.
/// ([`nvml_status`]), this process's own CPU energy on macOS
/// ([`process_energy_status`]) — is used on this host, and if not, why.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CounterStatus {
/// The counter could not be opened, or not read twice before the probe's
@ -281,6 +295,43 @@ pub fn counter_probe_runs() -> (usize, usize) {
}
}
/// This process's own CPU energy counter's status, probed once per process
/// and cached: whether [`PowerMeter::detect_in_process`] reads it.
///
/// On macOS the counter is `proc_pid_rusage(getpid(), RUSAGE_INFO_V6)`'s
/// `ri_energy_nj`, and it is `Live` only if it advanced in at least five
/// sample intervals, at least four in five of those observed, over at least
/// 300 ms, while the probing thread burned CPU (~25 ms of CPU an interval,
/// about 300 ms in all where it is live, never past five
/// [`CPU_RAIL_LIVENESS_BUDGET`]s). Always `Unavailable` off macOS: Linux has
/// no per-process energy counter.
pub fn process_energy_status() -> CounterStatus {
#[cfg(target_os = "macos")]
{
static STATUS: std::sync::OnceLock<CounterStatus> = std::sync::OnceLock::new();
*STATUS.get_or_init(process::host_status)
}
#[cfg(not(target_os = "macos"))]
{
CounterStatus::Unavailable
}
}
/// How many times this process has run the real process-energy liveness
/// probe: for the test that pins [`process_energy_status`]'s once-per-process
/// cache. Always 0 off macOS.
#[doc(hidden)]
pub fn process_energy_probe_runs() -> usize {
#[cfg(target_os = "macos")]
{
process::REAL_RUNS.load(std::sync::atomic::Ordering::SeqCst)
}
#[cfg(not(target_os = "macos"))]
{
0
}
}
/// How many times this process has run the real IOReport liveness probe:
/// for the test that pins [`ioreport_status`]'s once-per-process cache from
/// a process of its own. Always 0 off Apple Silicon macOS, which never
@ -363,6 +414,16 @@ pub enum ProbeError {
Read(String),
}
/// The estimator's power: `SANDBOX_METER_TDP_W` if it is a positive finite
/// number, else [`DEFAULT_TDP_WATTS`].
fn tdp_watts() -> f64 {
std::env::var("SANDBOX_METER_TDP_W")
.ok()
.and_then(|v| v.parse::<f64>().ok())
.filter(|w| w.is_finite() && *w > 0.0)
.unwrap_or(DEFAULT_TDP_WATTS)
}
/// The statuses the meter's probe selection is gated on: one per vendor
/// counter, each from its own cached probe in production.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -413,6 +474,8 @@ impl PowerMeter {
/// [`counter_admits`](liveness::counter_admits)) passes its status.
/// Separate so the gates are tested for every status without probing.
fn detect_given(statuses: Statuses) -> PowerMeter {
// Nothing is pushed on a platform with no machine-wide probe.
#[cfg_attr(not(any(all(target_os = "macos", target_arch = "aarch64"), target_os = "linux")), allow(unused_mut))]
let mut probes: Vec<Box<dyn Probe + Send>> = Vec::new();
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
@ -431,13 +494,50 @@ impl PowerMeter {
// A platform without a given probe has nothing to gate.
let _ = statuses;
let tdp_watts = std::env::var("SANDBOX_METER_TDP_W")
.ok()
.and_then(|v| v.parse::<f64>().ok())
.filter(|w| w.is_finite() && *w > 0.0)
.unwrap_or(DEFAULT_TDP_WATTS);
PowerMeter { probes, tdp_watts: tdp_watts() }
}
PowerMeter { probes, tdp_watts }
/// A meter for work that runs on **this process's own threads**, and only
/// for that: the handler of an in-process request, a closure, a library
/// call. Never for a subprocess.
///
/// Where this process's own CPU energy counter is live
/// ([`process_energy_status`]; macOS), the meter reads it and nothing
/// else: each span is one `Cpu` record, `HwShunt`, of the energy the
/// kernel attributes to this process's threads over the span — the thread
/// the work ran on, and any other thread of this process that ran during
/// it (so a span is this process's figure, not one thread's). It does NOT
/// cover child processes: a subprocess's energy is never attributed to its
/// parent, not even after it is reaped, so a span over a subprocess would
/// read this process's waiting, near zero, as the subprocess's work. Use
/// [`detect`](Self::detect) for subprocess work (its counters are
/// machine-wide), or [`run_attributed`] for a single-process child. Nor
/// does it cover the GPU or the Neural Engine. The machine-wide probes are
/// left out because a second CPU record would count the same work twice.
/// [`Span::stop`]'s rule holds: a span over which the counter did not
/// advance is estimated, never sealed as a measured zero.
///
/// Where the counter is not live, and off macOS, this is
/// [`detect`](Self::detect). The first call pays the counter's liveness
/// probe, about 300 ms of CPU on this thread where it is live; `detect`
/// never includes this probe.
pub fn detect_in_process() -> PowerMeter {
Self::detect_in_process_given(process_energy_status(), Self::detect)
}
/// [`detect_in_process`](Self::detect_in_process) for a given status of
/// this process's counter, which production takes from the cached
/// [`process_energy_status`] and nowhere else, falling back to
/// `otherwise` (production: [`detect`](Self::detect), not called when
/// the counter is used). Separate so the gate is tested for every status
/// without probing.
fn detect_in_process_given(process: CounterStatus, otherwise: impl FnOnce() -> PowerMeter) -> PowerMeter {
#[cfg(target_os = "macos")]
if let Ok(p) = process::ProcessEnergyProbe::given(process) {
return PowerMeter { probes: vec![Box::new(p)], tdp_watts: tdp_watts() };
}
let _ = process;
otherwise()
}
/// The names of the active hardware probes (empty ⇒ estimator-only).
@ -871,6 +971,46 @@ mod tests {
assert!(live.is_empty(), "no RAPL or NVML probe exists off Linux: {live:?}");
}
/// `detect` never reads this process's own counter, whatever the
/// machine-wide statuses: it meters subprocesses, which that counter does
/// not cover.
#[test]
fn detect_never_reads_this_process_counter() {
for ioreport in [IoReportStatus::Unavailable, IoReportStatus::CpuRailFrozen, IoReportStatus::Live] {
for counter in [CounterStatus::Unavailable, CounterStatus::Frozen, CounterStatus::Live] {
let s = Statuses { ioreport, rapl: counter, nvml: counter };
let sources = PowerMeter::detect_given(s).sources();
assert!(!sources.contains(&"process-energy"), "{s:?} selected {sources:?}");
}
}
}
/// `detect_in_process` reads this process's counter only when it is
/// live, and then that probe alone (a second CPU record would count the
/// same work twice); otherwise it is the fallback (`detect` in
/// production), which it does not call when the counter is used. Nothing
/// here probes: the status is given and the fallback is scripted.
#[test]
fn detect_in_process_reads_this_process_only_for_a_live_status() {
let called = std::cell::Cell::new(0);
let fallback = || {
called.set(called.get() + 1);
meter_of(vec![scripted("fallback", &[], &[])])
};
for status in [CounterStatus::Unavailable, CounterStatus::Frozen] {
assert_eq!(PowerMeter::detect_in_process_given(status, fallback).sources(), ["fallback"], "{status:?}");
}
assert_eq!(called.get(), 2);
let live = PowerMeter::detect_in_process_given(CounterStatus::Live, fallback).sources();
#[cfg(target_os = "macos")]
{
assert_eq!(live, ["process-energy"]);
assert_eq!(called.get(), 2, "the fallback ran although the counter was used");
}
#[cfg(not(target_os = "macos"))]
assert_eq!(live, ["fallback"], "no per-process counter off macOS");
}
/// The counter statuses agree with themselves, and off Linux there is no
/// RAPL or NVML to probe.
#[test]

View file

@ -0,0 +1,278 @@
//! This process's own CPU energy on macOS: `proc_pid_rusage(getpid(),
//! RUSAGE_INFO_V6)` and its `ri_energy_nj`, the cumulative CPU energy the
//! kernel attributes to this process's threads, in nanojoules. A public
//! libproc call that needs no privilege. `HwShunt`, `Cpu` class.
//!
//! **What it covers, and what it does not.** The figure is the energy of this
//! process's OWN threads: the thread a span's work ran on and any other
//! thread of this process that ran during the span. It does not cover child
//! processes — the kernel never folds a child's energy into its parent's, not
//! even after reaping it (`rusage_info` has a child CPU-time field and no
//! child energy field) — nor other processes, the GPU or the Neural Engine.
//! So the probe is never part of [`PowerMeter::detect`](super::PowerMeter::detect):
//! a meter that spans a subprocess with it would read this process's waiting,
//! near zero, as the energy of the subprocess's work. It is used only through
//! [`PowerMeter::detect_in_process`](super::PowerMeter::detect_in_process),
//! for work that runs on this process's own threads.
//!
//! **Used only when the counter is live** ([`process_energy_status`](super::process_energy_status)):
//! the same sustained-advance rule as every other vendor counter, probed once
//! per process — this process's counter advanced in at least five sample
//! intervals, at least four in five of those observed, over at least 300 ms,
//! while the probing thread burned CPU. The rule and the run live in
//! [`liveness`](super::liveness); this module only reads the integer.
//!
//! The same read, on a child that has exited but not yet been reaped, gives
//! that child's own energy: [`child_reading`] is the rule
//! [`run_attributed`](super::run_attributed) applies to it.
use super::liveness::{counter_admits, counter_run, counter_status_of, counter_tick, gated, RailWatch, Sample, PROBE_TICK};
use super::{CounterStatus, Probe, ProbeError, CPU_RAIL_LIVENESS_BUDGET};
use sandbox_schema::DeviceClass;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
/// `RUSAGE_INFO_V6` in macOS `<sys/resource.h>`.
const RUSAGE_INFO_V6: libc::c_int = 6;
/// `struct rusage_info_v6` from the macOS 27 SDK's `<sys/resource.h>`, field
/// for field. Apple appends to this struct in new versions and never
/// reorders a published one; the layout is pinned by a test (464 bytes,
/// `ri_energy_nj` at byte 336, as the SDK's own `offsetof` gives them).
#[repr(C)]
#[derive(Clone, Copy)]
struct RusageInfoV6 {
ri_uuid: [u8; 16],
ri_user_time: u64,
ri_system_time: u64,
ri_pkg_idle_wkups: u64,
ri_interrupt_wkups: u64,
ri_pageins: u64,
ri_wired_size: u64,
ri_resident_size: u64,
ri_phys_footprint: u64,
ri_proc_start_abstime: u64,
ri_proc_exit_abstime: u64,
ri_child_user_time: u64,
ri_child_system_time: u64,
ri_child_pkg_idle_wkups: u64,
ri_child_interrupt_wkups: u64,
ri_child_pageins: u64,
ri_child_elapsed_abstime: u64,
ri_diskio_bytesread: u64,
ri_diskio_byteswritten: u64,
ri_cpu_time_qos_default: u64,
ri_cpu_time_qos_maintenance: u64,
ri_cpu_time_qos_background: u64,
ri_cpu_time_qos_utility: u64,
ri_cpu_time_qos_legacy: u64,
ri_cpu_time_qos_user_initiated: u64,
ri_cpu_time_qos_user_interactive: u64,
ri_billed_system_time: u64,
ri_serviced_system_time: u64,
ri_logical_writes: u64,
ri_lifetime_max_phys_footprint: u64,
ri_instructions: u64,
ri_cycles: u64,
ri_billed_energy: u64,
ri_serviced_energy: u64,
ri_interval_max_phys_footprint: u64,
ri_runnable_time: u64,
ri_flags: u64,
ri_user_ptime: u64,
ri_system_ptime: u64,
ri_pinstructions: u64,
ri_pcycles: u64,
ri_energy_nj: u64,
ri_penergy_nj: u64,
ri_secure_time_in_system: u64,
ri_secure_ptime_in_system: u64,
ri_neural_footprint: u64,
ri_lifetime_max_neural_footprint: u64,
ri_interval_max_neural_footprint: u64,
ri_conclave_footprint: u64,
ri_page_wait_time_mach: u64,
ri_page_cache_hits: u64,
ri_reserved: [u64; 6],
}
/// What this module reads of one process's `rusage_info_v6`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Usage {
/// `ri_energy_nj`: the CPU energy the kernel attributes to the process's
/// own threads, cumulative, in nanojoules.
pub(super) energy_nj: u64,
/// `ri_user_time + ri_system_time`: the process's own CPU time (Mach
/// absolute-time units; only compared with zero here).
pub(super) own_cpu: u64,
/// `ri_child_user_time + ri_child_system_time`: the CPU time of the
/// descendants the process reaped (same units). Their energy is in no
/// field at all.
pub(super) reaped_descendants_cpu: u64,
}
/// One `proc_pid_rusage(pid, RUSAGE_INFO_V6)` read: of a live process, or of
/// an exited child that has not yet been reaped. `None` if the call fails
/// (no such process, or not one this process may read).
pub(super) fn usage_of(pid: libc::pid_t) -> Option<Usage> {
// SAFETY: an all-zero `RusageInfoV6` is a valid value of a struct of
// plain integers.
let mut info: RusageInfoV6 = unsafe { std::mem::zeroed() };
// SAFETY: `info` is a writable `rusage_info_v6`, the struct the V6 flavor
// fills, and outlives the call.
let rc = unsafe {
libc::proc_pid_rusage(pid, RUSAGE_INFO_V6, &mut info as *mut RusageInfoV6 as *mut libc::rusage_info_t)
};
(rc == 0).then(|| Usage {
energy_nj: info.ri_energy_nj,
own_cpu: info.ri_user_time.saturating_add(info.ri_system_time),
reaped_descendants_cpu: info.ri_child_user_time.saturating_add(info.ri_child_system_time),
})
}
/// How many times this process ran the real process-energy liveness probe:
/// bumped by [`host_status`] before anything else it does, read through
/// [`process_energy_probe_runs`](super::process_energy_probe_runs).
pub(super) static REAL_RUNS: AtomicUsize = AtomicUsize::new(0);
/// This process's energy-counter status from one real probe run: the value
/// [`process_energy_status`](super::process_energy_status) caches, and its
/// only source. Call that, not this.
pub(super) fn host_status() -> CounterStatus {
REAL_RUNS.fetch_add(1, Ordering::SeqCst);
counter_status_of(real_run().verdict())
}
/// The real probe run: [`counter_run`] over this process's own counter, a
/// tick that burns [`PROBE_TICK`] of this thread's CPU time (this thread is
/// one of the threads the counter covers) and a monotonic clock, for
/// [`CPU_RAIL_LIVENESS_BUDGET`].
fn real_run() -> RailWatch {
let epoch = Instant::now();
counter_run(
|| ProcessEnergyProbe::open().ok().map(|p| move || p.raw_sample()),
|| counter_tick(PROBE_TICK),
move || epoch.elapsed(),
CPU_RAIL_LIVENESS_BUDGET,
)
}
/// This process's own CPU energy counter, as a meter probe.
pub struct ProcessEnergyProbe {
/// This process, read once at construction.
pid: libc::pid_t,
}
impl ProcessEnergyProbe {
/// The probe [`PowerMeter::detect_in_process`](super::PowerMeter::detect_in_process)
/// uses, for this process's counter `status`, which production passes
/// from the cached [`process_energy_status`](super::process_energy_status)
/// and nowhere else: only when the counter is live ([`gated`]).
pub(super) fn given(status: CounterStatus) -> Result<ProcessEnergyProbe, ProbeError> {
gated("process energy", status, Self::open)
}
/// This process's counter, with no liveness gate: one read must succeed.
/// Used by the liveness probe itself and by [`given`](Self::given) once
/// the gate passed.
fn open() -> Result<ProcessEnergyProbe, ProbeError> {
// SAFETY: `getpid` has no preconditions.
let pid = unsafe { libc::getpid() };
usage_of(pid)
.map(|_| ProcessEnergyProbe { pid })
.ok_or_else(|| ProbeError::Unavailable("proc_pid_rusage(RUSAGE_INFO_V6) failed for this process".into()))
}
/// One absolute sample for the liveness rule: the raw nanojoule integer.
fn raw_sample(&self) -> Option<Sample> {
usage_of(self.pid).map(|u| vec![("ri_energy_nj".to_string(), i64::try_from(u.energy_nj).unwrap_or(i64::MAX))])
}
}
impl Probe for ProcessEnergyProbe {
fn name(&self) -> &'static str {
"process-energy"
}
fn snapshot(&mut self) -> Result<Vec<(DeviceClass, u64)>, ProbeError> {
let u = usage_of(self.pid).ok_or_else(|| ProbeError::Read("proc_pid_rusage failed".into()))?;
Ok(vec![(DeviceClass::Cpu, u.energy_nj / 1_000)])
}
}
/// The rule for a child's own energy, read from the child after it exited
/// and before it was reaped: a reading in microjoules, or `None`.
///
/// - Only while this host's per-process energy counter is live: the same
/// kernel accounting, shown to advance under load in this process.
/// - Only for a single-process child: if it reaped descendants that used CPU
/// time, its own figure does not cover them, because the kernel folds a
/// reaped descendant's CPU time into its parent and its energy into
/// nothing (`cargo` running `rustc`, `sh -c 'a; b'`). Descendants it did not
/// reap (orphaned, or still running) leave no trace here and are never
/// covered.
/// - Only if it advanced: a child that ran and reads zero energy is no
/// reading of its work.
#[cfg_attr(not(target_arch = "aarch64"), allow(dead_code))] // the native runner is Apple Silicon's
pub(super) fn child_reading(status: CounterStatus, child: &Usage) -> Option<u64> {
if !counter_admits(status) || child.reaped_descendants_cpu > 0 || child.energy_nj == 0 {
return None;
}
Some(child.energy_nj / 1_000)
}
#[cfg(test)]
mod tests {
use super::*;
/// The struct is the SDK's: the sizes and offsets the macOS 27 SDK's own
/// `sizeof`/`offsetof` give (measured with a C program against
/// `<sys/resource.h>`).
#[test]
fn the_rusage_struct_is_the_sdks_layout() {
assert_eq!(std::mem::size_of::<RusageInfoV6>(), 464);
assert_eq!(std::mem::offset_of!(RusageInfoV6, ri_energy_nj), 336);
assert_eq!(std::mem::offset_of!(RusageInfoV6, ri_penergy_nj), 344);
assert_eq!(std::mem::offset_of!(RusageInfoV6, ri_child_user_time), 96);
}
/// This process can read itself, and a pid that does not exist reads
/// nothing.
#[test]
fn this_process_reads_itself_and_a_missing_pid_reads_nothing() {
let me = usage_of(unsafe { libc::getpid() }).expect("this process reads itself");
assert!(me.own_cpu > 0, "{me:?}");
assert_eq!(usage_of(-7), None);
}
fn child(energy_nj: u64, reaped_descendants_cpu: u64) -> Usage {
Usage { energy_nj, own_cpu: 1_000_000, reaped_descendants_cpu }
}
/// A single-process child's own energy is a reading only while the
/// counter is live, only if it reaped no descendant that used CPU, and
/// only if it advanced.
#[test]
fn a_childs_own_energy_is_a_reading_only_for_a_single_process_child() {
use CounterStatus::{Frozen, Live, Unavailable};
// The observed shapes on this M5 Max: `sh -c 'awk …'` (sh execs awk,
// one process) read 1,647,495,220 nJ; `sh -c 'awk …; true'` (awk a
// reaped child of sh) read sh's own 5,475,117 nJ, beside 15,204,950
// ticks of reaped-descendant CPU time.
assert_eq!(child_reading(Live, &child(1_647_495_220, 0)), Some(1_647_495));
assert_eq!(child_reading(Live, &child(5_475_117, 15_204_950)), None, "awk's energy is not in sh's");
assert_eq!(child_reading(Live, &child(0, 0)), None, "ran, and read zero");
for status in [Frozen, Unavailable] {
assert_eq!(child_reading(status, &child(1_647_495_220, 0)), None, "{status:?}");
}
}
/// The gate declines a counter that is not live without reading it.
#[test]
fn only_a_live_status_builds_the_process_probe() {
for status in [CounterStatus::Unavailable, CounterStatus::Frozen] {
assert!(ProcessEnergyProbe::given(status).is_err(), "{status:?}");
}
assert!(ProcessEnergyProbe::given(CounterStatus::Live).is_ok());
}
}

View file

@ -0,0 +1,194 @@
//! The per-process CPU energy probe on real hardware. Its counter covers
//! every thread of THIS process, so these tests run in a process of their own
//! (this file) and one at a time (a lock): a burn in a test running beside
//! another would land in the other's span. Each asserts against an oracle of
//! its own — `proc_pid_rusage` read into a raw buffer at the SDK's offset,
//! sharing no code with the probe — and logs what it saw.
#![cfg(all(target_os = "macos", target_arch = "aarch64"))]
use sandbox_meter::probe::process_energy_probe_runs;
use sandbox_meter::{process_energy_status, CounterStatus, Measurement, PowerMeter, Provenance};
use sandbox_schema::DeviceClass;
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
/// One test at a time: the counter is process-wide.
static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
fn alone() -> MutexGuard<'static, ()> {
ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner())
}
/// The oracle: `ri_energy_nj` of `pid`, read with `proc_pid_rusage(pid,
/// RUSAGE_INFO_V6)` into a raw buffer and taken at byte 336, where the macOS
/// SDK's `<sys/resource.h>` puts it (a 16-byte uuid, then 40 `uint64_t`s).
fn oracle_energy_nj(pid: libc::pid_t) -> Option<u64> {
let mut buf = [0u64; 64]; // 512 bytes, more than the struct's 464
// SAFETY: `buf` is writable and larger than `rusage_info_v6`.
let rc = unsafe { libc::proc_pid_rusage(pid, 6, buf.as_mut_ptr() as *mut libc::rusage_info_t) };
(rc == 0).then_some(buf[336 / 8])
}
fn me() -> libc::pid_t {
// SAFETY: no preconditions.
unsafe { libc::getpid() }
}
/// This thread's CPU time.
fn thread_cpu() -> Duration {
let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
// SAFETY: `ts` is writable.
unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) };
Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)
}
/// Burn `cpu` of this thread's CPU time on this one thread — CPU time, not
/// wall time, so a loaded machine that takes the core away makes the burn
/// longer, never smaller — at the user-interactive QoS class, so the burn
/// lands on the cores interactive work gets.
fn burn_cpu(cpu: Duration) {
// SAFETY: sets this thread's own QoS class; no preconditions.
unsafe { libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE, 0) };
let start = thread_cpu();
let mut x = 0u64;
while thread_cpu() - start < cpu {
for _ in 0..10_000 {
x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
}
}
std::hint::black_box(x);
}
fn joules(uj: u64) -> f64 {
uj as f64 / 1e6
}
/// The span's one record, which must be a `Cpu` hardware reading.
fn the_reading(m: &Measurement) -> u64 {
assert_eq!(m.source, "process-energy", "{m:?}");
assert_eq!(m.devices.len(), 1, "{m:?}");
let d = m.devices[0];
assert_eq!((d.class, d.provenance), (DeviceClass::Cpu, Provenance::HwShunt), "{m:?}");
d.check_provenance().unwrap();
d.measured_uj.expect("a reading carries its measured figure")
}
/// The counter is live on this host, and the oracle agrees: over a second of
/// burn sampled every ~25 ms of this thread's CPU, it advanced in at least
/// nine intervals in ten. The probe ran once, however often it is asked.
#[test]
fn the_process_energy_probe_is_live() {
let _one = alone();
let status = process_energy_status();
let (mut advanced, mut intervals) = (0, 0);
let mut last = oracle_energy_nj(me()).expect("oracle read");
let t = Instant::now();
while t.elapsed() < Duration::from_secs(1) {
burn_cpu(Duration::from_millis(25));
let now = oracle_energy_nj(me()).expect("oracle read");
intervals += 1;
advanced += u32::from(now > last);
last = now;
}
for _ in 0..8 {
assert_eq!(process_energy_status(), status);
let _ = PowerMeter::detect_in_process();
}
let runs = process_energy_probe_runs();
eprintln!("process energy: status={status:?} oracle advanced in {advanced}/{intervals} intervals, probe runs={runs}");
assert_eq!(runs, 1, "the liveness probe runs once per process");
if advanced * 10 >= intervals * 9 {
assert_eq!(status, CounterStatus::Live, "the oracle saw the counter keep advancing");
}
if status == CounterStatus::Live {
assert!(advanced * 5 >= intervals * 4, "Live, yet the oracle saw it advance in {advanced}/{intervals}");
}
#[cfg(target_arch = "aarch64")]
assert_eq!(status, CounterStatus::Live, "measured live on this Apple Silicon fleet (macOS 27.0.1)");
}
/// 200 ms of one thread's CPU, burned in this process and metered through
/// `detect_in_process`, is a `Cpu` hardware reading between 0.2 J and 20 J,
/// and inside the oracle's reading of the same work (read around the span).
#[test]
fn an_in_process_burn_is_a_hardware_reading_of_its_energy() {
let _one = alone();
let mut meter = PowerMeter::detect_in_process();
assert_eq!(meter.sources(), ["process-energy"], "status {:?}", process_energy_status());
let outer_before = oracle_energy_nj(me()).unwrap();
let ((), m) = meter.measure(|| burn_cpu(Duration::from_millis(200)));
let outer = oracle_energy_nj(me()).unwrap() - outer_before;
let uj = the_reading(&m);
eprintln!("200 ms in-process burn: {:.4} J over {:?} wall (oracle around it: {:.4} J)", joules(uj), m.wall, outer as f64 / 1e9);
assert!((200_000..=20_000_000).contains(&uj), "{uj} µJ for 200 ms of one thread");
assert!(uj <= outer / 1_000 + 1, "the span read more ({uj} µJ) than the oracle around it ({outer} nJ)");
}
/// A 200 ms sleep in this process is a reading under 0.1 J: the counter
/// covers this process's threads, and they did next to nothing.
#[test]
fn an_in_process_sleep_reads_under_a_tenth_of_a_joule() {
let _one = alone();
let mut meter = PowerMeter::detect_in_process();
let ((), m) = meter.measure(|| std::thread::sleep(Duration::from_millis(200)));
let uj = the_reading(&m);
eprintln!("200 ms in-process sleep: {uj} µJ");
assert!(uj < 100_000, "{uj} µJ for a 200 ms sleep");
}
/// `detect()` never reads this process's counter, even where it is live:
/// `detect` meters subprocesses, which that counter does not cover.
#[test]
fn detect_does_not_include_the_process_probe() {
let _one = alone();
let status = process_energy_status();
let sources = PowerMeter::detect().sources();
eprintln!("detect(): {sources:?} (process energy {status:?})");
assert!(!sources.contains(&"process-energy"), "{sources:?}");
assert_eq!(status, CounterStatus::Live, "setup: the probe was available to include");
}
/// Work in a child process is not attributed to the in-process probe. A child
/// burns ~0.4 s of CPU while this process waits; the oracle reads the child's
/// own energy from the exited child before it is reaped, and this process's
/// energy around the wait. The in-process span over the wait is a fraction
/// of the child's energy, and under 0.1 J — or, if this process's counter did
/// not move at all while it waited, an estimate, never a reading.
#[test]
fn work_in_a_child_process_is_not_attributed_to_the_in_process_probe() {
let _one = alone();
let mut meter = PowerMeter::detect_in_process();
let mine_before = oracle_energy_nj(me()).unwrap();
let span = meter.start();
let child = std::process::Command::new("awk")
.arg("BEGIN { for (i = 0; i < 12000000; i++) x += i; print x }")
.stdout(std::process::Stdio::null())
.spawn()
.expect("spawn awk");
let pid = child.id() as libc::pid_t;
// Wait for the exit without reaping, so the child is still readable.
// SAFETY: an all-zero siginfo_t is a valid out-parameter; `pid` is ours.
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::waitid(libc::P_PID, pid as libc::id_t, &mut info, libc::WEXITED | libc::WNOWAIT) };
assert_eq!(rc, 0, "waitid: {}", std::io::Error::last_os_error());
let m = span.stop(&mut meter);
let mine = oracle_energy_nj(me()).unwrap() - mine_before;
let childs = oracle_energy_nj(pid).expect("the exited, unreaped child reads");
let mut child = child;
assert!(child.wait().expect("reap").success());
eprintln!(
"child work: child {:.4} J, this process {:.6} J around the wait, span {:?}",
childs as f64 / 1e9,
mine as f64 / 1e9,
m.devices
);
assert!(childs >= 100_000_000, "setup: the child did real work ({childs} nJ)");
assert!(mine < 100_000_000 && mine * 10 < childs, "this process's counter took the child's work: {mine} nJ");
if m.reads_cpu() {
let uj = the_reading(&m);
assert!(uj < 100_000 && uj * 1_000 * 10 < childs, "the span read the child's work: {uj} µJ");
} else {
assert_eq!(m.devices[0].provenance, Provenance::Estimator, "{m:?}");
}
}

View file

@ -1230,7 +1230,10 @@ pub fn prove_local(
));
}
// (3) Meter this host's own counters across the engine's execution.
// (3) Meter this host's own counters across the engine's execution. The
// engine is a subprocess, so the meter is `detect()`'s machine-wide one,
// never `detect_in_process()`: this process's own energy counter does not
// cover a child process, and would read the proof waiting.
let mut meter = PowerMeter::detect();
let sources = meter.sources().join(",");
let (status, measurement) = meter.measure(|| {