Files
xserv/crates/xserv-model/src/sampling.rs
Gahow Wang ce10e4a998 sampling: NaN-safe sample() top-k/top-p path
partial_cmp().unwrap() in the top-k / top-p sort and softmax paths would
panic the engine thread on a single NaN logit. The greedy argmax path
is already NaN-safe. Add a one-pass NaN → -inf sweep on the extracted
last_row before temperature scaling, which is equivalent to masking the
token and keeps the sampler deterministic. Warn once when triggered so
the underlying numeric bug isn't silently hidden.
2026-07-01 15:13:19 +08:00

198 lines
6.5 KiB
Rust

use half::bf16;
use rand::Rng;
use xserv_tensor::{DType, Device, Tensor};
#[derive(Clone)]
pub struct SamplingParams {
pub temperature: f32,
pub top_k: usize,
pub top_p: f32,
}
impl Default for SamplingParams {
fn default() -> Self {
Self {
temperature: 0.0,
top_k: 0,
top_p: 1.0,
}
}
}
/// Sample a token from logits with shape [seq_len, vocab_size].
/// Uses the last position's logits. Handles both F32 and BF16 dtypes.
pub fn sample(logits: &Tensor, params: &SamplingParams) -> u32 {
assert_eq!(logits.ndim(), 2);
// Greedy fast path: GPU argmax + 4-byte D2H instead of copying the whole
// [seq, vocab] logits to the host and scanning it (~201k bf16/token).
// NaN logits lose every `>` comparison in the kernel, matching the
// NaN-safe host argmax below.
if params.temperature == 0.0
&& logits.dtype() == DType::BF16
&& matches!(logits.device(), Device::Cuda(_))
&& logits.is_contiguous()
{
let ids = xserv_kernels::argmax_bf16_to_host(logits);
return *ids.last().unwrap();
}
let vocab_size = logits.shape()[1];
let seq_len = logits.shape()[0];
let logits_cpu = logits.to_device(Device::Cpu);
// Extract last row as f32
let mut last_row: Vec<f32> = match logits.dtype() {
DType::F32 => {
let data = logits_cpu.as_slice::<f32>();
data[(seq_len - 1) * vocab_size..seq_len * vocab_size].to_vec()
}
DType::BF16 => {
let data = logits_cpu.as_slice::<bf16>();
data[(seq_len - 1) * vocab_size..seq_len * vocab_size]
.iter()
.map(|v| v.to_f32())
.collect()
}
_ => panic!("unsupported dtype for sampling: {:?}", logits.dtype()),
};
// Greedy
if params.temperature == 0.0 {
return argmax(&last_row);
}
// NaN-safe: sampling path uses partial_cmp().unwrap() in top-k/top-p
// sorts and softmax; a single NaN logit would panic the engine thread.
// Replace NaN with -inf (equivalent to masking) instead.
let mut nan_seen = false;
for v in last_row.iter_mut() {
if v.is_nan() {
nan_seen = true;
*v = f32::NEG_INFINITY;
}
}
if nan_seen {
eprintln!("[sampling] WARNING: NaN logits encountered in sample()");
}
// Apply temperature
let mut logits_f32: Vec<f32> = last_row.iter().map(|v| v / params.temperature).collect();
// Top-k filtering
if params.top_k > 0 && params.top_k < vocab_size {
let mut indices: Vec<usize> = (0..vocab_size).collect();
indices.select_nth_unstable_by(params.top_k, |&a, &b| {
logits_f32[b].partial_cmp(&logits_f32[a]).unwrap()
});
// Everything after top_k should be masked
for &i in &indices[params.top_k..] {
logits_f32[i] = f32::NEG_INFINITY;
}
}
// Top-p (nucleus) filtering
if params.top_p < 1.0 {
// Sort indices by descending logit value
let mut indices: Vec<usize> = (0..vocab_size).collect();
indices.sort_unstable_by(|&a, &b| logits_f32[b].partial_cmp(&logits_f32[a]).unwrap());
// Compute softmax probabilities for the sorted order
let max_val = logits_f32[indices[0]];
let sorted_probs: Vec<f32> = indices
.iter()
.map(|&i| (logits_f32[i] - max_val).exp())
.collect();
let sum: f32 = sorted_probs.iter().sum();
let sorted_probs: Vec<f32> = sorted_probs.iter().map(|v| v / sum).collect();
// Cumulative sum, find cutoff
let mut cumsum = 0.0f32;
let mut cutoff = indices.len();
for (rank, &prob) in sorted_probs.iter().enumerate() {
cumsum += prob;
if cumsum > params.top_p {
cutoff = rank + 1; // keep at least this many
break;
}
}
// Mask everything beyond cutoff
for &i in &indices[cutoff..] {
logits_f32[i] = f32::NEG_INFINITY;
}
}
// Softmax
let max_val = logits_f32.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let exps: Vec<f32> = logits_f32.iter().map(|v| (v - max_val).exp()).collect();
let sum: f32 = exps.iter().sum();
let probs: Vec<f32> = exps.iter().map(|v| v / sum).collect();
// Weighted random sampling
let mut rng = rand::thread_rng();
let r: f32 = rng.r#gen();
let mut cumsum = 0.0f32;
for (i, &p) in probs.iter().enumerate() {
cumsum += p;
if cumsum > r {
return i as u32;
}
}
// Fallback (rounding edge case)
(vocab_size - 1) as u32
}
/// Greedy argmax with a repetition penalty applied to `recent` token ids
/// (HF-style: divide positive logits by `penalty`, multiply negative by it).
/// `penalty <= 1.0` is a no-op. Mitigates greedy repetition loops on reasoning
/// models without changing the forward pass. NaN-safe.
pub fn sample_greedy_penalized(logits: &Tensor, recent: &[u32], penalty: f32) -> u32 {
assert_eq!(logits.ndim(), 2);
let vocab_size = logits.shape()[1];
let seq_len = logits.shape()[0];
let logits_cpu = logits.to_device(Device::Cpu);
let mut last_row: Vec<f32> = match logits.dtype() {
DType::F32 => {
logits_cpu.as_slice::<f32>()[(seq_len - 1) * vocab_size..seq_len * vocab_size].to_vec()
}
DType::BF16 => logits_cpu.as_slice::<bf16>()
[(seq_len - 1) * vocab_size..seq_len * vocab_size]
.iter()
.map(|v| v.to_f32())
.collect(),
_ => panic!("unsupported dtype for sampling: {:?}", logits.dtype()),
};
if penalty > 1.0 {
for &id in recent {
let i = id as usize;
if i < last_row.len() {
let v = last_row[i];
last_row[i] = if v > 0.0 { v / penalty } else { v * penalty };
}
}
}
argmax(&last_row)
}
fn argmax(data: &[f32]) -> u32 {
// NaN-safe: a single NaN logit must not crash the engine thread (a
// partial_cmp().unwrap() panics on NaN). Skip NaNs; warn once if seen.
let mut best_i = 0usize;
let mut best = f32::NEG_INFINITY;
let mut nan_seen = false;
for (i, &v) in data.iter().enumerate() {
if v.is_nan() {
nan_seen = true;
continue;
}
if v > best {
best = v;
best_i = i;
}
}
if nan_seen {
eprintln!("[sampling] WARNING: NaN logits encountered in argmax");
}
best_i as u32
}