From ad8fbf48ca858dda00d32271081003cbf12b4e0f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 21:08:26 -0400 Subject: [PATCH 1/2] sandbox-meter: this process's own CPU energy, read only for in-process work and only while it keeps advancing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS 27.0.1 an unprivileged reader sees the machine-wide IOReport CPU rail frozen, so every meter here has been an estimator on Apple Silicon. The kernel does keep a live per-process figure: `proc_pid_rusage(getpid(), RUSAGE_INFO_V6)` and its `ri_energy_nj`, the CPU energy it attributes to this process's threads, public libproc, no privilege. On this M5 Max it advances every few milliseconds under load. Its coverage is the trap. It counts this process's own threads and nothing else: not child processes (the kernel never folds a child's energy into its parent, not even after reaping it; `rusage_info` has a child CPU-time field and no child energy field), not other processes, not the GPU or the ANE. `PowerMeter` meters subprocess work in jcp-gateway (the upstream MCP server), joule-code-remediate (cargo) and sandbox-proof (the engine), and in-process work in joule-ui-serve's handlers. A probe added to `detect()` would read the gateway waiting, near zero, as the energy of the tool call. So the new `ProcessEnergyProbe` (`Cpu`, `HwShunt`, source "process-energy") is never part of `detect()`. It is reached only through a new constructor, `PowerMeter::detect_in_process()`, documented as only for work on this process's own threads: where the counter is live the meter reads it and nothing else (a machine-wide CPU probe beside it would count the same work twice); where it is not, and off macOS, it is `detect()`. The counter is gated by the shared rule, probed once per process and cached (`process_energy_status()`, a `CounterStatus`): it must advance in at least five intervals, at least four in five, over at least 300 ms, while the probe burns CPU, through the same `liveness::counter_run` and `gated` as RAPL and NVML. `Span::stop`'s held-CPU rule applies unchanged: a span over which the counter did not advance is estimated. The struct is the SDK's `rusage_info_v6` field for field, pinned by a test at the SDK's own sizeof and offsets (464 bytes, `ri_energy_nj` at 336). `run_attributed` (subprocess windows). The question was whether a child's own figure can be read reliably. It can, for the direct child: wait for the exit without reaping (`waitid(P_PID, pid, WEXITED | WNOWAIT)`), `proc_pid_rusage` the exited child, then reap with `wait4` as before. Measured here before writing it: the read succeeds on the zombie and is stable across repeated reads, then fails (ESRCH) once reaped; `sh -c 'awk ...'` (sh execs awk) read 1.647 J for 0.6 s of awk's CPU; `sh -c 'awk ...; true'` (awk a reaped child of sh) read 5.5 mJ, sh's own start-up, beside 0.63 s of reaped-descendant CPU time. So the new `Attribution::child_energy_uj` is the direct child's own energy, given only when the per-process counter is live, the child reaped no descendant that used CPU time, and it advanced. Grandchildren are not covered: cargo -> rustc gets `None`, as does any shell that forks its commands, and a descendant the child did not reap leaves no trace at all. The status is read before the window opens, so its first-use probe never lands inside it. The apportioned `attributed_uj` is unchanged. jcp-gateway, joule-code-remediate and sandbox-proof stay on `detect()`, and their docs now say why: their metered work is a subprocess. remediate's test helper sets the new field to `None` (cargo). Tests. Hardware, in their own process (`tests/process_energy.rs`, one at a time, each against an oracle that reads `ri_energy_nj` into a raw buffer at the SDK offset and shares no code with the probe): `the_process_energy_probe_is_live` (pinned Live on Apple Silicon; the oracle saw 39/39 intervals advance; the probe ran once), `an_in_process_burn_is_a_hardware_reading_of_its_energy` (200 ms of one thread's CPU, user-interactive QoS, via `detect_in_process`: one `Cpu` `HwShunt` record in 0.2..20 J and inside the oracle's reading around it), `an_in_process_sleep_reads_under_a_tenth_of_a_joule`, `detect_does_not_include_the_process_probe`, `work_in_a_child_process_is_not_attributed_to_the_in_process_probe`. Unit: `the_rusage_struct_is_the_sdks_layout`, `this_process_reads_itself_and_a_missing_pid_reads_nothing`, `a_childs_own_energy_is_a_reading_only_for_a_single_process_child`, `only_a_live_status_builds_the_process_probe`, `detect_never_reads_this_process_counter` (every status combination), `detect_in_process_reads_this_process_only_for_a_live_status` (and never calls the fallback when it reads), `a_single_process_childs_own_energy_is_read_before_it_is_reaped`, `a_child_whose_work_ran_in_a_descendant_has_no_reading`. Measured on this M5 Max (macOS 27.0.1, other builds loading it), verbatim: 200 ms in-process burn: 0.4361 J over 219.588375ms wall (oracle around it: 0.4361 J) 200 ms in-process sleep: 575 µJ child work: child 0.8730 J, this process 0.001304 J around the wait, span [... measured_uj: Some(1304) ... HwShunt] process energy: status=Live oracle advanced in 39/39 intervals, probe runs=1 child energy: status=Live child_energy_uj=Some(3128510) proc_cpu_secs=1.499 On the M3 Ultra (macOS 27.0.1, 10.10.10.2; lib and process_energy test binaries copied, run once with --nocapture, deleted), verbatim: 200 ms in-process burn: 1.3150 J over 200.032666ms wall (oracle around it: 1.3150 J) 200 ms in-process sleep: 1796 µJ process energy: status=Live oracle advanced in 40/40 intervals, probe runs=1 child work: child 2.3750 J, this process 0.014451 J around the wait, span [... measured_uj: Some(14451) ... HwShunt] child energy: status=Live child_energy_uj=Some(10145217) proc_cpu_secs=2.044 ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false 105 lib tests and 5 process_energy tests passed. The per-process counter is live on both chips; IOReport's CPU rail stays frozen. sandbox-meter passes (105 lib, 5 process_energy, both once-per-process files); every sandbox-rs crate that depends on the meter passes (238 tests); jcp-gateway and jcp-action-receipt (33) and joule-code-remediate (13) pass. Clippy is clean for aarch64-apple-darwin and x86_64-unknown-linux-gnu, and x86_64-apple-darwin checks clean. Falsified, each restored from git and re-run green: the probe added to `detect()` fails `detect_never_reads_this_process_counter` and `detect_does_not_include_the_process_probe`; a constant counter, and the parent's pid in place of this one, fail `the_process_energy_probe_is_live` (status Frozen while the oracle saw it advance) and the 200 ms burn (it falls back to the 4.1 J estimate); `given` ignoring the status fails `only_a_live_status_builds_the_process_probe` and `detect_in_process_reads_this_process_only_for_a_live_status`; a child reading that ignores reaped descendants fails both child tests (sh's 5.5 mJ stood in for awk's work); one that ignores liveness fails the pure child test. Rebased onto 7cf1f036 (the RAPL figure sums the package zones by name), which builds `rapl` under test on every platform and adds its fake-tree tests. The one conflict was the module declarations in probe/mod.rs; both `process` and `rapl`'s new cfg stand. The counts above predate the rebase: on the M5 Max sandbox-meter now has 120 lib tests (114 before it), all passing, with process_energy (5) and both once-per-process tests; the sandbox-rs workspace, the sandbox-conformance binary, jcp-gateway, jcp-action-receipt and joule-code-remediate pass too. Clippy on sandbox-meter is clean for aarch64-apple-darwin and x86_64-unknown-linux-gnu. The M3 Ultra run was not repeated. Rebased again, onto a16e36cf, whose sandbox-meter side builds `liveness` and `rapl` on every platform and adds `RaplPackages` and `counter_liveness`. The one conflict was again the module declarations in probe/mod.rs: `liveness` and `rapl` are unconditional, `process` stays macOS-only. On the M5 Max sandbox-meter now has 122 lib tests, all passing, with process_energy (5) and both once-per-process tests; the sandbox-rs workspace (321), the sandbox-conformance binary, jcp-gateway and jcp-action-receipt (33) and joule-code-remediate (13) pass. Clippy on sandbox-meter and sandbox-exec-microvm is clean for aarch64-apple-darwin and x86_64-unknown-linux-gnu. Co-Authored-By: Claude Opus 5.5 --- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 4 + .../crates/joule-code-remediate/src/lib.rs | 8 + sandbox/README.md | 2 +- .../crates/sandbox-meter/src/lib.rs | 4 +- .../sandbox-meter/src/probe/attribute.rs | 107 ++++++- .../sandbox-meter/src/probe/liveness.rs | 25 +- .../crates/sandbox-meter/src/probe/mod.rs | 158 +++++++++- .../crates/sandbox-meter/src/probe/process.rs | 278 ++++++++++++++++++ .../sandbox-meter/tests/process_energy.rs | 194 ++++++++++++ .../crates/sandbox-proof/src/lib.rs | 5 +- 10 files changed, 757 insertions(+), 28 deletions(-) create mode 100644 sandbox/sandbox-rs/crates/sandbox-meter/src/probe/process.rs create mode 100644 sandbox/sandbox-rs/crates/sandbox-meter/tests/process_energy.rs 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/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(|| { -- 2.54.0 (Apple Git-157) From 8aa25541b8598eecf47c9c7e684f30c6c85953a4 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 21:08:43 -0400 Subject: [PATCH 2/2] joule-ui: served and example interactions are metered by this process's own CPU energy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit joule-ui-serve's action handlers and the joule-ui-interaction examples' handlers are in-process work, verified rather than assumed: `Host::handle` calls the handler inside `run_interaction`, on the worker thread that holds the host lock, and nothing in joule-ui-serve or the examples spawns a process. So they now meter with `PowerMeter::detect_in_process()`, and on a host whose per-process CPU energy counter is live (both Apple Silicon hosts checked, macOS 27.0.1, where the machine-wide CPU rail is frozen) each interaction is sealed as a `HwShunt` reading of the energy the kernel attributes to this process's threads over the interaction, instead of the 20 W wall-clock estimate. Where the counter is not live the meter is `detect()`'s, as before. The figure is this process's, not one thread's: in joule-ui-serve it includes any worker that served a socket during the span. joule-ui-serve: `main` serves with `served_power_meter()`, which is `detect_in_process()`, and the end-to-end test now builds its meter from that same function and asserts, where the counter is live, that the meter is the process probe and that each ~24 ms served interaction is `HwShunt`. The examples: `real_meter` and `jcp_metered` use `detect_in_process()` and say which counter they read. With real readings an interaction of ~150 ms costs a fraction of a joule, not the estimate's 3 J, so six fires no longer spend `jcp_metered`'s 15 J grant; it now fires until the real JCP runtime denies one (at most 200), printing the first five and the last three, so the budget binds whatever the figures are. The grant itself is unchanged. SPEC §5 names the per-process counter among the reference meter's sources; the two Cargo comments say which meter each crate uses. Tests: joule-ui-serve, -interaction, -conformance, -mcp, -core, -a2ui and -render pass (72). The served interactions on this M5 Max: `Energy { measured_uj: Some(25908), ... provenance: HwShunt }` and `Some(23193)`. `gen_vectors` regenerated into a scratch directory is byte-identical to `conformance/v1/` (`diff -r` clean). The examples, release build, here: real_meter: render #1: allow · 195798 µJ [HwShunt] ... sealed chain: 3 links verified, 584156 µJ total — hardware counter deltas (HwShunt), this process's CPU energy over each interaction jcp_metered: 82 interactions sealed: 81 allowed; 15001229 µJ sealed, hardware counter deltas (HwShunt) from process-energy; 15000000 of the 15000000 µJ budget debited; 1 denied by the real JCP runtime once the budget was spent. (The per-interaction figure varied from 0.04 to 0.21 J: the handlers burn for 150 ms of wall time, and other builds were loading this machine.) Falsified, restored from git and re-run green: `served_power_meter` returning `detect()` fails `the_served_meter_never_seals_zero_or_an_estimate_as_a_reading`. Rebased onto 7cf1f036 with the commit before it; this commit's files are unchanged. joule-ui-interaction, -serve, -conformance, -mcp, -core, -a2ui and -render pass, and `gen_vectors` regenerated into a scratch directory is still byte-identical to `conformance/v1/`. Rebased again, onto a16e36cf, with the commit before it; this commit's files are unchanged. The seven joule-ui crates pass (72), and `gen_vectors` regenerated into a scratch directory is still byte-identical to `conformance/v1/`. Co-Authored-By: Claude Opus 5.5 --- joule-ui/SPEC.md | 10 ++-- .../crates/joule-ui-interaction/Cargo.toml | 5 +- .../examples/jcp_metered.rs | 52 +++++++++++++------ .../examples/real_meter.rs | 27 ++++++---- .../crates/joule-ui-serve/Cargo.toml | 8 +-- .../joule-ui-serve/src/bin/joule_ui_serve.rs | 38 ++++++++++---- 6 files changed, 96 insertions(+), 44 deletions(-) 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:?}"); -- 2.54.0 (Apple Git-157)