Skip to content

Commit 49cdf84

Browse files
committed
feat(gms): integrate persistent KV with trtllm v2
Signed-off-by: Maksim Khadkevich <mkhadkevich@nvidia.com>
1 parent 7f239e1 commit 49cdf84

14 files changed

Lines changed: 1386 additions & 93 deletions

File tree

components/src/dynamo/trtllm/__main__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
3+
# ruff: noqa: E402
34

45
import os
56

67
if "PYTHONHASHSEED" not in os.environ:
78
os.environ["PYTHONHASHSEED"] = "0"
89

10+
from dynamo.trtllm.startup import configure_gms_openmpi_defaults
11+
12+
configure_gms_openmpi_defaults()
13+
914
from dynamo.trtllm.main import main
1015

1116
if __name__ == "__main__":

components/src/dynamo/trtllm/main.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,30 @@ async def worker():
102102
# The drain callback waits for in-flight requests to finish so that GPU
103103
# memory is not freed while transfers are active (issue #7319).
104104
engine_holder: list = []
105+
publisher_holder: list = []
105106
drain_callback = None
106107
if config.disaggregation_mode == DisaggregationMode.PREFILL:
107108
drain_callback = _make_drain_callback(engine_holder)
108109

110+
async def cleanup_engine() -> None:
111+
if publisher_holder:
112+
await publisher_holder[0].cleanup()
113+
logging.info("TRT-LLM publisher cleanup complete")
114+
115+
if not engine_holder:
116+
logging.info("TRT-LLM engine not initialized; skipping cleanup")
117+
return
118+
119+
await engine_holder[0].cleanup()
120+
logging.info("TRT-LLM engine cleanup complete")
121+
109122
install_signal_handlers(
110123
loop,
111124
runtime,
112125
shutdown_endpoints,
113126
shutdown_event,
114127
drain_callback=drain_callback,
128+
cleanup_callback=cleanup_engine,
115129
)
116130

117131
logging.info(f"Initializing the worker with config: {config}")
@@ -121,6 +135,7 @@ async def worker():
121135
shutdown_event,
122136
shutdown_endpoints,
123137
engine_holder=engine_holder,
138+
publisher_holder=publisher_holder,
124139
)
125140

126141

components/src/dynamo/trtllm/publisher.py

Lines changed: 165 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import asyncio
2323
import concurrent.futures
2424
import logging
25+
import struct
2526
import threading
2627
import time
2728
import traceback
@@ -32,11 +33,17 @@
3233
from typing import Any, Awaitable, Callable, Dict, Optional, Union
3334

3435
import msgspec
36+
import xxhash
3537
import zmq
3638
from prometheus_client import CollectorRegistry
3739

3840
from dynamo.common.utils.prometheus import LLMBackendMetrics
39-
from dynamo.llm import FpmDirectPublisher, KvEventPublisher, WorkerMetricsPublisher
41+
from dynamo.llm import (
42+
FpmDirectPublisher,
43+
KvEventPublisher,
44+
WorkerMetricsPublisher,
45+
compute_block_hash_for_seq,
46+
)
4047

4148
logging.basicConfig(level=logging.DEBUG)
4249

@@ -107,6 +114,66 @@ def _to_signed_i64(value: int | None) -> int | None:
107114
return value
108115

109116

