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, lr=3e-3):
        super().__init__()
        self.save_hyperparameters()
        self.emb = nn.Embedding(vocab_size, num_hiddens)
        self.head = nn.LazyLinear(num_symbols)

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

    def configure_optimizers(self):
        return torch.optim.Adam(self.parameters(), lr=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(nn.Sequential(*[S4DBlock(48, 4) for _ in range(2)]),
                num_hiddens=48)
train_copy('S4D', s4d, copy_data)

S4D: final validation accuracy 0.611

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
a_t = torch.rand(num_steps, num_states)      # Per-step decays in (0, 1)
b_t = torch.randn(num_steps, num_states)     # Per-step inputs
h, ys = torch.zeros(num_states), []
for t in range(num_steps):
    h = a_t[t] * h + b_t[t]
    ys.append(h)
err = (associative_scan(a_t, b_t) - torch.stack(ys)).abs().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(nn.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):
        super().__init__()
        H, N, R = num_hiddens, num_states, max(2, num_hiddens // 16)
        self.log_a = nn.Parameter(
            torch.log(torch.arange(1., N + 1)).repeat(H, 1))
        self.W_dt = nn.Sequential(nn.Linear(H, R), nn.Linear(R, H, bias=False))
        dt = torch.exp(torch.rand(H) * math.log(dt_max / dt_min)
                       + math.log(dt_min))
        self.b_dt = nn.Parameter(dt + torch.log(-torch.expm1(-dt)))
        self.W_B = nn.Linear(H, N, bias=False)
        self.W_C = nn.Linear(H, N, bias=False)
        self.D = nn.Parameter(torch.ones(H))

    def forward(self, u):                    # (num_steps, batch, num_hiddens)
        a = -torch.exp(self.log_a)                    # (H, N), Re(a) < 0
        dt = F.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 = torch.exp(dt.unsqueeze(-1) * a)       # (T, batch, H, N)
        b_bar = (dt * u).unsqueeze(-1) * B.unsqueeze(-2)
        x = associative_scan(a_bar, b_bar)
        return (x * C.unsqueeze(-2)).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(nn.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):
        super().__init__()
        d = expand * num_hiddens
        self.ln = nn.LayerNorm(num_hiddens)
        self.W_in = nn.Linear(num_hiddens, 2 * d)
        self.conv = nn.Conv1d(d, d, conv_width, groups=d,
                              padding=conv_width - 1)
        self.ssm = SelectiveSSM(d, num_states)
        self.W_out = nn.Linear(d, num_hiddens)
        self.drop = nn.Dropout(dropout)

    def forward(self, X):                    # (num_steps, batch, num_hiddens)
        u, gate = self.W_in(self.ln(X)).chunk(2, -1)
        u = self.conv(u.permute(1, 2, 0))[..., :X.shape[0]]  # Causal: trim
        y = self.ssm(F.silu(u.permute(2, 0, 1)))
        return X + self.drop(self.W_out(y * F.silu(gate)))

class Mamba(d2l.Module):
    """A stack of Mamba blocks with the recurrent-cell interface."""
    def __init__(self, num_inputs, num_blocks=2, num_states=4, dropout=0):
        super().__init__()
        self.save_hyperparameters()
        self.num_hiddens = num_inputs                 # Output width, for heads
        self.blocks = nn.Sequential(*[
            MambaBlock(num_inputs, num_states, dropout=dropout)
            for _ in range(num_blocks)])
        self.ln = nn.LayerNorm(num_inputs)

    def forward(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     88.9   2.33   297,539      0.7
 minGRU     83.5   2.30   214,851      0.8
  Mamba     80.5   2.28   488,739      5.0
  • 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 = -torch.exp(self.log_a)
    dt = F.softplus(self.W_dt(u) + self.b_dt)         # (batch, H)
    B, C = self.W_B(u), self.W_C(u)                   # (batch, N)
    a_bar = torch.exp(dt.unsqueeze(-1) * a)           # (batch, H, N)
    b_bar = (dt * u).unsqueeze(-1) * B.unsqueeze(-2)
    x = b_bar if x is None else a_bar * x + b_bar
    return (x * C.unsqueeze(-2)).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 = self.W_in(self.ln(X)).chunk(2, -1)
    if state is None:
        state = (u.new_zeros(*u.shape, self.conv.kernel_size[0]), None)
    buf, x = state
    buf = torch.cat([buf[..., 1:], u.unsqueeze(-1)], -1)  # Roll the window
    u = (buf * self.conv.weight[:, 0]).sum(-1) + self.conv.bias
    y, x = self.ssm.step(F.silu(u), x)
    return X + self.drop(self.W_out(y * F.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) if state is None else list(state)
    for i, blk in enumerate(self.blocks):
        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):

mamba_lm.eval()                                  # Dropout off
X = data.X[data.num_train:data.num_train + 8].to(d2l.try_gpu())
with torch.no_grad():
    logits_scan = mamba_lm(X)                    # (batch, T, vocab)
    state, cols = None, []
    for t in range(X.shape[1]):                  # One token at a time
        emb = mamba_lm.emb(X[:, t].unsqueeze(0)) # (1, batch, d)
        y, state = mamba_lm.rnn.step(emb[0], state)
        cols.append(mamba_lm.linear(y))
    logits_step = torch.stack(cols, 1)
err = float((logits_step - logits_scan).abs().max())
scale = float(logits_scan.abs().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 6.20e-06, relative 5.47e-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):
    def step(ids):  # Token ids in, numpy logits for the next token out
        with torch.no_grad():
            logits = model(d2l.tensor([ids], device=d2l.try_gpu()))
        return d2l.numpy(logits)[0, -1]
    return step

def stepped_fn(model):
    state, seen = None, 0
    def step(ids):  # Consume only the tokens the state has not absorbed
        nonlocal state, seen
        with torch.no_grad():
            for i in ids[seen:]:
                emb = model.emb(d2l.tensor([[i]], device=d2l.try_gpu()))
                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 travelleren the Time Traveller and diling with cry. Weve it will was the\nside and mon'
 minGRU: 'the time travellered, and the unlination. And\nthe mean, and it seemed to the faint, and a'
  Mamba: 'the time traveller, I was not believent the lever, and\nfolden in the sunt of the d'

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.