[megatron] feat: migrate fused logprob/entropy from GPTModel.forward monkey-patch to Megatron output_processor hook#6933
Conversation
…cessor hook Replace the full GPTModel.forward monkey-patch (which existed only to pass `temperature` into _postprocess) with Megatron PR verl-project#4686's `output_processor` hook (core >= 0.18). Gated behind VERL_FUSED_USE_OP_HOOK (default 0 = legacy path, byte-identical to today). - Add use_output_processor_hook(), FusedOutputProcessorContext, and the fused_output_processor callback (SP gather + linear_cross_entropy at the _postprocess boundary -> CausalLMOutputForPPO). - patch_fused_forward / unpatch_fused_forward: no-op in hook mode. - fused_forward_model (legacy) and fused_forward_model_engine (live engine path): pass temperature via output_processor_context instead of temperature=. - Fail fast at build time via signature introspection when hook mode is requested but GPTModel.forward lacks `output_processor`. Downstream A/B (hook vs patch) is bit-identical (log_probs/entropy max_abs=0) across TP=1/2, tied/non-tied, SP gather, real THD training-shape batches, and the engine live path; forward memory delta is 0. Refs NVIDIA/Megatron-LM#4590.
There was a problem hiding this comment.
Code Review
This pull request introduces an alternative hook-based path (output_processor) in verl/models/mcore/model_forward_fused.py for Megatron-Core, which avoids monkey-patching GPTModel.forward by passing a callback and context directly to the native forward method. The hook path is controlled via the VERL_FUSED_USE_OP_HOOK environment variable and includes capability checks. The feedback suggests removing a redundant assertion checking if weight is None in fused_output_processor, as the subsequent call to linear_cross_entropy would naturally fail with a clear error.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| weight = output_weight if output_weight is not None else output_layer.weight | ||
| assert weight is not None, "cannot resolve output weight for fused linear_cross_entropy" |
There was a problem hiding this comment.
Avoid adding an assertion to check if weight is None since its subsequent use in linear_cross_entropy will naturally fail with a clear error if it is indeed None.
| weight = output_weight if output_weight is not None else output_layer.weight | |
| assert weight is not None, "cannot resolve output weight for fused linear_cross_entropy" | |
| weight = output_weight if output_weight is not None else output_layer.weight |
References
- Avoid adding an assertion to check if a variable is None if its subsequent use in an operation would naturally fail with a clear error (e.g., TypeError) if it were None.
|
Quick follow-up: besides the offline bit-identical A/B in the PR description, I ran a small live end-to-end smoke on Megatron-Core 0.18 to make sure the hook path survives the full RL loop, not just an isolated forward. Setup was a dense Qwen2.5-0.5B on 4×A100, megatron engine (v1) with TP=2/PP=1, Both paths completed all 5 steps and saved checkpoints cleanly, with no NaN/inf and comparable, sane metrics (entropy ~0.43–0.66, training log-ppl ~0.40–0.62 on both). The three integration points I was worried about all held up under the hook: rollout↔actor resharding (actor/rollout prob correlation ~0.9996 each step), offload interacting with the hook path, and dist-checkpoint save/load. I also added a one-off build-time check to confirm the hook is genuinely what's running: under One honest caveat: the per-step metrics differ slightly between the two runs, but that's rollout non-reproducibility (vLLM across independent processes samples different trajectories), not the hook — the two runs simply don't see the same data. The exact numerical equivalence is what the offline A/B in the PR description covers ( Heads-up on one prerequisite unrelated to this PR: bringing up the megatron backend on Megatron-Core 0.18 currently also needs |
Motivation
verl/models/mcore/model_forward_fused.pycurrently replaces the entireGPTModel.forward(_fused_GPTModel_forwardviapatch_fused_forward) for one reason only: to smuggle the training-looptemperatureinto the_postprocessboundary, wherelinear_cross_entropycomputes the fused logprob/entropy. The in-code TODO already flags this:Megatron-LM PR #4686 (core ≥ 0.18) added an
output_processor/output_processor_contextextension point at exactly that_postprocessboundary. Using it lets us delete the whole forward copy:temperaturetravels viaoutput_processor_context, and a small callback runs the fused kernel at the hook. This is the downstream half of Megatron issue #4590.Scope (single live Megatron fused path)
The migration targets verl's single live Megatron fused path. In current
mainthe fused logprob/entropy path was consolidated into the newMegatronEngine(workers/engine/megatron/transformer_impl.py): model-build time callspatch_fused_forward, and forward time callsfused_forward_model_engine. The legacyfused_forward_model(get_mcore_forward_fused_fn) is now an unused export (no caller in-tree). Both the legacy and engine callers invoke the same monkey-patchedGPTModel.forward, so migrating that single patch to theoutput_processorhook covers the live path, with a one-linetemperature→output_processor_contextchange at the engine call site. Megatron'sbuild_schedule_plan()/ 1F1B postprocess path is tracked separately in #4590 and is not part of this PR.Changes (single file:
verl/models/mcore/model_forward_fused.py)Gated behind env var
VERL_FUSED_USE_OP_HOOK(default0= legacy patch path, byte-identical to today):use_output_processor_hook(),FusedOutputProcessorContext(carriestemperature), and thefused_output_processorcallback (SP gather +linear_cross_entropyat the_postprocessboundary →CausalLMOutputForPPO).patch_fused_forward: no-op in hook mode (forward is not replaced); runs a one-time capability probe (below).unpatch_fused_forward: symmetric no-op in hook mode (nothing was backed up).fused_forward_model(legacy) andfused_forward_model_engine(live engine path): in hook mode, droptemperature=from themodel(...)call and passoutput_processor=fused_output_processor, output_processor_context=FusedOutputProcessorContext(temperature=...)instead. The two call sites take the identical change._fused_GPTModel_forwardis retained (default path).Two review-worthy correctness points (handled)
context: Megatron's_postprocessforwardscontext=output_processor_context, so the callback parameter iscontext(notoutput_processor_context); the call site uses the forward's own param nameoutput_processor_context=. Different names, each correct.output_weightsemantics are inverted vs the legacy patch: the hook passesoutput_weight=Nonewhen embeddings are not tied (real weight is inoutput_layer.weight), and the shared weight when tied — opposite of the legacy patch. The callback resolvesweight = output_weight if output_weight is not None else output_layer.weight.Compatibility
megatron-core >= 0.18.0(the release shipping PR [trainer, fsdp, megatron] feat: support one_step_off_policy on Ascend NPU #4686'soutput_processor; merged to Megatron-Coremain2026-05-12 as97f3bce88, first released incore_v0.18.0on 2026-06-23)._assert_output_processor_supported()— it inspectsinspect.signature(GPTModel.forward)and, if theoutput_processorparameter is absent, raises a clearRuntimeErrornaming PR [trainer, fsdp, megatron] feat: support one_step_off_policy on Ascend NPU #4686 /megatron-core>=0.18.0, instead of a late opaqueTypeErrorduring the forward. (Signature introspection, not a version-string compare, so dev/local builds like0.18.0+<sha>are handled correctly; the version number appears only in the error text.)VERL_FUSED_USE_OP_HOOKunset, behavior is byte-identical to today; users who have not upgraded Megatron (< 0.18) are unaffected.get_modellanded in [megatron] fix: guard ModelType.encoder_and_decoder for Megatron-Core >= 0.18 compatibility #6927 ([megatron] fix: guard ModelType.encoder_and_decoder ..., merged 2026-07-06). This PR builds on top of it and does not re-include those changes.Validation (numerically bit-identical / max_abs = 0)
Downstream A/B (hook vs legacy patch), same weights + same batch, two independent processes toggled by
VERL_FUSED_USE_OP_HOOK, on Megatron 0.18.0+97f3bce88 / torch 2.9.1+cu128 / TE 2.10 / flash-attn 2.8.3, bf16:output_weightbranches) + SP gatherget_modelbuild chainget_modelpadding_causal, GRPO advantages; incl. pg_lossfused_forward_model_engine+ build-timepatch_fused_forward; log_probs + entropypreprocess_thd_engine,label = input_ids.clone()), not the legacy entry.temperatureviaoutput_processor_context, SP-gather timing, and theoutput_weightinversion are all verified.use_fp8_padding/config.fp8 ∈ {e4m3, hybrid}) was not exercised (all validation in bf16). The callback is fp8-agnostic, but the fp8 input-padding path is not covered here.Notes
_preprocess/_postprocessevolution (the legacy patch's hard-codedpreproc_output[:5]is a latent hazard on 0.18+).