spark_model/layers/ops/ssm_gdn_a3.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GDN FLA prefill op — extracted from `ssm_gdn_a2.rs` during the ≤500-line
4//! split (the 3-kernel FLA path grew past the cap when the vtile spine added
5//! its handle + dispatch). All public items remain available at
6//! `crate::layers::ops::*` via the re-export in `ops.rs`.
7#![allow(unused_imports)]
8
9use anyhow::Result;
10use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
11use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
12
13use crate::layers::moe;
14use crate::weight_map::{DenseWeight, Fp8DenseWeight, Fp8Weight, QuantizedWeight};
15
16use super::*;
17
18/// FLA multi-kernel chunked GDN prefill (`ATLAS_GDN_FLA=1`).
19///
20/// Three sequential launches on `stream` (CPU-serialized → no GPU sync needed):
21/// 1. recompute_wu (grid [num_chunks, nv, batch], 128 thr): solve (I+L)U=βV,
22/// (I+L)W=β·exp(gc)·K → W_out, U_out (bf16), gc_out (f32).
23/// 2. chunk_delta_h_ksplit (grid [nv, batch], 256 thr): serial f32 state spine,
24/// 2 threads/v-column for occupancy → S_out (per-chunk entry states bf16),
25/// uc_out (bf16); updates h_state in-place.
26/// 3. chunk_fwd_o (grid [num_chunks, nv, batch], 128 thr): O = Q̃·S_c +
27/// tril(decay·Q̃·Kᵀ)·uc → output (bf16, same layout as wy4).
28///
29/// W_out/U_out/S_out/uc_out are the caller's pre-sized scratch (BufferArena
30/// `gdn_fla_scratch`, sub-divided). Strides match the packed conv layout
31/// (qk_stride=v_stride=conv_dim, gb_stride=2*nv) exactly like the wy4/chunk64 path.
32#[allow(clippy::too_many_arguments)]
33pub fn gdn_prefill_fla(
34 gpu: &dyn GpuBackend,
35 k_recompute_wu: KernelHandle,
36 k_chunk_delta_h: KernelHandle,
37 // wmma + DV-block-split spine (gated_delta_rule_chunk_delta_h_tc_vblock). When
38 // non-zero AND ATLAS_GDN_TC_VBLOCK=1, replaces the scalar ksplit spine (drop-in
39 // ABI; grid y = batch·num_dv_blocks, smem 81KB vs 97KB). KernelHandle(0) = off.
40 k_chunk_delta_h_tc_vblock: KernelHandle,
41 k_chunk_delta_h_fused: KernelHandle,
42 k_chunk_delta_h_tma: KernelHandle,
43 k_chunk_fwd_o: KernelHandle,
44 h_state: DevicePtr,
45 query: DevicePtr,
46 key: DevicePtr,
47 value: DevicePtr,
48 gate: DevicePtr,
49 beta: DevicePtr,
50 output: DevicePtr,
51 w_out: DevicePtr,
52 u_out: DevicePtr,
53 s_out: DevicePtr,
54 uc_out: DevicePtr,
55 gc_out: DevicePtr,
56 batch_size: u32,
57 seq_len: u32,
58 num_chunks: u32,
59 num_k_heads: u32,
60 num_v_heads: u32,
61 k_dim: u32,
62 v_dim: u32,
63 qk_stride: u32,
64 v_stride: u32,
65 gb_stride: u32,
66 // h_state passed as a device POINTER TABLE (one [nv,kd,vd] per request) when
67 // batched co-dispatch reuses the per-request states; false = contiguous base.
68 h_state_is_table: bool,
69 // VARLEN (ragged co-dispatch): per-stream cu_seqlens (token offsets, batch+1
70 // ints) + cu_chunks (chunk offsets, batch+1 ints) on device. When is_varlen,
71 // `num_chunks` must be the MAX over streams (grid x). is_varlen=false →
72 // cu_* unused (pass NULL).
73 cu_seqlens: DevicePtr,
74 cu_chunks: DevicePtr,
75 is_varlen: bool,
76 profile: bool,
77 stream: u64,
78) -> Result<()> {
79 const C: u32 = 64; // CHUNK (kernel constant)
80 let (kd, vd) = (k_dim, v_dim);
81 // smem byte sizes — identical formulas to the GATE-B example (validated).
82 // L aliases the kk Gram (disjoint triangles) — one C*C*4 buffer, not two.
83 let smem_wu = C * kd * 2 + C * C * 4 + C * 4;
84 let smem_dh = 2 * (C * (2 * kd + vd) * 2) + 2 * C * 4 + 2 * (C + 1) * 4;
85 let smem_fo = C * kd * 2 + C * kd * 2 + C * C * 4 + C * vd * 2 + kd * vd * 2 + 2 * C * 4;
86
87 let mut t0: Option<std::time::Instant> = if profile {
88 gpu.synchronize(stream)?;
89 Some(std::time::Instant::now())
90 } else {
91 None
92 };
93
94 macro_rules! prof {
95 ($label:expr, $t0:expr) => {
96 if let Some(t0) = $t0.take() {
97 gpu.synchronize(stream)?;
98 let elapsed = t0.elapsed().as_micros();
99 tracing::info!(" SSM prefill [{}] N={}: {}µs", $label, seq_len, elapsed);
100 *$t0 = Some(std::time::Instant::now());
101 }
102 };
103 }
104
105 // Kernel 1: recompute_wu.
106 KernelLaunch::new(gpu, k_recompute_wu)
107 .grid([num_chunks, num_v_heads, batch_size])
108 .block([256, 1, 1])
109 .shared_mem(smem_wu)
110 .arg_ptr(key)
111 .arg_ptr(value)
112 .arg_ptr(gate)
113 .arg_ptr(beta)
114 .arg_ptr(w_out)
115 .arg_ptr(u_out)
116 .arg_ptr(gc_out)
117 .arg_u32(batch_size)
118 .arg_u32(seq_len)
119 .arg_u32(num_chunks)
120 .arg_u32(num_k_heads)
121 .arg_u32(num_v_heads)
122 .arg_u32(kd)
123 .arg_u32(vd)
124 .arg_u32(qk_stride)
125 .arg_u32(v_stride)
126 .arg_u32(gb_stride)
127 .arg_ptr(cu_seqlens)
128 .arg_ptr(cu_chunks)
129 .arg_u32(is_varlen as u32)
130 .launch(stream)?;
131 prof!("gdn_fla_recompute_wu", &mut t0);
132
133 // Kernel 2: chunk_delta_h — the fused spine OR the wmma + DV-block-split
134 // tc_vblock (gated). Both are drop-in ABI; only grid-y extent, block size and
135 // dynamic smem differ.
136 //
137 // DEFAULT is `..._vfused` (SPLIT=2, 256 threads): the two per-chunk passes are
138 // folded into one, which collapses `duc` from a CHUNK-long array to a scalar and
139 // drops smem 99,336 -> 49,412 B. That is worth 2.01x over ksplit on the isolated
140 // spine and ~68 ms of cold TTFT (one-variable A/B, same binary, 10 reps/leg).
141 //
142 // `ATLAS_GDN_VTILE=1` raises the same core to SPLIT=4 / 512 threads for 2.15x.
143 // It is NOT the default: it regressed tool-calling accuracy below the gate
144 // floors on BOTH models in a full record campaign —
145 // bfcl-subset (27B) 83.62 / 82.72 vs floors 83.42 / 83.32 FAIL
146 // bfcl-echolp (35B) 85.96 / 86.09 vs floors 86.10 / 86.50 FAIL
147 // same binary, spine off 84.22 / 84.12 PASS
148 //
149 // ★ WHAT IS AND IS NOT KNOWN. The ssm-poisoning gate is the 4-minute tripwire
150 // that separates these, and it bisects the two changes vtile made at once:
151 // ksplit (unfused, SPLIT=2) 12/12 replays byte-identical
152 // vfused (fused, SPLIT=2) 12/12 <- shipped
153 // vtile (fused, SPLIT=4) 1/12
154 // So the FUSION is innocent and SPLIT=4 is implicated. The mechanism is NOT
155 // reassociation of the k-sum, which an earlier revision of this comment claimed:
156 // accumulating the SPLIT-way butterfly fold in Neumaier-compensated form scored
157 // 0/12 — no better — so that hypothesis is refuted and the true cause of
158 // SPLIT=4's drift is UNKNOWN. Since SPLIT=4 buys only 7% over SPLIT=2, the
159 // warp-density half was never where the win was.
160 //
161 // ★ Neither cos>=0.99 on the isolated spine NOR a byte-identical greedy
162 // comparison on a single prompt caught this. A drift too small to change one
163 // trajectory still moved BFCL by 1.4 points across 995 samples. Use the
164 // ssm-poisoning tripwire before trusting any change to this kernel.
165 let use_fused = k_chunk_delta_h_fused.0 != 0
166 && std::env::var("ATLAS_GDN_VTILE").ok().as_deref() != Some("0");
167 let use_tcvb = !use_fused
168 && k_chunk_delta_h_tc_vblock.0 != 0
169 && std::env::var("ATLAS_GDN_TC_VBLOCK").ok().as_deref() == Some("1");
170 const DV_BLK: u32 = 64; // matches the kernel's compile-time DV_BLK
171 let num_dv_blk = (vd / DV_BLK).max(1); // 2 for Holo (vd=128)
172 // tc_vblock smem: St[DV_BLK*kd] + ws[C*DV_BLK]f32 + buf[2][C*kd + C*DV_BLK] + gcb + decb
173 let smem_tcvb = DV_BLK * kd * 2
174 + C * DV_BLK * 4
175 + 2 * (C * kd + C * DV_BLK) * 2
176 + 2 * C * 4
177 + 2 * (C + 1) * 4;
178 // The fused spine stages {W,K,U} single-buffered plus one decay row; it does NOT
179 // split the DV axis, so grid.y stays `batch_size`. smem is identical for both
180 // members — only the thread count differs, and it must match the kernel that
181 // init.rs actually loaded for the same env value.
182 // `..._pipe` double-buffers {W,K,U} through `cp.async`, so it needs the SAME
183 // footprint the original (also double-buffered) spine uses — `smem_dh`. Under-
184 // sizing this reads the second slot out of bounds, so the selector has to agree
185 // with the kernel `init.rs` loaded for the same env value.
186 let pipe = std::env::var("ATLAS_GDN_PIPE").ok().as_deref() == Some("1");
187 let smem_fused = if pipe {
188 smem_dh
189 } else {
190 C * kd * 2 + C * kd * 2 + C * vd * 2 + (C + 1) * 4
191 };
192 let fused_block = match std::env::var("ATLAS_GDN_VTILE").ok().as_deref() {
193 Some("1") if !pipe => 512u32, // SPLIT=4 build
194 _ => 256u32, // SPLIT=2 build (default, and the pipe build)
195 };
196 // ── TMA path (ATLAS_GDN_TMA=1) ───────────────────────────────────────────
197 // Every precondition is CHECKED, not assumed. The descriptors are encoded
198 // from the compile-time tile (K_DIM/V_DIM = 128, CHUNK = 64), so a runtime
199 // head narrower than the tile would load the wrong columns SILENTLY — TMA
200 // reports no error for a well-formed descriptor pointed at the wrong shape.
201 // Varlen is excluded because `choff` then comes from `cu_chunks` and the
202 // flat row count the descriptor needs is not known on the host.
203 let tma_requested = std::env::var("ATLAS_GDN_TMA").ok().as_deref() == Some("1");
204 // ★ NAME THE GUARD THAT REJECTED. A perf path that asks to be enabled and
205 // silently is not measures as "no effect" — PR #296 shipped exactly that
206 // (an ldmatrix GEMM that fell back with no error while both gates stayed
207 // green), and this path reproduced it during bring-up: an A/B ran with the
208 // env set, fell back to `vfused`, and the two arms differed by noise.
209 let tma_reject: Option<&str> = if !tma_requested {
210 Some("not requested")
211 } else if k_chunk_delta_h_tma.0 == 0 {
212 Some("kernel absent from this image")
213 } else if is_varlen {
214 Some(
215 "varlen: choff comes from cu_chunks, so the descriptor's flat row count is unknown host-side",
216 )
217 } else if kd != 128 || vd != 128 || C != 64 {
218 Some("head/chunk differs from the compile-time tile the descriptors encode")
219 } else if !qk_stride.is_multiple_of(8) {
220 Some("qk_stride is not a multiple of 8 (bf16 row pitch must be 16-byte aligned)")
221 } else {
222 None
223 };
224 if tma_requested && let Some(why) = tma_reject {
225 tracing::warn!("ATLAS_GDN_TMA=1 but the TMA spine is NOT running: {why}");
226 }
227 let tma_ok = tma_reject.is_none();
228 if tma_ok {
229 tracing::info!("GDN state spine: gated_delta_rule_chunk_delta_h_tma");
230 }
231 // `cuda_backend` (and with it `TensorMap`) only exists under the cuda
232 // feature; the metal build has no TMA and must not reference it. The guard
233 // above already resolves to false there via the absent kernel handle, but a
234 // `use` is resolved at compile time regardless of the branch being taken.
235 #[cfg(feature = "cuda")]
236 if tma_ok {
237 use spark_runtime::cuda_backend::tensormap::TensorMap;
238 // W/U are [total_blocks][CHUNK][tile] flattened; as a 2-D tensor that is
239 // (total_blocks * CHUNK) rows of `tile` columns, contiguous.
240 let blocks = (batch_size as u64) * (num_chunks as u64) * (num_v_heads as u64);
241 let w_map =
242 TensorMap::tiled_2d_bf16(w_out, blocks * C as u64, kd as u64, kd as u64, C, kd)?;
243 let u_map =
244 TensorMap::tiled_2d_bf16(u_out, blocks * C as u64, vd as u64, vd as u64, C, vd)?;
245 // K is a VIEW into the packed qkvz tensor: rows are tokens at a
246 // `qk_stride` pitch, and the kernel supplies `kh * K_DIM` as the column
247 // origin. This is the gather `cdh_prefetch` does one row at a time.
248 let k_map = TensorMap::tiled_2d_bf16(
249 key,
250 (batch_size as u64) * (seq_len as u64),
251 qk_stride as u64,
252 qk_stride as u64,
253 C,
254 kd,
255 )?;
256 KernelLaunch::new(gpu, k_chunk_delta_h_tma)
257 .grid([num_v_heads, batch_size, 1])
258 .block([256, 1, 1])
259 .shared_mem(smem_dh)
260 .arg_ptr(h_state)
261 .arg_tensormap(w_map.bytes())
262 .arg_tensormap(u_map.bytes())
263 .arg_tensormap(k_map.bytes())
264 .arg_ptr(gc_out)
265 .arg_ptr(s_out)
266 .arg_ptr(uc_out)
267 .arg_u32(batch_size)
268 .arg_u32(seq_len)
269 .arg_u32(num_chunks)
270 .arg_u32(num_k_heads)
271 .arg_u32(num_v_heads)
272 .arg_u32(vd)
273 .arg_u32(h_state_is_table as u32)
274 .arg_ptr(cu_seqlens)
275 .arg_ptr(cu_chunks)
276 .arg_u32(is_varlen as u32)
277 .launch(stream)?;
278 prof!("gdn_fla_chunk_delta_h", &mut t0);
279 }
280
281 // Kernel 2 (non-TMA). Both paths write s_out/uc_out and fall through to
282 // kernel 3, which is identical either way.
283 if !tma_ok {
284 let (k_cdh, cdh_grid_y, cdh_smem, cdh_block) = if use_fused {
285 (k_chunk_delta_h_fused, batch_size, smem_fused, fused_block)
286 } else if use_tcvb {
287 (
288 k_chunk_delta_h_tc_vblock,
289 batch_size * num_dv_blk,
290 smem_tcvb,
291 256u32,
292 )
293 } else {
294 (k_chunk_delta_h, batch_size, smem_dh, 256u32)
295 };
296 KernelLaunch::new(gpu, k_cdh)
297 .grid([num_v_heads, cdh_grid_y, 1])
298 .block([cdh_block, 1, 1])
299 .shared_mem(cdh_smem)
300 .arg_ptr(h_state)
301 .arg_ptr(w_out)
302 .arg_ptr(u_out)
303 .arg_ptr(key)
304 .arg_ptr(gate)
305 .arg_ptr(gc_out)
306 .arg_ptr(s_out)
307 .arg_ptr(uc_out)
308 .arg_u32(batch_size)
309 .arg_u32(seq_len)
310 .arg_u32(num_chunks)
311 .arg_u32(num_k_heads)
312 .arg_u32(num_v_heads)
313 .arg_u32(kd)
314 .arg_u32(vd)
315 .arg_u32(qk_stride)
316 .arg_u32(gb_stride)
317 .arg_u32(h_state_is_table as u32)
318 .arg_ptr(cu_seqlens)
319 .arg_ptr(cu_chunks)
320 .arg_u32(is_varlen as u32)
321 .launch(stream)?;
322 prof!("gdn_fla_chunk_delta_h", &mut t0);
323 }
324
325 // Kernel 3: chunk_fwd_o.
326 KernelLaunch::new(gpu, k_chunk_fwd_o)
327 .grid([num_chunks, num_v_heads, batch_size])
328 .block([512, 1, 1])
329 .shared_mem(smem_fo)
330 .arg_ptr(query)
331 .arg_ptr(key)
332 .arg_ptr(gate)
333 .arg_ptr(gc_out)
334 .arg_ptr(s_out)
335 .arg_ptr(uc_out)
336 .arg_ptr(output)
337 .arg_u32(batch_size)
338 .arg_u32(seq_len)
339 .arg_u32(num_chunks)
340 .arg_u32(num_k_heads)
341 .arg_u32(num_v_heads)
342 .arg_u32(kd)
343 .arg_u32(vd)
344 .arg_u32(qk_stride)
345 .arg_u32(gb_stride)
346 .arg_ptr(cu_seqlens)
347 .arg_ptr(cu_chunks)
348 .arg_u32(is_varlen as u32)
349 .launch(stream)?;
350 if let Some(t0) = t0 {
351 gpu.synchronize(stream)?;
352 let elapsed = t0.elapsed().as_micros();
353 tracing::info!(
354 " SSM prefill [gdn_fla_chunk_fwd_o] N={}: {}µs",
355 seq_len,
356 elapsed
357 );
358 }
359 Ok(())
360}