[KVConnector][P2P] Configurable unbound-store timeout and one-RTT rejection of a late fetch (#53453)
Signed-off-by: Liran Schour <lirans@il.ibm.com>
liranschour committed
Sep 15, 2026 at 05:38 UTC
17bc3e106acbaeb552892893373659bb68d5020e
3 files changed
+193
-14
docs/features/kv_offloading_usage.md
+22
@@ -203,9 +203,31 @@ Block content hashes must match across instances for peers to exchange blocks (s
203
| `port` | no | `$VLLM_P2P_SIDE_CHANNEL_PORT` (`5710`) | Base port for the control socket. Must be reachable from peers. The bound port is `base + data_parallel_index` (one socket per DP replica). When omitted, the base resolves from the env var below. |
204
| `backends` | no | `["UCX"]` | NIXL transport backends. See [NixlConnector Usage Guide](nixl_connector_usage.md#selecting-a-nixl-transport-backend-plugin) for available backends and selection guidance. |
205
| `num_threads` | no | `4` | NIXL agent worker threads. Only used when `backends` is UCX-only; ignored when any non-UCX backend is requested. |
206
+| `unbound_store_timeout_s` | no | `60` | Seconds a producer holds stored blocks for a consumer that has not fetched them yet. Raise it for deployments whose prefills outlast the default; the blocks keep primary-tier CPU slots pinned for the whole window. Once it expires a late fetch is rejected in a single round trip, so the consumer falls back to local prefill immediately. |
207
208
The `backends` and `num_threads` options mirror the conditional logic used by [`NixlConnector`](nixl_connector_usage.md#selecting-a-nixl-transport-backend-plugin): when any non-UCX backend is configured, NIXL is initialised with `backends=...`; otherwise it falls back to a UCX-only agent with the configured `num_threads`. This lets the P2P tier use a different transport (e.g. `MOONCAKE`, `GDS_MT`, `LIBFABRIC`) than the main `NixlConnector` running in the same process.
209
210
+A producer parks a request's blocks until the consumer's `FetchMsg` arrives; if none arrives
211
+within `unbound_store_timeout_s` the blocks are released so they stop pinning primary-tier
212
+slots. Raise it when prefills legitimately take longer than the default:
213
+
214
+```bash
215
+vllm serve <model> \
216
+ --kv-transfer-config '{
217
+ "kv_connector": "OffloadingConnector",
218
+ "kv_role": "kv_both",
219
+ "kv_connector_extra_config": {
220
+ "spec_name": "TieringOffloadingSpec",
221
+ "secondary_tiers": [
222
+ {
223
+ "type": "p2p",
224
+ "unbound_store_timeout_s": 180
225
+ }
226
+ ]
227
+ }
228
+ }'
229
+```
230
+
231
#### Environment Variables
232
233
Rather than embedding `host`/`port` in each `secondary_tiers` entry, set them once at deploy time via environment variables (mirroring `VLLM_NIXL_SIDE_CHANNEL_HOST`/`VLLM_NIXL_SIDE_CHANNEL_PORT`). Explicit `host`/`port` config keys, when present, take precedence.
tests/v1/kv_offload/tiering/p2p/test_manager.py
+75
-4
@@ -117,6 +117,8 @@ def _make_manager() -> P2PSecondaryTierManager:
117
mgr._sessions = {}
118
mgr._kv_to_session = {}
119
mgr._unbound_stores = {}
120
+ mgr._reaped_stores = {}
121
+ mgr._unbound_store_timeout_s = _UNBOUND_STORE_TIMEOUT_S
122
mgr._failed_serve_ctxs = []
123
return mgr
124
@@ -759,9 +761,9 @@ class TestGetFinished:
761
assert "req-fresh" in mgr._unbound_stores
762
763
def test_unbound_store_reaped_after_timeout(self):
762
- """Unbound stores past _UNBOUND_STORE_TIMEOUT_S surface as failed
763
- and their kv_request_id lands in _failed_req_ids so a late
764
- FetchMsg/lookup doesn't try to satisfy them."""
764
+ """Unbound stores past the reap deadline surface as failed and their
765
+ kv_request_id lands in _reaped_stores so a late FetchMsg is rejected
766
+ instead of parking demand nothing can satisfy."""
767
from vllm.v1.kv_offload.tiering.p2p.manager import _UnboundStoreBatch
768
769
mgr = self._make()
@@ -779,7 +781,7 @@ class TestGetFinished:
781
# 2 baseline + 2 buffered stores
782
assert JobResult(job_id=10, success=False) in results
783
assert JobResult(job_id=11, success=False) in results
782
- assert "req-stale" in mgr._failed_req_ids
784
+ assert "req-stale" in mgr._reaped_stores
785
786
def test_submit_store_parks_unbound_batch(self):
787
"""submit_store on an unbound id appends a batch with a fresh
@@ -1204,6 +1206,75 @@ class TestBidirectionalManager:
1206
assert 201 in b_ok, f"B loads succeeded: {b_ok}"
1207
assert 200 in b_ok, f"B stores succeeded: {b_ok}"
1208
1209
+ def test_late_fetch_after_reap_fails_immediately(self):
1210
+ """A fetch for a reaped kv_request_id fails in one round trip.
1211
+
1212
+ The producer drops parked blocks once unbound_store_timeout_s
1213
+ expires. A consumer whose fetch arrives after that must learn on the
1214
+ next tick via TransferDoneMsg(success=False) — not stall for
1215
+ _LOAD_TIMEOUT_S and then take the abort path.
1216
+ """
1217
+ from vllm.v1.kv_offload.tiering.p2p.session.client import _LOAD_TIMEOUT_S
1218
+
1219
+ mgr_a, mgr_b = _build_paired_managers()
1220
+ kv_id = "req-late"
1221
+
1222
+ # Record every message type leaving the consumer, so the assertion
1223
+ # below can prove no abort was ever needed.
1224
+ sent_from_a: list[str] = []
1225
+ drain = mgr_a._control._drain_outbound_to
1226
+
1227
+ def recording_drain(peer_local_id: str):
1228
+ out = drain(peer_local_id)
1229
+ sent_from_a.extend(msg.get("type") for _, msg in out)
1230
+ return out
1231
+
1232
+ mgr_a._control._drain_outbound_to = recording_drain # type: ignore[method-assign]
1233
+
1234
+ # Producer parks the blocks, then reaps them before any fetch lands.
1235
+ mgr_b.submit_store(
1236
+ _job_metadata(
1237
+ job_id=200,
1238
+ keys=[b"b-block"],
1239
+ chunk_ids=[0],
1240
+ kv_params={"remote_decoder": {"kv_request_id": kv_id}},
1241
+ )
1242
+ )
1243
+ mgr_b._unbound_store_timeout_s = 0.0
1244
+ reap_results = list(mgr_b.get_finished_jobs())
1245
+ assert JobResult(job_id=200, success=False) in reap_results
1246
+ assert kv_id in mgr_b._reaped_stores
1247
+
1248
+ # Consumer now asks for the blocks that no longer exist.
1249
+ consumer_params = {
1250
+ "remote_prefiller": {
1251
+ "kv_request_id": kv_id,
1252
+ "remote_host": "B",
1253
+ "remote_port": 2,
1254
+ },
1255
+ }
1256
+ mgr_a.on_new_request(_req_context(consumer_params))
1257
+ mgr_a.submit_load(
1258
+ _job_metadata(
1259
+ job_id=101,
1260
+ keys=[b"b-block"],
1261
+ chunk_ids=[0],
1262
+ kv_params=consumer_params,
1263
+ )
1264
+ )
1265
+
1266
+ started = time.monotonic()
1267
+ all_a: list[JobResult] = []
1268
+ for _ in range(8):
1269
+ all_a.extend(list(mgr_a.get_finished_jobs()))
1270
+ list(mgr_b.get_finished_jobs())
1271
+ elapsed = time.monotonic() - started
1272
+
1273
+ assert JobResult(job_id=101, success=False) in all_a, all_a
1274
+ assert kv_id in mgr_a._failed_req_ids
1275
+ assert "abort_fetch" not in sent_from_a, sent_from_a
1276
+ assert elapsed < _LOAD_TIMEOUT_S
1277
+
1278
1279
# ---------------------------------------------------------------------------
1280
# _accept_new_peers — duplicate connection rejection
vllm/v1/kv_offload/tiering/p2p/manager.py
+96
-10
@@ -47,10 +47,18 @@ logger = init_logger(__name__)
47
# Reap unbound store batches that have been parked without a FetchMsg
48
# binding them to a session for longer than this. Protects against the
49
# prefiller buffering blocks for a decoder that never asks (decoder died,
50
-# network partition, lost kv_request_id). Must be longer than the per-store
51
-# deadline so the store-timeout path fires first for individual jobs.
50
+# network partition, lost kv_request_id). Applies only while a batch is
51
+# unbound: binding hands it to the session, which times the job out under
52
+# _STORE_TIMEOUT_S instead.
53
+# Overridable per tier via the ``unbound_store_timeout_s`` config key.
54
_UNBOUND_STORE_TIMEOUT_S = 60.0
55
56
+# How long a reaped kv_request_id is remembered so a fetch that arrives
57
+# after the reap can be rejected immediately. Must exceed the consumer's
58
+# load timeout (session.client._LOAD_TIMEOUT_S): past that the consumer has
59
+# already given up on its own, so there is nobody left to reject.
60
+_REAPED_ID_RETENTION_S = 60.0
61
+
62
# Time we wait during shutdown for inflight transfers to drain via
63
# cancel(mode="wait") before falling back to mode="immediate". Bounded
64
# so a wedged peer can't hang shutdown.
@@ -208,6 +216,7 @@ class P2PSecondaryTierManager(SecondaryTierManager):
216
port: int | None = None,
217
backends: list[str] | None = None,
218
num_threads: int = 4,
219
+ unbound_store_timeout_s: float = _UNBOUND_STORE_TIMEOUT_S,
220
**kwargs: Any,
221
) -> None:
222
"""Initialize the P2P secondary tier manager.
@@ -245,9 +254,29 @@ class P2PSecondaryTierManager(SecondaryTierManager):
254
num_threads: NIXL agent worker threads for the UCX-only
255
branch. Ignored when ``backends`` contains a non-UCX
256
entry.
257
+ unbound_store_timeout_s: Seconds a producer holds stored blocks
258
+ for a consumer that has not fetched them yet, before
259
+ ``_reap_unbound_stores`` drops them. Raise it for
260
+ deployments whose prefills outlast the default; the blocks
261
+ keep primary-tier slots pinned for the whole window.
262
**kwargs: Reserved for future tier-specific options.
263
+
264
+ Raises:
265
+ ValueError: If ``unbound_store_timeout_s`` is not a positive
266
+ number, or anything convertible to one.
267
"""
268
super().__init__(offloading_spec, primary_kv_view, tier_type)
269
+ try:
270
+ timeout_s = float(unbound_store_timeout_s)
271
+ except (TypeError, ValueError):
272
+ timeout_s = float("nan")
273
+ if not timeout_s > 0:
274
+ raise ValueError(
275
+ f"unbound_store_timeout_s must be a positive number, got "
276
+ f"{unbound_store_timeout_s!r}"
277
+ )
278
+ self._unbound_store_timeout_s = timeout_s
279
+
280
# Block hashes chain from NONE_HASH (see v1/core/kv_cache_utils.py).
281
# Peers whose seeds differ compute different hashes for identical
282
# content, so lookups silently miss and no KV crosses the wire. The
@@ -303,8 +332,16 @@ class P2PSecondaryTierManager(SecondaryTierManager):
332
# kv_request_id → list of batches submit_store'd before any peer
333
# asked for that id. Drained into a session by _on_session_fetch
334
# when the corresponding FetchMsg arrives, or surfaced as failures
306
- # by _reap_unbound_stores after _UNBOUND_STORE_TIMEOUT_S.
335
+ # by _reap_unbound_stores after _unbound_store_timeout_s.
336
self._unbound_stores: dict[str, list[_UnboundStoreBatch]] = {}
337
+ # kv_request_id → time its parked batches were reaped. Nothing under
338
+ # one of these ids can be served any more, so a FetchMsg for it is
339
+ # rejected on the spot by _poll_once instead of parking demand until
340
+ # the consumer's own load timeout, and submit_store fails rather than
341
+ # re-parking under it — a prefill that outran the timeout would
342
+ # otherwise keep pinning fresh primary-tier slots, one timeout at a
343
+ # time. Entries are pruned after _REAPED_ID_RETENTION_S.
344
+ self._reaped_stores: dict[str, float] = {}
345
346
self._finished_jobs: list[JobResult] = []
347
# kv_request_ids that hit a transport/session failure; On load lookup()
@@ -390,7 +427,7 @@ class P2PSecondaryTierManager(SecondaryTierManager):
427
kv_transfer_params; if a session has bound the id, finish it. If
428
no session has bound the id yet, this is a no-op: parked batches
429
in `_unbound_stores` are left in place and cleaned up only by
393
- `_reap_unbound_stores` after `_UNBOUND_STORE_TIMEOUT_S`.
430
+ `_reap_unbound_stores` after `_unbound_store_timeout_s`.
431
"""
432
source = req_context.get_state(P2PSourceInfo)
433
dest = req_context.get_state(P2PDestInfo)
@@ -447,6 +484,22 @@ class P2PSecondaryTierManager(SecondaryTierManager):
484
self._finished_jobs.append(JobResult(job_id=job_id, success=False))
485
return
486
487
+ # This id's earlier batches were already reaped, so the consumer has
488
+ # either given up or will be rejected by the fetch path. Parking more
489
+ # would pin primary-tier slots for another full timeout on blocks
490
+ # nobody can fetch, so fail the job now — a prefill that outran the
491
+ # timeout falls back to local recompute on the decoder either way.
492
+ if kv_request_id in self._reaped_stores:
493
+ logger.warning(
494
+ "P2P %s: submit_store for reaped kv_request_id=%s job_id=%d "
495
+ "— failing without parking",
496
+ self._local_id,
497
+ kv_request_id,
498
+ job_id,
499
+ )
500
+ self._finished_jobs.append(JobResult(job_id=job_id, success=False))
501
+ return
502
+
503
# Fast path: a session has already received FetchMsg for this id,
504
# so we can route the batch straight into its ServerRole.
505
session = self._kv_to_session.get(kv_request_id)
@@ -707,13 +760,27 @@ class P2PSecondaryTierManager(SecondaryTierManager):
760
"""Time out submit_store batches that no peer has ever fetched.
761
762
Walks `_unbound_stores` for entries whose oldest batch is older
710
- than `_UNBOUND_STORE_TIMEOUT_S`. Drops the kv_request_id, surfaces
711
- every batched job as failed, and adds the id to `_failed_req_ids`
712
- so a late inbound FetchMsg short-circuits to a clean rejection.
763
+ than `_unbound_store_timeout_s`. Drops the kv_request_id, surfaces
764
+ every batched job as failed, and records the id in `_reaped_stores`
765
+ so a late inbound FetchMsg is rejected in one round trip and a late
766
+ `submit_store` fails instead of re-parking under the reaped id.
767
+
768
+ Also expires `_reaped_stores` entries whose consumer can no longer
769
+ be waiting, which is why this runs before the empty check below.
770
"""
771
+ now = time.monotonic()
772
+ if self._reaped_stores:
773
+ retention_deadline = now - _REAPED_ID_RETENTION_S
774
+ for kid in [
775
+ kid
776
+ for kid, reaped_at in self._reaped_stores.items()
777
+ if reaped_at <= retention_deadline
778
+ ]:
779
+ del self._reaped_stores[kid]
780
+
781
if not self._unbound_stores:
782
return
716
- deadline = time.monotonic() - _UNBOUND_STORE_TIMEOUT_S
783
+ deadline = now - self._unbound_store_timeout_s
784
expired: list[str] | None = None
785
for kid, batches in self._unbound_stores.items():
786
# Batches are appended in arrival order, so the head is oldest.
@@ -725,7 +792,7 @@ class P2PSecondaryTierManager(SecondaryTierManager):
792
return
793
for kid in expired:
794
batches = self._unbound_stores.pop(kid)
728
- self._failed_req_ids.add(kid)
795
+ self._reaped_stores[kid] = now
796
for batch in batches:
797
self._finished_jobs.append(
798
JobResult(job_id=batch.job_id, success=False)
@@ -735,7 +802,7 @@ class P2PSecondaryTierManager(SecondaryTierManager):
802
"without a fetch — failing %d job(s)",
803
self._local_id,
804
kid,
738
- _UNBOUND_STORE_TIMEOUT_S,
805
+ self._unbound_store_timeout_s,
806
len(batches),
807
)
808
@@ -779,6 +846,24 @@ class P2PSecondaryTierManager(SecondaryTierManager):
846
# inline in dispatch, so the replayed add_stored_blocks calls
847
# match that demand and submit transfers immediately.
848
for kv_request_id in result.new_fetch_ids:
849
+ if kv_request_id in self._reaped_stores:
850
+ # This id's blocks were already reaped, so no
851
+ # submit_store will ever satisfy the demand on_fetch
852
+ # just recorded. Finalize the round now: the peer gets
853
+ # TransferDoneMsg(success=False) on this tick and falls
854
+ # back to local prefill, instead of parking until its
855
+ # own load timeout expires. The id is deliberately left
856
+ # unbound so any later submit_store keeps taking the
857
+ # reject path. Batches re-parked between the reap and the
858
+ # retention prune are failed here rather than left to a
859
+ # second reap, which would pin their slots for another
860
+ # full timeout.
861
+ session.finish_request(kv_request_id)
862
+ for batch in self._unbound_stores.pop(kv_request_id, ()):
863
+ self._finished_jobs.append(
864
+ JobResult(job_id=batch.job_id, success=False)
865
+ )
866
+ continue
867
self._kv_to_session[kv_request_id] = session
868
for batch in self._unbound_stores.pop(kv_request_id, ()):
869
session.add_stored_blocks(
@@ -810,6 +895,7 @@ class P2PSecondaryTierManager(SecondaryTierManager):
895
JobResult(job_id=batch.job_id, success=False)
896
)
897
self._unbound_stores.clear()
898
+ self._reaped_stores.clear()
899
self._control.close()
900
self._data.close()
901