Files
vLLM/tests/kernels/attention/test_rocm_paged_mxfp4_indexer.py
T
2026-10-12 23:24:43 -07:00

601 lines
22 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""DeepSeek V4.1's ROCm MXFP4 sparse indexer against a torch reference.
The indexer K cache is a view into vLLM's block-major KV pool: its pages are
a block stride apart with other layers' pages in between, so the tests put
it there. The reference reads the keys back from a second cache the same
writer fills in natural order, so it shares none of the preshuffled
addressing it checks.
"""
import itertools
import types
import pytest
import torch
from vllm.platforms import current_platform
if not current_platform.is_rocm():
pytest.skip("ROCm-only", allow_module_level=True)
from vllm.config import CUDAGraphMode
from vllm.models.deepseek_v41.amd.rocm import (
rocm_mxfp4_indexer_k_store,
rocm_mxfp4_indexer_q_quant,
)
from vllm.platforms.rocm import get_cdna_version
from vllm.utils.math_utils import cdiv
from vllm.utils.torch_utils import set_random_seed
from vllm.v1.attention.backends.mla.indexer import (
DeepseekV32IndexerPrefillChunkMetadata,
)
from vllm.v1.attention.backends.mla.rocm_paged_mxfp4_indexer import (
RocmMxfp4IndexerMetadata,
native_decode,
plan_gather_launches,
plan_prefill_chunks,
)
from vllm.v1.attention.ops import rocm_paged_mxfp4_indexer as ops
from vllm.v1.worker.workspace import init_workspace_manager, reset_workspace_manager
if get_cdna_version() != 4:
pytest.skip("CDNA4-only", allow_module_level=True)
HEADS, HEAD_DIM = 32, 128
WIDTH = HEAD_DIM // 2 + HEAD_DIM // 32
# V4.1's 8-token blocks, with the pool (2048 blocks) and top-k (512) scaled
# down so short test contexts still select.
CAND_BLOCK, POOL_BLOCKS, TOPK = 8, 16, 32
MAX_LEN = 4096
DEVICE = "cuda"
_E2M1 = torch.tensor([0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0])
E2M1 = torch.cat([_E2M1, -_E2M1])
def _dequant(packed, e8m0):
"""[..., D/2] e2m1 pairs and [..., D/32] e8m0 to [..., D] fp32."""
table = E2M1.to(packed.device)
vals = torch.stack([table[(packed & 0xF).long()], table[(packed >> 4).long()]], -1)
scale = torch.pow(2.0, e8m0.float() - 127)
return (vals.flatten(-2).unflatten(-1, (-1, 32)) * scale[..., None]).flatten(-2)
@pytest.fixture(autouse=True)
def _workspace():
init_workspace_manager(torch.device(DEVICE))
yield
reset_workspace_manager()
class _Case:
"""Requests with their keys written into a block-major pool (ratio-2 and
ratio-1 pages side by side in every block) and into natural-order caches
the reference reads. The bf16 inputs are kept for the end-to-end test."""
def __init__(self, seq_lens, block):
from aiter.ops.triton.fusions.k_norm_rope_mxfp4_cache import (
k_norm_rope_mxfp4_cache,
)
self.seq_lens = seq_lens
num_blocks = sum(cdiv(n, block) for n in seq_lens) + 3
perm = torch.randperm(num_blocks)
width = max(cdiv(n, block) for n in seq_lens)
self.block_table = torch.zeros(len(seq_lens), width, dtype=torch.int32)
used = 0
for req, n in enumerate(seq_lens):
self.block_table[req, : cdiv(n, block)] = perm[used : used + cdiv(n, block)]
used += cdiv(n, block)
self.block_table = self.block_table.to(DEVICE)
def align(n):
return cdiv(n, 576) * 576
page2, page1 = block // 2 * WIDTH, block * WIDTH
stride = align(page2) + align(page1) + 576
self.pool = torch.zeros(num_blocks * stride, dtype=torch.uint8, device=DEVICE)
self.page_bytes = torch.zeros_like(self.pool, dtype=torch.bool)
for i in range(num_blocks):
base = i * stride
self.page_bytes[base : base + page2] = True
base += align(page2)
self.page_bytes[base : base + page1] = True
self.cache = {
2: torch.as_strided(
self.pool, (num_blocks, block // 2, WIDTH), (stride, WIDTH, 1), 0
),
1: torch.as_strided(
self.pool,
(num_blocks, block, WIDTH),
(stride, WIDTH, 1),
align(page2),
),
}
self.offsets = [0]
for n in seq_lens:
self.offsets.append(self.offsets[-1] + n)
# the same keys in natural order, one entry per page
self.natural = {
r: torch.zeros(self.offsets[-1], 1, WIDTH, dtype=torch.uint8, device=DEVICE)
for r in (1, 2)
}
self.cos_sin = cos_sin = torch.randn(MAX_LEN, 64, device=DEVICE)
self.norm = norm = torch.rand(HEAD_DIM, device=DEVICE) + 0.5
self.k_pre = {}
for req, n in enumerate(seq_lens):
if n == 0:
continue
k_pre = torch.randn(n, HEAD_DIM, device=DEVICE).to(torch.bfloat16)
self.k_pre[req] = k_pre
pos = torch.arange(n, device=DEVICE)
for ratio, cache in self.cache.items():
comp = pos // ratio
boundary = (pos + 1) % ratio == 0
entries = cache.shape[1]
page = self.block_table[req, comp // entries].long()
slot = torch.where(boundary, page * entries + comp % entries, -1)
nat = torch.where(boundary, self.offsets[req] + comp, -1)
rocm_mxfp4_indexer_k_store(
k_pre,
pos,
cos_sin,
norm,
1e-6,
cache,
slot,
ratio,
True,
num_heads=HEADS,
)
k_norm_rope_mxfp4_cache(
k_pre, pos, cos_sin, norm, 1e-6, self.natural[ratio], nat, ratio
)
def keys(self, ratio, req, n):
nat = self.natural[ratio][self.offsets[req] : self.offsets[req] + n, 0]
return _dequant(nat[:, : HEAD_DIM // 2], nat[:, HEAD_DIM // 2 :])
def _queries(num_rows):
q = torch.randint(0, 256, (num_rows, HEADS, HEAD_DIM // 2), dtype=torch.uint8)
scales = torch.randint(122, 130, (num_rows, HEADS, HEAD_DIM // 32))
q, scales = q.to(DEVICE), scales.to(torch.uint8).to(DEVICE)
weights = torch.randn(num_rows, HEADS, device=DEVICE)
# the fused Q kernel returns the scales as one int32 per head
return q, scales.view(torch.int32).squeeze(-1), weights, _dequant(q, scales)
def _scores(q, w, keys):
return (torch.relu(q @ keys.T) * w[:, None]).sum(0)
def _assert_topk(row, scores, k):
"""``row`` is a top-k of the entries of ``scores`` above -inf, up to the
fp32 reduce order."""
valid = scores > float("-inf")
want = min(k, int(valid.sum()))
sel = row[row >= 0].long()
assert sel.numel() == want and sel.unique().numel() == want
if want:
assert bool(valid[sel].all())
kth = scores[valid].topk(want).values[-1]
finite = scores[torch.isfinite(scores)]
tol = 1e-4 * float(finite.abs().max()) if finite.numel() else 0.0
assert bool((scores[sel] >= kth - tol).all())
def _block_maxima(scores):
end = scores.numel()
if end == 0:
return scores
padded = torch.full((cdiv(end, CAND_BLOCK) * CAND_BLOCK,), float("-inf"))
padded[:end] = scores.cpu()
out = padded.view(-1, CAND_BLOCK).amax(1)
out[(end - 1) // CAND_BLOCK] = float("inf")
return out.to(scores.device)
def _forward_context(monkeypatch, metadata):
context = types.SimpleNamespace(
attn_metadata={"indexer": metadata},
cudagraph_runtime_mode=CUDAGraphMode.NONE,
)
monkeypatch.setattr(ops, "get_forward_context", lambda: context)
def _decode_metadata(case, rows, ratio, query_lens):
lens = torch.tensor([(p + 1) // ratio for _, p in rows], dtype=torch.int32)
lens = lens.to(DEVICE)
block_table = case.block_table[[req for req, _ in rows]].contiguous()
entries = case.cache[ratio].shape[1]
next_n = max(query_lens)
context_lens = (torch.tensor(case.seq_lens, dtype=torch.int32) // ratio).to(DEVICE)
native = native_decode(lens, block_table, query_lens, next_n, context_lens)
schedule = torch.empty(
ops.rocm_mxfp4_decode_schedule_words(HEADS, HEAD_DIM, entries, next_n),
dtype=torch.int32,
device=DEVICE,
)
return RocmMxfp4IndexerMetadata(
seq_lens=None,
max_seq_len=max(case.seq_lens),
slot_mapping=None,
num_decodes=len(case.seq_lens),
num_decode_tokens=len(rows),
num_prefills=0,
num_prefill_tokens=0,
decode=types.SimpleNamespace(block_table=block_table, seq_lens=lens[:, None]),
decode_row_lens=lens,
decode_block_ends=(lens + CAND_BLOCK - 1) // CAND_BLOCK,
decode_native=native,
decode_schedule=ops.build_rocm_mxfp4_decode_schedule(
lens, HEADS, HEAD_DIM, entries, schedule, MAX_LEN // ratio, native
),
)
def _prefill_metadata(
case, rows, ratio, chunk_bounds, query_start_loc, gather_rows, gather
):
chunks = []
for req_lo, req_hi, t0, t1 in chunk_bounds:
ends = torch.tensor(
[(rows[t][1] + 1) // ratio for t in range(t0, t1)], dtype=torch.int32
).to(DEVICE)
chunks.append(
DeepseekV32IndexerPrefillChunkMetadata(
block_table=case.block_table[req_lo:req_hi],
cu_seqlen_ks=torch.zeros_like(ends),
cu_seqlen_ke=ends,
cu_seq_lens=torch.zeros(1, dtype=torch.int32, device=DEVICE),
token_to_seq=torch.zeros(1, dtype=torch.int32, device=DEVICE),
total_seq_lens=1,
max_seqlen_k=int(ends.max()),
token_start=t0,
token_end=t1,
num_reqs=req_hi - req_lo,
)
)
seq_lens = torch.tensor(case.seq_lens)
context_lens = (seq_lens // ratio).int().to(DEVICE)
plans = plan_prefill_chunks(
chunks,
query_start_loc,
seq_lens,
context_lens,
ratio,
CAND_BLOCK,
0.0 if gather else None,
torch.tensor(query_start_loc, dtype=torch.int32, device=DEVICE),
)
return RocmMxfp4IndexerMetadata(
seq_lens=None,
max_seq_len=max(case.seq_lens),
slot_mapping=None,
num_decodes=0,
num_decode_tokens=0,
num_prefills=len(case.seq_lens),
num_prefill_tokens=len(rows),
prefill=types.SimpleNamespace(chunks=chunks),
prefill_plans=plans,
gather_launches=plan_gather_launches(chunks, plans, gather_rows),
)
# The kernel prefers 128-token pages.
BLOCKS = pytest.mark.parametrize("block", [128, 64])
@BLOCKS
def test_k_store_stays_inside_its_pages(block):
"""The writer touches only its pages of the block-major pool. What they hold
is checked by the layer tests, which read them back through aiter's kernel."""
set_random_seed(0)
case = _Case([700, 333], block)
assert int(case.pool[~case.page_bytes].count_nonzero()) == 0
@pytest.mark.parametrize(
"rows, width, k",
# vLLM's kernel below 128 rows, aiter's from 128 rows and on every k=2048
[(8, 4096, 512), (128, 4096, 512), (8, 16384, 2048)],
)
def test_topk_matches_torch_on_both_kernels(rows, width, k):
set_random_seed(0)
logits = torch.randn(rows, width, device=DEVICE)
lengths = torch.randint(1, width + 1, (rows,), dtype=torch.int32, device=DEVICE)
lengths[0] = k // 2 # shorter than k, so the rest of the row pads with -1
out = torch.full((rows, k), 7, dtype=torch.int32, device=DEVICE)
ops._topk(logits, lengths, out, k)
for r in range(rows):
n = min(k, int(lengths[r]))
want = torch.topk(logits[r, : int(lengths[r])], n).indices.sort().values
got = out[r]
assert torch.equal(got[got >= 0].sort().values, want.int())
assert int((got == -1).sum()) == k - n
def _run_layers(monkeypatch, case, rows, metadata):
"""Ratio-2 dense layer, the candidate source, and a consumer both ways;
each checked against the torch reference."""
num_rows = len(rows)
q, q_scale, weights, q_ref = _queries(num_rows)
hidden = torch.empty(num_rows, 1, device=DEVICE)
def topk_buffer():
return torch.full((num_rows + 2, TOPK), 7, dtype=torch.int32, device=DEVICE)
_forward_context(monkeypatch, metadata(2))
out = topk_buffer()
ops.rocm_mxfp4_sparse_attn_indexer(
hidden,
"indexer",
case.cache[2],
q,
q_scale,
weights,
TOPK,
HEAD_DIM,
MAX_LEN // 2,
out,
compress_ratio=2,
)
for i, (req, pos) in enumerate(rows):
end = (pos + 1) // 2
keys = case.keys(2, req, end)
_assert_topk(out[i], _scores(q_ref[i], weights[i], keys), TOPK)
source = metadata(1)
_forward_context(monkeypatch, source)
pool = torch.full((num_rows + 2, POOL_BLOCKS), 5, dtype=torch.int32, device=DEVICE)
out = topk_buffer()
ops.rocm_mxfp4_sparse_attn_indexer(
hidden,
"indexer",
case.cache[1],
q,
q_scale,
weights,
TOPK,
HEAD_DIM,
MAX_LEN,
out,
compress_ratio=1,
candidate_blocks=pool,
candidate_block_size=CAND_BLOCK,
candidate_write=True,
)
allowed = []
for i, (req, pos) in enumerate(rows):
scores = _scores(q_ref[i], weights[i], case.keys(1, req, pos + 1))
_assert_topk(out[i], scores, TOPK)
_assert_topk(pool[i], _block_maxima(scores), POOL_BLOCKS)
mask = torch.zeros_like(scores, dtype=torch.bool)
for block in pool[i][pool[i] >= 0].tolist():
mask[block * CAND_BLOCK : (block + 1) * CAND_BLOCK] = True
allowed.append(torch.where(mask, scores, float("-inf")))
pa = ops._aiter()
build = pa.build_candidate_gather
builds = []
def counting_build(*args, **kwargs):
builds.append(1)
return build(*args, **kwargs)
monkeypatch.setattr(pa, "build_candidate_gather", counting_build)
for gather in (True, False):
consumer = metadata(1, gather)
consumer.decode_use_gather = gather
_forward_context(monkeypatch, consumer)
builds.clear()
# Two consumer layers of one step: the second reuses the first's pools.
for _ in range(2):
out = topk_buffer()
ops.rocm_mxfp4_sparse_mqa_indexer(
hidden,
"indexer",
case.cache[1],
q,
q_scale,
weights,
TOPK,
HEAD_DIM,
MAX_LEN,
out,
1,
pool,
CAND_BLOCK,
POOL_BLOCKS * CAND_BLOCK,
)
for i in range(num_rows):
_assert_topk(out[i], allowed[i], TOPK)
if gather:
launches = len(consumer.gather_launches) + (consumer.decode is not None)
assert len(builds) == launches
@BLOCKS
@pytest.mark.parametrize(
"query_lens",
# Uniform steps launch the dense layers on next_n-row sequences, also with
# cudagraph padding (query length 0) after them; a ragged step keeps a row
# per token.
[[6, 6, 6, 6], [2, 2, 2], [2, 2, 2, 0], [2, 1, 2]],
ids=["native6", "native2", "padded", "ragged"],
)
def test_decode_layers_match_reference(monkeypatch, block, query_lens):
set_random_seed(0)
next_n = max(query_lens)
case = _Case([q and n for q, n in zip(query_lens, [900, 333, 610, 1200])], block)
# a padding request has next_n rows of its own and no context
rows = [
(req, n - q + j if q else -1)
for req, (n, q) in enumerate(zip(case.seq_lens, query_lens))
for j in range(q or next_n)
]
_run_layers(
monkeypatch,
case,
rows,
lambda r, gather=False: _decode_metadata(case, rows, r, query_lens),
)
@pytest.mark.parametrize(
"seq_lens,new_tokens,chunks",
[
# one request past a cached prefix, then one sliced into two chunks
([760, 500], [300, 500], [(0, 1, 0, 300), (1, 2, 300, 560), (1, 2, 560, 800)]),
# three requests in one chunk, the first two with equal rows
([600, 450, 900], [200, 200, 120], [(0, 3, 0, 520)]),
# four requests with four different query lengths in one chunk
([900, 600, 450, 700], [150, 90, 200, 60], [(0, 4, 0, 500)]),
],
ids=["sliced", "batched", "ragged"],
)
# The consumers rejoin a request's rows across chunks, then cut them at most
# gather_rows a launch.
@pytest.mark.parametrize("gather_rows", [1 << 20, 128], ids=["joined", "cut"])
@BLOCKS
def test_prefill_layers_match_reference(
monkeypatch, seq_lens, new_tokens, chunks, gather_rows, block
):
set_random_seed(0)
case = _Case(seq_lens, block)
rows = [
(req, n - q + i)
for req, (n, q) in enumerate(zip(seq_lens, new_tokens))
for i in range(q)
]
query_start_loc = [0, *itertools.accumulate(new_tokens)]
_run_layers(
monkeypatch,
case,
rows,
lambda r, gather=True: _prefill_metadata(
case, rows, r, chunks, query_start_loc, gather_rows, gather
),
)
def _rope_gptj(x, cos_sin, positions):
"""GPT-J RoPE on the last 64 dims, as the indexer rotates Q and K: bf16 in
and out."""
x = x.bfloat16().float()
cos, sin = cos_sin[positions].chunk(2, dim=-1)
if x.dim() == 3:
cos, sin = cos[:, None], sin[:, None]
rot = x[..., -64:]
even, odd = rot[..., 0::2].clone(), rot[..., 1::2].clone()
rot[..., 0::2] = even * cos - odd * sin
rot[..., 1::2] = odd * cos + even * sin
return x.bfloat16()
def _mxfp4_round_trip(x):
"""MXFP4 round trip of x, rounded as the writers do: a scale of
2^ceil(log2(amax / 6)) per 32 values, then the nearest e2m1 value, ties to
even."""
blocks = x.float().unflatten(-1, (-1, 32))
amax = blocks.abs().amax(-1, keepdim=True).clamp(min=2**-126)
scale = torch.exp2(torch.ceil(torch.log2(amax / 6)))
y = (blocks / scale).clamp(-6, 6)
# Even codes first: argmin keeps the first of two equal distances.
grid = torch.cat([_E2M1[0::2], _E2M1[1::2]]).to(x.device)
idx = (y.abs()[..., None] - grid).abs().argmin(-1)
return (grid[idx] * y.sign() * scale).flatten(-2)
def _torch_keys(case, ratio, req):
"""A request's compressed keys in torch: k_norm of each group's last
token, rotated at the group's first position, through MXFP4."""
k = case.k_pre[req][ratio - 1 :: ratio].float()
k = k * torch.rsqrt(k.pow(2).mean(-1, keepdim=True) + 1e-6) * case.norm
pos = torch.arange(k.shape[0], device=DEVICE) * ratio
return _mxfp4_round_trip(_rope_gptj(k, case.cos_sin, pos))
def _topk_indices(q, weights, keys, ends, k):
"""Top-k of sum_h relu(q_h . key) * weights_h over each row's [0, end)."""
scores = (torch.einsum("thd,cd->thc", q, keys).relu() * weights[..., None]).sum(1)
cols = torch.arange(keys.shape[0], device=DEVICE)
return scores.masked_fill(cols >= ends[:, None], float("-inf")).topk(k).indices
def _recall(selected, reference):
"""Per row, the fraction of the reference top-k that ``selected`` picked."""
return (selected[:, :, None] == reference[:, None, :]).any(1).float().mean(1)
@BLOCKS
@pytest.mark.parametrize("ratio", [2, 1])
@pytest.mark.parametrize("step", ["decode", "prefill"])
def test_selection_from_bf16_matches_torch(monkeypatch, step, ratio, block):
"""End to end from bf16 K and Q: vLLM's MXFP4 K store and Q quant, the
paged pool, metadata from the planning functions the builder calls, and
the indexer op pick what the same MXFP4 math picks in torch."""
set_random_seed(0)
if step == "decode":
case = _Case([3000, 2600, 1900, 3500, 1200, 2200, 3900, 1600], block)
rows = [(req, n - 1) for req, n in enumerate(case.seq_lens)]
metadata = _decode_metadata(case, rows, ratio, [1] * len(rows))
else:
new_tokens = [300, 200, 250]
case = _Case([1500, 900, 2000], block)
rows = [
(req, n - q + i)
for req, (n, q) in enumerate(zip(case.seq_lens, new_tokens))
for i in range(q)
]
metadata = _prefill_metadata(
case,
rows,
ratio,
[(0, len(new_tokens), 0, len(rows))],
[0, *itertools.accumulate(new_tokens)],
1 << 20,
gather=False,
)
num_rows = len(rows)
q_bf16 = torch.randn(num_rows, HEADS, HEAD_DIM, device=DEVICE).to(torch.bfloat16)
w = torch.randn(num_rows, HEADS, device=DEVICE)
pos = torch.tensor([p for _, p in rows], device=DEVICE)
scales = (HEAD_DIM**-0.5, HEADS**-0.5)
(q, q_scale), weights = rocm_mxfp4_indexer_q_quant(
pos, q_bf16, case.cos_sin, w, *scales
)
_forward_context(monkeypatch, metadata)
out = torch.full((num_rows, TOPK), 7, dtype=torch.int32, device=DEVICE)
ops.rocm_mxfp4_sparse_attn_indexer(
torch.empty(num_rows, 1, device=DEVICE),
"indexer",
case.cache[ratio],
q,
q_scale,
weights,
TOPK,
HEAD_DIM,
MAX_LEN // ratio,
out,
compress_ratio=ratio,
)
ends = (pos + 1) // ratio
assert bool(((out >= 0) & (out < ends[:, None])).all())
assert all(row.unique().numel() == TOPK for row in out)
q_ref = _mxfp4_round_trip(_rope_gptj(q_bf16, case.cos_sin, pos))
w_ref = w * scales[0] * scales[1]
agree = []
for req in range(len(case.seq_lens)):
mine = torch.tensor([r == req for r, _ in rows], device=DEVICE)
ref = _topk_indices(
q_ref[mine], w_ref[mine], _torch_keys(case, ratio, req), ends[mine], TOPK
)
agree.append(_recall(out[mine], ref))
assert torch.cat(agree).mean().item() >= 0.99