Skip to content

feat(loss): chunked masked CE, packing-mask preservation, scoped MoE AC#2996

Draft
yuhezhang-ai wants to merge 8 commits into
mainfrom
yuhez/upstream-loss-packing
Draft

feat(loss): chunked masked CE, packing-mask preservation, scoped MoE AC#2996
yuhezhang-ai wants to merge 8 commits into
mainfrom
yuhez/upstream-loss-packing

Conversation

@yuhezhang-ai

Copy link
Copy Markdown
Contributor

Summary

Three independent training-efficiency/correctness changes, each with CPU-runnable unit tests:

  1. feat(loss): chunked fp32 path on MaskedCrossEntropy (chunk_size) — computes the
    identical sum-reduced masked CE one fp32 [chunk_size, V] slice at a time instead of
    upcasting the full [N, V] logits, and (with inplace_grad=True) writes the backward
    gradient into the logits storage instead of allocating a second [N, V] buffer.
  2. fix(models): preserve indexed packing masks for flash attention — newer Transformers
    (5.x) mask preprocessing coerces the integer indexed masks produced by neat packing
    (1, 2, ... per packed document, 0 = padding) to bool before dispatch, erasing the
    document boundaries _get_unpad_data needs to build per-document cu_seqlens. Packed
    samples then silently attend across document boundaries.
  3. feat(moe): scoped activation-checkpointing modesdistributed.activation_checkpointing: non_moe | non_moe_no_attn checkpoint per submodule on the MoE parallelizer path, leaving
    the MoE MLP (router + expert dispatch) — and optionally self-attention — uncheckpointed.

Motivation

  • Chunked masked CE. On the last pipeline stage, MaskedCrossEntropy materializes a full
    fp32 copy of the logits plus cross_entropy's saved fp32 log-softmax — 2-3x S * V * 4B
    on top of the lm_head output and optimizer state. For large vocabularies (V > 1.5e5) at
    long context this dominates the last-stage memory peak (multi-GiB per microbatch) and
    scales linearly with context length. Fused linear CE avoids this but is not usable under
    pipeline parallelism, where the schedule calls loss(output, target) on already
    materialized logits. The chunked path bounds the fp32 transient at chunk_size * V * 4B
    regardless of sequence length; inplace_grad additionally removes a full [N, V]
    allocation at the backward peak. Distinct from the existing ChunkedCrossEntropy, which
    chunks the forward but still lets autograd save per-chunk fp32 activations for backward.
  • Packing-mask preservation. Restores neat-packing correctness under
    flash_attention_2/flash_attention_3 with current Transformers; without it, packing
    silently degrades to one attention span per row.
  • Scoped MoE AC. Full-block AC on MoE models recomputes the expert dispatch
    (permute / all-to-all / grouped GEMM) in backward — the most expensive recompute in the
    block. The scoped modes keep AC on attention/norms/dense MLPs while trading the MoE MLP's
    activation memory for skipping the dispatch recompute; non_moe_no_attn also keeps
    FA activations live. Complements fix(distributed): fix and scope vlm activation checkpointing #2840 (which scopes the generic FSDP2/VLM path; the MoE
    parallelizer path had no scoped modes) and builds on fix(moe): preserve MLP dispatch through checkpoint wrappers #2955 (submodule checkpoint wrappers
    are transparent to block-forward dispatch via unwrap_checkpoint_wrapper).

What's included

  • nemo_automodel/components/loss/masked_ce.py
    • _ChunkedMaskedCESum autograd function: per-chunk fp32 logsumexp + label-gather forward
      (saves only the original-dtype logits + labels), per-chunk softmax recompute backward,
      optional in-place gradient into the logits storage.
    • MaskedCrossEntropy(chunk_size=None, inplace_grad=True): opt-in; chunk_size=None
      keeps the existing behavior bit-for-bit. Requires reduction="sum" and
      fp32_upcast=True; validates shapes and re-validates a mutated chunk_size at forward.
  • nemo_automodel/components/models/common/packing.py
    • _patch_preprocess_mask_arguments_for_packing(), installed from configure_packing:
      returns non-bool 2D masks as-is for FA2/FA3, probing the installed Transformers return
      arity so the shim tracks signature changes; no-op when masking_utils is absent (older
      Transformers). Also adds qwen3 to the create_causal_mask packing-patch module list.
  • nemo_automodel/components/moe/parallelizer.py + nemo_automodel/recipes/_dist_utils.py
    • apply_ac(..., activation_checkpointing=...) with scoped modes non_moe /
      non_moe_no_attn (wraps self_attn/linear_attn/norms/dense mlp per submodule,
      skips MoE modules and, for no_attn, self_attn); weight-tied MTP blocks stay
      excluded; router-recompute warning suppressed for scoped modes (the router is never
      checkpointed there).
    • _normalize_activation_checkpointing accepts the two mode strings (case/hyphen
      tolerant), like "selective".

