[KV Offloading] Add labeled metrics support (#45957)

Signed-off-by: srinivas_oo7 <sklinkedin0120@gmail.com> Co-authored-by: srinivas_oo7 <sklinkedin0120@gmail.com>

Srinivas Krovvidi committed Jun 21, 2026 at 13:04 UTC c441ad1c07cbfe0240a5699e4386fd0b5cc8aa82
3 files changed +384 -123
tests/v1/kv_connector/unit/offloading_connector/test_metrics.py
+255 -60
@@ -22,7 +22,7 @@ from vllm.v1.kv_offload.base import (
22 OffloadingGaugeMetadata,
23 OffloadingHistogramMetadata,
24 )
25 -from vllm.v1.kv_offload.cpu.spec import CPUOffloadingSpec
25 +from vllm.v1.kv_offload.factory import OffloadingSpecFactory
26
27 LOAD_BYTES = _TransferMetricName.LOAD_BYTES
28 LOAD_TIME = _TransferMetricName.LOAD_TIME
@@ -33,6 +33,8 @@ STORE_SIZE = _TransferMetricName.STORE_SIZE
33 STORES_SKIPPED = "vllm:kv_offload_stores_skipped"
34 PENDING_STORES = "vllm:kv_offload_pending_stores"
35 LOOKUP_LATENCY = "vllm:kv_offload_lookup_latency_seconds"
36 +MY_COUNTER = "my_counter"
37 +MY_LABEL = "my_label"
38
39
40 class _FakeMetric:
@@ -67,6 +69,20 @@ class _FakeVllmConfig:
69 )
70
71
72 +def _spec_cls_with_metric_definitions(
73 + metric_definitions: dict[str, Any],
74 +) -> type:
75 + """Build a fake offloading spec class reporting the given metric
76 + definitions, so tests don't need to patch the real CPU spec."""
77 +
78 + class _FakeOffloadingSpec:
79 + @staticmethod
80 + def build_metric_definitions(extra_config):
81 + return metric_definitions
82 +
83 + return _FakeOffloadingSpec
84 +
85 +
86 def _metric_metadata():
87 return {
88 LOAD_BYTES: OffloadingCounterMetadata(
@@ -96,9 +112,17 @@ def _metric_metadata():
112 LOOKUP_LATENCY: OffloadingHistogramMetadata(
113 documentation="lookup latency",
114 ),
115 + MY_COUNTER: OffloadingCounterMetadata(
116 + documentation="counter with a label",
117 + labelnames=(MY_LABEL,),
118 + ),
119 }
120
121
122 +def _unlabeled(values: dict[str, Any], metric_name: str) -> Any:
123 + return values[metric_name][()]
124 +
125 +
126 def test_build_kv_connector_stats_with_none():
127 """Test that build_kv_connector_stats returns empty stats when given None."""
128 stats = OffloadingConnector.build_kv_connector_stats(data=None)
@@ -131,13 +155,13 @@ def test_build_kv_connector_stats_reconstructs_offload_stats():
155 STORES_SKIPPED: _MetricType.COUNTER,
156 },
157 _StatsKey.DATA: {
134 - LOAD_BYTES: 24,
135 - LOAD_TIME: 1.5,
136 - LOAD_SIZE: [16, 8],
137 - STORE_BYTES: 3,
138 - STORE_TIME: 0.3,
139 - STORE_SIZE: [1, 2],
140 - STORES_SKIPPED: 5,
158 + LOAD_BYTES: {(): 24},
159 + LOAD_TIME: {(): 1.5},
160 + LOAD_SIZE: {(): [16, 8]},
161 + STORE_BYTES: {(): 3},
162 + STORE_TIME: {(): 0.3},
163 + STORE_SIZE: {(): [1, 2]},
164 + STORES_SKIPPED: {(): 5},
165 },
166 }
167
@@ -145,22 +169,28 @@ def test_build_kv_connector_stats_reconstructs_offload_stats():
169
170 assert isinstance(stats, OffloadingConnectorStats)
171 values = stats.data[_StatsKey.DATA]
148 - assert values[LOAD_BYTES] == 24
149 - assert values[LOAD_TIME] == 1.5
150 - assert values[LOAD_SIZE] == [16, 8]
151 - assert values[STORE_BYTES] == 3
152 - assert values[STORE_TIME] == 0.3
153 - assert values[STORE_SIZE] == [1, 2]
154 - assert values[STORES_SKIPPED] == 5
172 + assert _unlabeled(values, LOAD_BYTES) == 24
173 + assert _unlabeled(values, LOAD_TIME) == 1.5
174 + assert _unlabeled(values, LOAD_SIZE) == [16, 8]
175 + assert _unlabeled(values, STORE_BYTES) == 3
176 + assert _unlabeled(values, STORE_TIME) == 0.3
177 + assert _unlabeled(values, STORE_SIZE) == [1, 2]
178 + assert _unlabeled(values, STORES_SKIPPED) == 5
179
180
181 def _make_stats_data(
182 metric_data: dict[str, Any],
183 metric_metadata: dict[str, Any],
184 ) -> dict[str, Any]:
161 - """Build a structured data dict from flat metric data and metadata."""
185 + """Build a structured data dict from flat metric data and metadata.
186 +
187 + Values for unlabeled metrics may be passed flat (wrapped here under the
188 + empty label tuple); values for labeled metrics must already be passed as
189 + a ``{labelvalues: value}`` map.
190 + """
191 metric_types = {}
163 - for key in metric_data:
192 + data = {}
193 + for key, value in metric_data.items():
194 md = metric_metadata[key]
195 if isinstance(md, OffloadingCounterMetadata):
196 metric_types[key] = _MetricType.COUNTER
@@ -168,9 +198,10 @@ def _make_stats_data(
198 metric_types[key] = _MetricType.GAUGE
199 elif isinstance(md, OffloadingHistogramMetadata):
200 metric_types[key] = _MetricType.HISTOGRAM
201 + data[key] = value if md.labelnames else {(): value}
202 return {
203 _StatsKey.TYPES: metric_types,
173 - _StatsKey.DATA: metric_data,
204 + _StatsKey.DATA: data,
205 }
206
207
@@ -215,34 +246,106 @@ def test_aggregate_same_connector():
246
247 assert result is stats1 # Should return self
248 values = result.data[_StatsKey.DATA]
218 - assert values[LOAD_BYTES] == 34
219 - assert values[LOAD_TIME] == 2.6
220 - assert values[LOAD_SIZE] == [16, 8, 3, 7]
221 - assert values[STORE_BYTES] == 19
222 - assert values[STORE_TIME] == 2.3
223 - assert values[STORE_SIZE] == [1, 2, 16]
224 - assert values[STORES_SKIPPED] == 4
225 - assert values[PENDING_STORES] == 1
226 - assert values[LOOKUP_LATENCY] == [0.1, 0.2, 0.3]
249 + assert _unlabeled(values, LOAD_BYTES) == 34
250 + assert _unlabeled(values, LOAD_TIME) == 2.6
251 + assert _unlabeled(values, LOAD_SIZE) == [16, 8, 3, 7]
252 + assert _unlabeled(values, STORE_BYTES) == 19
253 + assert _unlabeled(values, STORE_TIME) == 2.3
254 + assert _unlabeled(values, STORE_SIZE) == [1, 2, 16]
255 + assert _unlabeled(values, STORES_SKIPPED) == 4
256 + assert _unlabeled(values, PENDING_STORES) == 1
257 + assert _unlabeled(values, LOOKUP_LATENCY) == [0.1, 0.2, 0.3]
258 +
259 +
260 +def test_aggregate_labeled_metrics():
261 + metadata = _metric_metadata()
262 + stats1 = OffloadingConnectorStats(
263 + data=_make_stats_data(
264 + {
265 + MY_COUNTER: {
266 + ("a",): 10,
267 + ("b",): 3,
268 + },
269 + },
270 + metadata,
271 + ),
272 + )
273 + stats2 = OffloadingConnectorStats(
274 + data=_make_stats_data(
275 + {
276 + MY_COUNTER: {
277 + ("a",): 7,
278 + ("c",): 5,
279 + },
280 + },
281 + metadata,
282 + ),
283 + )
284 +
285 + stats1.aggregate(stats2)
286 +
287 + values = stats1.data[_StatsKey.DATA][MY_COUNTER]
288 + assert values[("a",)] == 17
289 + assert values[("b",)] == 3
290 + assert values[("c",)] == 5
291 +
292 +
293 +def test_aggregate_labeled_metric_missing_from_self():
294 + """Aggregating a labeled metric that self doesn't have at all yet."""
295 + metadata = _metric_metadata()
296 + stats1 = OffloadingConnectorStats()
297 + stats2 = OffloadingConnectorStats(
298 + data=_make_stats_data(
299 + {
300 + MY_COUNTER: {
301 + ("a",): 7,
302 + ("b",): 5,
303 + },
304 + },
305 + metadata,
306 + ),
307 + )
308 +
309 + stats1.aggregate(stats2)
310 +
311 + values = stats1.data[_StatsKey.DATA][MY_COUNTER]
312 + assert values[("a",)] == 7
313 + assert values[("b",)] == 5
314 + assert stats1.data[_StatsKey.TYPES][MY_COUNTER] == _MetricType.COUNTER
315 +
316 +
317 +def test_helper_methods_accept_labeled_metrics():
318 + stats = OffloadingConnectorStats()
319 +
320 + stats.increase_counter(MY_COUNTER, 3, ("a",))
321 + stats.increase_counter(MY_COUNTER, 4, ("a",))
322 + stats.set_gauge(PENDING_STORES, 2, ("b",))
323 + stats.observe_histogram(LOOKUP_LATENCY, 0.1, ("b",))
324 + stats.observe_histogram(LOOKUP_LATENCY, 0.2, ("b",))
325 +
326 + values = stats.data[_StatsKey.DATA]
327 + assert values[MY_COUNTER][("a",)] == 7
328 + assert values[PENDING_STORES][("b",)] == 2
329 + assert values[LOOKUP_LATENCY][("b",)] == [0.1, 0.2]
330
331
332 def test_aggregate_merges_types():
333 stats1 = OffloadingConnectorStats(
334 data={
335 _StatsKey.TYPES: {LOAD_BYTES: _MetricType.COUNTER},
233 - _StatsKey.DATA: {LOAD_BYTES: 1},
336 + _StatsKey.DATA: {LOAD_BYTES: {(): 1}},
337 },
338 )
339 stats2 = OffloadingConnectorStats(
340 data={
341 _StatsKey.TYPES: {PENDING_STORES: _MetricType.GAUGE},
239 - _StatsKey.DATA: {PENDING_STORES: 2},
342 + _StatsKey.DATA: {PENDING_STORES: {(): 2}},
343 },
344 )
345
346 result = stats1.aggregate(stats2)
347
245 - assert result.data[_StatsKey.DATA][PENDING_STORES] == 2
348 + assert _unlabeled(result.data[_StatsKey.DATA], PENDING_STORES) == 2
349 assert result.data[_StatsKey.TYPES][PENDING_STORES] == _MetricType.GAUGE
350
351
@@ -283,6 +386,26 @@ def test_reduce():
386 assert reduced[f"{LOOKUP_LATENCY}_sum"] == sum([0.1, 0.2, 0.3])
387
388
389 +def test_reduce_labeled_metrics():
390 + metadata = _metric_metadata()
391 + stats = OffloadingConnectorStats(
392 + data=_make_stats_data(
393 + {
394 + MY_COUNTER: {
395 + ("a",): 17,
396 + ("b",): 3,
397 + },
398 + },
399 + metadata,
400 + ),
401 + )
402 +
403 + reduced = stats.reduce()
404 +
405 + assert reduced[f"{MY_COUNTER}:{('a',)}"] == 17
406 + assert reduced[f"{MY_COUNTER}:{('b',)}"] == 3
407 +
408 +
409 def test_reset():
410 """Test that reset() resets all connector stats."""
411 metadata = _metric_metadata()
@@ -326,11 +449,11 @@ def test_prom_metrics_observes_manager_counter():
449 prom_metrics.observe(
450 {
451 _StatsKey.TYPES: {STORES_SKIPPED: _MetricType.COUNTER},
329 - _StatsKey.DATA: {STORES_SKIPPED: 7},
452 + _StatsKey.DATA: {STORES_SKIPPED: {(): 7}},
453 }
454 )
455
333 - counter = prom_metrics.offloading_metrics[(0, STORES_SKIPPED)]
456 + counter = prom_metrics.offloading_metrics[(0, STORES_SKIPPED, ())]
457 assert counter.increments == [7]
458 counter_def = prom_metrics._offloading_metric_defs[STORES_SKIPPED]
459 assert counter_def.kwargs["name"] == "vllm:kv_offload_stores_skipped"
@@ -360,22 +483,22 @@ def test_prom_metrics_observes_flat_transfer_metrics_and_legacy_metrics():
483 STORE_SIZE: _MetricType.HISTOGRAM,
484 },
485 _StatsKey.DATA: {
363 - LOAD_BYTES: 24,
364 - LOAD_TIME: 1.5,
365 - LOAD_SIZE: [16, 8],
366 - STORE_BYTES: 3,
367 - STORE_TIME: 0.3,
368 - STORE_SIZE: [1, 2],
486 + LOAD_BYTES: {(): 24},
487 + LOAD_TIME: {(): 1.5},
488 + LOAD_SIZE: {(): [16, 8]},
489 + STORE_BYTES: {(): 3},
490 + STORE_TIME: {(): 0.3},
491 + STORE_SIZE: {(): [1, 2]},
492 },
493 }
494 )
495
373 - assert prom_metrics.offloading_metrics[(0, LOAD_BYTES)].increments == [24]
374 - assert prom_metrics.offloading_metrics[(0, LOAD_TIME)].increments == [1.5]
375 - assert prom_metrics.offloading_metrics[(0, LOAD_SIZE)].observed == [16, 8]
376 - assert prom_metrics.offloading_metrics[(0, STORE_BYTES)].increments == [3]
377 - assert prom_metrics.offloading_metrics[(0, STORE_TIME)].increments == [0.3]
378 - assert prom_metrics.offloading_metrics[(0, STORE_SIZE)].observed == [1, 2]
496 + assert prom_metrics.offloading_metrics[(0, LOAD_BYTES, ())].increments == [24]
497 + assert prom_metrics.offloading_metrics[(0, LOAD_TIME, ())].increments == [1.5]
498 + assert prom_metrics.offloading_metrics[(0, LOAD_SIZE, ())].observed == [16, 8]
499 + assert prom_metrics.offloading_metrics[(0, STORE_BYTES, ())].increments == [3]
500 + assert prom_metrics.offloading_metrics[(0, STORE_TIME, ())].increments == [0.3]
501 + assert prom_metrics.offloading_metrics[(0, STORE_SIZE, ())].observed == [1, 2]
502
503 assert prom_metrics.counter_kv_bytes[(0, "CPU_to_GPU")].increments == [24]
504 assert prom_metrics.counter_kv_transfer_time[(0, "CPU_to_GPU")].increments == [1.5]
@@ -396,7 +519,9 @@ def test_prom_metrics_observes_manager_gauge_and_histogram():
519 ),
520 }
521 with patch.object(
399 - CPUOffloadingSpec, "build_metric_definitions", return_value=metric_definitions
522 + OffloadingSpecFactory,
523 + "get_spec_cls",
524 + return_value=_spec_cls_with_metric_definitions(metric_definitions),
525 ):
526 prom_metrics = OffloadPromMetrics(
527 vllm_config=_FakeVllmConfig(store_threshold=0), # type: ignore[arg-type]
@@ -416,20 +541,91 @@ def test_prom_metrics_observes_manager_gauge_and_histogram():
541 LOOKUP_LATENCY: _MetricType.HISTOGRAM,
542 },
543 _StatsKey.DATA: {
419 - PENDING_STORES: 5,
420 - LOOKUP_LATENCY: [0.2, 0.4],
544 + PENDING_STORES: {(): 5},
545 + LOOKUP_LATENCY: {(): [0.2, 0.4]},
546 },
547 }
548 )
549
425 - gauge = prom_metrics.offloading_metrics[(0, PENDING_STORES)]
426 - histogram = prom_metrics.offloading_metrics[(0, LOOKUP_LATENCY)]
550 + gauge = prom_metrics.offloading_metrics[(0, PENDING_STORES, ())]
551 + histogram = prom_metrics.offloading_metrics[(0, LOOKUP_LATENCY, ())]
552 assert gauge.set_values == [5]
553 assert histogram.observed == [0.2, 0.4]
554 histogram_def = prom_metrics._offloading_metric_defs[LOOKUP_LATENCY]
555 assert histogram_def.kwargs["buckets"] == (0.1, 1.0)
556
557
558 +def test_prom_metrics_lazily_observes_labeled_metric():
559 + metric_definitions = {
560 + MY_COUNTER: OffloadingCounterMetadata(
561 + documentation="counter with a label",
562 + labelnames=(MY_LABEL,),
563 + ),
564 + }
565 + with patch.object(
566 + OffloadingSpecFactory,
567 + "get_spec_cls",
568 + return_value=_spec_cls_with_metric_definitions(metric_definitions),
569 + ):
570 + prom_metrics = OffloadPromMetrics(
571 + vllm_config=_FakeVllmConfig(store_threshold=0), # type: ignore[arg-type]
572 + metric_types={
573 + Gauge: _FakeMetric,
574 + Counter: _FakeMetric,
575 + Histogram: _FakeMetric,
576 + },
577 + labelnames=["model_name", "engine"],
578 + per_engine_labelvalues={0: ["model", "0"]},
579 + )
580 +
581 + assert (0, MY_COUNTER, ("a",)) not in prom_metrics.offloading_metrics
582 +
583 + prom_metrics.observe(
584 + {
585 + _StatsKey.TYPES: {MY_COUNTER: _MetricType.COUNTER},
586 + _StatsKey.DATA: {MY_COUNTER: {("a",): 7}},
587 + }
588 + )
589 +
590 + counter = prom_metrics.offloading_metrics[(0, MY_COUNTER, ("a",))]
591 + assert counter.increments == [7]
592 + assert counter.labelvalues == ("model", "0", "a")
593 + counter_def = prom_metrics._offloading_metric_defs[MY_COUNTER]
594 + assert counter_def.kwargs["labelnames"] == ["model_name", "engine", MY_LABEL]
595 +
596 +
597 +def test_prom_metrics_rejects_wrong_label_count():
598 + metric_definitions = {
599 + MY_COUNTER: OffloadingCounterMetadata(
600 + documentation="counter with a label",
601 + labelnames=(MY_LABEL,),
602 + ),
603 + }
604 + with patch.object(
605 + OffloadingSpecFactory,
606 + "get_spec_cls",
607 + return_value=_spec_cls_with_metric_definitions(metric_definitions),
608 + ):
609 + prom_metrics = OffloadPromMetrics(
610 + vllm_config=_FakeVllmConfig(store_threshold=0), # type: ignore[arg-type]
611 + metric_types={
612 + Gauge: _FakeMetric,
613 + Counter: _FakeMetric,
614 + Histogram: _FakeMetric,
615 + },
616 + labelnames=["model_name", "engine"],
617 + per_engine_labelvalues={0: ["model", "0"]},
618 + )
619 +
620 + with pytest.raises(AssertionError, match="expects 1 labels"):
621 + prom_metrics.observe(
622 + {
623 + _StatsKey.TYPES: {MY_COUNTER: _MetricType.COUNTER},
624 + _StatsKey.DATA: {MY_COUNTER: {("a", "extra"): 7}},
625 + }
626 + )
627 +
628 +
629 def test_prom_metrics_uses_configured_manager_metrics():
630 prom_metrics = OffloadPromMetrics(
631 vllm_config=_FakeVllmConfig(store_threshold=0), # type: ignore[arg-type]
@@ -458,9 +654,9 @@ def test_aggregate_into_empty_stats():
654 PENDING_STORES: _MetricType.GAUGE,
655 },
656 _StatsKey.DATA: {
461 - LOAD_BYTES: 42,
462 - LOAD_SIZE: [10, 20],
463 - PENDING_STORES: 3,
657 + LOAD_BYTES: {(): 42},
658 + LOAD_SIZE: {(): [10, 20]},
659 + PENDING_STORES: {(): 3},
660 },
661 },
662 )
@@ -469,9 +665,9 @@ def test_aggregate_into_empty_stats():
665
666 assert result is empty
667 values = result.data[_StatsKey.DATA]
472 - assert values[LOAD_BYTES] == 42
473 - assert values[LOAD_SIZE] == [10, 20]
474 - assert values[PENDING_STORES] == 3
668 + assert _unlabeled(values, LOAD_BYTES) == 42
669 + assert _unlabeled(values, LOAD_SIZE) == [10, 20]
670 + assert _unlabeled(values, PENDING_STORES) == 3
671
672
673 def test_prom_metrics_multi_engine_routing():
@@ -490,14 +686,13 @@ def test_prom_metrics_multi_engine_routing():
686 prom_metrics.observe(
687 {
688 _StatsKey.TYPES: {LOAD_BYTES: _MetricType.COUNTER},
493 - _StatsKey.DATA: {LOAD_BYTES: 100},
689 + _StatsKey.DATA: {LOAD_BYTES: {(): 100}},
690 },
691 engine_idx=1,
692 )
693
498 - engine0 = prom_metrics.offloading_metrics[(0, LOAD_BYTES)]
499 - engine1 = prom_metrics.offloading_metrics[(1, LOAD_BYTES)]
500 - assert engine0.increments == []
694 + assert (0, LOAD_BYTES, ()) not in prom_metrics.offloading_metrics
695 + engine1 = prom_metrics.offloading_metrics[(1, LOAD_BYTES, ())]
696 assert engine1.increments == [100]
697
698
@@ -518,6 +713,6 @@ def test_prom_metrics_rejects_undeclared_metric():
713 prom_metrics.observe(
714 {
715 _StatsKey.TYPES: {"unknown:metric": _MetricType.COUNTER},
521 - _StatsKey.DATA: {"unknown:metric": 1},
716 + _StatsKey.DATA: {"unknown:metric": {(): 1}},
717 }
718 )
vllm/distributed/kv_transfer/kv_connector/v1/offloading/metrics.py
+128 -63
@@ -112,7 +112,7 @@ class _StatsKey:
112
113 # Maps metric name -> _MetricType value
114 TYPES = "types"
115 - # Maps metric name -> observed value (number or list)
115 + # Maps metric name -> {label values tuple -> observed value (number or list)}
116 DATA = "data"
117
118
@@ -125,15 +125,17 @@ class OffloadingConnectorStats(KVConnectorStats):
125
126 {
127 _StatsKey.TYPES: {name: _MetricType.*, ...},
128 - _StatsKey.DATA: {name: value, ...},
128 + _StatsKey.DATA: {name: {labelvalues: value, ...}, ...},
129 }
130
131 This structure is self-describing: it survives IPC serialization
132 without needing the full ``OffloadingMetricMetadata`` objects on the
133 receiving side.
134
135 - Counter values are aggregated by summing, gauge values use the latest
136 - snapshot, and histogram values are lists of observed samples.
135 + Counter values are aggregated by summing per-label-tuple, gauge values
136 + use the latest snapshot per-label-tuple, and histogram values are lists of
137 + observed samples per-label-tuple. Unlabeled metrics use ``()`` as their
138 + labelvalues tuple.
139 """
140
141 def __post_init__(self):
@@ -160,26 +162,32 @@ class OffloadingConnectorStats(KVConnectorStats):
162 assert isinstance(other, OffloadingConnectorStats)
163 other_types = other._types
164 other_values = other._values
163 - for key, value in other_values.items():
165 + for key, other_label_values in other_values.items():
166 type_str = other_types.get(key)
167 if type_str is None:
168 raise AssertionError(f"Unknown offloading stats key: {key}")
169 self._types.setdefault(key, type_str)
168 - if type_str == _MetricType.HISTOGRAM:
169 - assert isinstance(value, list)
170 - if key not in self._values:
171 - self._values[key] = value
170 + current_label_values = self._values.setdefault(key, {})
171 + for labelvalues, value in other_label_values.items():
172 + if type_str == _MetricType.HISTOGRAM:
173 + assert isinstance(value, list)
174 + if labelvalues not in current_label_values:
175 + current_label_values[labelvalues] = list(value)
176 + else:
177 + assert isinstance(current_label_values[labelvalues], list)
178 + current_label_values[labelvalues].extend(value)
179 + elif type_str == _MetricType.COUNTER:
180 + assert isinstance(value, int | float)
181 + current_label_values[labelvalues] = (
182 + current_label_values.get(labelvalues, 0) + value
183 + )
184 + elif type_str == _MetricType.GAUGE:
185 + assert isinstance(value, int | float)
186 + current_label_values[labelvalues] = value
187 else:
173 - assert isinstance(self._values[key], list)
174 - self._values[key].extend(value)
175 - elif type_str == _MetricType.COUNTER:
176 - assert isinstance(value, int | float)
177 - self._values[key] = self._values.get(key, 0) + value
178 - elif type_str == _MetricType.GAUGE:
179 - assert isinstance(value, int | float)
180 - self._values[key] = value
181 - else:
182 - raise AssertionError(f"Unknown metric type '{type_str}' for key: {key}")
188 + raise AssertionError(
189 + f"Unknown metric type '{type_str}' for key: {key}"
190 + )
191 return self
192
193 def reduce(self) -> dict[str, int | float]:
@@ -190,44 +198,62 @@ class OffloadingConnectorStats(KVConnectorStats):
198 stats for the last time interval.
199 """
200 return_dict: dict[str, int | float] = {}
193 - for key, value in self._values.items():
201 + for key, label_value_map in self._values.items():
202 type_str = self._types.get(key)
203 if type_str is None:
204 raise AssertionError(f"Unknown offloading stats key: {key}")
197 - if type_str == _MetricType.HISTOGRAM:
198 - assert isinstance(value, list)
199 - return_dict[f"{key}_count"] = len(value)
200 - return_dict[f"{key}_sum"] = sum(value)
201 - elif type_str in (_MetricType.COUNTER, _MetricType.GAUGE):
202 - assert isinstance(value, int | float)
203 - return_dict[key] = value
204 - else:
205 - raise AssertionError(f"Unknown metric type '{type_str}' for key: {key}")
205 + for labelvalues, value in label_value_map.items():
206 + key_with_labels = f"{key}:{labelvalues}" if labelvalues else key
207 + if type_str == _MetricType.HISTOGRAM:
208 + assert isinstance(value, list)
209 + return_dict[f"{key_with_labels}_count"] = len(value)
210 + return_dict[f"{key_with_labels}_sum"] = sum(value)
211 + elif type_str in (_MetricType.COUNTER, _MetricType.GAUGE):
212 + assert isinstance(value, int | float)
213 + return_dict[key_with_labels] = value
214 + else:
215 + raise AssertionError(
216 + f"Unknown metric type '{type_str}' for key: {key}"
217 + )
218 return return_dict
219
220 def is_empty(self) -> bool:
221 return not self.data.get(_StatsKey.DATA)
222
223 def increase_counter(
212 - self, counter_name: str, counter_increase_value: int | float
224 + self,
225 + counter_name: str,
226 + counter_increase_value: int | float,
227 + labelvalues: tuple[str, ...] = (),
228 ) -> None:
229 """Increase a counter on the stats payload."""
230 self._types.setdefault(counter_name, _MetricType.COUNTER)
216 - self._values[counter_name] = (
217 - self._values.get(counter_name, 0) + counter_increase_value
231 + counter_values = self._values.setdefault(counter_name, {})
232 + counter_values[labelvalues] = (
233 + counter_values.get(labelvalues, 0) + counter_increase_value
234 )
235
220 - def set_gauge(self, gauge_name: str, gauge_value: int | float) -> None:
236 + def set_gauge(
237 + self,
238 + gauge_name: str,
239 + gauge_value: int | float,
240 + labelvalues: tuple[str, ...] = (),
241 + ) -> None:
242 """Set a gauge snapshot on the stats payload."""
243 self._types.setdefault(gauge_name, _MetricType.GAUGE)
223 - self._values[gauge_name] = gauge_value
244 + gauge_values = self._values.setdefault(gauge_name, {})
245 + gauge_values[labelvalues] = gauge_value
246
247 def observe_histogram(
226 - self, histogram_name: str, histogram_value: int | float
248 + self,
249 + histogram_name: str,
250 + histogram_value: int | float,
251 + labelvalues: tuple[str, ...] = (),
252 ) -> None:
253 """Record a histogram observation on the stats payload."""
254 self._types.setdefault(histogram_name, _MetricType.HISTOGRAM)
230 - self._values.setdefault(histogram_name, []).append(histogram_value)
255 + histogram_values = self._values.setdefault(histogram_name, {})
256 + histogram_values.setdefault(labelvalues, []).append(histogram_value)
257
258
259 class OffloadPromMetrics(KVConnectorPromMetrics):
@@ -255,7 +281,10 @@ class OffloadPromMetrics(KVConnectorPromMetrics):
281
282 self._observe_deprecated_metrics = issubclass(spec_cls, CPUOffloadingSpec)
283 self._offloading_metric_defs: dict[str, PromMetricT] = {}
258 - self.offloading_metrics: dict[tuple[int, str], PromMetricT] = {}
284 + # (engine_idx, metric_name, labelvalues) -> metric with bound labels
285 + self.offloading_metrics: dict[
286 + tuple[int, str, tuple[str, ...]], PromMetricT
287 + ] = {}
288
289 self._counter_kv_bytes = self._counter_cls(
290 name=_DEPRECATED_TOTAL_BYTES,
@@ -301,10 +330,6 @@ class OffloadPromMetrics(KVConnectorPromMetrics):
330 self._offloading_metric_defs[metric_name] = self._create_metric(
331 metric_name, metadata
332 )
304 - for engine_idx, labelvalues in per_engine_labelvalues.items():
305 - self.offloading_metrics[(engine_idx, metric_name)] = (
306 - self._offloading_metric_defs[metric_name].labels(*labelvalues)
307 - )
333
334 def _create_metric(
335 self, metric_name: str, metadata: OffloadingMetricMetadata
@@ -312,7 +337,7 @@ class OffloadPromMetrics(KVConnectorPromMetrics):
337 kwargs: dict[str, Any] = {
338 "name": metric_name,
339 "documentation": metadata.documentation,
315 - "labelnames": self._labelnames,
340 + "labelnames": self._labelnames + list(metadata.labelnames),
341 }
342 if isinstance(metadata, OffloadingCounterMetadata):
343 metric_cls = self._counter_cls
@@ -326,11 +351,37 @@ class OffloadPromMetrics(KVConnectorPromMetrics):
351 raise AssertionError(f"Unknown offloading metric metadata: {metadata}")
352 return metric_cls(**kwargs)
353
354 + def _get_prometheus_metric(
355 + self,
356 + metric_name: str,
357 + labelvalues: tuple[str, ...],
358 + engine_idx: int,
359 + ) -> PromMetric:
360 + metadata = self._offloading_metric_metadata[metric_name]
361 + if len(labelvalues) != len(metadata.labelnames):
362 + raise AssertionError(
363 + f"Metric {metric_name} expects {len(metadata.labelnames)} labels, "
364 + f"got {len(labelvalues)}"
365 + )
366 + key = (engine_idx, metric_name, labelvalues)
367 + prom_metric = self.offloading_metrics.get(key)
368 + if prom_metric is None:
369 + engine_labelvalues = self.per_engine_labelvalues[engine_idx]
370 + prom_metric = self._offloading_metric_defs[metric_name].labels(
371 + *(engine_labelvalues + list(labelvalues))
372 + )
373 + self.offloading_metrics[key] = prom_metric
374 + return prom_metric
375 +
376 def _increase_counter(
330 - self, metric_name: str, value: int | float, engine_idx: int
377 + self,
378 + metric_name: str,
379 + value: int | float,
380 + labelvalues: tuple[str, ...],
381 + engine_idx: int,
382 ) -> None:
332 - self.offloading_metrics[(engine_idx, metric_name)].inc(value)
333 - if not self._observe_deprecated_metrics:
383 + self._get_prometheus_metric(metric_name, labelvalues, engine_idx).inc(value)
384 + if labelvalues or not self._observe_deprecated_metrics:
385 return
386 # Keep deprecated CPU offload transfer metrics updated during the
387 # transition to flat metric names.
@@ -343,15 +394,26 @@ class OffloadPromMetrics(KVConnectorPromMetrics):
394 elif metric_name == _TransferMetricName.STORE_TIME:
395 self.counter_kv_transfer_time[(engine_idx, _TransferType.STORE)].inc(value)
396
346 - def _set_gauge(self, metric_name: str, value: int | float, engine_idx: int) -> None:
347 - self.offloading_metrics[(engine_idx, metric_name)].set(value)
397 + def _set_gauge(
398 + self,
399 + metric_name: str,
400 + value: int | float,
401 + labelvalues: tuple[str, ...],
402 + engine_idx: int,
403 + ) -> None:
404 + self._get_prometheus_metric(metric_name, labelvalues, engine_idx).set(value)
405
406 def _observe_histogram(
350 - self, metric_name: str, value: list[int | float], engine_idx: int
407 + self,
408 + metric_name: str,
409 + value: list[int | float],
410 + labelvalues: tuple[str, ...],
411 + engine_idx: int,
412 ) -> None:
413 + prom_metric = self._get_prometheus_metric(metric_name, labelvalues, engine_idx)
414 for observation in value:
353 - self.offloading_metrics[(engine_idx, metric_name)].observe(observation)
354 - if not self._observe_deprecated_metrics:
415 + prom_metric.observe(observation)
416 + if labelvalues or not self._observe_deprecated_metrics:
417 continue
418 # Keep deprecated CPU offload transfer metrics updated during the
419 # transition to flat metric names.
@@ -368,20 +430,23 @@ class OffloadPromMetrics(KVConnectorPromMetrics):
430 """Observe transfer statistics."""
431 metric_types = transfer_stats_data.get(_StatsKey.TYPES, {})
432 metric_data = transfer_stats_data.get(_StatsKey.DATA, {})
371 - for key, value in metric_data.items():
433 + for key, label_value_map in metric_data.items():
434 type_str = metric_types.get(key)
435 if type_str is None:
436 raise AssertionError(f"Unknown offloading stats key: {key}")
437 assert key in self._offloading_metric_defs
376 - if type_str == _MetricType.COUNTER:
377 - assert isinstance(value, int | float)
378 - self._increase_counter(key, value, engine_idx)
379 - elif type_str == _MetricType.GAUGE:
380 - assert isinstance(value, int | float)
381 - self._set_gauge(key, value, engine_idx)
382 - elif type_str == _MetricType.HISTOGRAM:
383 - assert isinstance(value, list)
384 - assert all(isinstance(v, int | float) for v in value)
385 - self._observe_histogram(key, value, engine_idx)
386 - else:
387 - raise AssertionError(f"Unknown metric type '{type_str}' for key: {key}")
438 + for labelvalues, value in label_value_map.items():
439 + if type_str == _MetricType.COUNTER:
440 + assert isinstance(value, int | float)
441 + self._increase_counter(key, value, labelvalues, engine_idx)
442 + elif type_str == _MetricType.GAUGE:
443 + assert isinstance(value, int | float)
444 + self._set_gauge(key, value, labelvalues, engine_idx)
445 + elif type_str == _MetricType.HISTOGRAM:
446 + assert isinstance(value, list)
447 + assert all(isinstance(v, int | float) for v in value)
448 + self._observe_histogram(key, value, labelvalues, engine_idx)
449 + else:
450 + raise AssertionError(
451 + f"Unknown metric type '{type_str}' for key: {key}"
452 + )
vllm/v1/kv_offload/base.py
+1
@@ -129,6 +129,7 @@ The class provides the following primitives:
129 @dataclass(frozen=True)
130 class OffloadingMetricMetadata:
131 documentation: str
132 + labelnames: tuple[str, ...] = ()
133
134
135 @dataclass(frozen=True)