6. Chunked Scan for Long Sequences
cartesia-pytorch.) Confirmed: chunked/blocked recurrent computation to bound peak activation memory is standard practice in SSM implementations processing long sequences (e.g. Mamba's chunked scan formulations, and the general streaming/chunked-state pattern used for real-time, memory-bounded sequence processing) — general SSM/streaming engineering practice, not a claim about Cartesia's specific kernel implementation.Problem
For a real-time voice model streaming minutes of audio, materializing the full-length scan output all at once — or holding every timestep's activations for backprop — doesn't scale: memory grows linearly with sequence length, which is exactly the cost SSMs are supposed to avoid relative to attention. The standard fix is a chunked (blocked) scan: split the sequence into fixed-size chunks, compute each chunk's local scan with a closed-form vectorized formula, and carry only a single (batch, dim) hidden state across chunk boundaries — so peak intermediate memory is bounded by one chunk, not the whole sequence, and the only sequential Python-level loop is over num_chunks = length / chunk_size (far fewer iterations than a per-timestep loop).
Within a chunk, the closed form comes from unrolling h_t = a_t * h_{t-1} + b_t: writing A_t = prod(a_1..a_t) as the chunk-local cumulative product, h_t = A_t * h_prev + A_t * cumsum(b_k / A_k for k=1..t). This uses torch.cumprod and torch.cumsum instead of a timestep loop within the chunk. Note the division by A_k means very long chunks re-invite numerical trouble (A_k can underflow toward zero for decaying gates) — chunking bounds both memory and the numerical error of this cumsum trick, which is part of why production scan kernels keep chunk sizes modest rather than relying on the trick over arbitrarily long spans.
Source: src/6_chunked_blocked_scan.py
def chunked_scan(a: torch.Tensor, b: torch.Tensor, chunk_size: int, h0: torch.Tensor | None = None) -> torch.Tensor:
...
Examples:
>>> a = torch.tensor([[[0.5], [0.6], [0.7], [0.8]]])
>>> b = torch.tensor([[[1.0], [1.0], [1.0], [1.0]]])
>>> chunked_scan(a, b, chunk_size=2)
tensor([[[1.0000], [1.6000], [2.1200], [2.6960]]])
Step-by-Step Approach
- Assert
length % chunk_size == 0for simplicity (a production version would pad or handle a ragged final chunk; call this assumption out explicitly rather than silently mishandling it). - Initialize the carried state
htoh0or zeros, shape(batch, dim)— this is the only thing that crosses a chunk boundary. - Loop over chunk start indices
0, chunk_size, 2*chunk_size, ...(onlynum_chunksiterations). Slice outa_chunk, b_chunkof shape(batch, chunk_size, dim). - Within the chunk, compute
a_cumprod = torch.cumprod(a_chunk, dim=1)— this isA_t, the product of gates from the start of the chunk through positiont. - Compute
b_scaled = b_chunk / a_cumprod, thencum_b_scaled = torch.cumsum(b_scaled, dim=1)— together these realizesum_k b_k / A_kvia a running sum instead of a nested loop. - Combine:
h_chunk = a_cumprod * (h.unsqueeze(1) + cum_b_scaled)gives every hidden state within the chunk in one vectorized expression, correctly incorporating the carried-inh. - Update the carried state to the chunk's last position,
h = h_chunk[:, -1, :], and appendh_chunkto the output list; after the loop,torch.catthe chunks along the time axis.
The key insight is that the within-chunk formula does real work vectorized (cumprod/cumsum over chunk_size, not a Python loop), while the only genuinely sequential Python-level iteration is over the much smaller num_chunks — this is the same "trade some parallelism for bounded memory" idea a production streaming decoder uses, just expressed as training-time chunking here instead of true incremental inference.
Reference solution
def chunked_scan(a: torch.Tensor, b: torch.Tensor, chunk_size: int, h0: torch.Tensor | None = None) -> torch.Tensor:
batch, length, dim = a.shape
assert length % chunk_size == 0, "length must be divisible by chunk_size"
h = torch.zeros(batch, dim, dtype=a.dtype, device=a.device) if h0 is None else h0
chunks = []
for start in range(0, length, chunk_size): # only num_chunks iterations, not `length`
a_chunk = a[:, start:start + chunk_size, :]
b_chunk = b[:, start:start + chunk_size, :]
# closed form within the chunk: A_t = cumulative product, no per-timestep loop
a_cumprod = torch.cumprod(a_chunk, dim=1) # (batch, chunk_size, dim)
b_scaled = b_chunk / a_cumprod # b_k / A_k
cum_b_scaled = torch.cumsum(b_scaled, dim=1)
# h_t = A_t * (h_prev_from_last_chunk + cumsum(b_k / A_k))
h_chunk = a_cumprod * (h.unsqueeze(1) + cum_b_scaled)
chunks.append(h_chunk)
h = h_chunk[:, -1, :] # carry only the last state across the chunk boundary
return torch.cat(chunks, dim=1)
Key Functions & Tricks
torch.cumprod(a_chunk, dim=1)— vectorized running product within a chunk, replaces a per-timestep multiply looptorch.cumsum(b_scaled, dim=1)— vectorized running sum, paired with cumprod to realize the closed-form scan solutiona[:, start:start + chunk_size, :]— chunk slicing along the time axis- Bounded-memory streaming pattern — carry only a fixed-size state across an outer loop, vectorize the inner work
h_chunk[:, -1, :]— extract the last timestep of a chunk to hand off as the next chunk's initial state
How to Recognize This Pattern
The signal: "long sequence," "can't fit the whole thing in memory," "streaming," or "process in blocks/chunks while carrying state" — any phrasing that asks you to bound memory rather than just minimize total compute is pointing at chunking, which is a genuinely different axis from the sequential-vs-parallel tradeoff in the earlier scan problems (you can chunk a sequential scan, or chunk a parallel scan; chunking is about the outer memory envelope, not the inner algorithm). A common variation is combining this with gradient checkpointing — recomputing each chunk's activations during the backward pass instead of storing them, trading ~10-20% extra compute for a much larger memory reduction, exactly the kind of memory/compute tradeoff interviewers ask candidates to reason about explicitly. A common pitfall is the cumprod-division numerical fragility mentioned above: if gates are strongly decaying (a close to 0), a_cumprod underflows within just a few dozen steps even in float32, silently corrupting b_scaled = b / a_cumprod with a division by (near) zero — which is a real, practical reason to keep chunk sizes small rather than an arbitrary implementation choice.