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        // Resolved ONCE — this runs per stochastic sample step. Same
172        // variable as the sibling dump in
173        // `spark-server/scheduler/decode_logits_seq.rs`, which caches it the
174        // same way; two crates cannot share a levers struct, so the shared
175        // thing is the spelling, and both write into the SAME directory under
176        // different file names (`logits_fetch.bin` here,
177        // `logits_seq.bin` there) precisely so one flag arms both views.
178        static DUMP_DIR: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
179        if let Some(dir) = DUMP_DIR.get_or_init(|| std::env::var("ATLAS_DUMP_LOGITS_PATH").ok()) {
180            use std::io::Write;
181            let path = std::path::Path::new(&dir).join("logits_fetch.bin");
182            if let Ok(mut f) = std::fs::OpenOptions::new()
183                .create(true)
184                .append(true)
185                .open(&path)
186            {
187                let bytes: &[u8] = unsafe {
188                    std::slice::from_raw_parts(
189                        self.logits_f32.as_ptr() as *const u8,
190                        self.vocab_size * 4,
191                    )
192                };
193                let _ = f.write_all(bytes);
194            }
195        }
196        Ok(&self.logits_f32[..self.vocab_size])
197    }
198
199    /// Sample a token from logits on the GPU.
200    ///
201    /// `logits_ptr` points to `[vocab_size]` BF16 values on device.
202    /// Reads BF16, expands to FP32, then samples with full precision.
203    pub fn sample(
204        &mut self,
205        logits_ptr: DevicePtr,
206        params: &SamplingParams,
207        gpu: &dyn GpuBackend,
208    ) -> Result<u32> {
209        if params.is_greedy() {
210            // Greedy: BF16 argmax is fine (argmax is robust to BF16 quantization)
211            let byte_len = self.vocab_size * 2;
212            gpu.copy_d2h(logits_ptr, &mut self.logits_host[..byte_len])?;
213            return Ok(argmax_bf16(&self.logits_host[..byte_len]));
214        }
215        // Stochastic: expand to FP32 for accurate sampling
216        let f32_logits = self.fetch_logits_f32(logits_ptr, gpu)?;
217        let f32_bytes: &[u8] = unsafe {
218            std::slice::from_raw_parts(f32_logits.as_ptr() as *const u8, f32_logits.len() * 4)
219        };
220        Ok(sample_with_params(f32_bytes, params))
221    }
222
223    /// Sample a batch of tokens (one per sequence in the batch).
224    ///
225    /// `logits_ptr` points to [batch_size, vocab_size] BF16 values.
226    pub fn sample_batch(
227        &mut self,
228        logits_ptr: DevicePtr,
229        batch_size: usize,
230        params: &[&SamplingParams],
231        gpu: &dyn GpuBackend,
232    ) -> Result<Vec<u32>> {
233        let total_bytes = batch_size * self.vocab_size * 2; // BF16
234        if self.logits_host.len() < total_bytes {
235            self.logits_host.resize(total_bytes, 0);
236        }
237        gpu.copy_d2h(logits_ptr, &mut self.logits_host[..total_bytes])?;
238
239        let stride_bf16 = self.vocab_size * 2;
240        let mut tokens = Vec::with_capacity(batch_size);
241        for i in 0..batch_size {
242            let start = i * stride_bf16;
243            let end = start + stride_bf16;
244            let p = params.get(i).copied().unwrap_or(params[0]);
245            tokens.push(if p.is_greedy() {
246                argmax_bf16(&self.logits_host[start..end])
247            } else {
248                // Expand BF16 → FP32 for accurate stochastic sampling
249                if self.logits_f32.len() < self.vocab_size {
250                    self.logits_f32.resize(self.vocab_size, 0.0);
251                }
252                for j in 0..self.vocab_size {
253                    self.logits_f32[j] = bf16_to_f32(
254                        self.logits_host[start + j * 2],
255                        self.logits_host[start + j * 2 + 1],
256                    );
257                }
258                let f32_bytes: &[u8] = unsafe {
259                    std::slice::from_raw_parts(
260                        self.logits_f32.as_ptr() as *const u8,
261                        self.vocab_size * 4,
262                    )
263                };
264                sample_with_params(f32_bytes, p)
265            });
266        }
267        Ok(tokens)
268    }
269}
270
271/// Sampling pipeline: repetition_penalty → top-n-sigma → temperature → top-k → softmax → min-p → top-p → sample.
272///
273/// `data` contains FP32 logits (4 bytes per element, little-endian).
274/// `token_history`: previous token IDs for repetition penalty (empty = no penalty).
275/// LZ penalty: penalize tokens that would extend repeated n-gram patterns
276/// in the recent token history. Based on arXiv:2504.20131.
277///
278/// For each candidate token that appears in the history, check if appending it
279/// creates a repeated 3/4/5-gram. Penalize proportional to n-gram length and
280/// frequency: `logit -= penalty * (ngram_len - 2) * count`.
281pub fn apply_lz_penalty(logits: &mut [f32], history: &[u32], penalty: f32) {
282    use std::collections::HashSet;
283    // Window the history to last 256 tokens to avoid penalizing
284    // cross-turn structural repetition (e.g., JSON keys in tool calls).
285    const LZ_WINDOW: usize = 256;
286    let history = if history.len() > LZ_WINDOW {
287        &history[history.len() - LZ_WINDOW..]
288    } else {
289        history
290    };
291    let n = logits.len();
292    // Only check tokens that appear in history (others can't form repeats)
293    let token_set: HashSet<u32> = history.iter().copied().collect();
294    for &candidate in &token_set {
295        if (candidate as usize) >= n {
296            continue;
297        }
298        for ngram_len in 3..=5usize {
299            if history.len() < ngram_len {
300                continue;
301            }
302            // The n-gram that would form: history[-(ngram_len-1)..] ++ [candidate]
303            let suffix = &history[history.len() - (ngram_len - 1)..];
304            let count = history
305                .windows(ngram_len)
306                .filter(|w| w[..ngram_len - 1] == *suffix && w[ngram_len - 1] == candidate)
307                .count();
308            if count > 0 {
309                logits[candidate as usize] -= penalty * (ngram_len as f32 - 2.0) * count as f32;
310            }
311        }
312    }
313}
314
315/// DRY (Don't Repeat Yourself) penalty. Ported from llama.cpp PR #9702.
316///
317/// Uses suffix matching to find the longest repeated sequence ending at the current
318/// position in the token history. For each candidate token, checks if appending it
319/// would extend a previously-seen sequence. Applies exponential penalty:
320///   `penalty = multiplier * base^(match_length - allowed_length)`
321///
322/// Sequence breakers (e.g., newlines, quotes, braces) reset tracking, preventing
323/// false positives in structured output like JSON tool calls.
324pub fn apply_dry_penalty(
325    logits: &mut [f32],
326    history: &[u32],
327    multiplier: f32,
328    base: f32,
329    allowed_length: u32,
330    breakers: &[u32],
331) {
332    if history.is_empty() || multiplier == 0.0 {
333        return;
334    }
335    let n = logits.len();
336    let hist_len = history.len();
337    let allowed = allowed_length as usize;
338
339    // Build suffix match table: for each position i in history, find the length
340    // of the longest suffix of history[..hist_len] that matches starting at i.
341    // This is a simplified Z-function approach.
342    let mut match_lengths = vec![0usize; hist_len];
343    for i in (0..hist_len.saturating_sub(1)).rev() {
344        // Check if history[i] is a sequence breaker — reset match length
345        if breakers.contains(&history[i]) {
346            match_lengths[i] = 0;
347            continue;
348        }
349        // Match history[i..] against history[hist_len - 1 - k..] for increasing k
350        let mut len = 0;
351        let mut j = i;
352        let mut k = hist_len - 1;
353        while j < k && history[j] == history[k] {
354            len += 1;
355            if breakers.contains(&history[j]) {
356                break;
357            }
358            if j == 0 {
359                break;
360            }
361            j -= 1;
362            k -= 1;
363        }
364        // Correction: we want the match starting at position (i) comparing with the suffix
365        // This gives us: if we see history[i..i+len] == history[hist_len-len..hist_len],
366        // then the token at history[i+len] (if it existed) would extend the repeat.
367        match_lengths[i] = len;
368    }
369
370    // For each position where a match of length > allowed was found, the token
371    // that FOLLOWS the match in history (history[i - 1] looking backward from the match start)
372    // would extend a repeat if generated next. Penalize it.
373    #[allow(clippy::needless_range_loop)]
374    for i in 0..hist_len.saturating_sub(1) {
375        let len = match_lengths[i];
376        if len > allowed {
377            // The token at history[i + len] (one past the match) would extend the repeat
378            let extend_pos = i + len;
379            if extend_pos < hist_len {
380                let token = history[extend_pos] as usize;
381                if token < n {
382                    let penalty = multiplier * base.powi((len - allowed) as i32);
383                    logits[token] -= penalty;
384                }
385            }
386        }
387    }
388}
389
390/// Apply repetition / presence / frequency / LZ / DRY penalties and
391/// per-token logit bias to `logits` IN PLACE, using `token_history`.
392///
393/// SSOT for the pre-filter logit-modification block. Extracted verbatim
394/// from `sample_with_params_seeded` (the non-MTP sampling path) so the
395/// MTP verify path (`verify_pick_with_pipeline`) and bootstrap path
396/// (`sample_token_with_grammar`) apply the *same* penalties+bias the
397/// non-MTP path does — previously those two paths emitted tokens with no
398/// penalties (hardcoded `repetition_penalty=1.0`, empty history), so the
399/// configured `repetition_penalty`/`dry_multiplier` from MODEL.toml never
400/// reached MTP-emitted tokens and the model degenerated into repeated
401/// tool-call argument junk.
402///
403/// BACKWARD-COMPATIBLE / ADDITIVE: a mathematical no-op when
404/// `repetition_penalty == 1.0`, `presence_penalty == 0.0`,
405/// `frequency_penalty == 0.0`, `lz_penalty <= 0.0`, `dry_multiplier <= 0.0`
406/// and `logit_bias` is empty — every branch below is individually gated on
407/// its parameter being non-neutral, so the NVFP4 / Gemma / Mistral presets
408/// (which use those neutral values) are byte-for-byte unchanged.
409pub fn apply_penalties_and_bias(
410    logits: &mut [f32],
411    params: &SamplingParams,
412    token_history: &[u32],
413) {
414    let n = logits.len();
415
416    // ── 0. Windowed repetition penalty: penalize recently seen tokens ──
417    // Window=0 uses full history; window>0 uses only the last N tokens.
418    // Skip when rep_penalty <= 0.0 — the divide at the next branch would
419    // produce inf for positive logits and 0 for negative, poisoning the
420    // distribution. (Caller intent for 0.0 is unclear; treat as no-op.)
421    let rep_penalty = params.repetition_penalty;
422    if rep_penalty != 1.0 && rep_penalty > 0.0 && !token_history.is_empty() {
423        let window = params.repetition_penalty_window as usize;
424        let effective = if window > 0 && window < token_history.len() {
425            &token_history[token_history.len() - window..]
426        } else {
427            token_history
428        };
429        for &tid in effective {
430            if (tid as usize) < n {
431                let logit = &mut logits[tid as usize];
432                if *logit > 0.0 {
433                    *logit /= rep_penalty;
434                } else {
435                    *logit *= rep_penalty;
436                }
437            }
438        }
439    }
440
441    // ── 0b. OpenAI-style additive penalties (presence + frequency) ──
442    // Presence: z'ⱼ = zⱼ − β (flat, if token appeared at all)
443    // Frequency: z'ⱼ = zⱼ − α · cⱼ (proportional to occurrence count)
444    let freq_pen = params.frequency_penalty;
445    let pres_pen = params.presence_penalty;
446    if (freq_pen != 0.0 || pres_pen != 0.0) && !token_history.is_empty() {
447        let window = params.repetition_penalty_window as usize;
448        let effective = if window > 0 && window < token_history.len() {
449            &token_history[token_history.len() - window..]
450        } else {
451            token_history
452        };
453        // Count occurrences per token
454        let mut counts = std::collections::HashMap::<u32, u32>::new();
455        for &tid in effective {
456            *counts.entry(tid).or_insert(0) += 1;
457        }
458        for (&tid, &count) in &counts {
459            if (tid as usize) < n {
460                logits[tid as usize] -= freq_pen * count as f32 + pres_pen;
461            }
462        }
463    }
464
465    // ── 0c. LZ penalty: penalize tokens that extend repeated n-gram patterns ──
466    if params.lz_penalty > 0.0 && token_history.len() >= 4 {
467        apply_lz_penalty(logits, token_history, params.lz_penalty);
468    }
469
470    // ── 0d. DRY penalty: exponential penalty for extending repeated sequences ──
471    if params.dry_multiplier > 0.0 && token_history.len() >= 3 {
472        apply_dry_penalty(
473            logits,
474            token_history,
475            params.dry_multiplier,
476            params.dry_base,
477            params.dry_allowed_length,
478            &params.dry_sequence_breakers,
479        );
480    }
481
482    // ── 0e. Logit bias: additive per-token bias ──
483    for &(tid, bias) in &params.logit_bias {
484        if (tid as usize) < n {
485            logits[tid as usize] += bias;
486        }
487    }
488}
489
490mod sample_impl;
491pub use sample_impl::{sample_with_params_history, sample_with_params_seeded};
492
493/// Convenience wrapper: sample without token history (no repetition penalty).
494pub fn sample_with_params(data: &[u8], params: &SamplingParams) -> u32 {
495    sample_with_params_history(data, params, &[])
496}
497
498/// Argmax over an f32 slice with the strict-`>` FIRST-index-wins tie-break.
499///
500/// SSOT for this pick: the verify path (`spark-server`'s
501/// `verify_pipeline_helper/argmax.rs`) calls here too. The naive
502/// `if v > best { best = v; idx = i }` loop carries a dependency through BOTH
503/// the running value and the index, which blocks vectorisation — measured
504/// 1.19 ms for 4x248k on the verify path before the two-pass rewrite (5.95x).
505///
506/// Equivalence to that loop, including the awkward cases: `>` is false for
507/// NaN in both passes so NaN never wins (all-NaN => -inf max, pass 2 finds no
508/// equal, falls back to 0 — same as the loop); IEEE -0.0 == +0.0 so neither
509/// `>` nor `==` separates them and the first zero encountered is returned
510/// either way. `f32::max` is deliberately avoided (it returns the non-NaN
511/// operand, which would let a NaN-adjacent value win where `>` ignored it).
512pub fn argmax_first_wins_f32(v: &[f32]) -> u32 {
513    const LANES: usize = 8;
514    let mut acc = [f32::NEG_INFINITY; LANES];
515    let mut chunks = v.chunks_exact(LANES);
516    for c in &mut chunks {
517        for (a, &x) in acc.iter_mut().zip(c) {
518            if x > *a {
519                *a = x;
520            }
521        }
522    }
523    let mut best = f32::NEG_INFINITY;
524    for &a in acc.iter() {
525        if a > best {
526            best = a;
527        }
528    }
529    for &x in chunks.remainder() {
530        if x > best {
531            best = x;
532        }
533    }
534    v.iter()
535        .position(|&x| x == best)
536        .unwrap_or(0)
537        .try_into()
538        .unwrap_or(0)
539}
540
541/// Argmax over FP32 values stored as raw bytes (4 bytes per element, little-endian).
542/// First-index-wins, identical to [`argmax_first_wins_f32`] — same two-pass
543/// shape, iterating the byte chunks directly so no `Vec<f32>` is materialised.
544pub fn argmax_f32(data: &[u8]) -> u32 {
545    debug_assert!(data.len().is_multiple_of(4));
546    let vals = || {
547        data.chunks_exact(4)
548            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
549    };
550    // Lane-based pass 1 for the same reason as `argmax_first_wins_f32`: a
551    // serial float-max fold is a strict-IEEE dependency chain the compiler
552    // will not vectorise.
553    const LANES: usize = 8;
554    let mut acc = [f32::NEG_INFINITY; LANES];
555    let mut it = data.chunks_exact(4 * LANES);
556    for block in &mut it {
557        for (a, c) in acc.iter_mut().zip(block.chunks_exact(4)) {
558            let x = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
559            if x > *a {
560                *a = x;
561            }
562        }
563    }
564    let mut best = f32::NEG_INFINITY;
565    for &a in acc.iter() {
566        if a > best {
567            best = a;
568        }
569    }
570    for c in it.remainder().chunks_exact(4) {
571        let x = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
572        if x > best {
573            best = x;
574        }
575    }
576    vals()
577        .position(|x| x == best)
578        .unwrap_or(0)
579        .try_into()
580        .unwrap_or(0)
581}
582
583/// Legacy: argmax over BF16 values (still used by argmax_on_device fallback).
584pub fn argmax_bf16(data: &[u8]) -> u32 {
585    debug_assert!(data.len().is_multiple_of(2));
586    let n = data.len() / 2;
587    if n == 0 {
588        return 0;
589    }
590    let mut best_idx: u32 = 0;
591    let mut best_val = bf16_to_f32(data[0], data[1]);
592    for i in 1..n {
593        let val = bf16_to_f32(data[i * 2], data[i * 2 + 1]);
594        if val > best_val {
595            best_val = val;
596            best_idx = i as u32;
597        }
598    }
599    best_idx
600}
601
602/// Convert BF16 (2 bytes, little-endian) to f32.
603#[inline]
604fn bf16_to_f32(lo: u8, hi: u8) -> f32 {
605    let bits = (lo as u32) | ((hi as u32) << 8);
606    f32::from_bits(bits << 16)
607}
608
609#[cfg(test)]
610mod tests;