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}