Skip to main content

rlx_qwen3/
sampling.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Host-side logits sampler.
17//!
18//! Operates on a single `[vocab]` slice — caller is responsible for
19//! pulling the last position's row out of `[B, S, vocab]` logits.
20//!
21//! Sampling is a host-side step (not a graph op) for now because:
22//!   - The decision tree (temperature → top-k → top-p → multinomial)
23//!     is branchy and cheap; no win from baking it into the graph.
24//!   - Keeping it out of the graph lets a downstream `Speculator`
25//!     impl call the same sampler for both the draft and the
26//!     verifier without graph surgery.
27//!
28//! Determinism: backed by `rlx_ir::Philox4x32`, same RNG already used
29//! by `rlx-runtime/src/spec_decode.rs`. Same seed → same sequence.
30
31use rlx_ir::Philox4x32;
32use rlx_runtime::samplers::{
33    Dry, DynamicTemperature, MirostatV1, MirostatV2, RepetitionPenalty, SamplerChain, SamplerState,
34    Temperature, TopK, TopNSigma, TopP, TypicalP, Xtc,
35};
36
37/// Mirostat variant — mirrors `rlx_runtime::lm::MirostatMode` so callers
38/// don't need to import two enums.
39#[derive(Debug, Clone, Copy, PartialEq, Default)]
40pub enum MirostatMode {
41    #[default]
42    Off,
43    V1,
44    V2,
45}
46
47/// Sampling configuration. Construct via [`SampleOpts::greedy`] /
48/// [`SampleOpts::temperature`] or build manually.
49///
50/// Order of operations matches HF `transformers` defaults:
51///   1. `temperature` divides logits (skipped if `<= 0` or `1.0`).
52///   2. `top_k` truncates to the K highest-logit tokens (0 = disabled).
53///   3. `top_p` truncates by nucleus cumulative-mass cutoff (1.0 = disabled).
54///   4. Softmax + multinomial sample (or argmax when greedy).
55///
56/// Advanced samplers (Mirostat, DRY, XTC, etc.) live on the struct
57/// behind explicit fields that default to off; when any of them is
58/// engaged the sampler routes through `rlx_runtime::SamplerChain`
59/// instead of the inline top-k/top-p path. See [`SampleOpts::is_classic`].
60/// Maximum number of DRY-sampler sequence-break tokens supported by
61/// the fixed-size [`SampleOpts::dry_sequence_breakers`] buffer.
62/// 32 covers every special-token set in current tokenizers (BOS,
63/// EOS, padding, role-tag tokens, newline, period). Storing it
64/// inline keeps `SampleOpts` `Copy`; the whole model-runner
65/// ecosystem assumes that — moving away cascades into 30+ call sites.
66pub const DRY_BREAKERS_MAX: usize = 32;
67
68#[derive(Debug, Clone, Copy)]
69pub struct SampleOpts {
70    pub temperature: f32,
71    pub top_k: usize,
72    pub top_p: f32,
73    pub seed: u64,
74    pub greedy: bool,
75
76    // ── advanced samplers (all default to off / no-op) ───────────
77    pub dynamic_temp: Option<(f32, f32)>,
78    pub dynamic_temp_exponent: f32,
79    pub typical_p: f32,
80    pub top_n_sigma: f32,
81    pub xtc_threshold: f32,
82    pub xtc_prob: f32,
83    pub dry_multiplier: f32,
84    pub dry_base: f32,
85    pub dry_allowed_length: usize,
86    pub dry_max_ngram: usize,
87    /// DRY sequence-break tokens packed into a fixed-size array.
88    /// Use [`SampleOpts::dry_breakers`] to read the slice or
89    /// [`SampleOpts::with_dry_breakers`] to set it.
90    pub dry_sequence_breakers: [u32; DRY_BREAKERS_MAX],
91    pub dry_sequence_breakers_len: u8,
92    pub mirostat: MirostatMode,
93    pub mirostat_tau: f32,
94    pub mirostat_eta: f32,
95    pub mirostat_m: usize,
96    pub repetition_penalty: f32,
97    pub frequency_penalty: f32,
98    pub presence_penalty: f32,
99    pub repetition_window: usize,
100    pub min_keep: usize,
101}
102
103impl SampleOpts {
104    pub fn greedy() -> Self {
105        Self {
106            temperature: 1.0,
107            top_k: 0,
108            top_p: 1.0,
109            seed: 0,
110            greedy: true,
111            dynamic_temp: None,
112            dynamic_temp_exponent: 1.0,
113            typical_p: 1.0,
114            top_n_sigma: 0.0,
115            xtc_threshold: 0.0,
116            xtc_prob: 0.0,
117            dry_multiplier: 0.0,
118            dry_base: 1.75,
119            dry_allowed_length: 2,
120            dry_max_ngram: 32,
121            dry_sequence_breakers: [0; DRY_BREAKERS_MAX],
122            dry_sequence_breakers_len: 0,
123            mirostat: MirostatMode::Off,
124            mirostat_tau: 5.0,
125            mirostat_eta: 0.1,
126            mirostat_m: 100,
127            repetition_penalty: 1.0,
128            frequency_penalty: 0.0,
129            presence_penalty: 0.0,
130            repetition_window: 64,
131            min_keep: 1,
132        }
133    }
134
135    pub fn temperature(temp: f32, seed: u64) -> Self {
136        Self {
137            temperature: temp,
138            top_k: 0,
139            top_p: 1.0,
140            seed,
141            greedy: false,
142            ..Self::greedy()
143        }
144    }
145
146    pub fn with_top_k(mut self, k: usize) -> Self {
147        self.top_k = k;
148        self
149    }
150
151    pub fn with_top_p(mut self, p: f32) -> Self {
152        self.top_p = p;
153        self
154    }
155
156    // ── advanced sampler builders (chainable) ────────────────────
157
158    pub fn with_dynamic_temp(mut self, min: f32, max: f32) -> Self {
159        self.dynamic_temp = Some((min, max));
160        self.greedy = false;
161        self
162    }
163
164    pub fn with_typical_p(mut self, p: f32) -> Self {
165        self.typical_p = p;
166        self.greedy = false;
167        self
168    }
169
170    pub fn with_top_n_sigma(mut self, n: f32) -> Self {
171        self.top_n_sigma = n;
172        self.greedy = false;
173        self
174    }
175
176    pub fn with_xtc(mut self, threshold: f32, prob: f32) -> Self {
177        self.xtc_threshold = threshold;
178        self.xtc_prob = prob;
179        self.greedy = false;
180        self
181    }
182
183    pub fn with_dry(mut self, multiplier: f32, base: f32, allowed_length: usize) -> Self {
184        self.dry_multiplier = multiplier;
185        self.dry_base = base;
186        self.dry_allowed_length = allowed_length;
187        self.greedy = false;
188        self
189    }
190
191    pub fn with_mirostat_v1(mut self, tau: f32, eta: f32) -> Self {
192        self.mirostat = MirostatMode::V1;
193        self.mirostat_tau = tau;
194        self.mirostat_eta = eta;
195        self.greedy = false;
196        self
197    }
198
199    pub fn with_mirostat_v2(mut self, tau: f32, eta: f32) -> Self {
200        self.mirostat = MirostatMode::V2;
201        self.mirostat_tau = tau;
202        self.mirostat_eta = eta;
203        self.greedy = false;
204        self
205    }
206
207    pub fn with_repetition_penalty(mut self, p: f32) -> Self {
208        self.repetition_penalty = p;
209        self
210    }
211
212    pub fn with_frequency_presence(mut self, frequency: f32, presence: f32) -> Self {
213        self.frequency_penalty = frequency;
214        self.presence_penalty = presence;
215        self
216    }
217
218    /// True when only classic top-k/top-p/temperature/greedy is active —
219    /// callers can stay on the cheap inline path. Returns false the
220    /// moment any advanced sampler is engaged so they get routed
221    /// through `SamplerChain`.
222    pub fn is_classic(&self) -> bool {
223        self.dynamic_temp.is_none()
224            && self.typical_p >= 1.0
225            && self.top_n_sigma <= 0.0
226            && self.xtc_prob <= 0.0
227            && self.dry_multiplier <= 0.0
228            && self.mirostat == MirostatMode::Off
229            && self.frequency_penalty == 0.0
230            && self.presence_penalty == 0.0
231            && (self.repetition_penalty - 1.0).abs() < f32::EPSILON
232    }
233
234    /// Build a `rlx_runtime::SamplerChain` from these options. See
235    /// `rlx_runtime::SampleOpts::into_chain` — same ordering.
236    pub fn into_chain(&self) -> SamplerChain {
237        let mut b = SamplerChain::builder();
238        if (self.repetition_penalty - 1.0).abs() > f32::EPSILON
239            || self.frequency_penalty != 0.0
240            || self.presence_penalty != 0.0
241        {
242            b = b.push(RepetitionPenalty {
243                penalty: self.repetition_penalty,
244                frequency: self.frequency_penalty,
245                presence: self.presence_penalty,
246                last_n: self.repetition_window,
247            });
248        }
249        if self.dry_multiplier > 0.0 {
250            b = b.push(Dry {
251                multiplier: self.dry_multiplier,
252                base: self.dry_base,
253                allowed_length: self.dry_allowed_length,
254                max_ngram: self.dry_max_ngram,
255                sequence_breakers: self.dry_sequence_breakers
256                    [..self.dry_sequence_breakers_len as usize]
257                    .to_vec(),
258            });
259        }
260        if self.mirostat == MirostatMode::Off {
261            if let Some((mn, mx)) = self.dynamic_temp {
262                b = b.push(DynamicTemperature {
263                    min: mn,
264                    max: mx,
265                    exponent: self.dynamic_temp_exponent,
266                });
267            } else if self.temperature > 0.0 && (self.temperature - 1.0).abs() > f32::EPSILON {
268                b = b.push(Temperature {
269                    t: self.temperature,
270                });
271            }
272        }
273        if self.top_k > 0 {
274            b = b.push(TopK { k: self.top_k });
275        }
276        if self.typical_p < 1.0 && self.typical_p > 0.0 {
277            b = b.push(TypicalP {
278                p: self.typical_p,
279                min_keep: self.min_keep,
280            });
281        }
282        if self.top_p < 1.0 && self.top_p > 0.0 {
283            b = b.push(TopP {
284                p: self.top_p,
285                min_keep: self.min_keep,
286            });
287        }
288        if self.top_n_sigma > 0.0 {
289            b = b.push(TopNSigma {
290                n: self.top_n_sigma,
291            });
292        }
293        if self.xtc_prob > 0.0 && self.xtc_threshold > 0.0 {
294            b = b.push(Xtc {
295                threshold: self.xtc_threshold,
296                prob: self.xtc_prob,
297                min_keep: self.min_keep,
298            });
299        }
300        match self.mirostat {
301            MirostatMode::Off => {}
302            MirostatMode::V1 => {
303                b = b.push(MirostatV1 {
304                    tau: self.mirostat_tau,
305                    eta: self.mirostat_eta,
306                    m: self.mirostat_m,
307                });
308            }
309            MirostatMode::V2 => {
310                b = b.push(MirostatV2 {
311                    tau: self.mirostat_tau,
312                    eta: self.mirostat_eta,
313                });
314            }
315        }
316        b.build()
317    }
318}
319
320/// Sample one token id from a `[vocab]` logits slice. Returns the
321/// chosen index. Uses `opts.seed` only — prefer [`sample_token_at`]
322/// when generating multiple tokens from the same seed.
323pub fn sample_token(logits: &[f32], opts: SampleOpts) -> usize {
324    sample_token_at(logits, opts, 0)
325}
326
327/// Like [`sample_token`] but derives RNG from `opts.seed.wrapping_add(step)`.
328/// Callers should pass the zero-based index of the token being sampled
329/// (0 for the first post-prefill token, then 1, 2, …).
330pub fn sample_token_at(logits: &[f32], opts: SampleOpts, step: u64) -> usize {
331    // Fast classic path: untouched legacy behaviour for callers that
332    // don't engage any advanced sampler. Saves a Vec clone + chain build.
333    if opts.is_classic() {
334        return sample_token_inner(logits, opts, step);
335    }
336    sample_token_with_history(logits, &opts, &[], step) as usize
337}
338
339/// Sample one token using the full advanced sampler chain, optionally
340/// conditioned on `history` (newest token last). Required for
341/// `DRY` / `RepetitionPenalty` which use the token history. The chain
342/// is rebuilt per call — cheap (microseconds) compared to the kernel
343/// step that produced the logits. Callers that want amortised chain
344/// state (e.g. Mirostat's μ updates) should keep their own
345/// `SamplerState` across steps; see [`sample_token_stateful`].
346pub fn sample_token_with_history(
347    logits: &[f32],
348    opts: &SampleOpts,
349    history: &[u32],
350    step: u64,
351) -> u32 {
352    let mut state = SamplerState::new();
353    sample_token_stateful(logits, opts, history, step, &mut state)
354}
355
356/// Like [`sample_token_with_history`] but threads the `SamplerState`
357/// across calls. Use this when running Mirostat or other stateful
358/// samplers across a decode loop so μ persists between steps.
359pub fn sample_token_stateful(
360    logits: &[f32],
361    opts: &SampleOpts,
362    history: &[u32],
363    step: u64,
364    state: &mut SamplerState,
365) -> u32 {
366    let mut work = logits.to_vec();
367    let chain = opts.into_chain();
368    let mut rng = Philox4x32::new(opts.seed.wrapping_add(step));
369    chain.sample(&mut work, history, state, &mut rng)
370}
371
372fn sample_token_inner(logits: &[f32], opts: SampleOpts, step: u64) -> usize {
373    assert!(!logits.is_empty(), "sample_token: empty logits");
374
375    if opts.greedy {
376        return argmax(logits);
377    }
378
379    // 1. temperature: divide logits, in place on a working copy.
380    let mut work: Vec<f32> = if opts.temperature > 0.0 && opts.temperature != 1.0 {
381        logits.iter().map(|&l| l / opts.temperature).collect()
382    } else {
383        logits.to_vec()
384    };
385
386    // 2. top_k: mask everything outside the K highest logits.
387    if opts.top_k > 0 && opts.top_k < work.len() {
388        let mut indexed: Vec<(usize, f32)> =
389            work.iter().enumerate().map(|(i, &v)| (i, v)).collect();
390        // Partial sort: nth_element-style, descending.
391        indexed.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
392        let cutoff = indexed[opts.top_k - 1].1;
393        for v in work.iter_mut() {
394            if *v < cutoff {
395                *v = f32::NEG_INFINITY;
396            }
397        }
398    }
399
400    // 3. softmax (numerically stable).
401    let max = work.iter().copied().fold(f32::NEG_INFINITY, f32::max);
402    let mut probs: Vec<f32> = work.iter().map(|&l| (l - max).exp()).collect();
403    let sum: f32 = probs.iter().sum();
404    if sum > 0.0 {
405        for p in probs.iter_mut() {
406            *p /= sum;
407        }
408    } else {
409        // All -inf (shouldn't happen post-softmax): fall back to greedy.
410        return argmax(logits);
411    }
412
413    // 4. top_p: nucleus cutoff over sorted-descending probability.
414    if opts.top_p < 1.0 && opts.top_p > 0.0 {
415        let mut order: Vec<usize> = (0..probs.len()).collect();
416        order.sort_unstable_by(|&a, &b| {
417            probs[b]
418                .partial_cmp(&probs[a])
419                .unwrap_or(std::cmp::Ordering::Equal)
420        });
421        let mut cum = 0.0f32;
422        let mut keep = vec![false; probs.len()];
423        for &i in &order {
424            cum += probs[i];
425            keep[i] = true;
426            if cum >= opts.top_p {
427                break;
428            }
429        }
430        let mut renorm = 0.0f32;
431        for (i, p) in probs.iter_mut().enumerate() {
432            if !keep[i] {
433                *p = 0.0;
434            } else {
435                renorm += *p;
436            }
437        }
438        if renorm > 0.0 {
439            for p in probs.iter_mut() {
440                *p /= renorm;
441            }
442        }
443    }
444
445    // 5. multinomial sample.
446    let mut rng = Philox4x32::new(opts.seed.wrapping_add(step));
447    let u = rng.next_f32();
448    let mut acc = 0.0f32;
449    for (i, &p) in probs.iter().enumerate() {
450        acc += p;
451        if u < acc {
452            return i;
453        }
454    }
455    probs.len() - 1
456}
457
458fn argmax(xs: &[f32]) -> usize {
459    let mut best = 0usize;
460    let mut best_v = f32::NEG_INFINITY;
461    for (i, &v) in xs.iter().enumerate() {
462        if v > best_v {
463            best_v = v;
464            best = i;
465        }
466    }
467    best
468}
469
470/// HF-style repetition penalty using per-token occurrence counts.
471pub fn apply_repetition_penalty(
472    logits: &mut [f32],
473    counts: &std::collections::HashMap<u32, u32>,
474    penalty: f32,
475) {
476    if penalty <= 1.0 || counts.is_empty() {
477        return;
478    }
479    for (&tok, &count) in counts {
480        let idx = tok as usize;
481        if idx >= logits.len() {
482            continue;
483        }
484        let factor = penalty.powi(count as i32);
485        if logits[idx] > 0.0 {
486            logits[idx] /= factor;
487        } else {
488            logits[idx] *= factor;
489        }
490    }
491}
492
493/// Numerically-stable softmax over a logits row. Exposed so
494/// `Speculator` implementations can hand the resulting probability
495/// vector to `rlx-runtime::spec_decode` without re-implementing it.
496pub fn softmax_logits(logits: &[f32]) -> Vec<f32> {
497    let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
498    let mut p: Vec<f32> = logits.iter().map(|&l| (l - max).exp()).collect();
499    let sum: f32 = p.iter().sum();
500    if sum > 0.0 {
501        for v in p.iter_mut() {
502            *v /= sum;
503        }
504    }
505    p
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn greedy_matches_argmax() {
514        let logits = vec![0.1, 0.5, 0.2, -1.0, 0.49];
515        let t = sample_token(&logits, SampleOpts::greedy());
516        assert_eq!(t, 1);
517    }
518
519    #[test]
520    fn top_k_one_equals_greedy() {
521        let logits = vec![0.1, 0.5, 0.2, -1.0, 0.49];
522        let opts = SampleOpts::temperature(1.0, 42).with_top_k(1);
523        assert_eq!(sample_token(&logits, opts), 1);
524    }
525
526    #[test]
527    fn top_p_full_equals_unrestricted_multinomial() {
528        // With top_p=1.0 the nucleus mask is a no-op; sampling should
529        // still be deterministic given the seed and produce a valid id.
530        let logits = vec![1.0, 2.0, 0.5, 0.0];
531        let opts = SampleOpts::temperature(1.0, 7).with_top_p(1.0);
532        let t = sample_token(&logits, opts);
533        assert!(t < logits.len());
534    }
535
536    #[test]
537    fn deterministic_for_same_seed() {
538        let logits: Vec<f32> = (0..32).map(|i| (i as f32) * 0.01).collect();
539        let opts = SampleOpts::temperature(0.7, 123).with_top_k(4);
540        let a = sample_token(&logits, opts);
541        let b = sample_token(&logits, opts);
542        assert_eq!(a, b);
543    }
544
545    #[test]
546    fn top_p_truncates_low_mass() {
547        // One token has nearly all the mass; top_p=0.5 should keep
548        // only that token and pick it regardless of RNG.
549        let mut logits = vec![-10.0f32; 16];
550        logits[7] = 10.0;
551        let opts = SampleOpts::temperature(1.0, 999).with_top_p(0.5);
552        assert_eq!(sample_token(&logits, opts), 7);
553    }
554
555    #[test]
556    fn high_temperature_still_returns_valid_id() {
557        let logits = vec![0.0; 10];
558        let opts = SampleOpts::temperature(100.0, 1);
559        let t = sample_token(&logits, opts);
560        assert!(t < 10);
561    }
562
563    #[test]
564    fn sample_token_at_varies_rng_by_step() {
565        let logits: Vec<f32> = (0..64).map(|i| (i as f32) * 0.05).collect();
566        let opts = SampleOpts::temperature(0.9, 100).with_top_p(1.0);
567        let a = sample_token_at(&logits, opts, 0);
568        let b = sample_token_at(&logits, opts, 1);
569        assert_ne!(a, b);
570    }
571
572    #[test]
573    fn mirostat_v2_routes_through_chain() {
574        // Engaging Mirostat flips `is_classic()` and routes through the
575        // SamplerChain path. Check it produces a valid token without
576        // diverging.
577        let logits: Vec<f32> = (0..32).map(|i| (i as f32) * 0.1).collect();
578        let opts = SampleOpts::temperature(0.7, 42).with_mirostat_v2(5.0, 0.1);
579        assert!(!opts.is_classic());
580        let t = sample_token(&logits, opts);
581        assert!(t < 32);
582    }
583
584    #[test]
585    fn dynamic_temp_routes_through_chain() {
586        let logits: Vec<f32> = (0..32).map(|i| (i as f32) * 0.1).collect();
587        let opts = SampleOpts::temperature(1.0, 7).with_dynamic_temp(0.5, 1.5);
588        assert!(!opts.is_classic());
589        let t = sample_token(&logits, opts);
590        assert!(t < 32);
591    }
592
593    #[test]
594    fn classic_path_unchanged_with_new_fields() {
595        // Greedy without any advanced sampler must still be classic
596        // (no behavior change for existing callers).
597        let opts = SampleOpts::greedy();
598        assert!(opts.is_classic());
599        let opts = SampleOpts::temperature(0.7, 1)
600            .with_top_k(40)
601            .with_top_p(0.9);
602        assert!(opts.is_classic());
603    }
604
605    #[test]
606    fn sample_token_with_history_threads_dry() {
607        // History [0,1,0,1,0] + DRY → token 1 (continuing the 0-1 pattern)
608        // should be penalised.
609        let history = vec![0u32, 1, 0, 1, 0];
610        let logits = vec![0.0, 0.0];
611        let opts = SampleOpts::temperature(1.0, 0).with_dry(1.0, 2.0, 2);
612        let mut state = SamplerState::new();
613        let t = sample_token_stateful(&logits, &opts, &history, 0, &mut state);
614        assert!(t < 2);
615    }
616}