Skip to content
53 changes: 53 additions & 0 deletions tensorrt_llm/_torch/pyexecutor/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,14 @@ def try_prepare_estimation(self) -> bool:
logger.info(
"KV cache size estimation is not supported for context parallelism, disable it."
)
if (self._is_kv_cache_manager_v2
and self._mapping.cp_config.get('cp_type') == CpType.HELIX):
# Promote like the encoder-decoder case so build_managers
# runs configure_kv_cache_capacity(), which sets the quota
# KVCacheManagerV2 requires at construction (V1 stays local).
# HELIX only: configure_kv_cache_capacity has no sizing path
# for other CP types and would hit its assertion.
self._skip_est = True
model_config = self._model_engine.model.model_config
if model_config.attn_backend == "VANILLA":
estimating_kv_cache = False
Expand Down Expand Up @@ -1071,6 +1079,41 @@ def try_prepare_estimation(self) -> bool:
self._kv_cache_config.max_tokens = max_tokens
return estimating_kv_cache

def _configure_helix_kv_cache_capacity(self) -> None:
"""Set the helix KV quota without profiling (not CP-aware).

Explicit quotas pass through; otherwise fraction sizing sets
``max_gpu_total_bytes`` (a rank-local byte cap the manager consumes
as-is). Setting ``max_tokens`` here would overshoot the fraction:
the manager inflates that knob by 1 / max_util_for_resume.
"""
if (self._kv_cache_config.max_tokens is not None
and self._kv_cache_config.max_tokens <= 0):
raise ValueError(
"Helix CP: kv_cache_config.max_tokens must be positive when "
f"set, got {self._kv_cache_config.max_tokens}.")
if (self._kv_cache_config.max_gpu_total_bytes or 0) > 0 or \
(self._kv_cache_config.max_tokens or 0) > 0:
logger.info("Helix CP: skipping KV cache capacity profiling; using "
"the explicitly configured quota.")
return
fraction = self._kv_cache_config.free_gpu_memory_fraction
free_mem, _total = torch.cuda.mem_get_info()
budget_bytes = int(free_mem * fraction)
if budget_bytes <= 0:
raise ValueError(
"Helix CP: fraction-based KV sizing found no usable free "
"memory; set kv_cache_config.max_tokens or "
"max_gpu_total_bytes.")
logger.warning(
"Helix CP: capacity profiling is unsupported; sizing the KV "
f"cache as fraction {fraction} of free memory -> "
f"max_gpu_total_bytes={budget_bytes} (rank-local byte cap; the "
"manager min-syncs across ranks and converts to global tokens). "
"Set kv_cache_config.max_tokens or max_gpu_total_bytes to "
"override.")
self._kv_cache_config.max_gpu_total_bytes = budget_bytes

