Skip to content
Closed
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
d5e5e7a
Support unified memory decode host pools
Aug 27, 2026
705f648
Fix unified draft retraction indices
Aug 28, 2026
f37d710
Simplify unified decode host allocation
Aug 28, 2026
1b75db1
Gate unsupported unified hybrid host transfers
Sep 2, 2026
28962eb
Merge updated PD page-envelope branch into decode host pool
ZYHowell Sep 2, 2026
bdb7287
Merge updated UMP PD stack into host pool
Sep 2, 2026
45b4663
Merge restored eviction policy into decode host pool
Sep 2, 2026
43ebdb4
Merge joint capacity accounting into decode host pool
Sep 2, 2026
db64894
Refactor unified host pool allocation
Sep 4, 2026
d3e06da
Reject unsafe unified decode host paths
Sep 4, 2026
3ef84f1
Merge transfer-index fixes into decode host pool
Sep 4, 2026
372d211
Merge commit '7fadf8efc0' into yonghao/ump-decode-host-pool
Sep 4, 2026
ec0e777
Merge commit '25d1e3bc64d22be2ed3665e033a2218a1f7cbce2' into yonghao/…
Sep 4, 2026
b0018de
Update decode offload test fixtures for sidecars
Sep 4, 2026
6f9a8f2
Merge capacity fixes and guard shared host-layout access
Sep 8, 2026
d9ce0bb
Merge UMP cleanup into shared host pool
Sep 8, 2026
a3a32f2
Merge PD refresh and reconcile shared host pool with upstream UMBP
Sep 10, 2026
35519a8
Merge Python 3.10 fixture compatibility fix from PD envelopes
Sep 10, 2026
7a71192
Merge PD cleanup into decode host pool
Sep 10, 2026
b7c0f51
Fix L2 producer ordering and host element metadata
Sep 12, 2026
f7cda39
Merge PD shared-capacity cleanup
Sep 13, 2026
415c3bc
Merge decode reservation cleanup
Sep 13, 2026
759b428
Unify host transfer lifecycle and index preparation
Sep 13, 2026
3b85f00
Merge shared SWA budget cleanup
Sep 13, 2026
4ed9263
Merge PD fixes and use physical SWA offload indices
Sep 13, 2026
1e3a2c4
Merge unified admission and PD allocation review cleanup
Sep 13, 2026
39a7bcc
Merge unified prefill rounding fix into host pool
Sep 14, 2026
1ff144d
Unify host-pool allocation and L2 transfer lifecycles
Sep 14, 2026
a8e0085
Merge reviewed PD cleanup into host-pool layer
Sep 14, 2026
5e5433a
Merge updated UMP PD support into unified host pools
Sep 14, 2026
f7f0741
Update staged transfer fixtures for host-pool hooks
Sep 14, 2026
eb907b6
Merge PD updates and unify host layout and write interfaces
Sep 14, 2026
e6a273f
Use declared HiCache transfer capability fields
Sep 14, 2026
bd62e85
Merge shared KV budget fixes into unified host pool
Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions python/sglang/srt/arg_groups/kv_cache_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
resolving_view,
use_mla_backend,
)
from sglang.srt.configs.hybrid_arch import mambaish_config
from sglang.srt.environ import envs
from sglang.srt.model_executor.cuda_graph_config import Backend
from sglang.srt.runtime_context import get_platform
Expand Down Expand Up @@ -162,6 +163,16 @@ def handle_cache_compatibility(server_args: Any) -> None:
"--disaggregation-decode-retraction-backup=host_pool requires "
"--disable-priority-preemption when priority scheduling is enabled."
)
if (
cfg.disaggregation_decode_retraction_backup == "host_pool"
and cfg.disaggregation_decode_enable_radix_cache
and cfg.hicache_write_policy in ("write_through", "write_through_selective")
):
raise ValueError(
"--disaggregation-decode-retraction-backup=host_pool does not "
"support decode radix cache with a write-through HiCache policy. "
Comment thread
ZYHowell marked this conversation as resolved.
Resolved
"Use --hicache-write-policy=write_back or disable decode radix cache."
)

