Skip to content

[Bugfix][KV Offload][P2P] Fault a peer whose source WRITE stalls - #64251

Merged
orozery merged 7 commits into
vllm-project:mainfrom
liranschour:p2p-source-stall-deadline
Oct 29, 2026
Merged

orozery merged 7 commits into
vllm-project:mainfrom
liranschour:p2p-source-stall-deadline

Conversation

@liranschour

@liranschour liranschour commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Overview

Part of #63602 (gap 2, source half). Builds on #64250 (merged), which retains the stalled transfer across the teardown this fault triggers. One commit on main (ffaa825b33).

A source WRITE that NIXL keeps reporting as PROC/PEND past VLLM_P2P_TRANSFER_STALL_TIMEOUT_S (default 20 s) now faults its peer. The fault releases nothing.

Claims

  • A stalled source transfer faults its peer and ends the session. Before this change, nothing ever faulted it, and write_blocks() kept posting new WRITEs to the wedged peer.
  • The fault releases nothing. The stalled transfer and its pins are retained across teardown ([Bugfix][KV Offload][P2P] Retain unconfirmed source WRITEs across session teardown #64250) until the transport reports it stopped.
  • A stall fault skips the 10 s peer-fault drain, since a stalled transfer would never drain.

Validation

The issue's source-side property: a WRITE still pending at 300 s must have faulted its peer, while keeping the transfer and its store job held.

main (with #64250): consumer_failed_at_s=35.0; source_alive=True;  inflight=[0]; pinned=[1]; peer_fault=None                    -> FAILED
this PR:            consumer_failed_at_s=35.0; source_alive=False; inflight=[0]; pinned=[1]; peer_fault=transfer stalled for 20s -> PASSED

The issue body's test_gap2_pending_transfer.py still fails, for an unrelated reason. It hard-codes the consumer failing at exactly 40 s, but since #63781 the consumer fails at 35 s (30 s load + 5 s abort-ack). The check above is the same scenario without that hard-coded timing.

New tests, run with this PR's tests on #64250's code:

$ pytest tests/v1/kv_offload/tiering/p2p/test_sessions.py -k stall
FAILED ...::test_stalled_transfer_faults_peer_without_releasing   E  assert not True
FAILED ...::test_stalled_peer_gets_no_new_writes                  E  assert None is not None
2 failed, 2 passed

(The two that pass guard the no-fault cases: under the deadline, and the deadline disabled with 0.)

With this PR:

$ LD_LIBRARY_PATH=.../nvidia/cu13/lib pytest tests/v1/kv_offload/tiering/p2p
426 passed
$ pre-commit run --files <changed>      # mypy included
Passed

CPU-only, and CI runs it in misc.yaml. The 20 s default is not yet measured on GPU. It is far above the ~131 ms p99 tiering-lock hold we measured on H200 at c256, and above healthy transfer times seen so far. Before this leaves draft, I'd like to measure transfer duration by size under load from NIXL telemetry, and run a real stall (rc_gda re-enabled, as in #63499).

Details

Root cause.

  • NixlTransport.poll() treats PROC/PEND as "wait" forever.
  • _drain_abort holds a stuck transfer and its AbortAck forever, and only logs after 10 s.
  • Nothing records when a transfer was submitted, so nothing faults the peer.
  • write_blocks is gated only on peer_fault, so the source keeps feeding the wedged peer.

In the GPU run in #63602, 36 WRITEs never completed, and pulls from a healthy peer dropped (679/847).

Change.

  • _InflightXfer.submitted_at is set on submit.
  • ServerRole.collect_results() checks for stalls when the transport reports no fault. Once the oldest in-flight transfer is past the timeout, it sets peer_fault = "transfer stalled for Ns" and transfer_stalled.
  • Everything after that is the existing [Bugfix][KV Offload][P2P] Report NIXL submission failures and escalate persistent faults to session teardown #60374 path: no new WRITEs, _escalate_peer_fault then DisconnectMsg (the consumer fails its loads), reap, and the data-plane reconnect backoff.
  • _escalate_peer_fault skips its drain wait when transfer_stalled is set.

Detection lives in ServerRole, not NixlTransport. It sits next to the _STORE_TIMEOUT_S logic it mirrors, it doesn't depend on which transport is used, and the session reproducers exercise it directly.

Why it depends on #64250. Without retention, the teardown this fault triggers would run cancel(mode="immediate") and unpin the stalled transfer's blocks. That is the very release the deadline must not cause.

New env var VLLM_P2P_TRANSFER_STALL_TIMEOUT_S is a float, default 20, and 0 disables it. It is documented in vllm/envs.py and in the P2P table in docs/usage/security.md, next to the other VLLM_P2P_* limits.

Complexity: about 40 non-test lines.


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI. (GPU measurement of the default pending; see Validation.)

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

🤖 Generated with Claude Code

@mergify

mergify Bot commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Documentation preview: https://vllm--64251.org.readthedocs.build/en/64251/

Part of vllm-project#63602 (gap 2, source half). Stacked on the gap 3 retention change.

A transfer NIXL keeps reporting as PROC/PEND was never bounded on the
source: poll() treats it as "wait", the abort drain holds it and its
AbortAck forever, and nothing faults the peer. write_blocks() is gated
only on peer_fault, so the source kept posting WRITEs to a wedged peer
and accumulated stuck transfers and pins.

ServerRole now stamps each transfer's submit time. Once one outlives
VLLM_P2P_TRANSFER_STALL_TIMEOUT_S (default 20 s, 0 disables), the peer is
faulted through the existing vllm-project#60374 path: no new WRITEs, DisconnectMsg so
the consumer fails its loads, reap with the data-plane reconnect backoff.
It releases nothing; the reap retains the stalled transfer and its pins
until the transport reports it stopped. A stall fault skips the 10 s
peer-fault drain, since the stalled transfer would never drain.

Signed-off-by: Liran Schour <lirans@il.ibm.com>
@liranschour
liranschour force-pushed the p2p-source-stall-deadline branch from 6e57bb4 to ffaa825 Compare October 28, 2026 15:41
@liranschour
liranschour marked this pull request as ready for review October 28, 2026 15:49

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@orozery orozery left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some unverified Claude comments.

Comment on lines +778 to +779
elif self.peer_fault is None:
self._check_stalled_transfers()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude:
The check runs before the done/failed loops, so it faults the peer over transfers this very poll already resolved — move it after the drain.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the stall check after the done/failed drain.

Comment on lines +1255 to +1257
now = time.monotonic()
oldest = min(x.submitted_at for x in self._inflight.values())
age = now - oldest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude:
Age-since-submit includes time nobody polled, so one scheduler stall past the timeout faults every session that has an in-flight WRITE; measure progress between polls instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I think measuring age since submit is the right call. The reorder for comment 1 (e260881) takes care of most of the scheduler-stall case, and the rest is safe.

What the reorder covers. The stall check now runs after the done/failed drain. Say the scheduler doesn't poll for longer than the timeout. Every WRITE that finished during the gap comes back in that next poll() and is drained as done before _check_stalled_transfers looks at the oldest in-flight transfer. Missed polls don't hold transfers up either: UCX runs its own progress threads, so WRITEs keep going while nobody polls. So a scheduler stall no longer faults sessions whose WRITEs actually finished. The fault fires only if a WRITE is still PROC/PEND, by a fresh state read, more than 20s after submit. On a healthy lane that's milliseconds of work, so we treat it as a real stall wherever the time went.

Why per-poll progress doesn't help. NIXL only gives us a transfer's state, not how many bytes have moved. Between two polls a pending WRITE looks the same whether it is moving or wedged. "Progress between polls" would just mean "still PROC/PEND". That's what the check already tests, and it uses fresh state.

Why the fault is safe. The stall check never releases anything on a timer; it only marks the peer faulted. Every release after that goes through a fresh state read:

  • Teardown: close() cancels with mode="wait". That checks check_xfer_state per transfer and releases only DONE/ERR. A WRITE still PROC/PEND is retained, with its source pins, not freed.
  • Hold cap: _poll_retained calls poll_retired() (another state read) before its 30s cap force-releases anything. Only transfers still running after that read are released.
  • Abort drain and store timeout: both cancel with mode="wait" too.

The only release that skips the state check is the shutdown drain's mode="immediate" fallback, and it runs only after its own wait-and-poll loop has timed out.

So even a fault after a long gap can't release a WRITE that might still be landing. At worst, a session whose WRITE is still pending after the gap gets torn down. That's what the stall timeout is for.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather keep the age-since-submit measure. There are two kinds of polling gap:

  1. The scheduler thread is busy but the process runs. NIXL's progress thread and the UCX worker threads (num_threads=4) keep the WRITE moving without us polling. After the reorder in e260881, anything that completed during the gap is drained before the stall check. A transfer still PROC/PEND 20s after submit had 20s of real progress time, so faulting it is correct.
  2. The whole process is frozen (SIGSTOP, VM pause). We can't tell this apart from a stall, and we don't need to: the session is lost either way. The peer's ZMQ heartbeat (10s) drops us and the consumer's load timeout aborts the fetch. Clamping the gap would keep a session alive that the peer has already reaped, and only delay our own teardown.

In both cases the fault releases nothing. Teardown and the hold cap re-read transfer state before releasing anything, so there's no correctness risk in calling the transfer stalled.

…tions

The stall check ran before the done/failed loops, so a transfer the
transport had just reported complete was still in _inflight and could
fault the peer. Run it after the drain.

Addresses review comment on vllm-project#64251.

Signed-off-by: Liran Schour <lirans@il.ibm.com>
@liranschour
liranschour requested a review from orozery October 29, 2026 10:22
@orozery orozery added the ready ONLY add when PR is ready to merge/full CI is needed label Oct 29, 2026
@orozery

orozery commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

/ci run

@github-actions

Copy link
Copy Markdown

✅ @liranschour, CI is now available for this PR.

  • /ci run starts upstream CI; /amd-ci run starts AMD CI only.
  • Run commands test your branch with every commit currently on its upstream target branch. If it is behind, the bot merges the target branch into it first (pull before pushing again). Append --allow-stale to a run command to test an outdated branch as is, at your own risk.
  • /ci retry retries failed jobs in the CI build for the current PR head. If the current head has no CI build, it starts a new CI build for the current head containing only jobs that failed in the latest earlier CI build for this PR.
  • /amd-ci retry retries failed jobs in AMD CI for the current PR head. Use /amd-ci run when the current head has no AMD CI build.
  • /ci cancel cancels scheduled or running CI builds for this PR branch; /amd-ci cancel does the same for AMD CI only.

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #98271 for commit c4fbde6d242a.

ℹ️ This PR is 6 commits behind upstream main. Merged upstream main into this branch as c4fbde6d242a before starting CI. Pull the branch before pushing more changes.

@liranschour

Copy link
Copy Markdown
Contributor Author

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #98292 for commit 58f0f2f7ef60.

ℹ️ This PR is 3 commits behind upstream main. Merged upstream main into this branch as 58f0f2f7ef60 before starting CI. Pull the branch before pushing more changes.

@orozery
orozery merged commit 491067f into vllm-project:main Oct 29, 2026
193 checks passed
jiuyueQ added a commit to jiuyueQ/vllm that referenced this pull request Nov 2, 2026
Retire queued fetches locally, release producer storage without closing newer work,
and preserve post-disconnect abort acknowledgements after rebasing on vllm-project#64251.

Co-authored-by: OpenAI Codex
Signed-off-by: jiuyueQ <631905507@qq.com>
jiuyueQ added a commit to jiuyueQ/vllm that referenced this pull request Nov 3, 2026
Retire queued fetches locally, release producer storage without closing newer work,
and preserve post-disconnect abort acknowledgements after rebasing on vllm-project#64251.

Co-authored-by: OpenAI Codex
Signed-off-by: jiuyueQ <631905507@qq.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation ready ONLY add when PR is ready to merge/full CI is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants