#![cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
use cera::backend::wgpu::{GpuContext, shaders};
use cera::model::gpu_turboquant::{TqAttnParams, TqGpuCache, TqLayout, TqMode, TqParams};
use cera::model::{BlockType, ModelConfig, ScalarMultipliers};
use cera::turboquant::{
CompressedKeyCache, CompressedValueCache, EncodeScratch, QueryRotationScratch, RotationState,
TurboQuantConfig, attn_scores_turboquant_gqa, attn_values_turboquant_gqa,
compress_and_append_keys, compress_and_append_values, encode_compressed_keys,
encode_compressed_values, rotate_queries,
};
const SEED: u64 = 0xC0FFEE;
const MAX_SEQ: usize = 12;
const SEQ_LEN: usize = 7;
const N_KV_HEADS: usize = 2;
const N_HEADS: usize = 4;
const LAYER: usize = 3;
const TOL: f32 = 2e-4;
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed | 1)
}
fn next_f32(&mut self) -> f32 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
((self.0 >> 33) as f32 / (1u64 << 31) as f32) - 1.0
}
fn fill(&mut self, n: usize) -> Vec<f32> {
(0..n).map(|_| self.next_f32()).collect()
}
}
struct Fixture {
head_dim: usize,
rotation: RotationState,
config: TurboQuantConfig,
layout: TqLayout,
signs: Vec<f32>,
sign_off: usize,
}
impl Fixture {
fn new(head_dim: usize) -> Self {
let rotation = RotationState::from_seed(SEED ^ LAYER as u64, head_dim);
let mut signs = Vec::new();
for layer in 0..=LAYER {
let r = RotationState::from_seed(SEED ^ layer as u64, head_dim);
signs.extend_from_slice(&r.polar_signs);
signs.extend_from_slice(&r.jl_signs);
}
Self {
head_dim,
rotation,
config: TurboQuantConfig::for_head_dim(head_dim),
layout: TqLayout::new(head_dim),
signs,
sign_off: LAYER * 2 * head_dim,
}
}
fn encode_params(&self, n_tokens: usize, n_heads: usize, src_stride: usize) -> TqParams {
TqParams {
n_tokens: n_tokens as u32,
n_heads: n_heads as u32,
head_dim: self.head_dim as u32,
src_stride: src_stride as u32,
dst_pos: 0,
max_seq_len: MAX_SEQ as u32,
sign_off: self.sign_off as u32,
q_cap: SEQ_LEN as u32,
..Default::default()
}
.with_quant_config(&self.config)
}
}
fn run_tq_kernel(
ctx: &GpuContext,
entry: &str,
src: &[f32],
out_words: usize,
signs: &[f32],
params: &TqParams,
groups: usize,
) -> Vec<u32> {
let pipeline = ctx.create_pipeline(shaders::TURBOQUANT, entry, entry);
let src_buf = ctx.upload_f32(src, "src");
let dst_buf = ctx.create_storage_rw((out_words * 4) as u64, "dst");
let signs_buf = ctx.upload_f32(signs, "signs");
let params_buf = ctx.upload_storage(bytemuck::bytes_of(params), "params");
let bg = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(entry),
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: src_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: dst_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: signs_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: params_buf.as_entire_binding(),
},
],
});
let mut enc = ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some(entry),
timestamp_writes: None,
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(groups as u32, 1, 1);
}
ctx.queue.submit(Some(enc.finish()));
ctx.download_u32(&dst_buf, out_words)
}
fn cpu_encode(
fx: &Fixture,
k: &[f32],
v: &[f32],
kv_dim: usize,
n: usize,
) -> (CompressedKeyCache, CompressedValueCache) {
let mut keys = CompressedKeyCache::new(N_KV_HEADS, fx.head_dim, n);
let mut values = CompressedValueCache::new(N_KV_HEADS, fx.head_dim, n);
let mut scratch = EncodeScratch::new(fx.head_dim);
for t in 0..n {
compress_and_append_keys(
&k[t * kv_dim..(t + 1) * kv_dim],
N_KV_HEADS,
fx.head_dim,
&fx.rotation,
&fx.config,
&mut keys,
&mut scratch,
);
compress_and_append_values(
&v[t * kv_dim..(t + 1) * kv_dim],
N_KV_HEADS,
fx.head_dim,
&fx.rotation,
&fx.config,
&mut values,
&mut scratch,
);
}
(keys, values)
}
fn check_encode(ctx: &GpuContext, head_dim: usize) {
let fx = Fixture::new(head_dim);
let kv_dim = N_KV_HEADS * head_dim;
let mut rng = Lcg::new(11);
let k = rng.fill(SEQ_LEN * kv_dim);
let v = rng.fill(SEQ_LEN * kv_dim);
let (cpu_keys, cpu_values) = cpu_encode(&fx, &k, &v, kv_dim, SEQ_LEN);
let pw = fx.layout.polar_words;
let jw = fx.layout.jl_words;
let vecs = N_KV_HEADS * MAX_SEQ;
let (jl_off, norm_off) = fx.layout.key_regions(vecs);
let params = fx.encode_params(SEQ_LEN, N_KV_HEADS, kv_dim);
let words = run_tq_kernel(
ctx,
"tq_encode_keys",
&k,
fx.layout.key_words(vecs).expect("key_words"),
&fx.signs,
¶ms,
SEQ_LEN * N_KV_HEADS,
);
for h in 0..N_KV_HEADS {
for t in 0..SEQ_LEN {
let slot = h * MAX_SEQ + t;
let gpu_polar: &[u8] = bytemuck::cast_slice(&words[slot * pw..slot * pw + pw]);
let cpu_polar = &cpu_keys.polar_data[h][t * pw * 4..(t + 1) * pw * 4];
assert_eq!(
gpu_polar, cpu_polar,
"hd={head_dim} key polar mismatch at h={h} t={t}"
);
let gpu_jl: &[u8] =
bytemuck::cast_slice(&words[jl_off + slot * jw..jl_off + slot * jw + jw]);
let cpu_jl = &cpu_keys.jl_data[h][t * jw * 4..(t + 1) * jw * 4];
assert_eq!(
gpu_jl, cpu_jl,
"hd={head_dim} key JL mismatch at h={h} t={t}"
);
let nw = words[norm_off + slot];
assert_eq!(
(nw & 0xFFFF) as u16,
cpu_keys.norms[h][t],
"hd={head_dim} key norm mismatch at h={h} t={t}"
);
assert_eq!(
(nw >> 16) as u16,
cpu_keys.residual_norms[h][t],
"hd={head_dim} key residual-norm mismatch at h={h} t={t}"
);
}
}
let v_norm_off = vecs * pw;
let words = run_tq_kernel(
ctx,
"tq_encode_values",
&v,
fx.layout.value_words(vecs).expect("value_words"),
&fx.signs,
¶ms,
SEQ_LEN * N_KV_HEADS,
);
for h in 0..N_KV_HEADS {
for t in 0..SEQ_LEN {
let slot = h * MAX_SEQ + t;
let gpu_polar: &[u8] = bytemuck::cast_slice(&words[slot * pw..slot * pw + pw]);
let cpu_polar = &cpu_values.polar_data[h][t * pw * 4..(t + 1) * pw * 4];
assert_eq!(
gpu_polar, cpu_polar,
"hd={head_dim} value polar mismatch at h={h} t={t}"
);
assert_eq!(
(words[v_norm_off + slot] & 0xFFFF) as u16,
cpu_values.norms[h][t],
"hd={head_dim} value norm mismatch at h={h} t={t}"
);
}
}
}
fn check_rotate_q(ctx: &GpuContext, head_dim: usize) {
let fx = Fixture::new(head_dim);
let q_dim = N_HEADS * head_dim;
let mut rng = Lcg::new(23);
let q = rng.fill(SEQ_LEN * q_dim);
let mut params = fx.encode_params(SEQ_LEN, N_HEADS, q_dim);
params.q_cap = SEQ_LEN as u32;
let region = SEQ_LEN * N_HEADS * head_dim;
let words = run_tq_kernel(
ctx,
"tq_rotate_q",
&q,
2 * region + SEQ_LEN * N_HEADS,
&fx.signs,
¶ms,
SEQ_LEN * N_HEADS,
);
let got: &[f32] = bytemuck::cast_slice(&words);
let mut scratch = QueryRotationScratch::new(N_HEADS, head_dim);
for t in 0..SEQ_LEN {
rotate_queries(
&q[t * q_dim..(t + 1) * q_dim],
N_HEADS,
head_dim,
&fx.rotation,
&mut scratch,
);
for h in 0..N_HEADS {
let base = (t * N_HEADS + h) * head_dim;
for d in 0..head_dim {
assert_close(
got[base + d],
scratch.q_rot[h * head_dim + d],
&format!("hd={head_dim} q_rot t={t} h={h} d={d}"),
);
assert_close(
got[region + base + d],
scratch.q_jl[h * head_dim + d],
&format!("hd={head_dim} q_jl t={t} h={h} d={d}"),
);
}
assert_close(
got[2 * region + t * N_HEADS + h],
scratch.q_jl_total_sums[h],
&format!("hd={head_dim} q_jl sum t={t} h={h}"),
);
}
}
}
fn check_attention(ctx: &GpuContext, head_dim: usize, seq_len: usize, q_splits: usize) {
assert!(
seq_len.is_multiple_of(q_splits),
"test setup: seq_len {seq_len} must divide evenly into {q_splits} splits"
);
let fx = Fixture::new(head_dim);
let kv_dim = N_KV_HEADS * head_dim;
let q_dim = N_HEADS * head_dim;
let scale = 1.0 / (head_dim as f32).sqrt();
let group_size = N_HEADS / N_KV_HEADS;
let cache_cap = seq_len + 5;
let mut rng = Lcg::new(37);
let k = rng.fill(seq_len * kv_dim);
let v = rng.fill(seq_len * kv_dim);
let q = rng.fill(seq_len * q_dim);
let (cpu_keys, cpu_values) = cpu_encode(&fx, &k, &v, kv_dim, seq_len);
let vecs = N_KV_HEADS * cache_cap;
let mut enc_params = fx.encode_params(seq_len, N_KV_HEADS, kv_dim);
enc_params.max_seq_len = cache_cap as u32;
let key_words = run_tq_kernel(
ctx,
"tq_encode_keys",
&k,
fx.layout.key_words(vecs).expect("key_words"),
&fx.signs,
&enc_params,
seq_len * N_KV_HEADS,
);
let value_words = run_tq_kernel(
ctx,
"tq_encode_values",
&v,
fx.layout.value_words(vecs).expect("value_words"),
&fx.signs,
&enc_params,
seq_len * N_KV_HEADS,
);
let mut rot_params = fx.encode_params(seq_len, N_HEADS, q_dim);
rot_params.q_cap = seq_len as u32;
let region = seq_len * N_HEADS * head_dim;
let qrot_words = run_tq_kernel(
ctx,
"tq_rotate_q",
&q,
2 * region + seq_len * N_HEADS,
&fx.signs,
&rot_params,
seq_len * N_HEADS,
);
let rows_per_split = seq_len / q_splits;
let mut got = vec![0.0f32; seq_len * q_dim];
for split in 0..q_splits {
let q_base = split * rows_per_split;
let attn_params = TqAttnParams {
n_heads: N_HEADS as u32,
n_kv_heads: N_KV_HEADS as u32,
head_dim: head_dim as u32,
max_seq: seq_len as u32,
start_pos: 0,
scale,
q_cap: seq_len as u32,
out_stride: q_dim as u32,
qjl_scale: TqAttnParams::qjl_scale_for(head_dim),
sign_off: fx.sign_off as u32,
centroids: fx.config.centroids,
q_base: q_base as u32,
cache_cap: cache_cap as u32,
};
let chunk = run_attention(
ctx,
&qrot_words,
&key_words,
&value_words,
&fx.signs,
&attn_params,
seq_len * q_dim,
N_HEADS,
rows_per_split,
);
let lo = q_base * q_dim;
let hi = lo + rows_per_split * q_dim;
got[lo..hi].copy_from_slice(&chunk[lo..hi]);
}
let mut scratch = QueryRotationScratch::new(N_HEADS, head_dim);
for t_q in 0..seq_len {
let live = t_q + 1;
rotate_queries(
&q[t_q * q_dim..(t_q + 1) * q_dim],
N_HEADS,
head_dim,
&fx.rotation,
&mut scratch,
);
let mut expected = vec![0.0f32; q_dim];
for kv_head in 0..N_KV_HEADS {
let group_start = kv_head * group_size;
let mut scores = vec![0.0f32; group_size * live];
attn_scores_turboquant_gqa(
&cpu_keys,
kv_head,
group_start,
group_size,
&mut scores,
head_dim,
scale,
live,
&fx.config,
&mut scratch,
);
for g in 0..group_size {
let row = &mut scores[g * live..(g + 1) * live];
let max = row.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let mut sum = 0.0;
for s in row.iter_mut() {
*s = (*s - max).exp();
sum += *s;
}
for s in row.iter_mut() {
*s /= sum;
}
}
attn_values_turboquant_gqa(
&cpu_values,
kv_head,
group_start,
group_size,
&scores,
&mut expected,
head_dim,
live,
&fx.rotation,
&fx.config,
);
}
for (i, &want) in expected.iter().enumerate() {
assert_close(
got[t_q * q_dim + i],
want,
&format!("hd={head_dim} attn out t_q={t_q} i={i}"),
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_attention(
ctx: &GpuContext,
qrot: &[u32],
keys: &[u32],
values: &[u32],
signs: &[f32],
params: &TqAttnParams,
out_floats: usize,
n_heads: usize,
n_queries: usize,
) -> Vec<f32> {
let pipeline = ctx.create_pipeline(
shaders::FLASH_ATTENTION_TQ,
"flash_attention_tq",
"flash_attention_tq",
);
let qrot_buf = ctx.upload_storage(bytemuck::cast_slice(qrot), "qrot");
let k_buf = ctx.upload_storage(bytemuck::cast_slice(keys), "keys");
let v_buf = ctx.upload_storage(bytemuck::cast_slice(values), "values");
let out_buf = ctx.create_storage_rw((out_floats * 4) as u64, "out");
let params_buf = ctx.upload_storage(bytemuck::cast_slice(¶ms.to_u32_array()), "params");
let signs_buf = ctx.upload_f32(signs, "signs");
let bg = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("flash_attention_tq"),
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: qrot_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: k_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: v_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: out_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 5,
resource: signs_buf.as_entire_binding(),
},
],
});
let mut enc = ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("flash_attention_tq"),
timestamp_writes: None,
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(n_heads as u32, n_queries as u32, 1);
}
ctx.queue.submit(Some(enc.finish()));
ctx.download_f32(&out_buf, out_floats)
}
fn check_snapshot_roundtrip(ctx: &GpuContext, head_dim: usize) {
let fx = Fixture::new(head_dim);
let kv_dim = N_KV_HEADS * head_dim;
let mut rng = Lcg::new(59);
let k = rng.fill(SEQ_LEN * kv_dim);
let v = rng.fill(SEQ_LEN * kv_dim);
let (cpu_keys, cpu_values) = cpu_encode(&fx, &k, &v, kv_dim, SEQ_LEN);
let n_layers = LAYER + 1;
let config = ModelConfig {
architecture: "lfm2".into(),
n_layers,
hidden_size: N_HEADS * head_dim,
intermediate_size: N_HEADS * head_dim,
n_heads: N_HEADS,
n_kv_heads: N_KV_HEADS,
head_dim,
vocab_size: 32,
max_seq_len: MAX_SEQ,
rope_theta: 10_000.0,
rms_norm_eps: 1e-5,
block_types: vec![BlockType::Attention; n_layers],
conv_kernel_size: Some(3),
kv_heads_per_layer: vec![N_KV_HEADS; n_layers],
scalars: ScalarMultipliers::default(),
moe: None,
is_causal: true,
class_labels: Vec::new(),
};
let tq = TqGpuCache::new(ctx, &config, MAX_SEQ, SEQ_LEN, TqMode { seed: SEED })
.expect("TqGpuCache allocation");
tq.write_params(ctx, &config, SEQ_LEN, 0, 1.0 / (head_dim as f32).sqrt());
let k_buf = ctx.upload_f32(&k, "k_src");
let v_buf = ctx.upload_f32(&v, "v_src");
let mut enc = ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
tq.encode_kv(ctx, &mut enc, LAYER, &k_buf, &v_buf, SEQ_LEN);
ctx.queue.submit(Some(enc.finish()));
let (keys_blob, values_blob) = tq.snapshot_layer(ctx, LAYER, SEQ_LEN);
assert_eq!(
keys_blob,
encode_compressed_keys(&cpu_keys),
"hd={head_dim} TQK1 snapshot differs from the CPU encoding"
);
assert_eq!(
values_blob,
encode_compressed_values(&cpu_values),
"hd={head_dim} TQV1 snapshot differs from the CPU encoding"
);
assert_eq!(
tq.restore_layer(ctx, 0, &keys_blob, &values_blob),
Some(SEQ_LEN),
"hd={head_dim} restore rejected a blob it wrote itself"
);
let (keys_again, values_again) = tq.snapshot_layer(ctx, 0, SEQ_LEN);
assert_eq!(
keys_again, keys_blob,
"hd={head_dim} key restore round-trip"
);
assert_eq!(
values_again, values_blob,
"hd={head_dim} value restore round-trip"
);
assert_eq!(
tq.restore_layer(ctx, 0, &keys_blob[..keys_blob.len() - 1], &values_blob),
None,
"hd={head_dim} truncated TQK1 blob was accepted"
);
assert_eq!(
tq.restore_layer(ctx, 0, &values_blob, &keys_blob),
None,
"hd={head_dim} swapped key/value blobs were accepted"
);
let (empty_k, empty_v) = tq.snapshot_layer(ctx, 0, 0);
assert_eq!(tq.restore_layer(ctx, 0, &empty_k, &empty_v), Some(0));
}
fn assert_close(got: f32, want: f32, what: &str) {
let diff = (got - want).abs();
assert!(
diff < TOL,
"{what}: got={got} want={want} diff={diff} (tol={TOL})"
);
}
fn context() -> Option<GpuContext> {
match GpuContext::new() {
Ok(ctx) => Some(ctx),
Err(e) => {
eprintln!("skipping: no wgpu adapter ({e})");
None
}
}
}
#[test]
fn encode_matches_cpu() {
let Some(ctx) = context() else { return };
check_encode(&ctx, 64);
check_encode(&ctx, 128);
}
#[test]
fn rotate_q_matches_cpu() {
let Some(ctx) = context() else { return };
check_rotate_q(&ctx, 64);
check_rotate_q(&ctx, 128);
}
#[test]
fn attention_matches_cpu() {
let Some(ctx) = context() else { return };
check_attention(&ctx, 64, SEQ_LEN, 1);
check_attention(&ctx, 128, SEQ_LEN, 1);
}
#[test]
fn attention_matches_cpu_multi_tile_and_split_queries() {
let Some(ctx) = context() else { return };
check_attention(&ctx, 64, 600, 2);
check_attention(&ctx, 128, 600, 2);
}
#[test]
fn snapshot_roundtrips_and_matches_cpu_blobs() {
let Some(ctx) = context() else { return };
check_snapshot_roundtrip(&ctx, 64);
check_snapshot_roundtrip(&ctx, 128);
}