-
Notifications
You must be signed in to change notification settings - Fork 2.8k
[None][fix] Account for PARD draft KV capacity in cache manager V2 #18932
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -120,6 +120,11 @@ | |
| "iter_partial_reused_blocks", | ||
| "iter_missed_blocks", | ||
| ) | ||
|
|
||
| # Every generation step reserves the golden/base token in addition to any | ||
| # speculative draft tokens. Keep this shared with the capacity-growth paths so | ||
| # cache estimation and runtime allocation cannot drift apart. | ||
| BASE_GENERATION_TOKEN_COUNT = 1 | ||
| KV_CACHE_ITERATION_STATS_POOL_GROUP_FIELDS = tuple( | ||
| field_name | ||
| for field_name in KV_CACHE_ITERATION_STATS_DELTA_FIELDS | ||
|
|
@@ -331,23 +336,20 @@ def _estimate_swa_cache_size( | |
| *, | ||
| context: bool, | ||
| scratch: bool, | ||
| generation_capacity_headroom: Optional[int] = None, | ||
| generation_capacity_headroom: int = BASE_GENERATION_TOKEN_COUNT, | ||
| ) -> tuple[int, int]: | ||
| tokens_per_block = int(tokens_per_block) | ||
| size_per_token = 0 | ||
| size_per_request = 0 | ||
| scratch_keys = set() | ||
| for layer_size, window_size in zip(layer_sizes, attention_windows): | ||
| if window_size is not None and window_size > 0: | ||
| if generation_capacity_headroom is None: | ||
| window_blocks = math.ceil(window_size / tokens_per_block) | ||
| else: | ||
| # Match DFlash's retained boundary page and capacity reserved | ||
| # ahead of committed history for the next draft step. | ||
| window_blocks = ( | ||
| math.ceil((window_size + generation_capacity_headroom - 1) / tokens_per_block) | ||
| + 1 | ||
| ) | ||
| # Match AttnLifeCycle.get_stale_range(): the live interval contains | ||
| # window_size + generation_capacity_headroom - 1 tokens. Across all | ||
| # page offsets, that interval touches at most the count below. | ||
| window_blocks = ( | ||
| math.ceil((window_size + generation_capacity_headroom - 2) / tokens_per_block) + 1 | ||
| ) | ||
| window_tokens = window_blocks * tokens_per_block | ||
| if not context: | ||
| size_per_request += window_tokens * layer_size | ||
|
|
@@ -363,16 +365,63 @@ def _estimate_swa_cache_size( | |
| return size_per_token, size_per_request | ||
|
|
||
|
|
||
| def _get_dflash_generation_kv_capacity_headroom(spec_config) -> Optional[int]: | ||
| """DFlash KV capacity reserved ahead of committed history.""" | ||
| from tensorrt_llm._torch.speculative.interface import SpeculativeDecodingMode | ||
| def _get_kv_reserve_draft_tokens(spec_config, *, is_draft: bool) -> int: | ||
| """Return the same speculative KV reserve used by runtime resize paths.""" | ||
| if spec_config is None: | ||
| return 0 | ||
| reserve = spec_config.max_total_draft_tokens | ||
| if ( | ||
| is_draft | ||
| and getattr(spec_config, "use_dynamic_tree", False) | ||
| and getattr(spec_config, "dynamic_tree_max_topK", 0) > 0 | ||
| ): | ||
| draft_loop_tokens = spec_config.dynamic_tree_max_topK * spec_config.max_draft_len | ||
| reserve = max(reserve, draft_loop_tokens) | ||
| return reserve | ||
|
|
||
|
|
||
| def _estimate_cache_size_components( | ||
| layer_sizes: Sequence[int], | ||
| attention_windows: Sequence[int | None], | ||
| tokens_per_block: int, | ||
| *, | ||
| scratch: bool, | ||
| generation_capacity_headroom: int, | ||
| ) -> tuple[int, int, int]: | ||
| """Return context/generation bytes per token and generation bytes per request. | ||
|
|
||
| Static profiling and runtime quota conversion must charge the same SWA | ||
| retention pages and context scratch space. Resume-watermark normalization | ||
| is separate from these usable-capacity costs. | ||
| """ | ||
| full_attn_size = _estimate_full_attn_size_per_token(layer_sizes, attention_windows) | ||
| context_swa_size, _ = _estimate_swa_cache_size( | ||
| layer_sizes, attention_windows, tokens_per_block, context=True, scratch=scratch | ||
| ) | ||
| generation_swa_size, generation_swa_per_request = _estimate_swa_cache_size( | ||
| layer_sizes, | ||
| attention_windows, | ||
| tokens_per_block, | ||
| context=False, | ||
| scratch=False, | ||
| generation_capacity_headroom=generation_capacity_headroom, | ||
| ) | ||
| return ( | ||
| full_attn_size + context_swa_size, | ||
| full_attn_size + generation_swa_size, | ||
| generation_swa_per_request, | ||
| ) | ||
|
|
||
| if spec_config is None or spec_config.spec_dec_mode != SpeculativeDecodingMode.DFLASH: | ||
| return None | ||
|
|
||
| def _get_generation_kv_capacity_headroom(spec_config, *, is_draft: bool) -> int: | ||
| """Maximum capacity lead over history used by generation KV allocation.""" | ||
| from tensorrt_llm._torch.speculative import get_num_extra_kv_tokens | ||
|
|
||
| return get_num_extra_kv_tokens(spec_config) + spec_config.tokens_per_gen_step | ||
| if spec_config is None: | ||
| return BASE_GENERATION_TOKEN_COUNT | ||
| reserve = _get_kv_reserve_draft_tokens(spec_config, is_draft=is_draft) | ||
| dynamic_reserve = reserve - spec_config.max_total_draft_tokens | ||
| return get_num_extra_kv_tokens(spec_config) + spec_config.tokens_per_gen_step + dynamic_reserve | ||
|
|
||
|
|
||
| def _get_single_swa_pool_slot_bytes( | ||
|
|
@@ -1029,15 +1078,12 @@ def __init__( | |
| if not self._supports_reuse_match_backoff: | ||
| self.reuse_match_backoff = 0 | ||
| # Mirror V1's KV reserve sizing (see V1 __init__ for rationale). | ||
| self._kv_reserve_draft_tokens = self.max_total_draft_tokens | ||
| if ( | ||
| self.is_draft | ||
| and spec_config is not None | ||
| and getattr(spec_config, "use_dynamic_tree", False) | ||
| and getattr(spec_config, "dynamic_tree_max_topK", 0) > 0 | ||
| ): | ||
| draft_loop_tokens = spec_config.dynamic_tree_max_topK * spec_config.max_draft_len | ||
| self._kv_reserve_draft_tokens = max(self.max_total_draft_tokens, draft_loop_tokens) | ||
| self._kv_reserve_draft_tokens = _get_kv_reserve_draft_tokens( | ||
| spec_config, is_draft=self.is_draft | ||
| ) | ||
| self._generation_kv_capacity_headroom = _get_generation_kv_capacity_headroom( | ||
| spec_config, is_draft=self.is_draft | ||
| ) | ||
|
|
||
| self.event_buffer_max_size = kv_cache_config.event_buffer_max_size | ||
| self.enable_stats = enable_stats | ||
|
|
@@ -1417,11 +1463,8 @@ def create_cold_page_codec(cache_config: object) -> Optional[object]: | |
| self.max_seq_len = int(max_num_tokens) | ||
|
|
||
| # Pad max_blocks_per_seq to next multiple of 4 (copy_block_offsets kernel). | ||
| # Account for max single-sequence capacity = seq_len + extra KV tokens + | ||
| # _kv_reserve_draft_tokens (see __init__) + 1 base decode token. | ||
| max_seq_capacity = ( | ||
| self.max_seq_len + self.num_extra_kv_tokens + self._kv_reserve_draft_tokens + 1 | ||
| ) | ||
| # Include the same maximum generation lead used by allocation and sizing. | ||
| max_seq_capacity = self.max_seq_len + self._generation_kv_capacity_headroom | ||
| self.max_blocks_per_seq = ( | ||
| max_seq_capacity + self._ledger_tokens_per_block - 1 | ||
| ) // self._ledger_tokens_per_block | ||
|
|
@@ -1707,33 +1750,26 @@ def _get_max_tokens_from_quota(self, quota: int) -> float: | |
|
|
||
| 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 | ||
| ) | ||
| context_swa_size_per_token, _ = _estimate_swa_cache_size( | ||
| ( | ||
| context_size_per_token, | ||
| generation_size_per_token, | ||
| generation_swa_size_per_request, | ||
| ) = _estimate_cache_size_components( | ||
| layer_sizes, | ||
| attention_windows, | ||
| self.tokens_per_block, | ||
| context=True, | ||
| scratch=self.enable_swa_scratch_reuse, | ||
| ) | ||
| ( | ||
| generation_swa_size_per_token, | ||
| generation_swa_size_per_request, | ||
| ) = _estimate_swa_cache_size( | ||
| layer_sizes, attention_windows, self.tokens_per_block, context=False, scratch=False | ||
| generation_capacity_headroom=self._generation_kv_capacity_headroom, | ||
| ) | ||
| size_per_batch = self.max_batch_size * generation_swa_size_per_request | ||
| if quota < size_per_batch: | ||
| return 0 | ||
| context_size_per_token = full_attn_size_per_token + context_swa_size_per_token | ||
| context_limit_quota = self.max_num_tokens * context_size_per_token + size_per_batch | ||
| if quota <= context_limit_quota: | ||
| if context_size_per_token <= 0: | ||
| return float("inf") | ||
| return (quota - size_per_batch) / context_size_per_token | ||
|
|
||
| generation_size_per_token = full_attn_size_per_token + generation_swa_size_per_token | ||
| if generation_size_per_token <= 0: | ||
| return float("inf") | ||
| return self.max_num_tokens + (quota - context_limit_quota) / generation_size_per_token | ||
|
|
@@ -1750,34 +1786,24 @@ def _get_quota_from_max_tokens(self, max_tokens: int) -> int: | |
|
|
||
| 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 | ||
| ) | ||
| ( | ||
| context_swa_size_per_token, | ||
| _, | ||
| ) = _estimate_swa_cache_size( | ||
| context_size_per_token, | ||
| generation_size_per_token, | ||
| generation_swa_size_per_request, | ||
| ) = _estimate_cache_size_components( | ||
| layer_sizes, | ||
| attention_windows, | ||
| self.tokens_per_block, | ||
| context=True, | ||
| scratch=self.enable_swa_scratch_reuse, | ||
| ) | ||
| ( | ||
| generation_swa_size_per_token, | ||
| generation_swa_size_per_request, | ||
| ) = _estimate_swa_cache_size( | ||
| layer_sizes, attention_windows, self.tokens_per_block, context=False, scratch=False | ||
| generation_capacity_headroom=self._generation_kv_capacity_headroom, | ||
| ) | ||
| context_tokens = min(max_tokens, self.max_num_tokens) | ||
| generation_tokens = max_tokens - context_tokens | ||
| generation_quota = ( | ||
| max_tokens * full_attn_size_per_token | ||
| + generation_tokens * generation_swa_size_per_token | ||
| return int( | ||
| context_tokens * context_size_per_token | ||
| + generation_tokens * generation_size_per_token | ||
| + self.max_batch_size * generation_swa_size_per_request | ||
| ) | ||
| context_extra_quota = context_tokens * context_swa_size_per_token | ||
| return int(generation_quota + context_extra_quota) | ||
|
|
||
| def _get_event_num_blocks_per_cache_level( | ||
| self, | ||
|
|
@@ -2546,7 +2572,7 @@ def _required_gen_capacity(self, req: LlmRequest, current_capacity: int) -> int: | |
|
|
||
| Grows *current_capacity* by 1 + draft tokens. | ||
| """ | ||
| return current_capacity + 1 + self._generation_draft_slots(req) | ||
| return current_capacity + BASE_GENERATION_TOKEN_COUNT + self._generation_draft_slots(req) | ||
|
|
||
| def _generation_draft_slots(self, req: LlmRequest) -> int: | ||
| """Physical draft width reserved for one iteration. Dynamic-tree draft pools | ||
|
|
@@ -4162,37 +4188,34 @@ def get_cache_size_per_token( | |
| backoff, | ||
| max_seq_len, | ||
| ) | ||
| full_attn_size_per_token = _estimate_full_attn_size_per_token( | ||
| layer_sizes, attention_windows | ||
| generation_capacity_headroom = _get_generation_kv_capacity_headroom( | ||
| spec_config, is_draft=is_draft | ||
| ) | ||
| dflash_headroom = _get_dflash_generation_kv_capacity_headroom(spec_config) | ||
| is_dflash_draft = is_draft and dflash_headroom is not None | ||
| generation_capacity_headroom = dflash_headroom if is_dflash_draft else None | ||
| swa_size_per_token, swa_size_per_request = _estimate_swa_cache_size( | ||
| ( | ||
| context_size_per_token, | ||
| cache_size_per_token, | ||
| swa_size_per_request, | ||
| ) = _estimate_cache_size_components( | ||
| layer_sizes, | ||
| attention_windows, | ||
| tokens_per_block, | ||
| context=False, | ||
| scratch=False, | ||
| scratch=bool(kwargs.get("enable_swa_scratch_reuse", False)), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This classmethod already receives |
||
| generation_capacity_headroom=generation_capacity_headroom, | ||
| ) | ||
| context_swa_size_per_token = 0 | ||
| if is_dflash_draft: | ||
| context_swa_size_per_token, _ = _estimate_swa_cache_size( | ||
| layer_sizes, | ||
| attention_windows, | ||
| tokens_per_block, | ||
| context=True, | ||
| scratch=False, | ||
| ) | ||
| # The affine slope covers all tokens; context additionally retains SWA | ||
| # pages for the current token batch beyond the generation windows. | ||
| fixed_cost = ( | ||
| swa_size_per_request * max_batch_size + context_swa_size_per_token * max_num_tokens | ||
| swa_size_per_request * max_batch_size | ||
| + (context_size_per_token - cache_size_per_token) * max_num_tokens | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This term is a real fix -- the runtime quota conversion has always charged On a Gemma3-27B-shaped config (30 SWA layers @ w=1024, 10 full-attn, 2048 B/token/layer, tpb=64, bs=8, max_num_tokens=8192) the target intercept goes from ~0.47 GiB to ~0.97 GiB: ~469 MiB from this context term and ~30 MiB from the (For what it is worth, I brute-forced the new |
||
| ) | ||
| cache_size_per_token = full_attn_size_per_token + swa_size_per_token | ||
| bytes_per_slot = _get_single_swa_pool_slot_bytes( | ||
| layer_sizes, attention_windows, tokens_per_block | ||
| ) | ||
| if is_dflash_draft and bytes_per_slot is not None: | ||
| if is_draft and fixed_cost > 0 and bytes_per_slot is not None: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor robustness point, not currently reachable: |
||
| # The affine intercept is the configured quota needed to preserve | ||
| # the fixed usable capacity at V2's resume watermark. Keep the | ||
| # allocator's page rounding inside the manager estimator rather | ||
| # than extending the generic CacheCost model with pool geometry. | ||
| required_slots = math.ceil(fixed_cost / bytes_per_slot) | ||
| resume_util = float(np.float32(kv_cache_config.max_util_for_resume)) | ||
| fixed_cost = math.ceil(required_slots / resume_util) * bytes_per_slot | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_max_num_tokensis set unconditionally in__init__(L631) and read directly everywhere else in this file (L879, L1470, L1671, L2058), so this fallback is only needed by the hand-builtobject.__new__(KvCacheCreator)double intest_kv_cache_budget_split._make_creator, which never sets it. Onmainthe conditional expressionself._max_num_tokens if is_draft else 0short-circuited and never touched the attribute foris_draft=False; reading it unconditionally is what makes those ~15_split_kv_cache_budget_for_drafttests need the fallback.Please set
_max_num_tokensin that fixture (astest_kv_cache_estimation.pyL169 andtest_dual_pool_kv_cache.pyL179 already do) and useself._max_num_tokenshere -- otherwise a genuinely missing attribute silently estimates a zero fixed cost instead of failing.