refactor(hicache): flatten L2 transfer execution (#34793)

GB300 test fails unrelated
This commit is contained in:
cctry
2026-08-16 00:33:34 -07:00
committed by GitHub
parent 56a759cffc
commit 8922bb98e2
10 changed files with 767 additions and 648 deletions
@@ -13,14 +13,14 @@ from sglang.srt.environ import envs
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.hybrid_cache.hybrid_pool_assembler import (
build_kv_host_pool,
)
from sglang.srt.mem_cache.memory_pool import (
MHATokenToKVPool,
MLATokenToKVPool,
ReqToTokenPool,
)
from sglang.srt.mem_cache.pool_host.common import get_allocator_type
from sglang.srt.mem_cache.pool_host.mha import get_mha_host_pool_cls
from sglang.srt.mem_cache.pool_host.mla import MLATokenToKVPoolHost
from sglang.srt.runtime_context import get_schedule
from sglang.srt.server_args import ServerArgs
from sglang.srt.utils.common import ceil_align
@@ -55,28 +55,14 @@ class DecodeKVCacheOffloadManager:
self.page_size, (env_stride // self.page_size) * self.page_size
)
kv_cache = self.token_to_kv_pool_allocator.get_kvcache()
allocator_type = get_allocator_type(server_args)
if isinstance(kv_cache, MHATokenToKVPool):
self.decode_host_mem_pool = get_mha_host_pool_cls(kv_cache)(
kv_cache,
server_args.hicache_ratio,
server_args.hicache_size,
self.page_size,
server_args.hicache_mem_layout,
allocator_type=allocator_type,
)
elif isinstance(kv_cache, MLATokenToKVPool):
self.decode_host_mem_pool = MLATokenToKVPoolHost(
kv_cache,
server_args.hicache_ratio,
server_args.hicache_size,
self.page_size,
server_args.hicache_mem_layout,
allocator_type=allocator_type,
)
else:
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,
server_args=server_args,
use_mla=isinstance(kv_cache, MLATokenToKVPool),
)
self.tp_group = tp_group
self.tp_world_size = torch.distributed.get_world_size(group=self.tp_group)
+116 -111
View File
@@ -16,7 +16,6 @@ limitations under the License.
import logging
import threading
import time
from functools import cache
from queue import Empty, Queue
from typing import TYPE_CHECKING, List, NamedTuple, Optional
@@ -38,6 +37,7 @@ from sglang.srt.layers.dp_attention import (
get_attention_dp_rank,
is_dp_attention_enabled,
)
from sglang.srt.mem_cache.l2_transfer import L2Transfer, L2TransferEngine
from sglang.srt.mem_cache.memory_pool import MLATokenToKVPool
from sglang.srt.runtime_context import get_parallel
from sglang.srt.utils import get_device_module
@@ -47,26 +47,6 @@ logger = logging.getLogger(__name__)
device_module = get_device_module()
@cache
def _timing_events_supported() -> bool:
try:
device_module.Event(enable_timing=True)
return True
except (TypeError, NotImplementedError):
logger.warning(
"%s.Event does not support enable_timing=True; load-back "
"duration metric will be skipped on this backend.",
device_module.__name__,
)
return False
def make_timing_event_pair():
timing_enabled = _timing_events_supported()
kwargs = {"enable_timing": True} if timing_enabled else {}
return device_module.Event(**kwargs), device_module.Event(**kwargs), timing_enabled
class LayerLoadingEvent:
def __init__(self, num_layers: int):
self._num_layers = num_layers
@@ -126,30 +106,66 @@ class CacheOperation:
device_indices: torch.Tensor,
node_id: int,
priority: Optional[int] = None,
pool_transfers: Optional[List[PoolTransfer]] = None,
):
self.host_indices = host_indices
self.device_indices = device_indices
self.node_ids = [node_id]
self.data = None
self.pool_transfers = pool_transfers
self.id = CacheOperation.counter
CacheOperation.counter += 1
# default priority is the order of creation
self.priority = priority if priority is not None else self.id
@staticmethod
def _merge_pool_transfers(
ops: List[CacheOperation],
) -> Optional[List[PoolTransfer]]:
grouped: dict[tuple[PoolName, Optional[PoolName]], List[PoolTransfer]] = {}
for op in ops:
for transfer in op.pool_transfers or []:
grouped.setdefault(
(transfer.name, transfer.indices_from_pool), []
).append(transfer)
if not grouped:
return None
def cat_or_none(tensors):
parts = [tensor for tensor in tensors if tensor is not None]
return torch.cat(parts) if parts else None
return [
PoolTransfer(
name=transfers[0].name,
host_indices=cat_or_none(t.host_indices for t in transfers),
device_indices=cat_or_none(t.device_indices for t in transfers),
keys=[key for t in transfers if t.keys for key in t.keys] or None,
hit_policy=transfers[0].hit_policy,
indices_from_pool=transfers[0].indices_from_pool,
)
for transfers in grouped.values()
]
@staticmethod
def merge_ops(ops: List[CacheOperation]) -> CacheOperation:
assert len(ops) > 0
assert ops
if len(ops) == 1:
return ops[0]
host_indices = torch.cat([op.host_indices for op in ops])
device_indices = torch.cat([op.device_indices for op in ops])
node_ids = []
priority = min(op.priority for op in ops)
for op in ops:
node_ids.extend(op.node_ids)
merged_op = CacheOperation(host_indices, device_indices, -1, priority)
merged_op = CacheOperation(
host_indices,
device_indices,
-1,
priority,
pool_transfers=CacheOperation._merge_pool_transfers(ops),
)
merged_op.node_ids = node_ids
return merged_op
@@ -302,8 +318,7 @@ class HiCacheController:
self.ack_load_queue: List[HiCacheAck] = []
self.ack_write_queue: List[HiCacheAck] = []
self.write_stream = device_module.Stream()
self.load_stream = device_module.Stream()
self.l2_transfer_engine = L2TransferEngine(io_backend)
# If a storage backend is provided at startup, treat it as an implicit attach,
# so init/runtime share the same lifecycle semantics and code paths.
@@ -698,60 +713,21 @@ class HiCacheController:
return
op = CacheOperation.merge_ops(self.write_queue)
# Kernel write-back keeps host indices on CPU only for page_first AND only
# when the staged JIT write-back kernel is available (it stages through
# device memory and accepts CPU destination indices). Otherwise we fall back
# to the plain transfer kernel, whose CUDA/HIP implementation requires
# device-resident destination indices -- so the indices must be moved to the
# device first. Without the can_use_write_back_jit check this crashes on
# backends where the JIT kernel is unavailable, with
# "Destination indices must be a CUDA tensor".
if (
self.io_backend == "kernel"
and self.mem_pool_host.layout == "page_first"
and getattr(self.mem_pool_host, "can_use_write_back_jit", False)
):
host_indices, device_indices = op.host_indices, op.device_indices
else:
host_indices, device_indices = self.move_indices(
op.host_indices, op.device_indices
)
host_indices, device_indices, pool_transfers = self._move_write_operation(op)
self.write_queue.clear()
start_event = device_module.Event()
ack_start_event, ack_finish_event, timing_enabled = make_timing_event_pair()
start_event.record()
with device_module.stream(self.write_stream):
start_event.wait(self.write_stream)
ack_start_event.record()
self.mem_pool_host.backup_from_device_all_layer(
self.mem_pool_device, host_indices, device_indices, self.io_backend
)
if self.has_draft:
self.mem_pool_host_draft.backup_from_device_all_layer(
self.mem_pool_device_draft,
host_indices,
device_indices,
self.io_backend,
)
ack_finish_event.record()
# NOTE: We must save the host indices and device indices here,
# this is because we need to guarantee that these tensors are
# still alive when the write stream is executing.
if host_indices.is_cuda:
host_indices.record_stream(self.write_stream)
if device_indices.is_cuda:
device_indices.record_stream(self.write_stream)
completion = self.l2_transfer_engine.submit_device_to_host(
self._l2_transfers(host_indices, device_indices, pool_transfers)
)
self.ack_write_queue.append(
HiCacheAck(
start_event=ack_start_event,
finish_event=ack_finish_event,
start_event=completion.start_event,
finish_event=completion.finish_event,
node_ids=op.node_ids,
num_tokens=len(op.device_indices),
timing_enabled=timing_enabled,
num_tokens_by_pool={PoolName.KV.value: len(op.device_indices)},
timing_enabled=completion.timing_enabled,
num_tokens_by_pool=self._num_tokens_by_pool(op),
num_bytes=self._transfer_num_bytes(op),
)
)
@@ -764,6 +740,9 @@ class HiCacheController:
num_bytes += num_tokens * self.mem_pool_host_draft.size_per_token
return num_bytes
def _num_tokens_by_pool(self, op: CacheOperation) -> dict[str, int]:
return {PoolName.KV.value: len(op.device_indices)}
def load(
self,
host_indices: torch.Tensor,
@@ -781,7 +760,9 @@ class HiCacheController:
)
return device_indices
def move_indices(self, host_indices: torch.Tensor, device_indices: torch.Tensor):
def move_indices(
self, host_indices: torch.Tensor, device_indices: torch.Tensor
) -> tuple[torch.Tensor, torch.Tensor]:
# move indices to GPU if using kernels, to host if using direct indexing
if self.io_backend == "kernel":
if not host_indices.is_cuda:
@@ -803,58 +784,82 @@ class HiCacheController:
else:
raise ValueError(f"Unsupported io backend")
def _move_write_operation(
self, op: CacheOperation
) -> tuple[torch.Tensor, torch.Tensor, Optional[List[PoolTransfer]]]:
"""Keep CPU host indices only for page-first staged write-back."""
if (
self.io_backend == "kernel"
and self.mem_pool_host.layout == "page_first"
and getattr(self.mem_pool_host, "can_use_write_back_jit", False)
):
return op.host_indices, op.device_indices, op.pool_transfers
return self._move_op_indices(op)
def _move_op_indices(
self, op: CacheOperation
) -> tuple[torch.Tensor, torch.Tensor, Optional[List[PoolTransfer]]]:
return (*self.move_indices(op.host_indices, op.device_indices), None)
def _l2_transfers(
self,
host_indices: torch.Tensor,
device_indices: torch.Tensor,
pool_transfers: Optional[List[PoolTransfer]] = None,
) -> list[L2Transfer]:
transfers = [
L2Transfer(
host_pool=self.mem_pool_host,
device_pool=self.mem_pool_device,
host_indices=host_indices,
device_indices=device_indices,
)
]
if self.has_draft and host_indices.numel() > 0:
transfers.append(
L2Transfer(
host_pool=self.mem_pool_host_draft,
device_pool=self.mem_pool_device_draft,
host_indices=host_indices,
device_indices=device_indices,
)
)
return transfers
def _l2_load_transfers(
self,
host_indices: torch.Tensor,
device_indices: torch.Tensor,
pool_transfers: Optional[List[PoolTransfer]] = None,
) -> list[L2Transfer]:
return self._l2_transfers(host_indices, device_indices, pool_transfers)
def start_loading(self) -> int:
if len(self.load_queue) == 0:
return -1
producer_id = self.layer_done_counter.update_producer()
op = CacheOperation.merge_ops(self.load_queue)
host_indices, device_indices = self.move_indices(
op.host_indices, op.device_indices
)
host_indices, device_indices, pool_transfers = self._move_op_indices(op)
self.load_queue.clear()
producer_event = self.layer_done_counter.events[producer_id]
producer_event.start_event.record()
ack_start_event, ack_finish_event, timing_enabled = make_timing_event_pair()
with device_module.stream(self.load_stream):
producer_event.start_event.wait(self.load_stream)
ack_start_event.record()
for i in range(self.layer_num):
self.mem_pool_host.load_to_device_per_layer(
self.mem_pool_device,
host_indices,
device_indices,
i,
self.io_backend,
)
if self.has_draft and i < self.mem_pool_host_draft.layer_num:
self.mem_pool_host_draft.load_to_device_per_layer(
self.mem_pool_device_draft,
host_indices,
device_indices,
i,
self.io_backend,
)
producer_event.complete(i)
ack_finish_event.record()
# NOTE: We must save the host indices and device indices here,
# this is because we need to guarantee that these tensors are
# still alive when the load stream is executing.
if host_indices.is_cuda:
host_indices.record_stream(self.load_stream)
if device_indices.is_cuda:
device_indices.record_stream(self.load_stream)
completion = self.l2_transfer_engine.submit_host_to_device(
self._l2_load_transfers(host_indices, device_indices, pool_transfers),
start_event=producer_event.start_event,
on_layer_done=producer_event.complete,
layer_num=self.layer_num,
)
self.ack_load_queue.append(
HiCacheAck(
start_event=ack_start_event,
finish_event=ack_finish_event,
start_event=completion.start_event,
finish_event=completion.finish_event,
node_ids=op.node_ids,
num_tokens=len(op.device_indices),
timing_enabled=timing_enabled,
num_tokens_by_pool={PoolName.KV.value: len(op.device_indices)},
timing_enabled=completion.timing_enabled,
num_tokens_by_pool=self._num_tokens_by_pool(op),
num_bytes=self._transfer_num_bytes(op),
)
)
@@ -26,6 +26,7 @@ from sglang.srt.observability.metrics_collector import (
from sglang.srt.runtime_context import get_observability
if TYPE_CHECKING:
from sglang.srt.managers.cache_controller import HiCacheController
from sglang.srt.managers.schedule_batch import Req
from sglang.srt.mem_cache.radix_cache import RadixKey
from sglang.srt.mem_cache.unified_cache.cache_action import (
@@ -233,6 +234,7 @@ class BasePrefixCache(ABC, PrefixCacheTrait):
metrics_collector: Optional[RadixCacheMetricsCollector] = (
None # metrics collector for the cache
)
cache_controller: Optional[HiCacheController] = None
def init_metrics_collector(self):
from sglang.srt.runtime_context import get_server_args
@@ -5,14 +5,14 @@ import logging
import os
import threading
import time
from dataclasses import replace
from queue import Empty, Queue
from typing import TYPE_CHECKING, Any, Callable, List, Optional
import torch
from sglang.srt.managers.cache_controller import CacheOperation as BaseCacheOperation
from sglang.srt.managers.cache_controller import (
HiCacheAck,
CacheOperation,
)
from sglang.srt.managers.cache_controller import (
HiCacheController as BaseHiCacheController,
@@ -23,9 +23,6 @@ from sglang.srt.managers.cache_controller import (
from sglang.srt.managers.cache_controller import (
StorageOperation as BaseStorageOperation,
)
from sglang.srt.managers.cache_controller import (
make_timing_event_pair,
)
from sglang.srt.mem_cache.hicache_storage import (
HiCacheStorageExtraInfo,
PoolHitPolicy,
@@ -33,75 +30,14 @@ from sglang.srt.mem_cache.hicache_storage import (
PoolTransfer,
PoolTransferResult,
)
from sglang.srt.mem_cache.l2_transfer import L2Transfer
from sglang.srt.mem_cache.memory_pool_host import HostPoolGroup, PoolEntry
from sglang.srt.mem_cache.pool_host.mha import MHATokenToKVPoolHost
from sglang.srt.utils import get_device_module
if TYPE_CHECKING:
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
logger = logging.getLogger(__name__)
device_module = get_device_module()
class CacheOperation(BaseCacheOperation):
def __init__(
self,
host_indices: torch.Tensor,
device_indices: torch.Tensor,
node_id: int,
priority: Optional[int] = None,
pool_transfers: Optional[list[PoolTransfer]] = None,
):
super().__init__(host_indices, device_indices, node_id, priority)
self.pool_transfers = pool_transfers
@staticmethod
def merge_pool_transfers(
ops: List[CacheOperation],
) -> Optional[list[PoolTransfer]]:
grouped: dict[tuple[PoolName, Optional[PoolName]], list[PoolTransfer]] = {}
for op in ops:
for t in op.pool_transfers or []:
grouped.setdefault((t.name, t.indices_from_pool), []).append(t)
if not grouped:
return None
def cat_or_none(tensors):
parts = [x for x in tensors if x is not None]
return torch.cat(parts) if parts else None
return [
PoolTransfer(
name=ts[0].name,
host_indices=cat_or_none(t.host_indices for t in ts),
device_indices=cat_or_none(t.device_indices for t in ts),
keys=[k for t in ts if t.keys for k in t.keys] or None,
hit_policy=ts[0].hit_policy,
indices_from_pool=ts[0].indices_from_pool,
)
for ts in grouped.values()
]
@staticmethod
def merge_ops(ops: List[CacheOperation]) -> CacheOperation:
if len(ops) == 1:
return ops[0]
host_indices = torch.cat([op.host_indices for op in ops])
device_indices = torch.cat([op.device_indices for op in ops])
node_ids = []
priority = min(op.priority for op in ops)
for op in ops:
node_ids.extend(op.node_ids)
merged = CacheOperation(
host_indices,
device_indices,
-1,
priority,
pool_transfers=CacheOperation.merge_pool_transfers(ops),
)
merged.node_ids = node_ids
return merged
class StorageOperation(BaseStorageOperation):
@@ -403,69 +339,134 @@ class HybridCacheController(BaseHiCacheController):
self.start_writing()
return host_indices
def start_writing(self) -> None:
if not self.write_queue:
return
op = CacheOperation.merge_ops(self.write_queue)
# Page-first staged write-back kernels need CPU destination host indices.
# A HostPoolGroup may mix staged and non-staged child pools, so let it
# normalize indices per child instead of moving the whole operation here.
if (
self.io_backend == "kernel"
and self.mem_pool_host.layout == "page_first"
and (
getattr(self.mem_pool_host, "can_use_write_back_jit", False)
or getattr(
self.mem_pool_host, "supports_per_pool_backup_indices", False
)
)
):
host_indices = op.host_indices
device_indices = op.device_indices
resolved_pool_transfers = op.pool_transfers
else:
host_indices, device_indices, resolved_pool_transfers = (
self.move_hybrid_indices(op)
)
self.write_queue.clear()
start_event = device_module.Event()
ack_start_event, ack_finish_event, timing_enabled = make_timing_event_pair()
start_event.record()
with device_module.stream(self.write_stream):
start_event.wait(self.write_stream)
ack_start_event.record()
self.mem_pool_host.backup_from_device_all_layer(
self.mem_pool_device,
host_indices,
device_indices,
self.io_backend,
pool_transfers=resolved_pool_transfers,
)
if self.has_draft and host_indices.numel() > 0:
self.mem_pool_host_draft.backup_from_device_all_layer(
self.mem_pool_device_draft,
host_indices,
device_indices,
self.io_backend,
)
ack_finish_event.record()
self._record_transfer_indices_on_stream(
self.write_stream,
host_indices,
device_indices,
resolved_pool_transfers,
)
self.ack_write_queue.append(
HiCacheAck(
start_event=ack_start_event,
finish_event=ack_finish_event,
node_ids=op.node_ids,
num_tokens=len(op.device_indices),
timing_enabled=timing_enabled,
num_tokens_by_pool=self._num_tokens_by_pool(op),
num_bytes=self._transfer_num_bytes(op),
)
def _move_op_indices(
self, op: CacheOperation
) -> tuple[torch.Tensor, torch.Tensor, Optional[list[PoolTransfer]]]:
return self.move_hybrid_indices(op)
def _move_write_operation(
self, op: CacheOperation
) -> tuple[torch.Tensor, torch.Tensor, Optional[list[PoolTransfer]]]:
host_group = self.mem_pool_host
if self.io_backend != "kernel" or host_group.layout != "page_first":
return self.move_hybrid_indices(op)
if not getattr(host_group, "supports_per_pool_backup_indices", False):
if not getattr(host_group, "can_use_write_back_jit", False):
return self.move_hybrid_indices(op)
return op.host_indices, op.device_indices, op.pool_transfers
def move_for_pool(host_pool, host_indices, device_indices):
if getattr(host_pool, "can_use_write_back_jit", False):
if host_indices.is_cuda:
host_indices = host_indices.cpu()
return host_indices, device_indices
return self.move_indices(host_indices, device_indices)
host_indices, device_indices = move_for_pool(
host_group.anchor_entry.host_pool,
op.host_indices,
op.device_indices,
)
pool_transfers = []
for transfer in op.pool_transfers or []:
entry = host_group.entry_map[transfer.name]
transfer_host_indices, transfer_device_indices = move_for_pool(
entry.host_pool,
transfer.host_indices,
transfer.device_indices,
)
pool_transfers.append(
replace(
transfer,
host_indices=transfer_host_indices,
device_indices=transfer_device_indices,
)
)
return host_indices, device_indices, pool_transfers or None
def _l2_transfers(
self,
host_indices: torch.Tensor,
device_indices: torch.Tensor,
pool_transfers: Optional[list[PoolTransfer]] = None,
) -> list[L2Transfer]:
anchor = self.mem_pool_host.anchor_entry
transfers = []
if host_indices.numel() > 0:
transfers.append(
L2Transfer(
host_pool=anchor.host_pool,
device_pool=anchor.device_pool,
host_indices=host_indices,
device_indices=device_indices,
layer_mapper=anchor.layer_mapper,
)
)
for pool_transfer in pool_transfers or []:
if (
pool_transfer.host_indices is None
or pool_transfer.device_indices is None
):
raise ValueError(f"Unresolved L2 transfer for {pool_transfer.name}.")
entry = self.mem_pool_host.entry_map[pool_transfer.name]
transfers.append(
L2Transfer(
host_pool=entry.host_pool,
device_pool=entry.device_pool,
host_indices=pool_transfer.host_indices,
device_indices=pool_transfer.device_indices,
layer_mapper=entry.layer_mapper,
)
)
if self.has_draft and host_indices.numel() > 0:
transfers.append(
L2Transfer(
host_pool=self.mem_pool_host_draft,
device_pool=self.mem_pool_device_draft,
host_indices=host_indices,
device_indices=device_indices,
)
)
return transfers
def _l2_load_transfers(
self,
host_indices: torch.Tensor,
device_indices: torch.Tensor,
pool_transfers: Optional[list[PoolTransfer]] = None,
) -> list[L2Transfer]:
transfers = self._l2_transfers(host_indices, device_indices, pool_transfers)
if getattr(self, "has_mtp_draft", False):
target_transfers = list(transfers)
for depth, draft_device_pool in enumerate(self.mtp_draft_device_pools):
for transfer in target_transfers:
if transfer.layer_mapper is None:
continue
draft_host_layer = transfer.layer_mapper(self.layer_num + depth)
if draft_host_layer is None:
continue
def draft_layer_mapper(
layer_id: int,
*,
expected_layer_id: int = depth,
host_layer_id: int = draft_host_layer,
) -> Optional[int]:
if layer_id == expected_layer_id:
return host_layer_id
return None
transfers.append(
L2Transfer(
host_pool=transfer.host_pool,
device_pool=draft_device_pool,
host_indices=transfer.host_indices,
device_indices=transfer.device_indices,
layer_mapper=draft_layer_mapper,
is_draft=True,
)
)
return transfers
def _num_tokens_by_pool(self, op: CacheOperation) -> dict[str, int]:
"""Per-pool token counts for a merged transfer op (anchor + extra
@@ -546,108 +547,6 @@ class HybridCacheController(BaseHiCacheController):
)
return device_indices
def start_loading(self) -> int:
if not self.load_queue:
return -1
producer_id = self.layer_done_counter.update_producer()
op = CacheOperation.merge_ops(self.load_queue)
host_indices, device_indices, resolved_pool_transfers = (
self.move_hybrid_indices(op)
)
self.load_queue.clear()
producer_event = self.layer_done_counter.events[producer_id]
producer_event.start_event.record()
ack_start_event, ack_finish_event, timing_enabled = make_timing_event_pair()
with device_module.stream(self.load_stream):
producer_event.start_event.wait(self.load_stream)
ack_start_event.record()
target_device_pool = self.mem_pool_host.anchor_entry.device_pool
for i in range(self.layer_num):
self.mem_pool_host.load_to_device_per_layer(
target_device_pool,
host_indices,
device_indices,
i,
self.io_backend,
pool_transfers=resolved_pool_transfers,
)
if (
self.has_draft
and host_indices.numel() > 0
and i < self.mem_pool_host_draft.layer_num
):
self.mem_pool_host_draft.load_to_device_per_layer(
self.mem_pool_device_draft,
host_indices,
device_indices,
i,
self.io_backend,
)
# HiCache now supports draft caches through two paths:
#
# - Packed: standard NextN/MTP models (DeepSeek-V3.2, GLM-5.x,
# DeepSeek-V4, MiMo-V2.5) and DeepSeek-V4 DSpark. Draft KV/indexer/SWA
# buffers are appended to the matching target host pools as tail layers
# and share their slot mappings. D2H/H2D therefore moves target and draft
# in the same cache operation; the branch below restores the tail layers.
#
# - Sidecar: standalone EAGLE/EAGLE3 (for example Llama-2/Llama-3.1),
# DFlash (for example Gemma-4), and non-DeepSeek-V4 DSpark. Draft
# KV/indexer/SWA gets a separate host-pool entry sized to its source target
# pool. Its PoolTransfer follows the target KV or SWA indices and is
# attached to the same cache operation.
if self.has_mtp_draft and i < len(self.mtp_draft_device_pools):
self.mem_pool_host.load_to_device_per_layer(
self.mtp_draft_device_pools[i],
host_indices,
device_indices,
self.layer_num + i,
self.io_backend,
pool_transfers=resolved_pool_transfers,
is_draft=True,
)
producer_event.complete(i)
ack_finish_event.record()
self._record_transfer_indices_on_stream(
self.load_stream,
host_indices,
device_indices,
resolved_pool_transfers,
)
self.ack_load_queue.append(
HiCacheAck(
ack_start_event,
ack_finish_event,
op.node_ids,
num_tokens=len(op.device_indices),
timing_enabled=timing_enabled,
num_tokens_by_pool=self._num_tokens_by_pool(op),
num_bytes=self._transfer_num_bytes(op),
)
)
return producer_id
def _record_transfer_indices_on_stream(
self,
stream: torch.Stream,
host_indices: torch.Tensor,
device_indices: torch.Tensor,
pool_transfers: Optional[list[PoolTransfer]] = None,
) -> None:
if host_indices.is_cuda:
host_indices.record_stream(stream)
if device_indices.is_cuda:
device_indices.record_stream(stream)
for transfer in pool_transfers or []:
if transfer.host_indices is not None and transfer.host_indices.is_cuda:
transfer.host_indices.record_stream(stream)
if transfer.device_indices is not None and transfer.device_indices.is_cuda:
transfer.device_indices.record_stream(stream)
def prefetch(
self,
request_id: str,
@@ -151,6 +151,120 @@ def build_pool_entry(
)
def build_kv_only_group(
*,
page_size: int,
server_args: ServerArgs,
kv_pool: Any,
full_layer_mapping: dict[int, int],
use_mla: bool,
override_kv_cache_dim: Optional[int] = None,
host_size: Optional[float] = None,
mtp_draft_device_pools: tuple[Any, ...] = (),
) -> HostPoolGroup:
"""Anchor-only host pool group for a flat MHA/MLA device pool."""
transfer_layer_num = len(full_layer_mapping)
kv_host_pool = build_kv_host_pool(
kv_pool=kv_pool,
page_size=page_size,
server_args=server_args,
use_mla=use_mla,
override_kv_cache_dim=override_kv_cache_dim,
host_size=host_size,
mtp_draft_device_pools=mtp_draft_device_pools,
)
if mtp_draft_device_pools:
full_layer_mapping = _with_mtp_layer_mapping(
full_layer_mapping,
transfer_layer_start=transfer_layer_num,
target_device_layer_num=kv_pool.layer_num,
draft_layer_num=len(mtp_draft_device_pools),
)
return HostPoolGroup(
[
build_pool_entry(
name=PoolName.KV,
host_pool=kv_host_pool,
device_pool=kv_pool,
layer_mapping=full_layer_mapping,
transfer_layer_num=transfer_layer_num + len(mtp_draft_device_pools),
is_anchor=True,
)
]
)
def build_hybrid_swa_group(
*,
page_size: int,
server_args: ServerArgs,
full_kv_pool: Any,
swa_kv_pool: Any,
full_layer_mapping: dict[int, int],
swa_layer_mapping: dict[int, int],
use_mla: bool,
kv_host_size: Optional[float] = None,
swa_host_size: Optional[float] = None,
host_swa_evict_fn: Optional[Callable[[int], Any]] = None,
device_swa_evict_fn: Optional[Callable[[int], Any]] = None,
swa_attn_allocator: Any = None,
mtp_swa_device_pools: tuple[Any, ...] = (),
) -> HostPoolGroup:
"""Anchor (full) + SWA host pool group for a hybrid-SWA device pool."""
transfer_layer_num = len(full_layer_mapping | swa_layer_mapping)
kv_host_pool = build_kv_host_pool(
kv_pool=full_kv_pool,
page_size=page_size,
server_args=server_args,
use_mla=use_mla,
host_size=kv_host_size,
pool_label="full",
)
swa_host_pool = build_kv_host_pool(
kv_pool=swa_kv_pool,
page_size=page_size,
server_args=server_args,
use_mla=use_mla,
host_size=swa_host_size,
mtp_draft_device_pools=mtp_swa_device_pools,
pool_label="swa",
)
if mtp_swa_device_pools:
swa_layer_mapping = _with_mtp_layer_mapping(
swa_layer_mapping,
transfer_layer_start=transfer_layer_num,
target_device_layer_num=swa_kv_pool.layer_num,
draft_layer_num=len(mtp_swa_device_pools),
)
return HostPoolGroup(
[
build_pool_entry(
name=PoolName.KV,
host_pool=kv_host_pool,
device_pool=full_kv_pool,
layer_mapping=full_layer_mapping,
transfer_layer_num=transfer_layer_num,
is_anchor=True,
),
build_pool_entry(
name=PoolName.SWA,
host_pool=swa_host_pool,
device_pool=swa_kv_pool,
layer_mapping=swa_layer_mapping,
transfer_layer_num=transfer_layer_num + len(mtp_swa_device_pools),
host_evict_fn=host_swa_evict_fn,
device_evict_fn=device_swa_evict_fn,
device_alloc_fn=(
swa_attn_allocator.alloc if swa_attn_allocator is not None else None
),
device_free_fn=(
swa_attn_allocator.free if swa_attn_allocator is not None else None
),
),
]
)
def build_kv_only_stack(
*,
params: CacheInitParams,
@@ -167,33 +281,15 @@ def build_kv_only_stack(
enable_storage_metrics: bool = False,
) -> tuple[HostPoolGroup, HybridCacheController]:
transfer_layer_num = len(full_layer_mapping)
kv_host_pool = build_kv_host_pool(
kv_pool=kv_pool,
host_pool_group = build_kv_only_group(
page_size=params.page_size,
server_args=server_args,
kv_pool=kv_pool,
full_layer_mapping=full_layer_mapping,
use_mla=use_mla,
override_kv_cache_dim=override_kv_cache_dim,
mtp_draft_device_pools=params.mtp_draft_device_pools,
)
if params.mtp_draft_device_pools:
full_layer_mapping = _with_mtp_layer_mapping(
full_layer_mapping,
transfer_layer_start=transfer_layer_num,
target_device_layer_num=kv_pool.layer_num,
draft_layer_num=len(params.mtp_draft_device_pools),
)
entries = [
build_pool_entry(
name=PoolName.KV,
host_pool=kv_host_pool,
device_pool=kv_pool,
layer_mapping=full_layer_mapping,
transfer_layer_num=transfer_layer_num + len(params.mtp_draft_device_pools),
is_anchor=True,
)
]
host_pool_group = HostPoolGroup(entries)
cache_controller = HybridCacheController(
params.token_to_kv_pool_allocator,
host_pool_group,
@@ -248,56 +344,22 @@ def build_hybrid_swa_stack(
server_args.hicache_size, (full_kv_pool, swa_kv_pool)
)
kv_host_pool = build_kv_host_pool(
kv_pool=full_kv_pool,
host_pool_group = build_hybrid_swa_group(
page_size=params.page_size,
server_args=server_args,
full_kv_pool=full_kv_pool,
swa_kv_pool=swa_kv_pool,
full_layer_mapping=full_layer_mapping,
swa_layer_mapping=swa_layer_mapping,
use_mla=use_mla,
host_size=kv_host_size,
pool_label="full",
kv_host_size=kv_host_size,
swa_host_size=swa_host_size,
host_swa_evict_fn=host_swa_evict_fn,
device_swa_evict_fn=device_swa_evict_fn,
# For SWA hybrid, device allocation goes through the inner allocator.
swa_attn_allocator=params.token_to_kv_pool_allocator.swa_attn_allocator,
mtp_swa_device_pools=mtp_swa_device_pools,
)
swa_host_pool = build_kv_host_pool(
kv_pool=swa_kv_pool,
page_size=params.page_size,
server_args=server_args,
use_mla=use_mla,
host_size=swa_host_size,
mtp_draft_device_pools=mtp_swa_device_pools,
pool_label="swa",
)
if mtp_swa_device_pools:
swa_layer_mapping = _with_mtp_layer_mapping(
swa_layer_mapping,
transfer_layer_start=transfer_layer_num,
target_device_layer_num=swa_kv_pool.layer_num,
draft_layer_num=len(mtp_swa_device_pools),
)
# For SWA hybrid, the device alloc/free goes through the inner swa_attn_allocator
swa_attn_allocator = params.token_to_kv_pool_allocator.swa_attn_allocator
entries = [
build_pool_entry(
name=PoolName.KV,
host_pool=kv_host_pool,
device_pool=full_kv_pool,
layer_mapping=full_layer_mapping,
transfer_layer_num=transfer_layer_num,
is_anchor=True,
),
build_pool_entry(
name=PoolName.SWA,
host_pool=swa_host_pool,
device_pool=swa_kv_pool,
layer_mapping=swa_layer_mapping,
transfer_layer_num=transfer_layer_num + len(mtp_swa_device_pools),
host_evict_fn=host_swa_evict_fn,
device_evict_fn=device_swa_evict_fn,
device_alloc_fn=swa_attn_allocator.alloc,
device_free_fn=swa_attn_allocator.free,
),
]
host_pool_group = HostPoolGroup(entries)
cache_controller = HybridCacheController(
params.token_to_kv_pool_allocator,
host_pool_group,
@@ -846,7 +908,7 @@ def build_anchor_sidecar_stack(
mtp_draft_device_pools=mtp_draft_device_pools,
)
sidecar_host_pool = sidecar_host_pool_factory(kv_host_pool)
# Let HostPoolGroup dispatch packed MTP tail layers through the normal path.
# Expose packed MTP tail layers to the controller's flat transfer builder.
if mtp_draft_device_pools:
full_layer_mapping = _with_mtp_layer_mapping(
full_layer_mapping,
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import logging
from functools import cache
from typing import Any, Callable, NamedTuple, Optional
import torch
from sglang.srt.utils import get_device_module
logger = logging.getLogger(__name__)
device_module = get_device_module()
@cache
def _timing_events_supported() -> bool:
try:
device_module.Event(enable_timing=True)
return True
except (TypeError, NotImplementedError):
logger.warning(
"%s.Event does not support timing; L2 transfer timing is disabled",
device_module.__name__,
)
return False
def make_timing_event_pair():
timing_enabled = _timing_events_supported()
kwargs = {"enable_timing": True} if timing_enabled else {}
return device_module.Event(**kwargs), device_module.Event(**kwargs), timing_enabled
class L2Transfer(NamedTuple):
host_pool: Any
device_pool: Any
host_indices: torch.Tensor
device_indices: torch.Tensor
layer_mapper: Optional[Callable[[int], Optional[int]]] = None
is_draft: bool = False
class TransferCompletion(NamedTuple):
start_event: Any
finish_event: Any
timing_enabled: bool
class L2TransferEngine:
"""Runs resolved device↔host transfers without owning cache state."""
def __init__(self, io_backend: str):
self.io_backend = io_backend
self.device_to_host_stream = device_module.Stream()
self.host_to_device_stream = device_module.Stream()
def submit_device_to_host(self, transfers: list[L2Transfer]) -> TransferCompletion:
start_event = self._start_event(None)
ack_start, ack_finish, timing_enabled = make_timing_event_pair()
with device_module.stream(self.device_to_host_stream):
start_event.wait(self.device_to_host_stream)
ack_start.record()
for transfer in transfers:
transfer.host_pool.backup_from_device_all_layer(
transfer.device_pool,
transfer.host_indices,
transfer.device_indices,
self.io_backend,
)
ack_finish.record()
self._record_stream(transfers, self.device_to_host_stream)
return TransferCompletion(ack_start, ack_finish, timing_enabled)
def submit_host_to_device(
self,
transfers: list[L2Transfer],
*,
layer_num: int,
start_event=None,
on_layer_done=None,
) -> TransferCompletion:
start_event = self._start_event(start_event)
ack_start, ack_finish, timing_enabled = make_timing_event_pair()
primary = transfers[0] if transfers else None
with device_module.stream(self.host_to_device_stream):
start_event.wait(self.host_to_device_stream)
ack_start.record()
for layer_id in range(layer_num):
for transfer in transfers:
local_layer_id = (
transfer.layer_mapper(layer_id)
if transfer.layer_mapper is not None
else layer_id
)
if local_layer_id is None or (
transfer is not primary
and transfer.layer_mapper is None
and layer_id >= transfer.host_pool.layer_num
):
continue
transfer.host_pool.load_to_device_per_layer(
transfer.device_pool,
transfer.host_indices,
transfer.device_indices,
local_layer_id,
self.io_backend,
is_draft=transfer.is_draft,
)
if on_layer_done is not None:
on_layer_done(layer_id)
ack_finish.record()
self._record_stream(transfers, self.host_to_device_stream)
return TransferCompletion(ack_start, ack_finish, timing_enabled)
@staticmethod
def _start_event(start_event):
if start_event is None:
start_event = device_module.Event()
start_event.record()
return start_event
@staticmethod
def _record_stream(transfers: list[L2Transfer], stream) -> None:
for transfer in transfers:
for indices in (transfer.host_indices, transfer.device_indices):
if indices.is_cuda:
indices.record_stream(stream)
@@ -1662,120 +1662,6 @@ class HostPoolGroup:
def set_from_flat_data_page(self, index: int, data_page) -> None:
return self.anchor_entry.host_pool.set_from_flat_data_page(index, data_page)
def load_to_device_per_layer(
self,
device_pool,
host_indices,
device_indices,
layer_id,
io_backend,
pool_transfers: Optional[list] = None,
*,
is_draft: bool = False,
) -> None:
# 1. Anchor (KV) transfer
anchor = self.anchor_entry
local_layer_id = anchor.layer_mapper(layer_id)
if local_layer_id is not None and host_indices.numel() > 0:
anchor.host_pool.load_to_device_per_layer(
device_pool if is_draft else anchor.device_pool,
host_indices,
device_indices,
local_layer_id,
io_backend,
is_draft=is_draft,
)
# 2. Extra pool transfers
for transfer in pool_transfers or []:
entry = self.entry_map.get(transfer.name)
if entry is None or transfer.host_indices is None:
continue
local_layer_id = entry.layer_mapper(layer_id)
if local_layer_id is None:
continue
entry.host_pool.load_to_device_per_layer(
device_pool if is_draft else entry.device_pool,
transfer.host_indices,
transfer.device_indices,
local_layer_id,
io_backend,
is_draft=is_draft,
)
def _backup_uses_cpu_host_indices(self, host_pool, io_backend) -> bool:
return (
io_backend == "kernel"
and getattr(host_pool, "layout", None) == "page_first"
and getattr(host_pool, "can_use_write_back_jit", False)
)
def _kernel_index_device(self, entry, device_indices):
if device_indices is not None and device_indices.is_cuda:
return device_indices.device
return getattr(entry.device_pool, "device", None)
def _normalize_backup_indices(
self, entry, host_indices, device_indices, io_backend
):
if io_backend != "kernel":
return host_indices, device_indices
if self._backup_uses_cpu_host_indices(entry.host_pool, io_backend):
if host_indices.is_cuda:
host_indices = host_indices.cpu()
return host_indices, device_indices
if not host_indices.is_cuda:
target_device = self._kernel_index_device(entry, device_indices)
if target_device is not None:
host_indices = host_indices.to(target_device, non_blocking=True)
if host_indices.is_cuda:
host_indices.record_stream(
torch.cuda.current_stream(host_indices.device)
)
return host_indices, device_indices
def backup_from_device_all_layer(
self,
device_pool,
host_indices,
device_indices,
io_backend,
pool_transfers: Optional[list] = None,
) -> None:
# 1. Anchor (KV) backup
# A zero-length anchor denotes a component-only backup.
if host_indices.numel() > 0:
anchor_host_indices, anchor_device_indices = self._normalize_backup_indices(
self.anchor_entry, host_indices, device_indices, io_backend
)
self.anchor_entry.host_pool.backup_from_device_all_layer(
self.anchor_entry.device_pool,
anchor_host_indices,
anchor_device_indices,
io_backend,
)
# 2. Extra pool backup
for transfer in pool_transfers or []:
entry = self.entry_map.get(transfer.name)
if entry is None or transfer.host_indices is None:
continue
transfer_host_indices, transfer_device_indices = (
self._normalize_backup_indices(
entry,
transfer.host_indices,
transfer.device_indices,
io_backend,
)
)
entry.host_pool.backup_from_device_all_layer(
entry.device_pool,
transfer_host_indices,
transfer_device_indices,
io_backend,
)
class DSAIndexerPoolHost(HostKVCache):
"""Host-side DSA index buffers only. Slot layout matches the anchor MLA host pool."""