-
Notifications
You must be signed in to change notification settings - Fork 8.9k
[PD]: Support HiCache prefetching and pd-incremental transfer on decode side #26227
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
Merged
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
7505929
Support decode side hicache
hzh0425 079eef6
Merge remote-tracking branch 'origin1/main' into hicache/decode-hicache
hzh0425 529f56c
upd1
hzh0425 fd97af4
upd hicache
hzh0425 c74695a
Merge remote-tracking branch 'origin1/main' into hicache/deocde-hicache3
hzh0425 97cf9c5
upd hicache
hzh0425 ee06120
Merge remote-tracking branch 'origin/main' into hicache/deocde-hicache3
hzh0425 356acea
upd
hzh0425 4bd80b2
extract deocde_hicache_mixin
hzh0425 c6a535d
upd
hzh0425 56f066b
Merge branch 'main' into hicache/decode-hicache
hzh0425 6697c83
update test
hzh0425 167a784
fix
hzh0425 9bf2301
upd
hzh0425 abafb6f
Merge branch 'main' into hicache/decode-hicache
hzh0425 669e084
Merge branch 'main' into hicache/decode-hicache
hzh0425 080c9e7
Merge branch 'main' into hicache/decode-hicache
huangtingwei9988 00c2ced
fix comment
hzh0425 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
311 changes: 311 additions & 0 deletions
311
python/sglang/srt/disaggregation/decode_hicache_mixin.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,311 @@ | ||
| """HiCache integration mixins for the decode side of PD disaggregation""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from dataclasses import dataclass | ||
| from enum import Enum | ||
| from typing import TYPE_CHECKING, Any, List, Optional | ||
|
|
||
| import torch | ||
|
|
||
| from sglang.srt.disaggregation.base import KVPoll | ||
| from sglang.srt.managers.schedule_policy import match_prefix_for_req | ||
| from sglang.srt.mem_cache.base_prefix_cache import InitLoadBackParams | ||
|
|
||
| if TYPE_CHECKING: | ||
| from sglang.srt.disaggregation.decode import DecodeRequest | ||
| from sglang.srt.managers.schedule_batch import Req | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @dataclass | ||
| class DecodePrefixMatch: | ||
| prefix_indices: torch.Tensor | ||
| l2_host_hit_length: int | ||
| l3_storage_hit_length: int | ||
| last_device_node: Any | ||
| last_host_node: Any = None | ||
| prefetch_registered: bool = False | ||
|
|
||
| @property | ||
| def l1_prefix_len(self) -> int: | ||
| return len(self.prefix_indices) | ||
|
|
||
| @property | ||
| def decode_prefix_len(self) -> int: | ||
| return self.l1_prefix_len + self.l2_host_hit_length + self.l3_storage_hit_length | ||
|
|
||
| @property | ||
| def needs_local_restore(self) -> bool: | ||
| return self.decode_prefix_len > self.l1_prefix_len | ||
|
|
||
| @property | ||
| def restore_token_count(self) -> int: | ||
| """Number of tokens that need L2/L3 load_back to device.""" | ||
| return self.decode_prefix_len - self.l1_prefix_len | ||
|
|
||
|
|
||
| class HiCacheRestoreResult(Enum): | ||
| """Outcome of one tick of the HiCache local-restore state machine.""" | ||
|
|
||
| PENDING = "pending" | ||
| READY = "ready" | ||
| FAILED = "failed" | ||
|
|
||
|
|
||
| class DecodeHiCachePreallocMixin: | ||
| """HiCache hooks for ``DecodePreallocQueue``: issue prefetch + reserve tokens.""" | ||
|
|
||
| def _build_decode_prefix_match(self, req: "Req", result: Any) -> DecodePrefixMatch: | ||
| """Convert a ``match_prefix_for_req`` result into ``DecodePrefixMatch``. | ||
|
|
||
| Performs the optional L3 storage hit length query when decode-side | ||
| HiCache is enabled and the last host node is backed up. | ||
| """ | ||
| prefix_indices = result.device_indices | ||
| l1_prefix_len = len(prefix_indices) | ||
| l2_host_hit_length = result.host_hit_length | ||
|
|
||
| l3_storage_hit_length = 0 | ||
| last_host_node = None | ||
| if self.scheduler.enable_decode_hicache: | ||
| last_host_node = result.last_host_node | ||
| if last_host_node.backuped or last_host_node is self.tree_cache.root_node: | ||
| matched_len = l1_prefix_len + l2_host_hit_length | ||
| suffix_tokens = req.origin_input_ids[matched_len:] | ||
| last_hash = last_host_node.get_last_hash_value() | ||
| prefix_keys = ( | ||
| last_host_node.get_prefix_hash_values(last_host_node.parent) | ||
| if self.tree_cache.hicache_storage_pass_prefix_keys | ||
| else None | ||
| ) | ||
| l3_storage_hit_length = self.tree_cache.query_storage_hit_length( | ||
| last_host_node, | ||
| suffix_tokens, | ||
| last_hash, | ||
| prefix_keys, | ||
| ) | ||
|
|
||
| return DecodePrefixMatch( | ||
| prefix_indices=prefix_indices, | ||
| l2_host_hit_length=l2_host_hit_length, | ||
| l3_storage_hit_length=l3_storage_hit_length, | ||
| last_device_node=result.last_device_node, | ||
| last_host_node=last_host_node if l3_storage_hit_length > 0 else None, | ||
| ) | ||
|
|
||
| def _start_hicache_prefetch( | ||
| self, req: "Req", prefix_match: Optional["DecodePrefixMatch"] | ||
| ) -> None: | ||
| """Issue L3 storage prefetch after admission succeeds. | ||
|
|
||
| On failure, degrades to L2-only restore by clearing l3 fields. | ||
| """ | ||
| if ( | ||
| prefix_match is None | ||
| or prefix_match.l3_storage_hit_length <= 0 | ||
| or prefix_match.last_host_node is None | ||
| ): | ||
| return | ||
| try: | ||
| node = prefix_match.last_host_node | ||
| matched_len = prefix_match.l1_prefix_len + prefix_match.l2_host_hit_length | ||
| suffix = req.origin_input_ids[ | ||
| matched_len : matched_len + prefix_match.l3_storage_hit_length | ||
| ] | ||
| last_hash = node.get_last_hash_value() | ||
| prefix_keys = ( | ||
| node.get_prefix_hash_values(node.parent) | ||
| if self.tree_cache.hicache_storage_pass_prefix_keys | ||
| else None | ||
| ) | ||
| self.tree_cache.prefetch_from_storage( | ||
| req.rid, node, suffix, last_hash, prefix_keys | ||
| ) | ||
| prefix_match.prefetch_registered = ( | ||
| req.rid in self.tree_cache.ongoing_prefetch | ||
| ) | ||
| except Exception as e: | ||
| logger.warning( | ||
| "HiCache L3 prefetch failed for rid=%s: %s; falling back to L2-only LoadingBack", | ||
| req.rid, | ||
| e, | ||
| ) | ||
| prefix_match.l3_storage_hit_length = 0 | ||
| prefix_match.prefetch_registered = False | ||
|
|
||
| def _hicache_pending_restore_tokens(self) -> int: | ||
| """Total device tokens reserved for pending HiCache L2/L3 load_back.""" | ||
| if not self.scheduler.enable_decode_hicache: | ||
| return 0 | ||
| return sum( | ||
| dr.prefix_match.restore_token_count | ||
| for dr in self.transfer_queue.queue | ||
| if dr.prefix_match is not None | ||
| and dr.hicache_restore_status == HiCacheRestoreResult.PENDING | ||
| and dr.hicache_restored_node is None | ||
| ) | ||
|
|
||
|
|
||
| class HiCacheRestoreGatedKVReceiver: | ||
| """Wraps a kv_receiver so KVPoll.Success is gated on HiCache restore READY.""" | ||
|
|
||
| def __init__(self, decode_req: "DecodeRequest"): | ||
| self.decode_req = decode_req | ||
|
|
||
| def poll(self) -> KVPoll: | ||
| poll = self.decode_req.kv_receiver.poll() | ||
| if ( | ||
| poll == KVPoll.Success | ||
| and self.decode_req.hicache_restore_status == HiCacheRestoreResult.PENDING | ||
| ): | ||
| return KVPoll.Transferring | ||
| return poll | ||
|
|
||
|
|
||
| class DecodeHiCacheTransferMixin: | ||
| """HiCache hooks for ``DecodeTransferQueue``: drive restore state machine.""" | ||
|
|
||
| def _clean_hicache_prefetch_resources(self, decode_req: "DecodeRequest") -> None: | ||
| if ( | ||
| decode_req.prefix_match is not None | ||
| and decode_req.prefix_match.prefetch_registered | ||
| ): | ||
| self.tree_cache.release_aborted_request(decode_req.req.rid) | ||
| if decode_req.hicache_restored_node is not None: | ||
| self.tree_cache.dec_lock_ref(decode_req.hicache_restored_node) | ||
| decode_req.hicache_restored_node = None | ||
|
|
||
| def _try_hicache_queue_load_back(self, dr: "DecodeRequest") -> bool: | ||
| """Queue one L2->L1 load_back op for ``dr``; True iff a DMA was queued. | ||
|
|
||
| On success, ``dr.hicache_restored_node`` and ``hicache_restored_kv_indices`` | ||
| are populated, and an inc_lock_ref is held until commit/abort. | ||
| Trivial cases (all-on-device / no needed coverage) auto-flip to READY. | ||
| Failback paths flip to FAILED. | ||
| """ | ||
| pm = dr.prefix_match | ||
|
|
||
| # Wait for L3 -> L2 prefetch to drain (skip when no L3 hit). | ||
| if pm.l3_storage_hit_length > 0: | ||
| if not self.tree_cache.check_prefetch_progress(dr.req.rid): | ||
| return False | ||
| self.tree_cache.pop_prefetch_loaded_tokens(dr.req.rid) | ||
|
|
||
| # Re-match: req.last_node / prefix_indices updated to current device state. | ||
| rematch = match_prefix_for_req( | ||
| self.tree_cache, | ||
| dr.req, | ||
| dr.req.origin_input_ids, | ||
| cow_mamba=False, | ||
| include_req=True, | ||
| ) | ||
| new_indices, restored_node = self.tree_cache.init_load_back( | ||
| InitLoadBackParams( | ||
| best_match_node=rematch.best_match_node, | ||
| host_hit_length=rematch.host_hit_length, | ||
| req=dr.req, | ||
| ) | ||
| ) | ||
| # Failback: total coverage < required prefix means device alloc likely failed. | ||
| if len(rematch.device_indices) + len(new_indices) < pm.decode_prefix_len: | ||
| logger.warning( | ||
| "HiCache load_back failed for rid=%s: device_indices=%d, " | ||
| "new_indices=%d, expected decode_prefix_len=%d (l1=%d, l2=%d, l3=%d)", | ||
| dr.req.rid, | ||
| len(rematch.device_indices), | ||
| len(new_indices), | ||
| pm.decode_prefix_len, | ||
| pm.l1_prefix_len, | ||
| pm.l2_host_hit_length, | ||
| pm.l3_storage_hit_length, | ||
| ) | ||
| dr.hicache_restore_status = HiCacheRestoreResult.FAILED | ||
| return False | ||
|
|
||
| dr.hicache_restored_kv_indices = torch.cat( | ||
| [rematch.device_indices[pm.l1_prefix_len :], new_indices] | ||
| ) | ||
| dr.hicache_restored_node = restored_node | ||
| self.tree_cache.inc_lock_ref(restored_node) | ||
|
|
||
| if len(new_indices) == 0: | ||
| # Whole prefix already on device; no DMA needed. | ||
| dr.hicache_restore_status = HiCacheRestoreResult.READY | ||
| return False | ||
| return True | ||
|
|
||
| def _process_hicache_local_restores( | ||
| self, decode_reqs: List["DecodeRequest"] | ||
| ) -> None: | ||
| if not hasattr(self.tree_cache, "is_load_back_event_done"): | ||
| return | ||
|
|
||
| # Filter once: keep only PENDING reqs that still need restore work; | ||
| # trivially-done reqs (no prefix_match / nothing to restore) flip to READY. | ||
| active: List["DecodeRequest"] = [] | ||
| for dr in decode_reqs: | ||
| if dr.hicache_restore_status != HiCacheRestoreResult.PENDING: | ||
| continue | ||
| pm = dr.prefix_match | ||
| if pm is None or not pm.needs_local_restore: | ||
| dr.hicache_restore_status = HiCacheRestoreResult.READY | ||
| continue | ||
| active.append(dr) | ||
|
|
||
| # Phase A: advance in-flight DMAs to READY. | ||
| for dr in active: | ||
| if ( | ||
| dr.hicache_restored_node is not None | ||
| and self.tree_cache.is_load_back_event_done( | ||
| dr.hicache_load_consumer_index | ||
| ) | ||
| ): | ||
| dr.hicache_restore_status = HiCacheRestoreResult.READY | ||
|
|
||
| # Phase B: queue new load_back ops if the next slot is free. | ||
| # The (producer_index + 1) check ensures we never overwrite a still-in-flight slot: | ||
| # if a previous req holds that slot and isn't done, its event won't be signaled. | ||
| counter = self.tree_cache.cache_controller.layer_done_counter | ||
| if not self.tree_cache.is_load_back_event_done( | ||
| (counter.producer_index + 1) % counter.num_counters | ||
| ): | ||
| return | ||
| queued = [ | ||
| dr | ||
| for dr in active | ||
| if dr.hicache_restored_node is None | ||
| and self._try_hicache_queue_load_back(dr) | ||
| ] | ||
| if not queued: | ||
| return | ||
|
|
||
| # Phase C: kick off merged DMA, bind consumer_index for Phase A polling next tick. | ||
| consumer_index = self.tree_cache.ready_to_load_host_cache() | ||
| if consumer_index < 0: | ||
| for dr in queued: | ||
| dr.hicache_restore_status = HiCacheRestoreResult.READY | ||
| return | ||
| for dr in queued: | ||
| dr.hicache_load_consumer_index = consumer_index | ||
|
|
||
| def _commit_hicache_local_restore_to_req(self, decode_req: "DecodeRequest") -> None: | ||
| prefix_match = decode_req.prefix_match | ||
| if prefix_match is None or not prefix_match.needs_local_restore: | ||
| return | ||
|
|
||
| self.tree_cache.dec_lock_ref(prefix_match.last_device_node) | ||
|
|
||
| self.tree_cache.req_to_token_pool.write( | ||
| ( | ||
| decode_req.req.req_pool_idx, | ||
| slice(prefix_match.l1_prefix_len, prefix_match.decode_prefix_len), | ||
| ), | ||
| decode_req.hicache_restored_kv_indices, | ||
| ) | ||
| decode_req.req.prefix_indices = torch.cat( | ||
| [prefix_match.prefix_indices, decode_req.hicache_restored_kv_indices] | ||
| ) | ||
| decode_req.req.last_node = decode_req.hicache_restored_node | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -812,6 +812,20 @@ def loading_check(self): | |
| # ACK until all events are processed | ||
| del self.cache_controller.ack_load_queue[:finish_count] | ||
|
|
||
| def is_load_back_event_done(self, consumer_index: int) -> bool: | ||
| """Return True after the local load-back event is complete.""" | ||
| if consumer_index < 0: | ||
| return True | ||
|
|
||
| finish_event = self.cache_controller.layer_done_counter.events[ | ||
| consumer_index | ||
| ].finish_event | ||
| if not finish_event.query(): | ||
| return False | ||
|
|
||
| self.loading_check() | ||
| return True | ||
|
|
||
| def evictable_size(self): | ||
| return self.evictable_size_ | ||
|
|
||
|
|
@@ -1070,6 +1084,42 @@ def init_load_back( | |
| last_node, | ||
| ) | ||
|
|
||
| def query_storage_hit_length( | ||
| self, | ||
| last_host_node: TreeNode, | ||
| new_input_tokens: List[int], | ||
| last_hash: Optional[str] = None, | ||
| prefix_keys: Optional[List[str]] = None, | ||
| ) -> int: | ||
| if not self.enable_storage or self.cache_controller.prefetch_rate_limited(): | ||
| return 0 | ||
|
hzh0425 marked this conversation as resolved.
|
||
|
|
||
| prefetch_key = RadixKey( | ||
| new_input_tokens, | ||
| extra_key=last_host_node.key.extra_key, | ||
| is_bigram=self.is_eagle, | ||
| ).page_aligned(self.page_size) | ||
| if len(prefetch_key) < self.prefetch_threshold: | ||
| return 0 | ||
|
|
||
| operation = PrefetchOperation( | ||
| "__storage_hit_query__", | ||
| self.cache_controller.mem_pool_host.get_dummy_flat_data_page()[:0], | ||
| prefetch_key, | ||
| last_hash, | ||
| prefix_keys, | ||
| ) | ||
| hash_values, storage_hit_count = self.cache_controller._storage_hit_query( | ||
| operation | ||
| ) | ||
| storage_hit_count_tensor = torch.tensor(storage_hit_count, dtype=torch.int) | ||
| self._all_reduce_attn_groups( | ||
|
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. We could revisit this in the future if this per-request |
||
| storage_hit_count_tensor, torch.distributed.ReduceOp.MIN | ||
| ) | ||
|
hzh0425 marked this conversation as resolved.
|
||
| storage_hit_count = storage_hit_count_tensor.item() | ||
| storage_hit_count = storage_hit_count - (storage_hit_count % self.page_size) | ||
| return storage_hit_count | ||
|
|
||
| def ready_to_load_host_cache(self) -> int: | ||
| """ | ||
| Notify the cache controller to start the KV cache loading. | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.