[AMD][DSV4] feat: enable fp8 two-pool unified_kv on gfx950 (#37413)

This commit is contained in:
amd-danli103
2026-09-14 02:49:11 -07:00
committed by GitHub
parent 95140a7b0c
commit 5aa9b8fb3e
21 changed files with 3594 additions and 104 deletions
@@ -0,0 +1,247 @@
# SPDX-License-Identifier: MIT
# Copyright (C) 2024-2026, Advanced Micro Devices, Inc. All rights reserved.
"""The backend's own prefill wiring: what reaches the reader, and what lands in the ring.
The pieces on either side of this are covered elsewhere -- the scatter primitive by
test_dsv4_unified_fp8_scatter, the model->backend kwargs by the q_pair test -- but
the middle, where _forward_unified_kv picks the fp8 arm and hands the packed pair to
both attention and the ring write, had nothing running through it.
Losing the rope half of that write is silent: the nope pool gets this chunk's rows,
the rope pool keeps stale ones, and later chunks plus decode read a wrong RoPE with
no crash and no NaN. So these run the real store against real (small) pools and pin
that both pools got written, on the same ring row. The attention reader is stubbed:
it is covered by test_dsv4_unified_fp8_prefill, and the store is what is at stake.
"""
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import torch
import sglang.srt.layers.attention.deepseek_v4_backend_hip_radix as backend_mod
from sglang.kernels.ops.attention.dsv4.unified_kv_kernels import runtime
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DSV4_FP8_NOPE_ROW_BYTES
from sglang.srt.model_executor.forward_batch_info import ForwardMode
from sglang.srt.utils import is_gfx95_supported, is_hip
from sglang.test.ci.ci_register import register_amd_ci
from sglang.test.test_utils import CustomTestCase
# the store is a plain row move, but the two-pool layout it pins is gfx95-only, so
# run it where the feature lives rather than on the default mi300 runner
register_amd_ci(est_time=15, suite="stage-b-test-1-gpu-small-amd-mi35x")
DEVICE = torch.device("cuda")
NOPE_ROW_BYTES = DSV4_FP8_NOPE_ROW_BYTES
ROPE_DIM = 64
V_HEAD_DIM = 512
NUM_HEADS = 16
WIN = 8
RING_STRIDE = 8
SWA_PAGES = 24 # ring rows are state_slot * RING_STRIDE + pos % RING_STRIDE, so < 24
POOL_ROWS = 32
# distinctive fill, so "the store never ran here" and "the store wrote zeros" are
# different failures
NOPE_SENTINEL = 0xEE
ROPE_SENTINEL = -7.0
# two requests on ring slots 1 and 2, three tokens each at positions 0..2
STATE_SLOT = [1, 1, 1, 2, 2, 2]
POSITIONS = [0, 1, 2, 0, 1, 2]
CU_Q = [0, 0, 0, 3, 3, 3]
EXPECTED_ROWS = [8, 9, 10, 16, 17, 18]
_needs_gfx950 = unittest.skipUnless(
torch.cuda.is_available() and is_hip() and is_gfx95_supported(),
"the two-pool fp8 layout is gfx95-only",
)
def _ints(values):
return torch.tensor(values, dtype=torch.int32, device=DEVICE).contiguous()
class _Pool:
"""Just the surface _forward_unified_kv touches."""
def __init__(self, fp8):
self.unified_swa_window = WIN
self.unified_swa_ring_size = RING_STRIDE
self.unified_swa_pages = SWA_PAGES
if fp8:
self.nope = torch.full(
(POOL_ROWS, NOPE_ROW_BYTES),
NOPE_SENTINEL,
dtype=torch.uint8,
device=DEVICE,
).view(torch.float8_e4m3fn)
else:
self.nope = torch.full(
(POOL_ROWS, V_HEAD_DIM),
ROPE_SENTINEL,
dtype=torch.bfloat16,
device=DEVICE,
)
self.rope = torch.full(
(POOL_ROWS, ROPE_DIM), ROPE_SENTINEL, dtype=torch.bfloat16, device=DEVICE
)
def get_unified_kv(self, layer_id):
return self.nope
def get_unified_kv_rope(self, layer_id):
return self.rope
def _chunk(fp8):
"""This fwd's K, one row per token, every row a different value."""
tokens = len(STATE_SLOT)
if fp8:
rows = torch.arange(1, tokens + 1, dtype=torch.uint8, device=DEVICE)
nope = rows[:, None].expand(tokens, NOPE_ROW_BYTES).contiguous()
nope = nope.view(torch.float8_e4m3fn)
else:
rows = torch.arange(1, tokens + 1, dtype=torch.bfloat16, device=DEVICE)
nope = rows[:, None].expand(tokens, V_HEAD_DIM).contiguous()
rope = (
torch.arange(1, tokens + 1, dtype=torch.bfloat16, device=DEVICE)[:, None]
.expand(tokens, ROPE_DIM)
.contiguous()
)
return nope, rope
class TestUnifiedFp8BackendPrefill(CustomTestCase):
def _run(self, fp8=True, save_kv_cache=True):
tokens = len(STATE_SLOT)
pool = _Pool(fp8)
k_nope, k_rope = _chunk(fp8)
if fp8:
q = torch.zeros(
tokens, NUM_HEADS, NOPE_ROW_BYTES, dtype=torch.uint8, device=DEVICE
).view(torch.float8_e4m3fn)
q_rope = torch.zeros(
tokens, NUM_HEADS, ROPE_DIM, dtype=torch.bfloat16, device=DEVICE
)
else:
q = torch.zeros(
tokens, NUM_HEADS, V_HEAD_DIM, dtype=torch.bfloat16, device=DEVICE
)
q_rope, k_rope = None, None
unified_meta = SimpleNamespace(
pf_state_slot=_ints(STATE_SLOT),
pf_chunk_start=_ints([0] * tokens),
pf_cu_q=_ints(CU_Q),
pf_final_pos=_ints([max(POSITIONS)] * tokens),
)
core_meta = SimpleNamespace(
unified=unified_meta,
c128_page_indices=None,
c4_sparse_page_indices=None,
)
forward_batch = SimpleNamespace(
forward_mode=ForwardMode.EXTEND,
positions=torch.tensor(POSITIONS, dtype=torch.int64, device=DEVICE),
req_pool_indices=_ints(STATE_SLOT),
)
fake_self = SimpleNamespace(
token_to_kv_pool=pool, softmax_scale=V_HEAD_DIM**-0.5
)
reader_calls = []
def _fake_reader(**kwargs):
reader_calls.append(kwargs)
return torch.zeros(
tokens, NUM_HEADS, V_HEAD_DIM, dtype=torch.bfloat16, device=DEVICE
)
target = "prefill_fp8_2buff" if fp8 else "prefill"
with (
patch.object(runtime, target, _fake_reader),
patch.object(
backend_mod,
"get_parallel",
return_value=SimpleNamespace(attn_cp_size=1, attn_cp_rank=0),
),
):
backend_mod.DeepseekV4HipRadixBackend._forward_unified_kv(
fake_self,
q=q,
kv=k_nope,
layer=SimpleNamespace(layer_id=0, v_head_dim=V_HEAD_DIM),
forward_batch=forward_batch,
compress_ratio=0,
attn_sink=torch.zeros(NUM_HEADS, dtype=torch.float32, device=DEVICE),
core_attn_metadata=core_meta,
save_kv_cache=save_kv_cache,
q_rope=q_rope,
k_rope=k_rope,
)
self.assertEqual(len(reader_calls), 1)
return pool, k_nope, k_rope, reader_calls[0]
def _untouched(self):
return sorted(set(range(POOL_ROWS)) - set(EXPECTED_ROWS))
@_needs_gfx950
def test_both_pools_get_this_chunk_on_the_same_ring_row(self):
"""the regression this file exists for: a rope pool left holding stale rows"""
pool, k_nope, k_rope, _ = self._run()
for token, row in enumerate(EXPECTED_ROWS):
self.assertTrue(
torch.equal(
pool.nope[row].view(torch.uint8), k_nope[token].view(torch.uint8)
),
f"nope pool row {row} does not hold token {token}",
)
self.assertTrue(
torch.equal(pool.rope[row], k_rope[token]),
f"rope pool row {row} does not hold token {token} -- "
f"got {pool.rope[row][0].item()}, want {k_rope[token][0].item()}",
)
@_needs_gfx950
def test_rows_outside_the_window_are_left_alone(self):
"""both scatters take the same row, so neither may spray past it"""
pool, _, _, _ = self._run()
rest = self._untouched()
self.assertTrue(
bool((pool.nope[rest].view(torch.uint8) == NOPE_SENTINEL).all())
)
self.assertTrue(bool((pool.rope[rest] == ROPE_SENTINEL).all()))
@_needs_gfx950
def test_the_reader_gets_the_same_pair_the_ring_write_does(self):
_, k_nope, k_rope, call = self._run()
self.assertIs(call["kv_extend"], k_nope)
self.assertIs(call["kv_extend_rope"], k_rope)
self.assertIsNotNone(call["unified_kv_rope"])
@_needs_gfx950
def test_nothing_is_written_when_the_model_already_stored(self):
pool, _, _, _ = self._run(save_kv_cache=False)
self.assertTrue(bool((pool.nope.view(torch.uint8) == NOPE_SENTINEL).all()))
self.assertTrue(bool((pool.rope == ROPE_SENTINEL).all()))
@_needs_gfx950
def test_the_bf16_arm_never_touches_the_rope_pool(self):
"""one pool, one write -- the rope pool only exists under the fp8 layout"""
pool, k_nope, _, _ = self._run(fp8=False)
for token, row in enumerate(EXPECTED_ROWS):
self.assertTrue(torch.equal(pool.nope[row], k_nope[token]))
self.assertTrue(bool((pool.rope == ROPE_SENTINEL).all()))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,332 @@
"""Two-pool fp8 store tests for the compressor's norm+rope kernel.
Under SGLANG_DSV4_UNIFIED_KV_FP8 the c4/c128 compressor writes its compressed
latent through ``forward_fp8_2buff``: a 512 B fp8 nope row (448 B payload + 7
UE8M0 tile scales stored twice) in the unified_kv pool, plus a bf16 rope row in
the second pool, both at ``out_loc``. These tests pin that layout for both
compress ratios, against the bf16 store of the same kernel (which shares the
norm+rope math, so the comparison is byte-exact) and against a torch reference.
Both plans are covered. Most cases run the decode plan; the extend arm (what
prefill takes) gets the bf16 comparison only, since its plan check and its
out_loc bound are hand-copied from decode's and nothing else exercises them.
"""
import unittest
import torch
from sglang.kernels.ops.attention.deepseek_v4_rope import precompute_freqs_cis
from sglang.kernels.ops.attention.dsv4 import (
CompressorDecodePlan,
CompressorPrefillPlan,
compress_norm_rope_store,
)
from sglang.srt.mem_cache.deepseek_v4_memory_pool import (
DSV4_FP8_NOPE_ROW_BYTES,
DSV4_FP8_QUANT_TILE,
)
from sglang.srt.utils import is_gfx95_supported
from sglang.test.ci.ci_register import register_amd_ci
from sglang.test.test_utils import CustomTestCase
# the kernel takes E4M3FN vs E4M3FNUZ from the arch and the two-pool layout is only
# ever allocated on gfx95, so on the default mi300 runner every case here would skip
register_amd_ci(est_time=60, suite="stage-b-test-1-gpu-small-amd-mi35x")
DEVICE = torch.device("cuda")
HEAD_DIM = 512
ROPE_DIM = 64
NOPE_DIM = HEAD_DIM - ROPE_DIM
NUM_TILES = NOPE_DIM // DSV4_FP8_QUANT_TILE
SCALE_OFF = NOPE_DIM
SCALE_BYTES = 2 * NUM_TILES
NUM_TOKENS = 6
POOL_ROWS = 32
EPS = 1e-6
FP8_MAX = torch.finfo(torch.float8_e4m3fn).max
RATIOS = (4, 128)
def _inputs(compress_ratio, seq_lens=None):
torch.manual_seed(compress_ratio)
kv = torch.randn(NUM_TOKENS, HEAD_DIM, device=DEVICE, dtype=torch.bfloat16)
weight = torch.randn(HEAD_DIM, device=DEVICE, dtype=torch.bfloat16)
if seq_lens is None:
seq_lens = (
torch.arange(1, NUM_TOKENS + 1, device=DEVICE, dtype=torch.int64)
* compress_ratio
)
plan = CompressorDecodePlan.generate_legacy(
compress_ratio,
torch.arange(NUM_TOKENS, device=DEVICE, dtype=torch.int64),
seq_lens,
)
# every other row, so a row that gets written always has an untouched neighbour
out_loc = torch.arange(1, 2 * NUM_TOKENS + 1, 2, device=DEVICE, dtype=torch.int64)
freqs_cis = precompute_freqs_cis(
ROPE_DIM, int(seq_lens.max().item()) + 1, 0, 10000, 1, 32, 1
).to(DEVICE)
return kv, weight, seq_lens, plan, out_loc, freqs_cis
def _extend_inputs(compress_ratio):
"""one request whose extend spans several compress boundaries"""
torch.manual_seed(compress_ratio + 1)
total = compress_ratio * NUM_TOKENS
seq_lens = torch.tensor([total], dtype=torch.int64)
plan = CompressorPrefillPlan.generate_legacy(
compress_ratio,
torch.zeros(1, dtype=torch.int64, device=DEVICE),
seq_lens,
seq_lens.clone(), # the whole sequence is the extend
total,
DEVICE,
)
# the kernel binds its token count off the input and then requires the plan to
# have that many rows, so the fixture has to follow whatever the planner emitted
num_c = plan.plan_c.shape[0]
kv = torch.randn(num_c, HEAD_DIM, device=DEVICE, dtype=torch.bfloat16)
weight = torch.randn(HEAD_DIM, device=DEVICE, dtype=torch.bfloat16)
# unlike decode, extend indexes out_loc by ragged_id -- one entry per q token, of
# which only the compress boundaries are ever read. Sizing this num_c long instead
# reads off the end and stores to whatever row index it finds there.
written = torch.arange(1, 2 * num_c + 1, 2, device=DEVICE, dtype=torch.int64)
out_loc = torch.zeros(total, dtype=torch.int64, device=DEVICE)
out_loc[compress_ratio - 1 :: compress_ratio] = written
freqs_cis = precompute_freqs_cis(ROPE_DIM, total + 1, 0, 10000, 1, 32, 1).to(DEVICE)
return kv, weight, plan, out_loc, written, freqs_cis
def _ref_norm_rope(kv, weight, freqs_cis, positions):
"""rmsnorm over the latent, then rope on the trailing 64, as the kernel does."""
x = kv.float()
x = x * torch.rsqrt(x.pow(2).sum(-1, keepdim=True) / HEAD_DIM + EPS)
x = x * weight.float()
nope, pe = x[:, :NOPE_DIM], x[:, NOPE_DIM:]
freqs = torch.view_as_real(freqs_cis).flatten(-2)[positions]
freqs = freqs.reshape(-1, ROPE_DIM // 2, 2).float()
pairs = pe.reshape(-1, ROPE_DIM // 2, 2)
out = torch.empty_like(pairs)
out[..., 0] = pairs[..., 0] * freqs[..., 0] - pairs[..., 1] * freqs[..., 1]
out[..., 1] = pairs[..., 0] * freqs[..., 1] + pairs[..., 1] * freqs[..., 0]
# the quant warps round through bf16 first, so the scales come off bf16 values
return nope.to(torch.bfloat16).float(), out.reshape(-1, ROPE_DIM)
def _tile_scale_bytes(nope):
"""cast_to_ue8m0(max(absmax, 1e-4) / fp8_max) per 1x64 tile."""
tiles = nope.reshape(nope.shape[0], NUM_TILES, DSV4_FP8_QUANT_TILE)
scale_raw = tiles.abs().amax(-1).clamp_min(1e-4) / FP8_MAX
bits = scale_raw.contiguous().view(torch.int32)
exp = ((bits >> 23) & 0xFF) + ((bits & 0x7FFFFF) != 0).to(torch.int32)
return exp.to(torch.uint8)
@unittest.skipUnless(is_gfx95_supported(), "needs an AMD gfx95 GPU for e4m3fn")
class TestUnifiedFp8CompressStore(CustomTestCase):
def _store_fp8(self, compress_ratio, *, seq_lens=None, rope_rows=POOL_ROWS):
kv, weight, seq_lens, plan, out_loc, freqs_cis = _inputs(
compress_ratio, seq_lens
)
nope_pool = torch.zeros(
POOL_ROWS, DSV4_FP8_NOPE_ROW_BYTES, dtype=torch.float8_e4m3fn, device=DEVICE
)
rope_pool = torch.zeros(
rope_rows, ROPE_DIM, dtype=torch.bfloat16, device=DEVICE
)
compress_norm_rope_store(
kv.clone(),
plan,
norm_weight=weight,
norm_eps=EPS,
freq_cis=freqs_cis,
out_loc=out_loc,
kvcache=nope_pool.view(torch.uint8),
page_size=1,
fp8_2buff=True,
kvcache_rope=rope_pool.view(torch.uint8),
)
ref = _ref_norm_rope(kv, weight, freqs_cis, (seq_lens - compress_ratio).long())
return nope_pool, rope_pool, out_loc, ref
def _store_bf16(self, compress_ratio):
"""same inputs through the bf16 store, i.e. the values before quantization"""
kv, weight, _, plan, out_loc, freqs_cis = _inputs(compress_ratio)
cache = torch.zeros(POOL_ROWS, HEAD_DIM, dtype=torch.bfloat16, device=DEVICE)
compress_norm_rope_store(
kv.clone(),
plan,
norm_weight=weight,
norm_eps=EPS,
freq_cis=freqs_cis,
out_loc=out_loc,
kvcache=cache.view(torch.uint8),
page_size=1,
bf16_store=True,
)
return cache[out_loc]
def _store_extend(self, compress_ratio, *, fp8):
kv, weight, plan, out_loc, written, freqs_cis = _extend_inputs(compress_ratio)
rows = int(written.max().item()) + 2
common = dict(
norm_weight=weight,
norm_eps=EPS,
freq_cis=freqs_cis,
out_loc=out_loc,
page_size=1,
)
if not fp8:
cache = torch.zeros(rows, HEAD_DIM, dtype=torch.bfloat16, device=DEVICE)
compress_norm_rope_store(
kv.clone(),
plan,
kvcache=cache.view(torch.uint8),
bf16_store=True,
**common,
)
return cache[written]
nope_pool = torch.zeros(
rows, DSV4_FP8_NOPE_ROW_BYTES, dtype=torch.float8_e4m3fn, device=DEVICE
)
rope_pool = torch.zeros(rows, ROPE_DIM, dtype=torch.bfloat16, device=DEVICE)
compress_norm_rope_store(
kv.clone(),
plan,
kvcache=nope_pool.view(torch.uint8),
fp8_2buff=True,
kvcache_rope=rope_pool.view(torch.uint8),
**common,
)
return nope_pool, rope_pool, written
def test_extend_plan_stores_the_same_rows(self):
for ratio in RATIOS:
with self.subTest(compress_ratio=ratio):
nope_pool, rope_pool, written = self._store_extend(ratio, fp8=True)
pre_quant = self._store_extend(ratio, fp8=False)
nope = pre_quant[:, :NOPE_DIM].float()
num_c = nope.shape[0]
scale_bytes = _tile_scale_bytes(nope)
scale = torch.exp2((scale_bytes.to(torch.int32) - 127).float())
want = (
nope.reshape(num_c, NUM_TILES, DSV4_FP8_QUANT_TILE)
/ scale[..., None]
).to(torch.float8_e4m3fn)
self.assertTrue(
torch.equal(
nope_pool[written][:, :NOPE_DIM].view(torch.uint8),
want.view(torch.uint8).reshape(num_c, NOPE_DIM),
)
)
self.assertTrue(
torch.equal(rope_pool[written], pre_quant[:, NOPE_DIM:])
)
def test_row_matches_the_bf16_store_byte_for_byte(self):
for ratio in RATIOS:
with self.subTest(compress_ratio=ratio):
nope_pool, rope_pool, out_loc, _ = self._store_fp8(ratio)
pre_quant = self._store_bf16(ratio)
nope = pre_quant[:, :NOPE_DIM].float()
scale_bytes = _tile_scale_bytes(nope)
scale = torch.exp2((scale_bytes.to(torch.int32) - 127).float())
want = (
nope.reshape(NUM_TOKENS, NUM_TILES, DSV4_FP8_QUANT_TILE)
/ scale[..., None]
).to(torch.float8_e4m3fn)
# the fixture has to reach the top e4m3 exponent, otherwise it would
# not notice a cast that saturates everything above 256
self.assertTrue(bool((want.float().abs() >= 256).any()))
row = nope_pool[out_loc]
self.assertTrue(
torch.equal(
row[:, :NOPE_DIM].view(torch.uint8),
want.view(torch.uint8).reshape(NUM_TOKENS, NOPE_DIM),
)
)
got_scales = row.view(torch.uint8)[
:, SCALE_OFF : SCALE_OFF + SCALE_BYTES
].reshape(NUM_TOKENS, NUM_TILES, 2)
self.assertTrue(torch.equal(got_scales[..., 0], scale_bytes))
self.assertTrue(torch.equal(got_scales[..., 1], scale_bytes))
self.assertTrue(
torch.equal(rope_pool[out_loc], pre_quant[:, NOPE_DIM:])
)
def test_scale_bytes_track_the_torch_reference(self):
for ratio in RATIOS:
with self.subTest(compress_ratio=ratio):
nope_pool, _, out_loc, (ref_nope, _) = self._store_fp8(ratio)
got = nope_pool.view(torch.uint8)[
out_loc, SCALE_OFF : SCALE_OFF + SCALE_BYTES
].reshape(NUM_TOKENS, NUM_TILES, 2)
self.assertTrue(torch.equal(got[..., 0], _tile_scale_bytes(ref_nope)))
def test_dequantized_nope_tracks_the_reference(self):
for ratio in RATIOS:
with self.subTest(compress_ratio=ratio):
nope_pool, _, out_loc, (ref_nope, _) = self._store_fp8(ratio)
exps = _tile_scale_bytes(ref_nope).to(torch.int32) - 127
payload = nope_pool[out_loc, :NOPE_DIM].float()
deq = (
payload.reshape(NUM_TOKENS, NUM_TILES, DSV4_FP8_QUANT_TILE)
* torch.exp2(exps.float())[..., None]
).reshape(NUM_TOKENS, NOPE_DIM)
# e4m3 carries 3 mantissa bits, so half a step is at most ~2^-4 of
# the tile's own absmax; beyond that the scale or the payload is off
tile_absmax = (
ref_nope.reshape(NUM_TOKENS, NUM_TILES, DSV4_FP8_QUANT_TILE)
.abs()
.amax(-1)
.repeat_interleave(DSV4_FP8_QUANT_TILE, dim=1)
)
self.assertTrue(torch.all((deq - ref_nope).abs() <= 0.07 * tile_absmax))
def test_rope_pool_matches_the_bf16_reference(self):
for ratio in RATIOS:
with self.subTest(compress_ratio=ratio):
_, rope_pool, out_loc, (_, ref_pe) = self._store_fp8(ratio)
torch.testing.assert_close(
rope_pool[out_loc].float(), ref_pe, rtol=2e-2, atol=2e-2
)
def test_pad_and_neighbour_rows_untouched(self):
nope_pool, rope_pool, out_loc, _ = self._store_fp8(4)
nope_bytes = nope_pool.view(torch.uint8)
self.assertTrue(torch.all(nope_bytes[out_loc, SCALE_OFF + SCALE_BYTES :] == 0))
untouched = torch.ones(POOL_ROWS, dtype=torch.bool, device=DEVICE)
untouched[out_loc] = False
self.assertTrue(torch.all(nope_bytes[untouched] == 0))
self.assertTrue(torch.all(rope_pool[untouched] == 0))
def test_non_boundary_decode_is_skipped(self):
# only sequences whose length is a multiple of the ratio produce a token
seq_lens = torch.full(
(NUM_TOKENS,), 4 * 128 + 1, device=DEVICE, dtype=torch.int64
)
nope_pool, rope_pool, _, _ = self._store_fp8(128, seq_lens=seq_lens)
self.assertTrue(torch.all(nope_pool.view(torch.uint8) == 0))
self.assertTrue(torch.all(rope_pool == 0))
def test_short_rope_pool_rejected(self):
# one row index addresses both pools, so a short rope pool has to be caught
# before either pool is written
with self.assertRaises(RuntimeError):
self._store_fp8(4, rope_rows=POOL_ROWS // 2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,310 @@
"""Decode read path over the two-pool fp8 unified_kv (aiter's v4 nm asm kernel).
What these pin is the reader-side plumbing, not the kernel's arithmetic: the
packed 512 B nope row and the bf16 rope pool addressed by one shared row index,
a per-token ``qo_indptr``, and the ragged ``kv_indptr`` the existing index
builders emit -- including what they emit for a cuda-graph padded row. The
reference attends over the *dequantized* pools, so a mismatch is the wiring
rather than the fp8 round-trip.
The quantization helpers mirror aiter's own reference
(``op_tests/test_mla_v40_persistent.py``: ``quantize_v4_nope_bpad8`` /
``pack_v4_nope_scale``). They are duplicated rather than imported because that
file is a test, not part of the aiter package.
"""
import unittest
import torch
from sglang.kernels.ops.attention.dsv4.unified_kv_kernels import runtime
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DSV4_FP8_NOPE_ROW_BYTES
from sglang.srt.utils import is_gfx95_supported, is_hip
from sglang.test.ci.ci_register import register_amd_ci
from sglang.test.test_utils import CustomTestCase
# the asm shader is only shipped for gfx950
register_amd_ci(est_time=60, suite="stage-b-test-1-gpu-small-amd-mi35x")
DEVICE = torch.device("cuda")
NOPE_ROW_BYTES = DSV4_FP8_NOPE_ROW_BYTES
NOPE_DIM = 448 # fp8 values per row, in elements
ROPE_DIM = 64
QUANT_TILE = 64
NUM_TILES = NOPE_DIM // QUANT_TILE # 7
SCALE_OFF = NOPE_DIM # scales start where the values end
# latent element count; the same number as NOPE_ROW_BYTES, different unit
V_HEAD_DIM = NOPE_DIM + ROPE_DIM
SOFTMAX_SCALE = V_HEAD_DIM**-0.5 # what the kernel hardcodes
_needs_gfx950 = unittest.skipUnless(
torch.cuda.is_available() and is_hip() and is_gfx95_supported(),
"two-pool fp8 decode runs on the gfx950 asm shader",
)
def _pow2_ceil_scale(amax: torch.Tensor) -> torch.Tensor:
"""amax/fp8_max -> the next power of two at or above it, as fp32"""
return torch.pow(2.0, torch.clamp_min(amax, 1e-4).log2().ceil()).to(torch.float32)
def _pow2_to_e8m0(pow2: torch.Tensor) -> torch.Tensor:
"""byte B encodes 2^(B-127); 0 means 0.0 and 255 means inf, so clamp to 254"""
biased = torch.log2(pow2).round().to(torch.int32) + 127
return torch.clamp(biased, 0, 254).to(torch.uint8)
def _e8m0_to_fp32(byte: torch.Tensor) -> torch.Tensor:
return torch.exp2((byte.to(torch.int32) - 127).to(torch.float32))
def _quantize_nope(nope_fp32: torch.Tensor):
"""[..., 448] fp32 -> (fp8 values, [..., 7] e8m0 bytes, bf16 round-trip)"""
fp8_max = float(torch.finfo(torch.float8_e4m3fn).max)
leading = nope_fp32.shape[:-1]
tiled = nope_fp32.reshape(*leading, NUM_TILES, QUANT_TILE)
scale = _pow2_ceil_scale(tiled.abs().amax(dim=-1) / fp8_max)
values = (tiled / scale.unsqueeze(-1)).to(torch.float8_e4m3fn)
dequant = (values.to(torch.float32) * scale.unsqueeze(-1)).reshape(
*leading, NOPE_DIM
)
return (
values.reshape(*leading, NOPE_DIM),
_pow2_to_e8m0(scale),
dequant.to(torch.bfloat16),
)
def _pack(values: torch.Tensor, scale_e8m0: torch.Tensor) -> torch.Tensor:
"""448 values + each tile scale twice + pad, as one NOPE_ROW_BYTES fp8 row
The 50 pad bytes get garbage on purpose. Production allocates Q with
nope_pool.new_empty(), so a reader that ever starts looking past the scales
should fail here and not in a bf16-vs-fp8 accuracy chase.
"""
leading = values.shape[:-1]
row = torch.randint(
1, 256, (*leading, NOPE_ROW_BYTES), dtype=torch.uint8, device=values.device
)
row[..., :NOPE_DIM] = values.view(torch.uint8)
dup = scale_e8m0.unsqueeze(-1).expand(*scale_e8m0.shape, 2).reshape(*leading, -1)
row[..., SCALE_OFF : SCALE_OFF + 2 * NUM_TILES] = dup
return row.view(torch.float8_e4m3fn)
def _make_latent(*leading: int):
"""Return (packed fp8 rows, bf16 rope, bf16 latent the kernel effectively sees)."""
nope = torch.randn(*leading, NOPE_DIM, device=DEVICE, dtype=torch.float32)
rope = torch.randn(*leading, ROPE_DIM, device=DEVICE, dtype=torch.bfloat16)
values, scale, nope_bf16 = _quantize_nope(nope)
silver = torch.cat([nope_bf16, rope], dim=-1)
return _pack(values, scale).contiguous(), rope.contiguous(), silver
def _ragged(lengths, rows, device=DEVICE):
"""per-token row lists -> (flat int32 indices, int32 indptr)"""
indptr = torch.zeros(len(lengths) + 1, dtype=torch.int32, device=device)
indptr[1:] = torch.cumsum(
torch.tensor(lengths, dtype=torch.int32, device=device), dim=0
)
flat = torch.cat(
[
torch.randperm(rows, device=device)[:n].to(torch.int32)
for n in lengths
if n > 0
]
or [torch.empty(0, dtype=torch.int32, device=device)]
)
return flat.contiguous(), indptr
def _reference(q_silver, kv_silver, indices, indptr, sink):
"""Ragged sparse attention in fp32; V is the full latent, sink has zero V."""
T, H, _ = q_silver.shape
out = torch.zeros(T, H, V_HEAD_DIM, device=q_silver.device, dtype=torch.float32)
q = q_silver.float()
sink_f = sink.float()
for t in range(T):
lo, hi = int(indptr[t]), int(indptr[t + 1])
k = kv_silver[indices[lo:hi].long()].float() # [L, 512]
logits = q[t] @ k.transpose(0, 1) * SOFTMAX_SCALE # [H, L]
aug = torch.cat([logits, sink_f.unsqueeze(1)], dim=1)
m = aug.amax(dim=1, keepdim=True)
p = torch.exp(logits - m)
denom = p.sum(dim=1, keepdim=True) + torch.exp(sink_f.unsqueeze(1) - m)
out[t] = (p @ k) / denom
return out
class TestUnifiedFp8Decode(CustomTestCase):
def setUp(self):
torch.manual_seed(7)
self.rows = 256
def _run(self, lengths, num_heads):
T = len(lengths)
pool_nope, pool_rope, kv_silver = _make_latent(self.rows)
q_packed, q_rope, q_silver = _make_latent(T, num_heads)
indices, indptr = _ragged(lengths, self.rows)
sink = torch.randn(num_heads, device=DEVICE, dtype=torch.float32)
got = runtime.decode_fp8_2buff(
q=q_packed,
q_rope=q_rope,
unified_kv=pool_nope,
unified_kv_rope=pool_rope,
kv_indices=indices,
kv_indptr=indptr,
attn_sink=sink,
v_head_dim=V_HEAD_DIM,
)
want = _reference(q_silver, kv_silver, indices, indptr, sink)
return got.float(), want
def _assert_close(self, got, want, atol=3e-2, rtol=3e-2):
"""torch-style combined bound.
A pure relative bound is useless here: the latent's outputs straddle
zero, so an absolute error of 4e-3 -- which is what bf16 accumulation
costs -- reads as 47% relative on the rows that land near zero.
"""
diff = (got - want).abs()
outside = diff > atol + rtol * want.abs()
self.assertEqual(
outside.sum().item(),
0,
f"{outside.sum().item()}/{outside.numel()} elements outside "
f"{atol}+{rtol}|ref|, max abs {diff.max().item():.4g}",
)
@_needs_gfx950
def test_matches_dequantized_reference(self):
for lengths in ([64] * 4, [17, 5, 128, 1], [200] * 8):
with self.subTest(lengths=lengths):
got, want = self._run(lengths, num_heads=16)
self._assert_close(got, want)
@_needs_gfx950
def test_head_count_64(self):
got, want = self._run([48, 96], num_heads=64)
self._assert_close(got, want)
@_needs_gfx950
def test_cuda_graph_pad_reads_only_the_reserved_ring_row(self):
"""What the real builder emits for a cuda-graph padded row.
Not an empty segment: both dsv4 backends fill padded ``seq_lens`` with 1,
so ``clamp(seq_lens, max=win)`` leaves the pad one row long. It lands on
ring row 0, the slot ReqToTokenPool reserves for exactly this
(``free_slots`` starts at 1), so a pad only ever reads and writes there.
"""
win = ring = 64
seq_lens = torch.tensor([37, 55, 1, 1], dtype=torch.int32, device=DEVICE)
state_slot = torch.tensor([1, 2, 0, 0], dtype=torch.int32, device=DEVICE)
n = seq_lens.numel()
zero = torch.zeros(n, dtype=torch.int32, device=DEVICE)
indices, indptr = runtime.build_decode_streams(
state_slot=state_slot,
positions=seq_lens - 1, # raw_positions, as the backend derives it
swa_len=torch.clamp(seq_lens, max=win),
hca_len=zero,
csa_len=zero,
hca_page_indices=torch.zeros(n, 1, dtype=torch.int32, device=DEVICE),
csa_width=1,
win=win,
ring_stride=ring,
swa_pages=self.rows,
)[:2]
seg = (indptr[1 : n + 1] - indptr[:n]).tolist()
self.assertEqual(seg, [37, 55, 1, 1])
for pad in (2, 3):
self.assertEqual(indices[int(indptr[pad])].item(), 0)
live = indices[: int(indptr[2])]
self.assertGreaterEqual(int(live.min()), ring, "live rows hit slot 0's block")
pool_nope, pool_rope, _ = _make_latent(self.rows)
q_packed, q_rope, _ = _make_latent(n, 16)
out = runtime.decode_fp8_2buff(
q=q_packed,
q_rope=q_rope,
unified_kv=pool_nope,
unified_kv_rope=pool_rope,
kv_indices=indices.contiguous(),
kv_indptr=indptr,
attn_sink=torch.randn(16, device=DEVICE, dtype=torch.float32),
v_head_dim=V_HEAD_DIM,
)
# the mask never fires here, so what matters is the reserved row keeping
# the pad finite rather than it coming back zeroed
self.assertTrue(bool(out.isfinite().all()))
@_needs_gfx950
def test_empty_segment_comes_back_nonfinite(self):
"""Guard for a shape the builders do not reach today.
Padded seq_lens are always filled with 1 (see
test_cuda_graph_pad_reads_only_the_reserved_ring_row), so an empty segment
can only come from a builder change -- and it comes back NaN, not zero,
since the asm kernel divides by an all-sink denominator.
"""
got, want = self._run([32, 0, 32], num_heads=16)
self.assertTrue(bool(torch.isnan(got[1]).any()))
for t in (0, 2):
self._assert_close(got[t], want[t])
@_needs_gfx950
def test_split_tail_override_matches_reference(self):
"""past 40 tokens runtime overrides the split count, moving the kernel onto
a different stage-2 merge partition -- must still match the reference
"""
lengths = [200, 64] * 24 # 48 tokens, both layer flavours' segment lengths
self.assertGreater(len(lengths), 40)
got, want = self._run(lengths, num_heads=16)
self._assert_close(got, want)
@_needs_gfx950
def test_rejects_pool_that_is_not_a_pair(self):
pool_nope, pool_rope, _ = _make_latent(self.rows)
q_packed, q_rope, _ = _make_latent(2, 16)
indices, indptr = _ragged([4, 4], self.rows)
sink = torch.zeros(16, device=DEVICE, dtype=torch.float32)
with self.assertRaises(AssertionError):
runtime.decode_fp8_2buff(
q=q_packed,
q_rope=q_rope,
unified_kv=pool_nope,
unified_kv_rope=pool_rope[: self.rows // 2],
kv_indices=indices,
kv_indptr=indptr,
attn_sink=sink,
v_head_dim=V_HEAD_DIM,
)
@_needs_gfx950
def test_rejects_q_row_wider_than_the_pool_row(self):
pool_nope, pool_rope, _ = _make_latent(self.rows)
q_packed, q_rope, _ = _make_latent(2, 16)
indices, indptr = _ragged([4, 4], self.rows)
sink = torch.zeros(16, device=DEVICE, dtype=torch.float32)
wider = torch.zeros(
2, 16, NOPE_ROW_BYTES + 64, device=DEVICE, dtype=torch.float8_e4m3fn
)
wider[..., :NOPE_ROW_BYTES] = q_packed
with self.assertRaises(AssertionError):
runtime.decode_fp8_2buff(
q=wider,
q_rope=q_rope,
unified_kv=pool_nope,
unified_kv_rope=pool_rope,
kv_indices=indices,
kv_indptr=indptr,
attn_sink=sink,
v_head_dim=V_HEAD_DIM,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,298 @@
# SPDX-License-Identifier: MIT
# Copyright (C) 2024-2026, Advanced Micro Devices, Inc. All rights reserved.
"""Prefill read path over the two-pool fp8 unified_kv (aiter's opus kernel).
Two regions per token: the paged prefix pools and this chunk's flat extend pair.
What these pin is that both regions are addressed with the same row layout and
that the pair guards fire before the launch -- the reference attends over the
*dequantized* pools, so a mismatch is the wiring rather than the fp8 round-trip.
The quantization helpers are the ones from the decode test rather than a shared
module: files under test/registered/ are collected standalone (no __init__.py,
no conftest), so importing across them breaks in CI.
"""
import unittest
import torch
from sglang.kernels.ops.attention.dsv4.unified_kv_kernels import runtime
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DSV4_FP8_NOPE_ROW_BYTES
from sglang.srt.utils import is_gfx95_supported, is_hip
from sglang.test.ci.ci_register import register_amd_ci
from sglang.test.test_utils import CustomTestCase
register_amd_ci(est_time=60, suite="stage-b-test-1-gpu-small-amd-mi35x")
DEVICE = torch.device("cuda")
NOPE_ROW_BYTES = DSV4_FP8_NOPE_ROW_BYTES
NOPE_DIM = 448 # fp8 values per row, in elements
ROPE_DIM = 64
QUANT_TILE = 64
NUM_TILES = NOPE_DIM // QUANT_TILE # 7
SCALE_OFF = NOPE_DIM
# latent element count; the same number as NOPE_ROW_BYTES, different unit
V_HEAD_DIM = NOPE_DIM + ROPE_DIM
SOFTMAX_SCALE = V_HEAD_DIM**-0.5
_needs_gfx950 = unittest.skipUnless(
torch.cuda.is_available() and is_hip() and is_gfx95_supported(),
"two-pool fp8 prefill runs on the gfx950 opus kernel",
)
def _pow2_ceil_scale(amax: torch.Tensor) -> torch.Tensor:
return torch.pow(2.0, torch.clamp_min(amax, 1e-4).log2().ceil()).to(torch.float32)
def _pow2_to_e8m0(pow2: torch.Tensor) -> torch.Tensor:
biased = torch.log2(pow2).round().to(torch.int32) + 127
return torch.clamp(biased, 0, 254).to(torch.uint8)
def _quantize_nope(nope_fp32: torch.Tensor):
"""[..., 448] fp32 -> (fp8 values, [..., 7] e8m0 bytes, bf16 round-trip)"""
fp8_max = float(torch.finfo(torch.float8_e4m3fn).max)
leading = nope_fp32.shape[:-1]
tiled = nope_fp32.reshape(*leading, NUM_TILES, QUANT_TILE)
scale = _pow2_ceil_scale(tiled.abs().amax(dim=-1) / fp8_max)
values = (tiled / scale.unsqueeze(-1)).to(torch.float8_e4m3fn)
dequant = (values.to(torch.float32) * scale.unsqueeze(-1)).reshape(
*leading, NOPE_DIM
)
return (
values.reshape(*leading, NOPE_DIM),
_pow2_to_e8m0(scale),
dequant.to(torch.bfloat16),
)
def _pack(values: torch.Tensor, scale_e8m0: torch.Tensor) -> torch.Tensor:
"""448 values + each tile scale twice + pad, as one NOPE_ROW_BYTES fp8 row
Pad bytes get garbage on purpose, same reason as the decode test: production
allocates these with new_empty(), so a reader that walks past the scales
should fail here rather than as an accuracy drift.
"""
leading = values.shape[:-1]
row = torch.randint(
1, 256, (*leading, NOPE_ROW_BYTES), dtype=torch.uint8, device=values.device
)
row[..., :NOPE_DIM] = values.view(torch.uint8)
dup = scale_e8m0.unsqueeze(-1).expand(*scale_e8m0.shape, 2).reshape(*leading, -1)
row[..., SCALE_OFF : SCALE_OFF + 2 * NUM_TILES] = dup
return row.view(torch.float8_e4m3fn)
def _make_latent(*leading: int):
"""Return (packed fp8 rows, bf16 rope, bf16 latent the kernel effectively sees)."""
nope = torch.randn(*leading, NOPE_DIM, device=DEVICE, dtype=torch.float32)
rope = torch.randn(*leading, ROPE_DIM, device=DEVICE, dtype=torch.bfloat16)
values, scale, nope_bf16 = _quantize_nope(nope)
silver = torch.cat([nope_bf16, rope], dim=-1)
return _pack(values, scale).contiguous(), rope.contiguous(), silver
def _ragged(lengths, rows):
"""per-token row lists -> (flat int32 indices, int32 indptr)"""
indptr = torch.zeros(len(lengths) + 1, dtype=torch.int32, device=DEVICE)
indptr[1:] = torch.cumsum(
torch.tensor(lengths, dtype=torch.int32, device=DEVICE), dim=0
)
parts = [
torch.randperm(rows, device=DEVICE)[:n].to(torch.int32)
for n in lengths
if n > 0
]
flat = (
torch.cat(parts) if parts else torch.empty(0, dtype=torch.int32, device=DEVICE)
)
return flat.contiguous(), indptr
def _reference(q_silver, sources, sink, scale):
"""Ragged two-region attention in fp32; V is the full latent, sink V is zero.
``sources`` is [(silver, indices, indptr), ...]. The kernel shares one online
softmax across the regions, so order does not matter and this just
concatenates whatever each region selected.
"""
T, H, _ = q_silver.shape
out = torch.zeros(T, H, V_HEAD_DIM, device=q_silver.device, dtype=torch.float32)
q = q_silver.float()
sink_f = sink.float()
for t in range(T):
keys = []
for silver, indices, indptr in sources:
lo, hi = int(indptr[t]), int(indptr[t + 1])
if hi > lo:
keys.append(silver[indices[lo:hi].long()].float())
if not keys:
# only the sink is left: it contributes to the denominator and has
# V = 0, so the row is exactly zero
continue
k = torch.cat(keys, dim=0)
logits = q[t] @ k.transpose(0, 1) * scale
m = torch.cat([logits, sink_f.unsqueeze(1)], dim=1).amax(dim=1, keepdim=True)
p = torch.exp(logits - m)
denom = p.sum(dim=1, keepdim=True) + torch.exp(sink_f.unsqueeze(1) - m)
out[t] = (p @ k) / denom
return out
class TestUnifiedFp8Prefill(CustomTestCase):
def setUp(self):
torch.manual_seed(11)
self.rows = 256
def _run(self, prefix_lens, extend_lens, num_heads=16, scale=SOFTMAX_SCALE):
T = len(prefix_lens)
self.assertEqual(T, len(extend_lens))
extend_rows = max(max(extend_lens), 1)
pool_nope, pool_rope, pool_silver = _make_latent(self.rows)
ext_nope, ext_rope, ext_silver = _make_latent(extend_rows)
q_packed, q_rope, q_silver = _make_latent(T, num_heads)
pre_i, pre_p = _ragged(prefix_lens, self.rows)
ext_i, ext_p = _ragged(extend_lens, extend_rows)
sink = torch.randn(num_heads, device=DEVICE, dtype=torch.float32)
got = runtime.prefill_fp8_2buff(
q=q_packed,
q_rope=q_rope,
unified_kv=pool_nope,
unified_kv_rope=pool_rope,
kv_indices_prefix=pre_i,
kv_indptr_prefix=pre_p,
kv_extend=ext_nope,
kv_extend_rope=ext_rope,
kv_indices_extend=ext_i,
kv_indptr_extend=ext_p,
attn_sink=sink,
softmax_scale=scale,
v_head_dim=V_HEAD_DIM,
)
want = _reference(
q_silver,
[(pool_silver, pre_i, pre_p), (ext_silver, ext_i, ext_p)],
sink,
scale,
)
return got.float(), want
def _assert_close(self, got, want, atol=3e-2, rtol=3e-2):
"""torch-style combined bound, same reasoning as the decode test.
A pure relative bound is useless here: the latent's outputs straddle
zero, so the absolute error bf16 accumulation costs reads as a huge
relative one on the rows that land near zero.
"""
diff = (got - want).abs()
outside = diff > atol + rtol * want.abs()
self.assertEqual(
outside.sum().item(),
0,
f"{outside.sum().item()}/{outside.numel()} elements outside "
f"the bound, max abs {diff.max().item():.4g}",
)
@_needs_gfx950
def test_matches_dequantized_reference(self):
cases = (
([64, 64, 64, 64], [1, 2, 3, 4]),
([17, 5, 128, 1], [4, 4, 4, 4]),
([200] * 6, [1, 3, 6, 2, 5, 4]),
)
for prefix_lens, extend_lens in cases:
with self.subTest(prefix=prefix_lens, extend=extend_lens):
got, want = self._run(prefix_lens, extend_lens)
self._assert_close(got, want)
@_needs_gfx950
def test_first_chunk_has_an_empty_prefix_for_every_token(self):
"""the real shape of chunk 0: nothing committed yet, extend is all there is"""
got, want = self._run([0, 0, 0, 0], [1, 2, 3, 4])
self.assertTrue(bool(got.isfinite().all()))
self._assert_close(got, want)
@_needs_gfx950
def test_a_token_with_neither_region_comes_back_zero(self):
"""Not NaN, which is where this differs from the asm decode reader.
decode_fp8_2buff has to mask that case itself; this kernel already
returns zeros, so there is deliberately no mask on this path. The
reference skips those rows for the same reason: with only the sink left
the numerator is zero.
"""
got, want = self._run([64, 0, 64], [2, 0, 2])
self.assertTrue(torch.equal(got[1], torch.zeros_like(got[1])))
self._assert_close(got, want)
@_needs_gfx950
def test_head_count_64(self):
got, want = self._run([48, 96], [3, 5], num_heads=64)
self._assert_close(got, want)
@_needs_gfx950
def test_scale_is_passed_through(self):
"""unlike the decode reader, this kernel takes the scale as an argument"""
got, want = self._run([32, 32], [2, 2], scale=0.5 * SOFTMAX_SCALE)
self._assert_close(got, want)
@_needs_gfx950
def test_extend_row_narrower_than_the_pool_is_rejected(self):
"""the two regions are walked with one row layout, so a short row would
read the next token's bytes as this one's scales"""
pool_nope, pool_rope, _ = _make_latent(self.rows)
q_packed, q_rope, _ = _make_latent(2, 16)
ext_nope, ext_rope, _ = _make_latent(4)
pre_i, pre_p = _ragged([8, 8], self.rows)
ext_i, ext_p = _ragged([1, 1], 4)
with self.assertRaisesRegex(AssertionError, "extend nope row"):
runtime.prefill_fp8_2buff(
q=q_packed,
q_rope=q_rope,
unified_kv=pool_nope,
unified_kv_rope=pool_rope,
kv_indices_prefix=pre_i,
kv_indptr_prefix=pre_p,
kv_extend=ext_nope[:, : NOPE_ROW_BYTES // 2].contiguous(),
kv_extend_rope=ext_rope,
kv_indices_extend=ext_i,
kv_indptr_extend=ext_p,
attn_sink=torch.randn(16, device=DEVICE, dtype=torch.float32),
softmax_scale=SOFTMAX_SCALE,
v_head_dim=V_HEAD_DIM,
)
@_needs_gfx950
def test_mismatched_pools_are_rejected_before_the_launch(self):
pool_nope, _, _ = _make_latent(self.rows)
short_rope = torch.zeros(
self.rows // 2, ROPE_DIM, dtype=torch.bfloat16, device=DEVICE
)
q_packed, q_rope, _ = _make_latent(2, 16)
ext_nope, ext_rope, _ = _make_latent(4)
pre_i, pre_p = _ragged([8, 8], self.rows)
ext_i, ext_p = _ragged([1, 1], 4)
with self.assertRaisesRegex(AssertionError, "pool rows differ"):
runtime.prefill_fp8_2buff(
q=q_packed,
q_rope=q_rope,
unified_kv=pool_nope,
unified_kv_rope=short_rope,
kv_indices_prefix=pre_i,
kv_indptr_prefix=pre_p,
kv_extend=ext_nope,
kv_extend_rope=ext_rope,
kv_indices_extend=ext_i,
kv_indptr_extend=ext_p,
attn_sink=torch.randn(16, device=DEVICE, dtype=torch.float32),
softmax_scale=SOFTMAX_SCALE,
v_head_dim=V_HEAD_DIM,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,539 @@
"""Two-pool fp8 store tests for the fused QK norm+RoPE kernel wrapper.
Under SGLANG_DSV4_UNIFIED_KV_FP8 ``fused_qk_norm_rope_swa_store`` delegates to
aiter, which packs K into a 512 B fp8 nope row (448 B payload + 14 B duplicated
E8M0 tile scales) plus a bf16 rope row and scatters both into the SWA ring.
These tests pin that layout, which the decode reader depends on, and which of the
two forms Q comes back in: the same packed pair when the caller supplies a rope
buffer (what the v4 nm asm reader takes), plain rotated bf16 when it does not
(what the Triton reader takes).
"""
import unittest
import torch
from sglang.kernels.ops.attention.fused_qk_norm_rope_store import (
_HAS_GROUP_QUANT,
fused_qk_norm_rope_swa_store,
)
from sglang.srt.mem_cache.deepseek_v4_memory_pool import (
DSV4_FP8_NOPE_ROW_BYTES,
DSV4_FP8_QUANT_TILE,
)
from sglang.test.ci.ci_register import register_amd_ci
from sglang.test.test_utils import CustomTestCase
# aiter's group-quant path is gfx95-only, so on the default mi300 runner every case
# here would skip
register_amd_ci(est_time=20, suite="stage-b-test-1-gpu-small-amd-mi35x")
DEVICE = torch.device("cuda")
NOPE_DIM = 448
ROPE_DIM = 64
HEAD_DIM = NOPE_DIM + ROPE_DIM
NUM_TILES = NOPE_DIM // DSV4_FP8_QUANT_TILE
SCALE_OFF = NOPE_DIM
SCALE_BYTES = 2 * NUM_TILES
NUM_HEADS = 4
EPS = 1e-6
MAX_POS = 256
RING_STRIDE = 16
def _cos_sin():
inv = 1.0 / (
10000 ** (torch.arange(0, ROPE_DIM, 2, dtype=torch.float32) / ROPE_DIM)
)
ang = torch.arange(MAX_POS, dtype=torch.float32)[:, None] * inv[None, :]
return (
ang.cos().to(torch.bfloat16).to(DEVICE),
ang.sin().to(torch.bfloat16).to(DEVICE),
)
def _ref_norm_rope(kv, weight, cos, sin, positions):
"""rmsnorm over the whole latent, then GPT-J rope on the trailing pe half."""
x = kv.float()
scale = torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + EPS)
normed = x * scale * weight.float()
nope, pe = normed[:, :NOPE_DIM], normed[:, NOPE_DIM:]
c = cos.float()[positions]
s = sin.float()[positions]
even, odd = pe[:, 0::2], pe[:, 1::2]
out = torch.empty_like(pe)
out[:, 0::2] = even * c - odd * s
out[:, 1::2] = odd * c + even * s
return nope, out
def _ref_tile_scales(nope):
"""e8m0 exponent byte per 1x64 tile, from the fp32 reference nope."""
tiles = nope.reshape(nope.shape[0], NUM_TILES, DSV4_FP8_QUANT_TILE)
absmax = tiles.abs().amax(-1).clamp_min(1e-8)
fp8_max = torch.finfo(torch.float8_e4m3fn).max
return torch.ceil(torch.log2(absmax / fp8_max))
def _pools(n_rows):
nope_pool = torch.zeros(
n_rows, DSV4_FP8_NOPE_ROW_BYTES, dtype=torch.float8_e4m3fn, device=DEVICE
)
rope_pool = torch.zeros(n_rows, ROPE_DIM, dtype=torch.bfloat16, device=DEVICE)
return nope_pool, rope_pool
class _StoreCase(CustomTestCase):
def setUp(self):
torch.manual_seed(7)
self.T = 6
self.cos, self.sin = _cos_sin()
self.weight = torch.randn(HEAD_DIM, device=DEVICE, dtype=torch.bfloat16)
self.kv = torch.randn(self.T, HEAD_DIM, device=DEVICE, dtype=torch.bfloat16)
self.q = torch.randn(
self.T, NUM_HEADS * HEAD_DIM, device=DEVICE, dtype=torch.bfloat16
)
self.positions = torch.arange(self.T, device=DEVICE, dtype=torch.int64)
# distinct ring rows so each row has one unambiguous writer
self.swa_loc = (
self.positions.to(torch.int32) % RING_STRIDE + RING_STRIDE
).contiguous()
@unittest.skipUnless(_HAS_GROUP_QUANT, "needs aiter's group-quant kernel on gfx95x")
class TestUnifiedFp8QkNormRope(_StoreCase):
def _call(
self,
nope_pool=None,
rope_pool=None,
k_nope=None,
k_rope=None,
q_out=None,
q_rope_out=None,
):
return fused_qk_norm_rope_swa_store(
q=self.q,
kv=self.kv,
q_norm_weight=None,
kv_norm_weight=self.weight,
q_rms_eps=EPS,
kv_rms_eps=EPS,
rope_head_dim=ROPE_DIM,
cos_cache=self.cos,
sin_cache=self.sin,
positions=self.positions,
swa_cache=nope_pool,
swa_loc=None if nope_pool is None else self.swa_loc,
swa_page_size=1,
dtype=torch.bfloat16,
fp8_2buff=True,
swa_rope_cache=rope_pool,
k_nope_out=k_nope,
k_rope_out=k_rope,
q_out=q_out,
q_rope_out=q_rope_out,
)
def test_pool_rows_equal_the_dense_packed_output(self):
"""the ring write and the dense K buffers come from the same values"""
nope_pool, rope_pool = _pools(2 * RING_STRIDE)
k_nope = torch.empty(
self.T, 1, DSV4_FP8_NOPE_ROW_BYTES, dtype=torch.float8_e4m3fn, device=DEVICE
)
k_rope = torch.empty(self.T, 1, ROPE_DIM, dtype=torch.bfloat16, device=DEVICE)
self._call(nope_pool, rope_pool, k_nope, k_rope)
rows = self.swa_loc.long()
pool_bytes = nope_pool.view(torch.uint8)[rows, : SCALE_OFF + SCALE_BYTES]
dense_bytes = k_nope.view(torch.uint8)[:, 0, : SCALE_OFF + SCALE_BYTES]
self.assertTrue(torch.equal(pool_bytes, dense_bytes))
self.assertTrue(torch.equal(rope_pool[rows], k_rope[:, 0]))
def test_strided_kv_slice_matches_contiguous(self):
"""kv is a strided slice of qkv_a; aiter forwards kv.stride(0), so going
back to assuming a packed row would corrupt silently instead of erroring
Covers both callers: the fused ring write (pools) and the caller-buffer
pair (k_nope/k_rope) that prefill and target-verify pass instead.
"""
q_lora_rank = 1536 # DSV4-Pro; only its being != 0 matters here
wide = torch.randn(
self.T, q_lora_rank + HEAD_DIM, device=DEVICE, dtype=torch.bfloat16
)
strided = wide[..., q_lora_rank:]
self.assertFalse(strided.is_contiguous())
self.assertEqual(strided.stride(-1), 1)
runs = []
for kv in (strided, strided.contiguous()):
self.kv = kv
nope_pool, rope_pool = _pools(2 * RING_STRIDE)
k_nope = torch.zeros(
self.T,
1,
DSV4_FP8_NOPE_ROW_BYTES,
dtype=torch.float8_e4m3fn,
device=DEVICE,
)
k_rope = torch.zeros(
self.T, 1, ROPE_DIM, dtype=torch.bfloat16, device=DEVICE
)
packed = self._call(nope_pool, rope_pool, k_nope, k_rope)
runs.append((nope_pool, rope_pool, k_nope, k_rope, packed))
for got, want in zip(*runs):
self.assertTrue(torch.equal(got.view(torch.uint8), want.view(torch.uint8)))
def test_scale_bytes_are_duplicated_e8m0(self):
"""the asm reader reads each tile scale twice, so the 14 B must be 7 equal pairs"""
nope_pool, rope_pool = _pools(2 * RING_STRIDE)
self._call(nope_pool, rope_pool)
rows = self.swa_loc.long()
scales = nope_pool.view(torch.uint8)[
rows, SCALE_OFF : SCALE_OFF + SCALE_BYTES
].reshape(self.T, NUM_TILES, 2)
self.assertTrue(torch.equal(scales[..., 0], scales[..., 1]))
ref_nope, _ = _ref_norm_rope(
self.kv, self.weight, self.cos, self.sin, self.positions
)
expected = (_ref_tile_scales(ref_nope) + 127).to(torch.uint8)
self.assertTrue(torch.equal(scales[..., 0], expected))
def test_dequantized_nope_tracks_the_reference(self):
nope_pool, rope_pool = _pools(2 * RING_STRIDE)
self._call(nope_pool, rope_pool)
ref_nope, _ = _ref_norm_rope(
self.kv, self.weight, self.cos, self.sin, self.positions
)
exps = _ref_tile_scales(ref_nope)
payload = nope_pool[self.swa_loc.long(), :NOPE_DIM].float()
deq = (
payload.reshape(self.T, NUM_TILES, DSV4_FP8_QUANT_TILE)
* torch.exp2(exps)[..., None]
).reshape(self.T, NOPE_DIM)
# e4m3 carries 3 mantissa bits, so the worst case is ~2^-4 of the tile's
# own absmax. Anything beyond that means the scale or the payload is off,
# not rounding.
tile_absmax = (
ref_nope.reshape(self.T, NUM_TILES, DSV4_FP8_QUANT_TILE)
.abs()
.amax(-1)
.repeat_interleave(DSV4_FP8_QUANT_TILE, dim=1)
)
self.assertTrue(torch.all((deq - ref_nope).abs() <= 0.07 * tile_absmax))
def test_rope_pool_matches_the_bf16_reference(self):
nope_pool, rope_pool = _pools(2 * RING_STRIDE)
self._call(nope_pool, rope_pool)
_, ref_pe = _ref_norm_rope(
self.kv, self.weight, self.cos, self.sin, self.positions
)
got = rope_pool[self.swa_loc.long()].float()
torch.testing.assert_close(got, ref_pe, rtol=2e-2, atol=2e-2)
def test_q_stays_bf16_and_rotated(self):
q_out = self._call()
self.assertEqual(q_out.dtype, torch.bfloat16)
self.assertEqual(tuple(q_out.shape), (self.T, NUM_HEADS, HEAD_DIM))
head = self.q.view(self.T, NUM_HEADS, HEAD_DIM)[:, 0]
ones = torch.ones(HEAD_DIM, device=DEVICE, dtype=torch.bfloat16)
ref_nope, ref_pe = _ref_norm_rope(
head, ones, self.cos, self.sin, self.positions
)
got = q_out[:, 0].float()
torch.testing.assert_close(got[:, :NOPE_DIM], ref_nope, rtol=2e-2, atol=2e-2)
torch.testing.assert_close(got[:, NOPE_DIM:], ref_pe, rtol=2e-2, atol=2e-2)
def test_q_is_packed_like_k_when_a_rope_buffer_is_given(self):
"""the v4 nm asm reader wants Q in the same 512 B form as the pool rows
Pinned against the bf16 Q the same call produces without the rope buffer,
so this is the quantization of a known-good rotated Q rather than a
second reimplementation of norm+rope.
"""
q_packed = torch.empty(
self.T,
NUM_HEADS,
DSV4_FP8_NOPE_ROW_BYTES,
dtype=torch.float8_e4m3fn,
device=DEVICE,
)
q_rope = torch.empty(
self.T, NUM_HEADS, ROPE_DIM, dtype=torch.bfloat16, device=DEVICE
)
got = self._call(q_out=q_packed, q_rope_out=q_rope)
self.assertIs(got, q_packed)
ref = self._call().float() # bf16 Q, same input
ref_nope, ref_pe = ref[..., :NOPE_DIM], ref[..., NOPE_DIM:]
raw = q_packed.view(torch.uint8)
exp = _ref_tile_scales(ref_nope.reshape(-1, NOPE_DIM)).reshape(
self.T, NUM_HEADS, NUM_TILES
)
scale_bytes = raw[..., SCALE_OFF : SCALE_OFF + SCALE_BYTES].reshape(
self.T, NUM_HEADS, NUM_TILES, 2
)
torch.testing.assert_close(
scale_bytes[..., 0].int() - 127, exp.int(), rtol=0, atol=0
)
self.assertTrue(torch.equal(scale_bytes[..., 0], scale_bytes[..., 1]))
scale = torch.exp2(scale_bytes[..., 0].float() - 127)
dq = (
raw[..., :NOPE_DIM]
.view(torch.float8_e4m3fn)
.float()
.reshape(self.T, NUM_HEADS, NUM_TILES, DSV4_FP8_QUANT_TILE)
* scale.unsqueeze(-1)
).reshape(self.T, NUM_HEADS, NOPE_DIM)
# atol is half an fp8 step at the *tile's* absmax, not a per-element
# relative error -- a small value sharing a tile with a large one carries
# the large one's step. Measured 0.125 worst case at absmax 3.9, and the
# parts that must be exact (scale bytes above, rope below) are pinned as
# such.
torch.testing.assert_close(dq, ref_nope, rtol=5e-2, atol=5e-2)
torch.testing.assert_close(q_rope.float(), ref_pe, rtol=0, atol=0)
def test_fp8_q_without_a_rope_buffer_is_rejected(self):
q_packed = torch.empty(
self.T,
NUM_HEADS,
DSV4_FP8_NOPE_ROW_BYTES,
dtype=torch.float8_e4m3fn,
device=DEVICE,
)
with self.assertRaises(AssertionError):
self._call(q_out=q_packed)
def test_strided_q_out_is_filled_without_touching_the_padding(self):
"""attn_tp_size > 1 hands us a slice of a head-padded [T, 64, D] buffer
The zero-init is this test's way of seeing whether the staging copy strays
outside the slice. gfx950 allocates that buffer with new_empty, so in
production the padding holds garbage, not zeros -- what matters is only that
nobody writes it.
"""
padded = torch.zeros(self.T, 64, HEAD_DIM, device=DEVICE, dtype=torch.bfloat16)
q_out = padded[:, :NUM_HEADS, :]
self.assertFalse(q_out.is_contiguous())
packed = self._call()
got = fused_qk_norm_rope_swa_store(
q=self.q,
kv=self.kv,
q_norm_weight=None,
kv_norm_weight=self.weight,
q_rms_eps=EPS,
kv_rms_eps=EPS,
rope_head_dim=ROPE_DIM,
cos_cache=self.cos,
sin_cache=self.sin,
positions=self.positions,
q_out=q_out,
dtype=torch.bfloat16,
fp8_2buff=True,
)
self.assertIs(got, q_out)
self.assertTrue(torch.all(padded[:, NUM_HEADS:, :] == 0))
# staging must not reorder the heads, so the strided destination has to
# hold exactly what the contiguous call produced
self.assertTrue(torch.equal(q_out, packed))
def test_negative_position_skips_both_pools(self):
"""a stale/pad token must leave both pools alone, not half-write a row"""
nope_pool, rope_pool = _pools(2 * RING_STRIDE)
self.positions[2] = -1
self._call(nope_pool, rope_pool)
row = self.swa_loc[2].item()
self.assertEqual(nope_pool.view(torch.uint8)[row].max().item(), 0)
self.assertEqual(rope_pool[row].abs().max().item(), 0)
def test_rope_pool_is_required_with_the_nope_pool(self):
nope_pool, _ = _pools(2 * RING_STRIDE)
with self.assertRaises(AssertionError):
self._call(nope_pool, None)
def test_mismatched_pools_are_rejected_before_the_launch(self):
"""aiter aborts the process on a short pool, so these must fail in python"""
nope_pool, rope_pool = _pools(2 * RING_STRIDE)
short_rope = rope_pool[:RING_STRIDE].contiguous()
cases = {
"fewer rope rows": (nope_pool, short_rope),
"rope dtype": (nope_pool, rope_pool.to(torch.float16)),
"rope width": (nope_pool, rope_pool[:, : ROPE_DIM // 2].contiguous()),
"nope row bytes": (nope_pool[:, :NOPE_DIM].contiguous(), rope_pool),
}
for name, (nope, rope) in cases.items():
with self.subTest(name), self.assertRaises(AssertionError):
self._call(nope, rope)
def test_bf16_store_is_a_different_store(self):
nope_pool, rope_pool = _pools(2 * RING_STRIDE)
with self.assertRaises(AssertionError):
fused_qk_norm_rope_swa_store(
q=self.q,
kv=self.kv,
q_norm_weight=None,
kv_norm_weight=self.weight,
q_rms_eps=EPS,
kv_rms_eps=EPS,
rope_head_dim=ROPE_DIM,
cos_cache=self.cos,
sin_cache=self.sin,
positions=self.positions,
swa_cache=nope_pool,
swa_loc=self.swa_loc,
swa_page_size=1,
dtype=torch.bfloat16,
bf16_store=True,
fp8_2buff=True,
swa_rope_cache=rope_pool,
)
class TestBf16StoreStillWorks(_StoreCase):
"""fp8_2buff returns before the Triton kernel, so pin the branch it skips"""
def test_bf16_store_writes_the_whole_row(self):
pool = torch.zeros(
2 * RING_STRIDE, HEAD_DIM, device=DEVICE, dtype=torch.bfloat16
)
ref_nope, ref_pe = _ref_norm_rope(
self.kv, self.weight, self.cos, self.sin, self.positions
)
q_out = fused_qk_norm_rope_swa_store(
q=self.q,
kv=self.kv,
q_norm_weight=None,
kv_norm_weight=self.weight,
q_rms_eps=EPS,
kv_rms_eps=EPS,
rope_head_dim=ROPE_DIM,
cos_cache=self.cos,
sin_cache=self.sin,
positions=self.positions,
swa_cache=pool,
swa_loc=self.swa_loc,
swa_page_size=1,
dtype=torch.bfloat16,
bf16_store=True,
)
self.assertEqual(q_out.dtype, torch.bfloat16)
self.assertEqual(tuple(q_out.shape), (self.T, NUM_HEADS, HEAD_DIM))
rows = self.swa_loc.long()
got = pool[rows].float()
torch.testing.assert_close(got[:, :NOPE_DIM], ref_nope, rtol=2e-2, atol=2e-2)
torch.testing.assert_close(got[:, NOPE_DIM:], ref_pe, rtol=2e-2, atol=2e-2)
untouched = torch.ones(pool.shape[0], dtype=torch.bool, device=DEVICE)
untouched[rows] = False
self.assertEqual(pool[untouched].abs().max().item(), 0)
@unittest.skipUnless(_HAS_GROUP_QUANT, "needs aiter's group-quant kernel on gfx95x")
class TestUnifiedFp8SwaRingWrap(CustomTestCase):
"""What the ring holds once a slot gets written a second time.
The two pools have to turn over together. A row whose nope came from the new
token but whose rope is still the old one decodes against the wrong angle,
and nothing downstream can notice -- both halves are individually
well-formed.
Wrap is driven across calls, not inside one. Within a launch the
out-of-window tokens carry loc -1 and get skipped, so every live row has a
single writer; two writers to one row in one launch would be a race with no
defined winner to assert on.
"""
def setUp(self):
torch.manual_seed(11)
self.T = 6
self.cos, self.sin = _cos_sin()
self.weight = torch.randn(HEAD_DIM, device=DEVICE, dtype=torch.bfloat16)
self.q = torch.randn(
self.T, NUM_HEADS * HEAD_DIM, device=DEVICE, dtype=torch.bfloat16
)
def _store(self, kv, positions, swa_loc, nope_pool, rope_pool):
"""one launch; hands back the dense K pair as the per-token truth"""
k_nope = torch.empty(
self.T, 1, DSV4_FP8_NOPE_ROW_BYTES, dtype=torch.float8_e4m3fn, device=DEVICE
)
k_rope = torch.empty(self.T, 1, ROPE_DIM, dtype=torch.bfloat16, device=DEVICE)
fused_qk_norm_rope_swa_store(
q=self.q,
kv=kv,
q_norm_weight=None,
kv_norm_weight=self.weight,
q_rms_eps=EPS,
kv_rms_eps=EPS,
rope_head_dim=ROPE_DIM,
cos_cache=self.cos,
sin_cache=self.sin,
positions=positions,
swa_cache=nope_pool,
swa_loc=swa_loc,
swa_page_size=1,
dtype=torch.bfloat16,
fp8_2buff=True,
swa_rope_cache=rope_pool,
k_nope_out=k_nope,
k_rope_out=k_rope,
)
return k_nope.view(torch.uint8)[:, 0, : SCALE_OFF + SCALE_BYTES], k_rope[:, 0]
def _pass(self, step, nope_pool, rope_pool, count=None):
"""step 0 fills the ring, step 1 comes back around onto the same slots"""
count = self.T if count is None else count
kv = torch.randn(self.T, HEAD_DIM, device=DEVICE, dtype=torch.bfloat16)
positions = (
torch.arange(self.T, device=DEVICE, dtype=torch.int64) + step * RING_STRIDE
)
swa_loc = (positions.to(torch.int32) % RING_STRIDE + RING_STRIDE).contiguous()
# tokens past `count` fall out of window on this pass, like a short step
if count < self.T:
positions = positions.clone()
positions[count:] = -1
nope, rope = self._store(kv, positions, swa_loc, nope_pool, rope_pool)
return swa_loc.long(), nope.clone(), rope.clone()
def test_wrap_turns_over_both_pools(self):
nope_pool, rope_pool = _pools(2 * RING_STRIDE)
rows, old_nope, _ = self._pass(0, nope_pool, rope_pool)
rows2, new_nope, new_rope = self._pass(1, nope_pool, rope_pool)
self.assertTrue(torch.equal(rows, rows2), "the wrap must reuse the same slots")
# only meaningful if pass 1 actually changed the bytes
self.assertFalse(torch.equal(old_nope, new_nope))
pool_bytes = nope_pool.view(torch.uint8)[rows, : SCALE_OFF + SCALE_BYTES]
self.assertTrue(torch.equal(pool_bytes, new_nope))
self.assertTrue(torch.equal(rope_pool[rows], new_rope))
def test_a_slot_the_wrap_skipped_keeps_its_old_pair(self):
"""a short second pass must leave the rows it didn't address alone"""
keep = 2
nope_pool, rope_pool = _pools(2 * RING_STRIDE)
rows, old_nope, old_rope = self._pass(0, nope_pool, rope_pool)
_, new_nope, new_rope = self._pass(1, nope_pool, rope_pool, count=keep)
pool_bytes = nope_pool.view(torch.uint8)[rows, : SCALE_OFF + SCALE_BYTES]
got_rope = rope_pool[rows]
self.assertTrue(torch.equal(pool_bytes[:keep], new_nope[:keep]))
self.assertTrue(torch.equal(got_rope[:keep], new_rope[:keep]))
self.assertTrue(torch.equal(pool_bytes[keep:], old_nope[keep:]))
self.assertTrue(torch.equal(got_rope[keep:], old_rope[keep:]))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,267 @@
"""SWA ring scatter tests for the two-pool fp8 unified_kv layout.
``store_swa_into_unified`` writes one latent row per token. Under
SGLANG_DSV4_UNIFIED_KV_FP8 that row is split over a packed fp8 nope pool and a
bf16 rope pool, so what these tests pin is that the ring row index -- derived
from state_slot/positions alone -- stays identical to the bf16 layout's and
identical between the two pools.
"""
import unittest
import torch
from sglang.kernels.ops.attention.dsv4.unified_kv_kernels import runtime
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DSV4_FP8_NOPE_ROW_BYTES
from sglang.test.ci.ci_register import register_amd_ci
from sglang.test.test_utils import CustomTestCase
# the scatter itself is a plain row move, but the layout it pins is gfx95-only, so
# run it where the feature lives rather than on the default mi300 runner
register_amd_ci(est_time=10, suite="stage-b-test-1-gpu-small-amd-mi35x")
DEVICE = torch.device("cuda")
# 448 values + 14 E8M0 scales + 50 pad, in bytes
NOPE_ROW_BYTES = DSV4_FP8_NOPE_ROW_BYTES
ROPE_DIM = 64
# V4-Pro latent, in elements -- same number as NOPE_ROW_BYTES, different unit
BF16_LATENT = 448 + ROPE_DIM
RING_STRIDE = 16
WIN = 8
N_PAGES = 64
def _inputs(n_rows=12):
"""state_slot/positions whose ring rows are all distinct, so a row's writer is unambiguous"""
state_slot = torch.tensor(
[0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3][:n_rows],
device=DEVICE,
dtype=torch.int32,
)
positions = torch.tensor(
[0, 1, 2, 16, 17, 18, 32, 33, 34, 48, 49, 50][:n_rows],
device=DEVICE,
dtype=torch.int32,
)
return state_slot, positions
def _expected_rows(state_slot, positions, final_pos=None):
loc = state_slot.long() * RING_STRIDE + positions.long() % RING_STRIDE
if final_pos is None:
keep = torch.ones_like(loc, dtype=torch.bool)
else:
keep = positions.long() > final_pos.long() - WIN
return loc, keep
def _packed_nope(n_rows):
"""random packed fp8 rows; byte 0 is forced nonzero so a written row is detectable"""
raw = torch.randint(
0, 256, (n_rows, NOPE_ROW_BYTES), device=DEVICE, dtype=torch.uint8
)
raw[:, 0] = torch.arange(1, n_rows + 1, device=DEVICE, dtype=torch.uint8)
return raw.view(torch.float8_e4m3fn), raw
def _bf16_rope(n_rows):
rope = torch.randn(n_rows, ROPE_DIM, device=DEVICE, dtype=torch.bfloat16)
rope[:, 0] = torch.arange(1, n_rows + 1, device=DEVICE, dtype=torch.bfloat16)
return rope.contiguous()
def _store(kv, pool, state_slot, positions, final_pos=None, **kw):
runtime.store_swa_into_unified(
kv=kv,
state_slot=state_slot,
positions=positions,
unified_kv=pool,
win=WIN,
ring_stride=RING_STRIDE,
final_pos=final_pos,
**kw,
)
class TestUnifiedFp8SwaScatter(CustomTestCase):
def setUp(self):
torch.manual_seed(20)
self.state_slot, self.positions = _inputs()
self.n_rows = self.state_slot.shape[0]
def _run_two_pool(self, final_pos=None):
kv_nope, nope_bytes = _packed_nope(self.n_rows)
kv_rope = _bf16_rope(self.n_rows)
pool_nope = torch.zeros(
N_PAGES, NOPE_ROW_BYTES, device=DEVICE, dtype=torch.float8_e4m3fn
)
pool_rope = torch.zeros(N_PAGES, ROPE_DIM, device=DEVICE, dtype=torch.bfloat16)
_store(
kv_nope,
pool_nope,
self.state_slot,
self.positions,
final_pos=final_pos,
kv_rope=kv_rope,
unified_kv_rope=pool_rope,
)
return pool_nope, pool_rope, nope_bytes, kv_rope
def _run_bf16(self, final_pos=None):
kv = torch.randn(
self.n_rows, BF16_LATENT, device=DEVICE, dtype=torch.bfloat16
).contiguous()
kv[:, 0] = torch.arange(1, self.n_rows + 1, device=DEVICE, dtype=torch.bfloat16)
pool = torch.zeros(N_PAGES, BF16_LATENT, device=DEVICE, dtype=torch.bfloat16)
_store(kv, pool, self.state_slot, self.positions, final_pos=final_pos)
return pool, kv
def test_bf16_single_pool_unchanged(self):
"""the bf16 path still writes exactly the expected ring rows"""
pool, kv = self._run_bf16()
loc, keep = _expected_rows(self.state_slot, self.positions)
expected = torch.zeros_like(pool)
expected[loc[keep]] = kv[keep]
self.assertTrue(torch.equal(pool, expected))
def test_two_pool_bytes_exact(self):
"""each pool gets its half verbatim -- nope byte-for-byte, rope bit-for-bit"""
pool_nope, pool_rope, nope_bytes, kv_rope = self._run_two_pool()
loc, keep = _expected_rows(self.state_slot, self.positions)
exp_nope = torch.zeros_like(pool_nope).view(torch.uint8)
exp_nope[loc[keep]] = nope_bytes[keep]
self.assertTrue(torch.equal(pool_nope.view(torch.uint8), exp_nope))
exp_rope = torch.zeros_like(pool_rope)
exp_rope[loc[keep]] = kv_rope[keep]
self.assertTrue(torch.equal(pool_rope, exp_rope))
def test_two_pool_rows_match_bf16(self):
"""same state_slot/positions -> same ring rows as bf16, and the same in both pools"""
pool_nope, pool_rope, _, _ = self._run_two_pool()
pool_bf16, _ = self._run_bf16()
rows_nope = (pool_nope.view(torch.uint8) != 0).any(dim=1)
rows_rope = (pool_rope != 0).any(dim=1)
rows_bf16 = (pool_bf16 != 0).any(dim=1)
self.assertTrue(torch.equal(rows_nope, rows_bf16))
self.assertTrue(torch.equal(rows_rope, rows_bf16))
self.assertEqual(int(rows_bf16.sum()), self.n_rows)
def test_final_pos_skips_both_pools(self):
"""tokens already outside the window are skipped in nope and rope alike"""
# positions[t] <= final_pos[t] - WIN skips; give the first half a far
# final_pos and the second half its own position
final_pos = self.positions.clone()
final_pos[: self.n_rows // 2] = self.positions.max() + WIN
pool_nope, pool_rope, nope_bytes, kv_rope = self._run_two_pool(
final_pos=final_pos
)
loc, keep = _expected_rows(self.state_slot, self.positions, final_pos)
self.assertTrue(bool((~keep).any()), "test would be vacuous without a skip")
rows_nope = (pool_nope.view(torch.uint8) != 0).any(dim=1)
rows_rope = (pool_rope != 0).any(dim=1)
expected_rows = torch.zeros(N_PAGES, device=DEVICE, dtype=torch.bool)
expected_rows[loc[keep]] = True
self.assertTrue(torch.equal(rows_nope, expected_rows))
self.assertTrue(torch.equal(rows_rope, expected_rows))
def test_rope_tensor_and_pool_come_together(self):
kv_nope, _ = _packed_nope(self.n_rows)
kv_rope = _bf16_rope(self.n_rows)
pool_nope = torch.zeros(
N_PAGES, NOPE_ROW_BYTES, device=DEVICE, dtype=torch.float8_e4m3fn
)
pool_rope = torch.zeros(N_PAGES, ROPE_DIM, device=DEVICE, dtype=torch.bfloat16)
with self.assertRaises(AssertionError):
_store(
kv_nope,
pool_nope,
self.state_slot,
self.positions,
kv_rope=kv_rope,
)
with self.assertRaises(AssertionError):
_store(
kv_nope,
pool_nope,
self.state_slot,
self.positions,
unified_kv_rope=pool_rope,
)
def test_short_rope_pool_rejected(self):
"""the kernel doesn't bound-check the ring row, so a rope pool with fewer
rows than the nope pool writes into whatever tensor follows it"""
kv_nope, _ = _packed_nope(self.n_rows)
pool_nope = torch.zeros(
N_PAGES, NOPE_ROW_BYTES, device=DEVICE, dtype=torch.float8_e4m3fn
)
# ring rows reach state_slot 3 -> row 48, well past this
pool_rope = torch.zeros(8, ROPE_DIM, device=DEVICE, dtype=torch.bfloat16)
with self.assertRaises(AssertionError):
_store(
kv_nope,
pool_nope,
self.state_slot,
self.positions,
kv_rope=_bf16_rope(self.n_rows),
unified_kv_rope=pool_rope,
)
def test_rope_row_width_mismatch_rejected(self):
"""row width is read off src, so a wider pool would place row i at i * D"""
kv_nope, _ = _packed_nope(self.n_rows)
pool_nope = torch.zeros(
N_PAGES, NOPE_ROW_BYTES, device=DEVICE, dtype=torch.float8_e4m3fn
)
pool_rope = torch.zeros(
N_PAGES, ROPE_DIM * 2, device=DEVICE, dtype=torch.bfloat16
)
with self.assertRaises(AssertionError):
_store(
kv_nope,
pool_nope,
self.state_slot,
self.positions,
kv_rope=_bf16_rope(self.n_rows),
unified_kv_rope=pool_rope,
)
def test_dtype_mismatch_rejected(self):
"""a bf16 row must not land in an fp8 pool (the DSpark-under-fp8 case)"""
kv = torch.randn(
self.n_rows, NOPE_ROW_BYTES, device=DEVICE, dtype=torch.bfloat16
)
pool_nope = torch.zeros(
N_PAGES, NOPE_ROW_BYTES, device=DEVICE, dtype=torch.float8_e4m3fn
)
with self.assertRaises(AssertionError):
_store(kv, pool_nope, self.state_slot, self.positions)
def test_empty_batch_is_a_noop(self):
empty_slot = torch.zeros(0, device=DEVICE, dtype=torch.int32)
kv_nope, _ = _packed_nope(0)
pool_nope = torch.zeros(
N_PAGES, NOPE_ROW_BYTES, device=DEVICE, dtype=torch.float8_e4m3fn
)
pool_rope = torch.zeros(N_PAGES, ROPE_DIM, device=DEVICE, dtype=torch.bfloat16)
_store(
kv_nope,
pool_nope,
empty_slot,
empty_slot,
kv_rope=_bf16_rope(0),
unified_kv_rope=pool_rope,
)
self.assertEqual(int((pool_nope.view(torch.uint8) != 0).sum()), 0)
self.assertEqual(int((pool_rope != 0).sum()), 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,128 @@
import contextlib
import unittest
import torch
from sglang.srt.mem_cache.deepseek_v4_memory_pool import (
DSV4_FP8_NOPE_ROW_BYTES,
DSV4_FP8_QUANT_TILE,
DeepSeekV4UnifiedKVPool,
dsv4_unified_row_bytes,
)
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
# DeepSeek-V4-Pro geometry.
NOPE_DIM = 448
ROPE_DIM = 64
class _StubMemorySaver:
def region(self, _tag):
return contextlib.nullcontext()
class TestDSV4UnifiedRowBytes(CustomTestCase):
"""Row width drives both `bytes_per_full_token` and `_fixed_swa_bytes`, so the
capacity claim for the fp8 pool is only as good as this arithmetic."""
def test_bf16_row_is_the_whole_latent(self):
self.assertEqual(
dsv4_unified_row_bytes(NOPE_DIM, ROPE_DIM, fp8=False),
(NOPE_DIM + ROPE_DIM) * 2,
)
def test_fp8_row_is_padded_nope_plus_bf16_rope(self):
self.assertEqual(
dsv4_unified_row_bytes(NOPE_DIM, ROPE_DIM, fp8=True),
DSV4_FP8_NOPE_ROW_BYTES + ROPE_DIM * 2,
)
def test_fp8_saves_exactly_three_eighths(self):
"""0.625x is where the >=1.40x capacity target comes from; the remaining
dilution is the fixed SWA/c4-state bias, not the row."""
bf16 = dsv4_unified_row_bytes(NOPE_DIM, ROPE_DIM, fp8=False)
fp8 = dsv4_unified_row_bytes(NOPE_DIM, ROPE_DIM, fp8=True)
self.assertEqual((bf16, fp8), (1024, 640))
self.assertAlmostEqual(fp8 / bf16, 0.625)
def test_scales_and_latent_fit_the_asm_stride(self):
"""7 tiles written twice = 14 B; 448 + 14 leaves 50 B the reader never
touches. If a future head_dim broke this the pack would silently overlap."""
num_tiles = NOPE_DIM // DSV4_FP8_QUANT_TILE
self.assertEqual(num_tiles, 7)
self.assertLessEqual(NOPE_DIM + 2 * num_tiles, DSV4_FP8_NOPE_ROW_BYTES)
def test_oversized_latent_is_rejected(self):
# ValueError, not assert: sizing has to keep checking under python -O
with self.assertRaises(ValueError):
dsv4_unified_row_bytes(DSV4_FP8_NOPE_ROW_BYTES, ROPE_DIM, fp8=True)
class TestDSV4UnifiedFp8PoolAllocation(CustomTestCase):
"""The sizing formula and the allocation are two separate code paths; this pins
them to the same row width so a change to one cannot silently outrun the other."""
STAGE_RATIOS = [4, 128]
NUM_SLOTS = 3
NUM_BLOCKS = 5
PAGE_SIZE = 256
SWA_RING = 8
def _pool(self, fp8):
return DeepSeekV4UnifiedKVPool(
stage_ratios=self.STAGE_RATIOS,
num_slots=self.NUM_SLOTS,
num_blocks=self.NUM_BLOCKS,
page_size=self.PAGE_SIZE,
qk_nope_head_dim=NOPE_DIM,
qk_rope_head_dim=ROPE_DIM,
device="cpu",
memory_saver_adapter=_StubMemorySaver(),
custom_mem_pool=None,
swa_ring_size=self.SWA_RING,
fp8=fp8,
)
def test_bf16_pool_is_unchanged(self):
"""fp8 defaults off, so the bf16 arm must keep one pool of bf16 latents."""
pool = self._pool(fp8=False)
for buf, rope in zip(pool.kv_buffer, pool.kv_buffer_rope):
self.assertEqual(buf.dtype, torch.bfloat16)
self.assertEqual(buf.shape[1], NOPE_DIM + ROPE_DIM)
self.assertIsNone(rope)
def test_fp8_pool_row_counts_match_across_both_pools(self):
"""A row index addresses the SWA ring and the compressed region in both
pools, so the two must have identical row counts."""
pool = self._pool(fp8=True)
for buf, rope in zip(pool.kv_buffer, pool.kv_buffer_rope):
self.assertEqual(buf.dtype, torch.float8_e4m3fn)
self.assertEqual(rope.dtype, torch.bfloat16)
self.assertEqual(buf.shape[0], rope.shape[0])
self.assertEqual(buf.shape[1], DSV4_FP8_NOPE_ROW_BYTES)
self.assertEqual(rope.shape[1], ROPE_DIM)
def test_fp8_pool_bytes_match_the_sizing_row_width(self):
bf16, fp8 = self._pool(fp8=False), self._pool(fp8=True)
for layer, buf in enumerate(bf16.kv_buffer):
rows = buf.shape[0]
self.assertEqual(fp8.kv_buffer[layer].shape[0], rows)
self.assertEqual(
buf.nbytes,
rows * dsv4_unified_row_bytes(NOPE_DIM, ROPE_DIM, fp8=False),
)
self.assertEqual(
fp8.kv_buffer[layer].nbytes + fp8.kv_buffer_rope[layer].nbytes,
rows * dsv4_unified_row_bytes(NOPE_DIM, ROPE_DIM, fp8=True),
)
def test_rope_accessor_rejects_the_bf16_pool(self):
with self.assertRaises(AssertionError):
self._pool(fp8=False).get_unified_kv_rope(0)
if __name__ == "__main__":
unittest.main()
@@ -1118,6 +1118,9 @@ class TestSWAPoolFloor(CustomTestCase):
cfg.disaggregation_mode = None
cfg.disaggregation_decode_extra_slots = 0
cfg._unified = True
cfg._unified_fp8 = False
# object.__new__ skips __init__; bf16 unified row is 2B * latent
cfg._unified_row_bytes = cfg.attn_head_dim * 2
return cfg
# Token pool plus the three request-scoped fixed pools, sized from the
@@ -0,0 +1,279 @@
"""DeepSeek-V4 unified_kv fp8: the packed pairs handed to the two readers.
Decode only needs Q packed -- its K is already in the ring. Prefill is a KV
source of its own, so it gets a packed K pair beside the Q one, and the same
buffers have to reach both attention and the ring write after it. Verify wants
both halves: it reads the ring the way decode does and fills it the way prefill
does, only the write lands before attention instead of after.
"""
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import torch
import sglang.srt.models.deepseek_v4 as deepseek_v4
from sglang.kernels.ops.attention.dsv4.unified_kv_kernels import env_gate
from sglang.srt.environ import envs
from sglang.srt.model_executor.forward_batch_info import ForwardMode
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=2, suite="base-a-test-cpu")
# deliberately != head_dim below: the row width has to come off the pool, since
# that is the stride the kernel reads Q with. Sharing head_dim's value would let
# a regression that reads self.head_dim pass.
NOPE_ROW_BYTES = 16
ROPE_DIM = 2
HEAD_DIM = 8
N_LOCAL_HEADS = 16
TOKENS = 3
class _RecordingBackend:
def __init__(self):
self.calls = []
def forward(self, **kwargs):
self.calls.append(kwargs)
query = kwargs["q"]
# bf16 regardless of the q layout -- attention output is never fp8
return torch.zeros(
query.shape[0], query.shape[1], ROPE_DIM, dtype=torch.bfloat16
)
class _Pool:
def __init__(self, fp8):
rows = 32
self.nope = torch.zeros(
rows, NOPE_ROW_BYTES, dtype=torch.float8_e4m3fn if fp8 else torch.bfloat16
)
self.rope = torch.zeros(rows, ROPE_DIM, dtype=torch.bfloat16)
def get_unified_kv(self, layer_id):
return self.nope
def get_unified_kv_rope(self, layer_id):
return self.rope
class _Harness(deepseek_v4.MQALayer):
def __init__(self, rank=3):
torch.nn.Module.__init__(self)
self.layer_id = 0
self.attn_tp_rank = rank
self.attn_tp_size = 8
self.n_heads = 128
self.n_local_heads = N_LOCAL_HEADS
self.head_dim = HEAD_DIM
self.n_local_groups = 1
self.o_lora_rank = 3
self.qk_rope_head_dim = ROPE_DIM
self.freqs_cis = torch.empty(0)
self.compress_ratio = 4
self.attn_mqa = SimpleNamespace(layer_id=0, v_head_dim=ROPE_DIM)
self.attn_sink = torch.nn.Parameter(torch.arange(128, dtype=torch.float32))
self._attn_sink_local = None
self.alt_streams = None
self.dsa_enable_prefill_cp = False
self.use_npu_arch35_mxfp8_wo_a = False
self.compressor = object()
self.wo_a = SimpleNamespace(
weight=torch.ones(
self.n_local_groups,
self.o_lora_rank,
self.n_local_heads * ROPE_DIM,
dtype=torch.bfloat16,
)
)
self.wo_b = lambda value: (value, None)
self.prepare_kwargs = None
def _forward_prepare(
self,
x,
positions,
forward_batch,
attn_backend,
q_out=None,
x_quant=None,
q_rope_out=None,
k_nope_out=None,
k_rope_out=None,
):
self.prepare_kwargs = dict(
q_out=q_out,
q_rope_out=q_rope_out,
k_nope_out=k_nope_out,
k_rope_out=k_rope_out,
)
q_out.zero_()
# mirrors the prefill arm: the packed nope half leaves on the kv slot,
# which is what turns save_kv_cache on in the caller
return q_out, k_nope_out
def _run(fp8, mode=ForwardMode.DECODE, cp=False, fused_verify=True):
layer = _Harness()
layer.dsa_enable_prefill_cp = cp
backend = _RecordingBackend()
forward_batch = SimpleNamespace(forward_mode=mode)
with (
envs.SGLANG_OPT_USE_MULTI_STREAM_OVERLAP.override(False),
envs.SGLANG_OPT_FUSED_QK_NORM_ROPE_VERIFY.override(fused_verify),
patch.object(env_gate, "is_unified_kv_triton", return_value=True),
patch.object(env_gate, "is_unified_kv_fp8", return_value=fp8),
patch.object(deepseek_v4, "get_token_to_kv_pool", return_value=_Pool(fp8)),
patch.object(
deepseek_v4,
"get_attn_tp_context",
return_value=SimpleNamespace(input_scattered=True),
),
patch.object(
deepseek_v4, "get_parallel", return_value=SimpleNamespace(tp_size=8)
),
patch.object(deepseek_v4, "get_attn_backend", return_value=backend),
patch.object(deepseek_v4, "dsa_use_prefill_cp", return_value=cp),
patch.object(deepseek_v4, "fused_rope_inplace", return_value=None),
patch.object(deepseek_v4, "_FP8_WO_A_GEMM", False),
patch.object(deepseek_v4, "_is_gfx942_supported", False),
patch.object(deepseek_v4, "_is_hip", True),
patch.object(deepseek_v4, "_is_npu", False),
):
layer.forward(
torch.zeros(TOKENS, 4, dtype=torch.bfloat16),
torch.arange(TOKENS),
forward_batch,
)
return layer, backend.calls[0]
class TestUnifiedFp8QPair(unittest.TestCase):
def test_fp8_decode_hands_the_backend_a_packed_pair(self):
layer, call = _run(fp8=True)
q, q_rope = call["q"], call["q_rope"]
self.assertEqual(q.dtype, torch.float8_e4m3fn)
# width off the pool, not off head_dim
self.assertEqual(tuple(q.shape), (TOKENS, N_LOCAL_HEADS, NOPE_ROW_BYTES))
self.assertEqual(tuple(q_rope.shape), (TOKENS, N_LOCAL_HEADS, ROPE_DIM))
self.assertEqual(q_rope.dtype, torch.bfloat16)
# the asm kernel walks both as flat buffers, no stride arguments
self.assertTrue(q.is_contiguous())
self.assertTrue(q_rope.is_contiguous())
# same pair reached the store, or nothing would have written them
self.assertIs(layer.prepare_kwargs["q_out"], q)
self.assertIs(layer.prepare_kwargs["q_rope_out"], q_rope)
def test_bf16_decode_still_gets_one_plain_tensor(self):
layer, call = _run(fp8=False)
# q_rope absent is what routes the backend back to the Triton reader
self.assertNotIn("q_rope", call)
self.assertIsNone(layer.prepare_kwargs["q_rope_out"])
self.assertEqual(call["q"].dtype, torch.bfloat16)
self.assertEqual(tuple(call["q"].shape), (TOKENS, N_LOCAL_HEADS, HEAD_DIM))
def test_fp8_prefill_also_gets_a_packed_k_pair(self):
layer, call = _run(fp8=True, mode=ForwardMode.EXTEND)
k, k_rope = call["k"], call["k_rope"]
self.assertEqual(k.dtype, torch.float8_e4m3fn)
# one row per token, width off the pool like Q
self.assertEqual(tuple(k.shape), (TOKENS, NOPE_ROW_BYTES))
self.assertEqual(tuple(k_rope.shape), (TOKENS, ROPE_DIM))
self.assertEqual(k_rope.dtype, torch.bfloat16)
self.assertTrue(k.is_contiguous())
self.assertTrue(k_rope.is_contiguous())
# the buffers the fused store filled are the ones attention reads, and
# the ring write after it consumes the same rows
self.assertIs(layer.prepare_kwargs["k_nope_out"], k)
self.assertIs(layer.prepare_kwargs["k_rope_out"], k_rope)
self.assertTrue(call["save_kv_cache"])
# Q is packed here too, that is what picks the fp8 prefill kernel
self.assertEqual(call["q"].dtype, torch.float8_e4m3fn)
self.assertIsNotNone(call["q_rope"])
def test_fp8_decode_gets_no_k_pair(self):
"""decode attends over rows the ring already holds, so it has no extend"""
layer, call = _run(fp8=True, mode=ForwardMode.DECODE)
self.assertNotIn("k_rope", call)
self.assertIsNone(layer.prepare_kwargs["k_nope_out"])
self.assertIsNone(layer.prepare_kwargs["k_rope_out"])
def test_bf16_prefill_keeps_one_plain_tensor(self):
layer, call = _run(fp8=False, mode=ForwardMode.EXTEND)
self.assertNotIn("q_rope", call)
self.assertNotIn("k_rope", call)
self.assertIsNone(layer.prepare_kwargs["k_nope_out"])
self.assertEqual(call["q"].dtype, torch.bfloat16)
def test_fp8_target_verify_gets_the_packed_pair(self):
"""verify reads the ring like decode, but it also feeds it like prefill"""
layer, call = _run(fp8=True, mode=ForwardMode.TARGET_VERIFY)
# packed Q is what picks the decode reader over the Triton one
self.assertEqual(call["q"].dtype, torch.float8_e4m3fn)
self.assertIsNotNone(call["q_rope"])
k, k_rope = call["k"], call["k_rope"]
self.assertEqual(k.dtype, torch.float8_e4m3fn)
self.assertEqual(tuple(k.shape), (TOKENS, NOPE_ROW_BYTES))
self.assertEqual(tuple(k_rope.shape), (TOKENS, ROPE_DIM))
self.assertIs(layer.prepare_kwargs["k_nope_out"], k)
self.assertIs(layer.prepare_kwargs["k_rope_out"], k_rope)
# unlike prefill the ring write happens before attention, but it is the
# same flag and the same pair
self.assertTrue(call["save_kv_cache"])
def test_fp8_target_verify_needs_the_fused_store(self):
"""nothing else packs the pair, so the unfused arm would hand over bf16"""
with self.assertRaisesRegex(
NotImplementedError, "SGLANG_OPT_FUSED_QK_NORM_ROPE_VERIFY"
):
_run(fp8=True, mode=ForwardMode.TARGET_VERIFY, fused_verify=False)
def test_bf16_target_verify_is_left_alone(self):
"""the packing is fp8-only; bf16 verify keeps working as it always did"""
layer, call = _run(fp8=False, mode=ForwardMode.TARGET_VERIFY)
self.assertNotIn("q_rope", call)
self.assertNotIn("k_rope", call)
self.assertIsNone(layer.prepare_kwargs["k_nope_out"])
def test_fp8_prefill_cp_is_refused_with_a_reason(self):
"""the gather hands kv back in global token order after norm+RoPE, so
packing would have to move ahead of it -- refuse rather than guess"""
with self.assertRaisesRegex(NotImplementedError, "cp_size"):
_run(fp8=True, mode=ForwardMode.EXTEND, cp=True)
def test_bf16_prefill_cp_is_left_alone(self):
"""the refusal is fp8-only, CP prefill without it keeps working"""
_, call = _run(fp8=False, mode=ForwardMode.EXTEND, cp=True)
self.assertNotIn("q_rope", call)
self.assertNotIn("k_rope", call)
def test_fp8_decode_under_cp_is_not_refused(self):
"""only prefill packs this chunk; decode reads rows the ring already has"""
_, call = _run(fp8=True, mode=ForwardMode.DECODE, cp=True)
self.assertEqual(call["q"].dtype, torch.float8_e4m3fn)
def test_sink_is_sliced_to_this_rank(self):
_, call = _run(fp8=True)
sink = call["attn_sink"]
self.assertEqual(tuple(sink.shape), (N_LOCAL_HEADS,))
torch.testing.assert_close(
sink, torch.arange(3 * N_LOCAL_HEADS, 4 * N_LOCAL_HEADS).float()
)
if __name__ == "__main__":
unittest.main()