Skip to content

Commit 7f32db4

Browse files
committed
perf(mamba3): seq-chunked backward to bound the seq=4096 train-step peak
The fwd+bwd+AdamW step at seq=4096 bs=1 for the 1B local_gb10_quarter model bursts to ~97GB while the forward fits in ~5GB. Profiling (scripts/ profile_bwd_mem_20260601.py) pins the wall: the mamba3 MIMO backward allocates fp32 partials of shape (B,SEQ,H,N,P) plus a (B,SEQ+1,H,P,N) state scratch slab -> ~21GB per mamba3 layer at seq=4096, x3 mamba3 layers = ~63GB. Per-layer mx.checkpoint is a near no-op here because these buffers live INSIDE the mamba3 custom_function VJP, not in the autograd-retained activation set. Fix (env-gated CPPMEGA_MAMBA3_BWD_SEQ_CHUNK=<chunk_len>, default OFF): split the time axis into chunks, carry the forward scan state h across boundaries (cheap O(num_chunks) boundary states) and feed each chunk's dh0 as the next (earlier) chunk's incoming end-state cotangent. Bounds the bwd working set to chunk-len instead of full seq. Direct-call peak for a seq=1024 mamba3 stack drops 5.56GB -> 1.04GB (chunk=128), a 5.3x reduction. Numerically EQUIVALENT (RULE #1): the chunked carry reproduces the exact monolithic math. scripts/verify_mamba3_chunk_equiv_20260601.py: identical loss (dloss=0) and grads to max_rel=1.4e-6 on the real HybridTinyLM training loss; direct kernel parity max_rel=3.8e-7 (fp32 reduction-order noise only). Wired for both Path-B kernels: a dh_init-seeded Metal kernel variant (_BWD_KERNEL_DHINIT) and the CUDA-eager kernel (added dh_init input). Pure-MLX parity oracle also supports dh_init. Explicit backend='metal' chunk request that the kernel cannot serve falls back to the pure-MLX oracle per chunk. All 46 pre-existing mamba3 unit tests still pass (1 unrelated docstring test was already red on main).
1 parent e7bda77 commit 7f32db4

4 files changed

Lines changed: 794 additions & 83 deletions

File tree

cppmega_mlx/nn/_tilelang/_cuda_eager.py

Lines changed: 155 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,7 @@ def bwd(
509509
dt: T.Tensor((BATCH, SEQ, HEADS), "float32"),
510510
D: T.Tensor((HEADS,), "float32"),
511511
h0: T.Tensor((BATCH, HEADS, HEADDIM, STATE), "float32"),
512+
dh_init: T.Tensor((BATCH, HEADS, HEADDIM, STATE), "float32"),
512513
dx: T.Tensor((BATCH, SEQ, HEADS, HEADDIM), "float32"),
513514
dz: T.Tensor((BATCH, SEQ, HEADS, HEADDIM), "float32"),
514515
dB_lane_grad: T.Tensor((BATCH, SEQ, HEADS, STATE, HEADDIM), "float32"),
@@ -548,10 +549,12 @@ def bwd(
548549
h_state[n] = decay[0] * h_state[n] + x_val * B[b, t, h, n]
549550
h_steps_scratch[b, t + 1, h, p, n] = h_state[n]
550551

551-
# Reverse-time VJP scan.
552+
# Reverse-time VJP scan. dh is seeded with the incoming
553+
# end-state cotangent (0 for a full-sequence backward; the
554+
# next chunk's dh0 for the sequence-chunked orchestrator).
552555
for n in T.serial(STATE):
553556
h_state[n] = h_steps_scratch[b, SEQ, h, p, n]
554-
dh[n] = 0.0
557+
dh[n] = dh_init[b, h, p, n]
555558
dD_acc[0] = 0.0
556559

557560
for rt in T.serial(SEQ):
@@ -620,6 +623,100 @@ def _mamba3_bwd_cuda_kernel(
620623
return tilelang.compile(prim, target="cuda", out_idx=None)
621624

622625

626+
def _mamba3_mimo_bwd_cuda_eager_once(
627+
dy: mx.array,
628+
x: mx.array,
629+
B: mx.array,
630+
C: mx.array,
631+
z: mx.array,
632+
A: mx.array,
633+
dt: mx.array,
634+
D: mx.array,
635+
h0: mx.array,
636+
*,
637+
dh_init: mx.array | None = None,
638+
) -> tuple[
639+
mx.array, mx.array, mx.array, mx.array, mx.array, mx.array, mx.array, mx.array
640+
]:
641+
"""Single CUDA bwd kernel call for one (sub)sequence.
642+
643+
``dh_init`` seeds the reverse scan's end-state cotangent (0 for a full
644+
backward; the next chunk's ``dh0`` for the chunked orchestrator).
645+
"""
646+
647+
import torch
648+
649+
from cppmega_mlx.nn._tilelang.mamba3 import _validate_inputs
650+
651+
batch, seq, heads, headdim, state = _validate_inputs(x, B, C, z, A, dt, D, h0)
652+
653+
dyf = dy.astype(mx.float32)
654+
xf = x.astype(mx.float32)
655+
Bf = B.astype(mx.float32)
656+
Cf = C.astype(mx.float32)
657+
zf = z.astype(mx.float32)
658+
Af = A.astype(mx.float32)
659+
dtf = dt.astype(mx.float32)
660+
Df = D.astype(mx.float32)
661+
h0f = h0.astype(mx.float32)
662+
if dh_init is None:
663+
dh_init_f = mx.zeros_like(h0f)
664+
else:
665+
if dh_init.shape != h0.shape:
666+
raise ValueError(
667+
f"dh_init must match h0 shape {h0.shape}, got {dh_init.shape}"
668+
)
669+
dh_init_f = dh_init.astype(mx.float32)
670+
671+
kernel = _mamba3_bwd_cuda_kernel(batch, seq, heads, headdim, state)
672+
dy_t = _mlx_to_torch_cuda(dyf)
673+
x_t = _mlx_to_torch_cuda(xf)
674+
B_t = _mlx_to_torch_cuda(Bf)
675+
C_t = _mlx_to_torch_cuda(Cf)
676+
z_t = _mlx_to_torch_cuda(zf)
677+
A_t = _mlx_to_torch_cuda(Af)
678+
dt_t = _mlx_to_torch_cuda(dtf)
679+
D_t = _mlx_to_torch_cuda(Df)
680+
h0_t = _mlx_to_torch_cuda(h0f)
681+
dh_init_t = _mlx_to_torch_cuda(dh_init_f)
682+
683+
dx_t = torch.zeros(batch, seq, heads, headdim, dtype=torch.float32, device="cuda")
684+
dz_t = torch.zeros(batch, seq, heads, headdim, dtype=torch.float32, device="cuda")
685+
dB_lg_t = torch.zeros(
686+
batch, seq, heads, state, headdim, dtype=torch.float32, device="cuda"
687+
)
688+
dC_lg_t = torch.zeros(
689+
batch, seq, heads, state, headdim, dtype=torch.float32, device="cuda"
690+
)
691+
dA_lg_t = torch.zeros(
692+
batch, seq, heads, headdim, dtype=torch.float32, device="cuda"
693+
)
694+
ddt_lg_t = torch.zeros(
695+
batch, seq, heads, headdim, dtype=torch.float32, device="cuda"
696+
)
697+
dD_lg_t = torch.zeros(batch, heads, headdim, dtype=torch.float32, device="cuda")
698+
dh0_t = torch.zeros(
699+
batch, heads, headdim, state, dtype=torch.float32, device="cuda"
700+
)
701+
kernel(
702+
dy_t, x_t, B_t, C_t, z_t, A_t, dt_t, D_t, h0_t, dh_init_t,
703+
dx_t, dz_t, dB_lg_t, dC_lg_t, dA_lg_t, ddt_lg_t, dD_lg_t, dh0_t,
704+
)
705+
torch.cuda.synchronize()
706+
707+
# Reduce the per-(b,h,p) lane partials over the P axis (matches the Metal
708+
# bf16-snapshot reduction axes exactly).
709+
dx = _torch_cuda_to_mlx(dx_t, x.dtype)
710+
dz = _torch_cuda_to_mlx(dz_t, z.dtype)
711+
dh0_g = _torch_cuda_to_mlx(dh0_t, h0.dtype)
712+
dB = _torch_cuda_to_mlx(dB_lg_t.sum(dim=4), B.dtype)
713+
dC = _torch_cuda_to_mlx(dC_lg_t.sum(dim=4), C.dtype)
714+
dA = _torch_cuda_to_mlx(dA_lg_t.sum(dim=3), A.dtype)
715+
ddt = _torch_cuda_to_mlx(ddt_lg_t.sum(dim=3), dt.dtype)
716+
dD = _torch_cuda_to_mlx(dD_lg_t.sum(dim=(0, 2)), D.dtype)
717+
return dx, dB, dC, dz, dA, ddt, dD, dh0_g
718+
719+
623720
def mamba3_mimo_bwd_cuda_eager(
624721
dy: mx.array,
625722
x: mx.array,
@@ -641,16 +738,26 @@ def mamba3_mimo_bwd_cuda_eager(
641738
pure-MLX reference VJP. fp32 carriers only (matches the production fp32
642739
EAGER path). The kernel emits per-(b,h,p) lane partials for the gradients
643740
that are reduced over the HEADDIM/P axis; the reductions happen here in MLX.
741+
742+
When ``CPPMEGA_MAMBA3_BWD_SEQ_CHUNK`` selects a chunk length < seq, this
743+
routes through the sequence-chunked orchestrator: the giant fp32 bwd
744+
partials ``(B,SEQ,H,N,P)`` and the ``(B,SEQ+1,H,P,N)`` state slab are then
745+
bounded by chunk-length instead of the full sequence. The math is identical
746+
(the chunk's scan state and end-state cotangent are carried exactly).
644747
"""
645748

646749
ok, _reason = cuda_eager_available()
647750
if not ok:
648751
return None
649752

650-
from cppmega_mlx.nn._tilelang.mamba3 import _validate_inputs
753+
from cppmega_mlx.nn._tilelang.mamba3 import (
754+
_validate_inputs,
755+
mamba3_bwd_seq_chunk,
756+
mamba3_mimo_bwd_seq_chunked,
757+
)
651758

652759
try:
653-
import torch
760+
import torch # noqa: F401
654761
except Exception:
655762
return None
656763

@@ -667,72 +774,52 @@ def mamba3_mimo_bwd_cuda_eager(
667774
mx.zeros_like(h0),
668775
)
669776

670-
dyf = dy.astype(mx.float32)
671-
xf = x.astype(mx.float32)
672-
Bf = B.astype(mx.float32)
673-
Cf = C.astype(mx.float32)
674-
zf = z.astype(mx.float32)
675-
Af = A.astype(mx.float32)
676-
dtf = dt.astype(mx.float32)
677-
Df = D.astype(mx.float32)
678-
h0f = h0.astype(mx.float32)
777+
chunk = mamba3_bwd_seq_chunk()
778+
if chunk > 0 and seq > chunk:
779+
def _cuda_fwd_state(x_c, B_c, C_c, z_c, A_c, dt_c, D_c, h0_c):
780+
out = mamba3_mimo_fwd_cuda_eager(
781+
x_c, B_c, C_c, z_c, A_c, dt_c, D_c, h0_c
782+
)
783+
if out is None:
784+
raise RuntimeError(
785+
"mamba3 CUDA chunked bwd: forward state pass returned None"
786+
)
787+
return out[1]
788+
789+
def _cuda_bwd_chunk(
790+
dy_c, x_c, B_c, C_c, z_c, A_c, dt_c, D_c, h0_c, *, dh_init, trap=None
791+
):
792+
grads = _mamba3_mimo_bwd_cuda_eager_once(
793+
dy_c, x_c, B_c, C_c, z_c, A_c, dt_c, D_c, h0_c, dh_init=dh_init
794+
)
795+
if trap is not None:
796+
from cppmega_mlx.nn._tilelang._mamba3_helpers import bwd_dtrap_ddt
797+
798+
ddt_trap, _dtrap = bwd_dtrap_ddt(
799+
grads[5].astype(mx.float32),
800+
dt_c.astype(mx.float32),
801+
trap.astype(mx.float32),
802+
)
803+
grads = list(grads)
804+
grads[5] = grads[5] + ddt_trap.astype(grads[5].dtype)
805+
grads = tuple(grads)
806+
return grads
807+
808+
try:
809+
return mamba3_mimo_bwd_seq_chunked(
810+
dy, x, B, C, z, A, dt, D, h0,
811+
chunk=chunk,
812+
fwd_state_fn=_cuda_fwd_state,
813+
bwd_chunk_fn=_cuda_bwd_chunk,
814+
)
815+
except Exception as exc:
816+
raise RuntimeError(
817+
"_cuda_eager.mamba3_mimo_bwd_cuda_eager: chunked Mamba3 MIMO "
818+
f"bwd failed ({type(exc).__name__}: {exc})."
819+
) from exc
679820

680821
try:
681-
kernel = _mamba3_bwd_cuda_kernel(batch, seq, heads, headdim, state)
682-
dy_t = _mlx_to_torch_cuda(dyf)
683-
x_t = _mlx_to_torch_cuda(xf)
684-
B_t = _mlx_to_torch_cuda(Bf)
685-
C_t = _mlx_to_torch_cuda(Cf)
686-
z_t = _mlx_to_torch_cuda(zf)
687-
A_t = _mlx_to_torch_cuda(Af)
688-
dt_t = _mlx_to_torch_cuda(dtf)
689-
D_t = _mlx_to_torch_cuda(Df)
690-
h0_t = _mlx_to_torch_cuda(h0f)
691-
692-
dx_t = torch.zeros(
693-
batch, seq, heads, headdim, dtype=torch.float32, device="cuda"
694-
)
695-
dz_t = torch.zeros(
696-
batch, seq, heads, headdim, dtype=torch.float32, device="cuda"
697-
)
698-
dB_lg_t = torch.zeros(
699-
batch, seq, heads, state, headdim, dtype=torch.float32, device="cuda"
700-
)
701-
dC_lg_t = torch.zeros(
702-
batch, seq, heads, state, headdim, dtype=torch.float32, device="cuda"
703-
)
704-
dA_lg_t = torch.zeros(
705-
batch, seq, heads, headdim, dtype=torch.float32, device="cuda"
706-
)
707-
ddt_lg_t = torch.zeros(
708-
batch, seq, heads, headdim, dtype=torch.float32, device="cuda"
709-
)
710-
dD_lg_t = torch.zeros(
711-
batch, heads, headdim, dtype=torch.float32, device="cuda"
712-
)
713-
dh0_t = torch.zeros(
714-
batch, heads, headdim, state, dtype=torch.float32, device="cuda"
715-
)
716-
kernel(
717-
dy_t,
718-
x_t,
719-
B_t,
720-
C_t,
721-
z_t,
722-
A_t,
723-
dt_t,
724-
D_t,
725-
h0_t,
726-
dx_t,
727-
dz_t,
728-
dB_lg_t,
729-
dC_lg_t,
730-
dA_lg_t,
731-
ddt_lg_t,
732-
dD_lg_t,
733-
dh0_t,
734-
)
735-
torch.cuda.synchronize()
822+
return _mamba3_mimo_bwd_cuda_eager_once(dy, x, B, C, z, A, dt, D, h0)
736823
except Exception as exc:
737824
# RULE #1: a kernel launch/writeback failure is a real bug in the
738825
# vendored Mamba3 MIMO bwd kernel, not "feature absent". Raise loud
@@ -744,18 +831,6 @@ def mamba3_mimo_bwd_cuda_eager(
744831
f"({type(exc).__name__}: {exc})."
745832
) from exc
746833

747-
# Reduce the per-(b,h,p) lane partials over the P axis (matches the Metal
748-
# bf16-snapshot reduction axes exactly).
749-
dx = _torch_cuda_to_mlx(dx_t, x.dtype)
750-
dz = _torch_cuda_to_mlx(dz_t, z.dtype)
751-
dh0_g = _torch_cuda_to_mlx(dh0_t, h0.dtype)
752-
dB = _torch_cuda_to_mlx(dB_lg_t.sum(dim=4), B.dtype)
753-
dC = _torch_cuda_to_mlx(dC_lg_t.sum(dim=4), C.dtype)
754-
dA = _torch_cuda_to_mlx(dA_lg_t.sum(dim=3), A.dtype)
755-
ddt = _torch_cuda_to_mlx(ddt_lg_t.sum(dim=3), dt.dtype)
756-
dD = _torch_cuda_to_mlx(dD_lg_t.sum(dim=(0, 2)), D.dtype)
757-
return dx, dB, dC, dz, dA, ddt, dD, dh0_g
758-
759834

760835

761836

0 commit comments

Comments
 (0)