if cfg.enable_hierarchical_cache and cfg.disable_radix_cache:
raise ValueError(
Expand Down Expand Up @@ -230,15 +241,38 @@ def handle_unified_memory_pool(server_args: Any) -> None:
"ships host/C4 rows straight from the allocator, bypassing the "
"virtual->physical translation the unified pool needs."
)
assert cfg.disaggregation_decode_retraction_backup != "host_pool", (
"--enable-unified-memory with PD disaggregation does not support "
"--disaggregation-decode-retraction-backup=host_pool; use "
"cpu_tensor (the automatic default for unified pools)."
)
assert not cfg.disaggregation_decode_enable_offload_kvcache, (
"--enable-unified-memory with PD disaggregation does not yet support "
"--disaggregation-decode-enable-offload-kvcache."
)
if cfg.disaggregation_decode_enable_offload_kvcache:
assert cfg.hicache_storage_backend == "file", (
"--enable-unified-memory with decode KV offload currently "
"supports --hicache-storage-backend=file only."
)
model_config = model_config_of(server_args)
assert not use_mla_backend(server_args), (
"--enable-unified-memory decode KV offload does not support "
"MLA models yet."
)
assert mambaish_config(model_config) is None, (
"--enable-unified-memory decode KV offload does not support "
"hybrid-Mamba models."
)
assert not model_config.is_hybrid_swa, (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug][P1] These gates and the pool factory admit disjoint model sets, so neither new flag can be enabled. Both new blocks assert not use_mla_backend(...), mambaish_config(model_config) is None and not model_config.is_hybrid_swa. KVCacheConfigurator._init_pools raises unless the model is hybrid-Mamba or hybrid-SWA ("--enable-unified-memory only supports hybrid Mamba and hybrid sliding-window-attention models"), so every model fails one side or the other: a hybrid model trips these asserts, a non-hybrid model trips the configurator. The resolution path agrees -- kv_cache_builder computes supports_host_pool = unified_draft_host_pool_supported and not unified_hybrid_swa and not uses_ssm_state(...), which is unconditionally False under --enable-unified-memory. The result is that pool_host/unified.py, the UnifiedSWAKVPool branch of DecodeKVCacheOffloadManager, build_hybrid_swa_pool_pair and the shared-arena reclaim machinery are reachable only from unit tests that construct the pools directly; there is no configuration in which the feature runs.

Suggestion: Drop the is_hybrid_swa assert from both blocks (that is the case the code is written for) and fix the hybrid-SWA path -- see the translate_loc_from_full_to_swa and index-filter comments, which are exactly the bugs the gate is currently hiding. If hybrid-SWA is genuinely out of scope for this PR, then the SWA branches and the shared-arena machinery should not land yet either.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reachability analysis is correct: these UMP host-pool retraction/offload flags are still intentionally unavailable, and this PR should not be described as enabling them end to end. I am keeping the safety gates in this update. Fixing one physical-index call is not sufficient evidence to enable the whole lifecycle; ordinary UMP PD retraction continues to use cpu_tensor.

#37496 separately integrates the shared host arena with hybrid-SWA HiCache backup/load-back. That does not automatically qualify these decode flags. The scope/staging concern remains open; I am not marking it resolved or removing the gates based only on unit coverage.

