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}
25
26impl QwenModel {
27    /// Builds a [`QwenModel`] from an already-parsed [`LoadedModel`]
28    /// (`kopitiam_loader::load_model`'s result): resolves
29    /// [`QwenConfig::from_metadata`], loads and dequantizes every weight
30    /// tensor via [`crate::weights::ModelWeights::load`], and precomputes
31    /// RoPE's rotation tables up to the model's context window.
32    pub fn from_loaded_model(model: &LoadedModel) -> Result<Self> {
33        let config = QwenConfig::from_metadata(model.metadata())?;
34        let weights = ModelWeights::load(model, &config)?;
35        let rope = RotaryEmbedding::new(config.rope_dimension_count, config.rope_theta, config.max_context);
36        Ok(Self { config, weights, rope, backend: CpuBackend })
37    }
38
39    pub fn config(&self) -> &QwenConfig {
40        &self.config
41    }
42
43    pub fn backend(&self) -> &CpuBackend {
44        &self.backend
45    }
46}
47
48impl Model for QwenModel {
49    fn forward(&self, token_ids: &[u32], cache: &mut KvCache) -> Result<Tensor> {
50        let ids: Vec<i32> = token_ids.iter().map(|&id| id as i32).collect();
51        let ids_tensor = Tensor::from_i32(ids, [token_ids.len()])?;
52
53        // [seq, hidden]: one embedding row per input token.
54        let mut x = self.weights.token_embd.gather_rows(&ids_tensor)?;
55
56        // Read once, before any layer's cache is touched by this call --
57        // see crate::attention::attention_forward's docs for why reading
58        // this per-layer instead (KvCache::len() reports layer 0's length
59        // specifically) is a real KV-cache correctness bug, not a harmless
60        // simplification.
61        let position_offset = cache.len();
62
63        for (layer_index, layer) in self.weights.layers.iter().enumerate() {
64            x = block_forward(
65                &x,
66                layer,
67                &self.rope,
68                self.config.n_heads,
69                self.config.n_kv_heads,
70                self.config.head_dim,
71                self.config.norm_eps,
72                layer_index,
73                position_offset,
74                cache,
75            )?;
76        }
77
78        let x = x.rms_norm(&self.weights.output_norm, self.config.norm_eps)?;
79        // logits = x @ output_weight^T -> [seq, vocab_size]. Goes through
80        // `linear` (not a bare `matmul`+`transpose`) so a quantized
81        // `output.weight` -- usually the single largest weight matrix in a
82        // Qwen checkpoint, see `crate::weights::ModelWeights`'s docs --
83        // gets the same `Tensor::quantized_matmul` fast path every other
84        // projection does, via `linear`'s dtype dispatch.
85        linear(&x, &self.weights.output_weight, None)
86    }
87
88    fn vocab_size(&self) -> usize {
89        self.config.vocab_size
90    }
91
92    fn max_context(&self) -> usize {
93        self.config.max_context
94    }
95
96    fn new_cache(&self) -> KvCache {
97        KvCache::new(self.config.n_layers, self.config.max_context)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::sampling::greedy_argmax;
105    use crate::test_support::synthetic_gguf::{build, tiny_model_bytes, write_temp_gguf, SyntheticModelSpec};
106    use crate::traits::Backend;
107    use kopitiam_core::DType;
108
109    fn load_tiny() -> QwenModel {
110        let bytes = tiny_model_bytes();
111        let path = write_temp_gguf(&bytes, "model-tiny");
112        let loaded = kopitiam_loader::load_model(&path).unwrap();
113        QwenModel::from_loaded_model(&loaded).unwrap()
114    }
115
116    /// Builds and loads a full `QwenModel` from `spec`, through the real
117    /// GGUF byte format end to end (write -> `kopitiam_loader::load_model`
118    /// -> `QwenModel::from_loaded_model`) — the same path
119    /// `kopitiam-ai`'s `LocalAdapter` uses on a real model file, just
120    /// pointed at a synthetic one.
121    fn load_from_spec(spec: &SyntheticModelSpec, disambiguator: &str) -> QwenModel {
122        let bytes = build(spec);
123        let path = write_temp_gguf(&bytes, disambiguator);
124        let loaded = kopitiam_loader::load_model(&path).unwrap();
125        QwenModel::from_loaded_model(&loaded).unwrap()
126    }
127
128    /// The end-to-end wiring test called for by this crate's task brief:
129    /// no real Qwen GGUF is present on this machine (see
130    /// `crate::model::tests::a_real_model_on_disk_is_used_if_present`,
131    /// which is `#[ignore]`d for exactly that reason), so this builds a
132    /// tiny-but-structurally-real synthetic GGUF (2 layers, GQA-shaped,
133    /// tied embeddings, random-but-fixed weights) and proves the whole
134    /// load -> forward -> logits pipeline runs and produces finite,
135    /// correctly-shaped output. It intentionally does not assert anything
136    /// about *which* tokens the random weights favor -- that would be
137    /// asserting a coincidence, not a property of the code.
138    #[test]
139    fn synthetic_model_forward_pass_runs_end_to_end_and_produces_finite_logits() {
140        let model = load_tiny();
141        let mut cache = model.new_cache();
142
143        let prompt = [3u32, 7, 1, 22];
144        let logits = model.forward(&prompt, &mut cache).unwrap();
145        assert_eq!(logits.shape().dims(), &[prompt.len(), model.vocab_size()]);
146        for v in logits.to_vec_f32().unwrap() {
147            assert!(v.is_finite(), "logits must be finite, got {v}");
148        }
149        assert_eq!(cache.len(), prompt.len());
150
151        // Greedy-decode one further step to prove sampling composes with a
152        // real forward pass end to end, per this crate's "greedy decode
153        // end-to-end" acceptance bar.
154        let next = greedy_argmax(&logits.to_vec_f32().unwrap()[(prompt.len() - 1) * model.vocab_size()..]);
155        assert!((next as usize) < model.vocab_size());
156    }
157
158    /// A model whose GGUF has no separate `output.weight` (tied
159    /// embeddings) must still produce logits -- the tied-embedding
160    /// fallback in `ModelWeights::load` must actually be wired into the
161    /// forward pass, not just present as an unused field.
162    #[test]
163    fn tied_embeddings_model_still_produces_logits() {
164        let spec = SyntheticModelSpec { tie_embeddings: true, ..SyntheticModelSpec::default() };
165        let bytes = build(&spec);
166        let path = write_temp_gguf(&bytes, "model-tied");
167        let loaded = kopitiam_loader::load_model(&path).unwrap();
168        let model = QwenModel::from_loaded_model(&loaded).unwrap();
169        let mut cache = model.new_cache();
170
171        let logits = model.forward(&[1, 2, 3], &mut cache).unwrap();
172        assert_eq!(logits.shape().dims(), &[3, spec.vocab_size]);
173        assert!(logits.to_vec_f32().unwrap().iter().all(|v| v.is_finite()));
174    }
175
176    /// SmolLM2-360M (`kopitiam-models`' current `DEFAULT_MODEL_ID`) is a
177    /// *LLaMA*-architecture GGUF, not the Qwen2 this runtime was first
178    /// written against. It differs in exactly three load-bearing ways, all
179    /// of which this test proves are handled end to end:
180    ///
181    /// 1. `general.architecture` is `"llama"`, so every hyperparameter KV is
182    ///    namespaced `llama.*` — the loader's arch dispatch must read the
183    ///    architecture string and resolve `llama.block_count`,
184    ///    `llama.rope.freq_base`, `llama.attention.layer_norm_rms_epsilon`,
185    ///    etc. from it (not from a hard-coded `qwen2.` prefix).
186    /// 2. It has no Q/K/V projection biases — the bias tensors are absent
187    ///    from the file and must load as `None`, not error or read garbage.
188    /// 3. It ties its LM head to the token embedding table — there is no
189    ///    separate `output.weight`, so the head must fall back to
190    ///    `token_embd`.
191    ///
192    /// A Qwen-assuming loader would break on all three (miss every
193    /// hyperparameter, fail to find `attn_q.bias`, or fail to find
194    /// `output.weight`). This asserts each is resolved and that a full
195    /// forward pass then produces finite, correctly-shaped logits.
196    #[test]
197    fn llama_arch_smollm2_shaped_model_loads_and_runs_end_to_end() {
198        let spec = SyntheticModelSpec::llama_tied_no_bias();
199        let bytes = build(&spec);
200        let path = write_temp_gguf(&bytes, "model-llama-smollm2");
201        let loaded = kopitiam_loader::load_model(&path).unwrap();
202
203        // (1) Arch dispatch: the loader read `general.architecture` and then
204        // resolved every `llama.*`-namespaced hyperparameter through it --
205        // counts/sizes *and* the rope/norm keys under deeper sub-namespaces.
206        assert_eq!(loaded.metadata().architecture.as_deref(), Some("llama"));
207        assert_eq!(loaded.metadata().n_layers, Some(spec.n_layers as u64));
208        assert_eq!(loaded.metadata().n_heads, Some(spec.n_heads as u64));
209        assert_eq!(loaded.metadata().rope_theta, Some(spec.rope_theta));
210
211        let model = QwenModel::from_loaded_model(&loaded).unwrap();
212        // The rope/norm values really flowed through from `llama.*` keys
213        // into the resolved config (not silent defaults -- both differ from
214        // the 10000.0 / 1e-6 fallbacks `QwenConfig` would use if the keys
215        // had not resolved).
216        assert_eq!(model.config().rope_theta, 100_000.0);
217        assert_eq!(model.config().norm_eps, 1e-5);
218
219        // (2) LLaMA has no QKV bias: every layer's Q/K/V bias must be absent.
220        for (i, layer) in model.weights.layers.iter().enumerate() {
221            assert!(
222                layer.bq.is_none() && layer.bk.is_none() && layer.bv.is_none(),
223                "layer {i}: a LLaMA-arch model must load with no Q/K/V bias"
224            );
225        }
226
227        // (3) Tied embeddings: no separate output.weight in the file, so the
228        // LM head is the token embedding table itself.
229        assert!(loaded.tensor("output.weight").is_none(), "SmolLM2 ties its LM head; no output.weight should exist");
230        assert_eq!(
231            model.weights.output_weight.to_vec_f32().unwrap(),
232            model.weights.token_embd.to_vec_f32().unwrap(),
233            "a tied LM head must equal the token embedding table"
234        );
235
236        // (4) A full forward pass produces finite, correctly-shaped logits,
237        // and greedy sampling composes with it -- the same acceptance bar
238        // the Qwen end-to-end test holds, now on the LLaMA path.
239        let mut cache = model.new_cache();
240        let prompt = [2u32, 5, 9, 1];
241        let logits = model.forward(&prompt, &mut cache).unwrap();
242        assert_eq!(logits.shape().dims(), &[prompt.len(), model.vocab_size()]);
243        assert!(logits.to_vec_f32().unwrap().iter().all(|v| v.is_finite()), "LLaMA-path logits must be finite");
244        let next = greedy_argmax(&logits.to_vec_f32().unwrap()[(prompt.len() - 1) * model.vocab_size()..]);
245        assert!((next as usize) < model.vocab_size());
246    }
247
248    /// The architecture string is *only* a metadata-routing key: it selects
249    /// which `<arch>.*` hyperparameter names the loader reads, and nothing in
250    /// the forward pass branches on it. Two models identical in every value
251    /// but their `general.architecture` (`"llama"` vs `"qwen2"`) -- both
252    /// bias-free and both tied, so the tensor sets are byte-for-byte the same
253    /// -- must therefore produce bit-identical logits. This is what "add the
254    /// LLaMA path without disturbing the Qwen path" means concretely: there
255    /// is one forward pass, selected by which tensors are present, not by a
256    /// hard-coded architecture branch that could drift between the two.
257    #[test]
258    fn llama_and_qwen2_arch_with_identical_weights_produce_bit_identical_logits() {
259        let base = SyntheticModelSpec { with_qkv_bias: false, tie_embeddings: true, ..SyntheticModelSpec::default() };
260        let llama = SyntheticModelSpec { architecture: "llama", ..base.clone() };
261        let qwen2 = SyntheticModelSpec { architecture: "qwen2", ..base };
262
263        let m_llama = load_from_spec(&llama, "arch-eq-llama");
264        let m_qwen2 = load_from_spec(&qwen2, "arch-eq-qwen2");
265
266        let prompt = [1u32, 4, 2, 7];
267        let mut c_llama = m_llama.new_cache();
268        let mut c_qwen2 = m_qwen2.new_cache();
269        let l_llama = m_llama.forward(&prompt, &mut c_llama).unwrap().to_vec_f32().unwrap();
270        let l_qwen2 = m_qwen2.forward(&prompt, &mut c_qwen2).unwrap().to_vec_f32().unwrap();
271
272        assert_eq!(l_llama.len(), l_qwen2.len());
273        for (i, (a, b)) in l_llama.iter().zip(&l_qwen2).enumerate() {
274            assert_eq!(
275                a.to_bits(),
276                b.to_bits(),
277                "logit {i} differs across architecture string ({a} vs {b}) -- the arch string must not affect compute"
278            );
279        }
280    }
281
282    /// The KV-cache correctness property named explicitly in this crate's
283    /// task brief: decoding token-by-token *with* the cache must produce
284    /// bitwise-identical logits to a single forward pass over the whole
285    /// sequence *without* it (a fresh cache, called once with all tokens).
286    /// This single test is the one most likely to catch a KV-cache bug --
287    /// a wrong position offset, an off-by-one in the causal mask, a stale
288    /// or duplicated cache entry -- because any of those would perturb
289    /// *some* attention score and, without a cache-vs-no-cache oracle,
290    /// would otherwise only show up as "the model seems a bit off".
291    #[test]
292    fn decoding_with_a_kv_cache_matches_a_full_forward_pass_without_one_bit_for_bit() {
293        let model = load_tiny();
294        let tokens = [5u32, 12, 30, 2, 8];
295
296        // Reference: one forward call over the whole sequence, fresh cache.
297        let mut full_cache = model.new_cache();
298        let full_logits = model.forward(&tokens, &mut full_cache).unwrap().to_vec_f32().unwrap();
299
300        // Decode: one forward call per token, reusing one cache across calls.
301        let mut step_cache = model.new_cache();
302        let mut decoded_logits = Vec::new();
303        for &token in &tokens {
304            let step = model.forward(&[token], &mut step_cache).unwrap();
305            decoded_logits.extend(step.to_vec_f32().unwrap());
306        }
307
308        assert_eq!(full_cache.len(), step_cache.len());
309        assert_eq!(
310            full_logits.len(),
311            decoded_logits.len(),
312            "full-forward and step-by-step decode must produce the same number of logit values"
313        );
314        for (i, (full, decoded)) in full_logits.iter().zip(&decoded_logits).enumerate() {
315            assert_eq!(
316                full.to_bits(),
317                decoded.to_bits(),
318                "logit {i} differs between full-forward ({full}) and cached decode ({decoded}) -- \
319                 this is exactly the KV-cache bug this test exists to catch"
320            );
321        }
322    }
323
324    /// Guards against a *different* KV-cache bug than the equivalence test
325    /// above: rather than proving "matches a no-cache oracle", this proves
326    /// the cache actually accumulates rather than silently resetting or
327    /// overwriting -- if it did, `cache.len()` would not grow, and the
328    /// third decode step would attend over 1 cached position instead of 3.
329    #[test]
330    fn the_cache_length_grows_by_exactly_one_position_per_decode_step() {
331        let model = load_tiny();
332        let mut cache = model.new_cache();
333        for (step, &token) in [4u32, 9, 15].iter().enumerate() {
334            model.forward(&[token], &mut cache).unwrap();
335            assert_eq!(cache.len(), step + 1);
336        }
337    }
338
339    /// Real, full-size Qwen `.gguf` weights (as opposed to the vocab-only
340    /// fixture at `crates/kopitiam-ai/vendor/llama.cpp/models/`) were not
341    /// found anywhere on this machine when this test was written (`find ~
342    /// -name "*.gguf" -size +100M` and a broader filesystem search both
343    /// came back empty). This test is `#[ignore]`d rather than deleted so
344    /// it is ready to run the moment a real model is placed at the path
345    /// below, without anyone having to reconstruct what "load and greedily
346    /// generate a few tokens" should look like from scratch.
347    #[test]
348    #[ignore = "no real Qwen GGUF present on this machine; point REAL_QWEN_GGUF_PATH at one to run this"]
349    fn a_real_model_on_disk_is_used_if_present() {
350        let path = std::env::var("REAL_QWEN_GGUF_PATH").expect("set REAL_QWEN_GGUF_PATH to a real Qwen .gguf file");
351        let loaded = kopitiam_loader::load_model(path).unwrap();
352        let model = QwenModel::from_loaded_model(&loaded).unwrap();
353        let mut cache = model.new_cache();
354
355        // A handful of arbitrary, in-range token ids stands in for a real
356        // prompt: this test's purpose is proving the pipeline runs on real
357        // weights, not testing tokenizer round-tripping (see
358        // `crate::gguf_tokenizer` for that).
359        let prompt = [1u32, 2, 3, 4];
360        let mut logits = model.forward(&prompt, &mut cache).unwrap().to_vec_f32().unwrap();
361        for _ in 0..5 {
362            let next = greedy_argmax(&logits[logits.len() - model.vocab_size()..]);
363            let step = model.forward(&[next], &mut cache).unwrap();
364            logits = step.to_vec_f32().unwrap();
365        }
366    }
367
368    #[test]
369    fn dtype_matches_and_config_is_reachable() {
370        let model = load_tiny();
371        assert_eq!(model.weights.token_embd.dtype(), DType::F32);
372        assert_eq!(model.config().n_layers, 2);
373        assert_eq!(model.backend().device(), kopitiam_core::Device::Cpu);
374    }
375
376    // -- Quantized-weight wiring: the Phase 2 "the fast path is actually
377    // -- reachable end to end, not just a library function nobody calls" gate.
378
379    /// A model whose GGUF ships every matmul-operand weight as real `Q8_0`
380    /// bytes must load them still `Q8_0` (see `crate::weights::ModelWeights`'s
381    /// and `crate::bridge::load_matmul_weight`'s docs) and run a forward
382    /// pass end to end through `crate::linear::linear`'s dtype dispatch,
383    /// producing finite, correctly-shaped logits.
384    #[test]
385    fn quantized_matmul_weights_load_natively_and_produce_finite_logits() {
386        // tie_embeddings: false, so a separate (quantized) output.weight
387        // is actually written -- otherwise it would fall back to a clone
388        // of the (always-f32) token embedding table and this test would
389        // not exercise the output projection's quantized path at all.
390        let spec = SyntheticModelSpec {
391            quantize_matmul_weights: true,
392            tie_embeddings: false,
393            ..SyntheticModelSpec::quantized_benchmark()
394        };
395        let model = load_from_spec(&spec, "model-quantized");
396
397        assert_eq!(model.weights.layers[0].wq.dtype(), DType::Q8_0);
398        assert_eq!(model.weights.output_weight.dtype(), DType::Q8_0);
399        // Embeddings are never quantized in this scope (gather_rows needs
400        // f32 elementwise access) -- see `crate::bridge::load_matmul_weight`.
401        assert_eq!(model.weights.token_embd.dtype(), DType::F32);
402
403        let mut cache = model.new_cache();
404        let prompt = [3u32, 7, 1, 22, 9];
405        let logits = model.forward(&prompt, &mut cache).unwrap();
406        assert_eq!(logits.shape().dims(), &[prompt.len(), model.vocab_size()]);
407        assert!(logits.to_vec_f32().unwrap().iter().all(|v| v.is_finite()));
408    }
409
410    /// Isolates quantization error from every other source of divergence:
411    /// both specs below share one `Xorshift64` stream consumed in the same
412    /// call order (see `synthetic_gguf::build`'s docs), so the *only*
413    /// difference between the two resulting models is whether each
414    /// matmul-operand weight got rounded to `Q8_0` on the way in. This is
415    /// not a tight bound (four transformer layers of RMSNorm/softmax/SiLU
416    /// nonlinearity compound Q8_0's ~1/127 per-element rounding error
417    /// considerably, and the weights themselves are quantized here, unlike
418    /// `kopitiam-tensor`'s tighter kernel-level gate which isolates
419    /// activation-only error) -- but it is tight enough that a real wiring
420    /// bug (a transposed index, a forgotten scale multiply, reading the
421    /// wrong block) would blow it up by orders of magnitude and get caught
422    /// here.
423    #[test]
424    fn quantized_and_f32_weights_of_the_same_underlying_values_produce_similar_logits() {
425        let f32_spec = SyntheticModelSpec::quantized_benchmark();
426        let q_spec = SyntheticModelSpec { quantize_matmul_weights: true, ..SyntheticModelSpec::quantized_benchmark() };
427
428        let f32_model = load_from_spec(&f32_spec, "model-q-cmp-f32");
429        let q_model = load_from_spec(&q_spec, "model-q-cmp-q8");
430
431        let prompt = [3u32, 7, 1, 22, 9];
432        let mut f32_cache = f32_model.new_cache();
433        let logits_f32 = f32_model.forward(&prompt, &mut f32_cache).unwrap().to_vec_f32().unwrap();
434        let mut q_cache = q_model.new_cache();
435        let logits_q = q_model.forward(&prompt, &mut q_cache).unwrap().to_vec_f32().unwrap();
436
437        assert_eq!(logits_f32.len(), logits_q.len());
438        for (f32_v, q_v) in logits_f32.iter().zip(&logits_q) {
439            let scale = f32_v.abs().max(1.0);
440            assert!(
441                (f32_v - q_v).abs() / scale < 1.0,
442                "quantized ({q_v}) and f32 ({f32_v}) logits diverged past a sane bound"
443            );
444        }
445    }
446
447    // ---------------------------------------------------------------
448    // Benchmark: quantized weights vs. dequantize-to-f32.
449    // ---------------------------------------------------------------
450
451    /// Measures what Phase 2 actually bought, so that "faster" and "smaller"
452    /// are numbers rather than feelings.
453    ///
454    /// `#[ignore]`d because it is a measurement, not an assertion — it takes
455    /// seconds, and a wall-clock number is not a correctness property and must
456    /// never fail CI on a loaded machine. Run it deliberately:
457    ///
458    /// ```text
459    /// cargo test --release -p kopitiam-runtime bench_quantized -- --ignored --nocapture
460    /// ```
461    ///
462    /// # What this does and does not prove
463    ///
464    /// It compares a Q8_0-weighted model against the same model with its weights
465    /// dequantized to `f32`, on a synthetic 4-layer / 256-hidden toy. It is
466    /// therefore honest about *ratios* (memory, and whether the fused kernel is
467    /// in the right ballpark) and dishonest about *absolutes*: a 4-layer toy on
468    /// this desktop tells you nothing about a 7B model on a phone. The number
469    /// that actually matters is the memory one, because that is the difference
470    /// between the model fitting on the target device and not existing there at
471    /// all.
472    #[test]
473    #[ignore = "measurement, not an assertion; run deliberately with --ignored --nocapture"]
474    fn bench_quantized_vs_f32_weights() {
475        use std::time::Instant;
476
477        let spec = SyntheticModelSpec::quantized_benchmark();
478
479        let mut f32_spec = spec.clone();
480        f32_spec.quantize_matmul_weights = false;
481        let mut q_spec = spec.clone();
482        q_spec.quantize_matmul_weights = true;
483
484        let f32_bytes = build(&f32_spec);
485        let q_bytes = build(&q_spec);
486
487        let f32_model = load_from_spec(&f32_spec, "bench-f32");
488        let q_model = load_from_spec(&q_spec, "bench-q8");
489
490        // 32-token prefill, then 32 decode steps — the two phases that behave
491        // differently (prefill is compute-bound, decode is memory-bound).
492        let prompt: Vec<u32> = (0..32).map(|i| (i % 100) as u32).collect();
493
494        let run = |model: &QwenModel| -> (f64, f64) {
495            let mut cache = model.new_cache();
496            let t0 = Instant::now();
497            model.forward(&prompt, &mut cache).unwrap();
498            let prefill = t0.elapsed().as_secs_f64();
499
500            let t1 = Instant::now();
501            for i in 0..32u32 {
502                model.forward(&[i % 100], &mut cache).unwrap();
503            }
504            let decode = t1.elapsed().as_secs_f64();
505            (prefill, decode)
506        };
507
508        // Warm the caches/branch predictors once so the first model measured
509        // is not unfairly penalised.
510        run(&f32_model);
511        run(&q_model);
512
513        let (f32_prefill, f32_decode) = run(&f32_model);
514        let (q_prefill, q_decode) = run(&q_model);
515
516        println!();
517        println!("=== Kopitiam Runtime: quantized (Q8_0) vs f32 weights ===");
518        println!("model: {} layers, hidden {}, vocab {}", spec.n_layers, spec.hidden_size, spec.vocab_size);
519        println!();
520        println!("GGUF file size on disk");
521        println!("  f32 weights : {:>10} bytes", f32_bytes.len());
522        println!("  Q8_0 weights: {:>10} bytes  ({:.2}x smaller)",
523                 q_bytes.len(), f32_bytes.len() as f64 / q_bytes.len() as f64);
524        println!();
525        println!("prefill (32 tokens)");
526        println!("  f32 : {:>8.2} ms  ({:>7.1} tok/s)", f32_prefill * 1e3, 32.0 / f32_prefill);
527        println!("  Q8_0: {:>8.2} ms  ({:>7.1} tok/s)", q_prefill * 1e3, 32.0 / q_prefill);
528        println!();
529        println!("decode (32 steps, with KV cache)");
530        println!("  f32 : {:>8.2} ms  ({:>7.1} tok/s)", f32_decode * 1e3, 32.0 / f32_decode);
531        println!("  Q8_0: {:>8.2} ms  ({:>7.1} tok/s)", q_decode * 1e3, 32.0 / q_decode);
532        println!();
533        println!("NOTE: the memory ratio is the number that matters. A 7B Q4_0 model");
534        println!("dequantized to f32 is ~28GB and simply does not fit on a phone; kept");
535        println!("quantized it is ~4GB and does. Wall-clock on a 4-layer toy on this");
536        println!("desktop says little about a real model on the real target.");
537
538        // The one thing worth asserting: quantized weights really are smaller.
539        // That is a property, not a measurement, and it is the whole point.
540        assert!(q_bytes.len() < f32_bytes.len(), "quantized weights must be smaller on disk");
541    }
542}