Skip to main content

ferrox_models/sampling/
rng.rs

1//! The seeded generator every draw in a generation comes off, and the
2//! entry points that use it.
3//!
4//! Split out of `sampling.rs` so the chain runner beside it stays about
5//! the chain. The RNG is one concept and a small one, but it is the
6//! concept the whole run's reproducibility rests on: a request that
7//! passed `seed: 42` must draw the same tokens on every machine, so
8//! every draw -- the token, speculative decoding's accept coin, and
9//! XTC's -- has to come off this one stream in this one order.
10
11use super::penalties::apply_history_penalties;
12use super::{filtered_distribution, greedy_choice, SamplingParams};
13use crate::penalty_window::PenaltyWindow;
14
15/// Sets logits a caller wants to forbid to `-inf`, in place, before the
16/// sampler looks at them.
17///
18/// Two callers today, and they COMPOSE rather than exclude each other --
19/// a masked logit stays masked, so the order they run in cannot matter:
20/// JSON-object mode's character-class filter
21/// (`ferrox_server::json_mode`), and grammar-constrained decoding
22/// ([`crate::grammar_sampler::GrammarSampler::mask_logits`]).
23///
24/// The signature returns nothing because the callback runs from inside
25/// the sampler, which has no error to return one through. A mask that
26/// CAN fail -- a grammar that dead-ends leaves every logit at `-inf`,
27/// and sampling from that is how an "impossible" request becomes
28/// arbitrary text with a 200 -- records its refusal in the closure's own
29/// captured state, and the decode loop reads it after the sample and
30/// throws the token away. `ferrox_server::sample_step::sample_next` is
31/// the one place that pairing lives.
32pub type LogitMask<'a> = &'a mut dyn FnMut(&mut [f32]);
33
34/// A small, seedable xorshift64* generator. Not cryptographically
35/// secure -- sampling doesn't need that -- but reproducible given a
36/// seed, which greedy argmax already was for free.
37pub struct Sampler {
38    state: u64,
39}
40
41impl Sampler {
42    pub fn new(seed: u64) -> Self {
43        // xorshift64* requires a nonzero seed.
44        Sampler {
45            state: if seed == 0 { 0x9E3779B97F4A7C15 } else { seed },
46        }
47    }
48
49    fn next_u64(&mut self) -> u64 {
50        self.state ^= self.state << 13;
51        self.state ^= self.state >> 7;
52        self.state ^= self.state << 17;
53        // The `*` in xorshift64*. Without it this is plain xorshift64,
54        // whose state IS its output, and a small seed's first output is
55        // therefore still small: for every seed below ~4000 the first
56        // draw landed in the bottom eighth of [0, 1), so a request that
57        // asked for `seed: 42` always got its first token from the
58        // bottom of the CDF. The multiply is what decorrelates the
59        // output from a low-entropy state; see
60        // `low_seeds_do_not_bias_the_first_draw`.
61        self.state.wrapping_mul(0x2545F491_4F6CDD1D)
62    }
63
64    /// Uniform float in [0.0, 1.0).
65    fn next_f32(&mut self) -> f32 {
66        (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
67    }
68
69    /// The one uniform draw XTC needs for this token, or `None` when XTC
70    /// cannot fire for these parameters.
71    ///
72    /// XTC is the only sampler in the chain that is itself stochastic
73    /// (`llama_sample_xtc_apply` draws from its own `std::mt19937`,
74    /// `src/llama-sampler.cpp:2146`), which is why the chain below takes
75    /// the roll as an argument instead of owning an RNG: the chain is
76    /// also what `sampling_distribution` runs for speculative
77    /// verification, and a filter that drew its own randomness there
78    /// would make "the distribution the sampler draws from" a different
79    /// distribution every time it was asked.
80    ///
81    /// **The draw is skipped when XTC cannot fire**, and that is not an
82    /// optimisation. Every draw advances the seeded stream, so drawing
83    /// unconditionally would shift every subsequent token of every
84    /// existing seeded generation, on every run that never asked for
85    /// XTC. [`SamplingParams::xtc_can_fire`] is the single predicate
86    /// this and [`Candidates::xtc`] share.
87    pub fn xtc_roll(&mut self, params: &SamplingParams) -> Option<f32> {
88        if params.xtc_can_fire() {
89            Some(self.next_f32())
90        } else {
91            None
92        }
93    }
94
95    /// Samples one token id from `logits`, given `params` and the
96    /// [`PenaltyWindow`] the penalties look back over. Falls back to
97    /// plain greedy argmax when `params.temperature <= 0.0`.
98    ///
99    /// `history` is a window and not a slice on purpose: it carries the
100    /// PROMPT as well as the generated tokens, which is what llama.cpp
101    /// penalises over. See [`crate::penalty_window`].
102    ///
103    /// A length-1 `logits` vector is treated as a precomputed greedy token
104    /// id (`logits[0] as usize`) — used by the Metal dense-stack path that
105    /// returns GPU argmax instead of downloading the full vocab.
106    pub fn sample(
107        &mut self,
108        logits: &[f32],
109        params: &SamplingParams,
110        history: PenaltyWindow<'_>,
111    ) -> usize {
112        self.sample_with_mask(logits, params, history, None)
113    }
114
115    /// Like [`Self::sample`], but optionally zeroes disallowed logits via
116    /// `mask` before argmax / nucleus sampling (used for JSON-object mode).
117    pub fn sample_with_mask(
118        &mut self,
119        logits: &[f32],
120        params: &SamplingParams,
121        history: PenaltyWindow<'_>,
122        mut mask: Option<LogitMask<'_>>,
123    ) -> usize {
124        let xtc_roll = self.xtc_roll(params);
125        if params.temperature <= 0.0 && mask.is_none() {
126            if logits.len() == 1 {
127                return logits[0] as usize;
128            }
129            let mut scores = logits.to_vec();
130            apply_history_penalties(&mut scores, params, history);
131            return greedy_choice(scores, params, history, xtc_roll);
132        }
133
134        let mut scores: Vec<f32> = logits.to_vec();
135        apply_history_penalties(&mut scores, params, history);
136
137        if let Some(m) = mask.as_mut() {
138            m(&mut scores);
139        }
140
141        if params.temperature <= 0.0 {
142            if scores.len() == 1 {
143                return scores[0] as usize;
144            }
145            return greedy_choice(scores, params, history, xtc_roll);
146        }
147
148        let probs = filtered_distribution(scores, params, history, xtc_roll);
149        self.sample_from(&probs)
150    }
151
152    /// A uniform draw in `[0.0, 1.0)`.
153    ///
154    /// Exposed because speculative decoding's accept test is a coin
155    /// flip against `p_target(x) / p_draft(x)` rather than a draw from
156    /// a distribution, and it must come off the same seeded stream as
157    /// every other draw in the run or a "reproducible given a seed"
158    /// generation stops being reproducible.
159    pub fn uniform(&mut self) -> f32 {
160        self.next_f32()
161    }
162
163    /// Draws one index from an already-normalised distribution.
164    ///
165    /// Split out of [`Self::sample_with_mask`] so speculative decoding
166    /// can sample from a distribution it had to compute anyway (the
167    /// rejection rule needs `p_target` itself, not just a draw from it)
168    /// and still go through *exactly* the same draw as ordinary
169    /// sampling. Two separate copies of this loop would be two chances
170    /// to be subtly non-lossless.
171    pub fn sample_from(&mut self, probs: &[f32]) -> usize {
172        let draw = self.next_f32();
173        let mut cumulative = 0.0f32;
174        for (i, &p) in probs.iter().enumerate() {
175            cumulative += p;
176            if draw < cumulative {
177                return i;
178            }
179        }
180        // Floating-point rounding may leave `draw` fractionally above
181        // the final cumulative sum; the last nonzero-probability token
182        // is the correct fallback, not index 0.
183        probs
184            .iter()
185            .enumerate()
186            .rev()
187            .find(|&(_, &p)| p > 0.0)
188            .map(|(i, _)| i)
189            .unwrap_or(0)
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::sampling::{sampling_distribution, spread_logits};
197
198    ///
199    /// This is the reason [`Sampler::xtc_roll`] is conditional rather
200    /// than unconditional. Delete the `xtc_can_fire` guard there and
201    /// this goes red on the first token.
202    #[test]
203    fn a_chain_without_xtc_does_not_consume_a_draw_for_it() {
204        let params = SamplingParams {
205            temperature: 1.0,
206            ..SamplingParams::default()
207        };
208        let logits = spread_logits(32);
209        assert!(
210            Sampler::new(99).xtc_roll(&params).is_none(),
211            "the guard must refuse the draw, not merely ignore it"
212        );
213
214        // ONE draw per token, taken by hand off a generator that never
215        // heard of XTC. An unconditional roll makes `sample` consume
216        // two values per token, so the very first token comes off the
217        // SECOND draw and this diverges immediately.
218        let mut sampled_by_chain = Sampler::new(99);
219        let mut by_hand = Sampler::new(99);
220        for step in 0..16 {
221            let sampled = sampled_by_chain.sample(&logits, &params, PenaltyWindow::new(&[], &[]));
222            let probs = sampling_distribution(&logits, &params, PenaltyWindow::new(&[], &[]), None);
223            assert_eq!(
224                sampled,
225                by_hand.sample_from(&probs),
226                "token {step} came off a different position in the stream"
227            );
228        }
229        // And the two generators are still in lockstep afterwards.
230        assert_eq!(sampled_by_chain.uniform(), by_hand.uniform());
231    }
232
233    /// **The flag does something.** A chain that runs the temperature
234    /// before top-p keeps a different candidate set than the default,
235    /// which is the whole reason the order is worth exposing -- and the
236    /// reason getting it wrong is a silent quality regression rather
237    /// than an error.
238    ///
239    /// A hot temperature flattens the distribution, so a top-p applied
240    /// after it sums smaller probabilities and reaches `p` later,
241    /// keeping MORE candidates.
242
243    #[test]
244    fn temperature_zero_accepts_precomputed_argmax_singleton() {
245        let mut sampler = Sampler::new(1);
246        let params = SamplingParams::default();
247        assert_eq!(
248            sampler.sample(&[42.0], &params, PenaltyWindow::new(&[], &[])),
249            42
250        );
251        // Non-greedy must not treat a singleton as a token id.
252        let sampled = SamplingParams {
253            temperature: 0.8,
254            ..SamplingParams::default()
255        };
256        // Softmax of a single logit → only token 0 is eligible.
257        assert_eq!(
258            sampler.sample(&[42.0], &sampled, PenaltyWindow::new(&[], &[])),
259            0
260        );
261    }
262
263    #[test]
264    fn temperature_zero_is_deterministic_greedy_argmax() {
265        let logits = vec![0.1, 0.9, 0.3, -0.2];
266        let params = SamplingParams::default();
267        let mut sampler = Sampler::new(42);
268        assert_eq!(
269            sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[])),
270            1
271        );
272        // Must be deterministic regardless of RNG state advancing.
273        assert_eq!(
274            sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[])),
275            1
276        );
277    }
278
279    #[test]
280    fn high_temperature_can_pick_a_non_argmax_token_over_many_draws() {
281        let logits = vec![1.0, 1.0, 1.0, 1.0];
282        let params = SamplingParams {
283            temperature: 1.0,
284            ..SamplingParams::default()
285        };
286        let mut sampler = Sampler::new(7);
287        let mut seen = std::collections::HashSet::new();
288        for _ in 0..200 {
289            seen.insert(sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[])));
290        }
291        assert!(
292            seen.len() > 1,
293            "uniform logits at temperature=1.0 must produce more than one distinct token across 200 draws"
294        );
295    }
296
297    #[test]
298    fn top_k_one_is_equivalent_to_greedy() {
299        let logits = vec![0.1, 0.9, 0.3, -0.2];
300        let params = SamplingParams {
301            temperature: 1.0,
302            top_k: 1,
303            ..SamplingParams::default()
304        };
305        let mut sampler = Sampler::new(123);
306        for _ in 0..20 {
307            assert_eq!(
308                sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[])),
309                1
310            );
311        }
312    }
313
314    #[test]
315    fn top_p_near_zero_is_equivalent_to_greedy() {
316        let logits = vec![0.1, 5.0, 0.3, -0.2];
317        let params = SamplingParams {
318            temperature: 1.0,
319            top_p: 0.001,
320            ..SamplingParams::default()
321        };
322        let mut sampler = Sampler::new(9);
323        for _ in 0..20 {
324            assert_eq!(
325                sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[])),
326                1
327            );
328        }
329    }
330
331    #[test]
332    fn presence_and_frequency_penalties_reduce_seen_token_logits() {
333        let logits = vec![0.0, 5.0, 0.0];
334        let params = SamplingParams {
335            temperature: 1.0,
336            presence_penalty: 10.0,
337            frequency_penalty: 0.0,
338            ..SamplingParams::default()
339        };
340        let mut sampler = Sampler::new(1);
341        let mut counts = [0usize; 3];
342        for _ in 0..500 {
343            counts[sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[1]))] += 1;
344        }
345        assert!(
346            counts[1] < 250,
347            "presence_penalty should discourage token 1; counts={counts:?}"
348        );
349
350        let params = SamplingParams {
351            temperature: 1.0,
352            presence_penalty: 0.0,
353            frequency_penalty: 10.0,
354            ..SamplingParams::default()
355        };
356        let mut sampler = Sampler::new(2);
357        counts = [0; 3];
358        for _ in 0..500 {
359            counts[sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[1, 1, 1]))] += 1;
360        }
361        assert!(
362            counts[1] < 250,
363            "frequency_penalty should discourage repeated token 1; counts={counts:?}"
364        );
365    }
366
367    #[test]
368    fn repetition_penalty_reduces_probability_of_recently_seen_token() {
369        let logits = vec![0.0, 5.0, 0.0];
370        let params = SamplingParams {
371            temperature: 1.0,
372            repetition_penalty: 1000.0,
373            ..SamplingParams::default()
374        };
375        let mut sampler = Sampler::new(3);
376        let mut counts = [0usize; 3];
377        for _ in 0..500 {
378            counts[sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[1]))] += 1;
379        }
380        assert!(
381            counts[1] < 250,
382            "heavily penalizing token 1 (already in history) should make it far less likely than its raw logit alone would suggest; got counts={counts:?}"
383        );
384    }
385
386    #[test]
387    fn low_seeds_do_not_bias_the_first_draw() {
388        // Every generation seeds a fresh `Sampler` (the server does it
389        // per request, from the caller's `seed`), so the FIRST draw off
390        // a freshly seeded generator is the one users actually see.
391        // Plain xorshift64 returns its own state, so seeds 1..4000 all
392        // produced a first draw in the bottom eighth of [0, 1) -- the
393        // first sampled token of every seeded request came off the
394        // bottom of the CDF.
395        let vocab = 8;
396        let logits = vec![0.0f32; vocab];
397        let params = SamplingParams {
398            temperature: 1.0,
399            ..SamplingParams::default()
400        };
401        let seeds = 4_000u64;
402        let mut counts = vec![0usize; vocab];
403        for seed in 1..=seeds {
404            counts[Sampler::new(seed).sample(&logits, &params, PenaltyWindow::new(&[], &[]))] += 1;
405        }
406        let expected = seeds as f64 / vocab as f64;
407        for (token, &c) in counts.iter().enumerate() {
408            assert!(
409                (c as f64 - expected).abs() < expected * 0.25,
410                "uniform logits: token {token} came up {c} times across {seeds} seeds, \
411                 expected about {expected:.0} (counts={counts:?})"
412            );
413        }
414    }
415
416    #[test]
417    fn the_published_distribution_is_the_one_sample_actually_draws_from() {
418        // `sampling_distribution` is load-bearing for lossless
419        // speculative verification: if it disagreed with what `sample`
420        // draws from, every accept/reject decision would be measured
421        // against the wrong target. Check them against each other
422        // empirically, with filters on so the two code paths have
423        // something to disagree about.
424        let logits = vec![0.4, 2.0, -1.0, 1.2, 0.9, -0.3];
425        let params = SamplingParams {
426            temperature: 0.8,
427            top_p: 0.9,
428            top_k: 4,
429            repetition_penalty: 1.3,
430            ..SamplingParams::default()
431        };
432        let history = [1usize, 4];
433        let claimed =
434            sampling_distribution(&logits, &params, PenaltyWindow::new(&[], &history), None);
435        assert!((claimed.iter().sum::<f32>() - 1.0).abs() < 1e-5);
436
437        let draws = 100_000;
438        let mut counts = vec![0usize; logits.len()];
439        let mut sampler = Sampler::new(0xC0FFEE);
440        for _ in 0..draws {
441            counts[sampler.sample(&logits, &params, PenaltyWindow::new(&[], &history))] += 1;
442        }
443        for (i, &c) in counts.iter().enumerate() {
444            let empirical = c as f64 / draws as f64;
445            assert!(
446                (empirical - claimed[i] as f64).abs() < 0.01,
447                "token {i}: sample() draws it {empirical:.4} of the time but \
448                 sampling_distribution claims {:.4}",
449                claimed[i]
450            );
451        }
452    }
453
454    #[test]
455    fn degenerate_all_zero_probability_falls_back_to_greedy() {
456        // top_k=1 combined with a top_p that would exclude even that
457        // one surviving token is a contradictory/degenerate
458        // configuration; must not panic or sample index 0 blindly.
459        let logits = vec![0.1, 0.9, 0.3, -0.2];
460        let params = SamplingParams {
461            temperature: 1.0,
462            top_k: 1,
463            top_p: 1.0,
464            ..SamplingParams::default()
465        };
466        let mut sampler = Sampler::new(1);
467        assert_eq!(
468            sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[])),
469            1
470        );
471    }
472}