Skip to content

Commit 7042f47

Browse files
rrutmannclaude
andcommitted
feat: Add torchtitan-style chunked lm_head loss (ChunkedLMHeadCrossEntropyLoss)
Applies the lm_head projection chunk-wise along the sequence dimension inside the loss, so the full [B, L, vocab_size] logits tensor is never materialized (peak head memory O(B*L/num_chunks*V)). Each chunk's loss is backpropagated through the lm_head immediately; hidden-state gradients are accumulated in a pre-allocated buffer and propagated through the trunk with a single backward. - GPT2LLMConfig.return_hidden_states: forward stops after lm_head_norm - get_fsdp2_wrapped_model(wrap_lm_head_separately=True): lm_head as its own fully_shard unit (required; tied embeddings unsupported) - Trainer/Evaluator branch on the loss type; loss owns the backward pass - FSDP2 handling verified by kernel-trace profiling on 2 nodes (8B, seq 4096): * lm_head kept unsharded across chunks, grad reduce-scatter coalesced into the last chunk's backward * set_is_last_backward(False) during chunk backwards: the root post-backward callback otherwise finalizes all param groups per chunk backward, wiping backward-prefetch bookkeeping and exposing every trunk block's all-gather (+282 ms/step GPU idle, -15% MFU) * set_modules_to_backward_prefetch([lm_head]) self-override: prevents the default heuristic from re-gathering the last trunk block per chunk Benchmark (2760186/2761014, 2x4 GH200, mbs 2): MFU 0.45 == baseline chunked-CLM loss, peak memory 55.2 vs 59.7 GB; loss curves match within bf16 noise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 73f6e80 commit 7042f47

8 files changed

Lines changed: 394 additions & 2 deletions

File tree

‎src/modalities/config/config.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ class ChunkedCLMCrossEntropyLossConfig(BaseModel):
9292
use_compile: bool = True
9393

9494

95+
class ChunkedLMHeadCrossEntropyLossConfig(BaseModel):
96+
target_key: str
97+
prediction_key: str
98+
num_chunks: Annotated[int, Field(strict=True, ge=1)] = 8
99+
use_compile: bool = True
100+
101+
95102
# Checkpointing
96103
class SaveEveryKStepsCheckpointingStrategyConfig(BaseModel):
97104
k: PositiveInt
@@ -303,6 +310,7 @@ class FSDP2WrappedModelConfig(BaseModel):
303310
reshard_after_forward: bool = True
304311
device_mesh: PydanticDeviceMeshIFType
305312
layers_per_fsdp_unit: int = 1
313+
wrap_lm_head_separately: bool = False
306314

307315
@model_validator(mode="after")
308316
def validate_mixed_precision_settings(self):

‎src/modalities/evaluator.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from modalities.dataloader.dataloader import LLMDataLoader
1010
from modalities.logging_broker.messages import ExperimentStatus, MessageTypes, ProgressUpdate
1111
from modalities.logging_broker.publisher import MessagePublisher
12+
from modalities.loss_functions import ChunkedLMHeadCrossEntropyLoss
1213
from modalities.models.model import model_predict_batch
1314
from modalities.models.parallelism.pipeline_parallelism import Pipeline
1415
from modalities.running_env.fsdp.device_mesh import ParallelismDegrees, get_parallel_degree
@@ -81,6 +82,8 @@ def evaluate_batch(
8182
else None
8283
)
8384
else:
85+
if isinstance(loss_fun, ChunkedLMHeadCrossEntropyLoss) and loss_fun.lm_head is None:
86+
loss_fun.bind_lm_head(model[0])
8487
result_batch = model_predict_batch(model=model[0], batch=batch)
8588
loss = loss_fun(result_batch)
8689
return loss

‎src/modalities/loss_functions.py‎

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22
from typing import overload
33

44
import torch
5+
import torch.nn as nn
56
import torch.nn.functional as F
7+
from torch.distributed.fsdp import FSDPModule
68
from torch.nn import CrossEntropyLoss
79

810
from modalities.batch import InferenceResultBatch
911

12+
# PyTorch's default ignore index for cross-entropy loss
13+
IGNORE_INDEX = -100
14+
1015

1116
class Loss(ABC):
1217
def __init__(self, tag: str):
@@ -166,6 +171,190 @@ def __call__(self, forward_batch: InferenceResultBatch) -> torch.Tensor:
166171
return total_loss / flat_labels.numel()
167172

168173

