Skip to content

feat: fp32 LM head in vLLM and MCore + fix max-length handling - #3936

Open
HeyyyyyyG wants to merge 9 commits into
super-v3.5-posttrainingfrom
jiaqiz/super_v35_fp32_lmhead
Open

feat: fp32 LM head in vLLM and MCore + fix max-length handling#3936
HeyyyyyyG wants to merge 9 commits into
super-v3.5-posttrainingfrom
jiaqiz/super_v35_fp32_lmhead

Conversation

@HeyyyyyyG

@HeyyyyyyG HeyyyyyyG commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

What does this PR do ?

  1. Using FP32 for LM head for both vLLM and MCore

  2. Handle the situation where model hits max length correctly. There's a handler in gym, but currently nemorl doesn't trigger it.

Usage

diff --git a/RLVR/nemotron-3.5-super/configs/super35_rlvr_pipeclean_64n.yaml b/RLVR/nemotron-3.5-super/configs/super35_rlvr_pipeclean_64n.yaml
index f318a1b..caf86b6 100644
--- a/RLVR/nemotron-3.5-super/configs/super35_rlvr_pipeclean_64n.yaml
+++ b/RLVR/nemotron-3.5-super/configs/super35_rlvr_pipeclean_64n.yaml
@@ -193,6 +193,17 @@ policy:
     bias_activation_fusion: false
     defer_fp32_logits: true
 
+    # bf16 rounding of the LM-head logits is the dominant source of
+    # generation/training logprob mismatch (train/token_mult_prob_error).
+    # "tf32" runs the head GEMM in fp32 on TF32 tensor cores: numerically
+    # identical to full fp32 here (inputs are already exact bf16 values) but at
+    # ~baseline speed, where fp32_lm_head: true costs ~2x on the logprob pass.
+    # Must be paired with NRL_VLLM_FP32_LM_HEAD=1 below — enabling this on only
+    # one engine makes the multiplicative error worse, since both otherwise
+    # round to the same grid.
+    fp32_lm_head: tf32
+    fp32_residual_connection: true
+
     # Logging
     track_moe_metrics: true
     moe_per_layer_logging: true
@@ -309,6 +320,10 @@ policy:
     vllm_cfg:
       async_engine: true
       precision: ${policy.precision}
+      # Generation-side half of policy.megatron_cfg.fp32_lm_head: computes the
+      # vLLM logits with an fp32 copy of the LM head. Both engines must be set.
+      env_vars:
+        NRL_VLLM_FP32_LM_HEAD: "1"
       kv_cache_dtype: "auto"
       tensor_parallel_size: 4
       pipeline_parallel_size: 1

bf16 rounding of the LM-head GEMM output is the dominant contributor to
train/token_mult_prob_error. Logits are ~15-30 in magnitude, where the bf16
ulp is 0.125-0.25, so rounding perturbs each logprob by ~0.02-0.03. Both
engines round to the same grid, which is why fixing only one side makes the
multiplicative error worse (1.029 -> 1.035) and both must be changed together.

Megatron (megatron_cfg.fp32_lm_head: true | "tf32"): wrap output_layer.forward
to upcast input and weight, inside the autograd graph so gradients still reach
the bf16 weight. "tf32" runs that fp32 GEMM on TF32 tensor cores, which is
numerically identical here (inputs are already exact bf16 values, and
accumulation stays fp32) but avoids the 2.1x cost of full fp32: 19.75s vs
40.1s vs a 19.5s baseline on a fixed logprob pass. Also plumbs
megatron_cfg.fp32_residual_connection.

vLLM (NRL_VLLM_FP32_LM_HEAD=1): source-patch NemotronH.compute_logits to use an
fp32 copy of lm_head. This must be a source patch, not a monkeypatch: the model
executes in EngineCore subprocesses that never see a parent-process patch.

Keeping that copy correct under RL is the subtle part. Engines start on
load_format=dummy and receive trained weights by refit, so a copy cached on
first use holds dummy weights; measured that way, every sequence exceeded the
masking threshold (mean seq error 6.2e11, loss and reward 0). The copy is
therefore invalidated in _load_weights, the leaf every transport funnels
through, including the checkpoint engine, which bypasses the weight-update
lifecycle. Refit logs show it is load-bearing per step, not just at startup:
the live head drifts ~0.02 from the cache after each update. Building the copy
is skipped under CUDA graph capture, since an allocation there lives in the
graph's private memory pool and is not valid for later eager replays, and it is
stored with object.__setattr__ so it does not enter named_parameters(), which
the refit weight mapping is built from. The MTP/Eagle3 drafter is covered too:
it is a separate module with its own head and its own weight stream.

