open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/fabric_calibrate.rs
Claude f5760cc22d
All checks were successful
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 1m2s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 6s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 46s
standards workspaces build + test / arl-rs (arl/arl-rs, arl-rs, linux) (pull_request) Successful in 1m16s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m53s
standards workspaces build + test / eoc-rs (eoc/eoc-rs, -p eoc-meter --features cuda, eoc-rs, linux) (pull_request) Successful in 2m9s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m14s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m11s
standards workspaces build + test / pool-rs (pool/pool-rs, pool-rs, linux) (pull_request) Successful in 1m8s
standards workspaces build + test / proof-rs (proof-conformance, proof/proof-rs, proof-rs, linux, true) (pull_request) Successful in 1m28s
standards workspaces build + test / sense-rs (sense/sense-rs, sense-rs, linux) (pull_request) Successful in 1m16s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m27s
standards workspaces build + test / smart-byte-rs (smart-byte/smart-byte-rs, smart-byte-rs, linux) (pull_request) Successful in 4m10s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 6m50s
standards workspaces build + test / joulecontract-rs (joulecontract/joulecontract-rs, joulecontract-rs, linux-arm64, true) (pull_request) Successful in 1m30s
standards workspaces build + test / map-rs (map-conformance, map/map-rs, map-rs, linux-arm64) (pull_request) Successful in 1m28s
standards workspaces build + test / aac-rs (aac-conformance, aac/aac-rs, aac-rs, linux-arm64) (pull_request) Successful in 5m17s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 5m10s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 2m24s
standards workspaces build + test / openpay (openpay, openpay, linux-arm64) (pull_request) Successful in 6m16s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 8m16s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 2m52s
jouleclaw-loader-gguf: 0 warnings, and -D warnings now gates it
12 -> 0, in both the default and `rayon fabric-meter` configurations, and
the crate joins the `-D warnings` clippy step in standards-build.yml.

The last dozen needed judgement rather than a script:

- two `from_str` methods that are not FromStr-shaped (one returns Option,
  the other is infallible) renamed to `parse`. Both are file-local, so
  there is no caller churn -- except that my check for callers missed
  mlx.rs, which reaches SafetensorsDtype through a type ALIAS, so
  `SafetensorsDtype::from_str(` never matched. Renaming wants a grep for
  the method name across the workspace, not for the type names you happen
  to know.
- `assert!(FLASH_KV_CHUNK.is_power_of_two())` and `>= 64` moved out of a
  `#[test]` into `const _: () = assert!(..)` beside the constant. These
  are properties of a constant; a test only holds them if someone runs it.
- the `kv_quant_tests` module moved to the end of kv_cache_inplace.rs,
  because 300 lines of real items sat after it.
- an example discarded the `Result` from `matmul_bf16_threaded`, so a
  failure would have been timed as a success.
- `needless_range_loop` allowed per-file in three benches and one test:
  those are separate crates, so src/lib.rs's decision does not reach them.
- checked_div for two guarded divisions, and one unused `mut`.

Two near-misses worth recording:

- `fabric_dispatch.rs` calls `ledger_record` twice with identical
  arguments, which reads exactly like a copy-paste bug. The comment three
  lines above says attention is "recorded as two GEMM-equivalents" so the
  measured µJ/GFLOP table prices it. Deliberate. Left alone.
- clippy's `chunks_exact_mut` suggestion is `as_chunks_mut::<N>().0.iter()`
  -- a non-mutable iterator over a mutable chunk. It does not compile, and
  only the fabric-meter configuration contained one, so the default build
  stayed green while that example was broken. Checking both configurations
  is what caught it.

The gate covers default features only. Under `rayon fabric-meter` this
crate is also clean, but clippy reports jouleclaw-backend-apple through
the path dependency, and that crate has 43 warnings -- 27 `unused_unsafe`
and 15 `unused_imports` that are unused in THAT configuration and needed
in others. Those want cfg work, not deletion, which is the trap #373
already paid for once.

Verified: four lint specs pass, 343 loader-gguf tests, 77 omni tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-21 20:40:32 -04:00

