[Core][Frontend] Bind KV-event publishers at port 0 and expose the bound endpoints (#55844)

Signed-off-by: aoshen02 <aoshen02@users.noreply.github.com> Co-authored-by: mukeshbhandarkar <shell00035@gmail.com> Co-authored-by: aoshen02 <aoshen02@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: aoshen02 <aoshen@inferact.ai>

hangangqiang committed Sep 19, 2026 at 09:11 UTC 90531daf6dde1c961d326d43c493477feca510c7
11 files changed +282 -14
rust/src/server/src/routes.rs
+2
@@ -6,6 +6,7 @@ mod cache;
6 mod collective_rpc;
7 mod health;
8 mod inference;
9 +mod kv_event_sources;
10 mod load;
11 mod lora;
12 mod metrics;
@@ -91,6 +92,7 @@ fn build_router_with_options(
92 .route("/metrics", get(metrics::scrape))
93 .route("/load", get(load::load))
94 .route("/version", get(version::version))
95 + .route("/kv_event_sources", get(kv_event_sources::kv_event_sources))
96 // OpenAI-compatible endpoints
97 .route("/v1/models", get(openai::list_models))
98 .route("/v1/completions", post(openai::completions))
rust/src/server/src/routes/kv_event_sources.rs new
+25
@@ -0,0 +1,25 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use std::collections::BTreeMap;
5 +use std::sync::Arc;
6 +
7 +use axum::Json;
8 +use axum::extract::State;
9 +use vllm_engine_core_client::protocol::handshake::KvEventsConfig;
10 +
11 +use crate::state::AppState;
12 +
13 +/// KV-event publisher config of each data-parallel rank, keyed by rank.
14 +pub async fn kv_event_sources(
15 + State(state): State<Arc<AppState>>,
16 +) -> Json<BTreeMap<u32, KvEventsConfig>> {
17 + Json(
18 + state
19 + .engine_core_client()
20 + .ready_responses()
21 + .into_iter()
22 + .filter_map(|ready| Some((ready.data_parallel_rank, ready.kv_events_config.clone()?)))
23 + .collect(),
24 + )
25 +}
rust/src/server/src/routes/tests.rs
+50
@@ -2054,6 +2054,56 @@ async fn version_returns_engine_vllm_version() {
2054 );
2055 }
2056
2057 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2058 +#[serial]
2059 +async fn kv_event_sources_reports_each_ready_engine() {
2060 + let ready = vllm_engine_core_client::protocol::handshake::EngineCoreReadyResponse {
2061 + data_parallel_rank: 1,
2062 + kv_events_config: Some(
2063 + vllm_engine_core_client::protocol::handshake::KvEventsConfig {
2064 + enable_kv_cache_events: true,
2065 + publisher: "zmq".to_string(),
2066 + endpoint: "tcp://10.0.0.2:41233".to_string(),
2067 + replay_endpoint: None,
2068 + buffer_steps: 10_000,
2069 + hwm: 100_000,
2070 + max_queue_size: 100_000,
2071 + topic: "kv".to_string(),
2072 + },
2073 + ),
2074 + ..default_ready_response()
2075 + };
2076 + let (mut app, _engine_task) = test_dev_mode_app_with_ready(ready).await;
2077 + let response = app
2078 + .call(
2079 + Request::builder()
2080 + .uri("/kv_event_sources")
2081 + .body(Body::empty())
2082 + .expect("build request"),
2083 + )
2084 + .await
2085 + .expect("call app");
2086 +
2087 + assert_eq!(response.status(), StatusCode::OK);
2088 + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
2089 + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
2090 + assert_eq!(
2091 + json,
2092 + json!({
2093 + "1": {
2094 + "enable_kv_cache_events": true,
2095 + "publisher": "zmq",
2096 + "endpoint": "tcp://10.0.0.2:41233",
2097 + "replay_endpoint": null,
2098 + "buffer_steps": 10000,
2099 + "hwm": 100000,
2100 + "max_queue_size": 100000,
2101 + "topic": "kv",
2102 + }
2103 + })
2104 + );
2105 +}
2106 +
2107 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2108 #[serial]
2109 async fn server_info_endpoint_is_dev_mode_only() {
tests/distributed/conftest.py
+4
@@ -91,8 +91,11 @@ class MockSubscriber:
91 if isinstance(replay_endpoints, str):
92 replay_endpoints = [replay_endpoints]
93
94 + ipv6 = any("[" in ep for ep in pub_endpoints + (replay_endpoints or []))
95 +
96 # Set up subscriber socket - connect to all endpoints
97 self.sub = self.ctx.socket(zmq.SUB)
98 + self.sub.setsockopt(zmq.IPV6, ipv6)
99 self.sub.setsockopt(zmq.SUBSCRIBE, topic.encode("utf-8"))
100 for endpoint in pub_endpoints:
101 self.sub.connect(endpoint)
@@ -103,6 +106,7 @@ class MockSubscriber:
106 if replay_endpoints:
107 for replay_endpoint in replay_endpoints:
108 replay = self.ctx.socket(zmq.DEALER)
109 + replay.setsockopt(zmq.IPV6, ipv6)
110 replay.connect(replay_endpoint)
111 self.replay_sockets.append(replay)
112
tests/distributed/test_events.py
+74
@@ -332,3 +332,77 @@ def test_event_publisher_factory(random_port):
332 publisher = EventPublisherFactory.create(config, DP_RANK)
333 assert isinstance(publisher, ZmqEventPublisher)
334 publisher.shutdown()
335 +
336 +
337 +@pytest.mark.parametrize(
338 + ("endpoint", "expected"),
339 + [("tcp://*:5557", "tcp://*:5558"), ("tcp://*:0", "tcp://*:0")],
340 +)
341 +def test_offset_endpoint_port_keeps_port_zero(endpoint, expected):
342 + """Port 0 is not offset: every rank asks the OS for its own port."""
343 + from vllm.distributed.kv_events import ZmqEventPublisher
344 +
345 + assert ZmqEventPublisher.offset_endpoint_port(endpoint, 1) == expected
346 +
347 +
348 +@pytest.mark.parametrize("node_ip", ["127.0.0.1", "::1"])
349 +def test_ephemeral_publishers_report_bound_endpoints(monkeypatch, node_ip):
350 + """Two ranks binding tcp://*:0 get distinct OS-assigned ports on this
351 + node's IP and report endpoints a subscriber can use for events and replay."""
352 + from vllm.config.kv_events import KVEventsConfig
353 + from vllm.distributed import kv_events
354 + from vllm.utils.network_utils import split_zmq_path
355 +
356 + from .conftest import MockSubscriber
357 +
358 + monkeypatch.setattr(kv_events, "get_ip", lambda: node_ip)
359 + config = KVEventsConfig(
360 + enable_kv_cache_events=True,
361 + publisher="zmq",
362 + endpoint="tcp://*:0",
363 + replay_endpoint="tcp://*:0",
364 + topic="kv",
365 + )
366 + pubs = [EventPublisherFactory.create(config, rank) for rank in range(2)]
367 + try:
368 + resolved = [pub.get_publisher_config() for pub in pubs]
369 + endpoints = [e for c in resolved for e in (c.endpoint, c.replay_endpoint)]
370 + hosts_ports = {split_zmq_path(e)[1:] for e in endpoints}
371 + assert {h for h, _ in hosts_ports} == {node_ip}
372 + assert len(hosts_ports) == 4 and "0" not in {p for _, p in hosts_ports}
373 + assert config.endpoint == "tcp://*:0"
374 +
375 + sub = MockSubscriber(endpoints[::2], endpoints[1::2], topic="kv")
376 + try:
377 + # PUB/SUB drops messages sent before the subscription lands, so
378 + # keep publishing until both ranks are heard.
379 + ranks: set[int] = set()
380 + deadline = time.time() + 10
381 + while ranks != {0, 1} and time.time() < deadline:
382 + for pub in pubs:
383 + pub.publish(create_test_events(1))
384 + while (result := sub.receive_one(timeout=100)) is not None:
385 + ranks.add(result[1].data_parallel_rank)
386 + assert ranks == {0, 1}
387 + sub.request_replay(0, socket_idx=1)
388 + assert sub.receive_replay(socket_idx=1)
389 + finally:
390 + sub.close()
391 + finally:
392 + for pub in pubs:
393 + pub.shutdown()
394 +
395 +
396 +@pytest.mark.parametrize("node_ip", ["0.0.0.0", "::"])
397 +def test_ephemeral_bind_needs_a_node_ip(monkeypatch, node_ip):
398 + """tcp://*:0 is advertised as this node's IP; with no usable IP the
399 + publisher refuses instead of reporting a wildcard."""
400 + from vllm.config.kv_events import KVEventsConfig
401 + from vllm.distributed import kv_events
402 +
403 + monkeypatch.setattr(kv_events, "get_ip", lambda: node_ip)
404 + config = KVEventsConfig(
405 + enable_kv_cache_events=True, publisher="zmq", endpoint="tcp://*:0"
406 + )
407 + with pytest.raises(ValueError, match="VLLM_HOST_IP"):
408 + EventPublisherFactory.create(config, DP_RANK)
tests/v1/engine/test_engine_core_client.py
+55
@@ -214,6 +214,7 @@ def _make_dplb_client(num_engines: int = 3, client_count: int = 1) -> DPLBAsyncM
214 client.core_engines = [bytes([i, 0]) for i in range(num_engines)]
215 client.lb_engines = [[0, 0, 0.0] for _ in range(num_engines)]
216 client.eng_start_index = 0
217 + client._kv_event_sources = {}
218 return client
219
220
@@ -1490,3 +1491,57 @@ def test_ready_response_queries_executor_with_weight_transfer():
1491
1492 executor.supports_draft_weight_updates.assert_called_once()
1493 assert response.supports_draft_weight_updates is True
1494 +
1495 +
1496 +def test_apply_ready_response_retains_kv_event_sources():
1497 + """Each engine's resolved publisher config is kept by DP rank; engines
1498 + without KV events contribute nothing."""
1499 + import msgspec
1500 +
1501 + from vllm.config.kv_events import KVEventsConfig
1502 +
1503 + client = object.__new__(MPClient)
1504 + client._effective_attention_block_sizes = set()
1505 + client._kv_event_sources = {}
1506 + client.vllm_config = SimpleNamespace(
1507 + cache_config=SimpleNamespace(block_size=16, num_gpu_blocks=0),
1508 + model_config=SimpleNamespace(max_model_len=8192),
1509 + )
1510 +
1511 + def ready(rank: int, config: KVEventsConfig | None) -> bytes:
1512 + return msgspec.msgpack.encode(
1513 + EngineCoreReadyResponse(
1514 + max_model_len=8192,
1515 + num_gpu_blocks=100,
1516 + block_size=16,
1517 + dp_stats_address=None,
1518 + dtype="bfloat16",
1519 + vllm_version="test",
1520 + world_size=2,
1521 + data_parallel_size=2,
1522 + tensor_parallel_size=1,
1523 + pipeline_parallel_size=1,
1524 + decode_context_parallel_size=1,
1525 + data_parallel_rank=rank,
1526 + max_num_seqs=256,
1527 + max_num_batched_tokens=8192,
1528 + instance_id="test-instance",
1529 + supports_lora=False,
1530 + max_loras=0,
1531 + kv_events_config=config,
1532 + )
1533 + )
1534 +
1535 + rank1 = KVEventsConfig(
1536 + enable_kv_cache_events=True, publisher="zmq", endpoint="tcp://10.0.0.2:41233"
1537 + )
1538 + client._apply_ready_response(ready(1, rank1))
1539 + client._apply_ready_response(ready(0, None))
1540 + assert client.get_kv_event_sources() == {1: rank1}
1541 +
1542 + # A rank that comes back (elastic re-add) reports its new port.
1543 + rank1_again = KVEventsConfig(
1544 + enable_kv_cache_events=True, publisher="zmq", endpoint="tcp://10.0.0.2:47001"
1545 + )
1546 + client._apply_ready_response(ready(1, rank1_again))
1547 + assert client.get_kv_event_sources() == {1: rank1_again}
vllm/distributed/kv_events.py
+40 -14
@@ -17,6 +17,12 @@ import zmq
17
18 from vllm.config.kv_events import KVEventsConfig
19 from vllm.logger import init_logger
20 +from vllm.utils.network_utils import (
21 + get_ip,
22 + get_tcp_uri,
23 + is_valid_ipv6_address,
24 + split_zmq_path,
25 +)
26 from vllm.v1.core.kv_cache_utils import ExternalBlockHash
27
28 logger = init_logger(__name__)
@@ -349,6 +355,8 @@ class ZmqEventPublisher(EventPublisher):
355 replay_endpoint, self._dp_rank
356 )
357 assert self._endpoint is not None
358 + self._hwm = hwm
359 + self._socket_setup()
360 self._publisher_config = KVEventsConfig(
361 enable_kv_cache_events=True,
362 publisher="zmq",
@@ -359,8 +367,6 @@ class ZmqEventPublisher(EventPublisher):
367 max_queue_size=max_queue_size,
368 topic=topic,
369 )
362 - self._hwm = hwm
363 - self._socket_setup()
370
371 # Payload
372 self._seq_gen = count()
@@ -423,16 +429,10 @@ class ZmqEventPublisher(EventPublisher):
429 if self._pub is None:
430 self._pub = self._ctx.socket(zmq.PUB)
431 self._pub.set_hwm(self._hwm)
426 - # Heuristic: bind if wildcard / * present, else connect.
427 - # bind stable, connect volatile convention
428 - if self._endpoint is not None and (
429 - "*" in self._endpoint
430 - or "::" in self._endpoint
431 - or self._endpoint.startswith("ipc://")
432 - or self._endpoint.startswith("inproc://")
433 - ):
434 - self._pub.bind(self._endpoint)
435 - elif self._endpoint is not None:
432 + assert self._endpoint is not None
433 + if self._is_bind_endpoint(self._endpoint):
434 + self._endpoint = self._bind(self._pub, self._endpoint)
435 + else:
436 self._pub.connect(self._endpoint)
437
438 # Set up replay socket: use ROUTER
@@ -441,7 +441,31 @@ class ZmqEventPublisher(EventPublisher):
441 # 3) works in our non‑blocking poll loop alongside PUB
442 if self._replay_endpoint is not None:
443 self._replay = self._ctx.socket(zmq.ROUTER)
444 - self._replay.bind(self._replay_endpoint)
444 + self._replay_endpoint = self._bind(self._replay, self._replay_endpoint)
445 +
446 + @staticmethod
447 + def _is_bind_endpoint(endpoint: str) -> bool:
448 + """Bind wildcard and ipc/inproc endpoints; connect to the rest."""
449 + return (
450 + "*" in endpoint
451 + or "::" in endpoint
452 + or endpoint.startswith(("ipc://", "inproc://"))
453 + )
454 +
455 + @staticmethod
456 + def _bind(sock: zmq.Socket, endpoint: str) -> str:
457 + """Bind; tcp://*:0 binds this node's IP and returns the bound endpoint."""
458 + if endpoint != "tcp://*:0":
459 + sock.bind(endpoint)
460 + return endpoint
461 + ip = get_ip()
462 + if ip in ("0.0.0.0", "::"):
463 + raise ValueError("tcp://*:0 needs a node IP; set VLLM_HOST_IP")
464 + if is_valid_ipv6_address(ip):
465 + sock.setsockopt(zmq.IPV6, 1)
466 + sock.bind(get_tcp_uri(ip, 0))
467 + _, _, port = split_zmq_path(sock.getsockopt_string(zmq.LAST_ENDPOINT))
468 + return get_tcp_uri(ip, int(port))
469
470 def _publisher_thread(self) -> None:
471 """Background thread that processes the event queue."""
@@ -514,7 +538,7 @@ class ZmqEventPublisher(EventPublisher):
538
539 Returns:
540 The endpoint with the port offset by data_parallel_rank
517 - or suffix appended
541 + or suffix appended; a tcp port of 0 is left for the OS.
542
543 """
544 # Do nothing if input is None or data_parallel_rank is 0
@@ -529,6 +553,8 @@ class ZmqEventPublisher(EventPublisher):
553 last_colon_idx = endpoint.rfind(":")
554 base_addr = endpoint[:last_colon_idx]
555 base_port = int(endpoint[last_colon_idx + 1 :])
556 + if base_port == 0:
557 + return endpoint
558 new_port = base_port + data_parallel_rank
559 return f"{base_addr}:{new_port}"
560 return endpoint
vllm/engine/protocol.py
+5
@@ -7,6 +7,7 @@ from dataclasses import dataclass
7 from typing import TYPE_CHECKING, Any
8
9 from vllm.config import ModelConfig, VllmConfig
10 +from vllm.config.kv_events import KVEventsConfig
11 from vllm.distributed.weight_transfer.base import (
12 WeightTransferInitRequest,
13 WeightTransferUpdateRequest,
@@ -62,6 +63,10 @@ class EngineClient(ABC):
63 @abstractmethod
64 def dead_error(self) -> BaseException: ...
65
66 + def get_kv_event_sources(self) -> dict[int, KVEventsConfig]:
67 + """KV-event publisher config of each engine, keyed by DP rank."""
68 + return {}
69 +
70 def check_admission( # noqa: B027
71 self, n: int = 1, request_id: str | None = None
72 ) -> None:
vllm/entrypoints/serve/instrumentator/basic.py
+7
@@ -54,3 +54,10 @@ async def get_server_load_metrics(request: Request):
54 async def show_version():
55 ver = {"version": VLLM_VERSION}
56 return JSONResponse(content=ver)
57 +
58 +
59 +@router.get("/kv_event_sources")
60 +async def kv_event_sources(raw_request: Request):
61 + """KV-event publisher config per DP rank; empty on render-only servers."""
62 + client: EngineClient | None = raw_request.app.state.engine_client
63 + return client.get_kv_event_sources() if client is not None else {}
vllm/v1/engine/async_llm.py
+4
@@ -12,6 +12,7 @@ from typing import Any
12 import vllm.envs as envs
13 from vllm import TokensPrompt
14 from vllm.config import VllmConfig
15 +from vllm.config.kv_events import KVEventsConfig
16 from vllm.distributed.weight_transfer.base import (
17 WeightTransferInitRequest,
18 WeightTransferUpdateRequest,
@@ -1282,3 +1283,6 @@ class AsyncLLM(EngineClient):
1283 async def get_weight_version(self) -> str:
1284 """Return the latest committed weight version."""
1285 return await self.engine_core.get_weight_version_async()
1286 +
1287 + def get_kv_event_sources(self) -> dict[int, KVEventsConfig]:
1288 + return self.engine_core.get_kv_event_sources()
vllm/v1/engine/core_client.py
+16
@@ -23,6 +23,7 @@ import zmq.asyncio
23
24 from vllm import envs
25 from vllm.config import VllmConfig
26 +from vllm.config.kv_events import KVEventsConfig
27 from vllm.envs import VLLM_ENGINE_READY_TIMEOUT_S
28 from vllm.logger import init_logger
29 from vllm.lora.request import LoRARequest
@@ -86,6 +87,10 @@ class EngineCoreClient(ABC):
87 * AsyncMPClient: ZMQ + background proc EngineCore w/ asyncio (for AsyncLLM)
88 """
89
90 + def get_kv_event_sources(self) -> dict[int, KVEventsConfig]:
91 + """KV-event publisher config of each ready engine, keyed by DP rank."""
92 + return {}
93 +
94 @staticmethod
95 def make_client(
96 multiprocess_mode: bool,
@@ -572,6 +577,7 @@ class MPClient(EngineCoreClient):
577 self.vllm_config = vllm_config
578 self._renderer: BaseRenderer | None = renderer
579 self._effective_attention_block_sizes: set[int | None] = set()
580 + self._kv_event_sources: dict[int, KVEventsConfig] = {}
581
582 # ZMQ setup.
583 sync_ctx = zmq.Context(io_threads=2)
@@ -860,6 +866,14 @@ class MPClient(EngineCoreClient):
866 else:
867 assert response.dp_stats_address == self.stats_update_address
868
869 + if response.kv_events_config is not None:
870 + self._kv_event_sources[response.data_parallel_rank] = (
871 + response.kv_events_config
872 + )
873 +
874 + def get_kv_event_sources(self) -> dict[int, KVEventsConfig]:
875 + return dict(self._kv_event_sources)
876 +
877
878 def _process_utility_output(
879 output: UtilityOutput, utility_results: dict[int, AnyFuture]
@@ -1944,6 +1958,8 @@ class DPLBAsyncMPClient(DPAsyncMPClient):
1958 self.lb_engines = self.lb_engines[:new_data_parallel_size]
1959 # Pending coordinator snapshots must use the surviving ranks too.
1960 self.engine_ranks_managed = self.engine_ranks_managed[:new_data_parallel_size]
1961 + for rank in range(new_data_parallel_size, cur_data_parallel_size):
1962 + self._kv_event_sources.pop(rank, None)
1963 removed_dp_size = cur_data_parallel_size - new_data_parallel_size
1964 pause_modes = ["keep"] * new_data_parallel_size + ["abort"] * removed_dp_size
1965 pause_futures = [