Add tiering offloading metrics (#48798)
Signed-off-by: srinivas_oo7 <sklinkedin0120@gmail.com> Co-authored-by: srinivas_oo7 <sklinkedin0120@gmail.com>
Srinivas Krovvidi committed
Aug 9, 2026 at 21:23 UTC
a123159f7ab0dbacb4d8f45cdeec3cb366982e50
16 files changed
+1031
-237
tests/v1/kv_connector/unit/offloading_connector/test_metrics.py
+34
-4
@@ -25,6 +25,7 @@ from vllm.v1.kv_offload.base import (
25
OffloadingHistogramMetadata,
26
)
27
from vllm.v1.kv_offload.factory import OffloadingSpecFactory
28
+from vllm.v1.kv_offload.tiering.base import TieringOffloadingMetrics
29
30
LOAD_BYTES = _TransferMetricName.LOAD_BYTES
31
LOAD_TIME = _TransferMetricName.LOAD_TIME
@@ -101,10 +102,15 @@ class _FakeMetric:
102
103
104
class _FakeVllmConfig:
104
- def __init__(self, store_threshold: int = 2):
105
- self.kv_transfer_config = SimpleNamespace(
106
- kv_connector_extra_config={"store_threshold": store_threshold}
107
- )
105
+ def __init__(
106
+ self,
107
+ store_threshold: int = 2,
108
+ extra_config: dict[str, Any] | None = None,
109
+ ):
110
+ config = {"store_threshold": store_threshold}
111
+ if extra_config is not None:
112
+ config.update(extra_config)
113
+ self.kv_transfer_config = SimpleNamespace(kv_connector_extra_config=config)
114
115
116
def _spec_cls_with_metric_definitions(
@@ -679,6 +685,30 @@ def test_prom_metrics_uses_configured_manager_metrics():
685
assert STORES_SKIPPED not in prom_metrics._offloading_metric_metadata
686
687
688
+def test_prom_metrics_registers_tiering_metrics_from_spec():
689
+ prom_metrics = OffloadPromMetrics(
690
+ vllm_config=_FakeVllmConfig(
691
+ store_threshold=0,
692
+ extra_config={
693
+ "spec_name": "TieringOffloadingSpec",
694
+ "secondary_tiers": [],
695
+ },
696
+ ), # type: ignore[arg-type]
697
+ metric_types={
698
+ Gauge: _FakeMetric,
699
+ Counter: _FakeMetric,
700
+ Histogram: _FakeMetric,
701
+ },
702
+ labelnames=["model_name", "engine"],
703
+ per_engine_labelvalues={0: ["model", "0"]},
704
+ )
705
+
706
+ metric = prom_metrics._offloading_metric_defs[
707
+ TieringOffloadingMetrics.BLOCK_QUERIES
708
+ ]
709
+ assert metric.kwargs["labelnames"] == ["model_name", "engine", "tier"]
710
+
711
+
712
def test_aggregate_into_empty_stats():
713
"""Aggregating non-empty stats into a fresh (empty) stats object works."""
714
empty = OffloadingConnectorStats()
tests/v1/kv_offload/tiering/p2p/test_manager.py
+3
-3
@@ -16,7 +16,7 @@ import numpy as np
16
import pytest
17
18
from vllm.v1.kv_offload.base import LookupResult, ReqContext, ScheduleEndContext
19
-from vllm.v1.kv_offload.tiering.base import JobMetadata, JobResult
19
+from vllm.v1.kv_offload.tiering.base import JobResult, TransferJob
20
from vllm.v1.kv_offload.tiering.p2p import manager as manager_module
21
from vllm.v1.kv_offload.tiering.p2p.manager import (
22
_UNBOUND_STORE_TIMEOUT_S,
@@ -86,12 +86,12 @@ def _job_metadata(
86
keys: list[bytes] | None = None,
87
block_ids: list[int] | None = None,
88
kv_params: dict | None = None,
89
-) -> JobMetadata:
89
+) -> TransferJob:
90
if keys is None:
91
keys = [b"key1"]
92
if block_ids is None:
93
block_ids = list(range(len(keys)))
94
- return JobMetadata(
94
+ return TransferJob(
95
job_id=job_id,
96
keys=keys,
97
block_ids=np.array(block_ids),
tests/v1/kv_offload/tiering/p2p/test_sessions.py
+3
-3
@@ -24,7 +24,7 @@ from vllm.v1.kv_offload.base import (
24
ReqContext,
25
RequestOffloadingContext,
26
)
27
-from vllm.v1.kv_offload.tiering.base import JobMetadata
27
+from vllm.v1.kv_offload.tiering.base import TransferJob
28
from vllm.v1.kv_offload.tiering.p2p.session import (
29
LoadResult,
30
P2PSession,
@@ -256,13 +256,13 @@ class FakeParent:
256
self,
257
keys: Sequence[OffloadKey],
258
ctx: ReqContext,
259
- ) -> JobMetadata:
259
+ ) -> TransferJob:
260
keys_list = list(keys)
261
self.calls.append(("create_store_job", tuple(keys_list), ctx.req_id))
262
block_ids = np.array([self.stored[k] for k in keys_list], dtype=np.int32)
263
job_id = self._next_job_id
264
self._next_job_id += 1
265
- return JobMetadata(
265
+ return TransferJob(
266
job_id=job_id,
267
keys=keys_list,
268
block_ids=block_ids,
tests/v1/kv_offload/tiering/test_fs_tier.py
+3
-3
@@ -35,7 +35,7 @@ from vllm.v1.kv_offload.config import (
35
OffloadingModelConfig,
36
OffloadingParallelConfig,
37
)
38
-from vllm.v1.kv_offload.tiering.base import JobMetadata
38
+from vllm.v1.kv_offload.tiering.base import TransferJob
39
from vllm.v1.kv_offload.tiering.factory import SecondaryTierFactory
40
from vllm.v1.kv_offload.tiering.fs.manager import (
41
FileSystemTierManager,
@@ -105,10 +105,10 @@ def make_job(
105
keys: list[OffloadKey],
106
block_ids: list[int] | None = None,
107
is_promotion: bool = False,
108
-) -> JobMetadata:
108
+) -> TransferJob:
109
if block_ids is None:
110
block_ids = list(range(len(keys)))
111
- return JobMetadata(
111
+ return TransferJob(
112
job_id=job_id,
113
keys=keys,
114
block_ids=np.array(block_ids, dtype=np.int64),
tests/v1/kv_offload/tiering/test_metrics.py
new
+226
@@ -0,0 +1,226 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from collections.abc import Iterable
5
+
6
+import numpy as np
7
+import pytest
8
+
9
+from vllm.v1.kv_offload.base import LookupResult, ReqContext, make_offload_key
10
+from vllm.v1.kv_offload.tiering.base import (
11
+ JobResult,
12
+ TieringOffloadingMetrics,
13
+ TransferJob,
14
+)
15
+from vllm.v1.kv_offload.tiering.manager import JobMetadata
16
+from vllm.v1.kv_offload.tiering.metrics import TieringMetricsTracker
17
+
18
+_CTX = ReqContext(req_id="test")
19
+
20
+
21
+def to_keys(int_ids: Iterable[int]):
22
+ return [make_offload_key(str(i).encode(), 0) for i in int_ids]
23
+
24
+
25
+def test_tiering_metrics_tracker_records_lookup_metrics():
26
+ tracker = TieringMetricsTracker(
27
+ tier_types=["fs", "p2p"],
28
+ num_primary_blocks=5,
29
+ primary_block_size=16,
30
+ )
31
+ tracker.on_new_request(_CTX)
32
+
33
+ key = to_keys([1])[0]
34
+ tracker.on_lookup(
35
+ _CTX,
36
+ key,
37
+ tracker.primary_tier_label,
38
+ LookupResult.MISS,
39
+ lookup_duration=0.0,
40
+ )
41
+ tracker.on_lookup(_CTX, key, tracker.tier_label(0), LookupResult.MISS, 0.0)
42
+ tracker.on_lookup(_CTX, key, tracker.tier_label(1), LookupResult.HIT, 0.0)
43
+ tracker.on_lookup(_CTX, key, tracker.tier_label(1), LookupResult.HIT, 0.0)
44
+
45
+ stats = tracker.take_stats()
46
+ assert stats is not None
47
+ values = stats.data["data"]
48
+ assert values[TieringOffloadingMetrics.BLOCK_QUERIES][("0:primary",)] == 1
49
+ assert values[TieringOffloadingMetrics.BLOCK_QUERIES][("1:fs",)] == 1
50
+ assert values[TieringOffloadingMetrics.BLOCK_QUERIES][("2:p2p",)] == 1
51
+ assert values[TieringOffloadingMetrics.BLOCK_HITS][("2:p2p",)] == 1
52
+ assert ("1:fs",) not in values[TieringOffloadingMetrics.BLOCK_HITS]
53
+
54
+
55
+def test_tiering_metrics_tracker_stops_lookup_metrics_after_allocation():
56
+ tracker = TieringMetricsTracker(
57
+ tier_types=["fs"],
58
+ num_primary_blocks=5,
59
+ primary_block_size=16,
60
+ )
61
+ tracker.on_new_request(_CTX)
62
+ key = to_keys([1])[0]
63
+
64
+ tracker.on_lookup(
65
+ _CTX,
66
+ key,
67
+ tracker.primary_tier_label,
68
+ LookupResult.MISS,
69
+ lookup_duration=0.0,
70
+ )
71
+ tracker.on_lookup(_CTX, key, tracker.tier_label(0), LookupResult.HIT, 0.0)
72
+
73
+ stats = tracker.take_stats()
74
+ assert stats is not None
75
+ values = stats.data["data"]
76
+ assert values[TieringOffloadingMetrics.BLOCK_QUERIES][("0:primary",)] == 1
77
+ assert values[TieringOffloadingMetrics.BLOCK_QUERIES][("1:fs",)] == 1
78
+ assert values[TieringOffloadingMetrics.BLOCK_HITS][("1:fs",)] == 1
79
+
80
+ tracker.on_request_allocated(_CTX)
81
+ tracker.on_lookup(
82
+ _CTX,
83
+ key,
84
+ tracker.primary_tier_label,
85
+ LookupResult.MISS,
86
+ lookup_duration=0.0,
87
+ )
88
+ tracker.on_lookup(_CTX, key, tracker.tier_label(0), LookupResult.HIT, 0.0)
89
+
90
+ stats = tracker.take_stats()
91
+ assert stats is not None
92
+ values = stats.data["data"]
93
+ assert TieringOffloadingMetrics.BLOCK_QUERIES not in values
94
+ assert TieringOffloadingMetrics.BLOCK_HITS not in values
95
+
96
+
97
+def test_tiering_metrics_tracker_records_finished_job_metrics():
98
+ tracker = TieringMetricsTracker(
99
+ tier_types=["fs"],
100
+ num_primary_blocks=5,
101
+ primary_block_size=16,
102
+ )
103
+ cascade_key, promotion_key, failed_cascade_key, failed_promotion_key = to_keys(
104
+ range(4)
105
+ )
106
+ jobs = [
107
+ JobMetadata(
108
+ TransferJob(0, [cascade_key], np.array([0]), False, _CTX),
109
+ 0,
110
+ ),
111
+ JobMetadata(
112
+ TransferJob(1, [promotion_key], np.array([1]), True, _CTX),
113
+ 0,
114
+ ),
115
+ JobMetadata(
116
+ TransferJob(2, [failed_cascade_key], np.array([2]), False, _CTX),
117
+ 0,
118
+ ),
119
+ JobMetadata(
120
+ TransferJob(3, [failed_promotion_key], np.array([3]), True, _CTX),
121
+ 0,
122
+ ),
123
+ ]
124
+ for job in jobs:
125
+ tracker.on_job_registered(job)
126
+
127
+ tracker.on_job_finished(
128
+ jobs[0], JobResult(job_id=0, success=True, transfer_time=0.5)
129
+ )
130
+ tracker.on_job_finished(
131
+ jobs[1], JobResult(job_id=1, success=True, transfer_time=0.25)
132
+ )
133
+ tracker.on_job_finished(jobs[2], JobResult(job_id=2, success=False))
134
+ tracker.on_job_finished(jobs[3], JobResult(job_id=3, success=False))
135
+
136
+ stats = tracker.take_stats()
137
+ assert stats is not None
138
+ values = stats.data["data"]
139
+ label = ("1:fs",)
140
+ assert values[TieringOffloadingMetrics.WRITE_BYTES][label] == 16
141
+ assert values[TieringOffloadingMetrics.WRITE_TIME][label] == 0.5
142
+ assert values[TieringOffloadingMetrics.READ_BYTES][label] == 16
143
+ assert values[TieringOffloadingMetrics.READ_TIME][label] == 0.25
144
+ assert values[TieringOffloadingMetrics.CASCADE_JOB_FAILURES][label] == 1
145
+ assert values[TieringOffloadingMetrics.PROMOTION_JOB_FAILURES][label] == 1
146
+ tracker.assert_idle()
147
+
148
+
149
+def test_tiering_metrics_tracker_records_partial_promotion_success_bytes():
150
+ tracker = TieringMetricsTracker(
151
+ tier_types=["fs"],
152
+ num_primary_blocks=5,
153
+ primary_block_size=16,
154
+ )
155
+ keys = to_keys(range(3))
156
+ job = JobMetadata(
157
+ TransferJob(0, keys, np.array([0, 1, 2]), True, _CTX),
158
+ 0,
159
+ )
160
+ tracker.on_job_registered(job)
161
+
162
+ tracker.on_job_finished(
163
+ job,
164
+ JobResult(
165
+ job_id=0,
166
+ success=False,
167
+ successful_keys=(keys[0], keys[2]),
168
+ transfer_time=0.5,
169
+ ),
170
+ )
171
+
172
+ stats = tracker.take_stats()
173
+ assert stats is not None
174
+ values = stats.data["data"]
175
+ label = ("1:fs",)
176
+ assert values[TieringOffloadingMetrics.READ_BYTES][label] == 32
177
+ assert values[TieringOffloadingMetrics.READ_TIME][label] == 0.5
178
+ assert values[TieringOffloadingMetrics.PROMOTION_JOB_FAILURES][label] == 1
179
+ tracker.assert_idle()
180
+
181
+
182
+def test_tiering_metrics_tracker_reports_active_job_and_primary_usage_gauges():
183
+ tracker = TieringMetricsTracker(
184
+ tier_types=["fs", "p2p"],
185
+ num_primary_blocks=6,
186
+ primary_block_size=16,
187
+ )
188
+ fs_job = JobMetadata(
189
+ TransferJob(0, to_keys([0, 1]), np.array([0, 1]), False, _CTX),
190
+ 0,
191
+ )
192
+ p2p_job = JobMetadata(
193
+ TransferJob(1, to_keys([2, 3, 4]), np.array([2, 3, 4]), True, _CTX),
194
+ 1,
195
+ )
196
+ tracker.on_job_registered(fs_job)
197
+ tracker.on_job_registered(p2p_job)
198
+
199
+ stats = tracker.take_stats()
200
+ assert stats is not None
201
+ values = stats.data["data"]
202
+ fs_label = ("1:fs",)
203
+ p2p_label = ("2:p2p",)
204
+ assert values[TieringOffloadingMetrics.PRIMARY_READ_USAGE_PERC][
205
+ fs_label
206
+ ] == pytest.approx(2 / 6)
207
+ assert values[TieringOffloadingMetrics.ACTIVE_CASCADE_JOBS][fs_label] == 1
208
+ assert values[TieringOffloadingMetrics.PRIMARY_WRITE_USAGE_PERC][
209
+ p2p_label
210
+ ] == pytest.approx(3 / 6)
211
+ assert values[TieringOffloadingMetrics.ACTIVE_PROMOTION_JOBS][p2p_label] == 1
212
+
213
+
214
+def test_tiering_metrics_tracker_records_promotion_allocation_failures():
215
+ tracker = TieringMetricsTracker(
216
+ tier_types=["fs"],
217
+ num_primary_blocks=1,
218
+ primary_block_size=16,
219
+ )
220
+
221
+ tracker.on_promotion_allocation_failure()
222
+
223
+ stats = tracker.take_stats()
224
+ assert stats is not None
225
+ values = stats.data["data"]
226
+ assert values[TieringOffloadingMetrics.PROMOTION_ALLOCATION_FAILURES][()] == 1
tests/v1/kv_offload/tiering/test_obj_tier.py
+6
-3
@@ -34,7 +34,7 @@ from vllm.v1.kv_offload.config import (
34
OffloadingModelConfig,
35
OffloadingParallelConfig,
36
)
37
-from vllm.v1.kv_offload.tiering.base import JobMetadata, JobResult
37
+from vllm.v1.kv_offload.tiering.base import JobResult, TransferJob
38
from vllm.v1.kv_offload.tiering.manager import (
39
CPUPrimaryTierOffloadingManager,
40
TieringOffloadingManager,
@@ -105,10 +105,10 @@ def make_job(
105
job_id: int,
106
keys: list[OffloadKey],
107
block_ids: list[int] | None = None,
108
-) -> JobMetadata:
108
+) -> TransferJob:
109
if block_ids is None:
110
block_ids = list(range(len(keys)))
111
- return JobMetadata(
111
+ return TransferJob(
112
job_id=job_id,
113
keys=keys,
114
block_ids=np.array(block_ids, dtype=np.int64),
@@ -201,6 +201,9 @@ class MockNixlAgent:
201
def release_dlist_handle(self, handle):
202
pass
203
204
+ def get_xfer_telemetry(self, handle):
205
+ return SimpleNamespace(xferDuration=1000)
206
+
207
def _query_memory(self, queries, mem_type, agent_name):
208
return [object() if q[3] in self._stored_obj_keys else None for q in queries]
209
tests/v1/kv_offload/tiering/test_tiering_offloading.py
+149
-63
@@ -29,6 +29,7 @@ from vllm.v1.kv_offload.base import (
29
Medium,
30
OffloadingCounterMetadata,
31
OffloadingEvent,
32
+ OffloadingGaugeMetadata,
33
OffloadKey,
34
OffloadPolicy,
35
ReqContext,
@@ -39,10 +40,10 @@ from vllm.v1.kv_offload.base import (
40
make_offload_key,
41
)
42
from vllm.v1.kv_offload.tiering.base import (
42
- JobMetadata,
43
JobResult,
44
SecondaryTierManager,
45
TieringOffloadingMetrics,
46
+ TransferJob,
47
)
48
from vllm.v1.kv_offload.tiering.example.manager import ExampleSecondaryTierManager
49
from vllm.v1.kv_offload.tiering.factory import SecondaryTierFactory
@@ -85,6 +86,16 @@ def count_hits(manager, keys: list[OffloadKey]) -> int | None:
86
return count
87
88
89
+def histogram_count_key(metric_name: str, labelvalues: tuple[str, ...]) -> str:
90
+ return f"{metric_name}:{labelvalues}_count"
91
+
92
+
93
+def has_histogram_count(reduced: dict[str, int | float], metric_name: str) -> bool:
94
+ return any(
95
+ key.startswith(f"{metric_name}:") and key.endswith("_count") for key in reduced
96
+ )
97
+
98
+
99
class MetricsSecondaryTierManager(SecondaryTierManager):
100
"""Test-only secondary tier that declares and emits one labeled metric."""
101
@@ -102,18 +113,26 @@ class MetricsSecondaryTierManager(SecondaryTierManager):
113
def __init__(self, *args, **kwargs):
114
super().__init__(*args, **kwargs)
115
self.stats: OffloadingConnectorStats | None = None
116
+ self.lookup_result = LookupResult.MISS
117
+ self.finished_jobs: list[JobResult] = []
118
+ self.submitted_loads: list[TransferJob] = []
119
+ self.submitted_stores: list[TransferJob] = []
120
106
- def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None:
107
- return False
121
+ def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult:
122
+ return self.lookup_result
123
109
- def submit_store(self, job_metadata: JobMetadata) -> None:
124
+ def submit_store(self, job_metadata: TransferJob) -> None:
125
+ self.submitted_stores.append(job_metadata)
126
return
127
112
- def submit_load(self, job_metadata: JobMetadata) -> None:
128
+ def submit_load(self, job_metadata: TransferJob) -> None:
129
+ self.submitted_loads.append(job_metadata)
130
return
131
132
def get_finished_jobs(self) -> Iterable[JobResult]:
116
- return ()
133
+ results = self.finished_jobs
134
+ self.finished_jobs = []
135
+ return results
136
137
def drain_jobs(self) -> None:
138
return
@@ -141,6 +160,24 @@ def test_tiering_spec_collects_secondary_metric_definitions(monkeypatch):
160
metadata = metrics[MetricsSecondaryTierManager.MY_TIER_METRIC]
161
assert metadata.documentation == "Number of bytes served by the test tier."
162
assert metadata.labelnames == ("tier",)
163
+ assert metrics[TieringOffloadingMetrics.BLOCK_QUERIES].labelnames == ("tier",)
164
+ assert metrics[TieringOffloadingMetrics.LOOKUP_SYNC_DELAY].labelnames == ("tier",)
165
+ assert metrics[TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY].labelnames == ("tier",)
166
+ assert metrics[TieringOffloadingMetrics.PROMOTION_JOB_FAILURES].labelnames == (
167
+ "tier",
168
+ )
169
+ assert isinstance(
170
+ metrics[TieringOffloadingMetrics.PROMOTION_ALLOCATION_FAILURES],
171
+ OffloadingCounterMetadata,
172
+ )
173
+ for metric_name in (
174
+ TieringOffloadingMetrics.PRIMARY_WRITE_USAGE_PERC,
175
+ TieringOffloadingMetrics.PRIMARY_READ_USAGE_PERC,
176
+ TieringOffloadingMetrics.ACTIVE_PROMOTION_JOBS,
177
+ TieringOffloadingMetrics.ACTIVE_CASCADE_JOBS,
178
+ ):
179
+ assert isinstance(metrics[metric_name], OffloadingGaugeMetadata)
180
+ assert metrics[metric_name].labelnames == ("tier",)
181
182
183
def test_tiering_manager_aggregates_secondary_stats():
@@ -261,12 +298,15 @@ class TestTieringOffloadingManager:
298
self._start_request()
299
# Register an in-flight promotion job for tier1 by hand.
300
job_id = self.manager._next_job_id()
264
- self.manager._transfer_jobs[job_id] = JobMetadata(
265
- job_id=job_id,
266
- keys=to_keys([1, 2]),
267
- block_ids=[0, 1],
268
- is_promotion=True,
269
- req_context=_CTX,
301
+ self.manager._register_job(
302
+ TransferJob(
303
+ job_id=job_id,
304
+ keys=to_keys([1, 2]),
305
+ block_ids=[0, 1],
306
+ is_promotion=True,
307
+ req_context=_CTX,
308
+ ),
309
+ 0,
310
)
311
failed = JobResult(job_id=job_id, success=False)
312
with (
@@ -285,12 +325,15 @@ class TestTieringOffloadingManager:
325
326
self._start_request()
327
job_id = self.manager._next_job_id()
288
- self.manager._transfer_jobs[job_id] = JobMetadata(
289
- job_id=job_id,
290
- keys=to_keys([1]),
291
- block_ids=[0],
292
- is_promotion=True,
293
- req_context=_CTX,
328
+ self.manager._register_job(
329
+ TransferJob(
330
+ job_id=job_id,
331
+ keys=to_keys([1]),
332
+ block_ids=[0],
333
+ is_promotion=True,
334
+ req_context=_CTX,
335
+ ),
336
+ 0,
337
)
338
ok = JobResult(job_id=job_id, success=True)
339
with (
@@ -473,7 +516,7 @@ class TestTieringOffloadingManager:
516
for block in blocks:
517
self.secondary_tier1.blocks[block] = True
518
476
- def submit_partial(job_metadata: JobMetadata) -> None:
519
+ def submit_partial(job_metadata: TransferJob) -> None:
520
successful_keys = (
521
None
522
if successful_indices is None
@@ -500,7 +543,7 @@ class TestTieringOffloadingManager:
543
] == expected_results
544
545
def test_lookup_reports_sync_delay_for_resolved_lookups(self, manager_setup):
503
- """Resolved lookups report one sync delay sample on allocation."""
546
+ """Resolved lookups report one sync delay sample per tier and block."""
547
self._start_request()
548
blocks = to_keys(range(2))
549
@@ -508,22 +551,39 @@ class TestTieringOffloadingManager:
551
for block in blocks:
552
assert self.manager.lookup(block, _CTX) is LookupResult.MISS
553
511
- stats = self.manager.get_stats()
512
- if stats is not None:
513
- assert f"{TieringOffloadingMetrics.LOOKUP_SYNC_DELAY}_count" not in (
514
- stats.reduce()
515
- )
516
-
517
- self._simulate_on_schedule_end(new_req_ids=[_CTX.req_id])
518
-
554
stats = self.manager.get_stats()
555
assert stats is not None
556
reduced = stats.reduce()
522
- assert reduced[f"{TieringOffloadingMetrics.LOOKUP_SYNC_DELAY}_count"] == 1
523
- assert f"{TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY}_count" not in reduced
557
+ assert (
558
+ reduced[
559
+ histogram_count_key(
560
+ TieringOffloadingMetrics.LOOKUP_SYNC_DELAY, ("0:primary",)
561
+ )
562
+ ]
563
+ == 2
564
+ )
565
+ assert (
566
+ reduced[
567
+ histogram_count_key(
568
+ TieringOffloadingMetrics.LOOKUP_SYNC_DELAY, ("1:example",)
569
+ )
570
+ ]
571
+ == 2
572
+ )
573
+ assert (
574
+ reduced[
575
+ histogram_count_key(
576
+ TieringOffloadingMetrics.LOOKUP_SYNC_DELAY, ("2:example",)
577
+ )
578
+ ]
579
+ == 2
580
+ )
581
+ assert not has_histogram_count(
582
+ reduced, TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY
583
+ )
584
525
- def test_lookup_reports_async_delay_across_promotion(self, manager_setup):
526
- """A new request reports async delay at schedule end."""
585
+ def test_lookup_does_not_report_async_delay_for_promotion(self, manager_setup):
586
+ """Promotion time is not included in lookup async delay."""
587
self._start_request()
588
block = to_keys(range(1))[0]
589
self.secondary_tier1.blocks[block] = True
@@ -531,13 +591,30 @@ class TestTieringOffloadingManager:
591
# First lookup finds the block in a secondary tier and defers.
592
assert self.manager.lookup(block, _CTX) is LookupResult.RETRY
593
534
- # The first scheduler step reports async delay for the new request.
594
+ # Promotion is not treated as unresolved secondary lookup time.
595
self._simulate_on_schedule_end(new_req_ids=[_CTX.req_id])
596
stats = self.manager.get_stats()
597
assert stats is not None
598
reduced = stats.reduce()
539
- assert reduced[f"{TieringOffloadingMetrics.LOOKUP_SYNC_DELAY}_count"] == 1
540
- assert reduced[f"{TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY}_count"] == 1
599
+ assert (
600
+ reduced[
601
+ histogram_count_key(
602
+ TieringOffloadingMetrics.LOOKUP_SYNC_DELAY, ("0:primary",)
603
+ )
604
+ ]
605
+ == 1
606
+ )
607
+ assert (
608
+ reduced[
609
+ histogram_count_key(
610
+ TieringOffloadingMetrics.LOOKUP_SYNC_DELAY, ("1:example",)
611
+ )
612
+ ]
613
+ == 1
614
+ )
615
+ assert not has_histogram_count(
616
+ reduced, TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY
617
+ )
618
619
# Promotion completes on the next scheduler step.
620
self._simulate_on_schedule_end()
@@ -547,38 +624,51 @@ class TestTieringOffloadingManager:
624
625
stats = self.manager.get_stats()
626
if stats is not None:
550
- assert f"{TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY}_count" not in (
551
- stats.reduce()
627
+ assert not has_histogram_count(
628
+ stats.reduce(), TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY
629
)
630
554
- def test_lookup_reports_async_delay_on_request_finish(self, manager_setup):
555
- """Never-allocated lookup delays flush at teardown."""
631
+ def test_lookup_reports_async_delay_when_deferred_lookup_resolves(
632
+ self, manager_setup
633
+ ):
634
+ """Async delay is observed when a RETRY lookup later resolves."""
635
ctx = ReqContext(req_id="req_lookup_finish")
636
self._start_request(ctx)
637
block = to_keys(range(1))[0]
559
- self.secondary_tier1.blocks[block] = True
638
+ self.secondary_tier1.lookup = MagicMock(
639
+ side_effect=[LookupResult.RETRY, LookupResult.HIT]
640
+ )
641
561
- # Lookup finds the block in a secondary tier and defers.
642
+ # First lookup is deferred by the secondary tier.
643
assert self.manager.lookup(block, ctx) is LookupResult.RETRY
644
564
- self._simulate_on_schedule_end()
645
stats = self.manager.get_stats()
646
if stats is not None:
567
- assert f"{TieringOffloadingMetrics.LOOKUP_SYNC_DELAY}_count" not in (
568
- stats.reduce()
569
- )
570
- assert f"{TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY}_count" not in (
571
- stats.reduce()
647
+ assert not has_histogram_count(
648
+ stats.reduce(), TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY
649
)
650
574
- # Request finishes before the deferred lookup is ever resolved.
575
- self.manager.on_request_finished(ctx)
651
+ assert self.manager.lookup(block, ctx) is LookupResult.RETRY
652
653
stats = self.manager.get_stats()
654
assert stats is not None
655
reduced = stats.reduce()
580
- assert reduced[f"{TieringOffloadingMetrics.LOOKUP_SYNC_DELAY}_count"] == 1
581
- assert reduced[f"{TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY}_count"] == 1
656
+ assert (
657
+ reduced[
658
+ histogram_count_key(
659
+ TieringOffloadingMetrics.LOOKUP_SYNC_DELAY, ("1:example",)
660
+ )
661
+ ]
662
+ == 1
663
+ )
664
+ assert (
665
+ reduced[
666
+ histogram_count_key(
667
+ TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY, ("1:example",)
668
+ )
669
+ ]
670
+ == 1
671
+ )
672
673
def test_partial_lookup(self, manager_setup):
674
"""Test lookup with partial hits."""
@@ -938,9 +1028,7 @@ class TestTieringOffloadingManager:
1028
ctx = ReqContext(req_id="req_policy_lifecycle_2")
1029
result = self.manager.on_new_request(ctx)
1030
assert result.policy == OffloadPolicy.REQUEST_LEVEL
941
- assert self.manager._req_state[ctx.req_id].request_level_tiers == {
942
- self.secondary_tier1
943
- }
1031
+ assert self.manager._req_state[ctx.req_id].request_level_tiers == {0}
1032
1033
# Cleanup
1034
self.manager.on_request_finished(ctx)
@@ -996,13 +1084,13 @@ class TestTieringOffloadingManager:
1084
primary tier; pending submissions are dropped without being sent
1085
to the secondary tier. Active request state is retained."""
1086
# Cascade — populates primary blocks and leaves cascade jobs
999
- # in _transfer_jobs (the synchronous example tier has already
1087
+ # in _jobs (the synchronous example tier has already
1088
# queued completions); reset_cache's drain loop will pick them up.
1089
blocks = to_keys(range(3))
1090
self._start_request()
1091
self.manager.prepare_store(blocks, _CTX)
1092
self.manager.complete_store(blocks, _CTX, success=True)
1005
- assert self.manager._transfer_jobs
1093
+ assert self.manager._jobs
1094
1095
# Pending promotion submission (deferred — no on_schedule_end after
1096
# the lookup that staged it).
@@ -1020,9 +1108,7 @@ class TestTieringOffloadingManager:
1108
)
1109
rl_ctx = ReqContext(req_id="rl")
1110
self.manager.on_new_request(rl_ctx)
1023
- assert self.manager._req_state[rl_ctx.req_id].request_level_tiers == {
1024
- self.secondary_tier1
1025
- }
1111
+ assert self.manager._req_state[rl_ctx.req_id].request_level_tiers == {0}
1112
1113
# Mark this step as already polled (reset_cache must clear it).
1114
self.manager._processed_jobs_this_step = True
@@ -1035,7 +1121,7 @@ class TestTieringOffloadingManager:
1121
self.manager.reset_cache()
1122
1123
# Orchestrator state cleared.
1038
- assert self.manager._transfer_jobs == {}
1124
+ assert self.manager._jobs == {}
1125
assert self.manager._pending_load_submissions == {}
1126
assert set(self.manager._req_state) == {_CTX.req_id, rl_ctx.req_id}
1127
assert self.manager._processed_jobs_this_step is False
@@ -1063,18 +1149,18 @@ class TestTieringOffloadingManager:
1149
wraps=self.secondary_tier2.drain_jobs
1150
)
1151
1066
- # Drive a cascade so a job lands in _transfer_jobs.
1152
+ # Drive a cascade so a job lands in _jobs.
1153
blocks = to_keys(range(3))
1154
self._start_request()
1155
self.manager.prepare_store(blocks, _CTX)
1156
self.manager.complete_store(blocks, _CTX, success=True)
1071
- assert self.manager._transfer_jobs
1157
+ assert self.manager._jobs
1158
1159
self.manager.reset_cache()
1160
1161
self.secondary_tier1.drain_jobs.assert_called_once()
1162
self.secondary_tier2.drain_jobs.assert_called_once()
1077
- assert self.manager._transfer_jobs == {}
1163
+ assert self.manager._jobs == {}
1164
1165
@pytest.mark.parametrize(
1166
"load_tier_filter",
vllm/v1/kv_offload/tiering/base.py
+20
-4
@@ -39,10 +39,25 @@ class TieringOffloadingMetrics:
39
40
LOOKUP_SYNC_DELAY = "vllm:kv_offload_tiering_lookup_sync_delay_seconds"
41
LOOKUP_ASYNC_DELAY = "vllm:kv_offload_tiering_lookup_async_delay_seconds"
42
+ READ_BYTES = "vllm:kv_offload_tiering_read_bytes"
43
+ READ_TIME = "vllm:kv_offload_tiering_read_time"
44
+ WRITE_BYTES = "vllm:kv_offload_tiering_write_bytes"
45
+ WRITE_TIME = "vllm:kv_offload_tiering_write_time"
46
+ PROMOTION_JOB_FAILURES = "vllm:kv_offload_tiering_promotion_job_failures"
47
+ CASCADE_JOB_FAILURES = "vllm:kv_offload_tiering_cascade_job_failures"
48
+ BLOCK_QUERIES = "vllm:kv_offload_tiering_block_queries"
49
+ BLOCK_HITS = "vllm:kv_offload_tiering_block_hits"
50
+ PRIMARY_WRITE_USAGE_PERC = "vllm:kv_offload_tiering_primary_write_usage_perc"
51
+ PRIMARY_READ_USAGE_PERC = "vllm:kv_offload_tiering_primary_read_usage_perc"
52
+ PROMOTION_ALLOCATION_FAILURES = (
53
+ "vllm:kv_offload_tiering_promotion_allocation_failures"
54
+ )
55
+ ACTIVE_PROMOTION_JOBS = "vllm:kv_offload_tiering_active_promotion_jobs"
56
+ ACTIVE_CASCADE_JOBS = "vllm:kv_offload_tiering_active_cascade_jobs"
57
58
59
@dataclass
45
-class JobMetadata:
60
+class TransferJob:
61
"""Metadata for an in-flight async transfer job."""
62
63
job_id: JobId
@@ -63,6 +78,7 @@ class JobResult:
78
# keys that were successfully loaded. None means all keys share the fate
79
# indicated by `success`. Must be a subset of the job's original keys.
80
successful_keys: Collection[OffloadKey] | None = None
81
+ transfer_time: float | None = None
82
83
84
class ParentManager(ABC):
@@ -96,7 +112,7 @@ class ParentManager(ABC):
112
self,
113
keys: Collection[OffloadKey],
114
req_context: ReqContext,
99
- ) -> JobMetadata: ...
115
+ ) -> TransferJob: ...
116
117
@abstractmethod
118
def on_request_finished(self, req_context: ReqContext) -> None: ...
@@ -153,7 +169,7 @@ class SecondaryTierManager(ABC):
169
pass
170
171
@abstractmethod
156
- def submit_store(self, job_metadata: JobMetadata) -> None:
172
+ def submit_store(self, job_metadata: TransferJob) -> None:
173
"""
174
Submit an async job to store blocks from the primary tier to this
175
secondary tier.
@@ -181,7 +197,7 @@ class SecondaryTierManager(ABC):
197
pass
198
199
@abstractmethod
184
- def submit_load(self, job_metadata: JobMetadata) -> None:
200
+ def submit_load(self, job_metadata: TransferJob) -> None:
201
"""
202
Submit an async job to load blocks from this secondary tier to the
203
primary tier.
vllm/v1/kv_offload/tiering/example/manager.py
+19
-5
@@ -23,9 +23,9 @@ from vllm.v1.kv_offload.base import (
23
RequestOffloadingContext,
24
)
25
from vllm.v1.kv_offload.tiering.base import (
26
- JobMetadata,
26
JobResult,
27
SecondaryTierManager,
28
+ TransferJob,
29
)
30
31
logger = logging.getLogger(__name__)
@@ -73,6 +73,8 @@ class ExampleSecondaryTierManager(SecondaryTierManager):
73
74
# Completed jobs waiting to be retrieved by get_finished_jobs()
75
self.completed_jobs: list[JobResult] = []
76
+ assert primary_kv_view.strides is not None
77
+ self._block_size = primary_kv_view.strides[0]
78
79
@override
80
def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult:
@@ -89,7 +91,7 @@ class ExampleSecondaryTierManager(SecondaryTierManager):
91
return LookupResult.HIT if key in self.blocks else LookupResult.MISS
92
93
@override
92
- def submit_store(self, job_metadata: JobMetadata) -> None:
94
+ def submit_store(self, job_metadata: TransferJob) -> None:
95
"""
96
Submit a job to store blocks from primary tier to this tier.
97
@@ -106,10 +108,16 @@ class ExampleSecondaryTierManager(SecondaryTierManager):
108
109
for key in keys:
110
self.blocks[key] = True
109
- self.completed_jobs.append(JobResult(job_id=job_metadata.job_id, success=True))
111
+ self.completed_jobs.append(
112
+ JobResult(
113
+ job_id=job_metadata.job_id,
114
+ success=True,
115
+ transfer_time=0.0,
116
+ )
117
+ )
118
119
@override
112
- def submit_load(self, job_metadata: JobMetadata) -> None:
120
+ def submit_load(self, job_metadata: TransferJob) -> None:
121
"""
122
Submit a job to load blocks from this tier to primary tier.
123
@@ -131,7 +139,13 @@ class ExampleSecondaryTierManager(SecondaryTierManager):
139
)
140
return
141
134
- self.completed_jobs.append(JobResult(job_id=job_metadata.job_id, success=True))
142
+ self.completed_jobs.append(
143
+ JobResult(
144
+ job_id=job_metadata.job_id,
145
+ success=True,
146
+ transfer_time=0.0,
147
+ )
148
+ )
149
150
@override
151
def get_finished_jobs(self) -> Iterable[JobResult]:
vllm/v1/kv_offload/tiering/fs/manager.py
+16
-8
@@ -43,11 +43,11 @@ from vllm.v1.kv_offload.file_mapper import FileMapper
43
from vllm.v1.kv_offload.tiering.async_lookup import AsyncLookupManager
44
from vllm.v1.kv_offload.tiering.base import (
45
JobId,
46
- JobMetadata,
46
JobResult,
47
RequestOffloadingContext,
48
ScheduleEndContext,
49
SecondaryTierManager,
50
+ TransferJob,
51
)
52
from vllm.v1.kv_offload.tiering.fs.io import (
53
batch_load_block,
@@ -214,12 +214,13 @@ class FileSystemTierManager(SecondaryTierManager):
214
return LookupResult.HIT if result else LookupResult.MISS
215
216
@override
217
- def submit_store(self, job_metadata: JobMetadata) -> None:
217
+ def submit_store(self, job_metadata: TransferJob) -> None:
218
+ keys = list(job_metadata.keys)
219
if self.events is not None:
219
- self._store_job_keys[job_metadata.job_id] = list(job_metadata.keys)
220
+ self._store_job_keys[job_metadata.job_id] = keys
221
task = functools.partial(
222
batch_store_block,
222
- [self.file_mapper.get_file_name(key) for key in job_metadata.keys],
223
+ [self.file_mapper.get_file_name(key) for key in keys],
224
self._primary_kv_view,
225
[int(bid) * self._block_size for bid in job_metadata.block_ids],
226
self._block_size,
@@ -228,7 +229,7 @@ class FileSystemTierManager(SecondaryTierManager):
229
self._pool.enqueue_store(job_metadata.job_id, 1, [task])
230
231
@override
231
- def submit_load(self, job_metadata: JobMetadata) -> None:
232
+ def submit_load(self, job_metadata: TransferJob) -> None:
233
job_id = job_metadata.job_id
234
# Track this load's keys so a failed promotion can mark only its failed
235
# keys as a miss (see get_finished_jobs).
@@ -271,7 +272,7 @@ class FileSystemTierManager(SecondaryTierManager):
272
"""Collect finished jobs; a failed promotion marks only its failed keys
273
as a miss here (scheduler thread)."""
274
results = []
274
- for job_id, success in self._pool.get_finished():
275
+ for job_id, success, transfer_time in self._pool.get_finished():
276
if self.events is not None:
277
keys = self._store_job_keys.pop(job_id, None)
278
if success and keys:
@@ -298,10 +299,17 @@ class FileSystemTierManager(SecondaryTierManager):
299
job_id=job_id,
300
success=False,
301
successful_keys=tuple(successful) if successful else None,
302
+ transfer_time=transfer_time,
303
)
304
)
303
- else:
304
- results.append(JobResult(job_id=job_id, success=success))
305
+ continue
306
+ results.append(
307
+ JobResult(
308
+ job_id=job_id,
309
+ success=success,
310
+ transfer_time=transfer_time,
311
+ )
312
+ )
313
return results
314
315
@override
vllm/v1/kv_offload/tiering/fs/thread_pool.py
+25
-8
@@ -9,6 +9,7 @@ Thread pool:
9
"""
10
11
import threading
12
+import time
13
from collections import deque
14
from collections.abc import Callable, Iterable
15
@@ -25,26 +26,37 @@ class JobState:
26
Each task calls task_done(success) when it finishes.
27
"""
28
28
- __slots__ = ("_job_id", "_n_tasks", "_completed", "_success", "_lock")
29
+ __slots__ = (
30
+ "_job_id",
31
+ "_n_tasks",
32
+ "_completed",
33
+ "_success",
34
+ "_transfer_time",
35
+ "_lock",
36
+ )
37
38
def __init__(self, job_id: JobId, n_tasks: int) -> None:
39
self._job_id: JobId = job_id
40
self._n_tasks = n_tasks
41
self._completed = 0
42
self._success = True
43
+ self._transfer_time = 0.0
44
self._lock = threading.Lock()
45
46
@property
47
def job_id(self) -> JobId:
48
return self._job_id
49
41
- def task_done(self, success: bool) -> tuple[bool, bool]:
50
+ def task_done(
51
+ self, success: bool, transfer_time: float
52
+ ) -> tuple[bool, bool, float]:
53
"""Returns if job completed and success flag"""
54
with self._lock:
55
self._completed += 1
56
+ self._transfer_time += transfer_time
57
if not success:
58
self._success = False
47
- return self._completed == self._n_tasks, self._success
59
+ return self._completed == self._n_tasks, self._success, self._transfer_time
60
61
62
class DualQueueThreadPool:
@@ -67,7 +79,7 @@ class DualQueueThreadPool:
79
self._condition = threading.Condition(threading.Lock())
80
self._stop = False
81
self._threads: list[threading.Thread] = []
70
- self._finished_q: deque[tuple[JobId, bool]] = deque()
82
+ self._finished_q: deque[tuple[JobId, bool, float]] = deque()
83
self._inflight_jobs = 0 # guarded by _condition
84
85
for i in range(n_read_threads):
@@ -118,7 +130,7 @@ class DualQueueThreadPool:
130
self._store_q.append((fn, state))
131
self._condition.notify(n_tasks)
132
121
- def get_finished(self) -> list[tuple[JobId, bool]]:
133
+ def get_finished(self) -> list[tuple[JobId, bool, float]]:
134
# No lock needed: deque is thread-safe for concurrent append/popleft,
135
# and the manager is the sole popper.
136
jobs = []
@@ -163,18 +175,23 @@ class DualQueueThreadPool:
175
secondary = self._store_q if load_priority else self._load_q
176
task, state = primary.popleft() if primary else secondary.popleft()
177
try:
178
+ start_time = time.monotonic()
179
task()
167
- job_finished, success = state.task_done(True)
180
+ transfer_time = time.monotonic() - start_time
181
+ job_finished, success, total_time = state.task_done(True, transfer_time)
182
except Exception as exc:
183
+ transfer_time = time.monotonic() - start_time
184
logger.error(
185
"Job %s block I/O failed: %s",
186
state.job_id,
187
exc,
188
)
174
- job_finished, success = state.task_done(False)
189
+ job_finished, success, total_time = state.task_done(
190
+ False, transfer_time
191
+ )
192
193
if job_finished:
194
with self._condition:
178
- self._finished_q.append((state.job_id, success))
195
+ self._finished_q.append((state.job_id, success, total_time))
196
self._inflight_jobs -= 1
197
self._condition.notify_all()
vllm/v1/kv_offload/tiering/manager.py
+132
-119
@@ -23,6 +23,7 @@ Key Design Principles:
23
import time
24
from collections.abc import Collection, Iterable, Sequence
25
from dataclasses import dataclass, field
26
+from typing import NamedTuple
27
28
import numpy as np
29
from typing_extensions import override
@@ -48,12 +49,12 @@ from vllm.v1.kv_offload.cpu.manager import CPUOffloadingManager
49
from vllm.v1.kv_offload.cpu.shared_offload_region import SharedOffloadRegion
50
from vllm.v1.kv_offload.tiering.base import (
51
JobId,
51
- JobMetadata,
52
JobResult,
53
ParentManager,
54
SecondaryTierManager,
55
- TieringOffloadingMetrics,
55
+ TransferJob,
56
)
57
+from vllm.v1.kv_offload.tiering.metrics import TieringMetricsTracker
58
59
logger = init_logger(__name__)
60
@@ -72,11 +73,12 @@ class RequestState:
73
req_context: ReqContext
74
pending_primary_stores: int = 0
75
is_finished: bool = False
75
- request_level_tiers: set[SecondaryTierManager] | None = None
76
- sync_lookup_delay: float = 0.0
77
- # time.monotonic() of this request's first deferred secondary-tier lookup;
78
- # None once consumed (observed) or while no secondary lookup is pending.
79
- secondary_lookup_start_time: float | None = None
76
+ request_level_tiers: set[int] | None = None
77
+
78
+
79
+class JobMetadata(NamedTuple):
80
+ transfer_job: TransferJob
81
+ tier_idx: int
82
83
84
class CPUPrimaryTierOffloadingManager(CPUOffloadingManager):
@@ -132,31 +134,33 @@ class CPUPrimaryTierOffloadingManager(CPUOffloadingManager):
134
135
class _SecondaryTierFacingParent(ParentManager):
136
"""Wrapper that implements ParentManager by delegating to the
135
- TieringOffloadingManager with exclude_tier set to the origin tier."""
137
+ TieringOffloadingManager with exclude_tier_idx set to the origin tier."""
138
137
- __slots__ = ("_m", "_origin")
139
+ __slots__ = ("_m", "_origin_idx")
140
141
def __init__(
142
self,
143
manager: "TieringOffloadingManager",
142
- tier: SecondaryTierManager,
144
+ tier_idx: int,
145
):
146
self._m = manager
145
- self._origin = tier
147
+ self._origin_idx = tier_idx
148
149
def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext:
148
- return self._m.on_new_request(req_context, exclude_tier=self._origin)
150
+ return self._m.on_new_request(req_context, exclude_tier_idx=self._origin_idx)
151
152
def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult:
151
- return self._m.lookup(key, req_context, exclude_tier=self._origin)
153
+ return self._m.lookup(key, req_context, exclude_tier_idx=self._origin_idx)
154
155
def create_store_job(
156
self, keys: Collection[OffloadKey], req_context: ReqContext
155
- ) -> JobMetadata:
156
- return self._m.create_store_job(keys, req_context)
157
+ ) -> TransferJob:
158
+ return self._m.create_store_job(keys, req_context, self._origin_idx)
159
160
def on_request_finished(self, req_context: ReqContext) -> None:
159
- return self._m.on_request_finished(req_context, exclude_tier=self._origin)
161
+ return self._m.on_request_finished(
162
+ req_context, exclude_tier_idx=self._origin_idx
163
+ )
164
165
166
class TieringOffloadingManager(OffloadingManager):
@@ -193,18 +197,23 @@ class TieringOffloadingManager(OffloadingManager):
197
198
self._job_id_counter: int = 0
199
# Job tracking: maps job_id to metadata for all in-flight transfers.
196
- # JobMetadata.is_promotion distinguishes direction:
200
+ # TransferJob.is_promotion distinguishes direction:
201
# True: secondary → primary (promotion)
202
# False: primary → secondary (cascade)
199
- self._transfer_jobs: dict[JobId, JobMetadata] = {}
203
+ self._jobs: dict[JobId, JobMetadata] = {}
204
+ primary_view = self.primary_tier.get_kv_memoryview()
205
+ assert primary_view.strides is not None
206
+ self._metrics = TieringMetricsTracker(
207
+ tier_types=[tier.tier_type for tier in self.secondary_tiers],
208
+ num_primary_blocks=self.primary_tier._num_blocks,
209
+ primary_block_size=primary_view.strides[0],
210
+ )
211
212
# Pending promotion requests accumulated during lookup() calls; flushed
213
# as one batched submit_load() per (tier, request) in on_schedule_end().
203
- # Outer key: tier. Inner key: req_context.req_id — the same ReqContext
214
+ # Outer key: tier index. Inner key: req_context.req_id — the same ReqContext
215
# object is reused for all block lookups of a given request per engine step.
205
- self._pending_load_submissions: dict[
206
- SecondaryTierManager, dict[str, PendingPromotion]
207
- ] = {}
216
+ self._pending_load_submissions: dict[int, dict[str, PendingPromotion]] = {}
217
218
# Gate for once-per-step execution of _maybe_process_finished_jobs().
219
# Reset at the end of each step in on_schedule_end().
@@ -217,13 +226,13 @@ class TieringOffloadingManager(OffloadingManager):
226
227
# Cached ParentManager wrappers for each secondary tier.
228
self._tier_parents: dict[SecondaryTierManager, _SecondaryTierFacingParent] = {
220
- tier: _SecondaryTierFacingParent(self, tier)
221
- for tier in self.secondary_tiers
229
+ tier: _SecondaryTierFacingParent(self, tier_idx)
230
+ for tier_idx, tier in enumerate(self.secondary_tiers)
231
}
232
224
- # Buffers manager-level observations (e.g. lookup delay) between
225
- # get_stats() calls; merged in and reset each time get_stats() runs.
226
- self._stats = OffloadingConnectorStats()
233
+ @property
234
+ def _transfer_jobs(self) -> dict[JobId, JobMetadata]:
235
+ return self._jobs
236
237
def _next_job_id(self) -> JobId:
238
"""Generate a unique job ID for async transfer tracking."""
@@ -231,6 +240,14 @@ class TieringOffloadingManager(OffloadingManager):
240
self._job_id_counter += 1
241
return job_id
242
243
+ def _register_job(self, transfer_job: TransferJob, tier_idx: int) -> None:
244
+ job_metadata = JobMetadata(transfer_job, tier_idx)
245
+ self._jobs[transfer_job.job_id] = job_metadata
246
+ self._metrics.on_job_registered(job_metadata)
247
+
248
+ def _pop_job(self, job_id: JobId) -> JobMetadata | None:
249
+ return self._jobs.pop(job_id, None)
250
+
251
def _maybe_process_finished_jobs(self):
252
"""
253
Poll secondary tiers for completed jobs (at most once per step).
@@ -247,13 +264,14 @@ class TieringOffloadingManager(OffloadingManager):
264
def _complete_promotion(
265
self, job_metadata: JobMetadata, completed_job: JobResult
266
) -> None:
267
+ transfer_job = job_metadata.transfer_job
268
successful_keys = completed_job.successful_keys
269
failed_keys: Collection[OffloadKey]
270
if completed_job.success:
253
- successful_keys = job_metadata.keys
271
+ successful_keys = transfer_job.keys
272
failed_keys = ()
273
elif successful_keys:
256
- failed_keys_set = set(job_metadata.keys)
274
+ failed_keys_set = set(transfer_job.keys)
275
assert failed_keys_set.issuperset(successful_keys), (
276
f"Finished promotion job_id {completed_job.job_id} "
277
"reported unknown successful keys"
@@ -262,18 +280,18 @@ class TieringOffloadingManager(OffloadingManager):
280
failed_keys = failed_keys_set
281
else:
282
successful_keys = ()
265
- failed_keys = job_metadata.keys
283
+ failed_keys = transfer_job.keys
284
285
if successful_keys:
286
self.primary_tier.complete_write(
287
successful_keys,
270
- job_metadata.req_context,
288
+ transfer_job.req_context,
289
True,
290
)
291
if failed_keys:
292
self.primary_tier.complete_write(
293
failed_keys,
276
- job_metadata.req_context,
294
+ transfer_job.req_context,
295
False,
296
)
297
@@ -291,13 +309,19 @@ class TieringOffloadingManager(OffloadingManager):
309
for i, tier in enumerate(self.secondary_tiers):
310
for completed_job in tier.get_finished_jobs():
311
job_id = completed_job.job_id
294
- job_metadata = self._transfer_jobs.pop(job_id, None)
312
+ job_metadata = self._pop_job(job_id)
313
assert job_metadata is not None, (
314
f"Finished job_id {job_id} from tier #{i}"
297
- f" ({tier.tier_type}) not in _transfer_jobs"
315
+ f" ({tier.tier_type}) not in _jobs"
316
)
317
+ assert job_metadata.tier_idx == i, (
318
+ f"Finished job_id {job_id} reported by tier #{i}"
319
+ f" but belongs to tier #{job_metadata.tier_idx}"
320
+ )
321
+ transfer_job = job_metadata.transfer_job
322
+ self._metrics.on_job_finished(job_metadata, completed_job)
323
300
- if job_metadata.is_promotion:
324
+ if transfer_job.is_promotion:
325
# secondary→primary transfer (promotion) completed.
326
# Make blocks available in primary tier.
327
self._complete_promotion(job_metadata, completed_job)
@@ -305,7 +329,7 @@ class TieringOffloadingManager(OffloadingManager):
329
# primary→secondary transfer completed.
330
# Decrement ref_cnt on primary blocks.
331
self.primary_tier.complete_read(
308
- job_metadata.keys, job_metadata.req_context
332
+ transfer_job.keys, transfer_job.req_context
333
)
334
335
@override
@@ -314,7 +338,7 @@ class TieringOffloadingManager(OffloadingManager):
338
key: OffloadKey,
339
req_context: ReqContext,
340
*,
317
- exclude_tier: SecondaryTierManager | None = None,
341
+ exclude_tier_idx: int | None = None,
342
) -> LookupResult:
343
"""
344
Check whether a single block is offloaded and ready.
@@ -343,73 +367,58 @@ class TieringOffloadingManager(OffloadingManager):
367
# in time for a promotion this lookup may initiate.
368
self._maybe_process_finished_jobs()
369
346
- req_state = self._req_state.get(req_context.req_id)
347
-
370
+ start_time = time.monotonic()
371
primary_hit = self.primary_tier.lookup(key, req_context)
372
+ lookup_duration = time.monotonic() - start_time
373
+ self._metrics.on_lookup(
374
+ req_context,
375
+ key,
376
+ self._metrics.primary_tier_label,
377
+ primary_hit,
378
+ lookup_duration,
379
+ )
380
if primary_hit is LookupResult.HIT:
381
return LookupResult.HIT
382
if primary_hit is LookupResult.HIT_PENDING:
383
return LookupResult.HIT_PENDING
384
354
- lookup_start = time.monotonic()
385
any_retry = False
356
- for tier in self.secondary_tiers:
357
- if tier is exclude_tier:
386
+ for i, tier in enumerate(self.secondary_tiers):
387
+ if i == exclude_tier_idx:
388
continue
389
if not req_context.load_tier_filter.allows(tier.medium, tier.locality):
390
continue
391
+ labelvalues = self._metrics.tier_label(i)
392
+ start_time = time.monotonic()
393
result = tier.lookup(key, req_context)
394
+ lookup_duration = time.monotonic() - start_time
395
if result is LookupResult.HIT:
363
- promoted = self._initiate_promotion(tier, key, req_context)
364
- self._accumulate_lookup_sync_delay(req_state, lookup_start)
365
- if (
366
- req_state is not None
367
- and promoted
368
- and req_state.secondary_lookup_start_time is None
369
- ):
370
- req_state.secondary_lookup_start_time = lookup_start
396
+ self._metrics.on_lookup(
397
+ req_context,
398
+ key,
399
+ labelvalues,
400
+ result,
401
+ lookup_duration,
402
+ )
403
+ promoted = self._initiate_promotion(i, key, req_context)
404
return LookupResult.MISS if not promoted else LookupResult.RETRY
405
if result is LookupResult.RETRY:
406
any_retry = True
407
+ self._metrics.on_lookup(
408
+ req_context,
409
+ key,
410
+ labelvalues,
411
+ result,
412
+ lookup_duration,
413
+ )
414
375
- self._accumulate_lookup_sync_delay(req_state, lookup_start)
415
if any_retry:
377
- if req_state is not None and req_state.secondary_lookup_start_time is None:
378
- req_state.secondary_lookup_start_time = lookup_start
416
return LookupResult.RETRY
417
return LookupResult.MISS
418
382
- def _accumulate_lookup_sync_delay(
383
- self, req_state: RequestState | None, start_time: float
384
- ) -> None:
385
- """Accumulate secondary-tier lookup time until allocation or finish."""
386
- if req_state is not None:
387
- req_state.sync_lookup_delay += time.monotonic() - start_time
388
-
389
- def _maybe_observe_lookup_sync_delay(self, req_state: RequestState) -> None:
390
- delay = req_state.sync_lookup_delay
391
- if delay == 0:
392
- return
393
- req_state.sync_lookup_delay = 0.0
394
- self._stats.observe_histogram(
395
- TieringOffloadingMetrics.LOOKUP_SYNC_DELAY,
396
- delay,
397
- )
398
-
399
- def _maybe_observe_lookup_async_delay(self, req_state: RequestState) -> None:
400
- """Flush a pending deferred secondary-tier lookup timer, if any."""
401
- start_time = req_state.secondary_lookup_start_time
402
- if start_time is None:
403
- return
404
- req_state.secondary_lookup_start_time = None
405
- self._stats.observe_histogram(
406
- TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY,
407
- time.monotonic() - start_time,
408
- )
409
-
419
def _initiate_promotion(
420
self,
412
- tier: SecondaryTierManager,
421
+ tier_idx: int,
422
key: OffloadKey,
423
req_context: ReqContext,
424
) -> bool:
@@ -423,7 +432,7 @@ class TieringOffloadingManager(OffloadingManager):
432
batched job.
433
434
Args:
426
- tier: The secondary tier to promote from
435
+ tier_idx: The secondary tier index to promote from
436
key: Block to promote
437
req_context: Per-request context forwarded to primary.prepare_write().
438
@@ -439,13 +448,14 @@ class TieringOffloadingManager(OffloadingManager):
448
if primary_write_result is None:
449
# Primary tier is full; caller should treat the block as unavailable
450
# rather than retrying indefinitely.
451
+ self._metrics.on_promotion_allocation_failure()
452
return False
453
454
store_spec = primary_write_result.store_spec
455
assert isinstance(store_spec, CPULoadStoreSpec)
456
# Defer submit_load to on_schedule_end(). Group by (tier, request) so
457
# each request's blocks are submitted as one batched job per tier.
448
- tier_pending = self._pending_load_submissions.setdefault(tier, {})
458
+ tier_pending = self._pending_load_submissions.setdefault(tier_idx, {})
459
ctx_id = req_context.req_id
460
if ctx_id not in tier_pending:
461
tier_pending[ctx_id] = PendingPromotion(
@@ -465,17 +475,18 @@ class TieringOffloadingManager(OffloadingManager):
475
if not self._pending_load_submissions:
476
return
477
468
- for tier, pending_by_ctx in self._pending_load_submissions.items():
478
+ for tier_idx, pending_by_ctx in self._pending_load_submissions.items():
479
+ tier = self.secondary_tiers[tier_idx]
480
for entry in pending_by_ctx.values():
481
job_id = self._next_job_id()
471
- job_metadata = JobMetadata(
482
+ job_metadata = TransferJob(
483
job_id=job_id,
484
keys=entry.keys,
485
block_ids=np.array(entry.block_ids, dtype=np.int64),
486
is_promotion=True,
487
req_context=entry.req_context,
488
)
478
- self._transfer_jobs[job_id] = job_metadata
489
+ self._register_job(job_metadata, tier_idx)
490
tier.submit_load(job_metadata)
491
492
self._pending_load_submissions.clear()
@@ -594,7 +605,7 @@ class TieringOffloadingManager(OffloadingManager):
605
self,
606
keys: Sequence[OffloadKey],
607
req_context: ReqContext,
597
- request_level_tiers: set[SecondaryTierManager],
608
+ request_level_tiers: set[int],
609
) -> None:
610
"""
611
For tiers that requested request-level policy, submit_store() for
@@ -609,8 +620,9 @@ class TieringOffloadingManager(OffloadingManager):
620
if not ready_keys:
621
return
622
612
- for tier in request_level_tiers:
613
- job_metadata = self.create_store_job(ready_keys, req_context)
623
+ for tier_idx in request_level_tiers:
624
+ job_metadata = self.create_store_job(ready_keys, req_context, tier_idx)
625
+ tier = self.secondary_tiers[tier_idx]
626
tier.submit_store(job_metadata)
627
628
@override
@@ -647,8 +659,8 @@ class TieringOffloadingManager(OffloadingManager):
659
# LoadStoreSpec AND to increment ref_cnt (protecting blocks from
660
# eviction during the async transfer). One prepare_read() call per
661
# secondary tier.
650
- for tier in self.secondary_tiers:
651
- job_metadata = self.create_store_job(keys, req_context)
662
+ for tier_idx, tier in enumerate(self.secondary_tiers):
663
+ job_metadata = self.create_store_job(keys, req_context, tier_idx)
664
tier.submit_store(job_metadata)
665
666
# Note: The async transfers are now in flight. Their completion is
@@ -663,12 +675,13 @@ class TieringOffloadingManager(OffloadingManager):
675
self,
676
keys: Collection[OffloadKey],
677
req_context: ReqContext,
666
- ) -> JobMetadata:
678
+ tier_idx: int = 0,
679
+ ) -> TransferJob:
680
"""Pin blocks in the primary tier and create a tracked store job.
681
682
Calls prepare_read() to increment ref_cnt (protecting blocks
683
from eviction during the async transfer), allocates a job ID,
671
- and registers the job in _transfer_jobs.
684
+ and registers the job in _jobs.
685
686
The caller is responsible for the actual data transfer and
687
reporting completion via get_finished_jobs().
@@ -676,14 +689,14 @@ class TieringOffloadingManager(OffloadingManager):
689
primary_blocks_spec = self.primary_tier.prepare_read(keys, req_context)
690
assert isinstance(primary_blocks_spec, CPULoadStoreSpec)
691
job_id = self._next_job_id()
679
- job_metadata = JobMetadata(
692
+ job_metadata = TransferJob(
693
job_id=job_id,
694
keys=keys,
695
block_ids=primary_blocks_spec.block_ids,
696
is_promotion=False,
697
req_context=req_context,
698
)
686
- self._transfer_jobs[job_id] = job_metadata
699
+ self._register_job(job_metadata, tier_idx)
700
return job_metadata
701
702
@override
@@ -691,7 +704,7 @@ class TieringOffloadingManager(OffloadingManager):
704
self,
705
req_context: ReqContext,
706
*,
694
- exclude_tier: SecondaryTierManager | None = None,
707
+ exclude_tier_idx: int | None = None,
708
) -> RequestOffloadingContext:
709
"""
710
Query each secondary tier for its offload policy preference.
@@ -700,14 +713,15 @@ class TieringOffloadingManager(OffloadingManager):
713
Only stores REQUEST_LEVEL tier decisions for use in prepare_store.
714
"""
715
state = RequestState(req_context=req_context)
703
- for tier in self.secondary_tiers:
704
- if tier is exclude_tier:
716
+ self._metrics.on_new_request(req_context)
717
+ for tier_idx, tier in enumerate(self.secondary_tiers):
718
+ if tier_idx == exclude_tier_idx:
719
continue
720
tier_ctx = tier.on_new_request(req_context)
721
if tier_ctx.policy == OffloadPolicy.REQUEST_LEVEL:
722
if state.request_level_tiers is None:
723
state.request_level_tiers = set()
710
- state.request_level_tiers.add(tier)
724
+ state.request_level_tiers.add(tier_idx)
725
self._req_state[req_context.req_id] = state
726
727
policy = (
@@ -722,17 +736,17 @@ class TieringOffloadingManager(OffloadingManager):
736
self,
737
req_context: ReqContext,
738
*,
725
- exclude_tier: SecondaryTierManager | None = None,
739
+ exclude_tier_idx: int | None = None,
740
) -> None:
741
self.primary_tier.on_request_finished(req_context)
742
state = self._req_state[req_context.req_id]
743
state.is_finished = True
730
- self._maybe_finalize_request(req_context.req_id, exclude_tier)
744
+ self._maybe_finalize_request(req_context.req_id, exclude_tier_idx)
745
746
def _maybe_finalize_request(
747
self,
748
req_id: str,
735
- exclude_tier: SecondaryTierManager | None = None,
749
+ exclude_tier_idx: int | None = None,
750
) -> None:
751
"""Finalize secondary tiers once no more store cascades can be submitted.
752
@@ -746,12 +760,11 @@ class TieringOffloadingManager(OffloadingManager):
760
if state.pending_primary_stores != 0:
761
return
762
749
- for tier in self.secondary_tiers:
750
- if tier is exclude_tier:
763
+ for tier_idx, tier in enumerate(self.secondary_tiers):
764
+ if tier_idx == exclude_tier_idx:
765
continue
766
tier.on_request_finished(state.req_context)
753
- self._maybe_observe_lookup_sync_delay(state)
754
- self._maybe_observe_lookup_async_delay(state)
767
+ self._metrics.on_request_finished(state.req_context)
768
del self._req_state[req_id]
769
770
@override
@@ -782,15 +795,14 @@ class TieringOffloadingManager(OffloadingManager):
795
state = self._req_state.get(req_id)
796
if state is None:
797
continue
785
- self._maybe_observe_lookup_sync_delay(state)
786
- self._maybe_observe_lookup_async_delay(state)
798
+ self._metrics.on_request_allocated(state.req_context)
799
800
@override
801
def has_pending_work(self) -> bool:
802
# In-flight primary<->secondary transfers (pending promotions are
803
# translated to transfer jobs in on_schedule_end), plus any work the
804
# secondary tiers themselves still have outstanding.
793
- return bool(self._transfer_jobs) or any(
805
+ return bool(self._jobs) or any(
806
tier.has_pending_work() for tier in self.secondary_tiers
807
)
808
@@ -825,11 +837,13 @@ class TieringOffloadingManager(OffloadingManager):
837
# All tier I/O has stopped; consume their completion notifications
838
# so manager bookkeeping is consistent before the primary reset.
839
self._process_finished_jobs()
840
+ assert not self._jobs
841
842
# Deferred promotion submissions reserve primary slots that the
843
# reset below invalidates; their submit_load() has not yet been
844
# called so no tier I/O is touching that memory.
845
self._pending_load_submissions.clear()
846
+ self._metrics.assert_idle()
847
848
finished_req_ids = []
849
for req_id, state in self._req_state.items():
@@ -838,8 +852,7 @@ class TieringOffloadingManager(OffloadingManager):
852
continue
853
for tier in self.secondary_tiers:
854
tier.on_request_finished(state.req_context)
841
- self._maybe_observe_lookup_sync_delay(state)
842
- self._maybe_observe_lookup_async_delay(state)
855
+ self._metrics.on_request_finished(state.req_context)
856
finished_req_ids.append(req_id)
857
858
self.primary_tier.reset_cache()
@@ -855,6 +868,13 @@ class TieringOffloadingManager(OffloadingManager):
868
if stats is not None and stats.is_empty():
869
stats = None
870
871
+ metrics_stats = self._metrics.take_stats()
872
+ if metrics_stats is not None:
873
+ if stats is None:
874
+ stats = metrics_stats
875
+ else:
876
+ stats.aggregate(metrics_stats)
877
+
878
for tier in self.secondary_tiers:
879
tier_stats = tier.get_stats()
880
if tier_stats is None or tier_stats.is_empty():
@@ -864,13 +884,6 @@ class TieringOffloadingManager(OffloadingManager):
884
else:
885
stats.aggregate(tier_stats)
886
867
- if not self._stats.is_empty():
868
- if stats is None:
869
- stats = self._stats
870
- else:
871
- stats.aggregate(self._stats)
872
- self._stats = OffloadingConnectorStats()
873
-
887
return stats
888
889
@override
vllm/v1/kv_offload/tiering/metrics.py
new
+269
@@ -0,0 +1,269 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import functools
5
+import time
6
+from dataclasses import dataclass, field
7
+from typing import Protocol
8
+
9
+from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
10
+ OffloadingConnectorStats,
11
+)
12
+from vllm.v1.kv_offload.base import LookupResult, OffloadKey, ReqContext
13
+from vllm.v1.kv_offload.tiering.base import (
14
+ JobResult,
15
+ TieringOffloadingMetrics,
16
+ TransferJob,
17
+)
18
+
19
+TierLabel = tuple[str]
20
+PRIMARY_TIER_LABEL: TierLabel = ("0:primary",)
21
+
22
+
23
+class _JobMetadataLike(Protocol):
24
+ @property
25
+ def transfer_job(self) -> TransferJob: ...
26
+
27
+ @property
28
+ def tier_idx(self) -> int: ...
29
+
30
+
31
+@dataclass(slots=True)
32
+class _RequestMetricsState:
33
+ observed_lookups: dict[TierLabel, dict[OffloadKey, float | None]] | None = field(
34
+ default_factory=dict
35
+ )
36
+
37
+
38
+@dataclass
39
+class _TierState:
40
+ active_promotion_count: int = 0
41
+ active_cascade_count: int = 0
42
+ primary_write_block_count: int = 0
43
+ primary_read_block_count: int = 0
44
+
45
+
46
+class TieringMetricsTracker:
47
+ def __init__(
48
+ self,
49
+ tier_types: list[str],
50
+ num_primary_blocks: int,
51
+ primary_block_size: int,
52
+ ) -> None:
53
+ self._tier_types = tier_types
54
+ self._num_primary_blocks = num_primary_blocks
55
+ self._primary_block_size = primary_block_size
56
+ self._request_states: dict[str, _RequestMetricsState] = {}
57
+ self._tier_states = [_TierState() for _ in tier_types]
58
+ self._stats = OffloadingConnectorStats()
59
+
60
+ @functools.cache # noqa: B019
61
+ def tier_label(self, tier_idx: int) -> TierLabel:
62
+ return (f"{tier_idx + 1}:{self._tier_types[tier_idx]}",)
63
+
64
+ @property
65
+ def primary_tier_label(self) -> TierLabel:
66
+ return PRIMARY_TIER_LABEL
67
+
68
+ def on_new_request(self, req_context: ReqContext) -> None:
69
+ self._request_states[req_context.req_id] = _RequestMetricsState()
70
+
71
+ def on_request_allocated(self, req_context: ReqContext) -> None:
72
+ state = self._request_states.get(req_context.req_id)
73
+ if state is None:
74
+ return
75
+ state.observed_lookups = None
76
+
77
+ def on_request_finished(self, req_context: ReqContext) -> None:
78
+ self._request_states.pop(req_context.req_id, None)
79
+
80
+ def on_lookup(
81
+ self,
82
+ req_context: ReqContext,
83
+ key: OffloadKey,
84
+ tier_label: TierLabel,
85
+ result: LookupResult,
86
+ lookup_duration: float,
87
+ ) -> None:
88
+ state = self._request_states.get(req_context.req_id)
89
+ if state is None:
90
+ state = _RequestMetricsState()
91
+ self._request_states[req_context.req_id] = state
92
+
93
+ if state.observed_lookups is not None and result in (
94
+ LookupResult.HIT,
95
+ LookupResult.MISS,
96
+ ):
97
+ observed = state.observed_lookups.setdefault(tier_label, {})
98
+ start_time = observed.get(key)
99
+ if key not in observed or start_time is not None:
100
+ observed[key] = None
101
+ self._observe_resolved_lookup(
102
+ tier_label,
103
+ result,
104
+ lookup_duration,
105
+ start_time,
106
+ )
107
+ elif result is LookupResult.RETRY and state.observed_lookups is not None:
108
+ observed = state.observed_lookups.setdefault(tier_label, {})
109
+ observed.setdefault(key, time.monotonic() - lookup_duration)
110
+
111
+ def on_job_registered(self, job_metadata: _JobMetadataLike) -> None:
112
+ transfer_job = job_metadata.transfer_job
113
+ state = self._tier_states[job_metadata.tier_idx]
114
+ block_count = len(transfer_job.block_ids)
115
+ if transfer_job.is_promotion:
116
+ state.active_promotion_count += 1
117
+ state.primary_write_block_count += block_count
118
+ else:
119
+ state.active_cascade_count += 1
120
+ state.primary_read_block_count += block_count
121
+
122
+ def on_job_finished(
123
+ self, job_metadata: _JobMetadataLike, result: JobResult
124
+ ) -> None:
125
+ self._observe_finished_job_stats(job_metadata, result)
126
+ self._decrement_tier_state(job_metadata)
127
+
128
+ def on_promotion_allocation_failure(self) -> None:
129
+ self._stats.increase_counter(
130
+ TieringOffloadingMetrics.PROMOTION_ALLOCATION_FAILURES
131
+ )
132
+
133
+ def take_stats(self) -> OffloadingConnectorStats | None:
134
+ active_transfer_stats = OffloadingConnectorStats()
135
+ self._observe_active_transfer_stats(active_transfer_stats)
136
+
137
+ stats = None
138
+ if not active_transfer_stats.is_empty():
139
+ stats = active_transfer_stats
140
+ if not self._stats.is_empty():
141
+ if stats is None:
142
+ stats = self._stats
143
+ else:
144
+ stats.aggregate(self._stats)
145
+ self._stats = OffloadingConnectorStats()
146
+ return stats
147
+
148
+ def assert_idle(self) -> None:
149
+ assert all(
150
+ state.active_promotion_count == 0
151
+ and state.active_cascade_count == 0
152
+ and state.primary_write_block_count == 0
153
+ and state.primary_read_block_count == 0
154
+ for state in self._tier_states
155
+ )
156
+
157
+ def _decrement_tier_state(self, job_metadata: _JobMetadataLike) -> None:
158
+ transfer_job = job_metadata.transfer_job
159
+ state = self._tier_states[job_metadata.tier_idx]
160
+ block_count = len(transfer_job.block_ids)
161
+ if transfer_job.is_promotion:
162
+ assert state.active_promotion_count > 0
163
+ state.active_promotion_count -= 1
164
+ state.primary_write_block_count -= block_count
165
+ assert state.primary_write_block_count >= 0
166
+ else:
167
+ assert state.active_cascade_count > 0
168
+ state.active_cascade_count -= 1
169
+ state.primary_read_block_count -= block_count
170
+ assert state.primary_read_block_count >= 0
171
+
172
+ def _observe_finished_job_stats(
173
+ self,
174
+ job_metadata: _JobMetadataLike,
175
+ completed_job: JobResult,
176
+ ) -> None:
177
+ transfer_job = job_metadata.transfer_job
178
+ labelvalues = self.tier_label(job_metadata.tier_idx)
179
+ completed_key_count = len(transfer_job.keys)
180
+ if not completed_job.success:
181
+ failure_metric = (
182
+ TieringOffloadingMetrics.PROMOTION_JOB_FAILURES
183
+ if transfer_job.is_promotion
184
+ else TieringOffloadingMetrics.CASCADE_JOB_FAILURES
185
+ )
186
+ self._stats.increase_counter(failure_metric, labelvalues=labelvalues)
187
+ if transfer_job.is_promotion and completed_job.successful_keys:
188
+ completed_key_count = len(completed_job.successful_keys)
189
+ else:
190
+ return
191
+
192
+ bytes_metric = (
193
+ TieringOffloadingMetrics.READ_BYTES
194
+ if transfer_job.is_promotion
195
+ else TieringOffloadingMetrics.WRITE_BYTES
196
+ )
197
+ time_metric = (
198
+ TieringOffloadingMetrics.READ_TIME
199
+ if transfer_job.is_promotion
200
+ else TieringOffloadingMetrics.WRITE_TIME
201
+ )
202
+ transfer_size = completed_key_count * self._primary_block_size
203
+ self._stats.increase_counter(bytes_metric, transfer_size, labelvalues)
204
+ if completed_job.transfer_time is not None:
205
+ self._stats.increase_counter(
206
+ time_metric, completed_job.transfer_time, labelvalues
207
+ )
208
+
209
+ def _observe_active_transfer_stats(self, stats: OffloadingConnectorStats) -> None:
210
+ for tier_idx, state in enumerate(self._tier_states):
211
+ labelvalues = self.tier_label(tier_idx)
212
+ write_usage = (
213
+ state.primary_write_block_count / self._num_primary_blocks
214
+ if self._num_primary_blocks > 0
215
+ else 0.0
216
+ )
217
+ read_usage = (
218
+ state.primary_read_block_count / self._num_primary_blocks
219
+ if self._num_primary_blocks > 0
220
+ else 0.0
221
+ )
222
+ stats.set_gauge(
223
+ TieringOffloadingMetrics.PRIMARY_WRITE_USAGE_PERC,
224
+ write_usage,
225
+ labelvalues,
226
+ )
227
+ stats.set_gauge(
228
+ TieringOffloadingMetrics.PRIMARY_READ_USAGE_PERC,
229
+ read_usage,
230
+ labelvalues,
231
+ )
232
+ stats.set_gauge(
233
+ TieringOffloadingMetrics.ACTIVE_PROMOTION_JOBS,
234
+ state.active_promotion_count,
235
+ labelvalues,
236
+ )
237
+ stats.set_gauge(
238
+ TieringOffloadingMetrics.ACTIVE_CASCADE_JOBS,
239
+ state.active_cascade_count,
240
+ labelvalues,
241
+ )
242
+
243
+ def _observe_resolved_lookup(
244
+ self,
245
+ tier_label: TierLabel,
246
+ result: LookupResult,
247
+ lookup_duration: float,
248
+ async_start_time: float | None,
249
+ ) -> None:
250
+ self._stats.increase_counter(
251
+ TieringOffloadingMetrics.BLOCK_QUERIES,
252
+ labelvalues=tier_label,
253
+ )
254
+ if result is LookupResult.HIT:
255
+ self._stats.increase_counter(
256
+ TieringOffloadingMetrics.BLOCK_HITS,
257
+ labelvalues=tier_label,
258
+ )
259
+ self._stats.observe_histogram(
260
+ TieringOffloadingMetrics.LOOKUP_SYNC_DELAY,
261
+ lookup_duration,
262
+ tier_label,
263
+ )
264
+ if async_start_time is not None:
265
+ self._stats.observe_histogram(
266
+ TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY,
267
+ time.monotonic() - async_start_time,
268
+ tier_label,
269
+ )
vllm/v1/kv_offload/tiering/obj/manager.py
+20
-5
@@ -22,11 +22,11 @@ from vllm.v1.kv_offload.file_mapper import FileMapper
22
from vllm.v1.kv_offload.tiering.async_lookup import AsyncLookupManager
23
from vllm.v1.kv_offload.tiering.base import (
24
JobId,
25
- JobMetadata,
25
JobResult,
26
RequestOffloadingContext,
27
ScheduleEndContext,
28
SecondaryTierManager,
29
+ TransferJob,
30
)
31
from vllm.v1.kv_offload.tiering.obj.config import ObjStoreConfig
32
@@ -267,7 +267,11 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
267
self._pending_results.append(JobResult(job_id=job_id, success=False))
268
return
269
270
- self._transfers[job_id] = TransferEntry(xfer_handle, files_desc, obj_handle)
270
+ self._transfers[job_id] = TransferEntry(
271
+ xfer_handle=xfer_handle,
272
+ files_desc=files_desc,
273
+ obj_handle=obj_handle,
274
+ )
275
276
def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult:
277
result = self._lookup_manager.lookup(key, req_context)
@@ -275,7 +279,7 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
279
return LookupResult.RETRY
280
return LookupResult.HIT if result else LookupResult.MISS
281
278
- def submit_store(self, job_metadata: JobMetadata) -> None:
282
+ def submit_store(self, job_metadata: TransferJob) -> None:
283
if self.events is not None:
284
self._store_job_keys[job_metadata.job_id] = list(job_metadata.keys)
285
obj_keys = (self._file_mapper.get_file_name(k) for k in job_metadata.keys)
@@ -283,7 +287,7 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
287
job_metadata.job_id, job_metadata.block_ids, obj_keys, NIXL_WRITE
288
)
289
286
- def submit_load(self, job_metadata: JobMetadata) -> None:
290
+ def submit_load(self, job_metadata: TransferJob) -> None:
291
self._load_job_keys[job_metadata.job_id] = list(job_metadata.keys)
292
obj_keys = (self._file_mapper.get_file_name(k) for k in job_metadata.keys)
293
self._submit_transfer(
@@ -317,6 +321,11 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
321
success = False
322
logger.warning("transfer failed job=%d state=%s", job_id, state)
323
324
+ transfer_time = None
325
+ if success:
326
+ telemetry = self._agent.get_xfer_telemetry(entry.xfer_handle)
327
+ transfer_time = telemetry.xferDuration / 1e6
328
+
329
try:
330
self._agent.release_xfer_handle(entry.xfer_handle)
331
except Exception as exc:
@@ -341,7 +350,13 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
350
logger.warning("deregister_memory failed for job %d: %s", job_id, exc)
351
352
del self._transfers[job_id]
344
- self._pending_results.append(JobResult(job_id=job_id, success=success))
353
+ self._pending_results.append(
354
+ JobResult(
355
+ job_id=job_id,
356
+ success=success,
357
+ transfer_time=transfer_time,
358
+ )
359
+ )
360
361
def get_finished_jobs(self) -> Iterable[JobResult]:
362
"""Poll transfers; a failed promotion marks its cached verdicts False
vllm/v1/kv_offload/tiering/p2p/manager.py
+3
-3
@@ -28,9 +28,9 @@ from vllm.v1.kv_offload.base import (
28
)
29
from vllm.v1.kv_offload.file_mapper import FileMapper
30
from vllm.v1.kv_offload.tiering.base import (
31
- JobMetadata,
31
JobResult,
32
SecondaryTierManager,
33
+ TransferJob,
34
)
35
from vllm.v1.kv_offload.tiering.p2p.control import ControlTransport, ZmqTransport
36
from vllm.v1.kv_offload.tiering.p2p.data import DataTransport, NixlTransport
@@ -411,7 +411,7 @@ class P2PSecondaryTierManager(SecondaryTierManager):
411
return
412
413
@override
414
- def submit_store(self, job_metadata: JobMetadata) -> None:
414
+ def submit_store(self, job_metadata: TransferJob) -> None:
415
job_id = job_metadata.job_id
416
keys = list(job_metadata.keys)
417
block_ids = job_metadata.block_ids
@@ -470,7 +470,7 @@ class P2PSecondaryTierManager(SecondaryTierManager):
470
)
471
472
@override
473
- def submit_load(self, job_metadata: JobMetadata) -> None:
473
+ def submit_load(self, job_metadata: TransferJob) -> None:
474
job_id = job_metadata.job_id
475
keys = list(job_metadata.keys)
476
block_ids = job_metadata.block_ids
vllm/v1/kv_offload/tiering/spec.py
+103
-6
@@ -61,6 +61,8 @@ from typing_extensions import override
61
from vllm.logger import init_logger
62
from vllm.v1.kv_offload.base import (
63
CanonicalKVCaches,
64
+ OffloadingCounterMetadata,
65
+ OffloadingGaugeMetadata,
66
OffloadingHistogramMetadata,
67
OffloadingManager,
68
OffloadingMetricMetadata,
@@ -103,10 +105,10 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
105
metrics[TieringOffloadingMetrics.LOOKUP_SYNC_DELAY] = (
106
OffloadingHistogramMetadata(
107
documentation=(
106
- "Histogram of total blocking time spent querying secondary "
107
- "tiers for a request, accumulated from first lookup until "
108
- "the request is allocated or finishes, in seconds."
108
+ "Histogram of blocking time spent in a per-block tier lookup "
109
+ "that resolved as a hit or miss, labeled by tier, in seconds."
110
),
111
+ labelnames=("tier",),
112
buckets=(
113
0.00001,
114
0.00005,
@@ -125,10 +127,11 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
127
metrics[TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY] = (
128
OffloadingHistogramMetadata(
129
documentation=(
128
- "Histogram of wall-clock time from a request's first deferred "
129
- "secondary-tier lookup until the request is allocated or "
130
- "finishes, in seconds."
130
+ "Histogram of wall-clock time from a per-block tier lookup "
131
+ "first returning retry until that same tier lookup resolves "
132
+ "as a hit or miss, labeled by tier, in seconds."
133
),
134
+ labelnames=("tier",),
135
buckets=(
136
0.0001,
137
0.0005,
@@ -144,6 +147,100 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
147
),
148
)
149
)
150
+ metrics[TieringOffloadingMetrics.READ_BYTES] = OffloadingCounterMetadata(
151
+ documentation=(
152
+ "Total bytes read from secondary tiers into the primary tier, "
153
+ "labeled by tier."
154
+ ),
155
+ labelnames=("tier",),
156
+ )
157
+ metrics[TieringOffloadingMetrics.READ_TIME] = OffloadingCounterMetadata(
158
+ documentation=(
159
+ "Total time spent reading from secondary tiers into the primary "
160
+ "tier, in seconds, labeled by tier."
161
+ ),
162
+ labelnames=("tier",),
163
+ )
164
+ metrics[TieringOffloadingMetrics.WRITE_BYTES] = OffloadingCounterMetadata(
165
+ documentation=(
166
+ "Total bytes written from the primary tier to secondary tiers, "
167
+ "labeled by tier."
168
+ ),
169
+ labelnames=("tier",),
170
+ )
171
+ metrics[TieringOffloadingMetrics.WRITE_TIME] = OffloadingCounterMetadata(
172
+ documentation=(
173
+ "Total time spent writing from the primary tier to secondary "
174
+ "tiers, in seconds, labeled by tier."
175
+ ),
176
+ labelnames=("tier",),
177
+ )
178
+ metrics[TieringOffloadingMetrics.PROMOTION_JOB_FAILURES] = (
179
+ OffloadingCounterMetadata(
180
+ documentation=(
181
+ "Number of failed secondary-tier promotion jobs, labeled by tier."
182
+ ),
183
+ labelnames=("tier",),
184
+ )
185
+ )
186
+ metrics[TieringOffloadingMetrics.CASCADE_JOB_FAILURES] = (
187
+ OffloadingCounterMetadata(
188
+ documentation=(
189
+ "Number of failed secondary-tier cascade jobs, labeled by tier."
190
+ ),
191
+ labelnames=("tier",),
192
+ )
193
+ )
194
+ metrics[TieringOffloadingMetrics.BLOCK_QUERIES] = OffloadingCounterMetadata(
195
+ documentation=(
196
+ "Number of block lookup queries sent to a tier, labeled by tier."
197
+ ),
198
+ labelnames=("tier",),
199
+ )
200
+ metrics[TieringOffloadingMetrics.BLOCK_HITS] = OffloadingCounterMetadata(
201
+ documentation="Number of block lookup hits in a tier, labeled by tier.",
202
+ labelnames=("tier",),
203
+ )
204
+ metrics[TieringOffloadingMetrics.PROMOTION_ALLOCATION_FAILURES] = (
205
+ OffloadingCounterMetadata(
206
+ documentation=(
207
+ "Number of promotion attempts that failed because the "
208
+ "primary tier could not allocate space."
209
+ ),
210
+ )
211
+ )
212
+ metrics[TieringOffloadingMetrics.PRIMARY_WRITE_USAGE_PERC] = (
213
+ OffloadingGaugeMetadata(
214
+ documentation=(
215
+ "Current fraction of primary-tier space used by writes from "
216
+ "secondary tiers, labeled by tier."
217
+ ),
218
+ labelnames=("tier",),
219
+ )
220
+ )
221
+ metrics[TieringOffloadingMetrics.PRIMARY_READ_USAGE_PERC] = (
222
+ OffloadingGaugeMetadata(
223
+ documentation=(
224
+ "Current fraction of primary-tier space used by reads to "
225
+ "secondary tiers, labeled by tier."
226
+ ),
227
+ labelnames=("tier",),
228
+ )
229
+ )
230
+ metrics[TieringOffloadingMetrics.ACTIVE_PROMOTION_JOBS] = (
231
+ OffloadingGaugeMetadata(
232
+ documentation=(
233
+ "Number of active secondary-tier promotion jobs, labeled by tier."
234
+ ),
235
+ labelnames=("tier",),
236
+ )
237
+ )
238
+ metrics[TieringOffloadingMetrics.ACTIVE_CASCADE_JOBS] = OffloadingGaugeMetadata(
239
+ documentation=(
240
+ "Number of active secondary-tier cascade jobs, labeled by tier."
241
+ ),
242
+ labelnames=("tier",),
243
+ )
244
secondary_tier_configs = extra_config.get("secondary_tiers", [])
245
if not isinstance(secondary_tier_configs, list):
246
raise ValueError("secondary_tiers must be a list of tier configurations")