def configure_kv_cache_capacity(self,
py_executor: PyExecutor = None) -> None:
"""Perform KV cache capacity estimation.
Expand All @@ -1081,6 +1124,16 @@ def configure_kv_cache_capacity(self,
mapping = self._mapping

# TODO: support CP by generating dummy requests for it.
if mapping.cp_config.get('cp_type') == CpType.HELIX:
Comment thread
lancelly marked this conversation as resolved.
if not self._is_kv_cache_manager_v2:
# The helix sizing below emits V2 ledger (global) quotas;
# V1 reads max_tokens as rank-local. Reject explicitly.
raise NotImplementedError(
"TRTLLM_SKIP_KV_CACHE_ESTIMATION with helix CP requires "
"the V2 KV cache manager "
"(kv_cache_config.use_kv_cache_manager_v2=True).")
self._configure_helix_kv_cache_capacity()
return
assert 'cp_type' not in mapping.cp_config

fraction = self._kv_cache_config.free_gpu_memory_fraction
Expand Down
152 changes: 145 additions & 7 deletions tensorrt_llm/_torch/pyexecutor/kv_cache_manager_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,34 @@ def __init__(
self.num_kv_heads = num_kv_heads
self.head_dim = head_dim
self.tokens_per_block = tokens_per_block
# Helix super-block ledger: bookkeeping runs in GLOBAL tokens, one
# ledger block = cp_size physical pages (one per rank), while
# self.tokens_per_block stays the PHYSICAL kernel page size.
self._has_cp_helix = mapping.has_cp_helix()
self._helix_cp_rank = mapping.cp_rank if self._has_cp_helix else 0
self._helix_cp_size = mapping.cp_size if self._has_cp_helix else 1
self._ledger_tokens_per_block = tokens_per_block * self._helix_cp_size
if self._has_cp_helix:
if kv_cache_config.enable_block_reuse:
raise ValueError(
"KVCacheManagerV2 does not support block reuse with "
"helix context parallelism: a ledger block's bytes span "
"all CP ranks, so publishing it requires a group-wide "
"protocol. Set kv_cache_config.enable_block_reuse=False."
)
if is_draft:
raise ValueError(
"KVCacheManagerV2 does not support a draft cache "
"manager with helix context parallelism."
)
if mapping.enable_attention_dp:
raise ValueError(
"KVCacheManagerV2 does not support attention-DP with "
"helix context parallelism: disagg transfer-completion "
"consensus is skipped under attention-DP, so the "
"scheduler's request view would not be rank-invariant "
"across the CP group."
)
self.max_seq_len = max_seq_len
self.max_batch_size = max_batch_size
self.max_num_tokens = max_num_tokens
Expand Down Expand Up @@ -1179,7 +1207,9 @@ def append_to_kv_heads_per_layer(
max_seq_capacity = (
self.max_seq_len + self.num_extra_kv_tokens + self._kv_reserve_draft_tokens + 1
)
self.max_blocks_per_seq = (max_seq_capacity + tokens_per_block - 1) // tokens_per_block
self.max_blocks_per_seq = (
max_seq_capacity + self._ledger_tokens_per_block - 1
) // self._ledger_tokens_per_block
if self.max_blocks_per_seq % 4 != 0:
self.max_blocks_per_seq = ((self.max_blocks_per_seq + 3) // 4) * 4

Expand Down Expand Up @@ -1453,6 +1483,17 @@ def _get_runtime_cache_size_layer_components(self) -> tuple[List[int], List[Opti
return layer_sizes, attention_windows

def _get_max_tokens_from_quota(self, quota: int) -> float:
"""Rank-local byte quota -> token capacity (GLOBAL tokens under helix)."""
tokens = self._get_max_tokens_from_quota_impl(quota)
if self._has_cp_helix and not math.isinf(tokens):
# Floor to whole physical pages before scaling: a ledger block
# allocates one full page on every CP rank, so a partial
# trailing page in the rank-local budget is never usable.
tokens = int(tokens) // self.tokens_per_block * self.tokens_per_block
tokens *= self._helix_cp_size
Comment thread
lancelly marked this conversation as resolved.
return tokens

def _get_max_tokens_from_quota_impl(self, quota: int) -> float:
layer_sizes, attention_windows = self._get_runtime_cache_size_layer_components()
full_attn_size_per_token = _estimate_full_attn_size_per_token(
layer_sizes, attention_windows
Expand Down Expand Up @@ -1486,6 +1527,16 @@ def _get_max_tokens_from_quota(self, quota: int) -> float:
return self.max_num_tokens + (quota - context_limit_quota) / generation_size_per_token

def _get_quota_from_max_tokens(self, max_tokens: int) -> int:
"""Token capacity (GLOBAL tokens under helix) -> rank-local byte quota."""
if self._has_cp_helix:
# Round up to whole ledger blocks first: allocation is page-
# granular on every rank, so a request of N global tokens costs
# ceil(N / ledger_tpb) full physical pages per rank.
blocks = -(-int(max_tokens) // self._ledger_tokens_per_block)
max_tokens = blocks * self.tokens_per_block
return self._get_quota_from_max_tokens_impl(max_tokens)

def _get_quota_from_max_tokens_impl(self, max_tokens: int) -> int:
layer_sizes, attention_windows = self._get_runtime_cache_size_layer_components()
full_attn_size_per_token = _estimate_full_attn_size_per_token(
layer_sizes, attention_windows
Expand Down Expand Up @@ -1908,7 +1959,9 @@ def _build_base_config(
)

return KVCacheManagerConfigPy(
tokens_per_block=tokens_per_block,
# Used by the backend only for token<->block arithmetic and
# radix hashing; BufferConfig.size above stays the physical page.
tokens_per_block=self._ledger_tokens_per_block,
cache_tiers=cache_tiers,
layers=layer_configs,
typical_step=typical_step,
Expand Down Expand Up @@ -2157,6 +2210,17 @@ def get_index_k_buffer(
def get_num_available_tokens(
self, *, token_num_upper_bound: int, batch_size: int = 1, max_num_draft_tokens: int = 0
) -> int:
"""Clamp ``token_num_upper_bound`` to the allocatable token capacity.

