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    /// The KV-cache correctness property named explicitly in this crate's
177    /// task brief: decoding token-by-token *with* the cache must produce
178    /// bitwise-identical logits to a single forward pass over the whole
179    /// sequence *without* it (a fresh cache, called once with all tokens).
180    /// This single test is the one most likely to catch a KV-cache bug --
181    /// a wrong position offset, an off-by-one in the causal mask, a stale
182    /// or duplicated cache entry -- because any of those would perturb
183    /// *some* attention score and, without a cache-vs-no-cache oracle,
184    /// would otherwise only show up as "the model seems a bit off".
185    #[test]
186    fn decoding_with_a_kv_cache_matches_a_full_forward_pass_without_one_bit_for_bit() {
187        let model = load_tiny();
188        let tokens = [5u32, 12, 30, 2, 8];
189
190        // Reference: one forward call over the whole sequence, fresh cache.
191        let mut full_cache = model.new_cache();
192        let full_logits = model.forward(&tokens, &mut full_cache).unwrap().to_vec_f32().unwrap();
193
194        // Decode: one forward call per token, reusing one cache across calls.
195        let mut step_cache = model.new_cache();
196        let mut decoded_logits = Vec::new();
197        for &token in &tokens {
198            let step = model.forward(&[token], &mut step_cache).unwrap();
199            decoded_logits.extend(step.to_vec_f32().unwrap());
200        }
201
202        assert_eq!(full_cache.len(), step_cache.len());
203        assert_eq!(
204            full_logits.len(),
205            decoded_logits.len(),
206            "full-forward and step-by-step decode must produce the same number of logit values"
207        );
208        for (i, (full, decoded)) in full_logits.iter().zip(&decoded_logits).enumerate() {
209            assert_eq!(
210                full.to_bits(),
211                decoded.to_bits(),
212                "logit {i} differs between full-forward ({full}) and cached decode ({decoded}) -- \
213                 this is exactly the KV-cache bug this test exists to catch"
214            );
215        }
216    }
217
218    /// Guards against a *different* KV-cache bug than the equivalence test
219    /// above: rather than proving "matches a no-cache oracle", this proves
220    /// the cache actually accumulates rather than silently resetting or
221    /// overwriting -- if it did, `cache.len()` would not grow, and the
222    /// third decode step would attend over 1 cached position instead of 3.
223    #[test]
224    fn the_cache_length_grows_by_exactly_one_position_per_decode_step() {
225        let model = load_tiny();
226        let mut cache = model.new_cache();
227        for (step, &token) in [4u32, 9, 15].iter().enumerate() {
228            model.forward(&[token], &mut cache).unwrap();
229            assert_eq!(cache.len(), step + 1);
230        }
231    }
232
233    /// Real, full-size Qwen `.gguf` weights (as opposed to the vocab-only
234    /// fixture at `crates/kopitiam-ai/vendor/llama.cpp/models/`) were not
235    /// found anywhere on this machine when this test was written (`find ~
236    /// -name "*.gguf" -size +100M` and a broader filesystem search both
237    /// came back empty). This test is `#[ignore]`d rather than deleted so
238    /// it is ready to run the moment a real model is placed at the path
239    /// below, without anyone having to reconstruct what "load and greedily
240    /// generate a few tokens" should look like from scratch.
241    #[test]
242    #[ignore = "no real Qwen GGUF present on this machine; point REAL_QWEN_GGUF_PATH at one to run this"]
243    fn a_real_model_on_disk_is_used_if_present() {
244        let path = std::env::var("REAL_QWEN_GGUF_PATH").expect("set REAL_QWEN_GGUF_PATH to a real Qwen .gguf file");
245        let loaded = kopitiam_loader::load_model(path).unwrap();
246        let model = QwenModel::from_loaded_model(&loaded).unwrap();
247        let mut cache = model.new_cache();
248
249        // A handful of arbitrary, in-range token ids stands in for a real
250        // prompt: this test's purpose is proving the pipeline runs on real
251        // weights, not testing tokenizer round-tripping (see
252        // `crate::gguf_tokenizer` for that).
253        let prompt = [1u32, 2, 3, 4];
254        let mut logits = model.forward(&prompt, &mut cache).unwrap().to_vec_f32().unwrap();
255        for _ in 0..5 {
256            let next = greedy_argmax(&logits[logits.len() - model.vocab_size()..]);
257            let step = model.forward(&[next], &mut cache).unwrap();
258            logits = step.to_vec_f32().unwrap();
259        }
260    }
261
262    #[test]
263    fn dtype_matches_and_config_is_reachable() {
264        let model = load_tiny();
265        assert_eq!(model.weights.token_embd.dtype(), DType::F32);
266        assert_eq!(model.config().n_layers, 2);
267        assert_eq!(model.backend().device(), kopitiam_core::Device::Cpu);
268    }
269
270    // -- Quantized-weight wiring: the Phase 2 "the fast path is actually
271    // -- reachable end to end, not just a library function nobody calls" gate.
272
273    /// A model whose GGUF ships every matmul-operand weight as real `Q8_0`
274    /// bytes must load them still `Q8_0` (see `crate::weights::ModelWeights`'s
275    /// and `crate::bridge::load_matmul_weight`'s docs) and run a forward
276    /// pass end to end through `crate::linear::linear`'s dtype dispatch,
277    /// producing finite, correctly-shaped logits.
278    #[test]
279    fn quantized_matmul_weights_load_natively_and_produce_finite_logits() {
280        // tie_embeddings: false, so a separate (quantized) output.weight
281        // is actually written -- otherwise it would fall back to a clone
282        // of the (always-f32) token embedding table and this test would
283        // not exercise the output projection's quantized path at all.
284        let spec = SyntheticModelSpec {
285            quantize_matmul_weights: true,
286            tie_embeddings: false,
287            ..SyntheticModelSpec::quantized_benchmark()
288        };
289        let model = load_from_spec(&spec, "model-quantized");
290
291        assert_eq!(model.weights.layers[0].wq.dtype(), DType::Q8_0);
292        assert_eq!(model.weights.output_weight.dtype(), DType::Q8_0);
293        // Embeddings are never quantized in this scope (gather_rows needs
294        // f32 elementwise access) -- see `crate::bridge::load_matmul_weight`.
295        assert_eq!(model.weights.token_embd.dtype(), DType::F32);
296
297        let mut cache = model.new_cache();
298        let prompt = [3u32, 7, 1, 22, 9];
299        let logits = model.forward(&prompt, &mut cache).unwrap();
300        assert_eq!(logits.shape().dims(), &[prompt.len(), model.vocab_size()]);
301        assert!(logits.to_vec_f32().unwrap().iter().all(|v| v.is_finite()));
302    }
303
304    /// Isolates quantization error from every other source of divergence:
305    /// both specs below share one `Xorshift64` stream consumed in the same
306    /// call order (see `synthetic_gguf::build`'s docs), so the *only*
307    /// difference between the two resulting models is whether each
308    /// matmul-operand weight got rounded to `Q8_0` on the way in. This is
309    /// not a tight bound (four transformer layers of RMSNorm/softmax/SiLU
310    /// nonlinearity compound Q8_0's ~1/127 per-element rounding error
311    /// considerably, and the weights themselves are quantized here, unlike
312    /// `kopitiam-tensor`'s tighter kernel-level gate which isolates
313    /// activation-only error) -- but it is tight enough that a real wiring
314    /// bug (a transposed index, a forgotten scale multiply, reading the
315    /// wrong block) would blow it up by orders of magnitude and get caught
316    /// here.
317    #[test]
318    fn quantized_and_f32_weights_of_the_same_underlying_values_produce_similar_logits() {
319        let f32_spec = SyntheticModelSpec::quantized_benchmark();
320        let q_spec = SyntheticModelSpec { quantize_matmul_weights: true, ..SyntheticModelSpec::quantized_benchmark() };
321
322        let f32_model = load_from_spec(&f32_spec, "model-q-cmp-f32");
323        let q_model = load_from_spec(&q_spec, "model-q-cmp-q8");
324
325        let prompt = [3u32, 7, 1, 22, 9];
326        let mut f32_cache = f32_model.new_cache();
327        let logits_f32 = f32_model.forward(&prompt, &mut f32_cache).unwrap().to_vec_f32().unwrap();
328        let mut q_cache = q_model.new_cache();
329        let logits_q = q_model.forward(&prompt, &mut q_cache).unwrap().to_vec_f32().unwrap();
330
331        assert_eq!(logits_f32.len(), logits_q.len());
332        for (f32_v, q_v) in logits_f32.iter().zip(&logits_q) {
333            let scale = f32_v.abs().max(1.0);
334            assert!(
335                (f32_v - q_v).abs() / scale < 1.0,
336                "quantized ({q_v}) and f32 ({f32_v}) logits diverged past a sane bound"
337            );
338        }
339    }
340
341    // ---------------------------------------------------------------
342    // Benchmark: quantized weights vs. dequantize-to-f32.
343    // ---------------------------------------------------------------
344
345    /// Measures what Phase 2 actually bought, so that "faster" and "smaller"
346    /// are numbers rather than feelings.
347    ///
348    /// `#[ignore]`d because it is a measurement, not an assertion — it takes
349    /// seconds, and a wall-clock number is not a correctness property and must
350    /// never fail CI on a loaded machine. Run it deliberately:
351    ///
352    /// ```text
353    /// cargo test --release -p kopitiam-runtime bench_quantized -- --ignored --nocapture
354    /// ```
355    ///
356    /// # What this does and does not prove
357    ///
358    /// It compares a Q8_0-weighted model against the same model with its weights
359    /// dequantized to `f32`, on a synthetic 4-layer / 256-hidden toy. It is
360    /// therefore honest about *ratios* (memory, and whether the fused kernel is
361    /// in the right ballpark) and dishonest about *absolutes*: a 4-layer toy on
362    /// this desktop tells you nothing about a 7B model on a phone. The number
363    /// that actually matters is the memory one, because that is the difference
364    /// between the model fitting on the target device and not existing there at
365    /// all.
366    #[test]
367    #[ignore = "measurement, not an assertion; run deliberately with --ignored --nocapture"]
368    fn bench_quantized_vs_f32_weights() {
369        use std::time::Instant;
370
371        let spec = SyntheticModelSpec::quantized_benchmark();
372
373        let mut f32_spec = spec.clone();
374        f32_spec.quantize_matmul_weights = false;
375        let mut q_spec = spec.clone();
376        q_spec.quantize_matmul_weights = true;
377
378        let f32_bytes = build(&f32_spec);
379        let q_bytes = build(&q_spec);
380
381        let f32_model = load_from_spec(&f32_spec, "bench-f32");
382        let q_model = load_from_spec(&q_spec, "bench-q8");
383
384        // 32-token prefill, then 32 decode steps — the two phases that behave
385        // differently (prefill is compute-bound, decode is memory-bound).
386        let prompt: Vec<u32> = (0..32).map(|i| (i % 100) as u32).collect();
387
388        let run = |model: &QwenModel| -> (f64, f64) {
389            let mut cache = model.new_cache();
390            let t0 = Instant::now();
391            model.forward(&prompt, &mut cache).unwrap();
392            let prefill = t0.elapsed().as_secs_f64();
393
394            let t1 = Instant::now();
395            for i in 0..32u32 {
396                model.forward(&[i % 100], &mut cache).unwrap();
397            }
398            let decode = t1.elapsed().as_secs_f64();
399            (prefill, decode)
400        };
401
402        // Warm the caches/branch predictors once so the first model measured
403        // is not unfairly penalised.
404        run(&f32_model);
405        run(&q_model);
406
407        let (f32_prefill, f32_decode) = run(&f32_model);
408        let (q_prefill, q_decode) = run(&q_model);
409
410        println!();
411        println!("=== Kopitiam Runtime: quantized (Q8_0) vs f32 weights ===");
412        println!("model: {} layers, hidden {}, vocab {}", spec.n_layers, spec.hidden_size, spec.vocab_size);
413        println!();
414        println!("GGUF file size on disk");
415        println!("  f32 weights : {:>10} bytes", f32_bytes.len());
416        println!("  Q8_0 weights: {:>10} bytes  ({:.2}x smaller)",
417                 q_bytes.len(), f32_bytes.len() as f64 / q_bytes.len() as f64);
418        println!();
419        println!("prefill (32 tokens)");
420        println!("  f32 : {:>8.2} ms  ({:>7.1} tok/s)", f32_prefill * 1e3, 32.0 / f32_prefill);
421        println!("  Q8_0: {:>8.2} ms  ({:>7.1} tok/s)", q_prefill * 1e3, 32.0 / q_prefill);
422        println!();
423        println!("decode (32 steps, with KV cache)");
424        println!("  f32 : {:>8.2} ms  ({:>7.1} tok/s)", f32_decode * 1e3, 32.0 / f32_decode);
425        println!("  Q8_0: {:>8.2} ms  ({:>7.1} tok/s)", q_decode * 1e3, 32.0 / q_decode);
426        println!();
427        println!("NOTE: the memory ratio is the number that matters. A 7B Q4_0 model");
428        println!("dequantized to f32 is ~28GB and simply does not fit on a phone; kept");
429        println!("quantized it is ~4GB and does. Wall-clock on a 4-layer toy on this");
430        println!("desktop says little about a real model on the real target.");
431
432        // The one thing worth asserting: quantized weights really are smaller.
433        // That is a property, not a measurement, and it is the whole point.
434        assert!(q_bytes.len() < f32_bytes.len(), "quantized weights must be smaller on disk");
435    }
436}