Skip to content

[Bugfix][KV Offload] Roll back failed P2P NIXL registration - #63628

Merged
orozery merged 3 commits into
vllm-project:mainfrom
Levius-Fubuki:submit/p2p-nixl-registration-rollback
Oct 26, 2026
Merged

orozery merged 3 commits into
vllm-project:mainfrom
Levius-Fubuki:submit/p2p-nixl-registration-rollback

Conversation

@Levius-Fubuki

@Levius-Fubuki Levius-Fubuki commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Overview

Roll back a newly registered NIXL agent when descriptor preparation fails in the P2P KV-offload transport. Related to #63602, gap 4 only.

AI assistance (OpenAI Codex) was used for investigation, implementation, tests, and this description. The human submitter confirmed completion of manual review and authorized submission. The reviewed contribution carries their DCO sign-off. Original-submission results below are the recorded server runs; review follow-up results are the recorded local runs. No additional human test execution is asserted.

Claims

  • A failure in get_xfer_descs or prep_xfer_dlist rolls back the unpublished remote registration.
  • A failed repeat using already-published native metadata leaves that registration owned by its existing peer.
  • Cleanup failure is logged without replacing the original registration exception. Native cleanup success cannot be guaranteed when NIXL itself rejects removal.

Validation

The table and native validation below describe the original submission, 18349008c; follow-up results are recorded separately below.

Base: a7407ab44557802ca32a0ba2c2707362d7d21563.

Check Base Changed
Eight new constructor-based cases 6 failed, 2 passed 8 passed
Complete data-transport file Not measured 64 passed
Data transport, sessions, ZMQ transport Not measured 223 passed
Full P2P suite, including manager Not measured 319 passed
Native NIXL 1.5.0 DRAM/UCX reproducer Metadata survives descriptor failure Rollback succeeds; 6 real WRITE byte comparisons pass
Applicable pre-commit hooks Not measured Passed, including pinned Ruff, mypy, and test tethering
.venv/bin/python -m pytest \
  --confcutdir=tests/v1/kv_offload/tiering/p2p \
  tests/v1/kv_offload/tiering/p2p/test_data_transport.py \
  tests/v1/kv_offload/tiering/p2p/test_sessions.py \
  tests/v1/kv_offload/tiering/p2p/test_zmq_transport.py -q

.venv/bin/pre-commit run --files \
  vllm/v1/kv_offload/tiering/p2p/data/nixl.py \
  tests/v1/kv_offload/tiering/p2p/test_data_transport.py

Tests construct the real NixlTransport; only the external NIXL boundary is mocked. Native validation uses the production transport with two real NIXL agents, host buffers, UCX TCP/shared memory/CUDA-copy, injected descriptor failures, native metadata queries, retries, and exact byte comparisons. Both descriptor stages and an idempotent repeat are checked.

This source-level Python validation used Python 3.12.3 and the server's existing torch 2.12.1+cu130. It did not build main's torch 2.14 native extensions. The command excludes the heavyweight root tests/conftest.py, and uses the P2P suite's real conftest. No LLM/OpenAI serving run, output-quality evaluation, or RDMA/multi-node fault injection was performed; those are not implied by the transport results.

Review follow-up — sync registration

Commit 6ed41ccf1b routes add_remote_peer() through add_remote_peer_async(...).result(), so synchronous and asynchronous builds share the single registration worker. Superseded synchronous registrations now raise, and the rollback check documents its serialization requirement.

Validation of this follow-up used macOS/Python 3.12 with the real NixlTransport constructor and a mocked external NIXL boundary:

Check Original PR 18349008c With follow-up
Race regression plus sync/async superseded-registration cases 2 failed, 1 passed 3 passed within the full suite
Complete data-transport file 64 passed before adding the regressions 66 passed
Full P2P suite Previously recorded server result: 319 passed 321 passed
Applicable pre-commit hooks Previously passed Passed, including pinned Ruff, mypy, and test tethering