Unit note: under helix the backend runs on the ledger
``tokens_per_block``, so the returned capacity (like
``token_num_upper_bound`` and ``max_seq_len``) is in GLOBAL ledger
tokens - the coordinate request lengths are expressed in. Callers
that additionally bound the result by per-forward budgets (e.g.
``max_num_tokens``) stay consistent because a helix context forward
replicates all tokens on every rank, so both bounds constrain the
same request-length variable.
"""
extra_tokens = self.num_extra_kv_tokens + max_num_draft_tokens
# Token num upper bound is the maximum number of tokens that can be allocated in the kv cache manager.
# We need to add extra tokens to the token num upper bound to account for the extra tokens.
Expand Down Expand Up @@ -2231,6 +2295,33 @@ def _effective_draft_len(self, req: LlmRequest) -> int:
draft_len = self.max_total_draft_tokens
return draft_len

def _helix_local_len(self, global_len: int) -> int:
"""Tokens of the first ``global_len`` owned by this CP rank
(continuation round-robin: page b lives on rank b %% cp)."""
phys = self.tokens_per_block
full, rem = divmod(global_len, self._ledger_tokens_per_block)
return full * phys + min(max(rem - self._helix_cp_rank * phys, 0), phys)

def _set_helix_rank_fields(self, req: LlmRequest) -> None:
"""Derive the per-rank helix fields from the global position.

The decode-step index is manager-owned (committed in
``try_allocate_generation`` on successful resize) rather than derived
from ``py_decoding_iter``: the sampler advances that counter after
scheduling under the overlap loop, so a schedule-time read is one
step behind and would repeat the first decode position, overwriting
the first generated token's KV. Assumes one new token per step
(draft-token modes are rejected under helix).
"""
step = req.py_helix_decode_group_index + 1
pos = req.total_input_len_cp + step - 1
owner = (pos // self.tokens_per_block) % self._helix_cp_size
active = owner == self._helix_cp_rank
req.py_helix_is_inactive_rank = not active
# Convention shared with model_engine: the active rank's seqlen
# includes the in-flight token (past_seen = seqlen - 1 there).
req.seqlen_this_rank_cp = self._helix_local_len(pos) + (1 if active else 0)

def _required_gen_capacity(self, req: LlmRequest, current_capacity: int) -> int:
"""Compute generation KV cache capacity for a request.

Expand All @@ -2255,7 +2346,16 @@ def try_allocate_generation(self, req: LlmRequest) -> bool:

draft_len = self._effective_draft_len(req)
self._allocated_draft_lens[req.py_request_id] = draft_len
return kv_cache.resize(self._required_gen_capacity(req, kv_cache.capacity))
is_helix_req = self._has_cp_helix and not req.is_dummy_request
if is_helix_req:
self._set_helix_rank_fields(req)
if not kv_cache.resize(self._required_gen_capacity(req, kv_cache.capacity)):
return False
if is_helix_req:
# Commit only on success so a same-pass retry recomputes the
# same step instead of skipping one.
req.py_helix_decode_group_index += 1
return True

