spark_model/layers/qwen3_ssm/
gdn_flags.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GDN / SSM decode-path flags, resolved ONCE from the serve command line.
4//!
5//! These three select KERNELS on the GDN decode path, and they are coupled:
6//! the FP16 h-state twins only exist on the fused-norm arm, so `h_f16` without
7//! `fused_norm` reaches an FP32-only kernel that would read the FP16 pool as
8//! FP32 — plausible numbers, silent garbage. That coupling is checked at serve
9//! time by `spark-server`'s arg validation, not discovered at the first decode
10//! step.
11//!
12//! ## Why these are set, not read
13//!
14//! They were three independent `std::env::var` reads scattered across six call
15//! sites, each with its own convention (`ATLAS_SSM_H_FP16` presence-gated —
16//! where `=0` meant ON — and the other two `== "1"`). That is how the same
17//! flag came to be decoded two different ways in one binary. They are now ONE
18//! cell, written once from [`set_from_cli`] before any model is built.
19//!
20//! The environment variables remain honoured when the setter never runs (a
21//! test, a microbenchmark example, an older script), so nothing that worked
22//! before stops working; the CLI wins when both are present.
23//!
24//! Follow-up: this is process-scoped, so a hot-swap to a model with a
25//! different recipe keeps the first model's kernel selection. The proper home
26//! is `ModelLevers`, which is carried per model — deferred because the h-state
27//! dtype is read from `SsmLayerState` construction sites that have no
28//! `ForwardContext`.
29
30/// The resolved flags. `None` until `set_from_cli` or the first env fallback.
31static FLAGS: std::sync::OnceLock<GdnFlags> = std::sync::OnceLock::new();
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub struct GdnFlags {
35    /// `--ssm-h-dtype f16`: store the GDN decode h-state as FP16.
36    pub h_f16: bool,
37    /// Stage 3 of the f16 h-state: additionally SIZE the h pools at 2 bytes
38    /// per element. Must imply `h_f16` (a narrow pool holding FP32 would be
39    /// an OOB write, not a mode). NOT serveable yet and therefore has NO
40    /// CLI surface — the CLI mapping always publishes `false`, and
41    /// `ssm_h_fp16_preconditions` refuses it besides (defense in depth) —
42    /// but the sizing plumbing keys off THIS field so the pool, preflight
43    /// and every byte-copier already agree on the storage width when
44    /// prefill narrowing lands.
45    pub h_f16_pool: bool,
46    /// `--gdn-fused-norm`: fused GDN output-norm decode kernel.
47    pub fused_norm: bool,
48    /// `--ssm-batched-recurrent`: one strided recurrent launch per batch.
49    pub batched_recurrent: bool,
50    /// `--exact-verify`: run the sequential-decode-EXACT per-token MTP-verify
51    /// chain (issue #435 route (a)) instead of the default WY-chunkwise /
52    /// fused BF16-conv arms. OPT-IN, default OFF; the measured decode-step
53    /// cost (~+22-36% at the n=8/16/32 verify rungs) is why.
54    ///
55    /// SCOPE: this makes the GDN/SSM verify chain exact. It does NOT deliver
56    /// end-to-end spec-on == spec-off, because every FFN and attention
57    /// projection dispatches on ROW COUNT (verify K=4 takes
58    /// `w4a16_gemv_batch4`, decode takes `w4a16_gemv`) and those separate
59    /// implementations round differently — ~5e-5 of lanes by 1 ULP, on every
60    /// shape measured (#459). Closing that needs single-row routing for the
61    /// whole verify forward, which is future work.
62    ///
63    /// ★ Attribution warning, learned the hard way: a 2026-08-21 measurement
64    /// showed gross output degeneration (video-fidelity 0/2, 0/4 at C=2/C=4)
65    /// that this flag appeared to fix. The real cause was the K=4 verdict
66    /// rewind bug (#699); this flag only changed dispatch so the bug stopped
67    /// firing. The 1-ULP divergence this flag actually closes has never been
68    /// shown to cause more than an occasional flipped token at temperature 0.
69    /// If flipping this flag changes gross behavior, suspect a dispatch-
70    /// sensitive scheduler bug first. Details on `ServeArgs::exact_verify`.
71    pub exact_verify: bool,
72}
73
74impl GdnFlags {
75    /// Whether the MTP-verify pass must run the sequential-decode-exact
76    /// conv+GDN chain (issue #435 route (a)). Default FALSE: exact verify is
77    /// opt-in via `--exact-verify`, so with default settings spec-on output
78    /// is NOT bitwise-equal to spec-off (the #435 divergence ships).
79    ///
80    /// Pure so it is testable without touching the process-global flags cell.
81    /// `h_f16` forces non-exact even when requested, because an FP16 h-state
82    /// is a whole-chain numerics change that is not bit-comparable to the
83    /// FP32 reference in the first place, and the exact arm's kernels are
84    /// FP32 readers (reading the FP16 pool through them would be silent
85    /// garbage, not an error). CLI validation additionally REJECTS the
86    /// explicit pair, so this clause is defense in depth, not the interface.
87    pub fn verify_exact_active(self) -> bool {
88        self.exact_verify && !self.h_f16
89    }
90    /// The legacy environment reading, used when the CLI never set anything.
91    ///
92    /// `ATLAS_SSM_H_FP16` stays PRESENCE-gated here on purpose: that is how
93    /// every script and ledger in the campaign wrote it, and silently changing
94    /// `=0` from ON to OFF would retroactively re-label measurements. New
95    /// configuration should use `--ssm-h-dtype`.
96    fn from_env() -> Self {
97        Self {
98            h_f16: std::env::var("ATLAS_SSM_H_FP16").is_ok(),
99            // No environment fallback on purpose (house rule: no new env
100            // knobs) — stage 3 has no CLI surface either until prefill
101            // narrowing lands; only unit tests exercise the sizing.
102            h_f16_pool: false,
103            fused_norm: std::env::var("ATLAS_GDN_FUSED_NORM").as_deref() == Ok("1"),
104            batched_recurrent: std::env::var("ATLAS_SSM_BATCHED_RECURRENT").as_deref() == Ok("1"),
105            // No legacy environment variable on purpose (house rule: CLI flags
106            // or defaults, no new env knobs). Default = the legacy WY arms;
107            // exact verify is CLI-opt-in only (`--exact-verify`).
108            exact_verify: false,
109        }
110    }
111}
112
113/// Publish the command line's resolution. Call once, before the model builds.
114///
115/// Returns the value in force, which is the argument unless something already
116/// read a flag (in which case the read wins and the caller should say so
117/// rather than pretend the setting took).
118pub fn set_from_cli(flags: GdnFlags) -> GdnFlags {
119    let _ = FLAGS.set(flags);
120    *FLAGS.get().expect("just set")
121}
122
123/// The resolved flags, falling back to the environment on first touch.
124pub fn flags() -> GdnFlags {
125    *FLAGS.get_or_init(GdnFlags::from_env)
126}
127
128/// Widest chain-verify K with an FP16 h-state twin
129/// (`gated_delta_rule_wy{5..16}_f16`).
130///
131/// The SSOT for "can this verify width run under the f16 pool". K=17 — the
132/// DFlash arm at gamma 16 — has no twin, and the FP32 wy17 kernel over an
133/// FP16 h-state emits fluent garbage rather than faulting, so the CLI
134/// validator and the serve preflight both gate on this instead of a literal.
135///
136/// Expressed as K, not gamma: the DFlash verify width is gamma + 1, and
137/// conflating the two is how the width check came to admit gamma 16 (K=17,
138/// no twin) while its message claimed to cover "widths 5..16".
139pub const MAX_F16_TWIN_K: usize = 16;
140
141/// The largest `--dflash-gamma` whose verify width still has an FP16 twin.
142pub const MAX_F16_TWIN_DFLASH_GAMMA: usize = MAX_F16_TWIN_K - 1;
143
144/// The served DFlash gamma for a drafter of this trained block size, when
145/// no `--dflash-gamma` was given.
146///
147/// THE SSOT, and it must stay that way: the drafter head resolves its gamma
148/// through this, and so does every preflight that sizes a pool or a reserve
149/// from a peeked `dflash_config.block_size`. Two spellings of this rule is
150/// how the SSM MTP intermediates came to be reserved for K=9 while verify
151/// asked for K=10, a hard error at the first verify step, mid-graph-capture.
152///
153/// `block + 2`, because these drafters chain PAST their trained block:
154/// measured on Qwen3.8-27B DFlash2 (block 8), gamma 8 runs 7 drafts, one
155/// short, while gamma 10 holds full-block 9/9 accepts and is the fastest
156/// measured serve (63.0 vs 56.2 tok/s on GB10, 2026-08-29).
157///
158/// Clamped to `MAX_F16_TWIN_DFLASH_GAMMA` so a block-16-class drafter lands
159/// on 15, the widest verify width with kernel coverage under both h-state
160/// dtypes, instead of gamma 18 / K=19, which no wyN kernel serves.
161pub const fn default_dflash_gamma(trained_block_size: usize) -> usize {
162    let bumped = trained_block_size + 2;
163    if bumped > MAX_F16_TWIN_DFLASH_GAMMA {
164        MAX_F16_TWIN_DFLASH_GAMMA
165    } else {
166        bumped
167    }
168}
169
170/// `--ssm-h-dtype f16` (legacy `ATLAS_SSM_H_FP16`).
171pub fn ssm_h_fp16_enabled() -> bool {
172    flags().h_f16
173}
174
175/// Stage 3 of the f16 h-state: h pools SIZED at 2 bytes/element
176/// (`--ssm-h-dtype f16-pool`). Implies [`ssm_h_fp16_enabled`] — a narrow
177/// pool holding FP32 would be an OOB write, not a mode — which
178/// [`ssm_h_dtype_bits`] guarantees at the one place the value is decoded.
179pub fn ssm_h_f16_pool_enabled() -> bool {
180    flags().h_f16_pool
181}
182
183/// SSOT decode of `--ssm-h-dtype` into the two h-state bits it publishes:
184/// `(h_f16, h_f16_pool)`.
185///
186/// Both the CLI validator (which rejects the pairs the mode cannot serve)
187/// and `publish_kernel_flags` (which publishes the cell the kernels
188/// dispatch on) go through THIS, so a validator that accepted one reading
189/// while the kernels took another is not expressible. Anything that is not
190/// exactly `f16` or `f16-pool` — including `f32` and an absent flag — is
191/// FP32; `check_enum` has already rejected unknown spellings by the time
192/// this runs, and defaulting an unknown one to FP32 here is the safe arm
193/// besides.
194pub fn ssm_h_dtype_bits(dtype: Option<&str>) -> (bool, bool) {
195    match dtype {
196        Some("f16") => (true, false),
197        // f16-pool is f16 PLUS the narrow pool: never one without the other.
198        Some("f16-pool") => (true, true),
199        _ => (false, false),
200    }
201}
202
203/// `--gdn-fused-norm` (legacy `ATLAS_GDN_FUSED_NORM=1`).
204pub fn gdn_fused_norm_enabled() -> bool {
205    flags().fused_norm
206}
207
208/// `--ssm-batched-recurrent` (legacy `ATLAS_SSM_BATCHED_RECURRENT=1`).
209pub fn ssm_batched_recurrent_enabled() -> bool {
210    flags().batched_recurrent
211}
212
213/// `--exact-verify` given (and h-state is FP32): the MTP-verify pass runs
214/// the sequential-decode-exact chain. FALSE by default — without the flag the
215/// verify pass runs the WY/chunkwise arms and #435's spec-on/spec-off output
216/// divergence remains. See [`GdnFlags::verify_exact_active`].
217pub fn verify_exact_enabled() -> bool {
218    flags().verify_exact_active()
219}
220
221/// Batch width at which the multi-seq decode projections switch to the
222/// 128-row M-tile. `None` (kill switch `ATLAS_NO_SSM_M128`, PRESENCE check —
223/// `=0` is NOT "off") keeps the 64-row twin at every width.
224///
225/// 65 is the DERIVED crossover, not a tuned constant: `ceil(m/64) >
226/// ceil(m/128)` first holds at m=65, so m<=64 gains no weight-read reduction
227/// from the wider tile and would only pad MMA rows. Identical rule to the
228/// dense-FFN prefill macro's `m <= 64` small-M arm.
229pub(crate) fn ssm_m128_min_m() -> Option<u32> {
230    static M: std::sync::OnceLock<Option<u32>> = std::sync::OnceLock::new();
231    *M.get_or_init(|| {
232        if std::env::var("ATLAS_NO_SSM_M128").is_ok() {
233            None
234        } else {
235            Some(65)
236        }
237    })
238}
239
240#[cfg(test)]
241mod tests {
242    use super::{GdnFlags, ssm_h_dtype_bits};
243
244    const BASE: GdnFlags = GdnFlags {
245        h_f16: false,
246        h_f16_pool: false,
247        fused_norm: false,
248        batched_recurrent: false,
249        exact_verify: false,
250    };
251
252    /// POSITIVE (the default): with no flags the verify pass runs the legacy
253    /// WY/chunkwise arms, NOT the exact chain. Exact verify became OPT-IN
254    /// (every surveyed production engine ships exactness opt-in; its measured
255    /// decode-step cost here is ~+22-36%), so the #435 divergence is the
256    /// documented default behaviour — this test pins that polarity.
257    #[test]
258    fn legacy_wy_verify_is_the_default() {
259        assert!(
260            !BASE.verify_exact_active(),
261            "default must be the legacy WY arms — exact verify is opt-in"
262        );
263        // Orthogonal flags do not sneak exact mode on.
264        assert!(
265            !GdnFlags {
266                fused_norm: true,
267                batched_recurrent: true,
268                ..BASE
269            }
270            .verify_exact_active()
271        );
272    }
273
274    /// POSITIVE (the opt-in): `--exact-verify` selects the exact chain, alone
275    /// and beside the orthogonal GDN flags.
276    #[test]
277    fn exact_verify_flag_selects_the_exact_chain() {
278        assert!(
279            GdnFlags {
280                exact_verify: true,
281                ..BASE
282            }
283            .verify_exact_active()
284        );
285        assert!(
286            GdnFlags {
287                exact_verify: true,
288                fused_norm: true,
289                batched_recurrent: true,
290                ..BASE
291            }
292            .verify_exact_active()
293        );
294    }
295
296    /// The environment fallback can NEVER turn exact verify on: there is no
297    /// `ATLAS_*` variable for it on purpose (house rule: no new env knobs),
298    /// so a serve that skips `set_from_cli` still defaults to the WY arms.
299    /// Deterministic despite reading the process environment, because only
300    /// the `exact_verify` field is asserted and no variable feeds it.
301    #[test]
302    fn env_fallback_never_enables_exact_verify() {
303        assert!(!GdnFlags::from_env().exact_verify);
304        // Same rule for the stage-3 pool sizing: no env variable feeds it.
305        // `--ssm-h-dtype f16-pool` is the ONLY way to publish it, so a
306        // legacy `ATLAS_SSM_H_FP16=1` script keeps the FP32-sized pool.
307        assert!(!GdnFlags::from_env().h_f16_pool);
308    }
309
310    /// A narrow pool holding FP32 is an out-of-bounds write, not a mode, so
311    /// `h_f16_pool` without `h_f16` must not be expressible from any input.
312    /// This is the ONE decode both the validator and the publisher use, so
313    /// pinning it here pins it for both.
314    #[test]
315    fn the_pool_bit_is_never_set_without_the_dtype_bit() {
316        for (spelling, expected) in [
317            (None, (false, false)),
318            (Some("f32"), (false, false)),
319            (Some("f16"), (true, false)),
320            (Some("f16-pool"), (true, true)),
321            (Some(""), (false, false)),
322            (Some("F16-POOL"), (false, false)),
323            (Some("f16 "), (false, false)),
324        ] {
325            assert_eq!(ssm_h_dtype_bits(spelling), expected, "{spelling:?}");
326        }
327    }
328
329    /// NEGATIVE: an FP16 h-state forces non-exact EVEN WHEN exact was
330    /// requested — the exact arm's FP32 kernels must never read the FP16
331    /// pool. (CLI validation rejects the explicit pair; this is the
332    /// defense-in-depth layer beneath it.)
333    #[test]
334    fn h_f16_forces_non_exact_even_when_requested() {
335        assert!(
336            !GdnFlags {
337                exact_verify: true,
338                h_f16: true,
339                ..BASE
340            }
341            .verify_exact_active()
342        );
343        assert!(
344            !GdnFlags {
345                h_f16: true,
346                ..BASE
347            }
348            .verify_exact_active()
349        );
350    }
351}