Skip to content

feat(loss): --log-probs-backend fused_lm_head, log-probs without materializing the logits - #4645

Open
omri-manolo wants to merge 3 commits into
radixark:mainfrom
omri-manolo:feat/fused-lm-head
Open

omri-manolo wants to merge 3 commits into
radixark:mainfrom
omri-manolo:feat/fused-lm-head

Conversation

@omri-manolo

Copy link
Copy Markdown
Contributor

Implements RFC #4108: a new opt-in --log-probs-backend fused_lm_head. The actor's model returns the output layer's input instead of its logits, and the loss runs the output layer on the rows it scores, a tile at a time under activation checkpointing, reducing each tile with #3741's fused_log_probs_and_entropy. The micro-batch's [tokens × vocab/TP] logits and their gradient are never materialized. torch and fused are unchanged.

How it works

  • Hook: lm_head.py passes Megatron's GPTModel.forward(output_processor=...). The processor returns the decoder output as [b, s, h] (gathered over TP when sequence parallel is on) and records the output layer for the loss (batch["lm_head"]). Only the actor's forwards use it; the critic keeps its value head.
  • Op: fused_lm_head_log_probs_and_entropy (fused_log_probs.py). Per tile of scored rows, under torch.utils.checkpoint: the output layer with sequence parallel off, then fused_log_probs_and_entropy with inplace_backward. Backward replays the tile, so the output layer computes its own gradients and the hidden gradient is all-reduced over TP by the layer's backward. Tile size is --log-probs-chunk-size rows (default 4096). Extra memory is about one [tile, vocab/TP] tile, independent of the token budget.
  • Selection: get_log_probs_and_entropy selects response rows and targets exactly as fused does, so prompt rows are never multiplied by the output weight.

Scope

  • Covers --loss-type policy_loss and sft_loss, with entropy (on-policy distillation goes through the policy loss).
  • Rejected at startup with a clear message: true-on-policy mode, sampling-support replay, a missing --vocab-size, a train backend other than Megatron, other loss types, multi-LoRA. Models whose forward has no output_processor (e.g. HybridModel) are rejected at model build.
  • With --enable-mtp-training a startup warning says the MTP heads are not covered: Megatron computes the MTP loss in GPTModel._postprocess before the output_processor runs, so each MTP layer still materializes its fp32 logits. A fused MTP loss needs a Megatron hook; follow-up.
  • Score centering's K-candidate scoring through this backend is ready on top of Fused log-prob op: K targets per row; score centering trains on it #3998 (fused op with K targets per row); it will follow as a small PR once Fused log-prob op: K targets per row; score centering trains on it #3998 lands.

Testing

  • Unit/GPU tests (tests/fast-gpu/test_fused_lm_head_log_probs.py, tests/fast/backends/megatron_utils/test_lm_head.py, argument validation): equality against fused on hidden @ W.T for log-probs, entropy and the hidden/weight gradients; uneven tiles, no scored rows, frozen weight, two TP shards over NCCL; policy and SFT losses in thd and bshd. With the existing fused log-prob tests: 624 passed on 1× H100.
  • End to end on this branch: Qwen3.6-27B (TP4, 2×8 H100), the same recorded rollouts replayed into both backends (--load-debug-rollout-data --debug-train-only):
tokens/GPU backend step-0 trainer-vs-rollout |Δlogp| peak allocated (GiB) result
114,688 fused — — OOM ("Tried to allocate 13.03 GiB")
114,688 fused_lm_head 0.005929194 64.75 3 steps
49,152 fused 0.005926252 52.97 3 steps
49,152 fused_lm_head 0.005926252 (identical) 49.44 3 steps

Steps 1–2 differ within the run-to-run variation we see within one backend.

Not in this PR

…nd the model's role

The output processor is named hidden_states_output_processor, after the
GPTModel kwarg it fills. It records GPTModel's scale_logits with the
output layer, and project applies it to each tile, so MuP models are
supported instead of rejected.
@omri-manolo
omri-manolo marked this pull request as ready for review November 3, 2026 13:49
@omri-manolo
omri-manolo requested a review from fzyzcjy as a code owner November 3, 2026 13:49

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@HJSang HJSang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design question: everything LMHeadContext records (output_layer, output_weight, scale_logits) is constant for the model's lifetime, but lm_head_forward_kwargs builds a new context per micro-batch in both forward steps and the processor fills it at call time. Could the context be built once at model build (next to check_model_takes_output_processor) and bound to the forward, with the loss receiving that run-level LMHead once instead of batch["lm_head"] plus the forward_only callback kwarg? That would make hidden_states_output_processor stateless (gather + transpose only) and leave the forward steps and the batch dict as they were. Nice that this builds on the upstream output_processor hook (NVIDIA/Megatron-LM#4686) rather than a patch.