def revert_allocate_generation(self, req: LlmRequest) -> None:
"""Undo the capacity growth from try_allocate_generation.
Expand All @@ -2273,6 +2373,9 @@ def revert_allocate_generation(self, req: LlmRequest) -> None:
kv_cache = self.kv_cache_map.get(req.py_request_id)
if kv_cache is None or not kv_cache.is_active:
return
if self._has_cp_helix and not req.is_dummy_request and req.py_helix_decode_group_index > 0:
# The forward pass for this step is skipped; give the step back.
req.py_helix_decode_group_index -= 1
draft_len = self._allocated_draft_lens.pop(
req.py_request_id, self._effective_draft_len(req)
)
Expand Down Expand Up @@ -2374,7 +2477,10 @@ def _prepare_context_impl(self, req: LlmRequest) -> bool:
tokens,
cache_salt=req.cache_salt,
is_dummy=req.is_dummy,
expected_prompt_length=req.prompt_len - 1,
expected_prompt_length=(
req.total_input_len_cp if self._has_cp_helix else req.prompt_len
)
- 1,
)
if kv_cache is None:
return False
Expand Down Expand Up @@ -2412,6 +2518,13 @@ def resize_context(self, req: LlmRequest, num_tokens: int) -> bool:
assert not req.is_disagg_generation_init_state, (
f"req {req.py_request_id}: use prepare_disagg_gen_init"
)
if self._has_cp_helix and not req.is_dummy_request:
raise ValueError(
"resize_context is not helix-aware: its rank-local chunk "
"target would under-size the global ledger. Helix requests "
"are disagg-generation-only and must never take the context "
"path."
)
kv_cache = self.kv_cache_map.get(req.py_request_id)
if kv_cache is None:
return False
Expand Down Expand Up @@ -2445,11 +2558,14 @@ def prepare_disagg_gen_init(self, req: LlmRequest) -> bool:

# prompt_len is the full incoming prompt length, robust to block
# reuse (which may leave a non-zero context_current_position).
target = req.prompt_len + get_draft_token_length(req) + self.num_extra_kv_tokens
# Helix requests carry the rank-local strided slice in prompt_len;
# the global ledger sizes off the full prompt instead.
prompt_len = req.total_input_len_cp if self._has_cp_helix else req.prompt_len
target = prompt_len + get_draft_token_length(req) + self.num_extra_kv_tokens
capacity = max(kv_cache.capacity, target)
pre_cap = kv_cache.capacity

success = kv_cache.resize(capacity, req.prompt_len)
success = kv_cache.resize(capacity, prompt_len)
if not success:
if req.is_first_context_chunk:
kv_cache.suspend()
Expand Down Expand Up @@ -3166,6 +3282,10 @@ def release_resources(
# a non-zero number to skip illegal memory access issue in MLA kernel
# during warmup.
token_num = token_nums[i] if token_nums is not None else 1 + max_num_draft_tokens
if self._has_cp_helix:
# Keep the frozen dummy fields self-consistent (the active
# rank's past_seen = seqlen - 1 must stay >= 1).
token_num = max(token_num, 2)
# token_num - 1 is the past history length in generation.
history_hint = max(0, token_num - 1) if is_gen and not materialize_history else None
encoder_output_len = encoder_output_lens[i] if encoder_output_lens is not None else None
Expand Down Expand Up @@ -3237,6 +3357,20 @@ def release_resources(
req.prompt_len = token_num - 1
req.py_prompt_len = req.prompt_len
req.py_draft_tokens = [1] * max_num_draft_tokens
if self._has_cp_helix:
# Frozen fields (V1 parity): dummies never pass the
# per-step derivation — last CP rank active, shared
# synthetic global length.
if self._helix_cp_rank == self._helix_cp_size - 1:
req.py_helix_is_inactive_rank = False
req.prompt_len = token_num - 1
else:
req.py_helix_is_inactive_rank = True
req.prompt_len = token_num
req.py_prompt_len = req.prompt_len
req.seqlen_this_rank_cp = req.prompt_len
req.total_input_len_cp = token_num * self._helix_cp_size - 1
req.py_decoding_iter = 1
if prepare_resource:
new_capacity = kv_cache.capacity + _kv_draft + 1
success = kv_cache.resize(new_capacity, history_length=history_hint)
Expand Down Expand Up @@ -3671,7 +3805,11 @@ def update_resources(
else kv_cache.capacity - rewind_len
)
history_length = (
None if self.kv_compression_manages_history else req.max_beam_num_tokens - 1
None
# Reuse (history's consumer) is disabled under helix, and
# max_beam_num_tokens mixes rank-local and global counts.
if self.kv_compression_manages_history or self._has_cp_helix
else req.max_beam_num_tokens - 1
)
success = kv_cache.resize(new_capacity, history_length)
if not success:
Expand Down
3 changes: 3 additions & 0 deletions tensorrt_llm/_torch/pyexecutor/llm_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,9 @@ def __init__(
self.py_end_id = self.end_id
self.py_min_length = self.sampling_config.min_length
self.py_helix_is_inactive_rank = False
# Manager-owned helix decode-step counter; see
# KVCacheManagerV2._set_helix_rank_fields.
self.py_helix_decode_group_index = 0
self.py_draft_logits = None
self.py_target_probs = None
self.py_per_pos_drafted = [0] * MAX_SPEC_DECODE_POSITIONS
Expand Down
13 changes: 13 additions & 0 deletions tensorrt_llm/_torch/pyexecutor/scheduler/scheduler_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ def __init__(
self.chunk_unit_size = 0
self.max_context_length = max_num_tokens
self.tokens_per_block = kv_cache_manager.tokens_per_block
self.has_cp_helix = kv_cache_manager._has_cp_helix
draft_mgr_name = (
type(draft_kv_cache_manager).__name__ if draft_kv_cache_manager is not None else "None"
)
Expand Down Expand Up @@ -993,6 +994,18 @@ def _try_schedule_generation(
success = self.kv_cache_manager.try_allocate_generation(req)

if not success:
if self.has_cp_helix:
# No-evict stance: every validated helix run used
# GUARANTEED_NO_EVICT semantics; eviction stays off under
# helix until a KV-pressure e2e validates it.
raise RuntimeError(
f"[V2Scheduler] KV allocation failed for helix request "
f"{req.py_request_id}; eviction is disabled under helix "
f"CP pending end-to-end validation. Increase "
f"kv_cache_config.max_gpu_total_bytes (rank-local bytes) "
f"or max_tokens (GLOBAL tokens across CP ranks, not "
f"per-rank), or reduce concurrency."
)
Comment thread
lancelly marked this conversation as resolved.
req_it_end, success = self._try_evict_for_gen(
req, requests_list, req_it, req_it_end, evicted, inflight_request_ids
)
Expand Down
Loading
Loading