spark_runtime/run_metrics.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Run mailboxes — the observability surfaces that stay process-global on
4//! purpose, and the one call that keeps them honest across a model swap.
5//!
6//! Most model-derived state in Atlas is carried: `SchedCtx`, `ForwardContext`,
7//! `ModelLevers`, `OpCache`. A handful of counters cannot be, because their
8//! *readers* cannot be handed a carrier — `/metrics` answers from an HTTP
9//! handler thread and the dashboard polls from the TUI thread, both while the
10//! scheduler is mid-step and holding its own context. A process-global address
11//! is what an observability surface is for.
12//!
13//! That leaves the scoping problem: after a swap the counters would describe
14//! two models at once. [`reset_for_new_run`] solves it from the other end —
15//! the values stay reachable at a fixed address, but they start clean when a
16//! run does, so a reader asking "what is the prefix-cache hit rate" gets the
17//! rate for the model now running. Prometheus reads the reset as a counter
18//! restart, which it already handles.
19//!
20//! Called from `AtlasCudaBackend::new`, which is where a model's GPU state
21//! begins. That is deliberately upstream of the first kernel lookup, so the
22//! kernel audit records only this model's modules.
23
24use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
25use std::sync::{LazyLock, Mutex};
26
27/// The process's single run mailbox.
28///
29/// Seven separate statics across three modules became one, because they were
30/// always one thing: the numbers a reader gets when it asks what the running
31/// model is doing. Splitting them meant `reset_for_new_run` had to reach into
32/// three modules and could silently miss one — the failure being a counter
33/// that keeps a dead model's value while its neighbours restart.
34#[derive(Debug, Default)]
35pub struct RunMetrics {
36 // ── Prefix cache (one RadixTree per server) ──
37 pub cache_hits: AtomicU64,
38 pub cache_misses: AtomicU64,
39 pub cache_hit_tokens: AtomicU64,
40
41 // ── Sampler entropy ──
42 /// Most recent per-token entropy, f32 bits for a lock-free read.
43 pub last_entropy: AtomicU32,
44 pub low_entropy_tokens: AtomicU64,
45 pub total_sampled_tokens: AtomicU64,
46
47 /// Free device memory at GPU-context init, before this run allocated
48 /// anything. Lets KV sizing measure this process's own footprint as
49 /// `baseline - free_now`, excluding co-tenants automatically. `0` =
50 /// unset (the mock backend in tests) and callers fall back.
51 pub baseline_free_bytes: AtomicUsize,
52 /// `(module, func, loaded, dispatch site)` for every kernel lookup this run
53 /// made. The site is the `Location` of the `.kernel(…)` / `try_kernel(…)`
54 /// call, carried in through `#[track_caller]`: a bare `module::func` list
55 /// is not actionable when the same module is looked up from a dozen
56 /// constructors and the fix is always "go to that line".
57 pub kernel_audit: Mutex<Vec<(String, String, bool, &'static std::panic::Location<'static>)>>,
58
59 // ── Per-run baselines for the counters above ──
60 //
61 // `cache_hits`, `cache_misses` and `cache_hit_tokens` are exported on
62 // /metrics as `atlas_prefix_cache_*_total`, declared `TYPE counter`, and a
63 // counter must only ever climb. Zeroing them was harmless while a backend
64 // was built exactly once per process — the reset happened before anything
65 // could scrape. Hot-swap builds one per load, so the same call now resets
66 // live counters mid-life, which reads to Prometheus as a restart and
67 // corrupts `rate()` and `increase()` across the swap.
68 //
69 // The counters therefore stay cumulative, and "this run" is DERIVED by
70 // subtracting a snapshot taken when the run began. One authoritative
71 // number, two views of it.
72 run_base_cache_hits: AtomicU64,
73 run_base_cache_misses: AtomicU64,
74 run_base_cache_hit_tokens: AtomicU64,
75}
76
77/// The mailbox. See the module doc for why this one is static.
78static METRICS: LazyLock<RunMetrics> = LazyLock::new(RunMetrics::default);
79
80/// Read the mailbox.
81pub fn metrics() -> &'static RunMetrics {
82 &METRICS
83}
84
85/// Begin a new run's accounting. Called when a new model's backend is built.
86///
87/// The monotonic counters are SNAPSHOTTED, not cleared — see the baseline
88/// fields. Everything else here is per-run scratch that nothing exports, so it
89/// is cleared outright.
90pub fn reset_for_new_run() {
91 let m = metrics();
92 for (counter, base) in [
93 (&m.cache_hits, &m.run_base_cache_hits),
94 (&m.cache_misses, &m.run_base_cache_misses),
95 (&m.cache_hit_tokens, &m.run_base_cache_hit_tokens),
96 ] {
97 base.store(counter.load(Ordering::Relaxed), Ordering::Relaxed);
98 }
99 for c in [&m.low_entropy_tokens, &m.total_sampled_tokens] {
100 c.store(0, Ordering::Relaxed);
101 }
102 m.baseline_free_bytes.store(0, Ordering::Relaxed);
103 m.last_entropy.store(0, Ordering::Relaxed);
104 if let Ok(mut v) = m.kernel_audit.lock() {
105 v.clear();
106 }
107 // The next model runs its own eager lookups and gets its own boot gate, so
108 // the seal from the outgoing model must not outlive it — otherwise every
109 // one of the incoming model's own lookups reads as a "late" one.
110 crate::kernel_audit::unseal();
111}
112
113/// Prefix-cache activity since the CURRENT model was loaded.
114///
115/// The dashboard asks "how is this model doing", not "how has this process
116/// done since boot"; after a swap those differ. Prometheus wants the opposite
117/// and reads the cumulative counters directly.
118pub fn cache_counts_this_run() -> (u64, u64, u64) {
119 let m = metrics();
120 let sub = |c: &AtomicU64, b: &AtomicU64| {
121 c.load(Ordering::Relaxed)
122 .saturating_sub(b.load(Ordering::Relaxed))
123 };
124 (
125 sub(&m.cache_hits, &m.run_base_cache_hits),
126 sub(&m.cache_misses, &m.run_base_cache_misses),
127 sub(&m.cache_hit_tokens, &m.run_base_cache_hit_tokens),
128 )
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 /// Written as a threshold rather than an equality on purpose.
136 ///
137 /// The mailbox is process-global, and cargo runs this binary's tests in
138 /// parallel threads — the `radix_tree` and `sampler` cases record into it
139 /// while this one runs. An `assert_eq!(.., 0)` after the reset is
140 /// therefore flaky by construction, which is a fair demonstration of what
141 /// a process-global counter costs even when a global is the right shape.
142 /// A run's worth of hits is orders of magnitude above the handful a
143 /// concurrent test contributes, so the drop is unambiguous.
144 /// The assertion here is INVERTED from what it was, deliberately.
145 ///
146 /// It used to require that `cache_hit_count()` itself dropped to near zero.
147 /// That was correct while a backend was built exactly once per process: the
148 /// reset ran before anything could observe the counter. Hot-swap builds one
149 /// per load, and the same value is exported on /metrics as
150 /// `atlas_prefix_cache_hits_total`, declared `TYPE counter` — so the old
151 /// behaviour resets a live counter, which Prometheus reads as a restart.
152 ///
153 /// A new run still starts from the bottom; the counter is no longer the
154 /// thing that moves.
155 #[test]
156 fn a_new_run_starts_from_the_bottom() {
157 const RUN: u64 = 10_000;
158 for _ in 0..RUN {
159 crate::prefix_cache::record_cache_hit(1);
160 }
161 let cumulative = crate::prefix_cache::cache_hit_count();
162 assert!(cumulative >= RUN, "the run accumulated");
163
164 reset_for_new_run();
165
166 assert!(
167 crate::prefix_cache::cache_hit_count() >= cumulative,
168 "the exported counter must never go backwards"
169 );
170 let (hits, _, tokens) = cache_counts_this_run();
171 assert!(
172 hits < RUN / 10,
173 "but the next run does not inherit the previous run's hits"
174 );
175 assert!(tokens < RUN / 10);
176 }
177}
178
179#[cfg(test)]
180mod swap_counter_tests {
181 use super::*;
182
183 /// Deliberately expressed as `>=` against a value read at the start, not as
184 /// an equality. Other tests in this binary record into the same global
185 /// mailbox concurrently — they can only ADD, so a lower bound is immune to
186 /// them, and an exact assertion here was flaky on the first run of four.
187 #[test]
188 fn a_new_run_does_not_move_the_counters_prometheus_exports() {
189 // atlas_prefix_cache_hits_total is declared TYPE counter, and a counter
190 // must only ever climb. Zeroing it was harmless when a backend was
191 // built once per process; hot-swap builds one per load, so the same
192 // call would reset a live counter and read to Prometheus as a restart.
193 let m = metrics();
194 let before = m.cache_hits.load(Ordering::Relaxed);
195 m.cache_hits.fetch_add(7, Ordering::Relaxed);
196
197 reset_for_new_run();
198
199 assert!(
200 m.cache_hits.load(Ordering::Relaxed) >= before + 7,
201 "the cumulative counter must not go backwards across a swap"
202 );
203 }
204}