Stabilize CP HiCache page-tail ownership under EAGLE reuse

CP shared KV and HiCache now keep page-aligned physical ownership while preserving valid-token radix semantics. Repeated tiny EAGLE exact hits free duplicate tail pages instead of leaking one allocator page, owner-lane load-back uses page-vector admission/eviction, and single-DP idle schedulers avoid entering an unnecessary MLP-sync collective.

The commit also records the current page-aligned cache contract and adds gated decode-side EAGLE accept diagnostics so future accept-length collapses can be tied to draft KV/state transfer evidence instead of more prefill cache speculation.

Constraint: CP HiCache allocator ownership is page-granular while radix matching remains valid-token based.

Constraint: New diagnostics must be gated and must not alter normal EAGLE, transfer, or cache behavior.

Rejected: Padding short requests to cp_size or 2*cp_size pages | wastes KV capacity and still hides valid-tail lifecycle bugs.

Rejected: Adding more unconditional collectives to prove CP consistency | hot-path collectives previously caused severe performance risk.

Confidence: medium

Scope-risk: broad

Directive: Do not reintroduce silent fallback for CP shared KV/HiCache paths; warning-level fallback or fail-fast is intentional.

Tested: git diff --check

Tested: local py_compile for all modified Python files

Tested: remote g0034 container py_compile for modified Python/test files

Tested: remote g0034 container PYTHONPATH=python python -m pytest -q test/registered/unit/layers/test_nsa_cp_utils.py test/registered/unit/mem_cache/test_cp_shared_kv_runtime.py test/registered/unit/mem_cache/test_cp_hicache_load_back_owner_lanes.py test/registered/unit/managers/test_scheduler_dp_attn_mixin.py => 114 passed, 5 warnings, 2 subtests passed

Not-tested: full ETE traffic rerun after this commit

Not-tested: CUDA/TAI kernel benchmark coverage for all production shapes
This commit is contained in:
laoyao0822
2026-05-30 01:20:01 +08:00
parent 21065cdfdf
commit b56a4f2e6b
16 changed files with 964 additions and 17 deletions

View File

@@ -509,6 +509,22 @@ class DecodePreallocQueue:
len(kv_args.state_data_ptrs),
)
if envs.SGLANG_EAGLE_ACCEPT_DEBUG.get() and self.spec_algorithm.is_eagle():
logger.info(
"[EAGLE_ACCEPT_DEBUG] decode_kv_manager cp_rank=%s "
"target_kv_bufs=%s draft_kv_bufs=%s total_kv_bufs=%s "
"target_state_type=%s registered_state_bufs=%s "
"draft_state_type=%s draft_state_bufs=%s",
self.tp_rank,
target_kv_buffer_count,
draft_kv_buffer_count,
len(kv_args.kv_data_ptrs),
kv_args.state_type,
len(kv_args.state_data_ptrs),
kv_args.draft_state_type,
kv_args.draft_state_buffer_count,
)
kv_args.ib_device = self.scheduler.server_args.disaggregation_ib_device
kv_args.gpu_id = self.scheduler.gpu_id
kv_manager_class = get_kv_class(self.transfer_backend, KVClassType.MANAGER)

View File

