diff --git a/.github/workflows/standards-build.yml b/.github/workflows/standards-build.yml index b3f638223..46439dde2 100644 --- a/.github/workflows/standards-build.yml +++ b/.github/workflows/standards-build.yml @@ -635,6 +635,22 @@ jobs: # tests/process_gpu.rs prints this once its 1.5 s GPU burn was checked against the oracle. grep -q 'process-gpu: oracle-checked:' "$log" + # The SMC whole-machine meter, `cfg(all(feature = "smc", target_os = "macos"))`, + # alone and beside IOReport (which changes what `detect_meter` picks). Its + # hardware test prints `apple-smc: measured` only once it has read a figure + # from the real SMC, so the grep fails if the runner had no `PSTR` or a cfg + # left the test unrun. + - name: jouleclaw-energy (smc, feature AND platform gated) + shell: bash + working-directory: jouleclaw/jouleclaw-rs + run: | + set -o pipefail + for features in smc ioreport,smc; do + log=$(mktemp) + cargo test -p jouleclaw-energy --features "$features" -- --nocapture 2>&1 | tee "$log" + grep -q 'apple-smc: measured' "$log" + done + - name: sandbox-meter shell: bash working-directory: sandbox/sandbox-rs @@ -765,6 +781,8 @@ jobs: lint proof/proof-rs -p proof-cascade -p proof-solvers lint eoc/eoc-rs -p eoc-meter lint jouleclaw/jouleclaw-rs -p jouleclaw-energy --features ioreport + lint jouleclaw/jouleclaw-rs -p jouleclaw-energy --features smc + lint jouleclaw/jouleclaw-rs -p jouleclaw-energy --features ioreport,smc # And the DEFAULT build. Linting only the featured one hid a real # clippy error for as long as it existed: every hardware meter in # `detect_meter` is behind a `cfg`, so the default build is a diff --git a/jouleclaw/SPEC.md b/jouleclaw/SPEC.md index 406fa96ee..f2cf2b04a 100644 --- a/jouleclaw/SPEC.md +++ b/jouleclaw/SPEC.md @@ -71,7 +71,7 @@ how the value was obtained: | Provenance | Meaning | |----------------|--------------------------------------------------------| | `HwShunt` | Hardware reading: a shunt / coulomb counter (Jetson INA3221), or a vendor energy counter (RAPL MSR, NVML cumulative-energy counter, Apple IOReport) while it is live for the domain it claims (below) | -| `ModelBased` | Vendor-provided estimate from freq / voltage / utilisation (ROCm SMI, NVML `power.draw`) | +| `ModelBased` | Vendor-provided estimate from freq / voltage / utilisation (ROCm SMI, NVML `power.draw`), or a vendor's sampled power integrated over time (Apple SMC `PSTR`, whole machine) | | `Estimator` | JouleClaw static cost model from arch × precision × batch tables | A vendor energy counter's reading is a real reading. What separates diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/Cargo.toml b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/Cargo.toml index fbca0214b..e5c1b202a 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/Cargo.toml +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/Cargo.toml @@ -16,7 +16,8 @@ tracing = { workspace = true, optional = true } nvml-wrapper = { workspace = true, optional = true } # `libc`: the IOReport backend's CoreFoundation types and thread clock on macOS, -# and the liveness probe's thread CPU clock on every Unix (RAPL on Linux). +# the SMC backend's Mach types, and the liveness probe's thread CPU clock on +# every Unix (RAPL on Linux). [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -31,6 +32,7 @@ nvml = ["dep:nvml-wrapper", "dep:tracing"] # NVIDIA discrete — NVML cumul ioreport = [] # Apple Silicon M-series — real IOReport energy counter (HwShunt while every CPU rail is live), load-avg estimator fallback; and the per-process GPU counter (GPU rail × this process's GPU-time share) ina3221 = [] # NVIDIA Jetson (Orin / Thor) — sysfs i2c shunt reader hwmon = [] # Linux generic — for AMD Strix Halo / Ryzen AI Max iGPU + others +smc = [] # Mac — the SMC's whole-machine power (`PSTR`) integrated over time (ModelBased, domain System); a sampler thread per process # The per-process GPU counter's hardware test loads the GPU itself (this crate # holds no Metal; its consumers supply the load). Same Metal bindings as diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/MEASURED_ENERGY.md b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/MEASURED_ENERGY.md index 09f75ae59..4beec0191 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/MEASURED_ENERGY.md +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/MEASURED_ENERGY.md @@ -11,8 +11,9 @@ is its provenance — which counter, which domain, and whether it was live — n its accuracy. A counter that is present but not live (frozen, or republished in lumps) gives no `HwShunt` figure: the meter falls back to its estimator. -The goal is that on real hardware a reading is **never** an `Estimator`. It is -not met on Apple Silicon under macOS 27.0.1 (below). The +The goal is that on real hardware a reading is **never** an `Estimator`. +IOReport does not meet it on Apple Silicon under macOS 27 (below). Built with +`smc`, such a Mac is metered as a whole machine instead, `ModelBased` (below). The [`energy_probe`](examples/energy_probe.rs) example reads the best available counter for its build features, integrates over a ~2 s CPU-churn window, and prints the measured microjoules with provenance: @@ -21,6 +22,7 @@ prints the measured microjoules with provenance: cargo run -p jouleclaw-energy --release --example energy_probe --features nvml # NVIDIA (Linux/Windows) cargo run -p jouleclaw-energy --release --example energy_probe --features rapl # x86 (Linux) cargo run -p jouleclaw-energy --release --example energy_probe --features ioreport # Apple Silicon +cargo run -p jouleclaw-energy --release --example energy_probe --features smc # Mac, whole machine ``` ## Verified on real hardware @@ -31,6 +33,8 @@ cargo run -p jouleclaw-energy --release --example energy_probe --features iorepo | Windows 11 + GeForce RTX 2060 | NVML (`nvml.dll`) | GPU | **HwShunt** | 18.8 J · 9.4 W | | Linux x86 + Tesla T4 (AWS g4dn.metal) | NVML (`libnvidia-ml`) | GPU | **HwShunt** | 148.4 J · 71.8 W | | Linux x86, Xeon Platinum 8259CL (AWS g4dn.metal) | RAPL powercap | CPU package | **HwShunt** | 154.2 J · 77.1 W (1 µJ res) | +| Mac Studio, M3 Ultra, macOS 27.2 | SMC `PSTR`, integrated | whole machine (`System`) | **ModelBased** | 299.9 J · 120.0 W (2.5 s) | +| MacBook Pro, M5 Max, macOS 27.2, on AC | SMC `PSTR`, integrated | whole machine (`System`) | **ModelBased** | 160.9 J · 64.4 W (2.5 s) | Notes: @@ -145,11 +149,46 @@ checked against the test's own read on each span checked against the oracle (the span included. On a shared host, the 1.5 s burn gives a figure or `GpuShared`, depending on what else runs on the GPU. +## A Mac as a whole machine (SMC `PSTR`, unprivileged) + +The SMC publishes the machine's total power as the key `PSTR`, and any process +reads it through the `AppleSMCKeysEndpoint` IOKit user client, with no root. +[`apple_smc`](src/apple_smc.rs) (feature `smc`) integrates it over time: + +| | | +|---|---| +| Domain | `System`: everything the machine's DC input feeds (SoC, memory, storage, fans, the rest of the board), not the power supply's AC-to-DC loss | +| Grade | **ModelBased**: a sampled power value integrated over time, like NVML's `power.draw` integral, never `HwShunt` | +| Sampling | one thread per process reads `PSTR` every 100 ms and integrates the value it holds, so a figure does not depend on how often its caller reads | +| Checks | `PSTR` must be a 4-byte float and be seen to refresh before the counter is available; samples at 0 W or less, above 1.5 kW, or not a number are discarded; a value held 10 s gives no figure | +| Minimum window | 2 s | + +Measured on macOS 27.2: + +- **Refresh.** `PSTR` changes about once a second on an M3 Ultra Mac Studio and + an M5 Max MacBook Pro (median gap between changes 1.0 s on both), and trails a + step in CPU load by about one refresh. Hence the 2 s minimum window. +- **Coverage.** On the Mac Studio `PSTR` equals the DC input, `PDTR`. On the + MacBook Pro on AC with the battery not charging, `PSTR` read 51.7 W against + 54.9 W at the DC input: it is the system's draw, not the input's. +- **Not established:** whether each value averages the second before it or + samples one point. Other work on the dev Mac swings it between about 35 and 64 W, + which hides a 4-core burst of 200 ms. The `ModelBased` grade and the 2 s window + hold either way. + +`detect_meter` puts it after a live IOReport counter, a hardware reading over a +narrower domain, and before the load-average estimator, which reads no power. On +both Macs above IOReport's CPU rails are frozen, so with `smc` the host meter is +the SMC: a figure for the whole machine, which holds the work and everything +else the machine powered over the window, so it can read high for the work but +never low. + ## Not yet a hardware reading - **Apple Silicon host-wide CPU energy on macOS 27.0.1**, for an unprivileged process — the IOReport CPU rails are frozen (above), so the load-avg estimator - meters work outside this process. + meters work outside this process. Built with `smc`, the whole machine meters + it instead, `ModelBased` (above): a figure for the machine, not for the CPU. - **Apple Neural Engine energy**, unprivileged: `ANE0` is frozen and ANE work runs in `aned`; the `H1xANELoadBalancer` clients carry no time or energy. - **x86 CPU energy on Windows** — RAPL MSRs need a kernel driver (e.g. WinRing0) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/examples/energy_probe.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/examples/energy_probe.rs index 50739b31f..f395eeb0e 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/examples/energy_probe.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/examples/energy_probe.rs @@ -15,6 +15,10 @@ //! # load-avg estimator, with the reason printed): //! cargo run -p jouleclaw-energy --release --example energy_probe --features ioreport //! +//! # Mac, whole machine (the SMC's `PSTR` integrated over time, ModelBased; +//! # with `ioreport` too, after a live IOReport counter): +//! cargo run -p jouleclaw-energy --release --example energy_probe --features smc +//! //! It samples the counter across a ~2 s window (driving a small CPU workload so //! the package/SoC is not fully idle) and prints the integrated microjoules. For //! the integrating counters (NVML) the repeated reads during the window are what @@ -65,6 +69,20 @@ fn pick_counter() -> Option<(&'static str, Box)> { return Some(("apple-ioreport (Energy Model)", Box::new(c))); } println!("ioreport : declined ({:?})", c.status()); + } + #[cfg(all(feature = "smc", target_os = "macos"))] + { + // The whole machine's power from the SMC, integrated over time: a vendor + // power reading, so ModelBased, over the System domain. + let c = jouleclaw_energy::apple_smc::SmcSystemCounter::new(); + if c.is_available() { + println!("smc : PSTR {:.1} W now", c.watts().unwrap_or(0.0)); + return Some(("apple-smc (SMC PSTR, whole machine)", Box::new(c))); + } + println!("smc : unavailable ({})", c.unavailable_reason().unwrap_or("?")); + } + #[cfg(all(feature = "ioreport", target_os = "macos"))] + { let c = jouleclaw_energy::apple::AppleSiliconMeter::new(); if c.is_available() { return Some(("apple (load-avg estimator)", Box::new(c))); @@ -93,7 +111,7 @@ fn main() { let Some((label, counter)) = pick_counter() else { eprintln!( "no hardware energy counter available on this host/build.\n\ - rebuild with --features nvml|rapl|ioreport for this platform." + rebuild with --features nvml|rapl|ioreport|smc for this platform." ); std::process::exit(1); }; @@ -114,9 +132,11 @@ fn main() { } }; - // Integrate over a ~2 s window: churn the CPU and re-read every ~200 ms so - // the integrating counters accumulate the wall-power integral. - let window = Duration::from_secs(2); + // Integrate over a ~2 s window, or the counter's minimum window if longer + // (plus a margin): churn the CPU and re-read every ~200 ms so the + // integrating counters accumulate the wall-power integral. + let floor = Duration::from_nanos(counter.min_window_ns()); + let window = Duration::from_secs(2).max(floor + Duration::from_millis(500)); let start = Instant::now(); let mut checksum: u64 = 0; while start.elapsed() < window { diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs index ec54c7653..59a2761b9 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs @@ -3596,13 +3596,15 @@ mod tests { /// A declined counter yields no number at all, and the fallback that runs /// in its place carries its own grade end to end: `detect_meter` hands out - /// the load-average estimator, and its readings say `Estimator` — never a - /// hardware-graded figure from a frozen rail. And it meters work that ran: - /// 150 ms of CPU work, read through `detect_meter`, is a figure larger than - /// one quantum of the meter, at the fallback's grade — not a zero, and not - /// a floor. + /// the SMC's whole-machine integral where the `smc` feature is on and + /// `PSTR` is live (its readings say `ModelBased`), and the load-average + /// estimator otherwise (`Estimator`) — never a hardware-graded figure from + /// a frozen rail. And it meters work that ran: 150 ms of CPU work, or the + /// meter's minimum window if longer, read through `detect_meter`, is a + /// figure larger than one quantum of the meter, at the fallback's grade — + /// not a zero, and not a floor. #[test] - fn a_declined_counter_yields_no_reading_and_the_fallback_says_estimator() { + fn a_declined_counter_yields_no_reading_and_the_fallback_carries_its_grade() { let counter = AppleIoReportCounter::new(); let composite = crate::meter::detect_meter(); let active = composite.active().expect("detect_meter always has a meter"); @@ -3616,11 +3618,22 @@ mod tests { matches!(counter.read(), Err(EnergyError::NoCounter(EnergyDomain::SocTotal))), "a declined IOReport counter must not produce a reading" ); - // The load-average estimator on Apple Silicon; the static one elsewhere. - let fallback = if cfg!(target_arch = "aarch64") { "apple-silicon" } else { "estimation" }; + // The SMC's whole-machine integral where it is built and live; else + // the load-average estimator on Apple Silicon, the static one elsewhere. + #[cfg(feature = "smc")] + let smc = crate::apple_smc::SmcSystemCounter::new().is_available(); + #[cfg(not(feature = "smc"))] + let smc = false; + let (fallback, grade) = if smc { + ("apple-smc", Provenance::ModelBased) + } else if cfg!(target_arch = "aarch64") { + ("apple-silicon", Provenance::Estimator) + } else { + ("estimation", Provenance::Estimator) + }; assert_eq!(composite.active_name(), fallback); - assert_eq!(active.provenance(), Provenance::Estimator); - assert_eq!(reading.provenance, Provenance::Estimator); + assert_eq!(active.provenance(), grade); + assert_eq!(reading.provenance, grade); let work = Duration::from_millis(150).max(Duration::from_nanos(active.min_window_ns())); let before = composite.read().expect("the fallback reads"); @@ -3638,7 +3651,7 @@ mod tests { ); assert_eq!( (before.provenance, after.provenance), - (Provenance::Estimator, Provenance::Estimator), + (grade, grade), "the fallback's figure must carry the fallback's grade" ); assert!( diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_smc.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_smc.rs new file mode 100644 index 000000000..2c95170cd --- /dev/null +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_smc.rs @@ -0,0 +1,711 @@ +//! Whole-machine power on a Mac, from its System Management Controller: +//! domain [`EnergyDomain::System`], graded [`Provenance::ModelBased`]. +//! +//! The SMC publishes the machine's total power as the key `PSTR`, a 4-byte +//! little-endian float in watts. An unprivileged process reads it through the +//! `AppleSMCKeysEndpoint` IOKit user client: no root, no entitlement. Measured +//! on macOS 27.2 on an M5 Max MacBook Pro and an M3 Ultra Mac Studio: both +//! publish it, refreshed about once a second, and the value trails a step in +//! CPU load by about one refresh. +//! +//! **What it covers.** Everything the machine's DC power input feeds: the SoC, +//! memory, storage, fans and the rest of the board, so the work wherever it ran +//! on this machine and everything else the machine powered meanwhile. Not the +//! power supply's own AC-to-DC loss. On the Mac Studio `PSTR` equals the DC +//! input (`PDTR`). On the MacBook Pro, on AC with the battery not charging, it +//! read 51.7 W against 54.9 W at the DC input: it is the system's draw, not the +//! input's. +//! +//! **Grade.** `PSTR` is a power reading, not an energy counter. This counter +//! integrates it over time, a Riemann sum of the SMC's sampled power, so a +//! figure advances whenever time passes and no liveness probe can vouch for it. +//! Like NVML's `power.draw` integral, its readings are graded +//! [`Provenance::ModelBased`] (JouleClaw SPEC §1.3), never `HwShunt`. +//! +//! **Sampling.** One sampler thread per process, started by the first +//! [`SmcSystemCounter::new`], reads `PSTR` every [`POLL`] for the life of the +//! process and integrates the value it holds, so a figure does not depend on how +//! often its caller reads. A read between polls extends the integral to now at +//! the held value, which the next poll integrates over the same time, so +//! readings never run backwards. +//! +//! **Checks.** +//! - `PSTR` must exist and be a 4-byte float (`flt `). +//! - The counter is available only once the sampler has seen the value refresh, +//! within [`FIRST_REFRESH_TIMEOUT`] of its first reading: a value that never +//! moves is not a live reading. The first construction in a process waits for +//! that, about a second. +//! - A running machine never draws 0 W, and no Mac draws more than +//! [`MAX_SYSTEM_WATTS`]. A sample outside that range, or not a number, is +//! discarded: the previous value is held and the sample is counted +//! ([`SmcSystemCounter::implausible_samples`]). +//! - A read gives no figure once the value has held for [`STALE_AFTER`]: the +//! SMC stopped refreshing it, or the sampler stopped reading it. +//! +//! Resolution: 1 mJ, the `ModelBased` floor. Minimum window: [`MIN_WINDOW`], +//! one refresh period of the value plus its lag behind the load. +//! +//! Compiled on macOS with the `smc` feature. + +use std::ffi::{CStr, c_char, c_void}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, mpsc}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use crate::meter::EnergyMeter; +use crate::{EnergyCounter, EnergyDomain, EnergyError, EnergyReading, Provenance}; + +/// How often the sampler reads `PSTR`. A tenth of its ~1 s refresh period, so a +/// new value is integrated from at most 100 ms after the SMC publishes it. +pub const POLL: Duration = Duration::from_millis(100); + +/// How long the sampler waits, from its first reading, to see `PSTR` refresh +/// before it declares the counter unavailable: three refresh periods. +pub const FIRST_REFRESH_TIMEOUT: Duration = Duration::from_secs(3); + +/// A value that has held this long (ten refresh periods) is not current. +pub const STALE_AFTER: Duration = Duration::from_secs(10); + +/// The smallest window a figure means anything over: the value's ~1 s refresh +/// period plus its ~1 s lag behind the load. +pub const MIN_WINDOW: Duration = Duration::from_secs(2); + +/// A sample above this is a glitch. It is above the largest Apple Silicon Mac's +/// power supply (the Mac Pro's 1,280 W). +pub const MAX_SYSTEM_WATTS: f32 = 1_500.0; + +/// The SMC key for the machine's total power. +const PSTR: u32 = u32::from_be_bytes(*b"PSTR"); +/// The SMC data type of a 4-byte float. +const TYPE_FLT: u32 = u32::from_be_bytes(*b"flt "); + +// --- IOKit and the SMC user client -------------------------------------------- + +type IoObject = libc::mach_port_t; + +const KERN_SUCCESS: libc::kern_return_t = 0; +/// `kIOMainPortDefault` is `MACH_PORT_NULL`. +const MAIN_PORT_DEFAULT: libc::mach_port_t = 0; +/// The user client's struct method that carries every key request. +const KERNEL_INDEX_SMC: u32 = 2; +/// Request: read a key's bytes. +const SMC_CMD_READ_BYTES: u8 = 5; +/// Request: read a key's size and type. +const SMC_CMD_READ_KEYINFO: u8 = 9; +/// Result: no such key. +const SMC_KEY_NOT_FOUND: u8 = 0x84; + +#[link(name = "IOKit", kind = "framework")] +unsafe extern "C" { + static mach_task_self_: libc::mach_port_t; + + fn IOServiceMatching(name: *const c_char) -> *mut c_void; + fn IOServiceGetMatchingServices( + main_port: libc::mach_port_t, + matching: *const c_void, + existing: *mut IoObject, + ) -> libc::kern_return_t; + fn IOIteratorNext(iterator: IoObject) -> IoObject; + fn IOObjectRelease(object: IoObject) -> libc::kern_return_t; + fn IORegistryEntryGetName(entry: IoObject, name: *mut c_char) -> libc::kern_return_t; + fn IOServiceOpen( + service: IoObject, + owning_task: libc::mach_port_t, + kind: u32, + connect: *mut IoObject, + ) -> libc::kern_return_t; + fn IOServiceClose(connect: IoObject) -> libc::kern_return_t; + fn IOConnectCallStructMethod( + connection: IoObject, + selector: u32, + input: *const c_void, + input_size: usize, + output: *mut c_void, + output_size: *mut usize, + ) -> libc::kern_return_t; +} + +/// The SMC's request and reply, `SMCKeyData_t`: 80 bytes, laid out as the C +/// struct (the asserts below pin each field the SMC reads or writes). The +/// fields this module never reads are still part of the layout the SMC expects. +#[repr(C)] +#[derive(Clone, Copy, Default)] +#[allow(dead_code)] +struct KeyData { + key: u32, + /// `SMCKeyData_vers_t`: major, minor, build, reserved, release (u16). + vers: [u16; 3], + /// `SMCKeyData_pLimitData_t`: version, length (u16 each), then three u32. + p_limit: [u32; 4], + key_info: KeyInfo, + result: u8, + status: u8, + data8: u8, + data32: u32, + bytes: [u8; 32], +} + +/// `SMCKeyData_keyInfo_t`. The SMC fills in `data_attributes`; this module +/// does not read it. +#[repr(C)] +#[derive(Clone, Copy, Default)] +#[allow(dead_code)] +struct KeyInfo { + data_size: u32, + data_type: u32, + data_attributes: u8, +} + +const _: () = { + assert!(size_of::() == 80); + assert!(std::mem::offset_of!(KeyData, vers) == 4); + assert!(std::mem::offset_of!(KeyData, p_limit) == 12); + assert!(std::mem::offset_of!(KeyData, key_info) == 28); + assert!(std::mem::offset_of!(KeyData, result) == 40); + assert!(std::mem::offset_of!(KeyData, data8) == 42); + assert!(std::mem::offset_of!(KeyData, data32) == 44); + assert!(std::mem::offset_of!(KeyData, bytes) == 48); +}; + +/// An open connection to the SMC's key endpoint. +struct Smc { + conn: IoObject, +} + +impl Smc { + fn open() -> Result { + // SAFETY: a NUL-terminated class name; the returned dictionary is + // consumed by `IOServiceGetMatchingServices`, which is called on it at + // once and not otherwise used. + let matching = unsafe { IOServiceMatching(c"AppleSMC".as_ptr()) }; + if matching.is_null() { + return Err("IOServiceMatching(AppleSMC) returned no dictionary".into()); + } + let mut iterator: IoObject = 0; + // SAFETY: `matching` is the dictionary just created; `iterator` is a + // valid out pointer. + if unsafe { IOServiceGetMatchingServices(MAIN_PORT_DEFAULT, matching, &mut iterator) } != KERN_SUCCESS { + return Err("no AppleSMC service".into()); + } + let mut conn: IoObject = 0; + let mut failure = None; + loop { + // SAFETY: `iterator` is the live iterator returned above. + let service = unsafe { IOIteratorNext(iterator) }; + if service == 0 { + break; + } + let mut name = [0 as c_char; 128]; // io_name_t + // SAFETY: `name` is a 128-byte buffer, the size of `io_name_t`. + let named = unsafe { IORegistryEntryGetName(service, name.as_mut_ptr()) } == KERN_SUCCESS + // SAFETY: on success the buffer holds a NUL-terminated name. + && unsafe { CStr::from_ptr(name.as_ptr()) }.to_bytes() == b"AppleSMCKeysEndpoint"; + if named && conn == 0 { + // SAFETY: `service` is a live registry entry; `mach_task_self_` + // is this task's port, set by the runtime before `main`. + let rc = unsafe { IOServiceOpen(service, mach_task_self_, 0, &mut conn) }; + if rc != KERN_SUCCESS { + conn = 0; + failure = Some(format!("IOServiceOpen(AppleSMCKeysEndpoint): {rc:#x}")); + } + } + // SAFETY: each entry the iterator returns is ours to release. + unsafe { IOObjectRelease(service) }; + } + // SAFETY: the iterator is ours to release. + unsafe { IOObjectRelease(iterator) }; + if conn == 0 { + return Err(failure.unwrap_or_else(|| "no AppleSMCKeysEndpoint service".into())); + } + Ok(Self { conn }) + } + + fn call(&self, input: &KeyData) -> Result { + let mut output = KeyData::default(); + let mut size = size_of::(); + // SAFETY: both buffers are `KeyData`, 80 bytes, and `size` says so. + let rc = unsafe { + IOConnectCallStructMethod( + self.conn, + KERNEL_INDEX_SMC, + (input as *const KeyData).cast(), + size_of::(), + (&mut output as *mut KeyData).cast(), + &mut size, + ) + }; + if rc != KERN_SUCCESS { + return Err(format!("IOConnectCallStructMethod: {rc:#x}")); + } + match output.result { + 0 => Ok(output), + SMC_KEY_NOT_FOUND => Err("no such SMC key".into()), + other => Err(format!("SMC result {other:#x}")), + } + } +} + +impl Drop for Smc { + fn drop(&mut self) { + // SAFETY: `conn` is the connection `open` made, closed once. + unsafe { IOServiceClose(self.conn) }; + } +} + +/// `PSTR`, checked once to be a 4-byte float, read on demand. +struct Pstr { + smc: Smc, + info: KeyInfo, +} + +impl Pstr { + fn open() -> Result { + let smc = Smc::open()?; + let info = smc + .call(&KeyData { key: PSTR, data8: SMC_CMD_READ_KEYINFO, ..KeyData::default() }) + .map_err(|e| format!("PSTR: {e}"))? + .key_info; + if info.data_size != 4 || info.data_type != TYPE_FLT { + let kind = String::from_utf8_lossy(&info.data_type.to_be_bytes()).into_owned(); + return Err(format!("PSTR is {} bytes of type {kind:?}, not a 4-byte float", info.data_size)); + } + Ok(Self { smc, info }) + } + + fn watts(&self) -> Result { + let reply = self + .smc + .call(&KeyData { key: PSTR, key_info: self.info, data8: SMC_CMD_READ_BYTES, ..KeyData::default() }) + .map_err(|e| format!("PSTR: {e}"))?; + let [a, b, c, d, ..] = reply.bytes; + Ok(f32::from_le_bytes([a, b, c, d])) + } +} + +// --- The integral -------------------------------------------------------------- + +/// Whether a sample can be a running machine's total power. +fn plausible(watts: f32) -> bool { + watts > 0.0 && watts <= MAX_SYSTEM_WATTS +} + +/// `watts` held for `held`, in µJ, rounded down. +fn uj(watts: f32, held: Duration) -> u64 { + (f64::from(watts) * held.as_secs_f64() * 1e6) as u64 +} + +/// The sampler's integral. Pure: the sampler and the tests drive it with the +/// times and samples they choose. +#[derive(Debug)] +struct State { + /// The value being integrated, W: the last plausible sample. + watts: f32, + /// When the sampler last polled. + polled_at: Instant, + /// When the value last changed. + changed_at: Instant, + /// Energy integrated up to `polled_at`, µJ. + total_uj: u64, + /// Samples discarded as implausible. + implausible: u64, + /// The last poll's failure, cleared by the next reading. + last_error: Option, +} + +impl State { + /// The integral from `at`, where the sampler saw `watts` as a fresh value. + fn new(watts: f32, at: Instant) -> Self { + Self { watts, polled_at: at, changed_at: at, total_uj: 0, implausible: 0, last_error: None } + } + + /// One poll at `at`: integrate the held value up to `at`, then take the + /// sample as the value to hold from here, if it is plausible. + fn poll(&mut self, at: Instant, sample: Result) { + let held = at.saturating_duration_since(self.polled_at); + self.total_uj = self.total_uj.saturating_add(uj(self.watts, held)); + self.polled_at = self.polled_at.max(at); + match sample { + Ok(watts) if plausible(watts) => { + self.last_error = None; + if watts != self.watts { + self.watts = watts; + self.changed_at = self.polled_at; + } + } + Ok(_) => { + self.last_error = None; + self.implausible += 1; + } + Err(e) => self.last_error = Some(e), + } + } + + /// The integral at `now`, or why there is no current figure. + fn energy_at(&self, now: Instant) -> Result { + let held = now.saturating_duration_since(self.changed_at); + if held > STALE_AFTER { + let why = match &self.last_error { + Some(e) => format!("; the last read failed: {e}"), + None => String::new(), + }; + return Err(format!( + "SMC PSTR has held {} W for {held:?}, more than {STALE_AFTER:?}: not a current reading{why}", + self.watts + )); + } + Ok(self.total_uj.saturating_add(uj(self.watts, now.saturating_duration_since(self.polled_at)))) + } +} + +type Shared = Arc>; + +fn lock(shared: &Shared) -> MutexGuard<'_, State> { + shared.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Start a sampler over `open`'s reader, polling every `poll`. It reports once +/// it has seen the value refresh, or why it will not run: the reader would not +/// open, its first reading was implausible, or the value did not change within +/// `first_refresh`. +fn start( + open: impl FnOnce() -> Result + Send + 'static, + poll: Duration, + first_refresh: Duration, +) -> Result +where + R: FnMut() -> Result + 'static, +{ + let (tx, rx) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("jouleclaw-smc".into()) + .spawn(move || run(tx, open, poll, first_refresh)) + .map_err(|e| format!("the SMC sampler thread did not start: {e}"))?; + rx.recv().map_err(|_| "the SMC sampler stopped before it reported".to_string())? +} + +fn run( + tx: mpsc::SyncSender>, + open: impl FnOnce() -> Result, + poll: Duration, + first_refresh: Duration, +) where + R: FnMut() -> Result, +{ + let mut read = match open() { + Ok(read) => read, + Err(e) => { + let _ = tx.send(Err(e)); + return; + } + }; + let first = match read() { + Ok(watts) if plausible(watts) => watts, + Ok(watts) => { + let _ = tx.send(Err(format!("the first PSTR reading, {watts} W, is not a running machine's power"))); + return; + } + Err(e) => { + let _ = tx.send(Err(e)); + return; + } + }; + let began = Instant::now(); + let fresh = loop { + std::thread::sleep(poll); + match read() { + Ok(watts) if plausible(watts) && watts != first => break watts, + _ if began.elapsed() >= first_refresh => { + let _ = tx.send(Err(format!( + "SMC PSTR held {first} W for {first_refresh:?} after its first reading: not a live reading" + ))); + return; + } + _ => {} + } + }; + let shared: Shared = Arc::new(Mutex::new(State::new(fresh, Instant::now()))); + if tx.send(Ok(Arc::clone(&shared))).is_err() { + return; + } + loop { + std::thread::sleep(poll); + let sample = read(); + // The poll's time is taken under the lock, after the SMC read, so no + // reader can have extended the integral past it. + let mut state = lock(&shared); + state.poll(Instant::now(), sample); + } +} + +/// The process's one sampler over the real SMC, started on first use. +fn sampler() -> Result { + static SAMPLER: OnceLock> = OnceLock::new(); + SAMPLER + .get_or_init(|| { + start( + || { + let pstr = Pstr::open()?; + Ok(move || pstr.watts()) + }, + POLL, + FIRST_REFRESH_TIMEOUT, + ) + }) + .clone() +} + +// --- The counter ----------------------------------------------------------------- + +/// The machine's total energy since this process's SMC sampler started, from +/// `PSTR` integrated over time: [`EnergyDomain::System`], +/// [`Provenance::ModelBased`]. +pub struct SmcSystemCounter { + sampler: Result, +} + +impl Default for SmcSystemCounter { + fn default() -> Self { + Self::new() + } +} + +impl SmcSystemCounter { + /// The counter over this process's SMC sampler. The first call in a process + /// starts the sampler and waits until it has seen `PSTR` refresh (about a + /// second; at most [`FIRST_REFRESH_TIMEOUT`]); every later call shares it. + pub fn new() -> Self { + Self { sampler: sampler() } + } + + /// Whether the sampler runs: `PSTR` exists, is a float, read plausibly and + /// was seen to refresh. + pub fn is_available(&self) -> bool { + self.sampler.is_ok() + } + + /// Why the counter is unavailable, if it is. + pub fn unavailable_reason(&self) -> Option<&str> { + self.sampler.as_ref().err().map(String::as_str) + } + + /// The power being integrated now, W: the last plausible `PSTR` sample. + pub fn watts(&self) -> Option { + self.sampler.as_ref().ok().map(|s| lock(s).watts) + } + + /// How many samples were discarded as implausible (0 W or less, above + /// [`MAX_SYSTEM_WATTS`], or not a number). + pub fn implausible_samples(&self) -> u64 { + self.sampler.as_ref().map_or(0, |s| lock(s).implausible) + } +} + +impl EnergyCounter for SmcSystemCounter { + fn domain(&self) -> EnergyDomain { + EnergyDomain::System + } + + fn provenance(&self) -> Provenance { + // A sampled power value integrated over time: never a hardware reading. + Provenance::ModelBased + } + + fn resolution_uj(&self) -> u64 { + 1_000 + } + + fn min_window_ns(&self) -> u64 { + MIN_WINDOW.as_nanos() as u64 + } + + fn read(&self) -> Result { + let shared = self.sampler.as_ref().map_err(|_| EnergyError::NoCounter(EnergyDomain::System))?; + let uj = lock(shared).energy_at(Instant::now()).map_err(EnergyError::Platform)?; + let timestamp_ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| EnergyError::Platform(e.to_string()))? + .as_nanos() as u64; + Ok(EnergyReading { uj, timestamp_ns, domain: EnergyDomain::System, provenance: Provenance::ModelBased }) + } +} + +impl EnergyMeter for SmcSystemCounter { + fn name(&self) -> &'static str { + "apple-smc" + } + + fn is_available(&self) -> bool { + self.sampler.is_ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MS: Duration = Duration::from_millis(1); + + fn counter(sampler: Result) -> SmcSystemCounter { + SmcSystemCounter { sampler } + } + + #[test] + fn the_layout_is_the_smc_s() { + // Pinned at compile time; restated so a reader sees the size here. + assert_eq!(size_of::(), 80); + assert_eq!(PSTR.to_be_bytes(), *b"PSTR"); + assert_eq!(TYPE_FLT.to_be_bytes(), *b"flt "); + } + + /// Each poll integrates the value held since the previous one, and a read + /// between polls extends it to now at the held value. + #[test] + fn the_held_value_is_integrated() { + let t0 = Instant::now(); + let mut s = State::new(10.0, t0); + s.poll(t0 + 100 * MS, Ok(10.0)); + assert_eq!(s.total_uj, 1_000_000, "10 W for 100 ms"); + s.poll(t0 + 200 * MS, Ok(20.0)); + assert_eq!(s.total_uj, 2_000_000, "the second 100 ms is still at the held 10 W"); + assert_eq!(s.energy_at(t0 + 250 * MS), Ok(3_000_000), "then 20 W for 50 ms"); + } + + /// A read between polls never exceeds what the next poll integrates, so a + /// sequence of reads never runs backwards. + #[test] + fn reads_never_run_backwards() { + let t0 = Instant::now(); + let mut s = State::new(50.0, t0); + let mut last = 0; + for i in 1..=200u32 { + let at = t0 + i * 37 * MS / 10; + if i % 3 == 0 { + s.poll(at, Ok(40.0 + (i % 17) as f32)); + } + let e = s.energy_at(at).expect("current"); + assert!(e >= last, "read {i}: {e} < {last}"); + last = e; + } + } + + /// A sample that cannot be a running machine's power is not integrated: + /// the previous value is held, and the sample is counted. + #[test] + fn implausible_samples_are_held_over_and_counted() { + let t0 = Instant::now(); + let mut s = State::new(30.0, t0); + let bad = [0.0, -4.0, f32::NAN, f32::INFINITY, MAX_SYSTEM_WATTS + 1.0]; + for (i, w) in bad.into_iter().enumerate() { + s.poll(t0 + (i as u32 + 1) * 100 * MS, Ok(w)); + } + assert_eq!(s.watts, 30.0); + assert_eq!(s.implausible, bad.len() as u64); + assert_eq!(s.total_uj, 15_000_000, "30 W held for 500 ms"); + s.poll(t0 + 600 * MS, Ok(MAX_SYSTEM_WATTS)); + assert_eq!(s.watts, MAX_SYSTEM_WATTS, "the ceiling itself is plausible"); + } + + /// A value that has held for longer than `STALE_AFTER` gives no figure, + /// whether the SMC kept returning it or the reads failed; a fresh value + /// brings the figure back. + #[test] + fn a_held_value_goes_stale() { + let t0 = Instant::now(); + let mut s = State::new(12.5, t0); + for i in 1..=110u32 { + s.poll(t0 + i * 100 * MS, Ok(12.5)); + } + let held = s.energy_at(t0 + 11_000 * MS).expect_err("held 11 s"); + assert!(held.contains("12.5 W") && held.contains("not a current reading"), "{held}"); + s.poll(t0 + 11_100 * MS, Ok(13.0)); + assert!(s.energy_at(t0 + 11_100 * MS).is_ok()); + + let mut s = State::new(12.5, t0); + for i in 1..=110u32 { + s.poll(t0 + i * 100 * MS, Err("IOConnectCallStructMethod: 0xe00002c2".into())); + } + assert_eq!(s.total_uj, uj(12.5, 11_000 * MS), "failed reads hold the value until it is stale"); + let failed = s.energy_at(t0 + 11_000 * MS).expect_err("stale"); + assert!(failed.contains("0xe00002c2"), "{failed}"); + } + + /// The sampler reports only once it has seen the value refresh, and says + /// why it will not run otherwise. + #[test] + fn the_sampler_starts_only_on_a_refresh() { + let script = |values: Vec>| { + move || { + let mut values = values.into_iter(); + Ok(move || values.next().unwrap_or(Ok(7.0))) + } + }; + let ok = start(script(vec![Ok(5.0), Ok(5.0), Ok(6.0)]), MS, 50 * MS).expect("a refresh was seen"); + assert_eq!(lock(&ok).watts, 6.0, "the integral starts at the refresh"); + + let held = start(|| Ok(|| Ok(5.0)), MS, 30 * MS).expect_err("never refreshed"); + assert!(held.contains("held 5 W") && held.contains("not a live reading"), "{held}"); + + let first = start(script(vec![Ok(0.0)]), MS, 30 * MS).expect_err("0 W first"); + assert!(first.contains("not a running machine's power"), "{first}"); + + let closed = start(|| Err:: Result, _>("no AppleSMCKeysEndpoint service".into()), MS, 30 * MS) + .expect_err("did not open"); + assert_eq!(closed, "no AppleSMCKeysEndpoint service"); + + let err = start(script(vec![Err("PSTR: no such SMC key".into())]), MS, 30 * MS).expect_err("no key"); + assert_eq!(err, "PSTR: no such SMC key"); + } + + /// The counter's contract: whole-machine domain, a `ModelBased` grade on + /// every reading, and no reading without a sampler. + #[test] + fn the_counter_declares_its_domain_and_grade() { + let none = counter(Err("no AppleSMCKeysEndpoint service".into())); + assert!(!none.is_available()); + assert_eq!(none.unavailable_reason(), Some("no AppleSMCKeysEndpoint service")); + assert!(matches!(none.read(), Err(EnergyError::NoCounter(EnergyDomain::System)))); + assert_eq!((none.watts(), none.implausible_samples()), (None, 0)); + + let shared = Arc::new(Mutex::new(State::new(20.0, Instant::now()))); + let c = counter(Ok(shared)); + assert_eq!((c.domain(), c.provenance(), c.name()), (EnergyDomain::System, Provenance::ModelBased, "apple-smc")); + assert!(c.resolution_uj() >= 1_000); + assert_eq!(c.min_window_ns(), 2_000_000_000); + let r = c.read().expect("reads"); + assert_eq!((r.domain, r.provenance), (EnergyDomain::System, Provenance::ModelBased)); + assert_eq!(c.watts(), Some(20.0)); + } + + /// On this Mac: the SMC's `PSTR`, read with no privilege, meters a CPU + /// burn longer than the minimum window as a figure a machine can draw. + #[test] + fn the_smc_meters_this_mac() { + let c = SmcSystemCounter::new(); + let Some(watts) = c.watts() else { + eprintln!("apple-smc: unavailable: {}", c.unavailable_reason().unwrap_or("?")); + return; + }; + let window = MIN_WINDOW + Duration::from_millis(500); + let r0 = c.read().expect("an available counter reads"); + let t = Instant::now(); + let mut x: u64 = 0x9E37_79B9_7F4A_7C15; + while t.elapsed() < window { + for _ in 0..100_000 { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + } + } + std::hint::black_box(x); + let r1 = c.read().expect("an available counter reads"); + let took = t.elapsed(); + let uj = r1.uj.checked_sub(r0.uj).expect("never runs backwards"); + let average = uj as f64 / took.as_secs_f64() / 1e6; + eprintln!( + "apple-smc: measured {uj} µJ over {took:?} = {average:.1} W [{}] (PSTR {watts:.1} W at start, {} implausible)", + r1.provenance, + c.implausible_samples() + ); + assert_eq!((r1.domain, r1.provenance), (EnergyDomain::System, Provenance::ModelBased)); + assert!(average > 0.5 && average <= f64::from(MAX_SYSTEM_WATTS), "{average} W is not a running Mac's draw"); + } +} diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs index fce2e6432..8020d3a53 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs @@ -12,7 +12,10 @@ //! counter's resolution. //! - [`Provenance::ModelBased`] — a vendor-provided model estimate derived //! from frequency, voltage, and utilization (NVML `power.draw` on older -//! GPUs, ROCm-SMI `power1_average`). Millijoule-quantized at best. +//! GPUs, ROCm-SMI `power1_average`), or a vendor's sampled power integrated +//! over time (the Apple SMC's whole-machine `PSTR`, domain +//! [`EnergyDomain::System`], with the `smc` feature). Millijoule-quantized at +//! best. //! - [`Provenance::Estimator`] — a JouleClaw static cost model derived from //! architecture × precision × batch tables. Used only on platforms //! with no usable hardware counter (consumer AMD GPUs, ARM PMU). @@ -86,6 +89,10 @@ pub mod apple_ioreport; #[cfg(all(feature = "ioreport", target_os = "macos"))] mod apple_gpu; +// The whole machine's power on a Mac (SMC `PSTR`), integrated over time. +#[cfg(all(feature = "smc", target_os = "macos"))] +pub mod apple_smc; + // This process's own GPU energy (Apple Silicon), and a span of in-process work // on its CPU and GPU together. Always compiled; the GPU counter is available // only on macOS with the `ioreport` feature. (Their docs are their own `//!`.) @@ -117,9 +124,10 @@ pub enum Provenance { /// channels whose CPU rails are all live, Jetson INA3221 i2c. HwShunt, /// Vendor-provided model estimate derived from frequency, voltage, - /// and utilization. Millijoule-quantized at best; not a true - /// measurement. Examples: NVML `power.draw` interpolation, ROCm-SMI - /// `power1_average`. + /// and utilization, or a vendor's sampled power integrated over time. + /// Millijoule-quantized at best; not a true measurement. Examples: NVML + /// `power.draw` interpolation, ROCm-SMI `power1_average`, the Apple SMC's + /// system power `PSTR`. ModelBased, /// JouleClaw static cost model from architecture × precision × batch /// tables. Used only when no hardware counter exists. Tolerance @@ -292,6 +300,13 @@ pub enum EnergyDomain { /// client's GPU time over the span. Not the CPU that encoded the work, not /// the ANE, not DRAM. ProcessGpu, + /// The **whole machine**: everything its DC power input feeds — the SoC, + /// memory, storage, fans and the rest of the board — as one figure (on a + /// Mac, the SMC's system-total power `PSTR`, `apple_smc::SmcSystemCounter`). + /// It holds [`Self::SocTotal`] and every other domain on the machine, so it + /// covers work wherever it ran here, and also everything else the machine + /// powered meanwhile. Not the power supply's own AC-to-DC loss. + System, } /// How much of a unit of work a figure accounts for (the family's coverage diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/meter.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/meter.rs index 00f87b1ff..e1090b93b 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/meter.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/meter.rs @@ -257,7 +257,10 @@ impl EnergyMeter for EstimationMeter { /// /// With the `ioreport` feature on macOS, the IOReport counter is selected only /// when [`crate::ioreport_status`] is `Live`; the first call in a process runs -/// that liveness probe on the calling thread. RAPL (`rapl`, Linux) is selected +/// that liveness probe on the calling thread. With the `smc` feature on macOS, +/// the SMC's whole-machine power integral (`ModelBased`, over +/// [`EnergyDomain::System`]) comes next; the first call starts its sampler and +/// waits to see `PSTR` refresh, about a second. RAPL (`rapl`, Linux) is selected /// only when every package counter is live ([`crate::rapl::rapl_liveness`]); NVML (`nvml`) /// reads its cumulative-energy counter as a hardware reading only when that is /// live, and otherwise its power-draw integral, graded `ModelBased`. Each of @@ -283,9 +286,26 @@ pub fn detect_meter() -> CompositeMeter { // an M3 Ultra), and the // summed rails then move only with the GPU, or not at all. // `crate::ioreport_status()` says which case this host is in. When it - // is unavailable, `CompositeMeter` falls through to the load-average - // estimator below, whose readings say `Provenance::Estimator`. + // is unavailable, `CompositeMeter` falls through: to the SMC's + // whole-machine integral where `smc` is on and `PSTR` is live + // (`Provenance::ModelBased`), else to the load-average estimator below, + // whose readings say `Provenance::Estimator`. meters.push(Box::new(crate::apple_ioreport::AppleIoReportCounter::new())); + } + + #[cfg(all(feature = "smc", target_os = "macos"))] + { + // The whole machine's power from the SMC (`PSTR`), integrated over + // time: `ModelBased`, over `EnergyDomain::System`. After a live IOReport + // counter, which is a hardware reading over a narrower domain; before + // the load-average estimator, which reads no power at all. Its domain + // holds the SoC and everything else the machine powers, so a figure + // from it may be high for the work, never low. + meters.push(Box::new(crate::apple_smc::SmcSystemCounter::new())); + } + + #[cfg(all(feature = "ioreport", target_os = "macos"))] + { meters.push(Box::new(crate::apple::AppleSiliconMeter::new())); } @@ -390,6 +410,36 @@ mod tests { assert!(!names.contains(&"process-cpu"), "{names:?}"); } + /// With the `smc` feature on macOS the SMC's whole-machine integral sits + /// after the IOReport counter and before the load-average estimator, and it + /// is the active meter wherever IOReport is not live and `PSTR` is. + #[test] + #[cfg(all(feature = "smc", target_os = "macos"))] + fn the_smc_meter_sits_between_ioreport_and_the_estimator() { + let m = detect_meter(); + let apple: Vec<&str> = m.names().into_iter().filter(|n| n.starts_with("apple-")).collect(); + let want: &[&str] = + if cfg!(feature = "ioreport") { &["apple-ioreport", "apple-smc", "apple-silicon"] } else { &["apple-smc"] }; + assert_eq!(apple, want); + let ioreport_live = crate::ioreport_status() == crate::IoReportStatus::Live; + let smc = crate::apple_smc::SmcSystemCounter::new(); + let expected = if ioreport_live { + "apple-ioreport" + } else if smc.is_available() { + "apple-smc" + } else if cfg!(all(feature = "ioreport", target_arch = "aarch64")) { + "apple-silicon" + } else { + "estimation" + }; + eprintln!("apple-smc: detect_meter holds {:?}, active {}", m.names(), m.active_name()); + assert_eq!(m.active_name(), expected, "{:?}", smc.unavailable_reason()); + if expected == "apple-smc" { + let r = m.read().expect("the SMC meter reads"); + assert_eq!((r.domain, r.provenance), (EnergyDomain::System, Provenance::ModelBased)); + } + } + /// `detect_in_process_meter` prefers the process counter where it is live, /// and is `detect_meter` where it is not (and off macOS). #[test] diff --git a/mesh/mesh-rs/crates/mesh-meter/src/lib.rs b/mesh/mesh-rs/crates/mesh-meter/src/lib.rs index 6010221bf..b3c743757 100644 --- a/mesh/mesh-rs/crates/mesh-meter/src/lib.rs +++ b/mesh/mesh-rs/crates/mesh-meter/src/lib.rs @@ -358,10 +358,14 @@ mod hw { /// request ran on that process's threads alone, and it misses any GPU work /// and any other process serving it. Nor does one process's own GPU energy /// (`ProcessGpu`), for the same reasons and because its CPU is not in it. - /// The match is exhaustive, so a new domain has to be decided here. + /// The whole machine (`System`, a Mac's SMC system power) does: it holds + /// every part the request ran on, and everything else the machine powered + /// meanwhile, so its figure may be high for the request but never misses + /// part of it. The match is exhaustive, so a new domain has to be decided + /// here. pub fn covers_whole_request(domain: EnergyDomain) -> bool { match domain { - EnergyDomain::SocTotal | EnergyDomain::CpuPkg => true, + EnergyDomain::SocTotal | EnergyDomain::CpuPkg | EnergyDomain::System => true, EnergyDomain::CpuCore | EnergyDomain::Dram | EnergyDomain::Gpu @@ -681,11 +685,12 @@ mod hw { /// domain added to the family meter fails to compile in /// `covers_whole_request` before it can be graded here by accident. #[test] - fn only_the_soc_or_a_cpu_package_covers_the_whole_request() { + fn only_the_soc_a_cpu_package_or_the_machine_covers_the_whole_request() { use EnergyDomain::*; for (d, whole) in [ (SocTotal, true), (CpuPkg, true), + (System, true), (CpuCore, false), (Dram, false), (Gpu, false),