jouleclaw-energy: IOReport only when its CPU rail keeps moving; consumers never zero-fill or relabel #117
65 changed files with 7887 additions and 698 deletions
1
joule-code/examples/l3-jouleclaw/Cargo.lock
generated
1
joule-code/examples/l3-jouleclaw/Cargo.lock
generated
|
|
@ -501,6 +501,7 @@ dependencies = [
|
|||
"joule-code-receipt",
|
||||
"joule-code-schema",
|
||||
"jouleclaw-energy",
|
||||
"jouleclaw-fabric",
|
||||
"jouleclaw-llm",
|
||||
"jouleclaw-loader-gguf",
|
||||
"jouleclaw-runtime",
|
||||
|
|
|
|||
|
|
@ -23,4 +23,7 @@ jouleclaw-energy = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-en
|
|||
# the in-process L3 backend.
|
||||
jouleclaw-runtime = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime" }
|
||||
jouleclaw-loader-gguf = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf", features = ["rayon", "fabric"] }
|
||||
# The grades (`Measured` / `Benchmarked` / `Estimated`) the fabric ledger prices
|
||||
# a call from, which the L3 stage grades the call's projected energy by.
|
||||
jouleclaw-fabric = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-fabric" }
|
||||
serde_json = "1"
|
||||
|
|
|
|||
|
|
@ -39,11 +39,21 @@ JOULE_CODE_L3_ENGINE=llama cargo run --release --bin run -- /path/to/model.gguf
|
|||
```
|
||||
|
||||
The `run` binary generates with the local model, content-addresses the result as
|
||||
a `CodingOp` (tier L3), and **meters host energy over the inference** with
|
||||
JouleClaw's energy counter — so the receipt's joule cost is a real local
|
||||
measurement, honestly tagged with the counter's provenance (`HwShunt` on
|
||||
RAPL/NVML hosts; `Estimator` on Apple Silicon until IOReport is wired), not a
|
||||
datacenter token estimate.
|
||||
a `CodingOp` (tier L3), and puts the **host** energy spent during the inference on
|
||||
the receipt, tagged with the provenance of whatever metered it:
|
||||
|
||||
- **Apple Silicon whose IOReport CPU energy rail is live** — JouleClaw's IOReport
|
||||
counter, a live vendor energy counter: a real local measurement, tagged
|
||||
`HwShunt` (with `measured_uj` set).
|
||||
- **Apple Silicon whose CPU rail is not live** — macOS 27.0.1 for an unprivileged
|
||||
process, observed on an M5 Max and an M3 Ultra — JouleClaw's load-average
|
||||
estimator, tagged `Estimator`. Not a measurement.
|
||||
- **Anywhere else**, Linux and Windows included — the token-count estimate,
|
||||
tagged `Estimator`. This binding builds `jouleclaw-energy` with the `ioreport`
|
||||
feature only, so it reads no RAPL or NVML counter even where one exists.
|
||||
|
||||
The printout names the meter that ran and the IOReport status, and says whether
|
||||
the figure was measured or estimated.
|
||||
|
||||
Verified live: the llama.cpp path against a local 350M GGUF, and the in-process
|
||||
from-scratch runtime against `ternary-bonsai-1.7b-q2_0` (qwen3, Q2_0) — real text
|
||||
|
|
|
|||
|
|
@ -7,12 +7,16 @@
|
|||
//! By default it runs the model **in-process on JouleClaw's own from-scratch GGUF
|
||||
//! tensor runtime** (`jouleclaw-runtime`); set `JOULE_CODE_L3_ENGINE=llama` to use
|
||||
//! the `llama.cpp` shell-out backend instead. Either way it content-addresses the
|
||||
//! result as a `CodingOp` and meters the **host** energy spent during the
|
||||
//! inference with JouleClaw's energy counter — so the receipt's joule cost is a
|
||||
//! real local measurement (honestly tagged with the counter's provenance), not a
|
||||
//! datacenter token estimate.
|
||||
//! result as a `CodingOp` and puts the **host** energy spent during the inference
|
||||
//! on the receipt, from JouleClaw's best counter on this host and tagged with that
|
||||
//! counter's provenance: a measurement where the host exposes a live counter
|
||||
//! (IOReport on Apple Silicon whose CPU rail advances, `HwShunt`), an estimate
|
||||
//! from host load where it does not (Apple Silicon whose IOReport CPU rail is
|
||||
//! frozen, as on macOS 27.0.1), and the token-count estimate where there is no
|
||||
//! counter at all. The printed receipt says which.
|
||||
|
||||
use jouleclaw_energy::{EnergyCounter, Provenance as JcProvenance};
|
||||
use jouleclaw_energy::meter::EnergyMeter;
|
||||
use jouleclaw_energy::Provenance as JcProvenance;
|
||||
use jouleclaw_llm::{LlmBackend, LlmRequest};
|
||||
|
||||
use joule_code_cargo::{Anchor, CodingOp, Edit};
|
||||
|
|
@ -23,11 +27,13 @@ use l3_jouleclaw_binding::llama_backend::LlamaCompletionBackend;
|
|||
use l3_jouleclaw_binding::runtime_backend::JouleclawRuntimeBackend;
|
||||
|
||||
/// The host's best energy counter, or None where unavailable (e.g. CI/Linux).
|
||||
fn host_counter() -> Option<Box<dyn EnergyCounter>> {
|
||||
fn host_counter() -> Option<Box<dyn EnergyMeter>> {
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
// Prefer the real IOReport energy-model counter (ModelBased); the
|
||||
// load-avg estimator is only a last resort if IOReport will not open.
|
||||
// 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).
|
||||
let ioreport = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
|
||||
if ioreport.is_available() {
|
||||
return Some(Box::new(ioreport));
|
||||
|
|
@ -40,6 +46,39 @@ fn host_counter() -> Option<Box<dyn EnergyCounter>> {
|
|||
None
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// A counter that went backwards, one that registered nothing across the
|
||||
/// inference (no inference runs on zero energy), or readings that disagree about
|
||||
/// provenance give no counter figure: never a zero.
|
||||
fn receipt_energy(
|
||||
before: Option<(u64, JcProvenance)>,
|
||||
after: Option<(u64, JcProvenance)>,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
) -> (Energy, bool) {
|
||||
if let (Some((b, before_prov)), Some((a, prov))) = (before, after) {
|
||||
if let Some(uj) = a.checked_sub(b).filter(|uj| *uj > 0 && before_prov == prov) {
|
||||
let prov = map_provenance(prov);
|
||||
let measured_uj = (prov == Provenance::HwShunt).then_some(uj);
|
||||
return (Energy { measured_uj, estimated_uj: uj, provenance: prov }, true);
|
||||
}
|
||||
}
|
||||
(Energy::from_tokens(input_tokens, output_tokens), false)
|
||||
}
|
||||
|
||||
/// How the receipt's energy was obtained, for the printout: by which counter, or
|
||||
/// from token counts. An estimate is never called metered.
|
||||
fn energy_note(energy: &Energy, counter: &str, from_counter: bool) -> String {
|
||||
match (from_counter, energy.provenance) {
|
||||
(false, _) => "estimated from token counts".to_string(),
|
||||
(true, Provenance::HwShunt) => format!("measured by {counter}"),
|
||||
(true, Provenance::ModelBased) => format!("host-metered by {counter} (energy model)"),
|
||||
(true, Provenance::Estimator) => format!("estimated by {counter} from host load"),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_provenance(p: JcProvenance) -> Provenance {
|
||||
match p {
|
||||
JcProvenance::HwShunt => Provenance::HwShunt,
|
||||
|
|
@ -77,10 +116,11 @@ fn main() {
|
|||
},
|
||||
};
|
||||
let counter = host_counter();
|
||||
let counter_name = counter.as_ref().map_or("none", |c| c.name());
|
||||
|
||||
// Meter host energy across the inference. Prime the counter once so the
|
||||
// first-sample init delta is excluded from the measured window.
|
||||
let read = |c: &Option<Box<dyn EnergyCounter>>| {
|
||||
let read = |c: &Option<Box<dyn EnergyMeter>>| {
|
||||
c.as_ref()
|
||||
.and_then(|c| c.read().ok())
|
||||
.map(|r| (r.uj, r.provenance))
|
||||
|
|
@ -113,27 +153,14 @@ fn main() {
|
|||
};
|
||||
let op_id = op.op_id().expect("op_id");
|
||||
|
||||
// Receipt energy: real measured host energy if a counter was available,
|
||||
// else the token-based estimate (honest fallback).
|
||||
let energy = match (before, after) {
|
||||
(Some((b, _)), Some((a, prov))) => {
|
||||
let uj = a.saturating_sub(b);
|
||||
let prov = map_provenance(prov);
|
||||
match prov {
|
||||
Provenance::HwShunt => Energy {
|
||||
measured_uj: Some(uj),
|
||||
estimated_uj: uj,
|
||||
provenance: prov,
|
||||
},
|
||||
_ => Energy {
|
||||
measured_uj: None,
|
||||
estimated_uj: uj,
|
||||
provenance: prov,
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => Energy::from_tokens(response.input_tokens as u64, response.output_tokens as u64),
|
||||
};
|
||||
// Receipt energy: the host counter's figure at its provenance, else the
|
||||
// token-based estimate (honest fallback).
|
||||
let (energy, from_counter) = receipt_energy(
|
||||
before,
|
||||
after,
|
||||
response.input_tokens as u64,
|
||||
response.output_tokens as u64,
|
||||
);
|
||||
let receipt = Receipt {
|
||||
op_id: op_id.clone(),
|
||||
tier: Tier::L3,
|
||||
|
|
@ -151,18 +178,109 @@ fn main() {
|
|||
println!("op_id : {op_id}");
|
||||
println!("edits : {} (GENERATED.rs)", op.edits.len());
|
||||
println!("tier : {}", receipt.tier);
|
||||
println!(
|
||||
"meter : {counter_name} (IOReport: {:?})",
|
||||
jouleclaw_energy::ioreport_status()
|
||||
);
|
||||
println!(
|
||||
"tokens : in={} out={}",
|
||||
response.input_tokens, response.output_tokens
|
||||
);
|
||||
println!(
|
||||
"energy : {:.3} J ({:?}{})",
|
||||
"energy : {:.3} J ({:?}, {})",
|
||||
receipt.energy.estimated_uj as f64 / 1_000_000.0,
|
||||
receipt.energy.provenance,
|
||||
if receipt.energy.measured_uj.is_some() {
|
||||
", measured"
|
||||
} else {
|
||||
", host-metered"
|
||||
}
|
||||
energy_note(&receipt.energy, counter_name, from_counter)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_counter_reading_keeps_its_provenance() {
|
||||
let (e, from_counter) =
|
||||
receipt_energy(Some((100, JcProvenance::HwShunt)), Some((400, JcProvenance::HwShunt)), 7, 9);
|
||||
assert!(from_counter);
|
||||
assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (Some(300), 300, Provenance::HwShunt));
|
||||
for (jc, p) in [(JcProvenance::ModelBased, Provenance::ModelBased), (JcProvenance::Estimator, Provenance::Estimator)] {
|
||||
let (e, from_counter) = receipt_energy(Some((100, jc)), Some((400, jc)), 7, 9);
|
||||
assert!(from_counter);
|
||||
assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (None, 300, p));
|
||||
assert!(e.check_provenance().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_two_readings_the_receipt_is_the_token_estimate() {
|
||||
let tokens = Energy::from_tokens(7, 9);
|
||||
let est = JcProvenance::Estimator;
|
||||
for (before, after) in [
|
||||
(None, None),
|
||||
(None, Some((400, est))),
|
||||
(Some((100, est)), None),
|
||||
(Some((400, est)), Some((100, est))), // went backwards
|
||||
(Some((400, est)), Some((400, est))), // registered nothing
|
||||
(Some((9, JcProvenance::ModelBased)), Some((9, JcProvenance::ModelBased))), // a held rail
|
||||
(Some((100, JcProvenance::ModelBased)), Some((400, est))), // disagree
|
||||
] {
|
||||
let (e, from_counter) = receipt_energy(before, after, 7, 9);
|
||||
assert!(!from_counter, "{before:?} → {after:?}");
|
||||
assert_eq!(e, tokens);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_estimate_is_never_printed_as_metered() {
|
||||
let est = Energy { measured_uj: None, estimated_uj: 5, provenance: Provenance::Estimator };
|
||||
assert_eq!(energy_note(&est, "apple-silicon", true), "estimated by apple-silicon from host load");
|
||||
assert_eq!(energy_note(&est, "none", false), "estimated from token counts");
|
||||
let modeled = Energy { measured_uj: None, estimated_uj: 5, provenance: Provenance::ModelBased };
|
||||
assert_eq!(energy_note(&modeled, "apple-ioreport", true), "host-metered by apple-ioreport (energy model)");
|
||||
let measured = Energy { measured_uj: Some(5), estimated_uj: 5, provenance: Provenance::HwShunt };
|
||||
assert_eq!(energy_note(&measured, "rapl", true), "measured by rapl");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn on_this_host_the_estimator_fallback_runs_unless_ioreport_is_live() {
|
||||
let status = jouleclaw_energy::ioreport_status();
|
||||
let counter = host_counter();
|
||||
let name = counter.as_ref().map_or("none", |c| c.name());
|
||||
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) {
|
||||
x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
|
||||
}
|
||||
std::hint::black_box(x);
|
||||
let after = read();
|
||||
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})");
|
||||
if status == jouleclaw_energy::IoReportStatus::Live {
|
||||
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:?}");
|
||||
assert_eq!(note, "estimated by apple-silicon from host load");
|
||||
} else {
|
||||
assert_eq!((name, from_counter), ("none", false));
|
||||
assert_eq!(e, Energy::from_tokens(7, 9));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,10 +180,13 @@ impl LlmBackend for HybridBackend {
|
|||
.with_sampling(sampling);
|
||||
|
||||
// Fabric attribution window: GFLOPs per member for
|
||||
// THIS call, priced by the machine's calibration —
|
||||
// a calibrated per-operation model (ModelBased), not
|
||||
// a meter reading; reported only when the fabric is
|
||||
// on and calibrated.
|
||||
// THIS call, each priced by the member's loaded matmul
|
||||
// figure — a projection, not a meter reading; reported
|
||||
// only when the fabric priced some of it. The L3 stage
|
||||
// grades it by its weakest input
|
||||
// (`crate::projection_grade`): ModelBased only when
|
||||
// every member was priced by a Measured or Benchmarked
|
||||
// figure.
|
||||
jouleclaw_loader_gguf::fabric_dispatch::ledger_reset();
|
||||
let out = match &self.model {
|
||||
Hybrid::NemotronH(m) => generate(m, &prompt, ¶ms),
|
||||
|
|
@ -201,10 +204,10 @@ impl LlmBackend for HybridBackend {
|
|||
finish_reason: if stopped { FinishReason::Stop } else { FinishReason::Length },
|
||||
input_tokens: prompt.len() as u32,
|
||||
output_tokens: out.len() as u32,
|
||||
// ModelBased when the fabric ledger priced the call
|
||||
// against this machine's calibration; honest None
|
||||
// otherwise (the run binary can still meter host
|
||||
// energy over the whole call).
|
||||
// The fabric projection when the ledger priced the
|
||||
// call (graded by the stage from the ledger's
|
||||
// inputs); honest None otherwise (the run binary can
|
||||
// still meter host energy over the whole call).
|
||||
energy_joules,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,16 +31,53 @@ use joule_code_cargo::{Anchor, CodingOp, Edit};
|
|||
use joule_code_cascade::{FnStage, Request, Resolution, Stage};
|
||||
use joule_code_receipt::{Energy, Provenance, Receipt};
|
||||
use joule_code_schema::{ContentId, Tier, ALG_ED25519};
|
||||
use jouleclaw_fabric::EnergyProvenance;
|
||||
use jouleclaw_llm::{EchoBackend, LlmBackend, LlmRequest};
|
||||
use jouleclaw_loader_gguf::fabric_dispatch::{ledger_reset, ledger_snapshot, LedgerEntry};
|
||||
|
||||
/// The grade of a per-call energy figure projected from the fabric attribution
|
||||
/// ledger (`window`: the ledger's entries for that call), by its **weakest
|
||||
/// input**.
|
||||
///
|
||||
/// A projection is GFLOPs × each member's µJ/GFLOP; it is never a reading, so
|
||||
/// at best it is a calibrated per-operation model, `ModelBased` (joule-code
|
||||
/// SPEC §6). It is that only when every member that ran was priced by a
|
||||
/// calibrated figure: `Measured` (a calibration row a live meter read) or
|
||||
/// `Benchmarked` (a recorded run on that member class). One input priced by an
|
||||
/// `Estimated` figure — a coarse model, such as the fabric's seeded GPU figures —
|
||||
/// makes the whole projection an estimate, `Estimator`; so does a member that
|
||||
/// ran unpriced (its work is missing from the sum), and an empty window (a
|
||||
/// figure that cannot be traced to any priced input).
|
||||
pub fn projection_grade(window: &[LedgerEntry]) -> Provenance {
|
||||
if window.is_empty() {
|
||||
return Provenance::Estimator;
|
||||
}
|
||||
let mut grade = Provenance::ModelBased;
|
||||
for entry in window {
|
||||
match entry.projected_grade {
|
||||
Some(EnergyProvenance::Measured | EnergyProvenance::Benchmarked) => {}
|
||||
Some(EnergyProvenance::Estimated) | None => grade = Provenance::Estimator,
|
||||
}
|
||||
}
|
||||
grade
|
||||
}
|
||||
|
||||
/// Build an L3 cascade [`Stage`] backed by any JouleClaw [`LlmBackend`]. The
|
||||
/// stage generates a coding operation from the request intent and reports the
|
||||
/// backend's joule cost as the receipt energy (tier L3, joules class).
|
||||
///
|
||||
/// The stage opens the fabric attribution window around the call (it resets
|
||||
/// the process-wide ledger first), so the figure a backend reports is graded by
|
||||
/// the inputs this call was priced from ([`projection_grade`]) and by nothing an
|
||||
/// earlier call left in the ledger. The ledger is process-wide: work another
|
||||
/// thread dispatches during the call lands in the same window.
|
||||
pub fn jouleclaw_l3_stage(backend: Box<dyn LlmBackend>) -> impl Stage {
|
||||
FnStage::new(Tier::L3, move |req: &Request| {
|
||||
ledger_reset();
|
||||
let response = backend
|
||||
.complete(&LlmRequest::from_prompt(req.intent.clone(), 256))
|
||||
.ok()?;
|
||||
let window = ledger_snapshot();
|
||||
|
||||
// Reference materialization: the model's output becomes one edit. A
|
||||
// production binding would parse structured edits from the model.
|
||||
|
|
@ -59,15 +96,15 @@ pub fn jouleclaw_l3_stage(backend: Box<dyn LlmBackend>) -> impl Stage {
|
|||
};
|
||||
let op_id = op.op_id().ok()?;
|
||||
|
||||
// L3 energy, by best available provenance: when the
|
||||
// backend reports a per-call figure (the hybrid
|
||||
// backend's fabric ledger — GFLOPs attributed per
|
||||
// member, priced by THIS machine's IOReport
|
||||
// calibration), that is a calibrated per-operation
|
||||
// model: ModelBased per SPEC §6. Otherwise the
|
||||
// catalog constant, honestly tagged Estimator.
|
||||
// L3 energy. When the backend reports a per-call figure
|
||||
// (the hybrid backend's fabric projection — GFLOPs
|
||||
// attributed per member, priced by each member's loaded
|
||||
// matmul figure), it is graded by its weakest input:
|
||||
// ModelBased only if every member was priced by a
|
||||
// Measured or Benchmarked figure, Estimator otherwise.
|
||||
// Without a figure, the catalog constant, Estimator.
|
||||
let (estimated_uj, provenance) = match response.energy_joules {
|
||||
Some(j) => ((j * 1_000_000.0) as u64, Provenance::ModelBased),
|
||||
Some(j) => ((j * 1_000_000.0) as u64, projection_grade(&window)),
|
||||
None => (
|
||||
(backend.typical_joules_per_call() * 1_000_000.0) as u64,
|
||||
Provenance::Estimator,
|
||||
|
|
@ -121,6 +158,98 @@ mod tests {
|
|||
assert_eq!(res.receipt.energy.provenance, Provenance::Estimator);
|
||||
}
|
||||
|
||||
/// A ledger entry for `member`, priced at `grade` (`None`: unpriced).
|
||||
fn entry(member: &str, grade: Option<EnergyProvenance>) -> LedgerEntry {
|
||||
LedgerEntry {
|
||||
member_id: member.into(),
|
||||
ops: 3,
|
||||
gflops: 1.5,
|
||||
projected_uj: grade.map(|_| 1.5 * 7_000.0),
|
||||
projected_grade: grade,
|
||||
}
|
||||
}
|
||||
|
||||
/// The weakest input decides: calibrated figures (Measured, Benchmarked)
|
||||
/// make a ModelBased projection; one Estimated or unpriced input, or no input
|
||||
/// at all, makes it an Estimator figure.
|
||||
#[test]
|
||||
fn a_projection_is_graded_by_its_weakest_input() {
|
||||
use EnergyProvenance::*;
|
||||
let cases: [(&[Option<EnergyProvenance>], Provenance); 8] = [
|
||||
(&[Some(Measured)], Provenance::ModelBased),
|
||||
(&[Some(Benchmarked)], Provenance::ModelBased),
|
||||
(&[Some(Measured), Some(Benchmarked)], Provenance::ModelBased),
|
||||
(&[Some(Estimated)], Provenance::Estimator),
|
||||
(&[Some(Benchmarked), Some(Estimated)], Provenance::Estimator),
|
||||
(&[Some(Estimated), Some(Measured)], Provenance::Estimator),
|
||||
(&[Some(Measured), None], Provenance::Estimator),
|
||||
(&[], Provenance::Estimator),
|
||||
];
|
||||
for (grades, want) in cases {
|
||||
let window: Vec<LedgerEntry> =
|
||||
grades.iter().enumerate().map(|(i, g)| entry(&format!("local/m{i}"), *g)).collect();
|
||||
assert_eq!(projection_grade(&window), want, "{grades:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A backend that runs one real matmul through the fabric dispatcher (the
|
||||
/// path the hybrid models take) and reports a per-call figure, as the hybrid
|
||||
/// backend does from the ledger.
|
||||
struct DispatchingBackend {
|
||||
/// Run the matmul (and so leave a ledger entry), or not.
|
||||
dispatch: bool,
|
||||
}
|
||||
|
||||
impl LlmBackend for DispatchingBackend {
|
||||
fn model_name(&self) -> &str {
|
||||
"dispatching"
|
||||
}
|
||||
fn complete(&self, request: &LlmRequest) -> Result<jouleclaw_llm::LlmResponse, jouleclaw_llm::LlmError> {
|
||||
if self.dispatch {
|
||||
let (m, n, k) = (1usize, 32usize, 64usize);
|
||||
let a = vec![0.5f32; m * k];
|
||||
let w = vec![0u8; 2 * n * k];
|
||||
let mut out = vec![0f32; m * n];
|
||||
jouleclaw_loader_gguf::fabric_dispatch::matmul_bf16_auto(&a, &w, &[], m, n, k, &mut out);
|
||||
}
|
||||
Ok(jouleclaw_llm::LlmResponse {
|
||||
text: format!("fn answer() {{}} // {}", request.prompt),
|
||||
finish_reason: jouleclaw_llm::FinishReason::Stop,
|
||||
input_tokens: 4,
|
||||
output_tokens: 4,
|
||||
energy_joules: Some(0.25),
|
||||
})
|
||||
}
|
||||
fn typical_joules_per_call(&self) -> f64 {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(backend: DispatchingBackend) -> Energy {
|
||||
let stage = jouleclaw_l3_stage(Box::new(backend));
|
||||
let res = stage
|
||||
.try_resolve(&Request { prior: "b3:AAAA".into(), intent: "write a hello function".into() })
|
||||
.expect("L3 resolved");
|
||||
res.receipt.energy
|
||||
}
|
||||
|
||||
/// A per-call figure is no longer ModelBased by default. Here the fabric is
|
||||
/// off (no `JOULECLAW_FABRIC=1`), so the member that ran was priced by
|
||||
/// nothing: the figure is reported, as an estimate. A figure with no ledger
|
||||
/// input at all is an estimate too.
|
||||
#[test]
|
||||
fn a_figure_the_ledger_did_not_price_is_an_estimate() {
|
||||
for dispatch in [true, false] {
|
||||
let e = resolve(DispatchingBackend { dispatch });
|
||||
assert_eq!(
|
||||
(e.measured_uj, e.estimated_uj, e.provenance),
|
||||
(None, 250_000, Provenance::Estimator),
|
||||
"dispatch={dispatch}"
|
||||
);
|
||||
assert!(e.check_provenance().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_falls_through_l0_to_l3() {
|
||||
// Empty L0 cache in front of the L3 model stage: L0 misses, L3 closes.
|
||||
|
|
|
|||
65
joule-code/examples/l3-jouleclaw/tests/common/mod.rs
Normal file
65
joule-code/examples/l3-jouleclaw/tests/common/mod.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//! Shared by the fabric-grade tests: run the L3 stage, with the fabric ON in this
|
||||
//! test process and priced from a calibration file the test writes (never the
|
||||
//! machine's own `~/.jouleclaw/fabric-calibration.json`), over a backend that
|
||||
//! dispatches one real matmul through the fabric and reports the ledger's
|
||||
//! projection, as the hybrid backend does.
|
||||
|
||||
use joule_code_cascade::{Request, Stage};
|
||||
use joule_code_receipt::Energy;
|
||||
use jouleclaw_fabric::EnergyProvenance;
|
||||
use jouleclaw_llm::{FinishReason, LlmBackend, LlmError, LlmRequest, LlmResponse};
|
||||
use jouleclaw_loader_gguf::fabric_dispatch::{ledger_reset, ledger_snapshot, matmul_bf16_auto, LedgerEntry};
|
||||
|
||||
/// One decode-shaped matmul (m = 1, a 4 KiB weight): the fabric places it on the
|
||||
/// CPU SIMD member, `local/cpu-neon`.
|
||||
struct OneMatmul;
|
||||
|
||||
impl LlmBackend for OneMatmul {
|
||||
fn model_name(&self) -> &str {
|
||||
"one-matmul"
|
||||
}
|
||||
fn complete(&self, _request: &LlmRequest) -> Result<LlmResponse, LlmError> {
|
||||
let (m, n, k) = (1usize, 32usize, 64usize);
|
||||
let a = vec![0.5f32; m * k];
|
||||
let w = vec![0u8; 2 * n * k];
|
||||
let mut out = vec![0f32; m * n];
|
||||
ledger_reset();
|
||||
matmul_bf16_auto(&a, &w, &[], m, n, k, &mut out);
|
||||
let projected_j: f64 = ledger_snapshot().iter().filter_map(|e| e.projected_uj).sum::<f64>() / 1e6;
|
||||
Ok(LlmResponse {
|
||||
text: "fn hello() {}".into(),
|
||||
finish_reason: FinishReason::Stop,
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
energy_joules: (projected_j > 0.0).then_some(projected_j),
|
||||
})
|
||||
}
|
||||
fn typical_joules_per_call(&self) -> f64 {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a calibration file pricing `local/cpu-neon`'s matmul at `grade` (or
|
||||
/// declaring none), switch the fabric on for this process, resolve one L3
|
||||
/// request, and return its receipt energy with the ledger window it was priced
|
||||
/// from. Call once per test process: the fabric is discovered once.
|
||||
pub fn resolve_priced_at(tag: &str, grade: Option<EnergyProvenance>) -> (Energy, Vec<LedgerEntry>) {
|
||||
let path = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("l3-fabric-grade-{tag}.json"));
|
||||
let declared = grade.map(|g| format!(r#","provenance":"{g:?}""#)).unwrap_or_default();
|
||||
// Priced high enough that the one 4.1e-6 GFLOP matmul projects to ~1 mJ, a
|
||||
// whole number of µJ on the receipt.
|
||||
let row = format!(
|
||||
r#"[{{"member_id":"local/cpu-neon","op":"matmul","uj_per_gflop":250000000.0,"method":"test row"{declared}}}]"#
|
||||
);
|
||||
std::fs::write(&path, row).expect("write the test calibration file");
|
||||
std::env::set_var("JOULECLAW_FABRIC_CALIBRATION", &path);
|
||||
std::env::set_var("JOULECLAW_FABRIC", "1");
|
||||
|
||||
let stage = l3_jouleclaw_binding::jouleclaw_l3_stage(Box::new(OneMatmul));
|
||||
let res = stage
|
||||
.try_resolve(&Request { prior: "b3:AAAA".into(), intent: "write a hello function".into() })
|
||||
.expect("L3 resolved");
|
||||
let window = ledger_snapshot();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
(res.receipt.energy, window)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
//! The L3 stage grades a fabric-priced figure by its weakest input: with every
|
||||
//! member that ran priced by a calibrated figure (here a `Measured` row), the
|
||||
//! receipt says ModelBased — a calibrated per-operation model, never a reading.
|
||||
//! Its own test process, because the fabric is discovered once per process.
|
||||
|
||||
mod common;
|
||||
|
||||
use joule_code_receipt::Provenance;
|
||||
use jouleclaw_fabric::EnergyProvenance;
|
||||
|
||||
#[test]
|
||||
fn a_call_priced_by_calibrated_figures_is_model_based() {
|
||||
let (energy, window) = common::resolve_priced_at("measured", Some(EnergyProvenance::Measured));
|
||||
eprintln!("window: {window:?}\nenergy: {energy:?}");
|
||||
assert!(!window.is_empty());
|
||||
for e in &window {
|
||||
assert_eq!(e.projected_grade, Some(EnergyProvenance::Measured), "{e:?}");
|
||||
}
|
||||
assert!(energy.estimated_uj > 0, "the projection is reported: {energy:?}");
|
||||
assert_eq!((energy.provenance, energy.measured_uj), (Provenance::ModelBased, None), "{energy:?}");
|
||||
assert!(energy.check_provenance().is_ok());
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
//! The L3 stage grades a fabric-priced figure by its weakest input: with the
|
||||
//! member that ran priced by an `Estimated` figure, the receipt says Estimator.
|
||||
//! Its own test process, because the fabric is discovered once per process.
|
||||
|
||||
mod common;
|
||||
|
||||
use joule_code_receipt::Provenance;
|
||||
use jouleclaw_fabric::EnergyProvenance;
|
||||
|
||||
#[test]
|
||||
fn a_call_priced_by_an_estimated_figure_is_an_estimate() {
|
||||
let (energy, window) = common::resolve_priced_at("estimated", Some(EnergyProvenance::Estimated));
|
||||
eprintln!("window: {window:?}\nenergy: {energy:?}");
|
||||
let neon = window.iter().find(|e| e.member_id == "local/cpu-neon").expect("the m=1 matmul ran on cpu-neon");
|
||||
assert_eq!(neon.projected_grade, Some(EnergyProvenance::Estimated), "the ledger carries the row's grade");
|
||||
assert!(energy.estimated_uj > 0, "the projection is reported: {energy:?}");
|
||||
assert_eq!((energy.provenance, energy.measured_uj), (Provenance::Estimator, None), "{energy:?}");
|
||||
assert!(energy.check_provenance().is_ok());
|
||||
}
|
||||
|
|
@ -48,13 +48,15 @@ fn hybrid_backend_completes_in_process() {
|
|||
"smoke answer should mention Paris, got {:?}",
|
||||
resp.text
|
||||
);
|
||||
// Energy contract: with the fabric on and calibrated,
|
||||
// the call reports a ModelBased figure from the
|
||||
// attribution ledger; otherwise honest None.
|
||||
// Energy contract: with the fabric on, the call reports
|
||||
// the attribution ledger's projection, graded by its
|
||||
// weakest priced input; otherwise honest None.
|
||||
match resp.energy_joules {
|
||||
Some(j) => {
|
||||
assert!(j > 0.0);
|
||||
eprintln!(" fabric-priced energy: {j:.1} J (ModelBased)");
|
||||
let window = jouleclaw_loader_gguf::fabric_dispatch::ledger_snapshot();
|
||||
let grade = l3_jouleclaw_binding::projection_grade(&window);
|
||||
eprintln!(" fabric-priced energy: {j:.1} J ({grade:?})");
|
||||
}
|
||||
None => eprintln!(" energy: None (fabric off — honest)"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,10 +70,38 @@ how the value was obtained:
|
|||
|
||||
| Provenance | Meaning |
|
||||
|----------------|--------------------------------------------------------|
|
||||
| `HwShunt` | Real hardware shunt / coulomb counter (RAPL MSR, NVML cumulative-energy counter, Jetson INA3221) |
|
||||
| `ModelBased` | Vendor-provided estimate from freq / voltage / utilisation (Apple IOReport, ROCm SMI, NVML `power.draw`) |
|
||||
| `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`) |
|
||||
| `Estimator` | JouleClaw static cost model from arch × precision × batch tables |
|
||||
|
||||
A vendor energy counter's reading is a real reading. What separates
|
||||
one such reading from another is provenance — which counter, over
|
||||
which domain, and whether it was live when read — not accuracy. So a
|
||||
live vendor counter is graded `HwShunt`, and liveness is the
|
||||
condition:
|
||||
|
||||
**Liveness (MUST).** A reading from a vendor energy counter (RAPL,
|
||||
NVML, Apple IOReport, …) **MAY** be graded as a hardware reading only
|
||||
while the counter is demonstrably live for the domain the figure
|
||||
claims: the implementation **MUST** have observed the counter
|
||||
covering that domain advance under load (for Apple IOReport: the CPU
|
||||
energy rail itself advancing in at least 5 sample intervals, in at
|
||||
least 4 of every 5, over at least 300 ms while the probing thread
|
||||
burns CPU). A counter that is present but not live — frozen, or
|
||||
republished in lumps — **MUST NOT** produce a hardware-graded figure;
|
||||
the implementation falls back to its estimator or reports the work
|
||||
unmetered, and **SHOULD** record why. A figure **MUST NOT** claim
|
||||
coverage of a domain its counter does not read; where the format has
|
||||
a coverage field, such a figure is Partial.
|
||||
|
||||
Apple IOReport's GPU rail is not a partial reading of CPU work. It is
|
||||
machine-wide — it meters every client of the GPU on the host, display
|
||||
compositing among them, and moves while nothing of the metered work
|
||||
runs — so where the CPU rail is frozen and only the GPU rail advances,
|
||||
the reference implementation declines the counter and estimates the
|
||||
work rather than reporting the GPU rail as `Partial`: the rail does
|
||||
not cover part of the work, it covers other work.
|
||||
|
||||
The thermodynamic circuit breaker **MUST** enforce at the granularity
|
||||
of the **worst** counter in the request's span. Implementations
|
||||
**MUST** surface `resolution_uj()` and `min_window_ns()` so callers
|
||||
|
|
@ -85,7 +113,10 @@ Realistic floors:
|
|||
- Intel / AMD x86 RAPL: ~1 μJ, ~10 ms window. `HwShunt`.
|
||||
- Jetson INA3221: ~10 mW (integrate for energy). `HwShunt`.
|
||||
- NVIDIA discrete (NVML cumulative-energy): ~1 mJ, ~50 ms. `HwShunt`.
|
||||
- Apple Silicon IOReport: ~1 mJ, ~10 ms. **Model-based**, not measured.
|
||||
- Apple Silicon IOReport: ~1 mJ, ~10 ms. `HwShunt` while its CPU
|
||||
energy rail is live. On macOS 27.0.1 an unprivileged reader's CPU
|
||||
rails are frozen (observed on an M5 Max and an M3 Ultra), so the
|
||||
counter is declined there and the host is estimated (`Estimator`).
|
||||
Marketing claims of microjoule precision on Apple Silicon are
|
||||
sales talk; the spec is honest about this.
|
||||
- Consumer AMD GPU, ARM PMU: no usable counter. Use a calibrated
|
||||
|
|
@ -1031,6 +1062,14 @@ counter (`Provenance::HwShunt`), a documented vendor model
|
|||
(`Provenance::ModelBased`), or a calibrated static estimator
|
||||
(`Provenance::Estimator`) with a wider tolerance band.
|
||||
|
||||
A hardware counter here means one that was live for the domain the
|
||||
figure claims (§1.3, Liveness). A counter that is present but not
|
||||
live — frozen, or republished in lumps — is not a hardware reading
|
||||
however real the silicon behind it: its span is the estimator's, or
|
||||
the work is reported unmetered, and the receipt says so. Grading a
|
||||
frozen counter's figure `HwShunt` is the receipts-as-marketing
|
||||
failure mode in its most literal form.
|
||||
|
||||
The breaker enforces at the granularity of the worst counter in
|
||||
the span. This is normative.
|
||||
|
||||
|
|
|
|||
|
|
@ -341,11 +341,13 @@ async fn run(cli: Cli) -> Result<(), CliError> {
|
|||
// EnergyCounter impl, this command reads the live one.
|
||||
println!("Energy counter (not bound in v0.1)");
|
||||
println!(" resolution varies by platform: 1 μJ on Intel/AMD RAPL,");
|
||||
println!(" ~1 mJ on Apple IOReport (model-based),");
|
||||
println!(" ~1 mJ on Apple IOReport,");
|
||||
println!(" ~1 mJ on NVIDIA NVML,");
|
||||
println!(" ~10 mW on Jetson INA3221 (shunt; integrate).");
|
||||
println!(" honesty `Provenance::HwShunt` on RAPL/NVML/INA3221,");
|
||||
println!(" `Provenance::ModelBased` on Apple IOReport / ROCm SMI,");
|
||||
println!(" honesty `Provenance::HwShunt` on RAPL/NVML/INA3221, and on");
|
||||
println!(" Apple IOReport while its CPU energy rail is live");
|
||||
println!(" (a counter that is not live gives no HwShunt figure),");
|
||||
println!(" `Provenance::ModelBased` on ROCm SMI,");
|
||||
println!(" `Provenance::Estimator` on consumer AMD GPU / ARM PMU.");
|
||||
println!();
|
||||
println!("Bind a counter at startup with `jouleclaw_energy::EnergyCounter`.");
|
||||
|
|
|
|||
|
|
@ -17,9 +17,16 @@
|
|||
//! Every passport links a [`passport::MeasurementMethod`] to a
|
||||
//! [`jouleclaw_energy::Provenance`] tag — measurement honesty travels
|
||||
//! all the way from the platform counter into the compliance report.
|
||||
//! Apple Silicon (until the IOReport FFI ships) flows through as
|
||||
//! `Provenance::Estimator`; RAPL and NVML flow through as
|
||||
//! `Provenance::HwShunt`.
|
||||
//! RAPL and NVML flow through as `Provenance::HwShunt`. So does Apple
|
||||
//! Silicon IOReport observed live (`MeasurementMethod::IoReportLive`):
|
||||
//! a vendor energy counter's reading is a real reading, and what
|
||||
//! decides its grade is provenance — was the counter live for the
|
||||
//! domain the figure claims — not accuracy. A bare
|
||||
//! `MeasurementMethod::IoReport` records no such observation; where
|
||||
//! the CPU energy rail is frozen (macOS 27.0.1 for an unprivileged
|
||||
//! reader) the figure came from the estimator, so it flows through as
|
||||
//! `Provenance::Estimator`. A passport may say its inference energy
|
||||
//! was measured only when its method is graded `HwShunt`.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
|
|
|||
|
|
@ -114,8 +114,19 @@ impl Ord for EnergyLabel {
|
|||
pub enum MeasurementMethod {
|
||||
/// Intel / AMD RAPL counters.
|
||||
Rapl,
|
||||
/// Apple Silicon IOReport (or estimator until FFI ships).
|
||||
/// Apple Silicon IOReport, with no record that its CPU energy rail was
|
||||
/// observed live while the figure was taken. Graded `Estimator`: the
|
||||
/// counter yields a reading only where that rail advances under load
|
||||
/// (`jouleclaw_energy::ioreport_status() == Live`), and where it does not
|
||||
/// — every CPU rail frozen for an unprivileged reader on macOS 27.0.1 —
|
||||
/// the host's figure is the estimator's. Use [`Self::IoReportLive`] for a
|
||||
/// figure read while the rail was live.
|
||||
IoReport,
|
||||
/// Apple Silicon IOReport, read while its CPU energy rail was observed
|
||||
/// live (`jouleclaw_energy::IoReportStatus::Live`). A live vendor energy
|
||||
/// counter is a hardware reading, graded `HwShunt` like RAPL and NVML:
|
||||
/// its trust gap is provenance, which liveness answers, not accuracy.
|
||||
IoReportLive,
|
||||
/// NVIDIA Management Library.
|
||||
Nvml,
|
||||
/// Combined GPU (NVML) + CPU (RAPL) measurement.
|
||||
|
|
@ -129,11 +140,18 @@ impl MeasurementMethod {
|
|||
/// Pure-Rust translation from the JouleClaw energy contract.
|
||||
pub fn provenance(&self) -> Provenance {
|
||||
match self {
|
||||
Self::Rapl | Self::Nvml | Self::NvmlRapl => Provenance::HwShunt,
|
||||
Self::Rapl | Self::Nvml | Self::NvmlRapl | Self::IoReportLive => Provenance::HwShunt,
|
||||
Self::IoReport => Provenance::Estimator,
|
||||
Self::Estimated => Provenance::Estimator,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a figure obtained this way is a hardware reading from a live
|
||||
/// counter — the only grade under which inference energy may be called
|
||||
/// measured.
|
||||
pub fn is_live_hardware_reading(&self) -> bool {
|
||||
self.provenance() == Provenance::HwShunt
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MeasurementMethod {
|
||||
|
|
@ -141,6 +159,7 @@ impl fmt::Display for MeasurementMethod {
|
|||
match self {
|
||||
Self::Rapl => write!(f, "RAPL"),
|
||||
Self::IoReport => write!(f, "IOReport"),
|
||||
Self::IoReportLive => write!(f, "IOReport (live)"),
|
||||
Self::Nvml => write!(f, "NVML"),
|
||||
Self::NvmlRapl => write!(f, "NVML+RAPL"),
|
||||
Self::Estimated => write!(f, "Estimated"),
|
||||
|
|
@ -335,6 +354,18 @@ pub enum PassportError {
|
|||
/// A required field was missing.
|
||||
#[error("required field missing: {0}")]
|
||||
MissingField(&'static str),
|
||||
/// The compliance record says inference energy was measured, but the
|
||||
/// energy profile's method is not graded as a live hardware reading.
|
||||
#[error(
|
||||
"inference energy is claimed measured, but the energy profile's method {method} is graded \
|
||||
{provenance}, not a live hardware reading"
|
||||
)]
|
||||
MeasuredWithoutHardwareReading {
|
||||
/// The energy profile's measurement method.
|
||||
method: MeasurementMethod,
|
||||
/// That method's grade.
|
||||
provenance: Provenance,
|
||||
},
|
||||
/// JSON encoding failed.
|
||||
#[error("json encoding failed: {0}")]
|
||||
JsonEncode(#[from] serde_json::Error),
|
||||
|
|
@ -485,9 +516,13 @@ impl PassportBuilder {
|
|||
}
|
||||
|
||||
/// Consume the builder and return a passport. Errors on missing
|
||||
/// required fields.
|
||||
/// required fields, and on a compliance record that calls inference
|
||||
/// energy measured (`inference_energy_measured`) when the energy
|
||||
/// profile's method is not graded as a live hardware reading
|
||||
/// ([`MeasurementMethod::is_live_hardware_reading`]): a bare
|
||||
/// `IoReport`, or `Estimated`.
|
||||
pub fn build(self) -> Result<AiServicePassport, PassportError> {
|
||||
Ok(AiServicePassport {
|
||||
let passport = AiServicePassport {
|
||||
passport_version: self.passport_version,
|
||||
service_id: self.service_id,
|
||||
provider: self.provider,
|
||||
|
|
@ -512,7 +547,15 @@ impl PassportBuilder {
|
|||
.compliance
|
||||
.ok_or(PassportError::MissingField("compliance"))?,
|
||||
pricing: self.pricing.unwrap_or_default(),
|
||||
})
|
||||
};
|
||||
let method = passport.energy_profile.measurement_method;
|
||||
if passport.compliance.eu_ai_act.inference_energy_measured && !method.is_live_hardware_reading() {
|
||||
return Err(PassportError::MeasuredWithoutHardwareReading {
|
||||
method,
|
||||
provenance: method.provenance(),
|
||||
});
|
||||
}
|
||||
Ok(passport)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -698,7 +741,13 @@ mod tests {
|
|||
MeasurementMethod::NvmlRapl.provenance(),
|
||||
Provenance::HwShunt
|
||||
);
|
||||
// IOReport is Estimator until the FFI is wired — see apple.rs.
|
||||
// IOReport read while its CPU rail was live is a hardware reading, the
|
||||
// grade of RAPL and NVML. A bare IoReport records no liveness: where the
|
||||
// rail is frozen (macOS 27.0.1) the figure is the estimator's.
|
||||
assert_eq!(
|
||||
MeasurementMethod::IoReportLive.provenance(),
|
||||
Provenance::HwShunt
|
||||
);
|
||||
assert_eq!(
|
||||
MeasurementMethod::IoReport.provenance(),
|
||||
Provenance::Estimator
|
||||
|
|
@ -717,6 +766,51 @@ mod tests {
|
|||
assert_eq!(p.model.parameters, 70_000_000_000);
|
||||
}
|
||||
|
||||
fn build_with(method: MeasurementMethod, measured: bool) -> Result<AiServicePassport, PassportError> {
|
||||
let now = Utc::now();
|
||||
let mut compliance = sample_compliance();
|
||||
compliance.eu_ai_act.inference_energy_measured = measured;
|
||||
AiServicePassport::builder("svc-method")
|
||||
.provider("jouleclaw.dev")
|
||||
.issued_at(now)
|
||||
.valid_until(now + Duration::days(90))
|
||||
.model(sample_model())
|
||||
.hardware(sample_hardware())
|
||||
.energy_profile(EnergyProfile { measurement_method: method, ..sample_energy_profile() })
|
||||
.carbon_profile(sample_carbon_profile())
|
||||
.energy_label(sample_label(EnergyLabel::A, 0.35))
|
||||
.compliance(compliance)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// A passport may call its inference energy measured only when its method is
|
||||
/// graded as a live hardware reading. A bare IOReport records no liveness, so
|
||||
/// paired with "measured" it is refused, as an estimate is; IOReport observed
|
||||
/// live, RAPL and NVML are accepted. Unclaimed, every method builds.
|
||||
#[test]
|
||||
fn a_measured_claim_needs_a_live_hardware_reading() {
|
||||
for method in [MeasurementMethod::IoReport, MeasurementMethod::Estimated] {
|
||||
match build_with(method, true) {
|
||||
Err(PassportError::MeasuredWithoutHardwareReading { method: m, provenance }) => {
|
||||
assert_eq!((m, provenance), (method, Provenance::Estimator));
|
||||
}
|
||||
other => panic!("{method} paired with a measured claim was not refused: {other:?}"),
|
||||
}
|
||||
assert!(build_with(method, false).is_ok(), "{method} without a measured claim was refused");
|
||||
}
|
||||
for method in [
|
||||
MeasurementMethod::IoReportLive,
|
||||
MeasurementMethod::Rapl,
|
||||
MeasurementMethod::Nvml,
|
||||
MeasurementMethod::NvmlRapl,
|
||||
] {
|
||||
let p = build_with(method, true).unwrap_or_else(|e| panic!("{method} refused: {e}"));
|
||||
assert!(p.compliance.eu_ai_act.inference_energy_measured);
|
||||
}
|
||||
let why = build_with(MeasurementMethod::IoReport, true).unwrap_err().to_string();
|
||||
assert!(why.contains("IOReport") && why.contains("estimator") && why.contains("live hardware reading"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_passport_errors_without_required_field() {
|
||||
let now = Utc::now();
|
||||
|
|
@ -754,6 +848,7 @@ mod tests {
|
|||
fn measurement_method_display() {
|
||||
assert_eq!(format!("{}", MeasurementMethod::Rapl), "RAPL");
|
||||
assert_eq!(format!("{}", MeasurementMethod::IoReport), "IOReport");
|
||||
assert_eq!(format!("{}", MeasurementMethod::IoReportLive), "IOReport (live)");
|
||||
assert_eq!(format!("{}", MeasurementMethod::Nvml), "NVML");
|
||||
assert_eq!(format!("{}", MeasurementMethod::NvmlRapl), "NVML+RAPL");
|
||||
assert_eq!(format!("{}", MeasurementMethod::Estimated), "Estimated");
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ serde_json = { workspace = true, optional = true }
|
|||
tracing = { workspace = true, optional = true }
|
||||
nvml-wrapper = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# `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).
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
|
|
@ -25,8 +27,8 @@ serde = ["dep:serde", "dep:serde_json"]
|
|||
|
||||
# Per-platform energy backends. Off by default so the foundation is OS-neutral;
|
||||
# downstream crates / binaries enable what their target supports.
|
||||
rapl = [] # Intel/AMD x86 — Linux sysfs powercap
|
||||
nvml = ["dep:nvml-wrapper", "dep:tracing"] # NVIDIA discrete — NVML cumulative-energy counter
|
||||
ioreport = [] # Apple Silicon M-series — real IOReport energy counter (ModelBased), load-avg estimator fallback
|
||||
rapl = [] # Intel/AMD x86 — Linux sysfs powercap (HwShunt while its counter is live)
|
||||
nvml = ["dep:nvml-wrapper", "dep:tracing"] # NVIDIA discrete — NVML cumulative-energy counter (HwShunt while live), power-draw integral (ModelBased) otherwise
|
||||
ioreport = [] # Apple Silicon M-series — real IOReport energy counter (HwShunt while every CPU rail is live), load-avg estimator fallback
|
||||
ina3221 = [] # NVIDIA Jetson (Orin / Thor) — sysfs i2c shunt reader
|
||||
hwmon = [] # Linux generic — for AMD Strix Halo / Ryzen AI Max iGPU + others
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
# Measured energy — cross-platform verification
|
||||
|
||||
JouleClaw energy readings carry an honest [`Provenance`](src/lib.rs): `HwShunt`
|
||||
(a real shunt / MSR / coulomb counter), `ModelBased` (a vendor freq/voltage/
|
||||
utilisation model — a real on-device reading, not a shunt), or `Estimator` (a
|
||||
static cost model, used only where no counter exists).
|
||||
(a hardware reading: a shunt, or a vendor energy counter — RAPL, NVML, Apple
|
||||
IOReport — while it is live for the domain it claims), `ModelBased` (a vendor
|
||||
freq/voltage/utilisation model), or `Estimator` (a static cost model, used where
|
||||
no live counter exists).
|
||||
|
||||
The goal is that on real hardware a reading is **never** an `Estimator`. The
|
||||
A vendor energy counter's reading is a real reading; what has to be established
|
||||
is its provenance — which counter, which domain, and whether it was live — not
|
||||
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
|
||||
[`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:
|
||||
|
|
@ -20,18 +27,32 @@ cargo run -p jouleclaw-energy --release --example energy_probe --features iorepo
|
|||
|
||||
| Platform | Counter | Domain | Provenance | Reading (2 s churn) |
|
||||
|---|---|---|---|---|
|
||||
| Apple Silicon (this dev host) | IOReport "Energy Model" | SoC total | **ModelBased** | 74.1 J · 25.5 W |
|
||||
| Apple Silicon (this dev host; chip and macOS version not recorded) | IOReport "Energy Model" | SoC total | **ModelBased** (the tag then) | 74.1 J · 25.5 W |
|
||||
| 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) |
|
||||
|
||||
Notes:
|
||||
|
||||
- **Apple** replaced a load-avg `Estimator` (which guessed ~10.7 W for the same
|
||||
churn — under-reporting the real ~25.5 W by ~2.4×). The IOReport counter
|
||||
([`apple_ioreport`](src/apple_ioreport.rs)) reads the SoC's CPU/GPU/ANE/DRAM
|
||||
Energy Model channels with no sudo. End-to-end, a Joule Code L3 inference on
|
||||
Apple now emits a receipt tagged `ModelBased`, not `Estimator`.
|
||||
- **Apple — dated note (2026-09-30).** The Apple row was committed on
|
||||
2026-06-12 and predates macOS 27. Which chip and which macOS version it was
|
||||
read on were not recorded, so it cannot be reproduced or re-checked; it was
|
||||
tagged `ModelBased`, the grade IOReport carried then, and no liveness check
|
||||
existed. It stands as a historical figure only. A live IOReport reading is now
|
||||
graded `HwShunt`, like RAPL and NVML.
|
||||
- **Apple on macOS 27.0.1.** The IOReport counter
|
||||
([`apple_ioreport`](src/apple_ioreport.rs)) opens with no sudo, but an
|
||||
unprivileged reader does **not** get live CPU energy: every CPU rail in the
|
||||
Energy Model group holds its value under minutes of full CPU load, moving only
|
||||
in batch refreshes tens of minutes apart. Observed on an M5 Max (`CPU Energy`
|
||||
frozen while `GPU Energy` advances) and on an M3 Ultra (all 645 Energy Model
|
||||
rails frozen, CPU, DRAM and display included). So the counter checks that its
|
||||
CPU rail is live before it reads (`ioreport_status()`), declines when it is
|
||||
not, and the load-avg estimator meters the host instead: on those hosts a
|
||||
Joule Code L3 inference emits a receipt tagged `Estimator`. Where the
|
||||
rail is live, the receipt is tagged `HwShunt`. The load-avg estimator is
|
||||
coarse: on the dev host it once guessed ~10.7 W for the churn above, against
|
||||
25.5 W read from IOReport.
|
||||
- **NVML** is one cross-platform counter ([`nvml`](src/nvml.rs)) — the same code
|
||||
reports `HwShunt` on Windows and Linux; it loads the driver's NVML library by
|
||||
name on each OS.
|
||||
|
|
@ -42,6 +63,8 @@ Notes:
|
|||
|
||||
## Not yet a hardware reading
|
||||
|
||||
- **Apple Silicon CPU energy on macOS 27.0.1**, for an unprivileged process —
|
||||
the IOReport CPU rails are frozen (above), so the load-avg estimator meters it.
|
||||
- **x86 CPU energy on Windows** — RAPL MSRs need a kernel driver (e.g. WinRing0)
|
||||
on Windows; the NVML GPU path is the Windows `HwShunt` route. (CPU-side is an
|
||||
`Estimator` there for now.)
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@
|
|||
Honest energy accounting requires honest energy readings. The
|
||||
[`EnergyCounter`] trait in this crate names three provenance tiers:
|
||||
|
||||
- **HwShunt** — a hardware shunt reading (Intel/AMD RAPL MSR,
|
||||
NVML pstate sampling, board-level current sense, IPMI sensor).
|
||||
Authoritative.
|
||||
- **HwShunt** — a hardware reading: a shunt (board-level current
|
||||
sense, IPMI sensor), or a vendor energy counter (Intel/AMD RAPL
|
||||
MSR, NVML cumulative energy, Apple IOReport) while it is
|
||||
demonstrably live for the domain the figure claims — the
|
||||
measurer observed the counter covering that domain advance under
|
||||
load. A counter that is present but not live (frozen, or
|
||||
republished in lumps) gives no HwShunt figure. Authoritative.
|
||||
- **ModelBased** — a vendor-published energy model (DCGM-X,
|
||||
Apple `powermetrics`, vendor whitepapers) consulted at run time.
|
||||
NVML `power.draw`, vendor whitepapers) consulted at run time.
|
||||
Trustworthy if the model itself was validated by HwShunt.
|
||||
- **Estimator** — anything derived from wall-clock × static
|
||||
power ceiling. Useful as a floor; never as a ceiling.
|
||||
|
|
@ -37,8 +41,12 @@ Every workload measurement MUST capture:
|
|||
1. **Hardware identification** —
|
||||
`vendor`, `model`, `tdp_w` (datasheet), `host_os`.
|
||||
2. **Counter** — which trait impl was used (`RaplCounter`,
|
||||
`NvmlCounter`, `AppleEstimator`, or a vendor-specific impl).
|
||||
3. **Provenance** — `HwShunt` | `ModelBased` | `Estimator`.
|
||||
`NvmlCounter`, `AppleIoReportCounter`, `AppleSiliconMeter`, or a
|
||||
vendor-specific impl).
|
||||
3. **Provenance** — `HwShunt` | `ModelBased` | `Estimator`. `HwShunt`
|
||||
only for a counter observed live over the run (for Apple IOReport,
|
||||
`ioreport_status()` returned `Live`); record in `method` how
|
||||
liveness was established.
|
||||
4. **Resolution** — `resolution_uj` (smallest delta the counter
|
||||
meaningfully reports) and `min_window_ns` (smallest sampling
|
||||
window below which the reading is noise-dominated). Honest
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ under named workloads. This directory carries the second half.
|
|||
| `vendors/amd-cpu-rapl.json` | `RaplCounter` | HwShunt |
|
||||
| `vendors/nvidia-gpu-nvml.json` | `NvmlCounter` | HwShunt |
|
||||
| `vendors/amd-gpu-rocm.json` | `RocmSmiCounter` | HwShunt |
|
||||
| `vendors/apple-silicon-ioreport.json` | `AppleIoReport` | ModelBased |
|
||||
| `vendors/apple-silicon-ioreport.json` | `AppleIoReportCounter` | HwShunt, while live (below) |
|
||||
| `vendors/huawei-ascend-npu.json` | `AscendSmiCounter` | HwShunt |
|
||||
|
||||
The Cloud TPU, Intel Gaudi, Cambricon, and Qualcomm Cloud AI
|
||||
|
|
@ -63,6 +63,18 @@ past peer review.
|
|||
|
||||
| Tag | What you're trusting | Use case |
|
||||
|---|---|---|
|
||||
| `HwShunt` | A hardware shunt or vendor-grade MSR | Authoritative; the circuit breaker may use these directly. |
|
||||
| `ModelBased` | A validated vendor energy model (Apple `powermetrics`, DCGM-X) | Trustworthy iff the model was validated against HwShunt elsewhere. |
|
||||
| `HwShunt` | A hardware shunt, or a vendor energy counter (RAPL, NVML, Apple IOReport) while it is live for the domain it claims | Authoritative; the circuit breaker may use these directly. |
|
||||
| `ModelBased` | A validated vendor energy model (DCGM-X, NVML `power.draw`) | Trustworthy iff the model was validated against HwShunt elsewhere. |
|
||||
| `Estimator` | Wall-clock × static TDP, or a hand-tuned heuristic | A floor, never a ceiling. The breaker treats Estimator as worst-case. |
|
||||
|
||||
A vendor energy counter's reading is a real reading; what a fixture must
|
||||
pin down is its provenance, not its accuracy. A `HwShunt` figure in a
|
||||
fixture is a claim that the counter was demonstrably live for the domain
|
||||
the figure claims while the workload ran: the measurer observed the counter
|
||||
covering that domain advance under load. For Apple IOReport that means
|
||||
`jouleclaw_energy::ioreport_status()` was `Live` (the CPU energy rail
|
||||
advanced in at least 5 sample intervals, in at least 4 of every 5, over at
|
||||
least 300 ms while the probing thread burned CPU). A counter that is present
|
||||
but not live — frozen, or republished in lumps, as the CPU rails are for an
|
||||
unprivileged reader on macOS 27.0.1 — gives no `HwShunt` figure; that run is
|
||||
an `Estimator` run, or no run at all.
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
"host_os": null
|
||||
},
|
||||
"counter": {
|
||||
"impl": "AppleIoReport",
|
||||
"provenance": "ModelBased",
|
||||
"impl": "AppleIoReportCounter",
|
||||
"provenance": "HwShunt",
|
||||
"resolution_uj": 1000,
|
||||
"min_window_ns": 10000000
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
//! Cross-platform energy probe — read the best available hardware counter on
|
||||
//! this host and print a **measured** energy delta with its honest provenance.
|
||||
//! this host and print its energy delta with its honest provenance.
|
||||
//!
|
||||
//! The point of the probe: prove that on real hardware JouleClaw reports a
|
||||
//! `HwShunt` / `ModelBased` reading, not an `Estimator` fallback. Build with the
|
||||
//! feature that matches the host's counter:
|
||||
//! The point of the probe: show whether on real hardware JouleClaw reports a
|
||||
//! `HwShunt` / `ModelBased` reading or an `Estimator` fallback, and label the
|
||||
//! figure accordingly. Build with the feature that matches the host's counter:
|
||||
//!
|
||||
//! # NVIDIA discrete GPU (Linux or Windows — loads libnvidia-ml / nvml.dll):
|
||||
//! cargo run -p jouleclaw-energy --release --example energy_probe --features nvml
|
||||
|
|
@ -11,7 +11,8 @@
|
|||
//! # Intel/AMD x86 on Linux (powercap sysfs RAPL):
|
||||
//! cargo run -p jouleclaw-energy --release --example energy_probe --features rapl
|
||||
//!
|
||||
//! # Apple Silicon (load-avg estimator until IOReport is wired):
|
||||
//! # Apple Silicon (IOReport Energy Model when its CPU rail is live, else the
|
||||
//! # load-avg estimator, with the reason printed):
|
||||
//! cargo run -p jouleclaw-energy --release --example energy_probe --features ioreport
|
||||
//!
|
||||
//! It samples the counter across a ~2 s window (driving a small CPU workload so
|
||||
|
|
@ -29,16 +30,17 @@ fn pick_counter() -> Option<(&'static str, Box<dyn EnergyCounter>)> {
|
|||
{
|
||||
let c = jouleclaw_energy::nvml::NvmlCounter::new();
|
||||
if c.is_available() {
|
||||
// Report which NVML path is live — the true cumulative-energy
|
||||
// counter (accurate even when `power_usage()`/`nvidia-smi power.draw`
|
||||
// report garbage) or the power-integration fallback.
|
||||
// Report which NVML path reads — the cumulative-energy counter,
|
||||
// only where it was observed live (HwShunt), or the power-draw
|
||||
// integral (ModelBased) — and the counter's liveness.
|
||||
println!(
|
||||
"nvml path : {}",
|
||||
"nvml path : {} (counter liveness: {:?})",
|
||||
if c.uses_true_counter() {
|
||||
"nvmlDeviceGetTotalEnergyConsumption (true cumulative counter)"
|
||||
"nvmlDeviceGetTotalEnergyConsumption (live cumulative counter, HwShunt)"
|
||||
} else {
|
||||
"power_usage() integration (fallback)"
|
||||
}
|
||||
"power_usage() integration (ModelBased)"
|
||||
},
|
||||
c.liveness()
|
||||
);
|
||||
return Some(("nvml (NVIDIA GPU shunt)", Box::new(c)));
|
||||
}
|
||||
|
|
@ -46,18 +48,23 @@ fn pick_counter() -> Option<(&'static str, Box<dyn EnergyCounter>)> {
|
|||
#[cfg(feature = "rapl")]
|
||||
{
|
||||
let c = jouleclaw_energy::rapl::RaplCounter::new();
|
||||
println!("rapl : liveness {:?}", c.liveness());
|
||||
if c.is_available() {
|
||||
return Some(("rapl (x86 package MSR)", Box::new(c)));
|
||||
}
|
||||
}
|
||||
#[cfg(all(feature = "ioreport", target_os = "macos"))]
|
||||
{
|
||||
// Prefer the real IOReport energy-model counter (ModelBased); fall back
|
||||
// to the load-avg estimator only if IOReport will not open.
|
||||
// Prefer the real IOReport energy counter (HwShunt, like RAPL and NVML).
|
||||
// It is unavailable unless IOReport opens AND its CPU energy rail
|
||||
// advances under load — a counter that is present but not live gives no
|
||||
// hardware-graded figure; say which before falling back to the load-avg
|
||||
// estimator.
|
||||
let c = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
|
||||
if c.is_available() {
|
||||
return Some(("apple-ioreport (Energy Model)", Box::new(c)));
|
||||
}
|
||||
println!("ioreport : declined ({:?})", c.status());
|
||||
let c = jouleclaw_energy::apple::AppleSiliconMeter::new();
|
||||
if c.is_available() {
|
||||
return Some(("apple (load-avg estimator)", Box::new(c)));
|
||||
|
|
@ -82,6 +89,7 @@ fn churn(deadline: Instant) -> u64 {
|
|||
}
|
||||
|
||||
fn main() {
|
||||
println!("== JouleClaw energy probe ==");
|
||||
let Some((label, counter)) = pick_counter() else {
|
||||
eprintln!(
|
||||
"no hardware energy counter available on this host/build.\n\
|
||||
|
|
@ -90,7 +98,6 @@ fn main() {
|
|||
std::process::exit(1);
|
||||
};
|
||||
|
||||
println!("== JouleClaw energy probe ==");
|
||||
println!("counter : {label}");
|
||||
println!("provenance : {}", counter.provenance());
|
||||
println!("domain : {:?}", counter.domain());
|
||||
|
|
@ -128,7 +135,12 @@ fn main() {
|
|||
let elapsed = start.elapsed().as_secs_f64();
|
||||
let delta_uj = after.uj.saturating_sub(before.uj);
|
||||
let joules = delta_uj as f64 / 1_000_000.0;
|
||||
println!("--- measured ---");
|
||||
// An estimator's figure is not a measurement; say which one this is.
|
||||
if after.provenance == jouleclaw_energy::Provenance::Estimator {
|
||||
println!("--- estimated ---");
|
||||
} else {
|
||||
println!("--- measured ---");
|
||||
}
|
||||
println!("window : {:.3} s", elapsed);
|
||||
println!(
|
||||
"energy : {} uj ({:.3} J) avg {:.2} W [{}]",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
//! Apple Silicon energy estimator — `Provenance::Estimator`.
|
||||
//!
|
||||
//! **HONEST DISCLOSURE.** The production-quality path is IOReport's
|
||||
//! `"Energy Model"` channel group (`PMP`/`Energy Model` keys), which
|
||||
//! returns vendor-modelled package energy at ~1 mJ / ~10 ms. JouleClaw
|
||||
//! has not yet wired the IOReport FFI; until then this backend uses
|
||||
//! the system 1-minute load average, normalises it against
|
||||
//! **HONEST DISCLOSURE.** The measured Apple path is IOReport's
|
||||
//! `"Energy Model"` channel group, wired as
|
||||
//! [`crate::apple_ioreport::AppleIoReportCounter`] (`HwShunt`, while live). This
|
||||
//! backend is the fallback wherever that counter is unavailable: IOReport
|
||||
//! will not open, or its CPU energy rail does not keep advancing under load
|
||||
//! (macOS 27.0.1 on an M5 Max and an M3 Ultra; [`crate::ioreport_status`]
|
||||
//! says which). It uses the system 1-minute load average, normalises it against
|
||||
//! `available_parallelism`, and interpolates between a fixed
|
||||
//! `idle_watts = 8 W` and `peak_watts = 40 W` band.
|
||||
//!
|
||||
|
|
@ -14,9 +16,9 @@
|
|||
//! ~100 ms (load-average is updated by the kernel at ~5 s but quoted
|
||||
//! at much higher frequency; the practical noise floor is ~100 ms).
|
||||
//!
|
||||
//! When the IOReport bridge ships, this module should be replaced or
|
||||
//! re-tagged as `Provenance::ModelBased`. **DO NOT change the tag in
|
||||
//! place** — the breaker depends on it.
|
||||
//! It is selected precisely when no live reading exists, so it stays
|
||||
//! `Provenance::Estimator` on every host. **DO NOT change the tag** —
|
||||
//! the breaker depends on it.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
|
@ -87,7 +89,7 @@ impl EnergyCounter for AppleSiliconMeter {
|
|||
}
|
||||
|
||||
fn provenance(&self) -> Provenance {
|
||||
// Until IOReport is wired this is unambiguously an Estimator.
|
||||
// A load-average model: unambiguously an Estimator, on every host.
|
||||
Provenance::Estimator
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +153,7 @@ mod tests {
|
|||
fn apple_meter_declares_estimator_provenance() {
|
||||
let m = AppleSiliconMeter::new();
|
||||
// The honesty tag is what the breaker reads. It MUST be
|
||||
// Estimator until IOReport ships.
|
||||
// Estimator: this is what runs when IOReport has no live CPU rail.
|
||||
assert_eq!(m.provenance(), Provenance::Estimator);
|
||||
assert_eq!(m.domain(), EnergyDomain::SocTotal);
|
||||
assert_eq!(m.name(), "apple-silicon");
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,16 +5,30 @@
|
|||
//! Every energy reading in JouleClaw carries a [`Provenance`] tag declaring
|
||||
//! how the value was obtained:
|
||||
//!
|
||||
//! - [`Provenance::HwShunt`] — a real hardware shunt measurement (Intel/AMD
|
||||
//! x86 RAPL MSRs, NVIDIA discrete GPU cumulative-energy counter,
|
||||
//! Jetson INA3221 i2c shunts). Honest to the counter's resolution.
|
||||
//! - [`Provenance::HwShunt`] — a hardware reading: a shunt (Jetson INA3221
|
||||
//! i2c), or a vendor energy counter (Intel/AMD x86 RAPL MSRs, the NVIDIA
|
||||
//! discrete GPU cumulative-energy counter, Apple Silicon IOReport) while it
|
||||
//! is demonstrably live for the domain the figure claims. Honest to the
|
||||
//! counter's resolution.
|
||||
//! - [`Provenance::ModelBased`] — a vendor-provided model estimate derived
|
||||
//! from frequency, voltage, and utilization (Apple Silicon IOReport,
|
||||
//! NVML `power.draw` on older GPUs). Millijoule-quantized at best.
|
||||
//! from frequency, voltage, and utilization (NVML `power.draw` on older
|
||||
//! GPUs, ROCm-SMI `power1_average`). 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).
|
||||
//!
|
||||
//! A vendor energy counter's reading is a real reading; the trust gap is
|
||||
//! provenance, not accuracy. It may be graded `HwShunt` only while the
|
||||
//! counter is demonstrably live for the domain the figure claims: the
|
||||
//! implementation MUST have observed the counter covering that domain advance
|
||||
//! under load. A counter that is present but not live — frozen, or republished
|
||||
//! in lumps — MUST NOT produce a `HwShunt` figure; the meter falls back to its
|
||||
//! estimator. On Apple Silicon that is [`ioreport_status`]: the IOReport counter
|
||||
//! reads only when it is [`IoReportStatus::Live`]. RAPL and NVML are gated on the
|
||||
//! same rule ([`CounterLiveness`]): a RAPL counter reads only when it is live, and
|
||||
//! an NVML cumulative-energy counter that is not live gives way to the
|
||||
//! power-draw integral, which is graded [`Provenance::ModelBased`].
|
||||
//!
|
||||
//! The thermodynamic circuit breaker is only as honest as the **worst**
|
||||
//! counter in a request's span. The breaker MUST surface
|
||||
//! [`EnergyCounter::resolution_uj`] and [`EnergyCounter::min_window_ns`] so
|
||||
|
|
@ -52,19 +66,25 @@ pub mod apple_ioreport;
|
|||
pub mod meter;
|
||||
pub mod ledger;
|
||||
|
||||
mod liveness;
|
||||
pub use liveness::CounterLiveness;
|
||||
|
||||
/// Provenance of an energy reading. Load-bearing — every consumer of a
|
||||
/// reading MUST inspect this before claiming accuracy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub enum Provenance {
|
||||
/// Real hardware shunt or coulomb counter. Honest to the counter's
|
||||
/// resolution. Examples: Intel/AMD x86 RAPL MSRs, NVIDIA discrete
|
||||
/// `nvmlDeviceGetTotalEnergyConsumption`, Jetson INA3221 i2c.
|
||||
/// A hardware reading: a shunt or coulomb counter, or a vendor energy
|
||||
/// counter while it is demonstrably live for the domain the reading
|
||||
/// claims (see the crate docs). Honest to the counter's resolution.
|
||||
/// Examples: Intel/AMD x86 RAPL MSRs, NVIDIA discrete
|
||||
/// `nvmlDeviceGetTotalEnergyConsumption`, Apple IOReport "Energy Model"
|
||||
/// 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: Apple IOReport "Energy Model" channels,
|
||||
/// NVML `power.draw` interpolation, ROCm-SMI `power1_average`.
|
||||
/// measurement. Examples: NVML `power.draw` interpolation, ROCm-SMI
|
||||
/// `power1_average`.
|
||||
ModelBased,
|
||||
/// JouleClaw static cost model from architecture × precision × batch
|
||||
/// tables. Used only when no hardware counter exists. Tolerance
|
||||
|
|
@ -82,6 +102,102 @@ impl fmt::Display for Provenance {
|
|||
}
|
||||
}
|
||||
|
||||
/// Why the Apple Silicon IOReport counter (`apple_ioreport::AppleIoReportCounter`,
|
||||
/// built with the `ioreport` feature on macOS) is, or is not, usable on this host.
|
||||
///
|
||||
/// The counter is available — and [`meter::detect_meter`] selects it — only when
|
||||
/// this is [`IoReportStatus::Live`]. Under any other status the host is metered
|
||||
/// by the next rung instead (on Apple Silicon, the load-average estimator, whose
|
||||
/// readings say [`Provenance::Estimator`]); this says why.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IoReportStatus {
|
||||
/// IOReport is not usable here: this build has no IOReport backend (not
|
||||
/// macOS, or built without the `ioreport` feature), or the probe could not
|
||||
/// subscribe to the `Energy Model` group or never got a sample of it (not
|
||||
/// Apple Silicon, Rosetta, a future macOS that changes the private
|
||||
/// surface). The probe saw nothing, so it claims neither a live rail nor a
|
||||
/// missing one.
|
||||
Unavailable,
|
||||
/// The `Energy Model` group was subscribed and sampled, but no `CPU Energy`
|
||||
/// rail (nor `DIE_<n>_CPU Energy`) exists in it — an unfamiliar channel
|
||||
/// naming. The counter is never used.
|
||||
CpuRailAbsent,
|
||||
/// A CPU energy rail exists, but not every one keeps advancing like a live
|
||||
/// reading while this process burns CPU: one held its value, or moved only
|
||||
/// in the few steps of a batch refresh (one step, or two 64 ms apart, as
|
||||
/// observed). The platform is not exposing live energy for the whole CPU to
|
||||
/// this process, so the summed rails would report GPU-only energy, zero, or
|
||||
/// (one die live beside a frozen one, on a multi-die part) one die's energy
|
||||
/// as the energy of CPU work. Observed on macOS 27.0.1 on an M5 Max and an
|
||||
/// M3 Ultra, every CPU rail frozen.
|
||||
CpuRailFrozen,
|
||||
/// Every CPU energy rail — `CPU Energy`, or each `DIE_<n>_CPU Energy` on a
|
||||
/// multi-die part — keeps advancing under load, sample after sample: each
|
||||
/// in at least five sample intervals, four in every five of those observed,
|
||||
/// over at least 300 ms. The counter is usable.
|
||||
Live,
|
||||
}
|
||||
|
||||
/// This host's [`IoReportStatus`], probed once per process and cached.
|
||||
///
|
||||
/// On macOS with the `ioreport` feature, the first call opens an IOReport
|
||||
/// subscription to the `Energy Model` group and burns CPU on the calling thread
|
||||
/// while it watches the CPU energy rails, re-sampling after every 25 ms of the
|
||||
/// thread's own CPU time (a tick gives up after 500 ms of wall time on a host
|
||||
/// too loaded to grant it that): about
|
||||
/// `apple_ioreport::CPU_RAIL_LIVENESS_BUDGET` where the rail is frozen, less
|
||||
/// where it is live (it stops as soon as every CPU rail has advanced in five
|
||||
/// intervals, four in every five of those observed, over at least 300 ms), and
|
||||
/// up to five budgets where samples are slow or fail. Every later call — and
|
||||
/// every `AppleIoReportCounter::new()` and [`meter::detect_meter`] — reuses
|
||||
/// that answer: this process-wide cache is the probe's one production caller,
|
||||
/// whichever thread asks first. Anywhere else it returns
|
||||
/// [`IoReportStatus::Unavailable`] without probing.
|
||||
pub fn ioreport_status() -> IoReportStatus {
|
||||
#[cfg(all(feature = "ioreport", target_os = "macos"))]
|
||||
{
|
||||
static STATUS: std::sync::OnceLock<IoReportStatus> = std::sync::OnceLock::new();
|
||||
*STATUS.get_or_init(apple_ioreport::probe_host_status)
|
||||
}
|
||||
#[cfg(not(all(feature = "ioreport", target_os = "macos")))]
|
||||
{
|
||||
IoReportStatus::Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/// How many times this process has run the real IOReport liveness probe, on
|
||||
/// any thread. For the test that pins [`ioreport_status`] to one probe per
|
||||
/// process, from a process of its own; not part of the metering API. Always 0
|
||||
/// without the IOReport backend, which never probes.
|
||||
#[doc(hidden)]
|
||||
pub fn ioreport_probe_runs() -> usize {
|
||||
#[cfg(all(feature = "ioreport", target_os = "macos"))]
|
||||
{
|
||||
apple_ioreport::REAL_RUNS.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
#[cfg(not(all(feature = "ioreport", target_os = "macos")))]
|
||||
{
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// How many times this process has asked IOReport for the `Energy Model` group
|
||||
/// subscription the liveness probe samples, on any thread. Only the probe asks
|
||||
/// for it, once a run, so a path that probes around the cache shows here even
|
||||
/// if it bypasses the run counter ([`ioreport_probe_runs`]). For the same test;
|
||||
/// always 0 without the IOReport backend.
|
||||
#[doc(hidden)]
|
||||
pub fn ioreport_group_opens() -> usize {
|
||||
#[cfg(all(feature = "ioreport", target_os = "macos"))]
|
||||
{
|
||||
apple_ioreport::GROUP_OPENS.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
#[cfg(not(all(feature = "ioreport", target_os = "macos")))]
|
||||
{
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Hardware energy domain a counter reports on.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
|
|
@ -238,6 +354,16 @@ mod tests {
|
|||
assert_eq!(counter.resolution_uj(), 1);
|
||||
}
|
||||
|
||||
/// A build without the IOReport backend (not macOS, or no `ioreport`
|
||||
/// feature) never claims it: the status is `Unavailable`, with no probe.
|
||||
/// The macOS + `ioreport` side is asserted in `apple_ioreport`'s tests.
|
||||
#[test]
|
||||
#[cfg(not(all(feature = "ioreport", target_os = "macos")))]
|
||||
fn without_the_ioreport_backend_the_status_is_unavailable() {
|
||||
assert_eq!(ioreport_status(), IoReportStatus::Unavailable);
|
||||
assert_eq!((ioreport_probe_runs(), ioreport_group_opens()), (0, 0), "a build with no IOReport backend probed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranking_provenance_by_honesty() {
|
||||
// The breaker MUST treat HwShunt as the most honest source.
|
||||
|
|
|
|||
445
jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/liveness.rs
Normal file
445
jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/liveness.rs
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
//! The sustained-advance liveness rule, shared by the vendor energy counters that
|
||||
//! read one cumulative value per channel (RAPL's `energy_uj`, NVML's
|
||||
//! cumulative-energy counter).
|
||||
//!
|
||||
//! A vendor energy counter's reading may be graded a hardware reading
|
||||
//! ([`crate::Provenance::HwShunt`]) only while the counter is demonstrably live
|
||||
//! for the domain the figure claims (JouleClaw SPEC §1.3, JCR-1 §2.3): the
|
||||
//! implementation must have observed the counter covering that domain advance
|
||||
//! under load. Present is not live. A counter can be readable and still hold its
|
||||
//! value, or be republished in lumps — Apple's IOReport CPU rail does exactly
|
||||
//! that for an unprivileged reader on macOS 27.0.1 (see
|
||||
//! `apple_ioreport`) — and then a span across it reads zero, or reads one
|
||||
//! refresh's lump as the energy of whatever ran.
|
||||
//!
|
||||
//! The rule is the one the IOReport backend is gated on, with the same numbers:
|
||||
//! a counter is live only if, while the probing thread burns its own CPU time
|
||||
//! between samples, every channel the figure sums advanced in at least
|
||||
//! [`MIN_LIVE_ADVANCES`] sample intervals, in at least [`LIVE_SHARE`] (four in
|
||||
//! every five) of the intervals observed, over at least [`MIN_LIVE_WINDOW`] of
|
||||
//! wall-clock time. A batch refresh of one or a few steps cannot meet that; a
|
||||
//! live counter does within a fraction of a second. Each counter probes once per
|
||||
//! process and caches the answer. The rule asks **every** channel to be live,
|
||||
//! here as in IOReport, which asks it of every CPU rail it sums (`CPU Energy`,
|
||||
//! or each die's `DIE_<n>_CPU Energy`): a RAPL figure is the sum over every
|
||||
//! package zone, an NVML figure the sum over every GPU, and an IOReport figure
|
||||
//! the sum over every die, so one frozen package, device or die would leave part
|
||||
//! of the claimed domain unread.
|
||||
//!
|
||||
//! The decision ([`AdvanceWatch`]) is kept apart from the sampling, the burn and
|
||||
//! the clock, so the tests drive it with scripts.
|
||||
|
||||
// The probe machinery runs for the RAPL backend on Linux and the NVML backend;
|
||||
// the rule's numbers are also read by the IOReport backend. A build with neither
|
||||
// probing backend keeps the public `CounterLiveness` type, and nothing calls the
|
||||
// rest outside the tests.
|
||||
#![cfg_attr(not(any(all(feature = "rapl", target_os = "linux"), feature = "nvml")), allow(dead_code))]
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Whether a vendor energy counter (RAPL, NVML) is live on this host, as its
|
||||
/// liveness probe found it. Only [`CounterLiveness::Live`] lets the counter
|
||||
/// produce a hardware-graded ([`crate::Provenance::HwShunt`]) reading.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CounterLiveness {
|
||||
/// The counter could not be watched: it is absent, this process cannot read
|
||||
/// it (RAPL's `energy_uj` is root-only on kernels hardened after
|
||||
/// CVE-2020-8694), it is not supported by the device, or the probe never got
|
||||
/// two samples of it. The probe saw nothing, so it claims neither a live
|
||||
/// counter nor a frozen one.
|
||||
Unavailable,
|
||||
/// The counter was read, but did not keep advancing while the probe burned
|
||||
/// CPU: it held its value, or moved in too few of the probe's intervals (a
|
||||
/// batch refresh). Its figure would not be a reading of the work.
|
||||
Frozen,
|
||||
/// Every channel the counter's figure sums advanced in at least five sample
|
||||
/// intervals, four in every five of those observed, over at least 300 ms,
|
||||
/// while the probing thread burned CPU.
|
||||
Live,
|
||||
}
|
||||
|
||||
/// In at least how many separate sample intervals a channel must advance to
|
||||
/// count as live. One is not enough, nor two: a batch refresh can be published
|
||||
/// in two steps (observed on IOReport, macOS 27.0.1).
|
||||
pub(crate) const MIN_LIVE_ADVANCES: u32 = 5;
|
||||
|
||||
/// The least share of all the intervals observed so far in which a channel must
|
||||
/// have advanced, as (numerator, denominator): four in every five.
|
||||
pub(crate) const LIVE_SHARE: (u64, u64) = (4, 5);
|
||||
|
||||
/// The least wall-clock span, from the probe's first sample to its latest, over
|
||||
/// which those advances must have been observed.
|
||||
pub(crate) const MIN_LIVE_WINDOW: Duration = Duration::from_millis(300);
|
||||
|
||||
/// The probe never runs longer than this many budgets, however slow or failing
|
||||
/// its samples are.
|
||||
pub(crate) const PROBE_CAP_BUDGETS: u32 = 5;
|
||||
|
||||
/// How long (wall clock) a probe watches a counter before concluding it is
|
||||
/// frozen; it runs past this only until it has [`MIN_LIVE_ADVANCES`] intervals,
|
||||
/// up to [`PROBE_CAP_BUDGETS`] budgets.
|
||||
pub(crate) const LIVENESS_BUDGET: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// The most wall-clock time one tick of the probe's burn may take, on a host too
|
||||
/// loaded to grant the probing thread its CPU.
|
||||
pub(crate) const PROBE_TICK_WALL_CAP: Duration = Duration::from_millis(500);
|
||||
|
||||
/// The rule, fed whole samples — one cumulative value per channel, in a fixed
|
||||
/// channel order — each with the wall-clock time it was taken. Each interval is
|
||||
/// one pair of consecutive samples, and each channel advances at most once in
|
||||
/// it (a value strictly above the previous sample's). Comparing every sample
|
||||
/// with the first instead would count one refresh again at every later sample.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AdvanceWatch {
|
||||
/// How many channels a sample carries; a sample of another length is not
|
||||
/// a sample of this counter, and is ignored.
|
||||
channels: usize,
|
||||
/// Sample intervals observed so far.
|
||||
intervals: u32,
|
||||
/// The wall-clock time of the first sample; `None` before it.
|
||||
first_at: Option<Duration>,
|
||||
/// How much wall-clock time the samples so far span.
|
||||
window: Duration,
|
||||
/// The previous sample; `None` before the first.
|
||||
prev: Option<Vec<u64>>,
|
||||
/// For each channel, the number of intervals in which it advanced.
|
||||
advances: Vec<u32>,
|
||||
}
|
||||
|
||||
impl AdvanceWatch {
|
||||
/// A rule for a counter whose samples carry `channels` values.
|
||||
pub(crate) fn new(channels: usize) -> Self {
|
||||
Self {
|
||||
channels,
|
||||
intervals: 0,
|
||||
first_at: None,
|
||||
window: Duration::ZERO,
|
||||
prev: None,
|
||||
advances: vec![0; channels],
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold in the next sample, taken at `at` on any clock whose origin is
|
||||
/// fixed for the watch. The first sample is only the baseline for the next,
|
||||
/// and the start of the window.
|
||||
pub(crate) fn observe(&mut self, sample: &[u64], at: Duration) {
|
||||
if sample.len() != self.channels {
|
||||
return;
|
||||
}
|
||||
let first_at = *self.first_at.get_or_insert(at);
|
||||
if let Some(prev) = self.prev.take() {
|
||||
self.intervals += 1;
|
||||
self.window = at.saturating_sub(first_at);
|
||||
for ((count, now), before) in self.advances.iter_mut().zip(sample).zip(&prev) {
|
||||
*count += u32::from(now > before);
|
||||
}
|
||||
}
|
||||
self.prev = Some(sample.to_vec());
|
||||
}
|
||||
|
||||
/// Has every channel kept advancing — in enough separate intervals, in
|
||||
/// enough of all the intervals so far, over a long enough window — to be
|
||||
/// live? A counter with no channel is never live.
|
||||
pub(crate) fn is_live(&self) -> bool {
|
||||
self.window >= MIN_LIVE_WINDOW
|
||||
&& !self.advances.is_empty()
|
||||
&& self.advances.iter().all(|a| self.sustained(*a))
|
||||
}
|
||||
|
||||
/// Whether a channel that advanced in `advances` intervals did so in at
|
||||
/// least [`MIN_LIVE_ADVANCES`] of them and in at least [`LIVE_SHARE`] of all
|
||||
/// the intervals observed so far.
|
||||
fn sustained(&self, advances: u32) -> bool {
|
||||
let (num, den) = LIVE_SHARE;
|
||||
advances >= MIN_LIVE_ADVANCES && u64::from(advances) * den >= u64::from(self.intervals) * num
|
||||
}
|
||||
|
||||
/// Keep sampling? Until the counter has proved live or `budget` is spent —
|
||||
/// but never stop before a live counter could have shown
|
||||
/// [`MIN_LIVE_ADVANCES`] advances, up to [`PROBE_CAP_BUDGETS`] budgets.
|
||||
pub(crate) fn wants_more(&self, elapsed: Duration, budget: Duration) -> bool {
|
||||
!self.is_live()
|
||||
&& (elapsed < budget || (self.intervals < MIN_LIVE_ADVANCES && elapsed < budget * PROBE_CAP_BUDGETS))
|
||||
}
|
||||
|
||||
/// The verdict: `Unavailable` if not one interval was observed, else `Live`
|
||||
/// or `Frozen`.
|
||||
pub(crate) fn verdict(&self) -> CounterLiveness {
|
||||
if self.intervals == 0 {
|
||||
CounterLiveness::Unavailable
|
||||
} else if self.is_live() {
|
||||
CounterLiveness::Live
|
||||
} else {
|
||||
CounterLiveness::Frozen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe's loop, over any sampler, tick and clock: take a baseline sample,
|
||||
/// then `tick` and sample again for as long as [`AdvanceWatch::wants_more`]
|
||||
/// says, feeding every sample that came back to the rule with the clock's
|
||||
/// reading as it came back. A failed sample (`None`) is skipped; the clock still
|
||||
/// runs, so a sampler that keeps failing ends at the cap with no verdict.
|
||||
pub(crate) fn watch(
|
||||
channels: usize,
|
||||
mut sample: impl FnMut() -> Option<Vec<u64>>,
|
||||
mut tick: impl FnMut(),
|
||||
now: impl Fn() -> Duration,
|
||||
budget: Duration,
|
||||
) -> AdvanceWatch {
|
||||
let started = now();
|
||||
let mut w = AdvanceWatch::new(channels);
|
||||
if let Some(s) = sample() {
|
||||
w.observe(&s, now());
|
||||
}
|
||||
while w.wants_more(now().saturating_sub(started), budget) {
|
||||
tick();
|
||||
if let Some(s) = sample() {
|
||||
w.observe(&s, now());
|
||||
}
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
/// The probe over any opener, tick and clock: `open` says how many channels
|
||||
/// the counter has and hands a sampler over them, or `None` if the counter
|
||||
/// cannot be opened (then nothing is sampled or ticked, and the verdict is
|
||||
/// `Unavailable`).
|
||||
pub(crate) fn probe_with<F>(
|
||||
open: impl FnOnce() -> Option<(usize, F)>,
|
||||
tick: impl FnMut(),
|
||||
now: impl Fn() -> Duration,
|
||||
budget: Duration,
|
||||
) -> CounterLiveness
|
||||
where
|
||||
F: FnMut() -> Option<Vec<u64>>,
|
||||
{
|
||||
match open() {
|
||||
Some((channels, sample)) => watch(channels, sample, tick, now, budget).verdict(),
|
||||
None => CounterLiveness::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
/// The real probe: [`probe_with`] over `open`, a tick that burns `tick_cpu` of
|
||||
/// this thread's own CPU time ([`burn_thread_cpu`]), the monotonic clock and
|
||||
/// [`LIVENESS_BUDGET`]. Runs on the calling thread.
|
||||
pub(crate) fn probe<F>(open: impl FnOnce() -> Option<(usize, F)>, tick_cpu: Duration) -> CounterLiveness
|
||||
where
|
||||
F: FnMut() -> Option<Vec<u64>>,
|
||||
{
|
||||
let epoch = Instant::now();
|
||||
probe_with(open, || burn_thread_cpu(tick_cpu, PROBE_TICK_WALL_CAP), move || epoch.elapsed(), LIVENESS_BUDGET)
|
||||
}
|
||||
|
||||
/// This thread's CPU time so far (`CLOCK_THREAD_CPUTIME_ID`), or `None` where
|
||||
/// it cannot be read.
|
||||
#[cfg(unix)]
|
||||
fn thread_cpu_time() -> Option<Duration> {
|
||||
let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
|
||||
// SAFETY: `ts` is a valid, writable timespec for the duration of the call.
|
||||
let rc = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) };
|
||||
(rc == 0 && ts.tv_sec >= 0 && ts.tv_nsec >= 0).then(|| Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32))
|
||||
}
|
||||
|
||||
/// No thread CPU clock off Unix: the burn falls back to wall-clock time.
|
||||
#[cfg(not(unix))]
|
||||
fn thread_cpu_time() -> Option<Duration> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Burn `cpu` of this thread's own CPU time, or stop after `wall_cap` of
|
||||
/// wall-clock time, whichever comes first. Where the thread CPU clock cannot be
|
||||
/// read, spin for `cpu` of wall-clock time instead.
|
||||
pub(crate) fn burn_thread_cpu(cpu: Duration, wall_cap: Duration) {
|
||||
let wall = Instant::now();
|
||||
let start = thread_cpu_time();
|
||||
let mut acc: u64 = 0;
|
||||
loop {
|
||||
for _ in 0..1024 {
|
||||
acc = std::hint::black_box(acc.wrapping_add(1));
|
||||
}
|
||||
let burned = match (start, thread_cpu_time()) {
|
||||
(Some(start), Some(now)) => now.saturating_sub(start),
|
||||
_ => wall.elapsed(),
|
||||
};
|
||||
if burned >= cpu || wall.elapsed() >= wall_cap {
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::hint::black_box(acc);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
/// The probe interval of an unloaded RAPL probe, in the tests' own literal.
|
||||
pub(crate) const EVERY: Duration = Duration::from_millis(31);
|
||||
|
||||
/// The tests' own statement of the bar: at least five advancing intervals,
|
||||
/// four in every five, over at least 300 ms.
|
||||
const TEST_MIN_ADVANCES: u32 = 5;
|
||||
const TEST_MIN_WINDOW: Duration = Duration::from_millis(300);
|
||||
|
||||
/// Feed a fresh rule `samples`, the i-th at `i * every`; return it and
|
||||
/// whether it was live after each interval.
|
||||
fn feed(channels: usize, samples: &[Vec<u64>], every: Duration) -> (AdvanceWatch, Vec<bool>) {
|
||||
let mut w = AdvanceWatch::new(channels);
|
||||
let mut live = Vec::new();
|
||||
for (i, s) in samples.iter().enumerate() {
|
||||
w.observe(s, every * i as u32);
|
||||
if i > 0 {
|
||||
live.push(w.is_live());
|
||||
}
|
||||
}
|
||||
(w, live)
|
||||
}
|
||||
|
||||
/// `n` one-channel samples of a counter that advances in interval k
|
||||
/// (counted from 1) exactly when `advances(k)`.
|
||||
pub(crate) fn one_channel(n: usize, advances: impl Fn(usize) -> bool) -> Vec<Vec<u64>> {
|
||||
let mut v = 8_000_000_000u64;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
if i > 0 && advances(i) {
|
||||
v += 1_234;
|
||||
}
|
||||
vec![v]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A counter advancing in every interval is live once it has five advances
|
||||
/// AND 300 ms of window — not before.
|
||||
#[test]
|
||||
fn a_counter_advancing_every_interval_is_live_at_five_advances_and_300_ms() {
|
||||
let (w, live) = feed(1, &one_channel(40, |_| true), EVERY);
|
||||
let first = live.iter().position(|&l| l).map(|k| k + 1).expect("never live");
|
||||
// 300 ms / 31 ms → the 10th interval is the first at or past 300 ms.
|
||||
assert_eq!(first, 10, "{live:?}");
|
||||
assert!(EVERY * first as u32 >= TEST_MIN_WINDOW && first as u32 >= TEST_MIN_ADVANCES);
|
||||
assert_eq!(w.verdict(), CounterLiveness::Live);
|
||||
}
|
||||
|
||||
/// On a loaded host the probe's intervals are long (100 ms here): 300 ms of
|
||||
/// window comes after three intervals, but a counter is live only at its
|
||||
/// fifth advance. Three steps a refresh happened to publish 100 ms apart,
|
||||
/// filling those three intervals, are not a live counter.
|
||||
#[test]
|
||||
fn on_long_intervals_five_advances_are_still_required() {
|
||||
const LOADED: Duration = Duration::from_millis(100);
|
||||
let (_, live) = feed(1, &one_channel(12, |_| true), LOADED);
|
||||
let first = live.iter().position(|&l| l).map(|k| k + 1);
|
||||
assert_eq!(first, Some(TEST_MIN_ADVANCES as usize), "{live:?}");
|
||||
let (w, live) = feed(1, &one_channel(12, |k| k <= 3), LOADED);
|
||||
assert!(live.iter().all(|l| !l), "three 100 ms steps counted as live: {live:?}");
|
||||
assert_eq!(w.verdict(), CounterLiveness::Frozen);
|
||||
}
|
||||
|
||||
/// One jump, or a refresh in two to five quick steps, is not a live counter,
|
||||
/// however many intervals follow.
|
||||
#[test]
|
||||
fn a_batch_refresh_is_frozen() {
|
||||
for steps in 1..=5usize {
|
||||
let (w, live) = feed(1, &one_channel(40, |k| k <= steps), EVERY);
|
||||
assert!(live.iter().all(|l| !l), "{steps} quick steps counted as live: {live:?}");
|
||||
assert_eq!(w.verdict(), CounterLiveness::Frozen, "{steps} steps");
|
||||
}
|
||||
}
|
||||
|
||||
/// A counter that holds its value is frozen; a counter going down is not
|
||||
/// advancing.
|
||||
#[test]
|
||||
fn a_held_or_falling_counter_is_frozen() {
|
||||
let held: Vec<Vec<u64>> = (0..40).map(|_| vec![42]).collect();
|
||||
assert_eq!(feed(1, &held, EVERY).0.verdict(), CounterLiveness::Frozen);
|
||||
let falling: Vec<Vec<u64>> = (0..40).map(|i| vec![1_000_000 - i as u64]).collect();
|
||||
assert_eq!(feed(1, &falling, EVERY).0.verdict(), CounterLiveness::Frozen);
|
||||
}
|
||||
|
||||
/// Four in every five: missing one interval in five stays live; missing two
|
||||
/// in five does not.
|
||||
#[test]
|
||||
fn the_share_is_four_in_every_five() {
|
||||
let (w, _) = feed(1, &one_channel(41, |k| k % 5 != 0), EVERY);
|
||||
assert_eq!(w.verdict(), CounterLiveness::Live);
|
||||
let (w, _) = feed(1, &one_channel(41, |k| k % 5 != 0 && k % 5 != 1), EVERY);
|
||||
assert_eq!(w.verdict(), CounterLiveness::Frozen);
|
||||
}
|
||||
|
||||
/// Every channel must be live: one frozen GPU beside a live one leaves part of
|
||||
/// the summed figure unread.
|
||||
#[test]
|
||||
fn every_channel_must_be_live() {
|
||||
let both: Vec<Vec<u64>> = (0..40).map(|i| vec![100 + i as u64, 900 + i as u64]).collect();
|
||||
assert_eq!(feed(2, &both, EVERY).0.verdict(), CounterLiveness::Live);
|
||||
let one_frozen: Vec<Vec<u64>> = (0..40).map(|i| vec![100 + i as u64, 900]).collect();
|
||||
assert_eq!(feed(2, &one_frozen, EVERY).0.verdict(), CounterLiveness::Frozen);
|
||||
}
|
||||
|
||||
/// A sample of the wrong length is not a sample of this counter; no
|
||||
/// interval at all is no verdict.
|
||||
#[test]
|
||||
fn no_interval_is_unavailable() {
|
||||
assert_eq!(AdvanceWatch::new(1).verdict(), CounterLiveness::Unavailable);
|
||||
let mut w = AdvanceWatch::new(1);
|
||||
w.observe(&[1], Duration::ZERO);
|
||||
w.observe(&[2, 3], EVERY);
|
||||
assert_eq!(w.verdict(), CounterLiveness::Unavailable);
|
||||
assert!(!AdvanceWatch::new(0).is_live());
|
||||
}
|
||||
|
||||
/// The probe over a scripted opener, sampler and clock: the clock advances
|
||||
/// `EVERY` per tick; returns the verdict and how many samples were taken.
|
||||
pub(crate) fn scripted(opens: bool, value: impl Fn(usize) -> Option<u64>) -> (CounterLiveness, usize) {
|
||||
let t = Cell::new(Duration::ZERO);
|
||||
let n = Cell::new(0usize);
|
||||
let verdict = probe_with(
|
||||
|| {
|
||||
opens.then_some((1, || {
|
||||
let i = n.get();
|
||||
n.set(i + 1);
|
||||
value(i).map(|v| vec![v])
|
||||
}))
|
||||
},
|
||||
|| t.set(t.get() + EVERY),
|
||||
|| t.get(),
|
||||
LIVENESS_BUDGET,
|
||||
);
|
||||
(verdict, n.get())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_probe_reports_each_verdict() {
|
||||
assert_eq!(scripted(false, |_| Some(1)), (CounterLiveness::Unavailable, 0), "nothing opened: nothing sampled");
|
||||
assert_eq!(scripted(true, |i| Some(1_000 + i as u64)).0, CounterLiveness::Live);
|
||||
assert_eq!(scripted(true, |_| Some(7)).0, CounterLiveness::Frozen);
|
||||
assert_eq!(scripted(true, |_| None).0, CounterLiveness::Unavailable, "no sample ever came back");
|
||||
}
|
||||
|
||||
/// A live counter stops the probe early; a frozen one takes the budget; a
|
||||
/// failing sampler is capped.
|
||||
#[test]
|
||||
fn the_probe_stops_when_live_and_is_capped_when_failing() {
|
||||
let (_, live) = scripted(true, |i| Some(i as u64));
|
||||
assert_eq!(live, 11, "baseline + 10 intervals reach 300 ms at 31 ms");
|
||||
let (_, frozen) = scripted(true, |_| Some(7));
|
||||
assert_eq!(frozen, 1 + LIVENESS_BUDGET.as_millis() as usize / 31 + 1);
|
||||
let (_, failing) = scripted(true, |_| None);
|
||||
assert_eq!(failing, 1 + (LIVENESS_BUDGET * PROBE_CAP_BUDGETS).as_millis() as usize / 31 + 1);
|
||||
}
|
||||
|
||||
/// The real burn takes at least the CPU time it was asked for on this
|
||||
/// thread's own clock (where there is one).
|
||||
#[test]
|
||||
fn the_burn_burns_this_threads_cpu() {
|
||||
let before = thread_cpu_time();
|
||||
burn_thread_cpu(Duration::from_millis(20), Duration::from_millis(500));
|
||||
match (before, thread_cpu_time()) {
|
||||
(Some(b), Some(a)) => assert!(a - b >= Duration::from_millis(20), "{:?}", a - b),
|
||||
_ if cfg!(unix) => panic!("a Unix host has a thread CPU clock"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -241,28 +241,47 @@ impl EnergyMeter for EstimationMeter {
|
|||
|
||||
/// Detect the best meter for this platform. Always returns *something*
|
||||
/// — at minimum the [`EstimationMeter`] fallback.
|
||||
///
|
||||
/// 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
|
||||
/// 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
|
||||
/// those probes also runs once per process, on the first calling thread.
|
||||
pub fn detect_meter() -> CompositeMeter {
|
||||
let mut meters: Vec<Box<dyn EnergyMeter>> = Vec::new();
|
||||
|
||||
#[cfg(all(feature = "ioreport", target_os = "macos"))]
|
||||
{
|
||||
// Prefer the real IOReport energy counter (ModelBased — the same on-die
|
||||
// counters powermetrics/Activity Monitor read). It reports
|
||||
// `is_available() == false` if IOReport can't open (non-Apple-Silicon,
|
||||
// Rosetta, a future macOS), in which case `CompositeMeter` falls through
|
||||
// to the load-average estimator below. Without this the crate shipped a
|
||||
// real meter that `detect_meter` never selected.
|
||||
// Prefer the real IOReport energy counter (HwShunt, like RAPL and NVML —
|
||||
// the same on-die counters powermetrics/Activity Monitor read), but only
|
||||
// where it can meter CPU work: a vendor counter is a hardware reading
|
||||
// only while it is live for the domain it claims. It reports `is_available() == false` unless IOReport
|
||||
// opens AND every CPU energy rail it sums keeps advancing under load (not
|
||||
// just a batch refresh's few steps, nor one die of two): a subscription
|
||||
// can open while every CPU rail is frozen (macOS 27.0.1 on an M5 Max and
|
||||
// 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`.
|
||||
meters.push(Box::new(crate::apple_ioreport::AppleIoReportCounter::new()));
|
||||
meters.push(Box::new(crate::apple::AppleSiliconMeter::new()));
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "rapl", target_os = "linux"))]
|
||||
{
|
||||
// Available only when every package zone's `energy_uj` is readable and
|
||||
// keeps advancing under load (the sustained-advance rule); otherwise it
|
||||
// falls through. It sums every package, never `psys` or a sub-zone.
|
||||
meters.push(Box::new(crate::rapl::RaplCounter::new()));
|
||||
}
|
||||
|
||||
#[cfg(feature = "nvml")]
|
||||
{
|
||||
// HwShunt from the cumulative-energy counter only while it is live; the
|
||||
// power-draw integral (ModelBased) otherwise. Its domain is the GPUs.
|
||||
meters.push(Box::new(crate::nvml::NvmlCounter::new()));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,40 +1,87 @@
|
|||
//! NVIDIA discrete GPU energy counter via NVML — `Provenance::HwShunt`.
|
||||
//! NVIDIA discrete GPU energy counter via NVML — `Provenance::HwShunt` while its
|
||||
//! cumulative-energy counter is live, `Provenance::ModelBased` otherwise.
|
||||
//!
|
||||
//! Sums `power_usage()` across all detected GPUs and integrates over
|
||||
//! time to maintain a cumulative microjoule counter. On Hopper+ /
|
||||
//! Blackwell, `nvmlDeviceGetTotalEnergyConsumption()` returns the
|
||||
//! cumulative-energy counter directly; older silicon falls back to
|
||||
//! `power_usage()` integration (still hardware-shunt-based — NVIDIA's
|
||||
//! power-monitoring IC samples the rail at ~50 ms).
|
||||
//! On Volta+ silicon, `nvmlDeviceGetTotalEnergyConsumption()` returns a
|
||||
//! cumulative-energy counter (mJ since driver reload), summed here across every
|
||||
//! GPU. That is a vendor energy counter, and its reading is graded a hardware
|
||||
//! reading — but only while it is demonstrably live for the domain the figure
|
||||
//! claims (JouleClaw SPEC §1.3): every GPU's counter must keep advancing under
|
||||
//! the sustained-advance rule ([`crate::CounterLiveness`]) — in at least five
|
||||
//! sample intervals, four in every five, over at least 300 ms, while the
|
||||
//! probing thread burns 100 ms of its own CPU time between samples (twice the
|
||||
//! counter's ~50 ms sample period, so a live counter has moved in every
|
||||
//! interval; a powered GPU draws tens of watts at idle, ~1 J per interval,
|
||||
//! against a 1 mJ quantum). Probed once per process and cached.
|
||||
//!
|
||||
//! Resolution: ~1 mJ (vendor-reported; rounded up from the underlying
|
||||
//! INA3221 / on-die sense quantization).
|
||||
//! Where that counter is unsupported (pre-Volta, some consumer parts) or not
|
||||
//! live, the counter integrates `power_usage()` over wall time instead. That
|
||||
//! integral is a Riemann sum of the driver's sampled power, not a counter: it
|
||||
//! advances whenever time passes, so no liveness probe can vouch for it, and
|
||||
//! its readings are graded [`Provenance::ModelBased`] — the grade JouleClaw
|
||||
//! SPEC §1.3 gives NVML `power.draw`. Never `HwShunt`.
|
||||
//!
|
||||
//! Minimum useful sampling window: ~50 ms. Faster polls return the
|
||||
//! same sampled value with re-quantization noise.
|
||||
//! The domain is [`EnergyDomain::Gpu`]: every GPU NVML enumerates, nothing
|
||||
//! else. The CPU work that drives them is not in it.
|
||||
//!
|
||||
//! Resolution: ~1 mJ (the counter's quantum). Minimum useful sampling window:
|
||||
//! ~50 ms. Faster polls return the same sampled value.
|
||||
//!
|
||||
//! Without the `nvml` cargo feature this module is not compiled.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::meter::{EnergyMeter, EnergySource};
|
||||
use crate::{EnergyCounter, EnergyDomain, EnergyError, EnergyReading, Provenance};
|
||||
use crate::{CounterLiveness, EnergyCounter, EnergyDomain, EnergyError, EnergyReading, Provenance};
|
||||
|
||||
/// Thread CPU time the liveness probe burns between samples of the
|
||||
/// cumulative-energy counter: two of its ~50 ms sample periods.
|
||||
const PROBE_TICK: Duration = Duration::from_millis(100);
|
||||
|
||||
/// How `read()` obtains a figure, decided once at construction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
/// NVML did not initialise, or found no GPU: no reading.
|
||||
Unavailable,
|
||||
/// The cumulative-energy counter, live: a hardware reading (`HwShunt`).
|
||||
Counter,
|
||||
/// The integral of `power_usage()`: a vendor energy model (`ModelBased`).
|
||||
PowerIntegral,
|
||||
}
|
||||
|
||||
/// The reading mode for a host whose cumulative-energy counter is `supported`
|
||||
/// (or not) and whose liveness probe said `liveness`: the counter only when it
|
||||
/// is supported AND live, the power integral otherwise.
|
||||
fn mode_for(supported: bool, liveness: CounterLiveness) -> Mode {
|
||||
match (supported, liveness) {
|
||||
(true, CounterLiveness::Live) => Mode::Counter,
|
||||
(_, CounterLiveness::Live | CounterLiveness::Frozen | CounterLiveness::Unavailable) => Mode::PowerIntegral,
|
||||
}
|
||||
}
|
||||
|
||||
/// The grade of every reading a counter in `mode` produces.
|
||||
fn grade(mode: Mode) -> Provenance {
|
||||
match mode {
|
||||
Mode::Counter => Provenance::HwShunt,
|
||||
Mode::PowerIntegral => Provenance::ModelBased,
|
||||
// No reading is produced; this is the grade a reading would have had
|
||||
// from the counter, as the IOReport backend reports for a declined one.
|
||||
Mode::Unavailable => Provenance::HwShunt,
|
||||
}
|
||||
}
|
||||
|
||||
/// NVIDIA discrete GPU energy counter.
|
||||
///
|
||||
/// Initialises NVML at construction time. If NVML init fails or no
|
||||
/// GPUs are present the counter is permanently unavailable.
|
||||
pub struct NvmlCounter {
|
||||
available: bool,
|
||||
nvml: Option<nvml_wrapper::Nvml>,
|
||||
device_count: u32,
|
||||
/// `true` when the silicon exposes `nvmlDeviceGetTotalEnergyConsumption`
|
||||
/// (Volta+): a real cumulative coulomb count, read directly rather than
|
||||
/// integrated from `power_usage()`. Probed once at construction.
|
||||
use_total_energy: bool,
|
||||
mode: Mode,
|
||||
liveness: CounterLiveness,
|
||||
cumulative_uj: AtomicU64,
|
||||
last_timestamp_ms: AtomicU64,
|
||||
}
|
||||
|
|
@ -46,7 +93,10 @@ impl Default for NvmlCounter {
|
|||
}
|
||||
|
||||
impl NvmlCounter {
|
||||
/// Initialise NVML and enumerate GPUs.
|
||||
/// Initialise NVML and enumerate GPUs. Where every GPU supports the
|
||||
/// cumulative-energy counter, the first call in a process runs its liveness
|
||||
/// probe on the calling thread (about 0.5 s where it is live, about 1 s where
|
||||
/// it is not) and caches the answer ([`Self::liveness`]).
|
||||
pub fn new() -> Self {
|
||||
let nvml = match nvml_wrapper::Nvml::init() {
|
||||
Ok(nvml) => nvml,
|
||||
|
|
@ -60,20 +110,31 @@ impl NvmlCounter {
|
|||
tracing::debug!("NVML initialized but no GPUs found");
|
||||
return Self::unavailable(Some(nvml));
|
||||
}
|
||||
// Prefer the true cumulative-energy counter when the silicon supports
|
||||
// it — a real coulomb count, free of the timing error that integrating
|
||||
// `power_usage()` accrues. `NotSupported` on pre-Volta / some consumer
|
||||
// parts falls back to integration. Probe device 0 once here.
|
||||
let use_total_energy = nvml
|
||||
.device_by_index(0)
|
||||
.and_then(|device| device.total_energy_consumption())
|
||||
.is_ok();
|
||||
tracing::info!(gpu_count = device_count, use_total_energy, "NVML initialized");
|
||||
// The cumulative-energy counter must be readable on every GPU the figure
|
||||
// sums; `NotSupported` on any of them leaves the power integral.
|
||||
let supported = total_energy_uj(&nvml, device_count).is_some();
|
||||
let liveness = if supported {
|
||||
static LIVENESS: OnceLock<CounterLiveness> = OnceLock::new();
|
||||
*LIVENESS.get_or_init(|| {
|
||||
crate::liveness::probe(
|
||||
|| total_energy_uj(&nvml, device_count).map(|first| (first.len(), || total_energy_uj(&nvml, device_count))),
|
||||
PROBE_TICK,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
CounterLiveness::Unavailable
|
||||
};
|
||||
let mode = mode_for(supported, liveness);
|
||||
tracing::info!(gpu_count = device_count, supported, ?liveness, ?mode, "NVML initialized");
|
||||
Self::with(Some(nvml), device_count, mode, liveness)
|
||||
}
|
||||
|
||||
fn with(nvml: Option<nvml_wrapper::Nvml>, device_count: u32, mode: Mode, liveness: CounterLiveness) -> Self {
|
||||
Self {
|
||||
available: true,
|
||||
nvml: Some(nvml),
|
||||
nvml,
|
||||
device_count,
|
||||
use_total_energy,
|
||||
mode,
|
||||
liveness,
|
||||
cumulative_uj: AtomicU64::new(0),
|
||||
last_timestamp_ms: AtomicU64::new(0),
|
||||
}
|
||||
|
|
@ -82,27 +143,39 @@ impl NvmlCounter {
|
|||
/// An unavailable counter (NVML init failed or no GPUs), keeping any NVML
|
||||
/// handle so diagnostics can still query the library.
|
||||
fn unavailable(nvml: Option<nvml_wrapper::Nvml>) -> Self {
|
||||
Self {
|
||||
available: false,
|
||||
nvml,
|
||||
device_count: 0,
|
||||
use_total_energy: false,
|
||||
cumulative_uj: AtomicU64::new(0),
|
||||
last_timestamp_ms: AtomicU64::new(0),
|
||||
}
|
||||
Self::with(nvml, 0, Mode::Unavailable, CounterLiveness::Unavailable)
|
||||
}
|
||||
|
||||
/// Whether NVML initialised and at least one GPU was found.
|
||||
pub fn is_available(&self) -> bool {
|
||||
self.available
|
||||
self.mode != Mode::Unavailable
|
||||
}
|
||||
|
||||
/// `true` when `read()` uses the hardware's cumulative-energy counter
|
||||
/// (`nvmlDeviceGetTotalEnergyConsumption`), `false` when it falls back to
|
||||
/// integrating `power_usage()`. Lets callers report which path is live.
|
||||
/// (`nvmlDeviceGetTotalEnergyConsumption`) — supported on every GPU and
|
||||
/// live — and grades its readings `HwShunt`; `false` when it integrates
|
||||
/// `power_usage()` and grades them `ModelBased`, or reads nothing.
|
||||
pub fn uses_true_counter(&self) -> bool {
|
||||
self.use_total_energy
|
||||
self.mode == Mode::Counter
|
||||
}
|
||||
|
||||
/// The cumulative-energy counter's liveness on this host:
|
||||
/// [`CounterLiveness::Live`] exactly when [`Self::uses_true_counter`];
|
||||
/// `Unavailable` where it is unsupported or NVML has no GPU.
|
||||
pub fn liveness(&self) -> CounterLiveness {
|
||||
self.liveness
|
||||
}
|
||||
}
|
||||
|
||||
/// Every GPU's cumulative energy, in µJ, in device order; `None` if any of them
|
||||
/// cannot be read.
|
||||
fn total_energy_uj(nvml: &nvml_wrapper::Nvml, device_count: u32) -> Option<Vec<u64>> {
|
||||
(0..device_count)
|
||||
.map(|i| {
|
||||
let mj = nvml.device_by_index(i).ok()?.total_energy_consumption().ok()?;
|
||||
Some(mj.saturating_mul(1_000))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl EnergyCounter for NvmlCounter {
|
||||
|
|
@ -111,11 +184,7 @@ impl EnergyCounter for NvmlCounter {
|
|||
}
|
||||
|
||||
fn provenance(&self) -> Provenance {
|
||||
// NVIDIA's on-card power-monitor IC is a real shunt. The
|
||||
// `nvmlDeviceGetTotalEnergyConsumption` path is unambiguously
|
||||
// HwShunt; the `power_usage()` integration path is also shunt-
|
||||
// sourced, just with ~50 ms quantization.
|
||||
Provenance::HwShunt
|
||||
grade(self.mode)
|
||||
}
|
||||
|
||||
fn resolution_uj(&self) -> u64 {
|
||||
|
|
@ -134,7 +203,7 @@ impl EnergyCounter for NvmlCounter {
|
|||
.as_ref()
|
||||
.ok_or(EnergyError::NoCounter(EnergyDomain::Gpu))?;
|
||||
|
||||
if !self.available {
|
||||
if self.mode == Mode::Unavailable {
|
||||
return Err(EnergyError::NoCounter(EnergyDomain::Gpu));
|
||||
}
|
||||
|
||||
|
|
@ -143,11 +212,9 @@ impl EnergyCounter for NvmlCounter {
|
|||
.map_err(|e| EnergyError::Platform(e.to_string()))?
|
||||
.as_millis() as u64;
|
||||
|
||||
// Preferred path: the hardware's own cumulative-energy counter
|
||||
// (mJ since driver reload), summed across GPUs. No integration, so no
|
||||
// timing error — deltas across two reads are exact to the counter's
|
||||
// resolution.
|
||||
if self.use_total_energy {
|
||||
// The hardware's own cumulative-energy counter, summed across GPUs, read
|
||||
// only where it was observed live. No integration, so no timing error.
|
||||
if self.mode == Mode::Counter {
|
||||
let mut total_uj: u64 = 0;
|
||||
for i in 0..self.device_count {
|
||||
let device = nvml
|
||||
|
|
@ -162,12 +229,12 @@ impl EnergyCounter for NvmlCounter {
|
|||
uj: total_uj,
|
||||
timestamp_ns: now_ms.saturating_mul(1_000_000),
|
||||
domain: EnergyDomain::Gpu,
|
||||
provenance: Provenance::HwShunt,
|
||||
provenance: grade(self.mode),
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: integrate the shunt-sampled instantaneous power over wall
|
||||
// time. Still shunt-sourced, but a Riemann sum — hence ~50 ms windows.
|
||||
// The power integral: the shunt-sampled instantaneous power, integrated
|
||||
// over wall time — a Riemann sum, hence ~50 ms windows, graded ModelBased.
|
||||
let mut total_milliwatts: u64 = 0;
|
||||
for i in 0..self.device_count {
|
||||
let device = nvml
|
||||
|
|
@ -192,7 +259,7 @@ impl EnergyCounter for NvmlCounter {
|
|||
uj: total_uj,
|
||||
timestamp_ns: now_ms.saturating_mul(1_000_000),
|
||||
domain: EnergyDomain::Gpu,
|
||||
provenance: Provenance::HwShunt,
|
||||
provenance: grade(self.mode),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -203,7 +270,7 @@ impl EnergyMeter for NvmlCounter {
|
|||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
self.available
|
||||
self.mode != Mode::Unavailable
|
||||
}
|
||||
|
||||
fn energy_source(&self) -> EnergySource {
|
||||
|
|
@ -214,22 +281,23 @@ impl EnergyMeter for NvmlCounter {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nvml_counter_declares_hw_shunt() {
|
||||
let c = NvmlCounter::new();
|
||||
assert_eq!(c.provenance(), Provenance::HwShunt);
|
||||
assert_eq!(c.domain(), EnergyDomain::Gpu);
|
||||
assert_eq!(c.name(), "nvml");
|
||||
}
|
||||
use crate::liveness::tests::EVERY;
|
||||
use crate::liveness::{probe_with, LIVENESS_BUDGET};
|
||||
use std::cell::Cell;
|
||||
|
||||
#[test]
|
||||
fn nvml_counter_unavailable_without_gpu() {
|
||||
// CI / macOS / non-NVIDIA hosts will all hit this branch.
|
||||
let c = NvmlCounter::new();
|
||||
assert_eq!(c.name(), "nvml");
|
||||
assert_eq!(c.domain(), EnergyDomain::Gpu);
|
||||
if !c.is_available() {
|
||||
assert!(c.read().is_err());
|
||||
assert!(!c.uses_true_counter());
|
||||
assert_eq!(c.liveness(), CounterLiveness::Unavailable);
|
||||
}
|
||||
eprintln!("nvml on this host: available={} true_counter={} liveness={:?} grade={:?}",
|
||||
c.is_available(), c.uses_true_counter(), c.liveness(), c.provenance());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -240,4 +308,59 @@ mod tests {
|
|||
// 50 ms minimum window.
|
||||
assert!(c.min_window_ns() >= 50_000_000);
|
||||
}
|
||||
|
||||
/// The cumulative-energy counter is read, as a hardware reading, only where
|
||||
/// it is supported AND live. A frozen or unprobed counter leaves the power
|
||||
/// integral, which is never graded `HwShunt`.
|
||||
#[test]
|
||||
fn only_a_live_supported_counter_is_a_hardware_reading() {
|
||||
use CounterLiveness::*;
|
||||
for (supported, liveness, mode) in [
|
||||
(true, Live, Mode::Counter),
|
||||
(true, Frozen, Mode::PowerIntegral),
|
||||
(true, Unavailable, Mode::PowerIntegral),
|
||||
(false, Unavailable, Mode::PowerIntegral),
|
||||
(false, Live, Mode::PowerIntegral),
|
||||
] {
|
||||
assert_eq!(mode_for(supported, liveness), mode, "supported={supported} {liveness:?}");
|
||||
let c = NvmlCounter::with(None, 1, mode, liveness);
|
||||
assert_eq!(c.uses_true_counter(), mode == Mode::Counter);
|
||||
let want = if mode == Mode::Counter { Provenance::HwShunt } else { Provenance::ModelBased };
|
||||
assert_eq!(c.provenance(), want, "supported={supported} {liveness:?}");
|
||||
assert!(c.is_available());
|
||||
}
|
||||
assert_eq!(grade(Mode::PowerIntegral), Provenance::ModelBased, "a power.draw integral is a vendor model");
|
||||
}
|
||||
|
||||
/// The probe over scripted per-GPU counters (mJ → µJ, as `total_energy_uj`
|
||||
/// returns them) and a clock advancing `EVERY` per tick.
|
||||
fn nvml_probe(gpus: usize, mj: impl Fn(usize, usize) -> Option<u64>) -> CounterLiveness {
|
||||
let t = Cell::new(Duration::ZERO);
|
||||
let n = Cell::new(0usize);
|
||||
let sample = || {
|
||||
let i = n.get();
|
||||
n.set(i + 1);
|
||||
(0..gpus).map(|g| mj(i, g).map(|v| v * 1_000)).collect::<Option<Vec<u64>>>()
|
||||
};
|
||||
probe_with(
|
||||
|| sample().map(|first| (first.len(), sample)),
|
||||
|| t.set(t.get() + EVERY),
|
||||
|| t.get(),
|
||||
LIVENESS_BUDGET,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_gpu_must_keep_advancing() {
|
||||
// ~60 W idle per GPU: ~2 J per interval.
|
||||
assert_eq!(nvml_probe(2, |i, g| Some(5_000_000 * (g as u64 + 1) + 2_000 * i as u64)), CounterLiveness::Live);
|
||||
assert_eq!(
|
||||
nvml_probe(2, |i, g| Some(if g == 0 { 5_000_000 + 2_000 * i as u64 } else { 9_000_000 })),
|
||||
CounterLiveness::Frozen,
|
||||
"one GPU's counter held: the sum would leave its share of the domain unread"
|
||||
);
|
||||
assert_eq!(nvml_probe(1, |_, _| Some(5_000_000)), CounterLiveness::Frozen);
|
||||
assert_eq!(nvml_probe(1, |_, _| None), CounterLiveness::Unavailable);
|
||||
assert_eq!(nvml_probe(0, |_, _| Some(1)), CounterLiveness::Frozen, "no GPU is never live");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,216 @@
|
|||
//! Intel / AMD x86 RAPL energy counter — `Provenance::HwShunt`.
|
||||
//! Intel / AMD x86 RAPL energy counter — `Provenance::HwShunt` while it is live.
|
||||
//!
|
||||
//! Reads from the Linux `powercap` sysfs interface
|
||||
//! (`/sys/class/powercap/intel-rapl:0/energy_uj`). The kernel exposes
|
||||
//! the package-domain energy counter directly in microjoules with the
|
||||
//! MSR's native resolution (1 μJ on Intel, ~15 μJ on AMD Zen).
|
||||
//! Reads the Linux `powercap` sysfs interface (`/sys/class/powercap`): the
|
||||
//! energy of every package zone, summed. A package zone is a top-level
|
||||
//! `intel-rapl:<n>` zone whose `name` file reads `package-<n>`, so the figure
|
||||
//! covers every socket of a multi-socket server, not package 0 alone. Nothing
|
||||
//! else is summed, wherever it sits, zone 0 included: not `psys` (the platform
|
||||
//! zone, whose energy includes the packages', so summing it beside them counts
|
||||
//! them twice), not a `dram` zone, not a package's `core` / `uncore` / `dram`
|
||||
//! sub-zones (`intel-rapl:0:0`), and not the MMIO interface
|
||||
//! (`intel-rapl-mmio:0`, which republishes a package). The kernel exposes each
|
||||
//! package-domain counter directly in microjoules with the MSR's native
|
||||
//! resolution (1 μJ on Intel, ~15 μJ on AMD Zen), and each wraps at its own
|
||||
//! `max_energy_range_uj`, so the counter keeps a running total: each package's
|
||||
//! delta between reads, taken modulo that package's own range.
|
||||
//!
|
||||
//! Resolution: 1 μJ. Minimum useful sampling window: ~10 ms (the MSR
|
||||
//! itself updates at ~1 kHz; below this readings are quantization
|
||||
//! noise).
|
||||
//!
|
||||
//! **Liveness.** A vendor energy counter is a hardware reading only while it is
|
||||
//! demonstrably live for the domain the figure claims (JouleClaw SPEC §1.3).
|
||||
//! A readable `energy_uj` is not yet a live one: a hypervisor can pass the file
|
||||
//! through without updating it, and a counter that holds its value would bill
|
||||
//! every span zero. So the counter is available only when every package's file
|
||||
//! is readable **and** every package's value keeps advancing under the
|
||||
//! sustained-advance rule ([`crate::CounterLiveness`]): while the probing thread
|
||||
//! burns 25 ms of its own CPU time between samples, each advanced in at least
|
||||
//! five intervals, four in every five, over at least 300 ms. Every package,
|
||||
//! because the figure sums them all: one held beside one live would leave a
|
||||
//! socket's energy out while claiming the CPU. A host with no package zone (one
|
||||
//! that lists only `psys`) has no counter. Probed once per process for each
|
||||
//! package set and cached ([`rapl_liveness`]); about 0.3 s where the counters
|
||||
//! are live, about 1 s where one is frozen. Otherwise the counter is unavailable
|
||||
//! and [`crate::meter::detect_meter`] falls through to the estimator.
|
||||
//!
|
||||
//! On non-Linux targets the counter constructs successfully but is
|
||||
//! permanently unavailable — `read()` returns
|
||||
//! [`EnergyError::NoCounter`].
|
||||
//! [`EnergyError::NoCounter`] — and nothing is probed.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::meter::{EnergyMeter, EnergySource};
|
||||
use crate::{EnergyCounter, EnergyDomain, EnergyError, EnergyReading, Provenance};
|
||||
use crate::{CounterLiveness, EnergyCounter, EnergyDomain, EnergyError, EnergyReading, Provenance};
|
||||
|
||||
/// Default RAPL package-0 energy file on Linux.
|
||||
pub const RAPL_ENERGY_PATH: &str = "/sys/class/powercap/intel-rapl:0/energy_uj";
|
||||
/// Where the Linux kernel lists its powercap zones, RAPL's among them.
|
||||
pub const POWERCAP_ROOT: &str = "/sys/class/powercap";
|
||||
|
||||
/// Intel / AMD x86 RAPL package-domain energy counter.
|
||||
/// Thread CPU time the liveness probe burns between samples of `energy_uj`. The
|
||||
/// MSR updates at ~1 kHz, so a live counter advances in every interval.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
const PROBE_TICK: Duration = Duration::from_millis(25);
|
||||
|
||||
/// One RAPL package zone: a top-level `intel-rapl:<index>` zone whose `name`
|
||||
/// reads `package-<n>`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Package {
|
||||
/// The zone's directory, `<powercap>/intel-rapl:<index>`.
|
||||
dir: PathBuf,
|
||||
/// `<index>` in `intel-rapl:<index>`.
|
||||
index: u32,
|
||||
/// The zone's `max_energy_range_uj`, the value its `energy_uj` wraps at,
|
||||
/// when that reads as a number above zero.
|
||||
max_energy_range_uj: Option<u64>,
|
||||
}
|
||||
|
||||
/// Whether a top-level zone's `name` file names a package: `package-<n>`, `<n>`
|
||||
/// one or more digits (the file ends in a newline). Not `psys`, `dram`, `core`
|
||||
/// or `uncore`.
|
||||
pub(crate) fn is_package_name(name: &str) -> bool {
|
||||
name.trim().strip_prefix("package-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// The index of a top-level RAPL zone directory, `intel-rapl:<index>`. `None`
|
||||
/// for anything else: a sub-zone (`intel-rapl:0:0`), the control type itself
|
||||
/// (`intel-rapl`), and the MMIO interface (`intel-rapl-mmio:0`, also named
|
||||
/// `package-0`, which would count a package twice).
|
||||
pub(crate) fn top_level_zone(dir: &str) -> Option<u32> {
|
||||
let n = dir.strip_prefix("intel-rapl:")?;
|
||||
if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
n.parse().ok()
|
||||
}
|
||||
|
||||
/// The file at `path`, read and parsed as an unsigned integer, or `None`.
|
||||
fn read_u64(path: &Path) -> Option<u64> {
|
||||
std::fs::read_to_string(path).ok()?.trim().parse::<u64>().ok()
|
||||
}
|
||||
|
||||
/// The package zones under the powercap directory `root`, in index order: every
|
||||
/// top-level zone whose `name` reads `package-<n>`, with the range its counter
|
||||
/// wraps at. Everything else is left out, zone 0 included if it is not a
|
||||
/// package. Empty when no zone is a package, or `root` cannot be listed.
|
||||
pub(crate) fn packages_under(root: &Path) -> Vec<Package> {
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut packages: Vec<Package> = entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let index = top_level_zone(entry.file_name().to_str()?)?;
|
||||
let dir = entry.path();
|
||||
let name = std::fs::read_to_string(dir.join("name")).ok()?;
|
||||
is_package_name(&name).then(|| Package {
|
||||
max_energy_range_uj: read_u64(&dir.join("max_energy_range_uj")).filter(|&m| m > 0),
|
||||
dir,
|
||||
index,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
packages.sort_by_key(|p| p.index);
|
||||
packages
|
||||
}
|
||||
|
||||
/// Every package's `energy_uj`, in order. `None` when there is no package, or
|
||||
/// when any one cannot be read: a figure that left a package out would miss its
|
||||
/// energy while claiming the whole CPU.
|
||||
pub(crate) fn read_packages(packages: &[Package]) -> Option<Vec<u64>> {
|
||||
if packages.is_empty() {
|
||||
return None;
|
||||
}
|
||||
packages.iter().map(|p| read_u64(&p.dir.join("energy_uj"))).collect()
|
||||
}
|
||||
|
||||
/// One zone's energy between two reads, modulo its own wrap. A counter that went
|
||||
/// down wrapped at `max_range`; without a known (non-zero) range, or with an
|
||||
/// earlier read above it, the energy is unknown and `None`. Undercounts a wrap
|
||||
/// by at most one counter unit, and cannot see more than one wrap between two
|
||||
/// reads: about a quarter of an hour at 300 W for a package's usual
|
||||
/// 262,143,328,850 μJ range.
|
||||
pub(crate) fn zone_delta_uj(e0: u64, e1: u64, max_range: Option<u64>) -> Option<u64> {
|
||||
if e1 >= e0 {
|
||||
return Some(e1 - e0);
|
||||
}
|
||||
max_range.filter(|&m| m > 0)?.checked_sub(e0)?.checked_add(e1)
|
||||
}
|
||||
|
||||
/// A RAPL counter's running total across its reads.
|
||||
#[derive(Debug)]
|
||||
enum Running {
|
||||
/// Not read yet.
|
||||
Unread,
|
||||
/// Each package's raw `energy_uj` at the last read, and the total so far:
|
||||
/// the first read's counters summed, plus every package's energy since,
|
||||
/// each taken modulo its own range.
|
||||
At { last: Vec<u64>, total: u64 },
|
||||
/// A package's energy between two reads could not be known (its counter
|
||||
/// went down, and its range is unknown). The total no longer holds that
|
||||
/// energy, so every read from then on is [`EnergyError::CounterWrap`]
|
||||
/// rather than a figure short by it.
|
||||
Lost,
|
||||
}
|
||||
|
||||
impl Running {
|
||||
/// Fold in `now`, every package's raw counter in `packages` order, and
|
||||
/// return the running total. Each package's wrap is taken at ITS OWN range;
|
||||
/// summing the raw counters instead would turn one package's wrap into a
|
||||
/// fall of the sum.
|
||||
fn advance(&mut self, packages: &[Package], now: &[u64]) -> Result<u64, EnergyError> {
|
||||
match self {
|
||||
Running::Lost => Err(EnergyError::CounterWrap),
|
||||
Running::Unread => {
|
||||
let total = now
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, &uj| sum.checked_add(uj))
|
||||
.ok_or_else(|| EnergyError::Platform("the RAPL package counters' sum overflows u64".into()))?;
|
||||
*self = Running::At { last: now.to_vec(), total };
|
||||
Ok(total)
|
||||
}
|
||||
Running::At { last, total } => {
|
||||
if last.len() != now.len() || packages.len() != now.len() {
|
||||
return Err(EnergyError::Platform("a RAPL read did not cover every package".into()));
|
||||
}
|
||||
match energy_between(packages, last, now).and_then(|energy| total.checked_add(energy)) {
|
||||
Some(sum) => {
|
||||
*total = sum;
|
||||
last.copy_from_slice(now);
|
||||
Ok(sum)
|
||||
}
|
||||
None => {
|
||||
*self = Running::Lost;
|
||||
Err(EnergyError::CounterWrap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every package's energy between the raw counters `last` and `now`, summed,
|
||||
/// each taken modulo its own range ([`zone_delta_uj`]); `None` when one
|
||||
/// package's energy is unknown.
|
||||
fn energy_between(packages: &[Package], last: &[u64], now: &[u64]) -> Option<u64> {
|
||||
let mut energy = 0u64;
|
||||
for ((p, &e0), &e1) in packages.iter().zip(last).zip(now) {
|
||||
energy = energy.checked_add(zone_delta_uj(e0, e1, p.max_energy_range_uj)?)?;
|
||||
}
|
||||
Some(energy)
|
||||
}
|
||||
|
||||
/// Intel / AMD x86 RAPL package-domain energy counter: the sum over every
|
||||
/// package zone of the host.
|
||||
pub struct RaplCounter {
|
||||
available: bool,
|
||||
path: &'static str,
|
||||
packages: Vec<Package>,
|
||||
liveness: CounterLiveness,
|
||||
running: Mutex<Running>,
|
||||
}
|
||||
|
||||
impl Default for RaplCounter {
|
||||
|
|
@ -37,38 +220,123 @@ impl Default for RaplCounter {
|
|||
}
|
||||
|
||||
impl RaplCounter {
|
||||
/// Build a counter against the default `intel-rapl:0` path.
|
||||
/// Build a counter over this host's package zones (under [`POWERCAP_ROOT`]).
|
||||
///
|
||||
/// Availability requires the counter to be *readable*, not merely present.
|
||||
/// Since CVE-2020-8694 (PLATYPUS) the kernel restricts `energy_uj` to root
|
||||
/// (mode 0400), so on a hardened box the file exists but every read fails
|
||||
/// with `EPERM`. A mere `Path::exists()` check would then mark RAPL
|
||||
/// available, the composite meter would select it, and *every* `read()`
|
||||
/// would error instead of falling through to another counter — the opposite
|
||||
/// of the honesty contract. So probe with a real read+parse here.
|
||||
/// Availability requires every package counter to be *readable* and *live*,
|
||||
/// not merely present. Since CVE-2020-8694 (PLATYPUS) the kernel restricts
|
||||
/// `energy_uj` to root (mode 0400), so on a hardened box the files exist but
|
||||
/// every read fails with `EPERM`; the liveness probe then never gets a sample
|
||||
/// and the counter is [`CounterLiveness::Unavailable`], as it is on a host
|
||||
/// with no package zone. A readable package counter that does not keep
|
||||
/// advancing is [`CounterLiveness::Frozen`]. Either way it is unavailable,
|
||||
/// and the composite meter falls through instead of selecting a counter
|
||||
/// whose every read errors, or reads zero, or leaves a socket out.
|
||||
///
|
||||
/// The first call for a package set in a process runs the liveness probe on
|
||||
/// the calling thread; later calls reuse the answer ([`rapl_liveness`]).
|
||||
pub fn new() -> Self {
|
||||
Self::with_path(RAPL_ENERGY_PATH)
|
||||
Self::with_root(POWERCAP_ROOT)
|
||||
}
|
||||
|
||||
/// Build against an explicit `energy_uj` path (used by tests and hosts whose
|
||||
/// package-0 domain lives at a non-default index).
|
||||
pub fn with_path(path: &'static str) -> Self {
|
||||
let available = cfg!(target_os = "linux") && Self::probe_readable(path);
|
||||
Self { available, path }
|
||||
/// Build over the package zones under an explicit powercap directory (a
|
||||
/// container or chroot that mounts sysfs elsewhere). The same rules apply:
|
||||
/// package zones only, every one live.
|
||||
pub fn with_root(root: impl AsRef<Path>) -> Self {
|
||||
let packages = packages_under(root.as_ref());
|
||||
let liveness = package_liveness(&packages);
|
||||
Self::given(packages, liveness)
|
||||
}
|
||||
|
||||
/// Whether `path` can be opened, read, and parsed as a `u64` right now.
|
||||
fn probe_readable(path: &str) -> bool {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|raw| raw.trim().parse::<u64>().ok())
|
||||
.is_some()
|
||||
/// The counter over `packages`, whose liveness is `liveness`: available
|
||||
/// exactly when that is [`CounterLiveness::Live`].
|
||||
fn given(packages: Vec<Package>, liveness: CounterLiveness) -> Self {
|
||||
Self { available: admits(liveness), packages, liveness, running: Mutex::new(Running::Unread) }
|
||||
}
|
||||
|
||||
/// Whether this counter found a readable powercap entry.
|
||||
/// Whether this counter meters: every package's `energy_uj` is readable and
|
||||
/// live.
|
||||
pub fn is_available(&self) -> bool {
|
||||
self.available
|
||||
}
|
||||
|
||||
/// Why this counter is, or is not, available. It is
|
||||
/// [`CounterLiveness::Live`] exactly when [`Self::is_available`] is `true`.
|
||||
pub fn liveness(&self) -> CounterLiveness {
|
||||
self.liveness
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a liveness verdict lets the counter read: `Live`, and nothing else.
|
||||
fn admits(liveness: CounterLiveness) -> bool {
|
||||
match liveness {
|
||||
CounterLiveness::Live => true,
|
||||
CounterLiveness::Unavailable | CounterLiveness::Frozen => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The liveness of this host's RAPL package counters, probed once per process
|
||||
/// for each package set and cached: the first call for a set runs the probe on
|
||||
/// the calling thread (see the module docs for its cost), and every later call —
|
||||
/// and every [`RaplCounter::new`] over the same set — reuses it. A host with no
|
||||
/// package zone is [`CounterLiveness::Unavailable`] with no probe. Off Linux it
|
||||
/// is [`CounterLiveness::Unavailable`], with no probe.
|
||||
pub fn rapl_liveness() -> CounterLiveness {
|
||||
package_liveness(&packages_under(Path::new(POWERCAP_ROOT)))
|
||||
}
|
||||
|
||||
/// The liveness of `packages`, every one of them, cached per package set.
|
||||
fn package_liveness(packages: &[Package]) -> CounterLiveness {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if packages.is_empty() {
|
||||
return CounterLiveness::Unavailable;
|
||||
}
|
||||
cached(&package_set(packages), || probe_packages(packages))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = packages;
|
||||
CounterLiveness::Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/// A package set's cache key: its zone directories, in order.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn package_set(packages: &[Package]) -> Vec<PathBuf> {
|
||||
packages.iter().map(|p| p.dir.clone()).collect()
|
||||
}
|
||||
|
||||
/// The per-process, per-package-set cache in front of `probe`. The lock is held
|
||||
/// while the probe runs, so concurrent first callers wait for one probe rather
|
||||
/// than running several.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn cached(set: &[PathBuf], probe: impl FnOnce() -> CounterLiveness) -> CounterLiveness {
|
||||
static CACHE: Mutex<Vec<(Vec<PathBuf>, CounterLiveness)>> = Mutex::new(Vec::new());
|
||||
let mut cache = CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some((_, liveness)) = cache.iter().find(|(s, _)| s.as_slice() == set) {
|
||||
return *liveness;
|
||||
}
|
||||
let liveness = probe();
|
||||
cache.push((set.to_vec(), liveness));
|
||||
liveness
|
||||
}
|
||||
|
||||
/// One real, uncached run of the liveness probe over `packages`: the shared
|
||||
/// probe ([`crate::liveness::probe`]) over [`open_sampler`], burning
|
||||
/// [`PROBE_TICK`] of this thread's CPU time between samples. Every package is a
|
||||
/// channel, and the rule asks every channel to be live. Its one production
|
||||
/// caller is the cache in [`package_liveness`].
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn probe_packages(packages: &[Package]) -> CounterLiveness {
|
||||
crate::liveness::probe(|| open_sampler(packages), PROBE_TICK)
|
||||
}
|
||||
|
||||
/// The probe's opener for `packages`: one channel per package, sampled by
|
||||
/// reading every package's file. `None` — the counter cannot be opened — when
|
||||
/// there is no package, or a package's file cannot be read and parsed now.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn open_sampler(packages: &[Package]) -> Option<(usize, impl FnMut() -> Option<Vec<u64>> + '_)> {
|
||||
read_packages(packages).map(|_| (packages.len(), move || read_packages(packages)))
|
||||
}
|
||||
|
||||
impl EnergyCounter for RaplCounter {
|
||||
|
|
@ -77,7 +345,8 @@ impl EnergyCounter for RaplCounter {
|
|||
}
|
||||
|
||||
fn provenance(&self) -> Provenance {
|
||||
// MSR-backed hardware shunt. Honest to 1 μJ.
|
||||
// MSR-backed vendor energy counters, read only while every one is live.
|
||||
// Honest to 1 μJ.
|
||||
Provenance::HwShunt
|
||||
}
|
||||
|
||||
|
|
@ -91,41 +360,32 @@ impl EnergyCounter for RaplCounter {
|
|||
10_000_000
|
||||
}
|
||||
|
||||
/// The running total of every package's energy. Fails — no figure, never a
|
||||
/// partial one — when the counter is not available, when any package's
|
||||
/// counter cannot be read, and ([`EnergyError::CounterWrap`]) once a
|
||||
/// package's energy between two reads is unknown. The files are read under
|
||||
/// the lock, so two threads' reads are folded in the order they were taken,
|
||||
/// and an older read is never taken for a wrap.
|
||||
fn read(&self) -> Result<EnergyReading, EnergyError> {
|
||||
if !self.available {
|
||||
return Err(EnergyError::NoCounter(EnergyDomain::CpuPkg));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let raw = std::fs::read_to_string(self.path)
|
||||
.map_err(|e| EnergyError::Platform(format!("reading {}: {e}", self.path)))?;
|
||||
let uj: u64 = raw
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|e| EnergyError::Platform(format!("parsing {}: {e}", self.path)))?;
|
||||
|
||||
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::CpuPkg,
|
||||
provenance: Provenance::HwShunt,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
// Branch is dead on non-Linux but keeps the closure happy.
|
||||
let _ = self.path;
|
||||
Err(EnergyError::Platform(
|
||||
"RAPL only available on Linux".to_string(),
|
||||
))
|
||||
}
|
||||
let uj = {
|
||||
let mut running = self.running.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let now = read_packages(&self.packages)
|
||||
.ok_or_else(|| EnergyError::Platform("reading a RAPL package zone's energy_uj failed".to_string()))?;
|
||||
running.advance(&self.packages, &now)?
|
||||
};
|
||||
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::CpuPkg,
|
||||
provenance: Provenance::HwShunt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,6 +406,16 @@ impl EnergyMeter for RaplCounter {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::liveness::{probe_with, LIVENESS_BUDGET};
|
||||
use crate::liveness::tests::EVERY;
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// A package zone's range as the kernel commonly reports it (2^32 units of
|
||||
/// 61.035 μJ), and a smaller one, so that a wrap taken at the wrong zone's
|
||||
/// range shows.
|
||||
const RANGE: u64 = 262_143_328_850;
|
||||
const RANGE_B: u64 = 65_532_610_987;
|
||||
|
||||
#[test]
|
||||
fn rapl_counter_declares_hw_shunt() {
|
||||
|
|
@ -161,8 +431,366 @@ mod tests {
|
|||
let c = RaplCounter::new();
|
||||
if cfg!(not(target_os = "linux")) {
|
||||
assert!(!c.is_available());
|
||||
assert_eq!(c.liveness(), CounterLiveness::Unavailable);
|
||||
assert!(matches!(c.read(), Err(EnergyError::NoCounter(_))));
|
||||
}
|
||||
assert_eq!(c.name(), "rapl");
|
||||
}
|
||||
|
||||
/// Only a live counter is available; a frozen or unreadable one never reads,
|
||||
/// so it never hands the composite a zero or an error to bill.
|
||||
#[test]
|
||||
fn only_a_live_counter_is_available() {
|
||||
for (liveness, available) in [
|
||||
(CounterLiveness::Live, true),
|
||||
(CounterLiveness::Frozen, false),
|
||||
(CounterLiveness::Unavailable, false),
|
||||
] {
|
||||
let c = RaplCounter::given(Vec::new(), liveness);
|
||||
assert_eq!((c.is_available(), c.liveness()), (available, liveness));
|
||||
assert_eq!(EnergyMeter::is_available(&c), available);
|
||||
if !available {
|
||||
assert!(matches!(c.read(), Err(EnergyError::NoCounter(EnergyDomain::CpuPkg))), "{liveness:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fake powercap directory for one test, removed when dropped.
|
||||
struct Powercap(PathBuf);
|
||||
|
||||
impl Powercap {
|
||||
fn new(tag: &str) -> Self {
|
||||
let root = std::env::temp_dir().join(format!("jouleclaw-powercap-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create a fake powercap directory");
|
||||
Self(root)
|
||||
}
|
||||
|
||||
/// Add the zone directory `dir` (`intel-rapl:0`, `intel-rapl:0:1`,
|
||||
/// `intel-rapl-mmio:0`, …) with its `name`, its `energy_uj` and, when
|
||||
/// given, its `max_energy_range_uj`, newline-terminated as the kernel
|
||||
/// writes them.
|
||||
fn zone(&self, dir: &str, name: &str, energy_uj: u64, max: Option<u64>) -> &Self {
|
||||
let d = self.0.join(dir);
|
||||
std::fs::create_dir_all(&d).expect("create a fake zone");
|
||||
std::fs::write(d.join("name"), format!("{name}\n")).expect("write name");
|
||||
if let Some(m) = max {
|
||||
std::fs::write(d.join("max_energy_range_uj"), format!("{m}\n")).expect("write max_energy_range_uj");
|
||||
}
|
||||
self.set(dir, energy_uj);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set zone `dir`'s `energy_uj` atomically (write a sibling, rename
|
||||
/// over), so a concurrent read never sees a half-written file.
|
||||
fn set(&self, dir: &str, energy_uj: u64) {
|
||||
let file = self.0.join(dir).join("energy_uj");
|
||||
let tmp = self.0.join(dir).join("energy_uj.tmp");
|
||||
std::fs::write(&tmp, format!("{energy_uj}\n")).expect("write energy_uj");
|
||||
std::fs::rename(&tmp, &file).expect("rename energy_uj");
|
||||
}
|
||||
|
||||
fn root(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Powercap {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-socket server's powercap directory as the kernel lists it: the
|
||||
/// control type, two packages each with core or dram sub-zones, a platform
|
||||
/// (`psys`) zone, a top-level `dram` zone, and the MMIO interface
|
||||
/// republishing package 0.
|
||||
fn two_socket_server(tag: &str) -> Powercap {
|
||||
let pc = Powercap::new(tag);
|
||||
std::fs::create_dir_all(pc.root().join("intel-rapl")).expect("the control type");
|
||||
pc.zone("intel-rapl:0", "package-0", 1_000_000, Some(RANGE))
|
||||
.zone("intel-rapl:0:0", "core", 400_000, Some(RANGE))
|
||||
.zone("intel-rapl:0:1", "dram", 90_000, Some(RANGE_B))
|
||||
.zone("intel-rapl:1", "package-1", 5_000_000, Some(RANGE_B))
|
||||
.zone("intel-rapl:1:0", "dram", 70_000, Some(RANGE_B))
|
||||
.zone("intel-rapl:2", "psys", 80_000_000, Some(RANGE))
|
||||
.zone("intel-rapl:3", "dram", 3_000_000, Some(RANGE_B))
|
||||
.zone("intel-rapl-mmio:0", "package-0", 1_000_000, Some(RANGE));
|
||||
pc
|
||||
}
|
||||
|
||||
/// The zones `packages` lists, as (index, range).
|
||||
fn listed(packages: &[Package]) -> Vec<(u32, Option<u64>)> {
|
||||
packages.iter().map(|p| (p.index, p.max_energy_range_uj)).collect()
|
||||
}
|
||||
|
||||
/// The live counter over the package zones under `pc`.
|
||||
fn live_counter(pc: &Powercap) -> RaplCounter {
|
||||
RaplCounter::given(packages_under(pc.root()), CounterLiveness::Live)
|
||||
}
|
||||
|
||||
fn uj(c: &RaplCounter) -> u64 {
|
||||
c.read().expect("every package reads").uj
|
||||
}
|
||||
|
||||
/// A package zone is named `package-<n>`; `psys`, `dram`, `core` and
|
||||
/// malformed names are not. A top-level zone is `intel-rapl:<n>`; a
|
||||
/// sub-zone, the control type and the MMIO interface are not.
|
||||
#[test]
|
||||
fn only_a_top_level_zone_named_package_n_is_a_package() {
|
||||
for name in ["package-0", "package-1\n", "package-12"] {
|
||||
assert!(is_package_name(name), "{name:?}");
|
||||
}
|
||||
for name in ["psys", "psys\n", "dram", "core", "uncore", "package-", "package-x", "package-0a", "Package-0", ""] {
|
||||
assert!(!is_package_name(name), "{name:?}");
|
||||
}
|
||||
assert_eq!(top_level_zone("intel-rapl:0"), Some(0));
|
||||
assert_eq!(top_level_zone("intel-rapl:12"), Some(12));
|
||||
for dir in ["intel-rapl", "intel-rapl:", "intel-rapl:0:0", "intel-rapl:1:2", "intel-rapl-mmio:0", "intel-rapl:x", "dtpm"] {
|
||||
assert_eq!(top_level_zone(dir), None, "{dir:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-socket server: the figure is both packages' energy and nothing else
|
||||
/// — not `psys` (which includes the packages, so would count them twice),
|
||||
/// not a `dram` zone at the top level or under a package, not a core
|
||||
/// sub-zone, not the MMIO copy of package 0.
|
||||
#[test]
|
||||
fn a_two_socket_server_sums_its_two_packages_and_nothing_else() {
|
||||
let pc = two_socket_server("two-socket");
|
||||
let packages = packages_under(pc.root());
|
||||
assert_eq!(listed(&packages), vec![(0, Some(RANGE)), (1, Some(RANGE_B))]);
|
||||
assert_eq!(read_packages(&packages), Some(vec![1_000_000, 5_000_000]));
|
||||
let c = live_counter(&pc);
|
||||
let before = uj(&c);
|
||||
assert_eq!(before, 1_000_000 + 5_000_000);
|
||||
for (dir, v) in [
|
||||
("intel-rapl:0", 1_250_000),
|
||||
("intel-rapl:1", 5_400_000),
|
||||
("intel-rapl:0:0", 600_000),
|
||||
("intel-rapl:0:1", 190_000),
|
||||
("intel-rapl:1:0", 170_000),
|
||||
("intel-rapl:2", 99_000_000),
|
||||
("intel-rapl:3", 3_900_000),
|
||||
("intel-rapl-mmio:0", 1_250_000),
|
||||
] {
|
||||
pc.set(dir, v);
|
||||
}
|
||||
assert_eq!(uj(&c) - before, 250_000 + 400_000, "package 0's energy and package 1's, nothing else");
|
||||
}
|
||||
|
||||
/// Zone 0 is the platform zone and the package is zone 1: zone 0 is neither
|
||||
/// summed nor sampled, and the figure is zone 1's.
|
||||
#[test]
|
||||
fn a_zone_0_named_psys_is_left_out() {
|
||||
let pc = Powercap::new("psys-at-0");
|
||||
pc.zone("intel-rapl:0", "psys", 70_000_000, Some(RANGE))
|
||||
.zone("intel-rapl:1", "package-0", 2_000_000, Some(RANGE))
|
||||
.zone("intel-rapl:1:0", "core", 1_000, Some(RANGE));
|
||||
let packages = packages_under(pc.root());
|
||||
assert_eq!(listed(&packages), vec![(1, Some(RANGE))]);
|
||||
let (channels, mut sample) = open_sampler(&packages).expect("the package reads");
|
||||
assert_eq!((channels, sample()), (1, Some(vec![2_000_000])), "the liveness sample holds the package alone");
|
||||
let c = live_counter(&pc);
|
||||
let before = uj(&c);
|
||||
pc.set("intel-rapl:0", 90_000_000);
|
||||
pc.set("intel-rapl:1", 2_300_000);
|
||||
assert_eq!(uj(&c) - before, 300_000);
|
||||
}
|
||||
|
||||
/// Each package wraps at its own range: package 0 rolls over at RANGE while
|
||||
/// package 1 advances, then package 1 rolls over at RANGE_B. The energy is
|
||||
/// each zone's delta modulo its own range, and the total keeps rising across
|
||||
/// both wraps.
|
||||
#[test]
|
||||
fn each_package_wraps_at_its_own_range() {
|
||||
let pc = Powercap::new("wrap");
|
||||
pc.zone("intel-rapl:0", "package-0", RANGE - 1_000, Some(RANGE))
|
||||
.zone("intel-rapl:1", "package-1", RANGE_B - 300, Some(RANGE_B));
|
||||
let c = live_counter(&pc);
|
||||
let r0 = uj(&c);
|
||||
pc.set("intel-rapl:0", 500);
|
||||
pc.set("intel-rapl:1", RANGE_B - 100);
|
||||
let r1 = uj(&c);
|
||||
assert_eq!(r1 - r0, (1_000 + 500) + 200);
|
||||
pc.set("intel-rapl:0", 2_500);
|
||||
pc.set("intel-rapl:1", 400);
|
||||
let r2 = uj(&c);
|
||||
assert_eq!(r2 - r1, 2_000 + (100 + 400));
|
||||
// At the other zone's range, package 1's wrap would be off by the
|
||||
// difference of the two ranges.
|
||||
assert_eq!(zone_delta_uj(RANGE_B - 100, 400, Some(RANGE_B)), Some(500));
|
||||
assert_eq!(zone_delta_uj(RANGE_B - 100, 400, Some(RANGE)), Some(RANGE - RANGE_B + 500));
|
||||
}
|
||||
|
||||
/// A package whose range is unknown (absent, or zero): summed while it rises;
|
||||
/// once it goes down its energy since the last read is unknown, so that read
|
||||
/// is a counter wrap, and so is every later one, since the total would be
|
||||
/// short by that energy.
|
||||
#[test]
|
||||
fn a_fall_with_no_known_range_ends_the_figure() {
|
||||
let pc = Powercap::new("no-range");
|
||||
pc.zone("intel-rapl:0", "package-0", 10_000, None).zone("intel-rapl:1", "package-1", 20_000, Some(0));
|
||||
let c = live_counter(&pc);
|
||||
assert_eq!(listed(&c.packages), vec![(0, None), (1, None)]);
|
||||
let r0 = uj(&c);
|
||||
pc.set("intel-rapl:0", 15_000);
|
||||
pc.set("intel-rapl:1", 21_000);
|
||||
assert_eq!(uj(&c) - r0, 5_000 + 1_000);
|
||||
pc.set("intel-rapl:0", 3_000);
|
||||
assert!(matches!(c.read(), Err(EnergyError::CounterWrap)));
|
||||
pc.set("intel-rapl:0", 4_000);
|
||||
assert!(matches!(c.read(), Err(EnergyError::CounterWrap)), "a total short by the lost energy");
|
||||
assert_eq!(zone_delta_uj(1_000, 10, Some(0)), None, "a zero range is no range");
|
||||
}
|
||||
|
||||
/// A package that cannot be read fails the whole read: no figure that leaves
|
||||
/// a package out.
|
||||
#[test]
|
||||
fn an_unreadable_package_fails_the_read() {
|
||||
let pc = Powercap::new("unreadable");
|
||||
pc.zone("intel-rapl:0", "package-0", 10_000, Some(RANGE)).zone("intel-rapl:1", "package-1", 20_000, Some(RANGE));
|
||||
let c = live_counter(&pc);
|
||||
std::fs::remove_file(pc.root().join("intel-rapl:1/energy_uj")).expect("remove");
|
||||
assert_eq!(read_packages(&c.packages), None);
|
||||
assert!(open_sampler(&c.packages).is_none());
|
||||
assert!(matches!(c.read(), Err(EnergyError::Platform(_))));
|
||||
}
|
||||
|
||||
/// A host whose only zone is the platform (`psys`) has no package: the probe
|
||||
/// opens nothing and burns nothing, the liveness is unavailable, and the
|
||||
/// counter is unavailable and never reads. So too an empty powercap
|
||||
/// directory, and a missing one.
|
||||
#[test]
|
||||
fn a_host_with_only_psys_has_no_rapl_counter() {
|
||||
let pc = Powercap::new("only-psys");
|
||||
pc.zone("intel-rapl:0", "psys", 70_000_000, Some(RANGE));
|
||||
let empty = Powercap::new("empty");
|
||||
for root in [pc.root(), empty.root(), Path::new("/no/such/powercap")] {
|
||||
let packages = packages_under(root);
|
||||
assert!(packages.is_empty(), "{root:?}");
|
||||
let t = std::time::Instant::now();
|
||||
assert_eq!(probe_packages(&packages), CounterLiveness::Unavailable);
|
||||
assert!(t.elapsed() < Duration::from_millis(100), "burned {:?} probing no package", t.elapsed());
|
||||
assert_eq!(package_liveness(&packages), CounterLiveness::Unavailable);
|
||||
let c = RaplCounter::with_root(root);
|
||||
assert!(!c.is_available() && c.liveness() == CounterLiveness::Unavailable, "{root:?}");
|
||||
assert!(matches!(c.read(), Err(EnergyError::NoCounter(EnergyDomain::CpuPkg))));
|
||||
}
|
||||
let forced = RaplCounter::given(Vec::new(), CounterLiveness::Live);
|
||||
assert!(forced.read().is_err(), "a counter over no package read");
|
||||
}
|
||||
|
||||
/// The RAPL probe over scripted package counters (one value per package per
|
||||
/// sample, `None` for an unreadable file) and a clock that advances `EVERY`
|
||||
/// per tick.
|
||||
fn rapl_probe(packages: usize, file: impl Fn(usize) -> Option<Vec<u64>>) -> CounterLiveness {
|
||||
let t = Cell::new(Duration::ZERO);
|
||||
let n = Cell::new(0usize);
|
||||
let read = || {
|
||||
let i = n.get();
|
||||
n.set(i + 1);
|
||||
file(i)
|
||||
};
|
||||
probe_with(
|
||||
|| read().map(|_| (packages, || read())),
|
||||
|| t.set(t.get() + EVERY),
|
||||
|| t.get(),
|
||||
LIVENESS_BUDGET,
|
||||
)
|
||||
}
|
||||
|
||||
/// The cases a host can present: a live MSR, a file that holds its value, a
|
||||
/// counter republished in one lump, a root-only file; and with two packages,
|
||||
/// both live, or one held.
|
||||
#[test]
|
||||
fn the_rapl_probe_admits_only_counters_that_keep_advancing() {
|
||||
// ~45 W package: ~1.4 J per 31 ms interval.
|
||||
assert_eq!(rapl_probe(1, |i| Some(vec![81_000_000 + 1_400_000 * i as u64])), CounterLiveness::Live);
|
||||
assert_eq!(rapl_probe(1, |_| Some(vec![81_000_000])), CounterLiveness::Frozen, "a held value");
|
||||
assert_eq!(
|
||||
rapl_probe(1, |i| Some(vec![if i < 4 { 81_000_000 } else { 95_000_000 }])),
|
||||
CounterLiveness::Frozen,
|
||||
"one lump"
|
||||
);
|
||||
assert_eq!(rapl_probe(1, |_| None), CounterLiveness::Unavailable, "EPERM on every read");
|
||||
let both = |i: usize| Some(vec![81_000_000 + 1_400_000 * i as u64, 9_000_000 + 900_000 * i as u64]);
|
||||
assert_eq!(rapl_probe(2, both), CounterLiveness::Live);
|
||||
let one = |i: usize| Some(vec![81_000_000 + 1_400_000 * i as u64, 9_000_000]);
|
||||
assert_eq!(rapl_probe(2, one), CounterLiveness::Frozen, "package 1 held");
|
||||
}
|
||||
|
||||
/// The cache probes once per package set, whatever the verdict, and keeps
|
||||
/// sets apart: a set with one more package is another set.
|
||||
#[test]
|
||||
fn the_liveness_is_probed_once_per_package_set() {
|
||||
static RUNS: AtomicUsize = AtomicUsize::new(0);
|
||||
let probe = |verdict| {
|
||||
move || {
|
||||
RUNS.fetch_add(1, Ordering::SeqCst);
|
||||
verdict
|
||||
}
|
||||
};
|
||||
let a = vec![PathBuf::from("/test/only/rapl-a/intel-rapl:0")];
|
||||
let b = vec![PathBuf::from("/test/only/rapl-a/intel-rapl:0"), PathBuf::from("/test/only/rapl-a/intel-rapl:1")];
|
||||
assert_eq!(cached(&a, probe(CounterLiveness::Frozen)), CounterLiveness::Frozen);
|
||||
assert_eq!(cached(&a, probe(CounterLiveness::Live)), CounterLiveness::Frozen, "a cached verdict is reused");
|
||||
assert_eq!(cached(&b, probe(CounterLiveness::Live)), CounterLiveness::Live);
|
||||
assert_eq!(cached(&b, probe(CounterLiveness::Frozen)), CounterLiveness::Live);
|
||||
assert_eq!(RUNS.load(Ordering::SeqCst), 2);
|
||||
let pc = two_socket_server("cache-key");
|
||||
let key = package_set(&packages_under(pc.root()));
|
||||
assert_eq!(key, vec![pc.root().join("intel-rapl:0"), pc.root().join("intel-rapl:1")], "the key is the package set");
|
||||
}
|
||||
|
||||
/// The production probe — real file reads, the real CPU burn, the real clock
|
||||
/// and budget — over two packages that hold their values: frozen, after the
|
||||
/// whole budget.
|
||||
#[test]
|
||||
fn the_production_probe_finds_held_packages_frozen() {
|
||||
let pc = two_socket_server("held");
|
||||
let packages = packages_under(pc.root());
|
||||
let t = std::time::Instant::now();
|
||||
assert_eq!(probe_packages(&packages), CounterLiveness::Frozen);
|
||||
assert!(t.elapsed() >= LIVENESS_BUDGET, "a frozen verdict took only {:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// The production probe over a two-socket tree another thread keeps
|
||||
/// advancing, as the kernel advances live MSRs: live, well inside the budget,
|
||||
/// when both packages advance; frozen when package 1 holds while package 0
|
||||
/// and `psys` advance.
|
||||
#[test]
|
||||
fn the_production_probe_needs_every_package_to_keep_advancing() {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
let run = |tag: &str, moving: &'static [&'static str]| {
|
||||
let pc = Arc::new(two_socket_server(tag));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let writer = {
|
||||
let (pc, stop) = (Arc::clone(&pc), Arc::clone(&stop));
|
||||
std::thread::spawn(move || {
|
||||
let mut v = 100_000_000u64;
|
||||
while !stop.load(Ordering::SeqCst) {
|
||||
v += 90_000; // ~45 W at a 2 ms cadence
|
||||
for dir in moving {
|
||||
pc.set(dir, v);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
})
|
||||
};
|
||||
let packages = packages_under(pc.root());
|
||||
let t = std::time::Instant::now();
|
||||
let liveness = probe_packages(&packages);
|
||||
let took = t.elapsed();
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
writer.join().expect("writer thread");
|
||||
(liveness, took)
|
||||
};
|
||||
let (liveness, took) = run("advancing-both", &["intel-rapl:0", "intel-rapl:1"]);
|
||||
assert_eq!(liveness, CounterLiveness::Live);
|
||||
assert!(took >= Duration::from_millis(300) && took < LIVENESS_BUDGET, "{took:?}");
|
||||
let (liveness, _) = run("advancing-one", &["intel-rapl:0", "intel-rapl:2"]);
|
||||
assert_eq!(liveness, CounterLiveness::Frozen, "package 1 held its value");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
//! The IOReport liveness probe runs once per PROCESS.
|
||||
//!
|
||||
//! This file holds this one test, so cargo runs it as a process of its own.
|
||||
//! Nothing else in the process probes, and its threads' first calls are the
|
||||
//! process's first use of the cache. That is the case a per-thread cache, a
|
||||
//! cache that checks and then sets, or a counter constructor or meter
|
||||
//! selection that probes around the cache gets wrong. Run it with
|
||||
//! `--features ioreport` on macOS for the probe to exist; without that backend
|
||||
//! it asserts that nothing probes at all.
|
||||
|
||||
use std::sync::{Arc, Barrier};
|
||||
|
||||
use jouleclaw_energy::meter::detect_meter;
|
||||
use jouleclaw_energy::{ioreport_group_opens, ioreport_probe_runs, ioreport_status, IoReportStatus};
|
||||
|
||||
/// What one IOReport counter built on this thread says: its status, and
|
||||
/// whether it is available. `None` without the IOReport backend.
|
||||
fn counter() -> Option<(IoReportStatus, bool)> {
|
||||
#[cfg(all(feature = "ioreport", target_os = "macos"))]
|
||||
{
|
||||
let counter = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
|
||||
Some((counter.status(), counter.is_available()))
|
||||
}
|
||||
#[cfg(not(all(feature = "ioreport", target_os = "macos")))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `detect_meter` selects the IOReport counter.
|
||||
fn selected() -> bool {
|
||||
detect_meter().active_name() == "apple-ioreport"
|
||||
}
|
||||
|
||||
/// However many threads ask at once, and whatever they ask first — the
|
||||
/// status, a counter, or a meter selection, each gated on it — the real probe
|
||||
/// runs once. It opens the `Energy Model` group once, and every caller gets
|
||||
/// its one status.
|
||||
#[test]
|
||||
fn the_liveness_probe_runs_once_per_process() {
|
||||
assert_eq!(
|
||||
(ioreport_probe_runs(), ioreport_group_opens()),
|
||||
(0, 0),
|
||||
"the probe ran before this test's first use of the cache"
|
||||
);
|
||||
const THREADS: usize = 9;
|
||||
let start = Arc::new(Barrier::new(THREADS));
|
||||
let askers: Vec<_> = (0..THREADS)
|
||||
.map(|i| {
|
||||
let start = Arc::clone(&start);
|
||||
std::thread::spawn(move || {
|
||||
start.wait();
|
||||
// A third of the threads ask for the status first, a third
|
||||
// build a counter first, a third select a meter first.
|
||||
let (status, built, chosen) = match i % 3 {
|
||||
0 => {
|
||||
let status = ioreport_status();
|
||||
(status, counter(), selected())
|
||||
}
|
||||
1 => {
|
||||
let built = counter();
|
||||
let chosen = selected();
|
||||
(ioreport_status(), built, chosen)
|
||||
}
|
||||
_ => {
|
||||
let chosen = selected();
|
||||
let built = counter();
|
||||
(ioreport_status(), built, chosen)
|
||||
}
|
||||
};
|
||||
(status, built, chosen, ioreport_status())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let seen: Vec<_> = askers.into_iter().map(|t| t.join().expect("a thread that asked")).collect();
|
||||
let status = ioreport_status();
|
||||
let (runs, opens) = (ioreport_probe_runs(), ioreport_group_opens());
|
||||
eprintln!(
|
||||
"ioreport once per process: {THREADS} threads, status={status:?}, real probe runs={runs}, Energy Model group \
|
||||
opens={opens}"
|
||||
);
|
||||
for (first, built, chosen, again) in &seen {
|
||||
assert_eq!((*first, *again), (status, status), "threads saw different statuses: {seen:?}");
|
||||
if let Some((counter_status, available)) = built {
|
||||
assert_eq!(*available, status == IoReportStatus::Live, "a counter's availability disagrees: {seen:?}");
|
||||
if status != IoReportStatus::Live {
|
||||
assert_eq!(*counter_status, status, "a declined counter names another status: {seen:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(*chosen, status == IoReportStatus::Live, "meter selection disagrees with the status: {seen:?}");
|
||||
}
|
||||
#[cfg(all(feature = "ioreport", target_os = "macos"))]
|
||||
assert_eq!(
|
||||
(runs, opens),
|
||||
(1, 1),
|
||||
"the real probe ran {runs} time(s) and asked for the Energy Model group {opens} time(s) in this process \
|
||||
({THREADS} threads asked at once): {seen:?}"
|
||||
);
|
||||
#[cfg(not(all(feature = "ioreport", target_os = "macos")))]
|
||||
{
|
||||
assert_eq!((runs, opens), (0, 0), "no IOReport probe runs without the IOReport backend");
|
||||
assert_eq!(status, IoReportStatus::Unavailable);
|
||||
assert!(seen.iter().all(|(_, built, _, _)| built.is_none()));
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ pub fn detect_local() -> Vec<FabricMember> {
|
|||
// ~130 mJ per GFLOP order of magnitude.
|
||||
cpu_energy.insert(
|
||||
"matmul".to_string(),
|
||||
EnergyCost { uj_per_gflop: 130_000.0, provenance: EnergyProvenance::Benchmarked },
|
||||
EnergyCost { uj_per_gflop: 130_000.0, provenance: EnergyProvenance::Benchmarked, calibration: None },
|
||||
);
|
||||
members.push(FabricMember {
|
||||
id: "local/cpu-neon".into(),
|
||||
|
|
@ -77,7 +77,7 @@ fn probe_amx() -> Option<FabricMember> {
|
|||
// per GFLOP order of magnitude in its favoured regime.
|
||||
energy.insert(
|
||||
"matmul".to_string(),
|
||||
EnergyCost { uj_per_gflop: 7_000.0, provenance: EnergyProvenance::Benchmarked },
|
||||
EnergyCost { uj_per_gflop: 7_000.0, provenance: EnergyProvenance::Benchmarked, calibration: None },
|
||||
);
|
||||
Some(FabricMember {
|
||||
id: "local/amx".into(),
|
||||
|
|
@ -105,7 +105,7 @@ fn probe_metal_gpu() -> Option<FabricMember> {
|
|||
// 2 mJ per GFLOP.
|
||||
energy.insert(
|
||||
"matmul".to_string(),
|
||||
EnergyCost { uj_per_gflop: 2_000.0, provenance: EnergyProvenance::Estimated },
|
||||
EnergyCost { uj_per_gflop: 2_000.0, provenance: EnergyProvenance::Estimated, calibration: None },
|
||||
);
|
||||
Some(FabricMember {
|
||||
id: "local/metal-gpu".into(),
|
||||
|
|
@ -137,7 +137,7 @@ fn probe_metal_mpp() -> Option<FabricMember> {
|
|||
let mut energy = BTreeMap::new();
|
||||
energy.insert(
|
||||
"matmul".to_string(),
|
||||
EnergyCost { uj_per_gflop: 6_000.0, provenance: EnergyProvenance::Estimated },
|
||||
EnergyCost { uj_per_gflop: 6_000.0, provenance: EnergyProvenance::Estimated, calibration: None },
|
||||
);
|
||||
Some(FabricMember {
|
||||
id: "local/metal-mpp".into(),
|
||||
|
|
|
|||
|
|
@ -30,10 +30,13 @@
|
|||
//! but nothing was contacted; `Declared` is configuration
|
||||
//! the runtime was told about but cannot verify.
|
||||
//! - Energy figures carry provenance: `Measured` (a meter
|
||||
//! read it), `Benchmarked` (a recorded run on this member
|
||||
//! class), `Estimated` (a coarse model). Placement may use
|
||||
//! any of them; receipts must not upgrade one into
|
||||
//! another.
|
||||
//! read it, live for the domain the figure claims),
|
||||
//! `Benchmarked` (a recorded run on this member class),
|
||||
//! `Estimated` (a coarse model). Placement may use any of
|
||||
//! them; receipts must not upgrade one into another, and
|
||||
//! neither does loading one: a calibration row is loaded at
|
||||
//! the grade its writer declared, with its method, and a row
|
||||
//! that declares none is never `Measured`.
|
||||
//! - Numerics carry a parity tier against the CPU reference
|
||||
//! oracle: `BitExact`, `ToleranceCertified { max_ulp }`,
|
||||
//! or `Uncertified`. A member that has not been certified
|
||||
|
|
@ -120,7 +123,9 @@ pub enum ParityTier {
|
|||
/// reading never upgrades its own provenance).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum EnergyProvenance {
|
||||
/// A hardware meter read it (IOReport / RAPL / NVML).
|
||||
/// A hardware meter read it (IOReport / RAPL / NVML) while
|
||||
/// the meter was live for the domain the figure claims
|
||||
/// (JouleClaw SPEC §1.3): a hardware-graded reading.
|
||||
Measured,
|
||||
/// Recorded benchmark on this member class.
|
||||
Benchmarked,
|
||||
|
|
@ -130,12 +135,17 @@ pub enum EnergyProvenance {
|
|||
|
||||
/// Energy cost of one unit of work on a member, with
|
||||
/// provenance.
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct EnergyCost {
|
||||
/// Microjoules per gigaflop of the op class (order of
|
||||
/// magnitude is what placement needs).
|
||||
pub uj_per_gflop: f64,
|
||||
pub provenance: EnergyProvenance,
|
||||
/// The calibration row this figure was loaded from — its
|
||||
/// method, its writer's declared grade, and when and on
|
||||
/// what it was taken — or `None` for a seeded figure.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub calibration: Option<CalibrationEntry>,
|
||||
}
|
||||
|
||||
/// One member of the compute fabric.
|
||||
|
|
@ -156,16 +166,64 @@ pub struct FabricMember {
|
|||
pub detail: String,
|
||||
}
|
||||
|
||||
/// One measured calibration row (see
|
||||
/// `Fabric::apply_calibration_file`).
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
/// One calibration row (see `Fabric::apply_calibration_file`).
|
||||
///
|
||||
/// The optional fields default when absent, so a file written
|
||||
/// before they existed still loads.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CalibrationEntry {
|
||||
pub member_id: String,
|
||||
/// Op class key, e.g. `"matmul"`.
|
||||
pub op: String,
|
||||
/// Must be positive and finite, or the row is refused:
|
||||
/// nothing runs on zero or negative energy.
|
||||
pub uj_per_gflop: f64,
|
||||
/// Free-form method note (idle-subtraction caveat etc.).
|
||||
pub method: String,
|
||||
/// The grade of the figure, as its writer declared it from
|
||||
/// the meter that read it: `Measured` only for a hardware
|
||||
/// reading taken while that meter was live for the domain
|
||||
/// it claims. A row without it is loaded as
|
||||
/// [`UNDECLARED_ROW_PROVENANCE`], never as `Measured`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provenance: Option<EnergyProvenance>,
|
||||
/// When the row was taken (RFC 3339, UTC).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub date: Option<String>,
|
||||
/// The OS and version it was taken on, e.g. `"macOS 27.0.1 (26A123)"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub os: Option<String>,
|
||||
/// The chip it was taken on, e.g. `"Apple M5 Max"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub chip: Option<String>,
|
||||
}
|
||||
|
||||
/// The grade a calibration row loads at when it declares none.
|
||||
///
|
||||
/// Such a row was written before rows carried a grade: a
|
||||
/// recorded run on this member class, by a meter whose grade
|
||||
/// nobody wrote down, and before the IOReport counter was
|
||||
/// required to show its CPU rail live (on a frozen rail its
|
||||
/// figure was GPU-only energy, or zero). It is this machine's
|
||||
/// recorded run, so it still replaces a seeded figure, but at
|
||||
/// `Benchmarked`: loading it as `Measured` would upgrade a
|
||||
/// figure whose reading nobody can vouch for.
|
||||
pub const UNDECLARED_ROW_PROVENANCE: EnergyProvenance = EnergyProvenance::Benchmarked;
|
||||
|
||||
impl CalibrationEntry {
|
||||
/// The grade this row loads at: the one its writer
|
||||
/// declared, else [`UNDECLARED_ROW_PROVENANCE`].
|
||||
pub fn loaded_provenance(&self) -> EnergyProvenance {
|
||||
self.provenance.unwrap_or(UNDECLARED_ROW_PROVENANCE)
|
||||
}
|
||||
}
|
||||
|
||||
/// A calibration row the fabric refused to load, and why.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RejectedCalibration {
|
||||
pub member_id: String,
|
||||
pub op: String,
|
||||
pub why: String,
|
||||
}
|
||||
|
||||
/// The fabric as seen from this process: detected local
|
||||
|
|
@ -177,8 +235,8 @@ pub struct Fabric {
|
|||
|
||||
impl Fabric {
|
||||
/// Full discovery: local probes + ambient-credential
|
||||
/// reachability + this machine's measured calibration
|
||||
/// (when present).
|
||||
/// reachability + this machine's calibration file (when
|
||||
/// present), each row at the grade it declares.
|
||||
pub fn discover() -> Self {
|
||||
let mut members = detect_local();
|
||||
members.extend(reach_remote());
|
||||
|
|
@ -187,30 +245,74 @@ impl Fabric {
|
|||
fabric
|
||||
}
|
||||
|
||||
/// Overlay measured µJ figures from the calibration file
|
||||
/// written by `fabric_calibrate` (loader-gguf). Path from
|
||||
/// `JOULECLAW_FABRIC_CALIBRATION`, defaulting to
|
||||
/// `~/.jouleclaw/fabric-calibration.json`. Measured
|
||||
/// figures replace seeded Benchmarked/Estimated entries —
|
||||
/// placement should run on this machine's numbers, not a
|
||||
/// table written for another one.
|
||||
pub fn apply_calibration_file(&mut self) {
|
||||
/// Overlay this machine's µJ figures from the calibration
|
||||
/// file written by `fabric_calibrate` (loader-gguf). Path
|
||||
/// from `JOULECLAW_FABRIC_CALIBRATION`, defaulting to
|
||||
/// `~/.jouleclaw/fabric-calibration.json`. Calibrated
|
||||
/// figures replace seeded entries — placement should run on
|
||||
/// this machine's numbers, not a table written for another
|
||||
/// one — at the grade each row declares (see
|
||||
/// [`Self::apply_calibration`]). Returns the rows refused.
|
||||
pub fn apply_calibration_file(&mut self) -> Vec<RejectedCalibration> {
|
||||
let path = std::env::var("JOULECLAW_FABRIC_CALIBRATION").unwrap_or_else(|_| {
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
format!("{home}/.jouleclaw/fabric-calibration.json")
|
||||
});
|
||||
let Ok(bytes) = std::fs::read(&path) else { return };
|
||||
self.apply_calibration_path(std::path::Path::new(&path))
|
||||
}
|
||||
|
||||
/// [`Self::apply_calibration`] over the rows in the JSON
|
||||
/// file at `path`. A missing or unparseable file overlays
|
||||
/// nothing.
|
||||
pub fn apply_calibration_path(&mut self, path: &std::path::Path) -> Vec<RejectedCalibration> {
|
||||
let Ok(bytes) = std::fs::read(path) else { return Vec::new() };
|
||||
let Ok(entries) = serde_json::from_slice::<Vec<CalibrationEntry>>(&bytes) else {
|
||||
return;
|
||||
return Vec::new();
|
||||
};
|
||||
self.apply_calibration(entries)
|
||||
}
|
||||
|
||||
/// Overlay calibration rows onto the members they name.
|
||||
///
|
||||
/// Each row loads at [`CalibrationEntry::loaded_provenance`]
|
||||
/// — the grade its writer declared, never upgraded; a row
|
||||
/// that declares none is not `Measured` — and the row itself
|
||||
/// (its method, date, OS, chip) rides along on the figure as
|
||||
/// [`EnergyCost::calibration`]. A row whose `uj_per_gflop`
|
||||
/// is not positive and finite is refused and returned with
|
||||
/// why, and the member keeps the figure it had: a zero would
|
||||
/// make the member free to place on, and no work is. Rows
|
||||
/// for members this host does not have are skipped.
|
||||
pub fn apply_calibration(
|
||||
&mut self,
|
||||
entries: impl IntoIterator<Item = CalibrationEntry>,
|
||||
) -> Vec<RejectedCalibration> {
|
||||
let mut rejected = Vec::new();
|
||||
for e in entries {
|
||||
if !(e.uj_per_gflop.is_finite() && e.uj_per_gflop > 0.0) {
|
||||
rejected.push(RejectedCalibration {
|
||||
why: format!(
|
||||
"uj_per_gflop {} is not a positive, finite figure: no work runs on zero or negative energy",
|
||||
e.uj_per_gflop
|
||||
),
|
||||
member_id: e.member_id,
|
||||
op: e.op,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if let Some(m) = self.members.iter_mut().find(|m| m.id == e.member_id) {
|
||||
let op = e.op.clone();
|
||||
m.energy.insert(
|
||||
e.op,
|
||||
EnergyCost { uj_per_gflop: e.uj_per_gflop, provenance: EnergyProvenance::Measured },
|
||||
op,
|
||||
EnergyCost {
|
||||
uj_per_gflop: e.uj_per_gflop,
|
||||
provenance: e.loaded_provenance(),
|
||||
calibration: Some(e),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
rejected
|
||||
}
|
||||
|
||||
/// Members that may actually execute work right now:
|
||||
|
|
@ -249,27 +351,114 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// A fabric with the local members and no calibration file
|
||||
/// read: these tests never touch the real
|
||||
/// `~/.jouleclaw/fabric-calibration.json`, nor the env var
|
||||
/// other tests' `discover()` reads.
|
||||
fn local() -> Fabric {
|
||||
Fabric { members: detect_local() }
|
||||
}
|
||||
|
||||
fn row(op: &str, uj_per_gflop: f64, provenance: Option<EnergyProvenance>) -> CalibrationEntry {
|
||||
CalibrationEntry {
|
||||
member_id: "local/cpu-neon".into(),
|
||||
op: op.into(),
|
||||
uj_per_gflop,
|
||||
method: format!("{op}: test method"),
|
||||
provenance,
|
||||
date: Some("2026-09-30T12:00:00Z".into()),
|
||||
os: Some("macOS 27.0.1".into()),
|
||||
chip: Some("Apple M5 Max".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A row loads at the grade its writer declared — never
|
||||
/// upgraded, never downgraded — and the row itself, method
|
||||
/// and all, rides along on the figure.
|
||||
#[test]
|
||||
fn calibration_overlays_measured_figures() {
|
||||
let dir = std::env::temp_dir().join(format!("fab-cal-{}", std::process::id()));
|
||||
fn a_calibration_row_loads_at_its_declared_grade_with_its_method() {
|
||||
let mut fabric = local();
|
||||
let rows = [
|
||||
row("matmul", 123_456.0, Some(EnergyProvenance::Measured)),
|
||||
row("bench-op", 2_000.0, Some(EnergyProvenance::Benchmarked)),
|
||||
row("est-op", 3_000.0, Some(EnergyProvenance::Estimated)),
|
||||
];
|
||||
assert_eq!(fabric.apply_calibration(rows.clone()), vec![]);
|
||||
let cpu = fabric.get("local/cpu-neon").unwrap();
|
||||
for r in &rows {
|
||||
let cost = &cpu.energy[&r.op];
|
||||
assert_eq!(cost.uj_per_gflop, r.uj_per_gflop);
|
||||
assert_eq!(Some(cost.provenance), r.provenance, "{} loaded at another grade", r.op);
|
||||
assert_eq!(cost.calibration.as_ref(), Some(r), "{}: the row did not ride along", r.op);
|
||||
assert_eq!(cost.calibration.as_ref().unwrap().method, format!("{}: test method", r.op));
|
||||
}
|
||||
}
|
||||
|
||||
/// A row that declares no grade — every row written before
|
||||
/// rows carried one, in exactly that file format — still
|
||||
/// loads, and replaces the seeded figure, but is never
|
||||
/// `Measured`: nothing in it says a live meter read it.
|
||||
#[test]
|
||||
fn a_row_that_declares_no_grade_loads_but_never_as_measured() {
|
||||
let legacy = br#"[{"member_id":"local/cpu-neon","op":"matmul","uj_per_gflop":98765.0,
|
||||
"method":"IOReport SoC dE - idle(4.10 W)*dt over 12 ops of prefill-batch (m=16)"}]"#;
|
||||
let dir = std::env::temp_dir().join(format!("fab-cal-legacy-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("cal.json");
|
||||
let entries = vec![CalibrationEntry {
|
||||
member_id: "local/cpu-neon".into(),
|
||||
op: "matmul".into(),
|
||||
uj_per_gflop: 123_456.0,
|
||||
method: "test".into(),
|
||||
}];
|
||||
std::fs::write(&path, serde_json::to_vec(&entries).unwrap()).unwrap();
|
||||
// SAFETY: single-threaded test scope.
|
||||
unsafe { std::env::set_var("JOULECLAW_FABRIC_CALIBRATION", &path) };
|
||||
let fabric = Fabric::discover();
|
||||
unsafe { std::env::remove_var("JOULECLAW_FABRIC_CALIBRATION") };
|
||||
let cpu = fabric.get("local/cpu-neon").unwrap();
|
||||
let cost = cpu.energy.get("matmul").unwrap();
|
||||
assert_eq!(cost.uj_per_gflop, 123_456.0);
|
||||
assert_eq!(cost.provenance, EnergyProvenance::Measured);
|
||||
std::fs::write(&path, legacy).unwrap();
|
||||
let mut fabric = local();
|
||||
let rejected = fabric.apply_calibration_path(&path);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(rejected, vec![]);
|
||||
let cost = &fabric.get("local/cpu-neon").unwrap().energy["matmul"];
|
||||
assert_eq!(cost.uj_per_gflop, 98_765.0, "the legacy row did not load");
|
||||
assert_ne!(cost.provenance, EnergyProvenance::Measured, "a row with no declared grade was loaded as Measured");
|
||||
assert_eq!(cost.provenance, UNDECLARED_ROW_PROVENANCE);
|
||||
let carried = cost.calibration.as_ref().expect("the row rides along");
|
||||
assert!(carried.method.starts_with("IOReport SoC"), "{carried:?}");
|
||||
assert_eq!((&carried.provenance, &carried.date, &carried.os, &carried.chip), (&None, &None, &None, &None));
|
||||
}
|
||||
|
||||
/// A zero, negative, or non-finite figure is refused, says
|
||||
/// why, and leaves the member's figure as it was — a zero
|
||||
/// would make the member free to place on.
|
||||
#[test]
|
||||
fn a_non_positive_or_non_finite_figure_is_refused() {
|
||||
for bad in [0.0, -0.0, -5.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let mut fabric = local();
|
||||
let seeded = fabric.get("local/cpu-neon").unwrap().energy["matmul"].clone();
|
||||
let rejected = fabric.apply_calibration([row("matmul", bad, Some(EnergyProvenance::Measured))]);
|
||||
assert_eq!(rejected.len(), 1, "uj_per_gflop {bad} was accepted");
|
||||
assert_eq!((rejected[0].member_id.as_str(), rejected[0].op.as_str()), ("local/cpu-neon", "matmul"));
|
||||
assert!(rejected[0].why.contains("positive, finite"), "{}", rejected[0].why);
|
||||
let kept = &fabric.get("local/cpu-neon").unwrap().energy["matmul"];
|
||||
assert_eq!(
|
||||
(kept.uj_per_gflop, kept.provenance, kept.calibration.is_none()),
|
||||
(seeded.uj_per_gflop, seeded.provenance, true),
|
||||
"a refused row of {bad} changed the member's figure"
|
||||
);
|
||||
}
|
||||
// Zero from a file is refused the same way.
|
||||
let mut fabric = local();
|
||||
let dir = std::env::temp_dir().join(format!("fab-cal-zero-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("cal.json");
|
||||
std::fs::write(&path, serde_json::to_vec(&[row("matmul", 0.0, None)]).unwrap()).unwrap();
|
||||
let rejected = fabric.apply_calibration_path(&path);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(rejected.len(), 1);
|
||||
}
|
||||
|
||||
/// The new fields round-trip, and a row written with them
|
||||
/// omits none of them.
|
||||
#[test]
|
||||
fn a_calibration_row_round_trips() {
|
||||
let r = row("matmul", 1.5, Some(EnergyProvenance::Measured));
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
for key in ["\"provenance\":\"Measured\"", "\"date\"", "\"os\"", "\"chip\"", "\"method\""] {
|
||||
assert!(json.contains(key), "{key} missing from {json}");
|
||||
}
|
||||
assert_eq!(serde_json::from_str::<CalibrationEntry>(&json).unwrap(), r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ thiserror = { workspace = true }
|
|||
# only want the economics types don't pull in the inference substrate.
|
||||
[dev-dependencies]
|
||||
jouleclaw-loader-gguf = { path = "../jouleclaw-loader-gguf", features = ["rayon"] }
|
||||
# `ioreport` selects the Apple-Silicon load-derived energy estimator on macOS
|
||||
# (honest Provenance::Estimator — the hardware-shunt IOReport meter lives in a
|
||||
# separate repo's sandbox-meter and is intentionally NOT cross-dep'd here).
|
||||
# `ioreport` gives `detect_meter` the Apple-Silicon IOReport energy counter
|
||||
# (Provenance::HwShunt), used only where its CPU energy rail advances under
|
||||
# load, else the load-derived estimator (honest Provenance::Estimator).
|
||||
jouleclaw-energy = { path = "../jouleclaw-energy", features = ["ioreport"] }
|
||||
|
|
|
|||
|
|
@ -1,21 +1,22 @@
|
|||
//! Real local-model dispatch, real (Estimator-grade) measured joules, in the
|
||||
//! econ report — and the cheapest-sufficient energy collapse, measured.
|
||||
//! Real local-model dispatch, metered joules at the meter's honest provenance, in
|
||||
//! the econ report — and the cheapest-sufficient energy collapse.
|
||||
//!
|
||||
//! Wires the pure-Rust `jouleclaw-loader-gguf` inference substrate as a cascade
|
||||
//! [`Tier`]: it loads a real GGUF/safetensors model, greedily decodes an answer,
|
||||
//! and meters the energy of that decode window via `jouleclaw-energy`'s
|
||||
//! auto-detected meter. A recurring task is run several rounds with
|
||||
//! skill-compilation on: the first few rounds pay the **real model** (real
|
||||
//! decode time, real load-derived joules), then the class compiles into a
|
||||
//! decode time, real metered joules), then the class compiles into a
|
||||
//! deterministic skill and the rest resolve at ~0 — the cheapest-sufficient
|
||||
//! thesis in measured energy.
|
||||
//! thesis in metered energy.
|
||||
//!
|
||||
//! HONEST ENERGY: on Apple Silicon the auto-detected meter is a load-average ×
|
||||
//! power-band ESTIMATOR (`Provenance::Estimator`), not a hardware shunt (the
|
||||
//! shunt-grade IOReport meter lives in a separate repo and is deliberately not
|
||||
//! cross-dep'd into this public crate). Decode wall-time and token counts are
|
||||
//! exact measurements; the joules are an honest load-derived estimate over that
|
||||
//! window. A LOCAL model is priced energy-only (no per-call API dollars).
|
||||
//! HONEST ENERGY: the auto-detected meter is `jouleclaw-energy`'s. On Apple Silicon
|
||||
//! that is the IOReport energy counter (`Provenance::HwShunt`, a live vendor counter)
|
||||
//! only where its CPU energy rail advances under load; elsewhere — macOS 27.0.1 on an M5 Max
|
||||
//! and an M3 Ultra — it is a load-average × power-band ESTIMATOR
|
||||
//! (`Provenance::Estimator`). Each dispatch prints which meter ran and whether its
|
||||
//! joules were measured or estimated. Decode wall-time and token counts are exact
|
||||
//! measurements. A LOCAL model is priced energy-only (no per-call API dollars).
|
||||
//!
|
||||
//! Run (defaults to a Nemotron-3-Nano-4B snapshot if present):
|
||||
//! ```text
|
||||
|
|
@ -34,7 +35,8 @@ use jouleclaw_cascade::{
|
|||
VerificationStatus,
|
||||
};
|
||||
use jouleclaw_energy::meter::detect_meter;
|
||||
use jouleclaw_harness_econ::{EconHarness, PriceModel, Task};
|
||||
use jouleclaw_energy::Provenance;
|
||||
use jouleclaw_harness_econ::{joules_obtained, window_joules, EconHarness, PriceModel, Task};
|
||||
use jouleclaw_loader_gguf::bpe_tokenizer::BpeTokenizer;
|
||||
use jouleclaw_loader_gguf::nemotron_h::{NemotronHConfig, NemotronHModel};
|
||||
use jouleclaw_loader_gguf::safetensors_reader::read_safetensors_file;
|
||||
|
|
@ -42,12 +44,15 @@ use jouleclaw_skill::{ConstantSkillInducer, SkillTier};
|
|||
|
||||
const DEFAULT_SNAPSHOT: &str = "/Volumes/macos_4TB_external_1/hf_hub/models--nvidia--NVIDIA-Nemotron-3-Nano-4B-BF16/snapshots/dfaf35de3e30f1867dd8dbc38a7fc9fb52d3914f";
|
||||
|
||||
/// One real model dispatch: wall-time, tokens, and metered joules.
|
||||
/// One real model dispatch: wall-time, tokens, and metered joules — with the meter
|
||||
/// that metered them and the provenance of its readings.
|
||||
#[derive(Clone, Copy)]
|
||||
struct DispatchStat {
|
||||
secs: f64,
|
||||
tokens: usize,
|
||||
joules: f64,
|
||||
meter: &'static str,
|
||||
provenance: Provenance,
|
||||
}
|
||||
|
||||
/// A cascade tier backed by a real local model. Greedy-decodes an answer and
|
||||
|
|
@ -89,9 +94,12 @@ impl Tier for LocalModelTier {
|
|||
std::iter::once(self.bos).chain(self.tok.encode(&prompt)).collect();
|
||||
|
||||
// Fresh meter per dispatch: its first read sets the baseline, the second
|
||||
// yields the energy integrated over the decode window.
|
||||
// yields the energy integrated over the decode window. A window the meter
|
||||
// put no figure on stops the run (`window_joules`): a failed read is not a
|
||||
// zero-energy reading, and a figure the meter did not produce is never
|
||||
// reported.
|
||||
let meter = detect_meter();
|
||||
let e0 = meter.read().map(|r| r.uj).unwrap_or(0);
|
||||
let e0 = meter.read();
|
||||
let t = Instant::now();
|
||||
|
||||
let mut state = self.model.new_state();
|
||||
|
|
@ -114,15 +122,18 @@ impl Tier for LocalModelTier {
|
|||
}
|
||||
|
||||
let secs = t.elapsed().as_secs_f64();
|
||||
let e1 = meter.read().map(|r| r.uj).unwrap_or(e0);
|
||||
let joules = e1.saturating_sub(e0) as f64 / 1e6;
|
||||
let (joules, provenance) = window_joules(e0, meter.read())
|
||||
.unwrap_or_else(|why| panic!("no energy figure for this dispatch from `{}`: {why}", meter.active_name()));
|
||||
let text = self.tok.decode(&generated);
|
||||
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(DispatchStat { secs, tokens: generated.len(), joules });
|
||||
self.log.lock().unwrap().push(DispatchStat {
|
||||
secs,
|
||||
tokens: generated.len(),
|
||||
joules,
|
||||
meter: meter.active_name(),
|
||||
provenance,
|
||||
});
|
||||
|
||||
Ok(Answer {
|
||||
output: AnswerOutput::Text(text),
|
||||
|
|
@ -208,18 +219,27 @@ fn main() {
|
|||
println!("\nreal model dispatches: {} (rest served by the compiled skill)", stats.len());
|
||||
for (i, s) in stats.iter().enumerate() {
|
||||
println!(
|
||||
" dispatch {}: {} tok in {:.2}s ({:.1} tok/s), {:.3} J measured",
|
||||
" dispatch {}: {} tok in {:.2}s ({:.1} tok/s), {:.3} J {} by {} ({})",
|
||||
i + 1,
|
||||
s.tokens,
|
||||
s.secs,
|
||||
if s.secs > 0.0 { s.tokens as f64 / s.secs } else { 0.0 },
|
||||
s.joules
|
||||
s.joules,
|
||||
joules_obtained(s.provenance),
|
||||
s.meter,
|
||||
s.provenance
|
||||
);
|
||||
}
|
||||
let model_j: f64 = stats.iter().map(|s| s.joules).sum();
|
||||
let model_n = stats.len().max(1);
|
||||
// The dispatches' own provenance, not an assumed one.
|
||||
let provenance = match stats.first().map(|s| s.provenance) {
|
||||
Some(p) if stats.iter().all(|s| s.provenance == p) => format!("{p:?}"),
|
||||
Some(_) => "mixed provenance".to_string(),
|
||||
None => "no dispatch".to_string(),
|
||||
};
|
||||
println!(
|
||||
"\navg real-model dispatch : {:.3} J (Estimator, energy-only $)",
|
||||
"\navg real-model dispatch : {:.3} J ({provenance}, energy-only $)",
|
||||
model_j / model_n as f64
|
||||
);
|
||||
println!(
|
||||
|
|
@ -235,7 +255,9 @@ fn main() {
|
|||
);
|
||||
println!("dollars per verified fix : ${:.8}", report.usd_per_verified_fix);
|
||||
println!(
|
||||
"\nThe energy axis is real inference measured on this machine; the curve bends\n\
|
||||
because the recurring class compiled into a skill and stopped paying the model."
|
||||
"\nThe energy axis is real inference on this machine, at each dispatch's meter and\n\
|
||||
provenance above; the curve bends because the recurring class compiled into a\n\
|
||||
skill and stopped paying the model."
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
//! The genuine number, end to end: a real instruction-tuned **code model**
|
||||
//! (Granite-4.0-h, pure-Rust loader-gguf) generates a fix, which is applied as
|
||||
//! the whole corrected file and judged by a **real `python3` test run**
|
||||
//! ([`WholeFileValidator`]), with the decode energy measured. Output is the econ
|
||||
//! ([`WholeFileValidator`]), with the decode energy metered. Output is the econ
|
||||
//! report's joules-and-$ per VERIFIED fix on an operationally-verified fix.
|
||||
//!
|
||||
//! The local model emits the corrected file (the protocol that works for local
|
||||
//! models — they fumble unified-diff syntax), wrapped in Granite's chat
|
||||
//! template. Verification is operational: the patch is written and `python3`
|
||||
//! actually imports + asserts. Energy is the honest `Provenance::Estimator`
|
||||
//! load-derived figure (decode time + tokens are exact); a local model is
|
||||
//! priced energy-only.
|
||||
//! actually imports + asserts. Energy is `jouleclaw-energy`'s auto-detected
|
||||
//! meter at its own provenance: the IOReport energy counter (`HwShunt`) only
|
||||
//! where its CPU energy rail advances under load, else the load-derived
|
||||
//! estimator (`Estimator`), as on macOS 27.0.1 on an M5 Max and an M3 Ultra; the
|
||||
//! report says which (decode time + tokens are exact). A local model is priced
|
||||
//! energy-only.
|
||||
//!
|
||||
//! Run (defaults to the granite-4.0-h-micro snapshot if present):
|
||||
//! ```text
|
||||
|
|
@ -29,9 +32,10 @@ use jouleclaw_cascade::{
|
|||
VerificationStatus,
|
||||
};
|
||||
use jouleclaw_energy::meter::detect_meter;
|
||||
use jouleclaw_energy::Provenance;
|
||||
use jouleclaw_harness_econ::swebench::exec::{ShellTestRunner, WholeFileValidator};
|
||||
use jouleclaw_harness_econ::swebench::{PatchValidator, SweInstance};
|
||||
use jouleclaw_harness_econ::{EconHarness, PriceModel, Task};
|
||||
use jouleclaw_harness_econ::{joules_obtained, window_joules, EconHarness, PriceModel, Task};
|
||||
use jouleclaw_loader_gguf::bpe_tokenizer::BpeTokenizer;
|
||||
use jouleclaw_loader_gguf::granite_h::{GraniteHConfig, GraniteHModel};
|
||||
use jouleclaw_loader_gguf::safetensors_reader::read_safetensors_file;
|
||||
|
|
@ -43,6 +47,9 @@ struct DispatchStat {
|
|||
secs: f64,
|
||||
tokens: usize,
|
||||
joules: f64,
|
||||
/// The meter that metered the decode, and the provenance of its readings.
|
||||
meter: &'static str,
|
||||
provenance: Provenance,
|
||||
text: String,
|
||||
}
|
||||
|
||||
|
|
@ -89,8 +96,10 @@ impl Tier for LocalCoderTier {
|
|||
};
|
||||
let ids = self.chat_prompt(&user);
|
||||
|
||||
// A window the meter put no figure on stops the run (`window_joules`): a
|
||||
// failed read is not a zero-energy reading.
|
||||
let meter = detect_meter();
|
||||
let e0 = meter.read().map(|r| r.uj).unwrap_or(0);
|
||||
let e0 = meter.read();
|
||||
let t = Instant::now();
|
||||
|
||||
let mut state = self.model.new_state();
|
||||
|
|
@ -117,8 +126,8 @@ impl Tier for LocalCoderTier {
|
|||
}
|
||||
|
||||
let secs = t.elapsed().as_secs_f64();
|
||||
let e1 = meter.read().map(|r| r.uj).unwrap_or(e0);
|
||||
let joules = e1.saturating_sub(e0) as f64 / 1e6;
|
||||
let (joules, provenance) = window_joules(e0, meter.read())
|
||||
.unwrap_or_else(|why| panic!("no energy figure for this dispatch from `{}`: {why}", meter.active_name()));
|
||||
let text = self.tok.decode(&generated);
|
||||
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
|
|
@ -126,6 +135,8 @@ impl Tier for LocalCoderTier {
|
|||
secs,
|
||||
tokens: generated.len(),
|
||||
joules,
|
||||
meter: meter.active_name(),
|
||||
provenance,
|
||||
text: text.clone(),
|
||||
});
|
||||
|
||||
|
|
@ -255,11 +266,14 @@ fn main() {
|
|||
let stats = log.lock().unwrap();
|
||||
for s in stats.iter() {
|
||||
println!(
|
||||
"model: {} tok in {:.1}s ({:.1} tok/s), {:.3} J measured",
|
||||
"model: {} tok in {:.1}s ({:.1} tok/s), {:.3} J {} by {} ({})",
|
||||
s.tokens,
|
||||
s.secs,
|
||||
if s.secs > 0.0 { s.tokens as f64 / s.secs } else { 0.0 },
|
||||
s.joules
|
||||
s.joules,
|
||||
joules_obtained(s.provenance),
|
||||
s.meter,
|
||||
s.provenance
|
||||
);
|
||||
println!("--- model output ---\n{}\n--------------------", s.text.trim());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@
|
|||
//! code model. Five distinct bug classes (wrong-operator, off-by-one,
|
||||
//! empty-guard, boundary-comparison, identity-vs-abs) — each a real Python file
|
||||
//! the Granite coder must fix as a whole-file replacement, judged by a real
|
||||
//! `python3` test ([`WholeFileValidator`]), with the decode energy measured.
|
||||
//! `python3` test ([`WholeFileValidator`]), with the decode energy metered.
|
||||
//!
|
||||
//! This is the multi-instance generalisation of `swebench_local_coder`: instead
|
||||
//! of one fix it reports a measured curve across bug types — which classes a 4B
|
||||
//! of one fix it reports a metered curve across bug types — which classes a 4B
|
||||
//! local model resolves, and the joules/$ each verified fix actually cost. The
|
||||
//! denominator is fixes that PASS their test, never attempts. Energy is the
|
||||
//! honest `Provenance::Estimator` figure (decode time + tokens exact); a local
|
||||
//! model is priced energy-only.
|
||||
//! denominator is fixes that PASS their test, never attempts. Energy is
|
||||
//! `jouleclaw-energy`'s auto-detected meter at its own provenance: the IOReport
|
||||
//! energy counter (`HwShunt`) only where its CPU energy rail advances under
|
||||
//! load, else the load-derived estimator (`Estimator`), as on macOS 27.0.1 on an
|
||||
//! M5 Max and an M3 Ultra; the report says which (decode time + tokens exact). A
|
||||
//! local model is priced energy-only.
|
||||
//!
|
||||
//! Run (defaults to the granite-4.0-h-micro snapshot if present):
|
||||
//! ```text
|
||||
|
|
@ -30,9 +33,10 @@ use jouleclaw_cascade::{
|
|||
VerificationStatus,
|
||||
};
|
||||
use jouleclaw_energy::meter::detect_meter;
|
||||
use jouleclaw_energy::Provenance;
|
||||
use jouleclaw_harness_econ::swebench::exec::{ShellTestRunner, WholeFileValidator};
|
||||
use jouleclaw_harness_econ::swebench::{PatchValidator, SweInstance};
|
||||
use jouleclaw_harness_econ::{EconHarness, PriceModel, Task};
|
||||
use jouleclaw_harness_econ::{joules_obtained, window_joules, EconHarness, PriceModel, Task};
|
||||
use jouleclaw_loader_gguf::bpe_tokenizer::BpeTokenizer;
|
||||
use jouleclaw_loader_gguf::granite_h::{GraniteHConfig, GraniteHModel};
|
||||
use jouleclaw_loader_gguf::safetensors_reader::read_safetensors_file;
|
||||
|
|
@ -86,6 +90,9 @@ struct DispatchStat {
|
|||
secs: f64,
|
||||
tokens: usize,
|
||||
joules: f64,
|
||||
/// The meter that metered the decode, and the provenance of its readings.
|
||||
meter: &'static str,
|
||||
provenance: Provenance,
|
||||
text: String,
|
||||
}
|
||||
|
||||
|
|
@ -121,8 +128,10 @@ impl Tier for LocalCoderTier {
|
|||
);
|
||||
let ids = self.tok.encode(&prompt);
|
||||
|
||||
// A window the meter put no figure on stops the run (`window_joules`): a
|
||||
// failed read is not a zero-energy reading.
|
||||
let meter = detect_meter();
|
||||
let e0 = meter.read().map(|r| r.uj).unwrap_or(0);
|
||||
let e0 = meter.read();
|
||||
let t = Instant::now();
|
||||
|
||||
let mut state = self.model.new_state();
|
||||
|
|
@ -145,10 +154,17 @@ impl Tier for LocalCoderTier {
|
|||
}
|
||||
|
||||
let secs = t.elapsed().as_secs_f64();
|
||||
let e1 = meter.read().map(|r| r.uj).unwrap_or(e0);
|
||||
let joules = e1.saturating_sub(e0) as f64 / 1e6;
|
||||
let (joules, provenance) = window_joules(e0, meter.read())
|
||||
.unwrap_or_else(|why| panic!("no energy figure for this dispatch from `{}`: {why}", meter.active_name()));
|
||||
let text = self.tok.decode(&generated);
|
||||
self.log.lock().unwrap().push(DispatchStat { secs, tokens: generated.len(), joules, text: text.clone() });
|
||||
self.log.lock().unwrap().push(DispatchStat {
|
||||
secs,
|
||||
tokens: generated.len(),
|
||||
joules,
|
||||
meter: meter.active_name(),
|
||||
provenance,
|
||||
text: text.clone(),
|
||||
});
|
||||
|
||||
Ok(Answer {
|
||||
output: AnswerOutput::Text(text),
|
||||
|
|
@ -310,6 +326,9 @@ fn main() {
|
|||
"totals: {toks} tok in {secs:.1}s ({:.1} tok/s), {model_j:.3} J spent",
|
||||
if secs > 0.0 { toks as f64 / secs } else { 0.0 }
|
||||
);
|
||||
for s in stats.iter() {
|
||||
println!(" {:.3} J {} by {} ({})", s.joules, joules_obtained(s.provenance), s.meter, s.provenance);
|
||||
}
|
||||
if report.verified > 0 {
|
||||
println!(
|
||||
"joules per VERIFIED fix : {:.3} J ({:?})",
|
||||
|
|
@ -321,8 +340,8 @@ fn main() {
|
|||
}
|
||||
println!(
|
||||
"\nThe denominator is fixes that PASS their test, not attempts. Energy is real\n\
|
||||
inference measured on this machine; swap a stronger model tier to raise the\n\
|
||||
resolve rate and lower the joules-per-verified-fix."
|
||||
inference on this machine, at each dispatch's meter and provenance above; swap a\n\
|
||||
stronger model tier to raise the resolve rate and lower the joules-per-verified-fix."
|
||||
);
|
||||
|
||||
drop(stats);
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ use jouleclaw_cascade::{
|
|||
Answer, AnswerError, ExecutionTrace, L4ModelId, Query, QueryInput, Runtime, Tier,
|
||||
TierEstimate, TierId, VerificationStatus,
|
||||
};
|
||||
use jouleclaw_energy::Provenance;
|
||||
use jouleclaw_energy::{EnergyReading, Provenance};
|
||||
use jouleclaw_skill::{InMemorySkillStore, SharedSkills, SkillInducer, SkillStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -515,12 +515,87 @@ fn worst_provenance(a: Provenance, b: Provenance) -> Provenance {
|
|||
if rank(a) <= rank(b) { a } else { b }
|
||||
}
|
||||
|
||||
/// The joules a meter put on one window of work — a model dispatch, say — from its
|
||||
/// cumulative readings before and after it, with the provenance those readings carry.
|
||||
/// Or why there is no figure: a read failed, the readings disagree about provenance,
|
||||
/// the counter went backwards, or it registered nothing at all (no work runs on zero
|
||||
/// energy, so a zero means the meter did not see the work). A failed read is never
|
||||
/// taken as zero, which would bill the window nothing, or bill it the counter's whole
|
||||
/// history.
|
||||
pub fn window_joules<E: std::fmt::Display>(
|
||||
before: Result<EnergyReading, E>,
|
||||
after: Result<EnergyReading, E>,
|
||||
) -> Result<(f64, Provenance), String> {
|
||||
let before = before.map_err(|e| format!("the meter read failed before the window: {e}"))?;
|
||||
let after = after.map_err(|e| format!("the meter read failed after the window: {e}"))?;
|
||||
if before.provenance != after.provenance {
|
||||
return Err(format!(
|
||||
"the meter's readings disagree about provenance ({} then {})",
|
||||
before.provenance, after.provenance
|
||||
));
|
||||
}
|
||||
match after.uj.checked_sub(before.uj) {
|
||||
None => Err(format!("the counter went backwards ({} µJ, then {} µJ)", before.uj, after.uj)),
|
||||
Some(0) => Err("the meter registered no energy across the window".to_string()),
|
||||
Some(uj) => Ok((uj as f64 / 1e6, after.provenance)),
|
||||
}
|
||||
}
|
||||
|
||||
/// How a report says a metered figure at `provenance` was obtained: `"measured"` for a
|
||||
/// reading (a hardware shunt, a live vendor energy counter, or a vendor energy model),
|
||||
/// `"estimated"` for an estimate. The local-model examples print each dispatch's joules
|
||||
/// through it, beside the meter that ran, so an estimate — what
|
||||
/// `jouleclaw_energy::meter::detect_meter` yields on Apple Silicon whose IOReport CPU
|
||||
/// rail does not advance — is never called a measurement.
|
||||
pub fn joules_obtained(provenance: Provenance) -> &'static str {
|
||||
match provenance {
|
||||
Provenance::HwShunt | Provenance::ModelBased => "measured",
|
||||
Provenance::Estimator => "estimated",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use jouleclaw_cascade::{Cascade, ContextRef, JouleBudget, QualityFloor};
|
||||
use jouleclaw_skill::{ConstantSkillInducer, SkillTier, SKILL_JOULES};
|
||||
|
||||
fn reading(uj: u64, provenance: Provenance) -> Result<EnergyReading, String> {
|
||||
Ok(EnergyReading { uj, timestamp_ns: 0, domain: jouleclaw_energy::EnergyDomain::SocTotal, provenance })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_is_the_delta_at_its_readings_provenance() {
|
||||
let est = Provenance::Estimator;
|
||||
assert_eq!(window_joules(reading(1_000_000, est), reading(3_500_000, est)), Ok((2.5, est)));
|
||||
let mb = Provenance::ModelBased;
|
||||
assert_eq!(window_joules(reading(7, mb), reading(250_007, mb)), Ok((0.25, mb)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_without_two_good_readings_is_never_zero() {
|
||||
let est = Provenance::Estimator;
|
||||
let failed: Result<EnergyReading, String> = Err("no joule counter available".into());
|
||||
let why = window_joules(failed.clone(), reading(9_000_000, est)).unwrap_err();
|
||||
assert!(why.contains("read failed before the window"), "{why}");
|
||||
let why = window_joules(reading(9_000_000, est), failed).unwrap_err();
|
||||
assert!(why.contains("read failed after the window"), "{why}");
|
||||
let why = window_joules(reading(5, est), reading(4, est)).unwrap_err();
|
||||
assert!(why.contains("went backwards"), "{why}");
|
||||
let why = window_joules(reading(5, Provenance::ModelBased), reading(9, est)).unwrap_err();
|
||||
assert!(why.contains("disagree about provenance"), "{why}");
|
||||
let why = window_joules(reading(541_893, Provenance::ModelBased), reading(541_893, Provenance::ModelBased))
|
||||
.unwrap_err();
|
||||
assert!(why.contains("registered no energy"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_estimate_is_never_reported_as_measured() {
|
||||
assert_eq!(joules_obtained(Provenance::Estimator), "estimated");
|
||||
assert_eq!(joules_obtained(Provenance::ModelBased), "measured");
|
||||
assert_eq!(joules_obtained(Provenance::HwShunt), "measured");
|
||||
}
|
||||
|
||||
fn q(text: &str) -> Query {
|
||||
Query {
|
||||
input: QueryInput::Text(text.to_string()),
|
||||
|
|
|
|||
|
|
@ -91,7 +91,9 @@ use jouleclaw_jcr1::{
|
|||
consumer's job.
|
||||
- The 10 vectors are TOY joule values exercising each spec path. They
|
||||
become measurements only when minted off real RAPL / NVML / IOReport
|
||||
counters and cross-validated against Zeus.
|
||||
counters that were live for the domain they claim (JCR-1 §2.3,
|
||||
Liveness) and cross-validated against Zeus. Vector 6's `ModelBased`
|
||||
span is a toy grade on a toy figure, not an IOReport reading.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ Expected: `10/10 vectors verified`.
|
|||
tokens**, with a gateway HMAC. (The spec's worked example.)
|
||||
4. **L2** retrieval-grounded — claim provenance + a span citation.
|
||||
5. **L3** local model — joule class, HwShunt GPU.
|
||||
6. **L3** retrieval + model on Apple IOReport — **ModelBased**, mJ-floored.
|
||||
6. **L3** retrieval + model on a **ModelBased** span, mJ-floored (toy figures,
|
||||
like every vector here: not an IOReport or any other reading).
|
||||
7. **L4** remote frontier RPC — **Estimator** (remote energy unmeasurable).
|
||||
8. **Mixed span** HwShunt + Estimator → worst-counter rule forces **Estimator**.
|
||||
9. **Gateway HMAC** — two metered tool calls, both signed by `gw-edge-9`.
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@
|
|||
},
|
||||
{
|
||||
"n": 6,
|
||||
"description": "L3 model grounded on retrieval, measured on Apple IOReport (ModelBased, mJ floor).",
|
||||
"description": "L3 model grounded on retrieval; toy joule values on a ModelBased span (vendor-model grade, mJ floor), not a measurement.",
|
||||
"receipt": {
|
||||
"jc_receipt": "1",
|
||||
"id": "rcpt-0006",
|
||||
|
|
|
|||
|
|
@ -66,12 +66,36 @@ outer Smart Byte envelope. Carrying it inline makes the receipt self-describing.
|
|||
|
||||
### 2.3 Provenance (the honesty tag) — normative
|
||||
Every energy reading carries one of:
|
||||
- `HwShunt` — real hardware counter (x86 RAPL MSR, NVIDIA cumulative-energy
|
||||
counter, Jetson INA3221). Honest to the counter's resolution.
|
||||
- `ModelBased` — vendor model estimate from freq/voltage/utilization (Apple
|
||||
IOReport, NVML `power.draw`). Millijoule-quantized at best.
|
||||
- `HwShunt` — hardware reading: a shunt (Jetson INA3221), or a vendor energy
|
||||
counter (x86 RAPL MSR, NVIDIA cumulative-energy counter, Apple IOReport) while
|
||||
it is live for the domain it claims (below). Honest to the counter's
|
||||
resolution.
|
||||
- `ModelBased` — vendor model estimate from freq/voltage/utilization (NVML
|
||||
`power.draw`, ROCm SMI). Millijoule-quantized at best.
|
||||
- `Estimator` — JouleClaw static cost model. Wide tolerance by construction.
|
||||
|
||||
A vendor energy counter's reading is a real reading; the trust gap is
|
||||
provenance (which counter, which domain, whether it was live), not accuracy.
|
||||
Hence a live vendor counter is `HwShunt`, under this condition:
|
||||
|
||||
**Liveness (MUST):** A reading from a vendor energy counter (RAPL, NVML, Apple
|
||||
IOReport, …) MAY be graded as a hardware reading only while the counter is
|
||||
demonstrably live for the domain the figure claims: the implementation MUST have
|
||||
observed the counter covering that domain advance under load (for Apple IOReport:
|
||||
the CPU energy rail itself advancing in at least 5 sample intervals, in at least
|
||||
4 of every 5, over at least 300 ms while the probing thread burns CPU). A counter
|
||||
that is present but not live — frozen, or republished in lumps — MUST NOT produce
|
||||
a hardware-graded figure; the implementation falls back to its estimator or
|
||||
reports the work unmetered, and SHOULD record why. A figure MUST NOT claim
|
||||
coverage of a domain its counter does not read; where the format has a coverage
|
||||
field, such a figure is Partial. (JCR-1 v1 has no coverage field: a figure for a
|
||||
domain its counter does not read is not a `HwShunt` figure for that work at all.)
|
||||
|
||||
Apple IOReport's GPU rail is machine-wide (display compositing moves it with
|
||||
nothing of the metered work running), so it is not a partial reading of CPU work:
|
||||
where only the GPU rail is live, the reference implementation declines the
|
||||
counter and the CPU work is graded `Estimator`.
|
||||
|
||||
**Worst-counter rule (MUST):** `Receipt.energy_provenance` = the least-honest
|
||||
provenance among all readings contributing to `joules_uj`, ordered
|
||||
`HwShunt > ModelBased > Estimator`. A receipt whose span mixed a HwShunt GPU read
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
//! `jcr1_mint` — mint a fresh JCR-1 receipt from an energy reading.
|
||||
//!
|
||||
//! Reads CPU energy the way `jouleclaw-energy::rapl` does — straight from
|
||||
//! `/sys/class/powercap/intel-rapl:0/energy_uj`, no FFI. On a system
|
||||
//! Reads CPU energy through `jouleclaw-energy::rapl`: the sum of every
|
||||
//! powercap package zone (`/sys/class/powercap/intel-rapl:<n>` named
|
||||
//! `package-<n>`), no FFI. On a system
|
||||
//! without RAPL (Apple, BSD, CI sandboxes, this Mac the example is
|
||||
//! built on) the producer does NOT fake a `HwShunt` reading; it falls
|
||||
//! back to a time-based `Estimator` value rounded to the 1 mJ
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@
|
|||
//! global profile — executes regardless of box load; only the *accuracy* of the
|
||||
//! µJ figures needs a quiet machine (the marginal-energy subtraction assumes a
|
||||
//! stable idle draw). A backend the platform can't run for a shape reports
|
||||
//! `u64::MAX` and simply never wins.
|
||||
//! `u64::MAX` and simply never wins. A shape the meter cannot measure — the IOReport
|
||||
//! counter unavailable (its CPU energy rail not advancing, among other reasons), or a
|
||||
//! window that draws nothing above idle — is left untuned: nothing is recorded.
|
||||
|
||||
use jouleclaw_loader_gguf::energy_autotune;
|
||||
use jouleclaw_loader_gguf::fabric_dispatch;
|
||||
|
|
@ -39,14 +41,20 @@ fn main() {
|
|||
cost.uj,
|
||||
cost.ns
|
||||
),
|
||||
None => println!(" m={m:<4} n={n} k={k} → meter unavailable (need IOReport)"),
|
||||
None => println!(
|
||||
" m={m:<4} n={n} k={k} → not tuned: the meter produced no figure (IOReport {:?})",
|
||||
jouleclaw_energy::ioreport_status()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nglobal tuning profile (the dispatcher's lookup):");
|
||||
let snapshot = energy_autotune::global_snapshot();
|
||||
if snapshot.is_empty() {
|
||||
println!(" (empty — no shape was tuned; is the IOReport meter present?)");
|
||||
println!(
|
||||
" (empty — no shape was measured; IOReport {:?})",
|
||||
jouleclaw_energy::ioreport_status()
|
||||
);
|
||||
}
|
||||
for (key, cost) in snapshot {
|
||||
println!(
|
||||
|
|
|
|||
|
|
@ -15,14 +15,44 @@
|
|||
//! dataset records. Run this on a quiet machine or the
|
||||
//! numbers are someone else's workload.
|
||||
//!
|
||||
//! It writes nothing, and exits non-zero saying why, unless
|
||||
//! every figure is a measurement: the IOReport counter must
|
||||
//! be available (on Apple Silicon, its CPU energy rail must
|
||||
//! advance — `jouleclaw_energy::ioreport_status`), the idle
|
||||
//! baseline must advance, and every member's marginal
|
||||
//! energy must be positive (`marginal_energy`). A zero,
|
||||
//! clamped or not, would be loaded as a measured placement
|
||||
//! cost.
|
||||
//!
|
||||
//! Each row it writes declares its grade — the meter's, so a
|
||||
//! live IOReport reading (`HwShunt`) is `Measured` — and
|
||||
//! records its method, its date, and the OS and chip it was
|
||||
//! taken on (`marginal_energy::calibration_row`); the fabric
|
||||
//! loads it at that grade and carries the rest along.
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo run --release --features "rayon fabric-meter" \
|
||||
//! --example fabric_calibrate
|
||||
//! ```
|
||||
|
||||
use jouleclaw_energy::apple_ioreport::AppleIoReportCounter;
|
||||
use jouleclaw_energy::EnergyCounter;
|
||||
use jouleclaw_loader_gguf::marginal_energy::{
|
||||
calibration_row, idle_draw_uw, marginal_uj, refusal, NotMeasured,
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Write nothing: say why, with the IOReport status, on stderr and exit non-zero.
|
||||
fn refuse(path: &str, why: impl std::fmt::Display) -> ! {
|
||||
eprintln!("{}", refusal(path, why, jouleclaw_energy::ioreport_status()));
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
/// One cumulative reading, µJ.
|
||||
fn read_uj(counter: &AppleIoReportCounter) -> Result<u64, NotMeasured> {
|
||||
counter.read().map(|r| r.uj).map_err(|e| NotMeasured::ReadFailed(e.to_string()))
|
||||
}
|
||||
|
||||
struct Shape {
|
||||
label: &'static str,
|
||||
m: usize,
|
||||
|
|
@ -38,19 +68,22 @@ fn main() {
|
|||
Shape { label: "prefill-batch (m=16)", m: 16, n: 4096, k: 2304 },
|
||||
];
|
||||
|
||||
let counter = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let dir = format!("{home}/.jouleclaw");
|
||||
let path = format!("{dir}/fabric-calibration.json");
|
||||
|
||||
let counter = AppleIoReportCounter::new();
|
||||
if !counter.is_available() {
|
||||
eprintln!("IOReport unavailable on this host — nothing honest to write.");
|
||||
std::process::exit(1);
|
||||
refuse(&path, NotMeasured::CounterUnavailable("apple-ioreport declined".into()));
|
||||
}
|
||||
|
||||
// Idle baseline: 3 s of nothing.
|
||||
let idle_uw = {
|
||||
let e0 = counter.read().expect("read").uj;
|
||||
let e0 = read_uj(&counter).unwrap_or_else(|e| refuse(&path, e));
|
||||
let t0 = Instant::now();
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
let e1 = counter.read().expect("read").uj;
|
||||
(e1 - e0) as f64 / t0.elapsed().as_secs_f64()
|
||||
let e1 = read_uj(&counter).unwrap_or_else(|e| refuse(&path, e));
|
||||
idle_draw_uw(e0, e1, t0.elapsed().as_secs_f64()).unwrap_or_else(|e| refuse(&path, e))
|
||||
};
|
||||
println!("idle draw: {:.2} W (machine-wide SoC)", idle_uw / 1e6);
|
||||
|
||||
|
|
@ -108,31 +141,33 @@ fn main() {
|
|||
run(&mut out);
|
||||
// Measure ≥ 2 s of work.
|
||||
let mut iters = 0u64;
|
||||
let e0 = counter.read().expect("read").uj;
|
||||
let e0 = read_uj(&counter).unwrap_or_else(|e| refuse(&path, e));
|
||||
let t0 = Instant::now();
|
||||
while t0.elapsed().as_secs_f64() < 2.0 {
|
||||
run(&mut out);
|
||||
iters += 1;
|
||||
}
|
||||
let dt = t0.elapsed().as_secs_f64();
|
||||
let e1 = counter.read().expect("read").uj;
|
||||
let marginal_uj = ((e1 - e0) as f64 - idle_uw * dt).max(0.0);
|
||||
let uj_per_op = marginal_uj / iters as f64;
|
||||
let e1 = read_uj(&counter).unwrap_or_else(|e| refuse(&path, e));
|
||||
let marginal = marginal_uj(e0, e1, dt, idle_uw)
|
||||
.unwrap_or_else(|e| refuse(&path, format!("{member} on {}: {e}", shape.label)));
|
||||
let uj_per_op = marginal / iters as f64;
|
||||
let uj_per_gflop = uj_per_op / gflop;
|
||||
println!(
|
||||
"{member:>18}: {iters:>5} ops in {dt:.2}s → {:.0} µJ/op, {:.0} µJ/GFLOP ({:.1} ms/op)",
|
||||
uj_per_op, uj_per_gflop, dt * 1e3 / iters as f64,
|
||||
);
|
||||
entries.push(jouleclaw_fabric::CalibrationEntry {
|
||||
member_id: member.to_string(),
|
||||
op: "matmul".into(),
|
||||
entries.push(calibration_row(
|
||||
member,
|
||||
"matmul",
|
||||
uj_per_gflop,
|
||||
method: format!(
|
||||
counter.provenance(),
|
||||
format!(
|
||||
"IOReport SoC ΔE − idle({:.2} W)·Δt over {} ops of {} — \
|
||||
idle-subtraction model on a hardware reading",
|
||||
idle_uw / 1e6, iters, shape.label,
|
||||
idle-subtraction model on a hardware reading; IOReport {:?}",
|
||||
idle_uw / 1e6, iters, shape.label, jouleclaw_energy::ioreport_status(),
|
||||
),
|
||||
});
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,10 +180,7 @@ fn main() {
|
|||
latest.insert(e.member_id.clone(), e);
|
||||
}
|
||||
let out_entries: Vec<_> = latest.into_values().collect();
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let dir = format!("{home}/.jouleclaw");
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
let path = format!("{dir}/fabric-calibration.json");
|
||||
std::fs::write(&path, serde_json::to_vec_pretty(&out_entries).unwrap()).expect("write");
|
||||
println!("\nwrote {path} — Fabric::discover() now places on measured figures.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,22 +2,41 @@
|
|||
//! threaded-NEON CPU path, via the IOReport SoC energy counter.
|
||||
//! Run on a QUIET box: marginal energy = ΔE_busy − P_idle·Δt.
|
||||
//! `cargo run --release --features "rayon fabric fabric-meter" --example mpp_energy`
|
||||
//!
|
||||
//! It writes nothing, and exits non-zero saying why, unless both figures are
|
||||
//! measurements: the IOReport counter available (its CPU energy rail live —
|
||||
//! `jouleclaw_energy::ioreport_status`), an idle baseline that advances, and a
|
||||
//! positive marginal for the GPU **and** the CPU run. A CPU run that draws nothing
|
||||
//! above idle means the meter cannot see CPU work, so its SoC figure for the GPU run
|
||||
//! is not a whole-SoC figure either.
|
||||
#[cfg(all(feature = "fabric", feature = "fabric-meter"))]
|
||||
fn main() {
|
||||
use jouleclaw_energy::EnergyCounter;
|
||||
use jouleclaw_loader_gguf::marginal_energy::{
|
||||
calibration_row, energy_ratio, idle_draw_uw, marginal_uj, refusal, NotMeasured,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
fn f32_to_bf16(v: f32) -> u16 { let b = v.to_bits(); (((b + (((b >> 16) & 1) + 0x7fff)) >> 16)) as u16 }
|
||||
/// Write nothing: say why, with the IOReport status, on stderr and exit non-zero.
|
||||
fn refuse(path: &str, why: impl std::fmt::Display) -> ! {
|
||||
eprintln!("{}", refusal(path, why, jouleclaw_energy::ioreport_status()));
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let path = std::env::var("JOULECLAW_FABRIC_CALIBRATION").unwrap_or_else(|_| {
|
||||
format!("{}/.jouleclaw/fabric-calibration.json", std::env::var("HOME").unwrap_or_default())
|
||||
});
|
||||
let counter = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
|
||||
if !counter.is_available() {
|
||||
eprintln!("IOReport unavailable"); std::process::exit(1);
|
||||
refuse(&path, NotMeasured::CounterUnavailable("apple-ioreport declined".into()));
|
||||
}
|
||||
let read_uj = || counter.read().map(|r| r.uj).map_err(|e| NotMeasured::ReadFailed(e.to_string()));
|
||||
let idle_uw = {
|
||||
let e0 = counter.read().unwrap().uj;
|
||||
let e0 = read_uj().unwrap_or_else(|e| refuse(&path, e));
|
||||
let t0 = Instant::now();
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
let e1 = counter.read().unwrap().uj;
|
||||
(e1 - e0) as f64 / t0.elapsed().as_secs_f64()
|
||||
let e1 = read_uj().unwrap_or_else(|e| refuse(&path, e));
|
||||
idle_draw_uw(e0, e1, t0.elapsed().as_secs_f64()).unwrap_or_else(|e| refuse(&path, e))
|
||||
};
|
||||
println!("idle draw: {:.2} W (machine-wide SoC)", idle_uw / 1e6);
|
||||
|
||||
|
|
@ -29,10 +48,11 @@ fn main() {
|
|||
.collect();
|
||||
let gflop = 2.0 * m as f64 * n as f64 * k as f64 / 1e9;
|
||||
|
||||
// run a workload for ~`secs` and return (µJ/GFLOP, GFLOP/s).
|
||||
let measure = |secs: f64, mut run: Box<dyn FnMut()>| -> (f64, f64) {
|
||||
// run a workload for ~`secs` and return (µJ/GFLOP, GFLOP/s), or why the meter
|
||||
// produced no figure for it.
|
||||
let measure = |secs: f64, mut run: Box<dyn FnMut()>| -> Result<(f64, f64), NotMeasured> {
|
||||
run(); // warm
|
||||
let e0 = counter.read().unwrap().uj;
|
||||
let e0 = read_uj()?;
|
||||
let t0 = Instant::now();
|
||||
let mut ops = 0u64;
|
||||
while t0.elapsed().as_secs_f64() < secs {
|
||||
|
|
@ -40,16 +60,16 @@ fn main() {
|
|||
ops += 1;
|
||||
}
|
||||
let dt = t0.elapsed().as_secs_f64();
|
||||
let e1 = counter.read().unwrap().uj;
|
||||
let e1 = read_uj()?;
|
||||
let total_gf = gflop * ops as f64;
|
||||
let marginal_uj = (e1 - e0) as f64 - idle_uw * dt;
|
||||
(marginal_uj / total_gf, total_gf / dt)
|
||||
let marginal = marginal_uj(e0, e1, dt, idle_uw)?;
|
||||
Ok((marginal / total_gf, total_gf / dt))
|
||||
};
|
||||
|
||||
let mut out = vec![0f32; m * n];
|
||||
let optr = out.as_mut_ptr();
|
||||
let (a_p, w_p) = (&a, &w);
|
||||
let (gpu_ujpgf, gpu_gfps) = measure(3.0, Box::new(move || {
|
||||
let gpu = measure(3.0, Box::new(move || {
|
||||
let o = unsafe { std::slice::from_raw_parts_mut(optr, m * n) };
|
||||
let ok = jouleclaw_loader_gguf::fabric_dispatch::matmul_bf16_mpp_bt(a_p, w_p, m, n, k, o);
|
||||
assert!(ok, "mpp_bt fell back — set JOULECLAW_FABRIC=1 on a Metal-4 box");
|
||||
|
|
@ -57,42 +77,61 @@ fn main() {
|
|||
|
||||
let mut out2 = vec![0f32; m * n];
|
||||
let o2ptr = out2.as_mut_ptr();
|
||||
let (cpu_ujpgf, cpu_gfps) = measure(3.0, Box::new(move || {
|
||||
let cpu = measure(3.0, Box::new(move || {
|
||||
let o = unsafe { std::slice::from_raw_parts_mut(o2ptr, m * n) };
|
||||
jouleclaw_loader_gguf::matmul_bf16::matmul_bf16_threaded(a_p, w_p, &[], m, n, k, o);
|
||||
// Assert it ran, as the GPU run does: a refused shape would meter an empty loop.
|
||||
jouleclaw_loader_gguf::matmul_bf16::matmul_bf16_threaded(a_p, w_p, &[], m, n, k, o)
|
||||
.expect("threaded-NEON matmul refused this shape");
|
||||
}));
|
||||
|
||||
println!("shape {m}x{n}x{k}");
|
||||
println!(" matmul2d (metal-gpu): {:>9.0} µJ/GFLOP {:>7.1} GFLOP/s", gpu_ujpgf, gpu_gfps);
|
||||
println!(" threaded-NEON (cpu) : {:>9.0} µJ/GFLOP {:>7.1} GFLOP/s", cpu_ujpgf, cpu_gfps);
|
||||
println!(" energy ratio (CPU/GPU per GFLOP): {:.2}×", cpu_ujpgf / gpu_ujpgf);
|
||||
for (label, r) in [("matmul2d (metal-gpu)", &gpu), ("threaded-NEON (cpu) ", &cpu)] {
|
||||
match r {
|
||||
Ok((ujpgf, gfps)) => println!(" {label}: {ujpgf:>9.0} µJ/GFLOP {gfps:>7.1} GFLOP/s"),
|
||||
Err(e) => println!(" {label}: not measured — {e}"),
|
||||
}
|
||||
}
|
||||
let ratio = match (&gpu, &cpu) {
|
||||
(Ok((gpu_ujpgf, _)), Ok((cpu_ujpgf, _))) => energy_ratio(*cpu_ujpgf, *gpu_ujpgf),
|
||||
_ => None,
|
||||
};
|
||||
match ratio {
|
||||
Some(ratio) => println!(" energy ratio (CPU/GPU per GFLOP): {ratio:.2}×"),
|
||||
None => println!(" energy ratio (CPU/GPU per GFLOP): n/a — both runs must be measured"),
|
||||
}
|
||||
let gpu_ujpgf = match (gpu, cpu) {
|
||||
(Ok((gpu_ujpgf, _)), Ok(_)) => gpu_ujpgf,
|
||||
(Err(e), _) => refuse(&path, format!("the matmul2d (metal-gpu) run: {e}")),
|
||||
(_, Err(e)) => refuse(&path, format!("the threaded-NEON (cpu) run: {e}")),
|
||||
};
|
||||
|
||||
// Overlay the measured matmul2d µJ/GFLOP into this box's
|
||||
// calibration file under local/metal-mpp, so the placer +
|
||||
// ledger price the prefill path at its TRUE cost (not the
|
||||
// borrowed MPSMatrix metal-gpu figure). Merge, don't clobber.
|
||||
let path = std::env::var("JOULECLAW_FABRIC_CALIBRATION").unwrap_or_else(|_| {
|
||||
format!("{}/.jouleclaw/fabric-calibration.json", std::env::var("HOME").unwrap_or_default())
|
||||
});
|
||||
let mut entries: Vec<jouleclaw_fabric::CalibrationEntry> = std::fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
entries.retain(|e| e.member_id != "local/metal-mpp");
|
||||
entries.push(jouleclaw_fabric::CalibrationEntry {
|
||||
member_id: "local/metal-mpp".into(),
|
||||
op: "matmul".into(),
|
||||
uj_per_gflop: gpu_ujpgf,
|
||||
method: format!(
|
||||
"IOReport SoC ΔE − idle({:.2} W)·Δt over matmul2d {m}×{n}×{k} on a quiet box",
|
||||
idle_uw / 1e6
|
||||
let row = calibration_row(
|
||||
"local/metal-mpp",
|
||||
"matmul",
|
||||
gpu_ujpgf,
|
||||
counter.provenance(),
|
||||
format!(
|
||||
"IOReport SoC ΔE − idle({:.2} W)·Δt over matmul2d {m}×{n}×{k} on a quiet box; IOReport {:?}",
|
||||
idle_uw / 1e6,
|
||||
jouleclaw_energy::ioreport_status()
|
||||
),
|
||||
});
|
||||
);
|
||||
let grade = row.loaded_provenance();
|
||||
entries.push(row);
|
||||
if let Some(dir) = std::path::Path::new(&path).parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
if std::fs::write(&path, serde_json::to_vec_pretty(&entries).unwrap()).is_ok() {
|
||||
println!("wrote local/metal-mpp = {:.0} µJ/GFLOP (Measured) → {path}", gpu_ujpgf);
|
||||
println!("wrote local/metal-mpp = {:.0} µJ/GFLOP ({grade:?}) → {path}", gpu_ujpgf);
|
||||
}
|
||||
}
|
||||
#[cfg(not(all(feature = "fabric", feature = "fabric-meter")))]
|
||||
|
|
|
|||
|
|
@ -119,6 +119,82 @@ pub fn tune_shape(
|
|||
joules_minimal(&costs)
|
||||
}
|
||||
|
||||
/// [`tune_shape`] over a meter that can fail to measure a window: `metered(backend)`
|
||||
/// returns `None` when the meter did not register that backend's work (a read failed,
|
||||
/// the idle baseline did not advance, no energy above idle). One unmeasured window voids
|
||||
/// the whole tune — `None`, nothing to record — because ranking a measured backend
|
||||
/// against an unmeasured one decides by a missing figure. A backend that cannot run
|
||||
/// the shape reports `Some((u64::MAX, u64::MAX))` and, as in [`tune_shape`], never wins.
|
||||
#[cfg(any(test, feature = "fabric-meter"))]
|
||||
pub(crate) fn tune_measured(
|
||||
candidates: &[Backend],
|
||||
reps: u32,
|
||||
mut metered: impl FnMut(Backend) -> Option<(u64, u64)>,
|
||||
) -> Option<Cost> {
|
||||
let reps = reps.max(1);
|
||||
let mut costs = Vec::with_capacity(candidates.len());
|
||||
for &backend in candidates {
|
||||
let (mut uj_sum, mut ns_sum) = (0u64, 0u64);
|
||||
for _ in 0..reps {
|
||||
let (uj, ns) = metered(backend)?;
|
||||
uj_sum = uj_sum.saturating_add(uj);
|
||||
ns_sum = ns_sum.saturating_add(ns);
|
||||
}
|
||||
costs.push(Cost { backend, uj: uj_sum / reps as u64, ns: ns_sum / reps as u64 });
|
||||
}
|
||||
joules_minimal(&costs)
|
||||
}
|
||||
|
||||
/// The meter a recorded cost was measured on: which meter read it, the grade of its
|
||||
/// readings, and the energy domain they cover. A sealed tuning receipt carries it per
|
||||
/// shape, and [`TuningProfile`] ranks records by it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Metered {
|
||||
/// The meter's stable name, e.g. `"apple-ioreport"`.
|
||||
pub meter: String,
|
||||
/// The grade of its readings, as JCR-1 spells it: `"HwShunt"`, `"ModelBased"` or
|
||||
/// `"Estimator"`. A vendor energy counter reads `HwShunt` only while it is live for
|
||||
/// its domain (JouleClaw SPEC §1.3); one that is not live produces no reading.
|
||||
pub energy_provenance: String,
|
||||
/// The energy domain its readings cover, e.g. `"SocTotal"`.
|
||||
pub domain: String,
|
||||
}
|
||||
|
||||
/// What a sealed receipt says for a cost recorded through [`TuningProfile::record`],
|
||||
/// with no [`Metered`]: nothing recorded what meter measured it, or how.
|
||||
pub const UNRECORDED: &str = "unrecorded";
|
||||
|
||||
/// How far a record can be trusted against another for the same shape: a hardware
|
||||
/// reading over a vendor model over an estimate, and any of them over a cost recorded
|
||||
/// with no meter at all, which ranks last, as the worst-counter rule ranks an unknown.
|
||||
fn rank(metered: Option<&Metered>) -> u8 {
|
||||
match metered.map(|m| m.energy_provenance.as_str()) {
|
||||
Some("HwShunt") => 3,
|
||||
Some("ModelBased") => 2,
|
||||
Some("Estimator") => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "fabric-meter")]
|
||||
impl Metered {
|
||||
/// The record for a cost measured on `meter`: its name, its readings' grade, and
|
||||
/// their domain.
|
||||
pub fn of(meter: &dyn jouleclaw_energy::meter::EnergyMeter) -> Self {
|
||||
use jouleclaw_energy::Provenance as P;
|
||||
let grade = match meter.provenance() {
|
||||
P::HwShunt => "HwShunt",
|
||||
P::ModelBased => "ModelBased",
|
||||
P::Estimator => "Estimator",
|
||||
};
|
||||
Metered {
|
||||
meter: meter.name().to_string(),
|
||||
energy_provenance: grade.to_string(),
|
||||
domain: format!("{:?}", meter.domain()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A learned, deterministic map from op-shape to the backend that runs it in the
|
||||
/// fewest joules. Deterministic (a `BTreeMap`) so a persisted or sealed profile is
|
||||
/// byte-stable. The dispatcher consults it; a shape with no entry falls back to
|
||||
|
|
@ -126,7 +202,7 @@ pub fn tune_shape(
|
|||
/// measured-better one).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TuningProfile {
|
||||
chosen: BTreeMap<ShapeKey, Cost>,
|
||||
chosen: BTreeMap<ShapeKey, (Cost, Option<Metered>)>,
|
||||
}
|
||||
|
||||
impl TuningProfile {
|
||||
|
|
@ -135,18 +211,53 @@ impl TuningProfile {
|
|||
TuningProfile { chosen: BTreeMap::new() }
|
||||
}
|
||||
|
||||
/// Record a measurement for `key`, keeping whichever is joules-minimal — so a
|
||||
/// later, noisier measurement can never regress a shape's decision.
|
||||
/// Record a measurement for `key` with no record of the meter that took it, keeping
|
||||
/// whichever is joules-minimal among such records — so a later, noisier
|
||||
/// measurement can never regress a shape's decision.
|
||||
///
|
||||
/// Such a cost ranks below any cost recorded with its meter
|
||||
/// ([`Self::record_metered`]): nothing says the meter behind it was live, and a
|
||||
/// cost taken on a counter that was not — before the IOReport counter had to show
|
||||
/// its CPU rail live, a frozen rail metered CPU work as GPU-only energy, or zero —
|
||||
/// is a low figure that would otherwise beat every honest measurement after it.
|
||||
///
|
||||
/// A zero-joule cost is never recorded. Nothing runs on zero energy, so it means
|
||||
/// the meter did not see the work, not that the work was free; recorded, it would
|
||||
/// beat every later measurement and pin the shape to that backend for good.
|
||||
pub fn record(&mut self, key: ShapeKey, cost: Cost) {
|
||||
match self.chosen.get(&key) {
|
||||
Some(current) if current.uj <= cost.uj => {}
|
||||
_ => {
|
||||
self.chosen.insert(key, cost);
|
||||
}
|
||||
}
|
||||
self.insert(key, cost, None);
|
||||
}
|
||||
|
||||
/// Tune `key` across `candidates` and record the winner; returns it.
|
||||
/// Record a measurement for `key` taken on `metered`'s meter.
|
||||
///
|
||||
/// It replaces whatever the shape holds from a less trusted record (see
|
||||
/// [`Self::record`]: a hardware reading over a vendor model over an estimate, over
|
||||
/// a cost with no meter recorded), however many joules either drew: the decision
|
||||
/// is re-made on the better evidence. Against a record of the same standing the
|
||||
/// joules-minimal one is kept, and a record of better standing is never displaced
|
||||
/// by a worse one. A zero-joule cost is never recorded, and a zero the shape holds
|
||||
/// (an older build recorded them) is replaced by any figure.
|
||||
pub fn record_metered(&mut self, key: ShapeKey, cost: Cost, metered: Metered) {
|
||||
self.insert(key, cost, Some(metered));
|
||||
}
|
||||
|
||||
fn insert(&mut self, key: ShapeKey, cost: Cost, metered: Option<Metered>) {
|
||||
if cost.uj == 0 {
|
||||
return;
|
||||
}
|
||||
if let Some((current, current_meter)) = self.chosen.get(&key) {
|
||||
let (held, offered) = (rank(current_meter.as_ref()), rank(metered.as_ref()));
|
||||
let keep = current.uj != 0 && (held > offered || (held == offered && current.uj <= cost.uj));
|
||||
if keep {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.chosen.insert(key, (cost, metered));
|
||||
}
|
||||
|
||||
/// Tune `key` across `candidates` and record the winner; returns it. `None`, with
|
||||
/// nothing recorded, when the joules-minimal candidate drew zero joules (see
|
||||
/// [`Self::record`]).
|
||||
pub fn tune_and_record(
|
||||
&mut self,
|
||||
key: ShapeKey,
|
||||
|
|
@ -155,24 +266,37 @@ impl TuningProfile {
|
|||
metered: impl FnMut(Backend) -> (u64, u64),
|
||||
) -> Option<Cost> {
|
||||
let cost = tune_shape(candidates, reps, metered)?;
|
||||
if cost.uj == 0 {
|
||||
return None;
|
||||
}
|
||||
self.record(key, cost);
|
||||
Some(cost)
|
||||
}
|
||||
|
||||
/// The backend chosen for `key`, if this shape has been tuned.
|
||||
pub fn choose(&self, key: ShapeKey) -> Option<Backend> {
|
||||
self.chosen.get(&key).map(|c| c.backend)
|
||||
self.chosen.get(&key).map(|(c, _)| c.backend)
|
||||
}
|
||||
|
||||
/// The full measured cost recorded for `key`, if any.
|
||||
pub fn cost(&self, key: ShapeKey) -> Option<Cost> {
|
||||
self.chosen.get(&key).copied()
|
||||
self.chosen.get(&key).map(|(c, _)| *c)
|
||||
}
|
||||
|
||||
/// The tuned shapes and their winning costs, in deterministic key order — the
|
||||
/// preimage a sealed tuning receipt commits to.
|
||||
/// The meter `key`'s cost was recorded with, if it was recorded with one.
|
||||
pub fn metered(&self, key: ShapeKey) -> Option<&Metered> {
|
||||
self.chosen.get(&key).and_then(|(_, m)| m.as_ref())
|
||||
}
|
||||
|
||||
/// The tuned shapes and their winning costs, in deterministic key order.
|
||||
pub fn entries(&self) -> impl Iterator<Item = (&ShapeKey, &Cost)> {
|
||||
self.chosen.iter()
|
||||
self.chosen.iter().map(|(k, (c, _))| (k, c))
|
||||
}
|
||||
|
||||
/// The tuned shapes, their winning costs and the meter each was recorded with, in
|
||||
/// deterministic key order — the preimage a sealed tuning receipt commits to.
|
||||
pub fn entries_metered(&self) -> impl Iterator<Item = (&ShapeKey, &Cost, Option<&Metered>)> {
|
||||
self.chosen.iter().map(|(k, (c, m))| (k, c, m.as_ref()))
|
||||
}
|
||||
|
||||
/// Number of tuned shapes.
|
||||
|
|
@ -207,13 +331,22 @@ pub fn autotune_enabled() -> bool {
|
|||
})
|
||||
}
|
||||
|
||||
/// Record a measured cost into the process-global profile (the live tuner calls this).
|
||||
/// Record a measured cost into the process-global profile with no record of its meter
|
||||
/// ([`TuningProfile::record`]).
|
||||
pub fn record_global(key: ShapeKey, cost: Cost) {
|
||||
if let Ok(mut profile) = global_profile().lock() {
|
||||
profile.record(key, cost);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a cost measured on `metered`'s meter into the process-global profile
|
||||
/// ([`TuningProfile::record_metered`]); the live tuner calls this.
|
||||
pub fn record_global_metered(key: ShapeKey, cost: Cost, metered: Metered) {
|
||||
if let Ok(mut profile) = global_profile().lock() {
|
||||
profile.record_metered(key, cost, metered);
|
||||
}
|
||||
}
|
||||
|
||||
/// The globally-tuned backend for `key`, if this shape has been tuned — the
|
||||
/// dispatcher's hot-path lookup. `None` (untuned) ⇒ the caller keeps its heuristic.
|
||||
pub fn decide_global(key: ShapeKey) -> Option<Backend> {
|
||||
|
|
@ -221,7 +354,7 @@ pub fn decide_global(key: ShapeKey) -> Option<Backend> {
|
|||
}
|
||||
|
||||
/// A deterministic snapshot of every globally-tuned shape and its winning cost —
|
||||
/// for display or a sealed tuning receipt.
|
||||
/// for display.
|
||||
pub fn global_snapshot() -> Vec<(ShapeKey, Cost)> {
|
||||
global_profile()
|
||||
.lock()
|
||||
|
|
@ -229,14 +362,26 @@ pub fn global_snapshot() -> Vec<(ShapeKey, Cost)> {
|
|||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`global_snapshot`] with the meter each cost was recorded with — the preimage of a
|
||||
/// sealed tuning receipt.
|
||||
pub fn global_snapshot_metered() -> Vec<(ShapeKey, Cost, Option<Metered>)> {
|
||||
global_profile()
|
||||
.lock()
|
||||
.map(|profile| profile.entries_metered().map(|(k, c, m)| (*k, *c, m.cloned())).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ───────────────────────── sealing (the `attest` feature) ─────────────────────────
|
||||
|
||||
/// Seal the current global tuning profile as an **`autotune/1`** JCR-1 receipt
|
||||
/// (COSE_Sign1 / Ed25519) — [`global_snapshot`] is the preimage.
|
||||
/// (COSE_Sign1 / Ed25519) — [`global_snapshot_metered`] is the preimage.
|
||||
///
|
||||
/// The receipt commits to every tuned shape and its winning backend + measured
|
||||
/// joules, so a dispatch decision ("this shape ran on the CPU because it measured
|
||||
/// fewer joules there") becomes a verifiable artifact rather than a claim. Reuses
|
||||
/// fewer joules there") becomes a verifiable artifact rather than a claim. Each shape
|
||||
/// also says what its joules are: the `meter` that read them, its readings'
|
||||
/// `energy_provenance` and their `domain` ([`Metered`]), or [`UNRECORDED`] in all
|
||||
/// three (`"Unrecorded"` as the provenance) for a cost recorded with no meter. Reuses
|
||||
/// jouleclaw's own `jouleclaw_jcr1` envelope — no new crypto, and it verifies with
|
||||
/// every jouleclaw JCR-1 verifier. Energy is sealed as integer microjoules (JCR-1
|
||||
/// forbids floats); the backend is a stable label.
|
||||
|
|
@ -246,9 +391,13 @@ pub fn seal_profile(
|
|||
steward: &str,
|
||||
) -> Result<Vec<u8>, jouleclaw_jcr1::JcrError> {
|
||||
use serde_json::json;
|
||||
let shapes: Vec<serde_json::Value> = global_snapshot()
|
||||
let shapes: Vec<serde_json::Value> = global_snapshot_metered()
|
||||
.iter()
|
||||
.map(|(key, cost)| {
|
||||
.map(|(key, cost, metered)| {
|
||||
let (meter, provenance, domain) = match metered {
|
||||
Some(m) => (m.meter.as_str(), m.energy_provenance.as_str(), m.domain.as_str()),
|
||||
None => (UNRECORDED, "Unrecorded", UNRECORDED),
|
||||
};
|
||||
json!({
|
||||
"m_bucket": key.m_bucket,
|
||||
"n": key.n,
|
||||
|
|
@ -256,6 +405,9 @@ pub fn seal_profile(
|
|||
"backend": cost.backend.label(),
|
||||
"uj": cost.uj,
|
||||
"ns": cost.ns,
|
||||
"meter": meter,
|
||||
"energy_provenance": provenance,
|
||||
"domain": domain,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -343,6 +495,128 @@ mod tests {
|
|||
assert_eq!(p.cost(key).unwrap().uj, 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_joule_cost_is_never_recorded() {
|
||||
let key = ShapeKey::new(1, 32, 32);
|
||||
let mut p = TuningProfile::new();
|
||||
p.record(key, Cost { backend: Backend::Cpu, uj: 0, ns: 100 });
|
||||
assert!(p.is_empty(), "the meter did not see the work: nothing to record");
|
||||
// Nor does a zero displace a measured decision…
|
||||
p.record(key, Cost { backend: Backend::Gpu, uj: 500, ns: 100 });
|
||||
p.record(key, Cost { backend: Backend::Cpu, uj: 0, ns: 50 });
|
||||
assert_eq!(p.cost(key), Some(Cost { backend: Backend::Gpu, uj: 500, ns: 100 }));
|
||||
// …while a real, lower measurement still wins the shape.
|
||||
p.record(key, Cost { backend: Backend::Cpu, uj: 300, ns: 900 });
|
||||
assert_eq!(p.choose(key), Some(Backend::Cpu));
|
||||
// The process-global profile goes through the same gate. Unique dims.
|
||||
let key = ShapeKey::new(1, 999, 333);
|
||||
record_global(key, Cost { backend: Backend::Cpu, uj: 0, ns: 5 });
|
||||
assert_eq!(decide_global(key), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tune_and_record_records_nothing_when_the_winner_drew_zero_joules() {
|
||||
let key = ShapeKey::new(1, 48, 48);
|
||||
let mut p = TuningProfile::new();
|
||||
let chosen = p.tune_and_record(key, &[Backend::Gpu, Backend::Cpu], 1, |b| match b {
|
||||
Backend::Gpu => (500, 1_000),
|
||||
Backend::Cpu => (0, 1_500), // the CPU's window never registered on the meter
|
||||
});
|
||||
assert_eq!(chosen, None);
|
||||
assert!(p.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmeasured_candidate_voids_the_tune() {
|
||||
// The meter saw the GPU's window but not the CPU's: comparing them would decide
|
||||
// by a missing figure, so there is no decision at all.
|
||||
let tuned = tune_measured(&[Backend::Gpu, Backend::Cpu], 1, |b| match b {
|
||||
Backend::Gpu => Some((500, 1_000)),
|
||||
Backend::Cpu => None,
|
||||
});
|
||||
assert_eq!(tuned, None);
|
||||
// Every window measured: the ordinary joules-minimal tune, averaged over reps.
|
||||
let tuned = tune_measured(&[Backend::Gpu, Backend::Cpu], 2, |b| match b {
|
||||
Backend::Gpu => Some((500, 1_000)),
|
||||
Backend::Cpu => Some((300, 1_500)),
|
||||
});
|
||||
assert_eq!(tuned, Some(Cost { backend: Backend::Cpu, uj: 300, ns: 1_500 }));
|
||||
// A backend that cannot run the shape still never wins, even over reps.
|
||||
let tuned = tune_measured(&[Backend::Gpu, Backend::Cpu], 2, |b| match b {
|
||||
Backend::Gpu => Some((u64::MAX, u64::MAX)),
|
||||
Backend::Cpu => Some((300, 1_500)),
|
||||
});
|
||||
assert_eq!(tuned.map(|c| c.backend), Some(Backend::Cpu));
|
||||
}
|
||||
|
||||
fn hw(meter: &str) -> Metered {
|
||||
Metered { meter: meter.into(), energy_provenance: "HwShunt".into(), domain: "SocTotal".into() }
|
||||
}
|
||||
|
||||
fn graded(grade: &str) -> Metered {
|
||||
Metered { meter: "m".into(), energy_provenance: grade.into(), domain: "SocTotal".into() }
|
||||
}
|
||||
|
||||
/// A cost recorded with no meter — as every cost was before the IOReport counter
|
||||
/// had to show its CPU rail live, when a frozen rail could meter CPU work as a
|
||||
/// sliver of GPU energy — is replaced by a measurement on a recorded meter, even
|
||||
/// one that drew more joules: the decision is re-made on the better evidence. It
|
||||
/// never comes back: an unrecorded cost cannot displace a metered one.
|
||||
#[test]
|
||||
fn a_cost_recorded_without_its_meter_is_replaced_by_a_metered_one() {
|
||||
let key = ShapeKey::new(1, 64, 96);
|
||||
let mut p = TuningProfile::new();
|
||||
p.record(key, Cost { backend: Backend::Cpu, uj: 5, ns: 100 });
|
||||
assert_eq!((p.choose(key), p.metered(key)), (Some(Backend::Cpu), None));
|
||||
p.record_metered(key, Cost { backend: Backend::Gpu, uj: 500, ns: 100 }, hw("apple-ioreport"));
|
||||
assert_eq!(p.choose(key), Some(Backend::Gpu), "a metered measurement did not replace an unrecorded one");
|
||||
assert_eq!(p.metered(key), Some(&hw("apple-ioreport")));
|
||||
p.record(key, Cost { backend: Backend::Cpu, uj: 1, ns: 100 });
|
||||
assert_eq!(p.choose(key), Some(Backend::Gpu), "an unrecorded cost displaced a metered one");
|
||||
// Within a standing, joules-minimal as before: a lower hardware reading wins…
|
||||
p.record_metered(key, Cost { backend: Backend::Cpu, uj: 300, ns: 900 }, hw("apple-ioreport"));
|
||||
assert_eq!(p.cost(key), Some(Cost { backend: Backend::Cpu, uj: 300, ns: 900 }));
|
||||
// …and a cheaper figure of lower standing does not.
|
||||
p.record_metered(key, Cost { backend: Backend::Gpu, uj: 2, ns: 1 }, graded("Estimator"));
|
||||
p.record_metered(key, Cost { backend: Backend::Gpu, uj: 2, ns: 1 }, graded("ModelBased"));
|
||||
assert_eq!(p.cost(key), Some(Cost { backend: Backend::Cpu, uj: 300, ns: 900 }));
|
||||
// Standings rank hardware > vendor model > estimate > no meter.
|
||||
let key = ShapeKey::new(1, 64, 98);
|
||||
let mut p = TuningProfile::new();
|
||||
for (grade, uj) in [("Estimator", 900), ("ModelBased", 950), ("HwShunt", 990)] {
|
||||
p.record_metered(key, Cost { backend: Backend::Cpu, uj, ns: 1 }, graded(grade));
|
||||
assert_eq!(p.metered(key).map(|m| m.energy_provenance.as_str()), Some(grade), "{grade} did not take the shape");
|
||||
}
|
||||
}
|
||||
|
||||
/// A zero the shape holds — an older build recorded them, and nothing reads energy
|
||||
/// as zero — is replaced by the next figure of any standing.
|
||||
#[test]
|
||||
fn a_zero_the_shape_holds_is_replaced() {
|
||||
let key = ShapeKey::new(1, 64, 100);
|
||||
for metered in [None, Some(hw("apple-ioreport"))] {
|
||||
let mut p = TuningProfile::new();
|
||||
p.chosen.insert(key, (Cost { backend: Backend::Cpu, uj: 0, ns: 100 }, metered.clone()));
|
||||
p.record(key, Cost { backend: Backend::Gpu, uj: 700, ns: 100 });
|
||||
assert_eq!(p.cost(key).map(|c| (c.backend, c.uj)), Some((Backend::Gpu, 700)), "held zero, meter {metered:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A live IOReport counter's costs are recorded, and sealed, as hardware readings
|
||||
/// over the SoC — the counter's own name, grade and domain; an estimator's as
|
||||
/// estimates.
|
||||
#[cfg(all(feature = "fabric-meter", target_os = "macos"))]
|
||||
#[test]
|
||||
fn a_meter_is_recorded_as_its_name_grade_and_domain() {
|
||||
let ioreport = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
|
||||
assert_eq!(Metered::of(&ioreport), hw("apple-ioreport"));
|
||||
let estimator = jouleclaw_energy::meter::EstimationMeter::new(65.0);
|
||||
assert_eq!(
|
||||
Metered::of(&estimator),
|
||||
Metered { meter: "estimation".into(), energy_provenance: "Estimator".into(), domain: "SocTotal".into() }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_profile_records_and_is_consulted() {
|
||||
// Odd, unique dims so parallel tests never collide on the shared global.
|
||||
|
|
@ -357,9 +631,12 @@ mod tests {
|
|||
#[test]
|
||||
fn seal_profile_produces_a_verifiable_autotune_receipt() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
use serde_json::json;
|
||||
// Unique dims so parallel tests don't collide on the shared global.
|
||||
let key = ShapeKey::new(1, 4242, 2424);
|
||||
record_global(key, Cost { backend: Backend::Gpu, uj: 6000, ns: 200_000 });
|
||||
record_global_metered(key, Cost { backend: Backend::Gpu, uj: 6000, ns: 200_000 }, hw("apple-ioreport"));
|
||||
let unrecorded = ShapeKey::new(1, 4243, 2425);
|
||||
record_global(unrecorded, Cost { backend: Backend::Cpu, uj: 7000, ns: 200_000 });
|
||||
let sk = SigningKey::from_bytes(&[9u8; 32]);
|
||||
let steward = "did:jouleclaw:autotune-test";
|
||||
let env = seal_profile(&sk, steward).unwrap();
|
||||
|
|
@ -369,9 +646,20 @@ mod tests {
|
|||
let payload = jouleclaw_jcr1::verify_cose_sign1(&env, steward, &pk).unwrap();
|
||||
let v = jouleclaw_jcr1::cbor_to_json(&payload).unwrap();
|
||||
assert_eq!(v["autotune_receipt"], "autotune/1");
|
||||
assert!(v["shapes"].as_array().unwrap().iter().any(|s| {
|
||||
s["n"] == 4242 && s["k"] == 2424 && s["backend"] == "gpu" && s["uj"] == 6000
|
||||
}));
|
||||
let shapes = v["shapes"].as_array().unwrap();
|
||||
let ours = shapes.iter().find(|s| s["n"] == 4242 && s["k"] == 2424).expect("our shape is sealed");
|
||||
assert_eq!((&ours["backend"], &ours["uj"]), (&json!("gpu"), &json!(6000)));
|
||||
// What the joules are: the meter, its grade and its domain, in the signed core.
|
||||
assert_eq!(
|
||||
(&ours["meter"], &ours["energy_provenance"], &ours["domain"]),
|
||||
(&json!("apple-ioreport"), &json!("HwShunt"), &json!("SocTotal"))
|
||||
);
|
||||
let theirs = shapes.iter().find(|s| s["n"] == 4243 && s["k"] == 2425).expect("the unrecorded shape is sealed");
|
||||
assert_eq!(
|
||||
(&theirs["meter"], &theirs["energy_provenance"], &theirs["domain"]),
|
||||
(&json!(UNRECORDED), &json!("Unrecorded"), &json!(UNRECORDED)),
|
||||
"a cost with no meter recorded is sealed as one"
|
||||
);
|
||||
|
||||
// A flipped byte fails verification.
|
||||
let mut tampered = env.clone();
|
||||
|
|
|
|||
|
|
@ -336,6 +336,13 @@ mod enabled {
|
|||
/// no matmul figure) — carries the calibration's
|
||||
/// provenance, it is NOT a per-op measurement.
|
||||
pub projected_uj: Option<f64>,
|
||||
/// The grade of the figure that priced `projected_uj`
|
||||
/// — `Measured`, `Benchmarked` or `Estimated`, as the
|
||||
/// member's matmul cost was loaded — or `None` exactly
|
||||
/// when `projected_uj` is. A projection is only as good
|
||||
/// as its weakest priced input, so a consumer grading
|
||||
/// one needs each input's grade, not just the sum.
|
||||
pub projected_grade: Option<jouleclaw_fabric::EnergyProvenance>,
|
||||
}
|
||||
|
||||
/// Snapshot the attribution ledger (per-member totals
|
||||
|
|
@ -344,16 +351,13 @@ mod enabled {
|
|||
let l = ledger().lock().unwrap();
|
||||
l.iter()
|
||||
.map(|(member, cell)| {
|
||||
let projected_uj = fabric().and_then(|f| {
|
||||
f.get(member)
|
||||
.and_then(|m| m.energy.get("matmul"))
|
||||
.map(|c| c.uj_per_gflop * cell.gflops)
|
||||
});
|
||||
let cost = fabric().and_then(|f| f.get(member).and_then(|m| m.energy.get("matmul")));
|
||||
LedgerEntry {
|
||||
member_id: member.to_string(),
|
||||
ops: cell.ops,
|
||||
gflops: cell.gflops,
|
||||
projected_uj,
|
||||
projected_uj: cost.map(|c| c.uj_per_gflop * cell.gflops),
|
||||
projected_grade: cost.map(|c| c.provenance),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
|
@ -426,11 +430,19 @@ mod enabled {
|
|||
|
||||
/// Tune one shape **live** against the real backends: run `matmul_bf16_amx` and
|
||||
/// `matmul_bf16_gpu` under the IOReport meter and record the **joules-minimal**
|
||||
/// choice into the global profile. `a`/`w` are representative inputs of the
|
||||
/// choice into the global profile, with the meter that measured it (its name, its
|
||||
/// readings' grade — `HwShunt`, the counter being live — and its domain), so the
|
||||
/// record outranks any cost the shape holds with no meter recorded. `a`/`w` are representative inputs of the
|
||||
/// shape; a warmup pass tunes each shape a model uses, then the run is launched
|
||||
/// with `JOULECLAW_ENERGY_AUTOTUNE=1`. Energy is honest marginal draw
|
||||
/// (`ΔE − P_idle·Δt`), averaged over a `window_secs` loop past the counter's
|
||||
/// update window. Returns the winning cost, or `None` if the meter is absent.
|
||||
/// update window. Returns the winning cost, or `None` — recording nothing — when
|
||||
/// the meter cannot measure: the IOReport counter is unavailable (including an
|
||||
/// Apple Silicon host whose CPU energy rail does not advance, see
|
||||
/// `jouleclaw_energy::ioreport_status`), a read fails, the idle baseline does not
|
||||
/// advance, or either backend's window draws no energy above idle
|
||||
/// ([`crate::marginal_energy`]). An unmeasured backend never wins, or loses, a
|
||||
/// tune-off against a measured one.
|
||||
#[cfg(feature = "fabric-meter")]
|
||||
pub fn tune_shape_live(
|
||||
a: &[f32],
|
||||
|
|
@ -440,7 +452,8 @@ mod enabled {
|
|||
k: usize,
|
||||
window_secs: f64,
|
||||
) -> Option<crate::energy_autotune::Cost> {
|
||||
use crate::energy_autotune::{record_global, tune_shape, Backend, Cost, ShapeKey};
|
||||
use crate::energy_autotune::{record_global_metered, tune_measured, Backend, Cost, Metered, ShapeKey};
|
||||
use crate::marginal_energy::{idle_draw_uw, marginal_uj, per_op_uj};
|
||||
use jouleclaw_energy::EnergyCounter;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
|
@ -448,27 +461,28 @@ mod enabled {
|
|||
if !counter.is_available() {
|
||||
return None;
|
||||
}
|
||||
let read = || counter.read().ok().map(|r| r.uj);
|
||||
// Idle baseline, so we charge each kernel its *marginal* energy, not the
|
||||
// whole SoC's — the same honest figure the mpp_energy example reports.
|
||||
let idle_uw = {
|
||||
let e0 = counter.read().ok()?.uj;
|
||||
let e0 = read()?;
|
||||
let t0 = Instant::now();
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let e1 = counter.read().ok()?.uj;
|
||||
e1.saturating_sub(e0) as f64 / t0.elapsed().as_secs_f64().max(1e-9)
|
||||
let e1 = read()?;
|
||||
idle_draw_uw(e0, e1, t0.elapsed().as_secs_f64()).ok()?
|
||||
};
|
||||
|
||||
let mut out = vec![0f32; m * n];
|
||||
let mut measure = |backend: Backend| -> (u64, u64) {
|
||||
let measure = |backend: Backend| -> Option<(u64, u64)> {
|
||||
let run = |out: &mut [f32]| match backend {
|
||||
Backend::Cpu => matmul_bf16_amx(a, w, &[], m, n, k, out),
|
||||
Backend::Gpu => matmul_bf16_gpu(a, w, &[], m, n, k, out),
|
||||
};
|
||||
// A backend that refuses this shape can never win the tune-off.
|
||||
if !run(&mut out) {
|
||||
return (u64::MAX, u64::MAX);
|
||||
return Some((u64::MAX, u64::MAX));
|
||||
}
|
||||
let e0 = counter.read().map(|r| r.uj).unwrap_or(0);
|
||||
let e0 = read()?;
|
||||
let t0 = Instant::now();
|
||||
let mut ops = 0u64;
|
||||
while t0.elapsed().as_secs_f64() < window_secs {
|
||||
|
|
@ -476,14 +490,14 @@ mod enabled {
|
|||
ops += 1;
|
||||
}
|
||||
let dt = t0.elapsed().as_secs_f64();
|
||||
let e1 = counter.read().map(|r| r.uj).unwrap_or(e0);
|
||||
let e1 = read()?;
|
||||
let ops = ops.max(1);
|
||||
let marginal = (e1.saturating_sub(e0) as f64 - idle_uw * dt).max(0.0);
|
||||
((marginal / ops as f64) as u64, (dt / ops as f64 * 1e9) as u64)
|
||||
let uj = marginal_uj(e0, e1, dt, idle_uw).and_then(|mj| per_op_uj(mj, ops)).ok()?;
|
||||
Some((uj, (dt / ops as f64 * 1e9) as u64))
|
||||
};
|
||||
|
||||
let cost: Cost = tune_shape(&[Backend::Cpu, Backend::Gpu], 1, &mut measure)?;
|
||||
record_global(ShapeKey::new(m, n, k), cost);
|
||||
let cost: Cost = tune_measured(&[Backend::Cpu, Backend::Gpu], 1, measure)?;
|
||||
record_global_metered(ShapeKey::new(m, n, k), cost, Metered::of(&counter));
|
||||
Some(cost)
|
||||
}
|
||||
|
||||
|
|
@ -1379,6 +1393,37 @@ pub fn matmul_bf16_auto(
|
|||
#[cfg(all(test, feature = "fabric", target_os = "macos", target_arch = "aarch64"))]
|
||||
mod tests {
|
||||
|
||||
/// On this host, with the IOReport meter (`fabric-meter`): a live tune records a
|
||||
/// shape only when the meter measured every backend's window, never a zero, and
|
||||
/// what it returns is exactly what it recorded. Where the counter is declined —
|
||||
/// macOS 27.0.1 on an M5 Max and an M3 Ultra, whose CPU energy rail does not
|
||||
/// advance — it returns `None` and the global profile gains nothing for the shape.
|
||||
/// It used to fall to `(ΔE − P_idle·Δt).max(0) = 0` µJ per op on a frozen rail and
|
||||
/// record that zero, which then beat every later measurement.
|
||||
#[cfg(feature = "fabric-meter")]
|
||||
#[test]
|
||||
fn a_live_tune_records_only_what_the_meter_measured() {
|
||||
use crate::energy_autotune::{global_snapshot, ShapeKey};
|
||||
let status = jouleclaw_energy::ioreport_status();
|
||||
// Odd, unique dims so parallel tests never collide on the shared global profile.
|
||||
let (m, n, k) = (1usize, 48usize, 80usize);
|
||||
let a = vec![0.25f32; m * k];
|
||||
let w: Vec<u8> = (0..n * k)
|
||||
.flat_map(|i| crate::matmul_bf16::f32_to_bf16_rne(((i % 7) as f32 - 3.0) * 0.01).to_le_bytes())
|
||||
.collect();
|
||||
let tuned = super::tune_shape_live(&a, &w, m, n, k, 0.05);
|
||||
let key = ShapeKey::new(m, n, k);
|
||||
let recorded = global_snapshot().into_iter().find(|(s, _)| *s == key).map(|(_, c)| c);
|
||||
eprintln!("tune_shape_live on this host: ioreport={status:?} tuned={tuned:?} recorded={recorded:?}");
|
||||
assert_eq!(tuned, recorded, "a live tune records exactly what it returns");
|
||||
if let Some(cost) = recorded {
|
||||
assert!(cost.uj > 0 && cost.uj != u64::MAX, "recorded {cost:?}");
|
||||
}
|
||||
if status != jouleclaw_energy::IoReportStatus::Live {
|
||||
assert_eq!(tuned, None, "the IOReport counter is declined ({status:?}), so nothing is measured");
|
||||
}
|
||||
}
|
||||
|
||||
/// The attribution ledger records every dispatch with
|
||||
/// the member that actually ran it and the op's GFLOPs.
|
||||
#[test]
|
||||
|
|
@ -1400,6 +1445,11 @@ mod tests {
|
|||
.expect("the m=1 dispatch is attributed to cpu-neon");
|
||||
let expect_gf = 2.0 * (m * n * k) as f64 / 1e9;
|
||||
assert!(neon.ops >= 1 && neon.gflops >= expect_gf - 1e-9);
|
||||
// A projection and its grade come from the same cost row:
|
||||
// both present, or both absent.
|
||||
for e in &snap {
|
||||
assert_eq!(e.projected_uj.is_some(), e.projected_grade.is_some(), "{e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Certification: the Metal GPU member (resident-weight
|
||||
|
|
|
|||
|
|
@ -181,16 +181,14 @@ mod tests {
|
|||
use std::io::Write;
|
||||
|
||||
fn aligned_temp_mmap(len: usize) -> memmap2::Mmap {
|
||||
// A name unique within this process. A timestamp is not: it ticks in
|
||||
// microseconds on macOS, and parallel tests get here at once, so two
|
||||
// tests could get one file and truncate or delete it under each other.
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir();
|
||||
let pid = std::process::id();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = dir.join(format!(
|
||||
"jouleclaw-gpu-overlay-{}-{}.bin",
|
||||
pid, nanos
|
||||
));
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = dir.join(format!("jouleclaw-gpu-overlay-{pid}-{seq}.bin"));
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
pub mod mmap_advise;
|
||||
pub mod cpu_prefetch;
|
||||
pub mod energy_autotune;
|
||||
pub mod marginal_energy;
|
||||
pub mod huge_page;
|
||||
pub mod io_uring_prefetch;
|
||||
pub mod tier_store;
|
||||
|
|
@ -1369,17 +1370,15 @@ mod prefetch_policy_tests {
|
|||
use std::io::Write;
|
||||
|
||||
fn write_synth_gguf() -> std::path::PathBuf {
|
||||
// A name unique within this process. A timestamp is not: it ticks in
|
||||
// microseconds on macOS, and parallel tests get here at once, so two
|
||||
// tests could get one file and truncate or delete it under each other.
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let bytes = synthesize_llama_gguf(&SyntheticConfig::default());
|
||||
let dir = std::env::temp_dir();
|
||||
let pid = std::process::id();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = dir.join(format!(
|
||||
"jouleclaw-prefetch-test-{}-{}.gguf",
|
||||
pid, nanos
|
||||
));
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = dir.join(format!("jouleclaw-prefetch-test-{pid}-{seq}.gguf"));
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
f.write_all(&bytes).unwrap();
|
||||
f.sync_all().unwrap();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,412 @@
|
|||
//! Marginal energy on a machine-wide cumulative energy counter, and when there is none.
|
||||
//!
|
||||
//! The live autotuner ([`crate::fabric_dispatch`]'s `tune_shape_live`) and the
|
||||
//! calibration examples (`fabric_calibrate`, `mpp_energy`) price work the same way:
|
||||
//! sample the counter across an idle window for the idle draw, then across a busy
|
||||
//! window, and charge the work `ΔE_busy − P_idle·Δt`. That is a figure only if the
|
||||
//! counter metered both windows. A counter that does not advance at idle is not
|
||||
//! metering this machine (an SoC draws power doing nothing), and a busy window that
|
||||
//! draws nothing above idle means the meter did not see the work. Either way there is
|
||||
//! no figure, and nothing may be recorded from it: a zero, or a negative clamped to
|
||||
//! zero, would be written down as a measurement the meter never made.
|
||||
//!
|
||||
//! Pure arithmetic over raw readings, so the refusals are unit-tested without hardware.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Why a marginal-energy measurement has no figure.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum NotMeasured {
|
||||
/// The energy counter is not available on this host; the payload says why.
|
||||
CounterUnavailable(String),
|
||||
/// A read of the counter failed.
|
||||
ReadFailed(String),
|
||||
/// The cumulative counter went backwards across a window.
|
||||
CounterWentBackwards {
|
||||
/// Reading at the start of the window, µJ.
|
||||
before_uj: u64,
|
||||
/// Reading at the end of the window, µJ.
|
||||
after_uj: u64,
|
||||
},
|
||||
/// A window with no duration, or no operations in it.
|
||||
EmptyWindow,
|
||||
/// The counter did not advance across the idle window.
|
||||
IdleDidNotAdvance {
|
||||
/// Length of the idle window, seconds.
|
||||
secs: f64,
|
||||
},
|
||||
/// The busy window drew no energy above the idle draw.
|
||||
NoMarginal {
|
||||
/// `ΔE_busy − P_idle·Δt`, µJ — zero or negative.
|
||||
marginal_uj: f64,
|
||||
},
|
||||
/// The marginal energy per operation is below one microjoule, so it rounds to 0.
|
||||
BelowResolution {
|
||||
/// Marginal energy of the whole window, µJ.
|
||||
marginal_uj: f64,
|
||||
/// Operations in the window.
|
||||
ops: u64,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for NotMeasured {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::CounterUnavailable(why) => write!(
|
||||
f,
|
||||
"the energy counter is not available on this host ({why})"
|
||||
),
|
||||
Self::ReadFailed(e) => write!(f, "a read of the energy counter failed: {e}"),
|
||||
Self::CounterWentBackwards {
|
||||
before_uj,
|
||||
after_uj,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"the cumulative counter went backwards ({before_uj} µJ, then {after_uj} µJ)"
|
||||
)
|
||||
}
|
||||
Self::EmptyWindow => {
|
||||
f.write_str("the measurement window has no duration or no operations")
|
||||
}
|
||||
Self::IdleDidNotAdvance { secs } => write!(
|
||||
f,
|
||||
"the counter did not advance across {secs:.2} s of idle, so it is not metering this machine"
|
||||
),
|
||||
Self::NoMarginal { marginal_uj } => write!(
|
||||
f,
|
||||
"the busy window drew {marginal_uj:.0} µJ above the idle draw: the meter did not see the work"
|
||||
),
|
||||
Self::BelowResolution { marginal_uj, ops } => {
|
||||
write!(f, "{marginal_uj:.0} µJ over {ops} ops is below 1 µJ per op")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NotMeasured {}
|
||||
|
||||
/// The energy a window drew, from the counter's reading at its start and end.
|
||||
fn window_uj(before_uj: u64, after_uj: u64, secs: f64) -> Result<u64, NotMeasured> {
|
||||
if !(secs.is_finite() && secs > 0.0) {
|
||||
return Err(NotMeasured::EmptyWindow);
|
||||
}
|
||||
after_uj
|
||||
.checked_sub(before_uj)
|
||||
.ok_or(NotMeasured::CounterWentBackwards {
|
||||
before_uj,
|
||||
after_uj,
|
||||
})
|
||||
}
|
||||
|
||||
/// The idle draw in µW, from the counter's readings before and after `secs` of idle.
|
||||
/// Refused when the counter did not advance.
|
||||
pub fn idle_draw_uw(before_uj: u64, after_uj: u64, secs: f64) -> Result<f64, NotMeasured> {
|
||||
match window_uj(before_uj, after_uj, secs)? {
|
||||
0 => Err(NotMeasured::IdleDidNotAdvance { secs }),
|
||||
delta => Ok(delta as f64 / secs),
|
||||
}
|
||||
}
|
||||
|
||||
/// The marginal energy of a busy window, `ΔE − idle_uw·secs`, in µJ. Refused unless it
|
||||
/// is positive: never clamped to zero.
|
||||
pub fn marginal_uj(
|
||||
before_uj: u64,
|
||||
after_uj: u64,
|
||||
secs: f64,
|
||||
idle_uw: f64,
|
||||
) -> Result<f64, NotMeasured> {
|
||||
let delta = window_uj(before_uj, after_uj, secs)?;
|
||||
let marginal = delta as f64 - idle_uw * secs;
|
||||
if marginal.is_finite() && marginal > 0.0 {
|
||||
Ok(marginal)
|
||||
} else {
|
||||
Err(NotMeasured::NoMarginal {
|
||||
marginal_uj: marginal,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole microjoules per operation of a window's marginal energy. Refused when it would
|
||||
/// round to zero, so a per-op cost is never recorded as 0.
|
||||
pub fn per_op_uj(marginal_uj: f64, ops: u64) -> Result<u64, NotMeasured> {
|
||||
if ops == 0 {
|
||||
return Err(NotMeasured::EmptyWindow);
|
||||
}
|
||||
let per_op = marginal_uj / ops as f64;
|
||||
if !(per_op.is_finite() && per_op > 0.0) {
|
||||
return Err(NotMeasured::NoMarginal { marginal_uj });
|
||||
}
|
||||
match per_op as u64 {
|
||||
0 => Err(NotMeasured::BelowResolution { marginal_uj, ops }),
|
||||
uj => Ok(uj),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ratio of two marginal-energy figures, or `None` unless both are positive and
|
||||
/// finite: a ratio against an unmeasured figure is undefined, not `NaN` or `inf`.
|
||||
pub fn energy_ratio(numerator: f64, denominator: f64) -> Option<f64> {
|
||||
let measured = |x: f64| x.is_finite() && x > 0.0;
|
||||
(measured(numerator) && measured(denominator)).then(|| numerator / denominator)
|
||||
}
|
||||
|
||||
/// What a calibration example prints before it exits non-zero having written nothing:
|
||||
/// the file it refused to write, why the figure it needed is not a measurement, and
|
||||
/// the IOReport status (`jouleclaw_energy::ioreport_status`), which says whether the
|
||||
/// counter is metering this host at all and, if not, why. Every figure in that file is
|
||||
/// loaded as a measurement, so a refusal must leave it untouched.
|
||||
pub fn refusal(path: &str, why: impl fmt::Display, ioreport_status: impl fmt::Debug) -> String {
|
||||
format!(
|
||||
"refusing to write {path}: {why}\nIOReport status: {ioreport_status:?}; nothing was written \
|
||||
(its figures are loaded as measurements)"
|
||||
)
|
||||
}
|
||||
|
||||
// ── Calibration rows (the `fabric-meter` feature) ──────────────────────────────
|
||||
|
||||
/// The fabric grade of a calibration figure read on a meter whose readings carry
|
||||
/// `meter`. A hardware reading is `Measured` — and a counter that is not live produces
|
||||
/// no reading at all, so `HwShunt` here means the meter was live for its domain. A
|
||||
/// vendor-model reading is a recorded run on the member, not a measurement
|
||||
/// (`Benchmarked`); an estimator's is `Estimated`. Never upgraded.
|
||||
#[cfg(feature = "fabric-meter")]
|
||||
pub fn calibration_grade(meter: jouleclaw_energy::Provenance) -> jouleclaw_fabric::EnergyProvenance {
|
||||
use jouleclaw_energy::Provenance as Meter;
|
||||
use jouleclaw_fabric::EnergyProvenance as Grade;
|
||||
match meter {
|
||||
Meter::HwShunt => Grade::Measured,
|
||||
Meter::ModelBased => Grade::Benchmarked,
|
||||
Meter::Estimator => Grade::Estimated,
|
||||
}
|
||||
}
|
||||
|
||||
/// A calibration row: `op` on `member` at `uj_per_gflop`, graded from the meter that
|
||||
/// read it ([`calibration_grade`]), with `method`, and stamped with when it was taken
|
||||
/// and on what OS and chip — so a row can be told apart from one taken on another
|
||||
/// machine or OS, where the same counter may not be live.
|
||||
#[cfg(feature = "fabric-meter")]
|
||||
pub fn calibration_row(
|
||||
member: &str,
|
||||
op: &str,
|
||||
uj_per_gflop: f64,
|
||||
meter: jouleclaw_energy::Provenance,
|
||||
method: String,
|
||||
) -> jouleclaw_fabric::CalibrationEntry {
|
||||
let date = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|d| rfc3339_utc(d.as_secs()));
|
||||
jouleclaw_fabric::CalibrationEntry {
|
||||
member_id: member.to_string(),
|
||||
op: op.to_string(),
|
||||
uj_per_gflop,
|
||||
method,
|
||||
provenance: Some(calibration_grade(meter)),
|
||||
date,
|
||||
os: host_os(),
|
||||
chip: host_chip(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first line a command prints, trimmed, if it ran and printed one.
|
||||
#[cfg(feature = "fabric-meter")]
|
||||
fn command_line(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = std::process::Command::new(program).args(args).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let line = String::from_utf8(out.stdout).ok()?.lines().next()?.trim().to_string();
|
||||
(!line.is_empty()).then_some(line)
|
||||
}
|
||||
|
||||
/// This host's OS and version, e.g. `"macOS 27.0.1 (26A434)"`; elsewhere the OS name.
|
||||
#[cfg(feature = "fabric-meter")]
|
||||
fn host_os() -> Option<String> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let name = command_line("sw_vers", &["-productName"])?;
|
||||
let version = command_line("sw_vers", &["-productVersion"])?;
|
||||
Some(match command_line("sw_vers", &["-buildVersion"]) {
|
||||
Some(build) => format!("{name} {version} ({build})"),
|
||||
None => format!("{name} {version}"),
|
||||
})
|
||||
} else {
|
||||
Some(std::env::consts::OS.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// This host's chip, e.g. `"Apple M5 Max"`, where the OS names it.
|
||||
#[cfg(feature = "fabric-meter")]
|
||||
fn host_chip() -> Option<String> {
|
||||
if cfg!(target_os = "macos") {
|
||||
command_line("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `secs` since the Unix epoch as an RFC 3339 UTC timestamp (proleptic Gregorian).
|
||||
#[cfg(any(test, feature = "fabric-meter"))]
|
||||
fn rfc3339_utc(secs: u64) -> String {
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
// Civil date from days since 1970-01-01 (Howard Hinnant's `civil_from_days`).
|
||||
let z = days as i64 + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
|
||||
rem / 3_600,
|
||||
rem % 3_600 / 60,
|
||||
rem % 60
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_counter_that_holds_at_idle_has_no_idle_baseline() {
|
||||
// A frozen rail reads the same before and after: not metering, so no baseline.
|
||||
assert_eq!(
|
||||
idle_draw_uw(480_083_160, 480_083_160, 3.0),
|
||||
Err(NotMeasured::IdleDidNotAdvance { secs: 3.0 })
|
||||
);
|
||||
assert_eq!(
|
||||
idle_draw_uw(2_000, 1_000, 3.0),
|
||||
Err(NotMeasured::CounterWentBackwards {
|
||||
before_uj: 2_000,
|
||||
after_uj: 1_000
|
||||
})
|
||||
);
|
||||
assert_eq!(idle_draw_uw(0, 5, 0.0), Err(NotMeasured::EmptyWindow));
|
||||
// A live counter: 6 J over 3 s is a 2 W idle draw.
|
||||
assert_eq!(idle_draw_uw(1_000, 6_001_000, 3.0), Ok(2_000_000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_busy_window_at_or_below_idle_is_refused_never_clamped_to_zero() {
|
||||
let idle_uw = 2_000_000.0; // 2 W
|
||||
// 10 J over 2 s at a 2 W idle: 6 J of marginal energy.
|
||||
assert_eq!(marginal_uj(0, 10_000_000, 2.0, idle_uw), Ok(6_000_000.0));
|
||||
// Exactly idle, below idle, and a counter that did not move at all: no figure.
|
||||
assert_eq!(
|
||||
marginal_uj(0, 4_000_000, 2.0, idle_uw),
|
||||
Err(NotMeasured::NoMarginal { marginal_uj: 0.0 })
|
||||
);
|
||||
assert_eq!(
|
||||
marginal_uj(0, 3_000_000, 2.0, idle_uw),
|
||||
Err(NotMeasured::NoMarginal {
|
||||
marginal_uj: -1_000_000.0
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
marginal_uj(7, 7, 2.0, idle_uw),
|
||||
Err(NotMeasured::NoMarginal {
|
||||
marginal_uj: -4_000_000.0
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
marginal_uj(0, 10_000_000, 2.0, f64::NAN),
|
||||
Err(NotMeasured::NoMarginal { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
marginal_uj(9, 8, 2.0, idle_uw),
|
||||
Err(NotMeasured::CounterWentBackwards {
|
||||
before_uj: 9,
|
||||
after_uj: 8
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_per_op_cost_never_rounds_to_zero() {
|
||||
assert_eq!(per_op_uj(6_000_000.0, 1_000), Ok(6_000));
|
||||
assert_eq!(
|
||||
per_op_uj(500.0, 1_000),
|
||||
Err(NotMeasured::BelowResolution {
|
||||
marginal_uj: 500.0,
|
||||
ops: 1_000
|
||||
})
|
||||
);
|
||||
assert_eq!(per_op_uj(6_000_000.0, 0), Err(NotMeasured::EmptyWindow));
|
||||
assert_eq!(
|
||||
per_op_uj(0.0, 10),
|
||||
Err(NotMeasured::NoMarginal { marginal_uj: 0.0 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cpu_gpu_ratio_is_undefined_unless_both_are_measured() {
|
||||
assert_eq!(energy_ratio(6.0, 3.0), Some(2.0));
|
||||
// Frozen rails left both marginals at zero: the old ratio was 0/0 = NaN.
|
||||
assert_eq!(energy_ratio(0.0, 0.0), None);
|
||||
assert_eq!(energy_ratio(5.0, 0.0), None, "was +inf");
|
||||
assert_eq!(energy_ratio(-1.0, 3.0), None);
|
||||
assert_eq!(energy_ratio(f64::NAN, 3.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_names_the_file_the_reason_and_the_ioreport_status() {
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
enum Status {
|
||||
CpuRailFrozen,
|
||||
}
|
||||
let msg = refusal(
|
||||
"/tmp/x/fabric-calibration.json",
|
||||
NotMeasured::IdleDidNotAdvance { secs: 3.0 },
|
||||
Status::CpuRailFrozen,
|
||||
);
|
||||
assert!(msg.starts_with("refusing to write /tmp/x/fabric-calibration.json: "), "{msg}");
|
||||
assert!(msg.contains("did not advance across 3.00 s of idle"), "{msg}");
|
||||
assert!(msg.contains("IOReport status: CpuRailFrozen"), "{msg}");
|
||||
assert!(msg.contains("nothing was written"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_says_why() {
|
||||
let why = NotMeasured::CounterUnavailable("IOReport: CpuRailFrozen".into()).to_string();
|
||||
assert!(
|
||||
why.contains("not available") && why.contains("CpuRailFrozen"),
|
||||
"{why}"
|
||||
);
|
||||
let why = NotMeasured::NoMarginal { marginal_uj: -12.0 }.to_string();
|
||||
assert!(why.contains("did not see the work"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calibration_row_is_dated_in_rfc3339_utc() {
|
||||
assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(rfc3339_utc(951_782_400), "2000-02-29T00:00:00Z");
|
||||
assert_eq!(rfc3339_utc(1_790_000_000), "2026-09-21T14:13:20Z");
|
||||
assert_eq!(rfc3339_utc(4_102_444_799), "2099-12-31T23:59:59Z");
|
||||
}
|
||||
|
||||
/// Only a hardware reading becomes a `Measured` row; nothing is upgraded.
|
||||
#[cfg(feature = "fabric-meter")]
|
||||
#[test]
|
||||
fn only_a_hardware_reading_is_a_measured_calibration_row() {
|
||||
use jouleclaw_energy::Provenance as Meter;
|
||||
use jouleclaw_fabric::EnergyProvenance as Grade;
|
||||
assert_eq!(calibration_grade(Meter::HwShunt), Grade::Measured);
|
||||
assert_eq!(calibration_grade(Meter::ModelBased), Grade::Benchmarked);
|
||||
assert_eq!(calibration_grade(Meter::Estimator), Grade::Estimated);
|
||||
let row = calibration_row("local/amx", "matmul", 7.5, Meter::Estimator, "m".into());
|
||||
assert_eq!(
|
||||
(row.member_id.as_str(), row.op.as_str(), row.uj_per_gflop, row.provenance, row.method.as_str()),
|
||||
("local/amx", "matmul", 7.5, Some(Grade::Estimated), "m")
|
||||
);
|
||||
assert!(row.date.as_deref().is_some_and(|d| d.len() == 20 && d.ends_with('Z')), "{:?}", row.date);
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(row.os.as_deref().is_some_and(|os| os.starts_with("macOS ")), "{:?}", row.os);
|
||||
assert!(row.chip.is_some(), "the chip was not recorded");
|
||||
}
|
||||
eprintln!("a calibration row on this host: date={:?} os={:?} chip={:?}", row.date, row.os, row.chip);
|
||||
}
|
||||
}
|
||||
|
|
@ -253,16 +253,14 @@ mod tests {
|
|||
|
||||
fn tempfile_compat() -> std::fs::File {
|
||||
// Minimal tempfile shim — keep dep surface tiny.
|
||||
// A name unique within this process. A timestamp is not: it ticks in
|
||||
// microseconds on macOS, and parallel tests get here at once, so two
|
||||
// tests could get one file and truncate or delete it under each other.
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir();
|
||||
let pid = std::process::id();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = dir.join(format!(
|
||||
"jouleclaw-mmap-advise-{}-{}.bin",
|
||||
pid, nanos
|
||||
));
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = dir.join(format!("jouleclaw-mmap-advise-{pid}-{seq}.bin"));
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
|
|
|
|||
|
|
@ -467,12 +467,13 @@ mod tests {
|
|||
// promote/demote helpers exercise the lookup path.
|
||||
let bytes = synthesize_llama_gguf(&SyntheticConfig::default());
|
||||
let dir = std::env::temp_dir();
|
||||
// A name unique within this process. A timestamp is not: it ticks in
|
||||
// microseconds on macOS, and parallel tests get here at once, so two
|
||||
// tests could get one file and truncate or delete it under each other.
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let pid = std::process::id();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = dir.join(format!("jouleclaw-moe-test-{pid}-{nanos}.gguf"));
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = dir.join(format!("jouleclaw-moe-test-{pid}-{seq}.gguf"));
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let model = crate::read_gguf_file(&path).unwrap();
|
||||
let data_offset = match &model.buf {
|
||||
|
|
|
|||
|
|
@ -716,17 +716,15 @@ mod tests {
|
|||
use std::io::Write;
|
||||
|
||||
fn write_synth_gguf() -> PathBuf {
|
||||
// A name unique within this process. A timestamp is not: it ticks in
|
||||
// microseconds on macOS, and parallel tests get here at once, so two
|
||||
// tests could get one file and truncate or delete it under each other.
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let bytes = synthesize_llama_gguf(&SyntheticConfig::default());
|
||||
let dir = std::env::temp_dir();
|
||||
let pid = std::process::id();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = dir.join(format!(
|
||||
"jouleclaw-tier-store-{}-{}.gguf",
|
||||
pid, nanos
|
||||
));
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = dir.join(format!("jouleclaw-tier-store-{pid}-{seq}.gguf"));
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
f.write_all(&bytes).unwrap();
|
||||
f.sync_all().unwrap();
|
||||
|
|
|
|||
|
|
@ -114,9 +114,12 @@ impl<I: LocalIndex> LocalIndexTier<I> {
|
|||
/// The L1 envelope is a JouleClaw static cost model — the donor
|
||||
/// measured ~890 µJ on its tantivy reference path but no hardware
|
||||
/// shunt is sampled here, so [`Provenance::Estimator`] is the
|
||||
/// honest label. Consumers whose backend exposes a real shunt
|
||||
/// (NVML, IOReport) SHOULD surface that separately through the
|
||||
/// `jouleclaw-prov` receipt path.
|
||||
/// honest label. Consumers whose backend reads a vendor energy
|
||||
/// counter (RAPL, NVML, Apple IOReport) SHOULD surface that
|
||||
/// reading separately through the `jouleclaw-prov` receipt path,
|
||||
/// graded as a hardware reading only while the counter is live for
|
||||
/// the domain the figure claims (JouleClaw SPEC §1.3); a counter
|
||||
/// that is present but not live gives no hardware-graded figure.
|
||||
pub const fn provenance() -> Provenance {
|
||||
Provenance::Estimator
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,9 +112,12 @@ impl<R: Reranker> RerankTier<R> {
|
|||
/// The L2.5 envelope is derived from the reranker's self-report
|
||||
/// (`typical_joules_per_doc * doc_count`), not from a hardware
|
||||
/// shunt sampled by JouleClaw, so [`Provenance::Estimator`] is the
|
||||
/// honest label. Consumers whose backend exposes a real shunt
|
||||
/// (NVML, IOReport) SHOULD surface that separately through the
|
||||
/// `jouleclaw-prov` receipt path.
|
||||
/// honest label. Consumers whose backend reads a vendor energy
|
||||
/// counter (RAPL, NVML, Apple IOReport) SHOULD surface that
|
||||
/// reading separately through the `jouleclaw-prov` receipt path,
|
||||
/// graded as a hardware reading only while the counter is live for
|
||||
/// the domain the figure claims (JouleClaw SPEC §1.3); a counter
|
||||
/// that is present but not live gives no hardware-graded figure.
|
||||
pub const fn provenance() -> Provenance {
|
||||
Provenance::Estimator
|
||||
}
|
||||
|
|
|
|||
1
jouledesk/jouledesk-rs/Cargo.lock
generated
1
jouledesk/jouledesk-rs/Cargo.lock
generated
|
|
@ -1262,6 +1262,7 @@ dependencies = [
|
|||
name = "jouledesk-github"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"jcp-efficiency",
|
||||
"jouleclaw-energy",
|
||||
"jouledesk",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -17,8 +17,13 @@ path = "src/main.rs"
|
|||
|
||||
[dependencies]
|
||||
jouledesk = { path = "../jouledesk" }
|
||||
# Real, measured energy for the work — Apple-Silicon IOReport on this box.
|
||||
# The host's best energy meter: Apple-Silicon IOReport where its CPU rail is live,
|
||||
# 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"] }
|
||||
serde_json = "1"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
tiny_http = "0.12"
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests read the sealed efficiency/1 receipt back to check its provenance.
|
||||
jcp-efficiency = { path = "../../../../jcp/jcp-rs/crates/jcp-efficiency" }
|
||||
|
|
|
|||
|
|
@ -2,13 +2,20 @@
|
|||
//!
|
||||
//! The chat/forge transforms in [`jouledesk::surface`] turn an event into a reply body
|
||||
//! offline. This crate closes the loop: it takes a real `@joule` mention, **does the
|
||||
//! work with a real model under a real energy meter**, **seals a verifiable
|
||||
//! `efficiency/1` at the measured cost**, and **posts the reply back through the GitHub
|
||||
//! API**. So the joules in the receipt are measured on the machine that did the work,
|
||||
//! not asserted — and a human can actually tag `@joule` and get an accountable answer.
|
||||
//! work with a real model under the best energy meter this host exposes**, **seals a
|
||||
//! verifiable `efficiency/1` at that meter's figure and provenance**, and **posts the
|
||||
//! reply back through the GitHub API**. The joules in the receipt come from the machine
|
||||
//! that did the work, not a constant: measured where the host exposes a live counter,
|
||||
//! estimated — and sealed and posted as an estimate — where it does not (Apple Silicon
|
||||
//! whose IOReport CPU rail does not advance, for one). A human can actually tag `@joule`
|
||||
//! and get an accountable answer.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use jouleclaw_energy::meter::CompositeMeter;
|
||||
use jouleclaw_energy::{EnergyReading, Provenance};
|
||||
use jouledesk::surface::{parse_event, Surface};
|
||||
use jouledesk::{Skill, SkillOutcome, TagOutcome, Teammate};
|
||||
use jouledesk::{EnergyProvenance, Skill, SkillOutcome, TagOutcome, Teammate};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// The Slack binding — the same core ([`do_work`] + [`seal`]) on a second surface.
|
||||
|
|
@ -18,61 +25,176 @@ pub mod slack;
|
|||
const DEFAULT_MODEL: &str =
|
||||
"/Volumes/macos_4TB_external_1/neuronexus_ai/model_library/gemma-4-E2B-q4_k_m.gguf";
|
||||
|
||||
/// The work product: a real answer and the **real energy it cost**, measured on this
|
||||
/// machine (not a constant).
|
||||
/// The model the public entry points ([`do_work`], [`handle`], [`slack::handle`]) run:
|
||||
/// `JOULEDESK_MODEL`, else [`DEFAULT_MODEL`]. Read once, at the entry point, and passed
|
||||
/// down, so the model that runs is the model the reply names.
|
||||
#[cfg(not(test))]
|
||||
fn configured_model() -> String {
|
||||
std::env::var("JOULEDESK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into())
|
||||
}
|
||||
|
||||
/// Unit tests pass the model path themselves. None may read `JOULEDESK_MODEL` — a
|
||||
/// process-global that tests running in parallel would race on — or reach the default
|
||||
/// model, which runs a real model through `llama-completion` on a host that has it.
|
||||
#[cfg(test)]
|
||||
fn configured_model() -> String {
|
||||
panic!("a unit test reached the configured model ({DEFAULT_MODEL} unless JOULEDESK_MODEL); pass a model path")
|
||||
}
|
||||
|
||||
/// The work product: a real answer and the energy this machine's meter puts on it (not
|
||||
/// a constant) — a reading or an estimate, as `provenance` says.
|
||||
pub struct Work {
|
||||
pub answer: String,
|
||||
/// The meter's figure for the work, in µJ: the delta between its readings before
|
||||
/// and after, as read, with no floor. A measurement only when `provenance` says so.
|
||||
/// `0` and meaningless when `provenance` is `unmetered`; such work is never sealed.
|
||||
pub measured_uj: u64,
|
||||
/// 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`…).
|
||||
pub meter: String,
|
||||
pub model: String,
|
||||
/// Why the meter produced no figure for the work, when `provenance` is `unmetered`.
|
||||
pub unmetered: Option<String>,
|
||||
}
|
||||
|
||||
/// Do the actual work for a mention — generate an answer with a real model, with a real
|
||||
/// hardware energy counter read around it so the cost is measured.
|
||||
/// `Work::provenance` for work whose meter produced no figure for it.
|
||||
const UNMETERED: &str = "unmetered";
|
||||
|
||||
/// Do the actual work for a mention — generate an answer with a real model, with the
|
||||
/// host's best energy meter read around it: measured where the host exposes a live
|
||||
/// counter, estimated where it does not, and the provenance says which.
|
||||
pub fn do_work(question: &str) -> Work {
|
||||
do_work_with(question, &configured_model())
|
||||
}
|
||||
|
||||
/// [`do_work`] with the model given as the path to a local GGUF; a path that does not
|
||||
/// exist answers with the deterministic fallback, and runs no subprocess.
|
||||
pub(crate) fn do_work_with(question: &str, model: &str) -> Work {
|
||||
let meter = jouleclaw_energy::meter::detect_meter();
|
||||
let before = meter.read().ok().map(|r| r.uj);
|
||||
let (answer, model) = match run_model(question) {
|
||||
Some(a) => (a, model_label()),
|
||||
let ((answer, model), figure) = metered(&meter, || match run_model(question, model) {
|
||||
Some(a) => (a, model_label(model)),
|
||||
None => (fallback_answer(question), "deterministic-extract".to_string()),
|
||||
});
|
||||
let (measured_uj, provenance, unmetered) = match figure {
|
||||
Ok((uj, provenance)) => (uj, provenance.to_string(), None),
|
||||
Err(why) => (0, UNMETERED.to_string(), Some(why)),
|
||||
};
|
||||
let after = meter.read().ok().map(|r| r.uj);
|
||||
let res = meter.active().map(|m| m.resolution_uj()).unwrap_or(1000);
|
||||
let provenance = meter
|
||||
.active()
|
||||
.map(|m| m.provenance().to_string())
|
||||
.unwrap_or_else(|| "estimator".into());
|
||||
// A cumulative counter: the work's cost is the delta, floored to what the meter can
|
||||
// resolve so we never seal below the counter's honest resolution.
|
||||
let measured_uj = match (before, after) {
|
||||
(Some(a), Some(b)) if b >= a => (b - a).max(res),
|
||||
_ => res,
|
||||
};
|
||||
Work { answer, measured_uj, provenance, meter: meter.active_name().to_string(), model }
|
||||
Work { answer, measured_uj, provenance, meter: meter.active_name().to_string(), model, unmetered }
|
||||
}
|
||||
|
||||
fn model_label() -> String {
|
||||
std::env::var("JOULEDESK_MODEL")
|
||||
.unwrap_or_else(|_| DEFAULT_MODEL.into())
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("model")
|
||||
.to_string()
|
||||
/// Run `work` under `meter`: its result, and the meter's figure for it (see
|
||||
/// [`window_energy`]) or why there is none.
|
||||
fn metered<T>(meter: &CompositeMeter, work: impl FnOnce() -> T) -> (T, Result<(u64, Provenance), String>) {
|
||||
let min_window = Duration::from_nanos(meter.active().map_or(u64::MAX, |m| m.min_window_ns()));
|
||||
let started = Instant::now();
|
||||
let before = meter.read().ok();
|
||||
let out = work();
|
||||
let after = meter.read().ok();
|
||||
let figure = window_energy(before.as_ref(), after.as_ref(), started.elapsed(), min_window);
|
||||
(out, figure)
|
||||
}
|
||||
|
||||
/// Generate an answer with a local model via `llama-completion` (the reference model
|
||||
/// hook — swap for Mesh / JouleClaw in production). `None` if no model is configured.
|
||||
fn run_model(question: &str) -> Option<String> {
|
||||
let model = std::env::var("JOULEDESK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
|
||||
if !std::path::Path::new(&model).exists() {
|
||||
/// What a window of work cost, from a cumulative meter's readings before and after it:
|
||||
/// the delta, with the provenance those readings carry — or why there is no honest
|
||||
/// figure: a read failed, the two readings disagree about provenance, the counter went
|
||||
/// backwards, the window was shorter than the meter says it can resolve
|
||||
/// (`min_window_ns`), or the meter registered no energy at all across the work. The
|
||||
/// delta is never floored to the meter's resolution (a floor fabricates energy the meter
|
||||
/// did not report), and nothing becomes a zero: no work runs on zero energy, so a zero
|
||||
/// means the meter did not see the work.
|
||||
fn window_energy(
|
||||
before: Option<&EnergyReading>,
|
||||
after: Option<&EnergyReading>,
|
||||
window: Duration,
|
||||
min_window: Duration,
|
||||
) -> Result<(u64, Provenance), String> {
|
||||
let (Some(before), Some(after)) = (before, after) else {
|
||||
return Err("a read of it failed".to_string());
|
||||
};
|
||||
if before.provenance != after.provenance {
|
||||
return Err(format!(
|
||||
"its readings disagree about provenance ({} then {})",
|
||||
before.provenance, after.provenance
|
||||
));
|
||||
}
|
||||
let Some(uj) = after.uj.checked_sub(before.uj) else {
|
||||
return Err(format!("its cumulative reading went backwards ({} µJ, then {} µJ)", before.uj, after.uj));
|
||||
};
|
||||
if window < min_window {
|
||||
return Err(format!(
|
||||
"the work took {}, shorter than the {} window it can resolve",
|
||||
span(window),
|
||||
span(min_window)
|
||||
));
|
||||
}
|
||||
if uj == 0 {
|
||||
return Err(format!("it registered no energy across {} of work", span(window)));
|
||||
}
|
||||
Ok((uj, after.provenance))
|
||||
}
|
||||
|
||||
/// A duration as a reply states it: µs below a millisecond, else ms.
|
||||
fn span(d: Duration) -> String {
|
||||
if d < Duration::from_millis(1) {
|
||||
format!("{} µs", d.as_micros())
|
||||
} else if d.subsec_nanos().is_multiple_of(1_000_000) {
|
||||
format!("{} ms", d.as_millis())
|
||||
} else {
|
||||
format!("{:.1} ms", d.as_secs_f64() * 1e3)
|
||||
}
|
||||
}
|
||||
|
||||
/// The provenance `work`'s energy is sealed at: its meter's, as the ledger schema names
|
||||
/// it. `None` for work with no figure, which has nothing to seal.
|
||||
fn ledger_provenance(work: &Work) -> Option<EnergyProvenance> {
|
||||
[Provenance::HwShunt, Provenance::ModelBased, Provenance::Estimator]
|
||||
.into_iter()
|
||||
.find(|p| p.to_string() == work.provenance)
|
||||
.map(|p| match p {
|
||||
Provenance::HwShunt => EnergyProvenance::HwShunt,
|
||||
Provenance::ModelBased => EnergyProvenance::ModelBased,
|
||||
Provenance::Estimator => EnergyProvenance::Estimator,
|
||||
})
|
||||
}
|
||||
|
||||
/// How `work`'s energy figure was obtained, as a reply states it: a reading was
|
||||
/// "measured via" its meter, an estimate "estimated by" it. `None` for work with no
|
||||
/// figure, which a reply never shows.
|
||||
fn obtained(work: &Work) -> Option<&'static str> {
|
||||
match ledger_provenance(work)? {
|
||||
EnergyProvenance::Estimator => Some("estimated by"),
|
||||
EnergyProvenance::HwShunt | EnergyProvenance::ModelBased => Some("measured via"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A reply's energy line for work the meter put no figure on, saying why.
|
||||
fn unmetered_line(work: &Work) -> String {
|
||||
let why = work.unmetered.as_deref().unwrap_or("it produced no figure");
|
||||
format!("not metered — `{}`: {why}; no energy figure was sealed", work.meter)
|
||||
}
|
||||
|
||||
/// The outcome of work that was not sealed because its meter put no figure on it.
|
||||
const NOT_SEALED: &str = "not sealed (no energy figure)";
|
||||
|
||||
/// The model a reply names: the file name of its path.
|
||||
fn model_label(model: &str) -> String {
|
||||
model.rsplit('/').next().unwrap_or("model").to_string()
|
||||
}
|
||||
|
||||
/// Generate an answer with the local model at `model` via `llama-completion` (the
|
||||
/// reference model hook — swap for Mesh / JouleClaw in production). `None` if there is
|
||||
/// no model file there.
|
||||
fn run_model(question: &str, model: &str) -> Option<String> {
|
||||
if !std::path::Path::new(model).exists() {
|
||||
return None;
|
||||
}
|
||||
let bin = std::env::var("JOULEDESK_LLAMA").unwrap_or_else(|_| "llama-completion".into());
|
||||
let prompt = format!("<start_of_turn>user\n{question}<end_of_turn>\n<start_of_turn>model\n");
|
||||
let out = std::process::Command::new(&bin)
|
||||
.args([
|
||||
"-m", &model, "-p", &prompt, "-n", "160", "--temp", "0.2", "-r", "<end_of_turn>",
|
||||
"-m", model, "-p", &prompt, "-n", "160", "--temp", "0.2", "-r", "<end_of_turn>",
|
||||
"--no-display-prompt",
|
||||
])
|
||||
.output()
|
||||
|
|
@ -93,19 +215,25 @@ fn fallback_answer(question: &str) -> String {
|
|||
}
|
||||
|
||||
/// The result of handling one mention: who to address, the reply body to post, and the
|
||||
/// accountable facts (sealed decision + measured energy + verification).
|
||||
/// accountable facts (sealed decision + metered energy + verification).
|
||||
pub struct Handled {
|
||||
pub who: String,
|
||||
pub body: String,
|
||||
pub outcome: String,
|
||||
/// [`Work::measured_uj`]: `0` and meaningless when the work was not sealed.
|
||||
pub measured_uj: u64,
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
/// Handle one GitHub issue-comment event (`{ "comment": {…} }`): parse the mention, do
|
||||
/// the work under the meter, seal it through the teammate loop at the **measured** cost,
|
||||
/// and compose the reply body. `None` if the comment does not address `handle`.
|
||||
/// the work under the meter, seal it through the teammate loop at the meter's figure and
|
||||
/// provenance, and compose the reply body. `None` if the comment does not address `handle`.
|
||||
pub fn handle(event: &Value, handle: &str) -> Option<Handled> {
|
||||
handle_with(event, handle, &configured_model())
|
||||
}
|
||||
|
||||
/// [`handle`] with the model given as the path to a local GGUF (see [`do_work_with`]).
|
||||
pub(crate) fn handle_with(event: &Value, handle: &str, model: &str) -> Option<Handled> {
|
||||
let mention = parse_event(Surface::GitHub, event)?;
|
||||
if !mention.text.contains(handle) {
|
||||
return None;
|
||||
|
|
@ -114,24 +242,21 @@ pub fn handle(event: &Value, handle: &str) -> Option<Handled> {
|
|||
let question =
|
||||
if question.is_empty() { "Please help with this thread.".into() } else { question };
|
||||
|
||||
// real work + real measured energy → seal at the measured cost → compose.
|
||||
let work = do_work(&question);
|
||||
// real work + metered energy → seal at the meter's figure and provenance → compose.
|
||||
let work = do_work_with(&question, model);
|
||||
let (outcome_str, verified) = seal(&work, &question);
|
||||
let body = compose_github(&mention.who, &work, &outcome_str, verified);
|
||||
Some(Handled { who: mention.who, body, outcome: outcome_str, measured_uj: work.measured_uj, verified })
|
||||
}
|
||||
|
||||
/// Seal the work through the teammate loop at the **measured** cost — shared by every
|
||||
/// surface binding. Returns (outcome, ledger-verified).
|
||||
/// Seal the work through the teammate loop at the meter's figure, labelled with the
|
||||
/// meter's provenance — shared by every surface binding. Returns (outcome,
|
||||
/// ledger-verified). Work its meter put no figure on is not sealed: there is no figure to
|
||||
/// commit, and none is invented.
|
||||
pub fn seal(work: &Work, intent: &str) -> (String, bool) {
|
||||
let mut t = Teammate::new();
|
||||
let cost = work.measured_uj;
|
||||
let kind = "answer";
|
||||
let _ = t.grant_skill(
|
||||
Skill::new("responder", &[kind], cost, move |_| SkillOutcome::done(cost)),
|
||||
u64::MAX,
|
||||
);
|
||||
let outcome = t.tag(kind, intent);
|
||||
let Some((t, outcome)) = seal_work(work, intent) else {
|
||||
return (NOT_SEALED.to_string(), false);
|
||||
};
|
||||
let verified = t.verify_ledger().is_ok();
|
||||
let outcome_str = match &outcome {
|
||||
TagOutcome::Done => "completed".to_string(),
|
||||
|
|
@ -141,6 +266,22 @@ pub fn seal(work: &Work, intent: &str) -> (String, bool) {
|
|||
(outcome_str, verified)
|
||||
}
|
||||
|
||||
/// The teammate that sealed `work`, and the tag's outcome: the work's figure committed as
|
||||
/// the skill's cost, its `efficiency/1` sealed at the meter's provenance. `None` for work
|
||||
/// its meter put no figure on.
|
||||
fn seal_work(work: &Work, intent: &str) -> Option<(Teammate, TagOutcome)> {
|
||||
let provenance = ledger_provenance(work)?;
|
||||
let mut t = Teammate::new();
|
||||
let cost = work.measured_uj;
|
||||
let kind = "answer";
|
||||
let _ = t.grant_skill(
|
||||
Skill::new("responder", &[kind], cost, move |_| SkillOutcome::done(cost)),
|
||||
u64::MAX,
|
||||
);
|
||||
let outcome = t.tag_with_provenance(kind, intent, provenance);
|
||||
Some((t, outcome))
|
||||
}
|
||||
|
||||
fn compose_github(who: &str, work: &Work, outcome: &str, verified: bool) -> String {
|
||||
let mj = work.measured_uj as f64 / 1000.0;
|
||||
let answer = &work.answer;
|
||||
|
|
@ -149,12 +290,26 @@ fn compose_github(who: &str, work: &Work, outcome: &str, verified: bool) -> Stri
|
|||
let meter = &work.meter;
|
||||
let prov = &work.provenance;
|
||||
let verify = if verified { "verified ✓" } else { "UNVERIFIED ✗" };
|
||||
// Work with no figure was never put to a grant and never reached a ledger: say so,
|
||||
// rather than name a grant decision or a ledger that does not exist.
|
||||
let (how_done, energy, ledger) = match obtained(work) {
|
||||
Some(how) => (
|
||||
"Decided under a signed grant, worked on real hardware, sealed to a ledger anyone can verify",
|
||||
format!("{mj:.1} mJ ({uj} µJ) — {how} `{meter}`, provenance `{prov}`"),
|
||||
format!("ledger {verify}"),
|
||||
),
|
||||
None => (
|
||||
"Worked on real hardware; not metered, so no grant decided it and nothing was sealed",
|
||||
unmetered_line(work),
|
||||
"no ledger entry".to_string(),
|
||||
),
|
||||
};
|
||||
format!(
|
||||
"🔷 **@joule** — {outcome}, @{who}.\n\n{answer}\n\n---\n\
|
||||
_Decided under a signed grant, worked on real hardware, sealed to a ledger anyone can verify._\n\
|
||||
_{how_done}._\n\
|
||||
- **model:** `{model}`\n\
|
||||
- **energy:** {mj:.1} mJ ({uj} µJ) — measured via `{meter}`, provenance `{prov}`\n\
|
||||
- **decision:** {outcome} · ledger {verify}"
|
||||
- **energy:** {energy}\n\
|
||||
- **decision:** {outcome} · {ledger}"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -235,25 +390,225 @@ fn describe(e: ureq::Error) -> String {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use jouleclaw_energy::{EnergyDomain, IoReportStatus};
|
||||
|
||||
fn reading(uj: u64, provenance: Provenance) -> EnergyReading {
|
||||
EnergyReading { uj, timestamp_ns: 0, domain: EnergyDomain::SocTotal, provenance }
|
||||
}
|
||||
|
||||
const SECOND: Duration = Duration::from_secs(1);
|
||||
/// A model path with no file behind it: the work is the deterministic fallback and
|
||||
/// no subprocess runs. Tests pass it explicitly; none reads `JOULEDESK_MODEL`.
|
||||
pub(crate) const NO_MODEL: &str = "/nonexistent.gguf";
|
||||
/// The Apple estimator's declared minimum window.
|
||||
const ESTIMATOR_WINDOW: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Work as `do_work` would produce it under a meter named `meter`.
|
||||
pub(crate) fn work(uj: u64, provenance: &str, meter: &str) -> Work {
|
||||
Work {
|
||||
answer: "an answer".into(),
|
||||
measured_uj: uj,
|
||||
provenance: provenance.into(),
|
||||
meter: meter.into(),
|
||||
model: "a-model".into(),
|
||||
unmetered: (provenance == UNMETERED)
|
||||
.then(|| "the work took 200 µs, shorter than the 100 ms window it can resolve".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The provenance label and µJ sealed into `work`'s one `efficiency/1` receipt.
|
||||
fn sealed(work: &Work) -> (String, u64) {
|
||||
let (t, outcome) = seal_work(work, "a question").expect("work with a figure is sealed");
|
||||
assert_eq!(outcome, TagOutcome::Done);
|
||||
let recs = jcp_efficiency::verify_chain(t.efficiency_ledger(), t.steward(), &t.steward_pubkey())
|
||||
.expect("the energy ledger verifies");
|
||||
assert_eq!(recs.len(), 1);
|
||||
(recs[0].provenance.clone(), recs[0].energy_uj)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_a_mention_and_seals_measured_energy() {
|
||||
std::env::set_var("JOULEDESK_MODEL", "/nonexistent.gguf"); // force the fallback (no subprocess)
|
||||
fn a_figure_is_the_delta_as_read_never_floored() {
|
||||
// Both Apple meters resolve 1000 µJ; a 250 µJ delta over a window the meter can
|
||||
// resolve stays 250 — it is not raised to the resolution.
|
||||
let est = Provenance::Estimator;
|
||||
let fig = |b, a, p| window_energy(Some(&reading(b, p)), Some(&reading(a, p)), SECOND, ESTIMATOR_WINDOW);
|
||||
assert_eq!(fig(5_000, 5_250, est), Ok((250, est)));
|
||||
let mb = Provenance::ModelBased;
|
||||
assert_eq!(fig(7, 80_007, mb), Ok((80_000, mb)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_without_two_good_readings_has_no_figure() {
|
||||
let est = Provenance::Estimator;
|
||||
let fig = |b: Option<EnergyReading>, a: Option<EnergyReading>| {
|
||||
window_energy(b.as_ref(), a.as_ref(), SECOND, ESTIMATOR_WINDOW)
|
||||
};
|
||||
let failed = Err("a read of it failed".to_string());
|
||||
assert_eq!(fig(None, Some(reading(5_000, est))), failed, "the first read failed");
|
||||
assert_eq!(fig(Some(reading(5_000, est)), None), failed, "the second read failed");
|
||||
assert_eq!(fig(None, None), failed);
|
||||
let backwards = fig(Some(reading(5_000, est)), Some(reading(4_000, est))).unwrap_err();
|
||||
assert!(backwards.contains("went backwards"), "{backwards}");
|
||||
let disagree = fig(Some(reading(5_000, Provenance::ModelBased)), Some(reading(9_000, est))).unwrap_err();
|
||||
assert!(disagree.contains("disagree about provenance"), "{disagree}");
|
||||
}
|
||||
|
||||
/// A window shorter than the meter can resolve has no figure, whatever the delta;
|
||||
/// a meter that registered nothing has none either. Neither becomes a zero.
|
||||
#[test]
|
||||
fn a_window_the_meter_cannot_resolve_is_never_a_zero() {
|
||||
let est = Provenance::Estimator;
|
||||
let short = Duration::from_micros(200);
|
||||
let why = window_energy(Some(&reading(5_000, est)), Some(&reading(5_000, est)), short, ESTIMATOR_WINDOW)
|
||||
.unwrap_err();
|
||||
assert_eq!(why, "the work took 200 µs, shorter than the 100 ms window it can resolve");
|
||||
// A step landing in a window too short to resolve is not a figure for the work.
|
||||
let why = window_energy(Some(&reading(5_000, est)), Some(&reading(20_000, est)), short, ESTIMATOR_WINDOW)
|
||||
.unwrap_err();
|
||||
assert!(why.contains("shorter than the 100 ms window"), "{why}");
|
||||
let mb = Provenance::ModelBased;
|
||||
let why = window_energy(Some(&reading(9, mb)), Some(&reading(9, mb)), SECOND, Duration::from_millis(10))
|
||||
.unwrap_err();
|
||||
assert_eq!(why, "it registered no energy across 1000 ms of work");
|
||||
assert_eq!(span(Duration::from_micros(1_500)), "1.5 ms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_estimate_is_sealed_and_posted_as_an_estimate() {
|
||||
let w = work(12_345, &Provenance::Estimator.to_string(), "apple-silicon");
|
||||
assert_eq!(sealed(&w), ("Estimator".to_string(), 12_345));
|
||||
let body = compose_github("dana", &w, "completed", true);
|
||||
assert!(
|
||||
body.contains("12.3 mJ (12345 µJ) — estimated by `apple-silicon`, provenance `estimator`"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(!body.contains("measured"), "an estimate is never called a measurement: {body}");
|
||||
assert!(body.contains("_Decided under a signed grant, worked on real hardware, sealed to a ledger"), "{body}");
|
||||
assert!(body.contains("- **decision:** completed · ledger verified ✓"), "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reading_is_sealed_at_its_own_provenance() {
|
||||
// A live IOReport counter's reading is a hardware reading, as RAPL's is.
|
||||
let w = work(80_000, &Provenance::HwShunt.to_string(), "apple-ioreport");
|
||||
assert_eq!(sealed(&w), ("HwShunt".to_string(), 80_000));
|
||||
let body = compose_github("dana", &w, "completed", true);
|
||||
assert!(body.contains("measured via `apple-ioreport`, provenance `hw_shunt`"), "{body}");
|
||||
let w = work(80_000, &Provenance::HwShunt.to_string(), "rapl");
|
||||
assert_eq!(sealed(&w), ("HwShunt".to_string(), 80_000));
|
||||
// A vendor energy model's reading is sealed as one.
|
||||
let w = work(80_000, &Provenance::ModelBased.to_string(), "vendor-model");
|
||||
assert_eq!(sealed(&w), ("ModelBased".to_string(), 80_000));
|
||||
let body = compose_github("dana", &w, "completed", true);
|
||||
assert!(body.contains("measured via `vendor-model`, provenance `model_based`"), "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_with_no_figure_is_neither_sealed_nor_given_one() {
|
||||
let w = work(0, UNMETERED, "apple-silicon");
|
||||
assert!(seal_work(&w, "a question").is_none(), "no figure, so nothing is committed");
|
||||
assert_eq!(seal(&w, "a question"), (NOT_SEALED.to_string(), false));
|
||||
let body = compose_github("dana", &w, NOT_SEALED, false);
|
||||
assert!(
|
||||
body.contains(
|
||||
"not metered — `apple-silicon`: the work took 200 µs, shorter than the 100 ms window it \
|
||||
can resolve; no energy figure was sealed"
|
||||
),
|
||||
"{body}"
|
||||
);
|
||||
assert!(body.contains("nothing was sealed") && !body.contains("mJ"), "{body}");
|
||||
assert_unmetered_reply(&body);
|
||||
}
|
||||
|
||||
/// A reply for work with no figure names no grant decision and no ledger: neither
|
||||
/// exists, because such work is never put to a grant or sealed.
|
||||
pub(crate) fn assert_unmetered_reply(body: &str) {
|
||||
assert!(!body.to_lowercase().contains("signed grant"), "no grant decided this work: {body}");
|
||||
assert!(!body.contains("UNVERIFIED") && !body.contains("ledger verified"), "there is no ledger: {body}");
|
||||
assert!(body.contains("no ledger entry"), "{body}");
|
||||
}
|
||||
|
||||
/// End to end on this host, through `handle`: the reply names the meter that
|
||||
/// actually ran. With no model configured the work is a deterministic extract that
|
||||
/// takes well under a millisecond, shorter than any meter here can resolve (the
|
||||
/// estimator's 100 ms, IOReport's 10 ms), so the reply says it was not metered and
|
||||
/// why, and nothing is sealed — where it used to seal the meter's resolution,
|
||||
/// 1000 µJ, as a measurement. The meter is IOReport only where its CPU rail is live.
|
||||
#[test]
|
||||
fn a_mention_answered_too_fast_to_meter_is_posted_as_unmetered() {
|
||||
let event =
|
||||
json!({ "comment": { "user": { "login": "dana" }, "body": "@joule summarize the thread" } });
|
||||
let h = handle(&event, "@joule").expect("addresses @joule");
|
||||
let h = handle_with(&event, "@joule", NO_MODEL).expect("addresses @joule");
|
||||
let status = jouleclaw_energy::ioreport_status();
|
||||
let energy = h.body.lines().find(|l| l.contains("**energy:**")).unwrap_or_default();
|
||||
println!("jouledesk-github on this host: ioreport={status:?} {energy} · outcome={}", h.outcome);
|
||||
assert_eq!(h.who, "dana");
|
||||
assert!(h.verified, "the sealed ledger verifies");
|
||||
assert!(h.measured_uj >= 1000, "measured energy floored to the meter resolution");
|
||||
assert!(h.body.contains("@dana") && h.body.contains("measured via"));
|
||||
std::env::remove_var("JOULEDESK_MODEL");
|
||||
assert_eq!((h.outcome.as_str(), h.verified, h.measured_uj), (NOT_SEALED, false, 0));
|
||||
assert!(energy.contains("not metered — `") && energy.contains("shorter than the"), "{energy}");
|
||||
assert!(!h.body.contains("measured") && !h.body.contains(" mJ"), "{}", h.body);
|
||||
assert!(h.body.contains("- **model:** `deterministic-extract`"), "{}", h.body);
|
||||
assert_unmetered_reply(&h.body);
|
||||
let ioreport = energy.contains("`apple-ioreport`");
|
||||
assert_eq!(ioreport, status == IoReportStatus::Live, "IOReport runs exactly when its CPU rail is live");
|
||||
}
|
||||
|
||||
/// End to end on this host with the real meter, for work long enough to meter: the
|
||||
/// meter's own figure, sealed and posted at the meter's own provenance. IOReport
|
||||
/// (`hw_shunt`) only where its CPU rail is live; elsewhere — macOS 27.0.1 on an
|
||||
/// M5 Max and an M3 Ultra — an estimator, sealed `Estimator` and posted "estimated".
|
||||
#[test]
|
||||
fn on_this_host_work_the_meter_can_resolve_is_sealed_at_its_provenance() {
|
||||
let status = jouleclaw_energy::ioreport_status();
|
||||
let meter = jouleclaw_energy::meter::detect_meter();
|
||||
let name = meter.active_name();
|
||||
let min_window = Duration::from_nanos(meter.active().expect("detect_meter has a meter").min_window_ns());
|
||||
let ((), figure) = metered(&meter, || {
|
||||
let t = Instant::now();
|
||||
let mut x = 1u64;
|
||||
while t.elapsed() < min_window + Duration::from_millis(50) {
|
||||
x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
|
||||
}
|
||||
std::hint::black_box(x);
|
||||
});
|
||||
let (uj, provenance) = figure.unwrap_or_else(|why| panic!("`{name}` put no figure on resolvable work: {why}"));
|
||||
let w = Work {
|
||||
answer: "an answer".into(),
|
||||
measured_uj: uj,
|
||||
provenance: provenance.to_string(),
|
||||
meter: name.to_string(),
|
||||
model: "a-model".into(),
|
||||
unmetered: None,
|
||||
};
|
||||
let (label, sealed_uj) = sealed(&w);
|
||||
let body = compose_github("dana", &w, "completed", true);
|
||||
let energy = body.lines().find(|l| l.contains("**energy:**")).unwrap_or_default();
|
||||
println!("jouledesk-github on this host: ioreport={status:?} sealed {label} {sealed_uj} µJ; {energy}");
|
||||
assert!(uj > 0 && sealed_uj == uj, "{uj} µJ read, {sealed_uj} µJ sealed");
|
||||
assert_eq!(name == "apple-ioreport", status == IoReportStatus::Live, "IOReport runs exactly when live");
|
||||
match provenance {
|
||||
Provenance::Estimator => {
|
||||
assert_eq!(label, "Estimator");
|
||||
assert!(energy.contains(&format!("estimated by `{name}`, provenance `estimator`")), "{energy}");
|
||||
assert!(!body.contains("measured"), "{body}");
|
||||
}
|
||||
Provenance::ModelBased | Provenance::HwShunt => {
|
||||
assert_ne!(label, "Estimator");
|
||||
assert!(energy.contains(&format!("measured via `{name}`")), "{energy}");
|
||||
}
|
||||
}
|
||||
if matches!(name, "apple-silicon" | "estimation") {
|
||||
assert_eq!(provenance, Provenance::Estimator, "an estimator's figure is an estimate");
|
||||
}
|
||||
if name == "apple-ioreport" {
|
||||
assert_eq!(provenance, Provenance::HwShunt, "a live IOReport reading is a hardware reading");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_comments_that_dont_address_the_handle() {
|
||||
let event = json!({ "comment": { "user": { "login": "x" }, "body": "just chatting" } });
|
||||
assert!(handle(&event, "@joule").is_none());
|
||||
assert!(handle_with(&event, "@joule", NO_MODEL).is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//! Env: `GITHUB_TOKEN` (for --reply / --serve), `JOULEDESK_MODEL` (a local GGUF),
|
||||
//! `JOULEDESK_HANDLE` (default `@joule`).
|
||||
|
||||
use jouledesk_github::{handle, GitHub};
|
||||
use jouledesk_github::{handle, GitHub, Handled};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn arg(a: &[String], k: &str) -> Option<String> {
|
||||
|
|
@ -17,6 +17,16 @@ fn has(a: &[String], k: &str) -> bool {
|
|||
a.iter().any(|x| x == k)
|
||||
}
|
||||
|
||||
/// A handled mention's energy for a log line: the sealed figure, or that there is none.
|
||||
/// The reply body says whether the figure was measured or estimated, and by which meter.
|
||||
fn energy(h: &Handled) -> String {
|
||||
if h.verified {
|
||||
format!("{} µJ", h.measured_uj)
|
||||
} else {
|
||||
format!("no sealed figure ({})", h.outcome)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().skip(1).collect();
|
||||
let handle_tok = arg(&a, "--handle")
|
||||
|
|
@ -29,7 +39,7 @@ fn main() {
|
|||
let event = json!({ "comment": { "user": { "login": "you" }, "body": format!("{handle_tok} {q}") } });
|
||||
match handle(&event, &handle_tok) {
|
||||
Some(h) => {
|
||||
eprintln!("[measured {} µJ · verified={}]", h.measured_uj, h.verified);
|
||||
eprintln!("[energy {} · verified={}]", energy(&h), h.verified);
|
||||
println!("{}", h.body);
|
||||
}
|
||||
None => eprintln!("no mention"),
|
||||
|
|
@ -44,7 +54,7 @@ fn main() {
|
|||
"text": format!("<@UBOT> {q}") } });
|
||||
match jouledesk_github::slack::handle(&event) {
|
||||
Some(h) => {
|
||||
eprintln!("[measured {} µJ · verified={}]", h.measured_uj, h.verified);
|
||||
eprintln!("[energy {} · verified={}]", energy(&h), h.verified);
|
||||
println!("{}", h.body);
|
||||
}
|
||||
None => eprintln!("no mention"),
|
||||
|
|
@ -64,7 +74,7 @@ fn main() {
|
|||
let event = json!({ "event": { "user": "someone", "channel": channel, "text": format!("<@UBOT> {q}") } });
|
||||
let h = jouledesk_github::slack::handle(&event).expect("mention");
|
||||
match jouledesk_github::slack::Slack::new(token).post_message(&channel, &h.body) {
|
||||
Ok(ts) => println!("posted to {channel} ts={ts} (measured {} µJ)", h.measured_uj),
|
||||
Ok(ts) => println!("posted to {channel} ts={ts} (energy {})", energy(&h)),
|
||||
Err(e) => {
|
||||
eprintln!("slack post failed: {e}");
|
||||
std::process::exit(1);
|
||||
|
|
@ -95,7 +105,7 @@ fn main() {
|
|||
match gh.latest_mention(&repo, issue, &handle_tok, &bot) {
|
||||
Ok(Some(event)) => {
|
||||
let h = handle(&event, &handle_tok).expect("mention parses");
|
||||
eprintln!("→ {} (measured {} µJ, verified={})", h.outcome, h.measured_uj, h.verified);
|
||||
eprintln!("→ {} (energy {}, verified={})", h.outcome, energy(&h), h.verified);
|
||||
match gh.post_comment(&repo, issue, &h.body) {
|
||||
Ok(id) => println!("posted reply comment id={id} to {repo}#{issue}"),
|
||||
Err(e) => {
|
||||
|
|
@ -137,7 +147,7 @@ fn main() {
|
|||
if let Some(h) = handle(&event, &handle_tok) {
|
||||
if !repo.is_empty() && issue != 0 {
|
||||
match gh.post_comment(&repo, issue, &h.body) {
|
||||
Ok(id) => eprintln!("auto-replied to {repo}#{issue} (comment {id}, {} µJ)", h.measured_uj),
|
||||
Ok(id) => eprintln!("auto-replied to {repo}#{issue} (comment {id}, energy {})", energy(&h)),
|
||||
Err(e) => eprintln!("post failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,27 @@
|
|||
//! The Slack binding — the second surface, on the same core.
|
||||
//!
|
||||
//! An `app_mention` event comes in; `@joule` does the work with the same real model
|
||||
//! under the same real energy meter, seals the same verifiable `efficiency/1` at the
|
||||
//! measured cost, and posts the reply with `chat.postMessage`. Only the wire shape and
|
||||
//! the delivery client differ from GitHub — the accountable core is identical.
|
||||
//! under the same energy meter, seals the same verifiable `efficiency/1` at that meter's
|
||||
//! figure and provenance, and posts the reply with `chat.postMessage`. Only the wire
|
||||
//! shape and the delivery client differ from GitHub — the accountable core is identical.
|
||||
//!
|
||||
//! Live delivery needs a Slack **bot token** (`SLACK_BOT_TOKEN`) + a workspace app
|
||||
//! subscribed to `app_mention`; `--slack-dry-run` proves the payload here without one.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{do_work, seal, Handled};
|
||||
use super::{do_work_with, seal, Handled};
|
||||
|
||||
/// Handle one Slack `app_mention` event: `{ "event": { "user", "text", "channel" } }`.
|
||||
/// Strips the bot mention, does the work under the meter, seals, and composes the reply
|
||||
/// in Slack `mrkdwn`. `None` if there is no addressable text.
|
||||
pub fn handle(event: &Value) -> Option<Handled> {
|
||||
handle_with(event, &super::configured_model())
|
||||
}
|
||||
|
||||
/// [`handle`] with the model given as the path to a local GGUF (see
|
||||
/// [`super::do_work_with`]).
|
||||
pub(crate) fn handle_with(event: &Value, model: &str) -> Option<Handled> {
|
||||
let ev = &event["event"];
|
||||
let who = ev["user"].as_str()?.to_string();
|
||||
let question = strip_mentions(ev["text"].as_str()?);
|
||||
|
|
@ -23,7 +29,7 @@ pub fn handle(event: &Value) -> Option<Handled> {
|
|||
return None;
|
||||
}
|
||||
|
||||
let work = do_work(&question);
|
||||
let work = do_work_with(&question, model);
|
||||
let (outcome, verified) = seal(&work, &question);
|
||||
let body = compose(&who, &work, &outcome, verified);
|
||||
Some(Handled { who, body, outcome, measured_uj: work.measured_uj, verified })
|
||||
|
|
@ -48,7 +54,7 @@ fn strip_mentions(text: &str) -> String {
|
|||
}
|
||||
|
||||
/// The reply in Slack `mrkdwn` (single `*` for bold, `<@id>` mentions) — same facts as
|
||||
/// the GitHub reply: real answer + the sealed, measured attestation.
|
||||
/// the GitHub reply: real answer + the sealed, metered attestation, at its provenance.
|
||||
fn compose(who: &str, work: &super::Work, outcome: &str, verified: bool) -> String {
|
||||
let mj = work.measured_uj as f64 / 1000.0;
|
||||
let answer = &work.answer;
|
||||
|
|
@ -56,12 +62,25 @@ fn compose(who: &str, work: &super::Work, outcome: &str, verified: bool) -> Stri
|
|||
let meter = &work.meter;
|
||||
let prov = &work.provenance;
|
||||
let verify = if verified { "verified ✓" } else { "UNVERIFIED ✗" };
|
||||
// As on GitHub: work with no figure names no grant decision and no ledger.
|
||||
let (how_done, energy, ledger) = match super::obtained(work) {
|
||||
Some(how) => (
|
||||
"Signed grant · worked on real hardware · sealed to a verifiable ledger",
|
||||
format!("{mj:.1} mJ {how} `{meter}` (provenance `{prov}`)"),
|
||||
format!("ledger {verify}"),
|
||||
),
|
||||
None => (
|
||||
"Worked on real hardware · not metered · no grant decision · nothing sealed",
|
||||
super::unmetered_line(work),
|
||||
"no ledger entry".to_string(),
|
||||
),
|
||||
};
|
||||
format!(
|
||||
"🔷 *@joule* — {outcome}, <@{who}>.\n\n{answer}\n\n\
|
||||
_Signed grant · metered on real hardware · sealed to a verifiable ledger_\n\
|
||||
_{how_done}_\n\
|
||||
• *model:* `{model}`\n\
|
||||
• *energy:* {mj:.1} mJ measured via `{meter}` (provenance `{prov}`)\n\
|
||||
• *decision:* {outcome} · ledger {verify}"
|
||||
• *energy:* {energy}\n\
|
||||
• *decision:* {outcome} · {ledger}"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -95,19 +114,42 @@ impl Slack {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The same end-to-end path as the GitHub binding's: the deterministic fallback is
|
||||
/// too fast for any meter here to resolve, so the Slack reply says it was not
|
||||
/// metered, and why, and seals nothing — never a figure, never "measured".
|
||||
#[test]
|
||||
fn slack_app_mention_seals_and_composes_mrkdwn() {
|
||||
std::env::set_var("JOULEDESK_MODEL", "/nonexistent.gguf"); // force fallback (no subprocess)
|
||||
fn slack_app_mention_composes_mrkdwn_and_never_claims_a_figure_it_lacks() {
|
||||
let event = json!({
|
||||
"event": { "type": "app_mention", "user": "U123", "channel": "C1",
|
||||
"text": "<@UBOT> summarize the thread" }
|
||||
});
|
||||
let h = handle(&event).expect("addresses the app");
|
||||
let h = handle_with(&event, crate::tests::NO_MODEL).expect("addresses the app");
|
||||
assert_eq!(h.who, "U123");
|
||||
assert!(h.verified);
|
||||
assert!(h.measured_uj >= 1000);
|
||||
assert!(h.body.contains("<@U123>") && h.body.contains("measured via"));
|
||||
std::env::remove_var("JOULEDESK_MODEL");
|
||||
assert!(h.body.contains("<@U123>"), "{}", h.body);
|
||||
let energy = h.body.lines().find(|l| l.contains("• *energy:* ")).expect("an energy line");
|
||||
assert_eq!((h.outcome.as_str(), h.verified), (super::super::NOT_SEALED, false), "{energy}");
|
||||
assert!(energy.contains("not metered — `") && energy.contains("shorter than the"), "{energy}");
|
||||
assert!(!h.body.contains("measured") && !h.body.contains(" mJ"), "{}", h.body);
|
||||
assert!(h.body.contains("• *model:* `deterministic-extract`"), "{}", h.body);
|
||||
crate::tests::assert_unmetered_reply(&h.body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_posts_an_estimate_as_an_estimate() {
|
||||
use jouleclaw_energy::Provenance;
|
||||
let w = crate::tests::work(12_345, &Provenance::Estimator.to_string(), "apple-silicon");
|
||||
let body = compose("U123", &w, "completed", true);
|
||||
assert!(body.contains("12.3 mJ estimated by `apple-silicon` (provenance `estimator`)"), "{body}");
|
||||
assert!(!body.contains("measured") && !body.contains("metered on"), "{body}");
|
||||
assert!(body.contains("_Signed grant · worked on real hardware · sealed to a verifiable ledger_"), "{body}");
|
||||
assert!(body.contains("• *decision:* completed · ledger verified ✓"), "{body}");
|
||||
let w = crate::tests::work(80_000, &Provenance::HwShunt.to_string(), "apple-ioreport");
|
||||
let body = compose("U123", &w, "completed", true);
|
||||
assert!(body.contains("80.0 mJ measured via `apple-ioreport` (provenance `hw_shunt`)"), "{body}");
|
||||
let w = crate::tests::work(0, super::super::UNMETERED, "apple-ioreport");
|
||||
let body = compose("U123", &w, super::super::NOT_SEALED, false);
|
||||
assert!(body.contains("not metered") && body.contains("nothing sealed") && !body.contains("mJ"), "{body}");
|
||||
crate::tests::assert_unmetered_reply(&body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ use jcp_schema::{Did, Energy};
|
|||
// jcp-orchestrate directly.
|
||||
pub use jcp_orchestrate::{HarnessOutcome as SkillOutcome, Task as SkillTask};
|
||||
|
||||
// Re-exported so a binding can say how a tag's energy was obtained
|
||||
// ([`Teammate::tag_with_provenance`]) without depending on jcp-schema directly.
|
||||
pub use jcp_schema::Provenance as EnergyProvenance;
|
||||
|
||||
/// Host-surface bindings (§8): Slack, GitHub, GitLab, Discord, Matrix, and a neutral
|
||||
/// shape — parse an inbound event → tag → render the reply.
|
||||
pub mod surface;
|
||||
|
|
@ -151,7 +155,25 @@ impl Teammate {
|
|||
/// A member tags `@joule` to delegate `intent` of a given `kind` (SPEC §4). The
|
||||
/// teammate authorises it against its grants **outside the model**, runs it, and
|
||||
/// seals the decision. Returns the outcome.
|
||||
///
|
||||
/// The task's `efficiency/1` receipt carries its energy as a hardware reading
|
||||
/// ([`EnergyProvenance::HwShunt`]). A binding whose figure came from anything
|
||||
/// else — an energy model, an estimator — uses [`Self::tag_with_provenance`].
|
||||
pub fn tag(&mut self, kind: &str, intent: &str) -> TagOutcome {
|
||||
self.tag_with_provenance(kind, intent, EnergyProvenance::HwShunt)
|
||||
}
|
||||
|
||||
/// [`Self::tag`], sealing the task's `efficiency/1` energy at `provenance`: how
|
||||
/// the skill's committed cost was actually obtained. A binding that meters the
|
||||
/// work passes its meter's provenance, so a figure from an energy model is
|
||||
/// sealed as modeled and an estimate as an estimate, never as a hardware
|
||||
/// reading (SPEC §6: energy "at honest provenance").
|
||||
pub fn tag_with_provenance(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
intent: &str,
|
||||
provenance: EnergyProvenance,
|
||||
) -> TagOutcome {
|
||||
self.n += 1;
|
||||
let id = format!("tag-{}", self.n);
|
||||
self.orch.submit(Task::new(id.clone(), kind, intent.as_bytes().to_vec()));
|
||||
|
|
@ -175,13 +197,18 @@ impl Teammate {
|
|||
.map(|c| c.cost_uj)
|
||||
.unwrap_or(0);
|
||||
let completed = matches!(outcome, TagOutcome::Done);
|
||||
let energy = match provenance {
|
||||
EnergyProvenance::HwShunt => Energy::measured(cost),
|
||||
EnergyProvenance::ModelBased => Energy::modeled(cost),
|
||||
EnergyProvenance::Estimator => Energy::estimator(cost),
|
||||
};
|
||||
self.eff.append(&Efficiency {
|
||||
task: id,
|
||||
completed,
|
||||
verdict: if completed { "done".into() } else { "failed".into() },
|
||||
tokens_in: 0,
|
||||
tokens_out: 0,
|
||||
energy: Energy::measured(cost),
|
||||
energy,
|
||||
wall_ns: 0,
|
||||
value_uusd: None,
|
||||
energy_attestation: None,
|
||||
|
|
@ -357,6 +384,35 @@ mod tests {
|
|||
assert_eq!(t.verify_ledger(), Ok(1), "only the real mention dispatched");
|
||||
}
|
||||
|
||||
/// The provenance label sealed into the one `efficiency/1` receipt of a
|
||||
/// teammate that ran one tag, read back through the verified chain.
|
||||
fn sealed_provenance(t: &Teammate) -> String {
|
||||
let recs = jcp_efficiency::verify_chain(t.efficiency_ledger(), t.steward(), &t.steward_pubkey())
|
||||
.expect("the energy ledger verifies under the steward key");
|
||||
assert_eq!(recs.len(), 1, "one efficiency/1 receipt per task that ran");
|
||||
assert_eq!(recs[0].energy_uj, 100_000, "the committed cost, as sealed");
|
||||
recs[0].provenance.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tags_energy_is_sealed_at_the_provenance_it_was_obtained_at() {
|
||||
for (provenance, label) in [
|
||||
(EnergyProvenance::Estimator, "Estimator"),
|
||||
(EnergyProvenance::ModelBased, "ModelBased"),
|
||||
(EnergyProvenance::HwShunt, "HwShunt"),
|
||||
] {
|
||||
let mut t = Teammate::new();
|
||||
t.grant_skill(summarizer(), 1_000_000).unwrap();
|
||||
assert_eq!(t.tag_with_provenance("summarize", "the Q3 thread", provenance), TagOutcome::Done);
|
||||
assert_eq!(sealed_provenance(&t), label, "an energy figure obtained as {provenance:?}");
|
||||
}
|
||||
// Plain `tag` is unchanged: it seals the committed cost as a hardware reading.
|
||||
let mut t = Teammate::new();
|
||||
t.grant_skill(summarizer(), 1_000_000).unwrap();
|
||||
assert_eq!(t.tag("summarize", "the Q3 thread"), TagOutcome::Done);
|
||||
assert_eq!(sealed_provenance(&t), "HwShunt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn efficiency_accounting_rolls_up_completed_and_wasted() {
|
||||
let mut t = Teammate::new();
|
||||
|
|
|
|||
|
|
@ -65,7 +65,8 @@ cargo clippy --workspace --all-targets
|
|||
# end-to-end: mint a JCP grant, serve, print the sealed receipt
|
||||
cargo run -p mesh-core --example demo
|
||||
|
||||
# on Linux/RAPL or a CUDA/NVML box, meter real silicon (HwShunt provenance)
|
||||
# on Linux/RAPL or a CUDA/NVML box, meter real silicon (HwShunt provenance, where
|
||||
# the counter is live; a counter that is not live falls back to the estimator)
|
||||
cargo run -p mesh-core --example demo --features hw-meter-rapl
|
||||
|
||||
# regenerate the published conformance vectors
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ serde_json = { workspace = true }
|
|||
|
||||
[features]
|
||||
default = []
|
||||
# Forwarded to mesh-meter: use the family's hardware-backed meter.
|
||||
# Forwarded to mesh-meter: use the family's hardware-backed meter. Each platform
|
||||
# feature implies `hw-meter`, which is what the demo checks: without it
|
||||
# `--features hw-meter-rapl` built the backend and then used the estimator.
|
||||
hw-meter = ["mesh-meter/hw-meter"]
|
||||
hw-meter-rapl = ["mesh-meter/hw-meter-rapl"]
|
||||
hw-meter-nvml = ["mesh-meter/hw-meter-nvml"]
|
||||
hw-meter-apple = ["mesh-meter/hw-meter-apple"]
|
||||
hw-meter-rapl = ["hw-meter", "mesh-meter/hw-meter-rapl"]
|
||||
hw-meter-nvml = ["hw-meter", "mesh-meter/hw-meter-nvml"]
|
||||
hw-meter-apple = ["hw-meter", "mesh-meter/hw-meter-apple"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -15,6 +15,15 @@
|
|||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
// A platform meter feature must select the family meter, not only build its
|
||||
// backend: callers (the demo among them) gate on `hw-meter`, and without it the
|
||||
// documented `--features hw-meter-rapl` built RAPL and then ran the estimator.
|
||||
#[cfg(all(
|
||||
any(feature = "hw-meter-rapl", feature = "hw-meter-nvml", feature = "hw-meter-apple"),
|
||||
not(feature = "hw-meter")
|
||||
))]
|
||||
compile_error!("a hw-meter-* feature must enable mesh-core's `hw-meter` feature");
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
//! This is the production drop-in the reference engine stands in for. With it,
|
||||
//! the mesh routes and **meters real model tokens** — the energy span around
|
||||
//! [`ServingEngine::serve`] is a genuine decode, so the receipt's
|
||||
//! `uj_per_accepted_token` is a measured figure on real silicon.
|
||||
//! `uj_per_accepted_token` is a measured figure on real silicon — where the
|
||||
//! span's meter is a hardware counter that is live for the domain it reads.
|
||||
//! Where it is not (Apple Silicon whose IOReport CPU rail is frozen, a build or
|
||||
//! host without a hardware meter) the span says `Estimator`, and so does the
|
||||
//! figure.
|
||||
//!
|
||||
//! It is gated behind the `gguf` feature so the default workspace build (and
|
||||
//! `cargo test --workspace`) stays light and needs no model weights. Enable it,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,21 @@
|
|||
//! `Estimator` provenance. The default so the foundation is OS-neutral.
|
||||
//! * [`ManualMeter`] — caller-driven, for tests and replaying recorded spans.
|
||||
//!
|
||||
//! With the `hw-meter` feature, [`HwMeter`] bridges the family's hardware meter
|
||||
//! (RAPL / NVML / Apple Silicon) so a span on real silicon yields `HwShunt`.
|
||||
//! With the `hw-meter` feature, [`HwMeter`] bridges the family's meter (RAPL /
|
||||
//! NVML / Apple Silicon IOReport, else the family's estimator), so a span carries
|
||||
//! the provenance of whatever actually metered it: `HwShunt` from a hardware
|
||||
//! counter that is live for the domain it reads (Apple IOReport included, where
|
||||
//! its CPU energy rail advances under load), `ModelBased` from a vendor energy
|
||||
//! model, `Estimator` where the host exposes no live counter — as on Apple Silicon
|
||||
//! whose IOReport CPU rail is frozen (macOS 27.0.1).
|
||||
//!
|
||||
//! A `mesh/1` receipt has no coverage field (SPEC §3.3), so its figure is read
|
||||
//! as the energy of the whole request. A counter whose domain does not cover the
|
||||
//! whole request — an NVML GPU counter, with the CPU that drives the GPU
|
||||
//! unmetered — therefore never yields a `HwShunt` span through [`HwMeter`]: the
|
||||
//! span is graded as mesh grades an estimate of the whole request, `Estimator`
|
||||
//! with no `measured_uj`, and its figure is the counter's reading plus the
|
||||
//! bridge's own estimate of the rest of the host over the same window.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
|
|
@ -197,31 +210,84 @@ impl Meter for SteppingMeter {
|
|||
|
||||
#[cfg(feature = "hw-meter")]
|
||||
mod hw {
|
||||
use super::{Meter, Sample};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
use super::{EstimatorMeter, Meter, Sample};
|
||||
use jouleclaw_energy::meter::{detect_meter, CompositeMeter};
|
||||
use jouleclaw_energy::EnergyDomain;
|
||||
use jouleclaw_energy::Provenance as JpProvenance;
|
||||
use mesh_schema::Provenance;
|
||||
|
||||
/// Bridges the family's hardware-backed [`CompositeMeter`] (RAPL / NVML /
|
||||
/// Apple Silicon, with an estimator fallback) into the Mesh [`Meter`]
|
||||
/// interface. The active backend's provenance is mapped through unchanged,
|
||||
/// so honesty is preserved across the bridge.
|
||||
/// [`EstimatorMeter`]'s name: what [`HwMeter`] reports once it has fallen back.
|
||||
const FALLBACK_NAME: &str = "estimator";
|
||||
|
||||
/// Bridges the family's [`CompositeMeter`] (RAPL / NVML / Apple Silicon
|
||||
/// IOReport where the host exposes a live counter, else the family's
|
||||
/// estimator) into the Mesh [`Meter`] interface. The active backend's
|
||||
/// provenance is mapped through unchanged, so honesty is preserved across the
|
||||
/// bridge. On Apple Silicon whose IOReport CPU energy rail does not advance
|
||||
/// (macOS 27.0.1 on an M5 Max and an M3 Ultra), the active backend is the load
|
||||
/// estimator and spans say `Estimator`.
|
||||
///
|
||||
/// If the backend fails a read, the bridge takes this crate's no-hardware path
|
||||
/// from then on: an [`EstimatorMeter`], continuing from the last good reading,
|
||||
/// tagged `Estimator`. A failed read is never a zero reading, which would bill a
|
||||
/// span nothing, or bill it the counter's whole history.
|
||||
///
|
||||
/// A backend whose readings do not cover the whole request
|
||||
/// ([`covers_whole_request`]: an NVML counter reads the GPUs alone) never
|
||||
/// yields a `HwShunt` span, live or not: `mesh/1` has no coverage field, so
|
||||
/// its figure would claim the CPU it does not read (SPEC §3.3). Its readings
|
||||
/// are graded as an estimate of the whole request, `Estimator`, and carry the
|
||||
/// counter's figure plus this crate's [`EstimatorMeter`] for the rest of the
|
||||
/// host, so a span is billed what the counter read and an estimate of what it
|
||||
/// did not.
|
||||
pub struct HwMeter {
|
||||
inner: CompositeMeter,
|
||||
fallback: EstimatorMeter,
|
||||
state: Mutex<Fallback>,
|
||||
}
|
||||
|
||||
/// Where the fallback estimate picks up.
|
||||
#[derive(Default)]
|
||||
struct Fallback {
|
||||
/// A read of the backend has failed; every snapshot since is an estimate.
|
||||
failed: bool,
|
||||
/// The backend's last good reading, µJ.
|
||||
last_uj: u64,
|
||||
/// The fallback estimator's cumulative figure at that reading, µJ.
|
||||
fallback_at_last_uj: u64,
|
||||
}
|
||||
|
||||
impl HwMeter {
|
||||
/// Detect and wrap the best meter for this platform.
|
||||
pub fn detect() -> Self {
|
||||
Self::bridging(detect_meter())
|
||||
}
|
||||
|
||||
fn bridging(inner: CompositeMeter) -> Self {
|
||||
Self {
|
||||
inner: detect_meter(),
|
||||
inner,
|
||||
fallback: EstimatorMeter::default(),
|
||||
state: Mutex::new(Fallback::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The active backend's short name (`"rapl"`, `"nvml"`, `"estimation"`,
|
||||
/// `"none"`).
|
||||
/// The active backend's short name (`"rapl"`, `"nvml"`, `"apple-ioreport"`,
|
||||
/// `"apple-silicon"`, `"estimation"`, `"none"`), or `"estimator"` once a
|
||||
/// read has failed and the bridge estimates.
|
||||
pub fn active_name(&self) -> &'static str {
|
||||
self.inner.active_name()
|
||||
if self.state().failed {
|
||||
FALLBACK_NAME
|
||||
} else {
|
||||
self.inner.active_name()
|
||||
}
|
||||
}
|
||||
|
||||
fn state(&self) -> MutexGuard<'_, Fallback> {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -233,23 +299,399 @@ mod hw {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether a reading over `domain` covers the whole of a served request, so
|
||||
/// that its grade can stand for the request on a `mesh/1` receipt, which has
|
||||
/// no coverage field. The whole SoC does, and so does a CPU package (a RAPL
|
||||
/// figure, on a host serving from its CPU). A GPU, NPU or ANE alone does not:
|
||||
/// the CPU that drives the accelerator is unmetered. Nor does one core, DRAM,
|
||||
/// or an external rail whose scope this crate cannot know. 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::CpuCore
|
||||
| EnergyDomain::Dram
|
||||
| EnergyDomain::Gpu
|
||||
| EnergyDomain::Npu
|
||||
| EnergyDomain::Ane
|
||||
| EnergyDomain::ExternalRail(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Meter for HwMeter {
|
||||
fn name(&self) -> &str {
|
||||
self.inner.active_name()
|
||||
self.active_name()
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Sample {
|
||||
match self.inner.read() {
|
||||
Ok(r) => Sample {
|
||||
uj: r.uj,
|
||||
provenance: map_prov(r.provenance),
|
||||
},
|
||||
// No meter available: report a zero estimator reading rather
|
||||
// than panic; the resulting delta is honestly tagged Estimator.
|
||||
Err(_) => Sample {
|
||||
let mut state = self.state();
|
||||
if !state.failed {
|
||||
match self.inner.read() {
|
||||
Ok(r) => {
|
||||
let estimate_uj = self.fallback.snapshot().uj;
|
||||
// A reading that does not cover the whole request is an
|
||||
// estimate of it: the counter's figure plus the estimator's
|
||||
// for the rest of the host, graded `Estimator`.
|
||||
let (uj, provenance) = if covers_whole_request(r.domain) {
|
||||
(r.uj, map_prov(r.provenance))
|
||||
} else {
|
||||
(r.uj.saturating_add(estimate_uj), Provenance::Estimator)
|
||||
};
|
||||
state.last_uj = uj;
|
||||
state.fallback_at_last_uj = estimate_uj;
|
||||
return Sample { uj, provenance };
|
||||
}
|
||||
Err(_) => state.failed = true,
|
||||
}
|
||||
}
|
||||
// The backend could not be read: estimate from here on, continuing
|
||||
// from its last good reading, so a span across the failure is billed an
|
||||
// estimate of its own length — never zero, never the counter's history.
|
||||
let since = self
|
||||
.fallback
|
||||
.snapshot()
|
||||
.uj
|
||||
.saturating_sub(state.fallback_at_last_uj);
|
||||
Sample {
|
||||
uj: state.last_uj.saturating_add(since),
|
||||
provenance: Provenance::Estimator,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
use jouleclaw_energy::meter::EnergyMeter;
|
||||
use jouleclaw_energy::{EnergyCounter, EnergyDomain, EnergyError, EnergyReading};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
/// A cumulative counter with a long history: 1 MJ, in µJ.
|
||||
const BASE: u64 = 1_000_000_000_000;
|
||||
/// µJ per read.
|
||||
const STEP: u64 = 1_000;
|
||||
|
||||
/// A backend whose read number `n` (from 0) fails when `fails(n)`; a good
|
||||
/// read returns `BASE + n·STEP` µJ, `ModelBased`.
|
||||
struct Flaky {
|
||||
reads: AtomicU64,
|
||||
fails: fn(u64) -> bool,
|
||||
}
|
||||
|
||||
impl EnergyCounter for Flaky {
|
||||
fn domain(&self) -> EnergyDomain {
|
||||
EnergyDomain::SocTotal
|
||||
}
|
||||
fn provenance(&self) -> JpProvenance {
|
||||
JpProvenance::ModelBased
|
||||
}
|
||||
fn resolution_uj(&self) -> u64 {
|
||||
1
|
||||
}
|
||||
fn min_window_ns(&self) -> u64 {
|
||||
1
|
||||
}
|
||||
fn read(&self) -> Result<EnergyReading, EnergyError> {
|
||||
let n = self.reads.fetch_add(1, Ordering::SeqCst);
|
||||
if (self.fails)(n) {
|
||||
return Err(EnergyError::Platform("sample failed".into()));
|
||||
}
|
||||
Ok(EnergyReading {
|
||||
uj: BASE + n * STEP,
|
||||
timestamp_ns: 0,
|
||||
domain: EnergyDomain::SocTotal,
|
||||
provenance: JpProvenance::ModelBased,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl EnergyMeter for Flaky {
|
||||
fn name(&self) -> &'static str {
|
||||
"flaky"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// A backend that is not available, as the IOReport counter is on a host whose
|
||||
/// CPU energy rail does not advance. Its read would claim a `ModelBased` zero;
|
||||
/// the bridge must never get there.
|
||||
struct Declined;
|
||||
|
||||
impl EnergyCounter for Declined {
|
||||
fn domain(&self) -> EnergyDomain {
|
||||
EnergyDomain::SocTotal
|
||||
}
|
||||
fn provenance(&self) -> JpProvenance {
|
||||
JpProvenance::ModelBased
|
||||
}
|
||||
fn resolution_uj(&self) -> u64 {
|
||||
1
|
||||
}
|
||||
fn min_window_ns(&self) -> u64 {
|
||||
1
|
||||
}
|
||||
fn read(&self) -> Result<EnergyReading, EnergyError> {
|
||||
Ok(EnergyReading {
|
||||
uj: 0,
|
||||
provenance: Provenance::Estimator,
|
||||
},
|
||||
timestamp_ns: 0,
|
||||
domain: EnergyDomain::SocTotal,
|
||||
provenance: JpProvenance::ModelBased,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl EnergyMeter for Declined {
|
||||
fn name(&self) -> &'static str {
|
||||
"declined"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn bridge(fails: fn(u64) -> bool) -> HwMeter {
|
||||
let flaky = Flaky {
|
||||
reads: AtomicU64::new(0),
|
||||
fails,
|
||||
};
|
||||
HwMeter::bridging(CompositeMeter::new(vec![Box::new(flaky)]))
|
||||
}
|
||||
|
||||
/// A 20 ms span on `m`.
|
||||
fn span(m: &HwMeter) -> mesh_schema::Energy {
|
||||
let s = Session::start(m);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
s.stop()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_that_fails_mid_span_is_estimated_never_zero() {
|
||||
let m = bridge(|n| n >= 1); // the span's closing read fails
|
||||
let e = span(&m);
|
||||
assert_eq!(e.provenance, Provenance::Estimator, "{e:?}");
|
||||
assert_eq!(e.measured_uj, None);
|
||||
assert!(e.estimated_uj > 0, "a 20 ms span is not free: {e:?}");
|
||||
assert!(e.check_provenance().is_ok());
|
||||
assert_eq!(m.name(), EstimatorMeter::default().name());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_span_that_opens_on_a_failed_read_is_not_billed_the_counters_history() {
|
||||
let m = bridge(|n| n == 0); // only the opening read fails; the backend recovers
|
||||
let e = span(&m);
|
||||
assert_eq!(e.provenance, Provenance::Estimator, "{e:?}");
|
||||
assert!(e.estimated_uj > 0 && e.estimated_uj < BASE, "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backend_that_never_reads_is_estimated() {
|
||||
let e = span(&bridge(|_| true));
|
||||
assert_eq!(e.provenance, Provenance::Estimator, "{e:?}");
|
||||
assert!(e.estimated_uj > 0, "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn once_a_read_fails_the_bridge_estimates_and_never_runs_backwards() {
|
||||
let m = bridge(|n| n == 1);
|
||||
let good = m.snapshot();
|
||||
assert_eq!((good.uj, good.provenance), (BASE, Provenance::ModelBased));
|
||||
let mut prev = good.uj;
|
||||
for _ in 0..3 {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
let s = m.snapshot();
|
||||
assert_eq!(
|
||||
s.provenance,
|
||||
Provenance::Estimator,
|
||||
"the backend is not trusted again"
|
||||
);
|
||||
assert!(s.uj >= prev, "{} then {}", prev, s.uj);
|
||||
prev = s.uj;
|
||||
}
|
||||
}
|
||||
|
||||
/// An unavailable counter takes this crate's no-hardware path: spans say
|
||||
/// `Estimator`, carry no measured figure and are not free — never the
|
||||
/// `ModelBased` zero the declined counter would have read.
|
||||
#[test]
|
||||
fn an_unavailable_counter_takes_the_no_hardware_path_never_a_modelbased_zero() {
|
||||
let m = HwMeter::bridging(CompositeMeter::new(vec![Box::new(Declined)]));
|
||||
let e = span(&m);
|
||||
assert_eq!(e.provenance, Provenance::Estimator, "{e:?}");
|
||||
assert_eq!(e.measured_uj, None);
|
||||
assert!(e.estimated_uj > 0, "a 20 ms span is not free: {e:?}");
|
||||
assert_eq!(m.name(), EstimatorMeter::default().name());
|
||||
}
|
||||
|
||||
/// The same with the real IOReport counter, bridged alone (no estimator behind
|
||||
/// it in the composite): where its CPU rail is not live (macOS 27.0.1 on an
|
||||
/// M5 Max and an M3 Ultra) it is unavailable, and the bridge estimates.
|
||||
#[cfg(all(feature = "hw-meter-apple", target_os = "macos"))]
|
||||
#[test]
|
||||
fn the_real_ioreport_counter_when_declined_takes_the_no_hardware_path() {
|
||||
let status = jouleclaw_energy::ioreport_status();
|
||||
let counter = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
|
||||
let available = counter.is_available();
|
||||
let m = HwMeter::bridging(CompositeMeter::new(vec![Box::new(counter)]));
|
||||
let e = span(&m);
|
||||
println!("mesh-meter, IOReport bridged alone: ioreport={status:?} available={available} meter={} energy={e:?}", m.name());
|
||||
assert_eq!(available, status == jouleclaw_energy::IoReportStatus::Live);
|
||||
if available {
|
||||
assert_eq!((m.name(), e.provenance), ("apple-ioreport", Provenance::HwShunt));
|
||||
} else {
|
||||
assert_eq!(m.name(), EstimatorMeter::default().name());
|
||||
assert_eq!((e.provenance, e.measured_uj), (Provenance::Estimator, None));
|
||||
assert!(e.estimated_uj > 0, "{e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A backend that steps `STEP` µJ per read from `BASE`, over `domain`, at
|
||||
/// `grade` — a live NVML counter is `(Gpu, HwShunt)`.
|
||||
struct Stepping {
|
||||
reads: AtomicU64,
|
||||
domain: EnergyDomain,
|
||||
grade: JpProvenance,
|
||||
}
|
||||
|
||||
impl EnergyCounter for Stepping {
|
||||
fn domain(&self) -> EnergyDomain {
|
||||
self.domain
|
||||
}
|
||||
fn provenance(&self) -> JpProvenance {
|
||||
self.grade
|
||||
}
|
||||
fn resolution_uj(&self) -> u64 {
|
||||
1
|
||||
}
|
||||
fn min_window_ns(&self) -> u64 {
|
||||
1
|
||||
}
|
||||
fn read(&self) -> Result<EnergyReading, EnergyError> {
|
||||
let n = self.reads.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(EnergyReading {
|
||||
uj: BASE + n * STEP,
|
||||
timestamp_ns: 0,
|
||||
domain: self.domain,
|
||||
provenance: self.grade,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl EnergyMeter for Stepping {
|
||||
fn name(&self) -> &'static str {
|
||||
"stepping"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn stepping(domain: EnergyDomain, grade: JpProvenance) -> HwMeter {
|
||||
let backend = Stepping { reads: AtomicU64::new(0), domain, grade };
|
||||
HwMeter::bridging(CompositeMeter::new(vec![Box::new(backend)]))
|
||||
}
|
||||
|
||||
/// A live GPU counter's span is not a measurement of the request: mesh/1
|
||||
/// has no coverage field, so a HwShunt GPU figure would claim the CPU it
|
||||
/// did not read. It is graded as an estimate of the whole request —
|
||||
/// `Estimator`, no `measured_uj` — and billed the counter's reading plus
|
||||
/// the estimator's figure for the rest of the host, never less than the
|
||||
/// counter read. The same counter over the whole SoC passes through as a
|
||||
/// measurement: only the domain differs.
|
||||
#[test]
|
||||
fn a_gpu_only_reading_is_an_estimate_of_the_whole_request() {
|
||||
for grade in [JpProvenance::HwShunt, JpProvenance::ModelBased, JpProvenance::Estimator] {
|
||||
let m = stepping(EnergyDomain::Gpu, grade);
|
||||
let e = span(&m);
|
||||
assert_eq!((e.provenance, e.measured_uj), (Provenance::Estimator, None), "{grade:?}: {e:?}");
|
||||
assert!(e.check_provenance().is_ok());
|
||||
// 20 ms at the estimator's 15 W is ~300,000 µJ beside the GPU's STEP.
|
||||
assert!(e.estimated_uj > STEP, "{grade:?}: the unmetered CPU was billed nothing: {e:?}");
|
||||
assert!(e.estimated_uj < STEP + 2_000_000, "{grade:?}: {e:?}");
|
||||
assert_eq!(m.name(), "stepping", "the counter still reads; it is graded, not dropped");
|
||||
}
|
||||
let whole = span(&stepping(EnergyDomain::SocTotal, JpProvenance::HwShunt));
|
||||
assert_eq!((whole.provenance, whole.measured_uj, whole.estimated_uj), (Provenance::HwShunt, Some(STEP), STEP));
|
||||
}
|
||||
|
||||
/// Which domains stand for the whole request. Every one is listed, so a
|
||||
/// 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() {
|
||||
use EnergyDomain::*;
|
||||
for (d, whole) in [
|
||||
(SocTotal, true),
|
||||
(CpuPkg, true),
|
||||
(CpuCore, false),
|
||||
(Dram, false),
|
||||
(Gpu, false),
|
||||
(Npu, false),
|
||||
(Ane, false),
|
||||
(ExternalRail(0), false),
|
||||
] {
|
||||
assert_eq!(covers_whole_request(d), whole, "{d:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The family's NVML counter reads the GPUs alone: through this bridge it
|
||||
/// never grades a span HwShunt, whatever its liveness.
|
||||
#[cfg(feature = "hw-meter-nvml")]
|
||||
#[test]
|
||||
fn the_nvml_counter_does_not_cover_the_whole_request() {
|
||||
let nvml = jouleclaw_energy::nvml::NvmlCounter::new();
|
||||
assert_eq!(nvml.domain(), EnergyDomain::Gpu);
|
||||
assert!(!covers_whole_request(nvml.domain()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backend_that_reads_passes_through_unchanged() {
|
||||
let m = bridge(|_| false);
|
||||
let e = span(&m);
|
||||
assert_eq!(
|
||||
(e.provenance, e.estimated_uj),
|
||||
(Provenance::ModelBased, STEP)
|
||||
);
|
||||
assert_eq!(m.name(), "flaky");
|
||||
}
|
||||
|
||||
/// This host, with `hw-meter-apple`: the bridge says what the family meter
|
||||
/// says. IOReport only where its CPU rail is live; elsewhere (macOS 27.0.1 on
|
||||
/// an M5 Max and an M3 Ultra) the estimator, whose spans say `Estimator`.
|
||||
#[cfg(feature = "hw-meter-apple")]
|
||||
#[test]
|
||||
fn on_this_host_the_bridge_is_as_honest_as_the_family_meter() {
|
||||
let status = jouleclaw_energy::ioreport_status();
|
||||
let m = HwMeter::detect();
|
||||
let s = Session::start(&m);
|
||||
let t = std::time::Instant::now();
|
||||
let mut x = 1u64;
|
||||
while t.elapsed() < Duration::from_millis(50) {
|
||||
x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
|
||||
}
|
||||
std::hint::black_box(x);
|
||||
let e = s.stop();
|
||||
println!(
|
||||
"mesh-meter on this host: ioreport={status:?} meter={} energy={e:?}",
|
||||
m.name()
|
||||
);
|
||||
if status == jouleclaw_energy::IoReportStatus::Live {
|
||||
assert_eq!(m.name(), "apple-ioreport");
|
||||
assert_eq!(e.provenance, Provenance::HwShunt, "a live IOReport span is a hardware reading");
|
||||
} else {
|
||||
assert_ne!(
|
||||
m.name(),
|
||||
"apple-ioreport",
|
||||
"IOReport is used only when its CPU rail is live"
|
||||
);
|
||||
}
|
||||
if matches!(m.name(), "apple-silicon" | "estimation") {
|
||||
assert_eq!(e.provenance, Provenance::Estimator);
|
||||
assert_eq!(e.measured_uj, None);
|
||||
assert!(e.estimated_uj > 0, "{e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,8 +39,12 @@ pub enum SchemaError {
|
|||
/// [`Energy::check_provenance`] and again at seal time.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Provenance {
|
||||
/// Read from a hardware energy interface (RAPL / NVML / SMC / shunt).
|
||||
/// Requires a present `measured_uj`.
|
||||
/// Read from a hardware energy interface: a shunt, or a vendor energy
|
||||
/// counter (RAPL / NVML / Apple IOReport / SMC) while it is demonstrably
|
||||
/// live for the domain the figure claims — the implementation observed the
|
||||
/// counter covering that domain advance under load. A counter that is
|
||||
/// present but not live (frozen, or republished in lumps) yields no
|
||||
/// `HwShunt` figure. Requires a present `measured_uj`.
|
||||
HwShunt,
|
||||
/// Computed from a calibrated per-operation model. `measured_uj` may be
|
||||
/// absent.
|
||||
|
|
|
|||
Loading…
Reference in a new issue