Skip to main content

cortiq_engine/
sampler.rs

1//! Token sampling — temperature, top-p, top-k, min-p, repetition penalty.
2//!
3//! Randomness comes from an explicit SplitMix64 PRNG carried by the
4//! caller: reproducible with a seed, unbiased across the whole CDF
5//! (the v1 `subsec_nanos` source could never pick past ~23% of it).
6
7use serde::{Deserialize, Serialize};
8
9/// SplitMix64 — tiny, fast, statistically solid for sampling.
10#[derive(Debug, Clone)]
11pub struct SplitMix64 {
12    state: u64,
13}
14
15/// Reusable per-pipeline sampling workspace. The epoch table lets the
16/// repetition penalty visit each token id once without allocating a HashSet
17/// or clearing a vocab-sized boolean vector on every decode step.
18#[derive(Debug, Default)]
19pub struct SamplerScratch {
20    seen_epoch: Vec<u32>,
21    epoch: u32,
22    /// The working copy of the logits. At a 129k vocab that is half a
23    /// megabyte allocated, filled and dropped per token; the struct that
24    /// exists to hold scratch may as well hold this one too.
25    probs: Vec<f32>,
26}
27
28impl SamplerScratch {
29    fn begin_seen(&mut self, vocab_size: usize) -> u32 {
30        if self.seen_epoch.len() < vocab_size {
31            self.seen_epoch.resize(vocab_size, 0);
32        }
33        self.epoch = self.epoch.wrapping_add(1);
34        if self.epoch == 0 {
35            self.seen_epoch.fill(0);
36            self.epoch = 1;
37        }
38        self.epoch
39    }
40}
41
42impl SplitMix64 {
43    pub fn new(seed: u64) -> Self {
44        Self { state: seed }
45    }
46
47    /// Seed from OS entropy (address-space + time mix) when none given.
48    pub fn from_entropy() -> Self {
49        let t = std::time::SystemTime::now()
50            .duration_since(std::time::UNIX_EPOCH)
51            .unwrap_or_default();
52        let addr = Box::into_raw(Box::new(0u8)) as u64;
53        // SAFETY: pointer came from Box::into_raw just above.
54        unsafe { drop(Box::from_raw(addr as *mut u8)) };
55        Self::new(t.as_nanos() as u64 ^ addr.rotate_left(17) ^ 0x9E3779B97F4A7C15)
56    }
57
58    #[inline]
59    pub fn next_u64(&mut self) -> u64 {
60        self.state = self.state.wrapping_add(0x9E3779B97F4A7C15);
61        let mut z = self.state;
62        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
63        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
64        z ^ (z >> 31)
65    }
66
67    /// Uniform f32 in [0, 1).
68    #[inline]
69    pub fn next_f32(&mut self) -> f32 {
70        (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
71    }
72}
73
74/// Sampling configuration.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct SamplerConfig {
77    pub temperature: f32,
78    pub top_p: f32,
79    pub top_k: u32,
80    pub repetition_penalty: f32,
81    pub min_p: f32,
82    /// Fixed seed for reproducible generation (None = entropy).
83    #[serde(default)]
84    pub seed: Option<u64>,
85    /// Token IDs to suppress (force logit to -inf).
86    #[serde(default)]
87    pub suppress_tokens: Vec<u32>,
88}
89
90impl Default for SamplerConfig {
91    fn default() -> Self {
92        Self {
93            temperature: 0.7,
94            top_p: 0.9,
95            top_k: 40,
96            repetition_penalty: 1.1,
97            min_p: 0.05,
98            seed: None,
99            suppress_tokens: Vec::new(),
100        }
101    }
102}
103
104/// Sample next token from logits. Chain order is fixed:
105/// rep-penalty → temperature → softmax → min-p → top-k → top-p → sample.
106pub fn sample(
107    logits: &[f32],
108    config: &SamplerConfig,
109    past_tokens: &[u32],
110    rng: &mut SplitMix64,
111) -> u32 {
112    let mut scratch = SamplerScratch::default();
113    sample_with_scratch(logits, config, past_tokens, rng, &mut scratch)
114}
115
116/// Sampling entry point for hot decode loops with reusable scratch storage.
117pub fn sample_with_scratch(
118    logits: &[f32],
119    config: &SamplerConfig,
120    past_tokens: &[u32],
121    rng: &mut SplitMix64,
122    scratch: &mut SamplerScratch,
123) -> u32 {
124    if config.temperature < 1e-6
125        && config.repetition_penalty == 1.0
126        && config.suppress_tokens.is_empty()
127    {
128        return argmax(logits);
129    }
130    // Borrowed from the scratch and handed back at the single exit: at a
131    // 129k vocab this copy is half a megabyte allocated, filled and dropped
132    // per token, and the struct that exists to hold scratch may as well
133    // hold it. Every early return goes through `done` so the buffer never
134    // leaks back to the allocator.
135    let mut probs = std::mem::take(&mut scratch.probs);
136    probs.clear();
137    probs.extend_from_slice(logits);
138
139    for &tok in &config.suppress_tokens {
140        if (tok as usize) < probs.len() {
141            probs[tok as usize] = f32::NEG_INFINITY;
142        }
143    }
144
145    if config.repetition_penalty != 1.0 {
146        apply_repetition_penalty(&mut probs, past_tokens, config.repetition_penalty, scratch);
147    }
148
149    let mut done = |probs: Vec<f32>, tok: u32| -> u32 {
150        scratch.probs = probs;
151        tok
152    };
153
154    if config.temperature < 1e-6 {
155        let t = argmax(&probs); // greedy
156        return done(probs, t);
157    }
158    if config.temperature != 1.0 {
159        for p in probs.iter_mut() {
160            *p /= config.temperature;
161        }
162    }
163
164    softmax_inplace(&mut probs);
165
166    if config.min_p > 0.0 {
167        let max_prob = probs.iter().cloned().fold(0.0f32, f32::max);
168        let threshold = max_prob * config.min_p;
169        for p in probs.iter_mut() {
170            if *p < threshold {
171                *p = 0.0;
172            }
173        }
174    }
175
176    if config.top_k > 0 && (config.top_k as usize) < probs.len() {
177        apply_top_k(&mut probs, config.top_k as usize);
178    }
179
180    if config.top_p < 1.0 && config.top_p > 0.0 {
181        apply_top_p(&mut probs, config.top_p);
182    }
183
184    let sum: f32 = probs.iter().sum();
185    if sum > 0.0 {
186        for p in probs.iter_mut() {
187            *p /= sum;
188        }
189    } else {
190        // Everything filtered out — fall back to greedy over original logits.
191        let t = argmax(logits);
192        return done(probs, t);
193    }
194
195    let t = categorical_sample(&probs, rng.next_f32());
196    done(probs, t)
197}
198
199/// Greedy: index of the maximum value.
200///
201/// Four running maxima instead of one: the scalar `max_by` carried a loop
202/// dependency through the comparison, which at a 129k vocab is a tenth of a
203/// millisecond of pure serial work per token.
204///
205/// Ties resolve to the HIGHEST index — not an arbitrary choice, it is what
206/// `Iterator::max_by` does (it keeps the last of several equal maxima) and
207/// therefore what this has always returned. `explain`'s preview compares
208/// its own argmax against what greedy emits, and that test is what catches
209/// the flip.
210pub fn argmax(values: &[f32]) -> u32 {
211    if values.is_empty() {
212        return 0;
213    }
214    let n = values.len();
215    let mut best = [(0usize, f32::NEG_INFINITY); 4];
216    for (l, b) in best.iter_mut().enumerate() {
217        b.0 = l.min(n - 1);
218    }
219    let mut i = 0;
220    while i + 4 <= n {
221        for l in 0..4 {
222            let v = values[i + l];
223            if v >= best[l].1 {
224                best[l] = (i + l, v);
225            }
226        }
227        i += 4;
228    }
229    let mut bi = best[0].0;
230    let mut bv = best[0].1;
231    for b in &best[1..] {
232        if b.1 > bv || (b.1 == bv && b.0 > bi) {
233            bi = b.0;
234            bv = b.1;
235        }
236    }
237    while i < n {
238        if values[i] >= bv {
239            bv = values[i];
240            bi = i;
241        }
242        i += 1;
243    }
244    bi as u32
245}
246
247fn softmax_inplace(logits: &mut [f32]) {
248    let max_val = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
249    let mut sum = 0.0f32;
250    for v in logits.iter_mut() {
251        *v = (*v - max_val).exp();
252        sum += *v;
253    }
254    if sum > 0.0 {
255        for v in logits.iter_mut() {
256            *v /= sum;
257        }
258    }
259}
260
261fn apply_repetition_penalty(
262    logits: &mut [f32],
263    past_tokens: &[u32],
264    penalty: f32,
265    scratch: &mut SamplerScratch,
266) {
267    let epoch = scratch.begin_seen(logits.len());
268    for &tok in past_tokens {
269        let idx = tok as usize;
270        if idx < logits.len() && scratch.seen_epoch[idx] != epoch {
271            scratch.seen_epoch[idx] = epoch;
272            if logits[idx] > 0.0 {
273                logits[idx] /= penalty;
274            } else {
275                logits[idx] *= penalty;
276            }
277        }
278    }
279}
280
281/// Keep the k highest-probability tokens (plus exact ties at the
282/// threshold), zero the rest. Selection, not a full vocab sort — the
283/// old double `sort_by` over ~150k probs was ~1ms of pure per-token
284/// overhead (roadmap §3 P0).
285fn apply_top_k(probs: &mut [f32], k: usize) {
286    if k == 0 || k >= probs.len() {
287        return;
288    }
289    let mut sel: Vec<f32> = probs.to_vec();
290    // k-th largest = (k-1)-th index in a descending partition.
291    let (_, kth, _) = sel.select_nth_unstable_by(k - 1, |a, b| {
292        b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
293    });
294    let threshold = *kth;
295    for p in probs.iter_mut() {
296        if *p < threshold {
297            *p = 0.0;
298        }
299    }
300}
301
302/// Nucleus: keep the smallest prefix of tokens whose cumulative
303/// probability reaches top_p. Only surviving (non-zero) candidates are
304/// sorted — after top-k that is ≤ k elements, not the whole vocab; the
305/// kept set is marked in-place instead of a per-token HashSet.
306fn apply_top_p(probs: &mut [f32], top_p: f32) {
307    let mut indexed: Vec<(usize, f32)> = probs
308        .iter()
309        .copied()
310        .enumerate()
311        .filter(|&(_, p)| p > 0.0)
312        .collect();
313    indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
314
315    let mut cumsum = 0.0f32;
316    let mut cutoff_idx = indexed.len();
317    for (i, &(_, prob)) in indexed.iter().enumerate() {
318        cumsum += prob;
319        if cumsum >= top_p {
320            cutoff_idx = i + 1;
321            break;
322        }
323    }
324
325    // Zero the dropped tail directly — indices, not membership tests.
326    for &(i, _) in &indexed[cutoff_idx..] {
327        probs[i] = 0.0;
328    }
329}
330
331/// Inverse-CDF sampling with an externally supplied uniform r ∈ [0, 1).
332fn categorical_sample(probs: &[f32], r: f32) -> u32 {
333    let mut cumsum = 0.0f32;
334    for (i, &p) in probs.iter().enumerate() {
335        cumsum += p;
336        if r < cumsum {
337            return i as u32;
338        }
339    }
340    probs.iter().rposition(|&p| p > 0.0).unwrap_or(0) as u32
341}
342
343#[cfg(test)]
344mod tests {
345    /// The four-lane argmax against the obvious scalar one, including the
346    /// tie rule: `max_by` keeps the LAST of several equal maxima, and a
347    /// lane split that quietly picked the first would move greedy output on
348    /// any model with two equally-likely tokens.
349    #[test]
350    fn argmax_lanes_match_the_scalar_one_ties_and_all() {
351        // The reference IS the old implementation, `max_by` and all — the
352        // point is that nothing observable changed, tie rule included.
353        let scalar = |v: &[f32]| -> u32 {
354            v.iter()
355                .enumerate()
356                .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
357                .map(|(i, _)| i as u32)
358                .unwrap_or(0)
359        };
360        for n in 0..40usize {
361            for seed in 0..8u64 {
362                let mut r = super::SplitMix64::new(seed * 7 + n as u64);
363                // Quantized to few distinct values on purpose: ties are the
364                // case the lanes can get wrong and random floats never hit.
365                let v: Vec<f32> = (0..n)
366                    .map(|_| ((r.next_u64() % 5) as f32) - 2.0)
367                    .collect();
368                assert_eq!(super::argmax(&v), scalar(&v), "n={n} seed={seed} {v:?}");
369            }
370        }
371        let flat = vec![f32::NEG_INFINITY; 13];
372        assert_eq!(super::argmax(&flat), scalar(&flat));
373    }
374
375    use super::*;
376
377    #[test]
378    fn test_argmax() {
379        let logits = vec![0.1, 0.5, 0.3, 0.9, 0.2];
380        assert_eq!(argmax(&logits), 3);
381    }
382
383    #[test]
384    fn test_greedy_sampling() {
385        let logits = vec![1.0, 5.0, 2.0, 3.0];
386        let config = SamplerConfig {
387            temperature: 0.0,
388            ..Default::default()
389        };
390        let mut rng = SplitMix64::new(1);
391        assert_eq!(sample(&logits, &config, &[], &mut rng), 1);
392    }
393
394    #[test]
395    fn test_softmax() {
396        let mut logits = vec![1.0, 2.0, 3.0];
397        softmax_inplace(&mut logits);
398        let sum: f32 = logits.iter().sum();
399        assert!((sum - 1.0).abs() < 1e-5);
400        assert!(logits[2] > logits[1] && logits[1] > logits[0]);
401    }
402
403    #[test]
404    fn test_repetition_penalty() {
405        let mut logits = vec![1.0, 2.0, 3.0, 4.0];
406        let mut scratch = SamplerScratch::default();
407        apply_repetition_penalty(&mut logits, &[1, 3], 2.0, &mut scratch);
408        assert_eq!(logits, vec![1.0, 1.0, 3.0, 2.0]);
409    }
410
411    #[test]
412    fn repetition_penalty_applies_once_per_unique_token() {
413        let mut logits = vec![1.0, 4.0, -6.0];
414        let mut scratch = SamplerScratch::default();
415        apply_repetition_penalty(&mut logits, &[1, 1, 2, 1, 2], 2.0, &mut scratch);
416        assert_eq!(logits, vec![1.0, 2.0, -12.0]);
417    }
418
419    #[test]
420    fn top_k_keeps_exactly_k() {
421        let mut probs = vec![0.1, 0.4, 0.05, 0.3, 0.15];
422        apply_top_k(&mut probs, 2);
423        let kept = probs.iter().filter(|&&p| p > 0.0).count();
424        assert_eq!(kept, 2, "top-k must keep exactly k (was k+1 in v1)");
425        assert!(probs[1] > 0.0 && probs[3] > 0.0);
426    }
427
428    #[test]
429    fn rng_reaches_full_cdf() {
430        // v1 bug: r < 0.233 always, so the CDF tail was unreachable.
431        // With uniform probs the LAST index must be sampled sometimes.
432        let probs = vec![0.25f32; 4];
433        let mut rng = SplitMix64::new(42);
434        let mut hits = [0usize; 4];
435        for _ in 0..4000 {
436            let i = categorical_sample(&probs, rng.next_f32()) as usize;
437            hits[i] += 1;
438        }
439        for (i, &h) in hits.iter().enumerate() {
440            assert!(h > 700, "index {i} sampled only {h}/4000 — biased RNG");
441        }
442    }
443
444    #[test]
445    fn same_seed_same_sequence() {
446        let logits: Vec<f32> = (0..32).map(|i| (i as f32 * 0.37).sin()).collect();
447        let config = SamplerConfig {
448            temperature: 1.0,
449            seed: Some(7),
450            ..Default::default()
451        };
452        let run = |seed: u64| -> Vec<u32> {
453            let mut rng = SplitMix64::new(seed);
454            (0..16)
455                .map(|_| sample(&logits, &config, &[], &mut rng))
456                .collect()
457        };
458        assert_eq!(run(7), run(7), "same seed must reproduce");
459        assert_ne!(run(7), run(8), "different seed must differ");
460    }
461}