Skip to content

Commit 05e53d8

Browse files
committed
feat(pr2): grad-accum + checkpoint eval-barrier in the eager loop (memory fix)
PR-2 from FUSED-PIPELINE-ROADMAP §4/§5: bring the seq=4096 training-step peak down by fixing the LOOP, not the kernels. - one_step_train: add grad_accum_steps (kwarg or GRAD_ACCUM_STEPS env, default 1 = byte-identical to prior single-eval step). When >1, split the batch into N microbatches, run value_and_grad per micro, mx.eval the token-weighted accumulator as a free-barrier, and drop each micro's forward/backward graph before the next. Peak activation collapses from full-batch to ~one microbatch. Token-weighting makes the accumulated grad numerically equal to the full-batch grad (a token-mean loss => full grad = sum_i (n_i/N) grad_i). Optional clear_cache hook after the step. - hybrid_lm: opt-in mx.eval(hidden_states) free-barrier after each grad-checkpointed layer (CPPMEGA_MLX_CHECKPOINT_EVAL_BARRIER=1, default OFF), so the stored checkpoint input is forced+freed before recompute — converting the previously no-op checkpoint into a real O(sbh) footprint. Numeric equivalence verified on Mac (scratch/pr2_grad_accum_equivalence.py): N=4 micro vs full-batch -> loss abs-diff 0.0, worst grad rel-diff 2.8e-7, post-step param rel-diff 1.2e-7; checkpoint barrier ON vs OFF -> grads bitwise identical. All loop/compiled/gradient/checkpoint tests pass (165). Both levers env-gated, default = current behavior (RULE #1, no silent change).
1 parent f34aa4e commit 05e53d8

4 files changed

Lines changed: 385 additions & 13 deletions

File tree

