Skip to main content

memra_sampling/
lib.rs

1//! Host-side sampler chain (BASE-2, MEMRA-BUILD-MAP §BASE-2). Ports llama.cpp CPU sampler
2//! semantics (llama-sampler.cpp): repetition/freq/presence penalties -> temperature -> top-k ->
3//! top-p -> min-p -> categorical draw. Greedy (temp<=0) = argmax, the bit-exact reference.
4//!
5//! Runs on the host over the full [n_vocab] f32 logit vector already brought back by the per-step
6//! D2H sync (decode.rs) — at B=2-4 this is single-µs, no GPU kernel needed (the GPU-fused sampler
7//! is a deferred PERF item, only needed once CUDA-graph removes the D2H barrier).
8
9/// Sampler configuration. Defaults = greedy (temp 0). Order of application matches llama.cpp.
10#[derive(Clone, Debug)]
11pub struct SamplerConfig {
12    pub temperature: f32,      // <= 0.0 => greedy argmax (penalties/top-k/p ignored)
13    pub top_k: usize,          // 0 => disabled (keep all)
14    pub top_p: f32,            // 1.0 => disabled
15    pub min_p: f32,            // 0.0 => disabled
16    pub penalty_last_n: usize, // window of recent tokens for penalties (0 => disabled)
17    pub penalty_repeat: f32,   // 1.0 => disabled (llama default 1.0)
18    pub penalty_freq: f32,     // 0.0 => disabled
19    pub penalty_present: f32,  // 0.0 => disabled
20    pub seed: u64,
21}
22
23impl Default for SamplerConfig {
24    fn default() -> Self {
25        SamplerConfig {
26            temperature: 0.0,
27            top_k: 0,
28            top_p: 1.0,
29            min_p: 0.0,
30            penalty_last_n: 0,
31            penalty_repeat: 1.0,
32            penalty_freq: 0.0,
33            penalty_present: 0.0,
34            seed: 0,
35        }
36    }
37}
38
39/// Stateful sampler: owns the RNG + the recent-token history (for penalties).
40pub struct Sampler {
41    cfg: SamplerConfig,
42    rng: SplitMix64,
43    history: Vec<u32>, // recently emitted tokens (for penalty window)
44}
45
46impl Sampler {
47    pub fn new(cfg: SamplerConfig) -> Self {
48        let rng = SplitMix64::new(cfg.seed);
49        Sampler {
50            cfg,
51            rng,
52            history: Vec::new(),
53        }
54    }
55
56    pub fn is_greedy(&self) -> bool {
57        self.cfg.temperature <= 0.0
58    }
59    /// Sampled spec in its FASTEST regime: pure temperature, no truncation filters, no
60    /// penalties. Filters and penalties are also distribution-exact under the rejection
61    /// verify (spec.rs applies both symmetrically to draft q and target p), so they remain
62    /// spec-ELIGIBLE — see `spec_eligible` in memra-server's worker, the authoritative
63    /// predicate. What they cost is the in-graph draft chain: the captured sampled draft
64    /// samples from the RAW softmax and can hold neither per-row filter stats nor a varying
65    /// penalty history, so `spec.rs` engages `graph_s` only in this pure-temp regime
66    /// (`pure_temp`) and otherwise falls back to the eager draft chain. This predicate names
67    /// that regime; it is NOT an eligibility test.
68    pub fn is_spec_sampling(&self) -> bool {
69        self.cfg.temperature > 0.0
70            && self.cfg.penalty_repeat == 1.0
71            && self.cfg.penalty_freq == 0.0
72            && self.cfg.penalty_present == 0.0
73            && self.cfg.top_k == 0
74            && self.cfg.top_p >= 1.0
75            && self.cfg.min_p <= 0.0
76    }
77    pub fn top_k(&self) -> usize {
78        self.cfg.top_k
79    }
80    pub fn penalty_last_n(&self) -> usize {
81        self.cfg.penalty_last_n
82    }
83    pub fn penalty_repeat(&self) -> f32 {
84        self.cfg.penalty_repeat
85    }
86    pub fn penalty_freq(&self) -> f32 {
87        self.cfg.penalty_freq
88    }
89    pub fn penalty_present(&self) -> f32 {
90        self.cfg.penalty_present
91    }
92    pub fn top_p(&self) -> f32 {
93        self.cfg.top_p
94    }
95    pub fn min_p(&self) -> f32 {
96        self.cfg.min_p
97    }
98    pub fn temperature(&self) -> f32 {
99        self.cfg.temperature
100    }
101    pub fn seed(&self) -> u64 {
102        self.cfg.seed
103    }
104
105    /// Record an emitted token so subsequent penalties see it.
106    pub fn accept(&mut self, token: u32) {
107        self.history.push(token);
108    }
109
110    /// Sample the next token id from raw logits [n_vocab]. Does NOT mutate logits in place beyond
111    /// a local copy. Returns the chosen token id. (Caller should `accept()` it afterwards.)
112    pub fn sample(&mut self, logits: &[f32]) -> u32 {
113        // Greedy fast path: argmax over RAW logits (penalties don't change the argmax direction
114        // enough to matter for the reference path; llama greedy is also pre-penalty argmax only
115        // when no penalties set — but to stay correct under penalties we still apply them first).
116        if self.is_greedy()
117            && self.cfg.penalty_repeat == 1.0
118            && self.cfg.penalty_freq == 0.0
119            && self.cfg.penalty_present == 0.0
120        {
121            return argmax_u32(logits);
122        }
123
124        // Work on (id, logit) candidates.
125        let mut cand: Vec<(u32, f32)> = logits
126            .iter()
127            .enumerate()
128            .map(|(i, &l)| (i as u32, l))
129            .collect();
130
131        // 1. Penalties (operate on logits, over the last-n history window).
132        self.apply_penalties(&mut cand);
133
134        // Greedy-with-penalties: argmax after penalties, no sampling.
135        if self.is_greedy() {
136            let mut best = cand[0];
137            for &c in &cand[1..] {
138                if c.1 > best.1 {
139                    best = c;
140                }
141            }
142            return best.0;
143        }
144
145        // 2. Temperature scale.
146        if self.cfg.temperature > 0.0 && self.cfg.temperature != 1.0 {
147            let inv = 1.0 / self.cfg.temperature;
148            for c in cand.iter_mut() {
149                c.1 *= inv;
150            }
151        }
152
153        // 3. top-k: keep the k highest-logit candidates (partial sort by logit desc).
154        if self.cfg.top_k > 0 && self.cfg.top_k < cand.len() {
155            cand.sort_unstable_by(|a, b| b.1.total_cmp(&a.1));
156            cand.truncate(self.cfg.top_k);
157        }
158
159        // softmax over the surviving candidates (numerically stable).
160        softmax_inplace(&mut cand);
161
162        // 4. top-p (nucleus): smallest set whose cumulative prob >= top_p. Needs desc-by-prob order.
163        if self.cfg.top_p < 1.0 {
164            cand.sort_unstable_by(|a, b| b.1.total_cmp(&a.1));
165            let mut cum = 0.0f32;
166            let mut keep = 0usize;
167            for (i, c) in cand.iter().enumerate() {
168                cum += c.1;
169                keep = i + 1;
170                if cum >= self.cfg.top_p {
171                    break;
172                }
173            }
174            cand.truncate(keep.max(1));
175        }
176
177        // 5. min-p: keep candidates with prob >= min_p * max_prob.
178        if self.cfg.min_p > 0.0 {
179            let maxp = cand.iter().map(|c| c.1).fold(0.0f32, f32::max);
180            let thresh = self.cfg.min_p * maxp;
181            cand.retain(|c| c.1 >= thresh);
182            if cand.is_empty() {
183                return argmax_u32(logits);
184            } // safety
185        }
186
187        // renormalize the surviving probs and draw.
188        let sum: f32 = cand.iter().map(|c| c.1).sum();
189        let r = self.rng.next_f32() * sum;
190        let mut acc = 0.0f32;
191        for c in &cand {
192            acc += c.1;
193            if acc >= r {
194                return c.0;
195            }
196        }
197        cand.last().unwrap().0
198    }
199
200    /// llama.cpp penalty: for each token in the last-n history, repeat-divide/multiply its logit
201    /// and apply frequency*count + presence. (llama-sampler.cpp penalties.)
202    fn apply_penalties(&self, cand: &mut [(u32, f32)]) {
203        let n = self.cfg.penalty_last_n;
204        if n == 0 {
205            return;
206        }
207        if self.cfg.penalty_repeat == 1.0
208            && self.cfg.penalty_freq == 0.0
209            && self.cfg.penalty_present == 0.0
210        {
211            return;
212        }
213        let start = self.history.len().saturating_sub(n);
214        let window = &self.history[start..];
215        if window.is_empty() {
216            return;
217        }
218        // count occurrences in the window
219        use std::collections::HashMap;
220        let mut counts: HashMap<u32, i32> = HashMap::new();
221        for &t in window {
222            *counts.entry(t).or_insert(0) += 1;
223        }
224        for c in cand.iter_mut() {
225            if let Some(&cnt) = counts.get(&c.0) {
226                // repeat: llama divides if logit>0 else multiplies (penalize toward 0)
227                if self.cfg.penalty_repeat != 1.0 {
228                    if c.1 > 0.0 {
229                        c.1 /= self.cfg.penalty_repeat;
230                    } else {
231                        c.1 *= self.cfg.penalty_repeat;
232                    }
233                }
234                c.1 -= cnt as f32 * self.cfg.penalty_freq;
235                c.1 -= self.cfg.penalty_present; // presence: applied once if count>0
236            }
237        }
238    }
239}
240
241fn argmax_u32(logits: &[f32]) -> u32 {
242    let mut best = 0u32;
243    let mut bv = f32::NEG_INFINITY;
244    for (i, &v) in logits.iter().enumerate() {
245        if v > bv {
246            bv = v;
247            best = i as u32;
248        }
249    }
250    best
251}
252
253/// Stable softmax over candidate logits, writing probs back into the logit slot.
254fn softmax_inplace(cand: &mut [(u32, f32)]) {
255    let maxl = cand.iter().map(|c| c.1).fold(f32::NEG_INFINITY, f32::max);
256    let mut sum = 0.0f32;
257    for c in cand.iter_mut() {
258        let e = (c.1 - maxl).exp();
259        c.1 = e;
260        sum += e;
261    }
262    let inv = if sum > 0.0 { 1.0 / sum } else { 0.0 };
263    for c in cand.iter_mut() {
264        c.1 *= inv;
265    }
266}
267
268/// SplitMix64 — deterministic seedable RNG (so a fixed seed reproduces the token stream for the
269/// validation gate). Not crypto; fine for sampling.
270struct SplitMix64 {
271    state: u64,
272}
273impl SplitMix64 {
274    fn new(seed: u64) -> Self {
275        SplitMix64 {
276            state: seed.wrapping_add(0x9E3779B97F4A7C15),
277        }
278    }
279    fn next_u64(&mut self) -> u64 {
280        self.state = self.state.wrapping_add(0x9E3779B97F4A7C15);
281        let mut z = self.state;
282        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
283        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
284        z ^ (z >> 31)
285    }
286    /// uniform f32 in [0,1).
287    fn next_f32(&mut self) -> f32 {
288        // top 24 bits -> [0,1)
289        ((self.next_u64() >> 40) as f32) / (1u32 << 24) as f32
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn greedy_is_argmax() {
299        let mut s = Sampler::new(SamplerConfig::default()); // temp 0
300        let logits = vec![0.1, 5.0, 2.0, -1.0];
301        assert_eq!(s.sample(&logits), 1);
302    }
303
304    #[test]
305    fn temp_sampling_deterministic_with_seed() {
306        let cfg = SamplerConfig {
307            temperature: 1.0,
308            seed: 42,
309            ..Default::default()
310        };
311        let logits = vec![1.0, 2.0, 3.0, 0.5];
312        let a = Sampler::new(cfg.clone()).sample(&logits);
313        let b = Sampler::new(cfg).sample(&logits);
314        assert_eq!(a, b, "same seed must reproduce the draw");
315        assert!(a < 4);
316    }
317
318    #[test]
319    fn top_k_one_is_argmax() {
320        let cfg = SamplerConfig {
321            temperature: 1.0,
322            top_k: 1,
323            seed: 7,
324            ..Default::default()
325        };
326        let logits = vec![0.1, 5.0, 2.0, -1.0];
327        assert_eq!(
328            Sampler::new(cfg).sample(&logits),
329            1,
330            "top_k=1 collapses to argmax"
331        );
332    }
333
334    #[test]
335    fn min_p_keeps_only_high_prob() {
336        // logit 10 dominates; min_p 0.5 should drop the rest -> always pick id 2.
337        let cfg = SamplerConfig {
338            temperature: 1.0,
339            min_p: 0.5,
340            seed: 3,
341            ..Default::default()
342        };
343        let logits = vec![0.0, 0.0, 10.0, 0.0];
344        for _ in 0..16 {
345            assert_eq!(Sampler::new(cfg.clone()).sample(&logits), 2);
346        }
347    }
348
349    #[test]
350    fn repeat_penalty_suppresses_recent() {
351        // greedy + heavy repeat penalty: id 1 is argmax but recently emitted -> should drop it.
352        let mut cfg = SamplerConfig::default();
353        cfg.penalty_last_n = 8;
354        cfg.penalty_repeat = 100.0;
355        let mut s = Sampler::new(cfg);
356        s.accept(1); // 1 was just emitted
357        let logits = vec![4.0, 5.0, 4.5, 1.0]; // raw argmax = 1
358        let got = s.sample(&logits);
359        assert_ne!(
360            got, 1,
361            "recent token must be penalized out of greedy argmax"
362        );
363        assert_eq!(got, 2, "next-highest after penalizing 1");
364    }
365}