jouleclaw-energy: a Mac's whole-machine power from the SMC (EnergyDomain::System, ModelBased) #622
10 changed files with 903 additions and 30 deletions
18
.github/workflows/standards-build.yml
vendored
18
.github/workflows/standards-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<dyn EnergyCounter>)> {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
711
jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_smc.rs
Normal file
711
jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_smc.rs
Normal file
|
|
@ -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::<KeyData>() == 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<Self, String> {
|
||||
// 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<KeyData, String> {
|
||||
let mut output = KeyData::default();
|
||||
let mut size = size_of::<KeyData>();
|
||||
// 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::<KeyData>(),
|
||||
(&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<Self, String> {
|
||||
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<f32, String> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<f32, String>) {
|
||||
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<u64, String> {
|
||||
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<Mutex<State>>;
|
||||
|
||||
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<R>(
|
||||
open: impl FnOnce() -> Result<R, String> + Send + 'static,
|
||||
poll: Duration,
|
||||
first_refresh: Duration,
|
||||
) -> Result<Shared, String>
|
||||
where
|
||||
R: FnMut() -> Result<f32, String> + '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<R>(
|
||||
tx: mpsc::SyncSender<Result<Shared, String>>,
|
||||
open: impl FnOnce() -> Result<R, String>,
|
||||
poll: Duration,
|
||||
first_refresh: Duration,
|
||||
) where
|
||||
R: FnMut() -> Result<f32, String>,
|
||||
{
|
||||
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<Shared, String> {
|
||||
static SAMPLER: OnceLock<Result<Shared, String>> = 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<Shared, String>,
|
||||
}
|
||||
|
||||
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<f32> {
|
||||
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<EnergyReading, EnergyError> {
|
||||
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<Shared, String>) -> 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::<KeyData>(), 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<Result<f32, String>>| {
|
||||
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::<fn() -> Result<f32, String>, _>("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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue