Skip to content

[Test][RL] Add weight update E2E tests - #53541

Open
Windfeng8 wants to merge 12 commits into
vllm-project:mainfrom
Windfeng8:test_vllm/weight-update
Open

Windfeng8 wants to merge 12 commits into
vllm-project:mainfrom
Windfeng8:test_vllm/weight-update

Conversation

@Windfeng8

@Windfeng8 Windfeng8 commented Aug 24, 2026 •

Copy link
Copy Markdown

Purpose

This PR adds end-to-end coverage for the RL weight-transfer lifecycle described in RFC #45585 and consolidated in RFC #55421.

The test exercises real weight transfers through the public HTTP client and validates the committed model numerically against the trainer. It covers:

  • IPC and NCCL weight-transfer backends.

  • MRV1 and MRV2 model runners.

  • Repeated RL update rounds, including a level-2 sleep/wake cycle.

  • Weight-version propagation through the finish handshake.

  • Explicit prefix-cache invalidation through pause(clear_cache=True).

  • Per-token log-probability agreement between vLLM and the trainer model.

  • Shared server lifecycle and cleanup behavior for the existing pause/resume suite.

This PR intentionally does not add product metrics, a frontend-specific state machine, or model/quantization reload matrices. Those concerns are covered separately or remain next to their owning components.

Test Coverage

Weight-transfer matrix

Model runner Backend GPU requirement Coverage
MRV1 IPC 1 GPU Real trainer-to-vLLM transfer
MRV2 IPC 1 GPU Real trainer-to-vLLM transfer
MRV1 NCCL 2 GPUs Trainer and inference on separate GPUs
MRV2 NCCL 2 GPUs Trainer and inference on separate GPUs

All four combinations use facebook/opt-125m for both the trainer and inference server. The trainer mutates a real output-embedding tensor, and ModuleSource sends the resulting model state through the selected backend.

Weight-update lifecycle

Each backend/runner case performs the following sequence:

  1. Start an inference server configured for the selected backend and model runner.
  2. Load the same checkpoint on the trainer GPU.
  3. Generate a baseline response and compare its generated-token log probabilities with the trainer.
  4. Populate the prefix cache.
  5. Change the trainer’s output-embedding weights from checkpoint state A to state B.
  6. Pause generation with clear_cache=True.
  7. Transfer B through the real trainer/client protocol and commit weight version B.
  8. Resume generation and verify:
    • /weight_info reports version B;
    • the first request has no cached prompt tokens;
    • vLLM’s generated-token log probabilities match trainer B;
    • the prefix cache becomes populated again.
  9. Restore the trainer weights to state A.
  10. Pause with cache invalidation, enter level-2 sleep, and wake only the model weights.
  11. Transfer A, commit version A-restored, wake the KV cache, and resume.
  12. Verify:
  • /weight_info reports A-restored;
  • the restored output token IDs match the baseline;
  • vLLM’s log probabilities match the restored trainer;
  • cache invalidation and subsequent cache reuse behave as expected.

The numerical oracle evaluates the exact tokens generated by vLLM rather than comparing independently generated text. Token log probabilities are compared with an absolute tolerance of 0.05.

Server lifecycle and stability

The shared test server now uses the repository’s RemoteOpenAIServer lifecycle for port allocation, process-group shutdown, and GPU-memory release checks.

For the pause/resume suite:

  • One module-scoped server is reused for each MRV1/MRV2 configuration.
  • Function-scoped global cleanup is skipped while the shared server is still active.
  • Parent-process distributed and memory cleanup runs only after the remote server has fully exited.
  • Background requests use bounded waits and preserve thread exceptions for assertion and diagnostics.

For the weight-transfer suite:

  • Each backend/runner combination owns an isolated server, trainer, transport, and model state.
  • The server exits before pytest performs function-scoped global cleanup.
  • Trainer shutdown and CUDA-memory cleanup run in finally blocks.

This preserves server reuse where state can safely be shared without introducing additional server startups.

CI integration

The regular entrypoints job runs tests marked not distributed, including the single-GPU IPC cases.

A dedicated Buildkite step runs the NCCL cases:

