[AMD] Enable FP4 indexer for Deepseek V4 (#37353)
Co-authored-by: 1am9trash <1am9trash@gmail.com> Co-authored-by: AMD-yanfeiwang <256076023+AMD-yanfeiwang@users.noreply.github.com> Co-authored-by: Thomas Wang <thomawan@amd.com>
This commit is contained in:
co-authored by
1am9trash
AMD-yanfeiwang
Thomas Wang
parent
f6aed6ec53
commit
f8cbf000f4
@@ -0,0 +1,776 @@
|
||||
"""HIP counterpart of ``test_fp4_indexer.py`` for the AITER FP4 DeepSeek-V4 indexer.
|
||||
|
||||
The CUDA path exposes the quantizer, the cache store and the fused
|
||||
norm/RoPE/store as separate Triton entry points. On HIP all three collapse into
|
||||
``aiter_k_indexer_fp4_cache_write``, and the query side into
|
||||
``aiter_q_indexer_fp4``, so the tests below mirror the CUDA file's four cases
|
||||
through those two ops. The FP4 grid and the UE8M0 scale rule are identical on
|
||||
both targets, so the reference helpers are shared verbatim.
|
||||
|
||||
Two layout details differ from CUDA and are pinned here because nothing else
|
||||
checks them: the K cache keeps payload and scale in separate buffers with the
|
||||
scale token axis shuffled, and the Q scale is emitted preshuffled into the
|
||||
logits kernel's ABI layout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.kernels.ops.attention.deepseek_v4_rope import precompute_freqs_cis
|
||||
from sglang.kernels.ops.attention.dsv4 import (
|
||||
CompressorDecodePlan,
|
||||
compress_norm_rope_store,
|
||||
)
|
||||
from sglang.kernels.ops.attention.dsv4.compress import CompressorPrefillPlan
|
||||
from sglang.kernels.ops.attention.dsv4.fp4_indexer_hip import (
|
||||
FP4KWriteMetadata,
|
||||
_decode_cta_count,
|
||||
_guard_page_table,
|
||||
aiter_fp4_paged_mqa_logits,
|
||||
aiter_k_indexer_fp4_cache_write,
|
||||
aiter_q_indexer_fp4,
|
||||
prepare_fp4_decode_workspace,
|
||||
prepare_fp4_k_write_metadata,
|
||||
prepare_fp4_prefill_workspace,
|
||||
)
|
||||
from sglang.srt.utils import get_device, is_gfx95_supported, is_hip
|
||||
from sglang.test.ci.ci_register import register_amd_ci
|
||||
|
||||
register_amd_ci(est_time=120, suite="stage-b-test-1-gpu-small-amd-mi35x")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (is_hip() and is_gfx95_supported()),
|
||||
reason="The FP4 indexer adapters wrap AITER CDNA4 (gfx95x) kernels.",
|
||||
)
|
||||
|
||||
HEAD_DIM = 128
|
||||
FP4_DIM = HEAD_DIM // 2
|
||||
GROUP_SIZE = 32
|
||||
SCALE_GROUPS = HEAD_DIM // GROUP_SIZE
|
||||
PAGE_SIZE = 64
|
||||
E2M1_MAX = 6.0
|
||||
NUM_HEADS = 64
|
||||
ROPE_DIM = 64
|
||||
NORM_EPS = 1.0e-6
|
||||
# Tokens per group along the shuffled scale axis; see _ref_store_fp4_index_cache.
|
||||
SCALE_SHUFFLE_TILE = 16
|
||||
|
||||
_E2M1_GRID = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0]
|
||||
|
||||
|
||||
def _ceil_ue8m0_exp_ref(x: torch.Tensor) -> torch.Tensor:
|
||||
bits = x.to(torch.float32).contiguous().view(torch.int32)
|
||||
exp = (bits >> 23) & 0xFF
|
||||
mantissa = bits & 0x7FFFFF
|
||||
exp = exp + (mantissa != 0).to(torch.int32)
|
||||
return exp.clamp(1, 254)
|
||||
|
||||
|
||||
def _fp4_e2m1_code_ref(x: torch.Tensor) -> torch.Tensor:
|
||||
ax = torch.minimum(x.abs(), torch.tensor(E2M1_MAX, device=x.device))
|
||||
idx = torch.zeros_like(ax, dtype=torch.uint8)
|
||||
for threshold in (0.25, 0.75, 1.25, 1.75, 2.5, 3.5, 5.0):
|
||||
idx += (ax > threshold).to(torch.uint8)
|
||||
sign = ((x < 0) & (idx != 0)).to(torch.uint8) * 8
|
||||
return idx | sign
|
||||
|
||||
|
||||
def _ref_quantize_fp4_indexer(x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Nibble-packed E2M1 payload and per-group UE8M0 exponents.
|
||||
|
||||
Same rule as the CUDA reference, except the exponents stay one byte per
|
||||
group instead of being packed into an int32: the HIP cache stores them in a
|
||||
separate buffer, one byte per (group, token).
|
||||
"""
|
||||
x = x.contiguous().view(-1, HEAD_DIM).float()
|
||||
groups = x.view(-1, SCALE_GROUPS, GROUP_SIZE)
|
||||
scale_raw = (groups.abs().amax(dim=-1) / E2M1_MAX).clamp_min(1.0e-4)
|
||||
scale_exp = _ceil_ue8m0_exp_ref(scale_raw)
|
||||
scale = (scale_exp << 23).contiguous().view(torch.float32)
|
||||
|
||||
scaled = (groups / scale.unsqueeze(-1)).view(-1, HEAD_DIM)
|
||||
code = _fp4_e2m1_code_ref(scaled)
|
||||
packed = (code[:, 0::2].to(torch.int16) | (code[:, 1::2].to(torch.int16) << 4)).to(
|
||||
torch.uint8
|
||||
)
|
||||
return packed, scale_exp.to(torch.uint8)
|
||||
|
||||
|
||||
def _canonical_zero(packed: torch.Tensor) -> torch.Tensor:
|
||||
"""Fold negative zero onto positive zero in both nibbles.
|
||||
|
||||
AITER keeps the sign bit when a lane quantizes to zero magnitude, while the
|
||||
reference clears it. Both decode to 0.0, so normalize before comparing the
|
||||
packed bytes.
|
||||
"""
|
||||
lo, hi = packed & 0x0F, packed >> 4
|
||||
zero = torch.zeros_like(lo)
|
||||
lo = torch.where(lo == 0x8, zero, lo)
|
||||
hi = torch.where(hi == 0x8, zero, hi)
|
||||
return lo | (hi << 4)
|
||||
|
||||
|
||||
def _ref_dequantize_fp4_indexer(
|
||||
packed: torch.Tensor, scale_exp: torch.Tensor
|
||||
) -> torch.Tensor:
|
||||
"""Inverse of :func:`_ref_quantize_fp4_indexer`, for value comparisons."""
|
||||
packed = packed.reshape(-1, FP4_DIM)
|
||||
codes = torch.stack([packed & 0x0F, packed >> 4], dim=-1).long()
|
||||
values = torch.tensor(_E2M1_GRID + [-v for v in _E2M1_GRID], device=packed.device)[
|
||||
codes.reshape(-1, SCALE_GROUPS, GROUP_SIZE)
|
||||
]
|
||||
factor = (scale_exp.reshape(-1, SCALE_GROUPS).to(torch.int32) << 23).view(
|
||||
torch.float32
|
||||
)
|
||||
return (values * factor.unsqueeze(-1)).reshape(-1, HEAD_DIM)
|
||||
|
||||
|
||||
def _empty_index_k_cache(num_pages: int) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Allocate the split buffers ``uses_aiter_fp4_layout`` creates per layer."""
|
||||
payload = torch.zeros(
|
||||
(num_pages, 1, SCALE_GROUPS, PAGE_SIZE, GROUP_SIZE // 2),
|
||||
dtype=torch.uint8,
|
||||
device=get_device(),
|
||||
).view(torch.float4_e2m1fn_x2)
|
||||
scale = torch.zeros(
|
||||
(num_pages, 1, SCALE_GROUPS, PAGE_SIZE), dtype=torch.uint8, device=get_device()
|
||||
)
|
||||
return payload, scale
|
||||
|
||||
|
||||
def _ref_store_fp4_index_cache(
|
||||
x_fp4: torch.Tensor,
|
||||
x_sf: torch.Tensor,
|
||||
loc: torch.Tensor,
|
||||
num_pages: int,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Build the payload and scale buffers a correct writer would produce.
|
||||
|
||||
Payload rows stay in slot order. The scale buffer stores each page's tokens
|
||||
as the transpose of a ``SCALE_SHUFFLE_TILE x 4`` tile, so token ``t`` lands
|
||||
at ``(t % 16) * 4 + t // 16``. Rows whose ``loc`` is negative are skipped and
|
||||
must be left at zero.
|
||||
"""
|
||||
payload = torch.zeros(
|
||||
(num_pages, 1, SCALE_GROUPS, PAGE_SIZE, GROUP_SIZE // 2),
|
||||
dtype=torch.uint8,
|
||||
device=x_fp4.device,
|
||||
)
|
||||
scale = torch.zeros(
|
||||
(num_pages, 1, SCALE_GROUPS, PAGE_SIZE), dtype=torch.uint8, device=x_fp4.device
|
||||
)
|
||||
for token_id in range(x_fp4.shape[0]):
|
||||
cache_loc = int(loc[token_id].item())
|
||||
if cache_loc < 0:
|
||||
continue
|
||||
page, offset = divmod(cache_loc, PAGE_SIZE)
|
||||
shuffled = (offset % SCALE_SHUFFLE_TILE) * 4 + offset // SCALE_SHUFFLE_TILE
|
||||
for group in range(SCALE_GROUPS):
|
||||
lo = group * (GROUP_SIZE // 2)
|
||||
payload[page, 0, group, offset] = x_fp4[token_id, lo : lo + GROUP_SIZE // 2]
|
||||
scale[page, 0, group, shuffled] = x_sf[token_id, group]
|
||||
return payload, scale
|
||||
|
||||
|
||||
def _read_index_k_cache(payload, scale, loc: torch.Tensor):
|
||||
"""Gather ``loc`` back out of the cache as (packed nibbles, exponents)."""
|
||||
page, offset = loc // PAGE_SIZE, loc % PAGE_SIZE
|
||||
shuffled = (offset % SCALE_SHUFFLE_TILE) * 4 + offset // SCALE_SHUFFLE_TILE
|
||||
packed = payload.view(torch.uint8)[page, 0, :, offset].reshape(-1, FP4_DIM)
|
||||
return packed, scale[page, 0, :, shuffled]
|
||||
|
||||
|
||||
def _rope_tables(
|
||||
max_pos: int,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
"""The bf16 cos/sin pair ``DeepseekV4AttentionMLA`` hands the FP4 adapters."""
|
||||
freqs_cis = precompute_freqs_cis(ROPE_DIM, max_pos, 0, 10000, 1, 32, 1).to(
|
||||
get_device()
|
||||
)
|
||||
return (
|
||||
freqs_cis.real.to(torch.bfloat16),
|
||||
freqs_cis.imag.to(torch.bfloat16),
|
||||
freqs_cis,
|
||||
)
|
||||
|
||||
|
||||
def _ref_apply_rope(x, cos, sin, positions: torch.Tensor) -> torch.Tensor:
|
||||
"""Interleaved (non-neox) RoPE over the trailing ``ROPE_DIM`` lanes."""
|
||||
shape = (positions.shape[0], *(1,) * (x.dim() - 2), ROPE_DIM // 2)
|
||||
c = cos.float()[positions].reshape(shape)
|
||||
s = sin.float()[positions].reshape(shape)
|
||||
pairs = x[..., ROPE_DIM:].reshape(*x.shape[:-1], ROPE_DIM // 2, 2)
|
||||
even, odd = pairs[..., 0], pairs[..., 1]
|
||||
rotated = torch.stack([even * c - odd * s, even * s + odd * c], dim=-1)
|
||||
return torch.cat([x[..., :ROPE_DIM], rotated.flatten(-2)], dim=-1)
|
||||
|
||||
|
||||
def _ref_hadamard(x: torch.Tensor) -> torch.Tensor:
|
||||
"""Orthonormal Sylvester Hadamard, the rotation ``do_rotate_act`` applies.
|
||||
|
||||
Written out in torch instead of reusing ``ops.quantization.hadamard``
|
||||
because that kernel is CUDA-only and does not build under ROCm.
|
||||
"""
|
||||
h = torch.ones(1, 1, device=get_device(), dtype=torch.float32)
|
||||
while h.shape[0] < HEAD_DIM:
|
||||
h = torch.cat([torch.cat([h, h], 1), torch.cat([h, -h], 1)], 0)
|
||||
return x.float() @ (h * HEAD_DIM**-0.5)
|
||||
|
||||
|
||||
def _ref_k_transform(k, norm_weight, cos, sin, positions) -> torch.Tensor:
|
||||
x = k.float()
|
||||
x = x * torch.rsqrt((x * x).mean(dim=-1, keepdim=True) + NORM_EPS)
|
||||
x = x * norm_weight.float()
|
||||
return _ref_hadamard(_ref_apply_rope(x, cos, sin, positions))
|
||||
|
||||
|
||||
def _ref_q_transform(q, cos, sin, positions) -> torch.Tensor:
|
||||
return _ref_hadamard(_ref_apply_rope(q.float(), cos, sin, positions))
|
||||
|
||||
|
||||
def _write_index_k_cache(
|
||||
k, norm_weight, cos, sin, positions, loc, payload, scale
|
||||
) -> None:
|
||||
aiter_k_indexer_fp4_cache_write(
|
||||
k=k,
|
||||
norm_weight=norm_weight,
|
||||
norm_epsilon=NORM_EPS,
|
||||
cos=cos,
|
||||
sin=sin,
|
||||
plan=None,
|
||||
out_loc=None,
|
||||
k_payload=payload,
|
||||
k_scale=scale,
|
||||
write_metadata=FP4KWriteMetadata(positions, loc),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The four cases mirrored from the CUDA file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_tokens", [1, 7, 96])
|
||||
def test_quantize_fp4_indexer_tensor(num_tokens: int) -> None:
|
||||
"""The fused writer's quantization matches the shared FP4 reference exactly."""
|
||||
torch.manual_seed(num_tokens)
|
||||
cos, sin, _ = _rope_tables(512)
|
||||
payload, scale = _empty_index_k_cache(-(-num_tokens // PAGE_SIZE) + 1)
|
||||
x = torch.randn(num_tokens, HEAD_DIM, device=get_device(), dtype=torch.bfloat16)
|
||||
x[0, :8] = torch.tensor(
|
||||
[-8.0, -6.0, -3.0, -1.5, 0.0, 0.5, 2.0, 8.0],
|
||||
device=get_device(),
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
norm_weight = torch.randn(HEAD_DIM, device=get_device(), dtype=torch.bfloat16)
|
||||
positions = torch.arange(num_tokens, device=get_device(), dtype=torch.int64) * 3
|
||||
loc = torch.arange(num_tokens, device=get_device(), dtype=torch.int64)
|
||||
|
||||
_write_index_k_cache(x, norm_weight, cos, sin, positions, loc, payload, scale)
|
||||
|
||||
ref_fp4, ref_sf = _ref_quantize_fp4_indexer(
|
||||
_ref_k_transform(x, norm_weight, cos, sin, positions)
|
||||
)
|
||||
stored_fp4, stored_sf = _read_index_k_cache(payload, scale, loc)
|
||||
torch.testing.assert_close(stored_sf, ref_sf)
|
||||
torch.testing.assert_close(_canonical_zero(stored_fp4), _canonical_zero(ref_fp4))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_tokens", [1, 16, 96])
|
||||
def test_fp4_index_cache_store_layout(num_tokens: int) -> None:
|
||||
"""Scattered slots land in the paged layout and touch nothing else."""
|
||||
torch.manual_seed(num_tokens + 50)
|
||||
cos, sin, _ = _rope_tables(512)
|
||||
num_pages = max(2, -(-num_tokens // PAGE_SIZE) + 1)
|
||||
payload, scale = _empty_index_k_cache(num_pages)
|
||||
x = torch.randn(num_tokens, HEAD_DIM, device=get_device(), dtype=torch.bfloat16)
|
||||
norm_weight = torch.randn(HEAD_DIM, device=get_device(), dtype=torch.bfloat16)
|
||||
positions = torch.arange(num_tokens, device=get_device(), dtype=torch.int64) * 3
|
||||
loc = torch.randperm(num_pages * PAGE_SIZE, device=get_device())[:num_tokens].to(
|
||||
torch.int64
|
||||
)
|
||||
# A masked row must be dropped rather than written to some default slot.
|
||||
loc[num_tokens // 2] = -1
|
||||
|
||||
_write_index_k_cache(x, norm_weight, cos, sin, positions, loc, payload, scale)
|
||||
|
||||
ref_fp4, ref_sf = _ref_quantize_fp4_indexer(
|
||||
_ref_k_transform(x, norm_weight, cos, sin, positions)
|
||||
)
|
||||
expected_payload, expected_scale = _ref_store_fp4_index_cache(
|
||||
ref_fp4, ref_sf, loc, num_pages
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
_canonical_zero(payload.view(torch.uint8)), _canonical_zero(expected_payload)
|
||||
)
|
||||
torch.testing.assert_close(scale, expected_scale)
|
||||
|
||||
|
||||
# 17 and 33 straddle the 16-token scale shuffle tile, 65 the 64-token page.
|
||||
@pytest.mark.parametrize("num_tokens", [1, 16, 17, 33, 65, 96])
|
||||
def test_fp4_fused_norm_rope_store_layout(num_tokens: int) -> None:
|
||||
"""The real ``compress_norm_rope_store`` entry point, plan and metadata included."""
|
||||
torch.manual_seed(num_tokens + 100)
|
||||
num_pages = -(-num_tokens // PAGE_SIZE) + 1
|
||||
compress_ratio = 4
|
||||
kv = torch.randn(num_tokens, HEAD_DIM, device=get_device(), dtype=torch.bfloat16)
|
||||
norm_weight = torch.randn(HEAD_DIM, device=get_device(), dtype=torch.bfloat16)
|
||||
seq_lens = (
|
||||
torch.arange(1, num_tokens + 1, device=get_device(), dtype=torch.int64)
|
||||
* compress_ratio
|
||||
)
|
||||
req_pool_indices = torch.arange(num_tokens, device=get_device(), dtype=torch.int64)
|
||||
plan = CompressorDecodePlan.generate_legacy(
|
||||
compress_ratio, req_pool_indices, seq_lens
|
||||
)
|
||||
loc = torch.arange(num_tokens, device=get_device(), dtype=torch.int64)
|
||||
rope_len = int(seq_lens.max().item()) + 1
|
||||
cos, sin, freqs_cis = _rope_tables(rope_len)
|
||||
payload, scale = _empty_index_k_cache(num_pages)
|
||||
|
||||
metadata = prepare_fp4_k_write_metadata(plan, loc, rope_len)
|
||||
compress_norm_rope_store(
|
||||
kv.clone(),
|
||||
plan,
|
||||
norm_weight=norm_weight,
|
||||
norm_eps=NORM_EPS,
|
||||
freq_cis=freqs_cis,
|
||||
out_loc=loc,
|
||||
kvcache=payload,
|
||||
page_size=PAGE_SIZE,
|
||||
use_fp4=True,
|
||||
kvcache_scale=scale,
|
||||
rope_cache=(cos, sin),
|
||||
fp4_k_write_metadata=metadata,
|
||||
)
|
||||
|
||||
# The plan drives RoPE off the compression boundary, not the token index.
|
||||
torch.testing.assert_close(metadata.positions, seq_lens - compress_ratio)
|
||||
ref_fp4, ref_sf = _ref_quantize_fp4_indexer(
|
||||
_ref_k_transform(kv, norm_weight, cos, sin, metadata.positions)
|
||||
)
|
||||
expected_payload, expected_scale = _ref_store_fp4_index_cache(
|
||||
ref_fp4, ref_sf, metadata.slots, num_pages
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
_canonical_zero(payload.view(torch.uint8)), _canonical_zero(expected_payload)
|
||||
)
|
||||
torch.testing.assert_close(scale, expected_scale)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_size", [1, 5, 17])
|
||||
def test_fp4_fused_q_indexer_rope_hadamard_quant(batch_size: int) -> None:
|
||||
torch.manual_seed(batch_size + 200)
|
||||
cos, sin, _ = _rope_tables(256)
|
||||
q = torch.randn(
|
||||
batch_size, NUM_HEADS, HEAD_DIM, device=get_device(), dtype=torch.bfloat16
|
||||
)
|
||||
positions = (
|
||||
torch.arange(batch_size, device=get_device(), dtype=torch.int64) * 7
|
||||
) % 63
|
||||
|
||||
q_fp4, q_sf = aiter_q_indexer_fp4(q.contiguous(), cos, sin, positions)
|
||||
|
||||
ref_fp4, ref_sf = _ref_quantize_fp4_indexer(
|
||||
_ref_q_transform(q, cos, sin, positions)
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
_canonical_zero(q_fp4.view(torch.uint8).reshape(-1, FP4_DIM)),
|
||||
_canonical_zero(ref_fp4),
|
||||
)
|
||||
# Unlike the CUDA path, the scales are emitted already preshuffled into the
|
||||
# logits kernel's ABI: heads split as (m_tiles, 16) and moved behind the
|
||||
# group axis. Nothing downstream reorders them, and every candidate layout
|
||||
# shares the tensor's shape, so a regression here is silent.
|
||||
m_tiles, k_tiles = NUM_HEADS // 16, HEAD_DIM // 128
|
||||
expected_sf = (
|
||||
ref_sf.reshape(batch_size, m_tiles, 16, k_tiles, SCALE_GROUPS)
|
||||
.permute(0, 3, 4, 2, 1)
|
||||
.contiguous()
|
||||
)
|
||||
torch.testing.assert_close(q_sf.reshape(expected_sf.shape), expected_sf)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HIP-only surface: schedule bookkeeping and the paged MQA logits kernel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("logical_width", [1, 3, 4, 5, 8, 33])
|
||||
def test_guard_page_table_pads_to_schedule_granularity(logical_width: int) -> None:
|
||||
rows = 3
|
||||
page_table = torch.arange(
|
||||
1, rows * logical_width + 1, device=get_device(), dtype=torch.int32
|
||||
).reshape(rows, logical_width)
|
||||
|
||||
guarded, max_seq_len = _guard_page_table(page_table)
|
||||
|
||||
padded_width = max(4, -(-logical_width // 4) * 4)
|
||||
assert guarded.shape == (rows, padded_width + 4)
|
||||
assert guarded.dtype is torch.int32
|
||||
assert max_seq_len == padded_width * PAGE_SIZE
|
||||
torch.testing.assert_close(guarded[:, :logical_width], page_table)
|
||||
assert guarded[:, logical_width:].eq(0).all()
|
||||
|
||||
|
||||
def test_guard_page_table_refreshes_reused_buffer() -> None:
|
||||
rows, width = 2, 6
|
||||
first = torch.arange(rows * width, device=get_device(), dtype=torch.int32).reshape(
|
||||
rows, width
|
||||
)
|
||||
guarded, _ = _guard_page_table(first)
|
||||
|
||||
refreshed, _ = _guard_page_table(first + 100, out=guarded)
|
||||
|
||||
assert refreshed.data_ptr() == guarded.data_ptr()
|
||||
torch.testing.assert_close(refreshed[:, :width], first + 100)
|
||||
assert refreshed[:, width:].eq(0).all()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"num_queries,max_seq_len", [(1, 256), (8, 4096), (512, 256), (4096, 65536)]
|
||||
)
|
||||
def test_decode_cta_count_stays_within_available_chunks(
|
||||
num_queries: int, max_seq_len: int
|
||||
) -> None:
|
||||
chunks_per_seq = max(1, -(-max_seq_len // 256))
|
||||
|
||||
cta_count = _decode_cta_count(num_queries, max_seq_len)
|
||||
|
||||
assert 1 <= cta_count <= num_queries * chunks_per_seq
|
||||
assert cta_count <= max(1024, num_queries * 4)
|
||||
|
||||
|
||||
def _decode_plan(seq_lens: torch.Tensor, compress_ratio: int) -> CompressorDecodePlan:
|
||||
"""Hand-build the 16-byte decode plan rows the metadata builder reads."""
|
||||
words = torch.zeros((seq_lens.shape[0], 4), dtype=torch.int32, device=get_device())
|
||||
words[:, 0] = seq_lens.to(torch.int32)
|
||||
return CompressorDecodePlan(compress_ratio, words.view(torch.uint8))
|
||||
|
||||
|
||||
def _prefill_plan(
|
||||
seq_lens: torch.Tensor, ragged_ids: torch.Tensor, compress_ratio: int
|
||||
) -> CompressorPrefillPlan:
|
||||
words = torch.zeros((seq_lens.shape[0], 4), dtype=torch.int32, device=get_device())
|
||||
words[:, 0] = seq_lens.to(torch.int32)
|
||||
words[:, 1] = ragged_ids.to(torch.int32)
|
||||
return CompressorPrefillPlan(
|
||||
compress_ratio,
|
||||
words.view(torch.uint8),
|
||||
torch.zeros((seq_lens.shape[0], 8), dtype=torch.uint8, device=get_device()),
|
||||
)
|
||||
|
||||
|
||||
def test_k_write_metadata_decode_masks_unaligned_and_out_of_range() -> None:
|
||||
compress_ratio, rope_len = 4, 4096
|
||||
seq_lens = torch.tensor(
|
||||
[8, 9, 0, rope_len + compress_ratio], device=get_device(), dtype=torch.int64
|
||||
)
|
||||
out_loc = torch.tensor([11, 22, 33, 44], device=get_device(), dtype=torch.int64)
|
||||
|
||||
meta = prepare_fp4_k_write_metadata(
|
||||
_decode_plan(seq_lens, compress_ratio), out_loc, rope_len
|
||||
)
|
||||
|
||||
# Row 0 is the only aligned, in-range row; 1 is unaligned, 2 has a negative
|
||||
# RoPE position and 3 runs past the table. Only the slot mask has to cover
|
||||
# all three: an out-of-range position is additionally clamped to 0 to keep
|
||||
# the RoPE gather in bounds, but an unaligned row keeps its position and is
|
||||
# dropped by its -1 slot alone.
|
||||
torch.testing.assert_close(
|
||||
meta.slots,
|
||||
torch.tensor([11, -1, -1, -1], device=get_device(), dtype=torch.int64),
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
meta.positions,
|
||||
torch.tensor([4, 5, 0, 0], device=get_device(), dtype=torch.int64),
|
||||
)
|
||||
|
||||
|
||||
def test_k_write_metadata_prefill_gathers_ragged_slots() -> None:
|
||||
compress_ratio = 4
|
||||
seq_lens = torch.tensor([4, 8, 12, 16], device=get_device(), dtype=torch.int64)
|
||||
ragged_ids = torch.tensor([2, 0, 5, 9], device=get_device(), dtype=torch.int64)
|
||||
out_loc = torch.arange(6, device=get_device(), dtype=torch.int64) * 7
|
||||
|
||||
meta = prepare_fp4_k_write_metadata(
|
||||
_prefill_plan(seq_lens, ragged_ids, compress_ratio), out_loc, 4096
|
||||
)
|
||||
|
||||
# ragged_id 9 is past the end of out_loc and must be dropped, not clamped.
|
||||
torch.testing.assert_close(
|
||||
meta.slots,
|
||||
torch.tensor([14, 0, 35, -1], device=get_device(), dtype=torch.int64),
|
||||
)
|
||||
torch.testing.assert_close(meta.positions, seq_lens - compress_ratio)
|
||||
|
||||
|
||||
def test_k_write_metadata_prefill_with_empty_out_loc_writes_nothing() -> None:
|
||||
seq_lens = torch.tensor([4, 8, 12], device=get_device(), dtype=torch.int64)
|
||||
plan = _prefill_plan(seq_lens, torch.zeros_like(seq_lens), 4)
|
||||
|
||||
meta = prepare_fp4_k_write_metadata(
|
||||
plan, torch.empty(0, device=get_device(), dtype=torch.int64), 4096
|
||||
)
|
||||
|
||||
assert meta.slots.eq(-1).all()
|
||||
|
||||
|
||||
def _build_logits_case(
|
||||
batch: int,
|
||||
seq_len: int,
|
||||
*,
|
||||
ctx_lens: list[int] | None = None,
|
||||
shuffle_pages: bool = False,
|
||||
):
|
||||
"""Populate an FP4 K cache and quantized Q for one synthetic indexer step.
|
||||
|
||||
Every slot of every page is written, including the tail past ``ctx_lens``,
|
||||
so a last block that is only partly in context still has live neighbours
|
||||
that must not leak into the scored range.
|
||||
"""
|
||||
pages_per_seq = -(-seq_len // PAGE_SIZE)
|
||||
padded_len = pages_per_seq * PAGE_SIZE
|
||||
num_pages = batch * pages_per_seq
|
||||
cos, sin, _ = _rope_tables(max(padded_len, 256))
|
||||
payload, scale = _empty_index_k_cache(num_pages)
|
||||
|
||||
physical = (
|
||||
torch.randperm(num_pages, device=get_device())
|
||||
if shuffle_pages
|
||||
else torch.arange(num_pages, device=get_device())
|
||||
)
|
||||
page_table = physical.to(torch.int32).reshape(batch, pages_per_seq)
|
||||
context = torch.tensor(
|
||||
ctx_lens if ctx_lens is not None else [seq_len] * batch,
|
||||
device=get_device(),
|
||||
dtype=torch.int32,
|
||||
)
|
||||
|
||||
kv_positions = (
|
||||
torch.arange(padded_len, device=get_device(), dtype=torch.int64)
|
||||
.repeat(batch)
|
||||
.reshape(batch, padded_len)
|
||||
)
|
||||
loc = (
|
||||
page_table.long()[:, :, None] * PAGE_SIZE
|
||||
+ torch.arange(PAGE_SIZE, device=get_device(), dtype=torch.int64)[None, None, :]
|
||||
).reshape(batch, padded_len)
|
||||
|
||||
k = torch.randn(
|
||||
batch, padded_len, HEAD_DIM, device=get_device(), dtype=torch.bfloat16
|
||||
)
|
||||
norm_weight = torch.randn(HEAD_DIM, device=get_device(), dtype=torch.bfloat16)
|
||||
_write_index_k_cache(
|
||||
k.reshape(-1, HEAD_DIM),
|
||||
norm_weight,
|
||||
cos,
|
||||
sin,
|
||||
kv_positions.reshape(-1),
|
||||
loc.reshape(-1),
|
||||
payload,
|
||||
scale,
|
||||
)
|
||||
k_ref = _ref_k_transform(
|
||||
k.reshape(-1, HEAD_DIM), norm_weight, cos, sin, kv_positions.reshape(-1)
|
||||
).reshape(batch, padded_len, HEAD_DIM)
|
||||
|
||||
q = torch.randn(
|
||||
batch, NUM_HEADS, HEAD_DIM, device=get_device(), dtype=torch.bfloat16
|
||||
)
|
||||
q_positions = (context.long() - 1).clamp_min(0)
|
||||
q_fp4, q_scale = aiter_q_indexer_fp4(q.contiguous(), cos, sin, q_positions)
|
||||
q_ref = _ref_q_transform(q, cos, sin, q_positions)
|
||||
|
||||
# ``C4Indexer.compute_weights`` runs a bf16 projection and the adapter
|
||||
# forwards the result unconverted, so the kernel is fed bf16 weights.
|
||||
weights = torch.randn(batch, NUM_HEADS, device=get_device(), dtype=torch.bfloat16)
|
||||
weight_scale = HEAD_DIM**-0.5 * NUM_HEADS**-0.5
|
||||
|
||||
q_dq = _ref_dequantize_fp4_indexer(
|
||||
q_fp4.view(torch.uint8), _ref_quantize_fp4_indexer(q_ref)[1]
|
||||
).reshape(batch, NUM_HEADS, HEAD_DIM)
|
||||
k_dq = _ref_dequantize_fp4_indexer(
|
||||
*_read_index_k_cache(payload, scale, loc.reshape(-1))
|
||||
).reshape(batch, padded_len, HEAD_DIM)
|
||||
|
||||
def _logits_from(q_src, k_src) -> torch.Tensor:
|
||||
# The indexer scores each head separately, clamps it at zero and only
|
||||
# then takes the weighted sum; dropping the ReLU changes the result.
|
||||
per_head = torch.einsum("qhd,qsd->qhs", q_src.float(), k_src.float())
|
||||
return weight_scale * torch.einsum(
|
||||
"qhs,qh->qs", per_head.relu(), weights.float()
|
||||
)
|
||||
|
||||
return {
|
||||
"q_fp4": q_fp4,
|
||||
"q_scale": q_scale,
|
||||
"payload": payload,
|
||||
"scale": scale,
|
||||
"weights": weights,
|
||||
"weight_scale": weight_scale,
|
||||
"page_table": page_table,
|
||||
"c4_seq_lens": context,
|
||||
# Against exactly the FP4 operands the kernel read, and against the
|
||||
# unquantized bf16 model.
|
||||
"ref_logits_fp4": _logits_from(q_dq, k_dq),
|
||||
"ref_logits_bf16": _logits_from(q_ref, k_ref),
|
||||
"context": context,
|
||||
"seq_len": seq_len,
|
||||
}
|
||||
|
||||
|
||||
def _run_logits(case, *, is_decode: bool, decode_ws=None, prefill_ws=None):
|
||||
return aiter_fp4_paged_mqa_logits(
|
||||
q_fp4=case["q_fp4"],
|
||||
q_scale=case["q_scale"],
|
||||
k_payload=case["payload"],
|
||||
k_scale=case["scale"],
|
||||
weights=case["weights"],
|
||||
page_table=case["page_table"],
|
||||
c4_seq_lens=case["c4_seq_lens"],
|
||||
weight_scale=case["weight_scale"],
|
||||
is_decode=is_decode,
|
||||
decode_workspace=decode_ws,
|
||||
prefill_workspace=prefill_ws,
|
||||
)
|
||||
|
||||
|
||||
def _assert_logits_agree(logits: torch.Tensor, case) -> None:
|
||||
"""Check each row over its own context, against FP4 operands and bf16.
|
||||
|
||||
The FP4 comparison is the tight one: the reference is fed exactly what the
|
||||
kernel read, so only reduction order differs. The bf16 comparison is a
|
||||
coarse guard that FP4 has not disturbed the ranking the indexer is about to
|
||||
top-k; a dropped ReLU or a mispaired scale lands near 0.7 there, well clear
|
||||
of the quantization noise floor. Positions past a row's context are left
|
||||
undefined by design, so they are never compared.
|
||||
"""
|
||||
for row, ctx in enumerate(case["context"].tolist()):
|
||||
if ctx == 0:
|
||||
# A padded row owns no valid position; all it must do is leave the
|
||||
# rest of the batch alone, which the other iterations cover.
|
||||
continue
|
||||
got = logits[row, :ctx]
|
||||
exact = case["ref_logits_fp4"][row, :ctx]
|
||||
bf16 = case["ref_logits_bf16"][row, :ctx]
|
||||
torch.testing.assert_close(
|
||||
got, exact, rtol=2.0e-3, atol=2.0e-3, msg=f"row {row} (ctx={ctx})"
|
||||
)
|
||||
cosine = torch.nn.functional.cosine_similarity(got, bf16, dim=-1).item()
|
||||
assert cosine > 0.95, f"row {row} (ctx={ctx}) cosine vs bf16 is {cosine:.4f}"
|
||||
topk = min(64, ctx)
|
||||
overlap = (
|
||||
len(
|
||||
set(got.topk(topk).indices.tolist())
|
||||
& set(bf16.topk(topk).indices.tolist())
|
||||
)
|
||||
/ topk
|
||||
)
|
||||
assert overlap > 0.75, f"row {row} top-{topk} overlap is {overlap:.3f}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch,seq_len", [(1, 256), (2, 384), (4, 512)])
|
||||
def test_decode_paged_mqa_logits(batch: int, seq_len: int) -> None:
|
||||
torch.manual_seed(batch * 100 + seq_len)
|
||||
case = _build_logits_case(batch, seq_len)
|
||||
workspace = prepare_fp4_decode_workspace(case["page_table"], case["c4_seq_lens"])
|
||||
|
||||
logits = _run_logits(case, is_decode=True, decode_ws=workspace)
|
||||
|
||||
_assert_logits_agree(logits, case)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch,seq_len", [(1, 256), (3, 512)])
|
||||
def test_prefill_paged_mqa_logits(batch: int, seq_len: int) -> None:
|
||||
torch.manual_seed(batch * 200 + seq_len)
|
||||
case = _build_logits_case(batch, seq_len)
|
||||
workspace = prepare_fp4_prefill_workspace(case["page_table"], case["c4_seq_lens"])
|
||||
|
||||
logits = _run_logits(case, is_decode=False, prefill_ws=workspace)
|
||||
|
||||
_assert_logits_agree(logits, case)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_decode", [True, False])
|
||||
def test_logits_with_ragged_context_lengths(is_decode: bool) -> None:
|
||||
"""Sizing the persistent grid for uneven contexts is the scheduler's job.
|
||||
|
||||
A uniform batch hides an unbalanced chunk assignment: every row gets the
|
||||
same number of KV chunks, so an off-by-one in the split still covers each
|
||||
row exactly once.
|
||||
"""
|
||||
torch.manual_seed(21 if is_decode else 22)
|
||||
# Deliberately not multiples of the 64-token page or the 256-token chunk,
|
||||
# so the last block of each row is only partly in context.
|
||||
# A 0 stands for a padded row: ``match_num_queries`` pads c4_seq_lens with
|
||||
# 0 on the FP4 path, and such a row must not disturb its neighbours.
|
||||
ctx_lens = [17, 512, 1, 300, 0, 64, 129, 511]
|
||||
case = _build_logits_case(len(ctx_lens), 512, ctx_lens=ctx_lens)
|
||||
if is_decode:
|
||||
ws = prepare_fp4_decode_workspace(case["page_table"], case["c4_seq_lens"])
|
||||
logits = _run_logits(case, is_decode=True, decode_ws=ws)
|
||||
else:
|
||||
ws = prepare_fp4_prefill_workspace(case["page_table"], case["c4_seq_lens"])
|
||||
logits = _run_logits(case, is_decode=False, prefill_ws=ws)
|
||||
|
||||
_assert_logits_agree(logits, case)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_decode", [True, False])
|
||||
def test_logits_follow_shuffled_page_table(is_decode: bool) -> None:
|
||||
"""Pages of a sequence are neither contiguous nor ordered under a radix cache."""
|
||||
torch.manual_seed(31 if is_decode else 32)
|
||||
case = _build_logits_case(4, 384, ctx_lens=[384, 300, 129, 384], shuffle_pages=True)
|
||||
if is_decode:
|
||||
ws = prepare_fp4_decode_workspace(case["page_table"], case["c4_seq_lens"])
|
||||
logits = _run_logits(case, is_decode=True, decode_ws=ws)
|
||||
else:
|
||||
ws = prepare_fp4_prefill_workspace(case["page_table"], case["c4_seq_lens"])
|
||||
logits = _run_logits(case, is_decode=False, prefill_ws=ws)
|
||||
|
||||
_assert_logits_agree(logits, case)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_decode", [True, False])
|
||||
def test_pinned_schedule_matches_unpinned_logits(is_decode: bool) -> None:
|
||||
"""A pinned workspace only preplans the grid; the logits must not move."""
|
||||
torch.manual_seed(11 if is_decode else 12)
|
||||
case = _build_logits_case(2, 384)
|
||||
if is_decode:
|
||||
workspace = prepare_fp4_decode_workspace(
|
||||
case["page_table"], case["c4_seq_lens"]
|
||||
)
|
||||
pinned = _run_logits(case, is_decode=True, decode_ws=workspace)
|
||||
else:
|
||||
workspace = prepare_fp4_prefill_workspace(
|
||||
case["page_table"], case["c4_seq_lens"]
|
||||
)
|
||||
pinned = _run_logits(case, is_decode=False, prefill_ws=workspace)
|
||||
unpinned = _run_logits(case, is_decode=is_decode)
|
||||
|
||||
seq_len = case["seq_len"]
|
||||
torch.testing.assert_close(pinned[:, :seq_len], unpinned[:, :seq_len])
|
||||
|
||||
|
||||
def test_stale_workspace_row_count_falls_back_to_inline_schedule() -> None:
|
||||
"""DP padding can leave a workspace sized for a different row count."""
|
||||
torch.manual_seed(13)
|
||||
case = _build_logits_case(2, 256)
|
||||
stale = prepare_fp4_decode_workspace(
|
||||
case["page_table"][:1], case["c4_seq_lens"][:1]
|
||||
)
|
||||
|
||||
with_stale = _run_logits(case, is_decode=True, decode_ws=stale)
|
||||
without = _run_logits(case, is_decode=True)
|
||||
|
||||
seq_len = case["seq_len"]
|
||||
torch.testing.assert_close(with_stale[:, :seq_len], without[:, :seq_len])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -136,6 +136,17 @@ class TestLazyHostPoolRelease(CustomTestCase):
|
||||
def _make_logical_pool():
|
||||
return LogicalHostPool(size=8, page_size=2)
|
||||
|
||||
@staticmethod
|
||||
def _make_transfer_pool(*, page_aligned_only):
|
||||
pool = DeepSeekV4PagedHostPool.__new__(DeepSeekV4PagedHostPool)
|
||||
pool.pool_name = str(PoolName.DEEPSEEK_V4_C4_INDEXER)
|
||||
pool.slot_page_size = 4
|
||||
pool.layer_num = 1
|
||||
pool.page_aligned_only = page_aligned_only
|
||||
pool.device_ptrs = [0]
|
||||
pool.data_ptrs = [0]
|
||||
return pool
|
||||
|
||||
def _assert_lazy_release(self, pool):
|
||||
self.assertEqual(pool.free(torch.empty(0, dtype=torch.int64)), 0)
|
||||
self.assertEqual(pool.num_release_slots, 0)
|
||||
@@ -190,6 +201,26 @@ class TestLazyHostPoolRelease(CustomTestCase):
|
||||
pool.clear()
|
||||
self.assertEqual(len(pool.alloc(1)), 2)
|
||||
|
||||
def test_grouped_page_rows_reject_unaligned_transfers(self):
|
||||
# FP4 indexer rows group their slots, so a partial page has no
|
||||
# well-defined token-granular copy and must not silently fall back.
|
||||
pool = self._make_transfer_pool(page_aligned_only=True)
|
||||
unaligned = torch.arange(3, dtype=torch.int64)
|
||||
with self.assertRaisesRegex(ValueError, "page-aligned"):
|
||||
pool.backup_from_device_all_layer(None, unaligned, unaligned, "direct")
|
||||
with self.assertRaisesRegex(ValueError, "page-aligned"):
|
||||
pool.load_to_device_per_layer(None, unaligned, unaligned, 0, "direct")
|
||||
|
||||
def test_fused_page_rows_keep_token_granular_transfers(self):
|
||||
pool = self._make_transfer_pool(page_aligned_only=False)
|
||||
unaligned = torch.arange(3, dtype=torch.int64)
|
||||
with unittest.mock.patch(
|
||||
"sglang.srt.mem_cache.memory_pool_host.transfer_cache_dsv4_mla"
|
||||
) as transfer:
|
||||
pool.backup_from_device_all_layer(None, unaligned, unaligned, "direct")
|
||||
pool.load_to_device_per_layer(None, unaligned, unaligned, 0, "direct")
|
||||
self.assertEqual(transfer.call_count, 2)
|
||||
|
||||
def test_logical_pool_lazy_release(self):
|
||||
pool = self._make_logical_pool()
|
||||
self._assert_lazy_release(pool)
|
||||
|
||||
Reference in New Issue
Block a user