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}