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