Skip to main content

frink_models/
layer_loops.rs

1//! **THE SAME PHYSICAL LAYERS RUN MORE THAN ONCE** -- Nanbeige's
2//! `num_loops`, as one value a `ModelConfig` carries and one rule for
3//! which logical layer runs which weights and where the loop norm sits.
4//!
5//! # What it is
6//!
7//! `src/models/nanbeige.cpp:6-12` read `{arch}.num_loops` (default 1)
8//! and `{arch}.skip_loop_final_norm` (default false). With `n_loops >
9//! 1`, `:19-31` set `n_layer_all = n_layer_phys * n_loops` and COPY the
10//! per-layer head / kv-head / ff / swa arrays from physical layer `i`
11//! to every logical slot `i + j * n_phys`; `:47-66` create tensors for
12//! the `n_phys` physical layers only and `:69-73` alias
13//! `layers[i + j * n_phys] = layers[i]`. So the graph (`:94-176`) walks
14//! `n_layer_all` logical layers, each with its OWN KV cache and its own
15//! row in every per-layer table, over `n_phys` sets of weights. After
16//! the last logical layer of every pass but the final one (`:167-175`:
17//! `(il + 1) % n_phys == 0 && (il + 1) < n_layer`), the running
18//! residual is normed with `output_norm` -- the lm_head's norm, the
19//! same tensor -- unless `skip_loop_final_norm`. Everything inside a
20//! pass is plain Llama.
21//!
22//! # Reach -- MEASURED
23//!
24//! `grep -l 'n_loops\|n_layer_phys' src/models/*.cpp` over all 155
25//! graphs (2026-09-12) is `nanbeige.cpp`; `LLM_KV_NUM_LOOPS` and
26//! `LLM_KV_SKIP_LOOP_FINAL_NORM` are read nowhere else. So
27//! [`LOOP_READERS`] has one row and the keys are dead metadata on every
28//! other architecture, as upstream (the `yarn_log_multiplier` rule,
29//! `crate::yarn_magnitude`).
30//!
31//! # What frink does with it
32//!
33//! The weights are shared and the KV is not, and the seam says exactly
34//! that rather than copying weights: `Decoder::layers` stays the
35//! PHYSICAL vector the loader filled, `ModelConfig::n_layers` is the
36//! LOGICAL count (so every KV cache, per-layer table and budget is
37//! sized per logical layer, as `n_layer_all` sizes them upstream), and
38//! `Decoder::layer_for(l)` is the ONE mapping from a logical index to
39//! its weights, `l % n_phys`. The three host bodies iterate logical
40//! indices and ask it; `LayerLoops::loop_norm_after(l)` says where the
41//! loop norm goes and `Decoder::final_norm` is the tensor that goes
42//! there. The per-layer shape arrays are replicated at load, as
43//! `:24-26` replicate them.
44//!
45//! Every fused Metal launch indexes `layers[l]` and `metal_kvs[l]` with
46//! one `l`, so `metal_can_serve_model` refuses a looped model; the host
47//! bodies serve it. A LoRA adapter attaches to physical layers by
48//! tensor name and so reaches every logical layer that shares them,
49//! which is what `build_lora_mm` on an aliased `layers[il]` does too.
50
51use crate::LoadError;
52use frink_gguf::TensorSource;
53
54/// Architectures whose `load_arch_hparams` reads `num_loops`, with the
55/// line.
56pub const LOOP_READERS: &[(&str, &str)] = &[
57    ("nanbeige", "src/models/nanbeige.cpp:6-31,167-175"),
58    ("hrm_text", "src/models/hrm-text.cpp:10-23,47-87,183-196"),
59];
60
61/// A model whose logical layers are `n_loops` passes over `n_phys`
62/// physical ones. Only ever constructed with `n_loops >= 2`: a file
63/// declaring 1 (or nothing) is a plain model and carries `None`.
64/// The norm a pass boundary applies to the residual, if any.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum LoopNorm {
67    /// `output_norm`, the model's own weighted final norm
68    /// (`nanbeige.cpp:167-175`).
69    Output,
70    /// A WEIGHTLESS RMS: `hrm-text.cpp:162` closes every stack with
71    /// `build_norm(cur, nullptr, nullptr, LLM_NORM_RMS, ...)`, and the
72    /// architecture has no `output_norm` tensor at all -- the last
73    /// stack's norm IS the final one.
74    Weightless,
75}
76
77/// Which of HRM-Text's two residual streams a finished stack updates.
78///
79/// `hrm-text.cpp:183-196`: each H cycle runs `l_cycles` LOW stacks and
80/// then one HIGH stack, every stack reading `zH + zL` and replacing one
81/// of the two with its output.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum HrmStream {
84    /// A low-cycle pass: its output becomes `zL`.
85    Low,
86    /// The last pass of an H cycle: its output becomes `zH`.
87    High,
88}
89
90/// A model whose logical layers are several passes over fewer physical
91/// ones.
92///
93/// Two shapes, because two graphs upstream do it and they differ in
94/// every detail but that: which physical layer a pass runs, what norm
95/// sits at a pass boundary, and whether the passes share ONE residual
96/// stream (`nanbeige`) or recombine TWO (`hrm_text`). Parameterising
97/// one enum rather than adding a second field for each difference is
98/// what keeps the three host bodies asking one question.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum LayerLoops {
101    /// `nanbeige.cpp:6-31`: `n_loops` passes over `n_phys` layers, one
102    /// residual stream, `output_norm` between passes.
103    Repeat {
104        /// Physical layers: the blocks the file holds tensors for.
105        n_phys: usize,
106        /// Passes over them; `>= 2`.
107        n_loops: usize,
108        /// `{arch}.skip_loop_final_norm`: no norm between passes.
109        skip_loop_final_norm: bool,
110    },
111    /// `hrm-text.cpp:47-87,183-196`: TWO stacks of `lps` layers each --
112    /// the file holds `2 * lps` blocks -- replayed over
113    /// `h_cycles * (l_cycles + 1)` passes, with a weightless norm after
114    /// every pass and the two residual streams recombined at every
115    /// stack boundary.
116    Hrm {
117        /// `{arch}.hrm.layers_per_stack`.
118        lps: usize,
119        /// `{arch}.hrm.h_cycles`.
120        h_cycles: usize,
121        /// `{arch}.hrm.l_cycles`.
122        l_cycles: usize,
123    },
124}
125
126impl LayerLoops {
127    /// llama.cpp's `n_layer_all`, the count every per-layer thing --
128    /// the KV caches above all -- is sized by.
129    pub fn logical_layers(&self) -> usize {
130        match *self {
131            Self::Repeat {
132                n_phys, n_loops, ..
133            } => n_phys * n_loops,
134            // `hrm-text.cpp:22-23` asserts exactly this against
135            // `block_count`.
136            Self::Hrm {
137                lps,
138                h_cycles,
139                l_cycles,
140            } => lps * h_cycles * (l_cycles + 1),
141        }
142    }
143
144    /// The physical layer logical layer `l` runs.
145    pub fn physical(&self, l: usize) -> usize {
146        match *self {
147            Self::Repeat { n_phys, .. } => l % n_phys,
148            // `hrm-text.cpp:57-68`: the first low pass creates blocks
149            // `[0, lps)` and the first high pass blocks `[lps, 2*lps)`;
150            // every later pass ALIASES one of the two.
151            Self::Hrm { lps, l_cycles, .. } => {
152                let pass = l / lps;
153                let is_high = pass % (l_cycles + 1) == l_cycles;
154                (if is_high { lps } else { 0 }) + l % lps
155            }
156        }
157    }
158
159    /// Physical layers: how many blocks the file holds tensors for.
160    pub fn physical_layers(&self) -> usize {
161        match *self {
162            Self::Repeat { n_phys, .. } => n_phys,
163            Self::Hrm { lps, .. } => 2 * lps,
164        }
165    }
166
167    /// The norm applied to the residual AFTER logical layer `l`, if
168    /// any.
169    ///
170    /// `nanbeige` norms at the end of every pass but the LAST (its own
171    /// `output_norm` follows that one anyway) and only when the file
172    /// does not skip it; `hrm_text` norms at the end of EVERY stack,
173    /// including the last, because it has no other final norm.
174    pub fn loop_norm_after(&self, l: usize) -> Option<LoopNorm> {
175        match *self {
176            Self::Repeat {
177                n_phys,
178                skip_loop_final_norm,
179                ..
180            } => (!skip_loop_final_norm
181                && (l + 1).is_multiple_of(n_phys)
182                && l + 1 < self.logical_layers())
183            .then_some(LoopNorm::Output),
184            Self::Hrm { lps, .. } => (l + 1).is_multiple_of(lps).then_some(LoopNorm::Weightless),
185        }
186    }
187
188    /// Does logical layer `l` START a stack, i.e. does the residual it
189    /// reads come from recombining the two streams?
190    pub fn stack_starts_at(&self, l: usize) -> bool {
191        match *self {
192            Self::Repeat { .. } => false,
193            Self::Hrm { lps, .. } => l.is_multiple_of(lps),
194        }
195    }
196
197    /// Which stream the pass ending at logical layer `l` writes, or
198    /// `None` when `l` does not end a stack.
199    pub fn stream_after(&self, l: usize) -> Option<HrmStream> {
200        match *self {
201            Self::Repeat { .. } => None,
202            Self::Hrm { lps, l_cycles, .. } => {
203                if !(l + 1).is_multiple_of(lps) {
204                    return None;
205                }
206                let pass = l / lps;
207                Some(if pass % (l_cycles + 1) == l_cycles {
208                    HrmStream::High
209                } else {
210                    HrmStream::Low
211                })
212            }
213        }
214    }
215}
216
217/// Reads the two keys for a file: `Some` only for an architecture whose
218/// graph loops AND a `num_loops` above 1.
219pub fn read_layer_loops(
220    file: &impl TensorSource,
221    arch: &str,
222    n_phys: usize,
223) -> Result<Option<LayerLoops>, LoadError> {
224    if !LOOP_READERS.iter().any(|(name, _)| *name == arch) {
225        return Ok(None);
226    }
227    let key = |k: &str| format!("{arch}.{k}");
228    if arch == "hrm_text" {
229        // `hrm-text.cpp:10-12` reads all three REQUIRED and `:17-19`
230        // asserts each is above zero; `:22-23` then asserts the layer
231        // count IS `lps * h * (l + 1)`, which the caller checks below
232        // against `block_count`.
233        let read = |k: &str| -> Result<usize, LoadError> {
234            file.metadata_u64(&key(k))
235                .map(|v| v as usize)
236                .ok_or_else(|| LoadError::MissingHparam(key(k)))
237        };
238        let (lps, h_cycles, l_cycles) = (
239            read("hrm.layers_per_stack")?,
240            read("hrm.h_cycles")?,
241            read("hrm.l_cycles")?,
242        );
243        if lps == 0 || h_cycles == 0 || l_cycles == 0 {
244            return Err(LoadError::UnsupportedFeature(
245                arch.to_string(),
246                format!(
247                    "hrm.layers_per_stack / h_cycles / l_cycles are {lps} / {h_cycles} / \
248                     {l_cycles}; llama.cpp asserts each is above zero (hrm-text.cpp:17-19)"
249                ),
250            ));
251        }
252        let schedule = LayerLoops::Hrm {
253            lps,
254            h_cycles,
255            l_cycles,
256        };
257        // `hrm-text.cpp:22-23`: the GGUF block count IS the expanded
258        // slot count, so `n_phys` here is the slot count and the two
259        // must agree. A file that disagrees fails llama.cpp's assert
260        // and stops here with the numbers rather than running a
261        // schedule the tensors do not match.
262        if schedule.logical_layers() != n_phys {
263            return Err(LoadError::UnsupportedFeature(
264                arch.to_string(),
265                format!(
266                    "block_count is {n_phys} but hrm.layers_per_stack * hrm.h_cycles * \
267                     (hrm.l_cycles + 1) is {}; llama.cpp asserts they are equal \
268                     (hrm-text.cpp:22-23)",
269                    schedule.logical_layers()
270                ),
271            ));
272        }
273        return Ok(Some(schedule));
274    }
275    let n_loops = file.metadata_u64(&key("num_loops")).unwrap_or(1) as usize;
276    if n_loops == 0 {
277        // `GGML_ASSERT(n_loops_u >= 1)`, nanbeige.cpp:8.
278        return Err(LoadError::UnsupportedFeature(
279            arch.to_string(),
280            "num_loops is 0; llama.cpp asserts it is at least 1".to_string(),
281        ));
282    }
283    if n_loops == 1 {
284        return Ok(None);
285    }
286    let skip_loop_final_norm = file
287        .metadata_bool(&key("skip_loop_final_norm"))
288        .unwrap_or(false);
289    Ok(Some(LayerLoops::Repeat {
290        n_phys,
291        n_loops,
292        skip_loop_final_norm,
293    }))
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    /// Two physical layers run twice: logical 0,1,2,3 are physical
301    /// 0,1,0,1; the loop norm sits after logical 1 and NOT after
302    /// logical 3, which the final norm follows.
303    #[test]
304    fn the_schedule_and_the_loop_norm_follow_nanbeige_cpp() {
305        let loops = LayerLoops::Repeat {
306            n_phys: 2,
307            n_loops: 2,
308            skip_loop_final_norm: false,
309        };
310        assert_eq!(loops.logical_layers(), 4);
311        assert_eq!(
312            (0..4).map(|l| loops.physical(l)).collect::<Vec<_>>(),
313            [0, 1, 0, 1]
314        );
315        assert_eq!(
316            (0..4)
317                .map(|l| loops.loop_norm_after(l).is_some())
318                .collect::<Vec<_>>(),
319            [false, true, false, false]
320        );
321        assert_eq!(loops.loop_norm_after(1), Some(LoopNorm::Output));
322        // A looped model reads ONE stream, so nothing recombines.
323        assert!((0..4).all(|l| !loops.stack_starts_at(l)));
324        assert!((0..4).all(|l| loops.stream_after(l).is_none()));
325        let skipped = LayerLoops::Repeat {
326            n_phys: 2,
327            n_loops: 2,
328            skip_loop_final_norm: true,
329        };
330        assert!((0..4).all(|l| skipped.loop_norm_after(l).is_none()));
331        // Three passes over three layers: norms after 2 and 5, not 8.
332        let three = LayerLoops::Repeat {
333            n_phys: 3,
334            n_loops: 3,
335            skip_loop_final_norm: false,
336        };
337        assert_eq!(
338            (0..9)
339                .filter(|&l| three.loop_norm_after(l).is_some())
340                .collect::<Vec<_>>(),
341            [2, 5]
342        );
343    }
344
345    /// HRM-Text's schedule, read off `hrm-text.cpp:57-68,183-196`:
346    /// two stacks of `lps` blocks, replayed over `h * (l + 1)` passes,
347    /// a weightless norm after EVERY pass, and the stream each pass
348    /// writes.
349    #[test]
350    fn the_hrm_schedule_aliases_two_stacks_and_names_the_stream_each_pass_writes() {
351        // lps = 2, h = 2, l = 2: passes are LOW LOW HIGH LOW LOW HIGH,
352        // twelve logical layers over four physical ones.
353        let hrm = LayerLoops::Hrm {
354            lps: 2,
355            h_cycles: 2,
356            l_cycles: 2,
357        };
358        assert_eq!(hrm.logical_layers(), 12);
359        assert_eq!(hrm.physical_layers(), 4);
360        assert_eq!(
361            (0..12).map(|l| hrm.physical(l)).collect::<Vec<_>>(),
362            // LOW(0,1) LOW(0,1) HIGH(2,3) LOW(0,1) LOW(0,1) HIGH(2,3)
363            [0, 1, 0, 1, 2, 3, 0, 1, 0, 1, 2, 3]
364        );
365        // Every stack boundary norms, including the last: the
366        // architecture has no `output_norm` tensor.
367        assert_eq!(
368            (0..12)
369                .filter(|&l| hrm.loop_norm_after(l) == Some(LoopNorm::Weightless))
370                .collect::<Vec<_>>(),
371            [1, 3, 5, 7, 9, 11]
372        );
373        assert_eq!(
374            (0..12)
375                .filter(|&l| hrm.stack_starts_at(l))
376                .collect::<Vec<_>>(),
377            [0, 2, 4, 6, 8, 10]
378        );
379        assert_eq!(
380            (0..12)
381                .filter_map(|l| hrm.stream_after(l))
382                .collect::<Vec<_>>(),
383            [
384                HrmStream::Low,
385                HrmStream::Low,
386                HrmStream::High,
387                HrmStream::Low,
388                HrmStream::Low,
389                HrmStream::High,
390            ]
391        );
392    }
393
394    #[test]
395    fn every_reader_is_an_audited_generic_row() {
396        for (arch, line) in LOOP_READERS {
397            let profile = crate::capability::resolve_profile(arch)
398                .unwrap_or_else(|| panic!("`{arch}` ({line}) is not a registered architecture"));
399            assert!(matches!(
400                profile.path,
401                crate::capability::ArchPath::GenericGqa { .. }
402            ));
403            assert!(crate::capability::AUDITED_GENERIC_GQA.contains(arch));
404        }
405    }
406}