cppmega_mlx/models/hybrid_lm.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
import math
11+
import os
1112
from dataclasses import asdict, dataclass, field
1213
from typing import (
1314
TYPE_CHECKING,
@@ -61,6 +62,13 @@
6162
if TYPE_CHECKING:
6263
from cppmega_mlx.runtime.path_c_fusion import PathCFusionRegion
6364

65+
# PR-2 (FUSED-PIPELINE-ROADMAP §4): opt-in free-barrier after each
66+
# grad-checkpointed layer. Default OFF so behavior is unchanged unless
67+
# CPPMEGA_MLX_CHECKPOINT_EVAL_BARRIER=1 is set.
68+
_CHECKPOINT_EVAL_BARRIER = (
69+
os.environ.get("CPPMEGA_MLX_CHECKPOINT_EVAL_BARRIER") == "1"
70+
)
71+
6472
HybridBackend = Literal["attention", "mamba3", "moe", "m2rnn", "engram", "concept"]
6573
HybridBlockModule = (
6674
CausalSelfAttention
@@ -2288,6 +2296,16 @@ def decoder_hidden_states(
22882296
else:
22892297
fn = lambda hs, _layer=layer, _mask=mask: _layer(hs, _mask)
22902298
hidden_states = nn.utils.checkpoint(layer, fn)(hidden_states)
2299+
if _CHECKPOINT_EVAL_BARRIER:
2300+
# PR-2 (FUSED-PIPELINE-ROADMAP §4): force+free each
2301+
# checkpointed layer's stored input before the next layer
2302+
# builds its graph, so the recompute working set never
2303+
# coexists with the full set of stored inputs. Without this
2304+
# barrier MLX's lazy tape keeps every layer's checkpoint
2305+
# input live simultaneously and checkpoint is a no-op for
2306+
# peak memory. Default OFF; opt in via
2307+
# CPPMEGA_MLX_CHECKPOINT_EVAL_BARRIER=1.
2308+
mx.eval(hidden_states)
22912309
else:
22922310
attention_layer_idx = 0
22932311
for layer_index, layer in enumerate(self.layers):

cppmega_mlx/training/loop.py

Lines changed: 148 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,27 @@
44

55
import os
66
import time
7-
from dataclasses import dataclass
7+
from dataclasses import dataclass, replace
88
from typing import Callable, Mapping
99

1010
import mlx.core as mx
1111
import mlx.nn as nn
1212
import mlx.optimizers as optim
13-
from mlx.utils import tree_flatten
13+
from mlx.utils import tree_flatten, tree_map
1414

15-
from cppmega_mlx.data.batch import LMTokenBatch
15+
from cppmega_mlx.data.batch import LMTokenBatch, ensure_lm_batch
1616
from cppmega_mlx.training.loss import next_token_cross_entropy
1717
from cppmega_mlx.training.optimizers import make_adamw
1818

1919

2020
LossFn = Callable[[nn.Module, LMTokenBatch | Mapping[str, mx.array] | mx.array], tuple[mx.array, mx.array]]
2121

2222
_STRICT_DTYPE_CONTRACT_ENV = "STRICT_DTYPE_CONTRACT"
23+
# PR-2 (FUSED-PIPELINE-ROADMAP §4/§5): gradient accumulation in the loop.
24+
# Default OFF (grad_accum_steps=1) so behavior is byte-identical to the prior
25+
# single-eval step unless explicitly opted in. ``GRAD_ACCUM_STEPS`` env var
26+
# provides a process-wide default for callers that do not pass the kwarg.
27+
_GRAD_ACCUM_STEPS_ENV = "GRAD_ACCUM_STEPS"
2328

2429

2530
@dataclass(frozen=True)
@@ -68,32 +73,162 @@ def assert_grad_dtype_matches_param_dtype(grads, params) -> None:
6873
)
6974

7075

76+
def _resolve_grad_accum_steps(grad_accum_steps: int | None) -> int:
77+
"""Resolve the microbatch count from kwarg then env, default 1 (OFF)."""
78+
79+
if grad_accum_steps is None:
80+
env_value = os.environ.get(_GRAD_ACCUM_STEPS_ENV)
81+
grad_accum_steps = int(env_value) if env_value not in (None, "") else 1
82+
if not isinstance(grad_accum_steps, int) or grad_accum_steps < 1:
83+
raise ValueError(
84+
f"grad_accum_steps must be a positive integer, got {grad_accum_steps!r}"
85+
)
86+
return grad_accum_steps
87+
88+
89+
def _microbatch_slices(batch_size: int, num_micro: int) -> list[tuple[int, int]]:
90+
"""Split ``batch_size`` rows into ``num_micro`` contiguous, near-equal spans.
91+
92+
Raises if the batch is too small to provide one row per microbatch — a
93+
silent no-op split would mask a misconfigured ``grad_accum_steps`` (RULE #1).
94+
"""
95+
96+
if num_micro > batch_size:
97+
raise ValueError(
98+
f"grad_accum_steps={num_micro} exceeds batch size {batch_size}; "
99+
"each microbatch needs at least one row"
100+
)
101+
base, extra = divmod(batch_size, num_micro)
102+
spans: list[tuple[int, int]] = []
103+
start = 0
104+
for i in range(num_micro):
105+
length = base + (1 if i < extra else 0)
106+
spans.append((start, start + length))
107+
start += length
108+
return spans
109+
110+
111+
def _slice_lm_batch(batch: LMTokenBatch, start: int, stop: int) -> LMTokenBatch:
112+
"""Return a row-slice ``[start:stop)`` of every per-example array field."""
113+
114+
updates: dict[str, mx.array] = {}
115+
for name, value in vars(batch).items():
116+
if isinstance(value, mx.array) and value.shape and value.shape[0] == batch.tokens.shape[0]:
117+
updates[name] = value[start:stop]
118+
return replace(batch, **updates)
119+
120+
121+
def _accumulate_grads_token_weighted(
122+
accumulator: dict | None,
123+
grads: dict,
124+
weight: mx.array,
125+
) -> dict:
126+
"""Add ``weight * grads`` into ``accumulator`` (token-weighted running sum).
127+
128+
Token weighting makes the accumulated gradient numerically equal to the
129+
full-batch gradient: the per-microbatch loss is a token-mean, so the
130+
full-batch grad is ``sum_i (n_i / N) * grad_i`` by linearity.
131+
"""
132+
133+
weighted = tree_map(
134+
lambda g: (g * weight).astype(g.dtype) if isinstance(g, mx.array) else g,
135+
grads,
136+
)
137+
if accumulator is None:
138+
return weighted
139+
return tree_map(
140+
lambda acc, g: acc + g if isinstance(acc, mx.array) else acc,
141+
accumulator,
142+
weighted,
143+
)
144+
145+
71146
def one_step_train(
72147
model: nn.Module,
73148
optimizer: optim.Optimizer,
74149
batch: LMTokenBatch | Mapping[str, mx.array] | mx.array,
75150
*,
76151
loss_fn: LossFn = next_token_cross_entropy,
152+
grad_accum_steps: int | None = None,
153+
clear_cache: bool = False,
77154
) -> TrainStepResult:
78-
"""Run one eager MLX AdamW-compatible training step."""
155+
"""Run one eager MLX AdamW-compatible training step.
156+
157+
``grad_accum_steps`` (default 1, also settable via the ``GRAD_ACCUM_STEPS``
158+
env var) splits the batch into N microbatches and accumulates token-weighted
159+
grads with an ``mx.eval(grads)`` free-barrier between microbatches, dropping
160+
each microbatch's forward/backward graph before the next is built. This
161+
collapses peak activation memory from full-batch to ~one microbatch while
162+
remaining numerically equivalent to the full-batch step (FUSED-PIPELINE
163+
ROADMAP §4/§5, PR-2). With N=1 the path is byte-identical to the prior step.
164+
"""
79165

80166
model.train()
81-
loss_and_grad = nn.value_and_grad(model, loss_fn)
167+
num_micro = _resolve_grad_accum_steps(grad_accum_steps)
82168

83169
start = time.perf_counter()
84-
(loss, ntokens), grads = loss_and_grad(model, batch)
170+
if num_micro == 1:
171+
loss_and_grad = nn.value_and_grad(model, loss_fn)
172+
(loss, ntokens), grads = loss_and_grad(model, batch)
173+
if _strict_dtype_contract_enabled():
174+
assert_grad_dtype_matches_param_dtype(grads, model.parameters())
175+
optimizer.update(model, grads)
176+
mx.eval(model.parameters(), optimizer.state, loss, ntokens)
177+
elapsed = time.perf_counter() - start
178+
tokens = int(ntokens.item())
179+
if clear_cache:
180+
mx.clear_cache()
181+
return TrainStepResult(
182+
loss=float(loss.item()),
183+
ntokens=tokens,
184+
seconds=elapsed,
185+
tokens_per_second=tokens / elapsed if elapsed > 0 else float("inf"),
186+
)
187+
188+
lm_batch = ensure_lm_batch(batch)
189+
batch_size = int(lm_batch.tokens.shape[0])
190+
spans = _microbatch_slices(batch_size, num_micro)
191+
192+
loss_and_grad = nn.value_and_grad(model, loss_fn)
193+
194+
# Pass 1: total live-token count across the whole batch, used to
195+
# token-weight each microbatch grad so the accumulated grad equals the
196+
# full-batch grad. ``target_mask`` is pure data (no forward graph), so this
197+
# costs nothing in activation memory.
198+
total_ntokens = lm_batch.target_mask.sum().astype(mx.float32)
199+
mx.eval(total_ntokens)
200+
total_tokens_int = int(total_ntokens.item())
201+
if total_tokens_int <= 0:
202+
raise ValueError("grad accumulation requires at least one live target token")
203+
204+
accumulator: dict | None = None
205+
weighted_loss = mx.array(0.0, dtype=mx.float32)
206+
for span_start, span_stop in spans:
207+
micro = _slice_lm_batch(lm_batch, span_start, span_stop)
208+
(micro_loss, micro_ntok), micro_grads = loss_and_grad(model, micro)
209+
weight = (micro_ntok.astype(mx.float32) / total_ntokens)
210+
accumulator = _accumulate_grads_token_weighted(accumulator, micro_grads, weight)
211+
weighted_loss = weighted_loss + micro_loss.astype(mx.float32) * weight
212+
# Free-barrier: force the accumulator + running loss, then drop the
213+
# microbatch's forward/backward graph before building the next one.
214+
mx.eval(accumulator, weighted_loss)
215+
del micro_grads, micro, micro_loss, micro_ntok
216+
217+
if accumulator is None:
218+
raise RuntimeError("grad accumulation produced no gradients")
85219
if _strict_dtype_contract_enabled():
86-
assert_grad_dtype_matches_param_dtype(grads, model.parameters())
87-
optimizer.update(model, grads)
88-
mx.eval(model.parameters(), optimizer.state, loss, ntokens)
220+
assert_grad_dtype_matches_param_dtype(accumulator, model.parameters())
221+
optimizer.update(model, accumulator)
222+
mx.eval(model.parameters(), optimizer.state, weighted_loss)
89223
elapsed = time.perf_counter() - start
90224

91-
tokens = int(ntokens.item())
225+
if clear_cache:
226+
mx.clear_cache()
92227
return TrainStepResult(
93-
loss=float(loss.item()),
94-
ntokens=tokens,
228+
loss=float(weighted_loss.item()),
229+
ntokens=total_tokens_int,
95230
seconds=elapsed,
96-
tokens_per_second=tokens / elapsed if elapsed > 0 else float("inf"),
231+
tokens_per_second=total_tokens_int / elapsed if elapsed > 0 else float("inf"),
97232
)
98233

99234

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""PR-2 numeric-equivalence check (FUSED-PIPELINE-ROADMAP §4/§5).
2+
3+
Verify that token-weighted grad accumulation over N microbatches produces
4+
gradients and a loss numerically equal (within fp tolerance) to the
5+
full-batch step. RULE #1: a mismatch here means the loop fix is wrong.
6+
7+
Run: python3 scratch/pr2_grad_accum_equivalence.py
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import copy
13+
import os
14+
15+
import mlx.core as mx
16+
import mlx.nn as nn
17+
from mlx.utils import tree_flatten
18+
19+
from cppmega_mlx.data.batch import synthetic_token_batch
20+
from cppmega_mlx.models.hybrid_lm import HybridTinyConfig, HybridTinyLM
21+
from cppmega_mlx.training.loss import next_token_cross_entropy
22+
from cppmega_mlx.training.loop import one_step_train
23+
24+
25+
def _build(grad_checkpoint: bool) -> HybridTinyLM:
26+
mx.random.seed(0)
27+
cfg = HybridTinyConfig(
28+
vocab_size=64,
29+
hidden_size=16,
30+
pattern="A",
31+
depth=2,
32+
num_attention_heads=4,
33+
max_seq_length=16,
34+
grad_checkpoint=grad_checkpoint,
35+
)
36+
model = HybridTinyLM(config=cfg)
37+
mx.eval(model.parameters())
38+
return model
39+
40+
41+
def _grads_only(model: HybridTinyLM, batch) -> dict:
42+
loss_and_grad = nn.value_and_grad(model, next_token_cross_entropy)
43+
(loss, ntokens), grads = loss_and_grad(model, batch)
44+
mx.eval(grads, loss, ntokens)
45+
return loss, ntokens, grads
46+
47+
48+
def _flat(grads) -> dict:
49+
return {k: v for k, v in tree_flatten(grads)}
50+
51+
52+
def _max_rel_diff(a: dict, b: dict) -> float:
53+
worst = 0.0
54+
worst_name = ""
55+
for k in a:
56+
ga, gb = a[k], b[k]
57+
denom = mx.maximum(mx.abs(ga).max(), mx.array(1e-6))
58+
rel = float((mx.abs(ga - gb).max() / denom).item())
59+
if rel > worst:
60+
worst, worst_name = rel, k
61+
print(f" worst grad rel-diff: {worst:.3e} at {worst_name}")
62+
return worst
63+
64+
65+
def main() -> None:
66+
batch_size = 4
67+
batch = synthetic_token_batch(batch_size=batch_size, seq_length=16, vocab_size=64)
68+
69+
# Reference full-batch grads (single forward/backward).
70+
ref_model = _build(grad_checkpoint=False)
71+
full_loss, full_ntok, full_grads = _grads_only(ref_model, batch)
72+
full_flat = _flat(full_grads)
73+
print(f"full-batch loss={float(full_loss.item()):.8f} ntokens={int(full_ntok.item())}")
74+
75+
# Manual token-weighted accumulation matching one_step_train's math.
76+
acc_model = _build(grad_checkpoint=False)
77+
total_ntok = batch.target_mask.sum().astype(mx.float32)
78+
mx.eval(total_ntok)
79+
num_micro = 4
80+
base = batch_size // num_micro
81+
acc = None
82+
acc_loss = mx.array(0.0, dtype=mx.float32)
83+
from cppmega_mlx.training.loop import _slice_lm_batch
84+
loss_and_grad = nn.value_and_grad(acc_model, next_token_cross_entropy)
85+
for i in range(num_micro):
86+
micro = _slice_lm_batch(batch, i * base, (i + 1) * base)
87+
(ml, mn), mg = loss_and_grad(acc_model, micro)
88+
w = mn.astype(mx.float32) / total_ntok
89+
wg = {k: v * w for k, v in tree_flatten(mg)}
90+
if acc is None:
91+
acc = wg
92+
else:
93+
acc = {k: acc[k] + wg[k] for k in acc}
94+
acc_loss = acc_loss + ml.astype(mx.float32) * w
95+
mx.eval(acc, acc_loss)
96+
print(f"accum loss={float(acc_loss.item()):.8f}")
97+
print(f"loss abs-diff: {abs(float(acc_loss.item()) - float(full_loss.item())):.3e}")
98+
worst = _max_rel_diff(full_flat, acc)
99+
100+
tol = 2e-4
101+
assert abs(float(acc_loss.item()) - float(full_loss.item())) < tol, "loss mismatch"
102+
assert worst < tol, f"grad mismatch {worst} >= {tol}"
103+
104+
# End-to-end through one_step_train (optimizer applied; compare param deltas).
105+
from cppmega_mlx.training.optimizers import make_adamw
106+
m_full = _build(grad_checkpoint=False)
107+
opt_full = make_adamw(learning_rate=1e-3)
108+
r_full = one_step_train(m_full, opt_full, batch, grad_accum_steps=1)
109+
110+
m_acc = _build(grad_checkpoint=False)
111+
opt_acc = make_adamw(learning_rate=1e-3)
112+
r_acc = one_step_train(m_acc, opt_acc, batch, grad_accum_steps=4)
113+
print(f"one_step_train loss full={r_full.loss:.8f} accum={r_acc.loss:.8f} "
114+
f"diff={abs(r_full.loss - r_acc.loss):.3e}")
115+
pf = _flat(m_full.parameters())
116+
pa = _flat(m_acc.parameters())
117+
pworst = _max_rel_diff(pf, pa)
118+
assert abs(r_full.loss - r_acc.loss) < tol, "one_step_train loss mismatch"
119+
assert pworst < tol, f"post-step param mismatch {pworst}"
120+
assert r_full.ntokens == r_acc.ntokens, "ntokens mismatch"
121+
print("PASS: grad accumulation is numerically equivalent to full-batch step")
122+
123+
124+
if __name__ == "__main__":
125+
main()

0 commit comments

Comments
 (0)