Skip to main content

ferrox_models/
gdn.rs

1//! Qwen-style Gated Delta Net (GDN) — linear-attention / SSM recurrent
2//! primitive for hybrid arches (`qwen35`, `qwen35moe`, `qwen3next`, …).
3//!
4//! Distinct from Kimi KDA (`kda.rs`): GDN uses a **fused** QKV projection,
5//! a **single** depthwise `ssm_conv1d` over the concatenated QKV channels,
6//! per-head `ssm_alpha` / `ssm_beta` gates, and decay
7//! `exp(softplus(α + ssm_dt) · ssm_a)` (GGUF `ssm_a` is typically
8//! `-exp(A_log)`). KDA is not a drop-in for this graph.
9//!
10//! ## GGUF tensor name mapping (per layer `L`)
11//!
12//! | Role | GGUF name |
13//! |---|---|
14//! | Fused Q‖K‖V | `blk.{L}.attn_qkv.weight` |
15//! | Output / z gate | `blk.{L}.attn_gate.weight` |
16//! | Depthwise causal conv | `blk.{L}.ssm_conv1d.weight` |
17//! | Decay bias | `blk.{L}.ssm_dt.bias` (alt: `ssm_dt`) |
18//! | Decay scale | `blk.{L}.ssm_a` |
19//! | Input gate β | `blk.{L}.ssm_beta.weight` |
20//! | Forget raw α | `blk.{L}.ssm_alpha.weight` |
21//! | Output RMSNorm | `blk.{L}.ssm_norm.weight` |
22//! | Output projection | `blk.{L}.ssm_out.weight` |
23//!
24//! Legacy `qwen3next` may pack β/α into `ssm_ba` or fuse QKV+z into
25//! `ssm_in`; this module implements the split qwen35 layout only.
26//!
27//! GGUF weight load skeleton: [`crate::hybrid_gguf_loader`]. Serve still
28//! fail-closed — factory [`HybridEngine::reject`](crate::hybrid_engine::HybridEngine::reject).
29
30use ferrox_core::matmul::{rms_norm, silu};
31use ferrox_core::weight_matrix::WeightMatrix;
32
33/// Tiny-config dims for the Qwen35-style GDN step (equal K/V heads).
34#[derive(Debug, Clone, Copy)]
35pub struct GdnConfig {
36    pub hidden_dim: usize,
37    pub num_v_heads: usize,
38    pub head_dim: usize,
39    pub conv_kernel_size: usize,
40    pub rms_norm_eps: f32,
41}
42
43impl GdnConfig {
44    pub fn qkv_dim(&self) -> usize {
45        // Q + K + V with num_k_heads == num_v_heads, head_k == head_v.
46        3 * self.num_v_heads * self.head_dim
47    }
48
49    pub fn v_dim(&self) -> usize {
50        self.num_v_heads * self.head_dim
51    }
52}
53
54/// Weights matching the qwen35 GGUF layout (see module docs).
55pub struct GdnWeights {
56    pub attn_qkv: WeightMatrix,  // [qkv_dim, hidden]
57    pub attn_gate: WeightMatrix, // [v_dim, hidden]
58    /// Depthwise taps, row-major `[qkv_dim, conv_kernel_size]`.
59    pub ssm_conv1d: Vec<f32>,
60    pub ssm_dt: Vec<f32>,        // [num_v_heads]
61    pub ssm_a: Vec<f32>,         // [num_v_heads]
62    pub ssm_beta: WeightMatrix,  // [num_v_heads, hidden]
63    pub ssm_alpha: WeightMatrix, // [num_v_heads, hidden]
64    pub ssm_norm: Vec<f32>,      // [head_dim]
65    pub ssm_out: WeightMatrix,   // [hidden, v_dim]
66}
67
68/// Fixed-size recurrent + short-conv state (unlike growing KV).
69pub struct GdnState {
70    conv_hist: Vec<f32>,
71    /// Flat `[num_v_heads, head_dim, head_dim]` — state[v, k] per head.
72    recurrent: Vec<f32>,
73}
74
75impl GdnState {
76    pub fn new(cfg: &GdnConfig) -> Self {
77        Self {
78            conv_hist: Vec::new(),
79            recurrent: vec![0f32; cfg.num_v_heads * cfg.head_dim * cfg.head_dim],
80        }
81    }
82}
83
84fn softplus(x: f32) -> f32 {
85    if x > 20.0 {
86        x
87    } else {
88        (1.0 + x.exp()).ln()
89    }
90}
91
92fn sigmoid(x: f32) -> f32 {
93    1.0 / (1.0 + (-x).exp())
94}
95
96fn l2_normalize(v: &mut [f32], eps: f32) {
97    let norm_sq: f32 = v.iter().map(|x| x * x).sum();
98    let scale = 1.0 / (norm_sq + eps).sqrt();
99    for x in v.iter_mut() {
100        *x *= scale;
101    }
102}
103
104/// Depthwise causal conv over `dim` channels + SiLU (padding = kernel−1).
105fn causal_conv_step(
106    weight: &[f32],
107    history: &mut Vec<f32>,
108    current: &[f32],
109    kernel_size: usize,
110    dim: usize,
111) -> Vec<f32> {
112    let hist_len = history.len() / dim.max(1);
113    let missing = (kernel_size - 1).saturating_sub(hist_len);
114
115    let mut y = vec![0f32; dim];
116    for j in 0..kernel_size {
117        if j < missing {
118            continue;
119        }
120        let src: &[f32] = if j == kernel_size - 1 {
121            current
122        } else {
123            let hist_idx = j - missing;
124            &history[hist_idx * dim..(hist_idx + 1) * dim]
125        };
126        for d in 0..dim {
127            y[d] += weight[d * kernel_size + j] * src[d];
128        }
129    }
130    for v in y.iter_mut() {
131        *v = silu(*v);
132    }
133
134    history.extend_from_slice(current);
135    let max_hist_len = (kernel_size - 1) * dim;
136    if history.len() > max_hist_len {
137        let excess = history.len() - max_hist_len;
138        history.drain(0..excess);
139    }
140    y
141}
142
143/// One decode step. Assumes `num_k_heads == num_v_heads` and
144/// `head_k_dim == head_v_dim == cfg.head_dim`.
145pub fn gdn_forward_token(
146    weights: &GdnWeights,
147    cfg: &GdnConfig,
148    hidden: &[f32],
149    state: &mut GdnState,
150) -> Vec<f32> {
151    assert_eq!(hidden.len(), cfg.hidden_dim);
152    let qkv_dim = cfg.qkv_dim();
153    let v_dim = cfg.v_dim();
154    let head_dim = cfg.head_dim;
155    let n_heads = cfg.num_v_heads;
156
157    let qkv_lin = weights.attn_qkv.apply(hidden);
158    let z = weights.attn_gate.apply(hidden);
159    let beta_raw = weights.ssm_beta.apply(hidden);
160    let alpha_raw = weights.ssm_alpha.apply(hidden);
161
162    let qkv = causal_conv_step(
163        &weights.ssm_conv1d,
164        &mut state.conv_hist,
165        &qkv_lin,
166        cfg.conv_kernel_size,
167        qkv_dim,
168    );
169
170    let qk_dim = n_heads * head_dim;
171    let (q_all, rest) = qkv.split_at(qk_dim);
172    let (k_all, v_all) = rest.split_at(qk_dim);
173
174    let scale = 1.0 / (head_dim as f32).sqrt();
175    let mut y_flat = vec![0f32; v_dim];
176
177    #[allow(clippy::needless_range_loop)]
178    for h in 0..n_heads {
179        let base = h * head_dim;
180        let mut q_h = q_all[base..base + head_dim].to_vec();
181        let mut k_h = k_all[base..base + head_dim].to_vec();
182        let v_h = &v_all[base..base + head_dim];
183
184        l2_normalize(&mut q_h, 1e-6);
185        l2_normalize(&mut k_h, 1e-6);
186        for x in q_h.iter_mut() {
187            *x *= scale;
188        }
189
190        // g = exp(softplus(α + dt) * A); A = ssm_a (often negative).
191        let gate = softplus(alpha_raw[h] + weights.ssm_dt[h]) * weights.ssm_a[h];
192        let decay = gate.exp();
193        let beta = sigmoid(beta_raw[h]);
194
195        let s_base = h * head_dim * head_dim;
196        let s = &mut state.recurrent[s_base..s_base + head_dim * head_dim];
197
198        // state *= decay
199        for cell in s.iter_mut() {
200            *cell *= decay;
201        }
202
203        // kv_mem[v] = sum_k state[v,k] * k[k]
204        let mut kv_mem = vec![0f32; head_dim];
205        for v_idx in 0..head_dim {
206            let mut acc = 0f32;
207            for k_idx in 0..head_dim {
208                acc += s[v_idx * head_dim + k_idx] * k_h[k_idx];
209            }
210            kv_mem[v_idx] = acc;
211        }
212
213        // state[v,k] += beta * (v - kv_mem)[v] * k[k]
214        for v_idx in 0..head_dim {
215            let delta = (v_h[v_idx] - kv_mem[v_idx]) * beta;
216            for k_idx in 0..head_dim {
217                s[v_idx * head_dim + k_idx] += delta * k_h[k_idx];
218            }
219        }
220
221        // y[v] = sum_k state[v,k] * q[k]
222        for v_idx in 0..head_dim {
223            let mut acc = 0f32;
224            for k_idx in 0..head_dim {
225                acc += s[v_idx * head_dim + k_idx] * q_h[k_idx];
226            }
227            y_flat[base + v_idx] = acc;
228        }
229    }
230
231    // Per-head RMSNorm on y, then SiLU(z) * normed.
232    let mut gated = vec![0f32; v_dim];
233    for h in 0..n_heads {
234        let base = h * head_dim;
235        let normed = rms_norm(
236            &y_flat[base..base + head_dim],
237            &weights.ssm_norm,
238            cfg.rms_norm_eps,
239        );
240        for i in 0..head_dim {
241            gated[base + i] = silu(z[base + i]) * normed[i];
242        }
243    }
244
245    weights.ssm_out.apply(&gated)
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use ferrox_core::tensor::Tensor;
252
253    const HIDDEN: usize = 4;
254    const N_HEADS: usize = 2;
255    const HEAD_DIM: usize = 2;
256    const CONV_K: usize = 2;
257    const QKV_DIM: usize = 3 * N_HEADS * HEAD_DIM; // 12
258    const V_DIM: usize = N_HEADS * HEAD_DIM; // 4
259
260    fn wm(data: &[f32], rows: usize, cols: usize) -> WeightMatrix {
261        assert_eq!(data.len(), rows * cols);
262        WeightMatrix::F32(Tensor::new(data.to_vec(), vec![rows, cols]))
263    }
264
265    fn cfg() -> GdnConfig {
266        GdnConfig {
267            hidden_dim: HIDDEN,
268            num_v_heads: N_HEADS,
269            head_dim: HEAD_DIM,
270            conv_kernel_size: CONV_K,
271            rms_norm_eps: 1e-5,
272        }
273    }
274
275    fn make_weights() -> GdnWeights {
276        // Deterministic tiny synthetic weights (not a golden oracle).
277        let mut qkv = Vec::with_capacity(QKV_DIM * HIDDEN);
278        for i in 0..QKV_DIM * HIDDEN {
279            qkv.push(((i % 7) as f32 - 3.0) * 0.1);
280        }
281        let mut gate = Vec::with_capacity(V_DIM * HIDDEN);
282        for i in 0..V_DIM * HIDDEN {
283            gate.push(((i % 5) as f32 - 2.0) * 0.08);
284        }
285        let mut conv = Vec::with_capacity(QKV_DIM * CONV_K);
286        for i in 0..QKV_DIM * CONV_K {
287            conv.push(if i % CONV_K == CONV_K - 1 { 1.0 } else { 0.1 });
288        }
289        let mut beta = Vec::with_capacity(N_HEADS * HIDDEN);
290        let mut alpha = Vec::with_capacity(N_HEADS * HIDDEN);
291        for i in 0..N_HEADS * HIDDEN {
292            beta.push(((i % 3) as f32 - 1.0) * 0.2);
293            alpha.push(((i % 4) as f32 - 1.5) * 0.15);
294        }
295        let mut out = Vec::with_capacity(HIDDEN * V_DIM);
296        for i in 0..HIDDEN * V_DIM {
297            out.push(((i % 6) as f32 - 2.5) * 0.12);
298        }
299        GdnWeights {
300            attn_qkv: wm(&qkv, QKV_DIM, HIDDEN),
301            attn_gate: wm(&gate, V_DIM, HIDDEN),
302            ssm_conv1d: conv,
303            ssm_dt: vec![0.1, -0.05],
304            // Negative A → decay ∈ (0, 1] after softplus·A + exp.
305            ssm_a: vec![-0.5, -0.75],
306            ssm_beta: wm(&beta, N_HEADS, HIDDEN),
307            ssm_alpha: wm(&alpha, N_HEADS, HIDDEN),
308            ssm_norm: vec![1.0, 1.0],
309            ssm_out: wm(&out, HIDDEN, V_DIM),
310        }
311    }
312
313    #[test]
314    fn gdn_forward_token_tiny_dims_finite_and_shaped() {
315        let weights = make_weights();
316        let cfg = cfg();
317        let mut state = GdnState::new(&cfg);
318        let hidden = [0.2f32, -0.1, 0.3, -0.4];
319
320        let out0 = gdn_forward_token(&weights, &cfg, &hidden, &mut state);
321        assert_eq!(out0.len(), HIDDEN);
322        assert!(out0.iter().all(|x| x.is_finite()));
323
324        let out1 = gdn_forward_token(&weights, &cfg, &hidden, &mut state);
325        assert_eq!(out1.len(), HIDDEN);
326        assert!(out1.iter().all(|x| x.is_finite()));
327        // Second step must see non-zero recurrent state → different output.
328        assert!(
329            out0.iter()
330                .zip(out1.iter())
331                .any(|(a, b)| (a - b).abs() > 1e-6),
332            "recurrent state should change the second token"
333        );
334    }
335
336    #[test]
337    fn softplus_matches_closed_form_at_zero() {
338        assert!((softplus(0.0) - (2.0f32).ln()).abs() < 1e-6);
339    }
340}