1use crate::units::BYTES_PER_GB;
4use crate::{options::Options, prompt::Prompt, qwen4_exp, tok};
5use anyhow::{Context, Result};
6use std::path::Path;
7
8mod decode;
9mod diagnostics;
10
11pub(crate) use decode::Decode;
12use diagnostics::{dump_decode, dump_prefill_chunk, dump_run, qwen4_exp_check_rows};
13
14pub fn run(model_dir: &Path, prompt: &str, options: &Options) -> Result<()> {
15 options.validate()?;
16
17 let (max_tokens, max_ctx, raw, check, repeat) = (
18 options.max_tokens,
19 options.max_ctx,
20 options.raw,
21 options.check,
22 options.repeat,
23 );
24 let t0 = std::time::Instant::now();
25 let mut model_options = options.clone();
26 let packed = qwen4_exp::packed::Packed::open(model_dir)?;
27
28 if packed.cfg.mtp_num_hidden_layers == 0 {
29 model_options.drafts = 0;
30 }
31
32 let options = &model_options;
33 let tok = tok::ChatTokenizer::load(model_dir)?;
34 let prompt = if raw {
35 Prompt::raw(prompt.to_owned())
36 } else {
37 tok.template
38 .as_ref()
39 .context("checkpoint has no chat template")?
40 .user(prompt)?
41 };
42 let ids = tok.encode(&prompt.text)?;
43
44 check_budget(ids.len(), max_tokens, options.effective_drafts(), max_ctx)?;
45
46 if options.cut_weak > 0.0 {
47 eprintln!(
48 "WARNING: --cut-weak skips late weak experts; output depends on disk timing and is not reproducible."
49 );
50 }
51
52 let mut gpu = qwen4_exp::gpu::Gpu::load(&packed, max_ctx, options)?;
53 let cpu = if check {
54 Some(qwen4_exp::cpu::CpuModel::load(&packed)?)
55 } else {
56 None
57 };
58
59 eprintln!(
60 "cherenkov gpu: max_ctx {max_ctx}, {:.2} GB Metal (expert pool {:.1} GB = {} records, working-set limit {:.2} GB), load {:.2}s, clock probe {:.2} ms",
61 gpu.allocated_gb(),
62 gpu.pool_bytes() as f64 / BYTES_PER_GB as f64,
63 gpu.pool_slots(),
64 gpu.working_set_limit_gb(),
65 t0.elapsed().as_secs_f64(),
66 gpu.throttle_ms()?
67 );
68
69 for run in 0..repeat {
70 if run > 0 {
71 gpu.reset();
72 eprintln!("--- repeat {run}: clock probe {:.2} ms", gpu.throttle_ms()?);
73 }
74
75 let mut cpu_state = cpu.as_ref().map(|m| m.new_state());
76
77 qwen4_exp_gen_once(
78 &mut gpu,
79 cpu.as_ref(),
80 cpu_state.as_mut(),
81 &tok,
82 &ids,
83 max_tokens,
84 options,
85 None,
86 &mut |token| {
87 use std::io::Write as _;
88
89 print!("{}", tok.decode(&[token])?);
90 std::io::stdout().flush()?;
91
92 Ok(())
93 },
94 )?;
95 println!();
96 }
97
98 eprintln!("clock probe at end {:.2} ms", gpu.throttle_ms()?);
99
100 dump_run(&gpu)
101}
102
103fn prefill_engine(
105 gpu: &mut qwen4_exp::gpu::Gpu<'_>,
106 cpu: Option<&qwen4_exp::cpu::CpuModel<'_>>,
107 mut cpu_state: Option<&mut qwen4_exp::cpu::State>,
108 ids: &[u32],
109 n_draft: usize,
110 pf_chunk: usize,
111 mut argmax_lines: Option<&mut Vec<String>>,
112) -> Result<PrefillResume> {
113 let (mut p, mut cur, mut drafts) = (0, ids[0], Vec::new());
114 let check = cpu.is_some();
115 let n_chunks = ids.len().div_ceil(pf_chunk);
118 let chunk_len = ids.len().div_ceil(n_chunks);
119 let mut d1 = 0u32;
120
121 while p < ids.len() {
122 let n = (ids.len() - p).min(chunk_len);
123 let chunk = &ids[p..p + n];
124 let next_after = ids.get(p + n).copied();
125 let (c, d) = gpu.prefill_chunk(chunk, next_after, check || argmax_lines.is_some())?;
126 cur = c;
127 d1 = d;
128
129 if let Some(lines) = argmax_lines.as_deref_mut() {
130 dump_prefill_chunk(gpu, true, p, n, ids.len(), lines);
131 }
132
133 if let (Some(m), Some(st)) = (cpu, cpu_state.as_deref_mut()) {
134 let mut next: Vec<u32> = chunk[1..].to_vec();
135
136 next.push(next_after.unwrap_or(cur));
137 qwen4_exp_check_rows(
138 m,
139 st,
140 gpu,
141 chunk,
142 gpu.has_mtp().then_some(&next[..]),
143 p,
144 "prefill",
145 true,
146 )?;
147 }
148
149 p += n;
150 }
151
152 if n_draft > 0 {
153 drafts = vec![d1];
154
155 if n_draft >= 2 {
156 drafts.push(gpu.mtp_chain(d1)?);
157 }
158 }
159
160 gpu.prefill_release();
161
162 let st = &gpu.prefill_stats;
163 let recs: usize = st.iter().map(|s| s.fetched).sum();
164 let sum = |f: fn(&qwen4_exp::gpu::prefill::ChunkStats) -> f64| st.iter().map(f).sum::<f64>();
165 let bytes: usize = st.iter().map(|s| s.fetched_bytes).sum();
166
167 eprintln!(
168 "prefill engine: {} chunk(s) of up to {pf_chunk}, {recs} expert records streamed ({:.1} GB, {:.0} MB/token), {:.1}s waiting for ring reuse | GPU s: DeltaNet blocks {:.1}, attention blocks {:.1}, expert streams {:.1}, MTP {:.1} | n-gram gather {:.1}s CPU",
169 st.len(),
170 bytes as f64 / BYTES_PER_GB as f64,
171 bytes as f64 / 1e6 / ids.len() as f64,
172 sum(|s| s.wait_s),
173 sum(|s| s.gpu_delta_s),
174 sum(|s| s.gpu_attn_s),
175 sum(|s| s.gpu_experts_s),
176 sum(|s| s.gpu_mtp_s),
177 sum(|s| s.ngram_s),
178 );
179
180 Ok(PrefillResume {
181 next: cur,
182 drafts,
183 logits: None,
184 })
185}
186
187fn prefill_row_batches(
189 gpu: &mut qwen4_exp::gpu::Gpu<'_>,
190 cpu: Option<&qwen4_exp::cpu::CpuModel<'_>>,
191 mut cpu_state: Option<&mut qwen4_exp::cpu::State>,
192 ids: &[u32],
193 n_draft: usize,
194 mut argmax_lines: Option<&mut Vec<String>>,
195) -> Result<PrefillResume> {
196 use qwen4_exp::gpu::MAX_NB;
197
198 let (mut p, mut cur, mut drafts) = (0, ids[0], Vec::new());
199 let rows_max: usize = std::env::var("CHERENKOV_ROWS_MAX")
201 .ok()
202 .and_then(|v| v.parse().ok())
203 .unwrap_or(MAX_NB)
204 .clamp(1, MAX_NB);
205
206 while p < ids.len() {
207 let n = (ids.len() - p).min(rows_max);
208 let rows = &ids[p..p + n];
209 let res = gpu.step_rows(rows, false, false)?;
210
211 if let Some(lines) = argmax_lines.as_deref_mut() {
212 dump_prefill_chunk(gpu, false, p, n, ids.len(), lines);
213 }
214
215 gpu.commit(n)?;
216
217 let last = p + n == ids.len();
218 let mut next: Vec<u32> = ids[p + 1..p + n].to_vec();
219
220 next.push(if last { res[n - 1] } else { ids[p + n] });
221
222 if n_draft > 0 {
223 drafts = gpu.mtp_draft(&next, if last { n_draft } else { 1 })?;
224 }
225
226 if let (Some(m), Some(st)) = (cpu, cpu_state.as_deref_mut()) {
227 qwen4_exp_check_rows(
228 m,
229 st,
230 gpu,
231 rows,
232 (n_draft > 0).then_some(&next[..]),
233 p,
234 "prefill",
235 false,
236 )?;
237 }
238
239 p += n;
240 cur = res[n - 1];
241 }
242
243 Ok(PrefillResume {
244 next: cur,
245 drafts,
246 logits: None,
247 })
248}
249
250fn prefill_prompt(
252 gpu: &mut qwen4_exp::gpu::Gpu<'_>,
253 cpu: Option<&qwen4_exp::cpu::CpuModel<'_>>,
254 cpu_state: Option<&mut qwen4_exp::cpu::State>,
255 ids: &[u32],
256 n_draft: usize,
257 resume: Option<PrefillResume>,
258) -> Result<PrefillResume> {
259 use qwen4_exp::gpu::MAX_NB;
260
261 let t1 = std::time::Instant::now();
264 let prepared = resume.is_some();
265 let mut seed = resume.unwrap_or_else(|| PrefillResume {
266 next: ids[0],
267 drafts: Vec::new(),
268 logits: None,
269 });
270 let check = cpu.is_some();
274 let pf_min: usize = std::env::var("CHERENKOV_PREFILL_MIN")
275 .ok()
276 .and_then(|v| v.parse().ok())
277 .unwrap_or(64);
278 let engine = !prepared && ids.len() >= pf_min;
279 let prefill_fit = if engine {
280 gpu.prefill_rows_fit(check || std::env::var_os("CHERENKOV_DUMP_ARGMAX").is_some())?
281 } else {
282 1
283 };
284 let mut pf_chunk: usize = std::env::var("CHERENKOV_PREFILL_CHUNK")
285 .ok()
286 .and_then(|v| v.parse().ok())
287 .unwrap_or(prefill_fit)
288 .min(prefill_fit)
289 .max(1);
290
291 if check {
292 pf_chunk = pf_chunk.min(1024);
293 }
294
295 let dump_argmax = std::env::var("CHERENKOV_DUMP_ARGMAX").ok();
298 let mut argmax_lines: Vec<String> = Vec::new();
299 let lines = dump_argmax.as_ref().map(|_| &mut argmax_lines);
300
301 if engine {
302 seed = prefill_engine(gpu, cpu, cpu_state, ids, n_draft, pf_chunk, lines)?;
303 } else if !prepared {
304 seed = prefill_row_batches(gpu, cpu, cpu_state, ids, n_draft, lines)?;
305 }
306
307 if let Some(path) = &dump_argmax {
308 std::fs::write(path, argmax_lines.join("\n") + "\n")?;
309 }
310
311 if let Ok(path) = std::env::var("CHERENKOV_DUMP_LOGITS") {
317 let last_rows = if engine {
318 1
319 } else {
320 ((ids.len() - 1) % MAX_NB) + 1
321 };
322 let l = gpu.logits_row(last_rows - 1);
323 let bytes: Vec<u8> = l.iter().flat_map(|v| v.to_le_bytes()).collect();
324
325 std::fs::write(&path, bytes)?;
326 }
327
328 let prefill = t1.elapsed().as_secs_f64();
329
330 if !prepared {
331 eprintln!(
332 "prefill {} tokens in {:.2}s ({:.1} tok/s){}",
333 ids.len(),
334 prefill,
335 ids.len() as f64 / prefill,
336 if engine {
337 " [engine]"
338 } else {
339 " [row batches]"
340 }
341 );
342 }
343
344 Ok(seed)
345}
346
347#[allow(clippy::too_many_arguments)]
348pub(crate) fn qwen4_exp_gen_once(
349 gpu: &mut qwen4_exp::gpu::Gpu<'_>,
350 cpu: Option<&qwen4_exp::cpu::CpuModel<'_>>,
351 mut cpu_state: Option<&mut qwen4_exp::cpu::State>,
352 tok: &tok::ChatTokenizer,
353 ids: &[u32],
354 max_tokens: usize,
355 options: &Options,
356 resume: Option<PrefillResume>,
357 emit: &mut dyn FnMut(u32) -> Result<()>,
358) -> Result<()> {
359 let n_draft = options.effective_drafts();
364
365 anyhow::ensure!(
366 n_draft == 0 || gpu.has_mtp(),
367 "drafting needs the checkpoint's MTP head"
368 );
369 let mut seed = prefill_prompt(gpu, cpu, cpu_state.as_deref_mut(), ids, n_draft, resume)?;
374
375 if !options.sampling.greedy() && seed.logits.is_none() {
376 seed.logits = Some(gpu.logits_row(gpu.last_logits_row()).to_vec());
377 }
378
379 let prefill_steps = gpu.step_ms.len();
380 let mut decoder = Decode::new(seed, ids, tok, options, max_tokens, None)?;
381 let t2 = std::time::Instant::now();
382
383 while decoder.finish_reason.is_none() {
384 decoder.step(gpu, cpu, cpu_state.as_deref_mut(), emit)?;
385 }
386
387 let steps = decoder.steps;
388 let accepted = decoder.accepted;
389 let out = decoder.tokens;
390 let decode = t2.elapsed().as_secs_f64();
391 let n = steps.max(1);
392 let recent = |v: &[f64]| -> Vec<f64> { v[v.len().saturating_sub(n)..].to_vec() };
393 let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len().max(1) as f64;
394 let ms = recent(&gpu.step_ms);
395 let gpu_recent = recent(&gpu.gpu_ms);
396 let io_recent = recent(&gpu.io_ms);
397 let set_recent = recent(&gpu.set_ms);
398 let read_recent = recent(&gpu.read_ms);
399 let idle_recent = recent(&gpu.gpu_idle_ms);
400 let mtp_recent = recent(&gpu.mtp_ms);
401 let ngram_recent = recent(&gpu.ngram_ms);
402 let miss_recent = &gpu.misses[gpu.misses.len().saturating_sub(n)..];
403 let la_recent = &gpu.lookahead_issued[gpu.lookahead_issued.len().saturating_sub(n)..];
404 let warm_recent = &gpu.warm[gpu.warm.len().saturating_sub(n)..];
405 let cut_recent = &gpu.cut[gpu.cut.len().saturating_sub(n)..];
406
407 eprintln!(
408 " dispatches/step {} | cpu turnaround {:.1} ms/step | drafts {n_draft}, accepted {:.2}/step ({:.2} tokens/step) | mtp {:.1} ms/step | n-gram gather {:.1} ms/step",
409 gpu.dispatches.last().copied().unwrap_or(0),
410 mean(&idle_recent),
411 accepted as f64 / n as f64,
412 (out.len()) as f64 / n as f64,
413 mean(&mtp_recent),
414 mean(&ngram_recent),
415 );
416 eprintln!(
417 "decode {} tokens in {:.2}s ({:.2} tok/s) | {} steps, mean step {:.1} ms (min {:.1}, max {:.1}) | gpu-span {:.1} ms | io wait {:.1} ms (set {:.1}, read {:.1}) | sync fetches/step {:.1} + lookahead {:.1} (warm {:.1}, cut {:.1}) | pool {}/{} | {:.2} GB Metal",
418 out.len(),
419 decode,
420 out.len() as f64 / decode.max(1e-9),
421 steps,
422 mean(&ms),
423 ms.iter().cloned().fold(f64::INFINITY, f64::min),
424 ms.iter().cloned().fold(0.0, f64::max),
425 mean(&gpu_recent),
426 mean(&io_recent),
427 mean(&set_recent),
428 mean(&read_recent),
429 miss_recent.iter().sum::<usize>() as f64 / miss_recent.len().max(1) as f64,
430 la_recent.iter().sum::<usize>() as f64 / la_recent.len().max(1) as f64,
431 warm_recent.iter().sum::<usize>() as f64 / warm_recent.len().max(1) as f64,
432 cut_recent.iter().sum::<usize>() as f64 / cut_recent.len().max(1) as f64,
433 gpu.pool_resident(),
434 gpu.pool_slots(),
435 gpu.allocated_gb()
436 );
437 eprintln!(
438 "memory_stats {}",
439 serde_json::to_string(&gpu.memory_stats())?
440 );
441 dump_decode(gpu, ids, &out, prefill_steps)?;
442
443 Ok(())
444}
445
446#[derive(Default)]
447pub(crate) struct PrefillResume {
448 pub next: u32,
449 pub drafts: Vec<u32>,
450 pub logits: Option<Vec<f32>>,
451}
452
453pub(crate) fn check_budget(
454 prompt: usize,
455 output: usize,
456 drafts: usize,
457 context: usize,
458) -> Result<()> {
459 anyhow::ensure!(prompt > 0, "prompt must contain at least one token");
460
461 let need = prompt
462 .checked_add(output)
463 .and_then(|n| n.checked_add(drafts))
464 .ok_or_else(|| anyhow::anyhow!("token budget overflows"))?;
465
466 anyhow::ensure!(
467 need <= context,
468 "prompt ({prompt}) + output ({output}) + draft lookahead ({drafts}) needs {need} tokens, exceeding --max-ctx {context}"
469 );
470
471 Ok(())
472}
473
474#[cfg(test)]
475#[path = "../tests/unit/runner.rs"]
476mod tests;