310 lines
14 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Wave 67: measure THIS machine's fabric, in joules.
//!
//! Runs the same BF16 matmul workloads on every serving
//! local member — NEON, AMX (Accelerate), Metal GPU
//! (resident-weight MPS) — under an energy meter, and
//! writes the measured µJ/GFLOP table the placer loads at
//! discovery (`jouleclaw_fabric::calibration_file_path`:
//! `JOULECLAW_FABRIC_CALIBRATION` where it is set, else
//! `~/.jouleclaw/fabric-calibration.json`).
//!
//! Two meters, chosen as the live autotuner chooses
//! (`fabric_dispatch::tune_shape_live`):
//!
//! - **The IOReport SoC counter, where its CPU energy rail
//! is live.** It is machine-wide, so marginal energy per
//! op = ΔE_busy − P_idle·Δt, with P_idle sampled
//! immediately before the runs — an idle-subtraction MODEL
//! on top of a hardware reading, the same caveat the
//! joule-fix fleet dataset records. Run this on a quiet
//! machine or the numbers are someone else's workload.
//! Every figure must be a measurement or nothing is
//! written: 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.
//! - **This process's own CPU and GPU counters, where it is
//! not** (macOS 27.0.1: the rail is frozen for an
//! unprivileged reader): each member's loop is one
//! `ProcessSpanMeter` span, its figure this process's CPU
//! energy plus its GPU energy, with no idle to subtract
//! (`marginal_energy::measure_member_process`). A member is
//! written only from a span metered whole: a CPU member's
//! GPU share an exact zero, a GPU member's the GPU rail
//! while this process was the GPU's sole user. Any other
//! member is refused, saying why, and the rest are still
//! written. The domain is this process's CPU and GPU, not
//! the SoC: DRAM is outside it, and AMX work through
//! Accelerate is counted as the kernel attributes it to the
//! calling threads' CPU energy, which is not checked
//! against a rail here — the row's method says so.
//!
//! Each row it writes declares its grade — the meter's, so a
//! live 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. The file's rows
//! for the three members this example measures
//! (`marginal_energy::FABRIC_CALIBRATE_MEMBERS`) are all
//! replaced (`marginal_energy::merge_calibration`): a member
//! written now by its new row, and a member refused or
//! skipped by none, so the fabric places it on its seeded
//! figure — never on a row an earlier run took, which may be
//! of another domain, grade or shape than the rows written
//! beside it. Rows of members it does not measure
//! (`local/metal-mpp`, which `mpp_energy` writes) stay as
//! they were.
//!
//! Exit status: 0 when every member was written; 2 when
//! some were and others were refused or skipped (absent at
//! warm-up); 1, having written nothing, when none could be.
//!
//! ```bash
//! cargo run --release --features "rayon fabric-meter" \
//! --example fabric_calibrate
//! ```
use jouleclaw_energy::apple_ioreport::AppleIoReportCounter;
use jouleclaw_energy::span::ProcessSpanMeter;
use jouleclaw_energy::EnergyCounter;
use jouleclaw_loader_gguf::marginal_energy::{
calibration_row, idle_draw_uw, marginal_uj, measure_member_process, merge_calibration, process_method, refusal,
MemberRuns, NotMeasured, FABRIC_CALIBRATE_MEMBERS,
};
use std::collections::BTreeMap;
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()))
}
/// The meter every member is priced on.
enum Meter {
/// The machine-wide IOReport SoC counter, its CPU rail live, and the idle draw
/// sampled before the runs, µW.
IoReport { counter: AppleIoReportCounter, idle_uw: f64 },
/// This process's own CPU and GPU counters.
Process(ProcessSpanMeter),
}
struct Shape {
label: &'static str,
m: usize,
n: usize,
k: usize,
}
/// A member's row, for the AMX member's method: what this process's CPU counter
/// covers of work it hands to Accelerate.
const AMX_NOTE: &str = "; AMX work through Accelerate counted as the kernel attributes it to the calling \
threads' CPU energy, not checked against a rail";
fn main() {
// Decode GEMV at the lm_head class (wave 66's open
// question) and a prefill batch shape.
let shapes = [
Shape { label: "decode-gemv (m=1, lm_head class)", m: 1, n: 98304, k: 2304 },
Shape { label: "prefill-batch (m=16)", m: 16, n: 4096, k: 2304 },
];
// The file the placer loads: the variable's where it is set.
let file = jouleclaw_fabric::calibration_file_path();
let path = file.display().to_string();
let counter = AppleIoReportCounter::new();
let meter = if counter.is_available() {
// Idle baseline: 3 s of nothing.
let idle_uw = {
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 = 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);
Meter::IoReport { counter, idle_uw }
} else {
let span = ProcessSpanMeter::new(jouleclaw_backend_apple::gpu_liveness_load);
if !span.is_metered() {
refuse(
&path,
NotMeasured::CounterUnavailable(
"apple-ioreport declined, and this process's own CPU and GPU counters are not both live".into(),
),
);
}
println!(
"IOReport declined ({:?}): pricing each member on this process's own CPU and GPU counters",
jouleclaw_energy::ioreport_status()
);
Meter::Process(span)
};
// Per member, the row from its LAST shape (the batch shape for
// matrix members reflects their serving regime; the decode
// shape stays in the printout for the record). A member whose
// last shape was refused or skipped has no row: neither an
// earlier shape's figure nor an earlier run's stands in for it.
let mut latest: BTreeMap<String, jouleclaw_fabric::CalibrationEntry> = BTreeMap::new();
let mut refused: BTreeMap<String, String> = BTreeMap::new();
for shape in &shapes {
let (m, n, k) = (shape.m, shape.n, shape.k);
let gflop = 2.0 * m as f64 * n as f64 * k as f64 / 1e9;
println!("\n── {} ({:.2} GFLOP/op) ──", shape.label, gflop);
// Synthetic BF16 weight + activation.
let mut w = vec![0u8; 2 * n * k];
for (i, c) in w.as_chunks_mut::<2>().0.iter_mut().enumerate() {
let v = (((i * 40503) % 2000) as f32 / 1000.0 - 1.0) * 0.05;
c.copy_from_slice(
&jouleclaw_loader_gguf::matmul_bf16::f32_to_bf16_rne(v).to_le_bytes(),
);
}
let a: Vec<f32> = (0..m * k)
.map(|i| ((i * 2654435761 % 1000) as f32 / 500.0) - 1.0)
.collect();
let mut out = vec![0f32; m * n];
type Runner<'a> = Box<dyn Fn(&mut [f32]) -> bool + 'a>;
let members: Vec<(&str, MemberRuns, Runner)> = vec![
(
"local/cpu-neon",
MemberRuns::Cpu,
Box::new(|out: &mut [f32]| {
jouleclaw_loader_gguf::matmul_bf16::matmul_bf16_neon(&a, &w, &[], m, n, k, out);
true
}),
),
(
"local/amx",
MemberRuns::Cpu,
Box::new(|out: &mut [f32]| {
jouleclaw_loader_gguf::fabric_dispatch::matmul_bf16_amx(&a, &w, &[], m, n, k, out)
}),
),
(
"local/metal-gpu",
MemberRuns::Gpu,
Box::new(|out: &mut [f32]| {
jouleclaw_loader_gguf::fabric_dispatch::matmul_bf16_gpu(&a, &w, &[], m, n, k, out)
}),
),
];
for (member, runs, run) in &members {
// Warm up (and for the GPU: pay the residency
// upload OUTSIDE the measured window — calibration
// prices the steady state; the upload amortizes).
if !run(&mut out) {
println!("{member:>18}: honestly absent / refused — skipped");
latest.remove(*member);
refused.insert(member.to_string(), format!("{}: absent or refused at warm-up", shape.label));
continue;
}
run(&mut out);
match &meter {
Meter::IoReport { counter, idle_uw } => {
// Measure ≥ 2 s of work.
let mut iters = 0u64;
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 = 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,
);
refused.remove(*member);
latest.insert(
member.to_string(),
calibration_row(
member,
"matmul",
uj_per_gflop,
counter.provenance(),
format!(
"IOReport SoC ΔE − idle({:.2} W)·Δt over {} ops of {} — \
idle-subtraction model on a hardware reading; IOReport {:?}",
idle_uw / 1e6, iters, shape.label, jouleclaw_energy::ioreport_status(),
),
),
);
}
Meter::Process(span) => {
// ≥ 2 s of work, one span.
match measure_member_process(span, *runs, gflop, 2.0, || {
run(&mut out);
}) {
Ok(f) => {
println!(
"{member:>18}: {:>5} ops in {:.2}s → {:.0} µJ/op, {:.0} µJ/GFLOP ({:.1} ms/op; \
CPU {} µJ + GPU {} µJ)",
f.ops,
f.secs,
f.uj_per_gflop * gflop,
f.uj_per_gflop,
f.secs * 1e3 / f.ops as f64,
f.cpu_uj,
f.gpu_uj,
);
let mut method = process_method(&f, *runs, shape.label);
if *member == "local/amx" {
method.push_str(AMX_NOTE);
}
refused.remove(*member);
latest.insert(
member.to_string(),
calibration_row(member, "matmul", f.uj_per_gflop, f.provenance, method),
);
}
Err(why) => {
println!("{member:>18}: refused — {why}");
latest.remove(*member);
refused.insert(member.to_string(), format!("{}: {why}", shape.label));
}
}
}
}
}
}
if latest.is_empty() {
let why: Vec<String> = refused.iter().map(|(m, w)| format!("{m} ({w})")).collect();
refuse(&path, format!("no member was measured whole: {}", why.join("; ")));
}
// Every row of a member this example measures is replaced: by
// its row from this run, or by none. Other members' rows stay.
let existing: Vec<jouleclaw_fabric::CalibrationEntry> = std::fs::read(&file)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let written: Vec<String> = latest.keys().cloned().collect();
let rows = merge_calibration(existing, &FABRIC_CALIBRATE_MEMBERS, latest.into_values().collect());
if let Some(dir) = file.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).expect("mkdir");
}
std::fs::write(&file, serde_json::to_vec_pretty(&rows).unwrap()).expect("write");
println!("\nwrote {} to {path} — Fabric::discover() places them on these figures.", written.join(", "));
if !refused.is_empty() {
for (member, why) in &refused {
eprintln!("not written: {member} — {why}; its row was dropped, so it is placed on its seeded figure");
}
std::process::exit(2);
}
}