|
21 | 21 | import math |
22 | 22 | import random |
23 | 23 | from abc import ABC, abstractmethod |
24 | | -from collections import namedtuple |
| 24 | +from collections import OrderedDict, namedtuple |
25 | 25 | from dataclasses import MISSING, astuple, dataclass, field, fields, replace |
26 | 26 | from typing import TYPE_CHECKING, Dict, List, Set, Tuple |
27 | 27 |
|
@@ -178,6 +178,20 @@ def create( |
178 | 178 | kv_cache_manager has block reuse enabled; DefaultADPRouter |
179 | 179 | otherwise. |
180 | 180 | """ |
| 181 | + if attention_dp_config is not None and getattr( |
| 182 | + attention_dp_config, "kv_cache_routing_conversation_affinity", False |
| 183 | + ): |
| 184 | + # Explicit conversation_id -> rank affinity. Independent of the KV |
| 185 | + # cache manager (works with or without block reuse, though it is |
| 186 | + # most beneficial with reuse on), so it is checked before the |
| 187 | + # KV-cache-aware path and takes precedence when both are enabled. |
| 188 | + return ConversationAwareADPRouter( |
| 189 | + dist=dist, |
| 190 | + max_sessions=getattr( |
| 191 | + attention_dp_config, "kv_cache_routing_max_sessions", 1 << 16 |
| 192 | + ), |
| 193 | + ) |
| 194 | + |
181 | 195 | if ( |
182 | 196 | attention_dp_config is not None |
183 | 197 | and attention_dp_config.enable_kv_cache_aware_routing |
@@ -726,3 +740,191 @@ def _sort_key(req_item): |
726 | 740 | ) |
727 | 741 |
|
728 | 742 | return all_ranks_new_requests, expected_num_active_requests |
| 743 | + |
| 744 | + |
| 745 | +class ConversationAwareADPRouter(ADPRouter): |
| 746 | + """Conversation-affinity request router for attention data parallelism. |
| 747 | +
|
| 748 | + Routes the *first* request of each conversation round-robin across ranks, |
| 749 | + then pins every later request carrying the same ``conversation_id`` to the |
| 750 | + rank that served that conversation's first turn. This keeps a multi-turn |
| 751 | + conversation's growing KV-cache prefix on a single rank (maximizing block |
| 752 | + reuse, minimizing recompute and cross-rank migration) while still spreading |
| 753 | + the birth of new conversations evenly. |
| 754 | +
|
| 755 | + Contrast with :class:`KVCacheAwareADPRouter`, which *infers* affinity from |
| 756 | + probed prefix-match lengths: that affinity is lost as soon as a |
| 757 | + conversation's blocks are evicted -- the request then re-routes by load and |
| 758 | + the conversation "migrates" ranks. This router keeps an **explicit** |
| 759 | + ``conversation_id -> rank`` map, so stickiness is deterministic and survives |
| 760 | + cache eviction. It is inspired by the serve-level ConversationRouter |
| 761 | + (``tensorrt_llm/serve/router.py``) and the first-turn-round-robin idea from |
| 762 | + PR #14744, but applied at the intra-instance ADP-rank level. |
| 763 | +
|
| 764 | + ``conversation_id`` is read from |
| 765 | + ``req.py_disaggregated_params.conversation_id`` (populated by the serve-side |
| 766 | + conversation / KV-aware router from the ``X-Session-ID`` header; see |
| 767 | + PR #14744). When it is absent -- header not sent, non-disaggregated, or the |
| 768 | + serve-side propagation is not present -- the request falls back to |
| 769 | + load-balanced round-robin and is *not* recorded, so behaviour degrades |
| 770 | + gracefully to ``DefaultADPRouter``-style spreading. |
| 771 | +
|
| 772 | + Determinism: :meth:`route_requests` runs locally on every TP rank with no |
| 773 | + broadcast, so the round-robin cursor and the conversation->rank map MUST |
| 774 | + evolve identically on every rank. They do, because every rank processes the |
| 775 | + same ``new_requests`` in the same order. Any divergence would deadlock the |
| 776 | + distributed allgather protocol -- this is the same invariant the warmup / |
| 777 | + first-turn-round-robin cursors rely on above. |
| 778 | + """ |
| 779 | + |
| 780 | + # Default LRU cap on the conversation->rank map (entries are ~tens of |
| 781 | + # bytes each). Bounds memory on long-running servers as conversations churn. |
| 782 | + DEFAULT_MAX_SESSIONS = 1 << 16 |
| 783 | + |
| 784 | + def __init__(self, dist: "Distributed", max_sessions: int = DEFAULT_MAX_SESSIONS): |
| 785 | + super().__init__(dist) |
| 786 | + # conversation_id -> rank, LRU-ordered (most-recently-routed last). |
| 787 | + self._conv_to_rank: "OrderedDict[str, int]" = OrderedDict() |
| 788 | + self._max_sessions = max(1, int(max_sessions)) |
| 789 | + # Round-robin cursor for first-turn / unkeyed requests. Mutated |
| 790 | + # identically on every rank (route_requests is deterministic), exactly |
| 791 | + # like KVCacheAwareADPRouter._first_turn_rr_counter; divergence deadlocks. |
| 792 | + self._rr_counter = 0 |
| 793 | + |
| 794 | + def create_rank_state( |
| 795 | + self, |
| 796 | + active_requests: list[LlmRequest], |
| 797 | + new_requests: list[RequestQueueItem], |
| 798 | + ) -> RankState: |
| 799 | + if self.dist.has_cp_helix: |
| 800 | + num_active_tokens = sum(req.total_input_len_cp for req in active_requests) |
| 801 | + else: |
| 802 | + num_active_tokens = sum(req.py_orig_prompt_len for req in active_requests) |
| 803 | + return RankState( |
| 804 | + rank=self.dist.tp_rank, |
| 805 | + num_active_requests=len(active_requests), |
| 806 | + num_active_tokens=num_active_tokens, |
| 807 | + ) |
| 808 | + |
| 809 | + @staticmethod |
| 810 | + def _conversation_id(req_item) -> "str | None": |
| 811 | + """Return the request's conversation id, or None when unavailable. |
| 812 | +
|
| 813 | + Read from ``py_disaggregated_params.conversation_id`` (serve-side |
| 814 | + propagated from the X-Session-ID header). Empty strings are treated as |
| 815 | + absent so they fall through to the load-balanced path. |
| 816 | + """ |
| 817 | + req = getattr(req_item, "request", None) |
| 818 | + if req is None: |
| 819 | + return None |
| 820 | + disagg = getattr(req, "py_disaggregated_params", None) |
| 821 | + if disagg is None: |
| 822 | + return None |
| 823 | + conv_id = getattr(disagg, "conversation_id", None) |
| 824 | + return conv_id if conv_id else None |
| 825 | + |
| 826 | + def _record_home(self, conv_id: str, rank: int) -> None: |
| 827 | + """Bind/refresh a conversation's home rank with LRU eviction.""" |
| 828 | + self._conv_to_rank[conv_id] = rank |
| 829 | + self._conv_to_rank.move_to_end(conv_id) |
| 830 | + while len(self._conv_to_rank) > self._max_sessions: |
| 831 | + self._conv_to_rank.popitem(last=False) |
| 832 | + |
| 833 | + def route_requests( |
| 834 | + self, |
| 835 | + all_rank_states: list[RankState], |
| 836 | + new_requests: list[RequestQueueItem], |
| 837 | + max_num_active_requests: int, |
| 838 | + ) -> Tuple[Dict[int, List[RequestQueueItem]], int]: |
| 839 | + tp_size = len(all_rank_states) |
| 840 | + all_ranks_new_requests: Dict[int, List[RequestQueueItem]] = { |
| 841 | + s.rank: [] for s in all_rank_states |
| 842 | + } |
| 843 | + all_ranks_num_active_requests = [s.num_active_requests for s in all_rank_states] |
| 844 | + |
| 845 | + def get_relax_value(req_item): |
| 846 | + scheduling_params = getattr(req_item.request, "py_scheduling_params", None) |
| 847 | + if scheduling_params is None: |
| 848 | + return True |
| 849 | + return scheduling_params.attention_dp_relax |
| 850 | + |
| 851 | + sorted_requests = sorted(new_requests, key=get_relax_value) |
| 852 | + |
| 853 | + # 1) Honour an explicit attention_dp_rank first (strict placement), |
| 854 | + # matching DefaultADPRouter / KVCacheAwareADPRouter. |
| 855 | + remaining_unscheduled: List[RequestQueueItem] = [] |
| 856 | + for req_item in sorted_requests: |
| 857 | + scheduling_params = getattr(req_item.request, "py_scheduling_params", None) |
| 858 | + target_dp_rank = ( |
| 859 | + scheduling_params.attention_dp_rank if scheduling_params is not None else None |
| 860 | + ) |
| 861 | + if ( |
| 862 | + target_dp_rank is not None |
| 863 | + and all_ranks_num_active_requests[target_dp_rank] < max_num_active_requests |
| 864 | + ): |
| 865 | + all_ranks_num_active_requests[target_dp_rank] += 1 |
| 866 | + all_ranks_new_requests[target_dp_rank].append(req_item) |
| 867 | + else: |
| 868 | + remaining_unscheduled.append(req_item) |
| 869 | + |
| 870 | + # 2) Loose fair-share soft cap (mirrors DefaultADPRouter) used for |
| 871 | + # spreading *new* conversations, so a burst of new conversations |
| 872 | + # can't all land on one rank. Sticky returns honour the hard |
| 873 | + # ``max_num_active_requests`` cap instead (stickiness wins). |
| 874 | + num_new_requests_all_ranks = len(remaining_unscheduled) |
| 875 | + total_num_active_requests = sum(all_ranks_num_active_requests) |
| 876 | + expected_num_active_requests = max( |
| 877 | + (total_num_active_requests + num_new_requests_all_ranks + tp_size - 1) // tp_size, |
| 878 | + max(all_ranks_num_active_requests), |
| 879 | + ) |
| 880 | + |
| 881 | + def _least_loaded(soft_cap: int) -> int: |
| 882 | + """Lowest-active rank under soft_cap (deterministic), else global min.""" |
| 883 | + cands = [r for r in range(tp_size) if all_ranks_num_active_requests[r] < soft_cap] |
| 884 | + if not cands: |
| 885 | + cands = list(range(tp_size)) |
| 886 | + return min(cands, key=lambda r: (all_ranks_num_active_requests[r], r)) |
| 887 | + |
| 888 | + def _next_rr(soft_cap: int) -> int: |
| 889 | + """Round-robin to the next rank under soft_cap; advance the cursor.""" |
| 890 | + for _ in range(tp_size): |
| 891 | + r = self._rr_counter % tp_size |
| 892 | + self._rr_counter = (self._rr_counter + 1) % tp_size |
| 893 | + if all_ranks_num_active_requests[r] < soft_cap: |
| 894 | + return r |
| 895 | + return _least_loaded(soft_cap) |
| 896 | + |
| 897 | + for req_item in remaining_unscheduled: |
| 898 | + conv_id = self._conversation_id(req_item) |
| 899 | + rank = None |
| 900 | + |
| 901 | + if conv_id is not None and conv_id in self._conv_to_rank: |
| 902 | + home = self._conv_to_rank[conv_id] |
| 903 | + # Sticky: return the conversation to its home rank as long as |
| 904 | + # that rank is under the hard cap. Stickiness intentionally |
| 905 | + # uses the hard cap (not the soft fair-share) so cache locality |
| 906 | + # is preserved in the common case. |
| 907 | + if all_ranks_num_active_requests[home] < max_num_active_requests: |
| 908 | + rank = home |
| 909 | + self._record_home(conv_id, home) # LRU touch |
| 910 | + # else: home saturated this batch -> fall through to overflow |
| 911 | + # WITHOUT rebinding, so the conversation returns home next batch. |
| 912 | + |
| 913 | + if rank is None: |
| 914 | + # First turn of a new conversation, sticky-overflow, or no |
| 915 | + # conversation_id -> round-robin spread under the soft cap. |
| 916 | + rank = _next_rr(expected_num_active_requests) |
| 917 | + if conv_id is not None and conv_id not in self._conv_to_rank: |
| 918 | + # Bind this new conversation's home to its first-turn rank. |
| 919 | + self._record_home(conv_id, rank) |
| 920 | + |
| 921 | + all_ranks_new_requests[rank].append(req_item) |
| 922 | + all_ranks_num_active_requests[rank] += 1 |
| 923 | + |
| 924 | + logger.debug( |
| 925 | + f"[adp_router][conv] new_reqs_per_rank=" |
| 926 | + f"{[len(all_ranks_new_requests[r]) for r in range(tp_size)]} " |
| 927 | + f"tracked_convs={len(self._conv_to_rank)}" |
| 928 | + ) |
| 929 | + |
| 930 | + return all_ranks_new_requests, expected_num_active_requests |
0 commit comments