diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 1492e5045..edcc28bfb 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -75,6 +75,10 @@ pub struct Gateway { runtime: Runtime, grants: BTreeMap, 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, diff --git a/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs b/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs index 3189912be..79ff38c0f 100644 --- a/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs @@ -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, } } diff --git a/joule-ui/SPEC.md b/joule-ui/SPEC.md index 6030a4404..e5543a4e1 100644 --- a/joule-ui/SPEC.md +++ b/joule-ui/SPEC.md @@ -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 diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-interaction/Cargo.toml b/joule-ui/joule-ui-rs/crates/joule-ui-interaction/Cargo.toml index bee1be358..29c5a6baf 100644 --- a/joule-ui/joule-ui-rs/crates/joule-ui-interaction/Cargo.toml +++ b/joule-ui/joule-ui-rs/crates/joule-ui-interaction/Cargo.toml @@ -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 diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-interaction/examples/jcp_metered.rs b/joule-ui/joule-ui-rs/crates/joule-ui-interaction/examples/jcp_metered.rs index 372eb265e..0fc77d90b 100644 --- a/joule-ui/joule-ui-rs/crates/joule-ui-interaction/examples/jcp_metered.rs +++ b/joule-ui/joule-ui-rs/crates/joule-ui-interaction/examples/jcp_metered.rs @@ -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"
"); 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 { diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-interaction/examples/real_meter.rs b/joule-ui/joule-ui-rs/crates/joule-ui-interaction/examples/real_meter.rs index 79767cac6..d1486445c 100644 --- a/joule-ui/joule-ui-rs/crates/joule-ui-interaction/examples/real_meter.rs +++ b/joule-ui/joule-ui-rs/crates/joule-ui-interaction/examples/real_meter.rs @@ -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 { diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-serve/Cargo.toml b/joule-ui/joule-ui-rs/crates/joule-ui-serve/Cargo.toml index 2601f3b5d..9a89a2834 100644 --- a/joule-ui/joule-ui-rs/crates/joule-ui-serve/Cargo.toml +++ b/joule-ui/joule-ui-rs/crates/joule-ui-serve/Cargo.toml @@ -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. diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-serve/src/bin/joule_ui_serve.rs b/joule-ui/joule-ui-rs/crates/joule-ui-serve/src/bin/joule_ui_serve.rs index a056b4208..65525eb95 100644 --- a/joule-ui/joule-ui-rs/crates/joule-ui-serve/src/bin/joule_ui_serve.rs +++ b/joule-ui/joule-ui-rs/crates/joule-ui-serve/src/bin/joule_ui_serve.rs @@ -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 Meter for SpanMeter { } } -/// The meter the server seals interactions with. +/// The meter the server seals interactions with, over [`served_power_meter`]. fn served_meter(meter: PowerMeter) -> Box { 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:?}"); diff --git a/sandbox/README.md b/sandbox/README.md index bd97bf043..6fb5f3f1f 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -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. diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/lib.rs index 02ad724c9..3b273cd8a 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/lib.rs @@ -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). diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/attribute.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/attribute.rs index 700bd8255..39f27efd3 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/attribute.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/attribute.rs @@ -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, } #[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 { + 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 diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/liveness.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/liveness.rs index 06b04e495..b06beb9c0 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/liveness.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/liveness.rs @@ -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( open: impl FnOnce() -> Option, 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) -> CounterStatus { match verdict { Some(CpuRail::Live) => CounterStatus::Live, @@ -461,7 +462,6 @@ pub(super) fn counter_status_of(verdict: Option) -> 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

( what: &str, status: CounterStatus, @@ -487,7 +486,6 @@ pub(super) fn gated

( /// 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()); diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs index 6b5474e7a..ca2d932a8 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs @@ -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 = 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::().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> = 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::().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] diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/process.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/process.rs new file mode 100644 index 000000000..097c92bce --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/process.rs @@ -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 ``. +const RUSAGE_INFO_V6: libc::c_int = 6; + +/// `struct rusage_info_v6` from the macOS 27 SDK's ``, 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 { + // 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 { + 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 { + // 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 { + 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, 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 { + 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 + /// ``). + #[test] + fn the_rusage_struct_is_the_sdks_layout() { + assert_eq!(std::mem::size_of::(), 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()); + } +} diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/tests/process_energy.rs b/sandbox/sandbox-rs/crates/sandbox-meter/tests/process_energy.rs new file mode 100644 index 000000000..54185f97d --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-meter/tests/process_energy.rs @@ -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 `` puts it (a 16-byte uuid, then 40 `uint64_t`s). +fn oracle_energy_nj(pid: libc::pid_t) -> Option { + 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:?}"); + } +} diff --git a/sandbox/sandbox-rs/crates/sandbox-proof/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-proof/src/lib.rs index 8b83196eb..ed5e5e77b 100644 --- a/sandbox/sandbox-rs/crates/sandbox-proof/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-proof/src/lib.rs @@ -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(|| {