Signed-off-by: Yongye Zhu <zyy1102000@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
601 lines
22 KiB
Python
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
|