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 ¶ms.dry_sequence_breakers,
471 );
472 }
473
474 // ── 0e. Logit bias: additive per-token bias ──
475 for &(tid, bias) in ¶ms.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;