[HiCache] Allow a retraction host pool smaller than the device pool (#35543)

Co-authored-by: cctry <cctry@fb.com>
This commit is contained in:
cctry
2026-08-19 22:59:57 -07:00
committed by GitHub
co-authored by cctry
parent 50dae2d99d
commit 32d98aad13
7 changed files with 203 additions and 52 deletions
+23 -7
View File
@@ -1914,7 +1914,8 @@ def release_req(
tree_cache: BasePrefixCache, tree_cache: BasePrefixCache,
hisparse_coordinator: Optional[HiSparseCoordinator], hisparse_coordinator: Optional[HiSparseCoordinator],
offload_kv: bool = True, offload_kv: bool = True,
) -> None: ) -> bool:
"""Returns False when the KV backup failed and the request cannot be resumed."""
if hisparse_coordinator is not None and not req.finished(): if hisparse_coordinator is not None and not req.finished():
hisparse_coordinator.retract_req(req) hisparse_coordinator.retract_req(req)
@@ -1922,8 +1923,9 @@ def release_req(
# restored later without recompute (see resume_retracted_reqs/load_kv_cache). # restored later without recompute (see resume_retracted_reqs/load_kv_cache).
# Callers that will recompute the KV instead (PD true-retraction rebootstrap) # Callers that will recompute the KV instead (PD true-retraction rebootstrap)
# pass offload_kv=False to skip the wasteful device->host copy. # pass offload_kv=False to skip the wasteful device->host copy.
backup_saved = True
if server_args.disaggregation_mode == "decode" and offload_kv: if server_args.disaggregation_mode == "decode" and offload_kv:
retraction_backup( backup_saved = retraction_backup(
req, req,
tree_cache, tree_cache,
req_to_token_pool, req_to_token_pool,
@@ -1937,6 +1939,7 @@ def release_req(
evict_from_tree_cache(tree_cache, num_tokens) evict_from_tree_cache(tree_cache, num_tokens)
req.reset_for_retract() req.reset_for_retract()
return backup_saved
def retract_all( def retract_all(
@@ -2820,6 +2823,7 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin):
sorted_indices = self._get_decode_retraction_order(self.reqs, server_args) sorted_indices = self._get_decode_retraction_order(self.reqs, server_args)
retracted_reqs = [] retracted_reqs = []
reqs_to_abort: List[Req] = []
first_iter = True first_iter = True
while first_iter or ( while first_iter or (
not self.check_decode_mem(selected_indices=sorted_indices) not self.check_decode_mem(selected_indices=sorted_indices)
@@ -2831,11 +2835,23 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin):
first_iter = False first_iter = False
idx = sorted_indices.pop() idx = sorted_indices.pop()
req = self.reqs[idx] req = self.reqs[idx]
retracted_reqs.append(req)
# release memory and don't insert into the tree because we need the space instantly # release memory and don't insert into the tree because we need the space instantly
self.release_req(idx, len(sorted_indices), server_args) if self.release_req(idx, len(sorted_indices), server_args):
retracted_reqs.append(req)
else:
# The retraction host pool could not hold the backup and the
# device KV is already freed, so the request cannot resume.
req.to_finish = FINISH_ABORT(
"Retraction host KV pool exhausted. Aborting the request.",
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
)
reqs_to_abort.append(req)
logger.warning(
"retract_decode: aborted request %s, retraction host pool "
"exhausted",
req.rid,
)
reqs_to_abort: List[Req] = []
if len(sorted_indices) <= 1 and not self.check_decode_mem( if len(sorted_indices) <= 1 and not self.check_decode_mem(
selected_indices=sorted_indices selected_indices=sorted_indices
): ):
@@ -2910,8 +2926,8 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin):
remaing_req_count: int, remaing_req_count: int,
server_args: ServerArgs, server_args: ServerArgs,
offload_kv: bool = True, offload_kv: bool = True,
): ) -> bool:
release_req( return release_req(
req=self.reqs[idx], req=self.reqs[idx],
remaing_req_count=remaing_req_count, remaing_req_count=remaing_req_count,
server_args=server_args, server_args=server_args,
+6 -3
View File
@@ -144,17 +144,20 @@ def retraction_backup(
req_to_token_pool: ReqToTokenPool, req_to_token_pool: ReqToTokenPool,
token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator, token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator,
backend: str, backend: str,
) -> None: ) -> bool:
"""Returns False when the host pool cannot hold the backup; the caller
aborts the request since its KV cannot be preserved."""
if backend == "cpu_tensor": if backend == "cpu_tensor":
req.offload_kv_cache(req_to_token_pool, token_to_kv_pool_allocator) req.offload_kv_cache(req_to_token_pool, token_to_kv_pool_allocator)
return return True
if backend != "host_pool": if backend != "host_pool":
raise ValueError(f"Unknown retraction backup backend: {backend}") raise ValueError(f"Unknown retraction backup backend: {backend}")
if req.seqlen <= 1: if req.seqlen <= 1:
return return True
unified_cache = cast("UnifiedRadixCache", tree_cache) unified_cache = cast("UnifiedRadixCache", tree_cache)
req.retraction_backup = unified_cache.retraction_backup(req) req.retraction_backup = unified_cache.retraction_backup(req)
return req.retraction_backup is not None
def retraction_restore( def retraction_restore(
@@ -139,6 +139,12 @@ def _register_legacy_hicache_draft(
tree_cache.cache_controller.set_draft_kv_pool(pool, draft_host_pool) tree_cache.cache_controller.set_draft_kv_pool(pool, draft_host_pool)
# Host slots a backup-only retraction pool gets, as a fraction of the device
# pool. Sized well under 1.0 because a retraction burst touches a fraction of
# the device tokens; overflow aborts the request rather than pre-reserving.
BACKUP_ONLY_HICACHE_RATIO = 0.2
def resolve_decode_retraction_backup(*, tp_worker: BaseTpWorker) -> str: def resolve_decode_retraction_backup(*, tp_worker: BaseTpWorker) -> str:
"""Resolve the retraction backend onto the config bags and return it. """Resolve the retraction backend onto the config bags and return it.
@@ -180,10 +186,14 @@ def resolve_decode_retraction_backup(*, tp_worker: BaseTpWorker) -> str:
fields["disaggregation_decode_retraction_backup"] = backend fields["disaggregation_decode_retraction_backup"] = backend
if memory.hicache_ratio is None: if memory.hicache_ratio is None:
# Only a decode server reaches resolution with the ratio unset; host-pool # Only a decode server reaches resolution with the ratio unset. A
# retraction sizes the host pool 1:1 with the device pool, everything # backup-only pool can be small: retractions that overflow it abort their
# else keeps the standard default. # request instead of crashing the scheduler. Sharing the pool with
fields["hicache_ratio"] = 1.0 if backend == "host_pool" else 2.0 # HiCache keeps the standard default.
if backend == "host_pool" and not memory.enable_hierarchical_cache:
fields["hicache_ratio"] = BACKUP_ONLY_HICACHE_RATIO
else:
fields["hicache_ratio"] = 2.0
source = "kv_cache_builder.decode_retraction" source = "kv_cache_builder.decode_retraction"
get_context().override(source, **fields) get_context().override(source, **fields)
@@ -1042,24 +1042,6 @@ class UnifiedRadixCache(BasePrefixCache):
"an MHA or hybrid-SWA HiCache host stack." "an MHA or hybrid-SWA HiCache host stack."
) )
kv_cache = self.token_to_kv_pool_allocator.get_kvcache()
device_pools = {PoolName.KV: kv_cache}
if isinstance(kv_cache, SWAKVPool):
device_pools = {
PoolName.KV: kv_cache.full_kv_pool,
PoolName.SWA: kv_cache.swa_kv_pool,
}
for name, device_pool in device_pools.items():
host_pool = self.host_pool_group.entry_map[name].host_pool
if host_pool.logical_size < device_pool.size:
raise ValueError(
"Retraction host pool is smaller than its device pool: "
f"pool={name}, host_slots={host_pool.logical_size}, "
f"device_slots={device_pool.size}. Increase --hicache-ratio "
"or --hicache-size."
)
for spec in self.sidecar_pool_specs: for spec in self.sidecar_pool_specs:
source_size = self.host_pool_group.entry_map[ source_size = self.host_pool_group.entry_map[
spec.indices_from_pool spec.indices_from_pool
@@ -1138,7 +1120,8 @@ class UnifiedRadixCache(BasePrefixCache):
return 0 return 0
return self.evict_host(num_tokens) return self.evict_host(num_tokens)
def retraction_backup(self, req: Req) -> RetractionBackup: def retraction_backup(self, req: Req) -> Optional[RetractionBackup]:
"""Back up device KV to the host pool; None when it cannot fit after reclaim."""
assert req.seqlen > 1 assert req.seqlen > 1
device_indices, extra_transfers = self._retraction_device_transfers(req) device_indices, extra_transfers = self._retraction_device_transfers(req)
@@ -1147,11 +1130,7 @@ class UnifiedRadixCache(BasePrefixCache):
self._reclaim_retraction_host(len(device_indices)) self._reclaim_retraction_host(len(device_indices))
host_indices = self.host_pool_group.alloc(len(device_indices)) host_indices = self.host_pool_group.alloc(len(device_indices))
if host_indices is None: if host_indices is None:
raise RuntimeError( return None
"Retraction host KV pool exhausted after reclaim: "
f"request={req.rid}, required_slots={len(device_indices)}, "
f"available_slots={self.host_pool_group.available_size()}."
)
resolved = self.cache_controller._resolve_pool_transfers_allocation( resolved = self.cache_controller._resolve_pool_transfers_allocation(
extra_transfers or None, extra_transfers or None,
@@ -1161,10 +1140,7 @@ class UnifiedRadixCache(BasePrefixCache):
) )
if resolved is None and extra_transfers: if resolved is None and extra_transfers:
self.host_pool_group.free(host_indices) self.host_pool_group.free(host_indices)
raise RuntimeError( return None
"Retraction auxiliary host allocation failed after atomic rollback: "
f"request={req.rid}, pools={[x.name for x in extra_transfers]}."
)
backup = RetractionBackup( backup = RetractionBackup(
host_indices=host_indices, host_indices=host_indices,
+1 -1
View File
@@ -2701,7 +2701,7 @@ class ServerArgs:
] = "cache" ] = "cache"
hicache_ratio: A[ hicache_ratio: A[
Optional[float], Optional[float],
"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.", "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 0.2 for backup-only host-pool decode retraction.",
NS("memory"), NS("memory"),
] = None ] = None
hicache_size: A[ hicache_size: A[
@@ -5,6 +5,7 @@ import threading
import time import time
import unittest import unittest
import uuid import uuid
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
@@ -13,6 +14,7 @@ import openai
import requests import requests
from transformers import AutoTokenizer from transformers import AutoTokenizer
from sglang.srt.mem_cache.kv_cache_builder import BACKUP_ONLY_HICACHE_RATIO
from sglang.test.ci.ci_register import register_cuda_ci from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.kits.json_constrained_kit import JSONConstrainedMixin from sglang.test.kits.json_constrained_kit import JSONConstrainedMixin
from sglang.test.kits.pause_generation_kit import PauseResumeInPlaceMixin from sglang.test.kits.pause_generation_kit import PauseResumeInPlaceMixin
@@ -20,6 +22,7 @@ from sglang.test.kits.spec_server_kits import SpecGrammarKit
from sglang.test.run_eval import run_eval from sglang.test.run_eval import run_eval
from sglang.test.server_fixtures.disaggregation_fixture import ( from sglang.test.server_fixtures.disaggregation_fixture import (
PDDisaggregationServerBase, PDDisaggregationServerBase,
assert_process_healthy,
) )
from sglang.test.test_utils import ( from sglang.test.test_utils import (
DEFAULT_DRAFT_MODEL_EAGLE3, DEFAULT_DRAFT_MODEL_EAGLE3,
@@ -292,6 +295,102 @@ class TestDisaggregationMooncakeSpec(
print(f"Retraction speculative {accept_length=:.4f}") print(f"Retraction speculative {accept_length=:.4f}")
self.assertGreater(accept_length, self.min_retraction_accept_length) self.assertGreater(accept_length, self.min_retraction_accept_length)
def test_oversized_backup_aborts_only_its_own_request(self):
# Backup-only host_pool retraction sizes the host pool at a fraction of the
# device pool, so a long enough request cannot be backed up. Derive the
# length from the running server rather than pinning pool sizes, which
# would change what the other cases in this class exercise.
info = requests.get(self.decode_url + "/get_server_info", timeout=30).json()
device_tokens = info["max_total_num_tokens"]
host_slots = int(device_tokens * BACKUP_ONLY_HICACHE_RATIO)
# Over the host pool, but still inside both the device pool and the model
# context — a pool far larger than the context would reject the request
# before it ever reaches retraction.
oversized_len = min(int(device_tokens * 0.4), info["max_req_input_len"] - 1024)
self.assertGreater(
oversized_len,
host_slots,
f"no prompt length both overflows the {host_slots}-slot host pool and "
f"fits the {info['max_req_input_len']}-token context",
)
def oversized_request(seed):
# Sent on its own: a batched /generate fails as a whole once any member
# aborts, which would hide the concurrent traffic's own outcome.
# Generate long enough to still be decoding when a forced retraction
# lands — a short request finishes first and is never retracted.
return requests.post(
self.lb_url + "/generate",
json={
"input_ids": [seed] * oversized_len,
"sampling_params": {"max_new_tokens": 512, "ignore_eos": True},
},
timeout=900,
)
def ordinary_request(seed):
# Must still be decoding when the oversized prefill lands: retraction
# keeps one request, so a batch that has drained to a single entry is
# skipped entirely and nothing is ever picked.
return requests.post(
self.lb_url + "/generate",
json={
"input_ids": [seed] * 512,
"sampling_params": {"max_new_tokens": 4096, "ignore_eos": True},
},
timeout=900,
)
# Retraction picks the request with the fewest generated tokens; the prompt
# length only breaks ties. The oversized request has by far the longest
# prefill, so in a fixed batch it enters decode last, holds the fewest
# tokens, and is picked first — but only while nothing newer arrives, which
# is why the eval below runs after these rather than alongside them.
with ThreadPoolExecutor(max_workers=4) as pool:
oversized = pool.submit(oversized_request, 233)
ordinary = [pool.submit(ordinary_request, 300 + i) for i in range(3)]
response = oversized.result()
neighbours = [f.result() for f in ordinary]
# A 200 here means the request was never retracted, not that the abort path
# is broken, so surface the retraction count to tell the two apart.
meta = (
response.json().get("meta_info", {}) if response.status_code == 200 else {}
)
self.assertEqual(
response.status_code,
500,
(
f"expected an aborted backup; got num_retractions="
f"{meta.get('num_retractions')} completion_tokens="
f"{meta.get('completion_tokens')}"
if meta
else response.text
),
)
self.assertIn("Retraction host KV pool exhausted", response.text)
for neighbour in neighbours:
self.assertEqual(neighbour.status_code, 200, neighbour.text)
# The abort must leave the scheduler serving, and ordinary traffic must stay
# correct afterwards — a leaked host slot or a damaged neighbour shows up as
# a wrong answer rather than merely a 200.
assert_process_healthy(self, "decode", self.process_decode, self.decode_url)
metrics = run_eval(
SimpleNamespace(
base_url=f"http://{self.base_host}:{self.lb_port}",
eval_name="gsm8k",
api="completion",
max_tokens=512,
num_examples=64,
num_threads=32,
)
)
print(f"Post-abort gsm8k metrics: {metrics}")
# Looser than the 200-example test_gsm8k bar above: 64 examples is a
# health check on the post-abort server, not an accuracy measurement.
self.assertGreater(metrics["score"], 0.62)
def test_gsm8k(self): def test_gsm8k(self):
args = SimpleNamespace( args = SimpleNamespace(
base_url=f"http://{self.base_host}:{self.lb_port}", base_url=f"http://{self.base_host}:{self.lb_port}",
@@ -5,6 +5,7 @@ import torch
from sglang.srt.mem_cache.allocator import TokenToKVPoolAllocator from sglang.srt.mem_cache.allocator import TokenToKVPoolAllocator
from sglang.srt.mem_cache.cache_init_params import CacheInitParams from sglang.srt.mem_cache.cache_init_params import CacheInitParams
from sglang.srt.mem_cache.common import retraction_backup
from sglang.srt.mem_cache.hicache_storage import PoolName from sglang.srt.mem_cache.hicache_storage import PoolName
from sglang.srt.mem_cache.kv_cache_builder import maybe_register_hicache_draft from sglang.srt.mem_cache.kv_cache_builder import maybe_register_hicache_draft
from sglang.srt.mem_cache.memory_pool import MHATokenToKVPool, ReqToTokenPool from sglang.srt.mem_cache.memory_pool import MHATokenToKVPool, ReqToTokenPool
@@ -71,11 +72,12 @@ class TestDecodeRetractionBackup(unittest.TestCase):
self.assertTrue(torch.equal(key[indices], expected_key)) self.assertTrue(torch.equal(key[indices], expected_key))
self.assertTrue(torch.equal(value[indices], expected_value)) self.assertTrue(torch.equal(value[indices], expected_value))
def test_restores_target_and_draft_kv(self): def _build_cache(self, hicache_ratio: float):
"""Bring up a UnifiedRadixCache with a draft sidecar over fresh pools."""
server_args = ServerArgs( server_args = ServerArgs(
model_path="dummy", model_path="dummy",
page_size=1, page_size=1,
hicache_ratio=1.0, hicache_ratio=hicache_ratio,
hicache_io_backend="kernel", hicache_io_backend="kernel",
hicache_mem_layout="page_first", hicache_mem_layout="page_first",
) )
@@ -119,16 +121,61 @@ class TestDecodeRetractionBackup(unittest.TestCase):
) )
self.assertIn(PoolName.DRAFT, cache.host_pool_group.entry_map) self.assertIn(PoolName.DRAFT, cache.host_pool_group.entry_map)
cache.validate_retraction_host_capacity() cache.validate_retraction_host_capacity()
return SimpleNamespace(
server_args=server_args,
req_to_token_pool=req_to_token_pool,
allocator=allocator,
target_pool=target_pool,
draft_pool=draft_pool,
cache=cache,
)
req = SimpleNamespace( def _admit_req(self, env, num_tokens: int):
rid="request", req_pool_idx=None, seqlen=self.num_tokens + 1 req = SimpleNamespace(rid="request", req_pool_idx=None, seqlen=num_tokens + 1)
) self.assertIsNotNone(env.req_to_token_pool.alloc([req]))
self.assertIsNotNone(req_to_token_pool.alloc([req])) source_indices = env.allocator.alloc(num_tokens)
source_indices = allocator.alloc(self.num_tokens)
self.assertIsNotNone(source_indices) self.assertIsNotNone(source_indices)
req_to_token_pool.write( env.req_to_token_pool.write(
(req.req_pool_idx, slice(0, self.num_tokens)), source_indices (req.req_pool_idx, slice(0, num_tokens)), source_indices
) )
return req, source_indices
def test_backup_declined_when_host_pool_too_small(self):
# A backup-only host pool is deliberately smaller than the device pool,
# so a large enough request cannot be preserved.
env = self._build_cache(hicache_ratio=0.1)
self.assertLess(env.cache.host_pool_group.available_size(), self.num_tokens)
req, source_indices = self._admit_req(env, self.num_tokens)
host_free_before = env.cache.host_pool_group.available_size()
self.assertIsNone(env.cache.retraction_backup(req))
# The declined backup must not leak host slots.
self.assertEqual(env.cache.host_pool_group.available_size(), host_free_before)
# This is the signal release_req propagates so retract_decode aborts.
self.assertFalse(
retraction_backup(
req,
env.cache,
env.req_to_token_pool,
env.allocator,
"host_pool",
)
)
env.allocator.free(source_indices)
env.req_to_token_pool.free(req)
def test_restores_target_and_draft_kv(self):
env = self._build_cache(hicache_ratio=1.0)
req_to_token_pool = env.req_to_token_pool
allocator = env.allocator
target_pool = env.target_pool
draft_pool = env.draft_pool
cache = env.cache
req, source_indices = self._admit_req(env, self.num_tokens)
self._seed_pool(target_pool, source_indices, base=1000) self._seed_pool(target_pool, source_indices, base=1000)
self._seed_pool(draft_pool, source_indices, base=3000) self._seed_pool(draft_pool, source_indices, base=3000)