jouledesk-github, Joule Code L3: meter a Mac with the SMC where IOReport is not live (stacked on #622) #624

Merged
dcharlot merged 1 commit from jouleclaw-energy/smc-consumers into main 2026-11-05 04:52:29 -05:00
5 changed files with 126 additions and 27 deletions

View file

@ -17,7 +17,7 @@ joule-code-cargo = { path = "../../joule-code-rs/crates/joule-code-cargo" }
joule-code-receipt = { path = "../../joule-code-rs/crates/joule-code-receipt" }
joule-code-cascade = { path = "../../joule-code-rs/crates/joule-code-cascade" }
jouleclaw-llm = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-llm" }
jouleclaw-energy = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-energy", features = ["ioreport"] }
jouleclaw-energy = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-energy", features = ["ioreport", "smc"] }
# JouleClaw's own from-scratch GGUF tensor runtime (no llama.cpp, no FFI) —
# the in-process L3 backend.
jouleclaw-runtime = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime" }

View file

@ -61,10 +61,14 @@ the reading covers:
the in-process engine, this process's counters not live (the span then falls
back to a host meter, whose figure is the machine's, not the engine's): the
host meter's figure as it is. That is JouleClaw's IOReport counter where its
CPU energy rail is live (`HwShunt`), its load-average estimator where it is not
(macOS 27.0.1 for an unprivileged process, `Estimator`), and the token-count
estimate anywhere else (Linux and Windows included; this binding builds
`jouleclaw-energy` with the `ioreport` feature only).
CPU energy rail is live (`HwShunt`); where it is not (macOS 27 for an
unprivileged process), the Mac's whole-machine power from the SMC, integrated
over time (`ModelBased`), or the load-average estimator where that is not live
either (`Estimator`); and the token-count estimate anywhere else (Linux and
Windows included; this binding builds `jouleclaw-energy` with the `ioreport`
and `smc` features only). An inference shorter than the host counter's minimum
window (2 s for the SMC) takes the token-count estimate too: the counter's
figure is noise below it.
The receipt has no coverage field, so a partial reading is never sealed as a
hardware reading of the whole. The printout names the engine, the host meter and

View file

