#![allow(clippy::similar_names)]
#![allow(clippy::too_many_lines)]
use crate::kernels::Kernel;
use crate::ptx::builder::{PtxArithmetic, PtxComparison, PtxControl};
use crate::ptx::{PtxKernel, PtxReg, PtxType};
#[derive(Debug, Clone)]
pub struct BatchedIncrementalAttentionKernel {
pub max_seq_len: u32,
pub head_dim: u32,
pub num_heads: u32,
pub num_kv_heads: u32,
pub batch_size: u32,
pub scale: f32,
}
impl BatchedIncrementalAttentionKernel {
#[must_use]
pub fn new(
max_seq_len: u32,
head_dim: u32,
num_heads: u32,
num_kv_heads: u32,
batch_size: u32,
) -> Self {
Self {
max_seq_len,
head_dim,
num_heads,
num_kv_heads,
batch_size,
scale: 1.0 / (head_dim as f32).sqrt(),
}
}
}
impl Kernel for BatchedIncrementalAttentionKernel {
fn name(&self) -> &str {
"batched_incremental_attention"
}
fn build_ptx(&self) -> PtxKernel {
let head_dim = self.head_dim;
let scale = self.scale;
let max_seq_len = self.max_seq_len;
let num_heads = self.num_heads;
let num_kv_heads = self.num_kv_heads;
let _batch_size = self.batch_size;
PtxKernel::new("batched_incremental_attention")
.param(PtxType::U64, "q_ptr") .param(PtxType::U64, "k_ptrs_ptr") .param(PtxType::U64, "v_ptrs_ptr") .param(PtxType::U64, "out_ptr") .param(PtxType::U64, "seq_lens_ptr") .shared_memory(0)
.build(move |ctx| {
let head_idx = ctx.special_reg(PtxReg::CtaIdX);
let batch_idx = ctx.special_reg(PtxReg::CtaIdY);
let lane_id = ctx.special_reg(PtxReg::TidX);
let q_ptr = ctx.load_param_u64("q_ptr");
let k_ptrs_ptr = ctx.load_param_u64("k_ptrs_ptr");
let v_ptrs_ptr = ctx.load_param_u64("v_ptrs_ptr");
let out_ptr = ctx.load_param_u64("out_ptr");
let seq_lens_ptr = ctx.load_param_u64("seq_lens_ptr");
let four = ctx.mov_u32_imm(4);
let eight = ctx.mov_u32_imm(8);
let batch_idx_bytes = ctx.mul_wide_u32_reg(batch_idx, four);
let seq_len_addr = ctx.add_u64(seq_lens_ptr, batch_idx_bytes);
let seq_len = ctx.ld_global_u32(seq_len_addr);
let batch_ptr_off = ctx.mul_wide_u32_reg(batch_idx, eight);
let k_ptr_addr = ctx.add_u64(k_ptrs_ptr, batch_ptr_off);
let v_ptr_addr = ctx.add_u64(v_ptrs_ptr, batch_ptr_off);
let k_cache_ptr = ctx.ld_global_u64(k_ptr_addr);
let v_cache_ptr = ctx.ld_global_u64(v_ptr_addr);
let head_dim_u32 = ctx.mov_u32_imm(head_dim);
let num_heads_u32 = ctx.mov_u32_imm(num_heads);
let batch_head_stride = ctx.mul_lo_u32(num_heads_u32, head_dim_u32);
let batch_off = ctx.mul_lo_u32(batch_idx, batch_head_stride);
let head_off = ctx.mul_lo_u32(head_idx, head_dim_u32);
let q_head_off = ctx.add_u32_reg(batch_off, head_off);
let q_head_off_bytes = ctx.mul_wide_u32_reg(q_head_off, four);
let q_head_ptr = ctx.add_u64(q_ptr, q_head_off_bytes);
let out_head_ptr = ctx.add_u64(out_ptr, q_head_off_bytes);
let kv_head_idx = ctx.mul_u32(head_idx, num_kv_heads);
let kv_head_idx = ctx.div_u32(kv_head_idx, num_heads);
let kv_stride = ctx.mov_u32_imm(max_seq_len * head_dim);
let kv_head_off = ctx.mul_lo_u32(kv_head_idx, kv_stride);
let kv_head_off_bytes = ctx.mul_wide_u32_reg(kv_head_off, four);
let k_head_ptr = ctx.add_u64(k_cache_ptr, kv_head_off_bytes);
let v_head_ptr = ctx.add_u64(v_cache_ptr, kv_head_off_bytes);
let q0_off_bytes = ctx.mul_wide_u32_reg(lane_id, four);
let q0_addr = ctx.add_u64(q_head_ptr, q0_off_bytes);
let in_bounds0 = ctx.setp_lt_u32(lane_id, head_dim_u32);
let q0 = ctx.ld_global_f32_predicated(q0_addr, in_bounds0, 0.0);
let lane_plus_32 = ctx.add_u32(lane_id, 32);
let q1_off_bytes = ctx.mul_wide_u32_reg(lane_plus_32, four);
let q1_addr = ctx.add_u64(q_head_ptr, q1_off_bytes);
let in_bounds1 = ctx.setp_lt_u32(lane_plus_32, head_dim_u32);
let q1 = ctx.ld_global_f32_predicated(q1_addr, in_bounds1, 0.0);
let lane_plus_64 = ctx.add_u32(lane_id, 64);
let q2_off_bytes = ctx.mul_wide_u32_reg(lane_plus_64, four);
let q2_addr = ctx.add_u64(q_head_ptr, q2_off_bytes);
let in_bounds2 = ctx.setp_lt_u32(lane_plus_64, head_dim_u32);
let q2 = ctx.ld_global_f32_predicated(q2_addr, in_bounds2, 0.0);
let lane_plus_96 = ctx.add_u32(lane_id, 96);
let q3_off_bytes = ctx.mul_wide_u32_reg(lane_plus_96, four);
let q3_addr = ctx.add_u64(q_head_ptr, q3_off_bytes);
let in_bounds3 = ctx.setp_lt_u32(lane_plus_96, head_dim_u32);
let q3 = ctx.ld_global_f32_predicated(q3_addr, in_bounds3, 0.0);
let out0 = ctx.mov_f32_imm(0.0);
let out1 = ctx.mov_f32_imm(0.0);
let out2 = ctx.mov_f32_imm(0.0);
let out3 = ctx.mov_f32_imm(0.0);
let max_score = ctx.mov_f32_imm(f32::NEG_INFINITY);
let sum_exp = ctx.mov_f32_imm(0.0);
let log2e = ctx.mov_f32_imm(std::f32::consts::LOG2_E);
let scale_reg = ctx.mov_f32_imm(scale);
let pos = ctx.mov_u32_imm(0);
ctx.label("batched_seq_loop");
let loop_cond = ctx.setp_lt_u32(pos, seq_len);
ctx.branch_if_not(loop_cond, "batched_seq_loop_end");
let k_pos_off = ctx.mul_lo_u32(pos, head_dim_u32);
let k0_elem_off = ctx.add_u32_reg(k_pos_off, lane_id);
let k0_off_bytes = ctx.mul_wide_u32_reg(k0_elem_off, four);
let k0_addr = ctx.add_u64(k_head_ptr, k0_off_bytes);
let k0 = ctx.ld_global_f32_predicated(k0_addr, in_bounds0, 0.0);
let k1_elem_off = ctx.add_u32_reg(k_pos_off, lane_plus_32);
let k1_off_bytes = ctx.mul_wide_u32_reg(k1_elem_off, four);
let k1_addr = ctx.add_u64(k_head_ptr, k1_off_bytes);
let k1 = ctx.ld_global_f32_predicated(k1_addr, in_bounds1, 0.0);
let k2_elem_off = ctx.add_u32_reg(k_pos_off, lane_plus_64);
let k2_off_bytes = ctx.mul_wide_u32_reg(k2_elem_off, four);
let k2_addr = ctx.add_u64(k_head_ptr, k2_off_bytes);
let k2 = ctx.ld_global_f32_predicated(k2_addr, in_bounds2, 0.0);
let k3_elem_off = ctx.add_u32_reg(k_pos_off, lane_plus_96);
let k3_off_bytes = ctx.mul_wide_u32_reg(k3_elem_off, four);
let k3_addr = ctx.add_u64(k_head_ptr, k3_off_bytes);
let k3 = ctx.ld_global_f32_predicated(k3_addr, in_bounds3, 0.0);
let dot = ctx.mul_f32(q0, k0);
ctx.fma_f32_inplace(dot, q1, k1);
ctx.fma_f32_inplace(dot, q2, k2);
ctx.fma_f32_inplace(dot, q3, k3);
for delta in [16, 8, 4, 2, 1] {
let other = ctx.shfl_down_f32(dot, delta, 0xFFFF_FFFF);
ctx.add_f32_inplace(dot, other);
}
let dot = ctx.shfl_idx_f32(dot, 0, 0xFFFF_FFFF);
let score = ctx.mul_f32(dot, scale_reg);
let old_max = ctx.mov_f32_imm(0.0);
ctx.mov_f32_reg(old_max, max_score);
ctx.max_f32_inplace(max_score, score);
let score_minus_max = ctx.sub_f32(score, max_score);
let score_log2 = ctx.mul_f32(score_minus_max, log2e);
let exp_score = ctx.ex2_f32(score_log2);
let old_minus_new = ctx.sub_f32(old_max, max_score);
let log2_old = ctx.mul_f32(old_minus_new, log2e);
let correction = ctx.ex2_f32(log2_old);
ctx.mul_f32_inplace(sum_exp, correction);
ctx.add_f32_inplace(sum_exp, exp_score);
ctx.mul_f32_inplace(out0, correction);
ctx.mul_f32_inplace(out1, correction);
ctx.mul_f32_inplace(out2, correction);
ctx.mul_f32_inplace(out3, correction);
let v0_addr = ctx.add_u64(v_head_ptr, k0_off_bytes);
let v0 = ctx.ld_global_f32_predicated(v0_addr, in_bounds0, 0.0);
ctx.fma_f32_inplace(out0, exp_score, v0);
let v1_addr = ctx.add_u64(v_head_ptr, k1_off_bytes);
let v1 = ctx.ld_global_f32_predicated(v1_addr, in_bounds1, 0.0);
ctx.fma_f32_inplace(out1, exp_score, v1);
let v2_addr = ctx.add_u64(v_head_ptr, k2_off_bytes);
let v2 = ctx.ld_global_f32_predicated(v2_addr, in_bounds2, 0.0);
ctx.fma_f32_inplace(out2, exp_score, v2);
let v3_addr = ctx.add_u64(v_head_ptr, k3_off_bytes);
let v3 = ctx.ld_global_f32_predicated(v3_addr, in_bounds3, 0.0);
ctx.fma_f32_inplace(out3, exp_score, v3);
ctx.add_u32_inplace(pos, 1);
ctx.branch("batched_seq_loop");
ctx.label("batched_seq_loop_end");
let one = ctx.mov_f32_imm(1.0);
let inv_sum = ctx.div_f32(one, sum_exp);
ctx.mul_f32_inplace(out0, inv_sum);
ctx.mul_f32_inplace(out1, inv_sum);
ctx.mul_f32_inplace(out2, inv_sum);
ctx.mul_f32_inplace(out3, inv_sum);
let out0_addr = ctx.add_u64(out_head_ptr, q0_off_bytes);
ctx.branch_if_not(in_bounds0, "batched_skip_store0");
ctx.st_global_f32(out0_addr, out0);
ctx.label("batched_skip_store0");
let out1_addr = ctx.add_u64(out_head_ptr, q1_off_bytes);
ctx.branch_if_not(in_bounds1, "batched_skip_store1");
ctx.st_global_f32(out1_addr, out1);
ctx.label("batched_skip_store1");
let out2_addr = ctx.add_u64(out_head_ptr, q2_off_bytes);
ctx.branch_if_not(in_bounds2, "batched_skip_store2");
ctx.st_global_f32(out2_addr, out2);
ctx.label("batched_skip_store2");
let out3_addr = ctx.add_u64(out_head_ptr, q3_off_bytes);
ctx.branch_if_not(in_bounds3, "batched_skip_store3");
ctx.st_global_f32(out3_addr, out3);
ctx.label("batched_skip_store3");
ctx.ret();
})
}
}
#[cfg(test)]
mod cb008_online_softmax_rescale {
use super::BatchedIncrementalAttentionKernel;
use crate::kernels::attention::paged::flash_decoding::FlashDecodingChunkKernel;
use crate::kernels::Kernel;
fn ternary(line: &str, op: &str) -> Option<(String, String, String)> {
let line = line.trim().trim_end_matches(';');
let rest = line.strip_prefix(op)?.trim();
let mut parts = rest.split(',').map(str::trim);
let dst = parts.next()?.to_string();
let a = parts.next()?.to_string();
let b = parts.next()?.to_string();
if parts.next().is_some() {
return None;
}
Some((dst, a, b))
}
fn rescale_reads_a_saved_max(ptx: &str) -> Result<(), String> {
let running_max = ptx
.lines()
.filter_map(|l| ternary(l, "max.f32"))
.find(|(dst, a, _)| dst == a)
.map(|(dst, _, _)| dst)
.ok_or_else(|| {
"no in-place `max.f32 %fM, %fM, %fS;` found — this kernel does not have the \
online-softmax shape this test asserts about, so the assertion is vacuous"
.to_string()
})?;
let ex2_args: Vec<String> = ptx
.lines()
.filter_map(|l| {
let l = l.trim().trim_end_matches(';');
let rest = l.strip_prefix("ex2.approx.f32")?.trim();
rest.split(',').nth(1).map(|s| s.trim().to_string())
})
.collect();
if ex2_args.is_empty() {
return Err("no `ex2.approx.f32` found — no exponential, so no online softmax".into());
}
let subs: Vec<(String, String, String)> =
ptx.lines().filter_map(|l| ternary(l, "sub.f32")).collect();
let muls: Vec<(String, String, String)> =
ptx.lines().filter_map(|l| ternary(l, "mul.f32")).collect();
let mut checked = 0usize;
for (sub_dst, sub_a, sub_b) in &subs {
if sub_b != &running_max {
continue; }
let feeds_ex2 = muls
.iter()
.any(|(mul_dst, mul_a, _)| mul_a == sub_dst && ex2_args.contains(mul_dst));
if !feeds_ex2 {
continue;
}
checked += 1;
assert_ne!(
sub_a, sub_b,
"FALSIFY-CB-008: online-softmax rescale computes `{sub_a} - {sub_b}`, i.e. the \
running max minus ITSELF, so the correction is exp2(0) == 1.0 for every KV \
position and `sum_exp`/`out` are never brought onto the new max's scale. \
`let old_max = max_score;` binds the same VirtualReg; copy it into a fresh one \
with `mov_f32_imm` + `mov_f32_reg` first (see flash_decoding/chunk_kernel.rs). \
This is aprender#2753: batched CUDA decode emitting a constant token to the cap."
);
}
if checked == 0 {
return Err(format!(
"found the running max ({running_max}) but no `sub.f32 _, _, {running_max}` \
feeding an ex2 — the rescale term was not located, so nothing was asserted"
));
}
Ok(())
}
#[test]
fn batched_incremental_attention_rescales_online_softmax() {
let kernel = BatchedIncrementalAttentionKernel::new(2048, 128, 12, 2, 4);
let ptx = kernel.emit_ptx_for_target("sm_89");
rescale_reads_a_saved_max(&ptx).expect("PTX shape");
}
#[test]
fn flash_decoding_chunk_kernel_stays_green() {
let kernel = FlashDecodingChunkKernel::new(2048, 128, 12, 2, 4);
let ptx = kernel.emit_ptx_for_target("sm_89");
rescale_reads_a_saved_max(&ptx).expect("PTX shape");
}
#[test]
fn checker_rejects_a_self_subtraction() {
let poisoned = "\
max.f32 %f8, %f8, %f23;\n\
sub.f32 %f24, %f23, %f8;\n\
mul.f32 %f25, %f24, %f10;\n\
ex2.approx.f32 %f26, %f25;\n\
sub.f32 %f27, %f8, %f8;\n\
mul.f32 %f28, %f27, %f10;\n\
ex2.approx.f32 %f29, %f28;\n";
let caught = std::panic::catch_unwind(|| rescale_reads_a_saved_max(poisoned));
assert!(
caught.is_err(),
"the checker did not fire on PTX that literally contains \
`sub.f32 %f27, %f8, %f8;` — it cannot detect the defect it exists for"
);
}
#[test]
fn checker_accepts_a_saved_max() {
let repaired = "\
mov.f32 %f24, %f8;\n\
max.f32 %f8, %f8, %f23;\n\
sub.f32 %f25, %f23, %f8;\n\
mul.f32 %f26, %f25, %f10;\n\
ex2.approx.f32 %f27, %f26;\n\
sub.f32 %f28, %f24, %f8;\n\
mul.f32 %f29, %f28, %f10;\n\
ex2.approx.f32 %f30, %f29;\n";
rescale_reads_a_saved_max(repaired).expect("repaired PTX must pass");
}
}
#[cfg(test)]
#[cfg(feature = "cuda")]
mod cb008_gpu_numerics {
use super::BatchedIncrementalAttentionKernel;
use crate::driver::{CudaContext, CudaModule, CudaStream, GpuBuffer, LaunchConfig};
use crate::kernels::Kernel;
const HEAD_DIM: usize = 128; const NUM_HEADS: usize = 12;
const NUM_KV_HEADS: usize = 2; const MAX_SEQ: usize = 2048; const M: usize = 3; const SEQ_LENS: [usize; M] = [17, 11, 5];
fn kv_group_of(head: usize) -> usize {
head * NUM_KV_HEADS / NUM_HEADS
}
fn q_at(slot: usize, head: usize, d: usize) -> f32 {
0.5 + 0.01 * ((slot * 5 + head * 3 + d) % 7) as f32
}
fn k_base(slot: usize, group: usize, d: usize) -> f32 {
0.02 * (1 + (slot * 3 + group * 5 + d) % 11) as f32
}
fn k_at(slot: usize, group: usize, pos: usize, d: usize) -> f32 {
k_base(slot, group, d) * (1.0 + 0.6 * pos as f32)
}
fn v_at(slot: usize, group: usize, pos: usize, d: usize) -> f32 {
(pos as f32 + 1.0) + 0.01 * d as f32 + 0.5 * slot as f32 + 0.25 * group as f32
}
fn reference(slot: usize, head: usize) -> Vec<f32> {
let group = kv_group_of(head);
let seq_len = SEQ_LENS[slot];
let scale = 1.0 / (HEAD_DIM as f32).sqrt();
let scores: Vec<f32> = (0..seq_len)
.map(|p| {
let dot: f32 = (0..HEAD_DIM)
.map(|d| q_at(slot, head, d) * k_at(slot, group, p, d))
.sum();
dot * scale
})
.collect();
let max = scores.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let exps: Vec<f32> = scores.iter().map(|s| (s - max).exp()).collect();
let denom: f32 = exps.iter().sum();
(0..HEAD_DIM)
.map(|d| {
let acc: f32 = (0..seq_len)
.map(|p| exps[p] * v_at(slot, group, p, d))
.sum();
acc / denom
})
.collect()
}
#[test]
fn batched_attention_matches_cpu_softmax_at_production_shape() {
let Ok(ctx) = CudaContext::new(0) else {
println!(
"cb008_gpu_numerics: no CUDA device — SKIPPED. The always-on guard for this \
defect is cb008_online_softmax_rescale (PTX codegen), which needs no device."
);
return;
};
let stream = CudaStream::new(&ctx).expect("stream");
{
let scale = 1.0 / (HEAD_DIM as f32).sqrt();
let score = |p: usize| -> f32 {
(0..HEAD_DIM)
.map(|d| q_at(0, 0, d) * k_at(0, 0, p, d))
.sum::<f32>()
* scale
};
let first = score(0);
let last = score(SEQ_LENS[0] - 1);
assert!(
last > first + 4.0,
"fixture is inert: scores span only {first}..{last}, so a rescale stuck at 1.0 \
would be invisible and this test would assert nothing"
);
for p in 1..SEQ_LENS[0] {
assert!(
score(p) > score(p - 1),
"scores must increase at every position"
);
}
}
let mut q_host = vec![0.0f32; M * NUM_HEADS * HEAD_DIM];
for slot in 0..M {
for head in 0..NUM_HEADS {
for d in 0..HEAD_DIM {
q_host[(slot * NUM_HEADS + head) * HEAD_DIM + d] = q_at(slot, head, d);
}
}
}
let slot_stride = NUM_KV_HEADS * MAX_SEQ * HEAD_DIM;
let mut k_host = vec![0.0f32; M * slot_stride];
let mut v_host = vec![0.0f32; M * slot_stride];
for slot in 0..M {
for group in 0..NUM_KV_HEADS {
for pos in 0..SEQ_LENS[slot] {
let base = slot * slot_stride + (group * MAX_SEQ + pos) * HEAD_DIM;
for d in 0..HEAD_DIM {
k_host[base + d] = k_at(slot, group, pos, d);
v_host[base + d] = v_at(slot, group, pos, d);
}
}
}
}
let q_buf = GpuBuffer::from_host(&ctx, &q_host).expect("q");
let k_buf = GpuBuffer::from_host(&ctx, &k_host).expect("k");
let v_buf = GpuBuffer::from_host(&ctx, &v_host).expect("v");
let out_buf = GpuBuffer::<f32>::new(&ctx, M * NUM_HEADS * HEAD_DIM).expect("out");
let stride_bytes = (slot_stride * std::mem::size_of::<f32>()) as u64;
let k_ptrs: Vec<u64> = (0..M)
.map(|s| k_buf.as_ptr() + s as u64 * stride_bytes)
.collect();
let v_ptrs: Vec<u64> = (0..M)
.map(|s| v_buf.as_ptr() + s as u64 * stride_bytes)
.collect();
let seq_lens: Vec<u32> = SEQ_LENS.iter().map(|&s| s as u32).collect();
let k_ptrs_buf = GpuBuffer::from_host(&ctx, &k_ptrs).expect("k_ptrs");
let v_ptrs_buf = GpuBuffer::from_host(&ctx, &v_ptrs).expect("v_ptrs");
let seq_lens_buf = GpuBuffer::from_host(&ctx, &seq_lens).expect("seq_lens");
let kernel = BatchedIncrementalAttentionKernel::new(
MAX_SEQ as u32,
HEAD_DIM as u32,
NUM_HEADS as u32,
NUM_KV_HEADS as u32,
M as u32,
);
let ptx = kernel.emit_ptx_for_target("sm_89");
let mut module = CudaModule::from_ptx(&ctx, &ptx).expect("module");
let config = LaunchConfig {
grid: (NUM_HEADS as u32, M as u32, 1),
block: (32, 1, 1),
shared_mem: 0,
};
let mut a0 = q_buf.as_ptr();
let mut a1 = k_ptrs_buf.as_ptr();
let mut a2 = v_ptrs_buf.as_ptr();
let mut a3 = out_buf.as_ptr();
let mut a4 = seq_lens_buf.as_ptr();
unsafe {
stream
.launch_kernel(
&mut module,
kernel.name(),
&config,
&mut [
std::ptr::from_mut(&mut a0).cast(),
std::ptr::from_mut(&mut a1).cast(),
std::ptr::from_mut(&mut a2).cast(),
std::ptr::from_mut(&mut a3).cast(),
std::ptr::from_mut(&mut a4).cast(),
],
)
.expect("launch");
}
stream.synchronize().expect("sync");
let mut got = vec![0.0f32; M * NUM_HEADS * HEAD_DIM];
out_buf.copy_to_host(&mut got).expect("download");
for slot in 0..M {
for head in 0..NUM_HEADS {
let want = reference(slot, head);
let base = (slot * NUM_HEADS + head) * HEAD_DIM;
for d in 0..HEAD_DIM {
let g = got[base + d];
let w = want[d];
assert!(
(g - w).abs() <= 2e-3 * w.abs().max(1.0),
"FALSIFY-CB-008: batched attention slot {slot} head {head} \
(kv group {group}, seq_len {sl}) dim {d} = {g}, CPU softmax \
reference = {w}. Scores increase with position here, so the \
online-softmax rescale runs at every step; a correction stuck at 1.0 \
over-weights early KV positions and lands near the unweighted mean of \
V instead of near V[seq_len-1]. Q differs per head and K/V per slot, \
so a GQA head-mapping or slot-stride error also lands here. \
See aprender#2753.",
group = kv_group_of(head),
sl = SEQ_LENS[slot]
);
}
}
}
}
}