Skip to content

Commit 32fe19d

Browse files
committed
feat(router): Add NIXL-based disaggregated prefill routing support
Signed-off-by: Yiqi Xue <xuey666@gmail.com>
1 parent b66c80c commit 32fe19d

6 files changed

Lines changed: 709 additions & 36 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ dependencies = [
3131
"opentelemetry-exporter-otlp>=1.28.0",
3232
"h11>=0.16.0", # fix critical vulnerability GHSA-vqfr-h8mv-ghfj
3333
"httpcore>=1.0.8", # required for h11>=0.16.0
34+
"pyzmq>=27.0.0",
35+
"msgspec>=0.19.0",
3436
]
3537

3638
[project.scripts]

src/vllm_router/app.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from vllm_router.routers.main_router import main_router
3434
from vllm_router.routers.metrics_router import metrics_router
3535
from vllm_router.routers.routing_logic import (
36+
DisaggregatedPrefillRouter,
3637
cleanup_routing_logic,
3738
get_routing_logic,
3839
initialize_routing_logic,
@@ -48,6 +49,7 @@
4849
from vllm_router.services.request_service.rewriter import (
4950
get_request_rewriter,
5051
)
52+
from vllm_router.services.request_service.zmq_proxy import NixlConfig, ZmqProxy
5153
from vllm_router.stats.engine_stats import (
5254
get_engine_stats_scraper,
5355
initialize_engine_stats_scraper,
@@ -106,6 +108,7 @@
106108
@asynccontextmanager
107109
async def lifespan(app: FastAPI):
108110
app.state.aiohttp_client_wrapper.start()
111+
109112
if hasattr(app.state, "batch_processor"):
110113
await app.state.batch_processor.initialize()
111114

@@ -125,7 +128,29 @@ async def lifespan(app: FastAPI):
125128
logger.info("Validating external provider models against live provider APIs")
126129
await app.state.external_provider_registry.validate_models()
127130

128-
yield
131+
use_nixl = (
132+
isinstance(app.state.router, DisaggregatedPrefillRouter)
133+
and hasattr(app.state, "nixl_config")
134+
and app.state.nixl_config is not None
135+
)
136+
if use_nixl:
137+
logger.info(
138+
"Starting ZMQ task because the routing logic is"
139+
" RoutingLogic.DISAGGREGATED_PREFILL and nixl_proxy_host is configured"
140+
)
141+
nixl_config = app.state.nixl_config
142+
app.state.zmq_proxy = ZmqProxy(
143+
finished_req_ttl=nixl_config.finished_req_ttl,
144+
cleanup_interval=nixl_config.cleanup_interval,
145+
)
146+
await app.state.zmq_proxy.start(nixl_config.proxy_host, nixl_config.proxy_port)
147+
148+
yield
149+
150+
await app.state.zmq_proxy.stop()
151+
else:
152+
yield
153+
129154
await app.state.aiohttp_client_wrapper.stop()
130155

131156
# Close the threaded-components
@@ -230,8 +255,16 @@ def initialize_all(app: FastAPI, args):
230255
namespace=args.k8s_namespace,
231256
port=args.k8s_port,
232257
label_selector=args.k8s_label_selector,
233-
prefill_model_labels=args.prefill_model_labels,
234-
decode_model_labels=args.decode_model_labels,
258+
prefill_model_labels=(
259+
parse_comma_separated_args(args.prefill_model_labels)
260+
if args.prefill_model_labels
261+
else None
262+
),
263+
decode_model_labels=(
264+
parse_comma_separated_args(args.decode_model_labels)
265+
if args.decode_model_labels
266+
else None
267+
),
235268
watcher_timeout_seconds=args.k8s_watcher_timeout_seconds,
236269
health_check_timeout_seconds=args.backend_health_check_timeout_seconds,
237270
)
@@ -363,6 +396,18 @@ def initialize_all(app: FastAPI, args):
363396
app.state.router = get_routing_logic()
364397
app.state.request_rewriter = get_request_rewriter()
365398

399+
# Build NixlConfig if disaggregated prefill with NIXL proxy is configured.
400+
if hasattr(args, "nixl_proxy_host") and args.nixl_proxy_host is not None:
401+
app.state.nixl_config = NixlConfig(
402+
proxy_host=args.nixl_proxy_host,
403+
proxy_port=args.nixl_proxy_port,
404+
peer_host=args.nixl_peer_host,
405+
peer_init_port=args.nixl_peer_init_port,
406+
peer_alloc_port=args.nixl_peer_alloc_port,
407+
finished_req_ttl=args.nixl_finished_req_ttl,
408+
cleanup_interval=args.nixl_cleanup_interval,
409+
)
410+
366411

367412
app = FastAPI(lifespan=lifespan)
368413
app.include_router(main_router)

src/vllm_router/parsers/parser.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,53 @@ def parse_args():
474474
default=None,
475475
help="Path to a YAML file defining external LLM provider configurations (startup-time only).",
476476
)
477+
parser.add_argument(
478+
"--nixl-peer-host",
479+
type=str,
480+
help="The hostname or IP address of the NIXL peer service. Only use for DisaggregatedPrefillRouter.",
481+
)
482+
parser.add_argument(
483+
"--nixl-peer-init-port",
484+
type=int,
485+
default=7300,
486+
help="The initialization port for the NIXL peer service. Only use for DisaggregatedPrefillRouter.",
487+
)
488+
parser.add_argument(
489+
"--nixl-peer-alloc-port",
490+
type=int,
491+
default=7400,
492+
help="The allocation port for the NIXL peer service. Only use for DisaggregatedPrefillRouter.",
493+
)
494+
parser.add_argument(
495+
"--nixl-proxy-host",
496+
type=str,
497+
help="The hostname or IP address for the NIXL proxy server. Only use for DisaggregatedPrefillRouter.",
498+
)
499+
parser.add_argument(
500+
"--nixl-proxy-port",
501+
type=int,
502+
default=7500,
503+
help="The port for the NIXL proxy server. Only use for DisaggregatedPrefillRouter.",
504+
)
505+
parser.add_argument(
506+
"--nixl-finished-req-ttl",
507+
type=float,
508+
default=120.0,
509+
help=(
510+
"Seconds to retain a KV-ready entry in the ZMQ proxy before "
511+
"evicting it. Must be at least as long as the worst-case decode "
512+
"latency for a single request. Defaults to 120 s."
513+
),
514+
)
515+
parser.add_argument(
516+
"--nixl-cleanup-interval",
517+
type=float,
518+
default=60.0,
519+
help=(
520+
"How often (seconds) the ZMQ proxy background task scans for "
521+
"stale KV-ready entries. Defaults to 60 s."
522+
),
523+
)
477524

