feat(grpo): add wall-clock time-efficiency reward for NeMo-Gym rollouts - #4029
feat(grpo): add wall-clock time-efficiency reward for NeMo-Gym rollouts#4029yfw wants to merge 5 commits into
Conversation
Port the `grpo.time_efficiency` reward from the SWE time-efficiency
experiments on the internal `sdd/swe-opencode-superv35` branch onto
super-v3.5-posttraining:
reward_i = reward_i - lambda_time * (openhands_run_time_i / 60)
`openhands_run_time` is the agent-loop wall time reported by the Gym
swe_agents server. With the default lambda_time = 1/60 a 60-minute rollout
costs exactly 1.0. `apply_to` selects whether failures are charged too
("all", the original semantics) or only resolved rollouts ("correct");
`floor` optionally clamps the post-deduction reward. Group-level
`time_efficiency/*` metrics are surfaced next to the reward metrics.
Adapted to this branch's conventions instead of applying the original diff:
- `TimeEfficiencyConfig` is a pydantic BaseModel on `GRPOConfig`
(`grpo.time_efficiency`), with the logic in a standalone
`nemo_rl/utils/time_efficiency.py` module, mirroring length_penalty.
- Threaded explicitly through run_nemo_gym_rollout_sync,
run_async_nemo_gym_rollout and _postprocess_single_nemo_gym_group and
wired at the training rollout call sites (grpo_train, the async
trajectory collector and the sync rollout actor). Validation keeps the
raw env reward, matching how length_penalty_config is handled here (the
original branch also applied it during validation).
- Runs before the reward-zeroing penalties so those still zero a rollout.
- Documented in the grpo_math_1B exemplar and reference configs; disabled
by default so existing runs are unaffected.
Enable with e.g.
++grpo.time_efficiency.enabled=true ++grpo.time_efficiency.apply_to=all
++grpo.advantage_clip_low=-3 ++grpo.advantage_clip_high=3
The advantage clip is strongly recommended: with normalize_rewards the small
continuous term is divided by the group's reward spread and can explode.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
Co-Authored-By: Mengru Wang <mengruw@nvidia.com>
8a2cf8c to
0ed0001
Compare
yfw
left a comment
There was a problem hiding this comment.
Reviewed at 0ed0001 against super-v3.5-posttraining.
Merge safety. Nothing in this PR changes behaviour while time_efficiency.enabled: false, which is the default and the setting of every shipped recipe. apply_time_efficiency_reward returns {} before touching results; all three new parameters default to None and every caller of the three widened signatures is keyword-only, so ppo.py, distillation.py and grpo.validate() are unaffected; a resolved-config scan across both examples/configs/** and examples/nemo_gym/** found no recipe that sets the block; and the saved config.yaml is write-only, so a checkpoint predating this field resumes on the pydantic default.
Verification run against this head. The 20 new tests pass. tests/unit/test_config_v2.py passes (14), and removing the reference-config block makes it fail with missing=['grpo.time_efficiency'] — the pairing is load-bearing and present. tests/unit/utils + tests/unit/experience + tests/unit/algorithms + test_config_v2.py: 1363 passed; the 8 non-passing tests are all tests/unit/utils/test_native_checkpoint.py (NVIDIA driver ... too old (found version 12080) while building nv-grouped-gemm), pre-existing on the base branch and with no import path to anything this PR touches. A 22-mutation sweep over nemo_rl/utils/time_efficiency.py killed 21, including replacing the whole function body with return {} — the new tests have real teeth. The 2 pyrefly errors and the 3 files ruff reformats in this tree are pre-existing on the base branch (git blame dates them to Sept 2 and Sept 4), not from this PR.
Both CI ratchets are satisfied unprompted: nemo_rl/utils/time_efficiency.py is in pyrefly.toml project-includes, and the exemplar/reference-config edit is paired. The config shape also follows the right internal analog — a typed BaseModel sub-block like reward_scaling/reward_shaping — rather than length_penalty's untyped dumped-dict shape that the PR body cites; extra="allow" matches all five sibling reward configs and the config-conventions rule.
One inconsistency not posted inline because no shipped config reaches it: on the single-controller path, SyncRolloutActor.rollout_to_tq passes the block for both training and validate_sync, so the new comment at grpo.py:357-358 ("validation reports the raw env reward") holds for the legacy validate() but not for grpo_train_sync — the same way effort_levels and reward_penalties already behave there. Worth a word in that comment.
Three comments inline: one reachable reward inversion with a two-part fix, one factual correction to the module docstring, and one metric that misreports on the apply_to: "correct" arm.
Generated by Claude Code
Address the review on #4029 (posted review plus the team review): - Run the deduction LAST in _postprocess_single_nemo_gym_group, after effort shaping, the reward-zeroing penalties and the length penalties. Those all assume a binary env reward: with the deduction first, length penalties silently skipped every group (_is_binary_reward), effort shaping scaled/inverted the bonus, and under apply_to="all" a rollout zeroed by a penalty (0.0) outranked an honest failure (-lambda*t). - apply_to="correct" now requires a resolved rollout that still carries a positive reward after the penalties, so a penalized-but-resolved rollout is not charged. - Metric keys use the <name>/<stat> convention (time_efficiency/{minutes,deduction}/{mean,max}) so aggregate_rollout_metrics maxes the /max entries across prompt groups instead of averaging them; group_has_signal is keyed on the deductions (with a 1e-6 tolerance) so skipped rollouts and floor clamps no longer report a signal that never reached a reward. - SyncRolloutActor.rollout_to_tq gains is_validation; validate_sync passes it so single-controller validation accuracy uses the raw env reward, matching grpo.validate(). - Raise at setup when grpo.time_efficiency is enabled without the NeMo-Gym path (mirrors _raise_if_reward_penalties_enabled_without_nemo_gym). - Docstrings: openhands_run_time includes apptainer spin-up (it excludes final evaluation and Ray queueing); results is one prompt group on the async path and the whole batch in run_nemo_gym_rollout_sync; add the missing length_penalty_config entry to run_nemo_gym_rollout_sync. - Tests: ordering vs reward-zeroing penalties under both apply_to modes, the "correct" gate on zeroed rollouts, deduction-keyed group_has_signal, and the setup guard. Co-Authored-By: Mengru Wang <mengruw@nvidia.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
…reward Address the follow-up review comment on #4029: swe_agents flags timed-out rollouts with mask_sample, which drops them from the loss but leaves them in the group baseline. Under apply_to="all" such a row is, by construction, the group's longest rollout and carried its largest deduction (-1.0 at the 3600 s timeout) without training, dragging the leave-one-out baseline down and pushing every trainable failure in the group to a positive advantage. - _postprocess_single_nemo_gym_group now extracts the mask_sample flags once via _extract_mask_sample_flags (the same source of truth used for the loss mask in final_batch) and passes them to apply_time_efficiency_reward, which leaves flagged rows uncharged. When env.should_mask_flagged_samples is off those rows train and are still charged. - group_has_signal is computed over the trainable rows only, so a masked row's 0 deduction does not report a signal the policy cannot learn from. - Length mismatch between the flags and the results raises. - Document the behavior on the apply_to attribute and in the exemplar YAML; tests for both flag settings (pure and postprocess-level). Co-Authored-By: Mengru Wang <mengruw@nvidia.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
…ward
Port the per-tool-call arm of the internal experiments
("toolCallEncouragingTimePenalty") as an option of the existing
grpo.time_efficiency block rather than a second block, so both arms share
one implementation and the reviewed wiring:
reward += lambda_call_bonus * min(calls / call_bonus_ref, 1)
reward -= lambda_time * openhands_run_time / 60
reward = max(reward, floor) # when floor is set
- lambda_call_bonus (default 0.0, must be >= 0) pays for exploring; only
well-formed function_call items count, so a malformed call earns nothing.
call_bonus_ref (default null) is required when the bonus is on and is
model/task specific: calibrate it to the untrained checkpoint's mean calls
per solved task (time_efficiency/calls/mean at step 1). Validated at
config load.
- The bonus follows the same apply_to and mask_sample gates as the
deduction; the docs recommend apply_to "correct" with it, since paying it
on failures rewards junk calls. floor now counts how often it fired.
- New metrics: time_efficiency/calls/mean, bonus/mean, seconds_per_call
(the quantity a call bonus can be gamed on), bonus_saturated_frac and
floored_frac. deduction/* stays the realized decrease of the reward, now
net of the bonus.
- With the defaults the block is byte-for-byte the plain time deduction;
exemplar and reference YAML carry the two new keys with their defaults.
- 14 new pure unit tests (saturation, gating, floor, malformed calls,
validators, default equivalence).
Co-Authored-By: Mengru Wang <mengruw@nvidia.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
…ccepts it lambda_call_bonus: float = Field(default=0.0, ge=0.0) matches no Field overload as pyrefly models pydantic (no-matching-overload at nemo_rl/utils/time_efficiency.py:87), and the file is in pyrefly.toml project-includes, so the pre-commit pyrefly hook fails on it. Use the Annotated[float, Field(ge=0.0)] = 0.0 form the other pyrefly-checked configs already use (single_controller_utils/config.py:157, models/generation/vllm/config.py:150). Behaviour is unchanged: the default is still 0.0 and negative values still fail validation (test_rejects_negative_call_bonus). Verified: pyrefly 0 errors, ruff clean, 56 tests pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
What does this PR do ?
Adds an opt-in wall-clock time-efficiency reward (
grpo.time_efficiency) for NeMo-Gym agentic (SWE) rollouts, ported from the internalsdd/swe-opencode-superv35time-efficiency experiments.openhands_run_timeis the wall time of the agent container from launch to exit, reported by the Gymswe_agentsserver (it includes apptainer spin-up and excludes final evaluation and Ray queueing). With the defaultlambda_time = 1/60a 60-minute rollout costs exactly 1.0.apply_toselects whether failures are charged too ("all", the original semantics) or only resolved rollouts whose reward survived the reward-zeroing penalties ("correct");lambda_call_bonus> 0 (withcall_bonus_ref) adds the per-tool-call arm from the internal experiments: a saturating bonus for well-formedfunction_callitems that pays for exploring, following the sameapply_togate (use it with"correct", since paying it on failures rewards junk calls).flooroptionally clamps the adjusted reward.time_efficiency/{minutes,deduction}/{mean,max},calls/mean,bonus/mean,seconds_per_call,bonus_saturated_frac,floored_fracandgroup_has_signalare logged next to the reward metrics.nemo_rl/utils/time_efficiency.py:TimeEfficiencyConfig(pydanticBaseModel) andapply_time_efficiency_reward, mirroring howlength_penaltylives in utils.run_nemo_gym_rollout_sync,run_async_nemo_gym_rolloutand_postprocess_single_nemo_gym_group; runs after the other reward shapers (effort shaping, reward-zeroing penalties, length penalties), which all assume a binary env reward, so the continuous deduction composes with them.grpo_train,AsyncTrajectoryCollector,SyncRolloutActor). Validation keeps the raw env reward on both thegrpo.validate()path and the single-controllervalidate_syncpath (rollout_to_tq(..., is_validation=True)); the source branch also applied it during validation.mask_sample(e.g.swe_agentstimeouts) are not charged whileenv.should_mask_flagged_samplesis on: they are dropped from the loss, so charging them could only drag their siblings' group baseline down. With the flag off they train and are charged.reward_penaltiesguard), since only Gym agents emitopenhands_run_time.GRPOConfig.time_efficiencyfield; documented in thegrpo_math_1Bexemplar and reference configs. Disabled by default, so existing runs are unaffected.The SWE e2e config that uses this lives in the post-training pipeline repo rather than here.
Issues
N/A
Usage
The advantage clip is strongly recommended: with
normalize_rewardsthe small continuous term is divided by the group's reward spread and can explode.Before your PR is "Ready for review"
Pre checks:
tests/unit/utils/test_time_efficiency.py(22 tests),tests/unit/experience/test_time_efficiency_rollouts.py(ordering vs reward-zeroing penalties under bothapply_tomodes), and setup-guard tests intests/unit/algorithms/test_grpo.pyBaseModel; no docs page changes.Additional Information
Differences from the internal implementation (
sdd/swe-opencode-superv35,grpo.time_efficiencyin_postprocess_single_group). The reward formula,lambda_time,floor, and the handling of missing or malformedopenhands_run_timeare unchanged. What differs:env.should_mask_flagged_samplesis on, in bothapply_tomodes. This matters for"all": internally aswe_agentstimeout (flaggedmask_sample, always the group's longest rollout) was charged the full deduction even though it is dropped from the loss, which pulled the group baseline down for its siblings; here it keeps its raw 0.0. Under
"correct"a timeout is unresolved and was never charged, so the only change is the rare resolved rollout that Gym flags for max iterations or context window, which is now exempt too. With the flag off, flagged rows train and are charged as before. For the SWE configas run, this is the one change that alters training rewards, and only in the
"all"arm."correct"gate. Requiresresolvedand a positive post-penalty reward; internallyresolvedalone. Identical forswe_agentsunless a reward-zeroing penalty is enabled.val:total_reward/meanwas accuracy minus the mean deduction. Here validation reports the raw env reward on both thegrpo.validate()and single-controllervalidate_syncpaths, so validation curves are not directly comparable to the internal runs.time_efficiency/{minutes,deduction}/{mean,max}(wasminutes_mean,minutes_max,deduction_mean,deduction_max), so the async aggregator takes true maxima across groups.group_has_signalis keyed on the deductions of trainable rows rather than on raw wall times, so it reads 0 when nothing was actually charged.grpo.time_efficiencyis a typedTimeEfficiencyConfig(an invalidapply_tofails at load) threaded explicitly through the rollout functions, instead of an untyped dict read frommaster_configinside the postprocess. Enabling it without the NeMo-Gym path raises at setup, mirroring thereward_penaltiesguard.openhands_run_timeis documented as spanning the agent container from launch to exit (it includes apptainer spin-up); the internal comments described it as the agent loop only.per_tool_time_efficiencyblock (call bonus plus time deduction) is folded into this same block aslambda_call_bonus/call_bonus_ref, off by default.call_bonus_refis required when the bonus is on instead of defaulting to 56, and the bonus follows the sameapply_toandmask_samplegates and the same last-in-the-postprocess ordering as the deduction. Metrics for it:calls/mean,bonus/mean,seconds_per_call,bonus_saturated_frac,floored_frac.Net effect for comparing against the completed internal runs: training rewards differ only through item 1, and validation metrics through item 4.
apply_to: allcharged failures for elapsed time and taught the policy to quit early, costing accuracy on long problems;apply_to: correctis the follow-up arm.