12. Chunk-wise Recurrent Scan with Carried State
Problem
A real-time voice server can't wait for an entire utterance before it starts producing audio — it processes fixed-size windows ("chunks") of frames as they arrive and must carry the recurrent state from the end of one chunk into the start of the next, so that chunk boundaries are invisible in the output: running the recurrence chunk-by-chunk must give bit-identical results to running it once over the whole sequence.
Given the recurrence h_t = a_t * h_{t-1} + x_t (elementwise), implement a chunked scan that processes chunk_size timesteps at a time, carries the final hidden state of each chunk into the next chunk's initial state, and handles a sequence length that is not an exact multiple of chunk_size (the last chunk is simply shorter).
Source: src/12_chunked_linear_scan.py
def chunked_linear_scan(
x: torch.Tensor, a: torch.Tensor, h0: torch.Tensor, chunk_size: int,
) -> torch.Tensor: ...
>>> x = torch.randn(2, 7, 4)
>>> a = torch.sigmoid(torch.randn(2, 7, 4))
>>> h0 = torch.zeros(2, 4)
>>> chunked_linear_scan(x, a, h0, chunk_size=3).shape
torch.Size([2, 7, 4])
Step-by-Step Approach
- Iterate over chunk start indices with a step of
chunk_size:range(0, seq_len, chunk_size). - For each chunk, clamp its end index to
min(start + chunk_size, seq_len)so the final, possibly-shorter chunk doesn't index out of bounds. - Within a chunk, run the ordinary sequential recurrence timestep by timestep, always reading the current carried hidden state
has the previous step's output. - After finishing a chunk, do not reset
h— it must flow directly into the first timestep of the next chunk as that chunk's initial state, exactly as if no chunk boundary existed. - Collect every per-timestep hidden state (not just the per-chunk final ones) into the output, and stack them into a single
(batch, seq_len, hidden)tensor at the end. - Validate against an unchunked reference: the same recurrence run as one single sequential pass over all
seq_lensteps must match exactly, for anychunk_sizeincluding one that doesn't evenly divideseq_len.
The key insight is that "chunking" only changes how the loop is organized (an outer loop over chunk boundaries, an inner loop over timesteps within a chunk) — it never changes the math, because the one piece of state that crosses a chunk boundary, h, is threaded through unchanged.
Reference solution
import torch
def chunked_linear_scan(
x: torch.Tensor, a: torch.Tensor, h0: torch.Tensor, chunk_size: int,
) -> torch.Tensor:
batch, seq_len, hidden = x.shape
h = h0
outputs = []
# outer loop advances by whole chunks -- this is the boundary a
# real-time server actually pays for (one chunk = one arrived window of
# audio); the inner loop is the sequential recurrence within that chunk
for start in range(0, seq_len, chunk_size):
end = min(start + chunk_size, seq_len)
for t in range(start, end):
h = a[:, t, :] * h + x[:, t, :]
outputs.append(h)
# h now holds the state carried across the chunk boundary into the
# next iteration's initial state -- nothing besides h survives
return torch.stack(outputs, dim=1)
Key Functions & Tricks
range(0, seq_len, chunk_size)— generates chunk start offsets without needing a separate counter or ceiling-division computation.min(start + chunk_size, seq_len)— clamps the last chunk's end index so a non-divisible sequence length never indexes past the tensor.- Carrying a single scalar-per-batch-row Python variable
hacross both loop levels — the entire "carried state" API surface a streaming caller needs is this one variable's value at chunk boundaries. torch.stack(outputs, dim=1)— every timestep's hidden state is appended regardless of which chunk it came from, so the final shape is indistinguishable from an unchunked run.
How to Recognize This Pattern
Recognize this whenever a problem frames a scan/recurrence around "process in windows/chunks as they arrive" rather than "process the whole sequence at once" — the giveaway is a state that must survive across separate calls or separate loop bodies. A common variation exposes the chunk boundary directly as a class with a reset_state()/push_chunk() API instead of one function that owns the whole sequence internally. The most common pitfall is accidentally re-initializing the hidden state at the start of each chunk (using h0 instead of the previous chunk's final h), which silently produces the right shape but numerically treats every chunk as if it were the very start of the sequence.