174+
class ChunkedLMHeadCrossEntropyLoss(Loss):
175+
"""Chunked lm_head + cross-entropy loss with per-chunk backward.
176+
177+
In contrast to ``ChunkedCLMCrossEntropyLoss`` (which chunks the loss math over
178+
already-materialized logits), this loss receives the *hidden states* from the model
179+
(see ``GPT2LLMConfig.return_hidden_states``) and applies the lm_head projection
180+
chunk-wise along the sequence dimension. The full ``[B, L, vocab_size]`` logits
181+
tensor is never materialized; peak memory for the head is ``O(B * L/num_chunks * V)``.
182+
183+
During training (``compute_and_backward``), each chunk's loss is backpropagated
184+
immediately through the lm_head so its autograd graph is freed before the next chunk
185+
runs. The gradients w.r.t. the hidden states are collected in a pre-allocated buffer
186+
and propagated through the transformer trunk with a single ``backward`` call at the
187+
end. This loss therefore OWNS the backward pass; the trainer must not call
188+
``loss.backward()`` again.
189+
190+
Under FSDP2, the lm_head must be its own ``fully_shard`` unit (see
191+
``ModelFactory.get_fsdp2_wrapped_model(..., wrap_lm_head_separately=True)``). The
192+
lm_head weight is kept unsharded across all chunks (avoiding repeated all-gathers)
193+
and its gradient reduce-scatter is coalesced into the last chunk's backward.
194+
Weight tying is not supported in that setting, since wte and lm_head would then
195+
belong to different FSDP units.
196+
197+
Args:
198+
target_key: Key to access labels in the ``InferenceResultBatch``.
199+
prediction_key: Key to access the hidden states in the ``InferenceResultBatch``.
200+
num_chunks: Number of chunks to split the sequence dimension into. The sequence
201+
length must be divisible by this. Defaults to 8 (as in torchtitan).
202+
use_compile: Apply ``torch.compile`` to the per-chunk cross-entropy function.
203+
Defaults to ``True``.
204+
tag: Loss tag used for logging.
205+
"""
206+
207+
def __init__(
208+
self,
209+
target_key: str,
210+
prediction_key: str,
211+
num_chunks: int = 8,
212+
use_compile: bool = True,
213+
tag: str = "ChunkedLMHeadCrossEntropyLoss",
214+
):
215+
super().__init__(tag)
216+
if num_chunks < 1:
217+
raise ValueError(f"num_chunks must be >= 1, got {num_chunks}")
218+
self.target_key = target_key
219+
self.prediction_key = prediction_key
220+
self.num_chunks = num_chunks
221+
self.lm_head: nn.Module | None = None
222+
self._loss_fn = torch.compile(_ce_loss_sum) if use_compile else _ce_loss_sum
223+
224+
def bind_lm_head(self, model: nn.Module) -> None:
225+
"""Binds the (possibly FSDP-wrapped) lm_head module of the given model to this loss.
226+
227+
Must be called after the model is fully wrapped (FSDP etc.) and before the first
228+
loss computation. The trainer and evaluator call this lazily.
229+
"""
230+
self.lm_head = model.get_submodule("transformer.lm_head")
231+
if isinstance(self.lm_head, FSDPModule):
232+
# FSDP2's default backward-prefetch heuristic picks its target from a global,
233+
# cross-module post-forward-call order. Since lm_head.forward() is invoked
234+
# num_chunks times per step, interleaved with its own per-chunk backward calls
235+
# (forward(chunk0) -> backward(chunk0) -> forward(chunk1) -> ...), that ordering
236+
# no longer reflects real data dependencies: the heuristic ends up re-gathering
237+
# unrelated modules (e.g., the last transformer block, which no chunk backward
238+
# actually needs, since each hidden_chunk is a detached leaf) or lm_head's own
239+
# earlier forward call. Overriding the prefetch target to lm_head itself makes
240+
# this a no-op (unshard() returns immediately when already unsharded, which it
241+
# is throughout the chunk loop -- see compute_and_backward), eliminating the
242+
# wasted re-gathers. An empty list would NOT disable prefetching here: FSDP
243+
# treats an empty override list the same as no override (falls back to the
244+
# default heuristic), so a self-reference is required.
245+
self.lm_head.set_modules_to_backward_prefetch([self.lm_head])
246+
247+
def _chunk(self, t: torch.Tensor) -> list[torch.Tensor]:
248+
seq_len = t.shape[1]
249+
if seq_len % self.num_chunks != 0:
250+
raise ValueError(
251+
f"Sequence length {seq_len} must be divisible by num_chunks {self.num_chunks}."
252+
)
253+
return list(t.tensor_split(self.num_chunks, dim=1))
254+
255+
def _chunk_ce_sum(self, hidden_chunk: torch.Tensor, label_chunk: torch.Tensor) -> torch.Tensor:
256+
logits = self.lm_head(hidden_chunk)
257+
return self._loss_fn(logits.reshape(-1, logits.size(-1)), label_chunk.reshape(-1).long())
258+
259+
def __call__(self, forward_batch: InferenceResultBatch) -> torch.Tensor:
260+
"""Forward-only chunked loss (used for evaluation, typically under ``torch.no_grad()``)."""
261+
if self.lm_head is None:
262+
raise RuntimeError(
263+
"lm_head is not set. Call bind_lm_head(model) before computing "
264+
"ChunkedLMHeadCrossEntropyLoss."
265+
)
266+
hidden_states = forward_batch.get_predictions(self.prediction_key)
267+
labels = forward_batch.get_targets(self.target_key).to(hidden_states.device)
268+
269+
num_valid = (labels != IGNORE_INDEX).sum().clamp(min=1)
270+
total_loss = hidden_states.new_zeros(())
271+
for hidden_chunk, label_chunk in zip(self._chunk(hidden_states), self._chunk(labels)):
272+
total_loss = total_loss + self._chunk_ce_sum(hidden_chunk, label_chunk)
273+
return total_loss / num_valid
274+
275+
def compute_and_backward(
276+
self, hidden_states: torch.Tensor, labels: torch.Tensor, grad_scale: float = 1.0
277+
) -> torch.Tensor:
278+
"""Computes the loss chunk-wise and runs the full backward pass.
279+
280+
Args:
281+
hidden_states: Model output of shape [B, L, D] (after lm_head_norm), requires grad.
282+
labels: Target token ids of shape [B, L]; ``IGNORE_INDEX`` entries are masked out.
283+
grad_scale: Factor multiplied into the backpropagated loss only (e.g.,
284+
1/gradient_accumulation_steps). The returned loss value is unscaled.
285+
286+
Returns:
287+
The detached mean loss over non-ignored tokens.
288+
"""
289+
if self.lm_head is None:
290+
raise RuntimeError(
291+
"lm_head is not set. Call bind_lm_head(model) before computing "
292+
"ChunkedLMHeadCrossEntropyLoss."
293+
)
294+
if not hidden_states.requires_grad:
295+
raise RuntimeError(
296+
"compute_and_backward requires hidden_states with requires_grad=True. "
297+
"Use __call__ for evaluation."
298+
)
299+
300+
labels = labels.to(hidden_states.device)
301+
hidden_chunks = [c.detach().requires_grad_() for c in self._chunk(hidden_states)]
302+
label_chunks = self._chunk(labels)
303+
304+
num_valid = (labels != IGNORE_INDEX).sum().clamp(min=1)
305+
grad_buffer = torch.zeros_like(hidden_states)
306+
total_loss = hidden_states.new_zeros(())
307+
308+
# Keep the lm_head weight unsharded across all chunks (one all-gather instead of
309+
# num_chunks) and coalesce the gradient reduce-scatter into the last chunk's backward.
310+
is_fsdp2 = isinstance(self.lm_head, FSDPModule)
311+
if is_fsdp2:
312+
self.lm_head.set_reshard_after_forward(False)
313+
self.lm_head.set_reshard_after_backward(False)
314+
# The chunk backwards are intermediate backwards of the same training step. By
315+
# default FSDP2 treats every backward() as the last one: its root post-backward
316+
# callback then finalizes ALL param groups, clearing the global post_forward_order
317+
# and every transformer block's _post_forward_indices -- the bookkeeping that
318+
# backward prefetching relies on. The subsequent trunk backward would then run
319+
# with prefetching disabled, exposing every block's all-gather (~10 ms GPU idle
320+
# per block, +~280 ms/step measured for the 8B model at micro-batch size 2).
321+
# Marking the chunk backwards as non-last skips that finalization. The flag lives
322+
# on the state context shared by the entire FSDP tree, so setting it through the
323+
# lm_head handle also governs the root callback.
324+
self.lm_head.set_is_last_backward(False)
325+
326+
offset = 0
327+
for chunk_idx, (hidden_chunk, label_chunk) in enumerate(zip(hidden_chunks, label_chunks)):
328+
is_last = chunk_idx == self.num_chunks - 1
329+
if is_fsdp2:
330+
self.lm_head.set_requires_gradient_sync(is_last)
331+
if is_last:
332+
self.lm_head.set_reshard_after_backward(True)
333+
334+
chunk_loss = self._chunk_ce_sum(hidden_chunk, label_chunk)
335+
# Backward through the lm_head only (hidden_chunk is a detached leaf); frees
336+
# this chunk's activations before the next chunk's forward.
337+
(chunk_loss * (grad_scale / num_valid)).backward()
338+
339+
chunk_len = hidden_chunk.shape[1]
340+
grad_buffer[:, offset : offset + chunk_len] = hidden_chunk.grad
341+
hidden_chunk.grad = None
342+
offset += chunk_len
343+
total_loss = total_loss + chunk_loss.detach()
344+
345+
if is_fsdp2:
346+
self.lm_head.set_reshard_after_forward(True)
347+
self.lm_head.reshard()
348+
# The trunk backward is the true last backward of this step: let the root
349+
# callback finalize all param groups and clear the prefetch bookkeeping.
350+
self.lm_head.set_is_last_backward(True)
351+
352+
# Single backward through the transformer trunk with the accumulated gradient.
353+
hidden_states.backward(gradient=grad_buffer)
354+
355+
return total_loss / num_valid
356+
357+
169358
def nce_loss(
170359
embedding1: torch.Tensor, embedding2: torch.Tensor, device: torch.device, is_asymmetric: bool, temperature: float
171360
) -> torch.Tensor:

