spark_runtime/kernel_audit.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Startup kernel-resolution audit + embedded-kernel-set table.
4//!
5//! Two halves, both printed once at model-load time:
6//! 1. The EMBEDDED kernel set — every `(module, ptx)` compiled into this
7//! binary, with a per-kernel PTX content hash and the overall kernel-set
8//! hash. The count here is ground truth (e.g. 98 vs 99 modules), and the
9//! hashes pin exactly which kernel binary is loaded — so a stale/dropped
10//! kernel from a build-codegen regression is visible at a glance.
11//! 2. The RESOLUTION audit — every `GpuBackend::kernel(module, func)` lookup,
12//! whether it resolved, and WHERE it was issued from. A MISSING optional
13//! kernel (`try_kernel` → handle 0) silently falls back to a slower
14//! dispatch path with no error; this surfaces it (see the 2026-06-04
15//! pipelined-GEMM regression where `w8a16_gemm_pipelined` resolved to 0
16//! and QKVZ fell back to the ~4.6× slower `w8a16_gemm`).
17//!
18//! Every kernel lookup in Atlas is EAGER: each one sits in a constructor on the
19//! `serve_phases::build_model` path, so by the time the model is built the
20//! audit holds the COMPLETE `(module, func)` set this model asks for. That is
21//! what makes [`seal`] meaningful — after it, a lookup is by definition a late
22//! one, and a late MISS is a silent slow path nobody would ever see. Sealing
23//! turns the invariant from a belief into an assertion.
24
25mod report;
26
27pub use report::{render_kernel_table, unresolved_report};
28
29use std::collections::BTreeMap;
30use std::panic::Location;
31use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
32
33// The audit vector is a field of the single run mailbox,
34// `crate::run_metrics::RunMetrics`. It is per-model in the sharpest way —
35// it lists which of THIS model's registry modules resolved — so without
36// the run-start clear a swap would leave the dashboard's kernel table
37// showing both models' modules with no way to tell them apart.
38
39/// True once the boot gate has run and passed. See [`seal`].
40static SEALED: AtomicBool = AtomicBool::new(false);
41/// `--dangerously-allow-unresolved-kernel-lookups`, as handed to [`seal`].
42static ALLOW_UNRESOLVED: AtomicBool = AtomicBool::new(false);
43/// Unresolved lookups for the live model: the gate's count, plus any late
44/// miss recorded after the seal. Exported as `atlas_kernel_lookups_unresolved`.
45static UNRESOLVED: AtomicU64 = AtomicU64::new(0);
46/// One-shot latch so a late miss inside a hot loop warns once, not per token.
47static LATE_WARNED: AtomicBool = AtomicBool::new(false);
48
49/// One deduped kernel-resolution row.
50///
51/// `site` is the DISPATCH SITE — `file:line` of the `.kernel(…)` /
52/// `try_kernel(…)` call, captured through `#[track_caller]`. A bare
53/// `module::func` list is not actionable: the same module name is looked up
54/// from a dozen constructors, and the fix is always "go to that line".
55#[derive(Clone, Debug)]
56pub struct AuditRow {
57 pub module: String,
58 pub func: String,
59 /// True if ANY lookup of this `(module, func)` resolved.
60 pub loaded: bool,
61 /// Dispatch site of the first lookup of this pair.
62 pub site: &'static Location<'static>,
63}
64
65impl AuditRow {
66 /// `module::func` — the name the log table and the TUI both print.
67 pub fn name(&self) -> String {
68 format!("{}::{}", self.module, self.func)
69 }
70}
71
72/// Record one kernel lookup. Cheap; called from `GpuBackend::kernel`.
73///
74/// `site` is the caller's `Location`, which the backend obtains from its own
75/// `#[track_caller]` frame — this function cannot take it implicitly, because
76/// its own caller is the backend, not the dispatch site.
77pub fn record(module: &str, func: &str, loaded: bool, site: &'static Location<'static>) {
78 if !loaded && SEALED.load(Ordering::Acquire) {
79 late_miss(module, func, site);
80 }
81 if let Ok(mut v) = crate::run_metrics::metrics().kernel_audit.lock() {
82 v.push((module.to_string(), func.to_string(), loaded, site));
83 }
84}
85
86/// A kernel lookup that FAILED after the boot gate had already passed.
87///
88/// This can only happen if a lookup is not eager — i.e. some dispatch path
89/// resolves a kernel lazily, on the first request that needs it. That is
90/// precisely the case the boot gate cannot see, so it must be loud here or it
91/// is invisible forever: the caller takes a silent slow path and the only
92/// symptom is a throughput number nobody has a baseline for.
93fn late_miss(module: &str, func: &str, site: &'static Location<'static>) {
94 UNRESOLVED.fetch_add(1, Ordering::Relaxed);
95 if ALLOW_UNRESOLVED.load(Ordering::Relaxed) {
96 if !LATE_WARNED.swap(true, Ordering::Relaxed) {
97 tracing::warn!(
98 "kernel lookup {module}::{func} at {}:{} failed AFTER the boot audit sealed. \
99 This lookup is not eager, so the boot gate could not see it. Continuing \
100 because --dangerously-allow-unresolved-kernel-lookups was passed. \
101 Performance may be seriously degraded. We recommend you open a GitHub issue \
102 and/or open a PR to solve this issue. \
103 (atlas_kernel_lookups_unresolved counts every occurrence.)",
104 site.file(),
105 site.line(),
106 );
107 }
108 return;
109 }
110 // Abort, not panic: a panic here unwinds one scheduler/request thread and
111 // leaves a half-serving process behind, which is the same silent
112 // degradation this gate exists to stop. FAIL LOUDLY.
113 tracing::error!(
114 "kernel lookup {module}::{func} at {}:{} failed AFTER the boot audit sealed — a \
115 non-eager lookup that the boot gate could not see. The dispatch that asked for it is \
116 now on a silent fallback path. Aborting. Pass \
117 --dangerously-allow-unresolved-kernel-lookups to downgrade this to a warning.",
118 site.file(),
119 site.line(),
120 );
121 std::process::abort();
122}
123
124/// Close the audit for this run: the boot gate has run and every eager lookup
125/// has been made. `unresolved` is the gate's own count (rows that failed and
126/// were not classified expected-absent); `allow` is
127/// `--dangerously-allow-unresolved-kernel-lookups`.
128pub fn seal(unresolved: u64, allow: bool) {
129 UNRESOLVED.store(unresolved, Ordering::Relaxed);
130 ALLOW_UNRESOLVED.store(allow, Ordering::Relaxed);
131 LATE_WARNED.store(false, Ordering::Relaxed);
132 SEALED.store(true, Ordering::Release);
133}
134
135/// Re-open the audit for a new model load. Called from
136/// [`crate::run_metrics::reset_for_new_run`], which is where a run begins —
137/// the next model runs its own eager lookups and gets its own gate.
138pub fn unseal() {
139 SEALED.store(false, Ordering::Release);
140 UNRESOLVED.store(0, Ordering::Relaxed);
141 LATE_WARNED.store(false, Ordering::Relaxed);
142}
143
144/// Unresolved kernel lookups for the live model. Exported on `/metrics` as
145/// `atlas_kernel_lookups_unresolved` so a gate can assert `== 0` without
146/// parsing logs.
147pub fn unresolved_lookups() -> u64 {
148 UNRESOLVED.load(Ordering::Relaxed)
149}
150
151/// Structured resolution rows for observers (log table, TUI kernel table, the
152/// boot gate): deduped `(module, func)`, sorted, `loaded` true if ANY lookup of
153/// that pair resolved.
154pub fn audit_rows() -> Vec<AuditRow> {
155 let mut resolved: BTreeMap<(String, String), (bool, &'static Location<'static>)> =
156 BTreeMap::new();
157 if let Ok(v) = crate::run_metrics::metrics().kernel_audit.lock() {
158 for (m, f, ok, site) in v.iter() {
159 let e = resolved
160 .entry((m.clone(), f.clone()))
161 .or_insert((false, *site));
162 e.0 = e.0 || *ok;
163 }
164 }
165 resolved
166 .into_iter()
167 .map(|((module, func), (loaded, site))| AuditRow {
168 module,
169 func,
170 loaded,
171 site,
172 })
173 .collect()
174}
175
176/// The failed lookups, split by whether the operator must act.
177///
178/// SSOT: the log table, the TUI kernel table and the boot gate all read this
179/// one function. Reporting the two classes as a single list is what let the
180/// 27B ship with concurrent decode silently disabled — the four dropped GDN
181/// kernels sat among ~26 entries for architectures the model does not have, so
182/// the whole warning read as benign and everyone learned to skip it. A warning
183/// that is almost always noise trains people to ignore the one time it is not.
184#[derive(Clone, Debug, Default)]
185pub struct FailureSplit {
186 /// Actionable. Nothing declared these absent, so either the model's
187 /// dispatch should not have asked (gate it on config, see
188 /// `qwen3_attention::init`) or the kernel should have been compiled.
189 pub required: Vec<AuditRow>,
190 /// Declared in this target's MODEL.toml `[expected_absent]`, each with a
191 /// stated reason. Informational; never fatal.
192 pub expected: Vec<AuditRow>,
193}
194
195/// Split [`audit_rows`]'s failures against a target's `[expected_absent]`
196/// declaration (`TargetPtxSet::expected_absent`).
197pub fn classify_failures(expected_absent: &[(&str, &str)]) -> FailureSplit {
198 split_failures(&audit_rows(), expected_absent)
199}
200
201/// [`classify_failures`] over rows the caller already has.
202pub fn split_failures(rows: &[AuditRow], expected_absent: &[(&str, &str)]) -> FailureSplit {
203 let (expected, required): (Vec<AuditRow>, Vec<AuditRow>) =
204 rows.iter().filter(|r| !r.loaded).cloned().partition(|r| {
205 expected_absent
206 .iter()
207 .any(|(em, ef)| *em == r.module.as_str() && *ef == r.func.as_str())
208 });
209 FailureSplit { required, expected }
210}