spark_runtime/sampler/sample_impl.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2//
3// Core sampling pipeline (`sample_with_params_history` + seeded
4// implementation). Split out of `sampler.rs` to keep the parent file
5// under the 500-line cap. The parent re-exports these via `pub use`.
6
7use super::{SamplingParams, apply_penalties_and_bias, record_entropy};
8
9pub fn sample_with_params_history(
10 data: &[u8],
11 params: &SamplingParams,
12 token_history: &[u32],
13) -> u32 {
14 sample_with_params_seeded(data, params, token_history, params.seed)
15}
16
17/// Core sampling pipeline with explicit seed control.
18/// `seed` overrides the RNG for deterministic sampling. None = thread_rng.
19pub fn sample_with_params_seeded(
20 data: &[u8],
21 params: &SamplingParams,
22 token_history: &[u32],
23 seed: Option<u64>,
24) -> u32 {
25 let n = data.len() / 4;
26 let top_k = params.top_k as usize;
27 let top_p = params.top_p;
28 let top_n_sigma = params.top_n_sigma;
29 let min_p = params.min_p;
30
31 // Read raw logits into a mutable vec for in-place modifications.
32 // Penalties (repetition / presence / frequency / LZ / DRY) and
33 // logit_bias are applied to `raw_logits` BEFORE the greedy bypass
34 // below, so they take effect even at `temperature == 0.0`. Atlas
35 // previously short-circuited to `argmax(raw_logits)` for greedy,
36 // silently dropping caller-configured penalties — the 2026-05-01
37 // sweep showed this caused Gemma-4-31B's haiku to enter a
38 // repetition loop ("la... la... laaaL!") even with the model's
39 // configured `repetition_penalty=1.1` because the harness uses
40 // `temperature=0`. HF Transformers, vLLM, and llama.cpp all run
41 // LogitsProcessor (penalties + bias) before greedy argmax — Atlas
42 // is the outlier here.
43 // Chunked conversion instead of per-element indexed `read_f32`: the
44 // indexed form does four bounds-checked byte reads per element through a
45 // shared counter, which the compiler lowers poorly; `chunks_exact` is the
46 // canonical vectorisable shape. Same bytes, same values.
47 let mut raw_logits: Vec<f32> = data
48 .chunks_exact(4)
49 .take(n)
50 .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
51 .collect();
52
53 // ── 0. Penalties (repetition / presence / frequency / LZ / DRY) +
54 // logit bias, applied in place via the shared SSOT helper.
55 // Identical behavior to the previous inline block; the same helper is
56 // now also invoked on the MTP verify + bootstrap paths so all three
57 // emit/verify sites apply the same penalties+bias+history.
58 apply_penalties_and_bias(&mut raw_logits, params, token_history);
59
60 // ── Greedy bypass (post-penalty argmax) ──
61 // At `temperature == 0.0` we return argmax of the penalty/bias-modified
62 // logits. We bypass top_n_sigma, temperature scaling, top_k, top_p,
63 // min_p — all of those either filter (set values to -inf) or apply
64 // monotonic transforms, neither of which can re-order the maximum. Only
65 // penalties + logit_bias actually re-order logits, so as long as those
66 // ran first, this argmax is correct AND respects caller config.
67 if params.temperature <= 0.0 {
68 return greedy_pick_last_wins(&raw_logits);
69 }
70 let temperature = params.temperature;
71
72 // ── 1. Top-n-sigma: filter noise in logit space (temperature-invariant) ──
73 // Keep tokens with logit >= mean - n*sigma. Filters NVFP4 quantization noise.
74 if top_n_sigma > 0.0 {
75 let sum: f32 = raw_logits.iter().sum();
76 let mean = sum / n as f32;
77 let var: f32 = raw_logits.iter().map(|x| (x - mean).powi(2)).sum::<f32>() / n as f32;
78 let sigma = var.sqrt();
79 if sigma > 0.0 {
80 let threshold = mean - top_n_sigma * sigma;
81 for logit in raw_logits.iter_mut() {
82 if *logit < threshold {
83 *logit = f32::NEG_INFINITY;
84 }
85 }
86 }
87 }
88
89 // ── 2. Temperature scaling ──
90 let mut logits: Vec<(u32, f32)> = raw_logits
91 .iter()
92 .enumerate()
93 .filter(|(_, v)| v.is_finite()) // Skip -inf tokens from top-n-sigma
94 .map(|(i, v)| (i as u32, v / temperature))
95 .collect();
96
97 if logits.is_empty() {
98 // Fallback: if top-n-sigma filtered everything, use argmax of original
99 return raw_logits
100 .iter()
101 .enumerate()
102 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
103 .map(|(i, _)| i as u32)
104 .unwrap_or(0);
105 }
106
107 // ── 3. Rank-dependent filtering (top-k / min-p / top-p) ──
108 // These need descending order — but a full O(n·log n) sort of the whole
109 // ~248K vocab every token is wasteful when top_k caps survivors at a small
110 // k (the model default here is top_k=20; a full sort was ~2.3ms/tok). Use
111 // an O(n) quickselect to isolate the top-k, then sort only those k. Pure
112 // temperature sampling (no top_k/top_p/min_p) needs no ordering at all —
113 // the multinomial draw below is order-independent — so skip sorting.
114 let cmp_desc =
115 |a: &(u32, f32), b: &(u32, f32)| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal);
116 let sorted = if top_k > 0 && top_k < logits.len() {
117 // Quickselect the top-k into [0, top_k) (O(n)), drop the rest, then
118 // sort just the k survivors (O(k·log k)). Identical result to
119 // full-sort-then-truncate; min-p/top-p below run over these k.
120 logits.select_nth_unstable_by(top_k, cmp_desc);
121 logits.truncate(top_k);
122 logits.sort_unstable_by(cmp_desc);
123 true
124 } else if min_p > 0.0 || top_p < 1.0 {
125 // No top-k cap, but min-p/top-p still need full descending order.
126 logits.sort_unstable_by(cmp_desc);
127 true
128 } else {
129 false
130 };
131
132 // ── 4. Softmax ──
133 // `sorted` ⇒ logits[0] is the max; otherwise reduce for it. min_p/top_p
134 // below are only reachable when `sorted` is true, so their reliance on
135 // descending order still holds.
136 let max_val = if sorted {
137 logits[0].1
138 } else {
139 logits
140 .iter()
141 .map(|&(_, v)| v)
142 .fold(f32::NEG_INFINITY, f32::max)
143 };
144 let mut probs: Vec<(u32, f32)> = logits
145 .iter()
146 .map(|&(idx, logit)| (idx, (logit - max_val).exp()))
147 .collect();
148
149 // ── 4b. Entropy: H = -Σ p·ln(p) over the post-softmax distribution ──
150 {
151 let sum: f32 = probs.iter().map(|p| p.1).sum();
152 if sum > 0.0 {
153 let inv = 1.0 / sum;
154 let h: f32 = probs
155 .iter()
156 .map(|&(_, w)| {
157 let p = w * inv;
158 if p > 1e-10 { -p * p.ln() } else { 0.0 }
159 })
160 .sum();
161 record_entropy(h);
162 }
163 }
164
165 // ── 5. Min-p: keep tokens with prob >= min_p * max_prob ──
166 if min_p > 0.0 {
167 let max_prob = probs[0].1; // Already sorted descending
168 let threshold = min_p * max_prob;
169 probs.retain(|p| p.1 >= threshold);
170 }
171
172 // ── 6. Top-p (nucleus) ──
173 if top_p < 1.0 {
174 let sum: f32 = probs.iter().map(|p| p.1).sum();
175 let mut cumsum = 0.0f32;
176 let mut cutoff = probs.len();
177 for (i, &(_, prob)) in probs.iter().enumerate() {
178 cumsum += prob / sum;
179 if cumsum >= top_p {
180 cutoff = i + 1;
181 break;
182 }
183 }
184 probs.truncate(cutoff);
185 }
186
187 // Multinomial sample from the filtered distribution.
188 let sum: f32 = probs.iter().map(|p| p.1).sum();
189 let random_val: f32 = if let Some(s) = seed {
190 use rand::Rng;
191 use rand::SeedableRng;
192 let mut rng = rand::rngs::StdRng::seed_from_u64(s);
193 rng.r#gen::<f32>()
194 } else {
195 rand::random::<f32>()
196 };
197 let threshold = random_val * sum;
198 let mut cumsum = 0.0f32;
199 for &(idx, prob) in &probs {
200 cumsum += prob;
201 if cumsum >= threshold {
202 return idx;
203 }
204 }
205 probs.last().map_or(0, |p| p.0)
206}
207
208/// Greedy pick with `max_by(partial_cmp.unwrap_or(Equal))` semantics — the
209/// tie-break this path has ALWAYS had, which is LAST-index-wins (Rust's
210/// `max_by` returns the last of several equal maxima). This is the OPPOSITE of
211/// the verify path's first-index-wins `argmax_first_wins`, so that function
212/// must NOT be ported here: quantized logits produce exact ties, and flipping
213/// the tie-break would change emitted tokens.
214///
215/// NaN is the reason for the guarded structure. `partial_cmp` with a NaN
216/// returns `None`, mapped to `Equal` — and under `max_by`'s last-wins rule an
217/// "equal" NaN DISPLACES the current maximum, so a NaN anywhere after the true
218/// max changes the historical result. That quirk cannot be reproduced by a
219/// max-then-find pass, so: pass 1 computes the max over 8 independent lanes
220/// (vectorisable, no index dependency) while also detecting NaN; if ANY NaN is
221/// present the original `max_by` expression runs verbatim (bit-identical by
222/// construction); otherwise the answer is the LAST index equal to the max,
223/// which is exactly what `max_by` returns on NaN-free input (including
224/// -0.0/+0.0 ties, where `partial_cmp` says Equal and `==` agrees).
225fn greedy_pick_last_wins(v: &[f32]) -> u32 {
226 const LANES: usize = 8;
227 let mut acc = [f32::NEG_INFINITY; LANES];
228 let mut any_nan = false;
229 let mut chunks = v.chunks_exact(LANES);
230 for c in &mut chunks {
231 for (a, &x) in acc.iter_mut().zip(c) {
232 any_nan |= x.is_nan();
233 if x > *a {
234 *a = x;
235 }
236 }
237 }
238 let mut best = f32::NEG_INFINITY;
239 for &a in acc.iter() {
240 if a > best {
241 best = a;
242 }
243 }
244 for &x in chunks.remainder() {
245 any_nan |= x.is_nan();
246 if x > best {
247 best = x;
248 }
249 }
250 if any_nan {
251 // Bit-identical fallback: the exact historical expression.
252 return v
253 .iter()
254 .enumerate()
255 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
256 .map(|(i, _)| i as u32)
257 .unwrap_or(0);
258 }
259 v.iter()
260 .rposition(|&x| x == best)
261 .unwrap_or(0)
262 .try_into()
263 .unwrap_or(0)
264}
265
266#[cfg(test)]
267mod greedy_tests {
268 use super::greedy_pick_last_wins;
269
270 fn reference(v: &[f32]) -> u32 {
271 v.iter()
272 .enumerate()
273 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
274 .map(|(i, _)| i as u32)
275 .unwrap_or(0)
276 }
277
278 fn agree(v: &[f32]) {
279 assert_eq!(greedy_pick_last_wins(v), reference(v), "diverged on {v:?}");
280 }
281
282 #[test]
283 fn matches_max_by_reference() {
284 agree(&[]);
285 agree(&[1.0]);
286 agree(&[1.0, 3.0, 2.0]);
287 // Ties resolve to the LAST index — the opposite of the verify path.
288 agree(&[1.0, 5.0, 5.0, 5.0, 2.0]);
289 agree(&[5.0, 1.0, 5.0]);
290 // Tie straddling the 8-lane boundary.
291 agree(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 9.0, 9.0]);
292 agree(&[-5.0, -1.0, -3.0]);
293 agree(&[-0.0, 0.0, -0.0]);
294 agree(&[f32::NEG_INFINITY, f32::NEG_INFINITY]);
295 agree(&[f32::INFINITY, 1.0, f32::INFINITY]);
296 // NaN cases route to the verbatim fallback => trivially identical,
297 // but assert anyway so the routing itself is covered.
298 agree(&[f32::NAN, 1.0, 2.0]);
299 agree(&[1.0, 2.0, f32::NAN]);
300 agree(&[f32::NAN]);
301 }
302
303 #[test]
304 fn vocab_sized_last_wins() {
305 let mut v: Vec<f32> = (0..248_320)
306 .map(|i| (((i * 2654435761u64 as usize) % 100_003) as f32) / 1000.0 - 50.0)
307 .collect();
308 v[100_000] = 999.0;
309 v[200_000] = 999.0; // duplicate max — LAST must win here
310 assert_eq!(greedy_pick_last_wins(&v), reference(&v));
311 assert_eq!(greedy_pick_last_wins(&v), 200_000);
312 }
313}