memra_engine/progress.rs
1//! FORWARD-PROGRESS ODOMETER, the engine's own answer to "is this worker busy, or hung?"
2//! (lane/health-busy-vs-hung, memra#50, 2026-09-03).
3//!
4//! THE DEFECT THIS EXISTS FOR. `/health` used to read ONE signal for a BUSY worker: the age of
5//! the scheduler-loop heartbeat (`WorkerHealth::beat`, stamped once per iteration in
6//! `worker.rs`). That is a proxy for progress, not progress itself, and the proxy broke the
7//! moment ONE iteration legitimately ran longer than the stall threshold. Measured, on the
8//! glm5 ship-gate stress arm (darklanes `research/glm5-serving-launch-20260901/soak-20260901/
9//! RESULT.md`, RED finding 1): waves of 8 to 22 admitted sessions carrying 20k-88k-token
10//! prompts primed inside one scheduler iteration, the beat did not land for >120 s while the
11//! worker was PROGRESSING NORMALLY, `/health` answered 503 `unhealthy` for three guard ticks,
12//! and the supervisor SIGTERMed a server with 22 requests in flight. A false restart costs
13//! every in-flight request plus a full model load.
14//!
15//! WHAT THIS PUBLISHES, and why it is honest. Every completed PRIME CHUNK stamps this
16//! odometer: a token count, an event count, and the monotonic time of the last advance. The
17//! stamp sits where the chunk's host-side result already exists, the chunk's logits are a
18//! `Vec<f32>`, i.e. a device-to-host copy has already drained that stream (see
19//! `prime_chunk_ppn`'s exit-publication note). So an advance is not "the host queued some
20//! launches"; it is "the device finished that chunk's work and the host read the answer
21//! back". That is the strongest liveness attestation available without a second thread.
22//!
23//! WHAT IT CANNOT DETECT, stated so nobody reads more into it:
24//! * A worker looping FOREVER INSIDE one chunk (a wedged kernel, a hung driver call, a
25//! deadlock inside a single prime call) advances nothing, so it is caught, but only
26//! after the stall threshold, exactly as before. This buys correctness under load, not
27//! faster hang detection.
28//! * A worker making progress on the WRONG work (a livelock that re-primes the same chunk
29//! forever, a scheduler that starves one session while another runs) reads healthy. This
30//! is a liveness signal, not a fairness or a correctness one.
31//! * Chunk granularity is the resolution: with `MEMRA_PRIME_CHUNK=0` a prompt primes in one
32//! call up to `PRIME_CHUNK_LAUNCH_CAP` (65,520 tokens), so the odometer's own gap can be
33//! a whole 65k-token prime. A deployment that pins the monolithic rollback seam is back
34//! to sizing `MEMRA_HEALTH_STALL_S` from its prefill rate by hand.
35//! * It is PROCESS-GLOBAL, not per-session. One live session priming keeps the process
36//! healthy while another session's work is stuck behind it. That is correct for the
37//! question `/health` asks ("should this process be RESTARTED?") and wrong for any
38//! per-request SLO, which admission and the first-token deadline own instead.
39
40use std::sync::OnceLock;
41use std::sync::atomic::{AtomicU64, Ordering};
42use std::time::Instant;
43
44static ROWS: AtomicU64 = AtomicU64::new(0);
45static EVENTS: AtomicU64 = AtomicU64::new(0);
46/// Milliseconds since [`epoch`] at the last advance. `u64::MAX` means "never advanced", which
47/// is distinct from "advanced at t=0", a fresh process must not look like a progressing one.
48static LAST_MS: AtomicU64 = AtomicU64::new(u64::MAX);
49
50/// Process-start monotonic baseline. Milliseconds since this baseline are storable in an
51/// atomic and immune to wall-clock steps: an NTP correction must never look like a wedged GPU.
52fn epoch() -> Instant {
53 static E: OnceLock<Instant> = OnceLock::new();
54 *E.get_or_init(Instant::now)
55}
56
57fn now_ms() -> u64 {
58 epoch().elapsed().as_millis() as u64
59}
60
61/// One prime chunk completed on this process's worker thread, carrying `rows` token rows.
62///
63/// Ordering: the counters are Relaxed (diagnostics) but `LAST_MS` is Release and read Acquire,
64/// so a reader that observes a fresh timestamp also observes the counts that produced it.
65/// Cost is three atomic stores and one `Instant::now()` per CHUNK (not per token, not per
66/// kernel), which is noise against a chunk that just moved thousands of token rows.
67pub fn note_prime_rows(rows: usize) {
68 ROWS.fetch_add(rows as u64, Ordering::Relaxed);
69 EVENTS.fetch_add(1, Ordering::Relaxed);
70 LAST_MS.store(now_ms(), Ordering::Release);
71}
72
73/// Completed prime chunks so far. Used by `prime_cache_overlaid` to tell a CHUNKED walk
74/// (which already stamped per chunk) from a MONOLITHIC one (which stamped nothing, and whose
75/// only honest progress point is the call's own completion).
76pub fn events() -> u64 {
77 EVENTS.load(Ordering::Relaxed)
78}
79
80/// What the odometer has seen. `None` until the first advance, a process that has never
81/// primed anything reports nothing rather than reporting an age measured from boot.
82pub fn snapshot() -> Option<Progress> {
83 let last = LAST_MS.load(Ordering::Acquire);
84 if last == u64::MAX {
85 return None;
86 }
87 Some(Progress {
88 rows: ROWS.load(Ordering::Relaxed),
89 events: EVENTS.load(Ordering::Relaxed),
90 age_ms: now_ms().saturating_sub(last),
91 })
92}
93
94/// The odometer's observable state.
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub struct Progress {
97 /// token rows primed since process start.
98 pub rows: u64,
99 /// completed prime chunks since process start.
100 pub events: u64,
101 /// milliseconds since the last completed chunk.
102 pub age_ms: u64,
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 /// WIRING GATE. The odometer is only as honest as its call sites: a `progress` module that
110 /// compiles, publishes and is read by `/health` while NOTHING in the prime path calls it
111 /// would make every BUSY worker look hung again, silently, and every unit test above would
112 /// still pass. So assert the calls exist in the prime walks, in COMMENT-STRIPPED source
113 /// (the module and call-site docs mention `note_prime_rows` by name, and a doc mention is
114 /// not an invocation).
115 ///
116 /// Bound, not an exact count: chunk walks get added. What must never happen is the count
117 /// going to zero, or `prime_cache_overlaid` losing its call-granularity stamp, because
118 /// either failure is invisible on a host and expensive on a box.
119 #[test]
120 fn the_prime_walks_actually_call_the_odometer() {
121 let src = include_str!("hybrid_forward.rs");
122 let code: String = src
123 .lines()
124 .map(|l| l.trim_start())
125 .filter(|l| !l.starts_with("//"))
126 .collect::<Vec<_>>()
127 .join("\n");
128 let calls = code.matches("crate::progress::note_prime_rows(").count();
129 assert!(
130 calls >= 9,
131 "the prime walks must stamp the forward-progress odometer (memra#50); found \
132 {calls} live call sites in hybrid_forward.rs (7 per-chunk walks plus the two \
133 call-granularity shims)"
134 );
135 // Both ENTRY points need the shim, not just one: `prime_cache_batch` does not route
136 // through `prime_cache_overlaid`, and it was the multi-session batched wave prefill's
137 // only coverage gap (review of #106).
138 assert_eq!(
139 code.matches("crate::progress::events()").count(),
140 4,
141 "both prime entries (prime_cache_overlaid, prime_cache_batch) must compare the \
142 event count across the call, or a MONOLITHIC prime on that entry stamps nothing"
143 );
144 for entry in [
145 "fn prime_cache_overlaid_inner(",
146 "fn prime_cache_batch_inner(",
147 ] {
148 assert!(
149 code.contains(entry),
150 "the shim for {entry} is gone: its entry is stamping nothing"
151 );
152 }
153 }
154
155 /// The never-advanced state is DISTINCT from a zero age. Asserted because the whole point
156 /// of the odometer is that health falls back to beat age when it has nothing to say, and
157 /// a `Some(age 0)` on a fresh process would instead declare a never-run worker healthy
158 /// forever.
159 #[test]
160 fn snapshot_is_none_until_the_first_advance_then_counts() {
161 // This test owns the process-global only in the sense that it asserts monotonicity,
162 // never an absolute value: other tests in the same binary may also advance it.
163 let before = snapshot();
164 note_prime_rows(4096);
165 let after = snapshot().expect("an advance was just stamped");
166 match before {
167 None => assert_eq!(after.rows, 4096),
168 Some(b) => {
169 assert!(after.rows.saturating_sub(b.rows) >= 4096);
170 assert!(after.events > b.events);
171 }
172 }
173 }
174}