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}