"--enable-unified-memory decode KV offload does not support "
"hybrid-SWA H2D/D2H transfers yet."
)
if cfg.disaggregation_decode_retraction_backup == "host_pool":
model_config = model_config_of(server_args)
assert not cfg.disaggregation_decode_enable_radix_cache, (
"--enable-unified-memory host-pool decode retraction does not "
"support decode radix-cache H2D/D2H transfers yet."
)
assert mambaish_config(model_config) is None, (
"--enable-unified-memory host-pool decode retraction does not "
"support hybrid-Mamba models."
)
assert not model_config.is_hybrid_swa, (
"--enable-unified-memory host-pool decode retraction does not "
"support hybrid-SWA H2D/D2H transfers yet."
)
assert cfg.speculative_algorithm in (None, "DSPARK"), (
"--enable-unified-memory only supports --speculative-algorithm "
"DSPARK (chain draft); other speculative algorithms are not yet "
Expand Down
149 changes: 123 additions & 26 deletions python/sglang/srt/disaggregation/decode_kvcache_offload_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,22 @@
from sglang.srt.managers.cache_controller import HiCacheController
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
from sglang.srt.mem_cache.hicache_storage import (
PoolHitPolicy,
PoolName,
PoolTransfer,
)
from sglang.srt.mem_cache.hybrid_cache.hybrid_pool_assembler import (
build_hybrid_swa_stack,
build_kv_host_pool,
)
from sglang.srt.mem_cache.memory_pool import (
MHATokenToKVPool,
MLATokenToKVPool,
ReqToTokenPool,
)
from sglang.srt.mem_cache.unified_memory_pool import UnifiedSWAKVPool
from sglang.srt.runtime_context import (
get_memory,
get_schedule,
Expand Down Expand Up @@ -56,15 +64,6 @@ def __init__(
self.offload_stride = max(
self.page_size, (env_stride // self.page_size) * self.page_size
)
kv_cache = self.token_to_kv_pool_allocator.get_kvcache()
if not isinstance(kv_cache, (MHATokenToKVPool, MLATokenToKVPool)):
raise ValueError("Unsupported KV cache type for decode offload")
self.decode_host_mem_pool = build_kv_host_pool(
kv_pool=kv_cache,
page_size=self.page_size,
use_mla=isinstance(kv_cache, MLATokenToKVPool),
)

self.tp_group = tp_group
self.tp_world_size = torch.distributed.get_world_size(group=self.tp_group)

Expand All @@ -79,17 +78,56 @@ def __init__(
f"Invalid hicache storage backend extra config JSON: {e}"
)

self.cache_controller = HiCacheController(
token_to_kv_pool_allocator=self.token_to_kv_pool_allocator,
mem_pool_host=self.decode_host_mem_pool,
page_size=self.page_size,
tp_group=tp_group,
io_backend=get_memory().hicache_io_backend,
load_cache_event=threading.Event(),
storage_backend=get_memory().hicache_storage_backend,
model_name=get_serving().served_model_name,
storage_backend_extra_config=hicache_storage_backend_extra_config,
)
self.kv_cache = self.token_to_kv_pool_allocator.get_kvcache()
if isinstance(self.kv_cache, UnifiedSWAKVPool):
full_mapping = {
gid: lid
for gid, (lid, is_swa) in self.kv_cache.layers_mapping.items()
if not is_swa
}
swa_mapping = {
gid: lid
for gid, (lid, is_swa) in self.kv_cache.layers_mapping.items()
if is_swa
}
params = CacheInitParams(
disable=False,
req_to_token_pool=self.req_to_token_pool,
token_to_kv_pool_allocator=self.token_to_kv_pool_allocator,
page_size=self.page_size,
tp_cache_group=tp_group,
)
self.decode_host_mem_pool, self.cache_controller = build_hybrid_swa_stack(
params=params,
full_kv_pool=self.kv_cache.full_kv_pool,
swa_kv_pool=self.kv_cache.swa_kv_pool,
full_layer_mapping=full_mapping,
swa_layer_mapping=swa_mapping,
load_cache_event=threading.Event(),
storage_backend=get_memory().hicache_storage_backend,
use_mla=False,
model_name=get_serving().served_model_name,
storage_backend_extra_config=hicache_storage_backend_extra_config,
)
else:
if not isinstance(self.kv_cache, (MHATokenToKVPool, MLATokenToKVPool)):
raise ValueError("Unsupported KV cache type for decode offload")
self.decode_host_mem_pool = build_kv_host_pool(
Comment thread
ZYHowell marked this conversation as resolved.
Resolved
kv_pool=self.kv_cache,
page_size=self.page_size,
use_mla=isinstance(self.kv_cache, MLATokenToKVPool),
)
self.cache_controller = HiCacheController(
token_to_kv_pool_allocator=self.token_to_kv_pool_allocator,
mem_pool_host=self.decode_host_mem_pool,
page_size=self.page_size,
tp_group=tp_group,
io_backend=get_memory().hicache_io_backend,
load_cache_event=threading.Event(),
storage_backend=get_memory().hicache_storage_backend,
model_name=get_serving().served_model_name,
storage_backend_extra_config=hicache_storage_backend_extra_config,
)

self.ongoing_offload = {}
self.ongoing_backup = {}
Expand Down Expand Up @@ -119,6 +157,33 @@ def _prefill_offloaded_len(self, req: Req) -> int:
# Page-aligned prompt length; the prefill instance offloaded this part.
return len(req.origin_input_ids) // self.page_size * self.page_size

def has_inflight_device_transfer(self) -> bool:
"""Whether a device-to-host copy may still read unified KV pages."""
return bool(self.ongoing_offload)

def _resolve_offload_transfers(
self, virtual_indices: torch.Tensor
) -> tuple[torch.Tensor, list[PoolTransfer]]:
full_indices = (
self.token_to_kv_pool_allocator.translate_kv_indices_for_transfer(
virtual_indices
)
)
if not isinstance(self.kv_cache, UnifiedSWAKVPool):
return full_indices, []

swa_indices = self.kv_cache.translate_loc_from_full_to_swa(virtual_indices)
Comment thread
ZYHowell marked this conversation as resolved.
Resolved
live_swa_indices = swa_indices[swa_indices > 0].to(torch.int64)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] The boolean filter yields a transfer length that is not a multiple of the page size. swa_indices[swa_indices > 0] drops arbitrary positions, so live_swa_indices.numel() is unrelated to page_size. That tensor becomes PoolTransfer.device_indices, and HostPoolGroup allocates with alloc(len(transfer.device_indices)), whose _request_page_counts asserts "The requested size should be a multiple of the page size"; _to_page_indices rejects it as well. Concretely, page_size=4 with 10 of 12 window tokens mapped gives numel() == 10 and aborts. Separately, > 0 rather than >= 0 silently discards physical slot 0; that happens to be the reserved sink here, but the intent is not stated anywhere.

Suggestion: Select whole pages -- take the trailing page-aligned run of bound indices (or reshape to (-1, page_size) and keep rows that are fully bound) -- so the count is page-aligned by construction. Comment the slot-0 exclusion, or compare against the sink explicitly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proposed 10-of-12 mapping is not produced by this allocator/caller contract. Offload start/length and stride are page-aligned, and SWA bindings/tombstones are per virtual page, not per token. A mapped physical page is entirely above the reserved sink page; an unmapped page is entirely filtered out. Thus this filter removes whole pages on valid input. Physical slot 0 is reserved, not live KV. Aligned caller.

The new real-page regression covers 0, 4 and 12 tombstoned tokens out of 12 at page size 4, including the all-unbound case. I did not add arbitrary partial-row recovery, which would mask a broken page-binding invariant. An explicit sink/invariant comment would still be a reasonable clarity improvement.

if live_swa_indices.numel() == 0:
return full_indices, []
return full_indices, [
PoolTransfer(
name=PoolName.SWA,
device_indices=live_swa_indices,
hit_policy=PoolHitPolicy.TRAILING_PAGES,
)
]

def offload_kv_cache(self, req) -> bool:
"""Offload incremental KV cache for decode side."""

Expand Down Expand Up @@ -158,7 +223,10 @@ def offload_kv_cache(self, req) -> bool:
start = prefill_offloaded_len + state.inc_len
end = start + incremental_aligned_len
incremental_tokens = all_tokens[start:end]
incremental_indices = token_indices[start:end]
incremental_indices = token_indices[start:end].to(torch.int64)
device_indices, pool_transfers = self._resolve_offload_transfers(
incremental_indices
)

# Prefill-aligned GPU slots are freed at request finish in
# _release_finished_req, NOT here. The decoding request
Expand All @@ -170,8 +238,9 @@ def offload_kv_cache(self, req) -> bool:
self.request_counter += 1
ack_id = self.request_counter
host_indices = self.cache_controller.write(
device_indices=incremental_indices.long(),
device_indices=device_indices,
node_id=ack_id,
**({"extra_pools": pool_transfers} if pool_transfers else {}),
)
if host_indices is None:
logger.error(f"Not enough host memory for request {req.rid}")
Expand All @@ -183,6 +252,7 @@ def offload_kv_cache(self, req) -> bool:
host_indices,
incremental_tokens,
time.time(),
pool_transfers,
)
state.inc_len += incremental_aligned_len
return True
Expand Down Expand Up @@ -218,6 +288,7 @@ def _check_offload_progress(self, finish_count):
host_indices,
incremental_tokens,
start_time,
pool_transfers,
) = self.ongoing_offload.pop(ack_id)

self._mark_offload_finished(req)
Expand All @@ -227,7 +298,12 @@ def _check_offload_progress(self, finish_count):
else None
)
last_hash = self._trigger_backup(
req, host_indices, incremental_tokens, start_time, prior_hash
req,
host_indices,
incremental_tokens,
start_time,
prior_hash,
pool_transfers,
)
if req in self.offloaded_state:
self.offloaded_state[req].last_hash = last_hash
Expand Down Expand Up @@ -258,26 +334,47 @@ def _check_backup_progress(self, finish_count):
for _ in range(finish_count):
storage_operation = self.cache_controller.ack_backup_queue.get()
ack_id = storage_operation.id
req_id, host_indices, start_time = self.ongoing_backup.pop(ack_id)
req_id, host_indices, start_time, pool_transfers = self.ongoing_backup.pop(
ack_id
)