‎src/modalities/models/gpt2/gpt2_model.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,10 @@ class GPT2LLMConfig(BaseModel):
528528
enforce_swiglu_hidden_dim_multiple_of (int): If specified, enforces the hidden dimension
529529
in the SwiGLU layer to be a multiple of this value. Note that this is only relevant if the
530530
activation_type is SwiGLU. Defaults to 256.
531+
return_hidden_states (bool): If True, forward returns the hidden states after lm_head_norm
532+
instead of logits, i.e., the lm_head projection is skipped. Required for
533+
ChunkedLMHeadCrossEntropyLoss, which applies the lm_head chunk-wise inside the loss.
534+
Defaults to False.
531535
"""
532536

533537
sample_key: str
@@ -553,6 +557,7 @@ class GPT2LLMConfig(BaseModel):
553557
lm_head_norm_config: LayerNormWrapperConfig
554558
use_weight_tying: bool
555559
enforce_swiglu_hidden_dim_multiple_of: int = 256
560+
return_hidden_states: bool = False
556561

557562
@model_validator(mode="after")
558563
def check_divisibility(self) -> "GPT2LLMConfig":
@@ -1019,6 +1024,7 @@ def __init__(
10191024
lm_head_norm_config: LayerNormWrapperConfig,
10201025
use_weight_tying: bool,
10211026
enforce_swiglu_hidden_dim_multiple_of: int = 256,
1027+
return_hidden_states: bool = False,
10221028
):
10231029
"""
10241030
Initializes the GPT2LLM object.
@@ -1046,6 +1052,8 @@ def __init__(
10461052
enforce_swiglu_hidden_dim_multiple_of (int): Enforces
10471053
the hidden dimension in the SwiGLU layer to be a multiple of this value.
10481054
Note that this is only relevant if the activation_type is SwiGLU. Defaults to 256.
1055+
return_hidden_states (bool): If True, forward returns the hidden states after lm_head_norm
1056+
instead of logits (the lm_head projection is skipped). Defaults to False.
10491057
"""
10501058
weight_decay_groups = {
10511059
"linear": [".attn", ".mlp", ".lm_head.weight"],
@@ -1059,6 +1067,7 @@ def __init__(
10591067
self.n_embd = n_embd
10601068
self.n_layer = n_layer
10611069
self.poe_type = poe_type
1070+
self.return_hidden_states = return_hidden_states
10621071

10631072
assert vocab_size is not None
10641073
assert sequence_length is not None
@@ -1205,6 +1214,10 @@ def forward_impl(self, inputs: torch.Tensor) -> torch.Tensor:
12051214
for layer_idx in self.transformer.h:
12061215
h = self.transformer.h[layer_idx](h)
12071216
h = self.transformer.lm_head_norm(h) if hasattr(self.transformer, "lm_head_norm") else h
1217+
if self.return_hidden_states:
1218+
# The lm_head projection is applied chunk-wise inside ChunkedLMHeadCrossEntropyLoss,
1219+
# so the full [B, L, vocab_size] logits tensor is never materialized.
1220+
return h
12081221
h = self.transformer.lm_head(h) if hasattr(self.transformer, "lm_head") else h
12091222
return h
12101223

‎src/modalities/models/model_factory.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ def get_fsdp2_wrapped_model(
173173
mixed_precision_settings: FSDP2MixedPrecisionSettings,
174174
reshard_after_forward: bool,
175175
layers_per_fsdp_unit: int = 1,
176+
wrap_lm_head_separately: bool = False,
176177
) -> FSDP2:
177178
"""Get the FSDP2-wrapped model.
178179
@@ -187,6 +188,10 @@ def get_fsdp2_wrapped_model(
187188
mixed_precision_settings (FSDP2MixedPrecisionSettings): Mixed precision settings.
188189
reshard_after_forward (bool): Whether to reshard after forward.
189190
layers_per_fsdp_unit (int): Number of layers per FSDP unit. Default is 1.
191+
wrap_lm_head_separately (bool): Whether to wrap the lm_head (transformer.lm_head)
192+
as its own FSDP unit instead of leaving it in the root unit. Required for
193+
ChunkedLMHeadCrossEntropyLoss, which calls the lm_head standalone outside the
194+
model forward. Not supported with tied word embeddings. Defaults to False.
190195
191196
Returns:
192197
FSDP2: The FSDP2-wrapped model.
@@ -237,6 +242,16 @@ def get_fsdp2_wrapped_model(
237242
reshard_after_forward=reshard_block_after_forward,
238243
)
239244

245+
if wrap_lm_head_separately:
246+
# The lm_head gets its own FSDP unit so that ChunkedLMHeadCrossEntropyLoss can call
247+
# it standalone (outside the model forward) with proper unshard/reshard hooks and
248+
# per-chunk gradient sync control. With tied embeddings, wte and lm_head would share
249+
# one parameter across two FSDP units, which is not supported.
250+
if getattr(model, "has_tied_word_embeddings", False):
251+
raise ValueError("wrap_lm_head_separately is not supported with tied word embeddings.")
252+
lm_head = model.get_submodule("transformer.lm_head")
253+
fully_shard(lm_head, **fsdp_config, reshard_after_forward=reshard_after_forward)
254+
240255
# finally, we shard the entire model
241256
fully_shard(model, **fsdp_config, reshard_after_forward=reshard_after_forward)
242257
logger.info(
@@ -616,6 +631,7 @@ def get_gpt2_model(
616631
use_weight_tying: bool,
617632
use_meta_device: Optional[bool] = False,
618633
enforce_swiglu_hidden_dim_multiple_of: int = 256,
634+
return_hidden_states: bool = False,
619635
) -> GPT2LLM:
620636
config = dict(
621637
sample_key=sample_key,
@@ -638,6 +654,7 @@ def get_gpt2_model(
638654
lm_head_norm_config=lm_head_norm_config,
639655
use_weight_tying=use_weight_tying,
640656
enforce_swiglu_hidden_dim_multiple_of=enforce_swiglu_hidden_dim_multiple_of,
657+
return_hidden_states=return_hidden_states,
641658
)
642659
if use_meta_device and use_weight_tying:
643660
raise ValueError(

‎src/modalities/registry/components.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
BatchSamplerConfig,
2626
CheckpointSavingConfig,
2727
ChunkedCLMCrossEntropyLossConfig,
28+
ChunkedLMHeadCrossEntropyLossConfig,
2829
CLMCrossEntropyLossConfig,
2930
CombinedDatasetConfig,
3031
CompiledModelConfig,
@@ -84,7 +85,7 @@
8485
ProgressSubscriberFactory,
8586
ResultsSubscriberFactory,
8687
)
87-
from modalities.loss_functions import ChunkedCLMCrossEntropyLoss, CLMCrossEntropyLoss
88+
from modalities.loss_functions import ChunkedCLMCrossEntropyLoss, ChunkedLMHeadCrossEntropyLoss, CLMCrossEntropyLoss
8889
from modalities.models.coca.coca_model import CoCa, CoCaConfig
8990
from modalities.models.coca.collator import CoCaCollateFnConfig, CoCaCollatorFn
9091
from modalities.models.components.layer_norms import (
@@ -255,6 +256,9 @@ class ComponentEntity:
255256
ComponentEntity(
256257
"loss", "chunked_clm_cross_entropy_loss", ChunkedCLMCrossEntropyLoss, ChunkedCLMCrossEntropyLossConfig
257258
),
259+
ComponentEntity(
260+
"loss", "chunked_lm_head_cross_entropy_loss", ChunkedLMHeadCrossEntropyLoss, ChunkedLMHeadCrossEntropyLossConfig
261+
),
258262
# optimizers
259263
ComponentEntity(
260264
"optimizer", "adam", maybe_model_list_for_optimizer(OptimizerFactory.get_adam), AdamOptimizerConfig

0 commit comments

Comments
 (0)