Selective State Space Models

Selective State Space Models

The previous section ended on a confession: the S4D is LTI. Its kernel weights the past by position, never by content: the model decides what to remember before it sees the input.

The LSTM had the opposite profile: gates that read the data, but a nonlinear recurrence that trains sequentially.

This section: make the dynamics a function of the input (Gu & Dao, 2023):

  • the forget gate falls out a third time (engineering → calculus → selectivity),
  • the scan survives; the kernel view does not,
  • packaged as the Mamba block (2023): selective recurrence competitive at language-model scaling — ground S4’s long-range wins had not reached.

A task that defeats time invariance

Selective copying: symbols scattered among filler at random positions; reproduce them, in order, at the query slots.

  • Store a token because it is a symbol, not because of where it sits.
  • LM-scale counterpart: associative recall: the induction head we built in ch. 10; a synthetic recall benchmark predicts most of the attention-vs-SSM gap (Arora et al., 2024).

Generating the task

Filler is token 0, queries are token 1, symbols are ids 2-9:

def selective_copy(num_seqs, num_steps, num_marked, num_symbols, seed=42):
    """Sequences of filler (0) with num_marked symbols at random positions,
    followed by num_marked query slots (1); targets are the symbols."""
    rng = np.random.default_rng(seed)
    X = np.zeros((num_seqs, num_steps + num_marked), dtype=np.int64)
    X[:, num_steps:] = 1                          # Query slots
    Y = rng.integers(2, 2 + num_symbols, (num_seqs, num_marked))
    pos = np.argsort(rng.random((num_seqs, num_steps)), axis=1)
    pos = np.sort(pos[:, :num_marked], axis=1)    # Marked positions, in order
    np.put_along_axis(X, pos, Y, axis=1)
    return X, Y - 2                               # Classes 0..num_symbols-1

class SelectiveCopy(d2l.DataModule):
    def __init__(self, num_train=8192, num_val=1024, batch_size=128,
                 num_steps=256, num_marked=4, num_symbols=8):
        super().__init__()
        self.save_hyperparameters()
        X, Y = selective_copy(num_train + num_val, num_steps, num_marked,
                              num_symbols)
        self.X, self.Y = d2l.tensor(X), d2l.tensor(Y)

    def get_dataloader(self, train):
        idx = slice(0, self.num_train) if train else slice(self.num_train, None)
        return self.get_tensorloader([self.X, self.Y], train, idx)

copy_data = SelectiveCopy()

Two witnesses from earlier sections

The S4D stack of the previous section (imported from d2l) vs. the LSTM, both ~30k parameters, same harness: embed, encode, classify the query slots.

class CopyModel(d2l.Classifier):
    """Read a token sequence; predict the symbols at the query slots."""
    def __init__(self, encoder, num_hiddens, vocab_size=10, num_marked=4,
                 num_symbols=8, num_features=None, lr=3e-3, rngs=None):
        super().__init__()
        self.save_hyperparameters(ignore=['encoder', 'rngs'])
        rngs = nnx.Rngs(1) if rngs is None else rngs
        num_features = num_hiddens if num_features is None else num_features
        self.encoder = encoder
        # Embed by one-hot matmul: on a table this tiny (ten rows), the
        # scatter-add in an embedding lookup's gradient makes 33,000 colliding
        # updates to ten rows and serializes the whole training step on GPU;
        # as a dense product, forward and backward are ordinary matmuls
        self.emb = nnx.Linear(vocab_size, num_hiddens, use_bias=False,
                              rngs=rngs)
        self.head = nnx.Linear(num_features, num_symbols, rngs=rngs)

    def forward(self, X):
        E = self.emb(jax.nn.one_hot(X.T, self.vocab_size))
        Y = self.encoder(E)                             # (T, batch, hiddens)
        Y = Y[0] if isinstance(Y, tuple) else Y         # RNNs return a state
        return self.head(Y[-self.num_marked:]).swapaxes(0, 1)

    def configure_optimizers(self):
        return optax.adam(self.lr)
copy_curves = {}

def train_copy(name, model, data, epochs=32):
    trainer = d2l.Trainer(max_epochs=epochs, gradient_clip_val=1, num_gpus=1)
    trainer.fit(model, data)
    pts = model.board.data['val_acc']
    copy_curves[name] = ([p.x for p in pts], [float(p.y) for p in pts])
    print(f'{name}: final validation accuracy {copy_curves[name][1][-1]:.3f}')

s4d = CopyModel(nnx.Sequential(*[S4DBlock(48, 4) for _ in range(2)]),
                num_hiddens=48)
train_copy('S4D', s4d, copy_data)

S4D: final validation accuracy 0.658

The LTI model stalls; the gated one solves it

