Use a shared byte budget for unified hybrid-SWA memory (#36729)

Co-authored-by: yhzhuang <yhzhuang@fb.com>
Co-authored-by: Cheng Wan <cheng.wan@radixark.ai>
This commit is contained in:
Yonghao Zhuang
2026-09-15 15:27:00 -07:00
committed by GitHub
co-authored by yhzhuang Cheng Wan
parent 4da5599e93
commit 2929a39927
32 changed files with 2220 additions and 588 deletions
@@ -11,13 +11,17 @@ corruption with no crash.
"""
import unittest
from types import SimpleNamespace
from typing import List, Optional, Set
import torch
from sglang.srt.disaggregation.utils import (
DisaggregationMode,
unified_memory_disagg_move_gate,
)
from sglang.srt.mem_cache.allocator.unified_sub_pool import MultiEndedAllocator
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
@@ -187,58 +191,41 @@ class TestMoveGateRejectsNonPdNode(CustomTestCase):
class TestUnifiedAllocatorsPublishTheTransferContract(CustomTestCase):
"""Every unified composite allocator must OVERRIDE the two PD hooks.
"""Unified composites must translate virtual IDs before PD transfer.
`BaseTokenToKVPoolAllocator.translate_kv_indices_for_transfer` is the
IDENTITY, and `set_disagg_move_gate` exists only where a composite defines
it. Inheriting either is silent, not loud: identity puts VIRTUAL ids on the
wire (they address real bytes, so the peer gets plausible garbage), and a
missing gate lets lazy compaction relocate pages under in-flight RDMA.
An AST-level check because instantiating these composites needs a GPU.
The implementation may be inherited from a shared unified allocator base,
but inheriting the static allocator's identity would put virtual IDs on the
wire and silently corrupt KV. Gate installation must reach every member.
"""
# Composites that own the full-side virtual ids and so must define the
# transfer translate themselves.
_COMPOSITES = (
"UnifiedMambaTokenToKVPoolAllocator",
"UnifiedSWATokenToKVPoolAllocator",
)
# Every composite must define the gate setter, including the tri-pool,
# which inherits the SWA translates (same full side) but has a THIRD
# member the 2-pool setter does not reach.
_GATE_COMPOSITES = _COMPOSITES + ("UnifiedMambaSWATokenToKVPoolAllocator",)
@staticmethod
def _own_methods(cls_name: str) -> Set[str]:
"""Names this class defines ITSELF, inheritance excluded.
Resolved off the class object rather than by parsing a named module:
these composites have already been moved once (out of
`multi_ended_allocator` into `allocator/unified_*`), and a hardcoded
module path turns that kind of move into a test failure that says
nothing about the contract. `__dict__` needs no GPU -- it is the class
body, not an instance.
"""
from sglang.srt.mem_cache.allocator import (
unified_hybrid_swa,
unified_mamba,
def _allocator_class(name):
from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedMambaSWATokenToKVPoolAllocator,
UnifiedSWATokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator,
)
for mod in (unified_mamba, unified_hybrid_swa):
cls = getattr(mod, cls_name, None)
if cls is not None:
return set(vars(cls))
raise AssertionError(f"class {cls_name} not found in the unified allocators")
classes = (
UnifiedMambaTokenToKVPoolAllocator,
UnifiedSWATokenToKVPoolAllocator,
UnifiedMambaSWATokenToKVPoolAllocator,
)
return {cls.__name__: cls for cls in classes}[name]
def test_transfer_translate_is_not_inherited_identity(self):
for name in self._COMPOSITES:
with self.subTest(composite=name):
self.assertIn(
"translate_kv_indices_for_transfer",
self._own_methods(name),
f"{name} inherits the identity transfer translate; PD would "
"ship VIRTUAL ids and corrupt KV without any error",
virtual = torch.tensor([1, 3], dtype=torch.int32)
for name in self._EXPECTED_COVERAGE:
with self.subTest(composite=name), get_parallel().override(attn_dcp_size=1):
alloc = object.__new__(self._allocator_class(name))
alloc.full_attn_allocator = SimpleNamespace(
translate_kv_loc=lambda ids: ids + 16
)
physical = alloc.translate_kv_indices_for_transfer(virtual)
self.assertEqual(physical.dtype, torch.int64)
self.assertEqual(physical.tolist(), [17, 19])
# Every sub-allocator attribute a composite can hold. The stub carries all
# of them regardless of composite, so the assertion is on what installation
@@ -270,11 +257,7 @@ class TestUnifiedAllocatorsPublishTheTransferContract(CustomTestCase):
`object.__new__` skips `__init__` (which needs a GPU); the setter reads
only `lazy_compaction` and the member attributes.
"""
from sglang.srt.mem_cache.allocator import unified_hybrid_swa, unified_mamba
cls = getattr(unified_mamba, cls_name, None) or getattr(
unified_hybrid_swa, cls_name
)
cls = self._allocator_class(cls_name)
alloc = object.__new__(cls)
alloc.lazy_compaction = True
for attr in self._MEMBER_ATTRS:
@@ -313,14 +296,8 @@ class TestUnifiedAllocatorsPublishTheTransferContract(CustomTestCase):
"""
import inspect
from sglang.srt.mem_cache.allocator import unified_hybrid_swa, unified_mamba
for name in self._EXPECTED_COVERAGE:
cls = getattr(unified_mamba, name, None) or getattr(
unified_hybrid_swa, name
)
if "set_disagg_move_gate" not in vars(cls):
continue # inherited, and the inherited one is checked above
cls = self._allocator_class(name)
with self.subTest(composite=name):
body = inspect.getsource(cls.set_disagg_move_gate)
self.assertIn("install_move_gate", body)
@@ -331,10 +308,23 @@ class TestUnifiedAllocatorsPublishTheTransferContract(CustomTestCase):
does not name the SWA page holding the same virtual token. The read-path
`translate_loc_from_full_to_swa` cannot stand in either: it returns
kernel-facing ids, and the transfer addresses raw page envelopes."""
self.assertIn(
"translate_swa_indices_for_transfer",
self._own_methods("UnifiedSWATokenToKVPoolAllocator"),
)
virtual = torch.tensor([1, 3], dtype=torch.int32)
for name in (
"UnifiedSWATokenToKVPoolAllocator",
"UnifiedMambaSWATokenToKVPoolAllocator",
):
with self.subTest(composite=name), get_parallel().override(attn_dcp_size=1):
alloc = object.__new__(self._allocator_class(name))
alloc.full_attn_allocator = SimpleNamespace(
translate_kv_loc=lambda ids: ids + 16
)
alloc.swa_attn_allocator = SimpleNamespace(
translate_kv_loc=lambda ids: ids + 32,
translate_kv_loc_for_kernel=lambda ids: ids + 64,
)
physical = alloc.translate_swa_indices_for_transfer(virtual)
self.assertEqual(physical.dtype, torch.int64)
self.assertEqual(physical.tolist(), [33, 35])
class TestEverySwaAllocatorAnswersTheTransferTranslate(CustomTestCase):
@@ -16,6 +16,12 @@ from sglang.srt.mem_cache.base_prefix_cache import (
DecLockRefResult,
IncLockRefResult,
)
from sglang.srt.mem_cache.prefill_budget import (
PrefillBudget,
SWAPrefillBudget,
estimate_swa_kv_tokens,
)
from sglang.srt.mem_cache.unified_memory_pool import init_unified_swa_pools
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
@@ -75,6 +81,10 @@ class TestPrefillAdder(CustomTestCase):
allocator.swa_available_size.return_value = swa_available_size
allocator.available_size.return_value = available_size
allocator.size_swa = size_swa
allocator.swa_req_ring = False
allocator.create_prefill_budget.side_effect = lambda tree_cache, **kwargs: (
PrefillBudget(allocator, tree_cache, **kwargs)
)
return allocator
def create_running_batch(self, reqs=None) -> MagicMock:
@@ -129,8 +139,115 @@ class TestPrefillAdder(CustomTestCase):
priority_scheduling_preemption_threshold=0,
)
defaults.update(kwargs)
defaults["token_to_kv_pool_allocator"].page_size = defaults["page_size"]
return PrefillAdder(**defaults)
def create_shared_adder(self, *, num_mixed_decode_tokens=0):
self.mock_tree_cache.supports_mamba.return_value = False
self.mock_tree_cache.sliding_window_size = 8
self.mock_tree_cache.is_tree_cache.return_value = False
allocator = init_unified_swa_pools(
device="cpu",
kv_cache_dtype=torch.float16,
head_num=1,
head_dim=4,
v_head_dim=4,
swa_head_num=1,
swa_head_dim=4,
swa_v_head_dim=4,
page_size=4,
start_layer=0,
end_layer=2,
swa_attention_layer_ids=[1],
full_attention_layer_ids=[0],
total_bytes=1024,
enable_memory_saver=False,
need_sort=False,
lazy_compaction=True,
).token_to_kv_pool_allocator
return self.create_adder(
self.create_running_batch(),
page_size=4,
rem_chunk_tokens=16,
num_mixed_decode_tokens=num_mixed_decode_tokens,
token_to_kv_pool_allocator=allocator,
)
def create_shared_req(self, rid, max_new_tokens=4):
req = self.create_mock_req(rid, priority=0, max_new_tokens=max_new_tokens)
req.sampling_params.ignore_eos = False
req.swa_host_hit_length = 0
req.last_node = MagicMock()
req.full_untruncated_fill_ids = list(range(12))
req.set_extend_range = MagicMock(
side_effect=lambda start, end: setattr(
req, "extend_range", Range(start, end)
)
)
return req
def test_shared_admission_reserves_all_pending_requests(self):
adder = self.create_shared_adder()
first, second = (
self.create_shared_req("first"),
self.create_shared_req("second"),
)
adder.add_one_req(first, has_chunked_req=False, truncation_align_size=None)
self.assertEqual(adder.can_run_list, [first])
self.assertEqual(
adder.add_one_req(
second, has_chunked_req=False, truncation_align_size=None
),
AddReqResult.NO_TOKEN,
)
self.assertEqual(adder.can_run_list, [first])
def test_shared_admission_rechecks_after_prefix_lock(self):
adder = self.create_shared_adder()
self.assertIsNotNone(adder.token_to_kv_pool_allocator.alloc(24))
self.mock_tree_cache.full_evictable_size.return_value = 24
self.mock_tree_cache.swa_evictable_size.return_value = 24
def lock_prefix(_):
self.mock_tree_cache.full_evictable_size.return_value = 0
self.mock_tree_cache.swa_evictable_size.return_value = 0
return IncLockRefResult()
self.mock_tree_cache.inc_lock_ref.side_effect = lock_prefix
req = self.create_shared_req("locked-prefix")
self.assertEqual(
adder.add_one_req(req, has_chunked_req=False, truncation_align_size=None),
AddReqResult.NO_TOKEN,
)
self.mock_tree_cache.inc_lock_ref.assert_called_once()
self.assertEqual(adder.can_run_list, [])
def test_shared_continuation_defers_when_decode_consumes_chunk_budget(self):
"""Mixed decode must not commit an empty or negatively sliced prompt."""
for decode_tokens in (16, 28):
with self.subTest(decode_tokens=decode_tokens):
adder = self.create_shared_adder(num_mixed_decode_tokens=decode_tokens)
req = self.create_shared_req("continuation")
before = (
adder.memory_budget.total_offset,
adder.memory_budget.swa_offset,
)
self.assertIs(adder.add_chunked_req(req), req)
self.assertEqual(adder.can_run_list, [])
req.set_extend_range.assert_not_called()
self.assertEqual(
(adder.memory_budget.total_offset, adder.memory_budget.swa_offset),
before,
)
def test_shared_continuation_uses_memory_chunk_limit(self):
adder = self.create_shared_adder()
req = self.create_shared_req("continuation", max_new_tokens=80)
self.assertIs(adder.add_chunked_req(req), req)
self.assertEqual(req.extend_range.length, 8)
self.assertEqual(adder.memory_budget.total_offset, 12)
self.assertEqual(adder.memory_budget.swa_offset, 12)
def test_storage_prefetch_fulfillment_resolves_at_admission(self):
adder = self.create_adder(self.create_running_batch())
req = self.create_mock_req("storage-hit", priority=0, max_new_tokens=1)
@@ -208,7 +325,7 @@ class TestPrefillAdder(CustomTestCase):
running_batch = self.create_running_batch(running_reqs)
adder = self.create_adder(running_batch)
self.assertEqual(adder.rem_total_token_offset, 225)
self.assertEqual(adder.memory_budget.total_offset, 225)
self.mock_token_allocator.full_available_size.return_value = (
225 # full occupation of GRam
@@ -221,7 +338,9 @@ class TestPrefillAdder(CustomTestCase):
self.assertTrue(success)
self.assertIn(running_reqs[0], adder.preempt_list)
self.assertEqual(adder.rem_total_token_offset, 175) # 50 + 75 + 100 - 50 = 175
self.assertEqual(
adder.memory_budget.total_offset, 175
) # 50 + 75 + 100 - 50 = 175
running_batch.release_req.assert_called_once()
def test_preempt_success_low_priority_values_first(self):
@@ -238,7 +357,7 @@ class TestPrefillAdder(CustomTestCase):
running_batch = self.create_running_batch(running_reqs)
adder = self.create_adder(running_batch)
self.assertEqual(adder.rem_total_token_offset, 225)
self.assertEqual(adder.memory_budget.total_offset, 225)
self.mock_token_allocator.full_available_size.return_value = (
225 # full occupation of GRam
@@ -251,7 +370,9 @@ class TestPrefillAdder(CustomTestCase):
self.assertTrue(success)
self.assertIn(running_reqs[2], adder.preempt_list)
self.assertEqual(adder.rem_total_token_offset, 125) # 50 + 75 + 100 - 100 = 125
self.assertEqual(
adder.memory_budget.total_offset, 125
) # 50 + 75 + 100 - 100 = 125
running_batch.release_req.assert_called_once()
def test_preempt_fail_low_priority_values_first(self):
@@ -268,7 +389,7 @@ class TestPrefillAdder(CustomTestCase):
running_batch = self.create_running_batch(running_reqs)
adder = self.create_adder(running_batch)
self.assertEqual(adder.rem_total_token_offset, 225)
self.assertEqual(adder.memory_budget.total_offset, 225)
self.mock_token_allocator.full_available_size.return_value = (
225 # full occupation of GRam
@@ -306,7 +427,7 @@ class TestPrefillAdder(CustomTestCase):
running_batch = self.create_running_batch(running_reqs)
adder = self.create_adder(running_batch)
self.assertEqual(adder.rem_total_token_offset, 225)
self.assertEqual(adder.memory_budget.total_offset, 225)
self.mock_token_allocator.full_available_size.return_value = (
225 # full occupation of GRam
@@ -344,7 +465,7 @@ class TestPrefillAdder(CustomTestCase):
running_batch = self.create_running_batch(running_reqs)
adder = self.create_adder(running_batch)
self.assertEqual(adder.rem_total_token_offset, 225)
self.assertEqual(adder.memory_budget.total_offset, 225)
self.mock_token_allocator.full_available_size.return_value = 225
self.mock_token_allocator.available_size.return_value = 225
@@ -356,7 +477,7 @@ class TestPrefillAdder(CustomTestCase):
first_success = adder.preempt_to_schedule(first_req)
self.assertTrue(first_success)
self.assertIn(running_reqs[0], adder.preempt_list)
self.assertEqual(adder.rem_total_token_offset, 175)
self.assertEqual(adder.memory_budget.total_offset, 175)
running_batch.release_req.assert_called_once()
# Second call needs more tokens than currently free, so it would need to
@@ -367,7 +488,7 @@ class TestPrefillAdder(CustomTestCase):
second_success = adder.preempt_to_schedule(second_req)
self.assertFalse(second_success)
self.assertEqual(adder.rem_total_token_offset, 175)
self.assertEqual(adder.memory_budget.total_offset, 175)
self.assertEqual(adder.preempt_list.count(running_reqs[0]), 1)
running_batch.release_req.assert_called_once()
@@ -387,7 +508,7 @@ class TestPrefillAdder(CustomTestCase):
running_batch = self.create_running_batch(running_reqs)
adder = self.create_adder(running_batch)
self.assertEqual(adder.rem_total_token_offset, 475)
self.assertEqual(adder.memory_budget.total_offset, 475)
self.mock_token_allocator.full_available_size.return_value = (
475 # full occupation of GRam
@@ -400,7 +521,7 @@ class TestPrefillAdder(CustomTestCase):
self.assertTrue(success)
self.assertIn(running_reqs[2], adder.preempt_list)
self.assertEqual(
adder.rem_total_token_offset, 375
adder.memory_budget.total_offset, 375
) # 50 + 75 + 100 + 125 + 125 - 100 = 375
running_batch.release_req.assert_called_once()
@@ -420,7 +541,7 @@ class TestPrefillAdder(CustomTestCase):
running_batch = self.create_running_batch(running_reqs)
adder = self.create_adder(running_batch)
self.assertEqual(adder.rem_total_token_offset, 475)
self.assertEqual(adder.memory_budget.total_offset, 475)
self.mock_token_allocator.full_available_size.return_value = (
475 # full occupation of GRam
@@ -434,7 +555,7 @@ class TestPrefillAdder(CustomTestCase):
self.assertIn(running_reqs[2], adder.preempt_list)
self.assertIn(running_reqs[3], adder.preempt_list)
self.assertEqual(
adder.rem_total_token_offset, 250
adder.memory_budget.total_offset, 250
) # 50 + 75 + 100 + 125 + 125 - 100 - 125 = 250
self.assertEqual(running_batch.release_req.call_count, 2)
@@ -456,8 +577,8 @@ class TestPrefillAdder(CustomTestCase):
self.assertEqual(adder.rem_input_tokens, 192) # 200 - 8
self.assertEqual(adder.rem_chunk_tokens, 56) # 64 - 8
self.assertEqual(adder.rem_total_token_offset, 408) # 8 + 8 * 50
self.assertEqual(adder.cur_rem_token_offset, 8)
self.assertEqual(adder.memory_budget.total_offset, 408) # 8 + 8 * 50
self.assertEqual(adder.memory_budget.current_offset, 8)
self.assertEqual(adder.budget_state(), AddReqResult.CONTINUE)
# Add a prefill that exactly consumes the chunk budget
@@ -497,7 +618,7 @@ class TestPrefillAdder(CustomTestCase):
self.assertEqual(adder2.rem_input_tokens, 195) # 200 - 5
self.assertEqual(adder2.rem_chunk_tokens, 59) # 64 - 5
self.assertEqual(adder2.rem_total_token_offset, 255) # 5 + 5 * 50
self.assertEqual(adder2.memory_budget.total_offset, 255) # 5 + 5 * 50
self.assertEqual(adder2.budget_state(), AddReqResult.CONTINUE)
# Same prefill no longer exhausts the chunk budget
@@ -562,6 +683,10 @@ class TestPrefillAdder(CustomTestCase):
rem_chunk_tokens=rem_chunk,
)
adder.is_hybrid_swa = is_hybrid_swa
if is_hybrid_swa:
adder.memory_budget = SWAPrefillBudget(
self.mock_token_allocator, self.mock_tree_cache
)
req = self.create_mock_req("chunked", priority=0, max_new_tokens=128)
req.prefix_indices = []
@@ -640,7 +765,16 @@ class TestPrefillAdder(CustomTestCase):
page_size=page,
rem_chunk_tokens=rem_chunk,
)
self.assertEqual(adder._swa_budget_for_req(extend, max_new), expected)
self.assertEqual(
estimate_swa_kv_tokens(
extend,
max_new,
sliding_window_size=window,
page_size=page,
allocation_limit=rem_chunk,
),
expected,
)
def test_swa_admission_admits_short_cached_resume_at_two_window_pool(self):
# Livelock regression (real incident). At an SWA pool ~= 2 sliding
@@ -660,6 +794,9 @@ class TestPrefillAdder(CustomTestCase):
self.mock_tree_cache.is_tree_cache.return_value = False
adder = self.create_adder(self.create_running_batch(), page_size=PAGE)
adder.is_hybrid_swa = True
adder.memory_budget = SWAPrefillBudget(
self.mock_token_allocator, self.mock_tree_cache
)
req = self.create_mock_req(
"resume", priority=0, max_new_tokens=40, output_len=10
@@ -677,7 +814,9 @@ class TestPrefillAdder(CustomTestCase):
req.sampling_params = SimpleNamespace(max_new_tokens=40, ignore_eos=False)
# Pre-fix: a constant sliding-window reservation rejects the resume.
with patch.object(adder, "_swa_reserved_tokens", return_value=WINDOW + PAGE):
with patch.object(
adder.memory_budget, "swa_tokens", return_value=WINDOW + PAGE
):
self.assertIs(
adder.add_one_req(
req, has_chunked_req=False, truncation_align_size=None
@@ -708,6 +847,9 @@ class TestPrefillAdder(CustomTestCase):
self.mock_token_allocator.swa_available_size.return_value = 400
adder = self.create_adder(self.create_running_batch(), page_size=PAGE)
adder.is_hybrid_swa = True
adder.memory_budget = SWAPrefillBudget(
self.mock_token_allocator, self.mock_tree_cache
)
req = self.create_mock_req("dropped-fetch", priority=0, max_new_tokens=8)
req.prefix_indices = torch.empty(0, dtype=torch.int64)
req.full_untruncated_fill_ids = list(range(SPAN))
@@ -844,7 +986,7 @@ class TestPrefillAdder(CustomTestCase):
extend if req.retracted_stain else 0,
)
self.assertEqual(
adder.rem_total_token_offset,
adder.memory_budget.total_offset,
adder.ceil_paged_tokens(extend) + decode + 2,
)
self.assertEqual(
@@ -1092,6 +1234,11 @@ class TestPrefillAdder(CustomTestCase):
) -> PrefillAdder:
self.mock_tree_cache.sliding_window_size = sliding_window
self.mock_token_allocator = self.create_token_allocator(size_swa=size_swa)
self.mock_token_allocator.create_prefill_budget.side_effect = (
lambda tree_cache, **kwargs: SWAPrefillBudget(
self.mock_token_allocator, tree_cache, **kwargs
)
)
return self.create_adder(
self.create_running_batch(),
page_size=page_size,
@@ -1103,7 +1250,7 @@ class TestPrefillAdder(CustomTestCase):
# once running decodes drain -> must wait, not take the hatch.
adder = self.create_swa_adder(size_swa=1024, sliding_window=128)
self.assertFalse(
adder._swa_req_never_fits(extend_input_len=256, max_new_tokens=64)
adder.memory_budget.swa_never_fits(extend_input_len=256, max_new_tokens=64)
)
def test_swa_never_fits_true_when_budget_exceeds_whole_pool(self):
@@ -1111,7 +1258,7 @@ class TestPrefillAdder(CustomTestCase):
# pool: it can never fit however far the pool drains -> hatch.
adder = self.create_swa_adder(size_swa=1024, sliding_window=128)
self.assertTrue(
adder._swa_req_never_fits(
adder.memory_budget.swa_never_fits(
extend_input_len=256, max_new_tokens=64, swa_host_hit_length=4096
)
)
@@ -1121,14 +1268,14 @@ class TestPrefillAdder(CustomTestCase):
# the budget against size_swa (guards against a wrong-accessor bug).
req = dict(extend_input_len=256, max_new_tokens=64, swa_host_hit_length=600)
self.assertTrue(
self.create_swa_adder(size_swa=512, sliding_window=128)._swa_req_never_fits(
**req
)
self.create_swa_adder(
size_swa=512, sliding_window=128
).memory_budget.swa_never_fits(**req)
)
self.assertFalse(
self.create_swa_adder(
size_swa=4096, sliding_window=128
)._swa_req_never_fits(**req)
).memory_budget.swa_never_fits(**req)
)
@@ -2,8 +2,12 @@ import logging
import unittest
from types import SimpleNamespace
import torch
from sglang.srt.environ import envs
from sglang.srt.managers.scheduler import Scheduler
from sglang.srt.mem_cache.allocator.token import TokenToKVPoolAllocator
from sglang.srt.mem_cache.unified_memory_pool import init_unified_swa_pools
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_cpu_ci
@@ -56,6 +60,16 @@ class TestSchedulerInitReqMaxNewTokens(unittest.TestCase):
scheduler.max_total_num_tokens = max_total_num_tokens
scheduler.page_size = page_size
scheduler.max_new_tokens_limit = envs.SGLANG_MAX_NEW_TOKENS_LIMIT.get()
scheduler.sliding_window_size = None
scheduler.chunked_prefill_size = None
scheduler.token_to_kv_pool_allocator = TokenToKVPoolAllocator(
size=max_total_num_tokens,
dtype=torch.int64,
device="cpu",
kvcache=None,
need_sort=False,
)
scheduler.token_to_kv_pool_allocator.page_size = page_size
return scheduler
def _new_req(self, max_new_tokens, input_len: int = 8, min_new_tokens: int = 0):
@@ -180,6 +194,37 @@ class TestSchedulerInitReqMaxNewTokens(unittest.TestCase):
)
self._init_and_check(scheduler, req)
def test_unified_budget_rounds_prompt_and_decode_together(self):
bundle = init_unified_swa_pools(
device="cpu",
kv_cache_dtype=torch.float16,
head_num=1,
head_dim=4,
v_head_dim=4,
swa_head_num=1,
swa_head_dim=4,
swa_v_head_dim=4,
page_size=4,
start_layer=0,
end_layer=2,
swa_attention_layer_ids=[1],
full_attention_layer_ids=[0],
total_bytes=384,
enable_memory_saver=False,
need_sort=False,
lazy_compaction=True,
)
scheduler = self._new_scheduler(page_size=4)
scheduler.token_to_kv_pool_allocator = bundle.token_to_kv_pool_allocator
scheduler.sliding_window_size = 4
scheduler.chunked_prefill_size = 4
scheduler.max_new_tokens_limit = None
for prompt_len in (4, 5, 6, 7):
with self.subTest(prompt_len=prompt_len):
req = self._new_req(max_new_tokens=1, input_len=prompt_len)
scheduler.init_req_max_new_tokens(req)
self.assertEqual(req.sampling_params.max_new_tokens, 1)
if __name__ == "__main__":
unittest.main()
@@ -14,7 +14,9 @@ from types import SimpleNamespace
from unittest.mock import MagicMock
from sglang.srt.disaggregation.decode import DecodePreallocQueue
from sglang.srt.mem_cache.kv_cache_configurator import KVCacheConfigurator
from sglang.srt.model_executor.model_runner import ModelRunner
from sglang.srt.runtime_context import get_context
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
@@ -28,6 +30,7 @@ def _make_model_runner(**attrs):
raise AttributeError on the internal `self.effective_max_total_num_tokens`
read inside `max_token_pool_size`."""
instance = object.__new__(ModelRunner)
instance.kv_cache_configurator = object.__new__(KVCacheConfigurator)
for name, value in attrs.items():
setattr(instance, name, value)
return instance
@@ -85,8 +88,9 @@ class TestMaxTokenPoolSize(CustomTestCase):
full_max_total_num_tokens=3000,
swa_max_total_num_tokens=500,
)
self.assertEqual(instance.max_token_pool_size, 3000)
self.assertEqual(instance.effective_max_total_num_tokens, 3000)
with get_context().override_server_args(enable_unified_memory=False):
self.assertEqual(instance.max_token_pool_size, 3000)
self.assertEqual(instance.effective_max_total_num_tokens, 3000)
def _make_prealloc_queue(
@@ -35,6 +35,8 @@ from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
from sglang.srt.mem_cache.kv_index_translator import KVIndexTranslator, KVReadTables
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
from sglang.srt.mem_cache.unified_memory_pool import MHASubPoolSpec, UnifiedKVPool
from sglang.srt.state_capturer.base import BaseTopkCapturer
from sglang.test.test_utils import CustomTestCase
_DEV = "cpu"
_FULL_L = 2
@@ -135,6 +137,7 @@ class TestPassthrough(unittest.TestCase):
device=_DEV,
)
self.assertFalse(src.is_translating)
self.assertEqual(src.capture_token_capacity(17), 18)
rows = torch.tensor([2, 0])
view = src.build_index_table(
req_pool_indices=rows, seq_lens=torch.tensor([5, 3])
@@ -594,7 +597,7 @@ class TestViewMemo(unittest.TestCase):
self.assertEqual(v2.ids.shape[0], 1)
class TestWriteLoc(unittest.TestCase):
class TestWriteLoc(CustomTestCase):
"""The two-phase write contract: `rebind_write_loc` rebinds the full side
once at ForwardBatch construction, and the sliding-window write loc derives
POINTWISE from the full-side values -- pads, slices, and fresh copies
@@ -630,6 +633,121 @@ class TestWriteLoc(unittest.TestCase):
self.assertTrue(torch.equal(fb.out_cache_loc, want_full))
self.assertTrue(torch.equal(virt, keep))
def test_capture_capacity_covers_dcp_widened_mamba_ids(self):
"""A capturer must store the highest virtual IDs issued under DCP."""
from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.unified_memory_pool import MambaSubPoolSpec
from sglang.srt.runtime_context import get_parallel
for dcp_size in (1, 2, 4):
with (
self.subTest(dcp_size=dcp_size),
get_parallel().override(attn_dcp_size=dcp_size),
):
pool = UnifiedKVPool(
total_bytes=2048,
sub_pool_specs=[
MHASubPoolSpec(
name="full",
layer_num=1,
head_num=1,
head_dim=4,
store_dtype=torch.float16,
grow_direction="up",
),
MambaSubPoolSpec(
name="mamba",
layer_num=1,
conv_state_shapes=((2, 2),),
conv_dtype=torch.float16,
temporal_state_shape=(2, 2),
temporal_dtype=torch.float16,
grow_direction="down",
),
],
device="cpu",
enable_memory_saver=False,
page_size=4,
)
allocator = UnifiedMambaTokenToKVPoolAllocator(
unified_buffer=pool,
kvcache=SimpleNamespace(full_kv_pool=None, mamba_pool=None),
device="cpu",
page_size=4,
)
virt = allocator.alloc(allocator.available_size())
self.assertIsNotNone(virt)
src = _make_source(allocator, virt[None, :], 4)
cap = object.__new__(BaseTopkCapturer)
cap.topk_size = 1
expected = torch.arange(len(virt), dtype=torch.int32).reshape(-1, 1, 1)
cap.device_cache = SimpleNamespace(buffer=expected)
cap.host_cache = SimpleNamespace(
buffer=torch.zeros(
src.capture_token_capacity(1), 1, 1, dtype=torch.int32
)
)
fb = _FakeForwardBatch(out_cache_loc=virt)
fb.out_cache_loc_virtual = virt
cap.on_forward_end(fb, False, None, no_copy_to_cpu=False)
req_pool = SimpleNamespace(req_to_token=virt[None, :])
self.assertTrue(
torch.equal(cap.get_topk(0, len(virt) + 1, req_pool), expected)
)
def test_topk_capture_round_trips_request_token_ids(self):
for ps in (1, 4, 64):
for translating in (False, True):
for overlap in (False, True):
with self.subTest(
page_size=ps, translating=translating, overlap=overlap
):
src, _, _, _, virt, _, _ = self._built(ps=ps, n=3 * ps)
req_pool = SimpleNamespace(req_to_token=virt.clone()[None, :])
fb = _FakeForwardBatch(out_cache_loc=virt.clone())
if translating:
src.rebind_write_loc(fb)
fb.out_cache_loc = torch.cat(
[fb.out_cache_loc, virt.new_zeros(2)]
)
else:
fb.out_cache_loc_virtual = None
# Admission may be capped below IDs issued after reuse.
capacity = src.capture_token_capacity(ps)
self.assertGreater(capacity, int(virt.max()))
expected = (
torch.arange(len(virt) * 4, dtype=torch.int32).reshape(
-1, 2, 2
)
+ 1
)
cap = object.__new__(BaseTopkCapturer)
cap.topk_size = 2
cap.device_cache = SimpleNamespace(
buffer=torch.cat([expected, expected.new_zeros(2, 2, 2)])
)
cap.host_cache = SimpleNamespace(
buffer=torch.zeros(capacity, 2, 2, dtype=torch.int32)
)
result = cap.on_forward_end(
fb, False, None, no_copy_to_cpu=overlap
)
if overlap:
fb.out_cache_loc.zero_()
if translating:
fb.out_cache_loc_virtual.zero_()
cap.device_cache.buffer.zero_()
result.map_device_tensors(lambda value: value.cpu())
result.finalize()
self.assertTrue(
torch.equal(
cap.get_topk(0, len(virt) + 1, req_pool), expected
)
)
def test_swa_write_loc_round_trips_from_full_side(self):
"""Derived property: `field(full(t)) == swa(t)` for any virtual run t,
across page sizes and multipliers."""
@@ -40,6 +40,7 @@ from sglang.srt.mem_cache.allocator.unified_sub_pool import (
MultiEndedAllocator,
)
from sglang.srt.mem_cache.base_prefix_cache import EvictParams
from sglang.srt.mem_cache.prefill_budget import estimate_swa_kv_tokens
from sglang.srt.mem_cache.unified_cache.components import ComponentType
from sglang.srt.mem_cache.unified_memory_pool import (
MambaSubPoolSpec,
@@ -558,6 +559,7 @@ class TestUnifiedSWATokenToKVPoolAllocator(unittest.TestCase):
swa_layer_num=2,
head_num=2,
head_dim=4,
page_size=1,
):
full_spec = MHASubPoolSpec(
name="full",
@@ -584,6 +586,7 @@ class TestUnifiedSWATokenToKVPoolAllocator(unittest.TestCase):
sub_pool_specs=[full_spec, swa_spec],
device=_DEV,
enable_memory_saver=False,
page_size=page_size,
)
kvcache = _FakeUnifiedSWAKVPool(pool)
allocator = UnifiedSWATokenToKVPoolAllocator(
@@ -592,11 +595,175 @@ class TestUnifiedSWATokenToKVPoolAllocator(unittest.TestCase):
device=_DEV,
full_max_total_num_tokens=n_full_slots,
swa_max_total_num_tokens=n_swa_slots,
page_size=page_size,
need_sort=False,
forward_stream=None,
)
return pool, allocator, kvcache
def test_reclaim_plan_matches_exhaustive_page_targets(self):
page_size = 4
_, allocator, _ = self._build(
n_full_slots=40, n_swa_slots=24, page_size=page_size
)
allocator.lazy_compaction = True
for sub_pool in (allocator.full_attn_allocator, allocator.swa_attn_allocator):
sub_pool.lazy_compaction = True
sub_pool.disagg_move_gate = lambda: False
live = allocator.alloc(16)
self.assertIsNotNone(live)
allocator.free(live[4:8])
allocator.free_swa(live[8:12])
for compacted in (False, True):
for sub_pool in (
allocator.full_attn_allocator,
allocator.swa_attn_allocator,
):
sub_pool.disagg_move_gate = lambda: compacted
for full_evictable, swa_evictable in ((0, 0), (7, 5), (12, 8), (100, 100)):
max_full = min(12, full_evictable) // page_size
max_swa = min(8, swa_evictable) // page_size
for full_pages in range(9):
for swa_pages in range(9):
feasible = [
(full * page_size, swa * page_size)
for swa in range(max_swa + 1)
for full in range(max_full + 1)
if allocator._fits_page_demand(
full_pages,
swa_pages,
full_reclaim_pages=full,
swa_reclaim_pages=swa,
compacted=compacted,
)
]
with self.subTest(
compacted=compacted,
evictable=(full_evictable, swa_evictable),
pages=(full_pages, swa_pages),
):
self.assertEqual(
allocator.reclaim_plan(
full_pages * page_size,
swa_pages * page_size,
full_evictable_tokens=full_evictable,
swa_evictable_tokens=swa_evictable,
),
feasible[0] if feasible else None,
)
def test_restore_swa_without_allocating_more_full(self):
_, allocator, _ = self._build(page_size=4)
indices = allocator.alloc(8)
full_before = allocator.translate_kv_indices_for_transfer(indices).clone()
allocator.free_swa(indices)
self.assertEqual(allocator.reclaim_plan(0, 8), (0, 0))
self.assertTrue(allocator.can_reserve(0, 8))
self.assertTrue(allocator.ensure_capacity(0, 8))
allocator.swa_attn_allocator.alloc_with_virtual((indices // 4).unique())
self.assertTrue(
torch.equal(
allocator.translate_kv_indices_for_transfer(indices), full_before
)
)
self.assertTrue(
bool((allocator.swa_attn_allocator.translate_kv_loc(indices) > 0).all())
)
def test_empty_pool_reservation_matches_packed_byte_boundary(self):
page_size = 4
_, allocator, _ = self._build(
n_full_slots=40,
n_swa_slots=24,
full_layer_num=4,
swa_layer_num=2,
page_size=page_size,
)
full_allocator = allocator.full_attn_allocator
swa_allocator = allocator.swa_attn_allocator
swa_pages = 2
full_pages = (
allocator._empty_shared_gap_bytes
- swa_pages * swa_allocator.entry_bytes_per_page
) // full_allocator.entry_bytes_per_page
packed_bytes = (
full_pages * full_allocator.entry_bytes_per_page
+ swa_pages * swa_allocator.entry_bytes_per_page
)
self.assertLessEqual(
full_pages + 1, full_allocator.num_pages - full_allocator.min_page_index
)
self.assertLessEqual(
swa_pages, swa_allocator.num_pages - swa_allocator.min_page_index
)
self.assertLessEqual(packed_bytes, allocator._empty_shared_gap_bytes)
self.assertGreater(
packed_bytes + full_allocator.entry_bytes_per_page,
allocator._empty_shared_gap_bytes,
)
self.assertTrue(
allocator.can_reserve(
full_pages * page_size,
swa_pages * page_size,
empty_pool=True,
)
)
self.assertFalse(
allocator.can_reserve(
full_pages * page_size + 1,
swa_pages * page_size,
empty_pool=True,
)
)
extend_tokens = 32
max_new_tokens = 0
reservation_full_tokens = extend_tokens + max_new_tokens + page_size
reservation_swa_tokens = estimate_swa_kv_tokens(
extend_tokens,
max_new_tokens,
sliding_window_size=16,
page_size=page_size,
allocation_limit=16,
)
reservation_swa_with_tail = estimate_swa_kv_tokens(
extend_tokens,
max_new_tokens,
sliding_window_size=16,
page_size=page_size,
)
reservation_bytes = (
reservation_full_tokens // page_size
) * full_allocator.entry_bytes_per_page + (
reservation_swa_tokens // page_size
) * swa_allocator.entry_bytes_per_page
reservation_bytes_with_tail = (
reservation_full_tokens // page_size
) * full_allocator.entry_bytes_per_page + (
reservation_swa_with_tail // page_size
) * swa_allocator.entry_bytes_per_page
self.assertLessEqual(reservation_bytes, allocator._empty_shared_gap_bytes)
self.assertGreater(
reservation_bytes_with_tail, allocator._empty_shared_gap_bytes
)
self.assertTrue(
allocator.can_reserve(
reservation_full_tokens,
reservation_swa_tokens,
empty_pool=True,
)
)
self.assertFalse(
allocator.can_reserve(
reservation_full_tokens,
reservation_swa_with_tail,
empty_pool=True,
)
)
def _alloc(self, allocator, kvcache, n):
"""Allocate N virtual ids; stamp the data marker on both sub-pools."""
v = allocator.alloc(n)
@@ -0,0 +1,300 @@
"""CPU regressions for allocator-owned prefill admission and pending demand."""
import unittest
from array import array
from types import SimpleNamespace
from unittest.mock import MagicMock
import torch
from sglang.srt.managers.schedule_batch import Req
from sglang.srt.managers.schedule_policy import PrefillAdder
from sglang.srt.managers.scheduler import Scheduler
from sglang.srt.mem_cache.allocator.hisparse import (
DeepSeekV4HiSparseTokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.allocator.swa import (
PureSWATokenToKVPoolAllocator,
SWATokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedMambaSWATokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.common import evict_from_tree_cache
from sglang.srt.mem_cache.prefill_budget import SWAPrefillBudget
from sglang.srt.mem_cache.unified_memory_pool import init_unified_swa_pools
from sglang.srt.runtime_context import get_parallel
from sglang.srt.sampling.sampling_params import SamplingParams
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
def _shared_allocator(*, page_size=4, total_bytes=1024):
return init_unified_swa_pools(
device="cpu",
kv_cache_dtype=torch.float16,
head_num=1,
head_dim=4,
v_head_dim=4,
swa_head_num=1,
swa_head_dim=4,
swa_v_head_dim=4,
page_size=page_size,
start_layer=0,
end_layer=2,
swa_attention_layer_ids=[1],
full_attention_layer_ids=[0],
total_bytes=total_bytes,
enable_memory_saver=False,
need_sort=False,
lazy_compaction=True,
).token_to_kv_pool_allocator
def _cache():
return SimpleNamespace(
sliding_window_size=8,
full_evictable_size=lambda: 0,
swa_evictable_size=lambda: 0,
is_chunk_cache=lambda: False,
)
class TestSharedPrefillMemoryBudget(unittest.TestCase):
def setUp(self):
self.allocator = _shared_allocator()
self.cache = _cache()
self.budget = self.allocator.create_prefill_budget(self.cache)
self.request = dict(
extend_input_len=12,
total_tokens=20,
max_new_tokens=4,
input_tokens=12,
swa_host_hit_length=0,
chunk_limit=16,
)
def test_pending_batch_cannot_spend_shared_bytes_twice(self):
self.assertEqual(self.budget.check_prefill(**self.request), (True, 16))
self.budget.reserve(12, 4, chunk_limit=16)
# Each side separately has room, but their combined reservation does not.
self.assertGreater(self.budget.remaining_total, 20)
self.assertGreater(self.budget.remaining_swa, 16)
self.assertEqual(self.budget.check_prefill(**self.request), (False, None))
self.assertEqual(self.allocator.full_attn_allocator.allocated_count(), 0)
self.assertEqual(self.allocator.swa_attn_allocator.allocated_count(), 0)
def test_mixed_decode_reserves_both_sides(self):
budget = self.allocator.create_prefill_budget(
self.cache, num_mixed_decode_tokens=4
)
budget.reserve(12, 4, chunk_limit=16)
self.assertEqual(
(budget.total_offset, budget.current_offset, budget.swa_offset),
(24, 20, 20),
)
self.assertEqual(budget.check_prefill(**self.request), (False, None))
def test_prefix_lock_changes_admission_without_rebuilding_budget(self):
self.assertIsNotNone(self.allocator.alloc(24))
self.cache.full_evictable_size = lambda: 24
self.cache.swa_evictable_size = lambda: 24
self.assertEqual(self.budget.check_prefill(**self.request), (True, 16))
# Locking the cached prefix removes its eviction credit.
self.cache.full_evictable_size = lambda: 0
self.cache.swa_evictable_size = lambda: 0
self.assertEqual(self.budget.check_prefill(**self.request), (False, None))
def test_final_chunk_reserves_decode_headroom(self):
limit = self.budget.fit_chunk(
extend_input_len=12, max_new_tokens=80, chunk_limit=16
)
self.assertEqual(limit, 8)
self.budget.reserve(limit, 0, chunk_limit=16, is_chunked_continuation=True)
self.assertIsNotNone(self.allocator.alloc(limit))
def test_host_swa_load_is_part_of_joint_demand(self):
self.assertEqual(self.budget.check_prefill(**self.request), (True, 16))
request = {**self.request, "swa_host_hit_length": 32}
self.assertEqual(self.budget.check_prefill(**request), (False, None))
def test_prompt_clipping_uses_the_empty_pool(self):
kwargs = dict(token_capacity=1, sliding_window_size=8, chunk_size=16)
limit = self.allocator.max_new_tokens_for_memory(12, 80, **kwargs)
self.assertIsNotNone(limit)
self.assertGreater(limit, 0)
self.assertIsNotNone(self.allocator.alloc(24))
self.assertEqual(
self.allocator.max_new_tokens_for_memory(12, 80, **kwargs), limit
)
self.assertIsNone(self.allocator.max_new_tokens_for_memory(100, 0, **kwargs))
def test_shared_stats_pair_available_tokens_with_current_capacity(self):
self.assertIsNotNone(self.allocator.alloc(12))
(full_capacity, full_free), (swa_capacity, swa_free) = (
self.allocator.swa_capacity_and_available(full_capacity=1, swa_capacity=1)
)
self.assertEqual(full_capacity - full_free, 12)
self.assertEqual(swa_capacity - swa_free, 12)
def test_common_eviction_dispatches_joint_reclaim(self):
self.cache.token_to_kv_pool_allocator = self.allocator
self.allocator.evict_to_free_tokens = MagicMock()
evict_from_tree_cache(self.cache, 8)
self.allocator.evict_to_free_tokens.assert_called_once_with(self.cache, 8)
class TestSharedPrefillAdmission(unittest.TestCase):
def _new_admission(self, page_size, pool_pages, *, ignore_eos=False):
allocator = _shared_allocator(
page_size=page_size, total_bytes=pool_pages * page_size * 16
)
req = Req(
rid="unaligned-prompt",
origin_input_text=None,
origin_input_ids=array("q", [1] * (page_size + 1)),
sampling_params=SamplingParams(max_new_tokens=1, ignore_eos=ignore_eos),
)
scheduler = Scheduler.__new__(Scheduler)
scheduler.max_req_len = 16 * page_size
scheduler.max_total_num_tokens = allocator.size_full
scheduler.page_size = page_size
scheduler.max_new_tokens_limit = None
scheduler.sliding_window_size = page_size
scheduler.chunked_prefill_size = page_size
scheduler.token_to_kv_pool_allocator = allocator
with get_parallel().override(attn_dcp_size=1):
scheduler.init_req_max_new_tokens(req)
self.assertEqual(req.sampling_params.max_new_tokens, 1)
req._refresh_fill_ids()
cache = SimpleNamespace(
sliding_window_size=page_size,
disable=True,
full_evictable_size=lambda: 0,
swa_evictable_size=lambda: 0,
is_chunk_cache=lambda: True,
supports_mamba=lambda: False,
)
adder = PrefillAdder(
page_size=page_size,
tree_cache=cache,
token_to_kv_pool_allocator=allocator,
running_batch=None,
new_token_ratio=1.0,
rem_input_tokens=16 * page_size,
rem_chunk_tokens=page_size,
)
return allocator, req, adder
def test_unaligned_final_chunk_makes_progress(self):
for page_size in (4, 64):
with self.subTest(page_size=page_size):
allocator, req, adder = self._new_admission(page_size, pool_pages=7)
req.prefix_indices = allocator.alloc(page_size)
self.assertIsNotNone(req.prefix_indices)
self.assertTrue(allocator.can_reserve(page_size + 2, page_size + 2))
self.assertIsNone(adder.add_chunked_req(req))
self.assertEqual(adder.can_run_list, [req])
self.assertEqual(req.extend_range.length, 1)
def test_unaligned_ignore_eos_enters_empty_pool(self):
for page_size in (4, 64):
with self.subTest(page_size=page_size):
allocator, req, adder = self._new_admission(
page_size, pool_pages=6, ignore_eos=True
)
self.assertEqual(len(req.prefix_indices), 0)
self.assertTrue(allocator.can_reserve(2 * page_size + 2, 2 * page_size))
adder.add_one_req(
req, has_chunked_req=False, truncation_align_size=None
)
self.assertEqual(adder.can_run_list, [req])
class TestFixedPrefillMemoryBudget(unittest.TestCase):
def _allocator(self, cls=SWATokenToKVPoolAllocator):
allocator = object.__new__(cls)
allocator.page_size = 4
allocator._size_full = 128
allocator._size_swa = 64
allocator.full_available_size = lambda: 128
allocator.swa_available_size = lambda: 64
return allocator
def test_ring_slot_reserved_once_and_evictable_tokens_give_no_credit(self):
allocator = self._allocator()
allocator._swa_req_ring = True
allocator._swa_ring_cost = 32
cache = _cache()
cache.swa_evictable_size = lambda: 1000
budget = allocator.create_prefill_budget(cache)
budget.reserve(12, 4, chunk_limit=16)
self.assertEqual(budget.remaining_swa, 32)
budget.reserve(12, 4, chunk_limit=16, is_chunked_continuation=True)
self.assertEqual(budget.remaining_swa, 32)
# The last exact ring slot remains admissible.
self.assertEqual(
budget.check_prefill(
extend_input_len=4,
total_tokens=12,
max_new_tokens=4,
input_tokens=4,
swa_host_hit_length=0,
chunk_limit=16,
),
(True, 16),
)
def test_pure_swa_budget_reads_swa_capacity(self):
allocator = self._allocator(PureSWATokenToKVPoolAllocator)
allocator.full_available_size = lambda: 0
budget = allocator.create_prefill_budget(_cache())
self.assertEqual(budget.remaining_total, 64)
self.assertTrue(budget.has_capacity())
def test_hisparse_budget_reads_wrapper_capacity(self):
allocator = self._allocator(DeepSeekV4HiSparseTokenToKVPoolAllocator)
allocator.full_available_size = lambda: 12
budget = allocator.create_prefill_budget(_cache())
self.assertEqual(budget.remaining_total, 12)
self.assertEqual(budget.remaining_swa, 64)
def test_tri_pool_keeps_fixed_admission_and_clipping(self):
allocator = self._allocator(UnifiedMambaSWATokenToKVPoolAllocator)
allocator.can_reserve = MagicMock(
side_effect=AssertionError("two-pool reservation")
)
budget = allocator.create_prefill_budget(_cache())
self.assertIs(type(budget), SWAPrefillBudget)
self.assertTrue(budget.has_capacity())
self.assertEqual(
allocator.max_new_tokens_for_memory(
5,
100,
token_capacity=32,
sliding_window_size=8,
chunk_size=16,
),
19,
)
def test_tri_pool_eviction_does_not_reenter_common(self):
allocator = self._allocator(UnifiedMambaSWATokenToKVPoolAllocator)
allocator.available_size = lambda: 0
allocator.full_available_size = lambda: 0
allocator.swa_available_size = lambda: 0
cache = _cache()
cache.token_to_kv_pool_allocator = allocator
cache.evict_for_alloc = MagicMock()
evict_from_tree_cache(cache, 8)
cache.evict_for_alloc.assert_called_once()
params = cache.evict_for_alloc.call_args.args[0]
self.assertEqual((params.num_tokens, params.swa_num_tokens), (8, 8))
if __name__ == "__main__":
unittest.main()
@@ -13,9 +13,9 @@
# ==============================================================================
"""Byte-budget buffer sizing for the unified 2-pool factories.
With ``unified_total_bytes`` set the buffer is that many bytes exactly (the
mamba pair adds the state pool's bytes on top -- the budget is captured AFTER
the state carve-out); without it, sizing falls back to the token-count re-sum.
The SWA factory's ``total_bytes`` sets the exact buffer size. The Mamba pair's
``unified_total_bytes`` adds state bytes because its budget excludes that carve-out.
Without an explicit budget, sizing falls back to the token-count re-sum.
Sizing from the ratio-derived token counts instead would re-introduce the
configurator's rounding, which floors by the cell size and then page-aligns
EACH side, losing up to about a page of tokens per side.
@@ -91,7 +91,7 @@ class TestBudgetSizing(unittest.TestCase):
(64 + 32) * e + (e - 2), # almost one more entry
):
with self.subTest(budget=budget):
bundle = _swa_factory(unified_total_bytes=budget)
bundle = _swa_factory(total_bytes=budget)
self.assertEqual(bundle.unified_memory_pool.total_bytes, budget)
def test_fallback_is_the_token_count_resum(self):
@@ -166,7 +166,7 @@ class TestBs1FeasibilityFloor(unittest.TestCase):
must fail loud instead of livelocking later."""
with self.assertRaises(RuntimeError) as ctx:
_swa_factory(
unified_total_bytes=8 * _entry_bytes(),
total_bytes=8 * _entry_bytes(),
model_context_len=4096,
sliding_window_size=4096,
)
@@ -184,7 +184,7 @@ class TestBs1FeasibilityFloor(unittest.TestCase):
):
with self.subTest(case=case):
bundle = _swa_factory(
unified_total_bytes=200 * e,
total_bytes=200 * e,
model_context_len=model_context_len,
sliding_window_size=sliding_window_size,
)
@@ -327,6 +327,7 @@ class TestEveryUnifiedAllocatorOverridesFreeSegment(unittest.TestCase):
mea.MultiEndedAllocator,
unified_mamba.UnifiedMambaTokenToKVPoolAllocator,
unified_hybrid_swa.UnifiedSWATokenToKVPoolAllocator,
unified_hybrid_swa.UnifiedMambaSWATokenToKVPoolAllocator,
):
with self.subTest(cls=cls.__name__):
self.assertIsNot(
@@ -346,9 +347,13 @@ class TestEveryUnifiedAllocatorOverridesFreeSegment(unittest.TestCase):
mea.MultiEndedAllocator,
unified_mamba.UnifiedMambaTokenToKVPoolAllocator,
unified_hybrid_swa.UnifiedSWATokenToKVPoolAllocator,
unified_hybrid_swa.UnifiedMambaSWATokenToKVPoolAllocator,
):
with self.subTest(cls=cls.__name__):
self.assertIn("free_page_reps_group", inspect.getsource(cls))
alloc = object.__new__(cls)
alloc.free_group = None
alloc.free_group_begin()
self.assertEqual(alloc.free_page_reps_group, [])
class TestUnifiedSwaFullSideGroup(unittest.TestCase):
@@ -554,7 +554,11 @@ class TestUnifiedRadixAllocationEviction(CustomTestCase):
def test_common_helper_uses_allocation_aware_entry_point(self):
tree_cache = MagicMock()
tree_cache.is_chunk_cache.return_value = False
tree_cache.token_to_kv_pool_allocator.available_size.return_value = 30
from sglang.srt.mem_cache.allocator.token import TokenToKVPoolAllocator
allocator = object.__new__(TokenToKVPoolAllocator)
allocator.available_size = lambda: 30
tree_cache.token_to_kv_pool_allocator = allocator
evict_from_tree_cache(tree_cache, num_tokens=100)
@@ -4319,7 +4319,7 @@ class UnifiedRadixCacheSuite:
def test_buffer_load_back_swa_window_charged_at_admission(self):
"""Admission contract: a request the SWA budget gate accepts must be
allocatable at batch time (_swa_reserved_tokens: "an admitted request
allocatable at batch time (estimate_swa_kv_tokens: "an admitted request
cannot OOM"). Regression: buffer mode surfaced a staged prefetch as
host_hit_length only, so the gate never charged the SWA window that
consumption (init_load_back -> cc.load) allocates and the request
@@ -20,7 +20,7 @@ Pure CPU; fakes stand in for the KV pools (data markers verify moves).
import inspect
import unittest
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import torch
@@ -175,6 +175,33 @@ class TestUnifiedTriPool(unittest.TestCase):
self.assertIs(kvcache._full_allocator, fa)
self.assertIs(kvcache._swa_allocator, sa)
def test_pd_preallocation_binds_only_swa_tail_pages(self):
_, allocator, _, _ = self._build(page_size=4)
before = allocator.available_size()
prefix = torch.tensor([0], dtype=torch.int64)
seq = torch.tensor([12], dtype=torch.int64)
# With an empty prefix, ordinary allocation supplies the same virtual
# pages without launching the GPU extend kernel. All page binding and
# capacity accounting still run through the real sub-allocators.
full = allocator.full_attn_allocator
with patch.object(
full, "alloc_extend", side_effect=lambda *a, **kw: full.alloc(12)
):
virtual = allocator.alloc_extend_swa_tail(
prefix, prefix, seq, seq, torch.tensor([-1]), 12, swa_tail_len=5
)
self.assertIsNotNone(virtual)
self.assertEqual(len(virtual), 12)
self.assertEqual(allocator.full_attn_allocator.allocated_count(), 12)
self.assertEqual(allocator.swa_attn_allocator.allocated_count(), 8)
swa_pages = allocator.swa_v2p_page_table[virtual[::4] // 4]
self.assertLessEqual(swa_pages[0].item(), 0)
self.assertTrue(torch.all(swa_pages[1:] > 0).item())
allocator.free(virtual)
self.assertEqual(allocator.full_attn_allocator.allocated_count(), 0)
self.assertEqual(allocator.swa_attn_allocator.allocated_count(), 0)
self.assertEqual(allocator.available_size(), before)
def test_empty_float_is_transparent_to_the_ends(self):
_, allocator, _, _ = self._build()
fa = allocator.full_attn_allocator
@@ -961,11 +988,19 @@ class TestTriFactorySizing(unittest.TestCase):
return kw
def test_budget_sizing_and_boot_signature(self):
from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWAAllocatorBase,
UnifiedSWATokenToKVPoolAllocator,
)
budget = 1 << 20
bundle = init_unified_mamba_swa_pools(
**self._factory_kwargs(unified_total_bytes=budget)
)
pool = bundle.unified_memory_pool
allocator = bundle.token_to_kv_pool_allocator
self.assertIsInstance(allocator, UnifiedSWAAllocatorBase)
self.assertNotIsInstance(allocator, UnifiedSWATokenToKVPoolAllocator)
# Buffer = budget + the state pool's bytes (budget captured AFTER the
# state carve-out), never the token-count re-sum.
state_bytes = 4 * pool.spec("mamba").entry_bytes()
@@ -83,6 +83,7 @@ def _make_model_runner(
disaggregation_mode="null",
max_running_requests=None,
disaggregation_decode_extra_slots=0,
enable_unified_memory=False,
kv_lora_rank=512,
qk_rope_head_dim=64,
swa_kv_lora_rank=128,
@@ -150,6 +151,7 @@ def _make_model_runner(
disaggregation_mode=disaggregation_mode,
max_running_requests=max_running_requests,
disaggregation_decode_extra_slots=disaggregation_decode_extra_slots,
enable_unified_memory=enable_unified_memory,
enable_hisparse=False,
enable_hierarchical_cache=False,
enable_dsa_cache_layer_split=False,
@@ -299,7 +301,14 @@ class TestDefaultConfigurator(CustomTestCase):
class TestHybridSWAConfigurator(CustomTestCase):
"""Hybrid SWA: full/swa split, ratio, memory invariant."""
def _make_swa_runner(self, full_layers=16, swa_layers=16, ratio=0.5, page_size=1):
def _make_swa_runner(
self,
full_layers=16,
swa_layers=16,
ratio=0.5,
page_size=1,
enable_unified_memory=False,
):
return _make_model_runner(
self,
is_hybrid_swa=True,
@@ -308,6 +317,7 @@ class TestHybridSWAConfigurator(CustomTestCase):
swa_num_kv_heads=4,
page_size=page_size,
swa_full_tokens_ratio=ratio,
enable_unified_memory=enable_unified_memory,
)
def _run(self, available_bytes, **kwargs):
@@ -329,6 +339,92 @@ class TestHybridSWAConfigurator(CustomTestCase):
self.assertLessEqual(used, available)
self.assertGreater(used, available * 0.99)
def test_draft_does_not_inherit_target_shared_byte_budget(self):
"""A separate draft pool must not allocate the target's byte envelope again."""
from sglang.srt.mem_cache.kv_cache_configurator import KVCacheConfigurator
mr, _, config = self._run(1 << 20, enable_unified_memory=True)
self.assertIsNotNone(config.unified_memory_pool_bytes)
configurator = object.__new__(KVCacheConfigurator)
configurator.model_config = mr.model_config
configurator.is_hybrid_swa = True
configurator.is_draft_worker = False
target = configurator._derive_pool_sizes(config=config)
configurator.is_draft_worker = True
draft = configurator._derive_pool_sizes(config=config)
self.assertEqual(
target.unified_memory_pool_bytes, config.unified_memory_pool_bytes
)
self.assertIsNone(draft.unified_memory_pool_bytes)
self.assertEqual(
draft.full_max_total_num_tokens, config.full_max_total_num_tokens
)
self.assertEqual(
draft.swa_max_total_num_tokens, config.swa_max_total_num_tokens
)
def test_unified_capacity_is_maximal_with_draft_pool(self):
page_size = 8
full_layers = 2
swa_layers = 1
draft_layers = 2
draft_swa_layers = 1
ratio = 0.5
mr = _make_model_runner(
self,
is_hybrid_swa=True,
full_attention_layer_ids=list(range(full_layers)),
swa_attention_layer_ids=list(range(full_layers, full_layers + swa_layers)),
swa_num_kv_heads=4,
swa_full_tokens_ratio=ratio,
page_size=page_size,
enable_unified_memory=True,
speculative_algorithm="EAGLE",
)
mr.spec_algorithm.is_eagle.return_value = True
mr.spec_algorithm.is_none.return_value = False
mr.spec_aux_config.eagle_draft_num_layers = draft_layers
mr.spec_aux_config.eagle_draft_swa_num_layers = draft_swa_layers
full_bytes_per_token = _full_per_token(mr)
swa_bytes_per_token = _swa_per_token(mr)
target_full_bytes_per_token = full_bytes_per_token * full_layers
draft_bytes_per_token = (
full_bytes_per_token * (draft_layers - draft_swa_layers)
+ swa_bytes_per_token * draft_swa_layers
)
def allocation_bytes(full_tokens, *, include_reserved_draft_page=True):
swa_tokens = int(full_tokens * ratio) // page_size * page_size
target_bytes = (
full_tokens * target_full_bytes_per_token
+ swa_tokens * swa_bytes_per_token * swa_layers
)
virtual_span = max(target_bytes // target_full_bytes_per_token - 1, 0)
draft_tokens = (virtual_span + page_size - 1) // page_size * page_size
if include_reserved_draft_page:
draft_tokens += page_size
return target_bytes + draft_tokens * draft_bytes_per_token
expected_full_tokens = 10 * page_size
available = allocation_bytes(
expected_full_tokens + page_size,
include_reserved_draft_page=False,
)
with mock_cpu_env():
from sglang.srt.model_executor.pool_configurator import (
create_memory_pool_configurator,
)
cfg = create_memory_pool_configurator(mr)
config = cfg.calculate_pool_sizes(available, page_size)
full_tokens = config.full_max_total_num_tokens
self.assertEqual(full_tokens % page_size, 0)
self.assertEqual(full_tokens, expected_full_tokens)
self.assertLessEqual(allocation_bytes(full_tokens), available)
self.assertGreater(allocation_bytes(full_tokens + page_size), available)
@patch(
"sglang.srt.mem_cache.kv_cache_configurator.calculate_mla_kv_cache_dim",
return_value=576,