| """Validate the fused paged INT4-KIVI decode kernel against the dense path. |
| |
| Both sides read the SAME packed int4 cache (built with ``int4_kivi_store`` exactly |
| as vLLM's prefill does), so this isolates KERNEL correctness from quant error: |
| the fused ``int4_kivi_paged_decode`` must match dequant-the-whole-cache |
| (``int4_kivi_gather_dequant``) + GQA softmax attention, to fp32 tolerance. |
| |
| Run with the vLLM venv from a NON-vllm cwd: |
| cd /tmp && /home/alex/poolside-hackathon-kv-quant/.venv-vllm/bin/python \ |
| /home/alex/poolside-hackathon-kv-quant/.claude/worktrees/kv-quant-decode-speed/scripts/validate_paged_decode.py |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
|
|
| import torch |
|
|
| from vllm.v1.attention.ops.triton_int4_kivi import ( |
| BLOCK, |
| int4_kivi_gather_dequant, |
| int4_kivi_paged_decode, |
| int4_kivi_store, |
| ) |
|
|
| DEV = "cuda" |
| HQ, HK, D = 48, 8, 128 |
| PAGE = 16 |
| FULL_DIM = D // 2 + D // 16 |
|
|
|
|
| def build_cache(seq_lens, seed=0): |
| """Build a paged int4 cache + block_table for the given per-request seq_lens, |
| storing each request's whole sequence prefill-style (monotone slots from a |
| block boundary -> full blocks become per-channel K, trailing block per-token). |
| Returns (kv_cache, block_table, seq_lens_t, k_ref, v_ref) where k_ref/v_ref |
| are the original bf16 K/V (only for sanity, not used as the reference).""" |
| g = torch.Generator(device=DEV).manual_seed(seed) |
| B = len(seq_lens) |
| nblk_per = [(L + PAGE - 1) // PAGE for L in seq_lens] |
| max_blocks = max(nblk_per) |
| num_blocks = sum(nblk_per) + 4 |
| kv_cache = torch.zeros( |
| (num_blocks, 2, PAGE, HK, FULL_DIM), dtype=torch.uint8, device=DEV |
| ) |
| block_table = torch.zeros((B, max_blocks), dtype=torch.int32, device=DEV) |
|
|
| cursor = 1 |
| for b, L in enumerate(seq_lens): |
| nb = nblk_per[b] |
| phys = list(range(cursor, cursor + nb)) |
| cursor += nb |
| for j, p in enumerate(phys): |
| block_table[b, j] = p |
| k = torch.randn(L, HK, D, generator=g, device=DEV, dtype=torch.bfloat16) |
| v = torch.randn(L, HK, D, generator=g, device=DEV, dtype=torch.bfloat16) |
| |
| slots = torch.tensor( |
| [phys[t // PAGE] * PAGE + (t % PAGE) for t in range(L)], |
| dtype=torch.int64, device=DEV, |
| ) |
| int4_kivi_store(k, v, kv_cache, slots, D) |
| seq_lens_t = torch.tensor(seq_lens, dtype=torch.int32, device=DEV) |
| return kv_cache, block_table, seq_lens_t |
|
|
|
|
| def ref_attend(q, kv_cache, block_table, seq_lens): |
| """Dense reference: gather-dequant the whole cache, GQA softmax attention.""" |
| B = q.shape[0] |
| max_seq = int(seq_lens.max().item()) |
| k_dense, v_dense = int4_kivi_gather_dequant( |
| kv_cache, block_table, seq_lens, D, HK, max_seq |
| ) |
| group = HQ // HK |
| sm = 1.0 / math.sqrt(D) |
| out = torch.empty(B, HQ, D, dtype=torch.bfloat16, device=DEV) |
| for b in range(B): |
| L = int(seq_lens[b].item()) |
| k = k_dense[b, :, :L, :].float().repeat_interleave(group, dim=0) |
| v = v_dense[b, :, :L, :].float().repeat_interleave(group, dim=0) |
| qb = q[b].float().unsqueeze(1) |
| scores = (qb @ k.transpose(-1, -2)) * sm |
| p = torch.softmax(scores, dim=-1) |
| out[b] = (p @ v).squeeze(1).to(torch.bfloat16) |
| return out |
|
|
|
|
| def run_case(seq_lens, seed=0): |
| kv_cache, bt, sl = build_cache(seq_lens, seed=seed) |
| g = torch.Generator(device=DEV).manual_seed(seed + 999) |
| B = len(seq_lens) |
| q = torch.randn(B, HQ, D, generator=g, device=DEV, dtype=torch.bfloat16) |
| sm = 1.0 / math.sqrt(D) |
| ref = ref_attend(q, kv_cache, bt, sl) |
| fused = int4_kivi_paged_decode(q, kv_cache, bt, sl, sm) |
| diff = (fused.float() - ref.float()).abs() |
| rel = diff / (ref.float().abs() + 1e-3) |
| return diff.max().item(), diff.mean().item(), rel.max().item() |
|
|
|
|
| if __name__ == "__main__": |
| torch.manual_seed(0) |
| cases = { |
| "exact-block (L=512)": [512], |
| "partial-tail (L=500)": [500], |
| "short (L=33)": [33], |
| "tiny (L=1)": [1], |
| "mixed batch": [128, 257, 64, 1000, 16, 999], |
| "long (L=12000)": [12000], |
| "long mixed": [12000, 8001, 16000, 4096], |
| } |
| ok = True |
| for name, sl in cases.items(): |
| amax, amean, rmax = run_case(sl, seed=hash(name) % 10000) |
| tol = 5e-2 |
| passed = amax < tol |
| ok = ok and passed |
| flag = "ok " if passed else "FAIL" |
| print(f"[{flag}] {name:24s} max|Δ|={amax:.4e} mean|Δ|={amean:.2e} relmax={rmax:.2e}") |
| print("ALL PASS" if ok else "SOME FAILED") |
|
|