Proof

  • CPU parity (flagship): tests/unit_tests/loss/test_masked_ce.py asserts loss and
    gradient parity of the chunked path against both the stock full-tensor path and
    F.cross_entropy(reduction="sum") at atol=1e-7 (fp32, with ignore_index holes and a
    mask), for inplace_grad in {False, True}, plus input-validation and
    num_label_tokens=0 zero-gradient tests. A CUDA test covers bf16 logits and asserts the
    gradient aliases the logits storage under inplace_grad.
  • Packing: tests/unit_tests/models/common/test_packing.py asserts the indexed mask is
    passed through unmodified with the correct tuple arity, with and without position_ids.
  • Scoped AC: tests/unit_tests/moe/test_parallelizer.py asserts submodule wrapping,
    MoE-MLP and self-attention exclusion, no block re-registration, and no router warning;
    tests/unit_tests/recipes/test_dist_utils.py covers parser normalization variants.
  • All three changes have been validated in internal large-scale runs (up to 128 GPUs),
    including long-context large-vocabulary pipeline-parallel training (chunked CE removed
    last-stage loss OOMs) and packed-sequence SFT with FA2.

Part of #2985

🤖 Generated with Claude Code

Computing masked cross-entropy on the last pipeline stage upcasts the full
[N, V] logits to fp32 and additionally saves cross_entropy's fp32 log-softmax
for backward, which dominates the loss-side memory peak for large-vocabulary
models at long context.

Add an opt-in chunk_size to MaskedCrossEntropy that computes the identical
sum-reduced loss one fp32 [chunk_size, V] slice at a time:

- forward: per-chunk fp32 logsumexp + label-logit gather accumulated into a
  scalar; only the original-dtype logits and labels are saved for backward.
- backward: recomputes the softmax per chunk and, with inplace_grad=True
  (default for the chunked path), writes the gradient into the logits storage,
  removing a further full [N, V] allocation at the backward peak.

The default chunk_size=None keeps the existing full-tensor behavior
unchanged. CPU unit tests assert loss and gradient parity against the
full-tensor path and F.cross_entropy, input validation, and the zero
num_label_tokens guard; a CUDA test covers bf16 logits and grad/logits
storage aliasing under inplace_grad.

Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
Newer Transformers (5.x) preprocesses 2D attention masks before dispatching
attention and can coerce the integer indexed masks produced by neat packing
(1, 2, ... per packed document, 0 = padding) into bool masks. That erases the
document boundaries the patched _get_unpad_data needs to build per-document
cu_seqlens, silently collapsing packed samples into one attention span.

Patch transformers.masking_utils._preprocess_mask_arguments from
configure_packing so non-bool 2D masks are returned as-is for
flash_attention_2/flash_attention_3, probing the installed Transformers
return arity so the shim tracks minor signature changes. Also add qwen3 to
the list of model modules whose create_causal_mask is bypassed for packing.

Unit test asserts the indexed mask is passed through untouched with the
correct tuple arity, with and without position_ids.

Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
…E MLP

Full-block activation checkpointing on MoE models recomputes the expert
dispatch (permute/all-to-all/grouped GEMM) in backward, which is the most
expensive part of the block to recompute while the MoE MLP is also the memory
consumer that least needs checkpointing once its dispatch is saved.

Add two scoped modes to distributed.activation_checkpointing, consumed by the
MoE parallelizer (parallelize_model -> apply_ac):

- "non_moe": checkpoint attention, layer norms, and dense MLPs per
  submodule, leaving the MoE MLP (router + expert dispatch) uncheckpointed.
- "non_moe_no_attn": additionally leave self-attention uncheckpointed.

