Skip to content

[trainer] feat: add V1 trainer support for the RemoteBackend plugin abstraction - #7102

Open
sfc-gh-kganesan wants to merge 3 commits into
verl-project:mainfrom
Snowflake-AI-Research:karthik/v1-remote-backend
Open

[trainer] feat: add V1 trainer support for the RemoteBackend plugin abstraction#7102
sfc-gh-kganesan wants to merge 3 commits into
verl-project:mainfrom
Snowflake-AI-Research:karthik/v1-remote-backend

Conversation

@sfc-gh-kganesan

@sfc-gh-kganesan sfc-gh-kganesan commented Jul 21, 2026

Copy link
Copy Markdown

V1 trainer support for the RemoteBackend plugin abstraction

Summary

Adds V1 trainer support for the RemoteBackend plugin abstraction. The V0
seam for out-of-process RL backends (training + sampling that live outside
verl core, e.g. Arctic-Platform's DeepSpeed/vLLM stack) lands in
#6422; this PR extends the same seam to V1 without
regressing any V1 path and without requiring the V0 forwarder to be
rewritten.

Design goal (from the V0 review): all GPU work happens on the plugin side;
verl-core drives it through a thin CPU-only forwarder actor. This PR
preserves that invariant.

Contract

Users opt in with a Hydra group choice remote_backend=<name>. In
main_ppo.main, _resolve_remote_backend_from_hydra_choice stamps
trainer.remote_backend=<name> and (unless the user already picked a
custom mode) trainer.v1.trainer_mode=remote_backend. That mode dispatches
to PPOTrainerRemoteBackend, which:

  1. Instantiates the plugin's RemoteBackend.from_config(config) on the
    driver and captures its reconnect_handle().
  2. Overrides three extension hooks added to PPOTrainer:
    • _actor_rollout_wg_extra_kwargs -> smuggles {main_config, backend_handle}
      into every actor-rollout worker via RayClassWithInitArgs.
    • _llm_server_replica_init_kwargs -> forwards the same handle into
      every RolloutReplica via LLMServerManager.replica_init_kwargs.
    • _checkpoint_engine_backend -> "remote_backend".
  3. Overrides _init_resource_pool_mgr to swap in the plugin-provided
    worker class and mark the resource pool CPU-only. The forwarder does no
    GPU work; the plugin already claims all GPUs internally, so
    double-booking through Ray would starve it ("Total available GPUs 0"
    at placement time).
  4. Optionally enforces a single-forwarder invariant when the backend
    reports requires_single_forwarder() (Arctic does): otherwise
    Dispatch.ONE_TO_ALL calls duplicate against the single backend and
    mesh-dispatched compute fragments the global batch.

CheckpointEngineManager.update_weights gets one new short-circuit: for
backend='remote_backend' it invokes actor_wg.update_weights(...) (the
forwarder relays to the plugin, which does the transfer, typically CUDA
IPC), then fans set_global_steps(...) out to any replicas that
implement it so downstream metrics can tag rollouts with their policy
version.

Files changed

verl-core:

  • verl/remote_backend/{init,base,worker_utils}.py (new) —
    RemoteBackend ABC + registry (name -> backend class + lazy forwarder
    loader) + backend-agnostic tensor/metric helpers. Ported from V0
    unchanged.
  • verl/checkpoint_engine/remote_backend.py (new) — no-op checkpoint
    engine stub registered under "remote_backend". send_weights /
    receive_weights are never called on the short-circuit path.
  • verl/checkpoint_engine/init.py — eagerly import the stub.
  • verl/checkpoint_engine/base.py
    CheckpointEngineManager.update_weights also short-circuits for
    backend='remote_backend' and fans out set_global_steps to replicas
    that implement it.
  • verl/trainer/ppo/v1/trainer_base.py — three PPOTrainer extension
    hooks. Defaults preserve existing V1 behavior.
  • verl/trainer/ppo/v1/trainer_remote_backend.py (new) —
    PPOTrainerRemoteBackend, ~170 lines.
  • verl/trainer/ppo/v1/init.py — re-export.
  • verl/trainer/config/ppo_trainer.yaml
    optional remote_backend@remote_backend: null defaults entry.
  • verl/trainer/main_ppo.py_resolve_remote_backend_from_hydra_choice.
  • verl/workers/rollout/replica.pyRolloutReplica.__init__ accepts
    **kwargs (forwarded from LLMServerManager.replica_init_kwargs).
  • verl/workers/rollout/llm_server.pyLLMServerManager accepts
    replica_init_kwargs and forwards to replica constructors.
  • verl/single_controller/ray/base.pyResourcePoolManager.use_gpu
    (default True, no behavior change for existing paths). CPU-only pools
    skip the GPU headroom check in _check_resource_available.
  • tests/remote_backend/ (new) — CPU-only tests: registry decorator +
    lazy worker loader, checkpoint-engine dispatch, trainer dispatch, and
    the Hydra choice hook (9 tests).

Net diff: +119 / -6 lines in existing files, plus ~460 lines of
new plugin-seam code + tests.

Plugin side (out of tree, for context)

The Arctic-Platform migration is confined to a new
arctic_platform/integrations/verl/v1/ subpackage
(worker.py ~430 LoC, replica.py ~100 LoC, server.py ~75 LoC). V0
modules are untouched. Register-time auto-detection picks V0 vs V1 based
on whether verl.trainer.ppo.v1.trainer_remote_backend imports.

Validation

BIRD text-to-SQL, data.max_prompt_length=32768, data.max_response_length=4096,
train_batch_size=128, rollout.n=16, ppo_max_token_len_per_gpu=98304,
recipe-aligned with Arctic-Platform's
recipe/skyrl-integration/recipes/rl/verl/txt2sql/run_qwen3_32b_bird_grpo_arl_zorro_yes.sh.
Model: Qwen3-8B. 5 steps per config on 8× H200.

Speedup (per-step, s):

Metric Stock V1 (vLLM+FSDP2) Arctic V1 + ZoRRo Arctic speedup
timing_s/update_actor 380.9 166.0 2.30×
timing_s/old_log_prob 58.2 60.4 0.96×
timing_s/gen 200.6 204.7 0.98×
timing_s/step (total) 731.4 438.3 1.67×

The wall-clock win is dominated by ZoRRo's fused actor update. gen is
neutral: with the current Arctic recipe knob enforce_eager=False and
vLLM 0.18.0's new cudagraph_mode=FULL_AND_PIECEWISE default, FCA is
silently disabled at runtime. Reverting to the pre-plugin
enforce_eager=True re-enables FCA (all 8 inference workers log
Forest Cascade Attention ENABLED (... cudagraph_mode=NONE)) and drops
gen from ~205 s to ~191 s on 8B/BIRD (~7% inference speedup, 4-step
average). This knob is on the Arctic-Platform side and is being tracked
in a separate PR against the plugin.

Convergence: rewards stay in the same band across both paths (step-1
critic/score/mean: stock 0.635 vs Arctic 0.635; step-5 stock 0.63 vs
Arctic 0.63), with matching response-length trajectories. No divergence.

Test plan

  • pytest tests/remote_backend/ — 9 CPU-only tests (registries, trainer
    dispatch, Hydra choice hook). All green.
  • ruff check and ruff format --check on all modified files: clean.
  • 8B BIRD 5-step recipe runs above.
  • Existing V1 sync/async trainer paths unaffected — this seam is opt-in
    via the remote_backend=<name> Hydra choice.

Notes for reviewers

  • ResourcePoolManager.use_gpu defaults to True; only remote_backend
    callers flip it. Existing non-plugin paths keep the current behavior
    bit-for-bit.
  • set_global_steps fan-out in CheckpointEngineManager is gated on
    hasattr(r, 'set_global_steps'); verl's built-in RolloutReplica
    doesn't implement it, so it's a no-op for the naive path.
  • RemoteBackend and RemoteBackendRegistry are ported from V0 unchanged
    so the two trainers can share a single ABC surface. No new methods; the
    V1-specific plumbing lives entirely in PPOTrainerRemoteBackend and
    the extension hooks in PPOTrainer.

Companion PR (Arctic-Platform plugin V1 shims + from-scratch setup guide): Snowflake-AI-Research/Arctic-Platform#41

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a pluggable out-of-process RemoteBackend abstraction to verl, enabling external RL backends to manage their own GPU compute and weight synchronization. It adds the PPOTrainerRemoteBackend trainer, a RemoteBackendCheckpointEngine adapter, associated registries, CPU-only resource pool support, and comprehensive unit tests. The review feedback highlights three key areas for improvement: avoiding blocking ray.get() calls inside async methods by using asyncio.gather, removing redundant getattr checks for guaranteed abstract methods, and optimizing tensor operations in make_njt by converting to CPU lists before looping to prevent synchronous GPU-to-CPU transfers.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

# the transfer to the actor side (naive: verl actor -> rollout server;
# remote_backend: plugin owns the transfer, typically CUDA IPC).
if self.backend in ("naive", "remote_backend"):
ray.get(self.actor_wg.update_weights(global_steps=global_steps, mode=self.backend))

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.

high

Avoid using ray.get() inside async methods of Ray actors (or classes running within them like CheckpointEngineManager). Calling ray.get() blocks the event loop, which can lead to performance degradation or deadlocks, and typically triggers warnings in Ray.

Since self.actor_wg.update_weights is non-blocking and returns a list of ray.ObjectRefs, you should asynchronously await them using asyncio.gather.

Suggested change
ray.get(self.actor_wg.update_weights(global_steps=global_steps, mode=self.backend))
await asyncio.gather(*self.actor_wg.update_weights(global_steps=global_steps, mode=self.backend))
References
  1. Avoid using ray.get() inside async methods of Ray actors. This blocks the event loop, which can lead to performance degradation or deadlocks, and typically triggers warnings in Ray. Instead, use await on remote calls or asyncio.gather to parallelize multiple remote calls asynchronously.

Comment on lines +161 to +164
destroy = getattr(backend, "destroy", None)
if destroy is None:
return
result = destroy()

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.

high

Avoid over-defensive programming, such as using redundant getattr and None checks on attributes or methods that are guaranteed to exist.

Since destroy is defined as an @abc.abstractmethod on the RemoteBackend base class, any concrete backend subclass is guaranteed to implement it. Therefore, checking for its existence with getattr(backend, "destroy", None) is redundant.

Suggested change
destroy = getattr(backend, "destroy", None)
if destroy is None:
return
result = destroy()
result = backend.destroy()
References
  1. Avoid over-defensive programming, such as wrapping inspect.signature in try-except blocks or using redundant getattr and None checks, unless there is a concrete, documented risk of runtime failure (e.g., dealing with uninspectable C-extensions or attributes that are legitimately optional in supported versions).

cu_seqlens = data["input_ids"].offsets()
seq_lengths = cu_seqlens.diff()
starts = data["attention_mask"].long().argmax(dim=1)
pieces = [tensor[b, starts[b].item() : starts[b].item() + seq_lengths[b].item()] for b in range(tensor.shape[0])]

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.

high

Calling .item() on PyTorch tensors inside a loop over the batch size causes synchronous GPU-to-CPU transfers for every iteration. This introduces significant host-device synchronization overhead and can become a major performance bottleneck.

Instead, convert the entire starts and seq_lengths tensors to CPU lists once using .tolist() before the list comprehension, and then index into those lists.

Suggested change
pieces = [tensor[b, starts[b].item() : starts[b].item() + seq_lengths[b].item()] for b in range(tensor.shape[0])]
starts_cpu = starts.tolist()
seq_lengths_cpu = seq_lengths.tolist()
pieces = [tensor[b, starts_cpu[b] : starts_cpu[b] + seq_lengths_cpu[b]] for b in range(tensor.shape[0])]

@sfc-gh-kganesan

Copy link
Copy Markdown
Author

@wuxibin89 can you please help review the V1 port?



@register_trainer("remote_backend")
class PPOTrainerRemoteBackend(PPOTrainerSync):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Does remote backend support async training?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

we will add async support in a followup PR shortly, this PR is to land the remote abstract backend + sync colocate support :)

