Skip to content

Commit e040899

Browse files
Srinivasoo7srinivas_oo7orozery
authored
[KV Offloading] Add basic offloading metrics (vllm-project#45958)
Signed-off-by: srinivas_oo7 <sklinkedin0120@gmail.com> Signed-off-by: Srinivas Krovvidi <194645829+Srinivasoo7@users.noreply.github.com> Co-authored-by: srinivas_oo7 <sklinkedin0120@gmail.com> Co-authored-by: Or Ozeri <or@ozery.com> Co-authored-by: Or Ozeri <oro@il.ibm.com>
1 parent dd5c299 commit e040899

10 files changed

Lines changed: 320 additions & 16 deletions

File tree

tests/v1/kv_connector/unit/offloading_connector/test_metrics.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
1111
OffloadingConnectorStats,
1212
OffloadPromMetrics,
13+
_ConnectorMetricName,
1314
_MetricType,
1415
_StatsKey,
1516
_TransferMetricName,
17+
get_connector_metric_definitions,
1618
)
1719
from vllm.distributed.kv_transfer.kv_connector.v1.offloading_connector import (
1820
OffloadingConnector,
@@ -37,6 +39,42 @@
3739
MY_LABEL = "my_label"
3840

3941

42+
def test_connector_metric_histogram_buckets():
43+
metadata = get_connector_metric_definitions()
44+
45+
sync_delay = metadata[_ConnectorMetricName.LOOKUP_SYNC_DELAY]
46+
assert isinstance(sync_delay, OffloadingHistogramMetadata)
47+
assert sync_delay.buckets == (
48+
0.00001,
49+
0.00005,
50+
0.0001,
51+
0.0005,
52+
0.001,
53+
0.005,
54+
0.01,
55+
0.05,
56+
0.1,
57+
0.5,
58+
1,
59+
)
60+
61+
async_delay = metadata[_ConnectorMetricName.LOOKUP_ASYNC_DELAY]
62+
assert isinstance(async_delay, OffloadingHistogramMetadata)
63+
assert async_delay.buckets == (
64+
0.0001,
65+
0.0005,
66+
0.001,
67+
0.005,
68+
0.01,
69+
0.05,
70+
0.1,
71+
0.5,
72+
1,
73+
5,
74+
10,
75+
)
76+
77+
4078
class _FakeMetric:
4179
def __init__(self, **kwargs):
4280
self.kwargs = kwargs

tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
to_keys,
1212
)
1313
from tests.v1.kv_connector.unit.utils import EOS_TOKEN_ID
14+
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
15+
OffloadingConnectorStats,
16+
_ConnectorMetricName,
17+
)
1418
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import (
1519
OffloadingConnectorScheduler,
1620
RequestOffloadState,
@@ -32,6 +36,72 @@
3236
from vllm.v1.request import RequestStatus
3337

3438

39+
def _reduce_kv_connector_stats(runner):
40+
reduced: dict[str, int | float] = {}
41+
for payload in runner.kv_connector_stats:
42+
stats = (
43+
payload
44+
if hasattr(payload, "reduce")
45+
else OffloadingConnectorStats(data=payload)
46+
)
47+
for key, value in stats.reduce().items():
48+
reduced[key] = reduced.get(key, 0) + value
49+
return reduced
50+
51+
52+
def test_scheduler_reports_allocation_failure(request_runner):
53+
runner = request_runner(
54+
block_size=4,
55+
num_gpu_blocks=10,
56+
async_scheduling=False,
57+
)
58+
runner.new_request(token_ids=[0] * 4)
59+
runner.manager.prepare_store.side_effect = lambda keys, req_context: None
60+
61+
runner.run(decoded_tokens=[EOS_TOKEN_ID])
62+
63+
reduced = _reduce_kv_connector_stats(runner)
64+
assert reduced[_ConnectorMetricName.ALLOCATION_FAILURE] == 1
65+
66+
67+
def test_scheduler_reports_lookup_sync_delay(request_runner):
68+
runner = request_runner(
69+
block_size=4,
70+
num_gpu_blocks=10,
71+
async_scheduling=False,
72+
)
73+
runner.new_request(token_ids=[1] * 4)
74+
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
75+
generate_store_output([])
76+
)
77+
78+
runner.run(decoded_tokens=[EOS_TOKEN_ID])
79+
80+
reduced = _reduce_kv_connector_stats(runner)
81+
assert reduced[f"{_ConnectorMetricName.LOOKUP_SYNC_DELAY}_count"] == 1
82+
assert reduced[f"{_ConnectorMetricName.LOOKUP_SYNC_DELAY}_sum"] > 0
83+
84+
85+
def test_scheduler_reports_lookup_async_delay_on_resolve(request_runner):
86+
"""A deferred lookup reports its async delay once it resolves."""
87+
runner = request_runner(
88+
block_size=4,
89+
num_gpu_blocks=10,
90+
async_scheduling=False,
91+
)
92+
runner.manager.lookup.side_effect = [LookupResult.RETRY, LookupResult.MISS]
93+
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
94+
generate_store_output([])
95+
)
96+
97+
runner.new_request(token_ids=[1] * 4)
98+
runner.run(decoded_tokens=[EOS_TOKEN_ID])
99+
100+
reduced = _reduce_kv_connector_stats(runner)
101+
assert reduced[f"{_ConnectorMetricName.LOOKUP_ASYNC_DELAY}_count"] == 1
102+
assert reduced[f"{_ConnectorMetricName.LOOKUP_ASYNC_DELAY}_sum"] > 0
103+
104+
35105
@pytest.mark.parametrize("async_scheduling", [True, False])
36106
def test_offloading_connector(request_runner, async_scheduling: bool):
37107
block_size = 4

