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 ¶ms.dry_sequence_breakers,
479 );
480 }
481
482 // ── 0e. Logit bias: additive per-token bias ──
483 for &(tid, bias) in ¶ms.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;