Skip to content

Commit 966611d

Browse files
committed
fix(grad-checkpoint): use nn.utils.checkpoint — mx.checkpoint silently dropped param grads
The decoder's grad_checkpoint used mx.checkpoint(layer), which only checkpoints w.r.t. the call's array inputs and does NOT thread the module's trainable parameters through the recompute. Under nn.value_and_grad this SILENTLY DROPPED gradients for many parameters — verified the ENTIRE final MoE layer's expert + shared_expert weights, plus conv_bias and rope_inv_freq, got |grad|==0 with checkpoint vs |grad|>0 without (loss identical, so the bug was invisible to a loss-only check). It also made the additive attention mask a differentiable checkpoint input, tripping 'scaled_dot_product_attention does not support VJP w.r.t. mask' on CUDA — the real reason grad-checkpointed Path-B training was blocked on gb10. nn.utils.checkpoint(module) checkpoints w.r.t. the module's trainable parameters AND inputs (threads params via module.trainable_parameters()), giving bitwise-correct gradients: on the real HybridTinyLM, grad_checkpoint ON now matches OFF with dloss=0 and grad max_rel=0.0 (was max_rel=1.0 / dropped grads). Adds test_grad_checkpoint_gradients_match_non_checkpoint (RULE #1 regression: ON==OFF grads) and updates the decoder-checkpoint spy test to assert the nn.utils.checkpoint contract. This is a CORRECTNESS fix independent of memory — every prior --grad-checkpoint run trained with wrong gradients.
1 parent 0031bcc commit 966611d

2 files changed

Lines changed: 108 additions & 8 deletions

File tree

