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}