[Rust Frontend] Record Mooncake/NIXL KV-connector metrics (#52755)

Co-authored-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: Markov Ilya <markovilya197@gmail.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Ilya Markov committed Sep 4, 2026 at 08:21 UTC ae2d1ca96fa2a98f92c775acfe8a154748495a27
10 files changed +600 -62
rust/src/engine-core-client/src/metrics.rs
+181 -3
@@ -7,11 +7,14 @@ use std::time::{SystemTime, UNIX_EPOCH};
7
8 use vllm_metrics::{
9 EngineLabels, EnginePositionLabels, F64Gauge, Family, HistogramMetric, LoraAdapterNames,
10 - LoraInfoLabels, SchedulerLogStatsAccumulator, SchedulerMetrics, U64Counter, U64Gauge,
10 + LoraInfoLabels, MooncakeOperationCounterFamily, MooncakeOperationHistogramFamily,
11 + MooncakeOperationLabels, SchedulerLogStatsAccumulator, SchedulerMetrics, U64Counter, U64Gauge,
12 WaitingReasonLabels,
13 };
14
14 -use crate::protocol::stats::SchedulerStats;
15 +use crate::protocol::stats::{
16 + KvConnectorStats, MooncakeStats, MultiConnectorStats, NixlStats, SchedulerStats,
17 +};
18 use crate::transport::ConnectedEngine;
19
20 const WAITING_REASON_CAPACITY: &str = "capacity";
@@ -57,6 +60,24 @@ struct SchedulerStatsHandles {
60 kv_block_idle_before_evict_seconds: HistogramMetric,
61 kv_block_reuse_gap_seconds: HistogramMetric,
62
63 + // Mooncake store connector telemetry, decoded from `kv_connector_stats`.
64 + // Kept as `Family` (not pre-resolved) because `operation`/`status` are
65 + // dynamic per-record labels.
66 + mooncake_operation_time_seconds: MooncakeOperationHistogramFamily,
67 + mooncake_operation_total: MooncakeOperationCounterFamily,
68 + mooncake_operation_keys_total: MooncakeOperationCounterFamily,
69 + mooncake_operation_bytes_total: MooncakeOperationCounterFamily,
70 + mooncake_operation_failed_keys_total: MooncakeOperationCounterFamily,
71 +
72 + // NIXL connector telemetry, decoded from `kv_connector_stats`.
73 + nixl_xfer_time_seconds: HistogramMetric,
74 + nixl_post_time_seconds: HistogramMetric,
75 + nixl_bytes_transferred: HistogramMetric,
76 + nixl_num_descriptors: HistogramMetric,
77 + nixl_num_failed_transfers: U64Counter,
78 + nixl_num_failed_notifications: U64Counter,
79 + nixl_num_kv_expired_reqs: U64Counter,
80 +
81 // Non-Prometheus interval accumulator for periodic text-log helpers.
82 log_stats: SchedulerLogStatsAccumulator,
83 }
@@ -150,6 +171,20 @@ fn resolve_scheduler_stats_handles(
171 .kv_block_idle_before_evict_seconds
172 .get_or_create_owned(&labels),
173 kv_block_reuse_gap_seconds: metrics.kv_block_reuse_gap_seconds.get_or_create_owned(&labels),
174 + mooncake_operation_time_seconds: metrics.mooncake_operation_time_seconds.clone(),
175 + mooncake_operation_total: metrics.mooncake_operation_total.clone(),
176 + mooncake_operation_keys_total: metrics.mooncake_operation_keys_total.clone(),
177 + mooncake_operation_bytes_total: metrics.mooncake_operation_bytes_total.clone(),
178 + mooncake_operation_failed_keys_total: metrics.mooncake_operation_failed_keys_total.clone(),
179 + nixl_xfer_time_seconds: metrics.nixl_xfer_time_seconds.get_or_create_owned(&labels),
180 + nixl_post_time_seconds: metrics.nixl_post_time_seconds.get_or_create_owned(&labels),
181 + nixl_bytes_transferred: metrics.nixl_bytes_transferred.get_or_create_owned(&labels),
182 + nixl_num_descriptors: metrics.nixl_num_descriptors.get_or_create_owned(&labels),
183 + nixl_num_failed_transfers: metrics.nixl_num_failed_transfers.get_or_create_owned(&labels),
184 + nixl_num_failed_notifications: metrics
185 + .nixl_num_failed_notifications
186 + .get_or_create_owned(&labels),
187 + nixl_num_kv_expired_reqs: metrics.nixl_num_kv_expired_reqs.get_or_create_owned(&labels),
188 labels,
189 }
190 }
@@ -236,6 +271,79 @@ fn record_scheduler_stats_with_handles(handles: &SchedulerStatsHandles, stats: &
271 }
272 }
273 }
274 +
275 + // Connector-specific KV transfer stats. A bare connector reports its own
276 + // flat payload; MultiConnector reports connector class name -> flat child
277 + // payload.
278 + if let Some(kv_connector_stats) = &stats.kv_connector_stats {
279 + match kv_connector_stats {
280 + KvConnectorStats::Nixl(stats) => record_nixl_stats(handles, stats),
281 + KvConnectorStats::Mooncake(stats) => record_mooncake_stats(handles, stats),
282 + KvConnectorStats::Multi(stats) => record_multi_connector_stats(handles, stats),
283 + KvConnectorStats::Other(_) => {}
284 + }
285 + }
286 +}
287 +
288 +fn record_multi_connector_stats(handles: &SchedulerStatsHandles, stats: &MultiConnectorStats) {
289 + for nixl in [&stats.nixl, &stats.nixl_pull, &stats.nixl_push].into_iter().flatten() {
290 + record_nixl_stats(handles, nixl);
291 + }
292 + if let Some(mooncake) = &stats.mooncake {
293 + record_mooncake_stats(handles, mooncake);
294 + }
295 +}
296 +
297 +fn record_mooncake_stats(handles: &SchedulerStatsHandles, stats: &MooncakeStats) {
298 + for (operation, records) in &stats.0 {
299 + for record in records {
300 + let labels = MooncakeOperationLabels {
301 + model_name: handles.labels.model_name.clone(),
302 + engine: handles.labels.engine,
303 + operation: operation.as_str().to_string(),
304 + status: record.status.as_str().to_string(),
305 + };
306 + handles
307 + .mooncake_operation_time_seconds
308 + .get_or_create(&labels)
309 + .observe(record.duration_seconds);
310 + handles.mooncake_operation_total.get_or_create(&labels).inc();
311 + handles
312 + .mooncake_operation_keys_total
313 + .get_or_create(&labels)
314 + .inc_by(record.num_keys);
315 + handles
316 + .mooncake_operation_bytes_total
317 + .get_or_create(&labels)
318 + .inc_by(record.num_bytes);
319 + handles
320 + .mooncake_operation_failed_keys_total
321 + .get_or_create(&labels)
322 + .inc_by(record.num_failed_keys);
323 + }
324 + }
325 +}
326 +
327 +fn record_nixl_stats(handles: &SchedulerStatsHandles, stats: &NixlStats) {
328 + for value in &stats.transfer_duration {
329 + handles.nixl_xfer_time_seconds.observe(*value);
330 + }
331 + for value in &stats.post_duration {
332 + handles.nixl_post_time_seconds.observe(*value);
333 + }
334 + for value in &stats.bytes_transferred {
335 + handles.nixl_bytes_transferred.observe(*value as f64);
336 + }
337 + for value in &stats.num_descriptors {
338 + handles.nixl_num_descriptors.observe(*value as f64);
339 + }
340 + handles
341 + .nixl_num_failed_transfers
342 + .inc_by(stats.num_failed_transfers.iter().sum());
343 + handles
344 + .nixl_num_failed_notifications
345 + .inc_by(stats.num_failed_notifications.iter().sum());
346 + handles.nixl_num_kv_expired_reqs.inc_by(stats.num_kv_expired_reqs.iter().sum());
347 }
348
349 /// Exports `vllm:lora_requests_info` as a single series covering all LoRA
@@ -286,12 +394,16 @@ fn now_unix_secs() -> f64 {
394
395 #[cfg(test)]
396 mod tests {
289 - use std::collections::BTreeSet;
397 + use std::collections::{BTreeMap, BTreeSet};
398
399 use expect_test::expect;
400 use vllm_metrics::Metrics;
401
402 use crate::metrics::LoraInfoExporter;
403 + use crate::protocol::stats::{
404 + KvConnectorStats, MooncakeOperation, MooncakeRecord, MooncakeStats, MooncakeStatus,
405 + MultiConnectorStats, NixlStats, SchedulerStats,
406 + };
407
408 fn names(values: &[&str]) -> BTreeSet<String> {
409 values.iter().map(|name| (*name).to_string()).collect()
@@ -345,4 +457,70 @@ mod tests {
457 exporter.update(&metrics.scheduler, names(&[]), names(&[]));
458 expect![[""]].assert_eq(&lora_series(&metrics.render().unwrap()));
459 }
460 +
461 + fn nixl_stats() -> NixlStats {
462 + NixlStats {
463 + transfer_duration: vec![0.01, 0.02],
464 + post_duration: vec![0.001, 0.002],
465 + bytes_transferred: vec![4096, 8192],
466 + num_descriptors: vec![2, 4],
467 + num_failed_transfers: vec![],
468 + num_failed_notifications: vec![],
469 + num_kv_expired_reqs: vec![1],
470 + }
471 + }
472 +
473 + fn mooncake_stats() -> MooncakeStats {
474 + MooncakeStats(BTreeMap::from([(
475 + MooncakeOperation::LoadGet,
476 + vec![MooncakeRecord {
477 + duration_seconds: 0.05,
478 + num_keys: 3,
479 + num_bytes: 1024,
480 + status: MooncakeStatus::Ok,
481 + num_failed_keys: 0,
482 + }],
483 + )]))
484 + }
485 +
486 + /// Records one MultiConnector payload into Mooncake and NIXL metrics.
487 + #[test]
488 + fn kv_connector_stats_are_recorded_into_mooncake_and_nixl_metrics() {
489 + let metrics = Metrics::new();
490 + let handles = super::resolve_scheduler_stats_handles(&metrics.scheduler, "model", 0);
491 +
492 + let stats = SchedulerStats {
493 + kv_connector_stats: Some(KvConnectorStats::Multi(Box::new(MultiConnectorStats {
494 + nixl: Some(nixl_stats()),
495 + mooncake: Some(mooncake_stats()),
496 + ..Default::default()
497 + }))),
498 + ..Default::default()
499 + };
500 +
501 + super::record_scheduler_stats_with_handles(&handles, &stats);
502 +
503 + let rendered = metrics.render().unwrap();
504 + assert!(rendered.contains(
505 + "vllm:mooncake_store_operation_total{model_name=\"model\",engine=\"0\",\
506 + operation=\"load_get\",status=\"ok\"} 1"
507 + ));
508 + assert!(rendered.contains(
509 + "vllm:mooncake_store_operation_keys_total{model_name=\"model\",engine=\"0\",\
510 + operation=\"load_get\",status=\"ok\"} 3"
511 + ));
512 + assert!(rendered.contains(
513 + "vllm:mooncake_store_operation_bytes_total{model_name=\"model\",engine=\"0\",\
514 + operation=\"load_get\",status=\"ok\"} 1024"
515 + ));
516 + assert!(
517 + rendered.contains(
518 + "vllm:nixl_num_kv_expired_reqs_total{model_name=\"model\",engine=\"0\"} 1"
519 + )
520 + );
521 + assert!(
522 + rendered
523 + .contains("vllm:nixl_xfer_time_seconds_count{model_name=\"model\",engine=\"0\"} 2")
524 + );
525 + }
526 }
rust/src/engine-core-client/src/protocol/stats.rs
+116 -4
@@ -5,8 +5,6 @@ use std::collections::BTreeMap;
5
6 use serde::{Deserialize, Serialize};
7
8 -use crate::protocol::OpaqueValue;
9 -
8 /// Stores cache hit statistics.
9 ///
10 /// Original Python definition:
@@ -158,6 +156,120 @@ pub struct CudagraphStats {
156 pub runtime_mode: String,
157 }
158
159 +/// KV connector telemetry DTOs carried by [`SchedulerStats`].
160 +pub mod kv_connector {
161 + use std::collections::BTreeMap;
162 +
163 + use serde::{Deserialize, Serialize};
164 +
165 + use crate::protocol::OpaqueValue;
166 +
167 + /// NIXL connector transfer telemetry.
168 + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
169 + #[serde(deny_unknown_fields)]
170 + pub struct NixlStats {
171 + /// Time spent transferring each successful request.
172 + pub transfer_duration: Vec<f64>,
173 + /// Time spent posting each successful transfer.
174 + pub post_duration: Vec<f64>,
175 + /// Bytes transferred by each successful request.
176 + pub bytes_transferred: Vec<u64>,
177 + /// Descriptor count for each successful request.
178 + pub num_descriptors: Vec<u64>,
179 + /// Failure counter increments collected since the previous update.
180 + pub num_failed_transfers: Vec<u64>,
181 + /// Notification failure counter increments collected since the previous update.
182 + pub num_failed_notifications: Vec<u64>,
183 + /// Expired-request counter increments collected since the previous update.
184 + pub num_kv_expired_reqs: Vec<u64>,
185 + }
186 +
187 + /// Mooncake store operation name.
188 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
189 + #[serde(rename_all = "snake_case")]
190 + pub enum MooncakeOperation {
191 + SaveExists,
192 + SavePut,
193 + LoadGet,
194 + LookupExists,
195 + }
196 +
197 + impl MooncakeOperation {
198 + pub(crate) const fn as_str(self) -> &'static str {
199 + match self {
200 + Self::SaveExists => "save_exists",
201 + Self::SavePut => "save_put",
202 + Self::LoadGet => "load_get",
203 + Self::LookupExists => "lookup_exists",
204 + }
205 + }
206 + }
207 +
208 + /// Mooncake store operation status.
209 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210 + #[serde(rename_all = "snake_case")]
211 + pub enum MooncakeStatus {
212 + Ok,
213 + Error,
214 + PartialFailure,
215 + }
216 +
217 + impl MooncakeStatus {
218 + pub(crate) const fn as_str(self) -> &'static str {
219 + match self {
220 + Self::Ok => "ok",
221 + Self::Error => "error",
222 + Self::PartialFailure => "partial_failure",
223 + }
224 + }
225 + }
226 +
227 + /// One Mooncake store operation sample.
228 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
229 + #[serde(deny_unknown_fields)]
230 + pub struct MooncakeRecord {
231 + pub duration_seconds: f64,
232 + pub num_keys: u64,
233 + pub num_bytes: u64,
234 + pub status: MooncakeStatus,
235 + pub num_failed_keys: u64,
236 + }
237 +
238 + /// Mooncake store telemetry grouped by operation.
239 + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
240 + #[serde(transparent)]
241 + pub struct MooncakeStats(pub BTreeMap<MooncakeOperation, Vec<MooncakeRecord>>);
242 +
243 + /// Telemetry emitted by a `MultiConnector` with a flat child payload map.
244 + #[serde_with::skip_serializing_none]
245 + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
246 + pub struct MultiConnectorStats {
247 + #[serde(rename = "NixlConnector")]
248 + pub nixl: Option<NixlStats>,
249 + #[serde(rename = "NixlPullConnector")]
250 + pub nixl_pull: Option<NixlStats>,
251 + #[serde(rename = "NixlPushConnector")]
252 + pub nixl_push: Option<NixlStats>,
253 + #[serde(rename = "MooncakeStoreConnector")]
254 + pub mooncake: Option<MooncakeStats>,
255 + /// Child connector payloads without a Rust telemetry implementation.
256 + #[serde(flatten)]
257 + pub other: BTreeMap<String, OpaqueValue>,
258 + }
259 +
260 + /// Connector-specific scheduler telemetry.
261 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262 + #[serde(untagged)]
263 + pub enum KvConnectorStats {
264 + Nixl(NixlStats),
265 + Mooncake(MooncakeStats),
266 + Multi(Box<MultiConnectorStats>),
267 + Other(BTreeMap<String, OpaqueValue>),
268 + }
269 +}
270 +
271 +pub use kv_connector::*;
272 +
273 /// Stats associated with the scheduler.
274 ///
275 /// Original Python definition:
@@ -185,8 +297,8 @@ pub struct SchedulerStats {
297 pub kv_cache_eviction_events: Vec<KvCacheEvictionEvent>,
298 /// Speculative decoding scheduler stats, when enabled.
299 pub spec_decoding_stats: Option<SpecDecodingStats>,
188 - /// Connector-specific KV transfer stats, kept opaque for now.
189 - pub kv_connector_stats: Option<BTreeMap<String, OpaqueValue>>,
300 + /// Connector-specific KV transfer stats.
301 + pub kv_connector_stats: Option<KvConnectorStats>,
302 /// CUDA graph runtime stats when graph metrics are enabled.
303 pub cudagraph_stats: Option<CudagraphStats>,
304 /// Estimated MFU/performance stats, when enabled.
rust/src/engine-core-client/src/tests/client.rs
+27 -1
@@ -31,7 +31,7 @@ use crate::protocol::output::{
31 };
32 use crate::protocol::request::{EngineCoreRequest, EngineCoreRequestType};
33 use crate::protocol::sampling::EngineCoreSamplingParams;
34 -use crate::protocol::stats::SchedulerStats;
34 +use crate::protocol::stats::{KvConnectorStats, MooncakeOperation, SchedulerStats};
35 use crate::protocol::tensor::{WireArrayData, WireTensor};
36 use crate::protocol::utility::{UtilityOutput, UtilityResultEnvelope};
37 use crate::test_utils::{
@@ -2635,6 +2635,10 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2635 let inline_prompt_frames = lines.next().expect("missing inline prompt logprobs fixture line");
2636 let multipart_prompt_frames =
2637 lines.next().expect("missing multipart prompt logprobs fixture line");
2638 + let nixl_stats_hex = lines.next().expect("missing NIXL stats fixture line");
2639 + let mooncake_stats_hex = lines.next().expect("missing Mooncake stats fixture line");
2640 + let multi_connector_stats_hex =
2641 + lines.next().expect("missing MultiConnector stats fixture line");
2642 let ready_response_hex = lines.next().expect("missing ready response fixture line");
2643
2644 let request_bytes = hex::decode(request_hex).unwrap();
@@ -2799,6 +2803,28 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2803 .expect("multipart prompt logprobs decoded"),
2804 );
2805
2806 + let nixl_stats: KvConnectorStats =
2807 + rmp_serde::from_slice(&hex::decode(nixl_stats_hex).unwrap()).unwrap();
2808 + assert!(matches!(nixl_stats, KvConnectorStats::Nixl(_)));
2809 +
2810 + let mooncake_stats: KvConnectorStats =
2811 + rmp_serde::from_slice(&hex::decode(mooncake_stats_hex).unwrap()).unwrap();
2812 + assert!(matches!(
2813 + mooncake_stats,
2814 + KvConnectorStats::Mooncake(stats)
2815 + if stats.0.contains_key(&MooncakeOperation::LoadGet)
2816 + ));
2817 +
2818 + let multi_connector_stats: KvConnectorStats =
2819 + rmp_serde::from_slice(&hex::decode(multi_connector_stats_hex).unwrap()).unwrap();
2820 + assert!(matches!(
2821 + multi_connector_stats,
2822 + KvConnectorStats::Multi(stats)
2823 + if stats.nixl.is_some()
2824 + && stats.mooncake.is_some()
2825 + && stats.other.contains_key("UnsupportedConnector")
2826 + ));
2827 +
2828 let map_keys = |bytes: &[u8]| -> BTreeSet<String> {
2829 match decode_value(bytes) {
2830 Value::Map(entries) => entries
rust/src/engine-core-client/src/tests/python_compat.py
+29
@@ -456,6 +456,32 @@ ready_response = EngineCoreReadyResponse(
456 ),
457 )
458
459 +nixl_stats = {
460 + "transfer_duration": [0.01, 0.02],
461 + "post_duration": [0.001, 0.002],
462 + "bytes_transferred": [4096, 8192],
463 + "num_descriptors": [2, 4],
464 + "num_failed_transfers": [],
465 + "num_failed_notifications": [],
466 + "num_kv_expired_reqs": [1],
467 +}
468 +mooncake_stats = {
469 + "load_get": [
470 + {
471 + "duration_seconds": 0.05,
472 + "num_keys": 3,
473 + "num_bytes": 1024,
474 + "status": "ok",
475 + "num_failed_keys": 0,
476 + }
477 + ]
478 +}
479 +multi_connector_stats = {
480 + "NixlConnector": nixl_stats,
481 + "MooncakeStoreConnector": mooncake_stats,
482 + "UnsupportedConnector": {"sample_count": 1},
483 +}
484 +
485 print(msgspec.msgpack.encode(request).hex())
486 print(msgspec.msgpack.encode(defaults_request).hex())
487 print(msgpack.packb(multimodal_request_wire, use_bin_type=True).hex())
@@ -475,4 +501,7 @@ print(
501 for frame in encode_output_frames(multipart_prompt_logprobs, size_threshold=1)
502 )
503 )
504 +print(msgspec.msgpack.encode(nixl_stats).hex())
505 +print(msgspec.msgpack.encode(mooncake_stats).hex())
506 +print(msgspec.msgpack.encode(multi_connector_stats).hex())
507 print(msgspec.msgpack.encode(ready_response).hex())
rust/src/metrics/src/scheduler.rs
+195
@@ -29,6 +29,58 @@ fn kv_block_reuse_gap_histogram() -> Histogram {
29 Histogram::new(KV_CACHE_RESIDENCY_BUCKETS.iter().copied())
30 }
31
32 +// Buckets copied verbatim from the Python `MooncakeStorePromMetrics` /
33 +// `NixlPromMetrics` classes so dashboards built against either frontend are
34 +// interchangeable.
35 +const MOONCAKE_OPERATION_TIME_BUCKETS: [f64; 15] = [
36 + 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 2e-1, 3e-1, 4e-1, 5e-1, 7.5e-1, 1.0, 1.5, 2.0, 3.0, 4.0,
37 +];
38 +const NIXL_XFER_TIME_BUCKETS: [f64; 12] = [
39 + 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0, 5.0,
40 +];
41 +const NIXL_POST_TIME_BUCKETS: [f64; 13] = [
42 + 0.001, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0, 5.0,
43 +];
44 +// Uniform 2KB to 8GB range: 2**(10+i) for i in range(1, 25, 2).
45 +const NIXL_BYTES_TRANSFERRED_BUCKETS: [f64; 12] = [
46 + 2048.0,
47 + 8192.0,
48 + 32768.0,
49 + 131072.0,
50 + 524288.0,
51 + 2097152.0,
52 + 8388608.0,
53 + 33554432.0,
54 + 134217728.0,
55 + 536870912.0,
56 + 2147483648.0,
57 + 8589934592.0,
58 +];
59 +const NIXL_NUM_DESCRIPTORS_BUCKETS: [f64; 14] = [
60 + 10.0, 20.0, 30.0, 50.0, 75.0, 100.0, 200.0, 400.0, 1000.0, 2000.0, 4000.0, 10000.0, 20000.0,
61 + 50000.0,
62 +];
63 +
64 +fn mooncake_operation_time_histogram() -> Histogram {
65 + Histogram::new(MOONCAKE_OPERATION_TIME_BUCKETS.iter().copied())
66 +}
67 +
68 +fn nixl_xfer_time_histogram() -> Histogram {
69 + Histogram::new(NIXL_XFER_TIME_BUCKETS.iter().copied())
70 +}
71 +
72 +fn nixl_post_time_histogram() -> Histogram {
73 + Histogram::new(NIXL_POST_TIME_BUCKETS.iter().copied())
74 +}
75 +
76 +fn nixl_bytes_transferred_histogram() -> Histogram {
77 + Histogram::new(NIXL_BYTES_TRANSFERRED_BUCKETS.iter().copied())
78 +}
79 +
80 +fn nixl_num_descriptors_histogram() -> Histogram {
81 + Histogram::new(NIXL_NUM_DESCRIPTORS_BUCKETS.iter().copied())
82 +}
83 +
84 #[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]
85 pub struct EngineLabels {
86 pub model_name: String,
@@ -49,6 +101,24 @@ pub struct WaitingReasonLabels {
101 pub reason: &'static str,
102 }
103
104 +/// Labels for per-operation Mooncake store connector telemetry. `operation`
105 +/// and `status` are dynamic (not known ahead of time), unlike the other
106 +/// fixed-label families in this module.
107 +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]
108 +pub struct MooncakeOperationLabels {
109 + pub model_name: String,
110 + pub engine: u32,
111 + pub operation: String,
112 + pub status: String,
113 +}
114 +
115 +/// Family type aliases for the Mooncake connector telemetry, exposed so
116 +/// `engine-core-client` can hold onto the `Family` directly (rather than a
117 +/// pre-resolved handle) since `operation`/`status` vary per record.
118 +pub type MooncakeOperationHistogramFamily =
119 + Family<MooncakeOperationLabels, Histogram, fn() -> Histogram>;
120 +pub type MooncakeOperationCounterFamily = Family<MooncakeOperationLabels, U64Counter>;
121 +
122 /// Adapter names encoded as a deterministic comma-joined Prometheus label value.
123 #[derive(Clone, Debug, Hash, PartialEq, Eq)]
124 pub struct LoraAdapterNames(pub BTreeSet<String>);
@@ -184,6 +254,22 @@ pub struct SchedulerMetrics {
254 pub kv_block_idle_before_evict_seconds: HistogramFamily,
255 pub kv_block_reuse_gap_seconds: HistogramFamily,
256
257 + // Mooncake store connector telemetry. Mirrors `MooncakeStorePromMetrics`.
258 + pub mooncake_operation_time_seconds: MooncakeOperationHistogramFamily,
259 + pub mooncake_operation_total: MooncakeOperationCounterFamily,
260 + pub mooncake_operation_keys_total: MooncakeOperationCounterFamily,
261 + pub mooncake_operation_bytes_total: MooncakeOperationCounterFamily,
262 + pub mooncake_operation_failed_keys_total: MooncakeOperationCounterFamily,
263 +
264 + // NIXL connector telemetry. Mirrors `NixlPromMetrics`.
265 + pub nixl_xfer_time_seconds: HistogramFamily,
266 + pub nixl_post_time_seconds: HistogramFamily,
267 + pub nixl_bytes_transferred: HistogramFamily,
268 + pub nixl_num_descriptors: HistogramFamily,
269 + pub nixl_num_failed_transfers: Family<EngineLabels, U64Counter>,
270 + pub nixl_num_failed_notifications: Family<EngineLabels, U64Counter>,
271 + pub nixl_num_kv_expired_reqs: Family<EngineLabels, U64Counter>,
272 +
273 /// Non-Prometheus interval accumulators for periodic text-log helpers.
274 pub log_stats: Family<EngineLabels, SchedulerLogStatsAccumulator>,
275 }
@@ -336,6 +422,103 @@ impl SchedulerMetrics {
422 kv_block_reuse_gap_seconds.clone(),
423 );
424
425 + // Mooncake store connector telemetry.
426 + let mooncake_operation_time_seconds =
427 + Family::new_with_constructor(mooncake_operation_time_histogram as fn() -> Histogram);
428 + registry.register(
429 + "vllm:mooncake_store_operation_time_seconds",
430 + "Histogram of Mooncake store communication time.",
431 + mooncake_operation_time_seconds.clone(),
432 + );
433 +
434 + // NOTE: registration names below omit the `_total` suffix that the
435 + // Python metric names carry (e.g. `vllm:mooncake_store_operation_total`)
436 + // because this crate's `prometheus-client` (unlike Python's, which
437 + // strips a user-supplied `_total` before re-adding it) appends
438 + // `_total` unconditionally for counters; see the note in `lib.rs`.
439 + let mooncake_operation_total = Family::default();
440 + registry.register(
441 + "vllm:mooncake_store_operation",
442 + "Number of Mooncake store communication operations.",
443 + mooncake_operation_total.clone(),
444 + );
445 +
446 + let mooncake_operation_keys_total = Family::default();
447 + registry.register(
448 + "vllm:mooncake_store_operation_keys",
449 + "Number of Mooncake store keys touched by operations.",
450 + mooncake_operation_keys_total.clone(),
451 + );
452 +
453 + let mooncake_operation_bytes_total = Family::default();
454 + registry.register(
455 + "vllm:mooncake_store_operation_bytes",
456 + "Number of bytes transferred by Mooncake store operations.",
457 + mooncake_operation_bytes_total.clone(),
458 + );
459 +
460 + let mooncake_operation_failed_keys_total = Family::default();
461 + registry.register(
462 + "vllm:mooncake_store_operation_failed_keys",
463 + "Number of Mooncake store keys that failed in operations.",
464 + mooncake_operation_failed_keys_total.clone(),
465 + );
466 +
467 + // NIXL connector telemetry.
468 + let nixl_xfer_time_seconds =
469 + Family::new_with_constructor(nixl_xfer_time_histogram as fn() -> Histogram);
470 + registry.register(
471 + "vllm:nixl_xfer_time_seconds",
472 + "Histogram of transfer duration for NIXL KV Cache transfers.",
473 + nixl_xfer_time_seconds.clone(),
474 + );
475 +
476 + let nixl_post_time_seconds =
477 + Family::new_with_constructor(nixl_post_time_histogram as fn() -> Histogram);
478 + registry.register(
479 + "vllm:nixl_post_time_seconds",
480 + "Histogram of transfer post time for NIXL KV Cache transfers.",
481 + nixl_post_time_seconds.clone(),
482 + );
483 +
484 + let nixl_bytes_transferred =
485 + Family::new_with_constructor(nixl_bytes_transferred_histogram as fn() -> Histogram);
486 + registry.register(
487 + "vllm:nixl_bytes_transferred",
488 + "Histogram of bytes transferred per NIXL KV Cache transfers.",
489 + nixl_bytes_transferred.clone(),
490 + );
491 +
492 + let nixl_num_descriptors =
493 + Family::new_with_constructor(nixl_num_descriptors_histogram as fn() -> Histogram);
494 + registry.register(
495 + "vllm:nixl_num_descriptors",
496 + "Histogram of number of descriptors per NIXL KV Cache transfers.",
497 + nixl_num_descriptors.clone(),
498 + );
499 +
500 + let nixl_num_failed_transfers = Family::default();
501 + registry.register(
502 + "vllm:nixl_num_failed_transfers",
503 + "Number of failed NIXL KV Cache transfers.",
504 + nixl_num_failed_transfers.clone(),
505 + );
506 +
507 + let nixl_num_failed_notifications = Family::default();
508 + registry.register(
509 + "vllm:nixl_num_failed_notifications",
510 + "Number of failed NIXL KV Cache notifications.",
511 + nixl_num_failed_notifications.clone(),
512 + );
513 +
514 + let nixl_num_kv_expired_reqs = Family::default();
515 + registry.register(
516 + "vllm:nixl_num_kv_expired_reqs",
517 + "Number of requests that had their KV expire. \
518 + NOTE: This metric is tracked on the P instance.",
519 + nixl_num_kv_expired_reqs.clone(),
520 + );
521 +
522 Self {
523 scheduler_running,
524 scheduler_waiting,
@@ -356,6 +539,18 @@ impl SchedulerMetrics {
539 kv_block_lifetime_seconds,
540 kv_block_idle_before_evict_seconds,
541 kv_block_reuse_gap_seconds,
542 + mooncake_operation_time_seconds,
543 + mooncake_operation_total,
544 + mooncake_operation_keys_total,
545 + mooncake_operation_bytes_total,
546 + mooncake_operation_failed_keys_total,
547 + nixl_xfer_time_seconds,
548 + nixl_post_time_seconds,
549 + nixl_bytes_transferred,
550 + nixl_num_descriptors,
551 + nixl_num_failed_transfers,
552 + nixl_num_failed_notifications,
553 + nixl_num_kv_expired_reqs,
554 log_stats: Family::default(),
555 }
556 }
tests/v1/kv_connector/unit/test_multi_connector.py
+38 -43
@@ -23,6 +23,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import (
23 from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStats
24 from vllm.distributed.kv_transfer.kv_connector.v1.multi_connector import (
25 MultiConnector,
26 + MultiKVConnectorPromMetrics,
27 MultiKVConnectorStats,
28 MultiKVConnectorWorkerMetadata,
29 )
@@ -559,14 +560,12 @@ class TestMultiConnectorStats:
560 correct data."""
561 serialized_data = {
562 "NixlConnector": {
562 - "data": {
563 - "transfer_duration": [1.5, 2.3],
564 - "post_duration": [0.1, 0.2],
565 - "bytes_transferred": [1024, 2048],
566 - "num_descriptors": [10, 20],
567 - "num_failed_transfers": [],
568 - "num_failed_notifications": [],
569 - }
563 + "transfer_duration": [1.5, 2.3],
564 + "post_duration": [0.1, 0.2],
565 + "bytes_transferred": [1024, 2048],
566 + "num_descriptors": [10, 20],
567 + "num_failed_transfers": [],
568 + "num_failed_notifications": [],
569 }
570 }
571
@@ -584,16 +583,14 @@ class TestMultiConnectorStats:
583 """Test reconstruction with multiple connector types that have custom stats."""
584 serialized_data = {
585 "NixlConnector": {
587 - "data": {
588 - "transfer_duration": [1.5],
589 - "post_duration": [0.1],
590 - "bytes_transferred": [1024],
591 - "num_descriptors": [10],
592 - "num_failed_transfers": [],
593 - "num_failed_notifications": [],
594 - }
586 + "transfer_duration": [1.5],
587 + "post_duration": [0.1],
588 + "bytes_transferred": [1024],
589 + "num_descriptors": [10],
590 + "num_failed_transfers": [],
591 + "num_failed_notifications": [],
592 },
596 - "MockConnector": {"data": {"mock_field": [1, 2, 3]}},
593 + "MockConnector": {"mock_field": [1, 2, 3]},
594 }
595
596 stats = MultiConnector.build_kv_connector_stats(data=serialized_data)
@@ -608,20 +605,19 @@ class TestMultiConnectorStats:
605 assert isinstance(stats.data["MockConnector"], MockConnectorStats)
606 # Verify data is preserved
607 assert stats.data["MockConnector"].data == {"mock_field": [1, 2, 3]}
608 + assert stats.to_dict() == serialized_data
609
610 def test_build_kv_connector_stats_raises_error_for_unknown_connector(self):
611 """Test that unknown connectors raise an error."""
612 serialized_data = {
615 - "UnknownConnector": {"data": {"some_field": [1, 2, 3]}},
613 + "UnknownConnector": {"some_field": [1, 2, 3]},
614 "NixlConnector": {
617 - "data": {
618 - "transfer_duration": [1.5],
619 - "post_duration": [0.1],
620 - "bytes_transferred": [1024],
621 - "num_descriptors": [10],
622 - "num_failed_transfers": [],
623 - "num_failed_notifications": [],
624 - }
615 + "transfer_duration": [1.5],
616 + "post_duration": [0.1],
617 + "bytes_transferred": [1024],
618 + "num_descriptors": [10],
619 + "num_failed_transfers": [],
620 + "num_failed_notifications": [],
621 },
622 }
623
@@ -675,7 +671,7 @@ class TestMultiConnectorStats:
671
672 mixed_data = {
673 "NixlConnector": nixl_stats, # Already instantiated
678 - "MockConnector": {"data": {"mock_field": [1, 2, 3]}}, # Serialized
674 + "MockConnector": {"mock_field": [1, 2, 3]}, # Serialized
675 }
676
677 stats = MultiConnector.build_kv_connector_stats(data=mixed_data)
@@ -695,16 +691,14 @@ class TestMultiConnectorStats:
691 # so it returns None and should be skipped
692 serialized_data = {
693 "NixlConnector": {
698 - "data": {
699 - "transfer_duration": [1.5],
700 - "post_duration": [0.1],
701 - "bytes_transferred": [1024],
702 - "num_descriptors": [10],
703 - "num_failed_transfers": [],
704 - "num_failed_notifications": [],
705 - }
694 + "transfer_duration": [1.5],
695 + "post_duration": [0.1],
696 + "bytes_transferred": [1024],
697 + "num_descriptors": [10],
698 + "num_failed_transfers": [],
699 + "num_failed_notifications": [],
700 },
707 - "ExampleConnector": {"data": {"some_field": [1, 2, 3]}},
701 + "ExampleConnector": {"some_field": [1, 2, 3]},
702 }
703
704 stats = MultiConnector.build_kv_connector_stats(data=serialized_data)
@@ -718,14 +712,15 @@ class TestMultiConnectorStats:
712 # ExampleConnector should be skipped (returns None)
713 assert "ExampleConnector" not in stats.data
714
721 - def test_build_kv_connector_stats_handles_malformed_data(self):
722 - """Test that malformed data raises appropriate errors."""
723 - serialized_data = {
724 - "NixlConnector": {"wrong_field": {"transfer_duration": [1.5]}}
725 - }
715 + def test_prom_metrics_passes_flat_data_to_children(self):
716 + child_metrics = MagicMock()
717 + metrics = object.__new__(MultiKVConnectorPromMetrics)
718 + metrics._prom_metrics = {"NixlConnector": child_metrics}
719 + payload = {"NixlConnector": {"transfer_duration": [1.5]}}
720
727 - with pytest.raises(AssertionError, match="Expected a dict with a 'data' field"):
728 - MultiConnector.build_kv_connector_stats(data=serialized_data)
721 + metrics.observe(payload, engine_idx=2)
722 +
723 + child_metrics.observe.assert_called_once_with({"transfer_duration": [1.5]}, 2)
724
725 def test_aggregate_same_connector(self):
726 """Test aggregating stats from the same connector type."""
tests/v1/kv_connector/unit/test_nixl_connector.py
+2 -1
@@ -1709,8 +1709,9 @@ def test_scheduler_kv_connector_stats_aggregation():
1709 final_stats = next(
1710 iter(engine_core_outputs.values())
1711 ).scheduler_stats.kv_connector_stats
1712 + # The scheduler stats payload carries serialized per-connector dicts.
1713 nixl_stats = final_stats["NixlConnector"]
1713 - assert nixl_stats.num_successful_transfers == 2
1714 + assert len(nixl_stats["transfer_duration"]) == 2
1715
1716
1717 @pytest.mark.parametrize("distributed_executor_backend", ["ray", None])
vllm/distributed/kv_transfer/kv_connector/v1/metrics.py
+4
@@ -26,6 +26,10 @@ class KVConnectorStats:
26
27 data: dict[str, Any] = field(default_factory=dict)
28
29 + def to_dict(self) -> dict[str, Any]:
30 + """Return the serializable connector stats payload."""
31 + return self.data
32 +
33 def reset(self):
34 """Reset the stats, clear the state."""
35 raise NotImplementedError
vllm/distributed/kv_transfer/kv_connector/v1/multi_connector.py
+7 -9
@@ -75,6 +75,11 @@ class MultiKVConnectorStats(KVConnectorStats):
75 This is used to aggregate the stats from all connectors separately.
76 """
77
78 + def to_dict(self) -> dict[str, Any]:
79 + return {
80 + connector_id: stats.to_dict() for connector_id, stats in self.data.items()
81 + }
82 +
83 def aggregate(self, other: KVConnectorStats) -> KVConnectorStats:
84 for connector_id, stats in other.data.items():
85 if connector_id not in self.data:
@@ -122,7 +127,7 @@ class MultiKVConnectorPromMetrics(KVConnectorPromMetrics):
127 f"{connector_id} is not contained in the list of registered connectors "
128 f"with Prometheus metrics support: {self._prom_metrics.keys()}"
129 )
125 - self._prom_metrics[connector_id].observe(stats_data["data"], engine_idx)
130 + self._prom_metrics[connector_id].observe(stats_data, engine_idx)
131
132
133 class MultiConnector(KVConnectorBase_V1, SupportsHMA):
@@ -617,16 +622,9 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA):
622 connector_name
623 )
624
620 - # stats_value is the serialized dataclass which contains {'data': {...}}
621 - # We need to extract the inner 'data' field to avoid double-nesting
622 - assert isinstance(stats_value, dict) and "data" in stats_value, (
623 - f"Expected a dict with a 'data' field, got {stats_value}"
624 - )
625 - inner_data = stats_value["data"]
626 -
625 # Use the connector's build_kv_connector_stats to reconstruct
626 if reconstructed_stats := connector_cls.build_kv_connector_stats(
629 - data=inner_data
627 + data=stats_value
628 ):
629 reconstructed_data[connector_name] = reconstructed_stats
630
vllm/v1/core/sched/scheduler.py
+1 -1
@@ -2704,7 +2704,7 @@ class Scheduler(SchedulerInterface):
2704 )
2705 spec_stats = spec_decoding_stats
2706 connector_stats_payload = (
2707 - kv_connector_stats.data if kv_connector_stats else None
2707 + kv_connector_stats.to_dict() if kv_connector_stats else None
2708 )
2709 return SchedulerStats(
2710 num_running_reqs=len(self.running),