pytest -v -s \
  entrypoints/serve/dev/rlhf/state_transitions/test_weight_update.py \
  -m distributed

The NCCL step requests two H100 GPUs so the trainer and inference server can run on separate devices.

Test results

The weight-transfer and pause/resume groups completed successfully on a dual-H100 system:

Group Result Duration
Weight transfer 4 passed 129.14s
Pause/resume 12 passed 95.91s

Weight-transfer case durations:

Backend Runner Duration
NCCL MRV2 36.12s
NCCL MRV1 35.69s
IPC MRV1 29.05s
IPC MRV2 26.14s

The reported results cover all four backend/runner combinations. No metrics, cluster-wide transaction semantics, routing replay, or model/quantization expansion is claimed by this PR.

36.12s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_weight_update.py::test_transfer_commits_weights_and_version_across_rl_steps[nccl-MRV2]
35.69s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_weight_update.py::test_transfer_commits_weights_and_version_across_rl_steps[nccl-MRV1]
29.05s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_weight_update.py::test_transfer_commits_weights_and_version_across_rl_steps[ipc-MRV1]
26.14s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_weight_update.py::test_transfer_commits_weights_and_version_across_rl_steps[ipc-MRV2]
0.60s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_weight_update.py::test_transfer_commits_weights_and_version_across_rl_steps[nccl-MRV1]
0.58s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_weight_update.py::test_transfer_commits_weights_and_version_across_rl_steps[nccl-MRV2]
0.51s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_weight_update.py::test_transfer_commits_weights_and_version_across_rl_steps[ipc-MRV1]
0.39s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_weight_update.py::test_transfer_commits_weights_and_version_across_rl_steps[ipc-MRV2]
0.01s setup    tests/entrypoints/serve/dev/rlhf/state_transitions/test_weight_update.py::test_transfer_commits_weights_and_version_across_rl_steps[ipc-MRV1]

29.30s setup    tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_state_and_idempotency_across_cycles[MRV2]
27.78s setup    tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_state_and_idempotency_across_cycles[MRV1]
10.45s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV1-keep]
10.04s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV2-keep]
5.67s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV2-wait]
5.59s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV1-wait]
1.61s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_clear_cache_preserves_output_and_controls_prefix_cache[MRV2]
1.20s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_clear_cache_preserves_output_and_controls_prefix_cache[MRV1]
0.87s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_clear_cache_preserves_output_and_controls_prefix_cache[MRV2]
0.77s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_state_and_idempotency_across_cycles[MRV1]
0.75s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_state_and_idempotency_across_cycles[MRV2]
0.64s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV2-abort]
0.64s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV1-abort]
0.12s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_invalid_mode_preserves_state[MRV1]
0.12s call     tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_invalid_mode_preserves_state[MRV2]
0.02s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_state_and_idempotency_across_cycles[MRV1]
0.01s setup    tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV2-abort]
0.01s setup    tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV1-abort]
0.01s setup    tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV2-keep]
0.01s setup    tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV2-wait]
0.01s setup    tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_invalid_mode_preserves_state[MRV1]
0.01s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_state_and_idempotency_across_cycles[MRV2]
0.01s setup    tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_invalid_mode_preserves_state[MRV2]
0.01s setup    tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV1-wait]
0.01s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV2-wait]
0.01s setup    tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV1-keep]
0.01s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV2-abort]
0.01s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_invalid_mode_preserves_state[MRV1]
0.01s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_invalid_mode_preserves_state[MRV2]
0.01s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV1-keep]
0.01s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV1-abort]
0.01s setup    tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_clear_cache_preserves_output_and_controls_prefix_cache[MRV1]
0.01s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV1-wait]
0.01s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_mode_request_lifecycle[MRV2-keep]
0.01s setup    tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_clear_cache_preserves_output_and_controls_prefix_cache[MRV2]
0.01s teardown tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py::TestPauseResume::test_clear_cache_preserves_output_and_controls_prefix_cache[MRV1]

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

