spark_runtime/
sampler.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Token sampling strategies.
4//!
5//! Phase 1: Greedy argmax (CPU-side D2H + argmax).
6//! Future: temperature, top-k, top-p, min-p, repetition penalty.
7
8use std::sync::atomic::Ordering;
9
10use crate::gpu::{DevicePtr, GpuBackend};
11use anyhow::Result;
12
13// The entropy gauges are fields of the single run mailbox,
14// `crate::run_metrics::RunMetrics` — see that module for why one static and
15// not none, and why it is cleared at run start.
16
17/// Read the most recent per-token entropy (nats).
18pub fn last_entropy() -> f32 {
19    f32::from_bits(
20        crate::run_metrics::metrics()
21            .last_entropy
22            .load(Ordering::Relaxed),
23    )
24}
25
26/// Total tokens with entropy < 0.3 (potential degeneration).
27pub fn low_entropy_token_count() -> u64 {
28    crate::run_metrics::metrics()
29        .low_entropy_tokens
30        .load(Ordering::Relaxed)
31}
32
33/// Total tokens sampled (for computing low-entropy ratio).
34pub fn total_sampled_token_count() -> u64 {
35    crate::run_metrics::metrics()
36        .total_sampled_tokens
37        .load(Ordering::Relaxed)
38}
39
40pub(super) fn record_entropy(entropy: f32) {
41    let m = crate::run_metrics::metrics();
42    m.last_entropy.store(entropy.to_bits(), Ordering::Relaxed);
43    m.total_sampled_tokens.fetch_add(1, Ordering::Relaxed);
44    if entropy < 0.3 {
45        m.low_entropy_tokens.fetch_add(1, Ordering::Relaxed);
46    }
47}
48
49/// Sampling parameters for a request.
50#[derive(Debug, Clone)]
51pub struct SamplingParams {
52    /// Temperature (0.0 = greedy).
53    pub temperature: f32,
54    /// Top-k: keep only the k highest-probability tokens before sampling.
55    /// 0 = disabled (use all tokens).
56    pub top_k: u32,
57    /// Top-p (nucleus): keep smallest set of tokens whose cumulative probability >= p.
58    /// 1.0 = disabled.
59    pub top_p: f32,
60    /// Top-n-sigma: filter tokens in logit space before temperature scaling.
61    /// Keep only tokens with logit >= mean - n*sigma. Temperature-invariant.
62    /// 0.0 = disabled. Recommended: 1.0 for NVFP4 models.
63    pub top_n_sigma: f32,
64    /// Min-p: keep tokens with prob >= min_p * max_prob (post-softmax).
65    /// 0.0 = disabled. Recommended: 0.05-0.1.
66    pub min_p: f32,
67    /// Per-token logit bias: (token_id, bias_value) pairs.
68    /// Applied additively to raw logits before any filtering.
69    pub logit_bias: Vec<(u32, f32)>,
70    /// Repetition penalty: multiply logits of previously-seen tokens.
71    /// 1.0 = disabled. Recommended: 1.05-1.1.
72    pub repetition_penalty: f32,
73    /// Repetition penalty window: only consider the last N tokens.
74    /// 0 = full history (default). Recommended: 64 for long-form generation.
75    pub repetition_penalty_window: u32,
76    /// Presence penalty (OpenAI-style): flat additive penalty for each token that
77    /// appeared at least once. Range [-2.0, 2.0], 0.0 = disabled.
78    pub presence_penalty: f32,
79    /// Frequency penalty (OpenAI-style): additive penalty proportional to occurrence
80    /// count. Range [-2.0, 2.0], 0.0 = disabled.
81    pub frequency_penalty: f32,
82    /// LZ penalty: penalize tokens that extend repeated n-gram patterns.
83    /// 0.0 = disabled. 1.0 = moderate (default). Based on arXiv:2504.20131.
84    pub lz_penalty: f32,
85    /// DRY (Don't Repeat Yourself) penalty multiplier. From llama.cpp.
86    /// Uses Z-algorithm O(n) sequence matching with exponential penalty.
87    /// 0.0 = disabled. Recommended: 0.8.
88    pub dry_multiplier: f32,
89    /// DRY penalty base for exponential scaling. penalty = multiplier * base^(match_len - allowed_len).
90    /// Recommended: 1.75.
91    pub dry_base: f32,
92    /// DRY minimum match length before penalty applies. Sequences shorter than this are ignored.
93    /// Recommended: 2.
94    pub dry_allowed_length: u32,
95    /// DRY sequence breaker token IDs. Delimiters (newlines, colons, quotes, braces) that
96    /// reset sequence tracking. Critical for JSON/tool call output where structural tokens repeat.
97    pub dry_sequence_breakers: Vec<u32>,
98    /// Maximum tokens to generate.
99    pub max_tokens: usize,
100    /// Stop token IDs.
101    pub stop_token_ids: Vec<u32>,
102    /// Seed for deterministic sampling. When Some, the RNG is seeded with this
103    /// value for reproducible output. None = non-deterministic (thread_rng).
104    pub seed: Option<u64>,
105}
106
107impl SamplingParams {
108    /// Greedy sampling with a max token limit.
109    pub fn greedy(max_tokens: usize) -> Self {
110        Self {
111            temperature: 0.0,
112            top_k: 0,
113            top_p: 1.0,
114            top_n_sigma: 0.0,
115            min_p: 0.0,
116            logit_bias: Vec::new(),
117            repetition_penalty: 1.0,
118            repetition_penalty_window: 0,
119            presence_penalty: 0.0,
120            frequency_penalty: 0.0,
121            lz_penalty: 0.0,
122            dry_multiplier: 0.0,
123            dry_base: 1.75,
124            dry_allowed_length: 2,
125            dry_sequence_breakers: Vec::new(),
126            max_tokens,
127            stop_token_ids: Vec::new(),
128            seed: None,
129        }
130    }
131
132    pub fn is_greedy(&self) -> bool {
133        self.temperature == 0.0
134    }
135}
136
137/// Sampler that picks tokens from logits.
138pub struct Sampler {
139    /// Reusable host buffer for BF16 logits D2H copy.
140    logits_host: Vec<u8>,
141    /// FP32 expanded logits for accurate sampling.
142    logits_f32: Vec<f32>,
143    /// Vocab size.
144    vocab_size: usize,
145}
146
147impl Sampler {
148    pub fn new(vocab_size: usize) -> Self {
149        let logits_host = vec![0u8; vocab_size * 2]; // BF16 from GPU
150        let logits_f32 = vec![0.0f32; vocab_size]; // FP32 for sampling
151        Self {
152            logits_host,
153            logits_f32,
154            vocab_size,
155        }
156    }
157
158    /// Copy BF16 logits from GPU, expand to FP32, return FP32 slice.
159    fn fetch_logits_f32(&mut self, logits_ptr: DevicePtr, gpu: &dyn GpuBackend) -> Result<&[f32]> {
160        let byte_len = self.vocab_size * 2;
161        gpu.copy_d2h(logits_ptr, &mut self.logits_host[..byte_len])?;
162        // BF16 → FP32 expansion: full precision for sampling
163        for i in 0..self.vocab_size {
164            self.logits_f32[i] = bf16_to_f32(self.logits_host[i * 2], self.logits_host[i * 2 + 1]);
165        }
166        // Raw-logits dump for numerics triage (`ATLAS_DUMP_LOGITS_PATH=/dir`):
167        // appends each stochastic-sample step's FP32 logits as one row of a
168        // flat binary file. The reporting APIs only expose post-softmax
169        // values, which cannot distinguish a genuinely flat distribution
170        // from a mis-scaled one — the raw values can.
171        if let Ok(dir) = std::env::var("ATLAS_DUMP_LOGITS_PATH") {
172            use std::io::Write;
173            let path = std::path::Path::new(&dir).join("logits_fetch.bin");
174            if let Ok(mut f) = std::fs::OpenOptions::new()
175                .create(true)
176                .append(true)
177                .open(&path)
178            {
179                let bytes: &[u8] = unsafe {
180                    std::slice::from_raw_parts(
181                        self.logits_f32.as_ptr() as *const u8,
182                        self.vocab_size * 4,
183                    )
184                };
185                let _ = f.write_all(bytes);
186            }
187        }
188        Ok(&self.logits_f32[..self.vocab_size])
189    }
190
191    /// Sample a token from logits on the GPU.
192    ///
193    /// `logits_ptr` points to `[vocab_size]` BF16 values on device.
194    /// Reads BF16, expands to FP32, then samples with full precision.
195    pub fn sample(
196        &mut self,
197        logits_ptr: DevicePtr,
198        params: &SamplingParams,
199        gpu: &dyn GpuBackend,
200    ) -> Result<u32> {
201        if params.is_greedy() {
202            // Greedy: BF16 argmax is fine (argmax is robust to BF16 quantization)
203            let byte_len = self.vocab_size * 2;
204            gpu.copy_d2h(logits_ptr, &mut self.logits_host[..byte_len])?;
205            return Ok(argmax_bf16(&self.logits_host[..byte_len]));
206        }
207        // Stochastic: expand to FP32 for accurate sampling
208        let f32_logits = self.fetch_logits_f32(logits_ptr, gpu)?;
209        let f32_bytes: &[u8] = unsafe {
210            std::slice::from_raw_parts(f32_logits.as_ptr() as *const u8, f32_logits.len() * 4)
211        };
212        Ok(sample_with_params(f32_bytes, params))
213    }
214
215    /// Sample a batch of tokens (one per sequence in the batch).
216    ///
217    /// `logits_ptr` points to [batch_size, vocab_size] BF16 values.
218    pub fn sample_batch(
219        &mut self,
220        logits_ptr: DevicePtr,
221        batch_size: usize,
222        params: &[&SamplingParams],
223        gpu: &dyn GpuBackend,
224    ) -> Result<Vec<u32>> {
225        let total_bytes = batch_size * self.vocab_size * 2; // BF16
226        if self.logits_host.len() < total_bytes {
227            self.logits_host.resize(total_bytes, 0);
228        }
229        gpu.copy_d2h(logits_ptr, &mut self.logits_host[..total_bytes])?;
230
231        let stride_bf16 = self.vocab_size * 2;
232        let mut tokens = Vec::with_capacity(batch_size);
233        for i in 0..batch_size {
234            let start = i * stride_bf16;
235            let end = start + stride_bf16;
236            let p = params.get(i).copied().unwrap_or(params[0]);
237            tokens.push(if p.is_greedy() {
238                argmax_bf16(&self.logits_host[start..end])
239            } else {
240                // Expand BF16 → FP32 for accurate stochastic sampling
241                if self.logits_f32.len() < self.vocab_size {
242                    self.logits_f32.resize(self.vocab_size, 0.0);
243                }
244                for j in 0..self.vocab_size {
245                    self.logits_f32[j] = bf16_to_f32(
246                        self.logits_host[start + j * 2],
247                        self.logits_host[start + j * 2 + 1],
248                    );
249                }
250                let f32_bytes: &[u8] = unsafe {
251                    std::slice::from_raw_parts(
252                        self.logits_f32.as_ptr() as *const u8,
253                        self.vocab_size * 4,
254                    )
255                };
256                sample_with_params(f32_bytes, p)
257            });
258        }
259        Ok(tokens)
260    }
261}
262
263/// Sampling pipeline: repetition_penalty → top-n-sigma → temperature → top-k → softmax → min-p → top-p → sample.
264///
265/// `data` contains FP32 logits (4 bytes per element, little-endian).
266/// `token_history`: previous token IDs for repetition penalty (empty = no penalty).
267/// LZ penalty: penalize tokens that would extend repeated n-gram patterns
268/// in the recent token history. Based on arXiv:2504.20131.
269///
270/// For each candidate token that appears in the history, check if appending it
271/// creates a repeated 3/4/5-gram. Penalize proportional to n-gram length and
272/// frequency: `logit -= penalty * (ngram_len - 2) * count`.
273pub fn apply_lz_penalty(logits: &mut [f32], history: &[u32], penalty: f32) {
274    use std::collections::HashSet;
275    // Window the history to last 256 tokens to avoid penalizing
276    // cross-turn structural repetition (e.g., JSON keys in tool calls).
277    const LZ_WINDOW: usize = 256;
278    let history = if history.len() > LZ_WINDOW {
279        &history[history.len() - LZ_WINDOW..]
280    } else {
281        history
282    };
283    let n = logits.len();
284    // Only check tokens that appear in history (others can't form repeats)
285    let token_set: HashSet<u32> = history.iter().copied().collect();
286    for &candidate in &token_set {
287        if (candidate as usize) >= n {
288            continue;
289        }
290        for ngram_len in 3..=5usize {
291            if history.len() < ngram_len {
292                continue;
293            }
294            // The n-gram that would form: history[-(ngram_len-1)..] ++ [candidate]
295            let suffix = &history[history.len() - (ngram_len - 1)..];
296            let count = history
297                .windows(ngram_len)
298                .filter(|w| w[..ngram_len - 1] == *suffix && w[ngram_len - 1] == candidate)
299                .count();
300            if count > 0 {
301                logits[candidate as usize] -= penalty * (ngram_len as f32 - 2.0) * count as f32;
302            }
303        }
304    }
305}
306
307/// DRY (Don't Repeat Yourself) penalty. Ported from llama.cpp PR #9702.
308///
309/// Uses suffix matching to find the longest repeated sequence ending at the current
310/// position in the token history. For each candidate token, checks if appending it
311/// would extend a previously-seen sequence. Applies exponential penalty:
312///   `penalty = multiplier * base^(match_length - allowed_length)`
313///
314/// Sequence breakers (e.g., newlines, quotes, braces) reset tracking, preventing
315/// false positives in structured output like JSON tool calls.
316pub fn apply_dry_penalty(
317    logits: &mut [f32],
318    history: &[u32],
319    multiplier: f32,
320    base: f32,
321    allowed_length: u32,
322    breakers: &[u32],
323) {
324    if history.is_empty() || multiplier == 0.0 {
325        return;
326    }
327    let n = logits.len();
328    let hist_len = history.len();
329    let allowed = allowed_length as usize;
330
331    // Build suffix match table: for each position i in history, find the length
332    // of the longest suffix of history[..hist_len] that matches starting at i.
333    // This is a simplified Z-function approach.
334    let mut match_lengths = vec![0usize; hist_len];
335    for i in (0..hist_len.saturating_sub(1)).rev() {
336        // Check if history[i] is a sequence breaker — reset match length
337        if breakers.contains(&history[i]) {
338            match_lengths[i] = 0;
339            continue;
340        }
341        // Match history[i..] against history[hist_len - 1 - k..] for increasing k
342        let mut len = 0;
343        let mut j = i;
344        let mut k = hist_len - 1;
345        while j < k && history[j] == history[k] {
346            len += 1;
347            if breakers.contains(&history[j]) {
348                break;
349            }
350            if j == 0 {
351                break;
352            }
353            j -= 1;
354            k -= 1;
355        }
356        // Correction: we want the match starting at position (i) comparing with the suffix
357        // This gives us: if we see history[i..i+len] == history[hist_len-len..hist_len],
358        // then the token at history[i+len] (if it existed) would extend the repeat.
359        match_lengths[i] = len;
360    }
361
362    // For each position where a match of length > allowed was found, the token
363    // that FOLLOWS the match in history (history[i - 1] looking backward from the match start)
364    // would extend a repeat if generated next. Penalize it.
365    #[allow(clippy::needless_range_loop)]
366    for i in 0..hist_len.saturating_sub(1) {
367        let len = match_lengths[i];
368        if len > allowed {
369            // The token at history[i + len] (one past the match) would extend the repeat
370            let extend_pos = i + len;
371            if extend_pos < hist_len {
372                let token = history[extend_pos] as usize;
373                if token < n {
374                    let penalty = multiplier * base.powi((len - allowed) as i32);
375                    logits[token] -= penalty;
376                }
377            }
378        }
379    }
380}
381
382/// Apply repetition / presence / frequency / LZ / DRY penalties and
383/// per-token logit bias to `logits` IN PLACE, using `token_history`.
384///
385/// SSOT for the pre-filter logit-modification block. Extracted verbatim
386/// from `sample_with_params_seeded` (the non-MTP sampling path) so the
387/// MTP verify path (`verify_pick_with_pipeline`) and bootstrap path
388/// (`sample_token_with_grammar`) apply the *same* penalties+bias the
389/// non-MTP path does — previously those two paths emitted tokens with no
390/// penalties (hardcoded `repetition_penalty=1.0`, empty history), so the
391/// configured `repetition_penalty`/`dry_multiplier` from MODEL.toml never
392/// reached MTP-emitted tokens and the model degenerated into repeated
393/// tool-call argument junk.
394///
395/// BACKWARD-COMPATIBLE / ADDITIVE: a mathematical no-op when
396/// `repetition_penalty == 1.0`, `presence_penalty == 0.0`,
397/// `frequency_penalty == 0.0`, `lz_penalty <= 0.0`, `dry_multiplier <= 0.0`
398/// and `logit_bias` is empty — every branch below is individually gated on
399/// its parameter being non-neutral, so the NVFP4 / Gemma / Mistral presets
400/// (which use those neutral values) are byte-for-byte unchanged.
401pub fn apply_penalties_and_bias(
402    logits: &mut [f32],
403    params: &SamplingParams,
404    token_history: &[u32],
405) {
406    let n = logits.len();
407
408    // ── 0. Windowed repetition penalty: penalize recently seen tokens ──
409    // Window=0 uses full history; window>0 uses only the last N tokens.
410    // Skip when rep_penalty <= 0.0 — the divide at the next branch would
411    // produce inf for positive logits and 0 for negative, poisoning the
412    // distribution. (Caller intent for 0.0 is unclear; treat as no-op.)
413    let rep_penalty = params.repetition_penalty;
414    if rep_penalty != 1.0 && rep_penalty > 0.0 && !token_history.is_empty() {
415        let window = params.repetition_penalty_window as usize;
416        let effective = if window > 0 && window < token_history.len() {
417            &token_history[token_history.len() - window..]
418        } else {
419            token_history
420        };
421        for &tid in effective {
422            if (tid as usize) < n {
423                let logit = &mut logits[tid as usize];
424                if *logit > 0.0 {
425                    *logit /= rep_penalty;
426                } else {
427                    *logit *= rep_penalty;
428                }
429            }
430        }
431    }
432
433    // ── 0b. OpenAI-style additive penalties (presence + frequency) ──
434    // Presence: z'ⱼ = zⱼ − β (flat, if token appeared at all)
435    // Frequency: z'ⱼ = zⱼ − α · cⱼ (proportional to occurrence count)
436    let freq_pen = params.frequency_penalty;
437    let pres_pen = params.presence_penalty;
438    if (freq_pen != 0.0 || pres_pen != 0.0) && !token_history.is_empty() {
439        let window = params.repetition_penalty_window as usize;
440        let effective = if window > 0 && window < token_history.len() {
441            &token_history[token_history.len() - window..]
442        } else {
443            token_history
444        };
445        // Count occurrences per token
446        let mut counts = std::collections::HashMap::<u32, u32>::new();
447        for &tid in effective {
448            *counts.entry(tid).or_insert(0) += 1;
449        }
450        for (&tid, &count) in &counts {
451            if (tid as usize) < n {
452                logits[tid as usize] -= freq_pen * count as f32 + pres_pen;
453            }
454        }
455    }
456
457    // ── 0c. LZ penalty: penalize tokens that extend repeated n-gram patterns ──
458    if params.lz_penalty > 0.0 && token_history.len() >= 4 {
459        apply_lz_penalty(logits, token_history, params.lz_penalty);
460    }
461
462    // ── 0d. DRY penalty: exponential penalty for extending repeated sequences ──
463    if params.dry_multiplier > 0.0 && token_history.len() >= 3 {
464        apply_dry_penalty(
465            logits,
466            token_history,
467            params.dry_multiplier,
468            params.dry_base,
469            params.dry_allowed_length,
470            &params.dry_sequence_breakers,
471        );
472    }
473
474    // ── 0e. Logit bias: additive per-token bias ──
475    for &(tid, bias) in &params.logit_bias {
476        if (tid as usize) < n {
477            logits[tid as usize] += bias;
478        }
479    }
480}
481
482mod sample_impl;
483pub use sample_impl::{sample_with_params_history, sample_with_params_seeded};
484
485/// Convenience wrapper: sample without token history (no repetition penalty).
486pub fn sample_with_params(data: &[u8], params: &SamplingParams) -> u32 {
487    sample_with_params_history(data, params, &[])
488}
489
490/// Argmax over an f32 slice with the strict-`>` FIRST-index-wins tie-break.
491///
492/// SSOT for this pick: the verify path (`spark-server`'s
493/// `verify_pipeline_helper/argmax.rs`) calls here too. The naive
494/// `if v > best { best = v; idx = i }` loop carries a dependency through BOTH
495/// the running value and the index, which blocks vectorisation — measured
496/// 1.19 ms for 4x248k on the verify path before the two-pass rewrite (5.95x).
497///
498/// Equivalence to that loop, including the awkward cases: `>` is false for
499/// NaN in both passes so NaN never wins (all-NaN => -inf max, pass 2 finds no
500/// equal, falls back to 0 — same as the loop); IEEE -0.0 == +0.0 so neither
501/// `>` nor `==` separates them and the first zero encountered is returned
502/// either way. `f32::max` is deliberately avoided (it returns the non-NaN
503/// operand, which would let a NaN-adjacent value win where `>` ignored it).
504pub fn argmax_first_wins_f32(v: &[f32]) -> u32 {
505    const LANES: usize = 8;
506    let mut acc = [f32::NEG_INFINITY; LANES];
507    let mut chunks = v.chunks_exact(LANES);
508    for c in &mut chunks {
509        for (a, &x) in acc.iter_mut().zip(c) {
510            if x > *a {
511                *a = x;
512            }
513        }
514    }
515    let mut best = f32::NEG_INFINITY;
516    for &a in acc.iter() {
517        if a > best {
518            best = a;
519        }
520    }
521    for &x in chunks.remainder() {
522        if x > best {
523            best = x;
524        }
525    }
526    v.iter()
527        .position(|&x| x == best)
528        .unwrap_or(0)
529        .try_into()
530        .unwrap_or(0)
531}
532
533/// Argmax over FP32 values stored as raw bytes (4 bytes per element, little-endian).
534/// First-index-wins, identical to [`argmax_first_wins_f32`] — same two-pass
535/// shape, iterating the byte chunks directly so no `Vec<f32>` is materialised.
536pub fn argmax_f32(data: &[u8]) -> u32 {
537    debug_assert!(data.len().is_multiple_of(4));
538    let vals = || {
539        data.chunks_exact(4)
540            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
541    };
542    // Lane-based pass 1 for the same reason as `argmax_first_wins_f32`: a
543    // serial float-max fold is a strict-IEEE dependency chain the compiler
544    // will not vectorise.
545    const LANES: usize = 8;
546    let mut acc = [f32::NEG_INFINITY; LANES];
547    let mut it = data.chunks_exact(4 * LANES);
548    for block in &mut it {
549        for (a, c) in acc.iter_mut().zip(block.chunks_exact(4)) {
550            let x = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
551            if x > *a {
552                *a = x;
553            }
554        }
555    }
556    let mut best = f32::NEG_INFINITY;
557    for &a in acc.iter() {
558        if a > best {
559            best = a;
560        }
561    }
562    for c in it.remainder().chunks_exact(4) {
563        let x = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
564        if x > best {
565            best = x;
566        }
567    }
568    vals()
569        .position(|x| x == best)
570        .unwrap_or(0)
571        .try_into()
572        .unwrap_or(0)
573}
574
575/// Legacy: argmax over BF16 values (still used by argmax_on_device fallback).
576pub fn argmax_bf16(data: &[u8]) -> u32 {
577    debug_assert!(data.len().is_multiple_of(2));
578    let n = data.len() / 2;
579    if n == 0 {
580        return 0;
581    }
582    let mut best_idx: u32 = 0;
583    let mut best_val = bf16_to_f32(data[0], data[1]);
584    for i in 1..n {
585        let val = bf16_to_f32(data[i * 2], data[i * 2 + 1]);
586        if val > best_val {
587            best_val = val;
588            best_idx = i as u32;
589        }
590    }
591    best_idx
592}
593
594/// Convert BF16 (2 bytes, little-endian) to f32.
595#[inline]
596fn bf16_to_f32(lo: u8, hi: u8) -> f32 {
597    let bits = (lo as u32) | ((hi as u32) << 8);
598    f32::from_bits(bits << 16)
599}
600
601#[cfg(test)]
602mod tests;