[AMD][DSV4] Fix unified-KV pool sizing and SWA ring accounting (#30315)

This commit is contained in:
yuttian1
2026-09-05 16:39:40 -07:00
committed by GitHub
parent 6a0c55fd6c
commit 514b45fd34
23 changed files with 792 additions and 160 deletions
@@ -7,7 +7,11 @@ import pytest
import torch
import triton
from sglang.kernels.ops.attention.dsv4 import compress_forward
from sglang.kernels.ops.attention.dsv4 import (
CompressorDecodePlan,
CompressorPrefillPlan,
compress_forward,
)
from sglang.srt.utils import get_device
from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci
from sglang.test.kernels.deepseek_v4.common import (
@@ -122,6 +126,91 @@ def _make_inputs(
# -----------------------------------------------------------------------------
@pytest.mark.parametrize("ring_size", [8, 16])
@pytest.mark.parametrize(
("gpu_inputs", "use_cuda_graph"),
[(False, False), (True, False), (True, True)],
)
def test_unified_request_ring_plans_ignore_full_to_state(
ring_size: int, gpu_inputs: bool, use_cuda_graph: bool
) -> None:
"""C4 plans must address state by request slot, not the full-cache map."""
device = torch.device(get_device())
req_pool_indices = torch.tensor([2, 5], dtype=torch.int64, device=device)
req_to_token = torch.zeros((6, 16), dtype=torch.int32, device=device)
full_to_state = torch.zeros(1, dtype=torch.int64, device=device)
seq_lens = torch.tensor([8, 12], dtype=torch.int64)
extend_lens = torch.tensor([4, 4], dtype=torch.int64)
if gpu_inputs:
seq_lens = seq_lens.to(device)
extend_lens = extend_lens.to(device)
prefill = CompressorPrefillPlan.generate(
compress_ratio=RATIO,
req_pool_indices=req_pool_indices,
seq_lens=seq_lens,
extend_lens=extend_lens,
req_to_token=req_to_token,
full_to_state=full_to_state,
swa_page_size=256,
ring_size=ring_size,
num_q_tokens=8,
use_cuda_graph=use_cuda_graph,
use_req_ring=True,
)
plan_c = prefill.plan_c.view(torch.int32).reshape(-1, 4).cpu()
plan_w = prefill.plan_w.view(torch.int32).reshape(-1, 2).cpu()
valid_c = plan_c[plan_c[:, 2] >= 0]
got_reads = {
int(row[1].item()) & 0xFFFF: (int(row[2].item()), int(row[3].item()))
for row in valid_c
}
expected_reads = {
3: (
(2 * ring_size + 3 % ring_size) // RATIO,
(2 * ring_size + 7 % ring_size) // RATIO,
),
7: (
(5 * ring_size + 7 % ring_size) // RATIO,
(5 * ring_size + 11 % ring_size) // RATIO,
),
}
assert got_reads == expected_reads
valid_w = plan_w[plan_w[:, 1] >= 0]
got_writes = {int(row[0].item()): int(row[1].item()) for row in valid_w}
expected_writes = {
**{j: 2 * ring_size + (4 + j) % ring_size for j in range(4)},
**{4 + j: 5 * ring_size + (8 + j) % ring_size for j in range(4)},
}
assert got_writes == expected_writes
assert {got_writes[j] for j in range(4)}.isdisjoint(
{got_writes[j] for j in range(4, 8)}
)
decode = CompressorDecodePlan.generate(
compress_ratio=RATIO,
req_pool_indices=req_pool_indices,
req_to_token=req_to_token,
full_to_state=full_to_state,
seq_lens=torch.tensor([8, 12], dtype=torch.int64, device=device),
swa_page_size=256,
ring_size=ring_size,
use_req_ring=True,
)
got_decode = decode.plan_d.view(torch.int32).reshape(-1, 4).cpu()
expected_decode = torch.tensor(
[
[8, 2 * ring_size + 7 % ring_size, *expected_reads[3]],
[12, 5 * ring_size + 11 % ring_size, *expected_reads[7]],
],
dtype=torch.int32,
)
assert torch.equal(got_decode, expected_decode)
assert got_decode[0, 1] != got_decode[1, 1]
@pytest.mark.parametrize("mode", ["legacy", "paged"])
@pytest.mark.parametrize("seq_len", [4, 8, 32, 256, 1024])
def test_prefill_no_context(mode: str, seq_len: int) -> None:
@@ -9,10 +9,7 @@ from sglang.srt.managers.schedule_policy import (
PrefillAdder,
estimate_prefill_extend_tile_metrics,
)
from sglang.srt.mem_cache.base_prefix_cache import (
DecLockRefResult,
IncLockRefResult,
)
from sglang.srt.mem_cache.base_prefix_cache import DecLockRefResult, IncLockRefResult
from sglang.srt.runtime_context import get_context
from sglang.srt.server_args import ServerArgs, set_global_server_args_for_scheduler
from sglang.srt.utils.common import Range
@@ -71,6 +68,13 @@ class TestPrefillAdder(CustomTestCase):
allocator.swa_available_size.return_value = swa_available_size
allocator.available_size.return_value = available_size
allocator.size_swa = size_swa
# get_kvcache().[_unified_kv] gates the unified-KV SWA-ring accounting
# path in schedule_policy.add_chunked_req / rem_swa_tokens. A bare
# MagicMock auto-creates any attribute access as a truthy Mock, so
# without this the getattr(..., "_unified_kv", False) default never
# triggers and these tests silently exercise the unified-KV branch
# instead of the standard hybrid-SWA one they intend to cover.
allocator.get_kvcache.return_value._unified_kv = False
return allocator
def create_running_batch(self, reqs=None) -> MagicMock:
@@ -23,6 +23,9 @@ class _FakeAllocator:
self.alloc_calls = []
self.extend_calls = []
def get_kvcache(self):
return None
def available_size(self):
return 1 << 30
@@ -0,0 +1,119 @@
"""CPU/mock tests for unified DSV4 C4 request-state lifecycle."""
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock
import torch
from sglang.srt.mem_cache.allocation import alloc_req_slots
from sglang.srt.mem_cache.deepseek_v4_compress_state import KVAndScore
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.model_executor.pool_configurator import DSV4PoolConfigurator
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
def _request(req_pool_idx=None, *, reused=False):
return SimpleNamespace(
kv=SimpleNamespace(
req_pool_idx=req_pool_idx,
kv_committed_len=1 if reused else 0,
kv_allocated_len=1 if reused else 0,
holds_kv=reused,
),
inflight_middle_chunks=1 if reused else 0,
)
def _c4_pool(rows: int, width: int, ring_size: int):
return SimpleNamespace(
ratio=4,
ring_size=ring_size,
kv_score_buffer=KVAndScore(torch.full((rows, width), 7.0)),
)
class TestUnifiedC4StateLifecycle(unittest.TestCase):
def test_pool_size_is_exact_request_ring_product(self):
configurator = object.__new__(DSV4PoolConfigurator)
configurator.disaggregation_mode = "decode"
configurator.disaggregation_decode_extra_slots = 3
configurator.c4_ring_size = 16
self.assertEqual(configurator._unified_c4_state_pool_size(10), 14 * 16)
def test_clear_resets_only_selected_request_rings(self):
ring_size = 8
logical_rows = 4 * ring_size
physical_rows = logical_rows + ring_size + 4
attn = _c4_pool(physical_rows, width=12, ring_size=ring_size)
indexer = _c4_pool(physical_rows, width=8, ring_size=ring_size)
c128 = SimpleNamespace(
ratio=128,
ring_size=128,
kv_score_buffer=KVAndScore(torch.full((physical_rows, 8), 9.0)),
)
token_pool = object.__new__(DeepSeekV4TokenToKVPool)
token_pool._unified_kv = True
token_pool.compress_state_pools = [attn, c128]
token_pool.indexer_compress_state_pools = [indexer, None]
token_pool.get_ring_size = MagicMock(return_value=ring_size)
token_pool.clear_c4_req_states([1, 3])
selected = torch.tensor(list(range(8, 16)) + list(range(24, 32)))
untouched = torch.tensor(list(range(0, 8)) + list(range(16, 24)))
for pool in (attn, indexer):
state = pool.kv_score_buffer.kv_score
half = state.shape[-1] // 2
self.assertTrue(
torch.equal(
state[selected, :half], torch.zeros_like(state[selected, :half])
)
)
self.assertTrue(torch.isneginf(state[selected, half:]).all())
self.assertTrue((state[untouched] == 7).all())
self.assertTrue((state[logical_rows:] == 7).all())
self.assertTrue((c128.kv_score_buffer.kv_score == 9).all())
def test_alloc_clears_new_slots_but_not_reused_slots(self):
req_pool = ReqToTokenPool(3, 16, "cpu", enable_memory_saver=False)
token_pool = MagicMock()
reused = _request()
# First admission: a brand-new slot, so its C4 ring must be cleared.
(reused_idx,) = alloc_req_slots(
req_pool, [reused], None, token_to_kv_pool=token_pool
)
token_pool.clear_c4_req_states.assert_called_once_with([reused_idx])
# Chunked continuation reuses the same slot -- clearing it here would
# wipe the state captured by the previous chunk.
token_pool.clear_c4_req_states.reset_mock()
reused.kv.req_pool_idx = reused_idx
reused.kv.kv_committed_len = 1
reused.kv.kv_allocated_len = 1
reused.kv.holds_kv = True
reused.inflight_middle_chunks = 1
self.assertEqual(
alloc_req_slots(req_pool, [reused], None, token_to_kv_pool=token_pool),
[reused_idx],
)
token_pool.clear_c4_req_states.assert_not_called()
# Mixed batch: only the newly allocated slot is cleared.
fresh = _request()
indices = alloc_req_slots(
req_pool, [reused, fresh], None, token_to_kv_pool=token_pool
)
self.assertEqual(indices[0], reused_idx)
self.assertNotEqual(indices[1], reused_idx)
token_pool.clear_c4_req_states.assert_called_once_with([indices[1]])
if __name__ == "__main__":
unittest.main()
@@ -131,6 +131,7 @@ class TestDeepSeekV4HiSparseAllocator(CustomTestCase):
queue = DecodePreallocQueue.__new__(DecodePreallocQueue)
queue.req_to_token_pool = req_to_token_pool
queue.token_to_kv_pool_allocator = allocator
queue.token_to_kv_pool = None
queue.tree_cache = SimpleNamespace(
evictable_size=MagicMock(return_value=0),
protected_size=MagicMock(return_value=0),
@@ -36,6 +36,11 @@ def _make_self(*, page_size: int, full_available: int, swa_available: int):
return SimpleNamespace(
page_size=page_size,
# alloc_extend branches on self._unified to skip the vestigial paged SWA
# allocator on the unified-KV path. This stub exercises the standard
# hybrid-SWA path, so pin it False rather than letting the attribute go
# missing (SimpleNamespace raises instead of defaulting).
_unified=False,
full_attn_allocator=SimpleNamespace(
available_size=lambda: full_available,
alloc_extend=MagicMock(return_value=full_indices),