fix(genrm): bound cohort deadlines and failure cleanup - #3351
Conversation
|
🌿 Preview your docs: https://nvidia-preview-yuhez-3181-genrm-cohort-lifecycle.docs.buildwithfern.com/nemo/gym Here are the markdown pages you've updated: |
|
/ok to test e4154b6 |
|
/ok to test cfc48b3 |
## Summary
Fix GenRM comparison cohorts so physical retries cannot add duplicate
logical siblings or mix responses from different prompt-group attempts.
Previously, GenRM buffered requests in an append-only list keyed
primarily by task/prompt identity. A retry could therefore append
another physical response for an existing logical sibling, causing the
cohort to exceed `num_rollouts_per_prompt` or map rewards to the wrong
response.
This change introduces explicit logical cohort identity:
- `_ng_group_id`: stable identity for one logical prompt group.
- `_ng_group_attempt`: physical attempt of the entire prompt group.
- `_ng_rollout_index`: logical sibling slot within the group.
GenRM cohorts are now keyed by:
```text
(group_id, group_attempt)
```
Members within a cohort are keyed by:
```text
rollout_index
```
## Flow
First sibling arrives
│
▼
┌─────────────────┐
│ COLLECTING │
│ Waiting for N │
│ unique indices │
└────────┬────────┘
│
All N siblings arrive
│
▼
┌─────────────────┐
│ EVALUATING │
│ GenRM compares │
│ the full cohort │
└────────┬────────┘
│
┌──────────┴──────────┐
│ │
Success Unhandled cohort error
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ COMPLETED │ │ FAILED │
│ Rewards cached │ │ Waiters receive │
│ by rollout_idx │ │ controlled 503 │
└─────────────────┘ └─────────────────┘
A conflicting response for an already occupied `rollout_index`, a stale
group attempt, or inconsistent prompt content is rejected rather than
silently creating another cohort member.
## Changes
- Replace the append-only cohort list with one authoritative member per
`rollout_index`.
- Isolate replacement cohorts using `(group_id, group_attempt)`.
- Track the newest attempt for each `group_id` and reject stale
requests.
- Supersede incomplete older attempts when a newer attempt arrives.
- Map returned rewards by `rollout_index`, independent of HTTP arrival
order.
- Allow identical duplicate requests to share the existing result.
- Return cached rewards for identical late duplicates.
- Reject conflicting duplicates with a controlled HTTP error.
- Release every waiter when cohort evaluation raises or is cancelled,
and when cohort collection times out.
- Do not retire a logical member merely because one HTTP client
disconnects.
- Retain compact completed/failed tombstones with configurable TTL and
size bounds.
- Release full response bodies after a cohort becomes terminal.
- Echo `_ng_group_id`, `_ng_group_attempt`, `_ng_task_index`, and
`_ng_rollout_index` through the shared
rollout-collection response boundary.
## Current failure behavior and follow-up
This PR preserves the existing GenRM judge fallback behavior. Judge
transport
errors and exhausted output-parsing retries caught by
`_run_single_comparison()` return the configured default scores. The
cohort is
therefore completed with fallback rewards rather than failed with HTTP
503.
A cohort enters the failed state and releases its waiters with HTTP 503
only
when an error escapes `_run_compare()`, evaluation is cancelled, or
cohort
collection times out.
`cohort_collection_timeout_s` optionally bounds the time spent waiting
for all
cohort members. This PR does not add an evaluation deadline or a
per-judge HTTP
deadline.
Follow-up NVIDIA-NeMo#3351 adds bounded collection, evaluation, and judge-request
deadlines; propagates judge transport failures as cohort failures; and
expands
failure cleanup and shutdown handling. It may also require an explicit,
run-unique `_ng_group_id` for multi-member verification. Those
behavioral
changes are intentionally outside the scope of this PR.
## Backward compatibility
Legacy requests without `_ng_group_id` continue using task/prompt-based
cohort identity.
When `_ng_group_id` is supplied but `_ng_group_attempt` is omitted, the
request is treated as attempt `0` and emits a migration warning.
The cohort registry remains process-local. GenRM comparison should
continue using one HTTP worker until this state is moved to shared
storage.
## Configuration
Adds:
- `cohort_collection_timeout_s`
- `cohort_result_ttl_s`
- `max_terminal_cohorts`
Collection timeout is optional so long-running tool rollouts are not
expired by default.
## Checklist
- [ ] I have read the [contributing
guidelines](https://docs.nvidia.com/nemo/gym/latest/contribute/development-setup).
- [ ] The change is focused; unrelated "drive-by" edits are tracked as
separate issues/PRs.
- [ ] Tests added or updated and pass locally, or N/A for docs-only /
non-code changes (so CI unit/server checks pass when applicable).
- [ ] Pre-commit checks pass locally (`pre-commit run --all-files`) (so
CI lint/format/copyright pass).
- [ ] All commits have DCO sign-off (`git commit -s`) (so the DCO check
passes).
---------
Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
Add finite collection, evaluation and judge-request deadlines, drain owned work on failure or shutdown, and reject judge transport failures without publishing ordinary rewards. Preserve merged legacy sequential reruns and explicit-ID replay, keeping group constants local to the resource server. Cover collector failure artifacts and repeated legacy runs over real HTTP. Accept null judge status and make malformed-answer fallback independent of retry order. Document caller-owned group recovery and bounded retention. Co-authored-by: Anish Mahishi <amahishi@nvidia.com> Co-authored-by: Jiacheng Xu <jiachengx@nvidia.com> Co-authored-by: Teodor-Dumitru Ene <teodord.ene@gmail.com> Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
0851309 to
3491eed
Compare
|
/ok to test 3491eed |
|
Thanks @ananthsub — the updates are in the current head,
Validation: CI passed, along with 4,845 local core tests and 192 GenRM tests. The real-model smoke passed on this head using Qwen3-0.6B and the supported 235B GenRM judge: eight policy generations and four completed judge calls. It verified preserved failed answers, complete replacement, recovery from HTTP/body interruptions, correct rewards, and no additional work on a second collector resume. RL coordination remains required: the compatibility field alone does not fix the downstream loss-mask and group-statistics issues. RL #4160 contains those fixes and is still open. Before RL adopts this Gym revision, those fixes and a focused Gym-to-RL masking integration check are needed. Gym could merge first while RL retains its existing Gym revision; please confirm your preferred sequencing. The known Ray launcher abort still occurred during teardown after the functional checks passed; cleanup stopped all owned processes. That limitation and the individual-reverification restrictions remain documented. Ready for your re-review. |
ananthsub
left a comment
There was a problem hiding this comment.
looking really good! one comment inline
| assert orjson.loads(metrics_fpath.read_bytes())[0]["key_metrics"] == {"mean/reward": expected_mean} | ||
|
|
||
| @pytest.mark.parametrize("count_judge_failure", [False, True]) | ||
| async def test_masked_judge_failure_counts_as_zero_only_when_opted_in( |
There was a problem hiding this comment.
Consider an evaluation with one rollout and this configuration:
route_failures_to_sidecar: true
count_failure_classes_as_zero:
- judge_failedThe rollout returns:
{
"reward": 0.0,
"mask_sample": true,
"_ng_failure_class": "judge_failed"
}Online collection writes the row to the failure sidecar, then reaches the no-persisted-results guard and raises:
RuntimeError: None of the 1 dispatched rollouts produced a result
This happens before the opted-in failure is converted into a metrics-only zero at lines 1660–1673.
Running offline aggregation over the same artifacts succeeds and reports:
{"mean/reward": 0.0}Therefore online and offline behavior still differs when every rollout belongs to an explicitly counted failure class. This parity test covers three successes plus one failure, so it does not reach the all-failure boundary.
Please determine the eligible metrics-only failure rows before applying the no-results guard. The guard should raise only when both are empty:
not persisted_results and not counted_failuresKeep the current failure behavior when count_failure_classes_as_zero is empty. Please add an all-judge_failed regression asserting that:
- online and offline aggregation both report
mean/reward: 0.0; - the saved sidecar remains masked and unchanged; and
- the main rollout JSONL remains free of synthetic results.
Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
…rt-lifecycle Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
|
/ok to test 7ffb0ef |
…rt-lifecycle Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
|
/ok to test 6a7f30b |
|
🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/35895013332 |
… in the judge failure (NVIDIA-NeMo#3303) ## What does this PR do? Names GenRM output-budget exhaustion in the judge failure instead of reporting it as a generic "no completed answer", in `resources_servers/genrm_compare`. No behaviour change: same retries, same `JudgeError`, only the message and a per-attempt warning. ## Why When the GenRM model spends its whole `max_output_tokens` on reasoning, the model server returns HTTP 200 with a complete envelope and no verdict: `status: "incomplete"`, `incomplete_details.reason: "max_output_tokens"`. Since NVIDIA-NeMo#2814 and NVIDIA-NeMo#3351 the retry path already handles this correctly (retry, then `JudgeError`), but the error reads the same as an empty or garbled answer, so an operator cannot tell a budget problem from a transport or parsing one without opening the raw response. Measured against a hosted GenRM endpoint with the candidates delivered as `response_1`/`response_2` roles, temperature 0.6, same input each time: | `max_output_tokens` | runs | verdict (`status: completed`) | exhausted (`status: incomplete`, `reason: max_output_tokens`, no verdict) | |---|---|---|---| | 16,384 | 5 | 2 | 3 | | 24,576 | 4 | 2 | 2 | The outcome is bimodal: the same input either converges at roughly 8 to 10k output tokens or runs away and consumes whatever budget it is given (4 of 4 still exhausted at 49,152), so the fix belongs with the judge's prompt or reasoning budget, and the message now points there. ## How - `_output_budget_exhausted(raw_response)`: true when the Responses object is `incomplete` with reason `max_output_tokens`. - Each such attempt logs `GenRM output budget exhausted for pair (i, j) (attempt a/n, max_output_tokens=N)`. - When all attempts fail without a completed answer, the existing `JudgeError` message gains `(k of them exhausted max_output_tokens=N without emitting a verdict; raise the budget or constrain the judge's reasoning)`. Completed-but-empty answers keep the unchanged generic message. - Tests: detection helper, all attempts exhausted -> message names the budget, exhausted then verdict -> normal parse, empty completed answer -> generic message. `resources_servers/genrm_compare/tests`: 35 passed. Per review, the earlier opt-in budget escalation (`genrm_budget_exhausted_retry_multiplier`, `genrm_max_output_tokens_cap`) is dropped. ## Checklist - [x] I have read the [contributing guidelines](https://docs.nvidia.com/nemo/gym/latest/contribute/development-setup). - [x] The change is focused; unrelated "drive-by" edits are tracked as separate issues/PRs. - [x] Tests added or updated and pass locally (`resources_servers/genrm_compare/tests`, 35 passed). - [x] Pre-commit checks pass locally (`pre-commit run --files ...`: all Passed). - [x] All commits have DCO sign-off (`git commit -s`). Signed-off-by: Balaji Ganesan <bganesan@nvidia.com>
This follows merged #2814, which established group, attempt, and member identities. It addresses #3181's immediate timeout and failure-cleanup requirements.
Recovery and compatibility
A judge failure returns HTTP 200 with
mask_sample: true,failure_kind: judge_failed, a boundedfailure_reason, and the existing failure-sidecar tags. The shared judge failsafe also preservesinstance_config.mask_sample: truefor older RL consumers. This shared response fix applies to every environment usingjudge_failsafe.The existing evaluation opt-in
count_failure_classes_as_zero: [judge_failed]still includes selected failures as zeros. Only the metric-input copy is unmasked; saved failure records and training masks remain unchanged. Online and offline aggregation apply the same policy, including when every rollout fails. With the opt-in, an all-judge_failedrun reportsmean/reward: 0.0; without eligible counted failures or successful results, collection still raises. The main rollout JSONL never receives synthetic successes.A masked HTTP 200 result does not automatically make RL retry the group. It is a completed, unusable result for the current RL caller. If the caller chooses replacement:
The judge-request default is now 1,800 seconds, matching the overall evaluation budget. This allows substantially more room for long generations and queueing than the previous 300-second default; deployments must still size both limits for their workload.
Required RL coordination: before NeMo RL adopts this Gym revision, coordinate with RL #4160 / #4061. Reading the mask alone is insufficient: TransferQueue must preserve it in the training loss mask, and unavailable judge rewards must be excluded from group statistics. #4160 includes these fixes with
masked_reward_policy: excludeas the default. Top-level field ingestion remains separate migration work, so the nested compatibility field remains. Before updating RL's Gym revision, validate a real failed Gym response through RL's loss mask and group statistics. Gym can merge first if RL keeps its existing Gym revision until those fixes are available.GenRM-specific usage and recovery documentation is now in the resources-server README, rather than a new Fern site page.
Closes #3181.
Validation
Current code:
6a7f30b707aee62c46478d757005f3fcc9fabe0d, incorporating main atd4c0f86c7. The latest fix is059adb409.python -m pytest tests/unit_tests resources_servers/genrm_compare/tests responses_api_agents/osworld_agent/tests/test_dependency_policy.py -m 'not sandbox' -q --tb=short. There were 103 skips, 673 sandbox tests deselected, and 184 passing subtests. Sandbox tests were not run locally.be7e6353d, including core and all eight server shards.be7e6353d.be7e6353d. Gym's native CLI/HTTP path used Qwen3-0.6B policy and Qwen3-Nemotron-235B-A22B-GenRM-2603 on eight H100s. Eight policy generations and four completed real judge calls were captured.CUDA_LAUNCH_BLOCKING=1for diagnostics after an earlier CUDA failure. That failure did not recur; this does not establish its root cause. The run's functional assertions passed before the separate launcher teardown issue described below.Limits and follow-ups
The caller coordinates complete replacement attempts. There is no partial scoring, automatic group scheduling, durable answer reuse, or change to generation/judge overlap. Legacy identities cannot isolate overlapping runs or provide completed replay. Explicit-ID replay and failure records are process-local and bounded by retention; eviction or restart is not a recovery protocol.
Ordinary individual reverification is unsupported. The existing
judge_failed_onlyguard bypass does not create groups or advance attempts: retained failures remain failures until the caller supplies a complete replacement with the required identity.Active-group memory limits, retention optimization, caller-minted group identities, durable outcomes, richer telemetry, and improvements to retry backoff remain follow-up work. The inherited judge-failed-only mode-guard bypass needs a separate compatibility change; documenting it does not make individual GenRM reverification supported.
A Ray teardown abort was previously reproduced on both main and an earlier PR head; its native cause remains undiagnosed. In the passing job 19084624, the Gym launcher again exited with SIGABRT after the functional assertions completed. The harness stopped the remaining owned children and recorded no survivors; the separate policy server exited normally and Slurm released the allocation. This validates the GenRM failure/recovery behavior, not a clean Ray launcher shutdown.
Related PRs: what is covered and what could follow
Built on #2814 (Anish Mahishi), with deadline and cleanup requirements from #2385 (Teodor-Dumitru Ene) and #2903 (Jiacheng Xu). Contributor credit remains in commit trailers.
#2385: the timeout requirement is covered. Its partial-scoring policy is not adopted: if A/B/C arrive but D is missing, this PR fails the incomplete group. Whole-group failure is allowed by #3181. Close #2385 as superseded if that policy is accepted; otherwise retain/rework partial scoring as separate follow-up work with explicit missing-member and training/metric semantics.
#2903: together, #2814 and this PR cover request deduplication, duplicate-disconnect safety, and failure cleanup. Three policy differences remain:
After this PR merges, maintainers can close #2903 as superseded if these choices are accepted, tracking desired early judging or broader replay/retry behavior separately. Alternatively, rework the existing PR against current main to focus on the remaining scope. Closing either older PR means its core problem is addressed and remaining choices are accepted or tracked, not that every proposed behavior was merged.