Skip to main content

kopitiam_runtime/
model.rs

1//! [`QwenModel`]: the concrete Qwen-family transformer, wiring embedding,
2//! [`crate::rope::RotaryEmbedding`], [`crate::block::block_forward`] (per
3//! layer), the final norm, and the output projection into one
4//! [`crate::traits::Model`] implementation.
5
6use kopitiam_core::Result;
7use kopitiam_loader::LoadedModel;
8use kopitiam_tensor::Tensor;
9
10use crate::block::block_forward;
11use crate::config::QwenConfig;
12use crate::kv_cache::KvCache;
13use crate::linear::linear;
14use crate::rope::RotaryEmbedding;
15use crate::traits::{CpuBackend, Model};
16use crate::weights::ModelWeights;
17
18/// A loaded, ready-to-run Qwen-family transformer.
19pub struct QwenModel {
20    config: QwenConfig,
21    weights: ModelWeights,
22    rope: RotaryEmbedding,
23    backend: CpuBackend,
24    /// GPU offload for the output projection, when this machine can do it.
25    ///
26    /// `None` is the ordinary case, not a failure — no GPU, a weight the kernel
27    /// does not decode, or an adapter that cannot bind it. See
28    /// [`crate::gpu_offload`] for why this ONE matmul is offloaded and the rest
29    /// deliberately are not.
30    #[cfg(feature = "gpu")]
31    gpu: Option<(kopitiam_gpu::Executor, crate::gpu_offload::GpuOutputHead)>,
32}
33
34impl QwenModel {
35    /// Builds a [`QwenModel`] from an already-parsed [`LoadedModel`]
36    /// (`kopitiam_loader::load_model`'s result): resolves
37    /// [`QwenConfig::from_metadata`], loads and dequantizes every weight
38    /// tensor via [`crate::weights::ModelWeights::load`], and precomputes
39    /// RoPE's rotation tables up to the model's context window.
40    pub fn from_loaded_model(model: &LoadedModel) -> Result<Self> {
41        let config = QwenConfig::from_metadata(model.metadata())?;
42        let weights = ModelWeights::load(model, &config)?;
43        let rope = RotaryEmbedding::new(config.rope_dimension_count, config.rope_theta, config.max_context, config.rope_kind);
44
45        // Probing the adapter and uploading the weight costs ~100ms once, and
46        // buys ~17.6ms per token back on the output projection — so it pays for
47        // itself inside a six-token reply and is pure cost for a one-token one.
48        // Done at load rather than lazily because a chat session is the case
49        // this exists for; a caller that wants neither can build without the
50        // `gpu` feature.
51        #[cfg(feature = "gpu")]
52        let gpu = {
53            let executor = kopitiam_gpu::Executor::new();
54            crate::gpu_offload::GpuOutputHead::try_build(model, &executor).map(|head| (executor, head))
55        };
56
57        Ok(Self {
58            config,
59            weights,
60            rope,
61            backend: CpuBackend,
62            #[cfg(feature = "gpu")]
63            gpu,
64        })
65    }
66
67    /// Whether the output projection is running on the GPU for this model.
68    ///
69    /// Reported so a caller can say which path it took instead of guessing —
70    /// "is it using the GPU?" is otherwise unanswerable from outside.
71    #[must_use]
72    pub fn gpu_output_head_active(&self) -> bool {
73        #[cfg(feature = "gpu")]
74        {
75            self.gpu.is_some()
76        }
77        #[cfg(not(feature = "gpu"))]
78        {
79            false
80        }
81    }
82
83    pub fn config(&self) -> &QwenConfig {
84        &self.config
85    }
86
87    pub fn backend(&self) -> &CpuBackend {
88        &self.backend
89    }
90}
91
92/// Prints a one-line fingerprint of an intermediate tensor when
93/// `KOPITIAM_TRACE_TENSORS` is set, and costs nothing otherwise.
94///
95/// # Why this exists
96///
97/// "The model answers nonsense" localises to *some* layer, and the only way to
98/// find which is to compare intermediates against a reference implementation
99/// (`llama.cpp`'s `llama-eval-callback` prints the same kind of fingerprint —
100/// see `docs/REFERENCE-ORACLE.md`). Reading our own code cannot do it: every
101/// component of this forward pass has been individually verified correct
102/// against hand-computed references, and the composed result still disagrees
103/// (`bd-b9x`).
104///
105/// The line carries shape, the first few values, and mean/max-abs. The
106/// summary statistics matter as much as the values: two implementations can
107/// agree on element 0 and diverge in scale, and `absmax` catches that
108/// immediately where eyeballing four floats does not.
109pub(crate) fn trace(label: &str, t: &Tensor) {
110    // Read the env var once, not once per tensor per layer per token: this sits
111    // on the decode hot path, where a `getenv` 200+ times per token is pure
112    // waste for a facility that is off in every normal run.
113    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
114    if !*ENABLED.get_or_init(|| std::env::var_os("KOPITIAM_TRACE_TENSORS").is_some()) {
115        return;
116    }
117    let Ok(v) = t.to_vec_f32() else {
118        eprintln!("[trace] {label:<28} <not materialisable>");
119        return;
120    };
121    let n = v.len().max(1);
122    let mean = v.iter().sum::<f32>() / n as f32;
123    let absmax = v.iter().fold(0f32, |m, x| m.max(x.abs()));
124    let sum: f32 = v.iter().sum();
125    let head: Vec<String> = v.iter().take(4).map(|x| format!("{x:+.4}")).collect();
126    // `sum` is printed because llama.cpp's eval-callback prints exactly that,
127    // which makes the two dumps directly comparable line by line.
128    eprintln!(
129        "[trace] {label:<24} {:?} sum={sum:+.6} first=[{}] mean={mean:+.6} absmax={absmax:.4}",
130        t.shape().dims(),
131        head.join(", ")
132    );
133}
134
135impl Model for QwenModel {
136    fn forward(&self, token_ids: &[u32], cache: &mut KvCache) -> Result<Tensor> {
137        let ids: Vec<i32> = token_ids.iter().map(|&id| id as i32).collect();
138        let ids_tensor = Tensor::from_i32(ids, [token_ids.len()])?;
139
140        // [seq, hidden]: one embedding row per input token.
141        let mut x = self.weights.token_embd.gather_rows(&ids_tensor)?;
142        trace("inp_embd", &x);
143
144        // Read once, before any layer's cache is touched by this call --
145        // see crate::attention::attention_forward's docs for why reading
146        // this per-layer instead (KvCache::len() reports layer 0's length
147        // specifically) is a real KV-cache correctness bug, not a harmless
148        // simplification.
149        let position_offset = cache.len();
150
151        for (layer_index, layer) in self.weights.layers.iter().enumerate() {
152            x = block_forward(
153                &x,
154                layer,
155                &self.rope,
156                self.config.n_heads,
157                self.config.n_kv_heads,
158                self.config.head_dim,
159                self.config.norm_eps,
160                layer_index,
161                position_offset,
162                cache,
163            )?;
164            trace(&format!("blk.{layer_index}.out"), &x);
165        }
166
167        let x = x.rms_norm(&self.weights.output_norm, self.config.norm_eps)?;
168        trace("output_norm", &x);
169
170        // The one offloaded matmul. Falls through to the CPU path below on ANY
171        // GPU failure — a device can be lost or busy mid-session, and a wrong
172        // answer is never preferable to a slower one.
173        #[cfg(feature = "gpu")]
174        if let Some((executor, head)) = &self.gpu {
175            let rows = x.shape().dims()[0];
176            if let Ok(hidden) = x.to_vec_f32()
177                && let Some(logits) = head.forward(executor, &hidden, rows)
178            {
179                return Tensor::from_f32(logits, [rows, self.config.vocab_size]);
180            }
181        }
182        // logits = x @ output_weight^T -> [seq, vocab_size]. Goes through
183        // `linear` (not a bare `matmul`+`transpose`) so a quantized
184        // `output.weight` -- usually the single largest weight matrix in a
185        // Qwen checkpoint, see `crate::weights::ModelWeights`'s docs --
186        // gets the same `Tensor::quantized_matmul` fast path every other
187        // projection does, via `linear`'s dtype dispatch.
188        linear(&x, &self.weights.output_weight, None)
189    }
190
191    fn vocab_size(&self) -> usize {
192        self.config.vocab_size
193    }
194
195    fn max_context(&self) -> usize {
196        self.config.max_context
197    }
198
199    fn new_cache(&self) -> KvCache {
200        KvCache::new(self.config.n_layers, self.config.max_context)
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::sampling::greedy_argmax;
208    use crate::test_support::synthetic_gguf::{build, tiny_model_bytes, write_temp_gguf, SyntheticModelSpec};
209    use crate::traits::Backend;
210    use kopitiam_core::DType;
211
212    fn load_tiny() -> QwenModel {
213        let bytes = tiny_model_bytes();
214        let path = write_temp_gguf(&bytes, "model-tiny");
215        let loaded = kopitiam_loader::load_model(&path).unwrap();
216        QwenModel::from_loaded_model(&loaded).unwrap()
217    }
218
219    /// Builds and loads a full `QwenModel` from `spec`, through the real
220    /// GGUF byte format end to end (write -> `kopitiam_loader::load_model`
221    /// -> `QwenModel::from_loaded_model`) — the same path
222    /// `kopitiam-ai`'s `LocalAdapter` uses on a real model file, just
223    /// pointed at a synthetic one.
224    fn load_from_spec(spec: &SyntheticModelSpec, disambiguator: &str) -> QwenModel {
225        let bytes = build(spec);
226        let path = write_temp_gguf(&bytes, disambiguator);
227        let loaded = kopitiam_loader::load_model(&path).unwrap();
228        QwenModel::from_loaded_model(&loaded).unwrap()
229    }
230
231    /// The end-to-end wiring test called for by this crate's task brief:
232    /// no real Qwen GGUF is present on this machine (see
233    /// `crate::model::tests::a_real_model_on_disk_is_used_if_present`,
234    /// which is `#[ignore]`d for exactly that reason), so this builds a
235    /// tiny-but-structurally-real synthetic GGUF (2 layers, GQA-shaped,
236    /// tied embeddings, random-but-fixed weights) and proves the whole
237    /// load -> forward -> logits pipeline runs and produces finite,
238    /// correctly-shaped output. It intentionally does not assert anything
239    /// about *which* tokens the random weights favor -- that would be
240    /// asserting a coincidence, not a property of the code.
241    #[test]
242    fn synthetic_model_forward_pass_runs_end_to_end_and_produces_finite_logits() {
243        let model = load_tiny();
244        let mut cache = model.new_cache();
245
246        let prompt = [3u32, 7, 1, 22];
247        let logits = model.forward(&prompt, &mut cache).unwrap();
248        assert_eq!(logits.shape().dims(), &[prompt.len(), model.vocab_size()]);
249        for v in logits.to_vec_f32().unwrap() {
250            assert!(v.is_finite(), "logits must be finite, got {v}");
251        }
252        assert_eq!(cache.len(), prompt.len());
253
254        // Greedy-decode one further step to prove sampling composes with a
255        // real forward pass end to end, per this crate's "greedy decode
256        // end-to-end" acceptance bar.
257        let next = greedy_argmax(&logits.to_vec_f32().unwrap()[(prompt.len() - 1) * model.vocab_size()..]);
258        assert!((next as usize) < model.vocab_size());
259    }
260
261    /// A model whose GGUF has no separate `output.weight` (tied
262    /// embeddings) must still produce logits -- the tied-embedding
263    /// fallback in `ModelWeights::load` must actually be wired into the
264    /// forward pass, not just present as an unused field.
265    #[test]
266    fn tied_embeddings_model_still_produces_logits() {
267        let spec = SyntheticModelSpec { tie_embeddings: true, ..SyntheticModelSpec::default() };
268        let bytes = build(&spec);
269        let path = write_temp_gguf(&bytes, "model-tied");
270        let loaded = kopitiam_loader::load_model(&path).unwrap();
271        let model = QwenModel::from_loaded_model(&loaded).unwrap();
272        let mut cache = model.new_cache();
273
274        let logits = model.forward(&[1, 2, 3], &mut cache).unwrap();
275        assert_eq!(logits.shape().dims(), &[3, spec.vocab_size]);
276        assert!(logits.to_vec_f32().unwrap().iter().all(|v| v.is_finite()));
277    }
278
279    /// SmolLM2-360M (`kopitiam-models`' current `DEFAULT_MODEL_ID`) is a
280    /// *LLaMA*-architecture GGUF, not the Qwen2 this runtime was first
281    /// written against. It differs in exactly three load-bearing ways, all
282    /// of which this test proves are handled end to end:
283    ///
284    /// 1. `general.architecture` is `"llama"`, so every hyperparameter KV is
285    ///    namespaced `llama.*` — the loader's arch dispatch must read the
286    ///    architecture string and resolve `llama.block_count`,
287    ///    `llama.rope.freq_base`, `llama.attention.layer_norm_rms_epsilon`,
288    ///    etc. from it (not from a hard-coded `qwen2.` prefix).
289    /// 2. It has no Q/K/V projection biases — the bias tensors are absent
290    ///    from the file and must load as `None`, not error or read garbage.
291    /// 3. It ties its LM head to the token embedding table — there is no
292    ///    separate `output.weight`, so the head must fall back to
293    ///    `token_embd`.
294    ///
295    /// A Qwen-assuming loader would break on all three (miss every
296    /// hyperparameter, fail to find `attn_q.bias`, or fail to find
297    /// `output.weight`). This asserts each is resolved and that a full
298    /// forward pass then produces finite, correctly-shaped logits.
299    #[test]
300    fn llama_arch_smollm2_shaped_model_loads_and_runs_end_to_end() {
301        let spec = SyntheticModelSpec::llama_tied_no_bias();
302        let bytes = build(&spec);
303        let path = write_temp_gguf(&bytes, "model-llama-smollm2");
304        let loaded = kopitiam_loader::load_model(&path).unwrap();
305
306        // (1) Arch dispatch: the loader read `general.architecture` and then
307        // resolved every `llama.*`-namespaced hyperparameter through it --
308        // counts/sizes *and* the rope/norm keys under deeper sub-namespaces.
309        assert_eq!(loaded.metadata().architecture.as_deref(), Some("llama"));
310        assert_eq!(loaded.metadata().n_layers, Some(spec.n_layers as u64));
311        assert_eq!(loaded.metadata().n_heads, Some(spec.n_heads as u64));
312        assert_eq!(loaded.metadata().rope_theta, Some(spec.rope_theta));
313
314        let model = QwenModel::from_loaded_model(&loaded).unwrap();
315        // The rope/norm values really flowed through from `llama.*` keys
316        // into the resolved config (not silent defaults -- both differ from
317        // the 10000.0 / 1e-6 fallbacks `QwenConfig` would use if the keys
318        // had not resolved).
319        assert_eq!(model.config().rope_theta, 100_000.0);
320        assert_eq!(model.config().norm_eps, 1e-5);
321
322        // (2) LLaMA has no QKV bias: every layer's Q/K/V bias must be absent.
323        for (i, layer) in model.weights.layers.iter().enumerate() {
324            assert!(
325                layer.bq.is_none() && layer.bk.is_none() && layer.bv.is_none(),
326                "layer {i}: a LLaMA-arch model must load with no Q/K/V bias"
327            );
328        }
329
330        // (3) Tied embeddings: no separate output.weight in the file, so the
331        // LM head is the token embedding table itself.
332        assert!(loaded.tensor("output.weight").is_none(), "SmolLM2 ties its LM head; no output.weight should exist");
333        assert_eq!(
334            model.weights.output_weight.to_vec_f32().unwrap(),
335            model.weights.token_embd.to_vec_f32().unwrap(),
336            "a tied LM head must equal the token embedding table"
337        );
338
339        // (4) A full forward pass produces finite, correctly-shaped logits,
340        // and greedy sampling composes with it -- the same acceptance bar
341        // the Qwen end-to-end test holds, now on the LLaMA path.
342        let mut cache = model.new_cache();
343        let prompt = [2u32, 5, 9, 1];
344        let logits = model.forward(&prompt, &mut cache).unwrap();
345        assert_eq!(logits.shape().dims(), &[prompt.len(), model.vocab_size()]);
346        assert!(logits.to_vec_f32().unwrap().iter().all(|v| v.is_finite()), "LLaMA-path logits must be finite");
347        let next = greedy_argmax(&logits.to_vec_f32().unwrap()[(prompt.len() - 1) * model.vocab_size()..]);
348        assert!((next as usize) < model.vocab_size());
349    }
350
351    /// The architecture string **does** affect compute, and must: it selects
352    /// the RoPE pairing convention.
353    ///
354    /// # This test used to assert the exact opposite, and that was the bug
355    ///
356    /// It previously demanded *bit-identical* logits across `"llama"` and
357    /// `"qwen2"`, on the reasoning that the arch string is "only a
358    /// metadata-routing key" and "nothing in the forward pass branches on it".
359    /// That reasoning was wrong, and the test enshrined the wrongness: per
360    /// `llama.cpp`'s `llama_model_rope_type`, `llama` files want interleaved
361    /// (NORM) RoPE and `qwen2` files want split-half (NEOX). We applied
362    /// split-half to both, so every LLaMA-architecture model — including
363    /// SmolLM2, KOPITIAM's default — ran with mispositioned queries and keys.
364    /// See `bd-b9x` and [`crate::rope`]'s module docs.
365    ///
366    /// The lesson worth keeping: a test asserting two things are equal is only
367    /// as good as the argument that they *should* be. This one passed happily
368    /// for as long as the bug existed, because the bug is precisely what made
369    /// the two paths identical.
370    #[test]
371    fn the_architecture_string_selects_the_rope_convention() {
372        let base = SyntheticModelSpec { with_qkv_bias: false, tie_embeddings: true, ..SyntheticModelSpec::default() };
373        let llama = SyntheticModelSpec { architecture: "llama", ..base.clone() };
374        let qwen2 = SyntheticModelSpec { architecture: "qwen2", ..base };
375
376        let m_llama = load_from_spec(&llama, "arch-eq-llama");
377        let m_qwen2 = load_from_spec(&qwen2, "arch-eq-qwen2");
378
379        assert_eq!(m_llama.config().rope_kind, crate::rope::RopeKind::Interleaved);
380        assert_eq!(m_qwen2.config().rope_kind, crate::rope::RopeKind::SplitHalf);
381
382        // A multi-token prompt is essential: at position 0 both conventions are
383        // the identity, so a single-token prompt could not tell them apart —
384        // which is exactly why this bug hid behind short prompts.
385        let prompt = [1u32, 4, 2, 7];
386        let mut c_llama = m_llama.new_cache();
387        let mut c_qwen2 = m_qwen2.new_cache();
388        let l_llama = m_llama.forward(&prompt, &mut c_llama).unwrap().to_vec_f32().unwrap();
389        let l_qwen2 = m_qwen2.forward(&prompt, &mut c_qwen2).unwrap().to_vec_f32().unwrap();
390
391        assert_eq!(l_llama.len(), l_qwen2.len());
392        assert!(
393            l_llama.iter().zip(&l_qwen2).any(|(a, b)| a.to_bits() != b.to_bits()),
394            "llama and qwen2 must NOT produce identical logits — identical output means \
395             both are getting the same RoPE convention, which is the bd-b9x bug"
396        );
397    }
398
399    /// The KV-cache correctness property named explicitly in this crate's
400    /// task brief: decoding token-by-token *with* the cache must produce
401    /// bitwise-identical logits to a single forward pass over the whole
402    /// sequence *without* it (a fresh cache, called once with all tokens).
403    /// This single test is the one most likely to catch a KV-cache bug --
404    /// a wrong position offset, an off-by-one in the causal mask, a stale
405    /// or duplicated cache entry -- because any of those would perturb
406    /// *some* attention score and, without a cache-vs-no-cache oracle,
407    /// would otherwise only show up as "the model seems a bit off".
408    #[test]
409    fn decoding_with_a_kv_cache_matches_a_full_forward_pass_without_one_bit_for_bit() {
410        let model = load_tiny();
411        let tokens = [5u32, 12, 30, 2, 8];
412
413        // Reference: one forward call over the whole sequence, fresh cache.
414        let mut full_cache = model.new_cache();
415        let full_logits = model.forward(&tokens, &mut full_cache).unwrap().to_vec_f32().unwrap();
416
417        // Decode: one forward call per token, reusing one cache across calls.
418        let mut step_cache = model.new_cache();
419        let mut decoded_logits = Vec::new();
420        for &token in &tokens {
421            let step = model.forward(&[token], &mut step_cache).unwrap();
422            decoded_logits.extend(step.to_vec_f32().unwrap());
423        }
424
425        assert_eq!(full_cache.len(), step_cache.len());
426        assert_eq!(
427            full_logits.len(),
428            decoded_logits.len(),
429            "full-forward and step-by-step decode must produce the same number of logit values"
430        );
431        for (i, (full, decoded)) in full_logits.iter().zip(&decoded_logits).enumerate() {
432            assert_eq!(
433                full.to_bits(),
434                decoded.to_bits(),
435                "logit {i} differs between full-forward ({full}) and cached decode ({decoded}) -- \
436                 this is exactly the KV-cache bug this test exists to catch"
437            );
438        }
439    }
440
441    /// Guards against a *different* KV-cache bug than the equivalence test
442    /// above: rather than proving "matches a no-cache oracle", this proves
443    /// the cache actually accumulates rather than silently resetting or
444    /// overwriting -- if it did, `cache.len()` would not grow, and the
445    /// third decode step would attend over 1 cached position instead of 3.
446    #[test]
447    fn the_cache_length_grows_by_exactly_one_position_per_decode_step() {
448        let model = load_tiny();
449        let mut cache = model.new_cache();
450        for (step, &token) in [4u32, 9, 15].iter().enumerate() {
451            model.forward(&[token], &mut cache).unwrap();
452            assert_eq!(cache.len(), step + 1);
453        }
454    }
455
456    /// Real, full-size Qwen `.gguf` weights (as opposed to the vocab-only
457    /// fixture at `crates/kopitiam-ai/vendor/llama.cpp/models/`) were not
458    /// found anywhere on this machine when this test was written (`find ~
459    /// -name "*.gguf" -size +100M` and a broader filesystem search both
460    /// came back empty). This test is `#[ignore]`d rather than deleted so
461    /// it is ready to run the moment a real model is placed at the path
462    /// below, without anyone having to reconstruct what "load and greedily
463    /// generate a few tokens" should look like from scratch.
464    #[test]
465    #[ignore = "no real Qwen GGUF present on this machine; point REAL_QWEN_GGUF_PATH at one to run this"]
466    fn a_real_model_on_disk_is_used_if_present() {
467        let path = std::env::var("REAL_QWEN_GGUF_PATH").expect("set REAL_QWEN_GGUF_PATH to a real Qwen .gguf file");
468        let loaded = kopitiam_loader::load_model(path).unwrap();
469        let model = QwenModel::from_loaded_model(&loaded).unwrap();
470        let mut cache = model.new_cache();
471
472        // A handful of arbitrary, in-range token ids stands in for a real
473        // prompt: this test's purpose is proving the pipeline runs on real
474        // weights, not testing tokenizer round-tripping (see
475        // `crate::gguf_tokenizer` for that).
476        let prompt = [1u32, 2, 3, 4];
477        let mut logits = model.forward(&prompt, &mut cache).unwrap().to_vec_f32().unwrap();
478        for _ in 0..5 {
479            let next = greedy_argmax(&logits[logits.len() - model.vocab_size()..]);
480            let step = model.forward(&[next], &mut cache).unwrap();
481            logits = step.to_vec_f32().unwrap();
482        }
483    }
484
485    #[test]
486    fn dtype_matches_and_config_is_reachable() {
487        let model = load_tiny();
488        assert_eq!(model.weights.token_embd.dtype(), DType::F32);
489        assert_eq!(model.config().n_layers, 2);
490        assert_eq!(model.backend().device(), kopitiam_core::Device::Cpu);
491    }
492
493    // -- Quantized-weight wiring: the Phase 2 "the fast path is actually
494    // -- reachable end to end, not just a library function nobody calls" gate.
495
496    /// A model whose GGUF ships every matmul-operand weight as real `Q8_0`
497    /// bytes must load them still `Q8_0` (see `crate::weights::ModelWeights`'s
498    /// and `crate::bridge::load_matmul_weight`'s docs) and run a forward
499    /// pass end to end through `crate::linear::linear`'s dtype dispatch,
500    /// producing finite, correctly-shaped logits.
501    #[test]
502    fn quantized_matmul_weights_load_natively_and_produce_finite_logits() {
503        // tie_embeddings: false, so a separate (quantized) output.weight
504        // is actually written -- otherwise it would fall back to a clone
505        // of the (always-f32) token embedding table and this test would
506        // not exercise the output projection's quantized path at all.
507        let spec = SyntheticModelSpec {
508            quantize_matmul_weights: true,
509            tie_embeddings: false,
510            ..SyntheticModelSpec::quantized_benchmark()
511        };
512        let model = load_from_spec(&spec, "model-quantized");
513
514        assert_eq!(model.weights.layers[0].wq.dtype(), DType::Q8_0);
515        assert_eq!(model.weights.output_weight.dtype(), DType::Q8_0);
516        // Embeddings are never quantized in this scope (gather_rows needs
517        // f32 elementwise access) -- see `crate::bridge::load_matmul_weight`.
518        assert_eq!(model.weights.token_embd.dtype(), DType::F32);
519
520        let mut cache = model.new_cache();
521        let prompt = [3u32, 7, 1, 22, 9];
522        let logits = model.forward(&prompt, &mut cache).unwrap();
523        assert_eq!(logits.shape().dims(), &[prompt.len(), model.vocab_size()]);
524        assert!(logits.to_vec_f32().unwrap().iter().all(|v| v.is_finite()));
525    }
526
527    /// Isolates quantization error from every other source of divergence:
528    /// both specs below share one `Xorshift64` stream consumed in the same
529    /// call order (see `synthetic_gguf::build`'s docs), so the *only*
530    /// difference between the two resulting models is whether each
531    /// matmul-operand weight got rounded to `Q8_0` on the way in. This is
532    /// not a tight bound (four transformer layers of RMSNorm/softmax/SiLU
533    /// nonlinearity compound Q8_0's ~1/127 per-element rounding error
534    /// considerably, and the weights themselves are quantized here, unlike
535    /// `kopitiam-tensor`'s tighter kernel-level gate which isolates
536    /// activation-only error) -- but it is tight enough that a real wiring
537    /// bug (a transposed index, a forgotten scale multiply, reading the
538    /// wrong block) would blow it up by orders of magnitude and get caught
539    /// here.
540    #[test]
541    fn quantized_and_f32_weights_of_the_same_underlying_values_produce_similar_logits() {
542        let f32_spec = SyntheticModelSpec::quantized_benchmark();
543        let q_spec = SyntheticModelSpec { quantize_matmul_weights: true, ..SyntheticModelSpec::quantized_benchmark() };
544
545        let f32_model = load_from_spec(&f32_spec, "model-q-cmp-f32");
546        let q_model = load_from_spec(&q_spec, "model-q-cmp-q8");
547
548        let prompt = [3u32, 7, 1, 22, 9];
549        let mut f32_cache = f32_model.new_cache();
550        let logits_f32 = f32_model.forward(&prompt, &mut f32_cache).unwrap().to_vec_f32().unwrap();
551        let mut q_cache = q_model.new_cache();
552        let logits_q = q_model.forward(&prompt, &mut q_cache).unwrap().to_vec_f32().unwrap();
553
554        assert_eq!(logits_f32.len(), logits_q.len());
555        for (f32_v, q_v) in logits_f32.iter().zip(&logits_q) {
556            let scale = f32_v.abs().max(1.0);
557            assert!(
558                (f32_v - q_v).abs() / scale < 1.0,
559                "quantized ({q_v}) and f32 ({f32_v}) logits diverged past a sane bound"
560            );
561        }
562    }
563
564    // ---------------------------------------------------------------
565    // Benchmark: quantized weights vs. dequantize-to-f32.
566    // ---------------------------------------------------------------
567
568    /// Measures what Phase 2 actually bought, so that "faster" and "smaller"
569    /// are numbers rather than feelings.
570    ///
571    /// `#[ignore]`d because it is a measurement, not an assertion — it takes
572    /// seconds, and a wall-clock number is not a correctness property and must
573    /// never fail CI on a loaded machine. Run it deliberately:
574    ///
575    /// ```text
576    /// cargo test --release -p kopitiam-runtime bench_quantized -- --ignored --nocapture
577    /// ```
578    ///
579    /// # What this does and does not prove
580    ///
581    /// It compares a Q8_0-weighted model against the same model with its weights
582    /// dequantized to `f32`, on a synthetic 4-layer / 256-hidden toy. It is
583    /// therefore honest about *ratios* (memory, and whether the fused kernel is
584    /// in the right ballpark) and dishonest about *absolutes*: a 4-layer toy on
585    /// this desktop tells you nothing about a 7B model on a phone. The number
586    /// that actually matters is the memory one, because that is the difference
587    /// between the model fitting on the target device and not existing there at
588    /// all.
589    #[test]
590    #[ignore = "measurement, not an assertion; run deliberately with --ignored --nocapture"]
591    fn bench_quantized_vs_f32_weights() {
592        use std::time::Instant;
593
594        let spec = SyntheticModelSpec::quantized_benchmark();
595
596        let mut f32_spec = spec.clone();
597        f32_spec.quantize_matmul_weights = false;
598        let mut q_spec = spec.clone();
599        q_spec.quantize_matmul_weights = true;
600
601        let f32_bytes = build(&f32_spec);
602        let q_bytes = build(&q_spec);
603
604        let f32_model = load_from_spec(&f32_spec, "bench-f32");
605        let q_model = load_from_spec(&q_spec, "bench-q8");
606
607        // 32-token prefill, then 32 decode steps — the two phases that behave
608        // differently (prefill is compute-bound, decode is memory-bound).
609        let prompt: Vec<u32> = (0..32).map(|i| (i % 100) as u32).collect();
610
611        let run = |model: &QwenModel| -> (f64, f64) {
612            let mut cache = model.new_cache();
613            let t0 = Instant::now();
614            model.forward(&prompt, &mut cache).unwrap();
615            let prefill = t0.elapsed().as_secs_f64();
616
617            let t1 = Instant::now();
618            for i in 0..32u32 {
619                model.forward(&[i % 100], &mut cache).unwrap();
620            }
621            let decode = t1.elapsed().as_secs_f64();
622            (prefill, decode)
623        };
624
625        // Warm the caches/branch predictors once so the first model measured
626        // is not unfairly penalised.
627        run(&f32_model);
628        run(&q_model);
629
630        let (f32_prefill, f32_decode) = run(&f32_model);
631        let (q_prefill, q_decode) = run(&q_model);
632
633        println!();
634        println!("=== Kopitiam Runtime: quantized (Q8_0) vs f32 weights ===");
635        println!("model: {} layers, hidden {}, vocab {}", spec.n_layers, spec.hidden_size, spec.vocab_size);
636        println!();
637        println!("GGUF file size on disk");
638        println!("  f32 weights : {:>10} bytes", f32_bytes.len());
639        println!("  Q8_0 weights: {:>10} bytes  ({:.2}x smaller)",
640                 q_bytes.len(), f32_bytes.len() as f64 / q_bytes.len() as f64);
641        println!();
642        println!("prefill (32 tokens)");
643        println!("  f32 : {:>8.2} ms  ({:>7.1} tok/s)", f32_prefill * 1e3, 32.0 / f32_prefill);
644        println!("  Q8_0: {:>8.2} ms  ({:>7.1} tok/s)", q_prefill * 1e3, 32.0 / q_prefill);
645        println!();
646        println!("decode (32 steps, with KV cache)");
647        println!("  f32 : {:>8.2} ms  ({:>7.1} tok/s)", f32_decode * 1e3, 32.0 / f32_decode);
648        println!("  Q8_0: {:>8.2} ms  ({:>7.1} tok/s)", q_decode * 1e3, 32.0 / q_decode);
649        println!();
650        println!("NOTE: the memory ratio is the number that matters. A 7B Q4_0 model");
651        println!("dequantized to f32 is ~28GB and simply does not fit on a phone; kept");
652        println!("quantized it is ~4GB and does. Wall-clock on a 4-layer toy on this");
653        println!("desktop says little about a real model on the real target.");
654
655        // The one thing worth asserting: quantized weights really are smaller.
656        // That is a property, not a measurement, and it is the whole point.
657        assert!(q_bytes.len() < f32_bytes.len(), "quantized weights must be smaller on disk");
658    }
659}