The Transformer encoder post opened with a specific complaint: RNNs process a sequence one token at a time, so training on a long sequence can't be parallelized across positions. Self-attention fixed that — every position looks at every other position in one matrix multiply, no waiting for a hidden state to arrive from the previous step. But that fix has its own bill, and it comes due at exactly the moment self-attention's parallelism paid off: comparing every position against every other position costs in the sequence length , and generating text one token at a time means caching every previous position's key and value vectors forever, a cache that only grows. Mamba [1] is built around a blunt question: what would it take to get a recurrence — one hidden state, updated one token at a time, cost that scales with and not — to actually work as well as attention?
Plain recurrences already existed, and they don't work as well as attention, for a reason this post spends real time on: a fixed-parameter recurrence has no way to look at what token just arrived and decide whether it matters. Mamba's core idea — the "selective" in "selective state space" — is to let the recurrence's own update rule depend on the current input, token by token.
That fix isn't free. Older state-space models trained fast because a fixed recurrence can be rewritten as one convolution and computed over an entire sequence at once — but that rewrite only works if the update rule is the same at every step. Making the rule depend on the input breaks it, which threatens to undo the whole point of moving away from RNNs in the first place. Mamba's second idea, a hardware-aware parallel scan, is what gets that parallel training speed back without giving up selectivity. This post covers both ideas — selection, and the scan that keeps it fast — plus the block that wraps them into something you can actually stack into a language model.
This post assumes you've read the Transformer encoder post (self-attention, and why its cost is quadratic) and the decoder post (causal masking and the growing key/value cache at generation time) — both get referenced directly below as the thing Mamba is an alternative to. Nothing here depends on the vision-language posts; Mamba, in its original form, is a language-sequence architecture, not a multimodal one.
Contents
- Pick Two: Parallelism, Speed, or Memory — why RNNs, Transformers, and state space models each make a different trade, and what that trade costs as sequences get longer
- A State Space Model, One Token at a Time — the continuous equations, a leaky-bucket metaphor for what they mean, and turning a continuous rate into a discrete step
- Two Views of the Same Computation — the same recurrence, unrolled into a convolution, and why that convolution is what made pre-Mamba state space models fast to train
- Why Fixed Parameters Can't Selectively Remember — the same four tokens, run through a recurrence that can't tell a filler word from a content word
- The Selection Mechanism: Let the Input Set the Dials — Δ, B, and C stop being constants and start being functions of the current token
- The Price of Selection: No More Convolution — why making the recurrence input-dependent breaks the exact trick that made it fast in the first place
- Parallel Scan: Getting Parallelism Back — an associative operator that lets four sequential steps collapse into two parallel rounds
- The Mamba Block: Assembling the Layer — expand, branch, gate, project — the whole thing that replaces a Transformer block
- What Mamba Buys You, and What It Costs — constant-size inference memory against a growing KV cache, and the one thing attention still does better
- Putting It All Together — a minimal reference implementation: discretize, scan, and the full block, in plain NumPy
1. Pick Two: Parallelism, Speed, or Memory
1.1. Three ways to read a sequence
Three families of sequence model exist, and it's worth being blunt about what each one is actually good and bad at before touching a single equation. An RNN keeps a fixed-size hidden state and updates it one token at a time — inference is cheap and memory is constant, but training is sequential, since token 5's hidden state can't be computed before token 4's is. A Transformer looks at every pair of positions at once — training is fully parallel, since every position's output is one matrix multiply away from every other position — but inference means re-attending to a key/value cache that has one entry per previous token, forever growing. A state space model is the attempt to get an RNN's constant-size inference state and a Transformer's parallel training at the same time, by choosing a recurrence simple enough (linear, not the nonlinear gates an LSTM or GRU use) that it can be restructured into a form a GPU can parallelize.
1.2. How cost actually grows with length
The trade in Three ways to read a sequence isn't abstract — it shows up directly in how training compute scales with sequence length . Self-attention computes an matrix of pairwise scores, so its cost is : double the sequence length, and attention gets roughly four times more expensive. A recurrence — RNN or state space model alike — updates a fixed-size state once per token, so its cost is : double the sequence length, and the cost roughly doubles too, not quadruples.
Nothing about this curve is specific to Mamba — it's the same curve that makes long-context Transformers expensive and is exactly the gap Mamba's recurrence is built to reopen. The rest of this post is about how to build a recurrence that's actually competitive with attention's quality, not just its asymptotics.
2. A State Space Model, One Token at a Time
2.1. The continuous equations, and a bucket to picture them with
A state space model's entire memory is one vector , updated continuously through a linear differential equation, then read out through a separate linear map:
, , , are all just matrices (or a scalar, in 's case) — no attention, no softmax, nothing nonlinear anywhere in this system. The easiest way to hold onto what it means: picture as the water level in a bucket. is water pouring in, at a rate set by the current input . is a leak in the bottom of the bucket — a negative means water drains out over time, so the bucket "forgets" what it held a while ago unless it keeps getting topped up. is a dipstick: it doesn't change the water level, it just reads a number off of it, and is a direct pipe from the input straight to the reading, bypassing the bucket entirely.
Notice that never touches — it's a completely separate, direct shortcut,
independent of the bucket, the leak, and everything else in this post that actually does
interesting work (discretization, selection, the scan). To keep every worked example focused on
the part that matters, this post sets everywhere from here on — every below is
computed as plainly , with the term written out once, explicitly, in the very next
worked example, so it's visible exactly where it drops out rather than silently missing. Since
never interacts with , adding a nonzero back into any equation or code sample below is
always just one independent, final + D * x_t, tacked on after everything else is computed.
2.2. Discretization: turning a rate into a step
Equation (1) describes water level changing every instant, but token sequences are discrete — there is no "" between the first and second word. Zero-order hold (ZOH) is the standard fix: assume the input stays constant for a step of size , solve the differential equation exactly over that one step, and read off what becomes at the end of it:
is the step size — how much simulated time one token is worth — and it's a genuine parameter of the model, not a fixed constant like a sample rate. and are the discrete counterparts of the continuous and : everything downstream of this equation works with and , never the continuous originals directly.
The bucket picture still holds: a larger means more simulated time passes per token, so (how much of the old water level survives) shrinks toward zero and (how much of the new input gets poured in) grows — the bucket both drains faster and fills faster over one step. A tiny does the opposite: and , so almost nothing happens in that step at all. Hold onto that — it's the entire mechanism The Selection Mechanism builds on, three sections from now.
Equation (2) might look reverse-engineered to produce exactly this small- behavior. It isn't — it's the exact solution of Equation (1) under one very standard assumption, using a technique (zero-order hold) that's decades older than state space models and comes straight from classical control theory. That derivation is a detour from the main thread of this post, so it's worked out in full — every step, nothing skipped — in the appendix at the end, for anyone who wants to see it.
2.3. Discrete recurrence, and a worked example
Once discretized, the model runs exactly like an RNN — one step, one token, no differential equations left to solve:
To make this concrete, reuse the Transformer post's own toy sentence, "I think I understand" — the same four tokens, with "I" appearing twice, at positions 1 and 3. Here each token is reduced to a single scalar "signal" (state dimension , model dimension , so there's only one channel to track and no per-channel indexing to carry around — this is the model's channel count, the same overloaded use of the letter the Mamba paper itself makes; it has nothing to do with the feedthrough matrix from Equation (1), which Section 2.1 already set to zero for every example in this post), and is a fixed diagonal matrix, written as just its two diagonal entries:
import numpy as np
def discretize(A, B, Delta):
Abar = np.exp(Delta * A)
Bbar = (Abar - 1.0) / A * B # (Delta*A)^-1 (exp(Delta*A) - I) * Delta == (Abar-1)/A
return Abar, Bbar
tokens = ["I", "think", "I", "understand"]
x = np.array([-1.0, 0.5, -1.0, 1.0])
A = np.array([-1.0, -0.5]) # diagonal entries, fixed for the whole sequence
B, C, Delta = np.array([0.4, 0.4]), np.array([0.4, 0.4]), 1.0 # also fixed
D = 0.0 # the feedthrough matrix from equation 1/3, not the model dimension -- see Section 2.1
Abar, Bbar = discretize(A, B, Delta)
print("Abar", np.round(Abar, 3), "Bbar", np.round(Bbar, 3))
# Abar [0.368 0.607] Bbar [0.253 0.315]
h = np.zeros(2)
for tok, xt in zip(tokens, x):
h = Abar * h + Bbar * xt
y = C @ h + D * xt # D = 0.0 throughout this post -- see Section 2.1
print(f"{tok:10s} h={np.round(h, 3)} y={round(float(y), 3)}")
# I h=[-0.253 -0.315] y=-0.227
# think h=[ 0.033 -0.034] y=-0.0
# I h=[-0.241 -0.335] y=-0.23
# understand h=[ 0.164 0.112] y=0.11
and are computed once, from Equation (2), and then reused unchanged at every one of the four steps — that single fact, that the same two numbers apply no matter which word just came in, is exactly what Why Fixed Parameters Can't Selectively Remember comes back to break.
3. Two Views of the Same Computation
3.1. Unrolling the recurrence into a convolution
Because Equation (3) is linear, it can be unrolled by hand, one substitution at a time, starting from (the same zero starting state every worked example in this post uses) and dropping the term, since Section 2.1 already set for the rest of this post — it would otherwise appear here as one more additive term, entirely outside the recurrence, since it never touches in the first place.
Step 1: unroll the state. Substitute Equation (3)'s own recurrence into itself — replace with , then replace that the same way, and so on, all the way back to :
Step 2: read it out through . Apply Equation (3)'s own output equation, (with ), to the unrolled from step 1 — every term of the sum just picks up a in front:
Every in Equation (4) is a weighted sum over a window of past inputs, not just alone — and the weights never depend on , only on how many steps back you're looking. So the same short list of weights applies at every position, just slid over by one step each time increases by one: always multiplies whichever token is current, always multiplies whichever token came one step before it, and so on, regardless of which actual tokens those happen to be. Sliding one fixed list of weights along a sequence and reading off a weighted sum of whatever currently falls under it is the literal definition of a (1D, causal) convolution — the same operation a CNN's kernel performs sliding across an image, just sliding along time here instead of space. Collect those weights into one vector, call it , and equation (4) becomes:
To see the sliding actually happen, reuse Section 2.3's own numbers — , , — and collapse the two state channels the same way does at each offset , giving four fixed kernel taps that hold for the whole sequence:
Sliding under ("I", "think", "I", "understand") so that always lines up with the current token, with one token back, with two back, and so on, gives:
the exact same four numbers Section 2.3's recurrence printed, one token at a time. Notice that never changed across those four sums — only which each one lined up with changed, sliding over by one position every step. That's exactly what a convolution kernel does: the same fixed weights, run over a shifting window of the input.
3.2. Training as convolution, generating as recurrence
This dual view is the whole reason pre-Mamba state space models like S4 [2] were fast to train in the first place.
Precompute once, from alone. Every tap involves only the model's fixed parameters, never the actual input — so it can be worked out up front, with no data in sight. is the answer to "if one unit of input had arrived steps ago and decayed through ever since, how much of it would still read off today?" That's the system's impulse response, and it's the whole reason Section 3.1's kernel is a fixed list of numbers in the first place, computable before a single real token shows up.
Training: skip entirely, and do every position at once. Once exists, reads straight off the raw inputs — never gets built at all. Convolving against the whole sequence the naive way — sliding it one step at a time, the way Section 3.1's four sums did by hand — costs . A convolution is exactly the computation the Fast Fourier Transform (FFT) is built to speed up: a decades-old algorithm, unrelated to state space models, that computes the same result in without sliding step by step at all. Either way, are independent sums over data that's already all there during training, so every one of them can be computed simultaneously — no waiting for to materialize before , the exact problem RNNs have.
Generation: no future to convolve over, so it's an RNN again. Tokens now arrive one at a time, and there's no "whole sequence" yet to slide across — tomorrow's tokens don't exist yet. So the same model switches to Equation (3)'s recurrent form instead, exactly like an RNN, with the same -per-step, constant-memory inference Pick Two: Parallelism, Speed, or Memory promised. Same , , ; two different ways of running the arithmetic, picked for whichever situation — training on a fixed sequence, or generating one token at a time — you're actually in.
4. Why Fixed Parameters Can't Selectively Remember
Section 1.2 ended on a promise: build a recurrence competitive with attention's quality, not just its speed. Attention gets that quality by looking back over every previous token at each step and weighting them by content — a filler word and the key noun get different weights, regardless of distance. It can do this because it never compresses: every past token's key and value is kept, which is exactly the growing cache Section 9.1 measures. That per-token, content-based weighting is what "selectively remember" means here.
A recurrence works the opposite way: it never keeps individual tokens, only one fixed-size state , updated as each token arrives. Whatever the update doesn't capture is lost. That's what makes it cheap — but it means the update itself has to do attention's job: decide, per token, how much to keep. 4.1 shows that with fixed, it can't.
4.1. One rule for every token
Go back to the worked example in Discrete recurrence, and a worked
example. and came out to
[0.368 0.607] and [0.253 0.315] — and stayed exactly those two numbers at "I", at "think", and
at "understand". The model has no way to look at and decide "this one matters more" or
"this one's just a filler word, don't bother updating the state much" — the update rule is
identical no matter what token just arrived, because and are constants set
before the sequence is even seen.
4.2. Where this actually breaks: selective copying
This isn't hypothetical. The Mamba paper tests it directly with a synthetic benchmark, selective copying: most tokens are blank filler, a few are real content, and the task is to reproduce just the content, in order, ignoring the filler. Unlike "I" in the sentence example, which still carries some signal, these filler tokens carry none — the contrast is total. Solving this requires the recurrence to decide, per token, whether to write to memory at all: barely touch it for filler, write firmly for content.
A fixed can't make that decision, because the decision requires looking at the content of , and fixed parameters are chosen before the model ever sees the input.
A system with a constant , , is called linear time-invariant (LTI) — "time-invariant" meaning the rule itself is the same at every position, regardless of position or content. LTI is exactly what makes the convolution view possible: a fixed kernel only makes sense if the same weights genuinely apply at every offset. Section 6 comes back to this trade directly — selectivity and the convolution view turn out to be mutually exclusive.
5. The Selection Mechanism: Let the Input Set the Dials
5.1. Three small projections, read off the input
isn't one of the original state-space matrices — it's the step size from Discretization: turning a rate into a step. Equation (2) already showed what it does: a large drains toward zero and grows , so the old state drains away and the current token dominates; a tiny leaves and , so almost nothing happens. That's the write-time lever Section 4 was missing — it just needs to depend on the token instead of staying fixed. and are simpler: controls how much of the input enters the state, controls how much of the state reaches the output. Mamba makes all three functions of the current token , computed fresh at every step:
itself stays fixed — it's still initialized once, the way HiPPO[3] theory prescribes for S4-style models — but feeds into
from Equation (2), so the discretized ends up
input-dependent anyway, indirectly, through . softplus — — just keeps strictly positive, the way a step size has to be, while still
letting push it arbitrarily large or small.
5.2. Δ as a gate
The bucket metaphor from The continuous equations, and a bucket to picture them with explains exactly what a selective buys: a small means and (Equation (2)) — almost none of this token gets poured in, and almost all of the old water level survives untouched. A large does the opposite — shrinks toward zero and grows, so the old state mostly drains away and the new token dominates what's left. is, in effect, a per-token gate deciding "skip this" versus "reset around this" — precisely the decision a fixed could never make.
5.3. Worked example: the same four tokens, now selective
Assign each token in "I think I understand" a scalar chosen so "I" reads as a filler and "understand" reads as the sentence's actual content — the same values used in Discrete recurrence, and a worked example's STATIC run, run again through Equation (6) instead of fixed constants:
def softplus(z):
return np.log1p(np.exp(z))
w_B, w_C, w_delta, b_delta = np.array([0.4, 0.4]), np.array([0.4, 0.4]), 2.0, 0.0
h = np.zeros(2)
for tok, xt in zip(tokens, x):
Delta_t = softplus(w_delta * xt + b_delta)
B_t, C_t = w_B * xt, w_C * xt
Abar_t, Bbar_t = discretize(A, B_t, Delta_t)
h_prev = h.copy()
h = Abar_t * h + Bbar_t * xt
y = C_t @ h + D * xt # D is still 0.0, reused unchanged from the STATIC example above
print(f"{tok:10s} Delta={Delta_t:.3f} Abar={np.round(Abar_t, 3)} "
f"h_prev={np.round(h_prev, 3)} -> h={np.round(h, 3)} y={round(float(y), 3)}")
# I Delta=0.127 Abar=[0.881 0.939] h_prev=[0. 0.] -> h=[0.048 0.049] y=-0.039
# think Delta=1.313 Abar=[0.269 0.519] h_prev=[0.048 0.049] -> h=[0.086 0.122] y=0.042
# I Delta=0.127 Abar=[0.881 0.939] h_prev=[0.086 0.122] -> h=[0.123 0.163] y=-0.115
# understand Delta=2.127 Abar=[0.119 0.345] h_prev=[0.123 0.163] -> h=[0.367 0.58 ] y=0.379
Compare the two "I" rows against the "understand" row: at "I", sits near
[0.88, 0.94] — almost all of survives, and barely moves ([0.086 0.122] to
[0.123 0.163], a small nudge). At "understand", drops to [0.12, 0.35] — most of the
old state drains away — and jumps ([0.123 0.163] to [0.367 0.58], roughly triple). Nothing
in the STATIC run could ever produce that
contrast, because and there were the same two numbers at every single token,
filler or not.
6. The Price of Selection: No More Convolution
Two Views of the Same Computation's convolution kernel only makes sense if and are the same matrices at every offset — that's what let a single kernel, computed once, apply uniformly across the whole sequence. The moment and change at every , as Equation (6) makes them, there is no longer one kernel to compute — there'd need to be a different kernel at every position, which isn't a convolution at all anymore. Selectivity buys exactly the thing Why Fixed Parameters Can't Selectively Remember was missing, but it costs the FFT-parallel training trick that made LTI state space models fast, seemingly forcing a return to the RNN's one-step-at-a-time training. Section 7 is Mamba's answer to that trade-off.
7. Parallel Scan: Getting Parallelism Back
7.1. An operator that composes affine steps
Equation (3)'s recurrence, , is an affine map applied to at every step: write it as , with standing for and for . Chain two consecutive steps, then , by substituting the first directly into the second:
Whatever turns out to be, running step then step is identical to running one combined step with slope and intercept — and neither of those two numbers involves at all. isn't two different tokens' getting mixed together; by the time step runs, is just a fixed number already sitting in , and decays it the same way the bucket drains whatever it's already holding. Collect the two combined numbers into a pair, and this substitution is the combine operator:
Because ordinary multiplication and addition are associative, is too: gives the same pair as . That's the one property a parallel scan (or prefix-sum) algorithm needs: an associative operator can be applied to disjoint chunks of a sequence independently, then those chunk-results combined afterward, rather than folding strictly left to right — and since never touches , two far-apart steps can be combined before either one ever sees the actual running state.
7.2. Worked example: four steps in two rounds
Take the four (Abar, Bbar·x) pairs from Worked example: the same four tokens, now selective — tracking just the first of the two state channels, to keep the arithmetic on one line — and combine them pairwise instead of one at a time:
def combine(P, Q):
aP, bP = P
aQ, bQ = Q
return (aP * aQ, aQ * bP + bQ)
# (Abar[0], Bbar[0]*x) for I, think, I, understand -- channel 0 only, from section 5.3.
# Bbar[0] for "I" is -0.048, but x is -1.0 there too, so Bbar[0]*x = +0.048.
steps = [(0.881, 0.048), (0.269, 0.073), (0.881, 0.048), (0.119, 0.352)]
P12 = combine(steps[0], steps[1])
P34 = combine(steps[2], steps[3])
P14 = combine(P12, P34)
print("P12", np.round(P12, 3), "P34", np.round(P34, 3), "P14", np.round(P14, 3))
# P12 [0.237 0.086] P34 [0.105 0.358] P14 [0.025 0.367]
h4 = P14[0] * 0.0 + P14[1] # h_{-1} = 0
print(round(h4, 3))
# 0.367
0.367 is exactly the first entry of h after "understand" in the selective worked
example — computed here without ever
folding left to right through all four steps in sequence. P12 and P34 can be computed at the
same time, since neither depends on the other, and only the final combine(P12, P34) waits on
both — two sequential "rounds" for four tokens instead of four, and for tokens this generalizes
to rounds instead of .
7.3. Hardware-aware: why this still needs custom kernels
The algorithm above is parallel on paper, but naively materializing every intermediate pair for every channel and every batch element is a lot of memory traffic — the actual Mamba implementation fuses discretization, the scan, and the final elementwise multiply into a single GPU kernel that keeps intermediate values in fast on-chip SRAM and only writes the final output back to slower HBM, an optimization in the same spirit as FlashAttention's approach to fusing attention's own intermediate steps. None of that changes the math in Equation (8) — it changes only how cheaply a GPU can execute it.
8. The Mamba Block: Assembling the Layer
8.1. Expand, branch, gate, project
Everything so far — discretization, selection, the scan — lives inside a single sub-block called the selective SSM (SSM: state space model, the machinery built in Sections 2-7). A full Mamba block wraps that sub-block the same way a Transformer block wraps self-attention: a linear projection first, the core computation in the middle, a linear projection back out, all inside a residual connection.
The input is projected up to a wider dimension and split into two branches. The first branch goes through a small causal depthwise convolution — a short local look-back, mixing each position with the handful just before it, before the SSM ever sees it — then a SiLU nonlinearity (, a smooth alternative to ReLU that lets small negative values through instead of zeroing them), then the selective SSM itself, exactly as derived in The Selection Mechanism. The second branch is a pure gate: SiLU applied to a separate linear projection of the same input, with no SSM in it at all. The two branches meet only at , an elementwise product — the gate branch gets to scale down or shut off individual channels of the SSM's output before the final projection brings the dimension back down to 's size. This design — a convolution-then-SSM branch multiplicatively gated by a second branch — comes from H3 [4], folded together with a standard gated-MLP block; Mamba's own contribution on top is entirely inside the "SSM" box, not the branching structure around it.
8.2. Where this sits in a full model
A full Mamba model is a stack of these blocks, each one wrapped in the same pre-norm-plus-residual pattern a Transformer block uses — a normalization layer, then the block, then added back to its own input — repeated some number of layers deep, with an embedding table at the bottom and a vocabulary projection at the top, exactly like the decoder post's own architecture. The only thing that changes, layer to layer, is what sits between the two residual connections: self-attention there, this block here.
9. What Mamba Buys You, and What It Costs
9.1. A state that never grows
The decoder post's causal masking section already established why generating the -th token with a Transformer means keeping every previous position's key and value vectors around — a cache that grows linearly with how much has been generated so far, and gets attended to in full at every subsequent step. Mamba's inference state is the hidden vector from Equation (3), alone — a fixed size, set by the state dimension , that never grows no matter how long the sequence gets. Generating token 10,000 costs exactly what generating token 10 cost, an actual -per-step guarantee Pick Two: Parallelism, Speed, or Memory opened this post with.
9.2. Where attention still wins
That fixed-size state is also the honest limitation: a Transformer's KV cache is, in effect, perfect memory — token 10,000 can attend directly back to token 1's exact key and value, no matter how much has happened in between. Mamba's has to compress everything worth remembering into one -dimensional vector, and doesn't grow with the sequence. Tasks that need exact long-range recall — repeating back a specific token from thousands of positions ago, verbatim — are precisely where a fixed-size state is most likely to have already overwritten what it needed. This is an active area past the original paper: Mamba-2 [5] reframes the selective SSM as a form of linear attention to close part of that gap, and several hybrid architectures interleave ordinary attention layers with SSM layers rather than picking one mechanism for an entire model — betting that a few layers of perfect, expensive memory plus many layers of cheap, approximate memory beats either extreme alone.
10. Putting It All Together
10.1. Discretize and scan
The two functions this whole post is really about — both already exhaustively worked by hand above — as plain, runnable NumPy:
import numpy as np
def softplus(z):
return np.log1p(np.exp(z))
def discretize(A, B, Delta):
"""Zero-order hold, equation 2. A: (N,) diagonal entries. B: (N,). Delta: scalar."""
Abar = np.exp(Delta * A)
Bbar = (Abar - 1.0) / A * B
return Abar, Bbar
def selective_ssm_step(h_prev, x_t, A, W_B, W_C, w_delta, b_delta, D):
"""One selective-SSM step (equations 3 and 6). h_prev, A, W_B, W_C: (N,). x_t, w_delta,
b_delta, D: scalars, since this reference sticks to one channel throughout. D here is
equation 1/3's feedthrough matrix, not the model-dimension D used elsewhere in this post's
prose -- pass D=0.0 to match every worked example above (Section 2.1). Returns the new
hidden state and this step's output."""
Delta_t = softplus(w_delta * x_t + b_delta)
B_t, C_t = W_B * x_t, W_C * x_t
Abar_t, Bbar_t = discretize(A, B_t, Delta_t)
h_t = Abar_t * h_prev + Bbar_t * x_t
y_t = C_t @ h_t + D * x_t
return h_t, y_t
def selective_scan(x, A, W_B, W_C, w_delta, b_delta, D):
"""Sequential reference scan (Section 5.3's worked loop, generalized). A real
implementation replaces this loop with the parallel-scan/combine of Section 7, computed
inside a fused GPU kernel -- the output is identical either way."""
h = np.zeros_like(A)
ys = []
for x_t in x:
h, y_t = selective_ssm_step(h, x_t, A, W_B, W_C, w_delta, b_delta, D)
ys.append(y_t)
return np.array(ys)
10.2. The Mamba block
Section 8's branching structure (Equation (9)), with the selective SSM above as its core:
def silu(z):
return z / (1.0 + np.exp(-z))
def causal_conv1d(u, kernel):
"""Depthwise causal convolution: position t only ever sees positions <= t. kernel: a short
per-channel filter, applied independently to each channel of u."""
L = len(u)
k = len(kernel)
padded = np.concatenate([np.zeros(k - 1), u]) # left-pad so output length stays L
return np.array([np.dot(padded[t:t + k], kernel) for t in range(L)])
def mamba_block(u0, W_in, conv_kernel, A, W_B, W_C, w_delta, b_delta, D, W_out):
"""u0: (L,) input sequence, one scalar channel, matching this post's worked examples.
W_in projects to two branches; W_out projects the gated result back down (equation 9)."""
projected = u0 * W_in # (L, 2) -- toy stand-in for Linear_in
u, z = projected[:, 0], projected[:, 1]
u_conv = causal_conv1d(u, conv_kernel)
u_prime = silu(u_conv)
y = selective_scan(u_prime, A, W_B, W_C, w_delta, b_delta, D)
gated = y * silu(z)
return gated * W_out # toy stand-in for Linear_out
causal_conv1d and the two toy W_in/W_out stand-ins are simplified to a single scalar channel
throughout, matching every worked example in this post's model dimension of 1; a real Mamba layer
runs this same logic independently across however many channels the model's expanded inner
dimension has, and
selective_scan's sequential Python loop is exactly what Parallel Scan's
combine operator replaces on real hardware — different order of operations, identical numbers
out.
Appendix: Where Equation 2 Actually Comes From
Discretization: turning a rate into a step stated Equation (2) without deriving it, and promised that the small- behavior it produces — , — isn't a designed-in limit but a mathematical consequence of exactly solving Equation (1). This appendix makes good on that promise, spelling out every step of the derivation, including the two calculus facts it leans on (how to differentiate a matrix exponential, and the product rule) rather than assuming them.
The setup. Zero-order hold assumes the input is held constant at for one whole step (that's the "hold" in the name — the simplest way to turn one discrete sample into a continuous signal is to hold it flat until the next sample arrives). Re-anchor time so that step runs from to , with . Equation (1)'s state equation, with now a fixed constant instead of a function of time, becomes an ordinary linear differential equation with constant forcing:
A refresher: differentiating the matrix exponential. The rest of this derivation leans on one calculus fact: , for any fixed matrix . This isn't special to matrices — it's true for exactly the same reason is true for a plain number , because is defined by the same power series as the scalar exponential, just with in place of a number:
Differentiating term by term with respect to (each term is just raised to a power, times a constant matrix, so ordinary single-variable calculus applies to each one separately):
Setting gives the specific fact this derivation needs: .
The product rule, spelled out. For two ordinary functions and , the product rule says — the same rule taught in any first calculus course. It holds just as well when is a matrix-valued function and is a vector-valued one, for the same reason the sum and constant-multiple rules carry over: differentiation is applied entry by entry, and every entry of is an ordinary sum of products of scalar functions, each of which obeys the scalar product rule individually.
Apply it with and , using from the step above:
commutes with any power series built out of — including , which is exactly such a series — so can be rewritten as , and the two terms above factor together:
The ODE from the setup says , i.e. . Substituting that in:
This is the entire point of multiplying by in the first place (the classical "integrating factor" trick): it turns a differential equation that mixes and into a plain derivative of a single product, sitting on the left, with nothing but a function of on the right — which can now just be integrated directly.
Integrating both sides. Integrate from to . The left side is the derivative of something, so the fundamental theorem of calculus collapses it to the difference of its endpoint values:
On the right, is constant over the step by assumption, so it factors outside the integral, leaving just the integral of a matrix exponential:
The integral of a matrix exponential. Claim: . Check it the same way any antiderivative gets checked — differentiate it and confirm the original function comes back, using from two steps ago:
So the claimed antiderivative is correct, and:
Solving for . Putting the left and right sides from "Integrating both sides" back together:
Multiply both sides by (the inverse of , since for any matrix):
Simplify the second term the same way as before — (and its inverse) commute with :
giving:
That's Equation (2), exactly — , and , the same thing as once the 's are multiplied back in and canceled (kept explicit in that form since is about to become a learned, per-token parameter in The Selection Mechanism). Nothing in this derivation was chosen to produce a particular limit — every step above is either the definition of the matrix exponential, the ordinary product rule, or the fundamental theorem of calculus.
A concrete check. Plug in the toy and — the same two values the worked example uses for its full recurrence — at :
import numpy as np
A = np.array([-1.0, -0.5])
B = np.array([0.4, 0.4])
Abar = np.exp(1.0 * A)
Bbar = (Abar - 1.0) / A * B
print(np.round(Abar, 3), np.round(Bbar, 3))
# [0.368 0.607] [0.253 0.315]
That matches and exactly as the worked example computes them too, because it's the same formula being evaluated on the same numbers — the derivation above didn't introduce any new approximation on top of what equation 2 already says.
Why the small- limit isn't tuned. Expand using the same power series introduced above, . As , every term past the first vanishes, so — not because was built to converge to , but because any convergent power series in reduces to its constant term as , and here the constant term happens to be . The same expansion, substituted into , gives — so linearly in , not because it was designed to shrink, but because that's what's left of the expansion once the constant term cancels against the . Checking this numerically, at against the same toy and :
Abar_tiny, Bbar_tiny = np.exp(0.01 * A), (np.exp(0.01 * A) - 1.0) / A * B
print(np.round(Abar_tiny, 5), np.round(Bbar_tiny, 5), np.round(0.01 * B, 5))
# [0.99005 0.99501] [0.00398 0.00399] [0.004 0.004]
already sits within rounding error of , exactly as the first-order Taylor term predicts. This is why the "gate" reading of isn't a second mechanism layered on top of discretization — it's a direct consequence of solving equation 1 exactly, one that would show up in any discretization built the same way. (S4's earlier variants also tried the bilinear/Tustin transform instead of zero-order hold; both satisfy the same small- limit, since that's a property every consistent discretization of an ODE has to have, not something specific to the zero-order-hold choice.)
References
- [1]Gu & Dao (2023). Mamba: Linear-Time Sequence Modeling with Selective State Spaces.
- [2]Gu, Goel & Ré (2021). Efficiently Modeling Long Sequences with Structured State Spaces. International Conference on Learning Representations (ICLR).
- [3]Gu, Dao, Ermon, Rudra & Ré (2020). HiPPO: Recurrent Memory with Optimal Polynomial Projections. Neural Information Processing Systems (NeurIPS).
- [4]Fu, Dao, Saab, Thomas, Rudra & Ré (2023). Hungry Hungry Hippos: Towards Language Modeling with State Space Models. International Conference on Learning Representations (ICLR).
- [5]Dao & Gu (2024). Transformers are SSMs: Generalized Models and Efficient Algorithms Through Structured State Space Duality. International Conference on Machine Learning (ICML).