Measured on Nemotron 3.5 Super, 2-step SWE GRPO, iter10500:
train/token_mult_prob_error 1.03522 -> 1.01543 and train/gen_kl_error
0.004042 -> 0.000797, with rewards unchanged and no measurable speed cost.
Holds under MTP speculative decoding (1.03966 -> 1.01468).

All three flags default off, so this is inert unless requested.

Signed-off-by: Jiaqi Zeng <jiaqiz@nvidia.com>

Declares both keys on MegatronConfig as NotRequired with documentation, and
records the defaults in the exemplar configs plus the matching
tests/unit/reference_configs snapshots, per the config conventions: no
call-site fallbacks, defaults live in the YAML for TypedDict schemas.

Signed-off-by: Jiaqi Zeng <jiaqiz@nvidia.com>
The Gym vllm_model proxy already handles a prompt that overflows the context
window: it converts the failure into an empty completion with
finish_reason="length" and lets the rollout end cleanly
(responses_api_models/vllm_model/app.py). That handler fires only when the
response is HTTP 400 *and* the body contains "context length".

The NeMo-RL chat endpoint satisfied neither condition on two of its three
overflow paths. create_chat_completion mapped only VLLMValidationError to 400,
but the online renderer and _clamp_max_tokens raise a plain ValueError for the
same condition, which escaped as a 500. The code was already half aware of
this: preprocess_chat catches (ValueError, VLLMValidationError), logs "Prompt
exceeds max_model_len", then re-raises into a handler that only catches one of
them. Separately, the _clamp_max_tokens message said "max_model_len", which
matches neither substring the proxy looks for, so even a 400 would not have
been classified.

The result was that an overflowing rollout burned its retry budget on 500s and
then failed, instead of being ended gracefully and masked from the gradient.
Seen on both agent harnesses: rarely with openhands (5 occurrences in a 2-step
run, which survived) and often with opencode, where it stalled a step at 7 of 8
trajectories.

Catch context-length ValueError and return 400, letting any other ValueError
still surface as a 500, and reword the clamp error to say "maximum context
length" so the proxy's substring check matches.

Signed-off-by: Jiaqi Zeng <jiaqiz@nvidia.com>
@HeyyyyyyG
HeyyyyyyG requested review from a team as code owners August 31, 2026 18:15
@copy-pr-bot

copy-pr-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown

Auto-sync is disabled for ready for review pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@HeyyyyyyG
HeyyyyyyG requested a review from yfw August 31, 2026 20:33
@HeyyyyyyG HeyyyyyyG changed the title Using FP32 for LM head for both vLLM and MCore Using FP32 for LM head for both vLLM and MCore + Handle hitting max length correctly Aug 31, 2026
@HeyyyyyyG HeyyyyyyG changed the title Using FP32 for LM head for both vLLM and MCore + Handle hitting max length correctly Using FP32 for LM head for both vLLM and MCore + Handling hitting max length correctly Aug 31, 2026
…aqiz/super_v35_fp32_lmhead

Signed-off-by: Jiaqi Zeng <jiaqiz@nvidia.com>

# Conflicts:
#	nemo_rl/models/generation/vllm/patches.py
@HeyyyyyyG HeyyyyyyG changed the title Using FP32 for LM head for both vLLM and MCore + Handling hitting max length correctly feat: Using FP32 for LM head for both vLLM and MCore + handling hitting max length correctly Aug 31, 2026
@HeyyyyyyG HeyyyyyyG changed the title feat: Using FP32 for LM head for both vLLM and MCore + handling hitting max length correctly feat: fp32 LM head in vLLM and MCore + fix max-length handling Aug 31, 2026
An overflowing prompt is recovered by Gym's vllm_model proxy: it returns an
empty completion with finish_reason="length" instead of failing the rollout.
That only happens when this endpoint answers HTTP 400 *and* the body contains
"context length" (responses_api_models/vllm_model/app.py). Both halves broke
silently before — the rollout just burned its retry budget on 500s and failed —
and neither is visible from either repo alone, so pin the contract here.

Extracts the marker, the message builder and the detection predicate to module
scope so they are reachable from a test; create_chat_completion and
_clamp_max_tokens now share them instead of repeating the literal.

