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}