Skip to main content

rlx_gemma/
qat_loader.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Weight loader for the Gemma 4 E2B *mobile QAT* safetensors checkpoint.
17//!
18//! The mobile checkpoint differs from a normal HF Gemma in three ways the
19//! stock safetensors loader can't handle:
20//!
21//! 1. **Name prefix** — the language model lives under `model.language_model.`
22//!    while the rlx Gemma builder asks for `model.…` / `lm_head.weight`.
23//! 2. **Integer-quant storage** — most linears ship `{m}.weight` as packed
24//!    `uint8`/`int8` plus a float `{m}.weight_scale`; embeddings ship
25//!    `{m}.embedding_quantized` + `{m}.embedding_scale`. We dequantize to F32
26//!    on `take` using [`crate::qat`] (proven bit-exact vs HF).
27//! 3. **Per-module bit widths** — resolved from `quantization_config` via
28//!    [`crate::qat::GemmaQuantPlan`].
29//!
30//! This loader returns F32 (correctness-first). A future packed
31//! `DequantMatMul` path keeps weights low-bit in-graph for speed.
32//!
33//! The giant `embed_tokens_per_layer` table (`[vocab, 35*256]`, ~9.4 GB in F32)
34//! is **never** materialized whole: [`GemmaQatLoader::dequant_embedding_rows`]
35//! gathers + dequantizes only the rows for the actual prompt tokens, which the
36//! runner uses to precompute the Per-Layer-Embedding inputs.
37
38use std::collections::HashSet;
39use std::path::Path;
40
41use anyhow::{Context, Result, anyhow, bail};
42use rlx_core::safetensors_checkpoint::SafetensorsCheckpoint;
43use rlx_core::weight_loader::WeightLoader;
44use safetensors::Dtype;
45
46use crate::qat::{GemmaQuantBits, GemmaQuantPlan, dequantize_matrix, unpack_row};
47
48/// Loader over a single-file (or sharded) Gemma 4 E2B mobile checkpoint.
49pub struct GemmaQatLoader {
50    ckpt: SafetensorsCheckpoint,
51    plan: GemmaQuantPlan,
52    taken: HashSet<String>,
53}
54
55impl GemmaQatLoader {
56    /// Open the checkpoint directory (expects `model.safetensors` +
57    /// `config.json` with a `quantization_config` block).
58    pub fn open(dir: &Path) -> Result<Self> {
59        let ckpt = SafetensorsCheckpoint::open(dir)?;
60        let cfg_path = dir.join("config.json");
61        let raw = std::fs::read(&cfg_path)
62            .with_context(|| format!("reading {cfg_path:?} for quantization_config"))?;
63        let json: serde_json::Value =
64            serde_json::from_slice(&raw).with_context(|| format!("parsing {cfg_path:?}"))?;
65        let quant = json.get("quantization_config").ok_or_else(|| {
66            anyhow!("{cfg_path:?}: no quantization_config (not a QAT checkpoint?)")
67        })?;
68        let plan = GemmaQuantPlan::from_json(quant);
69        Ok(Self {
70            ckpt,
71            plan,
72            taken: HashSet::new(),
73        })
74    }
75
76    /// Translate a builder-side HF name to the checkpoint's storage name.
77    /// Text LM weights live under `model.language_model.`; the multimodal
78    /// towers/projectors (`vision_tower`, `audio_tower`, `embed_vision`,
79    /// `embed_audio`) and `lm_head` are stored verbatim under `model.` and
80    /// must NOT get the `language_model` infix.
81    fn remap(key: &str) -> String {
82        if key.starts_with("lm_head") {
83            return key.to_string();
84        }
85        const VERBATIM: [&str; 4] = [
86            "model.vision_tower",
87            "model.audio_tower",
88            "model.embed_vision",
89            "model.embed_audio",
90        ];
91        if VERBATIM.iter().any(|p| key.starts_with(p)) {
92            return key.to_string();
93        }
94        match key.strip_prefix("model.") {
95            Some(rest) => format!("model.language_model.{rest}"),
96            None => key.to_string(),
97        }
98    }
99
100    /// Unpacked input width for a packed weight `[out, packed_cols]`.
101    fn unpacked_cols(packed_cols: usize, bits: GemmaQuantBits) -> usize {
102        packed_cols * bits.values_per_byte()
103    }
104
105    fn read_scale(&self, name: &str) -> Result<Vec<f32>> {
106        let (bytes, dt, _shape) = self.ckpt.tensor_raw(name)?;
107        anyhow::ensure!(dt == Dtype::F32, "{name}: scale must be F32, got {dt:?}");
108        Ok(bytes
109            .chunks_exact(4)
110            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
111            .collect())
112    }
113
114    /// Dequantize one full quantized linear `{st}.weight` (+ `.weight_scale`)
115    /// to a row-major `[out, in]` F32 matrix.
116    fn dequant_linear(&self, st_weight: &str) -> Result<(Vec<f32>, Vec<usize>)> {
117        let module = st_weight.strip_suffix(".weight").unwrap_or(st_weight);
118        let bits = self
119            .plan
120            .resolve_bits(module)
121            .ok_or_else(|| anyhow!("{module}: no quant bits (in modules_to_not_convert?)"))?;
122        let (qbytes, qdt, qshape) = self.ckpt.tensor_raw(st_weight)?;
123        anyhow::ensure!(
124            matches!(qdt, Dtype::U8 | Dtype::I8),
125            "{st_weight}: expected U8/I8 packed weight, got {qdt:?}"
126        );
127        anyhow::ensure!(
128            qshape.len() == 2,
129            "{st_weight}: expected rank-2, got {qshape:?}"
130        );
131        let out = qshape[0];
132        let inn = Self::unpacked_cols(qshape[1], bits);
133        let scale = self.read_scale(&format!("{module}.weight_scale"))?;
134        let w = dequantize_matrix(&qbytes, &scale, out, inn, bits)?;
135        Ok((w, vec![out, inn]))
136    }
137
138    /// Dequantize a full quantized embedding table to `[vocab, dim]` F32 with a
139    /// per-row scale (used for the main `embed_tokens`; the giant per-layer
140    /// table must use [`Self::dequant_embedding_rows`] instead). Does **not**
141    /// apply the `sqrt(dim)` embed-scale — the builder applies that separately.
142    fn dequant_embedding_full(&self, base: &str) -> Result<(Vec<f32>, Vec<usize>)> {
143        let bits = self
144            .plan
145            .resolve_bits(base)
146            .ok_or_else(|| anyhow!("{base}: no quant bits for embedding"))?;
147        let (qbytes, qdt, qshape) = self
148            .ckpt
149            .tensor_raw(&format!("{base}.embedding_quantized"))?;
150        anyhow::ensure!(
151            matches!(qdt, Dtype::U8 | Dtype::I8),
152            "{base}.embedding_quantized: expected U8/I8, got {qdt:?}"
153        );
154        let vocab = qshape[0];
155        let dim = Self::unpacked_cols(qshape[1], bits);
156        let (sbytes, _sdt, sshape) = self.ckpt.tensor_raw(&format!("{base}.embedding_scale"))?;
157        let scale: Vec<f32> = sbytes
158            .chunks_exact(4)
159            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
160            .collect();
161        anyhow::ensure!(
162            sshape == [vocab, 1],
163            "{base}.embedding_scale: expected per-row [vocab,1], got {sshape:?} \
164             (grouped scale → use dequant_embedding_rows)"
165        );
166        let w = dequantize_matrix(&qbytes, &scale, vocab, dim, bits)?;
167        Ok((w, vec![vocab, dim]))
168    }
169
170    /// Gather + dequantize only `rows` of a (possibly grouped-scale) quantized
171    /// embedding. `builder_key` is e.g. `model.embed_tokens.weight` or
172    /// `model.embed_tokens_per_layer.weight`. Returns `(flat rows*dim, dim)`.
173    /// Handles both per-row scale `[vocab,1]` and the per-layer grouped scale
174    /// `[vocab, groups]` (one scale per `dim/groups`-wide block). Does not apply
175    /// the `sqrt` embed-scale.
176    pub fn dequant_embedding_rows(
177        &self,
178        builder_key: &str,
179        rows: &[u32],
180    ) -> Result<(Vec<f32>, usize)> {
181        let base = Self::remap(builder_key);
182        let base = base.strip_suffix(".weight").unwrap_or(&base);
183        let bits = self
184            .plan
185            .resolve_bits(base)
186            .ok_or_else(|| anyhow!("{base}: no quant bits for embedding"))?;
187        let (qbytes, qdt, qshape) = self
188            .ckpt
189            .tensor_raw(&format!("{base}.embedding_quantized"))?;
190        anyhow::ensure!(
191            matches!(qdt, Dtype::U8 | Dtype::I8),
192            "{base}.embedding_quantized: expected U8/I8, got {qdt:?}"
193        );
194        let vocab = qshape[0];
195        let packed_cols = qshape[1];
196        let dim = Self::unpacked_cols(packed_cols, bits);
197        let (sbytes, _sdt, sshape) = self.ckpt.tensor_raw(&format!("{base}.embedding_scale"))?;
198        let scale: Vec<f32> = sbytes
199            .chunks_exact(4)
200            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
201            .collect();
202        let groups = if sshape == [vocab, 1] {
203            1
204        } else {
205            anyhow::ensure!(
206                sshape.len() == 2 && sshape[0] == vocab,
207                "{base}.embedding_scale: unexpected shape {sshape:?}"
208            );
209            sshape[1]
210        };
211        anyhow::ensure!(
212            dim % groups == 0,
213            "{base}: dim {dim} not divisible by scale groups {groups}"
214        );
215        let block = dim / groups;
216        let mut out = Vec::with_capacity(rows.len() * dim);
217        for &r in rows {
218            let r = r as usize;
219            anyhow::ensure!(r < vocab, "{base}: row {r} >= vocab {vocab}");
220            let row_bytes = &qbytes[r * packed_cols..(r + 1) * packed_cols];
221            let q = unpack_row(row_bytes, dim, bits);
222            for (j, qv) in q.iter().enumerate() {
223                let g = j / block;
224                out.push(*qv as f32 * scale[r * groups + g]);
225            }
226        }
227        Ok((out, dim))
228    }
229
230    /// Read a plain float tensor by builder-side key (remapped) as F32.
231    pub fn float_tensor(&self, builder_key: &str) -> Result<(Vec<f32>, Vec<usize>)> {
232        self.take_float(&Self::remap(builder_key))
233    }
234
235    /// Main token embedding rows for `ids`, scaled by the Gemma embed-scale
236    /// `sqrt(hidden_size)` → `inputs_embeds` `[seq, hidden]` (flat).
237    pub fn inputs_embeds(&self, cfg: &crate::config::GemmaConfig, ids: &[u32]) -> Result<Vec<f32>> {
238        let (rows, dim) = self.dequant_embedding_rows("model.embed_tokens.weight", ids)?;
239        anyhow::ensure!(
240            dim == cfg.hidden_size,
241            "embed dim {dim} != hidden {}",
242            cfg.hidden_size
243        );
244        let scale = (cfg.hidden_size as f32).sqrt();
245        Ok(rows.iter().map(|v| v * scale).collect())
246    }
247
248    /// Compute the Per-Layer-Embedding inputs `[seq * num_layers * ple_w]`
249    /// (layout `[s][layer][d]`), matching HF `Gemma4TextModel.get_per_layer_inputs`
250    /// + `project_per_layer_inputs`:
251    ///   tok  = embed_tokens_per_layer(ids) · √ple_w                  (token identity)
252    ///   proj = (inputs_embeds · per_layer_model_projeᵀ) · hidden^-½  (context)
253    ///   proj = per_layer_projection_norm(proj)                       (RMSNorm, ×(1+γ))
254    ///   out  = (proj + tok) · 2^-½
255    ///
256    /// This runs out-of-graph so the 9.4 GB per-layer table is never materialized.
257    pub fn compute_per_layer_inputs(
258        &self,
259        cfg: &crate::config::GemmaConfig,
260        ids: &[u32],
261    ) -> Result<Vec<f32>> {
262        let seq = ids.len();
263        let h = cfg.hidden_size;
264        let nl = cfg.num_hidden_layers;
265        let pw = cfg.ple_width();
266        let eps = cfg.rms_norm_eps as f32;
267
268        let (tok_rows, tdim) =
269            self.dequant_embedding_rows("model.embed_tokens_per_layer.weight", ids)?;
270        anyhow::ensure!(tdim == nl * pw, "per-layer embed dim {tdim} != {nl}*{pw}");
271        let tok_scale = (pw as f32).sqrt();
272
273        let ie = self.inputs_embeds(cfg, ids)?; // [seq, h]
274        let (w, wshape) = self.float_tensor("model.per_layer_model_projection.weight")?;
275        anyhow::ensure!(
276            wshape == [nl * pw, h],
277            "per_layer_model_projection shape {wshape:?} != [{}, {h}]",
278            nl * pw
279        );
280        let proj_scale = (h as f32).powf(-0.5);
281        let (gnorm, gshape) = self.float_tensor("model.per_layer_projection_norm.weight")?;
282        anyhow::ensure!(gshape == [pw], "projection_norm shape {gshape:?} != [{pw}]");
283        let inv_sqrt2 = 0.5f32.sqrt();
284
285        let mut out = vec![0f32; seq * nl * pw];
286        for s in 0..seq {
287            let emb = &ie[s * h..(s + 1) * h];
288            for layer in 0..nl {
289                // context projection block for this (token, layer): [pw]
290                let mut block = vec![0f32; pw];
291                for (d, b) in block.iter_mut().enumerate() {
292                    let wrow = &w[(layer * pw + d) * h..(layer * pw + d + 1) * h];
293                    let acc: f32 = emb.iter().zip(wrow).map(|(a, b)| a * b).sum();
294                    *b = acc * proj_scale;
295                }
296                // RMSNorm over the pw block, gamma = 1 + gnorm.
297                let ss: f32 = block.iter().map(|x| x * x).sum();
298                let rms = 1.0 / (ss / pw as f32 + eps).sqrt();
299                for d in 0..pw {
300                    let normed = block[d] * rms * (1.0 + gnorm[d]);
301                    let tok = tok_rows[s * tdim + layer * pw + d] * tok_scale;
302                    out[s * nl * pw + layer * pw + d] = (normed + tok) * inv_sqrt2;
303                }
304            }
305        }
306        Ok(out)
307    }
308
309    /// Read a plain float tensor (`F32`/`F16`/`BF16`) by storage name as F32.
310    ///
311    /// **Norm convention bridge:** Gemma 4's `Gemma4RMSNorm` scales by `weight`
312    /// directly (`normed * weight`), unlike Gemma 1/2/3's `(1 + weight)` that
313    /// the rlx builder's `gemma_rms` assumes. So for norm tensors we return
314    /// `weight - 1`; the builder then computes `1 + (weight - 1) = weight`,
315    /// reproducing Gemma 4's plain-weight RMSNorm without touching the shared
316    /// builder. (Verified bit-exact vs HF on per_layer_projection_norm.)
317    fn take_float(&self, st: &str) -> Result<(Vec<f32>, Vec<usize>)> {
318        let (bytes, dt, shape) = self.ckpt.tensor_raw(st)?;
319        let mut data = match dt {
320            Dtype::F32 | Dtype::F16 | Dtype::BF16 => bytes_view_to_f32(&bytes, dt)?,
321            other => bail!("{st}: take_float on non-float dtype {other:?}"),
322        };
323        if st.ends_with("norm.weight") {
324            for v in &mut data {
325                *v -= 1.0;
326            }
327        }
328        Ok((data, shape))
329    }
330
331    fn take_impl(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)> {
332        self.taken.insert(key.to_string());
333        let st = Self::remap(key);
334        // Case 1: tensor stored directly (float norms, packed-int linears).
335        if self.ckpt.contains(&st) {
336            let (_b, dt, _s) = self.ckpt.tensor_raw(&st)?;
337            return match dt {
338                Dtype::U8 | Dtype::I8 => self.dequant_linear(&st),
339                _ => self.take_float(&st),
340            };
341        }
342        // Case 2: quantized embedding (`embed_tokens` only — the per-layer table
343        // is handled out-of-graph via `dequant_embedding_rows`).
344        let base = st.strip_suffix(".weight").unwrap_or(&st);
345        if self.ckpt.contains(&format!("{base}.embedding_quantized")) {
346            return self.dequant_embedding_full(base);
347        }
348        bail!("GemmaQatLoader: tensor {key} (→ {st}) not found in checkpoint")
349    }
350}
351
352fn bytes_view_to_f32(bytes: &[u8], dt: Dtype) -> Result<Vec<f32>> {
353    Ok(match dt {
354        Dtype::F32 => bytes
355            .chunks_exact(4)
356            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
357            .collect(),
358        Dtype::F16 => bytes
359            .chunks_exact(2)
360            .map(|c| half::f16::from_le_bytes([c[0], c[1]]).to_f32())
361            .collect(),
362        Dtype::BF16 => bytes
363            .chunks_exact(2)
364            .map(|c| half::bf16::from_le_bytes([c[0], c[1]]).to_f32())
365            .collect(),
366        other => bail!("unsupported float dtype {other:?}"),
367    })
368}
369
370fn transpose_2d(data: &[f32], rows: usize, cols: usize) -> Vec<f32> {
371    let mut t = vec![0f32; data.len()];
372    for r in 0..rows {
373        for c in 0..cols {
374            t[c * rows + r] = data[r * cols + c];
375        }
376    }
377    t
378}
379
380impl WeightLoader for GemmaQatLoader {
381    fn format_id(&self) -> &'static str {
382        "gemma-qat-safetensors"
383    }
384
385    fn len(&self) -> usize {
386        self.ckpt.keys().count()
387    }
388
389    fn take(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)> {
390        self.take_impl(key)
391    }
392
393    fn take_transposed(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)> {
394        let (data, shape) = self.take_impl(key)?;
395        anyhow::ensure!(
396            shape.len() == 2,
397            "take_transposed on non-2D tensor {key}: {shape:?}"
398        );
399        let (rows, cols) = (shape[0], shape[1]);
400        Ok((transpose_2d(&data, rows, cols), vec![cols, rows]))
401    }
402
403    fn remaining_keys(&self) -> Vec<String> {
404        self.ckpt
405            .keys()
406            .filter(|k| !self.taken.contains(*k))
407            .map(|s| s.to_string())
408            .collect()
409    }
410
411    fn arch_hint(&self) -> Option<&str> {
412        Some("gemma4")
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    /// Resolve the downloaded checkpoint dir (HF cache snapshot) if present.
421    fn fixture_dir() -> Option<std::path::PathBuf> {
422        if let Some(d) = std::env::var_os("RLX_GEMMA4_E2B_DIR") {
423            let p = std::path::PathBuf::from(d);
424            return p.join("config.json").is_file().then_some(p);
425        }
426        // HF hub default cache layout.
427        let home = std::env::var_os("HOME")?;
428        let base = std::path::Path::new(&home).join(
429            ".cache/huggingface/hub/\
430             models--google--gemma-4-E2B-it-qat-mobile-transformers/snapshots",
431        );
432        let snap = std::fs::read_dir(&base).ok()?.flatten().next()?.path();
433        snap.join("config.json").is_file().then_some(snap)
434    }
435
436    #[test]
437    fn loads_and_dequants_real_checkpoint() {
438        let Some(dir) = fixture_dir() else {
439            eprintln!("[qat_loader] checkpoint not found — skipping");
440            return;
441        };
442        let mut ld = GemmaQatLoader::open(&dir).expect("open ckpt");
443
444        // A 4-bit attention projection → [out, in] = [2048, 1536].
445        let (q, qs) = ld
446            .take("model.layers.0.self_attn.q_proj.weight")
447            .expect("take q_proj");
448        assert_eq!(qs, vec![2048, 1536]);
449        assert!(q.iter().all(|v| v.is_finite()));
450        assert!(q.iter().any(|&v| v != 0.0));
451
452        // A BF16 norm → [1536].
453        let (n, ns) = ld.take("model.norm.weight").expect("take norm");
454        assert_eq!(ns, vec![1536]);
455        assert!(n.iter().all(|v| v.is_finite()));
456
457        // lm_head is a separate 2-bit table [262144, 1536].
458        let (_h, hs) = ld.take("lm_head.weight").expect("take lm_head");
459        assert_eq!(hs, vec![262144, 1536]);
460
461        // Transpose flips the 2D shape.
462        let (_qt, qts) = ld
463            .take_transposed("model.layers.0.self_attn.k_proj.weight")
464            .expect("take_t k_proj");
465        assert_eq!(qts, vec![1536, 256]); // k_proj is [256,1536] → T [1536,256]
466
467        // Per-layer embedding rows: grouped scale, gather just two tokens.
468        let (ple, dim) = ld
469            .dequant_embedding_rows("model.embed_tokens_per_layer.weight", &[818, 5279])
470            .expect("ple rows");
471        assert_eq!(dim, 35 * 256);
472        assert_eq!(ple.len(), 2 * 35 * 256);
473        assert!(ple.iter().all(|v| v.is_finite()));
474
475        // Exact cross-check vs HF ground truth (fixtures/gemma4_e2b/loader_check.json),
476        // when present. Proves the full remap + dequant + grouped-scale pipeline
477        // matches transformers bit-for-bit, not just "finite".
478        let fx_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
479            .join("../../fixtures/gemma4_e2b/loader_check.json");
480        if let Ok(raw) = std::fs::read(&fx_path) {
481            let fx: serde_json::Value = serde_json::from_slice(&raw).unwrap();
482            let arr = |k: &str| -> Vec<f32> {
483                fx[k]
484                    .as_array()
485                    .unwrap()
486                    .iter()
487                    .map(|v| v.as_f64().unwrap() as f32)
488                    .collect()
489            };
490            let approx = |a: &[f32], b: &[f32], tag: &str| {
491                assert_eq!(a.len(), b.len(), "{tag} len");
492                for (i, (x, y)) in a.iter().zip(b).enumerate() {
493                    assert!(
494                        (x - y).abs() <= 1e-6 * (1.0 + y.abs()),
495                        "{tag}[{i}] {x} != {y}"
496                    );
497                }
498            };
499            // q_proj row 0, first 8.
500            approx(&q[..8], &arr("q_proj_l0_row0_head8"), "q_proj");
501            // norm first 8. The loader returns Gemma-4 norm weights as `w-1`
502            // (so the builder's `1+(w-1)` delta-gamma reproduces the plain-weight
503            // RMSNorm); add 1 back to compare against HF's raw weight.
504            let n_raw: Vec<f32> = n[..8].iter().map(|x| x + 1.0).collect();
505            approx(&n_raw, &arr("norm_head8"), "norm");
506            // per-layer embed token 818: first block + second block (grouped scale).
507            approx(&ple[..8], &arr("ple_tok818_first8"), "ple_b0");
508            approx(&ple[256..260], &arr("ple_tok818_block1_first4"), "ple_b1");
509            eprintln!("[qat_loader] exact cross-check vs HF: PASS");
510        } else {
511            eprintln!("[qat_loader] loader_check.json absent — structural checks only");
512        }
513    }
514
515    #[test]
516    fn per_layer_inputs_match_hf() {
517        let Some(dir) = fixture_dir() else {
518            eprintln!("[qat_loader] checkpoint not found — skipping");
519            return;
520        };
521        let ld = GemmaQatLoader::open(&dir).expect("open ckpt");
522        let cfg = crate::config::GemmaConfig::from_file(&dir.join("config.json")).expect("cfg");
523        let ids = [818u32, 5279, 529, 7001, 563];
524        let pli = ld.compute_per_layer_inputs(&cfg, &ids).expect("pli");
525        assert_eq!(pli.len(), 5 * 35 * 256);
526        assert!(pli.iter().all(|v| v.is_finite()));
527
528        // Exact vs HF (fixtures/gemma4_e2b/per_layer_inputs.bin), when present.
529        let bin = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
530            .join("../../fixtures/gemma4_e2b/per_layer_inputs.bin");
531        if let Ok(raw) = std::fs::read(&bin) {
532            let hf: Vec<f32> = raw
533                .chunks_exact(4)
534                .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
535                .collect();
536            assert_eq!(hf.len(), pli.len(), "pli len mismatch");
537            let mut maxd = 0f32;
538            for (a, b) in pli.iter().zip(&hf) {
539                maxd = maxd.max((a - b).abs());
540            }
541            eprintln!("[qat_loader] per_layer_inputs maxdiff vs HF = {maxd:.3e}");
542            // BF16 weights + f32 accumulation → small but nonzero.
543            assert!(maxd < 5e-3, "per_layer_inputs maxdiff {maxd} too large");
544        } else {
545            eprintln!("[qat_loader] per_layer_inputs.bin absent — structural check only");
546        }
547    }
548}