names = list(copy_curves)
d2l.plot([copy_curves[n][0] for n in names],
         [copy_curves[n][1] for n in names], 'epoch',
         'validation accuracy', legend=names, figsize=(5, 3))

  • LSTM: climbs to solved. Gates read the data; “store this, it is a symbol” is learnable.
  • S4D: plateaus. Pointwise nonlinearities can suppress filler locally, but a fixed kernel cannot align “third symbol seen” with “third query slot” under variable spacing.
  • The task is built to stress content-independent dynamics — the mechanism the S4D lacks is content-dependent gating.

Selective SSMs: let the input choose the dynamics

One change to the S4D recipe: step size, input matrix, read-out become functions of the input,

\boldsymbol{\Delta}_t = \textrm{softplus}(\mathbf{u}_t \mathbf{W}_{\Delta} + \mathbf{b}_{\Delta}), \qquad \mathbf{B}_t = \mathbf{u}_t \mathbf{W}_B, \qquad \mathbf{C}_t = \mathbf{u}_t \mathbf{W}_C,

\mathbf{x}_t = e^{\Delta_{t,h} \mathbf{a}} \odot \mathbf{x}_{t-1} + \Delta_{t,h}\, u_{t,h}\, \mathbf{B}_t.

Recall the step-size box: \Delta \to 0 freezes the state, \Delta large overwrites it. Input-dependent \Delta_t = forget and input gate in one scalar, on a linear state:

  • filler → \Delta_t \approx 0: glide through, untouched;
  • symbol → \Delta_t opens: write.

The gate, derived a third time.

The price and the save

Price: time-varying coefficients kill the convolution view. No fixed \bar{\mathbf{K}}, no FFT. Of the three views only the recurrence survives.

Save: the recurrence is still an affine map of the state; the combine (\mathbf{a}_2, \mathbf{b}_2) \circ (\mathbf{a}_1, \mathbf{b}_1) never assumed constant coefficients. Same scan, per-step tensors:

num_steps, num_states = 100, 4
key1, key2 = jax.random.split(d2l.get_key())
a_t = jax.random.uniform(key1, (num_steps, num_states))  # Decays in (0, 1)
b_t = jax.random.normal(key2, (num_steps, num_states))   # Per-step inputs
h, ys = jnp.zeros(num_states), []
for t in range(num_steps):
    h = a_t[t] * h + b_t[t]
    ys.append(h)
err = jnp.abs(associative_scan(a_t, b_t) - jnp.stack(ys)).max()
print(f'time-varying scan vs loop: {float(err):.2e}')
time-varying scan vs loop: 4.77e-07

The selective SSM layer

Three changes vs. S4D: \Delta, \mathbf{B}, \mathbf{C} from linear heads; low-rank \Delta head (dt_rank); bias init gives the untrained layer S4D’s multi-timescale decays (\mathbf{B}_t, \mathbf{C}_t are input-dependent from the start):

