spark_model/layers/dense_ffn.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Dense SwiGLU FFN component for non-MoE models.
4//!
5//! Forward: gate = gate_proj(x), up = up_proj(x), out = down_proj(SiLU(gate) * up)
6//! 2 fused kernel launches per decode token (dual GEMV + SiLU-fused down GEMV).
7
8use anyhow::Result;
9use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
10
11use crate::layer::ForwardContext;
12use crate::layers::ops;
13use crate::layers::w4a16_gemv_tiers::W4a16BatchmTiers;
14use crate::weight_map::{
15 DenseWeight, Fp8Weight, Fp8WeightTransposed, PackedQ2Weight, QuantizedWeight,
16};
17
18pub struct DenseFfnWeights {
19 pub gate_proj: QuantizedWeight,
20 pub up_proj: QuantizedWeight,
21 pub down_proj: QuantizedWeight,
22 /// Transposed ([K/2, N]) copies for the fast `w4a16_gemm_t_m128` prefill
23 /// kernel. `None` → prefill falls back to the slow M64xN64 base kernel.
24 /// The non-transposed copies above are kept for the decode gemv path.
25 pub gate_proj_t: Option<QuantizedWeight>,
26 pub up_proj_t: Option<QuantizedWeight>,
27 pub down_proj_t: Option<QuantizedWeight>,
28}
29
30/// BF16 dense MLP weights — alternative to NVFP4 for precision-sensitive
31/// models (Gemma-4-31B). Each is `[N, K]` row-major BF16. When installed
32/// on a `DenseFfnLayer` via `set_bf16_weights`, the forward paths
33/// dispatch to `dense_gemv_bf16` / `dense_gemm_bf16` instead of the
34/// w4a16 NVFP4 kernels. Costs ~3.4 GB extra GPU memory on Gemma-4-31B
35/// (3 × hidden×intermediate × 2 bytes) vs NVFP4's 0.5 bytes/weight.
36pub struct DenseFfnWeightsBf16 {
37 pub gate_proj: DenseWeight,
38 pub up_proj: DenseWeight,
39 pub down_proj: DenseWeight,
40}
41
42/// Native block-scaled FP8 dense MLP weights — loaded directly from an FP8
43/// checkpoint (no NVFP4 requant). When installed via `set_fp8_weights`, decode
44/// dispatches `w8a16_gemv` and prefill `w8a16_gemm` per projection (BF16 act ×
45/// FP8 E4M3 weight with 2D block scales), mirroring the SSM/attention FP8 path.
46pub struct DenseFfnWeightsFp8 {
47 pub gate_proj: Fp8Weight,
48 pub up_proj: Fp8Weight,
49 pub down_proj: Fp8Weight,
50}
51
52/// Native keep-packed ternary Q2_0 dense MLP weights — loaded directly from a
53/// PrismML Q2_0 GGUF (`AVAROK_GGUF_NATIVE_Q2=1`) with NO dequant / NVFP4 requant.
54/// Each projection is a raw `block_q2_0` buffer (2-bit codes + inline fp16 scale
55/// per group). When installed via `set_q2_weights`, decode dispatches
56/// `q2_0_gemv` (BF16 act × 2-bit weight, dequant-in-dot-product), mirroring the
57/// FP8 path but with the weights ~4× smaller resident.
58pub struct DenseFfnWeightsQ2 {
59 pub gate_proj: PackedQ2Weight,
60 pub up_proj: PackedQ2Weight,
61 pub down_proj: PackedQ2Weight,
62}
63
64/// Activation function for gated FFN (SiLU for Qwen/Llama, GELU for Gemma-4).
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum FfnActivation {
67 SiLU,
68 GeLU,
69}
70
71/// A per-projection int8 W4A8 weight, built lazily from the NVFP4 weight on the
72/// first `AVAROK_INT8_PREFILL` prefill (see `DenseFfnLayer::ensure_int8_weight`).
73/// `w_i8` is `[N, K]` signed int8; `w_scale` is `[N, K/32]` F32. Cached for the
74/// process lifetime in a `OnceLock`, so the requant kernel runs once per weight.
75#[derive(Debug, Clone, Copy)]
76struct Int8Weight {
77 w_i8: DevicePtr,
78 w_scale: DevicePtr,
79}
80
81/// Q4_K-quantized FFN weight (GGML block_q4_K layout), materialized once at first
82/// `AVAROK_FFN_MMQ` prefill and cached for process lifetime in a `OnceLock`.
83#[derive(Debug, Clone, Copy)]
84struct Q4kWeight {
85 w_q4k: DevicePtr,
86}
87
88/// block_nvfp4-repacked FFN weight for the `AVAROK_FFN_NVFP4_MMQ` W4A4 prefill arm.
89/// Raw bit shuffle of the checkpoint's NVFP4 (same e2m1 codes + e4m3 scale bytes,
90/// same total bytes) — materialized once and cached for process lifetime.
91#[derive(Debug, Clone, Copy)]
92struct Fp4MmqWeight {
93 w: DevicePtr,
94}
95
96pub struct DenseFfnLayer {
97 pub weights: DenseFfnWeights,
98 activation: FfnActivation,
99 w4a16_gemv: KernelHandle,
100 /// Single-warp `w4a16_gemv_sw`. `KernelHandle(0)` on miss → base GEMV.
101 w4a16_gemv_sw: KernelHandle,
102 w4a16_gemv_dual: KernelHandle,
103 w4a16_gemv_silu_input: KernelHandle,
104 // LOSSLESS single-warp-per-output decode variants (8 outputs/block, no smem
105 // cross-warp reduce). Bit-identical to the 64-thread kernels (proven by the
106 // w4a16_gemv_sw microtest). Default ON via `ModelLevers::gemv_sw`;
107 // `AVAROK_NO_GEMV_SW=1` restores the 64-thread kernels. KernelHandle(0) on
108 // miss → fall back to base kernels.
109 w4a16_gemv_dual_sw: KernelHandle,
110 w4a16_gemv_silu_input_sw: KernelHandle,
111 w4a16_gemv_dual_batch2: KernelHandle,
112 w4a16_gemv_dual_batch3: KernelHandle,
113 w4a16_gemv_batch2: KernelHandle,
114 w4a16_gemv_batch3: KernelHandle,
115 /// Narrow `w4a16_gemv_batch{M}` family (M=4..8) for the K=4 verify FFN and
116 /// the K=5..8 chain verify. SSOT for the M -> tier decision; individual
117 /// tiers are 0-handles when the target did not load them.
118 w4a16_batchm: W4a16BatchmTiers,
119 w4a16_gemm: KernelHandle,
120 // 128x128 2-stage cp.async pipelined w4a16 GEMM — the fast prefill kernel
121 // attention/SSM already use. The base `w4a16_gemm` (M64xN64) only hits
122 // ~10 TFLOPS at M=8k and was the flat ~155 tok/s dense-FFN prefill
123 // bottleneck on Qwen3.6-27B. KernelHandle(0) on miss → scalar-tile fallback.
124 w4a16_gemm_t_m128_k: KernelHandle,
125 // v2: 8-warp (256-thread) variant of t_m128 — parallel chunk MMAs, 3 CTAs/SM.
126 // Preferred over t_m128 for dense-FFN prefill when present. KernelHandle(0) → use t_m128.
127 w4a16_gemm_t_m128_v2_k: KernelHandle,
128 // LOSSLESS BF16 variant of t_m128: same 128x128 cp.async tiling, but FP4→BF16
129 // dequant + BF16 m16n8k16 MMA (FP32 accum) instead of the FP8-E4M3 crush the
130 // default NVIDIA t_m128 uses. The FP8 path perturbs generation (measured
131 // length-truncations / accuracy risk on Qwen3.6-27B); this kernel keeps prefill
132 // outputs bit-for-bit vs the base `w4a16_gemm`. OPT-IN only, gated by
133 // AVAROK_BF16_TC_PREFILL (default off → dispatch unchanged). KernelHandle(0) on miss.
134 w4a16_gemm_t_m128_bf16_k: KernelHandle,
135 // v2 of the LOSSLESS BF16 128x128 prefill kernel: same MMA instruction order
136 // (so BIT-IDENTICAL to bf16_k, proven by w4a16_bf16_v2_microtest) but a
137 // smaller A-tile smem pad lifts occupancy from 2→3 CTAs/SM (~+50% resident
138 // warps), giving a measured ~3-8% faster prefill GEMM on this latency-bound
139 // kernel. Preferred over bf16_k when present. KernelHandle(0) on miss → bf16_k.
140 w4a16_gemm_t_m128_bf16_v2_k: KernelHandle,
141 // FP8 M64 prefill (w4a16_gemm_t): m16n8k32 e4m3 MMA + M_TILE=64. Packed 1-byte
142 // operands cut shared-memory load instructions ~4x (the v2 BF16 path is
143 // smem-bandwidth-bound, L1/TEX 90% per ncu), and M64's lower register pressure
144 // lifts occupancy → measured ~44 TFLOP/s vs ~30 for v2 (~1.47x prefill) on dgx1.
145 // LOSSY (FP8 E4M3, cosine ~0.9997) — OPT-IN via AVAROK_FP8_M64_PREFILL, gated on
146 // quality. KernelHandle(0) on miss → dispatch unchanged.
147 w4a16_gemm_t_k: KernelHandle,
148 // int8 W4A8 prefill (AVAROK_INT8_PREFILL): the validated requant→faith2
149 // pipeline (cosine 0.999978). `int8_gemm_faith2` is an int8×int8 MMA with
150 // per-32 block scales, so BOTH operands must be int8 — unlike the FP8 path
151 // (mixed BF16×FP8). At first int8 prefill we requant the NVFP4 gate/up/down
152 // weights to int8 once (`requant_w_nvfp4_int8`, cached in the OnceLocks
153 // below) and requant the BF16 activations every call (`requant_a_bf16_int8`,
154 // into `int8_a_scratch`). KernelHandle(0) on miss → arm never taken.
155 int8_faith2_k: KernelHandle,
156 // faith5: int32 per-sb accumulation (breaks the MMA→scale dependency chain).
157 // Opt-in via AVAROK_INT8_FAITH5=1 (replaces faith2 for int8 prefill GEMMs).
158 int8_faith5_k: KernelHandle,
159 requant_w_int8_k: KernelHandle,
160 requant_a_int8_k: KernelHandle,
161 // Lazily-built, process-lifetime int8 weight copies (one per projection),
162 // requanted from `self.weights.{gate,up,down}_proj`. Only ever touched when
163 // AVAROK_INT8_PREFILL is set → default-off path is byte-identical.
164 int8_gate: std::sync::OnceLock<Int8Weight>,
165 int8_up: std::sync::OnceLock<Int8Weight>,
166 int8_down: std::sync::OnceLock<Int8Weight>,
167 // Activation-requant scratch for the int8/NVFP4/Q4_K prefill GEMMs is now
168 // shared, arena-owned (BufferArena::ffn_act_{q8,a,scale}), sized once for
169 // max_batch_tokens × max(h, inter) — no per-layer allocation.
170 // W4A4 native-FP4 prefill (AVAROK_FP4_PREFILL): NVFP4 weights consumed directly
171 // (no requant), BF16 activations quantized to NVFP4 each call into ffn_act_a/scale.
172 // KernelHandle(0) on miss → arm never taken (default-off byte-identical).
173 w4a4_gemm_k: KernelHandle,
174 quantize_nvfp4_k: KernelHandle,
175 // Q4_K MMQ prefill (AVAROK_FFN_MMQ): vendored llama Q4_K W4A8 GEMM. Weights
176 // materialized NVFP4→bf16→Q4_K once (lazy, cached in the OnceLocks); activations
177 // quantized to q8_1_mmq each call into ffn_act_q8. KernelHandle(0) → arm skipped.
178 q4k_mmq_nc_k: KernelHandle,
179 q4k_mmq_wc_k: KernelHandle,
180 q4k_quant_act_k: KernelHandle,
181 q4k_quant_w_k: KernelHandle,
182 dequant_nvfp4_bf16_k: KernelHandle,
183 q4k_gate: std::sync::OnceLock<Q4kWeight>,
184 q4k_up: std::sync::OnceLock<Q4kWeight>,
185 q4k_down: std::sync::OnceLock<Q4kWeight>,
186 // NVFP4 W4A4 MMQ prefill (AVAROK_FFN_NVFP4_MMQ): vendored llama Blackwell block-scale
187 // FP4 MMA (80 TFLOP/s vs t_m128 ~51 on GB10). Gate/up weights repacked ONCE at load
188 // (raw bit shuffle, checkpoint layout → block_nvfp4, zero requantization); activations
189 // quantized per call into the shared ffn_act_q8 scratch; the per-tensor scale2 is
190 // folded in the scaled SiLU-mul. KernelHandle(0) → arm skipped.
191 nvfp4_mmq_nc_k: KernelHandle,
192 nvfp4_mmq_wc_k: KernelHandle,
193 /// M-sized MMQ tiles for DECODE. The 128 tile issues MMAs for all 128 columns
194 /// regardless of m, so at m=16 it discards 112 of them; these size the tile to
195 /// the batch. try_kernel: 0-handle -> dispatch keeps the 128 tile.
196 nvfp4_mmq16_nc_k: KernelHandle,
197 nvfp4_mmq16_wc_k: KernelHandle,
198 nvfp4_mmq32_nc_k: KernelHandle,
199 nvfp4_mmq32_wc_k: KernelHandle,
200 nvfp4_mmq64_nc_k: KernelHandle,
201 nvfp4_mmq64_wc_k: KernelHandle,
202 nvfp4_quant_act_k: KernelHandle,
203 nvfp4_repack_k: KernelHandle,
204 nvfp4_silu_scaled_k: KernelHandle,
205 nvfp4_silu_quant_k: KernelHandle,
206 nvfp4_scale_k: KernelHandle,
207 fp4mmq_gate: std::sync::OnceLock<Fp4MmqWeight>,
208 fp4mmq_up: std::sync::OnceLock<Fp4MmqWeight>,
209 fp4mmq_down: std::sync::OnceLock<Fp4MmqWeight>,
210 // Small-M (DFlash verify M=17) routing companion to `w4a16_gemm_t_k`
211 // (declared above): deep-K variant. w4a16_m17_bench: `w4a16_gemm_t_k64`
212 // wins deep-K down_proj (554 vs 810us at K=17408); the M64-tile
213 // `w4a16_gemm_t` beats M128 tiles at M<=64 (283 vs 324us on gate/up).
214 // KernelHandle(0) → m128 dispatch.
215 w4a16_gemm_t_k64_k: KernelHandle,
216 /// SiLU(gate)*up or GELU(gate)*up depending on activation.
217 act_mul: KernelHandle,
218 /// BF16 dense MLP weights — when `Some`, all forward paths use the
219 /// `dense_gemv_bf16` / `dense_gemm_bf16` kernels instead of w4a16
220 /// NVFP4. Falls back to the NVFP4 weights when `None`. Set via
221 /// `set_bf16_weights`. Used by Gemma-4 dense to avoid the structural
222 /// NVFP4 attention drift on greedy code generation (the fib test's
223 /// broken-indentation pattern).
224 bf16_weights: Option<DenseFfnWeightsBf16>,
225 dense_gemv_bf16_k: KernelHandle,
226 dense_gemm_bf16_k: KernelHandle,
227 // Tensor-core BF16 GEMM (m16n8k16 MMA) for the dense-FFN PREFILL path.
228 // The scalar `dense_gemm_bf16` is ~10x too slow on long prefills (it was
229 // the flat ~155 tok/s prefill bottleneck on Qwen3.6-27B dense NVFP4).
230 // KernelHandle(0) on miss → forward_prefill falls back to the scalar path.
231 // Decode (gemv, M=1) is untouched, so TPOT is unaffected.
232 dense_gemm_tc_k: KernelHandle,
233 /// Native FP8 dense MLP weights — when `Some`, decode/prefill dispatch the
234 /// block-scaled FP8 kernels (`w8a16_gemv` / `w8a16_gemm`) instead of w4a16
235 /// NVFP4. Set via `set_fp8_weights` for native FP8 checkpoints (Qwythos /
236 /// Ornith-FP8). Spec-decode batched paths fall back to dequant — dense
237 /// qwen3_5 has no MTP, so they're never reached.
238 fp8_weights: Option<DenseFfnWeightsFp8>,
239 w8a16_gemv_k: KernelHandle,
240 w8a16_gemm_k: KernelHandle,
241 w8a16_gemv_batch4_k: KernelHandle,
242 /// MAX_M=16 sibling of `w8a16_gemv_batch4_k` — the 5..=32-row decode tier
243 /// (#927). KernelHandle(0) on a shadow that lacks the entry point, which
244 /// puts those widths back on the tile GEMMs. Rule: `batch16_decode.rs`.
245 w8a16_gemv_batch16_k: KernelHandle,
246 /// Whether that tier is ARMED — `AVAROK_FFN_BATCH16=1`, latched once here
247 /// at construction. DEFAULT FALSE: the tier measured a net LOSS in serving
248 /// on H100 (-5.4% aggregate at C=16, +50 ms TTFT) and has never been
249 /// measured on GB10, where the handle resolves just the same. Receipt and
250 /// the full rule: `batch16_decode.rs`.
251 ///
252 /// A FIELD rather than a call into `ffn_batch16_enabled()` at dispatch
253 /// time, for two reasons: the route is then fixed before any CUDA-graph
254 /// capture, and the dispatch tests can pin BOTH polarities without a
255 /// process-global `OnceLock` that latches on whichever test runs first.
256 batch16_enabled: bool,
257 /// Tensor-core 16-row-M-tile GEMM (#927) — the `AVAROK_FFN_M16_TC` tier
258 /// that sits AHEAD of `w8a16_gemv_batch16_k` at 5..=32 rows when the lever
259 /// is set. KernelHandle(0) on a shadow that lacks the entry point, which
260 /// leaves the ladder exactly as #927 shipped it. Rule: `dense_ffn_m16_tc.rs`.
261 w8a16_gemm_m16_k: KernelHandle,
262 /// The `N_TILE=64` twin (`AVAROK_FFN_M16_TC_NTILE=64`). KernelHandle(0) on a
263 /// shadow built before the wide arm existed, which silently keeps the
264 /// 32-wide kernel. Rule: `m16_tc::m16_tc_kernel`.
265 w8a16_gemm_m16_n64_k: KernelHandle,
266 /// `AVAROK_FFN_M16_TC` (or the `AVAROK_M16_TC` umbrella), cached at
267 /// construction. Since round 6 this lever reaches ONLY the FFN arm — the
268 /// attention tiers have their own, because the H100 measured them moving in
269 /// opposite directions. A FIELD rather than a per-call accessor for two
270 /// reasons: the selector runs per projection per layer per step, and the
271 /// dispatch tests drive both arms without racing the process-global
272 /// `OnceLock` the env read lives in. Grammar: `dense_ffn_m16_tc.rs`.
273 m16_tc: bool,
274 /// The CTA N width this layer asks for, 32 or 64. Same field-not-accessor
275 /// reasoning as `m16_tc`.
276 m16_tc_n_tile: u32,
277 w8a16_gemm_pipelined_k: KernelHandle,
278 // Fused FP8 decode GEMVs (gate+up in one launch / silu+down in one launch),
279 // mirroring the NVFP4 w4a16_gemv_dual / w4a16_gemv_silu_input. KernelHandle(0)
280 // on miss → fall back to the 3-launch w8a16_gemv path. Module = .cu file stem.
281 w8a16_gemv_dual_k: KernelHandle,
282 w8a16_gemv_silu_input_k: KernelHandle,
283 // Fast transposed FP8 prefill GEMM (128x128 / 8-warp / two-level FP32 fold).
284 // Preferred over w8a16_gemm when a transposed FP8 weight copy is present.
285 // KernelHandle(0) → fall back to non-transposed w8a16_gemm.
286 w8a16_gemm_t_m128_k: KernelHandle,
287 // W8A8 block-scaled prefill pair (#917/#928): per-token 1x128 FP8
288 // activation quant + the FP8xFP8 GEMM with both scale sets folded in an
289 // FP32 epilogue. Same two entry points the attention Q/K/V/O prefill
290 // already resolves, so no model shadow needs a new kernel; KernelHandle(0)
291 // on a shadow that lacks them -> the W8A16 branches still run.
292 // Dispatch rule + rationale live in `dense_ffn_w8a8_prefill.rs` (SSOT).
293 per_token_group_quant_fp8_k: ops::Fp8ActQuant,
294 fp8_gemm_t_blockscaled_k: KernelHandle,
295 // VEC128 activation-scale layout adapter for the cuBLASLt arm of the pair
296 // above: cuBLASLt reads those scales with the TOKEN index contiguous, the
297 // quantizer writes them K-group-contiguous. KernelHandle(0) -> the cuBLASLt
298 // arm is not selectable and the in-tree GEMM runs (SSOT for the rule:
299 // `dense_ffn_w8a8_prefill.rs::w8a8_gemm`).
300 fp8_act_scale_kmajor_k: KernelHandle,
301 /// The FUSED `[2*inter, hidden]` block-scaled FP8 gate+up weight (#927),
302 /// installed by `set_fp8_gate_up_fused`. `gate_proj` and `up_proj` above
303 /// are VIEWS inside it whenever it is `Some`, so every un-fused rung reads
304 /// the same bytes and no arm needs a second copy. `None` on every route
305 /// that did not build it — the arm is optional at runtime, not assumed.
306 fp8_gate_up_fused: Option<Fp8Weight>,
307 /// Whether the compiled target ARMS the fused arm (`[defaults]
308 /// ffn_gateup_fused`, overridable with `AVAROK_FFN_GATEUP_FUSED`), cached at
309 /// construction for the two reasons `batch16_tier` is: the selector runs
310 /// per layer per step, and the dispatch tests drive both polarities without
311 /// racing the process-global `OnceLock`.
312 gateup_fused: bool,
313 /// The strided SiLU·mul that reads the fused `[m, 2*inter]` output.
314 /// `silu_mul_strided.cu` is a HOPPER-OWNED source (`[kernels] overrides`),
315 /// so KernelHandle(0) on every other target — and the probe is GATED on the
316 /// resolved lever, like `w8a16_gemm_m16_k`, because an unresolved lookup
317 /// nothing declared fails the boot audit CLOSED.
318 silu_mul_strided_k: KernelHandle,
319 /// v0 LoRA overlay for gate/up/down. `set_lora_weights` REJECTS layers
320 /// where `fp8_weights`, `bf16_weights` or `q2_weights` are installed (v0
321 /// supports the NVFP4 dispatch path only — those branches early-return
322 /// before the NVFP4 tail where the deltas land; holo is NVFP4 so it is
323 /// unaffected).
324 ///
325 /// M1 (2026-08-19): the deltas are APPLIED. `apply_lora_gate_up` runs
326 /// after the gate/up projection and before `silu_mul`; `apply_lora_down`
327 /// runs after the down projection. Every NVFP4 dispatch this layer can
328 /// take — decode `forward`, `forward_k2`/`k3`/`km`, `forward_prefill`,
329 /// `forward_batched` — calls both, because an adapter that applies on one
330 /// path and not another produces a model that contradicts itself between
331 /// prefill and decode.
332 ///
333 /// Until M1 this field was written by `set_lora_weights` and never read,
334 /// so an adapter targeting gate/up/down loaded successfully and changed
335 /// nothing. On a hybrid like Qwen3.8-27B that is most of the adapter:
336 /// community LoRAs for it put 67-78% of their parameter mass in the FFN.
337 lora: Option<ops::lora_delta::LoraFfnWeights>,
338
339 /// Native keep-packed ternary Q2_0 dense MLP weights (`AVAROK_GGUF_NATIVE_Q2`).
340 /// When installed via `set_q2_weights`, decode dispatches `q2_0_gemv_vec`
341 /// (BF16 activation × packed 2-bit weight, dequant-in-dot-product) — the
342 /// weights stay 2-bit resident (no NVFP4 requant). Highest-priority forward
343 /// branch. Prefill/batched paths for packed-Q2 are a deferred (Tier-2) phase
344 /// and currently bail — dense qwen35 has no MTP so k2/k3 are never reached.
345 q2_weights: Option<DenseFfnWeightsQ2>,
346 q2_0_gemv_k: KernelHandle,
347 // Batched (M=1..8) packed-Q2 decode GEMV handle. The kernel + wrapper are
348 // built and validated (CPU math test), but the batched-decode call site
349 // (spec-decode verify rows) is deferred to the same Tier-2 phase as prefill;
350 // dense qwen35 has no MTP so no batched decode reaches the FFN today.
351 #[allow(dead_code)]
352 q2_0_gemv_batchm_k: KernelHandle,
353 // Load-time packed-Q2 → BF16 dequant kernel (`dequant_gguf_bf16` module).
354 // Used by packed-Q2 PREFILL: dequant each proj into a TRANSIENT BF16 scratch
355 // buffer, run the normal BF16 GEMM, free the scratch — the resident weight
356 // stays 2-bit. Decode uses the native `q2_0_gemv` (no dequant). Tier-1 path.
357 dequant_q2_0_gn_k: KernelHandle,
358 // Native Q2_0 MMQ prefill (Tier-2, `AVAROK_GGUF_NATIVE_Q2_MMQ=1`): keeps the
359 // 2-bit weight packed and runs a tensor-core int8 MMA (dequant-in-register)
360 // against a q8_1 activation — no BF16 weight scratch, no dequant tax, no race.
361 // The q8_1 activation quantizer is SHARED with Q4_K (`q4k_quant_act_k`).
362 // KernelHandle(0) when absent → falls back to the transient-dequant path.
363 q2_0_mmq_nc_k: KernelHandle,
364 q2_0_mmq_wc_k: KernelHandle,
365}
366
367/// M-sized MMQ tiles: **ON by default**, disabled by `AVAROK_NO_MMQ_SMALL_TILE=1`.
368/// Strict `== "1"` on an `AVAROK_NO_*` name — presence flags here are enabled by `=0`.
369fn mmq_small_tile_enabled() -> bool {
370 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
371 *ON.get_or_init(|| std::env::var("AVAROK_NO_MMQ_SMALL_TILE").as_deref() != Ok("1"))
372}
373
374/// The m=64 MMQ tile: **ON by default**, disabled by `AVAROK_NO_MMQ_TILE64=1`. Separate
375/// from `AVAROK_NO_MMQ_SMALL_TILE` so this arm can be A/B'd without also reverting the
376/// already-shipped 16/32 tiles. Strict `== "1"`, matching the sibling above.
377fn mmq_tile64_enabled() -> bool {
378 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
379 *ON.get_or_init(|| std::env::var("AVAROK_NO_MMQ_TILE64").as_deref() != Ok("1"))
380}
381impl DenseFfnLayer {
382 pub fn new(weights: DenseFfnWeights, gpu: &dyn GpuBackend) -> Result<Self> {
383 Self::new_with_activation(weights, FfnActivation::SiLU, gpu)
384 }
385
386 pub fn new_with_activation(
387 weights: DenseFfnWeights,
388 activation: FfnActivation,
389 gpu: &dyn GpuBackend,
390 ) -> Result<Self> {
391 let act_mul = match activation {
392 FfnActivation::SiLU => gpu.kernel("moe_silu_mul", "moe_silu_mul")?,
393 FfnActivation::GeLU => gpu.kernel("gelu", "gelu_mul")?,
394 };
395 // Resolved ONCE for the layer: it gates both the kernel probe below
396 // and the per-step selector, and those two must never disagree —
397 // a probe skipped while the selector says yes is a NULL launch.
398 // Rule: `dense_ffn_gateup_fused.rs`.
399 let gateup_fused = gateup_fused::ffn_gateup_fused();
400 // BF16 path kernels — optional (only loaded if available; gemma4
401 // is the only consumer today). `try_kernel` returns
402 // `KernelHandle(0)` on miss so we don't break NVFP4-only models
403 // that were built without these kernels. Module names per
404 // `kernels/gb10/{target}/nvfp4/KERNEL.toml`:
405 // `dense_gemv_bf16 = "gemv"`, `dense_gemm_bf16 = "gemm"`.
406 let dense_gemv_bf16_k = super::try_kernel(gpu, "gemv", "dense_gemv_bf16");
407 let dense_gemm_bf16_k = super::try_kernel(gpu, "gemm", "dense_gemm_bf16");
408 let dense_gemm_tc_k = super::try_kernel(gpu, "gemm_tc", "dense_gemm_tc");
409
410 let layer = Self {
411 weights,
412 activation,
413 w4a16_gemv: gpu.kernel("w4a16_gemv", "w4a16_gemv")?,
414 w4a16_gemv_sw: super::try_kernel(gpu, "w4a16_gemv", "w4a16_gemv_sw"),
415 w4a16_gemv_dual: gpu.kernel("w4a16_gemv_fused", "w4a16_gemv_dual")?,
416 w4a16_gemv_silu_input: gpu.kernel("w4a16_gemv_fused", "w4a16_gemv_silu_input")?,
417 w4a16_gemv_dual_sw: super::try_kernel(gpu, "w4a16_gemv_fused", "w4a16_gemv_dual_sw"),
418 w4a16_gemv_silu_input_sw: super::try_kernel(
419 gpu,
420 "w4a16_gemv_fused",
421 "w4a16_gemv_silu_input_sw",
422 ),
423 w4a16_gemv_dual_batch2: gpu.kernel("w4a16_gemv", "w4a16_gemv_dual_batch2")?,
424 w4a16_gemv_dual_batch3: gpu.kernel("w4a16_gemv", "w4a16_gemv_dual_batch3")?,
425 w4a16_gemv_batch2: gpu.kernel("w4a16_gemv", "w4a16_gemv_batch2")?,
426 w4a16_gemv_batch3: gpu.kernel("w4a16_gemv", "w4a16_gemv_batch3")?,
427 w4a16_batchm: W4a16BatchmTiers::resolve(gpu),
428 w4a16_gemm: gpu.kernel("w4a16", "w4a16_gemm")?,
429 w4a16_gemm_t_m128_k: super::try_kernel(gpu, "w4a16", "w4a16_gemm_t_m128"),
430 w4a16_gemm_t_m128_v2_k: super::w4a16_v2_kernel(gpu),
431 w4a16_gemm_t_m128_bf16_k: super::try_kernel(gpu, "w4a16", "w4a16_gemm_t_m128_bf16"),
432 w4a16_gemm_t_m128_bf16_v2_k: super::try_kernel(
433 gpu,
434 "w4a16",
435 "w4a16_gemm_t_m128_bf16_v2",
436 ),
437 w4a16_gemm_t_k: super::tgemm_kernel(gpu),
438 int8_faith2_k: super::try_kernel(gpu, "w4a16", "int8_gemm_faith2"),
439 int8_faith5_k: super::try_kernel(gpu, "w4a16", "int8_gemm_i32acc"),
440 requant_w_int8_k: super::try_kernel(gpu, "w4a16", "requant_w_nvfp4_int8"),
441 requant_a_int8_k: super::try_kernel(gpu, "w4a16", "requant_a_bf16_int8"),
442 int8_gate: std::sync::OnceLock::new(),
443 int8_up: std::sync::OnceLock::new(),
444 int8_down: std::sync::OnceLock::new(),
445 w4a4_gemm_k: super::try_kernel(gpu, "w4a4", "w4a4_gemm"),
446 quantize_nvfp4_k: super::try_kernel(gpu, "quantize_nvfp4", "quantize_bf16_to_nvfp4"),
447 q4k_mmq_nc_k: super::try_kernel(gpu, "q4k_mmq", "avarok_q4k_mmq128_nc"),
448 q4k_mmq_wc_k: super::try_kernel(gpu, "q4k_mmq", "avarok_q4k_mmq128_wc"),
449 q4k_quant_act_k: super::try_kernel(gpu, "q4k_mmq", "avarok_q8_1_quantize_ds4_bf16"),
450 q4k_quant_w_k: super::try_kernel(gpu, "q4k_quantize", "q4k_quantize"),
451 dequant_nvfp4_bf16_k: super::try_kernel(
452 gpu,
453 "dequant_nvfp4_bf16",
454 "dequant_nvfp4_to_bf16",
455 ),
456 q4k_gate: std::sync::OnceLock::new(),
457 q4k_up: std::sync::OnceLock::new(),
458 q4k_down: std::sync::OnceLock::new(),
459 nvfp4_mmq_nc_k: super::try_kernel(gpu, "nvfp4_mmq", "avarok_nvfp4_mmq128_nc"),
460 nvfp4_mmq_wc_k: super::try_kernel(gpu, "nvfp4_mmq", "avarok_nvfp4_mmq128_wc"),
461 nvfp4_mmq16_nc_k: super::try_kernel(gpu, "nvfp4_mmq", "avarok_nvfp4_mmq16_nc"),
462 nvfp4_mmq16_wc_k: super::try_kernel(gpu, "nvfp4_mmq", "avarok_nvfp4_mmq16_wc"),
463 nvfp4_mmq32_nc_k: super::try_kernel(gpu, "nvfp4_mmq", "avarok_nvfp4_mmq32_nc"),
464 nvfp4_mmq32_wc_k: super::try_kernel(gpu, "nvfp4_mmq", "avarok_nvfp4_mmq32_wc"),
465 nvfp4_mmq64_nc_k: super::try_kernel(gpu, "nvfp4_mmq", "avarok_nvfp4_mmq64_nc"),
466 nvfp4_mmq64_wc_k: super::try_kernel(gpu, "nvfp4_mmq", "avarok_nvfp4_mmq64_wc"),
467 nvfp4_quant_act_k: super::try_kernel(gpu, "nvfp4_mmq", "avarok_nvfp4_quantize_bf16"),
468 nvfp4_repack_k: super::try_kernel(gpu, "nvfp4_mmq", "avarok_nvfp4_repack"),
469 nvfp4_silu_scaled_k: super::try_kernel(
470 gpu,
471 "nvfp4_mmq",
472 "avarok_nvfp4_silu_mul_scaled",
473 ),
474 nvfp4_silu_quant_k: super::try_kernel(gpu, "nvfp4_mmq", "avarok_nvfp4_silu_mul_quant"),
475 nvfp4_scale_k: super::try_kernel(gpu, "nvfp4_mmq", "avarok_nvfp4_scale_bf16"),
476 fp4mmq_gate: std::sync::OnceLock::new(),
477 fp4mmq_up: std::sync::OnceLock::new(),
478 fp4mmq_down: std::sync::OnceLock::new(),
479 w4a16_gemm_t_k64_k: super::k64_kernel(gpu).unwrap_or(KernelHandle(0)),
480 act_mul,
481 bf16_weights: None,
482 dense_gemv_bf16_k,
483 dense_gemm_bf16_k,
484 dense_gemm_tc_k,
485 fp8_weights: None,
486 w8a16_gemv_k: super::try_kernel(gpu, "w8a16_gemv", "w8a16_gemv"),
487 w8a16_gemm_k: super::try_kernel(gpu, "w8a16_gemm", "w8a16_gemm"),
488 w8a16_gemv_batch4_k: super::try_kernel(gpu, "w8a16_gemv_batch4", "w8a16_gemv_batch4"),
489 w8a16_gemv_batch16_k: super::try_kernel(gpu, "w8a16_gemv_batch4", "w8a16_gemv_batch16"),
490 batch16_enabled: batch16_decode::ffn_batch16_enabled(),
491 w8a16_gemm_m16_k: super::try_target_kernel(gpu, "w8a16_gemm_m16", "w8a16_gemm_m16"),
492 w8a16_gemm_m16_n64_k: super::try_target_kernel(
493 gpu,
494 "w8a16_gemm_m16",
495 "w8a16_gemm_m16_n64",
496 ),
497 m16_tc: m16_tc::m16_tc_levers().ffn,
498 m16_tc_n_tile: m16_tc::m16_tc_levers().ffn_n_tile,
499 w8a16_gemm_pipelined_k: super::try_kernel(
500 gpu,
501 "w8a16_gemm_pipelined",
502 "w8a16_gemm_pipelined",
503 ),
504 w8a16_gemv_dual_k: super::try_kernel(gpu, "w8a16_gemv_fused", "w8a16_gemv_dual"),
505 w8a16_gemv_silu_input_k: super::try_kernel(
506 gpu,
507 "w8a16_gemv_fused",
508 "w8a16_gemv_silu_input",
509 ),
510 w8a16_gemm_t_m128_k: super::try_kernel(gpu, "w8a16_gemm_t_m128", "w8a16_gemm_t_m128"),
511 per_token_group_quant_fp8_k: ops::Fp8ActQuant::resolve(gpu),
512 fp8_gemm_t_blockscaled_k: super::try_kernel(
513 gpu,
514 "fp8_gemm_t_blockscaled",
515 "fp8_gemm_t_blockscaled",
516 ),
517 fp8_act_scale_kmajor_k: super::try_kernel(
518 gpu,
519 "fp8_scale_transpose",
520 "fp8_act_scale_to_kmajor",
521 ),
522 fp8_gate_up_fused: None,
523 gateup_fused,
524 // ★ PROBED ONLY WHEN THE ARM IS ARMED: an unresolved lookup
525 // nothing declared fails the boot audit CLOSED, and
526 // `silu_mul_strided.cu` is HOPPER-OWNED — no other hardware tree
527 // carries it. Gating the lookup on the resolved lever is the fix
528 // `ptx_set.rs` prescribes.
529 silu_mul_strided_k: if gateup_fused {
530 super::try_target_kernel(gpu, "silu_mul_strided", "silu_mul_strided")
531 } else {
532 KernelHandle(0)
533 },
534 lora: None,
535 q2_weights: None,
536 // Winner of the decode-GEMV bench: candidate B (vectorized code loads
537 // + smem A-stage, 1 warp/row × 8 rows/CTA). ~268 GB/s (98% of the
538 // 273 GB/s LPDDR5X peak) at gate/up M=1 — 9.5× the original
539 // whole-block-strided `q2_0_gemv`. Same `(code-1)*d` FP32 numerics.
540 q2_0_gemv_k: super::try_kernel(gpu, "q2_0_gemv_vec", "q2_0_gemv_vec"),
541 q2_0_gemv_batchm_k: super::try_kernel(gpu, "q2_0_gemv_vec", "q2_0_gemv_vec_batchm"),
542 dequant_q2_0_gn_k: super::try_kernel(
543 gpu,
544 "dequant_gguf_bf16",
545 "dequant_q2_0_gn_to_bf16",
546 ),
547 // Resolved by `set_q2_weights`, never here: q2_0_mmq ships only
548 // in GGUF-serving targets, and an unconditional probe fails the
549 // boot audit on every dense-FFN model that never installs
550 // packed-Q2 weights.
551 q2_0_mmq_nc_k: KernelHandle(0),
552 q2_0_mmq_wc_k: KernelHandle(0),
553 };
554 Ok(layer)
555 }
556
557 /// Load-time finalize for the Q4_K MMQ prefill path (`AVAROK_FFN_MMQ`). MUST run at
558 /// load, BEFORE the KV cache is sized, so the net FFN footprint is correct when the KV
559 /// cache claims free memory. Order is critical: (1) eagerly materialize the Q4_K weights
560 /// (+9.63 GB) so they are accounted for now rather than lazily on first prefill (which
561 /// would over-subscribe AFTER the KV cache already grabbed the freed `_t` space → decode
562 /// OOM-throttle); (2) free the transposed `_proj_t` copies (−9.63 GB, dead under Q4_K
563 /// prefill — only the unreachable `Some(wt)` arms read them). Net FFN = baseline; decode
564 /// untouched (NVFP4 gemv on the non-`_t` copies). No-op unless Q4_K is active.
565 pub fn finalize_q4k_load(
566 &mut self,
567 gpu: &dyn GpuBackend,
568 h: u32,
569 inter: u32,
570 stream: u64,
571 ) -> Result<()> {
572 // Packed-Q2 (AVAROK_GGUF_NATIVE_Q2) FFN keeps its NVFP4 source weights
573 // NULL — the Q4_K prefill copy is built by dequant-ing those (NULL) NVFP4
574 // blocks, so running it here is a null-ptr kernel launch (CUDA 700).
575 // Packed-Q2 has its own prefill path (transient dequant), so skip.
576 if self.q2_weights.is_some() {
577 return Ok(());
578 }
579 // Native FP8 (#915): `forward_prefill_inner` returns from inside its
580 // `self.fp8_weights` arm long before the Q4_K/MMQ arm, so this repack
581 // is dead work — and since #915 the loader installs NULL NVFP4 source
582 // weights on that route, over which `ensure_q4k_weight`'s dequant is a
583 // CUDA-700 illegal access. Same reason as the packed-Q2 guard above.
584 if self.fp8_weights.is_some() {
585 return Ok(());
586 }
587 let q4k_active = self.q4k_mmq_nc_k.0 != 0
588 && self.q4k_quant_act_k.0 != 0
589 && self.q4k_quant_w_k.0 != 0
590 && self.dequant_nvfp4_bf16_k.0 != 0
591 && std::env::var_os("AVAROK_FFN_MMQ").is_some();
592 if !q4k_active {
593 return Ok(());
594 }
595 // (1) eagerly materialize the prefill weights BEFORE freeing `_t`, so the KV cache
596 // (sized after load) can't claim the freed space before the weights exist.
597 // gate/up: Q4_K (N=inter,K=h). down: HYBRID → int8 faith2 (N=h,K=inter) for accuracy,
598 // else Q4_K. ensure_int8_weight reads the non-`_t` NVFP4 down_proj (kept for decode gemv).
599 self.ensure_q4k_weight(
600 &self.q4k_gate,
601 gpu,
602 &self.weights.gate_proj,
603 inter,
604 h,
605 stream,
606 )?;
607 self.ensure_q4k_weight(&self.q4k_up, gpu, &self.weights.up_proj, inter, h, stream)?;
608 let down_faith2 = self.int8_faith2_k.0 != 0
609 && self.requant_a_int8_k.0 != 0
610 && std::env::var_os("AVAROK_FFN_MMQ_DOWN_Q4K").is_none();
611 if down_faith2 {
612 self.ensure_int8_weight(
613 &self.int8_down,
614 gpu,
615 &self.weights.down_proj,
616 h,
617 inter,
618 stream,
619 )?;
620 } else {
621 self.ensure_q4k_weight(
622 &self.q4k_down,
623 gpu,
624 &self.weights.down_proj,
625 h,
626 inter,
627 stream,
628 )?;
629 }
630 gpu.synchronize(stream)?;
631 // (2) free the dead transposed copies
632 let mut freed = 0usize;
633 for wt in [
634 &mut self.weights.gate_proj_t,
635 &mut self.weights.up_proj_t,
636 &mut self.weights.down_proj_t,
637 ] {
638 if let Some(w) = wt.as_ref()
639 && !w.weight.is_null()
640 {
641 gpu.free(w.weight)?;
642 gpu.free(w.weight_scale)?;
643 freed += 1;
644 }
645 *wt = None;
646 }
647 if freed > 0 {
648 // Log-once latch (see `avarok_core::scope`). It holds no model-derived
649 // value — the message is rebuilt from the arguments every call — so a
650 // stale entry cannot produce a wrong answer, only a suppressed duplicate
651 // line after a model swap. Scoping it would thread a logging concern
652 // through the call path to prevent one repeated INFO line.
653 // Latched on the BACKEND (`OpCache::once`), which exists by load
654 // time: `finalize_q4k_load` takes the `gpu` it is loading onto. A
655 // static meant only the first model in the process reported the
656 // decision.
657 if gpu.op_cache().once("log:ffn_mmq_freed_twins") {
658 tracing::info!(
659 "[avarok] AVAROK_FFN_MMQ: freed transposed FFN `_t` copies (dead under Q4_K prefill) — Q4_K weights net to ~0 vs NVFP4 baseline"
660 );
661 }
662 }
663 Ok(())
664 }
665
666 /// Eagerly materialize the block_nvfp4 gate/up copies for the `AVAROK_FFN_NVFP4_MMQ`
667 /// W4A4 prefill arm at LOAD time (before KV sizing), then free the now-dead gate/up
668 /// transposed `_t` copies so net FFN footprint stays at the NVFP4 baseline. Down is
669 /// untouched (hybrid: it stays on the default t_m128 path for accuracy → keeps its
670 /// `_t` copy). No-op unless the env + kernels are present.
671 pub fn finalize_nvfp4_mmq_load(
672 &mut self,
673 gpu: &dyn GpuBackend,
674 h: u32,
675 inter: u32,
676 stream: u64,
677 ) -> Result<()> {
678 // Packed-Q2 (AVAROK_GGUF_NATIVE_Q2) FFN keeps its NVFP4 source weights
679 // NULL. This W4A4-MMQ finalize is active by DEFAULT (SiLU + kernels
680 // present) and repacks the NVFP4 gate/up — over NULL pointers that's a
681 // CUDA-700 illegal access. Packed-Q2 uses its own decode/prefill path.
682 if self.q2_weights.is_some() {
683 return Ok(());
684 }
685 // Native FP8 (#915): same reasoning as `finalize_q4k_load`, and this
686 // one is active by DEFAULT wherever the W4A4-MMQ kernels exist — so on
687 // a target that ships them it would repack NULL NVFP4 gate/up AND free
688 // `_t` copies for a prefill arm the FP8 early-return never reaches.
689 if self.fp8_weights.is_some() {
690 return Ok(());
691 }
692 let active = self.nvfp4_mmq_nc_k.0 != 0
693 && self.nvfp4_quant_act_k.0 != 0
694 && self.nvfp4_repack_k.0 != 0
695 && self.nvfp4_silu_scaled_k.0 != 0
696 && matches!(self.activation, FfnActivation::SiLU)
697 && std::env::var_os("AVAROK_NO_FFN_NVFP4_MMQ").is_none();
698 if !active {
699 return Ok(());
700 }
701 self.ensure_nvfp4_mmq_weight(
702 &self.fp4mmq_gate,
703 gpu,
704 &self.weights.gate_proj,
705 inter,
706 h,
707 stream,
708 )?;
709 self.ensure_nvfp4_mmq_weight(
710 &self.fp4mmq_up,
711 gpu,
712 &self.weights.up_proj,
713 inter,
714 h,
715 stream,
716 )?;
717 let down_mmq = std::env::var_os("AVAROK_NO_FFN_NVFP4_MMQ_DOWN").is_none();
718 if down_mmq {
719 self.ensure_nvfp4_mmq_weight(
720 &self.fp4mmq_down,
721 gpu,
722 &self.weights.down_proj,
723 h,
724 inter,
725 stream,
726 )?;
727 }
728 gpu.synchronize(stream)?;
729 // Free the dead transposed copies (prefill for those projections now runs on the
730 // MMQ arm; decode reads the non-transposed originals). down_proj_t is freed only
731 // when the down A/B gate is on.
732 let mut down_t = if down_mmq {
733 Some(&mut self.weights.down_proj_t)
734 } else {
735 None
736 };
737 let mut freed = 0usize;
738 for wt in [&mut self.weights.gate_proj_t, &mut self.weights.up_proj_t]
739 .into_iter()
740 .chain(down_t.take())
741 {
742 if let Some(w) = wt.as_ref()
743 && !w.weight.is_null()
744 {
745 gpu.free(w.weight)?;
746 gpu.free(w.weight_scale)?;
747 freed += 1;
748 }
749 *wt = None;
750 }
751 if freed > 0 {
752 // Log-once latch (see `avarok_core::scope`). It holds no model-derived
753 // value — the message is rebuilt from the arguments every call — so a
754 // stale entry cannot produce a wrong answer, only a suppressed duplicate
755 // line after a model swap. Scoping it would thread a logging concern
756 // through the call path to prevent one repeated INFO line.
757 // Latched on the BACKEND (`OpCache::once`), which exists by load
758 // time: `finalize_q4k_load` takes the `gpu` it is loading onto. A
759 // static meant only the first model in the process reported the
760 // decision.
761 if gpu.op_cache().once("log:ffn_fp4mmq_freed_twins") {
762 tracing::info!(
763 "[avarok] AVAROK_FFN_NVFP4_MMQ: freed gate/up `_t` copies (dead under FP4-MMQ prefill) — block_nvfp4 copies net to ~0 vs NVFP4 baseline"
764 );
765 }
766 }
767 Ok(())
768 }
769
770 /// Ensure the block_nvfp4 copy of one NVFP4 projection exists (raw repack of the
771 /// checkpoint's packed E2M1 `[N, K/2]` + E4M3 `[N, K/16]` scales — zero numerics;
772 /// scale2 folded at the SiLU-mul). Cached in `cell` for process lifetime.
773 fn ensure_nvfp4_mmq_weight(
774 &self,
775 cell: &std::sync::OnceLock<Fp4MmqWeight>,
776 gpu: &dyn GpuBackend,
777 src: &QuantizedWeight,
778 n: u32,
779 k: u32,
780 stream: u64,
781 ) -> Result<Fp4MmqWeight> {
782 if let Some(w) = cell.get() {
783 return Ok(*w);
784 }
785 let w = gpu.alloc(ops::nvfp4_mmq_weight_bytes(n, k))?;
786 ops::nvfp4_mmq_repack(
787 gpu,
788 self.nvfp4_repack_k,
789 src.weight,
790 src.weight_scale,
791 w,
792 n,
793 k,
794 stream,
795 )?;
796 let built = Fp4MmqWeight { w };
797 if let Err(dup) = cell.set(built) {
798 gpu.synchronize(stream)?;
799 let _ = gpu.free(dup.w);
800 }
801 Ok(*cell.get().expect("fp4mmq weight cell set above"))
802 }
803
804 /// Install native block-scaled FP8 dense MLP weights. After this call the
805 /// forward paths dispatch `w8a16_gemv` (decode) / `w8a16_gemm` (prefill)
806 /// instead of w4a16 NVFP4. Caller must ensure those kernels are present in
807 /// the target (they are for the qwen3_5/ornith nvfp4 bundle).
808 pub fn set_fp8_weights(&mut self, gate: Fp8Weight, up: Fp8Weight, down: Fp8Weight) {
809 self.fp8_weights = Some(DenseFfnWeightsFp8 {
810 gate_proj: gate,
811 up_proj: up,
812 down_proj: down,
813 });
814 }
815
816 /// Install the FUSED `[2*inter, hidden]` block-scaled FP8 gate+up weight
817 /// (#927), enabling the one-GEMM decode arm.
818 ///
819 /// 🪤 CONTRACT, and the loader is the only caller: `gate` and `up` as
820 /// passed to [`Self::set_fp8_weights`] must ALREADY be views inside
821 /// `fused` (`gate.weight == fused.weight`, `up.weight ==
822 /// fused.weight + inter*hidden`, and the same for the scale grid). Passing
823 /// a fused weight built from DIFFERENT bytes than the two projections
824 /// would make the fused and un-fused rungs of one layer disagree, silently,
825 /// at whichever `m` moved the dispatch between them. Rule and residency
826 /// argument: `dense_ffn_gateup_fused.rs`.
827 pub fn set_fp8_gate_up_fused(&mut self, fused: Fp8Weight) {
828 debug_assert!(
829 self.fp8_weights
830 .as_ref()
831 .is_some_and(|w| w.gate_proj.weight == fused.weight),
832 "the fused gate+up weight must be the buffer gate_proj is a view into"
833 );
834 self.fp8_gate_up_fused = Some(fused);
835 }
836
837 /// Install the startup-static LoRA FFN overlay (gate/up/down deltas).
838 /// Hard-rejects when FP8/BF16 weight overlays are installed — those
839 /// decode branches early-return before the NVFP4 tail where the M1
840 /// delta insertions land, so a permissive install would silently skip
841 /// deltas. holo is NVFP4, so it is unaffected.
842 pub fn set_lora_weights(&mut self, w: ops::lora_delta::LoraFfnWeights) -> Result<()> {
843 anyhow::ensure!(
844 self.fp8_weights.is_none() && self.bf16_weights.is_none(),
845 "LoRA v0 supports only the NVFP4 dense-FFN path (FP8/BF16 weight \
846 overlays installed on this layer)"
847 );
848 // Packed-Q2 has its own gemv/batchm branches that early-return before
849 // the NVFP4 tail where the deltas land, exactly like FP8/BF16. Refusing
850 // here keeps the invariant the M1 apply relies on: if `self.lora` is
851 // Some, EVERY dispatch this layer can take applies it. A silently
852 // skipping path is worse than a refused load — it makes the adapter
853 // active in prefill and absent in decode, which reads as model
854 // weirdness rather than as a missing feature.
855 anyhow::ensure!(
856 self.q2_weights.is_none(),
857 "LoRA v0 supports only the NVFP4 dense-FFN path (packed-Q2 weights \
858 installed on this layer)"
859 );
860 // The decode down delta contracts over silu(gate)*up, which only the
861 // split-SiLU path materialises; `forward` pins that path whenever an
862 // adapter is installed. Refuse here if the layer cannot take it, so
863 // the pin is a guarantee rather than a hope.
864 anyhow::ensure!(
865 self.activation == FfnActivation::SiLU && self.act_mul.0 != 0 && self.w4a16_gemv.0 != 0,
866 "LoRA v0 needs the split-SiLU decode path (SiLU activation + \
867 act_mul + w4a16_gemv kernels); this layer resolved activation \
868 {:?}, act_mul={}, w4a16_gemv={}",
869 self.activation,
870 self.act_mul.0,
871 self.w4a16_gemv.0,
872 );
873 self.lora = Some(w);
874 Ok(())
875 }
876
877 /// M1 gate/up delta: `gate_out += ΔW_gate · x`, `up_out += ΔW_up · x`.
878 ///
879 /// Call AFTER the gate/up projection and BEFORE `silu_mul` — the deltas
880 /// belong to the projections, so they must land while gate/up are still
881 /// separate. Both buffers are the arena's dedicated `expert_gate_out` /
882 /// `expert_up_out` regions, contiguous with row stride `inter*2`, which is
883 /// what `apply_lora_delta`'s contiguity contract requires.
884 ///
885 /// No-op (and no launches) when the layer carries no adapter, so the
886 /// non-LoRA path stays byte-identical.
887 fn apply_lora_gate_up(
888 &self,
889 ctx: &ForwardContext,
890 input: DevicePtr,
891 gate_out: DevicePtr,
892 up_out: DevicePtr,
893 m: u32,
894 stream: u64,
895 ) -> Result<()> {
896 if ops::lora_delta::lora_no_ffn() {
897 return Ok(());
898 }
899 let Some(ref lw) = self.lora else {
900 return Ok(());
901 };
902 for (pair, base) in [(&lw.gate, gate_out), (&lw.up, up_out)] {
903 if let Some(pair) = pair.as_ref() {
904 ops::lora_delta::apply_lora_delta(
905 ctx.gpu,
906 &lw.kernels,
907 pair,
908 input,
909 base,
910 m,
911 ctx.buffers.lora_xa(),
912 ctx.buffers.lora_delta(),
913 stream,
914 )?;
915 }
916 }
917 Ok(())
918 }
919
920 /// M1 down delta: `output += ΔW_down · act`.
921 ///
922 /// Call AFTER the down projection. `act` is the SiLU(gate)*up activation —
923 /// the same tensor the base down projection contracted over, NOT the
924 /// layer input. Every dense path leaves it in the `expert_gate_out`
925 /// region (silu_mul writes in place over gate), contiguous at row stride
926 /// `inter*2`.
927 fn apply_lora_down(
928 &self,
929 ctx: &ForwardContext,
930 act: DevicePtr,
931 output: DevicePtr,
932 m: u32,
933 stream: u64,
934 ) -> Result<()> {
935 if ops::lora_delta::lora_no_ffn() {
936 return Ok(());
937 }
938 let Some(ref lw) = self.lora else {
939 return Ok(());
940 };
941 let Some(ref pair) = lw.down else {
942 return Ok(());
943 };
944 ops::lora_delta::apply_lora_delta(
945 ctx.gpu,
946 &lw.kernels,
947 pair,
948 act,
949 output,
950 m,
951 ctx.buffers.lora_xa(),
952 ctx.buffers.lora_delta(),
953 stream,
954 )
955 }
956
957 /// Install native keep-packed ternary Q2_0 dense MLP weights. After this
958 /// call, decode `forward` dispatches `q2_0_gemv` per projection (weights
959 /// stay 2-bit resident, no NVFP4 requant) as the highest-priority path.
960 /// Caller must ensure the `q2_0_gemv` kernel is present in the target
961 /// (checked at forward time; falls through to a clear error otherwise).
962 /// Prefill for packed-Q2 is a deferred phase — see `forward_prefill`.
963 pub fn set_q2_weights(
964 &mut self,
965 gate: PackedQ2Weight,
966 up: PackedQ2Weight,
967 down: PackedQ2Weight,
968 gpu: &dyn GpuBackend,
969 ) {
970 self.q2_weights = Some(DenseFfnWeightsQ2 {
971 gate_proj: gate,
972 up_proj: up,
973 down_proj: down,
974 });
975 // Resolved here, not in the constructor: these ship only in
976 // GGUF-serving targets and the boot audit fails closed on an
977 // unconditional probe everywhere else.
978 self.q2_0_mmq_nc_k = super::try_kernel(gpu, "q2_0_mmq", "avarok_q2_0_mmq128_nc");
979 self.q2_0_mmq_wc_k = super::try_kernel(gpu, "q2_0_mmq", "avarok_q2_0_mmq128_wc");
980 }
981
982 /// Install BF16 dense MLP weights. After this call, the forward paths
983 /// dispatch to the BF16 GEMV/GEMM kernels instead of w4a16. The
984 /// caller must ensure the BF16 kernels are loaded (see
985 /// `dense_gemv_bf16_k` / `dense_gemm_bf16_k` checks). Small-batch
986 /// paths reuse `forward_prefill` so they cannot enter NVFP4 kernels
987 /// with the null placeholder weights used by BF16-native layers.
988 pub fn set_bf16_weights(&mut self, gate: DenseWeight, up: DenseWeight, down: DenseWeight) {
989 self.bf16_weights = Some(DenseFfnWeightsBf16 {
990 gate_proj: gate,
991 up_proj: up,
992 down_proj: down,
993 });
994 }
995
996 /// Ensure the int8 W4A8 copy of one NVFP4 projection weight exists, building
997 /// it once via `requant_w_nvfp4_int8` and caching it in `cell`. Reads the
998 /// NON-transposed NVFP4 layout (`weight` = packed E2M1 `[N, K/2]`,
999 /// `weight_scale` = per-16 E4M3 `[N, K/16]`, `weight_scale_2` = per-tensor
1000 /// F32) — so it is independent of the `*_proj_t` transposed copies. The
1001 /// requant launches on `stream`; the subsequent faith2 read is stream-ordered
1002 /// after it, so no host sync is needed.
1003 fn ensure_int8_weight(
1004 &self,
1005 cell: &std::sync::OnceLock<Int8Weight>,
1006 gpu: &dyn GpuBackend,
1007 src: &QuantizedWeight,
1008 n: u32,
1009 k: u32,
1010 stream: u64,
1011 ) -> Result<Int8Weight> {
1012 if let Some(w) = cell.get() {
1013 return Ok(*w);
1014 }
1015 let (nn, kk) = (n as usize, k as usize);
1016 let w_i8 = gpu.alloc(nn * kk)?; // [N, K] int8
1017 let w_scale = gpu.alloc(nn * (kk / 32) * 4)?; // [N, K/32] F32
1018 ops::requant_w_nvfp4_int8(
1019 gpu,
1020 self.requant_w_int8_k,
1021 src.weight,
1022 src.weight_scale,
1023 src.weight_scale_2,
1024 w_i8,
1025 w_scale,
1026 n,
1027 k,
1028 stream,
1029 )?;
1030 let built = Int8Weight { w_i8, w_scale };
1031 // Lost a race (another thread built first): free our duplicate buffers.
1032 if let Err(dup) = cell.set(built) {
1033 let _ = gpu.free(dup.w_i8);
1034 let _ = gpu.free(dup.w_scale);
1035 }
1036 Ok(*cell.get().expect("int8 weight cell set above"))
1037 }
1038
1039 /// Lazily materialize a Q4_K FFN weight from the NVFP4 source: dequant NVFP4→bf16
1040 /// (transient buffer, freed) then quantize bf16→GGML block_q4_K (cached for the
1041 /// process lifetime). `src` is the non-transposed NVFP4 weight `[n, k]`.
1042 fn ensure_q4k_weight(
1043 &self,
1044 cell: &std::sync::OnceLock<Q4kWeight>,
1045 gpu: &dyn GpuBackend,
1046 src: &QuantizedWeight,
1047 n: u32,
1048 k: u32,
1049 stream: u64,
1050 ) -> Result<Q4kWeight> {
1051 if let Some(w) = cell.get() {
1052 return Ok(*w);
1053 }
1054 // transient bf16 [n, k] (freed after quantize); persistent Q4_K bytes.
1055 let bf16_tmp = gpu.alloc((n as usize) * (k as usize) * 2)?;
1056 ops::dequant_nvfp4_to_bf16(
1057 gpu,
1058 self.dequant_nvfp4_bf16_k,
1059 src.weight,
1060 src.weight_scale,
1061 bf16_tmp,
1062 src.weight_scale_2,
1063 n,
1064 k,
1065 stream,
1066 )?;
1067 let w_q4k = gpu.alloc(ops::q4k_weight_bytes(n, k))?;
1068 ops::quantize_weight_q4k(gpu, self.q4k_quant_w_k, bf16_tmp, w_q4k, n, k, stream)?;
1069 // bf16_tmp consumed by the quantize on `stream`; sync before freeing it.
1070 gpu.synchronize(stream)?;
1071 let _ = gpu.free(bf16_tmp);
1072 let built = Q4kWeight { w_q4k };
1073 if let Err(dup) = cell.set(built) {
1074 let _ = gpu.free(dup.w_q4k);
1075 }
1076 Ok(*cell.get().expect("q4k weight cell set above"))
1077 }
1078
1079 /// Single-token decode: 2-3 kernel launches depending on activation.
1080 /// SiLU: dual GEMV + SiLU-fused down GEMV (2 launches).
1081 /// GELU: dual GEMV + gelu_mul + down GEMV (3 launches, no fused GELU down kernel).
1082 pub fn forward(
1083 &self,
1084 input: DevicePtr,
1085 ctx: &ForwardContext,
1086 stream: u64,
1087 ) -> Result<DevicePtr> {
1088 let h = ctx.config.hidden_size as u32;
1089 let inter = ctx.config.intermediate_size as u32;
1090
1091 let gate_out = ctx.buffers.expert_gate_out();
1092 let up_out = ctx.buffers.expert_up_out();
1093
1094 // Native keep-packed Q2_0 dispatch (highest priority). Per-projection
1095 // `q2_0_gemv`: BF16 activation × packed 2-bit weight, dequant in the
1096 // dot-product — weights never expand to BF16/NVFP4. No fused dual/silu
1097 // kernel yet, so this is gate + up + silu_mul + down (4 launches),
1098 // mirroring the FP8 non-fused fallback. SiLU only (Ternary-Bonsai is a
1099 // Qwen-family SwiGLU); GeLU packed-Q2 is a follow-up.
1100 if let Some(ref q2w) = self.q2_weights {
1101 if self.q2_0_gemv_k.0 == 0 {
1102 anyhow::bail!(
1103 "q2_0_gemv kernel missing in this target build — packed-Q2 decode \
1104 (AVAROK_GGUF_NATIVE_Q2) is unavailable"
1105 );
1106 }
1107 if self.activation != FfnActivation::SiLU {
1108 anyhow::bail!(
1109 "packed-Q2 FFN decode supports SiLU only (got {:?})",
1110 self.activation
1111 );
1112 }
1113 let output = ctx.buffers.moe_output();
1114 ops::q2_0_gemv_vec(
1115 ctx.gpu,
1116 self.q2_0_gemv_k,
1117 input,
1118 &q2w.gate_proj,
1119 gate_out,
1120 stream,
1121 )?;
1122 ops::q2_0_gemv_vec(
1123 ctx.gpu,
1124 self.q2_0_gemv_k,
1125 input,
1126 &q2w.up_proj,
1127 up_out,
1128 stream,
1129 )?;
1130 ops::silu_mul(
1131 ctx.gpu,
1132 self.act_mul,
1133 gate_out,
1134 up_out,
1135 gate_out,
1136 inter,
1137 stream,
1138 )?;
1139 ops::q2_0_gemv_vec(
1140 ctx.gpu,
1141 self.q2_0_gemv_k,
1142 gate_out,
1143 &q2w.down_proj,
1144 output,
1145 stream,
1146 )?;
1147 return Ok(output);
1148 }
1149
1150 // FP8 dispatch: the fused FP8 dual-GEMV (gate+up in one launch) then the
1151 // down projection, mirroring the NVFP4 path. Falls back to the 4-launch
1152 // per-projection `w8a16_gemv` path when the fused kernels or a non-SiLU
1153 // activation make the fast path unavailable.
1154 //
1155 // SPLIT SiLU+down (DEFAULT; kill-switch AVAROK_NO_DECODE_SPLIT_SILU) —
1156 // #928. The NVFP4 arm below has staged `silu(gate)*up` once since the
1157 // ncu receipt quoted there; the FP8 arm never got the same treatment
1158 // and paid for it. `w8a16_gemv_silu_input` recomputes the SwiGLU PER
1159 // OUTPUT, not per block: each of the N/4 CTAs gives every one of its 4
1160 // outputs a 64-lane team that walks all of K, so the launch evaluates
1161 // N*K = 5,120 x 17,408 = 89.1 M silu(gate)*up — 5,120x the 17,408 the
1162 // token actually needs — and each one is an `__expf` plus a true FP32
1163 // divide (`--fmad=false`, no `-use_fast_math`, so `g/(1+e^-g)` is the
1164 // IEEE division sequence, not a reciprocal). nsys, 1xH100,
1165 // Qwen/Qwen3.8-27B-FP8, 2026-09-11 round 7, C=1 step 21.891 ms: 64
1166 // launches x 103.9 us = 6.65 ms/step = 30.4% of the step at 858 GB/s,
1167 // against the `w8a16_gemv_dual` that reads the SAME 89.1 MB of weights
1168 // per layer at 1,979 GB/s. Staging the activation once (one elementwise
1169 // launch over K, microseconds, and CUDA graphs amortise the launch)
1170 // leaves the down GEMV a pure weight-streaming kernel.
1171 //
1172 // NUMERICS, stated rather than implied: this is NOT bit-identical to
1173 // the fused kernel. `moe_silu_mul` rounds `g*(1/(1+e^-g))*u` to BF16
1174 // before the GEMV consumes it, where the fused kernel keeps
1175 // `(g/(1+e^-g))*u` in FP32 all the way into the dot product — a BF16
1176 // round plus a reciprocal-vs-divide difference on the activation. It
1177 // IS the numerics prefill runs, and the same trade the NVFP4 arm has
1178 // shipped by default; `AVAROK_NO_DECODE_SPLIT_SILU` restores the fused
1179 // kernel bit-for-bit.
1180 if let Some(ref fp8w) = self.fp8_weights {
1181 let output = ctx.buffers.moe_output();
1182 let arm = fp8_down::fp8_down_arm(
1183 self.activation == FfnActivation::SiLU,
1184 self.w8a16_gemv_dual_k.0 != 0,
1185 self.w8a16_gemv_silu_input_k.0 != 0,
1186 self.act_mul.0 != 0,
1187 self.w8a16_gemv_k.0 != 0,
1188 ctx.levers.decode_split_silu,
1189 );
1190 if arm != fp8_down::Fp8DownArm::PerProjection {
1191 ops::w8a16_gemv_dual(
1192 ctx.gpu,
1193 self.w8a16_gemv_dual_k,
1194 input,
1195 fp8w.gate_proj.weight,
1196 fp8w.gate_proj.row_scale,
1197 gate_out,
1198 fp8w.up_proj.weight,
1199 fp8w.up_proj.row_scale,
1200 up_out,
1201 inter,
1202 h,
1203 stream,
1204 )?;
1205 if arm == fp8_down::Fp8DownArm::SplitSilu {
1206 ops::silu_mul(
1207 ctx.gpu,
1208 self.act_mul,
1209 gate_out,
1210 up_out,
1211 gate_out,
1212 inter,
1213 stream,
1214 )?;
1215 ops::w8a16_gemv(
1216 ctx.gpu,
1217 self.w8a16_gemv_k,
1218 gate_out,
1219 fp8w.down_proj.weight,
1220 fp8w.down_proj.row_scale,
1221 output,
1222 h,
1223 inter,
1224 stream,
1225 )?;
1226 } else {
1227 ops::w8a16_gemv_silu_input(
1228 ctx.gpu,
1229 self.w8a16_gemv_silu_input_k,
1230 gate_out,
1231 up_out,
1232 fp8w.down_proj.weight,
1233 fp8w.down_proj.row_scale,
1234 output,
1235 h,
1236 inter,
1237 stream,
1238 )?;
1239 }
1240 return Ok(output);
1241 }
1242 ops::w8a16_gemv(
1243 ctx.gpu,
1244 self.w8a16_gemv_k,
1245 input,
1246 fp8w.gate_proj.weight,
1247 fp8w.gate_proj.row_scale,
1248 gate_out,
1249 inter,
1250 h,
1251 stream,
1252 )?;
1253 ops::w8a16_gemv(
1254 ctx.gpu,
1255 self.w8a16_gemv_k,
1256 input,
1257 fp8w.up_proj.weight,
1258 fp8w.up_proj.row_scale,
1259 up_out,
1260 inter,
1261 h,
1262 stream,
1263 )?;
1264 ops::silu_mul(
1265 ctx.gpu,
1266 self.act_mul,
1267 gate_out,
1268 up_out,
1269 gate_out,
1270 inter,
1271 stream,
1272 )?;
1273 ops::w8a16_gemv(
1274 ctx.gpu,
1275 self.w8a16_gemv_k,
1276 gate_out,
1277 fp8w.down_proj.weight,
1278 fp8w.down_proj.row_scale,
1279 output,
1280 h,
1281 inter,
1282 stream,
1283 )?;
1284 return Ok(output);
1285 }
1286
1287 // BF16 dispatch: per-projection GEMV via `dense_gemv_bf16`. We
1288 // don't have a fused dual-BF16-GEMV kernel today; two sequential
1289 // launches are still BF16-precision-correct and only ~10% slower
1290 // than the fused w4a16 path on Gemma-4-31B (the cost is dominated
1291 // by the bigger BF16 weight reads, not launch overhead).
1292 if let Some(ref bf16w) = self.bf16_weights {
1293 ops::dense_gemv(
1294 ctx.gpu,
1295 self.dense_gemv_bf16_k,
1296 input,
1297 &bf16w.gate_proj,
1298 gate_out,
1299 inter,
1300 h,
1301 stream,
1302 )?;
1303 ops::dense_gemv(
1304 ctx.gpu,
1305 self.dense_gemv_bf16_k,
1306 input,
1307 &bf16w.up_proj,
1308 up_out,
1309 inter,
1310 h,
1311 stream,
1312 )?;
1313 ops::silu_mul(
1314 ctx.gpu,
1315 self.act_mul,
1316 gate_out,
1317 up_out,
1318 gate_out,
1319 inter,
1320 stream,
1321 )?;
1322 let output = ctx.buffers.moe_output();
1323 ops::dense_gemv(
1324 ctx.gpu,
1325 self.dense_gemv_bf16_k,
1326 gate_out,
1327 &bf16w.down_proj,
1328 output,
1329 h,
1330 inter,
1331 stream,
1332 )?;
1333 return Ok(output);
1334 }
1335
1336 // AVAROK_DECODE_FFN_VIA_GEMM=1: route decode's M=1 FFN projections
1337 // through the SAME transposed-weight GEMM kernels the DFlash verify
1338 // path uses (`w4a16_prefill_gemm` → w4a16_gemm_t / _t_k64), instead
1339 // of the dedicated GEMV kernels. Purpose: bit-identical FFN numerics
1340 // between serial decode and batched verify — the batch-K vs batch-1
1341 // divergence #218's bisect isolated ("FFN non-associativity") and the
1342 // root cause of the T=0 spec trajectory flips (2026-07-07 session).
1343 // Split SiLU staging already matches prefill SiLU numerics (swiglu
1344 // clamp), so with this arm the whole FFN block is kernel-identical to
1345 // a verify row. Requires the *_proj_t transposed copies (the NVFP4-MMQ
1346 // prefill arm FREES them — disable it if the warn below fires).
1347 // The `OnceLock<bool>` static that lived here is now a field on
1348 // `layers::ops::ModelLevers` — resolved when the model is built and carried
1349 // on `ForwardContext`, because a static outlives the model whose flags it
1350 // encodes.
1351 if ctx.levers.decode_ffn_via_gemm
1352 && self.activation == FfnActivation::SiLU
1353 && self.act_mul.0 != 0
1354 {
1355 let wt_alive =
1356 |w: &Option<QuantizedWeight>| w.as_ref().is_some_and(|w| !w.weight.is_null());
1357 if wt_alive(&self.weights.gate_proj_t) && wt_alive(&self.weights.up_proj_t) {
1358 // Log-once latch (see `avarok_core::scope`). It holds no model-derived
1359 // value — the message is rebuilt from the arguments every call — so a
1360 // stale entry cannot produce a wrong answer, only a suppressed duplicate
1361 // line after a model swap. Scoping it would thread a logging concern
1362 // through the call path to prevent one repeated INFO line.
1363 if ctx.stats.once("log:decode_ffn_via_gemm") {
1364 tracing::info!(
1365 "decode FFN via verify GEMM path (AVAROK_DECODE_FFN_VIA_GEMM=1): \
1366 gate/up/down through w4a16_prefill_gemm at M=1"
1367 );
1368 }
1369 self.w4a16_prefill_gemm(
1370 ctx,
1371 &self.weights.gate_proj,
1372 self.weights.gate_proj_t.as_ref(),
1373 input,
1374 gate_out,
1375 1,
1376 inter,
1377 h,
1378 stream,
1379 )?;
1380 self.w4a16_prefill_gemm(
1381 ctx,
1382 &self.weights.up_proj,
1383 self.weights.up_proj_t.as_ref(),
1384 input,
1385 up_out,
1386 1,
1387 inter,
1388 h,
1389 stream,
1390 )?;
1391 ops::silu_mul(
1392 ctx.gpu,
1393 self.act_mul,
1394 gate_out,
1395 up_out,
1396 gate_out,
1397 inter,
1398 stream,
1399 )?;
1400 let output = ctx.buffers.moe_output();
1401 self.w4a16_prefill_gemm(
1402 ctx,
1403 &self.weights.down_proj,
1404 self.weights.down_proj_t.as_ref(),
1405 gate_out,
1406 output,
1407 1,
1408 h,
1409 inter,
1410 stream,
1411 )?;
1412 return Ok(output);
1413 }
1414 // Log-once latch (see `avarok_core::scope`). It holds no model-derived
1415 // value — the message is rebuilt from the arguments every call — so a
1416 // stale entry cannot produce a wrong answer, only a suppressed duplicate
1417 // line after a model swap. Scoping it would thread a logging concern
1418 // through the call path to prevent one repeated INFO line.
1419 if ctx.stats.once("log:decode_ffn_no_twins") {
1420 tracing::warn!(
1421 "AVAROK_DECODE_FFN_VIA_GEMM=1 requested but transposed FFN copies \
1422 are freed/absent (NVFP4-MMQ prefill arm?) — falling back to GEMV; \
1423 the unification experiment is NOT active"
1424 );
1425 }
1426 }
1427
1428 // Fused gate_proj + up_proj: [1, H] → [1, inter] × 2.
1429 // Single-warp variant (lossless) when the lever is on and the kernel
1430 // resolved; otherwise the 64-thread kernel. Dual and silu-input SW
1431 // are independent — missing silu_input_sw must not skip dual_sw on
1432 // the default split-SiLU path.
1433 let use_dual_sw = ops::use_gemv_sw(ctx.levers.gemv_sw, self.w4a16_gemv_dual_sw);
1434 let use_silu_sw = ops::use_gemv_sw(ctx.levers.gemv_sw, self.w4a16_gemv_silu_input_sw);
1435 if use_dual_sw {
1436 ops::w4a16_gemv_dual_sw(
1437 ctx.gpu,
1438 self.w4a16_gemv_dual_sw,
1439 input,
1440 &self.weights.gate_proj,
1441 gate_out,
1442 &self.weights.up_proj,
1443 up_out,
1444 inter,
1445 h,
1446 stream,
1447 )?;
1448 } else {
1449 ops::w4a16_gemv_dual(
1450 ctx.gpu,
1451 self.w4a16_gemv_dual,
1452 input,
1453 &self.weights.gate_proj,
1454 gate_out,
1455 &self.weights.up_proj,
1456 up_out,
1457 inter,
1458 h,
1459 stream,
1460 )?;
1461 }
1462
1463 let output = ctx.buffers.moe_output();
1464 // Split SiLU+down (DEFAULT; kill-switch AVAROK_NO_DECODE_SPLIT_SILU): the fused
1465 // silu_input kernel recomputes the SiLU transcendentals per OUTPUT ROW (N/4
1466 // blocks × redundant __expf) and measures COMPUTE-bound — ncu: SM 57% vs
1467 // memory 23%, 186 GB/s vs the dual GEMV's 266. Staging silu(gate)*up once
1468 // (one elementwise launch, CUDA graphs amortize it) lets the down GEMV run
1469 // memory-bound like the dual. Also aligns decode with the prefill SiLU
1470 // numerics (swiglu clamp), which the fused kernel lacked.
1471 //
1472 // An installed LoRA adapter PINS this path. The fused `silu_input`
1473 // alternative never materialises silu(gate)*up — it consumes gate and
1474 // up straight into the down GEMV — and the down delta has to contract
1475 // over exactly that activation. Reproducing it into `lora_hact` just
1476 // to feed the delta would compute the SiLU twice for a path that is
1477 // already the default and already the numerically preferred one, so
1478 // the adapter pins it instead. `set_lora_weights` refuses an adapter
1479 // when this path is unavailable on the layer, which makes
1480 // `lora.is_some()` imply the three conditions above.
1481 let split_silu = self.activation == FfnActivation::SiLU
1482 && self.act_mul.0 != 0
1483 && self.w4a16_gemv.0 != 0
1484 && (ctx.levers.decode_split_silu || self.lora.is_some());
1485 if split_silu {
1486 self.apply_lora_gate_up(ctx, input, gate_out, up_out, 1, stream)?;
1487 ops::silu_mul(
1488 ctx.gpu,
1489 self.act_mul,
1490 gate_out,
1491 up_out,
1492 gate_out,
1493 inter,
1494 stream,
1495 )?;
1496 ops::w4a16_decode_gemv(
1497 ctx.gpu,
1498 self.w4a16_gemv,
1499 self.w4a16_gemv_sw,
1500 ctx.levers.gemv_sw,
1501 gate_out,
1502 &self.weights.down_proj,
1503 output,
1504 h,
1505 inter,
1506 stream,
1507 )?;
1508 self.apply_lora_down(ctx, gate_out, output, 1, stream)?;
1509 return Ok(output);
1510 }
1511 debug_assert!(
1512 self.lora.is_none(),
1513 "LoRA installed but decode took the fused silu_input path, which \
1514 never materialises the activation the down delta contracts over; \
1515 set_lora_weights is supposed to make this unreachable"
1516 );
1517 match self.activation {
1518 FfnActivation::SiLU => {
1519 // Fused SiLU(gate)*up + down_proj: [1, inter] → [1, H]
1520 if use_silu_sw {
1521 ops::w4a16_gemv_silu_input_sw(
1522 ctx.gpu,
1523 self.w4a16_gemv_silu_input_sw,
1524 gate_out,
1525 up_out,
1526 &self.weights.down_proj,
1527 output,
1528 h,
1529 inter,
1530 stream,
1531 )?;
1532 } else {
1533 ops::w4a16_gemv_silu_input(
1534 ctx.gpu,
1535 self.w4a16_gemv_silu_input,
1536 gate_out,
1537 up_out,
1538 &self.weights.down_proj,
1539 output,
1540 h,
1541 inter,
1542 stream,
1543 )?;
1544 }
1545 }
1546 FfnActivation::GeLU => {
1547 // GELU(gate)*up → gate_out, then down_proj GEMV
1548 ops::silu_mul(
1549 ctx.gpu,
1550 self.act_mul,
1551 gate_out,
1552 up_out,
1553 gate_out,
1554 inter,
1555 stream,
1556 )?;
1557 ops::w4a16_decode_gemv(
1558 ctx.gpu,
1559 self.w4a16_gemv,
1560 self.w4a16_gemv_sw,
1561 ctx.levers.gemv_sw,
1562 gate_out,
1563 &self.weights.down_proj,
1564 output,
1565 h,
1566 inter,
1567 stream,
1568 )?;
1569 }
1570 }
1571
1572 Ok(output)
1573 }
1574
1575 /// Packed-Q2 batched decode FFN for `m` concurrent rows (`m >= 2`). Mirrors
1576 /// the single-token `forward` packed-Q2 arm — per-projection keep-packed
1577 /// `q2_0_gemv_vec_batchm` (BF16 `[m,·]` activation × 2-bit weight, dequant in
1578 /// the dot-product, no BF16/NVFP4 expansion), SiLU-mul between gate/up, then
1579 /// down — but with `m` activation rows staged per weight read. This is the
1580 /// correctness path for concurrent decode (C>=2): the NVFP4 `forward_k2/k3`
1581 /// GEMVs read the NULL NVFP4 fallback weights, so packed-Q2 must route here.
1582 /// The wrapper chunks internally for `m > 8`. SiLU only (Ternary-Bonsai is a
1583 /// SwiGLU); output lands in `moe_output` as `[m, h]` row-major.
1584 fn forward_km_q2(
1585 &self,
1586 q2w: &DenseFfnWeightsQ2,
1587 input: DevicePtr,
1588 ctx: &ForwardContext,
1589 m: u32,
1590 stream: u64,
1591 ) -> Result<()> {
1592 if self.q2_0_gemv_batchm_k.0 == 0 {
1593 anyhow::bail!(
1594 "q2_0_gemv_vec_batchm kernel missing in this target build — packed-Q2 \
1595 batched decode (AVAROK_GGUF_NATIVE_Q2, C>=2) is unavailable"
1596 );
1597 }
1598 if self.activation != FfnActivation::SiLU {
1599 anyhow::bail!(
1600 "packed-Q2 FFN batched decode supports SiLU only (got {:?})",
1601 self.activation
1602 );
1603 }
1604 let inter = ctx.config.intermediate_size as u32;
1605 let gate_out = ctx.buffers.expert_gate_out();
1606 let up_out = ctx.buffers.expert_up_out();
1607 let batchm = |w: &PackedQ2Weight, inp: DevicePtr, out: DevicePtr| -> Result<()> {
1608 ops::q2_0_gemv_vec_batchm(ctx.gpu, self.q2_0_gemv_batchm_k, inp, w, out, m, stream)
1609 };
1610 batchm(&q2w.gate_proj, input, gate_out)?;
1611 batchm(&q2w.up_proj, input, up_out)?;
1612 ops::silu_mul(
1613 ctx.gpu,
1614 self.act_mul,
1615 gate_out,
1616 up_out,
1617 gate_out,
1618 m * inter,
1619 stream,
1620 )?;
1621 let output = ctx.buffers.moe_output();
1622 batchm(&q2w.down_proj, gate_out, output)?;
1623 Ok(())
1624 }
1625
1626 /// K=2 speculative: batched GEMV for 2 tokens.
1627 /// 3 launches: dual batch2 (gate+up) + silu_mul + batch2 (down).
1628 pub fn forward_k2(&self, input: DevicePtr, ctx: &ForwardContext, stream: u64) -> Result<()> {
1629 // Packed-Q2: NVFP4 fallback weights are NULL, so the NVFP4 batch2 GEMVs
1630 // below would fault. Route to the keep-packed batchm FFN (m=2).
1631 if let Some(ref q2w) = self.q2_weights {
1632 return self.forward_km_q2(q2w, input, ctx, 2, stream);
1633 }
1634 if native_small_batch_uses_prefill(self.bf16_weights.is_some(), self.fp8_weights.is_some())
1635 {
1636 return self.forward_prefill(input, 2, ctx, stream);
1637 }
1638
1639 let h = ctx.config.hidden_size as u32;
1640 let inter = ctx.config.intermediate_size as u32;
1641
1642 let gate_out = ctx.buffers.expert_gate_out();
1643 let up_out = ctx.buffers.expert_up_out();
1644
1645 // Fused gate+up for 2 tokens
1646 ops::w4a16_gemv_dual_batch2(
1647 ctx.gpu,
1648 self.w4a16_gemv_dual_batch2,
1649 input,
1650 &self.weights.gate_proj,
1651 gate_out,
1652 &self.weights.up_proj,
1653 up_out,
1654 inter,
1655 h,
1656 stream,
1657 )?;
1658 self.apply_lora_gate_up(ctx, input, gate_out, up_out, 2, stream)?;
1659 ops::silu_mul(
1660 ctx.gpu,
1661 self.act_mul,
1662 gate_out,
1663 up_out,
1664 gate_out,
1665 2 * inter,
1666 stream,
1667 )?;
1668 let output = ctx.buffers.moe_output();
1669 ops::w4a16_gemv_batch2(
1670 ctx.gpu,
1671 self.w4a16_gemv_batch2,
1672 gate_out,
1673 &self.weights.down_proj,
1674 output,
1675 h,
1676 inter,
1677 stream,
1678 )?;
1679 self.apply_lora_down(ctx, gate_out, output, 2, stream)?;
1680
1681 Ok(())
1682 }
1683
1684 /// K=3 speculative: batched GEMV for 3 tokens.
1685 /// 3 launches: dual batch3 (gate+up) + silu_mul + batch3 (down).
1686 pub fn forward_k3(&self, input: DevicePtr, ctx: &ForwardContext, stream: u64) -> Result<()> {
1687 // Packed-Q2: route to the keep-packed batchm FFN (m=3); NVFP4 weights null.
1688 if let Some(ref q2w) = self.q2_weights {
1689 return self.forward_km_q2(q2w, input, ctx, 3, stream);
1690 }
1691 if native_small_batch_uses_prefill(self.bf16_weights.is_some(), self.fp8_weights.is_some())
1692 {
1693 return self.forward_prefill(input, 3, ctx, stream);
1694 }
1695
1696 let h = ctx.config.hidden_size as u32;
1697 let inter = ctx.config.intermediate_size as u32;
1698
1699 let gate_out = ctx.buffers.expert_gate_out();
1700 let up_out = ctx.buffers.expert_up_out();
1701
1702 // Fused gate+up for 3 tokens
1703 ops::w4a16_gemv_dual_batch3(
1704 ctx.gpu,
1705 self.w4a16_gemv_dual_batch3,
1706 input,
1707 &self.weights.gate_proj,
1708 gate_out,
1709 &self.weights.up_proj,
1710 up_out,
1711 inter,
1712 h,
1713 stream,
1714 )?;
1715 self.apply_lora_gate_up(ctx, input, gate_out, up_out, 3, stream)?;
1716 ops::silu_mul(
1717 ctx.gpu,
1718 self.act_mul,
1719 gate_out,
1720 up_out,
1721 gate_out,
1722 3 * inter,
1723 stream,
1724 )?;
1725 let output = ctx.buffers.moe_output();
1726 ops::w4a16_gemv_batch3(
1727 ctx.gpu,
1728 self.w4a16_gemv_batch3,
1729 gate_out,
1730 &self.weights.down_proj,
1731 output,
1732 h,
1733 inter,
1734 stream,
1735 )?;
1736 self.apply_lora_down(ctx, gate_out, output, 3, stream)?;
1737
1738 Ok(())
1739 }
1740
1741 /// Batchm-GEMV kernel for `m` verify rows: the narrowest resolved tier in
1742 /// `w4a16_gemv_batch{4,5,6,7,8}` that covers `m`. 0-handle when out of
1743 /// range or absent. See `layers::w4a16_gemv_tiers` for the decision and
1744 /// the `AVAROK_NO_GEMV_EXACT_M_TIERS=1` kill switch.
1745 fn batchm_kernel(&self, m: u32) -> KernelHandle {
1746 self.w4a16_batchm.kernel(m)
1747 }
1748
1749 /// Whether the M-row batched-GEMV verify path is available for `m` rows
1750 /// (batchm kernel present AND NVFP4 weights loaded — the batchm GEMV
1751 /// reads the non-transposed NVFP4 layout).
1752 pub fn can_forward_km(&self, m: u32) -> bool {
1753 self.batchm_kernel(m).0 != 0
1754 && (!self.weights.gate_proj.weight.is_null()
1755 // Native FP8 (#915): the loader no longer builds the NVFP4
1756 // fallback, but `forward_km` redirects to `forward_prefill` for
1757 // an FP8 layer anyway (`native_small_batch_uses_prefill`), so
1758 // the answer must stay `true` or the n=4..8 verify arm in
1759 // `multi_seq/ffn.rs:145` would fall through to a DIFFERENT
1760 // branch and quietly change the routing this fix must not touch.
1761 || self.fp8_weights.is_some())
1762 }
1763
1764 /// K=m (m<=8) speculative verify: batched GEMV for m tokens.
1765 /// 4 launches: batchm gate + batchm up + silu_mul + batchm down — each
1766 /// projection weight is read ONCE for all m rows at near-peak stream
1767 /// bandwidth. nsys (2026-07-18, M=4): the `forward_prefill` MMQ arm this
1768 /// replaces for the K=4 verify cost 54.8 ms/step across the 64-layer
1769 /// dense FFN stack (~156 GB/s effective at M=4); the batch GEMV family
1770 /// measures ~290 GB/s on the same shapes (w8a16_gemv_batch4 sibling),
1771 /// putting this path at the ~31 ms weight-traffic floor. m=5..8 uses
1772 /// `w4a16_gemv_batch8` (batchm_bench: same weight-streaming bandwidth,
1773 /// removing the M>4 tile-GEMM cliff for chain-verify K=5..8).
1774 pub fn forward_km(
1775 &self,
1776 input: DevicePtr,
1777 m: u32,
1778 ctx: &ForwardContext,
1779 stream: u64,
1780 ) -> Result<()> {
1781 // As in k2/k3, concurrent decode must preserve an installed native
1782 // overlay even when valid, lower-precision NVFP4 fallbacks coexist.
1783 if native_small_batch_uses_prefill(self.bf16_weights.is_some(), self.fp8_weights.is_some())
1784 {
1785 return self.forward_prefill(input, m as usize, ctx, stream);
1786 }
1787 let h = ctx.config.hidden_size as u32;
1788 let inter = ctx.config.intermediate_size as u32;
1789 let kh = self.batchm_kernel(m);
1790
1791 let gate_out = ctx.buffers.expert_gate_out();
1792 let up_out = ctx.buffers.expert_up_out();
1793
1794 ops::w4a16_gemv_batchm(
1795 ctx.gpu,
1796 kh,
1797 input,
1798 &self.weights.gate_proj,
1799 gate_out,
1800 m,
1801 inter,
1802 h,
1803 stream,
1804 )?;
1805 ops::w4a16_gemv_batchm(
1806 ctx.gpu,
1807 kh,
1808 input,
1809 &self.weights.up_proj,
1810 up_out,
1811 m,
1812 inter,
1813 h,
1814 stream,
1815 )?;
1816 self.apply_lora_gate_up(ctx, input, gate_out, up_out, m, stream)?;
1817 ops::silu_mul(
1818 ctx.gpu,
1819 self.act_mul,
1820 gate_out,
1821 up_out,
1822 gate_out,
1823 m * inter,
1824 stream,
1825 )?;
1826 let output = ctx.buffers.moe_output();
1827 ops::w4a16_gemv_batchm(
1828 ctx.gpu,
1829 kh,
1830 gate_out,
1831 &self.weights.down_proj,
1832 output,
1833 m,
1834 h,
1835 inter,
1836 stream,
1837 )?;
1838 self.apply_lora_down(ctx, gate_out, output, m, stream)?;
1839
1840 Ok(())
1841 }
1842
1843 /// N-token prefill: GEMM for all projections.
1844 /// W4A16 prefill/verify GEMM dispatch, routed by (M, K) per
1845 /// w4a16_m17_bench measurements on GB10:
1846 /// - M<=64 (DFlash verify M=17): the M64-tile `w4a16_gemm_t` beats the
1847 /// M128-tile kernels (283 vs 324us on gate/up — 87% of an M128 tile
1848 /// is padding at M=17), and `w4a16_gemm_t_k64` wins deep-K down_proj
1849 /// (554 vs 810us at K=17408, where N/128 CTAs can't fill the GPU and
1850 /// the halved K-loop matters).
1851 /// - M>64 (real prefill): v2 (8-warp) > t_m128 (4-warp), unchanged.
1852 /// - No transposed copy: base `w4a16_gemm` (9-12x the bandwidth floor —
1853 /// last resort).
1854 ///
1855 /// Kill-switch: AVAROK_FFN_SMALLM=0 restores the m128-only dispatch for A/B.
1856 #[allow(clippy::too_many_arguments)]
1857 fn w4a16_prefill_gemm(
1858 &self,
1859 ctx: &ForwardContext,
1860 w: &QuantizedWeight,
1861 wt: Option<&QuantizedWeight>,
1862 input: DevicePtr,
1863 output: DevicePtr,
1864 m: u32,
1865 n: u32,
1866 k: u32,
1867 stream: u64,
1868 ) -> Result<()> {
1869 // The `OnceLock<bool>` static that lived here is now a field on
1870 // `layers::ops::ModelLevers` — resolved when the model is built and carried
1871 // on `ForwardContext`, because a static outlives the model whose flags it
1872 // encodes.
1873 if let Some(wt) = wt {
1874 if m <= 64 && k.is_multiple_of(32) && ctx.levers.ffn_small_m {
1875 if k >= crate::layers::w4a16_k64_min_k()
1876 && k.is_multiple_of(64)
1877 && self.w4a16_gemm_t_k64_k.0 != 0
1878 {
1879 return ops::w4a16_gemm_n128(
1880 ctx.gpu,
1881 self.w4a16_gemm_t_k64_k,
1882 input,
1883 wt,
1884 output,
1885 m,
1886 n,
1887 k,
1888 stream,
1889 );
1890 }
1891 if self.w4a16_gemm_t_k.0 != 0 {
1892 return ops::w4a16_gemm_n128(
1893 ctx.gpu,
1894 self.w4a16_gemm_t_k,
1895 input,
1896 wt,
1897 output,
1898 m,
1899 n,
1900 k,
1901 stream,
1902 );
1903 }
1904 }
1905 if self.w4a16_gemm_t_m128_v2_k.0 != 0 {
1906 return ops::w4a16_gemm_n128_m128_v2(
1907 ctx.gpu,
1908 self.w4a16_gemm_t_m128_v2_k,
1909 input,
1910 wt,
1911 output,
1912 m,
1913 n,
1914 k,
1915 stream,
1916 );
1917 }
1918 if self.w4a16_gemm_t_m128_k.0 != 0 {
1919 return ops::w4a16_gemm_n128_m128(
1920 ctx.gpu,
1921 self.w4a16_gemm_t_m128_k,
1922 input,
1923 wt,
1924 output,
1925 m,
1926 n,
1927 k,
1928 stream,
1929 );
1930 }
1931 }
1932 ops::w4a16_gemm(ctx.gpu, self.w4a16_gemm, input, w, output, m, n, k, stream)
1933 }
1934
1935 /// Timed wrapper around the dense-FFN prefill.
1936 ///
1937 /// ★ THIS PATH HAD NO TIMERS AT ALL, and that hid the largest unexplained
1938 /// number on the board. Profiling nvidia/Gemma-4-31B-IT-NVFP4 at a
1939 /// 4096-token prompt: wall 28,180 ms, while EVERY profiled phase across
1940 /// `ATTN prefill [...]` and `MoE prefill [...]` summed to 3,269.8 ms. 88% of
1941 /// the prefill was invisible — not attributed to something slow, simply not
1942 /// instrumented. `forward_prefill` dispatches ~20 quantization arms and none
1943 /// of them reported elapsed time; only one-shot "which arm was chosen" INFO
1944 /// lines existed.
1945 ///
1946 /// One coarse timer first, deliberately: it answers whether the missing time
1947 /// is here at all before anyone threads timers through twenty arms. Same
1948 /// `<AREA> prefill [phase] N=<n>: <us>µs` shape the attention and MoE paths
1949 /// already emit, so the existing log-summing one-liners pick it up unchanged.
1950 pub fn forward_prefill(
1951 &self,
1952 input: DevicePtr,
1953 num_tokens: usize,
1954 ctx: &ForwardContext,
1955 stream: u64,
1956 ) -> Result<()> {
1957 if !ctx.profile {
1958 return self.forward_prefill_inner(input, num_tokens, ctx, stream);
1959 }
1960 let t0 = std::time::Instant::now();
1961 let r = self.forward_prefill_inner(input, num_tokens, ctx, stream);
1962 // Sync so the figure is the kernel's, not the launch queue's — the
1963 // attention and MoE timers do the same under `ctx.profile`.
1964 ctx.gpu.synchronize(stream)?;
1965 tracing::info!(
1966 " FFN prefill [dense_total] N={}: {}µs",
1967 num_tokens,
1968 t0.elapsed().as_micros()
1969 );
1970 r
1971 }
1972
1973 fn forward_prefill_inner(
1974 &self,
1975 input: DevicePtr,
1976 num_tokens: usize,
1977 ctx: &ForwardContext,
1978 stream: u64,
1979 ) -> Result<()> {
1980 let h = ctx.config.hidden_size as u32;
1981 let inter = ctx.config.intermediate_size as u32;
1982 let m = num_tokens as u32;
1983
1984 let gate_out = ctx.buffers.expert_gate_out();
1985 let up_out = ctx.buffers.expert_up_out();
1986
1987 // Native keep-packed Q2_0 prefill (Tier-1): the resident weight stays
1988 // 2-bit, but prefill has no packed-MMQ kernel yet (that's Tier-2). So we
1989 // dequant each projection into a TRANSIENT BF16 scratch `[N, K]` via the
1990 // load-time `dequant_q2_0_gn_to_bf16` kernel, run the normal BF16
1991 // prefill GEMM (tensor-core when present), then free the scratch. Only a
1992 // per-matmul scratch is BF16 — the WeightStore blocks stay 2-bit. Decode
1993 // still uses the native `q2_0_gemv` (no dequant). SiLU only.
1994 if let Some(ref q2w) = self.q2_weights {
1995 if self.activation != FfnActivation::SiLU {
1996 anyhow::bail!(
1997 "packed-Q2 FFN prefill supports SiLU only (got {:?})",
1998 self.activation
1999 );
2000 }
2001
2002 // Tier-2 native MMQ prefill (AVAROK_GGUF_NATIVE_Q2_MMQ=1): quantize the
2003 // activation to q8_1 ONCE per projection-input (gate/up share `input`;
2004 // down re-quantizes `gate_out`), then run the packed 2-bit MMQ GEMM —
2005 // no BF16 weight dequant, no shared `q2_dequant_scratch`. Requires the
2006 // MMQ kernel + the shared q8_1 quantizer + group-128 weights.
2007 let q2_mmq = self.q2_0_mmq_nc_k.0 != 0
2008 && self.q4k_quant_act_k.0 != 0
2009 && ops::native_q2_mmq_enabled()
2010 && q2w.gate_proj.group == 128
2011 && q2w.up_proj.group == 128
2012 && q2w.down_proj.group == 128;
2013 if q2_mmq {
2014 static Q2MMQ_LOG: std::sync::Once = std::sync::Once::new();
2015 Q2MMQ_LOG.call_once(|| {
2016 eprintln!(
2017 "[avarok] AVAROK_GGUF_NATIVE_Q2_MMQ=1: dense-FFN prefill via native packed Q2_0 MMQ (W2A8, keep-packed)"
2018 );
2019 });
2020 let a_q8 = ctx.buffers.q2_act_q8();
2021 let mmq = |w: &PackedQ2Weight, out: DevicePtr| -> Result<()> {
2022 ops::q2_0_mmq_gemm(
2023 ctx.gpu,
2024 self.q2_0_mmq_nc_k,
2025 self.q2_0_mmq_wc_k,
2026 a_q8,
2027 w.weight,
2028 out,
2029 m,
2030 w.n,
2031 w.k,
2032 stream,
2033 )
2034 };
2035 // gate/up: quantize `input` [m,h] once, feed both.
2036 ops::quantize_act_q8_1(ctx.gpu, self.q4k_quant_act_k, input, a_q8, m, h, stream)?;
2037 mmq(&q2w.gate_proj, gate_out)?;
2038 mmq(&q2w.up_proj, up_out)?;
2039 ops::silu_mul(
2040 ctx.gpu,
2041 self.act_mul,
2042 gate_out,
2043 up_out,
2044 gate_out,
2045 m * inter,
2046 stream,
2047 )?;
2048 // down: quantize `gate_out` [m,inter] (same-stream after silu_mul).
2049 let output = ctx.buffers.moe_output();
2050 ops::quantize_act_q8_1(
2051 ctx.gpu,
2052 self.q4k_quant_act_k,
2053 gate_out,
2054 a_q8,
2055 m,
2056 inter,
2057 stream,
2058 )?;
2059 mmq(&q2w.down_proj, output)?;
2060 return Ok(());
2061 }
2062
2063 // Transient-dequant stopgap (Tier-1): requires the load-time dequant kernel.
2064 if self.dequant_q2_0_gn_k.0 == 0 {
2065 anyhow::bail!(
2066 "dequant_q2_0_gn_to_bf16 kernel missing in this target build — \
2067 packed-Q2 (AVAROK_GGUF_NATIVE_Q2) prefill is unavailable"
2068 );
2069 }
2070 let tc = self.dense_gemm_tc_k.0 != 0;
2071 // Dequant one packed-Q2 projection into the PERSISTENT arena BF16
2072 // scratch `[n, k]`, run the BF16 GEMM (A=`in` [m,k] → out [m,n]). No
2073 // per-matmul alloc/sync/free: the arena buffer is sized to the
2074 // largest packed projection and reused. Gate → up → down run
2075 // sequentially on `stream`, so each GEMM consumes the scratch before
2076 // the next projection's dequant overwrites it (same-stream order).
2077 let scratch = ctx.buffers.q2_dequant_scratch();
2078 let q2_gemm = |w: &PackedQ2Weight, input: DevicePtr, out: DevicePtr| -> Result<()> {
2079 let (n, k) = (w.n, w.k);
2080 debug_assert!(
2081 (n as usize) * (k as usize) * 2 <= ctx.buffers.q2_dequant_scratch_bytes(),
2082 "packed-Q2 FFN dequant scratch too small for [{n},{k}] BF16"
2083 );
2084 ops::dequant_q2_0_gn_to_bf16(
2085 ctx.gpu,
2086 self.dequant_q2_0_gn_k,
2087 w.weight,
2088 scratch,
2089 n,
2090 k,
2091 w.group as u32,
2092 stream,
2093 )?;
2094 let dw = DenseWeight { weight: scratch };
2095 if tc {
2096 ops::dense_gemm_tc(
2097 ctx.gpu,
2098 self.dense_gemm_tc_k,
2099 input,
2100 &dw,
2101 out,
2102 m,
2103 n,
2104 k,
2105 stream,
2106 )?;
2107 } else {
2108 ops::dense_gemm(
2109 ctx.gpu,
2110 self.dense_gemm_bf16_k,
2111 input,
2112 &dw,
2113 out,
2114 m,
2115 n,
2116 k,
2117 stream,
2118 )?;
2119 }
2120 Ok(())
2121 };
2122 q2_gemm(&q2w.gate_proj, input, gate_out)?;
2123 q2_gemm(&q2w.up_proj, input, up_out)?;
2124 ops::silu_mul(
2125 ctx.gpu,
2126 self.act_mul,
2127 gate_out,
2128 up_out,
2129 gate_out,
2130 m * inter,
2131 stream,
2132 )?;
2133 let output = ctx.buffers.moe_output();
2134 q2_gemm(&q2w.down_proj, gate_out, output)?;
2135 return Ok(());
2136 }
2137
2138 // Native FP8: small batches stream each weight once via the existing
2139 // batched GEMVs, avoiding padded MMA tiles. Larger prefills prefer a
2140 // transposed copy when available, then the same-format pipelined GEMM.
2141 // Every fallback retains the original E4M3 bytes and FP32 block scales.
2142 //
2143 // 🔴 ARM ORDER IS THE DISPATCH RULE — decode rungs first, widest last:
2144 //
2145 // 1. m <= 4 w8a16_gemv_batch4 one weight pass, 4-row tier
2146 // 2. m 5..=16 w8a16_gemm_m16 MMA, AVAROK_FFN_M16_TC only
2147 // 3. m 17..=32 w8a16_gemm_m16 x2 MMA, AVAROK_FFN_M16_TC only
2148 // (AVAROK_FFN_M16_TC reaches THIS arm only; the attention tiers
2149 // take AVAROK_ATTN_M16_TC, and AVAROK_M16_TC is both. Round 6
2150 // measured them moving in opposite directions: attention -21.7%,
2151 // this arm +13.7%. WHY: `dense_ffn_m16_tc.rs`.)
2152 // 4. m 5..=16 w8a16_gemv_batch16 OPT-IN, off by default
2153 // 5. m 17..=32 w8a16_gemv_batch16 x2 OPT-IN, off by default
2154 // 6. W8A8 block-scaled prefill (#917/#928)
2155 // 7. transposed / pipelined / base W8A16 tile GEMMs
2156 //
2157 // Rungs 2-3 are the TENSOR-CORE tier and are OFF unless
2158 // `AVAROK_FFN_M16_TC` is set. `AVAROK_FFN_M16_TC` reaches THIS arm only;
2159 // the attention tiers take `AVAROK_ATTN_M16_TC`, and `AVAROK_M16_TC` is
2160 // both. They are the only arm here that does NOT reproduce the scalar
2161 // `w8a16_gemv` bit-for-bit: an m16n8k16 MMA reassociates the K
2162 // reduction (<= 2 BF16 ULP), which is the same reassociation the
2163 // pre-#927 tile GEMMs had at these widths. WHY, the H100 numbers and
2164 // the seam: `dense_ffn_m16_tc.rs`.
2165 //
2166 // Rungs 4-5 are #927, and they are DISARMED unless `AVAROK_FFN_BATCH16=1`
2167 // — so a stock serve runs rungs 1, 6, 7 exactly as it did before #927.
2168 // The cliff they answer is real (the rung-7 tile GEMMs pad M to a
2169 // 128-row MMA tile, 5-12 TFLOP/s on these shapes), but the tier as a
2170 // whole lost the only end-to-end A/B it has: H100, 2026-09-11,
2171 // Qwen3.8-27B-FP8, C=16 aggregate 121.4 ON -> 128.0 tok/s OFF, because
2172 // it dispatches by row count and so catches a chunked prefill's tail
2173 // chunk. It has never been measured on GB10. Receipt, opt-in and the
2174 // consequence for rung 6's lower edge: `dense_ffn_batch16_decode.rs`.
2175 if let Some(ref fp8w) = self.fp8_weights {
2176 // Resolved ONCE for the whole FFN, not per projection: gate, up and
2177 // down share `m`, so a per-arm call would re-run the same match
2178 // three times and could not be read as one rule.
2179 let batch16 = self.ffn_batch16_plan(m);
2180 // Resolved per PROJECTION depth, not once: gate/up reduce over `h`
2181 // and down over `inter`, and the tier declines a K that is not a
2182 // whole number of 128-wide scale blocks.
2183 let m16_tc = |k: u32| self.ffn_m16_tc_plan(m, k);
2184 macro_rules! w8_gemm {
2185 ($w:expr, $wt:expr, $in:expr, $out:expr, $n:expr, $k:expr, $a8:expr, $cap:expr) => {
2186 match $wt {
2187 // 1. m <= 4.
2188 _ if (1..=4).contains(&m) && self.w8a16_gemv_batch4_k.0 != 0 => {
2189 ops::w8a16_gemv_batch4(
2190 ctx.gpu,
2191 self.w8a16_gemv_batch4_k,
2192 $in,
2193 $w.weight,
2194 $w.row_scale,
2195 $out,
2196 m,
2197 $n,
2198 $k,
2199 stream,
2200 )?
2201 }
2202 // 2-3. TENSOR-CORE tier, 5..=16 (one launch) and
2203 // 17..=32 (two halves). `Some(plan)` already encodes the
2204 // handle, the `AVAROK_FFN_M16_TC` lever (default OFF) and
2205 // the K % 128 guard, so this arm is inert until an
2206 // operator opts in. It REASSOCIATES — see the rule above.
2207 _ if m16_tc($k).is_some() => self.w8a16_m16_tc_proj(
2208 ctx,
2209 m16_tc($k).expect("guarded by is_some"),
2210 &$w,
2211 $in,
2212 $out,
2213 m,
2214 $n,
2215 $k,
2216 stream,
2217 )?,
2218 // 4-5. m 5..=16 (one launch) and 17..=32 (two halves).
2219 // `Some(plan)` already encodes the handle and the
2220 // `AVAROK_FFN_BATCH16=1` opt-in, so this is `None` on a
2221 // stock serve and the match falls through to rung 4.
2222 _ if batch16.is_some() => self.w8a16_batch16_proj(
2223 ctx,
2224 batch16.expect("guarded by is_some"),
2225 &$w,
2226 $in,
2227 $out,
2228 m,
2229 $n,
2230 $k,
2231 stream,
2232 )?,
2233 // 6. W8A8 block-scaled (#917/#928) — ahead of the W8A16
2234 // arms below, which run the BF16 MMA at ~12 TFLOP/s on
2235 // these shapes. `$a8` is `Some` exactly when
2236 // `prefill_w8a8_selected` held for this projection.
2237 _ if $a8.is_some() => {
2238 let (a_fp8, a_scale) = $a8.expect("guarded by is_some");
2239 self.w8a8_gemm(ctx, a_fp8, a_scale, &$w, $out, $cap, m, $n, $k, stream)?
2240 }
2241 // 7. Tile GEMMs — prefill widths (m > 32) and any
2242 // shape the rungs above declined.
2243 Some(wt) if self.w8a16_gemm_t_m128_k.0 != 0 => {
2244 let wt: Fp8WeightTransposed = wt;
2245 ops::w8a16_gemm_n128_m128(
2246 ctx.gpu,
2247 self.w8a16_gemm_t_m128_k,
2248 $in,
2249 wt.weight_t,
2250 wt.scale_t,
2251 $out,
2252 m,
2253 $n,
2254 $k,
2255 stream,
2256 )?
2257 }
2258 _ if self.w8a16_gemm_pipelined_k.0 != 0 => ops::w8a16_gemm_pipelined(
2259 ctx.gpu,
2260 self.w8a16_gemm_pipelined_k,
2261 $in,
2262 $w.weight,
2263 $w.row_scale,
2264 $out,
2265 m,
2266 $n,
2267 $k,
2268 stream,
2269 )?,
2270 _ => ops::w8a16_gemm(
2271 ctx.gpu,
2272 self.w8a16_gemm_k,
2273 $in,
2274 $w.weight,
2275 $w.row_scale,
2276 $out,
2277 m,
2278 $n,
2279 $k,
2280 stream,
2281 )?,
2282 }
2283 };
2284 }
2285 let gate_t: Option<Fp8WeightTransposed> = None;
2286 let up_t: Option<Fp8WeightTransposed> = None;
2287 let down_t: Option<Fp8WeightTransposed> = None;
2288 // W8A8 activation quant (#917/#928), ONCE per shared input: gate and
2289 // up both read `input`, so quantizing per projection would pay the
2290 // per-token E4M3 cast twice per layer for identical bytes. down
2291 // quantizes separately because its input is the post-SiLU product.
2292 // `None` => that projection keeps today's W8A16 dispatch.
2293 // Selection rule + WHY: `dense_ffn_w8a8_prefill.rs` (SSOT).
2294 //
2295 // `batch16.is_none()` is part of the condition and not only of the
2296 // match: rungs 2-3 sit AHEAD of the W8A8 arm, so at m=5..=32 the
2297 // quantizer launch below would be dead work whose result no arm
2298 // reads. This keeps the W8A8 lower edge honest at m > 32.
2299 // `m16_tc` is checked at BOTH projection depths: gate/up reduce
2300 // over `h` and down over `inter`, and either claiming the width
2301 // makes the quantizer launch below dead work for that projection.
2302 let w8a8_reachable =
2303 batch16.is_none() && m16_tc(h).is_none() && m16_tc(inter).is_none();
2304 let gate_up_w8a8 = w8a8_reachable
2305 && self.prefill_w8a8_selected(ctx, m, inter, h, &fp8w.gate_proj)
2306 && self.prefill_w8a8_selected(ctx, m, inter, h, &fp8w.up_proj);
2307 let down_w8a8 =
2308 w8a8_reachable && self.prefill_w8a8_selected(ctx, m, h, inter, &fp8w.down_proj);
2309 if !gate_up_w8a8 && !down_w8a8 && w8a8_reachable {
2310 self.log_w8a16_prefill_route(ctx);
2311 }
2312 let gu_a8 = if gate_up_w8a8 {
2313 Some(self.w8a8_quant_act(ctx, input, m, h, stream)?)
2314 } else {
2315 None
2316 };
2317 let gu_cap = ctx.buffers.expert_gate_out_bytes();
2318 // FUSED gate+up (#927): ONE cuBLASLt W8A8 GEMM at N=2*inter, then
2319 // the strided SiLU straight out of its `[m, 2*inter]` rows. It sits
2320 // HERE, ahead of the two `w8_gemm!` calls, because it is the SAME
2321 // W8A8 arm those calls would take (`gate_up_w8a8` is a clause of
2322 // its rule) with the two N's concatenated — not a new rung of the
2323 // ladder. Declines to the pair below at every width, target and
2324 // checkpoint it does not claim. Rule and the round-13 receipt:
2325 // `dense_ffn_gateup_fused.rs` (SSOT).
2326 if let Some((a_fp8, a_scale)) = gu_a8
2327 && let Some(fused) = self.gateup_fused_plan(ctx, m, inter, gate_up_w8a8)
2328 {
2329 self.w8a8_gate_up_fused(ctx, a_fp8, a_scale, fused, gate_out, m, inter, h, stream)?;
2330 } else {
2331 w8_gemm!(
2332 fp8w.gate_proj,
2333 gate_t,
2334 input,
2335 gate_out,
2336 inter,
2337 h,
2338 gu_a8,
2339 gu_cap
2340 );
2341 w8_gemm!(fp8w.up_proj, up_t, input, up_out, inter, h, gu_a8, gu_cap);
2342 ops::silu_mul(
2343 ctx.gpu,
2344 self.act_mul,
2345 gate_out,
2346 up_out,
2347 gate_out,
2348 m * inter,
2349 stream,
2350 )?;
2351 }
2352 let output = ctx.buffers.moe_output();
2353 // `gate_out` (the SiLU product) is fully written by the launch above
2354 // and re-read here; the quant lands in the SAME scratch the gate/up
2355 // quant used, which is safe because every launch is on `stream`.
2356 let down_a8 = if down_w8a8 {
2357 Some(self.w8a8_quant_act(ctx, gate_out, m, inter, stream)?)
2358 } else {
2359 None
2360 };
2361 let down_cap = ctx.buffers.moe_output_bytes();
2362 w8_gemm!(
2363 fp8w.down_proj,
2364 down_t,
2365 gate_out,
2366 output,
2367 h,
2368 inter,
2369 down_a8,
2370 down_cap
2371 );
2372 return Ok(());
2373 }
2374
2375 // BF16 prefill dispatch. Prefer the tensor-core m16n8k16 MMA kernel
2376 // (`dense_gemm_tc`, 3-5x+ over scalar) — the scalar `dense_gemm_bf16`
2377 // was the flat ~155 tok/s prefill bottleneck on Qwen3.6-27B dense
2378 // NVFP4 (FFN = ~83% of prefill). Falls back to scalar if the TC
2379 // kernel isn't loaded for this target. Decode (gemv, M=1) is a
2380 // separate path, so TPOT is unaffected; BF16 MMA preserves coherence.
2381 if let Some(ref bf16w) = self.bf16_weights {
2382 let tc = self.dense_gemm_tc_k.0 != 0;
2383 // helper: cuBLASLt when enabled (the big win at prefill M), else the
2384 // tensor-core MMA kernel, else scalar. dense_gemm_tc is ~1.4 TFLOP/s
2385 // on the large dense-FFN shapes (e.g. Laguna layer-0 gate/up/down at
2386 // N=12288/3072, K=3072) — nsys measured its 3 launches at ~100 ms
2387 // EACH = 33% of the whole C=1 prefill. cuBLASLt runs the identical
2388 // BF16×BF16→FP32 GEMM at 90+ TFLOP/s (~65× faster), the same path
2389 // q/k/v/o and the head-gate already use. Gated on AVAROK_CUBLAS_GEMM.
2390 macro_rules! ffn_gemm {
2391 ($a:expr, $b:expr, $c:expr, $n:expr, $k:expr) => {
2392 if ctx.dispatch.cublas.ffn {
2393 ops::cublas_bf16_proj_dense($a, $b.weight, $c, m, $n, $k, stream)?;
2394 } else if tc {
2395 ops::dense_gemm_tc(
2396 ctx.gpu,
2397 self.dense_gemm_tc_k,
2398 $a,
2399 $b,
2400 $c,
2401 m,
2402 $n,
2403 $k,
2404 stream,
2405 )?;
2406 } else {
2407 ops::dense_gemm(
2408 ctx.gpu,
2409 self.dense_gemm_bf16_k,
2410 $a,
2411 $b,
2412 $c,
2413 m,
2414 $n,
2415 $k,
2416 stream,
2417 )?;
2418 }
2419 };
2420 }
2421 ffn_gemm!(input, &bf16w.gate_proj, gate_out, inter, h);
2422 ffn_gemm!(input, &bf16w.up_proj, up_out, inter, h);
2423 ops::silu_mul(
2424 ctx.gpu,
2425 self.act_mul,
2426 gate_out,
2427 up_out,
2428 gate_out,
2429 m * inter,
2430 stream,
2431 )?;
2432 let output = ctx.buffers.moe_output();
2433 ffn_gemm!(gate_out, &bf16w.down_proj, output, h, inter);
2434 return Ok(());
2435 }
2436
2437 // Prefill: prefer the 128x128 cp.async-pipelined `w4a16_gemm_t_m128`
2438 // (the kernel attention/SSM use) over the M64xN64 base `w4a16_gemm`
2439 // (~10 TFLOPS, the flat ~155 tok/s bottleneck). That kernel needs the
2440 // TRANSPOSED weight layout, so we use the `*_proj_t` copies built at
2441 // load (decode keeps the non-transposed weights via gemv → TPOT/
2442 // coherence unaffected). Falls back to base when no transposed copy /
2443 // kernel is present.
2444 // LOSSLESS prefill opt-in: when AVAROK_BF16_TC_PREFILL is set AND the
2445 // BF16 128x128 kernel is present, route prefill GEMMs through the
2446 // bit-equivalent BF16 tensor-core path instead of the default FP8-E4M3
2447 // `t_m128`. The FP8 crush is fast but perturbs generation (measured
2448 // length-truncations / accuracy risk on Qwen3.6-27B); the BF16 variant
2449 // keeps the same 128x128 cp.async speed at base-kernel precision.
2450 // Unset (default) → every arm below is byte-for-byte the prior behavior
2451 // (PCND: explicit opt-in, no silent default change). Read once per call.
2452 // Env read only here; the usable gate (`bf16_tc_prefill`) is derived
2453 // below AFTER v1/v2 selection, from the handle actually launched.
2454 // Gating on v1's handle while dispatching v2 admitted launches of a
2455 // kernel this target may not carry.
2456 let bf16_tc_env = ctx.levers.bf16_tc_prefill;
2457 // FP8 M64 fast-prefill opt-in: route prefill GEMMs through the m16n8k32
2458 // e4m3 M64 kernel (~1.47x vs v2 BF16, smem-relieved). Lossy (cosine 0.9997)
2459 // → highest priority when set, so it overrides the BF16/FP8 t_m128 arms.
2460 // PCND: explicit opt-in, default off = byte-for-byte prior behavior.
2461 let fp8_m64_prefill = self.w4a16_gemm_t_k.0 != 0 && ctx.levers.fp8_m64_prefill;
2462 // int8 W4A8 fast-prefill opt-in (AVAROK_INT8_PREFILL): route prefill GEMMs
2463 // through the validated requant→`int8_gemm_faith2` pipeline (cosine
2464 // 0.999978 vs the host full-precision dequant GEMM). HIGHEST priority when
2465 // set, so it overrides every other prefill arm. Needs both operands int8:
2466 // the NVFP4 weights are requanted to int8 once (cached, see
2467 // `ensure_int8_weight`) and the BF16 activations are requanted every call
2468 // into the shared scratch (`ensure_int8_scratch`). LOSSY (perf gate, not
2469 // bit-identical) — the _2.5h IoU gate is the final arbiter.
2470 // PCND: explicit opt-in, default off = byte-for-byte prior behavior; the
2471 // arm is a no-op (and no buffers are built) unless the kernels are loaded.
2472 let int8_prefill = self.int8_faith2_k.0 != 0 && ctx.levers.int8_prefill;
2473 if int8_prefill {
2474 // Log-once latch (see `avarok_core::scope`). It holds no model-derived
2475 // value — the message is rebuilt from the arguments every call — so a
2476 // stale entry cannot produce a wrong answer, only a suppressed duplicate
2477 // line after a model swap. Scoping it would thread a logging concern
2478 // through the call path to prevent one repeated INFO line.
2479 if ctx.stats.once("log:ffn_int8_prefill") {
2480 tracing::info!(
2481 "[avarok] AVAROK_INT8_PREFILL=1: dense-FFN prefill via int8_gemm_faith2 (W4A8 requant→int8 MMA, lossy ~0.99998 cosine)"
2482 );
2483 }
2484 }
2485 // NVFP4 W4A4 MMQ prefill (AVAROK_FFN_NVFP4_MMQ) — vendored llama Blackwell
2486 // block-scale FP4 MMA, gate/up ONLY (hybrid: down stays on the default t_m128
2487 // path — SiLU(gate)*up is heavy-tailed and accuracy-critical). SiLU models only
2488 // (the scale2 fold lives in the scaled SiLU-mul). Mutually exclusive with
2489 // AVAROK_FFN_MMQ (both use the shared ffn_act_q8 scratch); this arm wins.
2490 //
2491 // An installed LoRA adapter turns this arm OFF. The MMQ path leaves
2492 // gate_out/up_out holding UNSCALED products and folds each
2493 // projection's `weight_scale_2` later, inside the SiLU-mul kernel. A
2494 // LoRA delta is a true-valued quantity, so adding it to those buffers
2495 // would put it through a scale2 multiply that does not belong to it —
2496 // silently wrong output rather than a failure. Folding scale2 into the
2497 // delta instead would mean reproducing the quant layout's arithmetic
2498 // in the adapter path, which is a much larger commitment than the
2499 // ~8% prefill this arm is worth (measured 831 vs 767 tok/s at 8K).
2500 // Correctness first; making LoRA and MMQ coexist is its own change.
2501 let fp4mmq_prefill = self.nvfp4_mmq_nc_k.0 != 0
2502 && self.nvfp4_quant_act_k.0 != 0
2503 && self.nvfp4_silu_scaled_k.0 != 0
2504 && matches!(self.activation, FfnActivation::SiLU)
2505 && self.lora.is_none()
2506 && ctx.levers.ffn_nvfp4_mmq;
2507 if fp4mmq_prefill {
2508 // Log-once latch (see `avarok_core::scope`). It holds no model-derived
2509 // value — the message is rebuilt from the arguments every call — so a
2510 // stale entry cannot produce a wrong answer, only a suppressed duplicate
2511 // line after a model swap. Scoping it would thread a logging concern
2512 // through the call path to prevent one repeated INFO line.
2513 if ctx.stats.once("log:ffn_fp4_mmq_prefill") {
2514 tracing::info!(
2515 "[avarok] AVAROK_FFN_NVFP4_MMQ=1: dense-FFN gate/up prefill via vendored llama NVFP4 W4A4 MMQ (block-scale FP4 MMA, ~80 TFLOP/s vs t_m128 ~51)"
2516 );
2517 }
2518 }
2519 // Down-projection MMQ arm (DEFAULT ON; kill-switch AVAROK_NO_FFN_NVFP4_MMQ_DOWN=1): route down through
2520 // the same MMQ arm (t_m128 runs the narrow-N down at only ~34 TFLOP/s in-model).
2521 // Accuracy note: down W4A4 cosine 0.9961 (random) — better than the previously
2522 // coherence-validated all-W4A4 config (0.991) — but still the heavy-tailed
2523 // projection, so it stays a SEPARATE opt-in gate.
2524 let fp4mmq_down =
2525 fp4mmq_prefill && self.nvfp4_scale_k.0 != 0 && ctx.levers.ffn_nvfp4_mmq_down;
2526 // HYBRID: route the accuracy-critical down_proj OFF Q4_K onto the near-lossless faith2
2527 // NVFP4 path (W4A8 requant, cos 0.99998). down=SiLU(gate)*up is heavy-tailed; Q4_K
2528 // superblock scaling clips it (BFCL `multiple` -4.0%; llama promotes only down→Q6_K for
2529 // this reason). gate/up stay on Q4_K. Default ON when MMQ active; AVAROK_FFN_MMQ_DOWN_Q4K=1
2530 // = lossy all-Q4_K (A/B only). Defined here (self-fields+env, no q4k_prefill var dep) so the
2531 // int8 scratch below can size for the hybrid down.
2532 let down_faith2 = self.q4k_mmq_nc_k.0 != 0
2533 && self.q4k_quant_act_k.0 != 0
2534 && self.q4k_quant_w_k.0 != 0
2535 && self.dequant_nvfp4_bf16_k.0 != 0
2536 && self.int8_faith2_k.0 != 0
2537 && self.requant_a_int8_k.0 != 0
2538 && !fp4mmq_prefill
2539 && ctx.levers.ffn_mmq
2540 && !ctx.levers.ffn_mmq_down_q4k;
2541 // Pre-allocate (or reuse) the activation-requant scratch once per call,
2542 // sized to the largest projection K (= max(h, inter)) so the per-GEMM
2543 // arms never trigger a mid-call grow/sync. NULL when the int8 path is off.
2544 // Shared, arena-owned activation-requant scratch (sized once for
2545 // max_batch_tokens × max(h, inter) in BufferSizes::from_config). Replaces
2546 // the former per-DenseFfnLayer grow-on-demand allocator that leaked
2547 // ~286MB × 64 layers on the MMQ prefill path.
2548 let (int8_a_i8, int8_a_scale) = if int8_prefill || down_faith2 {
2549 (ctx.buffers.ffn_act_a(), ctx.buffers.ffn_act_scale())
2550 } else {
2551 (DevicePtr::NULL, DevicePtr::NULL)
2552 };
2553 // W4A4 native-FP4 prefill (AVAROK_FP4_PREFILL) — HIGHEST priority. NVFP4 weights
2554 // used directly (no requant); BF16 activations quantized to NVFP4 each GEMM into
2555 // the shared scratch. Native FP4 tensor cores (sm_121a). Lossy (cos ~0.99 vs fp32).
2556 let fp4_prefill =
2557 self.w4a4_gemm_k.0 != 0 && self.quantize_nvfp4_k.0 != 0 && ctx.levers.fp4_prefill;
2558 if fp4_prefill {
2559 // Log-once latch (see `avarok_core::scope`). It holds no model-derived
2560 // value — the message is rebuilt from the arguments every call — so a
2561 // stale entry cannot produce a wrong answer, only a suppressed duplicate
2562 // line after a model swap. Scoping it would thread a logging concern
2563 // through the call path to prevent one repeated INFO line.
2564 if ctx.stats.once("log:ffn_fp4_prefill") {
2565 tracing::info!(
2566 "[avarok] AVAROK_FP4_PREFILL=1: dense-FFN prefill via w4a4_gemm (native FP4 MMA sm_121a, W4A4)"
2567 );
2568 }
2569 }
2570 // NVFP4 packed [m,K/2] + scale [m,K/16] both fit within the shared int8
2571 // buffers (a_i8 [m,K] ⊇ packed; a_scale [m,(K/32)*4] ⊇ scale). FP4-prefill
2572 // is a standalone A/B flag, never co-active with the int8/Q4_K down path.
2573 let (nvfp4_a_packed, nvfp4_a_scale) = if fp4_prefill {
2574 (ctx.buffers.ffn_act_a(), ctx.buffers.ffn_act_scale())
2575 } else {
2576 (DevicePtr::NULL, DevicePtr::NULL)
2577 };
2578 // Q4_K MMQ prefill (AVAROK_FFN_MMQ) — vendored llama Q4_K W4A8 GEMM. Highest priority
2579 // when enabled. Lossy (Q4_K weight format ≠ NVFP4); gate via BFCL before relying on it.
2580 let q4k_prefill = self.q4k_mmq_nc_k.0 != 0
2581 && self.q4k_quant_act_k.0 != 0
2582 && self.q4k_quant_w_k.0 != 0
2583 && self.dequant_nvfp4_bf16_k.0 != 0
2584 && !fp4mmq_prefill
2585 && ctx.levers.ffn_mmq;
2586 if q4k_prefill {
2587 // Log-once latch (see `avarok_core::scope`). It holds no model-derived
2588 // value — the message is rebuilt from the arguments every call — so a
2589 // stale entry cannot produce a wrong answer, only a suppressed duplicate
2590 // line after a model swap. Scoping it would thread a logging concern
2591 // through the call path to prevent one repeated INFO line.
2592 if ctx.stats.once("log:ffn_q4k_prefill") {
2593 tracing::info!(
2594 "[avarok] AVAROK_FFN_MMQ=1: dense-FFN prefill via vendored llama Q4_K MMQ (W4A8, +25%/+10% gate·down vs faith2)"
2595 );
2596 }
2597 }
2598 let q4k_a = if q4k_prefill {
2599 ctx.buffers.ffn_act_q8()
2600 } else {
2601 DevicePtr::NULL
2602 };
2603 // FP4-MMQ y scratch: block_fp4_mmq activations, in the SAME shared arena buffer
2604 // (fp4_act_scratch_bytes ≤ q8_1_scratch_bytes; mutually exclusive with q4k_prefill).
2605 let fp4_y = if fp4mmq_prefill {
2606 ctx.buffers.ffn_act_q8()
2607 } else {
2608 DevicePtr::NULL
2609 };
2610 // A/B escape hatch (benchmark only): force the proven v1 BF16 kernel even
2611 // when v2 is loaded, so v1-vs-v2 prefill TTFT can be compared in one
2612 // binary. Default unset → prefer v2 (the faster, bit-identical variant).
2613 let use_v2 = self.w4a16_gemm_t_m128_bf16_v2_k.0 != 0 && ctx.levers.prefill_v2;
2614 let bf16_kernel = if use_v2 {
2615 self.w4a16_gemm_t_m128_bf16_v2_k
2616 } else {
2617 self.w4a16_gemm_t_m128_bf16_k
2618 };
2619 // Final gate: the flag is honored only when the SELECTED kernel is
2620 // loaded (v2 when preferred, else v1) — not v1's handle unconditionally.
2621 let bf16_tc_prefill = bf16_kernel.0 != 0 && bf16_tc_env;
2622
2623 macro_rules! w4_gemm {
2624 ($w:expr, $wt:expr, $cell:expr, $qcell:expr, $fp4cell:expr, $allow_fp4:expr, $in:expr, $out:expr, $n:expr, $k:expr, $allow_q4k:expr) => {
2625 match $wt {
2626 // NVFP4 W4A4 MMQ prefill (AVAROK_FFN_NVFP4_MMQ) — HIGHEST priority.
2627 // `$allow_fp4` = fp4mmq_prefill for gate/up, fp4mmq_down for down.
2628 // Activation pre-quantized into `fp4_y` by the caller; the output is
2629 // missing ×scale2, folded downstream (scaled SiLU-mul / scale_bf16).
2630 _ if $allow_fp4 => {
2631 let _ = $in;
2632 let qw =
2633 self.ensure_nvfp4_mmq_weight($fp4cell, ctx.gpu, $w, $n, $k, stream)?;
2634 // Size the M tile to the batch when the batch is small and
2635 // the small-tile entries are present. m must be <= mmq_x or
2636 // grid.y>1 re-streams the weights per tile.
2637 let (tk_nc, tk_wc, tile) = if m <= 16
2638 && self.nvfp4_mmq16_nc_k.0 != 0
2639 && mmq_small_tile_enabled()
2640 {
2641 (self.nvfp4_mmq16_nc_k, self.nvfp4_mmq16_wc_k, 16u32)
2642 } else if m <= 32
2643 && self.nvfp4_mmq32_nc_k.0 != 0
2644 && mmq_small_tile_enabled()
2645 {
2646 (self.nvfp4_mmq32_nc_k, self.nvfp4_mmq32_wc_k, 32u32)
2647 } else if m <= 64
2648 && self.nvfp4_mmq64_nc_k.0 != 0
2649 && mmq_small_tile_enabled()
2650 && mmq_tile64_enabled()
2651 {
2652 (self.nvfp4_mmq64_nc_k, self.nvfp4_mmq64_wc_k, 64u32)
2653 } else {
2654 (self.nvfp4_mmq_nc_k, self.nvfp4_mmq_wc_k, 128u32)
2655 };
2656 ops::nvfp4_mmq_gemm_tiled(
2657 ctx.gpu, tk_nc, tk_wc, tile, fp4_y, qw.w, $out, m, $n, $k, stream,
2658 )?;
2659 }
2660 // Q4_K MMQ prefill (AVAROK_FFN_MMQ) — next priority, gated per-GEMM by
2661 // `$allow_q4k` (false for down in the hybrid → falls to the faith2 arm).
2662 // Activation `$in` is pre-quantized to q8_1 in `q4k_a` by the caller.
2663 _ if q4k_prefill && $allow_q4k => {
2664 let qw = self.ensure_q4k_weight($qcell, ctx.gpu, $w, $n, $k, stream)?;
2665 ops::q4k_mmq_gemm(
2666 ctx.gpu,
2667 self.q4k_mmq_nc_k,
2668 self.q4k_mmq_wc_k,
2669 q4k_a,
2670 qw.w_q4k,
2671 $out,
2672 m,
2673 $n,
2674 $k,
2675 stream,
2676 )?;
2677 }
2678 // W4A4 native-FP4 prefill (AVAROK_FP4_PREFILL) — HIGHEST priority.
2679 // The activation is PRE-quantized into the NVFP4 scratch by the caller
2680 // (`input` once for gate+up which share it; `gate_out` for down) — opt #1,
2681 // avoids the redundant re-quant. This arm just runs w4a4_gemm against the
2682 // native NVFP4 weight `$w` (no requant). sm_121a FP4 MMA.
2683 _ if fp4_prefill => {
2684 let _ = $in;
2685 ops::w4a4_gemm(
2686 ctx.gpu,
2687 self.w4a4_gemm_k,
2688 nvfp4_a_packed,
2689 nvfp4_a_scale,
2690 $w,
2691 $out,
2692 m,
2693 $n,
2694 $k,
2695 stream,
2696 )?;
2697 }
2698 // int8 W4A8 fast prefill (AVAROK_INT8_PREFILL) — next priority.
2699 // Independent of `$wt`/the transposed copies: requant reads the
2700 // non-transposed NVFP4 `$w` directly. Builds (once) + caches the
2701 // int8 weight in `$cell`, then requant_a + faith2 via the shared
2702 // scratch. Lossy (cosine ~0.99998). Also the HYBRID down path
2703 // (down_faith2 && !$allow_q4k): down falls here instead of Q4_K.
2704 _ if int8_prefill || (down_faith2 && !$allow_q4k) => {
2705 let iw = self.ensure_int8_weight($cell, ctx.gpu, $w, $n, $k, stream)?;
2706 // faith5 (AVAROK_INT8_FAITH5=1): int32 per-sb accumulation
2707 // breaks the MMA→scale dependency chain. Same kernel signature
2708 // + grid/block as faith2 — just a different KernelHandle.
2709 let int8_kernel = if self.int8_faith5_k.0 != 0 && ctx.levers.int8_faith5 {
2710 self.int8_faith5_k
2711 } else {
2712 self.int8_faith2_k
2713 };
2714 ops::int8_gemm_faith2_prefill(
2715 ctx.gpu,
2716 int8_kernel,
2717 self.requant_a_int8_k,
2718 $in,
2719 iw.w_i8,
2720 iw.w_scale,
2721 int8_a_i8,
2722 int8_a_scale,
2723 $out,
2724 m,
2725 $n,
2726 $k,
2727 stream,
2728 )?;
2729 }
2730 // Lossless opt-in: BF16 128x128 tensor-core prefill (bit-equivalent
2731 // to base `w4a16_gemm`). Preferred over the FP8 t_m128/v2 paths only
2732 // when AVAROK_BF16_TC_PREFILL is set and the kernel is loaded. Within
2733 // the lossless path, prefer the higher-occupancy v2 kernel (3 CTAs/SM,
2734 // bit-identical to v1) when it is loaded; else the proven v1 kernel.
2735 // Both go through the same launch helper (identical grid/block/args).
2736 // FP8 M64 fast prefill (AVAROK_FP8_M64_PREFILL) — highest priority,
2737 // M64 grid via the w4a16_gemm_n128 launcher.
2738 Some(wt) if fp8_m64_prefill => ops::w4a16_gemm_n128(
2739 ctx.gpu,
2740 self.w4a16_gemm_t_k,
2741 $in,
2742 &wt,
2743 $out,
2744 m,
2745 $n,
2746 $k,
2747 stream,
2748 )?,
2749 // v2's COMPILED signature carries a 9th param, `ldb`
2750 // (transposed-B row stride; == N for the FFN twins, which
2751 // are built unpadded). It MUST go through the `_ldb`
2752 // launcher: the 8-arg helper leaves cuLaunchKernel reading
2753 // one-past-the-end of the param array for `ldb` —
2754 // CUDA_ERROR_INVALID_VALUE or a host SIGSEGV depending on
2755 // the neighboring heap word. v1 takes exactly 8 params and
2756 // stays on the 8-arg helper.
2757 Some(wt) if bf16_tc_prefill && use_v2 => ops::w4a16_gemm_n128_m128_bf16_ldb(
2758 ctx.gpu,
2759 bf16_kernel,
2760 $in,
2761 &wt,
2762 $out,
2763 m,
2764 $n,
2765 $k,
2766 $n,
2767 stream,
2768 )?,
2769 Some(wt) if bf16_tc_prefill => ops::w4a16_gemm_n128_m128_bf16(
2770 ctx.gpu,
2771 bf16_kernel,
2772 $in,
2773 &wt,
2774 $out,
2775 m,
2776 $n,
2777 $k,
2778 stream,
2779 )?,
2780 // Small-M routing (DFlash verify, M<=64): delegate to
2781 // `w4a16_prefill_gemm`, which picks `w4a16_gemm_t` /
2782 // `w4a16_gemm_t_k64` per the w4a16_m17_bench numbers and
2783 // falls back to the same v2/m128 kernels below.
2784 // AVAROK_FFN_SMALLM=0 disables. Sits after the opt-in
2785 // quant arms so explicit MMQ/int8/FP8 experiments keep
2786 // priority.
2787 Some(wt) if m <= 64 => {
2788 self.w4a16_prefill_gemm(ctx, $w, Some(&wt), $in, $out, m, $n, $k, stream)?
2789 }
2790 // Prefer v2 (8-warp) > t_m128 (4-warp) > scalar-tile base.
2791 Some(wt) if self.w4a16_gemm_t_m128_v2_k.0 != 0 => ops::w4a16_gemm_n128_m128_v2(
2792 ctx.gpu,
2793 self.w4a16_gemm_t_m128_v2_k,
2794 $in,
2795 &wt,
2796 $out,
2797 m,
2798 $n,
2799 $k,
2800 stream,
2801 )?,
2802 Some(wt) if self.w4a16_gemm_t_m128_k.0 != 0 => ops::w4a16_gemm_n128_m128(
2803 ctx.gpu,
2804 self.w4a16_gemm_t_m128_k,
2805 $in,
2806 &wt,
2807 $out,
2808 m,
2809 $n,
2810 $k,
2811 stream,
2812 )?,
2813 _ => {
2814 ops::w4a16_gemm(ctx.gpu, self.w4a16_gemm, $in, $w, $out, m, $n, $k, stream)?
2815 }
2816 }
2817 };
2818 }
2819
2820 // W4A4 opt #1: quantize the gate/up SHARED input `[M, H]` to NVFP4 ONCE
2821 // (gate and up both read it) instead of per-GEMM. Reused by both arms below.
2822 if fp4_prefill {
2823 ops::quantize_bf16_to_nvfp4(
2824 ctx.gpu,
2825 self.quantize_nvfp4_k,
2826 input,
2827 nvfp4_a_packed,
2828 nvfp4_a_scale,
2829 m,
2830 h,
2831 stream,
2832 )?;
2833 }
2834 // Q4_K opt: quantize the gate/up SHARED input `[M, H]` to q8_1 ONCE (both read it).
2835 if q4k_prefill {
2836 ops::quantize_act_q8_1(ctx.gpu, self.q4k_quant_act_k, input, q4k_a, m, h, stream)?;
2837 }
2838 // FP4-MMQ: quantize the gate/up SHARED input `[M, H]` to block_fp4_mmq ONCE.
2839 if fp4mmq_prefill {
2840 ops::nvfp4_mmq_quantize_act(
2841 ctx.gpu,
2842 self.nvfp4_quant_act_k,
2843 input,
2844 fp4_y,
2845 m,
2846 h,
2847 stream,
2848 )?;
2849 }
2850 // Per-projection timers. `dense_total` localised 23.7 s of a 28.2 s
2851 // Gemma-4-31B prefill to this function; these say WHICH of the three
2852 // projections it is. Roofline for that shape: 3 x 5376 x 21504 x 4012 tok
2853 // x 60 layers = 167 TFLOP, so 23.7 s is ~7 TFLOP/s against a bf16
2854 // tensor-core peak two orders higher — a hypothesis these numbers test
2855 // rather than assume.
2856 // ★ PER-STEP, NOT CUMULATIVE. The first version of this timer measured
2857 // elapsed-since-one-start at each of the three call sites, so `up_proj`
2858 // included `gate_proj` and `down_proj` included both — and the summed
2859 // "total profiled" then exceeded the wall clock, which is the tell.
2860 macro_rules! ffn_step {
2861 ($label:expr, $t0:expr) => {
2862 if ctx.profile {
2863 ctx.gpu.synchronize(stream)?;
2864 tracing::info!(
2865 " FFN prefill [{}] N={}: {}µs",
2866 $label,
2867 num_tokens,
2868 $t0.elapsed().as_micros()
2869 );
2870 #[allow(unused_assignments)]
2871 {
2872 $t0 = std::time::Instant::now();
2873 }
2874 }
2875 };
2876 }
2877 #[allow(unused_mut, unused_assignments)]
2878 let mut t_ffn = std::time::Instant::now();
2879 // gate_proj GEMM: [M, H] → [M, inter]
2880 w4_gemm!(
2881 &self.weights.gate_proj,
2882 self.weights.gate_proj_t,
2883 &self.int8_gate,
2884 &self.q4k_gate,
2885 &self.fp4mmq_gate,
2886 fp4mmq_prefill,
2887 input,
2888 gate_out,
2889 inter,
2890 h,
2891 true
2892 );
2893 ffn_step!("gate_proj", t_ffn);
2894 // up_proj GEMM: [M, H] → [M, inter]
2895 w4_gemm!(
2896 &self.weights.up_proj,
2897 self.weights.up_proj_t,
2898 &self.int8_up,
2899 &self.q4k_up,
2900 &self.fp4mmq_up,
2901 fp4mmq_prefill,
2902 input,
2903 up_out,
2904 inter,
2905 h,
2906 true
2907 );
2908 ffn_step!("up_proj", t_ffn);
2909
2910 // LoRA gate/up deltas land here: the projections are complete and, with
2911 // the MMQ arm disabled above, gate_out/up_out hold true-valued BF16 —
2912 // so the delta adds in the same units it was trained in.
2913 self.apply_lora_gate_up(ctx, input, gate_out, up_out, m, stream)?;
2914 // activation(gate) * up for all M tokens (SiLU or GELU)
2915 let fused_down_quant = fp4mmq_down && self.nvfp4_silu_quant_k.0 != 0;
2916 if fused_down_quant {
2917 // Fused SiLU-mul + quantize straight into the down MMQ's y-format: the
2918 // [M, inter] bf16 intermediate is never written or re-read (that round-trip
2919 // is why the unfused down arm measured neutral). scale2 folds happen inside,
2920 // pre-clamp — identical math to the two-step path below.
2921 ops::nvfp4_silu_mul_quant(
2922 ctx.gpu,
2923 self.nvfp4_silu_quant_k,
2924 gate_out,
2925 up_out,
2926 fp4_y,
2927 self.weights.gate_proj.weight_scale_2,
2928 self.weights.up_proj.weight_scale_2,
2929 m,
2930 inter,
2931 stream,
2932 )?;
2933 } else if fp4mmq_prefill {
2934 // FP4-MMQ outputs are missing the per-tensor FP32 scale2 (the hardware MMA
2935 // applies only the per-16 e4m3 scales) — fold it here, before the nonlinearity.
2936 ops::nvfp4_silu_mul_scaled(
2937 ctx.gpu,
2938 self.nvfp4_silu_scaled_k,
2939 gate_out,
2940 up_out,
2941 gate_out,
2942 self.weights.gate_proj.weight_scale_2,
2943 self.weights.up_proj.weight_scale_2,
2944 m * inter,
2945 stream,
2946 )?;
2947 } else {
2948 ops::silu_mul(
2949 ctx.gpu,
2950 self.act_mul,
2951 gate_out,
2952 up_out,
2953 gate_out,
2954 m * inter,
2955 stream,
2956 )?;
2957 }
2958
2959 // W4A4 opt #1: quantize the down input (SiLU(gate)*up, `[M, inter]`) to NVFP4.
2960 if fp4_prefill {
2961 ops::quantize_bf16_to_nvfp4(
2962 ctx.gpu,
2963 self.quantize_nvfp4_k,
2964 gate_out,
2965 nvfp4_a_packed,
2966 nvfp4_a_scale,
2967 m,
2968 inter,
2969 stream,
2970 )?;
2971 }
2972 // Q4_K opt: quantize the down input (SiLU(gate)*up, `[M, inter]`) to q8_1.
2973 // Skip when the hybrid routes down to faith2 (it does its own int8 requant).
2974 if q4k_prefill && !down_faith2 {
2975 ops::quantize_act_q8_1(
2976 ctx.gpu,
2977 self.q4k_quant_act_k,
2978 gate_out,
2979 q4k_a,
2980 m,
2981 inter,
2982 stream,
2983 )?;
2984 }
2985 // FP4-MMQ down (two-step fallback, only when the fused kernel is absent):
2986 // quantize the down input (SiLU(gate)*up, `[M, inter]`) to block_fp4_mmq.
2987 if fp4mmq_down && !fused_down_quant {
2988 ops::nvfp4_mmq_quantize_act(
2989 ctx.gpu,
2990 self.nvfp4_quant_act_k,
2991 gate_out,
2992 fp4_y,
2993 m,
2994 inter,
2995 stream,
2996 )?;
2997 }
2998 // down_proj GEMM: [M, inter] → [M, H]
2999 // ($fp4cell is a placeholder — the FP4-MMQ arm is gated off by `false` below;
3000 // down stays on the default path in the FP4-MMQ hybrid.)
3001 let output = ctx.buffers.moe_output();
3002 w4_gemm!(
3003 &self.weights.down_proj,
3004 self.weights.down_proj_t,
3005 &self.int8_down,
3006 &self.q4k_down,
3007 &self.fp4mmq_down,
3008 fp4mmq_down,
3009 gate_out,
3010 output,
3011 h,
3012 inter,
3013 false
3014 );
3015 ffn_step!("down_proj", t_ffn);
3016 // FP4-MMQ down: fold the down-projection's per-tensor scale2 (no SiLU-mul here;
3017 // the consumer is the residual add).
3018 if fp4mmq_down {
3019 ops::nvfp4_scale_bf16(
3020 ctx.gpu,
3021 self.nvfp4_scale_k,
3022 output,
3023 self.weights.down_proj.weight_scale_2,
3024 m * h,
3025 stream,
3026 )?;
3027 }
3028 // AFTER the scale2 fold, not before: that fold scales the base
3029 // projection's output, and the delta is not part of that product.
3030 // `gate_out` holds silu(gate)*up — the activation the base down GEMM
3031 // just contracted over — because the fused silu+quant arm is off
3032 // whenever an adapter is installed.
3033 self.apply_lora_down(ctx, gate_out, output, m, stream)?;
3034
3035 Ok(())
3036 }
3037
3038 /// Batched forward (per-token loop). Used by forward_batched in model loop.
3039 pub fn forward_batched(
3040 &self,
3041 input: DevicePtr,
3042 num_tokens: usize,
3043 ctx: &ForwardContext,
3044 stream: u64,
3045 ) -> Result<()> {
3046 self.forward_prefill(input, num_tokens, ctx, stream)
3047 }
3048}
3049
3050/// The native-FP8 M=1 decode DOWN projection (#928) — the arm rule. A CHILD
3051/// module, not a sibling: this file is already at the CI size cap, and the
3052/// nsys attribution that motivates the arm needs room this file does not have.
3053#[path = "dense_ffn_fp8_down.rs"]
3054pub mod fp8_down;
3055/// W8A8 block-scaled prefill branch (#917/#928). A CHILD module, not a
3056/// sibling: it adds `impl DenseFfnLayer` methods that read this layer's
3057/// private kernel handles, and this file is already at the CI size cap.
3058#[path = "dense_ffn_w8a8_prefill.rs"]
3059pub mod w8a8_prefill;
3060
3061/// The 5..=32-row native-FP8 DECODE tier (#927) — same child-module reason as
3062/// `w8a8_prefill` above: it reads this layer's private kernel handles, and the
3063/// arm's rule, its opt-in and the measurements that made it opt-in do not fit
3064/// in this file's budget.
3065#[path = "dense_ffn_batch16_decode.rs"]
3066pub mod batch16_decode;
3067
3068/// The TENSOR-CORE 5..=32-row decode tier (`AVAROK_FFN_M16_TC`, #927) — same
3069/// child-module reason as the two above: it reads this layer's private kernel
3070/// handles, and its rule, lever and the numerics seam it opens need room.
3071#[path = "dense_ffn_m16_tc.rs"]
3072pub mod m16_tc;
3073
3074/// The FUSED gate+up DECODE GEMM (`ffn_gateup_fused`, #927) — a child module
3075/// because it reads this layer's private kernel handles, and the round-13
3076/// receipt, the layout decision and the residency-neutrality argument need
3077/// room this file does not have.
3078#[path = "dense_ffn_gateup_fused.rs"]
3079pub mod gateup_fused;
3080
3081/// Native BF16/FP8 overlays take precedence over any NVFP4 fallback weights.
3082/// Small batches must use the same format-aware dispatcher as prefill.
3083fn native_small_batch_uses_prefill(has_bf16: bool, has_fp8: bool) -> bool {
3084 has_bf16 || has_fp8
3085}
3086
3087#[cfg(test)]
3088#[path = "dense_ffn_mmq_tests.rs"]
3089mod mmq_tests;
3090
3091#[cfg(test)]
3092#[path = "dense_ffn_native_batch_tests.rs"]
3093mod native_batch_tests;
3094
3095#[cfg(test)]
3096#[path = "dense_ffn_kernel_tests.rs"]
3097mod kernel_tests;
3098
3099/// #915: the native-FP8 route must work with NO NVFP4 fallback weights, so the
3100/// loader can stop allocating 18.4 GiB of them.
3101#[cfg(test)]
3102#[path = "dense_ffn_fp8_residency_tests.rs"]
3103mod fp8_residency_tests;
3104
3105#[cfg(test)]
3106#[path = "dense_ffn_fp8_down_tests.rs"]
3107mod fp8_down_tests;
3108
3109#[cfg(test)]
3110mod tests {
3111 use super::native_small_batch_uses_prefill;
3112
3113 #[test]
3114 fn native_weight_presence_requires_prefill_dispatch() {
3115 assert!(native_small_batch_uses_prefill(true, false));
3116 assert!(native_small_batch_uses_prefill(false, true));
3117 assert!(native_small_batch_uses_prefill(true, true));
3118 assert!(!native_small_batch_uses_prefill(false, false));
3119 }
3120}