Skip to content

Commit 5d4de12

Browse files
Merge pull request #3849 from AI-Hypercomputer:feat/nnx-vocab-tiling-custom-vjp
PiperOrigin-RevId: 931260551
2 parents 7ac8f66 + f3c5140 commit 5d4de12

7 files changed

Lines changed: 530 additions & 60 deletions

File tree

src/maxtext/inference/maxengine/maxengine.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -446,12 +446,16 @@ def _load_params_nnx(self, params, rng):
446446
rest_dict = rest_state.to_pure_dict()
447447

448448
def _overlay(dst, src):
449-
if isinstance(dst, dict):
449+
if isinstance(dst, dict) and isinstance(src, dict):
450450
for k, v in dst.items():
451451
if k in src:
452452
dst[k] = _overlay(v, src[k])
453453
return dst
454-
return src if not isinstance(src, dict) else dst
454+
# On structural mismatch keep dst (PREFILL); swapping a leaf for a subtree
455+
# (or the other way) would corrupt the model. Both-leaves is the overlay case.
456+
if isinstance(dst, dict) or isinstance(src, dict):
457+
return dst
458+
return src
455459

456460
rest_dict = _overlay(rest_dict, loaded_rest_dict)
457461
nnx.replace_by_pure_dict(rest_state, rest_dict)

src/maxtext/layers/nnx_decoders.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -982,7 +982,7 @@ def apply_output_head(self, shared_embedding, y, deterministic, model_mode):
982982
if cfg.logits_via_embedding:
983983
# Use the transpose of embedding matrix for logit transform.
984984
if isinstance(shared_embedding, nnx.Module):
985-
embedding_table = shared_embedding.embedding.value
985+
embedding_table = shared_embedding.embedding[...]
986986
else:
987987
embedding_table = shared_embedding.variables["params"]["embedding"]
988988
if isinstance(embedding_table, nn.spmd.LogicallyPartitioned):