# Release host memory
for transfer in pool_transfers:
self.decode_host_mem_pool.get_pool(transfer.name).free(
transfer.host_indices
)
self.decode_host_mem_pool.free(host_indices)

logger.debug(
f"Finished backup request {req_id}, free host memory, len:{len(host_indices)}, cost time:{time.time() - start_time:.2f} seconds."
)

def _trigger_backup(
self, req, host_indices, incremental_tokens, start_time, prior_hash
self,
req,
host_indices,
incremental_tokens,
start_time,
prior_hash,
pool_transfers=(),
):
"""Trigger async backup from host to storage."""
page_hashes = self._compute_prefix_hash(incremental_tokens, prior_hash)
for transfer in pool_transfers:
page_count = transfer.device_indices.numel() // self.page_size
transfer.keys = page_hashes[-page_count:]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] page_hashes[-0:] is the whole list, not the empty one. When page_count is 0 -- reachable whenever the live index count is below one page, which the unaligned filter above makes easy -- page_hashes[-page_count:] evaluates to page_hashes[:], so the extra pool is backed up under every key in the chunk instead of none. The failure is silent: the transfer succeeds and the sidecar is populated with wrong-keyed entries that later read back as hits.

Suggestion: Guard the zero case explicitly: transfer.keys = page_hashes[-page_count:] if page_count else [].

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct about Python's [-0:] semantics, but the stated zero-page transfer does not reach this loop on the valid path. _resolve_offload_transfers returns no SWA transfer when all indices are unbound; otherwise the page-binding and aligned-chunk invariant makes the nonempty count at least one whole page. A malformed sub-page transfer is rejected by host allocation/index preparation before storage backup, not silently stored under every key.

