[KVConnector][P2P] Configurable unbound-store timeout and one-RTT rejection of a late fetch - #53453
Conversation
…tch in one RTT The P2P secondary tier parks a producer's stored blocks until the consumer's FetchMsg binds them to a session, and reaps them after 60 s so they stop pinning primary-tier CPU slots. Two problems, both reported in vllm-project#53128: 1. The 60 s ceiling is not configurable, so deployments whose prefills legitimately run longer have no recourse. Add an `unbound_store_timeout_s` tier config key (per-tier, like `host`/`port`/`backends`/`num_threads`), defaulting to the existing 60 s. 2. A fetch arriving after the reap was never rejected: `_poll_once` bound the id to the session and ServerRole parked demand no submit_store could ever satisfy, so the consumer burned its full 30 s load timeout plus an abort round trip before falling back to local prefill. Track reaped ids in a time-pruned `_reaped_stores` map and finalize the round on the spot, so the peer gets TransferDoneMsg(success=False) on the same tick. This replaces the reap path's `_failed_req_ids.add(kid)`, which claimed to make "a late inbound FetchMsg short-circuit to a clean rejection" but was read by no fetch-path code, and — since the producer request had already run on_request_finished, the only discard site — grew for the process lifetime. Signed-off-by: Liran Schour <lirans@il.ibm.com>
|
Documentation preview: https://vllm--53453.org.readthedocs.build/en/53453/ |
| # own load timeout expires. The id is deliberately left | ||
| # unbound so any later submit_store keeps taking the | ||
| # unbound path. | ||
| session.finish_request(kv_request_id) |
There was a problem hiding this comment.
Claude:
submit_store never consults _reaped_stores, so a producer whose prefill outruns the timeout keeps parking new batches under the reaped id.
Fix: also drain/fail self._unbound_stores.pop(kv_request_id, ()) in the reject branch, and short-circuit submit_store on a reaped id.
| ValueError: If ``unbound_store_timeout_s`` is not positive. | ||
| """ | ||
| super().__init__(offloading_spec, primary_kv_view, tier_type) | ||
| if unbound_store_timeout_s <= 0: |
There was a problem hiding this comment.
Probably worth converting to float first (with a try).
| ValueError: If ``unbound_store_timeout_s`` is not positive. | ||
| """ | ||
| super().__init__(offloading_spec, primary_kv_view, tier_type) | ||
| if unbound_store_timeout_s <= 0: |
There was a problem hiding this comment.
Claude: consider also rejecting values below _STORE_TIMEOUT_S.
There was a problem hiding this comment.
No need. These are 2 unrelated timeouts.
The reap path records the id in `_reaped_stores` and deliberately leaves it unbound so a late FetchMsg is rejected rather than served. But `submit_store` never consulted that map, so a producer whose prefill outruns `unbound_store_timeout_s` kept parking fresh batches under the reaped id: blocks nobody can fetch, pinning primary-tier CPU slots for another full timeout, once per reap cycle. Consult `_reaped_stores` in `submit_store` and fail the job immediately — the decoder falls back to local recompute either way. The fetch-path reject branch now also drains and fails anything still parked under the reaped id, so a re-parked batch can't outlive its rejection. Signed-off-by: Liran Schour <lirans@il.ibm.com>
Signed-off-by: Liran Schour <lirans@il.ibm.com>
The value is splatted into __init__ verbatim from the user's kv_connector_extra_config JSON (SecondaryTierFactory.create_secondary_tier passes **config), so the float annotation is enforced by nothing. Checking the raw value first meant a quoted "180" -- what deploy tooling that templates the JSON commonly produces -- died on a TypeError from the comparison, even though float() on the next line would have accepted it. Unconvertible values fared no better: the error named no config key. Coerce first and funnel both failure modes into one ValueError that names the key and echoes the offending value. The check is inverted to `not timeout_s > 0` so NaN is rejected as well; it passes `<= 0` and then disables reaping outright, since no deadline comparison against it is True. Also drop the stale claim that this deadline must exceed the per-store one "so the store-timeout path fires first for individual jobs", which implied the two can be pending on the same job and race. submit_store routes a batch straight to the session when its id is already bound and parks it otherwise, and binding pops every parked batch out of _unbound_stores, so a job is only ever under one of the two deadlines. Signed-off-by: Liran Schour <lirans@il.ibm.com>
|
/ci run |
|
✅ @liranschour, CI is now available for this PR.
|
|
✅ Triggered Buildkite CI #88816 for commit |
…ection of a late fetch (vllm-project#53453) Signed-off-by: Liran Schour <lirans@il.ibm.com>
Purpose
Fixes the two problems reported in #53128 for the
p2psecondary offloading tier.A producer parks a request's stored KV blocks until the consumer's
FetchMsgbinds them to asession, and reaps them after 60 s so they stop pinning primary-tier CPU slots.
The 60 s ceiling was not configurable. Deployments whose prefills legitimately run
longer than that had no recourse. This adds an
unbound_store_timeout_stier config key,defaulting to the existing 60 s, shaped like the tier's other keys (
host,port,backends,num_threads) — i.e. per-tier, documented, and validated:A fetch arriving after the reap was never rejected — this is the user-visible damage.
_poll_oncebound the id to the session andServerRoleparked demand that nosubmit_storecould ever satisfy, so the consumer burned its full_LOAD_TIMEOUT_S(30 s) plus an abort round trip before falling back to local prefill.Reaped ids now go into a time-pruned
_reaped_storesmap and_poll_oncefinalizes theround on the spot, so the peer receives
TransferDoneMsg(success=False)on the same tickand recomputes immediately. No protocol change and no new session API — this reuses the
existing
finish_request→_finalize_outboundterminal path.Where the reap clock starts (first vs. last stored chunk) is deliberately left alone: the
reporter measured only ~6 s of spread when idle and ~13 s under load, which is not what makes
the failure painful.
As a side effect this removes a real leak. The reap path's
self._failed_req_ids.add(kid)wasdocumented as making "a late inbound
FetchMsgshort-circuit to a clean rejection", but nocode on the fetch path ever read that set, so the claim never held; and because the producer
request had already run
on_request_finished(the onlydiscardsite), the id was neverremoved and the set grew for the lifetime of the process.
Not a duplicate
unbound_store_timeout_sknob via an undocumentedkwargs.popandnothing else. The knob alone leaves the 30 s consumer stall in place, which is the actual
regression; its PR body also misdescribes the change ("keep it until it is safely
bound/consumed" — it parameterises the reap, it does not remove it). This PR carries both
halves of the fix and shapes the knob as documented, validated tier config.
Test plan
New test
test_late_fetch_after_reap_fails_immediatelyasserts the path, not just theoutcome: the consumer's load job completes with
success=False, noAbortFetchMsgevercrosses the wire (proving it came from
TransferDoneMsg, not the timeout-then-abort path),and elapsed wall clock is far below
_LOAD_TIMEOUT_S. Verified that the test fails on theunpatched manager — the consumer gets no result at all within the bounded poll count.
The existing
test_unbound_store_reaped_after_timeoutwas updated for the_failed_req_ids→
_reaped_storesmove.No model eval: the change affects only failure-path timing and a config default, never
generated output.
AI assistance
AI assistance (Claude Code) was used to write this change. I reviewed every changed line and
ran the test commands above myself.
Closes #53128
🤖 Generated with Claude Code