Skip to content

Commit bbf3ae0

Browse files
committed
[None][feat] Add ConversationAwareADPRouter for explicit conversation->rank affinity
Adds an instance-level ADP router that round-robins the first request of each conversation across ranks, then pins every subsequent request with the same conversation_id to that conversation's first-turn rank. This keeps a multi-turn conversation's growing KV-cache prefix on one rank (maximizing block reuse, minimizing cross-rank migration) while spreading new conversations evenly. Unlike KVCacheAwareADPRouter (which infers affinity from probed prefix-match length and loses a conversation when its blocks are evicted), the conversation_id -> rank map is explicit and survives eviction. Inspired by the serve-level ConversationRouter and the first-turn-round-robin idea from #14744, applied at the intra-instance ADP-rank level. conversation_id is read from py_disaggregated_params.conversation_id (serve-side propagated from X-Session-ID); falls back to load-balanced round-robin when it is absent, so behavior degrades gracefully. Selected via the new attention_dp_config.kv_cache_routing_conversation_affinity flag (kv_cache_routing_max_sessions bounds the LRU map). Includes unit tests covering first-turn RR, stickiness, conv-less fallback, cross-rank determinism, LRU eviction, sticky overflow, and factory selection. Signed-off-by: Lance Liao <108499334+lancelly@users.noreply.github.com>
1 parent a50b5e2 commit bbf3ae0

3 files changed

Lines changed: 384 additions & 1 deletion

File tree

tensorrt_llm/_torch/pyexecutor/scheduler/adp_router.py

Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import math
2222
import random
2323
from abc import ABC, abstractmethod
24-
from collections import namedtuple
24+
from collections import OrderedDict, namedtuple
2525
from dataclasses import MISSING, astuple, dataclass, field, fields, replace
2626
from typing import TYPE_CHECKING, Dict, List, Set, Tuple
2727

@@ -178,6 +178,20 @@ def create(
178178
kv_cache_manager has block reuse enabled; DefaultADPRouter
179179
otherwise.
180180
"""
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+
181195
if (
182196
attention_dp_config is not None
183197
and attention_dp_config.enable_kv_cache_aware_routing
@@ -726,3 +740,191 @@ def _sort_key(req_item):
726740
)
727741

728742
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

tensorrt_llm/llmapi/llm_args.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,28 @@ class AttentionDpConfig(StrictBaseModel):
861861
"scatter requests that would otherwise consolidate on a single warm "
862862
"rank, wasting prefill. Default False preserves pre-warmup routing. "
863863
"Only used when enable_kv_cache_aware_routing is True.")
864+
kv_cache_routing_conversation_affinity: bool = Field(
865+
default=False,
866+
description=
867+
"Enable explicit conversation-affinity routing for attention DP. When "
868+
"True, the first request of each conversation is round-robined across "
869+
"ranks and every subsequent request carrying the same conversation_id "
870+
"(read from disaggregated_params, populated from the X-Session-ID "
871+
"header) is pinned to that conversation's first-turn rank. This keeps a "
872+
"multi-turn conversation's KV-cache prefix on one rank (maximizing "
873+
"block reuse, minimizing cross-rank migration). Unlike "
874+
"enable_kv_cache_aware_routing (affinity inferred from prefix-match "
875+
"length, which is lost when blocks are evicted), the conversation->rank "
876+
"map is explicit and survives eviction. Falls back to load-balanced "
877+
"round-robin when no conversation_id is available. Takes precedence "
878+
"over enable_kv_cache_aware_routing when both are set.")
879+
kv_cache_routing_max_sessions: int = Field(
880+
default=65536,
881+
description=
882+
"LRU cap on the conversation->rank map used by conversation-affinity "
883+
"routing. The oldest conversations are evicted once more than this many "
884+
"are tracked, bounding memory on long-running servers. Only used when "
885+
"kv_cache_routing_conversation_affinity is True.")
864886

865887
@model_validator(mode='after')
866888
def validate_attention_dp_config(self) -> 'AttentionDpConfig':

0 commit comments

Comments
 (0)