class SelectiveSSM(nnx.Module):
    """A diagonal SSM whose step size, input matrix, and read-out are
    functions of the input (Gu & Dao, 2023)."""
    def __init__(self, num_hiddens, num_states=4, dt_min=0.001, dt_max=0.1,
                 rngs=None):
        rngs = nnx.Rngs(0) if rngs is None else rngs
        H, N, R = num_hiddens, num_states, max(2, num_hiddens // 16)
        self.log_a = nnx.Param(jnp.tile(jnp.log(jnp.arange(1., N + 1)),
                                        (H, 1)))
        self.W_dt = nnx.Sequential(
            nnx.Linear(H, R, rngs=rngs),
            nnx.Linear(R, H, use_bias=False, rngs=rngs))
        dt = jnp.exp(rngs.params.uniform((H,)) * math.log(dt_max / dt_min)
                     + math.log(dt_min))
        self.b_dt = nnx.Param(dt + jnp.log(-jnp.expm1(-dt)))
        self.W_B = nnx.Linear(H, N, use_bias=False, rngs=rngs)
        self.W_C = nnx.Linear(H, N, use_bias=False, rngs=rngs)
        self.D = nnx.Param(jnp.ones(H))

    def __call__(self, u):                   # (num_steps, batch, num_hiddens)
        a = -jnp.exp(self.log_a[...])                 # (H, N), Re(a) < 0
        dt = jax.nn.softplus(self.W_dt(u) + self.b_dt)    # (T, batch, H)
        B, C = self.W_B(u), self.W_C(u)               # (T, batch, N)
        a_bar = jnp.exp(dt[..., None] * a)            # (T, batch, H, N)
        b_bar = (dt * u)[..., None] * B[..., None, :]
        x = associative_scan(a_bar, b_bar)
        return (x * C[..., None, :]).sum(-1) + self.D * u

Why 2023 and not 2020: hardware-awareness

Our implementation materializes (T, batch, H, N) coefficient tensors: fine at textbook scale, ruinous at model scale (the compute is trivial; memory traffic sets the speed).

The Mamba kernel:

  • fuses discretization + scan + read-out in one pass, intermediates in on-chip SRAM,
  • recomputes them in the backward pass instead of storing (store-vs-recompute, cf. 8.6),
  • changes nothing about what is computed, only whether it was worth computing.

The Mamba block

Block and language model

A dozen lines around SelectiveSSM; the stack keeps the (inputs, state) interface, so d2l.RNNLM wraps it unchanged:

class MambaBlock(nnx.Module):
    """Conv + SiLU + selective SSM, gated, inside a pre-norm residual."""
    def __init__(self, num_hiddens, num_states=4, expand=2, conv_width=4,
                 dropout=0, rngs=None):
        rngs = nnx.Rngs(0) if rngs is None else rngs
        d = expand * num_hiddens
        self.ln = nnx.LayerNorm(num_hiddens, rngs=rngs)
        self.W_in = nnx.Linear(num_hiddens, 2 * d, rngs=rngs)
        self.conv = nnx.Conv(d, d, kernel_size=(conv_width,),
                             feature_group_count=d, padding='CAUSAL',
                             rngs=rngs)
        self.ssm = SelectiveSSM(d, num_states, rngs=rngs)
        self.W_out = nnx.Linear(d, num_hiddens, rngs=rngs)
        self.drop = nnx.Dropout(dropout, rngs=rngs)

    def __call__(self, X):                   # (num_steps, batch, num_hiddens)
        u, gate = jnp.split(self.W_in(self.ln(X)), 2, axis=-1)
        u = jnp.swapaxes(self.conv(jnp.swapaxes(u, 0, 1)), 0, 1)
        y = self.ssm(jax.nn.silu(u))
        return X + self.drop(self.W_out(y * jax.nn.silu(gate)))

class Mamba(nnx.Module):
    """A stack of Mamba blocks with the recurrent-cell interface."""
    def __init__(self, num_inputs, num_blocks=2, num_states=4, dropout=0,
                 rngs=None):
        rngs = nnx.Rngs(0) if rngs is None else rngs
        self.num_inputs = self.num_hiddens = num_inputs
        self.blocks = nnx.Sequential(*[
            MambaBlock(num_inputs, num_states, dropout=dropout, rngs=rngs)
            for _ in range(num_blocks)])
        self.ln = nnx.LayerNorm(num_inputs, rngs=rngs)

    def __call__(self, X, state=None):
        return self.ln(self.blocks(X)), None

Capstone: the chapter’s three answers on one task

Gate it (LSTM) vs. linearize it (minGRU) vs. select it (Mamba); Time Machine BPE, 50k windows of 32, ten epochs, clip 1:

print(f'{"model":>7} {"val ppl":>8} {"bpb":>6} {"params":>9} {"s/epoch":>8}')
for name, (ppl, bpb, params, secs) in results.items():
    print(f'{name:>7} {ppl:>8.1f} {bpb:>6.2f} {params:>9,} {secs:>8.1f}')
  model  val ppl    bpb    params  s/epoch
   LSTM     90.7   2.34   297,027      1.5
 minGRU     85.3   2.31   214,851      1.0
  Mamba     88.9   2.33   488,739      2.5
  • Mamba: best perplexity of the chapter in the PyTorch run; the minGRU edges it out in the JAX run.
  • At this scale, recipe and initialization move the board by amounts comparable to the architectural gap.
  • Caveats: Adam vs. the baselines’ SGD; more parameters; a small corpus rewards memorization.

Stepping the selective model

Selectivity does not break stepping: \Delta_t, \mathbf{B}_t, \mathbf{C}_t come from the current token, so each step builds its own coefficients. One new piece of state: the causal conv needs its last 4 inputs → a rolling buffer: (conv buffer, SSM state) is exactly what production Mamba caches.

@d2l.add_to_class(SelectiveSSM)
def step(self, u, x=None):
    """Advance one token: u is (batch, H); x is the (batch, H, N) state."""
    a = -jnp.exp(self.log_a[...])
    dt = jax.nn.softplus(self.W_dt(u) + self.b_dt)    # (batch, H)
    B, C = self.W_B(u), self.W_C(u)                   # (batch, N)
    a_bar = jnp.exp(dt[..., None] * a)                # (batch, H, N)
    b_bar = (dt * u)[..., None] * B[..., None, :]
    x = b_bar if x is None else a_bar * x + b_bar
    return (x * C[..., None, :]).sum(-1) + self.D * u, x

@d2l.add_to_class(MambaBlock)
def step(self, X, state=None):
    """Advance one token, carrying (conv buffer, SSM state)."""
    u, gate = jnp.split(self.W_in(self.ln(X)), 2, axis=-1)
    if state is None:
        state = (jnp.zeros((u.shape[0], self.conv.kernel_size[0],
                            u.shape[-1])), None)
    buf, x = state
    buf = jnp.concatenate([buf[:, 1:], u[:, None]], 1)    # Roll the window
    u = (buf * self.conv.kernel[:, 0]).sum(1) + self.conv.bias
    y, x = self.ssm.step(jax.nn.silu(u), x)
    return X + self.drop(self.W_out(y * jax.nn.silu(gate))), (buf, x)

@d2l.add_to_class(Mamba)
def step(self, X, state=None):
    """Advance the stack one token: X is (batch, d); one state per block."""
    state = ([None] * len(self.blocks.layers) if state is None
             else list(state))
    for i, blk in enumerate(self.blocks.layers):
        X, state[i] = blk.step(X, state[i])
    return self.ln(X), state

Stepped == scanned, on the trained LM

Push validation windows through the trained capstone twice: parallel scan vs. token-by-token step; the logits must agree (dropout off):

eval_lm = nnx.view(mamba_lm, deterministic=True, use_running_average=True,
                   raise_if_not_found=False)     # Dropout off
X = jnp.asarray(data.X[data.num_train:data.num_train + 8])
with jax.default_matmul_precision('float32'):    # TF32 off, as before
    logits_scan = eval_lm(X)                     # (batch, T, vocab)
    state, cols = None, []
    for t in range(X.shape[1]):                  # One token at a time
        emb = eval_lm.emb(X[:, t][None])         # (1, batch, d)
        y, state = eval_lm.rnn.step(emb[0], state)
        cols.append(eval_lm.linear(y))
    logits_step = jnp.stack(cols, 1)
err = float(jnp.abs(logits_step - logits_scan).max())
scale = float(jnp.abs(logits_scan).max())
print(f'stepped vs scanned logits: deviation {err:.2e}, '
      f'relative {err / scale:.2e}')
assert err < 1e-3 * scale
stepped vs scanned logits: deviation 4.77e-06, relative 4.38e-07

Sampling all three (8.7’s toolkit)

Baselines re-run the prefix per token; Mamba decodes through step: prefill the prompt once, then one state update per token: O(1), like a KV cache that never grows.

def step_fn(model):
    model = nnx.view(model, deterministic=True, use_running_average=True,
                     raise_if_not_found=False)  # Dropout off for sampling
    def step(ids):  # Token ids in, numpy logits for the next token out
        return d2l.numpy(model(d2l.tensor([ids])))[0, -1]
    return step

def stepped_fn(model):
    model = nnx.view(model, deterministic=True, use_running_average=True,
                     raise_if_not_found=False)  # Dropout off for sampling
    state, seen = None, 0
    def step(ids):  # Consume only the tokens the state has not absorbed
        nonlocal state, seen
        for i in ids[seen:]:
            emb = model.emb(jnp.array([[i]]))
            y, state = model.rnn.step(emb[0], state)
        seen = len(ids)
        return d2l.numpy(model.linear(y))[0]
    return step

prefix = data.tokenizer.encode('the time traveller')
for name, model, fn in [('LSTM', lstm_lm, step_fn),
                        ('minGRU', mingru_lm, step_fn),
                        ('Mamba', mamba_lm, stepped_fn)]:
    out = d2l.generate(fn(model), prefix, 25, strategy='sample',
                       temperature=1.0, min_p=0.1,
                       rng=np.random.default_rng(0))
    print(f'{name:>7}: {data.tokenizer.decode(out)!r}')
   LSTM: 'the time traveller. I had a clighted, and soft,\nand resence of the world, and it was in'
 minGRU: "the time travellering, and the ending of the haility of the Time Machine, and then the Psychologist. I'\nf"
  Mamba: 'the time traveller, I was slored and fruit of the delicate entands of the sphinx.'

Selective copying, revisited

names = list(copy_curves)
d2l.plot([copy_curves[n][0] for n in names],
         [copy_curves[n][1] for n in names], 'epoch',
         'validation accuracy', legend=names, figsize=(5, 3))

Mamba solves what the S4D could not, faster than the LSTM. The content-dependent gating we deleted to linearize is restored, without giving up the scan.

The mechanism, measured

Designed: filler collapses \Delta_t, symbols open it. Measured, on the trained model (one forward pass, no retraining):

The narrated gate is what training actually found.

Summary: one question, three answers (so far)

Selectivity from the state-space side: continuous dynamics, discretize, let the input set the step size. Trains as a scan, decodes as a constant-size state.

Next section, the other road: start from the linear-attention recurrence of ch. 10 and arrive at the same recurrence.