Files
sglang/python/sglang/srt/managers/cache_controller.py
T
leaveletandClaude Opus 4.8 820790b97f Remove CP HiCache debug instrumentation
The CP shared-KV cache-hit corruption bug is fixed and verified end-to-end, so
drop the per-layer/per-request trace probes added during the investigation.
Each probe was a Python call plus an UNCACHED os.getenv (EnvField.get() re-reads
os.environ on every call) in the hot path even when disabled -- ~1000 per
forward from the 11 per-layer deepseek probes alone -- plus a latent risk that
an accidentally-set SGLANG_NSA_DUMP_DIR would dump tensors in-forward and tank
throughput.

Delete cp_hicache_trace.py and every reference to it:
- deepseek_v2: 11 per-layer fwd_hash probes + the final dump-flush block
- nsa_backend: the per-compose NSA tensor-dump block
- cp_shared_kv_runtime: 5 imports + the if _cptrace_enabled(...) compose blocks
- cache_controller / hiradix_cache / allocator / memory_pool_host: the
  cptrace/khash/knz/rng round-trip + lifecycle hashes
- environ: the SGLANG_CP_HICACHE_KV_TRACE and SGLANG_NSA_DUMP_DIR flags

Kept: SGLANG_DEBUG_CP_SHARED_KV / SGLANG_CP_TRANSFER_LOG transfer logging and the
x-request-id passthrough (independent of the trace module). Verified: imports
clean, 235 mem_cache + reasoning unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:59:01 +00:00

2261 lines
90 KiB
Python

