Skip to content

[rollout, vllm, trainer, ckpt, doc] feat: external vLLM rollout (official vllm serve + verl plugin) - #2

Open
Luosuu wants to merge 10 commits into
feat/transport-weight-registryfrom
dev/decoupled-async
Open

Luosuu wants to merge 10 commits into
feat/transport-weight-registryfrom
dev/decoupled-async

Conversation

@Luosuu

@Luosuu Luosuu commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Review PR for M5 on top of feat/transport-weight-registry (the branch behind verl-project#8526). Not for merging upstream as-is.

What this does

decoupled_async can generate on official vllm serve servers outside the trainer's Ray job. verl contributes a vLLM plugin, a launcher with a sidecar, and an HTTP registry; the vLLM build is unmodified. Design doc: docs/advance/external_vllm_rollout.md.

Where to look

  • verl/checkpoint_engine/transport/http_registry.py: RegistryServer (FastAPI over InMemoryWeightRegistry) and the HttpWeightRegistry client. registry.py gains a replica directory (report_replica / list_replicas, with each replica's age) and JSON manifest serialization.
  • verl/workers/rollout/vllm_rollout/transport_pull_engine.py: the verl_transport_pull WeightTransferEngine. Pulls a version under a lease, then runs a layerwise reload; the reload starts only after the pull succeeded. vllm_plugin.py plus the vllm.general_plugins entry point in pyproject.toml register it lazily.
  • verl/workers/rollout/vllm_rollout/external_launcher.py: wraps vllm serve (sets VLLM_PLUGINS, VLLM_SERVER_DEV_MODE, --weight-transfer-config, --enable-scale-out). Its HttpReplicaUpdateAgent sidecar drives /pause → /start_weight_update → /update_weights → /finish_weight_update → /resume and heartbeats to the registry. update_agent.py gains prefetch/apply/heartbeat hooks for it.
  • verl/workers/rollout/external_server.py: HttpServerHandle stands in for a vLLM server actor handle, calling /inference/v1/generate and returning TokenOutput. ExternalServerManager provides what the trainer uses of LLMServerManager.
  • verl/trainer/ppo/v1/trainer_decoupled_async.py and prompt_dispatcher.py: the external_rollout mode (start the registry server, wait for replicas, gate dispatch and validation on registry versions, fail on missing heartbeats).

Tests

Command Result
CPU suites (tests/trainer/ppo/v1/, tests/checkpoint_engine/*_on_cpu.py, tests/workers/rollout/test_external_server_on_cpu.py) 279 passed
pytest tests/workers/rollout/rollout_vllm/test_vllm_external_launcher.py (official vllm serve follows versions 0 → 1 → 2; greedy outputs and vLLM's weight_versions spans checked) passed
GSM8K async GRPO, Qwen2.5-0.5B, 30 steps: trainer on 4 GPUs, two launcher-started vllm serve (TP=2) on the other 4 completed; validation accuracy 0.470 at step 30 (baseline 0.477, in-job decoupled_async 0.501); 1.0 versions per trajectory

Bugs found by the end-to-end runs and fixed here: registry server returning before it accepts connections; ninja missing from vLLM's PATH in an unactivated venv; --enable-scale-out needed for the generate route; the plugin's reused registry connection dropping after idling between updates.

Known limitations

No prefetch (the pull runs inside /update_weights, while paused); fixed replica set; text-only; no pause metrics in the trainer; VLLM_SERVER_DEV_MODE exposes unauthenticated routes, so the rollout servers need an isolated network.

AI assistance (Claude Code) was used for the code, tests, experiments and this description.

🤖 Generated with Claude Code

Luosuu and others added 7 commits November 3, 2026 16:26
…replica (plugin, launcher, HTTP registry)

- HTTP weight registry: the trainer can serve InMemoryWeightRegistry over
  HTTP (RegistryServer) and processes outside its Ray job use
  HttpWeightRegistry. The registry also keeps a replica directory (URL,
  loaded version, heartbeat). Manifests travel as JSON.
- vLLM plugin: verl_transport_pull, a WeightTransferEngine registered through
  the vllm.general_plugins entry point. It pulls a published version into the
  worker's host mirror under a lease and loads it as a layerwise reload,
  starting the reload only after the pull succeeded.
- external_launcher: starts an official vllm serve with the plugin and runs a
  sidecar (HttpReplicaUpdateAgent) that keeps it on a recent version through
  vLLM's HTTP weight update routes and reports it to the registry.
- WeightUpdateAgent gains prefetch/apply/heartbeat hooks for that subclass.
- Design doc: docs/advance/external_vllm_rollout.md.

Trainer wiring and the HTTP generation client come next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…LM's PATH

vLLM's engine core runs ninja for kernel builds; in an unactivated venv it
was not on PATH and the engine failed to start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-enable-scale-out)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…M servers

- trainer.v1.decoupled_async.external_rollout: the trainer serves the weight
  registry over HTTP (RegistryServer), points the publisher at it, waits for
  min_replicas servers started by external_launcher to register, and
  generates through them. It launches no rollout replicas or update agents.
- HttpServerHandle stands in for a vLLM server actor handle: generate.remote()
  calls vLLM's /inference/v1/generate and returns TokenOutput (abort ->
  aborted, weight version spans -> global_steps), so the load balancer and
  FullyAsyncLLMServerClient are unchanged. ExternalServerManager provides the
  server-manager surface the trainer uses.
- PromptDispatcher can gate on loaded versions from the registry's replica
  directory and fails when a server stops reporting; the trainer waits for
  versions and checks heartbeats the same way.
- The registry reports each replica's age rather than a raw timestamp; the
  launcher's sidecar waits for the registry, so servers can start first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vLLM plugin keeps one registry client for the whole run. Its pooled
connection sat idle past uvicorn's keep-alive between weight updates, so the
next get_manifest failed with ServerDisconnectedError (seen in the external
vLLM GRPO run at version 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-04T01:07:36.633508Z 9bbcca3 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7b8a1aa984

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +102 to +105
manifest = self._run(self._registry.get_manifest(update_info.version))
if manifest is None:
raise RuntimeError(f"Weight version {update_info.version} is not published (or already reclaimed)")
pulled = self._run(self._puller.fetch(manifest))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retry versions retired before the external worker acquires a lease

If the trainer publishes another version between the sidecar's latest_ready() poll and this fetch, WeightPublisher.publish() retires the selected version and WeightPuller.fetch() raises LeaseError when acquiring its lease. Unlike the existing transport checkpoint engine's prefetch path, this path propagates the error through /update_weights, causing the sidecar to terminate the server and eventually fail training. This is a normal race in asynchronous publication, not necessarily a broken replica. Handle stale-version acquisition at the replica level and retry a newer version consistently across workers rather than treating it as a fatal update failure.

Useful? React with 👍 / 👎.

Comment on lines +159 to +164
deadline = time.monotonic() + timeout
while min(self._replica_versions(), default=-1) < version:
self._check_rollout()
if time.monotonic() > deadline:
raise TimeoutError(f"Rollout servers did not load version {version} within {timeout}s")
time.sleep(0.5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Demand the validation version from external update agents

When an external launcher uses --min-version-lag greater than one, validation can wait forever for a version the sidecar deliberately declines to load. For example, after loading version 0 with lag 2, the sidecar will not load version 1, but validation after publishing version 1 blocks here until init_timeout; the trainer cannot publish version 2 while blocked. The in-job branch avoids this through wait_for_version(), which sets the agent's _demanded version, whereas this branch only polls. Provide an equivalent demand mechanism for external sidecars, or explicitly reject lag configurations that cannot satisfy validation waits.

Useful? React with 👍 / 👎.

async def _apply(self, version: int):
await self._post("pause", params={"mode": "abort", "clear_cache": "true"})
await self._post("start_weight_update")
await self._post("update_weights", {"update_info": {"version": version}})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep replica heartbeats running during weight updates

If a legitimate pull and reload takes longer than external_rollout.heartbeat_timeout (60 seconds by default), the trainer or prompt dispatcher declares the healthy replica dead and fails the job. WeightUpdateAgent.run() awaits the entire _update() before issuing another heartbeat, so this potentially 1800-second HTTP request suppresses all replica reports while the transfer is progressing. Run heartbeats independently of update application so large-model or slower-network transfers do not trigger the missing-heartbeat failure.

Useful? React with 👍 / 👎.

Comment on lines +61 to +63
content = (choice.get("logprobs") or {}).get("content")
log_probs = [entry["logprob"] for entry in content] if content else None
extra_fields: dict[str, Any] = {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve requested top-k heads in the external token output

When score centering is enabled with rollout.topk_log_probs > 0, this converter retains only each sampled token's log probability and discards the returned top-k alternatives. The in-job server supplies response_topk_ids and response_topk_log_probs, which the agent loop converts into rollout_topk_ids and rollout_topk_log_probs; without them, score_centering_logits_processor() directly indexes missing batch fields and training fails. Extract the requested heads into the same extra fields, or reject this configuration before starting external rollout.

Useful? React with 👍 / 👎.

Comment on lines +150 to +153
handles = [
HttpServerHandle(url, prompt_length=rollout.prompt_length, response_length=rollout.response_length)
for url in self.server_addresses
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Forward the configured EOS policy to external generation

When actor_rollout_ref.rollout.ignore_eos=True, external generation silently ignores that setting: AgentLoopWorkerTQ.generate_sequences() does not include it in the request's sampling parameters, and these handles receive only the configured lengths. The in-job vLLMHttpServer.generate() explicitly injects self.config.ignore_eos, but the external converter has no access to it, so requests instead use the server's default EOS behavior and can terminate before the intended response budget. Pass the configured EOS policy into the handle and apply it as a sampling default.

Useful? React with 👍 / 👎.

Comment on lines +107 to +108
if self._external_rollout:
self._setup_external_rollout()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Allow zero local rollout nodes through the base setup

With external rollout enabled and rollout.nnodes=0 (the default), initialization fails before reaching this branch. The preceding super()._setup() calls PPOTrainer._setup(), which creates an empty LLMServerManager because _enable_hybrid_replicas=False; however, LLMServerManager.create_empty() still invokes its constructor without a worker group, triggering assert worker_group is not None or self.rollout_config.nnodes > 0. Although this change removes the trainer's standalone-GPU assertion for external mode, the downstream assertion still requires users to configure fictitious local rollout nodes. Adjust the empty-manager initialization so external-only training can start with zero local rollout resources.

Useful? React with 👍 / 👎.

Luosuu and others added 3 commits November 3, 2026 19:59
…ecture diagrams

Replace the ASCII diagrams with Mermaid flowcharts, which GitHub renders in
the PR view. verl's Sphinx docs show them as code blocks unless
sphinxcontrib-mermaid is enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Standalone SVGs (explicit colors, white background, system fonts) render the
same on GitHub and in verl's Sphinx docs, which has no Mermaid extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9bbcca331a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

) -> dict[str, Any]:
"""verl's sampling params as vLLM's, following `vLLMHttpServer.generate`."""
params = dict(sampling_params)
max_possible_tokens = prompt_length + response_length - prompt_len

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound external generation by the actual model context length

When the server's configured context length is smaller than prompt_length + response_length, this calculation permits requests that exceed the model's context window. For example, with max_model_len=2048, prompt length 512 and response budget 2048, the converter requests 2048 new tokens despite only 1536 fitting. Unlike the in-job vLLMHttpServer.generate(), which clamps against self.config.max_model_len - len(prompt_ids), external generation sends the oversized request and propagates the server's rejection as a rollout failure. Pass the actual context limit into the handle and use it for this bound.

Useful? React with 👍 / 👎.

Comment on lines +152 to +154
for url in self.server_addresses
]
self.global_load_balancer = get_router_handle(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve per-request sampling seeds in deterministic mode

With rollout.full_determinism=True and stochastic sampling, external rollout enables deterministic routing but never forwards rollout.seed into generation requests. AgentLoopWorkerTQ.generate_sequences() does not supply a seed, and the in-job vLLMHttpServer.generate() normally fills that gap with sampling_params.setdefault("seed", self.replica_rank + self.config.seed). These external handles receive neither setting, so requests use the server's unseeded-request sampling behavior instead, losing the configured per-request reproducibility even when routing and server startup seeds are fixed. Pass the determinism configuration and a stable replica-specific seed into each handle and inject the sampling seed when enabled.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant