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(¶ms).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, ¶ms, PenaltyWindow::new(&[], &[]));
222 let probs = sampling_distribution(&logits, ¶ms, 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], ¶ms, 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, ¶ms, PenaltyWindow::new(&[], &[])),
270 1
271 );
272 // Must be deterministic regardless of RNG state advancing.
273 assert_eq!(
274 sampler.sample(&logits, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, PenaltyWindow::new(&[], &[])),
469 1
470 );
471 }
472}