hicache L3: circuit breaker for Store transfer failures, TCP queue limits for Mooncake <= 0.3.13

A Store transfer that cannot complete blocks for Mooncake's fixed 60 s timeout. Open a breaker on the first
transfer failure or a get slower than DEEPINFER_HICACHE_L3_SLOW_S (5 s); while open, skip reads and drop queued
puts; close after DEEPINFER_HICACHE_L3_COOLDOWN_S (30 s, doubling on consecutive trips, capped at 10 min).

Mooncake <= 0.3.13 queues at most 1024 TCP transfers per peer, fewer than one long prompt's batch put; default
MC_TCP_MAX_QUEUED_TRANSFERS_PER_PEER / MC_TCP_MAX_PENDING_ADMISSIONS_PER_PEER to 65535 (the default since
kvcache-ai/Mooncake#3710) unless set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
whn09 2026-10-23 21:56:26 +08:00
parent 2ff094fddc
commit 3657e00592
3 changed files with 124 additions and 8 deletions

View File

@ -322,13 +322,14 @@ class HostPrefixCache:
return 0
max_pages = limit // bs
self._extend(chain, max_pages)
if self.l3 is not None and not chain.l3_tried:
if self.l3 is not None and not chain.l3_tried and self.l3.readable():
chain.l3_tried = True
try:
self._l3_fill(chain, max_pages, limit)
except Exception: # noqa: BLE001 - fall back to the host-only lookup
logger.exception("host prefix cache: L3 fetch failed for %s", request_id)
self.stats.l3_errors += 1
self.l3.breaker.trip("L3 fetch raised")
matched = 0
for h in chain.hashes[:max_pages]:
e = self.pages.get(h)

View File

@ -18,6 +18,17 @@ Objects
Data moves between the Store and the (registered) pinned host pools with batch_put_from_multi_buffers /
batch_get_into_multi_buffers, so no extra copies. Puts run on a background thread (write-behind); the engine thread
learns about completions through drain_done(). Gets are synchronous (called from try_resume).
Failure handling. A Store transfer that cannot complete blocks for Mooncake's fixed 60 s transfer timeout, so one bad
get stalls the engine thread that long. A circuit breaker therefore opens on the first transfer failure (any put or
get error other than "not found" / "already exists") or on a get slower than DEEPINFER_HICACHE_L3_SLOW_S (default 5 s).
While it is open, reads are skipped (host-only lookup) and queued puts are dropped. It closes after
DEEPINFER_HICACHE_L3_COOLDOWN_S (default 30 s, doubled on each consecutive trip up to 10 min).
TCP transport limits. Mooncake <= 0.3.13 queues at most 1024 transfers per peer, and one long prompt stores more
buffers than that in a single batch put; the rejected transfers hang until the 60 s timeout. Unless they are already
set, MC_TCP_MAX_QUEUED_TRANSFERS_PER_PEER and MC_TCP_MAX_PENDING_ADMISSIONS_PER_PEER default to 65535, which is the
default since kvcache-ai/Mooncake#3710.
"""
from __future__ import annotations
@ -37,6 +48,8 @@ logger = init_logger(__name__)
ENV = "DEEPINFER_HICACHE_MOONCAKE_STORE"
FORMAT_VERSION = 1
OBJECT_NOT_FOUND, OBJECT_ALREADY_EXISTS = -704, -705
TCP_ENV_DEFAULTS = {"MC_TCP_MAX_QUEUED_TRANSFERS_PER_PEER": "65535", "MC_TCP_MAX_PENDING_ADMISSIONS_PER_PEER": "65535"}
def config_from_env() -> dict | None:
@ -55,8 +68,36 @@ def layout_fingerprint(parts: dict[str, Any]) -> str:
return f"v{FORMAT_VERSION}-{hashlib.sha256(blob).hexdigest()[:16]}"
class _Breaker:
"""Opens on a transfer failure; closes after a cooldown that doubles on consecutive trips."""
def __init__(self, cooldown_s: float, max_cooldown_s: float = 600.0) -> None:
self.base, self.max = cooldown_s, max_cooldown_s
self.cooldown = cooldown_s
self.open_until = 0.0
self.trips = 0
self._lock = threading.Lock()
def allow(self) -> bool:
return time.monotonic() >= self.open_until
def trip(self, why: str) -> None:
with self._lock:
now = time.monotonic()
if now < self.open_until: # already open (e.g. the put thread and a get failed together)
return
if now - self.open_until > self.cooldown: # healthy for a while since the last trip: reset the backoff
self.cooldown = self.base
self.open_until = now + self.cooldown
self.trips += 1
logger.warning("HostPrefixCache L3: %s; Store disabled for %.0f s", why, self.cooldown)
self.cooldown = min(self.cooldown * 2, self.max)
class MooncakeL3:
def __init__(self, config: dict, fingerprint: str, group_pages: int) -> None:
for k, v in TCP_ENV_DEFAULTS.items(): # read by Mooncake's TCP transport when setup() creates it
os.environ.setdefault(k, v)
from mooncake.store import MooncakeDistributedStore # optional dependency (pip install mooncake-transfer-engine)
cfg = dict(config)
@ -71,8 +112,10 @@ class MooncakeL3:
self._state_prefix = f"state/{fingerprint}/"
self._puts: queue.Queue = queue.Queue()
self._done: collections.deque = collections.deque() # tokens of finished puts (appended by the worker thread)
self.put_objects = self.put_failed = self.put_bytes = 0
self.put_objects = self.put_failed = self.put_bytes = self.put_dropped = 0
self.put_seconds = 0.0
self.slow_get_s = float(os.environ.get("DEEPINFER_HICACHE_L3_SLOW_S", "5"))
self.breaker = _Breaker(float(os.environ.get("DEEPINFER_HICACHE_L3_COOLDOWN_S", "30")))
threading.Thread(target=self._put_worker, name="hicache-l3-put", daemon=True).start()
logger.info("HostPrefixCache L3: Mooncake Store ready (fingerprint %s, %d pages per object)", fingerprint, group_pages)
@ -102,22 +145,33 @@ class MooncakeL3:
def _put_worker(self) -> None:
while True:
keys, ptrs, sizes, token = self._puts.get()
if not self.breaker.allow(): # Store unhealthy: drop the put, release the host entries right away
self.put_dropped += len(keys)
self._done.append(token)
continue
t0 = time.perf_counter()
try:
rcs = self.store.batch_put_from_multi_buffers(keys, ptrs, sizes)
# an object that already exists (another rank or instance stored it first) is not a failure
failed = sum(1 for rc in rcs if rc not in (0, -705))
self.put_objects += len(keys) - failed
self.put_failed += failed
bad = [rc for rc in rcs if rc not in (0, OBJECT_ALREADY_EXISTS)]
self.put_objects += len(keys) - len(bad)
self.put_failed += len(bad)
self.put_bytes += sum(sum(s) for s, rc in zip(sizes, rcs, strict=True) if rc == 0)
if bad:
self.breaker.trip(f"batch put failed for {len(bad)}/{len(keys)} objects (rc {bad[0]})")
except Exception: # noqa: BLE001 - a Store problem must not take the engine down
logger.exception("HostPrefixCache L3: batch put failed")
self.put_failed += len(keys)
self.breaker.trip("batch put raised")
self.put_seconds += time.perf_counter() - t0
self._done.append(token)
# ---------------------------------------------------------------- read (engine thread)
def readable(self) -> bool:
"""False while the breaker is open: skip the Store and use the host cache only."""
return self.breaker.allow()
def exists(self, keys: list[str]) -> list[bool]:
if not keys:
return []
@ -127,8 +181,17 @@ class MooncakeL3:
"""Read each object into its list of buffers; True where the whole object arrived."""
if not keys:
return []
t0 = time.perf_counter()
rcs = self.store.batch_get_into_multi_buffers(keys, ptrs, sizes)
return [rc == sum(s) for rc, s in zip(rcs, sizes, strict=True)]
dt = time.perf_counter() - t0
ok = [rc == sum(s) for rc, s in zip(rcs, sizes, strict=True)]
# "not found" just means evicted since exists(); anything else is a transfer problem
bad = [rc for rc, good in zip(rcs, ok, strict=True) if not good and rc != OBJECT_NOT_FOUND]
if bad:
self.breaker.trip(f"batch get failed for {len(bad)}/{len(keys)} objects (rc {bad[0]}, {dt:.1f} s)")
elif dt > self.slow_get_s:
self.breaker.trip(f"batch get of {len(keys)} objects took {dt:.1f} s")
return ok
def stats_dict(self) -> dict[str, int | float]:
return dict(
@ -137,4 +200,7 @@ class MooncakeL3:
l3_put_gib=round(self.put_bytes / 2**30, 3),
l3_put_seconds=round(self.put_seconds, 3),
l3_put_queue=self._puts.qsize(),
l3_put_dropped=self.put_dropped,
l3_breaker_trips=self.breaker.trips,
l3_breaker_open=int(not self.breaker.allow()),
)

View File

@ -1,15 +1,18 @@
"""HostPrefixCache L3 (Mooncake Store) logic against a dict-backed fake store: write-behind of page groups and
states, a second cache (another rank / instance) extending its empty host pool from L3, no re-puts, and a fetch that
loses an object between exists() and get(). Same fake engine as test_host_cache.py. Needs one GPU."""
states, a second cache (another rank / instance) extending its empty host pool from L3, no re-puts, a fetch that
loses an object between exists() and get(), and the circuit breaker after a transfer failure. Same fake engine as
test_host_cache.py. Needs one GPU."""
from __future__ import annotations
import ctypes
import time
import numpy as np
import pytest
import torch
from deepinfer.hicache.mooncake_store import _Breaker
from tests.hicache.test_host_cache import BLOCK, PAGE, STATE, FakeSeq, _prefill, _tokens, engine # noqa: F401
pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="needs a GPU")
@ -25,8 +28,14 @@ class FakeL3:
self.objects: dict[str, bytes] = {}
self.puts = 0
self.drop_on_get: set[str] = set() # keys that vanish between exists() and get()
self.fail_gets = False # every get is a transfer failure (trips the breaker, like MooncakeL3.get)
self.gets = 0
self.breaker = _Breaker(30.0)
self._done: list[object] = []
def readable(self) -> bool:
return self.breaker.allow()
def register(self, ptr: int, nbytes: int) -> None:
pass
@ -51,6 +60,10 @@ class FakeL3:
return [k in self.objects for k in keys]
def get(self, keys, ptrs, sizes) -> list[bool]: # noqa: ANN001
self.gets += 1
if self.fail_gets:
self.breaker.trip("test transfer failure")
return [False] * len(keys)
ok = []
for k, ps, ss in zip(keys, ptrs, sizes, strict=True):
data = None if k in self.drop_on_get else self.objects.get(k)
@ -142,3 +155,39 @@ def test_object_lost_between_exists_and_get_falls_back(engine, l3): # noqa: F81
assert b_cache.try_resume(b, "b", b.tokens) == 0 # miss, sequence untouched
assert b.block_table == [] and b_cache.stats.l3_get_failed == 1
assert len(b_cache.pages) == 0 and len(b_cache.free_pages) == b_cache.num_pages
def test_transfer_failure_opens_the_breaker(engine, l3): # noqa: F811
from deepinfer.hicache import HostPrefixCache
a_cache = HostPrefixCache(engine, size_bytes=50 * PAGE, num_state_slots=8)
a = FakeSeq("a", _tokens(1000, 1))
_prefill(engine, a_cache, a)
_settle(a_cache)
l3.fail_gets = True
b_cache = HostPrefixCache(engine, size_bytes=50 * PAGE, num_state_slots=8)
b = FakeSeq("b", np.concatenate([a.tokens, _tokens(500, 2)]))
assert b_cache.try_resume(b, "b", b.tokens) == 0 and l3.gets == 1
assert not l3.readable() and l3.breaker.trips == 1
# while open, later requests do not touch the Store at all (no 60 s stalls), even once it is healthy again
l3.fail_gets = False
c = FakeSeq("c", np.concatenate([a.tokens, _tokens(500, 3)]))
assert b_cache.try_resume(c, "c", c.tokens) == 0 and l3.gets == 1
# after the cooldown the Store is used again
l3.breaker.open_until = 0.0
d = FakeSeq("d", np.concatenate([a.tokens, _tokens(500, 4)]))
assert b_cache.try_resume(d, "d", d.tokens) == 4 * BLOCK and l3.gets == 2
def test_breaker_backoff() -> None:
br = _Breaker(10.0, max_cooldown_s=25.0)
assert br.allow()
br.trip("x")
assert not br.allow() and br.trips == 1
br.trip("y") # already open: no second trip
assert br.trips == 1
for want in (20.0, 25.0): # consecutive trips right after reopening double the cooldown, capped
br.open_until = time.monotonic() - 1e-3
br.trip("z")
assert abs(br.open_until - time.monotonic() - want) < 1.0