Skip to main content

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