117+
_XXH3_SEED = 1337
118+
_UINT64_MASK = (1 << 64) - 1
119+
# TRT-LLM's PyExecutor/VANILLA path can emit request tokens with an
120+
# engine-added leading special token even when the router request token_ids did
121+
# not contain it. Strip only common BOS-style ids before computing router
122+
# hashes for oversized TRT event blocks.
123+
_TRT_LEADING_SPECIAL_TOKEN_IDS = {0, 1, 2, 128000, 128001, 128002}
124+
125+
126+
def _xxh3_u64(data: bytes, seed: int = _XXH3_SEED) -> int:
127+
return xxhash.xxh3_64_intdigest(data, seed=seed & _UINT64_MASK) & _UINT64_MASK
128+
129+
130+
def _compute_router_sequence_hashes(local_hashes: list[int]) -> list[int]:
131+
if not local_hashes:
132+
return []
133+
134+
sequence_hashes = [int(local_hashes[0]) & _UINT64_MASK]
135+
for local_hash in local_hashes[1:]:
136+
payload = struct.pack(
137+
"<QQ", sequence_hashes[-1], int(local_hash) & _UINT64_MASK
138+
)
139+
sequence_hashes.append(_xxh3_u64(payload))
140+
return sequence_hashes
141+
142+
143+
def _strip_trt_leading_special_token(
144+
token_ids: list[int], kv_block_size: int
145+
) -> tuple[list[int], bool]:
146+
if len(token_ids) <= kv_block_size or not token_ids:
147+
return token_ids, False
148+
if token_ids[0] in _TRT_LEADING_SPECIAL_TOKEN_IDS:
149+
stripped = token_ids[1:]
150+
if len(stripped) >= kv_block_size:
151+
return stripped, True
152+
return token_ids, False
153+
154+
155+
def _split_oversized_trt_block_for_router(
156+
token_ids: list[int], kv_block_size: int, lora_name: Optional[str] = None
157+
) -> tuple[list[int], list[int], list[int], bool]:
158+
if kv_block_size <= 0:
159+
return [], [], [], False
160+
161+
normalized_tokens, stripped_special = _strip_trt_leading_special_token(
162+
token_ids, kv_block_size
163+
)
164+
usable_len = len(normalized_tokens) - (len(normalized_tokens) % kv_block_size)
165+
if usable_len <= 0:
166+
return [], [], [], stripped_special
167+
168+
full_tokens = normalized_tokens[:usable_len]
169+
local_hashes = compute_block_hash_for_seq(
170+
[int(token) for token in full_tokens], kv_block_size, lora_name=lora_name
171+
)
172+
sequence_hashes = _compute_router_sequence_hashes(local_hashes)
173+
num_block_tokens = [kv_block_size] * len(sequence_hashes)
174+
return full_tokens, num_block_tokens, sequence_hashes, stripped_special
175+
176+
110177
class ZmqKvEventPublisher:
111178
"""
112179
Pure Python ZMQ PUBLISHER for TensorRT-LLM KV events.
@@ -431,8 +498,14 @@ def __init__(
431498
# A set to store the block hash of partial block (i.e. block containing less than kv_block_size tokens) hashes.
432499
# It is used to prevent sending remove event to kv router since partial blocks are not stored.
433500
self.partial_block_hashes: set[int] = set()
501+
# Maps oversized TRT engine block hashes to the synthetic router-visible
502+
# sequence hashes we publish for those blocks, so later remove events
503+
# evict the same hashes from the router indexer.
504+
self.synthetic_block_hashes: dict[int, list[int]] = {}
434505
self.error_queue: Queue = Queue()
435506
self._stop_event = threading.Event()
507+
self._cleanup_lock = asyncio.Lock()
508+
self._cleanup_started = False
436509
# Track the last engine event_id to assert consecutive event IDs from the engine
437510
self._last_engine_event_id: Optional[int] = None
438511

@@ -815,26 +888,59 @@ def _handle_kv_event(self, event):
815888
block_mm_infos: list[dict | None] = []
816889
kv_block_size = self.kv_block_size
817890
partial_block_hashes = self.partial_block_hashes
891+
synthetic_block_hashes = self.synthetic_block_hashes
892+
lora_name = data.get("lora_name")
893+
synthetic_parent_hash = parent_hash
818894
for block in data["blocks"]:
819895
block_tokens = block["tokens"]
820896
token_num_in_block = len(block_tokens)
821-
if token_num_in_block > kv_block_size:
822-
logging.error(
823-
f"Block contains {token_num_in_block} tokens, which is greater than kv_block_size {kv_block_size}"
824-
)
825-
return
897+
raw_token_ids = [int(t["token_id"]) for t in block_tokens]
826898
block_hash = _to_signed_i64(block["block_hash"])
827899
if block_hash is None:
828900
logging.warning(
829901
f"Skipping block with None hash containing {token_num_in_block} tokens"
830902
)
831903
continue
904+
905+
if token_num_in_block > kv_block_size:
906+
(
907+
full_tokens,
908+
synthetic_num_block_tokens,
909+
synthetic_hashes,
910+
stripped_special,
911+
) = _split_oversized_trt_block_for_router(
912+
raw_token_ids, kv_block_size, lora_name
913+
)
914+
if not synthetic_hashes:
915+
logging.debug(
916+
"Skipping oversized TRT KV block with no full router blocks: "
917+
f"token_count={token_num_in_block}, kv_block_size={kv_block_size}"
918+
)
919+
partial_block_hashes.add(block_hash)
920+
continue
921+
922+
signed_hashes = [_to_signed_i64(h) for h in synthetic_hashes]
923+
token_ids.extend(full_tokens)
924+
num_block_tokens.extend(synthetic_num_block_tokens)
925+
block_hashes.extend(h for h in signed_hashes if h is not None)
926+
block_mm_infos.extend([None] * len(signed_hashes))
927+
synthetic_block_hashes[block_hash] = [
928+
h for h in signed_hashes if h is not None
929+
]
930+
synthetic_parent_hash = None
931+
logging.debug(
932+
"Split oversized TRT KV block for router indexing: "
933+
f"engine_hash={block_hash}, token_count={token_num_in_block}, "
934+
f"published_blocks={len(signed_hashes)}, stripped_special={stripped_special}"
935+
)
936+
continue
937+
832938
if token_num_in_block < kv_block_size:
833939
partial_block_hashes.add(block_hash)
834940
break
835941
num_block_tokens.append(token_num_in_block)
836942
block_hashes.append(block_hash)
837-
token_ids.extend(int(t["token_id"]) for t in block_tokens)
943+
token_ids.extend(raw_token_ids)
838944

839945
mm_keys = block.get("mm_keys")
840946
if mm_keys:
@@ -856,14 +962,18 @@ def _handle_kv_event(self, event):
856962
else:
857963
block_mm_infos.append(None)
858964

859-
lora_name = data.get("lora_name")
965+
if not block_hashes:
966+
logging.debug(
967+
f"Skipping stored KV event {event_id} with no full router blocks"
968+
)
969+
return
860970

861971
# Get attention_dp_rank from event (TRT-LLM includes this in KVCacheEvent)
862972
# Default to 0 for backwards compatibility with older TRT-LLM versions
863973
attention_dp_rank = event.get("attention_dp_rank", 0)
864974

865975
logging.debug(
866-
f"publish stored event: engine_event_id: {event_id}, attention_dp_rank: {attention_dp_rank}, token_ids: {token_ids}, num_block_tokens: {num_block_tokens}, block_hashes: {block_hashes}, lora_name: {lora_name}, parent_hash: {parent_hash}"
976+
f"publish stored event: engine_event_id: {event_id}, attention_dp_rank: {attention_dp_rank}, token_ids: {token_ids}, num_block_tokens: {num_block_tokens}, block_hashes: {block_hashes}, lora_name: {lora_name}, parent_hash: {synthetic_parent_hash}"
867977
)
868978
# Publish to ZMQ if consolidator is enabled, otherwise publish to NATS
869979
# Note: event_id is managed internally by the publisher (monotonic counter per dp_rank)
@@ -873,7 +983,7 @@ def _handle_kv_event(self, event):
873983
token_ids,
874984
num_block_tokens,
875985
block_hashes,
876-
parent_hash,
986+
synthetic_parent_hash,
877987
block_mm_infos,
878988
attention_dp_rank,
879989
lora_name,
@@ -887,7 +997,7 @@ def _handle_kv_event(self, event):
887997
token_ids,
888998
num_block_tokens,
889999
block_hashes,
890-
parent_hash,
1000+
synthetic_parent_hash,
8911001
block_mm_infos,
8921002
lora_name=lora_name,
8931003
)
@@ -903,6 +1013,10 @@ def _handle_kv_event(self, event):
9031013
block_hash = _to_signed_i64(block_hash)
9041014
if block_hash is None:
9051015
continue
1016+
synthetic_hashes = self.synthetic_block_hashes.pop(block_hash, None)
1017+
if synthetic_hashes:
1018+
removed_block_hashes.extend(synthetic_hashes)
1019+
continue
9061020
if block_hash in self.partial_block_hashes:
9071021
logging.debug(
9081022
f"Skipping removing block hash {block_hash} since it is a partial block"
@@ -962,38 +1076,47 @@ def check_error_queue(self) -> Optional[Exception]:
9621076

9631077
async def cleanup(self) -> None:
9641078
"""Cleanup threads and resources"""
965-
self._stop_event.set()
966-
# Add timeout to prevent hanging
967-
cleanup_timeout = 5.0 # seconds
968-
969-
if self.publish_stats_thread and self.publish_stats_thread.is_alive():
970-
self.publish_stats_thread.stop()
971-
self.publish_stats_thread.join(timeout=cleanup_timeout)
972-
if self.publish_stats_thread.is_alive():
973-
logging.warning("Stats thread did not stop within timeout")
974-
975-
if (
976-
self.publish_kv_cache_events_thread
977-
and self.publish_kv_cache_events_thread.is_alive()
978-
):
979-
self.publish_kv_cache_events_thread.stop()
980-
self.publish_kv_cache_events_thread.join(timeout=cleanup_timeout)
981-
if self.publish_kv_cache_events_thread.is_alive():
982-
logging.warning("KV cache events thread did not stop within timeout")
983-
984-
# Shutdown ZMQ publisher if it exists
985-
if self.zmq_kv_event_publisher:
986-
self.zmq_kv_event_publisher.shutdown()
1079+
async with self._cleanup_lock:
1080+
if self._cleanup_started:
1081+
return
1082+
self._cleanup_started = True
1083+
1084+
self._stop_event.set()
1085+
# Add timeout to prevent hanging
1086+
cleanup_timeout = 5.0 # seconds
1087+
1088+
thread_warnings = (
1089+
(self.publish_stats_thread, "Stats thread did not stop within timeout"),
1090+
(
1091+
self.publish_kv_cache_events_thread,
1092+
"KV cache events thread did not stop within timeout",
1093+
),
1094+
)
1095+
live_threads = [
1096+
(thread, warning)
1097+
for thread, warning in thread_warnings
1098+
if thread and thread.is_alive()
1099+
]
1100+
for thread, _warning in live_threads:
1101+
thread.stop()
1102+
for thread, warning in live_threads:
1103+
thread.join(timeout=cleanup_timeout)
1104+
if thread.is_alive():
1105+
logging.warning(warning)
1106+
1107+
# Shutdown ZMQ publisher if it exists
1108+
if self.zmq_kv_event_publisher:
1109+
self.zmq_kv_event_publisher.shutdown()
9871110

988-
# Shutdown FpmDirectPublisher (stops the per-rank serialization tasks
989-
# and the event-plane publisher task on the Rust side). PyO3 surfaces
990-
# shutdown failures as PyRuntimeError; narrower catch keeps real
991-
# programming errors visible.
992-
if self.fpm_publisher is not None:
993-
try:
994-
self.fpm_publisher.shutdown()
995-
except RuntimeError as e:
996-
logging.warning(f"FpmDirectPublisher shutdown failed: {e}")
1111+
# Shutdown FpmDirectPublisher (stops the per-rank serialization tasks
1112+
# and the event-plane publisher task on the Rust side). PyO3 surfaces
1113+
# shutdown failures as PyRuntimeError; narrower catch keeps real
1114+
# programming errors visible.
1115+
if self.fpm_publisher is not None:
1116+
try:
1117+
self.fpm_publisher.shutdown()
1118+
except RuntimeError as e:
1119+
logging.warning(f"FpmDirectPublisher shutdown failed: {e}")
9971120

9981121
def update_max_window_size(self, event: dict) -> None:
9991122
if "window_size" in event:

0 commit comments

Comments
 (0)