spark_model/layers/w4a16_gemv_tiers.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! SSOT for the narrow `w4a16_gemv_batch{M}` tier family (M = 4..8).
4//!
5//! # Why this module exists
6//!
7//! `w4a16_gemv_batchm_impl<MAX_M>` sizes `acc[]`, `s_vl[]` and `smem[]` by
8//! `MAX_M`, and because its row loop is `#pragma unroll`ed, `MAX_M` also sizes
9//! the CODE: at 80 static SASS instructions per row on sm_121f, the MAX_M=8
10//! tier is 760 instructions against MAX_M=4's 440. The `t >= M` guard skips a
11//! dead row's WORK at run time but not its instructions, and the template is
12//! issue-bound (not DRAM-bound) at these M — so running M=5 on the MAX_M=8
13//! tier pays for three rows that are not there.
14//!
15//! Measured on the real 27B qkv/o shape (N=5120 K=5120, cold-cycled weights,
16//! 273 GB/s peak) before the exact-M tiers existed:
17//!
18//! | tier | M | time | eff BW | % peak |
19//! |--------|---|----------|------------|--------|
20//! | batch4 | 4 | 70.5 us | 209.2 GB/s | 76.6% |
21//! | batch8 | 4 | 74.8 us | 197.3 GB/s | 72.3% |
22//! | batch8 | 5 | 89.0 us | 165.7 GB/s | 60.7% |
23//! | batch8 | 6 | 91.5 us | 161.2 GB/s | 59.0% |
24//! | batch8 | 8 | 106.4 us | 138.5 GB/s | 50.7% |
25//!
26//! The `batch8 @ M=4` row is the argument: same rows, same weight stream,
27//! +6.1% for nothing. It is NOT occupancy — batch4 lands on 48 registers /
28//! 5 CTA per SM with no `__launch_bounds__` at all, which is exactly what the
29//! pragma pins batch8 to.
30//!
31//! # Why a shared table instead of a `match` per call site
32//!
33//! Before this module, FIVE structs each carried a `w4a16_gemv_batch4_k` /
34//! `w4a16_gemv_batch8_k` pair and each re-derived `1..=4 => batch4,
35//! 5..=8 => batch8` inline (dense_ffn, qwen3_ssm x2, qwen3_attention, mtp_head,
36//! model). Adding three tiers would have meant five more copies of a widening
37//! decision. The decision now lives here once, as a PURE function over which
38//! tiers the loaded target actually resolved.
39//!
40//! # Kill switch
41//!
42//! `ATLAS_NO_GEMV_EXACT_M_TIERS=1` (presence-checked per the house convention;
43//! `=0` is NOT off) hides widths 5/6/7 from the decision, restoring exactly the
44//! batch4/batch8 dispatch that shipped before them. It does not unload the
45//! kernels — it only removes them from selection, so an A/B needs no rebuild.
46
47use spark_runtime::gpu::{GpuBackend, KernelHandle};
48
49/// Tier widths in this family, narrowest first. Parallel to the `handles`
50/// field of [`W4a16BatchmTiers`] and to the `present` array of
51/// [`select_tier`].
52///
53/// Deliberately stops at 8. `w4a16_gemv_batch16`/`_batch32` exist but are NOT
54/// in this table: only two call sites can use them, they are wide-tier trades
55/// with their own (measured) `__launch_bounds__`-free codegen, and folding them
56/// in here would silently widen every site that today caps at 8.
57pub const W4A16_BATCHM_WIDTHS: [u32; 5] = [4, 5, 6, 7, 8];
58
59/// Index into [`W4A16_BATCHM_WIDTHS`] of the first EXACT-M tier (width 5).
60/// Widths below this index shipped before the exact-M tiers and are never
61/// hidden by the kill switch.
62const FIRST_EXACT_M: usize = 1;
63
64/// Widths hidden by `ATLAS_NO_GEMV_EXACT_M_TIERS=1`: the tiers added by this
65/// change. 8 is NOT hidden — it is the pre-existing M=5..8 tier.
66const EXACT_M_LAST: usize = 3;
67
68/// Are the exact-M tiers (5/6/7) allowed in the dispatch decision?
69///
70/// PRESENCE check, read once per process: this predicate sits on the decode
71/// path and is consulted per projection launch.
72pub fn exact_m_tiers_enabled() -> bool {
73 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
74 *ON.get_or_init(|| std::env::var_os("ATLAS_NO_GEMV_EXACT_M_TIERS").is_none())
75}
76
77/// PURE tier decision: index into [`W4A16_BATCHM_WIDTHS`] of the narrowest
78/// tier that both COVERS `m` rows and is present in the loaded target, or
79/// `None` when this family cannot serve `m` (caller falls back to the tile
80/// GEMMs / the wide tiers).
81///
82/// `present[i]` is whether `W4A16_BATCHM_WIDTHS[i]` resolved. `exact_m` is
83/// [`exact_m_tiers_enabled`], threaded in rather than read here so both
84/// polarities are testable without touching process env or a latched
85/// `OnceLock`.
86///
87/// Narrowest-that-covers, not exact-only: a target missing a tier must still
88/// dispatch, and the next wider tier is bit-identical at the same `m` (same
89/// template, `MAX_M`-independent per-row FMA chain) — only slower.
90pub fn select_tier(
91 m: u32,
92 present: [bool; W4A16_BATCHM_WIDTHS.len()],
93 exact_m: bool,
94) -> Option<usize> {
95 if m == 0 {
96 return None;
97 }
98 W4A16_BATCHM_WIDTHS
99 .iter()
100 .enumerate()
101 .find(|&(i, &w)| {
102 w >= m && present[i] && (exact_m || !(FIRST_EXACT_M..=EXACT_M_LAST).contains(&i))
103 })
104 .map(|(i, _)| i)
105}
106
107/// Resolved handles for the narrow `w4a16_gemv_batch{M}` family.
108///
109/// A zero handle means "this target did not load that tier"; every consumer
110/// must gate on `.0 != 0` exactly as it did with the individual fields, and
111/// [`Self::kernel`] returns a zero handle rather than panicking when nothing
112/// in the family covers `m`.
113#[derive(Clone, Copy, Debug)]
114pub struct W4a16BatchmTiers {
115 /// Parallel to [`W4A16_BATCHM_WIDTHS`].
116 handles: [KernelHandle; W4A16_BATCHM_WIDTHS.len()],
117}
118
119/// `KernelHandle` has no `Default`, so the "no NVFP4 kernels" state is spelled
120/// out: an all-zero table, which every consumer already treats as "decline".
121impl Default for W4a16BatchmTiers {
122 fn default() -> Self {
123 Self {
124 handles: [KernelHandle(0); W4A16_BATCHM_WIDTHS.len()],
125 }
126 }
127}
128
129impl W4a16BatchmTiers {
130 /// Resolve every tier in the family. Misses are silent zero handles —
131 /// tiers 5/6/7 are absent from any target built before they existed, and
132 /// dispatch degrades to the pre-existing batch4/batch8 decision.
133 pub fn resolve(gpu: &dyn GpuBackend) -> Self {
134 let mut handles = [KernelHandle(0); W4A16_BATCHM_WIDTHS.len()];
135 for (h, w) in handles.iter_mut().zip(W4A16_BATCHM_WIDTHS) {
136 // Width 8 resolves through the rt2-preferring helper: the
137 // register-tiled T=2 variant is bit-exact vs classic batch8
138 // (same per-row FMA chain; batchm_bench gate 4) and carries its
139 // own kill switch (`ATLAS_NO_BATCH8_RT=1`). All five tier
140 // consumers inherit the preference from this one site.
141 *h = if w == 8 {
142 super::batch8_kernel(gpu)
143 } else {
144 super::try_kernel(gpu, "w4a16_gemv", &format!("w4a16_gemv_batch{w}"))
145 };
146 }
147 Self { handles }
148 }
149
150 /// Which tiers this target resolved — the `present` argument of
151 /// [`select_tier`].
152 fn present(&self) -> [bool; W4A16_BATCHM_WIDTHS.len()] {
153 self.handles.map(|h| h.0 != 0)
154 }
155
156 /// Narrowest resolved tier covering `m` rows, or `KernelHandle(0)` when
157 /// this family cannot serve `m`.
158 pub fn kernel(&self, m: u32) -> KernelHandle {
159 select_tier(m, self.present(), exact_m_tiers_enabled())
160 .map_or(KernelHandle(0), |i| self.handles[i])
161 }
162
163 /// Width of the tier [`Self::kernel`] would pick, for logging and tests.
164 pub fn width(&self, m: u32) -> Option<u32> {
165 select_tier(m, self.present(), exact_m_tiers_enabled()).map(|i| W4A16_BATCHM_WIDTHS[i])
166 }
167
168 /// Is the BASE (`w4a16_gemv_batch4`) tier resolved? Capability probes that
169 /// ask "can this build do NVFP4 batched decode at all" mean this one
170 /// specifically — it is the tier every target has carried.
171 pub fn has_base(&self) -> bool {
172 self.handles[0].0 != 0
173 }
174}
175
176#[cfg(test)]
177#[path = "w4a16_gemv_tiers_tests.rs"]
178mod w4a16_gemv_tiers_tests;