1use 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
18pub struct QwenModel {
20 config: QwenConfig,
21 weights: ModelWeights,
22 rope: RotaryEmbedding,
23 backend: CpuBackend,
24}
25
26impl QwenModel {
27 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 let mut x = self.weights.token_embd.gather_rows(&ids_tensor)?;
55
56 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 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 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 #[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 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 #[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 #[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 let mut full_cache = model.new_cache();
192 let full_logits = model.forward(&tokens, &mut full_cache).unwrap().to_vec_f32().unwrap();
193
194 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 #[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 #[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 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 #[test]
279 fn quantized_matmul_weights_load_natively_and_produce_finite_logits() {
280 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 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 #[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 #[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 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 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 assert!(q_bytes.len() < f32_bytes.len(), "quantized weights must be smaller on disk");
435 }
436}