avarok_core/kimi_k3/
mxfp4.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! K3 official MXFP4 names → DeepSeek-V4 E8M0 unpack.
4//!
5//! DSV4 native MXFP4: `{prefix}.weight` (E2M1 packed) + `{prefix}.scale` (E8M0).
6//! K3 official TSV: `{prefix}.weight_packed` + `{prefix}.weight_scale`.
7//! Math is [`crate::mxfp4_e8m0`]; this module only remaps names.
8
9use std::collections::HashMap;
10
11use anyhow::{Context, Result, bail, ensure};
12use safetensors::tensor::Dtype;
13
14use crate::mxfp4_e8m0::{dequant_nvfp4_e8m0_to_bf16, dequant_nvfp4_e8m0_to_f32};
15
16/// Official TSV classes that are packed routed-expert MXFP4.
17pub const PACKED_CLASSES: &[&str] = &[
18    "language_model.model.layers.*.block_sparse_moe.experts.*.w1.weight_packed",
19    "language_model.model.layers.*.block_sparse_moe.experts.*.w1.weight_scale",
20    "language_model.model.layers.*.block_sparse_moe.experts.*.w2.weight_packed",
21    "language_model.model.layers.*.block_sparse_moe.experts.*.w2.weight_scale",
22    "language_model.model.layers.*.block_sparse_moe.experts.*.w3.weight_packed",
23    "language_model.model.layers.*.block_sparse_moe.experts.*.w3.weight_scale",
24];
25
26/// Opt-in for CPU `from_pretrained` packed ingest. Default is refuse.
27pub fn allow_mxfp4() -> bool {
28    std::env::var("K3_ALLOW_MXFP4").as_deref() == Ok("1")
29}
30
31pub fn is_expert_packed(name: &str) -> bool {
32    name.contains("block_sparse_moe.experts.") && name.ends_with(".weight_packed")
33}
34
35pub fn is_expert_scale(name: &str) -> bool {
36    name.contains("block_sparse_moe.experts.") && name.ends_with(".weight_scale")
37}
38
39pub fn unpacked_name(packed_name: &str) -> Option<String> {
40    packed_name
41        .strip_suffix("weight_packed")
42        .map(|p| format!("{p}weight"))
43}
44
45pub fn scale_name(packed_name: &str) -> Option<String> {
46    packed_name
47        .strip_suffix("weight_packed")
48        .map(|p| format!("{p}weight_scale"))
49}
50
51/// Unpack a K3 expert pair through the DSV4 host function.
52pub fn unpack_expert(packed: &[u8], scale: &[u8], n: usize, k: usize) -> Result<Vec<f32>> {
53    dequant_nvfp4_e8m0_to_f32(packed, scale, n, k)
54}
55
56/// Same unpack as DSV4 GPU-upload host path (BF16 bits).
57pub fn unpack_expert_bf16(packed: &[u8], scale: &[u8], n: usize, k: usize) -> Result<Vec<u16>> {
58    dequant_nvfp4_e8m0_to_bf16(packed, scale, n, k)
59}
60
61#[derive(Default)]
62pub(super) struct PackedSink {
63    packed: HashMap<String, (Vec<usize>, Vec<u8>)>,
64    scales: HashMap<String, (Vec<usize>, Vec<u8>)>,
65}
66
67impl PackedSink {
68    pub(super) fn take(
69        &mut self,
70        name: &str,
71        dtype: Dtype,
72        shape: &[usize],
73        data: &[u8],
74    ) -> Result<bool> {
75        if is_expert_packed(name) {
76            if !allow_mxfp4() {
77                bail!("S5 MXFP4 not this slice ({name})");
78            }
79            ensure!(
80                dtype == Dtype::U8,
81                "{name}: packed MXFP4 wants U8, got {dtype:?}"
82            );
83            self.packed
84                .insert(name.to_string(), (shape.to_vec(), data.to_vec()));
85            return Ok(true);
86        }
87        if is_expert_scale(name) {
88            if !allow_mxfp4() {
89                bail!("S5 MXFP4 not this slice ({name})");
90            }
91            ensure!(
92                dtype == Dtype::U8,
93                "{name}: E8M0 weight_scale wants U8, got {dtype:?}"
94            );
95            self.scales
96                .insert(name.to_string(), (shape.to_vec(), data.to_vec()));
97            return Ok(true);
98        }
99        Ok(false)
100    }
101
102    pub(super) fn finish(self, out: &mut HashMap<String, Vec<f32>>) -> Result<()> {
103        for (name, (shape, data)) in &self.packed {
104            let sname = scale_name(name).expect("weight_packed suffix");
105            let (_sshape, sdata) = self
106                .scales
107                .get(&sname)
108                .with_context(|| format!("{name}: missing DSV4-mapped E8M0 {sname}"))?;
109            let (n, k) = packed_nk(shape)?;
110            let vals = unpack_expert(data, sdata, n, k)?;
111            let dest = unpacked_name(name).expect("weight_packed suffix");
112            if out.insert(dest.clone(), vals).is_some() {
113                bail!("duplicate unpacked {dest}");
114            }
115        }
116        Ok(())
117    }
118}
119
120fn packed_nk(shape: &[usize]) -> Result<(usize, usize)> {
121    match *shape {
122        [n, k2] => Ok((n, k2.checked_mul(2).expect("k packed"))),
123        [len] => Ok((1, len.checked_mul(2).expect("k packed 1d"))),
124        _ => bail!("MXFP4 packed shape {shape:?}: want [n, k/2] or [k/2]"),
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::kimi_k3::cpu_weights::K3CpuModel;
132    use crate::numeric::f32_to_bf16;
133    use safetensors::tensor::{TensorView, serialize};
134    use std::path::PathBuf;
135    use std::process::Command;
136
137    const TINY_CFG: &str = r#"{
138      "model_type": "kimi_k3",
139      "text_config": {
140        "activation_situ_beta": 4.0,
141        "activation_situ_linear_beta": 25.0,
142        "attn_res_block_size": 2,
143        "first_k_dense_replace": 1,
144        "head_dim": 2,
145        "hidden_act": "situ",
146        "hidden_size": 4,
147        "intermediate_size": 4,
148        "kv_lora_rank": 2,
149        "latent_moe_use_norm": true,
150        "linear_attn_config": {
151          "full_attn_layers": [2],
152          "head_dim": 2,
153          "kda_layers": [1],
154          "num_heads": 1,
155          "short_conv_kernel_size": 4,
156          "use_full_rank_gate": true
157        },
158        "mla_use_nope": true,
159        "mla_use_output_gate": true,
160        "model_type": "kimi_linear",
161        "moe_intermediate_size": 4,
162        "moe_renormalize": true,
163        "moe_router_activation_func": "sigmoid",
164        "num_attention_heads": 1,
165        "num_experts": 2,
166        "num_experts_per_token": 1,
167        "num_hidden_layers": 2,
168        "num_shared_experts": 1,
169        "q_lora_rank": 4,
170        "qk_nope_head_dim": 2,
171        "qk_rope_head_dim": 2,
172        "rms_norm_eps": 1e-5,
173        "rope_theta": 10000.0,
174        "routed_expert_hidden_size": 4,
175        "topk_method": "noaux_tc",
176        "v_head_dim": 2,
177        "vocab_size": 8
178      }
179    }"#;
180
181    fn pack_nibble(n: usize, k: usize, nib: u8) -> (Vec<u8>, Vec<usize>) {
182        let total = n * k;
183        let mut packed = vec![0u8; total / 2];
184        for i in 0..total {
185            if i.is_multiple_of(2) {
186                packed[i / 2] |= nib & 0x0F;
187            } else {
188                packed[i / 2] |= (nib & 0x0F) << 4;
189            }
190        }
191        (packed, vec![n, k / 2])
192    }
193
194    #[test]
195    fn packed_classes_match_official_tsv() {
196        let tsv = include_str!("../../../../docs/k3/official-weight-classes.tsv");
197        let mut from_tsv: Vec<&str> = tsv
198            .lines()
199            .filter_map(|line| {
200                let class = line.split('\t').next_back()?;
201                (class.contains("experts.")
202                    && (class.ends_with("weight_packed") || class.ends_with("weight_scale")))
203                .then_some(class)
204            })
205            .collect();
206        from_tsv.sort_unstable();
207        let mut ours = PACKED_CLASSES.to_vec();
208        ours.sort_unstable();
209        assert_eq!(ours, from_tsv);
210    }
211
212    #[test]
213    fn k3_names_map_to_dsv4_weight_and_scale() {
214        let packed = "language_model.model.layers.12.block_sparse_moe.experts.7.w1.weight_packed";
215        assert_eq!(
216            unpacked_name(packed).as_deref(),
217            Some("language_model.model.layers.12.block_sparse_moe.experts.7.w1.weight")
218        );
219        assert_eq!(
220            scale_name(packed).as_deref(),
221            Some("language_model.model.layers.12.block_sparse_moe.experts.7.w1.weight_scale")
222        );
223        // DSV4 pair is `.weight` + `.scale`; K3 uses `_packed` / `_scale`.
224        assert_ne!(
225            scale_name(packed).unwrap().as_str().rsplit('.').next(),
226            Some("scale")
227        );
228    }
229
230    #[test]
231    fn synthetic_packed_round_trips_dsv4_e8m0() {
232        // n=1, k=32 (DSV4 GROUP_SIZE). nibble 2 = E2M1 1.0; scale 127 = 2^0.
233        let n = 1;
234        let k = crate::mxfp4_e8m0::GROUP_SIZE;
235        let (packed, _) = pack_nibble(n, k, 2);
236        let scales = vec![127u8];
237        let got = unpack_expert(&packed, &scales, n, k).unwrap();
238        assert_eq!(got, vec![1.0; k]);
239        let bf = unpack_expert_bf16(&packed, &scales, n, k).unwrap();
240        let want: Vec<u16> = vec![1.0; k].into_iter().map(f32_to_bf16).collect();
241        assert_eq!(bf, want);
242    }
243
244    #[test]
245    fn flipped_e8m0_scale_diverges_from_bf16_reference() {
246        let n = 1;
247        let k = crate::mxfp4_e8m0::GROUP_SIZE;
248        let (packed, _) = pack_nibble(n, k, 6); // E2M1 4.0
249        let good_scale = vec![127u8];
250        let mut bad_scale = good_scale.clone();
251        bad_scale[0] ^= 0x01; // 127 → 126: 4.0 vs 2.0
252        let good = unpack_expert_bf16(&packed, &good_scale, n, k).unwrap();
253        let bad = unpack_expert_bf16(&packed, &bad_scale, n, k).unwrap();
254        let ref_bf: Vec<u16> = unpack_expert(&packed, &good_scale, n, k)
255            .unwrap()
256            .into_iter()
257            .map(f32_to_bf16)
258            .collect();
259        assert_eq!(good, ref_bf, "good unpack is the BF16 reference");
260        assert_ne!(bad, ref_bf, "flipped E8M0 scale must diverge");
261    }
262
263    #[test]
264    fn from_pretrained_allow_mxfp4_skips_s5_bail() {
265        const THIS: &str = "kimi_k3::mxfp4::tests::from_pretrained_allow_mxfp4_skips_s5_bail";
266        const MARKER: &str = "K3_MXFP4_ALLOW_CHILD";
267        if std::env::var_os(MARKER).is_some() {
268            let dir = scratch_packed_only();
269            let err = K3CpuModel::from_pretrained(&dir).unwrap_err().to_string();
270            let _ = std::fs::remove_dir_all(&dir);
271            assert!(
272                !err.contains("S5 MXFP4 not this slice"),
273                "K3_ALLOW_MXFP4=1 must not use the S5 refuse: {err}"
274            );
275            return;
276        }
277        let output = Command::new(std::env::current_exe().unwrap())
278            .args(["--exact", THIS])
279            .env(MARKER, "1")
280            .env("K3_ALLOW_MXFP4", "1")
281            .output()
282            .unwrap();
283        assert!(
284            output.status.success(),
285            "ALLOW child failed:\n{}",
286            String::from_utf8_lossy(&output.stdout)
287        );
288    }
289
290    fn scratch_packed_only() -> PathBuf {
291        let nanos = std::time::SystemTime::now()
292            .duration_since(std::time::UNIX_EPOCH)
293            .unwrap()
294            .as_nanos();
295        let dir =
296            std::env::temp_dir().join(format!("k3-mxfp4-allow-{}-{nanos}", std::process::id()));
297        std::fs::create_dir_all(&dir).unwrap();
298        std::fs::write(dir.join("config.json"), TINY_CFG).unwrap();
299        let (packed, _) = pack_nibble(1, 32, 2);
300        let scale = vec![127u8];
301        let pv = TensorView::new(Dtype::U8, vec![1, 16], &packed).unwrap();
302        let sv = TensorView::new(Dtype::U8, vec![1], &scale).unwrap();
303        let bytes = serialize(
304            [
305                (
306                    "language_model.model.layers.1.block_sparse_moe.experts.0.w1.weight_packed"
307                        .to_string(),
308                    pv,
309                ),
310                (
311                    "language_model.model.layers.1.block_sparse_moe.experts.0.w1.weight_scale"
312                        .to_string(),
313                    sv,
314                ),
315            ],
316            None,
317        )
318        .unwrap();
319        std::fs::write(dir.join("model.safetensors"), bytes).unwrap();
320        dir
321    }
322}