spark_model/forward/qwen3_5/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Vendor-agnostic Qwen3.5 per-layer decoder forward.
3//!
4//! Extracted verbatim from the original Metal end-to-end driver
5//! (`crates/spark-runtime/examples/metal_qwen35_inference/`). The two
6//! exported functions — [`forward_full_attention`] and
7//! [`forward_linear_attention`] — drive a single decoder layer end to
8//! end (norm → projections → attention/GDN → residual+post-norm →
9//! MLP → residual). Any end-to-end inference example calls these
10//! through `&dyn GpuBackend` + a `QuantWeights` impl, regardless of
11//! which hardware target the backend speaks.
12//!
13//! What the module does not do (intentional):
14//! - Multi-token prefill / batched dispatch — single-token decode only
15//!   (KV-append at `cache_pos`, attention at `seq_len_attn = cache_pos+1`).
16//! - CUDA-graph capture, NCCL, paged-KV — the production decode path
17//!   (`crate::model::trait_impl::decode_a`) handles those; this is the
18//!   simpler shape an example or smoke driver wants.
19//! - Tokenizer / sampler / weight loading — the caller owns these.
20//!
21//! Performance: the fused kernels (`gemv_silu_gate_resid`,
22//! `gemv_gate_up_with`, `add_rms_norm`) all dispatch through trait
23//! methods that backends override with their fused launches. Atlas's
24//! Metal backend keeps decode at ~20 tok/s through this path
25//! identically to the inlined version it replaces.
26
27use anyhow::Result;
28use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
29
30use super::quant_weights::QuantWeights;
31
32mod full_attention;
33mod linear_attention;
34
35pub use full_attention::forward_full_attention;
36pub use linear_attention::forward_linear_attention;
37
38/// Compile-time-fixed dimensions for a Qwen3.5 checkpoint. Populate
39/// from the model's `config.json` (`text_config`) at startup.
40#[derive(Debug, Clone, Copy)]
41pub struct Qwen35ForwardConfig {
42    // Top-level model dims.
43    pub hidden: u32,
44    pub intermediate: u32,
45    pub num_layers: u32,
46    pub vocab: u32,
47    pub group_size: u32,
48    pub rms_eps: f32,
49
50    // Full-attention dims.
51    pub num_heads: u32,
52    pub num_kv_heads: u32,
53    pub head_dim: u32,
54    pub rope_theta: f32,
55    /// `head_dim * partial_rotary_factor` — Qwen3.5-VL rotates only
56    /// the first `rotary_dim` of each head (=64 of 256 for the
57    /// 4B checkpoint with `partial_rotary_factor = 0.25`).
58    pub rotary_dim: u32,
59
60    // Linear-attention (GDN) dims.
61    pub num_k_heads_lin: u32,
62    pub num_v_heads_lin: u32,
63    pub k_head_dim_lin: u32,
64    pub v_head_dim_lin: u32,
65    pub conv_kernel_size: u32,
66}
67
68impl Qwen35ForwardConfig {
69    /// Hardcoded constants for `mlx-community/Qwen3.5-4B-MLX-8bit`.
70    /// Matches the Metal example's `dims.rs` exactly so the extracted
71    /// forward path is byte-equivalent to the inlined version.
72    pub const fn qwen3_5_4b_mlx_int8() -> Self {
73        Self {
74            hidden: 2560,
75            intermediate: 9216,
76            num_layers: 32,
77            vocab: 248_320,
78            group_size: 64,
79            rms_eps: 1e-6,
80            num_heads: 16,
81            num_kv_heads: 4,
82            head_dim: 256,
83            rope_theta: 10_000_000.0,
84            rotary_dim: 64, // = head_dim * partial_rotary_factor (0.25)
85            num_k_heads_lin: 16,
86            num_v_heads_lin: 32,
87            k_head_dim_lin: 128,
88            v_head_dim_lin: 128,
89            conv_kernel_size: 4,
90        }
91    }
92
93    /// `Q_TOTAL = num_heads * head_dim * 2` — Qwen3.5 packs the
94    /// attention output gate into the same projection as Q, so the
95    /// q_proj produces a `[num_heads, head_dim * 2]` interleaved
96    /// tensor that needs a deinterleave step before normalisation.
97    #[inline]
98    pub const fn q_total(&self) -> u32 {
99        self.num_heads * self.head_dim * 2
100    }
101    /// `Q_ONLY = num_heads * head_dim` — half of `Q_TOTAL`, the
102    /// post-deinterleave Q size.
103    #[inline]
104    pub const fn q_only(&self) -> u32 {
105        self.num_heads * self.head_dim
106    }
107    /// `KV_DIM = num_kv_heads * head_dim`.
108    #[inline]
109    pub const fn kv_dim(&self) -> u32 {
110        self.num_kv_heads * self.head_dim
111    }
112    /// `Z_DIM_LIN = num_v_heads_lin * v_head_dim_lin`.
113    #[inline]
114    pub const fn z_dim_lin(&self) -> u32 {
115        self.num_v_heads_lin * self.v_head_dim_lin
116    }
117    /// `QKV_TOTAL_LIN = (num_k_heads_lin + num_k_heads_lin) * k_head_dim_lin
118    ///                + num_v_heads_lin * v_head_dim_lin`.
119    #[inline]
120    pub const fn qkv_total_lin(&self) -> u32 {
121        2 * self.num_k_heads_lin * self.k_head_dim_lin + self.num_v_heads_lin * self.v_head_dim_lin
122    }
123    /// `NUM_STATE_HEADS = num_v_heads_lin` — the number of GDN heads
124    /// the gate / beta / dt_bias / A_log vectors all run over.
125    #[inline]
126    pub const fn num_state_heads(&self) -> u32 {
127        self.num_v_heads_lin
128    }
129}
130
131/// Pre-resolved kernel handles. Resolve once at startup; pass `&` to
132/// every per-layer call so name-lookup overhead doesn't appear in the
133/// hot path.
134pub struct Qwen35Kernels {
135    pub rms: KernelHandle,
136    pub rope: KernelHandle,
137    pub kvap: KernelHandle,
138    pub attn: KernelHandle,
139    pub sg: KernelHandle,
140    pub add_rms: KernelHandle,
141    pub qkv_split: KernelHandle,
142    pub conv1d: KernelHandle,
143    pub gdn_gate: KernelHandle,
144    pub sigmoid: KernelHandle,
145    pub gdn_dec: KernelHandle,
146    /// TurboQuant KV cache paths (Turbo8/4/3/2): quantizing appends,
147    /// dequantizing decode attentions, and the WHT rotation bookends.
148    /// Resolved unconditionally (the kernels live in the common set) so
149    /// a turbo cache can never silently fall back to the bf16 kernels.
150    pub kvap_turbo8: KernelHandle,
151    pub attn_turbo8: KernelHandle,
152    pub kvap_turbo4: KernelHandle,
153    pub attn_turbo4: KernelHandle,
154    pub kvap_turbo3: KernelHandle,
155    pub attn_turbo3: KernelHandle,
156    pub kvap_turbo2: KernelHandle,
157    pub attn_turbo2: KernelHandle,
158    pub kvap_bf16k_turbo4v: KernelHandle,
159    pub attn_bf16k_turbo4v: KernelHandle,
160    pub kvap_bf16k_turbo3v: KernelHandle,
161    pub attn_bf16k_turbo3v: KernelHandle,
162    pub kvap_bf16k_turbo2v: KernelHandle,
163    pub attn_bf16k_turbo2v: KernelHandle,
164    pub wht: KernelHandle,
165    pub wht_inv: KernelHandle,
166}
167
168impl Qwen35Kernels {
169    /// Look up every kernel the per-layer forward needs. Fails loudly
170    /// if any are missing — better to surface that at startup than
171    /// silently mid-decode.
172    pub fn resolve(gpu: &dyn GpuBackend) -> Result<Self> {
173        Ok(Self {
174            rms: gpu.kernel("rms_norm", "rms_norm")?,
175            rope: gpu.kernel("rope_apply", "rope_apply")?,
176            kvap: gpu.kernel("kv_cache_append", "kv_cache_append")?,
177            attn: gpu.kernel("attention_decode", "attention_decode")?,
178            sg: gpu.kernel("sigmoid_gate", "sigmoid_gate")?,
179            add_rms: gpu.kernel("add_rms_norm", "add_rms_norm")?,
180            qkv_split: gpu.kernel("qwen35_qkv_split", "qwen35_qkv_split")?,
181            conv1d: gpu.kernel("causal_conv1d_update_l2norm", "causal_conv1d_update_l2norm")?,
182            gdn_gate: gpu.kernel("gdn_helpers", "gdn_compute_gate")?,
183            sigmoid: gpu.kernel("gdn_helpers", "sigmoid_bf16_to_f32")?,
184            gdn_dec: gpu.kernel("gated_delta_rule_decode", "gated_delta_rule_decode")?,
185            kvap_turbo8: gpu.kernel("kv_cache_append_turbo8", "kv_cache_append_turbo8")?,
186            attn_turbo8: gpu.kernel("attention_decode_turbo8", "attention_decode_turbo8")?,
187            kvap_turbo4: gpu.kernel("kv_cache_append_turbo4", "kv_cache_append_turbo4")?,
188            attn_turbo4: gpu.kernel("attention_decode_turbo4", "attention_decode_turbo4")?,
189            kvap_turbo3: gpu.kernel("kv_cache_append_turbo3", "kv_cache_append_turbo3")?,
190            attn_turbo3: gpu.kernel("attention_decode_turbo3", "attention_decode_turbo3")?,
191            kvap_turbo2: gpu.kernel("kv_cache_append_turbo2", "kv_cache_append_turbo2")?,
192            attn_turbo2: gpu.kernel("attention_decode_turbo2", "attention_decode_turbo2")?,
193            kvap_bf16k_turbo4v: gpu.kernel(
194                "kv_cache_append_bf16k_turbov",
195                "kv_cache_append_bf16k_turbo4v",
196            )?,
197            attn_bf16k_turbo4v: gpu.kernel(
198                "attention_decode_bf16k_turbov",
199                "attention_decode_bf16k_turbo4v",
200            )?,
201            kvap_bf16k_turbo3v: gpu.kernel(
202                "kv_cache_append_bf16k_turbov",
203                "kv_cache_append_bf16k_turbo3v",
204            )?,
205            attn_bf16k_turbo3v: gpu.kernel(
206                "attention_decode_bf16k_turbov",
207                "attention_decode_bf16k_turbo3v",
208            )?,
209            kvap_bf16k_turbo2v: gpu.kernel(
210                "kv_cache_append_bf16k_turbov",
211                "kv_cache_append_bf16k_turbo2v",
212            )?,
213            attn_bf16k_turbo2v: gpu.kernel(
214                "attention_decode_bf16k_turbov",
215                "attention_decode_bf16k_turbo2v",
216            )?,
217            wht: gpu.kernel("wht_bf16", "wht_bf16_inplace")?,
218            wht_inv: gpu.kernel("wht_bf16", "wht_bf16_inplace_inv")?,
219        })
220    }
221}
222
223/// KV cache storage format for the Metal contiguous cache.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum MetalKvDtype {
226    /// Raw bfloat, 2 bytes/elem.
227    Bf16,
228    /// FP8 E4M3 data + bf16 group-of-16 scales, WHT-rotated basis.
229    /// 2.13× smaller than bf16.
230    Turbo8,
231    /// 4-bit Lloyd-Max codebook indices + FP8 group-of-16 scales
232    /// (matched-norm L2), WHT-rotated basis. 3.56× smaller than bf16.
233    Turbo4,
234    /// 3-bit Lloyd-Max (8 values → 3 bytes) + FP8 group scales.
235    /// 4.57× smaller than bf16.
236    Turbo3,
237    /// 2-bit Lloyd-Max (4 elems/byte) + FP8 group scales.
238    /// 6.4× smaller than bf16.
239    Turbo2,
240    /// Safer-asym: K raw bf16 (un-rotated), V Turbo4. Production-
241    /// recommended frontier — K precision dominates retrieval quality.
242    Bf16KTurbo4V,
243    /// Safer-asym: K raw bf16, V Turbo3.
244    Bf16KTurbo3V,
245    /// Safer-asym: K raw bf16, V Turbo2.
246    Bf16KTurbo2V,
247}
248
249impl MetalKvDtype {
250    /// K side stored in the WHT-rotated basis (gates K rotation at
251    /// append and the WHT(Q) decode bookend).
252    pub fn k_is_rotated(self) -> bool {
253        matches!(
254            self,
255            Self::Turbo8 | Self::Turbo4 | Self::Turbo3 | Self::Turbo2
256        )
257    }
258    /// V side stored in the WHT-rotated basis (gates V rotation at
259    /// append and the iWHT(out) decode bookend).
260    pub fn v_is_rotated(self) -> bool {
261        self != Self::Bf16
262    }
263}
264
265impl std::str::FromStr for MetalKvDtype {
266    type Err = anyhow::Error;
267    fn from_str(s: &str) -> Result<Self> {
268        match s {
269            "bf16" => Ok(Self::Bf16),
270            "turbo8" => Ok(Self::Turbo8),
271            "turbo4" => Ok(Self::Turbo4),
272            "turbo3" => Ok(Self::Turbo3),
273            "turbo2" => Ok(Self::Turbo2),
274            "bf16k_turbo4v" => Ok(Self::Bf16KTurbo4V),
275            "bf16k_turbo3v" => Ok(Self::Bf16KTurbo3V),
276            "bf16k_turbo2v" => Ok(Self::Bf16KTurbo2V),
277            other => {
278                anyhow::bail!(
279                    "kv dtype {other:?} not supported on metal (bf16 | turbo8 | turbo4 | turbo3 | turbo2 | bf16k_turbo4v/3v/2v)"
280                )
281            }
282        }
283    }
284}
285
286/// Per-layer KV cache for a full-attention layer (single-batch).
287///
288/// `dtype` selects the storage format; for the turbo formats `k`/`v`
289/// hold packed quantized data in the WHT-rotated basis and `scales`
290/// holds the per-16-element group scales (bf16 for Turbo8, FP8 E4M3
291/// bytes for Turbo4). The forward routes appends and attention through
292/// the matching kernels with WHT(Q)/iWHT(out) bookends.
293pub struct LayerKvCache {
294    pub k: DevicePtr,
295    pub v: DevicePtr,
296    /// Capacity in tokens — caller pre-allocates `max_seq_len * KV_DIM`.
297    #[allow(dead_code)]
298    pub capacity: u32,
299    pub dtype: MetalKvDtype,
300    /// Per-side group-scale buffers — `Some` only for quantized sides
301    /// (both for symmetric turbo dtypes, V-only for the safer-asym
302    /// Bf16K+TurboNV family, neither for Bf16).
303    pub k_scales: Option<DevicePtr>,
304    pub v_scales: Option<DevicePtr>,
305}
306
307impl LayerKvCache {
308    /// Allocate a cache in the given storage format.
309    pub fn alloc(
310        gpu: &dyn GpuBackend,
311        dtype: MetalKvDtype,
312        max_seq: u32,
313        kv_dim: u32,
314    ) -> Result<Self> {
315        assert!(
316            dtype == MetalKvDtype::Bf16 || kv_dim.is_multiple_of(16),
317            "turbo dtypes need KV_DIM divisible by 16"
318        );
319        let n = (max_seq * kv_dim) as usize;
320        let scale_bytes_e4m3 = (max_seq * kv_dim / 16) as usize;
321        // (k_bytes, v_bytes, k_scale_bytes, v_scale_bytes)
322        let (kb, vb, ksb, vsb) = match dtype {
323            MetalKvDtype::Bf16 => (n * 2, n * 2, 0, 0),
324            // 1 byte/elem + bf16 scales (2 bytes per group of 16).
325            MetalKvDtype::Turbo8 => (n, n, scale_bytes_e4m3 * 2, scale_bytes_e4m3 * 2),
326            // 2 elems/byte + E4M3 scales.
327            MetalKvDtype::Turbo4 => (n / 2, n / 2, scale_bytes_e4m3, scale_bytes_e4m3),
328            // 8 values -> 3 bytes + E4M3 scales.
329            MetalKvDtype::Turbo3 => (n * 3 / 8, n * 3 / 8, scale_bytes_e4m3, scale_bytes_e4m3),
330            // 4 elems/byte + E4M3 scales.
331            MetalKvDtype::Turbo2 => (n / 4, n / 4, scale_bytes_e4m3, scale_bytes_e4m3),
332            // Safer-asym: K raw bf16, V packed + E4M3 scales.
333            MetalKvDtype::Bf16KTurbo4V => (n * 2, n / 2, 0, scale_bytes_e4m3),
334            MetalKvDtype::Bf16KTurbo3V => (n * 2, n * 3 / 8, 0, scale_bytes_e4m3),
335            MetalKvDtype::Bf16KTurbo2V => (n * 2, n / 4, 0, scale_bytes_e4m3),
336        };
337        let alloc_opt = |bytes: usize| -> Result<Option<DevicePtr>> {
338            Ok(if bytes > 0 {
339                Some(gpu.alloc(bytes)?)
340            } else {
341                None
342            })
343        };
344        Ok(Self {
345            k: gpu.alloc(kb)?,
346            v: gpu.alloc(vb)?,
347            capacity: max_seq,
348            dtype,
349            k_scales: alloc_opt(ksb)?,
350            v_scales: alloc_opt(vsb)?,
351        })
352    }
353}
354
355/// Full-attention layer weights, parameterised over the backend's
356/// quantised weight type.
357pub struct FullAttentionLayer<'a, Q: QuantWeights> {
358    pub input_ln: DevicePtr,
359    pub q_norm: DevicePtr,
360    pub k_norm: DevicePtr,
361    pub post_ln: DevicePtr,
362    pub q_proj: &'a Q,
363    pub k_proj: &'a Q,
364    pub v_proj: &'a Q,
365    pub o_proj: &'a Q,
366    pub gate_proj: &'a Q,
367    pub up_proj: &'a Q,
368    pub down_proj: &'a Q,
369}
370
371/// Per-call scratch buffers for the full-attention forward.
372pub struct FullAttentionScratch {
373    pub x_norm: DevicePtr,
374    pub q_full: DevicePtr,
375    pub q_split: DevicePtr,
376    pub gate_split: DevicePtr,
377    pub k: DevicePtr,
378    pub v: DevicePtr,
379    pub q_norm_out: DevicePtr,
380    pub k_norm_out: DevicePtr,
381    pub attn_out: DevicePtr,
382    pub gated_attn: DevicePtr,
383    pub o: DevicePtr,
384    pub x_resid: DevicePtr,
385    pub x_norm2: DevicePtr,
386    pub gate_act: DevicePtr,
387    pub up_act: DevicePtr,
388    pub x_out: DevicePtr,
389}
390
391/// Linear-attention (GDN) layer weights.
392pub struct LinearAttentionLayer<'a, Q: QuantWeights> {
393    pub input_ln: DevicePtr,
394    /// FP32 `[num_state_heads]`.
395    pub a_log: DevicePtr,
396    /// BF16 `[num_state_heads]`.
397    pub dt_bias: DevicePtr,
398    /// BF16 `[QKV_TOTAL_LIN, conv_kernel_size, 1]`.
399    pub conv1d_weight: DevicePtr,
400    pub in_proj_a: &'a Q,
401    pub in_proj_b: &'a Q,
402    pub in_proj_qkv: &'a Q,
403    pub in_proj_z: &'a Q,
404    /// BF16 `[v_head_dim_lin]`.
405    pub norm_weight: DevicePtr,
406    pub out_proj: &'a Q,
407    pub post_ln: DevicePtr,
408    pub gate_proj: &'a Q,
409    pub up_proj: &'a Q,
410    pub down_proj: &'a Q,
411}
412
413/// Per-layer SSM/conv state for a linear-attention layer. Persists
414/// across tokens. Caller owns alloc + zero-init.
415pub struct LinearAttentionState {
416    /// FP32 `[QKV_TOTAL_LIN, conv_kernel_size]`.
417    pub conv1d_state: DevicePtr,
418    /// FP32 `[batch=1, num_v_heads_lin, k_head_dim_lin, v_head_dim_lin]`.
419    pub gdn_state: DevicePtr,
420}
421
422/// Per-call scratch buffers for the linear-attention forward.
423pub struct LinearAttentionScratch {
424    pub x_norm: DevicePtr,
425    pub dt_raw: DevicePtr,
426    pub b_raw: DevicePtr,
427    pub qkv: DevicePtr,
428    pub qkv_smooth: DevicePtr,
429    pub z: DevicePtr,
430    /// FP32 `[num_state_heads]`.
431    pub gate: DevicePtr,
432    /// FP32 `[num_state_heads]`.
433    pub beta: DevicePtr,
434    pub y: DevicePtr,
435    pub y_norm: DevicePtr,
436    pub out: DevicePtr,
437    pub x_resid: DevicePtr,
438    pub x_norm2: DevicePtr,
439    pub gate_act: DevicePtr,
440    pub up_act: DevicePtr,
441    pub x_final: DevicePtr,
442}