@wuxibin89 wuxibin89 changed the title Add V1 trainer support for the RemoteBackend plugin abstraction [trainer] feat: add V1 trainer support for the RemoteBackend plugin abstraction Jul 21, 2026
from omegaconf import DictConfig


class RemoteBackend(abc.ABC):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please add RemoteBackend design doc to docs/index.rst.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Sure will do that!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added docs/advance/remote_backend.md and hooked it into docs/index.rst under the Advanced Features section (next to agent_loop / reward_loop / data/transfer_queue). Covers the ABC + registry, the V1 trainer subclass, the CheckpointEngineManager short-circuit + set_global_steps fan-out, the Hydra choice hook, and points at the Arctic-Platform reference implementation. Pushed in 3fe9463.

sfc-gh-kganesan added a commit to Snowflake-AI-Research/verl that referenced this pull request Jul 21, 2026
Addresses review comment on verl-project#7102 (wuxibin89): describe the
RemoteBackend ABC + registry, the V1 trainer subclass, the
CheckpointEngineManager short-circuit, and how a downstream plugin
wires itself in via VERL_USE_EXTERNAL_MODULES. Links the reference
Arctic-Platform implementation and lists the CPU-only tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
@wuxibin89

Copy link
Copy Markdown
Collaborator

/gemini review

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@wuxibin89

