3440 lines
143 KiB
Python
3440 lines
143 KiB
Python
from __future__ import annotations
|
|
|
|
import atexit
|
|
import logging
|
|
import threading
|
|
import time
|
|
from dataclasses import replace
|
|
from queue import Queue
|
|
from typing import TYPE_CHECKING, Iterator, NamedTuple, Optional, Sequence, TypeVar
|
|
|
|
import torch
|
|
|
|
from sglang.srt.distributed.communication_tags import P2PTag
|
|
from sglang.srt.environ import envs
|
|
from sglang.srt.managers.cache_controller import CacheOperation
|
|
from sglang.srt.mem_cache.allocator.page_interleave import (
|
|
page_interleave_shard_size,
|
|
)
|
|
from sglang.srt.mem_cache.base_prefix_cache import (
|
|
BasePrefixCache,
|
|
DecLockRefParams,
|
|
DecLockRefResult,
|
|
EvictParams,
|
|
EvictResult,
|
|
IncLockRefResult,
|
|
InitLoadBackParams,
|
|
InsertParams,
|
|
InsertResult,
|
|
MatchPrefixParams,
|
|
MatchResult,
|
|
)
|
|
from sglang.srt.mem_cache.buffer_mode.pipeline import (
|
|
BufferModePipeline,
|
|
validate_buffer_only_stack,
|
|
)
|
|
from sglang.srt.mem_cache.buffer_mode.storage_existence_cache import (
|
|
StorageExistenceCache,
|
|
)
|
|
from sglang.srt.mem_cache.common import RetractionBackup
|
|
from sglang.srt.mem_cache.hicache_storage import (
|
|
PoolName,
|
|
PoolTransfer,
|
|
SidecarPoolSpec,
|
|
)
|
|
from sglang.srt.mem_cache.hybrid_cache.hybrid_cache_controller import (
|
|
HybridCacheController,
|
|
)
|
|
from sglang.srt.mem_cache.memory_pool import MHATokenToKVPool
|
|
from sglang.srt.mem_cache.radix_cache import RadixKey
|
|
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
|
|
from sglang.srt.mem_cache.unified_cache.cache_action import (
|
|
BackupKV,
|
|
CacheAction,
|
|
ComponentAction,
|
|
FreeComponentDeviceSlot,
|
|
FreeDeviceKV,
|
|
FreeDeviceKVFullOnly,
|
|
ReplaceWriteThroughOnNodeSplit,
|
|
)
|
|
|
|
# UnifiedTreeNode / UnifiedLRUList live on the tree core; re-exported here
|
|
# because other modules and tests import them from this module.
|
|
from sglang.srt.mem_cache.unified_cache.components import (
|
|
BASE_COMPONENT_TYPE,
|
|
CacheTransferPhase,
|
|
ComponentType,
|
|
FullComponent,
|
|
MambaComponent,
|
|
PrepareLoadBackResult,
|
|
SWAComponent,
|
|
TreeComponent,
|
|
)
|
|
from sglang.srt.mem_cache.unified_cache.session_ref_tracker import (
|
|
UnifiedSessionRefTracker,
|
|
)
|
|
from sglang.srt.mem_cache.unified_cache.storage_attachment import StorageAttachment
|
|
from sglang.srt.mem_cache.unified_cache.tree_core_registry import create_tree_core
|
|
from sglang.srt.mem_cache.unified_cache.unified_cache_linker import (
|
|
UnifiedCacheLinker,
|
|
UnifiedCacheLinkerWrapper,
|
|
)
|
|
from sglang.srt.mem_cache.unified_cache.unified_tree_core import ( # noqa: F401
|
|
NodeId,
|
|
UnifiedLRUList,
|
|
UnifiedTreeCore,
|
|
UnifiedTreeNode,
|
|
)
|
|
from sglang.srt.observability.metrics_collector import (
|
|
StorageMetrics,
|
|
StorageMetricsCollector,
|
|
)
|
|
from sglang.srt.runtime_context import (
|
|
get_memory,
|
|
get_model,
|
|
get_observability,
|
|
)
|
|
from sglang.srt.session.streaming_session import StreamingSession
|
|
from sglang.srt.utils.common import ceil_align
|
|
|
|
if TYPE_CHECKING:
|
|
from sglang.srt.managers.cache_controller import HiCacheAck
|
|
from sglang.srt.managers.schedule_batch import Req
|
|
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
|
|
from sglang.srt.mem_cache.hybrid_cache.hybrid_cache_controller import (
|
|
PrefetchOperation,
|
|
)
|
|
from sglang.srt.mem_cache.pool_host import PoolEntry
|
|
from sglang.srt.server_args import ServerArgs
|
|
|
|
from sglang.srt.utils.rank_consensus_checker import rank_consensus
|
|
|
|
T = TypeVar("T")
|
|
|
|
COMPONENT_REGISTRY: dict[ComponentType, type[TreeComponent]] = {
|
|
ComponentType.FULL: FullComponent,
|
|
ComponentType.MAMBA: MambaComponent,
|
|
ComponentType.SWA: SWAComponent,
|
|
}
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class _OngoingWriteThrough(NamedTuple):
|
|
"""Tracks an in-flight D→H write-through operation."""
|
|
|
|
node_id: NodeId
|
|
lock_params: Optional[DecLockRefParams]
|
|
publish_node_ids: list[NodeId]
|
|
|
|
|
|
class _OngoingLoadBack(NamedTuple):
|
|
"""Tracks an in-flight H→D load-back operation."""
|
|
|
|
node_id: NodeId
|
|
lock_params: DecLockRefParams
|
|
host_lock_params: DecLockRefParams
|
|
|
|
|
|
class _OngoingPrefetch(NamedTuple):
|
|
"""Tracks an in-flight storage→host prefetch operation."""
|
|
|
|
anchor_node_id: NodeId
|
|
prefetch_key: RadixKey
|
|
host_indices: torch.Tensor
|
|
operation: PrefetchOperation
|
|
anchor_lock_params: DecLockRefParams
|
|
comp_xfers: dict[ComponentType, list[PoolTransfer]]
|
|
|
|
|
|
class UnifiedRadixCache(BasePrefixCache):
|
|
def __init__(
|
|
self,
|
|
params: CacheInitParams,
|
|
):
|
|
self.req_to_token_pool = params.req_to_token_pool
|
|
self.token_to_kv_pool_allocator = params.token_to_kv_pool_allocator
|
|
self.disable = params.disable
|
|
|
|
if params.enable_metrics:
|
|
self.init_metrics_collector()
|
|
self._enable_metrics_flag = params.enable_metrics
|
|
self.enable_storage_metrics = False
|
|
self.storage_metrics_collector: Optional[StorageMetricsCollector] = None
|
|
self.extra_metric_labels = None
|
|
|
|
assert params.tree_components is not None
|
|
self.tree_components = tuple(params.tree_components)
|
|
self.enable_session_radix_cache = params.enable_session_radix_cache
|
|
component_registry = COMPONENT_REGISTRY
|
|
if params.component_registry_override:
|
|
component_registry = {
|
|
**COMPONENT_REGISTRY,
|
|
**params.component_registry_override,
|
|
}
|
|
self.components: dict[ComponentType, TreeComponent] = {
|
|
ct: component_registry[ct](self, params) for ct in self.tree_components
|
|
}
|
|
self._components_tuple: tuple[TreeComponent, ...] = tuple(
|
|
self.components.values()
|
|
)
|
|
# Whether SWA is enabled.
|
|
self.is_swa_enabled = ComponentType.SWA in params.tree_components
|
|
# Whether Mamba is enabled.
|
|
self.is_mamba_enabled = ComponentType.MAMBA in params.tree_components
|
|
# Whether the mamba extra (ping-pong) buffer is enabled.
|
|
self.enable_mamba_extra_buffer = (
|
|
params.enable_mamba_extra_buffer if self.is_mamba_enabled else False
|
|
)
|
|
# SWA window size (None when SWA is not enabled).
|
|
self._sliding_window_size = (
|
|
params.sliding_window_size if self.is_swa_enabled else None
|
|
)
|
|
# The TreeCore owns the tree member-var state (structure, LRUs, sizes,
|
|
# evictable leaves) and drives the components' tree-level hooks.
|
|
self._tree_core_backend = envs.SGLANG_UNIFIED_RADIX_TREE_CORE_BACKEND.get()
|
|
self.tree_core = create_tree_core(
|
|
name=self._tree_core_backend,
|
|
params=params,
|
|
components=self.components,
|
|
)
|
|
# Components execute boundary actions through the tree core.
|
|
for component in self.components.values():
|
|
component.tree_core = self.tree_core
|
|
|
|
if (
|
|
page_interleave_shard_size(params.token_to_kv_pool_allocator) > 1
|
|
and not self.tree_core.supports_rotation_base
|
|
):
|
|
# A core that does not model rotation_base would never decline a
|
|
# cross-base graft, and the resulting cached path's page owners are
|
|
# not one cyclic run: later readers take a negative allgather pad or
|
|
# silently read another rank's scratch rows. Fail at construction
|
|
# rather than corrupt reads at serve time.
|
|
raise ValueError(
|
|
"logical-page KV sharding requires a tree core that tracks "
|
|
"rotation bases; "
|
|
f"SGLANG_UNIFIED_RADIX_TREE_CORE_BACKEND={self._tree_core_backend!r} "
|
|
"does not."
|
|
)
|
|
|
|
# Session ref tracking (--enable-session-radix-cache).
|
|
self.session_refs = UnifiedSessionRefTracker(
|
|
components=self._components_tuple,
|
|
tree_core=self.tree_core,
|
|
enable_session_radix_cache=self.enable_session_radix_cache,
|
|
)
|
|
|
|
self.sidecar_pool_specs: list[SidecarPoolSpec] = []
|
|
|
|
# Streaming session: embedded StreamingSession with self as inner.
|
|
# Always on -- zero overhead when no streaming session is open (the
|
|
# try_* entries short-circuit on non-streaming reqs / real TreeNodes).
|
|
# Dispatch methods below pre-check conditions so the session's
|
|
# internal fall-through to self.inner.xxx never fires -- no recursion.
|
|
self.session = StreamingSession(inner=self)
|
|
|
|
self.tp_group = params.tp_cache_group
|
|
self.attn_cp_group = params.attn_cp_cache_group
|
|
self.attn_tp_group = params.attn_tp_cache_group
|
|
self.pp_group = params.pp_cache_group
|
|
self.tp_world_size = (
|
|
1
|
|
if self.tp_group is None
|
|
else torch.distributed.get_world_size(group=self.tp_group)
|
|
)
|
|
self.pp_rank = params.pp_rank
|
|
self.pp_size = params.pp_size
|
|
self.work_list: list[torch.distributed.Work] = []
|
|
|
|
# HiCache D↔H defaults (overridden by init_hicache)
|
|
self.cache_controller: Optional[HybridCacheController] = None
|
|
self.host_pool_group = None # set by attach_hybrid_pool_to_unified_cache
|
|
# Owns the storage backend lifecycle; built by init_hicache.
|
|
self._storage_attachment: Optional[StorageAttachment] = None
|
|
self.linker: Optional[UnifiedCacheLinkerWrapper] = None
|
|
self.prefetch_stop_policy = "best_effort"
|
|
self.prefetch_threshold = 256
|
|
self.prefetch_timeout_base = 1.0
|
|
self.prefetch_timeout_per_page = 0.25
|
|
self.hicache_storage_pass_prefix_keys = False
|
|
# Buffer-only host memory mode (host RAM as transient GPU↔storage
|
|
# staging, not an L2 tier); resolved in init_hicache, which also
|
|
# constructs the pipeline collaborator (None = cache mode).
|
|
self.host_memory_mode = "cache"
|
|
self.buffer_pipeline: Optional[BufferModePipeline] = None
|
|
# Write-side dedupe: beliefs about what storage already holds, so
|
|
# re-inserts of hot prefixes skip the redundant backup.
|
|
self.storage_existence_cache = StorageExistenceCache()
|
|
# Cumulative prefetch-outcome counters, exported through the
|
|
# log_storage_metrics flow.
|
|
self._prefetch_outcome_stats: dict[str, float] = {
|
|
"attempts": 0,
|
|
"issued": 0,
|
|
"declined_too_short": 0,
|
|
"declined_rate_limited": 0,
|
|
"declined_anchor_lost": 0,
|
|
"declined_device_covered": 0,
|
|
"revoked_insufficient": 0,
|
|
"revoked_full_miss": 0,
|
|
"l3_demand_requests": 0,
|
|
"l3_miss_tokens": 0,
|
|
"l1l2_miss_tokens": 0,
|
|
}
|
|
|
|
self.reset()
|
|
logger.info(
|
|
f"Init Unified Radix Cache. Components: {self.tree_components}. "
|
|
f"Tree Core: {type(self.tree_core).__name__}"
|
|
)
|
|
|
|
def _all_reduce_attn_groups(self, tensor: torch.Tensor, op):
|
|
reduced = False
|
|
for group in (self.attn_cp_group, self.attn_tp_group):
|
|
if group is not None and torch.distributed.get_world_size(group=group) > 1:
|
|
torch.distributed.all_reduce(tensor, op=op, group=group)
|
|
reduced = True
|
|
if not reduced and self.tp_world_size > 1:
|
|
torch.distributed.all_reduce(tensor, op=op, group=self.tp_group)
|
|
|
|
def _barrier_attn_groups(self):
|
|
waited = False
|
|
for group in (self.attn_cp_group, self.attn_tp_group):
|
|
if group is not None and torch.distributed.get_world_size(group=group) > 1:
|
|
torch.distributed.barrier(group=group)
|
|
waited = True
|
|
if not waited and self.tp_world_size > 1:
|
|
torch.distributed.barrier(group=self.tp_group)
|
|
|
|
def _drain_async_work(self):
|
|
"""
|
|
Block until all outstanding async sends are consumed, then clear.
|
|
|
|
Called at the start of each event round, so work_list holds the sends
|
|
accumulated since the last round. This bounds it and applies
|
|
backpressure when a downstream PP rank lags. Scheduler thread only.
|
|
"""
|
|
for work in self.work_list:
|
|
work.wait()
|
|
self.work_list.clear()
|
|
|
|
def _all_reduce(self, data: torch.Tensor, tp_reduce_op: torch.distributed.ReduceOp):
|
|
"""
|
|
Synchronize data across all TP and PP ranks.
|
|
|
|
In particular, "tp_reduce_op" is performed on all TP ranks of the first PP rank,
|
|
and then the result is propagated to all following PP ranks.
|
|
|
|
Must be called in the scheduler thread.
|
|
"""
|
|
if self.pp_rank == 0:
|
|
self._all_reduce_attn_groups(data, tp_reduce_op)
|
|
self._pp_sync(data)
|
|
|
|
def _pp_sync(self, data: torch.Tensor) -> None:
|
|
"""
|
|
Synchronize data across the PP pipeline, where PPn (n>0) will receive PP0's data.
|
|
"""
|
|
if self.pp_size <= 1 or self.pp_group is None:
|
|
return
|
|
if self.pp_rank > 0:
|
|
torch.distributed.recv(
|
|
data,
|
|
group_src=self.pp_rank - 1,
|
|
group=self.pp_group,
|
|
tag=P2PTag.HIRADIX_PP_SYNC,
|
|
)
|
|
if self.pp_rank + 1 < self.pp_size:
|
|
copy_of_data = data.clone()
|
|
send_work = torch.distributed.isend(
|
|
copy_of_data,
|
|
group_dst=self.pp_rank + 1,
|
|
group=self.pp_group,
|
|
tag=P2PTag.HIRADIX_PP_SYNC,
|
|
)
|
|
self.work_list.append(send_work)
|
|
|
|
def init_cache_linker(self, cache_linker: UnifiedCacheLinker) -> None:
|
|
"""Attach an external KV store directly to the device pools."""
|
|
self.linker = UnifiedCacheLinkerWrapper(self, cache_linker)
|
|
|
|
def reset(self) -> None:
|
|
if self.linker is not None:
|
|
self.linker.reset()
|
|
self._reset_full()
|
|
|
|
def _reset_full(self) -> None:
|
|
"""Full reset: destroy entire tree and all state."""
|
|
self.tree_core.reset()
|
|
self.session_refs.reset()
|
|
|
|
# Reset Controller.
|
|
self.session.slots.clear()
|
|
self.ongoing_write_through: dict[int, _OngoingWriteThrough] = {}
|
|
self.ongoing_load_back: dict[int, _OngoingLoadBack] = {}
|
|
self.enable_storage = False
|
|
self.prefetch_loaded_tokens_by_reqid: dict[str, int] = {}
|
|
self.prefetch_loaded_storage_start_by_reqid: dict[str, int] = {}
|
|
self.ongoing_prefetch: dict[str, _OngoingPrefetch] = {}
|
|
# Rank-agreed L3-hit tokens not yet resolved as usable or unfulfilled.
|
|
# Cache-mode entries survive L3->L2 until H2D succeeds or admission
|
|
# fails; buffer-mode entries survive staging until the H2D ack.
|
|
self._storage_prefetch_hit_remaining_by_reqid: dict[str, int] = {}
|
|
# Rids whose storage prefetch resolved without a usable result;
|
|
# popped by the scheduler to pace availability-check retries.
|
|
self._storage_prefetch_missed_rids: set[str] = set()
|
|
self.ongoing_backup: dict[int, tuple[NodeId, DecLockRefParams]] = {}
|
|
if self.buffer_pipeline is not None:
|
|
self.buffer_pipeline.reset()
|
|
|
|
if self.cache_controller is not None:
|
|
self.cache_controller.reset()
|
|
self.cache_controller.mem_pool_host.clear()
|
|
self.enable_storage = self.cache_controller.enable_storage
|
|
|
|
self.tree_core.kv_events.record_all_cleared()
|
|
|
|
def init_hicache(self, server_args: ServerArgs, params: CacheInitParams) -> None:
|
|
"""Initialize HiCache infrastructure."""
|
|
self.host_memory_mode = get_memory().hicache_host_memory_mode
|
|
if self.host_memory_mode == "buffer_only":
|
|
# TODO(Jialin): Extend buffer-only state handoff to Mamba in a
|
|
# follow-up to #34798 and #35769.
|
|
# FULL and FULL+SWA only: Mamba has no state-handoff channel on
|
|
# the admission-time load-back read path and is not layer-gated.
|
|
# Lifting the fence also needs the admission charge: a staged
|
|
# state slot is request-pinned at consumption and must ride
|
|
# req.mamba_host_hit_length the way the SWA window does.
|
|
supported = {ComponentType.FULL, ComponentType.SWA}
|
|
if not set(self.tree_components) <= supported:
|
|
raise ValueError(
|
|
"--hicache-host-memory-mode buffer_only supports only "
|
|
"FULL/SWA unified trees; got components "
|
|
f"{sorted(ct.name for ct in self.tree_components)}."
|
|
)
|
|
from sglang.srt.mem_cache.hybrid_cache.hybrid_pool_assembler import (
|
|
attach_hybrid_pool_to_unified_cache,
|
|
)
|
|
|
|
self.load_cache_event = threading.Event()
|
|
self.sidecar_pool_specs.clear()
|
|
self.extra_metric_labels = get_observability().extra_metric_labels
|
|
|
|
# Parse storage config once, share with assembler and tree
|
|
storage_backend = get_memory().hicache_storage_backend
|
|
storage_extra_config = None
|
|
storage_prefetch_threshold = 256
|
|
prefetch_timeout_base = 1.0
|
|
prefetch_timeout_per_ki_token = 0.25
|
|
hicache_storage_pass_prefix_keys = False
|
|
if storage_backend is not None:
|
|
(
|
|
storage_extra_config,
|
|
storage_prefetch_threshold,
|
|
prefetch_timeout_base,
|
|
prefetch_timeout_per_ki_token,
|
|
hicache_storage_pass_prefix_keys,
|
|
) = HybridCacheController.parse_storage_backend_extra_config(
|
|
get_memory().hicache_storage_backend_extra_config
|
|
)
|
|
|
|
attach_hybrid_pool_to_unified_cache(
|
|
self,
|
|
params,
|
|
server_args,
|
|
load_cache_event=self.load_cache_event,
|
|
storage_backend=storage_backend,
|
|
storage_extra_config=storage_extra_config,
|
|
storage_prefetch_threshold=storage_prefetch_threshold,
|
|
)
|
|
# Tag HiCache enablement on the TreeCore.
|
|
if self.cache_controller is not None:
|
|
self.tree_core.set_hicache_enabled()
|
|
if self.supports_swa():
|
|
swa = self.components[ComponentType.SWA]
|
|
self.tree_core.has_swa_host_pool = swa._swa_kv_pool_host is not None
|
|
|
|
if self.host_memory_mode == "buffer_only":
|
|
self.tree_core.set_host_memory_buffer_only()
|
|
swa = self.components.get(ComponentType.SWA)
|
|
validate_buffer_only_stack(
|
|
sidecar_pool_specs=self.sidecar_pool_specs,
|
|
host_pool_group=self.host_pool_group,
|
|
swa_component=swa,
|
|
)
|
|
self.buffer_pipeline = BufferModePipeline(
|
|
cache=self,
|
|
max_context_len=get_model().context_length or 0,
|
|
swa_window_pages=(
|
|
swa.full_window_pages
|
|
if swa is not None and self.tree_core.has_swa_host_pool
|
|
else 0
|
|
),
|
|
# Leak backstop only: live queued tokens are intrinsically
|
|
# bounded by the FULL device pool (one intent per node, stale
|
|
# intents swept per tick), so a cap that binds on live
|
|
# content would drop-newest and punch storage holes.
|
|
write_backlog_cap=2 * self.token_to_kv_pool_allocator.size_full,
|
|
)
|
|
self.cache_controller.host_write_staged_tokens_fn = lambda: (
|
|
self.buffer_pipeline.write_staged_tokens_
|
|
)
|
|
|
|
# State initialization
|
|
self.write_through_threshold = (
|
|
1 if get_memory().hicache_write_policy == "write_through" else 2
|
|
)
|
|
self.is_write_back = (
|
|
self.cache_controller is not None
|
|
and self.cache_controller.write_policy == "write_back"
|
|
)
|
|
# Pre-seed the logical dropped-tokens series.
|
|
if self.metrics_collector is not None and self.cache_controller is not None:
|
|
reasons = ["host_pressure"]
|
|
if self._tracks_write_through_unbacked_evictions():
|
|
reasons.append("write_through_unbacked_eviction")
|
|
for reason in reasons:
|
|
self.metrics_collector.increment_dropped_tokens(
|
|
num_tokens=0,
|
|
reason=reason,
|
|
pool=PoolName.KV.value,
|
|
)
|
|
self.load_back_threshold = 10
|
|
self.prefetch_stop_policy = get_memory().hicache_storage_prefetch_policy
|
|
|
|
# Runtime attach/detach of the L3 backend (startup, admin API, atexit).
|
|
self._storage_attachment = StorageAttachment(self)
|
|
atexit.register(self.shutdown)
|
|
|
|
if storage_backend is not None:
|
|
self._storage_attachment.apply_runtime_config(
|
|
storage_backend=storage_backend,
|
|
prefetch_threshold=storage_prefetch_threshold,
|
|
prefetch_timeout_base=prefetch_timeout_base,
|
|
prefetch_timeout_per_ki_token=prefetch_timeout_per_ki_token,
|
|
hicache_storage_pass_prefix_keys=hicache_storage_pass_prefix_keys,
|
|
enable_storage=self.cache_controller.enable_storage,
|
|
enable_storage_metrics=self._enable_metrics_flag,
|
|
extra_metric_labels=self.extra_metric_labels,
|
|
)
|
|
|
|
def register_sidecar_pool(
|
|
self, spec: SidecarPoolSpec, entry: Optional[PoolEntry] = None
|
|
) -> None:
|
|
if entry is not None:
|
|
if self.cache_controller is None:
|
|
raise RuntimeError("HiCache controller is not attached.")
|
|
self.cache_controller.register_host_pool_entry(entry)
|
|
self.sidecar_pool_specs.append(spec)
|
|
|
|
def release_host_resources(self) -> None:
|
|
if self.linker is not None:
|
|
self.linker.close()
|
|
if self.host_pool_group is not None:
|
|
self.host_pool_group.destroy()
|
|
|
|
@rank_consensus(
|
|
same_params=["params"],
|
|
same_results=["result.full_kv_hit_length", "result.swa_host_hit_length"],
|
|
)
|
|
def match_prefix(self, params: MatchPrefixParams) -> MatchResult:
|
|
result = self.session.try_match_prefix(params)
|
|
if result is not None:
|
|
return result
|
|
if self.disable:
|
|
return self.tree_core.empty_match_result
|
|
result = self.tree_core.match_prefix(params)
|
|
# Apply the walk's actions (e.g. a pending write-through relocation on
|
|
# a split) before the finalizers, which can evict or raise.
|
|
self._apply_cache_actions(result.cache_actions)
|
|
for component in self._components_tuple:
|
|
result = component.finalize_match_result_in_cache(params, result)
|
|
# Finalizers must not emit actions; the walk's were applied above.
|
|
assert not result.cache_actions
|
|
if self.linker is not None and params.req is not None:
|
|
result = self.linker.match(params.key, params.req, result)
|
|
return result
|
|
|
|
def supports_fast_match_prefix(self) -> bool:
|
|
return self.tree_core.supports_fast_match_prefix()
|
|
|
|
def is_chunk_cache(self) -> bool:
|
|
return self.disable
|
|
|
|
def insert(self, params: InsertParams) -> InsertResult:
|
|
if self.disable:
|
|
return InsertResult(prefix_len=0)
|
|
# Fail fast on re-entrancy without touching the in-flight walk.
|
|
assert not self.tree_core.has_ongoing_insert(), "re-entrant insert"
|
|
# Pump the resumable insert, applying each step's actions at its barrier.
|
|
try:
|
|
step = self.tree_core.begin_insert(params)
|
|
while True:
|
|
self._apply_cache_actions(step.actions)
|
|
if step.result is not None:
|
|
# Walk actions flow through the steps; the result is action-free.
|
|
assert not step.result.cache_actions
|
|
return step.result
|
|
step = self.tree_core.resume_insert()
|
|
finally:
|
|
# Drain still-pending actions so frees reach the allocator on abort.
|
|
self._apply_cache_actions(self.tree_core.end_insert())
|
|
|
|
def evict(self, params: EvictParams) -> EvictResult:
|
|
return self._evict(params)
|
|
|
|
def evict_for_alloc(self, params: EvictParams) -> EvictResult:
|
|
"""Evict until the requested component allocations become feasible.
|
|
|
|
``params`` contains allocator shortfalls, not absolute eviction quotas.
|
|
A component eviction can cascade to its peers; with a shared memory pool,
|
|
those collateral frees can satisfy the original allocation before the
|
|
triggering component's requested count is reached.
|
|
"""
|
|
if self.disable:
|
|
return EvictResult()
|
|
|
|
request_by_type = self._evict_request_by_type(params)
|
|
available_size_targets = {
|
|
ct: (ct, self._component_available_size(ct) + request_cnt)
|
|
for ct, request_cnt in request_by_type.items()
|
|
if request_cnt > 0
|
|
}
|
|
allocator = self.token_to_kv_pool_allocator
|
|
mamba_full_donor = allocator.mamba_full_cache_donor()
|
|
mamba_target = available_size_targets.get(ComponentType.MAMBA)
|
|
initial_params = params
|
|
if mamba_target is not None and mamba_full_donor is not None:
|
|
# Full KV can supply bytes but cannot recycle Mamba virtual IDs.
|
|
mamba_id_shortfall = max(
|
|
0,
|
|
mamba_target[1]
|
|
- self.req_to_token_pool.mamba_allocator.available_size(),
|
|
)
|
|
initial_params = EvictParams(
|
|
num_tokens=params.num_tokens,
|
|
swa_num_tokens=params.swa_num_tokens,
|
|
mamba_num=mamba_id_shortfall,
|
|
)
|
|
result = self._evict(initial_params, available_size_targets)
|
|
|
|
if mamba_target is not None and mamba_full_donor is not None:
|
|
mamba_full_donor.prepare_mamba_allocation(mamba_target[1])
|
|
mamba_free_ids = self.req_to_token_pool.mamba_allocator.available_size()
|
|
mamba_capacity = self._component_available_size(ComponentType.MAMBA)
|
|
|
|
if mamba_free_ids >= mamba_target[1] and mamba_capacity < mamba_target[1]:
|
|
full_evictable = self.full_evictable_size()
|
|
if full_evictable > 0:
|
|
donor_result = self._evict(
|
|
EvictParams(num_tokens=full_evictable),
|
|
{ComponentType.FULL: mamba_target},
|
|
)
|
|
result.num_tokens_evicted += donor_result.num_tokens_evicted
|
|
result.swa_num_tokens_evicted += donor_result.swa_num_tokens_evicted
|
|
result.mamba_num_evicted += donor_result.mamba_num_evicted
|
|
|
|
# Preserve Mamba-victim recovery if Full cannot fund the target.
|
|
if (
|
|
self._component_available_size(ComponentType.MAMBA)
|
|
< mamba_target[1]
|
|
):
|
|
mamba_evictable = self.mamba_evictable_size()
|
|
if mamba_evictable > 0:
|
|
fallback_result = self._evict(
|
|
EvictParams(mamba_num=mamba_evictable),
|
|
{ComponentType.MAMBA: mamba_target},
|
|
)
|
|
result.num_tokens_evicted += fallback_result.num_tokens_evicted
|
|
result.swa_num_tokens_evicted += (
|
|
fallback_result.swa_num_tokens_evicted
|
|
)
|
|
result.mamba_num_evicted += fallback_result.mamba_num_evicted
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def _evict_request_by_type(params: EvictParams) -> dict[ComponentType, int]:
|
|
return {
|
|
ComponentType.FULL: params.num_tokens,
|
|
ComponentType.SWA: params.swa_num_tokens,
|
|
ComponentType.MAMBA: params.mamba_num,
|
|
ComponentType.C128: 0,
|
|
}
|
|
|
|
def _component_available_size(self, component_type: ComponentType) -> int:
|
|
"""Return capacity usable by the component's next allocation.
|
|
|
|
Shared allocators expose schedulable capacity, which includes peer holes
|
|
that an urgent allocator flush can reclaim without further eviction.
|
|
"""
|
|
if component_type == ComponentType.FULL:
|
|
if self.supports_swa():
|
|
return self.token_to_kv_pool_allocator.full_available_size()
|
|
return self.token_to_kv_pool_allocator.available_size()
|
|
if component_type == ComponentType.SWA:
|
|
return self.token_to_kv_pool_allocator.swa_available_size()
|
|
if component_type == ComponentType.MAMBA:
|
|
return self.req_to_token_pool.mamba_allocator.schedulable_available_size()
|
|
raise ValueError(f"Unsupported cache component: {component_type}")
|
|
|
|
def _evict(
|
|
self,
|
|
params: EvictParams,
|
|
available_size_targets: Optional[
|
|
dict[ComponentType, tuple[ComponentType, int]]
|
|
] = None,
|
|
) -> EvictResult:
|
|
if self.disable:
|
|
return EvictResult()
|
|
start_time = time.perf_counter()
|
|
tracker = {ct: 0 for ct in self.tree_components}
|
|
|
|
request_by_type = self._evict_request_by_type(params)
|
|
self._evict_components(
|
|
request_by_type,
|
|
tracker,
|
|
available_size_targets=available_size_targets,
|
|
)
|
|
|
|
if (
|
|
self.cache_controller is not None
|
|
and self.cache_controller.write_policy == "write_back"
|
|
):
|
|
self.writing_check(write_back=True)
|
|
|
|
# Report full-layer tokens only
|
|
self.update_eviction_metrics(tracker[BASE_COMPONENT_TYPE], start_time)
|
|
return EvictResult(
|
|
num_tokens_evicted=tracker[BASE_COMPONENT_TYPE],
|
|
swa_num_tokens_evicted=tracker.get(ComponentType.SWA, 0),
|
|
mamba_num_evicted=tracker.get(ComponentType.MAMBA, 0),
|
|
)
|
|
|
|
def _free_values(
|
|
self,
|
|
device_frees: dict[ComponentType, list[torch.Tensor]],
|
|
host_frees: dict[ComponentType, list[torch.Tensor]],
|
|
) -> None:
|
|
"""Free a tree-side step's returned device and host values right away."""
|
|
# Both drains must run even if one raises.
|
|
try:
|
|
self._drain_device_frees(device_frees)
|
|
finally:
|
|
self._drain_host_frees(host_frees)
|
|
|
|
def _accumulate_tracker(
|
|
self,
|
|
tracker: dict[ComponentType, int],
|
|
delta: dict[ComponentType, int],
|
|
) -> None:
|
|
"""Fold a step result's evicted delta into the running totals."""
|
|
for ct, n in delta.items():
|
|
tracker[ct] += n
|
|
|
|
def _evict_device_next_node(
|
|
self, component_type: ComponentType, tracker: dict[ComponentType, int]
|
|
) -> tuple[Optional[NodeId], bool]:
|
|
"""Advance the eviction walk one node, consuming its step result."""
|
|
result = self.tree_core.evict_device_next_node(component_type, tracker)
|
|
self._free_values(result.device_frees, result.host_frees)
|
|
if self._tracks_write_through_unbacked_evictions():
|
|
self._record_dropped_tokens(
|
|
result.unbacked_tokens,
|
|
reason="write_through_unbacked_eviction",
|
|
)
|
|
self._accumulate_tracker(tracker, result.tracker)
|
|
return result.node_id, result.made_progress
|
|
|
|
def _evict_device_leaf(
|
|
self, node_id: NodeId, tracker: dict[ComponentType, int]
|
|
) -> Optional[BackupKV]:
|
|
"""Evict one device leaf, consuming its step result; returns the
|
|
deferred write-back BackupKV when one must run before the demote."""
|
|
result = self.tree_core.evict_device_leaf(node_id, self.is_write_back)
|
|
self._free_values(result.device_frees, result.host_frees)
|
|
if self._tracks_write_through_unbacked_evictions():
|
|
self._record_dropped_tokens(
|
|
result.unbacked_tokens,
|
|
reason="write_through_unbacked_eviction",
|
|
)
|
|
self._accumulate_tracker(tracker, result.tracker)
|
|
return result.backup_kv
|
|
|
|
def _demote(self, node_id: NodeId, tracker: dict[ComponentType, int]) -> None:
|
|
"""Demote a backed-up node, consuming its step result."""
|
|
result = self.tree_core.demote(node_id)
|
|
self._free_values(result.device_frees, result.host_frees)
|
|
self._accumulate_tracker(tracker, result.tracker)
|
|
|
|
def _drop_subtree_no_host(
|
|
self, node_id: NodeId, tracker: dict[ComponentType, int]
|
|
) -> bool:
|
|
"""Run the write-back drop fallback, consuming its step result."""
|
|
result = self.tree_core.drop_subtree_no_host(node_id)
|
|
self._free_values(result.device_frees, result.host_frees)
|
|
if result.is_dropped:
|
|
self._record_dropped_tokens(
|
|
result.tracker.get(BASE_COMPONENT_TYPE, 0), reason="host_pressure"
|
|
)
|
|
self._accumulate_tracker(tracker, result.tracker)
|
|
return result.is_dropped
|
|
|
|
def _evict_components(
|
|
self,
|
|
request_by_type: dict[ComponentType, int],
|
|
tracker: dict[ComponentType, int],
|
|
available_size_targets: Optional[
|
|
dict[ComponentType, tuple[ComponentType, int]]
|
|
] = None,
|
|
) -> None:
|
|
# Buffer mode: eviction always wins over queued backup intents — a
|
|
# destroyed victim's intent is stale-swept and the content rewrites
|
|
# after its recompute.
|
|
last_mamba_donor_check = 0
|
|
mamba_donor_prepared = False
|
|
|
|
def target_reached(component_type: ComponentType) -> bool:
|
|
nonlocal last_mamba_donor_check, mamba_donor_prepared
|
|
if available_size_targets is None:
|
|
return False
|
|
target = available_size_targets.get(component_type)
|
|
if target is None:
|
|
return False
|
|
target_component, target_size = target
|
|
# A Full-leaf cascade can release Mamba or SWA state directly.
|
|
if self._component_available_size(target_component) >= target_size:
|
|
return True
|
|
if (
|
|
component_type == ComponentType.FULL
|
|
and target_component == ComponentType.MAMBA
|
|
):
|
|
donor = self.token_to_kv_pool_allocator.mamba_full_cache_donor()
|
|
assert donor is not None, "Mamba target requires a Full donor"
|
|
recheck_after = (
|
|
1
|
|
if mamba_donor_prepared
|
|
else donor.full_tokens_before_mamba_recheck(target_size)
|
|
)
|
|
if tracker[component_type] - last_mamba_donor_check < recheck_after:
|
|
return False
|
|
donor.prepare_mamba_allocation(target_size)
|
|
last_mamba_donor_check = tracker[component_type]
|
|
mamba_donor_prepared = True
|
|
# Schedulable capacity includes donor holes that allocation can compact.
|
|
return self._component_available_size(target_component) >= target_size
|
|
|
|
for ct in self.tree_components:
|
|
request_cnt = request_by_type[ct]
|
|
# A preceding component may have cascade-evicted this component or,
|
|
# on a shared pool, released enough bytes to satisfy its allocation.
|
|
if tracker[ct] >= request_cnt or target_reached(ct):
|
|
continue
|
|
self.tree_core.evict_device_start(ct, request_cnt)
|
|
try:
|
|
while True:
|
|
node_id, made_progress = self._evict_device_next_node(ct, tracker)
|
|
if node_id is None:
|
|
if not made_progress:
|
|
break
|
|
else:
|
|
backup_kv = self._evict_device_leaf(node_id, tracker)
|
|
if node_id is not None and backup_kv is not None:
|
|
# Deferred demote: run the D->H backup, demote only on success.
|
|
written = self._execute_and_commit_kv_backup(
|
|
backup_kv, write_back=True
|
|
)
|
|
if written > 0:
|
|
self.writing_check(write_back=True)
|
|
self._demote(node_id, tracker)
|
|
elif self._drop_subtree_no_host(node_id, tracker):
|
|
logger.warning(
|
|
"write_back: KV subtree dropped without backup "
|
|
"due to host memory pressure, root node %d",
|
|
node_id,
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"write_back: backup failed under host memory "
|
|
"pressure but subtree drop declined (node "
|
|
"locked); root node %d stays device-resident "
|
|
"until host space frees",
|
|
node_id,
|
|
)
|
|
if target_reached(ct):
|
|
break
|
|
finally:
|
|
self.tree_core.evict_device_end(ct)
|
|
|
|
def _tracks_write_through_unbacked_evictions(self) -> bool:
|
|
return (
|
|
isinstance(self.tree_core, UnifiedTreeCore)
|
|
and self.host_memory_mode == "cache"
|
|
and self.cache_controller is not None
|
|
and self.cache_controller.write_policy == "write_through"
|
|
)
|
|
|
|
def _record_dropped_tokens(self, dropped_tokens: int, reason: str) -> None:
|
|
"""Record logical KV tokens irreversibly dropped without a host backup."""
|
|
if self.metrics_collector is None or dropped_tokens <= 0:
|
|
return
|
|
self.metrics_collector.increment_dropped_tokens(
|
|
num_tokens=dropped_tokens,
|
|
reason=reason,
|
|
pool=PoolName.KV.value,
|
|
)
|
|
|
|
def inc_lock_ref(
|
|
self, node_id: NodeId, skip_lock_components: Sequence[ComponentType] = ()
|
|
) -> IncLockRefResult:
|
|
result = self.session.try_inc_lock_ref(node_id)
|
|
if result is not None:
|
|
return result
|
|
if self.disable:
|
|
return IncLockRefResult()
|
|
return self.tree_core.inc_lock_ref(node_id, skip_lock_components)
|
|
|
|
def dec_lock_ref(
|
|
self,
|
|
node_id: NodeId,
|
|
params: DecLockRefParams,
|
|
skip_swa: bool = False,
|
|
) -> DecLockRefResult:
|
|
result = self.session.try_dec_lock_ref(node_id, params)
|
|
if result is not None:
|
|
return result
|
|
if self.disable:
|
|
return DecLockRefResult()
|
|
return self.tree_core.dec_lock_ref(node_id, params, skip_swa)
|
|
|
|
def _dec_req_lock(self, req: Req, *, skip_swa: bool = False) -> None:
|
|
"""Release the tree lock a request holds on its last_node with the
|
|
receipt its acquire returned, so it never drops a lock it never took."""
|
|
self.dec_lock_ref(req.last_node, req.lock_receipt, skip_swa=skip_swa)
|
|
|
|
def dec_swa_lock_only(
|
|
self,
|
|
node_id: NodeId,
|
|
params: DecLockRefParams,
|
|
) -> None:
|
|
if self.disable:
|
|
return
|
|
result = self.tree_core.dec_swa_lock_only(node_id, params)
|
|
self._free_values(result.device_frees, result.host_frees)
|
|
|
|
def inc_host_lock_ref(self, node_id: NodeId) -> IncLockRefResult:
|
|
if self.disable:
|
|
return IncLockRefResult()
|
|
return self.tree_core.inc_host_lock_ref(node_id)
|
|
|
|
def dec_host_lock_ref(
|
|
self, node_id: NodeId, params: DecLockRefParams
|
|
) -> DecLockRefResult:
|
|
if self.disable:
|
|
return DecLockRefResult()
|
|
return self.tree_core.dec_host_lock_ref(node_id, params)
|
|
|
|
def cache_finished_req(
|
|
self, req: Req, is_insert: bool = True, *, kv_len_to_handle: int, **kwargs
|
|
) -> None:
|
|
if self.session.try_cache_finished_req(req, is_insert=is_insert, **kwargs):
|
|
return
|
|
|
|
if self.disable:
|
|
self.free_kv_row(req.kv, [(0, kv_len_to_handle)])
|
|
for comp in self._components_tuple:
|
|
comp.cleanup_after_caching_req(req, is_finished=True)
|
|
return
|
|
|
|
token_ids = (req.origin_input_ids + req.output_ids)[:kv_len_to_handle]
|
|
kv_indices = self.req_to_token_pool.req_to_token[
|
|
req.kv.req_pool_idx, :kv_len_to_handle
|
|
]
|
|
|
|
result = None
|
|
insert_params = None
|
|
|
|
if is_insert:
|
|
insert_params = InsertParams(
|
|
prev_prefix_len=req.kv.cache_protected_len,
|
|
priority=getattr(req, "priority", 0) or 0,
|
|
rotation_base=req.kv_rotation_base,
|
|
)
|
|
|
|
# components prepare insert data + return effective cache_len
|
|
effective_cache_len = len(token_ids)
|
|
for comp in self._components_tuple:
|
|
cl = comp.prepare_for_caching_req(
|
|
req=req,
|
|
insert_params=insert_params,
|
|
token_ids_len=len(token_ids),
|
|
is_finished=True,
|
|
)
|
|
if cl is not None:
|
|
effective_cache_len = min(effective_cache_len, cl)
|
|
|
|
# Truncate if needed; the tail free is deferred and batched with
|
|
# the unaligned tail below so a shared boundary page is emitted once.
|
|
kv_indices_full = kv_indices
|
|
tail_free_start = None
|
|
if effective_cache_len < len(token_ids):
|
|
tail_free_start = max(effective_cache_len, req.kv.cache_protected_len)
|
|
token_ids = token_ids[:effective_cache_len]
|
|
kv_indices = kv_indices[:effective_cache_len]
|
|
|
|
radix_key = RadixKey(
|
|
token_ids,
|
|
req.extra_key,
|
|
is_bigram=self.tree_core.is_eagle,
|
|
cache_salt=req.cache_salt,
|
|
).page_aligned(self.page_size)
|
|
page_aligned_len = len(radix_key)
|
|
values = kv_indices[:page_aligned_len].to(dtype=torch.int64, copy=True)
|
|
|
|
insert_params.key = radix_key
|
|
insert_params.value = values
|
|
result = self.insert(insert_params)
|
|
|
|
# Keep the prompt as an independent radix node. Finished requests
|
|
# append a short, request-specific output to a much longer prompt;
|
|
# without this split the prompt and output form one leaf and are
|
|
# evicted together. Re-inserting the prompt only changes topology:
|
|
# prev_prefix_len prevents the overlapping KV indices from being
|
|
# treated as duplicate allocations and freed. A declined rotation
|
|
# tail releases everything past the protected prefix below, so the
|
|
# split is skipped there rather than handing the tree rows that
|
|
# are about to be freed.
|
|
prompt_key = RadixKey(
|
|
req.origin_input_ids,
|
|
req.extra_key,
|
|
is_bigram=self.tree_core.is_eagle,
|
|
cache_salt=req.cache_salt,
|
|
).page_aligned(self.page_size)
|
|
if (
|
|
not result.rotation_tail_declined
|
|
and len(self._components_tuple) == 1
|
|
and self._components_tuple[0].component_type == BASE_COMPONENT_TYPE
|
|
and 0 < len(prompt_key) < len(radix_key)
|
|
):
|
|
self.insert(
|
|
replace(
|
|
insert_params,
|
|
key=prompt_key,
|
|
value=values[: len(prompt_key)],
|
|
prev_prefix_len=len(prompt_key),
|
|
priority=insert_params.priority + 1,
|
|
# Topology-only re-insert: the request itself created
|
|
# these nodes moments ago, so counting it as a hit is
|
|
# the same self-referencing inflation `chunked` exists
|
|
# to suppress. hit_count drives eviction order, so an
|
|
# extra bump here would silently promote every prompt
|
|
# node into the protected segment.
|
|
chunked=True,
|
|
)
|
|
)
|
|
|
|
# Free unaligned tail (+ deferred truncation tail). A rotation
|
|
# decline inserted nothing, so the whole span past the protected
|
|
# prefix stayed request-owned and is released here instead.
|
|
free_from = (
|
|
# min(): the protected prefix can already run past a truncated
|
|
# cache_len, and free_kv_row takes ascending ranges only.
|
|
min(req.kv.cache_protected_len, len(kv_indices))
|
|
if result.rotation_tail_declined
|
|
else page_aligned_len
|
|
)
|
|
ranges = [(free_from, len(kv_indices))]
|
|
if tail_free_start is not None:
|
|
ranges.append((tail_free_start, len(kv_indices_full)))
|
|
self.free_kv_row(req.kv, ranges)
|
|
else:
|
|
self.free_kv_row(req.kv, [(req.kv.cache_protected_len, kv_len_to_handle)])
|
|
|
|
# Synthetic profiling requests may own KV without locking a tree node.
|
|
if req.last_node is not None:
|
|
self._dec_req_lock(req, skip_swa=req.swa_prefix_lock_released)
|
|
|
|
if is_insert and result is not None and result.last_device_node is not None:
|
|
req.last_node = result.last_device_node
|
|
|
|
# cleanup
|
|
for comp in self._components_tuple:
|
|
comp.cleanup_after_caching_req(
|
|
req, is_finished=True, insert_result=result, insert_params=insert_params
|
|
)
|
|
|
|
if self.enable_session_radix_cache and result is not None:
|
|
from sglang.srt.managers.schedule_batch import FINISH_ABORT
|
|
|
|
if req.finished_reason is not None and not isinstance(
|
|
req.finished_reason, FINISH_ABORT
|
|
):
|
|
self.session_refs.register_session_ref(req)
|
|
|
|
def cache_unfinished_req(self, req: Req, chunked: bool = False, **kwargs) -> None:
|
|
if self.session.try_cache_unfinished_req(req, chunked=chunked, **kwargs):
|
|
return
|
|
|
|
token_ids = req.get_fill_ids()
|
|
|
|
if self.disable:
|
|
kv_indices = self.req_to_token_pool.req_to_token[
|
|
req.kv.req_pool_idx, : len(token_ids)
|
|
]
|
|
req.prefix_indices = kv_indices.to(dtype=torch.int64, copy=True)
|
|
return
|
|
|
|
kv_indices_orig = self.req_to_token_pool.req_to_token[
|
|
req.kv.req_pool_idx, : len(token_ids)
|
|
]
|
|
|
|
# components prepare insert data + return effective cache_len
|
|
insert_params = InsertParams(
|
|
prev_prefix_len=req.kv.cache_protected_len,
|
|
chunked=chunked,
|
|
priority=getattr(req, "priority", 0) or 0,
|
|
rotation_base=req.kv_rotation_base,
|
|
)
|
|
effective_cache_len = len(token_ids)
|
|
for comp in self._components_tuple:
|
|
cl = comp.prepare_for_caching_req(
|
|
req=req,
|
|
insert_params=insert_params,
|
|
token_ids_len=len(token_ids),
|
|
is_finished=False,
|
|
)
|
|
if cl is not None:
|
|
effective_cache_len = min(effective_cache_len, cl)
|
|
|
|
radix_key = RadixKey(
|
|
token_ids[:effective_cache_len],
|
|
req.extra_key,
|
|
is_bigram=self.tree_core.is_eagle,
|
|
cache_salt=req.cache_salt,
|
|
)
|
|
|
|
if envs.SGLANG_OPT_UNIFIED_CACHE_FREE_OUT_OF_WINDOW_SLOTS.get():
|
|
# The frontier lands a page below page_floor(pre_len + 1), which has to
|
|
# be where the insert stops, or the leaf it creates keeps less than a
|
|
# sliding window of live SWA and the match after the insert rejects it.
|
|
# The insert stops at page_floor(len(radix_key)), and a bigram key is
|
|
# one shorter than the tokens it spans, so measure the key.
|
|
for comp in self._components_tuple:
|
|
comp.free_out_of_window_slots(req, len(radix_key) - 1, insert_params)
|
|
|
|
if effective_cache_len <= 0:
|
|
req.prefix_indices = kv_indices_orig.to(dtype=torch.int64, copy=True)
|
|
for comp in self._components_tuple:
|
|
comp.cleanup_after_caching_req(
|
|
req, is_finished=False, insert_params=insert_params
|
|
)
|
|
return
|
|
|
|
kv_indices = kv_indices_orig[:effective_cache_len]
|
|
|
|
radix_key = radix_key.page_aligned(self.page_size)
|
|
page_aligned_len = len(radix_key)
|
|
values = kv_indices[:page_aligned_len].to(dtype=torch.int64, copy=True)
|
|
|
|
insert_params.key = radix_key
|
|
insert_params.value = values
|
|
result = self.insert(insert_params)
|
|
|
|
if result.rotation_tail_declined:
|
|
# Rotation-base discontinuity with the matched chain (pipelined
|
|
# batches raced this request's insert against another chain over
|
|
# the same prefix). Adopting the canonical locs would leave this
|
|
# request's row mixing two rotation runs, which the cyclic-owner
|
|
# gather contract forbids -- keep the request entirely on its own
|
|
# pages: no dedup free, no rebind, no protection change. The insert
|
|
# declined before its walk, so nothing was freed underneath us. The
|
|
# final cache_finished_req releases everything past the protected
|
|
# prefix.
|
|
req.prefix_indices = kv_indices_orig.to(dtype=torch.int64, copy=True)
|
|
for comp in self._components_tuple:
|
|
comp.cleanup_after_caching_req(
|
|
req, is_finished=False, insert_params=insert_params
|
|
)
|
|
return
|
|
|
|
# Match prefix. SWA insertion retains one extra window before the
|
|
# page-aligned boundary, so the normal match remains safe to repoint.
|
|
match_result = self.match_prefix(MatchPrefixParams(key=radix_key, req=req))
|
|
new_indices = match_result.device_indices
|
|
new_last_node = match_result.last_device_node
|
|
new_prefix_len = result.prefix_len
|
|
assert req.kv.cache_protected_len <= len(new_indices) + self.page_size - 1, (
|
|
f"{req.kv.cache_protected_len=}, {len(new_indices)=}, {page_aligned_len=}"
|
|
)
|
|
assert new_prefix_len <= len(new_indices), (
|
|
f"{new_prefix_len=}, {len(new_indices)=}"
|
|
)
|
|
self.req_to_token_pool.write(
|
|
(req.kv.req_pool_idx, slice(req.kv.cache_protected_len, len(new_indices))),
|
|
new_indices[req.kv.cache_protected_len :],
|
|
)
|
|
|
|
self._dec_req_lock(req, skip_swa=req.swa_prefix_lock_released)
|
|
# Opt-in: leave the matched-prefix mamba evictable during decode (it is
|
|
# already COW'd to the request's own slot, never read from this node again).
|
|
# Safe only because any future COW source is the COWing request's own
|
|
# admission-locked last_node (recorded only if still present, locked before
|
|
# the next alloc) -- not this evictable node. A scheduler that matched a
|
|
# whole batch before locking would break that. Off = original full lock.
|
|
lock_result = self.inc_lock_ref(
|
|
new_last_node,
|
|
skip_lock_components=(
|
|
(ComponentType.MAMBA,)
|
|
if envs.SGLANG_OPT_MAMBA_SKIP_DECODE_LOCK.get()
|
|
else ()
|
|
),
|
|
)
|
|
|
|
# Update req fields
|
|
if len(new_indices) < len(kv_indices_orig):
|
|
req.prefix_indices = torch.cat(
|
|
[new_indices, kv_indices_orig[len(new_indices) :]]
|
|
)
|
|
else:
|
|
req.prefix_indices = new_indices
|
|
req.kv.cache_protected_len = len(new_indices)
|
|
req.last_node = new_last_node
|
|
# Carry the receipt so this node's dec releases only what we locked.
|
|
req.lock_receipt = lock_result.to_dec_params()
|
|
# The rematch acquired a new SWA prefix lock.
|
|
req.swa_prefix_lock_released = False
|
|
|
|
# cleanup
|
|
for comp in self._components_tuple:
|
|
comp.cleanup_after_caching_req(
|
|
req,
|
|
is_finished=False,
|
|
insert_result=result,
|
|
insert_params=insert_params,
|
|
)
|
|
|
|
# ---- Internal Helpers ----
|
|
|
|
def _apply_cache_actions(
|
|
self, actions: list[CacheAction | ComponentAction]
|
|
) -> None:
|
|
# Apply and consume one at a time: a spent list cannot be double-applied.
|
|
actions.reverse()
|
|
try:
|
|
while actions:
|
|
self._apply_cache_action(actions.pop())
|
|
finally:
|
|
actions.reverse()
|
|
|
|
def _apply_cache_action(self, action: CacheAction | ComponentAction) -> None:
|
|
# Component actions route to their component class; the rest are
|
|
# cache-owned and handled here by type.
|
|
if isinstance(action, ComponentAction):
|
|
self.components[action.component_type].apply_component_action(action)
|
|
elif isinstance(action, ReplaceWriteThroughOnNodeSplit):
|
|
self._replace_pending_write_through_node(
|
|
action.ack_id,
|
|
action.old_node_id,
|
|
[action.new_node_id, action.new_child_node_id],
|
|
)
|
|
if self.linker is not None:
|
|
self.linker.replace_pending_offload_node(
|
|
action.ack_id,
|
|
action.old_node_id,
|
|
[action.new_node_id, action.new_child_node_id],
|
|
)
|
|
elif isinstance(action, FreeDeviceKV):
|
|
# tree values are page-aligned copies of a kv row: page-exact segments
|
|
for indices in action.indices:
|
|
self.token_to_kv_pool_allocator.free_segment(indices, start_pos=0)
|
|
elif isinstance(action, FreeDeviceKVFullOnly):
|
|
for indices in action.indices:
|
|
self.token_to_kv_pool_allocator.free_full_segment(indices, start_pos=0)
|
|
elif isinstance(action, BackupKV):
|
|
if self.linker is not None:
|
|
self.linker.offload_nodes(action.node_ids)
|
|
else:
|
|
self._execute_and_commit_kv_backup(action)
|
|
else:
|
|
raise AssertionError(f"unhandled CacheAction: {type(action).__name__}")
|
|
|
|
def _drain_device_frees(
|
|
self, device_frees: dict[ComponentType, list[torch.Tensor]]
|
|
) -> None:
|
|
# Free per component device slots, consuming each entry as it frees.
|
|
for ct in list(device_frees):
|
|
self._apply_cache_action(
|
|
FreeComponentDeviceSlot(device_frees.pop(ct), component_type=ct)
|
|
)
|
|
|
|
def _drain_host_frees(
|
|
self, host_frees: dict[ComponentType, list[torch.Tensor]]
|
|
) -> None:
|
|
# Free per component host-pool slots, consuming each entry as it frees.
|
|
for ct in list(host_frees):
|
|
self.components[ct].free_host_values(host_frees.pop(ct))
|
|
|
|
def evict_host(
|
|
self, num_tokens: int, component_type: ComponentType = BASE_COMPONENT_TYPE
|
|
) -> int:
|
|
"""Evict host resources for a specific component to free host pool space."""
|
|
if self.host_memory_mode == "buffer_only":
|
|
# The tree never holds host values in buffer mode, and staging
|
|
# is operation-owned (freed at each ack): nothing is evictable.
|
|
return 0
|
|
result = self.tree_core.drive_host_eviction(component_type, num_tokens)
|
|
self._free_values(result.device_frees, result.host_frees)
|
|
return result.tracker.get(component_type, 0)
|
|
|
|
# ---- Decode retraction ----
|
|
|
|
def supports_retraction_backup(self) -> bool:
|
|
if self.cache_controller is None or self.host_pool_group is None:
|
|
return False
|
|
if self.supports_mamba():
|
|
return False
|
|
|
|
kv_cache = self.token_to_kv_pool_allocator.get_kvcache()
|
|
if isinstance(kv_cache, SWAKVPool):
|
|
return (
|
|
self.supports_swa()
|
|
and {
|
|
PoolName.KV,
|
|
PoolName.SWA,
|
|
}
|
|
<= self.host_pool_group.entry_map.keys()
|
|
)
|
|
return isinstance(kv_cache, MHATokenToKVPool) and (
|
|
PoolName.KV in self.host_pool_group.entry_map
|
|
)
|
|
|
|
def validate_retraction_host_capacity(self) -> None:
|
|
if not self.supports_retraction_backup():
|
|
raise ValueError(
|
|
"--disaggregation-decode-retraction-backup=host_pool requires "
|
|
"an MHA or hybrid-SWA HiCache host stack."
|
|
)
|
|
|
|
for spec in self.sidecar_pool_specs:
|
|
source_size = self.host_pool_group.entry_map[
|
|
spec.indices_from_pool
|
|
].host_pool.logical_size
|
|
sidecar_size = self.host_pool_group.entry_map[
|
|
spec.pool_name
|
|
].host_pool.logical_size
|
|
if sidecar_size < source_size:
|
|
raise ValueError(
|
|
"Retraction sidecar host pool is smaller than its index source: "
|
|
f"pool={spec.pool_name}, host_slots={sidecar_size}, "
|
|
f"source={spec.indices_from_pool}, source_slots={source_size}."
|
|
)
|
|
|
|
@staticmethod
|
|
def _pad_retraction_indices(indices: torch.Tensor, page_size: int) -> torch.Tensor:
|
|
aligned_len = ceil_align(len(indices), page_size)
|
|
if aligned_len == len(indices):
|
|
return indices
|
|
tail = indices[-1] + torch.arange(
|
|
1,
|
|
aligned_len - len(indices) + 1,
|
|
dtype=torch.int64,
|
|
device=indices.device,
|
|
)
|
|
return torch.cat([indices, tail])
|
|
|
|
def _retraction_device_transfers(
|
|
self, req: Req
|
|
) -> tuple[torch.Tensor, list[PoolTransfer]]:
|
|
num_tokens = req.seqlen - 1
|
|
full_indices = self.req_to_token_pool.req_to_token[
|
|
req.kv.req_pool_idx, :num_tokens
|
|
].to(torch.int64)
|
|
full_indices = self._pad_retraction_indices(full_indices, self.page_size)
|
|
|
|
component_transfers: dict[ComponentType, list[PoolTransfer]] = {}
|
|
if self.supports_swa():
|
|
kv_cache = self.token_to_kv_pool_allocator.get_kvcache()
|
|
assert self.sliding_window_size is not None
|
|
window_start = max(0, num_tokens - self.sliding_window_size)
|
|
window_start = window_start // self.page_size * self.page_size
|
|
window_indices = self.req_to_token_pool.req_to_token[
|
|
req.kv.req_pool_idx, window_start:num_tokens
|
|
].to(torch.int64)
|
|
swa_indices = kv_cache.translate_loc_from_full_to_swa(window_indices)
|
|
assert bool((swa_indices > 0).all()), (
|
|
f"unmapped SWA window positions for request {req.rid}"
|
|
)
|
|
component_transfers[ComponentType.SWA] = [
|
|
PoolTransfer(
|
|
name=PoolName.SWA,
|
|
device_indices=self._pad_retraction_indices(
|
|
swa_indices, self.page_size
|
|
),
|
|
)
|
|
]
|
|
|
|
kv_transfer = PoolTransfer(name=PoolName.KV, device_indices=full_indices)
|
|
extra_transfers = [
|
|
transfer
|
|
for transfers in component_transfers.values()
|
|
for transfer in transfers
|
|
]
|
|
extra_transfers.extend(
|
|
self._build_sidecar_transfers(
|
|
CacheTransferPhase.BACKUP_HOST,
|
|
kv_transfer,
|
|
component_transfers,
|
|
)
|
|
)
|
|
return full_indices, extra_transfers
|
|
|
|
def _reclaim_retraction_host(self, num_tokens: int) -> int:
|
|
if self.disable:
|
|
return 0
|
|
return self.evict_host(num_tokens)
|
|
|
|
def retraction_backup(self, req: Req) -> Optional[RetractionBackup]:
|
|
"""Back up device KV to the host pool; None when it cannot fit after reclaim."""
|
|
assert req.seqlen > 1
|
|
|
|
device_indices, extra_transfers = self._retraction_device_transfers(req)
|
|
host_indices = self.host_pool_group.alloc(len(device_indices))
|
|
if host_indices is None:
|
|
self._reclaim_retraction_host(len(device_indices))
|
|
host_indices = self.host_pool_group.alloc(len(device_indices))
|
|
if host_indices is None:
|
|
return None
|
|
|
|
resolved = self.host_pool_group.resolve_host_transfers(
|
|
extra_transfers or None,
|
|
primary_device_indices=device_indices,
|
|
primary_host_indices=host_indices,
|
|
)
|
|
if resolved is None and extra_transfers:
|
|
self.host_pool_group.free(host_indices)
|
|
return None
|
|
|
|
backup = RetractionBackup(
|
|
host_indices=host_indices,
|
|
pool_transfers=[replace(x, device_indices=None) for x in resolved or []]
|
|
or None,
|
|
)
|
|
operation = CacheOperation(
|
|
host_indices,
|
|
device_indices,
|
|
node_id=-1,
|
|
pool_transfers=resolved,
|
|
)
|
|
try:
|
|
write_host, write_device, write_pools = (
|
|
self.cache_controller._move_write_operation(operation)
|
|
)
|
|
completion = self.cache_controller.l2_transfer_engine.submit_device_to_host(
|
|
self.cache_controller._l2_transfers(
|
|
write_host, write_device, write_pools
|
|
)
|
|
)
|
|
completion.finish_event.synchronize()
|
|
except Exception:
|
|
self.retraction_discard(backup)
|
|
raise
|
|
return backup
|
|
|
|
def retraction_restore(self, req: Req, backup: RetractionBackup) -> None:
|
|
device_indices, current_transfers = self._retraction_device_transfers(req)
|
|
assert len(backup.host_indices) == len(device_indices), (
|
|
f"Host backup has {len(backup.host_indices)} slots, but restore has "
|
|
f"{len(device_indices)}"
|
|
)
|
|
|
|
current_by_name = {transfer.name: transfer for transfer in current_transfers}
|
|
saved_by_name = {
|
|
transfer.name: transfer for transfer in backup.pool_transfers or []
|
|
}
|
|
assert current_by_name.keys() == saved_by_name.keys(), (
|
|
f"Host backup pools {set(saved_by_name)} do not match restore pools "
|
|
f"{set(current_by_name)}"
|
|
)
|
|
restored_transfers = [
|
|
replace(
|
|
saved,
|
|
device_indices=current_by_name[name].device_indices,
|
|
)
|
|
for name, saved in saved_by_name.items()
|
|
]
|
|
resolved = self.cache_controller._resolve_device_transfers(
|
|
restored_transfers or None,
|
|
kv_device_indices=device_indices,
|
|
kv_host_indices=backup.host_indices,
|
|
)
|
|
assert resolved is not None or not restored_transfers
|
|
|
|
operation = CacheOperation(
|
|
backup.host_indices,
|
|
device_indices,
|
|
node_id=-1,
|
|
pool_transfers=resolved,
|
|
)
|
|
load_host, load_device, load_pools = self.cache_controller._move_op_indices(
|
|
operation
|
|
)
|
|
completion = self.cache_controller.l2_transfer_engine.submit_host_to_device(
|
|
self.cache_controller._l2_load_transfers(
|
|
load_host, load_device, load_pools
|
|
),
|
|
layer_num=self.cache_controller.layer_num,
|
|
)
|
|
completion.finish_event.synchronize()
|
|
self.retraction_discard(backup)
|
|
|
|
def retraction_discard(self, backup: RetractionBackup) -> None:
|
|
self.host_pool_group.free(backup.host_indices)
|
|
self.host_pool_group.release_transfers(backup.pool_transfers)
|
|
|
|
# ---- HiCache: Backup / LoadBack ----
|
|
|
|
def _execute_and_commit_kv_backup(
|
|
self, action: BackupKV, write_back: bool = False
|
|
) -> int:
|
|
"""Run a backup action top-down, stopping at the first failed backup."""
|
|
if self.buffer_pipeline is not None:
|
|
# Buffer mode bypasses the host-backup contiguity below: nothing
|
|
# is ever host-backuped here. Contiguity comes from end-to-end
|
|
# FIFO ordering instead (BackupKV chains are parent-before-child
|
|
# and every pipeline stage drains in order).
|
|
for node_id in action.node_ids:
|
|
self.buffer_pipeline.enqueue_backup_intent(node_id)
|
|
return 0
|
|
written = 0
|
|
for node_id in action.node_ids:
|
|
device_value, comp_xfers = self.tree_core.build_backup_spec(node_id)
|
|
# Overlapping chain actions may revisit nodes with Full KV already
|
|
# backed up. Skip only when no transfer remains.
|
|
if device_value.numel() == 0 and not comp_xfers:
|
|
continue
|
|
sidecar_xfers = self._build_backup_sidecar(device_value, comp_xfers)
|
|
host_indices = self._execute_kv_backup(
|
|
node_id, device_value, comp_xfers, sidecar_xfers
|
|
)
|
|
if host_indices is None:
|
|
return 0
|
|
self.tree_core.commit_backup(node_id, host_indices, comp_xfers)
|
|
lock_params = None
|
|
if not write_back:
|
|
lock_params = self.inc_lock_ref(node_id).to_dec_params()
|
|
publish_node_ids = self._backup_publish_node_ids(node_id, comp_xfers)
|
|
self._track_write_through_node(
|
|
node_id, lock_params, publish_node_ids=publish_node_ids
|
|
)
|
|
written = len(host_indices)
|
|
return written
|
|
|
|
@staticmethod
|
|
def _backup_publish_node_ids(
|
|
node_id: NodeId, comp_xfers: dict[ComponentType, list[PoolTransfer]]
|
|
) -> list[NodeId]:
|
|
"""The acked node plus every node a component backup transfer covers."""
|
|
publish_node_ids: list[NodeId] = []
|
|
for transfers in comp_xfers.values():
|
|
for transfer in transfers:
|
|
publish_node_ids.extend(transfer.nodes_to_load or ())
|
|
if node_id not in publish_node_ids:
|
|
publish_node_ids.append(node_id)
|
|
return list(dict.fromkeys(publish_node_ids))
|
|
|
|
def _build_backup_sidecar(self, device_value, comp_xfers):
|
|
"""Gather sidecar transfer spec."""
|
|
kv_xfer = PoolTransfer(name=PoolName.KV, device_indices=device_value)
|
|
return self._build_sidecar_transfers(
|
|
CacheTransferPhase.BACKUP_HOST, kv_xfer, comp_xfers
|
|
)
|
|
|
|
def _execute_kv_backup(self, node_id, device_value, comp_xfers, sidecar_xfers):
|
|
"""Execute Backup action."""
|
|
kv_tokens = len(device_value)
|
|
host_avail = self.cache_controller.mem_pool_host.available_size()
|
|
if host_avail < kv_tokens:
|
|
needed = kv_tokens - host_avail
|
|
if self.evict_host(needed) < needed:
|
|
return None
|
|
aux_xfers = [x for xfers in comp_xfers.values() for x in xfers]
|
|
aux_xfers.extend(sidecar_xfers)
|
|
return self.cache_controller.write(
|
|
device_value, node_id=node_id, extra_pools=aux_xfers or None
|
|
)
|
|
|
|
def _track_write_through_node(
|
|
self,
|
|
node_id: NodeId,
|
|
lock_params: Optional[DecLockRefParams],
|
|
publish_node_ids: list[NodeId],
|
|
) -> None:
|
|
publish_node_ids = self.tree_core.mark_write_through_pending(
|
|
publish_node_ids, ack_id=node_id
|
|
)
|
|
self.ongoing_write_through[node_id] = _OngoingWriteThrough(
|
|
node_id, lock_params, publish_node_ids
|
|
)
|
|
|
|
def _replace_pending_write_through_node(
|
|
self, ack_id: int, old_node_id: NodeId, new_node_ids: list[NodeId]
|
|
) -> None:
|
|
pending = self.ongoing_write_through.get(ack_id)
|
|
if pending is None:
|
|
return
|
|
|
|
lock_node_id, lock_params, publish_node_ids = pending
|
|
updated_node_ids = []
|
|
replaced = False
|
|
for node_id in publish_node_ids:
|
|
if node_id == old_node_id:
|
|
updated_node_ids.extend(new_node_ids)
|
|
replaced = True
|
|
else:
|
|
updated_node_ids.append(node_id)
|
|
|
|
if not replaced:
|
|
return
|
|
|
|
self.ongoing_write_through[ack_id] = _OngoingWriteThrough(
|
|
lock_node_id,
|
|
lock_params,
|
|
updated_node_ids,
|
|
)
|
|
|
|
def _finish_write_through_ack(self, ack_id: int) -> None:
|
|
if self.buffer_pipeline is not None:
|
|
self.buffer_pipeline.finish_backup_ack(ack_id)
|
|
return
|
|
|
|
lock_node_id, lock_params, publish_node_ids = self.ongoing_write_through.pop(
|
|
ack_id
|
|
)
|
|
self.tree_core.finish_write_through(publish_node_ids, ack_id)
|
|
if lock_params is not None:
|
|
self.dec_lock_ref(lock_node_id, lock_params)
|
|
if self.enable_storage:
|
|
# Back up each fragment: after a split, lock_node only holds the
|
|
# suffix; the prefix fragment must be persisted as well.
|
|
for node_id in publish_node_ids:
|
|
self.write_backup_storage(node_id)
|
|
|
|
def load_back(
|
|
self,
|
|
node_id: NodeId,
|
|
mem_quota: Optional[int] = None,
|
|
req=None,
|
|
) -> bool:
|
|
"""Load evicted KV data from host back to device (H→D)."""
|
|
if self.cache_controller is None:
|
|
return False
|
|
|
|
host_anchor_params = self.inc_host_lock_ref(node_id).to_dec_params()
|
|
|
|
# Lock the path before building transfers (the aux build can evict).
|
|
result = self.inc_lock_ref(node_id)
|
|
ancestor_lock_params = result.to_dec_params()
|
|
|
|
# Let each component pre-allocate per-request state for the load-back;
|
|
# the finally below lets components recover it unless the load succeeds.
|
|
preps: dict[ComponentType, PrepareLoadBackResult] = {
|
|
comp.component_type: comp.prepare_load_back(node_id, req=req)
|
|
for comp in self._components_tuple
|
|
}
|
|
success = False
|
|
try:
|
|
success = self._load_back_transfers(
|
|
node_id=node_id,
|
|
mem_quota=mem_quota,
|
|
req=req,
|
|
result=result,
|
|
ancestor_lock_params=ancestor_lock_params,
|
|
host_anchor_params=host_anchor_params,
|
|
)
|
|
return success
|
|
finally:
|
|
for comp in self._components_tuple:
|
|
comp.finalize_load_back(req, preps[comp.component_type], success)
|
|
|
|
def _load_back_transfers(
|
|
self,
|
|
*,
|
|
node_id: NodeId,
|
|
mem_quota: Optional[int],
|
|
req,
|
|
result: IncLockRefResult,
|
|
ancestor_lock_params: DecLockRefParams,
|
|
host_anchor_params: DecLockRefParams,
|
|
) -> bool:
|
|
# Build the KV + per-component aux transfers.
|
|
kv_xfer, comp_xfers = self.tree_core.build_load_back_spec(node_id, req=req)
|
|
kv_tokens = len(kv_xfer.host_indices)
|
|
sidecar_xfers = self._build_sidecar_transfers(
|
|
CacheTransferPhase.LOAD_BACK, kv_xfer, comp_xfers
|
|
)
|
|
|
|
# Skip if there is nothing to load, or if the Full-KV transfer is too
|
|
# small / exceeds memory quota. Aux transfers should still run even
|
|
# when the Full-KV load is skipped by thresholding. max(1, ...): an
|
|
# entirely empty spec (e.g. foreign-pin rejection) must never report
|
|
# success, even at load_back_threshold <= 0.
|
|
if (kv_tokens < max(1, self.load_back_threshold) and not comp_xfers) or (
|
|
mem_quota is not None and kv_tokens + result.delta > mem_quota
|
|
):
|
|
self.dec_lock_ref(node_id, ancestor_lock_params)
|
|
self.dec_host_lock_ref(node_id, host_anchor_params)
|
|
return False
|
|
|
|
avail = self._component_available_size(ComponentType.FULL)
|
|
if avail < kv_tokens:
|
|
needed = kv_tokens - avail
|
|
self.evict_for_alloc(EvictParams(num_tokens=needed))
|
|
if self._component_available_size(ComponentType.FULL) < kv_tokens:
|
|
self.dec_lock_ref(node_id, ancestor_lock_params)
|
|
self.dec_host_lock_ref(node_id, host_anchor_params)
|
|
return False
|
|
|
|
# Load H→D
|
|
aux_xfers = [x for xfers in comp_xfers.values() for x in xfers]
|
|
aux_xfers.extend(sidecar_xfers)
|
|
device_indices = self.cache_controller.load(
|
|
host_indices=kv_xfer.host_indices,
|
|
node_id=node_id,
|
|
extra_pools=aux_xfers or None,
|
|
)
|
|
|
|
self.dec_lock_ref(node_id, ancestor_lock_params)
|
|
if device_indices is None:
|
|
self.dec_host_lock_ref(node_id, host_anchor_params)
|
|
return False
|
|
|
|
# Commit the loaded KV back onto the node + apply its emitted actions.
|
|
self._apply_cache_actions(
|
|
self.tree_core.commit_load_back(
|
|
node_id, device_indices, kv_xfer, comp_xfers
|
|
)
|
|
)
|
|
|
|
self.ongoing_load_back[node_id] = _OngoingLoadBack(
|
|
node_id,
|
|
self.inc_lock_ref(node_id).to_dec_params(),
|
|
host_anchor_params,
|
|
)
|
|
|
|
return True
|
|
|
|
def _build_sidecar_transfers(
|
|
self,
|
|
phase: CacheTransferPhase,
|
|
kv_xfer: PoolTransfer,
|
|
comp_xfers: dict[ComponentType, list[PoolTransfer]],
|
|
) -> list[PoolTransfer]:
|
|
transfers: list[PoolTransfer] = []
|
|
for spec in self.sidecar_pool_specs:
|
|
if spec.indices_from_pool == PoolName.KV:
|
|
indices_source = kv_xfer
|
|
else:
|
|
source_component = {
|
|
PoolName.SWA: ComponentType.SWA,
|
|
PoolName.MAMBA: ComponentType.MAMBA,
|
|
}.get(spec.indices_from_pool)
|
|
if source_component is None:
|
|
raise AssertionError(
|
|
f"Unsupported sidecar indices source pool "
|
|
f"{spec.indices_from_pool}."
|
|
)
|
|
matching_sources = comp_xfers.get(source_component, ())
|
|
if not matching_sources:
|
|
continue
|
|
indices_source = matching_sources[0]
|
|
if indices_source.name != spec.indices_from_pool:
|
|
raise AssertionError(
|
|
f"Sidecar indices source pool {spec.indices_from_pool} "
|
|
f"resolved to {indices_source.name} during {phase}."
|
|
)
|
|
|
|
indices = (
|
|
indices_source.device_indices
|
|
if phase == CacheTransferPhase.BACKUP_HOST
|
|
else indices_source.host_indices
|
|
)
|
|
defer_kv_sidecar = (
|
|
phase == CacheTransferPhase.PREFETCH
|
|
and spec.indices_from_pool == PoolName.KV
|
|
)
|
|
if (indices is None or len(indices) == 0) and not defer_kv_sidecar:
|
|
continue
|
|
transfers.append(
|
|
PoolTransfer(
|
|
name=spec.pool_name,
|
|
keys=indices_source.keys,
|
|
hit_policy=spec.hit_policy,
|
|
indices_from_pool=spec.indices_from_pool,
|
|
)
|
|
)
|
|
return transfers
|
|
|
|
def write_backup_storage(self, node_id: NodeId) -> None:
|
|
if not self.enable_storage or self.cache_controller is None:
|
|
return
|
|
spec = self.tree_core.build_storage_backup_spec(
|
|
node_id, self.hicache_storage_pass_prefix_keys
|
|
)
|
|
if spec is None:
|
|
return
|
|
|
|
kv_xfer = PoolTransfer(
|
|
name=PoolName.KV,
|
|
host_indices=spec.host_value,
|
|
keys=spec.hash_value,
|
|
)
|
|
sidecar_xfers = self._build_sidecar_transfers(
|
|
CacheTransferPhase.BACKUP_STORAGE, kv_xfer, spec.comp_xfers
|
|
)
|
|
aux_xfers = [x for xfers in spec.comp_xfers.values() for x in xfers]
|
|
aux_xfers.extend(sidecar_xfers)
|
|
|
|
operation_id = self.cache_controller.write_storage(
|
|
spec.host_value,
|
|
spec.token_ids,
|
|
spec.hash_value,
|
|
spec.prefix_keys,
|
|
extra_pools=aux_xfers or None,
|
|
)
|
|
self.ongoing_backup[operation_id] = (
|
|
node_id,
|
|
self.inc_host_lock_ref(node_id).to_dec_params(),
|
|
)
|
|
|
|
def is_backuped(self, node_id: NodeId) -> bool:
|
|
return self.tree_core.is_backuped(node_id)
|
|
|
|
def is_root(self, node_id: NodeId) -> bool:
|
|
return self.tree_core.is_root(node_id)
|
|
|
|
def get_last_hash_value(self, node_id: NodeId) -> Optional[str]:
|
|
return self.tree_core.get_last_hash_value(node_id)
|
|
|
|
def get_prefix_hash_values(self, node_id: NodeId) -> list[str]:
|
|
return self.tree_core.get_prefix_hash_values(node_id)
|
|
|
|
def rotation_base_of(self, node_id: Optional[NodeId]) -> Optional[int]:
|
|
if node_id is None:
|
|
return None
|
|
return self.tree_core.rotation_base_of(node_id)
|
|
|
|
def query_storage_hit_length(
|
|
self,
|
|
last_host_node_id: NodeId,
|
|
new_input_tokens: list[int],
|
|
last_hash: Optional[str] = None,
|
|
prefix_keys: Optional[list[str]] = None,
|
|
extra_key: Optional[str] = None,
|
|
cache_salt: Optional[str] = None,
|
|
) -> int:
|
|
"""Probe L3 with the request namespace."""
|
|
if (
|
|
not self.enable_storage
|
|
or self.cache_controller is None
|
|
or self.cache_controller.prefetch_rate_limited()
|
|
):
|
|
return 0
|
|
|
|
prefetch_key = RadixKey(
|
|
new_input_tokens,
|
|
extra_key=extra_key,
|
|
is_bigram=self.tree_core.is_eagle,
|
|
cache_salt=cache_salt,
|
|
).page_aligned(self.page_size)
|
|
if len(prefetch_key) < self.prefetch_threshold:
|
|
return 0
|
|
|
|
from sglang.srt.mem_cache.hybrid_cache.hybrid_cache_controller import (
|
|
PrefetchOperation,
|
|
)
|
|
|
|
operation = PrefetchOperation(
|
|
"__storage_hit_query__",
|
|
prefetch_key,
|
|
last_hash,
|
|
prefix_keys,
|
|
)
|
|
_, storage_hit_count = self.cache_controller._storage_hit_query(operation)
|
|
storage_hit_count_tensor = torch.tensor(storage_hit_count, dtype=torch.int)
|
|
self._all_reduce_attn_groups(
|
|
storage_hit_count_tensor, torch.distributed.ReduceOp.MIN
|
|
)
|
|
storage_hit_count = storage_hit_count_tensor.item()
|
|
storage_hit_count -= storage_hit_count % self.page_size
|
|
return storage_hit_count
|
|
|
|
def prefetch_from_storage(
|
|
self,
|
|
req_id: str,
|
|
last_host_node_id: NodeId,
|
|
new_input_tokens: list[int],
|
|
last_hash: Optional[str] = None,
|
|
prefix_keys: Optional[list[str]] = None,
|
|
matched_prefix_tokens: Optional[list[int]] = None,
|
|
extra_key: Optional[str] = None,
|
|
cache_salt: Optional[str] = None,
|
|
) -> None:
|
|
if not self.enable_storage or self.cache_controller is None:
|
|
return
|
|
|
|
buffer_mode = self.host_memory_mode == "buffer_only"
|
|
# Key the span by the request's namespace, not the anchor's (a root
|
|
# anchor has none): a span published under the wrong namespace gets
|
|
# re-owned by the request's own insert (double free).
|
|
anchor_extra_key, anchor_cache_salt = self.tree_core.prefetch_anchor_info(
|
|
last_host_node_id
|
|
)
|
|
assert (anchor_extra_key is None or anchor_extra_key == extra_key) and (
|
|
anchor_cache_salt is None or anchor_cache_salt == cache_salt
|
|
), (
|
|
f"prefetch anchor namespace {(anchor_extra_key, anchor_cache_salt)} "
|
|
f"!= request namespace {(extra_key, cache_salt)}"
|
|
)
|
|
prefetch_key = RadixKey(
|
|
new_input_tokens,
|
|
extra_key=extra_key,
|
|
is_bigram=self.tree_core.is_eagle,
|
|
cache_salt=cache_salt,
|
|
).page_aligned(self.page_size)
|
|
prefetch_length = len(prefetch_key)
|
|
stats = self._prefetch_outcome_stats
|
|
if prefetch_length > 0:
|
|
stats["attempts"] += 1
|
|
if prefetch_length < self.prefetch_threshold:
|
|
if prefetch_length > 0:
|
|
stats["declined_too_short"] += 1
|
|
# A too-short/fully-matched suffix can become a full recompute if
|
|
# the device match evicts while queued; arm the paced retry.
|
|
self._storage_prefetch_missed_rids.add(req_id)
|
|
return
|
|
if not buffer_mode and self.cache_controller.prefetch_rate_limited():
|
|
stats["declined_rate_limited"] += 1
|
|
self._storage_prefetch_missed_rids.add(req_id)
|
|
return
|
|
if req_id in self.ongoing_prefetch or (
|
|
buffer_mode and self.buffer_pipeline.has_staged(req_id)
|
|
):
|
|
# A fetch (or an unconsumed hold) already exists for this rid;
|
|
# overwriting would leak its staging slots.
|
|
return
|
|
|
|
# Buffer mode holds no tree state during the fetch: buffers are
|
|
# operation-owned, so the anchor needs no pin.
|
|
anchor_lock_params = (
|
|
None
|
|
if buffer_mode
|
|
else self.inc_host_lock_ref(last_host_node_id).to_dec_params()
|
|
)
|
|
comp_xfers: dict[ComponentType, list[PoolTransfer]] = {}
|
|
alloc_failed = False
|
|
for ct in self.tree_components:
|
|
if ct == BASE_COMPONENT_TYPE:
|
|
continue
|
|
# Pre-allocate the component's prefetch host buffer so the build stays pure.
|
|
prep = self.components[ct].prepare_prefetch(
|
|
last_host_node_id, prefetch_tokens=len(prefetch_key)
|
|
)
|
|
if prep.alloc_failed:
|
|
alloc_failed = True
|
|
break
|
|
if prep.host_indices is None:
|
|
continue
|
|
transfers = self.tree_core.build_hicache_transfers(
|
|
ct,
|
|
last_host_node_id,
|
|
CacheTransferPhase.PREFETCH,
|
|
token_ids=prefetch_key.token_ids,
|
|
prefetch_tokens=len(prefetch_key),
|
|
last_hash=last_hash,
|
|
host_indices=prep.host_indices,
|
|
)
|
|
if transfers:
|
|
comp_xfers[ct] = transfers
|
|
kv_xfer = PoolTransfer(name=PoolName.KV, host_indices=None)
|
|
sidecar_xfers = self._build_sidecar_transfers(
|
|
CacheTransferPhase.PREFETCH, kv_xfer, comp_xfers
|
|
)
|
|
if alloc_failed:
|
|
# The whole storage fetch is forfeited over one aux staging
|
|
# alloc (e.g. a single SWA window) — count it, or write-burst
|
|
# starvation of the aux pool reads as generic hit-rate loss.
|
|
if (
|
|
self.enable_storage_metrics
|
|
and self.storage_metrics_collector is not None
|
|
):
|
|
self.storage_metrics_collector.log_prefetch_aux_alloc_failed_tokens(
|
|
len(prefetch_key)
|
|
)
|
|
self.cache_controller.append_host_mem_release(
|
|
extra_pools=[x for xfers in comp_xfers.values() for x in xfers],
|
|
)
|
|
if anchor_lock_params is not None:
|
|
self.dec_host_lock_ref(last_host_node_id, anchor_lock_params)
|
|
# Forfeited over transient staging pressure; retryable.
|
|
self._storage_prefetch_missed_rids.add(req_id)
|
|
return
|
|
|
|
aux_xfers = [x for xfers in comp_xfers.values() for x in xfers]
|
|
aux_xfers.extend(sidecar_xfers)
|
|
operation = self.cache_controller.prefetch(
|
|
req_id,
|
|
prefetch_key,
|
|
last_hash,
|
|
prefix_keys,
|
|
extra_pools=aux_xfers or None,
|
|
)
|
|
stats["issued"] += 1
|
|
# Snapshot the requested span for L3 miss-token accounting at the
|
|
# rank-synchronized query outcome.
|
|
operation.stats_requested_tokens = prefetch_length
|
|
operation.storage_start = len(matched_prefix_tokens or [])
|
|
self.ongoing_prefetch[req_id] = _OngoingPrefetch(
|
|
last_host_node_id,
|
|
prefetch_key,
|
|
None,
|
|
operation,
|
|
anchor_lock_params,
|
|
comp_xfers,
|
|
)
|
|
if buffer_mode:
|
|
self.buffer_pipeline.set_prefix_ctx(
|
|
req_id,
|
|
matched_prefix_tokens,
|
|
extra_key=extra_key,
|
|
cache_salt=cache_salt,
|
|
)
|
|
# Pin the just-matched anchor now: deferred to IO commit it is
|
|
# often already deleted under churn. The IO-commit call remains
|
|
# as the second chance that decides the fetch's fate.
|
|
self.buffer_pipeline.try_lock_anchor(req_id)
|
|
else:
|
|
# Cache mode reserves the requested span up front; buffer mode
|
|
# grants occupancy later at hit-alloc time, sized to the hit.
|
|
self.cache_controller.prefetch_tokens_occupied += len(prefetch_key)
|
|
|
|
def _prefetch_timeout_check_linear_func(self, operation: PrefetchOperation) -> bool:
|
|
return (
|
|
time.monotonic() - operation.start_time
|
|
> self.prefetch_timeout_base
|
|
+ len(operation.hash_value) * self.prefetch_timeout_per_page
|
|
)
|
|
|
|
@rank_consensus(same_results=True)
|
|
def _can_terminate_prefetch(self, operation: PrefetchOperation) -> bool:
|
|
if self.prefetch_stop_policy == "best_effort":
|
|
return True
|
|
if self.prefetch_stop_policy == "wait_complete":
|
|
return False
|
|
elif self.prefetch_stop_policy == "timeout":
|
|
# Wall-clock time may differ among ranks, all-reduce is needed to ensure
|
|
# all ranks reach the same final result. Otherwise PP/TP ranks will diverge.
|
|
#
|
|
# For TP, if any rank reaches the timeout, the final result is timeout.
|
|
#
|
|
# For PP, PP0 makes the decision and other ranks follow PP0's decision.
|
|
should_terminate = False
|
|
if self.pp_rank == 0:
|
|
should_terminate = self._prefetch_timeout_check_linear_func(operation)
|
|
should_terminate_tensor = torch.tensor(
|
|
int(should_terminate), dtype=torch.int, device="cpu"
|
|
)
|
|
self._all_reduce(should_terminate_tensor, torch.distributed.ReduceOp.MAX)
|
|
return should_terminate_tensor.item() == 1
|
|
else:
|
|
return True
|
|
|
|
@rank_consensus(same_params=True, same_results=True)
|
|
def check_prefetch_progress(self, req_id: str) -> bool:
|
|
if req_id not in self.ongoing_prefetch:
|
|
return True
|
|
|
|
_, _, _, operation, _, _ = self.ongoing_prefetch[req_id]
|
|
|
|
# Determine whether or not we should terminate this prefetch request.
|
|
should_terminate = operation.is_terminated() or self._can_terminate_prefetch(
|
|
operation
|
|
)
|
|
|
|
if not should_terminate:
|
|
return False
|
|
|
|
self.cache_controller.terminate_prefetch(operation)
|
|
if operation.host_indices is None:
|
|
self._storage_prefetch_missed_rids.add(req_id)
|
|
self.revoke_pending_prefetch(req_id)
|
|
else:
|
|
self._handle_prefetch_result(operation)
|
|
return True
|
|
|
|
def _handle_prefetch_result(self, operation: PrefetchOperation) -> None:
|
|
# This function **owns**:
|
|
# - host_indices[0 : completed_tokens]
|
|
# - sidecar pool hits if operation.pool_transfers_done is true
|
|
#
|
|
# That is, when this function returns the host memory referenced must be inserted
|
|
# into the radix tree or released to pool.
|
|
|
|
req_id = operation.request_id
|
|
completed_tokens = operation.completed_tokens
|
|
hash_value = operation.hash_value
|
|
|
|
(
|
|
last_host_node_id,
|
|
prefetch_key,
|
|
host_indices,
|
|
_,
|
|
anchor_lock_params,
|
|
comp_xfers,
|
|
) = self.ongoing_prefetch[req_id]
|
|
|
|
# All PP/TP ranks will get the same `min_completed_tokens`, because `completed_tokens`
|
|
# and `pool_hits` in their operations are same. No need to sync cross-rank here.
|
|
if not self._check_hybrid_prefetch_result(
|
|
req_id,
|
|
operation,
|
|
completed_tokens,
|
|
hash_value,
|
|
host_indices,
|
|
last_host_node_id,
|
|
anchor_lock_params,
|
|
prefetch_key,
|
|
):
|
|
# Hybrid all-or-nothing check failed; result already discarded.
|
|
return
|
|
|
|
allocated_tokens = len(host_indices)
|
|
if completed_tokens < allocated_tokens:
|
|
self._resolve_storage_prefetch_tokens(
|
|
req_id,
|
|
allocated_tokens - completed_tokens,
|
|
reason="storage_transfer",
|
|
)
|
|
if (
|
|
completed_tokens > 0
|
|
and self.enable_storage_metrics
|
|
and self.storage_metrics_collector is not None
|
|
):
|
|
self.storage_metrics_collector.log_prefetched_tokens(completed_tokens)
|
|
|
|
if self.buffer_pipeline is not None:
|
|
# No graft: release the rank-local tail beyond the synced usable
|
|
# length, then park the bounce for admission-time consumption.
|
|
return self.buffer_pipeline.stage_completed_prefetch(
|
|
req_id, completed_tokens, hash_value
|
|
)
|
|
|
|
fetched_key = prefetch_key[:completed_tokens]
|
|
insert_result = self.tree_core.insert_host(
|
|
last_host_node_id,
|
|
fetched_key,
|
|
host_indices[:completed_tokens],
|
|
hash_value[: completed_tokens // self.page_size],
|
|
)
|
|
|
|
# Apply the host-insert walk's actions before the transfer commit.
|
|
self._apply_cache_actions(insert_result.cache_actions)
|
|
|
|
if insert_result.host_insert_dropped:
|
|
self._resolve_storage_prefetch_tokens(req_id, insert_result.prefix_len)
|
|
self._finish_storage_prefetch(req_id, fulfilled_tokens=0, reason="dropped")
|
|
self.cache_controller.append_host_mem_release(
|
|
host_indices=host_indices[:completed_tokens],
|
|
extra_pools=[x for xfers in comp_xfers.values() for x in xfers],
|
|
)
|
|
loaded_from_storage = 0
|
|
else:
|
|
commit_actions: list[CacheAction | ComponentAction] = []
|
|
self.tree_core.commit_hicache_transfers(
|
|
last_host_node_id,
|
|
CacheTransferPhase.PREFETCH,
|
|
comp_xfers,
|
|
cache_actions=commit_actions,
|
|
insert_result=insert_result,
|
|
pool_storage_result=operation.pool_storage_result,
|
|
)
|
|
self._apply_cache_actions(commit_actions)
|
|
# The commit emits via commit_actions only; the walk's were applied above.
|
|
assert not insert_result.cache_actions
|
|
|
|
self.cache_controller.mem_pool_host.free(
|
|
host_indices[: insert_result.prefix_len]
|
|
)
|
|
loaded_from_storage = completed_tokens - insert_result.prefix_len
|
|
# Cache mode has only completed L3 -> L2 here. Keep the usable
|
|
# storage span unresolved until admission proves that L2 -> L1
|
|
# load-back actually materialized it for this request.
|
|
self._resolve_storage_prefetch_tokens(req_id, insert_result.prefix_len)
|
|
|
|
self.dec_host_lock_ref(last_host_node_id, anchor_lock_params)
|
|
del self.ongoing_prefetch[req_id]
|
|
self.cache_controller.prefetch_tokens_occupied -= len(prefetch_key)
|
|
|
|
self.prefetch_loaded_tokens_by_reqid[req_id] = loaded_from_storage
|
|
if loaded_from_storage > 0:
|
|
self.prefetch_loaded_storage_start_by_reqid[req_id] = (
|
|
operation.storage_start + insert_result.prefix_len
|
|
)
|
|
else:
|
|
self.prefetch_loaded_storage_start_by_reqid.pop(req_id, None)
|
|
logger.info(
|
|
"HiCache prefetch %s req=%s completed=%d matched=%d loaded=%d occupied=%d",
|
|
"dropped" if insert_result.host_insert_dropped else "success",
|
|
req_id,
|
|
completed_tokens,
|
|
insert_result.prefix_len,
|
|
loaded_from_storage,
|
|
self.cache_controller.prefetch_tokens_occupied,
|
|
)
|
|
return
|
|
|
|
def _check_hybrid_prefetch_result(
|
|
self,
|
|
req_id: str,
|
|
operation: PrefetchOperation,
|
|
completed_tokens: int,
|
|
hash_value: list[str],
|
|
host_indices: torch.Tensor,
|
|
last_host_node_id: NodeId,
|
|
anchor_lock_params: DecLockRefParams,
|
|
prefetch_key: RadixKey,
|
|
) -> bool:
|
|
"""Decide the length of usable prefix.
|
|
|
|
Two strategies depending on the hybrid layout:
|
|
|
|
* DSA-style (Full attention + KV-derived ALL_PAGES sidecar such as the
|
|
DSA / MiniMax indexer): *clamp* to the minimum fetched prefix shared by
|
|
the Full KV pool and every sidecar. A partial prefix is still usable
|
|
because the sidecar is page-aligned with KV and required for every page.
|
|
* Everything else (SWA / Mamba components, mixed DeepSeekV4 stacks):
|
|
*all-or-nothing*. Their pools only cover a window / tail and cannot be
|
|
truncated page by page, so any shortfall discards the whole prefetch.
|
|
|
|
Returns true if prefetch success, or false when an all-or-nothing prefetch
|
|
was discarded (the caller should then treat the prefetch as finished).
|
|
"""
|
|
# Sync completed tokens and per-pool hit pages across ATTN groups, taking
|
|
# the minimum so every rank agrees on the same usable prefix length.
|
|
#
|
|
# Skip KV-derived pools, which do not report hits in operation.pool_storage_result.
|
|
# Their hit lengths are stored in completed_tokens.
|
|
pool_transfers = [
|
|
transfer
|
|
for transfer in operation.pool_transfers or []
|
|
if transfer.indices_from_pool != PoolName.KV
|
|
]
|
|
hit_pages = (
|
|
operation.pool_storage_result.extra_pool_hit_pages if pool_transfers else {}
|
|
)
|
|
pool_hit_pages = [hit_pages.get(t.name, 0) for t in pool_transfers]
|
|
completed_tokens = operation.completed_tokens
|
|
# Hybrid cache state is all-or-nothing: every extra pool (SWA / Mamba / ...)
|
|
# must cover the same fetched prefix. If any pool falls short the whole
|
|
# prefetch result is unusable, so discard it and release everything.
|
|
expected_tokens = len(hash_value) * self.page_size
|
|
all_succeeded = completed_tokens == expected_tokens and all(
|
|
transfer.keys is not None and count == len(transfer.keys)
|
|
for transfer, count in zip(pool_transfers, pool_hit_pages)
|
|
)
|
|
if pool_transfers and not all_succeeded:
|
|
# Drop the KV beliefs from the first page any pool failed to serve;
|
|
# the next insert then re-writes that span through one FULL check,
|
|
# restoring the missing aux pages.
|
|
keep_pages = completed_tokens // self.page_size
|
|
for transfer, count in zip(pool_transfers, pool_hit_pages):
|
|
if transfer.keys is None:
|
|
keep_pages = 0
|
|
elif count < len(transfer.keys):
|
|
# Aux transfers key the chain's trailing pages.
|
|
keep_pages = min(
|
|
keep_pages, max(0, len(hash_value) - len(transfer.keys))
|
|
)
|
|
self.storage_existence_cache.invalidate_beyond(
|
|
PoolName.KV, hash_value, keep_pages=keep_pages
|
|
)
|
|
# The controller's prefetch IO thread already releases the untransferred
|
|
# tail (host_indices[completed_tokens:])
|
|
self.cache_controller.append_host_mem_release(
|
|
host_indices=host_indices[:completed_tokens],
|
|
extra_pools=pool_transfers if operation.pool_transfers_done else None,
|
|
)
|
|
self._finish_storage_prefetch(
|
|
req_id, fulfilled_tokens=0, reason="storage_transfer"
|
|
)
|
|
if anchor_lock_params is not None:
|
|
self.dec_host_lock_ref(last_host_node_id, anchor_lock_params)
|
|
if self.buffer_pipeline is not None:
|
|
self.buffer_pipeline.pop_prefix_ctx(req_id)
|
|
self.buffer_pipeline.release_anchor_lock(req_id)
|
|
del self.ongoing_prefetch[req_id]
|
|
self.cache_controller.prefetch_tokens_occupied -= (
|
|
self._prefetch_occupied_span(prefetch_key, host_indices)
|
|
)
|
|
self.prefetch_loaded_tokens_by_reqid[req_id] = 0
|
|
self.prefetch_loaded_storage_start_by_reqid.pop(req_id, None)
|
|
logger.warning(
|
|
"HiCache hybrid prefetch discarded req=%s completed=%d requested=%d "
|
|
"kv_beliefs_kept_pages=%d",
|
|
req_id,
|
|
completed_tokens,
|
|
expected_tokens,
|
|
keep_pages,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
def _record_storage_prefetch_hit(self, req_id: str, num_tokens: int) -> None:
|
|
"""Start accounting one rank-agreed positive L3 query result."""
|
|
if (
|
|
num_tokens <= 0
|
|
or not self.enable_storage_metrics
|
|
or self.storage_metrics_collector is None
|
|
):
|
|
return
|
|
if req_id in self._storage_prefetch_hit_remaining_by_reqid:
|
|
logger.warning(
|
|
"Replacing unresolved storage-hit accounting req=%s old=%d new=%d",
|
|
req_id,
|
|
self._storage_prefetch_hit_remaining_by_reqid[req_id],
|
|
num_tokens,
|
|
)
|
|
self.discard_storage_prefetch_accounting(req_id)
|
|
self._storage_prefetch_hit_remaining_by_reqid[req_id] = num_tokens
|
|
self.storage_metrics_collector.log_storage_prefetch_hit_tokens(num_tokens)
|
|
|
|
def _resolve_storage_prefetch_tokens(
|
|
self, req_id: str, num_tokens: int, reason: Optional[str] = None
|
|
) -> None:
|
|
if num_tokens <= 0:
|
|
return
|
|
remaining = self._storage_prefetch_hit_remaining_by_reqid.get(req_id)
|
|
if remaining is None:
|
|
return
|
|
dropped = min(num_tokens, remaining)
|
|
if num_tokens > remaining:
|
|
logger.warning(
|
|
"Storage-prefetch accounting exceeded remaining "
|
|
"tokens req=%s requested=%d remaining=%d reason=%s",
|
|
req_id,
|
|
num_tokens,
|
|
remaining,
|
|
reason,
|
|
)
|
|
if reason is not None:
|
|
self.storage_metrics_collector.log_storage_prefetch_unfulfilled_tokens(
|
|
dropped, reason
|
|
)
|
|
remaining -= dropped
|
|
if remaining:
|
|
self._storage_prefetch_hit_remaining_by_reqid[req_id] = remaining
|
|
else:
|
|
self._storage_prefetch_hit_remaining_by_reqid.pop(req_id, None)
|
|
|
|
def _finish_storage_prefetch(
|
|
self, req_id: str, fulfilled_tokens: int, reason: Optional[str]
|
|
) -> None:
|
|
remaining = self._storage_prefetch_hit_remaining_by_reqid.pop(req_id, None)
|
|
if remaining is None:
|
|
return
|
|
if fulfilled_tokens > remaining:
|
|
logger.warning(
|
|
"Storage-prefetch fulfilled accounting exceeded remaining "
|
|
"tokens req=%s fulfilled=%d remaining=%d",
|
|
req_id,
|
|
fulfilled_tokens,
|
|
remaining,
|
|
)
|
|
unfulfilled = max(0, remaining - fulfilled_tokens)
|
|
if reason is not None:
|
|
self.storage_metrics_collector.log_storage_prefetch_unfulfilled_tokens(
|
|
unfulfilled, reason
|
|
)
|
|
|
|
def finish_storage_prefetch_admission(
|
|
self, req_id: str, fulfilled_tokens: int, reason: Optional[str]
|
|
) -> None:
|
|
"""Resolve a cache-mode L3 hit after request admission.
|
|
|
|
Buffer mode resolves at the H2D completion ack instead, because its
|
|
host allocation is transport staging rather than a resident L2 hit.
|
|
"""
|
|
if self.host_memory_mode == "cache":
|
|
self._finish_storage_prefetch(req_id, fulfilled_tokens, reason)
|
|
|
|
def discard_storage_prefetch_accounting(self, req_id: str) -> None:
|
|
"""Drop lifecycle state for cases intentionally excluded from metrics."""
|
|
self._storage_prefetch_hit_remaining_by_reqid.pop(req_id, None)
|
|
|
|
def _handle_storage_prefetch_anchor_loss(self, req_id: str) -> None:
|
|
self._finish_storage_prefetch(req_id, fulfilled_tokens=0, reason="shrunk")
|
|
# The span is still L3-resident; retry from the shorter live match.
|
|
self._storage_prefetch_missed_rids.add(req_id)
|
|
self.revoke_pending_prefetch(req_id)
|
|
|
|
def pop_prefetch_loaded_tokens(self, req_id: str) -> int:
|
|
# The request is being scheduled; a still-unserved miss marker is moot.
|
|
self._storage_prefetch_missed_rids.discard(req_id)
|
|
self.prefetch_loaded_storage_start_by_reqid.pop(req_id, None)
|
|
return self.prefetch_loaded_tokens_by_reqid.pop(req_id, 0)
|
|
|
|
def pop_prefetch_loaded_span(self, req_id: str) -> tuple[int, Optional[int]]:
|
|
"""Pop the loaded L3 token count and its absolute prefix start."""
|
|
self._storage_prefetch_missed_rids.discard(req_id)
|
|
return (
|
|
self.prefetch_loaded_tokens_by_reqid.pop(req_id, 0),
|
|
self.prefetch_loaded_storage_start_by_reqid.pop(req_id, None),
|
|
)
|
|
|
|
def pop_storage_prefetch_miss(self, req_id: str) -> bool:
|
|
"""True once per resolved storage-prefetch miss for a live request;
|
|
the scheduler uses it to arm the paced availability-check retry."""
|
|
if req_id in self._storage_prefetch_missed_rids:
|
|
self._storage_prefetch_missed_rids.discard(req_id)
|
|
return True
|
|
return False
|
|
|
|
def plan_staged_splice(
|
|
self, req_id: str, device_prefix_len: int
|
|
) -> tuple[int, int]:
|
|
"""(kv, swa) host-hit tokens a staged buffer-mode prefetch will splice
|
|
given the request's live device prefix; frees unusable holds."""
|
|
if self.buffer_pipeline is None:
|
|
return 0, 0
|
|
return self.buffer_pipeline.plan_staged_splice(req_id, device_prefix_len)
|
|
|
|
def staged_prefetch_swa_tokens(self, req_id: str) -> int:
|
|
"""SWA device tokens consuming a staged buffer-mode prefetch will
|
|
allocate; surfaced as the request's swa_host_hit_length."""
|
|
if self.buffer_pipeline is None:
|
|
return 0
|
|
return self.buffer_pipeline.staged_prefetch_swa_tokens(req_id)
|
|
|
|
@rank_consensus(same_params=True)
|
|
def release_aborted_request(self, rid: str) -> None:
|
|
if self.linker is not None:
|
|
self.linker.release_request(rid)
|
|
self.prefetch_loaded_tokens_by_reqid.pop(rid, None)
|
|
self.prefetch_loaded_storage_start_by_reqid.pop(rid, None)
|
|
self._storage_prefetch_missed_rids.discard(rid)
|
|
if (
|
|
self.buffer_pipeline is not None
|
|
and self.buffer_pipeline.release_staged_hold(rid)
|
|
):
|
|
return
|
|
self.discard_storage_prefetch_accounting(rid)
|
|
if rid not in self.ongoing_prefetch:
|
|
return
|
|
|
|
(
|
|
last_host_node_id,
|
|
prefetch_key,
|
|
host_indices,
|
|
operation,
|
|
anchor_lock_params,
|
|
comp_xfers,
|
|
) = self.ongoing_prefetch[rid]
|
|
if operation.host_indices is None:
|
|
self.cache_controller.terminate_prefetch(operation)
|
|
self.revoke_pending_prefetch(rid)
|
|
return
|
|
|
|
completed_tokens, _ = self.cache_controller.terminate_prefetch(operation)
|
|
if anchor_lock_params is not None:
|
|
self.dec_host_lock_ref(last_host_node_id, anchor_lock_params)
|
|
del self.ongoing_prefetch[rid]
|
|
if self.buffer_pipeline is not None:
|
|
self.buffer_pipeline.pop_prefix_ctx(rid)
|
|
self.buffer_pipeline.release_anchor_lock(rid)
|
|
pool_transfers = [x for xfers in comp_xfers.values() for x in xfers]
|
|
self.cache_controller.append_host_mem_release(
|
|
host_indices=host_indices[:completed_tokens],
|
|
extra_pools=pool_transfers if operation.pool_transfers_done else None,
|
|
)
|
|
# Buffer mode granted occupancy at hit-alloc, sized to the bounce;
|
|
# cache mode reserved the requested span at enqueue.
|
|
self.cache_controller.prefetch_tokens_occupied -= self._prefetch_occupied_span(
|
|
prefetch_key, host_indices
|
|
)
|
|
|
|
def _invalidate_absent_from_hit_query(self, operation) -> None:
|
|
"""Drop KV beliefs beyond the folded usable cut (rank-synced): the
|
|
next insert then re-writes the node (all pools), healing stale
|
|
positives and aux holes at the cut through one FULL check."""
|
|
if self.host_memory_mode != "buffer_only":
|
|
return
|
|
chain = operation.all_hash_values
|
|
if chain is None:
|
|
return
|
|
self.storage_existence_cache.invalidate_beyond(
|
|
PoolName.KV, chain, keep_pages=operation.storage_hit_count // self.page_size
|
|
)
|
|
|
|
def _account_prefetch_outcome(self, operation, revoked: bool) -> None:
|
|
"""Feed the cumulative prefetch-outcome counters at the (rank-synced)
|
|
query outcome: T = prompt tokens, L = requested, m = L3-miss."""
|
|
requested = operation.stats_requested_tokens
|
|
if requested <= 0:
|
|
return
|
|
stats = self._prefetch_outcome_stats
|
|
hit = max(0, min(operation.storage_hit_count, requested))
|
|
if revoked:
|
|
if hit > 0:
|
|
stats["revoked_insufficient"] += 1
|
|
else:
|
|
stats["revoked_full_miss"] += 1
|
|
miss = requested - hit
|
|
stats["l3_demand_requests"] += 1
|
|
stats["l1l2_miss_tokens"] += requested
|
|
stats["l3_miss_tokens"] += miss
|
|
|
|
def prefetch_outcome_stats_snapshot(self) -> dict:
|
|
return self._prefetch_outcome_stats.copy()
|
|
|
|
def _prefetch_occupied_span(self, prefetch_key, host_indices) -> int:
|
|
"""Occupancy units held by a prefetch: cache mode reserves the
|
|
requested span at enqueue; buffer mode grants at hit-alloc, sized
|
|
to the allocation (0 while still querying / parked)."""
|
|
if self.host_memory_mode == "buffer_only":
|
|
return len(host_indices) if host_indices is not None else 0
|
|
return len(prefetch_key)
|
|
|
|
def revoke_pending_prefetch(self, req_id: str) -> None:
|
|
info = self.ongoing_prefetch.pop(req_id, None)
|
|
self._finish_storage_prefetch(req_id, fulfilled_tokens=0, reason="dropped")
|
|
if info is None:
|
|
return
|
|
(
|
|
last_host_node_id,
|
|
prefetch_key,
|
|
_host_indices,
|
|
operation,
|
|
anchor_lock_params,
|
|
comp_xfers,
|
|
) = info
|
|
self._invalidate_absent_from_hit_query(operation)
|
|
if self.buffer_pipeline is not None:
|
|
self.buffer_pipeline.pop_prefix_ctx(req_id)
|
|
self.buffer_pipeline.release_anchor_lock(req_id)
|
|
cc = self.cache_controller
|
|
cc.append_host_mem_release(
|
|
extra_pools=[x for xfers in comp_xfers.values() for x in xfers]
|
|
)
|
|
if anchor_lock_params is not None:
|
|
self.dec_host_lock_ref(last_host_node_id, anchor_lock_params)
|
|
# Every revoke path runs before the bounce alloc, so buffer mode
|
|
# holds no occupancy here; post-alloc aborts go through
|
|
# release_aborted_request instead.
|
|
assert _host_indices is None or self.host_memory_mode != "buffer_only"
|
|
cc.prefetch_tokens_occupied = max(
|
|
0,
|
|
cc.prefetch_tokens_occupied
|
|
- self._prefetch_occupied_span(prefetch_key, _host_indices),
|
|
)
|
|
|
|
def _drain_storage_control_queues_impl(
|
|
self,
|
|
n_storage_hit: Optional[int],
|
|
n_ack_prefetch: Optional[int],
|
|
n_backup: Optional[int],
|
|
n_release: Optional[int],
|
|
extra_release_counts: Optional[dict[PoolName, int]],
|
|
log_metrics: bool,
|
|
) -> None:
|
|
cc = self.cache_controller
|
|
|
|
def _drain_queue(q: Queue[T], n: Optional[int]) -> Iterator[T]:
|
|
"""If n is None, consume all items from the queue.
|
|
Otherwise, consume n items from the queue. Blocking if there are no enough n items.
|
|
|
|
In TP, each rank consumes the a minimal number of items of all ranks.
|
|
In PP, each rank consumes the exact number of items of PP0. Refer to _pp_sync for more details.
|
|
|
|
This prevents TP/PP divergence.
|
|
"""
|
|
if n is None:
|
|
while not q.empty():
|
|
item = q.get()
|
|
yield item
|
|
else:
|
|
for _ in range(n):
|
|
# Block when there are not enough elements.
|
|
# All TP/PP ranks must consume the same number of elements.
|
|
item = q.get()
|
|
yield item
|
|
|
|
buffer_mode = self.host_memory_mode == "buffer_only"
|
|
|
|
def _try_alloc_storage_hit(operation) -> bool:
|
|
"""Allocate the hit-sized bounce and launch the transfer.
|
|
Returns False when staging pressure defers the allocation
|
|
(buffer mode parks and retries; cache mode revokes)."""
|
|
req_id = operation.request_id
|
|
info = self.ongoing_prefetch.get(req_id)
|
|
hit_tokens = operation.storage_hit_count
|
|
if info is None:
|
|
return True # aborted/cleaned; nothing to retry
|
|
if operation.is_terminated():
|
|
self.revoke_pending_prefetch(req_id)
|
|
return True
|
|
|
|
if buffer_mode and cc.prefetch_rate_limited():
|
|
# Pool is load-saturated: hold the KNOWN hit until staged
|
|
# prefetches ahead of us are consumed. The op stays in
|
|
# ongoing_prefetch, so wait_complete keeps gating admission.
|
|
return False
|
|
if buffer_mode:
|
|
# IO commit: pin before the bounce alloc so a cancel is a
|
|
# plain revoke and a parked op keeps its pin; a fetch whose
|
|
# splice base is gone is not worth its storage read.
|
|
if self.buffer_pipeline.try_lock_anchor(req_id) == "anchor_lost":
|
|
self._prefetch_outcome_stats["declined_anchor_lost"] += 1
|
|
self._handle_storage_prefetch_anchor_loss(req_id)
|
|
return True
|
|
if self.buffer_pipeline.staged_span_covered(
|
|
req_id, operation.storage_hit_count
|
|
):
|
|
# Live tree already covers the span: nothing left to
|
|
# splice, so skip the storage read.
|
|
self._prefetch_outcome_stats["declined_device_covered"] += 1
|
|
self._finish_storage_prefetch(
|
|
req_id, fulfilled_tokens=0, reason=None
|
|
)
|
|
self.revoke_pending_prefetch(req_id)
|
|
return True
|
|
alloc_len = hit_tokens
|
|
host_indices = cc.mem_pool_host.alloc(alloc_len)
|
|
if host_indices is None:
|
|
self.evict_host(alloc_len)
|
|
host_indices = cc.mem_pool_host.alloc(alloc_len)
|
|
if host_indices is None and not buffer_mode:
|
|
# Memory-pressure fallback: a shorter page-aligned prefix.
|
|
# (Cache mode only — buffer mode parks for the full hit.)
|
|
available_size = cc.mem_pool_host.available_size()
|
|
alloc_len = min(
|
|
hit_tokens,
|
|
available_size - (available_size % self.page_size),
|
|
)
|
|
if alloc_len >= self.prefetch_threshold:
|
|
host_indices = cc.mem_pool_host.alloc(alloc_len)
|
|
if host_indices is None:
|
|
if buffer_mode:
|
|
return False
|
|
self._finish_storage_prefetch(
|
|
req_id, fulfilled_tokens=0, reason="host_capacity"
|
|
)
|
|
self.revoke_pending_prefetch(req_id)
|
|
return True
|
|
|
|
self._resolve_storage_prefetch_tokens(
|
|
req_id, hit_tokens - alloc_len, reason="host_capacity"
|
|
)
|
|
operation.storage_hit_count = alloc_len
|
|
operation.hash_value = operation.hash_value[: alloc_len // self.page_size]
|
|
operation.host_indices = host_indices
|
|
self.ongoing_prefetch[req_id] = info._replace(host_indices=host_indices)
|
|
if buffer_mode:
|
|
cc.prefetch_tokens_occupied += alloc_len
|
|
cc.prefetch_buffer.put(operation)
|
|
return True
|
|
|
|
def _drain_and_alloc_storage_hit():
|
|
# Parked hits first (FIFO fairness with retries; buffer only).
|
|
if buffer_mode:
|
|
parked = self.buffer_pipeline.pending_hit_allocs
|
|
while parked:
|
|
if not _try_alloc_storage_hit(parked[0]):
|
|
break
|
|
parked.popleft()
|
|
for operation in _drain_queue(cc.prefetch_hit_queue, n_storage_hit):
|
|
req_id = operation.request_id
|
|
hit_tokens = operation.storage_hit_count
|
|
info = self.ongoing_prefetch.get(req_id)
|
|
if info is None:
|
|
# Request already aborted/cleaned up; still flush the
|
|
# query's absent-hash feedback.
|
|
self._invalidate_absent_from_hit_query(operation)
|
|
if hit_tokens > 0:
|
|
self.discard_storage_prefetch_accounting(req_id)
|
|
continue
|
|
if hit_tokens > 0:
|
|
self._record_storage_prefetch_hit(req_id, hit_tokens)
|
|
if operation.is_terminated():
|
|
# Controller-side miss termination (retryable) or an abort
|
|
# race (abort cleanup discards the marker).
|
|
if hit_tokens > 0:
|
|
self._finish_storage_prefetch(
|
|
req_id,
|
|
fulfilled_tokens=0,
|
|
reason=(
|
|
"below_threshold"
|
|
if hit_tokens < self.prefetch_threshold
|
|
else None
|
|
),
|
|
)
|
|
self._storage_prefetch_missed_rids.add(req_id)
|
|
self.revoke_pending_prefetch(req_id)
|
|
continue
|
|
if hit_tokens < self.prefetch_threshold:
|
|
# Below-threshold hits are not worth the transfer.
|
|
self._account_prefetch_outcome(operation, revoked=True)
|
|
self._finish_storage_prefetch(
|
|
req_id, fulfilled_tokens=0, reason="below_threshold"
|
|
)
|
|
self._storage_prefetch_missed_rids.add(req_id)
|
|
self.revoke_pending_prefetch(req_id)
|
|
continue
|
|
self._invalidate_absent_from_hit_query(operation)
|
|
self._account_prefetch_outcome(operation, revoked=False)
|
|
if not _try_alloc_storage_hit(operation):
|
|
# Counted once at first parking, not per retry tick.
|
|
self._prefetch_outcome_stats["declined_rate_limited"] += 1
|
|
self.buffer_pipeline.pending_hit_allocs.append(operation)
|
|
|
|
def _drain_ack_prefetch():
|
|
for ack in _drain_queue(cc.ack_prefetch_queue, n_ack_prefetch):
|
|
operation = ack.operation
|
|
if ack.completed_tokens is not None:
|
|
if operation.request_id in self.ongoing_prefetch:
|
|
assert operation.completed_tokens <= ack.completed_tokens
|
|
operation.completed_tokens = ack.completed_tokens
|
|
if ack.pool_hits is not None:
|
|
if operation.request_id in self.ongoing_prefetch:
|
|
operation.pool_storage_result.update_extra_pool_hit_pages(
|
|
ack.pool_hits
|
|
)
|
|
operation.pool_transfers_done = True
|
|
if ack.completed_req:
|
|
if operation.request_id in self.ongoing_prefetch:
|
|
# check_prefetch_progress() is not called for this rid yet.
|
|
# Let us insert the prefetch result into the radix tree.
|
|
self._handle_prefetch_result(operation)
|
|
cc.append_host_mem_release(
|
|
operation.host_indices[operation.completed_tokens :],
|
|
(
|
|
operation.pool_transfers
|
|
if not operation.pool_transfers_done
|
|
else None
|
|
),
|
|
)
|
|
|
|
def _drain_backup():
|
|
drained = 0
|
|
for operation in _drain_queue(cc.ack_backup_queue, n_backup):
|
|
drained += 1
|
|
if buffer_mode:
|
|
# Storage write acked: free the staging.
|
|
self.buffer_pipeline.finish_storage_write_ack(operation.id)
|
|
else:
|
|
entry = self.ongoing_backup.pop(operation.id, None)
|
|
if entry is not None:
|
|
node_id, lock_params = entry
|
|
self.dec_host_lock_ref(node_id, lock_params)
|
|
if (
|
|
log_metrics
|
|
and self.enable_storage_metrics
|
|
and self.storage_metrics_collector is not None
|
|
):
|
|
self.storage_metrics_collector.log_backuped_tokens(
|
|
operation.completed_tokens
|
|
)
|
|
return drained
|
|
|
|
def _drain_release():
|
|
host_indices_list = []
|
|
released_tokens = 0
|
|
for host_indices in _drain_queue(cc.host_mem_release_queue, n_release):
|
|
host_indices_list.append(host_indices)
|
|
released_tokens += len(host_indices)
|
|
if host_indices_list:
|
|
cc.mem_pool_host.free(torch.cat(host_indices_list, dim=0))
|
|
return len(host_indices_list), released_tokens
|
|
|
|
def _drain_extra_release():
|
|
drained: dict[PoolName, tuple[int, int]] = {}
|
|
if not extra_release_counts:
|
|
return drained
|
|
for pool_name, limit in extra_release_counts.items():
|
|
release_queue = cc.extra_host_mem_release_queues.get(pool_name)
|
|
if release_queue is None:
|
|
continue
|
|
host_indices_list = []
|
|
released_tokens = 0
|
|
for host_indices in _drain_queue(release_queue, limit):
|
|
host_indices_list.append(host_indices)
|
|
released_tokens += len(host_indices)
|
|
if host_indices_list:
|
|
cc.mem_pool_host.free(
|
|
torch.cat(host_indices_list, dim=0), pool=pool_name
|
|
)
|
|
drained[pool_name] = (len(host_indices_list), released_tokens)
|
|
return drained
|
|
|
|
_drain_and_alloc_storage_hit()
|
|
_drain_ack_prefetch()
|
|
_drain_backup()
|
|
_drain_release()
|
|
_drain_extra_release()
|
|
|
|
def drain_storage_control_queues(self) -> None:
|
|
cc = self.cache_controller
|
|
extra_release_queues = getattr(cc, "extra_host_mem_release_queues", {})
|
|
extra_pool_names = list(extra_release_queues)
|
|
local_qsize_list = [
|
|
cc.prefetch_hit_queue.qsize(),
|
|
cc.ack_prefetch_queue.qsize(),
|
|
cc.ack_backup_queue.qsize(),
|
|
cc.host_mem_release_queue.qsize(),
|
|
*[
|
|
extra_release_queues[pool_name].qsize()
|
|
for pool_name in extra_pool_names
|
|
],
|
|
]
|
|
qsizes = torch.tensor(
|
|
local_qsize_list,
|
|
dtype=torch.int,
|
|
)
|
|
self._all_reduce(qsizes, torch.distributed.ReduceOp.MIN)
|
|
qsize_list = list(map(int, qsizes.tolist()))
|
|
n_storage_hit, n_ack_prefetch, n_backup, n_release = qsize_list[:4]
|
|
extra_release_counts = {
|
|
pool_name: count
|
|
for pool_name, count in zip(extra_pool_names, qsize_list[4:])
|
|
}
|
|
self._drain_storage_control_queues_impl(
|
|
n_storage_hit=n_storage_hit,
|
|
n_ack_prefetch=n_ack_prefetch,
|
|
n_backup=n_backup,
|
|
n_release=n_release,
|
|
extra_release_counts=extra_release_counts,
|
|
log_metrics=True,
|
|
)
|
|
|
|
def drain_storage_control_queues_local(self) -> None:
|
|
"""Drain the storage control queues without cross-rank synchronization.
|
|
|
|
For the detach / shutdown path, where best-effort cleanup matters more than
|
|
keeping the drained counts identical across ranks. The prefetch-hit queue is
|
|
deliberately skipped: servicing it would allocate host pages for a prefetch
|
|
that can no longer complete.
|
|
"""
|
|
cc = self.cache_controller
|
|
# The storage queues are created by the controller when the storage threads
|
|
# start, so they are still None when a backend was never attached.
|
|
if cc is None or cc.prefetch_hit_queue is None:
|
|
return
|
|
self._drain_storage_control_queues_impl(
|
|
n_storage_hit=0,
|
|
n_ack_prefetch=0,
|
|
n_backup=None,
|
|
n_release=None,
|
|
extra_release_counts={
|
|
name: None for name in cc.extra_host_mem_release_queues
|
|
},
|
|
log_metrics=False,
|
|
)
|
|
|
|
# ---- HiCache: Storage backend lifecycle (delegated) ----
|
|
|
|
def attach_storage_backend(
|
|
self,
|
|
storage_backend: str,
|
|
storage_backend_extra_config_json: Optional[str] = None,
|
|
served_model_name: Optional[str] = None,
|
|
hicache_storage_prefetch_policy: Optional[str] = None,
|
|
hicache_write_policy: Optional[str] = None,
|
|
) -> tuple[bool, str]:
|
|
"""Attach (enable) the HiCache storage backend at runtime."""
|
|
if self._storage_attachment is None:
|
|
return (
|
|
False,
|
|
"HiCache is not initialized; launch with "
|
|
"--enable-hierarchical-cache to attach a storage backend.",
|
|
)
|
|
return self._storage_attachment.attach(
|
|
storage_backend=storage_backend,
|
|
storage_backend_extra_config_json=storage_backend_extra_config_json,
|
|
served_model_name=served_model_name,
|
|
hicache_storage_prefetch_policy=hicache_storage_prefetch_policy,
|
|
hicache_write_policy=hicache_write_policy,
|
|
)
|
|
|
|
def detach_storage_backend(self) -> tuple[bool, str]:
|
|
"""Detach (disable) the HiCache storage backend at runtime."""
|
|
if self._storage_attachment is None:
|
|
return False, "HiCache storage backend is not initialized."
|
|
return self._storage_attachment.detach()
|
|
|
|
def shutdown(self) -> None:
|
|
"""Best-effort auto-detach of the storage backend on process shutdown."""
|
|
if self._storage_attachment is not None:
|
|
self._storage_attachment.shutdown()
|
|
|
|
def clear_storage_backend(self) -> bool:
|
|
if self._storage_attachment is None:
|
|
return False
|
|
ok = self._storage_attachment.clear()
|
|
if ok:
|
|
# L3 is empty now: every storage-presence belief is stale, and a
|
|
# retained positive would skip that page's backup forever.
|
|
self.storage_existence_cache.clear()
|
|
return ok
|
|
|
|
# ---- HiCache: Async Event Management ----
|
|
|
|
def _count_ready_acks(self, ack_queue) -> int:
|
|
ready_count = 0
|
|
for ack in ack_queue:
|
|
if not ack.finish_event.query():
|
|
break
|
|
ready_count += 1
|
|
return ready_count
|
|
|
|
def _sync_hicache_ready_counts(
|
|
self,
|
|
) -> tuple[int, int, tuple[int, ...], tuple[PoolName, ...]]:
|
|
cc = self.cache_controller
|
|
extra_release_queues = getattr(cc, "extra_host_mem_release_queues", {})
|
|
extra_pool_names = tuple(extra_release_queues) if self.enable_storage else ()
|
|
if cc is None or self.pp_rank > 0:
|
|
write_acks = 0
|
|
load_acks = 0
|
|
# Zero placeholders shaped like PP0's slots: _pp_sync hands the
|
|
# received tensor back in place, so all ranks must build the same
|
|
# length or PP1+ would recv into a mismatched buffer.
|
|
storage_queue_sizes = (
|
|
(0,) * (4 + len(extra_pool_names)) if self.enable_storage else ()
|
|
)
|
|
else:
|
|
write_acks = self._count_ready_acks(cc.ack_write_queue)
|
|
load_acks = self._count_ready_acks(cc.ack_load_queue)
|
|
storage_queue_sizes = (
|
|
(
|
|
cc.prefetch_hit_queue.qsize(),
|
|
cc.ack_prefetch_queue.qsize(),
|
|
cc.ack_backup_queue.qsize(),
|
|
cc.host_mem_release_queue.qsize(),
|
|
*(extra_release_queues[name].qsize() for name in extra_pool_names),
|
|
)
|
|
if self.enable_storage
|
|
else ()
|
|
)
|
|
|
|
# Piggybacked TP check: [digest, -digest] MIN-reduces to [min, -max],
|
|
# equal iff reclaim victim order matched on every rank.
|
|
digest = self.tree_core.write_back_duplicate_reclaim_digest
|
|
ready_counts = torch.tensor(
|
|
[
|
|
write_acks,
|
|
load_acks,
|
|
*storage_queue_sizes,
|
|
digest,
|
|
-digest,
|
|
],
|
|
dtype=torch.int64,
|
|
device="cpu",
|
|
)
|
|
self._all_reduce(ready_counts, torch.distributed.ReduceOp.MIN)
|
|
|
|
count_values = list(map(int, ready_counts.tolist()))
|
|
assert digest == count_values[-2] and digest == -count_values[-1], (
|
|
"write_back duplicate-reclaim victims diverged across PP/TP ranks"
|
|
)
|
|
return (
|
|
count_values[0],
|
|
count_values[1],
|
|
tuple(count_values[2:-2]),
|
|
extra_pool_names,
|
|
)
|
|
|
|
def writing_check(
|
|
self, write_back: bool = False, finish_count: Optional[int] = None
|
|
) -> None:
|
|
"""Poll write-through completions."""
|
|
cc = self.cache_controller
|
|
if cc is None:
|
|
return
|
|
|
|
if write_back:
|
|
# Blocking: wait for all pending write-backs
|
|
while self.ongoing_write_through:
|
|
for ack in cc.ack_write_queue:
|
|
ack.finish_event.synchronize()
|
|
for ack_id in ack.node_ids:
|
|
if ack_id in self.ongoing_write_through:
|
|
self._finish_write_through_ack(ack_id)
|
|
self._log_write_ack_metrics(ack)
|
|
cc.ack_write_queue.clear()
|
|
assert len(self.ongoing_write_through) == 0
|
|
return
|
|
|
|
if finish_count is None:
|
|
# Every rank must enter the all_reduce below; ongoing_write_through can
|
|
# diverge across ranks (e.g. write_backup returning 0 on a subset).
|
|
finish_count = 0
|
|
if self.pp_rank == 0:
|
|
finish_count = self._count_ready_acks(cc.ack_write_queue)
|
|
finish_count_tensor = torch.tensor(
|
|
finish_count, dtype=torch.int, device="cpu"
|
|
)
|
|
self._all_reduce(finish_count_tensor, torch.distributed.ReduceOp.MIN)
|
|
finish_count = finish_count_tensor.item()
|
|
|
|
# Process completed acks
|
|
while finish_count > 0:
|
|
ack = cc.ack_write_queue.pop(0)
|
|
ack.finish_event.synchronize()
|
|
for ack_id in ack.node_ids:
|
|
self._finish_write_through_ack(ack_id)
|
|
self._log_write_ack_metrics(ack)
|
|
finish_count -= 1
|
|
|
|
def _log_write_ack_metrics(self, ack: HiCacheAck) -> None:
|
|
"""Record D->H backup volume and duration for a completed write ack."""
|
|
if self.metrics_collector is None:
|
|
return
|
|
for pool, num_tokens in (ack.num_tokens_by_pool or {}).items():
|
|
if num_tokens > 0:
|
|
self.metrics_collector.increment_backup_num_tokens(
|
|
num_tokens=num_tokens, pool=pool
|
|
)
|
|
if ack.num_bytes > 0:
|
|
self.metrics_collector.increment_backup_num_bytes(ack.num_bytes)
|
|
if ack.timing_enabled:
|
|
duration_ms = ack.start_event.elapsed_time(ack.finish_event)
|
|
self.metrics_collector.observe_backup_duration(duration_ms / 1000.0)
|
|
|
|
def loading_check(self, finish_count: Optional[int] = None) -> None:
|
|
"""Poll load-back completions."""
|
|
cc = self.cache_controller
|
|
if cc is None:
|
|
return
|
|
if finish_count is None:
|
|
# Every rank must enter the all_reduce below; ongoing_load_back can
|
|
# diverge across ranks.
|
|
finish_count = 0
|
|
if self.pp_rank == 0:
|
|
finish_count = self._count_ready_acks(cc.ack_load_queue)
|
|
# Piggybacked TP check: [digest, -digest] MIN-reduces to [min, -max],
|
|
# equal iff reclaim victim order matched on every rank.
|
|
digest = self.tree_core.write_back_duplicate_reclaim_digest
|
|
sync_tensor = torch.tensor(
|
|
[finish_count, digest, -digest], dtype=torch.int64, device="cpu"
|
|
)
|
|
self._all_reduce(sync_tensor, torch.distributed.ReduceOp.MIN)
|
|
finish_count = int(sync_tensor[0].item())
|
|
assert sync_tensor[1].item() == -sync_tensor[2].item(), (
|
|
"write_back duplicate-reclaim victims diverged across TP ranks"
|
|
)
|
|
|
|
while finish_count > 0:
|
|
ack = cc.ack_load_queue.pop(0)
|
|
ack.finish_event.synchronize()
|
|
for ack_id in ack.node_ids:
|
|
if (
|
|
self.buffer_pipeline is not None
|
|
and self.buffer_pipeline.try_finish_load_back(ack_id)
|
|
):
|
|
continue
|
|
node, lock_params, host_lock_params = self.ongoing_load_back.pop(ack_id)
|
|
self.dec_lock_ref(node, lock_params)
|
|
self.dec_host_lock_ref(node, host_lock_params)
|
|
# Unpin the loaded nodes; host copies stay as reclaimable duplicates.
|
|
self.tree_core.finish_load_back(node)
|
|
|
|
if self.metrics_collector is not None:
|
|
for pool, num_tokens in (ack.num_tokens_by_pool or {}).items():
|
|
if num_tokens > 0:
|
|
self.metrics_collector.increment_load_back_num_tokens(
|
|
num_tokens=num_tokens, pool=pool
|
|
)
|
|
if ack.num_bytes > 0:
|
|
self.metrics_collector.increment_load_back_num_bytes(ack.num_bytes)
|
|
if ack.timing_enabled:
|
|
duration_ms = ack.start_event.elapsed_time(ack.finish_event)
|
|
self.metrics_collector.observe_load_back_duration(
|
|
duration_ms / 1000.0
|
|
)
|
|
finish_count -= 1
|
|
|
|
# ---- HiCache: Scheduler Entry Points ----
|
|
|
|
def init_load_back(
|
|
self,
|
|
params: InitLoadBackParams,
|
|
) -> tuple[torch.Tensor, NodeId]:
|
|
"""Prepare KV cache loading from host to device.
|
|
Returns (device_indices, last_node). Buffer mode dispatches to the
|
|
staged-prefetch consumption (BufferModePipeline.init_load_back)."""
|
|
if self.buffer_pipeline is not None:
|
|
return self.buffer_pipeline.init_load_back(params)
|
|
best_match_node_id = params.best_match_node
|
|
mem_quota = params.mem_quota
|
|
req = params.req
|
|
assert req is not None
|
|
if self.linker is not None and self.linker.has_hit(req.rid):
|
|
return self.linker.load_back(req)
|
|
last_best_match_device_node_id = req.last_node
|
|
|
|
if (
|
|
self.tree_core.is_full_device_evicted(best_match_node_id)
|
|
or params.host_hit_length > 0
|
|
or (
|
|
req is not None
|
|
and (req.swa_host_hit_length > 0 or req.mamba_host_hit_length > 0)
|
|
)
|
|
):
|
|
if self.load_back(best_match_node_id, mem_quota, req=req):
|
|
new_indices = self.tree_core.collect_full_device_indices(
|
|
best_match_node_id, last_best_match_device_node_id
|
|
)
|
|
if new_indices.numel() == 0:
|
|
return (
|
|
self.tree_core.empty_match_result.device_indices,
|
|
last_best_match_device_node_id,
|
|
)
|
|
|
|
logger.debug(
|
|
"init_load_back success: loaded %d tokens for node %d",
|
|
len(new_indices),
|
|
best_match_node_id,
|
|
)
|
|
return new_indices, best_match_node_id
|
|
|
|
return (
|
|
self.tree_core.empty_match_result.device_indices,
|
|
last_best_match_device_node_id,
|
|
)
|
|
|
|
def check_hicache_events(self) -> None:
|
|
"""Called per scheduler step to poll async HiCache events."""
|
|
if self.linker is not None:
|
|
finish_counts = torch.tensor(
|
|
[
|
|
self.linker.num_completed_loads(),
|
|
self.linker.num_completed_offloads(),
|
|
],
|
|
dtype=torch.int,
|
|
device="cpu",
|
|
)
|
|
self._all_reduce_attn_groups(finish_counts, torch.distributed.ReduceOp.MIN)
|
|
load_count, offload_count = map(int, finish_counts.tolist())
|
|
self.linker.drain_loads(load_count)
|
|
local_successes = self.linker.take_completed_offloads(offload_count)
|
|
if local_successes:
|
|
successes = torch.tensor(local_successes, dtype=torch.int, device="cpu")
|
|
self._all_reduce_attn_groups(successes, torch.distributed.ReduceOp.MIN)
|
|
self.linker.commit_completed_offloads(
|
|
[bool(success) for success in successes.tolist()]
|
|
)
|
|
return
|
|
|
|
# Reap the previous round's PP-sync sends before issuing new ones.
|
|
self._drain_async_work()
|
|
|
|
(
|
|
write_finish_count,
|
|
load_finish_count,
|
|
storage_queue_sizes,
|
|
extra_pool_names,
|
|
) = self._sync_hicache_ready_counts()
|
|
self.writing_check(finish_count=write_finish_count)
|
|
self.loading_check(finish_count=load_finish_count)
|
|
|
|
if self.enable_storage and storage_queue_sizes:
|
|
n_storage_hit, n_ack_prefetch, n_backup, n_release = storage_queue_sizes[:4]
|
|
extra_release_counts = {
|
|
pool_name: count
|
|
for pool_name, count in zip(extra_pool_names, storage_queue_sizes[4:])
|
|
}
|
|
self._drain_storage_control_queues_impl(
|
|
n_storage_hit=n_storage_hit,
|
|
n_ack_prefetch=n_ack_prefetch,
|
|
n_backup=n_backup,
|
|
n_release=n_release,
|
|
extra_release_counts=extra_release_counts,
|
|
log_metrics=True,
|
|
)
|
|
if self.buffer_pipeline is not None:
|
|
self.buffer_pipeline.flush_pending_writes()
|
|
if self.enable_storage_metrics and self.storage_metrics_collector is not None:
|
|
storage_metrics = self.cache_controller.storage_backend.get_stats()
|
|
if storage_metrics is None:
|
|
storage_metrics = StorageMetrics()
|
|
if not hasattr(storage_metrics, "prefetch_stats"):
|
|
storage_metrics.prefetch_stats = self.prefetch_outcome_stats_snapshot()
|
|
self.storage_metrics_collector.log_storage_metrics(storage_metrics)
|
|
|
|
def ready_to_load_host_cache(self) -> int:
|
|
"""Notify the cache controller to start the KV cache loading."""
|
|
if self.linker is not None:
|
|
return self.linker.start_layer_wise_loading()
|
|
if self.cache_controller is not None:
|
|
return self.cache_controller.start_loading()
|
|
return 0
|
|
|
|
def is_load_back_event_done(self, consumer_index: int) -> bool:
|
|
"""Return True after the local load-back event is complete.
|
|
|
|
Mirrors ``HiRadixCache`` so the disagg decode restore state machine
|
|
(``DecodeHiCacheTransferMixin``) can gate on load-back completion; the
|
|
controller-level ``layer_done_counter`` event is shared across cache
|
|
implementations, while the tree-side bookkeeping runs in
|
|
``loading_check``.
|
|
"""
|
|
if consumer_index < 0 or self.cache_controller is None:
|
|
return True
|
|
|
|
finish_event = self.cache_controller.layer_done_counter.events[
|
|
consumer_index
|
|
].finish_event
|
|
if not finish_event.query():
|
|
return False
|
|
|
|
self.loading_check()
|
|
return True
|
|
|
|
# ---- Query / Inspection APIs ----
|
|
# These APIs exist for compatibility with other RadixTree implementations.
|
|
# TODO: simplify and consolidate in a future refactor.
|
|
|
|
@property
|
|
def sliding_window_size(self):
|
|
return self._sliding_window_size
|
|
|
|
def swa_reprefill_tail_tokens(self) -> int:
|
|
"""
|
|
Only unified_kv needs this: SWA lives in a per-request ring
|
|
(state_slot/pos), not content-stable and never stored in the tree, so a
|
|
reused prefix's trailing sliding window would read another request's
|
|
stale ring slots. Re-prefilling that window rewrites this request's ring.
|
|
|
|
Applies to plain radix reuse as well as HiCache -- the ring is stale
|
|
either way. Returns 0 once SWA has a host pool to restore exact contents
|
|
from, and for every non-unified_kv layout, whose SWA slots are
|
|
content-stable.
|
|
"""
|
|
from sglang.kernels.ops.attention.dsv4.unified_kv_kernels.env_gate import (
|
|
is_unified_kv_triton,
|
|
)
|
|
|
|
swa = self.components.get(ComponentType.SWA)
|
|
if swa is None or not swa.sliding_window_size:
|
|
return 0
|
|
if not is_unified_kv_triton():
|
|
return 0
|
|
if self.tree_core.has_swa_host_pool:
|
|
return 0
|
|
return swa.sliding_window_size
|
|
|
|
def swa_retain_floor(self, req) -> int | None:
|
|
if not self.is_mamba_enabled or self._sliding_window_size is None:
|
|
return None
|
|
checkpoint = req.kv.mamba_last_track_seqlen
|
|
if checkpoint is None:
|
|
return None
|
|
return checkpoint - self._sliding_window_size
|
|
|
|
def supports_swa(self) -> bool:
|
|
return self.is_swa_enabled
|
|
|
|
def supports_mamba(self) -> bool:
|
|
return self.is_mamba_enabled
|
|
|
|
# ---- Session radix cache API (delegates to composed UnifiedSessionRefTracker) ----
|
|
|
|
def open_radix_session(self, session_id: str) -> Optional[int]:
|
|
return self.session_refs.open_radix_session(session_id)
|
|
|
|
def ensure_session_generation(self, session_id: str) -> int:
|
|
return self.session_refs.ensure_session_generation(session_id)
|
|
|
|
def release_radix_session(self, session_id: str) -> int:
|
|
return self.session_refs.release_radix_session(session_id)
|
|
|
|
# ---- Streaming session API (delegates to composed StreamingSession) ----
|
|
|
|
def supports_streaming_session(self) -> bool:
|
|
return True
|
|
|
|
def release_session(self, session_id: str) -> None:
|
|
self.session.release_session(session_id)
|
|
|
|
def session_held_tokens(self, active_pool_idxs: Optional[set] = None) -> int:
|
|
return self.session.session_held_tokens(active_pool_idxs)
|
|
|
|
def session_held_full_tokens(self, active_pool_idxs: Optional[set] = None) -> int:
|
|
return self.session.session_held_full_tokens(active_pool_idxs)
|
|
|
|
def session_held_swa_tokens(self, active_pool_idxs: Optional[set] = None) -> int:
|
|
return self.session.session_held_swa_tokens(active_pool_idxs)
|
|
|
|
def session_held_req_count(self, active_pool_idxs: Optional[set] = None) -> int:
|
|
return self.session.session_held_req_count(active_pool_idxs)
|
|
|
|
def session_held_mamba_slots(self, active_pool_idxs: Optional[set] = None) -> int:
|
|
return self.session.session_held_mamba_slots(active_pool_idxs)
|
|
|
|
def evictable_size(self) -> int:
|
|
return self.tree_core.evictable_size()
|
|
|
|
def protected_size(self) -> int:
|
|
return self.tree_core.protected_size()
|
|
|
|
def full_evictable_size(self) -> int:
|
|
return self.tree_core.full_evictable_size()
|
|
|
|
def full_protected_size(self) -> int:
|
|
return self.tree_core.full_protected_size()
|
|
|
|
def swa_evictable_size(self) -> int:
|
|
return self.tree_core.swa_evictable_size()
|
|
|
|
def mamba_evictable_size(self) -> int:
|
|
return self.tree_core.mamba_evictable_size()
|
|
|
|
def swa_protected_size(self) -> int:
|
|
return self.tree_core.swa_protected_size()
|
|
|
|
def mamba_protected_size(self) -> int:
|
|
return self.tree_core.mamba_protected_size()
|
|
|
|
def total_size(self) -> tuple[int, int]:
|
|
return self.tree_core.total_size()
|
|
|
|
def all_values_flatten(self) -> torch.Tensor:
|
|
return self.tree_core.all_values_flatten()
|
|
|
|
def all_mamba_values_flatten(self) -> torch.Tensor:
|
|
return self.tree_core.all_mamba_values_flatten()
|
|
|
|
def available_and_evictable_str(self) -> str:
|
|
# TODO(zhangmj): need more detailed log info for session reference.
|
|
if self.supports_swa():
|
|
full_available_size = self.token_to_kv_pool_allocator.full_available_size()
|
|
else:
|
|
full_available_size = self.token_to_kv_pool_allocator.available_size()
|
|
full_evictable = self.tree_core.component_evictable_size(BASE_COMPONENT_TYPE)
|
|
lines = [
|
|
f"Available full tokens: {full_available_size + full_evictable} "
|
|
f"(full_available_size={full_available_size} + full_evictable_size_={full_evictable})"
|
|
]
|
|
for ct in self.tree_components:
|
|
if ct == BASE_COMPONENT_TYPE:
|
|
continue
|
|
if ct.is_swa:
|
|
available_size = self.token_to_kv_pool_allocator.swa_available_size()
|
|
elif ct.is_mamba:
|
|
available_size = self.req_to_token_pool.mamba_allocator.available_size()
|
|
else:
|
|
continue
|
|
|
|
lines.append(
|
|
f"Available {ct}: {available_size + self.tree_core.component_evictable_size(ct)} "
|
|
f"(available_size={available_size} + component_evictable_size_={self.tree_core.component_evictable_size(ct)})"
|
|
)
|
|
return "\n".join(lines) + "\n"
|
|
|
|
def sanity_check(self):
|
|
"""Verify tree invariants.
|
|
|
|
TODO(hzh): This method has relatively high latency; simplify the
|
|
check logic once the tree implementation stabilizes.
|
|
"""
|
|
# Skip when streaming sessions hold tree locks: the check asserts
|
|
# all nodes are unlocked during idle, which streaming sessions break
|
|
# by design (they hold a first-turn lock across turns).
|
|
if self.session.any_holding_kv():
|
|
return
|
|
|
|
# Pass ongoing ops as lightweight (id, node_id) pairs so the tree core
|
|
# can resolve + validate them without reaching into Controller state.
|
|
if self.buffer_pipeline is not None:
|
|
ongoing_write_through = [
|
|
(nid, entry.intent.node_id)
|
|
for nid, entry in self.buffer_pipeline.ongoing_write_through.items()
|
|
]
|
|
else:
|
|
ongoing_write_through = [
|
|
(nid, wt.node_id) for nid, wt in self.ongoing_write_through.items()
|
|
]
|
|
ongoing_load_back = [
|
|
(nid, lb.node_id) for nid, lb in self.ongoing_load_back.items()
|
|
]
|
|
self.tree_core.sanity_check(ongoing_write_through, ongoing_load_back)
|
|
|
|
def pretty_print(self) -> None:
|
|
self.tree_core.pretty_print()
|
|
|
|
# ---- TreeCore state delegation ----
|
|
# The facade re-exposes tree-owned config (page_size, enable_storage, ...) so its
|
|
# own coordination methods and external callers read them off the cache.
|
|
|
|
# ``page_size`` keeps a setter: StreamingSession forwards assignment onto its
|
|
# inner cache (the PrefixCacheTrait surface).
|
|
@property
|
|
def page_size(self):
|
|
return self.tree_core.page_size
|
|
|
|
@page_size.setter
|
|
def page_size(self, value) -> None:
|
|
self.tree_core.page_size = value
|
|
|
|
@property
|
|
def enable_storage(self):
|
|
return self.tree_core.enable_storage
|
|
|
|
@enable_storage.setter
|
|
def enable_storage(self, value) -> None:
|
|
self.tree_core.enable_storage = value
|
|
|
|
@property
|
|
def write_through_threshold(self):
|
|
return self.tree_core.write_through_threshold
|
|
|
|
@write_through_threshold.setter
|
|
def write_through_threshold(self, value) -> None:
|
|
self.tree_core.write_through_threshold = value
|
|
|
|
@property
|
|
def is_write_back(self):
|
|
return self.tree_core.is_write_back
|
|
|
|
@is_write_back.setter
|
|
def is_write_back(self, value) -> None:
|
|
self.tree_core.is_write_back = value
|
|
|
|
@property
|
|
def device(self):
|
|
return self.tree_core.device
|
|
|
|
@property
|
|
def root_node(self):
|
|
return self.tree_core.root_node
|
|
|
|
def take_events(self):
|
|
# Drain the KV event queue from the TreeCore.
|
|
return self.tree_core.take_events()
|
|
|
|
def resolve_node_handle(self, node_handle):
|
|
"""Look up the node object from its NodeId.
|
|
|
|
TODO(Jialin): Remove after the Unified Radix Cache split.
|
|
"""
|
|
if isinstance(node_handle, int):
|
|
return self.tree_core.node_by_id(node_handle)
|
|
# Internal callers (and the session sentinel / None) pass a non-int through.
|
|
return node_handle
|
|
|
|
def root_node_handle(self, extra_key: Optional[str] = None) -> NodeId:
|
|
"""The root's NodeId -- URC match results carry NodeIds."""
|
|
return self.tree_core.root_node_handle(extra_key)
|
|
|
|
def dfs_weight_order(self, node_handles: Sequence[NodeId]) -> list[int]:
|
|
return self.tree_core.dfs_weight_order(node_handles)
|