Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2abd4e7
perf(grpo): evaluate sequence logprob filtering in training forward
CarlosGomes98 Sep 10, 2026
73c62e0
clarify code comments
CarlosGomes98 Sep 14, 2026
b5babef
chore: restore Megatron-Bridge submodule pin to origin/main
CarlosGomes98 Sep 15, 2026
79c7129
fix(grpo): preserve fractional single-forward normalization
CarlosGomes98 Sep 17, 2026
9697a3e
fix(grpo): address single-forward filtering review feedback
CarlosGomes98 Sep 17, 2026
8db5ecd
make rescaling requirement more readable
CarlosGomes98 Sep 18, 2026
a35789e
add test train normalizes survivors
CarlosGomes98 Sep 18, 2026
b3ca2b1
add comment explaining token factor value
CarlosGomes98 Sep 18, 2026
bce9547
fix(grpo): address single-forward review and add nightly coverage
CarlosGomes98 Sep 21, 2026
f092f3e
refactor(grpo): move in-loss sequence filtering flag to loss config
CarlosGomes98 Sep 21, 2026
f87eb81
fix(grpo): accept validated draft config in single-forward guard
CarlosGomes98 Sep 21, 2026
1e1d447
test(grpo): align single-forward nightly setup with existing recipes
CarlosGomes98 Sep 21, 2026
299bd5f
docs(grpo): illustrate single-forward reward and timing comparison
CarlosGomes98 Sep 21, 2026
4d47942
docs(grpo): match existing paragraph wrapping
CarlosGomes98 Sep 21, 2026
57dc5ad
docs(grpo): update comparison with completed training runs
CarlosGomes98 Sep 21, 2026
e046bb4
docs(grpo): simplify comparison plot annotations
CarlosGomes98 Sep 21, 2026
a3b0c11
docs(grpo): add gradient norm to training comparison
CarlosGomes98 Sep 21, 2026
ba34dd4
simplify part of the grpo doc
CarlosGomes98 Sep 21, 2026
db9abfc
Avoid BatchedDataDict.to
CarlosGomes98 Sep 22, 2026
b341d8b
fix(grpo): address single-forward review feedback
CarlosGomes98 Sep 22, 2026
ffadb85
add todo comment
CarlosGomes98 Sep 23, 2026
9aed5b9
remove trailing whitespace
CarlosGomes98 Sep 23, 2026
59d2ec0
minimize recipe
CarlosGomes98 Sep 23, 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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 31 additions & 2 deletions docs/guides/grpo.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,9 +503,9 @@ The table below covers the metrics reported by `ClippedPGLossFn` (the GRPO/PPO f
| **Follows `token_level_loss`** — token-normalized when `true`, sequence-normalized when `false` | `loss`, `kl_penalty` |
| **Always token-normalized**, regardless of the setting | `probs_ratio`, `probs_ratio_clamped`, `token_mult_prob_error`, `gen_kl_error`, `policy_kl_error`, `js_divergence_error`, `approx_entropy` |
| **Keyed on a different parameter** | `sampling_importance_ratio` follows `sequence_level_importance_ratios`; `is_oob_ratio` is sequence-normalized only for `truncated_importance_sampling_type: seq-mask-tis` |
| **Not normalized** — raw counts, per-microbatch means, or extrema combined with min/max | `num_valid_samples`, `positive_nll_loss`, `probs_ratio_min`, `probs_ratio_max`, `probs_ratio_clamped_min`, `probs_ratio_clamped_max` |
| **Not normalized** — raw counts, per-microbatch means, or extrema combined with min/max | `num_valid_samples`, `positive_nll_loss`, `probs_ratio_min`, `probs_ratio_max`, `probs_ratio_clamped_min`, `probs_ratio_clamped_max`, `num_masked_seqs_by_logprob_error`, `seq_logprob_error_valid_tokens`, `seq_logprob_error_valid_seqs` |

The authoritative list is the [`metric_normalizations` table in `ClippedPGLossFn`](https://github.com/NVIDIA-NeMo/RL/blob/4a1454bf430624786251d14ba0197169c8e68a5c/nemo_rl/algorithms/loss/loss_functions.py#L335-L371), which is kept in sync with the metrics the loss returns.
The authoritative list is the [`metric_normalizations` table in `ClippedPGLossFn`](https://github.com/NVIDIA-NeMo/RL/blob/1343b2f6fb92b4cced5e515e404595dcac627d98/nemo_rl/algorithms/loss/loss_functions.py#L372-L394), which is kept in sync with the metrics the loss returns.

#### Choosing a setting

Expand Down Expand Up @@ -557,6 +557,35 @@ By multiplying the first term of the loss function by the importance weights $\f
To enable the importance sampling correction, set the config `use_importance_sampling_correction=True` in the `ClippedPGLossConfig`. By default, we set this config to False to align with standard GRPO.


#### Single-forward sequence-logprob filtering

For non-streaming Megatron GRPO, enable `loss_fn.seq_logprob_error_in_loss` to apply the absolute sequence-logprob error threshold inside the training loss. This avoids the standalone policy-logprob forward even when `grpo.seq_logprob_error_threshold` is set:

```yaml
grpo:
seq_logprob_error_threshold: 2.0
skip_reference_policy_logprobs_calculation: true
loss_fn:
seq_logprob_error_in_loss: true
force_on_policy_ratio: true
token_level_loss: true
reference_policy_kl_penalty: 0.0
```

The default is `false`, retaining the existing pre-training check. This mode requires the `grpo` advantage estimator and one optimizer update per rollout batch (`force_on_policy_ratio`'s existing constraint). It supports synchronous and legacy asynchronous training, including synchronous data-plane training, sequence packing, and gradient accumulation. SingleController is rejected because its advantage baselines depend on the earlier filtering decision. DTensor, KL-in-reward, MTP, draft training, positive-example NLL, and distillation are not supported by this mode. A reference-model forward is still needed if reference KL is enabled; the example disables it. Existing recipes using MTP (such as the NeMo-Gym Nemotron recipes), distillation (`mopd`), or Automodel must first satisfy these constraints; setting the flag alone does not make them compatible.

The [Qwen2.5-Math-1.5B single-forward recipe](../../examples/configs/recipes/llm/grpo-qwen2.5-math-1.5b-instruct-1n8g-megatron-single-forward.yaml) provides a complete configuration for one node with eight GPUs. The comparison below used two GB300 nodes with four GPUs each, with overrides `cluster.num_nodes=2 cluster.gpus_per_node=4 cluster.segment_size=2`. The reported step times therefore do not describe the recipe's default topology and also depend on the benchmark hardware and runtime.

The loss computes the same mean `exp(abs(policy_logprob - generation_logprob))` over valid response tokens, using detached logprobs from the training forward. Both seq_logprob_error_in_loss true and false ignore nonfinite logprobs at masked-out positions. Rejected sequences contribute zero loss but are still processed by the batched forward/backward. The worker sums survivor counts over every microbatch and DP rank, then rescales accumulated gradients before gradient clipping and the optimizer step. Thus the denominator excludes threshold-rejected tokens, regardless of how sequences were packed. Existing `seq-mask-tis` remains active: TIS-only failures still count in that denominator. An optimizer batch with no surviving response tokens raises an error before the optimizer or scheduler updates.

Loss-local filtering does not alter the rollout tensors or reward-group advantages. Because it evaluates the training forward rather than a separate inference forward, FP8 scale history, packing, and other numerical differences can change which sequences pass. Bitwise equivalence is not expected. The training metrics report `num_masked_seqs_by_logprob_error`, `seq_logprob_error_valid_tokens`, and `seq_logprob_error_valid_seqs`; separate-pass sequence-error summary metrics are omitted rather than reported as placeholders. In this mode, all three counts are weighted by `sample_mask`: the masked sequence metric sums the weights of newly rejected sequences, and the valid token and sequence metrics sum surviving weights. With fractional sample weights, `num_masked_seqs_by_logprob_error` can therefore be fractional, unlike the integer count emitted by the separate-forward filter.

Measure end-to-end step time and reward/gradient-norm trajectories on the target recipe when comparing the modes. The avoided policy-logprob forward must be weighed against the device synchronization before gradient rescaling and the forward/backward work on rejected sequences. The rescaling restores normalization over surviving tokens before clipping; it does not introduce a different clipping threshold.

The following Qwen2.5-Math-1.5B comparison shows both modes over 450 steps with seed 42 on eight GB300 GPUs (two nodes). The reward curves closely track, with a 17.8% reduction in mean step time over steps 51–450 (6.69 seconds with in-loss filtering versus 8.14 seconds with separate-forward filtering).

![Train reward, token multiplicative probability error, step time, and gradient norm for single-forward versus separate-forward sequence-logprob filtering.](../assets/grpo-qwen-single-forward-comparison.png)

#### Overlong Filtering

This feature is controlled by the parameter `overlong_filtering`. It filters out sequences that exceed a predefined maximum length, helping maintain computational efficiency and model stability. When `overlong_filtering=True`, samples that reach `max_total_sequence_length` without producing an end-of-text token are excluded from loss computation. This reduces noise from penalizing generations that may be high-quality but exceed the sequence length limit.
Expand Down
3 changes: 3 additions & 0 deletions examples/configs/grpo_math_1B.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ loss_fn:
sequence_level_importance_ratios: false
token_level_loss: true
force_on_policy_ratio: false # Set to true to force ratio=1.0 (requires train_global_batch_size == num_prompts_per_step * num_generations_per_prompt)
# Opt-in single-forward Megatron GRPO: apply the threshold during training
# and normalize gradients over survivors before the optimizer step.
seq_logprob_error_in_loss: false
use_kl_in_reward: false # Reinforce++: add KL penalty to reward instead of loss
use_cispo: false # CISPO (https://arxiv.org/abs/2506.13585): clipped IS-weight policy optimization
# Disable PPO ratio (curr_logprobs - prev_logprobs).exp(); falls back to REINFORCE-style
Expand Down
1 change: 1 addition & 0 deletions examples/configs/ppo_math_1B.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ loss_fn:
sequence_level_importance_ratios: false
token_level_loss: true
force_on_policy_ratio: false
seq_logprob_error_in_loss: false # Only supported by non-streaming Megatron GRPO.
use_kl_in_reward: false # No KL in reward (KL penalty is 0 anyway)
# VAPO: NLL loss on correct samples. 0 = disabled.
positive_example_nll_weight: 0.0 # VAPO: 0.1
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
defaults: ../../grpo_math_1B.yaml
grpo:
max_num_steps: 450
seq_logprob_error_threshold: 2.0
skip_reference_policy_logprobs_calculation: true
loss_fn:
seq_logprob_error_in_loss: true
force_on_policy_ratio: true
reference_policy_kl_penalty: 0.0
checkpointing:
checkpoint_dir: results/grpo-qwen2.5-math-1.5b-instruct-1n8g-megatron-single-forward
policy:
model_name: Qwen/Qwen2.5-Math-1.5B-Instruct
tokenizer:
name: Qwen/Qwen2.5-Math-1.5B-Instruct
dtensor_cfg:
enabled: false
megatron_cfg:
enabled: true
scheduler:
lr_warmup_iters: 50
dynamic_batching:
enabled: true
sequence_packing:
enabled: false
make_sequence_length_divisible_by: 1
generation:
max_new_tokens: 512
vllm_cfg:
max_model_len: 512
data:
max_input_seq_length: 512
logger:
log_dir: logs/grpo-qwen2.5-math-1.5b-instruct-1n8g-megatron-single-forward
wandb_enabled: true
tensorboard_enabled: true
wandb:
project: nemo-rl
name: grpo-qwen2.5-math-1.5b-instruct-1n8g-megatron-single-forward
cluster:
gpus_per_node: 8
103 changes: 74 additions & 29 deletions nemo_rl/algorithms/grpo.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
WALL_CLOCK_EFFICIENCY_CATEGORIES,
calculate_baseline_and_std_per_prompt,
calculate_trivial_reward_distributions,
compute_seq_logprob_errors,
get_gdpo_reward_component_keys,
log_generation_metrics,
print_efficiency_summary,
Expand Down Expand Up @@ -136,6 +137,7 @@
router_replay_enabled,
)
from nemo_rl.models.policy import PolicyConfig
from nemo_rl.models.policy.draft_config import coerce_draft_config
from nemo_rl.models.policy.interfaces import ColocatablePolicyInterface
from nemo_rl.models.policy.lm_policy import Policy
from nemo_rl.telemetry.config import TelemetryConfig
Expand Down Expand Up @@ -462,6 +464,43 @@ class MasterConfig(BaseModel, extra="allow"):
# ===============================================================================


def _validate_seq_logprob_error_in_loss(master_config: MasterConfig) -> None:
"""Validate the single-forward threshold path before allocating workers."""
if not master_config.loss_fn.seq_logprob_error_in_loss:
return
if master_config.grpo.seq_logprob_error_threshold is None:
raise ValueError(
"loss_fn.seq_logprob_error_in_loss requires seq_logprob_error_threshold"
)
loss = master_config.loss_fn
if not loss.force_on_policy_ratio or not loss.token_level_loss:
raise ValueError(
"loss_fn.seq_logprob_error_in_loss requires force_on_policy_ratio=true "
"and token_level_loss=true"
)
if master_config.grpo.adv_estimator.name != "grpo" or loss.use_kl_in_reward:
raise ValueError(
"loss_fn.seq_logprob_error_in_loss requires the grpo advantage estimator "
"without use_kl_in_reward"
)
policy = master_config.policy
if "megatron_cfg" not in policy or not policy["megatron_cfg"]["enabled"]:
raise ValueError(
"loss_fn.seq_logprob_error_in_loss requires the Megatron backend"
)
draft = coerce_draft_config(policy.get("draft"))
if (
policy["megatron_cfg"].get("mtp_num_layers")
or (draft is not None and draft.enabled)
or loss.positive_example_nll_weight != 0
or opd_module.is_opd_enabled(master_config)
):
raise ValueError(
"loss_fn.seq_logprob_error_in_loss does not support MTP, draft, "
"positive-example NLL, or distillation losses"
)


def _validate_multimodal_dedup_capability(master_config: MasterConfig) -> None:
"""Reject configurations whose media transfer path is not qualified."""
if not master_config.grpo.deduplicate_multimodal_data:
Expand Down Expand Up @@ -587,6 +626,7 @@ def setup(
generation_config = DynamoConfig.model_validate(generation_config).model_dump()
policy_config["generation"] = generation_config
_validate_multimodal_dedup_capability(master_config)
_validate_seq_logprob_error_in_loss(master_config)

# Validation-only sampling is honored only on the NeMo-Gym vLLM rollout
# path; everywhere else validation must sample exactly like training.
Expand Down Expand Up @@ -793,7 +833,9 @@ def init_train_dataloader(dataset, suffix: str = ""):
)

loss_fn = ClippedPGLossFn(
loss_config, use_fused_linear_logprobs=use_fused_linear_logprobs
loss_config,
use_fused_linear_logprobs=use_fused_linear_logprobs,
seq_logprob_error_threshold=grpo_config.seq_logprob_error_threshold,
)

# Validate force_on_policy_ratio
Expand Down Expand Up @@ -2753,15 +2795,16 @@ def _resolve_logprob_skip_flags(
) -> tuple[bool, bool | None]:
"""Return (skip_prev_logprobs, skip_reference_logprobs); warn on incompatible combos.

Skip prev_logprobs when force_on_policy_ratio=True unless
seq_logprob_error_threshold is set (which requires prev_logprobs).
Skip prev_logprobs when force_on_policy_ratio=True unless the sequence
threshold is evaluated before training rather than inside the loss.
Skip reference_policy_logprobs when
``grpo.skip_reference_policy_logprobs_calculation`` is set.
"""
# todo @jiaqi: is there a better way to skip prev_logprobs computation while still computing the seq-level error metrics?
if (
master_config.loss_fn.force_on_policy_ratio
and master_config.grpo.seq_logprob_error_threshold is not None
and not master_config.loss_fn.seq_logprob_error_in_loss
):
warnings.warn(
"force_on_policy_ratio=True but seq_logprob_error_threshold is set. "
Expand Down Expand Up @@ -2802,27 +2845,13 @@ def compute_and_apply_seq_logprob_error_masking(
sample_mask = train_data["sample_mask"]
prev_logprobs = train_data["prev_logprobs"][:, 1:]
generation_logprobs = train_data["generation_logprobs"][:, 1:]
lp_error = torch.abs(generation_logprobs - prev_logprobs)

# Use combined mask exactly as in loss function
mask = token_mask * sample_mask.unsqueeze(-1)

# Calculate sequence-level multiplicative prob error.
#
# NOTE: When a sequence is fully masked (mask.sum == 0), it should not contribute to
# min/mean/max statistics; otherwise, it would yield a spurious 0 due to denominator
# clamping and incorrectly drag min_seq_mult_prob_error to 0.
denom = mask.sum(dim=-1)
valid_seq_mask = denom > 0

# EXACT same calculation as token_mult_prob_error but per-sequence (for valid sequences)
seq_mult_prob_error = torch.zeros_like(denom, dtype=lp_error.dtype)
seq_mult_prob_error, valid_seq_mask = compute_seq_logprob_errors(
policy_logprobs=prev_logprobs,
generation_logprobs=generation_logprobs,
token_mask=token_mask,
sample_mask=sample_mask,
)
if valid_seq_mask.any():
num = (torch.exp(lp_error * mask) * mask).sum(dim=-1)
seq_mult_prob_error[valid_seq_mask] = num[valid_seq_mask] / denom[
valid_seq_mask
].clamp(min=1)

valid_errors = seq_mult_prob_error[valid_seq_mask]
max_seq_mult_prob_error = valid_errors.max().item()
mean_seq_mult_prob_error = valid_errors.mean().item()
Expand Down Expand Up @@ -3599,10 +3628,18 @@ def _grpo_train_impl(
del logprob_data
del extra_multimodal_data

# Seq-level logprob error metrics/masking require real prev_logprobs
# Separate-pass seq-level metrics/masking require real prev_logprobs
if skip_prev_logprobs:
# Cannot compute seq-level metrics with placeholder prev_logprobs
seq_logprob_error_metrics = _placeholder_seq_logprob_error_metrics()
# In-loss filtering reports counts through all_mb_metrics.
# Use {} so placeholder zeros cannot overwrite those counts
# when seq_logprob_error_metrics is merged after training.
# Otherwise, placeholder prev_logprobs cannot provide
# sequence-error metrics.
seq_logprob_error_metrics = (
{}
if master_config.loss_fn.seq_logprob_error_in_loss
else _placeholder_seq_logprob_error_metrics()
Comment thread
CarlosGomes98 marked this conversation as resolved.
)
else:
seq_error_result = compute_and_apply_seq_logprob_error_masking(
train_data=train_data,
Expand Down Expand Up @@ -5394,10 +5431,18 @@ def _flush_collector_telemetry() -> None:
train_data["prev_logprobs"]
)

# Seq-level logprob error metrics/masking require real prev_logprobs
# Separate-pass seq-level metrics/masking require real prev_logprobs
if skip_prev_logprobs:
# Cannot compute seq-level metrics with placeholder prev_logprobs
seq_logprob_error_metrics = _placeholder_seq_logprob_error_metrics()
# In-loss filtering reports counts through all_mb_metrics.
# Use {} so placeholder zeros cannot overwrite those counts
# when seq_logprob_error_metrics is merged after training.
# Otherwise, placeholder prev_logprobs cannot provide
# sequence-error metrics.
seq_logprob_error_metrics = (
{}
if master_config.loss_fn.seq_logprob_error_in_loss
else _placeholder_seq_logprob_error_metrics()
)
else:
seq_error_result = compute_and_apply_seq_logprob_error_masking(
train_data=train_data,
Expand Down
14 changes: 11 additions & 3 deletions nemo_rl/algorithms/grpo_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -961,11 +961,19 @@ def grpo_train_sync(
extras_bdd["reference_policy_logprobs"] if compute_ref else None
)

# Seq-level logprob error metrics/masking require real prev_logprobs
# Separate-pass seq-level metrics/masking require real prev_logprobs
if skip_prev_logprobs:
sample_mask = loss_multiplier
# Cannot compute seq-level metrics with placeholder prev_logprobs
seq_logprob_error_metrics = _placeholder_seq_logprob_error_metrics()
# In-loss filtering reports counts through all_mb_metrics.
# Use {} so placeholder zeros cannot overwrite those counts
# when seq_logprob_error_metrics is merged after training.
# Otherwise, placeholder prev_logprobs cannot provide
# sequence-error metrics.
seq_logprob_error_metrics = (
{}
if master_config.loss_fn.seq_logprob_error_in_loss
else _placeholder_seq_logprob_error_metrics()
)
else:
sample_mask, seq_logprob_error_metrics = (
_compute_seq_logprob_error_metrics(
Expand Down
Loading
Loading