819 lines
30 KiB
Python
819 lines
30 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""
|
|
Round-trip tests for compressor → FP8 quant + KV cache insert → gather + dequant.
|
|
|
|
These tests cover:
|
|
A) DeepseekV4 Attention: head_dim=512 (448 FP8 nope + 64 bf16 rope), quant_block=64
|
|
B) Fused dequant+gather K cache
|
|
C) Indexer: head_dim=128 (all FP8), quant_block=128
|
|
D) DeepseekV4 Attention magnitude range: correctness across small/large values
|
|
E) Indexer fused Triton kernel: compress+norm+rope+quant+insert
|
|
"""
|
|
|
|
import math
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from vllm import _custom_ops as ops
|
|
from vllm.models.deepseek_v4.common.ops import (
|
|
dequantize_and_gather_k_cache,
|
|
quantize_and_insert_k_cache,
|
|
)
|
|
from vllm.models.deepseek_v4.common.ops.fused_compress_quant_cache import (
|
|
_fused_kv_compress_norm_rope_insert_indexer_attn,
|
|
_fused_kv_compress_norm_rope_insert_indexer_mxfp4_attn,
|
|
)
|
|
|
|
from .test_fused_indexer_q_rope_quant import quantize_to_mxfp4
|
|
|
|
|
|
def _ue8m0_reference(x: torch.Tensor, block_size: int, fp8_max: float):
|
|
"""PyTorch reference for UE8M0 FP8 quantization (per-block, power-of-2 scale).
|
|
|
|
Returns (x_fp8, scales) where x_fp8 is float8_e4m3fn and scales are float32.
|
|
"""
|
|
assert x.dim() == 1
|
|
n = x.numel()
|
|
n_blocks = math.ceil(n / block_size)
|
|
x_fp8 = torch.zeros(n, dtype=torch.float8_e4m3fn, device=x.device)
|
|
scales = torch.zeros(n_blocks, dtype=torch.float32, device=x.device)
|
|
|
|
for i in range(n_blocks):
|
|
start = i * block_size
|
|
end = min(start + block_size, n)
|
|
block = x[start:end].float()
|
|
amax = block.abs().max().clamp(min=1e-4)
|
|
raw_scale = amax / fp8_max
|
|
exponent = math.ceil(math.log2(raw_scale.item()))
|
|
scale = 2.0**exponent
|
|
scales[i] = scale
|
|
quantized = (block / scale).clamp(-fp8_max, fp8_max)
|
|
x_fp8[start:end] = quantized.to(torch.float8_e4m3fn)
|
|
|
|
return x_fp8, scales
|
|
|
|
|
|
# ── Test A: DeepseekV4 Attention path ──────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize("num_tokens", [1, 4, 8, 17])
|
|
@pytest.mark.parametrize("block_size", [16, 64])
|
|
def test_deepseek_v4_attention_quant_cache_roundtrip(num_tokens: int, block_size: int):
|
|
"""compressed_kv → quantize_and_insert_k_cache → dequantize_and_gather_k_cache
|
|
→ compare against original."""
|
|
|
|
HEAD_DIM = 512
|
|
NOPE_DIM = 448
|
|
HEAD_BYTES = 584 # 448 fp8 + 128 bf16 + 8 uint8 scale
|
|
FP8_MAX = 448.0
|
|
QUANT_BLOCK = 64
|
|
|
|
num_blocks = (num_tokens + block_size - 1) // block_size + 1
|
|
device = "cuda"
|
|
|
|
# Random compressed_kv (simulates compressor output)
|
|
compressed_kv = torch.randn(
|
|
num_tokens, HEAD_DIM, dtype=torch.bfloat16, device=device
|
|
)
|
|
|
|
# ── Quant + insert ──────────────────────────────────────────────────
|
|
k_cache = torch.zeros(
|
|
num_blocks, block_size, HEAD_BYTES, dtype=torch.uint8, device=device
|
|
)
|
|
k_cache_2d = k_cache.view(num_blocks, -1)
|
|
|
|
# Sequential slot mapping: token i → slot i
|
|
slot_mapping = torch.arange(num_tokens, dtype=torch.int64, device=device)
|
|
|
|
quantize_and_insert_k_cache(
|
|
compressed_kv, k_cache_2d, slot_mapping, block_size=block_size
|
|
)
|
|
|
|
# ── Gather + dequant ────────────────────────────────────────────────
|
|
num_reqs = 1
|
|
max_blocks_per_seq = num_blocks
|
|
out = torch.zeros(
|
|
num_reqs, num_tokens, HEAD_DIM, dtype=torch.bfloat16, device=device
|
|
)
|
|
seq_lens = torch.tensor([num_tokens], dtype=torch.int32, device=device)
|
|
# block_table: request 0 uses physical blocks 0, 1, ...
|
|
block_table = torch.arange(
|
|
max_blocks_per_seq, dtype=torch.int32, device=device
|
|
).unsqueeze(0)
|
|
|
|
dequantize_and_gather_k_cache(
|
|
out, k_cache, seq_lens, None, block_table, block_size, offset=0
|
|
)
|
|
|
|
recovered = out[0, :num_tokens]
|
|
|
|
# ── NoPE portion (first 448): FP8 quantized, expect UE8M0 error ──
|
|
nope_orig = compressed_kv[:, :NOPE_DIM].float()
|
|
nope_recv = recovered[:, :NOPE_DIM].float()
|
|
nope_diff = (nope_recv - nope_orig).abs()
|
|
|
|
# Per-token check: FP8 e4m3 (3-bit mantissa) worst-case error is
|
|
# half-ULP at the largest representable value. At y ≈ 448 (max),
|
|
# ULP = 2^(8-3) = 32, so error ≤ 16 * scale.
|
|
for t in range(num_tokens):
|
|
_, scales = _ue8m0_reference(
|
|
compressed_kv[t, :NOPE_DIM].float(), QUANT_BLOCK, FP8_MAX
|
|
)
|
|
max_allowed = 16.0 * scales.max().item()
|
|
token_diff = nope_diff[t].max().item()
|
|
assert token_diff <= max_allowed, (
|
|
f"Token {t} nope diff {token_diff} exceeds max_allowed "
|
|
f"{max_allowed} (scale={scales.max().item()})"
|
|
)
|
|
|
|
# ── RoPE portion (last 64): stored as bf16, should be exact ─────
|
|
rope_diff = (recovered[:, NOPE_DIM:] - compressed_kv[:, NOPE_DIM:]).abs()
|
|
assert rope_diff.max().item() == 0.0, (
|
|
f"RoPE portion should be exact but got max diff {rope_diff.max().item()}"
|
|
)
|
|
|
|
|
|
# ── Test B: Fused dequant+gather K cache ────────────────────────────────────
|
|
|
|
|
|
def _dequantize_and_gather_k_cache_reference(
|
|
out: torch.Tensor,
|
|
k_cache: torch.Tensor,
|
|
seq_lens: torch.Tensor,
|
|
gather_lens: torch.Tensor | None,
|
|
block_table: torch.Tensor,
|
|
block_size: int,
|
|
offset: int,
|
|
) -> None:
|
|
fp8_dim = 448
|
|
bf16_dim = 64
|
|
scale_dim = 8
|
|
quant_block = 64
|
|
token_data_size = fp8_dim + bf16_dim * 2
|
|
|
|
for req_id in range(seq_lens.shape[0]):
|
|
seq_len = seq_lens[req_id].item()
|
|
gather_len = gather_lens[req_id].item() if gather_lens is not None else seq_len
|
|
start_pos = seq_len - gather_len
|
|
|
|
for i in range(gather_len):
|
|
pos = start_pos + i
|
|
pos_in_block = pos % block_size
|
|
block_idx = block_table[req_id, pos // block_size].item()
|
|
cache_block = k_cache[block_idx].view(-1)
|
|
|
|
token_data_start = pos_in_block * token_data_size
|
|
fp8_bytes = cache_block[token_data_start : token_data_start + fp8_dim]
|
|
fp8_vals = fp8_bytes.view(torch.float8_e4m3fn).float()
|
|
|
|
scale_start = block_size * token_data_size + pos_in_block * scale_dim
|
|
encoded_scales = cache_block[scale_start : scale_start + scale_dim]
|
|
scales = torch.exp2(encoded_scales[:7].float() - 127.0)
|
|
dequant = fp8_vals * scales.repeat_interleave(quant_block)
|
|
|
|
bf16_start = token_data_start + fp8_dim
|
|
bf16_bytes = cache_block[bf16_start : bf16_start + bf16_dim * 2]
|
|
bf16_tail = bf16_bytes.view(torch.bfloat16)
|
|
|
|
out[req_id, offset + i, :fp8_dim] = dequant
|
|
out[req_id, offset + i, fp8_dim:] = bf16_tail
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("seq_lens_host", "gather_lens_host", "offset"),
|
|
[
|
|
([9, 23, 7], None, 0),
|
|
([19, 8, 257], [6, 8, 129], 5),
|
|
],
|
|
)
|
|
def test_dequantize_and_gather_k_cache(
|
|
seq_lens_host: list[int],
|
|
gather_lens_host: list[int] | None,
|
|
offset: int,
|
|
):
|
|
block_size = 64
|
|
head_dim = 512
|
|
nope_dim = 448
|
|
scale_dim = 8
|
|
head_bytes = nope_dim + (head_dim - nope_dim) * 2 + scale_dim
|
|
device = "cuda"
|
|
num_reqs = len(seq_lens_host)
|
|
num_tokens = sum(seq_lens_host)
|
|
max_gather_len = max(gather_lens_host or seq_lens_host)
|
|
max_blocks_per_seq = math.ceil(max(seq_lens_host) / block_size)
|
|
num_blocks = sum(math.ceil(seq_len / block_size) for seq_len in seq_lens_host)
|
|
|
|
compressed_kv = torch.randn(
|
|
num_tokens, head_dim, dtype=torch.bfloat16, device=device
|
|
)
|
|
|
|
# Randomize physical pages so the test covers block-table translation.
|
|
# Keep padded block-table entries invalid to catch accidental reads.
|
|
physical_blocks = torch.randperm(num_blocks, device=device)
|
|
block_table = torch.full(
|
|
(num_reqs, max_blocks_per_seq), int(-1e6), dtype=torch.int32, device=device
|
|
)
|
|
start = 0
|
|
for req_id, seq_len in enumerate(seq_lens_host):
|
|
num_req_blocks = math.ceil(seq_len / block_size)
|
|
req_blocks = physical_blocks[start : start + num_req_blocks]
|
|
block_table[req_id, :num_req_blocks] = req_blocks
|
|
start += num_req_blocks
|
|
|
|
# Build slot_mapping for quantize_and_insert_k_cache.
|
|
slot_mapping = torch.empty(num_tokens, dtype=torch.int64, device=device)
|
|
start = 0
|
|
for req_id, seq_len in enumerate(seq_lens_host):
|
|
logical_pos = torch.arange(seq_len, dtype=torch.int64, device=device)
|
|
block_idx = block_table[req_id, logical_pos // block_size].to(torch.int64)
|
|
token_slots = block_idx * block_size + logical_pos % block_size
|
|
slot_mapping[start : start + seq_len] = token_slots
|
|
start += seq_len
|
|
|
|
# Insert compressed K into the paged cache layout used by the gather op.
|
|
k_cache = torch.empty(
|
|
num_blocks, block_size, head_bytes, dtype=torch.uint8, device=device
|
|
)
|
|
k_cache_2d = k_cache.view(num_blocks, -1)
|
|
quantize_and_insert_k_cache(compressed_kv, k_cache_2d, slot_mapping, block_size)
|
|
|
|
out_shape = (num_reqs, offset + max_gather_len + 3, head_dim)
|
|
ref_out = torch.empty(out_shape, dtype=torch.bfloat16, device=device)
|
|
actual_out = torch.empty_like(ref_out)
|
|
seq_lens = torch.tensor(seq_lens_host, dtype=torch.int32, device=device)
|
|
gather_lens = (
|
|
torch.tensor(gather_lens_host, dtype=torch.int32, device=device)
|
|
if gather_lens_host is not None
|
|
else None
|
|
)
|
|
|
|
# Compare production gather against a PyTorch reference for valid output rows.
|
|
_dequantize_and_gather_k_cache_reference(
|
|
ref_out, k_cache, seq_lens, gather_lens, block_table, block_size, offset
|
|
)
|
|
dequantize_and_gather_k_cache(
|
|
actual_out, k_cache, seq_lens, gather_lens, block_table, block_size, offset
|
|
)
|
|
torch.accelerator.synchronize()
|
|
|
|
# only check non-padded content
|
|
for req_id, seq_len in enumerate(seq_lens_host):
|
|
gather_len = (
|
|
gather_lens_host[req_id] if gather_lens_host is not None else seq_len
|
|
)
|
|
actual = actual_out[req_id, offset : offset + gather_len]
|
|
expected = ref_out[req_id, offset : offset + gather_len]
|
|
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
|
|
|
|
|
# ── Test C: Indexer path ────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize("num_tokens", [1, 4, 8, 17])
|
|
@pytest.mark.parametrize("block_size", [16, 64])
|
|
def test_indexer_quant_cache_roundtrip(num_tokens: int, block_size: int):
|
|
"""k → indexer_k_quant_and_cache → cp_gather_indexer_k_quant_cache
|
|
→ manual dequant → compare against original."""
|
|
|
|
HEAD_DIM = 128
|
|
QUANT_BLOCK_SIZE = 128
|
|
# cache_stride = head_dim + (head_dim * 4 / quant_block_size) = 128 + 4 = 132
|
|
CACHE_STRIDE = HEAD_DIM + HEAD_DIM * 4 // QUANT_BLOCK_SIZE
|
|
|
|
num_blocks = (num_tokens + block_size - 1) // block_size + 1
|
|
device = "cuda"
|
|
|
|
# Random K (simulates compressor output for indexer)
|
|
k = torch.randn(num_tokens, HEAD_DIM, dtype=torch.bfloat16, device=device)
|
|
|
|
# ── Quant + insert ──────────────────────────────────────────────────
|
|
kv_cache = torch.zeros(
|
|
num_blocks, block_size, CACHE_STRIDE, dtype=torch.uint8, device=device
|
|
)
|
|
slot_mapping = torch.arange(num_tokens, dtype=torch.int64, device=device)
|
|
|
|
ops.indexer_k_quant_and_cache(k, kv_cache, slot_mapping, QUANT_BLOCK_SIZE, "ue8m0")
|
|
|
|
# ── Gather ──────────────────────────────────────────────────────────
|
|
max_blocks_per_seq = num_blocks
|
|
block_table = torch.arange(
|
|
max_blocks_per_seq, dtype=torch.int32, device=device
|
|
).unsqueeze(0)
|
|
cu_seq_lens = torch.tensor([0, num_tokens], dtype=torch.int32, device=device)
|
|
|
|
# dst_k: [total_seq_len, head_dim] as uint8 (raw FP8 bytes)
|
|
dst_k = torch.zeros(num_tokens, HEAD_DIM, dtype=torch.uint8, device=device)
|
|
# dst_scale: [total_seq_len, head_dim/quant_block*4] as uint8 (raw float32 bytes)
|
|
num_scale_bytes = HEAD_DIM * 4 // QUANT_BLOCK_SIZE # 4
|
|
dst_scale = torch.zeros(
|
|
num_tokens, num_scale_bytes, dtype=torch.uint8, device=device
|
|
)
|
|
|
|
ops.cp_gather_indexer_k_quant_cache(
|
|
kv_cache, dst_k, dst_scale, block_table, cu_seq_lens
|
|
)
|
|
|
|
# ── Manual dequant ──────────────────────────────────────────────────
|
|
k_fp8 = dst_k.view(torch.float8_e4m3fn).float() # [num_tokens, 128]
|
|
scale = dst_scale.view(torch.float32) # [num_tokens, 1]
|
|
k_recovered = k_fp8 * scale # [num_tokens, 128]
|
|
|
|
# ── Compare ─────────────────────────────────────────────────────────
|
|
diff = (k_recovered - k.float()).abs()
|
|
k_abs = k.float().abs()
|
|
|
|
for t in range(num_tokens):
|
|
amax = k_abs[t].max().clamp(min=1e-4).item()
|
|
# UE8M0: scale = 2^ceil(log2(amax / 448))
|
|
exponent = math.ceil(math.log2(amax / 448.0))
|
|
ue8m0_scale = 2.0**exponent
|
|
# FP8 e4m3 (3-bit mantissa): worst-case error = 16 * scale
|
|
max_allowed = 16.0 * ue8m0_scale
|
|
token_diff = diff[t].max().item()
|
|
assert token_diff <= max_allowed, (
|
|
f"Token {t} diff {token_diff} exceeds max_allowed "
|
|
f"{max_allowed} (scale={ue8m0_scale})"
|
|
)
|
|
|
|
|
|
def test_indexer_gather_accepts_upper_bound_output():
|
|
"""Gather only exact cu_seq_lens even when dst is over-allocated."""
|
|
|
|
head_dim = 128
|
|
quant_block_size = 128
|
|
cache_stride = head_dim + head_dim * 4 // quant_block_size
|
|
valid_tokens = 9
|
|
upper_bound_tokens = 13
|
|
block_size = 16
|
|
num_blocks = 2
|
|
sentinel = 123
|
|
device = "cuda"
|
|
|
|
k = torch.randn(valid_tokens, head_dim, dtype=torch.bfloat16, device=device)
|
|
kv_cache = torch.zeros(
|
|
num_blocks, block_size, cache_stride, dtype=torch.uint8, device=device
|
|
)
|
|
slot_mapping = torch.arange(valid_tokens, dtype=torch.int64, device=device)
|
|
ops.indexer_k_quant_and_cache(k, kv_cache, slot_mapping, quant_block_size, "ue8m0")
|
|
|
|
block_table = torch.arange(num_blocks, dtype=torch.int32, device=device).unsqueeze(
|
|
0
|
|
)
|
|
cu_seq_lens = torch.tensor([0, valid_tokens], dtype=torch.int32, device=device)
|
|
dst_k = torch.full(
|
|
(upper_bound_tokens, head_dim), sentinel, dtype=torch.uint8, device=device
|
|
)
|
|
num_scale_bytes = head_dim * 4 // quant_block_size
|
|
dst_scale = torch.full(
|
|
(upper_bound_tokens, num_scale_bytes),
|
|
sentinel,
|
|
dtype=torch.uint8,
|
|
device=device,
|
|
)
|
|
|
|
ops.cp_gather_indexer_k_quant_cache(
|
|
kv_cache, dst_k, dst_scale, block_table, cu_seq_lens
|
|
)
|
|
torch.accelerator.synchronize()
|
|
|
|
k_recovered = dst_k[:valid_tokens].view(torch.float8_e4m3fn).float() * dst_scale[
|
|
:valid_tokens
|
|
].view(torch.float32)
|
|
diff = (k_recovered - k.float()).abs()
|
|
max_allowed = (16.0 * dst_scale[:valid_tokens].view(torch.float32).max()).item()
|
|
assert diff.max().item() <= max_allowed
|
|
assert torch.all(dst_k[valid_tokens:] == sentinel)
|
|
assert torch.all(dst_scale[valid_tokens:] == sentinel)
|
|
|
|
|
|
# ── Test D: DeepseekV4 attention with values at different magnitudes ───────────
|
|
|
|
|
|
def test_deepseek_v4_quant_magnitude_range():
|
|
"""Test that quantization handles a range of magnitudes correctly."""
|
|
|
|
HEAD_DIM = 512
|
|
NOPE_DIM = 448
|
|
HEAD_BYTES = 584
|
|
block_size = 16
|
|
num_tokens = 4
|
|
num_blocks = 2
|
|
device = "cuda"
|
|
|
|
# Create inputs with varying magnitudes: small, medium, large
|
|
compressed_kv = torch.zeros(
|
|
num_tokens, HEAD_DIM, dtype=torch.bfloat16, device=device
|
|
)
|
|
compressed_kv[0] = 0.001 # very small
|
|
compressed_kv[1] = 1.0 # unit scale
|
|
compressed_kv[2] = 100.0 # large
|
|
compressed_kv[3] = torch.randn(HEAD_DIM, dtype=torch.bfloat16, device=device)
|
|
|
|
k_cache = torch.zeros(
|
|
num_blocks, block_size, HEAD_BYTES, dtype=torch.uint8, device=device
|
|
)
|
|
slot_mapping = torch.arange(num_tokens, dtype=torch.int64, device=device)
|
|
|
|
quantize_and_insert_k_cache(
|
|
compressed_kv, k_cache.view(num_blocks, -1), slot_mapping, block_size
|
|
)
|
|
|
|
out = torch.zeros(1, num_tokens, HEAD_DIM, dtype=torch.bfloat16, device=device)
|
|
seq_lens = torch.tensor([num_tokens], dtype=torch.int32, device=device)
|
|
block_table = torch.arange(num_blocks, dtype=torch.int32, device=device).unsqueeze(
|
|
0
|
|
)
|
|
|
|
dequantize_and_gather_k_cache(
|
|
out, k_cache, seq_lens, None, block_table, block_size, offset=0
|
|
)
|
|
|
|
recovered = out[0, :num_tokens]
|
|
|
|
# RoPE portion must be exact
|
|
rope_diff = (recovered[:, NOPE_DIM:] - compressed_kv[:, NOPE_DIM:]).abs().max()
|
|
assert rope_diff.item() == 0.0, f"RoPE diff {rope_diff.item()}"
|
|
|
|
# NoPE: relative error should be reasonable
|
|
for t in range(num_tokens):
|
|
orig = compressed_kv[t, :NOPE_DIM].float()
|
|
recv = recovered[t, :NOPE_DIM].float()
|
|
abs_diff = (recv - orig).abs().max().item()
|
|
magnitude = orig.abs().max().item()
|
|
if magnitude > 0.01:
|
|
rel_err = abs_diff / magnitude
|
|
assert rel_err < 0.15, (
|
|
f"Token {t}: rel_err={rel_err:.4f}, abs_diff={abs_diff:.6f}, "
|
|
f"magnitude={magnitude:.4f}"
|
|
)
|
|
|
|
|
|
# ── Test E: Indexer fused K-cache insert (Triton kernels) ────────────────────
|
|
#
|
|
# Both kernels share the same Triton signature; use_fp4 selects between them.
|
|
# Full pipeline: state-cache gather → softmax-weighted compress → RMSNorm →
|
|
# GPT-J RoPE → quant (MXFP4 or FP8) → paged cache insert.
|
|
|
|
|
|
def _reference_kv_compress_norm_rope(
|
|
state_cache: torch.Tensor,
|
|
block_table: torch.Tensor,
|
|
positions: torch.Tensor,
|
|
rms_weight: torch.Tensor,
|
|
cos_sin_cache: torch.Tensor,
|
|
compress_ratio: int = 1,
|
|
overlap: int = 0,
|
|
use_fp4: bool = False,
|
|
rms_eps: float = 1e-6,
|
|
fp8_max: float = 448.0,
|
|
return_full_cache: bool = False,
|
|
):
|
|
"""Compress → RMSNorm → GPT-J RoPE → quantize.
|
|
|
|
Gathers (1+overlap)*compress_ratio state entries per output token, applies
|
|
per-element softmax over the scores, and computes the weighted kv sum.
|
|
Returns (quantized_values, scale) matching the kernel's output layout.
|
|
"""
|
|
device = state_cache.device
|
|
head_dim = rms_weight.shape[0]
|
|
rope_dim = cos_sin_cache.shape[-1]
|
|
state_block_size = state_cache.shape[1]
|
|
state_width = state_cache.shape[-1] // 2
|
|
nope_dim = head_dim - rope_dim
|
|
total = (1 + overlap) * compress_ratio
|
|
results = []
|
|
for pos in positions.tolist():
|
|
src = torch.arange(pos - total + 1, pos + 1, dtype=torch.int64, device=device)
|
|
valid = src >= 0
|
|
idx = src.clamp(min=0)
|
|
pages = block_table[0, idx // state_block_size]
|
|
offsets = idx % state_block_size
|
|
raw = state_cache[pages, offsets].float() # [total, state_dim]
|
|
|
|
# Group 0 (tokens 0..cr-1): kv[:H], score[SW:SW+H]
|
|
# Group 1 (tokens cr..2cr-1): kv[H:2H], score[SW+H:SW+2H]
|
|
if overlap:
|
|
sw = state_width
|
|
g0_kv = raw[:compress_ratio, :head_dim]
|
|
g1_kv = raw[compress_ratio:, head_dim : 2 * head_dim]
|
|
g0_scores = raw[:compress_ratio, sw : sw + head_dim]
|
|
g1_scores = raw[compress_ratio:, sw + head_dim : sw + 2 * head_dim]
|
|
kv = torch.cat([g0_kv, g1_kv])
|
|
scores = torch.cat([g0_scores, g1_scores])
|
|
else:
|
|
kv = raw[:, :head_dim]
|
|
scores = raw[:, state_width : state_width + head_dim]
|
|
|
|
scores[~valid] = float("-inf")
|
|
kv[~valid] = 0.0
|
|
weights = torch.softmax(scores, dim=0)
|
|
compressed = (kv * weights).sum(dim=0) # [H]
|
|
var = (compressed * compressed).mean()
|
|
normed = compressed * torch.rsqrt(var + rms_eps) * rms_weight.float()
|
|
compressed_pos = (pos // compress_ratio) * compress_ratio
|
|
cos, sin = cos_sin_cache[compressed_pos].float().chunk(2)
|
|
nope, rope = normed.split([nope_dim, rope_dim])
|
|
rope = torch.stack(
|
|
[rope[0::2] * cos - rope[1::2] * sin, rope[1::2] * cos + rope[0::2] * sin],
|
|
dim=-1,
|
|
).reshape(rope_dim)
|
|
results.append(torch.cat([nope, rope]).to(state_cache.dtype))
|
|
result = torch.stack(results)
|
|
|
|
if return_full_cache:
|
|
# Contiguous 512-wide bf16 row (nope unrotated + rope rotated), matching
|
|
# the FlashInfer full-cache layout before any per-tensor fp8 quant. The
|
|
# kernel rounds the fp32 result to bf16 once at the store.
|
|
return result.to(torch.bfloat16)
|
|
|
|
if use_fp4:
|
|
return quantize_to_mxfp4(result)
|
|
else:
|
|
pairs = [
|
|
_ue8m0_reference(result[t], head_dim, fp8_max) for t in range(len(result))
|
|
]
|
|
quants, scales = zip(*pairs)
|
|
return torch.stack(quants), torch.cat(scales)
|
|
|
|
|
|
@pytest.mark.parametrize("num_tokens", [1, 7, 32])
|
|
@pytest.mark.parametrize("kv_block_size", [16, 32])
|
|
@pytest.mark.parametrize("use_fp4", [False, True])
|
|
def test_fused_kv_insert_indexer(num_tokens: int, kv_block_size: int, use_fp4: bool):
|
|
"""Fused K compress+norm+rope+quant+insert for the indexer KV cache."""
|
|
HEAD_DIM = 128
|
|
ROPE_DIM = 64
|
|
BLOCK_SIZE = 16
|
|
RMS_EPS = 1e-6
|
|
FP8_MAX = 448.0
|
|
|
|
device = "cuda"
|
|
torch.manual_seed(42)
|
|
compress_ratio = 4
|
|
|
|
if use_fp4:
|
|
TOKEN_STRIDE = HEAD_DIM // 2 # packed nibbles: 64 bytes
|
|
SCALE_DIM = HEAD_DIM // 32 # ue8m0 bytes: 4
|
|
QUANT_BLOCK = 32
|
|
kernel = _fused_kv_compress_norm_rope_insert_indexer_mxfp4_attn
|
|
else:
|
|
TOKEN_STRIDE = HEAD_DIM # FP8 bytes: 128
|
|
SCALE_DIM = 4 # 1 float32: 4 bytes
|
|
QUANT_BLOCK = HEAD_DIM
|
|
kernel = _fused_kv_compress_norm_rope_insert_indexer_attn
|
|
|
|
# overlap=1 whenever compress_ratio==4, matching DeepseekCompressor logic.
|
|
overlap = 1 if compress_ratio == 4 else 0
|
|
coff = 1 + overlap # multiplier for state_dim per entry
|
|
|
|
num_pages = (compress_ratio * num_tokens - 1) // BLOCK_SIZE + 2
|
|
state_cache = torch.randn(
|
|
num_pages,
|
|
BLOCK_SIZE,
|
|
2 * coff * HEAD_DIM, # kv_state + score_state, each coff*HEAD_DIM wide
|
|
dtype=torch.bfloat16,
|
|
device=device,
|
|
)
|
|
block_table = torch.arange(num_pages, dtype=torch.int32, device=device).unsqueeze(0)
|
|
token_to_req = torch.zeros(num_tokens, dtype=torch.int32, device=device)
|
|
slot_mapping = torch.arange(num_tokens, dtype=torch.int64, device=device)
|
|
positions = torch.arange(
|
|
compress_ratio - 1,
|
|
compress_ratio * num_tokens,
|
|
compress_ratio,
|
|
dtype=torch.int64,
|
|
device=device,
|
|
)
|
|
rms_weight = torch.randn(HEAD_DIM, dtype=torch.bfloat16, device=device)
|
|
cos_sin_cache = torch.randn(compress_ratio * num_tokens, ROPE_DIM, device=device)
|
|
|
|
kv_n_blocks = (num_tokens + kv_block_size - 1) // kv_block_size + 1
|
|
kv_cache = torch.zeros(
|
|
kv_n_blocks,
|
|
kv_block_size * (TOKEN_STRIDE + SCALE_DIM),
|
|
dtype=torch.uint8,
|
|
device=device,
|
|
)
|
|
|
|
kernel[(num_tokens,)](
|
|
state_cache,
|
|
state_cache.stride(0),
|
|
state_cache.stride(1),
|
|
token_to_req,
|
|
positions,
|
|
slot_mapping,
|
|
block_table,
|
|
block_table.stride(0),
|
|
BLOCK_SIZE,
|
|
rms_weight,
|
|
RMS_EPS,
|
|
cos_sin_cache,
|
|
cos_sin_cache.stride(0),
|
|
kv_cache,
|
|
slot_mapping,
|
|
kv_block_size,
|
|
HEAD_SIZE=HEAD_DIM,
|
|
TRITON_BLOCK_SIZE=HEAD_DIM,
|
|
STATE_WIDTH=coff * HEAD_DIM,
|
|
COMPRESS_RATIO=compress_ratio,
|
|
OVERLAP=overlap,
|
|
ROPE_HEAD_DIM=ROPE_DIM,
|
|
FP8_MAX=FP8_MAX,
|
|
QUANT_BLOCK=QUANT_BLOCK,
|
|
TOKEN_STRIDE=TOKEN_STRIDE,
|
|
SCALE_DIM=SCALE_DIM,
|
|
KV_BLOCK_STRIDE=kv_cache.stride(0),
|
|
num_warps=1,
|
|
)
|
|
|
|
k_quant, scale = _reference_kv_compress_norm_rope(
|
|
state_cache,
|
|
block_table,
|
|
positions,
|
|
rms_weight,
|
|
cos_sin_cache,
|
|
compress_ratio,
|
|
overlap,
|
|
use_fp4,
|
|
rms_eps=RMS_EPS,
|
|
fp8_max=FP8_MAX,
|
|
)
|
|
|
|
if use_fp4:
|
|
for i in range(num_tokens):
|
|
blk, pos = i // kv_block_size, i % kv_block_size
|
|
val_off = pos * TOKEN_STRIDE
|
|
fp4_actual = kv_cache[blk, val_off : val_off + TOKEN_STRIDE]
|
|
assert torch.equal(k_quant[i], fp4_actual), (
|
|
f"token {i}: packed nibbles differ, "
|
|
f"{(k_quant[i] != fp4_actual).sum()} "
|
|
f"/ {TOKEN_STRIDE}"
|
|
)
|
|
|
|
scale_off = kv_block_size * TOKEN_STRIDE + pos * SCALE_DIM
|
|
scale_actual = kv_cache[blk, scale_off : scale_off + SCALE_DIM]
|
|
assert torch.equal(scale_actual, scale[i]), (
|
|
f"token {i}: ue8m0 {scale_actual.tolist()} != {scale[i].tolist()}"
|
|
)
|
|
|
|
else:
|
|
k_quant = k_quant.view(torch.uint8)
|
|
for i in range(num_tokens):
|
|
blk, pos = i // kv_block_size, i % kv_block_size
|
|
val_off = pos * TOKEN_STRIDE
|
|
assert torch.equal(
|
|
k_quant[i], kv_cache[blk, val_off : val_off + TOKEN_STRIDE]
|
|
), f"token {i}: FP8 bytes differ"
|
|
|
|
scale_off = kv_block_size * TOKEN_STRIDE + pos * SCALE_DIM
|
|
actual_scale = kv_cache[blk, scale_off : scale_off + SCALE_DIM].view(
|
|
torch.float32
|
|
)
|
|
assert torch.equal(actual_scale, scale[i : i + 1]), (
|
|
f"token {i}: scale {actual_scale.item()} != {scale[i].item()}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("compress_ratio", [4, 128])
|
|
@pytest.mark.parametrize("store_fp8", [False, True])
|
|
def test_cutedsl_full_cache_store(compress_ratio: int, store_fp8: bool):
|
|
"""CuTeDSL compressor full-cache (FlashInfer) store parity for head=512.
|
|
|
|
Exercises the contiguous bf16 / per-tensor fp8 store branch of both the C4
|
|
fused kernel and the C128 split kernel against the PyTorch reference.
|
|
"""
|
|
cutedsl = pytest.importorskip("cutlass") # noqa: F841
|
|
from vllm.models.deepseek_v4.nvidia.ops.sparse_attn_compress_cutedsl import (
|
|
fused_kv_compress_norm_rope_insert_sparse_attn_cutedsl,
|
|
split_kv_compress_norm_rope_insert_sparse_attn_cutedsl,
|
|
)
|
|
|
|
HEAD_DIM = 512
|
|
ROPE_DIM = 64
|
|
RMS_EPS = 1e-6
|
|
FP8_MAX = 448.0
|
|
# C128 compress (Block8 kernel) requires state-cache block_size=8; C4 uses 16.
|
|
BLOCK_SIZE = 8 if compress_ratio == 128 else 16
|
|
KV_BLOCK_SIZE = 64
|
|
device = "cuda"
|
|
torch.manual_seed(7)
|
|
|
|
overlap = 1 if compress_ratio == 4 else 0
|
|
coff = 1 + overlap
|
|
num_tokens = 8
|
|
|
|
num_pages = (compress_ratio * num_tokens - 1) // BLOCK_SIZE + 2
|
|
# The production CompressorStateCache is fp32.
|
|
state_cache = torch.randn(
|
|
num_pages, BLOCK_SIZE, 2 * coff * HEAD_DIM, dtype=torch.float32, device=device
|
|
)
|
|
block_table = torch.arange(num_pages, dtype=torch.int32, device=device).unsqueeze(0)
|
|
token_to_req = torch.zeros(num_tokens, dtype=torch.int32, device=device)
|
|
slot_mapping = torch.arange(num_tokens, dtype=torch.int64, device=device)
|
|
positions = torch.arange(
|
|
compress_ratio - 1,
|
|
compress_ratio * num_tokens,
|
|
compress_ratio,
|
|
dtype=torch.int64,
|
|
device=device,
|
|
)
|
|
rms_weight = torch.randn(HEAD_DIM, dtype=torch.bfloat16, device=device)
|
|
cos_sin_cache = torch.randn(
|
|
compress_ratio * num_tokens, ROPE_DIM, dtype=torch.float32, device=device
|
|
)
|
|
|
|
dtype = torch.float8_e4m3fn if store_fp8 else torch.bfloat16
|
|
kv_n_blocks = (num_tokens + KV_BLOCK_SIZE - 1) // KV_BLOCK_SIZE + 1
|
|
k_cache = torch.zeros(
|
|
kv_n_blocks, KV_BLOCK_SIZE, HEAD_DIM, dtype=dtype, device=device
|
|
)
|
|
fp8_scale = torch.tensor(
|
|
[0.5 if store_fp8 else 1.0], dtype=torch.float32, device=device
|
|
)
|
|
|
|
if compress_ratio == 4:
|
|
fused_kv_compress_norm_rope_insert_sparse_attn_cutedsl(
|
|
state_cache,
|
|
token_to_req,
|
|
positions,
|
|
slot_mapping,
|
|
block_table,
|
|
BLOCK_SIZE,
|
|
rms_weight,
|
|
RMS_EPS,
|
|
cos_sin_cache,
|
|
k_cache,
|
|
slot_mapping,
|
|
KV_BLOCK_SIZE,
|
|
k_cache.stride(0),
|
|
head_size=HEAD_DIM,
|
|
state_width=coff * HEAD_DIM,
|
|
rope_head_dim=ROPE_DIM,
|
|
fp8_max=FP8_MAX,
|
|
quant_block=64,
|
|
token_stride=576,
|
|
scale_dim=8,
|
|
compress_ratio=compress_ratio,
|
|
overlap=True,
|
|
store_full_kv=True,
|
|
store_full_fp8=store_fp8,
|
|
fp8_scale=fp8_scale,
|
|
)
|
|
else:
|
|
compressed_kv = torch.empty(
|
|
(num_tokens, HEAD_DIM), dtype=torch.float32, device=device
|
|
)
|
|
split_kv_compress_norm_rope_insert_sparse_attn_cutedsl(
|
|
state_cache,
|
|
token_to_req,
|
|
positions,
|
|
slot_mapping,
|
|
block_table,
|
|
BLOCK_SIZE,
|
|
compressed_kv,
|
|
rms_weight,
|
|
RMS_EPS,
|
|
cos_sin_cache,
|
|
k_cache,
|
|
slot_mapping,
|
|
KV_BLOCK_SIZE,
|
|
k_cache.stride(0),
|
|
head_size=HEAD_DIM,
|
|
state_width=coff * HEAD_DIM,
|
|
rope_head_dim=ROPE_DIM,
|
|
fp8_max=FP8_MAX,
|
|
quant_block=64,
|
|
token_stride=576,
|
|
scale_dim=8,
|
|
compress_ratio=compress_ratio,
|
|
overlap=bool(overlap),
|
|
store_full_kv=True,
|
|
store_full_fp8=store_fp8,
|
|
fp8_scale=fp8_scale,
|
|
)
|
|
|
|
ref = _reference_kv_compress_norm_rope(
|
|
state_cache,
|
|
block_table,
|
|
positions,
|
|
rms_weight,
|
|
cos_sin_cache,
|
|
compress_ratio,
|
|
overlap,
|
|
rms_eps=RMS_EPS,
|
|
return_full_cache=True,
|
|
) # [num_tokens, HEAD_DIM] bf16
|
|
|
|
actual = torch.stack(
|
|
[k_cache[i // KV_BLOCK_SIZE, i % KV_BLOCK_SIZE] for i in range(num_tokens)]
|
|
)
|
|
if store_fp8:
|
|
ref_fp8 = torch.clamp(ref.float() / fp8_scale, -FP8_MAX, FP8_MAX).to(
|
|
torch.float8_e4m3fn
|
|
)
|
|
torch.testing.assert_close(actual.float(), ref_fp8.float(), rtol=0.0, atol=0.3)
|
|
else:
|
|
torch.testing.assert_close(actual.float(), ref.float(), rtol=3e-2, atol=3e-2)
|