sandbox-meter: a live per-process CPU energy probe for in-process work, and a real reading for a direct child #121
16 changed files with 853 additions and 72 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
278
sandbox/sandbox-rs/crates/sandbox-meter/src/probe/process.rs
Normal file
278
sandbox/sandbox-rs/crates/sandbox-meter/src/probe/process.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
194
sandbox/sandbox-rs/crates/sandbox-meter/tests/process_energy.rs
Normal file
194
sandbox/sandbox-rs/crates/sandbox-meter/tests/process_energy.rs
Normal 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:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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(|| {
|
||||
|
|
|
|||
Loading…
Reference in a new issue