478525
args = parser.parse_args()
479526
args = load_initial_config_from_config_file_if_required(parser, args)

src/vllm_router/service_discovery.py

Lines changed: 110 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ def get_endpoint_info(self) -> List[EndpointInfo]:
347347

348348
async def initialize_client_sessions(self) -> None:
349349
"""
350-
Initialize aiohttp ClientSession objects for prefill and decode endpoints.
350+
Initialize aiohttp client sessions for prefill and decode endpoints.
351351
This must be called from an async context during app startup.
352352
"""
353353
if (
@@ -756,18 +756,22 @@ def _add_engine(
756756
# Store model information in the endpoint info
757757
self.available_engines[engine_name].model_info = model_info
758758

759-
if self.event_loop_ready.is_set() and self.event_loop is not None:
760-
try:
759+
# Initialize client sessions only if event_loop is available
760+
try:
761+
if hasattr(self.app.state, "event_loop") and self.app.state.event_loop:
761762
fut = asyncio.run_coroutine_threadsafe(
762-
self.initialize_client_sessions(),
763-
self.event_loop,
763+
self.initialize_client_sessions(), self.app.state.event_loop
764764
)
765765
fut.result()
766-
except Exception as e:
767-
logger.error(f"Error initializing client sessions: {e}")
768-
else:
769-
logger.debug(
770-
"Event loop not ready; deferring client session initialization"
766+
logger.info("Client sessions initialized successfully in _add_engine")
767+
else:
768+
# Event loop not ready yet, client sessions will be initialized in lifespan
769+
logger.debug(
770+
"Event loop not ready in _add_engine, client sessions will be initialized later"
771+
)
772+
except Exception as e:
773+
logger.error(
774+
f"Error initializing client sessions in _add_engine: {e}", exc_info=True
771775
)
772776

773777
# Track all models we've ever seen
@@ -850,35 +854,63 @@ def close(self):
850854

851855
async def initialize_client_sessions(self) -> None:
852856
"""
853-
Initialize aiohttp ClientSession objects for prefill and decode endpoints.
857+
Initialize aiohttp client sessions for prefill and decode endpoints.
854858
This must be called from an async context during app startup.
855859
"""
860+
logger.debug(
861+
f"initialize_client_sessions called. prefill_model_labels={self.prefill_model_labels}, decode_model_labels={self.decode_model_labels}"
862+
)
856863
if (
857864
self.prefill_model_labels is not None
858865
and self.decode_model_labels is not None
859866
):
860867
endpoint_infos = self.get_endpoint_info()
868+
logger.debug(f"Got {len(endpoint_infos)} endpoints")
861869
for endpoint_info in endpoint_infos:
870+
logger.debug(
871+
f"Checking endpoint: url={endpoint_info.url}, model_label={endpoint_info.model_label}"
872+
)
862873
if endpoint_info.model_label in self.prefill_model_labels:
863874
if (
864875
hasattr(self.app.state, "prefill_client")
865876
and self.app.state.prefill_client is not None
866877
):
867-
await self.app.state.prefill_client.close()
868-
self.app.state.prefill_client = aiohttp.ClientSession(
869-
base_url=endpoint_info.url,
870-
timeout=aiohttp.ClientTimeout(total=None),
871-
)
878+
# Session already initialised; skip to avoid disrupting
879+
# in-flight requests. xPyD (multiple prefill nodes) is
880+
# not supported in this PR — only the first discovered
881+
# prefill endpoint is used.
882+
logger.debug(
883+
f"prefill_client already set, skipping {endpoint_info.url}"
884+
)
885+
else:
886+
self.app.state.prefill_client = aiohttp.ClientSession(
887+
base_url=endpoint_info.url,
888+
timeout=aiohttp.ClientTimeout(total=None),
889+
)
890+
logger.info(
891+
f"Created prefill_client for {endpoint_info.url} with timeout=None"
892+
)
893+
872894
elif endpoint_info.model_label in self.decode_model_labels:
873895
if (
874896
hasattr(self.app.state, "decode_client")
875897
and self.app.state.decode_client is not None
876898
):
877-
await self.app.state.decode_client.close()
878-
self.app.state.decode_client = aiohttp.ClientSession(
879-
base_url=endpoint_info.url,
880-
timeout=aiohttp.ClientTimeout(total=None),
881-
)
899+
logger.debug(
900+
f"decode_client already set, skipping {endpoint_info.url}"
901+
)
902+
else:
903+
self.app.state.decode_client = aiohttp.ClientSession(
904+
base_url=endpoint_info.url,
905+
timeout=aiohttp.ClientTimeout(total=None),
906+
)
907+
logger.info(
908+
f"Created decode_client for {endpoint_info.url} with timeout=None"
909+
)
910+
else:
911+
logger.warning(
912+
"prefill_model_labels or decode_model_labels is None, skipping client session initialization"
913+
)
882914

883915
def has_ever_seen_model(self, model_name: str) -> bool:
884916
"""Check if we've ever seen this model, even if currently scaled to zero."""
@@ -1212,6 +1244,21 @@ def _add_engine(self, engine_name: str, model_names: List[str], model_label: str
12121244
# Store model information in the endpoint info
12131245
self.available_engines[engine_name].model_info = model_info
12141246

1247+
try:
1248+
# Only initialize client sessions if event_loop is available
1249+
if hasattr(self.app.state, "event_loop") and self.app.state.event_loop:
1250+
fut = asyncio.run_coroutine_threadsafe(
1251+
self.initialize_client_sessions(), self.app.state.event_loop
1252+
)
1253+
fut.result()
1254+
else:
1255+
# Event loop not ready yet, client sessions will be initialized in lifespan
1256+
logger.debug(
1257+
"Event loop not ready, client sessions will be initialized later"
1258+
)
1259+
except Exception as e:
1260+
logger.error(f"Error initializing client sessions: {e}")
1261+
12151262
def _delete_engine(self, engine_name: str):
12161263
logger.info(f"Serving engine {engine_name} is deleted")
12171264
with self.available_engines_lock:
@@ -1287,25 +1334,58 @@ def close(self):
12871334

12881335
async def initialize_client_sessions(self) -> None:
12891336
"""
1290-
Initialize aiohttp ClientSession objects for prefill and decode endpoints.
1337+
Initialize aiohttp client sessions for prefill and decode endpoints.
12911338
This must be called from an async context during app startup.
12921339
"""
1340+
logger.debug(
1341+
f"K8sServiceNameServiceDiscovery.initialize_client_sessions called. prefill_model_labels={self.prefill_model_labels}, decode_model_labels={self.decode_model_labels}"
1342+
)
12931343
if (
12941344
self.prefill_model_labels is not None
12951345
and self.decode_model_labels is not None
12961346
):
12971347
endpoint_infos = self.get_endpoint_info()
1348+
logger.debug(f"Got {len(endpoint_infos)} endpoints")
12981349
for endpoint_info in endpoint_infos:
1350+
logger.debug(
1351+
f"Checking endpoint: url={endpoint_info.url}, model_label={endpoint_info.model_label}"
1352+
)
12991353
if endpoint_info.model_label in self.prefill_model_labels:
1300-
self.app.state.prefill_client = aiohttp.ClientSession(
1301-
base_url=endpoint_info.url,
1302-
timeout=aiohttp.ClientTimeout(total=None),
1303-
)
1354+
if (
1355+
hasattr(self.app.state, "prefill_client")
1356+
and self.app.state.prefill_client is not None
1357+
):
1358+
logger.debug(
1359+
f"prefill_client already set, skipping {endpoint_info.url}"
1360+
)
1361+
else:
1362+
self.app.state.prefill_client = aiohttp.ClientSession(
1363+
base_url=endpoint_info.url,
1364+
timeout=aiohttp.ClientTimeout(total=None),
1365+
)
1366+
logger.info(
1367+
f"Created prefill_client for {endpoint_info.url} with timeout=None"
1368+
)
13041369
elif endpoint_info.model_label in self.decode_model_labels:
1305-
self.app.state.decode_client = aiohttp.ClientSession(
1306-
base_url=endpoint_info.url,
1307-
timeout=aiohttp.ClientTimeout(total=None),
1308-
)
1370+
if (
1371+
hasattr(self.app.state, "decode_client")
1372+
and self.app.state.decode_client is not None
1373+
):
1374+
logger.debug(
1375+
f"decode_client already set, skipping {endpoint_info.url}"
1376+
)
1377+
else:
1378+
self.app.state.decode_client = aiohttp.ClientSession(
1379+
base_url=endpoint_info.url,
1380+
timeout=aiohttp.ClientTimeout(total=None),
1381+
)
1382+
logger.info(
1383+
f"Created decode_client for {endpoint_info.url} with timeout=None"
1384+
)
1385+
else:
1386+
logger.warning(
1387+
"K8sServiceNameServiceDiscovery: prefill_model_labels or decode_model_labels is None, skipping client session initialization"
1388+
)
13091389

13101390

13111391
def _create_service_discovery(

0 commit comments

Comments
 (0)