diff --git a/joule-code/examples/l3-jouleclaw/Cargo.toml b/joule-code/examples/l3-jouleclaw/Cargo.toml index 87a84157d..eeeb8fe75 100644 --- a/joule-code/examples/l3-jouleclaw/Cargo.toml +++ b/joule-code/examples/l3-jouleclaw/Cargo.toml @@ -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" } diff --git a/joule-code/examples/l3-jouleclaw/README.md b/joule-code/examples/l3-jouleclaw/README.md index fdcad1334..995d728e8 100644 --- a/joule-code/examples/l3-jouleclaw/README.md +++ b/joule-code/examples/l3-jouleclaw/README.md @@ -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 diff --git a/joule-code/examples/l3-jouleclaw/src/bin/run.rs b/joule-code/examples/l3-jouleclaw/src/bin/run.rs index 6a256e825..b026f9711 100644 --- a/joule-code/examples/l3-jouleclaw/src/bin/run.rs +++ b/joule-code/examples/l3-jouleclaw/src/bin/run.rs @@ -64,11 +64,17 @@ fn host_counter() -> Option> { // 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> { 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>, 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 { + 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> = 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)); } diff --git a/jouledesk/jouledesk-rs/crates/jouledesk-github/Cargo.toml b/jouledesk/jouledesk-rs/crates/jouledesk-github/Cargo.toml index 1b4bed0ac..82bfff20b 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk-github/Cargo.toml +++ b/jouledesk/jouledesk-rs/crates/jouledesk-github/Cargo.toml @@ -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" } diff --git a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs index 71ec908d0..f6a3b2e07 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs +++ b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs @@ -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(