Fix buffer-mode HiCache load-back ownership races; add optional prefetch anchor lock (#35769)
Signed-off-by: Zhiqiang Xie <zqx@meta.com>
This commit is contained in:
@@ -684,6 +684,10 @@ class Envs:
|
||||
SGLANG_HICACHE_FILE_BACKEND_ENABLE_METADATA_CACHE = EnvBool(False)
|
||||
# Positive cache TTL for filesystem metadata lookups (-1 disables positive expiration)
|
||||
SGLANG_HICACHE_FILE_BACKEND_METADATA_TTL = EnvFloat(5.0)
|
||||
# Buffer mode: pin a staged prefetch's device anchor from IO commit to
|
||||
# consumption so eviction cannot waste the fetch; cap = fraction of pool.
|
||||
SGLANG_ENABLE_HICACHE_BUFFER_ANCHOR_LOCK = EnvBool(False)
|
||||
SGLANG_HICACHE_BUFFER_ANCHOR_LOCK_CAP = EnvFloat(0.5)
|
||||
SGLANG_HICACHE_NIXL_BACKEND_STORAGE_DIR = EnvStr(None)
|
||||
# Enable O_DIRECT when opening NIXL POSIX backend files (bypasses OS page cache).
|
||||
# Disable with SGLANG_HICACHE_NIXL_USE_DIRECT_IO=0 or via the
|
||||
|
||||
@@ -33,6 +33,7 @@ from typing import TYPE_CHECKING, Optional
|
||||
import msgspec
|
||||
import torch
|
||||
|
||||
from sglang.srt.environ import envs
|
||||
from sglang.srt.managers.cache_controller import HICACHE_WRITE_STAGING_POOL_FRACTION
|
||||
from sglang.srt.mem_cache.base_prefix_cache import (
|
||||
DecLockRefParams,
|
||||
@@ -120,6 +121,14 @@ class _OngoingBufferLoadBack(msgspec.Struct):
|
||||
hash_values: list[str]
|
||||
|
||||
|
||||
class _AnchorLock(msgspec.Struct):
|
||||
"""Pins a staged prefetch's device anchor from IO commit to consumption."""
|
||||
|
||||
node_id: NodeId
|
||||
lock_params: DecLockRefParams
|
||||
tokens: int
|
||||
|
||||
|
||||
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
|
||||
@@ -199,6 +208,15 @@ class BufferModePipeline:
|
||||
# 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
|
||||
# Anchor-lock knobs; the cap keeps queued holds from pinning the pool.
|
||||
self.anchor_lock_enabled = envs.SGLANG_ENABLE_HICACHE_BUFFER_ANCHOR_LOCK.get()
|
||||
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
|
||||
|
||||
kvcache = cache.token_to_kv_pool_allocator.get_kvcache()
|
||||
full_pool = kvcache.full_kv_pool if isinstance(kvcache, SWAKVPool) else kvcache
|
||||
self.anchor_lock_cap_tokens = int(
|
||||
envs.SGLANG_HICACHE_BUFFER_ANCHOR_LOCK_CAP.get() * full_pool.size
|
||||
)
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
@@ -226,6 +244,10 @@ class BufferModePipeline:
|
||||
self.write_staged_tokens_ = 0
|
||||
self.write_backlog_tokens_ = 0
|
||||
self._backlog_cap_hits = 0
|
||||
# rid-keyed anchor locks; released idempotently at every exit.
|
||||
self.anchor_locks: dict[str, _AnchorLock] = {}
|
||||
self.anchor_locked_tokens_ = 0
|
||||
self._anchor_lock_cap_skips = 0
|
||||
|
||||
def is_idle(self) -> bool:
|
||||
"""No queued writes, staged prefetches, or storage writes in flight
|
||||
@@ -586,6 +608,61 @@ class BufferModePipeline:
|
||||
|
||||
# ---- load back pipeline (storage -> staging -> device) ----
|
||||
|
||||
def try_lock_anchor(self, req_id: str, anchor_node_id: NodeId) -> None:
|
||||
"""Pin the device anchor at IO commit so eviction cannot invalidate
|
||||
the splice; drift resolves at consumption, and the cap keeps queued
|
||||
holds from making the pool unevictable (over-cap launches unlocked)."""
|
||||
if not self.anchor_lock_enabled or req_id in self.anchor_locks:
|
||||
return
|
||||
prefix_tokens = self._prefetch_prefix_ctx.get(req_id)
|
||||
if not prefix_tokens:
|
||||
return # root anchor: nothing to pin
|
||||
matched_len = len(prefix_tokens)
|
||||
if self.anchor_locked_tokens_ + matched_len > self.anchor_lock_cap_tokens:
|
||||
self._anchor_lock_cap_skips += 1
|
||||
if (
|
||||
self._anchor_lock_cap_skips <= 3
|
||||
or self._anchor_lock_cap_skips % 1000 == 0
|
||||
):
|
||||
logger.warning(
|
||||
"HiCache anchor-lock cap reached (skip %d): locked=%d "
|
||||
"want=%d cap=%d; launching unlocked.",
|
||||
self._anchor_lock_cap_skips,
|
||||
self.anchor_locked_tokens_,
|
||||
matched_len,
|
||||
self.anchor_lock_cap_tokens,
|
||||
)
|
||||
return
|
||||
cache = self._cache
|
||||
try:
|
||||
node = cache.tree_core.node_by_id(anchor_node_id)
|
||||
except KeyError:
|
||||
return # anchor deleted; fetch unlocked
|
||||
if node.component_data[BASE_COMPONENT_TYPE].value is None:
|
||||
# Evicted since enqueue; fetch unlocked.
|
||||
logger.warning("HiCache anchor evicted before IO commit req=%s", req_id)
|
||||
return
|
||||
lock_params = cache.inc_lock_ref(anchor_node_id).to_dec_params()
|
||||
self.anchor_locks[req_id] = _AnchorLock(
|
||||
node_id=anchor_node_id,
|
||||
lock_params=lock_params,
|
||||
tokens=matched_len,
|
||||
)
|
||||
self.anchor_locked_tokens_ += matched_len
|
||||
|
||||
def release_anchor_lock(self, req_id: str) -> None:
|
||||
"""Drop a staged prefetch's anchor lock (idempotent; called at every
|
||||
consume/drop/abort exit)."""
|
||||
lock = self.anchor_locks.pop(req_id, None)
|
||||
if lock is None:
|
||||
return
|
||||
self._cache.dec_lock_ref(lock.node_id, lock.lock_params)
|
||||
self.anchor_locked_tokens_ -= lock.tokens
|
||||
assert self.anchor_locked_tokens_ >= 0, (
|
||||
f"anchor-lock accounting corrupted: locked={self.anchor_locked_tokens_} "
|
||||
f"after releasing {req_id}"
|
||||
)
|
||||
|
||||
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."""
|
||||
@@ -627,6 +704,7 @@ class BufferModePipeline:
|
||||
|
||||
if num_tokens == 0 or prefix_tokens is None:
|
||||
# Nothing usable fetched: recompute.
|
||||
self.release_anchor_lock(req_id)
|
||||
cc.append_host_mem_release(
|
||||
host_indices[:num_tokens], extra_pools=aux_xfers or None
|
||||
)
|
||||
@@ -678,10 +756,14 @@ class BufferModePipeline:
|
||||
)
|
||||
|
||||
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."""
|
||||
"""Consume the staged prefetch at prefill admission: device alloc,
|
||||
layer-gated H2D, and a plain insert so downstream sees ordinary tree
|
||||
state; invalid holds drop and the request recomputes.
|
||||
|
||||
Ownership contract: cc.load queues the H2D before insert adjudicates
|
||||
ownership, so the live pre-checks below must prove the insert can
|
||||
only ADD nodes — a dedup would free slots the in-flight copy still
|
||||
targets (queued use-after-free)."""
|
||||
cache = self._cache
|
||||
req = params.req
|
||||
assert req is not None
|
||||
@@ -689,10 +771,12 @@ class BufferModePipeline:
|
||||
unchanged = (empty, req.last_node)
|
||||
f = self.staged_prefetches.pop(req.rid, None)
|
||||
if f is None:
|
||||
self.release_anchor_lock(req.rid)
|
||||
return unchanged
|
||||
cc = cache.cache_controller
|
||||
|
||||
def _drop() -> tuple[torch.Tensor, NodeId]:
|
||||
self.release_anchor_lock(req.rid)
|
||||
self._free_staging_now(f.host_indices, f.aux_xfers)
|
||||
cc.prefetch_tokens_occupied -= f.occupied_tokens
|
||||
return unchanged
|
||||
@@ -700,8 +784,44 @@ class BufferModePipeline:
|
||||
# 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.
|
||||
logger.warning(
|
||||
"HiCache staged prefetch dropped req=%s reason=%s matched=%d "
|
||||
"now=%d tokens_wasted=%d locked=%s",
|
||||
req.rid,
|
||||
"growth" if len(req.prefix_indices) > f.matched_len else "shrink",
|
||||
f.matched_len,
|
||||
len(req.prefix_indices),
|
||||
f.num_tokens,
|
||||
req.rid in self.anchor_locks,
|
||||
)
|
||||
return _drop()
|
||||
|
||||
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
|
||||
|
||||
# Live ownership pre-check: the unified length detects anchor drift,
|
||||
# full_kv_hit_length detects FULL overlap the insert would dedup-free
|
||||
# (an SWA tombstone can mask live FULL from the unified match alone).
|
||||
live = cache.match_prefix(MatchPrefixParams(key=key))
|
||||
if (
|
||||
len(live.device_indices) != f.matched_len
|
||||
or live.full_kv_hit_length != f.matched_len
|
||||
):
|
||||
logger.warning(
|
||||
"HiCache staged prefetch dropped req=%s reason=overlap "
|
||||
"matched=%d live_unified=%d live_full=%d tokens_wasted=%d "
|
||||
"locked=%s",
|
||||
req.rid,
|
||||
f.matched_len,
|
||||
len(live.device_indices),
|
||||
live.full_kv_hit_length,
|
||||
f.num_tokens,
|
||||
req.rid in self.anchor_locks,
|
||||
)
|
||||
return _drop()
|
||||
|
||||
# Evict-before-alloc (mirrors _load_back_transfers): the budget gate
|
||||
@@ -748,13 +868,7 @@ class BufferModePipeline:
|
||||
|
||||
# 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(
|
||||
insert_result = cache.insert(
|
||||
InsertParams(
|
||||
key=key,
|
||||
value=torch.cat([req.prefix_indices, device_indices]),
|
||||
@@ -773,12 +887,24 @@ class BufferModePipeline:
|
||||
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
|
||||
self.release_anchor_lock(req.rid)
|
||||
canonical = m.device_indices[f.matched_len : span_end]
|
||||
if len(m.device_indices) < span_end or not torch.equal(
|
||||
canonical, device_indices
|
||||
):
|
||||
# Fail-stop: the insert freed or replaced slots the in-flight H2D
|
||||
# still targets; continuing risks silent KV corruption.
|
||||
raise RuntimeError(
|
||||
f"HiCache buffer load-back ownership violation req={f.req_id}: "
|
||||
f"insert prefix_len={insert_result.prefix_len} "
|
||||
f"expected={f.matched_len}, adopted={len(m.device_indices)} "
|
||||
f"span_end={span_end}, canonical_matches_incoming="
|
||||
f"{len(m.device_indices) >= span_end and torch.equal(canonical, device_indices)}; "
|
||||
f"in-flight H2D targets freed slots"
|
||||
)
|
||||
# Canonical ownership: return the post-insert tree slice, never the
|
||||
# raw cc.load allocation (torch.equal here; the tree slice is truth).
|
||||
return canonical, 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
|
||||
@@ -796,10 +922,11 @@ class BufferModePipeline:
|
||||
|
||||
cc.prefetch_tokens_occupied -= f.occupied_tokens
|
||||
logger.info(
|
||||
"HiCache prefetch fill committed req=%s filled=%d occupied=%d",
|
||||
"HiCache prefetch fill committed req=%s filled=%d occupied=%d locked=%d",
|
||||
f.req_id,
|
||||
f.num_tokens,
|
||||
cc.prefetch_tokens_occupied,
|
||||
self.anchor_locked_tokens_,
|
||||
)
|
||||
if cache.enable_storage_metrics and cache.storage_metrics_collector is not None:
|
||||
cache.storage_metrics_collector.log_prefetched_tokens(f.num_tokens)
|
||||
@@ -808,6 +935,7 @@ class BufferModePipeline:
|
||||
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."""
|
||||
self.release_anchor_lock(rid)
|
||||
staged = self.staged_prefetches.pop(rid, None)
|
||||
if staged is None:
|
||||
return False
|
||||
|
||||
@@ -1932,6 +1932,7 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
del self.ongoing_prefetch[req_id]
|
||||
if self.buffer_pipeline is not None:
|
||||
self.buffer_pipeline.pop_prefix_ctx(req_id)
|
||||
self.buffer_pipeline.release_anchor_lock(req_id)
|
||||
self.cache_controller.prefetch_tokens_occupied -= (
|
||||
self._prefetch_occupied_span(prefetch_key, host_indices)
|
||||
)
|
||||
@@ -1999,6 +2000,7 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
del self.ongoing_prefetch[rid]
|
||||
if self.buffer_pipeline is not None:
|
||||
self.buffer_pipeline.pop_prefix_ctx(rid)
|
||||
self.buffer_pipeline.release_anchor_lock(rid)
|
||||
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],
|
||||
@@ -2077,6 +2079,7 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
self._invalidate_absent_from_hit_query(operation)
|
||||
if self.buffer_pipeline is not None:
|
||||
self.buffer_pipeline.pop_prefix_ctx(req_id)
|
||||
self.buffer_pipeline.release_anchor_lock(req_id)
|
||||
cc = self.cache_controller
|
||||
cc.append_host_mem_release(
|
||||
extra_pools=[x for xfers in comp_xfers.values() for x in xfers]
|
||||
@@ -2159,6 +2162,10 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
self.ongoing_prefetch[req_id] = info._replace(host_indices=host_indices)
|
||||
if buffer_mode:
|
||||
cc.prefetch_tokens_occupied += alloc_len
|
||||
# IO commit: pin the anchor until consumption. Do not read
|
||||
# attributes off `operation` here — alternative cache
|
||||
# controllers may expose a narrower surface.
|
||||
self.buffer_pipeline.try_lock_anchor(req_id, info.anchor_node_id)
|
||||
cc.prefetch_buffer.put(operation)
|
||||
return True
|
||||
|
||||
|
||||
Reference in New Issue
Block a user