@ -64,11 +64,17 @@ fn host_counter() -> Option<Box<dyn EnergyMeter>> {
// The IOReport energy counter (HwShunt, a live vendor counter) where it can
// meter CPU work: it is unavailable unless IOReport opens AND its CPU energy
// rail advances under load (`jouleclaw_energy::ioreport_status()` says why
// not). Otherwise the load-average estimator (Estimator).
// not). Otherwise the Mac's whole-machine power from the SMC, integrated
// over time (ModelBased), where `PSTR` is live; otherwise the load-average
// estimator (Estimator).
let ioreport = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
if ioreport.is_available() {
return Some(Box::new(ioreport));
}
let smc = jouleclaw_energy::apple_smc::SmcSystemCounter::new();
if smc.is_available() {
return Some(Box::new(smc));
}
let meter = jouleclaw_energy::apple::AppleSiliconMeter::new();
if meter.is_available() {
return Some(Box::new(meter));
@ -77,6 +83,18 @@ fn host_counter() -> Option<Box<dyn EnergyMeter>> {
None
}
/// The counter's reading at the end of a window that lasted `elapsed`, or none
/// where the window was shorter than the counter's minimum (`min_window_ns`): its
/// readings are noise below it (the SMC's whole-machine figure needs 2 s), so the
/// receipt takes the token estimate instead ([`receipt_energy`]).
fn read_after(counter: &Option<Box<dyn EnergyMeter>>, elapsed: std::time::Duration) -> Option<(u64, JcProvenance)> {
let c = counter.as_ref()?;
if elapsed.as_nanos() < u128::from(c.min_window_ns()) {
return None;
}
c.read().ok().map(|r| (r.uj, r.provenance))
}
/// The receipt energy for an inference: the host counter's delta across it, with
/// the counter's provenance, or — without a counter, or without two readings of
/// it — the token-count estimate. `true` when the figure came from the counter.
@ -293,6 +311,7 @@ fn main() {
};
let _ = read(&counter); // prime
let before = read(&counter);
let started = std::time::Instant::now();
let (response, own) = infer(&engine, span.as_ref(), &LlmRequest::from_prompt(&intent, 256));
let response = match response {
@ -303,7 +322,7 @@ fn main() {
}
};
let after = read(&counter);
let after = read_after(&counter, started.elapsed());
// Materialize the model output as a coding operation.
let op = CodingOp {
@ -619,25 +638,69 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// On this host: the IOReport counter is used only where its CPU rail is live.
/// Elsewhere on Apple Silicon (macOS 27.0.1 on an M5 Max and an M3 Ultra) the
/// load estimator runs and meters the burn, and the receipt says Estimator; with
/// no counter at all (CI's Linux runner), the token estimate does.
/// A window shorter than the counter's minimum gives no counter reading, so the
/// receipt takes the token estimate; at or past it, the reading stands.
#[test]
fn on_this_host_the_estimator_fallback_runs_unless_ioreport_is_live() {
fn a_window_under_the_counters_minimum_takes_the_token_estimate() {
use jouleclaw_energy::{EnergyCounter, EnergyDomain, EnergyReading};
struct Fixed;
impl EnergyCounter for Fixed {
fn domain(&self) -> EnergyDomain {
EnergyDomain::System
}
fn provenance(&self) -> JcProvenance {
JcProvenance::ModelBased
}
fn resolution_uj(&self) -> u64 {
1_000
}
fn min_window_ns(&self) -> u64 {
2_000_000_000
}
fn read(&self) -> Result<EnergyReading, EnergyError> {
Ok(EnergyReading { uj: 9_000, timestamp_ns: 0, domain: self.domain(), provenance: self.provenance() })
}
}
impl EnergyMeter for Fixed {
fn name(&self) -> &'static str {
"fixed"
}
fn is_available(&self) -> bool {
true
}
}
let counter: Option<Box<dyn EnergyMeter>> = Some(Box::new(Fixed));
let short = std::time::Duration::from_millis(1_999);
assert_eq!(read_after(&counter, short), None);
assert_eq!(read_after(&counter, std::time::Duration::from_secs(2)), Some((9_000, JcProvenance::ModelBased)));
assert_eq!(read_after(&None, std::time::Duration::from_secs(9)), None);
let (e, from_counter) = receipt_energy(Some((1_000, JcProvenance::ModelBased)), read_after(&counter, short), 7, 9);
assert_eq!((e, from_counter), (Energy::from_tokens(7, 9), false));
}
/// On this host: the IOReport counter is used only where its CPU rail is live.
/// Elsewhere on a Mac (macOS 27 on an M5 Max and an M3 Ultra) the SMC's
/// whole-machine power meters the burn where `PSTR` is live, and the receipt says
/// ModelBased; the load estimator does where it is not, and the receipt says
/// Estimator; with no counter at all (CI's Linux runner), the token estimate
/// does. The burn outlasts the counter's minimum window, as an inference must
/// for its figure to stand.
#[test]
fn on_this_host_the_best_live_counter_meters_the_burn() {
let status = jouleclaw_energy::ioreport_status();
let counter = host_counter();
let name = counter.as_ref().map_or("none", |c| c.name());
let floor = std::time::Duration::from_nanos(counter.as_ref().map_or(0, |c| c.min_window_ns()));
let read = || counter.as_ref().and_then(|c| c.read().ok()).map(|r| (r.uj, r.provenance));
let _ = read(); // prime, as main does
let before = read();
let t = std::time::Instant::now();
let mut x = 1u64;
while t.elapsed() < std::time::Duration::from_millis(20) {
while t.elapsed() < floor.max(std::time::Duration::from_millis(20)) + std::time::Duration::from_millis(50) {
x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
}
std::hint::black_box(x);
let after = read();
let after = read_after(&counter, t.elapsed());
let (e, from_counter) = receipt_energy(before, after, 7, 9);
let note = energy_note(&e, name, from_counter);
println!("l3 on this host: ioreport={status:?} meter={name} energy={e:?} ({note})");
@ -645,16 +708,23 @@ mod tests {
assert_eq!(name, "apple-ioreport");
assert_eq!(e.provenance, Provenance::HwShunt, "a live IOReport reading is a hardware reading");
assert!(from_counter && e.measured_uj.is_some_and(|uj| uj > 0), "{e:?}");
} else {
assert_ne!(name, "apple-ioreport", "IOReport is used only when its CPU rail is live");
assert_eq!(e.provenance, Provenance::Estimator);
assert_eq!(e.measured_uj, None);
assert!(note.starts_with("estimated"), "{note}");
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
assert_eq!(name, "apple-silicon");
assert!(from_counter && e.estimated_uj > 0, "the estimator metered 20 ms of burn: {e:?}");
return;
}
assert_ne!(name, "apple-ioreport", "IOReport is used only when its CPU rail is live");
assert_eq!(e.measured_uj, None, "only a hardware reading is measured");
match name {
"apple-smc" => {
assert_eq!(e.provenance, Provenance::ModelBased, "the SMC's power integral is a model's figure");
assert!(from_counter && e.estimated_uj > 0, "the SMC metered the burn: {e:?}");
assert_eq!(note, "host-metered by apple-smc (energy model)");
}
"apple-silicon" => {
assert_eq!(e.provenance, Provenance::Estimator);
assert!(from_counter && e.estimated_uj > 0, "the estimator metered the burn: {e:?}");
assert_eq!(note, "estimated by apple-silicon from host load");
} else {
}
_ => {
assert!(!cfg!(all(target_os = "macos", target_arch = "aarch64")), "a Mac with no host counter: {name}");
assert_eq!((name, from_counter), ("none", false));
assert_eq!(e, Energy::from_tokens(7, 9));
}

View file

@ -17,8 +17,9 @@ path = "src/main.rs"
[dependencies]
jouledesk = { path = "../jouledesk" }
# The host's best energy meter: Apple-Silicon IOReport where its CPU rail is live,
# else the Mac's whole-machine power from the SMC (sealed and posted as modeled),
# else the load-average estimator (sealed and posted as an estimate).
jouleclaw-energy = { path = "../../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-energy", default-features = false, features = ["ioreport"] }
jouleclaw-energy = { path = "../../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-energy", default-features = false, features = ["ioreport", "smc"] }
# The model subprocess's own CPU energy, where the host counts each process's
# (macOS): `TreeMeter`, rooted at the model's process.
sandbox-meter = { path = "../../../../sandbox/sandbox-rs/crates/sandbox-meter" }

View file

@ -76,7 +76,8 @@ pub struct Work {
/// The provenance of the meter's readings (`hw_shunt`, `model_based`, `estimator`),
/// or `unmetered` when the meter produced no figure for the work.
pub provenance: String,
/// The meter that actually ran (`apple-ioreport`, `apple-silicon`, `estimation`…).
/// The meter that actually ran (`apple-ioreport`, `apple-smc`, `apple-silicon`,
/// `estimation`…).
pub meter: String,
pub model: String,
/// Why the meter produced no figure for the work, when `provenance` is `unmetered`.
@ -860,6 +861,9 @@ pub(crate) mod tests {
if name == "apple-ioreport" {
assert_eq!(provenance, Provenance::HwShunt, "a live IOReport reading is a hardware reading");
}
if name == "apple-smc" {
assert_eq!(provenance, Provenance::ModelBased, "the SMC's power integral is a model's figure");
}
}
/// The work is metered by the host meter, never by this process's own CPU-energy
@ -973,6 +977,21 @@ pub(crate) mod tests {
0
}
/// How many iterations of the stand-in's loop this binary runs in a second, from
/// 200 ms of it: the stand-in is this same binary, so its loop runs at this rate.
fn iterations_per_second() -> f64 {
let t = Instant::now();
let (mut x, mut i) = (1u64, 0u64);
while t.elapsed() < Duration::from_millis(200) {
for _ in 0..10_000 {
x = std::hint::black_box(x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(i));
i += 1;
}
}
std::hint::black_box(x);
i as f64 / t.elapsed().as_secs_f64()
}
/// Not a test of its own: the stand-in model, run as a subprocess by
/// [`a_model_subprocess_is_read_on_its_own_process_as_its_own_count_says`] (this
/// test binary, filtered to this test, behind a script that takes the model's
@ -1002,12 +1021,17 @@ pub(crate) mod tests {
/// Twice.
#[test]
fn a_model_subprocess_is_read_on_its_own_process_as_its_own_count_says() {
// About a second of one core, unoptimized; a fixed amount of work.
const ITERATIONS: u64 = 150_000_000;
// A fixed amount of work: a second of one core past the host meter's minimum
// window (100 ms for the estimator, 2 s for the SMC), at this loop's rate here,
// so the host meter can put a figure on the window whichever meter it is.
let floor = Duration::from_nanos(
jouleclaw_energy::meter::detect_meter().active().expect("detect_meter has a meter").min_window_ns(),
);
let iterations = (iterations_per_second() * (floor.as_secs_f64() + 1.0)) as u64;
let dir = std::env::temp_dir().join(format!("jouledesk-model-stand-in-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(STAND_IN_MARKER), ITERATIONS.to_string()).unwrap();
std::fs::write(dir.join(STAND_IN_MARKER), iterations.to_string()).unwrap();
let exe = std::env::current_exe().expect("this test binary");
let script = dir.join("stand-in-llama");
std::fs::write(