Copy link
Copy Markdown
Collaborator

sfc-gh-kganesan added a commit to Snowflake-AI-Research/verl that referenced this pull request Jul 23, 2026
Fixes the check_license.py assertion in the pre-commit / pre_commit_for_ppo
jobs (verl-project#7102). The file was empty; adding the standard Apache 2.0 header.
@sfc-gh-kganesan

Copy link
Copy Markdown
Author

@wuxibin89 fixed in 5cbfeb9tests/remote_backend/__init__.py was empty; added the standard Apache 2.0 header so check_license.py passes. Re-running pre-commit now.

@sfc-gh-kganesan

Copy link
Copy Markdown
Author

@wuxibin89 the failure was check_license.py on the empty tests/remote_backend/__init__.py; fixed in 5cbfeb9 (adds the standard Apache 2.0 header). Local pre-commit run --all-files clean afterwards. Could you trigger the workflows on the fork so CI reruns?

@wuxibin89

wuxibin89 commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

@sfc-gh-kganesan We will release v0.8.1 in next two weeks, hold this PR until release is done.

@sfc-gh-truwase

Copy link
Copy Markdown

@wuxibin89 just checking you had more precise release time for v0.8.1? Thanks!

@sfc-gh-truwase

Copy link
Copy Markdown

@wuxibin89 congrats on v0.9.0 release. Can we move forward with this PR, which also meets the need of other users?

@sfc-gh-kganesan @KunWuLuan FYI

sfc-gh-kganesan and others added 3 commits August 19, 2026 23:32
Extends the V0 RemoteBackend seam (verl-project#6422) to the V1
trainer stack so out-of-process RL backends (e.g. Arctic-Platform) can
plug into V1 without touching verl's V0 or V1 hot paths.

verl-core:

* verl/remote_backend/{base,worker_utils,__init__}.py: RemoteBackend
  ABC + registry (name -> backend class + lazy forwarder loader) and
  backend-agnostic tensor/metric helpers. Ported from V0 unchanged.
* verl/checkpoint_engine/remote_backend.py: no-op CheckpointEngine
  registered under 'remote_backend'. send/receive are never called on
  the short-circuit path.
* verl/checkpoint_engine/{__init__,base}.py: eager import above +
  CheckpointEngineManager.update_weights short-circuit for
  backend='remote_backend' with set_global_steps fan-out to replicas
  that implement it.
* verl/trainer/ppo/v1/trainer_base.py: three PPOTrainer extension
  hooks (_actor_rollout_wg_extra_kwargs,
  _llm_server_replica_init_kwargs, _checkpoint_engine_backend).
  Defaults preserve existing V1 behavior.
* verl/trainer/ppo/v1/trainer_remote_backend.py: PPOTrainerRemoteBackend
  (subclass of PPOTrainerSync). Builds the plugin's RemoteBackend on
  the driver, smuggles reconnect_handle to forwarder workers and
  rollout replicas, marks the resource pool CPU-only, and asserts
  single-forwarder when the backend requires it.
* verl/trainer/ppo/v1/__init__.py: re-export.
* verl/trainer/config/ppo_trainer.yaml: 'optional
  remote_backend@remote_backend: null' defaults entry.
* verl/trainer/main_ppo.py: _resolve_remote_backend_from_hydra_choice
  mirrors the 'remote_backend=<name>' Hydra choice onto
  trainer.remote_backend and trainer.v1.trainer_mode.
* verl/workers/rollout/{replica,llm_server}.py: RolloutReplica accepts
  **kwargs; LLMServerManager accepts replica_init_kwargs and forwards
  to replica constructors.
* verl/single_controller/ray/base.py: ResourcePoolManager.use_gpu
  (default True); CPU-only pools skip the GPU headroom check.
* tests/remote_backend/: 9 CPU-only tests (registries, trainer
  dispatch, Hydra choice hook).

Validation: 8B BIRD text-to-SQL, recipe-aligned, 5 steps.
update_actor 380.9s -> 166.0s (2.30x from ZoRRo), total step
731.4s -> 438.3s (1.67x), convergence preserved (reward means match
step-over-step).

Net diff: +119 / -6 lines in existing files, ~460 new lines in the
plugin-seam code and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses review comment on verl-project#7102 (wuxibin89): describe the
RemoteBackend ABC + registry, the V1 trainer subclass, the
CheckpointEngineManager short-circuit, and how a downstream plugin
wires itself in via VERL_USE_EXTERNAL_MODULES. Links the reference
Arctic-Platform implementation and lists the CPU-only tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fixes the check_license.py assertion in the pre-commit / pre_commit_for_ppo
jobs (verl-project#7102). The file was empty; adding the standard Apache 2.0 header.
@sfc-gh-kganesan
sfc-gh-kganesan force-pushed the karthik/v1-remote-backend branch from 5cbfeb9 to 6329d45 Compare August 19, 2026 23:40
@sfc-gh-kganesan

sfc-gh-kganesan commented Aug 19, 2026

Copy link
Copy Markdown
Author

Rebased onto latest main (b256ebf8). Merge conflict in verl/workers/rollout/llm_server.py resolved by keeping both load_balancer_cls (upstream) and replica_init_kwargs (this PR) on LLMServerManager.__init__.

pytest tests/remote_backend/ — 9/9 passed on the rebased branch.

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.

4 participants