eoc-meter: never build the IOReport counter on a CPU rail that does not keep moving #116
27 changed files with 6705 additions and 647 deletions
1
eoc/eoc-rs/Cargo.lock
generated
1
eoc/eoc-rs/Cargo.lock
generated
|
|
@ -716,6 +716,7 @@ name = "eoc"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"clap",
|
||||
"eoc-cache",
|
||||
"eoc-cascade",
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@
|
|||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eoc_core::JouleCost;
|
||||
use eoc_meter::{JouleCounter, StubCounter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
|
@ -98,6 +100,23 @@ impl ToolBox {
|
|||
}
|
||||
}
|
||||
|
||||
/// The power a tool step is taken to draw while it runs when no live counter
|
||||
/// measured it, in watts: about one busy core. A reference figure, to be
|
||||
/// replaced by one calibrated on the deployment host.
|
||||
pub const DEFAULT_TOOL_WATTS: f64 = 15.0;
|
||||
|
||||
/// The estimate of a step that ran for `elapsed` at `watts`, in micro-joules:
|
||||
/// `elapsed` × `watts`, to the nearest micro-joule. Never 0, because the step
|
||||
/// ran: a product under half a micro-joule is 1 µJ, and a power that is not a
|
||||
/// finite positive number is not one to estimate with, so
|
||||
/// [`DEFAULT_TOOL_WATTS`] stands in for it.
|
||||
pub fn duration_estimate(elapsed: Duration, watts: f64) -> u64 {
|
||||
let watts = if watts.is_finite() && watts > 0.0 { watts } else { DEFAULT_TOOL_WATTS };
|
||||
let uj = (elapsed.as_secs_f64() * watts * 1_000_000.0).round();
|
||||
// `as` saturates at u64::MAX.
|
||||
(uj as u64).max(1)
|
||||
}
|
||||
|
||||
/// Combined per-loop budget.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct Budget {
|
||||
|
|
@ -238,28 +257,79 @@ impl AgentLoop {
|
|||
}
|
||||
}
|
||||
|
||||
/// Take a meter reading. Returns 0 if the counter is unavailable.
|
||||
pub fn read_meter(&self) -> u64 {
|
||||
self.meter.read_microjoules().unwrap_or(0)
|
||||
/// Take a meter reading: the counter's cumulative micro-joules, or `None`
|
||||
/// when it could not be read. A failed read is not 0: subtracting a 0
|
||||
/// from the next successful read would charge the counter's whole
|
||||
/// since-boot total to whatever ran in between.
|
||||
pub fn read_meter(&self) -> Option<u64> {
|
||||
self.meter.read_microjoules().ok()
|
||||
}
|
||||
|
||||
/// Wrap an async block, charging measured wall-energy from the meter
|
||||
/// **plus** an upper-bound `estimated_microjoules` so loops degrade
|
||||
/// gracefully on stub-only hosts.
|
||||
/// What the meter measured over a span whose opening reading was
|
||||
/// `start`: `Some(delta)` only when that read succeeded, a read now
|
||||
/// succeeds, and the counter advanced between the two. `None` otherwise —
|
||||
/// a failed read at either end, a counter that held or went down, the
|
||||
/// [`StubCounter`] — never 0, and never the counter's since-boot total.
|
||||
/// Without an opening reading the counter is not read again.
|
||||
pub fn span_reading(&self, start: Option<u64>) -> Option<u64> {
|
||||
let start = start?;
|
||||
let now = self.read_meter()?;
|
||||
(now > start).then(|| now - start)
|
||||
}
|
||||
|
||||
/// Run an async block as one step and say what to charge it: what the
|
||||
/// meter measured over the step ([`Self::span_reading`]), or the larger of
|
||||
/// that and `estimated_microjoules`, the step's own estimate; and when
|
||||
/// the meter measured nothing, the estimate alone. The same rule
|
||||
/// eoc-local's backends and eoc-streaming meter by: a span is measured
|
||||
/// only when both reads succeed and the counter advanced. The figure
|
||||
/// alone; [`Self::measured_with`] also says whether it was measured.
|
||||
pub async fn measured<F, Fut, T>(&self, estimated_microjoules: u64, f: F) -> (T, u64)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = T>,
|
||||
{
|
||||
let before = self.read_meter();
|
||||
let (out, cost) = self.measured_with(|_| estimated_microjoules, f).await;
|
||||
(out, cost.microjoules)
|
||||
}
|
||||
|
||||
/// [`Self::measured`], with the step's estimate made once it has run, by
|
||||
/// `estimate`, from how long it ran (monotonic wall-clock time), and the
|
||||
/// figure's provenance: [`JouleCost::measured`] when the figure is what
|
||||
/// the meter read, [`JouleCost::estimated`] when it is the estimate —
|
||||
/// because the meter measured nothing, or read less than the estimate.
|
||||
pub async fn measured_with<E, F, Fut, T>(&self, estimate: E, f: F) -> (T, JouleCost)
|
||||
where
|
||||
E: FnOnce(Duration) -> u64,
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = T>,
|
||||
{
|
||||
let start = self.read_meter();
|
||||
let started = Instant::now();
|
||||
let out = f().await;
|
||||
let after = self.read_meter();
|
||||
let measured = after.saturating_sub(before);
|
||||
// Use the larger of measured vs estimated as the charge.
|
||||
let charged = measured.max(estimated_microjoules);
|
||||
let estimated = estimate(started.elapsed());
|
||||
let charged = match self.span_reading(start) {
|
||||
Some(measured) if measured >= estimated => JouleCost::measured(measured),
|
||||
_ => JouleCost::estimated(estimated),
|
||||
};
|
||||
(out, charged)
|
||||
}
|
||||
|
||||
/// Run a tool step and say what to charge it, and whether that was
|
||||
/// measured, by the rule of [`Self::measured_with`], with the step's
|
||||
/// estimate its duration at [`DEFAULT_TOOL_WATTS`]
|
||||
/// ([`duration_estimate`]). A tool that ran did work, so a step the meter
|
||||
/// did not measure — the [`StubCounter`], a failed read, a counter that
|
||||
/// held — is charged that estimate, never 0 µJ, and a tool that failed is
|
||||
/// charged for the time it ran too.
|
||||
pub async fn measured_tool<F, Fut, T>(&self, f: F) -> (T, JouleCost)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = T>,
|
||||
{
|
||||
self.measured_with(|elapsed| duration_estimate(elapsed, DEFAULT_TOOL_WATTS), f).await
|
||||
}
|
||||
|
||||
/// Append a span to the trace.
|
||||
pub fn push_span(&mut self, span: Span) {
|
||||
self.trace.push(span);
|
||||
|
|
@ -267,6 +337,10 @@ impl AgentLoop {
|
|||
|
||||
/// Charge an LLM step against the budget *and* record the span.
|
||||
/// Returns the stop reason if the budget is exhausted.
|
||||
///
|
||||
/// The step's figure is the provider's own, [`LlmReply::microjoules`],
|
||||
/// an estimate by [`LlmProvider`]'s contract that no counter here read,
|
||||
/// so the span is [`JouleSource::Estimated`](eoc_core::JouleSource).
|
||||
pub fn charge_llm_step(
|
||||
&mut self,
|
||||
kind: SpanKind,
|
||||
|
|
@ -276,7 +350,7 @@ impl AgentLoop {
|
|||
) -> Option<StopReason> {
|
||||
let span = Span::new(self.trace.len(), kind, label, input, reply.text.clone())
|
||||
.with_tokens(reply.tokens)
|
||||
.with_microjoules(reply.microjoules);
|
||||
.with_joule_cost(JouleCost::estimated(reply.microjoules));
|
||||
self.push_span(span);
|
||||
self.budget.charge_llm(reply.tokens, reply.microjoules)
|
||||
}
|
||||
|
|
@ -296,9 +370,173 @@ impl AgentLoop {
|
|||
}
|
||||
}
|
||||
|
||||
/// Test support: a counter that reads a script.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod scripted {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use eoc_meter::JouleCounter;
|
||||
|
||||
/// A counter that reads the scripted values in turn, then repeats the
|
||||
/// last one; `None` is a failed read. Counts its reads.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Scripted {
|
||||
script: Vec<Option<u64>>,
|
||||
reads: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl Scripted {
|
||||
pub(crate) fn new(script: &[Option<u64>]) -> Self {
|
||||
assert!(!script.is_empty(), "a script needs at least one read");
|
||||
Self { script: script.to_vec(), reads: Mutex::new(0) }
|
||||
}
|
||||
|
||||
/// How many times the counter has been read.
|
||||
pub(crate) fn reads(&self) -> usize {
|
||||
*self.reads.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl JouleCounter for Scripted {
|
||||
fn read_microjoules(&self) -> eoc_core::Result<u64> {
|
||||
let mut reads = self.reads.lock().unwrap();
|
||||
let value = self.script[(*reads).min(self.script.len() - 1)];
|
||||
*reads += 1;
|
||||
value.ok_or(eoc_core::Error::NoCounter)
|
||||
}
|
||||
fn name(&self) -> &'static str {
|
||||
"scripted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::scripted::Scripted;
|
||||
use super::*;
|
||||
use eoc_core::JouleSource;
|
||||
|
||||
/// What `measured` charges for one step with the estimate `estimate`,
|
||||
/// metered by a counter reading `script`, and how many reads it took.
|
||||
async fn charge(script: &[Option<u64>], estimate: u64) -> (u64, usize) {
|
||||
let meter = Arc::new(Scripted::new(script));
|
||||
let loop_ = AgentLoop::with_meter(Budget::unlimited(10), meter.clone());
|
||||
let (out, charged) = loop_.measured(estimate, || async { 42 }).await;
|
||||
assert_eq!(out, 42);
|
||||
(charged, meter.reads())
|
||||
}
|
||||
|
||||
/// A failed read is `None`, never 0.
|
||||
#[test]
|
||||
fn a_failed_read_is_no_reading() {
|
||||
let loop_ = AgentLoop::with_meter(Budget::unlimited(1), Arc::new(Scripted::new(&[None, Some(9_000_000)])));
|
||||
assert_eq!(loop_.read_meter(), None);
|
||||
assert_eq!(loop_.read_meter(), Some(9_000_000));
|
||||
}
|
||||
|
||||
/// A failed first read is no baseline: the next read is the counter's
|
||||
/// since-boot total (9 J here), and the step is charged its own estimate,
|
||||
/// not that. The counter is not read a second time.
|
||||
#[tokio::test]
|
||||
async fn a_failed_first_read_never_charges_the_since_boot_total() {
|
||||
assert_eq!(charge(&[None, Some(9_000_000)], 250).await, (250, 1));
|
||||
assert_eq!(charge(&[None, Some(9_000_000)], 0).await, (0, 1));
|
||||
}
|
||||
|
||||
/// Both reads succeeded and the counter advanced: the step is charged
|
||||
/// the delta, or its estimate when that is larger.
|
||||
#[tokio::test]
|
||||
async fn a_counter_that_advanced_charges_the_delta() {
|
||||
assert_eq!(charge(&[Some(1_000), Some(1_450)], 0).await, (450, 2));
|
||||
assert_eq!(charge(&[Some(1_000), Some(1_450)], 100).await, (450, 2));
|
||||
assert_eq!(charge(&[Some(1_000), Some(1_450)], 1_000).await, (1_000, 2));
|
||||
}
|
||||
|
||||
/// A failed last read, a counter that held, one that went down, and the
|
||||
/// stub measured nothing: the step is charged its estimate.
|
||||
#[tokio::test]
|
||||
async fn a_span_that_measured_nothing_charges_the_estimate() {
|
||||
assert_eq!(charge(&[Some(1_000), None], 250).await, (250, 2));
|
||||
assert_eq!(charge(&[Some(7_000), Some(7_000)], 250).await, (250, 2));
|
||||
assert_eq!(charge(&[Some(7_000), Some(6_000)], 250).await, (250, 2));
|
||||
let stub = AgentLoop::new(Budget::unlimited(1));
|
||||
assert_eq!(stub.measured(250, || async {}).await.1, 250);
|
||||
assert_eq!(stub.span_reading(stub.read_meter()), None);
|
||||
}
|
||||
|
||||
/// A step's duration estimate is its duration at the given power, and
|
||||
/// never 0: a step too short to cost half a micro-joule costs 1 µJ, and a
|
||||
/// power that is no power gives way to the default.
|
||||
#[test]
|
||||
fn a_duration_estimate_is_time_by_power_and_never_zero() {
|
||||
assert_eq!(duration_estimate(Duration::from_secs(2), 15.0), 30_000_000);
|
||||
assert_eq!(duration_estimate(Duration::from_millis(20), DEFAULT_TOOL_WATTS), 300_000);
|
||||
assert_eq!(duration_estimate(Duration::from_micros(10), 2.5), 25);
|
||||
assert_eq!(duration_estimate(Duration::ZERO, 15.0), 1);
|
||||
assert_eq!(duration_estimate(Duration::from_nanos(1), 15.0), 1);
|
||||
for no_power in [0.0, -3.0, f64::NAN, f64::INFINITY] {
|
||||
assert_eq!(duration_estimate(Duration::from_secs(1), no_power), 15_000_000, "{no_power}");
|
||||
}
|
||||
assert_eq!(duration_estimate(Duration::MAX, 15.0), u64::MAX);
|
||||
}
|
||||
|
||||
/// A tool step the meter did not measure is charged its duration at the
|
||||
/// default power, never 0: here the stub, a failed first read, and a
|
||||
/// counter that held, each over a step that runs at least 20 ms. A step
|
||||
/// the meter measured is charged the reading, or the estimate when that
|
||||
/// is larger.
|
||||
#[tokio::test]
|
||||
async fn a_tool_step_is_charged_at_least_its_duration_estimate() {
|
||||
let floor = duration_estimate(Duration::from_millis(20), DEFAULT_TOOL_WATTS);
|
||||
let step = || async { tokio::time::sleep(Duration::from_millis(20)).await };
|
||||
let stub = AgentLoop::new(Budget::unlimited(1));
|
||||
let (_, charged) = stub.measured_tool(step).await;
|
||||
assert!(charged.microjoules >= floor, "the stub: charged {charged} for 20 ms of work");
|
||||
for script in [&[None, Some(9_000_000)][..], &[Some(7_000), Some(7_000)][..]] {
|
||||
let loop_ = AgentLoop::with_meter(Budget::unlimited(1), Arc::new(Scripted::new(script)));
|
||||
let (_, charged) = loop_.measured_tool(step).await;
|
||||
assert!(charged.microjoules >= floor && charged.microjoules < 9_000_000, "{script:?}: charged {charged}");
|
||||
}
|
||||
let big = AgentLoop::with_meter(Budget::unlimited(1), Arc::new(Scripted::new(&[Some(0), Some(50_000_000)])));
|
||||
assert_eq!(big.measured_tool(step).await.1.microjoules, 50_000_000, "a reading above the estimate");
|
||||
}
|
||||
|
||||
/// A step's figure is measured only when it is what a live counter read:
|
||||
/// the counter advanced over the step, by at least the step's estimate.
|
||||
/// The stub, a failed first or last read, a counter that held or went
|
||||
/// down, and a counter that advanced by less than the estimate (10 µJ
|
||||
/// over a 20 ms step, so the estimate is charged) all give an estimate.
|
||||
#[tokio::test]
|
||||
async fn a_step_is_measured_only_when_charged_a_live_counters_reading() {
|
||||
let floor = duration_estimate(Duration::from_millis(20), DEFAULT_TOOL_WATTS);
|
||||
let step = || async { tokio::time::sleep(Duration::from_millis(20)).await };
|
||||
let (_, stub) = AgentLoop::new(Budget::unlimited(1)).measured_tool(step).await;
|
||||
assert_eq!(stub.source, JouleSource::Estimated, "the stub: {stub}");
|
||||
for script in [
|
||||
&[None, Some(9_000_000)][..],
|
||||
&[Some(7_000), None][..],
|
||||
&[Some(7_000), Some(7_000)][..],
|
||||
&[Some(7_000), Some(6_000)][..],
|
||||
&[Some(0), Some(10)][..],
|
||||
] {
|
||||
let loop_ = AgentLoop::with_meter(Budget::unlimited(1), Arc::new(Scripted::new(script)));
|
||||
let (_, cost) = loop_.measured_tool(step).await;
|
||||
assert_eq!(cost.source, JouleSource::Estimated, "{script:?}: {cost}");
|
||||
assert!(cost.microjoules >= floor, "{script:?}: {cost}");
|
||||
}
|
||||
let big = AgentLoop::with_meter(Budget::unlimited(1), Arc::new(Scripted::new(&[Some(0), Some(50_000_000)])));
|
||||
assert_eq!(big.measured_tool(step).await.1, JouleCost::measured(50_000_000));
|
||||
// A fixed estimate: the reading when it is at least the estimate.
|
||||
for (script, estimate, cost) in [
|
||||
(&[Some(1_000), Some(1_450)], 100, JouleCost::measured(450)),
|
||||
(&[Some(1_000), Some(1_450)], 450, JouleCost::measured(450)),
|
||||
(&[Some(1_000), Some(1_450)], 1_000, JouleCost::estimated(1_000)),
|
||||
(&[Some(7_000), Some(7_000)], 250, JouleCost::estimated(250)),
|
||||
] {
|
||||
let loop_ = AgentLoop::with_meter(Budget::unlimited(1), Arc::new(Scripted::new(script)));
|
||||
assert_eq!(loop_.measured_with(|_| estimate, || async {}).await.1, cost, "{script:?} {estimate}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budget_charge_llm_trips_token_cap() {
|
||||
|
|
@ -315,5 +553,7 @@ mod tests {
|
|||
assert!(stop.is_none());
|
||||
assert_eq!(loop_.trace.len(), 1);
|
||||
assert_eq!(loop_.trace.total_tokens(), 3);
|
||||
// The provider's own figure: an estimate.
|
||||
assert_eq!(loop_.trace.spans[0].joule_cost(), JouleCost::estimated(100));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,20 +168,23 @@ impl Agent for CodeActLoop {
|
|||
return Ok((last_output, StopReason::Error("no code block in reply".into())));
|
||||
}
|
||||
};
|
||||
let (out_res, microjoules) = self
|
||||
// Charged what the meter measured, or at least the step's
|
||||
// duration estimate: code that ran is never 0 µJ. The span says
|
||||
// which.
|
||||
let (out_res, cost) = self
|
||||
.inner
|
||||
.measured(0, || async { self.runtime.run(&code).await })
|
||||
.measured_tool(|| async { self.runtime.run(&code).await })
|
||||
.await;
|
||||
let out = match out_res {
|
||||
Ok(o) => o,
|
||||
Err(e) => format!("runtime_error: {e}"),
|
||||
};
|
||||
if let Some(stop) = self.inner.budget.charge_tool(microjoules) {
|
||||
if let Some(stop) = self.inner.budget.charge_tool(cost.microjoules) {
|
||||
return Ok((last_output, stop));
|
||||
}
|
||||
self.inner.push_span(
|
||||
crate::trace::Span::new(0, SpanKind::Act, "codeact.exec", code.clone(), out.clone())
|
||||
.with_microjoules(microjoules),
|
||||
.with_joule_cost(cost),
|
||||
);
|
||||
transcript.push_str(&format!("CODE:\n{code}\nOBSERVATION:\n{out}\n"));
|
||||
}
|
||||
|
|
@ -200,6 +203,7 @@ impl Agent for CodeActLoop {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use eoc_core::{JouleCost, JouleSource};
|
||||
|
||||
#[test]
|
||||
fn extract_code_finds_block() {
|
||||
|
|
@ -218,6 +222,80 @@ mod tests {
|
|||
assert!(CodeActLoop::extract_final("not done").is_none());
|
||||
}
|
||||
|
||||
/// A provider that replies with a script, 1,000 µJ a reply.
|
||||
struct Replies(std::sync::Mutex<Vec<&'static str>>);
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for Replies {
|
||||
async fn complete(&self, _prompt: &str) -> AgentResult<crate::agent::LlmReply> {
|
||||
let text = self.0.lock().unwrap().remove(0);
|
||||
Ok(crate::agent::LlmReply::new(text, 5, 1_000))
|
||||
}
|
||||
}
|
||||
|
||||
/// A runtime that takes at least 20 ms, and fails or not as told.
|
||||
struct SlowRuntime(bool);
|
||||
|
||||
#[async_trait]
|
||||
impl CodeRuntime for SlowRuntime {
|
||||
async fn run(&self, code: &str) -> AgentResult<String> {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
if self.0 {
|
||||
Err(AgentError::Tool { tool: "runtime".into(), reason: format!("failed: {code}") })
|
||||
} else {
|
||||
Ok(format!("ran {code}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Code the meter does not measure (the host default, the stub) is
|
||||
/// charged its duration estimate — at least 20 ms at the default power for
|
||||
/// a runtime that runs at least 20 ms — on the span and against the
|
||||
/// budget, whether it succeeded or failed: it ran either way.
|
||||
#[tokio::test]
|
||||
async fn unmetered_code_is_charged_its_duration_estimate() {
|
||||
let floor = crate::agent::duration_estimate(std::time::Duration::from_millis(20), crate::agent::DEFAULT_TOOL_WATTS);
|
||||
for fails in [false, true] {
|
||||
let provider = Arc::new(Replies(std::sync::Mutex::new(vec!["```\nnoop()\n```", "FINAL: done"])));
|
||||
let mut agent = CodeActLoop::new(provider, Arc::new(SlowRuntime(fails)), Budget::unlimited(10));
|
||||
let (out, stop) = agent.run("go").await.unwrap();
|
||||
assert_eq!((out.as_str(), stop), ("done", StopReason::Done));
|
||||
let act = agent.trace().spans.iter().find(|s| s.kind == SpanKind::Act).expect("an act span");
|
||||
assert!(act.microjoules >= floor, "fails={fails}: 20 ms of code charged {} µJ", act.microjoules);
|
||||
assert_eq!(agent.budget().joules.used_microjoules, 2_000 + act.microjoules);
|
||||
for span in &agent.trace().spans {
|
||||
assert_eq!(span.joule_source, JouleSource::Estimated, "fails={fails}: {span:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Code over which the counter advanced 50 J is measured, and charged
|
||||
/// that, whether it succeeded or failed; the model steps around it (the
|
||||
/// provider's own figures) are estimates.
|
||||
#[tokio::test]
|
||||
async fn metered_code_is_measured_and_model_steps_are_estimated() {
|
||||
for fails in [false, true] {
|
||||
let meter = Arc::new(crate::agent::scripted::Scripted::new(&[Some(0), Some(50_000_000)]));
|
||||
let provider = Arc::new(Replies(std::sync::Mutex::new(vec!["```\nnoop()\n```", "FINAL: done"])));
|
||||
let budget = Budget::unlimited(10);
|
||||
let mut agent = CodeActLoop::new(provider, Arc::new(SlowRuntime(fails)), budget);
|
||||
agent.inner = AgentLoop::with_meter(budget, meter);
|
||||
let (out, stop) = agent.run("go").await.unwrap();
|
||||
assert_eq!((out.as_str(), stop), ("done", StopReason::Done));
|
||||
let costs: Vec<_> = agent.trace().spans.iter().map(|s| (s.kind, s.joule_cost())).collect();
|
||||
assert_eq!(
|
||||
costs,
|
||||
[
|
||||
(SpanKind::Think, JouleCost::estimated(1_000)),
|
||||
(SpanKind::Act, JouleCost::measured(50_000_000)),
|
||||
(SpanKind::Think, JouleCost::estimated(1_000)),
|
||||
],
|
||||
"fails={fails}"
|
||||
);
|
||||
assert_eq!(agent.budget().joules.used_microjoules, 2_000 + 50_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_line_lenient() {
|
||||
let (n, a) = LineLangRuntime::parse_line("calc(1+1)").unwrap();
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use async_trait::async_trait;
|
|||
|
||||
use crate::agent::{Agent, AgentLoop, Budget, LlmProvider, StopReason};
|
||||
use crate::error::{AgentError, AgentResult};
|
||||
use crate::trace::SpanKind;
|
||||
use crate::trace::{Span, SpanKind};
|
||||
|
||||
/// Decomposes a goal into ordered subgoals.
|
||||
#[async_trait]
|
||||
|
|
@ -110,34 +110,48 @@ impl PlanExecuteLoop {
|
|||
|
||||
#[async_trait]
|
||||
impl Agent for PlanExecuteLoop {
|
||||
/// Neither a [`Planner`] nor a [`Worker`] reports what its step cost, so
|
||||
/// each step is metered here, by the rule of [`AgentLoop::measured_tool`]:
|
||||
/// what the meter measured over it, or at least its duration at
|
||||
/// [`DEFAULT_TOOL_WATTS`](crate::agent::DEFAULT_TOOL_WATTS) — never 0 µJ
|
||||
/// for a step that ran — and its span says which of the two it is
|
||||
/// charged. The plan step is charged against the joule cap,
|
||||
/// and each worker step against the joule cap and the tool-call quota. A
|
||||
/// step that fails ran too: it is recorded and charged, and then its
|
||||
/// error is returned.
|
||||
async fn run(&mut self, goal: &str) -> AgentResult<(String, StopReason)> {
|
||||
let subgoals = self.planner.plan(goal).await?;
|
||||
// Charge a notional cost for the planner step; planners that
|
||||
// wrap LlmProvider directly already self-meter via charge_llm_step,
|
||||
// but the trait-level abstraction does not.
|
||||
self.inner.push_span(crate::trace::Span::new(
|
||||
0,
|
||||
SpanKind::Plan,
|
||||
"plan_execute.plan",
|
||||
goal,
|
||||
subgoals.join(" | "),
|
||||
));
|
||||
let (planned, cost) = self.inner.measured_tool(|| self.planner.plan(goal)).await;
|
||||
let output = match &planned {
|
||||
Ok(subgoals) => subgoals.join(" | "),
|
||||
Err(e) => format!("ERROR: {e}"),
|
||||
};
|
||||
self.inner.push_span(
|
||||
Span::new(0, SpanKind::Plan, "plan_execute.plan", goal, output).with_joule_cost(cost),
|
||||
);
|
||||
let within_cap = self.inner.budget.joules.charge(cost.microjoules);
|
||||
let subgoals = planned?;
|
||||
if !within_cap {
|
||||
return Ok((String::new(), StopReason::BudgetExhausted("joules".into())));
|
||||
}
|
||||
|
||||
let mut prior = String::new();
|
||||
for (i, sub) in subgoals.iter().enumerate() {
|
||||
if let Some(stop) = self.inner.iteration_check(i) {
|
||||
return Ok((prior, stop));
|
||||
}
|
||||
let out = self.worker.execute(sub, &prior).await?;
|
||||
self.inner.push_span(crate::trace::Span::new(
|
||||
0,
|
||||
SpanKind::Execute,
|
||||
"plan_execute.execute",
|
||||
sub,
|
||||
out.clone(),
|
||||
));
|
||||
// Charge tool-call quota for the worker step.
|
||||
if let Some(stop) = self.inner.budget.charge_tool(0) {
|
||||
let (executed, cost) =
|
||||
self.inner.measured_tool(|| self.worker.execute(sub, &prior)).await;
|
||||
let output = match &executed {
|
||||
Ok(out) => out.clone(),
|
||||
Err(e) => format!("ERROR: {e}"),
|
||||
};
|
||||
self.inner.push_span(
|
||||
Span::new(0, SpanKind::Execute, "plan_execute.execute", sub, output)
|
||||
.with_joule_cost(cost),
|
||||
);
|
||||
let stop = self.inner.budget.charge_tool(cost.microjoules);
|
||||
let out = executed?;
|
||||
if let Some(stop) = stop {
|
||||
return Ok((out, stop));
|
||||
}
|
||||
prior = out;
|
||||
|
|
@ -157,6 +171,7 @@ impl Agent for PlanExecuteLoop {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use eoc_core::{JouleCost, JouleSource};
|
||||
|
||||
struct StaticPlanner(Vec<String>);
|
||||
#[async_trait]
|
||||
|
|
@ -183,4 +198,118 @@ mod tests {
|
|||
assert_eq!(out, "|a|b|c");
|
||||
assert_eq!(reason, StopReason::Done);
|
||||
}
|
||||
|
||||
const STEP: std::time::Duration = std::time::Duration::from_millis(20);
|
||||
|
||||
/// What a step of at least [`STEP`] is charged at least, unmetered.
|
||||
fn floor() -> u64 {
|
||||
crate::agent::duration_estimate(STEP, crate::agent::DEFAULT_TOOL_WATTS)
|
||||
}
|
||||
|
||||
/// A planner that takes at least [`STEP`] to plan `a`, `b`.
|
||||
struct SlowPlanner;
|
||||
#[async_trait]
|
||||
impl Planner for SlowPlanner {
|
||||
async fn plan(&self, _goal: &str) -> AgentResult<Vec<String>> {
|
||||
tokio::time::sleep(STEP).await;
|
||||
Ok(vec!["a".into(), "b".into()])
|
||||
}
|
||||
}
|
||||
|
||||
/// A worker that takes at least [`STEP`], and fails or not as told.
|
||||
struct SlowWorker(bool);
|
||||
#[async_trait]
|
||||
impl Worker for SlowWorker {
|
||||
async fn execute(&self, subgoal: &str, prior: &str) -> AgentResult<String> {
|
||||
tokio::time::sleep(STEP).await;
|
||||
if self.0 {
|
||||
Err(AgentError::Tool { tool: "worker".into(), reason: format!("failed: {subgoal}") })
|
||||
} else {
|
||||
Ok(format!("{prior}|{subgoal}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps the meter does not measure (the host default, the stub) are
|
||||
/// charged their duration estimates, never 0: the plan step and each
|
||||
/// worker step, each running at least 20 ms, carry at least 20 ms at the
|
||||
/// default power on their spans, and the budget is charged exactly what
|
||||
/// the spans carry, with one tool call a worker step.
|
||||
#[tokio::test]
|
||||
async fn unmetered_steps_are_charged_their_duration_estimates() {
|
||||
let mut pe = PlanExecuteLoop::new(Arc::new(SlowPlanner), Arc::new(SlowWorker(false)), Budget::unlimited(10));
|
||||
let (out, stop) = pe.run("go").await.unwrap();
|
||||
assert_eq!((out.as_str(), stop), ("|a|b", StopReason::Done));
|
||||
let spans = &pe.trace().spans;
|
||||
let kinds: Vec<_> = spans.iter().map(|s| s.kind).collect();
|
||||
assert_eq!(kinds, [SpanKind::Plan, SpanKind::Execute, SpanKind::Execute]);
|
||||
for span in spans {
|
||||
assert!(span.microjoules >= floor(), "a 20 ms step charged {} µJ: {span:?}", span.microjoules);
|
||||
assert_eq!(span.joule_source, JouleSource::Estimated, "{span:?}");
|
||||
}
|
||||
assert_eq!(pe.budget().joules.used_microjoules, pe.trace().total_microjoules());
|
||||
assert_eq!(pe.budget().tool_calls_used, 2);
|
||||
}
|
||||
|
||||
/// A step the meter measured is charged the counter's reading, and its
|
||||
/// span says measured: 40 J over the plan step and 50 J over worker `b`.
|
||||
/// Over worker `a` the counter held, so `a` is charged its own estimate,
|
||||
/// which is not 0 and is far below a joule for an echo, and says
|
||||
/// estimated.
|
||||
#[tokio::test]
|
||||
async fn a_metered_step_is_charged_the_counters_reading() {
|
||||
let meter = Arc::new(crate::agent::scripted::Scripted::new(&[
|
||||
Some(0),
|
||||
Some(40_000_000),
|
||||
Some(40_000_000),
|
||||
Some(40_000_000),
|
||||
Some(40_000_000),
|
||||
Some(90_000_000),
|
||||
]));
|
||||
let planner = Arc::new(StaticPlanner(vec!["a".into(), "b".into()]));
|
||||
let budget = Budget::unlimited(10);
|
||||
let mut pe = PlanExecuteLoop::new(planner, Arc::new(EchoWorker), budget);
|
||||
pe.inner = AgentLoop::with_meter(budget, meter.clone());
|
||||
let (out, stop) = pe.run("go").await.unwrap();
|
||||
assert_eq!((out.as_str(), stop), ("|a|b", StopReason::Done));
|
||||
assert_eq!(meter.reads(), 6);
|
||||
let spans = &pe.trace().spans;
|
||||
assert_eq!(spans[0].joule_cost(), JouleCost::measured(40_000_000), "{spans:?}");
|
||||
assert!((1..1_000_000).contains(&spans[1].microjoules), "{spans:?}");
|
||||
assert_eq!(spans[1].joule_source, JouleSource::Estimated, "{spans:?}");
|
||||
assert_eq!(spans[2].joule_cost(), JouleCost::measured(50_000_000), "{spans:?}");
|
||||
assert_eq!(pe.budget().joules.used_microjoules, 90_000_000 + spans[1].microjoules);
|
||||
}
|
||||
|
||||
/// A worker step that fails ran too: its span records the error and its
|
||||
/// duration estimate, the budget is charged that and a tool call, and
|
||||
/// then the error is returned.
|
||||
#[tokio::test]
|
||||
async fn a_failed_worker_step_is_recorded_and_charged() {
|
||||
let planner = Arc::new(StaticPlanner(vec!["a".into(), "b".into()]));
|
||||
let mut pe = PlanExecuteLoop::new(planner, Arc::new(SlowWorker(true)), Budget::unlimited(10));
|
||||
let err = pe.run("go").await.unwrap_err();
|
||||
assert!(matches!(err, AgentError::Tool { .. }), "{err:?}");
|
||||
let last = pe.trace().spans.last().expect("a span");
|
||||
assert_eq!(last.kind, SpanKind::Execute);
|
||||
assert!(last.output.starts_with("ERROR: "), "{last:?}");
|
||||
assert!(last.microjoules >= floor(), "a failed 20 ms step charged {} µJ", last.microjoules);
|
||||
assert_eq!(pe.budget().joules.used_microjoules, pe.trace().total_microjoules());
|
||||
assert_eq!(pe.budget().tool_calls_used, 1);
|
||||
}
|
||||
|
||||
/// A joule cap the plan step already spends stops the loop before any
|
||||
/// worker runs.
|
||||
#[tokio::test]
|
||||
async fn a_plan_step_over_the_joule_cap_stops_the_loop() {
|
||||
let mut pe = PlanExecuteLoop::new(
|
||||
Arc::new(SlowPlanner),
|
||||
Arc::new(SlowWorker(false)),
|
||||
Budget::new(1_000, 0.000_001, 10, 10),
|
||||
);
|
||||
let (out, stop) = pe.run("go").await.unwrap();
|
||||
assert_eq!((out.as_str(), stop), ("", StopReason::BudgetExhausted("joules".into())));
|
||||
assert_eq!(pe.trace().len(), 1);
|
||||
assert_eq!(pe.budget().tool_calls_used, 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,15 +160,18 @@ impl Agent for ReActLoop {
|
|||
};
|
||||
|
||||
let args = serde_json::json!({ "input": step.argument });
|
||||
let (result, microjoules) = self
|
||||
// Charged what the meter measured, or at least the step's
|
||||
// duration estimate: a tool that ran is never 0 µJ. The span
|
||||
// says which.
|
||||
let (result, cost) = self
|
||||
.inner
|
||||
.measured(0, || async { tool.run(args).await })
|
||||
.measured_tool(|| async { tool.run(args).await })
|
||||
.await;
|
||||
let observation = match result {
|
||||
Ok(v) => v.to_string(),
|
||||
Err(e) => format!("ERROR: {e}"),
|
||||
};
|
||||
if let Some(stop) = self.inner.budget.charge_tool(microjoules) {
|
||||
if let Some(stop) = self.inner.budget.charge_tool(cost.microjoules) {
|
||||
return Ok((self.last_output.clone(), stop));
|
||||
}
|
||||
self.inner.push_span(
|
||||
|
|
@ -179,7 +182,7 @@ impl Agent for ReActLoop {
|
|||
step.argument.clone(),
|
||||
observation.clone(),
|
||||
)
|
||||
.with_microjoules(microjoules),
|
||||
.with_joule_cost(cost),
|
||||
);
|
||||
self.transcript.push_str(&format!(
|
||||
"Thought: {th}\nAction: {ac}[{arg}]\nObservation: {obs}\n",
|
||||
|
|
@ -204,6 +207,7 @@ impl Agent for ReActLoop {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use eoc_core::{JouleCost, JouleSource};
|
||||
|
||||
#[test]
|
||||
fn parse_thought_action() {
|
||||
|
|
@ -225,4 +229,116 @@ mod tests {
|
|||
let err = ReActStep::parse("Thought: nope\n").unwrap_err();
|
||||
assert!(matches!(err, AgentError::Parse(_)));
|
||||
}
|
||||
|
||||
/// A provider that replies with a script, 1,000 µJ a reply.
|
||||
struct Replies(std::sync::Mutex<Vec<&'static str>>);
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for Replies {
|
||||
async fn complete(&self, _prompt: &str) -> AgentResult<crate::agent::LlmReply> {
|
||||
let text = self.0.lock().unwrap().remove(0);
|
||||
Ok(crate::agent::LlmReply::new(text, 5, 1_000))
|
||||
}
|
||||
}
|
||||
|
||||
struct Echo;
|
||||
|
||||
#[async_trait]
|
||||
impl crate::agent::Tool for Echo {
|
||||
fn name(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
async fn run(&self, args: serde_json::Value) -> AgentResult<serde_json::Value> {
|
||||
Ok(args)
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool that takes at least 20 ms and echoes its arguments.
|
||||
struct Slow;
|
||||
|
||||
#[async_trait]
|
||||
impl crate::agent::Tool for Slow {
|
||||
fn name(&self) -> &str {
|
||||
"slow"
|
||||
}
|
||||
async fn run(&self, args: serde_json::Value) -> AgentResult<serde_json::Value> {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
Ok(args)
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool step whose first meter read fails is charged its own estimate
|
||||
/// (its duration at the default power), never the counter's since-boot
|
||||
/// total: with a 5 J budget and a counter that reads 9 J once it reads at
|
||||
/// all, the agent finishes, having charged its two replies and the tool
|
||||
/// step's estimate, which is not 0.
|
||||
#[tokio::test]
|
||||
async fn a_failed_first_read_never_exhausts_the_joule_budget() {
|
||||
let meter = Arc::new(crate::agent::scripted::Scripted::new(&[None, Some(9_000_000)]));
|
||||
let provider = Arc::new(Replies(std::sync::Mutex::new(vec!["Action: echo[hi]", "Action: Finish[done]"])));
|
||||
let mut tools = ToolBox::new();
|
||||
tools.insert(Echo);
|
||||
let budget = Budget::new(1_000, 5.0, 10, 10);
|
||||
let mut agent = ReActLoop::new(provider, tools, budget, "system");
|
||||
agent.inner = AgentLoop::with_meter(budget, meter.clone());
|
||||
let (out, stop) = agent.run("go").await.unwrap();
|
||||
assert_eq!((out.as_str(), stop), ("done", StopReason::Done));
|
||||
assert_eq!(meter.reads(), 1, "the counter was read again after a failed first read");
|
||||
let act = agent.trace().spans.iter().find(|s| s.kind == SpanKind::Act).expect("an act span");
|
||||
assert!(act.microjoules > 0 && act.microjoules < 9_000_000, "{:?}", act);
|
||||
assert_eq!(agent.budget().joules.used_microjoules, 2_000 + act.microjoules, "{:?}", agent.trace().spans);
|
||||
}
|
||||
|
||||
/// A tool step the meter does not measure (the host default, the stub) is
|
||||
/// charged its duration estimate: at least 20 ms at the default power for
|
||||
/// a tool that runs at least 20 ms, on the span and against the budget.
|
||||
#[tokio::test]
|
||||
async fn an_unmetered_tool_step_is_charged_its_duration_estimate() {
|
||||
let provider = Arc::new(Replies(std::sync::Mutex::new(vec!["Action: slow[hi]", "Action: Finish[done]"])));
|
||||
let mut tools = ToolBox::new();
|
||||
tools.insert(Slow);
|
||||
let mut agent = ReActLoop::new(provider, tools, Budget::unlimited(10), "system");
|
||||
let (out, stop) = agent.run("go").await.unwrap();
|
||||
assert_eq!((out.as_str(), stop), ("done", StopReason::Done));
|
||||
let act = agent.trace().spans.iter().find(|s| s.kind == SpanKind::Act).expect("an act span");
|
||||
let floor = crate::agent::duration_estimate(std::time::Duration::from_millis(20), crate::agent::DEFAULT_TOOL_WATTS);
|
||||
assert!(act.microjoules >= floor, "a 20 ms tool step charged {} µJ", act.microjoules);
|
||||
assert_eq!(agent.budget().joules.used_microjoules, 2_000 + act.microjoules);
|
||||
for span in &agent.trace().spans {
|
||||
assert_eq!(span.joule_source, JouleSource::Estimated, "{span:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Each span says whether its figure was measured: a tool step over which
|
||||
/// the counter advanced 50 J is measured, and charged that; a model step
|
||||
/// (the provider's own figure) and an unknown tool's observation (nothing
|
||||
/// ran, 0 µJ) are estimates.
|
||||
#[tokio::test]
|
||||
async fn a_span_is_measured_only_when_a_live_counter_read_it() {
|
||||
let meter = Arc::new(crate::agent::scripted::Scripted::new(&[Some(0), Some(50_000_000)]));
|
||||
let provider = Arc::new(Replies(std::sync::Mutex::new(vec![
|
||||
"Action: nope[x]",
|
||||
"Action: echo[hi]",
|
||||
"Action: Finish[done]",
|
||||
])));
|
||||
let mut tools = ToolBox::new();
|
||||
tools.insert(Echo);
|
||||
let budget = Budget::unlimited(10);
|
||||
let mut agent = ReActLoop::new(provider, tools, budget, "system");
|
||||
agent.inner = AgentLoop::with_meter(budget, meter.clone());
|
||||
let (out, stop) = agent.run("go").await.unwrap();
|
||||
assert_eq!((out.as_str(), stop), ("done", StopReason::Done));
|
||||
let costs: Vec<_> = agent.trace().spans.iter().map(|s| (s.kind, s.joule_cost())).collect();
|
||||
assert_eq!(
|
||||
costs,
|
||||
[
|
||||
(SpanKind::Think, JouleCost::estimated(1_000)),
|
||||
(SpanKind::Observe, JouleCost::estimated(0)),
|
||||
(SpanKind::Think, JouleCost::estimated(1_000)),
|
||||
(SpanKind::Act, JouleCost::measured(50_000_000)),
|
||||
(SpanKind::Think, JouleCost::estimated(1_000)),
|
||||
]
|
||||
);
|
||||
assert_eq!(agent.budget().joules.used_microjoules, 3_000 + 50_000_000);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
//! Every loop emits a [`Trace`] consisting of ordered [`Span`]s. A span
|
||||
//! captures *what kind of step* ran (think / act / observe / reflect),
|
||||
//! its inputs and outputs as opaque strings, and the joule + token cost
|
||||
//! attributed to it. Traces are content-addressable via the existing
|
||||
//! attributed to it, with whether that joule figure was measured or
|
||||
//! estimated. Traces are content-addressable via the existing
|
||||
//! [`eoc_core::Receipt`] machinery — the cascade can memoize entire
|
||||
//! agent runs, not just individual responses.
|
||||
|
||||
use eoc_core::{JouleCost, JouleSource};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Kind of step.
|
||||
|
|
@ -60,10 +62,25 @@ pub struct Span {
|
|||
pub tokens: u64,
|
||||
/// Energy charged to this span (micro-joules).
|
||||
pub microjoules: u64,
|
||||
/// Where `microjoules` came from: [`JouleSource::Measured`] only when it
|
||||
/// is what a live hardware counter read over the step — both reads
|
||||
/// succeeded and the counter advanced — and [`JouleSource::Estimated`]
|
||||
/// otherwise: a provider's own figure, a duration estimate, a zero no
|
||||
/// counter read. A span serialized without this field reads back as
|
||||
/// `Estimated`: nothing in it says it was measured.
|
||||
#[serde(default = "unrecorded_joule_source")]
|
||||
pub joule_source: JouleSource,
|
||||
}
|
||||
|
||||
/// The provenance of a span that recorded none: an estimate, never a
|
||||
/// measurement.
|
||||
fn unrecorded_joule_source() -> JouleSource {
|
||||
JouleSource::Estimated
|
||||
}
|
||||
|
||||
impl Span {
|
||||
/// Build a new span.
|
||||
/// Build a new span, charged nothing yet: 0 µJ, which no counter read,
|
||||
/// so [`JouleSource::Estimated`].
|
||||
pub fn new(
|
||||
index: usize,
|
||||
kind: SpanKind,
|
||||
|
|
@ -79,6 +96,7 @@ impl Span {
|
|||
output: output.into(),
|
||||
tokens: 0,
|
||||
microjoules: 0,
|
||||
joule_source: JouleSource::Estimated,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,11 +106,25 @@ impl Span {
|
|||
self
|
||||
}
|
||||
|
||||
/// Attach an energy charge.
|
||||
pub fn with_microjoules(mut self, microjoules: u64) -> Self {
|
||||
self.microjoules = microjoules;
|
||||
/// Attach an energy charge with no provenance, which makes it an
|
||||
/// estimate: it replaces any [`JouleSource::Measured`] attached before,
|
||||
/// since that tag belonged to a different figure. A figure a live counter
|
||||
/// read is attached with [`Self::with_joule_cost`].
|
||||
pub fn with_microjoules(self, microjoules: u64) -> Self {
|
||||
self.with_joule_cost(JouleCost::estimated(microjoules))
|
||||
}
|
||||
|
||||
/// Attach an energy charge and its provenance.
|
||||
pub fn with_joule_cost(mut self, cost: JouleCost) -> Self {
|
||||
self.microjoules = cost.microjoules;
|
||||
self.joule_source = cost.source;
|
||||
self
|
||||
}
|
||||
|
||||
/// The energy charged to this span, with its provenance.
|
||||
pub fn joule_cost(&self) -> JouleCost {
|
||||
JouleCost { microjoules: self.microjoules, source: self.joule_source }
|
||||
}
|
||||
}
|
||||
|
||||
/// An ordered execution trace.
|
||||
|
|
@ -157,4 +189,41 @@ mod tests {
|
|||
assert_eq!(t.total_tokens(), 12);
|
||||
assert_eq!(t.total_microjoules(), 250);
|
||||
}
|
||||
|
||||
/// A span is measured only when given a measured cost. A new span and a
|
||||
/// bare figure are estimates, and a bare figure attached after a measured
|
||||
/// one does not inherit its tag.
|
||||
#[test]
|
||||
fn a_span_is_measured_only_when_given_a_measured_cost() {
|
||||
let span = || Span::new(0, SpanKind::Act, "calc", "1+1", "2");
|
||||
assert_eq!(span().joule_cost(), JouleCost::estimated(0));
|
||||
assert_eq!(span().with_microjoules(50).joule_cost(), JouleCost::estimated(50));
|
||||
assert_eq!(span().with_joule_cost(JouleCost::measured(50)).joule_cost(), JouleCost::measured(50));
|
||||
assert_eq!(span().with_joule_cost(JouleCost::estimated(50)).joule_cost(), JouleCost::estimated(50));
|
||||
let relabelled = span().with_joule_cost(JouleCost::measured(50)).with_microjoules(70);
|
||||
assert_eq!(relabelled.joule_cost(), JouleCost::estimated(70));
|
||||
}
|
||||
|
||||
/// A span's provenance survives serialization, and a trace written
|
||||
/// before spans carried one reads back with every span estimated.
|
||||
#[test]
|
||||
fn a_span_without_a_joule_source_reads_back_as_estimated() {
|
||||
let mut t = Trace::new();
|
||||
t.push(Span::new(0, SpanKind::Act, "calc", "1+1", "2").with_joule_cost(JouleCost::measured(50)));
|
||||
t.push(Span::new(0, SpanKind::Think, "t", "in", "out").with_microjoules(200));
|
||||
let json = serde_json::to_string(&t).unwrap();
|
||||
assert!(json.contains(r#""joule_source":"Measured""#), "{json}");
|
||||
let back: Trace = serde_json::from_str(&json).unwrap();
|
||||
let costs: Vec<_> = back.spans.iter().map(Span::joule_cost).collect();
|
||||
assert_eq!(costs, [JouleCost::measured(50), JouleCost::estimated(200)]);
|
||||
|
||||
let old = r#"{"spans":[
|
||||
{"index":0,"kind":"Act","label":"calc","input":"1+1","output":"2","tokens":0,"microjoules":50},
|
||||
{"index":1,"kind":"Think","label":"t","input":"in","output":"out","tokens":3,"microjoules":200}
|
||||
]}"#;
|
||||
let back: Trace = serde_json::from_str(old).unwrap();
|
||||
let costs: Vec<_> = back.spans.iter().map(Span::joule_cost).collect();
|
||||
assert_eq!(costs, [JouleCost::estimated(50), JouleCost::estimated(200)]);
|
||||
assert_eq!(back.total_tokens(), 3);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
//! EOC stage 1 — cache.
|
||||
//!
|
||||
//! The cheapest stage: an LRU keyed by `QueryId` returning a previously
|
||||
//! computed `Response`. Cache hits are reported with `JouleCost::zero()` —
|
||||
//! the energy of a hashmap lookup is below the noise floor of any
|
||||
//! contemporary hardware energy counter.
|
||||
//! computed `Response`. Cache hits are reported with `JouleCost::zero()`:
|
||||
//! zero micro-joules, tagged `Estimated`. The energy of a hashmap lookup is
|
||||
//! below the noise floor of any contemporary hardware energy counter, and no
|
||||
//! counter is read for a hit, so a hit never claims a measurement.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
|
|
@ -56,7 +57,8 @@ impl LruCache {
|
|||
}
|
||||
|
||||
/// Insert (or refresh) a `Response`. Re-stamps the response as `Cache`
|
||||
/// stage with zero joule cost so subsequent hits look like hits.
|
||||
/// stage with `JouleCost::zero()` (an estimated zero: no counter is read
|
||||
/// for a hit) so subsequent hits look like hits.
|
||||
pub fn insert(&self, q: &Query, payload: impl Into<String>) -> Response {
|
||||
let r = Response::new(q.id, payload.into(), StageKind::Cache, JouleCost::zero());
|
||||
self.inner
|
||||
|
|
@ -101,7 +103,8 @@ mod tests {
|
|||
let r = cache.try_resolve(&q).await.expect("hit");
|
||||
assert_eq!(r.stage, StageKind::Cache);
|
||||
assert_eq!(r.joule_cost.microjoules, 0);
|
||||
assert_eq!(r.joule_cost.source, JouleSource::Measured);
|
||||
// No counter was read for the hit: its zero is an estimate.
|
||||
assert_eq!(r.joule_cost.source, JouleSource::Estimated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -5,8 +5,16 @@
|
|||
//! Inputs are typed: joules come from [`eoc_meter`] / [`eoc_core::JouleCost`],
|
||||
//! intensity comes from [`crate::intensity::CarbonIntensity`], overhead
|
||||
//! comes from [`crate::pue::Pue`].
|
||||
//!
|
||||
//! An account keeps the provenance of its energy. Its IT-layer joules are
|
||||
//! exactly as measured as the [`JouleCost`] they came from. Its facility
|
||||
//! joules are measured only when every part of them was: the IT joules
|
||||
//! were, and the PUE added no overhead. The overhead a PUE above 1 adds is a
|
||||
//! published or assumed facility ratio applied to the reading, energy no
|
||||
//! counter on the host read, so a facility figure that includes any of it
|
||||
//! is an estimate.
|
||||
|
||||
use eoc_core::JouleCost;
|
||||
use eoc_core::{JouleCost, JouleSource};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::intensity::CarbonIntensity;
|
||||
|
|
@ -28,6 +36,36 @@ pub struct CarbonAccount {
|
|||
pub pue: f64,
|
||||
/// Final gCO2e number.
|
||||
pub g_co2e: f64,
|
||||
/// Where `it_joules` came from: the input [`JouleCost`]'s provenance,
|
||||
/// unchanged. An account serialized without this field reads back as
|
||||
/// `Estimated`: nothing in it says it was measured.
|
||||
#[serde(default = "unrecorded_source")]
|
||||
pub it_energy_source: JouleSource,
|
||||
/// Where `facility_joules`, and so the energy behind `g_co2e`, came from:
|
||||
/// `Measured` only when every part of it was — the IT joules were
|
||||
/// measured and the PUE added no overhead (a PUE of exactly 1). Any
|
||||
/// overhead is a facility ratio applied to the reading, not a reading,
|
||||
/// and makes the figure an estimate. Grid intensity has its own
|
||||
/// provenance (the reading's provider and kind) and does not enter this
|
||||
/// tag. Reads back as `Estimated` when absent.
|
||||
#[serde(default = "unrecorded_source")]
|
||||
pub facility_energy_source: JouleSource,
|
||||
}
|
||||
|
||||
/// The provenance of an account that recorded none: an estimate, never a
|
||||
/// measurement.
|
||||
fn unrecorded_source() -> JouleSource {
|
||||
JouleSource::Estimated
|
||||
}
|
||||
|
||||
/// The provenance of `it × pue`: measured only when the IT joules were and
|
||||
/// the PUE added nothing to them.
|
||||
fn facility_source(it: JouleSource, pue: &Pue) -> JouleSource {
|
||||
if it == JouleSource::Measured && pue.value() <= 1.0 {
|
||||
JouleSource::Measured
|
||||
} else {
|
||||
JouleSource::Estimated
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything that turns an `it_joules + intensity + pue` tuple into a
|
||||
|
|
@ -62,6 +100,8 @@ impl CarbonAccounting for StandardAccounting {
|
|||
g_co2e_per_kwh: intensity.g_co2e_per_kwh,
|
||||
pue: pue.value(),
|
||||
g_co2e,
|
||||
it_energy_source: joule_cost.source,
|
||||
facility_energy_source: facility_source(joule_cost.source, pue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -104,4 +144,40 @@ mod tests {
|
|||
let acc = account_request(&JouleCost::zero(), &intensity(800.0), &Pue::new(1.4));
|
||||
assert_eq!(acc.g_co2e, 0.0);
|
||||
}
|
||||
|
||||
/// The IT joules keep their input's provenance. The facility joules are
|
||||
/// measured only when every part of them was: measured IT joules and no
|
||||
/// PUE overhead. The figures do not depend on the tags.
|
||||
#[test]
|
||||
fn an_account_is_measured_only_where_every_part_was() {
|
||||
let ci = intensity(400.0);
|
||||
let tags = |cost: JouleCost, pue: f64| {
|
||||
let acc = account_request(&cost, &ci, &Pue::new(pue));
|
||||
(acc.it_energy_source, acc.facility_energy_source)
|
||||
};
|
||||
use JouleSource::{Estimated, Measured};
|
||||
assert_eq!(tags(JouleCost::measured(1_000_000), 1.0), (Measured, Measured));
|
||||
assert_eq!(tags(JouleCost::measured(1_000_000), 1.2), (Measured, Estimated), "PUE overhead is not a reading");
|
||||
assert_eq!(tags(JouleCost::estimated(1_000_000), 1.0), (Estimated, Estimated));
|
||||
assert_eq!(tags(JouleCost::estimated(1_000_000), 1.2), (Estimated, Estimated));
|
||||
// A PUE below 1 is clamped to 1: no overhead, so nothing unread.
|
||||
assert_eq!(tags(JouleCost::measured(1_000_000), 0.5), (Measured, Measured));
|
||||
|
||||
let m = account_request(&JouleCost::measured(1_000_000), &ci, &Pue::new(1.2));
|
||||
let e = account_request(&JouleCost::estimated(1_000_000), &ci, &Pue::new(1.2));
|
||||
assert_eq!((m.it_joules, m.facility_joules, m.g_co2e), (e.it_joules, e.facility_joules, e.g_co2e));
|
||||
}
|
||||
|
||||
/// The provenance survives a round trip; an account written before it
|
||||
/// existed recorded none, so it reads back as an estimate.
|
||||
#[test]
|
||||
fn an_account_without_provenance_reads_back_estimated() {
|
||||
let acc = account_request(&JouleCost::measured(1_000_000), &intensity(400.0), &Pue::new(1.0));
|
||||
let back: CarbonAccount = serde_json::from_str(&serde_json::to_string(&acc).unwrap()).unwrap();
|
||||
assert_eq!(back, acc);
|
||||
assert_eq!(back.facility_energy_source, JouleSource::Measured);
|
||||
let old = r#"{"it_joules":1.0,"facility_joules":1.0,"g_co2e_per_kwh":400.0,"pue":1.0,"g_co2e":0.1}"#;
|
||||
let back: CarbonAccount = serde_json::from_str(old).unwrap();
|
||||
assert_eq!((back.it_energy_source, back.facility_energy_source), (JouleSource::Estimated, JouleSource::Estimated));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ pub use error::{CarbonError, Result};
|
|||
pub use intensity::{CarbonIntensity, ProviderKind, Zone};
|
||||
pub use pue::{Pue, PueTable};
|
||||
pub use scheduler::{DemandShifter, RegionScheduler, ShiftDecision};
|
||||
pub use sci_bridge::{SciInputs, SciScore};
|
||||
pub use sci_bridge::{EvalEnergy, SciInputs, SciScore, sci_per_correct_answer};
|
||||
|
||||
/// gCO2e per kWh in a "typical" world-average grid (IEA 2024). Useful as
|
||||
/// a last-resort fallback when even the IEA per-country table cannot be
|
||||
|
|
|
|||
|
|
@ -15,21 +15,24 @@
|
|||
//! correct answer for an eval).
|
||||
//!
|
||||
//! `eoc-eval` already references SCI; this module is the explicit
|
||||
//! computation surface, and when the `eval-bridge` feature is on it
|
||||
//! exposes a constructor that turns an [`eoc_eval::EvalReport`] into an
|
||||
//! [`SciScore`] with `R = number of correct cases`.
|
||||
//! computation surface. [`sci_per_correct_answer`] scores an eval run's
|
||||
//! energy with `R = number of correct cases`, and when the `eval-bridge`
|
||||
//! feature is on, `sci_from_eval_report` feeds it an
|
||||
//! [`eoc_eval::EvalReport`]. A run with no correct answer has no score per
|
||||
//! correct answer: both return `None` for it.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::account::{CarbonAccount, account_request};
|
||||
use crate::intensity::CarbonIntensity;
|
||||
use crate::pue::Pue;
|
||||
use eoc_core::JouleCost;
|
||||
use eoc_core::{JouleCost, JouleSource};
|
||||
|
||||
/// Inputs to one SCI computation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SciInputs<'a> {
|
||||
/// Measured joules at the IT layer (from [`eoc_meter`]).
|
||||
/// Joules at the IT layer, with their provenance: measured by a live
|
||||
/// counter (see [`eoc_meter`]) or estimated. The score carries it.
|
||||
pub joule_cost: &'a JouleCost,
|
||||
/// Grid intensity (marginal preferred, average acceptable).
|
||||
pub intensity: &'a CarbonIntensity,
|
||||
|
|
@ -57,6 +60,21 @@ pub struct SciScore {
|
|||
pub functional_units: f64,
|
||||
/// The SCI value (gCO2e per functional unit).
|
||||
pub sci: f64,
|
||||
/// Where the energy term came from. The term is facility energy (IT
|
||||
/// joules × PUE), so this is the account's
|
||||
/// [`facility_energy_source`](CarbonAccount::facility_energy_source):
|
||||
/// `Measured` only when the input joules were measured and the PUE
|
||||
/// added no overhead, which no counter read. An SCI value is never more
|
||||
/// measured than the joules it was computed from. A score serialized
|
||||
/// without this field reads back as `Estimated`.
|
||||
#[serde(default = "unrecorded_energy_source")]
|
||||
pub energy_source: JouleSource,
|
||||
}
|
||||
|
||||
/// The provenance of a score that recorded none: an estimate, never a
|
||||
/// measurement.
|
||||
fn unrecorded_energy_source() -> JouleSource {
|
||||
JouleSource::Estimated
|
||||
}
|
||||
|
||||
impl SciScore {
|
||||
|
|
@ -77,35 +95,86 @@ impl SciScore {
|
|||
embodied_g_co2e: inputs.embodied_g_co2e,
|
||||
functional_units: r,
|
||||
sci,
|
||||
energy_source: account.facility_energy_source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An eval run's energy, as `eoc_eval::EvalReport` carries it: micro-joules
|
||||
/// per correct answer, the number of correct answers, and the provenance of
|
||||
/// the joules. Kept free of `eoc-eval` so the scoring below is compiled, and
|
||||
/// tested, in every build; the `eval-bridge` feature only fills it from a
|
||||
/// report.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct EvalEnergy {
|
||||
/// Micro-joules per correct answer. `u64::MAX` is the report's sentinel
|
||||
/// for "no correct answer", not a figure.
|
||||
pub joules_per_correct: u64,
|
||||
/// Correct answers in the run.
|
||||
pub correct: usize,
|
||||
/// Where the joules came from: `Measured` only when every case's were.
|
||||
pub joule_source: JouleSource,
|
||||
}
|
||||
|
||||
/// The SCI score per correct answer of an eval run: total IT energy
|
||||
/// `joules_per_correct × correct`, with the run's own provenance (never
|
||||
/// upgraded; see [`SciScore::energy_source`]), and `R = correct`.
|
||||
///
|
||||
/// `None` when the run has no correct answer — `correct == 0`, or the
|
||||
/// `u64::MAX` sentinel a report carries for that — since there is no figure
|
||||
/// per correct answer to score. The sentinel is never read as energy.
|
||||
pub fn sci_per_correct_answer(
|
||||
energy: EvalEnergy,
|
||||
intensity: &CarbonIntensity,
|
||||
pue: &Pue,
|
||||
embodied_g_co2e: f64,
|
||||
) -> Option<SciScore> {
|
||||
if energy.correct == 0 || energy.joules_per_correct == u64::MAX {
|
||||
return None;
|
||||
}
|
||||
let total_microjoules = (energy.joules_per_correct as u128)
|
||||
.saturating_mul(energy.correct as u128)
|
||||
.min(u64::MAX as u128) as u64;
|
||||
let cost = JouleCost {
|
||||
microjoules: total_microjoules,
|
||||
source: energy.joule_source,
|
||||
};
|
||||
Some(SciScore::compute(SciInputs {
|
||||
joule_cost: &cost,
|
||||
intensity,
|
||||
pue,
|
||||
embodied_g_co2e,
|
||||
functional_units: energy.correct as f64,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `eoc-eval` bridge — turns an `EvalReport` into an SCI score with
|
||||
/// `R = correct`. Compiled only with the `eval-bridge` Cargo feature.
|
||||
/// `R = correct`. Compiled only with the `eval-bridge` Cargo feature; it
|
||||
/// only hands the report's energy to [`sci_per_correct_answer`], which is
|
||||
/// compiled and tested in every build.
|
||||
///
|
||||
/// `EvalReport` carries `joules_per_correct` in micro-joules and a
|
||||
/// `correct` count, so we reconstruct the total IT-layer energy as
|
||||
/// `joules_per_correct * correct`.
|
||||
/// `correct` count, so the total IT-layer energy is reconstructed as
|
||||
/// `joules_per_correct * correct`. Its provenance is the report's own
|
||||
/// `joule_source`: measured only when every case of the run was. `None`
|
||||
/// for a run with no correct answer.
|
||||
#[cfg(feature = "eval-bridge")]
|
||||
pub fn sci_from_eval_report(
|
||||
report: &eoc_eval::EvalReport,
|
||||
intensity: &CarbonIntensity,
|
||||
pue: &Pue,
|
||||
embodied_g_co2e: f64,
|
||||
) -> SciScore {
|
||||
let correct = report.correct.max(1);
|
||||
let total_microjoules = (report.joules_per_correct as u128)
|
||||
.saturating_mul(correct as u128)
|
||||
.min(u64::MAX as u128) as u64;
|
||||
let cost = JouleCost::measured(total_microjoules);
|
||||
SciScore::compute(SciInputs {
|
||||
joule_cost: &cost,
|
||||
) -> Option<SciScore> {
|
||||
sci_per_correct_answer(
|
||||
EvalEnergy {
|
||||
joules_per_correct: report.joules_per_correct,
|
||||
correct: report.correct,
|
||||
joule_source: report.joule_source,
|
||||
},
|
||||
intensity,
|
||||
pue,
|
||||
embodied_g_co2e,
|
||||
functional_units: correct as f64,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -134,4 +203,111 @@ mod tests {
|
|||
assert_eq!(s.functional_units, 1.0);
|
||||
assert!(s.sci > 0.0);
|
||||
}
|
||||
|
||||
fn intensity() -> CarbonIntensity {
|
||||
CarbonIntensity::new("US", 400.0, IntensityKind::Average, ProviderKind::Mock, Utc::now())
|
||||
}
|
||||
|
||||
fn score(cost: JouleCost, pue: f64) -> SciScore {
|
||||
SciScore::compute(SciInputs {
|
||||
joule_cost: &cost,
|
||||
intensity: &intensity(),
|
||||
pue: &Pue::new(pue),
|
||||
embodied_g_co2e: 0.0,
|
||||
functional_units: 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// The score says where its energy term came from, and the same figure
|
||||
/// yields the same numbers either way. The term is facility energy, so
|
||||
/// it is measured only when the joules were and the PUE added nothing.
|
||||
#[test]
|
||||
fn the_score_carries_its_energy_provenance() {
|
||||
assert_eq!(score(JouleCost::measured(1_000_000), 1.0).energy_source, JouleSource::Measured);
|
||||
assert_eq!(score(JouleCost::estimated(1_000_000), 1.0).energy_source, JouleSource::Estimated);
|
||||
assert_eq!(
|
||||
score(JouleCost::measured(1_000_000), 1.2).energy_source,
|
||||
JouleSource::Estimated,
|
||||
"a PUE's overhead is not a reading"
|
||||
);
|
||||
assert_eq!(score(JouleCost::measured(1_000_000), 1.2).sci, score(JouleCost::estimated(1_000_000), 1.2).sci);
|
||||
}
|
||||
|
||||
/// A score written before `energy_source` existed recorded no
|
||||
/// provenance, so it reads back as an estimate.
|
||||
#[test]
|
||||
fn a_score_without_provenance_reads_back_estimated() {
|
||||
let json = r#"{"energy_kwh":1.0,"operational_g_co2e":1.0,"embodied_g_co2e":0.0,
|
||||
"functional_units":1.0,"sci":1.0}"#;
|
||||
let back: SciScore = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(back.energy_source, JouleSource::Estimated);
|
||||
}
|
||||
|
||||
fn run(joules_per_correct: u64, correct: usize, joule_source: JouleSource) -> EvalEnergy {
|
||||
EvalEnergy { joules_per_correct, correct, joule_source }
|
||||
}
|
||||
|
||||
/// An eval run's score is measured exactly when the run's joules were
|
||||
/// (and the PUE adds nothing); the run's provenance is never upgraded.
|
||||
/// Compiled in every build: the `eval-bridge` feature only fills the
|
||||
/// run's energy from a report.
|
||||
#[test]
|
||||
fn an_eval_runs_score_keeps_the_runs_provenance() {
|
||||
let ci = intensity();
|
||||
let per_correct = |source, pue: f64| {
|
||||
sci_per_correct_answer(run(1_000_000, 2, source), &ci, &Pue::new(pue), 0.0).expect("two correct answers")
|
||||
};
|
||||
assert_eq!(per_correct(JouleSource::Estimated, 1.0).energy_source, JouleSource::Estimated);
|
||||
assert_eq!(per_correct(JouleSource::Measured, 1.0).energy_source, JouleSource::Measured);
|
||||
assert_eq!(per_correct(JouleSource::Measured, 1.2).energy_source, JouleSource::Estimated);
|
||||
// 2 correct × 1 J, × 1.2 PUE = 2.4 J either way, over R = 2.
|
||||
let (e, m) = (per_correct(JouleSource::Estimated, 1.2), per_correct(JouleSource::Measured, 1.2));
|
||||
assert!((e.energy_kwh - 2.4 / 3_600_000.0).abs() < 1e-15, "{}", e.energy_kwh);
|
||||
assert_eq!(e.functional_units, 2.0);
|
||||
assert_eq!(e.sci, m.sci);
|
||||
}
|
||||
|
||||
/// A run with no correct answer has no score per correct answer. Its
|
||||
/// `u64::MAX` sentinel is never read as a u64::MAX µJ energy term,
|
||||
/// whatever `correct` says beside it.
|
||||
#[test]
|
||||
fn a_run_with_no_correct_answer_has_no_score() {
|
||||
let ci = intensity();
|
||||
let pue = Pue::new(1.2);
|
||||
for source in [JouleSource::Measured, JouleSource::Estimated] {
|
||||
assert_eq!(sci_per_correct_answer(run(u64::MAX, 0, source), &ci, &pue, 0.0), None);
|
||||
assert_eq!(sci_per_correct_answer(run(5_000_000, 0, source), &ci, &pue, 0.0), None);
|
||||
assert_eq!(sci_per_correct_answer(run(u64::MAX, 3, source), &ci, &pue, 0.0), None);
|
||||
}
|
||||
assert!(sci_per_correct_answer(run(1, 1, JouleSource::Measured), &ci, &pue, 0.0).is_some());
|
||||
}
|
||||
|
||||
/// The bridge scores a report exactly as the run's energy is scored, and
|
||||
/// gives none for a report with no correct answer.
|
||||
#[cfg(feature = "eval-bridge")]
|
||||
#[test]
|
||||
fn an_eval_reports_score_is_its_runs_score() {
|
||||
let report = |correct, joules_per_correct, joule_source| eoc_eval::EvalReport {
|
||||
harness: "fixed".into(),
|
||||
model: "test".into(),
|
||||
total_cases: 4,
|
||||
correct,
|
||||
score: correct as f64 / 4.0,
|
||||
metric: eoc_eval::Metric::Accuracy,
|
||||
joules_per_correct,
|
||||
joule_source,
|
||||
latency_p50_ms: 1,
|
||||
latency_p95_ms: 1,
|
||||
};
|
||||
let ci = intensity();
|
||||
for pue in [Pue::new(1.0), Pue::new(1.2)] {
|
||||
for source in [JouleSource::Estimated, JouleSource::Measured] {
|
||||
let got = sci_from_eval_report(&report(2, 1_000_000, source), &ci, &pue, 0.1);
|
||||
let want = sci_per_correct_answer(run(1_000_000, 2, source), &ci, &pue, 0.1);
|
||||
assert_eq!(got, want);
|
||||
assert!(got.is_some());
|
||||
}
|
||||
}
|
||||
assert_eq!(sci_from_eval_report(&report(0, u64::MAX, JouleSource::Measured), &ci, &Pue::new(1.2), 0.0), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,4 +30,7 @@ fn one_kilojoule_400g_pue_1_2() {
|
|||
assert!((acc.facility_joules - 1200.0).abs() < 1e-9);
|
||||
assert_eq!(acc.pue, 1.2);
|
||||
assert_eq!(acc.g_co2e_per_kwh, 400.0);
|
||||
// The IT joules were read; the PUE's overhead was not.
|
||||
assert_eq!(acc.it_energy_source, JouleSource::Measured);
|
||||
assert_eq!(acc.facility_energy_source, JouleSource::Estimated);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
//! End-to-end: joules (from eoc-meter stub) → CarbonAccount → SCI value.
|
||||
//! End-to-end: joules → CarbonAccount → SCI value.
|
||||
//!
|
||||
//! We exercise the joule pipeline by using `eoc_meter::detect()` which
|
||||
//! always returns *some* counter on every platform, then drive the SCI
|
||||
//! computation with a deterministic test reading.
|
||||
//! computation with a deterministic test figure. No counter read that
|
||||
//! figure, so it is an estimate, and the score says so.
|
||||
|
||||
use chrono::Utc;
|
||||
use eoc_carbon::intensity::{CarbonIntensity, IntensityKind, ProviderKind};
|
||||
use eoc_carbon::pue::Pue;
|
||||
use eoc_carbon::sci_bridge::{SciInputs, SciScore};
|
||||
use eoc_core::JouleCost;
|
||||
use eoc_core::{JouleCost, JouleSource};
|
||||
|
||||
#[test]
|
||||
fn full_request_to_sci_uses_meter_and_carbon_pipeline() {
|
||||
|
|
@ -18,8 +19,10 @@ fn full_request_to_sci_uses_meter_and_carbon_pipeline() {
|
|||
let _ = counter.read_microjoules();
|
||||
assert!(!counter.name().is_empty());
|
||||
|
||||
// Step 2 — pretend the meter reported 2000 J on the stub backend.
|
||||
let joule_cost = JouleCost::measured(2_000_000_000);
|
||||
// Step 2 — a fixed 2000 J for the request. No counter read it, so it
|
||||
// is an estimate: the stub reads 0, and a live counter would not read
|
||||
// this figure.
|
||||
let joule_cost = JouleCost::estimated(2_000_000_000);
|
||||
|
||||
// Step 3 — grid intensity for the request's region.
|
||||
let intensity = CarbonIntensity::new(
|
||||
|
|
@ -49,4 +52,6 @@ fn full_request_to_sci_uses_meter_and_carbon_pipeline() {
|
|||
assert!((sci.operational_g_co2e - 0.03667).abs() < 1e-4);
|
||||
assert_eq!(sci.embodied_g_co2e, 0.05);
|
||||
assert!((sci.sci - 0.08667).abs() < 1e-4);
|
||||
// The score is no more measured than the joules it was computed from.
|
||||
assert_eq!(sci.energy_source, JouleSource::Estimated);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
//! EOC cascade — the four-stage memoizing pipeline.
|
||||
//!
|
||||
//! `Cascade::resolve` walks `cache → kv → graph → neural` in order. The
|
||||
//! first stage to return `Some(Response)` wins. Joule cost is the sum of
|
||||
//! the stage-level cost reported by the stage plus any cascade-level
|
||||
//! overhead measured by the meter between the start and end of the call.
|
||||
//! first stage to return `Some(Response)` wins. Its joule cost combines the
|
||||
//! cost the stage reported with what the cascade's meter read over the whole
|
||||
//! call, when that meter is live; it is `Measured` only when every
|
||||
//! micro-joule of it was read from a live counter. Any micro-joule that was
|
||||
//! estimated makes the whole an estimate.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
|
|
@ -107,16 +109,17 @@ impl Cascade {
|
|||
r
|
||||
};
|
||||
|
||||
// Re-derive the response with the cascade-attributed joule cost
|
||||
// (stage-reported + measured overhead, if any).
|
||||
// Re-derive the response with the cascade-attributed joule cost: the
|
||||
// stage's own cost, combined with the meter's span when it is live.
|
||||
attach_cascade_cost(result, start, self.meter.as_ref())
|
||||
}
|
||||
|
||||
fn maybe_memoize(&self, _q: &Query, r: &Response) {
|
||||
if self.memoize {
|
||||
// Memoize as a *cache* entry so future hits report `Stage::Cache`
|
||||
// and `JouleCost::zero()`. The original receipt is no longer
|
||||
// valid (different stage + cost) — we mint a new one.
|
||||
// and `JouleCost::zero()`, an estimated zero: no counter is read
|
||||
// for a hit. The original receipt is no longer valid (different
|
||||
// stage + cost) — we mint a new one.
|
||||
let cached =
|
||||
Response::new(r.query_id, r.payload.clone(), eoc_core::Stage::Cache, JouleCost::zero());
|
||||
self.cache.insert_response(r.query_id, cached);
|
||||
|
|
@ -125,29 +128,54 @@ impl Cascade {
|
|||
}
|
||||
|
||||
fn attach_cascade_cost(
|
||||
mut response: Response,
|
||||
response: Response,
|
||||
start: Option<u64>,
|
||||
meter: &dyn JouleCounter,
|
||||
) -> Response {
|
||||
let end = meter.read_microjoules().ok();
|
||||
let overhead = match (start, end) {
|
||||
(Some(s), Some(e)) if e >= s => e - s,
|
||||
_ => 0,
|
||||
};
|
||||
let stage_cost = response.joule_cost.microjoules;
|
||||
let total = stage_cost.saturating_add(overhead);
|
||||
let source = if overhead > 0 {
|
||||
JouleSource::Measured
|
||||
} else {
|
||||
response.joule_cost.source
|
||||
};
|
||||
let joule_cost = JouleCost {
|
||||
microjoules: total,
|
||||
source,
|
||||
};
|
||||
let joule_cost = cascade_cost(response.joule_cost, live_span(start, end));
|
||||
// Rebuild the response so the receipt covers the final joule cost.
|
||||
response = Response::new(response.query_id, response.payload, response.stage, joule_cost);
|
||||
response
|
||||
Response::new(response.query_id, response.payload, response.stage, joule_cost)
|
||||
}
|
||||
|
||||
/// What the cascade's meter read over one resolve: the delta between its two
|
||||
/// reads, only when both succeeded and the counter advanced. A counter that
|
||||
/// read but did not move — the stub, a frozen rail — measured nothing, and a
|
||||
/// failed read leaves no span; neither is a figure.
|
||||
fn live_span(start: Option<u64>, end: Option<u64>) -> Option<u64> {
|
||||
match (start, end) {
|
||||
(Some(s), Some(e)) if e > s => Some(e - s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The joule cost of one resolve, from the answering stage's own cost and
|
||||
/// the cascade meter's live span over the whole resolve, if there was one.
|
||||
///
|
||||
/// It is `Measured` only when every micro-joule of it was read from a live
|
||||
/// counter; any micro-joule that was estimated makes the whole an estimate.
|
||||
///
|
||||
/// - No live span: the cascade read nothing, adds nothing, and the stage's
|
||||
/// cost stands as the stage reported it.
|
||||
/// - A live span and a measured stage: the stage read a counter on this host
|
||||
/// during the resolve, so inside the span. On the cascade's own counter the
|
||||
/// span already holds that reading, and adding the two would count it
|
||||
/// twice. The cost is the larger of the two, measured: exact when the stage
|
||||
/// read the cascade's counter, a lower bound if it read another.
|
||||
/// - A live span and an estimated stage: the estimate is energy no counter
|
||||
/// read, possibly spent elsewhere (a remote backend), so it is added to the
|
||||
/// span and the total is an estimate. An estimated zero, which is what a
|
||||
/// cache hit costs, adds nothing: the cost is the span's reading alone, and
|
||||
/// measured.
|
||||
fn cascade_cost(stage: JouleCost, span: Option<u64>) -> JouleCost {
|
||||
let Some(span) = span else {
|
||||
return stage;
|
||||
};
|
||||
match stage.source {
|
||||
JouleSource::Measured => JouleCost::measured(stage.microjoules.max(span)),
|
||||
JouleSource::Estimated if stage.microjoules == 0 => JouleCost::measured(span),
|
||||
JouleSource::Estimated => JouleCost::estimated(stage.microjoules.saturating_add(span)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -209,4 +237,157 @@ mod tests {
|
|||
let r2 = cascade.resolve(q).await;
|
||||
assert_eq!(r2.stage, StageKind::Cache);
|
||||
}
|
||||
|
||||
/// A counter that reads the scripted values in turn, then the last one
|
||||
/// again; `None` is a failed read. One instance can be shared by the
|
||||
/// cascade and a stage, as one host counter would be.
|
||||
struct Scripted {
|
||||
reads: std::sync::Mutex<std::collections::VecDeque<Option<u64>>>,
|
||||
}
|
||||
|
||||
impl Scripted {
|
||||
fn new(reads: &[Option<u64>]) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
reads: std::sync::Mutex::new(reads.iter().copied().collect()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl JouleCounter for Scripted {
|
||||
fn read_microjoules(&self) -> eoc_core::Result<u64> {
|
||||
let mut r = self.reads.lock().unwrap();
|
||||
let next = if r.len() > 1 {
|
||||
r.pop_front().unwrap()
|
||||
} else {
|
||||
r[0]
|
||||
};
|
||||
next.ok_or(eoc_core::Error::NoCounter)
|
||||
}
|
||||
fn name(&self) -> &'static str {
|
||||
"scripted"
|
||||
}
|
||||
}
|
||||
|
||||
/// A neural backend that meters itself the way the local backends do: it
|
||||
/// reads its counter before and after, and reports the delta as measured
|
||||
/// when the counter advanced.
|
||||
struct SelfMetered {
|
||||
meter: Arc<dyn JouleCounter>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl eoc_neural::NeuralBackend for SelfMetered {
|
||||
async fn infer(&self, q: &Query) -> Response {
|
||||
let start = self.meter.read_microjoules().ok();
|
||||
let end = self.meter.read_microjoules().ok();
|
||||
let cost = match (start, end) {
|
||||
(Some(s), Some(e)) if e > s => JouleCost::measured(e - s),
|
||||
_ => JouleCost::estimated(1),
|
||||
};
|
||||
Response::new(q.id, "metered".to_string(), StageKind::Neural, cost)
|
||||
}
|
||||
}
|
||||
|
||||
fn metered(meter: Arc<dyn JouleCounter>, neural: Box<dyn eoc_neural::NeuralBackend>) -> Cascade {
|
||||
Cascade::new(
|
||||
Arc::new(LruCache::new(8)),
|
||||
Arc::new(KvStage::new(Box::new(MemoryKvBackend::new()))),
|
||||
Arc::new(GraphStage::new()),
|
||||
Arc::new(NeuralStage::new(neural)),
|
||||
)
|
||||
.with_meter(meter)
|
||||
}
|
||||
|
||||
/// No meter is read for a cache hit, so it claims no measurement: not a
|
||||
/// memoized answer, not an inserted one.
|
||||
#[tokio::test]
|
||||
async fn a_cache_hit_claims_no_measurement() {
|
||||
let cascade = fixture();
|
||||
let q = Query::new("totally novel question");
|
||||
cascade.resolve(q.clone()).await;
|
||||
let memoized = cascade.resolve(q).await;
|
||||
assert_eq!(memoized.stage, StageKind::Cache);
|
||||
assert_eq!(memoized.joule_cost, JouleCost::estimated(0));
|
||||
|
||||
let q = Query::new("inserted by hand");
|
||||
cascade.cache().insert(&q, "answer");
|
||||
let inserted = cascade.resolve(q).await;
|
||||
assert_eq!(inserted.stage, StageKind::Cache);
|
||||
assert_eq!(inserted.joule_cost, JouleCost::estimated(0));
|
||||
}
|
||||
|
||||
/// A live counter advanced 500 µJ over the resolve, and the stage reported
|
||||
/// a 50 J estimate. The total holds an estimate, so it is one.
|
||||
#[tokio::test]
|
||||
async fn an_estimate_plus_a_live_span_is_an_estimate() {
|
||||
let meter = Scripted::new(&[Some(1_000), Some(1_500)]);
|
||||
let cascade = metered(meter, Box::new(EchoBackend::new().with_cost(50_000_000)));
|
||||
let r = cascade.resolve(Query::new("totally novel question")).await;
|
||||
assert_eq!(r.stage, StageKind::Neural);
|
||||
assert_eq!(r.joule_cost, JouleCost::estimated(50_000_500));
|
||||
assert!(r.receipt.verify(&r.query_id, &r.stage, &r.joule_cost, &r.payload));
|
||||
}
|
||||
|
||||
/// The stage metered itself on the cascade's own counter: it read 300 µJ
|
||||
/// (1_100 -> 1_400) inside the cascade's 500 µJ span (1_000 -> 1_500). The
|
||||
/// span already holds those 300 µJ, so the resolve cost 500 µJ, measured,
|
||||
/// never 800.
|
||||
#[tokio::test]
|
||||
async fn a_stage_metered_on_the_cascade_counter_is_counted_once() {
|
||||
let meter = Scripted::new(&[Some(1_000), Some(1_100), Some(1_400), Some(1_500)]);
|
||||
let shared: Arc<dyn JouleCounter> = meter;
|
||||
let cascade = metered(shared.clone(), Box::new(SelfMetered { meter: shared }));
|
||||
let r = cascade.resolve(Query::new("totally novel question")).await;
|
||||
assert_eq!(r.stage, StageKind::Neural);
|
||||
assert_eq!(r.joule_cost, JouleCost::measured(500));
|
||||
}
|
||||
|
||||
/// A cache hit adds no figure of its own, so on a live counter the cost is
|
||||
/// the span's reading alone, every micro-joule of it read: measured.
|
||||
#[tokio::test]
|
||||
async fn a_cache_hit_on_a_live_counter_is_the_span_reading() {
|
||||
let meter = Scripted::new(&[Some(2_000), Some(2_250)]);
|
||||
let cascade = metered(meter, Box::new(EchoBackend::new()));
|
||||
let q = Query::new("seen before");
|
||||
cascade.cache().insert(&q, "answer");
|
||||
let r = cascade.resolve(q).await;
|
||||
assert_eq!(r.stage, StageKind::Cache);
|
||||
assert_eq!(r.joule_cost, JouleCost::measured(250));
|
||||
}
|
||||
|
||||
/// A counter that reads but does not move, or fails a read, measured
|
||||
/// nothing: the stage's own cost stands, figure and tag.
|
||||
#[tokio::test]
|
||||
async fn a_span_that_is_not_live_adds_nothing() {
|
||||
for reads in [vec![Some(7_000)], vec![None, Some(9_000)], vec![Some(9_000), None]] {
|
||||
let meter = Scripted::new(&reads);
|
||||
let cascade = metered(meter, Box::new(EchoBackend::new().with_cost(42)));
|
||||
let r = cascade.resolve(Query::new("totally novel question")).await;
|
||||
assert_eq!(r.joule_cost, JouleCost::estimated(42), "reads {reads:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every branch of the rule, on the figures alone.
|
||||
#[test]
|
||||
fn the_cost_is_measured_only_when_every_microjoule_was_read() {
|
||||
let e = JouleCost::estimated;
|
||||
let m = JouleCost::measured;
|
||||
// No live span: the stage's cost, untouched.
|
||||
assert_eq!(cascade_cost(e(40), None), e(40));
|
||||
assert_eq!(cascade_cost(m(40), None), m(40));
|
||||
assert_eq!(cascade_cost(e(0), None), e(0));
|
||||
// A live span and an estimate: summed, and an estimate.
|
||||
assert_eq!(cascade_cost(e(40), Some(10)), e(50));
|
||||
// A live span and a measurement: the larger reading, never the sum.
|
||||
assert_eq!(cascade_cost(m(40), Some(100)), m(100));
|
||||
assert_eq!(cascade_cost(m(400), Some(100)), m(400));
|
||||
// A live span and an estimated zero: the span's reading.
|
||||
assert_eq!(cascade_cost(e(0), Some(100)), m(100));
|
||||
// A span is live only when the counter advanced.
|
||||
assert_eq!(live_span(Some(5), Some(5)), None);
|
||||
assert_eq!(live_span(Some(6), Some(5)), None);
|
||||
assert_eq!(live_span(None, Some(5)), None);
|
||||
assert_eq!(live_span(Some(5), None), None);
|
||||
assert_eq!(live_span(Some(5), Some(8)), Some(3));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ use serde::{Deserialize, Serialize};
|
|||
/// Where the joule reading came from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum JouleSource {
|
||||
/// Reading from a hardware energy counter (RAPL, NVML, powermetrics).
|
||||
/// Read from a live hardware energy counter (RAPL, NVML, or IOReport
|
||||
/// while its CPU rail is live): the counter advanced over the span.
|
||||
Measured,
|
||||
/// Synthesized estimate (no hardware counter available).
|
||||
/// Anything else: a synthesized estimate, or a figure no live counter
|
||||
/// read (no counter, a failed read, a counter that did not advance).
|
||||
Estimated,
|
||||
}
|
||||
|
||||
|
|
@ -21,11 +23,19 @@ pub struct JouleCost {
|
|||
}
|
||||
|
||||
impl JouleCost {
|
||||
/// A zero-cost reading — typical for in-process cache hits.
|
||||
/// Zero micro-joules, tagged [`JouleSource::Estimated`]: a figure no
|
||||
/// counter read. It is what a cache hit costs by assumption, since the
|
||||
/// energy of an in-process lookup is below any counter's resolution and
|
||||
/// no meter is read for it.
|
||||
///
|
||||
/// It is never [`JouleSource::Measured`]. Measured means a live hardware
|
||||
/// counter read the span and advanced over it; a zero that no counter read
|
||||
/// is an estimate, and a counter that read but did not move has measured
|
||||
/// nothing.
|
||||
pub fn zero() -> Self {
|
||||
Self {
|
||||
microjoules: 0,
|
||||
source: JouleSource::Measured,
|
||||
source: JouleSource::Estimated,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +47,8 @@ impl JouleCost {
|
|||
}
|
||||
}
|
||||
|
||||
/// A measured cost — used when a hardware counter reading is attached.
|
||||
/// A measured cost: the delta a live hardware counter (RAPL, NVML, a live
|
||||
/// IOReport rail) read over the span of the work, and nothing else.
|
||||
pub fn measured(microjoules: u64) -> Self {
|
||||
Self {
|
||||
microjoules,
|
||||
|
|
@ -60,3 +71,15 @@ impl std::fmt::Display for JouleCost {
|
|||
write!(f, "{} µJ ({tag})", self.microjoules)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A zero no counter read is an estimate, never a measurement.
|
||||
#[test]
|
||||
fn zero_claims_no_measurement() {
|
||||
assert_eq!(JouleCost::zero(), JouleCost::estimated(0));
|
||||
assert_eq!(JouleCost::zero().to_string(), "0 µJ (estimated)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::sync::Arc;
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use eoc_core::Query;
|
||||
use eoc_core::{JouleSource, Query};
|
||||
use eoc_neural::NeuralBackend;
|
||||
|
||||
use crate::error::Result;
|
||||
|
|
@ -77,6 +77,8 @@ impl EvalRunner {
|
|||
|
||||
let mut total_score = 0.0f64;
|
||||
let mut total_microjoules: u128 = 0;
|
||||
// Whether every case's cost so far was read from a live counter.
|
||||
let mut every_case_measured = true;
|
||||
let mut latencies: Vec<u64> = Vec::with_capacity(total_cases);
|
||||
|
||||
for case in &cases {
|
||||
|
|
@ -85,6 +87,7 @@ impl EvalRunner {
|
|||
let core_resp = self.backend.infer(&q).await;
|
||||
let latency_ms = start.elapsed().as_millis().min(u64::MAX as u128) as u64;
|
||||
total_microjoules += u128::from(core_resp.joule_cost.microjoules);
|
||||
every_case_measured &= core_resp.joule_cost.source == JouleSource::Measured;
|
||||
latencies.push(latency_ms);
|
||||
let response = Response::from_core(core_resp, latency_ms);
|
||||
let s = self.harness.score(&response, &case.expected).await;
|
||||
|
|
@ -107,6 +110,14 @@ impl EvalRunner {
|
|||
(total_microjoules / (correct as u128)).min(u64::MAX as u128) as u64
|
||||
};
|
||||
|
||||
// A total is only as good as its weakest part: one case that was not
|
||||
// measured makes it an estimate, and a run with no cases read nothing.
|
||||
let joule_source = if total_cases > 0 && every_case_measured {
|
||||
JouleSource::Measured
|
||||
} else {
|
||||
JouleSource::Estimated
|
||||
};
|
||||
|
||||
latencies.sort_unstable();
|
||||
let latency_p50_ms = percentile(&latencies, 0.50);
|
||||
let latency_p95_ms = percentile(&latencies, 0.95);
|
||||
|
|
@ -119,6 +130,7 @@ impl EvalRunner {
|
|||
score,
|
||||
metric,
|
||||
joules_per_correct,
|
||||
joule_source,
|
||||
latency_p50_ms,
|
||||
latency_p95_ms,
|
||||
})
|
||||
|
|
@ -144,12 +156,25 @@ pub struct EvalReport {
|
|||
pub metric: Metric,
|
||||
/// Micro-joules per correct case. `u64::MAX` when zero correct.
|
||||
pub joules_per_correct: u64,
|
||||
/// Where the energy behind `joules_per_correct` came from: `Measured`
|
||||
/// only when every case's cost was read from a live counter, `Estimated`
|
||||
/// otherwise, including a run with no cases. A report serialized without
|
||||
/// this field reads back as `Estimated`: nothing in it says it was
|
||||
/// measured.
|
||||
#[serde(default = "unrecorded_joule_source")]
|
||||
pub joule_source: JouleSource,
|
||||
/// Latency p50 in milliseconds.
|
||||
pub latency_p50_ms: u64,
|
||||
/// Latency p95 in milliseconds.
|
||||
pub latency_p95_ms: u64,
|
||||
}
|
||||
|
||||
/// The provenance of a report that recorded none: an estimate, never a
|
||||
/// measurement.
|
||||
fn unrecorded_joule_source() -> JouleSource {
|
||||
JouleSource::Estimated
|
||||
}
|
||||
|
||||
/// Nearest-rank percentile (no interpolation).
|
||||
fn percentile(sorted: &[u64], q: f64) -> u64 {
|
||||
if sorted.is_empty() {
|
||||
|
|
@ -248,6 +273,7 @@ mod tests {
|
|||
score: 1.0,
|
||||
metric: Metric::Accuracy,
|
||||
joules_per_correct: 100,
|
||||
joule_source: JouleSource::Measured,
|
||||
latency_p50_ms: 1,
|
||||
latency_p95_ms: 1,
|
||||
};
|
||||
|
|
@ -255,5 +281,65 @@ mod tests {
|
|||
let back: EvalReport = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.harness, "mmlu");
|
||||
assert_eq!(back.correct, 1);
|
||||
assert_eq!(back.joule_source, JouleSource::Measured);
|
||||
}
|
||||
|
||||
/// A report written before `joule_source` existed recorded no
|
||||
/// provenance, so it reads back as an estimate, never a measurement.
|
||||
#[test]
|
||||
fn a_report_without_provenance_reads_back_estimated() {
|
||||
let json = r#"{"harness":"mmlu","model":"m","total_cases":1,"correct":1,
|
||||
"score":1.0,"metric":"Accuracy","joules_per_correct":100,
|
||||
"latency_p50_ms":1,"latency_p95_ms":1}"#;
|
||||
let back: EvalReport = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(back.joule_source, JouleSource::Estimated);
|
||||
}
|
||||
|
||||
/// A backend whose every answer reports the given cost.
|
||||
struct Costing(JouleCost);
|
||||
#[async_trait]
|
||||
impl NeuralBackend for Costing {
|
||||
async fn infer(&self, q: &CoreQuery) -> CoreResponse {
|
||||
CoreResponse::new(q.id, "yes".into(), Stage::Neural, self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// One case's cost is measured exactly when its backend's is; a total
|
||||
/// with one estimated case in it is an estimate.
|
||||
#[tokio::test]
|
||||
async fn the_report_is_measured_only_when_every_case_was() {
|
||||
let run = |backend: Arc<dyn NeuralBackend>| async move {
|
||||
let harness = Box::new(FixedHarness { n_cases: 4, correct: "yes" });
|
||||
EvalRunner::new(harness, backend).run().await.unwrap()
|
||||
};
|
||||
let measured = run(Arc::new(Costing(JouleCost::measured(10)))).await;
|
||||
assert_eq!(measured.joule_source, JouleSource::Measured);
|
||||
let estimated = run(Arc::new(Costing(JouleCost::estimated(10)))).await;
|
||||
assert_eq!(estimated.joule_source, JouleSource::Estimated);
|
||||
|
||||
// Measured, measured, then estimated from the third answer on.
|
||||
struct Mixed(AtomicUsize);
|
||||
#[async_trait]
|
||||
impl NeuralBackend for Mixed {
|
||||
async fn infer(&self, q: &CoreQuery) -> CoreResponse {
|
||||
let cost = if self.0.fetch_add(1, Ordering::Relaxed) < 2 {
|
||||
JouleCost::measured(10)
|
||||
} else {
|
||||
JouleCost::estimated(10)
|
||||
};
|
||||
CoreResponse::new(q.id, "yes".into(), Stage::Neural, cost)
|
||||
}
|
||||
}
|
||||
let mixed = run(Arc::new(Mixed(AtomicUsize::new(0)))).await;
|
||||
assert_eq!(mixed.joule_source, JouleSource::Estimated);
|
||||
|
||||
let none = EvalRunner::new(
|
||||
Box::new(FixedHarness { n_cases: 0, correct: "yes" }),
|
||||
Arc::new(Costing(JouleCost::measured(10))),
|
||||
)
|
||||
.run()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(none.joule_source, JouleSource::Estimated, "no case ran, nothing was read");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
//! the same [`eoc_neural::NeuralBackend`] trait that
|
||||
//! [`eoc-vendor-api`](https://docs.rs/eoc-vendor-api) uses for commercial
|
||||
//! endpoints. Where vendor APIs *estimate* joule cost from token counts,
|
||||
//! local backends *measure* it — the host hardware is right there, and
|
||||
//! [`eoc_meter::JouleCounter`] readings taken on either side of the
|
||||
//! inference give an honest reading.
|
||||
//! local backends can *measure* it — the host hardware is right there, and
|
||||
//! when the host has a live counter, [`eoc_meter::JouleCounter`] readings
|
||||
//! taken on either side of the inference give an honest reading.
|
||||
//!
|
||||
//! ## Backends (all feature-gated)
|
||||
//!
|
||||
|
|
@ -23,11 +23,29 @@
|
|||
//!
|
||||
//! ## Joule attribution
|
||||
//!
|
||||
//! Each backend takes a [`JouleCounter`](eoc_meter::JouleCounter), reads
|
||||
//! it before and after inference, and reports the delta as
|
||||
//! [`JouleSource::Measured`](eoc_core::JouleSource::Measured). When the
|
||||
//! attached counter is [`StubCounter`](eoc_meter::StubCounter), cost is
|
||||
//! zero — useful for tests, deterministic playback, and CI.
|
||||
//! Each backend takes a [`JouleCounter`](eoc_meter::JouleCounter) and reads
|
||||
//! it around the inference, by one rule ([`span`]):
|
||||
//!
|
||||
//! - The inference is **measured** only when the counter read at the start,
|
||||
//! read at the end, and advanced between them. The delta is then a live
|
||||
//! hardware counter's reading, reported as
|
||||
//! [`JouleSource::Measured`](eoc_core::JouleSource::Measured).
|
||||
//! - Otherwise nothing was measured: the
|
||||
//! [`StubCounter`](eoc_meter::StubCounter), which always reads 0; a counter
|
||||
//! that read but did not move; or a read that failed at either end, such
|
||||
//! as the powermetrics stand-in's, which [`eoc_meter::detect`] selects on a
|
||||
//! Mac whose IOReport CPU rail is not live. A failed first read is never
|
||||
//! taken as 0, which would report the counter's since-boot total.
|
||||
//! - Inference that ran and that nothing measured is never 0 µJ. Every
|
||||
//! backend then reports its per-token estimate, `fallback_joules_per_token`
|
||||
//! (0.05 J by default, [`span::DEFAULT_FALLBACK_JOULES_PER_TOKEN`]) × the
|
||||
//! tokens it generated, tagged
|
||||
//! [`JouleSource::Estimated`](eoc_core::JouleSource::Estimated).
|
||||
//!
|
||||
//! `llamacpp` also reads the counter with each streamed token, so a chunk
|
||||
//! carries the reading so far when there is one; the inference's cost is
|
||||
//! the final read's. A generation that ends in an error is estimated over
|
||||
//! the tokens streamed before it, at least one.
|
||||
//!
|
||||
//! ## WASM
|
||||
//!
|
||||
|
|
@ -49,10 +67,17 @@ pub mod tokenizer;
|
|||
#[cfg(any(feature = "gguf", feature = "llamacpp"))]
|
||||
pub mod gguf;
|
||||
|
||||
#[cfg(feature = "llamacpp")]
|
||||
pub mod span;
|
||||
|
||||
// The module binds no native symbol (its generator is a placeholder until an
|
||||
// operator wires llama.cpp in), so this crate's tests compile it without the
|
||||
// feature, and its metering is tested in every build.
|
||||
#[cfg(any(feature = "llamacpp", test))]
|
||||
pub mod llamacpp;
|
||||
|
||||
#[cfg(all(feature = "mlx", target_os = "macos"))]
|
||||
// Likewise binds no native symbol yet: compiled for this crate's tests on
|
||||
// macOS without the feature.
|
||||
#[cfg(all(any(feature = "mlx", test), target_os = "macos"))]
|
||||
pub mod mlx;
|
||||
|
||||
#[cfg(feature = "mlc")]
|
||||
|
|
|
|||
|
|
@ -11,22 +11,33 @@
|
|||
//! | Linux + Intel Arc | SYCL |
|
||||
//! | otherwise | Vulkan (cross-vendor) or CPU |
|
||||
//!
|
||||
//! Joule attribution: a [`JouleCounter`] reading is taken before and
|
||||
//! after each inference. The delta is reported as
|
||||
//! [`JouleSource::Measured`](eoc_core::JouleSource::Measured) when the
|
||||
//! counter actually produced a number, else `Estimated`.
|
||||
//! Joule attribution: a [`JouleCounter`] span is opened before generation
|
||||
//! and read with each streamed token and once more at the end
|
||||
//! ([`crate::span`]). The inference's cost is the final reading,
|
||||
//! [`JouleSource::Measured`](eoc_core::JouleSource::Measured), only when
|
||||
//! the counter read at the start, read at the end, and advanced between
|
||||
//! them. Otherwise nothing was measured, and the cost is a per-token
|
||||
//! estimate ([`LlamaCppBackend::fallback_joules_per_token`] × tokens
|
||||
//! streamed), tagged estimated, never 0 µJ. A generation that ends in an
|
||||
//! error is estimated the same way, over the tokens streamed before it (at
|
||||
//! least one): inference may have run, so its cost is never 0 µJ either.
|
||||
//!
|
||||
//! Streaming: tokens are emitted through a `tokio::sync::mpsc` channel
|
||||
//! for incremental UIs. The non-streaming `infer` is implemented as
|
||||
//! "drain the stream".
|
||||
//!
|
||||
//! The module's own logic needs no native code: the reference build's
|
||||
//! generator below is a placeholder, so the module is compiled for this
|
||||
//! crate's tests without the `llamacpp` feature, and its metering runs
|
||||
//! under test on a scripted counter.
|
||||
//!
|
||||
//! [`llama-cpp-2`]: https://crates.io/crates/llama-cpp-2
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eoc_core::{JouleCost, JouleSource, Query, Response, Stage};
|
||||
use eoc_core::{JouleCost, Query, QueryId, Response, Stage};
|
||||
use eoc_meter::JouleCounter;
|
||||
use eoc_neural::NeuralBackend;
|
||||
use tokio::sync::mpsc;
|
||||
|
|
@ -34,6 +45,7 @@ use tokio::sync::mpsc;
|
|||
use crate::error::{LocalError, LocalResult};
|
||||
use crate::model_registry::Quantization;
|
||||
use crate::sampling::{GreedySampler, Sampler};
|
||||
use crate::span::{self, MeterSpan};
|
||||
|
||||
/// llama.cpp backend.
|
||||
pub struct LlamaCppBackend {
|
||||
|
|
@ -54,6 +66,9 @@ pub struct LlamaCppBackend {
|
|||
pub sampler: Box<dyn Sampler>,
|
||||
/// Joule counter used to attribute energy cost.
|
||||
pub meter: Arc<dyn JouleCounter>,
|
||||
/// Estimated joules per streamed token, used when the counter did not
|
||||
/// measure the inference ([`crate::span::per_token_estimate`]).
|
||||
pub fallback_joules_per_token: f64,
|
||||
}
|
||||
|
||||
/// Configuration for [`LlamaCppBackend`]. The runtime model object
|
||||
|
|
@ -105,6 +120,7 @@ impl LlamaCppBackend {
|
|||
quantization: config.quantization,
|
||||
sampler: Box::new(GreedySampler),
|
||||
meter,
|
||||
fallback_joules_per_token: span::DEFAULT_FALLBACK_JOULES_PER_TOKEN,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -114,9 +130,16 @@ impl LlamaCppBackend {
|
|||
self
|
||||
}
|
||||
|
||||
/// Override the per-token estimate used when nothing was measured.
|
||||
pub fn with_fallback_joules_per_token(mut self, j: f64) -> Self {
|
||||
self.fallback_joules_per_token = j;
|
||||
self
|
||||
}
|
||||
|
||||
/// Streaming inference: emits one token per send. The receiver is
|
||||
/// closed when generation completes. Each chunk includes the
|
||||
/// running joule estimate.
|
||||
/// closed when generation completes. Each chunk carries what the
|
||||
/// counter has read since generation started, when it has read
|
||||
/// anything ([`TokenChunk::cumulative_microjoules`]).
|
||||
///
|
||||
/// Implemented as `spawn_blocking` so the synchronous llama.cpp
|
||||
/// call doesn't starve the tokio reactor.
|
||||
|
|
@ -139,8 +162,12 @@ impl LlamaCppBackend {
|
|||
pub struct TokenChunk {
|
||||
/// Decoded UTF-8 fragment for this token.
|
||||
pub text: String,
|
||||
/// Cumulative joule cost since the start of generation.
|
||||
pub cumulative_microjoules: u64,
|
||||
/// What the counter has read since generation started, in micro-joules:
|
||||
/// `Some` only when it read at the start, read for this chunk, and
|
||||
/// advanced between the two. `None` when nothing was measured — a failed
|
||||
/// read, the stub, a counter that did not move — which is not 0 µJ: the
|
||||
/// tokens were generated.
|
||||
pub cumulative_microjoules: Option<u64>,
|
||||
/// Whether this is the last chunk.
|
||||
pub final_chunk: bool,
|
||||
}
|
||||
|
|
@ -148,46 +175,68 @@ pub struct TokenChunk {
|
|||
#[async_trait]
|
||||
impl NeuralBackend for LlamaCppBackend {
|
||||
async fn infer(&self, q: &Query) -> Response {
|
||||
// Drain the stream into a single response.
|
||||
let mut rx = self.stream(q).await;
|
||||
let mut text = String::new();
|
||||
let mut last_cum = 0u64;
|
||||
let mut source = JouleSource::Measured;
|
||||
while let Some(item) = rx.recv().await {
|
||||
match item {
|
||||
Ok(chunk) => {
|
||||
text.push_str(&chunk.text);
|
||||
last_cum = chunk.cumulative_microjoules;
|
||||
if chunk.cumulative_microjoules == 0 {
|
||||
source = JouleSource::Estimated;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Response::new(
|
||||
q.id,
|
||||
format!("[llamacpp-error: {e}]"),
|
||||
Stage::Neural,
|
||||
JouleCost {
|
||||
microjoules: 0,
|
||||
source: JouleSource::Estimated,
|
||||
},
|
||||
);
|
||||
}
|
||||
drain(self.stream(q).await, q.id, self.fallback_joules_per_token).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain a generation's stream into the response for query `id`. The cost
|
||||
/// of a stream that ends is [`StreamTally::cost`]. A stream that errors ends
|
||||
/// there, with the error text; its inference may have run — the prompt
|
||||
/// processed and every token streamed before the error generated — so its
|
||||
/// cost is the per-token estimate over those tokens, at least one, tagged
|
||||
/// estimated: never 0 µJ, and never a reading that covers only part of it.
|
||||
async fn drain(mut rx: mpsc::Receiver<LocalResult<TokenChunk>>, id: QueryId, joules_per_token: f64) -> Response {
|
||||
let mut text = String::new();
|
||||
let mut tally = StreamTally::default();
|
||||
while let Some(item) = rx.recv().await {
|
||||
match item {
|
||||
Ok(chunk) => {
|
||||
text.push_str(&chunk.text);
|
||||
tally.observe(&chunk);
|
||||
}
|
||||
Err(e) => {
|
||||
return Response::new(
|
||||
id,
|
||||
format!("[llamacpp-error: {e}]"),
|
||||
Stage::Neural,
|
||||
JouleCost::estimated(span::per_token_estimate(tally.tokens, joules_per_token)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Response::new(id, text, Stage::Neural, tally.cost(joules_per_token))
|
||||
}
|
||||
|
||||
/// What a drained stream says about its cost: how many tokens it carried,
|
||||
/// and the final chunk's reading.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
struct StreamTally {
|
||||
/// Chunks that carried a token.
|
||||
tokens: u64,
|
||||
/// The final chunk's reading: what the counter read over the whole
|
||||
/// generation, when it read anything. `None` until the final chunk, and
|
||||
/// after it when nothing was measured.
|
||||
final_reading: Option<u64>,
|
||||
}
|
||||
|
||||
impl StreamTally {
|
||||
fn observe(&mut self, chunk: &TokenChunk) {
|
||||
if chunk.final_chunk {
|
||||
self.final_reading = chunk.cumulative_microjoules;
|
||||
} else if !chunk.text.is_empty() {
|
||||
self.tokens += 1;
|
||||
}
|
||||
Response::new(
|
||||
q.id,
|
||||
text,
|
||||
Stage::Neural,
|
||||
JouleCost {
|
||||
microjoules: last_cum,
|
||||
source,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The generation's cost: the final reading, measured, when the counter
|
||||
/// measured the whole span; otherwise the per-token estimate. A reading
|
||||
/// from an earlier chunk covers only part of the generation, so it is
|
||||
/// never the cost of the whole.
|
||||
fn cost(&self, joules_per_token: f64) -> JouleCost {
|
||||
span::span_cost(self.final_reading, || span::per_token_estimate(self.tokens, joules_per_token))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "llamacpp")]
|
||||
fn run_llamacpp_blocking(
|
||||
prompt: &str,
|
||||
model_path: &std::path::Path,
|
||||
|
|
@ -221,7 +270,7 @@ fn run_llamacpp_blocking(
|
|||
// plumbing. Operators wire in the real call against their pinned
|
||||
// llama-cpp-2 revision.
|
||||
|
||||
let start = meter.read_microjoules().unwrap_or(0);
|
||||
let span = MeterSpan::open(meter);
|
||||
let mut out = String::new();
|
||||
let n = max_tokens.min(64) as usize;
|
||||
for i in 0..n {
|
||||
|
|
@ -243,15 +292,10 @@ fn run_llamacpp_blocking(
|
|||
break;
|
||||
}
|
||||
out.push_str(&frag);
|
||||
let cum = meter
|
||||
.read_microjoules()
|
||||
.ok()
|
||||
.map(|now| now.saturating_sub(start))
|
||||
.unwrap_or(0);
|
||||
if tx
|
||||
.blocking_send(Ok(TokenChunk {
|
||||
text: frag,
|
||||
cumulative_microjoules: cum,
|
||||
cumulative_microjoules: span.reading(meter),
|
||||
final_chunk: false,
|
||||
}))
|
||||
.is_err()
|
||||
|
|
@ -259,37 +303,18 @@ fn run_llamacpp_blocking(
|
|||
break;
|
||||
}
|
||||
}
|
||||
let cum = meter
|
||||
.read_microjoules()
|
||||
.ok()
|
||||
.map(|now| now.saturating_sub(start))
|
||||
.unwrap_or(0);
|
||||
let _ = tx.blocking_send(Ok(TokenChunk {
|
||||
text: String::new(),
|
||||
cumulative_microjoules: cum,
|
||||
cumulative_microjoules: span.reading(meter),
|
||||
final_chunk: true,
|
||||
}));
|
||||
let _ = out;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "llamacpp"))]
|
||||
#[allow(dead_code)]
|
||||
fn run_llamacpp_blocking(
|
||||
_prompt: &str,
|
||||
_model_path: &std::path::Path,
|
||||
_max_tokens: u32,
|
||||
_meter: &dyn JouleCounter,
|
||||
tx: mpsc::Sender<LocalResult<TokenChunk>>,
|
||||
) {
|
||||
let _ = tx.blocking_send(Err(LocalError::Backend {
|
||||
backend: "llamacpp",
|
||||
message: "feature `llamacpp` not enabled".into(),
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::span::scripted::Scripted;
|
||||
use eoc_meter::StubCounter;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -303,21 +328,135 @@ mod tests {
|
|||
assert!(matches!(r, Err(LocalError::ModelNotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_counter_reports_zero_cost() {
|
||||
// Construct a temp file so the existence check passes, then
|
||||
// verify that with a StubCounter the reported joule cost is 0.
|
||||
/// A backend on a dummy model file, metered by `meter`. The placeholder
|
||||
/// generator streams four tokens for "hello world" (its header, the two
|
||||
/// words, a filler), so the per-token estimate at 0.05 J/token is
|
||||
/// 200,000 µJ.
|
||||
fn backend(meter: Arc<dyn JouleCounter>) -> (tempfile::TempDir, LlamaCppBackend) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("dummy.gguf");
|
||||
std::fs::write(&p, b"").unwrap();
|
||||
let cfg = LlamaCppConfig {
|
||||
model_path: p,
|
||||
..LlamaCppConfig::default()
|
||||
};
|
||||
let backend = LlamaCppBackend::new(cfg, Arc::new(StubCounter)).unwrap();
|
||||
let q = Query::new("hello world");
|
||||
let r = backend.infer(&q).await;
|
||||
assert_eq!(r.joule_cost.microjoules, 0);
|
||||
let cfg = LlamaCppConfig { model_path: p, ..LlamaCppConfig::default() };
|
||||
let b = LlamaCppBackend::new(cfg, meter).unwrap().with_fallback_joules_per_token(0.05);
|
||||
(dir, b)
|
||||
}
|
||||
|
||||
const FOUR_TOKENS_UJ: u64 = 200_000;
|
||||
|
||||
/// Every chunk of a stream metered by `meter`, in order.
|
||||
async fn chunks(meter: Arc<dyn JouleCounter>) -> Vec<TokenChunk> {
|
||||
let (_dir, b) = backend(meter);
|
||||
let mut rx = b.stream(&Query::new("hello world")).await;
|
||||
let mut out = Vec::new();
|
||||
while let Some(item) = rx.recv().await {
|
||||
out.push(item.expect("the placeholder never errors"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn cost(meter: Arc<dyn JouleCounter>) -> JouleCost {
|
||||
let (_dir, b) = backend(meter);
|
||||
let r = b.infer(&Query::new("hello world")).await;
|
||||
assert_eq!(r.stage, Stage::Neural);
|
||||
r.joule_cost
|
||||
}
|
||||
|
||||
/// The stub measured nothing, and the tokens were generated: the cost is
|
||||
/// the per-token estimate, never 0 µJ.
|
||||
#[tokio::test]
|
||||
async fn the_stub_costs_the_per_token_estimate_never_zero() {
|
||||
assert_eq!(cost(Arc::new(StubCounter)).await, JouleCost::estimated(FOUR_TOKENS_UJ));
|
||||
let streamed = chunks(Arc::new(StubCounter)).await;
|
||||
assert_eq!(streamed.iter().filter(|c| !c.final_chunk).count(), 4);
|
||||
assert!(streamed.iter().all(|c| c.cumulative_microjoules.is_none()), "{streamed:?}");
|
||||
}
|
||||
|
||||
/// A failed first read is no baseline. The next reading is the counter's
|
||||
/// since-boot total (9 J here), not the generation's energy: no chunk
|
||||
/// carries it and the cost is not it.
|
||||
#[tokio::test]
|
||||
async fn a_failed_first_read_never_reports_the_since_boot_total() {
|
||||
let streamed = chunks(Arc::new(Scripted::new(&[None, Some(9_000_000)]))).await;
|
||||
assert!(streamed.iter().all(|c| c.cumulative_microjoules.is_none()), "{streamed:?}");
|
||||
let c = cost(Arc::new(Scripted::new(&[None, Some(9_000_000)]))).await;
|
||||
assert_eq!(c, JouleCost::estimated(FOUR_TOKENS_UJ));
|
||||
}
|
||||
|
||||
/// A counter that advanced over the whole generation measured it: the
|
||||
/// final reading, measured. Each chunk carries the reading so far.
|
||||
#[tokio::test]
|
||||
async fn a_counter_that_advanced_measures_the_generation() {
|
||||
let script = [Some(1_000), Some(1_100), Some(1_200), Some(1_300), Some(1_400), Some(1_450)];
|
||||
let meter = Arc::new(Scripted::new(&script));
|
||||
let streamed = chunks(meter.clone()).await;
|
||||
assert_eq!(meter.reads(), 6, "one read to open, one per token, one at the end");
|
||||
let readings: Vec<Option<u64>> = streamed.iter().map(|c| c.cumulative_microjoules).collect();
|
||||
assert_eq!(readings, vec![Some(100), Some(200), Some(300), Some(400), Some(450)]);
|
||||
assert_eq!(cost(Arc::new(Scripted::new(&script))).await, JouleCost::measured(450));
|
||||
}
|
||||
|
||||
/// A counter that read but did not move measured nothing.
|
||||
#[tokio::test]
|
||||
async fn a_counter_that_held_is_an_estimate() {
|
||||
assert_eq!(cost(Arc::new(Scripted::new(&[Some(7_000)]))).await, JouleCost::estimated(FOUR_TOKENS_UJ));
|
||||
}
|
||||
|
||||
/// The last read failed: the readings the earlier chunks carried cover
|
||||
/// part of the generation, so none of them is its cost.
|
||||
#[tokio::test]
|
||||
async fn a_failed_last_read_is_an_estimate_not_a_partial_reading() {
|
||||
let script = [Some(1_000), Some(1_100), Some(1_200), Some(1_300), Some(1_400), None];
|
||||
let streamed = chunks(Arc::new(Scripted::new(&script))).await;
|
||||
assert_eq!(streamed.last().map(|c| (c.final_chunk, c.cumulative_microjoules)), Some((true, None)));
|
||||
assert_eq!(cost(Arc::new(Scripted::new(&script))).await, JouleCost::estimated(FOUR_TOKENS_UJ));
|
||||
}
|
||||
|
||||
/// A stream that ends in an error, drained as `infer` drains one: `sent`
|
||||
/// token chunks, each carrying `reading`, then the error.
|
||||
async fn drain_with_error(sent: usize, reading: Option<u64>) -> Response {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
for i in 0..sent {
|
||||
let chunk = TokenChunk { text: format!("t{i} "), cumulative_microjoules: reading, final_chunk: false };
|
||||
tx.send(Ok(chunk)).await.unwrap();
|
||||
}
|
||||
let error = LocalError::Backend { backend: "llamacpp", message: "decode failed".to_string() };
|
||||
tx.send(Err(error)).await.unwrap();
|
||||
drop(tx);
|
||||
drain(rx, Query::new("hello world").id, 0.05).await
|
||||
}
|
||||
|
||||
/// An error ends the generation, but inference may have run: the cost is
|
||||
/// the per-token estimate over the tokens streamed before the error,
|
||||
/// tagged estimated — three tokens, 150,000 µJ at 0.05 J — and one token
|
||||
/// (50,000 µJ) when the error came first. Never 0 µJ, and never a reading
|
||||
/// an earlier chunk carried, which covers only part of the work.
|
||||
#[tokio::test]
|
||||
async fn an_error_costs_the_per_token_estimate_never_zero() {
|
||||
let r = drain_with_error(3, None).await;
|
||||
assert_eq!(r.payload, "[llamacpp-error: backend error (llamacpp): decode failed]");
|
||||
assert_eq!((r.stage, r.joule_cost), (Stage::Neural, JouleCost::estimated(150_000)));
|
||||
assert_eq!(drain_with_error(3, Some(400)).await.joule_cost, JouleCost::estimated(150_000));
|
||||
assert_eq!(drain_with_error(0, None).await.joule_cost, JouleCost::estimated(50_000));
|
||||
}
|
||||
|
||||
/// The tally counts tokens and takes only the final chunk's reading.
|
||||
#[test]
|
||||
fn the_tally_costs_only_the_final_reading() {
|
||||
let chunk = |text: &str, reading, final_chunk| TokenChunk {
|
||||
text: text.to_string(),
|
||||
cumulative_microjoules: reading,
|
||||
final_chunk,
|
||||
};
|
||||
let mut t = StreamTally::default();
|
||||
t.observe(&chunk("a", Some(100), false));
|
||||
t.observe(&chunk("b", Some(250), false));
|
||||
assert_eq!(t.cost(0.05), JouleCost::estimated(100_000), "no final chunk yet");
|
||||
t.observe(&chunk("", None, true));
|
||||
assert_eq!(t, StreamTally { tokens: 2, final_reading: None });
|
||||
assert_eq!(t.cost(0.05), JouleCost::estimated(100_000));
|
||||
let mut t = StreamTally::default();
|
||||
t.observe(&chunk("a", Some(100), false));
|
||||
t.observe(&chunk("", Some(300), true));
|
||||
assert_eq!(t.cost(0.05), JouleCost::measured(300));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,23 @@
|
|||
//! provides the *runtime loader* only. The expected on-disk layout is
|
||||
//! the standard `mlc-chat-config.json` + compiled library + weights.
|
||||
//!
|
||||
//! Joule attribution piggy-backs on the `mlc_chat` runtime's own
|
||||
//! reported timing where available; otherwise we sandwich the inference
|
||||
//! call with [`JouleCounter`] reads exactly like the other backends.
|
||||
//! Joule attribution sandwiches the inference call with [`JouleCounter`]
|
||||
//! reads, by the rule every backend here shares ([`crate::span`]): the
|
||||
//! delta is measured only when both reads succeed and the counter
|
||||
//! advanced. Otherwise nothing was measured, and the cost is a per-token
|
||||
//! estimate ([`MlcBackend::fallback_joules_per_token`]), tagged estimated,
|
||||
//! as `mlx`'s is — never 0 µJ for inference that ran.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eoc_core::{JouleCost, JouleSource, Query, Response, Stage};
|
||||
use eoc_core::{Query, Response, Stage};
|
||||
use eoc_meter::JouleCounter;
|
||||
use eoc_neural::NeuralBackend;
|
||||
|
||||
use crate::error::{LocalError, LocalResult};
|
||||
use crate::span::{self, MeterSpan};
|
||||
|
||||
/// MLC-LLM backend.
|
||||
pub struct MlcBackend {
|
||||
|
|
@ -37,6 +41,9 @@ pub struct MlcBackend {
|
|||
pub max_tokens: u32,
|
||||
/// Joule counter for attribution.
|
||||
pub meter: Arc<dyn JouleCounter>,
|
||||
/// Estimated joules per output token, used when the counter did not
|
||||
/// measure the inference ([`crate::span::per_token_estimate`]).
|
||||
pub fallback_joules_per_token: f64,
|
||||
}
|
||||
|
||||
impl MlcBackend {
|
||||
|
|
@ -52,6 +59,7 @@ impl MlcBackend {
|
|||
device: default_device().to_string(),
|
||||
max_tokens: 512,
|
||||
meter,
|
||||
fallback_joules_per_token: span::DEFAULT_FALLBACK_JOULES_PER_TOKEN,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +81,12 @@ impl MlcBackend {
|
|||
self.max_tokens = n;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the per-token estimate used when nothing was measured.
|
||||
pub fn with_fallback_joules_per_token(mut self, j: f64) -> Self {
|
||||
self.fallback_joules_per_token = j;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn default_device() -> &'static str {
|
||||
|
|
@ -99,34 +113,29 @@ impl NeuralBackend for MlcBackend {
|
|||
// let response = mlc.generate(&q.prompt, GenerationOptions { max_tokens, ... })?;
|
||||
//
|
||||
// Reference build emits a placeholder and uses the meter
|
||||
// sandwich for attribution.
|
||||
// sandwich for attribution. Its token count, for the estimate, is
|
||||
// the placeholder's own (as `mlx`'s is); a wired runtime passes the
|
||||
// number of tokens it generated.
|
||||
|
||||
let start = self.meter.read_microjoules().ok();
|
||||
let span = MeterSpan::open(self.meter.as_ref());
|
||||
let generated_tokens = u64::from(self.max_tokens.min(64));
|
||||
let payload = format!(
|
||||
"[mlc device={} model_dir={}]",
|
||||
self.device,
|
||||
self.model_dir.display()
|
||||
);
|
||||
let end = self.meter.read_microjoules().ok();
|
||||
let (microjoules, source) = match (start, end) {
|
||||
(Some(s), Some(e)) if e >= s && (e - s) > 0 => (e - s, JouleSource::Measured),
|
||||
_ => (0, JouleSource::Estimated),
|
||||
};
|
||||
Response::new(
|
||||
q.id,
|
||||
payload,
|
||||
Stage::Neural,
|
||||
JouleCost {
|
||||
microjoules,
|
||||
source,
|
||||
},
|
||||
)
|
||||
let cost = span::span_cost(span.reading(self.meter.as_ref()), || {
|
||||
span::per_token_estimate(generated_tokens, self.fallback_joules_per_token)
|
||||
});
|
||||
Response::new(q.id, payload, Stage::Neural, cost)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::span::scripted::Scripted;
|
||||
use eoc_core::JouleCost;
|
||||
use eoc_meter::StubCounter;
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -142,13 +151,38 @@ mod tests {
|
|||
assert!(matches!(r, Err(LocalError::ModelNotFound(_))));
|
||||
}
|
||||
|
||||
/// Inference that ran and that nothing measured costs the per-token
|
||||
/// estimate, tagged estimated: 8 tokens × 0.1 J = 800,000 µJ, never 0.
|
||||
#[tokio::test]
|
||||
async fn stub_counter_reports_zero() {
|
||||
async fn the_stub_costs_the_per_token_estimate_never_zero() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let b = MlcBackend::new(dir.path(), Arc::new(StubCounter)).unwrap();
|
||||
let q = Query::new("hello");
|
||||
let r = b.infer(&q).await;
|
||||
assert_eq!(r.joule_cost.microjoules, 0);
|
||||
let b = MlcBackend::new(dir.path(), Arc::new(StubCounter))
|
||||
.unwrap()
|
||||
.with_max_tokens(8)
|
||||
.with_fallback_joules_per_token(0.1);
|
||||
let r = b.infer(&Query::new("hello")).await;
|
||||
assert_eq!(r.joule_cost, JouleCost::estimated(800_000));
|
||||
assert_eq!(r.stage, Stage::Neural);
|
||||
// The default coefficient, over the placeholder's 64-token cap.
|
||||
let b = MlcBackend::new(dir.path(), Arc::new(StubCounter)).unwrap();
|
||||
assert_eq!(b.infer(&Query::new("hello")).await.joule_cost, JouleCost::estimated(64 * 50_000));
|
||||
}
|
||||
|
||||
/// Each way a span can fail to measure gives the estimate; only a counter
|
||||
/// that read twice and advanced gives a measured figure, its delta.
|
||||
#[tokio::test]
|
||||
async fn only_a_counter_that_advanced_is_measured() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cost = |script: &'static [Option<u64>]| {
|
||||
let b = MlcBackend::new(dir.path(), Arc::new(Scripted::new(script)))
|
||||
.unwrap()
|
||||
.with_max_tokens(8)
|
||||
.with_fallback_joules_per_token(0.1);
|
||||
async move { b.infer(&Query::new("hello")).await.joule_cost }
|
||||
};
|
||||
assert_eq!(cost(&[Some(1_000), Some(1_450)]).await, JouleCost::measured(450));
|
||||
assert_eq!(cost(&[Some(7_000)]).await, JouleCost::estimated(800_000), "held");
|
||||
assert_eq!(cost(&[None, Some(9_000_000)]).await, JouleCost::estimated(800_000), "failed first read");
|
||||
assert_eq!(cost(&[Some(1_000), None]).await, JouleCost::estimated(800_000), "failed last read");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,18 @@
|
|||
//! choice on M-series Macs for models that have been converted to MLX
|
||||
//! format. The Rust binding lives in [`mlx-rs`].
|
||||
//!
|
||||
//! Joule attribution on macOS uses `powermetrics`, which is already
|
||||
//! wired into [`eoc_meter`]. `powermetrics` requires root in practice;
|
||||
//! when readings fail, the backend tags joule cost as
|
||||
//! Joule attribution reads whatever [`JouleCounter`] the backend is given
|
||||
//! (on a Mac, [`eoc_meter::detect`] picks IOReport only when its CPU rail is
|
||||
//! live; its `powermetrics` stand-in never reads), by the rule every backend
|
||||
//! here shares ([`crate::span`]). A delta from a counter that read twice and
|
||||
//! advanced is reported as measured; when a read fails or the counter does
|
||||
//! not move, the backend tags joule cost as
|
||||
//! [`JouleSource::Estimated`](eoc_core::JouleSource::Estimated) and
|
||||
//! falls back to a coefficient-based estimate.
|
||||
//! falls back to a coefficient-based estimate, never 0 µJ.
|
||||
//!
|
||||
//! The module binds no `mlx-rs` symbol yet (its generator is a placeholder),
|
||||
//! so this crate's tests compile it on macOS without the feature, and its
|
||||
//! metering is tested in every macOS build.
|
||||
//!
|
||||
//! This module is hard-gated to `target_os = "macos"`. On any other
|
||||
//! platform compilation of `--features mlx` will fail with a clear
|
||||
|
|
@ -23,11 +30,12 @@ use std::path::PathBuf;
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eoc_core::{JouleCost, JouleSource, Query, Response, Stage};
|
||||
use eoc_core::{Query, Response, Stage};
|
||||
use eoc_meter::JouleCounter;
|
||||
use eoc_neural::NeuralBackend;
|
||||
|
||||
use crate::error::{LocalError, LocalResult};
|
||||
use crate::span::{self, MeterSpan};
|
||||
|
||||
/// Apple MLX backend.
|
||||
pub struct MlxBackend {
|
||||
|
|
@ -62,7 +70,7 @@ impl MlxBackend {
|
|||
tokenizer_path,
|
||||
max_tokens: 512,
|
||||
meter,
|
||||
fallback_joules_per_token: 0.05,
|
||||
fallback_joules_per_token: span::DEFAULT_FALLBACK_JOULES_PER_TOKEN,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -97,33 +105,22 @@ impl NeuralBackend for MlxBackend {
|
|||
// the trait + plumbing are exercised end-to-end. Operators
|
||||
// swap in the real mlx-rs symbols against their pinned rev.
|
||||
|
||||
let start = self.meter.read_microjoules().ok();
|
||||
let estimated_tokens = self.max_tokens.min(64) as u32;
|
||||
let span = MeterSpan::open(self.meter.as_ref());
|
||||
let estimated_tokens = u64::from(self.max_tokens.min(64));
|
||||
let payload = format!("[mlx model={} prompt={:?}]", self.model_path.display(), q.prompt);
|
||||
|
||||
let end = self.meter.read_microjoules().ok();
|
||||
let (microjoules, source) = match (start, end) {
|
||||
(Some(s), Some(e)) if e >= s && (e - s) > 0 => (e - s, JouleSource::Measured),
|
||||
_ => {
|
||||
let j = (estimated_tokens as f64) * self.fallback_joules_per_token;
|
||||
((j * 1_000_000.0).max(0.0) as u64, JouleSource::Estimated)
|
||||
}
|
||||
};
|
||||
Response::new(
|
||||
q.id,
|
||||
payload,
|
||||
Stage::Neural,
|
||||
JouleCost {
|
||||
microjoules,
|
||||
source,
|
||||
},
|
||||
)
|
||||
let cost = span::span_cost(span.reading(self.meter.as_ref()), || {
|
||||
span::per_token_estimate(estimated_tokens, self.fallback_joules_per_token)
|
||||
});
|
||||
Response::new(q.id, payload, Stage::Neural, cost)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::span::scripted::Scripted;
|
||||
use eoc_core::{JouleCost, JouleSource};
|
||||
use eoc_meter::StubCounter;
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -145,6 +142,28 @@ mod tests {
|
|||
assert_eq!(r.joule_cost.microjoules, 800_000);
|
||||
}
|
||||
|
||||
/// Only a counter that read twice and advanced is a measurement; a
|
||||
/// failed first read is no baseline, so a since-boot total is not one.
|
||||
#[tokio::test]
|
||||
async fn only_a_counter_that_advanced_is_measured() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let model = dir.path().join("model");
|
||||
std::fs::create_dir(&model).unwrap();
|
||||
let tok = model.join("tokenizer.json");
|
||||
for (script, want) in [
|
||||
(&[Some(1_000), Some(1_450)][..], JouleCost::measured(450)),
|
||||
(&[Some(7_000)][..], JouleCost::estimated(800_000)),
|
||||
(&[None, Some(9_000_000)][..], JouleCost::estimated(800_000)),
|
||||
(&[Some(1_000), None][..], JouleCost::estimated(800_000)),
|
||||
] {
|
||||
let b = MlxBackend::new(&model, &tok, Arc::new(Scripted::new(script)))
|
||||
.unwrap()
|
||||
.with_max_tokens(8)
|
||||
.with_fallback_joules_per_token(0.1);
|
||||
assert_eq!(b.infer(&Query::new("ping")).await.joule_cost, want, "{script:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_model_returns_not_found() {
|
||||
let r = MlxBackend::new("/no/such/path", "/no/tok", Arc::new(StubCounter));
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@
|
|||
//! is composed locally via the [`crate::sampling`] samplers.
|
||||
//!
|
||||
//! Joule attribution: pre/post counter reads sandwich the generation
|
||||
//! call.
|
||||
//! call, by the rule every backend here shares ([`crate::span`]): the
|
||||
//! delta is measured only when both reads succeed and the counter
|
||||
//! advanced. Otherwise nothing was measured, and the cost is a per-token
|
||||
//! estimate ([`OnnxBackend::fallback_joules_per_token`]), tagged estimated,
|
||||
//! as `mlx`'s is — never 0 µJ for inference that ran.
|
||||
//!
|
||||
//! [ONNX Runtime]: https://onnxruntime.ai
|
||||
//! [`ort`]: https://crates.io/crates/ort
|
||||
|
|
@ -20,12 +24,13 @@ use std::path::PathBuf;
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eoc_core::{JouleCost, JouleSource, Query, Response, Stage};
|
||||
use eoc_core::{Query, Response, Stage};
|
||||
use eoc_meter::JouleCounter;
|
||||
use eoc_neural::NeuralBackend;
|
||||
|
||||
use crate::error::{LocalError, LocalResult};
|
||||
use crate::sampling::{GreedySampler, Sampler};
|
||||
use crate::span::{self, MeterSpan};
|
||||
use crate::tokenizer::LocalTokenizer;
|
||||
|
||||
/// Generation-loop configuration.
|
||||
|
|
@ -100,6 +105,9 @@ pub struct OnnxBackend {
|
|||
pub sampler: Box<dyn Sampler>,
|
||||
/// Joule counter.
|
||||
pub meter: Arc<dyn JouleCounter>,
|
||||
/// Estimated joules per output token, used when the counter did not
|
||||
/// measure the inference ([`crate::span::per_token_estimate`]).
|
||||
pub fallback_joules_per_token: f64,
|
||||
}
|
||||
|
||||
impl OnnxBackend {
|
||||
|
|
@ -120,6 +128,7 @@ impl OnnxBackend {
|
|||
generation_config: GenerationConfig::default(),
|
||||
sampler: Box::new(GreedySampler),
|
||||
meter,
|
||||
fallback_joules_per_token: span::DEFAULT_FALLBACK_JOULES_PER_TOKEN,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -140,6 +149,12 @@ impl OnnxBackend {
|
|||
self.sampler = sampler;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the per-token estimate used when nothing was measured.
|
||||
pub fn with_fallback_joules_per_token(mut self, j: f64) -> Self {
|
||||
self.fallback_joules_per_token = j;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -164,51 +179,69 @@ impl NeuralBackend for OnnxBackend {
|
|||
// }
|
||||
// let text = self.tokenizer.decode(&tokens[input_ids.len()..], true)?;
|
||||
//
|
||||
// Reference build sandwich-meters around a placeholder.
|
||||
// Reference build sandwich-meters around a placeholder. Its token
|
||||
// count, for the estimate, is the placeholder's own (as `mlx`'s is);
|
||||
// the wired loop above passes the number of tokens it generated.
|
||||
|
||||
let start = self.meter.read_microjoules().ok();
|
||||
let span = MeterSpan::open(self.meter.as_ref());
|
||||
let generated_tokens = u64::from(self.generation_config.max_tokens.min(64));
|
||||
let payload = format!(
|
||||
"[onnx ep={} model={}]",
|
||||
self.execution_provider.as_str(),
|
||||
self.model_path.display()
|
||||
);
|
||||
let end = self.meter.read_microjoules().ok();
|
||||
let (microjoules, source) = match (start, end) {
|
||||
(Some(s), Some(e)) if e >= s && (e - s) > 0 => (e - s, JouleSource::Measured),
|
||||
_ => (0, JouleSource::Estimated),
|
||||
};
|
||||
Response::new(
|
||||
q.id,
|
||||
payload,
|
||||
Stage::Neural,
|
||||
JouleCost {
|
||||
microjoules,
|
||||
source,
|
||||
},
|
||||
)
|
||||
let cost = span::span_cost(span.reading(self.meter.as_ref()), || {
|
||||
span::per_token_estimate(generated_tokens, self.fallback_joules_per_token)
|
||||
});
|
||||
Response::new(q.id, payload, Stage::Neural, cost)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::span::scripted::Scripted;
|
||||
use eoc_core::JouleCost;
|
||||
use eoc_meter::StubCounter;
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_counter_reports_zero_cost() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let model = dir.path().join("model.onnx");
|
||||
fn backend(dir: &std::path::Path, meter: Arc<dyn JouleCounter>) -> OnnxBackend {
|
||||
let model = dir.join("model.onnx");
|
||||
std::fs::write(&model, b"").unwrap();
|
||||
let tok_path = dir.path().join("tokenizer.json");
|
||||
let tok_path = dir.join("tokenizer.json");
|
||||
std::fs::write(&tok_path, b"{}").unwrap();
|
||||
let tok = LocalTokenizer::from_path(&tok_path);
|
||||
let b = OnnxBackend::new(&model, tok, Arc::new(StubCounter)).unwrap();
|
||||
let q = Query::new("hi");
|
||||
let r = b.infer(&q).await;
|
||||
assert_eq!(r.joule_cost.microjoules, 0);
|
||||
OnnxBackend::new(&model, tok, meter)
|
||||
.unwrap()
|
||||
.with_generation_config(GenerationConfig { max_tokens: 8, ..GenerationConfig::default() })
|
||||
.with_fallback_joules_per_token(0.1)
|
||||
}
|
||||
|
||||
/// Inference that ran and that nothing measured costs the per-token
|
||||
/// estimate, tagged estimated: 8 tokens × 0.1 J = 800,000 µJ, never 0.
|
||||
#[tokio::test]
|
||||
async fn the_stub_costs_the_per_token_estimate_never_zero() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let r = backend(dir.path(), Arc::new(StubCounter)).infer(&Query::new("hi")).await;
|
||||
assert_eq!(r.joule_cost, JouleCost::estimated(800_000));
|
||||
assert_eq!(r.stage, Stage::Neural);
|
||||
}
|
||||
|
||||
/// Each way a span can fail to measure gives the estimate; only a counter
|
||||
/// that read twice and advanced gives a measured figure, its delta.
|
||||
#[tokio::test]
|
||||
async fn only_a_counter_that_advanced_is_measured() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for (script, want) in [
|
||||
(&[Some(1_000), Some(1_450)][..], JouleCost::measured(450)),
|
||||
(&[Some(7_000)][..], JouleCost::estimated(800_000)),
|
||||
(&[None, Some(9_000_000)][..], JouleCost::estimated(800_000)),
|
||||
(&[Some(1_000), None][..], JouleCost::estimated(800_000)),
|
||||
] {
|
||||
let b = backend(dir.path(), Arc::new(Scripted::new(script)));
|
||||
assert_eq!(b.infer(&Query::new("hi")).await.joule_cost, want, "{script:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_model_returns_not_found() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
188
eoc/eoc-rs/crates/eoc-local/src/span.rs
Normal file
188
eoc/eoc-rs/crates/eoc-local/src/span.rs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
//! What a counter read over one span of inference, and what a backend
|
||||
//! reports when it read nothing.
|
||||
//!
|
||||
//! Every backend in this crate meters the same way, so the rule lives here
|
||||
//! once and is tested without any native runtime:
|
||||
//!
|
||||
//! - A span is **measured** only when the counter read at its start, read
|
||||
//! again at its end, and advanced between the two. The reading is then the
|
||||
//! delta, tagged [`JouleSource::Measured`](eoc_core::JouleSource::Measured).
|
||||
//! - Anything else measured nothing: a failed read at either end, a counter
|
||||
//! that read but did not move (the [`StubCounter`](eoc_meter::StubCounter),
|
||||
//! a frozen rail, a placeholder). A failed first read is never taken as 0,
|
||||
//! which would turn the next successful read into the counter's whole
|
||||
//! since-boot total.
|
||||
//! - A span that measured nothing still ran inference, so its cost is never
|
||||
//! 0 µJ. The backend reports a per-token estimate instead, tagged
|
||||
//! [`JouleSource::Estimated`](eoc_core::JouleSource::Estimated).
|
||||
|
||||
use eoc_core::JouleCost;
|
||||
use eoc_meter::JouleCounter;
|
||||
|
||||
/// The joules per output token a backend estimates with when no live counter
|
||||
/// measured its inference, unless it is given another coefficient. The same
|
||||
/// figure `mlx` has always used; a reference value, to be replaced by one
|
||||
/// calibrated on the deployment host.
|
||||
pub const DEFAULT_FALLBACK_JOULES_PER_TOKEN: f64 = 0.05;
|
||||
|
||||
/// One span of inference, opened by reading the counter.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MeterSpan {
|
||||
/// The counter's reading when the span opened; `None` when that read
|
||||
/// failed, and then nothing over this span can be measured.
|
||||
start: Option<u64>,
|
||||
}
|
||||
|
||||
impl MeterSpan {
|
||||
/// Open a span: read `meter` once.
|
||||
pub fn open(meter: &dyn JouleCounter) -> Self {
|
||||
Self { start: meter.read_microjoules().ok() }
|
||||
}
|
||||
|
||||
/// What `meter` has read since the span opened: `Some(delta)` only when
|
||||
/// the opening read succeeded, this read succeeds, and the counter
|
||||
/// advanced between them. `None` otherwise — never 0, and never the
|
||||
/// counter's since-boot total.
|
||||
pub fn reading(&self, meter: &dyn JouleCounter) -> Option<u64> {
|
||||
let start = self.start?;
|
||||
let now = meter.read_microjoules().ok()?;
|
||||
(now > start).then(|| now - start)
|
||||
}
|
||||
}
|
||||
|
||||
/// The cost of a span of inference that ran: the counter's reading,
|
||||
/// measured, when there is one; otherwise `estimate()`, tagged estimated.
|
||||
/// The estimate is asked for only when nothing was read.
|
||||
pub fn span_cost(reading: Option<u64>, estimate: impl FnOnce() -> u64) -> JouleCost {
|
||||
match reading {
|
||||
Some(uj) => JouleCost::measured(uj),
|
||||
None => JouleCost::estimated(estimate()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-token estimate of inference that ran, in micro-joules: `tokens` ×
|
||||
/// `joules_per_token`, to the nearest micro-joule. Never 0, because the
|
||||
/// inference ran: at least one token is counted (a pass that emitted nothing
|
||||
/// still processed its prompt), a coefficient that is not a finite positive
|
||||
/// number is not one to estimate with, so [`DEFAULT_FALLBACK_JOULES_PER_TOKEN`]
|
||||
/// stands in for it, and a product under half a micro-joule is 1 µJ.
|
||||
pub fn per_token_estimate(tokens: u64, joules_per_token: f64) -> u64 {
|
||||
let per_token = if joules_per_token.is_finite() && joules_per_token > 0.0 {
|
||||
joules_per_token
|
||||
} else {
|
||||
DEFAULT_FALLBACK_JOULES_PER_TOKEN
|
||||
};
|
||||
let uj = (tokens.max(1) as f64 * per_token * 1_000_000.0).round();
|
||||
// `as` saturates at u64::MAX.
|
||||
(uj as u64).max(1)
|
||||
}
|
||||
|
||||
/// Test support: a counter that reads a script.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod scripted {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use eoc_meter::JouleCounter;
|
||||
|
||||
/// A counter that reads the scripted values in turn, then repeats the
|
||||
/// last one; `None` is a failed read. Counts its reads.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Scripted {
|
||||
script: Vec<Option<u64>>,
|
||||
reads: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl Scripted {
|
||||
pub(crate) fn new(script: &[Option<u64>]) -> Self {
|
||||
assert!(!script.is_empty(), "a script needs at least one read");
|
||||
Self { script: script.to_vec(), reads: Mutex::new(0) }
|
||||
}
|
||||
|
||||
/// How many times the counter has been read.
|
||||
pub(crate) fn reads(&self) -> usize {
|
||||
*self.reads.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl JouleCounter for Scripted {
|
||||
fn read_microjoules(&self) -> eoc_core::Result<u64> {
|
||||
let mut reads = self.reads.lock().unwrap();
|
||||
let value = self.script[(*reads).min(self.script.len() - 1)];
|
||||
*reads += 1;
|
||||
value.ok_or(eoc_core::Error::NoCounter)
|
||||
}
|
||||
fn name(&self) -> &'static str {
|
||||
"scripted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::scripted::Scripted;
|
||||
use super::*;
|
||||
use eoc_core::JouleSource;
|
||||
use eoc_meter::StubCounter;
|
||||
|
||||
fn read_span(script: &[Option<u64>]) -> Option<u64> {
|
||||
let c = Scripted::new(script);
|
||||
let span = MeterSpan::open(&c);
|
||||
let reading = span.reading(&c);
|
||||
assert_eq!(c.reads(), if script[0].is_some() { 2 } else { 1 });
|
||||
reading
|
||||
}
|
||||
|
||||
/// Both reads succeeded and the counter advanced: the delta.
|
||||
#[test]
|
||||
fn a_counter_that_advanced_reads_the_delta() {
|
||||
assert_eq!(read_span(&[Some(1_000), Some(1_450)]), Some(450));
|
||||
}
|
||||
|
||||
/// A failed first read is no baseline: a later reading of the counter's
|
||||
/// since-boot total is not the span's energy.
|
||||
#[test]
|
||||
fn a_failed_first_read_measures_nothing() {
|
||||
assert_eq!(read_span(&[None, Some(9_000_000)]), None);
|
||||
}
|
||||
|
||||
/// A failed last read leaves no reading, whatever the first read was.
|
||||
#[test]
|
||||
fn a_failed_last_read_measures_nothing() {
|
||||
assert_eq!(read_span(&[Some(1_000), None]), None);
|
||||
}
|
||||
|
||||
/// A counter that read but did not move measured nothing; one that went
|
||||
/// down (reset or wrapped) did not measure the span either.
|
||||
#[test]
|
||||
fn a_counter_that_did_not_advance_measures_nothing() {
|
||||
assert_eq!(read_span(&[Some(7_000), Some(7_000)]), None);
|
||||
assert_eq!(read_span(&[Some(7_000), Some(6_000)]), None);
|
||||
let stub = StubCounter;
|
||||
assert_eq!(MeterSpan::open(&stub).reading(&stub), None);
|
||||
}
|
||||
|
||||
/// A reading is measured and the estimate is not asked for; no reading is
|
||||
/// the estimate, tagged estimated.
|
||||
#[test]
|
||||
fn a_span_costs_its_reading_or_the_estimate() {
|
||||
assert_eq!(span_cost(Some(450), || panic!("estimated a measured span")), JouleCost::measured(450));
|
||||
let cost = span_cost(None, || 800_000);
|
||||
assert_eq!(cost, JouleCost::estimated(800_000));
|
||||
assert_eq!(cost.source, JouleSource::Estimated);
|
||||
}
|
||||
|
||||
/// Tokens × coefficient, never 0: a run that emitted no token counts one,
|
||||
/// a coefficient that is not finite and positive gives way to the
|
||||
/// default, and a tiny product is 1 µJ.
|
||||
#[test]
|
||||
fn the_per_token_estimate_is_never_zero() {
|
||||
assert_eq!(per_token_estimate(8, 0.1), 800_000);
|
||||
assert_eq!(per_token_estimate(10, 0.05), 500_000);
|
||||
assert_eq!(per_token_estimate(0, 0.1), 100_000);
|
||||
for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
assert_eq!(per_token_estimate(4, bad), 200_000, "coefficient {bad}");
|
||||
}
|
||||
assert_eq!(per_token_estimate(1, 1e-12), 1);
|
||||
assert_eq!(per_token_estimate(u64::MAX, 1e9), u64::MAX);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
//! Joule-attribution integration test.
|
||||
//!
|
||||
//! Verifies the contract: when the meter is a [`StubCounter`] the
|
||||
//! reported joule cost is 0; when the meter is a non-stub counter
|
||||
//! whose readings monotonically advance, the reported joule cost is
|
||||
//! non-zero.
|
||||
//! Verifies the contract: when the meter is a [`StubCounter`] nothing
|
||||
//! was measured, so the cost is the backend's per-token estimate, never
|
||||
//! 0 µJ; when the meter is a non-stub counter whose readings monotonically
|
||||
//! advance, the reported joule cost is non-zero and measured.
|
||||
//!
|
||||
//! These tests exercise the *plumbing* — the backends themselves use
|
||||
//! their respective placeholder implementations in the reference
|
||||
|
|
@ -48,14 +48,20 @@ impl JouleCounter for TickingCounter {
|
|||
|
||||
#[cfg(feature = "mlc")]
|
||||
#[tokio::test]
|
||||
async fn mlc_stub_counter_yields_zero_cost() {
|
||||
async fn mlc_stub_counter_yields_an_estimate_never_zero() {
|
||||
use eoc_local::MlcBackend;
|
||||
use eoc_neural::NeuralBackend;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let b = MlcBackend::new(dir.path(), Arc::new(StubCounter)).unwrap();
|
||||
let b = MlcBackend::new(dir.path(), Arc::new(StubCounter))
|
||||
.unwrap()
|
||||
.with_max_tokens(10)
|
||||
.with_fallback_joules_per_token(0.05);
|
||||
let q = Query::new("hello");
|
||||
let r = b.infer(&q).await;
|
||||
assert_eq!(r.joule_cost.microjoules, 0);
|
||||
// Nothing measured the inference, which ran: 10 * 0.05 J = 500,000 µJ,
|
||||
// an estimate.
|
||||
assert_eq!(r.joule_cost.microjoules, 500_000);
|
||||
assert_eq!(r.joule_cost.source, JouleSource::Estimated);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mlc")]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,93 @@
|
|||
//! The liveness probe behind `eoc_meter::ioreport_status()` runs once per
|
||||
//! PROCESS, however many threads ask for the status, construct the IOReport
|
||||
//! counter or select a counter, and in whatever order.
|
||||
//!
|
||||
//! This file is a test binary of its own, so it runs in a fresh process, and
|
||||
//! its single test is the first use of the cache: nothing in the process has
|
||||
//! probed before it starts (asserted). Two process-wide counts are pinned: the
|
||||
//! runs of the probe behind the cache, and the opens of the probe's Energy
|
||||
//! Model sampler, which every real probe makes whoever calls it — so a
|
||||
//! constructor or selection path that probed outside the cache would show as a
|
||||
//! second open even if it bypassed the cached function entirely.
|
||||
|
||||
use std::sync::Barrier;
|
||||
|
||||
use eoc_meter::IoReportStatus;
|
||||
|
||||
/// Threads released together at the cold cache.
|
||||
const THREADS: usize = 8;
|
||||
|
||||
/// What one thread got: the status, whether the IOReport counter was
|
||||
/// constructed, and the counter `detect()` selected.
|
||||
type Seen = (IoReportStatus, bool, &'static str);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn constructed() -> bool {
|
||||
eoc_meter::MacosIoReportCounter::try_detect().is_ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn constructed() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Status, construction and selection, each thread starting on a different
|
||||
/// one of the three paths into the cache.
|
||||
fn ask(thread: usize) -> Seen {
|
||||
match thread % 3 {
|
||||
0 => {
|
||||
let status = eoc_meter::ioreport_status();
|
||||
(status, constructed(), eoc_meter::detect().name())
|
||||
}
|
||||
1 => {
|
||||
let built = constructed();
|
||||
let picked = eoc_meter::detect().name();
|
||||
(eoc_meter::ioreport_status(), built, picked)
|
||||
}
|
||||
_ => {
|
||||
let picked = eoc_meter::detect().name();
|
||||
let built = constructed();
|
||||
(eoc_meter::ioreport_status(), built, picked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_liveness_probe_runs_once_per_process() {
|
||||
assert_eq!(
|
||||
(eoc_meter::liveness_probe_runs(), eoc_meter::liveness_sampler_opens()),
|
||||
(0, 0),
|
||||
"something in this process probed before the test's first use of the cache"
|
||||
);
|
||||
let gate = Barrier::new(THREADS);
|
||||
let seen: Vec<Seen> = std::thread::scope(|s| {
|
||||
let handles: Vec<_> = (0..THREADS)
|
||||
.map(|thread| {
|
||||
let gate = &gate;
|
||||
s.spawn(move || {
|
||||
gate.wait();
|
||||
ask(thread)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles.into_iter().map(|h| h.join().expect("a thread asking for the status panicked")).collect()
|
||||
});
|
||||
let (runs, opens) = (eoc_meter::liveness_probe_runs(), eoc_meter::liveness_sampler_opens());
|
||||
eprintln!(
|
||||
"ioreport cache: {THREADS} threads at a cold cache asked, constructed and selected and got {seen:?}; \
|
||||
probe runs in this process: {runs}, sampler opens: {opens}"
|
||||
);
|
||||
let first = seen[0].0;
|
||||
for (status, built, picked) in &seen {
|
||||
assert_eq!(*status, first, "the threads got different statuses: {seen:?}");
|
||||
assert_eq!(*built, *status == IoReportStatus::Live, "construction disagrees with the status: {seen:?}");
|
||||
assert_eq!(*picked == "ioreport", *built, "selection disagrees with construction: {seen:?}");
|
||||
}
|
||||
assert_eq!(eoc_meter::ioreport_status(), first, "the cached status changed");
|
||||
let once = usize::from(cfg!(target_os = "macos"));
|
||||
assert_eq!(runs, once, "the liveness probe behind ioreport_status() ran {runs} times in this process");
|
||||
assert_eq!(opens, once, "the liveness probe's sampler was opened {opens} times in this process: a probe ran outside the cache");
|
||||
if !cfg!(target_os = "macos") {
|
||||
assert_eq!(first, IoReportStatus::Unavailable);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,39 +3,41 @@
|
|||
//! A [`StreamMeter`] samples a [`JouleCounter`](eoc_meter::JouleCounter)
|
||||
//! at the start of a stream and again at the end (or on each
|
||||
//! checkpoint). The difference is attributed to the stream and surfaced
|
||||
//! as a [`JouleCost`](eoc_core::JouleCost). Token counts can be
|
||||
//! accumulated alongside so the cascade sees both axes.
|
||||
//! as a [`JouleCost`] when, and only when, the counter advanced over the
|
||||
//! span. A stream the counter did not meter has no figure at all, never
|
||||
//! 0 µJ: its work ran, and the caller keeps its own estimate. Token counts
|
||||
//! are accumulated alongside, metered or not, so the cascade sees both axes.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use eoc_core::{JouleCost, JouleSource};
|
||||
use eoc_core::JouleCost;
|
||||
use eoc_meter::JouleCounter;
|
||||
|
||||
use crate::error::{StreamError, StreamResult};
|
||||
|
||||
/// Per-stream accounting record.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct StreamAccount {
|
||||
/// Tokens streamed so far.
|
||||
pub tokens: u64,
|
||||
/// Cumulative micro-joule delta attributed to this stream.
|
||||
pub microjoules: u64,
|
||||
/// Whether the joule reading is from a hardware counter.
|
||||
pub measured: bool,
|
||||
/// A hardware counter's reading of this stream, in micro-joules: the
|
||||
/// counter read at the start and at the latest checkpoint, and advanced
|
||||
/// between them. `None` when the stream was not metered — the stub, a
|
||||
/// failed read, a counter that did not move (a frozen rail, a
|
||||
/// placeholder), or no checkpoint yet. Not metered is not 0 µJ: the work
|
||||
/// ran, and no counter read what it cost.
|
||||
pub microjoules: Option<u64>,
|
||||
}
|
||||
|
||||
impl StreamAccount {
|
||||
/// Convert to the canonical [`JouleCost`] type.
|
||||
pub fn joule_cost(&self) -> JouleCost {
|
||||
JouleCost {
|
||||
microjoules: self.microjoules,
|
||||
source: if self.measured {
|
||||
JouleSource::Measured
|
||||
} else {
|
||||
JouleSource::Estimated
|
||||
},
|
||||
}
|
||||
/// The stream's cost as the canonical [`JouleCost`]: `Some`, and
|
||||
/// [`JouleSource::Measured`](eoc_core::JouleSource::Measured), when a
|
||||
/// counter metered it; `None` when nothing did. There is deliberately no
|
||||
/// estimated zero here: a caller with no reading supplies its own
|
||||
/// estimate (from tokens, or time) and tags it as one.
|
||||
pub fn joule_cost(&self) -> Option<JouleCost> {
|
||||
self.microjoules.map(JouleCost::measured)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -48,8 +50,9 @@ impl StreamAccount {
|
|||
#[derive(Clone)]
|
||||
pub struct StreamMeter {
|
||||
counter: Arc<dyn JouleCounter>,
|
||||
start_uj: u64,
|
||||
measured: bool,
|
||||
/// The counter's reading when the stream started; `None` when that read
|
||||
/// failed, and then no span can be measured.
|
||||
start_uj: Option<u64>,
|
||||
state: Arc<Mutex<StreamAccount>>,
|
||||
}
|
||||
|
||||
|
|
@ -58,27 +61,22 @@ impl std::fmt::Debug for StreamMeter {
|
|||
f.debug_struct("StreamMeter")
|
||||
.field("counter", &self.counter.name())
|
||||
.field("start_uj", &self.start_uj)
|
||||
.field("measured", &self.measured)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamMeter {
|
||||
/// Start metering a stream. Reads the counter once; if the read
|
||||
/// fails the meter degrades to `Estimated` and continues.
|
||||
/// Start metering a stream. Reads the counter once; if the read fails
|
||||
/// the stream is never metered, and it continues counting tokens.
|
||||
/// Nothing is metered until a checkpoint sees the counter advance.
|
||||
pub fn start(counter: Arc<dyn JouleCounter>) -> Self {
|
||||
let (start_uj, measured) = match counter.read_microjoules() {
|
||||
Ok(uj) => (uj, counter.name() != "stub"),
|
||||
Err(_) => (0, false),
|
||||
};
|
||||
let start_uj = counter.read_microjoules().ok();
|
||||
Self {
|
||||
counter,
|
||||
start_uj,
|
||||
measured,
|
||||
state: Arc::new(Mutex::new(StreamAccount {
|
||||
tokens: 0,
|
||||
microjoules: 0,
|
||||
measured,
|
||||
microjoules: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
@ -92,17 +90,20 @@ impl StreamMeter {
|
|||
|
||||
/// Refresh the joule delta from the counter. Cheap and safe to
|
||||
/// call at every checkpoint; the cost is one counter read.
|
||||
///
|
||||
/// The delta is a reading only if the counter advanced since the start.
|
||||
/// A counter that reads but does not move — a frozen rail, a placeholder
|
||||
/// — has not measured the work: the stream is left not metered, never
|
||||
/// given its zero.
|
||||
pub fn checkpoint(&self) -> StreamResult<StreamAccount> {
|
||||
let now_uj = match self.counter.read_microjoules() {
|
||||
Ok(uj) => uj,
|
||||
Err(_) => {
|
||||
// No measurement available — leave the existing delta in place.
|
||||
let g = self.state.lock().map_err(poisoned)?;
|
||||
return Ok(*g);
|
||||
}
|
||||
let (Some(start_uj), Ok(now_uj)) = (self.start_uj, self.counter.read_microjoules()) else {
|
||||
// No baseline, or no reading now: nothing to measure. Leave the
|
||||
// account as it was — never a counter's since-boot total.
|
||||
let g = self.state.lock().map_err(poisoned)?;
|
||||
return Ok(*g);
|
||||
};
|
||||
let mut g = self.state.lock().map_err(poisoned)?;
|
||||
g.microjoules = now_uj.saturating_sub(self.start_uj);
|
||||
g.microjoules = (now_uj > start_uj).then(|| now_uj - start_uj);
|
||||
Ok(*g)
|
||||
}
|
||||
|
||||
|
|
@ -126,16 +127,87 @@ fn poisoned<T>(_: T) -> StreamError {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use eoc_core::JouleSource;
|
||||
use eoc_meter::StubCounter;
|
||||
|
||||
/// A counter that reads the scripted values in turn (then the last one
|
||||
/// again); `None` is a failed read.
|
||||
struct Scripted {
|
||||
reads: Mutex<Vec<Option<u64>>>,
|
||||
}
|
||||
|
||||
impl Scripted {
|
||||
fn new(reads: &[Option<u64>]) -> Arc<Self> {
|
||||
let mut reads = reads.to_vec();
|
||||
reads.reverse();
|
||||
Arc::new(Self { reads: Mutex::new(reads) })
|
||||
}
|
||||
}
|
||||
|
||||
impl JouleCounter for Scripted {
|
||||
fn read_microjoules(&self) -> eoc_core::Result<u64> {
|
||||
let mut r = self.reads.lock().unwrap();
|
||||
let next = if r.len() > 1 { r.pop().unwrap() } else { r[0] };
|
||||
next.ok_or(eoc_core::Error::NoCounter)
|
||||
}
|
||||
fn name(&self) -> &'static str {
|
||||
"scripted"
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-stub counter that reads but does not move has not metered the
|
||||
/// stream: it has no figure, never a zero sealed as one.
|
||||
#[test]
|
||||
fn stub_counter_estimated() {
|
||||
fn a_counter_that_did_not_advance_is_not_a_measurement() {
|
||||
let meter = StreamMeter::start(Scripted::new(&[Some(7_000)]));
|
||||
assert_eq!(meter.snapshot().unwrap().microjoules, None, "metered before any checkpoint");
|
||||
meter.on_token(3).unwrap();
|
||||
let acct = meter.finish().unwrap();
|
||||
assert_eq!(acct, StreamAccount { tokens: 3, microjoules: None });
|
||||
assert_eq!(acct.joule_cost(), None);
|
||||
}
|
||||
|
||||
/// A counter that advanced over the span is a measurement of it.
|
||||
#[test]
|
||||
fn a_counter_that_advanced_is_a_measurement() {
|
||||
let meter = StreamMeter::start(Scripted::new(&[Some(7_000), Some(7_000), Some(7_450)]));
|
||||
let mid = meter.checkpoint().unwrap();
|
||||
assert_eq!(mid.microjoules, None, "no advance yet");
|
||||
let acct = meter.finish().unwrap();
|
||||
assert_eq!(acct.microjoules, Some(450));
|
||||
assert_eq!(acct.joule_cost(), Some(JouleCost::measured(450)));
|
||||
assert_eq!(acct.joule_cost().unwrap().source, JouleSource::Measured);
|
||||
}
|
||||
|
||||
/// Without a starting reading there is no span: a later reading is the
|
||||
/// counter's since-boot total, not this stream's energy.
|
||||
#[test]
|
||||
fn a_failed_start_measures_nothing() {
|
||||
let meter = StreamMeter::start(Scripted::new(&[None, Some(9_000_000)]));
|
||||
let acct = meter.finish().unwrap();
|
||||
assert_eq!(acct.microjoules, None);
|
||||
assert_eq!(acct.joule_cost(), None);
|
||||
}
|
||||
|
||||
/// A counter that advanced once keeps that reading when a later read
|
||||
/// fails, and never turns it into the counter's since-boot total.
|
||||
#[test]
|
||||
fn a_failed_later_read_keeps_the_last_reading() {
|
||||
let meter = StreamMeter::start(Scripted::new(&[Some(1_000), Some(1_200), None]));
|
||||
assert_eq!(meter.checkpoint().unwrap().microjoules, Some(200));
|
||||
assert_eq!(meter.finish().unwrap().microjoules, Some(200));
|
||||
}
|
||||
|
||||
/// A stream the stub "metered" streamed its tokens and has no energy
|
||||
/// figure: not 0 µJ, which would present work that ran as free.
|
||||
#[test]
|
||||
fn an_unmetered_stream_has_no_figure() {
|
||||
let meter = StreamMeter::start(Arc::new(StubCounter));
|
||||
meter.on_token(5).unwrap();
|
||||
let acct = meter.finish().unwrap();
|
||||
assert_eq!(acct.tokens, 5);
|
||||
assert_eq!(acct.microjoules, 0);
|
||||
assert!(!acct.measured);
|
||||
assert_eq!(acct.joule_cost().source, JouleSource::Estimated);
|
||||
assert_eq!(acct.microjoules, None);
|
||||
assert_eq!(acct.joule_cost(), None);
|
||||
assert_eq!(StreamAccount::default().joule_cost(), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,3 +23,6 @@ eoc-cascade.workspace = true
|
|||
clap.workspace = true
|
||||
anyhow.workspace = true
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait.workspace = true
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
//! - `eoc query "<prompt>"` — run a prompt through the cascade and print
|
||||
//! the stage that resolved it plus the attributed joule cost.
|
||||
//! - `eoc meter` — detect available hardware energy counters and report
|
||||
//! the micro-joules consumed during one second of idle.
|
||||
//! the micro-joules consumed during one second of idle, or why nothing
|
||||
//! was metered.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ use eoc_core::Query;
|
|||
use eoc_graph::{GraphStage, Triple};
|
||||
use eoc_kv::{KvBackend, KvStage, MemoryKvBackend};
|
||||
use eoc_meter::detect;
|
||||
use eoc_neural::{EchoBackend, NeuralStage};
|
||||
use eoc_neural::{EchoBackend, NeuralBackend, NeuralStage};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
|
|
@ -40,7 +41,8 @@ enum Cmd {
|
|||
/// The prompt to resolve.
|
||||
prompt: String,
|
||||
},
|
||||
/// Detect the joule counter and report idle micro-joules per second.
|
||||
/// Detect the joule counter and report idle micro-joules per second, or
|
||||
/// why nothing was metered.
|
||||
Meter,
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +56,11 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
|
||||
fn build_cascade() -> Cascade {
|
||||
build_cascade_with(Box::new(EchoBackend::new().with_prefix("[neural fallback] ")))
|
||||
}
|
||||
|
||||
/// The CLI's cascade around a given neural backend, metered by [`detect`].
|
||||
fn build_cascade_with(neural: Box<dyn NeuralBackend>) -> Cascade {
|
||||
let cache = Arc::new(LruCache::new(1024));
|
||||
|
||||
let kv_backend = Box::new(MemoryKvBackend::new());
|
||||
|
|
@ -69,9 +76,7 @@ fn build_cascade() -> Cascade {
|
|||
Triple::new("Mars", "fourth planet from", "the Sun"),
|
||||
]);
|
||||
|
||||
let neural = Arc::new(NeuralStage::new(Box::new(
|
||||
EchoBackend::new().with_prefix("[neural fallback] "),
|
||||
)));
|
||||
let neural = Arc::new(NeuralStage::new(neural));
|
||||
|
||||
Cascade::new(cache, kv, graph, neural).with_meter(Arc::from(detect()))
|
||||
}
|
||||
|
|
@ -90,10 +95,114 @@ async fn run_query(prompt: String) -> Result<()> {
|
|||
async fn run_meter() -> Result<()> {
|
||||
let counter = detect();
|
||||
println!("counter: {}", counter.name());
|
||||
let start = counter.read_microjoules().unwrap_or(0);
|
||||
#[cfg(target_os = "macos")]
|
||||
println!("ioreport: {}", ioreport_line(eoc_meter::ioreport_status()));
|
||||
let start = counter.read_microjoules();
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
let end = counter.read_microjoules().unwrap_or(0);
|
||||
let delta = end.saturating_sub(start);
|
||||
println!("idle : {} µJ over 1.0 s ({:.3} W)", delta, (delta as f64) / 1_000_000.0);
|
||||
let end = counter.read_microjoules();
|
||||
println!("idle : {}", idle_line(counter.name(), start, end));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Why the IOReport counter is, or is not, the one in use on this Mac.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ioreport_line(status: eoc_meter::IoReportStatus) -> &'static str {
|
||||
use eoc_meter::IoReportStatus;
|
||||
match status {
|
||||
IoReportStatus::Live => "live (its CPU energy rail keeps advancing under load)",
|
||||
IoReportStatus::CpuRailFrozen => {
|
||||
"declined: its CPU energy rail did not keep advancing under load, so \
|
||||
its readings would be GPU-only energy, zero, or a stale refresh"
|
||||
}
|
||||
IoReportStatus::CpuRailAbsent => "declined: no CPU energy rail in its Energy Model group",
|
||||
IoReportStatus::Unavailable => {
|
||||
"unavailable: its Energy Model group could not be opened, or yielded no usable samples"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The idle figure, printed only when a hardware counter actually read.
|
||||
/// A failed read or the stub is reported as not metered — never as 0 W.
|
||||
fn idle_line(counter: &str, start: eoc_core::Result<u64>, end: eoc_core::Result<u64>) -> String {
|
||||
if counter == "stub" {
|
||||
return "not metered (stub counter: no hardware energy counter)".to_string();
|
||||
}
|
||||
match (start, end) {
|
||||
(Ok(s), Ok(e)) => {
|
||||
let delta = e.saturating_sub(s);
|
||||
format!("{delta} µJ over 1.0 s ({:.3} W)", (delta as f64) / 1_000_000.0)
|
||||
}
|
||||
(Err(err), _) | (_, Err(err)) => format!("not metered (counter read failed: {err})"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_cascade_with, idle_line};
|
||||
use async_trait::async_trait;
|
||||
use eoc_core::{Error, JouleCost, JouleSource, Query, Response, Stage};
|
||||
use eoc_neural::NeuralBackend;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A neural backend that does ~100 ms of real CPU work and reports its own
|
||||
/// estimate for it, as a real backend without a meter would.
|
||||
struct BurningBackend;
|
||||
|
||||
const BURN_ESTIMATE_UJ: u64 = 50_000_000;
|
||||
|
||||
#[async_trait]
|
||||
impl NeuralBackend for BurningBackend {
|
||||
async fn infer(&self, q: &Query) -> Response {
|
||||
let started = Instant::now();
|
||||
let mut acc: u64 = 0;
|
||||
while started.elapsed() < Duration::from_millis(100) {
|
||||
acc = acc.wrapping_add(1);
|
||||
}
|
||||
std::hint::black_box(acc);
|
||||
Response::new(q.id, "burned".to_string(), Stage::Neural, JouleCost::estimated(BURN_ESTIMATE_UJ))
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole CLI path on this host, around ~100 ms of real CPU work. Where
|
||||
/// the meter [`super::detect`] picks is no hardware reading — the stub, or
|
||||
/// the powermetrics stand-in, which is what a Mac whose IOReport CPU rail is
|
||||
/// not live falls back to — the cost is the backend's own estimate: the
|
||||
/// same non-zero figure, tagged estimated. Where it is a live hardware
|
||||
/// counter, the cost is that estimate plus what the counter read, still
|
||||
/// tagged estimated, because part of it is. Never a zero for the work, and
|
||||
/// never tagged measured.
|
||||
#[tokio::test]
|
||||
async fn work_on_an_unmetered_host_keeps_its_estimate() {
|
||||
let cascade = build_cascade_with(Box::new(BurningBackend));
|
||||
let meter = cascade.meter().name();
|
||||
#[cfg(target_os = "macos")]
|
||||
assert_eq!(
|
||||
meter == "ioreport",
|
||||
eoc_meter::ioreport_status() == eoc_meter::IoReportStatus::Live,
|
||||
"the CLI's meter is {meter} while IOReport is {:?}",
|
||||
eoc_meter::ioreport_status()
|
||||
);
|
||||
let r = cascade.resolve(Query::new("a question nothing has cached")).await;
|
||||
eprintln!("eoc cost on this host: meter={meter} cost={}", r.joule_cost);
|
||||
assert_eq!(r.stage, Stage::Neural);
|
||||
if matches!(meter, "stub" | "powermetrics") {
|
||||
assert_eq!(r.joule_cost, JouleCost::estimated(BURN_ESTIMATE_UJ), "meter={meter}");
|
||||
} else {
|
||||
// A hardware counter: the estimate plus what it read, never less,
|
||||
// and an estimate, since the backend's part of it is one.
|
||||
assert!(r.joule_cost.microjoules >= BURN_ESTIMATE_UJ, "{}", r.joule_cost);
|
||||
assert_eq!(r.joule_cost.source, JouleSource::Estimated, "{}", r.joule_cost);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_counter_that_did_not_read_is_never_printed_as_a_figure() {
|
||||
let failed = idle_line("powermetrics", Err(Error::NoCounter), Err(Error::NoCounter));
|
||||
assert_eq!(failed, "not metered (counter read failed: no joule counter available)");
|
||||
let half = idle_line("rapl", Ok(1_000), Err(Error::Io("gone".into())));
|
||||
assert!(half.starts_with("not metered"), "{half}");
|
||||
let stub = idle_line("stub", Ok(0), Ok(0));
|
||||
assert!(stub.starts_with("not metered"), "{stub}");
|
||||
assert_eq!(idle_line("rapl", Ok(1_000), Ok(501_000)), "500000 µJ over 1.0 s (0.500 W)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue