ferrox_models/draft_model.rs
1//! A second, smaller GGUF used as the drafter for speculative decoding.
2//!
3//! [`crate::speculative`] already had the half that is hard to get
4//! right: the rejection rule, which makes speculation lossless at every
5//! temperature rather than only at `--temp 0`. What it did not have was
6//! a drafter worth running. The only implementation in the tree is
7//! [`crate::speculative::PromptLookupSpeculator`], an n-gram match over
8//! the history with no model at all. It is free, and it helps on
9//! repetitive text, and it cannot carry a coding workload.
10//!
11//! # Why this is the item that moves the ceiling
12//!
13//! Decode reads every weight in the model to emit one token, so
14//!
15//! ```text
16//! tokens/sec <= memory bandwidth / model bytes
17//! ```
18//!
19//! is arithmetic, not engineering. A 17 GB checkpoint on a 960 GB/s
20//! card cannot pass about 56 tok/s however good the kernels are. Better
21//! kernels move an engine toward that number; they cannot move it past.
22//!
23//! A draft model changes what is read per token instead of how fast it
24//! is read. A 2 GB drafter proposes `k` tokens, the target checks all
25//! `k` in ONE pass over its 17 GB, good guesses are kept and bad ones
26//! discarded, and the text is exactly what the target would have
27//! written alone.
28//!
29//! # The two things this has to get right
30//!
31//! **The draft KV must roll back.** While proposing, the drafter
32//! advances its own cache over tokens the target has not accepted and
33//! may never accept. If those rows are left in place, the drafter's
34//! context silently diverges from the target's. Nothing errors: the
35//! accept rate just decays, which reads as "this drafter is bad" rather
36//! than "this drafter is desynchronised". [`DraftModelSpeculator`]
37//! therefore truncates to `synced` at the top of every `propose`, and
38//! `synced` only ever counts tokens the caller's history actually
39//! contains.
40//!
41//! This is the repo's dominant bug shape in its usual dress: two
42//! structures that must agree about one thing, here the target's
43//! history and the drafter's cache, with nothing enforcing it. What
44//! enforces it is that `synced` is derived from the history passed in
45//! on every call rather than remembered independently, so the drafter
46//! cannot hold an opinion about the history that the history disagrees
47//! with.
48//!
49//! **The vocabularies must match.** See [`VocabMismatch`].
50
51use crate::config::ModelConfig;
52use crate::decoder::Decoder;
53use crate::penalty_window::PenaltyWindow;
54use crate::sampling::{sampling_distribution, Sampler, SamplingParams};
55use crate::speculative::{DraftBlock, DraftDist, Drafter};
56use ferrox_core::cache::KvCache;
57
58/// The draft and target checkpoints do not agree about token ids.
59///
60/// This is the failure that costs a day, because it does not look like
61/// a failure. The rejection rule compares the drafter's `q(x)` with the
62/// target's `p(x)` at the same index `x`. If the two checkpoints number
63/// their vocabularies differently, those are probabilities of different
64/// tokens, the rule is comparing unrelated numbers, and the output is
65/// no longer the target's distribution. What comes out is fluent text,
66/// with no error and a plausible-looking accept rate.
67///
68/// So it is refused at construction, which per this repo's rule is
69/// coverage rather than a defect: a partly-implemented thing must stop
70/// and say what is missing instead of computing something else.
71///
72/// Vocabulary size is checked first because it is cheap and catches
73/// most real mismatches (a 32000-token Llama drafter against a
74/// 152064-token Qwen target). Equal sizes do not imply equal
75/// vocabularies, though, so the caller that has both tokenizers should
76/// also compare them; `vocab_size` is what a `Decoder` alone can see.
77#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
78pub enum VocabMismatch {
79 #[error(
80 "draft and target checkpoints disagree about vocabulary size ({draft} vs {target}), so a \
81 draft token id does not name the same token in both. Speculative decoding compares the \
82 drafter's probability for a token id against the target's probability for that same id, \
83 which would be comparing unrelated tokens: the result would not be the target's \
84 distribution, and it would look exactly like text that is. Use a draft model from the \
85 same family and tokenizer as the target"
86 )]
87 Size { draft: usize, target: usize },
88}
89
90/// A [`Drafter`] backed by a second [`Decoder`].
91///
92/// Owns its own KV caches, entirely separate from the target's. The two
93/// models run over the same token sequence but have different layer
94/// counts, head counts and head dimensions, so nothing about the two
95/// caches is shared.
96pub struct DraftModelSpeculator {
97 decoder: Decoder,
98 kv_caches: Vec<KvCache>,
99 /// How many tokens of the caller's history this drafter's KV holds.
100 ///
101 /// Never an independent record of "what I have seen": it is
102 /// recomputed against the history handed to `propose`, so a
103 /// rejected block cannot leave it overstating what is committed.
104 synced: usize,
105 sampling: SamplingParams,
106 rng: Sampler,
107 /// Positions whose draft probability fell below this stop the
108 /// block. A drafter that is guessing is worse than no drafter: the
109 /// target pays for the position either way, and a rejection also
110 /// throws away every position after it.
111 min_prob: f32,
112 max_draft: usize,
113}
114
115impl DraftModelSpeculator {
116 /// True when this drafter's KV really lives in the host caches it
117 /// owns.
118 ///
119 /// A backend that keeps KV on the device leaves these at zero, and
120 /// a drafter cannot roll back rows it cannot see. Callers check
121 /// this after one warm-up rather than discovering it as a wrong
122 /// accept rate.
123 pub fn keeps_host_kv(&self) -> bool {
124 // ROWS: the question is whether K/V actually landed in the
125 // host buffer, which a device-resident backend leaves empty.
126 self.kv_caches.first().is_some_and(|c| c.rows() > 0)
127 }
128
129 /// How many tokens of history this drafter's KV currently holds.
130 /// Exposed so a caller can assert the drafter kept up.
131 pub fn synced_len(&self) -> usize {
132 self.synced
133 }
134
135 /// Fails when the two checkpoints cannot be compared token for
136 /// token. See [`VocabMismatch`].
137 /// `decoder` is taken by value and has its KV-window policy turned
138 /// OFF (#61). [`Self::sync`] rolls the draft cache back to an
139 /// arbitrary committed length -- potentially the whole history,
140 /// after a long run of rejections -- and a windowed cache cannot
141 /// represent a rollback past the rows it kept. The drafter is a
142 /// small model whose KV is a small fraction of the target's, so
143 /// this gives up almost none of the saving.
144 pub fn new(
145 mut decoder: Decoder,
146 target_config: &ModelConfig,
147 sampling: SamplingParams,
148 seed: u64,
149 max_draft: usize,
150 min_prob: f32,
151 ) -> Result<Self, VocabMismatch> {
152 decoder.kv_window = crate::decoder::KvWindowPolicy::off();
153 let draft_vocab = decoder.config.vocab_size;
154 let target_vocab = target_config.vocab_size;
155 if draft_vocab != target_vocab {
156 return Err(VocabMismatch::Size {
157 draft: draft_vocab,
158 target: target_vocab,
159 });
160 }
161 let kv_caches = (0..decoder.config.n_layers)
162 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
163 .collect();
164 Ok(DraftModelSpeculator {
165 decoder,
166 kv_caches,
167 synced: 0,
168 sampling,
169 rng: Sampler::new(seed),
170 min_prob,
171 max_draft,
172 })
173 }
174
175 /// Drops every cached row past `len`, on every layer.
176 fn truncate_to(&mut self, len: usize) {
177 for cache in &mut self.kv_caches {
178 cache.truncate(len);
179 }
180 }
181
182 /// Brings the draft cache up to `history` and returns the logits
183 /// that follow its last token.
184 ///
185 /// Feeds only the tokens the cache does not already hold, which is
186 /// what makes drafting cheap across a long conversation: the first
187 /// call pays for the prompt and every later call pays for the
188 /// handful of tokens the target committed since.
189 ///
190 /// The cache is rolled back to at most `history.len() - 1` rather
191 /// than `history.len()`, and that off-by-one is load-bearing. The
192 /// logits a block is drafted from are the ones that follow the last
193 /// committed token, and they exist only as the return value of the
194 /// forward pass that consumed it. Truncating to the full history
195 /// would leave nothing to feed, so there would be no logits to
196 /// draft from and every call after a rollback would propose
197 /// nothing: speculation would quietly stop happening while
198 /// remaining perfectly correct, which is the kind of failure that
199 /// shows up as a benchmark result months later.
200 ///
201 /// The cost is re-feeding exactly one token per call. That is one
202 /// step of the small model, against a block of them saved.
203 fn sync(&mut self, history: &[usize]) -> Vec<f32> {
204 debug_assert!(
205 !history.is_empty(),
206 "callers return early on an empty history"
207 );
208 // Everything past what the caller's history contains was
209 // drafted and not accepted. It is not context, it is a guess
210 // the target threw away.
211 // Derived from the CACHE, not only from `self.synced`. The
212 // cache is the authority on how many rows exist, and a backend
213 // that keeps its KV somewhere other than this host `KvCache`
214 // leaves it at zero however many tokens were fed. Trusting the
215 // counter there truncated to 7 rows of a cache holding 0 and
216 // panicked on a real Metal run. That is the same lesson as the
217 // batched prefill: read the cursor, do not keep a copy of it.
218 // ROWS, for the same reason: this bounds what can be kept by
219 // what is really there, not by what the counter believes.
220 let held = self.kv_caches.first().map_or(0, |c| c.rows());
221 let keep = self.synced.min(history.len() - 1).min(held);
222 self.truncate_to(keep);
223 self.synced = keep;
224
225 let mut logits = Vec::new();
226 while self.synced < history.len() {
227 let pos = self.synced;
228 logits = self
229 .decoder
230 .forward_token(history[pos], pos, &mut self.kv_caches);
231 self.synced += 1;
232 }
233 logits
234 }
235}
236
237impl Drafter for DraftModelSpeculator {
238 fn propose(&mut self, history: &[usize], _target_hidden: &[f32], max_len: usize) -> DraftBlock {
239 let budget = max_len.min(self.max_draft);
240 if budget == 0 || history.is_empty() {
241 return DraftBlock::empty();
242 }
243
244 let mut logits = self.sync(history);
245
246 let mut tokens = Vec::with_capacity(budget);
247 let mut dists = Vec::with_capacity(budget);
248
249 for _ in 0..budget {
250 // `history` is everything the target has committed --
251 // prompt included -- and `tokens` is what this block has
252 // proposed on top of it, so the drafter's penalties see the
253 // same sequence the target's would. The block used to clone
254 // `history` per call to concatenate the two; the window
255 // borrows both halves instead.
256 // The XTC roll comes off THIS drafter's own stream, so the
257 // distribution reported as `q` below is the one the token
258 // was actually drawn from even when XTC is configured.
259 let xtc_roll = self.rng.xtc_roll(&self.sampling);
260 let probs = sampling_distribution(
261 &logits,
262 &self.sampling,
263 PenaltyWindow::new(history, &tokens),
264 xtc_roll,
265 );
266 let token = self.rng.sample_from(&probs);
267
268 // `q` MUST be the distribution this token was actually
269 // sampled from, truncation and all, or the rejection rule
270 // is corrected against a lie. `sampling_distribution`
271 // returns exactly that, so it is what gets reported.
272 let dist = DraftDist::from_dense(&probs);
273 let q = dist.prob(token);
274 if q < self.min_prob {
275 // Stop before committing this token, so the cache is
276 // not advanced over a position nobody drafted.
277 break;
278 }
279
280 tokens.push(token);
281 dists.push(dist);
282
283 let pos = self.synced;
284 logits = self.decoder.forward_token(token, pos, &mut self.kv_caches);
285 self.synced += 1;
286 }
287
288 DraftBlock::new(tokens, dists)
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::config::test_dense_fixture;
296
297 fn drafter(vocab: usize, max_draft: usize, min_prob: f32) -> DraftModelSpeculator {
298 let target = {
299 let mut c = test_dense_fixture();
300 c.vocab_size = vocab;
301 c
302 };
303 let decoder = Decoder::new_random_small(test_dense_fixture(), 2, vocab);
304 DraftModelSpeculator::new(
305 decoder,
306 &target,
307 SamplingParams::default(),
308 7,
309 max_draft,
310 min_prob,
311 )
312 .expect("matching vocabularies")
313 }
314
315 /// A draft model whose vocabulary differs from the target's is
316 /// refused at construction, not accepted and corrected later.
317 ///
318 /// There is nothing to correct. The rejection rule compares the
319 /// drafter's probability for token id `x` against the target's
320 /// probability for token id `x`; if the two checkpoints number
321 /// their vocabularies differently those are different tokens, and
322 /// the output is no longer the target's distribution while looking
323 /// exactly like text that is. Fluent, plausible accept rate, no
324 /// error. That is the one failure this engine refuses to serve.
325 #[test]
326 fn a_draft_model_with_a_different_vocabulary_is_refused_by_name() {
327 let mut target = test_dense_fixture();
328 target.vocab_size = 64;
329 let decoder = Decoder::new_random_small(test_dense_fixture(), 2, 32);
330
331 let err = DraftModelSpeculator::new(decoder, &target, SamplingParams::default(), 0, 4, 0.0)
332 .err()
333 .expect("32 != 64");
334
335 assert_eq!(
336 err,
337 VocabMismatch::Size {
338 draft: 32,
339 target: 64
340 }
341 );
342 let msg = err.to_string();
343 // The message has to say both numbers and why it matters, or
344 // the next person reads it as an arbitrary compatibility rule
345 // and looks for a flag to turn it off.
346 assert!(msg.contains("32") && msg.contains("64"), "{msg}");
347 assert!(msg.contains("same family and tokenizer"), "{msg}");
348 }
349
350 /// The drafter proposes a block and reports one distribution per
351 /// token, which is what the rejection rule needs to run at all.
352 #[test]
353 fn a_block_carries_one_honest_distribution_per_drafted_token() {
354 let mut d = drafter(32, 4, 0.0);
355 let block = d.propose(&[1, 2, 3], &[], 4);
356
357 assert_eq!(block.len(), 4, "the whole budget was drafted");
358 assert_eq!(block.tokens().len(), block.dists().len());
359 for (token, dist) in block.tokens().iter().zip(block.dists()) {
360 // `q(x)` for the token actually sampled must be nonzero:
361 // the rule divides by it.
362 assert!(
363 dist.prob(*token) > 0.0,
364 "a drafter must report the distribution it sampled from"
365 );
366 }
367 }
368
369 /// **The rollback.** After a block is proposed, the drafter's cache
370 /// holds rows for tokens the target has not accepted. The next call
371 /// arrives with a history that does not contain them, and those
372 /// rows must be gone before anything else is fed.
373 ///
374 /// Left in place, the drafter's context silently diverges from the
375 /// target's: every later proposal is conditioned on tokens that
376 /// were thrown away. Nothing errors. The accept rate decays, which
377 /// reads as "this drafter is bad" rather than "this drafter is
378 /// desynchronised", and that is why this is asserted on the cache
379 /// length rather than on output quality.
380 #[test]
381 fn the_draft_cache_rolls_back_the_positions_the_target_did_not_accept() {
382 let mut d = drafter(32, 4, 0.0);
383
384 let block = d.propose(&[1, 2, 3], &[], 4);
385 assert_eq!(block.len(), 4);
386 assert_eq!(
387 d.synced, 7,
388 "3 of history plus 4 drafted are in the cache after proposing"
389 );
390
391 // The target accepted exactly one of them, so the caller's
392 // history grew by one, not by four.
393 d.propose(&[1, 2, 3, block.tokens()[0]], &[], 4);
394
395 assert_eq!(
396 d.kv_caches[0].positions(),
397 d.synced,
398 "every layer's cache agrees with the drafter's own count"
399 );
400 assert_eq!(
401 d.synced, 8,
402 "4 committed tokens plus 4 freshly drafted, NOT 7 stale rows plus more"
403 );
404 }
405
406 /// A history shorter than what the cache holds is a rollback too,
407 /// and the arithmetic must not underflow into a huge truncate.
408 #[test]
409 fn a_history_shorter_than_the_cache_truncates_rather_than_underflowing() {
410 let mut d = drafter(32, 4, 0.0);
411 d.propose(&[1, 2, 3, 4, 5], &[], 4);
412 assert_eq!(d.synced, 9);
413
414 d.propose(&[1, 2], &[], 1);
415 assert_eq!(d.synced, 3, "2 of history plus 1 drafted");
416 assert_eq!(d.kv_caches[0].positions(), 3);
417 }
418
419 /// Drafting stops when the drafter's own probability for the token
420 /// it just sampled falls below the floor.
421 ///
422 /// A guessing drafter is worse than none: the target pays for the
423 /// position either way, and a rejection also discards every
424 /// position after it. With the floor above 1.0 nothing can clear
425 /// it, so the block is empty and the caller falls back to one
426 /// ordinary decode step.
427 #[test]
428 fn a_drafter_below_the_probability_floor_proposes_nothing() {
429 let mut d = drafter(32, 4, 1.01);
430 let block = d.propose(&[1, 2, 3], &[], 4);
431 assert!(block.is_empty(), "nothing clears a floor above 1.0");
432 assert_eq!(
433 d.synced, 3,
434 "and the cache holds the history only, no abandoned draft rows"
435 );
436 }
437
438 /// `max_draft` is a ceiling the caller's budget cannot raise.
439 #[test]
440 fn the_configured_maximum_bounds_the_callers_budget() {
441 let mut d = drafter(32, 2, 0.0);
442 assert_eq!(d.propose(&[1, 2, 3], &[], 8).len(), 2);
443 }
444
445 /// An empty history has nothing to condition on, and a zero budget
446 /// asked for nothing. Both propose nothing rather than panicking.
447 #[test]
448 fn an_empty_history_or_a_zero_budget_proposes_nothing() {
449 let mut d = drafter(32, 4, 0.0);
450 assert!(d.propose(&[], &[], 4).is_empty());
451 assert!(d.propose(&[1, 2], &[], 0).is_empty());
452 }
453
454 /// **The property the whole feature exists to preserve.**
455 ///
456 /// A draft model is only worth having if the text is exactly what
457 /// the target would have written alone. At temperature 0 that is
458 /// checkable exactly: token for token against a plain
459 /// `forward_token` loop over an identically seeded target.
460 ///
461 /// This is the test that catches a desynchronised draft cache, a
462 /// dishonest `q`, or an off-by-one in the block, because all three
463 /// change the output rather than announcing themselves. A drafter
464 /// is allowed to be bad; it is not allowed to be consulted in a way
465 /// that changes the answer.
466 ///
467 /// The drafter here is a genuinely different model from the target
468 /// (a different random seed, half the layers), so it is wrong
469 /// often, which is exactly the case where rejection and rollback
470 /// have to work.
471 #[test]
472 fn a_draft_model_does_not_change_what_the_target_writes() {
473 use crate::speculative::speculative_decode;
474
475 let cfg = test_dense_fixture();
476 let vocab = 32;
477 let prompt = vec![1usize, 2, 3, 4, 1, 2];
478 let max_new = 8;
479
480 let target = Decoder::new_random_small(cfg.clone(), 4, vocab);
481 let mut caches: Vec<KvCache> = (0..target.config.n_layers)
482 .map(|_| KvCache::new(target.config.n_kv_heads, target.config.head_dim))
483 .collect();
484
485 // A different model, not a copy of the target: two layers
486 // rather than four, so it disagrees constantly.
487 let draft = Decoder::new_random_small(cfg.clone(), 2, vocab);
488 let mut drafter =
489 DraftModelSpeculator::new(draft, &target.config, SamplingParams::default(), 11, 4, 0.0)
490 .expect("matching vocabularies");
491
492 let result = speculative_decode(&target, &prompt, max_new, &mut caches, &mut drafter);
493
494 // The same target, decoded the ordinary way.
495 let plain = Decoder::new_random_small(cfg, 4, vocab);
496 let mut plain_caches: Vec<KvCache> = (0..plain.config.n_layers)
497 .map(|_| KvCache::new(plain.config.n_kv_heads, plain.config.head_dim))
498 .collect();
499 let mut pending = plain
500 .forward_batch(&prompt, 0, &mut plain_caches)
501 .pop()
502 .expect("a non-empty prompt returns logits");
503 let mut greedy = Vec::with_capacity(max_new);
504 for pos in (prompt.len()..).take(max_new) {
505 let tok = pending
506 .iter()
507 .enumerate()
508 .max_by(|a, b| a.1.partial_cmp(b.1).expect("logits are finite"))
509 .map(|(i, _)| i)
510 .expect("a non-empty vocabulary");
511 greedy.push(tok);
512 pending = plain.forward_token(tok, pos, &mut plain_caches);
513 }
514
515 assert_eq!(
516 result.generated_tokens, greedy,
517 "a draft model may make decoding faster and may not make it different"
518 );
519 }
520
521 /// **`q` must be the distribution the token was really sampled
522 /// from**, at every temperature.
523 ///
524 /// The rejection rule accepts with probability `min(1, p(x)/q(x))`
525 /// and resamples from `max(0, p - q)`. A drafter that overstates
526 /// its own confidence, reporting a point mass for a token it
527 /// actually drew from a spread distribution, makes `p/q` too small:
528 /// tokens get rejected that should have been accepted, and the
529 /// residual it resamples from is not the right residual. The output
530 /// stops being the target's distribution.
531 ///
532 /// This cannot be caught at temperature 0, where the true
533 /// distribution IS a point mass and a dishonest report is
534 /// accidentally correct. That is exactly why this test sets a
535 /// temperature: a suite that only checks greedy decoding will pass
536 /// a drafter that lies.
537 #[test]
538 fn the_reported_distribution_is_the_one_sampled_from_at_temperature() {
539 let target = {
540 let mut c = test_dense_fixture();
541 c.vocab_size = 32;
542 c
543 };
544 let decoder = Decoder::new_random_small(test_dense_fixture(), 2, 32);
545 let sampling = SamplingParams {
546 temperature: 1.0,
547 ..SamplingParams::default()
548 };
549 let mut d = DraftModelSpeculator::new(decoder, &target, sampling.clone(), 3, 4, 0.0)
550 .expect("matching vocabularies");
551
552 let block = d.propose(&[1, 2, 3], &[], 4);
553 assert_eq!(block.len(), 4);
554
555 let spread = block.dists().iter().any(|dist| dist.support().len() > 1);
556 assert!(
557 spread,
558 "at temperature 1.0 a real model's draft distribution is not a point mass; if it were, this test could not tell an honest report from a lie"
559 );
560
561 for (token, dist) in block.tokens().iter().zip(block.dists()) {
562 let q = dist.prob(*token);
563 assert!(q > 0.0, "the sampled token must be in its own support");
564 assert!(
565 q < 1.0,
566 "a spread distribution reported as certainty is the lie this test exists for"
567 );
568 let total: f32 = dist.support().iter().map(|&(_, p)| p).sum();
569 assert!(
570 (total - 1.0).abs() < 1e-4,
571 "a reported distribution must be normalised, got {total}"
572 );
573 }
574 }
575}