diff --git a/proof/README.md b/proof/README.md index 08b0bdd78..5d3a3e945 100644 --- a/proof/README.md +++ b/proof/README.md @@ -37,10 +37,10 @@ proof/ - **`proof-schema`** — the content-addressed goal (`blake3(statement‖0x00‖context)`), the cheapest-first tier ladder, the honest energy model (`HwShunt`/`ModelBased`/`Estimator` with the coupling enforced), `SpecGrounding`, the verdict, and the `Solver` trait every backend implements. Canonical det-CBOR content addressing. - **`proof-solvers`** — two **real** rungs, their energy modeled from measured wall time (`ModelBased`, a ~15 W power model — not a hardware reading): a propositional decider (T0) that parses a boolean goal and decides validity by truth-table in process, and `z3` (T1) — shell or kept **resident** across goals — that decides an SMT-LIB goal (assert the negation; `unsat` ⇒ valid). Each refuses rather than fakes, so the router escalates. The model rungs (T2/T3) are honest stand-ins behind the same trait. -- **`proof-cascade`** — the escalation router (every joule accounted, `wasted_uj` disclosed), the content-addressed proof cache (the energy flywheel, durable as line-delimited JSON), batch energy accounting against a frontier-only baseline with the per-tier Amdahl breakdown, and a **real hardware energy meter** (`meter` — the `HwShunt` backend): it brackets a real proof, run on every core, against an idle baseline using the platform's energy interface (IOReport's Energy Model, read directly through its C API on Apple Silicon and used only while every CPU energy rail is live, one per die on a multi-die part; RAPL's package counters on Linux, used only while every one is live) and returns measured microjoules, so the headline `joules per verified proof` can be a real reading, not only a model. A vendor counter's reading is graded `HwShunt` only while the counter is live for the domain it claims ([`SPEC.md`](SPEC.md) §5), and the meter checks that before it brackets anything, by one rule on both platforms (the counter keeps advancing in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms while the probe burns CPU): on Apple Silicon for every IOReport CPU energy rail the figure sums (`CPU Energy`, or each die's `DIE__CPU Energy`), on Linux for every RAPL package counter the figure sums (`psys` is neither summed nor probed). Returns nothing where no interface is reachable or its counter is not live — the caller keeps the honestly-tagged `ModelBased` figure rather than inventing one. +- **`proof-cascade`** — the escalation router (every joule accounted, `wasted_uj` disclosed), the content-addressed proof cache (the energy flywheel, durable as line-delimited JSON), batch energy accounting against a frontier-only baseline with the per-tier Amdahl breakdown, and a **real hardware energy meter** (`meter` — the `HwShunt` backend): it runs a real proof on every spare core and returns its measured microjoules from the first live source — on macOS **this process's own CPU energy counter** (`proc_pid_rusage`'s `ri_energy_nj`, public API, no privilege), read before the workers start and after they are joined, so other processes are excluded by construction and no idle baseline is subtracted; else IOReport's Energy Model, read directly through its C API on Apple Silicon and bracketed against idle; on Linux RAPL's package counters, bracketed against idle — so the headline `joules per verified proof` can be a real reading, not only a model. A vendor or kernel counter's reading is graded `HwShunt` only while the counter is live for the domain it claims ([`SPEC.md`](SPEC.md) §5), and the meter checks that before it meters anything, by one rule for every source (the counter keeps advancing in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms while the probe burns CPU): the process counter itself, every IOReport CPU energy rail the figure sums (`CPU Energy`, or each die's `DIE__CPU Energy`), every RAPL package counter the figure sums (`psys` is neither summed nor probed). Each figure carries its domain: the process counter's is this process's CPU energy only — not other processes, child processes, GPU, ANE or DRAM. Returns nothing where no source is live — the caller keeps the honestly-tagged `ModelBased` figure rather than inventing one. - **`proof-receipt`** — the `proof/1` JCR-1 receipt: canonical det-CBOR + COSE_Sign1/EdDSA, vendored byte-compatible with the family's cross-language verifiers; refuses to seal an unproved goal. - **`proof-conformance`** — C1–C5 + a negative pack, all checks real, published vectors in `conformance/v1/`. -- **`proof-cli`** — the `proof` reference demo: a proof workload reporting joules-per-proof against a frontier-only baseline — like for like, charged only for the goals the cascade proved, with the all-goals figure and its goal count beside it — the Amdahl breakdown, the cache flywheel, and a sealed + verified receipt. `proof --measure` adds a **real hardware reading** of a non-trivial T0 proof (`HwShunt`) where the meter produces one; that needs a counter the meter has seen to be live (on Apple Silicon every IOReport CPU energy rail, on Linux every RAPL package counter). On the Macs measured so far (macOS 27.0.1, an M5 Max and an M3 Ultra) IOReport's CPU energy rail is frozen for an unprivileged reader, so `--measure` declines there, says why, and leaves every figure `ModelBased`; on Linux, RAPL's `energy_uj` is root-only on current kernels. +- **`proof-cli`** — the `proof` reference demo: a proof workload reporting joules-per-proof against a frontier-only baseline — like for like, charged only for the goals the cascade proved, with the all-goals figure and its goal count beside it — the Amdahl breakdown, the cache flywheel, and a sealed + verified receipt. `proof --measure` adds a **real hardware reading** of a non-trivial T0 proof (`HwShunt`) where the meter produces one, and prints which source metered it, its domain, and why each other source was not used; that needs a counter the meter has seen to be live. On the Macs measured so far (macOS 27.0.1, an M5 Max and an M3 Ultra) IOReport's CPU energy rail is frozen for an unprivileged reader, but this process's CPU energy counter is live, so `--measure` meters with it; the receipt still seals the cascade's own `ModelBased` figure for the first goal. On Linux, RAPL's `energy_uj` is root-only on current kernels. `cargo test --workspace` green; `cargo clippy --workspace --all-targets` is warning-free for Linux, and on macOS reports two lints in proof-cascade's diff --git a/proof/proof-rs/crates/proof-cascade/src/meter.rs b/proof/proof-rs/crates/proof-cascade/src/meter.rs index a7ac8e22b..0419aa834 100644 --- a/proof/proof-rs/crates/proof-cascade/src/meter.rs +++ b/proof/proof-rs/crates/proof-cascade/src/meter.rs @@ -1,41 +1,73 @@ //! Real hardware energy measurement — the `HwShunt` backend the schema's -//! `Provenance::HwShunt` promises. Brackets a multi-thread busy burst of `work` -//! against an idle baseline using the platform's energy interface (Apple's -//! IOReport Energy Model read directly via its C API — no `macmon`, no sudo — on -//! Apple Silicon; RAPL `energy_uj` on Linux) and returns the **marginal** -//! microjoules of one `work` call. Returns `None` where no interface is -//! reachable — the caller keeps the honestly-tagged `ModelBased` reading rather -//! than inventing a number. No elevated privileges and no external binaries. +//! `Provenance::HwShunt` promises. Returns the **marginal** microjoules of one +//! `work` call, run on every spare core, from the first of these sources that +//! is live on this host ([`meter_source`](crate::meter::meter_source)): //! -//! **A figure from this meter is a hardware reading, `HwShunt`, on both -//! platforms.** IOReport's Energy Model and RAPL are vendor energy counters, -//! not a shunt on the supply, and a vendor counter's reading is a real -//! reading: what it lacks is independent provenance, not accuracy. The grade -//! holds only while the counter is demonstrably live for the domain the figure -//! claims (SPEC §5): the implementation must have observed the counter covering -//! that domain advance under load. A counter that is present but not live — -//! frozen, or republished in lumps — must not produce a hardware-graded figure. -//! On both platforms this meter returns `None` for such a counter and the -//! caller keeps its own estimate. +//! 1. **This process's CPU energy counter** (macOS): `proc_pid_rusage(getpid(), +//! RUSAGE_INFO_V6)`'s `ri_energy_nj`, the cumulative CPU energy the kernel +//! attributes to this process's threads — public libproc API, no privilege. +//! The counter is read before the workers start and after they are all +//! joined, so its advance covers every call counted — and whatever else +//! this process's threads burn meanwhile, which in a quiet caller is the +//! workers' start-up and the sleeping caller, a few microjoules — while +//! other processes are excluded by construction: no idle baseline to +//! subtract. +//! 2. **IOReport's Energy Model** (Apple Silicon, read directly via its C API — +//! no `macmon`, no sudo): SoC power bracketed busy against idle. +//! 3. **RAPL** `energy_uj` (Linux): package power bracketed busy against idle. //! -//! **On Apple Silicon, IOReport counts as reachable only while every CPU -//! energy rail is live** ([`ioreport_status`](crate::meter::ioreport_status)). A -//! subscription can open while no CPU rail tracks the work: measured on macOS -//! 27.0.1 for an unprivileged reader, the CPU rails of an M5 Max and an M3 Ultra -//! hold their value under minutes of full CPU load and move only in rare batch -//! refreshes (both M3 Ultra dies held for at least 22 minutes, then stepped by -//! ~1.3 kJ each within one 100 ms sample, and an M5 Max's `CPU Energy` was seen -//! to publish one refresh in two steps 64 ms apart), while the M5 Max's GPU -//! rail keeps advancing. Bracketing CPU work against idle on those rails -//! brackets GPU activity, nothing, or a refresh. So the interface is used only -//! when every CPU rail — `CPU Energy`, or each die's `DIE__CPU Energy` on a -//! multi-die part such as the M3 Ultra — keeps advancing on its own while the -//! probe burns CPU — in at least five separate sample intervals, in at least -//! 80% of the intervals observed, over at least 300 ms of wall time — which no -//! refresh observed so far does; otherwise `None` is returned and the caller -//! keeps its `ModelBased` figure. Every rail, not one: the power reading sums -//! every CPU rail, so a die whose rail held is a die the figure would claim -//! without reading it. +//! Returns `None` where no source is live — the caller keeps the +//! honestly-tagged `ModelBased` reading rather than inventing a number. No +//! elevated privileges and no external binaries. +//! +//! **A figure from this meter is a hardware reading, `HwShunt`, from every +//! source** ([`MeterSource::provenance`](crate::meter::MeterSource::provenance)). +//! IOReport's Energy Model and RAPL are vendor energy counters, and the +//! process counter is the kernel's accounting of the CPU's own energy +//! counters per thread — none is a shunt on the supply, and a vendor or kernel +//! counter's reading is a real reading: what it lacks is independent +//! provenance, not accuracy. The grade holds only while the counter is +//! demonstrably live for the domain the figure claims (SPEC §5): the +//! implementation must have observed the counter covering that domain advance +//! under load. A counter that is present but not live — frozen, or republished +//! in lumps — must not produce a hardware-graded figure. Every source here is +//! probed by the same rule before it is used, and a source that fails it is +//! never selected. +//! +//! **The domain differs by source, and a figure claims only its own** +//! ([`MeterSource::domain`](crate::meter::MeterSource::domain)). The process +//! counter is THIS process's CPU energy: not other processes, not child +//! processes, not GPU, ANE or DRAM energy, and everything this process's +//! threads burn during the window — so a caller must not run other work in +//! the process while it meters. IOReport's figure is SoC energy (CPU + GPU + +//! other domains, `counts_once`) over idle; RAPL's, package energy over idle. +//! +//! **On macOS, the process counter is used only while it is live** +//! ([`process_energy_status`](crate::meter::process_energy_status)): it must +//! keep advancing while the probe burns CPU — in at least five separate sample +//! intervals, in at least 80% of the intervals observed, over at least 300 ms +//! of wall time — the sustained-advance rule every probe here applies. Measured +//! on macOS 27.0.1 for an unprivileged reader, it is live on an M5 Max and on +//! an M3 Ultra (the probe calls it `Live`, and an independent reader saw it +//! change at every one of 29–36 reads 25 ms apart): on the M5 Max it changes +//! about every 0.5–0.7 ms under load, holds near 1–2 mJ/s idle, and keeps the +//! energy of threads that have exited. +//! +//! **IOReport counts as reachable only while every CPU energy rail is live** +//! ([`ioreport_status`](crate::meter::ioreport_status)). A subscription can +//! open while no CPU rail tracks the work: measured on macOS 27.0.1 for an +//! unprivileged reader, the CPU rails of an M5 Max and an M3 Ultra hold their +//! value under minutes of full CPU load and move only in rare batch refreshes +//! (both M3 Ultra dies held for at least 22 minutes, then stepped by ~1.3 kJ +//! each within one 100 ms sample, and an M5 Max's `CPU Energy` was seen to +//! publish one refresh in two steps 64 ms apart), while the M5 Max's GPU rail +//! keeps advancing. Bracketing CPU work against idle on those rails brackets +//! GPU activity, nothing, or a refresh. So the interface is used only when +//! every CPU rail — `CPU Energy`, or each die's `DIE__CPU Energy` on a +//! multi-die part such as the M3 Ultra — keeps advancing on its own under the +//! same rule; otherwise it is declined. Every rail, not one: the power reading +//! sums every CPU rail, so a die whose rail held is a die the figure would +//! claim without reading it. //! //! **On Linux, RAPL counts as reachable only while every package counter is //! live** ([`rapl_status`](crate::meter::rapl_status)). The same probe loop and @@ -50,62 +82,150 @@ //! its liveness is not probed. `energy_uj` is root-only on current kernels, //! so an unprivileged run usually reads no zone: `Unavailable`, and `None`. //! -//! The chain on macOS is short on purpose. `host_status` is one expression: the -//! generic probe loop (`liveness::probe_with`) on the real parts — the probe's -//! own Energy Model subscription, the thread-CPU tick and the clocks — and the -//! pure status of its run. [`ioreport_status`](crate::meter::ioreport_status) -//! caches it once per process, and every selection -//! ([`hardware_meter_available`](crate::meter::hardware_meter_available), and -//! through it [`measure_marginal_uj`](crate::meter::measure_marginal_uj) and -//! `power_over`) gates the cached status through the -//! pure `admits`, which admits only `Live`. The decisions all live in pure code -//! that the tests run on scripted fakes, and the tests also run `host_status` -//! itself, on the real FFI and on scripted channel reads. The Linux chain has -//! the same shape: `rapl_host_status` is the probe loop on the sysfs zones, -//! the tick and the clocks (`rapl_probe_with`, which the tests run on -//! scripted zones on every platform), cached once per process by -//! [`rapl_status`](crate::meter::rapl_status) and gated by `admits_counter`. +//! The chains are short on purpose. Each source's production status is one +//! expression — the generic probe loop (`liveness::probe_watching`) on the +//! real parts (the reader, the thread-CPU tick and the clocks) and the pure +//! status of its run — cached once per process: `host_status` for IOReport +//! ([`ioreport_status`](crate::meter::ioreport_status), gated by `admits`), +//! `process_host_status` for the process counter +//! ([`process_energy_status`](crate::meter::process_energy_status)) and +//! `rapl_host_status` for RAPL ([`rapl_status`](crate::meter::rapl_status)), +//! both gated by `admits_counter`, which admits only `Live`. The selection +//! ([`meter_source`](crate::meter::meter_source)) takes the first admitted +//! source in order, and every metering path — [`measure_marginal`](crate::meter::measure_marginal), +//! `power_over`, `rapl_power_over` — goes through its source's own gate. The +//! decisions all live in pure code that the tests run on scripted fakes, and +//! the tests also run the production compositions on the real FFI. //! -//! It is a measurement with stated bounds — SoC/package power resolved to the -//! marginal delta over an idle machine ÷ iterations — exactly the discipline -//! the WAI sink meter uses, including running the burst on every core so the -//! signal clears a loaded machine's noisy idle baseline. `work` must be cheap, -//! pure, and idempotent (e.g. the real T0 decider on a fixed goal). +//! The brackets are measurements with stated bounds — SoC/package power +//! resolved to the marginal delta over an idle machine ÷ iterations — exactly +//! the discipline the WAI sink meter uses, including running the burst on +//! every core so the signal clears a loaded machine's noisy idle baseline. +//! `work` must be cheap, pure, and idempotent (e.g. the real T0 decider on a +//! fixed goal). use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Instant; -/// Marginal energy of one `work()` call in microjoules, from a hardware -/// interface. A non-zero figure is a hardware reading, `HwShunt`, on both -/// platforms, and is only ever produced through counters the liveness probe -/// saw keep advancing under load: on Apple Silicon a CPU energy rail, on -/// Linux every RAPL package counter (see the module doc). +/// The hardware source a figure from this meter came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeterSource { + /// This process's CPU energy counter (macOS `proc_pid_rusage` + /// `ri_energy_nj`), read around the work: no idle subtraction. + ProcessEnergy, + /// IOReport's Energy Model (Apple Silicon): SoC power, busy over idle. + IoReport, + /// RAPL's package counters (Linux): package power, busy over idle. + Rapl, +} + +impl MeterSource { + /// The grade of a figure from this source: `HwShunt` for every source — a + /// vendor counter's (IOReport, RAPL) or the kernel's per-process + /// accounting of the CPU's energy counters, read only while live (SPEC + /// §5; the owner's decision for the process counter). + pub fn provenance(self) -> proof_schema::Provenance { + proof_schema::Provenance::HwShunt + } + + /// The domain a figure from this source claims, and what it leaves out. + pub fn domain(self) -> &'static str { + match self { + MeterSource::ProcessEnergy => { + "this process's CPU energy, as the kernel attributes it to this process's threads \ + (not other processes, not child processes, not GPU, ANE or DRAM energy)" + } + MeterSource::IoReport => "SoC energy (CPU, GPU and other Energy Model domains) over the idle baseline", + MeterSource::Rapl => "CPU package energy (every RAPL package zone) over the idle baseline", + } + } +} + +/// One figure from this meter: the marginal energy of one `work` call, and the +/// source that metered it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Measurement { + pub uj: u64, + pub source: MeterSource, +} + +impl Measurement { + /// The figure as a schema reading: `measured_uj` present, graded by its + /// source ([`MeterSource::provenance`]). + pub fn energy(&self) -> proof_schema::Energy { + proof_schema::Energy { measured_uj: Some(self.uj), estimated_uj: self.uj, provenance: self.source.provenance() } + } +} + +/// Marginal energy of one `work()` call, from the first live hardware source +/// ([`meter_source`]), and which source it was. A figure is a hardware reading, +/// `HwShunt`, and is only ever produced through counters the liveness probe +/// saw keep advancing under load (see the module doc). /// -/// - `None` if no interface is reachable ([`hardware_meter_available`]: on -/// Apple Silicon, IOReport with a live CPU energy rail; on Linux, RAPL with -/// every package counter live — so a counter that is not live yields `None` -/// before any window runs and `work` never runs), if the -/// interface gave no reading for the idle or the busy window, or if `work` -/// never completed a call in the busy window. The caller keeps its own -/// `ModelBased` figure. -/// - `Some(0)` if the interface answered but the busy window did not draw more -/// than the idle one: the signal stayed inside the idle noise. That is not a -/// measured zero-energy call, and a caller must not report it as one -/// (`proof --measure` says the signal stayed inside the idle noise). +/// - `None` if no source is live ([`meter_source`] is `None`: a counter that +/// is not live yields `None` before any window runs, and `work` never runs), +/// if the selected source gave no reading, or if `work` never completed a +/// call. The caller keeps its own `ModelBased` figure. +/// - From the process counter, `Some` is the counter's advance across the +/// calls ÷ the calls; `Some(0)` means under half a microjoule a call, below +/// this API's resolution — not a zero-energy call. +/// - From a bracket (IOReport, RAPL), `Some(0)` means the busy window did not +/// draw more than the idle one: the signal stayed inside the idle noise. +/// That is not a measured zero-energy call, and a caller must not report it +/// as one (`proof --measure` says the signal stayed inside the idle noise). /// -/// `work` runs on every spare core during the busy window, so keep it pure + -/// `Copy`. +/// `work` runs on every spare core, so keep it pure + `Copy`. Anything else +/// this process runs during the window is counted by the process counter. +pub fn measure_marginal(work: F) -> Option +where + F: Fn() + Send + Sync + Copy + 'static, +{ + let source = meter_source()?; + let uj = match source { + MeterSource::ProcessEnergy => process_marginal_uj(work)?, + MeterSource::IoReport | MeterSource::Rapl => bracket_marginal_uj(work)?, + }; + Some(Measurement { uj, source }) +} + +/// [`measure_marginal`]'s figure alone, in microjoules. pub fn measure_marginal_uj(work: F) -> Option where F: Fn() + Send + Sync + Copy + 'static, { - measure_gated(hardware_meter_available(), work, idle_power, busy_power) + measure_marginal(work).map(|m| m.uj) } -/// [`measure_marginal_uj`] with the selection's answer and the power readers +/// The bracket rung alone — IOReport on macOS, RAPL on Linux — gated by its +/// own interface's cached status, whatever source ranks above it. +fn bracket_marginal_uj(work: F) -> Option +where + F: Fn() + Send + Sync + Copy + 'static, +{ + measure_gated(bracket_admitted(), work, idle_power, busy_power) +} + +/// Whether the bracket rung's interface is admitted: IOReport `Live` on macOS, +/// RAPL `Live` on Linux, never elsewhere. Each probe runs once per process. +fn bracket_admitted() -> bool { + #[cfg(target_os = "macos")] + { + admits(ioreport_status()) + } + #[cfg(target_os = "linux")] + { + admits_counter(rapl_status()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + false + } +} + +/// [`bracket_marginal_uj`] with the selection's answer and the power readers /// passed in. The selection is taken first: an interface that is not -/// available (on Apple Silicon, one [`admits`] turns away) returns `None` +/// available (one [`admits`] or [`admits_counter`] turns away) returns `None` /// before any power is read or `work` runs. fn measure_gated( available: bool, @@ -128,31 +248,52 @@ where Some((marginal_w * window / runs as f64 * 1e6).round() as u64) } -/// Whether a usable hardware energy interface is reachable on this machine: -/// on Linux, RAPL **with every package counter live** — [`rapl_status`] is -/// [`CounterStatus::Live`]; on Apple Silicon, IOReport **with a live CPU -/// energy rail** — [`ioreport_status`] is [`IoReportStatus::Live`]. A -/// readable counter that is not live does not count. This is the selection -/// every metering path goes through: the cached status, gated by `admits` / -/// `admits_counter`. The first call in a process pays the liveness probe (on -/// macOS its own Energy Model subscription; then about a second of burning -/// CPU time and re-sampling on the calling thread); the answer is cached for -/// the process. -pub fn hardware_meter_available() -> bool { +/// The source [`measure_marginal`] meters with on this host: the first one +/// that is live, in order — on macOS this process's CPU energy counter +/// ([`process_energy_status`]), then IOReport ([`ioreport_status`]); on Linux +/// RAPL ([`rapl_status`]); elsewhere none. A readable counter that is not live +/// is never selected. The first call in a process pays the liveness probe of +/// each source it has to consult (about 0.3 s of burning CPU on the calling +/// thread for a live process counter; for IOReport its own Energy Model +/// subscription and about a second); each answer is cached for the process, +/// and a source ranked below a live one is not probed here. +pub fn meter_source() -> Option { #[cfg(target_os = "macos")] { - admits(ioreport_status()) + macos_source(process_energy_status(), ioreport_status) } #[cfg(target_os = "linux")] { - admits_counter(rapl_status()) + admits_counter(rapl_status()).then_some(MeterSource::Rapl) } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { - false + None } } +/// The macOS selection, pure: the process counter if it is live; otherwise +/// IOReport if its CPU rail is live; otherwise none. IOReport's status is +/// asked only when the process counter is not live. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn macos_source(process: CounterStatus, ioreport: impl FnOnce() -> IoReportStatus) -> Option { + if admits_counter(process) { + Some(MeterSource::ProcessEnergy) + } else if admits(ioreport()) { + Some(MeterSource::IoReport) + } else { + None + } +} + +/// Whether any hardware source is live on this machine: [`meter_source`] is +/// `Some`. On macOS that is this process's CPU energy counter or IOReport with +/// a live CPU energy rail; on Linux, RAPL with every package counter live. A +/// readable counter that is not live does not count. +pub fn hardware_meter_available() -> bool { + meter_source().is_some() +} + /// Why the IOReport interface is or is not used on this host. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IoReportStatus { @@ -294,10 +435,11 @@ fn host() -> &'static HostStatus { /// This host's IOReport status, probed once per process and cached. /// -/// On macOS, [`hardware_meter_available`] and [`measure_marginal_uj`] use -/// IOReport only when `admits` this — only when it is -/// [`IoReportStatus::Live`]. Exposed so a caller can say *why* an Apple -/// Silicon host has no hardware reading instead of guessing. Always +/// On macOS, [`meter_source`] (and through it [`measure_marginal`]) and +/// `power_over` use IOReport only when `admits` this — only when it is +/// [`IoReportStatus::Live`] — and the selection asks for it only when this +/// process's CPU energy counter, ranked first, is not live. Exposed so a +/// caller can say *why* IOReport is not used instead of guessing. Always /// `Unavailable` off macOS; it does not describe Linux RAPL. pub fn ioreport_status() -> IoReportStatus { #[cfg(target_os = "macos")] @@ -332,7 +474,8 @@ fn admits(status: IoReportStatus) -> bool { matches!(status, IoReportStatus::Live) } -/// Whether a vendor energy counter other than IOReport's — RAPL's package +/// Whether an energy counter other than IOReport's — this process's CPU +/// energy counter on macOS ([`process_energy_status`]), RAPL's package /// counters on Linux ([`rapl_status`]) — is live for the domain a figure from /// it would claim (SPEC §5). Decided by the same probe loop and the same /// sustained-advance rule as [`IoReportStatus`], once per process. @@ -341,13 +484,14 @@ pub enum CounterStatus { /// The counter could not be read: no such interface on this platform, no /// permission to read it (RAPL's `energy_uj` is root-only on current /// kernels), or never two consecutive reads within the probe's five - /// budgets. Never used. + /// budgets. Never used. (The process counter is never `Absent`: it is one + /// counter, read or not.) Unavailable, /// The interface was read, but holds no counter for the domain a figure /// would claim (RAPL: zones, but no `package-` zone). Never used. Absent, /// The counter did not keep advancing while the probe burned CPU — for - /// RAPL, at least one package counter did not: it held, or moved in a + /// RAPL, at least one package counter did not — it held, or moved in a /// few steps close together. A figure from it would not be a live /// reading. Never used. Frozen, @@ -361,7 +505,7 @@ pub enum CounterStatus { /// The status a probe run of a [`CounterStatus`] source earns: `Unavailable` /// when nothing could be opened, or nothing was read twice in a row (no /// verdict); otherwise its verdict, one to one. -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))] fn counter_status_of(run: &liveness::ProbeRun) -> CounterStatus { match (run.opened, run.verdict) { (false, _) | (true, None) => CounterStatus::Unavailable, @@ -372,7 +516,7 @@ fn counter_status_of(run: &liveness::ProbeRun) -> CounterStatus { } /// Whether a [`CounterStatus`] lets its source meter: only `Live`. -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))] fn admits_counter(status: CounterStatus) -> bool { matches!(status, CounterStatus::Live) } @@ -449,9 +593,9 @@ fn rapl_host_status() -> CounterStatus { /// This host's RAPL status, probed once per process and cached: whether every /// RAPL package counter (`intel-rapl:` zones named `package-`) kept -/// advancing while the probe burned CPU. On Linux, [`hardware_meter_available`] -/// and [`measure_marginal_uj`] use RAPL only when this is -/// [`CounterStatus::Live`]. The first call in a process pays the probe (about +/// advancing while the probe burned CPU. On Linux, [`meter_source`] (and +/// through it [`measure_marginal`]) and `rapl_power_over` use RAPL only when +/// this is [`CounterStatus::Live`]. The first call in a process pays the probe (about /// `RAPL_LIVENESS_BUDGET` of burning CPU and re-reading sysfs on the calling /// thread when the counters are readable; nothing when they are not). Always /// `Unavailable` off Linux. @@ -481,7 +625,7 @@ fn bracket_with( where F: Fn() + Send + Sync + Copy + 'static, { - let workers = std::thread::available_parallelism().map(|n| n.get().saturating_sub(1).clamp(1, 12)).unwrap_or(6); + let workers = spare_cores(); let idle_w = idle_power()?; @@ -510,6 +654,208 @@ where Some((idle_w, busy_w?, window, runs)) } +// --------------------------------------------------------------------------- +// This process's CPU energy counter (macOS): its liveness, cached once per +// process, and the measurement that reads it around the work. +// --------------------------------------------------------------------------- + +/// How long (wall time) the process-counter liveness probe watches the counter +/// before concluding it is frozen: the IOReport probe's budget, ticks and caps. +/// The counter changes every ~0.5–0.7 ms under load on an M5 Max (macOS +/// 27.0.1), so a live counter is called live at the first interval past 300 ms. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const PROCESS_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); + +/// How long the process-counter measurement runs `work` on every spare core. +/// The counter is read before the workers start and after they are joined, +/// so the window only sets how many calls the figure averages over. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const PROCESS_WINDOW: std::time::Duration = std::time::Duration::from_millis(1000); + +/// The name the process counter has in the sample the liveness rule is handed. +const PROCESS_RAIL: &str = "process CPU energy"; + +/// Whether a sample channel is the process counter. +fn is_process_rail(name: &str) -> bool { + name == PROCESS_RAIL +} + +/// The liveness rule over the process counter: the one counter must keep +/// advancing, by the same sustained-advance rule as every other source. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const PROCESS_COUNTER: liveness::Watched = liveness::Watched { pick: is_process_rail }; + +/// One read of the process counter (cumulative nJ) as the rule is handed it. +fn process_read(nj: u64) -> liveness::Read { + liveness::Read { + sample: vec![(PROCESS_RAIL.to_string(), i64::try_from(nj).unwrap_or(i64::MAX))], + listed: liveness::Listed { channels: 1, group_only: true }, + } +} + +/// The process-counter liveness probe on its parts: `counter` reads this +/// process's cumulative CPU energy (nJ), and the tick and clocks are as for +/// IOReport. It opens when the counter reads, and hands the rule every read +/// ([`PROCESS_COUNTER`]). A read that fails is a failed read. Generic, so the +/// tests run this very composition on scripted counters on every platform. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn process_probe_with( + counter: impl Fn() -> Option, + tick: impl FnMut(), + wall: impl Fn() -> std::time::Duration, + cpu: impl Fn() -> Option, + budget: std::time::Duration, +) -> liveness::ProbeRun { + liveness::probe_watching( + PROCESS_COUNTER, + || counter().map(|_| ()), + |_: &()| counter().map(process_read), + tick, + wall, + cpu, + budget, + ) +} + +/// How many times this process has run the production process-counter +/// liveness probe — once at most (always 0 off macOS). Exposed for the +/// once-per-process tests; not part of the meter's interface. +#[doc(hidden)] +pub fn real_process_probe_runs() -> usize { + #[cfg(target_os = "macos")] + { + REAL_PROCESS_PROBE_RUNS.load(Ordering::SeqCst) + } + #[cfg(not(target_os = "macos"))] + { + 0 + } +} + +#[cfg(target_os = "macos")] +static REAL_PROCESS_PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// THE production process-counter composition: [`process_probe_with`] on the +/// real counter (`proc_pid_rusage` for this pid), the thread-CPU tick and the +/// clocks, and the status of its run. Counts itself first; reached in +/// production only through the cache in [`process_energy_status`]. +#[cfg(target_os = "macos")] +fn process_host_status() -> CounterStatus { + REAL_PROCESS_PROBE_RUNS.fetch_add(1, Ordering::SeqCst); + counter_status_of(&process_probe_with( + macos_process::energy_nj, + probe_clock::spin_tick, + probe_clock::wall_clock, + probe_clock::thread_cpu_time, + PROCESS_LIVENESS_BUDGET, + )) +} + +/// This host's process-counter status, probed once per process and cached: +/// whether this process's cumulative CPU energy counter (`proc_pid_rusage`, +/// `RUSAGE_INFO_V6`, `ri_energy_nj`) kept advancing while the probe burned +/// CPU. On macOS [`meter_source`] selects it first when this is +/// [`CounterStatus::Live`]. The first call pays the probe (about 0.3 s of +/// burning CPU on the calling thread for a live counter, up to about a second +/// for a frozen one). Always `Unavailable` off macOS. +pub fn process_energy_status() -> CounterStatus { + #[cfg(target_os = "macos")] + { + static PROCESS: std::sync::OnceLock = std::sync::OnceLock::new(); + *PROCESS.get_or_init(process_host_status) + } + #[cfg(not(target_os = "macos"))] + { + CounterStatus::Unavailable + } +} + +/// A test hook, not a setting: from now on this process's CPU energy counter +/// reads as the value it held at its first read after this call, as a frozen +/// counter would. A test in a process of its own uses it to drive the whole +/// production chain — probe, status, cache, selection, metering — down the +/// frozen path, which no Mac that runs the tests reaches otherwise: the +/// status must be `Frozen` and the process counter must never be selected. +/// It takes effect only before the first use of [`process_energy_status`], +/// which caches its answer for the process. It can only turn the meter off: a +/// counter that holds is never live. A no-op off macOS. +#[doc(hidden)] +pub fn simulate_frozen_process_energy() { + #[cfg(target_os = "macos")] + macos_process::FROZEN.store(true, Ordering::SeqCst); +} + +/// The process-counter rung: `work` on every spare core, metered by this +/// process's CPU energy counter — only while it is live (its own gate, on the +/// cached status). `None` off macOS. +fn process_marginal_uj(work: F) -> Option +where + F: Fn() + Send + Sync + Copy + 'static, +{ + #[cfg(target_os = "macos")] + { + if !admits_counter(process_energy_status()) { + return None; + } + process_marginal_with(work, macos_process::energy_nj, PROCESS_WINDOW) + } + #[cfg(not(target_os = "macos"))] + { + let _ = work; + None + } +} + +/// How many workers run `work`: every core but the calling thread's, at +/// least one, at most twelve. +fn spare_cores() -> usize { + std::thread::available_parallelism().map(|n| n.get().saturating_sub(1).clamp(1, 12)).unwrap_or(6) +} + +/// Run `work` on every spare core for `window`, reading `counter` — this +/// process's cumulative CPU energy in nJ — before the workers start and after +/// every one of them has been joined, and return the energy per call in µJ, +/// rounded to the nearest: the counter's advance ÷ every call that completed. +/// Reading after the join puts the energy of every counted call, and only +/// those calls' window, in the figure; a call still running when `window` +/// ends finishes and is counted. `None` if either read fails (the work never +/// runs when the first one does), if the counter went down, or if no call +/// completed. Generic over the counter, so the tests drive it on a scripted +/// counter. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn process_marginal_with(work: F, counter: impl Fn() -> Option, window: std::time::Duration) -> Option +where + F: Fn() + Send + Sync + Copy + 'static, +{ + let before = counter()?; + let stop = Arc::new(AtomicBool::new(false)); + let calls = Arc::new(AtomicU64::new(0)); + let handles: Vec<_> = (0..spare_cores()) + .map(|_| { + let (stop, calls) = (stop.clone(), calls.clone()); + std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + work(); + calls.fetch_add(1, Ordering::Relaxed); + } + }) + }) + .collect(); + std::thread::sleep(window); + stop.store(true, Ordering::Relaxed); + for h in handles { + let _ = h.join(); + } + let runs = calls.load(Ordering::SeqCst); + let after = counter()?; + if runs == 0 { + return None; + } + let nj = u128::from(after.checked_sub(before)?); + let runs = u128::from(runs); + u64::try_from((nj + runs * 500) / (runs * 1000)).ok() +} + // --------------------------------------------------------------------------- // Counter liveness: the decision, the probe loop and the record it keeps, // what an unopenable subscription means, which channels a read keeps, and @@ -2022,6 +2368,182 @@ mod probe_clock { } } +// --------------------------------------------------------------------------- +// macOS — this process's CPU energy counter: `proc_pid_rusage(getpid(), +// RUSAGE_INFO_V6)`, public libproc API, readable by an unprivileged process. +// --------------------------------------------------------------------------- +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] +mod macos_process { + use std::os::raw::{c_int, c_void}; + use std::sync::atomic::{AtomicBool, Ordering}; + + /// `struct rusage_info_v6` from `` (macOS 27.0 SDK), field + /// for field: 16 bytes of UUID then 56 `uint64_t`, 464 bytes, with + /// `ri_energy_nj` at byte 336. The kernel writes the whole struct for + /// `RUSAGE_INFO_V6`, so the buffer must be at least this large; a test + /// pins the size and the offset against the header's own numbers. + #[repr(C)] + #[allow(dead_code)] + pub(super) 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, + /// The CPU energy the kernel attributes to this process's threads, + /// cumulative, in nanojoules — exited threads included. + pub(super) 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], + } + + /// `RUSAGE_INFO_V6` (``). + const RUSAGE_INFO_V6: c_int = 6; + + unsafe extern "C" { + /// ``: `int proc_pid_rusage(int pid, int flavor, + /// rusage_info_t *buffer)`; 0 on success. + fn proc_pid_rusage(pid: c_int, flavor: c_int, buffer: *mut c_void) -> c_int; + } + + /// Set by [`super::simulate_frozen_process_energy`]. + pub(super) static FROZEN: AtomicBool = AtomicBool::new(false); + + /// This process's cumulative CPU energy in nanojoules: `ri_energy_nj` of + /// `proc_pid_rusage(, RUSAGE_INFO_V6)`. `None` if the call + /// fails. Under [`super::simulate_frozen_process_energy`], the value of + /// the first read after it, forever. + pub(super) fn energy_nj() -> Option { + if FROZEN.load(Ordering::SeqCst) { + static HELD: std::sync::OnceLock> = std::sync::OnceLock::new(); + return *HELD.get_or_init(read_energy_nj); + } + read_energy_nj() + } + + fn read_energy_nj() -> Option { + let pid = c_int::try_from(std::process::id()).ok()?; + let mut info = std::mem::MaybeUninit::::zeroed(); + // SAFETY: the buffer is a zeroed, writable `rusage_info_v6`, the size + // the kernel writes for RUSAGE_INFO_V6; it is read only on success, + // and every field is a plain integer. + let rc = unsafe { proc_pid_rusage(pid, RUSAGE_INFO_V6, info.as_mut_ptr().cast()) }; + (rc == 0).then(|| unsafe { info.assume_init() }.ri_energy_nj) + } + + #[cfg(test)] + mod tests { + use super::*; + use std::time::{Duration, Instant}; + + /// The header's numbers, as the tests' own literals: `rusage_info_v6` + /// is 16 bytes of UUID and 56 `uint64_t` (464 bytes), and + /// `ri_energy_nj` is the 41st `uint64_t` (byte 16 + 40 × 8 = 336). + #[test] + fn the_rusage_struct_matches_the_header() { + assert_eq!(std::mem::size_of::(), 464, "sizeof(struct rusage_info_v6)"); + assert_eq!(std::mem::offset_of!(RusageInfoV6, ri_energy_nj), 336, "offsetof(ri_energy_nj)"); + assert_eq!(std::mem::offset_of!(RusageInfoV6, ri_penergy_nj), 344, "offsetof(ri_penergy_nj)"); + } + + // ── The tests' own reading of the counter ────────────────────────── + + #[allow(clashing_extern_declarations)] + unsafe extern "C" { + #[link_name = "proc_pid_rusage"] + fn oracle_proc_pid_rusage(pid: c_int, flavor: c_int, buffer: *mut [u64; 58]) -> c_int; + #[link_name = "getpid"] + fn oracle_getpid() -> c_int; + } + + /// This process's `ri_energy_nj`, read by the tests themselves: their + /// own binding, their own pid (`getpid`), flavor 6, and a raw 464-byte + /// buffer read at byte 336 (the 43rd `uint64_t` of the buffer). + fn oracle_energy_nj() -> u64 { + let mut buf = [0u64; 58]; + // SAFETY: buf is 464 writable bytes, the size of rusage_info_v6. + let rc = unsafe { oracle_proc_pid_rusage(oracle_getpid(), 6, &mut buf) }; + assert_eq!(rc, 0, "the tests' own proc_pid_rusage(getpid(), 6) failed"); + buf[42] + } + + fn burn(d: Duration) { + let t = Instant::now(); + let mut acc = 0u64; + while t.elapsed() < d { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + } + + /// The FFI value pin: the production reader reads THIS process's + /// counter, fresh, every time. Each round brackets one production read + /// between two of the tests' own reads; the production value must lie + /// between them (the counter only goes up). Between rounds this thread + /// burns 50 ms, so the counter must advance, and the production reads + /// with it. A reader of another pid, a constant, or a stale first read + /// fails here. + #[test] + fn the_reader_reads_this_processs_counter_as_the_oracle_does() { + let mut prod = Vec::new(); + for round in 1..=3 { + let lo = oracle_energy_nj(); + let v = energy_nj().expect("the production reader reads this process's counter"); + let hi = oracle_energy_nj(); + eprintln!("process ffi pin: round={round} oracle_before={lo} production={v} oracle_after={hi}"); + assert!(lo <= v && v <= hi, "round {round}: the production read {v} is outside the oracle's [{lo}, {hi}]"); + prod.push(v); + burn(Duration::from_millis(50)); + } + assert!(prod.windows(2).all(|w| w[1] > w[0]), "the production reads did not advance across 50 ms burns: {prod:?}"); + } + } +} + // --------------------------------------------------------------------------- // Linux RAPL: the energy between two reads of the powercap zones, and what // the liveness rule is handed from one read, kept apart from the sysfs reads @@ -2469,12 +2991,13 @@ mod macos_ioreport { /// is live: [`super::ioreport_status`] is `Live`, meaning the probe saw that /// rail advance in at least five sample intervals, in at least 80% of them, /// over at least 300 ms, while it burned CPU. `None` if IOReport is - /// unavailable, its CPU energy rail is not live (the selection, - /// [`super::hardware_meter_available`], declines), or the signal is empty. + /// unavailable, its CPU energy rail is not live (`admits` declines the + /// cached status), or the signal is empty. pub fn power_over(d: Duration) -> Option { // Declined before sampling: on a frozen CPU rail the summed delta below - // would be GPU energy, or zero, attributed to CPU work. - if !super::hardware_meter_available() { + // would be GPU energy, or zero, attributed to CPU work. IOReport's own + // gate, whatever source ranks above it. + if !super::admits(super::ioreport_status()) { return None; } unsafe { @@ -2653,8 +3176,8 @@ mod macos_ioreport { use std::time::Instant; use crate::meter::liveness::{CpuRail, Listed, ProbeRun, RailKey}; use crate::meter::{ - admits, hardware_meter_available, host, host_status, ioreport_status, measure_marginal_uj, status_of, - HostStatus, IoReportStatus, CPU_RAIL_LIVENESS_BUDGET, + admits, bracket_admitted, bracket_marginal_uj, host, host_status, ioreport_status, meter_source, status_of, + HostStatus, IoReportStatus, MeterSource, CPU_RAIL_LIVENESS_BUDGET, }; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -3279,7 +3802,11 @@ mod macos_ioreport { /// The property that matters: the IOReport meter is **never** available /// when its CPU rail is frozen, because then it would bracket GPU-only - /// energy, zero, or a batch refresh as the energy of CPU work. + /// energy, zero, or a batch refresh as the energy of CPU work. "The + /// IOReport meter" is the bracket rung's own gate (`bracket_admitted`), + /// which `power_over` and the bracket go through whatever source ranks + /// above IOReport, and the selection never names IOReport unless it + /// is admitted. /// Deterministic on any host — it asserts the meter's decision against /// what the rail actually did, and every branch asserts something. /// @@ -3295,7 +3822,7 @@ mod macos_ioreport { let status = ioreport_status(); let cached = host(); let run = &cached.run; - let selected = hardware_meter_available(); + let selected = bracket_admitted(); let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); // Recorded so a log of a macOS run (manual, or a CI job that runs // this crate's lib tests with --nocapture) says which branch below @@ -3343,9 +3870,11 @@ mod macos_ioreport { let want = if run.intervals > 0 { IoReportStatus::CpuRailFrozen } else { IoReportStatus::Unavailable }; assert_eq!(status, want, "probe intervals {}", run.intervals); - // Declined end to end: no figure comes out of a frozen rail. + // Declined end to end: no figure comes out of a frozen rail, + // and the selection never names it. assert_eq!(power_over(Duration::from_millis(200)), None, "watts read from a frozen CPU rail"); - assert_eq!(measure_marginal_uj(cpu_work), None, "energy metered from a frozen CPU rail"); + assert_eq!(bracket_marginal_uj(cpu_work), None, "energy metered from a frozen CPU rail"); + assert_ne!(meter_source(), Some(MeterSource::IoReport), "a frozen CPU rail was selected"); } Oracle::CpuRailMoved(true) => { // Live rails. The probe had a third of the oracle's time: @@ -3362,11 +3891,11 @@ mod macos_ioreport { } } } - // On macOS IOReport is the only interface: selected exactly when Live. + // The IOReport rung is admitted exactly when Live. assert_eq!( selected, status == IoReportStatus::Live, - "hardware_meter_available() disagrees with ioreport_status() = {status:?}" + "bracket_admitted() disagrees with ioreport_status() = {status:?}" ); } @@ -4022,7 +4551,7 @@ mod macos_ioreport { #[test] fn power_over_declines_unless_the_cpu_rail_is_live() { let w = power_over(Duration::from_millis(300)); - if hardware_meter_available() { + if bracket_admitted() { assert!( matches!(w, Some(w) if w > 0.0 && w < 500.0), "a live CPU rail gave implausible SoC watts: {w:?}" @@ -4037,25 +4566,25 @@ mod macos_ioreport { } } - /// Every gated entry point takes its status from the cache. Once this - /// thread has the status, the selection costs it under 10 ms of its own - /// CPU (tests' clock), and on a declined host so do `power_over` and - /// `measure_marginal_uj`, which must give no figure. Re-running the + /// Every gated IOReport entry point takes its status from the cache. + /// Once this thread has the status, the IOReport rung's gate costs it + /// under 10 ms of its own CPU (tests' clock), and on a declined host so + /// do `power_over` and the bracket, which must give no figure. Re-running the /// probe by any route costs at least six reads of the Energy Model /// group and five 25 ms ticks of this thread's CPU, and on a frozen /// rail about a second of it. Thread CPU, not wall time, so host load and /// other IOReport readers cannot fail it. On a live host `power_over` - /// and `measure_marginal_uj` do real sampling work, so there only the - /// selection is timed and the status must be `Live`. + /// and the bracket do real sampling work, so there only the gate is + /// timed and the status must be `Live`. #[test] fn the_gated_entry_points_read_the_cached_status() { const WARM: Duration = Duration::from_millis(10); let status = ioreport_status(); let c0 = test_thread_cpu(); - let selected = hardware_meter_available(); + let selected = bracket_admitted(); let selection = test_thread_cpu() - c0; eprintln!("ioreport warm: status={status:?} selected={selected} selection burned {selection:?}"); - assert!(selection < WARM, "hardware_meter_available() burned {selection:?} of this thread's CPU after the status was cached"); + assert!(selection < WARM, "bracket_admitted() burned {selection:?} of this thread's CPU after the status was cached"); assert_eq!(selected, admits(status), "the selection is not admits(the cached status)"); if selected { assert_eq!(status, IoReportStatus::Live, "selected on {status:?}"); @@ -4065,12 +4594,12 @@ mod macos_ioreport { let watts = power_over(Duration::from_millis(200)); let power = test_thread_cpu() - c0; let c0 = test_thread_cpu(); - let figure = measure_marginal_uj(cpu_work); + let figure = bracket_marginal_uj(cpu_work); let metering = test_thread_cpu() - c0; - eprintln!("ioreport warm: declined power_over={watts:?} burned {power:?}; measure_marginal_uj={figure:?} burned {metering:?}"); + eprintln!("ioreport warm: declined power_over={watts:?} burned {power:?}; bracket={figure:?} burned {metering:?}"); assert_eq!((watts, figure), (None, None), "a declined meter ({status:?}) gave a figure"); assert!(power < WARM, "a declined power_over burned {power:?} of this thread's CPU: it probed again"); - assert!(metering < WARM, "a declined measure_marginal_uj burned {metering:?} of this thread's CPU: it probed again"); + assert!(metering < WARM, "a declined bracket burned {metering:?} of this thread's CPU: it probed again"); } /// When the meter IS selected, a multi-core CPU burst meters as non-zero @@ -4078,14 +4607,14 @@ mod macos_ioreport { /// and no figure comes out. No branch goes unasserted. #[test] fn a_selected_ioreport_meter_meters_cpu_work() { - if hardware_meter_available() { + if bracket_admitted() { assert_eq!(ioreport_status(), IoReportStatus::Live, "selected on a CPU rail that is not live"); assert_eq!( host_status().status, IoReportStatus::Live, "the CPU rail stopped advancing after the meter was selected" ); - let uj = measure_marginal_uj(cpu_work); + let uj = bracket_marginal_uj(cpu_work); assert!( matches!(uj, Some(u) if u > 0), "a live CPU rail metered a multi-core CPU burst as {uj:?}" @@ -4096,7 +4625,7 @@ mod macos_ioreport { IoReportStatus::Live, "status Live but the IOReport meter was not selected" ); - assert_eq!(measure_marginal_uj(cpu_work), None, "a declined IOReport meter produced a figure"); + assert_eq!(bracket_marginal_uj(cpu_work), None, "a declined IOReport meter produced a figure"); } } } @@ -4130,7 +4659,7 @@ fn busy_power() -> Option { fn rapl_power_over(d: std::time::Duration) -> Option { // Declined before reading: a counter that is readable but not live must // not produce a hardware-graded figure. - if !hardware_meter_available() { + if !admits_counter(rapl_status()) { return None; } let z0 = rapl::packages(&rapl_zones()); @@ -4402,6 +4931,185 @@ mod tests { assert_eq!(status, IoReportStatus::CpuRailFrozen); } + // ── The process counter: the composition, the measurement, the order ── + + /// Read `n` of a scripted process counter (cumulative nJ), or `None`. + type CounterScript = fn(u32) -> Option; + + /// [`process_probe_with`] — the composition `process_host_status` runs — + /// on a scripted counter, with a tick that advances a scripted wall clock + /// and CPU clock by 25 ms and counts itself. Returns the status, the run, + /// and how many times the counter was read and the probe ticked. + fn process_composed(script: CounterScript) -> (CounterStatus, ProbeRun, u32, u32) { + let (clock, cpu) = (Cell::new(Duration::ZERO), Cell::new(Duration::ZERO)); + let (reads, ticks) = (Cell::new(0u32), Cell::new(0u32)); + let run = process_probe_with( + || { + let n = reads.get(); + reads.set(n + 1); + script(n) + }, + || { + ticks.set(ticks.get() + 1); + clock.set(clock.get() + Duration::from_millis(25)); + cpu.set(cpu.get() + Duration::from_millis(25)); + }, + || clock.get(), + || Some(cpu.get()), + PROCESS_LIVENESS_BUDGET, + ); + (counter_status_of(&run), run, reads.get(), ticks.get()) + } + + /// A process counter that advances at every read is Live at the twelfth + /// 25 ms interval (five advances, 80%, 300 ms) and admitted, and the rule + /// was handed each value as read. The open is one read. + #[test] + fn process_a_counter_advancing_at_every_read_is_live() { + let (status, run, reads, ticks) = process_composed(|n| Some(48_564_621 + 43_868_912 * u64::from(n))); + assert_eq!(status, CounterStatus::Live, "{run:?}"); + assert!(admits_counter(status)); + assert_eq!((reads, ticks, run.intervals), (1 + 13, 12, 12)); + let handed: Vec = run.seen.iter().map(|s| s.cpu[0].1).collect(); + assert_eq!(handed, (1..=13).map(|n| 48_564_621 + 43_868_912 * n).collect::>()); + } + + /// A counter that holds, steps once, or steps twice close together is + /// Frozen after the whole budget, and never admitted. + #[test] + fn process_a_constant_or_stepping_counter_is_frozen() { + for (what, script) in [ + ("constant", (|_| Some(48_564_621)) as CounterScript), + ("one step", |n| Some(if n < 6 { 48_564_621 } else { 920_000_000 })), + ("two steps", |n| Some(48_564_621 + if n >= 6 { 61_764_485 } else { 0 } + if n >= 8 { 3_435 } else { 0 })), + ("four steps then a hold", |n| Some(48_564_621 + 1_000 * u64::from(n.min(4)))), + ] { + let (status, run, reads, ticks) = process_composed(script); + assert_eq!(status, CounterStatus::Frozen, "{what}: {run:?}"); + assert!(!admits_counter(status), "{what}"); + assert_eq!((reads, ticks, run.intervals), (1 + 41, 40, 40), "{what}"); + } + } + + /// A counter that cannot be read is `Unavailable` with no probe read and + /// no tick; one that reads at the open and never after gives no verdict, + /// `Unavailable`, after the five-budget cap. + #[test] + fn process_an_unreadable_counter_is_unavailable() { + let (status, run, reads, ticks) = process_composed(|_| None); + assert_eq!(status, CounterStatus::Unavailable); + assert!(!run.opened && run.calls.is_empty(), "{run:?}"); + assert_eq!((reads, ticks), (1, 0)); + let (status, run, _, ticks) = process_composed(|n| (n == 0).then_some(1)); + assert_eq!(status, CounterStatus::Unavailable, "{run:?}"); + assert!(run.opened && run.verdict.is_none()); + assert_eq!(ticks, 200, "five budgets of 25 ms ticks"); + } + + /// The measurement on a scripted counter that each call of `work` raises + /// by exactly 7,000 nJ: the figure is exactly 7 uJ a call, which holds + /// only if the counter is read before the first call and after the last + /// one is counted. Reading it while workers still run, or counting calls + /// before they are joined, misses calls on one side and not the other. + #[test] + fn process_the_measurement_is_the_counters_advance_over_every_call() { + static NJ: AtomicU64 = AtomicU64::new(1_000_000); + fn seven_uj() { + std::hint::black_box((0..2000u64).fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b))); + NJ.fetch_add(7_000, Ordering::SeqCst); + } + let reads = std::sync::Mutex::new(Vec::new()); + let r = process_marginal_with( + seven_uj, + || { + let v = NJ.load(Ordering::SeqCst); + reads.lock().unwrap().push(v); + Some(v) + }, + Duration::from_millis(200), + ); + let reads = reads.into_inner().unwrap(); + assert_eq!(reads.len(), 2, "one read before, one after: {reads:?}"); + assert_eq!(r, Some(7), "7,000 nJ a call is 7 uJ: reads {reads:?}"); + // Rounded to the nearest microjoule: 7,499 nJ is 7, 7,500 is 8. + static NJ2: AtomicU64 = AtomicU64::new(0); + fn just_under() { + NJ2.fetch_add(7_499, Ordering::SeqCst); + } + assert_eq!(process_marginal_with(just_under, || Some(NJ2.load(Ordering::SeqCst)), Duration::from_millis(50)), Some(7)); + static NJ3: AtomicU64 = AtomicU64::new(0); + fn half_up() { + NJ3.fetch_add(7_500, Ordering::SeqCst); + } + assert_eq!(process_marginal_with(half_up, || Some(NJ3.load(Ordering::SeqCst)), Duration::from_millis(50)), Some(8)); + } + + /// No figure — never an invented one — from a counter that cannot be read + /// (and then the work never runs) or that went down. + #[test] + fn process_an_unreadable_or_falling_counter_gives_no_figure() { + static CALLS: AtomicU64 = AtomicU64::new(0); + fn counted() { + CALLS.fetch_add(1, Ordering::SeqCst); + } + assert_eq!(process_marginal_with(counted, || None, Duration::from_millis(50)), None); + assert_eq!(CALLS.load(Ordering::SeqCst), 0, "the work ran although the counter could not be read"); + let n = Cell::new(0u32); + let falling = || { + n.set(n.get() + 1); + Some(if n.get() == 1 { 9_000_000 } else { 1_000 }) + }; + assert_eq!(process_marginal_with(counted, falling, Duration::from_millis(50)), None, "a counter that went down"); + let m = Cell::new(0u32); + let fails_after = || { + m.set(m.get() + 1); + (m.get() == 1).then_some(5) + }; + assert_eq!(process_marginal_with(counted, fails_after, Duration::from_millis(50)), None, "the second read failed"); + } + + /// The macOS order: the process counter if it is live — and then + /// IOReport is never asked — else IOReport if its CPU rail is live, else + /// nothing. A process counter that is not live never wins. + #[test] + fn the_process_counter_ranks_first_and_only_when_live() { + let never = || -> IoReportStatus { panic!("IOReport was asked although the process counter is live") }; + assert_eq!(macos_source(CounterStatus::Live, never), Some(MeterSource::ProcessEnergy)); + for process in [CounterStatus::Unavailable, CounterStatus::Absent, CounterStatus::Frozen] { + assert_eq!(macos_source(process, || IoReportStatus::Live), Some(MeterSource::IoReport), "{process:?}"); + for ioreport in [IoReportStatus::Unavailable, IoReportStatus::CpuRailAbsent, IoReportStatus::CpuRailFrozen] { + assert_eq!(macos_source(process, || ioreport), None, "{process:?} / {ioreport:?}"); + } + } + } + + /// Every source grades its figure `HwShunt`, with a measured figure, so + /// the schema's coupling holds; each names its own domain, and the + /// process counter's says what it leaves out. + #[test] + fn every_source_grades_hwshunt_and_names_its_domain() { + for source in [MeterSource::ProcessEnergy, MeterSource::IoReport, MeterSource::Rapl] { + let e = Measurement { uj: 42, source }.energy(); + assert_eq!((e.measured_uj, e.provenance), (Some(42), proof_schema::Provenance::HwShunt), "{source:?}"); + assert!(e.check_provenance().is_ok(), "{source:?}"); + } + let d = MeterSource::ProcessEnergy.domain(); + for left_out in ["other processes", "child processes", "GPU", "ANE", "DRAM"] { + assert!(d.contains(left_out), "the process counter's domain does not say it leaves out {left_out}: {d}"); + } + } + + /// The process counter is a macOS interface: off macOS its status says + /// so, no probe runs, and the hook is a no-op. + #[cfg(not(target_os = "macos"))] + #[test] + fn process_energy_is_unavailable_off_macos() { + simulate_frozen_process_energy(); + assert_eq!(process_energy_status(), CounterStatus::Unavailable); + assert_eq!(real_process_probe_runs(), 0); + assert_ne!(meter_source(), Some(MeterSource::ProcessEnergy)); + } + // ── RAPL: the production composition on scripted zones ──────────────── /// Read `n` of a scripted RAPL run: every top-level zone it lists, as diff --git a/proof/proof-rs/crates/proof-cascade/tests/ioreport_once.rs b/proof/proof-rs/crates/proof-cascade/tests/ioreport_once.rs index f159810ed..4107db866 100644 --- a/proof/proof-rs/crates/proof-cascade/tests/ioreport_once.rs +++ b/proof/proof-rs/crates/proof-cascade/tests/ioreport_once.rs @@ -1,19 +1,21 @@ -//! The IOReport liveness probe behind `ioreport_status()` runs once per -//! PROCESS, however many threads ask at once, through the status, the -//! selection and the metering entry point: a probe per call or per thread -//! would burn ~1 s on each caller's thread (inside `measure_marginal_uj`'s -//! bracket, a whole probe in the busy window) and could flip the verdict -//! mid-process. (`power_over` is private; the lib test +//! The liveness probes behind `ioreport_status()` and +//! `process_energy_status()` each run once per PROCESS, however many threads +//! ask at once, through the statuses, the selection and the metering entry +//! point: a probe per call or per thread would burn up to ~1 s on each +//! caller's thread (inside a measurement, a whole probe in its window) and +//! could flip the verdict mid-process. (`power_over` is private; the lib test //! `the_gated_entry_points_read_the_cached_status` pins it the same way, on //! the calling thread's CPU.) //! //! This file holds this one test, so that it runs in a process of its own and -//! IS the first use of the meter in that process. The counter it reads, -//! `real_probe_runs()`, counts runs of the production probe (`host_status`, -//! whose first statement it is), not lookups of the cache in front of it. +//! IS the first use of the meter in that process. The counters it reads, +//! `real_probe_runs()` and `real_process_probe_runs()`, count runs of the +//! production probes (`host_status`, `process_host_status`, whose first +//! statements they are), not lookups of the caches in front of them. use proof_cascade::meter::{ - hardware_meter_available, ioreport_status, measure_marginal_uj, real_probe_runs, IoReportStatus, + hardware_meter_available, ioreport_status, measure_marginal_uj, meter_source, process_energy_status, + real_probe_runs, real_process_probe_runs, CounterStatus, IoReportStatus, MeterSource, }; use std::sync::{Arc, Barrier}; use std::time::Duration; @@ -46,17 +48,18 @@ fn thread_cpu() -> Duration { Duration::ZERO } -/// Eight threads, released together, each ask for the status and then select -/// the meter the way a caller does (`hardware_meter_available()` is the -/// selection; `measure_marginal_uj` selects and, declined, gives no figure). -/// The probe must have run exactly once for all of them. Once a thread has its -/// status, its selection calls must be warm: together they may not burn 10 ms -/// of that thread's CPU, where re-running the probe by any route costs at -/// least six reads of the Energy Model group (~7 ms of thread CPU each) and -/// five 25 ms ticks, and normally ~1 s of burn. +/// Eight threads, released together, each ask for both statuses and then +/// select the meter the way a caller does (`meter_source()` and +/// `hardware_meter_available()` are the selection; `measure_marginal_uj` +/// selects and, declined, gives no figure). Each probe must have run exactly +/// once for all of them. Once a thread has its statuses, its selection calls +/// must be warm: together they may not burn 10 ms of that thread's CPU, where +/// re-running a probe by any route costs at least five 25 ms ticks, and +/// normally 0.3–1 s of burn. #[test] fn the_liveness_probe_runs_once_per_process() { assert_eq!(real_probe_runs(), 0, "the meter was used in this process before this test: this is not its first use"); + assert_eq!(real_process_probe_runs(), 0, "the process counter was used in this process before this test"); const THREADS: usize = 8; let gate = Arc::new(Barrier::new(THREADS)); let handles: Vec<_> = (0..THREADS) @@ -65,42 +68,54 @@ fn the_liveness_probe_runs_once_per_process() { std::thread::spawn(move || { gate.wait(); let status = ioreport_status(); + let process = process_energy_status(); let c0 = thread_cpu(); + let source = meter_source(); let selected = hardware_meter_available(); - let again = ioreport_status(); + let again = (ioreport_status(), process_energy_status()); let figure = if selected { None } else { measure_marginal_uj(cpu_work) }; let warm = thread_cpu() - c0; - (status, again, selected, figure, warm) + (status, process, source, again, selected, figure, warm) }) }) .collect(); let seen: Vec<_> = handles.into_iter().map(|h| h.join().expect("a probing thread panicked")).collect(); - let runs = real_probe_runs(); - let warmest = seen.iter().map(|s| s.4).max().unwrap_or_default(); + let (runs, process_runs) = (real_probe_runs(), real_process_probe_runs()); + let warmest = seen.iter().map(|s| s.6).max().unwrap_or_default(); + let (status, process, source) = (seen[0].0, seen[0].1, seen[0].2); eprintln!( - "ioreport cache: {THREADS} threads saw (status={:?}, selected={}); real probe runs in this process: {runs}; \ - warm selection calls burned at most {warmest:?} of a thread's CPU", - seen[0].0, seen[0].2 + "ioreport cache: {THREADS} threads saw (ioreport={status:?}, process={process:?}, source={source:?}); real \ + probe runs in this process: ioreport {runs}, process {process_runs}; warm selection calls burned at most \ + {warmest:?} of a thread's CPU" ); - for (i, (status, again, selected, figure, _)) in seen.iter().enumerate() { - assert_eq!((*status, *again), (seen[0].0, seen[0].0), "thread {i} was given a different status"); - assert_eq!(*selected, seen[0].2, "thread {i} made a different selection"); + for (i, (s, p, src, again, selected, figure, _)) in seen.iter().enumerate() { + assert_eq!((*s, *p, *again), (status, process, (status, process)), "thread {i} was given a different status"); + assert_eq!((*src, *selected), (source, source.is_some()), "thread {i} made a different selection"); if !selected { assert_eq!(*figure, None, "thread {i}: a declined meter gave a figure"); } } if cfg!(target_os = "macos") { - assert_eq!(seen[0].2, seen[0].0 == IoReportStatus::Live, "on macOS the meter is selected exactly when Live"); - assert_eq!(runs, 1, "the liveness probe ran {runs} times in this process ({THREADS} threads asked at once)"); + let want = if process == CounterStatus::Live { + Some(MeterSource::ProcessEnergy) + } else if status == IoReportStatus::Live { + Some(MeterSource::IoReport) + } else { + None + }; + assert_eq!(source, want, "on macOS: the process counter if Live, else IOReport if Live, else none"); + assert_eq!(runs, 1, "the IOReport probe ran {runs} times in this process ({THREADS} threads asked at once)"); + assert_eq!(process_runs, 1, "the process-counter probe ran {process_runs} times ({THREADS} threads asked at once)"); for (i, s) in seen.iter().enumerate() { assert!( - s.4 < Duration::from_millis(10), + s.6 < Duration::from_millis(10), "thread {i}: its warm selection calls burned {:?} of its CPU: something probed again, uncached", - s.4 + s.6 ); } } else { - assert_eq!(seen[0].0, IoReportStatus::Unavailable, "IOReport off macOS"); - assert_eq!(runs, 0, "a liveness probe ran off macOS"); + assert_eq!(status, IoReportStatus::Unavailable, "IOReport off macOS"); + assert_eq!(process, CounterStatus::Unavailable, "a process counter off macOS"); + assert_eq!((runs, process_runs), (0, 0), "a liveness probe ran off macOS"); } } diff --git a/proof/proof-rs/crates/proof-cascade/tests/ioreport_unopenable.rs b/proof/proof-rs/crates/proof-cascade/tests/ioreport_unopenable.rs index 829a0ec88..6e4297f67 100644 --- a/proof/proof-rs/crates/proof-cascade/tests/ioreport_unopenable.rs +++ b/proof/proof-rs/crates/proof-cascade/tests/ioreport_unopenable.rs @@ -1,12 +1,16 @@ //! The unopenable path of the whole production chain — probe, status, cache, //! selection, metering — on any host. Neither Mac that runs these tests lacks //! an Energy Model group, so `simulate_no_energy_model_group()` makes this -//! process's probe find none before the meter's first use. This file holds -//! this one test so that it runs in a process of its own and the hook cannot -//! reach any other test. +//! process's probe find none before the meter's first use. The process CPU +//! energy counter, which ranks above IOReport and is live on those Macs, is +//! held frozen (`simulate_frozen_process_energy()`), so that every source +//! declines and "no meter at all" is reachable. This file holds this one test +//! so that it runs in a process of its own and the hooks cannot reach any +//! other test. use proof_cascade::meter::{ - hardware_meter_available, ioreport_status, measure_marginal_uj, real_probe_runs, simulate_no_energy_model_group, + hardware_meter_available, ioreport_status, measure_marginal_uj, process_energy_status, real_probe_runs, + real_process_probe_runs, simulate_frozen_process_energy, simulate_no_energy_model_group, CounterStatus, IoReportStatus, }; use std::sync::atomic::{AtomicU64, Ordering}; @@ -21,13 +25,15 @@ fn counted_work() { } /// With no Energy Model group to subscribe to, the status is `Unavailable` — -/// never `Live`, never `CpuRailAbsent` — for every thread that asks, the meter -/// is never selected, metering gives no figure and never runs the work, and the -/// probe still ran exactly once. +/// never `Live`, never `CpuRailAbsent` — for every thread that asks; with the +/// process counter held, it is `Frozen`. The meter is never selected, metering +/// gives no figure and never runs the work, and each probe ran exactly once. #[test] fn a_host_with_no_energy_model_group_is_unavailable_and_every_meter_declines() { assert_eq!(real_probe_runs(), 0, "the meter was used in this process before this test: this is not its first use"); + assert_eq!(real_process_probe_runs(), 0, "the process counter was used in this process before this test"); simulate_no_energy_model_group(); + simulate_frozen_process_energy(); const THREADS: usize = 4; let gate = Arc::new(Barrier::new(THREADS)); let handles: Vec<_> = (0..THREADS) @@ -36,23 +42,30 @@ fn a_host_with_no_energy_model_group_is_unavailable_and_every_meter_declines() { std::thread::spawn(move || { gate.wait(); let status = ioreport_status(); + let process = process_energy_status(); let selected = hardware_meter_available(); let figure = if cfg!(target_os = "macos") { measure_marginal_uj(counted_work) } else { None }; - (status, selected, figure) + (status, process, selected, figure) }) }) .collect(); let seen: Vec<_> = handles.into_iter().map(|h| h.join().expect("a thread panicked")).collect(); let runs = real_probe_runs(); + let process_runs = real_process_probe_runs(); let work = WORK_CALLS.load(Ordering::SeqCst); - eprintln!("ioreport unopenable: {THREADS} threads saw {seen:?}; real probe runs: {runs}; work calls: {work}"); - for (i, (status, selected, figure)) in seen.iter().enumerate() { + eprintln!( + "ioreport unopenable: {THREADS} threads saw {seen:?}; real probe runs: {runs}; process probe runs: \ + {process_runs}; work calls: {work}" + ); + for (i, (status, process, selected, figure)) in seen.iter().enumerate() { assert_eq!(*status, IoReportStatus::Unavailable, "thread {i}: no Energy Model group, yet {status:?}"); if cfg!(target_os = "macos") { - assert!(!selected, "thread {i}: the meter was selected with no Energy Model group"); - assert_eq!(*figure, None, "thread {i}: a figure came out with no Energy Model group"); + assert_eq!(*process, CounterStatus::Frozen, "thread {i}: a held process counter was called {process:?}"); + assert!(!selected, "thread {i}: a meter was selected with no Energy Model group and a held counter"); + assert_eq!(*figure, None, "thread {i}: a figure came out with no Energy Model group and a held counter"); } } assert_eq!(work, 0, "the work ran {work} times with no usable meter"); assert_eq!(runs, usize::from(cfg!(target_os = "macos")), "the liveness probe's runs in this process"); + assert_eq!(process_runs, usize::from(cfg!(target_os = "macos")), "the process-counter probe's runs"); } diff --git a/proof/proof-rs/crates/proof-cascade/tests/process_energy.rs b/proof/proof-rs/crates/proof-cascade/tests/process_energy.rs new file mode 100644 index 000000000..f5a035101 --- /dev/null +++ b/proof/proof-rs/crates/proof-cascade/tests/process_energy.rs @@ -0,0 +1,210 @@ +//! This process's CPU energy counter as a meter, on the real hardware. +//! +//! Every test here checks the meter against an independent oracle: the tests' +//! own binding of `proc_pid_rusage(getpid(), 6)`, read into a raw buffer at +//! byte 336, with their own loop and their own literals. The oracle watches +//! the counter for one second while a helper thread burns CPU and calls it +//! moving only if it changed at no fewer than 5 of its reads and at 80% of +//! them. Where the oracle sees the counter move, the meter must have selected +//! it and must meter with it; where it does not, the meter must not. No branch +//! passes without asserting. +//! +//! The process counter counts everything this process's threads burn, so the +//! tests in this file run one at a time (`SERIAL`), and nothing else here runs +//! while one of them meters. + +use proof_cascade::meter::{measure_marginal, meter_source, process_energy_status, CounterStatus, MeterSource}; +use proof_schema::Provenance; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::{Duration, Instant}; + +static SERIAL: Mutex<()> = Mutex::new(()); + +fn serial() -> MutexGuard<'static, ()> { + SERIAL.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// What the oracle saw of the counter over its window. +#[derive(Debug, Clone, Copy)] +struct Oracle { + moved: bool, + reads: u32, + changes: u32, + advanced_nj: u64, +} + +#[cfg(target_os = "macos")] +mod ffi { + use std::os::raw::c_int; + unsafe extern "C" { + fn proc_pid_rusage(pid: c_int, flavor: c_int, buffer: *mut [u64; 58]) -> c_int; + fn getpid() -> c_int; + } + + /// `ri_energy_nj` of `proc_pid_rusage(getpid(), RUSAGE_INFO_V6 = 6)`: a + /// 464-byte buffer, read at byte 336 (its 43rd `uint64_t`). + pub fn energy_nj() -> Option { + let mut buf = [0u64; 58]; + // SAFETY: buf is 464 writable bytes, the size of rusage_info_v6. + (unsafe { proc_pid_rusage(getpid(), 6, &mut buf) } == 0).then_some(buf[42]) + } +} + +/// The oracle's verdict, taken once for this test binary (under `SERIAL`). +fn oracle() -> Oracle { + static ORACLE: OnceLock = OnceLock::new(); + *ORACLE.get_or_init(watch_counter) +} + +#[cfg(target_os = "macos")] +fn watch_counter() -> Oracle { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + let Some(first) = ffi::energy_nj() else { + return Oracle { moved: false, reads: 0, changes: 0, advanced_nj: 0 }; + }; + let stop = Arc::new(AtomicBool::new(false)); + let burner = { + let stop = stop.clone(); + std::thread::spawn(move || { + let mut acc = 0u64; + while !stop.load(Ordering::Relaxed) { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + }) + }; + let started = Instant::now(); + let (mut prev, mut reads, mut changes) = (first, 0u32, 0u32); + while started.elapsed() < Duration::from_secs(1) { + std::thread::sleep(Duration::from_millis(25)); + let now = ffi::energy_nj().expect("the oracle's read failed after its first succeeded"); + reads += 1; + changes += u32::from(now != prev); + prev = now; + } + stop.store(true, Ordering::Relaxed); + let _ = burner.join(); + let moved = changes >= 5 && changes * 5 >= reads * 4; + Oracle { moved, reads, changes, advanced_nj: prev - first } +} + +#[cfg(not(target_os = "macos"))] +fn watch_counter() -> Oracle { + Oracle { moved: false, reads: 0, changes: 0, advanced_nj: 0 } +} + +/// Real, cheap, pure CPU work: a fixed fold. +fn cheap() { + std::hint::black_box((0..20_000u64).fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b))); +} + +/// 2 ms of computing, by the wall clock. +fn compute_2ms() { + let t = Instant::now(); + let mut acc = 0u64; + while t.elapsed() < Duration::from_millis(2) { + acc = std::hint::black_box(acc.wrapping_add(1)); + } +} + +/// 2 ms of sleeping. +fn sleep_2ms() { + std::thread::sleep(Duration::from_millis(2)); +} + +/// The counter is selected exactly when the oracle sees it keep moving: live +/// and first in line where it moves, never selected where it does not. +#[test] +fn the_process_counter_is_live_exactly_when_the_oracle_sees_it_move() { + let _serial = serial(); + let status = process_energy_status(); + let o = oracle(); + let source = meter_source(); + eprintln!("process liveness: status={status:?} source={source:?} oracle={o:?}"); + if cfg!(target_os = "macos") { + assert_eq!( + status == CounterStatus::Live, + o.moved, + "the probe says {status:?}; the oracle saw the counter change at {} of {} reads (+{} nJ)", + o.changes, + o.reads, + o.advanced_nj + ); + if o.moved { + assert_eq!(source, Some(MeterSource::ProcessEnergy), "a live process counter was not selected first"); + } else { + assert_ne!(source, Some(MeterSource::ProcessEnergy), "a process counter the oracle saw hold was selected"); + } + } else { + assert_eq!(status, CounterStatus::Unavailable, "a process counter off macOS"); + assert_ne!(source, Some(MeterSource::ProcessEnergy)); + } +} + +/// A fixed cheap closure meters as a non-zero `HwShunt` figure from the +/// process counter, roughly stable: three calls within a factor of 3. +#[test] +fn a_fixed_cheap_closure_meters_as_a_stable_hwshunt_figure() { + let _serial = serial(); + if meter_source() == Some(MeterSource::ProcessEnergy) { + let figures: Vec<_> = (0..3).map(|_| measure_marginal(cheap).expect("a live process counter gave no figure")).collect(); + eprintln!("process stability: cheap closure, three calls: {figures:?}"); + for m in &figures { + assert_eq!(m.source, MeterSource::ProcessEnergy, "{m:?}"); + assert!(m.uj > 0, "a live process counter metered real work as zero: {m:?}"); + let e = m.energy(); + assert_eq!((e.measured_uj, e.provenance), (Some(m.uj), Provenance::HwShunt), "{m:?}"); + assert!(e.check_provenance().is_ok(), "{e:?}"); + } + let (lo, hi) = (figures.iter().map(|m| m.uj).min().unwrap(), figures.iter().map(|m| m.uj).max().unwrap()); + assert!(hi <= 3 * lo, "three calls on the same closure spread from {lo} to {hi} uJ, more than 3x"); + } else { + let o = oracle(); + assert!(!o.moved, "the oracle saw the process counter move ({o:?}), yet it was not selected"); + let m = measure_marginal(cheap); + assert_ne!(m.map(|m| m.source), Some(MeterSource::ProcessEnergy), "a process-counter figure from a counter not selected"); + } +} + +/// Sleeping is not computing: a closure that sleeps 2 ms meters at under a +/// tenth of one that computes for 2 ms — and stays there while four busy +/// child processes burn CPU beside it, which the counter excludes. +#[test] +fn a_closure_that_sleeps_meters_far_less_than_one_that_computes() { + let _serial = serial(); + if meter_source() == Some(MeterSource::ProcessEnergy) { + let compute = measure_marginal(compute_2ms).expect("computing gave no figure"); + let sleep = measure_marginal(sleep_2ms).expect("sleeping gave no figure"); + let busy: Vec<_> = (0..4) + .map(|_| { + std::process::Command::new("/bin/sh") + .args(["-c", "while :; do :; done"]) + .spawn() + .expect("a busy child process starts") + }) + .collect(); + std::thread::sleep(Duration::from_millis(100)); + let beside_children = measure_marginal(sleep_2ms); + for mut child in busy { + let _ = child.kill(); + let _ = child.wait(); + } + let beside_children = beside_children.expect("sleeping beside busy children gave no figure"); + eprintln!( + "process sleep vs compute: compute 2 ms = {compute:?}; sleep 2 ms = {sleep:?}; sleep 2 ms beside 4 busy \ + children = {beside_children:?}" + ); + assert!(compute.uj > 0, "computing metered as zero: {compute:?}"); + assert!(sleep.uj * 10 < compute.uj, "sleeping metered {} uJ a call, computing {} uJ", sleep.uj, compute.uj); + assert!( + beside_children.uj * 10 < compute.uj, + "sleeping beside busy child processes metered {} uJ a call, computing {} uJ: other processes were counted", + beside_children.uj, + compute.uj + ); + } else { + let o = oracle(); + assert!(!o.moved, "the oracle saw the process counter move ({o:?}), yet it was not selected"); + assert_ne!(measure_marginal(sleep_2ms).map(|m| m.source), Some(MeterSource::ProcessEnergy)); + } +} diff --git a/proof/proof-rs/crates/proof-cascade/tests/process_energy_frozen.rs b/proof/proof-rs/crates/proof-cascade/tests/process_energy_frozen.rs new file mode 100644 index 000000000..492a624a7 --- /dev/null +++ b/proof/proof-rs/crates/proof-cascade/tests/process_energy_frozen.rs @@ -0,0 +1,80 @@ +//! The frozen path of the whole process-counter chain — probe, status, cache, +//! selection, metering — on any host. The counter is live on the Macs that run +//! these tests, so `simulate_frozen_process_energy()` makes this process's +//! counter hold at its first value before the meter's first use, as a frozen +//! counter would. This file holds this one test so that it runs in a process +//! of its own and the hook cannot reach any other test. +//! +//! A selection that ignored the counter's liveness — choosing it because it +//! reads, not because it keeps advancing — fails here. + +use proof_cascade::meter::{ + ioreport_status, measure_marginal, meter_source, process_energy_status, real_process_probe_runs, + simulate_frozen_process_energy, CounterStatus, IoReportStatus, MeterSource, +}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Barrier}; + +/// How many times [`counted_work`] has run in this process. +static WORK_CALLS: AtomicU64 = AtomicU64::new(0); + +fn counted_work() { + WORK_CALLS.fetch_add(1, Ordering::SeqCst); + std::hint::black_box((0..4000u64).fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b))); +} + +/// With the counter holding, the status is `Frozen` for every thread that +/// asks, the counter is never selected and never meters, and its probe ran +/// exactly once. Whatever IOReport does is IOReport's own: selected only when +/// its CPU rail is live, and then its figure says so; otherwise there is no +/// figure and the work never runs. +#[test] +fn a_frozen_process_counter_is_never_selected_and_never_meters() { + assert_eq!(real_process_probe_runs(), 0, "the meter was used in this process before this test"); + simulate_frozen_process_energy(); + const THREADS: usize = 4; + let gate = Arc::new(Barrier::new(THREADS)); + let handles: Vec<_> = (0..THREADS) + .map(|_| { + let gate = gate.clone(); + std::thread::spawn(move || { + gate.wait(); + let status = process_energy_status(); + let source = meter_source(); + (status, source) + }) + }) + .collect(); + let seen: Vec<_> = handles.into_iter().map(|h| h.join().expect("a thread panicked")).collect(); + let runs = real_process_probe_runs(); + let ioreport = ioreport_status(); + let figure = measure_marginal(counted_work); + let work = WORK_CALLS.load(Ordering::SeqCst); + eprintln!( + "process frozen: {THREADS} threads saw {seen:?}; probe runs {runs}; ioreport={ioreport:?}; figure={figure:?}; \ + work calls {work}" + ); + let want_source = (ioreport == IoReportStatus::Live).then_some(MeterSource::IoReport); + for (i, (status, source)) in seen.iter().enumerate() { + if cfg!(target_os = "macos") { + assert_eq!(*status, CounterStatus::Frozen, "thread {i}: a held counter was called {status:?}"); + assert_eq!(*source, want_source, "thread {i}: a held process counter was selected, or IOReport mis-ranked"); + } else { + assert_eq!(*status, CounterStatus::Unavailable, "thread {i}: a process counter off macOS"); + assert_ne!(*source, Some(MeterSource::ProcessEnergy)); + } + } + assert_ne!(figure.map(|m| m.source), Some(MeterSource::ProcessEnergy), "a held process counter metered"); + if cfg!(target_os = "macos") { + assert_eq!(runs, 1, "the process-counter probe's runs in this process"); + match want_source { + Some(src) => assert_eq!(figure.map(|m| m.source), Some(src), "IOReport was live, yet: {figure:?}"), + None => { + assert_eq!(figure, None, "no live source, yet a figure"); + assert_eq!(work, 0, "the work ran {work} times with no live source"); + } + } + } else { + assert_eq!(runs, 0, "a process-counter probe ran off macOS"); + } +} diff --git a/proof/proof-rs/crates/proof-cli/src/main.rs b/proof/proof-rs/crates/proof-cli/src/main.rs index 7ada2899c..65259f3c1 100644 --- a/proof/proof-rs/crates/proof-cli/src/main.rs +++ b/proof/proof-rs/crates/proof-cli/src/main.rs @@ -10,8 +10,9 @@ //! with the all-goals figure and its goal count beside it — the Amdahl //! breakdown by tier, the cache flywheel, and a sealed + verified JCR-1 //! receipt. Only `--measure` attempts a hardware reading, and only through a -//! counter the meter has seen to be live (on Apple Silicon IOReport's CPU -//! energy rail, on Linux every RAPL package counter). +//! counter the meter has seen to be live (on macOS this process's CPU energy +//! counter, else IOReport's CPU energy rail; on Linux every RAPL package +//! counter). use ed25519_dalek::SigningKey; use proof_schema::{GoalKind, ProofGoal}; @@ -118,22 +119,29 @@ fn main() { summary.uj_by_tier[0], summary.uj_by_tier[1], summary.uj_by_tier[2], summary.uj_by_tier[3], t3, ); - // --measure: try to put a hardware reading beside the wall-time model. - // Bracket a real T0 proof against idle on the platform's energy interface - // (IOReport on Apple Silicon, used only while its CPU energy rail is live; - // RAPL on Linux, used only while every package counter is live) and report - // the marginal joules. The figure is tagged `Energy::measured`, i.e. - // HwShunt, on both platforms: a vendor energy counter's reading is a - // hardware reading while the counter is live for the domain it claims - // (SPEC §5), which the meter checks before it brackets anything. Where no - // interface is usable, print why: the figures above then stay ModelBased. + // --measure: try to put a hardware reading beside the wall-time model: + // meter a real T0 proof with the first live source (`meter_source`) — on + // macOS this process's CPU energy counter, else IOReport's Energy Model + // (used only while its CPU energy rail is live); on Linux RAPL (used only + // while every package counter is live) — and report the marginal joules. + // The figure is graded by its source, HwShunt for each: a vendor or kernel + // energy counter's reading is a hardware reading while the counter is live + // for the domain it claims (SPEC §5), which the meter checks before it + // meters anything; the figure's domain is printed beside it. Where no + // source is live, print why: the figures above then stay ModelBased. if measure { - use proof_cascade::meter::{hardware_meter_available, ioreport_status, measure_marginal_uj, rapl_status}; - let available = hardware_meter_available(); + use proof_cascade::meter::{ + ioreport_status, measure_marginal, meter_source, process_energy_status, rapl_status, MeterSource, + }; + let source = meter_source(); + let available = source.is_some(); // The decision THIS process made, printed so tests/measure.rs checks it // instead of re-probing in its own process, which could disagree. println!( - "\n --measure: hardware meter available={available} · ioreport_status={:?} · rapl_status={:?}", + "\n --measure: hardware meter available={available} · source={} · process_energy_status={:?} · \ + ioreport_status={:?} · rapl_status={:?}", + source.map_or_else(|| "none".to_string(), |s| format!("{s:?}")), + process_energy_status(), ioreport_status(), rapl_status() ); @@ -143,31 +151,60 @@ fn main() { no_hardware_reading_reason() ); } else { + // Why each source that is not used is not used: ranked below the + // one used, or not live. + if cfg!(target_os = "macos") && source == Some(MeterSource::ProcessEnergy) { + let own = if ioreport_status() == proof_cascade::meter::IoReportStatus::Live { + "its CPU energy rail is live too" + } else { + ioreport_reason() + }; + println!(" --measure: IOReport not used: the process counter ranks above it. On its own: {own}."); + } else if cfg!(target_os = "macos") { + println!(" --measure: this process's CPU energy counter not used: {}.", process_reason()); + } // Meter a REAL, non-trivial T0 proof: validity of a 16-variable // propositional tautology, decided by the actual truth-table decider - // (2^16 rows) — genuine, parallelizable compute, run on every core - // and bracketed against idle. This is real proof work, not a spin. + // (2^16 rows) — genuine, parallelizable compute, run on every core. + // This is real proof work, not a spin. const HARD: &str = "(a|b|c|d|e|f|g|h|i|j|k|l|m|n|o|p) | (~a&~b&~c&~d&~e&~f&~g&~h&~i&~j&~k&~l&~m&~n&~o&~p)"; - println!(" --measure: metering a real 16-variable T0 proof against idle on the energy interface…"); + println!( + " --measure: metering a real 16-variable T0 proof, on every spare core, {}…", + match source { + Some(MeterSource::ProcessEnergy) => { + "on this process's CPU energy counter (no idle baseline: other processes are not in it)" + } + _ => "against idle on the energy interface", + } + ); let parsed = proof_solvers::prop::parse(HARD).expect("tautology parses"); assert_eq!(proof_solvers::prop::is_valid(&parsed), Some(true), "the probe goal must be valid"); - let measured = measure_marginal_uj(|| { + let measured = measure_marginal(|| { if let Some(e) = proof_solvers::prop::parse(HARD) { std::hint::black_box(proof_solvers::prop::is_valid(&e)); } }); match measured { - Some(uj) if uj > 0 => { - let e = proof_schema::Energy::measured(uj); + Some(m) if m.uj > 0 => { + let e = m.energy(); let _ = e.check_provenance(); println!( - " measured: {uj} uj / proof · provenance {} (measured_uj present, the schema's strongest tier)", + " measured: {} uj / proof · provenance {} (measured_uj present, the schema's strongest tier) · source {:?}", + m.uj, e.provenance.jcr1_label(), + m.source, + ); + println!(" domain: {}", m.source.domain()); + println!( + " this proof's joules are a hardware reading; the per-goal figures above stay ModelBased, and the receipt seals them." ); - println!(" the standard's headline (joules per verified proof) is now a real hardware reading, not a model."); } - // The interface answered, and busy did not clear idle. + // The process counter advanced by under half a microjoule a call. + Some(m) if m.source == MeterSource::ProcessEnergy => println!( + " measured: under 0.5 uj per proof, below this meter's 1 uj resolution — not a zero-energy proof." + ), + // A bracket answered, and busy did not clear idle. Some(_) => println!( " measured: signal stayed inside the idle noise (a loaded/throttled box) — run on a quiet machine." ), @@ -215,36 +252,62 @@ fn main() { } /// Why `--measure` has no hardware reading on this host. It comes from the -/// meter's cached status — IOReport's on Apple Silicon, RAPL's on Linux — so a -/// counter that is not live is named as such instead of being misread as a -/// noisy machine. -fn no_hardware_reading_reason() -> &'static str { - use proof_cascade::meter::{ioreport_status, rapl_status, CounterStatus, IoReportStatus}; +/// meter's cached statuses — on Apple Silicon the process counter's and +/// IOReport's, on Linux RAPL's — so a counter that is not live is named as +/// such instead of being misread as a noisy machine. +fn no_hardware_reading_reason() -> String { if cfg!(target_os = "macos") { - match ioreport_status() { - IoReportStatus::CpuRailFrozen => { - "IOReport opens, but its CPU energy rail is frozen (it did not keep advancing while \ - this process burned CPU), so it cannot meter CPU work" - } - IoReportStatus::CpuRailAbsent => "IOReport's Energy Model group opens, but has no CPU energy rail", - IoReportStatus::Unavailable => { - "IOReport's Energy Model group could not be opened, or opened but could not be sampled \ - (opening it needs Apple Silicon, not Rosetta)" - } - IoReportStatus::Live => "IOReport's CPU energy rail is live, but the bracket returned no reading", - } + format!("{}; and {}", process_reason(), ioreport_reason()) } else if cfg!(target_os = "linux") { - match rapl_status() { - CounterStatus::Unavailable => "no readable RAPL counter at /sys/class/powercap/intel-rapl:*/energy_uj", - CounterStatus::Absent => "RAPL zones are readable, but none of them is a package zone", - CounterStatus::Frozen => { - "a RAPL package counter is readable but did not keep advancing while this process burned CPU, \ - so it cannot meter CPU work" - } - CounterStatus::Live => "RAPL's package counters are live, but the bracket returned no reading", - } + rapl_reason().to_string() } else { - "no hardware energy interface is supported on this OS" + "no hardware energy interface is supported on this OS".to_string() + } +} + +/// Why this process's CPU energy counter is or is not used, from its status. +fn process_reason() -> &'static str { + use proof_cascade::meter::{process_energy_status, CounterStatus}; + match process_energy_status() { + CounterStatus::Unavailable => { + "this process's CPU energy counter (proc_pid_rusage, ri_energy_nj) could not be read" + } + CounterStatus::Absent => "this process's CPU energy counter is absent", + CounterStatus::Frozen => { + "this process's CPU energy counter did not keep advancing while this process burned CPU" + } + CounterStatus::Live => "this process's CPU energy counter is live, but the measurement returned no reading", + } +} + +/// Why IOReport is or is not used, from its status. +fn ioreport_reason() -> &'static str { + use proof_cascade::meter::{ioreport_status, IoReportStatus}; + match ioreport_status() { + IoReportStatus::CpuRailFrozen => { + "IOReport opens, but its CPU energy rail is frozen (it did not keep advancing while \ + this process burned CPU), so it cannot meter CPU work" + } + IoReportStatus::CpuRailAbsent => "IOReport's Energy Model group opens, but has no CPU energy rail", + IoReportStatus::Unavailable => { + "IOReport's Energy Model group could not be opened, or opened but could not be sampled \ + (opening it needs Apple Silicon, not Rosetta)" + } + IoReportStatus::Live => "IOReport's CPU energy rail is live, but the bracket returned no reading", + } +} + +/// Why RAPL is or is not used, from its status. +fn rapl_reason() -> &'static str { + use proof_cascade::meter::{rapl_status, CounterStatus}; + match rapl_status() { + CounterStatus::Unavailable => "no readable RAPL counter at /sys/class/powercap/intel-rapl:*/energy_uj", + CounterStatus::Absent => "RAPL zones are readable, but none of them is a package zone", + CounterStatus::Frozen => { + "a RAPL package counter is readable but did not keep advancing while this process burned CPU, \ + so it cannot meter CPU work" + } + CounterStatus::Live => "RAPL's package counters are live, but the bracket returned no reading", } } diff --git a/proof/proof-rs/crates/proof-cli/tests/measure.rs b/proof/proof-rs/crates/proof-cli/tests/measure.rs index ce0282d3a..8497d94d3 100644 --- a/proof/proof-rs/crates/proof-cli/tests/measure.rs +++ b/proof/proof-rs/crates/proof-cli/tests/measure.rs @@ -1,15 +1,22 @@ -//! `proof --measure`, end to end: when the hardware meter declines, the output -//! names the reason, nothing in it claims a hardware reading, and the cascade's -//! own ModelBased figures stand; when it meters, the host really has a usable -//! interface. Every check is against the decision the `proof` process printed, -//! never a second probe in this process: two independent ~1 s probes of a -//! borderline rail could disagree. +//! `proof --measure`, end to end: when every hardware source declines, the +//! output names the reasons, nothing in it claims a hardware reading, and the +//! cascade's own ModelBased figures stand; when a source meters, the output +//! names the source, its HwShunt figure and its domain, and the sealed receipt +//! is unchanged. Every check is against the decision the `proof` process +//! printed, never a second probe in this process: two independent ~1 s probes +//! of a borderline counter could disagree. //! -//! The printed status is itself checked against what the process did with it. -//! The CLI's reason for declining comes from the status it acted on, so on -//! macOS every IOReport reason in the output must be the one for the printed -//! status, and a declined meter must print that reason: a CLI that prints one -//! status and acts on another fails here, whichever status either one is. +//! The printed statuses are themselves checked against what the process did +//! with them. Each reason the CLI gives comes from the status it acted on, so +//! every reason in the output must be the one for its printed status, the +//! source must be the first live one in order, and a declined meter must +//! print every source's reason: a CLI that prints one status and acts on +//! another fails here, whichever status either one is. +//! +//! One check is against the hardware: on macOS this test's own process reads +//! its own CPU energy counter (its own binding of `proc_pid_rusage`) while it +//! burns CPU. Where that counter keeps moving, the CLI must have metered with +//! its process counter and printed a non-zero HwShunt figure. use std::process::Command; @@ -20,8 +27,8 @@ fn field<'a>(stdout: &'a str, key: &str) -> Option<&'a str> { } /// Every `IoReportStatus` the CLI can print, each with the reason it gives -/// when that status is why there is no hardware reading (`Live`'s is for a -/// bracket that returned nothing). Each reason names exactly one status. +/// when that status is why IOReport is not used (`Live`'s is for a bracket +/// that returned nothing). Each reason names exactly one status. const IOREPORT_REASONS: [(&str, &str); 4] = [ ("CpuRailFrozen", "its CPU energy rail is frozen"), ("CpuRailAbsent", "has no CPU energy rail"), @@ -29,6 +36,15 @@ const IOREPORT_REASONS: [(&str, &str); 4] = [ ("Live", "CPU energy rail is live, but the bracket returned no reading"), ]; +/// Every process-counter `CounterStatus` the CLI can print, each with its +/// reason on macOS. +const PROCESS_REASONS: [(&str, &str); 4] = [ + ("Unavailable", "CPU energy counter (proc_pid_rusage, ri_energy_nj) could not be read"), + ("Absent", "CPU energy counter is absent"), + ("Frozen", "CPU energy counter did not keep advancing"), + ("Live", "CPU energy counter is live, but the measurement returned no reading"), +]; + /// Every RAPL `CounterStatus` the CLI can print, each with the reason it /// gives on Linux when that status is why there is no hardware reading. const RAPL_REASONS: [(&str, &str); 4] = [ @@ -38,8 +54,63 @@ const RAPL_REASONS: [(&str, &str); 4] = [ ("Live", "RAPL's package counters are live, but the bracket returned no reading"), ]; +/// Every reason of `table` in the output must be its printed status's own. +fn reasons_match(stdout: &str, table: &[(&str, &str)], printed: &str, what: &str) { + for (reason_status, reason) in table { + if stdout.contains(reason) { + assert_eq!( + *reason_status, printed, + "the CLI printed {what}={printed} but gave the reason for {reason_status}:\n{stdout}" + ); + } + } +} + +/// The reason `table` gives for `printed`. +fn reason_of<'a>(table: &[(&'a str, &'a str)], printed: &str) -> &'a str { + table.iter().find(|(s, _)| *s == printed).map(|(_, r)| *r).expect("a known status") +} + +#[cfg(target_os = "macos")] +mod oracle { + use std::os::raw::c_int; + use std::time::{Duration, Instant}; + unsafe extern "C" { + fn proc_pid_rusage(pid: c_int, flavor: c_int, buffer: *mut [u64; 58]) -> c_int; + fn getpid() -> c_int; + } + + /// `ri_energy_nj` of `proc_pid_rusage(getpid(), 6)`, read at byte 336. + fn energy_nj() -> Option { + let mut buf = [0u64; 58]; + // SAFETY: buf is 464 writable bytes, the size of rusage_info_v6. + (unsafe { proc_pid_rusage(getpid(), 6, &mut buf) } == 0).then_some(buf[42]) + } + + /// This test process's own counter, read every ~25 ms for 600 ms while + /// this thread burns CPU between reads: (reads, reads at which it had + /// changed), or `None` if it cannot be read. + pub fn watch() -> Option<(u32, u32)> { + let mut prev = energy_nj()?; + let (mut reads, mut changes) = (0u32, 0u32); + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(600) { + let t = Instant::now(); + let mut acc = 0u64; + while t.elapsed() < Duration::from_millis(25) { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + let now = energy_nj()?; + reads += 1; + changes += u32::from(now != prev); + prev = now; + } + Some((reads, changes)) + } +} + #[test] -fn measure_names_why_it_declined_and_claims_no_hardware_reading() { +fn measure_names_its_source_or_why_it_declined_and_leaves_the_receipt_alone() { let out = Command::new(env!("CARGO_BIN_EXE_proof")) .arg("--measure") .output() @@ -56,24 +127,34 @@ fn measure_names_why_it_declined_and_claims_no_hardware_reading() { Some("false") => false, other => panic!("proof --measure did not print its meter decision ({other:?}):\n{stdout}"), }; - let status = field(&stdout, "ioreport_status=") - .unwrap_or_else(|| panic!("proof --measure did not print its IOReport status:\n{stdout}")); - assert!( - IOREPORT_REASONS.iter().any(|&(s, _)| s == status), - "proof --measure printed an unknown IOReport status {status:?}:\n{stdout}" - ); - let rapl = field(&stdout, "rapl_status=") - .unwrap_or_else(|| panic!("proof --measure did not print its RAPL status:\n{stdout}")); - assert!( - RAPL_REASONS.iter().any(|&(s, _)| s == rapl), - "proof --measure printed an unknown RAPL status {rapl:?}:\n{stdout}" - ); + let printed = |key: &str, table: &[(&str, &str)]| { + let v = field(&stdout, key).unwrap_or_else(|| panic!("proof --measure did not print {key}:\n{stdout}")); + assert!(table.iter().any(|&(s, _)| s == v), "proof --measure printed an unknown {key}{v:?}:\n{stdout}"); + v.to_string() + }; + let status = printed("ioreport_status=", &IOREPORT_REASONS); + let process = printed("process_energy_status=", &PROCESS_REASONS); + let rapl = printed("rapl_status=", &RAPL_REASONS); + let source = field(&stdout, " · source=").unwrap_or_else(|| panic!("no source printed:\n{stdout}")).to_string(); let declined = stdout.contains("--measure: no hardware energy reading on this host: "); // Recorded so a log says which branch below was actually asserted. - eprintln!("proof --measure: declined={declined} available={available} ioreport_status={status} rapl_status={rapl}"); + eprintln!( + "proof --measure: declined={declined} available={available} source={source} process_energy_status={process} \ + ioreport_status={status} rapl_status={rapl}" + ); + if let Some(line) = stdout.lines().find(|l| l.trim_start().starts_with("measured: ")) { + eprintln!("proof --measure: {}", line.trim()); + } - // The sealed receipt is the cascade's own figure for the first goal, on - // every branch: ModelBased and non-zero, never the --measure figure. + // The sealed receipt is the cascade's own figure for the FIRST goal, on + // every branch: the first row's figure, ModelBased, never the --measure + // figure. + let first_row_uj: u64 = stdout + .lines() + .find(|l| l.trim_start().starts_with("T0:automation")) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|n| n.parse().ok()) + .unwrap_or_else(|| panic!("no first goal row:\n{stdout}")); let receipt = stdout .lines() .find(|l| l.trim_start().starts_with("receipt: closed at")) @@ -85,25 +166,21 @@ fn measure_names_why_it_declined_and_claims_no_hardware_reading() { .and_then(|n| n.parse().ok()) .unwrap_or_else(|| panic!("no uj figure in the receipt line: {receipt}")); assert!(receipt_uj > 0, "the receipt carries a zero figure for a proof that ran: {receipt}"); + assert_eq!(receipt_uj, first_row_uj, "the receipt is not the first goal's own figure: {receipt}\n{stdout}"); assert!(receipt.contains("provenance ModelBased"), "the receipt's provenance changed: {receipt}"); + assert!(receipt.contains("closed at T0:automation"), "the receipt is not the first goal's: {receipt}"); assert_eq!(declined, !available, "the CLI's output disagrees with the decision it printed:\n{stdout}"); + assert_eq!(available, source != "none", "available={available} but source={source}:\n{stdout}"); + // RAPL is a Linux interface: elsewhere it is Unavailable and no RAPL // reason is ever given. On Linux the meter is available exactly when RAPL // is Live, and a declined meter gives the printed RAPL status's reason. if cfg!(target_os = "linux") { - assert_eq!(available, rapl == "Live", "on Linux the meter is available exactly when RAPL is Live:\n{stdout}"); - for (reason_status, reason) in RAPL_REASONS { - if stdout.contains(reason) { - assert_eq!( - reason_status, rapl, - "the CLI printed rapl_status={rapl} but gave the reason for {reason_status}:\n{stdout}" - ); - } - } + assert_eq!(source, if rapl == "Live" { "Rapl" } else { "none" }, "on Linux the source is RAPL exactly when Live:\n{stdout}"); + reasons_match(&stdout, &RAPL_REASONS, &rapl, "rapl_status"); if declined { - let (_, reason) = RAPL_REASONS.iter().find(|&&(s, _)| s == rapl).expect("a known status"); - assert!(stdout.contains(reason), "declined with rapl_status={rapl}, but not for its reason:\n{stdout}"); + assert!(stdout.contains(reason_of(&RAPL_REASONS, &rapl)), "declined without RAPL's reason:\n{stdout}"); } } else { assert_eq!(rapl, "Unavailable", "a RAPL status other than Unavailable off Linux:\n{stdout}"); @@ -112,52 +189,56 @@ fn measure_names_why_it_declined_and_claims_no_hardware_reading() { } } if cfg!(target_os = "macos") { - assert_eq!( - available, - status == "Live", - "on macOS the meter is available exactly when IOReport is Live:\n{stdout}" - ); - // The reason is derived from the status the process acted on: it must - // be the printed status's own, and no other status's. - for (reason_status, reason) in IOREPORT_REASONS { - if stdout.contains(reason) { + // The first live source, in order: the process counter, then IOReport. + let want = if process == "Live" { + "ProcessEnergy" + } else if status == "Live" { + "IoReport" + } else { + "none" + }; + assert_eq!(source, want, "process_energy_status={process} ioreport_status={status}, yet source={source}:\n{stdout}"); + reasons_match(&stdout, &IOREPORT_REASONS, &status, "ioreport_status"); + reasons_match(&stdout, &PROCESS_REASONS, &process, "process_energy_status"); + if declined { + assert!(stdout.contains(reason_of(&IOREPORT_REASONS, &status)), "declined without IOReport's reason:\n{stdout}"); + assert!(stdout.contains(reason_of(&PROCESS_REASONS, &process)), "declined without the process counter's reason:\n{stdout}"); + } + if source == "IoReport" { + assert!( + stdout.contains("CPU energy counter not used: ") && stdout.contains(reason_of(&PROCESS_REASONS, &process)), + "IOReport metered, but the CLI did not say why the process counter was not used:\n{stdout}" + ); + } + // Against the hardware: this process's own counter. + #[cfg(target_os = "macos")] + { + let seen = oracle::watch(); + eprintln!("proof --measure: this test process's own counter (reads, changes) = {seen:?}"); + if let Some((reads, changes)) = seen.filter(|&(r, c)| c >= 5 && c * 5 >= r * 4) { assert_eq!( - reason_status, status, - "the CLI printed ioreport_status={status} but gave the reason for {reason_status}:\n{stdout}" + source, "ProcessEnergy", + "this test's own process counter changed at {changes} of {reads} reads, yet the CLI metered with \ + {source}:\n{stdout}" ); + } else { + assert_ne!(source, "ProcessEnergy", "the test's own counter did not keep moving ({seen:?}), yet:\n{stdout}"); } } - if declined { - let (_, reason) = IOREPORT_REASONS.iter().find(|&&(s, _)| s == status).expect("a known status"); - assert!( - stdout.contains(reason), - "the meter declined with ioreport_status={status}, but not for that status's reason ({reason:?}):\n{stdout}" - ); - } } else { - // Off macOS there is no IOReport: the status is always Unavailable, - // and no IOReport reason is ever given. + // Off macOS there is no IOReport and no process counter: both are + // Unavailable, and neither reason is ever given. assert_eq!(status, "Unavailable", "an IOReport status other than Unavailable off macOS:\n{stdout}"); - for (reason_status, reason) in IOREPORT_REASONS { - assert!( - !stdout.contains(reason), - "an IOReport reason ({reason_status}) off macOS:\n{stdout}" - ); + assert_eq!(process, "Unavailable", "a process counter off macOS:\n{stdout}"); + for (reason_status, reason) in IOREPORT_REASONS.iter().chain(&PROCESS_REASONS) { + assert!(!stdout.contains(reason), "an IOReport or process-counter reason ({reason_status}) off macOS:\n{stdout}"); } } + if declined { - assert!( - !stdout.contains("HwShunt"), - "the meter declined, yet the output claims a hardware reading:\n{stdout}" - ); - assert!( - !stdout.contains("idle noise"), - "the meter declined, yet the output blames a noisy machine:\n{stdout}" - ); - assert!( - !stdout.contains("--measure: metering a real"), - "the meter declined, yet the CLI metered:\n{stdout}" - ); + assert!(!stdout.contains("HwShunt"), "the meter declined, yet the output claims a hardware reading:\n{stdout}"); + assert!(!stdout.contains("idle noise"), "the meter declined, yet the output blames a noisy machine:\n{stdout}"); + assert!(!stdout.contains("--measure: metering a real"), "the meter declined, yet the CLI metered:\n{stdout}"); assert!( stdout.contains("The figures above stay ModelBased"), "a declined meter must leave the ModelBased figures standing:\n{stdout}" @@ -168,4 +249,27 @@ fn measure_names_why_it_declined_and_claims_no_hardware_reading() { "neither a declined meter nor a measurement in the output:\n{stdout}" ); } + if source == "ProcessEnergy" { + let measured = stdout + .lines() + .find(|l| l.trim_start().starts_with("measured: ")) + .unwrap_or_else(|| panic!("the process counter was selected, yet no measured line:\n{stdout}")); + let uj: u64 = measured + .trim_start() + .strip_prefix("measured: ") + .and_then(|r| r.split_whitespace().next()) + .and_then(|n| n.parse().ok()) + .unwrap_or_else(|| panic!("no figure in: {measured}")); + assert!(uj > 0, "the process counter metered a 2^16-row proof as {uj} uj: {measured}"); + assert!( + measured.contains("provenance HwShunt") && measured.contains("source ProcessEnergy"), + "the process counter's figure is not graded HwShunt and named by its source: {measured}" + ); + assert!( + stdout.contains("domain: this process's CPU energy") && stdout.contains("not other processes"), + "the process counter's figure was printed without its domain:\n{stdout}" + ); + assert!(!stdout.contains("idle noise"), "the process counter has no idle baseline to blame:\n{stdout}"); + assert_ne!(uj, receipt_uj, "the --measure figure leaked into the receipt:\n{stdout}"); + } }