Skip to main content

frink_models/
mtp_blocks.rs

1//! **NextN / MTP blocks are inside `block_count`, and are not layers.**
2//!
3//! A multi-token-prediction head is exported as one or more EXTRA
4//! decoder blocks appended after the trunk -- each with its own
5//! `attn_norm`, QKV, `wo`, `ffn_norm`, experts, PLUS the `nextn.*`
6//! tensors that make it a speculative head (`eh_proj`, `enorm`, `hnorm`,
7//! `shared_head_*`, `embed_tokens`). The converters count them in
8//! `block_count` (`conversion/mimo.py:22`, `step3.py:117-119`,
9//! `exaone.py:132,224`, `glm.py:99,116`, `deepseek.py:457,525`) and
10//! write `{arch}.nextn_predict_layers` beside it.
11//!
12//! llama.cpp never runs them as layers:
13//!
14//! - `llama-model.cpp:1092` reads `block_count` into `n_layer_all`;
15//! - `llama-hparams.cpp:280-282` defines `n_layer()` as
16//!   `n_layer_all - n_layer_nextn`;
17//! - `llama-graph.cpp:1433` builds every graph with `n_layer =
18//!   hparams.n_layer()`, so `mimo2.cpp:108`, `step35.cpp:205`,
19//!   `glm4-moe.cpp:161`, `deepseek2.cpp:470` loop over the trunk only;
20//! - each tensor loader loops `0..n_layer_all` and creates the blocks
21//!   at `i >= n_layer` with `TENSOR_SKIP` (`exaone4.cpp:43-49`,
22//!   `mimo2.cpp:35-37,51-52`) unless the context was opened as an MTP
23//!   draft (`load_mtp`), so the bytes are neither read nor missed.
24//!
25//! **UPSTREAM CHANGED THIS, and the change is recorded before it is
26//! followed.** When the pin moved to `5b59b83` on 2026-09-19,
27//! `llama-model.cpp:1261` reads `LLM_KV_NEXTN_PREDICT_LAYERS` in the
28//! COMMON loader, for every architecture, right after `block_count`
29//! and with a `GGML_ASSERT(n_layer_nextn <= n_layer_all)` beside it
30//! (upstream commit 9d81721, "load hparams.n_layer_nextn before
31//! n_layer() calls"). So upstream now subtracts the trailing blocks
32//! whatever the architecture, where it used to subtract only in the
33//! per-architecture loaders, and `grep -l
34//! LLM_KV_NEXTN_PREDICT_LAYERS src/models/*.cpp` is down to six files
35//! that read it a second time for their own reasons.
36//!
37//! frink still refuses a nonzero key on an architecture outside
38//! [`NEXTN_READERS`], which is now an OVER-refusal rather than a
39//! divergence in the dangerous direction: a file upstream would run
40//! by skipping its MTP blocks stops here instead of running them as
41//! decoder layers. Lifting it is a row of its own -- it needs a
42//! libllama golden built from the moved pin, on an architecture that
43//! is NOT one of the seventeen -- and until that exists the table
44//! below is what has evidence.
45//!
46//! **Only the graphs that READ the key subtract** (the measurement as
47//! it stood at the 2026-08-04 pin, and the one the table is built
48//! from): `grep -l
49//! LLM_KV_NEXTN_PREDICT_LAYERS src/models/*.cpp` was the seventeen in
50//! [`NEXTN_READERS`]. For any other architecture `n_layer_nextn` stays
51//! 0, every block runs, and a file carrying `nextn.*` tensors fails
52//! llama.cpp's own "not all tensors loaded" check -- so for those a
53//! nonzero key is refused here rather than honoured. Until 2026-09-11
54//! frink refused it for EVERY architecture (`capability::
55//! unsupported_feature_keys`), which was safe and over-broad: every
56//! real MiMo-V2 (`mimo.py:167`, three blocks), Step-3.5 (`step3.py:223`,
57//! three), K-EXAONE (`exaone.py:146`, one), GLM-4.5/4.6 (`glm.py:106`,
58//! one) and DeepSeek-V3 (`deepseek.py:498`, one) export carries the key
59//! with a nonzero value.
60//!
61//! **The order of the two reads matters, and is copied.** `exaone4.cpp:4`
62//! tests `n_layer() == 64` BEFORE `:18` reads the key, and
63//! `mimo2.cpp:12` / `step35.cpp:26` size the sliding-window array by
64//! `n_layer()` before `:19` / `:32` read it, so both see `n_layer_all`.
65//! The loader therefore feeds `block_count` -- [`TrunkLayers::
66//! block_count`], not [`TrunkLayers::n_layers`] -- to
67//! `capability::swa_disabled_by_arch` and to every per-layer array
68//! length check, and only the trunk to the layer loop. A hypothetical
69//! EXAONE-4.5 with 64 trunk layers and one MTP block gets NO window in
70//! llama.cpp, and gets none here.
71//!
72//! This module decides the trunk once ([`trunk_layers`]) and marks the
73//! skipped blocks' tensors as deliberately unread
74//! ([`note_mtp_blocks_skipped`]) so that `loader::
75//! assert_every_tensor_consumed` -- which exists precisely to catch a
76//! tensor nobody read -- can tell "skipped on purpose, as llama.cpp
77//! does" from "missing from the graph". The four dedicated engines
78//! (`glm52_gguf_loader`, `mla_gguf_loader`, `hybrid_gguf_loader`,
79//! `gemma4_gguf_loader`) take their layer count from the same function:
80//! three of the four own architectures in [`NEXTN_READERS`] and read
81//! `block_count` verbatim before this, so a real GLM-4.5 or DeepSeek-V3
82//! file would have run its MTP block as one more decoder layer with the
83//! `nextn.*` tensors silently unread.
84
85use frink_gguf::{ShardedGguf, TensorSource};
86
87use crate::loader::LoadError;
88
89/// Every graph whose `load_arch_hparams` reads
90/// `LLM_KV_NEXTN_PREDICT_LAYERS`, with the file. Measured over all 155
91/// `src/models/*.cpp`; `llama-arch.cpp` and `llama-model-saver.cpp` are
92/// the only other hits and neither is a graph.
93pub const NEXTN_READERS: &[(&str, &str)] = &[
94    ("bailingmoe2", "src/models/bailingmoe2.cpp"),
95    ("cohere2moe", "src/models/cohere2moe.cpp"),
96    ("deepseek2", "src/models/deepseek2.cpp"),
97    ("deepseek32", "src/models/deepseek32.cpp"),
98    ("deepseek4", "src/models/deepseek4.cpp"),
99    ("exaone-moe", "src/models/exaone-moe.cpp:23"),
100    ("exaone4", "src/models/exaone4.cpp:18"),
101    ("gemma4-assistant", "src/models/gemma4-assistant.cpp"),
102    ("glm-dsa", "src/models/glm-dsa.cpp"),
103    ("glm4moe", "src/models/glm4-moe.cpp"),
104    ("glm4", "src/models/glm4.cpp"),
105    ("hy-v3", "src/models/hy-v3.cpp"),
106    ("mimo2", "src/models/mimo2.cpp:19"),
107    ("qwen35", "src/models/qwen35.cpp"),
108    ("qwen35moe", "src/models/qwen35moe.cpp"),
109    ("qwen3next", "src/models/qwen3next.cpp"),
110    ("step35", "src/models/step35.cpp:32"),
111];
112
113/// Does `arch`'s graph subtract `nextn_predict_layers` from its layer
114/// count?
115pub fn reads_nextn(arch: &str) -> bool {
116    NEXTN_READERS.iter().any(|(a, _)| *a == arch)
117}
118
119/// How many of a file's blocks are decoder layers.
120///
121/// `block_count` is llama.cpp's `n_layer_all`; `n_layers` its
122/// `n_layer()`; `n_mtp_blocks` its `n_layer_nextn`. The three are
123/// carried together because two of them are what every array-length
124/// check and every layer loop must NOT be handed interchangeably.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub struct TrunkLayers {
127    /// `{arch}.block_count`, every block in the file.
128    pub block_count: usize,
129    /// The blocks that are decoder layers: `block_count - n_mtp_blocks`.
130    pub n_layers: usize,
131    /// `{arch}.nextn_predict_layers` where the architecture reads it,
132    /// else 0.
133    pub n_mtp_blocks: usize,
134}
135
136impl TrunkLayers {
137    /// The whole file is trunk.
138    pub fn all(block_count: usize) -> Self {
139        Self {
140            block_count,
141            n_layers: block_count,
142            n_mtp_blocks: 0,
143        }
144    }
145}
146
147/// Decides the trunk for `arch` from `{arch}.nextn_predict_layers`.
148///
149/// Refuses a nonzero count on an architecture whose graph does not
150/// read the key (module doc), and a count that is not below
151/// `block_count` (`mimo2.cpp:20`, `step35.cpp:33`, `exaone4.cpp:20`:
152/// `GGML_ASSERT(n_layer_nextn < n_layer_all)`).
153pub fn trunk_layers(
154    file: &impl TensorSource,
155    arch: &str,
156    block_count: usize,
157) -> Result<TrunkLayers, LoadError> {
158    let key = format!("{arch}.nextn_predict_layers");
159    let n_mtp_blocks = match file.metadata(&key) {
160        None => 0,
161        Some(v) => v.as_u64().ok_or_else(|| {
162            LoadError::UnsupportedFeature(
163                arch.to_string(),
164                format!("{key} is not an unsigned integer: {v:?}"),
165            )
166        })? as usize,
167    };
168    if n_mtp_blocks == 0 {
169        return Ok(TrunkLayers::all(block_count));
170    }
171    if !reads_nextn(arch) {
172        return Err(LoadError::UnsupportedFeature(
173            arch.to_string(),
174            format!(
175                "NextN/MTP prediction layers are counted in block_count and llama.cpp skips \
176                 them (n_layer = n_layer_all - n_layer_nextn) only for the graphs that read \
177                 the key; `{arch}` is not one of them (metadata {key}={n_mtp_blocks}), so \
178                 upstream would run every block and fail on the unread `nextn.*` tensors, \
179                 and the generic decoder would run the speculative head as ordinary \
180                 decoder layers"
181            ),
182        ));
183    }
184    if n_mtp_blocks >= block_count {
185        return Err(LoadError::UnsupportedFeature(
186            arch.to_string(),
187            format!(
188                "{key}={n_mtp_blocks} is not below block_count={block_count}; llama.cpp \
189                 asserts `n_layer_nextn < n_layer_all` and aborts on this file"
190            ),
191        ));
192    }
193    Ok(TrunkLayers {
194        block_count,
195        n_layers: block_count - n_mtp_blocks,
196        n_mtp_blocks,
197    })
198}
199
200/// Is `name` a tensor of one of the skipped blocks -- `blk.N.*` with
201/// `n_layers <= N < block_count`?
202///
203/// Exactly that range: a `blk.N` at or past `block_count` is not a
204/// block llama.cpp would have created either, and stays unread for the
205/// consumption gate to report.
206pub fn is_mtp_block_tensor(name: &str, trunk: &TrunkLayers) -> bool {
207    let Some(rest) = name.strip_prefix("blk.") else {
208        return false;
209    };
210    let Some((idx, _)) = rest.split_once('.') else {
211        return false;
212    };
213    idx.parse::<usize>()
214        .is_ok_and(|n| n >= trunk.n_layers && n < trunk.block_count)
215}
216
217/// Marks every tensor of the skipped blocks as deliberately unread, as
218/// llama.cpp's `TENSOR_SKIP` does, and returns how many it marked.
219pub fn note_mtp_blocks_skipped(file: &ShardedGguf, trunk: &TrunkLayers) -> usize {
220    if trunk.n_mtp_blocks == 0 {
221        return 0;
222    }
223    let mut marked = 0;
224    for (_, info) in file.tensors() {
225        if is_mtp_block_tensor(&info.name, trunk) {
226            file.note_consumed(&info.name);
227            marked += 1;
228        }
229    }
230    marked
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use frink_gguf::GgufValue;
237    use std::sync::Arc;
238
239    struct Meta(Vec<(String, GgufValue)>);
240    impl TensorSource for Meta {
241        fn metadata(&self, key: &str) -> Option<&GgufValue> {
242            self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v)
243        }
244        fn find_tensor(&self, _: &str) -> Option<&frink_gguf::TensorInfo> {
245            None
246        }
247        fn tensor_bytes(&self, name: &str) -> Result<&[u8], frink_gguf::GgufError> {
248            Err(frink_gguf::GgufError::TensorNotFound(name.to_string()))
249        }
250        fn tensor_mapped_range(
251            &self,
252            name: &str,
253        ) -> Result<(Arc<frink_gguf::MmapHandle>, std::ops::Range<usize>), frink_gguf::GgufError>
254        {
255            Err(frink_gguf::GgufError::TensorNotFound(name.to_string()))
256        }
257    }
258
259    fn nextn(arch: &str, n: Option<u32>) -> Meta {
260        Meta(
261            n.into_iter()
262                .map(|n| (format!("{arch}.nextn_predict_layers"), GgufValue::U32(n)))
263                .collect(),
264        )
265    }
266
267    /// A reader subtracts; the key absent or zero is the whole file.
268    #[test]
269    fn a_reader_subtracts_the_blocks_from_its_layer_count() {
270        assert_eq!(
271            trunk_layers(&nextn("exaone-moe", Some(1)), "exaone-moe", 5).unwrap(),
272            TrunkLayers {
273                block_count: 5,
274                n_layers: 4,
275                n_mtp_blocks: 1
276            }
277        );
278        assert_eq!(
279            trunk_layers(&nextn("mimo2", Some(3)), "mimo2", 51).unwrap(),
280            TrunkLayers {
281                block_count: 51,
282                n_layers: 48,
283                n_mtp_blocks: 3
284            }
285        );
286        for file in [nextn("exaone-moe", None), nextn("exaone-moe", Some(0))] {
287            assert_eq!(
288                trunk_layers(&file, "exaone-moe", 4).unwrap(),
289                TrunkLayers::all(4)
290            );
291        }
292    }
293
294    /// The key on a graph that never reads it: refused, with the
295    /// reason, and the value in it. `grok` is the row the old
296    /// `unsupported_feature_keys` test used, kept as the example.
297    #[test]
298    fn a_non_reader_with_a_nonzero_count_is_refused_and_zero_is_not() {
299        match trunk_layers(&nextn("grok", Some(1)), "grok", 4) {
300            Err(LoadError::UnsupportedFeature(arch, msg)) => {
301                assert_eq!(arch, "grok");
302                assert!(msg.contains("NextN/MTP"), "{msg}");
303                assert!(msg.contains("grok.nextn_predict_layers=1"), "{msg}");
304            }
305            other => panic!("expected a refusal, got {other:?}"),
306        }
307        assert_eq!(
308            trunk_layers(&nextn("grok", Some(0)), "grok", 4).unwrap(),
309            TrunkLayers::all(4)
310        );
311    }
312
313    /// llama.cpp's own assert, as a refusal rather than an abort.
314    #[test]
315    fn a_count_not_below_block_count_is_refused() {
316        for n in [4, 5] {
317            assert!(matches!(
318                trunk_layers(&nextn("mimo2", Some(n)), "mimo2", 4),
319                Err(LoadError::UnsupportedFeature(a, m)) if a == "mimo2" && m.contains("n_layer_nextn < n_layer_all")
320            ));
321        }
322    }
323
324    /// The census is what the predicate answers, and the three
325    /// architectures this task was aimed at are in it.
326    #[test]
327    fn the_reader_census_is_the_predicate() {
328        for (arch, _) in NEXTN_READERS {
329            assert!(reads_nextn(arch), "{arch}");
330        }
331        for arch in [
332            "mimo2",
333            "step35",
334            "exaone4",
335            "exaone-moe",
336            "glm4moe",
337            "deepseek2",
338        ] {
339            assert!(reads_nextn(arch), "{arch}");
340        }
341        for arch in ["llama", "grok", "gemma4", "qwen3moe", "mistral4"] {
342            assert!(!reads_nextn(arch), "{arch}");
343        }
344        assert_eq!(NEXTN_READERS.len(), 17, "measured on 2026-09-11");
345    }
346
347    /// Exactly the skipped range, and only `blk.` names.
348    #[test]
349    fn only_tensors_of_the_skipped_blocks_are_mtp_tensors() {
350        let trunk = TrunkLayers {
351            block_count: 6,
352            n_layers: 4,
353            n_mtp_blocks: 2,
354        };
355        assert!(!is_mtp_block_tensor("blk.3.attn_norm.weight", &trunk));
356        assert!(is_mtp_block_tensor("blk.4.attn_norm.weight", &trunk));
357        assert!(is_mtp_block_tensor("blk.4.nextn.eh_proj.weight", &trunk));
358        assert!(is_mtp_block_tensor("blk.5.ffn_down_exps.weight", &trunk));
359        // Past block_count is not a block llama.cpp would create either.
360        assert!(!is_mtp_block_tensor("blk.6.attn_norm.weight", &trunk));
361        assert!(!is_mtp_block_tensor("output.weight", &trunk));
362        assert!(!is_mtp_block_tensor("blk.x.attn_norm.weight", &trunk));
363        assert!(!is_mtp_block_tensor("blk.4", &trunk));
364    }
365}