@Windfeng8
Windfeng8 marked this pull request as draft August 24, 2026 06:52
@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@mergify mergify Bot added the frontend label Aug 25, 2026
@Windfeng8
Windfeng8 marked this pull request as ready for review August 25, 2026 02:33
@Windfeng8
Windfeng8 requested a review from njhill as a code owner August 25, 2026 02:33

Copy link
Copy Markdown
Author

/ci run

@github-actions

Copy link
Copy Markdown

❌ @Windfeng8, A reviewer with write access must run /ci run, approve the PR, or add the ready label first.

@@ -174,7 +226,22 @@ async def init_weight_transfer_engine(raw_request: Request):

@router.post("/start_weight_update")
async def start_weight_update(raw_request: Request):
sm: RLStateMachineState = raw_request.app.state.rl_state
if sm.is_updating:

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.

⚪ Severity: LOW

TOCTOU race condition: is_updating is checked without the lock, then the engine RPC (await ...start_weight_update()) is an asyncio yield point on line 239 before the locked state transition on line 240. Two concurrent requests can both pass this guard and invoke the engine RPC, bypassing the double-start protection that the state machine was specifically designed to enforce (preventing TMA descriptor errors and engine corruption).
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: The TOCTOU race can be fixed by acquiring the state machine's asyncio lock before checking is_updating and holding it through both the engine RPC and the state transition. This ensures two concurrent requests cannot both pass the guard.

Recommended approach (requires changes in two files):

  1. In rl_state_machine.py, either make _get_lock() public (rename to get_lock()) or add a new atomic helper method, e.g.:
async def acquire_start_weight_update(self) -> None:
    """Atomically check no update is active, then set the flag.
    Caller must invoke the engine RPC *inside* this guard."""
    async with self._get_lock():
        if self._is_updating:
            raise RuntimeError(
                "start_weight_update called while a weight update is already "
                "in progress.  Call finish_weight_update first."
            )
        self._is_updating = True
  1. In api_router.py, restructure start_weight_update so the guard and engine RPC are under the lock. If using the lock directly:
async def start_weight_update(raw_request: Request):
    sm = raw_request.app.state.rl_state
    async with sm.get_lock():
        if sm.is_updating:
            raise HTTPException(status_code=409, detail="...")
        await engine_client(raw_request).start_weight_update()
        sm._is_updating = True
    ...

Or if using the atomic method, call await sm.acquire_start_weight_update() after the engine RPC check has been moved inside the method.

The key principle: the check (is_updating), the engine RPC, and the flag set (_is_updating = True) must all be within the same critical section so no concurrent coroutine can interleave between them.

weight_label = body.get("weight_label")

state: RLStateMachineState = raw_request.app.state.rl_state
if not state.is_updating:

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.

⚪ Severity: LOW

TOCTOU race condition: is_updating is checked without the asyncio lock, followed by the engine RPC on line 304 (an await yield point), before the locked transition on line 308. Two concurrent finish_weight_update requests can both pass this guard and call the engine's finish_weight_update, corrupting the weight-update protocol state and the engine's internal model state.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: Wrap the is_updating guard, the engine RPC, and the state transition under the same asyncio lock to eliminate the TOCTOU window. The same pattern should also be applied to start_weight_update (line 230).

One approach: add a new method to RLStateMachineState (e.g., finish_weight_update_atomic(callback)) that acquires the lock, checks _is_updating, awaits the engine callback, and then transitions _is_updating = False — all within the same async with self._get_lock(): block. Then replace lines 294–308 with a single call to this new method, passing a callback for the engine RPC.

Alternatively, expose the lock via a public method (e.g., get_lock()) and restructure the endpoint handler to acquire it directly:

state: RLStateMachineState = raw_request.app.state.rl_state
async with state.get_lock():
    if not state.is_updating:
        raise HTTPException(
            status_code=HTTPStatus.CONFLICT.value,
            detail=(
                "finish_weight_update called without a preceding "
                "start_weight_update."
            ),
        )
    await engine_client(raw_request).finish_weight_update(weight_version)
    state._is_updating = False

Apply the analogous fix to start_weight_update as well.

@Windfeng8
Windfeng8 marked this pull request as draft August 25, 2026 02:44
@Windfeng8
Windfeng8 marked this pull request as ready for review August 25, 2026 13:00

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

