feat(grpc): add KV event source discovery (#50033)
Signed-off-by: Connor Carpenter <connorc@nvidia.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>
Connor Carpenter committed
Jul 29, 2026 at 13:46 UTC
48fc2e2797ec3f35605c3ff399bbaab7938b0459
13 files changed
+179
-5
rust/proto/control.proto
+21
@@ -8,6 +8,7 @@ service Control {
8
rpc GetServerInfo (GetServerInfoRequest) returns (ServerInfo) {}
9
rpc GetModelInfo (GetModelInfoRequest) returns (ModelInfo) {}
10
rpc Abort (AbortRequest) returns (AbortResponse) {}
11
+ rpc GetKvEventSources (GetKvEventSourcesRequest) returns (GetKvEventSourcesResponse) {}
12
}
13
14
message GetServerInfoRequest {}
@@ -51,3 +52,23 @@ message AbortRequest {
52
}
53
54
message AbortResponse {}
55
+
56
+// ======================================================================================
57
+// KV discovery
58
+// ======================================================================================
59
+
60
+message GetKvEventSourcesRequest {}
61
+message GetKvEventSourcesResponse { repeated KvEventSource sources = 1; }
62
+
63
+message KvEventSource {
64
+ string transport = 1;
65
+ string endpoint = 2;
66
+ string topic = 3;
67
+ string replay_endpoint = 4;
68
+ optional uint32 data_parallel_rank = 5;
69
+ string encoding = 6;
70
+ uint32 schema_version = 7;
71
+ uint32 buffer_steps = 8;
72
+ uint32 hwm = 9;
73
+ uint32 max_queue_size = 10;
74
+}
rust/src/engine-core-client/src/mock_engine.rs
+1
@@ -66,6 +66,7 @@ pub fn default_ready_response() -> EngineCoreReadyResponse {
66
instance_id: "test-instance".to_string(),
67
kv_cache_size_tokens: None,
68
kv_cache_max_concurrency: None,
69
+ kv_events_config: None,
70
}
71
}
72
rust/src/engine-core-client/src/protocol/handshake.rs
+16
@@ -24,6 +24,19 @@ pub struct ReadyMessage {
24
pub parallel_config_hash: Option<String>,
25
}
26
27
+/// KV-event publisher configuration reported by EngineCore.
28
+#[derive(Debug, Clone, Serialize, Deserialize)]
29
+pub struct KvEventsConfig {
30
+ pub enable_kv_cache_events: bool,
31
+ pub publisher: String,
32
+ pub endpoint: String,
33
+ pub replay_endpoint: Option<String>,
34
+ pub buffer_steps: u32,
35
+ pub hwm: u32,
36
+ pub max_queue_size: u32,
37
+ pub topic: String,
38
+}
39
+
40
/// Post-initialization configuration sent from each engine on the input socket
41
/// registration message, after the handshake completes.
42
///
@@ -71,6 +84,9 @@ pub struct EngineCoreReadyResponse {
84
pub kv_cache_size_tokens: Option<u64>,
85
/// Maximum achievable request concurrency given the KV cache, if reported.
86
pub kv_cache_max_concurrency: Option<f64>,
87
+ /// KV-event publisher configuration, if configured.
88
+ #[serde(default)]
89
+ pub kv_events_config: Option<KvEventsConfig>,
90
}
91
92
/// Frontend-owned ZMQ addresses that are sent to the engine during startup
rust/src/engine-core-client/src/tests/client.rs
+16
-1
@@ -19,7 +19,7 @@ use zeromq::prelude::{Socket, SocketRecv, SocketSend};
19
use zeromq::util::PeerIdentity;
20
use zeromq::{DealerSocket, PushSocket, SocketOptions, SubSocket, XPubSocket, ZmqMessage};
21
22
-use crate::protocol::handshake::{HandshakeInitMessage, ReadyMessage};
22
+use crate::protocol::handshake::{EngineCoreReadyResponse, HandshakeInitMessage, ReadyMessage};
23
use crate::protocol::logprobs::MaybeWireLogprobs;
24
use crate::protocol::multimodal::{
25
MmFeatureSpec, MmField, MmFieldElem, MmFlatField, MmKwargValue, MmSlice, PlaceholderRange,
@@ -2677,6 +2677,21 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2677
rust_ready_keys, python_ready_keys,
2678
"EngineCoreReadyResponse drifted from the Python dataclass",
2679
);
2680
+
2681
+ let ready_response: EngineCoreReadyResponse =
2682
+ rmp_serde::from_slice(&hex::decode(ready_response_hex).unwrap()).unwrap();
2683
+ let kv_events_config = ready_response.kv_events_config.expect("KV events config should decode");
2684
+ assert!(kv_events_config.enable_kv_cache_events);
2685
+ assert_eq!(kv_events_config.publisher, "zmq");
2686
+ assert_eq!(kv_events_config.endpoint, "tcp://127.0.0.1:5557");
2687
+ assert_eq!(
2688
+ kv_events_config.replay_endpoint.as_deref(),
2689
+ Some("tcp://127.0.0.1:5558")
2690
+ );
2691
+ assert_eq!(kv_events_config.topic, "kv");
2692
+ assert_eq!(kv_events_config.buffer_steps, 10_000);
2693
+ assert_eq!(kv_events_config.hwm, 100_000);
2694
+ assert_eq!(kv_events_config.max_queue_size, 100_000);
2695
}
2696
2697
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
rust/src/engine-core-client/src/tests/python_compat.py
+23
@@ -353,6 +353,18 @@ multipart_prompt_logprobs = engine_outputs_wire(
353
)
354
355
356
+@dataclass
357
+class KVEventsConfig:
358
+ enable_kv_cache_events: bool
359
+ publisher: str
360
+ endpoint: str
361
+ replay_endpoint: str | None
362
+ buffer_steps: int
363
+ hwm: int
364
+ max_queue_size: int
365
+ topic: str
366
+
367
+
368
@dataclass
369
class EngineCoreReadyResponse:
370
max_model_len: int
@@ -372,6 +384,7 @@ class EngineCoreReadyResponse:
384
instance_id: str
385
kv_cache_size_tokens: int | None = None
386
kv_cache_max_concurrency: float | None = None
387
+ kv_events_config: KVEventsConfig | None = None
388
389
390
ready_response = EngineCoreReadyResponse(
@@ -390,6 +403,16 @@ ready_response = EngineCoreReadyResponse(
403
max_num_seqs=256,
404
max_num_batched_tokens=8192,
405
instance_id="test-instance",
406
+ kv_events_config=KVEventsConfig(
407
+ enable_kv_cache_events=True,
408
+ publisher="zmq",
409
+ endpoint="tcp://127.0.0.1:5557",
410
+ replay_endpoint="tcp://127.0.0.1:5558",
411
+ buffer_steps=10_000,
412
+ hwm=100_000,
413
+ max_queue_size=100_000,
414
+ topic="kv",
415
+ ),
416
)
417
418
print(msgspec.msgpack.encode(request).hex())
rust/src/server/src/grpc/control.rs
+29
@@ -103,4 +103,33 @@ impl pb::control_server::Control for ControlServiceImpl {
103
.map_err(|error| Status::internal(error.to_report_string()))?;
104
Ok(Response::new(pb::AbortResponse {}))
105
}
106
+
107
+ async fn get_kv_event_sources(
108
+ &self,
109
+ _request: Request<pb::GetKvEventSourcesRequest>,
110
+ ) -> Result<Response<pb::GetKvEventSourcesResponse>, Status> {
111
+ let client = self.state.engine_core_client();
112
+ let sources = client.ready_responses().into_iter().filter_map(kv_event_source).collect();
113
+ Ok(Response::new(pb::GetKvEventSourcesResponse { sources }))
114
+ }
115
+}
116
+
117
+pub(super) fn kv_event_source(response: &EngineCoreReadyResponse) -> Option<pb::KvEventSource> {
118
+ let config = response.kv_events_config.as_ref()?;
119
+ if !config.enable_kv_cache_events || config.publisher != "zmq" {
120
+ return None;
121
+ }
122
+
123
+ Some(pb::KvEventSource {
124
+ transport: "zmq".to_string(),
125
+ endpoint: config.endpoint.clone(),
126
+ topic: config.topic.clone(),
127
+ replay_endpoint: config.replay_endpoint.clone().unwrap_or_default(),
128
+ data_parallel_rank: Some(response.data_parallel_rank),
129
+ encoding: "msgpack".to_string(),
130
+ schema_version: 1,
131
+ buffer_steps: config.buffer_steps,
132
+ hwm: config.hwm,
133
+ max_queue_size: config.max_queue_size,
134
+ })
135
}
rust/src/server/src/grpc/tests.rs
+36
@@ -29,6 +29,7 @@ use vllm_engine_core_client::mock_engine::{
29
DEFAULT_MOCK_BLOCK_SIZE, DEFAULT_MOCK_MAX_MODEL_LEN, DEFAULT_MOCK_NUM_GPU_BLOCKS,
30
default_ready_response,
31
};
32
+use vllm_engine_core_client::protocol::handshake::KvEventsConfig;
33
use vllm_engine_core_client::protocol::output::{
34
EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, RequestBatchOutputs,
35
};
@@ -44,6 +45,7 @@ use vllm_tokenizer::test_utils::TestTokenizer;
45
use zeromq::prelude::{SocketRecv, SocketSend};
46
use zeromq::{DealerSocket, PushSocket, ZmqMessage};
47
48
+use super::control::kv_event_source;
49
use super::pb::control_client::ControlClient;
50
use super::pb::inference_client::InferenceClient;
51
use super::{ControlServer, ControlServiceImpl, InferenceServer, InferenceServiceImpl, pb};
@@ -1319,6 +1321,40 @@ async fn control_aggregates_multi_engine_capacity() {
1321
1322
drop(engine_tasks);
1323
}
1324
+
1325
+#[test]
1326
+fn kv_event_source_filters_and_exposes_zmq_publisher() {
1327
+ let mut ready = default_ready_response();
1328
+ ready.data_parallel_rank = 2;
1329
+ ready.kv_events_config = Some(KvEventsConfig {
1330
+ enable_kv_cache_events: false,
1331
+ publisher: "null".to_string(),
1332
+ endpoint: "tcp://*:5559".to_string(),
1333
+ replay_endpoint: Some("tcp://*:5560".to_string()),
1334
+ buffer_steps: 10_000,
1335
+ hwm: 100_000,
1336
+ max_queue_size: 100_000,
1337
+ topic: "kv".to_string(),
1338
+ });
1339
+
1340
+ assert!(kv_event_source(&ready).is_none());
1341
+
1342
+ let config = ready.kv_events_config.as_mut().unwrap();
1343
+ config.enable_kv_cache_events = true;
1344
+ config.publisher = "zmq".to_string();
1345
+ let source = kv_event_source(&ready).expect("configured ZMQ event source");
1346
+ assert_eq!(source.transport, "zmq");
1347
+ assert_eq!(source.endpoint, "tcp://*:5559");
1348
+ assert_eq!(source.topic, "kv");
1349
+ assert_eq!(source.replay_endpoint, "tcp://*:5560");
1350
+ assert_eq!(source.data_parallel_rank, Some(2));
1351
+ assert_eq!(source.encoding, "msgpack");
1352
+ assert_eq!(source.schema_version, 1);
1353
+ assert_eq!(source.buffer_steps, 10_000);
1354
+ assert_eq!(source.hwm, 100_000);
1355
+ assert_eq!(source.max_queue_size, 100_000);
1356
+}
1357
+
1358
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1359
#[serial]
1360
async fn grpc_health_transitions_to_not_serving_when_engine_becomes_unhealthy() {
tests/distributed/test_events.py
+8
-3
@@ -284,7 +284,7 @@ def test_data_parallel_rank_tagging(publisher_config):
284
sub_1.close()
285
286
287
-def test_event_publisher_factory():
287
+def test_event_publisher_factory(random_port):
288
"""Test event publisher factory creation behavior under different configurations"""
289
from vllm.config.kv_events import KVEventsConfig
290
from vllm.distributed.kv_events import ZmqEventPublisher
@@ -308,10 +308,15 @@ def test_event_publisher_factory():
308
config = KVEventsConfig(
309
enable_kv_cache_events=True,
310
publisher="zmq",
311
- endpoint="inproc://test-factory-true",
311
+ endpoint=f"tcp://*:{random_port}",
312
+ replay_endpoint=f"tcp://*:{random_port + 100}",
313
)
313
- publisher = EventPublisherFactory.create(config, DP_RANK)
314
+ publisher = EventPublisherFactory.create(config, DP_RANK + 1)
315
assert isinstance(publisher, ZmqEventPublisher)
316
+ resolved_config = publisher.get_publisher_config()
317
+ assert resolved_config.endpoint == f"tcp://*:{random_port + 1}"
318
+ assert resolved_config.replay_endpoint == f"tcp://*:{random_port + 101}"
319
+ assert config.endpoint == f"tcp://*:{random_port}"
320
publisher.shutdown()
321
322
# test unknown publisher
vllm/distributed/kv_events.py
+18
@@ -272,6 +272,10 @@ class EventPublisher(ABC):
272
def shutdown(self) -> None:
273
"""Shutdown the publisher."""
274
275
+ def get_publisher_config(self) -> KVEventsConfig | None:
276
+ """Return the publisher's resolved runtime configuration."""
277
+ return None
278
+
279
280
class NullEventPublisher(EventPublisher):
281
"""No-op implementation (default when disabled)."""
@@ -335,6 +339,17 @@ class ZmqEventPublisher(EventPublisher):
339
self._replay_endpoint = self.offset_endpoint_port(
340
replay_endpoint, self._dp_rank
341
)
342
+ assert self._endpoint is not None
343
+ self._publisher_config = KVEventsConfig(
344
+ enable_kv_cache_events=True,
345
+ publisher="zmq",
346
+ endpoint=self._endpoint,
347
+ replay_endpoint=self._replay_endpoint,
348
+ buffer_steps=buffer_steps,
349
+ hwm=hwm,
350
+ max_queue_size=max_queue_size,
351
+ topic=topic,
352
+ )
353
self._hwm = hwm
354
self._socket_setup()
355
@@ -351,6 +366,9 @@ class ZmqEventPublisher(EventPublisher):
366
)
367
self._thread.start()
368
369
+ def get_publisher_config(self) -> KVEventsConfig:
370
+ return self._publisher_config
371
+
372
def publish(self, events: EventBatch) -> None:
373
if not self._running:
374
raise RuntimeError("Publisher is closed")
vllm/v1/core/sched/interface.py
+4
@@ -9,6 +9,7 @@ from vllm.multimodal import MULTIMODAL_REGISTRY, MultiModalRegistry
9
10
if TYPE_CHECKING:
11
from vllm.config import VllmConfig
12
+ from vllm.config.kv_events import KVEventsConfig
13
from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorBase
14
from vllm.distributed.kv_transfer.kv_connector.v1 import KVConnectorBase_V1
15
from vllm.v1.core.sched.output import GrammarOutput, SchedulerOutput
@@ -256,3 +257,6 @@ class SchedulerInterface(ABC):
257
258
def get_ec_connector(self) -> "ECConnectorBase | None":
259
return None
260
+
261
+ def get_kv_event_publisher_config(self) -> "KVEventsConfig | None":
262
+ return None
vllm/v1/core/sched/scheduler.py
+4
-1
@@ -8,7 +8,7 @@ from dataclasses import replace
8
from typing import Any
9
10
from vllm.compilation.cuda_graph import CUDAGraphStat
11
-from vllm.config import VllmConfig
11
+from vllm.config import KVEventsConfig, VllmConfig
12
from vllm.distributed.ec_transfer.ec_connector.base import (
13
ECConnectorBase,
14
ECConnectorMetadata,
@@ -2554,6 +2554,9 @@ class Scheduler(SchedulerInterface):
2554
def get_ec_connector(self) -> ECConnectorBase | None:
2555
return self.ec_connector
2556
2557
+ def get_kv_event_publisher_config(self) -> KVEventsConfig | None:
2558
+ return self.kv_event_publisher.get_publisher_config()
2559
+
2560
def _connector_finished(
2561
self, request: Request
2562
) -> tuple[bool, dict[str, Any] | None]:
vllm/v1/engine/__init__.py
+2
@@ -11,6 +11,7 @@ import msgspec
11
import numpy as np
12
import torch
13
14
+from vllm.config.kv_events import KVEventsConfig
15
from vllm.lora.request import LoRARequest
16
from vllm.multimodal.inputs import MultiModalFeatureSpec
17
from vllm.pooling_params import PoolingParams
@@ -90,6 +91,7 @@ class EngineCoreReadyResponse:
91
# KV cache capacity (None for encoder-only/attention-free models).
92
kv_cache_size_tokens: int | None = None
93
kv_cache_max_concurrency: float | None = None
94
+ kv_events_config: KVEventsConfig | None = None
95
96
97
class EngineCoreRequest(
vllm/v1/engine/core.py
+1
@@ -1639,6 +1639,7 @@ class EngineCoreProc(EngineCore):
1639
max_num_seqs=scheduler_config.max_num_seqs,
1640
max_num_batched_tokens=scheduler_config.max_num_batched_tokens,
1641
instance_id=self.vllm_config.instance_id,
1642
+ kv_events_config=self.scheduler.get_kv_event_publisher_config(),
1643
)
1644
1645
def process_input_sockets(