All checks were successful
energy meters on real hardware / RAPL and NVML on jetson-hub (pull_request) Successful in 57s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m42s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 55s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 1m32s
standards workspaces build + test / aac-rs (pull_request) Successful in 2m38s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 57s
standards workspaces build + test / map-rs (pull_request) Successful in 56s
standards workspaces build + test / mesh-rs (pull_request) Successful in 55s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 4m33s
standards workspaces build + test / openpay (pull_request) Successful in 3m27s
lockfiles / every Cargo.lock is current (pull_request) Successful in 37s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 4s
no-vanity-metrics / WAI public text names no comparison (pull_request) Successful in 7s
no-vanity-metrics / WAI public text names no company or product (pull_request) Successful in 6s
standards workspaces build + test / arl-rs (pull_request) Successful in 1m14s
standards workspaces build + test / eoc-rs (pull_request) Successful in 2m1s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m35s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 1m12s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 1m13s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m18s
standards workspaces build + test / sense-rs (pull_request) Successful in 1m14s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m24s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m59s
The README said only that joule cost is the unit of accounting. It now has the rule and where the cascade reads its meter since 0d78becb: around the neural stage, never around a hit, which is charged a per-hit figure calibrated on the host. The example output shows the `hit` line, with and without a live counter. eoc-cascade's own docs carry the final figures from the build host (M3 Ultra, macOS 27.0.1, release, load average 5 to 14, three runs of `examples/hit_cost`): - 10,000 cache hits: 4.18 to 4.20 ms, 0.375 µs a resolve at the median (parent: 1,641 to 1,651 ms, 162 to 164 µs). A cascade given no meter at all: 3.55 to 3.60 ms, 0.333 µs. KV 0.667 µs, graph 0.75 to 0.79 µs. - calibrated: 1,413 to 1,716 nJ a cache hit, 2,672 to 2,961 nJ a KV hit, 3,007 to 3,163 nJ a graph hit, each from 416 to 3,328 lookups in a span 51 to 351 times the 12 to 19 µJ an empty span read; 0.85 to 2.3 ms for a first hit, calibration included, 0.96 to 1.35 ms for one asked again. - a neural stage that does nothing: 162.4 to 162.6 µs a resolve and 14.3 to 14.8 µJ measured, the cascade's two reads; 0.79 to 0.83 µs with no meter. The three misses before the opening read: 0.95 µJ and 220 ns together. `HitCalibration`'s docs say the figure is that of the core the batch ran on and of the build, and that the calibration's own energy is charged to no resolve. The module docs no longer link the private `cascade_cost`. eoc-meter's crate docs and `detect_in_process` say how eoc-cascade uses a counter now (it was the example of a caller that should bracket a batch, not each operation). eoc-core's `JouleCost::zero` and eoc-cache said a lookup's energy is below any counter's resolution; it is about a microjoule, readable as the mean of a batch, and less than a read costs, which is why no counter is read for a hit. `examples/hit_cost` also runs the same stages with no meter, the baseline a metered hit is now within 0.05 µs of. Co-Authored-By: Claude Opus 5.5 <[email protected]>
134 lines
4.2 KiB
Rust
134 lines
4.2 KiB
Rust
//! EOC stage 1 — cache.
|
|
//!
|
|
//! The cheapest stage: an LRU keyed by `QueryId` returning a previously
|
|
//! computed `Response`. Cache hits are reported with `JouleCost::zero()`:
|
|
//! zero micro-joules, tagged `Estimated`. One lookup spends about a
|
|
//! micro-joule, less than a read of an energy counter around it would, so no
|
|
//! counter is read for a hit and a hit never claims a measurement. What a hit
|
|
//! costs on a host is readable as the mean of a batch in one span, and
|
|
//! eoc-cascade charges hits that, as an estimate.
|
|
|
|
#![forbid(unsafe_code)]
|
|
|
|
use std::num::NonZeroUsize;
|
|
use std::sync::Mutex;
|
|
|
|
use async_trait::async_trait;
|
|
use eoc_core::{JouleCost, Query, QueryId, Response, Stage as StageKind};
|
|
use lru::LruCache as InnerLru;
|
|
|
|
/// The stage trait — implemented by every level of the cascade.
|
|
///
|
|
/// Returning `Some(Response)` means "this stage answered". `None` means
|
|
/// "fall through to the next stage".
|
|
#[async_trait]
|
|
pub trait Stage: Send + Sync {
|
|
/// Try to resolve `q` at this stage.
|
|
async fn try_resolve(&self, q: &Query) -> Option<Response>;
|
|
}
|
|
|
|
/// In-process LRU cache stage.
|
|
pub struct LruCache {
|
|
inner: Mutex<InnerLru<QueryId, Response>>,
|
|
capacity: usize,
|
|
}
|
|
|
|
impl LruCache {
|
|
/// Construct an LRU with a given capacity (entries).
|
|
pub fn new(capacity: usize) -> Self {
|
|
let cap = NonZeroUsize::new(capacity.max(1)).expect("max(1) is non-zero");
|
|
Self {
|
|
inner: Mutex::new(InnerLru::new(cap)),
|
|
capacity,
|
|
}
|
|
}
|
|
|
|
/// Maximum number of entries.
|
|
pub fn capacity(&self) -> usize {
|
|
self.capacity
|
|
}
|
|
|
|
/// Number of entries currently held.
|
|
pub fn len(&self) -> usize {
|
|
self.inner.lock().expect("cache lock poisoned").len()
|
|
}
|
|
|
|
/// Is the cache empty?
|
|
pub fn is_empty(&self) -> bool {
|
|
self.len() == 0
|
|
}
|
|
|
|
/// Insert (or refresh) a `Response`. Re-stamps the response as `Cache`
|
|
/// 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
|
|
.lock()
|
|
.expect("cache lock poisoned")
|
|
.put(q.id, r.clone());
|
|
r
|
|
}
|
|
|
|
/// Insert a fully formed response under the given query id (used by the
|
|
/// cascade to memoize results produced by deeper stages).
|
|
pub fn insert_response(&self, query_id: QueryId, response: Response) {
|
|
self.inner
|
|
.lock()
|
|
.expect("cache lock poisoned")
|
|
.put(query_id, response);
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Stage for LruCache {
|
|
async fn try_resolve(&self, q: &Query) -> Option<Response> {
|
|
self.inner
|
|
.lock()
|
|
.expect("cache lock poisoned")
|
|
.get(&q.id)
|
|
.cloned()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use eoc_core::JouleSource;
|
|
|
|
#[tokio::test]
|
|
async fn lru_hits_are_nearly_free() {
|
|
let cache = LruCache::new(8);
|
|
let q = Query::new("hello");
|
|
assert!(cache.try_resolve(&q).await.is_none());
|
|
cache.insert(&q, "hi");
|
|
let r = cache.try_resolve(&q).await.expect("hit");
|
|
assert_eq!(r.stage, StageKind::Cache);
|
|
assert_eq!(r.joule_cost.microjoules, 0);
|
|
// No counter was read for the hit: its zero is an estimate.
|
|
assert_eq!(r.joule_cost.source, JouleSource::Estimated);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lru_evicts_at_capacity() {
|
|
let cache = LruCache::new(100);
|
|
// Fill 100 entries.
|
|
for i in 0..100 {
|
|
let q = Query::new(format!("q{i}"));
|
|
cache.insert(&q, format!("a{i}"));
|
|
}
|
|
assert_eq!(cache.len(), 100);
|
|
// Insert 50 more — first 50 should be evicted (LRU order).
|
|
for i in 100..150 {
|
|
let q = Query::new(format!("q{i}"));
|
|
cache.insert(&q, format!("a{i}"));
|
|
}
|
|
assert_eq!(cache.len(), 100);
|
|
// q0 should be gone.
|
|
let q0 = Query::new("q0");
|
|
assert!(cache.try_resolve(&q0).await.is_none());
|
|
// q149 should be present.
|
|
let q149 = Query::new("q149");
|
|
assert!(cache.try_resolve(&q149).await.is_some());
|
|
}
|
|
}
|