Skip to content

Monolithic fused head-loss kernel (torch.compile) (#507)#549

Open
jlamypoirier wants to merge 26 commits into
mainfrom
jlp_monolithic_head_loss
Open

Monolithic fused head-loss kernel (torch.compile) (#507)#549
jlamypoirier wants to merge 26 commits into
mainfrom
jlp_monolithic_head_loss

Conversation

@jlamypoirier

@jlamypoirier jlamypoirier commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Authored by Claude Opus 4.8 (Claude Code).

Summary

Implements the torch.compile portion of #507: a monolithic head loss that runs the vocabulary softmax once and shares it across a set of combinable losses, emitting each loss's scalar / metrics and the combined logits gradient in a single fused pass — instead of the per-loss loop where each loss re-softmaxes the full vocab and re-issues its own tensor-parallel all-reduces.

It is exposed as a loss type, not a head mode: configure a monolithic loss whose nested losses are the combinable set.

head:
  losses:
    combined:
      type: monolithic          # an ordinary head loss; the head loops over it unchanged
      losses:
        ce:      {type: label}
        z:       {type: z_loss, weight: 0.1}
        distill: {type: distillation, reference_model: teacher}
    dpo: {type: dpo, reference_model: ref}   # non-combinable → plain sibling, runs its own pass

Approach

  • A loss, not a mode. MonolithicLoss / MonolithicLossConfig is a dynamic_type loss. The head is unchanged — it loops over head.losses and threads one gradient buffer, so a non-combinable loss (e.g. DPO) is just a sibling entry that runs its own forward_backward. There is no head-level toggle, no fallback dispatch, and no central spec object.
  • Per-loss cores, one source of truth. Each combinable loss (label cross-entropy, z-loss, distillation, GRPO) owns a plain post-softmax function (e.g. z_loss_combinable) plus combinable_extract (eager kwargs → tensors) and combinable_core (the math). Its standalone fused_* kernel and the monolithic loop both call the same function, so the per-loss math lives once.
  • One fused boundary via object dispatch. _monolithic_core computes the student softmax once, then loops the child losses calling child.combinable_core(...). The child set is fixed per config, so the loop unrolls and each combinable_core inlines inside one @torch.compile graph — confirmed to fuse with no graph breaks. Gradient contributions accumulate in fp32 and cast to the logits dtype once.
  • Validation stays local. MonolithicLossConfig._validate requires its entries to be combinable and to agree on logits_scale_factor (one softmax = one scale). Duplicates are allowed and simply run twice over the shared student softmax.

Loss coverage

In the monolithic loss: label cross-entropy, z-loss, from-distribution cross-entropy / forward-KL / reverse-KL (distillation, with temperature), and the GRPO objective + new_logprobs + the GRPO metric family + entropy — all from the shared softmax, which removes the second softmax pass introduced in #494.

Out of scope / follow-ups. DPO (span-based + autograd + reference model) stays a standalone sibling loss. GSPO is a standalone loss for now (its per-segment eager seam doesn't fit the uniform core -> (loss, grad) shape); folding it into MonolithicLoss is a planned follow-up. The Triton port is also a follow-up.

Correctness

The per-loss kernels remain the equivalence oracle: the standalone fused_* kernels (now sharing the same combinable cores) validate against independent references, and the monolithic loss is validated end-to-end at the head level against the same references — across masking × cross-entropy-splits × dtypes, multi-token-prediction, tied embeddings, logit scaling, and softcap. GSPO's standalone three-phase form (compiled forward → eager segment seam → compiled backward) is unchanged and keeps its tensor-parallel coverage.

Validated on CPU (torch.compile) plus the distributed gloo world_size=2 chain for the tensor-parallel vocab-sharded path: head suite 129 passed, functional suite 260 passed, distributed 262 passed.

Performance

Opt-in, so this is upside rather than a default change. The fusion mechanism is a single shared softmax + one fused gradient pass across the enabled losses, so combined-loss configs avoid the redundant softmax/all-reduce passes of the per-loss loop. (Indicative single-GPU functional numbers from the earlier kernel structure showed ~1.5–1.7× on combined-loss configs with identical peak memory; a re-benchmark of the loss-type path is a follow-up. Tensor-parallel configs additionally save the redundant all-reduce set.)

Bundled fix

Per-loss distillation now honors its temperature config — the standalone path previously dropped it and defaulted to 1.0. Existing distillation runs with temperature != 1.0 will see corrected behavior.

Files

  • loss/monolithic.pyMonolithicLoss + the compiled _monolithic_core entry loop.
  • loss/config.pyMonolithicLossConfig + a combinable flag on each loss config.
  • loss/z_loss.py, loss/entropy_loss.py, loss/policy_gradient.py — per-loss combinable_* methods + the shared post-softmax functions; standalone fused_* kernels routed through them. GSPO's segment seam / backward moved back here.
  • functional/entropy_loss.py — the shared math cores (softmax_base, predicted_logits_from_labels, cross_entropy_from_labels_core, z_loss_core, the from-distribution cores).
  • loss/loss.pyregister_combinable_extras hook; _get_reference_model_logits resolves the head logits robustly for nested losses.
  • language_model/head.py — reverted to main (no monolithic-specific code).
  • tests/layers/test_lm_head.py, tests/layers/test_lm_losses.py — head-level parity for the monolithic loss type; standalone kernel + reference coverage retained.

🤖 Generated with Claude Code

jlamypoirier and others added 26 commits June 26, 2026 15:32
Introduce a single torch.compile head-loss kernel that runs the vocab softmax once and
emits all requested losses + the combined gradient in one pass, replacing the per-loss
loop where each loss runs its own softmax. This commit lands the scaffolding and the
first loss (cross-entropy from labels); further losses are added incrementally.

- `_monolithic_core` (loss/monolithic.py) is one @torch.compile boundary that inlines the
  plain softmax cores (`_softmax_base`, `_predicted_logits_from_labels`, newly factored out
  of functional/entropy_loss.py while keeping the public `fused_*` wrappers byte-identical),
  so compile fuses the work across enabled losses. Losses toggle on by passing their inputs;
  gradients accumulate in fp32 and cast once.
- Opt-in via `LanguageModelHeadConfig.loss_implementation` ({per_loss (default), fused, triton}).
  The head keeps the per-loss path as the baseline/oracle and falls back to it for losses not
  yet in the kernel (e.g. DPO), accumulating into the same gradient. Per-loss bookkeeping stays
  in the loss objects via `get_monolithic_spec` / `register_monolithic_outputs`.
- Validation requires a single shared effective logits scale factor across softmax losses.

Tests: `test_monolithic_loss` (kernel vs the fused CE baseline) and `fused_*` head configs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a z-loss branch to the monolithic kernel, reusing the shared softmax's
sum_exp_logits and logits_max (z-loss adds logits_max back; cross-entropy
cancels it) so the flagship CE + z-loss combo runs one softmax pass. z-loss
has no required tensor input, so it toggles on a static `z_loss_enabled` flag
rather than a None-gated tensor. Grad terms accumulate in fp32 and cast once.

Functional parity tests now cover z-loss alone and the CE + z-loss combo
against the summed per-loss baselines, plus a tensor-parallel subtest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the from-distribution entropy losses (cross-entropy / forward-KL /
reverse-KL over a teacher distribution given as logits or probabilities, with
temperature) to the monolithic kernel. These share the student softmax with
cross-entropy and z-loss, and add one teacher softmax in the same boundary when
the target is logits.

Factor the post-student-softmax math of the from-distribution helpers into
plain cores (_cross_entropy_from_distribution_core, _reverse_kl_from_distribution_core)
that accept the precomputed student softmax, and have both the public fused
wrappers and the monolithic kernel call them — one source of truth, baseline
math unchanged.

Functional parity tests cover all three loss types over both target formats and
a non-unit temperature, against fused_entropy_loss_forward_backward, plus
tensor-parallel subtests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ure (#507)

Wire LanguageModelDistillationLoss into the monolithic kernel via
get_monolithic_spec (the from-distribution kernel branch already exists), so
distillation and the label-CE + distillation combo run on one shared softmax.

Also fix a pre-existing bug: LanguageModelDistillationLoss._forward_backward
never passed the configured `temperature` to the loss kernel, so the teacher
softmax always used temperature 1.0 regardless of the config. Pass it in both
the per-loss and monolithic paths. Default is unchanged (1.0); this only affects
configs that explicitly set a non-unit temperature.

Head-level parity tests add distillation and label+distillation combos on the
fused path, plus a non-unit-temperature case on both paths. The reference now
scales the teacher logits by logits_scale_factor / temperature before the
softmax (identity for the existing scale-1, temperature-1 configs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the GRPO policy-gradient objective to the monolithic kernel: per-token
IS-ratio clipping over the shared softmax, plus the new_logprobs_mean metric
as a side output. Reuses the shared predicted-logits; uses out-of-place
unsqueeze on sum_exp_logits (the per-loss kernel mutates it in place).

LanguageModelGRPOLoss gets get_monolithic_spec / register_monolithic_outputs;
it falls back to the per-loss path when metrics are enabled (kernel metrics
land in a later step). Validated against fused_grpo_loss_forward_backward
(loss, grad, new_logprobs) at the functional, head, and tensor-parallel levels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…softmax (#507)

Emit the full GRPO metric family (ratio/KL/clip/advantage stats + optional
per-token entropy) from the monolithic kernel's shared softmax instead of a
second softmax pass over the logits (the #494 redundancy). Entropy is the only
metric needing the vocab axis; the rest reuse the already-computed new_log_probs.

Extract GRPOMetrics + the pure metric aggregation into a new grpo_metrics module
so both the baseline compute_grpo_metrics and the kernel share one source of
truth (and to avoid a monolithic<->policy_gradient import cycle). The GRPO loss
no longer falls back to the per-loss path when metrics are enabled.

Validated vs the independent loop reference: functional (with/without entropy),
head (per-loss + fused metrics configs), and tensor-parallel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split fused_gspo_loss_forward_backward into a compiled forward core (one softmax
+ predicted-logits -> new_log_probs), the eager segment seam (index_add_ +
SDP/SP all-reduce + clipping, where the symbolic num_segments stays out of every
compiled boundary), and a compiled backward core (the O(vocab) scatter-add grad,
previously eager). This is the proven three-phase layout already used by the
Triton GSPO kernel; the backward is now fused and num_segments never triggers a
recompile.

GSPO runs in the monolithic (fused) loss path via the existing spec-less
fallback, threading the shared gradient buffer; a standalone GSPO computes one
softmax, as before. Math is byte-equivalent to the prior eager implementation.

Validated vs reference_gspo_loss (functional across num_segments/dtypes, head
fused + per-loss configs, and a new tensor-parallel distributed subtest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validate the monolithic kernel composes arbitrary loss kinds over one
shared softmax. Functional level adds CE+z-loss+distillation and GRPO+z-loss
combos, comparing total loss + combined gradient against the sum of the
per-loss baselines (extends `_monolithic_baseline` to the distribution and
GRPO kinds and builds the shared input union). Head level adds the fused
three-way label+distillation+z-loss combo and GRPO+metrics+z-loss, checked
against the independent reference end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GSPO now runs inside the monolithic kernel sharing the single softmax,
rather than through the spec-less fallback. The kernel splits into three
phases when a GSPO spec is present: a compiled forward (shared softmax +
every enabled non-GSPO loss + GSPO's per-token new_log_probs), the eager
segment seam (index_add_ aggregation + SDP/SP all-reduce + clipping, the
symbolic num_segments staying out of all compiled boundaries), and a
compiled backward whose GSPO grad term composes into the shared fp32
accumulator and casts once. The per-loss math is factored into a plain
_apply_combinable_losses helper shared by the single-pass and GSPO-split
forwards; the GSPO seam/backward cores move to monolithic.py (the import
leaf) so policy_gradient can reuse them without a cycle.

GSPO gets get_monolithic_spec / register_monolithic_outputs; the standalone
fused_gspo_loss_forward_backward is unchanged and remains the oracle. Adds
monolithic-GSPO functional + tensor-parallel tests and a fused GSPO+z-loss
head combo (the shared-softmax payoff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The plain math cores are inlined into the monolithic kernel (and the
standalone GSPO path) from other modules, so a leading underscore —
which marks a module-private name that should not be imported elsewhere
— was wrong. Drop it from the cross-module-shared cores: softmax_base,
predicted_logits_from_labels, cross_entropy_from_distribution_core,
reverse_kl_from_distribution_core (entropy_loss), grpo_metrics_core
(grpo_metrics), and gspo_segment_seam / gspo_backward_core (monolithic).
The compiled `fused_*` wrappers and the module-local cores (e.g.
_monolithic_core, _gspo_forward_core) are unchanged. Pure rename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hare CE/z-loss cores

- GRPO `get_monolithic_spec` now skips `new_logprobs_mean` and the GRPO metric
  family when `losses is None` (nothing logged this step), mirroring the per-loss
  path; threaded `losses` through `get_monolithic_spec`. GSPO stays ungated to
  match its standalone.
- Drop the unused `triton` `LossImplementation` value (it silently ran the fused
  path); it returns when the Triton kernel lands.
- Extract `cross_entropy_from_labels_core` / `z_loss_core` (plain, taking the
  shared softmax tensors) so the monolithic kernel and the standalone `fused_*`
  kernels share one copy of the CE/z-loss math, matching the distribution cores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the head-level `loss_implementation` toggle + `MonolithicLossSpec`
marshalling with a `MonolithicLoss` / `MonolithicLossConfig` loss type that holds
the combinable child losses as nested entries and shares one softmax across them.
The head is untouched: the monolithic loss flows through the ordinary per-loss
loop, and non-combinable losses (e.g. DPO) are plain siblings.

- Each combinable loss (label-CE, z-loss, distillation, GRPO) owns a plain
  post-softmax `combinable` function + `combinable_extract` / `combinable_core`;
  the standalone `fused_*` kernels call the same function, so the per-loss math
  has one source of truth.
- `_monolithic_core` iterates entries via object-method dispatch (no flat arg
  list, no `if enabled` chain) and fuses into a single graph (no graph breaks).
- Removed `LossImplementation` enum + field, the `get_monolithic_spec` /
  `register_monolithic_outputs` hooks, `MonolithicLossSpec` / `MonolithicLossOutput`,
  the flat-arg kernel and its dispatcher; reverted head.py to main.
- GSPO reverts to standalone-only (segment seam / backward moved back to
  policy_gradient); folding it into `MonolithicLoss` is a follow-up.
- `_get_reference_model_logits` resolves the head logits by splitting on
  `.losses.`, so distillation works whether flat or nested in the composite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Kill the four `*_combinable` module-level indirection functions
(`z_loss_combinable`, `cross_entropy_from_labels_combinable`,
`distillation_combinable`, `grpo_combinable`): inline each into its loss's
`combinable_core`, now a `@staticmethod` so both the monolithic loop and the
standalone `fused_*` kernel call the one function.

Add an intermediate `CombinableLoss` base owning the shared combinable
machinery — `register_combinable_extras` (moved off the general
`LanguageModelLoss` base) and a `_accumulate_grad` staticmethod that replaces
the cast-and-accumulate tail duplicated across the z-loss / GRPO standalone
kernels and the monolithic core. `fused_z_loss_forward_backward` and
`fused_grpo_loss_forward_backward` now route through their `combinable_core`.

Verified: staticmethod dispatch still fuses with no graph breaks
(`fullgraph=True`); head + functional + distributed TP suites pass. Net -80 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delete the `fused_z_loss_forward_backward` / `fused_grpo_loss_forward_backward`
module functions — their body was just `softmax -> combinable_core -> accumulate`
(a one-child monolithic), kept module-level only so tests could import them.

Add `CombinableLoss.combinable_forward_backward` (compiled) as that standalone
path; z-loss / GRPO `_forward_backward` route their non-triton branch through it,
and the tests now construct the loss object and exercise the method directly (the
tensor-parallel `group` is passed per call, so a trivial single-rank object
suffices — including in the distributed subtests). GRPO's non-triton path now
gets its metric family from the shared softmax via `combinable_core` instead of a
separate `compute_grpo_metrics` pass (numerically identical, one fewer softmax);
the triton path keeps the separate pass since its kernel emits no metrics.

Verified: `combinable_forward_backward` fuses with no graph breaks
(`fullgraph`); head + functional + distributed TP suites pass (651 / 21 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Label-entropy and distillation now run their non-triton standalone path through
`CombinableLoss.combinable_forward_backward` (softmax -> combinable_core ->
accumulate), the same as z-loss / GRPO. This makes the general
`fused_entropy_loss_forward_backward` kernel and its three compiled base-wrappers
(`_fused_cross_entropy_base_from_{labels,distribution}`,
`_fused_reverse_kl_base_from_distribution`) unused, so they are deleted — the
shared `_core` functions remain the single source of truth. Numerically
identical: both paths already went through the same cores.

Tests build the label / distillation loss object and exercise the method (triton
path still calls its kernel directly). The `probabilities` target format is
dropped from the entropy suite — no loss config consumes it (distillation is
logits-only), so it was general-kernel-only coverage of an unreachable path.

Verified: all entropy variants fuse with no graph breaks (`fullgraph`); head +
functional + distributed TP suites pass (531 / 21 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The float32-vs-float16 tolerance selector compared `data_type` (the imported
module `fast_llm.engine.config_utils.data_type`) to `DataType.float32`, which is
always False — so the fused/triton comparisons always used the loose 1e-4
threshold, even in float32. Compare the `dtype` parameter instead, so float32
cases use the intended 1e-5; drop the now-unused module import. All float32
entropy/z-loss cases pass at the tightened tolerance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#507)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The forward_backward/_forward_backward template pair (skip -> _forward_backward
-> register -> weight) is a property of single-scalar losses, not the loss
abstraction itself. Move it into a new SingleLoss intermediate; make the base
LanguageModelLoss.forward_backward abstract. Composite losses (MonolithicLoss)
now satisfy forward_backward directly instead of carrying a dead _forward_backward
override that only existed to satisfy the abstract method.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fine-review follow-up: remove bench_monolithic.py from tracking (a stale local
dev script that should never have been committed), and drop downstream-consumer
references from the new loss/functional docstrings and comments per project
style ("describe what the thing does, not which caller uses it"). Also fixes the
cross_entropy_from_labels_core docstring, which claimed GRPO routes through it
(GRPO calls predicted_logits_from_labels directly).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-drop the stale local dev benchmark that a prior git add re-staged; it should
remain an untracked local file, not part of the PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… TP (#507)

- Reject `loss_type: reverse_kl` for a `label` loss at config time (labels are
  one-hot, so it is undefined; the triton path already asserted this, the
  compiled path silently computed cross-entropy).
- Reject a `use_triton` flag on a monolithic child loss, which the fused path
  ignores.
- Add a monolithic composite test (cross-entropy + z-loss sharing one softmax)
  checked against the two losses run standalone, covering the tensor-parallel
  distributed path where several per-loss all-reduces meet one shared softmax.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop unnecessary forward-ref quotes and parameterize the core's list
return type; add strict=True to the child zip; fix the log-prob
docstring (/ -> -).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A zero-weight loss contributes an all-zero gradient. Return grad_output
None for it so the backward term drops out of both the standalone and
fused paths, while its scalar is still logged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a CombinableLossConfig marker base owning use_triton; the
combinable losses inherit it (GRPO via multiple inheritance alongside
the policy-gradient base). The monolithic validation now checks
isinstance and reads the narrowed use_triton directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Children were built with the raw head scale, dropping the composite's
own logits_scale_factor field; pass self._logits_scale_factor so it
stacks like the weight field does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Declare get_inputs/fused_core on CombinableLoss; drop the redundant
losses-not-None guard (metrics-not-None already implies it); remove a
no-op f-string prefix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant