[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
@@ -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),