3. Selective-SSM Zero-Order-Hold Discretization
Problem
SSMs like S4 and Mamba are defined as continuous-time linear systems, x'(t) = A x(t) + B u(t), with a diagonal state matrix A (one scalar per (channel, state-dim) pair). To run on discrete audio/token timesteps, the continuous system must be discretized with a per-timestep step size delta_t — and because Mamba's delta_t is itself computed from the input (the "selection mechanism," covered in a later problem), this discretization must be recomputed at every timestep rather than once, offline.
The standard zero-order-hold (ZOH) discretization gives A_bar_t = exp(delta_t * A) and B_bar_t = (delta_t * A)^-1 * (exp(delta_t * A) - I) * delta_t * B_t. For diagonal A this collapses to elementwise ops (no matrix inverse needed). Writing B_bar's second factor as (exp(x) - 1) / x with x = delta_t * A, the naive (torch.exp(x) - 1) / x catastrophically cancels for small x (which happens often, since delta_t is a learned, data-dependent, often-small positive value) — the fix is torch.expm1(x) / x.
Source: src/3_ssm_zoh_discretization.py
def discretize_zoh(A: torch.Tensor, B: torch.Tensor, delta: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
...
Examples:
>>> A = torch.tensor([[-1.0]])
>>> delta = torch.tensor([[[0.1]]])
>>> B = torch.tensor([[[2.0]]])
>>> A_bar, B_bar = discretize_zoh(A, B, delta)
>>> A_bar
tensor([[[[0.9048]]]])
Step-by-Step Approach
- Check shapes:
Ais(dim, state), shared across time;deltais(batch, length, dim);Bis(batch, length, state), broadcast across channels. The output shapes are both(batch, length, dim, state). - Broadcast
deltaagainstAto formx = delta.unsqueeze(-1) * A, shape(batch, length, dim, state)— this is the exponent that appears in bothA_barandB_bar. - Compute
A_bar = torch.exp(x)directly — no numerical concern here,expis well-behaved at anyxin the realistic range (negative, since a stable continuous-time SSM needsA < 0). - For
B_bar's(exp(x)-1)/xfactor, usetorch.expm1(x) / xinstead of(torch.exp(x) - 1) / x—expm1is implemented to stay accurate asx -> 0, whereexp(x)-1subtracts two nearly-equal floats and loses precision. - Guard the
x == 0edge case explicitly withtorch.where(the true limit of(exp(x)-1)/xasx -> 0is exactly 1, so substitute1rather than dividing by zero) — even thoughexpm1(x)/xis far more stable than the naive form, literalx=0still divides by zero. - Assemble
B_bar = delta.unsqueeze(-1) * ratio * B.unsqueeze(2), broadcasting the per-timestep-per-stateB_tacross the channel axis. - Sanity-check the scalar case by hand:
A=-1, delta=0.1, B=2.0givesx=-0.1,A_bar=exp(-0.1)≈0.9048, andB_bar = 0.1 * (expm1(-0.1)/-0.1) * 2.0 ≈ 0.1903.
The key insight is that a numerically "obviously correct" formula ((exp(x)-1)/x) can silently degrade in float32 exactly in the regime this function is called most often — small delta_t — so recognizing when to reach for expm1 (or the analogous log1p, or a log-sum-exp trick elsewhere) is as much a part of "implementing the discretization" as getting the math right in the first place.
Reference solution
def discretize_zoh(A: torch.Tensor, B: torch.Tensor, delta: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
# x = delta_t * A, broadcast (batch,length,dim,1) * (dim,state) -> (batch,length,dim,state)
x = delta.unsqueeze(-1) * A
A_bar = torch.exp(x) # exp(delta_t * A)
# (exp(x) - 1) / x, computed via expm1 to avoid catastrophic cancellation as x -> 0
ratio = torch.where(x.abs() < 1e-8, torch.ones_like(x), torch.expm1(x) / x)
# B_bar = delta_t * ratio * B_t, broadcasting B_t over the channel/dim axis
B_bar = delta.unsqueeze(-1) * ratio * B.unsqueeze(2)
return A_bar, B_bar
Key Functions & Tricks
torch.expm1(x)— numerically stableexp(x) - 1, accurate for smallxwhere naive subtraction loses precisiontorch.where(cond, a, b)— branchless elementwise selection, used here to guard thex == 0divisiontensor.unsqueeze(-1)/tensor.unsqueeze(2)— insert broadcast axes to align(batch,length,dim)and(dim,state)/(batch,length,state)shapes- Zero-order-hold discretization — the standard continuous-to-discrete-time conversion for linear systems, elementwise for diagonal
A - Broadcasting instead of explicit loops/tiling — letting PyTorch's broadcast rules expand
(dim,state)against(batch,length,dim,1)rather than manually repeating tensors
How to Recognize This Pattern
The signal: a problem describes a continuous-time system parameter (a fixed A, or "the model's underlying dynamics") that must be converted into a per-timestep discrete update using a data-dependent step size — that's a discretization problem, and ZOH is the default well-established formula for linear systems. A common variation is the even simpler Euler/first-order approximation (B_bar ≈ delta * B, skipping the (exp(x)-1)/x factor entirely, which Mamba's paper notes as a valid simplification and which the later selective-SSM-forward problem in this set actually uses for B_bar) — know both and be able to say why the exact ZOH form for B_bar is preferred when you want fidelity to the true continuous system. The recurring pitfall across this entire numerical-stability family is reaching for the textbook formula (exp(x)-1, or log(1+x), or naive softmax) instead of its expm1/log1p/log-sum-exp stabilized counterpart whenever the input can be small or the output can be extreme — always ask "what happens as this input approaches its natural boundary (zero, or very large/small)" before shipping a formula verbatim.