Covers: all three overflow shapes vLLM produces (clamp, online renderer, and
vLLM's own serving message) are detected and satisfy Gym's classifier;
unrelated ValueErrors are not, so they keep surfacing as 500s; and the endpoint
still catches plain ValueError and answers 400 — asserted against the source,
since that path is nested in a Ray actor method and needs vLLM to import.

Both checks were mutation-tested: restoring the old "max_model_len" wording
fails the detection tests, and narrowing the except clause back to
VLLMValidationError alone fails the endpoint test.

Signed-off-by: Jiaqi Zeng <jiaqiz@nvidia.com>
The first version restated Gym's classifier inside the test, which only pinned
one side: Gym could change its substrings or status check and the test would
stay green while production broke.

Lift the expressions assigned to `is_out_of_context_length` out of
responses_api_models/vllm_model/app.py and evaluate them against the response
NeMo-RL actually sends, so the test passes only while both sides agree.

Verified by mutating each side in turn: returning 500, or rewording the message
back to "max_model_len", fails; and on the Gym side, renaming the substring to
"context_window" or switching the check to HTTP 422 fails, while renaming the
predicate variable raises with a message pointing at the moved handler.

Signed-off-by: Jiaqi Zeng <jiaqiz@nvidia.com>
marked.append(f"{label}:none-yet")
# Logged once per worker: _load_weights runs per refit batch.
if not getattr(self, "_nrl_fp32_dirty_logged", False):
self._nrl_fp32_dirty_logged = True

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.

2 action items.

TL;DR — pre-commit fails on this line, and separately the refresh log below it prints once per rank per training step.

AI-1 — pyrefly is red (CI-blocking)

ERROR Attribute `_nrl_fp32_dirty_logged` is implicitly defined by assignment in
method `_mark_fp32_lm_head_dirty`, which is not a constructor
  --> nemo_rl/models/generation/vllm/vllm_backend.py:627:18

VllmInternalWorkerExtension has no __init__, which is why its siblings are declared at class level — _mtp_drafter_from_disk: bool = False and _nrl_named_parameters — precisely so pyrefly accepts their later in-method assignment.

Action: add _nrl_fp32_dirty_logged: bool = False beside them at line 187. (No suggestion block — that line is outside this PR's diff hunks.) Verified: with that one line added, pyrefly check reports errors shown: 0. Everything else in pre-commit passes.

AI-2 — the refresh log is ungated

_sync_fp32_lm_head runs from the _weight_update_lifecycle finalizer, i.e. every refit, on every rank, per model — so the print at :700 is unbounded. Note the sibling on this line is already gated behind _nrl_fp32_dirty_logged, and :1276 is rank-0 gated; those are the two house idioms. Demoting to logger.info would not help — the call site frequency is the problem, not the sink. See the separate comment on :686.

Minor, same file: megatron_policy_worker.py:554 re-imports nemo_rl.models.megatron.setup, which is already imported at module top (:78).

Comment on lines +504 to +506
output_layer = getattr(module, "output_layer", None)
if output_layer is None:
continue # not the last pipeline stage

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.

1 action item.

TL;DR — apply_fp32_lm_head never checks that it wrapped anything, so on Nemotron VL/Omni it silently wraps nothing while the vLLM side still runs fp32 — the exact one-sided config this PR's own YAML comment says is worse than leaving the feature off.

The walk is while hasattr(module, "module"), then getattr(module, "output_layer", None). NemotronVLModel and NemotronOmniModel nest the LM under .llava_model.language_model / .language_model and define neither self.module nor __getattr__ delegation (modeling_nemotron_vl.py:54,70-74) — grep -c output_layer on that file is 0. So the loop stops immediately, output_layer is None, and line 505 takes continue # not the last pipeline stage on the last pipeline stage. The print at :529 sits after the assignment, so it never fires either: no error, no warning, no log.

The general problem is that zero matches is indistinguishable from success. The VLM shape is just the case that is reachable today — an upstream rename of output_layer or a new wrapper class would do the same.

Action: fail loud when a chunk that owns the output layer yields no wrap. post_process is the right predicate — it is what Megatron uses to mark that chunk, and it is present on GPTModel, on HybridModel:149 (so this fires for NemotronH too, not just VLMs), and on NemotronVLModel:

Suggested change
output_layer = getattr(module, "output_layer", None)
if output_layer is None:
continue # not the last pipeline stage
output_layer = getattr(module, "output_layer", None)
if output_layer is None:
if getattr(module, "post_process", False):
raise ValueError(
f"fp32_lm_head is enabled but no output_layer was found on a "
f"post_process model chunk of type {type(module).__name__}. The "
f"trainer would run bf16 while generation runs fp32, which is "
f"worse than disabling both."
)
continue # not the last pipeline stage

Separately, the unwrap itself needs the VLM chain to actually work on those models — freeze_moe_router at setup.py:1789-1800 already has it (.thinker -> .llava_model -> .language_model), with a comment naming these exact classes.

for label, model in (
("policy", self.model_runner.model),
("drafter", self._get_drafter_model()),
):

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.

1 action item.

TL;DR — the drafter branch allocates a ~512 MiB/rank fp32 head that nothing ever reads, because the source patch does not cover the drafter's model file.

The docstring says this "covers the MTP/Eagle3 drafter too", but _patch_vllm_nemotron_h_fp32_lm_head rewrites only model_executor/models/nemotron_h.py. The NemotronH drafter is NemotronHMTP in a different file, with its own full-vocab ParallelLMHead and its own unpatched compute_logits. So the fp32 copy built for "drafter" is never consulted.

Cost on the shipped spec-decode recipe (num_speculative_tokens: 5, tensor_parallel_size: 4, vocab 131072 / hidden 4096): 131072/4 x 4096 x 4 B = 512 MiB per rank, and it is allocated at the first refit — after memory profiling and after the KV cache is sized, so it is outside vLLM's budget. Correctness is unaffected (the drafter only proposes; the target verifies).

Action: drop the ("drafter", self._get_drafter_model()) entry from both _sync_fp32_lm_head and _mark_fp32_lm_head_dirty, and correct the drafter-coverage sentence in this docstring. Also note the comment at :663 ("Drafters commonly tie their head to the policy's") is wrong for this drafter — NemotronHMTP builds its own head unconditionally, despite its own assert message claiming otherwise. If drafter coverage is genuinely wanted, nemotron_h_mtp.py needs its own patch.

yfw and others added 4 commits September 1, 2026 22:59
…ides

Production runs of the Super blend28 (VL-architecture) checkpoint ran on a
random fp32 LM head: vLLM starts on dummy weights, the source patch built the
fp32 cache lazily during warmup, and the post-refit sync looked for `lm_head`
on `model_runner.model`, which is a CUDAGraphWrapper around a
NemotronH_Nano_VL_V2 that nests the text model as `language_model`. The sync
logged "has no lm_head; skipping" at info level and the cache was never
refreshed, so rollouts collapsed to garbage generations (8192-rollout batches
went from ~14 min to >90 min). Controlled probes did not reproduce it because
they load real weights (load_format=auto) and never refit.

The trainer side had the same shape: `apply_fp32_lm_head` walked `.module`
and looked for `output_layer`, which NemotronVLModel/NemotronOmniModel nest
under `.llava_model.language_model` / `.language_model` / `.thinker`. Zero
matches looked like "not the last pipeline stage", so the trainer stayed bf16
while generation ran fp32.

vLLM backend:
- `_resolve_lm_head_owner` sheds `unwrap()` wrappers, then descends into
  `language_model`, then scans `named_modules`; sync and dirty-marking both
  use it so they act on the module the patched `compute_logits` reads from.
- Sync raises if the policy has no resolvable head (drafters may still tie
  theirs) and asserts the fp32 copy equals `lm_head` after refresh.

Megatron setup:
- `_resolve_output_layer_owner` walks `.module`, `.thinker`, `.llava_model`,
  `.language_model` until `output_layer` appears (same chain as
  `freeze_moe_router`).
- A `post_process` chunk with no `output_layer` now raises instead of
  silently continuing.

Tests cover wrapper + VL resolution, build-then-refresh across an in-place
refit, fail-loud on a headless policy, drafter tolerance, and the three
nested Megatron shapes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Conflicts in vllm_worker_async.py: both sides converted context-length
overflow errors to HTTP 400. Kept this branch's version, which keys on the
"context length" marker that Gym's vllm_model proxy actually checks and is
pinned by tests/unit/models/generation/test_vllm_context_length_overflow.py.
Dropped the now-unreachable plain-ValueError handler from #3878 and replaced
its test parameter that lacked the marker with the two real vLLM messages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Declare `_nrl_fp32_dirty_logged` (and the new `_nrl_fp32_refresh_logged`)
  at class level; VllmInternalWorkerExtension has no __init__, so pyrefly
  rejects in-method first assignment.
- Gate the per-refit refresh log: print the first refresh per worker (the
  dummy->real transition whose drift is the useful signal), debug after.
- Drop the drafter from the fp32 head targets. NemotronHMTP has its own
  unpatched compute_logits in nemotron_h_mtp.py, so its fp32 copy
  (~512 MiB/rank, allocated after KV-cache sizing) was never read.
- Use the module-level `apply_fp32_lm_head` import in the Megatron worker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…2 head

`validate_fp32_lm_head_config` rejects megatron_cfg.fp32_lm_head without
NRL_VLLM_FP32_LM_HEAD=1 in generation.vllm_cfg.env_vars (and vice versa),
and rejects fp32_lm_head combined with use_fused_linear_logprobs, whose
fused linear+CE kernel bypasses output_layer. Both leave one engine in bf16
while the other runs fp32, which the config comment already documents as
worse than disabling the feature. Skipped when generation is not vLLM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@copy-pr-bot

copy-pr-bot Bot commented Sep 2, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

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.

2 participants