Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e2821ad
feat(vlm): route VLM GRPO through TQ trainer when data_plane.enabled
ZhiyuLi-Nvidia Jun 26, 2026
4a50fb3
fix(grpo_sync): skip redundant iter-1 refit when weights already synced
ZhiyuLi-Nvidia Jul 24, 2026
002bbae
fix(tq): thread VLM multimodal fields through TQ via torch.nested wire
ZhiyuLi-Nvidia Jul 27, 2026
12b0abc
test(tq): full-VLM wire roundtrip + defensive-error coverage
ZhiyuLi-Nvidia Jul 27, 2026
3de769e
test(tq): TQ-mediated VLM multimodal wire roundtrip
ZhiyuLi-Nvidia Jul 28, 2026
6a153bf
fix(tq): ship multimodal fields to the train forward; guard dynbatch
ZhiyuLi-Nvidia Aug 17, 2026
1fe782d
fix(vlm): accept processor in grpo_train_sync for launcher parity
ZhiyuLi-Nvidia Aug 17, 2026
4603365
fix(vlm): skip multimodal fields in the automodel seq-dim validator
ZhiyuLi-Nvidia Aug 17, 2026
a18956d
fix(tq): address VLM data-plane review findings
ZhiyuLi-Nvidia Aug 18, 2026
60c19aa
test(tq): cover the VLM wire boundary and dispatch parity
ZhiyuLi-Nvidia Aug 18, 2026
0e2ea50
fix(tq): satisfy pyrefly on the multimodal wire encoder
ZhiyuLi-Nvidia Aug 18, 2026
79da085
refactor(tq): let TransferQueue own per-row shape; drop the wire comp…
ZhiyuLi-Nvidia Aug 27, 2026
8d5e0fd
fix(tq): carry VLM multimodal row shapes without padding the wire
ZhiyuLi-Nvidia Aug 30, 2026
530eca4
docs(tq): note the ProcessorInterface refactor on the parity kwarg
ZhiyuLi-Nvidia Aug 30, 2026
50990af
docs(tq): correct from_wire's stale claim that it pads
ZhiyuLi-Nvidia Aug 30, 2026
5a41608
docs(tq): correct the stale wire-form comment in get_multimodal_dict
ZhiyuLi-Nvidia Aug 30, 2026
4bcb483
refactor(tq): stop shipping a batch-wide pad target over the wire
ZhiyuLi-Nvidia Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
defaults: vlm_grpo-nemotron-omni-30ba3b-clevr-1n8g-automodel-ep8.v1.yaml
data_plane:
enabled: true
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
defaults: vlm_grpo-qwen3.5-35ba3b-geo3k-2n8g-automodel-ep16.yaml
data_plane:
enabled: true
45 changes: 42 additions & 3 deletions examples/run_vlm_grpo.py

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.

can this PR be validated on nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16 ?

The implementation from #3290 explicitly forbids models like Nano-Omni from working with the DataPlane, see nemo_rl/data_plane/worker_mixin.py:137 . The check was added because PackedTensor objects could not be passed through the TQ, which is now implemented here.

Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@
from nemo_rl.utils.timer import Timer


def _select_trainer(master_config: MasterConfig):
"""Pick the synchronous trainer based on ``data_plane.enabled``.

Mirrors ``run_grpo.py`` so the VLM launcher routes through the same
TransferQueue-backed sibling trainer (``grpo_train_sync``) when the
data plane is enabled, and otherwise uses the legacy ``grpo_train``.
"""
dp_cfg = master_config.data_plane or {}
if dp_cfg.get("enabled", False):
from nemo_rl.algorithms.grpo_sync import grpo_train_sync

print("🚀 Running synchronous VLM GRPO training (TransferQueue)")
return grpo_train_sync

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.

do we have a plan to update the file name? the current naming is quite confusing
grpo_train_sync -> TQ path while grpo_train -> legacy path

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.

Yeap, I'd expect this issue would be fixed once legacy path is retired.

print("🚀 Running synchronous VLM GRPO training (legacy)")
return grpo_train


def parse_args() -> tuple[argparse.Namespace, list[str]]:
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description="Run GRPO training with configuration")
Expand Down Expand Up @@ -110,6 +127,20 @@ def main() -> None:
processor, config.data, config.env, is_vlm=True
)

# Pick the policy factory at the launcher level so the legacy trainer
# stays data-plane-agnostic (architectural invariant — see
# tests/unit/data_plane/test_architecture_invariants.py).
_dp_cfg = config.data_plane or {}
if _dp_cfg.get("enabled", False):
from nemo_rl.models.policy.tq_policy import TQPolicy

def _make_policy(**kwargs):
return TQPolicy(**kwargs, dp_cfg=_dp_cfg)

_policy_factory = _make_policy
else:
_policy_factory = None # setup() defaults to plain Policy