@Windfeng8
Windfeng8 marked this pull request as draft August 25, 2026 13:07
@Windfeng8
Windfeng8 marked this pull request as ready for review September 2, 2026 12:44

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

@Ronald1995

Copy link
Copy Markdown
Collaborator

Scope correction pushed in 2270be0a9fbd159129b4aefc41542614c6edc8ad.

The earlier mainline synchronization accidentally included unrelated changes from a local tracking reference that did not match the PR's actual upstream base. Those changes have now been removed from the final PR diff:

  • tests/kernels/moe/test_b12x.py
  • tests/models/test_initialization.py
  • tests/v1/kv_connector/unit/test_mooncake_store_scheduler.py
  • vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/scheduler.py
  • vllm/model_executor/layers/fused_moe/oracle/mxfp4.py

I verified the five files against the immutable upstream merge-base 6fbb00b18874e27ba7d7adc0a3b8e93fee763ab1 and then checked GitHub's actual head SHA and Files changed list after pushing: this PR now contains exactly 6 intended files. The RL changes themselves are unchanged. This correction uses an additive cleanup commit; it does not rewrite contributor history.

This corrects the earlier scope-validation claim. No new GPU execution results are claimed.

@Ronald1995

Copy link
Copy Markdown
Collaborator

Updated the shared RL server lifecycle without increasing server startup count.

  • Added reusable_server, which runs parent-process cleanup only after RemoteOpenAIServer has shut down its complete process group and checked GPU-memory release.
  • Kept the pause/resume server module-scoped across its test cases and deferred global cleanup until each MRV1/MRV2 server instance exits.
  • Left the weight-update test's function-owned server unchanged; it already exits before pytest's function-scoped cleanup and each backend/runner combination requires its own server.

Validation: targeted ruff check and ruff format --check passed. Full collection was unavailable locally because the test environment is missing tblib.

@mergify

mergify Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @Windfeng8.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Sep 16, 2026
Windfeng8 and others added 11 commits September 17, 2026 12:01
Signed-off-by: Windfeng8 <126474910+Windfeng8@users.noreply.github.com>
Signed-off-by: Windfeng8 <126474910+Windfeng8@users.noreply.github.com>
Signed-off-by: Windfeng8 <126474910+Windfeng8@users.noreply.github.com>
Signed-off-by: Windfeng8 <126474910+Windfeng8@users.noreply.github.com>
Signed-off-by: Windfeng8 <126474910+Windfeng8@users.noreply.github.com>
Signed-off-by: Windfeng8 <126474910+Windfeng8@users.noreply.github.com>
Signed-off-by: Windfeng8 <126474910+Windfeng8@users.noreply.github.com>
Update conftest.py and test_weight_update.py based on PR vllm-project#52144 server lifecycle patterns.

- Keep module-scoped server fixtures and MRV1/MRV2 coverage.
- Configure backend-specific weight transfer through the shared server harness.
- Exercise IPC/NCCL cases when the required CUDA hardware is available.
- Preserve protocol/error and reload coverage.

Validated on glusterfs-05 with CUDA_VISIBLE_DEVICES=3,4:
- g1 changes_output: 4 passed in 156.00s
- g2 reload bf16 tiny: 1 passed in 11.51s
- g3 reload bf16 3b: 1 passed in 15.63s
- g4 reload fp8: 1 passed in 24.48s
- g5 reload mxfp8: 1 skipped in 0.42s
- g6 rest: 40 passed in 298.28s

Signed-off-by: Windfeng8 <126474910+Windfeng8@users.noreply.github.com>
Signed-off-by: Ronald1995 <ronaldautomobile@163.com>
Signed-off-by: Ronald1995 <ronaldautomobile@163.com>
Signed-off-by: Ronald1995 <ronaldautomobile@163.com>
@Windfeng8
Windfeng8 force-pushed the test_vllm/weight-update branch from 96055d2 to ce14f05 Compare September 17, 2026 09:07
@mergify mergify Bot removed the needs-rebase label Sep 17, 2026
@mergify

mergify Bot commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @Windfeng8.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants