gpuviewer_core/events.rs
1//! Event derivation — the "story" layer.
2//!
3//! Two-tier honesty contract (non-negotiable, see docs/research/04-synthesis.md §5 risk 2):
4//! - `Confidence::Fact` events assert observed state transitions plainly (throttle bit set,
5//! process exited). They carry the raw evidence that produced them.
6//! - `Confidence::Likely` events are inferences (extrapolated OOM ETA, suspected dataloader
7//! stall) and must always read as hedged.
8
9use std::collections::{HashMap, VecDeque};
10
11use crate::model::{fmt_bytes, DeviceId, DynamicSample, ProcessSample, ThrottleReasons};
12use serde::{Deserialize, Serialize};
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Severity {
17 Info,
18 Warning,
19 Critical,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum Confidence {
25 Fact,
26 Likely,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum EventKind {
32 ThrottleStart,
33 ThrottleEnd,
34 ProcessAttached,
35 ProcessExited,
36 VramPressure,
37 IdleGap,
38 /// The collector itself fell behind its tick cadence — the recording has a hole, and
39 /// the recorder must say so rather than let the gap masquerade as device idleness.
40 /// Emitted by the tui collector (the engine owns tick timing, not this module).
41 CollectorStall,
42 /// History was truncated or restarted (ring wrap on resize, store re-init); consumers
43 /// must not treat the discontinuity as device behavior.
44 HistoryReset,
45 /// Device stopped answering queries while a workload was attached — possibly a hung
46 /// kernel or driver. An inference by nature: always `Confidence::Likely`.
47 HangSuspected,
48 /// A registered device stopped answering its dynamic probe entirely (consecutive
49 /// whole-probe failures, not per-metric absence — `NOT_SUPPORTED` stays `None` in the
50 /// sample and is never this). Driver reset, NVML dying, eGPU unplug, and an xe rebind
51 /// all look identical from this side of the probe, so the kind asserts only the
52 /// observed silence — the CAUSE is never claimed. Emitted by the tui collector (it
53 /// owns the probe loop and its tick counting). Always `Confidence::Fact`.
54 DeviceLost,
55 /// A device previously declared lost answered again. The samples between loss and
56 /// return were never collected; that gap stays blank in history — a hole, never
57 /// zeros. Always `Confidence::Fact`.
58 DeviceReturned,
59 /// A GPU-attached process is burning CPU while the GPU sits idle — the classic
60 /// CPU-bound dataloader. An inference: always `Confidence::Likely`.
61 CpuSpillover,
62 /// A recording session began folding history into the database — the flight
63 /// recorder's own power-on mark. Without it (and its stop twin) the timeline cannot
64 /// distinguish "the GPU sat idle" from "gpuviewer wasn't running": unrecorded time
65 /// renders blank, and the boundary events are what make that blank legible. Emitted
66 /// by the tui collector (it owns the recorder lifecycle). Always `Confidence::Fact`.
67 RecordingStarted,
68 /// The recording session ended cleanly (the stop mark and the partial rollup tail
69 /// reached the store). A session that dies without this mark — SIGKILL, OOM kill,
70 /// power loss — writes nothing, which is itself information: the NEXT session's
71 /// start mark narrates the missing stop. Always `Confidence::Fact`.
72 RecordingStopped,
73 /// Writes to the history database started failing (disk full, permissions revoked,
74 /// the file removed under a running session) — or, on the recovery edge, started
75 /// working again. The recorder swallows the error itself so a bad disk never takes
76 /// the live view down with it, but swallowing it SILENTLY is the one thing it must
77 /// not do: this product's entire promise is that you can scroll back, and a recorder
78 /// that quietly stopped recording breaks that promise exactly when it matters. The
79 /// same rule the collector applies to its own stalls, applied to its own storage.
80 /// Emitted by the tui collector. Always `Confidence::Fact`.
81 RecordingDegraded,
82}
83
84#[derive(Clone, Debug, Serialize, Deserialize)]
85pub struct Event {
86 pub ts_ms: u64,
87 pub device: DeviceId,
88 pub kind: EventKind,
89 pub severity: Severity,
90 pub confidence: Confidence,
91 /// One-line human narration ("GPU0 thermal throttling began — clocks 2520→1815 MHz").
92 pub title: String,
93 /// The raw evidence behind the narration, always auditable.
94 pub evidence: String,
95}
96
97/// VRAM-trend window length and event cooldown.
98const VRAM_WINDOW_MS: u64 = 180_000;
99const VRAM_MIN_SPAN_MS: u64 = 60_000;
100const VRAM_PRESSURE_FRAC: f64 = 0.85;
101const VRAM_MIN_SLOPE_BYTES_PER_MIN: f64 = 16.0 * 1024.0 * 1024.0;
102const VRAM_COOLDOWN_MS: u64 = 90_000;
103
104/// Idle-gap (training stall) thresholds: a gap only narrates after sustained activity,
105/// only once it lasted long enough to matter, and only if a real allocation stayed
106/// attached throughout — otherwise it is just an idle GPU, not a stall.
107const IDLE_ACTIVE_UTIL_PCT: f32 = 50.0;
108const IDLE_ACTIVE_MIN_MS: u64 = 30_000;
109const IDLE_GAP_UTIL_PCT: f32 = 10.0;
110const IDLE_GAP_MIN_MS: u64 = 10_000;
111const IDLE_HOLDER_MIN_BYTES: u64 = 256 * 1024 * 1024;
112
113/// Hang-suspicion thresholds. The most confidently-wrong-prone inference in the product, so
114/// the bar is deliberately steep: VRAM held but engines flat-dead, the holder's own util
115/// also flat, and the whole pattern sustained for ten unbroken minutes before we dare say
116/// "likely hung". A live trough that recovers, or any GPU activity, must not trip it.
117const HANG_DEVICE_UTIL_PCT: f32 = 2.0;
118const HANG_PROC_UTIL_PCT: f32 = 2.0;
119const HANG_HOLDER_MIN_BYTES: u64 = 1024 * 1024 * 1024;
120const HANG_RESET_UTIL_PCT: f32 = 10.0;
121const HANG_MIN_MS: u64 = 600_000;
122
123/// CPU-spillover thresholds. A freshly-loaded model that sits on a near-idle GPU while its
124/// own process pegs multiple cores is the signature of a partial CPU offload (the model did
125/// not fit in VRAM). We assess over a fixed window so a model still warming up is not judged
126/// prematurely, and demand a high CPU bar plus a near-dead GPU before claiming it.
127const SPILLOVER_HOLDER_MIN_BYTES: u64 = 2 * 1024 * 1024 * 1024;
128const SPILLOVER_WINDOW_MS: u64 = 90_000;
129const SPILLOVER_MAX_MEAN_UTIL_PCT: f64 = 15.0;
130const SPILLOVER_BUSY_UTIL_PCT: f32 = 30.0;
131const SPILLOVER_MIN_MEAN_CPU_PCT: f64 = 150.0;
132const SPILLOVER_MIN_CPU_SAMPLES: u32 = 3;
133/// Minimum observed-util coverage of the assessment window, as a percentage of its ticks.
134/// The "GPU stayed ~idle" half of the claim needs the same grounding as the CPU half: when
135/// util is `None` throughout (source cannot observe it), the mean would read as 0% and pass
136/// the idle gate with zero actual GPU evidence — exactly the confidently-wrong narration the
137/// honesty contract bans. Below half-window coverage the judgment is refused, silently.
138const SPILLOVER_MIN_UTIL_COVERAGE_PCT: u32 = 50;
139
140#[derive(Default)]
141struct DevState {
142 prev: Option<DynamicSample>,
143 procs: HashMap<u32, ProcessSample>,
144 seen_first_procs: bool,
145 throttle_since: Option<u64>,
146 /// Clock just before throttling began, for the "2520→1815 MHz" narration.
147 pre_throttle_clock: Option<u32>,
148 vram_window: VecDeque<(u64, u64)>,
149 last_pressure_evt: Option<u64>,
150 /// ts when util first crossed `IDLE_ACTIVE_UTIL_PCT`; None while below it.
151 active_since: Option<u64>,
152 /// Latched after `IDLE_ACTIVE_MIN_MS` of sustained activity: a trough only reads
153 /// as a training stall if real work preceded it (a desktop idling is no story).
154 idle_eligible: bool,
155 idle_gap: Option<IdleGap>,
156 hang: Option<HangEpisode>,
157 /// Open CPU-spillover assessments keyed by the holder's pid; one per new big-memory
158 /// process, closed (and judged) when its window elapses or it is cancelled.
159 spillovers: HashMap<u32, Spillover>,
160}
161
162/// An idle gap in flight: narrated (or discarded) only once util recovers and the
163/// gap's duration is actually known.
164struct IdleGap {
165 start_ms: u64,
166 /// Util of the sample just before the drop, for the "92% → 2%" evidence.
167 pre_util_pct: f32,
168 /// Running mean of util inside the gap (sum and sample count).
169 util_sum: f64,
170 util_n: u32,
171 /// pid → (name, mem at gap start) for processes holding ≥ `IDLE_HOLDER_MIN_BYTES`
172 /// when the gap opened; pruned as they exit. Empty at gap end means nobody stayed
173 /// attached, so the process_exited event already tells the story.
174 holders: HashMap<u32, (String, u64)>,
175 /// Set when a `HangSuspected` event fired while this gap was open. A hang is just an
176 /// idle gap that lasted long enough to look dead; narrating both for one trough would
177 /// double-count the same incident, so the gap stays silent on recovery.
178 hang_narrated: bool,
179}
180
181/// A hang suspicion in flight: VRAM held with both device and holder engines flat-dead,
182/// anchored to the largest qualifying holder. Emits once the pattern survives `HANG_MIN_MS`
183/// unbroken; reset (without emitting) the moment activity returns, the holder exits, or
184/// util goes unobservable.
185struct HangEpisode {
186 start_ms: u64,
187 /// The largest qualifying holder when the episode opened — the anchor of the narration.
188 /// A different (or larger) holder appearing later does not move the anchor; the claim is
189 /// about *this* allocation having gone quiet.
190 holder_pid: u32,
191 holder_name: String,
192 holder_mem: u64,
193 /// Running mean of device util across the episode, for the evidence line.
194 util_sum: f64,
195 util_n: u32,
196 /// Latched once the event has been emitted, so a sustained hang narrates exactly once.
197 fired: bool,
198}
199
200/// A CPU-spillover assessment in flight for one freshly-attached big-memory process.
201struct Spillover {
202 name: String,
203 mem_bytes: u64,
204 start_ms: u64,
205 util_sum: f64,
206 util_n: u32,
207 cpu_sum: f64,
208 cpu_n: u32,
209 /// Total ticks the assessment has lived through, observed-util or not — the denominator
210 /// for the `SPILLOVER_MIN_UTIL_COVERAGE_PCT` floor.
211 tick_n: u32,
212}
213
214#[derive(Default)]
215pub struct EventEngine {
216 state: HashMap<DeviceId, DevState>,
217 short_names: HashMap<DeviceId, String>,
218}
219
220impl EventEngine {
221 pub fn new() -> Self {
222 Self::default()
223 }
224
225 /// Register a friendly short name ("GPU0") used in narration.
226 pub fn register_device(&mut self, id: DeviceId, short_name: String) {
227 self.short_names.insert(id, short_name);
228 }
229
230 fn short(&self, id: &DeviceId) -> String {
231 self.short_names
232 .get(id)
233 .cloned()
234 .unwrap_or_else(|| id.0.clone())
235 }
236
237 /// Derive this tick's events.
238 ///
239 /// `processes` is `None` when the process list was **unobservable** — the backend's
240 /// process probe failed outright. That is emphatically not the same as `Some(&[])`,
241 /// which asserts the device really had no processes: an empty list makes every
242 /// previously-seen pid look like it exited, and `process_events` would narrate a
243 /// fact-grade "python (pid 4521) left GPU0, freeing 21.3 GiB" off a driver hiccup.
244 /// Fact-grade narration of an event that never happened is the failure mode this
245 /// project treats as fatal, so the unobservable case gets its own arm: the four
246 /// process-dependent derivations are skipped, and any inference resting on
247 /// *continuous* process observation is reset, because this tick broke that premise.
248 /// Device-level derivations (throttle, VRAM pressure) are unaffected and keep running.
249 pub fn observe(
250 &mut self,
251 device: &DeviceId,
252 sample: &DynamicSample,
253 processes: Option<&[ProcessSample]>,
254 mem_total: Option<u64>,
255 temp_slowdown_c: Option<f32>,
256 ) -> Vec<Event> {
257 let name = self.short(device);
258 let st = self.state.entry(device.clone()).or_default();
259 let mut out = Vec::new();
260
261 throttle_events(st, device, &name, sample, temp_slowdown_c, &mut out);
262 match processes {
263 Some(processes) => {
264 // Before process_events flips `seen_first_procs` / overwrites `st.procs`,
265 // so the newness diff that opens a spillover window sees this tick's
266 // arrivals.
267 spillover_events(st, device, &name, sample, processes, &mut out);
268 process_events(st, device, &name, sample.ts_ms, processes, &mut out);
269 // After process_events, so holder tracking sees this tick's process list.
270 hang_events(st, device, &name, sample, &mut out);
271 // After hang_events, so a hang that just fired can suppress the gap it
272 // lived in.
273 idle_gap_events(st, device, &name, sample, &mut out);
274 }
275 None => {
276 // Same discipline as `util_pct: None` below: drop the in-flight
277 // inferences rather than carry them across a blind tick. A hang claims a
278 // holder was resident for 10 unbroken minutes and an idle gap claims one
279 // stayed attached throughout — neither can be said across a tick where
280 // nobody could see the process list, and an open spillover window is
281 // judging a pid it can no longer observe.
282 st.hang = None;
283 st.idle_gap = None;
284 st.spillovers.clear();
285 // `st.procs` is deliberately LEFT INTACT: it is the last observed truth,
286 // not a claim about now. Clearing it would make every held process look
287 // like it exited on the next good tick — the same false narration by a
288 // slower route.
289 }
290 }
291 vram_pressure_events(st, device, &name, sample, mem_total, &mut out);
292
293 st.prev = Some(sample.clone());
294 out
295 }
296}
297
298fn throttle_events(
299 st: &mut DevState,
300 device: &DeviceId,
301 name: &str,
302 sample: &DynamicSample,
303 temp_slowdown_c: Option<f32>,
304 out: &mut Vec<Event>,
305) {
306 // Throttle unobservable on this source (`None` ≠ "not throttling" — design §5.4):
307 // neither a start nor an end can be asserted, so drop the open episode silently —
308 // the same blind-spot rule util uses for idle gaps and hangs. Narrating an "end"
309 // off a blind spot would be a fabricated fact.
310 let Some(throttle) = sample.throttle else {
311 st.throttle_since = None;
312 st.pre_throttle_clock = None;
313 return;
314 };
315 let prev_any = st
316 .prev
317 .as_ref()
318 .and_then(|p| p.throttle)
319 .map(|t| t.any())
320 .unwrap_or(false);
321 let now_any = throttle.any();
322
323 if !prev_any && now_any {
324 let labels = throttle.labels().join(", ");
325 let pre_clock = st.prev.as_ref().and_then(|p| p.sm_clock_mhz);
326 st.pre_throttle_clock = pre_clock;
327 st.throttle_since = Some(sample.ts_ms);
328
329 let clocks = match (pre_clock, sample.sm_clock_mhz) {
330 (Some(a), Some(b)) if b < a => format!(" — clocks {a}→{b} MHz"),
331 _ => String::new(),
332 };
333 let temp_part = match (sample.temp_c, temp_slowdown_c) {
334 (Some(t), Some(thr)) => format!("; {t:.0}°C vs {thr:.0}°C slowdown threshold"),
335 (Some(t), None) => format!("; {t:.0}°C"),
336 _ => String::new(),
337 };
338 out.push(Event {
339 ts_ms: sample.ts_ms,
340 device: device.clone(),
341 kind: EventKind::ThrottleStart,
342 severity: severity_for(&throttle),
343 confidence: Confidence::Fact,
344 title: format!("{name} began throttling ({labels}){clocks}"),
345 evidence: format!("throttle bits: [{labels}]{temp_part}"),
346 });
347 } else if prev_any && !now_any {
348 let dur = st
349 .throttle_since
350 .take()
351 .map(|t0| format!(" after {}", fmt_dur_ms(sample.ts_ms.saturating_sub(t0))))
352 .unwrap_or_default();
353 // Only claim "recovered" when clocks are actually back near pre-throttle levels;
354 // a throttle that ends because the GPU went idle is not a recovery.
355 let clocks = match (st.pre_throttle_clock.take(), sample.sm_clock_mhz) {
356 (Some(a), Some(b)) if b as f64 >= a as f64 * 0.9 => {
357 format!("; clocks recovered to {b} MHz")
358 }
359 (Some(a), Some(b)) => format!("; clocks now {b} MHz ({a} MHz pre-throttle)"),
360 _ => String::new(),
361 };
362 out.push(Event {
363 ts_ms: sample.ts_ms,
364 device: device.clone(),
365 kind: EventKind::ThrottleEnd,
366 severity: Severity::Info,
367 confidence: Confidence::Fact,
368 title: format!("{name} stopped throttling{dur}"),
369 evidence: format!("throttle bits cleared{clocks}"),
370 });
371 }
372}
373
374fn process_events(
375 st: &mut DevState,
376 device: &DeviceId,
377 name: &str,
378 ts_ms: u64,
379 processes: &[ProcessSample],
380 out: &mut Vec<Event>,
381) {
382 let now: HashMap<u32, &ProcessSample> = processes.iter().map(|p| (p.pid, p)).collect();
383
384 // Suppress the attach-flood on the very first observation: those processes were already
385 // there; narrating them as new would be a lie.
386 if st.seen_first_procs {
387 for (pid, p) in &now {
388 if !st.procs.contains_key(pid) {
389 let mem = p
390 .mem_bytes
391 .map(|b| format!(", using {}", fmt_bytes(b)))
392 .unwrap_or_default();
393 out.push(Event {
394 ts_ms,
395 device: device.clone(),
396 kind: EventKind::ProcessAttached,
397 severity: Severity::Info,
398 confidence: Confidence::Fact,
399 title: format!("{} (pid {}) attached to {name}{mem}", p.name, pid),
400 evidence: format!("new {} client in process list", p.kind.prose()),
401 });
402 }
403 }
404 let gone: Vec<ProcessSample> = st
405 .procs
406 .values()
407 .filter(|p| !now.contains_key(&p.pid))
408 .cloned()
409 .collect();
410 for p in gone {
411 let freed = p
412 .mem_bytes
413 .map(|b| format!(", freeing {}", fmt_bytes(b)))
414 .unwrap_or_default();
415 out.push(Event {
416 ts_ms,
417 device: device.clone(),
418 kind: EventKind::ProcessExited,
419 severity: Severity::Info,
420 confidence: Confidence::Fact,
421 title: format!("{} (pid {}) left {name}{freed}", p.name, p.pid),
422 evidence: format!(
423 "pid {} no longer in process list; last seen holding {}",
424 p.pid,
425 p.mem_bytes
426 .map(fmt_bytes)
427 .unwrap_or_else(|| "unknown memory".into())
428 ),
429 });
430 }
431 }
432 st.seen_first_procs = true;
433 st.procs = now.into_iter().map(|(k, v)| (k, v.clone())).collect();
434}
435
436fn idle_gap_events(
437 st: &mut DevState,
438 device: &DeviceId,
439 name: &str,
440 sample: &DynamicSample,
441 out: &mut Vec<Event>,
442) {
443 let Some(util) = sample.util_pct else {
444 // Utilization went unavailable: we can no longer see activity or idleness, so
445 // any gap claim from here on would be guesswork. Drop all tracking instead.
446 st.active_since = None;
447 st.idle_eligible = false;
448 st.idle_gap = None;
449 return;
450 };
451
452 if let Some(mut gap) = st.idle_gap.take() {
453 // Holders must stay attached for the WHOLE gap; one that exits mid-gap is
454 // already narrated by process_exited — an idle_gap on top would double-count.
455 gap.holders.retain(|pid, _| st.procs.contains_key(pid));
456
457 if util < IDLE_ACTIVE_UTIL_PCT {
458 gap.util_sum += util as f64;
459 gap.util_n += 1;
460 st.idle_gap = Some(gap);
461 return;
462 }
463
464 // Gap over — its duration is finally known, so decide whether it narrates.
465 let dur_ms = sample.ts_ms.saturating_sub(gap.start_ms);
466 let holder = gap
467 .holders
468 .iter()
469 .max_by_key(|(_, (_, mem))| *mem)
470 .map(|(pid, (pname, mem))| (*pid, pname.clone(), *mem));
471 if dur_ms >= IDLE_GAP_MIN_MS && !gap.hang_narrated {
472 if let Some((pid, pname, mem)) = holder {
473 let dur = fmt_dur_ms(dur_ms);
474 let mean_util = gap.util_sum / gap.util_n.max(1) as f64;
475 out.push(Event {
476 ts_ms: sample.ts_ms,
477 device: device.clone(),
478 kind: EventKind::IdleGap,
479 severity: Severity::Info,
480 confidence: Confidence::Likely,
481 title: format!(
482 "{name} sat idle {dur} while {pname} (pid {pid}) stayed attached \
483 — likely a dataloader or checkpoint stall"
484 ),
485 evidence: format!(
486 "util {:.0}% → mean {mean_util:.1}% over {dur} ({}..{} ms); \
487 {pname} (pid {pid}) held {} for the whole gap",
488 gap.pre_util_pct,
489 gap.start_ms,
490 sample.ts_ms,
491 fmt_bytes(mem),
492 ),
493 });
494 }
495 }
496 // Recovery starts a fresh activity clock: the next gap only narrates after the
497 // device has re-earned IDLE_ACTIVE_MIN_MS of sustained work.
498 st.active_since = Some(sample.ts_ms);
499 st.idle_eligible = false;
500 return;
501 }
502
503 if util >= IDLE_ACTIVE_UTIL_PCT {
504 let since = *st.active_since.get_or_insert(sample.ts_ms);
505 if sample.ts_ms.saturating_sub(since) >= IDLE_ACTIVE_MIN_MS {
506 st.idle_eligible = true;
507 }
508 return;
509 }
510
511 st.active_since = None;
512 if util >= IDLE_GAP_UTIL_PCT || !st.idle_eligible {
513 return;
514 }
515 // Gap opens. Capture who is attached with a real allocation right now; only they
516 // can anchor the "stayed attached" claim when the gap ends.
517 let holders: HashMap<u32, (String, u64)> = st
518 .procs
519 .values()
520 .filter_map(|p| {
521 let mem = p.mem_bytes?;
522 (mem >= IDLE_HOLDER_MIN_BYTES).then(|| (p.pid, (p.name.clone(), mem)))
523 })
524 .collect();
525 st.idle_gap = Some(IdleGap {
526 start_ms: sample.ts_ms,
527 pre_util_pct: st.prev.as_ref().and_then(|p| p.util_pct).unwrap_or(util),
528 util_sum: util as f64,
529 util_n: 1,
530 holders,
531 hang_narrated: false,
532 });
533}
534
535/// `HangSuspected` — VRAM held, engines flat-dead, holder alive: the job has likely hung.
536///
537/// An inference of the riskiest kind, so the gate is steep: the device must be effectively
538/// idle (`≤ HANG_DEVICE_UTIL_PCT`), a holder must be sitting on ≥ 1 GiB while its *own*
539/// engine activity is also flat (or unreported), and that exact pattern must survive a full
540/// `HANG_MIN_MS` without a break before we narrate. We anchor to the largest qualifying
541/// holder at episode start and never re-anchor: the claim is that *this* allocation went
542/// quiet. The episode is dropped (never narrated) the instant any premise stops holding —
543/// the device wakes up, the holder exits, util goes unobservable, or even a sub-throttle
544/// flicker of activity — because a hang we cannot stand fully behind is worse than silence.
545fn hang_events(
546 st: &mut DevState,
547 device: &DeviceId,
548 name: &str,
549 sample: &DynamicSample,
550 out: &mut Vec<Event>,
551) {
552 let Some(util) = sample.util_pct else {
553 // Util unobservable: we cannot see "zero engine activity", so we cannot claim a
554 // hang. Drop the episode rather than freeze a stale window across the blind spot.
555 st.hang = None;
556 return;
557 };
558
559 // The largest holder that is itself quiet: ≥ 1 GiB resident with its own util flat or
560 // simply not reported (a hung kernel reports no per-process util — absence is expected).
561 let candidate = st
562 .procs
563 .values()
564 .filter(|p| p.mem_bytes.unwrap_or(0) >= HANG_HOLDER_MIN_BYTES)
565 .filter(|p| p.util_pct.map(|u| u <= HANG_PROC_UTIL_PCT).unwrap_or(true))
566 .max_by_key(|p| p.mem_bytes.unwrap_or(0));
567 let condition = util <= HANG_DEVICE_UTIL_PCT && candidate.is_some();
568
569 if let Some(mut ep) = st.hang.take() {
570 // The anchored holder must still be alive; if it exited, `process_exited` already
571 // told the story and the premise ("process still alive") is gone.
572 let holder_alive = st.procs.contains_key(&ep.holder_pid);
573 if util > HANG_RESET_UTIL_PCT || !holder_alive || !condition {
574 // Any break ends the episode silently — continuity is the whole claim.
575 return;
576 }
577 ep.util_sum += util as f64;
578 ep.util_n += 1;
579 let elapsed = sample.ts_ms.saturating_sub(ep.start_ms);
580 if elapsed >= HANG_MIN_MS && !ep.fired {
581 ep.fired = true;
582 let mean_util = ep.util_sum / ep.util_n.max(1) as f64;
583 let dur = fmt_dur_ms(elapsed);
584 out.push(Event {
585 ts_ms: sample.ts_ms,
586 device: device.clone(),
587 kind: EventKind::HangSuspected,
588 severity: Severity::Warning,
589 confidence: Confidence::Likely,
590 title: format!(
591 "{name}: {} (pid {}) likely hung — held {} for {dur} with zero GPU \
592 activity, process still alive",
593 ep.holder_name,
594 ep.holder_pid,
595 fmt_bytes(ep.holder_mem),
596 ),
597 evidence: format!(
598 "device util mean {mean_util:.1}% over {dur} ({}..{} ms); \
599 {} (pid {}) held {} throughout while its own engine activity stayed flat",
600 ep.start_ms,
601 sample.ts_ms,
602 ep.holder_name,
603 ep.holder_pid,
604 fmt_bytes(ep.holder_mem),
605 ),
606 });
607 // A hang is an idle gap that lasted too long to look alive; if a gap is still
608 // open over this same trough, mute it so one incident is narrated once.
609 if let Some(gap) = st.idle_gap.as_mut() {
610 gap.hang_narrated = true;
611 }
612 }
613 st.hang = Some(ep);
614 return;
615 }
616
617 if condition {
618 let holder = candidate.expect("condition implies a candidate");
619 st.hang = Some(HangEpisode {
620 start_ms: sample.ts_ms,
621 holder_pid: holder.pid,
622 holder_name: holder.name.clone(),
623 holder_mem: holder.mem_bytes.unwrap_or(0),
624 util_sum: util as f64,
625 util_n: 1,
626 fired: false,
627 });
628 }
629}
630
631/// `CpuSpillover` — a freshly-loaded model whose GPU stays idle while its process burns
632/// CPU: the signature of a partial CPU offload (the model did not fit in VRAM).
633///
634/// We open a fixed `SPILLOVER_WINDOW_MS` assessment when a *new* process attaches holding
635/// ≥ 2 GiB, then judge at window close: narrate only if the GPU averaged near-idle while the
636/// process averaged multiple busy cores, with enough CPU samples to mean it. The assessment
637/// is cancelled silently — never narrated — if the process exits mid-window, the GPU shows
638/// real use at any point, or (honesty rule) we never once saw its CPU: with no CPU
639/// visibility we cannot claim it is "burning CPU", so we say nothing rather than guess.
640/// The same honesty rule covers the GPU side: util must have been observed on at least
641/// half the window's ticks, else "the GPU is ~idle" would rest on no observation at all.
642fn spillover_events(
643 st: &mut DevState,
644 device: &DeviceId,
645 name: &str,
646 sample: &DynamicSample,
647 processes: &[ProcessSample],
648 out: &mut Vec<Event>,
649) {
650 let now: HashMap<u32, &ProcessSample> = processes.iter().map(|p| (p.pid, p)).collect();
651
652 // Open a window for each newly-attached big-memory holder. Skip the first observation:
653 // those processes were already resident, not freshly loaded, so they are no story.
654 if st.seen_first_procs {
655 for (pid, p) in &now {
656 if st.procs.contains_key(pid) || st.spillovers.contains_key(pid) {
657 continue;
658 }
659 if p.mem_bytes.unwrap_or(0) >= SPILLOVER_HOLDER_MIN_BYTES {
660 st.spillovers.insert(
661 *pid,
662 Spillover {
663 name: p.name.clone(),
664 mem_bytes: p.mem_bytes.unwrap_or(0),
665 start_ms: sample.ts_ms,
666 util_sum: 0.0,
667 util_n: 0,
668 cpu_sum: 0.0,
669 cpu_n: 0,
670 tick_n: 0,
671 },
672 );
673 }
674 }
675 }
676
677 if st.spillovers.is_empty() {
678 return;
679 }
680
681 // A device showing real use cancels every open assessment at once: the premise of the
682 // whole inference is that the GPU is idle, and one busy reading refutes it.
683 let gpu_busy = sample
684 .util_pct
685 .map(|u| u >= SPILLOVER_BUSY_UTIL_PCT)
686 .unwrap_or(false);
687
688 let mut to_emit: Vec<Event> = Vec::new();
689 st.spillovers.retain(|pid, sp| {
690 if gpu_busy {
691 return false;
692 }
693 let Some(p) = now.get(pid) else {
694 // Exited mid-window: cancelled silently (its `process_exited` fact stands).
695 return false;
696 };
697 sp.tick_n += 1;
698 if let Some(u) = sample.util_pct {
699 sp.util_sum += u as f64;
700 sp.util_n += 1;
701 }
702 if let Some(c) = p.cpu_pct {
703 sp.cpu_sum += c as f64;
704 sp.cpu_n += 1;
705 }
706
707 if sample.ts_ms.saturating_sub(sp.start_ms) < SPILLOVER_WINDOW_MS {
708 return true; // window still open
709 }
710
711 // Window closed — judge. Means require samples; no CPU sample at all means no CPU
712 // visibility, and we refuse to claim a CPU burn we never observed. Symmetrically,
713 // util must have been *observed* on at least half the window's ticks: a blind
714 // window (util None throughout) would otherwise mean 0% and fabricate "the GPU
715 // is ~idle" with zero actual GPU evidence.
716 let util_grounded =
717 sp.util_n > 0 && sp.util_n * 100 >= sp.tick_n * SPILLOVER_MIN_UTIL_COVERAGE_PCT;
718 let mean_util = sp.util_sum / sp.util_n.max(1) as f64;
719 let mean_cpu = sp.cpu_sum / sp.cpu_n.max(1) as f64;
720 if util_grounded
721 && sp.cpu_n >= SPILLOVER_MIN_CPU_SAMPLES
722 && mean_util < SPILLOVER_MAX_MEAN_UTIL_PCT
723 && mean_cpu >= SPILLOVER_MIN_MEAN_CPU_PCT
724 {
725 let span = fmt_dur_ms(sample.ts_ms.saturating_sub(sp.start_ms));
726 to_emit.push(Event {
727 ts_ms: sample.ts_ms,
728 device: device.clone(),
729 kind: EventKind::CpuSpillover,
730 severity: Severity::Warning,
731 confidence: Confidence::Likely,
732 title: format!(
733 "{} (pid {pid}) loaded {} but {name} is ~idle while its CPU runs hot \
734 — likely partial CPU offload (model may not fit in VRAM)",
735 sp.name,
736 fmt_bytes(sp.mem_bytes),
737 ),
738 evidence: format!(
739 "over {span} ({}..{} ms): {name} util mean {mean_util:.1}%, \
740 {} (pid {pid}) CPU mean {mean_cpu:.0}% of one core ({} samples)",
741 sp.start_ms, sample.ts_ms, sp.name, sp.cpu_n,
742 ),
743 });
744 }
745 false // window done either way
746 });
747 out.extend(to_emit);
748}
749
750fn vram_pressure_events(
751 st: &mut DevState,
752 device: &DeviceId,
753 name: &str,
754 sample: &DynamicSample,
755 mem_total: Option<u64>,
756 out: &mut Vec<Event>,
757) {
758 let (Some(used), Some(total)) = (sample.mem_used_bytes, mem_total) else {
759 return;
760 };
761 if total == 0 {
762 return;
763 }
764
765 // A sharp drop (process exit, allocator reset) invalidates the trend: an endpoint
766 // slope over a window straddling the old peak would understate the *current* climb
767 // rate — wrong in the dangerous direction. Restart the window instead.
768 if let Some(&(_, last_used)) = st.vram_window.back() {
769 if last_used.saturating_sub(used) > total / 20 {
770 st.vram_window.clear();
771 }
772 }
773
774 st.vram_window.push_back((sample.ts_ms, used));
775 while let Some(&(t0, _)) = st.vram_window.front() {
776 if sample.ts_ms.saturating_sub(t0) > VRAM_WINDOW_MS {
777 st.vram_window.pop_front();
778 } else {
779 break;
780 }
781 }
782
783 let frac = used as f64 / total as f64;
784 if frac < VRAM_PRESSURE_FRAC {
785 return;
786 }
787 let (&(t0, b0), &(t1, b1)) = match (st.vram_window.front(), st.vram_window.back()) {
788 (Some(a), Some(b)) if t_span(a, b) >= VRAM_MIN_SPAN_MS => (a, b),
789 _ => return,
790 };
791 let span_min = (t1 - t0) as f64 / 60_000.0;
792 let slope_per_min = (b1 as f64 - b0 as f64) / span_min;
793 if slope_per_min < VRAM_MIN_SLOPE_BYTES_PER_MIN {
794 return;
795 }
796 if let Some(last) = st.last_pressure_evt {
797 if sample.ts_ms.saturating_sub(last) < VRAM_COOLDOWN_MS {
798 return;
799 }
800 }
801 st.last_pressure_evt = Some(sample.ts_ms);
802
803 let headroom = total.saturating_sub(used) as f64;
804 let eta_min = headroom / slope_per_min;
805 // Only name a "largest holder" when at least one process has a *known* size —
806 // with mem_bytes all-None (WSL2, unprivileged fdinfo) max_by_key would crown an
807 // arbitrary process on zero evidence.
808 let grower = st
809 .procs
810 .values()
811 .filter(|p| p.mem_bytes.is_some())
812 .max_by_key(|p| p.mem_bytes)
813 .map(|p| format!(" (largest holder: {} pid {})", p.name, p.pid))
814 .unwrap_or_default();
815
816 out.push(Event {
817 ts_ms: sample.ts_ms,
818 device: device.clone(),
819 kind: EventKind::VramPressure,
820 severity: Severity::Warning,
821 confidence: Confidence::Likely,
822 title: format!(
823 "{name} VRAM {:.0}% and climbing ~{}/min — likely full in ~{:.0} min{grower}",
824 frac * 100.0,
825 fmt_bytes(slope_per_min as u64),
826 eta_min
827 ),
828 evidence: format!(
829 "used {}/{} ({:.1}%); slope +{}/min over last {:.1} min (linear extrapolation)",
830 fmt_bytes(used),
831 fmt_bytes(total),
832 frac * 100.0,
833 fmt_bytes(slope_per_min as u64),
834 span_min
835 ),
836 });
837}
838
839fn severity_for(t: &ThrottleReasons) -> Severity {
840 if t.hw_slowdown {
841 Severity::Critical
842 } else {
843 Severity::Warning
844 }
845}
846
847fn t_span(a: &(u64, u64), b: &(u64, u64)) -> u64 {
848 b.0.saturating_sub(a.0)
849}
850
851fn fmt_dur_ms(ms: u64) -> String {
852 // Round to nearest second — a 3.9s episode is "4s", not "3s".
853 let s = (ms + 500) / 1000;
854 if s >= 60 {
855 format!("{}m {}s", s / 60, s % 60)
856 } else {
857 format!("{s}s")
858 }
859}
860
861#[cfg(test)]
862mod tests {
863 //! CPU-spillover observed-util coverage floor — regression tests for the honesty rule
864 //! that "the GPU is ~idle" must rest on real util observations, never on a mean over
865 //! zero (or too few) samples. The broader event-derivation suite lives in `lib.rs`;
866 //! these mirror its synthetic 1 Hz trace style.
867
868 use super::*;
869 use crate::model::ProcessKind;
870
871 /// A 1 Hz sample whose util may be absent — exercises the coverage-floor paths.
872 fn opt_sample(ts_ms: u64, util_pct: Option<f32>) -> DynamicSample {
873 DynamicSample {
874 ts_ms,
875 util_pct,
876 util_engine: None,
877 mem_used_bytes: Some(8 << 30),
878 power_mw: None,
879 temp_c: None,
880 fan_pct: None,
881 sm_clock_mhz: None,
882 mem_clock_mhz: None,
883 encoder_pct: None,
884 decoder_pct: None,
885 throttle: Some(ThrottleReasons::default()),
886 }
887 }
888
889 /// Build a process holding `mem` bytes, with optional self-util and CPU%.
890 fn proc_with(
891 pid: u32,
892 name: &str,
893 mem: u64,
894 util_pct: Option<f32>,
895 cpu_pct: Option<f32>,
896 ) -> ProcessSample {
897 ProcessSample {
898 pid,
899 name: name.into(),
900 kind: ProcessKind::Compute,
901 mem_bytes: Some(mem),
902 util_pct,
903 cpu_pct,
904 container: None,
905 }
906 }
907
908 /// Drive a 1 Hz trace with an optional util value, holding `procs` constant across it.
909 fn drive_opt(
910 engine: &mut EventEngine,
911 dev: &DeviceId,
912 ts_range: std::ops::RangeInclusive<u64>,
913 util_pct: Option<f32>,
914 procs: &[ProcessSample],
915 ) -> Vec<Event> {
916 let mut out = Vec::new();
917 for ts in ts_range.step_by(1000) {
918 out.extend(engine.observe(
919 dev,
920 &opt_sample(ts, util_pct),
921 Some(procs),
922 Some(16 << 30),
923 None,
924 ));
925 }
926 out
927 }
928
929 /// Util `None` on every tick of the window: the source cannot observe the GPU at all.
930 /// Before the coverage floor, the mean read as 0/max(1) = 0% and passed the idle gate —
931 /// narrating "the GPU is ~idle" with zero actual GPU evidence. Must stay silent.
932 #[test]
933 fn spillover_silent_when_util_unobserved_all_window() {
934 let mut engine = EventEngine::new();
935 let dev = DeviceId("test".into());
936
937 // Baseline tick with no procs so the model reads as freshly attached.
938 drive_opt(&mut engine, &dev, 0..=0, None, &[]);
939 // Hot CPU the whole window, but device util is None throughout.
940 let ollama = vec![proc_with(7777, "ollama", 12 << 30, Some(0.0), Some(310.0))];
941 let out = drive_opt(&mut engine, &dev, 1_000..=91_000, None, &ollama);
942 assert!(
943 out.iter().all(|e| e.kind != EventKind::CpuSpillover),
944 "util never observed — the GPU-idle claim has no evidence and must stay silent"
945 );
946 }
947
948 /// Util observed on fewer than half the window's ticks (31 of 91), all of them low:
949 /// the observed mean would pass the idle gate, but the coverage floor refuses the
950 /// judgment — too much of the window is a blind spot to mean it.
951 #[test]
952 fn spillover_silent_when_util_coverage_below_half_window() {
953 let mut engine = EventEngine::new();
954 let dev = DeviceId("test".into());
955
956 drive_opt(&mut engine, &dev, 0..=0, None, &[]);
957 let ollama = vec![proc_with(7777, "ollama", 12 << 30, Some(0.0), Some(310.0))];
958 // Blind for the first 60 ticks, observed-low for the last 31.
959 let mut out = drive_opt(&mut engine, &dev, 1_000..=60_000, None, &ollama);
960 out.extend(drive_opt(
961 &mut engine,
962 &dev,
963 61_000..=91_000,
964 Some(5.0),
965 &ollama,
966 ));
967 assert!(
968 out.iter().all(|e| e.kind != EventKind::CpuSpillover),
969 "31 of 91 ticks observed is below half-window coverage — must stay silent"
970 );
971 }
972
973 /// Coverage just over the floor (46 of 91 ticks observed, all low) restores the claim:
974 /// the floor blocks blindness, not legitimate partial visibility.
975 #[test]
976 fn spillover_fires_once_coverage_reaches_half_window() {
977 let mut engine = EventEngine::new();
978 let dev = DeviceId("test".into());
979 engine.register_device(dev.clone(), "GPU0".into());
980
981 drive_opt(&mut engine, &dev, 0..=0, None, &[]);
982 let ollama = vec![proc_with(7777, "ollama", 12 << 30, Some(0.0), Some(310.0))];
983 // Blind for 45 ticks, observed-low for 46: 46/91 clears the half-window floor.
984 let mut out = drive_opt(&mut engine, &dev, 1_000..=45_000, None, &ollama);
985 out.extend(drive_opt(
986 &mut engine,
987 &dev,
988 46_000..=91_000,
989 Some(5.0),
990 &ollama,
991 ));
992 let n = out
993 .iter()
994 .filter(|e| e.kind == EventKind::CpuSpillover)
995 .count();
996 assert_eq!(
997 n, 1,
998 "46 of 91 ticks observed clears the floor — the grounded claim must narrate once"
999 );
1000 }
1001
1002 /// The fully-observed near-idle window still narrates: the floor must not silence the
1003 /// textbook case it exists to protect.
1004 #[test]
1005 fn spillover_still_fires_with_observed_low_util() {
1006 let mut engine = EventEngine::new();
1007 let dev = DeviceId("test".into());
1008 engine.register_device(dev.clone(), "GPU0".into());
1009
1010 drive_opt(&mut engine, &dev, 0..=0, Some(3.0), &[]);
1011 let ollama = vec![proc_with(7777, "ollama", 12 << 30, Some(0.0), Some(310.0))];
1012 let out = drive_opt(&mut engine, &dev, 1_000..=91_000, Some(5.0), &ollama);
1013 let n = out
1014 .iter()
1015 .filter(|e| e.kind == EventKind::CpuSpillover)
1016 .count();
1017 assert_eq!(
1018 n, 1,
1019 "fully-observed near-idle GPU plus hot CPU must still narrate exactly once"
1020 );
1021 }
1022}