from __future__ import annotations
"""
Copyright 2023-2025 SGLang Team
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import logging
import threading
import time
from dataclasses import dataclass, field
from queue import Empty, Full, Queue
from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Set
import torch
from sglang.srt.environ import envs
from sglang.srt.mem_cache.hicache_storage import HiCacheStorageConfig
if TYPE_CHECKING:
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.memory_pool_host import HostKVCache
from sglang.srt.distributed import (
get_tensor_model_parallel_rank,
get_tensor_model_parallel_world_size,
)
from sglang.srt.layers.dp_attention import (
get_attention_dp_rank,
get_attention_tp_rank,
get_attention_tp_size,
is_dp_attention_enabled,
)
from sglang.srt.mem_cache.cp_shared_kv_layout import (
CpSharedKVLayout,
pad_token_locs_to_page_boundary,
)
from sglang.srt.mem_cache.memory_pool import MLATokenToKVPool, NSATokenToKVPool
from sglang.srt.mem_cache.page_index_utils import validate_page_aligned_token_indices
from sglang.srt.utils import get_device_module
logger = logging.getLogger(__name__)
device_module = get_device_module()
_CP_SHARED_KV_BS_GT1_CACHE_TIMING_COUNTS: Dict[str, int] = {}
def _cp_shared_kv_bs_gt1_cache_timing(
key: str,
start_time: float,
message: str,
*args,
) -> None:
if not envs.SGLANG_CP_SHARED_KV_BS_GT1_TIMING.get():
return
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
slow_ms = float(envs.SGLANG_CP_SHARED_KV_BS_GT1_TIMING_SLOW_MS.get())
if slow_ms > 0 and elapsed_ms < slow_ms:
return
limit = int(envs.SGLANG_CP_SHARED_KV_BS_GT1_TIMING_LIMIT.get())
count = _CP_SHARED_KV_BS_GT1_CACHE_TIMING_COUNTS.get(key, 0)
if limit > 0 and count >= limit:
return
_CP_SHARED_KV_BS_GT1_CACHE_TIMING_COUNTS[key] = count + 1
logger.info(
"[CP_SHARED_KV_BS_GT1_TIMING] event=%s elapsed_ms=%.3f " + message,
key,
elapsed_ms,
*args,
)
def _cp_shared_kv_bs_gt1_cache_timing_start() -> Optional[float]:
if not envs.SGLANG_CP_SHARED_KV_BS_GT1_TIMING.get():
return None
return time.perf_counter()
def _cp_shared_kv_bs_gt1_cache_timing_log(
key: str,
start_time: Optional[float],
message: str,
*args,
) -> None:
if start_time is None:
return
_cp_shared_kv_bs_gt1_cache_timing(key, start_time, message, *args)
class LayerLoadingEvent:
def __init__(self, num_layers: int):
self._num_layers = num_layers
self.load_events = [device_module.Event() for _ in range(num_layers)]
self.start_event = device_module.Event() # start event on controller stream
def complete(self, layer_index: int):
assert 0 <= layer_index < self._num_layers
self.load_events[layer_index].record()
def wait(self, layer_index: int):
device_module.current_stream().wait_event(self.load_events[layer_index])
def wait_on_stream(self, layer_index: int, stream):
assert 0 <= layer_index < self._num_layers
stream.wait_event(self.load_events[layer_index])
@property
def finish_event(self):
return self.load_events[-1]
class LayerDoneCounter:
def __init__(self, num_layers: int):
self.num_layers = num_layers
# extra producer and consumer counters for overlap mode
self.num_counters = 5
self.events = [LayerLoadingEvent(num_layers) for _ in range(self.num_counters)]
self.producer_index = -1
self.consumer_index = -1
self.consumer_indices: List[int] = []
def update_producer(self):
self.producer_index = (self.producer_index + 1) % self.num_counters
assert self.events[
self.producer_index
].finish_event.query(), (
"Producer finish event should be ready before being reused."
)
return self.producer_index
def set_consumer(self, index):
if isinstance(index, (list, tuple, set)):
self.consumer_indices = [int(i) for i in index if int(i) >= 0]
self.consumer_index = (
self.consumer_indices[0] if self.consumer_indices else -1
)
return
self.consumer_index = int(index)
self.consumer_indices = [self.consumer_index] if self.consumer_index >= 0 else []
def wait_until(self, threshold: int):
if not self.consumer_indices:
return
for consumer_index in self.consumer_indices:
self.events[consumer_index].wait(threshold)
def wait_until_on_stream(self, threshold: int, stream) -> None:
if not self.consumer_indices:
return
for consumer_index in self.consumer_indices:
self.events[consumer_index].wait_on_stream(threshold, stream)
def reset(self):
self.producer_index = -1
self.consumer_index = -1
self.consumer_indices = []
class CacheOperation:
counter = 0
def __init__(
self,
host_indices: torch.Tensor,
device_indices: torch.Tensor,
node_id: int,
priority: Optional[int] = None,
):
self.host_indices = host_indices
self.device_indices = device_indices
self.node_ids = [node_id]
self.data = None
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_ops(ops: List[CacheOperation]) -> CacheOperation:
assert len(ops) > 0
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.node_ids = node_ids
return merged_op
def __lt__(self, other: CacheOperation):
return self.priority < other.priority
class HiCacheAck(NamedTuple):
start_event: device_module.Event
finish_event: device_module.Event
node_ids: List[int]
class TransferBuffer:
"""
Overlapping buffer preparation and transfer operations to improve throughput.
"""
def __init__(
self, stop_event, buffer_count: int = 3, max_buffer_size: int = 1024
) -> None:
self.stop_event = stop_event
self.buffers = Queue(maxsize=buffer_count)
# todo: adjust the buffer size based on throughput profile of the system
self.max_buffer_size = max_buffer_size
def full(self) -> bool:
return self.buffers.full()
def empty(self) -> bool:
return self.buffers.empty()
def put(self, item, block=True, timeout=1) -> None:
while not self.stop_event.is_set():
try:
self.buffers.put(item, block=block, timeout=timeout)
break
except Full:
if not block:
break
continue
except Exception as e:
logger.error(e)
def get(self, block=True, timeout=1) -> Optional[CacheOperation]:
try:
return self.buffers.get(block=block, timeout=timeout)
except Empty:
return None
except Exception as e:
logger.error(e)
def clear(self):
self.buffers.queue.clear()
class StorageOperation:
counter = 0
def __init__(
self,
host_indices: torch.Tensor,
token_ids: List[int],
last_hash: Optional[str] = None,
hash_value: Optional[List[str]] = None,
prefix_keys: Optional[List[str]] = None,
):
self.host_indices = host_indices
self.token_ids = token_ids
self.last_hash = last_hash
self.completed_tokens = 0
self.hash_value = hash_value if hash_value is not None else []
self.prefix_keys = prefix_keys
self.id = StorageOperation.counter
StorageOperation.counter += 1
def __lt__(self, other: "StorageOperation"):
return self.id < other.id
class PrefetchOperation(StorageOperation):
def __init__(
self,
request_id: str,
host_indices: torch.Tensor,
token_ids: List[int],
last_hash: Optional[str] = None,
prefix_keys: Optional[List[str]] = None,
):
self.request_id = request_id
self._lock = threading.Lock()
self._terminated_flag = False
self.start_time = time.monotonic()
super().__init__(host_indices, token_ids, last_hash, prefix_keys=prefix_keys)
def increment(self, num_tokens: int):
with self._lock:
if self._terminated_flag:
return False
self.completed_tokens += num_tokens
return True
def mark_terminate(self):
with self._lock:
self._terminated_flag = True
def is_terminated(self) -> bool:
return self._terminated_flag
@dataclass
class HiCacheWriteResult:
metadata: object
required_host_slots: int = 0
@dataclass
class HiCacheWriteFailure:
required_host_slots: int
metadata: object = None
# Why the reservation failed. "host_capacity" failures are retryable via
# deterministic host eviction; "undrained_ack" means the node's previous
# backup ack has not been drained yet — callers must skip this backup
# round instead of entering the capacity retry/eviction path.
reason: str = "host_capacity"
@dataclass
class HiCacheWriteReservation:
metadata: object
host_indices: torch.Tensor
physical_device_indices: torch.Tensor
node_id: int = -1
priority: Optional[int] = None
draft_host_indices: Optional[torch.Tensor] = None
required_host_slots: int = 0
@dataclass
class HiCacheLayerWriteState:
reservation: HiCacheWriteReservation
total_layers: int
start_event: object
finish_event: object
layer_events: List[object] = field(default_factory=list)
completed_target_layers: Set[int] = field(default_factory=set)
completed_draft_layers: Set[int] = field(default_factory=set)
host_indices: Optional[torch.Tensor] = None
physical_device_indices: Optional[torch.Tensor] = None
draft_host_indices: Optional[torch.Tensor] = None
ack_appended: bool = False
class HiCacheController:
def __init__(
self,
token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator,
mem_pool_host: HostKVCache,
page_size: int,
tp_group: torch.distributed.ProcessGroup,
load_cache_event: threading.Event,
write_policy: str = "write_through_selective",
io_backend: str = "",
storage_backend: Optional[str] = None,
prefetch_threshold: int = 256,
model_name: Optional[str] = None,
storage_backend_extra_config: Optional[dict] = None,
pp_rank: int = 0,
pp_size: int = 1,
enable_storage_metrics: bool = False,
cp_shared_kv_layout: Optional[CpSharedKVLayout] = None,
draft_mem_pool_host: Optional["HostKVCache"] = None,
draft_mem_pool_device=None,
):
self.tp_group = tp_group
self.mem_pool_device_allocator = token_to_kv_pool_allocator
mem_pool_device = token_to_kv_pool_allocator.get_kvcache()
from sglang.srt.mem_cache.memory_pool import HybridLinearKVPool
if isinstance(mem_pool_device, HybridLinearKVPool):
mem_pool_device = mem_pool_device.full_kv_pool
self.mem_pool_device = mem_pool_device
self.cp_shared_kv_layout = cp_shared_kv_layout
self.has_draft = False
self.mem_pool_device_draft = None
self.mem_pool_host_draft = None
self.uses_cp_hicache = cp_shared_kv_layout is not None
if self.uses_cp_hicache and not isinstance(
self.mem_pool_device, NSATokenToKVPool
):
raise ValueError(
"CP shared KV HiCache host integration requires NSATokenToKVPool."
)
self.mem_pool_host = mem_pool_host
self.write_policy = write_policy
self.page_size = page_size
self.io_backend = io_backend
self.enable_storage = False
self.storage_backend = None
self.storage_backend_type = None
self.pp_rank = pp_rank
self.pp_size = pp_size
self.enable_storage_metrics = enable_storage_metrics
# Default storage page IO functions (may be overridden by attach).
self.page_get_func = self._generic_page_get
self.page_set_func = self._generic_page_set
# Dedicated stop event for storage background threads (prefetch/backup).
# NOTE: Do NOT reuse `self.stop_event` here since it also guards core HiCache
# transfer buffers (CPU<->GPU). We want to allow runtime attach/detach of
# storage without stopping the whole controller.
self.storage_stop_event = threading.Event()
self.device = self.mem_pool_device.device
self.layer_num = self.mem_pool_device.layer_num
self.layer_done_counter = LayerDoneCounter(self.layer_num)
self.mem_pool_device.register_layer_transfer_counter(self.layer_done_counter)
if hasattr(self.mem_pool_device, "register_layer_backup_notifier"):
self.mem_pool_device.register_layer_backup_notifier(
lambda layer_id: self.on_layer_end(layer_id, source="target")
)
self.draft_mem_pool_host = None
self.draft_mem_pool_device = None
if write_policy not in [
"write_through",
"write_through_selective",
"write_back",
"write_behind",
]:
raise ValueError(f"Invalid write policy: {write_policy}")
# self.write_queue = PriorityQueue[CacheOperation]()
self.load_queue: List[CacheOperation] = []
self.write_queue: List[CacheOperation] = []
self.draft_load_queue: List[CacheOperation] = []
self.draft_write_queue: List[CacheOperation] = []
self.ack_load_queue: List[HiCacheAck] = []
self.ack_write_queue: List[HiCacheAck] = []
self.pending_layer_writes: Dict[int, HiCacheLayerWriteState] = {}
# Highest node_id ever appended to ack_write_queue. TreeNode ids are
# strictly monotonic, so a reservation whose node_id exceeds this can
# never collide with a queued ack — the common (prepare-path) case
# short-circuits to one integer compare instead of a queue scan.
self._max_write_ack_node_id: int = -1
if draft_mem_pool_host is not None or draft_mem_pool_device is not None:
self.attach_draft_pool(draft_mem_pool_device, draft_mem_pool_host)
self.stop_event = threading.Event()
self.write_buffer = TransferBuffer(self.stop_event)
self.load_buffer = TransferBuffer(
self.stop_event, buffer_count=10, max_buffer_size=100
)
self.write_stream = device_module.Stream()
self.load_stream = device_module.Stream()
# 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.
if storage_backend is not None:
try:
self.attach_storage_backend(
storage_backend=storage_backend,
prefetch_threshold=prefetch_threshold,
model_name=model_name,
storage_backend_extra_config=storage_backend_extra_config,
)
except ValueError as e:
# Preserve the historical error shape on init for unknown backends.
raise ValueError(f"Failed to create storage backend: {e}") from e
@property
def has_draft_hicache(self) -> bool:
return (
self.draft_mem_pool_host is not None
and self.draft_mem_pool_device is not None
)
def attach_draft_pool(self, draft_mem_pool_device, draft_mem_pool_host) -> None:
if draft_mem_pool_device is None and draft_mem_pool_host is None:
return
if draft_mem_pool_device is None or draft_mem_pool_host is None:
raise ValueError(
"draft_mem_pool_device and draft_mem_pool_host must be provided together"
)
self.draft_mem_pool_device = draft_mem_pool_device
self.draft_mem_pool_host = draft_mem_pool_host
if hasattr(draft_mem_pool_device, "register_layer_transfer_counter"):
draft_mem_pool_device.register_layer_transfer_counter(
self.layer_done_counter
)
if hasattr(draft_mem_pool_device, "register_layer_backup_notifier"):
draft_mem_pool_device.register_layer_backup_notifier(
lambda layer_id: self.on_layer_end(layer_id, source="draft")
)
def on_layer_end(self, layer_id: int, source: str = "target") -> None:
if not self.pending_layer_writes:
return
if source not in ("target", "draft"):
raise ValueError(f"Unknown CP HiCache layer backup source: {source}")
self._submit_write_cp_layer_states(
list(self.pending_layer_writes.values()),
layer_id,
submit_target=(source == "target"),
submit_draft=(source == "draft"),
)
def _start_storage_threads(self):
"""Start storage prefetch/backup threads and their queues.
This is used by runtime attach, and also by reset when storage is enabled.
"""
assert self.enable_storage
assert not self.storage_stop_event.is_set()
self.prefetch_thread = threading.Thread(
target=self.prefetch_thread_func, daemon=True
)
self.backup_thread = threading.Thread(
target=self.backup_thread_func, daemon=True
)
self.prefetch_queue = Queue()
self.backup_queue = Queue()
self.prefetch_revoke_queue = Queue()
self.ack_backup_queue = Queue()
self.host_mem_release_queue = Queue()
self.prefetch_thread.start()
self.backup_thread.start()
def _stop_storage_threads(self):
"""Stop storage prefetch/backup threads and drain internal queues.
Caller should ensure no in-flight requests.
"""
# Always request stop. This is safe even when storage is already disabled,
# and makes detach truly idempotent (previous partial detach may have left
# threads alive).
# NOTE: do NOT clear stop_event unless threads have fully stopped; otherwise
# a still-alive thread may resume and touch released state.
self.storage_stop_event.set()
# Best-effort wakeups so threads exit promptly even if blocked on queues.
try:
if hasattr(self, "prefetch_queue"):
self.prefetch_queue.put_nowait(None)
if hasattr(self, "backup_queue"):
self.backup_queue.put_nowait(None)
if hasattr(self, "prefetch_buffer"):
self.prefetch_buffer.put_nowait(None)
except Exception:
pass
# Best-effort joins (threads are daemon, but join keeps state clean).
threads = []
if hasattr(self, "prefetch_thread"):
threads.append(self.prefetch_thread)
if hasattr(self, "backup_thread"):
threads.append(self.backup_thread)
if hasattr(self, "prefetch_io_aux_thread"):
threads.append(self.prefetch_io_aux_thread)
for t in threads:
try:
t.join(timeout=10)
except Exception:
pass
alive = [t for t in threads if getattr(t, "is_alive", lambda: False)()]
if alive:
logger.error(
"Failed to stop HiCache storage threads cleanly: %s",
[getattr(t, "name", repr(t)) for t in alive],
)
raise RuntimeError("Failed to stop HiCache storage threads cleanly.")
def attach_storage_backend(
self,
storage_backend: str,
prefetch_threshold: int = 256,
model_name: Optional[str] = None,
storage_backend_extra_config: Optional[dict] = None,
):
"""Attach (enable) storage backend at runtime.
Requirement: no in-flight requests. This call is expected to run on the scheduler
thread (control path), not concurrently with prefetch/backup.
"""
if self.uses_cp_hicache:
raise RuntimeError(
"CP shared KV HiCache does not support attaching a storage backend at runtime."
)
if self.enable_storage:
raise RuntimeError("Storage backend already attached.")
# Defensive: a previous partial detach may have flipped `enable_storage` but
# left background threads alive. Attaching on top of them is unsafe.
try:
self._stop_storage_threads()
except Exception as e:
raise RuntimeError(
"Cannot attach storage backend: previous detach did not stop storage threads cleanly."
) from e
# Rollback-safe init: if creation fails, keep controller state consistent
# for future attach attempts.
self.storage_backend_type = storage_backend
from sglang.srt.mem_cache.hicache_storage import get_hash_str
self.get_hash_str = get_hash_str
self.storage_config = self._generate_storage_config(
model_name, storage_backend_extra_config
)
# for MLA models, only one rank needs to backup the KV cache
self.backup_skip = (
self.storage_config.is_mla_model
# todo: load balancing
and self.storage_config.tp_rank != 0
)
# Use storage backend factory for dynamic backend creation
from sglang.srt.mem_cache.storage import StorageBackendFactory
try:
self.storage_backend = StorageBackendFactory.create_backend(
storage_backend, self.storage_config, self.mem_pool_host
)
self.storage_backend.register_mem_pool_host(self.mem_pool_host)
self.enable_storage = True
# todo: threshold policy for prefetching
self.prefetch_threshold = max(prefetch_threshold, self.page_size)
self.prefetch_capacity_limit = max(
0, int(0.8 * (self.mem_pool_host.size - self.mem_pool_device.size))
)
# granularity of batch storage IO operations, in number of pages
self.storage_batch_size = 128
# tracking the number of tokens locked in prefetching, updated by the main scheduler thread
self.prefetch_tokens_occupied = 0
# create a new communication group for synchronizing storage operations across TP workers
self.tp_world_size = torch.distributed.get_world_size(group=self.tp_group)
if self.tp_world_size > 1:
from sglang.srt.distributed.parallel_state import (
create_custom_parallel_group,
)
group_ranks = torch.distributed.get_process_group_ranks(self.tp_group)
self.prefetch_tp_group = create_custom_parallel_group(
group_ranks=group_ranks, backend="gloo"
)
# Select the get and set functions
self.page_get_func = self._generic_page_get
self.page_set_func = self._generic_page_set
if (self.storage_backend_type in ["hf3fs", "mooncake", "eic", "nixl"]) or (
self.storage_backend_type == "dynamic"
and bool(self.storage_config.extra_config.get("interface_v1", 0))
):
self.page_get_func = self._page_get_zero_copy
self.page_set_func = self._page_set_zero_copy
# Ensure stop_event is clear before starting threads.
self.storage_stop_event.clear()
self._start_storage_threads()
except Exception:
# Best-effort cleanup for partial init.
try:
self._stop_storage_threads()
except Exception:
pass
try:
if hasattr(self, "prefetch_tp_group"):
try:
torch.distributed.destroy_process_group(self.prefetch_tp_group)
except Exception:
pass
self.prefetch_tp_group = None
except Exception:
pass
try:
if (
hasattr(self, "storage_backend")
and self.storage_backend is not None
):
if hasattr(self.storage_backend, "close"):
self.storage_backend.close()
except Exception:
pass
self.storage_backend = None
self.storage_backend_type = None
self.enable_storage = False
self.page_get_func = self._generic_page_get
self.page_set_func = self._generic_page_set
raise
def detach_storage_backend(self):
"""Detach (disable) storage backend at runtime.
Requirement: no in-flight requests. This will stop storage threads and release
the backend instance (best-effort close).
"""
# Idempotent cleanup: even if `enable_storage` is already False,
# we may still have leftover resources (threads/backend/process group) from a
# previous partial detach. We attempt cleanup whenever possible.
try:
self._stop_storage_threads()
except Exception as e:
# Do not proceed tearing down backend/process group if threads are not
# fully stopped; otherwise still-alive threads may touch released state.
# Caller can retry detach.
logger.exception("Stop storage threads failed: %s", e)
# IMPORTANT: Do not silently succeed. Upper layers rely on exceptions here
# to avoid flipping `enable_storage` flags while threads are still alive.
raise RuntimeError("Stop storage threads failed; detach aborted.") from e
# Best-effort destroy process group created for storage ops.
try:
if (
hasattr(self, "prefetch_tp_group")
and self.prefetch_tp_group is not None
):
try:
torch.distributed.destroy_process_group(self.prefetch_tp_group)
except Exception:
pass
self.prefetch_tp_group = None
except Exception:
pass
# Best-effort close (some backends rely on GC/destructor).
try:
if (
hasattr(self, "storage_backend")
and self.storage_backend is not None
and hasattr(self.storage_backend, "close")
):
self.storage_backend.close()
except Exception:
logger.exception("Failed to close storage backend cleanly.")
self.storage_backend = None
self.storage_backend_type = None
self.enable_storage = False
self.page_get_func = self._generic_page_get
self.page_set_func = self._generic_page_set
# Now it's safe to clear the stop event for future re-attach.
self.storage_stop_event.clear()
def _generate_storage_config(
self,
model_name: Optional[str] = None,
storage_backend_extra_config: Optional[dict] = None,
):
if storage_backend_extra_config is None:
storage_backend_extra_config = {}
if is_dp_attention_enabled():
self.tp_rank = get_attention_tp_rank()
self.tp_size = get_attention_tp_size()
self.dp_rank = get_attention_dp_rank()
else:
self.tp_rank = get_tensor_model_parallel_rank()
self.tp_size = get_tensor_model_parallel_world_size()
self.dp_rank = 0
# Currently, NPUMLATokenToKVPool is the subclass of MLATokenToKVPool.
is_mla_backend = isinstance(self.mem_pool_device, MLATokenToKVPool)
# Least Common Multiple among heterogeneous tp size
tp_lcm_size = storage_backend_extra_config.pop("tp_lcm_size", None)
should_split_heads = False
if tp_lcm_size:
assert (
tp_lcm_size % self.tp_size == 0
), "tp_lcm_size must be divisible by tp_size."
should_split_heads = (
not is_mla_backend
and self.mem_pool_host.layout == "page_head"
and tp_lcm_size > self.tp_size
)
return HiCacheStorageConfig(
tp_rank=self.tp_rank,
tp_size=self.tp_size,
pp_rank=self.pp_rank,
pp_size=self.pp_size,
is_mla_model=is_mla_backend,
enable_storage_metrics=self.enable_storage_metrics,
is_page_first_layout=self.mem_pool_host.layout == "page_first",
model_name=model_name,
tp_lcm_size=tp_lcm_size,
should_split_heads=should_split_heads,
extra_config=storage_backend_extra_config,
)
def reset(self):
self.stop_event.set()
self.storage_stop_event.set()
self.write_queue.clear()
self.load_queue.clear()
self.draft_write_queue.clear()
self.draft_load_queue.clear()
self.write_buffer.clear()
self.load_buffer.clear()
self.ack_write_queue.clear()
self.ack_load_queue.clear()
self.pending_layer_writes.clear()
# HiRadixCache.reset() restarts TreeNode.counter from 0; with the queue
# cleared, resetting the max keeps the duplicate-reservation fast path
# active for the re-minted ids instead of falling back to queue scans.
self._max_write_ack_node_id = -1
if self.enable_storage:
self.prefetch_thread.join()
self.backup_thread.join()
self.prefetch_queue.queue.clear()
self.backup_queue.queue.clear()
self.prefetch_revoke_queue.queue.clear()
self.ack_backup_queue.queue.clear()
self.stop_event.clear()
self.storage_stop_event.clear()
if self.enable_storage:
self.prefetch_thread = threading.Thread(
target=self.prefetch_thread_func, daemon=True
)
self.backup_thread = threading.Thread(
target=self.backup_thread_func, daemon=True
)
self.prefetch_thread.start()
self.backup_thread.start()
def clear_draft_host_pool(self) -> None:
if self.draft_mem_pool_host is not None:
self.draft_mem_pool_host.clear()
def write(
self,
device_indices: torch.Tensor,
priority: Optional[int] = None,
node_id: int = -1,
) -> Optional[torch.Tensor | HiCacheWriteResult | HiCacheWriteFailure]:
"""
Back up KV caches from device memory to host memory.
"""
logger.debug(
"[CacheCtrl-write] write: node_id=%d len=%d cp=%s",
node_id,
len(device_indices),
self.uses_cp_hicache,
)
if self.uses_cp_hicache:
return self._write_cp(device_indices, priority, node_id)
host_indices = self.mem_pool_host.alloc(len(device_indices))
if host_indices is None:
logger.debug(
"[CacheCtrl-write] write non-CP FAILED (host full): node_id=%d len=%d",
node_id,
len(device_indices),
)
return None
self.write_queue.append(
CacheOperation(host_indices, device_indices, node_id, priority)
)
if self.has_draft and not self.uses_cp_hicache:
self.draft_write_queue.append(
CacheOperation(host_indices, device_indices, node_id, priority)
)
self.start_writing()
logger.debug(
"[CacheCtrl-write] write non-CP submitted: node_id=%d len=%d",
node_id,
len(device_indices),
)
return host_indices
def start_writing(self) -> None:
if len(self.write_queue) == 0 and len(self.draft_write_queue) == 0:
return
op = CacheOperation.merge_ops(self.write_queue) if self.write_queue else None
draft_op = (
CacheOperation.merge_ops(self.draft_write_queue)
if self.draft_write_queue
else None
)
node_ids = op.node_ids if op is not None else draft_op.node_ids
if op is not None:
host_indices, device_indices = self.move_indices(op, self.mem_pool_host)
else:
host_indices = device_indices = None
if draft_op is not None:
draft_host_indices, draft_device_indices = self.move_indices(
draft_op, self.draft_mem_pool_host
)
else:
draft_host_indices = draft_device_indices = None
self.write_queue.clear()
self.draft_write_queue.clear()
start_event = device_module.Event()
finish_event = device_module.Event()
start_event.record()
with device_module.stream(self.write_stream):
start_event.wait(self.write_stream)
if op is not None:
self.mem_pool_host.backup_from_device_all_layer(
self.mem_pool_device, host_indices, device_indices, self.io_backend
)
if draft_op is not None:
self.draft_mem_pool_host.backup_from_device_all_layer(
self.draft_mem_pool_device,
draft_host_indices,
draft_device_indices,
self.io_backend,
)
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 not None and host_indices.is_cuda:
host_indices.record_stream(self.write_stream)
if device_indices is not None and device_indices.is_cuda:
device_indices.record_stream(self.write_stream)
if draft_host_indices is not None and draft_host_indices.is_cuda:
draft_host_indices.record_stream(self.write_stream)
if draft_device_indices is not None and draft_device_indices.is_cuda:
draft_device_indices.record_stream(self.write_stream)
self._append_write_ack(HiCacheAck(start_event, finish_event, node_ids))
def _append_write_ack(self, ack: HiCacheAck) -> None:
"""Single funnel for ack_write_queue appends.
Keeps _max_write_ack_node_id (the node_has_undrained_write_ack fast
path) covering every enqueued node id; appending around this helper
would re-open false negatives in the duplicate-reservation gate.
"""
self.ack_write_queue.append(ack)
for node_id in ack.node_ids:
if node_id > self._max_write_ack_node_id:
self._max_write_ack_node_id = node_id
def _append_completed_write_ack(self, node_id: int) -> None:
event = device_module.Event()
event.record()
self._append_write_ack(HiCacheAck(event, event, [node_id]))
def _append_completed_load_ack(self, node_id: int) -> None:
event = device_module.Event()
event.record()
self.ack_load_queue.append(HiCacheAck(event, event, [node_id]))
def _validate_cp_hicache_page_indices(
self,
host_indices: torch.Tensor,
device_indices: torch.Tensor,
) -> None:
"""Validate page-shaped CP HiCache indices without CUDA host sync.
The production invariant is construction-based: HostKVCache alloc/free
preserves page-shaped host spans, and CpSharedKVLayout preserves
per-page contiguity when mapping logical locs to physical locs. The
generic validator uses Tensor truth values and would synchronize CUDA
tensors, so keep the hot path sync-free and validate CPU/fake-test
tensors only.
"""
if not host_indices.is_cuda:
validate_page_aligned_token_indices(
host_indices, self.page_size, "host_indices"
)
if not device_indices.is_cuda:
validate_page_aligned_token_indices(
device_indices, self.page_size, "physical_device_indices"
)
def node_has_undrained_write_ack(self, node_id: int) -> bool:
"""Whether a final write ack for this node is still in ack_write_queue.
The radix side drains acks via writing_check; until then the node's
previous backup owns radix/host state. Creating a second layer-write
state for the same node while its ack is undrained would put two acks
for one radix registration into the queue (one inc_lock_ref, two
completions) — the invariant is at most one in-flight ack per node.
Cost: node ids are strictly monotonic, so fresh ids (the per-request
prepare path) exit on the integer compare below; the queue scan runs
only for re-reservations of old node ids (rare write_backup fallback).
"""
if node_id > getattr(self, "_max_write_ack_node_id", -1):
return False
ack_write_queue = getattr(self, "ack_write_queue", None)
if not ack_write_queue:
return False
return any(node_id in ack.node_ids for ack in ack_write_queue)
def cancel_layer_write_state(self, node_id: int) -> bool:
"""Drop a pending per-layer write state during backup rollback.
Without this, an exception between reservation and final ack leaves a
half-submitted state in pending_layer_writes: the next forward's layer
hooks keep driving it, eventually writing D2H into host slots the
rollback already evicted and appending an ack for a node the radix
side no longer has registered. Synchronize the write stream so any
already-issued per-layer copies finish before the caller frees the
host slots.
"""
state = self.pending_layer_writes.pop(node_id, None)
if state is None:
return False
self.write_stream.synchronize()
return True
def reserve_write_cp(
self,
device_indices: torch.Tensor,
priority: Optional[int] = None,
node_id: int = -1,
) -> HiCacheWriteReservation | HiCacheWriteFailure:
from sglang.srt.mem_cache.hiradix_cache import CpHiCacheNodeMetadata
if node_id >= 0 and self.node_has_undrained_write_ack(node_id):
# Refusing here (instead of crashing later in writing_check) keeps
# the failure on the existing HiCacheWriteFailure path: the caller
# skips this backup round and may retry after the ack drains.
logger.warning(
"[CacheCtrl-write] reserve_write_cp refused: node_id=%d still "
"has an undrained write ack; skipping duplicate backup",
node_id,
)
return HiCacheWriteFailure(required_host_slots=0, reason="undrained_ack")
page_size = self.page_size
layout = self.cp_shared_kv_layout
logical_len = len(device_indices)
padded_device_indices = pad_token_locs_to_page_boundary(
device_indices,
page_size,
name="CP HiCache write device_indices",
)
padded_len = int(padded_device_indices.numel())
owned_mask = layout.owned_by_this_rank(padded_device_indices)
owned_positions = owned_mask.nonzero(as_tuple=True)[0].cpu()
# Capture the global owner pattern (one int8 per logical PAGE; identical
# on all CP ranks since it's a pure function of the logical page ids).
# load_cp will replay this pattern via alloc_pages_with_owners so the
# saved owned_positions correctly index the new allocation.
page_first_locs = padded_device_indices[::page_size]
logical_pages = torch.div(page_first_locs, page_size, rounding_mode="floor")
page_owners = layout.owner_for_logical_pages(logical_pages).to(
dtype=torch.int8, device="cpu"
)
if owned_positions.numel() == 0:
logger.debug(
"[CacheCtrl-write] reserve_write_cp zero-owned rank: node_id=%d logical_len=%d",
node_id,
logical_len,
)
empty = torch.empty((0,), dtype=torch.int64)
return HiCacheWriteReservation(
metadata=CpHiCacheNodeMetadata(
logical_len=logical_len,
padded_len=padded_len,
owned_positions=owned_positions,
host_indices=empty,
page_owners=page_owners,
page_size=page_size,
draft_host_indices=(empty.clone() if self.has_draft_hicache else None),
),
host_indices=empty,
physical_device_indices=empty,
node_id=node_id,
priority=priority,
draft_host_indices=(empty.clone() if self.has_draft_hicache else None),
)
owned_logical_indices = padded_device_indices[owned_mask]
physical_device_indices = layout.logical_locs_to_physical(owned_logical_indices)
host_alloc = getattr(
self.mem_pool_host,
"alloc_contiguous_preferred",
self.mem_pool_host.alloc,
)
host_indices = host_alloc(len(physical_device_indices))
if host_indices is None:
logger.debug(
"[CacheCtrl-write] reserve_write_cp FAILED (host full): node_id=%d logical_len=%d owned=%d",
node_id,
logical_len,
owned_positions.numel(),
)
return HiCacheWriteFailure(required_host_slots=len(physical_device_indices))
draft_host_indices = None
if self.has_draft_hicache:
draft_host_alloc = getattr(
self.draft_mem_pool_host,
"alloc_contiguous_preferred",
self.draft_mem_pool_host.alloc,
)
draft_host_indices = draft_host_alloc(len(physical_device_indices))
if draft_host_indices is None:
self.mem_pool_host.free(host_indices)
logger.debug(
"[CacheCtrl-write] reserve_write_cp FAILED (draft host full): node_id=%d logical_len=%d owned=%d",
node_id,
logical_len,
owned_positions.numel(),
)
return HiCacheWriteFailure(
required_host_slots=len(physical_device_indices)
)
try:
self._validate_cp_hicache_page_indices(
host_indices, physical_device_indices
)
if draft_host_indices is not None:
self._validate_cp_hicache_page_indices(
draft_host_indices, physical_device_indices
)
except Exception:
self.mem_pool_host.free(host_indices)
if draft_host_indices is not None:
self.draft_mem_pool_host.free(draft_host_indices)
raise
return HiCacheWriteReservation(
metadata=CpHiCacheNodeMetadata(
logical_len=logical_len,
padded_len=padded_len,
owned_positions=owned_positions,
host_indices=host_indices.cpu(),
page_owners=page_owners,
page_size=page_size,
draft_host_indices=(
draft_host_indices.cpu() if draft_host_indices is not None else None
),
),
host_indices=host_indices,
physical_device_indices=physical_device_indices,
node_id=node_id,
priority=priority,
draft_host_indices=draft_host_indices,
)
def submit_write_cp_all_layer(self, reservation: HiCacheWriteReservation) -> None:
logger.warning(
"[CP_HICACHE_FALLBACK][submit_write_cp_all_layer] "
"CP HiCache all-layer backup fallback. "
"node_id=%d logical_len=%d owned=%d physical=%d draft=%s",
reservation.node_id,
reservation.metadata.logical_len,
reservation.metadata.owned_positions.numel(),
len(reservation.physical_device_indices),
reservation.draft_host_indices is not None,
)
if len(reservation.physical_device_indices) == 0:
self._append_completed_write_ack(reservation.node_id)
logger.debug(
"[CacheCtrl-write] submit_write_cp_all_layer zero-owned ack: node_id=%d logical_len=%d",
reservation.node_id,
reservation.metadata.logical_len,
)
return
self.write_queue.append(
CacheOperation(
reservation.host_indices,
reservation.physical_device_indices,
reservation.node_id,
reservation.priority,
)
)
if reservation.draft_host_indices is not None:
self.draft_write_queue.append(
CacheOperation(
reservation.draft_host_indices,
reservation.physical_device_indices,
reservation.node_id,
reservation.priority,
)
)
self.start_writing()
logger.debug(
"[CacheCtrl-write] submit_write_cp_all_layer submitted: node_id=%d logical_len=%d owned=%d physical=%d draft=%s",
reservation.node_id,
reservation.metadata.logical_len,
reservation.metadata.owned_positions.numel(),
len(reservation.physical_device_indices),
reservation.draft_host_indices is not None,
)
def _get_or_create_layer_write_state(
self, reservation: HiCacheWriteReservation
) -> HiCacheLayerWriteState:
state = self.pending_layer_writes.get(reservation.node_id)
if state is not None:
if state.reservation is not reservation:
raise RuntimeError(
f"Conflicting CP HiCache per-layer backup reservation for "
f"node_id={reservation.node_id}"
)
return state
draft_layer_num = (
self.draft_mem_pool_device.layer_num
if reservation.draft_host_indices is not None
else 0
)
total_layers = max(self.layer_num, draft_layer_num)
if total_layers <= 0:
raise RuntimeError(
f"Invalid CP HiCache per-layer backup layer count: {total_layers}"
)
state = HiCacheLayerWriteState(
reservation=reservation,
total_layers=total_layers,
start_event=device_module.Event(),
finish_event=device_module.Event(),
)
if len(reservation.physical_device_indices) > 0:
op = CacheOperation(
reservation.host_indices,
reservation.physical_device_indices,
reservation.node_id,
reservation.priority,
)
state.host_indices, state.physical_device_indices = self.move_indices(
op, self.mem_pool_host
)
if reservation.draft_host_indices is not None:
draft_op = CacheOperation(
reservation.draft_host_indices,
reservation.physical_device_indices,
reservation.node_id,
reservation.priority,
)
state.draft_host_indices, _ = self.move_indices(
draft_op, self.draft_mem_pool_host
)
self.pending_layer_writes[reservation.node_id] = state
state.start_event.record()
return state
def _layer_write_state_done(self, state: HiCacheLayerWriteState) -> bool:
target_done = len(state.completed_target_layers) >= self.layer_num
draft_done = (
state.draft_host_indices is None
or len(state.completed_draft_layers)
>= self.draft_mem_pool_device.layer_num
)
return target_done and draft_done
@staticmethod
def _record_tensor_on_stream(tensor: Optional[torch.Tensor], stream) -> None:
if tensor is not None and tensor.is_cuda:
tensor.record_stream(stream)
@staticmethod
def _concat_layer_write_tensors(tensors: List[torch.Tensor]) -> torch.Tensor:
if len(tensors) == 1:
return tensors[0]
return torch.cat(tensors)
def _append_layer_write_ack(self, state: HiCacheLayerWriteState) -> None:
if state.ack_appended:
return
state.ack_appended = True
reservation = state.reservation
self.pending_layer_writes.pop(reservation.node_id, None)
self._append_write_ack(
HiCacheAck(state.start_event, state.finish_event, [reservation.node_id])
)
logger.debug(
"[CacheCtrl-write] submit_write_cp_layer final ack: node_id=%d logical_len=%d layers=%d draft=%s",
reservation.node_id,
reservation.metadata.logical_len,
state.total_layers,
reservation.draft_host_indices is not None,
)
def _submit_write_cp_layer_states(
self,
states: List[HiCacheLayerWriteState],
layer_id: int,
*,
submit_target: bool = True,
submit_draft: bool = True,
) -> None:
"""Submit one layer for a group of reserved CP host backups.
Radix/HiCache metadata remains per node. Only the transfer descriptor is
grouped so bs>1 batches issue one target D2H and one draft D2H per layer
instead of one D2H per request per layer.
"""
if not states:
return
unique_states: List[HiCacheLayerWriteState] = []
seen_state_ids = set()
for state in states:
state_id = id(state)
if state_id in seen_state_ids:
continue
seen_state_ids.add(state_id)
unique_states.append(state)
for state in unique_states:
if layer_id < 0 or layer_id >= state.total_layers:
raise ValueError(
f"layer_id={layer_id} is outside CP HiCache backup layer range "
f"[0, {state.total_layers}) for node_id={state.reservation.node_id}"
)
target_states = [
state
for state in unique_states
if submit_target
and layer_id < self.layer_num
and layer_id not in state.completed_target_layers
]
draft_states = [
state
for state in unique_states
if submit_draft
and state.draft_host_indices is not None
and layer_id < self.draft_mem_pool_device.layer_num
and layer_id not in state.completed_draft_layers
]
if not target_states and not draft_states:
return
active_states: List[HiCacheLayerWriteState] = []
seen_active_ids = set()
for state in target_states + draft_states:
state_id = id(state)
if state_id in seen_active_ids:
continue
seen_active_ids.add(state_id)
active_states.append(state)
layer_event = device_module.Event()
layer_event.record()
for state in active_states:
state.layer_events.append(layer_event)
target_transfer_states = [
state
for state in target_states
if state.physical_device_indices is not None
and len(state.physical_device_indices) > 0
]
draft_transfer_states = [
state
for state in draft_states
if state.physical_device_indices is not None
and len(state.physical_device_indices) > 0
]
grouped_tensors: List[torch.Tensor] = []
final_states: List[HiCacheLayerWriteState] = []
with device_module.stream(self.write_stream):
for state in active_states:
state.start_event.wait(self.write_stream)
layer_event.wait(self.write_stream)
if target_transfer_states:
target_host_indices = self._concat_layer_write_tensors(
[state.host_indices for state in target_transfer_states]
)
target_device_indices = self._concat_layer_write_tensors(
[
state.physical_device_indices
for state in target_transfer_states
]
)
self.mem_pool_host.backup_from_device_per_layer(
self.mem_pool_device,
target_host_indices,
target_device_indices,
layer_id,
self.io_backend,
)
grouped_tensors.extend([target_host_indices, target_device_indices])
if draft_transfer_states:
draft_host_indices = self._concat_layer_write_tensors(
[state.draft_host_indices for state in draft_transfer_states]
)
draft_device_indices = self._concat_layer_write_tensors(
[
state.physical_device_indices
for state in draft_transfer_states
]
)
self.draft_mem_pool_host.backup_from_device_per_layer(
self.draft_mem_pool_device,
draft_host_indices,
draft_device_indices,
layer_id,
self.io_backend,
)
grouped_tensors.extend([draft_host_indices, draft_device_indices])
for tensor in grouped_tensors:
self._record_tensor_on_stream(tensor, self.write_stream)
for state in target_states:
state.completed_target_layers.add(layer_id)
for state in draft_states:
state.completed_draft_layers.add(layer_id)
final_states = [
state
for state in active_states
if self._layer_write_state_done(state) and not state.ack_appended
]
for state in final_states:
state.finish_event.record()
self._record_tensor_on_stream(state.host_indices, self.write_stream)
self._record_tensor_on_stream(
state.physical_device_indices, self.write_stream
)
self._record_tensor_on_stream(
state.draft_host_indices, self.write_stream
)
# Value fingerprint of the EXACT device KV being backed up, taken on the
# DEFAULT stream (outside the write_stream block) so it reflects the
# correct post-store value -- NOT the racy write_stream copy's view. If a
# per-layer store/copy ordering race exists, the host copy will diverge
# from THIS hash, and the reload hash (post-H2D) will not match it.
for state in final_states:
self._append_layer_write_ack(state)
def submit_write_cp_layer(
self,
reservation: HiCacheWriteReservation,
layer_id: int,
*,
submit_target: bool = True,
submit_draft: bool = True,
) -> None:
"""Submit one layer of a reserved CP host backup.
Target and draft D2H are still one logical operation: this method only
queues a final write ack when all target/draft layers have been submitted.
"""
state = self._get_or_create_layer_write_state(reservation)
self._submit_write_cp_layer_states(
[state],
layer_id,
submit_target=submit_target,
submit_draft=submit_draft,
)
def submit_write_cp_per_layer(
self,
reservation: HiCacheWriteReservation,
*,
catch_up_all_layers: bool = True,
) -> None:
"""Register a CP write reservation for per-layer host backup.
Current radix insertion calls write_backup after the request KV has already
been materialized. For that path, catch up by submitting all layers
immediately through the per-layer API. Future early reservations can use
catch_up_all_layers=False and rely on explicit model layer-end hooks.
"""
state = self._get_or_create_layer_write_state(reservation)
if not catch_up_all_layers:
logger.debug(
"[CacheCtrl-write] submit_write_cp_per_layer registered: node_id=%d logical_len=%d layers=%d draft=%s",
reservation.node_id,
reservation.metadata.logical_len,
state.total_layers,
reservation.draft_host_indices is not None,
)
return
logger.warning(
"[CP_HICACHE_FALLBACK][catch_up_all_layers] "
"CP HiCache write is submitting all layers after KV materialization; "
"forward-overlap per-layer backup was not active. "
"node_id=%d logical_len=%d layers=%d draft=%s",
reservation.node_id,
reservation.metadata.logical_len,
state.total_layers,
reservation.draft_host_indices is not None,
)
total_layers = state.total_layers
for layer_id in range(total_layers):
self.submit_write_cp_layer(reservation, layer_id)
def _write_cp(
self,
device_indices: torch.Tensor,
priority: Optional[int] = None,
node_id: int = -1,
) -> HiCacheWriteResult | HiCacheWriteFailure:
reservation = self.reserve_write_cp(device_indices, priority, node_id)
if isinstance(reservation, HiCacheWriteFailure):
return reservation
self.submit_write_cp_per_layer(reservation)
return HiCacheWriteResult(metadata=reservation.metadata)
def set_draft_kv_pool(self, draft_device_pool, draft_host_pool) -> None:
"""Register draft KV pools so L2 ops piggyback draft transfers."""
self.has_draft = True
self.mem_pool_device_draft = draft_device_pool
self.mem_pool_host_draft = draft_host_pool
self.attach_draft_pool(draft_device_pool, draft_host_pool)
logger.info(
"HiCache draft KV registered: %s (host %d slots)",
type(draft_device_pool).__name__,
draft_host_pool.size,
)
def load(
self,
host_indices: torch.Tensor,
priority: Optional[int] = None,
node_id: int = -1,
) -> Optional[torch.Tensor]:
"""
Load KV caches from host memory to device memory.
"""
device_indices = self.mem_pool_device_allocator.alloc(len(host_indices))
if device_indices is None:
return None
self.load_queue.append(
CacheOperation(host_indices, device_indices, node_id, priority)
)
if self.has_draft and not self.uses_cp_hicache:
self.draft_load_queue.append(
CacheOperation(host_indices, device_indices, node_id, priority)
)
return device_indices
def load_cp(self, nodes_to_load, node_id: int = -1) -> Optional[torch.Tensor]:
start_time = time.perf_counter()
stage_start_time = start_time
stage_durations_ms: List[tuple[str, float]] = []
def record_stage(stage: str) -> None:
nonlocal stage_start_time
now = time.perf_counter()
stage_durations_ms.append((stage, (now - stage_start_time) * 1000.0))
stage_start_time = now
# Reproduce the original (write-time) CP owner pattern. Each node
# carries `page_owners` (one int8 per logical page, identical on all
# CP ranks) so we can ask the allocator for a fresh device range
# whose per-page owner sequence matches what was backed up. Without
# this, the saved `owned_positions` (positions WITHIN the original
# alloc that this rank owned) would index a new alloc with arbitrary
# owner pattern → each rank loads its host bytes into physical slots
# whose corresponding logical page is owned by some other rank →
# attention reads garbage at forward time.
page_owners: List[int] = []
for node in nodes_to_load:
meta = node.cp_hicache
if meta is None:
raise RuntimeError(
f"load_cp called with node {getattr(node, 'id', '?')} "
"that has no cp_hicache metadata"
)
# page_owners is CPU int8; .tolist() returns list[int] directly.
page_owners.extend(meta.page_owners.tolist())
record_stage("collect_page_owners")
device_indices = self.mem_pool_device_allocator.alloc_pages_with_owners(
page_owners
)
record_stage("alloc_pages_with_owners")
# Fail closed: returning None lets the caller drop to cache miss
# (cold prefill). Never proceed with a non-matching owner pattern.
if device_indices is None:
_cp_shared_kv_bs_gt1_cache_timing(
"hicache_load_cp_plan",
start_time,
"node_id=%d result=alloc_none pages=%d stages_ms=%s",
node_id,
len(page_owners),
stage_durations_ms,
)
return None
padded_len_expected = sum(
int(getattr(node.cp_hicache, "padded_len", node.host_len))
for node in nodes_to_load
)
if device_indices.numel() != padded_len_expected:
self.mem_pool_device_allocator.free(device_indices)
raise RuntimeError(
"alloc_pages_with_owners returned unexpected length: "
f"got {device_indices.numel()}, expected {padded_len_expected}"
)
host_chunks = []
draft_host_chunks = []
physical_chunks = []
visible_chunks = []
offset = 0
for node in nodes_to_load:
meta = node.cp_hicache
valid_len = int(getattr(meta, "valid_len", node.host_len))
padded_len = int(getattr(meta, "padded_len", node.host_len))
if valid_len != int(node.host_len):
self.mem_pool_device_allocator.free(device_indices)
raise RuntimeError(
"CP HiCache load metadata valid length mismatch: "
f"node_id={getattr(node, 'id', '?')} "
f"host_len={node.host_len} valid_len={valid_len}"
)
if padded_len < valid_len:
self.mem_pool_device_allocator.free(device_indices)
raise RuntimeError(
"CP HiCache load metadata padded length is shorter than "
"valid length: "
f"node_id={getattr(node, 'id', '?')} "
f"valid_len={valid_len} padded_len={padded_len}"
)
node_device_indices = device_indices[offset : offset + padded_len]
offset += padded_len
visible_chunks.append(node_device_indices[:valid_len])
if self.has_draft_hicache:
draft_host_indices = getattr(
meta, "draft_host_indices", None
)
if draft_host_indices is None:
self.mem_pool_device_allocator.free(device_indices)
raise RuntimeError(
"CP HiCache draft KV restore requested but node metadata "
"does not contain draft_host_indices"
)
else:
draft_host_indices = None
owned_positions = meta.owned_positions.to(device_indices.device)
if owned_positions.numel() == 0:
continue
selected_logical_locs = node_device_indices[owned_positions]
# Note: NOT re-validating owner_by_this_rank here. The invariant
# — every position in `owned_positions` lands on a page owned by
# this rank — is guaranteed by construction:
# (a) at write time, `owned_positions = owned_mask.nonzero(...)`
# where `owned_mask = owned_by_this_rank(device_indices)`;
# (b) at load time, `alloc_pages_with_owners(page_owners)` returns
# pages whose owner sequence matches `page_owners` by
# construction (and is debug-asserted inside the allocator).
# Both sides use the same `page_owners` (write-derived, identical
# on all CP ranks). A redundant `.all().item()` check here would
# force a CUDA host-sync — the exact anti-pattern commit 97a9f850c
# removed from the hot path.
physical_chunks.append(
self.cp_shared_kv_layout.logical_locs_to_physical(selected_logical_locs)
)
host_chunks.append(meta.host_indices)
if draft_host_indices is not None:
draft_host_chunks.append(draft_host_indices)
record_stage("build_chunks")
visible_device_indices = (
torch.cat(visible_chunks)
if visible_chunks
else torch.empty(
(0,), dtype=device_indices.dtype, device=device_indices.device
)
)
if not host_chunks:
# Keep CP load ACK rows identical across ranks. A zero-owned rank
# still queues a zero-length op with the logical node id so a later
# batched start_loading() merges the same node_ids as owning ranks.
self.load_queue.append(
CacheOperation(
torch.empty((0,), dtype=torch.int64),
torch.empty(
(0,), dtype=device_indices.dtype, device=device_indices.device
),
node_id,
)
)
_cp_shared_kv_bs_gt1_cache_timing(
"hicache_load_cp_plan",
start_time,
"node_id=%d result=zero_owned pages=%d visible_indices=%d "
"draft=%s stages_ms=%s",
node_id,
len(page_owners),
int(visible_device_indices.numel()),
self.has_draft_hicache,
[(stage, round(ms, 3)) for stage, ms in stage_durations_ms],
)
return visible_device_indices
host_indices = torch.cat(host_chunks)
physical_device_indices = torch.cat(physical_chunks)
draft_host_indices = None
if self.has_draft_hicache:
draft_host_indices = torch.cat(draft_host_chunks)
record_stage("concat_indices")
try:
self._validate_cp_hicache_page_indices(
host_indices, physical_device_indices
)
if draft_host_indices is not None:
self._validate_cp_hicache_page_indices(
draft_host_indices, physical_device_indices
)
record_stage("validate_indices")
except Exception:
self.mem_pool_device_allocator.free(device_indices)
raise
self.load_queue.append(
CacheOperation(
host_indices,
physical_device_indices,
node_id,
)
)
if draft_host_indices is not None:
self.draft_load_queue.append(
CacheOperation(
draft_host_indices,
physical_device_indices,
node_id,
)
)
total_duration = time.perf_counter() - start_time
if total_duration >= 1.0:
logger.warning(
"[HiCache-load] slow load_cp planning: node_id=%d duration_ms=%.3f "
"pages=%d host_indices=%d physical_indices=%d draft=%s stages_ms=%s",
node_id,
total_duration * 1000.0,
len(page_owners),
int(host_indices.numel()),
int(physical_device_indices.numel()),
draft_host_indices is not None,
[(stage, round(ms, 3)) for stage, ms in stage_durations_ms],
)
_cp_shared_kv_bs_gt1_cache_timing(
"hicache_load_cp_plan",
start_time,
"node_id=%d result=queued pages=%d host_indices=%d "
"physical_indices=%d visible_indices=%d draft=%s stages_ms=%s",
node_id,
len(page_owners),
int(host_indices.numel()),
int(physical_device_indices.numel()),
int(visible_device_indices.numel()),
draft_host_indices is not None,
[(stage, round(ms, 3)) for stage, ms in stage_durations_ms],
)
return visible_device_indices
def move_indices(self, op: CacheOperation, mem_pool_host=None):
mem_pool_host = mem_pool_host or self.mem_pool_host
host_indices, device_indices = op.host_indices, op.device_indices
# move indices to GPU if using kernels, to host if using direct indexing
if self.io_backend == "kernel":
if not host_indices.is_cuda:
host_indices = host_indices.to(self.device, non_blocking=True)
return host_indices, device_indices
elif self.io_backend == "direct":
if mem_pool_host.layout == "layer_first":
device_indices = device_indices.cpu()
host_indices, idx = host_indices.sort()
return host_indices, device_indices.index_select(0, idx)
elif mem_pool_host.layout in ("page_first_direct", "layer_page_first"):
return host_indices, device_indices.cpu()
else:
raise ValueError(
f"Unsupported layout {mem_pool_host.layout!r} for io backend 'direct'"
)
elif self.io_backend == "kernel_ascend":
return host_indices, device_indices.cpu()
else:
raise ValueError(f"Unsupported io backend")
def start_loading(self) -> int:
if len(self.load_queue) == 0 and len(self.draft_load_queue) == 0:
return -1
def begin_load_op(mem_pool_host, host_indices, device_indices) -> bool:
begin = getattr(mem_pool_host, "begin_load_to_device_op", None)
if begin is None:
return False
begin(host_indices, device_indices, self.io_backend)
return True
def end_load_op(mem_pool_host) -> None:
end = getattr(mem_pool_host, "end_load_to_device_op", None)
if end is not None:
end()
producer_id = self.layer_done_counter.update_producer()
op = CacheOperation.merge_ops(self.load_queue) if self.load_queue else None
draft_op = (
CacheOperation.merge_ops(self.draft_load_queue)
if self.draft_load_queue
else None
)
node_ids = op.node_ids if op is not None else draft_op.node_ids
if op is not None:
host_indices, device_indices = self.move_indices(op, self.mem_pool_host)
else:
host_indices = device_indices = None
if draft_op is not None:
draft_host_indices, draft_device_indices = self.move_indices(
draft_op, self.draft_mem_pool_host
)
else:
draft_host_indices = draft_device_indices = None
self.load_queue.clear()
self.draft_load_queue.clear()
producer_event = self.layer_done_counter.events[producer_id]
producer_event.start_event.record()
target_load_op_started = False
draft_load_op_started = False
try:
with device_module.stream(self.load_stream):
producer_event.start_event.wait(self.load_stream)
prepare_start = _cp_shared_kv_bs_gt1_cache_timing_start()
if op is not None:
target_load_op_started = begin_load_op(
self.mem_pool_host, host_indices, device_indices
)
if draft_op is not None:
draft_load_op_started = begin_load_op(
self.draft_mem_pool_host,
draft_host_indices,
draft_device_indices,
)
_cp_shared_kv_bs_gt1_cache_timing_log(
"prepare_load_descriptor",
prepare_start,
"target_tokens=%s draft_tokens=%s target_started=%s draft_started=%s",
int(host_indices.numel()) if host_indices is not None else 0,
int(draft_host_indices.numel())
if draft_host_indices is not None
else 0,
target_load_op_started,
draft_load_op_started,
)
draft_layer_num = (
self.draft_mem_pool_device.layer_num if draft_op is not None else 0
)
layer_loop_start = _cp_shared_kv_bs_gt1_cache_timing_start()
for i in range(max(self.layer_num, draft_layer_num)):
layer_submit_start = _cp_shared_kv_bs_gt1_cache_timing_start()
submitted_target = False
submitted_draft = False
if draft_op is not None and i < draft_layer_num:
if len(draft_host_indices) > 0:
self.draft_mem_pool_host.load_to_device_per_layer(
self.draft_mem_pool_device,
draft_host_indices,
draft_device_indices,
i,
self.io_backend,
)
submitted_draft = True
if op is not None and i < self.layer_num:
if len(host_indices) > 0:
self.mem_pool_host.load_to_device_per_layer(
self.mem_pool_device,
host_indices,
device_indices,
i,
self.io_backend,
)
submitted_target = True
# Value fingerprint of what actually landed on device
# after H2D (on load_stream, in-order after the load).
# Compare to backup_kv_hash(node,layer): inequality =
# round-trip delivered wrong KV. node_ids is the merged
# op's nodes (single node in the c=1 rehit pass).
producer_event.complete(i)
elif op is None and i < self.layer_num:
producer_event.complete(i)
_cp_shared_kv_bs_gt1_cache_timing_log(
"submit_h2d_layer_per_call_slow",
layer_submit_start,
"layer_id=%s target=%s draft=%s target_tokens=%s draft_tokens=%s",
i,
submitted_target,
submitted_draft,
int(host_indices.numel()) if host_indices is not None else 0,
int(draft_host_indices.numel())
if draft_host_indices is not None
else 0,
)
_cp_shared_kv_bs_gt1_cache_timing_log(
"submit_h2d_layer_loop",
layer_loop_start,
"layers=%s target_tokens=%s draft_tokens=%s",
max(self.layer_num, draft_layer_num),
int(host_indices.numel()) if host_indices is not None else 0,
int(draft_host_indices.numel())
if draft_host_indices is not None
else 0,
)
# 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 not None and host_indices.is_cuda:
host_indices.record_stream(self.load_stream)
if device_indices is not None and device_indices.is_cuda:
device_indices.record_stream(self.load_stream)
if draft_host_indices is not None and draft_host_indices.is_cuda:
draft_host_indices.record_stream(self.load_stream)
if draft_device_indices is not None and draft_device_indices.is_cuda:
draft_device_indices.record_stream(self.load_stream)
finally:
end_start = _cp_shared_kv_bs_gt1_cache_timing_start()
if draft_load_op_started:
end_load_op(self.draft_mem_pool_host)
if target_load_op_started:
end_load_op(self.mem_pool_host)
_cp_shared_kv_bs_gt1_cache_timing_log(
"end_load_descriptor",
end_start,
"target_started=%s draft_started=%s",
target_load_op_started,
draft_load_op_started,
)
self.ack_load_queue.append(
HiCacheAck(
start_event=producer_event.start_event,
finish_event=producer_event.finish_event,
node_ids=node_ids,
)
)
return producer_id
def evict_device(self, device_indices: torch.Tensor) -> int:
self.mem_pool_device_allocator.free(device_indices)
return len(device_indices)
def evict_host(self, host_indices: torch.Tensor, backup_only: bool = True) -> int:
if not backup_only:
raise ValueError("Other eviction policies are not supported yet.")
self.mem_pool_host.free(host_indices)
return len(host_indices)
def evict_cp_host(self, metadata, backup_only: bool = True) -> int:
if not backup_only:
raise ValueError("Other eviction policies are not supported yet.")
draft_host_indices = getattr(metadata, "draft_host_indices", None)
if self.has_draft_hicache and draft_host_indices is None:
raise RuntimeError(
"CP HiCache draft KV host evict requested but node metadata "
"does not contain draft_host_indices"
)
freed = self.evict_host(metadata.host_indices, backup_only=backup_only)
if self.has_draft_hicache:
self.draft_mem_pool_host.free(draft_host_indices)
return freed
def prefetch(
self,
request_id: str,
host_indices: torch.Tensor,
new_input_tokens: List[int],
last_hash: Optional[str] = None,
prefix_keys: Optional[List[str]] = None,
) -> PrefetchOperation:
"""
Prefetch KV caches from storage backend to host memory.
"""
operation = PrefetchOperation(
request_id, host_indices, new_input_tokens, last_hash, prefix_keys
)
self.prefetch_queue.put(operation)
return operation
def terminate_prefetch(self, operation):
operation.mark_terminate()
return operation.completed_tokens, operation.hash_value
def append_host_mem_release(self, host_indices: torch.Tensor):
if host_indices.numel() == 0:
return
pages = host_indices.split(self.mem_pool_host.page_size)
for page in pages:
self.host_mem_release_queue.put(page)
def _page_get_zero_copy(self, operation, hash_values, host_indices, extra_info=None):
results = self.storage_backend.batch_get_v1(
hash_values, host_indices, extra_info
)
inc = 0
for i in range(len(hash_values)):
if not results[i]:
logger.warning(
f"Prefetch operation {operation.request_id} failed to retrieve page {hash_values[i]}."
)
break
inc += self.page_size
operation.increment(inc)
# todo: deprecate
def _generic_page_get(self, operation, hash_values, host_indices, extra_info=None):
dummy_page_dst = [
self.mem_pool_host.get_dummy_flat_data_page() for _ in hash_values
]
page_data = self.storage_backend.batch_get(hash_values, dummy_page_dst)
if page_data is None:
return
for i in range(len(hash_values)):
if page_data[i] is None:
logger.warning(
f"Prefetch operation {operation.request_id} failed to retrieve page {hash_values[i]}."
)
break
# Must set the data before increasing the completed tokens.
# Otherwise this page may be read before being set.
self.mem_pool_host.set_from_flat_data_page(
host_indices[i * self.page_size],
page_data[i],
)
if not operation.increment(self.page_size):
break # Operation terminated by controller
def _page_transfer(self, operation):
# Transfer batch by batch
prefix_keys = operation.prefix_keys
for i in range(0, len(operation.hash_value), self.storage_batch_size):
batch_hashes = operation.hash_value[i : i + self.storage_batch_size]
batch_host_indices = operation.host_indices[
i * self.page_size : (i + len(batch_hashes)) * self.page_size
]
prev_completed_tokens = operation.completed_tokens
# Get one batch token, and update the completed_tokens if succeed
from sglang.srt.mem_cache.hicache_storage import HiCacheStorageExtraInfo
extra_info = HiCacheStorageExtraInfo(prefix_keys=prefix_keys)
self.page_get_func(operation, batch_hashes, batch_host_indices, extra_info)
# Check termination
if (
operation.completed_tokens
!= prev_completed_tokens + len(batch_hashes) * self.page_size
):
operation.mark_terminate()
break # Some operations fail or operation terminated by controller
if prefix_keys and len(prefix_keys) > 0:
prefix_keys += batch_hashes
def prefetch_io_aux_func(self):
"""
Auxiliary function conducting IO operations for prefetching.
"""
while not self.storage_stop_event.is_set():
try:
operation = self.prefetch_buffer.get(block=True, timeout=1)
if operation is None:
continue
self._page_transfer(operation)
# operation terminated by controller, release pre-allocated memory
self.append_host_mem_release(
operation.host_indices[operation.completed_tokens :]
)
except Empty:
continue
def prefetch_rate_limited(self) -> bool:
"""
Rate limit the prefetching operations to avoid overwhelming the storage backend.
"""
# cancel prefetch if too much memory is occupied
if self.prefetch_tokens_occupied >= self.prefetch_capacity_limit:
return True
# todo: more sophisticated rate limiting based on storage backend performance
return False
def _storage_hit_query(self, operation) -> tuple[list[str], int]:
last_hash = operation.last_hash
tokens_to_fetch = operation.token_ids
prefix_keys = operation.prefix_keys.copy() if operation.prefix_keys else None
storage_query_count = 0
hash_value = []
for start in range(
0, len(tokens_to_fetch), self.page_size * self.storage_batch_size
):
end = min(
start + self.page_size * self.storage_batch_size, len(tokens_to_fetch)
)
batch_tokens = tokens_to_fetch[start:end]
batch_hashes = []
for i in range(0, len(batch_tokens), self.page_size):
last_hash = self.get_hash_str(
batch_tokens[i : i + self.page_size], last_hash
)
batch_hashes.append(last_hash)
from sglang.srt.mem_cache.hicache_storage import HiCacheStorageExtraInfo
extra_info = HiCacheStorageExtraInfo(prefix_keys=prefix_keys)
hit_page_num = self.storage_backend.batch_exists(batch_hashes, extra_info)
hash_value.extend(batch_hashes[:hit_page_num])
storage_query_count += hit_page_num * self.page_size
if hit_page_num < len(batch_hashes):
break
if prefix_keys and len(prefix_keys) > 0:
prefix_keys += batch_hashes
return hash_value, storage_query_count
def prefetch_thread_func(self):
"""
Manage prefetching operations from storage backend to host memory.
"""
self.prefetch_buffer = Queue()
self.prefetch_io_aux_thread = threading.Thread(
target=self.prefetch_io_aux_func, daemon=True
)
self.prefetch_io_aux_thread.start()
while (not self.storage_stop_event.is_set()) or not self.prefetch_queue.empty():
try:
operation = self.prefetch_queue.get(block=True, timeout=1)
if operation is None:
continue
hash_value, storage_hit_count = self._storage_hit_query(operation)
if self.tp_world_size > 1:
storage_hit_count_tensor = torch.tensor(
storage_hit_count, dtype=torch.int
)
torch.distributed.all_reduce(
storage_hit_count_tensor,
op=torch.distributed.ReduceOp.MIN,
group=self.prefetch_tp_group,
)
storage_hit_count = storage_hit_count_tensor.item()
if storage_hit_count < self.prefetch_threshold:
# not to prefetch if not enough benefits
self.prefetch_revoke_queue.put(operation.request_id)
self.append_host_mem_release(operation.host_indices)
logger.info(
f"Revoking prefetch for request {operation.request_id} due to insufficient hits ({storage_hit_count})."
)
else:
operation.hash_value = hash_value[
: (storage_hit_count // self.page_size)
]
# free the pre-allocated memory for pages that are not hit
self.append_host_mem_release(
operation.host_indices[storage_hit_count:]
)
operation.host_indices = operation.host_indices[:storage_hit_count]
logger.info(
f"Prefetching {len(operation.hash_value)} pages for request {operation.request_id}."
)
self.prefetch_buffer.put(operation)
except Empty:
continue
def write_storage(
self,
host_indices: torch.Tensor,
token_ids: List[int],
hash_value: Optional[List[str]] = None,
prefix_keys: Optional[List[str]] = None,
) -> int:
"""
Write KV caches from host memory to storage backend.
"""
operation = StorageOperation(
host_indices, token_ids, hash_value=hash_value, prefix_keys=prefix_keys
)
self.backup_queue.put(operation)
return operation.id
# todo: deprecate
def _generic_page_set(self, hash_values, host_indices, extra_info=None) -> bool:
data = [
self.mem_pool_host.get_data_page(host_indices[i * self.page_size])
for i in range(len(hash_values))
]
return self.storage_backend.batch_set(hash_values, data)
def _page_set_zero_copy(self, hash_values, host_indices, extra_info=None) -> bool:
return all(
self.storage_backend.batch_set_v1(hash_values, host_indices, extra_info)
)
# Backup batch by batch
def _page_backup(self, operation):
# Backup batch by batch
prefix_keys = operation.prefix_keys
for i in range(0, len(operation.hash_value), self.storage_batch_size):
batch_hashes = operation.hash_value[i : i + self.storage_batch_size]
batch_host_indices = operation.host_indices[
i * self.page_size : (i + len(batch_hashes)) * self.page_size
]
# Set one batch token, and record if success.
# todo: allow partial success
from sglang.srt.mem_cache.hicache_storage import HiCacheStorageExtraInfo
extra_info = HiCacheStorageExtraInfo(prefix_keys=prefix_keys)
success = self.page_set_func(batch_hashes, batch_host_indices, extra_info)
if not success:
logger.warning(
f"Write page to storage: {len(batch_hashes)} pages failed."
)
break
if prefix_keys and len(prefix_keys) > 0:
prefix_keys += batch_hashes
operation.completed_tokens += self.page_size * len(batch_hashes)
def backup_thread_func(self):
"""
Manage backup operations from host memory to storage backend.
"""
while not self.storage_stop_event.is_set():
try:
operation = self.backup_queue.get(block=True, timeout=1)
if operation is None:
continue
if not self.backup_skip:
self._page_backup(operation)
self.ack_backup_queue.put(operation)
except Empty:
continue