The before-fix failures were assert set() == {'peer'} (the newer registration's agent was removed) and Failed: DID NOT RAISE RuntimeError (superseded sync registration returned silently).

# With the final tests and original nixl.py from 18349008c:
.venv/bin/python -m pytest \
  --confcutdir=tests/v1/kv_offload/tiering/p2p \
  tests/v1/kv_offload/tiering/p2p/test_data_transport.py \
  -k 'failed_registration_keeps_newer_sync_agent or registration_landing_after_remove_is_discarded' -q
# 2 failed, 1 passed, 63 deselected

# With the follow-up:
.venv/bin/python -m pytest \
  --confcutdir=tests/v1/kv_offload/tiering/p2p \
  tests/v1/kv_offload/tiering/p2p -q
# 321 passed

.venv/bin/pre-commit run --files \
  vllm/v1/kv_offload/tiering/p2p/data/nixl.py \
  tests/v1/kv_offload/tiering/p2p/test_data_transport.py
# All applicable hooks passed

Both baseline and changed runs emit the same missing generated vllm._version warning. No new native-NIXL/RDMA or serving validation is claimed for this follow-up. The already-existing dlist replacement leak remains outside this PR.

Details

The registration worker can register native metadata and then fail before _publish_peer records cleanup ownership. _build_peer_handles now uses the existing independent teardown helper on that exception path, while preserving the exception and avoiding removal of an already-published native name. All registration builds now use the existing single-worker executor, including the synchronous entry point; the generation/publish flow is unchanged. No steady-state inference work, synchronization, public option, or model arithmetic is added.

Duplicate checks covered the issue discussion, open PRs referencing #63602, and P2P/NIXL rollback keywords. #59524 fixes incomplete registration in the separate KV connector nixl/base_worker.py; this change fixes the P2P secondary-tier transport. #61667 covers consumer-slot quarantine for unconfirmed writes. Contributor tejachavali expressed interest in the broader issue; this PR does not claim assignment or close the full issue.

The existing Buildkite v1-kv-offload step selects the data-transport tests via pytest -v -s v1/kv_offload, and its source dependencies include vllm/v1/ and tests/v1/kv_offload.


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: Reuses existing cleanup and limits changes to the P2P registration failure path.
  • Testing and Validation: Regression and native transport evidence, reproduction commands, and untested configurations are recorded; the submitter confirmed completion of manual review.
  • Code Quality and Style: Applicable pre-commit hooks pass; no new user configuration or model behavior is introduced.
  • Pull Request Contents: Scope, duplication checks, evidence, and limitations are explicit.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

Co-authored-by: OpenAI Codex
Signed-off-by: levius <2114377220@qq.com>
Copilot AI balanced review requested due to automatic review settings October 26, 2026 00:53

@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.

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@nilig nilig 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.

The published-name check leaves a window when the synchronous add_remote_peer runs alongside an async registration for the same peer. If the older registration fails after the newer one has called add_remote_agent() but before its _publish_peer, the name is not published yet, so the rollback removes the agent the newer registration then publishes. The transport ends up with a published dlist for an agent that no longer exists. Reproduced at 18349008c with the real NixlTransport constructor and a fake external agent:

B entered prep while A was blocked: True
published={'peer:1': 'nixl-peer-name'} registered=set()
AssertionError: published agent was removed

The P2P session only calls add_remote_peer_async, so this is an API-level race, not a path production hits today. Wonder if we could also route the synchronous add_remote_peer through _reg_executor: async registrations are already serialized there, so the published-name check and the rollback would both finish before the next build starts. The synchronous call would then raise when superseded, as the async one does. Probe below in case it is useful as a regression test.

test_probe_window.py
"""Older failed registration A rolls back while newer sync registration B
has added the agent but not yet published it."""

import threading
from unittest.mock import MagicMock, patch

import numpy as np

from vllm.v1.kv_offload.tiering.p2p.data.nixl import NixlTransport


def test_rollback_while_newer_registration_is_building():
    agent = MagicMock()
    with (
        patch("vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgent", return_value=agent),
        patch("vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgentConfig"),
    ):
        transport = NixlTransport("test:1", memoryview(np.zeros((8, 1024), dtype=np.uint8)))
    registered: set[str] = set()

    def add_remote_agent(metadata):
        registered.add("nixl-peer-name")
        return "nixl-peer-name"

    a_in_prep, release_a = threading.Event(), threading.Event()
    b_in_prep, release_b = threading.Event(), threading.Event()
    calls = 0

    def prep_xfer_dlist(*args, **kwargs):
        nonlocal calls
        calls += 1
        if calls == 1:
            a_in_prep.set()
            assert release_a.wait(10)
            raise RuntimeError("A failed")
        b_in_prep.set()
        assert release_b.wait(10)
        return MagicMock(name="remote_dlist")

    agent.add_remote_agent.side_effect = add_remote_agent
    agent.remove_remote_agent.side_effect = registered.discard
    agent.prep_xfer_dlist.side_effect = prep_xfer_dlist
    errors = []

    def register_b():
        try:
            transport.add_remote_peer("peer:1", b"meta", 0x1000, 8, 1024)
        except BaseException as exc:
            errors.append(exc)

    try:
        old = transport.add_remote_peer_async("peer:1", b"meta", 0x1000, 8, 1024)
        assert a_in_prep.wait(5)
        transport.remove_remote_peer("peer:1")
        b = threading.Thread(target=register_b)
        b.start()
        concurrent = b_in_prep.wait(1.0)
        print(f"B entered prep while A was blocked: {concurrent}")
        release_a.set()
        try:
            old.result(timeout=5)
        except RuntimeError as exc:
            print(f"A: {exc}")
        assert b_in_prep.wait(5)
        release_b.set()
        b.join(timeout=5)
        published = dict(transport._peer_nixl_names)
        print(f"published={published} registered={registered} b_errors={errors}")
        assert errors == []
        assert registered == {"nixl-peer-name"}, "published agent was removed"
        assert transport.write_blocks("peer:1", [0], [1]) is not None
    finally:
        release_a.set()
        release_b.set()
        transport.close()

Drop it into tests/v1/kv_offload/tiering/p2p/ and run pytest --confcutdir=tests/v1/kv_offload/tiering/p2p tests/v1/kv_offload/tiering/p2p/test_probe_window.py -s.

@liranschour liranschour 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.

Thanks, this closes gap 4 cleanly. I checked the rollback against the registration flow on main and ran test_data_transport.py locally (64 passed).

The production path looks correct. The session only registers through add_remote_peer_async, so every build runs on the single _reg_executor worker. Between the published check and the release, no other build can publish the same NIXL name. A concurrent remove_remote_peer can at worst cause a second remove_remote_agent, which _release_remote_handles already logs and survives. Re-raising the original exception while _release_remote_handles logs cleanup failures is the right split.

Two inline comments: the sync-path race nilig raised, and a small nit.

Not for this PR, just noting it: re-registering a peer that's already published, without removing it first, overwrites _remote_dlists[peer_id] in _publish_peer and never releases the old dlist handle. That's on main already, so it would be a separate fix.

gen = self._begin_registration(peer_id)
nixl_name, remote_dlist = self._build_peer_handles(
agent_metadata, base_addr, num_blocks, block_len
peer_id, agent_metadata, base_addr, num_blocks, block_len

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.

Agree with nilig's race. I think it's worth fixing here. The rollback's ownership check is only sound because builds run one at a time on _reg_executor. This sync path builds on the caller's thread, so it can overlap an async build. An older failed registration can then remove an agent that this call is about to publish. Routing it through the executor removes the gap:

self.add_remote_peer_async(
    peer_id, agent_metadata, base_addr, num_blocks, block_len
).result()

Nothing calls this from the registration worker, so it can't deadlock. One behaviour change: a superseded sync registration now raises instead of returning silently. Today it ignores _publish_peer's False, which already looks like a bug to me.

Could you add a regression test as well? An older registration fails while a newer one for the same peer has added the agent but not yet published it, and the newer one should stay registered and usable for write_blocks.

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.

@liranschour Fixed the sync race, added regression coverage and the serialization comment in 6ed41cc. Full P2P suite: 321 passed; pre-commit passed. Could you please re-review?

remote_dlist = self._agent.prep_xfer_dlist(nixl_name, xfer_dlist)
except Exception:
with self._peer_lock:
published = nixl_name in self._peer_nixl_names.values()

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.

Nit: could you add a short comment here noting that this check relies on builds being serialized on _reg_executor? Otherwise adding workers, or a path that skips the executor, would quietly break the ownership check.

Co-authored-by: OpenAI Codex
Signed-off-by: levius <2114377220@qq.com>

@liranschour liranschour 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.

Thanks, verified on 6ed41cc: full P2P suite passes (321), and both new tests fail when I revert nixl.py to 1834900, so they catch the race and the silent superseded sync path. LGTM.

@orozery orozery added the ready ONLY add when PR is ready to merge/full CI is needed label Oct 26, 2026
@orozery

orozery commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

/ci run

@github-actions

Copy link
Copy Markdown

✅ @Levius-Fubuki, 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 #97288 for commit 930e0be02210.

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

@orozery
orozery merged commit 8a4b018 into vllm-project:main Oct 26, 2026
78 checks passed
Micador pushed a commit to Micador/vllm that referenced this pull request Oct 27, 2026
…ject#63628)

Signed-off-by: levius <2114377220@qq.com>
Co-authored-by: vllm-agent <claw@inferact.ai>
Signed-off-by: Christopher Mackenzie <christopher.mackenzie.software@gmail.com>
Micador pushed a commit to Micador/vllm that referenced this pull request Oct 27, 2026
…ject#63628)

Signed-off-by: levius <2114377220@qq.com>
Co-authored-by: vllm-agent <claw@inferact.ai>
Signed-off-by: Christopher Mackenzie <christopher.mackenzie.software@gmail.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 kv-connector 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.

6 participants