spark_runtime/kernel_audit/
report.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Rendering half of the kernel audit: the embedded-kernel-set table and the
4//! unresolved-lookup report the boot gate prints.
5//!
6//! [`unresolved_report`] is PLAIN ASCII on purpose. It is read through
7//! `docker logs`, `journalctl` and a non-TTY pipe far more often than on a
8//! terminal, and colour/box-drawing survives none of them. The embedded-set
9//! table keeps its original box glyphs — gates grep it as it stands.
10
11use std::collections::BTreeMap;
12
13use super::{FailureSplit, split_failures};
14
15/// FNV-1a 64-bit content fingerprint → 12 hex chars (matches build.rs).
16fn ptx_hash(bytes: &[u8]) -> String {
17    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
18    for &b in bytes {
19        h ^= b as u64;
20        h = h.wrapping_mul(0x0000_0100_0000_01b3);
21    }
22    format!("{:012x}", h & 0xffff_ffff_ffff)
23}
24
25/// Render the embedded kernel set (`embedded` = the LOADED target's
26/// `TargetPtxSet::modules`) plus the runtime resolution overlay. `set_hash` is
27/// `atlas_kernels::KERNEL_SET_HASH`. `shadowed_dropped` drives the SHADOWED
28/// column; `expected_absent` is the target's MODEL.toml declaration and drives
29/// the required-vs-expected split (via [`super::classify_failures`] — this file
30/// does not own that rule).
31pub fn render_kernel_table(
32    embedded: &[(&str, &[u8])],
33    set_hash: &str,
34    shadowed_dropped: &[(&str, &str)],
35    expected_absent: &[(&str, &str)],
36) -> String {
37    let rows = super::audit_rows();
38    // Per-module resolution rollup: any-requested / any-loaded.
39    let mut mod_resolved: BTreeMap<&str, bool> = BTreeMap::new();
40    for r in &rows {
41        let e = mod_resolved.entry(r.module.as_str()).or_insert(false);
42        *e = *e || r.loaded;
43    }
44
45    let mut out = String::new();
46    out.push_str(&format!(
47        "\n\u{250c}\u{2500} Kernel load audit \u{2500} {} kernels embedded \u{b7} \
48         set-hash {} \u{2500}\n",
49        embedded.len(),
50        set_hash
51    ));
52    out.push_str(&format!(
53        "\u{2502} {:<34} {:<14} {:<20} {}\n",
54        "MODULE (operation)", "PTX-HASH", "RESOLUTION", "SHADOWED"
55    ));
56    out.push_str(&format!("\u{2502} {}\n", "\u{2500}".repeat(84)));
57    let mut sorted: Vec<&(&str, &[u8])> = embedded.iter().collect();
58    sorted.sort_by_key(|(m, _)| *m);
59    for (m, blob) in sorted {
60        // Blob is the raw kernel bytes (PTX text or AMD/Metal binary);
61        // FNV-1a over the bytes directly — matches build.rs's set hash.
62        let h = ptx_hash(blob);
63        let res = match mod_resolved.get(m) {
64            Some(true) => "used",
65            Some(false) => "** lookup FAILED **",
66            None => "-", // embedded but not requested by this model's dispatch
67        };
68        // Y when this model's fork of the file dropped one or more kernels that
69        // `common/` defines — the module compiled, but not everything in it.
70        let n_dropped = shadowed_dropped.iter().filter(|(sm, _)| sm == m).count();
71        let shadow = if n_dropped > 0 {
72            format!("Y ({n_dropped} dropped)")
73        } else {
74            "N".to_string()
75        };
76        out.push_str(&format!("\u{2502} {m:<34} {h:<14} {res:<20} {shadow}\n"));
77    }
78    out.push_str("\u{2514}\u{2500}\n");
79
80    let split = split_failures(&rows, expected_absent);
81    if !split.expected.is_empty() {
82        // Informational only: this target's MODEL.toml declares each of these
83        // absent WITH A REASON, so nothing here is an action item.
84        out.push_str(&format!(
85            "\n{} kernel(s) declared expected-absent in this target's MODEL.toml \
86             [expected_absent] (no action):\n    {}\n",
87            split.expected.len(),
88            split
89                .expected
90                .iter()
91                .map(|r| r.name())
92                .collect::<Vec<_>>()
93                .join(", ")
94        ));
95    }
96    out
97}
98
99/// The unresolved-lookup report: the enumerated list FIRST, then the
100/// remediation block ONCE at the end.
101///
102/// `allowed` selects the closing paragraph — the offer to pass the flag when
103/// the boot is about to fail, and the bare consequence when the flag is already
104/// set. There is no third mode: a flag that MUTES the warning would recreate
105/// exactly the bug this gate exists to catch.
106pub fn unresolved_report(
107    split: &FailureSplit,
108    shadowed_dropped: &[(&str, &str)],
109    model: &str,
110    arch: &str,
111    quant: &str,
112    allowed: bool,
113) -> String {
114    let mut out = format!(
115        "{} unresolved kernel lookup(s) for ({model}, {arch}, {quant}). Each one resolved to \
116         handle 0, so its dispatch site is on a silent fallback path:\n",
117        split.required.len()
118    );
119    for (i, r) in split.required.iter().enumerate() {
120        let n = i + 1;
121        let name = r.name();
122        let dropped = shadowed_dropped
123            .iter()
124            .any(|(sm, sf)| *sm == r.module.as_str() && *sf == r.func.as_str());
125        // A dropped kernel is a BUILD defect with a known fix, so say so on the
126        // line rather than leaving it to be guessed from the table above.
127        let note = if dropped {
128            "  [SHADOW-DROPPED: common/ defines it, this target's kernel file shadows common/ \
129             without it - port it in as an exact piecewise copy]"
130        } else {
131            ""
132        };
133        out.push_str(&format!(
134            "  {n}. {name}  at {}:{}  ({model}, {arch}, {quant}){note}\n",
135            r.site.file(),
136            r.site.line(),
137        ));
138    }
139    out.push_str(
140        "\nEither gate the lookup on this model's config so it is never issued (see \
141         `qwen3_attention::init`), fix the build so the kernel is compiled, or declare it in \
142         this target's MODEL.toml [expected_absent] with a stated reason.\n",
143    );
144    if allowed {
145        out.push_str(
146            "\nPerformance may be seriously degraded. We recommend you open a GitHub issue \
147             and/or open a PR to solve this issue.\n",
148        );
149    } else {
150        out.push_str(
151            "\nIf you wish to allow this model to be served, you can pass\n\
152             --dangerously-allow-unresolved-kernel-lookups. But note that performance may be\n\
153             seriously degraded. We recommend you open a GitHub issue and/or open a PR to\n\
154             solve this issue.\n",
155        );
156    }
157    out
158}
159
160#[cfg(test)]
161mod tests {
162    use super::super::AuditRow;
163    use super::*;
164
165    fn row(module: &str, func: &str) -> AuditRow {
166        AuditRow {
167            module: module.to_string(),
168            func: func.to_string(),
169            loaded: false,
170            site: std::panic::Location::caller(),
171        }
172    }
173
174    /// The two closing paragraphs are owner-specified text. A flag that muted
175    /// the warning would recreate the bug, so assert both modes still print an
176    /// enumerated list and a remediation paragraph.
177    #[test]
178    fn the_report_enumerates_then_remediates_once() {
179        let split = FailureSplit {
180            required: vec![row("gdn", "gdn_decode_multi_seq")],
181            expected: vec![],
182        };
183        let deny = unresolved_report(&split, &[], "qwen3.6-27b", "sm_121", "nvfp4", false);
184        assert!(deny.starts_with("1 unresolved kernel lookup(s)"));
185        assert!(deny.contains("  1. gdn::gdn_decode_multi_seq"));
186        assert!(deny.contains("--dangerously-allow-unresolved-kernel-lookups"));
187        assert_eq!(deny.matches("open a GitHub issue").count(), 1);
188
189        let allow = unresolved_report(&split, &[], "qwen3.6-27b", "sm_121", "nvfp4", true);
190        assert!(allow.contains("  1. gdn::gdn_decode_multi_seq"));
191        assert!(
192            !allow.contains("If you wish to allow"),
193            "the flag is already set; do not offer it again"
194        );
195        assert_eq!(allow.matches("open a GitHub issue").count(), 1);
196    }
197
198    /// Plain ASCII: these lines are read through `docker logs` and non-TTY
199    /// pipes far more often than on a terminal.
200    #[test]
201    fn the_report_is_plain_ascii() {
202        let split = FailureSplit {
203            required: vec![row("gdn", "gdn_decode_multi_seq")],
204            expected: vec![],
205        };
206        let dropped = [("gdn", "gdn_decode_multi_seq")];
207        let text = unresolved_report(&split, &dropped, "m", "a", "q", false);
208        assert!(text.is_ascii(), "report must survive a non-TTY pipe");
209        assert!(text.contains("SHADOW-DROPPED"));
210    }
211
212    #[test]
213    fn expected_absent_never_lands_in_the_required_list() {
214        let rows = vec![row("mla_absorbed", "mla_batched_gemv"), row("gdn", "x")];
215        let split = split_failures(&rows, &[("mla_absorbed", "mla_batched_gemv")]);
216        assert_eq!(split.required.len(), 1);
217        assert_eq!(split.required[0].module, "gdn");
218        assert_eq!(split.expected.len(), 1);
219    }
220    /// The remediation sentence is a SPECIFIED requirement, quoted verbatim.
221    ///
222    /// ★ The existing tests check that the flag name appears and that the
223    /// GitHub line appears once — both of which survive a reword. The exact
224    /// wording was asked for, so it is pinned here as one normalised string.
225    /// Whitespace is collapsed because the source wraps the sentence across
226    /// lines for readability; that is formatting, not content.
227    #[test]
228    fn the_remediation_wording_is_the_one_that_was_asked_for() {
229        let split = FailureSplit {
230            required: vec![row("gdn", "gdn_decode_multi_seq")],
231            expected: vec![],
232        };
233        let deny = unresolved_report(&split, &[], "qwen3.6-27b", "sm_121", "nvfp4", false);
234        let flat = deny.split_whitespace().collect::<Vec<_>>().join(" ");
235        let required = "If you wish to allow this model to be served, you can pass \
236                        --dangerously-allow-unresolved-kernel-lookups. But note that \
237                        performance may be seriously degraded. We recommend you open a \
238                        GitHub issue and/or open a PR to solve this issue.";
239        let required = required.split_whitespace().collect::<Vec<_>>().join(" ");
240        assert!(
241            flat.contains(&required),
242            "the remediation sentence has drifted from the specified wording.\n\
243             wanted: {required}\n\
244             got:    {flat}"
245        );
246    }
247}