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}