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
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]>
216 lines
11 KiB
Rust
216 lines
11 KiB
Rust
//! Honest µJ/GFLOP of the Metal-4 matmul2d prefill path vs the
|
||
//! threaded-NEON CPU path.
|
||
//! `cargo run --release --features "rayon fabric fabric-meter" --example mpp_energy`
|
||
//!
|
||
//! Two meters, chosen as the live autotuner chooses (`fabric_dispatch::tune_shape_live`):
|
||
//!
|
||
//! - **The IOReport SoC counter, where its CPU energy rail is live.** Run on a QUIET
|
||
//! box: marginal energy = ΔE_busy − P_idle·Δt. It writes nothing, and exits non-zero
|
||
//! saying why, unless both figures are measurements: 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.
|
||
//! - **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 run is one `ProcessSpanMeter`
|
||
//! span, this process's CPU energy plus its GPU energy, no idle to subtract
|
||
//! (`marginal_energy::measure_member_process`). The GPU run's figure is written only
|
||
//! from a span metered whole — the GPU rail while this process was the GPU's sole
|
||
//! user — else nothing is written. The CPU run (its GPU share must be an exact zero)
|
||
//! stands on its own span and gives the ratio; its refusal is printed and does not
|
||
//! void the GPU figure, which needs no idle baseline to be this process's alone.
|
||
#[cfg(all(feature = "fabric", feature = "fabric-meter"))]
|
||
fn main() {
|
||
use jouleclaw_energy::span::ProcessSpanMeter;
|
||
use jouleclaw_energy::EnergyCounter;
|
||
use jouleclaw_loader_gguf::marginal_energy::{
|
||
calibration_row, energy_ratio, idle_draw_uw, marginal_uj, measure_member_process, process_method, refusal,
|
||
MemberRuns, 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 = jouleclaw_fabric::calibration_file_path().display().to_string();
|
||
let counter = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
|
||
if !counter.is_available() {
|
||
return process_counters(&path);
|
||
}
|
||
let read_uj = || counter.read().map(|r| r.uj).map_err(|e| NotMeasured::ReadFailed(e.to_string()));
|
||
let idle_uw = {
|
||
let e0 = read_uj().unwrap_or_else(|e| refuse(&path, e));
|
||
let t0 = Instant::now();
|
||
std::thread::sleep(Duration::from_secs(3));
|
||
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);
|
||
|
||
// Prefill projection shape.
|
||
let (m, n, k) = (256usize, 4096usize, 4096usize);
|
||
let a: Vec<f32> = (0..m * k).map(|i| ((i % 101) as f32 / 50.0) - 1.0).collect();
|
||
let w: Vec<u8> = (0..n * k)
|
||
.flat_map(|i| f32_to_bf16(((i % 97) as f32 / 48.0 - 1.0) * 0.1).to_le_bytes())
|
||
.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), 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 = read_uj()?;
|
||
let t0 = Instant::now();
|
||
let mut ops = 0u64;
|
||
while t0.elapsed().as_secs_f64() < secs {
|
||
run();
|
||
ops += 1;
|
||
}
|
||
let dt = t0.elapsed().as_secs_f64();
|
||
let e1 = read_uj()?;
|
||
let total_gf = gflop * ops as f64;
|
||
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 = 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");
|
||
}));
|
||
|
||
let mut out2 = vec![0f32; m * n];
|
||
let o2ptr = out2.as_mut_ptr();
|
||
let cpu = measure(3.0, Box::new(move || {
|
||
let o = unsafe { std::slice::from_raw_parts_mut(o2ptr, m * n) };
|
||
// 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}");
|
||
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 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()
|
||
),
|
||
);
|
||
write_row(&path, row);
|
||
|
||
/// Merge `row` into the calibration file under its member, replacing that member's
|
||
/// row and leaving every other row as it was (merge, don't clobber).
|
||
fn write_row(path: &str, row: jouleclaw_fabric::CalibrationEntry) {
|
||
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 != row.member_id);
|
||
let (member, ujpgf, grade) = (row.member_id.clone(), row.uj_per_gflop, 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 {member} = {ujpgf:.0} µJ/GFLOP ({grade:?}) → {path}");
|
||
}
|
||
}
|
||
|
||
/// The same two runs on this process's own CPU and GPU counters, each one span
|
||
/// ([`measure_member_process`]): the GPU run's figure is written only from a span
|
||
/// metered whole, and the CPU run, on its own span, gives the ratio.
|
||
fn process_counters(path: &str) {
|
||
let meter = ProcessSpanMeter::new(jouleclaw_backend_apple::gpu_liveness_load);
|
||
if !meter.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 ({:?}): each run metered on this process's own CPU and GPU counters",
|
||
jouleclaw_energy::ioreport_status()
|
||
);
|
||
let (m, n, k) = (256usize, 4096usize, 4096usize);
|
||
let a: Vec<f32> = (0..m * k).map(|i| ((i % 101) as f32 / 50.0) - 1.0).collect();
|
||
let w: Vec<u8> = (0..n * k)
|
||
.flat_map(|i| f32_to_bf16(((i % 97) as f32 / 48.0 - 1.0) * 0.1).to_le_bytes())
|
||
.collect();
|
||
let gflop = 2.0 * m as f64 * n as f64 * k as f64 / 1e9;
|
||
let mut out = vec![0f32; m * n];
|
||
let gpu_run = |o: &mut [f32]| {
|
||
let ok = jouleclaw_loader_gguf::fabric_dispatch::matmul_bf16_mpp_bt(&a, &w, m, n, k, o);
|
||
assert!(ok, "mpp_bt fell back — set JOULECLAW_FABRIC=1 on a Metal-4 box");
|
||
};
|
||
gpu_run(&mut out); // warm, outside the span
|
||
let gpu = measure_member_process(&meter, MemberRuns::Gpu, gflop, 3.0, || gpu_run(&mut out));
|
||
let cpu_run = |o: &mut [f32]| {
|
||
// 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, &w, &[], m, n, k, o)
|
||
.expect("threaded-NEON matmul refused this shape");
|
||
};
|
||
cpu_run(&mut out);
|
||
let cpu = measure_member_process(&meter, MemberRuns::Cpu, gflop, 3.0, || cpu_run(&mut out));
|
||
|
||
println!("shape {m}x{n}x{k}");
|
||
for (label, r) in [("matmul2d (metal-gpu)", &gpu), ("threaded-NEON (cpu) ", &cpu)] {
|
||
match r {
|
||
Ok(f) => println!(
|
||
" {label}: {:>9.0} µJ/GFLOP {:>7.1} GFLOP/s (CPU {} µJ + GPU {} µJ)",
|
||
f.uj_per_gflop,
|
||
gflop * f.ops as f64 / f.secs,
|
||
f.cpu_uj,
|
||
f.gpu_uj
|
||
),
|
||
Err(e) => println!(" {label}: not measured — {e}"),
|
||
}
|
||
}
|
||
match (&gpu, &cpu) {
|
||
(Ok(g), Ok(c)) => match energy_ratio(c.uj_per_gflop, g.uj_per_gflop) {
|
||
Some(ratio) => println!(" energy ratio (CPU/GPU per GFLOP): {ratio:.2}×"),
|
||
None => println!(" energy ratio (CPU/GPU per GFLOP): n/a"),
|
||
},
|
||
_ => println!(" energy ratio (CPU/GPU per GFLOP): n/a — both runs must be measured"),
|
||
}
|
||
let g = gpu.unwrap_or_else(|e| refuse(path, format!("the matmul2d (metal-gpu) run: {e}")));
|
||
let method = process_method(&g, MemberRuns::Gpu, &format!("matmul2d {m}×{n}×{k}"));
|
||
write_row(path, calibration_row("local/metal-mpp", "matmul", g.uj_per_gflop, g.provenance, method));
|
||
}
|
||
}
|
||
#[cfg(not(all(feature = "fabric", feature = "fabric-meter")))]
|
||
fn main() { println!("build with --features \"rayon fabric fabric-meter\""); }
|