tests/v1/kv_connector/unit/offloading_connector/utils.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ def __init__(self, vllm_config: VllmConfig, kv_cache_config: KVCacheConfig):
133133
self.manager = MagicMock(spec=OffloadingManager)
134134
self.manager.prepare_load = lambda keys, req_context: MockLoadStoreSpec(keys)
135135
self.manager.lookup.return_value = LookupResult.MISS
136+
self.manager.get_stats.return_value = None
136137
self.manager.on_new_request.return_value = RequestOffloadingContext()
137138
self.handler = MockOffloadingWorker()
138139

@@ -335,6 +336,7 @@ def __init__(
335336
self.completed_loads: list[TransferSummary] = []
336337
self.completed_stores: list[TransferSummary] = []
337338
self.flushed_gpu_blocks: set[GPUBlock] = set()
339+
self.kv_connector_stats: list[Any] = []
338340

339341
# block_id -> GPUBlock
340342
self.gpu_blocks: dict[int, GPUBlock] = {}
@@ -348,6 +350,12 @@ def __init__(
348350
slot_mapping={},
349351
)
350352

353+
def _record_kv_connector_stats(self, engine_outputs: dict[int, Any]) -> None:
354+
for output in engine_outputs.values():
355+
scheduler_stats = output.scheduler_stats
356+
if scheduler_stats is not None and scheduler_stats.kv_connector_stats:
357+
self.kv_connector_stats.append(scheduler_stats.kv_connector_stats)
358+
351359
def new_request(
352360
self,
353361
token_ids: list[int],
@@ -524,13 +532,17 @@ def _run(
524532
if self.async_scheduling:
525533
# in async scheduling we update the output of the previous step
526534
if prev_model_runner_output is not None:
527-
self.scheduler.update_from_output(
535+
engine_outputs = self.scheduler.update_from_output(
528536
prev_scheduler_output, prev_model_runner_output
529537
)
538+
self._record_kv_connector_stats(engine_outputs)
530539
prev_scheduler_output = scheduler_output
531540
prev_model_runner_output = model_runner_output
532541
else:
533-
self.scheduler.update_from_output(scheduler_output, model_runner_output)
542+
engine_outputs = self.scheduler.update_from_output(
543+
scheduler_output, model_runner_output
544+
)
545+
self._record_kv_connector_stats(engine_outputs)
534546

535547
if post_step_fn is not None:
536548
post_step_fn()
@@ -546,9 +558,10 @@ def _run(
546558
if token_id is None:
547559
if self.async_scheduling:
548560
# sample last token
549-
self.scheduler.update_from_output(
561+
engine_outputs = self.scheduler.update_from_output(
550562
prev_scheduler_output, prev_model_runner_output
551563
)
564+
self._record_kv_connector_stats(engine_outputs)
552565
break
553566

554567
self._parse_transfers()

tests/v1/kv_offload/cpu/test_manager.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,64 @@ def check_usage_stats(manager: CPUOffloadingManager, value: float):
224224
check_usage_stats(manager, 0.0)
225225

226226

227+
def test_cpu_manager_reports_allocation_size_histogram():
228+
manager = make_cpu_manager(num_blocks=4, cache_policy="lru")
229+
230+
manager.prepare_store(to_keys([1, 2]), _EMPTY_REQ_CTX)
231+
manager.complete_store(to_keys([1, 2]), _EMPTY_REQ_CTX)
232+
manager.prepare_store(to_keys([1, 2, 3]), _EMPTY_REQ_CTX)
233+
234+
stats = manager.get_stats()
235+
236+
assert stats is not None
237+
reduced = stats.reduce()
238+
assert reduced[f"{CPUOffloadingMetrics.CPU_ALLOCATION_SIZE}_count"] == 2
239+
assert reduced[f"{CPUOffloadingMetrics.CPU_ALLOCATION_SIZE}_sum"] == 3
240+
241+
# The cache-usage gauge is always reported, so get_stats() never returns
242+
# None, but the histogram has nothing new once its samples are consumed.
243+
second_stats = manager.get_stats()
244+
assert second_stats is not None
245+
assert f"{CPUOffloadingMetrics.CPU_ALLOCATION_SIZE}_count" not in (
246+
second_stats.reduce()
247+
)
248+
249+
250+
def test_cpu_manager_reports_allocation_size_on_allocation_failure(monkeypatch):
251+
manager = make_cpu_manager(num_blocks=4, cache_policy="lru")
252+
253+
def fail_allocate_blocks(keys):
254+
raise RuntimeError("allocation failed")
255+
256+
monkeypatch.setattr(manager, "_allocate_blocks", fail_allocate_blocks)
257+
258+
with pytest.raises(RuntimeError, match="allocation failed"):
259+
manager.prepare_store(to_keys([1, 2, 3]), _EMPTY_REQ_CTX)
260+
261+
stats = manager.get_stats()
262+
263+
assert stats is not None
264+
reduced = stats.reduce()
265+
assert reduced[f"{CPUOffloadingMetrics.CPU_ALLOCATION_SIZE}_count"] == 1
266+
assert reduced[f"{CPUOffloadingMetrics.CPU_ALLOCATION_SIZE}_sum"] == 3
267+
268+
269+
def test_cpu_manager_reports_allocation_size_on_eviction_failure():
270+
manager = make_cpu_manager(num_blocks=1, cache_policy="lru")
271+
272+
manager.prepare_store(to_keys([1]), _EMPTY_REQ_CTX)
273+
manager.get_stats()
274+
275+
assert manager.prepare_store(to_keys([2]), _EMPTY_REQ_CTX) is None
276+
277+
stats = manager.get_stats()
278+
279+
assert stats is not None
280+
reduced = stats.reduce()
281+
assert reduced[f"{CPUOffloadingMetrics.CPU_ALLOCATION_SIZE}_count"] == 1
282+
assert reduced[f"{CPUOffloadingMetrics.CPU_ALLOCATION_SIZE}_sum"] == 1
283+
284+
227285
def test_cpu_manager():
228286
"""
229287
Tests CPUOffloadingManager with lru policy.

tests/v1/kv_offload/test_factory.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
KVCacheGroupSpec,
2222
KVCacheTensor,
2323
)
24-
from vllm.v1.kv_offload.base import OffloadingSpec
24+
from vllm.v1.kv_offload.base import OffloadingHistogramMetadata, OffloadingSpec
2525
from vllm.v1.kv_offload.cpu.spec import CPUOffloadingSpec
2626
from vllm.v1.kv_offload.factory import OffloadingSpecFactory
2727
from vllm.v1.kv_offload.tiering.spec import TieringOffloadingSpec
@@ -248,8 +248,8 @@ def test_duplicate_registration_raises():
248248
# ---------------------------------------------------------------------------
249249

250250

251-
def test_build_metric_definitions_empty_below_threshold():
252-
"""store_threshold < 2 → only base metric (no stores_skipped)."""
251+
def test_build_metric_definitions_below_threshold():
252+
"""store_threshold < 2 keeps stores_skipped disabled."""
253253
from vllm.v1.kv_offload.cpu.common import CPUOffloadingMetrics
254254

255255
config = _make_vllm_config(store_threshold=1)
@@ -258,6 +258,32 @@ def test_build_metric_definitions_empty_below_threshold():
258258
config.kv_transfer_config.kv_connector_extra_config
259259
)
260260
assert CPUOffloadingMetrics.STORES_SKIPPED not in metrics
261+
assert CPUOffloadingMetrics.CPU_ALLOCATION_SIZE in metrics
262+
263+
264+
def test_build_metric_definitions_allocation_size_histogram():
265+
"""CPU allocation size is always reported as a histogram."""
266+
from vllm.v1.kv_offload.cpu.common import CPUOffloadingMetrics
267+
268+
config = _make_vllm_config(store_threshold=0)
269+
spec_cls = OffloadingSpecFactory.get_spec_cls(config)
270+
metrics = spec_cls.build_metric_definitions(
271+
config.kv_transfer_config.kv_connector_extra_config
272+
)
273+
metadata = metrics[CPUOffloadingMetrics.CPU_ALLOCATION_SIZE]
274+
assert isinstance(metadata, OffloadingHistogramMetadata)
275+
assert metadata.buckets == (
276+
1,
277+
4,
278+
16,
279+
64,
280+
256,
281+
1024,
282+
4096,
283+
16384,
284+
65536,
285+
262144,
286+
)
261287

262288

263289
def test_build_metric_definitions_returns_counter_at_threshold():

vllm/distributed/kv_transfer/kv_connector/v1/offloading/metrics.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ class _TransferMetricName:
3030
STORE_SIZE = "vllm:kv_offload_store_size"
3131

3232

33+
class _ConnectorMetricName:
34+
"""Connector-side metrics emitted by scheduler-side offloading code."""
35+
36+
LOOKUP_SYNC_DELAY = "vllm:kv_offload_lookup_sync_delay_seconds"
37+
LOOKUP_ASYNC_DELAY = "vllm:kv_offload_lookup_async_delay_seconds"
38+
ALLOCATION_FAILURE = "vllm:kv_offload_allocation_failure"
39+
40+
3341
class _TransferType:
3442
"""Transfer direction labels for deprecated CPU offload metrics."""
3543

@@ -74,6 +82,50 @@ def get_connector_metric_definitions() -> dict[str, OffloadingMetricMetadata]:
7482
documentation="Histogram of KV offload store operation size, in bytes.",
7583
buckets=TRANSFER_SIZE_BUCKETS,
7684
),
85+
_ConnectorMetricName.LOOKUP_SYNC_DELAY: OffloadingHistogramMetadata(
86+
documentation=(
87+
"Histogram of the time spent in a single offload lookup call, "
88+
"in seconds."
89+
),
90+
buckets=(
91+
0.00001,
92+
0.00005,
93+
0.0001,
94+
0.0005,
95+
0.001,
96+
0.005,
97+
0.01,
98+
0.05,
99+
0.1,
100+
0.5,
101+
1,
102+
),
103+
),
104+
_ConnectorMetricName.LOOKUP_ASYNC_DELAY: OffloadingHistogramMetadata(
105+
documentation=(
106+
"Histogram of time between a request's offload lookup first "
107+
"deferring and the following lookup resolving, or request "
108+
"finish, in seconds."
109+
),
110+
buckets=(
111+
0.0001,
112+
0.0005,
113+
0.001,
114+
0.005,
115+
0.01,
116+
0.05,
117+
0.1,
118+
0.5,
119+
1,
120+
5,
121+
10,
122+
),
123+
),
124+
_ConnectorMetricName.ALLOCATION_FAILURE: OffloadingCounterMetadata(
125+
documentation=(
126+
"Number of KV offload store allocation attempts that failed."
127+
),
128+
),
77129
}
78130

79131

@@ -223,7 +275,7 @@ def is_empty(self) -> bool:
223275
def increase_counter(
224276
self,
225277
counter_name: str,
226-
counter_increase_value: int | float,
278+
counter_increase_value: int | float = 1,
227279
labelvalues: tuple[str, ...] = (),
228280
) -> None:
229281
"""Increase a counter on the stats payload."""

0 commit comments

Comments
 (0)