Skip to content

Commit 1405036

Browse files
committed
feat(path-c): native CUDA mamba3 backward kernel (eager per-op) - 9-15x vs reference VJP
mamba3_mimo_bwd_path_c on CUDA had no backward kernel: it computed grads via mx.vjp(mamba3_mimo_reference) over the pure-MLX chunked diagonal scan, which dominates the eager-prefix VJP of a fused Path C step (~9s/step at seq=512; the 2 prefix mamba3 layers run every step because the fused region is structurally limited to the one contiguous A/M/R run, layers 10-12, since moe bricks aren't Path-C-eligible). Port the production single-kernel scratch backward to a standalone CUDA-eager TileLang prim (_cuda_eager._make_mamba3_bwd_cuda_prim): per (b,h,p) lane forward-recompute the state scan into a T.alloc_global slab, then a reverse-time VJP scan; VJP math byte-for-byte the Metal scratch kernel. CUDA adaptations: tir.metal.thread_position_in_grid_x -> bx*THREADS + get_thread_binding; T.alloc_var(init=) -> one-slot T.alloc_local; no PEP-563 annotations. P-axis reductions (dB/dC/dA/ddt/dD) in MLX outside the kernel on the same axes as the Metal path. Wired into the not-can_run_metal branch of mamba3_mimo_bwd_path_c with mx.vjp(reference) kept as a guarded fallback. gb10 parity vs mx.vjp(reference): all grads max_abs <= 2.4e-6 (fp32 roundoff); public entry 7.2e-7; end-to-end value_and_grad loss 7.6e-6. Speed: 9.4x (small) / 15.3x (medium) standalone. CUDA suite test_tilelang_mamba3_path_c.py: 0 regressions, 12 newly-passing backward/VJP tests (remaining failures are Metal-MSL-only, fail identically pristine). Metal byte-for-byte unchanged (CUDA-only branch). Paths B/D/E untouched.
1 parent 9a008ae commit 1405036

2 files changed

Lines changed: 317 additions & 5 deletions

File tree

cppmega_mlx/nn/_tilelang/_cuda_eager.py

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,304 @@ def mamba3_mimo_fwd_cuda_eager(
439439
return y, h_last
440440

441441

442+
# ---------------------------------------------------------------------------
443+
# mamba3 (bwd) — vendored CUDA-safe copy of the production reverse scan
444+
# ---------------------------------------------------------------------------
445+
#
446+
# Ports the Metal single-kernel scratch backward
447+
# (``mamba3_path_c._bwd_scratch_partial_kernel_for``) to CUDA. Per (b, h, p)
448+
# lane the kernel (1) recomputes the forward state scan into a global scratch
449+
# slab ``h_steps_scratch[b, t, h, p, n]`` (decayed recurrence
450+
# ``h = exp(A*dt)*h + x*B``), then (2) runs the reverse-time VJP scan to emit
451+
# *per-lane partial* gradients. The P-axis reductions that turn those partials
452+
# into dB/dC/dA/ddt/dD are done in MLX outside the kernel (identical axes to
453+
# ``mamba3_path_c._mamba3_mimo_bwd_path_c_bf16_snapshot_kernel``):
454+
#
455+
# dB = sum_p dB_lane_grad (B,S,H,STATE,HEADDIM) -> (B,S,H,STATE)
456+
# dC = sum_p dC_lane_grad (B,S,H,STATE,HEADDIM) -> (B,S,H,STATE)
457+
# dA = sum_p dA_lane_grad (B,S,H,HEADDIM) -> (B,S,H)
458+
# ddt = sum_p ddt_lane_grad (B,S,H,HEADDIM) -> (B,S,H)
459+
# dD = sum_{b,p} dD_lane_grad (B,H,HEADDIM) -> (H,)
460+
#
461+
# dx/dz/dh0 are per-lane (no reduction). The recurrence/VJP math is byte-for-byte
462+
# the Metal scratch kernel; only the codegen primitives change for nvcc:
463+
# * lane index from ``T.get_thread_binding()`` + block id, not the Metal
464+
# ``tir.metal.thread_position_in_grid_x`` intrinsic;
465+
# * ``T.alloc_local`` one-slot accumulators instead of ``T.alloc_var(init=)``
466+
# (the latter emits a non-lvalue ``float v = 0`` that nvcc rejects here).
467+
# All carriers fp32 (the production fp32 EAGER carrier path). Defined at module
468+
# scope (no ``from __future__ import annotations``) so the ``@T.prim_func``
469+
# ``get_type_hints`` re-evaluation resolves the shape names.
470+
471+
472+
def _make_mamba3_bwd_cuda_prim(
473+
BATCH: int,
474+
SEQ: int,
475+
HEADS: int,
476+
HEADDIM: int,
477+
STATE: int,
478+
) -> Any:
479+
"""Build a CUDA-safe Mamba3 MIMO backward (lane-grad partials) prim_func."""
480+
481+
import tilelang.language as T
482+
483+
LANES = BATCH * HEADS * HEADDIM
484+
THREADS = min(256, max(1, LANES))
485+
ad = "float32"
486+
487+
@T.prim_func
488+
def bwd(
489+
dy: T.Tensor((BATCH, SEQ, HEADS, HEADDIM), "float32"),
490+
x: T.Tensor((BATCH, SEQ, HEADS, HEADDIM), "float32"),
491+
B: T.Tensor((BATCH, SEQ, HEADS, STATE), "float32"),
492+
C: T.Tensor((BATCH, SEQ, HEADS, STATE), "float32"),
493+
z: T.Tensor((BATCH, SEQ, HEADS, HEADDIM), "float32"),
494+
A: T.Tensor((BATCH, SEQ, HEADS), "float32"),
495+
dt: T.Tensor((BATCH, SEQ, HEADS), "float32"),
496+
D: T.Tensor((HEADS,), "float32"),
497+
h0: T.Tensor((BATCH, HEADS, HEADDIM, STATE), "float32"),
498+
dx: T.Tensor((BATCH, SEQ, HEADS, HEADDIM), "float32"),
499+
dz: T.Tensor((BATCH, SEQ, HEADS, HEADDIM), "float32"),
500+
dB_lane_grad: T.Tensor((BATCH, SEQ, HEADS, STATE, HEADDIM), "float32"),
501+
dC_lane_grad: T.Tensor((BATCH, SEQ, HEADS, STATE, HEADDIM), "float32"),
502+
dA_lane_grad: T.Tensor((BATCH, SEQ, HEADS, HEADDIM), "float32"),
503+
ddt_lane_grad: T.Tensor((BATCH, SEQ, HEADS, HEADDIM), "float32"),
504+
dD_lane_grad: T.Tensor((BATCH, HEADS, HEADDIM), "float32"),
505+
dh0: T.Tensor((BATCH, HEADS, HEADDIM, STATE), "float32"),
506+
):
507+
h_steps_scratch = T.alloc_global(
508+
(BATCH, SEQ + 1, HEADS, HEADDIM, STATE), ad
509+
)
510+
with T.Kernel(T.ceildiv(LANES, THREADS), threads=THREADS) as bx:
511+
lane = T.get_thread_binding()
512+
gl = bx * THREADS + lane
513+
h_state = T.alloc_local((STATE,), ad)
514+
dh = T.alloc_local((STATE,), ad)
515+
decay = T.alloc_local((1,), ad)
516+
dD_acc = T.alloc_local((1,), ad)
517+
y_state = T.alloc_local((1,), ad)
518+
dx_inp = T.alloc_local((1,), ad)
519+
d_decay = T.alloc_local((1,), ad)
520+
if gl < LANES:
521+
p = gl % HEADDIM
522+
h = (gl // HEADDIM) % HEADS
523+
b = gl // (HEADDIM * HEADS)
524+
D_h = D[h]
525+
526+
# Forward recompute into the global scratch slab.
527+
for n in T.serial(STATE):
528+
h_state[n] = h0[b, h, p, n]
529+
h_steps_scratch[b, 0, h, p, n] = h_state[n]
530+
for t in T.serial(SEQ):
531+
decay[0] = T.exp(A[b, t, h] * dt[b, t, h])
532+
x_val = x[b, t, h, p]
533+
for n in T.serial(STATE):
534+
h_state[n] = decay[0] * h_state[n] + x_val * B[b, t, h, n]
535+
h_steps_scratch[b, t + 1, h, p, n] = h_state[n]
536+
537+
# Reverse-time VJP scan.
538+
for n in T.serial(STATE):
539+
h_state[n] = h_steps_scratch[b, SEQ, h, p, n]
540+
dh[n] = 0.0
541+
dD_acc[0] = 0.0
542+
543+
for rt in T.serial(SEQ):
544+
t = SEQ - 1 - rt
545+
A_val = A[b, t, h]
546+
dt_val = dt[b, t, h]
547+
decay[0] = T.exp(A_val * dt_val)
548+
x_val = x[b, t, h, p]
549+
z_val = z[b, t, h, p]
550+
dY = dy[b, t, h, p]
551+
552+
y_state[0] = 0.0
553+
for n in T.serial(STATE):
554+
y_state[0] = y_state[0] + h_state[n] * C[b, t, h, n]
555+
y_skipped = y_state[0] + D_h * x_val
556+
sig_z = 1.0 / (1.0 + T.exp(-z_val))
557+
silu_z = z_val * sig_z
558+
silu_dz = sig_z * (1.0 + z_val * (1.0 - sig_z))
559+
d_silu = dY * y_skipped
560+
d_y_skipped = dY * silu_z
561+
562+
dz[b, t, h, p] = d_silu * silu_dz
563+
dD_acc[0] = dD_acc[0] + d_y_skipped * x_val
564+
565+
dx_inp[0] = 0.0
566+
d_decay[0] = 0.0
567+
for n in T.serial(STATE):
568+
C_val = C[b, t, h, n]
569+
B_val = B[b, t, h, n]
570+
h_prev = h_steps_scratch[b, t, h, p, n]
571+
dh_n = dh[n] + d_y_skipped * C_val
572+
dC_lane_grad[b, t, h, n, p] = d_y_skipped * h_state[n]
573+
dB_lane_grad[b, t, h, n, p] = dh_n * x_val
574+
dx_inp[0] = dx_inp[0] + dh_n * B_val
575+
d_decay[0] = d_decay[0] + dh_n * h_prev
576+
dh[n] = dh_n * decay[0]
577+
h_state[n] = h_prev
578+
579+
dx_skip = d_y_skipped * D_h
580+
dx[b, t, h, p] = dx_skip + dx_inp[0]
581+
582+
d_logdecay = d_decay[0] * decay[0]
583+
dA_lane_grad[b, t, h, p] = d_logdecay * dt_val
584+
ddt_lane_grad[b, t, h, p] = d_logdecay * A_val
585+
586+
for n in T.serial(STATE):
587+
dh0[b, h, p, n] = dh[n]
588+
dD_lane_grad[b, h, p] = dD_acc[0]
589+
590+
return bwd
591+
592+
593+
@functools.lru_cache(maxsize=128)
594+
def _mamba3_bwd_cuda_kernel(
595+
BATCH: int,
596+
SEQ: int,
597+
HEADS: int,
598+
HEADDIM: int,
599+
STATE: int,
600+
) -> Any:
601+
"""Compile the CUDA-safe Mamba3 MIMO backward kernel (caller-owned outs)."""
602+
603+
import tilelang
604+
605+
prim = _make_mamba3_bwd_cuda_prim(BATCH, SEQ, HEADS, HEADDIM, STATE)
606+
return tilelang.compile(prim, target="cuda", out_idx=None)
607+
608+
609+
def mamba3_mimo_bwd_cuda_eager(
610+
dy: mx.array,
611+
x: mx.array,
612+
B: mx.array,
613+
C: mx.array,
614+
z: mx.array,
615+
A: mx.array,
616+
dt: mx.array,
617+
D: mx.array,
618+
h0: mx.array,
619+
) -> tuple[
620+
mx.array, mx.array, mx.array, mx.array, mx.array, mx.array, mx.array, mx.array
621+
] | None:
622+
"""TileLang-CUDA EAGER Mamba3 MIMO backward.
623+
624+
Returns grads ``(dx, dB, dC, dz, dA, ddt, dD, dh0)`` (matching the VJP order
625+
of the primals ``x, B, C, z, A, dt, D, h0``) cast back to the corresponding
626+
input dtypes, or ``None`` on any failure so the caller can fall back to the
627+
pure-MLX reference VJP. fp32 carriers only (matches the production fp32
628+
EAGER path). The kernel emits per-(b,h,p) lane partials for the gradients
629+
that are reduced over the HEADDIM/P axis; the reductions happen here in MLX.
630+
"""
631+
632+
ok, _reason = cuda_eager_available()
633+
if not ok:
634+
return None
635+
636+
from cppmega_mlx.nn._tilelang.mamba3 import _validate_inputs
637+
638+
try:
639+
import torch
640+
except Exception:
641+
return None
642+
643+
batch, seq, heads, headdim, state = _validate_inputs(x, B, C, z, A, dt, D, h0)
644+
if seq == 0:
645+
return (
646+
mx.zeros_like(x),
647+
mx.zeros_like(B),
648+
mx.zeros_like(C),
649+
mx.zeros_like(z),
650+
mx.zeros_like(A),
651+
mx.zeros_like(dt),
652+
mx.zeros_like(D),
653+
mx.zeros_like(h0),
654+
)
655+
656+
dyf = dy.astype(mx.float32)
657+
xf = x.astype(mx.float32)
658+
Bf = B.astype(mx.float32)
659+
Cf = C.astype(mx.float32)
660+
zf = z.astype(mx.float32)
661+
Af = A.astype(mx.float32)
662+
dtf = dt.astype(mx.float32)
663+
Df = D.astype(mx.float32)
664+
h0f = h0.astype(mx.float32)
665+
666+
try:
667+
kernel = _mamba3_bwd_cuda_kernel(batch, seq, heads, headdim, state)
668+
dy_t = _mlx_to_torch_cuda(dyf)
669+
x_t = _mlx_to_torch_cuda(xf)
670+
B_t = _mlx_to_torch_cuda(Bf)
671+
C_t = _mlx_to_torch_cuda(Cf)
672+
z_t = _mlx_to_torch_cuda(zf)
673+
A_t = _mlx_to_torch_cuda(Af)
674+
dt_t = _mlx_to_torch_cuda(dtf)
675+
D_t = _mlx_to_torch_cuda(Df)
676+
h0_t = _mlx_to_torch_cuda(h0f)
677+
678+
dx_t = torch.zeros(
679+
batch, seq, heads, headdim, dtype=torch.float32, device="cuda"
680+
)
681+
dz_t = torch.zeros(
682+
batch, seq, heads, headdim, dtype=torch.float32, device="cuda"
683+
)
684+
dB_lg_t = torch.zeros(
685+
batch, seq, heads, state, headdim, dtype=torch.float32, device="cuda"
686+
)
687+
dC_lg_t = torch.zeros(
688+
batch, seq, heads, state, headdim, dtype=torch.float32, device="cuda"
689+
)
690+
dA_lg_t = torch.zeros(
691+
batch, seq, heads, headdim, dtype=torch.float32, device="cuda"
692+
)
693+
ddt_lg_t = torch.zeros(
694+
batch, seq, heads, headdim, dtype=torch.float32, device="cuda"
695+
)
696+
dD_lg_t = torch.zeros(
697+
batch, heads, headdim, dtype=torch.float32, device="cuda"
698+
)
699+
dh0_t = torch.zeros(
700+
batch, heads, headdim, state, dtype=torch.float32, device="cuda"
701+
)
702+
kernel(
703+
dy_t,
704+
x_t,
705+
B_t,
706+
C_t,
707+
z_t,
708+
A_t,
709+
dt_t,
710+
D_t,
711+
h0_t,
712+
dx_t,
713+
dz_t,
714+
dB_lg_t,
715+
dC_lg_t,
716+
dA_lg_t,
717+
ddt_lg_t,
718+
dD_lg_t,
719+
dh0_t,
720+
)
721+
torch.cuda.synchronize()
722+
except Exception:
723+
return None
724+
725+
# Reduce the per-(b,h,p) lane partials over the P axis (matches the Metal
726+
# bf16-snapshot reduction axes exactly).
727+
dx = _torch_cuda_to_mlx(dx_t, x.dtype)
728+
dz = _torch_cuda_to_mlx(dz_t, z.dtype)
729+
dh0_g = _torch_cuda_to_mlx(dh0_t, h0.dtype)
730+
dB = _torch_cuda_to_mlx(dB_lg_t.sum(dim=4), B.dtype)
731+
dC = _torch_cuda_to_mlx(dC_lg_t.sum(dim=4), C.dtype)
732+
dA = _torch_cuda_to_mlx(dA_lg_t.sum(dim=3), A.dtype)
733+
ddt = _torch_cuda_to_mlx(ddt_lg_t.sum(dim=3), dt.dtype)
734+
dD = _torch_cuda_to_mlx(dD_lg_t.sum(dim=(0, 2)), D.dtype)
735+
return dx, dB, dC, dz, dA, ddt, dD, dh0_g
736+
737+
738+
739+
442740
# ---------------------------------------------------------------------------
443741
# FP8 Sparse-MLA — per-token FP8 producer (CUDA-safe) + prepared-buffer apply
444742
# ---------------------------------------------------------------------------
@@ -1025,6 +1323,7 @@ def m2rnn_supported_cuda_eager() -> tuple[bool, str]:
10251323
"cuda_eager_available",
10261324
"sparse_mla_fwd_cuda_eager",
10271325
"mamba3_mimo_fwd_cuda_eager",
1326+
"mamba3_mimo_bwd_cuda_eager",
10281327
"fp8_per_token_quant_cuda_eager",
10291328
"fp8_sparse_mla_apply_cuda_eager",
10301329
"m2rnn_mapped_packed_post_fwd_cuda_eager",

cppmega_mlx/nn/_tilelang/mamba3_path_c.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3826,17 +3826,30 @@ def mamba3_mimo_bwd_path_c(
38263826
) -> tuple[mx.array, mx.array, mx.array, mx.array, mx.array, mx.array, mx.array, mx.array]:
38273827
"""Backward pass via the lowered TileLang DSL kernel."""
38283828

3829-
# CUDA EAGER branch: the Metal SIMD lane-grad backward kernel is not
3830-
# ported to CUDA yet (TODO). On a non-Metal host, compute gradients
3831-
# via MLX autograd over the pure-MLX reference scan so the CUDA EAGER
3832-
# forward remains end-to-end differentiable. Returns grads for all 8
3833-
# inputs (x, B, C, z, A, dt, D, h0), matching the kernel ABI.
3829+
# CUDA EAGER branch: on a non-Metal host the Metal SIMD lane-grad
3830+
# backward kernel cannot lower. Use the vendored TileLang-CUDA backward
3831+
# kernel (mamba3_mimo_bwd_cuda_eager) which ports the production single-
3832+
# kernel scratch reverse scan. If that kernel is unavailable or raises,
3833+
# fall back to MLX autograd over the pure-MLX reference scan so
3834+
# correctness never regresses. Returns grads for all 8 inputs
3835+
# (x, B, C, z, A, dt, D, h0), matching the kernel/VJP ABI.
38343836
if not can_run_metal():
38353837
if out is not None:
38363838
raise RuntimeError(
38373839
"mamba3_mimo_bwd_path_c owner-output route is Metal/tvm-ffi "
38383840
"only; the CUDA EAGER backward returns fresh arrays"
38393841
)
3842+
from cppmega_mlx.nn._tilelang._cuda_eager import (
3843+
cuda_eager_available,
3844+
mamba3_mimo_bwd_cuda_eager,
3845+
)
3846+
3847+
cuda_ok, _cuda_reason = cuda_eager_available()
3848+
if cuda_ok:
3849+
grads = mamba3_mimo_bwd_cuda_eager(dy, x, B, C, z, A, dt, D, h0)
3850+
if grads is not None:
3851+
return grads
3852+
38403853
from cppmega_mlx.nn._tilelang.mamba3 import mamba3_mimo_reference
38413854

38423855
def _ref(x_, B_, C_, z_, A_, dt_, D_, h0_):

0 commit comments

Comments
 (0)