feat: SM120 (Blackwell Desktop) support for DeepSeek-V4 inference (#24692)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
dfa1af99f5
commit
524ba10eda
@@ -0,0 +1,465 @@
|
||||
"""SM120 FlashMLA sparse decode unit tests.
|
||||
|
||||
Validates the SM120-specific FlashMLA implementation that replaces the upstream
|
||||
`flash_mla` CUDA kernel (unavailable on SM120 / RTX PRO 6000):
|
||||
|
||||
- ``_gather_and_dequant``: byte-precise dequant of paged FP8 + BF16 + UE8M0 KV
|
||||
cache. Covers the dtype-reinterpretation surface that caused the historic
|
||||
uint8 garbled-output regression (see progress doc §4).
|
||||
- ``_sm120_sparse_decode_fwd``: pure-PyTorch reference path.
|
||||
- ``flash_mla_sparse_decode_triton``: tiled Triton kernel.
|
||||
- ``_apply_attn_sink`` / ``_merge_partial_attn``: post-processing helpers.
|
||||
- ``flash_mla_with_kvcache_sm120``: entry-point dispatch on
|
||||
``SGLANG_SM120_TRITON_FLASHMLA`` selects torch/triton paths and both yield
|
||||
matching output.
|
||||
|
||||
DSv4 cache layout (per page):
|
||||
data section: page_size * 576 bytes = 64 tokens * (448 nope + 128 rope)
|
||||
scale section: page_size * 8 bytes = 64 tokens * (7 UE8M0 scales + 1 pad)
|
||||
total bytes: page_size * 584 = stride(0) of k_cache
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.layers.attention import flash_mla_sm120 as fmod
|
||||
from sglang.srt.layers.attention.flash_mla_sm120 import (
|
||||
_D,
|
||||
_NOPE_DIM,
|
||||
_NOPE_ROPE_STRIDE,
|
||||
_NUM_TILES,
|
||||
_ROPE_DIM,
|
||||
_SCALE_STRIDE,
|
||||
_TILE_SIZE,
|
||||
_gather_and_dequant,
|
||||
_sm120_sparse_decode_fwd,
|
||||
flash_mla_with_kvcache_sm120,
|
||||
)
|
||||
from sglang.srt.layers.attention.flash_mla_sm120_triton import (
|
||||
_apply_attn_sink,
|
||||
_merge_partial_attn,
|
||||
flash_mla_sparse_decode_triton,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cuda_ci(est_time=45, stage="base-b", runner_config="1-gpu-large")
|
||||
|
||||
|
||||
# Per-token byte layout
|
||||
_BYTES_PER_TOKEN = _NOPE_ROPE_STRIDE + _SCALE_STRIDE # 576 + 8 = 584
|
||||
|
||||
|
||||
def _build_kvcache(
|
||||
num_pages: int,
|
||||
page_size: int,
|
||||
*,
|
||||
device: torch.device,
|
||||
seed: int = 0,
|
||||
):
|
||||
"""Build a synthetic FP8/BF16/UE8M0 KV cache.
|
||||
|
||||
Returns the (num_pages, page_size, 1, bpt) FP8-viewed cache plus the raw
|
||||
nope FP8 (float), rope BF16 (float), and UE8M0 scale (float) reference
|
||||
tensors for verification.
|
||||
"""
|
||||
g = torch.Generator(device="cpu").manual_seed(seed)
|
||||
bpt = _BYTES_PER_TOKEN # 584 — also satisfies k_cache.stride(0) requirement
|
||||
raw = torch.zeros(num_pages, page_size, bpt, dtype=torch.uint8, device=device)
|
||||
|
||||
# ---- Nope FP8 region: per-token bytes [0:448] ----
|
||||
# Stay in [0, 0x6F] to avoid NaN/Inf in float8_e4m3fn.
|
||||
nope_bytes = torch.randint(
|
||||
0, 0x70, (num_pages, page_size, _NOPE_DIM), generator=g, dtype=torch.uint8
|
||||
).to(device)
|
||||
raw[:, :, :_NOPE_DIM] = nope_bytes
|
||||
|
||||
# ---- Rope BF16 region: per-token bytes [448:576] = 64 bf16 values ----
|
||||
rope_bf16_vals = (
|
||||
torch.randn((num_pages, page_size, _ROPE_DIM), generator=g, dtype=torch.float32)
|
||||
.clamp(-2.0, 2.0)
|
||||
.to(torch.bfloat16)
|
||||
.to(device)
|
||||
)
|
||||
# View bf16 as 2 bytes per value -> 128 bytes per token rope region
|
||||
rope_as_uint8 = rope_bf16_vals.contiguous().view(
|
||||
torch.uint8
|
||||
) # (num_pages, page_size, 128)
|
||||
raw[:, :, _NOPE_DIM : _NOPE_DIM + _ROPE_DIM * 2] = rope_as_uint8
|
||||
|
||||
# ---- Scale section: starts at page_size * 576 ----
|
||||
# 7 UE8M0 bytes per token + 1 pad. Keep exponents in a sane range
|
||||
# (UE8M0 byte 'b' decodes to 2**(b-127)), so 120..130 gives ~[1/128, 8].
|
||||
scale_bytes = torch.randint(
|
||||
120,
|
||||
131,
|
||||
(num_pages, page_size, _NUM_TILES),
|
||||
generator=g,
|
||||
dtype=torch.uint8,
|
||||
).to(device)
|
||||
# raw is (num_pages, page_size, bpt) with bpt=584; data ends at 576, scale region 576..584
|
||||
# Per-token scale offset relative to scale section: token_idx * 8.
|
||||
# Note: the scale region in raw memory is at the END of the page after the
|
||||
# data section. Since raw shape is (num_pages, page_size, bpt), where
|
||||
# bpt=584, scale lives in raw[:, :, _NOPE_ROPE_STRIDE : _NOPE_ROPE_STRIDE+8].
|
||||
# BUT the gather code expects layout
|
||||
# raw_pages: (num_pages, page_bytes)
|
||||
# scale_section_offset = page_size * 576
|
||||
# i.e. data for ALL tokens first, then scale for ALL tokens. So we need
|
||||
# the flat per-page layout: [tok0_data(576), tok1_data, ..., tokN-1_data,
|
||||
# tok0_scale(8), tok1_scale, ...].
|
||||
# We constructed raw as (num_pages, page_size, bpt) which interleaves data
|
||||
# and scales per token. Build a fresh buffer with the correct flat order.
|
||||
flat = torch.zeros(num_pages, page_size * bpt, dtype=torch.uint8, device=device)
|
||||
# Data: per-token 576 bytes contiguous
|
||||
flat_data = raw[:, :, :_NOPE_ROPE_STRIDE].reshape(
|
||||
num_pages, page_size * _NOPE_ROPE_STRIDE
|
||||
)
|
||||
flat[:, : page_size * _NOPE_ROPE_STRIDE] = flat_data
|
||||
# Scales: per-token 8 bytes (only first 7 written)
|
||||
scale_block = torch.zeros(
|
||||
num_pages, page_size, _SCALE_STRIDE, dtype=torch.uint8, device=device
|
||||
)
|
||||
scale_block[:, :, :_NUM_TILES] = scale_bytes
|
||||
flat[:, page_size * _NOPE_ROPE_STRIDE :] = scale_block.reshape(
|
||||
num_pages, page_size * _SCALE_STRIDE
|
||||
)
|
||||
|
||||
# View as (num_pages, page_size, 1, bpt) float8_e4m3fn
|
||||
k_cache = flat.view(num_pages, page_size, 1, bpt).view(torch.float8_e4m3fn)
|
||||
# The Triton kernel expects k_cache.stride(0) == page_size * bpt.
|
||||
assert k_cache.stride(0) == page_size * bpt
|
||||
|
||||
# Reference dequant per token (matches what _gather_and_dequant should produce):
|
||||
nope_fp8 = nope_bytes.view(
|
||||
torch.float8_e4m3fn
|
||||
).float() # (num_pages, page_size, 448)
|
||||
scale_e8m0 = scale_bytes.view(
|
||||
torch.float8_e8m0fnu
|
||||
).float() # (num_pages, page_size, 7)
|
||||
nope_dequant = (
|
||||
nope_fp8.view(num_pages, page_size, _NUM_TILES, _TILE_SIZE)
|
||||
* scale_e8m0.view(num_pages, page_size, _NUM_TILES, 1)
|
||||
).view(
|
||||
num_pages, page_size, _NOPE_DIM
|
||||
) # float32
|
||||
ref_per_token = torch.cat(
|
||||
[nope_dequant.to(torch.bfloat16), rope_bf16_vals], dim=-1
|
||||
) # (num_pages, page_size, 512) bf16
|
||||
return k_cache, ref_per_token
|
||||
|
||||
|
||||
def _build_q_indices(
|
||||
batch_size: int,
|
||||
num_heads: int,
|
||||
topk: int,
|
||||
num_pages: int,
|
||||
page_size: int,
|
||||
*,
|
||||
device: torch.device,
|
||||
seed: int = 1,
|
||||
):
|
||||
g = torch.Generator(device="cpu").manual_seed(seed)
|
||||
q = (
|
||||
torch.randn((batch_size, 1, num_heads, _D), generator=g, dtype=torch.float32)
|
||||
.clamp(-1.5, 1.5)
|
||||
.to(torch.bfloat16)
|
||||
.to(device)
|
||||
)
|
||||
# Each batch picks `topk` random valid token-level indices into the pool
|
||||
pool_size = num_pages * page_size
|
||||
indices = torch.zeros((batch_size, 1, topk), dtype=torch.int32, device=device)
|
||||
for b in range(batch_size):
|
||||
perm = torch.randperm(pool_size, generator=g)[:topk]
|
||||
indices[b, 0] = perm.to(device=device, dtype=torch.int32)
|
||||
return q, indices
|
||||
|
||||
|
||||
class TestGatherAndDequant(CustomTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not torch.cuda.is_available():
|
||||
raise unittest.SkipTest("CUDA required")
|
||||
cls.device = torch.device("cuda")
|
||||
|
||||
def test_basic_dequant_matches_manual(self):
|
||||
"""Per-token output equals nope_fp8 * scale concatenated with rope_bf16."""
|
||||
num_pages, page_size = 3, 64
|
||||
k_cache, ref_per_token = _build_kvcache(
|
||||
num_pages, page_size, device=self.device, seed=42
|
||||
)
|
||||
# Pick 1 index per page (positions 0, 32, 63)
|
||||
token_ids = torch.tensor(
|
||||
[[0, 1 * page_size + 32, 2 * page_size + 63]],
|
||||
dtype=torch.int32,
|
||||
device=self.device,
|
||||
)
|
||||
out = _gather_and_dequant(k_cache, token_ids, page_size)
|
||||
self.assertEqual(out.shape, (1, 3, _D))
|
||||
# Expected entries
|
||||
expected = torch.stack(
|
||||
[
|
||||
ref_per_token[0, 0],
|
||||
ref_per_token[1, 32],
|
||||
ref_per_token[2, 63],
|
||||
],
|
||||
dim=0,
|
||||
).unsqueeze(0)
|
||||
# bf16 dequant: allow up to 1 ULP per element. Scales are integral
|
||||
# powers of 2 so the only loss is the fp8 -> bf16 mantissa rounding.
|
||||
torch.testing.assert_close(out, expected, atol=1e-2, rtol=1e-2)
|
||||
|
||||
def test_dequant_handles_full_page_range(self):
|
||||
"""All token positions in a single page produce correct output."""
|
||||
num_pages, page_size = 2, 64
|
||||
k_cache, ref_per_token = _build_kvcache(
|
||||
num_pages, page_size, device=self.device, seed=7
|
||||
)
|
||||
token_ids = torch.arange(page_size, dtype=torch.int32, device=self.device).view(
|
||||
1, page_size
|
||||
)
|
||||
out = _gather_and_dequant(k_cache, token_ids, page_size)
|
||||
torch.testing.assert_close(
|
||||
out, ref_per_token[0].unsqueeze(0), atol=1e-2, rtol=1e-2
|
||||
)
|
||||
|
||||
|
||||
class TestSparseDecodeTritonVsTorch(CustomTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not torch.cuda.is_available():
|
||||
raise unittest.SkipTest("CUDA required")
|
||||
cls.device = torch.device("cuda")
|
||||
|
||||
def _run(
|
||||
self,
|
||||
batch_size: int = 2,
|
||||
num_heads: int = 4,
|
||||
topk: int = 64,
|
||||
page_size: int = 64,
|
||||
num_pages: int = 4,
|
||||
seed: int = 11,
|
||||
with_topk_length: bool = True,
|
||||
with_attn_sink: bool = False,
|
||||
):
|
||||
k_cache, _ = _build_kvcache(num_pages, page_size, device=self.device, seed=seed)
|
||||
q, indices = _build_q_indices(
|
||||
batch_size,
|
||||
num_heads,
|
||||
topk,
|
||||
num_pages,
|
||||
page_size,
|
||||
device=self.device,
|
||||
seed=seed + 100,
|
||||
)
|
||||
topk_length = None
|
||||
if with_topk_length:
|
||||
topk_length = torch.tensor(
|
||||
[topk // 2, topk] if batch_size == 2 else [topk] * batch_size,
|
||||
dtype=torch.int32,
|
||||
device=self.device,
|
||||
)
|
||||
attn_sink = None
|
||||
if with_attn_sink:
|
||||
attn_sink = torch.full(
|
||||
(num_heads,), -1.5, dtype=torch.float32, device=self.device
|
||||
)
|
||||
softmax_scale = _D ** (-0.5)
|
||||
# Production passes config.v_head_dim == _D (512). Both impls return
|
||||
# (B, 1, H, _D); the Triton kernel always computes the full nope+rope.
|
||||
head_dim_v = _D
|
||||
|
||||
# PyTorch reference
|
||||
ref_out, ref_lse = _sm120_sparse_decode_fwd(
|
||||
q,
|
||||
k_cache,
|
||||
indices,
|
||||
topk_length,
|
||||
attn_sink,
|
||||
head_dim_v=head_dim_v,
|
||||
softmax_scale=softmax_scale,
|
||||
)
|
||||
|
||||
# Triton
|
||||
tri_out, tri_lse = flash_mla_sparse_decode_triton(
|
||||
q,
|
||||
k_cache,
|
||||
indices,
|
||||
topk_length,
|
||||
attn_sink,
|
||||
head_dim_v=head_dim_v,
|
||||
softmax_scale=softmax_scale,
|
||||
)
|
||||
|
||||
# Outputs both bf16, allow drift from online softmax base-2 vs base-e
|
||||
# math + bf16 mantissa precision (~7 bits). 5e-2 abs/rel is generous
|
||||
# but accounts for accumulated rounding across topk tokens.
|
||||
torch.testing.assert_close(
|
||||
tri_out.to(torch.float32),
|
||||
ref_out.to(torch.float32),
|
||||
atol=5e-2,
|
||||
rtol=5e-2,
|
||||
)
|
||||
return ref_out, tri_out, ref_lse, tri_lse
|
||||
|
||||
def test_triton_vs_torch_basic(self):
|
||||
self._run()
|
||||
|
||||
def test_triton_vs_torch_no_topk_length(self):
|
||||
self._run(with_topk_length=False)
|
||||
|
||||
def test_triton_vs_torch_with_attn_sink(self):
|
||||
self._run(with_attn_sink=True)
|
||||
|
||||
def test_triton_vs_torch_small_topk(self):
|
||||
self._run(topk=32, num_heads=2)
|
||||
|
||||
def test_triton_vs_torch_negative_indices_are_masked(self):
|
||||
"""Indices < 0 are 'invalid' and must contribute zero to output."""
|
||||
k_cache, _ = _build_kvcache(4, 64, device=self.device, seed=3)
|
||||
q, indices = _build_q_indices(2, 4, 32, 4, 64, device=self.device, seed=99)
|
||||
# Half of each batch's indices set to -1
|
||||
indices[:, :, 16:] = -1
|
||||
topk_length = torch.tensor([32, 32], dtype=torch.int32, device=self.device)
|
||||
|
||||
ref_out, _ = _sm120_sparse_decode_fwd(
|
||||
q, k_cache, indices, topk_length, None, _D, _D**-0.5
|
||||
)
|
||||
tri_out, _ = flash_mla_sparse_decode_triton(
|
||||
q, k_cache, indices, topk_length, None, _D, _D**-0.5
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
tri_out.to(torch.float32),
|
||||
ref_out.to(torch.float32),
|
||||
atol=5e-2,
|
||||
rtol=5e-2,
|
||||
)
|
||||
|
||||
|
||||
class TestApplyAttnSink(CustomTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not torch.cuda.is_available():
|
||||
raise unittest.SkipTest("CUDA required")
|
||||
cls.device = torch.device("cuda")
|
||||
|
||||
def test_sink_zero_means_full_dampen_to_half(self):
|
||||
"""attn_sink == lse implies sink contributes equal weight: output halves."""
|
||||
B, H, D = 1, 2, 8
|
||||
lse = torch.zeros(B, 1, H, device=self.device, dtype=torch.float32)
|
||||
attn_sink = torch.zeros(H, device=self.device, dtype=torch.float32)
|
||||
out = torch.ones(B, 1, H, D, device=self.device, dtype=torch.bfloat16)
|
||||
new_out, new_lse = _apply_attn_sink(out, lse, attn_sink)
|
||||
# combined_lse = logaddexp(0, 0) = log(2). w = exp(-log2) = 0.5.
|
||||
torch.testing.assert_close(
|
||||
new_out.float(),
|
||||
torch.full_like(new_out.float(), 0.5),
|
||||
atol=2e-3,
|
||||
rtol=2e-3,
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
new_lse,
|
||||
torch.full_like(new_lse, 0.6931472), # log(2)
|
||||
atol=1e-5,
|
||||
rtol=1e-5,
|
||||
)
|
||||
|
||||
def test_sink_dead_lse_stays_zero(self):
|
||||
"""lse == -inf (no valid tokens) -> output stays zero (weight=0)."""
|
||||
B, H, D = 1, 2, 4
|
||||
lse = torch.full(
|
||||
(B, 1, H), float("-inf"), device=self.device, dtype=torch.float32
|
||||
)
|
||||
attn_sink = torch.zeros(H, device=self.device, dtype=torch.float32)
|
||||
out = torch.ones(B, 1, H, D, device=self.device, dtype=torch.bfloat16)
|
||||
new_out, _ = _apply_attn_sink(out, lse, attn_sink)
|
||||
torch.testing.assert_close(
|
||||
new_out.float(), torch.zeros_like(new_out.float()), atol=0, rtol=0
|
||||
)
|
||||
|
||||
|
||||
class TestMergePartialAttn(CustomTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not torch.cuda.is_available():
|
||||
raise unittest.SkipTest("CUDA required")
|
||||
cls.device = torch.device("cuda")
|
||||
|
||||
def test_equal_lse_arithmetic_mean(self):
|
||||
"""lse1 == lse2 -> merged output is the arithmetic mean of out1, out2."""
|
||||
B, H, D = 1, 2, 4
|
||||
lse = torch.zeros(B, 1, H, device=self.device, dtype=torch.float32)
|
||||
out1 = torch.ones(B, 1, H, D, device=self.device, dtype=torch.bfloat16)
|
||||
out2 = torch.full((B, 1, H, D), 3.0, device=self.device, dtype=torch.bfloat16)
|
||||
merged, merged_lse = _merge_partial_attn(out1, lse, out2, lse)
|
||||
torch.testing.assert_close(
|
||||
merged.float(),
|
||||
torch.full_like(merged.float(), 2.0),
|
||||
atol=1e-2,
|
||||
rtol=1e-2,
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
merged_lse,
|
||||
torch.full_like(merged_lse, 0.6931472), # log(2)
|
||||
atol=1e-5,
|
||||
rtol=1e-5,
|
||||
)
|
||||
|
||||
def test_one_dead_branch_passes_through(self):
|
||||
"""If lse2 == -inf, merged equals out1."""
|
||||
B, H, D = 1, 1, 4
|
||||
lse1 = torch.zeros(B, 1, H, device=self.device, dtype=torch.float32)
|
||||
lse2 = torch.full(
|
||||
(B, 1, H), float("-inf"), device=self.device, dtype=torch.float32
|
||||
)
|
||||
out1 = torch.full((B, 1, H, D), 2.5, device=self.device, dtype=torch.bfloat16)
|
||||
out2 = torch.full((B, 1, H, D), 99.0, device=self.device, dtype=torch.bfloat16)
|
||||
merged, merged_lse = _merge_partial_attn(out1, lse1, out2, lse2)
|
||||
torch.testing.assert_close(merged.float(), out1.float(), atol=1e-3, rtol=1e-3)
|
||||
torch.testing.assert_close(merged_lse, lse1, atol=1e-5, rtol=1e-5)
|
||||
|
||||
|
||||
class TestEntryPointDispatch(CustomTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not torch.cuda.is_available():
|
||||
raise unittest.SkipTest("CUDA required")
|
||||
cls.device = torch.device("cuda")
|
||||
|
||||
def test_torch_backend_matches_triton_backend(self):
|
||||
"""SGLANG_SM120_TRITON_FLASHMLA toggles backend; both return matching out."""
|
||||
k_cache, _ = _build_kvcache(4, 64, device=self.device, seed=5)
|
||||
q, indices = _build_q_indices(1, 4, 32, 4, 64, device=self.device, seed=13)
|
||||
topk_length = torch.tensor([32], dtype=torch.int32, device=self.device)
|
||||
|
||||
kwargs = dict(
|
||||
q=q,
|
||||
k_cache=k_cache,
|
||||
indices=indices,
|
||||
topk_length=topk_length,
|
||||
attn_sink=None,
|
||||
head_dim_v=_D,
|
||||
softmax_scale=_D**-0.5,
|
||||
)
|
||||
|
||||
with mock.patch.object(fmod, "_sm120_default_backend", "torch"):
|
||||
out_torch, _ = flash_mla_with_kvcache_sm120(**kwargs)
|
||||
with mock.patch.object(fmod, "_sm120_default_backend", "triton"):
|
||||
out_triton, _ = flash_mla_with_kvcache_sm120(**kwargs)
|
||||
|
||||
torch.testing.assert_close(
|
||||
out_torch.to(torch.float32),
|
||||
out_triton.to(torch.float32),
|
||||
atol=5e-2,
|
||||
rtol=5e-2,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(unittest.main())
|
||||
@@ -0,0 +1,314 @@
|
||||
"""SM120 fp8_paged_mqa_logits_torch_sm120 vectorized PyTorch fallback tests.
|
||||
|
||||
Validates that the vectorized SM120-specific implementation matches the loopy
|
||||
reference (`fp8_paged_mqa_logits_torch`) and is CUDA-graph compatible.
|
||||
|
||||
Coverage:
|
||||
- Numeric equivalence vs reference at small shapes
|
||||
- Both KV-cache dtype views (uint8 raw / float8_e4m3fn) — guards against the
|
||||
historic garbled-output bug where Triton kernels treated uint8 bytes as raw
|
||||
integers instead of FP8 (dsv4_sm120_progress.md §4)
|
||||
- Variable per-batch seq_lens with -inf masking semantics
|
||||
- CUDA graph capture + replay equivalence (no .item() / data-dependent shapes)
|
||||
- Shape-assertion guards
|
||||
|
||||
Pure-PyTorch implementations on any CUDA GPU — no SM120 hardware required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.layers.attention.dsv4.indexer import (
|
||||
FP8_DTYPE,
|
||||
fp8_paged_mqa_logits_torch,
|
||||
fp8_paged_mqa_logits_torch_sm120,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cuda_ci(est_time=20, stage="base-b", runner_config="1-gpu-small")
|
||||
|
||||
|
||||
# DSv4 indexer cache layout (fixed by deepseek_v4_memory_pool.DeepSeekV4IndexerPool):
|
||||
# page_size = 64 tokens
|
||||
# head_dim = 128 (FP8 values per token)
|
||||
# quant_block_size = 128 -> num_scales_per_token = 1 (fp32 scale)
|
||||
# per-page memory: [page_size*head_dim FP8 bytes][page_size*4 scale bytes]
|
||||
# = [8192][256] = 8448 bytes
|
||||
# The (block_size, 1, head_dim+4) shape is a fiction for downstream consumers.
|
||||
PAGE_SIZE = 64
|
||||
HEAD_DIM = 128
|
||||
SCALE_BYTES_PER_TOKEN = 4
|
||||
HEAD_DIM_WITH_SF = HEAD_DIM + SCALE_BYTES_PER_TOKEN # 132
|
||||
PAGE_BYTES = PAGE_SIZE * HEAD_DIM + PAGE_SIZE * SCALE_BYTES_PER_TOKEN # 8448
|
||||
|
||||
|
||||
def _build_kvcache(
|
||||
num_pages: int,
|
||||
*,
|
||||
dtype_view: torch.dtype,
|
||||
device: torch.device,
|
||||
seed: int = 0,
|
||||
) -> torch.Tensor:
|
||||
"""Construct a paged KV cache matching the production layout.
|
||||
|
||||
Returns a tensor shaped (num_pages, PAGE_SIZE, 1, HEAD_DIM_WITH_SF) whose
|
||||
underlying memory is the same regardless of `dtype_view`:
|
||||
- [0 : PAGE_SIZE*HEAD_DIM) : random FP8 bit patterns (values)
|
||||
- [PAGE_SIZE*HEAD_DIM : PAGE_BYTES) : random positive fp32 scales (bytes)
|
||||
"""
|
||||
g = torch.Generator(device="cpu").manual_seed(seed)
|
||||
raw = torch.empty(num_pages, PAGE_BYTES, dtype=torch.uint8, device=device)
|
||||
|
||||
# Random FP8 byte pattern for the value section. Bias away from extreme
|
||||
# bit patterns that map to NaN/Inf in float8_e4m3fn (sign|exp4|mantissa3;
|
||||
# exp=0xF mantissa!=0 -> NaN). Restricting to [0, 0x6F] keeps |x| < 256.
|
||||
val_bytes = torch.randint(
|
||||
0, 0x70, (num_pages, PAGE_SIZE * HEAD_DIM), generator=g, dtype=torch.uint8
|
||||
).to(device)
|
||||
raw[:, : PAGE_SIZE * HEAD_DIM] = val_bytes
|
||||
|
||||
# Positive fp32 scales in [0.05, 0.55]. Byte-view into the trailing region.
|
||||
scales = (
|
||||
torch.rand((num_pages, PAGE_SIZE), generator=g, dtype=torch.float32).to(device)
|
||||
* 0.5
|
||||
+ 0.05
|
||||
)
|
||||
raw[:, PAGE_SIZE * HEAD_DIM :] = scales.contiguous().view(torch.uint8)
|
||||
|
||||
kv = raw.view(num_pages, PAGE_SIZE, 1, HEAD_DIM_WITH_SF)
|
||||
return kv if dtype_view == torch.uint8 else kv.view(dtype=dtype_view)
|
||||
|
||||
|
||||
def _build_inputs(
|
||||
batch_size: int,
|
||||
seq_lens: list[int],
|
||||
*,
|
||||
kv_dtype_view: torch.dtype,
|
||||
num_heads: int = 32,
|
||||
device: torch.device = torch.device("cuda"),
|
||||
seed: int = 0,
|
||||
):
|
||||
assert len(seq_lens) == batch_size
|
||||
max_seq_len = max(seq_lens)
|
||||
max_pages = (max_seq_len + PAGE_SIZE - 1) // PAGE_SIZE
|
||||
# One global page pool, batch picks its own page ids.
|
||||
num_pages_total = batch_size * max_pages + 1
|
||||
|
||||
kvcache = _build_kvcache(
|
||||
num_pages_total, dtype_view=kv_dtype_view, device=device, seed=seed
|
||||
)
|
||||
|
||||
g = torch.Generator(device="cpu").manual_seed(seed + 1)
|
||||
# Construct query as random bf16->fp8 to keep values inside fp8 range.
|
||||
q_bf16 = torch.randn(
|
||||
(batch_size, 1, num_heads, HEAD_DIM), generator=g, dtype=torch.float32
|
||||
).to(device)
|
||||
q_bf16 = q_bf16.clamp_(-2.0, 2.0)
|
||||
q_fp8 = q_bf16.to(FP8_DTYPE)
|
||||
if kv_dtype_view == torch.uint8:
|
||||
# Query dtype isn't toggled — only kvcache. q always fp8.
|
||||
pass
|
||||
|
||||
weight = (
|
||||
torch.rand((batch_size, num_heads), generator=g, dtype=torch.float32).to(device)
|
||||
* 0.5
|
||||
)
|
||||
|
||||
seq_lens_t = torch.tensor(seq_lens, dtype=torch.int32, device=device)
|
||||
|
||||
# Each batch occupies its own slice of pages, randomized for realism.
|
||||
page_table = torch.zeros((batch_size, max_pages), dtype=torch.int32, device=device)
|
||||
for i in range(batch_size):
|
||||
page_table[i] = torch.arange(
|
||||
1 + i * max_pages, 1 + (i + 1) * max_pages, dtype=torch.int32, device=device
|
||||
)
|
||||
|
||||
return q_fp8, kvcache, weight, seq_lens_t, page_table, max_seq_len
|
||||
|
||||
|
||||
def _compare(
|
||||
ref: torch.Tensor,
|
||||
sm120: torch.Tensor,
|
||||
seq_lens: torch.Tensor,
|
||||
*,
|
||||
atol: float = 1e-3,
|
||||
rtol: float = 1e-3,
|
||||
):
|
||||
"""Compare reference (uninitialized beyond seq_len) vs SM120 (-inf beyond)."""
|
||||
# Valid positions must match
|
||||
batch_size, max_seq_len = ref.shape
|
||||
for i in range(batch_size):
|
||||
sl = int(seq_lens[i].item())
|
||||
torch.testing.assert_close(
|
||||
ref[i, :sl], sm120[i, :sl], atol=atol, rtol=rtol, equal_nan=False
|
||||
)
|
||||
# Invalid positions in SM120 output must be -inf
|
||||
positions = torch.arange(max_seq_len, device=sm120.device)
|
||||
invalid = positions.unsqueeze(0) >= seq_lens.unsqueeze(1)
|
||||
assert torch.all(
|
||||
torch.isinf(sm120[invalid]) & (sm120[invalid] < 0)
|
||||
), "SM120 output must fill invalid positions with -inf"
|
||||
|
||||
|
||||
class TestSM120PagedMqaLogitsTorch(CustomTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not torch.cuda.is_available():
|
||||
raise unittest.SkipTest("CUDA required")
|
||||
cls.device = torch.device("cuda")
|
||||
|
||||
def _run_one(
|
||||
self,
|
||||
batch_size: int,
|
||||
seq_lens: list[int],
|
||||
kv_dtype_view: torch.dtype,
|
||||
num_heads: int = 32,
|
||||
):
|
||||
q, kv, w, sl, pt, msl = _build_inputs(
|
||||
batch_size,
|
||||
seq_lens,
|
||||
kv_dtype_view=kv_dtype_view,
|
||||
num_heads=num_heads,
|
||||
device=self.device,
|
||||
)
|
||||
# Reference (loopy)
|
||||
ref = fp8_paged_mqa_logits_torch(
|
||||
q,
|
||||
kv,
|
||||
w,
|
||||
sl,
|
||||
pt,
|
||||
deep_gemm_metadata=None,
|
||||
max_seq_len=msl,
|
||||
clean_logits=False,
|
||||
)
|
||||
# SM120 vectorized
|
||||
sm120 = fp8_paged_mqa_logits_torch_sm120(
|
||||
q,
|
||||
kv,
|
||||
w,
|
||||
sl,
|
||||
pt,
|
||||
deep_gemm_metadata=None,
|
||||
max_seq_len=msl,
|
||||
clean_logits=False,
|
||||
)
|
||||
_compare(ref, sm120, sl)
|
||||
|
||||
def test_equiv_fp8_view_bs1(self):
|
||||
self._run_one(1, [128], FP8_DTYPE)
|
||||
|
||||
def test_equiv_fp8_view_bs4_uniform(self):
|
||||
self._run_one(4, [128, 128, 128, 128], FP8_DTYPE)
|
||||
|
||||
def test_equiv_fp8_view_bs4_variable(self):
|
||||
self._run_one(4, [40, 96, 200, 256], FP8_DTYPE)
|
||||
|
||||
def test_equiv_uint8_view_bs1(self):
|
||||
"""Regression guard: KV cache viewed as raw uint8 (no FP8 dtype hint).
|
||||
|
||||
Historic bug (progress doc §4): some kernels treated uint8 bytes as raw
|
||||
integers, producing garbled attention output. The vectorized impl must
|
||||
produce the same numbers as the loopy reference regardless of the
|
||||
caller's KV view dtype.
|
||||
"""
|
||||
self._run_one(1, [128], torch.uint8)
|
||||
|
||||
def test_equiv_uint8_view_bs4_variable(self):
|
||||
self._run_one(4, [40, 96, 200, 256], torch.uint8)
|
||||
|
||||
def test_seq_lens_zero_remainder(self):
|
||||
"""seq_len not aligned to page_size — last partial page must mask correctly."""
|
||||
self._run_one(2, [65, 129], FP8_DTYPE) # 65 = 1 full page + 1 token
|
||||
|
||||
def test_seq_lens_full_pages(self):
|
||||
self._run_one(2, [64, 192], FP8_DTYPE)
|
||||
|
||||
def test_seq_lens_2d_input_accepted(self):
|
||||
"""SM120 impl squeezes seq_lens if dim>1 (matches indexer.py call site)."""
|
||||
q, kv, w, sl, pt, msl = _build_inputs(
|
||||
2, [64, 128], kv_dtype_view=FP8_DTYPE, device=self.device
|
||||
)
|
||||
sl_2d = sl.unsqueeze(-1) # (B, 1)
|
||||
ref = fp8_paged_mqa_logits_torch(
|
||||
q, kv, w, sl, pt, None, max_seq_len=msl, clean_logits=False
|
||||
)
|
||||
sm120 = fp8_paged_mqa_logits_torch_sm120(
|
||||
q, kv, w, sl_2d, pt, None, max_seq_len=msl, clean_logits=False
|
||||
)
|
||||
_compare(ref, sm120, sl)
|
||||
|
||||
def test_cuda_graph_capture_and_replay(self):
|
||||
"""No .item() / data-dependent control flow — must be CUDA-graph safe."""
|
||||
batch_size = 2
|
||||
seq_lens = [128, 192]
|
||||
q, kv, w, sl, pt, msl = _build_inputs(
|
||||
batch_size, seq_lens, kv_dtype_view=FP8_DTYPE, device=self.device
|
||||
)
|
||||
|
||||
# Warmup outside graph
|
||||
for _ in range(2):
|
||||
_ = fp8_paged_mqa_logits_torch_sm120(
|
||||
q, kv, w, sl, pt, None, max_seq_len=msl, clean_logits=False
|
||||
)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
# Pre-allocated output (graph replay reuses this buffer)
|
||||
static_logits_holder = {}
|
||||
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
out = fp8_paged_mqa_logits_torch_sm120(
|
||||
q, kv, w, sl, pt, None, max_seq_len=msl, clean_logits=False
|
||||
)
|
||||
static_logits_holder["out"] = out
|
||||
|
||||
# Eager reference using the same inputs
|
||||
ref = fp8_paged_mqa_logits_torch_sm120(
|
||||
q, kv, w, sl, pt, None, max_seq_len=msl, clean_logits=False
|
||||
)
|
||||
|
||||
graph.replay()
|
||||
torch.cuda.synchronize()
|
||||
torch.testing.assert_close(
|
||||
static_logits_holder["out"], ref, atol=1e-5, rtol=1e-5
|
||||
)
|
||||
|
||||
# Replay with a different seq_lens (in-place edit of the captured tensor)
|
||||
sl_new = torch.tensor([64, 256], dtype=torch.int32, device=self.device)
|
||||
sl.copy_(sl_new)
|
||||
graph.replay()
|
||||
torch.cuda.synchronize()
|
||||
ref_new = fp8_paged_mqa_logits_torch_sm120(
|
||||
q, kv, w, sl, pt, None, max_seq_len=msl, clean_logits=False
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
static_logits_holder["out"], ref_new, atol=1e-5, rtol=1e-5
|
||||
)
|
||||
|
||||
def test_shape_assertions(self):
|
||||
"""Wrong head_dim or block_size must raise."""
|
||||
q, kv, w, sl, pt, msl = _build_inputs(
|
||||
1, [64], kv_dtype_view=FP8_DTYPE, device=self.device
|
||||
)
|
||||
# head_dim != 128
|
||||
bad_q = q[..., :64]
|
||||
with self.assertRaises(AssertionError):
|
||||
fp8_paged_mqa_logits_torch_sm120(
|
||||
bad_q, kv, w, sl, pt, None, max_seq_len=msl, clean_logits=False
|
||||
)
|
||||
# clean_logits=True not supported
|
||||
with self.assertRaises(AssertionError):
|
||||
fp8_paged_mqa_logits_torch_sm120(
|
||||
q, kv, w, sl, pt, None, max_seq_len=msl, clean_logits=True
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(unittest.main())
|
||||
Reference in New Issue
Block a user