[KV Offloading] Back-pressure detection and remediation (#50045)
Signed-off-by: Bill Nell <bnell@redhat.com> Signed-off-by: bnellnm <49004751+bnellnm@users.noreply.github.com> Co-authored-by: Itay Etelis <92247226+Etelis@users.noreply.github.com>
bnellnm committed
Sep 18, 2026 at 14:47 UTC
23e26e058839fb2a3e77c78fbf2184f563593227
13 files changed
+1464
-5
tests/v1/kv_connector/unit/offloading_connector/test_events.py
+89
@@ -682,3 +682,92 @@ def test_tiering_accepts_self_describing_kv_events():
682
assert spec.kv_events_config.enable_kv_cache_events
683
assert spec.kv_events_config.self_describing_kv_events
684
assert tracker.self_describing_enabled
685
+
686
+
687
+def _build_tiering_spec(secondary_tiers, *, top_level_backpressure=None):
688
+ vllm_config = create_vllm_config(
689
+ block_size=4,
690
+ max_num_batched_tokens=16,
691
+ disable_hybrid_kv_cache_manager=False,
692
+ )
693
+ extra_config = {
694
+ "spec_name": "TieringOffloadingSpec",
695
+ "cpu_bytes_to_use": 1 << 20,
696
+ "secondary_tiers": secondary_tiers,
697
+ }
698
+ if top_level_backpressure is not None:
699
+ extra_config["backpressure"] = top_level_backpressure
700
+ vllm_config.kv_transfer_config = KVTransferConfig(
701
+ kv_connector="OffloadingConnector",
702
+ kv_role="kv_both",
703
+ kv_connector_extra_config=extra_config,
704
+ )
705
+ kv_cache_config = KVCacheConfig(
706
+ num_blocks=0,
707
+ kv_cache_tensors=[],
708
+ kv_cache_groups=[
709
+ KVCacheGroupSpec(
710
+ ["layer"],
711
+ FullAttentionSpec(
712
+ block_size=4,
713
+ num_kv_heads=1,
714
+ head_size=1,
715
+ dtype=torch.float32,
716
+ ),
717
+ )
718
+ ],
719
+ )
720
+ return TieringOffloadingSpec(build_offloading_config(vllm_config, kv_cache_config))
721
+
722
+
723
+def test_partial_tier_backpressure_inherits_top_level_defaults():
724
+ """A partial tier override still inherits missing fields.
725
+
726
+ Field-by-field merge means an override that sets only ``high_water_s``
727
+ keeps ``backpressure_cls`` (and any other field) from the top-level
728
+ default, so the resolved dict reaching the factory is complete.
729
+ """
730
+ spec = _build_tiering_spec(
731
+ [{"type": "example", "backpressure": {"high_water_s": 0.1}}],
732
+ top_level_backpressure={
733
+ "backpressure_cls": "EMABackpressureDetector",
734
+ "high_water_s": 0.5,
735
+ "low_water_s": 0.05,
736
+ },
737
+ )
738
+
739
+ resolved = spec.secondary_tier_configs[0]["backpressure"]
740
+ # Tier override wins for the field it sets.
741
+ assert resolved["high_water_s"] == 0.1
742
+ # Missing fields fall back to the top-level default.
743
+ assert resolved["backpressure_cls"] == "EMABackpressureDetector"
744
+ assert resolved["low_water_s"] == 0.05
745
+
746
+
747
+def test_tier_override_wins_over_top_level():
748
+ """Precedence is tier override > top-level default, field-by-field."""
749
+ spec = _build_tiering_spec(
750
+ [
751
+ {
752
+ "type": "example",
753
+ "backpressure": {"high_water_s": 0.1, "low_water_s": 0.02},
754
+ }
755
+ ],
756
+ top_level_backpressure={
757
+ "backpressure_cls": "EMABackpressureDetector",
758
+ "high_water_s": 0.5,
759
+ "low_water_s": 0.05,
760
+ },
761
+ )
762
+
763
+ resolved = spec.secondary_tier_configs[0]["backpressure"]
764
+ # Tier override wins for the fields it sets.
765
+ assert resolved["high_water_s"] == 0.1
766
+ assert resolved["low_water_s"] == 0.02
767
+ # Fields only in top-level still fill in.
768
+ assert resolved["backpressure_cls"] == "EMABackpressureDetector"
769
+
770
+
771
+def test_tier_without_backpressure_stays_unconfigured():
772
+ spec = _build_tiering_spec([{"type": "example"}])
773
+ assert "backpressure" not in spec.secondary_tier_configs[0]
tests/v1/kv_offload/tiering/p2p/test_manager.py
+34
@@ -180,6 +180,40 @@ class TestInitHashSeed:
180
assert mgr._get_hash_seed() == "random-seed-abc"
181
182
183
+class TestBackpressureRejected:
184
+ def _patch_transports(self, monkeypatch) -> None:
185
+ monkeypatch.setattr(manager_module, "NixlTransport", lambda *a, **k: object())
186
+ monkeypatch.setattr(manager_module, "ZmqTransport", lambda *a, **k: object())
187
+ monkeypatch.setattr(
188
+ manager_module.FileMapper,
189
+ "from_offloading_spec",
190
+ lambda **k: SimpleNamespace(get_run_config=lambda: {}),
191
+ )
192
+
193
+ def test_explicit_backpressure_detector_rejected(self, monkeypatch):
194
+ """An explicitly configured detector must fail fast for P2P.
195
+
196
+ The generic store-latency detector cannot fail fast in PD mode and
197
+ rendezvous time makes store latency an unreliable pressure signal,
198
+ so P2P rejects it rather than degrading silently.
199
+ """
200
+ self._patch_transports(monkeypatch)
201
+ with pytest.raises(ValueError, match="not supported for the P2P"):
202
+ P2PSecondaryTierManager(
203
+ offloading_spec=_init_offloading_spec(),
204
+ primary_kv_view=memoryview(bytearray(16)),
205
+ backpressure_detector=object(),
206
+ )
207
+
208
+ def test_no_detector_constructs_normally(self, monkeypatch):
209
+ self._patch_transports(monkeypatch)
210
+ mgr = P2PSecondaryTierManager(
211
+ offloading_spec=_init_offloading_spec(),
212
+ primary_kv_view=memoryview(bytearray(16)),
213
+ )
214
+ assert mgr.bp_detector is None
215
+
216
+
217
# ---------------------------------------------------------------------------
218
# Tests for _peer_id_from_params
219
# ---------------------------------------------------------------------------
tests/v1/kv_offload/tiering/test_backpressure.py
new
+706
@@ -0,0 +1,706 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Unit tests for back-pressure detection in TieringOffloadingManager.
4
+
5
+These tests use a DelayedSecondaryTierManager that holds completed jobs
6
+until explicitly released, allowing precise control over when the manager
7
+observes store completions and their apparent latency.
8
+"""
9
+
10
+import time
11
+from collections.abc import Iterable
12
+from unittest.mock import MagicMock, patch
13
+
14
+import pytest
15
+import torch
16
+
17
+from vllm.v1.kv_offload.base import (
18
+ LookupResult,
19
+ OffloadKey,
20
+ OffloadPolicy,
21
+ ReqContext,
22
+ RequestOffloadingContext,
23
+ ScheduleEndContext,
24
+ make_offload_key,
25
+)
26
+from vllm.v1.kv_offload.tiering.backpressure import (
27
+ DropAccountingPolicy,
28
+ DropStorePolicy,
29
+ EMABackpressureDetector,
30
+ ThrottledDropPolicy,
31
+)
32
+from vllm.v1.kv_offload.tiering.base import (
33
+ JobResult,
34
+ SecondaryTierManager,
35
+ TieringOffloadingMetrics,
36
+ TransferJob,
37
+)
38
+from vllm.v1.kv_offload.tiering.manager import (
39
+ CPUPrimaryTierOffloadingManager,
40
+ TieringOffloadingManager,
41
+)
42
+
43
+_BP_EMA_ALPHA = EMABackpressureDetector.DEFAULT_ALPHA
44
+_BP_WARMUP = EMABackpressureDetector.DEFAULT_WARMUP_COMPLETIONS
45
+# Water marks in s/MiB, scaled for the test's tiny 16-byte blocks.
46
+_BP_HIGH_WATER_S = 1000.0
47
+_BP_LOW_WATER_S = 500.0
48
+
49
+_CTX = ReqContext(req_id="test")
50
+_MOCK_OFFLOADING_SPEC = MagicMock()
51
+
52
+
53
+def _mock_mmap_region(num_blocks: int, row_bytes: int = 16):
54
+ mock = MagicMock()
55
+ view = memoryview(torch.zeros((num_blocks, row_bytes), dtype=torch.int8).numpy())
56
+ mock.create_kv_memoryview.return_value = view
57
+ return mock
58
+
59
+
60
+def to_keys(int_ids: Iterable[int]) -> list[OffloadKey]:
61
+ return [make_offload_key(str(i).encode(), 0) for i in int_ids]
62
+
63
+
64
+class DelayedSecondaryTierManager(SecondaryTierManager):
65
+ """Secondary tier that holds completed store jobs until released.
66
+
67
+ Jobs are stored immediately but their completion is not reported
68
+ by get_finished_jobs() until release_jobs() is called. This lets
69
+ tests control the apparent latency of store operations by adjusting
70
+ submit_time on the JobMetadata before triggering the completion poll.
71
+ """
72
+
73
+ def __init__(
74
+ self, offloading_spec, primary_kv_view, tier_type, backpressure_detector=None
75
+ ):
76
+ super().__init__(
77
+ offloading_spec,
78
+ primary_kv_view,
79
+ tier_type,
80
+ backpressure_detector=backpressure_detector,
81
+ )
82
+ self.blocks: dict[OffloadKey, bool] = {}
83
+ self._held_jobs: list[JobResult] = []
84
+ self._released_jobs: list[JobResult] = []
85
+ self._request_policy: OffloadPolicy | None = None
86
+
87
+ def lookup(self, key, req_context):
88
+ return LookupResult.HIT if key in self.blocks else LookupResult.MISS
89
+
90
+ def submit_store(self, job_metadata: TransferJob) -> None:
91
+ for key in job_metadata.keys:
92
+ self.blocks[key] = True
93
+ self._held_jobs.append(JobResult(job_id=job_metadata.job_id, success=True))
94
+
95
+ def submit_load(self, job_metadata: TransferJob) -> None:
96
+ self._released_jobs.append(JobResult(job_id=job_metadata.job_id, success=True))
97
+
98
+ def get_finished_jobs(self) -> Iterable[JobResult]:
99
+ result = self._released_jobs
100
+ self._released_jobs = []
101
+ return result
102
+
103
+ def release_jobs(self):
104
+ self._released_jobs.extend(self._held_jobs)
105
+ self._held_jobs.clear()
106
+
107
+ def on_new_request(self, req_context):
108
+ if self._request_policy is not None:
109
+ return RequestOffloadingContext(policy=self._request_policy)
110
+ return RequestOffloadingContext()
111
+
112
+ def drain_jobs(self):
113
+ self.release_jobs()
114
+
115
+ def has_pending_work(self):
116
+ return bool(self._held_jobs)
117
+
118
+ def get_num_blocks(self):
119
+ return len(self.blocks)
120
+
121
+
122
+class TestDefaultConfig:
123
+ def test_fs_gets_local_watermarks(self):
124
+ cfg = EMABackpressureDetector.default_config("fs")
125
+ assert cfg["high_water_s"] == EMABackpressureDetector.LOCAL_HIGH_WATER_S
126
+ assert cfg["low_water_s"] == EMABackpressureDetector.LOCAL_LOW_WATER_S
127
+
128
+ def test_obj_gets_network_watermarks(self):
129
+ cfg = EMABackpressureDetector.default_config("obj")
130
+ assert cfg["high_water_s"] == EMABackpressureDetector.NETWORK_HIGH_WATER_S
131
+ assert cfg["low_water_s"] == EMABackpressureDetector.NETWORK_LOW_WATER_S
132
+
133
+ def test_p2p_gets_local_watermarks(self):
134
+ cfg = EMABackpressureDetector.default_config("p2p")
135
+ assert cfg["high_water_s"] == EMABackpressureDetector.LOCAL_HIGH_WATER_S
136
+ assert cfg["low_water_s"] == EMABackpressureDetector.LOCAL_LOW_WATER_S
137
+
138
+ def test_fs_with_remote_locality_gets_network_watermarks(self):
139
+ cfg = EMABackpressureDetector.default_config("fs", locality="REMOTE")
140
+ assert cfg["high_water_s"] == EMABackpressureDetector.NETWORK_HIGH_WATER_S
141
+ assert cfg["low_water_s"] == EMABackpressureDetector.NETWORK_LOW_WATER_S
142
+
143
+ def test_fs_with_local_locality_gets_local_watermarks(self):
144
+ cfg = EMABackpressureDetector.default_config("fs", locality="LOCAL")
145
+ assert cfg["high_water_s"] == EMABackpressureDetector.LOCAL_HIGH_WATER_S
146
+ assert cfg["low_water_s"] == EMABackpressureDetector.LOCAL_LOW_WATER_S
147
+
148
+
149
+class TestIdleDecay:
150
+ """Tests for EMA idle decay — recovery from pressure without completions."""
151
+
152
+ def _make_detector(self, **kwargs):
153
+ defaults = dict(
154
+ high_water_s=_BP_HIGH_WATER_S,
155
+ low_water_s=_BP_LOW_WATER_S,
156
+ decay_half_life_s=2.0,
157
+ )
158
+ defaults.update(kwargs)
159
+ return EMABackpressureDetector(**defaults)
160
+
161
+ def test_ema_decays_while_under_pressure(self):
162
+ bp = self._make_detector()
163
+ bp._completions = _BP_WARMUP
164
+ bp._ema = _BP_HIGH_WATER_S * 2
165
+ bp._under_pressure = True
166
+ bp._last_update = time.monotonic()
167
+
168
+ with patch("time.monotonic", return_value=bp._last_update + 4.1):
169
+ # ~2.05 half-lives → EMA * 0.24, well below low water (500)
170
+ assert bp.is_under_pressure() is False
171
+ assert bp.store_latency_ema < _BP_LOW_WATER_S
172
+
173
+ def test_ema_stays_pressured_if_not_enough_decay(self):
174
+ bp = self._make_detector()
175
+ bp._completions = _BP_WARMUP
176
+ bp._ema = _BP_HIGH_WATER_S * 4
177
+ bp._under_pressure = True
178
+ bp._last_update = time.monotonic()
179
+
180
+ with patch("time.monotonic", return_value=bp._last_update + 2.0):
181
+ # 2s = 1 half-life → EMA * 0.5 = HIGH * 2, still above LOW
182
+ assert bp.is_under_pressure() is True
183
+ assert bp.store_latency_ema == pytest.approx(_BP_HIGH_WATER_S * 2, rel=0.01)
184
+
185
+ def test_no_decay_before_warmup(self):
186
+ bp = self._make_detector()
187
+ bp._ema = _BP_HIGH_WATER_S * 2
188
+ bp._under_pressure = True
189
+ bp._last_update = time.monotonic()
190
+
191
+ with patch("time.monotonic", return_value=bp._last_update + 10.0):
192
+ # Still in warmup (_completions=0 < _warmup_completions),
193
+ # so decay should not be applied in is_under_pressure.
194
+ assert bp.is_under_pressure() is True
195
+ assert bp.store_latency_ema == _BP_HIGH_WATER_S * 2
196
+
197
+ def test_no_decay_applied_during_new_sample(self):
198
+ """on_store_completed applies the new sample directly without
199
+ idle decay — decay only runs in is_under_pressure()."""
200
+ bp = self._make_detector()
201
+ bp._completions = _BP_WARMUP
202
+ bp._ema = _BP_HIGH_WATER_S * 2
203
+ bp._under_pressure = True
204
+ t0 = time.monotonic()
205
+ bp._last_update = t0
206
+
207
+ block_bytes = 16
208
+ with patch("time.monotonic", return_value=t0 + 4.0):
209
+ bp.on_store_completed(0.0, block_bytes)
210
+ # No decay in on_store_completed: EMA = alpha*0 + (1-alpha)*2000
211
+ expected = (1 - _BP_EMA_ALPHA) * _BP_HIGH_WATER_S * 2
212
+ assert bp.store_latency_ema == pytest.approx(expected, rel=0.01)
213
+
214
+ def test_reset_clears_last_update(self):
215
+ bp = self._make_detector()
216
+ bp._last_update = time.monotonic()
217
+ bp.reset()
218
+ assert bp._last_update == 0.0
219
+
220
+
221
+class TestBackpressure:
222
+ @pytest.fixture
223
+ def setup(self):
224
+ mock_region = _mock_mmap_region(20)
225
+ self.primary = CPUPrimaryTierOffloadingManager(
226
+ num_chunks=20, mmap_region=mock_region
227
+ )
228
+ mock_view = mock_region.create_kv_memoryview()
229
+ self.tier = DelayedSecondaryTierManager(
230
+ offloading_spec=_MOCK_OFFLOADING_SPEC,
231
+ primary_kv_view=mock_view,
232
+ tier_type="delayed",
233
+ backpressure_detector=EMABackpressureDetector(
234
+ high_water_s=_BP_HIGH_WATER_S,
235
+ low_water_s=_BP_LOW_WATER_S,
236
+ ),
237
+ )
238
+ self.manager = TieringOffloadingManager(
239
+ primary_tier=self.primary,
240
+ secondary_tiers=[self.tier],
241
+ )
242
+
243
+ def _start_request(self, ctx=_CTX):
244
+ if ctx.req_id not in self.manager._req_state:
245
+ self.manager.on_new_request(ctx)
246
+
247
+ def _store_blocks(self, keys, ctx=_CTX):
248
+ self._start_request(ctx)
249
+ result = self.manager.prepare_store(keys, ctx)
250
+ assert result is not None
251
+ self.manager.complete_store(keys, ctx, success=True)
252
+
253
+ def _simulate_on_schedule_end(self):
254
+ ctx = ScheduleEndContext(new_req_ids=[], preempted_req_ids=())
255
+ self.manager.on_schedule_end(ctx)
256
+
257
+ def _backdate_held_jobs(self, age_s: float):
258
+ """Set submit_time on all in-flight store jobs so they appear
259
+ to have taken ``age_s`` seconds when completed."""
260
+ self.manager._processed_jobs_this_step = False
261
+ now = time.monotonic()
262
+ for job_id, meta in self.manager._jobs.items():
263
+ tj = meta.transfer_job
264
+ if not tj.is_promotion and tj.submit_time > 0:
265
+ tj.submit_time = now - age_s
266
+
267
+ def test_ema_updates_on_store_completion(self, setup):
268
+ bp = self.tier.bp_detector
269
+ fast_latency = 0.01
270
+ # EMA is in s/MiB; with 16-byte blocks, scale = MiB / block_bytes.
271
+ scale = EMABackpressureDetector._MIB / self.tier.block_size_bytes
272
+
273
+ # During warmup, EMA stays at 0.
274
+ for i in range(_BP_WARMUP - 1):
275
+ keys = to_keys([300 + i])
276
+ self._store_blocks(keys)
277
+ self._backdate_held_jobs(fast_latency)
278
+ self.tier.release_jobs()
279
+ self._simulate_on_schedule_end()
280
+ assert bp.store_latency_ema == 0.0
281
+
282
+ # Final warmup sample seeds EMA with the mean.
283
+ keys = to_keys([400])
284
+ self._store_blocks(keys)
285
+ self._backdate_held_jobs(fast_latency)
286
+ self.tier.release_jobs()
287
+ self._simulate_on_schedule_end()
288
+ fast_s_per_mib = fast_latency * scale
289
+ assert bp.store_latency_ema == pytest.approx(fast_s_per_mib, rel=0.1)
290
+
291
+ # After warmup, EMA updates normally.
292
+ new_latency = 0.05
293
+ keys = to_keys([401])
294
+ self._store_blocks(keys)
295
+ self._backdate_held_jobs(new_latency)
296
+ self.tier.release_jobs()
297
+ self._simulate_on_schedule_end()
298
+ new_s_per_mib = new_latency * scale
299
+ expected = _BP_EMA_ALPHA * new_s_per_mib + (1 - _BP_EMA_ALPHA) * fast_s_per_mib
300
+ assert bp.store_latency_ema == pytest.approx(expected, rel=0.1)
301
+
302
+ def test_pressure_activates_above_high_water(self, setup):
303
+ bp = self.tier.bp_detector
304
+
305
+ # Warm up the detector with fast completions first.
306
+ for i in range(_BP_WARMUP):
307
+ keys = to_keys([200 + i])
308
+ self._store_blocks(keys)
309
+ self._backdate_held_jobs(0.001)
310
+ self.tier.release_jobs()
311
+ self._simulate_on_schedule_end()
312
+
313
+ keys = to_keys([100])
314
+ self._store_blocks(keys)
315
+ self._simulate_on_schedule_end()
316
+
317
+ # 0.1s for a single 16-byte block → ~6553 s/MiB, well above high water.
318
+ self._backdate_held_jobs(0.1)
319
+ self.tier.release_jobs()
320
+ self._simulate_on_schedule_end()
321
+
322
+ assert bp.store_latency_ema > _BP_HIGH_WATER_S
323
+ assert bp.is_under_pressure() is True
324
+
325
+ def test_pressure_clears_below_low_water(self, setup):
326
+ bp = self.tier.bp_detector
327
+ bp.store_latency_ema = _BP_HIGH_WATER_S * 2
328
+ bp._under_pressure = True
329
+ bp._completions = _BP_WARMUP
330
+
331
+ # Drive several fast completions to bring EMA below low water.
332
+ block_id = 100
333
+ for _ in range(50):
334
+ if bp.store_latency_ema < _BP_LOW_WATER_S:
335
+ break
336
+ keys = to_keys([block_id])
337
+ block_id += 1
338
+ self._store_blocks(keys)
339
+ # Pressure is on, so the cascade was skipped. Force-submit
340
+ # a store job manually to get a completion to feed the EMA.
341
+ job_meta = self.manager.create_store_job(keys, _CTX)
342
+ job_meta.submit_time = time.monotonic() - 0.001
343
+ self.tier.submit_store(job_meta)
344
+ self.tier.release_jobs()
345
+ self._simulate_on_schedule_end()
346
+ else:
347
+ pytest.fail(f"EMA did not recover below low water: {bp.store_latency_ema}")
348
+
349
+ assert bp.is_under_pressure() is False
350
+
351
+ def test_hysteresis_prevents_oscillation(self, setup):
352
+ bp = self.tier.bp_detector
353
+ bp._completions = _BP_WARMUP
354
+ block_bytes = self.tier.block_size_bytes
355
+
356
+ # Set EMA between low and high water marks (in s/MiB).
357
+ mid = (_BP_LOW_WATER_S + _BP_HIGH_WATER_S) / 2
358
+ bp.store_latency_ema = mid
359
+ bp._last_update = time.monotonic()
360
+
361
+ # Raw seconds that produce `mid` s/MiB for a given number of blocks.
362
+ def mid_latency_s(num_blocks: int) -> float:
363
+ return mid * (num_blocks * block_bytes / EMABackpressureDetector._MIB)
364
+
365
+ # When not under pressure, mid-range EMA should not activate.
366
+ bp._under_pressure = False
367
+ keys = to_keys(range(2))
368
+ self._store_blocks(keys)
369
+ self._backdate_held_jobs(mid_latency_s(len(keys)))
370
+ self.tier.release_jobs()
371
+ self._simulate_on_schedule_end()
372
+ assert bp.is_under_pressure() is False
373
+
374
+ # When under pressure, mid-range EMA should not deactivate.
375
+ bp._under_pressure = True
376
+ bp._last_update = time.monotonic()
377
+ keys2 = to_keys(range(10, 12))
378
+ # Force-submit since pressure is on (cascade would skip).
379
+ for k in keys2:
380
+ self.primary.prepare_store([k], _CTX)
381
+ self.primary.complete_store([k], _CTX)
382
+ job_meta = self.manager.create_store_job(keys2, _CTX)
383
+ job_meta.submit_time = time.monotonic() - mid_latency_s(len(keys2))
384
+ self.tier.submit_store(job_meta)
385
+ self.tier.release_jobs()
386
+ self._simulate_on_schedule_end()
387
+ assert bp.is_under_pressure() is True
388
+
389
+ def test_stores_skipped_under_pressure(self, setup):
390
+ bp = self.tier.bp_detector
391
+ bp._under_pressure = True
392
+ bp.store_latency_ema = _BP_HIGH_WATER_S * 4
393
+ initial_blocks = self.tier.get_num_blocks()
394
+
395
+ keys = to_keys(range(50, 53))
396
+ self._store_blocks(keys)
397
+
398
+ # Blocks should be in primary but NOT in secondary.
399
+ for k in keys:
400
+ assert self.primary.lookup(k, _CTX) is LookupResult.HIT
401
+ assert self.tier.get_num_blocks() == initial_blocks
402
+
403
+ def test_stores_resume_after_pressure_clears(self, setup):
404
+ bp = self.tier.bp_detector
405
+
406
+ # Start under pressure — stores skipped.
407
+ bp._under_pressure = True
408
+ bp.store_latency_ema = _BP_HIGH_WATER_S * 4
409
+ keys1 = to_keys(range(60, 62))
410
+ self._store_blocks(keys1)
411
+ assert all(k not in self.tier.blocks for k in keys1)
412
+
413
+ # Clear pressure — next store should cascade.
414
+ bp._under_pressure = False
415
+ keys2 = to_keys(range(70, 72))
416
+ self._store_blocks(keys2)
417
+ assert all(k in self.tier.blocks for k in keys2)
418
+
419
+ def test_dropped_store_count_tracked(self, setup):
420
+ bp = self.tier.bp_detector
421
+ bp._under_pressure = True
422
+ bp.store_latency_ema = _BP_HIGH_WATER_S * 4
423
+ policy = bp.policy
424
+ assert policy.pop_stores_dropped() == (0, 0)
425
+
426
+ self._store_blocks(to_keys([80]))
427
+ assert policy._stores_dropped == 1
428
+ assert policy._blocks_dropped == 1
429
+
430
+ self._store_blocks(to_keys([81, 82]))
431
+ assert policy._stores_dropped == 2
432
+ assert policy._blocks_dropped == 3
433
+
434
+ def test_metrics_reported_via_get_stats(self, setup):
435
+ bp = self.tier.bp_detector
436
+ bp._under_pressure = True
437
+ bp.store_latency_ema = 2.5
438
+ policy = bp.policy
439
+ policy._stores_dropped = 5
440
+ policy._blocks_dropped = 12
441
+
442
+ stats = self.manager.get_stats()
443
+ assert stats is not None
444
+ reduced = stats.reduce()
445
+
446
+ ema_key = TieringOffloadingMetrics.BACKPRESSURE_STORE_LATENCY_EMA
447
+ stores_key = TieringOffloadingMetrics.BACKPRESSURE_STORES_DROPPED
448
+ blocks_key = TieringOffloadingMetrics.BACKPRESSURE_BLOCKS_DROPPED
449
+ assert reduced[f"{ema_key}:('1:delayed',)"] == pytest.approx(2.5)
450
+ assert reduced[f"{stores_key}:('1:delayed',)"] == 5
451
+ assert reduced[f"{blocks_key}:('1:delayed',)"] == 12
452
+
453
+ # Dropped counts reset after get_stats.
454
+ assert policy._stores_dropped == 0
455
+ assert policy._blocks_dropped == 0
456
+
457
+ def test_reset_cache_clears_backpressure(self, setup):
458
+ bp = self.tier.bp_detector
459
+ bp.store_latency_ema = 5.0
460
+ bp._under_pressure = True
461
+
462
+ self.manager.reset_cache()
463
+
464
+ assert bp.store_latency_ema == 0.0
465
+ assert bp.is_under_pressure() is False
466
+
467
+ def test_request_level_tier_respects_backpressure(self, setup):
468
+ """Request-level cascade also skips pressured tiers."""
469
+ # Store blocks in primary first.
470
+ existing = to_keys(range(3))
471
+ self._store_blocks(existing)
472
+ self.tier.release_jobs()
473
+ self._simulate_on_schedule_end()
474
+ self._simulate_on_schedule_end()
475
+
476
+ # Make tier request-level for a new request.
477
+ self.tier._request_policy = OffloadPolicy.REQUEST_LEVEL
478
+ ctx = ReqContext(req_id="req_rl_bp")
479
+ self.manager.on_new_request(ctx)
480
+
481
+ # Activate pressure.
482
+ bp = self.tier.bp_detector
483
+ bp._under_pressure = True
484
+ bp.store_latency_ema = _BP_HIGH_WATER_S * 4
485
+ initial_blocks = self.tier.get_num_blocks()
486
+
487
+ # prepare_store with existing + new blocks.
488
+ new = to_keys(range(10, 12))
489
+ all_keys = existing + new
490
+ result = self.manager.prepare_store(all_keys, ctx)
491
+ assert result is not None
492
+
493
+ # Existing blocks would normally cascade to request-level tier,
494
+ # but should be skipped under pressure.
495
+ assert self.tier.get_num_blocks() == initial_blocks
496
+
497
+ def test_loads_continue_under_pressure(self, setup):
498
+ """Loads from a pressured tier still work."""
499
+ blocks = to_keys(range(3))
500
+ for b in blocks:
501
+ self.tier.blocks[b] = True
502
+
503
+ bp = self.tier.bp_detector
504
+ bp._under_pressure = True
505
+ bp.store_latency_ema = _BP_HIGH_WATER_S * 4
506
+
507
+ # Lookups should still initiate promotion.
508
+ for b in blocks:
509
+ result = self.manager.lookup(b, _CTX)
510
+ assert result is LookupResult.HIT_PENDING
511
+
512
+
513
+class TestThrottledDropPolicy:
514
+ """Tests for proportional throttling under pressure."""
515
+
516
+ def _make_detector(self, **kwargs):
517
+ defaults = dict(
518
+ high_water_s=_BP_HIGH_WATER_S,
519
+ low_water_s=_BP_LOW_WATER_S,
520
+ cooldown_s=0.0,
521
+ )
522
+ defaults.update(kwargs)
523
+ return EMABackpressureDetector(**defaults)
524
+
525
+ def test_no_drop_when_not_under_pressure(self):
526
+ bp = self._make_detector()
527
+ for _ in range(20):
528
+ assert bp.should_store(1) is True
529
+
530
+ def test_no_drop_at_exactly_high_water(self):
531
+ bp = self._make_detector()
532
+ bp._under_pressure = True
533
+ bp.store_latency_ema = _BP_HIGH_WATER_S
534
+ for _ in range(20):
535
+ assert bp.should_store(1) is True
536
+
537
+ def test_partial_drop_at_moderate_pressure(self):
538
+ bp = self._make_detector()
539
+ bp._under_pressure = True
540
+ bp.store_latency_ema = _BP_HIGH_WATER_S * 2
541
+ results = [bp.should_store(1) for _ in range(20)]
542
+ assert any(results), "some stores should be allowed"
543
+ assert not all(results), "some stores should be dropped"
544
+
545
+ def test_full_drop_at_severe_pressure(self):
546
+ bp = self._make_detector()
547
+ bp._under_pressure = True
548
+ bp.store_latency_ema = _BP_HIGH_WATER_S * 4
549
+ for _ in range(20):
550
+ assert bp.should_store(1) is False
551
+
552
+ def test_drop_rate_increases_with_severity(self):
553
+ """Higher EMA → higher drop rate."""
554
+ drop_counts = []
555
+ for multiplier in [1.5, 2.0, 2.5, 3.5]:
556
+ bp = self._make_detector()
557
+ bp._under_pressure = True
558
+ bp.store_latency_ema = _BP_HIGH_WATER_S * multiplier
559
+ drops = sum(1 for _ in range(100) if not bp.should_store(1))
560
+ drop_counts.append(drops)
561
+ for i in range(len(drop_counts) - 1):
562
+ assert drop_counts[i] <= drop_counts[i + 1]
563
+
564
+ def test_explicit_drop_store_policy_still_drops_all(self):
565
+ """DropStorePolicy (explicit) still drops everything under pressure."""
566
+ bp = EMABackpressureDetector(
567
+ high_water_s=_BP_HIGH_WATER_S,
568
+ low_water_s=_BP_LOW_WATER_S,
569
+ policy=DropStorePolicy(),
570
+ cooldown_s=0.0,
571
+ )
572
+ bp._under_pressure = True
573
+ bp.store_latency_ema = _BP_HIGH_WATER_S * 1.5
574
+ for _ in range(20):
575
+ assert bp.should_store(1) is False
576
+
577
+
578
+class TestSharedDropAccounting:
579
+ """Both policies share drop accounting via DropAccountingPolicy."""
580
+
581
+ @pytest.mark.parametrize("policy_cls", [DropStorePolicy, ThrottledDropPolicy])
582
+ def test_shared_base(self, policy_cls):
583
+ assert issubclass(policy_cls, DropAccountingPolicy)
584
+
585
+ @pytest.mark.parametrize("policy_cls", [DropStorePolicy, ThrottledDropPolicy])
586
+ def test_pop_returns_and_resets(self, policy_cls):
587
+ policy = policy_cls()
588
+ policy.on_store_skipped(3)
589
+ policy.on_store_skipped(2)
590
+ assert policy.pop_stores_dropped() == (2, 5)
591
+ assert policy.pop_stores_dropped() == (0, 0)
592
+
593
+ @pytest.mark.parametrize("policy_cls", [DropStorePolicy, ThrottledDropPolicy])
594
+ def test_reset_clears_counters(self, policy_cls):
595
+ policy = policy_cls()
596
+ policy.on_store_skipped(4)
597
+ policy.reset()
598
+ assert policy.pop_stores_dropped() == (0, 0)
599
+
600
+ def test_throttled_reset_also_clears_call_count(self):
601
+ policy = ThrottledDropPolicy()
602
+ policy._call_count = 7
603
+ policy.reset()
604
+ assert policy._call_count == 0
605
+
606
+
607
+class TestHealthyBypass:
608
+ """Tests for the fast-path bypass when a tier is consistently healthy."""
609
+
610
+ def _make_detector(self, **kwargs):
611
+ defaults = dict(
612
+ high_water_s=_BP_HIGH_WATER_S,
613
+ low_water_s=_BP_LOW_WATER_S,
614
+ cooldown_s=0.0,
615
+ )
616
+ defaults.update(kwargs)
617
+ return EMABackpressureDetector(**defaults)
618
+
619
+ def test_not_healthy_initially(self):
620
+ bp = self._make_detector()
621
+ assert bp.is_healthy() is False
622
+
623
+ def test_healthy_after_streak(self):
624
+ bp = self._make_detector()
625
+ bp._completions = _BP_WARMUP
626
+ bp._last_update = time.monotonic()
627
+ block_bytes = 16
628
+ fast_s_per_mib = _BP_LOW_WATER_S * 0.5
629
+ fast_elapsed = fast_s_per_mib * (block_bytes / EMABackpressureDetector._MIB)
630
+ for _ in range(EMABackpressureDetector._HEALTHY_THRESHOLD + _BP_WARMUP):
631
+ bp.on_store_completed(fast_elapsed, block_bytes)
632
+ assert bp.is_healthy() is True
633
+
634
+ def test_healthy_resets_on_high_latency(self):
635
+ bp = self._make_detector()
636
+ bp._completions = _BP_WARMUP
637
+ bp._healthy_streak = EMABackpressureDetector._HEALTHY_THRESHOLD
638
+ bp._ema = _BP_LOW_WATER_S
639
+ bp._last_update = time.monotonic()
640
+ block_bytes = 16
641
+ slow_s_per_mib = _BP_HIGH_WATER_S * 2
642
+ slow_elapsed = slow_s_per_mib * (block_bytes / EMABackpressureDetector._MIB)
643
+ bp.on_store_completed(slow_elapsed, block_bytes)
644
+ assert bp.is_healthy() is False
645
+
646
+ def test_healthy_bypasses_policy(self):
647
+ bp = self._make_detector()
648
+ bp._completions = _BP_WARMUP
649
+ bp._healthy_streak = EMABackpressureDetector._HEALTHY_THRESHOLD
650
+ assert bp.should_store(1) is True
651
+
652
+ def test_reset_clears_healthy_streak(self):
653
+ bp = self._make_detector()
654
+ bp._healthy_streak = EMABackpressureDetector._HEALTHY_THRESHOLD
655
+ bp.reset()
656
+ assert bp._healthy_streak == 0
657
+ assert bp.is_healthy() is False
658
+
659
+
660
+class TestCooldown:
661
+ """Tests for the post-pressure cooldown period."""
662
+
663
+ def _make_detector(self, **kwargs):
664
+ defaults = dict(
665
+ high_water_s=_BP_HIGH_WATER_S,
666
+ low_water_s=_BP_LOW_WATER_S,
667
+ cooldown_s=1.0,
668
+ )
669
+ defaults.update(kwargs)
670
+ return EMABackpressureDetector(**defaults)
671
+
672
+ def test_cooldown_throttles_stores(self):
673
+ bp = self._make_detector()
674
+ bp._completions = _BP_WARMUP
675
+ bp._under_pressure = True
676
+ bp._ema = _BP_HIGH_WATER_S * 2
677
+ bp._last_update = time.monotonic()
678
+
679
+ with patch("time.monotonic") as mock_time:
680
+ t0 = bp._last_update + 10.0
681
+ mock_time.return_value = t0
682
+ assert bp.is_under_pressure() is False
683
+ assert bp._pressure_cleared_at > 0
684
+
685
+ mock_time.return_value = t0 + 0.1
686
+ results = [bp.should_store(1) for _ in range(10)]
687
+ assert sum(results) == 5, "cooldown should allow ~50%"
688
+
689
+ def test_no_cooldown_when_never_pressured(self):
690
+ bp = self._make_detector()
691
+ for _ in range(20):
692
+ assert bp.should_store(1) is True
693
+
694
+ def test_no_cooldown_after_window_expires(self):
695
+ bp = self._make_detector()
696
+ bp._pressure_cleared_at = time.monotonic() - 2.0
697
+ for _ in range(20):
698
+ assert bp.should_store(1) is True
699
+
700
+ def test_reset_clears_cooldown(self):
701
+ bp = self._make_detector()
702
+ bp._pressure_cleared_at = time.monotonic()
703
+ bp._cooldown_count = 5
704
+ bp.reset()
705
+ assert bp._pressure_cleared_at == 0.0
706
+ assert bp._cooldown_count == 0
vllm/v1/kv_offload/tiering/backpressure.py
new
+384
@@ -0,0 +1,384 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from __future__ import annotations
4
+
5
+import time
6
+from abc import ABC, abstractmethod
7
+
8
+
9
+class BackpressurePolicy(ABC):
10
+ """Decides what action to take when a detector signals pressure."""
11
+
12
+ @abstractmethod
13
+ def should_store(self, detector: BackpressureDetector) -> bool: ...
14
+
15
+ @abstractmethod
16
+ def on_store_skipped(self, num_blocks: int) -> None: ...
17
+
18
+ @abstractmethod
19
+ def pop_stores_dropped(self) -> tuple[int, int]:
20
+ """Return and reset (stores_dropped, blocks_dropped)."""
21
+ ...
22
+
23
+ @abstractmethod
24
+ def reset(self) -> None: ...
25
+
26
+
27
+class DropAccountingPolicy(BackpressurePolicy):
28
+ """Base policy that tracks dropped store/block counters.
29
+
30
+ Subclasses implement ``should_store``; the shared drop accounting
31
+ (``on_store_skipped``/``pop_stores_dropped``/``reset``) lives here so
32
+ every policy reports these counters identically. Subclasses that add
33
+ their own state override ``reset`` and call ``super().reset()``.
34
+ """
35
+
36
+ def __init__(self):
37
+ self._stores_dropped: int = 0
38
+ self._blocks_dropped: int = 0
39
+
40
+ def on_store_skipped(self, num_blocks: int) -> None:
41
+ self._stores_dropped += 1
42
+ self._blocks_dropped += num_blocks
43
+
44
+ def pop_stores_dropped(self) -> tuple[int, int]:
45
+ stores, blocks = self._stores_dropped, self._blocks_dropped
46
+ self._stores_dropped = 0
47
+ self._blocks_dropped = 0
48
+ return stores, blocks
49
+
50
+ def reset(self) -> None:
51
+ self._stores_dropped = 0
52
+ self._blocks_dropped = 0
53
+
54
+
55
+class DropStorePolicy(DropAccountingPolicy):
56
+ """Silently drop stores to pressured tiers."""
57
+
58
+ def should_store(self, detector) -> bool:
59
+ return not detector.is_under_pressure()
60
+
61
+
62
+class ThrottledDropPolicy(DropAccountingPolicy):
63
+ """Drop stores proportionally to pressure severity.
64
+
65
+ Instead of dropping all stores when pressure is detected, the drop
66
+ rate scales with how far the EMA exceeds the high watermark::
67
+
68
+ overshoot = (ema - high_water) / high_water
69
+ drop_rate = min(1.0, overshoot / ramp_factor)
70
+
71
+ With the default ``ramp_factor=2.0``, stores ramp from 0% drop at
72
+ the high watermark to 100% drop at 3x the high watermark. This
73
+ keeps secondary tiers populated under moderate pressure, reducing
74
+ cache misses (and therefore recomputation / ITL regression) while
75
+ still protecting TTFT under severe congestion.
76
+ """
77
+
78
+ def __init__(self, ramp_factor: float = 2.0):
79
+ super().__init__()
80
+ self._call_count: int = 0
81
+ self._ramp_factor = ramp_factor
82
+
83
+ def should_store(self, detector: BackpressureDetector) -> bool:
84
+ # Proportional throttling needs the EMA and high watermark, which
85
+ # only the EMA detector exposes.
86
+ assert isinstance(detector, EMABackpressureDetector), (
87
+ "ThrottledDropPolicy requires an EMABackpressureDetector"
88
+ )
89
+ if not detector.is_under_pressure():
90
+ return True
91
+ ema = detector.store_latency_ema
92
+ high = detector._high
93
+ if high <= 0:
94
+ return False
95
+ overshoot = max(0.0, ema - high) / high
96
+ drop_rate = min(1.0, overshoot / self._ramp_factor)
97
+ if drop_rate <= 0:
98
+ return True
99
+ if drop_rate >= 1.0:
100
+ return False
101
+ self._call_count += 1
102
+ period = max(2, round(1.0 / drop_rate))
103
+ return (self._call_count % period) != 0
104
+
105
+ def reset(self) -> None:
106
+ super().reset()
107
+ self._call_count = 0
108
+
109
+
110
+class BackpressureDetector(ABC):
111
+ """Observes store completion signals and determines pressure state."""
112
+
113
+ def __init__(
114
+ self,
115
+ policy: BackpressurePolicy | None = None,
116
+ ):
117
+ self._policy = policy or ThrottledDropPolicy()
118
+
119
+ @property
120
+ def policy(self) -> BackpressurePolicy:
121
+ return self._policy
122
+
123
+ @abstractmethod
124
+ def on_store_completed(self, elapsed_s: float, num_bytes: int) -> None: ...
125
+
126
+ @abstractmethod
127
+ def is_under_pressure(self) -> bool: ...
128
+
129
+ @abstractmethod
130
+ def reset(self) -> None: ...
131
+
132
+ def update(self, submit_time: float, num_bytes: int) -> None:
133
+ """Update pressure state from a completed store job.
134
+
135
+ Args:
136
+ submit_time: ``time.monotonic()`` when the job was submitted.
137
+ num_bytes: Total bytes written (num_blocks * block_size_bytes).
138
+
139
+ """
140
+ if num_bytes <= 0:
141
+ return
142
+ elapsed = time.monotonic() - submit_time
143
+ self.on_store_completed(elapsed, num_bytes)
144
+
145
+ def should_store(self, num_blocks: int) -> bool:
146
+ """Check policy and record skip if rejected."""
147
+ if not self._policy.should_store(self):
148
+ self._policy.on_store_skipped(num_blocks)
149
+ return False
150
+ return True
151
+
152
+ @classmethod
153
+ def default_config(
154
+ cls, tier_type: str, *, locality: str | None = None
155
+ ) -> dict | None:
156
+ """Return default constructor kwargs for ``tier_type``, or None.
157
+
158
+ Args:
159
+ tier_type: The tier type string (e.g. ``"fs"``, ``"obj"``).
160
+ locality: Optional locality hint from the tier config
161
+ (``"LOCAL"`` or ``"REMOTE"``). When ``"REMOTE"``, tiers
162
+ that would normally get local-storage watermarks (e.g.
163
+ ``"fs"``) receive network watermarks instead.
164
+
165
+ """
166
+ return None
167
+
168
+ @property
169
+ def stats(self) -> dict[str, float]:
170
+ return {}
171
+
172
+
173
+class EMABackpressureDetector(BackpressureDetector):
174
+ """EMA of store latency normalized by transfer size.
175
+
176
+ The EMA tracks seconds per megabyte (s/MiB) so that the metric is
177
+ comparable regardless of how many blocks are in a job or how large
178
+ each block is. Water marks are in the same unit.
179
+
180
+ Default water marks are derived from fio benchmarks on the WDC H100
181
+ cluster. Two presets are provided:
182
+
183
+ LOCAL (NVMe/SSD): NVMe sustains ~5 GB/s writes.
184
+ high=0.005 s/MiB (~200 MB/s) catches severe congestion;
185
+ low=0.001 s/MiB (~1 GB/s) requires meaningful recovery.
186
+
187
+ NETWORK (CephFS, object store, ``obj``tiers, or any tier
188
+ with ``"locality": "REMOTE"``): CephFS sustains ~1.5 GB/s.
189
+ high=0.020 s/MiB (~50 MB/s); low=0.005 s/MiB (~200 MB/s).
190
+
191
+ ``obj`` tiers get NETWORK defaults automatically. An
192
+ ``fs`` tier defaults to LOCAL (appropriate for NVMe/SSD); set
193
+ ``"locality": "REMOTE"`` in the tier config for network-backed
194
+ filesystems like CephFS.
195
+ """
196
+
197
+ _MIB = 1 << 20
198
+
199
+ # EMA smoothing factor: higher values (→1) react faster to latency
200
+ # spikes but are noisier; lower values (→0) smooth more but lag.
201
+ DEFAULT_ALPHA = 0.15
202
+
203
+ # Number of store completions to collect before seeding the EMA.
204
+ # During warmup, pressure is never signalled; the EMA is initialized
205
+ # to the mean of the warmup samples to avoid cold-start false positives.
206
+ DEFAULT_WARMUP_COMPLETIONS = 10
207
+
208
+ # When under pressure and no completions arrive, the EMA decays
209
+ # toward zero so the detector can eventually recover. The half-life
210
+ # controls how quickly: after this many seconds of silence the EMA
211
+ # halves. With a 2 s half-life and high_water=0.005, an EMA frozen
212
+ # at 0.010 reaches low_water=0.001 in ~6.6 s.
213
+ DEFAULT_DECAY_HALF_LIFE_S = 2.0
214
+
215
+ # After pressure clears, stores are throttled to 50% for this many
216
+ # seconds to prevent a burst of stores from re-overwhelming the tier.
217
+ DEFAULT_COOLDOWN_S = 1.0
218
+
219
+ # Number of consecutive sub-low-watermark completions before the
220
+ # detector enters a "healthy" fast-path that bypasses policy checks.
221
+ _HEALTHY_THRESHOLD = 10
222
+
223
+ LOCAL_HIGH_WATER_S = 0.010
224
+ LOCAL_LOW_WATER_S = 0.002
225
+
226
+ NETWORK_HIGH_WATER_S = 0.050
227
+ NETWORK_LOW_WATER_S = 0.010
228
+
229
+ _NETWORK_TIER_TYPES = frozenset({"obj"})
230
+
231
+ @classmethod
232
+ def default_config(
233
+ cls, tier_type: str, *, locality: str | None = None
234
+ ) -> dict | None:
235
+ is_remote = locality is not None and locality.upper() == "REMOTE"
236
+ if is_remote or tier_type in cls._NETWORK_TIER_TYPES:
237
+ return {
238
+ "high_water_s": cls.NETWORK_HIGH_WATER_S,
239
+ "low_water_s": cls.NETWORK_LOW_WATER_S,
240
+ }
241
+ return {
242
+ "high_water_s": cls.LOCAL_HIGH_WATER_S,
243
+ "low_water_s": cls.LOCAL_LOW_WATER_S,
244
+ }
245
+
246
+ def __init__(
247
+ self,
248
+ high_water_s: float,
249
+ low_water_s: float,
250
+ alpha: float = DEFAULT_ALPHA,
251
+ warmup_completions: int = DEFAULT_WARMUP_COMPLETIONS,
252
+ decay_half_life_s: float = DEFAULT_DECAY_HALF_LIFE_S,
253
+ cooldown_s: float = DEFAULT_COOLDOWN_S,
254
+ policy: BackpressurePolicy | None = None,
255
+ ):
256
+ super().__init__(policy=policy)
257
+ if not (0 < alpha <= 1):
258
+ raise ValueError(f"alpha must be in (0, 1], got {alpha}")
259
+ if low_water_s > high_water_s:
260
+ raise ValueError(
261
+ f"low_water_s ({low_water_s}) must be <= high_water_s ({high_water_s})"
262
+ )
263
+
264
+ self._alpha = alpha
265
+ self._high = high_water_s
266
+ self._low = low_water_s
267
+ self._warmup_completions = warmup_completions
268
+ self._decay_half_life_s = decay_half_life_s
269
+ self._cooldown_s = cooldown_s
270
+ # Exponential moving average of store latency in seconds.
271
+ self._ema: float = 0.0
272
+ # Whether the tier is currently considered under pressure.
273
+ self._under_pressure: bool = False
274
+ # Number of store completions observed (for warmup gating).
275
+ self._completions: int = 0
276
+ # Samples collected during warmup; used to seed the EMA.
277
+ self._warmup_samples: list[float] = []
278
+ # Monotonic timestamp of the last EMA update; used for idle decay.
279
+ self._last_update: float = 0.0
280
+ # Consecutive completions with EMA below low watermark.
281
+ self._healthy_streak: int = 0
282
+ # Monotonic timestamp when pressure was last cleared (for cooldown).
283
+ self._pressure_cleared_at: float = 0.0
284
+ self._cooldown_count: int = 0
285
+
286
+ def _apply_idle_decay(self) -> None:
287
+ """Decay the EMA toward zero based on elapsed idle time.
288
+
289
+ Uses the formula ``ema * 0.5^(dt / half_life)`` so the EMA
290
+ halves every ``decay_half_life_s`` seconds of silence. This
291
+ lets the detector recover from pressure when no stores are
292
+ attempted (and therefore no completions arrive to update the
293
+ EMA organically).
294
+ """
295
+ now = time.monotonic()
296
+ dt = now - self._last_update
297
+ if dt <= 0 or self._decay_half_life_s <= 0:
298
+ return
299
+ self._ema *= 0.5 ** (dt / self._decay_half_life_s)
300
+ self._last_update = now
301
+
302
+ def on_store_completed(self, elapsed_s: float, num_bytes: int) -> None:
303
+ now = time.monotonic()
304
+ s_per_mib = elapsed_s / (num_bytes / self._MIB)
305
+ self._completions += 1
306
+ if self._completions <= self._warmup_completions:
307
+ self._warmup_samples.append(s_per_mib)
308
+ if self._completions == self._warmup_completions:
309
+ self._ema = sum(self._warmup_samples) / len(self._warmup_samples)
310
+ self._last_update = now
311
+ return
312
+ self._ema = self._alpha * s_per_mib + (1 - self._alpha) * self._ema
313
+ self._last_update = now
314
+ if self._ema > self._high:
315
+ self._under_pressure = True
316
+ self._pressure_cleared_at = 0.0
317
+ self._healthy_streak = 0
318
+ elif self._ema < self._low:
319
+ if self._under_pressure:
320
+ self._pressure_cleared_at = now
321
+ self._cooldown_count = 0
322
+ self._under_pressure = False
323
+ self._healthy_streak += 1
324
+ else:
325
+ self._healthy_streak = 0
326
+
327
+ def is_under_pressure(self) -> bool:
328
+ if self._under_pressure and self._completions >= self._warmup_completions:
329
+ self._apply_idle_decay()
330
+ if self._ema < self._low:
331
+ self._under_pressure = False
332
+ self._pressure_cleared_at = time.monotonic()
333
+ self._cooldown_count = 0
334
+ return self._under_pressure
335
+
336
+ def is_healthy(self) -> bool:
337
+ """True when the tier has been consistently fast.
338
+
339
+ After ``_HEALTHY_THRESHOLD`` consecutive store completions with
340
+ EMA below the low watermark (and no active pressure), the
341
+ detector enters a healthy fast-path: ``should_store()`` returns
342
+ ``True`` immediately, skipping the policy check entirely.
343
+ """
344
+ return (
345
+ not self._under_pressure and self._healthy_streak >= self._HEALTHY_THRESHOLD
346
+ )
347
+
348
+ def should_store(self, num_blocks: int) -> bool:
349
+ under_pressure = self.is_under_pressure()
350
+ if self.is_healthy():
351
+ return True
352
+ if (
353
+ not under_pressure
354
+ and self._pressure_cleared_at > 0
355
+ and time.monotonic() - self._pressure_cleared_at < self._cooldown_s
356
+ ):
357
+ self._cooldown_count += 1
358
+ if self._cooldown_count % 2 == 0:
359
+ self._policy.on_store_skipped(num_blocks)
360
+ return False
361
+ return super().should_store(num_blocks)
362
+
363
+ def reset(self) -> None:
364
+ self._ema = 0.0
365
+ self._under_pressure = False
366
+ self._completions = 0
367
+ self._warmup_samples.clear()
368
+ self._last_update = 0.0
369
+ self._healthy_streak = 0
370
+ self._pressure_cleared_at = 0.0
371
+ self._cooldown_count = 0
372
+ self._policy.reset()
373
+
374
+ @property
375
+ def store_latency_ema(self) -> float:
376
+ return self._ema
377
+
378
+ @store_latency_ema.setter
379
+ def store_latency_ema(self, value: float) -> None:
380
+ self._ema = value
381
+
382
+ @property
383
+ def stats(self) -> dict[str, float]:
384
+ return {"store_latency_ema": self._ema}
vllm/v1/kv_offload/tiering/base.py
+27
-1
@@ -2,9 +2,10 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""Abstract interfaces and data types for the secondary tiering layer."""
4
5
+import time
6
from abc import ABC, abstractmethod
7
from collections.abc import Collection, Iterable
7
-from dataclasses import dataclass
8
+from dataclasses import dataclass, field
9
from typing import TYPE_CHECKING, Any, ClassVar
10
11
import numpy as np
@@ -20,6 +21,9 @@ from vllm.v1.kv_offload.base import (
21
RequestOffloadingContext,
22
ScheduleEndContext,
23
)
24
+from vllm.v1.kv_offload.tiering.backpressure import (
25
+ BackpressureDetector,
26
+)
27
28
if TYPE_CHECKING:
29
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
@@ -52,6 +56,11 @@ class TieringOffloadingMetrics:
56
)
57
ACTIVE_PROMOTION_JOBS = "vllm:kv_offload_tiering_active_promotion_jobs"
58
ACTIVE_CASCADE_JOBS = "vllm:kv_offload_tiering_active_cascade_jobs"
59
+ BACKPRESSURE_STORE_LATENCY_EMA = (
60
+ "vllm:kv_offload_tiering_backpressure_store_latency_ema"
61
+ )
62
+ BACKPRESSURE_STORES_DROPPED = "vllm:kv_offload_tiering_backpressure_stores_dropped"
63
+ BACKPRESSURE_BLOCKS_DROPPED = "vllm:kv_offload_tiering_backpressure_blocks_dropped"
64
65
66
@dataclass
@@ -63,6 +72,7 @@ class TransferJob:
72
chunk_ids: np.ndarray
73
is_promotion: bool
74
req_context: ReqContext
75
+ submit_time: float = field(default_factory=time.monotonic)
76
77
78
@dataclass
@@ -77,6 +87,7 @@ class JobResult:
87
# indicated by `success`. Must be a subset of the job's original keys.
88
successful_keys: Collection[OffloadKey] | None = None
89
transfer_time: float | None = None
90
+ transfer_bytes: int | None = None
91
92
93
class ParentManager(ABC):
@@ -136,18 +147,33 @@ class SecondaryTierManager(ABC):
147
offloading_spec: "OffloadingSpec",
148
primary_kv_view: memoryview,
149
tier_type: str,
150
+ backpressure_detector: BackpressureDetector | None = None,
151
) -> None:
152
"""Args:
153
offloading_spec: Offloading configuration.
154
primary_kv_view: Memoryview of the primary tier's CPU KV cache.
155
tier_type: Tier type identifier, set by SecondaryTierFactory
156
from the registered tier type.
157
+ backpressure_detector: Optional `BackpressureDetector`.
158
159
"""
160
self._offloading_spec = offloading_spec
161
self._primary_kv_view: memoryview = primary_kv_view
162
+ assert primary_kv_view.strides is not None, (
163
+ "primary_kv_view.strides cannot be None"
164
+ )
165
+ self._block_size_bytes: int = primary_kv_view.strides[0]
166
self.tier_type = tier_type
167
self.locality: Locality | None = None
168
+ self._bp_detector = backpressure_detector
169
+
170
+ @property
171
+ def block_size_bytes(self) -> int:
172
+ return self._block_size_bytes
173
+
174
+ @property
175
+ def bp_detector(self) -> BackpressureDetector | None:
176
+ return self._bp_detector
177
178
@abstractmethod
179
def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult:
vllm/v1/kv_offload/tiering/example/manager.py
+4
@@ -31,6 +31,7 @@ logger = logging.getLogger(__name__)
31
32
if TYPE_CHECKING:
33
from vllm.v1.kv_offload.base import OffloadingSpec
34
+ from vllm.v1.kv_offload.tiering.backpressure import BackpressureDetector
35
36
37
class ExampleSecondaryTierManager(SecondaryTierManager):
@@ -49,6 +50,7 @@ class ExampleSecondaryTierManager(SecondaryTierManager):
50
primary_kv_view: memoryview,
51
tier_type: str,
52
custom_param: int = 0,
53
+ backpressure_detector: "BackpressureDetector | None" = None,
54
):
55
"""Initialize the example secondary tier.
56
@@ -57,12 +59,14 @@ class ExampleSecondaryTierManager(SecondaryTierManager):
59
primary_kv_view: Memoryview over the primary tier's KV buffer.
60
tier_type: Name identifying this tier type.
61
custom_param: Dummy parameter demonstrating custom args.
62
+ backpressure_detector: Optional backpressure detector.
63
64
"""
65
super().__init__(
66
offloading_spec=offloading_spec,
67
primary_kv_view=primary_kv_view,
68
tier_type=tier_type,
69
+ backpressure_detector=backpressure_detector,
70
)
71
72
logger.info(
vllm/v1/kv_offload/tiering/factory.py
+42
@@ -8,11 +8,33 @@ from vllm.logger import init_logger
8
from vllm.v1.kv_offload.tiering.base import SecondaryTierManager
9
10
if TYPE_CHECKING:
11
+ from vllm.v1.kv_offload.backpressure import BackpressureDetector
12
from vllm.v1.kv_offload.base import OffloadingSpec
13
14
logger = init_logger(__name__)
15
16
17
+_DEFAULT_PACKAGE = "vllm.v1.kv_offload.tiering.backpressure"
18
+
19
+
20
+def _import_class(name: str) -> type:
21
+ """Import a class by name.
22
+
23
+ If ``name`` contains a dot it is treated as a fully qualified path
24
+ (e.g. ``'my_package.MyDetector'``). Otherwise it is looked up in
25
+ the default backpressure module
26
+ (``vllm.v1.kv_offload.tiering.backpressure``), so short names like
27
+ ``'DropStorePolicy'`` or ``'EMABackpressureDetector'`` work out of
28
+ the box.
29
+ """
30
+ if "." in name:
31
+ module_path, _, class_name = name.rpartition(".")
32
+ else:
33
+ module_path, class_name = _DEFAULT_PACKAGE, name
34
+ module = importlib.import_module(module_path)
35
+ return getattr(module, class_name)
36
+
37
+
38
class SecondaryTierFactory:
39
"""Registry for SecondaryTierManager implementations, resolved by type.
40
@@ -47,10 +69,30 @@ class SecondaryTierFactory:
69
config = tier_config.copy()
70
tier_type = config.pop("type")
71
config.pop("module_path", None)
72
+ locality = config.get("locality")
73
+ bp_config = config.pop("backpressure", None)
74
+ bp_detector = None
75
+ if bp_config is not None:
76
+ bp_config = bp_config.copy()
77
+ bp_cls_name = bp_config.pop("backpressure_cls", None)
78
+ if bp_cls_name is None:
79
+ raise ValueError(
80
+ "backpressure config must include 'backpressure_cls' "
81
+ "(e.g. 'EMABackpressureDetector')"
82
+ )
83
+ policy_cls_name = bp_config.pop("policy_cls", None)
84
+ policy = _import_class(policy_cls_name)() if policy_cls_name else None
85
+ detector_cls: type[BackpressureDetector] = _import_class(bp_cls_name)
86
+ defaults = detector_cls.default_config(tier_type, locality=locality)
87
+ if defaults:
88
+ for k, v in defaults.items():
89
+ bp_config.setdefault(k, v)
90
+ bp_detector = detector_cls(policy=policy, **bp_config)
91
return tier_cls(
92
offloading_spec=offloading_spec,
93
primary_kv_view=primary_kv_view,
94
tier_type=tier_type,
95
+ backpressure_detector=bp_detector,
96
**config,
97
)
98
vllm/v1/kv_offload/tiering/fs/manager.py
+14
-1
@@ -40,6 +40,7 @@ from vllm.v1.kv_offload.base import (
40
)
41
from vllm.v1.kv_offload.file_mapper import FileMapper
42
from vllm.v1.kv_offload.tiering.async_lookup import AsyncLookupManager
43
+from vllm.v1.kv_offload.tiering.backpressure import BackpressureDetector
44
from vllm.v1.kv_offload.tiering.base import (
45
JobId,
46
JobResult,
@@ -115,6 +116,7 @@ class FileSystemTierManager(SecondaryTierManager):
116
n_write_threads: int = 16,
117
enable_kv_events: bool = False,
118
locality: str | None = None,
119
+ backpressure_detector: BackpressureDetector | None = None,
120
):
121
"""Args:
122
offloading_spec: Contains normalized offloading configuration and
@@ -129,9 +131,12 @@ class FileSystemTierManager(SecondaryTierManager):
131
cache events are enabled globally (kv_events_config).
132
locality: Whether this tier's storage is LOCAL or REMOTE relative
133
to the publishing vLLM instance.
134
+ backpressure_detector: Optional backpressure detector.
135
136
"""
134
- super().__init__(offloading_spec, primary_kv_view, tier_type)
137
+ super().__init__(
138
+ offloading_spec, primary_kv_view, tier_type, backpressure_detector
139
+ )
140
self.locality = Locality(locality) if locality is not None else None
141
142
self.events: list[OffloadingEvent] | None = None
@@ -150,6 +155,8 @@ class FileSystemTierManager(SecondaryTierManager):
155
# Keys of in-flight load (promotion) jobs, so a failed load can mark
156
# its own cached lookup verdicts False (see get_finished_jobs).
157
self._load_job_keys: dict[JobId, list[OffloadKey]] = {}
158
+ # Block count per in-flight job, used to report transfer_bytes.
159
+ self._job_block_counts: dict[JobId, int] = {}
160
# Per load job: how many blocks loaded before a failure (partial keep).
161
# Written by the pool worker inside the load task before it raises (so
162
# before task_done publishes the job); read on the scheduler thread in
@@ -225,6 +232,7 @@ class FileSystemTierManager(SecondaryTierManager):
232
self._block_size,
233
self._use_o_direct,
234
)
235
+ self._job_block_counts[job_metadata.job_id] = len(keys)
236
self._pool.enqueue_store(job_metadata.job_id, 1, [task])
237
238
@override
@@ -234,6 +242,7 @@ class FileSystemTierManager(SecondaryTierManager):
242
# keys as a miss (see get_finished_jobs).
243
keys = list(job_metadata.keys)
244
self._load_job_keys[job_id] = keys
245
+ self._job_block_counts[job_id] = len(keys)
246
paths = [self.file_mapper.get_file_name(key) for key in keys]
247
offsets = [int(cid) * self._block_size for cid in job_metadata.chunk_ids]
248
@@ -272,6 +281,8 @@ class FileSystemTierManager(SecondaryTierManager):
281
as a miss here (scheduler thread)."""
282
results = []
283
for job_id, success, transfer_time in self._pool.get_finished():
284
+ block_count = self._job_block_counts.pop(job_id, 0)
285
+ transfer_bytes = block_count * self._block_size if block_count else None
286
if self.events is not None:
287
keys = self._store_job_keys.pop(job_id, None)
288
if success and keys:
@@ -299,6 +310,7 @@ class FileSystemTierManager(SecondaryTierManager):
310
success=False,
311
successful_keys=tuple(successful) if successful else None,
312
transfer_time=transfer_time,
313
+ transfer_bytes=transfer_bytes,
314
)
315
)
316
continue
@@ -307,6 +319,7 @@ class FileSystemTierManager(SecondaryTierManager):
319
job_id=job_id,
320
success=success,
321
transfer_time=transfer_time,
322
+ transfer_bytes=transfer_bytes,
323
)
324
)
325
return results
vllm/v1/kv_offload/tiering/manager.py
+51
-1
@@ -238,6 +238,10 @@ class TieringOffloadingManager(OffloadingManager):
238
for tier_idx, tier in enumerate(self.secondary_tiers)
239
}
240
241
+ self._tier_index: dict[SecondaryTierManager, int] = {
242
+ tier: i for i, tier in enumerate(self.secondary_tiers)
243
+ }
244
+
245
@property
246
def _transfer_jobs(self) -> dict[JobId, JobMetadata]:
247
return self._jobs
@@ -337,6 +341,43 @@ class TieringOffloadingManager(OffloadingManager):
341
self.primary_tier.complete_read(
342
transfer_job.keys, transfer_job.req_context
343
)
344
+ if completed_job.success:
345
+ self._update_backpressure(tier, job_metadata, completed_job)
346
+
347
+ def _should_store_to_tier(
348
+ self, tier: SecondaryTierManager, num_blocks: int
349
+ ) -> bool:
350
+ detector = tier.bp_detector
351
+ if detector is None:
352
+ return True
353
+ return detector.should_store(num_blocks)
354
+
355
+ def _update_backpressure(
356
+ self,
357
+ tier: SecondaryTierManager,
358
+ job_metadata: JobMetadata,
359
+ completed_job: JobResult,
360
+ ) -> None:
361
+ detector = tier.bp_detector
362
+ if detector is None:
363
+ return
364
+ was_under_pressure = detector.is_under_pressure()
365
+ tj = job_metadata.transfer_job
366
+ num_bytes = (
367
+ completed_job.transfer_bytes
368
+ if completed_job.transfer_bytes is not None
369
+ else len(tj.keys) * tier.block_size_bytes
370
+ )
371
+ detector.update(tj.submit_time, num_bytes)
372
+ if detector.is_under_pressure() != was_under_pressure:
373
+ tier_idx = self._tier_index[tier]
374
+ logger.info(
375
+ "Tier #%d (%s) back-pressure %s (stats=%s)",
376
+ tier_idx,
377
+ tier.tier_type,
378
+ "activated" if detector.is_under_pressure() else "cleared",
379
+ detector.stats,
380
+ )
381
382
@override
383
def lookup(
@@ -643,8 +684,10 @@ class TieringOffloadingManager(OffloadingManager):
684
return
685
686
for tier_idx in request_level_tiers:
646
- job_metadata = self.create_store_job(ready_keys, req_context, tier_idx)
687
tier = self.secondary_tiers[tier_idx]
688
+ if not self._should_store_to_tier(tier, len(ready_keys)):
689
+ continue
690
+ job_metadata = self.create_store_job(ready_keys, req_context, tier_idx)
691
tier.submit_store(job_metadata)
692
693
def _flush_pending_cascades(self) -> None:
@@ -698,6 +741,8 @@ class TieringOffloadingManager(OffloadingManager):
741
# eviction during the async transfer). One prepare_read() call per
742
# secondary tier.
743
for tier_idx, tier in enumerate(self.secondary_tiers):
744
+ if not self._should_store_to_tier(tier, len(keys)):
745
+ continue
746
job_metadata = self.create_store_job(keys, req_context, tier_idx)
747
tier.submit_store(job_metadata)
748
@@ -904,6 +949,10 @@ class TieringOffloadingManager(OffloadingManager):
949
del self._req_state[req_id]
950
self._processed_jobs_this_step = False
951
952
+ for tier in self.secondary_tiers:
953
+ if tier.bp_detector is not None:
954
+ tier.bp_detector.reset()
955
+
956
@override
957
def get_stats(self) -> OffloadingConnectorStats | None:
958
stats = self.primary_tier.get_stats()
@@ -911,6 +960,7 @@ class TieringOffloadingManager(OffloadingManager):
960
if stats is not None and stats.is_empty():
961
stats = None
962
963
+ self._metrics.record_backpressure(self.secondary_tiers)
964
metrics_stats = self._metrics.take_stats()
965
if metrics_stats is not None:
966
if stats is None:
vllm/v1/kv_offload/tiering/metrics.py
+31
@@ -12,6 +12,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
12
from vllm.v1.kv_offload.base import LookupResult, OffloadKey, ReqContext
13
from vllm.v1.kv_offload.tiering.base import (
14
JobResult,
15
+ SecondaryTierManager,
16
TieringOffloadingMetrics,
17
TransferJob,
18
)
@@ -130,6 +131,36 @@ class TieringMetricsTracker:
131
TieringOffloadingMetrics.PROMOTION_ALLOCATION_FAILURES
132
)
133
134
+ def record_backpressure(
135
+ self,
136
+ tiers: list[SecondaryTierManager],
137
+ ) -> None:
138
+ for i, tier in enumerate(tiers):
139
+ detector = tier.bp_detector
140
+ if detector is None:
141
+ continue
142
+ label = self.tier_label(i)
143
+ ema = detector.stats.get("store_latency_ema")
144
+ if ema is not None:
145
+ self._stats.set_gauge(
146
+ TieringOffloadingMetrics.BACKPRESSURE_STORE_LATENCY_EMA,
147
+ ema,
148
+ labelvalues=label,
149
+ )
150
+ stores_dropped, blocks_dropped = detector.policy.pop_stores_dropped()
151
+ if stores_dropped > 0:
152
+ self._stats.increase_counter(
153
+ TieringOffloadingMetrics.BACKPRESSURE_STORES_DROPPED,
154
+ stores_dropped,
155
+ labelvalues=label,
156
+ )
157
+ if blocks_dropped > 0:
158
+ self._stats.increase_counter(
159
+ TieringOffloadingMetrics.BACKPRESSURE_BLOCKS_DROPPED,
160
+ blocks_dropped,
161
+ labelvalues=label,
162
+ )
163
+
164
def take_stats(self) -> OffloadingConnectorStats | None:
165
active_transfer_stats = OffloadingConnectorStats()
166
self._observe_active_transfer_stats(active_transfer_stats)
vllm/v1/kv_offload/tiering/obj/manager.py
+9
-1
@@ -22,6 +22,7 @@ from vllm.v1.kv_offload.base import (
22
)
23
from vllm.v1.kv_offload.file_mapper import FileMapper
24
from vllm.v1.kv_offload.tiering.async_lookup import AsyncLookupManager
25
+from vllm.v1.kv_offload.tiering.backpressure import BackpressureDetector
26
from vllm.v1.kv_offload.tiering.base import (
27
JobId,
28
JobResult,
@@ -112,6 +113,7 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
113
io_threads: int = 4,
114
enable_kv_events: bool = False,
115
locality: str | None = None,
116
+ backpressure_detector: BackpressureDetector | None = None,
117
):
118
"""Args:
119
offloading_spec: Offloading configuration.
@@ -125,9 +127,15 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
127
cache events are enabled globally (kv_events_config).
128
locality: Whether this tier's storage is LOCAL or REMOTE relative
129
to the publishing vLLM instance.
130
+ backpressure_detector: Optional backpressure detector.
131
132
"""
130
- super().__init__(offloading_spec, primary_kv_view, tier_type)
133
+ super().__init__(
134
+ offloading_spec,
135
+ primary_kv_view,
136
+ tier_type,
137
+ backpressure_detector,
138
+ )
139
self.locality = Locality(locality) if locality is not None else None
140
141
self.events: list[OffloadingEvent] | None = None
vllm/v1/kv_offload/tiering/p2p/manager.py
+19
-1
@@ -265,7 +265,25 @@ class P2PSecondaryTierManager(SecondaryTierManager):
265
number, or anything convertible to one.
266
267
"""
268
- super().__init__(offloading_spec, primary_kv_view, tier_type)
268
+ backpressure_detector = kwargs.pop("backpressure_detector", None)
269
+ if backpressure_detector is not None:
270
+ # The generic (store-latency) detector is unreliable for P2P: in
271
+ # PD mode dropping a store can leave the decoder waiting until the
272
+ # load timeout instead of failing fast, and rendezvous time makes
273
+ # store latency a poor pressure signal. Reject it until a
274
+ # P2P-specific fail-fast path exists.
275
+ raise ValueError(
276
+ "Backpressure is not supported for the P2P secondary tier. "
277
+ "The generic store-latency detector cannot fail fast in PD "
278
+ "mode and rendezvous time makes store latency an unreliable "
279
+ "pressure signal. Remove the 'backpressure' config from the "
280
+ "p2p tier."
281
+ )
282
+ super().__init__(
283
+ offloading_spec,
284
+ primary_kv_view,
285
+ tier_type,
286
+ )
287
try:
288
timeout_s = float(unbound_store_timeout_s)
289
except (TypeError, ValueError):
vllm/v1/kv_offload/tiering/spec.py
+54
@@ -247,6 +247,34 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
247
assert isinstance(tier_config, dict)
248
tier_cls = SecondaryTierFactory.get_tier_class(tier_config)
249
metrics.update(tier_cls.build_metric_definitions(tier_config))
250
+
251
+ metrics[TieringOffloadingMetrics.BACKPRESSURE_STORE_LATENCY_EMA] = (
252
+ OffloadingGaugeMetadata(
253
+ documentation=(
254
+ "Exponential moving average of store latency "
255
+ "for back-pressure detection, in s/MiB."
256
+ ),
257
+ labelnames=("tier",),
258
+ )
259
+ )
260
+ metrics[TieringOffloadingMetrics.BACKPRESSURE_STORES_DROPPED] = (
261
+ OffloadingCounterMetadata(
262
+ documentation=(
263
+ "Number of store operations dropped due to "
264
+ "back-pressure on a secondary tier."
265
+ ),
266
+ labelnames=("tier",),
267
+ )
268
+ )
269
+ metrics[TieringOffloadingMetrics.BACKPRESSURE_BLOCKS_DROPPED] = (
270
+ OffloadingCounterMetadata(
271
+ documentation=(
272
+ "Number of blocks dropped due to back-pressure on a secondary tier."
273
+ ),
274
+ labelnames=("tier",),
275
+ )
276
+ )
277
+
278
return metrics
279
280
def __init__(self, config: OffloadingConfig):
@@ -259,6 +287,32 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
287
if not isinstance(self.secondary_tier_configs, list):
288
raise ValueError("secondary_tiers must be a list of tier configurations")
289
290
+ # Backpressure config is merged field-by-field in priority order
291
+ # (highest first):
292
+ # 1. Per-tier ``backpressure`` dict in the tier config
293
+ # 2. Top-level ``backpressure`` in kv_connector_extra_config
294
+ # Merging per field (rather than per whole dict) means a partial
295
+ # tier override still inherits missing fields from the top-level
296
+ # default, so the resolved dict reaching the factory is complete.
297
+ # Within each tier's resolved dict, tier-type-aware water marks
298
+ # are filled in last so a bare ``"backpressure": {}`` picks up
299
+ # sensible thresholds for the storage medium.
300
+ bp_defaults = self.extra_config.get("backpressure")
301
+
302
+ for tier_cfg in self.secondary_tier_configs:
303
+ tier_override = tier_cfg.get("backpressure")
304
+ # Overlay from lowest to highest precedence so higher-precedence
305
+ # fields win while lower-precedence ones fill in the gaps.
306
+ merged: dict[str, Any] = {}
307
+ for source in (bp_defaults, tier_override):
308
+ if source:
309
+ merged.update(source)
310
+ # Only set a resolved dict when at least one source contributed
311
+ # (or an explicit ``backpressure`` key was present, e.g. ``{}``),
312
+ # so tiers without any backpressure config stay unconfigured.
313
+ if merged or "backpressure" in tier_cfg:
314
+ tier_cfg["backpressure"] = merged
315
+
316
# Scheduler-side mmap (rank=None); kept for cleanup
317
self._scheduler_mmap: SharedOffloadRegion | None = None
318