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}