Submodule wrapping is transparent to block forwards since dispatch on wrapped
modules goes through unwrap_checkpoint_wrapper (#2955). Scoped modes never
checkpoint the MoE MLP, so the router-recompute warning is skipped for them.
The strings survive _normalize_activation_checkpointing like "selective"
does, and existing boolean/"selective" behavior is unchanged.

Unit tests cover parser normalization (including hyphen/case variants),
submodule wrapping and MoE/self-attention exclusion, and the router-warning
suppression.

Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Jul 9, 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.

return chunk_size


class _ChunkedMaskedCESum(torch.autograd.Function):

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.

@yuhezhang-ai yuhezhang-ai changed the title feat(loss): chunked masked CE, FA2 packing-mask preservation, scoped MoE AC modes feat(loss): chunked masked CE, packing-mask preservation, scoped MoE AC Jul 9, 2026
yuhezhang-ai and others added 5 commits July 9, 2026 09:42
Route ChunkedCrossEntropy's default reduction="sum" path through the
memory-efficient _ChunkedMaskedCESum kernel shared with
MaskedCrossEntropy(chunk_size=...), so chunking now also bounds backward
memory: only the original-dtype logits are saved for backward (the
legacy per-chunk compiled cross_entropy loop additionally saved a full
fp32 copy of the logits), the softmax is recomputed one fp32 chunk at a
time, and inplace_grad=True reuses the logits storage as the grad
buffer. On [16384, 128256] bf16 logits (3.9 GiB) peak memory beyond the
logits drops from 7.8 GiB to 1.5 GiB for forward+backward, with grads
matching the legacy path to ~2e-6. Non-"sum" reductions keep the
legacy loop unchanged.

Also expose chunk_size/inplace_grad on MaskedCrossEntropyConfig so the
typed-config and registry-name paths can enable the chunked kernel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
…ped AC

The scoped activation-checkpointing modes wrap every non-MoE block.mlp in a
CheckpointWrapper, but the _mlp helpers of glm4_moe, glm4_moe_lite,
glm_moe_dsa, ling_v2, hy_v3, and hy_mt2 dispatched on isinstance(self.mlp,
MLP/MoE) without unwrapping, so dense-MLP layers (e.g. GLM
first_k_dense_replace) crashed with AssertionError at the first forward.
Apply the unwrap_checkpoint_wrapper pattern already used by qwen3_moe and
qwen3_next (#2955), and add a CPU regression test that builds REAL dense
blocks for all six families, applies the production apply_ac scoped wrapping,
and runs a forward+backward through the wrapped _mlp (the DummyBlock-based
parallelizer tests mock both the blocks and the wrapper and cannot catch
this).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
The scoped modes (non_moe/non_moe_no_attn) are implemented only by the MoE
parallelizer, which is selected when ep_size > 1. Without EP the mode string
reached the dense FSDP2 path, where any truthy value was coerced to full
per-block checkpointing - silently checkpointing the MoE MLP the user asked
to exempt. parse_distributed_section now raises a clear ValueError when a
scoped mode is configured with ep_size <= 1, and FSDP2Manager.parallelize
rejects string modes other than 'selective' instead of coercing them to True.
Also document that non_moe_no_attn only exempts self_attn; linear attention
(linear_attn, e.g. Qwen3-Next GatedDeltaNet) is still checkpointed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
inplace_grad=True reuses the logits storage as the gradient buffer,
destroying the logits values in backward - a silent behavior change for the
pre-existing public ChunkedCrossEntropy (whose sum path never mutated the
logits) and a version-counter crash if the logits feed another autograd
branch. Make it keyword-only and default False on ChunkedCrossEntropy,
MaskedCrossEntropy, and MaskedCrossEntropyConfig so the mutation is strictly
opt-in via config. Mark _ChunkedMaskedCESum.backward @once_differentiable so
double-backward (create_graph=True) fails loudly instead of returning wrong
higher-order gradients, document the retain_graph re-backward behavior under
inplace_grad=True, and add CPU tests for the non-mutating defaults and the
double-backward error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
When transformers.masking_utils or _preprocess_mask_arguments is unavailable,
or the return-arity probe fails, the packing shim silently no-opped - and on
future Transformers drift packed samples would silently attend across
document boundaries. Log a warning including the installed transformers
version on each of those paths (behavior otherwise unchanged), document the
probe's tensor layouts in a Google-style Args docstring, and add a unit test
asserting the warning fires with the version string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
@yuhezhang-ai

Copy link
Copy Markdown
Contributor Author

/ok to test c7af888

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