cppmega_mlx/models/hybrid_lm.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2255,15 +2255,28 @@ def decoder_hidden_states(
22552255
expand_heads=True,
22562256
)
22572257
if self.config.grad_checkpoint:
2258+
# Use nn.utils.checkpoint (NOT mx.checkpoint). mx.checkpoint(module)
2259+
# only checkpoints w.r.t. the call's array inputs and does NOT thread
2260+
# the module's trainable parameters through the recompute, so under
2261+
# nn.value_and_grad it silently DROPS gradients for some params (the
2262+
# whole final MoE layer's expert weights, conv_bias, rope_inv_freq —
2263+
# verified |g|>0 without checkpoint, ==0 with mx.checkpoint). It also
2264+
# makes the additive attention mask a differentiable checkpoint input,
2265+
# tripping "scaled_dot_product_attention does not support VJP w.r.t.
2266+
# mask" on CUDA. nn.utils.checkpoint(module) checkpoints w.r.t. the
2267+
# module's trainable parameters AND inputs (threads params via
2268+
# module.trainable_parameters()), giving bitwise-correct gradients
2269+
# (dloss=0, grad max_rel=0) and not differentiating the mask.
22582270
for layer_index, layer in enumerate(self.layers):
22592271
if stop_layer_index is not None and layer_index >= stop_layer_index:
22602272
break
2273+
checkpointed = nn.utils.checkpoint(layer)
22612274
if layer.backend == "engram" and document_ids is not None:
2262-
hidden_states = mx.checkpoint(layer)(
2275+
hidden_states = checkpointed(
22632276
hidden_states, mask, doc_ids=document_ids
22642277
)
22652278
else:
2266-
hidden_states = mx.checkpoint(layer)(hidden_states, mask)
2279+
hidden_states = checkpointed(hidden_states, mask)
22672280
else:
22682281
attention_layer_idx = 0
22692282
for layer_index, layer in enumerate(self.layers):

tests/test_cut_cross_entropy.py

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -426,15 +426,20 @@ def test_next_token_cut_cross_entropy_uses_hybrid_decoder_checkpoint(
426426
)
427427
checkpointed_layers: list[object] = []
428428

429-
def checkpoint_spy(fn):
430-
checkpointed_layers.append(fn)
429+
import mlx.nn as _nn
431430

432-
def wrapped(*args, **kwargs):
433-
return fn(*args, **kwargs)
431+
real_checkpoint = _nn.utils.checkpoint
434432

435-
return wrapped
433+
def checkpoint_spy(module, fn=None):
434+
# The decoder must checkpoint via nn.utils.checkpoint (which threads the
435+
# module's trainable parameters through the recompute), NOT the bare
436+
# mx.checkpoint (which silently drops parameter gradients and trips the
437+
# SDPA-mask VJP). Record each checkpointed module and delegate to the
438+
# real implementation so gradients stay correct.
439+
checkpointed_layers.append(module)
440+
return real_checkpoint(module, fn)
436441

437-
monkeypatch.setattr(mx, "checkpoint", checkpoint_spy)
442+
monkeypatch.setattr(_nn.utils, "checkpoint", checkpoint_spy)
438443
loss, ntokens = next_token_cut_cross_entropy(
439444
model, _hybrid_tiny_batch(), chunk_rows=2
440445
)
@@ -480,6 +485,88 @@ def loss_fn(model: HybridTinyLM, batch: LMTokenBatch) -> tuple[mx.array, mx.arra
480485
assert grads
481486

482487

488+
def test_grad_checkpoint_gradients_match_non_checkpoint() -> None:
489+
"""grad_checkpoint must be numerically equivalent (RULE #1).
490+
491+
Regression for the silent gradient-dropping bug: ``mx.checkpoint(module)``
492+
does NOT thread the module's trainable parameters through the recompute, so
493+
under ``nn.value_and_grad`` it returned ZERO gradients for some params
494+
(verified: the final MoE layer's expert weights, conv_bias, rope_inv_freq).
495+
The decoder now uses ``nn.utils.checkpoint`` which threads params correctly.
496+
This test fails closed if a future change reintroduces the drop: grads with
497+
grad_checkpoint=True must equal grads with grad_checkpoint=False.
498+
"""
499+
500+
import mlx.core as mx
501+
502+
def _flat(tree: object, prefix: str = "") -> dict[str, mx.array]:
503+
out: dict[str, mx.array] = {}
504+
if isinstance(tree, dict):
505+
for key, val in tree.items():
506+
out.update(_flat(val, f"{prefix}{key}."))
507+
elif isinstance(tree, (list, tuple)):
508+
for idx, val in enumerate(tree):
509+
out.update(_flat(val, f"{prefix}{idx}."))
510+
elif isinstance(tree, mx.array):
511+
out[prefix] = tree
512+
return out
513+
514+
base_kwargs = dict(
515+
vocab_size=32,
516+
hidden_size=16,
517+
max_seq_length=8,
518+
pattern="AEM",
519+
depth=3,
520+
num_attention_heads=2,
521+
dsa_a_layer_ranks=(0,),
522+
mamba_expand=1,
523+
mamba_head_dim=8,
524+
mamba_state_dim=8,
525+
mamba_groups=1,
526+
mamba_chunk_size=4,
527+
)
528+
mx.random.seed(7)
529+
model_off = HybridTinyLM(
530+
HybridTinyConfig(grad_checkpoint=False, **base_kwargs), dtype=mx.float32
531+
)
532+
mx.eval(model_off.parameters())
533+
model_on = HybridTinyLM(
534+
HybridTinyConfig(grad_checkpoint=True, **base_kwargs), dtype=mx.float32
535+
)
536+
model_on.update(model_off.parameters())
537+
mx.eval(model_on.parameters())
538+
539+
batch = LMTokenBatch(
540+
**{k: v for k, v in _hybrid_tiny_batch().items() if k != "document_ids"}
541+
)
542+
543+
def loss_fn(model: HybridTinyLM, batch: LMTokenBatch) -> tuple[mx.array, mx.array]:
544+
return next_token_cut_cross_entropy(model, batch, chunk_rows=2)
545+
546+
(loss_off, _), grads_off = nn.value_and_grad(model_off, loss_fn)(model_off, batch)
547+
(loss_on, _), grads_on = nn.value_and_grad(model_on, loss_fn)(model_on, batch)
548+
mx.eval(loss_off, grads_off, loss_on, grads_on)
549+
550+
assert math.isclose(_scalar(loss_off), _scalar(loss_on), rel_tol=0.0, abs_tol=1e-5)
551+
552+
flat_off = _flat(grads_off)
553+
flat_on = _flat(grads_on)
554+
assert set(flat_off) == set(flat_on)
555+
worst_rel = 0.0
556+
worst_key = ""
557+
for key, g_off in flat_off.items():
558+
a = g_off.astype(mx.float32)
559+
b = flat_on[key].astype(mx.float32)
560+
amax = float(mx.max(mx.abs(a)).item())
561+
if amax < 1e-8:
562+
continue
563+
rel = float(mx.max(mx.abs(a - b)).item()) / (amax + 1e-8)
564+
if rel > worst_rel:
565+
worst_rel = rel
566+
worst_key = key
567+
# nn.utils.checkpoint recomputes exactly -> bitwise-equal grads here.
568+
assert worst_rel < 1e-4, f"grad mismatch under checkpoint at {worst_key}: {worst_rel}"
569+
483570

484571
@pytest.mark.slow
485572
def test_chunked_eager_grad_hits_acceptance_4x_reduction_at_large_vocab(

0 commit comments

Comments
 (0)