@HJSang

HJSang commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Could the testing table also carry latency? Per-step train time (and the log-probs forward time) for fused vs fused_lm_head at the same token budget, so the memory saving is read next to the extra GEMM per tile (the RFC bench shows 277 ms vs 330 ms at 49K tokens; the end-to-end number at 49,152 and 114,688 tokens/GPU would complete the picture).

With sequence parallelism the gather's backward only splits the gradient back: ``project`` has already
summed it over the group, tile by tile.
"""
if output_layer.sequence_parallel:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With a LoRA target that resolves to the output layer (unembed, the default for Inkling models in hf_lora_targets.py), Bridge wraps output_layer in LoRALinear, which has no sequence_parallel or tp_group attribute, so this raises AttributeError on the first micro-batch after build and load (project would also pass weight= and runtime_gather_output= into the adapter). Could _validate_fused_lm_head_args reject that case at startup, next to the multi-LoRA check, until the wrapped layer is supported?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great catch! adding this check to _validate_fused_lm_head_args

lm_head_kwargs, lm_head = lm_head_forward_kwargs(args, model_chunks)
if return_schedule_plan:
assert not args.enable_mtp_training, "MTP training should not be enabled when using combined 1f1b"
assert lm_head is None, "--log-probs-backend fused_lm_head is not supported with combined 1f1b"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fires per micro-batch, after the model is built and the checkpoint loaded. Megatron selects the schedule plan from overlap_moe_expert_parallel_comm, so could the rejection move to _validate_fused_lm_head_args with the other startup checks, keeping this assert as a guard?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, will add this as a startup check as well.

from miles.utils.types import RolloutBatch

# [4096, V / TP] bf16 logits: 0.5 GiB at a 128K vocabulary over TP 2
DEFAULT_LM_HEAD_TILE_ROWS = 4096

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 4096 default lives here, in fused_lm_head_log_probs_and_entropy (tile_rows: int = 4096) and in the --log-probs-backend help text. Could one of them own it, e.g. make tile_rows a required keyword and let this constant be the single source?

LoRA on the output layer and --overlap-moe-expert-parallel-comm (the
combined 1F1B schedule) failed on the first micro-batch, after the model
was built and the checkpoint loaded; both are now rejected while the
arguments are validated. The output-layer check runs after
validate_lora_args, where the LoRA targets are resolved, and covers
Inkling, whose default targets include the output layer. The model.py
assert stays as a guard.

tile_rows is a required keyword of fused_lm_head_log_probs_and_entropy,
so DEFAULT_LM_HEAD_TILE_ROWS is the only default.
@omri-manolo

Copy link
Copy Markdown
Contributor Author

Design question: everything LMHeadContext records (output_layer, output_weight, scale_logits) is constant for the model's lifetime, but lm_head_forward_kwargs builds a new context per micro-batch in both forward steps and the processor fills it at call time. Could the context be built once at model build (next to check_model_takes_output_processor) and bound to the forward, with the loss receiving that run-level LMHead once instead of batch["lm_head"] plus the forward_only callback kwarg? That would make hidden_states_output_processor stateless (gather + transpose only) and leave the forward steps and the batch dict as they were. Nice that this builds on the upstream output_processor hook (NVIDIA/Megatron-LM#4686) rather than a patch.

Agreed, and thanks for the pointer. Two points before I rework it:

  1. Building at model build. output_layer and shared_embedding_or_output_weight() are public, but MuP's logit scaling is only exposed through the hook's scale_logits argument (otherwise it's the private _scale_logits). Options: reject use_mup at startup with this backend, or keep taking the scaling from the hook, which leaves the processor slightly stateful. WDYT?

  2. Handing the run-level LMHead to the loss. The losses only see (args, batch, logits), never the model, so whatever owns the LMHead has to pass it down somehow. Do you have an idea or preference here?

This branch has not been deployed

No deployments
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