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
@@ -1808,6 +1808,9 @@ def post_capture_kv_sizing_planned(server_args: Any) -> bool:
mla_enabled = use_mla_backend(server_args)
if not envs.SGLANG_ENABLE_POST_CAPTURE_KV_SIZING.get():
return False
# Unified arenas are fully backed before capture and cannot resize afterward.
if cfg.enable_unified_memory:
return False
if cfg.device != "cuda":
return False
if cfg.dcp_size != 1:
+52 -246
View File
@@ -50,9 +50,7 @@ from sglang.srt.mem_cache.allocator.hisparse import (
DeepSeekV4HiSparseTokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.allocator.swa import (
PureSWATokenToKVPoolAllocator,
SWATokenToKVPoolAllocator,
is_swa_req_ring,
)
from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedMambaSWATokenToKVPoolAllocator,
@@ -581,8 +579,6 @@ class PrefillAdder:
self.prefill_tile_block_m = prefill_tile_block_m
self.tree_cache = tree_cache
self.token_to_kv_pool_allocator = token_to_kv_pool_allocator
# Per-request SWA ring: one fixed slot per request, not a token budget.
self._swa_req_ring = is_swa_req_ring(token_to_kv_pool_allocator)
self.running_batch = running_batch
self.new_token_ratio = new_token_ratio
self.rem_input_tokens = rem_input_tokens - num_mixed_decode_tokens
@@ -595,8 +591,9 @@ class PrefillAdder:
if self.rem_chunk_tokens is not None:
self.rem_chunk_tokens -= num_mixed_decode_tokens
self.rem_total_token_offset = num_mixed_decode_tokens
self.cur_rem_token_offset = num_mixed_decode_tokens
self.memory_budget = token_to_kv_pool_allocator.create_prefill_budget(
tree_cache, num_mixed_decode_tokens=num_mixed_decode_tokens
)
self.req_states = None
self.can_run_list = []
@@ -612,7 +609,7 @@ class PrefillAdder:
if running_batch is not None:
# Estimate the offset in the remaining token space
self.rem_total_token_offset += sum(
self.memory_budget.total_offset += sum(
[
self._get_running_request_total_token_offset(r)
for r in running_batch.reqs
@@ -625,13 +622,7 @@ class PrefillAdder:
self.token_to_kv_pool_allocator,
(SWATokenToKVPoolAllocator, DeepSeekV4HiSparseTokenToKVPoolAllocator),
)
self.is_all_swa = isinstance(
self.token_to_kv_pool_allocator, PureSWATokenToKVPoolAllocator
)
self.is_hybrid_ssm_cache = self.tree_cache.supports_mamba()
self.rem_swa_token_offset = 0
# A new state slot eats shared-gap bytes that `rem_total_tokens` counts
# as free, so reserve per slot or admission over-commits. Gate on the
# ALLOCATOR, not `is_hybrid_ssm_cache`: that is False for `ChunkCache`,
@@ -718,114 +709,11 @@ class PrefillAdder:
@property
def rem_total_tokens(self):
if self.is_all_swa:
available_and_evictable = (
self.token_to_kv_pool_allocator.swa_available_size()
+ self.tree_cache.swa_evictable_size()
)
elif self.is_hybrid_swa:
available_and_evictable = (
self.token_to_kv_pool_allocator.full_available_size()
+ self.tree_cache.full_evictable_size()
)
elif self.is_hybrid_ssm_cache:
available_and_evictable = (
self.token_to_kv_pool_allocator.available_size()
+ self.tree_cache.full_evictable_size()
)
else:
available_and_evictable = (
self.token_to_kv_pool_allocator.available_size()
+ self.tree_cache.evictable_size()
)
return available_and_evictable - self.rem_total_token_offset
@property
def rem_swa_tokens(self):
allocator = self.token_to_kv_pool_allocator
if self._swa_req_ring:
# swa_available_size() already reports ring capacity; tree
# swa_evictable is in linear token units and frees no ring space.
return allocator.swa_available_size() - self.rem_swa_token_offset
return (
allocator.swa_available_size()
+ self.tree_cache.swa_evictable_size()
- self.rem_swa_token_offset
)
return self.memory_budget.remaining_total
@property
def cur_rem_tokens(self):
if self.is_all_swa:
available_and_evictable = (
self.token_to_kv_pool_allocator.swa_available_size()
+ self.tree_cache.swa_evictable_size()
)
elif self.is_hybrid_swa:
available_and_evictable = (
self.token_to_kv_pool_allocator.full_available_size()
+ self.tree_cache.full_evictable_size()
)
elif self.is_hybrid_ssm_cache:
available_and_evictable = (
self.token_to_kv_pool_allocator.available_size()
+ self.tree_cache.full_evictable_size()
)
else:
available_and_evictable = (
self.token_to_kv_pool_allocator.available_size()
+ self.tree_cache.evictable_size()
)
return available_and_evictable - self.cur_rem_token_offset
def _swa_budget_for_req(
self, extend_input_len: int, max_new_tokens: int, swa_host_hit_length: int = 0
) -> int:
"""SWA pool budget per request. Only valid when is_hybrid_swa is True.
With chunked prefill + overlap scheduler, the peak SWA occupancy is:
chunk N (running, not yet in tree) + sliding window (locked in tree)
+ chunk N+1 (new allocation)
Since chunk N and locked tokens are already excluded from
swa_available + swa_evictable, the budget only needs to cover the
chunk N+1 allocation plus decode headroom:
budget = max(alloc - window, 0) + min(extend + max_new_tokens, window) + page
where alloc = min(extend, rem_chunk); the min() cap keeps the two terms
from double-counting extend, so budget <= extend + max_new_tokens + page.
"""
allocator = self.token_to_kv_pool_allocator
if self._swa_req_ring:
# One ring slot per request, in the same unit as swa_available_size.
return allocator.swa_ring_cost_tokens
if self.rem_chunk_tokens is not None:
alloc = min(extend_input_len, self.rem_chunk_tokens)
else:
alloc = extend_input_len
window = self.tree_cache.sliding_window_size
return max(alloc - window, 0) + self._swa_reserved_tokens(
extend_input_len, max_new_tokens, swa_host_hit_length
)
def _swa_reserved_tokens(
self, extend_input_len: int, max_new_tokens: int, swa_host_hit_length: int = 0
) -> int:
"""SWA slots a request adds to its own sliding window + page slack + the
load-back charge. Shared floor of _swa_budget_for_req and _swa_chunk_cap.
The headroom is min(extend + decode, window), not a constant window: a
request contributes only extend + decode fresh tokens to its window and
a cached SWA prefix funds the rest. Charging a full window double-counted
a short cached-prefix resume and livelocked admission at a ~2-window
pool; keeping extend in the min() holds the reservation >= the prefill
allocation so an admitted request cannot OOM."""
window = self.tree_cache.sliding_window_size
headroom = min(extend_input_len + max_new_tokens, window)
reserved = headroom + self.page_size
if swa_host_hit_length > 0:
reserved += self.ceil_paged_tokens(swa_host_hit_length)
return reserved
return self.memory_budget.remaining_current
def _swa_new_tokens(self, req: Req) -> int:
"""Tokens a request may still decode, for SWA headroom sizing. Mirrors
@@ -837,80 +725,22 @@ class PrefillAdder:
CLIP_MAX_NEW_TOKENS,
)
def _swa_chunk_cap(self, max_new_tokens: int, swa_host_hit_length: int = 0) -> int:
"""Largest page-aligned extend chunk the SWA pool can admit right now,
keeping a sliding window of headroom below rem_swa_tokens; 0 if not
even one page fits. Only valid when is_hybrid_swa is True.
Escape hatch for a request whose budget can never pass the
_swa_budget_for_req gate (extend near/above the pool size, or a large
load-back charge): without shrinking its chunk it would be rejected
forever (head-of-line livelock). Shrinking is sound because past a
chunk boundary only the sliding window stays locked — the rest turns
evictable — so each pass's transient footprint fits the pool."""
# extend_input_len=0: this solves for the extend chunk itself, so the
# reserved headroom is the post-chunk decode window only.
cap = int(self.rem_swa_tokens) - self._swa_reserved_tokens(
0, max_new_tokens, swa_host_hit_length
)
if cap <= 0:
return 0
return cap // self.page_size * self.page_size
def _swa_req_never_fits(
self, extend_input_len: int, max_new_tokens: int, swa_host_hit_length: int = 0
) -> bool:
"""True when a request's SWA budget exceeds the *entire* SWA pool, so it
can never be admitted whole no matter how far the pool drains.
This is the head-of-line livelock the _swa_chunk_cap escape hatch exists
for; the hatch must fire only in this case. A request that merely
exceeds *current* rem_swa (transient pressure) would fit once running
decodes free their windows, so it must wait — admitting it into the
decode headroom collapses the SWA evictable cushion and forces running
requests to retract (observed as a severe retraction/re-prefill storm on
hybrid-SWA models at high concurrency)."""
capacity = self.token_to_kv_pool_allocator.size_swa
return (
self._swa_budget_for_req(
extend_input_len, max_new_tokens, swa_host_hit_length
)
>= capacity
)
def _swa_admission_gate(
def _check_prefill_budget(
self,
req: Req,
*,
extend_input_len: int,
total_tokens: int,
swa_host_hit_length: int,
chunk_tokens_limit: Optional[int],
) -> tuple[Optional[AddReqResult], Optional[int]]:
"""SWA-pool gate: a non-None verdict rejects; otherwise the returned chunk
limit stands, tightened to the pool cap when never-fits fires."""
max_new_tokens = self._swa_new_tokens(req)
swa_needed = self._swa_budget_for_req(
extend_input_len, max_new_tokens, swa_host_hit_length=swa_host_hit_length
) -> tuple[bool, Optional[int]]:
return self.memory_budget.check_prefill(
extend_input_len=extend_input_len,
total_tokens=total_tokens,
max_new_tokens=self._swa_new_tokens(req),
input_tokens=len(req.full_untruncated_fill_ids),
swa_host_hit_length=swa_host_hit_length,
chunk_limit=self.rem_chunk_tokens,
)
# Ring-slot capacity is exact, so needing exactly what is left still
# fits; the legacy SWA-token path keeps its conservative `>=`.
fits = (
swa_needed <= self.rem_swa_tokens
if self._swa_req_ring
else swa_needed < self.rem_swa_tokens
)
if fits:
return None, chunk_tokens_limit
if not self._swa_req_never_fits(
extend_input_len, max_new_tokens, swa_host_hit_length
):
return AddReqResult.NO_TOKEN, chunk_tokens_limit
swa_cap = self._swa_chunk_cap(max_new_tokens, swa_host_hit_length)
if self.rem_chunk_tokens is None or swa_cap <= 0:
return AddReqResult.NO_TOKEN, chunk_tokens_limit
current = (
self.rem_chunk_tokens if chunk_tokens_limit is None else chunk_tokens_limit
)
return None, min(current, swa_cap)
def _mamba_gap_budget_for_req(self, req: Req) -> int:
"""Shared-gap reservation (full-token-equivalents) for a request's new
@@ -931,9 +761,7 @@ class PrefillAdder:
return -(-tokens // self.page_size) * self.page_size
def budget_state(self):
no_token = self.rem_total_tokens <= 0 or self.cur_rem_tokens <= 0
if not no_token and self.is_hybrid_swa:
no_token = self.rem_swa_tokens <= 0
no_token = not self.memory_budget.has_capacity()
# Gate new mamba slots separately: rem_total_tokens' full_evictable can't
# cover a mamba slot, which needs mamba-recoverable bytes (see __init__).
if not no_token and self.rem_mamba_slots is not None:
@@ -980,17 +808,12 @@ class PrefillAdder:
if compute_charge is None:
compute_charge = extend_input_len
# alloc_extend reserves an extra page_size per request to make sure the budget doesn't over-commit
page_overhead = self.page_size
# `mamba_gap_reserve` (shared Mamba pool only; 0 otherwise) charges the new
# mamba state's shared-gap cost to BOTH full budgets: the slot is allocated
# immediately (counts against `cur_rem`) and held for the request lifetime
# (counts against `rem_total`). See `_mamba_gap_budget_for_req`.
self.rem_total_token_offset += (
extend_input_len + max_new_tokens + page_overhead + mamba_gap_reserve
)
self.cur_rem_token_offset += (
extend_input_len + page_overhead + mamba_gap_reserve
self.memory_budget.reserve(
extend_input_len,
max_new_tokens,
extra_tokens=mamba_gap_reserve,
chunk_limit=self.rem_chunk_tokens,
is_chunked_continuation=is_chunked_continuation,
)
# The new mamba slot also consumes one mamba-recoverable slot (gated
# separately so full_evictable can't cover it — see __init__).
@@ -998,14 +821,6 @@ class PrefillAdder:
self.rem_mamba_slots -= 1
self.rem_input_tokens -= compute_charge
if self.is_hybrid_swa:
# The ring slot is reserved once at first admission; charging it
# again on a continuation would double-count and over-throttle.
if not (self._swa_req_ring and is_chunked_continuation):
self.rem_swa_token_offset += self._swa_budget_for_req(
extend_input_len, max_new_tokens
)
if self.dllm_config is not None:
self.rem_dllm_tokens -= compute_charge
elif self.rem_chunk_tokens is not None:
@@ -1136,20 +951,11 @@ class PrefillAdder:
if self.dllm_config is not None:
_rem_tokens = self._get_dllm_remain_tokens()
else:
_rem_tokens = min(self.rem_chunk_tokens, int(self.rem_total_tokens))
if self.is_hybrid_swa and not self._swa_req_ring:
# alloc_extend needs extend_num_tokens + page_size per request,
# so reserve one page here to avoid OOM.
# Ring mode skips it: rem_swa_tokens counts slots, not chunk tokens.
_rem_tokens = min(
_rem_tokens, int(self.rem_swa_tokens) - self.page_size
)
# The chunked_req must be added to the list; otherwise, it will cause a memory leak.
# Therefore, in certain cases where _rem_tokens <= 0, it should be replaced with rem_chunk_tokens.
if _rem_tokens <= 0:
if self.is_hybrid_swa:
return req
_rem_tokens = self.rem_chunk_tokens
_rem_tokens = self.memory_budget.available_chunk_tokens(
self.rem_chunk_tokens
)
if _rem_tokens is None:
return req
# A mid-chunk rank prefills this pass regardless of the delayer
# verdict, so report prefillable=True and ignore the result.
@@ -1165,6 +971,13 @@ class PrefillAdder:
cand_extend_input_len = len(req.full_untruncated_fill_ids) - len(
req.prefix_indices
)
_rem_tokens = self.memory_budget.fit_chunk(
extend_input_len=cand_extend_input_len,
max_new_tokens=self._swa_new_tokens(req),
chunk_limit=_rem_tokens,
)
if _rem_tokens is None:
return req
truncated = cand_extend_input_len > _rem_tokens
new_len = min(cand_extend_input_len, _rem_tokens)
req.set_extend_range(len(req.prefix_indices), len(req.prefix_indices) + new_len)
@@ -1210,16 +1023,14 @@ class PrefillAdder:
# Shared Mamba pool: fold the new mamba state's shared-gap cost into the
# budget gate so admission can't over-commit (0 for baseline / non-Mamba).
paged_input += self._mamba_gap_budget_for_req(req)
if paged_input > min(self.cur_rem_tokens, self.rem_total_tokens):
fits = self.memory_budget.can_allocate_prefill(
paged_input=paged_input,
extend_input_len=cand_extend_input_len,
max_new_tokens=self._swa_new_tokens(req),
chunk_limit=self.rem_chunk_tokens,
)
if not fits:
return AddReqResult.NO_TOKEN
if self.is_hybrid_swa:
if (
self._swa_budget_for_req(
cand_extend_input_len, self._swa_new_tokens(req)
)
> self.rem_swa_tokens
):
return AddReqResult.NO_TOKEN
def add_req_state(r, insert_sort=False):
new_token_ratio = (
@@ -1369,9 +1180,6 @@ class PrefillAdder:
mamba_gap_reserve = self._mamba_gap_budget_for_req(req)
total_tokens += mamba_gap_reserve
if total_tokens >= self.rem_total_tokens:
return AddReqResult.NO_TOKEN
# The temporary pin excludes this prefix from the evictable budget.
# Selection itself neither allocates slots nor materializes host hits.
with self._lock_node(req.last_node):
@@ -1466,9 +1274,6 @@ class PrefillAdder:
truncation_align_size: Optional[int],
) -> _PrefillAdmission | AddReqResult:
"""Select a prefill shape without allocating or publishing cached KV."""
if total_tokens >= self.rem_total_tokens:
return AddReqResult.NO_TOKEN
prefix_len = len(req.prefix_indices) + host_hit_length
extend_len = len(req.full_untruncated_fill_ids) - prefix_len
input_tokens = self.ceil_paged_tokens(extend_len)
@@ -1476,13 +1281,14 @@ class PrefillAdder:
# exact-chunk-fill, so a request whose ceiled length would spill is
# not needlessly split into a second chunk.
chunk_fit_tokens = extend_len if self.exact_chunk_fill else input_tokens
chunk_tokens_limit = self.rem_chunk_tokens
if self.is_hybrid_swa:
verdict, chunk_tokens_limit = self._swa_admission_gate(
req, input_tokens, swa_host_hit_length, chunk_tokens_limit
)
if verdict is not None:
return verdict
can_admit, chunk_tokens_limit = self._check_prefill_budget(
req,
extend_input_len=extend_len,
total_tokens=total_tokens,
swa_host_hit_length=swa_host_hit_length,
)
if not can_admit:
return AddReqResult.NO_TOKEN
# Without chunking, allow the first request even above the input cap.
if (
@@ -1619,7 +1425,7 @@ class PrefillAdder:
release_counter = 0
for i, running_req in enumerate(self.running_batch.reqs):
if running_req in preemptible_reqs:
self.rem_total_token_offset -= (
self.memory_budget.total_offset -= (
self._get_running_request_total_token_offset(running_req)
)
release_counter += 1
+16 -11
View File
@@ -2523,23 +2523,28 @@ class Scheduler(
)
max_new_tokens = min(max_new_tokens, self.max_new_tokens_limit)
# Keep this bound consistent with PrefillAdder's admission budget:
# ceil_page(input_len) + max_new_tokens + page_size must be strictly
# smaller than max_total_num_tokens. Otherwise a request can be accepted
# into the waiting queue but can never be scheduled, blocking the queue
# and eventually making health checks fail.
paged_input_len = -(-input_len // self.page_size) * self.page_size
req.sampling_params.max_new_tokens = max(
# Keep this bound consistent with PrefillAdder's admission budget.
max_new_tokens = max(
0,
min(
max_new_tokens,
self.max_req_len - input_len - 1,
self.max_total_num_tokens * get_parallel().attn_dcp_size
- paged_input_len
- self.page_size
- 1,
),
)
max_new_tokens = self.token_to_kv_pool_allocator.max_new_tokens_for_memory(
input_len,
max_new_tokens,
token_capacity=self.max_total_num_tokens * get_parallel().attn_dcp_size,
sliding_window_size=self.sliding_window_size,
chunk_size=self.chunked_prefill_size,
)
if max_new_tokens is None:
req.set_finish_with_abort(
f"Request prompt exceeds the KV memory budget: input_len={input_len}."
)
max_new_tokens = 0
req.sampling_params.max_new_tokens = max(0, max_new_tokens)
# Clipping above can push max_new_tokens below min_new_tokens, which
# would suppress EOS for the whole generation. Restore the invariant.
if req.sampling_params.min_new_tokens > req.sampling_params.max_new_tokens:
@@ -22,9 +22,6 @@ from sglang.srt.managers.scheduler_components.pool_stats_observer import (
)
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.allocator.swa import is_swa_req_ring
from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedMambaSWATokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.observability.scheduler_stage_metrics import (
@@ -94,12 +91,13 @@ class SchedulerInvariantChecker:
return leak, msg
def _check_full_pool(self, ps: PoolStats, uncached: int = 0) -> Tuple[bool, str]:
if self.is_hybrid_swa and not self.full_tokens_per_layer:
allocator = self.token_to_kv_pool_allocator
if self.is_hybrid_swa and not ps.full_capacity:
return False, ""
if self.is_hybrid_swa:
protected = self.tree_cache.full_protected_size()
session_held = self.pool_stats_observer.session_held_full_tokens()
total = self.full_tokens_per_layer
total = ps.full_capacity
elif self.is_hybrid_ssm:
# Branch on cache type for the protected accessor (MambaRadixCache
# splits full/mamba; ChunkCache only has the single protected_size).
@@ -119,7 +117,6 @@ class SchedulerInvariantChecker:
session_held = self.pool_stats_observer.session_held_tokens()
total = self.max_total_num_tokens
full_evictable_size = ps.full_evictable_size
allocator = self.token_to_kv_pool_allocator
if get_parallel().dcp_enabled and allocator.page_size > 1:
# DCP stores logical tokens in widened physical pages. Prefix cache
# counters are logical-token based, while the allocator frees whole
@@ -129,14 +126,9 @@ class SchedulerInvariantChecker:
// allocator.page_size
* allocator.page_size
)
full_available = ps.full_available_size
if isinstance(allocator, UnifiedMambaSWATokenToKVPoolAllocator):
# Pair the static per-layer total with the conserve view, never the
# byte-coordinated one -- see `conserve_full_available_size`.
full_available = allocator.conserve_full_available_size()
leak, msg = self._check_pool_invariant(
"full",
full_available,
ps.full_available_size,
full_evictable_size,
protected,
session_held,
@@ -162,18 +154,13 @@ class SchedulerInvariantChecker:
f"evictable={ps.swa_evictable_size}, "
f"total={self.swa_tokens_per_layer}"
)
swa_available = ps.swa_available_size
if isinstance(allocator, UnifiedMambaSWATokenToKVPoolAllocator):
# Tri-pool: same floating-boundary phantom as the full pool -- use the
# slot-conservation view, not the byte-coordinated min (see _check_full_pool).
swa_available = allocator.conserve_swa_available_size()
return self._check_pool_invariant(
"swa",
swa_available,
ps.swa_available_size,
ps.swa_evictable_size,
self.tree_cache.swa_protected_size(),
self.pool_stats_observer.session_held_swa_tokens(),
self.swa_tokens_per_layer,
ps.swa_capacity,
uncached,
)
@@ -12,9 +12,6 @@ from typing import (
)
from sglang.srt.mem_cache.allocator.swa import is_swa_req_ring
from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedMambaSWATokenToKVPoolAllocator,
)
if TYPE_CHECKING:
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
@@ -38,6 +35,8 @@ class PoolStats:
is_hisparse: bool = False
# For hybrid-swa pools
full_capacity: Optional[int] = None
swa_capacity: Optional[int] = None
swa_num_used: Optional[int] = None
swa_token_usage: Optional[float] = None
swa_available_size: Optional[int] = None
@@ -289,29 +288,20 @@ class SchedulerPoolStatsObserver:
)
def _get_swa_token_info(self) -> PoolStats:
# `*_num_used` is `static_cap - (available + evictable)`, so the
# available term must match the static cap's denomination: the conserve
# view, never the byte-coordinated one (see
# `conserve_full_available_size`). Measured ~25-90x inflated otherwise.
allocator = self.token_to_kv_pool_allocator
if isinstance(allocator, UnifiedMambaSWATokenToKVPoolAllocator):
full_available_size = allocator.conserve_full_available_size()
swa_available_size = allocator.conserve_swa_available_size()
else:
full_available_size = allocator.full_available_size()
swa_available_size = allocator.swa_available_size()
(full_capacity, full_available_size), (swa_capacity, swa_available_size) = (
self.token_to_kv_pool_allocator.swa_capacity_and_available(
full_capacity=self.full_tokens_per_layer,
swa_capacity=self.swa_tokens_per_layer,
)
)
full_evictable_size = self.tree_cache.full_evictable_size()
swa_evictable_size = self.tree_cache.swa_evictable_size()
# Per-request SWA ring: released with the req slot, yet cached radix
# prefixes still report swa_evictable; counting it drives usage negative.
if is_swa_req_ring(self.token_to_kv_pool_allocator):
swa_evictable_size = 0
full_num_used = self.full_tokens_per_layer - (
full_available_size + full_evictable_size
)
swa_num_used = self.swa_tokens_per_layer - (
swa_available_size + swa_evictable_size
)
full_num_used = full_capacity - (full_available_size + full_evictable_size)
swa_num_used = swa_capacity - (swa_available_size + swa_evictable_size)
# FIXME(hisparse): host-backup transiently over-releases the device pool
# counter, producing negative full_num_used / swa_num_used. We clamp to 0
# to keep token_usage / leak checks sane, but the underlying accounting
@@ -319,16 +309,23 @@ class SchedulerPoolStatsObserver:
if self.enable_hisparse:
full_num_used = max(0, full_num_used)
swa_num_used = max(0, swa_num_used)
if not self.full_tokens_per_layer:
if not full_capacity:
full_num_used = 0
full_available_size = 0
full_token_usage = 0.0
else:
full_token_usage = full_num_used / self.full_tokens_per_layer
swa_token_usage = swa_num_used / self.swa_tokens_per_layer
full_token_usage = full_num_used / full_capacity
if not swa_capacity:
swa_num_used = 0
swa_available_size = 0
swa_token_usage = 0.0
else:
swa_token_usage = swa_num_used / swa_capacity
return PoolStats(
is_hybrid_swa=True,
full_capacity=full_capacity,
swa_capacity=swa_capacity,
full_num_used=full_num_used,
full_token_usage=full_token_usage,
full_available_size=full_available_size,
+40 -5
View File
@@ -69,12 +69,47 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
# The scheduler calls these unconditionally, with no allocator-type branches
# on its side; byte-accounted composites override the token-count defaults.
def evict_to_free_tokens(self, tree_cache, num_tokens: int) -> None:
"""Evict unlocked prefix-cache entries until this allocator can serve
``num_tokens`` or nothing evictable remains."""
from sglang.srt.mem_cache.common import evict_from_tree_cache
def create_prefill_budget(self, tree_cache, *, num_mixed_decode_tokens=0):
from sglang.srt.mem_cache.prefill_budget import PrefillBudget
evict_from_tree_cache(tree_cache, num_tokens)
return PrefillBudget(
self, tree_cache, num_mixed_decode_tokens=num_mixed_decode_tokens
)
def max_new_tokens_for_memory(
self,
input_tokens: int,
max_new_tokens: int,
*,
token_capacity: int,
sliding_window_size: int | None,
chunk_size: int | None,
) -> int | None:
"""Clip generation to the empty-pool budget; None means prompt cannot fit.
token_capacity is the scheduler's configured capacity, including its
distributed token scaling. Shared pools use their physical byte layout.
"""
paged_input = -(-input_tokens // self.page_size) * self.page_size
return max(
0, min(max_new_tokens, token_capacity - paged_input - self.page_size - 1)
)
def evict_to_free_tokens(self, tree_cache, num_tokens: int) -> bool | None:
"""Evict unlocked prefix-cache entries until this allocator can serve
``num_tokens`` or nothing evictable remains.
Return whether capacity was realized, or None if it still needs checking.
"""
from sglang.srt.mem_cache.base_prefix_cache import EvictParams
from sglang.srt.mem_cache.common import _evict_until_allocatable
if tree_cache is None or tree_cache.is_chunk_cache():
return
shortfall = num_tokens - self.available_size()
if shortfall > 0:
tree_cache.evict_for_alloc(EvictParams(num_tokens=shortfall))
_evict_until_allocatable(tree_cache, self, num_tokens)
def check_decode_capacity(
self,
@@ -357,6 +357,20 @@ class DeepSeekV4HiSparseTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
kv_indices
)
def create_prefill_budget(self, tree_cache, *, num_mixed_decode_tokens=0):
# Use this wrapper's capacity, which also includes the compressed pool.
from sglang.srt.mem_cache.prefill_budget import SWAPrefillBudget
return SWAPrefillBudget(
self, tree_cache, num_mixed_decode_tokens=num_mixed_decode_tokens
)
def swa_capacity_and_available(self, *, full_capacity, swa_capacity):
return (
(full_capacity, self.full_available_size()),
(swa_capacity, self.swa_available_size()),
)
def full_available_size(self):
return min(
self.logical_attn_allocator.full_available_size(),
@@ -158,6 +158,31 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self.swa_attn_allocator.available_size(),
)
def create_prefill_budget(self, tree_cache, *, num_mixed_decode_tokens=0):
from sglang.srt.mem_cache.prefill_budget import SWAPrefillBudget
return SWAPrefillBudget(
self, tree_cache, num_mixed_decode_tokens=num_mixed_decode_tokens
)
def swa_capacity_and_available(self, *, full_capacity, swa_capacity):
return (
(full_capacity, self.full_available_size()),
(swa_capacity, self.swa_available_size()),
)
def evict_to_free_tokens(self, tree_cache, num_tokens: int) -> None:
from sglang.srt.mem_cache.base_prefix_cache import EvictParams
if tree_cache is None or tree_cache.is_chunk_cache():
return
full_shortfall = max(0, num_tokens - self.full_available_size())
swa_shortfall = max(0, num_tokens - self.swa_available_size())
if full_shortfall or swa_shortfall:
tree_cache.evict_for_alloc(
EvictParams(num_tokens=full_shortfall, swa_num_tokens=swa_shortfall)
)
def full_available_size(self):
return self.full_attn_allocator.available_size()
@@ -687,6 +712,16 @@ class PureSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
def available_size(self):
return self.swa_attn_allocator.available_size()
def create_prefill_budget(self, tree_cache, *, num_mixed_decode_tokens=0):
from sglang.srt.mem_cache.prefill_budget import SWAPrefillBudget
return SWAPrefillBudget(
self,
tree_cache,
num_mixed_decode_tokens=num_mixed_decode_tokens,
all_swa=True,
)
def full_available_size(self):
return self.swa_attn_allocator.available_size()
@@ -17,6 +17,8 @@ sub-pools of one `UnifiedKVPool`, and the tri-pool variant that adds mamba state
from __future__ import annotations
import logging
import math
from abc import abstractmethod
from typing import Callable, List, Optional, Sequence, Tuple
import torch
@@ -42,11 +44,10 @@ from sglang.srt.utils.common import get_num_new_pages
logger = logging.getLogger(__name__)
class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
"""Composite allocator for the hybrid SWA pair (full + swa MHA sub-pools).
class UnifiedSWAAllocatorBase(SWATokenToKVPoolAllocator):
"""Shared FULL/SWA virtual IDs, allocation lifecycle, and index translation.
One alloc(N) binds N pages on BOTH sides under the same virtual id, so
`available_size()` (joint bytes, in TOKENS) is the only safe alloc pre-check.
Concrete allocators define the two-ended or Mamba/SWA/FULL capacity policy.
"""
# Parent's `size` property has no setter but base init does `self.size = size`;
@@ -65,26 +66,36 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
unified_buffer: UnifiedKVPool,
kvcache, # UnifiedSWAKVPool
device: str,
full_max_total_num_tokens: int,
swa_max_total_num_tokens: int,
full_max_total_num_tokens: Optional[int] = None,
swa_max_total_num_tokens: Optional[int] = None,
page_size: int = 1,
need_sort: bool = False,
forward_stream: Optional[torch.cuda.Stream] = None,
lazy_compaction: bool = False,
):
# Set _size_full / _size_swa BEFORE base init (read during it). STATIC
# partition caps -- the slot-conservation value the leak invariant expects.
self._size_full = full_max_total_num_tokens
self._size_swa = swa_max_total_num_tokens
self._full_max_total_num_tokens = full_max_total_num_tokens
self._swa_max_total_num_tokens = swa_max_total_num_tokens
if (full_max_total_num_tokens is None) != (swa_max_total_num_tokens is None):
raise ValueError(
"full_max_total_num_tokens and swa_max_total_num_tokens must "
"either both be set or both be omitted"
)
legacy_capacities = full_max_total_num_tokens is not None
self._size_full = (
int(full_max_total_num_tokens)
if legacy_capacities
else unified_buffer.max_slots("full") - 1
)
self._size_swa = (
int(swa_max_total_num_tokens)
if legacy_capacities
else unified_buffer.max_slots("swa") - 1
)
self.page_size = page_size
# The parent is inherited only for the isinstance contract: skip its
# static-partition sub-pool allocation, which the unified pool replaces.
BaseTokenToKVPoolAllocator.__init__(
self,
size=full_max_total_num_tokens,
size=self._size_full,
page_size=page_size,
dtype=unified_buffer.mha_spec("full").store_dtype,
device=device,
@@ -120,6 +131,16 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
)
self._wire_peers()
self._empty_shared_gap_bytes = self.full_attn_allocator._current_gap_bytes()
if not legacy_capacities:
self._size_full = self.full_attn_allocator.available_size()
self._size_swa = min(
self.swa_attn_allocator.available_size(),
len(self.full_attn_allocator.free_virtual_ids) * page_size,
)
self._full_max_total_num_tokens = self._size_full
self._swa_max_total_num_tokens = self._size_swa
# Epoch-keyed memo for the joint capacity view (any chain member's
# mutation invalidates -- see `MultiEndedAllocator._chain_capacity_epoch`).
self._joint_avail_memo_epoch: Optional[int] = None
@@ -143,7 +164,7 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
"[unified-memory-pool] UnifiedSWATokenToKVPoolAllocator ready: "
"full max_slots=%d (min_slot_index=%d, entry_bytes=%d), "
"swa max_slots=%d (min_slot_index=%d, entry_bytes=%d), "
"static caps full=%d swa=%d, joint available=%d",
"max capacity full=%d swa=%d, joint available=%d",
self.full_attn_allocator.max_slots,
self.full_attn_allocator.min_slot_index,
self.full_attn_allocator.entry_bytes,
@@ -157,18 +178,6 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
# -- construction hooks (the tri-pool subclass overrides both) --
def _build_swa_attn_allocator(self, **kwargs) -> MultiEndedAllocator:
"""The swa sub-allocator: an END pool in the 2-pool pair."""
return MultiEndedAllocator(
sub_pool_name="swa",
is_id_owner=False, # non-owner; consumes virtuals minted by full
**kwargs,
)
def _wire_peers(self) -> None:
self.full_attn_allocator.bind_peer(self.swa_attn_allocator)
self.swa_attn_allocator.bind_peer(self.full_attn_allocator)
# -- capacity reporting (three-way split) --
def available_size(self) -> int:
@@ -179,46 +188,6 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
self._joint_avail_memo_epoch = epoch
return self._joint_avail_memo_tokens
def _compute_available_size(self) -> int:
"""Joint byte budget in TOKENS: each composite alloc(1) consumes one
full-side AND one swa-side page under the same virtual id."""
fa, sa = self.full_attn_allocator, self.swa_attn_allocator
e_f = fa.entry_bytes_per_page
e_s = sa.entry_bytes_per_page
# Direction-agnostic shared gap: the free byte band between the two pools.
if fa.grow_direction == "up":
gap_bytes = max(0, sa._byte_low_frontier() - fa._byte_high_frontier())
else:
gap_bytes = max(0, fa._byte_low_frontier() - sa._byte_high_frontier())
R_f = fa.num_pages - fa.min_page_index - fa._allocated_pages()
R_s = sa.num_pages - sa.min_page_index - sa._allocated_pages()
if not self.lazy_compaction:
pages_by_bytes = gap_bytes // (e_f + e_s)
return min(pages_by_bytes, R_f, R_s) * self.page_size
H_f = len(fa._free_phys_pages)
H_s = len(sa._free_phys_pages)
K1 = min(H_f, H_s) # Phase 1: both drain
# Phase 2: fewer-holes side extends; more-holes side keeps draining.
if H_f <= H_s:
e_phase2 = e_f
K_phase2_max = H_s
else:
e_phase2 = e_s
K_phase2_max = H_f
K2_room = K_phase2_max - K1
K2 = min(K2_room, gap_bytes // e_phase2) if e_phase2 > 0 else K2_room
gap_bytes -= K2 * e_phase2
K3 = gap_bytes // (e_f + e_s) # Phase 3: both extend
K_total = K1 + K2 + K3
K_total = min(K_total, H_f + R_f, H_s + R_s) # index-space caps
return K_total * self.page_size
# Slot-conservation views for the leak invariant only; the byte-coordinated
# value would flag spurious leaks. `allocated_count()` is in TOKENS.
def _conserve_full_available_size(self) -> int:
@@ -261,17 +230,16 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
def schedulable_swa_available_size(self) -> int:
return self.swa_attn_allocator.schedulable_available_size()
def _flush_targets(self):
"""Flush ALL members, including ones that are not short themselves: a
one-sided hole is unusable, and compacting it yields SHARED gap."""
return (self.full_attn_allocator, self.swa_attn_allocator)
# `size_full` / `size_swa` bound each side independently; current capacities
# also account for the peer's live byte usage.
def _ask_float_for_room(self, need_tokens: int) -> None:
"""No float in a two-END chain -- nothing can slide."""
return None
@property
def current_full_capacity(self) -> int:
return self.full_available_size() + self.full_attn_allocator.allocated_count()
# `size_full` / `size_swa` are inherited and read the static caps; reporting
# `max_slots - 1` here would be ~= full_max + swa_max and over-promise.
@property
def current_swa_capacity(self) -> int:
return self.swa_available_size() + self.swa_attn_allocator.allocated_count()
@property
def draft_virtual_id_space(self) -> int:
@@ -396,11 +364,8 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
def alloc(self, need_size: int) -> Optional[torch.Tensor]:
with record_function("UnifiedSWAAlloc.alloc"):
# Joint pre-check. Both sides are mutual peers (each side's compaction
# opens gap for the other), so flush BOTH on shortfall.
if need_size > self.available_size():
if not _relieve_for_alloc(self, need_size):
return None
if not self.ensure_capacity(need_size, need_size):
return None
# Snapshot the virtual PAGES full will consume, to bind them on swa too.
num_pages = need_size // self.page_size
fa = self.full_attn_allocator
@@ -437,9 +402,8 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
prefix_lens=prefix_lens_cpu,
)
need_tokens = num_new_pages * self.page_size
if need_tokens > self.available_size():
if not _relieve_for_alloc(self, need_tokens):
return None
if not self.ensure_capacity(need_tokens, need_tokens):
return None
# Snapshot the virtual PAGES the kernel will consume; clone so swa keeps
# its view after the slice is consumed.
@@ -555,9 +519,8 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
seq_lens=seq_lens_cpu, page_size=self.page_size, decode=True
)
need_tokens = num_new_pages * self.page_size
if need_tokens > self.available_size():
if not _relieve_for_alloc(self, need_tokens):
return None
if not self.ensure_capacity(need_tokens, need_tokens):
return None
fa = self.full_attn_allocator
new_virtual_pages = fa.free_virtual_ids[:num_new_pages].clone()
@@ -727,14 +690,6 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
self.full_attn_allocator.clear_inverse_history()
self.swa_attn_allocator.clear_inverse_history()
def verify_byte_accounting(self) -> List[str]:
return (
_chain_byte_accounting_violations(
_end_pair_chain(self.full_attn_allocator, self.swa_attn_allocator)
)
+ self._joint_capacity_memo_violations()
)
def _joint_capacity_memo_violations(self) -> List[str]:
"""Idle-time twin of `MultiEndedAllocator._capacity_memo_violations`
for the composite joint view. Empty == healthy."""
@@ -781,6 +736,385 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
forward_done, out_cache_loc_virtual
)
@abstractmethod
def _build_swa_attn_allocator(self, **kwargs) -> MultiEndedAllocator: ...
@abstractmethod
def _wire_peers(self) -> None: ...
@abstractmethod
def _compute_available_size(self) -> int: ...
@abstractmethod
def ensure_capacity(self, full_tokens: int, swa_tokens: int) -> bool: ...
@abstractmethod
def _flush_targets(self): ...
@abstractmethod
def _ask_float_for_room(self, need_tokens: int) -> None: ...
class UnifiedSWATokenToKVPoolAllocator(UnifiedSWAAllocatorBase):
"""Two-ended FULL/SWA allocator with asymmetric shared-byte reservations."""
def _build_swa_attn_allocator(self, **kwargs) -> MultiEndedAllocator:
"""The swa sub-allocator: an END pool in the 2-pool pair."""
return MultiEndedAllocator(
sub_pool_name="swa",
is_id_owner=False, # non-owner; consumes virtuals minted by full
**kwargs,
)
def _wire_peers(self) -> None:
self.full_attn_allocator.bind_peer(self.swa_attn_allocator)
self.swa_attn_allocator.bind_peer(self.full_attn_allocator)
def _compute_available_size(self) -> int:
"""Joint byte budget in TOKENS: each composite alloc(1) consumes one
full-side AND one swa-side page under the same virtual id."""
fa, sa = self.full_attn_allocator, self.swa_attn_allocator
e_f = fa.entry_bytes_per_page
e_s = sa.entry_bytes_per_page
# Direction-agnostic shared gap: the free byte band between the two pools.
if fa.grow_direction == "up":
gap_bytes = max(0, sa._byte_low_frontier() - fa._byte_high_frontier())
else:
gap_bytes = max(0, fa._byte_low_frontier() - sa._byte_high_frontier())
R_f = fa.num_pages - fa.min_page_index - fa._allocated_pages()
R_s = sa.num_pages - sa.min_page_index - sa._allocated_pages()
if not self.lazy_compaction:
pages_by_bytes = gap_bytes // (e_f + e_s)
return min(pages_by_bytes, R_f, R_s) * self.page_size
H_f = len(fa._free_phys_pages)
H_s = len(sa._free_phys_pages)
K1 = min(H_f, H_s) # Phase 1: both drain
# Phase 2: fewer-holes side extends; more-holes side keeps draining.
if H_f <= H_s:
e_phase2 = e_f
K_phase2_max = H_s
else:
e_phase2 = e_s
K_phase2_max = H_f
K2_room = K_phase2_max - K1
K2 = min(K2_room, gap_bytes // e_phase2) if e_phase2 > 0 else K2_room
gap_bytes -= K2 * e_phase2
K3 = gap_bytes // (e_f + e_s) # Phase 3: both extend
K_total = K1 + K2 + K3
K_total = min(K_total, H_f + R_f, H_s + R_s) # index-space caps
return K_total * self.page_size
def _flush_targets(self):
"""Flush ALL members, including ones that are not short themselves: a
one-sided hole is unusable, and compacting it yields SHARED gap."""
return (self.full_attn_allocator, self.swa_attn_allocator)
def _ask_float_for_room(self, need_tokens: int) -> None:
"""No float in a two-END chain -- nothing can slide."""
return None
def reclaim_plan(
self,
full_tokens: int | float,
swa_tokens: int | float,
*,
full_evictable_tokens: int = 0,
swa_evictable_tokens: int = 0,
empty_pool: bool = False,
) -> Optional[Tuple[int, int]]:
"""Return cumulative FULL/SWA eviction targets, or None if impossible.
The tree evicts FULL before SWA. Minimize required SWA reclaim with all
evictable FULL available, then trim excess FULL reclaim. FULL eviction's
actual SWA cascade is counted by the tree's shared eviction tracker.
"""
if full_tokens < 0 or swa_tokens < 0:
return None
page_size = self.page_size
full_pages = (math.ceil(full_tokens) + page_size - 1) // page_size
swa_pages = (math.ceil(swa_tokens) + page_size - 1) // page_size
# Restoring host SWA for device-resident FULL can require more new
# SWA pages than new FULL pages; only the shared budget constrains it.
compacted = empty_pool or not self.lazy_compaction or self._compaction_allowed()
def fits(full_reclaim_pages: int, swa_reclaim_pages: int) -> bool:
return self._fits_page_demand(
full_pages,
swa_pages,
full_reclaim_pages=full_reclaim_pages,
swa_reclaim_pages=swa_reclaim_pages,
compacted=compacted,
empty_pool=empty_pool,
)
if empty_pool:
return (0, 0) if fits(0, 0) else None
if fits(0, 0):
return (0, 0)
max_full_pages = min(
self.full_attn_allocator.allocated_count() // page_size,
max(0, int(full_evictable_tokens)) // page_size,
)
max_swa_pages = min(
self.swa_attn_allocator.allocated_count() // page_size,
max(0, int(swa_evictable_tokens)) // page_size,
)
if not fits(max_full_pages, max_swa_pages):
return None
def first_fit(high: int, predicate: Callable[[int], bool]) -> int:
low = 0
while low < high:
mid = (low + high) // 2
if predicate(mid):
high = mid
else:
low = mid + 1
return low
swa_reclaim_pages = first_fit(
max_swa_pages, lambda value: fits(max_full_pages, value)
)
full_reclaim_pages = first_fit(
max_full_pages, lambda value: fits(value, swa_reclaim_pages)
)
return (
full_reclaim_pages * page_size,
swa_reclaim_pages * page_size,
)
def can_reserve(
self,
full_tokens: int | float,
swa_tokens: int | float,
*,
full_evictable_tokens: int = 0,
swa_evictable_tokens: int = 0,
empty_pool: bool = False,
require_token_slack: bool = False,
) -> bool:
"""Check pending FULL/SWA demand against the shared byte envelope.
Scheduler admission keeps the historical one-token strict slack at an
empty-pool boundary. Live admission checks the state reachable after
reclaiming the currently evictable pages from both sides.
"""
if full_tokens < 0 or swa_tokens < 0:
return False
if require_token_slack and (
full_tokens >= self.size_full or swa_tokens >= self.size_swa
):
return False
return (
self.reclaim_plan(
full_tokens,
swa_tokens,
full_evictable_tokens=full_evictable_tokens,
swa_evictable_tokens=swa_evictable_tokens,
empty_pool=empty_pool,
)
is not None
)
def _compaction_allowed(self) -> bool:
return all(
allocator.disagg_move_gate is None or allocator.disagg_move_gate()
for allocator in (self.full_attn_allocator, self.swa_attn_allocator)
)
def _fits_page_demand(
self,
num_full_pages: int,
num_swa_pages: int,
*,
full_reclaim_pages: int = 0,
swa_reclaim_pages: int = 0,
compacted: bool,
empty_pool: bool = False,
) -> bool:
"""Check one FULL/SWA page demand against a single allocator snapshot."""
if min(num_full_pages, num_swa_pages) < 0:
return False
fa, sa = self.full_attn_allocator, self.swa_attn_allocator
if empty_pool:
full_live_pages = swa_live_pages = 0
full_reclaim_pages = swa_reclaim_pages = 0
else:
full_live_pages = fa.allocated_count() // self.page_size
swa_live_pages = sa.allocated_count() // self.page_size
full_reclaim_pages = min(full_live_pages, max(0, int(full_reclaim_pages)))
swa_reclaim_pages = min(swa_live_pages, max(0, int(swa_reclaim_pages)))
full_live_pages -= full_reclaim_pages
swa_live_pages -= swa_reclaim_pages
full_total_pages = full_live_pages + num_full_pages
swa_total_pages = swa_live_pages + num_swa_pages
virtual_page_capacity = fa.num_virtual_ids - fa.min_page_index
full_page_capacity = min(
virtual_page_capacity,
fa.num_pages - fa.min_page_index,
)
swa_page_capacity = min(
virtual_page_capacity,
sa.num_pages - sa.min_page_index,
)
if full_total_pages > full_page_capacity or swa_total_pages > swa_page_capacity:
return False
if compacted:
full_virtual_room = virtual_page_capacity - full_live_pages
full_holes = swa_holes = 0
full_index_room = full_page_capacity - full_live_pages
swa_index_room = swa_page_capacity - swa_live_pages
gap_bytes = (
self._empty_shared_gap_bytes
- full_live_pages * fa.entry_bytes_per_page
- swa_live_pages * sa.entry_bytes_per_page
)
else:
full_virtual_room = len(fa.free_virtual_ids) + full_reclaim_pages
full_holes = len(fa._free_phys_pages) + full_reclaim_pages
swa_holes = len(sa._free_phys_pages) + swa_reclaim_pages
full_index_room = fa.num_pages - fa.min_page_index - fa._allocated_pages()
swa_index_room = sa.num_pages - sa.min_page_index - sa._allocated_pages()
gap_bytes = fa._current_gap_bytes()
if num_full_pages > full_virtual_room:
return False
if num_full_pages > full_holes + full_index_room:
return False
if num_swa_pages > swa_holes + swa_index_room:
return False
full_extensions = max(0, num_full_pages - full_holes)
swa_extensions = max(0, num_swa_pages - swa_holes)
return (
full_extensions * fa.entry_bytes_per_page
+ swa_extensions * sa.entry_bytes_per_page
<= max(0, gap_bytes)
)
def ensure_capacity(self, full_tokens: int, swa_tokens: int) -> bool:
"""Gate one allocation and compact both sides on shortfall."""
if full_tokens < 0 or swa_tokens < 0:
return False
if full_tokens == 0 and swa_tokens == 0:
return True
page_size = self.page_size
num_full_pages = (int(full_tokens) + page_size - 1) // page_size
num_swa_pages = (int(swa_tokens) + page_size - 1) // page_size
if self._fits_page_demand(
num_full_pages,
num_swa_pages,
compacted=False,
):
return True
if not self.lazy_compaction or not self._compaction_allowed():
return False
if not self._fits_page_demand(
num_full_pages,
num_swa_pages,
compacted=True,
):
return False
self.full_attn_allocator.flush_for_allocation()
self.swa_attn_allocator.flush_for_allocation()
return self._fits_page_demand(
num_full_pages,
num_swa_pages,
compacted=False,
)
def create_prefill_budget(self, tree_cache, *, num_mixed_decode_tokens=0):
from sglang.srt.mem_cache.prefill_budget import SharedSWAPrefillBudget
return SharedSWAPrefillBudget(
self, tree_cache, num_mixed_decode_tokens=num_mixed_decode_tokens
)
def swa_capacity_and_available(self, *, full_capacity, swa_capacity):
return (
(self.current_full_capacity, self.full_available_size()),
(self.current_swa_capacity, self.swa_available_size()),
)
def max_new_tokens_for_memory(
self,
input_tokens: int,
max_new_tokens: int,
*,
token_capacity: int,
sliding_window_size: int | None,
chunk_size: int | None,
) -> int | None:
from sglang.srt.mem_cache.prefill_budget import estimate_swa_kv_tokens
def fits(candidate):
return self.can_reserve(
input_tokens + candidate + self.page_size,
estimate_swa_kv_tokens(
input_tokens,
candidate,
sliding_window_size=sliding_window_size,
page_size=self.page_size,
allocation_limit=chunk_size,
),
empty_pool=True,
require_token_slack=True,
)
if not fits(0):
return None
if fits(max_new_tokens):
return max_new_tokens
lo, hi = 0, max_new_tokens
while lo < hi:
mid = (lo + hi + 1) // 2
if fits(mid):
lo = mid
else:
hi = mid - 1
return lo
def evict_to_free_tokens(self, tree_cache, num_tokens: int) -> bool | None:
from sglang.srt.mem_cache.base_prefix_cache import EvictParams
if tree_cache is None or tree_cache.is_chunk_cache():
return
reclaim_plan = self.reclaim_plan(
num_tokens,
num_tokens,
full_evictable_tokens=tree_cache.full_evictable_size(),
swa_evictable_tokens=tree_cache.swa_evictable_size(),
)
if reclaim_plan is None:
return
full_reclaim, swa_reclaim = reclaim_plan
if full_reclaim or swa_reclaim:
tree_cache.evict_for_alloc(
EvictParams(num_tokens=full_reclaim, swa_num_tokens=swa_reclaim)
)
# A zero-reclaim plan can still depend on compaction before allocation.
return self.ensure_capacity(num_tokens, num_tokens)
def verify_byte_accounting(self) -> List[str]:
return (
_chain_byte_accounting_violations(
_end_pair_chain(self.full_attn_allocator, self.swa_attn_allocator)
)
+ self._joint_capacity_memo_violations()
)
def flush_opportunistic(self) -> int:
"""Non-urgent flush of BOTH sub-allocators; sync-free."""
with record_function("UnifiedSWAAlloc.flush_opportunistic"):
@@ -796,7 +1130,7 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
return fa.flush_opportunistic() + sa.flush_opportunistic()
class UnifiedMambaSWATokenToKVPoolAllocator(UnifiedSWATokenToKVPoolAllocator):
class UnifiedMambaSWATokenToKVPoolAllocator(UnifiedSWAAllocatorBase):
"""Tri-pool composite for models with full KV + SWA KV + mamba/conv state
(both `mambaish_config` and `is_hybrid_swa`).
@@ -887,6 +1221,37 @@ class UnifiedMambaSWATokenToKVPoolAllocator(UnifiedSWATokenToKVPoolAllocator):
# -- capacity --
def can_reserve(
self,
full_tokens: int | float,
swa_tokens: int | float,
*,
full_evictable_tokens: int = 0,
swa_evictable_tokens: int = 0,
empty_pool: bool = False,
require_token_slack: bool = False,
) -> bool:
if (
full_tokens < 0
or swa_tokens < 0
or full_tokens != swa_tokens
or full_evictable_tokens
or swa_evictable_tokens
or empty_pool
):
return False
return full_tokens <= self.available_size()
def ensure_capacity(self, full_tokens: int, swa_tokens: int) -> bool:
if full_tokens < 0 or swa_tokens < 0 or full_tokens != swa_tokens:
return False
if full_tokens == 0:
return True
need_tokens = int(full_tokens)
if need_tokens <= self.available_size():
return True
return _relieve_for_alloc(self, need_tokens)
def _compute_available_size(self) -> int:
"""Joint TOKENS for `alloc(N)`: N costs N full pages AND N swa pages, drawn
from DIFFERENT bands -- full extends only into the high band, the float into
@@ -1036,17 +1401,33 @@ class UnifiedMambaSWATokenToKVPoolAllocator(UnifiedSWATokenToKVPoolAllocator):
super().set_inflight_forward(forward_done, out_cache_loc_virtual)
self.mamba_allocator.set_inflight_forward(forward_done, None)
def create_prefill_budget(self, tree_cache, *, num_mixed_decode_tokens=0):
# Mamba competes for the shared gap too; retain the tri-pool's existing
# token and state-slot admission until it has a three-way reservation.
return SWATokenToKVPoolAllocator.create_prefill_budget(
self, tree_cache, num_mixed_decode_tokens=num_mixed_decode_tokens
)
def max_new_tokens_for_memory(self, *args, **kwargs):
return BaseTokenToKVPoolAllocator.max_new_tokens_for_memory(
self, *args, **kwargs
)
def swa_capacity_and_available(self, *, full_capacity, swa_capacity):
return (
(full_capacity, self.conserve_full_available_size()),
(swa_capacity, self.conserve_swa_available_size()),
)
def evict_to_free_tokens(self, tree_cache, num_tokens: int) -> None:
"""Joint-aware eviction: one tri-lifetime node frees bytes on several sides
at once, so re-check the JOINT gate instead of the per-side shortfall."""
from sglang.srt.mem_cache.common import evict_from_tree_cache
# Arbitrary retry bound; a round that frees nothing ends the loop anyway.
for _ in range(4):
before = self.available_size()
if before >= num_tokens:
return
evict_from_tree_cache(tree_cache, num_tokens)
SWATokenToKVPoolAllocator.evict_to_free_tokens(self, tree_cache, num_tokens)
if self.available_size() <= before:
return # no progress
@@ -345,9 +345,13 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
# v2p is indexed by VIRTUAL page id, p2v by PHYSICAL page id. A non-owner
# consumes the owner's ids, so the two counts are unrelated.
assert virtual_num_pages is None or not is_id_owner, (
"only a non-owner allocator may use another pool's virtual-id space"
)
self.num_virtual_ids = (
self.num_pages if virtual_num_pages is None else virtual_num_pages
)
assert self.num_virtual_ids > 0, "virtual page count must be positive"
# Page 0 is the padding anchor; the trailing row is the -1 sentinel.
self.virtual_to_physical = torch.full(
(self.num_virtual_ids + 1,),
@@ -554,6 +558,7 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
f"is_id_owner={self.is_id_owner}, page_size={self.page_size}, "
f"min_page_index={self.min_page_index}, "
f"num_pages={self.num_pages}, "
f"num_virtual_ids={self.num_virtual_ids}, "
f"watermark_physical={self.watermark_physical}, "
f"allocated_pages={self._allocated_pages()}"
)
+7 -29
View File
@@ -11,7 +11,6 @@ from sglang.kernels.ops.memory.common import (
)
from sglang.kernels.ops.memory.common import get_last_loc_kernel as get_last_loc_kernel
from sglang.srt.mem_cache.allocator.page_interleave import page_interleave_shard_size
from sglang.srt.mem_cache.allocator.swa import SWATokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache, EvictParams
from sglang.srt.mem_cache.hicache_storage import PoolTransfer
from sglang.srt.mem_cache.memory_pool import HybridReqToTokenPool, ReqToTokenPool
@@ -161,34 +160,13 @@ def maybe_cache_unfinished_req(req: Req, tree_cache: BasePrefixCache, **kwargs):
tree_cache.cache_unfinished_req(req, **kwargs)
def evict_from_tree_cache(tree_cache: BasePrefixCache | None, num_tokens: int):
if tree_cache is None:
return
if tree_cache.is_chunk_cache():
return
allocator = tree_cache.token_to_kv_pool_allocator
if isinstance(allocator, SWATokenToKVPoolAllocator):
# Hybrid allocator
full_available_size = allocator.full_available_size()
swa_available_size = allocator.swa_available_size()
if full_available_size < num_tokens or swa_available_size < num_tokens:
full_num_tokens = max(0, num_tokens - full_available_size)
swa_num_tokens = max(0, num_tokens - swa_available_size)
tree_cache.evict_for_alloc(
EvictParams(num_tokens=full_num_tokens, swa_num_tokens=swa_num_tokens)
)
else:
# Standard allocator: evict only the shortfall (mirrors the SWA arm)
available_size = allocator.available_size()
if available_size < num_tokens:
tree_cache.evict_for_alloc(
EvictParams(num_tokens=num_tokens - available_size)
)
_evict_until_allocatable(tree_cache, allocator, num_tokens)
def evict_from_tree_cache(
tree_cache: BasePrefixCache | None, num_tokens: int
) -> bool | None:
if tree_cache is not None and not tree_cache.is_chunk_cache():
return tree_cache.token_to_kv_pool_allocator.evict_to_free_tokens(
tree_cache, num_tokens
)
def _evict_until_allocatable(
@@ -51,7 +51,7 @@ from sglang.srt.mem_cache.allocator.swa import (
is_swa_req_ring,
)
from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWATokenToKVPoolAllocator,
UnifiedSWAAllocatorBase,
)
from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator,
@@ -240,6 +240,7 @@ class _PoolSizes(msgspec.Struct, frozen=True, kw_only=True):
c128_state_pool_size: int
c4_state_dtype: Optional[torch.dtype]
c128_state_dtype: Optional[torch.dtype]
unified_memory_pool_bytes: Optional[int] = None
unified_total_bytes: Optional[int] = None
@@ -289,6 +290,23 @@ class KVCacheConfigurator:
self.draft_model_idx in self.model_config.swa_attention_layer_ids
)
def hybrid_swa_token_capacity(
self,
*,
allocator: BaseTokenToKVPoolAllocator,
full_capacity: Optional[int],
swa_capacity: Optional[int],
) -> int:
if get_memory().enable_unified_memory:
capacity = allocator.size_full
max_total_tokens = get_schedule().max_total_tokens
return (
min(capacity, max_total_tokens)
if max_total_tokens is not None
else capacity
)
return full_capacity or swa_capacity
def _build_fp4_quant_method(self, *, num_layers: int):
if not is_float4_e2m1fn_x2(self.kv_cache_dtype):
return None
@@ -423,6 +441,10 @@ class KVCacheConfigurator:
max_running_requests=max_running_requests,
full_max_total_num_tokens=full_max_total_num_tokens,
swa_max_total_num_tokens=swa_max_total_num_tokens,
# The target's byte envelope excludes the separate draft allocation.
unified_memory_pool_bytes=(
None if self.is_draft_worker else config.unified_memory_pool_bytes
),
c4_max_total_num_tokens=c4_max_total_num_tokens,
c128_max_total_num_tokens=c128_max_total_num_tokens,
c4_state_pool_size=c4_state_pool_size,
@@ -469,6 +491,7 @@ class KVCacheConfigurator:
max_num_reqs=sizes.max_running_requests,
full_max_total_num_tokens=sizes.full_max_total_num_tokens,
swa_max_total_num_tokens=sizes.swa_max_total_num_tokens,
unified_memory_pool_bytes=sizes.unified_memory_pool_bytes,
unified_total_bytes=sizes.unified_total_bytes,
)
else:
@@ -497,7 +520,7 @@ class KVCacheConfigurator:
token_to_kv_pool_allocator,
(
UnifiedMambaTokenToKVPoolAllocator,
UnifiedSWATokenToKVPoolAllocator,
UnifiedSWAAllocatorBase,
),
):
draft_virtual_id_space = (
@@ -520,7 +543,7 @@ class KVCacheConfigurator:
if (
isinstance(
token_to_kv_pool_allocator,
UnifiedSWATokenToKVPoolAllocator,
UnifiedSWAAllocatorBase,
)
and self.is_hybrid_swa
):
@@ -821,8 +844,9 @@ class KVCacheConfigurator:
self,
*,
max_num_reqs: int,
full_max_total_num_tokens: Optional[int],
swa_max_total_num_tokens: Optional[int],
full_max_total_num_tokens: Optional[int] = None,
swa_max_total_num_tokens: Optional[int] = None,
unified_memory_pool_bytes: Optional[int] = None,
unified_total_bytes: Optional[int] = None,
) -> UnifiedPoolBundle:
"""Build the unified-pool stack for a hybrid-SWA model (Triton): one byte
@@ -899,6 +923,12 @@ class KVCacheConfigurator:
if self.layer_info.start_layer <= i < self.layer_info.end_layer
]
total_bytes = unified_memory_pool_bytes
# An uncapped, draft-free pool owns the profiled budget, including bytes
# left over after rounding the FULL/SWA boot capacities to pages.
if unified_total_bytes is not None and self.spec_algorithm.is_none():
total_bytes = unified_total_bytes
bundle = init_unified_swa_pools(
device=self.device,
kv_cache_dtype=self.kv_cache_dtype,
@@ -915,6 +945,7 @@ class KVCacheConfigurator:
full_attention_layer_ids=full_attention_layer_ids,
full_max_total_num_tokens=full_max_total_num_tokens,
swa_max_total_num_tokens=swa_max_total_num_tokens,
total_bytes=total_bytes,
enable_memory_saver=get_exec().features.enable_memory_saver,
need_sort=get_disagg().disaggregation_mode in ("decode", "prefill"),
# Overlap mode: same wait_stream(forward_stream) rationale as
@@ -922,9 +953,6 @@ class KVCacheConfigurator:
forward_stream=self.forward_stream,
# Lazy compaction: default ON, with env var escape hatch for rollback / A/B.
lazy_compaction=_should_enable_lazy_compaction(),
# Draft workers keep the token-count byte sum (spec is asserted
# off under unified; belt only).
unified_total_bytes=(None if self.is_draft_worker else unified_total_bytes),
# bs=1 feasibility floor inputs. `model_context_len` bounds the
# sliding window term only -- the full-attention side is not
# charged, see `_check_bs1_feasibility_floor`.
@@ -2120,7 +2148,7 @@ class KVCacheConfigurator:
else:
swa_allocator = token_to_kv_pool_allocator
uses_unified_virtual_ids = isinstance(
swa_allocator, UnifiedSWATokenToKVPoolAllocator
swa_allocator, UnifiedSWAAllocatorBase
)
has_draft_swa_layers = (
not self.is_hybrid_swa_mtp_draft or self.draft_swa_full_capacity
@@ -2363,9 +2391,8 @@ class KVCacheConfigurator:
f"{config.max_total_num_tokens}"
)
if max_tokens != config.max_total_num_tokens:
# Token-capped re-derivation: the profiled budget no longer
# applies; the recalced config's unified_total_bytes stays None
# and the factories fall back to the token-count byte sum.
# Re-derive the capped budget: SWA carries unified_memory_pool_bytes;
# Mamba factories fall back to token-count sizing without unified_total_bytes.
config = configurator.calculate_pool_sizes_from_max_tokens(
max_tokens, get_schedule().page_size
)
@@ -65,7 +65,7 @@ from sglang.kernels.ops.kvcache.kv_read_table import (
build_kv_read_table_packed,
)
from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWATokenToKVPoolAllocator,
UnifiedSWAAllocatorBase,
)
from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator,
@@ -121,12 +121,13 @@ class KVIndexTranslator:
self.is_translating = (
isinstance(
token_to_kv_pool_allocator,
(UnifiedMambaTokenToKVPoolAllocator, UnifiedSWATokenToKVPoolAllocator),
(UnifiedMambaTokenToKVPoolAllocator, UnifiedSWAAllocatorBase),
)
and token_to_kv_pool_allocator.get_kvcache() is token_to_kv_pool
)
if self.is_translating:
alloc = token_to_kv_pool_allocator
self._capture_page_size = alloc.page_size
self._full_v2p_table = alloc.full_v2p_page_table
self._full_p2v_table = alloc.full_p2v_page_table
self._full_page_multiplier = alloc.kernel_page_multiplier
@@ -139,7 +140,7 @@ class KVIndexTranslator:
# DCP read ids stay WIDENED to the consumer: selecting this rank's
# share changes the length, so only the production site can do it.
self.defer_read_translate = get_parallel().attn_dcp_size > 1
if isinstance(alloc, UnifiedSWATokenToKVPoolAllocator):
if isinstance(alloc, UnifiedSWAAllocatorBase):
self._swa_v2p_table = alloc.swa_v2p_page_table
self._swa_page_multiplier = alloc.swa_kernel_page_multiplier
self._swa_write_loc_from_full = self._swa_write_loc_unified
@@ -171,6 +172,16 @@ class KVIndexTranslator:
)
self._index_table_memo: Optional[Tuple[weakref.ref, KVIndexTable]] = None
def capture_token_capacity(self, max_token_pool_size: int) -> int:
"""Host capture rows are indexed by request-token IDs, not kernel IDs.
Unified IDs span the whole virtual table even when admission is capped.
DCP widens allocator pages; the runner's page size stays physical.
"""
if self.is_translating:
return self._full_v2p_table.numel() * self._capture_page_size
return max_token_pool_size + self.page_size
# -- per-batch view --------------------------------------------------------
@property
@@ -0,0 +1,413 @@
# Copyright 2026 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Memory reservations for one prefill pass.
The scheduler supplies token demand and its chunk/decode limits. These objects
account for admitted but not yet allocated work and query live cache capacity:
locking a prefix or preempting a request must affect the next admission check.
They neither select requests nor mutate the prefix cache or allocator.
"""
from typing import Optional
from sglang.srt.mem_cache.allocator.swa import is_swa_req_ring
def estimate_swa_kv_tokens(
extend_input_len: int,
max_new_tokens: int,
*,
sliding_window_size: Optional[int],
page_size: int,
allocation_limit: Optional[int] = None,
host_hit_length: int = 0,
) -> int:
"""Peak SWA reservation for one prefill/decode request."""
if sliding_window_size is None or sliding_window_size <= 0:
reserved = extend_input_len + max_new_tokens + page_size
else:
allocated = (
extend_input_len
if allocation_limit is None
else min(extend_input_len, allocation_limit)
)
allocated_tail = max(allocated - sliding_window_size, 0)
# With a roughly two-window SWA pool, a cached prefix can already lock
# one window. Charging another whole window for a short resume can then
# block admission forever on an idle pool. Reserve only its uncached
# tail plus decode headroom, capped at the window.
# Including the extension also keeps the reservation large enough for
# this pass's prefill allocation.
reserved = (
allocated_tail
+ min(extend_input_len + max_new_tokens, sliding_window_size)
+ page_size
)
if host_hit_length > 0:
reserved += -(-host_hit_length // page_size) * page_size
return reserved
class PrefillBudget:
"""Fixed token pool. Offsets include pending allocations and decode headroom."""
def __init__(self, allocator, tree_cache, *, num_mixed_decode_tokens: int = 0):
self.allocator = allocator
self.tree_cache = tree_cache
self.page_size = allocator.page_size
self.total_offset = num_mixed_decode_tokens
self.current_offset = num_mixed_decode_tokens
self.swa_offset = 0
def ceil_paged_tokens(self, tokens: int) -> int:
return -(-tokens // self.page_size) * self.page_size
def _available_and_evictable(self):
evictable = (
self.tree_cache.full_evictable_size()
if self.tree_cache.supports_mamba()
else self.tree_cache.evictable_size()
)
return self.allocator.available_size() + evictable
@property
def remaining_total(self):
return self._available_and_evictable() - self.total_offset
@property
def remaining_current(self):
return self._available_and_evictable() - self.current_offset
@property
def remaining_swa(self):
return 0
def has_capacity(self) -> bool:
return self.remaining_total > 0 and self.remaining_current > 0
def check_prefill(
self,
*,
extend_input_len: int,
total_tokens: int,
max_new_tokens: int,
input_tokens: int,
swa_host_hit_length: int,
chunk_limit: int | None,
) -> tuple[bool, int | None]:
"""Return admission feasibility and a memory bound on the requested chunk."""
return (
(True, chunk_limit)
if total_tokens < self.remaining_total
else (False, None)
)
def can_allocate_prefill(
self,
*,
paged_input: int,
extend_input_len: int,
max_new_tokens: int,
chunk_limit: int | None,
) -> bool:
return paged_input <= min(self.remaining_current, self.remaining_total)
def available_chunk_tokens(self, chunk_limit: int) -> int | None:
available = min(chunk_limit, int(self.remaining_total))
# Single-pool continuation must make progress to release its KV.
return available if available > 0 else chunk_limit
def fit_chunk(
self,
*,
extend_input_len: int,
max_new_tokens: int,
chunk_limit: int,
) -> int | None:
return chunk_limit
def reserve(
self,
extend_input_len: int,
max_new_tokens: int,
*,
extra_tokens: int = 0,
chunk_limit: int | None = None,
is_chunked_continuation: bool = False,
) -> None:
extend_input_len = self.ceil_paged_tokens(extend_input_len)
immediate = extend_input_len + self.page_size + extra_tokens
self.total_offset += immediate + max_new_tokens
self.current_offset += immediate
class SWAPrefillBudget(PrefillBudget):
"""Separate FULL/SWA partitions, including per-request SWA rings."""
def __init__(self, *args, all_swa=False, **kwargs):
super().__init__(*args, **kwargs)
self.all_swa = all_swa
self.req_ring = is_swa_req_ring(self.allocator)
def _available_and_evictable(self):
if self.all_swa:
return (
self.allocator.swa_available_size()
+ self.tree_cache.swa_evictable_size()
)
return (
self.allocator.full_available_size() + self.tree_cache.full_evictable_size()
)
@property
def remaining_swa(self):
evictable = 0 if self.req_ring else self.tree_cache.swa_evictable_size()
return self.allocator.swa_available_size() + evictable - self.swa_offset
def swa_tokens(
self,
extend_input_len,
max_new_tokens,
*,
chunk_limit=None,
swa_host_hit_length=0,
):
if self.req_ring:
return self.allocator.swa_ring_cost_tokens
return estimate_swa_kv_tokens(
extend_input_len,
max_new_tokens,
sliding_window_size=self.tree_cache.sliding_window_size,
page_size=self.page_size,
allocation_limit=chunk_limit,
host_hit_length=swa_host_hit_length,
)
def swa_never_fits(self, extend_input_len, max_new_tokens, **kwargs):
needed = self.swa_tokens(extend_input_len, max_new_tokens, **kwargs)
return (
needed > self.allocator.size_swa
if self.req_ring
else needed >= self.allocator.size_swa
)
def _chunk_cap(self, max_new_tokens, swa_host_hit_length=0):
# Only the sliding window stays locked between chunks, so a smaller
# chunk can bound the transient SWA footprint of a longer prompt.
headroom = self.swa_tokens(
0, max_new_tokens, swa_host_hit_length=swa_host_hit_length
)
cap = int(self.remaining_swa) - headroom
return max(0, cap // self.page_size * self.page_size)
def check_prefill(
self,
*,
extend_input_len: int,
total_tokens: int,
max_new_tokens: int,
input_tokens: int,
swa_host_hit_length: int,
chunk_limit: int | None,
) -> tuple[bool, int | None]:
if total_tokens >= self.remaining_total:
return False, None
extend_input_len = self.ceil_paged_tokens(extend_input_len)
needed = self.swa_tokens(
extend_input_len,
max_new_tokens,
chunk_limit=chunk_limit,
swa_host_hit_length=swa_host_hit_length,
)
fits = (
needed <= self.remaining_swa
if self.req_ring
else needed < self.remaining_swa
)
if fits:
return True, chunk_limit
# Only permanent shortfalls may shrink a chunk. Transient pressure waits
# so a new prefill does not consume running decodes' window headroom.
cap = 0
if self.swa_never_fits(
extend_input_len,
max_new_tokens,
chunk_limit=chunk_limit,
swa_host_hit_length=swa_host_hit_length,
):
cap = self._chunk_cap(max_new_tokens, swa_host_hit_length)
if chunk_limit is None or cap <= 0:
return False, None
return True, min(chunk_limit, cap)
def has_capacity(self) -> bool:
return super().has_capacity() and self.remaining_swa > 0
def available_chunk_tokens(self, chunk_limit: int) -> int | None:
available = min(chunk_limit, int(self.remaining_total))
if not self.req_ring:
available = min(available, int(self.remaining_swa) - self.page_size)
return available if available > 0 else None
def can_allocate_prefill(
self,
*,
paged_input: int,
extend_input_len: int,
max_new_tokens: int,
chunk_limit: int | None,
) -> bool:
return (
super().can_allocate_prefill(
paged_input=paged_input,
extend_input_len=extend_input_len,
max_new_tokens=max_new_tokens,
chunk_limit=chunk_limit,
)
and self.swa_tokens(
extend_input_len, max_new_tokens, chunk_limit=chunk_limit
)
<= self.remaining_swa
)
def reserve(
self,
extend_input_len: int,
max_new_tokens: int,
*,
extra_tokens: int = 0,
chunk_limit: int | None = None,
is_chunked_continuation: bool = False,
) -> None:
super().reserve(extend_input_len, max_new_tokens, extra_tokens=extra_tokens)
# A continuation already owns its ring slot.
if not (self.req_ring and is_chunked_continuation):
self.swa_offset += self.swa_tokens(
self.ceil_paged_tokens(extend_input_len),
max_new_tokens,
chunk_limit=chunk_limit,
)
class SharedSWAPrefillBudget(SWAPrefillBudget):
"""FULL and SWA reservations compete for the same physical byte budget."""
def __init__(self, *args, num_mixed_decode_tokens=0, **kwargs):
super().__init__(
*args, num_mixed_decode_tokens=num_mixed_decode_tokens, **kwargs
)
self.swa_offset = num_mixed_decode_tokens
def _fits(self, full_tokens, swa_tokens, *, empty_pool=False):
return self.allocator.can_reserve(
full_tokens + (0 if empty_pool else self.total_offset),
swa_tokens + (0 if empty_pool else self.swa_offset),
full_evictable_tokens=0
if empty_pool
else self.tree_cache.full_evictable_size(),
swa_evictable_tokens=0
if empty_pool
else self.tree_cache.swa_evictable_size(),
empty_pool=empty_pool,
require_token_slack=empty_pool,
)
def _joint_chunk_cap(self, *, max_chunk_tokens, chunk_limit, swa_host_hit_length=0):
lo, hi = 0, max(0, max_chunk_tokens) // self.page_size
while lo < hi:
mid = (lo + hi + 1) // 2
tokens = mid * self.page_size
if self._fits(
tokens + self.page_size,
self.swa_tokens(
tokens,
0,
chunk_limit=chunk_limit,
swa_host_hit_length=swa_host_hit_length,
),
):
lo = mid
else:
hi = mid - 1
return lo * self.page_size
def check_prefill(
self,
*,
extend_input_len: int,
total_tokens: int,
max_new_tokens: int,
input_tokens: int,
swa_host_hit_length: int,
chunk_limit: int | None,
) -> tuple[bool, int | None]:
needed = self.swa_tokens(
extend_input_len,
max_new_tokens,
chunk_limit=chunk_limit,
swa_host_hit_length=swa_host_hit_length,
)
if self._fits(total_tokens, needed):
return True, chunk_limit
if chunk_limit is None or self._fits(
input_tokens + max_new_tokens + self.page_size,
self.swa_tokens(input_tokens, max_new_tokens, chunk_limit=chunk_limit),
empty_pool=True,
):
return False, None
cap = self._joint_chunk_cap(
max_chunk_tokens=min(chunk_limit, max(0, extend_input_len - 1)),
chunk_limit=chunk_limit,
swa_host_hit_length=swa_host_hit_length,
)
return (True, min(chunk_limit, cap)) if cap > 0 else (False, None)
def has_capacity(self) -> bool:
return self._fits(0, 0)
def available_chunk_tokens(self, chunk_limit: int) -> int | None:
return chunk_limit if chunk_limit > 0 else None
def fit_chunk(
self,
*,
extend_input_len: int,
max_new_tokens: int,
chunk_limit: int,
) -> int | None:
candidate = min(extend_input_len, chunk_limit)
finishes = candidate >= extend_input_len
headroom = max_new_tokens if finishes else 0
if self._fits(
candidate + headroom + self.page_size,
self.swa_tokens(candidate, headroom, chunk_limit=chunk_limit),
):
return chunk_limit
cap = self._joint_chunk_cap(
max_chunk_tokens=max(0, candidate - 1) if finishes else candidate,
chunk_limit=chunk_limit,
)
return min(chunk_limit, cap) if cap > 0 else None
def can_allocate_prefill(
self,
*,
paged_input: int,
extend_input_len: int,
max_new_tokens: int,
chunk_limit: int | None,
) -> bool:
return self._fits(
extend_input_len + max_new_tokens + self.page_size,
self.swa_tokens(extend_input_len, max_new_tokens, chunk_limit=chunk_limit),
)
@@ -226,11 +226,11 @@ class SWAComponent(TreeComponent):
def _unified_allocator(self):
"""The unified SWA composite, or None when running on the static pool."""
from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWATokenToKVPoolAllocator,
UnifiedSWAAllocatorBase,
)
allocator = self.cache.token_to_kv_pool_allocator
if isinstance(allocator, UnifiedSWATokenToKVPoolAllocator):
if isinstance(allocator, UnifiedSWAAllocatorBase):
return allocator
return None
@@ -1713,13 +1713,13 @@ def init_unified_swa_pools(
end_layer: int,
swa_attention_layer_ids: List[int],
full_attention_layer_ids: List[int],
full_max_total_num_tokens: int,
swa_max_total_num_tokens: int,
full_max_total_num_tokens: Optional[int] = None,
swa_max_total_num_tokens: Optional[int] = None,
total_bytes: Optional[int] = None,
enable_memory_saver: bool,
need_sort: bool,
forward_stream: Optional[torch.cuda.Stream] = None,
lazy_compaction: bool = False,
unified_total_bytes: Optional[int] = None,
model_context_len: Optional[int] = None,
sliding_window_size: Optional[int] = None,
) -> UnifiedSWAPoolBundle:
@@ -1758,15 +1758,20 @@ def init_unified_swa_pools(
store_dtype=store_dtype,
grow_direction="up",
)
if unified_total_bytes is not None:
# PROFILED byte budget, sized from directly: the re-sum's floor losses
# stay out of the buffer, and the token counts remain boot labels.
total_bytes = unified_total_bytes
else:
legacy_allocator_capacities = {}
if total_bytes is None:
if full_max_total_num_tokens is None or swa_max_total_num_tokens is None:
raise ValueError(
"total_bytes or both legacy full/SWA capacities must be provided"
)
total_bytes = (
full_max_total_num_tokens * full_spec.entry_bytes()
+ swa_max_total_num_tokens * swa_spec.entry_bytes()
)
legacy_allocator_capacities = {
"full_max_total_num_tokens": full_max_total_num_tokens,
"swa_max_total_num_tokens": swa_max_total_num_tokens,
}
if model_context_len is not None:
# bs=1 floor: ONE sliding window of swa KV (+ a page of slack for the
# page-granular walk) + the slot-0 sink. The full side is not charged
@@ -1785,6 +1790,8 @@ def init_unified_swa_pools(
],
factory="init_unified_swa_pools",
)
if total_bytes <= 0:
raise ValueError(f"total_bytes must be positive, got {total_bytes}")
shared_pool = UnifiedKVPool(
total_bytes=total_bytes,
sub_pool_specs=[full_spec, swa_spec],
@@ -1805,12 +1812,11 @@ def init_unified_swa_pools(
unified_buffer=shared_pool,
kvcache=token_to_kv_pool,
device=device,
full_max_total_num_tokens=full_max_total_num_tokens,
swa_max_total_num_tokens=swa_max_total_num_tokens,
page_size=page_size,
need_sort=need_sort,
forward_stream=forward_stream,
lazy_compaction=lazy_compaction,
**legacy_allocator_capacities,
)
logger.info(
@@ -1841,12 +1847,12 @@ def init_unified_swa_pools(
page_size,
)
logger.info(
"[unified-memory-pool] total_bytes=%d (=%.2f GB), full_max_total_num_tokens=%d, "
"swa_max_total_num_tokens=%d, joint_available=%d slots",
"[unified-memory-pool] total_bytes=%d (=%.2f GB), "
"full_capacity=%d, swa_capacity=%d, joint_available=%d slots",
total_bytes,
total_bytes / GB,
full_max_total_num_tokens,
swa_max_total_num_tokens,
allocator.size_full,
allocator.size_swa,
allocator.available_size(),
)
logger.info(
@@ -1117,7 +1117,9 @@ class ModelRunner:
RoutedExpertsCapturer.create(
model=self.model,
model_config=self.model_config,
num_tokens=self.max_token_pool_size + self.page_size,
num_tokens=self.kv_index_translator.capture_token_capacity(
self.max_token_pool_size
),
max_running_requests=self.max_running_requests,
device=self.device,
)
@@ -1127,7 +1129,9 @@ class ModelRunner:
set_global_indexer_capturer(
create_indexer_capturer(
model_config=self.model_config,
num_tokens=self.max_token_pool_size + self.page_size,
num_tokens=self.kv_index_translator.capture_token_capacity(
self.max_token_pool_size
),
max_running_requests=self.max_running_requests,
device=self.device,
)
@@ -1373,7 +1377,11 @@ class ModelRunner:
def effective_max_total_num_tokens(self):
"""Return the max token pool size considering hybrid swa settings."""
if self.is_hybrid_swa:
capacity = self.full_max_total_num_tokens or self.swa_max_total_num_tokens
capacity = self.kv_cache_configurator.hybrid_swa_token_capacity(
allocator=self.token_to_kv_pool_allocator,
full_capacity=self.full_max_total_num_tokens,
swa_capacity=self.swa_max_total_num_tokens,
)
else:
capacity = self.max_total_num_tokens
if (req_to_token_pool := getattr(self, "req_to_token_pool", None)) is not None:
@@ -71,6 +71,7 @@ class MemoryPoolConfig:
max_running_requests: Optional[int] = None
full_max_total_num_tokens: Optional[int] = None
swa_max_total_num_tokens: Optional[int] = None
unified_memory_pool_bytes: Optional[int] = None
# DSV4 compressed-attention pool sizes (target only; draft workers leave at 0).
c4_max_total_num_tokens: int = 0
@@ -698,6 +699,12 @@ class HybridSWAPoolConfigurator(MemoryPoolConfigurator):
+ self._draft_cell_size
)
def _unified_pool_bytes(self, full_tokens: int, swa_tokens: int) -> int:
return (
full_tokens * self._full_per_token * self._full_layers_num
+ swa_tokens * self._swa_per_token * self._swa_layers_num
)
def _max_unified_full_tokens(
self,
available_bytes: int,
@@ -707,7 +714,6 @@ class HybridSWAPoolConfigurator(MemoryPoolConfigurator):
"""Find the largest page-aligned full capacity whose allocations fit."""
draft_bytes_per_token = self._draft_pool_bytes_per_token()
target_full_bytes_per_token = self._full_per_token * self._full_layers_num
target_swa_bytes_per_token = self._swa_per_token * self._swa_layers_num
assert target_full_bytes_per_token > 0
def allocation_bytes(full_pages: int) -> int:
@@ -719,10 +725,7 @@ class HybridSWAPoolConfigurator(MemoryPoolConfigurator):
// page_size
* page_size
)
target_bytes = (
full_tokens * target_full_bytes_per_token
+ swa_tokens * target_swa_bytes_per_token
)
target_bytes = self._unified_pool_bytes(full_tokens, swa_tokens)
virtual_span = max(target_bytes // target_full_bytes_per_token - 1, 0)
draft_tokens = ceil_align(virtual_span, page_size) + page_size
return target_bytes + draft_tokens * draft_bytes_per_token
@@ -760,29 +763,34 @@ class HybridSWAPoolConfigurator(MemoryPoolConfigurator):
full_tokens = align_page_size(max_total_num_tokens)
swa_tokens = align_page_size(int(full_tokens * self._swa_full_tokens_ratio))
self.validate_swa_pool_size(
swa_tokens, self._sliding_window_size, self._page_size
)
if not self._enable_unified_memory:
self.validate_swa_pool_size(
swa_tokens, self._sliding_window_size, self._page_size
)
logger.info(
f"Use sliding window memory pool. "
f"full_layer_tokens={full_tokens}, swa_layer_tokens={swa_tokens}"
)
return self._make_pool_config(full_tokens, swa_tokens)
def _make_pool_config(self, full_tokens: int, swa_tokens: int) -> MemoryPoolConfig:
return MemoryPoolConfig(
max_total_num_tokens=full_tokens,
full_max_total_num_tokens=full_tokens,
swa_max_total_num_tokens=swa_tokens,
unified_memory_pool_bytes=(
self._unified_pool_bytes(full_tokens, swa_tokens)
if self._enable_unified_memory
else None
),
)
def calculate_pool_sizes(
self, available_bytes: int, page_size: int
) -> MemoryPoolConfig:
if (
self._enable_unified_memory
and self._full_layers_num > 0
and self._draft_pool_bytes_per_token() > 0
):
if self._enable_unified_memory and self._full_layers_num > 0:
max_total_num_tokens = self._max_unified_full_tokens(
available_bytes, page_size
)
@@ -873,7 +881,7 @@ class SWAChunkCapPoolConfigurator(HybridSWAPoolConfigurator):
* self._swa_per_token
* (self._swa_layers_num + self._draft_swa_layers_num)
)
if self._enable_unified_memory and self._draft_pool_bytes_per_token() > 0:
if self._enable_unified_memory:
full_tokens = self._max_unified_full_tokens(
available_bytes, page_size, fixed_swa_tokens=swa_tokens
)
@@ -894,11 +902,7 @@ class SWAChunkCapPoolConfigurator(HybridSWAPoolConfigurator):
f"Reduce --max-running-requests, lower SGLANG_SWA_EVICTION_INTERVAL, "
f"or increase --mem-fraction-static."
)
return MemoryPoolConfig(
max_total_num_tokens=full_tokens,
full_max_total_num_tokens=full_tokens,
swa_max_total_num_tokens=swa_tokens,
)
return self._make_pool_config(full_tokens, swa_tokens)
def calculate_pool_sizes_from_max_tokens(
self, max_total_num_tokens: int, page_size: int
@@ -906,10 +910,8 @@ class SWAChunkCapPoolConfigurator(HybridSWAPoolConfigurator):
# Constrained max_total goes to the full pool; SWA stays at its cap.
swa_tokens = ceil_align(self._swa_cap, page_size)
full_tokens = (max_total_num_tokens // page_size) * page_size
return MemoryPoolConfig(
max_total_num_tokens=full_tokens,
full_max_total_num_tokens=full_tokens,
swa_max_total_num_tokens=min(swa_tokens, max_total_num_tokens),
return self._make_pool_config(
full_tokens, min(swa_tokens, max_total_num_tokens)
)
+10 -2
View File
@@ -201,13 +201,21 @@ class BaseTopkCapturer:
slice_gpu = self._get_local_slice(
forward_batch, can_run_graph, cuda_graph_batch
)
# get_topk reads req_to_token IDs; attention may have rebound the write
# loc to kernel-facing IDs, which are neither stable nor the same space.
out_cache_loc = forward_batch.out_cache_loc_virtual
if out_cache_loc is None:
out_cache_loc = forward_batch.out_cache_loc
else:
# Kernel batches may be padded; only real request tokens are stored.
slice_gpu = slice_gpu[: out_cache_loc.shape[0]]
if no_copy_to_cpu:
# Clone before the next overlapping forward reuses these buffers.
return TopkCaptureOutput(
out_cache_loc=forward_batch.out_cache_loc.clone(),
out_cache_loc=out_cache_loc.clone(),
topk=slice_gpu.clone(),
host_cache=self.host_cache,
)
out_cache_loc_cpu = forward_batch.out_cache_loc.cpu()
out_cache_loc_cpu = out_cache_loc.cpu()
self.host_cache.buffer[out_cache_loc_cpu] = slice_gpu.cpu()
return None
@@ -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,