Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Robert Shaw <114415538+robertgshaw2-redhat@users.noreply.github.com>
3826 lines
135 KiB
Python
3826 lines
135 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Unit tests for the sparse MLA backends and utilities."""
|
|
|
|
import math
|
|
from collections import deque
|
|
from types import MethodType, SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from tests.v1.attention.test_mla_backends import (
|
|
BATCH_SPECS,
|
|
BatchSpec,
|
|
MockSparseMLAAttentionLayer,
|
|
create_and_prepopulate_kv_cache,
|
|
)
|
|
from tests.v1.attention.utils import (
|
|
create_common_attn_metadata,
|
|
create_standard_kv_cache_spec,
|
|
create_vllm_config,
|
|
)
|
|
from vllm import _custom_ops as ops
|
|
from vllm.compilation.breakable_cudagraph import BreakableCUDAGraphCapture
|
|
from vllm.config import (
|
|
CUDAGraphMode,
|
|
HiSparseConfig,
|
|
SpeculativeConfig,
|
|
set_current_vllm_config,
|
|
)
|
|
from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.worker import (
|
|
HiSparseConnectorWorker,
|
|
)
|
|
from vllm.forward_context import set_forward_context
|
|
from vllm.model_executor.layers.attention import mla_attention
|
|
from vllm.model_executor.layers.attention.mla_attention import _use_masked_mha
|
|
from vllm.model_executor.layers.attention.sparse_mla_attention import (
|
|
GLOBAL_TOPK_MASK_MAX_BYTES,
|
|
SparseMLACommonImpl,
|
|
SparseMLAPrefillMetadata,
|
|
_is_masked_mha_available,
|
|
_masked_mha_workspace_fits,
|
|
_topk_mask_shape,
|
|
_use_dense_mha_prefill,
|
|
)
|
|
from vllm.model_executor.layers.linear import ColumnParallelLinear
|
|
from vllm.platforms import current_platform
|
|
|
|
# TODO: Integrate ROCMAiterMLASparseBackend for ROCm.
|
|
# The ROCm sparse MLA backend (rocm_aiter_mla_sparse.py) has a compatible
|
|
# forward_mqa interface but needs validation on ROCm hardware.
|
|
if not current_platform.is_cuda():
|
|
pytest.skip(
|
|
"Sparse MLA backend tests currently only support CUDA. "
|
|
"ROCm support requires integrating ROCMAiterMLASparseBackend.",
|
|
allow_module_level=True,
|
|
)
|
|
|
|
from vllm.model_executor.layers.attention.mla_attention import (
|
|
_canonicalize_sparse_mla_kv_cache_dtype,
|
|
)
|
|
from vllm.model_executor.layers.attention.sparse_mla_attention import (
|
|
SharedTopkIndicesBuffer,
|
|
)
|
|
from vllm.utils.math_utils import cdiv
|
|
from vllm.utils.torch_utils import current_stream
|
|
from vllm.v1.attention.backends.mla import index_group as index_group_module
|
|
from vllm.v1.attention.backends.mla.flashattn_mla_sparse import (
|
|
FlashAttnMLASparseImpl,
|
|
)
|
|
from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import (
|
|
FlashInferMLASparseImpl,
|
|
FlashInferMLASparseMetadataBuilder,
|
|
FlashInferMLASparseTRTLLMBackend,
|
|
)
|
|
from vllm.v1.attention.backends.mla.flashinfer_mla_sparse_sm120 import (
|
|
FlashInferMLASparseSM120Impl,
|
|
)
|
|
from vllm.v1.attention.backends.mla.flashmla_sparse import (
|
|
FlashMLASparseBackend,
|
|
FlashMLASparseImpl,
|
|
FlashMLASparseMetadata,
|
|
FlashMLASparseMetadataBuilder,
|
|
triton_convert_req_index_to_global_index,
|
|
)
|
|
from vllm.v1.attention.backends.mla.index_group import (
|
|
HiSparseMLAIndexGroup,
|
|
SparseMLAIndexGroupBuilder,
|
|
)
|
|
from vllm.v1.attention.backends.mla.indexer import (
|
|
DeepseekV32IndexerMetadataBuilder,
|
|
)
|
|
from vllm.v1.attention.backends.mla.prefill import get_mla_prefill_backend
|
|
from vllm.v1.attention.backends.utils import (
|
|
split_decodes_and_prefills,
|
|
split_prefill_chunks,
|
|
)
|
|
from vllm.v1.attention.ops import flashmla
|
|
from vllm.v1.hisparse import runtime as hisparse_runtime
|
|
from vllm.v1.hisparse.runtime import (
|
|
HiSparseCacheHandle,
|
|
HiSparseRuntime,
|
|
ResolvedHiSparseConfig,
|
|
_has_hisparse_ops,
|
|
build_hisparse_prefill_staging_plan,
|
|
hisparse_prefill_staging_remap,
|
|
)
|
|
from vllm.v1.hisparse.types import SparseKVRowMirror
|
|
|
|
SPARSE_BACKEND_BATCH_SPECS = {
|
|
name: BATCH_SPECS[name]
|
|
for name in [
|
|
"mixed_small",
|
|
"mixed_medium",
|
|
"small_prefill",
|
|
"medium_prefill",
|
|
"single_prefill",
|
|
]
|
|
}
|
|
|
|
SPARSE_BACKEND_BATCH_SPECS["large_q_prefill"] = BatchSpec(
|
|
seq_lens=[1024] * 2, query_lens=[256] * 2
|
|
)
|
|
SPARSE_BACKEND_BATCH_SPECS["large_q_pure_prefill"] = BatchSpec(
|
|
seq_lens=[256] * 2, query_lens=[256] * 2
|
|
)
|
|
|
|
DEVICE_TYPE = current_platform.device_type
|
|
|
|
|
|
def test_nope_flashinfer_sparse_mla_uses_model_scale(monkeypatch):
|
|
"""Weight absorption must not change the model's attention temperature."""
|
|
model_scale = 256**-0.5
|
|
kv_lora_rank = 512
|
|
topk = torch.zeros((1, 1), dtype=torch.int32)
|
|
metadata = SimpleNamespace(
|
|
req_id_per_token=torch.zeros(1, dtype=torch.int32),
|
|
block_table=torch.zeros((1, 1), dtype=torch.int32),
|
|
block_size=1,
|
|
)
|
|
recorded_scale = None
|
|
|
|
impl = object.__new__(FlashInferMLASparseImpl)
|
|
impl.scale = model_scale
|
|
impl.qk_nope_head_dim = 256
|
|
impl.kv_lora_rank = kv_lora_rank
|
|
impl.qk_rope_head_dim = 0
|
|
impl.kv_cache_dtype = "auto"
|
|
impl.topk_indices_buffer = topk
|
|
impl.dcp_world_size = 1
|
|
impl._workspace_buffer = torch.empty(1)
|
|
impl.bmm1_scale = None
|
|
impl.bmm2_scale = None
|
|
impl.is_nope_mla = True
|
|
impl.need_to_return_lse_for_decode = False
|
|
impl.index_group = MagicMock()
|
|
impl.index_group_index = 0
|
|
impl.index_group.convert_logical_to_physical_topk.return_value = (
|
|
topk,
|
|
torch.ones(1, dtype=torch.int32),
|
|
)
|
|
|
|
import flashinfer.decode
|
|
|
|
def fake_flashinfer(**kwargs):
|
|
nonlocal recorded_scale
|
|
recorded_scale = kwargs["bmm1_scale"]
|
|
return torch.zeros((1, 1, 1, kv_lora_rank))
|
|
|
|
monkeypatch.setattr(
|
|
flashinfer.decode,
|
|
"trtllm_batch_decode_with_kv_cache_mla",
|
|
fake_flashinfer,
|
|
)
|
|
impl.forward_mqa(
|
|
torch.zeros(1, 1, kv_lora_rank),
|
|
torch.zeros(1, 1, kv_lora_rank),
|
|
metadata,
|
|
SimpleNamespace(),
|
|
)
|
|
|
|
assert recorded_scale == model_scale
|
|
assert recorded_scale != kv_lora_rank**-0.5
|
|
|
|
|
|
def test_hisparse_routes_prefill_to_sparse_mqa():
|
|
layer = SimpleNamespace(hisparse_cache=object())
|
|
|
|
assert not mla_attention.MLAAttention._use_sparse_mha(layer, SimpleNamespace())
|
|
|
|
|
|
def test_hisparse_metadata_keeps_short_prefill_indexer_enabled():
|
|
config = SimpleNamespace(
|
|
attention_config=SimpleNamespace(
|
|
hisparse_config=object(), sparse_mla_force_mqa=False
|
|
)
|
|
)
|
|
|
|
assert not _use_dense_mha_prefill(config, prefill_max_seq_len=512, topk_tokens=2048)
|
|
|
|
|
|
def test_unified_mla_prepares_batch_without_slot_mapping(monkeypatch):
|
|
layer = SimpleNamespace(
|
|
prepare_kv_cache_update=MagicMock(),
|
|
update_kv_cache=MagicMock(),
|
|
)
|
|
metadata = object()
|
|
monkeypatch.setattr(
|
|
mla_attention,
|
|
"get_attention_context",
|
|
lambda _: (metadata, layer, torch.empty(0), None),
|
|
)
|
|
tensor = torch.empty(0, device=DEVICE_TYPE)
|
|
|
|
mla_attention.unified_mla_kv_cache_update(tensor, tensor, "layer", "auto", tensor)
|
|
|
|
layer.prepare_kv_cache_update.assert_called_once_with(metadata)
|
|
layer.update_kv_cache.assert_not_called()
|
|
|
|
|
|
def _float_to_e8m0_truncate(f: float) -> float:
|
|
"""Simulate SM100's float -> e8m0 -> bf16 scale conversion.
|
|
e8m0 format only stores the exponent (power of 2).
|
|
cudaRoundZero truncates toward zero, meaning we round down to the
|
|
nearest power of 2.
|
|
"""
|
|
if f <= 0:
|
|
return 0.0
|
|
# e8m0 = floor(log2(f)), then 2^(e8m0)
|
|
# This is equivalent to truncating to the nearest power of 2 below f
|
|
exp = math.floor(math.log2(f))
|
|
return 2.0**exp
|
|
|
|
|
|
def _dequantize_fp8_ds_mla_entry(
|
|
cache_slice: torch.Tensor,
|
|
kv_lora_rank: int,
|
|
rope_dim: int,
|
|
dtype: torch.dtype,
|
|
simulate_sm100_e8m0_scales: bool = False,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
"""Dequantize a single fp8_ds_mla cache entry back to latent + rope.
|
|
|
|
Args:
|
|
simulate_sm100_e8m0_scales: If True, simulate the SM100 kernel's
|
|
float -> e8m0 -> bf16 scale conversion path.
|
|
|
|
"""
|
|
# The first kv_lora_rank bytes store FP8 latent values with one scale per
|
|
# 128 element tile written as float32 right after the latent payload.
|
|
scales = cache_slice.view(torch.float32)[kv_lora_rank // 4 : kv_lora_rank // 4 + 4]
|
|
latent = torch.empty(kv_lora_rank, dtype=torch.float16, device=cache_slice.device)
|
|
for tile_idx in range(4):
|
|
tile_start = tile_idx * 128
|
|
tile_end = tile_start + 128
|
|
scale_val = float(scales[tile_idx].item())
|
|
if simulate_sm100_e8m0_scales:
|
|
# Simulate the lossy float -> e8m0 -> bf16 conversion
|
|
scale_val = _float_to_e8m0_truncate(scale_val)
|
|
ops.convert_fp8(
|
|
latent[tile_start:tile_end],
|
|
cache_slice[tile_start:tile_end],
|
|
scale_val,
|
|
kv_dtype="fp8",
|
|
)
|
|
latent = latent.to(dtype)
|
|
|
|
rope_offset = kv_lora_rank // 2 + 8
|
|
rope_vals = cache_slice.view(dtype)[rope_offset : rope_offset + rope_dim]
|
|
return latent, rope_vals.clone()
|
|
|
|
|
|
def _quantize_dequantize_fp8_ds_mla(
|
|
kv_c: torch.Tensor,
|
|
k_pe: torch.Tensor,
|
|
block_size: int,
|
|
scale: torch.Tensor,
|
|
simulate_sm100_e8m0_scales: bool = False,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
"""Round-trip kv_c/k_pe though the fp8_ds_mla cache layout.
|
|
|
|
Args:
|
|
simulate_sm100_e8m0_scales: If True, simulate the SM100 kernel's
|
|
float -> e8m0 -> bf16 scale conversion in dequantization.
|
|
|
|
"""
|
|
if kv_c.numel() == 0:
|
|
return kv_c.clone(), k_pe.clone()
|
|
|
|
kv_lora_rank = kv_c.shape[-1]
|
|
rope_dim = k_pe.shape[-1]
|
|
num_tokens = kv_c.shape[0]
|
|
num_blocks = max(1, math.ceil(num_tokens / block_size))
|
|
entry_size = kv_lora_rank + 4 * 4 + 2 * rope_dim
|
|
|
|
tmp_cache = torch.zeros(
|
|
num_blocks, block_size, entry_size, dtype=torch.uint8, device=kv_c.device
|
|
)
|
|
slot_mapping = torch.arange(num_tokens, dtype=torch.long, device=kv_c.device)
|
|
|
|
ops.concat_and_cache_mla(
|
|
kv_c, k_pe, tmp_cache, slot_mapping, kv_cache_dtype="fp8_ds_mla", scale=scale
|
|
)
|
|
|
|
dequant_kv_c = torch.empty_like(kv_c)
|
|
dequant_k_pe = torch.empty_like(k_pe)
|
|
|
|
for token_idx in range(num_tokens):
|
|
slot = slot_mapping[token_idx].item()
|
|
block_idx = slot // block_size
|
|
block_offset = slot % block_size
|
|
cache_slice = tmp_cache[block_idx, block_offset]
|
|
latent, rope_vals = _dequantize_fp8_ds_mla_entry(
|
|
cache_slice,
|
|
kv_lora_rank,
|
|
rope_dim,
|
|
kv_c.dtype,
|
|
simulate_sm100_e8m0_scales=simulate_sm100_e8m0_scales,
|
|
)
|
|
dequant_kv_c[token_idx] = latent
|
|
dequant_k_pe[token_idx] = rope_vals
|
|
|
|
return dequant_kv_c, dequant_k_pe
|
|
|
|
|
|
_E2M1_TABLE = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0]
|
|
|
|
|
|
def _quantize_dequantize_nvfp4_ds_mla(
|
|
kv_c: torch.Tensor,
|
|
k_pe: torch.Tensor,
|
|
block_size: int,
|
|
kv_cache_dtype: str,
|
|
scale: torch.Tensor,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
"""Round-trip kv_c/k_pe through the nvfp4_ds_mla cache layout.
|
|
|
|
Layout (per token, uint8): [e2m1-packed NoPE | unscaled e4m3 RoPE |
|
|
per-16 e4m3 NoPE SFs]. The SF bytes are stored permuted (an 8x4 -> 4x8
|
|
transpose: the scale for element block s lives at byte 8 * (s & 3) +
|
|
(s >> 2)) so that the 8 scales one FlashMLA dequant thread needs are
|
|
contiguous. Dequant: x = value * float(sf), which is exact in bf16, so
|
|
this python dequant matches the kernels bit-for-bit.
|
|
"""
|
|
if kv_c.numel() == 0:
|
|
return kv_c.clone(), k_pe.clone()
|
|
|
|
kv_lora_rank = kv_c.shape[-1]
|
|
rope_dim = k_pe.shape[-1]
|
|
num_tokens = kv_c.shape[0]
|
|
num_blocks = max(1, math.ceil(num_tokens / block_size))
|
|
entry_size = (
|
|
math.ceil((kv_lora_rank // 2 + rope_dim + kv_lora_rank // 16) / 16) * 16
|
|
)
|
|
sf_nope_off = kv_lora_rank // 2 + rope_dim
|
|
|
|
tmp_cache = torch.zeros(
|
|
num_blocks, block_size, entry_size, dtype=torch.uint8, device=kv_c.device
|
|
)
|
|
slot_mapping = torch.arange(num_tokens, dtype=torch.long, device=kv_c.device)
|
|
ops.concat_and_cache_mla(
|
|
kv_c, k_pe, tmp_cache, slot_mapping, kv_cache_dtype=kv_cache_dtype, scale=scale
|
|
)
|
|
|
|
tokens = tmp_cache.view(-1, entry_size)[:num_tokens]
|
|
table = torch.tensor(
|
|
_E2M1_TABLE + [-v for v in _E2M1_TABLE],
|
|
dtype=torch.float32,
|
|
device=kv_c.device,
|
|
)
|
|
|
|
def unpack_e2m1(packed: torch.Tensor) -> torch.Tensor:
|
|
lo = (packed & 0xF).long()
|
|
hi = (packed >> 4).long()
|
|
return table[torch.stack([lo, hi], dim=-1).flatten(-2)]
|
|
|
|
nope_vals = unpack_e2m1(tokens[:, : kv_lora_rank // 2])
|
|
num_nope_sf = kv_lora_rank // 16
|
|
nope_sf = (
|
|
# undo the on-wire SF permutation -> element-block order
|
|
tokens[:, sf_nope_off : sf_nope_off + num_nope_sf]
|
|
.unflatten(-1, (4, num_nope_sf // 4))
|
|
.transpose(-1, -2)
|
|
.flatten(-2)
|
|
.view(torch.float8_e4m3fn)
|
|
.float()
|
|
)
|
|
dequant_kv_c = (
|
|
(nope_vals.unflatten(-1, (-1, 16)) * nope_sf.unsqueeze(-1)).flatten(-2)
|
|
).to(kv_c.dtype)
|
|
|
|
# RoPE: plain e4m3, no scale factor
|
|
rope_raw = tokens[:, kv_lora_rank // 2 : sf_nope_off]
|
|
dequant_k_pe = rope_raw.view(torch.float8_e4m3fn).to(k_pe.dtype)
|
|
|
|
return dequant_kv_c, dequant_k_pe
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"backend_cls",
|
|
[FlashMLASparseBackend, FlashInferMLASparseTRTLLMBackend],
|
|
ids=["FlashMLA", "FlashInferTRTLLM"],
|
|
)
|
|
@pytest.mark.parametrize("batch_name", list(SPARSE_BACKEND_BATCH_SPECS.keys()))
|
|
@pytest.mark.parametrize(
|
|
"kv_cache_dtype",
|
|
["auto", "fp8", "fp8_ds_mla", "nvfp4_ds_mla"],
|
|
)
|
|
@pytest.mark.parametrize("tensor_parallel_size", [1, 2, 4])
|
|
@pytest.mark.parametrize("block_size", [32, 64])
|
|
@pytest.mark.parametrize(("q_scale", "k_scale"), [(1.0, 1.0), (2.0, 3.0)])
|
|
def test_sparse_backend_decode_correctness(
|
|
default_vllm_config,
|
|
dist_init,
|
|
backend_cls,
|
|
batch_name,
|
|
kv_cache_dtype,
|
|
tensor_parallel_size,
|
|
block_size,
|
|
workspace_init,
|
|
q_scale: float,
|
|
k_scale: float,
|
|
monkeypatch,
|
|
):
|
|
if (
|
|
batch_name == "large_q_pure_prefill"
|
|
and backend_cls == FlashMLASparseBackend
|
|
and kv_cache_dtype == "fp8_ds_mla"
|
|
and tensor_parallel_size == 4
|
|
):
|
|
monkeypatch.setattr(
|
|
"vllm.v1.attention.backends.mla.flashmla_sparse.split_prefill_chunks",
|
|
lambda rows, capacity: [(i, i + 1) for i in range(len(rows))],
|
|
)
|
|
if kv_cache_dtype not in backend_cls.supported_kv_cache_dtypes:
|
|
pytest.skip(f"{backend_cls.get_name()} does not support {kv_cache_dtype}")
|
|
|
|
if (
|
|
backend_cls == FlashMLASparseBackend
|
|
and kv_cache_dtype.startswith("fp8")
|
|
and kv_cache_dtype != "fp8_ds_mla"
|
|
):
|
|
pytest.skip(
|
|
"FlashMLA Sparse Attention backend fp8 only supports "
|
|
"fp8_ds_mla kv-cache dtype"
|
|
)
|
|
|
|
if kv_cache_dtype == "nvfp4_ds_mla":
|
|
# Unlike "fp8" (an alias canonicalized to "fp8_ds_mla" above), the
|
|
# NVFP4 DS-MLA dtype must reach the backend unchanged.
|
|
assert (
|
|
_canonicalize_sparse_mla_kv_cache_dtype(backend_cls, kv_cache_dtype)
|
|
== kv_cache_dtype
|
|
)
|
|
device_capability = current_platform.get_device_capability()
|
|
if device_capability is None or device_capability.major != 10:
|
|
pytest.skip("The NVFP4 DS-MLA kv-cache dtype requires SM 10.x")
|
|
|
|
supported_block_sizes = backend_cls.get_supported_kernel_block_sizes()
|
|
if block_size not in supported_block_sizes:
|
|
pytest.skip(
|
|
f"{backend_cls.get_name()} does not support block_size={block_size}"
|
|
)
|
|
|
|
if backend_cls == FlashMLASparseBackend:
|
|
ok, reason = flashmla.is_flashmla_sparse_supported()
|
|
if not ok:
|
|
pytest.skip(reason)
|
|
elif backend_cls == FlashInferMLASparseTRTLLMBackend:
|
|
device_capability = current_platform.get_device_capability()
|
|
if device_capability is None or not backend_cls.supports_compute_capability(
|
|
device_capability
|
|
):
|
|
pytest.skip("FlashInferMLASparseTRTLLMBackend requires SM 10.x capability")
|
|
|
|
batch_spec = SPARSE_BACKEND_BATCH_SPECS[batch_name]
|
|
use_fp8_ds_mla_quantization = kv_cache_dtype == "fp8_ds_mla"
|
|
use_nvfp4_ds_mla_quantization = kv_cache_dtype == "nvfp4_ds_mla"
|
|
|
|
device = torch.device(DEVICE_TYPE)
|
|
dtype = torch.bfloat16
|
|
|
|
# Model hyper-parameters (kept intentionally small for the unit test)
|
|
total_num_heads = 128
|
|
# Compute per-rank heads for simulated TP
|
|
num_heads = max(1, total_num_heads // tensor_parallel_size)
|
|
|
|
kv_lora_rank = 512
|
|
qk_nope_head_dim = 128
|
|
qk_rope_head_dim = 64
|
|
v_head_dim = 128
|
|
head_size = kv_lora_rank + qk_rope_head_dim
|
|
topk_tokens = 128
|
|
|
|
max_seqlen = max(batch_spec.seq_lens)
|
|
total_cache_tokens = sum(batch_spec.seq_lens)
|
|
|
|
# Note: We use TP=1 to avoid multi-GPU requirements in CI.
|
|
# The test simulates head partitioning via mocked methods below.
|
|
vllm_config = create_vllm_config(
|
|
model_name="deepseek-ai/DeepSeek-V2-Lite-Chat",
|
|
tensor_parallel_size=1,
|
|
max_model_len=max_seqlen,
|
|
num_gpu_blocks=max(2048, cdiv(total_cache_tokens, block_size) + 1),
|
|
block_size=block_size,
|
|
hf_config_override={
|
|
"index_topk": topk_tokens,
|
|
"attn_module_list_cfg": [{"topk_tokens": topk_tokens}],
|
|
},
|
|
)
|
|
model_config = vllm_config.model_config
|
|
model_config.hf_text_config = SimpleNamespace(
|
|
index_topk=topk_tokens,
|
|
q_lora_rank=None,
|
|
kv_lora_rank=kv_lora_rank,
|
|
qk_nope_head_dim=qk_nope_head_dim,
|
|
qk_rope_head_dim=qk_rope_head_dim,
|
|
v_head_dim=v_head_dim,
|
|
model_type="deepseek_v2",
|
|
)
|
|
del model_config.hf_config.index_topk # Composite configs only nest this field.
|
|
model_config.dtype = dtype
|
|
model_config.get_num_attention_heads = MethodType(
|
|
lambda self, parallel_config: num_heads,
|
|
model_config,
|
|
)
|
|
model_config.get_num_kv_heads = MethodType(
|
|
lambda self, parallel_config: 1, model_config
|
|
)
|
|
model_config.get_head_size = MethodType(lambda self: head_size, model_config)
|
|
model_config.get_sliding_window = MethodType(lambda self: None, model_config)
|
|
|
|
kv_cache_spec = create_standard_kv_cache_spec(vllm_config)
|
|
|
|
torch.manual_seed(0)
|
|
|
|
scale = 1.0 / math.sqrt(head_size)
|
|
|
|
# Shared MLA projection weights to keep reference and backend in sync
|
|
W_UK = torch.rand(
|
|
kv_lora_rank, num_heads, qk_nope_head_dim, dtype=dtype, device=device
|
|
)
|
|
W_UV = torch.rand(kv_lora_rank, num_heads, v_head_dim, dtype=dtype, device=device)
|
|
|
|
# Build synthetic decode-only workload
|
|
seq_lens = batch_spec.seq_lens
|
|
query_lens = batch_spec.query_lens
|
|
|
|
# Pre-compute positions and sparse indices for all tokens.
|
|
# We need these BEFORE computing the reference to use sparse attention masks.
|
|
total_query_tokens = sum(query_lens)
|
|
positions = []
|
|
for i in range(batch_spec.batch_size):
|
|
s_len = seq_lens[i]
|
|
q_len = query_lens[i]
|
|
ctx_len = s_len - q_len
|
|
for q_idx in range(q_len):
|
|
positions.append(ctx_len + q_idx)
|
|
|
|
# Create sparse indices with UNIQUE per-token offsets to catch bugs where
|
|
# the kernel uses wrong indices for some tokens (e.g., due to incorrect
|
|
# tensor shapes like [1, num_tokens, ...] instead of [num_tokens, 1, ...]).
|
|
# Also include -1 masked indices to verify the kernel handles them correctly.
|
|
sparse_indices = torch.empty(
|
|
total_query_tokens, topk_tokens, dtype=torch.int32, device=device
|
|
)
|
|
for tok_idx in range(total_query_tokens):
|
|
max_valid_idx = positions[tok_idx]
|
|
offset = tok_idx * 7 # Prime number for varied offsets
|
|
# Use only half the topk indices as valid, mask the rest with -1
|
|
# This tests that the kernel correctly ignores -1 indices
|
|
num_valid = min(topk_tokens // 2, max_valid_idx + 1)
|
|
if num_valid > 0:
|
|
valid_range = torch.arange(num_valid, device=device, dtype=torch.int32)
|
|
tok_indices = (valid_range + offset) % (max_valid_idx + 1)
|
|
# Pad with -1 for the remaining positions
|
|
tok_indices = torch.cat(
|
|
[
|
|
tok_indices,
|
|
torch.full(
|
|
(topk_tokens - num_valid,), -1, device=device, dtype=torch.int32
|
|
),
|
|
]
|
|
)
|
|
else:
|
|
tok_indices = torch.full(
|
|
(topk_tokens,), -1, device=device, dtype=torch.int32
|
|
)
|
|
tok_indices[0] = 0 # At least one valid index
|
|
sparse_indices[tok_idx] = tok_indices
|
|
|
|
all_q_vllm, all_kv_c_vllm, all_k_pe_vllm = [], [], []
|
|
kv_c_contexts, k_pe_contexts = [], []
|
|
reference_outputs = []
|
|
|
|
kv_cache_scale = torch.tensor(k_scale, dtype=torch.float32, device=device)
|
|
global_token_idx = 0
|
|
|
|
for i in range(batch_spec.batch_size):
|
|
s_len = seq_lens[i]
|
|
q_len = query_lens[i]
|
|
ctx_len = s_len - q_len
|
|
|
|
q_c = torch.rand(
|
|
q_len,
|
|
num_heads,
|
|
qk_nope_head_dim + qk_rope_head_dim,
|
|
dtype=dtype,
|
|
device=device,
|
|
)
|
|
kv_c_full = torch.rand(s_len, kv_lora_rank, dtype=dtype, device=device)
|
|
k_pe_full = torch.rand(s_len, 1, qk_rope_head_dim, dtype=dtype, device=device)
|
|
|
|
if use_fp8_ds_mla_quantization:
|
|
is_sm100 = torch.cuda.get_device_capability()[0] >= 10
|
|
kv_c_full, k_pe_squeezed = _quantize_dequantize_fp8_ds_mla(
|
|
kv_c_full,
|
|
k_pe_full.squeeze(1),
|
|
block_size=block_size,
|
|
scale=kv_cache_scale,
|
|
simulate_sm100_e8m0_scales=is_sm100,
|
|
)
|
|
k_pe_full = k_pe_squeezed.unsqueeze(1)
|
|
elif use_nvfp4_ds_mla_quantization:
|
|
kv_c_full, k_pe_squeezed = _quantize_dequantize_nvfp4_ds_mla(
|
|
kv_c_full,
|
|
k_pe_full.squeeze(1),
|
|
block_size=block_size,
|
|
kv_cache_dtype=kv_cache_dtype,
|
|
scale=kv_cache_scale,
|
|
)
|
|
k_pe_full = k_pe_squeezed.unsqueeze(1)
|
|
|
|
q_nope, q_pe = q_c.split([qk_nope_head_dim, qk_rope_head_dim], dim=-1)
|
|
ql_nope = torch.einsum("qnh,lnh->qnl", q_nope, W_UK)
|
|
q_mqa = torch.cat([ql_nope, q_pe], dim=-1)
|
|
|
|
k_mqa = torch.cat([kv_c_full, k_pe_full.squeeze(1)], dim=-1)
|
|
v_mqa = kv_c_full
|
|
|
|
# Compute sparse SDPA reference per query token using its sparse indices
|
|
for q_idx in range(q_len):
|
|
tok_sparse_idx = sparse_indices[global_token_idx]
|
|
valid_mask = tok_sparse_idx >= 0
|
|
valid_indices = tok_sparse_idx[valid_mask].long()
|
|
|
|
q_tok = q_mqa[q_idx : q_idx + 1] # [1, num_heads, head_dim]
|
|
k_sparse = k_mqa[valid_indices] # [num_valid, head_dim]
|
|
v_sparse = v_mqa[valid_indices] # [num_valid, kv_lora_rank]
|
|
|
|
k_sparse = k_sparse.unsqueeze(1).expand(-1, num_heads, -1)
|
|
v_sparse = v_sparse.unsqueeze(1).expand(-1, num_heads, -1)
|
|
|
|
# SDPA: [1, num_heads, 1, head_dim] x [1, num_heads, num_valid, head_dim]
|
|
q_sdpa_in = q_tok.unsqueeze(0).transpose(1, 2)
|
|
k_sdpa_in = k_sparse.unsqueeze(0).transpose(1, 2)
|
|
v_sdpa_in = v_sparse.unsqueeze(0).transpose(1, 2)
|
|
|
|
sdpa_out = torch.nn.functional.scaled_dot_product_attention(
|
|
q_sdpa_in, k_sdpa_in, v_sdpa_in, scale=scale
|
|
)
|
|
sdpa_out = sdpa_out.transpose(1, 2).squeeze(
|
|
0
|
|
) # [1, num_heads, kv_lora_rank]
|
|
|
|
sdpa_out = torch.einsum("qnl,lnv->qnv", sdpa_out, W_UV)
|
|
reference_outputs.append(sdpa_out.flatten(start_dim=-2))
|
|
|
|
global_token_idx += 1
|
|
|
|
all_q_vllm.append(q_c)
|
|
all_kv_c_vllm.append(kv_c_full[ctx_len:])
|
|
all_k_pe_vllm.append(k_pe_full[ctx_len:])
|
|
kv_c_contexts.append(kv_c_full[: ctx_len + 1])
|
|
k_pe_contexts.append(k_pe_full[: ctx_len + 1])
|
|
|
|
query_vllm = torch.cat(all_q_vllm, dim=0)
|
|
kv_c_vllm = torch.cat(all_kv_c_vllm, dim=0)
|
|
k_pe_vllm = torch.cat(all_k_pe_vllm, dim=0)
|
|
sdpa_reference = torch.cat(reference_outputs, dim=0)
|
|
|
|
vllm_config.cache_config.cache_dtype = kv_cache_dtype
|
|
vllm_config.model_config.hf_text_config.index_topk = topk_tokens
|
|
|
|
common_attn_metadata = create_common_attn_metadata(
|
|
batch_spec,
|
|
vllm_config.cache_config.block_size,
|
|
device,
|
|
arange_block_indices=True,
|
|
)
|
|
|
|
kv_cache = create_and_prepopulate_kv_cache(
|
|
kv_c_contexts=kv_c_contexts,
|
|
k_pe_contexts=k_pe_contexts,
|
|
block_size=vllm_config.cache_config.block_size,
|
|
head_size=head_size,
|
|
dtype=dtype,
|
|
device=device,
|
|
num_blocks=vllm_config.cache_config.num_gpu_blocks,
|
|
common_attn_metadata=common_attn_metadata,
|
|
randomize_blocks=False,
|
|
kv_cache_dtype=kv_cache_dtype,
|
|
scale=kv_cache_scale,
|
|
)
|
|
|
|
# The sparse builder clones the layer's dense-MHA prefill backend from
|
|
# static_forward_context; register a mock layer carrying one.
|
|
prefill_backend = get_mla_prefill_backend(vllm_config)(
|
|
num_heads=num_heads,
|
|
scale=scale,
|
|
kv_lora_rank=kv_lora_rank,
|
|
qk_nope_head_dim=qk_nope_head_dim,
|
|
qk_rope_head_dim=qk_rope_head_dim,
|
|
v_head_dim=v_head_dim,
|
|
vllm_config=vllm_config,
|
|
)
|
|
vllm_config.compilation_config.static_forward_context["placeholder"] = (
|
|
SimpleNamespace(prefill_backend=prefill_backend)
|
|
)
|
|
|
|
builder_cls = backend_cls.get_builder_cls()
|
|
builder = builder_cls(kv_cache_spec, ["placeholder"], vllm_config, device)
|
|
metadata = builder.build(
|
|
common_prefix_len=0, common_attn_metadata=common_attn_metadata
|
|
)
|
|
|
|
# Use the pre-computed sparse_indices for the mock indexer
|
|
mock_indexer = SimpleNamespace(topk_indices_buffer=sparse_indices)
|
|
|
|
kv_b_proj_weight = torch.cat([W_UK, W_UV], dim=-1)
|
|
kv_b_proj_weight = kv_b_proj_weight.view(
|
|
kv_lora_rank, num_heads * (qk_nope_head_dim + v_head_dim)
|
|
)
|
|
|
|
mock_kv_b_proj = ColumnParallelLinear(
|
|
input_size=kv_lora_rank,
|
|
output_size=num_heads * (qk_nope_head_dim + v_head_dim),
|
|
bias=False,
|
|
).to(device=device, dtype=dtype)
|
|
mock_kv_b_proj.weight = torch.nn.Parameter(kv_b_proj_weight.T.contiguous())
|
|
|
|
impl_cls = backend_cls.get_impl_cls()
|
|
with set_current_vllm_config(vllm_config):
|
|
impl = impl_cls(
|
|
num_heads=num_heads,
|
|
head_size=head_size,
|
|
scale=scale,
|
|
num_kv_heads=1,
|
|
alibi_slopes=None,
|
|
sliding_window=None,
|
|
kv_cache_dtype=vllm_config.cache_config.cache_dtype,
|
|
logits_soft_cap=None,
|
|
attn_type="decoder",
|
|
kv_sharing_target_layer_name=None,
|
|
q_lora_rank=None,
|
|
kv_lora_rank=kv_lora_rank,
|
|
qk_nope_head_dim=qk_nope_head_dim,
|
|
qk_rope_head_dim=qk_rope_head_dim,
|
|
qk_head_dim=qk_nope_head_dim + qk_rope_head_dim,
|
|
v_head_dim=v_head_dim,
|
|
kv_b_proj=mock_kv_b_proj,
|
|
indexer=mock_indexer,
|
|
)
|
|
|
|
impl.process_weights_after_loading(dtype)
|
|
|
|
# Create mock sparse MLA layer with weight matrices
|
|
mock_layer = MockSparseMLAAttentionLayer(
|
|
impl=impl,
|
|
num_heads=num_heads,
|
|
qk_nope_head_dim=qk_nope_head_dim,
|
|
qk_rope_head_dim=qk_rope_head_dim,
|
|
v_head_dim=v_head_dim,
|
|
kv_lora_rank=kv_lora_rank,
|
|
device=device,
|
|
W_UK=W_UK,
|
|
W_UV=W_UV,
|
|
q_scale=q_scale,
|
|
k_scale=k_scale,
|
|
)
|
|
|
|
out_buffer = torch.empty(
|
|
metadata.num_actual_tokens, num_heads * v_head_dim, dtype=dtype, device=device
|
|
)
|
|
|
|
with torch.inference_mode():
|
|
if backend_cls == FlashMLASparseBackend and kv_cache_dtype == "fp8_ds_mla":
|
|
from vllm.v1.worker.workspace import current_workspace_manager
|
|
|
|
current_workspace_manager().lock()
|
|
backend_output = mock_layer.forward_impl(
|
|
query_vllm,
|
|
kv_c_vllm,
|
|
k_pe_vllm,
|
|
kv_cache,
|
|
metadata,
|
|
out_buffer,
|
|
)
|
|
|
|
assert backend_output.shape == sdpa_reference.shape
|
|
assert backend_output.dtype == sdpa_reference.dtype
|
|
assert torch.isfinite(backend_output).all()
|
|
|
|
# FP8 quantization introduces some error, but should be within reasonable bounds
|
|
# BF16 (auto) should be very accurate, FP8 allows slightly more tolerance
|
|
if kv_cache_dtype.startswith("fp8"):
|
|
torch.testing.assert_close(
|
|
backend_output, sdpa_reference, rtol=0.065, atol=0.05
|
|
)
|
|
else:
|
|
torch.testing.assert_close(backend_output, sdpa_reference, rtol=0.01, atol=0.01)
|
|
|
|
|
|
def _triton_convert_reference_impl(
|
|
req_ids: torch.Tensor,
|
|
block_table: torch.Tensor,
|
|
token_indices: torch.Tensor,
|
|
block_size: int,
|
|
num_topk_tokens: int,
|
|
HAS_PREFILL_WORKSPACE: bool = False,
|
|
prefill_workspace_request_ids: torch.Tensor | None = None,
|
|
prefill_workspace_starts: torch.Tensor | None = None,
|
|
) -> torch.Tensor:
|
|
"""Reference implementation for triton_convert_req_index_to_global_index."""
|
|
num_tokens = req_ids.shape[0]
|
|
max_blocks_per_req = block_table.shape[1]
|
|
result = torch.empty(
|
|
num_tokens, num_topk_tokens, dtype=torch.int32, device=req_ids.device
|
|
)
|
|
|
|
for token_id in range(num_tokens):
|
|
req_id = req_ids[token_id].item()
|
|
|
|
# Determine if this token uses workspace or paged cache
|
|
use_prefill_workspace = False
|
|
workspace_start = 0
|
|
if HAS_PREFILL_WORKSPACE and prefill_workspace_request_ids is not None:
|
|
assert prefill_workspace_starts is not None
|
|
prefill_req_id = prefill_workspace_request_ids[token_id].item()
|
|
if prefill_req_id >= 0:
|
|
use_prefill_workspace = True
|
|
workspace_start = prefill_workspace_starts[prefill_req_id].item()
|
|
|
|
for idx_id in range(num_topk_tokens):
|
|
token_idx = token_indices[token_id, idx_id].item()
|
|
|
|
if token_idx == -1:
|
|
result[token_id, idx_id] = -1
|
|
elif use_prefill_workspace:
|
|
# Prefill + using prefill workspace: map to workspace offset
|
|
result[token_id, idx_id] = workspace_start + token_idx
|
|
else:
|
|
# Decode: map to paged cache
|
|
block_id = token_idx // block_size
|
|
if block_id >= max_blocks_per_req:
|
|
result[token_id, idx_id] = -1
|
|
else:
|
|
block_num = block_table[req_id, block_id].item()
|
|
offset = token_idx % block_size
|
|
result[token_id, idx_id] = block_num * block_size + offset
|
|
|
|
return result
|
|
|
|
|
|
@pytest.mark.parametrize("block_size", [16, 64, 128])
|
|
@pytest.mark.parametrize("num_topk_tokens", [128, 256, 512])
|
|
@pytest.mark.skipif(
|
|
torch.cuda.get_device_capability() < (9, 0),
|
|
reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
|
|
)
|
|
def test_triton_convert_req_index_to_global_index_decode_only(
|
|
block_size, num_topk_tokens
|
|
):
|
|
device = torch.device(DEVICE_TYPE)
|
|
num_tokens = 8
|
|
num_requests = 4
|
|
max_blocks_per_req = 10
|
|
|
|
req_id = torch.randint(
|
|
0, num_requests, (num_tokens,), dtype=torch.int32, device=device
|
|
)
|
|
block_table = torch.randint(
|
|
0, 100, (num_requests, max_blocks_per_req), dtype=torch.int32, device=device
|
|
)
|
|
|
|
token_indices = torch.randint(
|
|
0,
|
|
block_size * max_blocks_per_req,
|
|
(num_tokens, num_topk_tokens),
|
|
dtype=torch.int32,
|
|
device=device,
|
|
)
|
|
|
|
# Set some to -1 to test masking
|
|
token_indices[0, :10] = -1
|
|
token_indices[3, 50:60] = -1
|
|
|
|
# Set some to out of bounds
|
|
token_indices[2, 100:110] = max_blocks_per_req * block_size
|
|
token_indices[6, 150:160] = max_blocks_per_req * block_size
|
|
|
|
result = triton_convert_req_index_to_global_index(
|
|
req_id,
|
|
block_table,
|
|
token_indices,
|
|
BLOCK_SIZE=block_size,
|
|
NUM_TOPK_TOKENS=num_topk_tokens,
|
|
)
|
|
|
|
reference_result = _triton_convert_reference_impl(
|
|
req_id,
|
|
block_table,
|
|
token_indices,
|
|
block_size,
|
|
num_topk_tokens,
|
|
)
|
|
|
|
torch.testing.assert_close(result, reference_result, rtol=0, atol=0)
|
|
|
|
|
|
def test_index_group_convert_during_piecewise_capture():
|
|
"""In piecewise cudagraph mode the indexer is captured while the convert
|
|
runs in the following eager break. The convert's side stream must not
|
|
wait on ``logical_topk_ready`` there: an event recorded inside a captured
|
|
segment is graph-local, and an eager wait on it raises
|
|
cudaErrorInvalidValue."""
|
|
device = torch.device(DEVICE_TYPE)
|
|
num_tokens, num_topk, num_requests, blocks_per_req, block_size = 8, 128, 4, 4, 16
|
|
|
|
logical_topk_indices = torch.randint(
|
|
0,
|
|
block_size * blocks_per_req,
|
|
(num_tokens, num_topk),
|
|
dtype=torch.int32,
|
|
device=device,
|
|
)
|
|
builder = SparseMLAIndexGroupBuilder(logical_topk_indices)
|
|
group, layer_index = builder.register_layer(is_index_producing_layer=True)
|
|
assert layer_index == 0 and group.has_indexer
|
|
|
|
req_id = torch.arange(num_tokens, dtype=torch.int32, device=device) % num_requests
|
|
block_table = torch.arange(
|
|
num_requests * blocks_per_req, dtype=torch.int32, device=device
|
|
).view(num_requests, blocks_per_req)
|
|
attn_metadata = SimpleNamespace(
|
|
req_id_per_token=req_id, block_table=block_table, block_size=block_size
|
|
)
|
|
expected = triton_convert_req_index_to_global_index(
|
|
req_id,
|
|
block_table,
|
|
logical_topk_indices,
|
|
BLOCK_SIZE=block_size,
|
|
NUM_TOPK_TOKENS=num_topk,
|
|
)
|
|
|
|
vllm_config = create_vllm_config(model_name="Qwen/Qwen3.5-0.8B")
|
|
marker = torch.zeros(1, device=device)
|
|
capture_stream = torch.cuda.Stream(device=device)
|
|
# torch.cuda.stream routes through vLLM's patched torch.cuda.set_stream,
|
|
# so current_stream() tracks the capture stream as in production.
|
|
with (
|
|
torch.cuda.stream(capture_stream),
|
|
set_forward_context(
|
|
None, vllm_config, cudagraph_runtime_mode=CUDAGraphMode.PIECEWISE
|
|
),
|
|
):
|
|
capture = BreakableCUDAGraphCapture()
|
|
with capture:
|
|
marker.add_(1)
|
|
# Record inside a captured segment, convert in the eager
|
|
# break — mirrors mla.py -> unified_mla_attention_with_output.
|
|
group.set_logical_topk_ready(layer_index)
|
|
capture.add_eager(
|
|
lambda: group.convert_logical_to_physical_topk(
|
|
layer_index,
|
|
logical_topk_indices,
|
|
attn_metadata,
|
|
block_stride_rows=None,
|
|
return_valid_counts=False,
|
|
)
|
|
)
|
|
marker.add_(1)
|
|
capture.replay()
|
|
capture_stream.synchronize()
|
|
|
|
result = group.physical_topk_indices[:num_tokens]
|
|
torch.testing.assert_close(result, expected, rtol=0, atol=0)
|
|
|
|
|
|
@pytest.mark.parametrize("block_size", [16])
|
|
@pytest.mark.skipif(
|
|
torch.cuda.get_device_capability() < (9, 0),
|
|
reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
|
|
)
|
|
def test_triton_convert_req_index_to_global_index_with_prefill_workspace(block_size):
|
|
device = torch.device(DEVICE_TYPE)
|
|
num_requests = 4
|
|
max_blocks_per_req = 8
|
|
num_topk_tokens = 128
|
|
|
|
# First 6 tokens are decode (reqs 0, 1), last 6 are prefill (reqs 2, 3)
|
|
req_id = torch.tensor(
|
|
[0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3], dtype=torch.int32, device=device
|
|
)
|
|
prefill_workspace_request_ids = torch.tensor(
|
|
[-1, -1, -1, -1, -1, -1, 0, 0, 0, 1, 1, 1], dtype=torch.int32, device=device
|
|
)
|
|
|
|
# Workspace starts for the 2 prefill reqs: req 2 starts at 0, req 3 starts at 100
|
|
prefill_workspace_starts = torch.tensor([0, 100], dtype=torch.int32, device=device)
|
|
|
|
block_table = torch.randint(
|
|
0, 50, (num_requests, max_blocks_per_req), dtype=torch.int32, device=device
|
|
)
|
|
token_indices = torch.randint(
|
|
0,
|
|
block_size * max_blocks_per_req,
|
|
(req_id.shape[0], num_topk_tokens),
|
|
dtype=torch.int32,
|
|
device=device,
|
|
)
|
|
|
|
# Set some to -1 to test masking
|
|
token_indices[0, :10] = -1
|
|
token_indices[3, 50:60] = -1
|
|
|
|
# Set some to out of bounds
|
|
token_indices[2, 100:110] = max_blocks_per_req * block_size
|
|
token_indices[6, 150:160] = max_blocks_per_req * block_size
|
|
|
|
result = triton_convert_req_index_to_global_index(
|
|
req_id,
|
|
block_table,
|
|
token_indices,
|
|
BLOCK_SIZE=block_size,
|
|
NUM_TOPK_TOKENS=num_topk_tokens,
|
|
HAS_PREFILL_WORKSPACE=True,
|
|
prefill_workspace_request_ids=prefill_workspace_request_ids,
|
|
prefill_workspace_starts=prefill_workspace_starts,
|
|
)
|
|
|
|
reference_result = _triton_convert_reference_impl(
|
|
req_id,
|
|
block_table,
|
|
token_indices,
|
|
block_size,
|
|
num_topk_tokens,
|
|
HAS_PREFILL_WORKSPACE=True,
|
|
prefill_workspace_request_ids=prefill_workspace_request_ids,
|
|
prefill_workspace_starts=prefill_workspace_starts,
|
|
)
|
|
|
|
torch.testing.assert_close(result, reference_result, rtol=0, atol=0)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
torch.cuda.get_device_capability() < (9, 0),
|
|
reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
|
|
)
|
|
def test_triton_convert_rejects_req_id_longer_than_token_indices():
|
|
"""Guard against the #47327 regression: the kernel grid is sized by
|
|
req_id but the output is allocated like token_indices, so a full-batch
|
|
req_id combined with an MQA-subset token_indices wrote past the end of
|
|
the output buffer. The wrapper must reject the length mismatch instead
|
|
of corrupting memory."""
|
|
device = torch.device(DEVICE_TYPE)
|
|
num_topk_tokens = 128
|
|
block_size = 64
|
|
block_table = torch.arange(40, dtype=torch.int32, device=device).view(4, 10)
|
|
|
|
# Full batch: 2 decode tokens + 10 prefill tokens
|
|
req_id_full = torch.tensor(
|
|
[0, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3], dtype=torch.int32, device=device
|
|
)
|
|
num_mqa_tokens = 2
|
|
token_indices = torch.randint(
|
|
0,
|
|
block_size * 10,
|
|
(num_mqa_tokens, num_topk_tokens),
|
|
dtype=torch.int32,
|
|
device=device,
|
|
)
|
|
|
|
with pytest.raises(AssertionError, match="must cover the same tokens"):
|
|
triton_convert_req_index_to_global_index(
|
|
req_id_full,
|
|
block_table,
|
|
token_indices,
|
|
BLOCK_SIZE=block_size,
|
|
NUM_TOPK_TOKENS=num_topk_tokens,
|
|
)
|
|
|
|
# The sliced call is the intended usage and must match the reference.
|
|
result = triton_convert_req_index_to_global_index(
|
|
req_id_full[:num_mqa_tokens],
|
|
block_table,
|
|
token_indices,
|
|
BLOCK_SIZE=block_size,
|
|
NUM_TOPK_TOKENS=num_topk_tokens,
|
|
)
|
|
reference = _triton_convert_reference_impl(
|
|
req_id_full[:num_mqa_tokens],
|
|
block_table,
|
|
token_indices,
|
|
block_size,
|
|
num_topk_tokens,
|
|
)
|
|
torch.testing.assert_close(result, reference, rtol=0, atol=0)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
torch.cuda.get_device_capability() < (9, 0),
|
|
reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
|
|
)
|
|
def test_flashmla_forward_bf16_kv_slices_req_id_to_mqa_tokens():
|
|
"""Guard against the #47327 regression: when the dense-MHA prefill split
|
|
is active, forward_mqa only receives the leading decode tokens, but
|
|
_forward_bf16_kv passed the full-batch req_id_per_token to the index
|
|
conversion, making it write past the end of its output buffer. The call
|
|
site must slice req_id_per_token to the MQA tokens."""
|
|
device = torch.device(DEVICE_TYPE)
|
|
num_topk_tokens = 128
|
|
block_size = 64
|
|
num_batch_tokens = 12
|
|
num_mqa_tokens = 2
|
|
|
|
attn_metadata = SimpleNamespace(
|
|
req_id_per_token=torch.tensor(
|
|
[0, 1] + [2] * 5 + [3] * 5, dtype=torch.int32, device=device
|
|
),
|
|
block_table=torch.arange(40, dtype=torch.int32, device=device).view(4, 10),
|
|
block_size=block_size,
|
|
num_decode_tokens=num_mqa_tokens,
|
|
num_actual_tokens=num_mqa_tokens,
|
|
)
|
|
assert attn_metadata.req_id_per_token.shape[0] == num_batch_tokens
|
|
|
|
q = torch.zeros(num_mqa_tokens, 4, 576, dtype=torch.bfloat16, device=device)
|
|
kv_cache = torch.zeros(40, block_size, 576, dtype=torch.bfloat16, device=device)
|
|
topk_indices = torch.randint(
|
|
0,
|
|
block_size * 10,
|
|
(num_mqa_tokens, num_topk_tokens),
|
|
dtype=torch.int32,
|
|
device=device,
|
|
)
|
|
|
|
captured = {}
|
|
|
|
def _stub_kernel(q, kv, indices, lengths, actual_num_heads):
|
|
captured["indices"] = indices
|
|
captured["actual_num_heads"] = actual_num_heads
|
|
return (
|
|
torch.zeros(q.shape[0], q.shape[1], 512, dtype=q.dtype, device=q.device),
|
|
None,
|
|
)
|
|
|
|
def _convert_topk(indices, metadata, **kwargs):
|
|
return triton_convert_req_index_to_global_index(
|
|
metadata.req_id_per_token[: indices.shape[0]],
|
|
metadata.block_table,
|
|
indices,
|
|
BLOCK_SIZE=metadata.block_size,
|
|
BLOCK_STRIDE_ROWS=kwargs["block_stride_rows"],
|
|
NUM_TOPK_TOKENS=indices.shape[1],
|
|
return_valid_counts=kwargs["return_valid_counts"],
|
|
)
|
|
|
|
stub_impl = SimpleNamespace(
|
|
_bf16_flash_mla_kernel=_stub_kernel,
|
|
_convert_logical_to_physical_topk=_convert_topk,
|
|
index_group=None,
|
|
index_group_index=0,
|
|
pcp_dcp_kv_gather=False,
|
|
)
|
|
|
|
out, _ = FlashMLASparseImpl._forward_bf16_kv(
|
|
stub_impl, q, kv_cache, topk_indices, attn_metadata, q.shape[1]
|
|
)
|
|
|
|
assert out.shape[0] == num_mqa_tokens
|
|
assert captured["indices"].shape[0] == num_mqa_tokens
|
|
assert captured["actual_num_heads"] == q.shape[1]
|
|
reference = _triton_convert_reference_impl(
|
|
attn_metadata.req_id_per_token[:num_mqa_tokens],
|
|
attn_metadata.block_table,
|
|
topk_indices,
|
|
block_size,
|
|
num_topk_tokens,
|
|
)
|
|
torch.testing.assert_close(captured["indices"], reference, rtol=0, atol=0)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"seq_lens,max_buf,expected",
|
|
[
|
|
# Basic split: totals per chunk ≤ max_buf
|
|
(torch.tensor([2, 3, 4, 2]), 5, [(0, 2), (2, 3), (3, 4)]),
|
|
# Exact fits should split between items when adding the next would overflow
|
|
(torch.tensor([5, 5, 5]), 5, [(0, 1), (1, 2), (2, 3)]),
|
|
# All requests fit in a single chunk
|
|
(torch.tensor([1, 1, 1]), 10, [(0, 3)]),
|
|
# Large buffer
|
|
(torch.tensor([4, 4, 4]), 100, [(0, 3)]),
|
|
],
|
|
)
|
|
def test_split_prefill_chunks(seq_lens, max_buf, expected):
|
|
out = split_prefill_chunks(seq_lens, max_buf)
|
|
assert out == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("max_query_len", "expected"),
|
|
[(32768, True), (33024, False)],
|
|
)
|
|
def test_masked_mha_workspace_fits_single_request_boundary(max_query_len, expected):
|
|
"""A 32K prefill needs the default workspace exactly; shrinking it would
|
|
push a supported request onto MQA."""
|
|
assert (
|
|
_masked_mha_workspace_fits(
|
|
batch_size=1,
|
|
max_query_len=max_query_len,
|
|
max_context_chunk_seq_len=0,
|
|
workspace_numel=GLOBAL_TOPK_MASK_MAX_BYTES // torch.int32.itemsize,
|
|
)
|
|
is expected
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("backend_name", "tensor_parallel_size", "query_len"),
|
|
[
|
|
("FLASHMLA_SPARSE", 4, 48 * 1024),
|
|
("FLASHMLA_SPARSE", 8, 112 * 1024),
|
|
("FLASHINFER_MLA_SPARSE", 4, 36 * 1024),
|
|
("FLASHINFER_MLA_SPARSE", 8, 64 * 1024),
|
|
],
|
|
)
|
|
def test_masked_mha_workspace_guards_long_routing_policy(
|
|
backend_name, tensor_parallel_size, query_len
|
|
):
|
|
assert _use_masked_mha(
|
|
backend_name=backend_name,
|
|
tensor_parallel_size=tensor_parallel_size,
|
|
qk_head_dim=256,
|
|
v_head_dim=256,
|
|
query_len=query_len,
|
|
seq_len=query_len,
|
|
has_context=False,
|
|
)
|
|
assert not _masked_mha_workspace_fits(
|
|
batch_size=1,
|
|
max_query_len=query_len,
|
|
max_context_chunk_seq_len=0,
|
|
workspace_numel=GLOBAL_TOPK_MASK_MAX_BYTES // torch.int32.itemsize,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model_dims", "kv_cache_dtype", "fa_version", "expected"),
|
|
[
|
|
pytest.param((128, 512, 128, 64, 128), "auto", 4, True, id="deepseek_v32"),
|
|
pytest.param((64, 512, 192, 64, 256), "auto", 4, True, id="glm5"),
|
|
pytest.param((64, 512, 256, 0, 256), "auto", 4, True, id="glm53_flash_nope"),
|
|
pytest.param((64, 512, 256, 0, 256), "fp8", 4, False, id="quantized_kv"),
|
|
pytest.param((64, 512, 256, 0, 256), "auto", 3, False, id="no_fa4"),
|
|
pytest.param((64, 512, 256, 64, 256), "auto", 4, False, id="rope_320"),
|
|
pytest.param((128, 512, 256, 0, 256), "auto", 4, False, id="wrong_heads"),
|
|
],
|
|
)
|
|
def test_is_masked_mha_available_model_dims(
|
|
monkeypatch, model_dims, kv_cache_dtype, fa_version, expected
|
|
):
|
|
"""The allow-list gates masked MHA per exact model geometry: the DeepSeek-V3.2,
|
|
GLM-5 and NoPE GLM-5.3-Flash layouts on an SM100-family GPU with FA4 and an
|
|
unquantized KV cache, nothing else."""
|
|
import vllm.model_executor.layers.attention.sparse_mla_attention as mod
|
|
|
|
monkeypatch.setattr(
|
|
mod.current_platform, "is_device_capability_family", lambda family: True
|
|
)
|
|
monkeypatch.setattr(mod, "get_flash_attn_version", lambda **kwargs: fa_version)
|
|
assert _is_masked_mha_available(*model_dims, kv_cache_dtype) is expected
|
|
|
|
|
|
def test_masked_mha_workspace_fits_accounts_for_batch_and_context():
|
|
"""Request count and context chunk length are independent multipliers."""
|
|
base = dict(batch_size=2, max_query_len=2048, max_context_chunk_seq_len=2048)
|
|
exact = math.prod(_topk_mask_shape(2, 2048, 2048))
|
|
|
|
assert _masked_mha_workspace_fits(**base, workspace_numel=exact)
|
|
assert not _masked_mha_workspace_fits(
|
|
**{**base, "batch_size": 3}, workspace_numel=exact
|
|
)
|
|
assert not _masked_mha_workspace_fits(
|
|
**{**base, "max_context_chunk_seq_len": 4096}, workspace_numel=exact
|
|
)
|
|
|
|
|
|
PREFILL_BATCH_SPECS = {
|
|
"short_dense_mha": BatchSpec(seq_lens=[64, 128], query_lens=[64, 128]),
|
|
"short_context_dense_mha": BatchSpec(seq_lens=[128, 160], query_lens=[64, 32]),
|
|
"masked_mha": BatchSpec(seq_lens=[256], query_lens=[256]),
|
|
"masked_mha_chunked_context": BatchSpec(seq_lens=[448, 384], query_lens=[256, 256]),
|
|
}
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
torch.cuda.get_device_capability()[0] < 10,
|
|
reason="Sparse MLA forward_mha requires FA4 (SM100+)",
|
|
)
|
|
@pytest.mark.parametrize("batch_name", list(PREFILL_BATCH_SPECS.keys()))
|
|
@pytest.mark.parametrize("kv_cache_dtype", ["auto"])
|
|
@pytest.mark.parametrize(
|
|
("num_heads", "qk_nope_head_dim", "qk_rope_head_dim", "v_head_dim"),
|
|
[
|
|
pytest.param(128, 128, 64, 128, id="deepseek_hd192_v128"),
|
|
pytest.param(64, 192, 64, 256, id="glm5_hd256_v256"),
|
|
pytest.param(64, 256, 0, 256, id="glm53_flash_nope_hd256_v256"),
|
|
],
|
|
)
|
|
def test_sparse_backend_prefill_correctness(
|
|
default_vllm_config,
|
|
dist_init,
|
|
batch_name,
|
|
kv_cache_dtype,
|
|
num_heads,
|
|
qk_nope_head_dim,
|
|
qk_rope_head_dim,
|
|
v_head_dim,
|
|
workspace_init,
|
|
):
|
|
"""Test dense and masked MHA across supported sparse MLA dimensions."""
|
|
backend_cls = FlashMLASparseBackend
|
|
batch_spec = PREFILL_BATCH_SPECS[batch_name]
|
|
|
|
device = torch.device("cuda")
|
|
dtype = torch.bfloat16
|
|
block_size = 64
|
|
|
|
kv_lora_rank = 512
|
|
head_size = kv_lora_rank + qk_rope_head_dim
|
|
masked_mha = batch_name.startswith("masked_mha")
|
|
topk_tokens = 200 if masked_mha else 512
|
|
|
|
max_seqlen = max(batch_spec.seq_lens)
|
|
total_cache_tokens = sum(batch_spec.seq_lens)
|
|
|
|
vllm_config = create_vllm_config(
|
|
model_name="deepseek-ai/DeepSeek-V2-Lite-Chat",
|
|
tensor_parallel_size=1,
|
|
max_model_len=max_seqlen,
|
|
num_gpu_blocks=max(2048, cdiv(total_cache_tokens, block_size) + 1),
|
|
block_size=block_size,
|
|
hf_config_override={
|
|
"index_topk": topk_tokens,
|
|
"attn_module_list_cfg": [{"topk_tokens": topk_tokens}],
|
|
},
|
|
)
|
|
model_config = vllm_config.model_config
|
|
model_config.hf_text_config = SimpleNamespace(
|
|
index_topk=topk_tokens,
|
|
q_lora_rank=None,
|
|
kv_lora_rank=kv_lora_rank,
|
|
qk_nope_head_dim=qk_nope_head_dim,
|
|
qk_rope_head_dim=qk_rope_head_dim,
|
|
v_head_dim=v_head_dim,
|
|
model_type="deepseek_v2",
|
|
)
|
|
del model_config.hf_config.index_topk # Composite configs only nest this field.
|
|
model_config.dtype = dtype
|
|
model_config.model_arch_config.total_num_attention_heads = num_heads
|
|
model_config.get_num_attention_heads = MethodType(
|
|
lambda self, parallel_config: num_heads, model_config
|
|
)
|
|
model_config.get_num_kv_heads = MethodType(
|
|
lambda self, parallel_config: 1, model_config
|
|
)
|
|
model_config.get_head_size = MethodType(lambda self: head_size, model_config)
|
|
model_config.get_sliding_window = MethodType(lambda self: None, model_config)
|
|
|
|
kv_cache_spec = create_standard_kv_cache_spec(vllm_config)
|
|
scale = 1.0 / math.sqrt(qk_nope_head_dim + qk_rope_head_dim)
|
|
|
|
torch.manual_seed(42)
|
|
|
|
W_UK = torch.rand(
|
|
kv_lora_rank, num_heads, qk_nope_head_dim, dtype=dtype, device=device
|
|
)
|
|
W_UV = torch.rand(kv_lora_rank, num_heads, v_head_dim, dtype=dtype, device=device)
|
|
|
|
seq_lens = batch_spec.seq_lens
|
|
query_lens = batch_spec.query_lens
|
|
|
|
# Compute dense reference outputs.
|
|
total_query_tokens = sum(query_lens)
|
|
sparse_indices = torch.full(
|
|
(total_query_tokens, topk_tokens), -1, dtype=torch.int32, device=device
|
|
)
|
|
|
|
all_q, all_kv_c_new, all_k_pe_new = [], [], []
|
|
kv_c_contexts, k_pe_contexts = [], []
|
|
reference_outputs = []
|
|
global_token_idx = 0
|
|
|
|
for i in range(batch_spec.batch_size):
|
|
s_len = seq_lens[i]
|
|
q_len = query_lens[i]
|
|
ctx_len = s_len - q_len
|
|
|
|
q_mha = torch.rand(
|
|
q_len,
|
|
num_heads,
|
|
qk_nope_head_dim + qk_rope_head_dim,
|
|
dtype=dtype,
|
|
device=device,
|
|
)
|
|
kv_c_full = torch.rand(s_len, kv_lora_rank, dtype=dtype, device=device)
|
|
k_pe_full = torch.rand(s_len, 1, qk_rope_head_dim, dtype=dtype, device=device)
|
|
|
|
# Decompress all KV for reference
|
|
kv_b_weight = torch.cat([W_UK, W_UV], dim=-1).view(
|
|
kv_lora_rank, num_heads * (qk_nope_head_dim + v_head_dim)
|
|
)
|
|
kv_decompressed = (kv_c_full @ kv_b_weight).view(
|
|
s_len, num_heads, qk_nope_head_dim + v_head_dim
|
|
)
|
|
k_nope_all, v_all = kv_decompressed.split(
|
|
[qk_nope_head_dim, v_head_dim], dim=-1
|
|
)
|
|
k_pe_expanded = k_pe_full.expand(-1, num_heads, -1)
|
|
k_all = torch.cat([k_nope_all, k_pe_expanded], dim=-1)
|
|
|
|
for j in range(q_len):
|
|
attend_end = ctx_len + j + 1
|
|
q_tok = q_mha[j : j + 1] # (1, H, D_qk)
|
|
if masked_mha:
|
|
actual_topk = min(topk_tokens, attend_end)
|
|
attend_indices = torch.randperm(attend_end, device=device)[:actual_topk]
|
|
sparse_indices[global_token_idx, :actual_topk] = attend_indices
|
|
k_attend = k_all[attend_indices]
|
|
v_attend = v_all[attend_indices]
|
|
else:
|
|
k_attend = k_all[:attend_end] # (N, H, D_qk)
|
|
v_attend = v_all[:attend_end] # (N, H, D_v)
|
|
|
|
q_sdpa = q_tok.unsqueeze(0).transpose(1, 2).float()
|
|
k_sdpa = k_attend.unsqueeze(0).transpose(1, 2).float()
|
|
v_sdpa = v_attend.unsqueeze(0).transpose(1, 2).float()
|
|
|
|
out = torch.nn.functional.scaled_dot_product_attention(
|
|
q_sdpa, k_sdpa, v_sdpa, scale=scale
|
|
)
|
|
out = out.transpose(1, 2).squeeze(0) # (1, H, D_v)
|
|
reference_outputs.append(out.to(dtype).flatten(start_dim=-2))
|
|
global_token_idx += 1
|
|
|
|
all_q.append(q_mha)
|
|
all_kv_c_new.append(kv_c_full[ctx_len:])
|
|
all_k_pe_new.append(k_pe_full[ctx_len:])
|
|
kv_c_contexts.append(kv_c_full)
|
|
k_pe_contexts.append(k_pe_full)
|
|
|
|
query_cat = torch.cat(all_q, dim=0)
|
|
kv_c_cat = torch.cat(all_kv_c_new, dim=0)
|
|
k_pe_cat = torch.cat(all_k_pe_new, dim=0)
|
|
ref_output = torch.cat(reference_outputs, dim=0)
|
|
|
|
vllm_config.cache_config.cache_dtype = kv_cache_dtype
|
|
vllm_config.model_config.hf_text_config.index_topk = topk_tokens
|
|
|
|
common_attn_metadata = create_common_attn_metadata(
|
|
batch_spec,
|
|
vllm_config.cache_config.block_size,
|
|
device,
|
|
arange_block_indices=True,
|
|
)
|
|
|
|
kv_cache = create_and_prepopulate_kv_cache(
|
|
kv_c_contexts=kv_c_contexts,
|
|
k_pe_contexts=k_pe_contexts,
|
|
block_size=block_size,
|
|
head_size=head_size,
|
|
dtype=dtype,
|
|
device=device,
|
|
num_blocks=vllm_config.cache_config.num_gpu_blocks,
|
|
common_attn_metadata=common_attn_metadata,
|
|
randomize_blocks=False,
|
|
kv_cache_dtype=kv_cache_dtype,
|
|
)
|
|
|
|
# The sparse builder clones the layer's dense-MHA prefill backend from
|
|
# static_forward_context; register a mock layer carrying one.
|
|
prefill_backend = get_mla_prefill_backend(vllm_config)(
|
|
num_heads=num_heads,
|
|
scale=scale,
|
|
kv_lora_rank=kv_lora_rank,
|
|
qk_nope_head_dim=qk_nope_head_dim,
|
|
qk_rope_head_dim=qk_rope_head_dim,
|
|
v_head_dim=v_head_dim,
|
|
vllm_config=vllm_config,
|
|
)
|
|
vllm_config.compilation_config.static_forward_context["placeholder"] = (
|
|
SimpleNamespace(prefill_backend=prefill_backend)
|
|
)
|
|
|
|
builder_cls = backend_cls.get_builder_cls()
|
|
builder = builder_cls(kv_cache_spec, ["placeholder"], vllm_config, device)
|
|
if batch_name == "masked_mha_chunked_context":
|
|
builder.chunked_prefill_workspace_size = block_size * batch_spec.batch_size
|
|
builder.chunked_prefill_workspace = torch.empty(
|
|
(builder.chunked_prefill_workspace_size, head_size),
|
|
dtype=dtype,
|
|
device=device,
|
|
)
|
|
# Drive the queries through the dense-MHA prefill path directly (the routing
|
|
# threshold would otherwise classify these short queries as MQA decodes).
|
|
builder.reorder_batch_threshold = 1
|
|
metadata = builder.build(
|
|
common_prefix_len=0, common_attn_metadata=common_attn_metadata
|
|
)
|
|
|
|
mock_indexer = SimpleNamespace(topk_indices_buffer=sparse_indices)
|
|
|
|
kv_b_proj_weight = torch.cat([W_UK, W_UV], dim=-1).view(
|
|
kv_lora_rank, num_heads * (qk_nope_head_dim + v_head_dim)
|
|
)
|
|
|
|
mock_kv_b_proj = ColumnParallelLinear(
|
|
input_size=kv_lora_rank,
|
|
output_size=num_heads * (qk_nope_head_dim + v_head_dim),
|
|
bias=False,
|
|
).to(device=device, dtype=dtype)
|
|
mock_kv_b_proj.weight = torch.nn.Parameter(kv_b_proj_weight.T.contiguous())
|
|
|
|
impl_cls = backend_cls.get_impl_cls()
|
|
with set_current_vllm_config(vllm_config):
|
|
impl = impl_cls(
|
|
num_heads=num_heads,
|
|
head_size=head_size,
|
|
scale=scale,
|
|
num_kv_heads=1,
|
|
alibi_slopes=None,
|
|
sliding_window=None,
|
|
kv_cache_dtype=kv_cache_dtype,
|
|
logits_soft_cap=None,
|
|
attn_type="decoder",
|
|
kv_sharing_target_layer_name=None,
|
|
q_lora_rank=None,
|
|
kv_lora_rank=kv_lora_rank,
|
|
qk_nope_head_dim=qk_nope_head_dim,
|
|
qk_rope_head_dim=qk_rope_head_dim,
|
|
qk_head_dim=qk_nope_head_dim + qk_rope_head_dim,
|
|
v_head_dim=v_head_dim,
|
|
kv_b_proj=mock_kv_b_proj,
|
|
indexer=mock_indexer,
|
|
)
|
|
impl.process_weights_after_loading(dtype)
|
|
|
|
out_buffer = torch.empty(
|
|
total_query_tokens, num_heads * v_head_dim, dtype=dtype, device=device
|
|
)
|
|
|
|
with torch.inference_mode():
|
|
impl.forward_mha(
|
|
q=query_cat,
|
|
kv_c_normed=kv_c_cat,
|
|
k_pe=k_pe_cat,
|
|
# Impls see the bind-time-squeezed [B, N, C] cache; mirror bind_kv_cache.
|
|
kv_c_and_k_pe_cache=kv_cache.squeeze(1),
|
|
attn_metadata=metadata,
|
|
k_scale=torch.tensor(1.0, device=device),
|
|
output=out_buffer,
|
|
)
|
|
|
|
assert out_buffer.shape == ref_output.shape
|
|
assert torch.isfinite(out_buffer).all(), "Non-finite values in output"
|
|
torch.testing.assert_close(out_buffer, ref_output, rtol=0.01, atol=0.01)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"seq_lens,query_lens,workspace_size,max_logits_bytes,expected",
|
|
[
|
|
(
|
|
torch.tensor([0]),
|
|
torch.tensor([0]),
|
|
100,
|
|
1000,
|
|
[],
|
|
),
|
|
# Logits constraint triggers split (M*N exceeds budget)
|
|
# req0: M=10, N=100 -> 1000 elems (4000 bytes) - fits in 5000
|
|
# req1: adding M=10, N=100 -> new_M=20, new_N=200 -> 4000 elems > 1250
|
|
(
|
|
torch.tensor([100, 100, 100]),
|
|
torch.tensor([10, 10, 10]),
|
|
1000, # workspace allows all
|
|
5000, # 1250 float32 elems -> forces split
|
|
[
|
|
(slice(0, 1), slice(0, 10)),
|
|
(slice(1, 2), slice(0, 10)),
|
|
(slice(2, 3), slice(0, 10)),
|
|
],
|
|
),
|
|
# Both constraints satisfied - all fit in one chunk
|
|
(
|
|
torch.tensor([10, 10, 10]),
|
|
torch.tensor([5, 5, 5]),
|
|
100,
|
|
10000, # 2500 elems, M*N = 15*30 = 450 < 2500
|
|
[(slice(0, 3), slice(0, 15))],
|
|
),
|
|
# Workspace constraint triggers first
|
|
(
|
|
torch.tensor([50, 50, 50]),
|
|
torch.tensor([1, 1, 1]),
|
|
50, # workspace only fits one at a time
|
|
1000000, # logits budget is huge
|
|
[
|
|
(slice(0, 1), slice(0, 1)),
|
|
(slice(1, 2), slice(0, 1)),
|
|
(slice(2, 3), slice(0, 1)),
|
|
],
|
|
),
|
|
# Greedy filling: first two fit, third doesn't
|
|
# req0: M=5, N=10 -> 50 elems
|
|
# req0+1: M=10, N=20 -> 200 elems <= 250
|
|
# req0+1+2: M=15, N=30 -> 450 elems > 250
|
|
(
|
|
torch.tensor([10, 10, 10]),
|
|
torch.tensor([5, 5, 5]),
|
|
100,
|
|
1000, # 250 elems
|
|
[(slice(0, 2), slice(0, 10)), (slice(2, 3), slice(0, 5))],
|
|
),
|
|
],
|
|
)
|
|
def test_split_indexer_prefill_chunks(
|
|
seq_lens, query_lens, workspace_size, max_logits_bytes, expected
|
|
):
|
|
out = DeepseekV32IndexerMetadataBuilder._split_indexer_prefill_chunks(
|
|
seq_lens,
|
|
query_lens,
|
|
workspace_size,
|
|
max_logits_bytes,
|
|
)
|
|
assert out == expected
|
|
|
|
|
|
def test_split_indexer_prefill_chunks_single_request_overflow():
|
|
"""Test that single request exceeding budget is sub-chunked on query dim."""
|
|
seq_lens = torch.tensor([1000, 50])
|
|
query_lens = torch.tensor([100, 5])
|
|
|
|
out = DeepseekV32IndexerMetadataBuilder._split_indexer_prefill_chunks(
|
|
seq_lens, query_lens, 2000, 1000
|
|
)
|
|
# max_logits_elems = 250, N=1000 -> max_q = 1 -> 100 query sub-chunks
|
|
expected = [(slice(0, 1), slice(i, i + 1)) for i in range(100)]
|
|
# req1: M=5, N=50 -> 250 elems fits budget
|
|
expected.append((slice(1, 2), slice(0, 5)))
|
|
assert out == expected
|
|
|
|
|
|
# 384 is not a power of two, so it counts via the tiled atomic accumulation
|
|
# rather than the single-tile path 128 takes.
|
|
@pytest.mark.parametrize("num_topk_tokens", [128, 384])
|
|
def test_triton_convert_returns_valid_counts(num_topk_tokens: int):
|
|
"""Test that return_valid_counts correctly counts non-negative indices."""
|
|
device = torch.device(DEVICE_TYPE)
|
|
num_tokens = 8
|
|
num_requests = 2
|
|
max_blocks_per_req = 10
|
|
block_size = 64
|
|
|
|
req_id = torch.tensor([0, 0, 0, 0, 1, 1, 1, 1], dtype=torch.int32, device=device)
|
|
block_table = torch.arange(
|
|
num_requests * max_blocks_per_req, dtype=torch.int32, device=device
|
|
).view(num_requests, max_blocks_per_req)
|
|
|
|
# Create token indices with varying numbers of valid entries: half the row,
|
|
# a quarter of it, the whole row, then a single valid entry -- twice over.
|
|
token_indices = torch.full(
|
|
(num_tokens, num_topk_tokens), -1, dtype=torch.int32, device=device
|
|
)
|
|
valid_counts_per_token = [
|
|
num_topk_tokens // 2,
|
|
num_topk_tokens // 4,
|
|
num_topk_tokens,
|
|
1,
|
|
] * 2
|
|
expected_valid = []
|
|
for i in range(num_tokens):
|
|
num_valid = valid_counts_per_token[i]
|
|
token_indices[i, :num_valid] = torch.arange(
|
|
num_valid, dtype=torch.int32, device=device
|
|
) % (block_size * max_blocks_per_req)
|
|
expected_valid.append(num_valid)
|
|
|
|
expected_valid_tensor = torch.tensor(
|
|
expected_valid, dtype=torch.int32, device=device
|
|
)
|
|
|
|
# Test with return_valid_counts=True
|
|
result, valid_counts = triton_convert_req_index_to_global_index(
|
|
req_id,
|
|
block_table,
|
|
token_indices,
|
|
BLOCK_SIZE=block_size,
|
|
NUM_TOPK_TOKENS=num_topk_tokens,
|
|
return_valid_counts=True,
|
|
)
|
|
|
|
torch.testing.assert_close(valid_counts, expected_valid_tensor, rtol=0, atol=0)
|
|
|
|
# Test that return_valid_counts=False returns only the indices
|
|
result_only = triton_convert_req_index_to_global_index(
|
|
req_id,
|
|
block_table,
|
|
token_indices,
|
|
BLOCK_SIZE=block_size,
|
|
NUM_TOPK_TOKENS=num_topk_tokens,
|
|
return_valid_counts=False,
|
|
)
|
|
assert isinstance(result_only, torch.Tensor)
|
|
for row, num_valid in enumerate(expected_valid):
|
|
compact_valid = result[row, :num_valid].sort().values
|
|
original_valid = result_only[row][result_only[row] >= 0].sort().values
|
|
torch.testing.assert_close(compact_valid, original_valid, rtol=0, atol=0)
|
|
assert torch.all(result[row, num_valid:] == -1)
|
|
|
|
|
|
def test_sparse_mla_index_group_converts_decode_indices_once(monkeypatch):
|
|
device = torch.device(DEVICE_TYPE)
|
|
logical = torch.full((2, 128), -1, dtype=torch.int32, device=device)
|
|
logical[:, :4] = torch.tensor(
|
|
[[0, 2, 5, -1], [1, 3, 7, -1]], dtype=torch.int32, device=device
|
|
)
|
|
builder = SparseMLAIndexGroupBuilder(logical)
|
|
group, leader_index = builder.register_layer(True)
|
|
follower_group, follower_index = builder.register_layer(False)
|
|
assert follower_group is group
|
|
|
|
block_table = torch.tensor([[2, 3], [4, 5]], dtype=torch.int32, device=device)
|
|
metadata = SimpleNamespace(
|
|
block_table=block_table,
|
|
block_size=4,
|
|
req_id_per_token=torch.tensor([0, 1], dtype=torch.int32, device=device),
|
|
num_decode_tokens=2,
|
|
num_actual_tokens=2,
|
|
)
|
|
calls = []
|
|
convert = index_group_module.triton_convert_req_index_to_global_index
|
|
|
|
def convert_spy(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return convert(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
index_group_module,
|
|
"triton_convert_req_index_to_global_index",
|
|
convert_spy,
|
|
)
|
|
group.set_logical_topk_ready(leader_index)
|
|
leader_result = group.convert_logical_to_physical_topk(
|
|
leader_index,
|
|
logical,
|
|
metadata,
|
|
block_stride_rows=None,
|
|
return_valid_counts=True,
|
|
)
|
|
follower_result = group.convert_logical_to_physical_topk(
|
|
follower_index,
|
|
logical,
|
|
metadata,
|
|
block_stride_rows=None,
|
|
return_valid_counts=True,
|
|
)
|
|
torch.accelerator.synchronize()
|
|
|
|
assert len(calls) == 1
|
|
leader_indices, leader_counts = leader_result
|
|
follower_indices, follower_counts = follower_result
|
|
torch.testing.assert_close(follower_indices, leader_indices)
|
|
torch.testing.assert_close(follower_counts, leader_counts)
|
|
|
|
|
|
def test_sparse_mla_index_group_falls_back_for_prefill_sized_batch(monkeypatch):
|
|
device = torch.device(DEVICE_TYPE)
|
|
logical = torch.full((4, 128), -1, dtype=torch.int32, device=device)
|
|
logical[:, 0] = torch.tensor([0, 1, 2, 3], dtype=torch.int32, device=device)
|
|
builder = SparseMLAIndexGroupBuilder(logical, max_decode_rows=2)
|
|
group, leader_index = builder.register_layer(True)
|
|
follower_group, follower_index = builder.register_layer(False)
|
|
assert follower_group is group
|
|
|
|
metadata = SimpleNamespace(
|
|
block_table=torch.tensor(
|
|
[[2], [3], [4], [5]], dtype=torch.int32, device=device
|
|
),
|
|
block_size=4,
|
|
req_id_per_token=torch.arange(4, dtype=torch.int32, device=device),
|
|
)
|
|
calls = 0
|
|
convert = index_group_module.triton_convert_req_index_to_global_index
|
|
|
|
def convert_spy(*args, **kwargs):
|
|
nonlocal calls
|
|
calls += 1
|
|
return convert(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
index_group_module,
|
|
"triton_convert_req_index_to_global_index",
|
|
convert_spy,
|
|
)
|
|
leader_result = group.convert_logical_to_physical_topk(
|
|
leader_index,
|
|
logical,
|
|
metadata,
|
|
block_stride_rows=None,
|
|
return_valid_counts=False,
|
|
)
|
|
follower_result = group.convert_logical_to_physical_topk(
|
|
follower_index,
|
|
logical,
|
|
metadata,
|
|
block_stride_rows=None,
|
|
return_valid_counts=False,
|
|
)
|
|
torch.accelerator.synchronize()
|
|
|
|
assert calls == 2
|
|
torch.testing.assert_close(follower_result, leader_result)
|
|
|
|
|
|
def test_sparse_mla_index_groups_own_distinct_physical_buffers():
|
|
logical = torch.empty((8, 128), dtype=torch.int32, device=DEVICE_TYPE)
|
|
builder = SparseMLAIndexGroupBuilder(logical, max_decode_rows=2)
|
|
|
|
first, _ = builder.register_layer(True)
|
|
follower, _ = builder.register_layer(False)
|
|
second, _ = builder.register_layer(True)
|
|
|
|
assert follower is first
|
|
assert second is not first
|
|
assert first.physical_topk_indices[:2].shape == (2, 128)
|
|
assert second.physical_topk_indices[:2].shape == (2, 128)
|
|
assert first.physical_topk_indices.data_ptr() != (
|
|
second.physical_topk_indices.data_ptr()
|
|
)
|
|
|
|
|
|
# HiSparse is host-resident-only and kernel-only: runtime construction
|
|
# raises without the compiled CUDA ops.
|
|
requires_hisparse_ops = pytest.mark.skipif(
|
|
not _has_hisparse_ops(),
|
|
reason="HiSparse CUDA ops not compiled",
|
|
)
|
|
|
|
|
|
def _make_hisparse_runtime(
|
|
*,
|
|
top_k: int = 4,
|
|
device_buffer_size: int = 5,
|
|
max_num_reqs: int = 2,
|
|
row_width: int = 8,
|
|
block_size: int = 64,
|
|
max_swap_rows: int | None = None,
|
|
index_group: hisparse_runtime.HiSparseIndexGroup | None = None,
|
|
) -> HiSparseRuntime:
|
|
runtime = HiSparseRuntime(
|
|
config=ResolvedHiSparseConfig(
|
|
top_k=top_k,
|
|
device_buffer_size=device_buffer_size,
|
|
),
|
|
max_num_reqs=max_num_reqs,
|
|
row_width=row_width,
|
|
kv_dtype=torch.float32,
|
|
device=DEVICE_TYPE,
|
|
max_swap_rows=max_swap_rows,
|
|
index_group=index_group,
|
|
)
|
|
blocks_per_request = cdiv(runtime.region_stride, block_size)
|
|
# Leave one extra physical block for resident-tier tests. HMA group layouts
|
|
# alias this backing, but distinct group allocations use distinct block IDs.
|
|
num_blocks = max_num_reqs * blocks_per_request + 1
|
|
raw = torch.zeros(
|
|
num_blocks * block_size * row_width,
|
|
dtype=torch.float32,
|
|
device=DEVICE_TYPE,
|
|
).view(torch.int8)
|
|
block_table = torch.arange(
|
|
max_num_reqs * blocks_per_request,
|
|
dtype=torch.int32,
|
|
device=DEVICE_TYPE,
|
|
).view(max_num_reqs, blocks_per_request)
|
|
runtime.bind_hot_cache(
|
|
raw,
|
|
byte_offset=0,
|
|
block_stride=block_size * row_width * torch.float32.itemsize,
|
|
num_blocks=num_blocks,
|
|
block_size=block_size,
|
|
block_table=block_table,
|
|
)
|
|
runtime.request_state_indices = torch.arange(
|
|
max_num_reqs, dtype=torch.int32, device=runtime.device
|
|
)
|
|
return runtime
|
|
|
|
|
|
def _make_hisparse_cache_handle(
|
|
*,
|
|
top_k: int = 4,
|
|
device_buffer_size: int = 5,
|
|
max_num_reqs: int = 2,
|
|
row_width: int = 8,
|
|
block_size: int = 64,
|
|
max_swap_rows: int | None = None,
|
|
) -> HiSparseCacheHandle:
|
|
runtime = _make_hisparse_runtime(
|
|
top_k=top_k,
|
|
device_buffer_size=device_buffer_size,
|
|
max_num_reqs=max_num_reqs,
|
|
row_width=row_width,
|
|
block_size=block_size,
|
|
max_swap_rows=max_swap_rows,
|
|
)
|
|
return HiSparseCacheHandle(runtime)
|
|
|
|
|
|
@requires_hisparse_ops
|
|
def test_hisparse_uses_graph_stable_request_state_mapping():
|
|
device = torch.device(DEVICE_TYPE)
|
|
block_size, row_width = 64, 8
|
|
runtime = _make_hisparse_runtime(
|
|
top_k=1,
|
|
device_buffer_size=2,
|
|
max_num_reqs=2,
|
|
row_width=row_width,
|
|
block_size=block_size,
|
|
)
|
|
runtime.bind_source_cache(
|
|
torch.arange(2 * block_size * row_width, dtype=torch.float32)
|
|
.view(2, block_size, row_width)
|
|
.pin_memory()
|
|
)
|
|
runtime.request_state_indices = torch.tensor([1], dtype=torch.int32, device=device)
|
|
|
|
cache_handle = HiSparseCacheHandle(runtime)
|
|
cache_handle.all_context_pages_resident = False
|
|
runtime.begin_forward()
|
|
cache_handle.swap_in(
|
|
req_id_per_token=torch.tensor([0], dtype=torch.int32, device=device),
|
|
block_table=torch.tensor([[1]], dtype=torch.int32, device=device),
|
|
logical_topk_indices=torch.tensor([[0]], dtype=torch.int32, device=device),
|
|
block_size=block_size,
|
|
)
|
|
torch.accelerator.synchronize()
|
|
|
|
assert (runtime.index_group.device_global_indices[0] == -1).all()
|
|
assert (runtime.index_group.device_global_indices[1] == block_size).any()
|
|
|
|
|
|
@requires_hisparse_ops
|
|
def test_hisparse_maps_speculative_rows_through_request_state():
|
|
"""Multiple verification rows for one request share its persistent state."""
|
|
device = torch.device(DEVICE_TYPE)
|
|
block_size, row_width = 64, 8
|
|
runtime = _make_hisparse_runtime(
|
|
top_k=1,
|
|
device_buffer_size=2,
|
|
max_num_reqs=1,
|
|
row_width=row_width,
|
|
block_size=block_size,
|
|
max_swap_rows=4,
|
|
)
|
|
runtime.bind_source_cache(
|
|
torch.zeros(1, block_size, row_width, dtype=torch.float32).pin_memory()
|
|
)
|
|
|
|
cache_handle = HiSparseCacheHandle(runtime)
|
|
resident_table = torch.tensor([[1]], dtype=torch.int32, device=device)
|
|
cache_handle.bind_cache(
|
|
runtime.hot.cache.view(torch.int8),
|
|
byte_offset=0,
|
|
block_stride=block_size * row_width * torch.float32.itemsize,
|
|
num_blocks=2,
|
|
block_size=block_size,
|
|
block_table=resident_table,
|
|
slot_mapping=torch.arange(4, dtype=torch.int64, device=device),
|
|
)
|
|
cache_handle.all_context_pages_resident = True
|
|
runtime.begin_forward()
|
|
physical = cache_handle.swap_in(
|
|
req_id_per_token=torch.zeros(4, dtype=torch.int32, device=device),
|
|
block_table=torch.zeros((1, 1), dtype=torch.int32, device=device),
|
|
logical_topk_indices=torch.zeros((4, 1), dtype=torch.int32, device=device),
|
|
block_size=block_size,
|
|
)
|
|
torch.accelerator.synchronize()
|
|
|
|
assert physical.tolist() == [[block_size]] * 4
|
|
|
|
|
|
@requires_hisparse_ops
|
|
def test_hisparse_resident_rows_bypass_hot_lru():
|
|
device = torch.device(DEVICE_TYPE)
|
|
block_size, row_width = 64, 8
|
|
cache_handle = _make_hisparse_cache_handle(
|
|
top_k=4,
|
|
device_buffer_size=5,
|
|
max_num_reqs=1,
|
|
row_width=row_width,
|
|
block_size=block_size,
|
|
)
|
|
raw = cache_handle.runtime.hot.cache.view(torch.int8)
|
|
resident_table = torch.tensor([[1]], dtype=torch.int32, device=device)
|
|
resident_slots = torch.tensor([block_size], dtype=torch.int64, device=device)
|
|
cache_handle.bind_cache(
|
|
raw,
|
|
byte_offset=0,
|
|
block_stride=block_size * row_width * torch.float32.itemsize,
|
|
num_blocks=2,
|
|
block_size=block_size,
|
|
block_table=resident_table,
|
|
slot_mapping=resident_slots,
|
|
)
|
|
|
|
host = torch.randn(1, block_size, row_width).pin_memory()
|
|
cache_handle.runtime.bind_source_cache(host)
|
|
resident_rows = host[0].to(device).add(7)
|
|
cache_handle.runtime.hot.cache[1].copy_(resident_rows)
|
|
source_table = torch.tensor([[5]], dtype=torch.int32, device=device)
|
|
request_ids = torch.zeros(1, dtype=torch.int32, device=device)
|
|
topk = torch.tensor([[1, 2, 3, 4]], dtype=torch.int32, device=device)
|
|
lru_before = cache_handle.runtime.index_group.lru_slots.clone()
|
|
|
|
cache_handle.runtime.begin_forward()
|
|
indices, valid_counts = cache_handle.swap_in(
|
|
req_id_per_token=request_ids,
|
|
block_table=source_table,
|
|
logical_topk_indices=topk,
|
|
block_size=block_size,
|
|
return_valid_counts=True,
|
|
)
|
|
torch.accelerator.synchronize()
|
|
|
|
expected_indices = topk + block_size
|
|
torch.testing.assert_close(indices, expected_indices)
|
|
torch.testing.assert_close(
|
|
valid_counts, torch.tensor([topk.shape[1]], dtype=torch.int32, device=device)
|
|
)
|
|
torch.testing.assert_close(cache_handle.runtime.index_group.lru_slots, lru_before)
|
|
torch.testing.assert_close(
|
|
cache_handle.runtime.hot.attention_cache.view(-1, row_width)[
|
|
indices.to(torch.long)
|
|
],
|
|
resident_rows[topk.to(torch.long)],
|
|
)
|
|
|
|
|
|
@requires_hisparse_ops
|
|
def test_hisparse_swap_in_preserves_rows_across_eviction():
|
|
device = torch.device(DEVICE_TYPE)
|
|
block_size = 64
|
|
row_width = 64
|
|
num_blocks = 64
|
|
top_k = 128
|
|
buf = 256
|
|
num_reqs = 4
|
|
|
|
kv_pool = torch.randn(
|
|
(num_blocks + 1, block_size, row_width), dtype=torch.float32
|
|
).pin_memory()
|
|
flat_pool = kv_pool.reshape(-1, row_width)
|
|
|
|
runtime = _make_hisparse_runtime(
|
|
top_k=top_k,
|
|
device_buffer_size=buf,
|
|
max_num_reqs=num_reqs,
|
|
row_width=row_width,
|
|
)
|
|
runtime.bind_source_cache(kv_pool)
|
|
cache = HiSparseCacheHandle(runtime)
|
|
cache.all_context_pages_resident = False
|
|
cache.runtime.begin_forward()
|
|
|
|
blocks_per_req = num_blocks // num_reqs
|
|
block_table = torch.arange(
|
|
1, num_blocks + 1, dtype=torch.int32, device=device
|
|
).view(num_reqs, blocks_per_req)
|
|
req_ids = torch.arange(num_reqs, dtype=torch.int32, device=device)
|
|
seq_len = blocks_per_req * block_size
|
|
base = torch.arange(top_k, dtype=torch.int32, device=device)
|
|
for step in range(3):
|
|
cache.runtime.begin_forward()
|
|
topk = torch.stack(
|
|
[(base + step * top_k + row * 17) % seq_len for row in range(num_reqs)]
|
|
)
|
|
topk[:, -1] = -1
|
|
|
|
hot_indices, valid_counts = cache.swap_in(
|
|
req_id_per_token=req_ids,
|
|
block_table=block_table,
|
|
logical_topk_indices=topk.clone(),
|
|
block_size=block_size,
|
|
return_valid_counts=True,
|
|
)
|
|
torch.accelerator.synchronize()
|
|
|
|
global_ref = _triton_convert_reference_impl(
|
|
req_ids, block_table, topk, block_size, top_k
|
|
)
|
|
valid = hot_indices >= 0
|
|
torch.testing.assert_close(
|
|
valid_counts,
|
|
(global_ref >= 0).sum(dim=1, dtype=torch.int32),
|
|
rtol=0,
|
|
atol=0,
|
|
)
|
|
gathered = cache.runtime.hot.attention_cache.reshape(-1, row_width)[
|
|
hot_indices[valid].to(torch.long)
|
|
].cpu()
|
|
expected = flat_pool[global_ref[valid].cpu().to(torch.long)]
|
|
torch.testing.assert_close(gathered, expected)
|
|
|
|
|
|
@requires_hisparse_ops
|
|
def test_hisparse_multi_step_swaps_match_independent():
|
|
"""The shared LRU must retain every speculative step until followers run."""
|
|
device = torch.device(DEVICE_TYPE)
|
|
torch.manual_seed(0)
|
|
block_size = 64
|
|
row_width = 64
|
|
num_blocks = 64
|
|
top_k = 128
|
|
buf = 3 * top_k
|
|
num_reqs = 4
|
|
|
|
kv_pool = torch.randn(
|
|
(num_blocks, block_size, row_width), dtype=torch.float32
|
|
).pin_memory()
|
|
|
|
def make(
|
|
index_group: hisparse_runtime.HiSparseIndexGroup | None = None,
|
|
) -> HiSparseCacheHandle:
|
|
runtime = _make_hisparse_runtime(
|
|
top_k=top_k,
|
|
max_num_reqs=num_reqs,
|
|
device_buffer_size=buf,
|
|
row_width=row_width,
|
|
block_size=block_size,
|
|
max_swap_rows=2 * num_reqs,
|
|
index_group=index_group,
|
|
)
|
|
runtime.bind_source_cache(kv_pool)
|
|
cache = HiSparseCacheHandle(runtime)
|
|
cache.all_context_pages_resident = False
|
|
return cache
|
|
|
|
blocks_per_req = num_blocks // num_reqs
|
|
block_table = torch.arange(num_blocks, dtype=torch.int32, device=device).view(
|
|
num_reqs, blocks_per_req
|
|
)
|
|
req_ids = torch.arange(num_reqs, dtype=torch.int32, device=device)
|
|
seq_len = blocks_per_req * block_size
|
|
producer = make()
|
|
shared = make(producer.runtime.index_group)
|
|
indep = make()
|
|
base = torch.arange(top_k, dtype=torch.int32, device=device)
|
|
for iteration in range(2):
|
|
producer.runtime.begin_forward()
|
|
shared.runtime.begin_forward()
|
|
indep.runtime.begin_forward()
|
|
topks = [
|
|
torch.stack(
|
|
[
|
|
(base + (iteration * 2 + step) * top_k + row * 17) % seq_len
|
|
for row in range(num_reqs)
|
|
]
|
|
)
|
|
for step in range(2)
|
|
]
|
|
kw = dict(
|
|
req_id_per_token=req_ids,
|
|
block_table=block_table,
|
|
block_size=block_size,
|
|
)
|
|
producer_indices = [
|
|
producer.swap_in(logical_topk_indices=topk.clone(), **kw) for topk in topks
|
|
]
|
|
independent_indices = [
|
|
indep.swap_in(logical_topk_indices=topk.clone(), **kw) for topk in topks
|
|
]
|
|
follower_indices = [
|
|
shared.swap_in(logical_topk_indices=topk.clone(), **kw) for topk in topks
|
|
]
|
|
torch.accelerator.synchronize()
|
|
|
|
for idx_full, idx_indep, idx_shared in zip(
|
|
producer_indices, independent_indices, follower_indices
|
|
):
|
|
torch.testing.assert_close(idx_shared, idx_full, rtol=0, atol=0)
|
|
torch.testing.assert_close(idx_indep, idx_full, rtol=0, atol=0)
|
|
torch.testing.assert_close(shared.runtime.hot.cache, indep.runtime.hot.cache)
|
|
|
|
shared_rows = shared.runtime.hot.attention_cache.reshape(-1, row_width)
|
|
host_rows = kv_pool.reshape(-1, row_width)
|
|
for physical_rows, logical_rows in zip(follower_indices, topks):
|
|
global_rows = _triton_convert_reference_impl(
|
|
req_ids, block_table, logical_rows, block_size, top_k
|
|
)
|
|
valid = physical_rows >= 0
|
|
torch.testing.assert_close(
|
|
shared_rows[physical_rows[valid].to(torch.long)].cpu(),
|
|
host_rows[global_rows[valid].cpu().to(torch.long)],
|
|
)
|
|
|
|
|
|
@requires_hisparse_ops
|
|
def test_hisparse_multi_step_writes_request_major_output():
|
|
device = torch.device(DEVICE_TYPE)
|
|
block_size = 64
|
|
row_width = 8
|
|
num_reqs = 2
|
|
query_len = 3
|
|
top_k = 4
|
|
cache = _make_hisparse_cache_handle(
|
|
top_k=top_k,
|
|
device_buffer_size=query_len * top_k,
|
|
max_num_reqs=num_reqs,
|
|
row_width=row_width,
|
|
block_size=block_size,
|
|
max_swap_rows=num_reqs * query_len,
|
|
)
|
|
host = torch.arange(
|
|
(num_reqs + 1) * block_size * row_width, dtype=torch.float32
|
|
).view(num_reqs + 1, block_size, row_width)
|
|
cache.runtime.bind_source_cache(host.pin_memory())
|
|
block_table = torch.arange(1, num_reqs + 1, dtype=torch.int32, device=device).view(
|
|
-1, 1
|
|
)
|
|
request_ids = torch.arange(num_reqs, dtype=torch.int32, device=device)
|
|
logical = torch.tensor(
|
|
[
|
|
[[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]],
|
|
[[12, 13, 14, 15], [16, 17, 18, 19], [20, 21, 22, 23]],
|
|
],
|
|
dtype=torch.int32,
|
|
device=device,
|
|
)
|
|
physical = torch.full_like(logical, -1)
|
|
valid_counts = torch.zeros((num_reqs, query_len), dtype=torch.int32, device=device)
|
|
|
|
cache.runtime.begin_forward()
|
|
for step in range(query_len):
|
|
cache.swap_in(
|
|
request_ids,
|
|
block_table,
|
|
logical[:, step],
|
|
block_size=block_size,
|
|
return_valid_counts=True,
|
|
attention_indices_out=physical[:, step],
|
|
valid_counts_out=valid_counts[:, step],
|
|
)
|
|
torch.accelerator.synchronize()
|
|
|
|
torch.testing.assert_close(
|
|
valid_counts,
|
|
torch.full_like(valid_counts, top_k),
|
|
)
|
|
global_rows = _triton_convert_reference_impl(
|
|
torch.arange(num_reqs, device=device, dtype=torch.int32).repeat_interleave(
|
|
query_len
|
|
),
|
|
block_table,
|
|
logical.view(num_reqs * query_len, top_k),
|
|
block_size,
|
|
top_k,
|
|
)
|
|
hot_rows = cache.runtime.hot.attention_cache.reshape(-1, row_width)
|
|
host_rows = host.reshape(-1, row_width)
|
|
torch.testing.assert_close(
|
|
hot_rows[physical.view(num_reqs * query_len, top_k).to(torch.long)].cpu(),
|
|
host_rows[global_rows.cpu().to(torch.long)],
|
|
)
|
|
|
|
|
|
@requires_hisparse_ops
|
|
def test_hisparse_kv_update_writes_resident_and_staging_caches():
|
|
device = torch.device(DEVICE_TYPE)
|
|
block_size = 4
|
|
row_width = 8
|
|
cache_handle = _make_hisparse_cache_handle(
|
|
max_num_reqs=1,
|
|
row_width=row_width,
|
|
block_size=block_size,
|
|
)
|
|
resident_slots = torch.tensor([4, 5, -1], dtype=torch.int64, device=device)
|
|
cache_handle.bind_cache(
|
|
cache_handle.runtime.hot.cache.view(torch.int8),
|
|
byte_offset=0,
|
|
block_stride=block_size * row_width * torch.float32.itemsize,
|
|
num_blocks=cache_handle.runtime.hot.cache.shape[0],
|
|
block_size=block_size,
|
|
block_table=torch.tensor([[1]], dtype=torch.int32, device=device),
|
|
slot_mapping=resident_slots,
|
|
)
|
|
cache_handle.mirror_staging_cache = torch.empty(
|
|
(1, block_size, row_width), dtype=torch.float32, device=device
|
|
)
|
|
cache_handle.mirror_staging_slots = torch.arange(
|
|
block_size, dtype=torch.int64, device=device
|
|
)
|
|
slots = torch.tensor([3, 7, -1], dtype=torch.int64, device=device)
|
|
kv_c = torch.randn(8, row_width - 2, device=device)
|
|
k_pe = torch.randn(8, 1, 2, device=device)
|
|
cache_handle.num_actual_tokens = slots.numel()
|
|
cache_handle.decode_batch = False
|
|
cache_handle.host_mirror_required = True
|
|
source_cache = torch.zeros_like(cache_handle.view.cache)
|
|
impl = object.__new__(FlashMLASparseImpl)
|
|
layer = SimpleNamespace(
|
|
hisparse_cache=cache_handle,
|
|
use_pcp=False,
|
|
impl=impl,
|
|
)
|
|
mla_attention.MLAAttention.update_kv_cache(
|
|
layer,
|
|
kv_c,
|
|
k_pe,
|
|
source_cache,
|
|
slots,
|
|
SimpleNamespace(num_decode_tokens=0),
|
|
"auto",
|
|
torch.tensor(1.0, device=device),
|
|
)
|
|
torch.accelerator.synchronize()
|
|
|
|
expected = torch.cat([kv_c[:2], k_pe[:2, 0]], dim=-1).cpu()
|
|
torch.testing.assert_close(
|
|
cache_handle.view.cache.view(-1, row_width)[resident_slots[:2]],
|
|
expected.to(device),
|
|
)
|
|
torch.testing.assert_close(source_cache, torch.zeros_like(source_cache))
|
|
staged = cache_handle.mirror_staging_cache.view(-1, row_width)
|
|
staged_expected = torch.cat([kv_c[:3], k_pe[:3, 0]], dim=-1)
|
|
torch.testing.assert_close(staged[:3], staged_expected)
|
|
|
|
|
|
@requires_hisparse_ops
|
|
def test_hisparse_row_dma_copies_discontiguous_spans_across_layers():
|
|
device = torch.device(DEVICE_TYPE)
|
|
block_size = 4
|
|
row_width = 8
|
|
num_rows = 16
|
|
resident_caches = tuple(
|
|
(
|
|
torch.arange(num_rows * row_width, dtype=torch.uint8, device=device)
|
|
.add_(layer_index * 17)
|
|
.view(num_rows // block_size, block_size, row_width)
|
|
)
|
|
for layer_index in range(2)
|
|
)
|
|
host_caches = tuple(
|
|
torch.zeros((num_rows, row_width), dtype=torch.uint8, pin_memory=True)
|
|
for _ in resident_caches
|
|
)
|
|
worker = object.__new__(HiSparseConnectorWorker)
|
|
worker.is_host_writer = True
|
|
worker.kernel_block_size = block_size
|
|
worker.resident_caches = resident_caches
|
|
worker.host_caches = host_caches
|
|
worker.cache_handles = [
|
|
SimpleNamespace(
|
|
decode_batch=True,
|
|
runtime=SimpleNamespace(resident_source_index=0),
|
|
)
|
|
for _ in resident_caches
|
|
]
|
|
worker.dma_stream = torch.cuda.Stream(device=device)
|
|
worker.host_write_event = torch.Event()
|
|
worker._dma_free_descriptors = []
|
|
worker._pending_dma_descriptors = deque()
|
|
worker._pending_transfer_events = deque()
|
|
worker._enqueued_transfer_ids = []
|
|
worker._dma_submitted = False
|
|
worker._set_row_mirrors(
|
|
(
|
|
SparseKVRowMirror((1,), 5, 2),
|
|
SparseKVRowMirror((8,), 10, 4),
|
|
)
|
|
)
|
|
|
|
worker._enqueue_row_dma(range(2))
|
|
current_stream().wait_event(worker.host_write_event)
|
|
torch.accelerator.synchronize()
|
|
|
|
for resident, host in zip(resident_caches, host_caches):
|
|
flat_resident = resident.view(num_rows, row_width).cpu()
|
|
torch.testing.assert_close(host[5:7], flat_resident[1:3])
|
|
torch.testing.assert_close(host[10:14], flat_resident[8:12])
|
|
|
|
|
|
@requires_hisparse_ops
|
|
def test_hisparse_remaps_strided_hma_rows_for_attention():
|
|
device = torch.device(DEVICE_TYPE)
|
|
block_size, row_width = 4, 8
|
|
runtime = _make_hisparse_runtime(
|
|
max_num_reqs=1,
|
|
block_size=block_size,
|
|
row_width=row_width,
|
|
)
|
|
page_elements = block_size * row_width
|
|
stride_elements = 3 * page_elements
|
|
byte_offset = page_elements * torch.float32.itemsize
|
|
raw = torch.zeros(2 * stride_elements, dtype=torch.float32, device=device).view(
|
|
torch.int8
|
|
)
|
|
runtime.bind_hot_cache(
|
|
raw,
|
|
byte_offset=byte_offset,
|
|
block_stride=stride_elements * torch.float32.itemsize,
|
|
num_blocks=2,
|
|
block_size=block_size,
|
|
block_table=torch.tensor([[0, 1]], dtype=torch.int32, device=device),
|
|
)
|
|
|
|
source = (
|
|
torch.arange(36 * row_width, dtype=torch.float32)
|
|
.view(9, block_size, row_width)
|
|
.pin_memory()
|
|
)
|
|
runtime.bind_source_cache(source)
|
|
cache = HiSparseCacheHandle(runtime)
|
|
cache.all_context_pages_resident = False
|
|
runtime.begin_forward()
|
|
attention_indices = cache.swap_in(
|
|
req_id_per_token=torch.tensor([0], dtype=torch.int32, device=device),
|
|
block_table=torch.arange(1, 9, dtype=torch.int32, device=device).view(1, 8),
|
|
logical_topk_indices=torch.tensor(
|
|
[[0, 1, 2, 3]], dtype=torch.int32, device=device
|
|
),
|
|
block_size=block_size,
|
|
)
|
|
|
|
assert runtime.hot.attention_cache is not None
|
|
assert runtime.hot.attention_cache.is_contiguous()
|
|
physical_indices = runtime.index_group.shared_topk.device_topk_rows[0]
|
|
expected = (
|
|
physical_indices // block_size * (stride_elements // row_width)
|
|
+ physical_indices % block_size
|
|
)
|
|
torch.testing.assert_close(attention_indices[0], expected)
|
|
|
|
|
|
@requires_hisparse_ops
|
|
def test_hisparse_gather_zeroes_unaligned_destination():
|
|
"""Invalid host rows are safely zeroed even at a byte-unaligned address."""
|
|
device = torch.device(DEVICE_TYPE)
|
|
row_bytes = 16
|
|
host_cache = torch.ones(1, row_bytes, dtype=torch.uint8).pin_memory()
|
|
storage = torch.full((2 * row_bytes + 1,), 7, dtype=torch.uint8, device=device)
|
|
hot_cache = torch.as_strided(
|
|
storage,
|
|
size=(2, 1, row_bytes),
|
|
stride=(row_bytes + 1, row_bytes, 1),
|
|
)
|
|
global_indices = torch.tensor([[1]], dtype=torch.int32, device=device)
|
|
hot_indices = torch.tensor([[1]], dtype=torch.int32, device=device)
|
|
miss_mask = torch.ones_like(global_indices)
|
|
|
|
torch.ops._C_cache_ops.hisparse_gather_plan(
|
|
host_cache,
|
|
hot_cache,
|
|
global_indices,
|
|
hot_indices,
|
|
miss_mask,
|
|
None,
|
|
)
|
|
torch.accelerator.synchronize()
|
|
|
|
torch.testing.assert_close(hot_cache[1, 0], torch.zeros_like(hot_cache[1, 0]))
|
|
assert storage[row_bytes].item() == 7
|
|
|
|
|
|
@requires_hisparse_ops
|
|
def test_hisparse_gather_rejects_short_attention_block_stride():
|
|
"""Reject a stride that would let the gather kernel cross block bounds."""
|
|
device = torch.device(DEVICE_TYPE)
|
|
host_cache = torch.ones(1, 16, dtype=torch.uint8).pin_memory()
|
|
hot_cache = torch.empty((1, 2, 16), dtype=torch.uint8, device=device)
|
|
global_indices = torch.zeros((1, 1), dtype=torch.int32, device=device)
|
|
hot_indices = torch.zeros_like(global_indices)
|
|
miss_mask = torch.ones_like(global_indices)
|
|
attention_indices = torch.empty_like(global_indices)
|
|
|
|
with pytest.raises(RuntimeError, match="stride must cover one hot block"):
|
|
torch.ops._C_cache_ops.hisparse_gather_plan(
|
|
host_cache,
|
|
hot_cache,
|
|
global_indices,
|
|
hot_indices,
|
|
miss_mask,
|
|
None,
|
|
attention_indices,
|
|
1,
|
|
)
|
|
|
|
|
|
@requires_hisparse_ops
|
|
def test_hisparse_newest_write_and_recycled_slot_invalidation():
|
|
"""Newest writes clamp padding and invalidated rows are loaded again."""
|
|
device = torch.device(DEVICE_TYPE)
|
|
block_size = 4
|
|
row_width = 8
|
|
num_blocks = 8
|
|
|
|
kv_pool = (
|
|
torch.arange(num_blocks * block_size * row_width, dtype=torch.float32)
|
|
.view(num_blocks, block_size, row_width)
|
|
.pin_memory()
|
|
)
|
|
flat_pool = kv_pool.reshape(-1, row_width)
|
|
|
|
cache_handle = _make_hisparse_cache_handle(block_size=block_size)
|
|
cache_handle.runtime.eager_host_mirror = True
|
|
|
|
block_table = torch.tensor([[2, 0, 4]], dtype=torch.int32, device=device)
|
|
resident_slots = torch.tensor([4, -1], dtype=torch.int64, device=device)
|
|
cache_handle.bind_cache(
|
|
cache_handle.runtime.hot.cache.view(torch.int8),
|
|
byte_offset=0,
|
|
block_stride=block_size * row_width * torch.float32.itemsize,
|
|
num_blocks=cache_handle.runtime.hot.cache.shape[0],
|
|
block_size=block_size,
|
|
block_table=torch.tensor([[0, 0, 1]], dtype=torch.int32, device=device),
|
|
slot_mapping=resident_slots,
|
|
)
|
|
req_ids = torch.tensor([0], dtype=torch.int32, device=device)
|
|
newest_global = 4 * block_size
|
|
padded_global = 1
|
|
padded_row = flat_pool[padded_global].clone()
|
|
slot_mapping = torch.tensor(
|
|
[newest_global, padded_global], dtype=torch.int64, device=device
|
|
)
|
|
request_state_indices = torch.tensor([0, -1], dtype=torch.int32, device=device)
|
|
cache_handle.runtime.request_state_indices = request_state_indices
|
|
|
|
kv_c = torch.randn(3, row_width - 2, device=device)
|
|
k_pe = torch.randn(3, 1, 2, device=device)
|
|
cache_handle.runtime.bind_source_cache(kv_pool)
|
|
cache_handle.num_actual_tokens = slot_mapping.numel()
|
|
ops.concat_and_cache_mla(
|
|
kv_c[:1],
|
|
k_pe[:1, 0],
|
|
cache_handle.view.cache,
|
|
resident_slots[:1],
|
|
kv_cache_dtype="auto",
|
|
scale=torch.tensor(1.0, device=device),
|
|
)
|
|
torch.accelerator.synchronize()
|
|
expected_row = torch.cat([kv_c[0], k_pe[0, 0]]).cpu()
|
|
flat_pool[newest_global].copy_(expected_row)
|
|
torch.testing.assert_close(flat_pool[newest_global], expected_row)
|
|
torch.testing.assert_close(flat_pool[padded_global], padded_row)
|
|
flat_hot = cache_handle.runtime.hot.cache.view(-1, row_width)
|
|
torch.testing.assert_close(flat_hot[resident_slots[0]].cpu(), expected_row)
|
|
|
|
topk = torch.tensor([[0, -1, -1, -1]], dtype=torch.int32, device=device)
|
|
cache_handle.all_context_pages_resident = False
|
|
cache_handle.runtime.begin_forward()
|
|
hot_indices = cache_handle.swap_in(
|
|
req_id_per_token=req_ids,
|
|
block_table=block_table,
|
|
logical_topk_indices=topk,
|
|
block_size=block_size,
|
|
)
|
|
torch.accelerator.synchronize()
|
|
stale_hot_slot = hot_indices[0, 0].item()
|
|
stale_row = flat_hot[stale_hot_slot].clone()
|
|
|
|
flat_pool[8] += 1000
|
|
cache_handle.runtime.invalidate_written_slots(
|
|
torch.tensor([8], dtype=torch.int64, device=device), req_ids
|
|
)
|
|
cache_handle.runtime.begin_forward()
|
|
hot_indices = cache_handle.swap_in(
|
|
req_id_per_token=req_ids,
|
|
block_table=block_table,
|
|
logical_topk_indices=topk,
|
|
block_size=block_size,
|
|
)
|
|
torch.accelerator.synchronize()
|
|
idx = hot_indices.cpu().tolist()[0][0]
|
|
assert not torch.equal(flat_hot[idx], stale_row)
|
|
torch.testing.assert_close(flat_hot[idx].cpu(), flat_pool[8])
|
|
|
|
|
|
@requires_hisparse_ops
|
|
def test_hisparse_mixed_batch_bf16_row_split(
|
|
default_vllm_config, dist_init, workspace_init
|
|
):
|
|
"""Host-resident mixed batch on the bf16 path is row-split.
|
|
|
|
Two long-context speculative-decode requests + one short local-prefill
|
|
chunk: every decode step must be served from the bounded hot buffer before
|
|
it is reused, while only the prefill rows' blocks are staged host->GPU.
|
|
"""
|
|
ok, reason = flashmla.is_flashmla_sparse_supported()
|
|
if not ok:
|
|
pytest.skip(reason)
|
|
|
|
device = torch.device(DEVICE_TYPE)
|
|
dtype = torch.bfloat16
|
|
torch.manual_seed(0)
|
|
|
|
num_heads = 64
|
|
kv_lora_rank = 512
|
|
qk_nope_head_dim = 128
|
|
qk_rope_head_dim = 64
|
|
v_head_dim = 128
|
|
head_size = kv_lora_rank + qk_rope_head_dim
|
|
topk_tokens = 128
|
|
block_size = 64
|
|
|
|
# Long decode contexts + a short prefill chunk (router shortcut shape).
|
|
batch_spec = BatchSpec(seq_lens=[2048, 2048, 192], query_lens=[2, 2, 64])
|
|
max_seqlen = max(batch_spec.seq_lens)
|
|
total_cache_tokens = sum(batch_spec.seq_lens)
|
|
total_tokens = batch_spec.compute_num_tokens()
|
|
|
|
vllm_config = create_vllm_config(
|
|
model_name="deepseek-ai/DeepSeek-V2-Lite-Chat",
|
|
tensor_parallel_size=1,
|
|
max_model_len=max_seqlen,
|
|
num_gpu_blocks=max(2048, cdiv(total_cache_tokens, block_size) + 1),
|
|
block_size=block_size,
|
|
hf_config_override={
|
|
"index_topk": topk_tokens,
|
|
"attn_module_list_cfg": [{"topk_tokens": topk_tokens}],
|
|
},
|
|
)
|
|
vllm_config.attention_config.hisparse_config = HiSparseConfig(
|
|
device_buffer_size=2 * topk_tokens,
|
|
)
|
|
vllm_config.speculative_config = SpeculativeConfig(
|
|
method="ngram", num_speculative_tokens=1
|
|
)
|
|
model_config = vllm_config.model_config
|
|
model_config.hf_text_config = SimpleNamespace(
|
|
index_topk=topk_tokens,
|
|
q_lora_rank=None,
|
|
kv_lora_rank=kv_lora_rank,
|
|
qk_nope_head_dim=qk_nope_head_dim,
|
|
qk_rope_head_dim=qk_rope_head_dim,
|
|
v_head_dim=v_head_dim,
|
|
model_type="deepseek_v2",
|
|
)
|
|
model_config.dtype = dtype
|
|
model_config.get_num_attention_heads = MethodType(
|
|
lambda self, parallel_config: num_heads, model_config
|
|
)
|
|
model_config.get_num_kv_heads = MethodType(
|
|
lambda self, parallel_config: 1, model_config
|
|
)
|
|
model_config.get_head_size = MethodType(lambda self: head_size, model_config)
|
|
model_config.get_sliding_window = MethodType(lambda self: None, model_config)
|
|
# create_hisparse_cache_handle sizes hot buffers per layer.
|
|
model_config.get_num_layers = MethodType(
|
|
lambda self, parallel_config: 1, model_config
|
|
)
|
|
|
|
kv_cache_spec = create_standard_kv_cache_spec(vllm_config)
|
|
common_attn_metadata = create_common_attn_metadata(
|
|
batch_spec,
|
|
vllm_config.cache_config.block_size,
|
|
device,
|
|
arange_block_indices=True,
|
|
)
|
|
# Prepopulate every position of every sequence so the forward needs no
|
|
# KV-cache update (the row split itself is what is under test).
|
|
kv_c_contexts = [
|
|
torch.rand(s_len, kv_lora_rank, dtype=dtype, device=device)
|
|
for s_len in batch_spec.seq_lens
|
|
]
|
|
k_pe_contexts = [
|
|
torch.rand(s_len, 1, qk_rope_head_dim, dtype=dtype, device=device)
|
|
for s_len in batch_spec.seq_lens
|
|
]
|
|
kv_cache = create_and_prepopulate_kv_cache(
|
|
kv_c_contexts=kv_c_contexts,
|
|
k_pe_contexts=k_pe_contexts,
|
|
block_size=vllm_config.cache_config.block_size,
|
|
head_size=head_size,
|
|
dtype=dtype,
|
|
device=device,
|
|
num_blocks=vllm_config.cache_config.num_gpu_blocks,
|
|
common_attn_metadata=common_attn_metadata,
|
|
randomize_blocks=False,
|
|
kv_cache_dtype="auto",
|
|
)
|
|
|
|
prefill_backend = get_mla_prefill_backend(vllm_config)(
|
|
num_heads=num_heads,
|
|
scale=1.0 / math.sqrt(head_size),
|
|
kv_lora_rank=kv_lora_rank,
|
|
qk_nope_head_dim=qk_nope_head_dim,
|
|
qk_rope_head_dim=qk_rope_head_dim,
|
|
v_head_dim=v_head_dim,
|
|
vllm_config=vllm_config,
|
|
)
|
|
vllm_config.compilation_config.static_forward_context["placeholder"] = (
|
|
SimpleNamespace(prefill_backend=prefill_backend)
|
|
)
|
|
|
|
builder_cls = FlashMLASparseBackend.get_builder_cls()
|
|
builder = builder_cls(kv_cache_spec, ["placeholder"], vllm_config, device)
|
|
metadata = builder.build(
|
|
common_prefix_len=0, common_attn_metadata=common_attn_metadata
|
|
)
|
|
assert isinstance(metadata.prefill, SparseMLAPrefillMetadata)
|
|
|
|
# Per-token sparse indices bounded by each token's position, with unique
|
|
# offsets and -1 padding (same construction as the decode parity test).
|
|
positions: list[int] = []
|
|
for i in range(batch_spec.batch_size):
|
|
ctx_len = batch_spec.seq_lens[i] - batch_spec.query_lens[i]
|
|
positions.extend(ctx_len + q_idx for q_idx in range(batch_spec.query_lens[i]))
|
|
sparse_indices = torch.empty(
|
|
total_tokens, topk_tokens, dtype=torch.int32, device=device
|
|
)
|
|
for tok_idx in range(total_tokens):
|
|
max_valid_idx = positions[tok_idx]
|
|
offset = tok_idx * 7 # Prime number for varied offsets
|
|
num_valid = min(topk_tokens // 2, max_valid_idx + 1)
|
|
valid_range = torch.arange(num_valid, device=device, dtype=torch.int32)
|
|
sparse_indices[tok_idx] = torch.cat(
|
|
[
|
|
(valid_range + offset) % (max_valid_idx + 1),
|
|
torch.full(
|
|
(topk_tokens - num_valid,), -1, device=device, dtype=torch.int32
|
|
),
|
|
]
|
|
)
|
|
|
|
q = torch.rand(total_tokens, num_heads, head_size, dtype=dtype, device=device)
|
|
mock_indexer = SimpleNamespace(
|
|
topk_indices_buffer=sparse_indices, topk_tokens=topk_tokens
|
|
)
|
|
|
|
impl_cls = FlashMLASparseBackend.get_impl_cls()
|
|
with set_current_vllm_config(vllm_config):
|
|
impl = impl_cls(
|
|
num_heads=num_heads,
|
|
head_size=head_size,
|
|
scale=1.0 / math.sqrt(head_size),
|
|
num_kv_heads=1,
|
|
alibi_slopes=None,
|
|
sliding_window=None,
|
|
kv_cache_dtype="auto",
|
|
logits_soft_cap=None,
|
|
attn_type="decoder",
|
|
kv_sharing_target_layer_name=None,
|
|
q_lora_rank=None,
|
|
kv_lora_rank=kv_lora_rank,
|
|
qk_nope_head_dim=qk_nope_head_dim,
|
|
qk_rope_head_dim=qk_rope_head_dim,
|
|
qk_head_dim=qk_nope_head_dim + qk_rope_head_dim,
|
|
v_head_dim=v_head_dim,
|
|
kv_b_proj=None,
|
|
indexer=mock_indexer,
|
|
)
|
|
assert isinstance(impl.index_group, HiSparseMLAIndexGroup)
|
|
cache_handle = impl.index_group.cache(impl.index_group_index)
|
|
blocks_per_request = cdiv(cache_handle.runtime.region_stride, block_size)
|
|
num_hot_blocks = cache_handle.runtime.max_num_reqs * blocks_per_request
|
|
hot_cache = torch.zeros(
|
|
num_hot_blocks * block_size * head_size,
|
|
dtype=dtype,
|
|
device=device,
|
|
).view(torch.int8)
|
|
cache_handle.runtime.bind_hot_cache(
|
|
hot_cache,
|
|
byte_offset=0,
|
|
block_stride=block_size * head_size * dtype.itemsize,
|
|
num_blocks=num_hot_blocks,
|
|
block_size=block_size,
|
|
block_table=torch.arange(num_hot_blocks, dtype=torch.int32, device=device).view(
|
|
cache_handle.runtime.max_num_reqs, blocks_per_request
|
|
),
|
|
)
|
|
cache_handle.runtime.request_state_indices = torch.arange(
|
|
cache_handle.runtime.max_num_reqs, dtype=torch.int32, device=device
|
|
)
|
|
impl.prepare_for_batch(metadata)
|
|
|
|
# Device-resident reference: the whole batch converted against the full
|
|
# block table and run as one kernel call over the GPU cache.
|
|
ref_topk, ref_topk_length = triton_convert_req_index_to_global_index(
|
|
metadata.req_id_per_token,
|
|
metadata.block_table,
|
|
sparse_indices,
|
|
BLOCK_SIZE=metadata.block_size,
|
|
NUM_TOPK_TOKENS=topk_tokens,
|
|
return_valid_counts=True,
|
|
)
|
|
reference, _ = impl._bf16_flash_mla_kernel(q, kv_cache, ref_topk, ref_topk_length)
|
|
|
|
# Host-resident pool with identical contents.
|
|
kv_pool = kv_cache.squeeze(1).cpu().pin_memory()
|
|
cache_handle.runtime.bind_source_cache(kv_pool)
|
|
cache_handle.source_block_table = metadata.block_table
|
|
|
|
staging_calls = []
|
|
original_gather = cache_handle.runtime.gather_prefill_cache
|
|
|
|
def spy_gather(self, kv, plan, resident_cache=None):
|
|
staged = original_gather(kv, plan, resident_cache)
|
|
staging_calls.append((plan, staged.shape))
|
|
return staged
|
|
|
|
cache_handle.runtime.gather_prefill_cache = MethodType(
|
|
spy_gather, cache_handle.runtime
|
|
)
|
|
|
|
backend_output, _ = impl._forward_bf16_kv(
|
|
q, kv_pool, sparse_indices, metadata, q.shape[1]
|
|
)
|
|
torch.accelerator.synchronize()
|
|
|
|
# Only the prefill rows' blocks were staged: the decode rows' 2048-token
|
|
# contexts (32 blocks each) must stay off the staging gather.
|
|
assert len(staging_calls) == 1
|
|
plan, staged_shape = staging_calls[0]
|
|
assert plan is metadata.prefill.host_staging_plan
|
|
prefill_blocks = cdiv(batch_spec.seq_lens[-1], block_size)
|
|
assert staged_shape[0] <= prefill_blocks + 1 # +1: block-0 tail padding
|
|
|
|
torch.testing.assert_close(backend_output, reference, rtol=0.01, atol=0.01)
|
|
|
|
|
|
def test_hisparse_prefill_staging_remap():
|
|
"""Compacted staging references the same host rows as direct indexing."""
|
|
block_size = 4
|
|
block_table = torch.tensor(
|
|
[[5, 2, -1, -1], [2, 7, 3, -1], [9, -1, -1, -1]], dtype=torch.int32
|
|
)
|
|
|
|
new_bt, row_ids = hisparse_prefill_staging_remap(block_table, block_size)
|
|
|
|
assert new_bt.shape == block_table.shape
|
|
assert row_ids.dtype == torch.int32 and new_bt.dtype == torch.int32
|
|
n_unique = len({0, 2, 3, 5, 7, 9}) # -1 clamps to block 0
|
|
flat_rows = row_ids.flatten()
|
|
assert (flat_rows >= 0).sum() == n_unique * block_size
|
|
for i in range(block_table.shape[0]):
|
|
for j in range(block_table.shape[1]):
|
|
orig = max(int(block_table[i, j]), 0)
|
|
staged = int(new_bt[i, j])
|
|
for k in range(block_size):
|
|
assert int(flat_rows[staged * block_size + k]) == orig * block_size + k
|
|
|
|
|
|
def test_hisparse_prefill_staging_plan_masks_unused_blocks():
|
|
block_table = torch.tensor([[5, 2, 7], [9, 3, 4]], dtype=torch.int32)
|
|
plan = build_hisparse_prefill_staging_plan(
|
|
block_table,
|
|
seq_lens=torch.tensor([5, 8], dtype=torch.int32),
|
|
block_size=4,
|
|
staging_block_capacity=4,
|
|
)
|
|
|
|
assert (plan.block_table[:, 2] == 0).all()
|
|
valid_rows = plan.row_ids[plan.row_ids >= 0]
|
|
assert set((valid_rows[::4] // 4).tolist()) == {0, 2, 3, 5, 9}
|
|
for row, used in enumerate((2, 2)):
|
|
for column in range(used):
|
|
staged_block = int(plan.block_table[row, column])
|
|
host_block = int(block_table[row, column])
|
|
assert int(valid_rows[staged_block * 4]) == host_block * 4
|
|
|
|
|
|
def test_hisparse_prefill_staging_plan_resolves_resident_sources():
|
|
"""Per-page resident hits become device sources; misses stay host DMAs."""
|
|
block_size = 4
|
|
resident_block_size = 2
|
|
block_table = torch.tensor([[5, 2, 0], [9, 3, 0]], dtype=torch.int32)
|
|
seq_lens = torch.tensor([5, 8], dtype=torch.int32)
|
|
plan = build_hisparse_prefill_staging_plan(
|
|
block_table, seq_lens, block_size, staging_block_capacity=4
|
|
)
|
|
# Two resident pages per host block; 0 entries are null (not resident).
|
|
resident_table = torch.tensor(
|
|
[[11, 12, 0, 13, 0, 0], [21, 0, 22, 23, 0, 0]], dtype=torch.int32
|
|
)
|
|
|
|
plan.ensure_gpu_sources(resident_table, resident_block_size)
|
|
|
|
assert plan.gpu_row_ids is not None
|
|
unique_hosts = (plan.row_ids[0].view(-1, block_size)[:, 0] // block_size).tolist()
|
|
gpu_rows = plan.gpu_row_ids[0].view(-1, block_size)
|
|
reps = {5: (0, 0), 2: (0, 1), 9: (1, 0), 3: (1, 1)}
|
|
for u, host_id in enumerate(unique_hosts):
|
|
for t in range(block_size):
|
|
if host_id < 0:
|
|
assert int(gpu_rows[u, t]) == -1
|
|
continue
|
|
if host_id == 0:
|
|
assert int(gpu_rows[u, t]) == -1
|
|
continue
|
|
row, col = reps[host_id]
|
|
page = t // resident_block_size
|
|
res_block = int(resident_table[row, col * 2 + page])
|
|
expected = (
|
|
res_block * resident_block_size + t % resident_block_size
|
|
if res_block > 0
|
|
else -1
|
|
)
|
|
assert int(gpu_rows[u, t]) == expected
|
|
valid_rows = plan.row_ids >= 0
|
|
torch.testing.assert_close(
|
|
plan.miss_mask,
|
|
((plan.gpu_row_ids < 0) & valid_rows).int(),
|
|
check_dtype=False,
|
|
)
|
|
|
|
plan.ensure_gpu_sources(torch.zeros_like(resident_table), resident_block_size)
|
|
|
|
assert plan.gpu_row_ids is not None
|
|
assert (plan.gpu_row_ids == -1).all()
|
|
|
|
|
|
@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
|
|
def test_hisparse_gather_prefill_cache_prefers_resident_rows():
|
|
"""Staged rows come from the resident cache when a shadow page exists."""
|
|
if not _has_hisparse_ops():
|
|
pytest.skip("hisparse CUDA ops unavailable")
|
|
device = torch.device("cuda")
|
|
block_size, resident_block_size, row_width = 4, 2, 16
|
|
block_table = torch.tensor([[5, 2, 0], [9, 3, 0]], dtype=torch.int32, device=device)
|
|
seq_lens = torch.tensor([5, 8], dtype=torch.int32, device=device)
|
|
plan = build_hisparse_prefill_staging_plan(
|
|
block_table, seq_lens, block_size, staging_block_capacity=4
|
|
)
|
|
resident_table = torch.tensor(
|
|
[[11, 12, 0, 13, 0, 0], [21, 0, 22, 23, 0, 0]],
|
|
dtype=torch.int32,
|
|
device=device,
|
|
)
|
|
plan.ensure_gpu_sources(resident_table, resident_block_size)
|
|
|
|
num_host_blocks, num_res_blocks = 10, 24
|
|
host_cache = (
|
|
torch.arange(num_host_blocks * block_size * row_width, dtype=torch.float32)
|
|
.view(num_host_blocks, block_size, row_width)
|
|
.pin_memory()
|
|
)
|
|
resident_cache = (
|
|
(
|
|
-torch.arange(
|
|
num_res_blocks * resident_block_size * row_width, dtype=torch.float32
|
|
)
|
|
- 1.0
|
|
)
|
|
.view(num_res_blocks, resident_block_size, row_width)
|
|
.to(device)
|
|
)
|
|
|
|
staged = HiSparseRuntime.gather_prefill_cache(
|
|
None, host_cache, plan, resident_cache=resident_cache
|
|
)
|
|
|
|
staged_flat = staged.view(-1, row_width).cpu()
|
|
host_flat = host_cache.view(-1, row_width)
|
|
resident_flat = resident_cache.reshape(-1, row_width).cpu()
|
|
gpu_rows = plan.gpu_row_ids[0].cpu()
|
|
host_rows = plan.row_ids[0].cpu()
|
|
for i in range(staged_flat.shape[0]):
|
|
if int(host_rows[i]) < 0:
|
|
assert int(plan.miss_mask[0, i]) == 0
|
|
continue
|
|
expected = (
|
|
resident_flat[int(gpu_rows[i])]
|
|
if int(gpu_rows[i]) >= 0
|
|
else host_flat[int(host_rows[i])]
|
|
)
|
|
torch.testing.assert_close(staged_flat[i], expected)
|
|
|
|
|
|
def test_hisparse_fp8_decode_resolves_steps_then_runs_batched_attention(monkeypatch):
|
|
"""FP8 decode must use active dimensions when common metadata is padded."""
|
|
device = torch.device("cpu")
|
|
num_decodes = 2
|
|
query_len = 3
|
|
num_tokens = num_decodes * query_len
|
|
q = torch.randn(num_tokens, 2, 4, device=device)
|
|
topk = (
|
|
torch.arange(num_tokens, dtype=torch.int32, device=device)
|
|
.view(-1, 1)
|
|
.expand(-1, 4)
|
|
)
|
|
steps: list[int] = []
|
|
kernel_shapes: list[torch.Size] = []
|
|
|
|
def swap_in(req_ids, *, logical_topk_indices, **kwargs): # noqa: ARG001
|
|
steps.append(int(logical_topk_indices[0, 0]))
|
|
output = kwargs["attention_indices_out"]
|
|
if output is not None:
|
|
output.copy_(logical_topk_indices + 10)
|
|
return topk[:num_decodes]
|
|
|
|
def run_kernel(self, *, q, **kwargs): # noqa: ARG001
|
|
kernel_shapes.append(q.shape)
|
|
return q[..., :1], None
|
|
|
|
runtime = SimpleNamespace(
|
|
hot=SimpleNamespace(attention_cache=torch.empty(1, device=device))
|
|
)
|
|
leader_cache = SimpleNamespace(
|
|
runtime=runtime,
|
|
source_block_table=torch.empty(
|
|
num_decodes, 1, dtype=torch.int32, device=device
|
|
),
|
|
swap_in=MagicMock(side_effect=swap_in),
|
|
)
|
|
follower_cache = SimpleNamespace(
|
|
runtime=runtime,
|
|
source_block_table=torch.empty(
|
|
num_decodes, 1, dtype=torch.int32, device=device
|
|
),
|
|
swap_in=MagicMock(side_effect=swap_in),
|
|
)
|
|
index_group = object.__new__(HiSparseMLAIndexGroup)
|
|
index_group.caches = [leader_cache, follower_cache]
|
|
index_group.physical_topk_indices = torch.empty(
|
|
(num_tokens + 1, 4), dtype=torch.int32, device=device
|
|
)
|
|
index_group.valid_topk_counts = torch.empty(
|
|
num_tokens + 1, dtype=torch.int32, device=device
|
|
)
|
|
index_group.request_ids = torch.arange(
|
|
num_decodes, dtype=torch.int32, device=device
|
|
)
|
|
impl = SimpleNamespace(
|
|
kv_lora_rank=1,
|
|
index_group=index_group,
|
|
index_group_index=0,
|
|
)
|
|
impl._fp8_flash_mla_kernel = MethodType(run_kernel, impl)
|
|
metadata = SimpleNamespace(
|
|
num_decodes=num_tokens,
|
|
num_decode_tokens=num_tokens,
|
|
decode_max_query_len=1,
|
|
query_start_loc=torch.arange(
|
|
0,
|
|
num_tokens + 1,
|
|
query_len,
|
|
dtype=torch.int32,
|
|
device=device,
|
|
),
|
|
block_table=torch.empty(num_decodes, 1, dtype=torch.int32, device=device),
|
|
block_size=64,
|
|
)
|
|
|
|
output = FlashMLASparseImpl._host_backed_fp8_decode(
|
|
impl,
|
|
q,
|
|
topk,
|
|
metadata,
|
|
SimpleNamespace(),
|
|
num_decodes,
|
|
query_len,
|
|
)
|
|
|
|
assert steps == [0, 1, 2]
|
|
assert kernel_shapes == [(num_decodes, query_len, 2, 4)]
|
|
assert output.shape == (num_tokens, 2, 1)
|
|
torch.testing.assert_close(
|
|
index_group.physical_topk_indices[:num_tokens], topk + 10
|
|
)
|
|
|
|
follower_indices = index_group.convert_decode_logical_to_physical_topk(
|
|
1,
|
|
topk,
|
|
metadata,
|
|
return_valid_counts=False,
|
|
num_decodes=num_decodes,
|
|
decode_query_len=query_len,
|
|
)
|
|
assert steps == [0, 1, 2, 0, 1, 2]
|
|
torch.testing.assert_close(follower_indices, topk + 10)
|
|
|
|
|
|
def test_hisparse_varlen_decode_uses_canonical_request_rows():
|
|
"""Graph metadata's query upper bound must preserve packed token rows."""
|
|
num_decodes = 2
|
|
topk = torch.arange(18, dtype=torch.int32).view(9, 2)
|
|
|
|
def swap_in(_request_ids, *, logical_topk_indices, **_kwargs):
|
|
return logical_topk_indices + 10
|
|
|
|
cache = SimpleNamespace(
|
|
source_block_table=torch.zeros((num_decodes, 1), dtype=torch.int32),
|
|
swap_in=MagicMock(side_effect=swap_in),
|
|
)
|
|
index_group = object.__new__(HiSparseMLAIndexGroup)
|
|
index_group.caches = [cache]
|
|
index_group.physical_topk_indices = torch.empty((10, 2), dtype=torch.int32)
|
|
index_group.valid_topk_counts = torch.empty(10, dtype=torch.int32)
|
|
index_group.request_ids = torch.arange(num_decodes, dtype=torch.int32)
|
|
metadata = SimpleNamespace(
|
|
num_decodes=num_decodes,
|
|
num_decode_tokens=topk.shape[0],
|
|
decode_max_query_len=15,
|
|
query_start_loc=torch.tensor([0, 8, 9], dtype=torch.int32),
|
|
block_size=64,
|
|
)
|
|
|
|
physical_topk = index_group.convert_decode_logical_to_physical_topk(
|
|
0,
|
|
topk,
|
|
metadata,
|
|
return_valid_counts=False,
|
|
)
|
|
|
|
torch.testing.assert_close(physical_topk, topk + 10)
|
|
request_rows = cache.swap_in.call_args_list[0].args[0]
|
|
torch.testing.assert_close(request_rows, index_group.request_ids)
|
|
|
|
|
|
def test_flashinfer_hisparse_decode_runs_batched_attention():
|
|
device = torch.device("cpu")
|
|
num_tokens = 6
|
|
q = torch.randn(num_tokens, 2, 4, device=device)
|
|
topk = torch.zeros(num_tokens, 4, dtype=torch.int32, device=device)
|
|
physical_topk = topk + 10
|
|
valid_counts = torch.full((num_tokens,), 4, dtype=torch.int32, device=device)
|
|
kernel_shapes: list[torch.Size] = []
|
|
|
|
def convert_decode(self, *args, **kwargs): # noqa: ARG001
|
|
return physical_topk, valid_counts
|
|
|
|
def prepare_kernel(self, *args, **kwargs): # noqa: ARG001
|
|
pass
|
|
|
|
def run_kernel(self, q, cache, indices, counts): # noqa: ARG001
|
|
kernel_shapes.append(q.shape)
|
|
return q[..., :1], None
|
|
|
|
cache_handle = SimpleNamespace(
|
|
runtime=SimpleNamespace(
|
|
hot=SimpleNamespace(attention_cache=torch.empty(1, device=device))
|
|
),
|
|
)
|
|
index_group = object.__new__(HiSparseMLAIndexGroup)
|
|
index_group.caches = [cache_handle]
|
|
index_group.convert_decode_logical_to_physical_topk = MethodType(
|
|
convert_decode, index_group
|
|
)
|
|
impl = object.__new__(FlashInferMLASparseImpl)
|
|
impl.topk_indices_buffer = topk
|
|
impl.index_group = index_group
|
|
impl.index_group_index = 0
|
|
impl._prepare_mqa_kernel = MethodType(prepare_kernel, impl)
|
|
impl._run_mqa_kernel = MethodType(run_kernel, impl)
|
|
metadata = SimpleNamespace(num_decode_tokens=num_tokens)
|
|
|
|
output, lse = FlashInferMLASparseImpl.forward_mqa(
|
|
impl,
|
|
q,
|
|
torch.empty(1, device=device),
|
|
metadata,
|
|
SimpleNamespace(),
|
|
)
|
|
|
|
assert kernel_shapes == [q.shape]
|
|
assert output.shape == (num_tokens, 2, 1)
|
|
assert lse is None
|
|
|
|
|
|
def test_flashattn_hisparse_decode_uses_index_group():
|
|
num_tokens = 4
|
|
q_nope = torch.empty(num_tokens, 2, 3, device=DEVICE_TYPE)
|
|
q_rope = torch.empty(num_tokens, 2, 1, device=DEVICE_TYPE)
|
|
topk = torch.zeros(num_tokens, 4, dtype=torch.int32, device=DEVICE_TYPE)
|
|
physical = topk.clone()
|
|
counts = torch.full((num_tokens,), 4, dtype=torch.int32, device=DEVICE_TYPE)
|
|
index_group = object.__new__(HiSparseMLAIndexGroup)
|
|
index_group.convert_decode_logical_to_physical_topk = MagicMock(
|
|
return_value=(physical, counts)
|
|
)
|
|
index_group.physical_kv_cache = MagicMock(
|
|
return_value=torch.empty(1, device=DEVICE_TYPE)
|
|
)
|
|
impl = object.__new__(FlashAttnMLASparseImpl)
|
|
impl.topk_indices_buffer = topk
|
|
impl.index_group = index_group
|
|
impl.index_group_index = 0
|
|
impl._run_mqa_kernel = MagicMock(return_value=q_nope[..., :1])
|
|
metadata = SimpleNamespace(num_decode_tokens=num_tokens, block_size=64)
|
|
|
|
output, lse = FlashAttnMLASparseImpl.forward_mqa(
|
|
impl,
|
|
(q_nope, q_rope),
|
|
torch.empty(1, device=DEVICE_TYPE),
|
|
metadata,
|
|
SimpleNamespace(),
|
|
)
|
|
|
|
assert output.shape == (num_tokens, 2, 1)
|
|
assert lse is None
|
|
index_group.convert_decode_logical_to_physical_topk.assert_called_once()
|
|
impl._run_mqa_kernel.assert_called_once()
|
|
|
|
|
|
def test_flashinfer_sm120_hisparse_decode_uses_index_group():
|
|
num_tokens = 4
|
|
q = torch.empty(num_tokens, 2, 4, device=DEVICE_TYPE)
|
|
topk = torch.zeros(num_tokens, 4, dtype=torch.int32, device=DEVICE_TYPE)
|
|
physical = topk.clone()
|
|
index_group = object.__new__(HiSparseMLAIndexGroup)
|
|
index_group.convert_decode_logical_to_physical_topk = MagicMock(
|
|
return_value=physical
|
|
)
|
|
index_group.physical_kv_cache = MagicMock(
|
|
return_value=torch.empty(1, device=DEVICE_TYPE)
|
|
)
|
|
impl = object.__new__(FlashInferMLASparseSM120Impl)
|
|
impl.topk_indices_buffer = topk
|
|
impl.index_group = index_group
|
|
impl.index_group_index = 0
|
|
impl._run_mqa_kernel = MagicMock(return_value=q[..., :1])
|
|
metadata = SimpleNamespace(
|
|
num_decode_tokens=num_tokens,
|
|
topk_tokens=topk.shape[1],
|
|
)
|
|
|
|
output, lse = FlashInferMLASparseSM120Impl.forward_mqa(
|
|
impl,
|
|
q,
|
|
torch.empty(1, device=DEVICE_TYPE),
|
|
metadata,
|
|
SimpleNamespace(),
|
|
)
|
|
|
|
assert output.shape == (num_tokens, 2, 1)
|
|
assert lse is None
|
|
index_group.convert_decode_logical_to_physical_topk.assert_called_once()
|
|
impl._run_mqa_kernel.assert_called_once()
|
|
|
|
|
|
def test_hisparse_resident_prefill_uses_attention_block_stride():
|
|
expected = torch.tensor([[19]], dtype=torch.int32)
|
|
cache_handle = SimpleNamespace(
|
|
all_context_pages_resident=True,
|
|
view=SimpleNamespace(block_size=64, attention_block_stride=832),
|
|
block_table=torch.tensor([[3]], dtype=torch.int32),
|
|
)
|
|
index_group = object.__new__(HiSparseMLAIndexGroup)
|
|
index_group.caches = [cache_handle]
|
|
index_group.physical_topk_indices = torch.empty((1, 1), dtype=torch.int32)
|
|
index_group._convert_once = MagicMock(return_value=expected)
|
|
topk = torch.zeros((2, 1), dtype=torch.int32)
|
|
metadata = SimpleNamespace(
|
|
req_id_per_token=torch.zeros(2, dtype=torch.int32),
|
|
)
|
|
|
|
result = index_group.convert_logical_to_physical_topk(
|
|
0,
|
|
topk,
|
|
metadata,
|
|
block_stride_rows=None,
|
|
return_valid_counts=False,
|
|
)
|
|
|
|
assert result is expected
|
|
assert index_group._convert_once.call_args.kwargs["block_stride_rows"] == 832
|
|
|
|
|
|
def test_hisparse_shared_sparse_builder_routes_multi_token_chunks_to_prefill():
|
|
"""Default sparse backends must route multi-token chunks to prefill."""
|
|
builder = object.__new__(FlashInferMLASparseMetadataBuilder)
|
|
builder.vllm_config = SimpleNamespace(
|
|
attention_config=SimpleNamespace(hisparse_config=object()),
|
|
speculative_config=SimpleNamespace(
|
|
num_speculative_tokens=4,
|
|
parallel_drafting=False,
|
|
),
|
|
parallel_config=SimpleNamespace(decode_context_parallel_size=1),
|
|
)
|
|
|
|
builder._init_reorder_batch_threshold(
|
|
1024,
|
|
supports_spec_as_decode=True,
|
|
supports_dcp_with_varlen=True,
|
|
)
|
|
|
|
assert builder.reorder_batch_threshold == 1
|
|
|
|
|
|
def test_hisparse_flashmla_reorders_full_speculative_window_as_decode():
|
|
builder = object.__new__(FlashMLASparseMetadataBuilder)
|
|
builder.vllm_config = SimpleNamespace(
|
|
attention_config=SimpleNamespace(hisparse_config=object()),
|
|
speculative_config=SimpleNamespace(
|
|
num_speculative_tokens=3,
|
|
parallel_drafting=False,
|
|
),
|
|
parallel_config=SimpleNamespace(decode_context_parallel_size=1),
|
|
)
|
|
|
|
builder._init_reorder_batch_threshold(
|
|
256,
|
|
supports_spec_as_decode=True,
|
|
supports_dcp_with_varlen=True,
|
|
)
|
|
|
|
assert builder.reorder_batch_threshold == 4
|
|
|
|
|
|
def test_hisparse_mixed_single_token_batch_is_not_decode_only():
|
|
cache = SimpleNamespace(
|
|
runtime=SimpleNamespace(
|
|
begin_forward=lambda: None,
|
|
eager_host_mirror=True,
|
|
),
|
|
)
|
|
metadata = SimpleNamespace(
|
|
num_decode_tokens=1,
|
|
num_actual_tokens=2,
|
|
max_query_len=1,
|
|
num_reqs=2,
|
|
req_id_per_token=torch.arange(2),
|
|
)
|
|
|
|
HiSparseCacheHandle._prepare_for_batch(cache, metadata)
|
|
|
|
assert not cache.decode_batch
|
|
|
|
|
|
def test_flashmla_cache_dtype_aliases_use_ds_layout():
|
|
# kv-cache dtype aliases are canonicalized to fp8_ds_mla before the layer
|
|
# stores kv_cache_dtype, so they cannot bypass the gate.
|
|
for alias in ("fp8", "fp8_e4m3"):
|
|
assert (
|
|
_canonicalize_sparse_mla_kv_cache_dtype(FlashMLASparseBackend, alias)
|
|
== "fp8_ds_mla"
|
|
)
|
|
|
|
|
|
def test_flashmla_common_metadata_requires_uniform_decodes():
|
|
common_metadata = SimpleNamespace(
|
|
max_query_len=3,
|
|
num_reqs=3,
|
|
num_actual_tokens=6,
|
|
query_start_loc_cpu=torch.tensor([0, 1, 3, 6]),
|
|
is_prefilling=None,
|
|
)
|
|
|
|
split = split_decodes_and_prefills(
|
|
common_metadata,
|
|
decode_threshold=128,
|
|
require_uniform=FlashMLASparseMetadataBuilder.require_uniform_decodes,
|
|
)
|
|
|
|
assert split == (1, 2, 1, 5)
|
|
|
|
|
|
def test_flashmla_fp8_metadata_excludes_zero_token_decode_padding(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"vllm.v1.attention.backends.mla.flashmla_sparse.get_mla_metadata",
|
|
lambda: (object(), None),
|
|
)
|
|
builder = SimpleNamespace(
|
|
device=torch.device(DEVICE_TYPE),
|
|
dummy_block_table=torch.zeros(7, 1, device=DEVICE_TYPE),
|
|
max_model_len_tensor=torch.zeros(7, device=DEVICE_TYPE),
|
|
pcp_dcp_kv_gather=False,
|
|
)
|
|
query_start_loc_cpu = torch.tensor([0, 110, 220, 330, 440, 550, 660, 660])
|
|
common_metadata = SimpleNamespace(
|
|
num_actual_tokens=660,
|
|
query_start_loc_cpu=query_start_loc_cpu,
|
|
seq_lens=torch.arange(7, device=DEVICE_TYPE),
|
|
)
|
|
metadata = FlashMLASparseMetadata(
|
|
num_reqs=7,
|
|
max_query_len=110,
|
|
max_seq_len=110,
|
|
num_actual_tokens=660,
|
|
query_start_loc=query_start_loc_cpu.to(DEVICE_TYPE),
|
|
slot_mapping=torch.arange(660, device=DEVICE_TYPE),
|
|
block_table=torch.zeros(7, 1, dtype=torch.int32, device=DEVICE_TYPE),
|
|
req_id_per_token=torch.zeros(660, dtype=torch.int32, device=DEVICE_TYPE),
|
|
num_decodes=7,
|
|
num_prefills=0,
|
|
num_decode_tokens=660,
|
|
)
|
|
|
|
fp8_metadata = FlashMLASparseMetadataBuilder._build_fp8_separate_prefill_decode(
|
|
builder, common_metadata, metadata
|
|
)
|
|
|
|
assert fp8_metadata.num_decodes == 6
|
|
assert fp8_metadata.num_decode_tokens == 660
|
|
assert fp8_metadata.decode is not None
|
|
assert fp8_metadata.decode.decode_query_len == 110
|
|
torch.testing.assert_close(
|
|
fp8_metadata.decode.seq_lens, torch.arange(6, device=DEVICE_TYPE)
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("use_mixed_batch", [False, True])
|
|
def test_flashmla_fp8_paths_accept_decode_subset(monkeypatch, use_mixed_batch: bool):
|
|
num_decode_tokens = 2
|
|
num_batch_tokens = 5
|
|
q = torch.empty(num_decode_tokens, 2, 3, device=DEVICE_TYPE)
|
|
topk_indices = torch.empty(num_decode_tokens, 4, device=DEVICE_TYPE)
|
|
kernel_q_shapes = []
|
|
|
|
def convert_indices(*args, **kwargs): # noqa: ARG001
|
|
assert not kwargs.get("HAS_PREFILL_WORKSPACE", False)
|
|
if not kwargs.get("return_valid_counts", False):
|
|
return topk_indices
|
|
valid_counts = torch.full(
|
|
(num_decode_tokens,), 4, dtype=torch.int32, device=DEVICE_TYPE
|
|
)
|
|
return topk_indices, valid_counts
|
|
|
|
monkeypatch.setattr(
|
|
"vllm.v1.attention.backends.mla.flashmla_sparse."
|
|
"triton_convert_req_index_to_global_index",
|
|
convert_indices,
|
|
)
|
|
|
|
def run_kernel(**kwargs):
|
|
kernel_q_shapes.append(kwargs["q"].shape)
|
|
return kwargs["q"][..., :1], None
|
|
|
|
if use_mixed_batch:
|
|
fp8_metadata = FlashMLASparseMetadata.FP8KernelMetadata(
|
|
scheduler_metadata=object(), # type: ignore[arg-type]
|
|
dummy_block_table=torch.empty(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
|
|
cache_lens=torch.empty(1, dtype=torch.int32, device=DEVICE_TYPE),
|
|
)
|
|
else:
|
|
FP8Meta = FlashMLASparseMetadata.FP8SeparatePrefillDecode
|
|
fp8_metadata = FP8Meta(
|
|
num_decodes=1,
|
|
num_prefills=1,
|
|
num_decode_tokens=num_decode_tokens,
|
|
num_prefill_tokens=num_batch_tokens - num_decode_tokens,
|
|
decode=FP8Meta.Decode(
|
|
seq_lens=torch.empty(1, dtype=torch.int32, device=DEVICE_TYPE),
|
|
kernel_metadata=object(), # type: ignore[arg-type]
|
|
decode_query_len=num_decode_tokens,
|
|
),
|
|
prefill=FP8Meta.Prefill(
|
|
request_ids=torch.empty(
|
|
num_batch_tokens, dtype=torch.int32, device=DEVICE_TYPE
|
|
),
|
|
workspace_starts=torch.empty(1, dtype=torch.int32, device=DEVICE_TYPE),
|
|
chunks=[],
|
|
),
|
|
)
|
|
metadata = SimpleNamespace(
|
|
fp8_extra_metadata=fp8_metadata,
|
|
fp8_use_mixed_batch=use_mixed_batch,
|
|
num_actual_tokens=num_batch_tokens,
|
|
num_decode_tokens=num_decode_tokens,
|
|
req_id_per_token=torch.empty(
|
|
num_batch_tokens, dtype=torch.int32, device=DEVICE_TYPE
|
|
),
|
|
block_table=torch.empty(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
|
|
block_size=64,
|
|
)
|
|
impl = SimpleNamespace(
|
|
kv_cache_dtype="fp8_ds_mla",
|
|
topk_indices_buffer=topk_indices,
|
|
num_heads=2,
|
|
kv_lora_rank=1,
|
|
index_group=None,
|
|
index_group_index=0,
|
|
dcp_world_size=1,
|
|
pcp_dcp_kv_gather=False,
|
|
need_to_return_lse_for_decode=False,
|
|
_fp8_flash_mla_kernel=run_kernel,
|
|
_convert_logical_to_physical_topk=(
|
|
lambda indices, metadata, **kwargs: convert_indices(
|
|
metadata.req_id_per_token[: indices.shape[0]],
|
|
metadata.block_table,
|
|
indices,
|
|
return_valid_counts=kwargs["return_valid_counts"],
|
|
)
|
|
),
|
|
)
|
|
impl._forward_fp8_kv_mixed_batch = MethodType(
|
|
FlashMLASparseImpl._forward_fp8_kv_mixed_batch, impl
|
|
)
|
|
impl._forward_fp8_kv_separate_prefill_decode = MethodType(
|
|
FlashMLASparseImpl._forward_fp8_kv_separate_prefill_decode, impl
|
|
)
|
|
|
|
output, lse = FlashMLASparseImpl.forward_mqa(
|
|
impl,
|
|
q,
|
|
torch.empty(0, device=DEVICE_TYPE),
|
|
metadata,
|
|
None,
|
|
)
|
|
|
|
assert kernel_q_shapes == [(1, num_decode_tokens, 2, 3)]
|
|
assert output.shape == (num_decode_tokens, 2, 1)
|
|
assert lse is None
|
|
|
|
|
|
def _build_sparse_dcp_vllm_config(
|
|
local_heads: int,
|
|
dcp_world_size: int,
|
|
comm_backend: str = "ag_rs",
|
|
):
|
|
"""Minimal sparse-MLA VllmConfig for the FlashMLASparse DCP head-envelope
|
|
guard. TP is simulated by mocking ``get_num_attention_heads`` to return the
|
|
per-rank head count, as the decode-correctness test above does.
|
|
"""
|
|
kv_lora_rank = 512
|
|
qk_nope_head_dim = 128
|
|
qk_rope_head_dim = 64
|
|
v_head_dim = 128
|
|
head_size = kv_lora_rank + qk_rope_head_dim
|
|
topk_tokens = 128
|
|
|
|
vllm_config = create_vllm_config(
|
|
model_name="deepseek-ai/DeepSeek-V2-Lite-Chat",
|
|
tensor_parallel_size=1,
|
|
max_model_len=4096,
|
|
block_size=64,
|
|
hf_config_override={
|
|
"index_topk": topk_tokens,
|
|
"attn_module_list_cfg": [{"topk_tokens": topk_tokens}],
|
|
},
|
|
)
|
|
model_config = vllm_config.model_config
|
|
model_config.dtype = torch.bfloat16
|
|
model_config.hf_text_config = SimpleNamespace(
|
|
index_topk=topk_tokens,
|
|
q_lora_rank=None,
|
|
kv_lora_rank=kv_lora_rank,
|
|
qk_nope_head_dim=qk_nope_head_dim,
|
|
qk_rope_head_dim=qk_rope_head_dim,
|
|
v_head_dim=v_head_dim,
|
|
model_type="deepseek_v2",
|
|
)
|
|
del model_config.hf_config.index_topk # Composite configs only nest this field.
|
|
model_config.get_num_attention_heads = MethodType(
|
|
lambda self, parallel_config: local_heads, model_config
|
|
)
|
|
model_config.get_num_kv_heads = MethodType(
|
|
lambda self, parallel_config: 1, model_config
|
|
)
|
|
model_config.get_head_size = MethodType(lambda self: head_size, model_config)
|
|
model_config.get_sliding_window = MethodType(lambda self: None, model_config)
|
|
|
|
vllm_config.cache_config.cache_dtype = "fp8_ds_mla"
|
|
vllm_config.parallel_config.decode_context_parallel_size = dcp_world_size
|
|
vllm_config.parallel_config.dcp_comm_backend = comm_backend
|
|
# The base builder clones the layer's dense-MHA prefill backend from
|
|
# static_forward_context; the guard tests never run prefill.
|
|
vllm_config.compilation_config.static_forward_context["placeholder"] = (
|
|
SimpleNamespace(prefill_backend=None)
|
|
)
|
|
return vllm_config
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
torch.cuda.get_device_capability() < (9, 0),
|
|
reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"local_heads,dcp_world_size,should_raise",
|
|
[
|
|
(16, 8, True),
|
|
(24, 4, True),
|
|
(16, 4, False),
|
|
(16, 1, False),
|
|
],
|
|
)
|
|
def test_fp8_dcp_head_envelope_guard(local_heads, dcp_world_size, should_raise):
|
|
"""The fp8 decode envelope (head padding + tile-scheduler metadata) is
|
|
sized from the local head count while the kernel runs on the DCP-gathered
|
|
heads, so the builder must reject configs where the two pad differently.
|
|
"""
|
|
device = torch.device(DEVICE_TYPE)
|
|
vllm_config = _build_sparse_dcp_vllm_config(local_heads, dcp_world_size)
|
|
kv_cache_spec = create_standard_kv_cache_spec(vllm_config)
|
|
builder_cls = FlashMLASparseBackend.get_builder_cls()
|
|
|
|
if should_raise:
|
|
with pytest.raises(NotImplementedError, match="envelope"):
|
|
builder_cls(kv_cache_spec, ["placeholder"], vllm_config, device)
|
|
else:
|
|
builder = builder_cls(kv_cache_spec, ["placeholder"], vllm_config, device)
|
|
gathered_heads = local_heads * dcp_world_size
|
|
local_pad = 64 if local_heads <= 64 else 128
|
|
gathered_pad = 64 if gathered_heads <= 64 else 128
|
|
assert builder.fp8_decode_padded_heads == local_pad
|
|
assert local_pad == gathered_pad
|
|
|
|
|
|
def test_fp8_mixed_batch_dcp_neutralizes_empty_rows(monkeypatch):
|
|
"""A decode row whose top-k shard holds no local candidates (all -1) has
|
|
undefined kernel out/lse; it must come back as (0, -inf), the identity of
|
|
the cross-rank LSE merge, or a NaN would survive the merge even at zero
|
|
weight (0 * NaN = NaN)."""
|
|
num_tokens, num_heads, head_dim = 3, 2, 3
|
|
q = torch.empty(num_tokens, num_heads, head_dim, device=DEVICE_TYPE)
|
|
local_indices = torch.tensor(
|
|
[[0, 1, -1, -1], [-1, -1, -1, -1], [2, -1, 3, -1]],
|
|
dtype=torch.int32,
|
|
device=DEVICE_TYPE,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"vllm.v1.attention.backends.mla.flashmla_sparse."
|
|
"triton_filter_and_convert_dcp_index",
|
|
lambda *args, **kwargs: local_indices,
|
|
)
|
|
|
|
def run_kernel(**kwargs):
|
|
out = torch.full(
|
|
(1, num_tokens, num_heads, 1), float("nan"), device=DEVICE_TYPE
|
|
)
|
|
lse = torch.full((1, num_heads, num_tokens), float("nan"), device=DEVICE_TYPE)
|
|
for token_id in (0, 2): # rows with local candidates get real values
|
|
out[0, token_id] = float(token_id + 1)
|
|
lse[0, :, token_id] = float(token_id + 1)
|
|
return out, lse
|
|
|
|
metadata = SimpleNamespace(
|
|
fp8_extra_metadata=FlashMLASparseMetadata.FP8KernelMetadata(
|
|
scheduler_metadata=object(), # type: ignore[arg-type]
|
|
dummy_block_table=torch.empty(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
|
|
cache_lens=torch.empty(1, dtype=torch.int32, device=DEVICE_TYPE),
|
|
),
|
|
req_id_per_token=torch.empty(num_tokens, dtype=torch.int32, device=DEVICE_TYPE),
|
|
block_table=torch.empty(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
|
|
block_size=64,
|
|
cp_kv_cache_interleave_size=1,
|
|
)
|
|
impl = SimpleNamespace(
|
|
index_group=None,
|
|
index_group_index=0,
|
|
dcp_world_size=2,
|
|
dcp_rank=0,
|
|
need_to_return_lse_for_decode=True,
|
|
_fp8_flash_mla_kernel=run_kernel,
|
|
)
|
|
|
|
out, lse = FlashMLASparseImpl._forward_fp8_kv_mixed_batch(
|
|
impl, q, torch.empty(0, device=DEVICE_TYPE), local_indices, metadata
|
|
)
|
|
|
|
assert torch.equal(out[1], torch.zeros_like(out[1]))
|
|
assert torch.isneginf(lse[1]).all()
|
|
for token_id in (0, 2):
|
|
assert torch.equal(out[token_id], torch.full_like(out[token_id], token_id + 1))
|
|
assert torch.equal(lse[token_id], torch.full_like(lse[token_id], token_id + 1))
|
|
assert out.is_contiguous()
|
|
assert not out.isnan().any()
|
|
assert not lse.isnan().any()
|
|
|
|
|
|
def test_hisparse_prefill_reuses_builder_staging_plan():
|
|
"""Every layer must reuse the batch plan instead of synchronizing to dedupe."""
|
|
plan = SimpleNamespace(
|
|
block_table=torch.tensor([[0]], dtype=torch.int32),
|
|
ensure_gpu_sources=MagicMock(),
|
|
)
|
|
staged = torch.empty((1, 1, 8))
|
|
calls = []
|
|
|
|
def gather(kv_cache, staging_plan, resident_cache=None):
|
|
calls.append((kv_cache, staging_plan, resident_cache))
|
|
return staged
|
|
|
|
resident_cache = torch.empty((1, 1, 8))
|
|
resident_block_table = torch.tensor([[1]], dtype=torch.int32)
|
|
cache = SimpleNamespace(
|
|
runtime=SimpleNamespace(
|
|
gather_prefill_cache=gather,
|
|
),
|
|
view=SimpleNamespace(cache=resident_cache, block_size=1),
|
|
block_table=resident_block_table,
|
|
)
|
|
index_group = object.__new__(HiSparseMLAIndexGroup)
|
|
index_group.caches = [cache]
|
|
source = torch.empty((1, 1, 8))
|
|
metadata = SimpleNamespace(
|
|
num_decodes=0,
|
|
num_decode_tokens=0,
|
|
seq_lens=torch.tensor([1], dtype=torch.int32),
|
|
prefill=SimpleNamespace(host_staging_plan=plan),
|
|
req_id_per_token=torch.tensor([0], dtype=torch.int32),
|
|
)
|
|
|
|
result, block_table, request_ids = index_group.stage_prefill_rows(
|
|
0, source, metadata
|
|
)
|
|
|
|
assert result is staged
|
|
assert block_table is plan.block_table
|
|
torch.testing.assert_close(request_ids, metadata.req_id_per_token)
|
|
plan.ensure_gpu_sources.assert_called_once()
|
|
args = plan.ensure_gpu_sources.call_args.args
|
|
torch.testing.assert_close(args[0], resident_block_table)
|
|
assert args[1] == 1
|
|
assert calls == [(source, plan, resident_cache)]
|
|
|
|
|
|
def test_hisparse_fp8_prefill_gather_uses_dedicated_stream(monkeypatch):
|
|
compute_stream = object()
|
|
prefill_stream = MagicMock()
|
|
gather = MagicMock()
|
|
monkeypatch.setattr(index_group_module, "current_stream", lambda: compute_stream)
|
|
monkeypatch.setattr(
|
|
index_group_module.ops, "cp_gather_and_upconvert_fp8_kv_cache", gather
|
|
)
|
|
|
|
resident_cache = torch.empty((2, 4, 656), dtype=torch.uint8)
|
|
ready = MagicMock()
|
|
cache = SimpleNamespace(
|
|
view=SimpleNamespace(cache=resident_cache, block_size=4),
|
|
block_table=torch.tensor([[2, 3]], dtype=torch.int32),
|
|
)
|
|
index_group = object.__new__(HiSparseMLAIndexGroup)
|
|
index_group.caches = [cache]
|
|
index_group.prefill_stream = prefill_stream
|
|
index_group.prefill_ready_events = [ready]
|
|
source = torch.empty((3, 4, 656), dtype=torch.uint8)
|
|
dst = torch.empty((8, 576), dtype=torch.bfloat16)
|
|
block_table = torch.tensor([[0, 1]], dtype=torch.int32)
|
|
workspace_starts = torch.tensor([0], dtype=torch.int32)
|
|
plan = SimpleNamespace(
|
|
block_table=torch.tensor([[0, 1]], dtype=torch.int32),
|
|
row_ids=torch.arange(8, dtype=torch.int32).view(1, -1),
|
|
gpu_row_ids=torch.full((1, 8), -1, dtype=torch.int32),
|
|
ensure_gpu_sources=MagicMock(),
|
|
)
|
|
metadata = SimpleNamespace(
|
|
prefill=SimpleNamespace(host_staging_plan=plan),
|
|
num_decodes=0,
|
|
)
|
|
|
|
result = index_group.gather_fp8_prefill(
|
|
0,
|
|
source,
|
|
dst,
|
|
block_table,
|
|
workspace_starts,
|
|
1,
|
|
metadata,
|
|
0,
|
|
)
|
|
|
|
prefill_stream.wait_stream.assert_called_once_with(compute_stream)
|
|
ready.record.assert_called_once_with(prefill_stream)
|
|
assert result is ready
|
|
gather.assert_called_once()
|
|
args = gather.call_args
|
|
assert args.args[0] is resident_cache
|
|
assert args.args[1] is dst
|
|
torch.testing.assert_close(args.args[2], plan.block_table)
|
|
assert args.args[3] is workspace_starts
|
|
assert args.args[4] == 1
|
|
assert args.kwargs["host_cache"].data_ptr() == source.data_ptr()
|
|
assert args.kwargs["host_row_ids"] is plan.row_ids
|
|
assert args.kwargs["device_row_ids"] is plan.gpu_row_ids
|
|
plan.ensure_gpu_sources.assert_called_once()
|
|
ensure_args = plan.ensure_gpu_sources.call_args.args
|
|
torch.testing.assert_close(ensure_args[0], cache.block_table)
|
|
assert ensure_args[1] == 4
|
|
|
|
|
|
def test_sparse_impl_observes_repointed_indexer_buffer():
|
|
"""The MTP proposer repoints the draft's indexer at the target model's buffer
|
|
after the backend impl is built, so the impl must resolve the buffer per read.
|
|
Snapshotting it in __init__ leaves the layer reading indices nothing writes."""
|
|
impl = object.__new__(FlashInferMLASparseImpl)
|
|
own = torch.zeros(4, 8, dtype=torch.int32)
|
|
target = torch.ones(4, 8, dtype=torch.int32)
|
|
indexer = SimpleNamespace(topk_indices_buffer=own)
|
|
impl.init_topk_indices_buffer(indexer, None)
|
|
|
|
assert impl.topk_indices_buffer is own
|
|
|
|
indexer.topk_indices_buffer = target
|
|
|
|
assert impl.topk_indices_buffer is target
|
|
|
|
|
|
def test_explicit_topk_buffer_supersedes_indexer():
|
|
"""Backbone skip-topk layers have no indexer, and the proposer also assigns the
|
|
shared buffer directly onto draft submodules."""
|
|
impl = object.__new__(FlashInferMLASparseImpl)
|
|
indexer = SimpleNamespace(
|
|
topk_indices_buffer=torch.zeros(2, 2, dtype=torch.int32),
|
|
)
|
|
impl.init_topk_indices_buffer(indexer, None)
|
|
explicit = torch.ones(2, 2, dtype=torch.int32)
|
|
|
|
impl.topk_indices_buffer = explicit
|
|
|
|
assert impl.topk_indices_buffer is explicit
|
|
|
|
|
|
def test_sparse_mla_common_impl_resolves_buffer_lazily():
|
|
"""Guards the whole SparseMLACommonImpl family at once: a backend that
|
|
snapshots the buffer instead silently loses MTP buffer sharing."""
|
|
assert issubclass(SparseMLACommonImpl, SharedTopkIndicesBuffer)
|
|
assert isinstance(SparseMLACommonImpl.topk_indices_buffer, property), (
|
|
"topk_indices_buffer must stay a lazily-resolved property"
|
|
)
|