mamba3
Mamba-3 MIMO chunked forward and recurrent decode on NVIDIA GPUs, loadable
through kernels. The reference baselines are the mamba3_MIMO_chunk_ref and
mamba3_MIMO_step_ref programs from state-spaces/mamba, matched to 2.2e-6 in
float32.
Upstream ships these paths as a pinned tilelang==0.1.8 kernel for the forward
and a CUTLASS-DSL kernel for decode, and Mamba3(is_mimo=True) raises in its
constructor when TileLang is absent rather than falling back. This is the same
two operations as one compiled extension with no TileLang, TVM-FFI,
CUTLASS-DSL or quack dependency.
Usage
from kernels import get_kernel
m3 = get_kernel("phanerozoic/mamba3", version=1, trust_remote_code=True)
# prefill: q, k are [B, S, R, Gqk, N]; v, z are [B, S, H, P]
y = m3.forward(q, k, v, q_bias, k_bias, mimo_v, mimo_o, angles, adt, dt, trap,
z=z, mimo_z=mimo_z, D=D, chunk_size=16)
# decode: one token per call, state carried
st = m3.DecodeState(B, H, headdim=P, dstate=N, rank=R, nangles=Na)
y_t = st.step(q_t, k_t, v_t, q_bias, k_bias, mimo_v, mimo_o, angles_t,
adt_t, dt_t, trap_t, z=z_t, mimo_z=mimo_z, D=D)
version selects the release branch; trust_remote_code is required by
kernels for publishers without the trusted-publisher mark. angles are the
raw per-step rotation rates; the cumulative sum is taken internally in float32.
API
| Symbol | Purpose |
|---|---|
forward(q, k, v, ..., chunk_size, norm_weight) |
chunked forward, [B, S, H, P] out, differentiable |
DecodeState(batch, nheads, headdim, dstate, rank, nangles) |
carried decode state |
DecodeState.step(...) |
one decode token, [B, H, P] out |
cumulative_angles(angles, dt), chunk_decay(adt, chunk_size) |
schedule helpers |
dispatch_paths(mimo_rank, dstate, headdim, chunk_size, bf16) |
which kernels a geometry selects |
norm_weight selects the fused head-wise RMSNorm pre-gate path. All three ops
carry meta kernels, so the forward traces under torch.compile(fullgraph=True)
without a graph break.
Method
The rank axis is flattened into the row index i = c*R + r, so a chunk tile is
(C*R, N) against (C*R, P) and the forward is the SSD decomposition:
chunk_state builds kv[n], state_passing scans it, chunk_scan computes
q_inter @ states[n] + mask(Q Kᵀ) V + D v. Keys carry
factor = γ_t + Δ_{t+1}(1 - λ_{t+1}), the single-SSD form of the trapezoidal
recurrence, and the intra diagonal block is rescaled to γ_t. Contractions are
register-blocked 4x4 on a 16x16 thread grid only where the tensor cores are not
reachable. The float32 contractions split each operand into a tf32 head and tail
and run hi*hi + hi*lo + lo*hi; the dropped lo*lo term is below the fp32
rounding of the sum, and a single tf32 pass would not hold the float32 contract.
Rotation is applied while staging and never materialized.
Decode runs the recurrence directly,
S ← αS + β(kprevᵀ vprev) + γ(kᵀv), one warp per p row with the rank axis in
registers. The trapezoidal term reaches one step back, so the previous key and
value are carried state.
mimo_v, mimo_o and mimo_z are (H, R, P) elementwise weights, so rank
expansion is a broadcast multiply and the fold is a contraction over r alone.
Rotation angles stay float32 at any input dtype, since they accumulate over the
sequence.
On the bfloat16 path the operands are staged by ldmatrix and contracted by
mma.sync.m16n8k16 written out directly, because nvcc's wmma never emits
ldmatrix for bf16 and falls back to scalar loads and matrix-move fixups. The
rank, the head dimension and the tile counts each warp owns are template
arguments, so the accumulators index statically and stay in registers across the
whole d loop and the epilogue, and the index arithmetic carries no integer
division. Every shared tile is padded off a multiple of the 32 banks. The causal
weight, the chunk decay and the D term all fold into the accumulator drain,
D v_r riding the intra product on A's diagonal, so the float A tile never
exists and the output tile reuses the space its operands occupied. The rank
weights and the decay column are staged once per block, a rotary pair is one wide
load, and the head axis varies fastest across the grid so that blocks sharing a
chunk's q and k are resident together. The state scan issues its chunk loads four
ahead of the carry that consumes them; the fma order is unchanged, and the writes
land on the addresses just read.
Measured
RTX 6000 Ada, H=32, P=64, N=128, R=4, C=16. Forward against the torch
reference program, which is what runs when TileLang is unavailable:
| seqlen | kernel | torch | speedup | peak memory |
|---|---|---|---|---|
| 512 | 0.331 ms | 3.312 ms | 10.02x | 62 vs 375 MB |
| 1024 | 0.586 ms | 7.860 ms | 13.42x | 131 vs 741 MB |
| 2048 | 1.531 ms | 17.609 ms | 11.50x | 254 vs 1472 MB |
| 4096 | 3.186 ms | 40.460 ms | 12.70x | 499 vs 2935 MB |
Decode against the eager step, with the state traffic it sustains:
| batch | kernel | eager | speedup | state |
|---|---|---|---|---|
| 1 | 0.0245 ms | 0.754 ms | 30.8x | 86 GB/s |
| 4 | 0.0398 ms | 0.749 ms | 18.8x | 211 GB/s |
| 16 | 0.0665 ms | 0.773 ms | 11.6x | 505 GB/s |
| 64 | 0.1659 ms | 2.030 ms | 12.2x | 809 GB/s |
| 256 | 0.9697 ms | 7.492 ms | 7.7x | 554 GB/s |
At batch 64 the decode step is at this card's memory roofline. At batch 1 it is launch- and dependency-latency bound; capture it in a CUDA graph.
Against upstream's CuTe decode kernel on an H200, H=64, P=64, N=128, R=4,
bfloat16, gated, eight tokens a call. Upstream's kernel takes q and k already
rotated, so the comparison is against its Triton rotary step and its CuTe step
together, which is what one call here does; the CuTe kernel alone is the column
beside it. Both sides agree with the reference program to 5.1e-3, and with each
other to 5.6e-3, which is bfloat16 rounding:
| batch | kernel | rotary + cute | cute alone | ratio |
|---|---|---|---|---|
| 1 | 0.812 ms | 2.901 ms | 0.646 ms | 3.57x |
| 4 | 0.813 | 2.885 | 0.645 | 3.55x |
| 16 | 1.180 | 2.894 | 0.638 | 2.45x |
| 64 | 3.563 | 2.894 | 0.742 | 0.81x |
| 256 | 12.738 | 3.051 | 1.973 | 0.24x |
The two scale differently. This kernel is work-proportional in batch; upstream's pair is nearly flat from 1 to 256, and its rotary step is four fifths of it, so the crossover is near batch 32. Below it this kernel leads by up to 3.6x, above it upstream does, by 4x at batch 256.
Against upstream's TileLang forward rather than the reference program, on an RTX 3070 Ti Laptop at sm_86 in bfloat16. Both sides run in one process from one float32 master set, each cast to its own dtype contract:
| S | H | R | C | kernel | tilelang | ratio |
|---|---|---|---|---|---|---|
| 512 | 8 | 4 | 16 | 0.180 ms | 0.398 ms | 2.21x |
| 1024 | 8 | 4 | 16 | 0.297 ms | 0.789 ms | 2.66x |
| 2048 | 8 | 4 | 16 | 0.544 ms | 1.628 ms | 2.99x |
| 4096 | 8 | 4 | 16 | 1.054 ms | 3.316 ms | 3.15x |
| 2048 | 8 | 1 | 64 | 0.252 ms | 0.514 ms | 2.04x |
| 2048 | 32 | 4 | 16 | 2.189 ms | 1.862 ms | 0.85x |
On an H200 at sm_90, where upstream's float32 forward also fits its 168 KB dynamic shared-memory request and both dtypes can be compared:
| S | H | R | C | kernel | tilelang | ratio | kernel f32 | tilelang f32 | ratio | |
|---|---|---|---|---|---|---|---|---|---|---|
| 512 | 8 | 4 | 16 | 0.114 ms | 0.204 ms | 1.79x | 0.183 ms | 0.281 ms | 1.54x | |
| 1024 | 8 | 4 | 16 | 0.106 | 0.383 | 3.62x | 0.308 | 0.554 | 1.80x | |
| 2048 | 8 | 4 | 16 | 0.181 | 0.754 | 4.16x | 0.598 | 1.140 | 1.91x | |
| 4096 | 8 | 4 | 16 | 0.378 | 1.641 | 4.34x | 1.236 | 2.547 | 2.06x | |
| 2048 | 32 | 4 | 16 | 0.712 | 1.035 | 1.45x | 2.217 | 1.311 | 0.59x | |
| 2048 | 8 | 1 | 64 | 0.100 | 0.267 | 2.68x | 0.211 | 0.365 | 1.73x |
The head-count row is part-dependent. This kernel is work-proportional, one
block per (batch, head, chunk); upstream's unit of parallelism absorbs 32
heads, so it is underutilized below H=32 and saturated at it. On sm_86 that
saturation is enough to take the row, and on sm_90 it is not. The float32 column
leads at H=8 and trails at H=32, the one geometry either dtype loses on this
part. Upstream's float32 forward requests 168,128 bytes of dynamic shared memory per
block, and 180,992 at C = 64. The opt-in ceiling is 101,376 bytes on consumer
and workstation Ampere and Ada. This kernel's float32 forward requests 39,104
bytes at the same geometry. The harness is benchmarks/bench_vs_upstream.py;
TODO.md records the rest.
Backward
forward is an autograd function. The backward is two chunk-parallel passes
around a reverse state scan, recomputing chunk intermediates rather than storing
them: the first recovers O and takes the epilogue adjoint, the scan runs
dSt[n] = dSt_part[n] + decay[n] dSt[n+1] in reverse, and the second produces
dQ, dKf and dV before the rotation adjoint lands on q, k and the
biases. Gradients cover the activations, every (H,R,P) rank weight, D, the
norm weight, and the schedule itself: dt, adt, trap and the rotation rates.
It is reproducible. The grid is (batch, chunk) with the heads looped inside, so
every gradient indexed by (b, s, ...) is owned by a single block and is
accumulated in head order rather than by atomics; per-head parameters go to a
per-block slot reduced in block order, and the two cross-chunk terms, the shifted
half of the trapezoidal factor and the scan's decay adjoint, are staged and
summed in a second ordered pass. No floating-point atomic appears anywhere in
the backward source, and repeated runs return the forward and all 14 gradients
bitwise identical.
The two chunk workspaces and the recomputed states are [B, H, Nc, N, P] each
and dO is [B, H, Nc, C*R, P], so peak allocation is roughly the forward's
plus that. At the geometry above:
| seqlen | forward | forward + backward |
|---|---|---|
| 512 | 55 MB | 129 MB |
| 1024 | 109 MB | 258 MB |
| 2048 | 218 MB | 513 MB |
| 4096 | 435 MB | 1025 MB |
Correctness
Every figure below is the worst case over that program's grid, measured on an
RTX 6000 Ada against state-spaces/mamba's own reference programs.
| program | checks | worst |
|---|---|---|
tests/test_mamba3.py decode |
ranks 1 to 8, grouped-query, both rotation fractions | 3.0e-7 |
tests/test_mamba3.py forward |
against mamba3_MIMO_chunk_ref |
2.5e-6 |
tests/test_mamba3.py forward vs decode |
partial rotation | 1.3e-6 |
tests/test_mamba3.py ragged |
sequence length not a multiple of the chunk | 3.3e-6 |
tests/test_mamba3.py fused norm |
against the upstream fused-norm reference | 3.6e-7 |
tests/test_mamba3.py bfloat16 |
against the float32 kernel | 9.4e-3 |
tests/test_mamba3.py long sequences |
S to 32,768, accumulated angle to 3,378 radians |
3.5e-6 |
tests/test_bwd.py |
15 raw gradients against autograd through the reference | 7.6e-7 |
tests/test_autograd.py |
the wrapped function end to end, cumsum adjoints included | 6.4e-7 |
tests/test_bwd_bf16.py |
the bfloat16 backward against the float32 one | 6.8e-3 |
tests/test_bigchunk.py |
the streamed intra path up to C*R = 256 |
6.1e-7 |
ref/test_ref.py |
this repo's step reference against upstream's | 2.3e-7 |
tests/test_dispatch.py |
all five launcher paths against the step reference | 1.5e-2 |
tests/test_determinism.py |
forward and 14 gradients over 3 repeats | bitwise |
tests/test_fixes.py |
meta kernels, fullgraph=True, the backward memory guard |
pass |
Upstream's chunk reference omits the cos/sin padding its step reference
performs, so its pairwise path is only defined at Na == N/2; partial rotation
is checked against decode instead.
The rotation angle is a cumulative sum over the sequence and grows without
bound, so it is evaluated with a range-reduced sincos rather than the
approximate intrinsic, whose argument reduction would make the residual
proportional to the angle. Agreement with the reference is flat in sequence
length: 2.3e-6 at S = 2048 and 3.5e-6 at S = 32768, where the accumulated
angle reaches 3,378 radians. The residual is set by the three-term tf32 split,
which accumulates over the N/8 contraction tiles, so it is the figure to watch
if N grows; MAMBA3_SCAN=fma returns the float32 scan to 1.4e-6 at a third
less throughput.
Requirements and limits
- NVIDIA GPU with compute capability 8.0+; float32 or bfloat16 values, float32 schedule and rotation angles.
Neven,Na <= N/2,mimo_rank <= 8,nheadsdivisible by the query-group count. Sequence length and chunk size are arbitrary; aboveC*R = 64the forward switches to a row-block streamed intra term so nothing scales with(C*R)².C = 64 / mimo_rankremains the fastest setting, since the intra term isO(S*C)and grows faster than the state workspace shrinks.- The backward has no streamed path, so its tiles stay resident and its shared
memory grows as
(C*R)²andC*R*P. Both entry points check the requirement against the device's opt-in maximum and raise naming the two figures rather than failing at launch. A 99 KB device admitsC*R = 64atheaddim = 64;C*R = 128, orheaddim = 128atC*R = 64, needs the forward instead. - Gradients are computed in float32 and returned in the input dtype, and the backward computes all of them whether or not each input requires grad.
chunk_stateandchunk_scanboth contract on the tensor cores in both dtypes, float32 through the three-pass tf32 split and bfloat16 natively. The tensor-core kernels are instantiated formimo_rankin {1, 2, 4, 8} andheaddimin {16, 32, 64, 128}, and the scans additionally wantchunk_size * mimo_rank == 64; any other geometry takes the FMA path.dispatch_pathsreports which kernels a geometry selects, andMAMBA3_SCAN=fmademotes the float32 scan to FMA for an A/B in one process.- Packaged for compute capabilities 8.0, 8.6, 8.9, 9.0, 10.0 and 12.0. The whole suite runs on 8.6 and 8.9; the others are build targets.
References
Lahoti, Li, Chen, Wang, Bick, Kolter, Dao, Gu, "Mamba-3: Improved Sequence
Modeling using State Space Principles" (ICLR 2026, arXiv:2603.15569); Dao and
Gu, the SSD chunked scan of Mamba-2; state-spaces/mamba as the reference
implementation.
License
Apache-2.0.
- Downloads last month
- 1
- OS
- linux
- Arch
- x86_64
- Kernel Builder
- 22e2aad