src/maxtext/models/models.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,6 @@ def __init__(
360360
else:
361361
decoder_linen = Decoder(config=cfg, mesh=mesh, quant=self.quant, model_mode=self.model_mode)
362362
self.decoder = nnx_wrappers.ToNNX(decoder_linen, rngs=rngs)
363-
self.hidden_states = None
364363

365364
batch_size, seq_len = max_utils.get_batch_seq_len_for_mode(config=cfg, model_mode=model_mode)
366365
dummy_decoder_input_tokens = jnp.ones((batch_size, seq_len), dtype=jnp.int32)
@@ -567,10 +566,6 @@ def __call__(
567566
mutable=mutable_collections,
568567
) # pytype: disable=wrong-keyword-args
569568

570-
# Materialize hidden state when vocab tiling is enabled
571-
if self.config.num_vocab_tiling > 1:
572-
self.hidden_states = hidden_state
573-
574569
# If we are initializing the model AND MTP is enabled, we must create
575570
# dummy target tensors. This allows Flax to trace the MTPBlock and create
576571
# all its necessary parameters, without requiring the main training pipeline

src/maxtext/utils/model_creation_utils.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,16 +1039,14 @@ def _build_value_target(v):
10391039
def _free_device_memory(node):
10401040
if isinstance(node, nnx.Variable) and not isinstance(node, (nnx.RngState, nnx.Cache)):
10411041
inner = node.get_value() if hasattr(node, "get_value") else node[...]
1042-
# Same QTensor caveat as `_build_value_target`: AQT serve-mode `qrhs.frozen`
1043-
# wraps a QTensor whose `__getitem__` fails on `LogicallyPartitioned`.
1044-
# We only need to free a single jax.Array leaf — for composite values
1045-
# there's nothing to free at this level, so skip.
1046-
val = inner if hasattr(inner, "shape") else None
1047-
else:
1048-
val = node
1049-
1050-
if isinstance(val, jax.Array) and not val.is_deleted():
1051-
val.delete()
1042+
# AQT serve-mode `qrhs.frozen` wraps a QTensor (composite pytree) rather
1043+
# than a single jax.Array. Walking via tree_leaves frees the qvalue/scale
1044+
# arrays too; the single-leaf case is a 1-element tree.
1045+
for leaf in jax.tree_util.tree_leaves(inner):
1046+
if isinstance(leaf, jax.Array) and not leaf.is_deleted():
1047+
leaf.delete()
1048+
elif isinstance(node, jax.Array) and not node.is_deleted():
1049+
node.delete()
10521050

10531051
return node
10541052

src/maxtext/utils/vocabulary_tiling.py

Lines changed: 156 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,29 @@
3030
from maxtext.utils import max_utils
3131

3232

33+
# Submodule names whose params are used by logits_from_hidden_states_for_vocab_tiling:
34+
# the final norm, the LM-head dense, and the embedding table when logits are tied.
35+
# vocab_tiling_nnx_loss splits these out as the only params the loss differentiates.
36+
_OUTPUT_HEAD_PATH_KEYS = ("token_embedder", "shared_embedding", "decoder_norm", "logits_dense")
37+
38+
39+
def _is_output_head_param_path(path, _value):
40+
"""Filter for nnx.split: True when the param path belongs to the output head."""
41+
42+
# JAX path entries differ by key type: DictKey uses .key, GetAttrKey uses .name
43+
# in newer Flax and .attr in older. Check all three so the filter survives
44+
# version upgrades.
45+
def _name(k):
46+
for attr in ("key", "attr", "name"):
47+
v = getattr(k, attr, None)
48+
if v is not None:
49+
return str(v)
50+
return str(k)
51+
52+
keys = [_name(k) for k in path]
53+
return any(k in keys for k in _OUTPUT_HEAD_PATH_KEYS)
54+
55+
3356
def vocab_tiling_linen_loss(
3457
hidden_states,
3558
data,
@@ -253,12 +276,12 @@ def _bwd_scan_body(grad_params_acc, chunk_data):
253276
def vocab_tiling_nnx_loss(model, hidden_states, data, config, is_train):
254277
"""Computes cross-entropy loss with vocab tiling for NNX models.
255278
256-
NNX equivalent of ``vocab_tiling_linen_loss``. Scans the vocab dimension
257-
and calls ``model.logits_from_hidden_states_for_vocab_tiling`` per chunk. The NNX model
258-
carries its own parameters, so no explicit gather is needed.
259-
260-
Uses default autograd; a custom_vjp for backward memory savings can be
261-
added later if needed.
279+
NNX equivalent of `vocab_tiling_linen_loss`. A `custom_vjp` runs the loss in
280+
vocab chunks via `jax.lax.scan` so the backward only holds one chunk's logits
281+
at a time, matching the Linen path's memory profile. `nnx.split` separates the
282+
output-head params (which the loss differentiates) from everything else; the
283+
rest of the model is passed through but not differentiated, so the scan's
284+
residuals stay small.
262285
263286
Args:
264287
model: NNX model exposing ``logits_from_hidden_states_for_vocab_tiling``.
@@ -320,42 +343,137 @@ def _reshape(inputs, out_shape, out_sharding):
320343
labels = _maybe_shard_with_name(labels, label_spec)
321344
segmentation = _maybe_shard_with_name(segmentation, label_spec)
322345

323-
batch_size, seq_len, emb_dim = hidden_states.shape
324-
vocab_tile_size = (batch_size * seq_len) // config.num_vocab_tiling
346+
# head_params is what the loss differentiates; other_params (transformer layers) and
347+
# rest (rngs) are passed through the custom_vjp but not differentiated. They go through
348+
# as primals rather than closure captures: capturing them leaks tracers across the
349+
# custom_vjp + lax.scan boundary, which fails for tied embeddings.
350+
graphdef, head_params, other_params, rest = nnx.split(model, _is_output_head_param_path, nnx.Param, ...)
325351

326-
reshaped_hidden_states = _reshape(
327-
hidden_states, (config.num_vocab_tiling, vocab_tile_size, emb_dim), reshaped_hidden_spec
328-
)
329-
reshaped_labels = _reshape(labels, (config.num_vocab_tiling, vocab_tile_size), reshaped_data_spec)
330-
reshaped_segmentation = _reshape(segmentation, (config.num_vocab_tiling, vocab_tile_size), reshaped_data_spec)
331-
332-
# Rebuild the model per chunk inside the scan: the output head pulls an rng stream, and
333-
# mutating the outer model's rng inside scan's sub-trace raises TraceContextError.
334-
# nnx.merge(..., copy=True) makes fresh Variables local to each iteration.
335-
graphdef, model_state = nnx.split(model)
336-
337-
def _scan_body(accumulators, chunk_data):
338-
loss_accumulator, z_loss_accumulator = accumulators
339-
hidden_chunk, label_chunk, segmentation_chunk = chunk_data
340-
hidden_chunk = _maybe_shard_with_name(hidden_chunk, chunked_hidden_spec)
341-
label_chunk = _maybe_shard_with_name(label_chunk, chunked_data_spec)
342-
segmentation_chunk = _maybe_shard_with_name(segmentation_chunk, chunked_data_spec)
343-
344-
chunk_model = nnx.merge(graphdef, model_state, copy=True)
345-
chunk_logits = chunk_model.logits_from_hidden_states_for_vocab_tiling(hidden_chunk, deterministic, model_mode)
346-
chunk_logits = _maybe_shard_with_name(chunk_logits, chunked_logits_spec)
347-
one_hot_label_chunk = jax.nn.one_hot(label_chunk, config.vocab_size)
348-
chunk_xent, chunk_z_loss = max_utils.cross_entropy_with_logits(
349-
chunk_logits, one_hot_label_chunk, z_loss=config.z_loss_multiplier
352+
def _logits_for_chunk(chunk_head_params, chunk_other_params, chunk_rest, hidden_chunk):
353+
local_model = nnx.merge(graphdef, chunk_head_params, chunk_other_params, chunk_rest, copy=True)
354+
chunk_logits = local_model.logits_from_hidden_states_for_vocab_tiling(hidden_chunk, deterministic, model_mode)
355+
return _maybe_shard_with_name(chunk_logits, chunked_logits_spec)
356+
357+
@jax.custom_vjp
358+
def chunked_cross_entropy_loss(chunk_head_params, chunk_other_params, chunk_rest, hidden_states, labels, segmentation):
359+
(total_loss, total_z_loss), _ = _chunked_cross_entropy_loss_fwd(
360+
chunk_head_params, chunk_other_params, chunk_rest, hidden_states, labels, segmentation
350361
)
362+
return total_loss, total_z_loss
363+
364+
def _chunked_cross_entropy_loss_fwd(
365+
chunk_head_params, chunk_other_params, chunk_rest, hidden_states, labels, segmentation
366+
):
367+
batch_size, seq_len, emb_dim = hidden_states.shape
368+
vocab_tile_size = (batch_size * seq_len) // config.num_vocab_tiling
369+
370+
reshaped_hidden_states = _reshape(
371+
hidden_states, (config.num_vocab_tiling, vocab_tile_size, emb_dim), reshaped_hidden_spec
372+
)
373+
reshaped_labels = _reshape(labels, (config.num_vocab_tiling, vocab_tile_size), reshaped_data_spec)
374+
reshaped_segmentation = _reshape(segmentation, (config.num_vocab_tiling, vocab_tile_size), reshaped_data_spec)
375+
376+
def _fwd_scan_body(accumulators, chunk_data):
377+
loss_accumulator, z_loss_accumulator = accumulators
378+
hidden_chunk, label_chunk, segmentation_chunk = chunk_data
379+
hidden_chunk = _maybe_shard_with_name(hidden_chunk, chunked_hidden_spec)
380+
label_chunk = _maybe_shard_with_name(label_chunk, chunked_data_spec)
381+
segmentation_chunk = _maybe_shard_with_name(segmentation_chunk, chunked_data_spec)
351382

352-
masked_xent = jnp.sum(chunk_xent * (segmentation_chunk != 0))
353-
masked_z_loss = jnp.sum(chunk_z_loss * (segmentation_chunk != 0))
383+
chunk_logits = _logits_for_chunk(chunk_head_params, chunk_other_params, chunk_rest, hidden_chunk)
384+
one_hot_label_chunk = jax.nn.one_hot(label_chunk, config.vocab_size)
385+
chunk_xent, chunk_z_loss = max_utils.cross_entropy_with_logits(
386+
chunk_logits, one_hot_label_chunk, z_loss=config.z_loss_multiplier
387+
)
354388

355-
return (loss_accumulator + masked_xent, z_loss_accumulator + masked_z_loss), None
389+
masked_xent = jnp.sum(chunk_xent * (segmentation_chunk != 0))
390+
masked_z_loss = jnp.sum(chunk_z_loss * (segmentation_chunk != 0))
391+
392+
return (loss_accumulator + masked_xent, z_loss_accumulator + masked_z_loss), None
356393

357-
initial_acc = (jnp.zeros((), dtype=hidden_states.dtype), jnp.zeros((), dtype=hidden_states.dtype))
358-
(total_loss, total_z_loss), _ = jax.lax.scan(
359-
_scan_body, initial_acc, (reshaped_hidden_states, reshaped_labels, reshaped_segmentation)
394+
# Always accumulate in fp32 — `cross_entropy_with_logits` returns fp32 regardless of
395+
# logits dtype, and a bf16 carry would mismatch the body output type under lax.scan.
396+
initial_acc = (jnp.zeros((), dtype=jnp.float32), jnp.zeros((), dtype=jnp.float32))
397+
(total_loss, total_z_loss), _ = jax.lax.scan(
398+
_fwd_scan_body, initial_acc, (reshaped_hidden_states, reshaped_labels, reshaped_segmentation)
399+
)
400+
residuals = (
401+
chunk_head_params,
402+
chunk_other_params,
403+
chunk_rest,
404+
reshaped_hidden_states,
405+
reshaped_labels,
406+
reshaped_segmentation,
407+
batch_size,
408+
seq_len,
409+
emb_dim,
410+
)
411+
return (total_loss, total_z_loss), residuals
412+
413+
def _chunked_cross_entropy_loss_bwd(residuals, cotangents):
414+
# z_loss is folded into the xent loss inside cross_entropy_with_logits.
415+
loss_cotangent, _ = cotangents
416+
417+
(
418+
chunk_head_params,
419+
chunk_other_params,
420+
chunk_rest,
421+
reshaped_hidden_states,
422+
reshaped_labels,
423+
reshaped_segmentation,
424+
batch_size,
425+
seq_len,
426+
emb_dim,
427+
) = residuals
428+
429+
def _single_chunk_loss_fn(input_head_params, input_hidden_chunk, input_label_chunk, input_segmentation_chunk):
430+
chunk_logits = _logits_for_chunk(input_head_params, chunk_other_params, chunk_rest, input_hidden_chunk)
431+
one_hot_label_chunk = jax.nn.one_hot(input_label_chunk, config.vocab_size)
432+
xent, _ = max_utils.cross_entropy_with_logits(chunk_logits, one_hot_label_chunk, z_loss=config.z_loss_multiplier)
433+
return jnp.sum(xent * (input_segmentation_chunk != 0))
434+
435+
def _bwd_scan_body(grad_head_acc, chunk_data):
436+
hidden_chunk, label_chunk, segmentation_chunk = chunk_data
437+
hidden_chunk = _maybe_shard_with_name(hidden_chunk, chunked_hidden_spec)
438+
label_chunk = _maybe_shard_with_name(label_chunk, chunked_data_spec)
439+
segmentation_chunk = _maybe_shard_with_name(segmentation_chunk, chunked_data_spec)
440+
441+
# pylint: disable=unnecessary-lambda-assignment
442+
loss_fn_for_vjp = lambda p, h: _single_chunk_loss_fn(p, h, label_chunk, segmentation_chunk)
443+
_, vjp_fn = jax.vjp(loss_fn_for_vjp, chunk_head_params, hidden_chunk)
444+
(grad_head_update, grad_hidden_chunk) = vjp_fn(1.0)
445+
grad_hidden_chunk = _maybe_shard_with_name(grad_hidden_chunk, chunked_hidden_spec)
446+
447+
grad_head_acc = jax.tree_util.tree_map(lambda acc, update: acc + update, grad_head_acc, grad_head_update)
448+
return grad_head_acc, grad_hidden_chunk
449+
450+
initial_grad_head = jax.tree_util.tree_map(jnp.zeros_like, chunk_head_params)
451+
452+
grad_head, grad_reshaped_hidden_states = jax.lax.scan(
453+
_bwd_scan_body, initial_grad_head, (reshaped_hidden_states, reshaped_labels, reshaped_segmentation)
454+
)
455+
grad_reshaped_hidden_states = _maybe_shard_with_name(grad_reshaped_hidden_states, reshaped_hidden_spec)
456+
grad_head = jax.tree_util.tree_map(lambda g: g * loss_cotangent, grad_head)
457+
grad_head = jax.tree_util.tree_map(lambda x, y: y.astype(x.dtype), chunk_head_params, grad_head)
458+
grad_reshaped_hidden_states = _reshape(grad_reshaped_hidden_states, (batch_size, seq_len, emb_dim), hidden_spec)
459+
460+
# Return explicit zeros for other_params and rest, not None. With None, JAX builds
461+
# the zero cotangents with the wrong layer-axis order for scanned params, and the
462+
# AOT trace fails the cotangent shape check.
463+
grad_other = jax.tree_util.tree_map(jnp.zeros_like, chunk_other_params)
464+
grad_rest = jax.tree_util.tree_map(jnp.zeros_like, chunk_rest)
465+
return (
466+
grad_head,
467+
grad_other,
468+
grad_rest,
469+
grad_reshaped_hidden_states.astype(reshaped_hidden_states.dtype),
470+
None,
471+
None,
472+
)
473+
474+
chunked_cross_entropy_loss.defvjp(_chunked_cross_entropy_loss_fwd, _chunked_cross_entropy_loss_bwd)
475+
476+
total_loss, total_z_loss = chunked_cross_entropy_loss(
477+
head_params, other_params, rest, hidden_states, labels, segmentation
360478
)
361479
return total_loss, total_z_loss

0 commit comments

Comments
 (0)