[HiCache] Buffer-only mode for HiCache host memory layer (#34798)
This commit is contained in:
@@ -17,7 +17,7 @@ import logging
|
||||
import threading
|
||||
import time
|
||||
from queue import Empty, Queue
|
||||
from typing import TYPE_CHECKING, List, NamedTuple, Optional
|
||||
from typing import TYPE_CHECKING, Callable, List, NamedTuple, Optional
|
||||
|
||||
import torch
|
||||
|
||||
@@ -203,6 +203,13 @@ class StorageOperation:
|
||||
self.completed_tokens = 0
|
||||
self.hash_value = hash_value if hash_value is not None else []
|
||||
self.prefix_keys = prefix_keys
|
||||
# Full queried page-hash chain, set by _storage_hit_query before
|
||||
# hash_value is truncated to the hit boundary; the tail is the
|
||||
# absence signal that invalidates buffer-mode existence beliefs.
|
||||
self.all_hash_values: Optional[List[str]] = None
|
||||
# Prefetch-outcome accounting, set at enqueue by the tree cache.
|
||||
self.stats_requested_tokens = 0
|
||||
self.stats_total_tokens = 0
|
||||
|
||||
self.id = StorageOperation.counter
|
||||
StorageOperation.counter += 1
|
||||
@@ -211,6 +218,15 @@ class StorageOperation:
|
||||
return self.id < other.id
|
||||
|
||||
|
||||
# Buffer-mode staging budgets. Prefetch staging is latency-critical
|
||||
# (wait_complete gates TTFT), so loads may fill the pool up to this fraction
|
||||
# before new prefetches are declined.
|
||||
HICACHE_LOAD_POOL_USAGE_FRACTION = 0.9
|
||||
# Write-staging floor: writes are deferrable, so the flush gate grows the
|
||||
# write window dynamically into whatever load staging is not using.
|
||||
HICACHE_WRITE_STAGING_POOL_FRACTION = 0.2
|
||||
|
||||
|
||||
class PrefetchOperation(StorageOperation):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -262,8 +278,10 @@ class HiCacheController:
|
||||
model_name: Optional[str] = None,
|
||||
storage_backend_extra_config: Optional[dict] = None,
|
||||
enable_storage_metrics: bool = False,
|
||||
host_memory_mode: str = "cache",
|
||||
):
|
||||
self.tp_group = tp_group
|
||||
self.host_memory_mode = host_memory_mode
|
||||
self.attn_cp_group = attn_cp_group
|
||||
self.attn_tp_group = attn_tp_group
|
||||
self.pp_group = pp_group
|
||||
@@ -283,6 +301,9 @@ class HiCacheController:
|
||||
self.storage_backend = None
|
||||
self.storage_backend_type = None
|
||||
self.enable_storage_metrics = enable_storage_metrics
|
||||
# Buffer mode: wired by the tree cache after attach; the load rate
|
||||
# limiter subtracts write staging from actual pool usage.
|
||||
self.host_write_staged_tokens_fn: Optional[Callable[[], int]] = None
|
||||
|
||||
# Draft KV pool support (best-effort piggyback on target L2/L3 ops).
|
||||
self.has_draft = False
|
||||
@@ -501,8 +522,16 @@ class HiCacheController:
|
||||
self.enable_storage = True
|
||||
# todo: threshold policy for prefetching
|
||||
self.prefetch_threshold = max(prefetch_threshold, self.page_size)
|
||||
# Budget speculative prefetch at half the host pool, leaving the rest for the write-back staging path.
|
||||
self.prefetch_capacity_limit = int(0.5 * self.mem_pool_host.size)
|
||||
if self.host_memory_mode == "buffer_only":
|
||||
# The whole pool is transient staging; loads may fill it up
|
||||
# to this fraction, and the tree's write flush gate yields
|
||||
# to live fetch demand (the write fraction is a floor).
|
||||
self.prefetch_capacity_limit = int(
|
||||
HICACHE_LOAD_POOL_USAGE_FRACTION * self.mem_pool_host.size
|
||||
)
|
||||
else:
|
||||
# Budget speculative prefetch at half the host pool, leaving the rest for the write-back staging path.
|
||||
self.prefetch_capacity_limit = int(0.5 * self.mem_pool_host.size)
|
||||
# tracking the number of tokens locked in prefetching, updated by the main scheduler thread
|
||||
self.prefetch_tokens_occupied = 0
|
||||
|
||||
@@ -1050,6 +1079,16 @@ class HiCacheController:
|
||||
"""
|
||||
Rate limit the prefetching operations to avoid overwhelming the storage backend.
|
||||
"""
|
||||
if self.host_memory_mode == "buffer_only":
|
||||
# Gate on real pool usage: buffer mode allocates hit-sized, so
|
||||
# prefetch_tokens_occupied's requested spans overstate it. Pool
|
||||
# state mutates only at scheduler-thread lockstep points, so this
|
||||
# stays TP-deterministic. Write staging is the write budget's
|
||||
# usage; charging it here would park hits behind its storage drain.
|
||||
used = self.mem_pool_host.size - self.mem_pool_host.available_size()
|
||||
if self.host_write_staged_tokens_fn is not None:
|
||||
used -= self.host_write_staged_tokens_fn()
|
||||
return max(0, used) >= self.prefetch_capacity_limit
|
||||
# cancel prefetch if too much memory is occupied
|
||||
if self.prefetch_tokens_occupied >= self.prefetch_capacity_limit:
|
||||
return True
|
||||
@@ -1066,6 +1105,7 @@ class HiCacheController:
|
||||
page_hashes = self.get_hash_str(
|
||||
tokens_to_fetch, last_hash, page_size=self.page_size
|
||||
)
|
||||
operation.all_hash_values = page_hashes
|
||||
|
||||
for start in range(0, len(page_hashes), STORAGE_BATCH_SIZE):
|
||||
batch_hashes = page_hashes[start : start + STORAGE_BATCH_SIZE]
|
||||
|
||||
@@ -2698,8 +2698,28 @@ class Scheduler(
|
||||
if self.enable_hicache_storage:
|
||||
req.init_next_round_input(self.tree_cache, cow_mamba=False)
|
||||
tree_cache = self.tree_cache
|
||||
if tree_cache.is_backuped(req.last_host_node) or tree_cache.is_root(
|
||||
req.last_host_node
|
||||
buffer_mode = self.server_args.hicache_host_memory_mode == "buffer_only"
|
||||
last_host_node = req.last_host_node
|
||||
# Buffer mode host-backups nothing, so match_prefix anchors at
|
||||
# root; re-anchor on the deepest device node. The anchor is only
|
||||
# read for hash/extra-key context here (never locked), so a device
|
||||
# node serves. Cache mode keeps the is_backuped gate below: its
|
||||
# write-through prefix is contiguous from root, so an unbacked
|
||||
# anchor means a guaranteed storage miss.
|
||||
if (
|
||||
buffer_mode
|
||||
and tree_cache.is_root(last_host_node)
|
||||
and not tree_cache.is_root(req.last_node)
|
||||
):
|
||||
last_host_node = req.last_node
|
||||
|
||||
if (
|
||||
tree_cache.is_backuped(last_host_node)
|
||||
or tree_cache.is_root(last_host_node)
|
||||
or (
|
||||
buffer_mode
|
||||
and tree_cache.get_last_hash_value(last_host_node) is not None
|
||||
)
|
||||
):
|
||||
matched_len = len(req.prefix_indices) + req.host_hit_length
|
||||
match_end = req._compute_max_prefix_len(
|
||||
@@ -2707,16 +2727,17 @@ class Scheduler(
|
||||
)
|
||||
new_input_tokens = req.full_untruncated_fill_ids[matched_len:match_end]
|
||||
prefix_keys = (
|
||||
tree_cache.get_prefix_hash_values(req.last_host_node)
|
||||
tree_cache.get_prefix_hash_values(last_host_node)
|
||||
if tree_cache.hicache_storage_pass_prefix_keys
|
||||
else None
|
||||
)
|
||||
tree_cache.prefetch_from_storage(
|
||||
req.rid,
|
||||
req.last_host_node,
|
||||
last_host_node,
|
||||
new_input_tokens,
|
||||
tree_cache.get_last_hash_value(req.last_host_node),
|
||||
tree_cache.get_last_hash_value(last_host_node),
|
||||
prefix_keys,
|
||||
matched_prefix_tokens=req.full_untruncated_fill_ids[:matched_len],
|
||||
)
|
||||
|
||||
def _add_request_to_queue(self, req: Req, is_retracted: bool = False):
|
||||
@@ -3329,6 +3350,23 @@ class Scheduler(
|
||||
req.storage_hit_length = loaded_tokens
|
||||
|
||||
req.init_next_round_input(self.tree_cache)
|
||||
if (
|
||||
self.enable_hicache_storage
|
||||
and self.server_args.hicache_host_memory_mode == "buffer_only"
|
||||
):
|
||||
# Buffer mode: surface a staged prefetch as the request's host
|
||||
# hit (consumed through init_load_back) plus its SWA window,
|
||||
# which consumption allocates and the request lock pins —
|
||||
# uncharged, the batch alloc can OOM. Set AFTER
|
||||
# init_next_round_input (which recomputes host_hit). Mamba
|
||||
# (fenced in init_hicache) will need the same charge via
|
||||
# mamba_host_hit_length.
|
||||
held_tokens = self.tree_cache.staged_prefetch_tokens(req.rid)
|
||||
if held_tokens > 0:
|
||||
req.host_hit_length = held_tokens
|
||||
req.swa_host_hit_length = (
|
||||
self.tree_cache.staged_prefetch_swa_tokens(req.rid)
|
||||
)
|
||||
res = adder.add_one_req(
|
||||
req,
|
||||
has_chunked_req=(self.chunked_req is not None),
|
||||
@@ -4146,6 +4184,11 @@ class Scheduler(
|
||||
if tc.enable_storage:
|
||||
idle &= len(tc.ongoing_prefetch) == 0
|
||||
idle &= len(tc.ongoing_backup) == 0
|
||||
if self.server_args.hicache_host_memory_mode == "buffer_only":
|
||||
# Queued writes, staged prefetches, and in-flight
|
||||
# storage writes still hold host staging
|
||||
# (buffer-mode unified tree only).
|
||||
idle &= tc.buffer_pipeline.is_idle()
|
||||
|
||||
return idle
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Buffer-only HiCache host memory mode (--hicache-host-memory-mode buffer_only).
|
||||
|
||||
Host RAM is a transient staging buffer between the GPU and the L3 storage
|
||||
backend, never an L2 cache tier: writes stage device KV through op-owned
|
||||
host bounces into storage and free them at the storage ack; reads fetch
|
||||
storage hits into op-owned bounces and publish them into the device tree at
|
||||
prefill admission. ``UnifiedRadixCache`` composes ``BufferModePipeline`` for
|
||||
the two transfer pipelines and dispatches to it at the mode branches.
|
||||
"""
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Refcounted content-addressed page cache over the buffer-mode host pool.
|
||||
|
||||
NOT WIRED YET: staged spans register as ``(pool, page_hash) -> (slots,
|
||||
refcount)`` so prefetches can be served zero-copy from local staging
|
||||
(write-around / promote-on-read retention, zero-ref LRU reclaim); keys are
|
||||
the content-chained page hashes, so entries survive node deletion, splits,
|
||||
and recompute. Counterpart of ``StorageExistenceCache`` (beliefs about
|
||||
STORAGE, dedupes writes); this tracks LOCAL HOST RAM and dedupes loads.
|
||||
|
||||
TP determinism: replicas must stay identical across attention ranks — the
|
||||
cache feeds scheduler-visible structure, so divergence is a collective
|
||||
hang, not a soft miss. Preconditions when wiring: (1) mutate only on the
|
||||
scheduler thread at lockstep points; (2) rank-reduce any fold anchored by a
|
||||
per-rank storage outcome (hit count, revoke) before it picks a mutation;
|
||||
(3) controller queues stay FIFO and single-threaded so MIN-count drains
|
||||
process the same prefix on every rank.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import Callable, Optional, Sequence
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.mem_cache.hicache_storage import (
|
||||
PoolHitPolicy,
|
||||
PoolName,
|
||||
PoolTransfer,
|
||||
)
|
||||
|
||||
|
||||
class _PageRef:
|
||||
"""One cached page: host slot span, reader refcount, and whether to
|
||||
retain at refs==0 (write-around: write staging frees at its storage ack
|
||||
unless a read promoted it). ``first_slot`` mirrors ``slots[0]`` as a
|
||||
plain int because per-page tensor-scalar reads are too slow in
|
||||
``release``."""
|
||||
|
||||
__slots__ = ("slots", "first_slot", "refs", "retain")
|
||||
|
||||
def __init__(
|
||||
self, slots: torch.Tensor, first_slot: int, refs: int = 1, retain: bool = True
|
||||
):
|
||||
self.slots = slots
|
||||
self.first_slot = first_slot
|
||||
self.refs = refs
|
||||
self.retain = retain
|
||||
|
||||
|
||||
class BufferPageCache:
|
||||
def __init__(self) -> None:
|
||||
# (pool, page_hash) -> _PageRef; slots stay allocated in the host
|
||||
# pool for as long as the entry exists.
|
||||
self._entries: dict[tuple[str, str], _PageRef] = {}
|
||||
# Per-pool zero-ref LRU (head = coldest): reclaim victims.
|
||||
self._zero_ref: dict[str, OrderedDict[str, None]] = {}
|
||||
# Per-pool slot tokens held by the cache (refed + zero-ref).
|
||||
self._held_tokens: dict[str, int] = {}
|
||||
# Per-pool slot tokens at refs=0 (reclaimable under pressure).
|
||||
self._zero_ref_tokens: dict[str, int] = {}
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
def num_zero_ref_pages(self) -> int:
|
||||
return sum(len(lru) for lru in self._zero_ref.values())
|
||||
|
||||
def held_tokens(self, pool: str) -> int:
|
||||
return self._held_tokens.get(pool, 0)
|
||||
|
||||
def zero_ref_tokens(self, pool: str) -> int:
|
||||
"""Slot tokens reclaimable right now (zero-ref cached pages).
|
||||
Occupancy/rate-limit gates must treat these as free-able, not used:
|
||||
a pool full of zero-ref cache is one reclaim away from empty."""
|
||||
return self._zero_ref_tokens.get(pool, 0)
|
||||
|
||||
def register(
|
||||
self,
|
||||
pool: str,
|
||||
hashes: Sequence[str],
|
||||
host_indices: torch.Tensor,
|
||||
page_size: int,
|
||||
retain: bool = True,
|
||||
) -> int:
|
||||
"""Cache a staged span, one entry per page, refs=1 (the staging op);
|
||||
returns the number of pages newly cached. ``retain=False`` =
|
||||
write-around (freed at last ref unless a read hit promotes it); a
|
||||
duplicate hash keeps the existing entry and the newcomer's slots
|
||||
stay op-owned for a raw free at release."""
|
||||
assert len(host_indices) == len(hashes) * page_size
|
||||
registered = 0
|
||||
entries = self._entries
|
||||
# One batched read of the page-boundary slot ids (see _PageRef).
|
||||
first_slots = host_indices[::page_size].tolist()
|
||||
for i, page_hash in enumerate(hashes):
|
||||
key = (pool, page_hash)
|
||||
existing = entries.get(key)
|
||||
if existing is not None:
|
||||
existing.retain = existing.retain or retain
|
||||
continue
|
||||
entries[key] = _PageRef(
|
||||
host_indices[i * page_size : (i + 1) * page_size],
|
||||
first_slots[i],
|
||||
retain=retain,
|
||||
)
|
||||
registered += 1
|
||||
if registered:
|
||||
self._held_tokens[pool] = (
|
||||
self._held_tokens.get(pool, 0) + registered * page_size
|
||||
)
|
||||
return registered
|
||||
|
||||
def contains(self, pool: str, page_hash: str) -> bool:
|
||||
"""Non-mutating presence probe (no LRU touch)."""
|
||||
return (pool, page_hash) in self._entries
|
||||
|
||||
def peek_run_len(self, pool: str, hashes: Sequence[str]) -> int:
|
||||
"""Length of the leading run of cached pages. Non-mutating."""
|
||||
entries = self._entries
|
||||
run = 0
|
||||
for page_hash in hashes:
|
||||
if (pool, page_hash) not in entries:
|
||||
break
|
||||
run += 1
|
||||
return run
|
||||
|
||||
def acquire(self, pool: str, hashes: Sequence[str]) -> Optional[torch.Tensor]:
|
||||
"""refs++ on every page and return the gathered slot tensor (pages
|
||||
expanded to token slots, in page order). All-or-nothing: returns
|
||||
None without mutating if any page is missing."""
|
||||
entries = self._entries
|
||||
refs = []
|
||||
for page_hash in hashes:
|
||||
entry = entries.get((pool, page_hash))
|
||||
if entry is None:
|
||||
return None
|
||||
refs.append(entry)
|
||||
zero_ref = self._zero_ref.get(pool)
|
||||
for page_hash, entry in zip(hashes, refs):
|
||||
if entry.refs == 0 and zero_ref is not None:
|
||||
if zero_ref.pop(page_hash, None) is not None:
|
||||
self._zero_ref_tokens[pool] -= len(entry.slots)
|
||||
entry.refs += 1
|
||||
# Read demand proven: promote write-around pages to retained.
|
||||
entry.retain = True
|
||||
return torch.cat([entry.slots for entry in refs])
|
||||
|
||||
def release(
|
||||
self,
|
||||
pool: str,
|
||||
hashes: Sequence[str],
|
||||
host_indices: torch.Tensor,
|
||||
page_size: int,
|
||||
) -> Optional[torch.Tensor]:
|
||||
"""Drop one ref per page of a span: at refs==0 retained pages move
|
||||
to the zero-ref LRU tail while write-around pages return their
|
||||
slots for an immediate free. Duplicate-staging slots (canonical
|
||||
entry lives elsewhere) are returned for a raw free without touching
|
||||
the canonical refcount."""
|
||||
assert len(host_indices) == len(hashes) * page_size
|
||||
leftover: list[torch.Tensor] = []
|
||||
entries = self._entries
|
||||
# One batched read of the page-boundary slot ids (see _PageRef).
|
||||
first_slots = host_indices[::page_size].tolist()
|
||||
for i, page_hash in enumerate(hashes):
|
||||
entry = entries.get((pool, page_hash))
|
||||
if entry is None or entry.first_slot != first_slots[i]:
|
||||
leftover.append(host_indices[i * page_size : (i + 1) * page_size])
|
||||
continue
|
||||
assert entry.refs > 0, "release without a matching acquire/register"
|
||||
entry.refs -= 1
|
||||
if entry.refs == 0:
|
||||
if entry.retain:
|
||||
self._zero_ref.setdefault(pool, OrderedDict())[page_hash] = None
|
||||
self._zero_ref_tokens[pool] = self._zero_ref_tokens.get(
|
||||
pool, 0
|
||||
) + len(entry.slots)
|
||||
else:
|
||||
del entries[(pool, page_hash)]
|
||||
self._held_tokens[pool] -= len(entry.slots)
|
||||
leftover.append(entry.slots)
|
||||
if not leftover:
|
||||
return None
|
||||
return torch.cat(leftover)
|
||||
|
||||
def reclaim(
|
||||
self,
|
||||
pool: str,
|
||||
need_tokens: int,
|
||||
free: Callable[[torch.Tensor], int],
|
||||
) -> int:
|
||||
"""Pop zero-ref LRU heads, free their slots back to the host pool,
|
||||
and drop the entries. Called under allocation pressure only. Returns
|
||||
the number of slot tokens freed (may undershoot when everything
|
||||
left is refed)."""
|
||||
zero_ref = self._zero_ref.get(pool)
|
||||
if not zero_ref or need_tokens <= 0:
|
||||
return 0
|
||||
freed = 0
|
||||
batch: list[torch.Tensor] = []
|
||||
while zero_ref and freed < need_tokens:
|
||||
page_hash, _ = zero_ref.popitem(last=False)
|
||||
entry = self._entries.pop((pool, page_hash))
|
||||
batch.append(entry.slots)
|
||||
freed += len(entry.slots)
|
||||
if batch:
|
||||
free(torch.cat(batch))
|
||||
self._held_tokens[pool] -= freed
|
||||
self._zero_ref_tokens[pool] -= freed
|
||||
return freed
|
||||
|
||||
|
||||
class BufferPageCacheOps:
|
||||
"""Pool-facing operations over a :class:`BufferPageCache`: span/hold
|
||||
registration and release keyed the way the storage write keys them,
|
||||
pressure reclaim, and the SWA-folded continuation fold. The caller owns
|
||||
the collectives — rank-reduce any fold anchored by a per-rank storage
|
||||
outcome before acting on it (see the module docstring)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
page_cache: BufferPageCache,
|
||||
mem_pool_host,
|
||||
sw_window_pages_fn: Callable[[], int],
|
||||
):
|
||||
# Rebound by the owner when the structure is recreated (reset).
|
||||
self.page_cache = page_cache
|
||||
self._mem_pool_host = mem_pool_host
|
||||
# SWA window in KV pages when SWA stages through a host pool
|
||||
# (0 = KV-only: no trailing window in the fold).
|
||||
self._sw_window_pages_fn = sw_window_pages_fn
|
||||
|
||||
def aux_window_keys(
|
||||
self, hash_values: list[str], transfer: PoolTransfer
|
||||
) -> Optional[list[str]]:
|
||||
"""Trailing KV page hashes keying an aux transfer's staged window
|
||||
(one key per aux-pool page), recomputed from the rank-synced span
|
||||
hashes so registration and release always agree across ranks."""
|
||||
if transfer.host_indices is None or transfer.host_indices.numel() == 0:
|
||||
return None
|
||||
if transfer.indices_from_pool is not None:
|
||||
return None # sidecar rides another pool's slots; nothing to key
|
||||
entry = self._mem_pool_host.entry_map.get(transfer.name)
|
||||
if entry is None:
|
||||
return None
|
||||
pool_page_size = entry.host_pool.page_size
|
||||
num_keys = len(transfer.host_indices) // pool_page_size
|
||||
if num_keys == 0 or num_keys > len(hash_values):
|
||||
return None
|
||||
return hash_values[-num_keys:]
|
||||
|
||||
def register_span(
|
||||
self,
|
||||
pool: PoolName,
|
||||
hashes: list[str],
|
||||
host_indices: torch.Tensor,
|
||||
retain: bool = True,
|
||||
) -> None:
|
||||
"""Cache a page-aligned staged span (refs=1 for the staging op)."""
|
||||
if not hashes:
|
||||
return
|
||||
entry = self._mem_pool_host.entry_map.get(pool)
|
||||
if entry is None:
|
||||
return
|
||||
self.page_cache.register(
|
||||
pool, hashes, host_indices, entry.host_pool.page_size, retain=retain
|
||||
)
|
||||
|
||||
def release_span(
|
||||
self,
|
||||
pool: PoolName,
|
||||
hashes: list[str],
|
||||
host_indices: torch.Tensor,
|
||||
) -> None:
|
||||
"""Drop the staging op's ref on a span; zero-ref pages stay cached
|
||||
(servable) until pressure reclaims them. Op-owned duplicate slots
|
||||
(their hash was cached elsewhere) are freed raw, as before."""
|
||||
if host_indices is None or host_indices.numel() == 0:
|
||||
return
|
||||
entry = self._mem_pool_host.entry_map.get(pool)
|
||||
if entry is None:
|
||||
return
|
||||
if not hashes:
|
||||
entry.host_pool.free(host_indices)
|
||||
return
|
||||
leftover = self.page_cache.release(
|
||||
pool, hashes, host_indices, entry.host_pool.page_size
|
||||
)
|
||||
if leftover is not None and leftover.numel() > 0:
|
||||
entry.host_pool.free(leftover)
|
||||
|
||||
def register_hold(
|
||||
self,
|
||||
hash_values: list[str],
|
||||
host_indices: torch.Tensor,
|
||||
aux_xfers: list[PoolTransfer],
|
||||
retain: bool = True,
|
||||
) -> None:
|
||||
"""Register a staged KV span plus its aux windows (SWA/Mamba states
|
||||
keyed by their trailing KV page hashes, same keying the storage
|
||||
write uses). ``retain=False`` = write-around: servable only while
|
||||
the staging op pins the slots, freed at the last release unless a
|
||||
read hit promotes it."""
|
||||
self.register_span(PoolName.KV, hash_values, host_indices, retain=retain)
|
||||
for transfer in aux_xfers:
|
||||
keys = self.aux_window_keys(hash_values, transfer)
|
||||
if keys is not None:
|
||||
self.register_span(
|
||||
transfer.name, keys, transfer.host_indices, retain=retain
|
||||
)
|
||||
|
||||
def release_hold(
|
||||
self,
|
||||
hash_values: list[str],
|
||||
host_indices: torch.Tensor,
|
||||
aux_xfers: list[PoolTransfer],
|
||||
) -> None:
|
||||
"""Mirror of register_hold for every hold retirement path
|
||||
(storage-ack, fill H2D-ack, staged drop, abort)."""
|
||||
self.release_span(PoolName.KV, hash_values, host_indices)
|
||||
for transfer in aux_xfers:
|
||||
if transfer.indices_from_pool is not None:
|
||||
continue
|
||||
keys = self.aux_window_keys(hash_values, transfer)
|
||||
self.release_span(transfer.name, keys or [], transfer.host_indices)
|
||||
|
||||
def reclaim(self, pool: PoolName, num_tokens: int) -> int:
|
||||
"""Free just enough zero-ref cached pages for an allocation of
|
||||
num_tokens to succeed. Scheduler-thread only (lockstep pressure
|
||||
points: staging-hit alloc, prepare_prefetch, cc.write)."""
|
||||
entry = self._mem_pool_host.entry_map.get(pool)
|
||||
if entry is None:
|
||||
return 0
|
||||
shortfall = num_tokens - entry.host_pool.available_size()
|
||||
if shortfall <= 0:
|
||||
return 0
|
||||
return self.page_cache.reclaim(pool, shortfall, entry.host_pool.free)
|
||||
|
||||
def continuation_run(self, chain: list[str], start_pages: int) -> int:
|
||||
"""Longest cached run continuing the span at page ``start_pages``
|
||||
(0 = leading run), folded for SWA: the joint span's trailing window
|
||||
must be fully cache-servable, mirroring batch_exists_v2's
|
||||
trailing_pages fold. Non-mutating and rank-deterministic."""
|
||||
page_cache = self.page_cache
|
||||
kv_run = page_cache.peek_run_len(PoolName.KV, chain[start_pages:])
|
||||
if kv_run == 0:
|
||||
return 0
|
||||
sw_pages = self._sw_window_pages_fn()
|
||||
if sw_pages == 0:
|
||||
return kv_run
|
||||
for cont in range(kv_run, 0, -1):
|
||||
joint = start_pages + cont
|
||||
window = min(sw_pages, joint)
|
||||
if cont < window:
|
||||
# Window straddles into the head; only possible for
|
||||
# anchored runs, and shrinking cont cannot fix it.
|
||||
break
|
||||
if all(
|
||||
page_cache.contains(PoolName.SWA, chain[i])
|
||||
for i in range(joint - window, joint)
|
||||
):
|
||||
return cont
|
||||
return 0
|
||||
|
||||
def acquire_span(
|
||||
self, chain: list[str], start_pages: int, cont_pages: int
|
||||
) -> Optional[tuple[torch.Tensor, list[PoolTransfer]]]:
|
||||
"""Acquire a folded continuation run: its KV pages plus the JOINT
|
||||
span's trailing SWA window (refs++ on every page). Returns
|
||||
(kv_slots, aux_xfers) or None (with no refs held) if a page
|
||||
vanished since the fold — defensive; fold and acquire run in the
|
||||
same lockstep step."""
|
||||
page_cache = self.page_cache
|
||||
cont_hashes = list(chain[start_pages : start_pages + cont_pages])
|
||||
kv_slots = page_cache.acquire(PoolName.KV, cont_hashes)
|
||||
if kv_slots is None:
|
||||
return None
|
||||
aux_xfers: list[PoolTransfer] = []
|
||||
sw_pages = self._sw_window_pages_fn()
|
||||
if sw_pages > 0:
|
||||
joint = start_pages + cont_pages
|
||||
window_hashes = list(chain[joint - min(sw_pages, joint) : joint])
|
||||
swa_slots = page_cache.acquire(PoolName.SWA, window_hashes)
|
||||
if swa_slots is None:
|
||||
self.release_span(PoolName.KV, cont_hashes, kv_slots)
|
||||
return None
|
||||
aux_xfers.append(
|
||||
PoolTransfer(
|
||||
name=PoolName.SWA,
|
||||
host_indices=swa_slots,
|
||||
keys=window_hashes,
|
||||
hit_policy=PoolHitPolicy.TRAILING_PAGES,
|
||||
)
|
||||
)
|
||||
return kv_slots, aux_xfers
|
||||
@@ -0,0 +1,816 @@
|
||||
"""Buffer-only mode transfer pipelines for the unified radix cache.
|
||||
|
||||
``BufferModePipeline`` owns all buffer-mode state and the two pipelines that
|
||||
move KV through the transient host staging buffer:
|
||||
|
||||
- backup (write path): admission-gated FIFO intents, head-of-line D2H
|
||||
staging launches, storage writes at the D2H ack, staging freed at the
|
||||
storage ack;
|
||||
- load back (read path): completed storage fetches parked as op-owned host
|
||||
bounces, consumed at prefill admission via a device alloc + layer-gated
|
||||
H2D + plain tree insert, bounce freed at the H2D ack.
|
||||
|
||||
The pipeline is an intimate collaborator of ``UnifiedRadixCache``: it is
|
||||
constructed by ``init_hicache`` only when ``--hicache-host-memory-mode
|
||||
buffer_only`` is active, and it drives tree/controller operations (insert,
|
||||
match, evict, lock refs, cache actions) through the owning cache. All
|
||||
buffer-mode-only state lives here; the cache dispatches to this object at
|
||||
its mode branches.
|
||||
|
||||
TP-lockstep contract: every mutation runs on the scheduler thread at
|
||||
rank-synchronized points (insert walks, rank-MIN-reduced drains, ack
|
||||
drains), so per-rank state never diverges. There is no runtime
|
||||
verification; a violation surfaces as an unexplained collective hang.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from array import array
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import msgspec
|
||||
import torch
|
||||
|
||||
from sglang.srt.managers.cache_controller import HICACHE_WRITE_STAGING_POOL_FRACTION
|
||||
from sglang.srt.mem_cache.base_prefix_cache import (
|
||||
DecLockRefParams,
|
||||
EvictParams,
|
||||
InitLoadBackParams,
|
||||
InsertParams,
|
||||
MatchPrefixParams,
|
||||
)
|
||||
from sglang.srt.mem_cache.hicache_storage import PoolHitPolicy, PoolName, PoolTransfer
|
||||
from sglang.srt.mem_cache.radix_cache import RadixKey
|
||||
from sglang.srt.mem_cache.unified_cache.cache_action import RebuildFullToSWAMapping
|
||||
from sglang.srt.mem_cache.unified_cache.components import (
|
||||
BASE_COMPONENT_TYPE,
|
||||
ComponentType,
|
||||
)
|
||||
from sglang.srt.mem_cache.unified_cache.unified_tree_core import (
|
||||
NodeId,
|
||||
UnifiedTreeNode,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.mem_cache.unified_cache.components import SWAComponent
|
||||
from sglang.srt.mem_cache.unified_radix_cache import UnifiedRadixCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _UnifiedBackupIntent(msgspec.Struct):
|
||||
"""Buffer-mode backup intent, unpinned while queued.
|
||||
|
||||
Snapshots node identity at enqueue time: a split rewrites the node's
|
||||
key/hash in place while these copies stay intact, so
|
||||
``node.hash_value != hash_values`` doubles as split detection and a None
|
||||
FULL device value as eviction detection (``_backup_intent_stale``).
|
||||
"""
|
||||
|
||||
node: UnifiedTreeNode
|
||||
node_id: int
|
||||
hash_values: list[str]
|
||||
key: RadixKey
|
||||
prefix_keys: Optional[list[str]] = None
|
||||
|
||||
|
||||
class _UnifiedBufferBackupEntry(msgspec.Struct):
|
||||
"""A buffer-mode backup after its D2H launch: intent + staging slots.
|
||||
|
||||
``host_indices`` are FULL-pool staging slots; ``aux_xfers`` carry the
|
||||
staged aux-pool slots (e.g. the SWA window). All are freed at the
|
||||
storage-write ack — host memory is never retained as a cache tier.
|
||||
"""
|
||||
|
||||
intent: _UnifiedBackupIntent
|
||||
host_indices: torch.Tensor
|
||||
aux_xfers: list[PoolTransfer]
|
||||
lock_params: DecLockRefParams
|
||||
|
||||
|
||||
class _StagedPrefetch(msgspec.Struct):
|
||||
"""A completed buffer-mode fetch parked until prefill admission: only
|
||||
the op-owned host bounce exists (no device state, nothing in the tree).
|
||||
"""
|
||||
|
||||
req_id: str
|
||||
key_tokens: list[int]
|
||||
extra_key: Optional[str]
|
||||
matched_len: int
|
||||
num_tokens: int
|
||||
occupied_tokens: int
|
||||
host_indices: torch.Tensor
|
||||
aux_xfers: list[PoolTransfer]
|
||||
hash_values: list[str]
|
||||
operation_id: int
|
||||
|
||||
|
||||
class _OngoingBufferLoadBack(msgspec.Struct):
|
||||
"""A buffer-mode load-back awaiting its H2D ack: the span is already
|
||||
tree-resident; only the host bounce remains to free.
|
||||
"""
|
||||
|
||||
req_id: str
|
||||
num_tokens: int
|
||||
occupied_tokens: int
|
||||
aux_xfers: list[PoolTransfer]
|
||||
host_indices: torch.Tensor
|
||||
hash_values: list[str]
|
||||
|
||||
|
||||
def _track_content_refs(refs: dict[str, int], hash_values: list[str]) -> None:
|
||||
"""Add one content ref per page hash (at D2H launch). Refcounted,
|
||||
not a flag: several launched entries can carry the same content
|
||||
(duplicate staging of republished spans)."""
|
||||
for h in hash_values:
|
||||
refs[h] = refs.get(h, 0) + 1
|
||||
|
||||
|
||||
def _untrack_content_refs(refs: dict[str, int], hash_values: list[str]) -> None:
|
||||
"""Drop one content ref per page hash (at storage-ack)."""
|
||||
for h in hash_values:
|
||||
n = refs.get(h, 0) - 1
|
||||
if n <= 0:
|
||||
refs.pop(h, None)
|
||||
else:
|
||||
refs[h] = n
|
||||
|
||||
|
||||
def validate_buffer_only_stack(
|
||||
sidecar_pool_specs: list, swa_component: Optional[SWAComponent]
|
||||
) -> None:
|
||||
"""Post-assembly buffer-mode fences.
|
||||
|
||||
Sidecar pools (DSv4 compressed regions) and unified_kv SWA (device-only
|
||||
ring, never offloaded) have no per-pool staging path yet.
|
||||
"""
|
||||
if sidecar_pool_specs:
|
||||
raise ValueError(
|
||||
"--hicache-host-memory-mode buffer_only does not support "
|
||||
"sidecar storage pools (DeepSeek-V4 compressed regions)."
|
||||
)
|
||||
swa = swa_component
|
||||
if swa is not None and swa._swa_kv_pool_host is None:
|
||||
# Only reachable on SWA models with the unified_kv layout (SWA as
|
||||
# a device-only ring): without a host pool the window can neither
|
||||
# stage for writes nor fetch for load-backs.
|
||||
raise ValueError(
|
||||
"--hicache-host-memory-mode buffer_only on SWA models "
|
||||
"requires an SWA host staging pool; the unified_kv layout "
|
||||
"keeps SWA as a device-only ring."
|
||||
)
|
||||
if swa is not None and swa._swa_kv_pool_host is not None:
|
||||
# Below two windows the pool cannot hold a staging write AND the
|
||||
# loads-priority reserve (_aux_loads_margin floors at one
|
||||
# window), so every window-carrying intent would be dropped as
|
||||
# oversize and SWA storage coverage would silently be zero.
|
||||
window_tokens = swa.full_window_pages * swa._swa_kv_pool_host.page_size
|
||||
if swa._swa_kv_pool_host.size < 2 * window_tokens:
|
||||
raise ValueError(
|
||||
"--hicache-host-memory-mode buffer_only requires an SWA "
|
||||
f"host pool of at least two trailing windows "
|
||||
f"({2 * window_tokens} tokens; got "
|
||||
f"{swa._swa_kv_pool_host.size}): one staging a write "
|
||||
"while one stays reserved for prefetch window allocs."
|
||||
)
|
||||
|
||||
|
||||
class BufferModePipeline:
|
||||
"""All buffer-mode state plus the backup and load-back pipelines.
|
||||
|
||||
Constructed by ``UnifiedRadixCache.init_hicache`` when host memory mode
|
||||
is ``buffer_only``; ``cache.buffer_pipeline is None`` elsewhere, which
|
||||
the cache's mode branches use as the dispatch test.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache: UnifiedRadixCache,
|
||||
swa_window_pages: int,
|
||||
write_backlog_cap: int,
|
||||
):
|
||||
self._cache = cache
|
||||
# SWA window size in KV pages when the SWA component stages through
|
||||
# a host pool (0 = KV-only: no trailing window staged). Static after
|
||||
# pool assembly.
|
||||
self._swa_window_pages = swa_window_pages
|
||||
# Metadata-only pending-write backlog cap; beyond it new intents
|
||||
# are dropped at admission (re-trigger on a later hit).
|
||||
self.write_backlog_cap = write_backlog_cap
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
# Load pipeline: hits awaiting a staging grant (park-and-retry),
|
||||
# enqueue-time prefix context, completed prefetches staged until
|
||||
# prefill admission, and load-backs in flight (keyed by synthetic
|
||||
# negative ack id).
|
||||
self.pending_hit_allocs: deque = deque()
|
||||
self._prefetch_prefix_ctx: dict[str, list[int]] = {}
|
||||
self.staged_prefetches: dict[str, _StagedPrefetch] = {}
|
||||
self.ongoing_buffer_load_back: dict[int, _OngoingBufferLoadBack] = {}
|
||||
# Backup pipeline: FIFO intents awaiting a D2H slot, node ids
|
||||
# anywhere in flight (dedupes re-triggers), and a content refcount
|
||||
# of every page hash between D2H launch and storage-ack — admission
|
||||
# skips content covered by beliefs + launched writes.
|
||||
self.pending_write_queue: deque[_UnifiedBackupIntent] = deque()
|
||||
self.inflight_backup_node_ids: set[int] = set()
|
||||
self.inflight_backup_hashes: dict[str, int] = {}
|
||||
# Backups between D2H launch and D2H ack (keyed by node id), then
|
||||
# between storage-write launch and storage ack (keyed by operation
|
||||
# id). Mirrors the cache-mode ongoing_write_through/ongoing_backup
|
||||
# stages, with buffer entries.
|
||||
self.ongoing_write_through: dict[int, _UnifiedBufferBackupEntry] = {}
|
||||
self.ongoing_backup: dict[int, _UnifiedBufferBackupEntry] = {}
|
||||
self.write_staged_tokens_ = 0
|
||||
self.write_backlog_tokens_ = 0
|
||||
self._backlog_cap_hits = 0
|
||||
|
||||
def is_idle(self) -> bool:
|
||||
"""No queued writes, staged prefetches, or storage writes in flight
|
||||
(all of which hold host staging or would re-trigger IO)."""
|
||||
return not (
|
||||
self.pending_write_queue or self.staged_prefetches or self.ongoing_backup
|
||||
)
|
||||
|
||||
# ---- backup pipeline (device -> staging -> storage) ----
|
||||
|
||||
def _backup_parent_covered(self, node: UnifiedTreeNode) -> bool:
|
||||
"""Only admit a node whose parent is stored/in-flight: writing above
|
||||
a dropped parent creates a permanent longest-prefix hole."""
|
||||
parent = node.parent
|
||||
if (
|
||||
parent is self._cache.root_node
|
||||
or parent.id in self.inflight_backup_node_ids
|
||||
):
|
||||
return True
|
||||
last_hash = parent.get_last_hash_value()
|
||||
return last_hash is not None and self._cache.storage_existence_cache.contains(
|
||||
PoolName.KV, last_hash
|
||||
)
|
||||
|
||||
def _log_backup_dropped(self, num_tokens: int) -> None:
|
||||
cache = self._cache
|
||||
if cache.enable_storage_metrics and cache.storage_metrics_collector is not None:
|
||||
cache.storage_metrics_collector.log_backup_dropped_tokens(num_tokens)
|
||||
|
||||
def enqueue_backup_intent(self, node: UnifiedTreeNode) -> None:
|
||||
"""Snapshot a backup intent and commit it to the write queue.
|
||||
Admission gates: belief skip, parent-cover, backlog cap, oversize.
|
||||
Drops are silent; the node re-triggers on a later hit."""
|
||||
if not self._cache.enable_storage or not node.hash_value:
|
||||
return
|
||||
if node.id in self.inflight_backup_node_ids:
|
||||
return
|
||||
# Admission cover: beliefs plus content past its D2H launch. The
|
||||
# launched cover keeps republished content (fill inserts under new
|
||||
# node ids) from re-writing while the original write drains.
|
||||
if self._cache.storage_existence_cache.covers_all(
|
||||
PoolName.KV, node.hash_value, extra_cover=self.inflight_backup_hashes
|
||||
):
|
||||
return
|
||||
intent_tokens = len(node.hash_value) * self._cache.page_size
|
||||
if self.write_backlog_tokens_ >= self.write_backlog_cap:
|
||||
# The cap sits at 2x the intrinsic live-backlog ceiling (see
|
||||
# init_hicache), so reaching it means leaked accounting or a
|
||||
# broken stale sweep — a bug, not load.
|
||||
self._backlog_cap_hits += 1
|
||||
if self._backlog_cap_hits <= 3 or self._backlog_cap_hits % 1000 == 0:
|
||||
logger.error(
|
||||
"HiCache write backlog cap hit (occurrence %d): "
|
||||
"backlog=%d cap=%d queue=%d. Live backlog is bounded "
|
||||
"by the device pool span, so this indicates a "
|
||||
"stale-sweep or accounting leak.",
|
||||
self._backlog_cap_hits,
|
||||
self.write_backlog_tokens_,
|
||||
self.write_backlog_cap,
|
||||
len(self.pending_write_queue),
|
||||
)
|
||||
self._log_backup_dropped(intent_tokens)
|
||||
return
|
||||
# A span larger than any pool's whole staging capacity can never
|
||||
# stage; admitting it would wedge the head-of-line queue forever.
|
||||
if not self._backup_parent_covered(node) or self._backup_oversize(
|
||||
node, intent_tokens
|
||||
):
|
||||
self._log_backup_dropped(intent_tokens)
|
||||
return
|
||||
|
||||
prefix_keys = (
|
||||
node.get_prefix_hash_values(node.parent)
|
||||
if self._cache.hicache_storage_pass_prefix_keys
|
||||
else None
|
||||
)
|
||||
intent = _UnifiedBackupIntent(
|
||||
node=node,
|
||||
node_id=node.id,
|
||||
hash_values=list(node.hash_value),
|
||||
key=node.key,
|
||||
prefix_keys=prefix_keys,
|
||||
)
|
||||
self.pending_write_queue.append(intent)
|
||||
self.inflight_backup_node_ids.add(node.id)
|
||||
self.write_backlog_tokens_ += intent_tokens
|
||||
|
||||
def _build_aux_staging_transfers(
|
||||
self, node: UnifiedTreeNode
|
||||
) -> Optional[list[PoolTransfer]]:
|
||||
"""Keys-only aux transfers mirroring what BACKUP_STORAGE would write;
|
||||
sizes the per-pool oversize gate (beliefs do not consult these)."""
|
||||
transfers: list[PoolTransfer] = []
|
||||
if ComponentType.SWA in self._cache.components:
|
||||
cd = node.component_data[ComponentType.SWA]
|
||||
if cd.value is not None:
|
||||
num_pages = len(cd.value) // self._cache.page_size
|
||||
if num_pages > 0:
|
||||
transfers.append(
|
||||
PoolTransfer(
|
||||
name=PoolName.SWA,
|
||||
keys=node.hash_value[-num_pages:],
|
||||
hit_policy=PoolHitPolicy.TRAILING_PAGES,
|
||||
)
|
||||
)
|
||||
return transfers or None
|
||||
|
||||
def _backup_oversize(
|
||||
self,
|
||||
node: UnifiedTreeNode,
|
||||
intent_tokens: int,
|
||||
aux_xfers: Optional[list[PoolTransfer]] = None,
|
||||
) -> bool:
|
||||
"""True if any pool's staging need exceeds that pool's write-usable
|
||||
capacity (total for KV, total minus the loads-priority margin for aux
|
||||
pools — matching ``_aux_budget_blocked``'s admission ceiling): such an
|
||||
intent could never stage and would wedge the FIFO head."""
|
||||
cc = self._cache.cache_controller
|
||||
if intent_tokens > cc.mem_pool_host.size:
|
||||
return True
|
||||
if aux_xfers is None:
|
||||
aux_xfers = self._build_aux_staging_transfers(node)
|
||||
for t in aux_xfers or ():
|
||||
entry = cc.mem_pool_host.entry_map.get(t.name)
|
||||
if entry is not None and (
|
||||
len(t.keys) * entry.host_pool.page_size
|
||||
> entry.host_pool.size - self._aux_loads_margin(entry.host_pool)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _aux_loads_margin(self, host_pool) -> int:
|
||||
"""Aux-pool tokens reserved for loads: at least one trailing window
|
||||
(prepare_prefetch allocates its window here and a failed alloc
|
||||
forfeits the whole prefetch), plus a 10% burst absorber mirroring
|
||||
live_cap."""
|
||||
return max(
|
||||
self._swa_window_pages * host_pool.page_size,
|
||||
host_pool.size // 10,
|
||||
)
|
||||
|
||||
def _backup_intent_stale(self, intent: _UnifiedBackupIntent) -> bool:
|
||||
# Arena-lookup failure = deleted, hash mismatch vs the enqueue-time
|
||||
# snapshot = split, a None FULL device value = evicted. Stale
|
||||
# intents drop silently; the node re-triggers on a later hit.
|
||||
node = intent.node
|
||||
try:
|
||||
self._cache.tree_core.node_by_id(intent.node_id)
|
||||
except KeyError:
|
||||
return True
|
||||
return (
|
||||
node.component_data[BASE_COMPONENT_TYPE].value is None
|
||||
or node.hash_value != intent.hash_values
|
||||
)
|
||||
|
||||
def _sweep_stale_backup_intents(self) -> None:
|
||||
"""Cancel stale intents anywhere in the queue, not just at the head:
|
||||
a dead intent would otherwise inflate the backlog accounting and
|
||||
hold FIFO position ahead of live segments."""
|
||||
if not self.pending_write_queue:
|
||||
return
|
||||
page_size = self._cache.page_size
|
||||
survivors: deque[_UnifiedBackupIntent] = deque()
|
||||
for intent in self.pending_write_queue:
|
||||
if self._backup_intent_stale(intent):
|
||||
self.inflight_backup_node_ids.discard(intent.node_id)
|
||||
self.write_backlog_tokens_ -= len(intent.hash_values) * page_size
|
||||
continue
|
||||
survivors.append(intent)
|
||||
self.pending_write_queue = survivors
|
||||
|
||||
def flush_pending_writes(self) -> None:
|
||||
"""Launch D2H transfers for admitted intents, head-of-line: device
|
||||
locks and staging slots are taken only here, when capacity allows."""
|
||||
if not self.pending_write_queue:
|
||||
return
|
||||
cc = self._cache.cache_controller
|
||||
self._sweep_stale_backup_intents()
|
||||
# Loads have priority (writes are deferrable): the write window is
|
||||
# the pool minus prefetch occupancy minus a 10% margin, floored at
|
||||
# the configured fraction.
|
||||
pool_tokens = cc.mem_pool_host.size
|
||||
live_cap = max(
|
||||
int(HICACHE_WRITE_STAGING_POOL_FRACTION * pool_tokens),
|
||||
pool_tokens - cc.prefetch_tokens_occupied - pool_tokens // 10,
|
||||
)
|
||||
while self.pending_write_queue:
|
||||
intent = self.pending_write_queue[0]
|
||||
intent_tokens = len(intent.hash_values) * self._cache.page_size
|
||||
if not self._backup_parent_covered(intent.node) or self._backup_oversize(
|
||||
intent.node, intent_tokens
|
||||
):
|
||||
# Unwritable intent (dropped parent or unstageable size):
|
||||
# cascade the drop down the chain rather than creating a
|
||||
# permanent storage hole / stalling the head-of-line queue.
|
||||
self.pending_write_queue.popleft()
|
||||
self.inflight_backup_node_ids.discard(intent.node_id)
|
||||
self.write_backlog_tokens_ -= intent_tokens
|
||||
self._log_backup_dropped(intent_tokens)
|
||||
continue
|
||||
if self.write_staged_tokens_ >= live_cap:
|
||||
# Yield to live fetch demand; retry next round.
|
||||
break
|
||||
if self._aux_budget_blocked(intent):
|
||||
# An aux pool lacks staging headroom: yield at the gate
|
||||
# instead of failing the alloc inside cc.write; acks free
|
||||
# aux staging, retry next round.
|
||||
break
|
||||
if not self._launch_backup_intent(intent):
|
||||
# Pool full of in-flight staging and nothing reclaimable
|
||||
# (the tree never holds host values in buffer mode):
|
||||
# defer, head-of-line; pending acks will free slots.
|
||||
break
|
||||
self.pending_write_queue.popleft()
|
||||
|
||||
def _launch_backup_intent(self, intent: _UnifiedBackupIntent) -> bool:
|
||||
"""Launch one admitted intent's D2H (staging alloc + device lock +
|
||||
async copy); the caller removes it from pending_write_queue. Returns
|
||||
False when staging cannot be allocated. From a successful launch the
|
||||
intent always reaches its storage-ack, so its content joins the
|
||||
LAUNCHED cover consulted by admission."""
|
||||
cache = self._cache
|
||||
cc = cache.cache_controller
|
||||
node = intent.node
|
||||
# Build aux transfers from the node's CURRENT state: a SWA span
|
||||
# tombstoned since admission backs up FULL-only, as in cache mode.
|
||||
device_value, comp_xfers = cache.tree_core.build_backup_spec(node.id)
|
||||
aux_xfers = [x for xfers in comp_xfers.values() for x in xfers]
|
||||
host_indices = cc.write(
|
||||
device_value,
|
||||
node_id=node.id,
|
||||
extra_pools=aux_xfers or None,
|
||||
)
|
||||
if host_indices is None:
|
||||
return False
|
||||
_track_content_refs(self.inflight_backup_hashes, intent.hash_values)
|
||||
# NOTE: no commit_backup — the node must never appear
|
||||
# host-resident in buffer mode; staging slots live in the entry.
|
||||
lock_params = cache.inc_lock_ref(node.id).to_dec_params()
|
||||
self.ongoing_write_through[node.id] = _UnifiedBufferBackupEntry(
|
||||
intent=intent,
|
||||
host_indices=host_indices,
|
||||
aux_xfers=aux_xfers,
|
||||
lock_params=lock_params,
|
||||
)
|
||||
self.write_staged_tokens_ += len(host_indices)
|
||||
self.write_backlog_tokens_ -= len(intent.hash_values) * cache.page_size
|
||||
return True
|
||||
|
||||
def _aux_budget_blocked(self, intent: _UnifiedBackupIntent) -> bool:
|
||||
"""True when an aux pool cannot stage this intent right now (free
|
||||
minus the loads-priority margin falls short of the need): defer at
|
||||
the gate instead of failing the alloc inside cc.write and blocking
|
||||
pure-KV intents behind an unallocatable head. The margin enforces
|
||||
loads-have-priority on aux pools the way live_cap does on the KV
|
||||
pool; avail already reflects prefetch-held slots, so no occupancy
|
||||
subtraction here."""
|
||||
aux = self._build_aux_staging_transfers(intent.node)
|
||||
if not aux:
|
||||
return False
|
||||
cc = self._cache.cache_controller
|
||||
for t in aux:
|
||||
entry = cc.mem_pool_host.entry_map.get(t.name)
|
||||
if entry is None:
|
||||
continue
|
||||
need = len(t.keys) * entry.host_pool.page_size
|
||||
headroom = entry.host_pool.available_size() - self._aux_loads_margin(
|
||||
entry.host_pool
|
||||
)
|
||||
if need > headroom:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _aux_window_keys(
|
||||
self, hash_values: list[str], transfer: PoolTransfer
|
||||
) -> Optional[list[str]]:
|
||||
"""Trailing KV page hashes keying an aux transfer's staged window
|
||||
(one key per aux-pool page)."""
|
||||
if transfer.host_indices is None or transfer.host_indices.numel() == 0:
|
||||
return None
|
||||
if transfer.indices_from_pool is not None:
|
||||
return None # sidecar rides another pool's slots; nothing to key
|
||||
entry = self._cache.cache_controller.mem_pool_host.entry_map.get(transfer.name)
|
||||
if entry is None:
|
||||
return None
|
||||
num_keys = len(transfer.host_indices) // entry.host_pool.page_size
|
||||
if num_keys == 0 or num_keys > len(hash_values):
|
||||
return None
|
||||
return hash_values[-num_keys:]
|
||||
|
||||
def finish_backup_ack(self, ack_id: int) -> None:
|
||||
"""D2H confirmed: drop the device lock and enqueue the storage write
|
||||
(which reads from the staging copy, so device eviction may proceed)."""
|
||||
entry = self.ongoing_write_through.pop(ack_id)
|
||||
intent = entry.intent
|
||||
self._cache.dec_lock_ref(intent.node_id, entry.lock_params)
|
||||
|
||||
# Every aux pool writes a trailing snapshot keyed by the last KV page
|
||||
# hashes it covers: the SWA window spans page_size-sized pages, the
|
||||
# Mamba state is a single slot (host pool page_size 1 -> one key).
|
||||
storage_xfers: list[PoolTransfer] = []
|
||||
for staged in entry.aux_xfers:
|
||||
keys = self._aux_window_keys(intent.hash_values, staged)
|
||||
if keys is None:
|
||||
continue
|
||||
storage_xfers.append(
|
||||
PoolTransfer(
|
||||
name=staged.name,
|
||||
host_indices=staged.host_indices,
|
||||
keys=keys,
|
||||
hit_policy=PoolHitPolicy.TRAILING_PAGES,
|
||||
)
|
||||
)
|
||||
operation_id = self._cache.cache_controller.write_storage(
|
||||
entry.host_indices,
|
||||
intent.key.token_ids,
|
||||
intent.hash_values,
|
||||
intent.prefix_keys,
|
||||
extra_pools=storage_xfers or None,
|
||||
)
|
||||
self.ongoing_backup[operation_id] = entry
|
||||
|
||||
def finish_storage_write_ack(self, operation_id: int) -> None:
|
||||
"""Storage write acked (rank-synced drain): free the entry's staging
|
||||
outright. Existence entries are added unconditionally
|
||||
(completed_tokens can diverge across ranks under backend failure) to
|
||||
keep admission decisions TP-deterministic. No-op for operations this
|
||||
pipeline does not own (e.g. acks for already-reset state)."""
|
||||
entry = self.ongoing_backup.pop(operation_id, None)
|
||||
if entry is None:
|
||||
return
|
||||
intent = entry.intent
|
||||
self._cache.storage_existence_cache.add(PoolName.KV, intent.hash_values)
|
||||
self._free_staging_now(entry.host_indices, entry.aux_xfers)
|
||||
self.write_staged_tokens_ -= len(entry.host_indices)
|
||||
self.inflight_backup_node_ids.discard(entry.intent.node_id)
|
||||
_untrack_content_refs(self.inflight_backup_hashes, intent.hash_values)
|
||||
|
||||
def _free_staging_now(
|
||||
self, host_indices: torch.Tensor, aux_xfers: list[PoolTransfer]
|
||||
) -> None:
|
||||
"""Synchronously free a staging span (KV + aux pools) on the
|
||||
scheduler thread; buffer-mode acks/drops all run here, so frees
|
||||
land before the tick's next gate reads pool availability."""
|
||||
cc = self._cache.cache_controller
|
||||
if host_indices is not None and host_indices.numel() > 0:
|
||||
cc.mem_pool_host.free(host_indices)
|
||||
for t in aux_xfers or ():
|
||||
if (
|
||||
t.host_indices is None
|
||||
or t.host_indices.numel() == 0
|
||||
or t.indices_from_pool is not None
|
||||
):
|
||||
continue
|
||||
entry = cc.mem_pool_host.entry_map.get(t.name)
|
||||
if entry is not None:
|
||||
entry.host_pool.free(t.host_indices)
|
||||
|
||||
# ---- load back pipeline (storage -> staging -> device) ----
|
||||
|
||||
def set_prefix_ctx(self, req_id: str, matched_prefix_tokens) -> None:
|
||||
"""Record the device-matched prefix at prefetch enqueue; consumed at
|
||||
staging commit to build the full-span tree key."""
|
||||
self._prefetch_prefix_ctx[req_id] = list(matched_prefix_tokens or [])
|
||||
|
||||
def pop_prefix_ctx(self, req_id: str) -> None:
|
||||
self._prefetch_prefix_ctx.pop(req_id, None)
|
||||
|
||||
def has_staged(self, req_id: str) -> bool:
|
||||
return req_id in self.staged_prefetches
|
||||
|
||||
@staticmethod
|
||||
def _occupied_span(host_indices) -> int:
|
||||
"""Occupancy units a buffer-mode prefetch holds: granted at
|
||||
hit-alloc, sized to the allocation (0 while still querying)."""
|
||||
return len(host_indices) if host_indices is not None else 0
|
||||
|
||||
def stage_completed_prefetch(
|
||||
self,
|
||||
req_id: str,
|
||||
num_tokens: int,
|
||||
hash_value: list[str],
|
||||
) -> bool:
|
||||
"""Park the completed fetch as a held bounce; the scheduler surfaces
|
||||
it as host_hit_length and the adder consumes it via init_load_back.
|
||||
Always returns True (ready is a stable, revisited state)."""
|
||||
cache = self._cache
|
||||
(
|
||||
_anchor,
|
||||
prefetch_key,
|
||||
host_indices,
|
||||
operation,
|
||||
_lock_params,
|
||||
comp_xfers,
|
||||
) = cache.ongoing_prefetch.pop(req_id)
|
||||
cc = cache.cache_controller
|
||||
prefix_tokens = self._prefetch_prefix_ctx.pop(req_id, None)
|
||||
aux_xfers = [x for xfers in comp_xfers.values() for x in xfers]
|
||||
|
||||
if num_tokens == 0 or prefix_tokens is None:
|
||||
# Nothing usable fetched: recompute.
|
||||
cc.append_host_mem_release(
|
||||
host_indices[:num_tokens], extra_pools=aux_xfers or None
|
||||
)
|
||||
cc.prefetch_tokens_occupied -= self._occupied_span(host_indices)
|
||||
cache.prefetch_loaded_tokens_by_reqid[req_id] = 0
|
||||
return True
|
||||
|
||||
staged_pages = num_tokens // cache.page_size
|
||||
staged_hashes = hash_value[:staged_pages]
|
||||
staged_kv = host_indices[:num_tokens]
|
||||
# Feed existence beliefs from the storage-fetched pages: the fetch
|
||||
# itself is the evidence, so feeding is sound even if this staged
|
||||
# prefetch is later dropped unconsumed.
|
||||
cache.storage_existence_cache.add(PoolName.KV, list(staged_hashes))
|
||||
occupied_tokens = self._occupied_span(host_indices)
|
||||
|
||||
self.staged_prefetches[req_id] = _StagedPrefetch(
|
||||
req_id=req_id,
|
||||
key_tokens=prefix_tokens + list(prefetch_key[:num_tokens].token_ids),
|
||||
extra_key=prefetch_key.extra_key,
|
||||
matched_len=len(prefix_tokens),
|
||||
num_tokens=num_tokens,
|
||||
occupied_tokens=occupied_tokens,
|
||||
host_indices=staged_kv,
|
||||
aux_xfers=aux_xfers,
|
||||
hash_values=staged_hashes,
|
||||
operation_id=operation.id,
|
||||
)
|
||||
cache.prefetch_loaded_tokens_by_reqid[req_id] = num_tokens
|
||||
return True
|
||||
|
||||
def staged_prefetch_tokens(self, req_id: str) -> int:
|
||||
"""Tokens a staged prefetch would splice (0 = no hold); surfaced by the
|
||||
scheduler as the request's host_hit_length."""
|
||||
f = self.staged_prefetches.get(req_id)
|
||||
return f.num_tokens if f is not None else 0
|
||||
|
||||
def staged_prefetch_swa_tokens(self, req_id: str) -> int:
|
||||
"""SWA device tokens consuming this staged prefetch will allocate (the
|
||||
staged trailing window); surfaced as the request's swa_host_hit_length
|
||||
so the adder's SWA gate charges the admission-time alloc."""
|
||||
f = self.staged_prefetches.get(req_id)
|
||||
if f is None:
|
||||
return 0
|
||||
return sum(
|
||||
len(t.host_indices)
|
||||
for t in f.aux_xfers
|
||||
if t.name == PoolName.SWA and t.host_indices is not None
|
||||
)
|
||||
|
||||
def init_load_back(self, params: InitLoadBackParams) -> tuple[torch.Tensor, NodeId]:
|
||||
"""Buffer-mode branch of init_load_back: consume the staged prefetch
|
||||
at prefill admission — device alloc (evict-before-alloc), layer-gated
|
||||
H2D, and a plain insert so downstream sees ordinary tree state.
|
||||
Misaligned or alloc-failed holds drop; the request recomputes."""
|
||||
cache = self._cache
|
||||
req = params.req
|
||||
assert req is not None
|
||||
empty = cache.tree_core.empty_match_result.device_indices
|
||||
unchanged = (empty, req.last_node)
|
||||
f = self.staged_prefetches.pop(req.rid, None)
|
||||
if f is None:
|
||||
return unchanged
|
||||
cc = cache.cache_controller
|
||||
|
||||
def _drop() -> tuple[torch.Tensor, NodeId]:
|
||||
self._free_staging_now(f.host_indices, f.aux_xfers)
|
||||
cc.prefetch_tokens_occupied -= f.occupied_tokens
|
||||
return unchanged
|
||||
|
||||
# Splice-validity: the span only fits if the device prefix still
|
||||
# ends exactly at the enqueue-time matched_len.
|
||||
if len(req.prefix_indices) != f.matched_len:
|
||||
# Prefix moved while held (leaf eviction or sibling extension):
|
||||
# drop and recompute.
|
||||
return _drop()
|
||||
|
||||
# Evict-before-alloc (mirrors _load_back_transfers): the budget gate
|
||||
# counts evictable pages, but cc.load draws from free slots only.
|
||||
if cache.supports_swa():
|
||||
avail = cache.token_to_kv_pool_allocator.full_available_size()
|
||||
else:
|
||||
avail = cache.token_to_kv_pool_allocator.available_size()
|
||||
if avail < f.num_tokens:
|
||||
needed = f.num_tokens - avail
|
||||
evicted = cache.evict(EvictParams(num_tokens=needed))
|
||||
if evicted.num_tokens_evicted < needed:
|
||||
# Genuinely no room (locked pages): recompute.
|
||||
return _drop()
|
||||
|
||||
load_back_id = -(f.operation_id) - 1
|
||||
device_indices = cc.load(
|
||||
host_indices=f.host_indices,
|
||||
node_id=load_back_id,
|
||||
extra_pools=f.aux_xfers or None,
|
||||
)
|
||||
if device_indices is None:
|
||||
# Transient allocator shortfall despite the evict: recompute
|
||||
# (init_load_back's degrade contract).
|
||||
return _drop()
|
||||
|
||||
swa_dev = next(
|
||||
(
|
||||
t.device_indices
|
||||
for t in f.aux_xfers
|
||||
if t.name == PoolName.SWA
|
||||
and t.device_indices is not None
|
||||
and t.device_indices.numel() > 0
|
||||
),
|
||||
None,
|
||||
)
|
||||
if swa_dev is not None:
|
||||
# Register the trailing window's FULL->SWA translation NOW: the
|
||||
# admitted request's attention reads the window through this
|
||||
# mapping during the layer-gated forward.
|
||||
cache._apply_cache_action(
|
||||
RebuildFullToSWAMapping([device_indices[-len(swa_dev) :]], [swa_dev])
|
||||
)
|
||||
|
||||
# Publish via a plain insert under the admission lock choreography;
|
||||
# the caller's request lock then pins the span (load_back pattern).
|
||||
key = RadixKey(
|
||||
array("q", f.key_tokens),
|
||||
extra_key=f.extra_key,
|
||||
is_bigram=cache.tree_core.is_eagle,
|
||||
).page_aligned(cache.page_size)
|
||||
span_end = f.matched_len + f.num_tokens
|
||||
cache.insert(
|
||||
InsertParams(
|
||||
key=key,
|
||||
value=torch.cat([req.prefix_indices, device_indices]),
|
||||
prev_prefix_len=f.matched_len,
|
||||
swa_evicted_seqlen=(
|
||||
max(0, span_end - len(swa_dev)) if swa_dev is not None else 0
|
||||
),
|
||||
)
|
||||
)
|
||||
self.ongoing_buffer_load_back[load_back_id] = _OngoingBufferLoadBack(
|
||||
req_id=f.req_id,
|
||||
num_tokens=f.num_tokens,
|
||||
occupied_tokens=f.occupied_tokens,
|
||||
aux_xfers=f.aux_xfers,
|
||||
host_indices=f.host_indices,
|
||||
hash_values=f.hash_values,
|
||||
)
|
||||
m = cache.match_prefix(MatchPrefixParams(key=key))
|
||||
if len(m.device_indices) < span_end:
|
||||
# The insert walk did not adopt the full span (should not happen
|
||||
# for a locked prefix); the slots are tree-owned/evictable — do
|
||||
# not splice, the request recomputes.
|
||||
return unchanged
|
||||
return device_indices, m.last_device_node
|
||||
|
||||
def try_finish_load_back(self, ack_id: int) -> bool:
|
||||
"""Fill ack: free the host bounce and return True when the ack id is
|
||||
a buffer-mode load-back. The span was published at admission; the
|
||||
ack never touches the tree (existence beliefs were fed from the
|
||||
storage-fetched pages at staging commit)."""
|
||||
f = self.ongoing_buffer_load_back.pop(ack_id, None)
|
||||
if f is None:
|
||||
return False
|
||||
cache = self._cache
|
||||
cc = cache.cache_controller
|
||||
|
||||
# The H2D consumed the bounce buffers; free them outright.
|
||||
self._free_staging_now(f.host_indices, f.aux_xfers)
|
||||
|
||||
cc.prefetch_tokens_occupied -= f.occupied_tokens
|
||||
logger.info(
|
||||
"HiCache prefetch fill committed req=%s filled=%d occupied=%d",
|
||||
f.req_id,
|
||||
f.num_tokens,
|
||||
cc.prefetch_tokens_occupied,
|
||||
)
|
||||
if cache.enable_storage_metrics and cache.storage_metrics_collector is not None:
|
||||
cache.storage_metrics_collector.log_prefetched_tokens(f.num_tokens)
|
||||
return True
|
||||
|
||||
def release_aborted_staged(self, rid: str) -> bool:
|
||||
"""Free an aborted request's staged prefetch (nothing device-side
|
||||
exists yet — only the bounce). Returns True when a hold existed."""
|
||||
staged = self.staged_prefetches.pop(rid, None)
|
||||
if staged is None:
|
||||
return False
|
||||
self._free_staging_now(staged.host_indices, staged.aux_xfers)
|
||||
self._cache.cache_controller.prefetch_tokens_occupied -= staged.occupied_tokens
|
||||
return True
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Local existence cache for HiCache buffer_only mode.
|
||||
|
||||
In buffer mode host memory holds no persistent copy, so without a local
|
||||
existence signal every re-insert of a hot prefix re-stages and re-writes to
|
||||
L3 storage. This cache is that signal: a bounded LRU of (pool, page-hash)
|
||||
entries *believed* present in storage.
|
||||
|
||||
Semantics are advisory, not authoritative:
|
||||
|
||||
- A hit skips the redundant D2H + storage write.
|
||||
- A stale positive (backend evicted the data) costs skipped write-backs until
|
||||
a prefetch hit-query shortfall invalidates the entries; the next insert
|
||||
then writes the data back. Never a correctness issue — at worst one cold
|
||||
recompute, the same as any cache miss.
|
||||
- A miss (entry LRU-evicted or never seen) just costs one redundant write
|
||||
(idempotent: storage keys are content-addressed).
|
||||
|
||||
Keys are the content-chained page hashes already computed at insert time, so
|
||||
lookups never hash anything and entries survive node deletion, splits, and
|
||||
recompute (same tokens => same chain). Page hashes are chained and the write
|
||||
path is prefix-contiguous (parent-cover gate), so a node's own page set is
|
||||
the only thing a caller needs to check.
|
||||
|
||||
TP determinism: replicas stay identical because every mutation happens on the
|
||||
scheduler thread at lockstep points with cross-rank-reduced inputs
|
||||
(storage-ack drain, prefetch-hit drain, fill commit). Do not touch it from
|
||||
anywhere else.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import Container, Iterable, Sequence
|
||||
|
||||
# ~131K entries; at ~150-250 B/entry this is <= ~30 MB and covers roughly
|
||||
# 8M KV tokens at page size 64 (aux-pool entries included). Coverage per MB
|
||||
# scales with page size — small-page configs simply remember fewer tokens.
|
||||
HICACHE_EXISTENCE_CACHE_MAX_ENTRIES = 128 * 1024
|
||||
|
||||
|
||||
class StorageExistenceCache:
|
||||
def __init__(self, max_entries: int = HICACHE_EXISTENCE_CACHE_MAX_ENTRIES):
|
||||
self.max_entries = max_entries
|
||||
self._entries: OrderedDict[tuple[str, str], None] = OrderedDict()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
def add(self, pool: str, hashes: Iterable[str]) -> None:
|
||||
entries = self._entries
|
||||
for h in hashes:
|
||||
entries[(pool, h)] = None
|
||||
entries.move_to_end((pool, h))
|
||||
while len(entries) > self.max_entries:
|
||||
entries.popitem(last=False)
|
||||
|
||||
def contains(self, pool: str, page_hash: str) -> bool:
|
||||
entries = self._entries
|
||||
if (pool, page_hash) not in entries:
|
||||
return False
|
||||
entries.move_to_end((pool, page_hash))
|
||||
return True
|
||||
|
||||
def contains_all(self, pool: str, hashes: Iterable[str]) -> bool:
|
||||
return all(self.contains(pool, h) for h in hashes)
|
||||
|
||||
def covers_all(
|
||||
self,
|
||||
pool: str,
|
||||
hashes: Iterable[str],
|
||||
extra_cover: Container[str] = frozenset(),
|
||||
) -> bool:
|
||||
"""True when every page is believed stored or sits in
|
||||
``extra_cover`` (e.g. content past its D2H launch, which always
|
||||
reaches its storage-ack). LRU-touches the believed entries."""
|
||||
return all(self.contains(pool, h) or h in extra_cover for h in hashes)
|
||||
|
||||
def invalidate_beyond(
|
||||
self, pool: str, hashes: Sequence[str], keep_pages: int
|
||||
) -> None:
|
||||
"""Ground-truth heal from a prefetch hit query: discard beliefs
|
||||
beyond the leading ``keep_pages`` of a hash chain (the folded
|
||||
usable cut). The next insert re-writes the discarded span, closing
|
||||
stale positives and aux holes at the cut."""
|
||||
for h in hashes[keep_pages:]:
|
||||
self._entries.pop((pool, h), None)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._entries.clear()
|
||||
@@ -1775,6 +1775,8 @@ class HiRadixCache(RadixCache):
|
||||
new_input_tokens: List[int],
|
||||
last_hash: Optional[str] = None,
|
||||
prefix_keys: Optional[List[str]] = None,
|
||||
# Scheduler-call parity with UnifiedRadixCache; unused in cache mode.
|
||||
matched_prefix_tokens: Optional[List[int]] = None,
|
||||
):
|
||||
prefetch_key = RadixKey(
|
||||
new_input_tokens,
|
||||
|
||||
@@ -112,6 +112,7 @@ class HybridCacheController(BaseHiCacheController):
|
||||
storage_backend_extra_config: Optional[dict] = None,
|
||||
transfer_layer_num: Optional[int] = None,
|
||||
enable_storage_metrics: bool = False,
|
||||
host_memory_mode: str = "cache",
|
||||
):
|
||||
startup_storage_backend = storage_backend
|
||||
self.extra_host_mem_release_queues: dict[PoolName, Queue[torch.Tensor]] = {}
|
||||
@@ -131,6 +132,7 @@ class HybridCacheController(BaseHiCacheController):
|
||||
model_name=model_name,
|
||||
storage_backend_extra_config=storage_backend_extra_config,
|
||||
enable_storage_metrics=enable_storage_metrics,
|
||||
host_memory_mode=host_memory_mode,
|
||||
)
|
||||
# Override layer_num: hybrid models transfer all layers (For example, Linear Model (KV + Mamba)),
|
||||
# not just the full attention layers reported by full_kv_pool.
|
||||
@@ -587,6 +589,7 @@ class HybridCacheController(BaseHiCacheController):
|
||||
hash_value = self.get_hash_str(
|
||||
operation.token_ids, operation.last_hash, page_size=self.page_size
|
||||
)
|
||||
operation.all_hash_values = hash_value
|
||||
|
||||
extra_info = HiCacheStorageExtraInfo(
|
||||
prefix_keys=operation.prefix_keys.copy() if operation.prefix_keys else None
|
||||
@@ -780,6 +783,28 @@ class HybridCacheController(BaseHiCacheController):
|
||||
continue
|
||||
trailing_n = len(transfer.keys) if transfer.keys else 1
|
||||
transfer.keys = all_hashes[max(0, kv_hit_pages - trailing_n) : kv_hit_pages]
|
||||
if transfer.host_indices is None:
|
||||
continue
|
||||
entry = self.mem_pool_host.entry_map.get(transfer.name)
|
||||
pool_page_size = (
|
||||
entry.host_pool.page_size if entry is not None else self.page_size
|
||||
)
|
||||
needed = len(transfer.keys) * pool_page_size
|
||||
if transfer.host_indices.numel() > needed:
|
||||
# The hit undershot the pre-allocated window buffer. Backends
|
||||
# fetch keys zipped against the buffer head, so shrink the
|
||||
# transfer to match and release the tail now — otherwise the
|
||||
# length mismatch makes batch_get_v2 fetch nothing and the
|
||||
# whole window is silently lost downstream.
|
||||
self.append_host_mem_release(
|
||||
extra_pools=[
|
||||
PoolTransfer(
|
||||
name=transfer.name,
|
||||
host_indices=transfer.host_indices[needed:],
|
||||
)
|
||||
]
|
||||
)
|
||||
transfer.host_indices = transfer.host_indices[:needed]
|
||||
|
||||
def _resolve_pool_transfers_allocation(
|
||||
self,
|
||||
|
||||
@@ -307,6 +307,7 @@ def build_kv_only_stack(
|
||||
storage_backend_extra_config=storage_backend_extra_config,
|
||||
transfer_layer_num=transfer_layer_num,
|
||||
enable_storage_metrics=enable_storage_metrics,
|
||||
host_memory_mode=server_args.hicache_host_memory_mode,
|
||||
)
|
||||
if params.mtp_draft_device_pools:
|
||||
cache_controller.set_mtp_draft_pools(params.mtp_draft_device_pools)
|
||||
@@ -377,6 +378,7 @@ def build_hybrid_swa_stack(
|
||||
storage_backend_extra_config=storage_backend_extra_config,
|
||||
transfer_layer_num=transfer_layer_num,
|
||||
enable_storage_metrics=enable_storage_metrics,
|
||||
host_memory_mode=server_args.hicache_host_memory_mode,
|
||||
)
|
||||
if mtp_swa_device_pools:
|
||||
cache_controller.set_mtp_draft_pools(mtp_swa_device_pools)
|
||||
@@ -664,6 +666,7 @@ def build_deepseek_v4_hicache_stack(
|
||||
storage_backend_extra_config=storage_backend_extra_config,
|
||||
transfer_layer_num=transfer_layer_num,
|
||||
enable_storage_metrics=enable_storage_metrics,
|
||||
host_memory_mode=server_args.hicache_host_memory_mode,
|
||||
)
|
||||
if mtp_swa_device_buffers:
|
||||
cache_controller.set_mtp_draft_pools(mtp_swa_device_buffers)
|
||||
@@ -759,6 +762,7 @@ def build_hybrid_mamba_stack(
|
||||
storage_backend_extra_config=storage_backend_extra_config,
|
||||
transfer_layer_num=transfer_layer_num,
|
||||
enable_storage_metrics=enable_storage_metrics,
|
||||
host_memory_mode=server_args.hicache_host_memory_mode,
|
||||
)
|
||||
if mtp_draft_device_pools:
|
||||
cache_controller.set_mtp_draft_pools(mtp_draft_device_pools)
|
||||
@@ -874,6 +878,7 @@ def build_hybrid_mamba_swa_stack(
|
||||
storage_backend_extra_config=storage_backend_extra_config,
|
||||
transfer_layer_num=transfer_layer_num,
|
||||
enable_storage_metrics=enable_storage_metrics,
|
||||
host_memory_mode=server_args.hicache_host_memory_mode,
|
||||
)
|
||||
return host_pool_group, cache_controller
|
||||
|
||||
@@ -951,6 +956,7 @@ def build_anchor_sidecar_stack(
|
||||
storage_backend_extra_config=storage_backend_extra_config,
|
||||
transfer_layer_num=transfer_layer_num,
|
||||
enable_storage_metrics=enable_storage_metrics,
|
||||
host_memory_mode=server_args.hicache_host_memory_mode,
|
||||
)
|
||||
if mtp_draft_device_pools:
|
||||
cache_controller.set_mtp_draft_pools(mtp_draft_device_pools)
|
||||
|
||||
@@ -129,7 +129,7 @@ class MambaPoolHost(HostKVCache):
|
||||
for conv_state in device_pool.mamba_cache.conv
|
||||
]
|
||||
|
||||
self.init_kv_buffer()
|
||||
self.kv_buffer = self.init_kv_buffer()
|
||||
self._init_write_back_staging_buffers()
|
||||
self.lock = threading.RLock()
|
||||
self.clear()
|
||||
@@ -201,6 +201,12 @@ class MambaPoolHost(HostKVCache):
|
||||
allocator=self.allocator,
|
||||
)
|
||||
)
|
||||
# destroy() unregisters via kv_buffer; without this list the pinned
|
||||
# registrations leak past the buffers' mmap. 0-element buffers
|
||||
# (conv-only models' temporal state) were never registered.
|
||||
return [
|
||||
buf for buf in (self.temporal_buffer, *self.conv_buffer) if buf.numel() > 0
|
||||
]
|
||||
|
||||
def _init_write_back_staging_buffers(self):
|
||||
self.temporal_staging_buffer = None
|
||||
|
||||
@@ -237,6 +237,16 @@ def create_tree_cache(ctx: TreeCacheBuildContext) -> BasePrefixCache:
|
||||
cache = default_radix_cache_factory(ctx)
|
||||
source = "default"
|
||||
|
||||
if (
|
||||
ctx.server_args.enable_hierarchical_cache
|
||||
and ctx.server_args.hicache_host_memory_mode == "buffer_only"
|
||||
and type(cache).__name__ != "UnifiedRadixCache"
|
||||
):
|
||||
raise ValueError(
|
||||
"--hicache-host-memory-mode buffer_only is only implemented for "
|
||||
f"the unified radix tree; this model selected {type(cache).__name__}."
|
||||
)
|
||||
|
||||
if ctx.server_args.enable_session_radix_cache and not getattr(
|
||||
cache, "enable_session_radix_cache", False
|
||||
):
|
||||
|
||||
@@ -158,7 +158,7 @@ class StorageBackendFactory:
|
||||
mem_pool_host: Any,
|
||||
) -> HiCacheStorage:
|
||||
"""Create built-in backend with original initialization logic."""
|
||||
if backend_name == "file":
|
||||
if backend_name in ("file", "sim"):
|
||||
return backend_class(storage_config)
|
||||
elif backend_name == "nixl":
|
||||
return backend_class(storage_config)
|
||||
@@ -198,6 +198,10 @@ StorageBackendFactory.register_backend(
|
||||
"file", "sglang.srt.mem_cache.hicache_storage", "HiCacheFile"
|
||||
)
|
||||
|
||||
StorageBackendFactory.register_backend(
|
||||
"sim", "sglang.srt.mem_cache.storage.sim_storage", "SimHiCacheStorage"
|
||||
)
|
||||
|
||||
StorageBackendFactory.register_backend(
|
||||
"nixl",
|
||||
"sglang.srt.mem_cache.storage.nixl.hicache_nixl",
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Deterministic no-IO storage simulator for HiCache benchmarking.
|
||||
|
||||
Stores KEYS ONLY (served KV is garbage — benchmark-only, for ignore_eos
|
||||
workloads where nothing reads the generated text) and sleeps
|
||||
``bytes / bandwidth + op latency`` on the calling backup/prefetch thread,
|
||||
so pipeline dynamics are preserved while the medium is exactly
|
||||
reproducible. Bandwidth is per scheduler rank (each rank ships its own
|
||||
shard). extra_config knobs: ``sim_write_gbps`` (default 5.0; <=0 =
|
||||
infinite), ``sim_read_gbps`` (default = write), ``sim_op_latency_us``
|
||||
(default 100, applied to exists queries too).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.mem_cache.hicache_storage import (
|
||||
HiCacheStorage,
|
||||
HiCacheStorageConfig,
|
||||
HiCacheStorageExtraInfo,
|
||||
PoolHitPolicy,
|
||||
PoolName,
|
||||
PoolTransfer,
|
||||
PoolTransferResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SimHiCacheStorage(HiCacheStorage):
|
||||
def __init__(self, storage_config: HiCacheStorageConfig):
|
||||
extra = storage_config.extra_config or {}
|
||||
self.write_gbps = float(extra.get("sim_write_gbps", 5.0))
|
||||
self.read_gbps = float(extra.get("sim_read_gbps", self.write_gbps))
|
||||
self.op_latency_s = float(extra.get("sim_op_latency_us", 100.0)) * 1e-6
|
||||
# Scoped key -> True. Keys only; there are no bytes to store.
|
||||
self._keys: set[str] = set()
|
||||
self._lock = threading.Lock()
|
||||
logger.info(
|
||||
"SimHiCacheStorage: write=%.2f GB/s read=%.2f GB/s latency=%.0fus "
|
||||
"(per rank; <=0 GB/s = infinite)",
|
||||
self.write_gbps,
|
||||
self.read_gbps,
|
||||
self.op_latency_s * 1e6,
|
||||
)
|
||||
|
||||
# ---- timing model ----
|
||||
|
||||
def _sleep_io(self, num_bytes: int, gbps: float) -> None:
|
||||
delay = self.op_latency_s
|
||||
if gbps > 0:
|
||||
delay += num_bytes / (gbps * 1e9)
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
|
||||
def _pool_bytes(self, name: PoolName, num_slots: int) -> int:
|
||||
return num_slots * self.registered_pools[name].size_per_token
|
||||
|
||||
@staticmethod
|
||||
def _scoped(name: PoolName, key: str) -> str:
|
||||
return key if name == PoolName.KV else f"{key}.{name}"
|
||||
|
||||
# ---- single-key surface (generic controller paths) ----
|
||||
|
||||
def get(
|
||||
self,
|
||||
key: str,
|
||||
target_location: Optional[Any] = None,
|
||||
target_sizes: Optional[Any] = None,
|
||||
) -> torch.Tensor | None:
|
||||
with self._lock:
|
||||
present = key in self._keys
|
||||
return target_location if present else None
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Optional[Any] = None,
|
||||
target_location: Optional[Any] = None,
|
||||
target_sizes: Optional[Any] = None,
|
||||
) -> bool:
|
||||
with self._lock:
|
||||
self._keys.add(key)
|
||||
return True
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
with self._lock:
|
||||
return key in self._keys
|
||||
|
||||
# ---- batch v0/v1 (generic page funcs) ----
|
||||
|
||||
def batch_get(
|
||||
self,
|
||||
keys: List[str],
|
||||
target_locations: Optional[Any] = None,
|
||||
target_sizes: Optional[Any] = None,
|
||||
) -> List[torch.Tensor | None]:
|
||||
locations = target_locations or [None] * len(keys)
|
||||
with self._lock:
|
||||
present = [k in self._keys for k in keys]
|
||||
num_bytes = sum(
|
||||
loc.numel() * loc.element_size()
|
||||
for loc, hit in zip(locations, present)
|
||||
if hit and loc is not None
|
||||
)
|
||||
self._sleep_io(num_bytes, self.read_gbps)
|
||||
return [loc if hit else None for loc, hit in zip(locations, present)]
|
||||
|
||||
def batch_set(
|
||||
self,
|
||||
keys: List[str],
|
||||
values: Optional[Any] = None,
|
||||
target_locations: Optional[Any] = None,
|
||||
target_sizes: Optional[Any] = None,
|
||||
) -> bool:
|
||||
num_bytes = sum(v.numel() * v.element_size() for v in values or ())
|
||||
self._sleep_io(num_bytes, self.write_gbps)
|
||||
with self._lock:
|
||||
self._keys.update(keys)
|
||||
return True
|
||||
|
||||
def batch_get_v1(
|
||||
self,
|
||||
keys: List[str],
|
||||
host_indices: torch.Tensor,
|
||||
extra_info: Optional[HiCacheStorageExtraInfo] = None,
|
||||
) -> List[bool]:
|
||||
with self._lock:
|
||||
present = [k in self._keys for k in keys]
|
||||
hit_slots = (len(host_indices) // max(1, len(keys))) * sum(present)
|
||||
self._sleep_io(hit_slots * self.mem_pool_host.size_per_token, self.read_gbps)
|
||||
return present
|
||||
|
||||
def batch_set_v1(
|
||||
self,
|
||||
keys: List[str],
|
||||
host_indices: torch.Tensor,
|
||||
extra_info: Optional[HiCacheStorageExtraInfo] = None,
|
||||
) -> List[bool]:
|
||||
self._sleep_io(
|
||||
len(host_indices) * self.mem_pool_host.size_per_token, self.write_gbps
|
||||
)
|
||||
with self._lock:
|
||||
self._keys.update(keys)
|
||||
return [True] * len(keys)
|
||||
|
||||
# ---- batch v2 (hybrid multi-pool paths) ----
|
||||
|
||||
def batch_exists(
|
||||
self,
|
||||
keys: List[str],
|
||||
extra_info: Optional[HiCacheStorageExtraInfo] = None,
|
||||
) -> int:
|
||||
self._sleep_io(0, self.read_gbps)
|
||||
with self._lock:
|
||||
for i, key in enumerate(keys):
|
||||
if key not in self._keys:
|
||||
return i
|
||||
return len(keys)
|
||||
|
||||
def batch_exists_v2(
|
||||
self,
|
||||
keys: List[str],
|
||||
pool_transfers: Optional[List[PoolTransfer]] = None,
|
||||
extra_info: Optional[HiCacheStorageExtraInfo] = None,
|
||||
) -> PoolTransferResult:
|
||||
"""Same fold semantics as HiCacheFile.batch_exists_v2, over the
|
||||
in-memory key set."""
|
||||
self._sleep_io(0, self.read_gbps)
|
||||
with self._lock:
|
||||
snapshot = self._keys.copy()
|
||||
|
||||
kv_pages = next(
|
||||
(i for i in range(len(keys)) if keys[i] not in snapshot), len(keys)
|
||||
)
|
||||
hit_count: dict[str, int] = {PoolName.KV: kv_pages} if kv_pages else {}
|
||||
final_pages = kv_pages
|
||||
|
||||
for transfer in pool_transfers or []:
|
||||
if final_pages == 0:
|
||||
break
|
||||
name = transfer.name
|
||||
if transfer.hit_policy == PoolHitPolicy.ALL_PAGES:
|
||||
boundary = next(
|
||||
(
|
||||
i
|
||||
for i in range(kv_pages)
|
||||
if self._scoped(name, keys[i]) not in snapshot
|
||||
),
|
||||
kv_pages,
|
||||
)
|
||||
else: # trailing_pages
|
||||
trailing = max(1, len(transfer.keys) if transfer.keys else 1)
|
||||
boundary = 0
|
||||
for prefix_len in range(kv_pages, 0, -1):
|
||||
if all(
|
||||
self._scoped(name, keys[i]) in snapshot
|
||||
for i in range(max(0, prefix_len - trailing), prefix_len)
|
||||
):
|
||||
boundary = prefix_len
|
||||
break
|
||||
if boundary:
|
||||
hit_count[name] = boundary
|
||||
final_pages = min(final_pages, boundary)
|
||||
|
||||
return PoolTransferResult(final_pages, hit_count)
|
||||
|
||||
def _batch_v2(
|
||||
self, transfers: List[PoolTransfer], gbps: float, record: bool
|
||||
) -> dict[str, List[bool]]:
|
||||
results: dict[str, List[bool]] = {}
|
||||
num_bytes = 0
|
||||
for t in transfers:
|
||||
t_keys = t.keys or []
|
||||
if t.host_indices is not None:
|
||||
num_bytes += self._pool_bytes(t.name, len(t.host_indices))
|
||||
scoped = [self._scoped(t.name, k) for k in t_keys]
|
||||
with self._lock:
|
||||
if record:
|
||||
self._keys.update(scoped)
|
||||
results[t.name] = [True] * len(t_keys)
|
||||
else:
|
||||
results[t.name] = [k in self._keys for k in scoped]
|
||||
self._sleep_io(num_bytes, gbps)
|
||||
return results
|
||||
|
||||
def batch_get_v2(
|
||||
self,
|
||||
transfers: List[PoolTransfer],
|
||||
extra_info: Optional[HiCacheStorageExtraInfo] = None,
|
||||
) -> dict[str, List[bool]]:
|
||||
return self._batch_v2(transfers, self.read_gbps, record=False)
|
||||
|
||||
def batch_set_v2(
|
||||
self,
|
||||
transfers: List[PoolTransfer],
|
||||
extra_info: Optional[HiCacheStorageExtraInfo] = None,
|
||||
) -> dict[str, List[bool]]:
|
||||
return self._batch_v2(transfers, self.write_gbps, record=True)
|
||||
|
||||
# ---- misc ----
|
||||
|
||||
def clear(self) -> bool:
|
||||
with self._lock:
|
||||
self._keys.clear()
|
||||
return True
|
||||
|
||||
def get_stats(self):
|
||||
return None
|
||||
@@ -71,6 +71,9 @@ class SWAComponent(TreeComponent):
|
||||
super().__init__(cache, params)
|
||||
self._session_leaf_covered_len: dict[str, dict[UnifiedTreeNode, int]] = {}
|
||||
self.sliding_window_size = params.sliding_window_size
|
||||
self.full_window_pages = (
|
||||
self.sliding_window_size + params.page_size - 1
|
||||
) // params.page_size
|
||||
# HiCache state: set to host SWA pool when HiCache enabled
|
||||
self._swa_kv_pool_host = None
|
||||
|
||||
@@ -770,12 +773,27 @@ class SWAComponent(TreeComponent):
|
||||
# unified_kv keeps SWA as a device-only ring -- nothing to prefetch into.
|
||||
if self._swa_kv_pool_host is None:
|
||||
return PreparePrefetchResult()
|
||||
sw_pages = (
|
||||
self.cache.sliding_window_size + self.cache.page_size - 1
|
||||
) // self.cache.page_size
|
||||
if sw_pages == 0 or prefetch_tokens // self.cache.page_size < sw_pages:
|
||||
sw_pages = self.full_window_pages
|
||||
if sw_pages == 0:
|
||||
return PreparePrefetchResult()
|
||||
num_tokens = sw_pages * self.cache.page_size
|
||||
prefetch_pages = prefetch_tokens // self.cache.page_size
|
||||
if prefetch_pages >= sw_pages:
|
||||
num_pages = sw_pages
|
||||
elif prefetch_pages <= 0:
|
||||
return PreparePrefetchResult()
|
||||
elif (
|
||||
self.tree_core.is_root(node_id)
|
||||
or self.cache.host_memory_mode == "buffer_only"
|
||||
):
|
||||
# Sub-window fetch: at root the sequence IS its window; mid-tree
|
||||
# (buffer mode) the window head is the device prefix's own ring
|
||||
# state, so only the suffix needs fetching.
|
||||
num_pages = prefetch_pages
|
||||
else:
|
||||
# Cache-mode graft: a mid-tree window head is not
|
||||
# device-guaranteed, require a full window.
|
||||
return PreparePrefetchResult()
|
||||
num_tokens = num_pages * self.cache.page_size
|
||||
host_indices = self._swa_kv_pool_host.alloc(num_tokens)
|
||||
if host_indices is None:
|
||||
self.cache.evict_host(num_tokens, ComponentType.SWA)
|
||||
@@ -869,12 +887,14 @@ class SWAComponent(TreeComponent):
|
||||
|
||||
if phase == CacheTransferPhase.PREFETCH:
|
||||
assert host_indices is not None
|
||||
sw_pages = host_indices.numel() // self.tree_core.page_size
|
||||
# Keys are unknowable at build time; placeholders carry the
|
||||
# count, _sync_trailing_keys fills the real trailing hashes.
|
||||
num_pages = host_indices.numel() // self.tree_core.page_size
|
||||
return [
|
||||
PoolTransfer(
|
||||
name=PoolName.SWA,
|
||||
host_indices=host_indices,
|
||||
keys=["__placeholder__"] * sw_pages,
|
||||
keys=["__placeholder__"] * num_pages,
|
||||
hit_policy=PoolHitPolicy.TRAILING_PAGES,
|
||||
)
|
||||
]
|
||||
@@ -997,6 +1017,13 @@ class SWAComponent(TreeComponent):
|
||||
and insert_result.inserted_host_node is not None
|
||||
else None
|
||||
)
|
||||
if anchor is not self.tree_core.root_node:
|
||||
# Cache-mode graft commit only (buffer fills never reach here):
|
||||
# a hit-shrunk window mid-tree is missing its head — drop it.
|
||||
# Root anchors are complete windows of their own.
|
||||
if window_require_pages < self.full_window_pages:
|
||||
self._release_swa_host(host_indices, cache_actions)
|
||||
return
|
||||
if (
|
||||
target is None
|
||||
or window_require_pages == 0
|
||||
|
||||
@@ -25,6 +25,13 @@ from sglang.srt.mem_cache.base_prefix_cache import (
|
||||
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 (
|
||||
PoolHitPolicy,
|
||||
@@ -71,6 +78,7 @@ from sglang.srt.mem_cache.unified_cache.unified_tree_core import ( # noqa: F401
|
||||
)
|
||||
from sglang.srt.observability.metrics_collector import (
|
||||
STAT_LOGGER_ROLE_STORAGE,
|
||||
StorageMetrics,
|
||||
StorageMetricsCollector,
|
||||
resolve_collector_class,
|
||||
)
|
||||
@@ -227,6 +235,30 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
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,
|
||||
"revoked_insufficient": 0,
|
||||
"revoked_full_miss": 0,
|
||||
"l3_demand_requests": 0,
|
||||
"l3_miss_tokens": 0,
|
||||
"l1l2_miss_tokens": 0,
|
||||
"l3_demand_total_tokens": 0,
|
||||
"l3_sum_rate_all": 0.0,
|
||||
"l3_sum_rate_main_weighted": 0.0,
|
||||
}
|
||||
|
||||
self.reset()
|
||||
logger.info(
|
||||
@@ -316,6 +348,8 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
self.prefetch_loaded_tokens_by_reqid: dict[str, int] = {}
|
||||
self.ongoing_prefetch: dict[str, _OngoingPrefetch] = {}
|
||||
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()
|
||||
@@ -326,6 +360,20 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
|
||||
def init_hicache(self, server_args: ServerArgs, params: CacheInitParams) -> None:
|
||||
"""Initialize HiCache infrastructure."""
|
||||
self.host_memory_mode = server_args.hicache_host_memory_mode
|
||||
if self.host_memory_mode == "buffer_only":
|
||||
# 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,
|
||||
)
|
||||
@@ -368,6 +416,28 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
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":
|
||||
swa = self.components.get(ComponentType.SWA)
|
||||
validate_buffer_only_stack(
|
||||
sidecar_pool_specs=self.sidecar_pool_specs, swa_component=swa
|
||||
)
|
||||
self.buffer_pipeline = BufferModePipeline(
|
||||
cache=self,
|
||||
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 server_args.hicache_write_policy == "write_through" else 2
|
||||
@@ -541,6 +611,9 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
request_by_type: dict[ComponentType, int],
|
||||
tracker: dict[ComponentType, int],
|
||||
) -> 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.
|
||||
for ct in self.tree_components:
|
||||
request_cnt = request_by_type[ct]
|
||||
# Skip eviction walk if request is already met
|
||||
@@ -926,6 +999,10 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
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)
|
||||
@@ -1170,6 +1247,16 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
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(
|
||||
self.tree_core.node_by_id(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)
|
||||
@@ -1249,6 +1336,10 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
@@ -1468,10 +1559,12 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
new_input_tokens: list[int],
|
||||
last_hash: Optional[str] = None,
|
||||
prefix_keys: Optional[list[str]] = None,
|
||||
matched_prefix_tokens: Optional[list[int]] = None,
|
||||
) -> None:
|
||||
if not self.enable_storage or self.cache_controller is None:
|
||||
return
|
||||
|
||||
buffer_mode = self.host_memory_mode == "buffer_only"
|
||||
extra_key, cache_salt = self.tree_core.prefetch_anchor_info(last_host_node_id)
|
||||
prefetch_key = RadixKey(
|
||||
new_input_tokens,
|
||||
@@ -1480,13 +1573,30 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
cache_salt=cache_salt,
|
||||
).page_aligned(self.page_size)
|
||||
prefetch_length = len(prefetch_key)
|
||||
if (
|
||||
prefetch_length < self.prefetch_threshold
|
||||
or self.cache_controller.prefetch_rate_limited()
|
||||
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
|
||||
return
|
||||
if not buffer_mode and self.cache_controller.prefetch_rate_limited():
|
||||
stats["declined_rate_limited"] += 1
|
||||
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
|
||||
|
||||
anchor_lock_params = self.inc_host_lock_ref(last_host_node_id).to_dec_params()
|
||||
# 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:
|
||||
@@ -1517,10 +1627,21 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
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],
|
||||
)
|
||||
self.dec_host_lock_ref(last_host_node_id, anchor_lock_params)
|
||||
if anchor_lock_params is not None:
|
||||
self.dec_host_lock_ref(last_host_node_id, anchor_lock_params)
|
||||
return
|
||||
|
||||
aux_xfers = [x for xfers in comp_xfers.values() for x in xfers]
|
||||
@@ -1532,6 +1653,13 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
prefix_keys,
|
||||
extra_pools=aux_xfers or None,
|
||||
)
|
||||
stats["issued"] += 1
|
||||
# Snapshots for the L3 miss accounting at the query outcome (the
|
||||
# hit/revoke drains): requested span and total prompt length.
|
||||
operation.stats_requested_tokens = prefetch_length
|
||||
operation.stats_total_tokens = prefetch_length + len(
|
||||
matched_prefix_tokens or []
|
||||
)
|
||||
self.ongoing_prefetch[req_id] = _OngoingPrefetch(
|
||||
last_host_node_id,
|
||||
prefetch_key,
|
||||
@@ -1540,7 +1668,12 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
anchor_lock_params,
|
||||
comp_xfers,
|
||||
)
|
||||
self.cache_controller.prefetch_tokens_occupied += len(prefetch_key)
|
||||
if buffer_mode:
|
||||
self.buffer_pipeline.set_prefix_ctx(req_id, matched_prefix_tokens)
|
||||
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 (
|
||||
@@ -1622,6 +1755,16 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
# Hybrid all-or-nothing check failed; result already discarded.
|
||||
return True
|
||||
|
||||
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.
|
||||
self.cache_controller.append_host_mem_release(
|
||||
host_indices[min_completed_tokens:completed_tokens]
|
||||
)
|
||||
return self.buffer_pipeline.stage_completed_prefetch(
|
||||
req_id, min_completed_tokens, hash_value
|
||||
)
|
||||
|
||||
fetched_key = prefetch_key[:min_completed_tokens]
|
||||
insert_result = self.tree_core.insert_host(
|
||||
last_host_node_id,
|
||||
@@ -1751,9 +1894,14 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
host_indices=host_indices[:completed_tokens],
|
||||
extra_pools=pool_transfers,
|
||||
)
|
||||
self.dec_host_lock_ref(last_host_node_id, anchor_lock_params)
|
||||
if anchor_lock_params is not None:
|
||||
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)
|
||||
if self.buffer_pipeline is not None:
|
||||
self.buffer_pipeline.pop_prefix_ctx(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
|
||||
logger.warning(
|
||||
"HiCache hybrid prefetch discarded req=%s completed=%d requested=%d",
|
||||
@@ -1773,8 +1921,27 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
def pop_prefetch_loaded_tokens(self, req_id: str) -> int:
|
||||
return self.prefetch_loaded_tokens_by_reqid.pop(req_id, 0)
|
||||
|
||||
def staged_prefetch_tokens(self, req_id: str) -> int:
|
||||
"""Tokens a staged buffer-mode prefetch would splice (0 = no hold);
|
||||
surfaced by the scheduler as the request's host_hit_length."""
|
||||
if self.buffer_pipeline is None:
|
||||
return 0
|
||||
return self.buffer_pipeline.staged_prefetch_tokens(req_id)
|
||||
|
||||
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)
|
||||
|
||||
def release_aborted_request(self, rid: str) -> None:
|
||||
self.prefetch_loaded_tokens_by_reqid.pop(rid, None)
|
||||
if (
|
||||
self.buffer_pipeline is not None
|
||||
and self.buffer_pipeline.release_aborted_staged(rid)
|
||||
):
|
||||
return
|
||||
if rid not in self.ongoing_prefetch:
|
||||
return
|
||||
|
||||
@@ -1793,13 +1960,73 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
|
||||
completed_tokens, _ = self.cache_controller.terminate_prefetch(operation)
|
||||
self._barrier_attn_groups()
|
||||
self.dec_host_lock_ref(last_host_node_id, anchor_lock_params)
|
||||
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.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],
|
||||
)
|
||||
self.cache_controller.prefetch_tokens_occupied -= len(prefetch_key)
|
||||
# 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
|
||||
total = max(operation.stats_total_tokens, requested, 1)
|
||||
stats["l3_demand_requests"] += 1
|
||||
stats["l1l2_miss_tokens"] += requested
|
||||
stats["l3_miss_tokens"] += miss
|
||||
stats["l3_demand_total_tokens"] += total
|
||||
stats["l3_sum_rate_all"] += miss / total
|
||||
stats["l3_sum_rate_main_weighted"] += (miss / requested) * total
|
||||
|
||||
def prefetch_outcome_stats_snapshot(self) -> dict:
|
||||
"""Cumulative counters + instantaneous occupancy, in the schema
|
||||
log_prefetch_stats consumers expect."""
|
||||
cc = self.cache_controller
|
||||
cap = max(cc.prefetch_capacity_limit, 1)
|
||||
return {
|
||||
**self._prefetch_outcome_stats,
|
||||
"occupancy_ratio": cc.prefetch_tokens_occupied / cap,
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -1809,17 +2036,27 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
last_host_node_id,
|
||||
prefetch_key,
|
||||
_host_indices,
|
||||
_operation,
|
||||
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)
|
||||
cc = self.cache_controller
|
||||
cc.append_host_mem_release(
|
||||
extra_pools=[x for xfers in comp_xfers.values() for x in xfers]
|
||||
)
|
||||
self.dec_host_lock_ref(last_host_node_id, anchor_lock_params)
|
||||
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 - len(prefetch_key)
|
||||
0,
|
||||
cc.prefetch_tokens_occupied
|
||||
- self._prefetch_occupied_span(prefetch_key, _host_indices),
|
||||
)
|
||||
|
||||
def _drain_storage_control_queues_impl(
|
||||
@@ -1842,56 +2079,100 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
drained += 1
|
||||
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)
|
||||
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
|
||||
alloc_len = operation.storage_hit_count
|
||||
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(
|
||||
operation.storage_hit_count,
|
||||
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._revoke_pending_prefetch(req_id)
|
||||
return True
|
||||
|
||||
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
|
||||
info = self.ongoing_prefetch.get(req_id)
|
||||
if info is None:
|
||||
# request already aborted/cleaned up, skip
|
||||
# Request already aborted/cleaned up; still flush the
|
||||
# query's absent-hash feedback.
|
||||
self._invalidate_absent_from_hit_query(operation)
|
||||
continue
|
||||
if operation.is_terminated():
|
||||
# request was aborted while the storage query was in flight
|
||||
# Aborted while the storage query was in flight.
|
||||
self._revoke_pending_prefetch(req_id)
|
||||
continue
|
||||
if operation.storage_hit_count < self.prefetch_threshold:
|
||||
# not to prefetch if not enough benefits
|
||||
# Below-threshold hit: classify + feed the L3 miss
|
||||
# accounting, then revoke (not enough benefit).
|
||||
self._account_prefetch_outcome(operation, revoked=True)
|
||||
self._revoke_pending_prefetch(req_id)
|
||||
continue
|
||||
|
||||
alloc_len = operation.storage_hit_count
|
||||
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:
|
||||
# Memory-pressure fallback: a shorter page-aligned prefix.
|
||||
available_size = cc.mem_pool_host.available_size()
|
||||
alloc_len = min(
|
||||
operation.storage_hit_count,
|
||||
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:
|
||||
self._revoke_pending_prefetch(req_id)
|
||||
continue
|
||||
|
||||
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)
|
||||
cc.prefetch_buffer.put(operation)
|
||||
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_backup():
|
||||
drained = 0
|
||||
for operation in _drain_queue(cc.ack_backup_queue, n_backup):
|
||||
drained += 1
|
||||
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 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
|
||||
@@ -2054,6 +2335,9 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
logger.error("Failed to clear hierarchical cache storage backend: %s", e)
|
||||
return False
|
||||
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()
|
||||
logger.info("Hierarchical cache storage backend cleared successfully!")
|
||||
return ok
|
||||
|
||||
@@ -2205,6 +2489,11 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
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)
|
||||
@@ -2233,7 +2522,10 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
params: InitLoadBackParams,
|
||||
) -> tuple[torch.Tensor, NodeId]:
|
||||
"""Prepare KV cache loading from host to device.
|
||||
Returns (device_indices, last_node) tuple."""
|
||||
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
|
||||
@@ -2306,10 +2598,16 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
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:
|
||||
self.storage_metrics_collector.log_storage_metrics(
|
||||
self.cache_controller.storage_backend.get_stats()
|
||||
)
|
||||
storage_metrics = self.cache_controller.storage_backend.get_stats()
|
||||
if storage_metrics is None:
|
||||
# Backends without native stats (e.g. file) still carry the
|
||||
# controller-side prefetch outcome counters.
|
||||
storage_metrics = StorageMetrics()
|
||||
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."""
|
||||
@@ -2464,9 +2762,15 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
|
||||
# Pass ongoing ops as lightweight (id, node_id) pairs so the tree core
|
||||
# can resolve + validate them without reaching into Controller state.
|
||||
ongoing_write_through = [
|
||||
(nid, wt.node_id) for nid, wt in self.ongoing_write_through.items()
|
||||
]
|
||||
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()
|
||||
]
|
||||
|
||||
@@ -1852,9 +1852,11 @@ class StorageMetricsCollector(_StatLoggerDIMixin):
|
||||
labels: Dict[str, str],
|
||||
):
|
||||
from prometheus_client import Counter as _PromCounter
|
||||
from prometheus_client import Gauge as _PromGauge
|
||||
from prometheus_client import Histogram as _PromHistogram
|
||||
|
||||
Counter = self._counter_cls or _PromCounter
|
||||
Gauge = self._gauge_cls or _PromGauge
|
||||
Histogram = self._histogram_cls or _PromHistogram
|
||||
|
||||
self.labels = labels
|
||||
@@ -1871,6 +1873,21 @@ class StorageMetricsCollector(_StatLoggerDIMixin):
|
||||
labelnames=labels.keys(),
|
||||
)
|
||||
|
||||
self.backup_dropped_tokens_total = Counter(
|
||||
name="sglang:hicache_backup_dropped_tokens_total",
|
||||
documentation="Buffer-mode backup tokens dropped by write-path rate "
|
||||
"limiting (backlog cap or dropped-parent cascade).",
|
||||
labelnames=labels.keys(),
|
||||
)
|
||||
|
||||
self.prefetch_aux_alloc_failed_tokens_total = Counter(
|
||||
name="sglang:hicache_prefetch_aux_alloc_failed_tokens_total",
|
||||
documentation="Prefetch tokens abandoned because an aux pool "
|
||||
"(e.g. the SWA trailing window) could not allocate host staging "
|
||||
"— the whole storage fetch is forfeited, not just the aux part.",
|
||||
labelnames=labels.keys(),
|
||||
)
|
||||
|
||||
bucket_io = [
|
||||
1,
|
||||
5,
|
||||
@@ -1925,6 +1942,16 @@ class StorageMetricsCollector(_StatLoggerDIMixin):
|
||||
if backuped_tokens > 0:
|
||||
self.backuped_tokens_total.labels(**self.labels).inc(backuped_tokens)
|
||||
|
||||
def log_backup_dropped_tokens(self, dropped_tokens: int):
|
||||
if dropped_tokens > 0:
|
||||
self.backup_dropped_tokens_total.labels(**self.labels).inc(dropped_tokens)
|
||||
|
||||
def log_prefetch_aux_alloc_failed_tokens(self, num_tokens: int):
|
||||
if num_tokens > 0:
|
||||
self.prefetch_aux_alloc_failed_tokens_total.labels(**self.labels).inc(
|
||||
num_tokens
|
||||
)
|
||||
|
||||
def _log_histogram(self, histogram, data: Union[int, float]):
|
||||
histogram.labels(**self.labels).observe(data)
|
||||
|
||||
|
||||
@@ -2668,14 +2668,22 @@ class ServerArgs:
|
||||
enable_hierarchical_cache: A[bool, "Enable hierarchical cache", NS("memory")] = (
|
||||
False
|
||||
)
|
||||
hicache_host_memory_mode: A[
|
||||
str,
|
||||
Arg(
|
||||
help="Whether host memory is a persistent HiCache tier (cache) or a transient staging buffer between GPU and the storage backend (buffer_only). buffer_only requires --hicache-storage-backend.",
|
||||
choices=["cache", "buffer_only"],
|
||||
),
|
||||
NS("memory"),
|
||||
] = "cache"
|
||||
hicache_ratio: A[
|
||||
Optional[float],
|
||||
"The ratio of the size of host KV cache memory pool to the size of device pool. Defaults to 2.0, or 1.0 for host-pool decode retraction.",
|
||||
"The ratio of the size of host KV cache memory pool to the size of device pool. Defaults to 2.0 in cache mode, 1.2 in buffer_only mode, or 1.0 for host-pool decode retraction.",
|
||||
NS("memory"),
|
||||
] = None
|
||||
hicache_size: A[
|
||||
int,
|
||||
"The size of host KV cache memory pool in gigabytes, which will override the hicache_ratio if set.",
|
||||
"The size of host KV cache memory pool in gigabytes. Overrides --hicache-ratio in either host memory mode.",
|
||||
NS("memory"),
|
||||
] = 0
|
||||
hicache_write_policy: A[
|
||||
@@ -2714,6 +2722,7 @@ class ServerArgs:
|
||||
help="The storage backend for hierarchical KV cache. Built-in backends: file, mooncake, hf3fs, nixl, aibrix. For dynamic backend, use --hicache-storage-backend-extra-config to specify: backend_name (custom name), module_path (Python module path), class_name (backend class name).",
|
||||
choices=[
|
||||
"file",
|
||||
"sim",
|
||||
"mooncake",
|
||||
"hf3fs",
|
||||
"nixl",
|
||||
@@ -7393,8 +7402,20 @@ class ServerArgs:
|
||||
)
|
||||
|
||||
def _handle_hicache_ratio_default(self):
|
||||
"""Default the host/device ratio per host memory mode.
|
||||
|
||||
Runs before the dummy-model boundary: direct HostKVCache consumers
|
||||
(unit fixtures, dummy-model launches) must never see a None ratio.
|
||||
buffer_only stages in flight rather than retaining, so it needs only
|
||||
enough to cover the write backlog plus parked prefetches.
|
||||
|
||||
A decode server keeps the ratio unset here: kv_cache_builder resolves
|
||||
it against the retraction-backup backend (1.0 for host_pool, else 2.0).
|
||||
"""
|
||||
if self.hicache_ratio is None and self.disaggregation_mode != "decode":
|
||||
self.hicache_ratio = 2.0
|
||||
self.hicache_ratio = (
|
||||
1.2 if self.hicache_host_memory_mode == "buffer_only" else 2.0
|
||||
)
|
||||
|
||||
def _handle_hicache(self):
|
||||
"""Normalize hicache-related knobs into a valid runtime configuration.
|
||||
@@ -7414,6 +7435,8 @@ class ServerArgs:
|
||||
):
|
||||
return
|
||||
|
||||
self._validate_hicache_host_memory_mode()
|
||||
|
||||
# Step 1: Initial layout-io compatibility normalization.
|
||||
self._resolve_layout_io_compatibility()
|
||||
|
||||
@@ -7423,6 +7446,51 @@ class ServerArgs:
|
||||
# Step 3: DCP compatibility for the L2 (device<->host) path.
|
||||
self._resolve_hicache_dcp_compatibility()
|
||||
|
||||
def _validate_hicache_host_memory_mode(self):
|
||||
if self.hicache_host_memory_mode not in ("cache", "buffer_only"):
|
||||
raise ValueError(
|
||||
"hicache_host_memory_mode must be 'cache' or 'buffer_only', "
|
||||
f"got {self.hicache_host_memory_mode!r}"
|
||||
)
|
||||
|
||||
# Both modes are defaulted upstream (a decode server resolves the
|
||||
# ratio later, in kv_cache_builder), so this fires only if that
|
||||
# defaulting regresses -- never build an unsized host pool.
|
||||
if (
|
||||
self.hicache_size <= 0
|
||||
and self.hicache_ratio is None
|
||||
and self.disaggregation_mode != "decode"
|
||||
):
|
||||
raise ValueError(
|
||||
f"--hicache-host-memory-mode {self.hicache_host_memory_mode} "
|
||||
"requires a host pool size: pass --hicache-size or "
|
||||
"--hicache-ratio."
|
||||
)
|
||||
|
||||
if self.hicache_host_memory_mode == "cache":
|
||||
return
|
||||
|
||||
if self.hicache_storage_backend is None:
|
||||
raise ValueError(
|
||||
"--hicache-host-memory-mode buffer_only requires a storage backend "
|
||||
"(--hicache-storage-backend): host memory is only a staging buffer "
|
||||
"and all cached data lives in storage."
|
||||
)
|
||||
if self.hicache_write_policy == "write_back":
|
||||
raise ValueError(
|
||||
"--hicache-host-memory-mode buffer_only does not support "
|
||||
"--hicache-write-policy write_back; use write_through or "
|
||||
"write_through_selective."
|
||||
)
|
||||
if self.disaggregation_mode == "decode":
|
||||
raise ValueError(
|
||||
"--hicache-host-memory-mode buffer_only is not supported on "
|
||||
"decode instances: the decode-side prefetch and offload paths "
|
||||
"bypass the buffer-mode pipeline, fetching without its prefix "
|
||||
"context and never consuming its staged holds. Prefill "
|
||||
"instances share the standard scheduler path and are supported."
|
||||
)
|
||||
|
||||
def _resolve_hicache_dcp_compatibility(self):
|
||||
if self.dcp_size <= 1 or not self.enable_hierarchical_cache:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user