Files
sglang/python/sglang/srt/model_executor/pool_configurator.py
T
Cheng Wan b99175dc7d [Config] Round 6.4: the runtime reads the bags, not the record (#38049)
Last of four; stacked on #38048.

The record is the operator's input; the bags are what is in effect. A reader
that takes the record and reads a field off it gets the input, which is the
wrong one of the two whenever resolution decided something -- and the mistake is
silent, because for most fields and most launches the two agree. Several of
these files already read both ways, sometimes in the same expression:

```python
get_tokenizer(
    get_serving().tokenizer_path,
    tokenizer_mode=server_args.tokenizer_mode,   # the input, not the decision
    ...
)
```

Sixty-odd files convert. Record field reads in runtime code go from 199 to 11.
Nine parameters that the conversion emptied are dropped along with the argument
at every call site -- the dead-parameter ratchet is what names them.

### "Runs after its process publishes" is a per-entry-point claim

Most converted reads sit in the serving and model-executor layers, which only
exist after publication, or in the two subprocess entry points, which publish
first thing. Three places are not like that, and they keep reading the record
they were handed:

- **`HttpServerEngineAdapter`** launches the server as a *child*. The parent
  resolves the record and never publishes, so the adapter's own reads -- the
  launch banner, the API key in its readiness loop, the TP width in
  `update_weights_from_tensor` -- are of `self.server_args`. A bag read here
  fails closed in a bare process, or answers for an unrelated engine in one that
  happens to have published.
- **`serve_grpc`** reads its sidecar port before the integrated servicer builds
  the `Engine` that publishes. The comment above that line already said so and
  already bound `cfg = resolving_view(server_args)` for it; the sidecar port and
  the port it derives from read `cfg`.
- **`initialize_dp_attention`** runs from callers whose publish is not
  guaranteed, so its one predicate stays on the resolution view.

`ROLE_NAMESPACE_SETS["dp_controller"]` gains `observability` and `serving`,
because the controller's metrics gate, tracing setup and worker-port broadcast
now read those namespaces. Under `SGLANG_ROLE_NAMESPACES=enforce` that set is
what the process may read, so a conversion that reaches a new namespace has to
widen it in the same change.

## Three things worth a reviewer's attention

**Eleven reads were `getattr(record, "field", default)`.** An AST scan for
attribute access does not see those, so the census that said "43 readers" was
counting the shape it could match rather than the thing it was after.
`incremental_streaming_output` was read that way twice, and the transcription
tests were the only reason it surfaced.

**Not every record read is a bag read waiting to happen.** A multimodal
processor's `base_gpu_id` is the instance's, not the process's: two engines in
one process keep different ones, and
`test_publishing_another_config_does_not_move_the_device` exists to say so. It
stays on the record while `rl_on_policy_target` beside it moves.
`RequestMetricsExporter` is the same shape -- it is handed the directory it
writes to, and a test builds several with different ones. `configure_logger` is
a third: 17 call sites, one of which passes an `argparse.Namespace`, so it is
not a global-context reader at all. Those eleven remaining reads are the ones
with a reason.

**The fixtures move with the code.** Tests that hung config off a mock manager
now publish a record, which is what the serving layer reads; where a test states
a value it says so with `override_server_args` instead of assigning through the
mock. `test_hisparse_unit` is the last of them: it stubbed a `server_args` onto
a fake scheduler to say the decode radix cache was off, and the value it was
standing in for is the published default, so the stub goes and the class
publishes.

## Two things CI caught that a local sweep could not

**`unittest.TestCase.enterContext` is Python 3.11+.** The converted fixtures used
it at 18 sites; `requires-python` is `>=3.10` and CI runs 3.10, so every one of
them raised `AttributeError` there while passing on a newer local interpreter.
They call `enter_override(self, ...)` now -- a four-line helper in
`sglang/test/test_utils.py` over the override's own `install()` / `restore()`.

**A batched sweep cannot see a missing publish.** Three fixtures needed a
published config and did not have one; each *passed* inside a shard where some
other file had published, and failed when run alone. The affected cases are
`test_serving_completions` (which set `incremental_streaming_output` on the mock
manager's record, where nothing reads it now), `test_qwen3_vl_feature_materialization`
(same shape for `mm_enable_dp_encoder`), and the two Qwen Rust tests -- whose
fixture already carried the comment `# Non-auto: get_resolved_model_impl would
choke on a SimpleNamespace` next to the `model_impl` it sets, which is exactly
what happened once `get_mm_processor_cls` started reading that value from the
bag. Its `publish` mirrors `model_impl` now, like the four fields it already
mirrored.

## Verification

A full registered-unit sweep (648 files) against this stack's merge-base:
19 failures on both sides, the same 19, none of them config. That sweep is what
caught 23 failures the file-scoped runs missed -- and, later, that the narrower
139-file list did not even contain the files this change reaches. It is also
what caught the `test_hisparse_unit` fixture above: the file passes inside a
shard where something else published, and fails when it is run on its own,
which is why every failing file is re-run alone before it is counted.
2026-09-06 21:41:46 -07:00

1174 lines
49 KiB
Python

"""Memory pool configurators for profiling and sizing KV cache pools.
Each model architecture has its own configurator that computes pool sizes
from available GPU memory using a unified coeff+bias model:
available_bytes = max_tokens * coeff + bias
max_tokens = (available_bytes - bias) / coeff
Two entry points, same core computation:
- calculate_pool_sizes(available_bytes, page_size): profiling path
- calculate_pool_sizes_from_max_tokens(max_tokens, page_size): constraint path
"""
from __future__ import annotations
import logging
from bisect import bisect_right
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
import torch
from sglang.srt.configs.hybrid_arch import mambaish_config
from sglang.srt.configs.model_config import (
AttentionArch,
dsa_layer_skips_topk,
get_dsa_index_head_dim,
get_minimax_sparse_attention_config,
get_minimax_sparse_disable_value_layer_ids,
get_minimax_sparse_layer_ids,
is_deepseek_dsa,
is_deepseek_v4,
is_minimax_sparse,
)
from sglang.srt.environ import envs
from sglang.srt.mem_cache.allocation_sizing import get_alloc_len_per_decode
from sglang.srt.mem_cache.deepseek_v4_memory_pool import (
get_compress_state_ring_size,
get_compress_state_write_pad,
get_dsv4_indexer_bytes_per_token,
)
from sglang.srt.mem_cache.memory_pool import DSATokenToKVPool
from sglang.srt.runtime_context import (
get_disagg,
get_exec,
get_memory,
get_parallel,
get_schedule,
get_spec,
max_speculative_num_draft_tokens,
)
from sglang.srt.utils.common import (
ceil_align,
ceil_div,
is_float4_e2m1fn_x2,
is_hip,
spec_decode_alloc_len_per_request,
)
_is_hip = is_hip()
@dataclass
class MemoryPoolConfig:
"""Resolved memory pool config, shared between target and draft workers."""
max_total_num_tokens: int
max_running_requests: Optional[int] = None
full_max_total_num_tokens: Optional[int] = None
swa_max_total_num_tokens: Optional[int] = None
# DSV4 compressed-attention pool sizes (target only; draft workers leave at 0).
c4_max_total_num_tokens: int = 0
c128_max_total_num_tokens: int = 0
c4_state_pool_size: int = 0
c128_state_pool_size: int = 0
mem_fraction_static: Optional[float] = None
# Unified pool only: the PROFILED byte budget for the token-granular
# sub-pools. Set, the factories size the buffer from it directly instead of
# re-summing ratio-derived token counts, which keeps the re-sum's floor
# losses out of the buffer; the token counts stay boot labels / conserve
# caps. None on the token-capped path -- a user token cap IS the budget.
unified_total_bytes: Optional[int] = None
def __post_init__(self):
if self.max_total_num_tokens <= 0:
msg = "Not enough memory. Please try to increase --mem-fraction-static."
if self.mem_fraction_static is not None:
msg += f" Current value: mem_fraction_static={self.mem_fraction_static}"
raise RuntimeError(msg)
if TYPE_CHECKING:
from sglang.srt.mem_cache.kv_cache_configurator import KVCacheConfigurator
logger = logging.getLogger(__name__)
def _dflash_draft_cell_size(kvc: KVCacheConfigurator) -> int:
"""Bytes/token the DFLASH draft KV pool adds to the target's budget, 0 if none.
Unlike an EAGLE draft, which reuses the target's attention config and is
therefore priced by layer count, a DFLASH draft has its own geometry and is
a flat additive term. Under DCP, the target pool is sharded while the draft
pool spans the allocator's widened virtual location space, so the draft
term is replicated across DCP ranks.
"""
if kvc.is_draft_worker or not kvc.spec_algorithm.is_dflash_family():
return 0
cell_size = kvc.spec_aux_config.dflash_draft_cell_size_per_token
if cell_size is None or int(cell_size) <= 0:
return 0
return int(cell_size) * get_parallel().attn_dcp_size
def _get_dsa_cache_layer_ids(kvc: KVCacheConfigurator, num_layers: int) -> list[int]:
"""Global layer ids represented by the local DSA pool's dense layer slots."""
if kvc.mambaish_config and not kvc.is_draft_worker:
layer_ids = [
layer_id
for layer_id in kvc.mambaish_config.full_attention_layer_ids
if kvc.layer_info.start_layer <= layer_id < kvc.layer_info.end_layer
]
else:
layer_ids = list(range(kvc.layer_info.start_layer, kvc.layer_info.end_layer))
# Draft pools and a few platform-specific pools may expose a synthetic layer
# count. They do not use indexShare, so only the length matters for sizing.
if len(layer_ids) != num_layers:
return list(range(num_layers))
return layer_ids
def _get_dsv4_compress_state_dtype_sizes() -> tuple[int, int]:
dtype_name = envs.SGLANG_DSV4_COMPRESS_STATE_DTYPE.get().strip().lower()
if dtype_name in ("float32", "fp32"):
return 4, 4
if dtype_name in ("bfloat16", "bf16"):
return 2, 2
raise ValueError(
"Unsupported SGLANG_DSV4_COMPRESS_STATE_DTYPE="
f"{dtype_name!r}. Expected one of: float32, fp32, bfloat16, bf16."
)
class MemoryPoolConfigurator:
"""Base class for memory pool configurators.
Subclasses compute pool sizes for their architecture via coeff+bias model.
Both entry points return MemoryPoolConfig (with max_running_requests=None,
to be filled by the consumer).
"""
def calculate_pool_sizes(
self, available_bytes: int, page_size: int
) -> MemoryPoolConfig:
"""Profiling path: compute pool sizes from available bytes."""
raise NotImplementedError
def calculate_pool_sizes_from_max_tokens(
self, max_total_num_tokens: int, page_size: int
) -> MemoryPoolConfig:
"""Constraint path: recalculate pool sizes from a constrained max_tokens."""
raise NotImplementedError
def finalize_with_max_running_requests(
self, config: MemoryPoolConfig
) -> MemoryPoolConfig:
return config
@staticmethod
def validate_swa_pool_size(
swa_tokens: int, sliding_window_size: Optional[int], page_size: int
) -> None:
"""Reject an SWA pool too small to ever admit a request.
Prefill charges min(extend + decode, window) + page_size of SWA headroom
per request, so a pool at or below that floor rejects every request no
matter how far it drains: the scheduler spins in the waiting queue and
the server hangs at warmup instead of failing here.
"""
if sliding_window_size is None:
return
if sliding_window_size + page_size >= swa_tokens:
raise ValueError(
f"SWA pool ({swa_tokens} tokens) cannot hold even one request: "
f"the prefill admission floor is sliding_window_size "
f"({sliding_window_size}) + page_size ({page_size}). "
f"Increase --swa-full-tokens-ratio or the total KV budget."
)
class DefaultPoolConfigurator(MemoryPoolConfigurator):
"""Configurator for standard models: MHA, MLA, DSA, FP4.
coeff = cell_size (bytes per token across all layers)
bias = 0
"""
def __init__(self, kvc: KVCacheConfigurator):
self.kv_cache_dtype_str = kvc.kv_cache_dtype_str
# Determine effective number of layers for KV cache
if mambaish := mambaish_config(kvc.model_config):
effective_layer_ids = [
i
for i in mambaish.full_attention_layer_ids
if kvc.layer_info.start_layer <= i < kvc.layer_info.end_layer
]
num_layers = len(effective_layer_ids)
else:
num_layers = kvc.layer_info.num_effective_layers
self._cell_size = self._compute_cell_size(kvc, num_layers)
has_kv_on_another_pp_stage = (
self._cell_size == 0
and mambaish is not None
and bool(mambaish.full_attention_layer_ids)
and kvc.ps.pp_size > 1
)
self._zero_kv_max_tokens = (
torch.iinfo(torch.int64).max
if has_kv_on_another_pp_stage
else get_schedule().max_total_tokens or kvc.model_config.context_len
)
# EAGLE/STANDALONE: scale cell_size to account for draft model KV cache.
# Assumes draft and target share the same per-layer KV size (head_dim,
# num_kv_heads, dtype), which holds for EAGLE/MTP draft models that
# reuse the target architecture's attention config.
if (
kvc.spec_algorithm.is_eagle() or kvc.spec_algorithm.is_standalone()
) and not kvc.is_draft_worker:
eagle_draft_num_layers = kvc.spec_aux_config.eagle_draft_num_layers
if (
eagle_draft_num_layers is not None
and int(eagle_draft_num_layers) > 0
and int(num_layers) > 0
):
draft_num_layers = int(eagle_draft_num_layers)
if is_deepseek_dsa(kvc.model_config.hf_config):
target_indexer_size = self._compute_dsa_indexer_cell_size(
kvc=kvc,
num_layers=num_layers,
)
target_kv_size = self._cell_size - target_indexer_size
from sglang.srt.layers.cp.utils import (
get_glm_dsa_layer_split_effective_num_layers,
)
target_kv_num_layers = get_glm_dsa_layer_split_effective_num_layers(
kvc, num_layers
)
draft_kv_size = int(
target_kv_size * draft_num_layers / target_kv_num_layers
)
draft_indexer_size = self._compute_dsa_indexer_cell_size(
kvc=kvc,
num_layers=draft_num_layers,
allocate_all_layers=True,
)
self._cell_size += draft_kv_size + draft_indexer_size
else:
self._cell_size = int(
self._cell_size * (1 + draft_num_layers / int(num_layers))
)
# DFLASH/DSPARK: reserve the draft runner's *actual* per-token KV cost.
# The draft allocates its own KV pool at the target's
# max_total_num_tokens, whose per-token footprint can differ from the
# target's (e.g. an MLA-latent target paired with a full per-head K/V
# draft), so size from the draft config rather than the layer ratio.
if kvc.spec_algorithm.is_dflash_family() and not kvc.is_draft_worker:
from sglang.srt.speculative.dflash_utils import (
scale_kv_cell_size_per_token_for_dflash,
)
draft_num_layers = kvc.spec_aux_config.dflash_draft_num_layers
if (
draft_num_layers is not None
and int(draft_num_layers) > 0
and int(num_layers) > 0
):
self._cell_size = scale_kv_cell_size_per_token_for_dflash(
target_cell_size_per_token=self._cell_size,
target_num_layers=int(num_layers),
draft_num_layers=int(draft_num_layers)
* get_parallel().attn_dcp_size,
draft_cell_size_per_token=_dflash_draft_cell_size(kvc) or None,
)
def _compute_cell_size(self, kvc: KVCacheConfigurator, num_layers: int) -> int:
"""Compute per-token KV cache cost in bytes. Subclasses can override."""
# args to config cell size
model_config = kvc.model_config
kv_cache_dtype = kvc.kv_cache_dtype
from sglang.srt.layers.cp.utils import (
get_glm_dsa_layer_split_effective_num_layers,
)
effective_num_layers = (
num_layers
if kvc.server_args.enable_hisparse
else get_glm_dsa_layer_split_effective_num_layers(kvc, num_layers)
)
kv_size = torch._utils._element_size(kv_cache_dtype)
tp_size = get_parallel().attn_tp_size
dcp_size = get_parallel().attn_dcp_size
if kvc.use_mla_backend:
from sglang.srt.mem_cache.kv_cache_configurator import (
calculate_mla_kv_cache_dim,
)
cell_size = (
calculate_mla_kv_cache_dim(
model_config=model_config,
kv_cache_dtype=kv_cache_dtype,
)
* effective_num_layers
* kv_size
)
if is_float4_e2m1fn_x2(kv_cache_dtype):
# kv_scale_buffer
scale_block_size = 16
cell_size = (cell_size // 2) + (
(
(model_config.kv_lora_rank + model_config.qk_rope_head_dim)
// scale_block_size
)
* effective_num_layers
* kv_size
)
# Add indexer KV cache overhead for DSA models (DeepSeek V3.2)
if is_deepseek_dsa(model_config.hf_config):
cell_size += self._compute_dsa_indexer_cell_size(
kvc=kvc,
num_layers=num_layers,
)
elif is_minimax_sparse(model_config.hf_config):
# Mirrors MiniMaxSparseKVPool: main pool (K+V all layers) + indexer pool
# (sparse-only, single-head; kv layers store K+V, k-only layers store K).
sparse_cfg = get_minimax_sparse_attention_config(model_config.hf_config)
dense_layer_ids, sparse_layer_ids = get_minimax_sparse_layer_ids(sparse_cfg)
indexer_k_only_layer_ids = set(
get_minimax_sparse_disable_value_layer_ids(sparse_cfg)
)
local_dense_layer_ids = [
l
for l in dense_layer_ids
if kvc.layer_info.start_layer <= l < kvc.layer_info.end_layer
]
local_sparse_layer_ids = [
l
for l in sparse_layer_ids
if kvc.layer_info.start_layer <= l < kvc.layer_info.end_layer
]
num_dense = len(local_dense_layer_ids)
num_sparse = len(local_sparse_layer_ids)
num_indexer_k_only = sum(
1 for l in local_sparse_layer_ids if l in indexer_k_only_layer_ids
)
num_indexer_kv = num_sparse - num_indexer_k_only
kv_heads = model_config.get_num_kv_heads(get_parallel().attn_tp_size)
head_dim = model_config.head_dim
indexer_head_dim = sparse_cfg["sparse_index_dim"]
indexer_dtype_size = torch._utils._element_size(kvc.model_dtype)
main_pool_bytes = (
(num_dense + num_sparse) * 2 * kv_heads * head_dim * kv_size
)
indexer_bytes = (
(num_indexer_kv * 2 + num_indexer_k_only)
* indexer_head_dim
* indexer_dtype_size
)
# FP4 scale buffer adjustment doesn't apply to MiniMax sparse:
# cell_size is already a sum over heterogeneous sub-pools.
return main_pool_bytes + indexer_bytes
else:
n = model_config.get_num_kv_heads(tp_size, dcp_size)
cell_size = (
n
* (model_config.head_dim + model_config.v_head_dim)
* effective_num_layers
* kv_size
)
if is_float4_e2m1fn_x2(kv_cache_dtype):
# kv_scale_buffer
scale_block_size = 16
k = model_config.head_dim
cell_size = (cell_size // 2) + (
(n * k * effective_num_layers * 2 * kv_size) // scale_block_size
)
# FP4 prefill uses one shared FP8 dequant workspace across layers.
cell_size += n * k * 2 * kv_size
elif self.kv_cache_dtype_str == "mxfp8":
scale_block_size = 32
cell_size += (
n * (model_config.head_dim + model_config.v_head_dim) * num_layers
) // scale_block_size
return cell_size
def _compute_dsa_indexer_cell_size(
self,
*,
kvc: KVCacheConfigurator,
num_layers: int,
allocate_all_layers: bool = False,
) -> int:
index_head_dim = get_dsa_index_head_dim(kvc.model_config.hf_config)
indexer_size_per_token = (
index_head_dim + index_head_dim // DSATokenToKVPool.quant_block_size * 4
)
element_size = torch._utils._element_size(
DSATokenToKVPool.index_k_with_scale_buffer_dtype
)
memory_config = get_memory()
indexer_ratio = 1
if memory_config.enable_hisparse:
from sglang.srt.mem_cache.sparsity import parse_hisparse_config
indexer_ratio = parse_hisparse_config().host_to_device_ratio
from sglang.srt.mem_cache.kv_cache_configurator import (
_should_elide_dsa_index_k,
)
if (
allocate_all_layers
or kvc.server_args.enable_hisparse
or not _should_elide_dsa_index_k(is_draft_worker=kvc.is_draft_worker)
):
num_indexer_layers = num_layers
else:
from sglang.srt.layers.cp.utils import (
get_glm_dsa_cp_layer_shard_info,
get_layer_shard_range,
)
_, shard_size = get_glm_dsa_cp_layer_shard_info(kvc)
if shard_size > 1:
# Preserve the existing LayerSplit sizing semantics. GLM-5.3
# hybrid-layer support is intentionally limited to the normal
# (non-LayerSplit) pool below.
active_indexer_layers = [
layer_id
for layer_id in range(
kvc.layer_info.start_layer, kvc.layer_info.end_layer
)
if not dsa_layer_skips_topk(kvc.model_config.hf_config, layer_id)
]
active_set = set(active_indexer_layers)
max_owned = 0
for rank in range(shard_size):
start, end = get_layer_shard_range(rank, shard_size, num_layers)
max_owned = max(
max_owned,
sum(
kvc.layer_info.start_layer + i in active_set
for i in range(start, end)
),
)
num_indexer_layers = max_owned + 1
else:
num_indexer_layers = sum(
not dsa_layer_skips_topk(kvc.model_config.hf_config, layer_id)
for layer_id in _get_dsa_cache_layer_ids(kvc, num_layers)
)
return int(
indexer_size_per_token * num_indexer_layers * element_size * indexer_ratio
)
def calculate_pool_sizes(
self, available_bytes: int, page_size: int
) -> MemoryPoolConfig:
max_total_num_tokens = (
available_bytes // self._cell_size
if self._cell_size
else self._zero_kv_max_tokens
)
max_total_num_tokens = max_total_num_tokens // page_size * page_size
return MemoryPoolConfig(max_total_num_tokens=max_total_num_tokens)
def calculate_pool_sizes_from_max_tokens(
self, max_total_num_tokens: int, page_size: int
) -> MemoryPoolConfig:
max_total_num_tokens = max_total_num_tokens // page_size * page_size
return MemoryPoolConfig(max_total_num_tokens=max_total_num_tokens)
class HybridSWAPoolConfigurator(MemoryPoolConfigurator):
"""Configurator for MHA or MLA models with sliding-window layers.
Splits available memory between full attention and SWA pools.
Does NOT inherit DefaultPoolConfigurator — different coeff model.
"""
def __init__(self, kvc: KVCacheConfigurator):
self.kv_cache_dtype_str = kvc.kv_cache_dtype_str
model_config = kvc.model_config
kv_cache_dtype = kvc.kv_cache_dtype
kv_size = torch._utils._element_size(kv_cache_dtype)
tp_size = get_parallel().attn_tp_size
self._full_layers_num = len(model_config.full_attention_layer_ids)
self._swa_layers_num = len(model_config.swa_attention_layer_ids)
assert self._swa_layers_num > 0, (
"Hybrid SWA model must have at least one SWA layer"
)
self._swa_full_tokens_ratio = get_schedule().swa_full_tokens_ratio
self._sliding_window_size = kvc.sliding_window_size
self._page_size = kvc.page_size
self._enable_unified_memory = get_memory().enable_unified_memory
if model_config.attention_arch == AttentionArch.MLA:
# MLA pool sizing uses latent dimensions rather than MHA heads.
from sglang.srt.mem_cache.kv_cache_configurator import (
calculate_mla_kv_cache_dim,
)
self._full_per_token = (
calculate_mla_kv_cache_dim(
model_config=model_config,
kv_cache_dtype=kv_cache_dtype,
)
* kv_size
)
if is_deepseek_dsa(model_config.hf_config):
index_head_dim = get_dsa_index_head_dim(model_config.hf_config)
index_elements = (
index_head_dim
+ index_head_dim // DSATokenToKVPool.quant_block_size * 4
)
self._full_per_token += index_elements * torch._utils._element_size(
DSATokenToKVPool.index_k_with_scale_buffer_dtype
)
self._swa_per_token = (
model_config.swa_kv_lora_rank + model_config.swa_qk_rope_head_dim
) * kv_size
else:
# Full layer per-token memory (bytes)
self._full_per_token = (
model_config.get_num_kv_heads(tp_size)
* (model_config.head_dim + model_config.v_head_dim)
* kv_size
)
# SWA layer per-token memory (bytes)
self._swa_per_token = (
model_config.get_swa_num_kv_heads(tp_size)
* (model_config.swa_head_dim + model_config.swa_v_head_dim)
* kv_size
)
if self.kv_cache_dtype_str == "mxfp8":
scale_block_size = 32
self._full_per_token += (
model_config.get_num_kv_heads(tp_size)
* (model_config.head_dim + model_config.v_head_dim)
) // scale_block_size
self._swa_per_token += (
model_config.get_swa_num_kv_heads(tp_size)
* (model_config.swa_head_dim + model_config.swa_v_head_dim)
) // scale_block_size
# Draft KV tensors use full, SWA, or full-capacity SWA geometry.
self._draft_full_layers_num = 0
self._draft_swa_layers_num = 0
self._draft_swa_full_layers_num = 0
if (
kvc.spec_algorithm.is_eagle() or kvc.spec_algorithm.is_standalone()
) and not kvc.is_draft_worker:
draft_layers = kvc.spec_aux_config.eagle_draft_num_layers
if draft_layers is not None and int(draft_layers) > 0:
draft_layers = int(draft_layers)
mtp_local_layer_ids = getattr(
getattr(model_config, "hf_text_config", None),
"mtp_local_layer_ids",
None,
)
if mtp_local_layer_ids is not None:
local_layer_ids = set(mtp_local_layer_ids)
self._draft_swa_full_layers_num = sum(
layer_id in local_layer_ids for layer_id in range(draft_layers)
)
else:
draft_swa_layers = kvc.spec_aux_config.eagle_draft_swa_num_layers
if draft_swa_layers is not None:
self._draft_swa_layers_num = min(
max(int(draft_swa_layers), 0), draft_layers
)
self._draft_full_layers_num = (
draft_layers
- self._draft_swa_layers_num
- self._draft_swa_full_layers_num
)
self._draft_cell_size = _dflash_draft_cell_size(kvc)
self._recompute_cell_size()
def _recompute_cell_size(self) -> None:
# Bytes per token of max_total_num_tokens.
#
# Hybrid (full_layers > 0): max_total = full_tokens, so cell_size accounts
# for both pools: F*nf + r*S*ns (where swa_tokens = full_tokens * r).
#
# All-SWA (full_layers == 0): max_total = swa_tokens directly. The ratio
# is meaningless here -- there is no full pool to relate to, and every
# token beyond the sliding window can be evicted. So cell_size = S*ns,
# with no ratio factor applied.
if self._full_layers_num == 0:
self._cell_size = (
self._swa_per_token * self._swa_layers_num
+ self._full_per_token * self._draft_full_layers_num
+ self._swa_per_token * self._draft_swa_layers_num
+ self._swa_per_token * self._draft_swa_full_layers_num
+ self._draft_cell_size
)
else:
self._cell_size = (
self._full_per_token
* (self._full_layers_num + self._draft_full_layers_num)
+ self._swa_per_token * self._draft_swa_full_layers_num
+ self._swa_full_tokens_ratio
* self._swa_per_token
* (self._swa_layers_num + self._draft_swa_layers_num)
+ self._draft_cell_size
)
def _draft_pool_bytes_per_token(self) -> int:
return int(
self._full_per_token * self._draft_full_layers_num
+ self._swa_per_token
* (self._draft_swa_layers_num + self._draft_swa_full_layers_num)
+ self._draft_cell_size
)
def _max_unified_full_tokens(
self,
available_bytes: int,
page_size: int,
fixed_swa_tokens: Optional[int] = None,
) -> int:
"""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:
full_tokens = full_pages * page_size
swa_tokens = (
fixed_swa_tokens
if fixed_swa_tokens is not None
else int(full_tokens * self._swa_full_tokens_ratio)
// page_size
* page_size
)
target_bytes = (
full_tokens * target_full_bytes_per_token
+ swa_tokens * target_swa_bytes_per_token
)
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
max_pages = available_bytes // target_full_bytes_per_token // page_size
full_pages = (
bisect_right(range(max_pages + 1), available_bytes, key=allocation_bytes)
- 1
)
return max(full_pages, 0) * page_size
def _solve_pool_sizes(
self, max_total_num_tokens: int, page_size: int
) -> MemoryPoolConfig:
"""Core computation: split max_total_num_tokens into full/swa pool sizes."""
def align_page_size(x: int) -> int:
return (x // page_size) * page_size
if self._full_layers_num == 0:
# All-SWA: no full pool, max_total = actual SWA pool size.
# Ratio is not applied -- see __init__ comment.
swa_tokens = align_page_size(max_total_num_tokens)
logger.info(
f"Use sliding window memory pool (all SWA). "
f"swa_layer_tokens={swa_tokens}"
)
return MemoryPoolConfig(
max_total_num_tokens=swa_tokens,
full_max_total_num_tokens=0,
swa_max_total_num_tokens=swa_tokens,
)
# Hybrid: full_tokens = max_total_num_tokens, swa_tokens = full_tokens * ratio
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
)
logger.info(
f"Use sliding window memory pool. "
f"full_layer_tokens={full_tokens}, swa_layer_tokens={swa_tokens}"
)
return MemoryPoolConfig(
max_total_num_tokens=full_tokens,
full_max_total_num_tokens=full_tokens,
swa_max_total_num_tokens=swa_tokens,
)
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
):
max_total_num_tokens = self._max_unified_full_tokens(
available_bytes, page_size
)
else:
max_total_num_tokens = int(available_bytes // self._cell_size)
return self._solve_pool_sizes(max_total_num_tokens, page_size)
def calculate_pool_sizes_from_max_tokens(
self, max_total_num_tokens: int, page_size: int
) -> MemoryPoolConfig:
return self._solve_pool_sizes(max_total_num_tokens, page_size)
class SWAChunkCapPoolConfigurator(HybridSWAPoolConfigurator):
"""Hybrid SWA configurator with the SWA pool sized from a fixed token cap.
When max_running_requests is explicit, the SWA pool's worst-case
footprint is bounded per request. The SWA pool is sized tightly from that
cap and the freed memory is redirected to the full pool, instead of sizing
both pools by swa_full_tokens_ratio.
"""
def __init__(self, kvc: KVCacheConfigurator):
self.kv_cache_dtype_str = kvc.kv_cache_dtype_str
super().__init__(kvc)
assert self._full_layers_num > 0
page_size = kvc.page_size
window = kvc.sliding_window_size
draft_tokens = get_spec().speculative_num_draft_tokens or 1
eviction_interval = max(1, envs.SGLANG_SWA_EVICTION_INTERVAL.get())
"""
__________[padding][eviction_interval][window]
Padding to make sure eviction point is page-aligned.
"""
trailing_tokens = window + eviction_interval * draft_tokens + page_size
if get_spec().speculative_algorithm is None:
decode_alloc = page_size
elif get_schedule().disable_overlap_schedule:
# spec-v1: new_tokens_required_next_decode per request.
decode_alloc = spec_decode_alloc_len_per_request(
page_size=page_size,
speculative_num_steps=get_spec().speculative_num_steps,
speculative_eagle_topk=get_spec().speculative_eagle_topk,
speculative_num_draft_tokens=get_spec().speculative_num_draft_tokens,
)
else:
# spec-v2: the overlap allocator keeps 2 * alloc_len outstanding
# (eagle_utils.eagle_prepare_for_decode: kv_committed_len + 2 * alloc_len).
decode_alloc = 2 * get_alloc_len_per_decode()
per_request = trailing_tokens + decode_alloc
num_reqs = get_schedule().max_running_requests // kvc.ps.attn_dp_size
if get_disagg().disaggregation_mode == "decode":
self._swa_cap = (
per_request * num_reqs
+ (window + page_size) * get_disagg().disaggregation_decode_extra_slots
)
else:
chunks_in_flight = 1 if get_schedule().disable_overlap_schedule else 2
self._swa_cap = (
per_request * num_reqs
+ chunks_in_flight * get_schedule().chunked_prefill_size
+ page_size
)
@staticmethod
def is_applicable(kvc: KVCacheConfigurator) -> bool:
"""True when SWAChunkCache can be sized from explicit max requests."""
if get_schedule().max_running_requests is None:
return False
if not get_memory().disable_radix_cache:
return False
if get_schedule().chunked_prefill_size is None:
return False
if kvc.sliding_window_size is None:
return False
return len(kvc.model_config.full_attention_layer_ids) > 0
def calculate_pool_sizes(
self, available_bytes: int, page_size: int
) -> MemoryPoolConfig:
# SWA pool sized tightly from the cap; the rest of the budget goes to full.
swa_tokens = ceil_align(self._swa_cap, page_size)
fixed_swa_bytes = (
swa_tokens
* 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:
full_tokens = self._max_unified_full_tokens(
available_bytes, page_size, fixed_swa_tokens=swa_tokens
)
else:
full_cell_size = (
self._full_per_token
* (self._full_layers_num + self._draft_full_layers_num)
+ self._swa_per_token * self._draft_swa_full_layers_num
)
full_tokens = (
int((available_bytes - fixed_swa_bytes) // full_cell_size) // page_size
) * page_size
if full_tokens <= 0:
raise RuntimeError(
f"SWA pool cap ({swa_tokens} tokens, "
f"{fixed_swa_bytes / (1 << 30):.2f} GiB) leaves no room for the full "
f"KV pool within the available {available_bytes / (1 << 30):.2f} GiB. "
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,
)
def calculate_pool_sizes_from_max_tokens(
self, max_total_num_tokens: int, page_size: int
) -> MemoryPoolConfig:
# 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),
)
@dataclass
class _DSV4PoolSizes:
full_max_total_num_tokens: int
swa_max_total_num_tokens: int
c4_max_total_num_tokens: int
c128_max_total_num_tokens: int
c4_state_pool_size: int
c128_state_pool_size: int
class DSV4PoolConfigurator(MemoryPoolConfigurator):
"""Configurator for DSV4 compressed-attention models.
Splits available memory across full / swa / c4 / c128 + c4_state / c128_state
pools. coeff is bytes_per_full_token (inflated by (T+D)/T when speculative
decode reserves a draft worker, mirroring dflash's cell_size scaling); bias = 0.
"""
def __init__(self, kvc: KVCacheConfigurator):
self.kv_cache_dtype_str = kvc.kv_cache_dtype_str
cfg = kvc.model_config
self.qk_nope_head_dim = cfg.qk_nope_head_dim
self.qk_rope_head_dim = cfg.qk_rope_head_dim
self.indexer_head_dim = cfg.index_head_dim
# HIP takes the FP4-accurate byte count here. The NVIDIA FP4 path
# keeps the FP8 estimate.
self.indexer_bytes_per_token = get_dsv4_indexer_bytes_per_token(
self.indexer_head_dim,
_is_hip and get_exec().kernel.enable_deepseek_v4_fp4_indexer,
)
self.context_len = kvc.model_config.context_len
# PP-local slice; matches DeepSeekV4TokenToKVPool's stage_ratios.
self.compression_ratios = cfg.compress_ratios[
kvc.layer_info.start_layer : kvc.layer_info.end_layer
]
if kvc.ps.pp_size > 1:
logger.info(
f"DSV4 pool PP slice: rank={kvc.pp_group.rank_in_group} "
f"layers=[{kvc.layer_info.start_layer},{kvc.layer_info.end_layer}) "
f"local={len(self.compression_ratios)}/{len(cfg.compress_ratios)}"
)
self.swa_page_size = cfg.window_size
self.sliding_window_size = kvc.sliding_window_size
self.swa_ratio = get_schedule().swa_full_tokens_ratio
self.is_speculative = get_spec().speculative_algorithm is not None
self.online_c128_mtp_max_draft_tokens = max_speculative_num_draft_tokens() or 0
self.requested_max_running_requests_per_worker = (
get_schedule().max_running_requests // kvc.ps.attn_dp_size
if get_schedule().max_running_requests is not None
else None
)
self.disaggregation_mode = get_disagg().disaggregation_mode
self.disaggregation_decode_extra_slots = (
get_disagg().disaggregation_decode_extra_slots or 0
)
if get_memory().enable_hisparse:
from sglang.srt.mem_cache.sparsity import parse_hisparse_config
self.c4_shrink_factor = parse_hisparse_config().host_to_device_ratio
else:
self.c4_shrink_factor = 1
assert self.c4_shrink_factor >= 1
if self.c4_shrink_factor > 1:
logger.info(f"HiSparse c4 host-to-device ratio = {self.c4_shrink_factor}")
self.c4_ring_size = get_compress_state_ring_size(4, self.is_speculative)
self.c128_ring_size = get_compress_state_ring_size(128, self.is_speculative)
self.num_layers_total = len(self.compression_ratios)
self.num_layers_ca4 = sum(1 for r in self.compression_ratios if r == 4)
self.num_layers_ca128 = sum(1 for r in self.compression_ratios if r == 128)
if self.is_speculative:
# Ring is sized once here, so it must serve the largest adaptive tier.
self._assert_ring_serves_draft_tokens(
max_speculative_num_draft_tokens() or 0
)
self.bytes_per_full_token = self._get_bytes_per_full_token()
if self.is_speculative:
# Reserve memory for the speculative draft worker by inflating
# per-token bytes by (target+draft)/target. Equivalent to dflash's
# scale_kv_cell_size_per_token_for_dflash but applied to
# bytes_per_full_token: tokens = avail / (bpft * (T+D)/T).
draft_layers = 1
target_layers = self.num_layers_total
self.bytes_per_full_token *= (target_layers + draft_layers) / target_layers
# Online c128 keeps a single in-progress (max, sum, kv) state per index
# and assumes a strict forward-only schedule. Speculative decode (MTP)
# would need rollback / replay across draft and verify, which the
# online path doesn't support yet.
if envs.SGLANG_OPT_USE_ONLINE_COMPRESS.get():
allow_experimental_online_c128_mtp = (
envs.SGLANG_EXPERIMENTAL_ONLINE_C128_MTP.get()
and kvc.spec_algorithm.is_eagle()
)
assert kvc.spec_algorithm.is_none() or allow_experimental_online_c128_mtp, (
"SGLANG_OPT_USE_ONLINE_COMPRESS does not support speculative decode "
"(MTP) yet, except the experimental EAGLE topk=1 path gated by "
"SGLANG_EXPERIMENTAL_ONLINE_C128_MTP=1"
)
if allow_experimental_online_c128_mtp:
assert self.online_c128_mtp_max_draft_tokens > 0, (
"SGLANG_EXPERIMENTAL_ONLINE_C128_MTP requires "
"speculative_num_draft_tokens to be set."
)
logger.warning(
"DSV4 compressed attention: experimental online c128 + MTP enabled "
f"(EAGLE topk=1 only, "
f"draft_banks={self.online_c128_mtp_max_draft_tokens}). "
"Validate correctness carefully."
)
else:
logger.info(
"DSV4 compressed attention: online c128 enabled (ring_size=1)"
)
def _assert_ring_serves_draft_tokens(self, num_draft_tokens: int) -> None:
"""A verify batch writes its whole optimistic tail into the ring, so ring
capacity bounds the draft count."""
for compress_ratio, ring_size, num_layers in (
(4, self.c4_ring_size, self.num_layers_ca4),
(128, self.c128_ring_size, self.num_layers_ca128),
):
if num_layers == 0:
continue
if compress_ratio == 128 and envs.SGLANG_OPT_USE_ONLINE_COMPRESS.get():
# Online c128 keeps per-draft state instead of a ring; sized separately.
continue
max_draft_tokens = get_compress_state_write_pad(compress_ratio, ring_size)
assert num_draft_tokens <= max_draft_tokens, (
f"speculative_num_draft_tokens={num_draft_tokens} exceeds what the c{compress_ratio} "
f"compress state ring can keep resident (ring_size={ring_size} serves at most "
f"{max_draft_tokens} draft tokens). Lower the draft count, or grow the ring in "
f"get_compress_state_ring_size()."
)
def _get_bytes_per_full_token(self) -> float:
kv_bytes = self.qk_nope_head_dim + self.qk_rope_head_dim * 2 + 8
attn_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
c4_state_dtype_size, c128_state_dtype_size = (
_get_dsv4_compress_state_dtype_sizes()
)
c4_state_bytes = 2 * 2 * attn_head_dim * c4_state_dtype_size
# Online c128 stores (max, sum, kv) per slot (3*head_dim) instead of
# raw (kv, score) (2*head_dim). Combined with ring_size=1 this still
# nets a large reduction (~3/256x) but the per-slot bytes go up.
c128_online = envs.SGLANG_OPT_USE_ONLINE_COMPRESS.get()
c128_state_bytes = (
(3 if c128_online else 2 * 1) * attn_head_dim * c128_state_dtype_size
)
c4_indexer_state_bytes = 2 * 2 * self.indexer_head_dim * c4_state_dtype_size
c4_state_ratio = self.c4_ring_size / self.swa_page_size
# C128 state is request-scoped and is finalized after
# max_running_requests is known, so it should not scale with
# full-token capacity here.
c128_state_ratio = 0
c4_frac = 1 / (4 * self.c4_shrink_factor)
return (
self.swa_ratio * kv_bytes * self.num_layers_total
+ c4_frac * kv_bytes * self.num_layers_ca4
+ 1 / 128 * kv_bytes * self.num_layers_ca128
+ 1 / 4 * self.indexer_bytes_per_token * self.num_layers_ca4
+ self.swa_ratio * c4_state_ratio * c4_state_bytes * self.num_layers_ca4
+ c128_state_ratio * c128_state_bytes * self.num_layers_ca128
+ self.swa_ratio
* c4_state_ratio
* c4_indexer_state_bytes
* self.num_layers_ca4
)
def _compute_dsv4_sizes(self, full_token: int, page_size: int) -> _DSV4PoolSizes:
full_token = full_token // page_size * page_size
swa_tokens = int(full_token * self.swa_ratio) // page_size * page_size
self.validate_swa_pool_size(swa_tokens, self.sliding_window_size, page_size)
return _DSV4PoolSizes(
full_max_total_num_tokens=full_token,
swa_max_total_num_tokens=swa_tokens,
c4_max_total_num_tokens=full_token // (4 * self.c4_shrink_factor),
c128_max_total_num_tokens=full_token // 128,
c4_state_pool_size=swa_tokens // self.swa_page_size * self.c4_ring_size,
c128_state_pool_size=0,
)
def _get_num_req_slots(self, max_running_requests: int) -> int:
if self.disaggregation_mode == "decode":
return max_running_requests + self.disaggregation_decode_extra_slots + 1
return max_running_requests + 1
def _get_c128_state_fixed_bytes(self, max_running_requests: int) -> int:
if self.num_layers_ca128 == 0:
return 0
_, c128_state_dtype_size = _get_dsv4_compress_state_dtype_sizes()
attn_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
num_req_slots = self._get_num_req_slots(max_running_requests)
if envs.SGLANG_OPT_USE_ONLINE_COMPRESS.get():
state_rows = num_req_slots + self.c128_ring_size + 1
state_rows *= 1 + self.online_c128_mtp_max_draft_tokens
state_last_dim = 3 * attn_head_dim
else:
state_pool_size = num_req_slots * self.c128_ring_size
state_rows = state_pool_size + self.c128_ring_size + 1
state_rows = ceil_div(state_rows, 128) * 128
state_last_dim = 2 * attn_head_dim
return (
state_rows * state_last_dim * c128_state_dtype_size * self.num_layers_ca128
)
def _get_c128_state_fixed_bytes_for_token_capacity(
self, token_capacity: int
) -> int:
if self.requested_max_running_requests_per_worker is not None:
return self._get_c128_state_fixed_bytes(
self.requested_max_running_requests_per_worker
)
estimated = int(token_capacity / self.context_len * 512)
estimated = max(min(estimated, 4096), 2048)
max_running_requests = min(estimated, token_capacity // 2)
return self._get_c128_state_fixed_bytes(max_running_requests)
def _to_config(self, sizes: _DSV4PoolSizes) -> MemoryPoolConfig:
full = sizes.full_max_total_num_tokens
swa = sizes.swa_max_total_num_tokens
logger.info(
f"DSV4 pool sizes: full={full}, swa={swa}, "
f"c4={sizes.c4_max_total_num_tokens}, "
f"c128={sizes.c128_max_total_num_tokens}, "
f"c4_state={sizes.c4_state_pool_size}, "
f"c128_state={sizes.c128_state_pool_size}"
)
return MemoryPoolConfig(
max_total_num_tokens=full,
full_max_total_num_tokens=full,
swa_max_total_num_tokens=swa,
c4_max_total_num_tokens=sizes.c4_max_total_num_tokens,
c128_max_total_num_tokens=sizes.c128_max_total_num_tokens,
c4_state_pool_size=sizes.c4_state_pool_size,
c128_state_pool_size=sizes.c128_state_pool_size,
)
def finalize_with_max_running_requests(
self, config: MemoryPoolConfig
) -> MemoryPoolConfig:
assert config.max_running_requests is not None
num_req_slots = self._get_num_req_slots(config.max_running_requests)
if envs.SGLANG_OPT_USE_ONLINE_COMPRESS.get():
config.c128_state_pool_size = num_req_slots
else:
config.c128_state_pool_size = num_req_slots * self.c128_ring_size
return config
def calculate_pool_sizes(
self, available_bytes: int, page_size: int
) -> MemoryPoolConfig:
assert page_size % 128 == 0, (
"page_size must be multiple of 128 for compressed attention"
)
if self.requested_max_running_requests_per_worker is not None:
c128_state_fixed_bytes = self._get_c128_state_fixed_bytes(
self.requested_max_running_requests_per_worker
)
else:
full_token = int(available_bytes / self.bytes_per_full_token)
c128_state_fixed_bytes = (
self._get_c128_state_fixed_bytes_for_token_capacity(full_token)
)
available_bytes_for_tokens = max(available_bytes - c128_state_fixed_bytes, 0)
full_token = int(available_bytes_for_tokens / self.bytes_per_full_token)
sizes = self._compute_dsv4_sizes(full_token, page_size)
logger.info(
f"DSV4 memory calculation: "
f"bytes_per_full_token={self.bytes_per_full_token:.2f}, "
f"available_bytes={available_bytes / (1 << 30):.2f} GB, "
f"c128_state_fixed={c128_state_fixed_bytes / (1 << 30):.2f} GB, "
f"full_token={sizes.full_max_total_num_tokens}"
)
return self._to_config(sizes)
def calculate_pool_sizes_from_max_tokens(
self, max_total_num_tokens: int, page_size: int
) -> MemoryPoolConfig:
assert page_size % 128 == 0, (
"page_size must be multiple of 128 for compressed attention"
)
sizes = self._compute_dsv4_sizes(max_total_num_tokens, page_size)
return self._to_config(sizes)
def create_memory_pool_configurator(
kvc: KVCacheConfigurator,
) -> MemoryPoolConfigurator:
"""Factory: select the right configurator for the model architecture."""
if is_deepseek_v4(kvc.model_config.hf_config) and kvc.is_hybrid_swa:
return DSV4PoolConfigurator(kvc)
if kvc.is_hybrid_swa:
if SWAChunkCapPoolConfigurator.is_applicable(kvc):
return SWAChunkCapPoolConfigurator(kvc)
return HybridSWAPoolConfigurator(kvc)
# Future: MambaPoolConfigurator
return DefaultPoolConfigurator(kvc)