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 (`AVAROK_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 reading used when the CLI never set anything: the compiled
91 /// target's declaration, with the environment overriding.
92 ///
93 /// `batched_recurrent` takes its DEFAULT from
94 /// `kernels/<hw>/HARDWARE.toml` `[defaults] ssm_batched_recurrent` —
95 /// `hopper` declares it ON (+6% on the serve, md5-identical output to the
96 /// per-sequence launches), `gb10` and `b200` declare it OFF, unchanged. It
97 /// used to be `AVAROK_SSM_BATCHED_RECURRENT=1` in an H100 launch script
98 /// outside this repository, which is the structure the 2026-09-11
99 /// maintainer review asked for. `AVAROK_SSM_BATCHED_RECURRENT` still
100 /// overrides, and `=0` now means OFF rather than reading as absent (see
101 /// `layers::ops::target_defaults`); everything that ever set it set it
102 /// to `1`.
103 ///
104 /// `AVAROK_SSM_H_FP16` stays PRESENCE-gated here on purpose: that is how
105 /// every script and ledger in the campaign wrote it, and silently changing
106 /// `=0` from ON to OFF would retroactively re-label measurements. New
107 /// configuration should use `--ssm-h-dtype`.
108 fn from_env() -> Self {
109 Self {
110 h_f16: std::env::var("AVAROK_SSM_H_FP16").is_ok(),
111 // No environment fallback on purpose (house rule: no new env
112 // knobs) — stage 3 has no CLI surface either until prefill
113 // narrowing lands; only unit tests exercise the sizing.
114 h_f16_pool: false,
115 fused_norm: std::env::var("AVAROK_GDN_FUSED_NORM").as_deref() == Ok("1"),
116 batched_recurrent: crate::layers::ops::target_defaults::resolved()
117 .ssm_batched_recurrent
118 .value,
119 // No legacy environment variable on purpose (house rule: CLI flags
120 // or defaults, no new env knobs). Default = the legacy WY arms;
121 // exact verify is CLI-opt-in only (`--exact-verify`).
122 exact_verify: false,
123 }
124 }
125}
126
127/// Publish the command line's resolution. Call once, before the model builds.
128///
129/// Returns the value in force, which is the argument unless something already
130/// read a flag (in which case the read wins and the caller should say so
131/// rather than pretend the setting took).
132pub fn set_from_cli(flags: GdnFlags) -> GdnFlags {
133 let _ = FLAGS.set(flags);
134 *FLAGS.get().expect("just set")
135}
136
137/// The resolved flags, falling back to the environment on first touch.
138pub fn flags() -> GdnFlags {
139 *FLAGS.get_or_init(GdnFlags::from_env)
140}
141
142/// Widest chain-verify K with an FP16 h-state twin
143/// (`gated_delta_rule_wy{5..16}_f16`).
144///
145/// The SSOT for "can this verify width run under the f16 pool". K=17 — the
146/// DFlash arm at gamma 16 — has no twin, and the FP32 wy17 kernel over an
147/// FP16 h-state emits fluent garbage rather than faulting, so the CLI
148/// validator and the serve preflight both gate on this instead of a literal.
149///
150/// Expressed as K, not gamma: the DFlash verify width is gamma + 1, and
151/// conflating the two is how the width check came to admit gamma 16 (K=17,
152/// no twin) while its message claimed to cover "widths 5..16".
153pub const MAX_F16_TWIN_K: usize = 16;
154
155/// The largest `--dflash-gamma` whose verify width still has an FP16 twin.
156pub const MAX_F16_TWIN_DFLASH_GAMMA: usize = MAX_F16_TWIN_K - 1;
157
158/// The served DFlash gamma for a drafter of this trained block size, when
159/// no `--dflash-gamma` was given.
160///
161/// THE SSOT, and it must stay that way: the drafter head resolves its gamma
162/// through this, and so does every preflight that sizes a pool or a reserve
163/// from a peeked `dflash_config.block_size`. Two spellings of this rule is
164/// how the SSM MTP intermediates came to be reserved for K=9 while verify
165/// asked for K=10, a hard error at the first verify step, mid-graph-capture.
166///
167/// `block + 2`, because these drafters chain PAST their trained block:
168/// measured on Qwen3.8-27B DFlash2 (block 8), gamma 8 runs 7 drafts, one
169/// short, while gamma 10 holds full-block 9/9 accepts and is the fastest
170/// measured serve (63.0 vs 56.2 tok/s on GB10, 2026-08-29).
171///
172/// Clamped to `MAX_F16_TWIN_DFLASH_GAMMA` so a block-16-class drafter lands
173/// on 15, the widest verify width with kernel coverage under both h-state
174/// dtypes, instead of gamma 18 / K=19, which no wyN kernel serves.
175pub const fn default_dflash_gamma(trained_block_size: usize) -> usize {
176 let bumped = trained_block_size + 2;
177 if bumped > MAX_F16_TWIN_DFLASH_GAMMA {
178 MAX_F16_TWIN_DFLASH_GAMMA
179 } else {
180 bumped
181 }
182}
183
184/// `--ssm-h-dtype f16` (legacy `AVAROK_SSM_H_FP16`).
185pub fn ssm_h_fp16_enabled() -> bool {
186 flags().h_f16
187}
188
189/// Stage 3 of the f16 h-state: h pools SIZED at 2 bytes/element
190/// (`--ssm-h-dtype f16-pool`). Implies [`ssm_h_fp16_enabled`] — a narrow
191/// pool holding FP32 would be an OOB write, not a mode — which
192/// [`ssm_h_dtype_bits`] guarantees at the one place the value is decoded.
193pub fn ssm_h_f16_pool_enabled() -> bool {
194 flags().h_f16_pool
195}
196
197/// SSOT decode of `--ssm-h-dtype` into the two h-state bits it publishes:
198/// `(h_f16, h_f16_pool)`.
199///
200/// Both the CLI validator (which rejects the pairs the mode cannot serve)
201/// and `publish_kernel_flags` (which publishes the cell the kernels
202/// dispatch on) go through THIS, so a validator that accepted one reading
203/// while the kernels took another is not expressible. Anything that is not
204/// exactly `f16` or `f16-pool` — including `f32` and an absent flag — is
205/// FP32; `check_enum` has already rejected unknown spellings by the time
206/// this runs, and defaulting an unknown one to FP32 here is the safe arm
207/// besides.
208pub fn ssm_h_dtype_bits(dtype: Option<&str>) -> (bool, bool) {
209 match dtype {
210 Some("f16") => (true, false),
211 // f16-pool is f16 PLUS the narrow pool: never one without the other.
212 Some("f16-pool") => (true, true),
213 _ => (false, false),
214 }
215}
216
217/// `--gdn-fused-norm` (legacy `AVAROK_GDN_FUSED_NORM=1`).
218pub fn gdn_fused_norm_enabled() -> bool {
219 flags().fused_norm
220}
221
222/// `--ssm-batched-recurrent` (legacy `AVAROK_SSM_BATCHED_RECURRENT=1`).
223pub fn ssm_batched_recurrent_enabled() -> bool {
224 flags().batched_recurrent
225}
226
227/// `--exact-verify` given (and h-state is FP32): the MTP-verify pass runs
228/// the sequential-decode-exact chain. FALSE by default — without the flag the
229/// verify pass runs the WY/chunkwise arms and #435's spec-on/spec-off output
230/// divergence remains. See [`GdnFlags::verify_exact_active`].
231pub fn verify_exact_enabled() -> bool {
232 flags().verify_exact_active()
233}
234
235/// Batch width at which the multi-seq decode projections switch to the
236/// 128-row M-tile. `None` (kill switch `AVAROK_NO_SSM_M128`, PRESENCE check —
237/// `=0` is NOT "off") keeps the 64-row twin at every width.
238///
239/// 65 is the DERIVED crossover, not a tuned constant: `ceil(m/64) >
240/// ceil(m/128)` first holds at m=65, so m<=64 gains no weight-read reduction
241/// from the wider tile and would only pad MMA rows. Identical rule to the
242/// dense-FFN prefill macro's `m <= 64` small-M arm.
243pub(crate) fn ssm_m128_min_m() -> Option<u32> {
244 static M: std::sync::OnceLock<Option<u32>> = std::sync::OnceLock::new();
245 *M.get_or_init(|| {
246 if std::env::var("AVAROK_NO_SSM_M128").is_ok() {
247 None
248 } else {
249 Some(65)
250 }
251 })
252}
253
254/// Write-on-accept K=4 batched verify. **Default OFF — opt in with
255/// `AVAROK_GDN_WOA=1`.** With it off, the parent wy4 kernel runs (writes every
256/// intermediate) and `trait_layer.rs`'s fold reports `Ok(false)`, so
257/// `async_chkpt.rs` performs the pre-WOA `h` restore for every K.
258///
259/// ★ IT SHIPPED ON AND IT CORRUPTS GENERATION AT C=4..8.
260///
261/// Measured on gb10, five concurrency-sweep runs in four hours, 2026-09-18.
262/// The signal is the SimHash semantic-loop watchdog, which cuts a response
263/// once the model starts paraphrasing itself; the cell then reports a
264/// truncated delivery and the gate refuses to score it:
265///
266/// | run | C4 | C8 | fires | vacuous | gate |
267/// |---------------------------|----|----|-------|---------|------|
268/// | main | 0 | 0 | 10 | 0 | PASS |
269/// | this line, fold ON | 4 | 1 | 16 | 2 | FAIL |
270/// | ditto, gamma pinned | 6 | 4 | 20 | 2 | FAIL |
271/// | this line, fold OFF | 0 | 1 | 10 | 0 | PASS |
272/// | fold ON + a folded-slots | 4 | 2 | 8 | 2 | FAIL |
273/// | scope fix | | | | | |
274///
275/// All five agree at C=16..128. So the ARM is identified and the bug inside it
276/// is not: the gamma resolver (pinning the width changed nothing), the EOS
277/// path (every request reports finish=length), the VERIFY_WY_TABLE_SEQS=32
278/// bound (C=4..8 cannot exceed it, and a breach asserts) and a stale
279/// folded-slots record (fix applied, C=4 unchanged at 4 fires) are each
280/// eliminated by measurement rather than argument.
281///
282/// No concurrency guard: `woa_now` does take `n`, but nothing in the code
283/// distinguishes n=4 from n=16 — same predicate, same kernels, same 2..=32
284/// gate — and C=64/128 chunk to <=32 so the fold runs there too. A guard would
285/// encode an unexplained measurement, not a mechanism.
286///
287/// Turn the default back on when #879's author can explain C=4..8.
288///
289/// Public so the serve can tell the gamma resolver whether the K=4 rung has
290/// its kernel (review of #845, question 3).
291pub fn gdn_woa_enabled() -> bool {
292 std::env::var("AVAROK_GDN_WOA").ok().as_deref() == Some("1")
293}
294
295#[cfg(test)]
296mod tests {
297 use super::{GdnFlags, ssm_h_dtype_bits};
298
299 const BASE: GdnFlags = GdnFlags {
300 h_f16: false,
301 h_f16_pool: false,
302 fused_norm: false,
303 batched_recurrent: false,
304 exact_verify: false,
305 };
306
307 /// POSITIVE (the default): with no flags the verify pass runs the legacy
308 /// WY/chunkwise arms, NOT the exact chain. Exact verify became OPT-IN
309 /// (every surveyed production engine ships exactness opt-in; its measured
310 /// decode-step cost here is ~+22-36%), so the #435 divergence is the
311 /// documented default behaviour — this test pins that polarity.
312 #[test]
313 fn legacy_wy_verify_is_the_default() {
314 assert!(
315 !BASE.verify_exact_active(),
316 "default must be the legacy WY arms — exact verify is opt-in"
317 );
318 // Orthogonal flags do not sneak exact mode on.
319 assert!(
320 !GdnFlags {
321 fused_norm: true,
322 batched_recurrent: true,
323 ..BASE
324 }
325 .verify_exact_active()
326 );
327 }
328
329 /// POSITIVE (the opt-in): `--exact-verify` selects the exact chain, alone
330 /// and beside the orthogonal GDN flags.
331 #[test]
332 fn exact_verify_flag_selects_the_exact_chain() {
333 assert!(
334 GdnFlags {
335 exact_verify: true,
336 ..BASE
337 }
338 .verify_exact_active()
339 );
340 assert!(
341 GdnFlags {
342 exact_verify: true,
343 fused_norm: true,
344 batched_recurrent: true,
345 ..BASE
346 }
347 .verify_exact_active()
348 );
349 }
350
351 /// The environment fallback can NEVER turn exact verify on: there is no
352 /// `AVAROK_*` variable for it on purpose (house rule: no new env knobs),
353 /// so a serve that skips `set_from_cli` still defaults to the WY arms.
354 /// Deterministic despite reading the process environment, because only
355 /// the `exact_verify` field is asserted and no variable feeds it.
356 #[test]
357 fn env_fallback_never_enables_exact_verify() {
358 assert!(!GdnFlags::from_env().exact_verify);
359 // Same rule for the stage-3 pool sizing: no env variable feeds it.
360 // `--ssm-h-dtype f16-pool` is the ONLY way to publish it, so a
361 // legacy `AVAROK_SSM_H_FP16=1` script keeps the FP32-sized pool.
362 assert!(!GdnFlags::from_env().h_f16_pool);
363 }
364
365 /// A narrow pool holding FP32 is an out-of-bounds write, not a mode, so
366 /// `h_f16_pool` without `h_f16` must not be expressible from any input.
367 /// This is the ONE decode both the validator and the publisher use, so
368 /// pinning it here pins it for both.
369 #[test]
370 fn the_pool_bit_is_never_set_without_the_dtype_bit() {
371 for (spelling, expected) in [
372 (None, (false, false)),
373 (Some("f32"), (false, false)),
374 (Some("f16"), (true, false)),
375 (Some("f16-pool"), (true, true)),
376 (Some(""), (false, false)),
377 (Some("F16-POOL"), (false, false)),
378 (Some("f16 "), (false, false)),
379 ] {
380 assert_eq!(ssm_h_dtype_bits(spelling), expected, "{spelling:?}");
381 }
382 }
383
384 /// NEGATIVE: an FP16 h-state forces non-exact EVEN WHEN exact was
385 /// requested — the exact arm's FP32 kernels must never read the FP16
386 /// pool. (CLI validation rejects the explicit pair; this is the
387 /// defense-in-depth layer beneath it.)
388 #[test]
389 fn h_f16_forces_non_exact_even_when_requested() {
390 assert!(
391 !GdnFlags {
392 exact_verify: true,
393 h_f16: true,
394 ..BASE
395 }
396 .verify_exact_active()
397 );
398 assert!(
399 !GdnFlags {
400 h_f16: true,
401 ..BASE
402 }
403 .verify_exact_active()
404 );
405 }
406}