jouleclaw-energy: a ledger total carries its weakest grade; macOS meter code is linted in CI #259
2 changed files with 148 additions and 39 deletions
16
.github/workflows/standards-build.yml
vendored
16
.github/workflows/standards-build.yml
vendored
|
|
@ -545,3 +545,19 @@ jobs:
|
|||
cargo test -p jouleclaw-loader-gguf --features "rayon fabric-meter" --test process_calibration -- --nocapture 2>&1 | tee -a "$log"
|
||||
grep -q 'calibration row stamped on macOS:' "$log"
|
||||
grep -q 'process calibration: CPU member' "$log"
|
||||
|
||||
# The meter code the steps above test compiles only on macOS (IOReport,
|
||||
# proc_pid_rusage, the process-tree readers), so no Linux job lints it.
|
||||
# Lints in it went unseen until a macOS sweep found them (#244). Each
|
||||
# crate is linted here, on its own platform and with the features its
|
||||
# tests use, and a warning fails the step.
|
||||
- name: clippy on the macOS meter code
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
lint() { (cd "$1" && shift && cargo clippy --all-targets --no-deps "$@" -- -D warnings); }
|
||||
lint jcp/jcp-rs -p jcp-energy -p jcp-gateway
|
||||
lint proof/proof-rs -p proof-cascade -p proof-solvers
|
||||
lint eoc/eoc-rs -p eoc-meter
|
||||
lint jouleclaw/jouleclaw-rs -p jouleclaw-energy --features ioreport
|
||||
lint sandbox/sandbox-rs -p sandbox-meter -p sandbox-proof
|
||||
|
|
|
|||
|
|
@ -9,10 +9,18 @@
|
|||
//! The ledger holds *integer microjoules only* — floating-point is
|
||||
//! reserved for derived quantities (carbon, joules). This matches the
|
||||
//! JouleClaw protocol's determinism guarantee.
|
||||
//!
|
||||
//! Every deposit carries the grade of the figure it deposits
|
||||
//! ([`Provenance`]), and the ledger keeps the weakest grade it has been
|
||||
//! given ([`EnergyLedger::provenance`]). A total is no better than its
|
||||
//! weakest part: one estimated deposit makes the whole total an estimate,
|
||||
//! however many readings sit beside it.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
|
||||
|
||||
use crate::Provenance;
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -175,6 +183,32 @@ pub struct EnergyLedger {
|
|||
total_uj: AtomicU64,
|
||||
total_ops: AtomicU64,
|
||||
carbon_gco2_per_kwh: AtomicU64, // f64 bits
|
||||
/// The weakest grade deposited ([`grade_code`]), or [`NO_DEPOSIT`].
|
||||
weakest: AtomicU8,
|
||||
}
|
||||
|
||||
/// [`EnergyLedger::weakest`] before anything is deposited: above every
|
||||
/// grade's code, so the first deposit's `fetch_min` replaces it.
|
||||
const NO_DEPOSIT: u8 = u8::MAX;
|
||||
|
||||
/// A grade as a code ordered weakest first, so the weakest of two is the
|
||||
/// smaller code: the order of [`crate::span::weaker`].
|
||||
fn grade_code(p: Provenance) -> u8 {
|
||||
match p {
|
||||
Provenance::Estimator => 0,
|
||||
Provenance::ModelBased => 1,
|
||||
Provenance::HwShunt => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// The grade a [`grade_code`] stands for; `None` for [`NO_DEPOSIT`].
|
||||
fn grade_of(code: u8) -> Option<Provenance> {
|
||||
match code {
|
||||
0 => Some(Provenance::Estimator),
|
||||
1 => Some(Provenance::ModelBased),
|
||||
2 => Some(Provenance::HwShunt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl EnergyLedger {
|
||||
|
|
@ -186,29 +220,42 @@ impl EnergyLedger {
|
|||
total_uj: AtomicU64::new(0),
|
||||
total_ops: AtomicU64::new(0),
|
||||
carbon_gco2_per_kwh: AtomicU64::new(0),
|
||||
weakest: AtomicU8::new(NO_DEPOSIT),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record energy (μJ) consumed by a layer. Hot path.
|
||||
/// Record energy (μJ) consumed by a layer, graded `provenance`: the
|
||||
/// grade of the figure, which for one of the [`costs`] defaults is
|
||||
/// [`Provenance::Estimator`]. Hot path.
|
||||
#[inline]
|
||||
pub fn record(&self, layer: OperationalLayer, microjoules: u64) {
|
||||
pub fn record(&self, layer: OperationalLayer, microjoules: u64, provenance: Provenance) {
|
||||
let idx = layer.index();
|
||||
self.weakest.fetch_min(grade_code(provenance), Ordering::Relaxed);
|
||||
self.layers[idx].fetch_add(microjoules, Ordering::Relaxed);
|
||||
self.ops[idx].fetch_add(1, Ordering::Relaxed);
|
||||
self.total_uj.fetch_add(microjoules, Ordering::Relaxed);
|
||||
self.total_ops.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Record an explicit batch (energy + operation count).
|
||||
/// Record an explicit batch (energy + operation count), graded
|
||||
/// `provenance` (see [`EnergyLedger::record`]).
|
||||
#[inline]
|
||||
pub fn record_batch(&self, layer: OperationalLayer, microjoules: u64, op_count: u64) {
|
||||
pub fn record_batch(&self, layer: OperationalLayer, microjoules: u64, op_count: u64, provenance: Provenance) {
|
||||
let idx = layer.index();
|
||||
self.weakest.fetch_min(grade_code(provenance), Ordering::Relaxed);
|
||||
self.layers[idx].fetch_add(microjoules, Ordering::Relaxed);
|
||||
self.ops[idx].fetch_add(op_count, Ordering::Relaxed);
|
||||
self.total_uj.fetch_add(microjoules, Ordering::Relaxed);
|
||||
self.total_ops.fetch_add(op_count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The grade of every total this ledger reports: the weakest grade
|
||||
/// among its deposits, `None` while it holds none.
|
||||
#[inline]
|
||||
pub fn provenance(&self) -> Option<Provenance> {
|
||||
grade_of(self.weakest.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Total energy in microjoules.
|
||||
#[inline]
|
||||
pub fn total_uj(&self) -> u64 {
|
||||
|
|
@ -285,6 +332,7 @@ impl EnergyLedger {
|
|||
total_uwh: total_uj / 3600,
|
||||
total_joules: total_uj as f64 / 1_000_000.0,
|
||||
total_ops: self.total_ops(),
|
||||
provenance: self.provenance(),
|
||||
carbon_gco2_per_kwh: self.carbon_intensity_gco2_per_kwh(),
|
||||
total_carbon_gco2e: self.total_carbon_gco2e(),
|
||||
layers,
|
||||
|
|
@ -327,6 +375,7 @@ impl EnergyLedger {
|
|||
}
|
||||
self.total_uj.store(0, Ordering::Relaxed);
|
||||
self.total_ops.store(0, Ordering::Relaxed);
|
||||
self.weakest.store(NO_DEPOSIT, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -350,6 +399,9 @@ pub struct LedgerSnapshot {
|
|||
pub total_joules: f64,
|
||||
/// Total operation count.
|
||||
pub total_ops: u64,
|
||||
/// The grade of every total here: the weakest grade among the
|
||||
/// deposits, `None` for an empty ledger.
|
||||
pub provenance: Option<Provenance>,
|
||||
/// Grid carbon intensity (gCO2 / kWh).
|
||||
pub carbon_gco2_per_kwh: f64,
|
||||
/// Total carbon emissions (gCO2e).
|
||||
|
|
@ -368,8 +420,9 @@ impl LedgerSnapshot {
|
|||
|
||||
/// One-line human-readable summary.
|
||||
pub fn summary(&self) -> String {
|
||||
let grade = self.provenance.map_or_else(|| "no deposits".to_string(), |p| p.to_string());
|
||||
format!(
|
||||
"{:.3} J | {} µWh | {:.6} gCO₂e | {} ops across {} layers",
|
||||
"{:.3} J [{grade}] | {} µWh | {:.6} gCO₂e | {} ops across {} layers",
|
||||
self.total_joules,
|
||||
self.total_uwh,
|
||||
self.total_carbon_gco2e,
|
||||
|
|
@ -419,13 +472,16 @@ pub struct CategoryBreakdown {
|
|||
}
|
||||
|
||||
// ── Default cost constants ────────────────────────────────────────
|
||||
// Conservative upper-bound estimates used when hardware metering is
|
||||
// unavailable. Calibrated against RAPL on Xeon and Apple Silicon
|
||||
// `powermetrics` (inv-energy donor measurements).
|
||||
// Conservative upper-bound figures for when no meter is read. They came
|
||||
// over with measurements from an earlier codebase (RAPL on Xeon,
|
||||
// `powermetrics` on Apple Silicon); none is calibrated on the host that
|
||||
// uses it.
|
||||
|
||||
/// Default per-operation energy cost estimates (μJ).
|
||||
/// Default per-operation energy costs (μJ). Each is a fixed figure, not a
|
||||
/// reading of this host, so a deposit computed from one is
|
||||
/// [`Provenance::Estimator`](crate::Provenance::Estimator).
|
||||
pub mod costs {
|
||||
/// TLS 1.3 ECDHE-P256 handshake (~300 μJ measured).
|
||||
/// TLS 1.3 ECDHE-P256 handshake (~300 μJ).
|
||||
pub const TLS_HANDSHAKE_UJ: u64 = 300;
|
||||
/// JWP frame encode/decode (~5 μJ).
|
||||
pub const JWP_FRAME_UJ: u64 = 5;
|
||||
|
|
@ -468,6 +524,14 @@ pub mod costs {
|
|||
/// JWP command dispatch (~10 μJ).
|
||||
pub const COMMAND_DISPATCH_UJ: u64 = 10;
|
||||
|
||||
// The orderings these defaults must keep, checked when the crate builds.
|
||||
const _: () = {
|
||||
assert!(TLS_HANDSHAKE_UJ > HEARTBEAT_UJ);
|
||||
assert!(AUTH_ECDSA_VERIFY_UJ > AUTH_HMAC_UJ);
|
||||
assert!(BLOCK_WRITE_4K_UJ > BLOCK_READ_4K_UJ);
|
||||
assert!(BLAKE3_1K_UJ < SHA256_1K_UJ);
|
||||
};
|
||||
|
||||
/// Bulk transfer cost.
|
||||
pub fn transfer_uj(bytes: u64) -> u64 {
|
||||
((bytes as f64) * TRANSFER_NJ_PER_BYTE / 1000.0) as u64
|
||||
|
|
@ -481,9 +545,9 @@ mod tests {
|
|||
#[test]
|
||||
fn record_and_read() {
|
||||
let ledger = EnergyLedger::new();
|
||||
ledger.record(OperationalLayer::TlsHandshake, 300);
|
||||
ledger.record(OperationalLayer::Authentication, 15);
|
||||
ledger.record(OperationalLayer::JwpFraming, 5);
|
||||
ledger.record(OperationalLayer::TlsHandshake, 300, Provenance::HwShunt);
|
||||
ledger.record(OperationalLayer::Authentication, 15, Provenance::HwShunt);
|
||||
ledger.record(OperationalLayer::JwpFraming, 5, Provenance::HwShunt);
|
||||
assert_eq!(ledger.total_uj(), 320);
|
||||
assert_eq!(ledger.total_ops(), 3);
|
||||
assert_eq!(ledger.layer_uj(OperationalLayer::TlsHandshake), 300);
|
||||
|
|
@ -492,14 +556,14 @@ mod tests {
|
|||
#[test]
|
||||
fn uwh_conversion() {
|
||||
let ledger = EnergyLedger::new();
|
||||
ledger.record(OperationalLayer::WasmExecution, 7200);
|
||||
ledger.record(OperationalLayer::WasmExecution, 7200, Provenance::HwShunt);
|
||||
assert_eq!(ledger.total_uwh(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joules_conversion() {
|
||||
let ledger = EnergyLedger::new();
|
||||
ledger.record(OperationalLayer::Inference, 1_000_000);
|
||||
ledger.record(OperationalLayer::Inference, 1_000_000, Provenance::HwShunt);
|
||||
assert!((ledger.total_joules() - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
|
|
@ -507,15 +571,15 @@ mod tests {
|
|||
fn carbon_accounting_kwh_scale() {
|
||||
let ledger = EnergyLedger::new();
|
||||
ledger.set_carbon_intensity(400.0);
|
||||
ledger.record(OperationalLayer::WasmExecution, 3_600_000_000_000);
|
||||
ledger.record(OperationalLayer::WasmExecution, 3_600_000_000_000, Provenance::HwShunt);
|
||||
assert!((ledger.total_carbon_gco2e() - 400.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_only_active_layers() {
|
||||
let ledger = EnergyLedger::new();
|
||||
ledger.record(OperationalLayer::TlsHandshake, 100);
|
||||
ledger.record(OperationalLayer::MeshHeartbeat, 3);
|
||||
ledger.record(OperationalLayer::TlsHandshake, 100, Provenance::HwShunt);
|
||||
ledger.record(OperationalLayer::MeshHeartbeat, 3, Provenance::HwShunt);
|
||||
let snap = ledger.snapshot();
|
||||
assert_eq!(snap.layers.len(), 2);
|
||||
assert_eq!(snap.total_uj, 103);
|
||||
|
|
@ -524,10 +588,10 @@ mod tests {
|
|||
#[test]
|
||||
fn category_breakdown_sums_correctly() {
|
||||
let ledger = EnergyLedger::new();
|
||||
ledger.record(OperationalLayer::TlsHandshake, 300);
|
||||
ledger.record(OperationalLayer::JwpFraming, 5);
|
||||
ledger.record(OperationalLayer::Authentication, 15);
|
||||
ledger.record(OperationalLayer::WasmExecution, 1000);
|
||||
ledger.record(OperationalLayer::TlsHandshake, 300, Provenance::HwShunt);
|
||||
ledger.record(OperationalLayer::JwpFraming, 5, Provenance::HwShunt);
|
||||
ledger.record(OperationalLayer::Authentication, 15, Provenance::HwShunt);
|
||||
ledger.record(OperationalLayer::WasmExecution, 1000, Provenance::HwShunt);
|
||||
let cats = ledger.category_breakdown();
|
||||
let protocol = cats.iter().find(|c| c.category == "protocol").expect("protocol");
|
||||
assert_eq!(protocol.energy_uj, 320);
|
||||
|
|
@ -536,10 +600,47 @@ mod tests {
|
|||
assert_eq!(compute.energy_uj, 1000);
|
||||
}
|
||||
|
||||
/// A total is graded by its weakest deposit, whatever the order of the
|
||||
/// deposits; a ledger with none has no grade, and a reset clears it.
|
||||
#[test]
|
||||
fn a_total_is_graded_by_its_weakest_deposit() {
|
||||
let ledger = EnergyLedger::new();
|
||||
assert_eq!(ledger.provenance(), None);
|
||||
assert_eq!(ledger.snapshot().provenance, None);
|
||||
ledger.record(OperationalLayer::Inference, 1_000, Provenance::HwShunt);
|
||||
assert_eq!(ledger.provenance(), Some(Provenance::HwShunt));
|
||||
ledger.record(OperationalLayer::TlsHandshake, costs::TLS_HANDSHAKE_UJ, Provenance::Estimator);
|
||||
ledger.record_batch(OperationalLayer::DataTransfer, 500, 10, Provenance::ModelBased);
|
||||
ledger.record(OperationalLayer::Inference, 1_000, Provenance::HwShunt);
|
||||
assert_eq!(ledger.provenance(), Some(Provenance::Estimator));
|
||||
let snap = ledger.snapshot();
|
||||
assert_eq!(snap.provenance, Some(Provenance::Estimator));
|
||||
assert!(snap.summary().contains("[estimator]"), "{}", snap.summary());
|
||||
ledger.reset();
|
||||
assert_eq!(ledger.provenance(), None);
|
||||
ledger.record_batch(OperationalLayer::DataTransfer, 500, 10, Provenance::ModelBased);
|
||||
assert_eq!(ledger.provenance(), Some(Provenance::ModelBased));
|
||||
}
|
||||
|
||||
/// The ledger's grade codes order grades as [`crate::span::weaker`]
|
||||
/// does, so the weakest code is the weakest grade.
|
||||
#[test]
|
||||
fn grade_codes_keep_the_crate_order() {
|
||||
let all = [Provenance::Estimator, Provenance::ModelBased, Provenance::HwShunt];
|
||||
for a in all {
|
||||
assert_eq!(grade_of(grade_code(a)), Some(a));
|
||||
for b in all {
|
||||
let weakest = grade_of(grade_code(a).min(grade_code(b)));
|
||||
assert_eq!(weakest, Some(crate::span::weaker(a, b)), "{a} vs {b}");
|
||||
}
|
||||
}
|
||||
assert_eq!(grade_of(NO_DEPOSIT), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_recording() {
|
||||
let ledger = EnergyLedger::new();
|
||||
ledger.record_batch(OperationalLayer::DataTransfer, 5000, 100);
|
||||
ledger.record_batch(OperationalLayer::DataTransfer, 5000, 100, Provenance::HwShunt);
|
||||
assert_eq!(ledger.layer_uj(OperationalLayer::DataTransfer), 5000);
|
||||
assert_eq!(ledger.layer_ops(OperationalLayer::DataTransfer), 100);
|
||||
assert_eq!(ledger.total_ops(), 100);
|
||||
|
|
@ -550,8 +651,8 @@ mod tests {
|
|||
// the meters are workload-agnostic, so training energy is attributed and totalled
|
||||
// exactly like inference — closing the "jouleclaw is inference-only" gap.
|
||||
let ledger = EnergyLedger::new();
|
||||
ledger.record(OperationalLayer::Training, 12_000);
|
||||
ledger.record(OperationalLayer::Inference, 3_000);
|
||||
ledger.record(OperationalLayer::Training, 12_000, Provenance::HwShunt);
|
||||
ledger.record(OperationalLayer::Inference, 3_000, Provenance::HwShunt);
|
||||
assert_eq!(ledger.layer_uj(OperationalLayer::Training), 12_000);
|
||||
// training shares the "ai" category with inference…
|
||||
assert_eq!(OperationalLayer::Training.category(), "ai");
|
||||
|
|
@ -563,7 +664,7 @@ mod tests {
|
|||
#[test]
|
||||
fn reset_clears_all() {
|
||||
let ledger = EnergyLedger::new();
|
||||
ledger.record(OperationalLayer::Inference, 999);
|
||||
ledger.record(OperationalLayer::Inference, 999, Provenance::HwShunt);
|
||||
ledger.reset();
|
||||
assert_eq!(ledger.total_uj(), 0);
|
||||
assert_eq!(ledger.layer_uj(OperationalLayer::Inference), 0);
|
||||
|
|
@ -594,8 +695,8 @@ mod tests {
|
|||
#[test]
|
||||
fn avg_uj_per_op() {
|
||||
let ledger = EnergyLedger::new();
|
||||
ledger.record(OperationalLayer::CommandDispatch, 100);
|
||||
ledger.record(OperationalLayer::CommandDispatch, 200);
|
||||
ledger.record(OperationalLayer::CommandDispatch, 100, Provenance::HwShunt);
|
||||
ledger.record(OperationalLayer::CommandDispatch, 200, Provenance::HwShunt);
|
||||
let snap = ledger.snapshot();
|
||||
let cmd = snap
|
||||
.layers
|
||||
|
|
@ -615,7 +716,7 @@ mod tests {
|
|||
let l = ledger.clone();
|
||||
handles.push(std::thread::spawn(move || {
|
||||
for _ in 0..1000 {
|
||||
l.record(OperationalLayer::JwpFraming, 5);
|
||||
l.record(OperationalLayer::JwpFraming, 5, Provenance::HwShunt);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
|
@ -626,14 +727,6 @@ mod tests {
|
|||
assert_eq!(ledger.total_ops(), 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_constants_internally_consistent() {
|
||||
assert!(costs::TLS_HANDSHAKE_UJ > costs::HEARTBEAT_UJ);
|
||||
assert!(costs::AUTH_ECDSA_VERIFY_UJ > costs::AUTH_HMAC_UJ);
|
||||
assert!(costs::BLOCK_WRITE_4K_UJ > costs::BLOCK_READ_4K_UJ);
|
||||
assert!(costs::BLAKE3_1K_UJ < costs::SHA256_1K_UJ);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_cost_calculation() {
|
||||
assert_eq!(costs::transfer_uj(1_000_000), 500);
|
||||
|
|
|
|||
Loading…
Reference in a new issue