metrale_telemetry/run_metrics.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The run mailbox: process-global counters read by the
4//! `/metrics` handler and the TUI, which hold no handle to the scheduler's
5//! context, and [`reset_for_new_run`], which starts a new run's accounting.
6//!
7//! `MetraleCudaBackend::new` calls [`reset_for_new_run`] before its first
8//! kernel lookup, so the kernel audit lists only that backend's lookups.
9//!
10//! Owner: telemetry.
11//! Invariants: the prefix-cache counters never decrease; a run's own counts
12//! are the difference from the snapshot `reset_for_new_run` takes.
13
14use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
15use std::sync::{LazyLock, Mutex};
16
17/// 2026-09-26: The process's run mailbox.
18#[derive(Debug, Default)]
19pub struct RunMetrics {
20 pub cache_hits: AtomicU64,
21 pub cache_misses: AtomicU64,
22 pub cache_hit_tokens: AtomicU64,
23
24 /// 2026-09-26: Most recent per-token entropy, as f32 bits.
25 pub last_entropy: AtomicU32,
26 pub low_entropy_tokens: AtomicU64,
27 pub total_sampled_tokens: AtomicU64,
28
29 /// 2026-09-26: The free-memory baseline `set_baseline_free_bytes` records
30 /// after the backend is built. KV sizing uses it only when neither
31 /// `METRALE_KV_EXTERNAL_RESERVE_GB` nor the allocation ledger applies: it
32 /// measures this process's use as `baseline - free_now` and charges the
33 /// smaller of that and the known footprint (`factory/build/kv_budget.rs`).
34 /// `0` means unset.
35 pub baseline_free_bytes: AtomicUsize,
36 /// 2026-09-26: `(module, func, loaded, dispatch site)` for every lookup
37 /// [`crate::kernel_audit::record`] received this run. The site is the
38 /// caller's `Location`, carried through `#[track_caller]` on
39 /// `GpuBackend::kernel`.
40 pub kernel_audit: Mutex<Vec<(String, String, bool, &'static std::panic::Location<'static>)>>,
41
42 // 2026-09-26: The prefix-cache counters at the start of this run.
43 // `/metrics` exports the counters as `metrale_prefix_cache_*_total`, type
44 // counter, so they are never reset; `cache_counts_this_run` subtracts
45 // these instead.
46 run_base_cache_hits: AtomicU64,
47 run_base_cache_misses: AtomicU64,
48 run_base_cache_hit_tokens: AtomicU64,
49}
50
51static METRICS: LazyLock<RunMetrics> = LazyLock::new(RunMetrics::default);
52
53/// 2026-09-26: Read the mailbox.
54pub fn metrics() -> &'static RunMetrics {
55 &METRICS
56}
57
58/// 2026-09-26: Begin a new run's accounting: snapshot the prefix-cache
59/// counters as this run's baseline, zero the entropy counters, the last
60/// entropy and the free-memory baseline, clear the kernel audit (unless its
61/// lock is poisoned) and unseal it.
62pub fn reset_for_new_run() {
63 let m = metrics();
64 for (counter, base) in [
65 (&m.cache_hits, &m.run_base_cache_hits),
66 (&m.cache_misses, &m.run_base_cache_misses),
67 (&m.cache_hit_tokens, &m.run_base_cache_hit_tokens),
68 ] {
69 base.store(counter.load(Ordering::Relaxed), Ordering::Relaxed);
70 }
71 for c in [&m.low_entropy_tokens, &m.total_sampled_tokens] {
72 c.store(0, Ordering::Relaxed);
73 }
74 m.baseline_free_bytes.store(0, Ordering::Relaxed);
75 m.last_entropy.store(0, Ordering::Relaxed);
76 if let Ok(mut v) = m.kernel_audit.lock() {
77 v.clear();
78 }
79 // 2026-09-26: The incoming model's lookups run before its own gate seals;
80 // under the outgoing model's seal a failed one would count as late.
81 crate::kernel_audit::unseal();
82}
83
84/// 2026-09-26: Prefix-cache `(hits, misses, hit tokens)` since the last
85/// [`reset_for_new_run`]. The TUI shows these; `/metrics` exports the
86/// cumulative counters.
87pub fn cache_counts_this_run() -> (u64, u64, u64) {
88 let m = metrics();
89 let sub = |c: &AtomicU64, b: &AtomicU64| {
90 c.load(Ordering::Relaxed)
91 .saturating_sub(b.load(Ordering::Relaxed))
92 };
93 (
94 sub(&m.cache_hits, &m.run_base_cache_hits),
95 sub(&m.cache_misses, &m.run_base_cache_misses),
96 sub(&m.cache_hit_tokens, &m.run_base_cache_hit_tokens),
97 )
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 /// 2026-09-26: After a reset the exported counter has not gone back, and
105 /// the run-relative count starts near 0. The checks are bounds, because
106 /// another test in this binary records into the same global mailbox
107 /// concurrently.
108 #[test]
109 fn a_new_run_starts_from_the_bottom() {
110 const RUN: u64 = 10_000;
111 for _ in 0..RUN {
112 crate::prefix_cache::record_cache_hit(1);
113 }
114 let cumulative = crate::prefix_cache::cache_hit_count();
115 assert!(cumulative >= RUN, "the run accumulated");
116
117 reset_for_new_run();
118
119 assert!(
120 crate::prefix_cache::cache_hit_count() >= cumulative,
121 "the exported counter must never go backwards"
122 );
123 let (hits, _, tokens) = cache_counts_this_run();
124 assert!(
125 hits < RUN / 10,
126 "but the next run does not inherit the previous run's hits"
127 );
128 assert!(tokens < RUN / 10);
129 }
130}
131
132#[cfg(test)]
133mod swap_counter_tests {
134 use super::*;
135
136 /// 2026-09-26: A lower bound against a value read at the start: other tests
137 /// in this binary can only add to the same global counter concurrently.
138 #[test]
139 fn a_new_run_does_not_move_the_counters_prometheus_exports() {
140 let m = metrics();
141 let before = m.cache_hits.load(Ordering::Relaxed);
142 m.cache_hits.fetch_add(7, Ordering::Relaxed);
143
144 reset_for_new_run();
145
146 assert!(
147 m.cache_hits.load(Ordering::Relaxed) >= before + 7,
148 "the cumulative counter must not go backwards across a swap"
149 );
150 }
151}