moe: KV-cached GPU decode for gpt-oss (O(1)/token)

decode_step(): single-token forward using GpuKVCache + the GPU
decode_attention_sink kernel (per-head sinks + sliding window) + per-token
MoE, so generation is O(1)/token instead of the host forward's O(n²)
recompute. generate(): prefill prompt token-by-token (causal attention =>
sequential == batched), then greedy decode to eos/max_new.

Verified against the reference host forward on the Paris prompt: both
predict top-1 token 12650 = " Paris" (MATCH_TOP1: YES; logits 15.375 vs
15.3125 — BF16 accumulation-order diff only). gptoss-logits now runs both
paths and asserts the match. Build green on dash5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 21:54:05 +08:00
parent 94957c5727
commit 7e7d077ff1
2 changed files with 120 additions and 6 deletions

View File

@@ -20,17 +20,36 @@ fn main() {
let model = GptOss::from_weights(config, floats, u8s);
eprintln!("[gptoss-logits] forward over {} tokens", tokens.len());
// (1) batched host-attention forward (reference path).
let logits = model.forward(&tokens); // [T, vocab]
let vocab = logits.shape()[1];
let t = logits.shape()[0];
let host = logits.to_device(Device::Cpu);
let data = host.as_slice::<bf16>();
let last = &data[(t - 1) * vocab..t * vocab];
let mut idx: Vec<usize> = (0..vocab).collect();
idx.sort_by(|&a, &b| last[b].to_f32().partial_cmp(&last[a].to_f32()).unwrap());
println!("top10 next-token (id: logit):");
for &i in &idx[..10] {
println!("[forward] top5 next-token (id: logit):");
for &i in &idx[..5] {
println!(" {i}: {:.4}", last[i].to_f32());
}
// (2) KV-cache GPU decode path (token-by-token prefill) — must match top-1.
let mut cache = xserv_model::GpuKVCache::new(
model.config.num_layers(), model.config.num_kv_heads(),
model.config.head_dim(), xserv_tensor::DType::BF16, Device::Cuda(0),
);
let mut dlog = model.decode_step(tokens[0], &mut cache);
for &tok in &tokens[1..] {
dlog = model.decode_step(tok, &mut cache);
}
let dh = dlog.to_device(Device::Cpu);
let dd = dh.as_slice::<bf16>();
let mut didx: Vec<usize> = (0..vocab).collect();
didx.sort_by(|&a, &b| dd[b].to_f32().partial_cmp(&dd[a].to_f32()).unwrap());
println!("[decode ] top5 next-token (id: logit):");
for &i in &didx[..5] {
println!(" {i}: {:.4}", dd[i].to_f32());
}
println!("MATCH_TOP1: {}", if idx[0] == didx[0] { "YES" } else { "NO" });
}

View File

@@ -21,9 +21,10 @@ use std::collections::HashMap;
use half::bf16;
use xserv_cuda::GpuBuffer;
use xserv_kernels::*;
use xserv_tensor::{Device, Tensor};
use xserv_tensor::{DType, Device, Tensor};
use crate::config::ModelConfig;
use crate::kv_cache::GpuKVCache;
pub struct GptOss {
pub config: ModelConfig,
@@ -46,7 +47,8 @@ struct Block {
v_bias: Tensor,
o_proj_wt: Tensor, // [n_heads*head_dim, hidden]
o_bias: Tensor,
sinks: Tensor, // [n_heads] (f32 on host)
sinks: Tensor, // [n_heads] BF16 on host (used by the host forward path)
sinks_gpu: Tensor, // [n_heads] F32 on GPU (used by decode_attention_sink)
sliding: bool,
// MoE. Experts stay MXFP4 on GPU; dequantized per use (see expert_forward).
router_wt: Tensor, // [hidden, n_experts]
@@ -132,6 +134,11 @@ impl GptOss {
down_bias.push(slice_row(&down_b, e, down_out).to_device(dev));
}
// Sinks: keep BF16 on host (host forward path) + F32 on GPU (decode kernel).
let sinks_cpu = take(&mut w, &format!("{p}.self_attn.sinks")).to_device(Device::Cpu);
let sinks_f32: Vec<f32> = sinks_cpu.as_slice::<bf16>().iter().map(|v| v.to_f32()).collect();
let sinks_gpu = Tensor::from_slice(&sinks_f32, &[sinks_f32.len()]).to_device(dev);
layers.push(Block {
input_norm: repl(take(&mut w, &format!("{p}.input_layernorm.weight"))),
post_norm: repl(take(&mut w, &format!("{p}.post_attention_layernorm.weight"))),
@@ -143,7 +150,8 @@ impl GptOss {
v_bias: repl(take(&mut w, &format!("{p}.self_attn.v_proj.bias"))),
o_proj_wt: wt(take(&mut w, &format!("{p}.self_attn.o_proj.weight"))),
o_bias: repl(take(&mut w, &format!("{p}.self_attn.o_proj.bias"))),
sinks: take(&mut w, &format!("{p}.self_attn.sinks")).to_device(Device::Cpu),
sinks: sinks_cpu,
sinks_gpu,
sliding: config.layer_uses_sliding_window(i),
router_wt: wt(take(&mut w, &format!("{p}.mlp.router.weight"))),
router_bias: repl(take(&mut w, &format!("{p}.mlp.router.bias"))),
@@ -208,6 +216,81 @@ impl GptOss {
matmul2(&x, &self.lm_head_t) // [T, vocab]
}
/// Single-token GPU decode step with KV cache. Returns logits [1, vocab].
/// Same math as `forward` but q_len=1, GPU sink-attention over cached K/V, so
/// it is O(1) per token. Prefill = call this for each prompt token (causal
/// attention makes sequential identical to a batched prefill).
pub fn decode_step(&self, token: u32, cache: &mut GpuKVCache) -> Tensor {
let hidden = self.config.hidden();
let n_heads = self.config.num_heads();
let n_kv = self.config.num_kv_heads();
let head_dim = self.config.head_dim();
let eps = self.config.rms_norm_eps.unwrap_or(1e-5) as f32;
let scale = (head_dim as f32).powf(-0.5);
let pos = cache.seq_len();
let positions = [pos as u32];
let mut x = embedding(&self.embed_tokens, &[token]); // [1, hidden]
for (li, layer) in self.layers.iter().enumerate() {
let residual = x.clone();
let normed = rmsnorm(&x, &layer.input_norm, eps);
let q = add_bias(&matmul2(&normed, &layer.q_proj_wt), &layer.q_bias); // [1, H*hd]
let k = add_bias(&matmul2(&normed, &layer.k_proj_wt), &layer.k_bias); // [1, Hkv*hd]
let v = add_bias(&matmul2(&normed, &layer.v_proj_wt), &layer.v_bias);
// reshape + rotate_half RoPE (t=1)
let q = reshape_heads_gpu(&q, 1, n_heads, head_dim); // [1,H,1,hd]
let k = reshape_heads_gpu(&k, 1, n_kv, head_dim);
let q = transpose_for_rope_gpu(&q, 1, n_heads, head_dim); // [1,H,hd]
let k = transpose_for_rope_gpu(&k, 1, n_kv, head_dim);
rope_inplace(&q, &self.rope_cache, &positions);
rope_inplace(&k, &self.rope_cache, &positions);
let q = transpose_from_rope_gpu(&q, 1, n_heads, head_dim); // [1,H,1,hd]
let k = transpose_from_rope_gpu(&k, 1, n_kv, head_dim); // [1,Hkv,1,hd]
let v = reshape_heads_gpu(&v, 1, n_kv, head_dim); // [1,Hkv,1,hd]
cache.append(li, &k, &v, 1, pos);
let (k_full, v_full) = cache.get_kv_len(li, pos + 1); // [1,Hkv,pos+1,hd]
let window = if layer.sliding { self.config.sliding_window().unwrap_or(0) } else { 0 };
let attn = decode_attention_sink(&q, &k_full, &v_full, &layer.sinks_gpu, scale, window); // [1,H,1,hd]
let attn = attn.reshape(&[1, n_heads * head_dim]); // head-major == o_proj input
let attn_proj = add_bias(&matmul2(&attn, &layer.o_proj_wt), &layer.o_bias);
x = add(&residual, &attn_proj);
let residual = x.clone();
let normed = rmsnorm(&x, &layer.post_norm, eps);
let moe = self.moe_ffn(&normed, layer, hidden);
x = add(&residual, &moe);
}
cache.advance_seq_len(1);
let x = rmsnorm(&x, &self.norm, eps);
matmul2(&x, &self.lm_head_t) // [1, vocab]
}
/// Greedy generation. Prefills `prompt` then generates up to `max_new` tokens,
/// stopping at `eos`. Returns generated token ids (prompt excluded).
pub fn generate(&self, prompt: &[u32], max_new: usize, eos: Option<u32>) -> Vec<u32> {
assert!(!prompt.is_empty());
let mut cache = GpuKVCache::new(
self.config.num_layers(), self.config.num_kv_heads(),
self.config.head_dim(), DType::BF16, Device::Cuda(0),
);
let mut logits = self.decode_step(prompt[0], &mut cache);
for &tok in &prompt[1..] {
logits = self.decode_step(tok, &mut cache);
}
let mut out = Vec::new();
let mut next = argmax_last(&logits);
for _ in 0..max_new {
out.push(next);
if Some(next) == eos { break; }
logits = self.decode_step(next, &mut cache);
next = argmax_last(&logits);
}
out
}
/// MoE FFN over [T, hidden]: router top-k softmax, per-token weighted sum of
/// its top-k experts' clamped-SwiGLU outputs. Correctness-first (per-token).
fn moe_ffn(&self, x: &Tensor, layer: &Block, hidden: usize) -> Tensor {
@@ -307,6 +390,18 @@ fn matmul2(a: &Tensor, b: &Tensor) -> Tensor {
matmul(a, b, GemmBackend::CuBlas)
}
/// Greedy argmax over the last row of a [*, vocab] BF16 logits tensor.
fn argmax_last(logits: &Tensor) -> u32 {
let vocab = logits.shape()[logits.ndim() - 1];
let rows = logits.numel() / vocab;
let host = logits.to_device(Device::Cpu);
let d = host.as_slice::<bf16>();
let last = &d[(rows - 1) * vocab..rows * vocab];
last.iter().enumerate()
.max_by(|a, b| a.1.to_f32().partial_cmp(&b.1.to_f32()).unwrap())
.map(|(i, _)| i as u32).unwrap()
}
/// One expert `e` of `layer`: clamped SwiGLU. x:[*,hidden] -> [*,hidden].
/// Dequantizes the expert's MXFP4 weights to BF16 scratch on GPU, then:
/// gate_up = x@gate_up + bias; gate=even cols, up=odd cols (interleaved);