Skip to main content

ferrox_models/
deepseek_v4_decoder.rs

1//! A dedicated decoder skeleton for DeepSeek V4's real architecture,
2//! separate from `ferrox-models::decoder::Decoder` (the generic GQA path
3//! every other preset uses) — analogous to `glm52_decoder.rs` /
4//! `kimi_decoder.rs`: composes the already-independently-tested mHC,
5//! CSA/HCA compression + attention, derope, grouped output projection,
6//! and sqrtsoftplus MoE primitives into one synthetic forward pass.
7//!
8//! **Synthetic weights only — not a real checkpoint path.** No GGUF
9//! loader, no `Engine` wiring, no claim of oracle-correct output against
10//! a production DeepSeek V4 file. This module exists to prove the real
11//! primitives compose into a finite forward pass end-to-end on tiny dims,
12//! the same rigor `glm52_decoder.rs` applies before a real loader lands.
13//!
14//! Real per-layer flow (simplified to one layer here, transcribed from
15//! llama.cpp PR #24162 `deepseek4.cpp`'s layer loop):
16//!
17//! ```text
18//! attn_in  = mHC_pre(hc_streams, attn_hc_pre)
19//! attn_out = rms_norm(attn_in) |> HCA/CSA attention |> derope |> grouped wo_a/wo_b
20//! hc_streams = mHC_post(attn_out, ...)
21//! ffn_in   = mHC_pre(hc_streams, ffn_hc_pre)
22//! ffn_out  = rms_norm(ffn_in) |> MoE (sqrtsoftplus routing)
23//! hc_streams = mHC_post(ffn_out, ...)
24//! hidden   = mHC_head(hc_streams)
25//! logits   = output_head(rms_norm(hidden))
26//! ```
27//!
28//! Deliberately **not** implemented in this skeleton (real, cited scope
29//! for later slices): incremental DSV4 KV/compressor state
30//! (`llama-kv-cache-dsv4.cpp`), CSA's `coff=2` dual-role projection,
31//! hash-based first-layer MoE selection, and multi-layer stacking.
32
33use ferrox_core::attention::apply_rope_back;
34use ferrox_core::csa_hca_compress::compress_block;
35use ferrox_core::deepseek_v4_attention::hca_attention;
36use ferrox_core::matmul::rms_norm;
37use ferrox_core::tensor::Tensor;
38use ferrox_core::weight_matrix::WeightMatrix;
39use ferrox_moe::{combine_expert_outputs, route_top_k, run_expert, ExpertWeights, GatingFunction};
40
41use crate::hyper_connections::{
42    head as hc_head, post as hc_post, pre as hc_pre, HyperConnectionHeadWeights,
43    HyperConnectionPreWeights, HC_MULT,
44};
45use crate::output_projection::grouped_output_projection;
46
47/// One layer's attention-side weights (synthetic, tiny-dim fixtures only).
48pub struct DeepseekV4AttnWeights {
49    pub q_proj: WeightMatrix,
50    pub k_proj: WeightMatrix,
51    pub v_proj: WeightMatrix,
52    /// Per-group down-projections (`wo_a`), one per contiguous head block.
53    pub group_down: Vec<WeightMatrix>,
54    pub wo_b: WeightMatrix,
55    /// Block-compression gate (`attn_comp_wgate`) and norm for HCA pooling.
56    pub comp_gate: WeightMatrix,
57    pub comp_norm: Vec<f32>,
58}
59
60/// MoE FFN weights for one layer (`sqrtsoftplus` gating, no hash routing).
61pub struct DeepseekV4MoeFfnWeights {
62    pub router_weight: WeightMatrix,
63    pub experts: Vec<ExpertWeights>,
64    pub shared_expert: ExpertWeights,
65}
66
67pub struct DeepseekV4DecoderLayerWeights {
68    pub attn_hc_pre: HyperConnectionPreWeights,
69    pub attn_norm_weight: Vec<f32>,
70    pub attn: DeepseekV4AttnWeights,
71    pub ffn_hc_pre: HyperConnectionPreWeights,
72    pub ffn_norm_weight: Vec<f32>,
73    pub ffn: DeepseekV4MoeFfnWeights,
74}
75
76pub struct DeepseekV4DecoderWeights {
77    pub embedding: Tensor,
78    pub layer: DeepseekV4DecoderLayerWeights,
79    pub hc_head: HyperConnectionHeadWeights,
80    pub final_norm_weight: Vec<f32>,
81    pub output_head: WeightMatrix,
82}
83
84pub struct DeepseekV4DecoderConfig {
85    pub rms_norm_eps: f32,
86    pub hc_sinkhorn_iters: u32,
87    pub hc_eps: f32,
88    pub n_heads: usize,
89    pub qk_head_dim: usize,
90    pub v_head_dim: usize,
91    pub qk_rope: usize,
92    pub compress_rope_theta: f32,
93    pub n_experts_active: usize,
94    pub moe_renormalize: bool,
95    /// HCA block length for synthetic compression (real V4 uses 128).
96    pub hca_compress_ratio: usize,
97}
98
99/// Minimal per-layer state: raw K/V for the SWA window plus optional
100/// compressed entries. No incremental DSV4 cache yet — callers append
101/// one token at a time and optionally pool when `hca_compress_ratio`
102/// raw positions are available.
103pub struct DeepseekV4LayerState {
104    hc_streams: [Vec<f32>; HC_MULT],
105    raw_k: Vec<f32>,
106    raw_v: Vec<f32>,
107    compressed_k: Vec<f32>,
108    compressed_v: Vec<f32>,
109    token_count: usize,
110}
111
112impl DeepseekV4LayerState {
113    pub fn new(hidden_dim: usize) -> Self {
114        let zero = vec![0.0; hidden_dim];
115        Self {
116            hc_streams: std::array::from_fn(|_| zero.clone()),
117            raw_k: Vec::new(),
118            raw_v: Vec::new(),
119            compressed_k: Vec::new(),
120            compressed_v: Vec::new(),
121            token_count: 0,
122        }
123    }
124
125    fn reset_hc_from_hidden(&mut self, hidden: &[f32]) {
126        for stream in self.hc_streams.iter_mut() {
127            stream.copy_from_slice(hidden);
128        }
129    }
130}
131
132pub struct DeepseekV4DecodeState {
133    layer: DeepseekV4LayerState,
134}
135
136impl DeepseekV4DecodeState {
137    pub fn new(hidden_dim: usize) -> Self {
138        Self {
139            layer: DeepseekV4LayerState::new(hidden_dim),
140        }
141    }
142}
143
144fn derope_attn_out(
145    attn_out: &mut [f32],
146    n_heads: usize,
147    v_head_dim: usize,
148    qk_rope: usize,
149    pos: usize,
150    theta: f32,
151) {
152    assert!(qk_rope <= v_head_dim);
153    for h in 0..n_heads {
154        let head_start = h * v_head_dim;
155        let rope_start = head_start + v_head_dim - qk_rope;
156        apply_rope_back(
157            &mut attn_out[rope_start..head_start + v_head_dim],
158            pos,
159            theta,
160        );
161    }
162}
163
164fn attn_forward_token(
165    weights: &DeepseekV4AttnWeights,
166    cfg: &DeepseekV4DecoderConfig,
167    attn_in: &[f32],
168    state: &mut DeepseekV4LayerState,
169) -> Vec<f32> {
170    let q = weights.q_proj.apply(attn_in);
171    let k = weights.k_proj.apply(attn_in);
172    let v = weights.v_proj.apply(attn_in);
173    debug_assert_eq!(q.len(), cfg.n_heads * cfg.qk_head_dim);
174    debug_assert_eq!(k.len(), cfg.n_heads * cfg.qk_head_dim);
175    debug_assert_eq!(v.len(), cfg.n_heads * cfg.v_head_dim);
176
177    state.raw_k.extend_from_slice(&k);
178    state.raw_v.extend_from_slice(&v);
179    state.token_count += 1;
180    let n_raw = state.token_count;
181
182    let ratio = cfg.hca_compress_ratio;
183    if n_raw >= ratio && n_raw.is_multiple_of(ratio) {
184        let per_token_k = cfg.n_heads * cfg.qk_head_dim;
185        let per_token_v = cfg.n_heads * cfg.v_head_dim;
186        let block_start = (n_raw - ratio) * per_token_k;
187        let block_end = n_raw * per_token_k;
188        let kv_block: Vec<Vec<f32>> = state.raw_k[block_start..block_end]
189            .chunks(cfg.qk_head_dim)
190            .map(|row| row.to_vec())
191            .collect();
192        let score_block: Vec<Vec<f32>> = kv_block
193            .iter()
194            .map(|row| weights.comp_gate.apply(row))
195            .collect();
196        let compressed_k = compress_block(
197            &kv_block,
198            &score_block,
199            &weights.comp_norm,
200            cfg.rms_norm_eps,
201            cfg.qk_rope,
202            n_raw / ratio,
203            cfg.compress_rope_theta,
204        );
205        let v_block_start = (n_raw - ratio) * per_token_v;
206        let v_block_end = n_raw * per_token_v;
207        let v_block: Vec<Vec<f32>> = state.raw_v[v_block_start..v_block_end]
208            .chunks(cfg.v_head_dim)
209            .map(|row| row.to_vec())
210            .collect();
211        let compressed_v = compress_block(
212            &v_block,
213            &score_block,
214            &weights.comp_norm,
215            cfg.rms_norm_eps,
216            cfg.qk_rope,
217            n_raw / ratio,
218            cfg.compress_rope_theta,
219        );
220        state.compressed_k.extend_from_slice(&compressed_k);
221        state.compressed_v.extend_from_slice(&compressed_v);
222    }
223
224    let n_compressed = if cfg.qk_head_dim > 0 {
225        state.compressed_k.len() / (cfg.n_heads * cfg.qk_head_dim)
226    } else {
227        0
228    };
229    let mut attn_out = hca_attention(
230        &q,
231        &state.raw_k,
232        &state.raw_v,
233        n_raw,
234        &state.compressed_k,
235        &state.compressed_v,
236        n_compressed,
237        cfg.n_heads,
238        cfg.qk_head_dim,
239        cfg.v_head_dim,
240    );
241
242    derope_attn_out(
243        &mut attn_out,
244        cfg.n_heads,
245        cfg.v_head_dim,
246        cfg.qk_rope,
247        state.token_count.saturating_sub(1),
248        cfg.compress_rope_theta,
249    );
250
251    grouped_output_projection(&attn_out, &weights.group_down, &weights.wo_b)
252}
253
254fn moe_ffn_forward(
255    weights: &DeepseekV4MoeFfnWeights,
256    cfg: &DeepseekV4DecoderConfig,
257    x: &[f32],
258) -> Vec<f32> {
259    let router_logits = weights.router_weight.apply(x);
260    let decision = route_top_k(
261        &router_logits,
262        cfg.n_experts_active,
263        GatingFunction::SqrtSoftplus,
264        cfg.moe_renormalize,
265    );
266    let routed_outputs: Vec<(Vec<f32>, f32)> = decision
267        .expert_ids
268        .iter()
269        .zip(decision.weights.iter())
270        .map(|(&e, &w)| (run_expert(x, &weights.experts[e]), w))
271        .collect();
272    let shared_out = run_expert(x, &weights.shared_expert);
273    combine_expert_outputs(&routed_outputs, &[shared_out], x.len())
274}
275
276/// One decode step through the single synthetic layer, then final norm +
277/// output projection. `token_id` indexes the embedding table.
278pub fn deepseek_v4_forward_token(
279    weights: &DeepseekV4DecoderWeights,
280    cfg: &DeepseekV4DecoderConfig,
281    token_id: usize,
282    state: &mut DeepseekV4DecodeState,
283) -> Vec<f32> {
284    let hidden_dim = weights.embedding.cols();
285    let hidden = weights.embedding.row(token_id).to_vec();
286    state.layer.reset_hc_from_hidden(&hidden);
287    let layer = &weights.layer;
288
289    let hc_residual = state.layer.hc_streams.clone();
290    let (attn_in, attn_post, attn_comb) = hc_pre(
291        &layer.attn_hc_pre,
292        &hc_residual,
293        cfg.rms_norm_eps,
294        cfg.hc_sinkhorn_iters,
295        cfg.hc_eps,
296    );
297    let attn_normed = rms_norm(&attn_in, &layer.attn_norm_weight, cfg.rms_norm_eps);
298    let attn_out = attn_forward_token(&layer.attn, cfg, &attn_normed, &mut state.layer);
299    state.layer.hc_streams = hc_post(&attn_out, &hc_residual, &attn_post, &attn_comb);
300
301    let hc_residual = state.layer.hc_streams.clone();
302    let (ffn_in, ffn_post, ffn_comb) = hc_pre(
303        &layer.ffn_hc_pre,
304        &hc_residual,
305        cfg.rms_norm_eps,
306        cfg.hc_sinkhorn_iters,
307        cfg.hc_eps,
308    );
309    let ffn_normed = rms_norm(&ffn_in, &layer.ffn_norm_weight, cfg.rms_norm_eps);
310    let ffn_out = moe_ffn_forward(&layer.ffn, cfg, &ffn_normed);
311    state.layer.hc_streams = hc_post(&ffn_out, &hc_residual, &ffn_post, &ffn_comb);
312
313    let collapsed = hc_head(
314        &weights.hc_head,
315        &state.layer.hc_streams,
316        cfg.rms_norm_eps,
317        cfg.hc_eps,
318    );
319    let final_normed = rms_norm(&collapsed, &weights.final_norm_weight, cfg.rms_norm_eps);
320    assert_eq!(final_normed.len(), hidden_dim);
321    weights.output_head.apply(&final_normed)
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::hyper_connections::HyperConnectionPreWeights;
328
329    const HIDDEN_DIM: usize = 8;
330    const EPS: f32 = 1e-5;
331    const NUM_HEADS: usize = 1;
332    const QK_HEAD_DIM: usize = 4;
333    const V_HEAD_DIM: usize = 4;
334    const QK_ROPE: usize = 2;
335    const N_GROUPS: usize = 1;
336    const O_LORA_RANK: usize = 2;
337    const O_GROUP_DIM: usize = (NUM_HEADS * V_HEAD_DIM) / N_GROUPS;
338    const N_EXPERTS: usize = 4;
339    const N_EXPERTS_ACTIVE: usize = 2;
340    const MOE_FFN_DIM: usize = 3;
341    const OUTPUT_VOCAB: usize = 5;
342    const HC_FLAT: usize = HC_MULT * HIDDEN_DIM;
343
344    fn wm(data: Vec<f32>, rows: usize, cols: usize) -> WeightMatrix {
345        assert_eq!(data.len(), rows * cols);
346        WeightMatrix::F32(Tensor::new(data, vec![rows, cols]))
347    }
348
349    fn synth(seed: usize, n: usize) -> Vec<f32> {
350        (0..n)
351            .map(|i| (((seed * 131 + i * 17 + 7) % 23) as f32 * 0.05) - 0.55)
352            .collect()
353    }
354
355    fn make_hc_pre(seed: usize) -> HyperConnectionPreWeights {
356        HyperConnectionPreWeights {
357            fn_proj: wm(
358                synth(seed, (2 + HC_MULT) * HC_MULT * HC_FLAT),
359                (2 + HC_MULT) * HC_MULT,
360                HC_FLAT,
361            ),
362            scale: [0.5, 0.5, 0.5],
363            base_pre: [0.1; HC_MULT],
364            base_post: [0.2; HC_MULT],
365            base_comb: [0.01; HC_MULT * HC_MULT],
366        }
367    }
368
369    fn make_hc_head(seed: usize) -> HyperConnectionHeadWeights {
370        HyperConnectionHeadWeights {
371            fn_proj: wm(synth(seed, HC_MULT * HC_FLAT), HC_MULT, HC_FLAT),
372            scale: 0.5,
373            base: [0.1; HC_MULT],
374        }
375    }
376
377    fn make_weights() -> DeepseekV4DecoderWeights {
378        let expert = |seed: usize| ExpertWeights {
379            gate: wm(
380                synth(seed, MOE_FFN_DIM * HIDDEN_DIM),
381                MOE_FFN_DIM,
382                HIDDEN_DIM,
383            ),
384            up: wm(
385                synth(seed + 1, MOE_FFN_DIM * HIDDEN_DIM),
386                MOE_FFN_DIM,
387                HIDDEN_DIM,
388            ),
389            down: wm(
390                synth(seed + 2, HIDDEN_DIM * MOE_FFN_DIM),
391                HIDDEN_DIM,
392                MOE_FFN_DIM,
393            ),
394        };
395
396        DeepseekV4DecoderWeights {
397            embedding: Tensor::new(
398                synth(1000, OUTPUT_VOCAB * HIDDEN_DIM),
399                vec![OUTPUT_VOCAB, HIDDEN_DIM],
400            ),
401            layer: DeepseekV4DecoderLayerWeights {
402                attn_hc_pre: make_hc_pre(100),
403                attn_norm_weight: vec![1.0; HIDDEN_DIM],
404                attn: DeepseekV4AttnWeights {
405                    q_proj: wm(
406                        synth(110, NUM_HEADS * QK_HEAD_DIM * HIDDEN_DIM),
407                        NUM_HEADS * QK_HEAD_DIM,
408                        HIDDEN_DIM,
409                    ),
410                    k_proj: wm(
411                        synth(111, NUM_HEADS * QK_HEAD_DIM * HIDDEN_DIM),
412                        NUM_HEADS * QK_HEAD_DIM,
413                        HIDDEN_DIM,
414                    ),
415                    v_proj: wm(
416                        synth(112, NUM_HEADS * V_HEAD_DIM * HIDDEN_DIM),
417                        NUM_HEADS * V_HEAD_DIM,
418                        HIDDEN_DIM,
419                    ),
420                    group_down: (0..N_GROUPS)
421                        .map(|g| {
422                            wm(
423                                synth(120 + g, O_LORA_RANK * O_GROUP_DIM),
424                                O_LORA_RANK,
425                                O_GROUP_DIM,
426                            )
427                        })
428                        .collect(),
429                    wo_b: wm(
430                        synth(130, HIDDEN_DIM * O_LORA_RANK * N_GROUPS),
431                        HIDDEN_DIM,
432                        O_LORA_RANK * N_GROUPS,
433                    ),
434                    comp_gate: wm(
435                        synth(140, QK_HEAD_DIM * QK_HEAD_DIM),
436                        QK_HEAD_DIM,
437                        QK_HEAD_DIM,
438                    ),
439                    comp_norm: vec![1.0; QK_HEAD_DIM],
440                },
441                ffn_hc_pre: make_hc_pre(200),
442                ffn_norm_weight: vec![1.0; HIDDEN_DIM],
443                ffn: DeepseekV4MoeFfnWeights {
444                    router_weight: wm(synth(300, N_EXPERTS * HIDDEN_DIM), N_EXPERTS, HIDDEN_DIM),
445                    experts: (0..N_EXPERTS).map(|e| expert(400 + e * 10)).collect(),
446                    shared_expert: expert(900),
447                },
448            },
449            hc_head: make_hc_head(500),
450            final_norm_weight: vec![1.0; HIDDEN_DIM],
451            output_head: wm(
452                synth(1100, OUTPUT_VOCAB * HIDDEN_DIM),
453                OUTPUT_VOCAB,
454                HIDDEN_DIM,
455            ),
456        }
457    }
458
459    fn decoder_cfg() -> DeepseekV4DecoderConfig {
460        DeepseekV4DecoderConfig {
461            rms_norm_eps: EPS,
462            hc_sinkhorn_iters: 4,
463            hc_eps: 1e-6,
464            n_heads: NUM_HEADS,
465            qk_head_dim: QK_HEAD_DIM,
466            v_head_dim: V_HEAD_DIM,
467            qk_rope: QK_ROPE,
468            compress_rope_theta: 1_000_000.0,
469            n_experts_active: N_EXPERTS_ACTIVE,
470            moe_renormalize: true,
471            hca_compress_ratio: 2,
472        }
473    }
474
475    #[test]
476    fn one_layer_synthetic_forward_produces_finite_logits() {
477        let weights = make_weights();
478        let cfg = decoder_cfg();
479        let mut state = DeepseekV4DecodeState::new(HIDDEN_DIM);
480
481        for token_id in 0..3 {
482            let logits =
483                deepseek_v4_forward_token(&weights, &cfg, token_id % OUTPUT_VOCAB, &mut state);
484            assert_eq!(logits.len(), OUTPUT_VOCAB);
485            assert!(
486                logits.iter().all(|v| v.is_finite()),
487                "token {token_id}: logits must be finite, got {logits:?}"
488            );
489            assert!(
490                !logits.iter().any(|v| v.is_nan()),
491                "token {token_id}: logits must not contain NaN, got {logits:?}"
492            );
493        }
494    }
495}