[Metrics][KV Offload] Add Prometheus metrics for SimpleCPUOffloadConnector (#57251)

Signed-off-by: Vincent <vincexxchan@gmail.com>

mevince committed Sep 26, 2026 at 17:17 UTC cd1daf58a98aad8c7241377edbe91a19aa38f71a
6 files changed +903 -6
docs/mkdocs/gen_files/generate_metrics.py
+4
@@ -23,6 +23,10 @@ METRIC_SOURCE_FILES = [
23 "path": "vllm/distributed/kv_transfer/kv_connector/v1/nixl/stats.py",
24 "key": "metrics-nixl",
25 },
26 + {
27 + "path": "vllm/v1/simple_kv_offload/metrics.py",
28 + "key": "metrics-simple-kv-offload",
29 + },
30 {"path": "vllm/v1/metrics/perf.py", "key": "metrics-mfu"},
31 ]
32
docs/usage/metrics.md
+24
@@ -45,6 +45,30 @@ The following metrics are exposed:
45
46 --8<-- "gen:metrics-nixl"
47
48 +## Simple CPU Offload Connector Metrics
49 +
50 +These metrics are exposed when the `SimpleCPUOffloadConnector` KV connector
51 +is configured (e.g. `--kv-transfer-config='{"kv_connector":
52 +"SimpleCPUOffloadConnector", "kv_role": "kv_both", "kv_connector_extra_config":
53 +{"kv_offload_backend": "disk", "disk_path": "/mnt/nvme/kv"}}'`). They are
54 +updated once per engine step.
55 +
56 +Caveats to keep in mind when interpreting them:
57 +
58 +- A "completed" store means the write syscalls returned; it is **not**
59 + fsync-durable, and the disk backend's file is process-lifetime scratch,
60 + unlinked at startup and shutdown.
61 +- `save_outcomes_total` classifies eager-mode boundary hand-off stores;
62 + lazy-mode stores are not classified.
63 +- `used_blocks` counts blocks pinned by in-flight transfers or cache hits;
64 + warm cached blocks that are evictable are not counted. Use the
65 + `capacity_blocks` label of `simple_kv_offload_info` as the denominator.
66 +- Counters and gauges are quantized to engine steps. They are reported by the
67 + scheduler process and reflect engine-wide logical block counts, not values
68 + pooled from individual tensor-parallel workers.
69 +
70 +--8<-- "gen:metrics-simple-kv-offload"
71 +
72 ## Model Flops Utilization (MFU) Performance Metrics
73
74 These metrics are available via `--enable-mfu-metrics`:
tests/v1/kv_connector/unit/test_simple_cpu_offload_metrics.py new
+466
@@ -0,0 +1,466 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for SimpleCPUOffloadConnector metrics."""
4 +
5 +from __future__ import annotations
6 +
7 +import dataclasses
8 +from types import SimpleNamespace
9 +from typing import Any
10 +
11 +import pytest
12 +from prometheus_client import Counter, Gauge, Histogram
13 +
14 +from tests.v1.kv_connector.unit.test_simple_cpu_offload_connector import (
15 + _make_connector,
16 +)
17 +from tests.v1.simple_kv_offload.test_scheduler import (
18 + make_request,
19 + make_scheduler,
20 + make_scheduler_output,
21 +)
22 +from vllm.distributed.kv_transfer.kv_connector.v1.simple_cpu_offload_connector import (
23 + SimpleCPUOffloadConnector,
24 +)
25 +from vllm.v1.outputs import KVConnectorOutput
26 +from vllm.v1.simple_kv_offload.manager import (
27 + BoundaryStoreStats,
28 + LoadRequestState,
29 + TransferMeta,
30 +)
31 +from vllm.v1.simple_kv_offload.metrics import (
32 + INFO_LABELS,
33 + OUTCOME_TO_FIELD,
34 + MetricName,
35 + SimpleCPUOffloadStats,
36 + _MetricType,
37 + _StatsKey,
38 +)
39 +
40 +SAVE_OUTCOMES = MetricName.SAVE_OUTCOMES
41 +LOAD_BLOCKS = MetricName.LOAD_BLOCKS
42 +USED_BLOCKS = MetricName.USED_BLOCKS
43 +PENDING_STORE_BLOCKS = MetricName.PENDING_STORE_BLOCKS
44 +INFO = MetricName.INFO
45 +
46 +
47 +class _FakeMetric:
48 + def __init__(self, **kwargs: Any):
49 + self.kwargs = kwargs
50 + self.children: list[_FakeMetric] = []
51 + self.observed: list[int | float] = []
52 + self.increments: list[int | float] = []
53 + self.set_values: list[int | float] = []
54 + self.labelvalues: tuple[object, ...] = ()
55 +
56 + def labels(self, *labelvalues: object) -> _FakeMetric:
57 + child = _FakeMetric(**self.kwargs)
58 + child.labelvalues = labelvalues
59 + self.children.append(child)
60 + return child
61 +
62 + def observe(self, value: int | float) -> None:
63 + self.observed.append(value)
64 +
65 + def inc(self, value: int | float) -> None:
66 + self.increments.append(value)
67 +
68 + def set(self, value: int | float) -> None:
69 + self.set_values.append(value)
70 +
71 +
72 +def _fake_metric_types() -> dict[type, type]:
73 + return {Gauge: _FakeMetric, Counter: _FakeMetric, Histogram: _FakeMetric}
74 +
75 +
76 +def _make_prom_metrics(per_engine: dict[int, list[object]] | None = None) -> Any:
77 + from vllm.v1.simple_kv_offload.metrics import SimpleCPUOffloadPromMetrics
78 +
79 + return SimpleCPUOffloadPromMetrics(
80 + vllm_config=SimpleNamespace(kv_transfer_config=None),
81 + metric_types=_fake_metric_types(),
82 + labelnames=["model_name", "engine"],
83 + per_engine_labelvalues=per_engine or {0: ["model", "0"]},
84 + )
85 +
86 +
87 +def _stats_payload(types: dict, data: dict) -> dict[str, Any]:
88 + return {_StatsKey.TYPES: types, _StatsKey.DATA: data}
89 +
90 +
91 +def _values(stats: SimpleCPUOffloadStats, name: str) -> dict:
92 + return stats.data[_StatsKey.DATA][name]
93 +
94 +
95 +# ---------------------------------------------------------------------------
96 +# Stats class
97 +# ---------------------------------------------------------------------------
98 +
99 +
100 +def test_build_kv_connector_stats_none_and_empty() -> None:
101 + stats = SimpleCPUOffloadConnector.build_kv_connector_stats(data=None)
102 + assert isinstance(stats, SimpleCPUOffloadStats)
103 + assert stats.is_empty()
104 +
105 + stats = SimpleCPUOffloadConnector.build_kv_connector_stats(data={})
106 + assert isinstance(stats, SimpleCPUOffloadStats)
107 + assert stats.is_empty()
108 +
109 +
110 +def test_stats_round_trip_through_serialized_dict() -> None:
111 + stats = SimpleCPUOffloadStats()
112 + stats.increase_counter(SAVE_OUTCOMES, 3, ("stored",))
113 + stats.increase_counter(LOAD_BLOCKS, 4)
114 + stats.set_gauge(USED_BLOCKS, 7)
115 + stats.set_gauge(INFO, 1, ("cpu", "false", "false", "64"))
116 +
117 + rebuilt = SimpleCPUOffloadConnector.build_kv_connector_stats(data=stats.to_dict())
118 + assert isinstance(rebuilt, SimpleCPUOffloadStats)
119 + assert _values(rebuilt, SAVE_OUTCOMES) == {("stored",): 3}
120 + assert _values(rebuilt, LOAD_BLOCKS) == {(): 4}
121 + assert _values(rebuilt, USED_BLOCKS) == {(): 7}
122 + assert _values(rebuilt, INFO) == {("cpu", "false", "false", "64"): 1}
123 +
124 +
125 +def test_stats_aggregate_sums_counters_and_keeps_latest_gauge() -> None:
126 + stats1 = SimpleCPUOffloadStats()
127 + stats1.increase_counter(SAVE_OUTCOMES, 3, ("stored",))
128 + stats1.increase_counter(SAVE_OUTCOMES, 1, ("dropped_cpu_full",))
129 + stats1.set_gauge(USED_BLOCKS, 7)
130 +
131 + stats2 = SimpleCPUOffloadStats()
132 + stats2.increase_counter(SAVE_OUTCOMES, 4, ("stored",))
133 + stats2.set_gauge(USED_BLOCKS, 2)
134 +
135 + result = stats1.aggregate(stats2)
136 + assert result is stats1
137 + outcomes = _values(stats1, SAVE_OUTCOMES)
138 + assert outcomes == {("stored",): 7, ("dropped_cpu_full",): 1}
139 + assert _values(stats1, USED_BLOCKS) == {(): 2}
140 +
141 +
142 +def test_stats_aggregate_merges_types_from_other() -> None:
143 + stats1 = SimpleCPUOffloadStats()
144 + stats1.set_gauge(USED_BLOCKS, 1)
145 +
146 + stats2 = SimpleCPUOffloadStats()
147 + stats2.increase_counter(LOAD_BLOCKS, 2)
148 +
149 + stats1.aggregate(stats2)
150 + assert stats1.data[_StatsKey.TYPES][LOAD_BLOCKS] == _MetricType.COUNTER
151 + assert _values(stats1, LOAD_BLOCKS) == {(): 2}
152 +
153 +
154 +def test_stats_reduce_and_reset() -> None:
155 + stats = SimpleCPUOffloadStats()
156 + stats.increase_counter(SAVE_OUTCOMES, 3, ("stored",))
157 + stats.set_gauge(PENDING_STORE_BLOCKS, 4)
158 + stats.increase_counter(LOAD_BLOCKS, 9)
159 + stats.set_gauge(INFO, 1, ("cpu", "false", "false", "64"))
160 +
161 + reduced = stats.reduce()
162 + assert reduced[f"{SAVE_OUTCOMES}:{('stored',)}"] == 3
163 + assert reduced[PENDING_STORE_BLOCKS] == 4
164 + assert reduced[LOAD_BLOCKS] == 9
165 + assert not any(key.startswith(INFO) for key in reduced)
166 +
167 + stats.reset()
168 + assert stats.is_empty()
169 +
170 +
171 +def test_outcome_labels_match_boundary_store_stats_fields() -> None:
172 + """Every outcome label maps 1:1 to a BoundaryStoreStats counter field."""
173 + boundary_fields = {
174 + f.name for f in dataclasses.fields(BoundaryStoreStats) if f.name != "published"
175 + }
176 + assert set(OUTCOME_TO_FIELD.values()) == boundary_fields
177 + assert len(set(OUTCOME_TO_FIELD.values())) == len(OUTCOME_TO_FIELD)
178 +
179 +
180 +# ---------------------------------------------------------------------------
181 +# Prom metrics class
182 +# ---------------------------------------------------------------------------
183 +
184 +
185 +def test_prom_metrics_registers_tier1_metrics() -> None:
186 + prom = _make_prom_metrics()
187 + defs = prom._defs
188 + assert set(defs) == {
189 + SAVE_OUTCOMES,
190 + LOAD_BLOCKS,
191 + USED_BLOCKS,
192 + PENDING_STORE_BLOCKS,
193 + INFO,
194 + }
195 + # The prom class registers literal names (required by the docs
196 + # generator); they must stay in sync with the emission-side constants.
197 + assert {m.kwargs["name"] for m in defs.values()} == {
198 + SAVE_OUTCOMES,
199 + LOAD_BLOCKS,
200 + USED_BLOCKS,
201 + PENDING_STORE_BLOCKS,
202 + INFO,
203 + }
204 + for metric in defs.values():
205 + assert metric.kwargs["documentation"]
206 + assert metric.kwargs["labelnames"][:2] == ["model_name", "engine"]
207 +
208 + assert defs[SAVE_OUTCOMES].kwargs["labelnames"] == [
209 + "model_name",
210 + "engine",
211 + "outcome",
212 + ]
213 + assert defs[LOAD_BLOCKS].kwargs["labelnames"] == ["model_name", "engine"]
214 + assert defs[INFO].kwargs["labelnames"] == [
215 + "model_name",
216 + "engine",
217 + *INFO_LABELS,
218 + ]
219 + assert defs[USED_BLOCKS].kwargs["labelnames"] == ["model_name", "engine"]
220 +
221 + # mostrecent: multiprocess scrapes expose one series per label set, not
222 + # one pid-tagged series per writer process.
223 + for gauge in (USED_BLOCKS, PENDING_STORE_BLOCKS, INFO):
224 + assert defs[gauge].kwargs["multiprocess_mode"] == "mostrecent"
225 +
226 +
227 +def test_prom_metrics_observe_routes_each_series() -> None:
228 + prom = _make_prom_metrics()
229 + prom.observe(
230 + _stats_payload(
231 + types={
232 + SAVE_OUTCOMES: _MetricType.COUNTER,
233 + LOAD_BLOCKS: _MetricType.COUNTER,
234 + USED_BLOCKS: _MetricType.GAUGE,
235 + PENDING_STORE_BLOCKS: _MetricType.GAUGE,
236 + INFO: _MetricType.GAUGE,
237 + },
238 + data={
239 + SAVE_OUTCOMES: {("stored",): 3},
240 + LOAD_BLOCKS: {(): 4},
241 + USED_BLOCKS: {(): 5},
242 + PENDING_STORE_BLOCKS: {(): 1},
243 + INFO: {("cpu", "false", "false", "64"): 1},
244 + },
245 + )
246 + )
247 +
248 + assert prom._metrics[(0, SAVE_OUTCOMES, ("stored",))].increments == [3]
249 + assert prom._metrics[(0, LOAD_BLOCKS, ())].increments == [4]
250 + assert prom._metrics[(0, USED_BLOCKS, ())].set_values == [5]
251 + assert prom._metrics[(0, PENDING_STORE_BLOCKS, ())].set_values == [1]
252 + assert prom._metrics[(0, INFO, ("cpu", "false", "false", "64"))].set_values == [1]
253 + assert prom._metrics[(0, SAVE_OUTCOMES, ("stored",))].labelvalues == (
254 + "model",
255 + "0",
256 + "stored",
257 + )
258 + assert prom._metrics[(0, INFO, ("cpu", "false", "false", "64"))].labelvalues == (
259 + "model",
260 + "0",
261 + "cpu",
262 + "false",
263 + "false",
264 + "64",
265 + )
266 +
267 +
268 +def test_prom_metrics_reuses_bound_children() -> None:
269 + prom = _make_prom_metrics()
270 + payload = _stats_payload(
271 + types={SAVE_OUTCOMES: _MetricType.COUNTER},
272 + data={SAVE_OUTCOMES: {("stored",): 1}},
273 + )
274 + prom.observe(payload)
275 + prom.observe(payload)
276 +
277 + child = prom._metrics[(0, SAVE_OUTCOMES, ("stored",))]
278 + assert child.increments == [1, 1]
279 + assert len(prom._defs[SAVE_OUTCOMES].children) == 1
280 +
281 +
282 +def test_prom_metrics_rejects_unknown_metric() -> None:
283 + prom = _make_prom_metrics()
284 + with pytest.raises(AssertionError, match="Unknown"):
285 + prom.observe(
286 + _stats_payload(
287 + types={"vllm:not_a_metric": _MetricType.COUNTER},
288 + data={"vllm:not_a_metric": {(): 1}},
289 + )
290 + )
291 +
292 +
293 +def test_prom_metrics_rejects_wrong_label_count() -> None:
294 + prom = _make_prom_metrics()
295 + with pytest.raises(AssertionError, match="labels"):
296 + prom.observe(
297 + _stats_payload(
298 + types={SAVE_OUTCOMES: _MetricType.COUNTER},
299 + data={SAVE_OUTCOMES: {("stored", "extra"): 1}},
300 + )
301 + )
302 +
303 +
304 +def test_prom_metrics_routes_per_engine() -> None:
305 + prom = _make_prom_metrics(per_engine={0: ["model", "0"], 1: ["model", "1"]})
306 + prom.observe(
307 + _stats_payload(
308 + types={USED_BLOCKS: _MetricType.GAUGE}, data={USED_BLOCKS: {(): 3}}
309 + ),
310 + engine_idx=1,
311 + )
312 + assert (0, USED_BLOCKS, ()) not in prom._metrics
313 + assert prom._metrics[(1, USED_BLOCKS, ())].set_values == [3]
314 +
315 +
316 +def test_connector_build_prom_metrics() -> None:
317 + from vllm.v1.simple_kv_offload.metrics import SimpleCPUOffloadPromMetrics
318 +
319 + prom = SimpleCPUOffloadConnector.build_prom_metrics(
320 + vllm_config=SimpleNamespace(kv_transfer_config=None),
321 + metric_types=_fake_metric_types(),
322 + labelnames=["model_name", "engine"],
323 + per_engine_labelvalues={0: ["model", "0"]},
324 + )
325 + assert isinstance(prom, SimpleCPUOffloadPromMetrics)
326 +
327 +
328 +# ---------------------------------------------------------------------------
329 +# Manager: interval stats
330 +# ---------------------------------------------------------------------------
331 +
332 +
333 +def test_manager_get_stats_reports_outcome_deltas_per_interval() -> None:
334 + fixture = make_scheduler()
335 + sched = fixture.scheduler
336 +
337 + sched.boundary_store_stats.stored += 3
338 + sched.boundary_store_stats.dropped_cpu_full += 2
339 + sched.boundary_store_stats.skipped_in_flight += 1
340 +
341 + stats = sched.get_stats()
342 + assert _values(stats, SAVE_OUTCOMES) == {
343 + ("stored",): 3,
344 + ("dropped_cpu_full",): 2,
345 + ("skipped_in_flight",): 1,
346 + }
347 +
348 + # Deltas are per-interval: a second drain reports no new outcomes.
349 + stats2 = sched.get_stats()
350 + assert SAVE_OUTCOMES not in stats2.data[_StatsKey.DATA]
351 +
352 +
353 +def test_manager_reset_preserves_undrained_outcome_deltas() -> None:
354 + fixture = make_scheduler()
355 + sched = fixture.scheduler
356 +
357 + sched.boundary_store_stats.stored += 3
358 + sched.reset()
359 +
360 + stats = sched.get_stats()
361 + assert _values(stats, SAVE_OUTCOMES) == {("stored",): 3}
362 +
363 +
364 +def test_manager_get_stats_gauges_and_info_labels() -> None:
365 + fixture = make_scheduler(num_cpu_blocks=8)
366 + sched = fixture.scheduler
367 +
368 + sched._store_event_to_blocks[0] = TransferMeta([10], [11])
369 + sched._pending_finished_stores.append(TransferMeta([12], [13]))
370 + sched._abandoned_store_event_to_blocks[1] = TransferMeta([14], [15])
371 +
372 + stats = sched.get_stats()
373 + assert _values(stats, PENDING_STORE_BLOCKS) == {(): 3}
374 + used = _values(stats, USED_BLOCKS)[()]
375 + assert used == sched.num_cpu_blocks - sched.cpu_block_pool.get_num_free_blocks()
376 +
377 + info = _values(stats, INFO)
378 + assert info == {("cpu", "false", "false", str(sched.num_cpu_blocks)): 1}
379 +
380 + # Gauges are re-reported every drain.
381 + stats2 = sched.get_stats()
382 + assert _values(stats2, PENDING_STORE_BLOCKS) == {(): 3}
383 +
384 +
385 +def test_manager_info_labels_reflect_disk_mode() -> None:
386 + connector = _make_connector(
387 + extra_config={
388 + "kv_offload_backend": "disk",
389 + "disk_path": "/tmp/fake-disk-metrics",
390 + "disk_capacity_bytes": 1024**3,
391 + "use_page_cache": True,
392 + }
393 + )
394 + sched = connector.scheduler_manager
395 + assert sched is not None
396 + assert sched._info_labelvalues[0] == "disk"
397 + assert sched._info_labelvalues[1] == "true"
398 + stats = sched.get_stats()
399 + assert set(_values(stats, INFO)) == {sched._info_labelvalues}
400 +
401 +
402 +def test_manager_info_labels_page_cache_false_for_cpu_backend() -> None:
403 + """CPU backend ignores use_page_cache; the info gauge must not claim it."""
404 + connector = _make_connector(extra_config={"use_page_cache": True})
405 + sched = connector.scheduler_manager
406 + assert sched is not None
407 + assert sched._info_labelvalues[:2] == ("cpu", "false")
408 + stats = sched.get_stats()
409 + assert set(_values(stats, INFO)) == {sched._info_labelvalues}
410 +
411 +
412 +def test_manager_counts_load_blocks_completed() -> None:
413 + fixture = make_scheduler(num_cpu_blocks=8)
414 + sched = fixture.scheduler
415 +
416 + request = make_request(num_blocks=2)
417 + gpu_blocks = fixture.gpu_block_pool.get_new_blocks(2)
418 + cpu_blocks = sched.cpu_block_pool.get_new_blocks(2)
419 + sched._reqs_to_load[request.request_id] = LoadRequestState(
420 + request=request,
421 + transfer_meta=TransferMeta(
422 + [b.block_id for b in gpu_blocks], [b.block_id for b in cpu_blocks]
423 + ),
424 + )
425 +
426 + sched.build_connector_meta(make_scheduler_output({}))
427 + stats = sched.get_stats()
428 + assert LOAD_BLOCKS not in stats.data[_StatsKey.DATA]
429 +
430 + sched.update_connector_output(
431 + KVConnectorOutput(finished_recving={request.request_id})
432 + )
433 + stats = sched.get_stats()
434 + assert _values(stats, LOAD_BLOCKS) == {(): 2}
435 +
436 +
437 +def test_manager_abandoned_load_counts_as_completed_after_reset() -> None:
438 + """Loads abandoned by reset() that still finish on the worker are
439 + counted as loaded blocks.
440 + """
441 + fixture = make_scheduler(num_cpu_blocks=8)
442 + sched = fixture.scheduler
443 +
444 + request = make_request(num_blocks=2)
445 + gpu_blocks = fixture.gpu_block_pool.get_new_blocks(2)
446 + cpu_blocks = sched.cpu_block_pool.get_new_blocks(2)
447 + sched._reqs_to_load[request.request_id] = LoadRequestState(
448 + request=request,
449 + transfer_meta=TransferMeta(
450 + [b.block_id for b in gpu_blocks], [b.block_id for b in cpu_blocks]
451 + ),
452 + )
453 +
454 + sched.build_connector_meta(make_scheduler_output({}))
455 + stats = sched.get_stats()
456 + assert LOAD_BLOCKS not in stats.data[_StatsKey.DATA]
457 +
458 + assert sched.reset() is False
459 + assert set(sched._abandoned_reqs_to_load) == {request.request_id}
460 +
461 + sched.update_connector_output(
462 + KVConnectorOutput(finished_recving={request.request_id})
463 + )
464 + assert sched._abandoned_reqs_to_load == {}
465 + stats = sched.get_stats()
466 + assert _values(stats, LOAD_BLOCKS) == {(): 2}
vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py
+43
@@ -15,6 +15,12 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import (
15 KVConnectorRole,
16 SupportsHMA,
17 )
18 +from vllm.distributed.kv_transfer.kv_connector.v1.metrics import (
19 + KVConnectorPromMetrics,
20 + KVConnectorStats,
21 + PromMetric,
22 + PromMetricT,
23 +)
24 from vllm.logger import init_logger
25 from vllm.v1.core.sched.output import SchedulerOutput
26 from vllm.v1.outputs import KVConnectorOutput
@@ -25,6 +31,10 @@ from vllm.v1.simple_kv_offload.manager import (
31 from vllm.v1.simple_kv_offload.metadata import (
32 SimpleCPUOffloadMetadata,
33 )
34 +from vllm.v1.simple_kv_offload.metrics import (
35 + SimpleCPUOffloadPromMetrics,
36 + SimpleCPUOffloadStats,
37 +)
38 from vllm.v1.simple_kv_offload.worker import (
39 SimpleCPUOffloadWorker,
40 )
@@ -162,6 +172,7 @@ class SimpleCPUOffloadConnector(KVConnectorBase_V1, SupportsHMA):
172 hash_block_size=hash_block_size,
173 lazy_offload=lazy_offload,
174 disk_capacity_bytes=disk_capacity_bytes if disk_mode else 0,
175 + use_page_cache=use_page_cache if disk_mode else False,
176 )
177 elif role == KVConnectorRole.WORKER:
178 self.worker_handler = SimpleCPUOffloadWorker(
@@ -307,6 +318,38 @@ class SimpleCPUOffloadConnector(KVConnectorBase_V1, SupportsHMA):
318 return self.scheduler_manager.get_boundary_store_stats()
319 return None
320
321 + def get_kv_connector_stats(self) -> KVConnectorStats | None:
322 + # Worker-side callers may invoke this hook on connectors constructed
323 + # via __new__ (as in test_worker.py), which lack scheduler_manager.
324 + scheduler_manager: SimpleCPUOffloadScheduler | None = getattr(
325 + self, "scheduler_manager", None
326 + )
327 + if scheduler_manager is not None:
328 + return scheduler_manager.get_stats()
329 + return None
330 +
331 + @classmethod
332 + def build_kv_connector_stats(
333 + cls, data: dict[str, Any] | None = None
334 + ) -> KVConnectorStats | None:
335 + return (
336 + SimpleCPUOffloadStats(data=data)
337 + if data is not None
338 + else SimpleCPUOffloadStats()
339 + )
340 +
341 + @classmethod
342 + def build_prom_metrics(
343 + cls,
344 + vllm_config: VllmConfig,
345 + metric_types: dict[type[PromMetric], type[PromMetricT]],
346 + labelnames: list[str],
347 + per_engine_labelvalues: dict[int, list[object]],
348 + ) -> KVConnectorPromMetrics:
349 + return SimpleCPUOffloadPromMetrics(
350 + vllm_config, metric_types, labelnames, per_engine_labelvalues
351 + )
352 +
353 def take_events(self) -> Iterable[KVCacheEvent]:
354 if self.scheduler_manager is not None:
355 return self.scheduler_manager.take_events()
vllm/v1/simple_kv_offload/manager.py
+82 -6
@@ -4,7 +4,7 @@
4
5 import contextlib
6 from collections.abc import Iterable
7 -from dataclasses import dataclass, field, replace
7 +from dataclasses import dataclass, field, fields, replace
8 from enum import Enum, auto
9 from typing import TYPE_CHECKING, Any
10
@@ -48,6 +48,11 @@ from vllm.v1.simple_kv_offload.metadata import (
48 SimpleCPUOffloadMetadata,
49 SimpleCPUOffloadWorkerMetadata,
50 )
51 +from vllm.v1.simple_kv_offload.metrics import (
52 + OUTCOME_TO_FIELD,
53 + MetricName,
54 + SimpleCPUOffloadStats,
55 +)
56
57 if TYPE_CHECKING:
58 from vllm.v1.core.kv_cache_manager import KVCacheBlocks
@@ -118,7 +123,8 @@ class BoundaryStoreStats:
123 symptom is a lower cache hit rate with nothing in the logs, so each decline
124 reason is counted and exposed through
125 ``SimpleCPUOffloadConnector.get_boundary_store_stats()``. Reset by
121 - ``reset()``; not otherwise cleared.
126 + ``reset()``, which carries any not-yet-drained deltas; not otherwise
127 + cleared.
128 """
129
130 published: int = 0
@@ -143,6 +149,7 @@ class SimpleCPUOffloadScheduler:
149 hash_block_size: int,
150 lazy_offload: bool = False,
151 disk_capacity_bytes: int = 0,
152 + use_page_cache: bool = False,
153 ):
154 self.vllm_config = vllm_config
155 self.kv_cache_config = kv_cache_config
@@ -169,6 +176,13 @@ class SimpleCPUOffloadScheduler:
176 self.cpu_kv_cache_config.prefix_cacheable_group_ids
177 )
178 self.kv_event_medium = MEDIUM_STORAGE if disk_capacity_bytes > 0 else MEDIUM_CPU
179 + # Must track kv_event_medium.
180 + self._info_labelvalues: tuple[str, ...] = (
181 + "disk" if disk_capacity_bytes > 0 else "cpu",
182 + str(use_page_cache).lower(),
183 + str(lazy_offload).lower(),
184 + str(self.num_cpu_blocks),
185 + )
186 # Find the full attention kv group for prefix cache matching.
187 self.fa_gidx = -1
188 for g_idx, g in enumerate(self.cpu_kv_cache_config.kv_cache_groups):
@@ -251,6 +265,9 @@ class SimpleCPUOffloadScheduler:
265 self._load_event_counter: int = 0
266 self._store_event_counter: int = 0
267 self.boundary_store_stats = BoundaryStoreStats()
268 + # Interval stats state drained by get_stats()
269 + self._boundary_stats_snapshot = BoundaryStoreStats()
270 + self._interval_load_blocks_completed = 0
271
272 # For TP/PP: track partial store completions across steps.
273 # Events must be reported by all world_size workers before considered complete.
@@ -951,6 +968,42 @@ class SimpleCPUOffloadScheduler:
968 def get_boundary_store_stats(self) -> BoundaryStoreStats:
969 return replace(self.boundary_store_stats)
970
971 + def get_stats(self) -> SimpleCPUOffloadStats:
972 + """Drain per-step stats for the connector's stats hooks."""
973 + stats = SimpleCPUOffloadStats()
974 +
975 + current = self.boundary_store_stats
976 + for outcome, field_name in OUTCOME_TO_FIELD.items():
977 + delta = getattr(current, field_name) - getattr(
978 + self._boundary_stats_snapshot, field_name
979 + )
980 + if delta > 0:
981 + stats.increase_counter(MetricName.SAVE_OUTCOMES, delta, (outcome,))
982 + self._boundary_stats_snapshot = replace(current)
983 +
984 + if self._interval_load_blocks_completed:
985 + stats.increase_counter(
986 + MetricName.LOAD_BLOCKS,
987 + self._interval_load_blocks_completed,
988 + )
989 + self._interval_load_blocks_completed = 0
990 +
991 + stats.set_gauge(
992 + MetricName.USED_BLOCKS,
993 + self.num_cpu_blocks - self.cpu_block_pool.get_num_free_blocks(),
994 + )
995 + pending = sum(
996 + len(t.cpu_block_ids)
997 + for t in (
998 + *self._store_event_to_blocks.values(),
999 + *self._pending_finished_stores,
1000 + *self._abandoned_store_event_to_blocks.values(),
1001 + )
1002 + )
1003 + stats.set_gauge(MetricName.PENDING_STORE_BLOCKS, pending)
1004 + stats.set_gauge(MetricName.INFO, 1, self._info_labelvalues)
1005 + return stats
1006 +
1007 def update_connector_output(self, connector_output: KVConnectorOutput) -> None:
1008 """Handle async transfer completions from worker.
1009
@@ -961,7 +1014,9 @@ class SimpleCPUOffloadScheduler:
1014 """
1015 # --- Load completions ---
1016 for req_id in list(connector_output.finished_recving or []):
964 - self._cleanup_load_request(req_id)
1017 + completed_blocks = self._cleanup_load_request(req_id)
1018 + if completed_blocks:
1019 + self._interval_load_blocks_completed += completed_blocks
1020
1021 # --- Store completions ---
1022 meta = connector_output.kv_connector_worker_meta
@@ -1266,18 +1321,22 @@ class SimpleCPUOffloadScheduler:
1321 if blocks_to_free:
1322 self.cpu_block_pool.free_blocks(blocks_to_free)
1323
1269 - def _cleanup_load_request(self, req_id: str) -> None:
1324 + def _cleanup_load_request(self, req_id: str) -> int:
1325 """Release all load resources for a request.
1326
1327 Shared between request_finished() and update_connector_output() paths.
1328 Removes the request from _reqs_to_load, cleans up event mappings,
1329 and frees CPU/GPU touch refs.
1330 +
1331 + Returns the number of blocks in the load if it had been issued to
1332 + the worker, else 0 (never-issued loads are not counted as completed
1333 + by the caller).
1334 """
1335 state = self._reqs_to_load.pop(req_id, None)
1336 if state is None:
1337 state = self._abandoned_reqs_to_load.pop(req_id, None)
1338 if state is None:
1280 - return
1339 + return 0
1340 # Remove from load event mapping (only this req, not whole event)
1341 if state.load_event is not None:
1342 reqs = self._load_event_to_reqs.get(state.load_event)
@@ -1300,6 +1359,10 @@ class SimpleCPUOffloadScheduler:
1359 for bid in state.transfer_meta.gpu_block_ids
1360 )
1361
1362 + if state.load_event is None:
1363 + return 0
1364 + return len(state.transfer_meta.gpu_block_ids)
1365 +
1366 def _cleanup_store_request(self, req_id: str) -> None:
1367 """Release store metadata for a request.
1368
@@ -1359,7 +1422,20 @@ class SimpleCPUOffloadScheduler:
1422 if event_idx in self._abandoned_store_event_to_blocks
1423 }
1424 self._cursor = None
1362 - self.boundary_store_stats = BoundaryStoreStats()
1425 + # Seed the fresh counters with outcomes since the last drain so a
1426 + # mid-interval reset() does not drop them from the next report.
1427 + undrained = BoundaryStoreStats(
1428 + **{
1429 + f.name: max(
1430 + 0,
1431 + getattr(self.boundary_store_stats, f.name)
1432 + - getattr(self._boundary_stats_snapshot, f.name),
1433 + )
1434 + for f in fields(BoundaryStoreStats)
1435 + }
1436 + )
1437 + self.boundary_store_stats = undrained
1438 + self._boundary_stats_snapshot = BoundaryStoreStats()
1439 # NOTE: _load_event_counter / _store_event_counter are not
1440 # reset as they are monotonic and must stay ahead of the workers
1441 # high-water marks to avoid event index collisions
vllm/v1/simple_kv_offload/metrics.py new
+284
@@ -0,0 +1,284 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Prometheus metrics for SimpleCPUOffloadConnector.
4 +
5 +The stats payload uses the same self-describing wire format as the
6 +offloading connector, so it survives IPC serialization without the
7 +metric classes on the receiving side.
8 +"""
9 +
10 +from dataclasses import dataclass
11 +from typing import Any
12 +
13 +from vllm.config import VllmConfig
14 +from vllm.distributed.kv_transfer.kv_connector.v1.metrics import (
15 + KVConnectorPromMetrics,
16 + KVConnectorStats,
17 + PromMetric,
18 + PromMetricT,
19 +)
20 +
21 +
22 +class MetricName:
23 + """Metric names for SimpleCPUOffloadConnector."""
24 +
25 + SAVE_OUTCOMES = "vllm:simple_kv_offload_save_outcomes_total"
26 + LOAD_BLOCKS = "vllm:simple_kv_offload_load_blocks_total"
27 + USED_BLOCKS = "vllm:simple_kv_offload_used_blocks"
28 + PENDING_STORE_BLOCKS = "vllm:simple_kv_offload_pending_store_blocks"
29 + INFO = "vllm:simple_kv_offload_info"
30 +
31 +
32 +# outcome label value -> BoundaryStoreStats field name.
33 +# ``published`` is the sum of all outcomes and is not labeled.
34 +OUTCOME_TO_FIELD = {
35 + "stored": "stored",
36 + "dropped_cpu_full": "dropped_cpu_full",
37 + "dropped_request_gone": "dropped_request_gone",
38 + "dropped_null_block": "dropped_null_block",
39 + "dropped_not_hashed": "dropped_not_hashed",
40 + "skipped_already_cached": "skipped_already_cached",
41 + "skipped_in_flight": "skipped_in_flight",
42 +}
43 +
44 +INFO_LABELS = ["backend", "page_cache", "lazy_offload", "capacity_blocks"]
45 +
46 +
47 +class _MetricType:
48 + """Type tags embedded in the serialized stats payload."""
49 +
50 + COUNTER = "counter"
51 + GAUGE = "gauge"
52 +
53 +
54 +class _StatsKey:
55 + """Top-level keys in the serialized stats dict."""
56 +
57 + # Maps metric name -> _MetricType value
58 + TYPES = "types"
59 + # Maps metric name -> {label values tuple -> observed value}
60 + DATA = "data"
61 +
62 +
63 +@dataclass
64 +class SimpleCPUOffloadStats(KVConnectorStats):
65 + """Stats payload for SimpleCPUOffloadConnector.
66 +
67 + The ``data`` dict is structured as::
68 +
69 + {
70 + _StatsKey.TYPES: {name: _MetricType.*, ...},
71 + _StatsKey.DATA: {name: {labelvalues: value, ...}, ...},
72 + }
73 +
74 + Counter values are summed when aggregating, gauge values keep the
75 + latest snapshot, and unlabeled metrics use ``()`` as their
76 + labelvalues tuple.
77 + """
78 +
79 + def __post_init__(self):
80 + if _StatsKey.DATA not in self.data:
81 + self.reset()
82 +
83 + def reset(self):
84 + self.data: dict[str, Any] = {
85 + _StatsKey.TYPES: {},
86 + _StatsKey.DATA: {},
87 + }
88 +
89 + @property
90 + def _types(self) -> dict[str, str]:
91 + return self.data[_StatsKey.TYPES]
92 +
93 + @property
94 + def _values(self) -> dict[str, Any]:
95 + return self.data[_StatsKey.DATA]
96 +
97 + def aggregate(self, other: "KVConnectorStats") -> "KVConnectorStats":
98 + if other.is_empty():
99 + return self
100 + assert isinstance(other, SimpleCPUOffloadStats)
101 + for key, other_label_values in other._values.items():
102 + type_str = other._types.get(key)
103 + if type_str is None:
104 + raise AssertionError(f"Unknown simple KV offload stats key: {key}")
105 + self._types.setdefault(key, type_str)
106 + current_label_values = self._values.setdefault(key, {})
107 + for labelvalues, value in other_label_values.items():
108 + if type_str == _MetricType.COUNTER:
109 + assert isinstance(value, int | float)
110 + current_label_values[labelvalues] = (
111 + current_label_values.get(labelvalues, 0) + value
112 + )
113 + elif type_str == _MetricType.GAUGE:
114 + assert isinstance(value, int | float)
115 + current_label_values[labelvalues] = value
116 + else:
117 + raise AssertionError(
118 + f"Unknown metric type '{type_str}' for key: {key}"
119 + )
120 + return self
121 +
122 + def reduce(self) -> dict[str, int | float]:
123 + """Reduce the interval observations to a flat summary for logging."""
124 + return_dict: dict[str, int | float] = {}
125 + for key, label_value_map in self._values.items():
126 + if key == MetricName.INFO:
127 + # Constant config fingerprint; exclude from the log summary.
128 + continue
129 + type_str = self._types.get(key)
130 + if type_str is None:
131 + raise AssertionError(f"Unknown simple KV offload stats key: {key}")
132 + for labelvalues, value in label_value_map.items():
133 + key_with_labels = f"{key}:{labelvalues}" if labelvalues else key
134 + if type_str in (_MetricType.COUNTER, _MetricType.GAUGE):
135 + assert isinstance(value, int | float)
136 + return_dict[key_with_labels] = value
137 + else:
138 + raise AssertionError(
139 + f"Unknown metric type '{type_str}' for key: {key}"
140 + )
141 + return return_dict
142 +
143 + def is_empty(self) -> bool:
144 + return not self.data.get(_StatsKey.DATA)
145 +
146 + def increase_counter(
147 + self,
148 + counter_name: str,
149 + counter_increase_value: int | float = 1,
150 + labelvalues: tuple[str, ...] = (),
151 + ) -> None:
152 + """Increase a counter on the stats payload."""
153 + self._types.setdefault(counter_name, _MetricType.COUNTER)
154 + counter_values = self._values.setdefault(counter_name, {})
155 + counter_values[labelvalues] = (
156 + counter_values.get(labelvalues, 0) + counter_increase_value
157 + )
158 +
159 + def set_gauge(
160 + self,
161 + gauge_name: str,
162 + gauge_value: int | float,
163 + labelvalues: tuple[str, ...] = (),
164 + ) -> None:
165 + """Set a gauge snapshot on the stats payload."""
166 + self._types.setdefault(gauge_name, _MetricType.GAUGE)
167 + gauge_values = self._values.setdefault(gauge_name, {})
168 + gauge_values[labelvalues] = gauge_value
169 +
170 +
171 +class SimpleCPUOffloadPromMetrics(KVConnectorPromMetrics):
172 + """Prometheus registration and recording for SimpleCPUOffloadConnector."""
173 +
174 + def __init__(
175 + self,
176 + vllm_config: VllmConfig,
177 + metric_types: dict[type[PromMetric], type[PromMetricT]],
178 + labelnames: list[str],
179 + per_engine_labelvalues: dict[int, list[object]],
180 + ):
181 + super().__init__(vllm_config, metric_types, labelnames, per_engine_labelvalues)
182 + # (engine_idx, metric_name, labelvalues) -> bound child metric
183 + self._metrics: dict[tuple[int, str, tuple[str, ...]], PromMetricT] = {}
184 +
185 + self._counter_save_outcomes = self._counter_cls(
186 + name="vllm:simple_kv_offload_save_outcomes_total",
187 + documentation=(
188 + "Store admission decisions in SimpleCPUOffloadConnector, by "
189 + "outcome. Outcomes classify eager-mode boundary hand-off "
190 + "stores; lazy-mode stores are not classified."
191 + ),
192 + labelnames=self._labelnames + ["outcome"],
193 + )
194 + self._counter_load_blocks = self._counter_cls(
195 + name="vllm:simple_kv_offload_load_blocks_total",
196 + documentation=(
197 + "KV blocks restored to GPU from the offload pool, counted "
198 + "when a load finishes. This is prefill work avoided "
199 + "through offload cache hits."
200 + ),
201 + labelnames=self._labelnames,
202 + )
203 + self._gauge_used_blocks = self._gauge_cls(
204 + name="vllm:simple_kv_offload_used_blocks",
205 + documentation=(
206 + "Offload-pool blocks currently pinned by in-flight "
207 + "transfers or cache hits (capacity minus free). Evictable "
208 + "cached blocks are not counted; capacity_blocks is on "
209 + "vllm:simple_kv_offload_info."
210 + ),
211 + labelnames=self._labelnames,
212 + multiprocess_mode="mostrecent",
213 + )
214 + self._gauge_pending_store_blocks = self._gauge_cls(
215 + name="vllm:simple_kv_offload_pending_store_blocks",
216 + documentation=(
217 + "Offload blocks in pending stores: queued for dispatch, "
218 + "issued to workers, or awaiting release after a cache "
219 + "reset. A persistently growing value indicates a stuck "
220 + "transfer."
221 + ),
222 + labelnames=self._labelnames,
223 + multiprocess_mode="mostrecent",
224 + )
225 + self._gauge_info = self._gauge_cls(
226 + name="vllm:simple_kv_offload_info",
227 + documentation=(
228 + "SimpleCPUOffloadConnector deployment facts. Value is "
229 + "always 1. backend is cpu or disk; page_cache and "
230 + "lazy_offload are true/false; capacity_blocks is the "
231 + "offload pool size."
232 + ),
233 + labelnames=self._labelnames + INFO_LABELS,
234 + multiprocess_mode="mostrecent",
235 + )
236 +
237 + self._defs: dict[str, PromMetricT] = {
238 + MetricName.SAVE_OUTCOMES: self._counter_save_outcomes,
239 + MetricName.LOAD_BLOCKS: self._counter_load_blocks,
240 + MetricName.USED_BLOCKS: self._gauge_used_blocks,
241 + MetricName.PENDING_STORE_BLOCKS: self._gauge_pending_store_blocks,
242 + MetricName.INFO: self._gauge_info,
243 + }
244 + self._num_label_values: dict[str, int] = {
245 + MetricName.SAVE_OUTCOMES: 1,
246 + MetricName.LOAD_BLOCKS: 0,
247 + MetricName.USED_BLOCKS: 0,
248 + MetricName.PENDING_STORE_BLOCKS: 0,
249 + MetricName.INFO: len(INFO_LABELS),
250 + }
251 +
252 + def observe(self, transfer_stats_data: dict[str, Any], engine_idx: int = 0):
253 + """Observe transfer statistics."""
254 + metric_types = transfer_stats_data.get(_StatsKey.TYPES, {})
255 + metric_data = transfer_stats_data.get(_StatsKey.DATA, {})
256 + for key, label_value_map in metric_data.items():
257 + prom_metric = self._defs.get(key)
258 + if prom_metric is None:
259 + raise AssertionError(f"Unknown simple KV offload stats key: {key}")
260 + type_str = metric_types.get(key)
261 + for labelvalues, value in label_value_map.items():
262 + if len(labelvalues) != self._num_label_values[key]:
263 + raise AssertionError(
264 + f"Metric {key} expects "
265 + f"{self._num_label_values[key]} labels, "
266 + f"got {len(labelvalues)}"
267 + )
268 + child = self._metrics.get((engine_idx, key, labelvalues))
269 + if child is None:
270 + engine_labelvalues = self.per_engine_labelvalues[engine_idx]
271 + child = prom_metric.labels(
272 + *(engine_labelvalues + list(labelvalues))
273 + )
274 + self._metrics[(engine_idx, key, labelvalues)] = child
275 + if type_str == _MetricType.COUNTER:
276 + assert isinstance(value, int | float)
277 + child.inc(value)
278 + elif type_str == _MetricType.GAUGE:
279 + assert isinstance(value, int | float)
280 + child.set(value)
281 + else:
282 + raise AssertionError(
283 + f"Unknown metric type '{type_str}' for key: {key}"
284 + )