with rl_init_timer.time("setup"):
(
policy,
Expand All @@ -125,7 +156,14 @@ def main() -> None:
master_config,
teacher_worker_groups,
alias_to_group_alias,
) = setup(config, tokenizer, dataset, val_dataset, processor=processor)
) = setup(
config,
tokenizer,
dataset,
val_dataset,
processor=processor,
policy_factory=_policy_factory,
)

rl_init_timer.record("total", time.perf_counter() - main_start)
rl_init_metrics = rl_init_timer.get_timing_metrics(reduction_op="sum")
Expand Down Expand Up @@ -170,8 +208,9 @@ def main() -> None:
processor=processor,
)
else:
print("🚀 Running synchronous GRPO training")
grpo_train(
# ``_select_trainer`` prints which sync trainer it picked.
trainer = _select_trainer(master_config)
trainer(

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.

examples/run_vlm_grpo.py:213

The sibling launcher wraps this call in with checkpointer:, and its comment says why:

# run_grpo.py:241-245
# grpo_train_sync defers checkpoint finalization to the checkpointer's
# background threads; the context manager guarantees they are flushed on
# exit. (grpo_train also flushes internally; shutdown() is idempotent.)
with checkpointer:
    trainer(

(

RL/examples/run_grpo.py

Lines 241 to 245 in 4bcb483

# grpo_train_sync defers checkpoint finalization to the checkpointer's
# background threads; the context manager guarantees they are flushed on
# exit. (grpo_train also flushes internally; shutdown() is idempotent.)
with checkpointer:
trainer(
)

The omission was harmless before this PR, because this launcher only ever called grpo_train, which flushes internally. Now that _select_trainer can return grpo_train_sync, the exception path is uncovered: that trainer calls checkpointer.shutdown() on its normal exits only, so a crash or interrupt after begin_finalization can kill the daemon thread before tmp_step_N is renamed, losing the last checkpoint. CheckpointManager.__enter__ / __exit__ already exist. Reachable on both new recipes.

Suggested change
trainer(
trainer = _select_trainer(master_config)
with checkpointer:
trainer(

(the call's arguments need one more level of indentation)

policy,
policy_generation,
dataloader,
Expand Down
18 changes: 15 additions & 3 deletions nemo_rl/algorithms/grpo.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,10 +462,22 @@ def _validate_multimodal_dedup_capability(master_config: MasterConfig) -> None:
"grpo.deduplicate_multimodal_data=true is currently qualified "
"only with policy.generation.backend=vllm."
)
if (master_config.data_plane or {}).get("enabled", False):
# The data plane carries deduplicated payloads: ``PackedTensor.to_wire``
# emits one wire row per *logical* row and walks segments under dedup, so
# the wire format itself is not the constraint. The one gap is NeMo-Gym:
# ``grpo_train_sync`` does not call
# ``attach_initial_nemo_gym_image_payloads``, which supplies the initial
# image tensors a Gym dataset omits from ``extra_env_info``. That helper is
# itself gated on ``should_use_nemo_gym``, so non-Gym recipes never needed
# it and are unaffected.
if (master_config.data_plane or {}).get("enabled", False) and (

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.

nemo_rl/algorithms/grpo.py:473

The premise of this relaxation does not hold: on the TQ trainer, deduplication never happens at all, so the flag becomes a silent no-op rather than a supported combination.

enable_deduplication() has exactly one caller in the tree — batched_data_dict.py:72, inside _prepare_multimodal_sharing, which repeat_interleave reaches only when share_immutable_media=True. The legacy trainer passes it:

# grpo.py:3069-3073
batch.repeat_interleave(
    master_config.grpo.num_generations_per_prompt,
    share_immutable_media=(master_config.grpo.deduplicate_multimodal_data),
)

grpo_sync.py:603 does not — it is a bare batch.repeat_interleave(num_generations_per_prompt), and this PR does not change it. So provenance is never assigned, _row_offsets stays None, and the deepcopy at batched_data_dict.py:969 runs with an empty memo — G independent copies of every image in driver RAM and G on the wire.

This is live in a recipe this PR adds: vlm_grpo-nemotron-omni-30ba3b-clevr-1n8g-automodel-ep8.v1-tq_simple.yaml sets data_plane.enabled: true and inherits deduplicate_multimodal_data: true from its base (line 3), with G=16. A user setting a memory-saving flag gets zero saving and no warning. The run went green because clevr images are small.

Worth knowing for whichever fix you pick: even with sharing enabled, to_wire emits one row per logical row, and torch.nested.as_nested_tensor(..., layout=torch.jagged) routes to jagged_from_list, which does values = torch.cat(tensors, dim=ragged_idx - 1) — so the wire payload is O(G × images) regardless. Carrying the sharing across the wire would mean emitting each physical segment once and putting the CSR map in the tags channel multimodal_row_tags already uses; from_wire already accepts _row_offsets / _segment_indices.

Three options, in order of effort: pass share_immutable_media=master_config.grpo.deduplicate_multimodal_data at grpo_sync.py:603 and accept that the wire still expands; restore the guard for the TQ path and drop deduplicate_multimodal_data: true from the new clevr recipe; or carry the provenance. Silently accepting a no-op flag is the one option worth avoiding.

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.

Rohit: Since your PR already added wiring for deduped PackedTensors, it is worthwhile to not block this path at all. With the current guard, the PR blocks NemoGym paths where dedup is not used.

I think the better raise condition for this is to check if master_config.grpo.deduplicate_multimodal_data is set to True instead of checking this in NeMo-Gym. Otherwise this blocks sync GRPO with NemoGym (even text only paths) for no reason. We can validate the dedup path later.

@terrykong might need your opinion on this.

should_use_nemo_gym(master_config)
):
raise NotImplementedError(
"grpo.deduplicate_multimodal_data=true is currently supported "
"only when data_plane.enabled=false."
"grpo.deduplicate_multimodal_data=true with data_plane.enabled=true "
"is not supported for NeMo-Gym runs: the TransferQueue trainer does "
"not attach the initial Gym image payloads. Non-Gym recipes are "

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.

nemo_rl/algorithms/grpo.py:479

Three user-facing copies of the old rule survive this relaxation and now contradict both the code and the recipe this PR adds (which sets deduplicate_multimodal_data: true and data_plane.enabled: true):

All three say "requires the vLLM generation backend and data_plane.enabled=false". Whatever text lands should also say what dedup actually does on the TQ path — see the comment on the guard above.

Separately, VLM GRPO over the data plane is a new supported configuration and nothing under docs/ mentions it (grep -rl data_plane docs/ hits only single-controller.md and the two nano-omni guides). A short subsection — which fields cross the wire, and the Gym-dedup exclusion — would fit in the nano-omni guide.

"supported."
)


Expand Down
37 changes: 35 additions & 2 deletions nemo_rl/algorithms/grpo_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
from typing import TYPE_CHECKING, Any, Optional

if TYPE_CHECKING:
from transformers import AutoProcessor

from nemo_rl.models.policy.tq_policy import TQPolicy

import numpy as np
Expand All @@ -50,6 +52,7 @@
MasterConfig,
_clip_grpo_advantages,
_create_advantage_estimator,
_initial_policy_generation_stale,
_log_mixed_rewards_and_advantages_information,
_placeholder_seq_logprob_error_metrics,
_policy_dtype,
Expand Down Expand Up @@ -390,6 +393,19 @@ def grpo_train_sync(
checkpointer: CheckpointManager,
grpo_save_state: GRPOSaveState,
master_config: MasterConfig,
# Unused here, and present only so the shared VLM launcher can pass one
# fixed kwarg set to whichever trainer ``_select_trainer`` returns.
# ``grpo_train``'s sole use of it is
# ``attach_initial_nemo_gym_image_payloads``, gated on
# ``grpo.deduplicate_multimodal_data`` *and* ``should_use_nemo_gym`` — the
# combination ``setup()`` rejects via
# ``_validate_multimodal_dedup_capability``. Non-Gym dedup runs never call
# that helper, so they need no processor here either.
#
# TODO: replace this parity kwarg with a ``ProcessorInterface`` both
# trainers consume, rather than threading ``Optional[AutoProcessor]``
# through every signature — ``grpo.py`` repeats it at seven sites.
processor: Optional["AutoProcessor"] = None,
) -> None:
"""Run GRPO training algorithm — TransferQueue-mediated.

Expand All @@ -414,7 +430,13 @@ def grpo_train_sync(

kv_scales_cache = None # Cache reused for computed kv scales

POLICY_GENERATION_STALE = True
# Skip a redundant iter-1 refit when setup() already synced weights
# (synchronizer not stale, fresh run). The redundant refit resets
# vLLM CUDA-graph / KV-cache state and yields a step-1
# token_mult_prob_error spike that converges by step 3.
POLICY_GENERATION_STALE = _initial_policy_generation_stale(
policy_generation, grpo_save_state.total_steps
)
assert policy_generation is not None

if master_config.grpo.skip_reference_policy_logprobs_calculation:
Expand Down Expand Up @@ -950,9 +972,20 @@ def grpo_train_sync(
# (logprobs/advantages/masks) and wire-only message
# log bulk fields are skipped by virtue of not being
# in DP_CALIB_INPUT_FIELDS.
# ``DP_CALIB_INPUT_FIELDS`` names a ``multi_modal_inputs``
# column that is never actually written — the rollout
# writes pixel_values / image_grid_thw / … individually
# — so on a VLM run this filter would otherwise yield
# only the text fields and calibrate image-blind.
# Local import: ``tq_policy`` is TYPE_CHECKING-only at
# module scope here (see the import block at the top).
from nemo_rl.models.policy.tq_policy import (
_present_multimodal_fields,
)

_calib_fields = [
f for f in (meta.fields or []) if f in DP_CALIB_INPUT_FIELDS
]
] + _present_multimodal_fields(meta)
calibration_data = policy.read_from_dataplane(
meta,
select_fields=_calib_fields,
Expand Down
Loading
Loading