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}