The all-tombstoned case is covered by the new transfer regression and returns an empty transfer list. I have not added the defensive slice guard because the reported reachable silent-corruption path was not established. Early return.

ack_id = self.cache_controller.write_storage(
host_indices,
incremental_tokens,
hash_value=page_hashes,
**({"extra_pools": pool_transfers} if pool_transfers else {}),
)
self.ongoing_backup[ack_id] = (
req.rid,
host_indices,
start_time,
pool_transfers,
)
self.ongoing_backup[ack_id] = (req.rid, host_indices, start_time)
return page_hashes[-1] if len(page_hashes) > 0 else prior_hash

def _compute_prefix_hash(self, tokens, prior_hash=""):
Expand Down
5 changes: 5 additions & 0 deletions python/sglang/srt/disaggregation/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,14 @@ def prefill_gate() -> bool:
if scheduler.disaggregation_mode == DisaggregationMode.DECODE:

def decode_gate() -> bool:
Comment thread
ZYHowell marked this conversation as resolved.
Resolved
decode_offload_manager = scheduler.decode_offload_manager
return not (
scheduler.disagg_decode_transfer_queue.queue
or scheduler.disagg_decode_prealloc_queue.has_published_destinations
or (
decode_offload_manager is not None
and decode_offload_manager.has_inflight_device_transfer()
Comment thread
ZYHowell marked this conversation as resolved.
Resolved
)
)

return decode_gate
Expand Down
Loading
Loading