@@ -350,6 +350,22 @@ class PrefillBootstrapQueue:
len(kv_args.state_data_ptrs),
)
if envs.SGLANG_EAGLE_ACCEPT_DEBUG.get() and self.spec_algorithm.is_eagle():
logger.info(
"[EAGLE_ACCEPT_DEBUG] prefill_kv_manager cp_rank=%s "
"target_kv_bufs=%s draft_kv_bufs=%s total_kv_bufs=%s "
"target_state_type=%s registered_state_bufs=%s "
"draft_state_type=%s draft_state_bufs=%s",
self.tp_rank,
target_kv_buffer_count,
draft_kv_buffer_count,
len(kv_args.kv_data_ptrs),
kv_args.state_type,
len(kv_args.state_data_ptrs),
kv_args.draft_state_type,
kv_args.draft_state_buffer_count,
)
kv_manager_class = get_kv_class(self.transfer_backend, KVClassType.MANAGER)
kv_manager = kv_manager_class(
kv_args,

View File

@@ -217,6 +217,8 @@ class Envs:
SGLANG_CP_SHARED_KV_MLA_PREFETCH_MIN_EXTEND_TOKENS = EnvInt(-1)
SGLANG_CP_DRAFT_SHARED_KV = EnvBool(False)
SGLANG_CP_DRAFT_SHARED_KV_DEBUG = EnvBool(False)
SGLANG_EAGLE_ACCEPT_DEBUG = EnvBool(False)
SGLANG_EAGLE_ACCEPT_DEBUG_INTERVAL = EnvInt(128)
SGLANG_DISABLE_TAI_BIGRAM = EnvBool(False)
SGLANG_TEST_REQUEST_TIME_STATS = EnvBool(False)
SGLANG_DISABLE_TP_MEMORY_INBALANCE_CHECK = EnvBool(False)

View File

@@ -968,7 +968,11 @@ def can_reuse_current_extend_kv(forward_batch) -> bool:
seq_len = int(seq_lens_cpu[0].item())
if extend_len <= 0 or seq_len < extend_len:
return False
return int(out_cache_loc.numel()) == extend_len
# ForwardBatch pads tensors such as out_cache_loc at the tail for CUDA graph
# and CP alignment. The first extend_len rows still cover the valid current
# suffix, so padded batches remain eligible for current reuse as long as the
# valid suffix is fully present.
return int(out_cache_loc.numel()) >= extend_len
def should_reuse_current_extend_kv(forward_batch) -> bool:

View File

@@ -403,6 +403,51 @@ def should_use_replicated_compute_for_short_radix_hit(
return False
def should_skip_cp_shared_kv_cp_split_for_short_page_extent(
forward_batch: "ForwardBatch",
cp_size: int,
) -> bool:
"""Avoid in-seq CP split when a shared-KV suffix has too few pages.
CP shared KV is page-owned. A cache-hit suffix with fewer physical pages
than CP lanes creates mostly-zero in-seq segments; the distributed NSA path
has repeatedly shown hangs on that shape. Keep the page cache contract by
running those tiny suffixes without NSA in-seq CP split instead of falling
back to token-balanced page-splitting.
"""
if (
forward_batch is None
or not getattr(forward_batch, "uses_cp_shared_kv", False)
or cp_size <= 1
):
return False
extend_seq_lens_cpu = getattr(forward_batch, "extend_seq_lens_cpu", None)
extend_prefix_lens_cpu = getattr(forward_batch, "extend_prefix_lens_cpu", None)
token_to_kv_pool = getattr(forward_batch, "token_to_kv_pool", None)
page_size = int(getattr(token_to_kv_pool, "page_size", 0) or 0)
if (
extend_seq_lens_cpu is None
or len(extend_seq_lens_cpu) != 1
or extend_prefix_lens_cpu is None
or len(extend_prefix_lens_cpu) != 1
or page_size <= 0
):
return False
extend_len = int(extend_seq_lens_cpu[0])
if extend_len <= 0:
return False
prefix_len = int(extend_prefix_lens_cpu[0])
if prefix_len <= 0 or prefix_len % page_size != 0:
return False
padded_pages = ceil_div(extend_len, page_size)
return padded_pages < cp_size
def can_cp_split(seq_len: int, cp_size: int, use_nsa: bool, forward_batch):
if is_nsa_prefill_cp_round_robin_split():
cur_cp_seq_len = seq_len // cp_size
@@ -415,6 +460,10 @@ def can_cp_split(seq_len: int, cp_size: int, use_nsa: bool, forward_batch):
# the seq data needs to be divided and recombined at twice the size of cp_size.
if should_use_replicated_compute_for_short_radix_hit(forward_batch, cp_size):
return False
if should_skip_cp_shared_kv_cp_split_for_short_page_extent(
forward_batch, cp_size
):
return False
cur_cp_seq_len = seq_len // (cp_size * 2)
if (
cur_cp_seq_len != 0

View File

@@ -1782,6 +1782,7 @@ class NativeSparseAttnBackend(
)
if can_reuse_current_kv:
current_kv_cache = _cat([k, k_rope], dim=-1)
current_locs_for_reuse = forward_batch.out_cache_loc
logical_page_table_1 = page_table_1
current_remap_page_size, current_remap_logical_page_capacity = (
current_loc_remap_fast_path_args(forward_batch)
@@ -1809,13 +1810,30 @@ class NativeSparseAttnBackend(
"MLA current reuse cp_rank=%s layer=%s current_locs=%s remapped=%s kv_ck=%s rope_ck=%s",
forward_batch.cp_shared_kv_layout.cp_rank,
layer.layer_id,
tensor_debug_summary(forward_batch.out_cache_loc),
tensor_debug_summary(current_locs_for_reuse),
tensor_debug_summary(page_table_1),
tensor_debug_checksum(k),
tensor_debug_checksum(k_rope),
)
kv_cache = current_kv_cache
else:
extend_lens_cpu_for_current = getattr(
forward_batch, "extend_seq_lens_cpu", None
)
if (
extend_lens_cpu_for_current is not None
and len(extend_lens_cpu_for_current) == 1
):
valid_current_rows = int(extend_lens_cpu_for_current[0])
if (
valid_current_rows > 0
and valid_current_rows < int(current_locs_for_reuse.numel())
and valid_current_rows <= int(current_kv_cache.shape[0])
):
current_kv_cache = current_kv_cache[:valid_current_rows]
current_locs_for_reuse = current_locs_for_reuse[
:valid_current_rows
]
prefetched_kv = None
if mla_prefetcher is not None:
prefetched_kv = mla_prefetcher.consume_prefix_with_current(
@@ -1823,7 +1841,7 @@ class NativeSparseAttnBackend(
kv_cache=kv_cache,
logical_locs=logical_page_table_1,
current_kv_cache=current_kv_cache,
current_locs=forward_batch.out_cache_loc,
current_locs=current_locs_for_reuse,
current_remap_page_size=current_remap_page_size,
current_remap_logical_page_capacity=current_remap_logical_page_capacity,
)
@@ -1869,7 +1887,7 @@ class NativeSparseAttnBackend(
f"extend_lens={extend_lens} "
f"current_rows={int(current_kv_cache.shape[0])} "
f"logical_page_table_shape={tuple(logical_page_table_1.shape)} "
f"current_locs_shape={tuple(forward_batch.out_cache_loc.shape)} "
f"current_locs_shape={tuple(current_locs_for_reuse.shape)} "
f"page_size={page_size}"
)
prefix_pages = int(prefix_lens_cpu[0]) // page_size
@@ -1885,7 +1903,7 @@ class NativeSparseAttnBackend(
kv_cache=kv_cache,
logical_locs=logical_page_table_1,
current_kv_cache=current_kv_cache,
current_locs=forward_batch.out_cache_loc,
current_locs=current_locs_for_reuse,
slot_remap=slot_remap,
layout=forward_batch.cp_shared_kv_layout,
page_size=page_size,
@@ -1946,7 +1964,7 @@ class NativeSparseAttnBackend(
"MLA partial current reuse cp_rank=%s layer=%s current_locs=%s remapped=%s kv_ck=%s rope_ck=%s",
forward_batch.cp_shared_kv_layout.cp_rank,
layer.layer_id,
tensor_debug_summary(forward_batch.out_cache_loc),
tensor_debug_summary(current_locs_for_reuse),
tensor_debug_summary(page_table_1),
tensor_debug_checksum(k),
tensor_debug_checksum(k_rope),

View File

@@ -154,6 +154,17 @@ def prepare_mlp_sync_batch_raw(
disable_overlap_schedule: bool,
offload_tags: set[str],
):
skip_all_gather = envs.SGLANG_SCHEDULER_SKIP_ALL_GATHER.get()
# With a single DP scheduling domain, all TP/CP ranks are expected to have
# the same local batch presence. If there is no local batch, an MLP-sync
# collective would only gather all-zero idle metadata. More importantly,
# in CP disaggregated prefill this idle NCCL/Gloo sync can race with the
# next request-broadcast collective after tiny health/internal requests and
# leave some ranks in recv broadcast while others enter this all-gather.
if local_batch is None and dp_size == 1 and not skip_all_gather:
return None
# Check if other DP workers have running batches
if local_batch is None or local_batch.forward_mode.is_prebuilt():
num_tokens = 0
@@ -176,7 +187,6 @@ def prepare_mlp_sync_batch_raw(
or num_tokens_for_logprob == local_batch.batch_size()
)
skip_all_gather = envs.SGLANG_SCHEDULER_SKIP_ALL_GATHER.get()
can_cuda_graph = (
local_batch is None
or local_batch.forward_mode.is_decode_or_idle()

View File

@@ -51,6 +51,82 @@ class SchedulerOutputProcessorMixin:
storage_backend_type = type(storage_backend).__name__
return storage_backend_type
def _maybe_log_eagle_accept_debug(
self: Scheduler,
batch: ScheduleBatch,
result: GenerationBatchResult,
) -> None:
"""Low-frequency EAGLE accept diagnostics for PD/debug runs.
This is intentionally gated. The hot path normally only reports an
aggregate accept length; when accept length collapses we need enough
per-request state to distinguish draft rejection from transfer/cache
lifecycle issues without adding per-token or per-layer logging.
"""
if not envs.SGLANG_EAGLE_ACCEPT_DEBUG.get():
return
if batch.spec_algorithm.is_none():
return
accept_per_req = getattr(result, "accept_length_per_req_cpu", None)
if accept_per_req is None:
return
counter = getattr(self, "_eagle_accept_debug_counter", 0) + 1
self._eagle_accept_debug_counter = counter
interval = envs.SGLANG_EAGLE_ACCEPT_DEBUG_INTERVAL.get()
interval = max(1, int(interval or 1))
has_zero_accept = any(int(x) <= 0 for x in accept_per_req)
if not has_zero_accept and counter % interval != 0:
return
spec_info = getattr(batch, "spec_info", None)
def _shape(obj):
shape = getattr(obj, "shape", None)
return tuple(shape) if shape is not None else None
samples = []
for i, req in enumerate(batch.reqs[: min(4, len(batch.reqs))]):
if not has_zero_accept and counter % interval != 0:
break
if has_zero_accept and int(accept_per_req[i]) > 0:
continue
samples.append(
{
"rid": str(getattr(req, "rid", ""))[:8],
"accepted_draft": int(accept_per_req[i]),
"origin": len(getattr(req, "origin_input_ids", []) or []),
"output": len(getattr(req, "output_ids", []) or []),
"cached": int(getattr(req, "cached_tokens", 0) or 0),
"spec_verify_ct": int(getattr(req, "spec_verify_ct", 0) or 0),
"has_pd_hidden": getattr(req, "hidden_states_tensor", None)
is not None,
"pd_hidden_shape": _shape(getattr(req, "hidden_states_tensor", None)),
"pd_topk_shape": _shape(getattr(req, "output_topk_p", None)),
}
)
log_fn = logger.warning if has_zero_accept else logger.info
log_fn(
"[EAGLE_ACCEPT_DEBUG] step=%d mode=%s bs=%d avg_accept=%.3f "
"min_accept=%d max_accept=%d num_zero=%d spec_v2=%s "
"spec_topk_shape=%s spec_hidden_shape=%s samples=%s",
counter,
getattr(self, "disaggregation_mode", None),
len(batch.reqs),
sum(int(x) for x in accept_per_req) / max(1, len(accept_per_req)),
min(int(x) for x in accept_per_req),
max(int(x) for x in accept_per_req),
sum(1 for x in accept_per_req if int(x) <= 0),
getattr(batch, "is_spec_v2", None),
_shape(getattr(spec_info, "topk_p", None)),
_shape(getattr(spec_info, "hidden_states", None)),
samples,
)
def _get_cached_tokens_details(self, req: Req) -> Optional[dict]:
"""Get detailed cache breakdown for a request, if available.
@@ -410,6 +486,7 @@ class SchedulerOutputProcessorMixin:
self.num_generated_tokens += len(batch.reqs)
if not batch.spec_algorithm.is_none():
self.update_spec_metrics(batch.batch_size(), result.num_accepted_tokens)
self._maybe_log_eagle_accept_debug(batch, result)
if self.enable_metrics:
self.metrics_collector.increment_decode_cuda_graph_pass(
value=can_run_cuda_graph

View File

@@ -349,10 +349,11 @@ def _evict_for_compute_owner_lanes(
)
return
evict_tokens = max(
allocator.page_size,
deficit_pages * allocator.page_size * int(getattr(allocator, "cp_size", 1)),
)
# ``deficits`` is already expressed in physical owner-lane pages. The
# old ``* cp_size`` multiplier turned a one-page lane deficit into one
# full CP stripe of eviction and caused large L1 churn under HiCache
# load-back pressure.
evict_tokens = max(allocator.page_size, deficit_pages * allocator.page_size)
before_available = allocator.available_size()
logger.info(
"[MemCache-evict] _evict_for_compute_owner_lanes attempt=%d: deficit_pages=%d evict_tokens=%d before_available=%d evictable_size=%d",

View File

@@ -1238,6 +1238,105 @@ class HiRadixCache(RadixCache):
)
return refreshed
def _evict_cp_owner_lane_deficit_nodes(
self, params: EvictParams
) -> EvictResult:
deficits = [max(0, int(v)) for v in (params.owner_lane_deficits or [])]
if not deficits or all(v <= 0 for v in deficits):
return EvictResult()
plan = CpLoadBackPlan(
page_owners=[],
required_by_owner=[],
available_by_owner=[],
deficit_by_owner=deficits,
host_hit_len=0,
)
eviction_plan = self._plan_cp_load_back_owner_lane_evictions(plan)
if len(eviction_plan.victims) == 0:
logger.info(
"[HiCache-evict] owner-lane evict found no contributing victims: "
"num_tokens=%d deficits=%s evictable_size=%d available_size=%d",
params.num_tokens,
deficits,
self.evictable_size_,
self.token_to_kv_pool_allocator.available_size(),
)
return EvictResult()
num_evicted = 0
num_locked_skipped = 0
write_back_nodes: List[TreeNode] = []
for victim in eviction_plan.victims:
if victim.lock_ref > 0:
num_locked_skipped += 1
continue
if victim.pin_expiry > 0 and time.monotonic() > victim.pin_expiry:
self._clear_pin(victim)
if not self._cp_device_leaf_is_load_back_victim(victim):
logger.info(
"[HiCache-evict] owner-lane evict victim no longer evictable: "
"victim_id=%s deficits=%s",
getattr(victim, "id", None),
deficits,
)
continue
if self._is_pinned(victim):
if self._node_backuped(victim):
num_evicted += self._evict_backuped(victim)
else:
written = self.write_backup(victim, write_back=True)
if written > 0:
write_back_nodes.append(victim)
else:
self._clear_pin(victim)
continue
if not self._node_backuped(victim):
if self.cache_controller.write_policy == "write_back":
written = self.write_backup(victim, write_back=True)
if written > 0:
write_back_nodes.append(victim)
continue
num_evicted += self._evict_regular(victim)
else:
num_evicted += self._evict_backuped(victim)
for child in victim.parent.children.values():
if child in write_back_nodes:
continue
if not child.evicted:
break
else:
self._update_leaf_status(victim.parent)
if write_back_nodes:
self.writing_check(write_back=True)
for victim in write_back_nodes:
if self._node_backuped(victim):
num_evicted += self._evict_backuped(victim)
logger.info(
"[HiCache-evict] owner-lane evict END: requested_tokens=%d "
"deficits=%s victims=%s planned_freed_by_owner=%s "
"remaining_deficit_by_owner=%s num_evicted=%d "
"num_locked_skipped=%d evictable_size_after=%d "
"available_size_after=%d",
params.num_tokens,
deficits,
[getattr(node, "id", None) for node in eviction_plan.victims],
eviction_plan.planned_freed_by_owner,
eviction_plan.remaining_deficit_by_owner,
num_evicted,
num_locked_skipped,
self.evictable_size_,
self.token_to_kv_pool_allocator.available_size(),
)
return EvictResult(num_tokens_evicted=num_evicted)
def _cp_build_write_admission(
self, device_indices: torch.Tensor, *, node_id: int, phase: str
) -> CpWriteAdmission:
@@ -2575,6 +2674,13 @@ class HiRadixCache(RadixCache):
def evict(self, params: EvictParams) -> EvictResult:
start_time = time.perf_counter()
num_tokens = params.num_tokens
if params.owner_lane_deficits is not None and any(
int(v) > 0 for v in params.owner_lane_deficits
):
result = self._evict_cp_owner_lane_deficit_nodes(params)
self.update_eviction_metrics(result.num_tokens_evicted, start_time)
return result
leaves = list(self.evictable_leaves)
eviction_heap = [
(self.eviction_strategy.get_priority(node), node) for node in leaves

View File

@@ -494,6 +494,28 @@ class RadixCache(BasePrefixCache):
end = start
self.token_to_kv_pool_allocator.free(kv_indices[start:end])
def _should_free_cp_exact_match_tail(
self, *, start: int, end: int, key_len: int
) -> bool:
"""Return whether duplicate-prefix freeing must include a CP tail page.
CP HiCache keeps radix keys at scheduler-visible valid lengths while the
allocator owns whole physical pages. For an exact duplicate hit whose
valid key ends inside a page (typical tiny EAGLE/bigram requests), the
duplicate request page is not retained by a new radix node. Flooring
``end`` to the previous page boundary would therefore leak that newly
allocated page. Partial/new-node insertions still use the conservative
page-floored free path because their tail page is represented by the
radix node being inserted.
"""
return (
getattr(self, "_uses_cp_hicache", False)
and key_len > 0
and end == key_len
and end > start
)
def cache_finished_req(self, req: Req, is_insert: bool = True):
"""Cache request when it finishes."""
# In deterministic mode, disable finished request insertion to radix cache
@@ -541,7 +563,14 @@ class RadixCache(BasePrefixCache):
new_prefix_len = result.prefix_len
# Free the duplicates that were already in the tree
self._free_kv_indices_range(
kv_indices, req.cache_protected_len, new_prefix_len
kv_indices,
req.cache_protected_len,
new_prefix_len,
include_partial_tail=self._should_free_cp_exact_match_tail(
start=req.cache_protected_len,
end=new_prefix_len,
key_len=len(keys),
),
)
else:
if prepared_cp_backup is not None and hasattr(
@@ -612,7 +641,16 @@ class RadixCache(BasePrefixCache):
req.cp_hicache_prepared_backup = None
new_prefix_len = result.prefix_len
self._free_kv_indices_range(kv_indices, req.cache_protected_len, new_prefix_len)
self._free_kv_indices_range(
kv_indices,
req.cache_protected_len,
new_prefix_len,
include_partial_tail=self._should_free_cp_exact_match_tail(
start=req.cache_protected_len,
end=new_prefix_len,
key_len=len(keys),
),
)
# The prefix indices could be updated, reuse it
match_result = self.match_prefix(MatchPrefixParams(key=radix_key))