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/// The tensor-core spine's compile-time tile: `K_DIM == V_DIM` in
19/// `kernels/hopper/common/gated_delta_rule_chunk_tc.cu`.
20pub(crate) const GDN_TC_DIM: u32 = 128;
21/// That kernel's `CHUNK`.
22pub(crate) const GDN_TC_CHUNK: u32 = 64;
23/// SSOT mirror of `TCF_SMEM` in the same file:
24///   St[128][136] + Wp[64][136] + Up[64][136] + ducT[128][72] + dec[65] f32
25///   = 34816 + 17408 + 17408 + 18432 + 260 = 88 324 B.
26/// The padded 136/72 row strides are what make the MMA fragment reads
27/// bank-conflict-free; under-sizing this reads a tile out of bounds, so the
28/// launcher and the kernel must not be able to disagree about it.
29pub(crate) const GDN_TC_SMEM: u32 = GDN_TC_DIM * 136 * 2
30    + 2 * (GDN_TC_CHUNK * 136 * 2)
31    + GDN_TC_DIM * 72 * 2
32    + (GDN_TC_CHUNK + 1) * 4;
33
34/// Why the `ATLAS_GDN_PREFILL_TC` spine is NOT running — `None` means it is.
35///
36/// Pure so the grammar is testable without a GPU or the process environment.
37/// NAME THE GUARD THAT REJECTED: a perf path that asks to be enabled and
38/// silently is not measures as "no effect" (PR #296 shipped exactly that, an
39/// ldmatrix GEMM that fell back with no error while both gates stayed green).
40///
41/// The tile guards are not defensive padding. The kernel's descriptors, smem
42/// layout and fragment maps are all compile-time 128/128/64, and its K staging
43/// reads 16 bytes at a time, so a narrower head or an odd `qk_stride` would
44/// load the wrong columns or fault rather than run slowly.
45pub(crate) fn gdn_tc_spine_reject(
46    requested: bool,
47    kernel_present: bool,
48    k_dim: u32,
49    v_dim: u32,
50    chunk: u32,
51    qk_stride: u32,
52) -> Option<&'static str> {
53    if !requested {
54        Some("not requested")
55    } else if !kernel_present {
56        Some("kernel absent from this image")
57    } else if k_dim != GDN_TC_DIM || v_dim != GDN_TC_DIM || chunk != GDN_TC_CHUNK {
58        Some("head/chunk differs from the compile-time tile (K_DIM=V_DIM=128, CHUNK=64)")
59    } else if !qk_stride.is_multiple_of(8) {
60        Some("qk_stride is not a multiple of 8 (the K staging uses 16-byte vector loads)")
61    } else {
62        None
63    }
64}
65
66#[cfg(test)]
67#[path = "ssm_gdn_tc_tests.rs"]
68mod ssm_gdn_tc_tests;
69
70/// FLA multi-kernel chunked GDN prefill (`ATLAS_GDN_FLA=1`).
71///
72/// Three sequential launches on `stream` (CPU-serialized → no GPU sync needed):
73///   1. recompute_wu  (grid [num_chunks, nv, batch], 128 thr): solve (I+L)U=βV,
74///      (I+L)W=β·exp(gc)·K → W_out, U_out (bf16), gc_out (f32).
75///   2. chunk_delta_h_ksplit (grid [nv, batch], 256 thr): serial f32 state spine,
76///      2 threads/v-column for occupancy → S_out (per-chunk entry states bf16),
77///      uc_out (bf16); updates h_state in-place.
78///   3. chunk_fwd_o   (grid [num_chunks, nv, batch], 128 thr): O = Q̃·S_c +
79///      tril(decay·Q̃·Kᵀ)·uc → output (bf16, same layout as wy4).
80///
81/// W_out/U_out/S_out/uc_out are the caller's pre-sized scratch (BufferArena
82/// `gdn_fla_scratch`, sub-divided). Strides match the packed conv layout
83/// (qk_stride=v_stride=conv_dim, gb_stride=2*nv) exactly like the wy4/chunk64 path.
84#[allow(clippy::too_many_arguments)]
85pub fn gdn_prefill_fla(
86    gpu: &dyn GpuBackend,
87    k_recompute_wu: KernelHandle,
88    // Hopper twins of kernels 1 and 3 (#928), KernelHandle(0) off that target.
89    // Selection grammar and footprints: ops::ssm_gdn_hopper_prefill.
90    k_recompute_wu_hopper: KernelHandle,
91    k_chunk_fwd_o_hopper: KernelHandle,
92    k_chunk_delta_h: KernelHandle,
93    // wmma + DV-block-split spine (gated_delta_rule_chunk_delta_h_tc_vblock). When
94    // non-zero AND ATLAS_GDN_TC_VBLOCK=1, replaces the scalar ksplit spine (drop-in
95    // ABI; grid y = batch·num_dv_blocks, smem 81KB vs 97KB). KernelHandle(0) = off.
96    k_chunk_delta_h_tc_vblock: KernelHandle,
97    // TENSOR-CORE spine (gated_delta_rule_chunk_delta_h_tcfuse), behind
98    // ATLAS_GDN_PREFILL_TC (presence, default OFF). Drop-in ABI == the fused
99    // spine; grid [nv, batch] and block 256 are unchanged, only the smem
100    // footprint differs. KernelHandle(0) = absent from this image.
101    k_chunk_delta_h_tcfuse: KernelHandle,
102    k_chunk_delta_h_fused: KernelHandle,
103    k_chunk_delta_h_tma: KernelHandle,
104    k_chunk_fwd_o: KernelHandle,
105    h_state: DevicePtr,
106    query: DevicePtr,
107    key: DevicePtr,
108    value: DevicePtr,
109    gate: DevicePtr,
110    beta: DevicePtr,
111    output: DevicePtr,
112    w_out: DevicePtr,
113    u_out: DevicePtr,
114    s_out: DevicePtr,
115    uc_out: DevicePtr,
116    gc_out: DevicePtr,
117    batch_size: u32,
118    seq_len: u32,
119    num_chunks: u32,
120    num_k_heads: u32,
121    num_v_heads: u32,
122    k_dim: u32,
123    v_dim: u32,
124    qk_stride: u32,
125    v_stride: u32,
126    gb_stride: u32,
127    // h_state passed as a device POINTER TABLE (one [nv,kd,vd] per request) when
128    // batched co-dispatch reuses the per-request states; false = contiguous base.
129    h_state_is_table: bool,
130    // VARLEN (ragged co-dispatch): per-stream cu_seqlens (token offsets, batch+1
131    // ints) + cu_chunks (chunk offsets, batch+1 ints) on device. When is_varlen,
132    // `num_chunks` must be the MAX over streams (grid x). is_varlen=false →
133    // cu_* unused (pass NULL).
134    cu_seqlens: DevicePtr,
135    cu_chunks: DevicePtr,
136    is_varlen: bool,
137    profile: bool,
138    stream: u64,
139) -> Result<()> {
140    const C: u32 = 64; // CHUNK (kernel constant)
141    let (kd, vd) = (k_dim, v_dim);
142    // smem byte sizes — identical formulas to the GATE-B example (validated).
143    // L aliases the kk Gram (disjoint triangles) — one C*C*4 buffer, not two.
144    let smem_wu = C * kd * 2 + C * C * 4 + C * 4;
145    let smem_dh = 2 * (C * (2 * kd + vd) * 2) + 2 * C * 4 + 2 * (C + 1) * 4;
146    let smem_fo = C * kd * 2 + C * kd * 2 + C * C * 4 + C * vd * 2 + kd * vd * 2 + 2 * C * 4;
147
148    let mut t0: Option<std::time::Instant> = if profile {
149        gpu.synchronize(stream)?;
150        Some(std::time::Instant::now())
151    } else {
152        None
153    };
154
155    macro_rules! prof {
156        ($label:expr, $t0:expr) => {
157            if let Some(t0) = $t0.take() {
158                gpu.synchronize(stream)?;
159                let elapsed = t0.elapsed().as_micros();
160                tracing::info!("  SSM prefill [{}] N={}: {}µs", $label, seq_len, elapsed);
161                *$t0 = Some(std::time::Instant::now());
162            }
163        };
164    }
165
166    // The TC prefill FAMILY lever, resolved ONCE for the three kernels it picks:
167    // the twins here and the state spine below. `[defaults] gdn_prefill_tc`,
168    // `ATLAS_GDN_PREFILL_TC` overriding — a VALUE, not a presence check, so `=0`
169    // turns the family off. `ATLAS_NO_GDN_PREFILL_TC_REMNANTS=1` is the A/B that
170    // keeps the spine and pins these two to their parents.
171    let tc_requested = super::target_defaults::resolved().gdn_prefill_tc.value;
172    let (wu, fo) = gdn_hopper_remnants(
173        tc_requested,
174        k_recompute_wu,
175        smem_wu,
176        k_chunk_fwd_o,
177        smem_fo,
178        k_recompute_wu_hopper,
179        k_chunk_fwd_o_hopper,
180        kd,
181        vd,
182        C,
183    );
184
185    // Kernel 1: recompute_wu, or its Hopper twin.
186    KernelLaunch::new(gpu, wu.kernel)
187        .grid([num_chunks, num_v_heads, batch_size])
188        .block([wu.block, 1, 1])
189        .shared_mem(wu.smem)
190        .arg_ptr(key)
191        .arg_ptr(value)
192        .arg_ptr(gate)
193        .arg_ptr(beta)
194        .arg_ptr(w_out)
195        .arg_ptr(u_out)
196        .arg_ptr(gc_out)
197        .arg_u32(batch_size)
198        .arg_u32(seq_len)
199        .arg_u32(num_chunks)
200        .arg_u32(num_k_heads)
201        .arg_u32(num_v_heads)
202        .arg_u32(kd)
203        .arg_u32(vd)
204        .arg_u32(qk_stride)
205        .arg_u32(v_stride)
206        .arg_u32(gb_stride)
207        .arg_ptr(cu_seqlens)
208        .arg_ptr(cu_chunks)
209        .arg_u32(is_varlen as u32)
210        .launch(stream)?;
211    prof!("gdn_fla_recompute_wu", &mut t0);
212
213    // Kernel 2: chunk_delta_h — the fused spine OR the wmma + DV-block-split
214    // tc_vblock (gated). Both are drop-in ABI; only grid-y extent, block size and
215    // dynamic smem differ.
216    //
217    // DEFAULT is `..._vfused` (SPLIT=2, 256 threads): the two per-chunk passes are
218    // folded into one, which collapses `duc` from a CHUNK-long array to a scalar and
219    // drops smem 99,336 -> 49,412 B. That is worth 2.01x over ksplit on the isolated
220    // spine and ~68 ms of cold TTFT (one-variable A/B, same binary, 10 reps/leg).
221    //
222    // `ATLAS_GDN_VTILE=1` raises the same core to SPLIT=4 / 512 threads for 2.15x.
223    // It is NOT the default: it regressed tool-calling accuracy below the gate
224    // floors on BOTH models in a full record campaign —
225    //   bfcl-subset (27B)  83.62 / 82.72  vs floors 83.42 / 83.32  FAIL
226    //   bfcl-echolp (35B)  85.96 / 86.09  vs floors 86.10 / 86.50  FAIL
227    //   same binary, spine off             84.22 / 84.12            PASS
228    //
229    // ★ WHAT IS AND IS NOT KNOWN. The ssm-poisoning gate is the 4-minute tripwire
230    // that separates these, and it bisects the two changes vtile made at once:
231    //     ksplit  (unfused, SPLIT=2)  12/12 replays byte-identical
232    //     vfused  (fused,   SPLIT=2)  12/12   <- shipped
233    //     vtile   (fused,   SPLIT=4)   1/12
234    // So the FUSION is innocent and SPLIT=4 is implicated. The mechanism is NOT
235    // reassociation of the k-sum, which an earlier revision of this comment claimed:
236    // accumulating the SPLIT-way butterfly fold in Neumaier-compensated form scored
237    // 0/12 — no better — so that hypothesis is refuted and the true cause of
238    // SPLIT=4's drift is UNKNOWN. Since SPLIT=4 buys only 7% over SPLIT=2, the
239    // warp-density half was never where the win was.
240    //
241    // ★ Neither cos>=0.99 on the isolated spine NOR a byte-identical greedy
242    // comparison on a single prompt caught this. A drift too small to change one
243    // trajectory still moved BFCL by 1.4 points across 995 samples. Use the
244    // ssm-poisoning tripwire before trusting any change to this kernel.
245    let use_fused = k_chunk_delta_h_fused.0 != 0
246        && std::env::var("ATLAS_GDN_VTILE").ok().as_deref() != Some("0");
247    let use_tcvb = !use_fused
248        && k_chunk_delta_h_tc_vblock.0 != 0
249        && std::env::var("ATLAS_GDN_TC_VBLOCK").ok().as_deref() == Some("1");
250    const DV_BLK: u32 = 64; // matches the kernel's compile-time DV_BLK
251    let num_dv_blk = (vd / DV_BLK).max(1); // 2 for Holo (vd=128)
252    // tc_vblock smem: St[DV_BLK*kd] + ws[C*DV_BLK]f32 + buf[2][C*kd + C*DV_BLK] + gcb + decb
253    let smem_tcvb = DV_BLK * kd * 2
254        + C * DV_BLK * 4
255        + 2 * (C * kd + C * DV_BLK) * 2
256        + 2 * C * 4
257        + 2 * (C + 1) * 4;
258    // The fused spine stages {W,K,U} single-buffered plus one decay row; it does NOT
259    // split the DV axis, so grid.y stays `batch_size`. smem is identical for both
260    // members — only the thread count differs, and it must match the kernel that
261    // init.rs actually loaded for the same env value.
262    // `..._pipe` double-buffers {W,K,U} through `cp.async`, so it needs the SAME
263    // footprint the original (also double-buffered) spine uses — `smem_dh`. Under-
264    // sizing this reads the second slot out of bounds, so the selector has to agree
265    // with the kernel `init.rs` loaded for the same env value.
266    let pipe = std::env::var("ATLAS_GDN_PIPE").ok().as_deref() == Some("1");
267    let smem_fused = if pipe {
268        smem_dh
269    } else {
270        C * kd * 2 + C * kd * 2 + C * vd * 2 + (C + 1) * 4
271    };
272    let fused_block = match std::env::var("ATLAS_GDN_VTILE").ok().as_deref() {
273        Some("1") if !pipe => 512u32, // SPLIT=4 build
274        _ => 256u32,                  // SPLIT=2 build (default, and the pipe build)
275    };
276    // ── TENSOR-CORE spine (`[defaults] gdn_prefill_tc`, false everywhere) ────
277    //
278    // WHY, in one receipt (full derivation in GDN-PREFILL-ATTRIBUTION.md): on
279    // 1xH100 / Qwen3.8-27B-FP8, nsys round 9 (2026-09-11) put
280    // `gated_delta_rule_chunk_delta_h_vfused` at 97.8 ms of a 368.3 ms
281    // 1193-token prefill (26.6%) and 376.1 ms of a 1163.5 ms 4593-token prefill
282    // (32.3%) across 96 launches — 3.75 / 3.70 TFLOP/s and 94 / 89 GB/s, i.e.
283    // 5.6% of FP32 peak, 2.7% of HBM and 0.38% of bf16 tensor-core peak with
284    // ZERO mma instructions issued. Its per-chunk cost is FLAT in T (53.6 us at
285    // 19 chunks, 54.4 us at 72) at ~95 000 cycles against a one-SM FP32 floor of
286    // 16 384, so it is latency-bound on the 64-deep dependent FMA chain, not
287    // bandwidth- or FLOP-bound. Its two siblings in the same file, whose big
288    // matmuls are already on mma.sync, run at 16-17 and 12-14.5 TFLOP/s.
289    //
290    // This arm puts BOTH per-chunk products on mma.sync.m16n8k16 (bf16 operands,
291    // f32 accumulate). The recurrent state never leaves the f32 accumulator and
292    // the decay math stays exact f32; S_c and duc are newly rounded to bf16 as
293    // MMA operands, and the k-reduction is reassociated into the MMA tree. That
294    // is why this is OPT-IN: the campaign's standing lesson on this exact kernel
295    // is that a spine change can read cos=1.0000 and still cost 1.4 BFCL points
296    // (see the SPLIT=4 note in ssm_gdn_a3's kernel-2 comment), so promotion needs
297    // the ssm-poisoning tripwire, not a cosine.
298    //
299    // The enable bit comes from the COMPILED TARGET's `[defaults] gdn_prefill_tc`
300    // with `ATLAS_GDN_PREFILL_TC` overriding, the same rung as every other
301    // lever (`layers::ops::target_defaults`). Every target declares it false, so
302    // this is opt-in everywhere today; the row exists so the reason is written
303    // down beside the arch it applies to, and so `init.rs` can gate the PROBE on
304    // the same bit that launches the kernel.
305    //
306    // NAME THE GUARD THAT REJECTED — a perf path that asks to be enabled and
307    // silently is not measures as "no effect" (PR #296 shipped exactly that).
308    let smem_tcfuse = GDN_TC_SMEM;
309    let tc_reject = gdn_tc_spine_reject(
310        tc_requested,
311        k_chunk_delta_h_tcfuse.0 != 0,
312        kd,
313        vd,
314        C,
315        qk_stride,
316    );
317    if tc_requested && let Some(why) = tc_reject {
318        tracing::warn!("ATLAS_GDN_PREFILL_TC set but the tensor-core spine is NOT running: {why}");
319    }
320    let tc_ok = tc_reject.is_none();
321    if tc_ok {
322        // `ssm_gdn_tc_route`: built from the SAME constant `init_kernels`
323        // binds the handle with (round 12 caught this line naming the family).
324        tracing::info!(
325            "{}",
326            gdn_tc_spine_route_line(num_v_heads, batch_size, smem_tcfuse)
327        );
328    }
329    // ── TMA path (ATLAS_GDN_TMA=1) ───────────────────────────────────────────
330    // Every precondition is CHECKED, not assumed. The descriptors are encoded
331    // from the compile-time tile (K_DIM/V_DIM = 128, CHUNK = 64), so a runtime
332    // head narrower than the tile would load the wrong columns SILENTLY — TMA
333    // reports no error for a well-formed descriptor pointed at the wrong shape.
334    // Varlen is excluded because `choff` then comes from `cu_chunks` and the
335    // flat row count the descriptor needs is not known on the host.
336    let tma_requested = std::env::var("ATLAS_GDN_TMA").ok().as_deref() == Some("1");
337    // ★ NAME THE GUARD THAT REJECTED. A perf path that asks to be enabled and
338    // silently is not measures as "no effect" — PR #296 shipped exactly that
339    // (an ldmatrix GEMM that fell back with no error while both gates stayed
340    // green), and this path reproduced it during bring-up: an A/B ran with the
341    // env set, fell back to `vfused`, and the two arms differed by noise.
342    let tma_reject: Option<&str> = if !tma_requested {
343        Some("not requested")
344    } else if tc_ok {
345        // Both levers are set: TMA yields, because ATLAS_GDN_PREFILL_TC is the
346        // one with a numerics contract to measure. Say so rather than silently
347        // running one of the two.
348        Some("ATLAS_GDN_PREFILL_TC is active and takes precedence")
349    } else if k_chunk_delta_h_tma.0 == 0 {
350        Some("kernel absent from this image")
351    } else if is_varlen {
352        Some(
353            "varlen: choff comes from cu_chunks, so the descriptor's flat row count is unknown host-side",
354        )
355    } else if kd != 128 || vd != 128 || C != 64 {
356        Some("head/chunk differs from the compile-time tile the descriptors encode")
357    } else if !qk_stride.is_multiple_of(8) {
358        Some("qk_stride is not a multiple of 8 (bf16 row pitch must be 16-byte aligned)")
359    } else {
360        None
361    };
362    if tma_requested && let Some(why) = tma_reject {
363        tracing::warn!("ATLAS_GDN_TMA=1 but the TMA spine is NOT running: {why}");
364    }
365    let tma_ok = tma_reject.is_none();
366    if tma_ok {
367        tracing::info!("GDN state spine: gated_delta_rule_chunk_delta_h_tma");
368    }
369    // `cuda_backend` (and with it `TensorMap`) only exists under the cuda
370    // feature; the metal build has no TMA and must not reference it. The guard
371    // above already resolves to false there via the absent kernel handle, but a
372    // `use` is resolved at compile time regardless of the branch being taken.
373    #[cfg(feature = "cuda")]
374    if tma_ok {
375        use spark_runtime::cuda_backend::tensormap::TensorMap;
376        // W/U are [total_blocks][CHUNK][tile] flattened; as a 2-D tensor that is
377        // (total_blocks * CHUNK) rows of `tile` columns, contiguous.
378        let blocks = (batch_size as u64) * (num_chunks as u64) * (num_v_heads as u64);
379        let w_map =
380            TensorMap::tiled_2d_bf16(w_out, blocks * C as u64, kd as u64, kd as u64, C, kd)?;
381        let u_map =
382            TensorMap::tiled_2d_bf16(u_out, blocks * C as u64, vd as u64, vd as u64, C, vd)?;
383        // K is a VIEW into the packed qkvz tensor: rows are tokens at a
384        // `qk_stride` pitch, and the kernel supplies `kh * K_DIM` as the column
385        // origin. This is the gather `cdh_prefetch` does one row at a time.
386        let k_map = TensorMap::tiled_2d_bf16(
387            key,
388            (batch_size as u64) * (seq_len as u64),
389            qk_stride as u64,
390            qk_stride as u64,
391            C,
392            kd,
393        )?;
394        KernelLaunch::new(gpu, k_chunk_delta_h_tma)
395            .grid([num_v_heads, batch_size, 1])
396            .block([256, 1, 1])
397            .shared_mem(smem_dh)
398            .arg_ptr(h_state)
399            .arg_tensormap(w_map.bytes())
400            .arg_tensormap(u_map.bytes())
401            .arg_tensormap(k_map.bytes())
402            .arg_ptr(gc_out)
403            .arg_ptr(s_out)
404            .arg_ptr(uc_out)
405            .arg_u32(batch_size)
406            .arg_u32(seq_len)
407            .arg_u32(num_chunks)
408            .arg_u32(num_k_heads)
409            .arg_u32(num_v_heads)
410            .arg_u32(vd)
411            .arg_u32(h_state_is_table as u32)
412            .arg_ptr(cu_seqlens)
413            .arg_ptr(cu_chunks)
414            .arg_u32(is_varlen as u32)
415            .launch(stream)?;
416        prof!("gdn_fla_chunk_delta_h", &mut t0);
417    }
418
419    // Kernel 2 (non-TMA). Both paths write s_out/uc_out and fall through to
420    // kernel 3, which is identical either way.
421    if !tma_ok {
422        let (k_cdh, cdh_grid_y, cdh_smem, cdh_block) = if tc_ok {
423            (k_chunk_delta_h_tcfuse, batch_size, smem_tcfuse, 256u32)
424        } else if use_fused {
425            (k_chunk_delta_h_fused, batch_size, smem_fused, fused_block)
426        } else if use_tcvb {
427            (
428                k_chunk_delta_h_tc_vblock,
429                batch_size * num_dv_blk,
430                smem_tcvb,
431                256u32,
432            )
433        } else {
434            (k_chunk_delta_h, batch_size, smem_dh, 256u32)
435        };
436        KernelLaunch::new(gpu, k_cdh)
437            .grid([num_v_heads, cdh_grid_y, 1])
438            .block([cdh_block, 1, 1])
439            .shared_mem(cdh_smem)
440            .arg_ptr(h_state)
441            .arg_ptr(w_out)
442            .arg_ptr(u_out)
443            .arg_ptr(key)
444            .arg_ptr(gate)
445            .arg_ptr(gc_out)
446            .arg_ptr(s_out)
447            .arg_ptr(uc_out)
448            .arg_u32(batch_size)
449            .arg_u32(seq_len)
450            .arg_u32(num_chunks)
451            .arg_u32(num_k_heads)
452            .arg_u32(num_v_heads)
453            .arg_u32(kd)
454            .arg_u32(vd)
455            .arg_u32(qk_stride)
456            .arg_u32(gb_stride)
457            .arg_u32(h_state_is_table as u32)
458            .arg_ptr(cu_seqlens)
459            .arg_ptr(cu_chunks)
460            .arg_u32(is_varlen as u32)
461            .launch(stream)?;
462        prof!("gdn_fla_chunk_delta_h", &mut t0);
463    }
464
465    // Kernel 3: chunk_fwd_o, or its Hopper twin.
466    KernelLaunch::new(gpu, fo.kernel)
467        .grid([num_chunks, num_v_heads, batch_size])
468        .block([fo.block, 1, 1])
469        .shared_mem(fo.smem)
470        .arg_ptr(query)
471        .arg_ptr(key)
472        .arg_ptr(gate)
473        .arg_ptr(gc_out)
474        .arg_ptr(s_out)
475        .arg_ptr(uc_out)
476        .arg_ptr(output)
477        .arg_u32(batch_size)
478        .arg_u32(seq_len)
479        .arg_u32(num_chunks)
480        .arg_u32(num_k_heads)
481        .arg_u32(num_v_heads)
482        .arg_u32(kd)
483        .arg_u32(vd)
484        .arg_u32(qk_stride)
485        .arg_u32(gb_stride)
486        .arg_ptr(cu_seqlens)
487        .arg_ptr(cu_chunks)
488        .arg_u32(is_varlen as u32)
489        .launch(stream)?;
490    if let Some(t0) = t0 {
491        gpu.synchronize(stream)?;
492        let elapsed = t0.elapsed().as_micros();
493        tracing::info!(
494            "  SSM prefill [gdn_fla_chunk_fwd_o] N={}: {}µs",
495            seq_len,
496            elapsed
497        );
498    }
499    Ok(())
500}