zipformer.com

An independent explainer of the ICLR 2024 paper

Zipformer: a speech encoder that runs at four frame rates

How the encoder from k2-fsa works, read from the paper and the icefall code, with the pieces you can change and measure.

The six Zipformer stacks Conv-Embed turns 100 Hz features into a 50 Hz sequence. Six stacks follow, at 50, 25, 12.5, 6.25, 12.5 and 25 Hz. Each stack is downsampled from the 50 Hz path and upsampled back into it. A final downsample gives the 25 Hz encoder output. 50 Hz path between stacks Conv-Embed 100 Hz fbank in 50 Hz out 50 Hz stack 1, 24 frames ÷2 ×2 25 Hz stack 2, 12 frames ÷4 ×4 12.5 Hz stack 3, 6 frames ÷8 ×8 6.25 Hz stack 4, 3 frames ÷4 ×4 12.5 Hz stack 5, 6 frames ÷2 ×2 25 Hz stack 6, 12 frames ÷2 25 Hz encoder output The six Zipformer stacks at 50, 25, 12.5, 6.25, 12.5 and 25 Hz, then the 25 Hz output. 50 Hz stack 1 24 frames 25 Hz stack 2 12 frames 12.5 Hz stack 3 6 frames 6.25 Hz stack 4 3 frames 12.5 Hz stack 5 6 frames 25 Hz stack 6 12 frames 25 Hz output 12 frames
The same 0.48 s of audio in every stack. Conv-Embed halves the 100 Hz feature rate to 50 Hz. Each later stack averages groups of frames down to its own rate, runs its Zipformer blocks there, repeats frames to get back to 50 Hz, and mixes the result with its input. Paper Figure 1 and Section 3.1.

What changes relative to Conformer

Zipformer was introduced by Zengwei Yao, Liyong Guo, Xiaoyu Yang, Wei Kang, Fangjun Kuang, Yifan Yang, Zengrui Jin, Long Lin and Daniel Povey in "Zipformer: A faster and better encoder for automatic speech recognition", published at ICLR 2024. It keeps the ingredients of a Conformer, attention plus convolution plus feed-forward, and changes almost everything about how they are arranged, normalized and optimized. The paper lists five changes; each has its own section below.

ConformerZipformerSection
Frame rate25 Hz throughout50, 25, 12.5, 6.25, 12.5, 25 Hz stacks; 25 Hz outputEncoder
BlockFF, self-attention, conv, FFAttention weights computed once, used by NLA and two SA modules; three FFs, two convs, two bypassesBlock
NormalizationLayerNorm per moduleOne BiasNorm per blockBiasNorm
ActivationSwishSwooshR and SwooshLSwoosh
OptimizerAdam, Noam scheduleScaledAdam, Eden scheduleScaledAdam

Six stacks, four frame rates

A Conformer runs every block at one rate, 25 Hz after its 4x subsampling front end. Zipformer subsamples only by 2 at the front, so the first stack sees 50 Hz, and then lets each stack pick its own rate. With the default factors (1, 2, 4, 8, 4, 2) the six stacks run at 50, 25, 12.5, 6.25, 12.5 and 25 Hz. Between stacks the sequence is always back at 50 Hz; a last Downsample brings the output to 25 Hz, the same rate a Conformer would hand to its decoder.

The resampling is close to the simplest thing that works. Downsampling by a factor k averages each group of k frames with k learned weights that go through a softmax, so the stack learns, for example, whether the first or second frame of each pair matters more. Upsampling repeats each frame k times. The stack output is then combined with the stack input by a Bypass module with a learned per-channel weight c:

Bypass⁡(x,y)=(1−c)⊙x+c⊙y\operatorname{Bypass}(x, y) = (1 - c) \odot x + c \odot y

During training c is held in [0.9, 1.0] at first and the lower limit is relaxed to 0.2 after 20,000 steps (Section 3.2). Starting near "straight through" forces every stack to learn something before it is allowed to switch itself mostly off.

Each stack has its own width, wider in the middle where frames are cheapest. When the sequence moves to a stack with a different width it is truncated or zero-padded. The encoder output has the largest width of any stack; channels the last stack does not have are taken from the most recent stack that does. The figure below works this out for any clip length and model size.

10.0 s

StackRateFramesWidth LayersHeadsScore entries

Where the output channels come from

Sequence lengths follow the code exactly. Feature frames are taken as 100 per second; Conv-Embed gives (T - 7) // 2 frames, a stack with factor k sees ceil(T50 / k), the output has (T50 + 1) // 2. Score entries are the size of the attention-weight matrices one forward pass builds (layers x heads x frames squared), counted from the configuration, not measured. Widths, layers and heads are paper Table 1 and Section 4.0.1.

Inside a block

A Conformer block is feed-forward, self-attention, convolution, feed-forward. Most of the cost of self-attention is in two quadratic steps: computing the attention weights and using them to aggregate frames. Zipformer splits these apart into a Multi-Head Attention Weight module (MHAW) and a Self-Attention module (SA), computes the weights once per block and uses them three times. The block is about twice as deep as a Conformer block, and the paper gives weight reuse as the main reason that depth is affordable in time and memory (Section 3.2).

Step through the order the code runs it in. Modules that consume the shared weights are marked.

Attention weights

zipformer.py line 781

Multi-head attention weights are computed once from the block input: softmax over query-key products plus a relative position term, 32 query dimensions per head. Nothing is aggregated yet. The weights are kept and handed to three later modules.

Attention weights: computed here.

1 of 13
One Zipformer block. Thirteen steps, one attention-weight computation, three consumers. Every module except the bypasses, the norm and the constraints adds its output to a residual stream. Paper Figure 2 (left) and Section 3.2.

Non-linear attention

NLA reuses the attention weights for something other than plain value averaging. With three linear projections A, B, C of the input, each 3/4 of the input width, the paper writes it as

NLA⁡(x)=linear⁡(A⊙attention⁡(tanh⁡(B)⊙C))\operatorname{NLA}(x) = \operatorname{linear}\big(A \odot \operatorname{attention}(\tanh(B) \odot C)\big)

where attention means multiplying along the time axis by a single head of the already computed weights. In the code that head is attn_weights[0:1], the first one. The module is a convolution module with the convolution replaced by attention-weighted mixing, and the non-linearity sits before the mixing, not after it.

What the ablations say

On Zipformer-M, LibriSpeech test-clean / test-other WER (paper Table 5): the full model is 2.21 / 4.79. Replacing each Zipformer block with two stacked Conformer-style blocks gives 2.18 / 4.95 with 73.9M parameters instead of 65.6M. Removing NLA gives 2.16 / 4.97, removing the Bypass gives 2.25 / 4.86. Removing NLA and also computing attention weights separately for each SA module gives 2.20 / 5.10: more parameters and slower, with no gain, which the authors read as the two attention patterns inside a block being similar enough to share.

BiasNorm keeps the length LayerNorm throws away

LayerNorm subtracts the mean and divides by the standard deviation, so every output vector has the same length whatever the input length was. The authors report two habits of trained Conformers that they attribute to this (Section 3.3). Some set one channel to a large constant, for example 50, which makes the denominator nearly fixed and so lets the other channels keep their size. And some modules end up "dead", with outputs around 10-6: the LayerNorm scale γ\gamma is pushed toward zero early in training, and if it oscillates around zero the gradient reaching the module keeps flipping sign, so it never learns.

BiasNorm builds the first habit in and removes the cause of the second:

LayerNorm⁡(x)=x−E[x]Var⁡[x]+ϵ⊙γ+βBiasNorm⁡(x)=xRMS⁡[x−b]⋅exp⁡(γ)\begin{aligned}\operatorname{LayerNorm}(x) &= \frac{x - \mathrm{E}[x]}{\sqrt{\operatorname{Var}[x] + \epsilon}} \odot \gamma + \beta \\[6pt] \operatorname{BiasNorm}(x) &= \frac{x}{\operatorname{RMS}[x - b]} \cdot \exp(\gamma)\end{aligned}

Here b is a learned per-channel bias that only enters the RMS, and γ\gamma is a single learned scalar. A large entry in b plays the role of the constant channel without occupying a real one. Because exp⁡(γ)\exp(\gamma) is always positive, the scale cannot change sign. There is no mean subtraction, which the paper calls a waste of time unless it follows a non-linearity. Zipformer uses one BiasNorm per block plus one in Conv-Embed; the per-module LayerNorms of a Conformer are gone.

  • LayerNorm
  • LayerNorm with an extra constant channel (value 4)
  • BiasNorm with b = 4 on channel 1, exp(γ) = 1
A 12-channel vector, scaled up and down. Left: RMS of the 12 output channels as the input RMS changes. LayerNorm output is flat: length is gone. A constant channel or a BiasNorm bias keeps output size proportional to input size when the input is small, and normalizes it when it is large. Right: the output channels at the current input size. The bias value 4 is an example, not a trained value.

SwooshR and SwooshL

Conformer uses Swish. Zipformer replaces it with two functions (Section 3.4, Equation 4):

SwooshR⁡(x)=log⁡(1+ex−1)−0.08x−0.313261687SwooshL⁡(x)=log⁡(1+ex−4)−0.08x−0.035\begin{aligned}\operatorname{SwooshR}(x) &= \log(1 + e^{x-1}) - 0.08x - 0.313261687 \\ \operatorname{SwooshL}(x) &= \log(1 + e^{x-4}) - 0.08x - 0.035\end{aligned}

The constant in SwooshR makes it pass through the origin. The constant in SwooshL was tuned: it slightly beat the value that would pass through the origin, so SwooshL(0) is about -0.017. The letter says which zero crossing sits at or near x = 0: the right one for SwooshR, the left one for SwooshL, which is SwooshR shifted right by about three.

The difference that matters is the slope for negative inputs. Swish and ReLU go flat, so a unit whose input stays negative stops receiving gradient, and in Adam-type optimizers its second-moment estimate shrinks toward zero, which makes later updates large. Both Swoosh functions keep a slope of -0.08 on the left. Switch the plot to derivatives to see it.

-3.00
  • SwooshR
  • SwooshL
  • Swish
  • ReLU
Swoosh keeps a gradient where Swish loses it. Curves are computed in your browser from the formulas above. Paper Appendix A.2 (Figure A.3) shows the same three curves over [-10, 10].

Which one goes where follows a training observation. With SwooshR everywhere, modules that sit behind a bypass connection, the feed-forward modules and the ConvNeXt layer in Conv-Embed, learned a large negative bias in the preceding linear layer: they wanted to be "normally off". SwooshL is close to zero around x = 0 and only climbs steeply past x = 4, which builds that behaviour into the activation. So SwooshL is used in the feed-forward modules and the ConvNeXt layer, SwooshR in the convolution modules and the rest of Conv-Embed. On Zipformer-M, using SwooshR everywhere costs 0.11 / 0.42 WER on test-clean / test-other, and Swish everywhere gives 2.27 / 5.37 against 2.21 / 4.79 (Table 5).

Activation constraints: Balancer and Whiten

Without per-module normalization, something else has to stop activations from drifting into bad ranges. Zipformer uses two modules that do nothing in the forward pass. In the backward pass they add the gradient of an auxiliary loss on the activations to the real gradient, scaled so it stays small next to it (Appendix A.3). The paper is candid that their placement does not follow a clean rule: they were added where a specific module was seen training badly or diverging.

Balancer

Balancer constrains each channel separately. It targets two failures: values that grow too large or too small (a risk with mixed-precision training) and "dead" channels that are almost always negative before a non-linearity. The constraints are a range for the proportion of positive values and a range for the mean absolute value. Assuming a Gaussian channel, those become limits on E[x]/Var⁡[x]\mathrm{E}[x]/\sqrt{\operatorname{Var}[x]} and on RMS⁡[x]\operatorname{RMS}[x], and the loss is the distance outside them (Equation 9). The extra gradient is normalized and multiplied by 0.04 times the magnitude of the real gradient at each element, so frames with tiny gradients, such as padding, get tiny corrections.

0.60
1.00
One channel, modelled as a Gaussian. Limits are the ones in the code: block output min_positive=0.45, max_positive=0.55, min_abs=0.2, max_abs=4.0; feed-forward 3 output 0.3, 0.7, min_abs 0.2 (after 4,000 batches), max_abs=4.0. The code converts them with the same approximations as the paper: RMS limit = 1.2533 x abs limit, mean/std limit = 0.8140 x atanh(2p - 1).

Whiten

The second failure is in the covariance of a whole layer's output: one or a few eigenvalues take over while the rest collapse, which the authors saw in models about to diverge. Whiten measures it with

Lwhiten=∑iλi2/D(∑iλi/D)2=∑i,jCij2/D(∑iCii/D)2\mathcal{L}_{\text{whiten}} = \frac{\sum_i \lambda_i^2 / D}{\left(\sum_i \lambda_i / D\right)^2} = \frac{\sum_{i,j} C_{ij}^2 / D}{\left(\sum_i C_{ii} / D\right)^2}

over the eigenvalues of the mean-subtracted covariance C. It is 1 for perfectly white features and D when one direction holds everything, and the right-hand form needs no eigendecomposition. The penalty only switches on above a limit: the paper gives 10 as the usual value, and in the current code the block-output limit grows from 4 to 12 over the first 20,000 batches.

0.90

A 256-channel covariance with eigenvalues proportional to i-a. Bars are the eigenvalues, largest first, on a linear scale. The metric is computed exactly from them. Paper Table 6: on Zipformer-M trained 40 epochs, removing Whiten moves test-other WER from 4.91 to 5.15; removing Balancer moves it to 4.97, and the authors keep Balancer mainly as protection against divergence.

ScaledAdam: updates relative to the parameter's size

Adam normalizes the gradient, so its step on each element is roughly the learning rate whatever the parameter's magnitude. For a tensor with RMS 0.01 that is a large relative change; for one with RMS 1 it is a small one. ScaledAdam multiplies Adam's update by the tensor's current RMS rt-1, so the relative change per step is about the same for every tensor (Section 3.5, Equation 6):

Δt=−αt⋅rt−1⋅1−β2t1−β1t⋅mtvt+ϵ rt−1=RMS⁡[θt−1]\begin{aligned}\Delta_t &= -\alpha_t \cdot r_{t-1} \cdot \frac{\sqrt{1-\beta_2^t}}{1-\beta_1^t} \cdot \frac{m_t}{\sqrt{v_t} + \epsilon} \ r_{t-1} &= \operatorname{RMS}[\theta_{t-1}]\end{aligned}

It also learns the scale explicitly. Treat each tensor as θ=r⋅θ′\theta = r \cdot \theta' and run Adam on r as if it were a parameter, with gradient ht=gt⋅θt−1h_t = g_t \cdot \theta_{t-1}. That adds a step along the parameter itself, growing or shrinking the whole tensor (Equation 7):

Δt,r′=−η⋅αt⋅1−β2t1−β1t⋅ntwt+ϵ θt−1 η=0.1\begin{aligned}\Delta'_{t,r} &= -\eta \cdot \alpha_t \cdot \frac{\sqrt{1-\beta_2^t}}{1-\beta_1^t} \cdot \frac{n_t}{\sqrt{w_t} + \epsilon} \, \theta_{t-1} \ \eta &= 0.1\end{aligned}

Shrinking a tensor is hard with plain gradient steps, because the noise in every step tends to grow its norm; this term gives the optimizer a direct handle on it. It is also what lets Zipformer drop most normalization layers: a module can learn to keep its own output in a sensible range. The code adds some practical limits the paper does not spell out: the scale is updated every 4 steps from the accumulated scale gradients, each scale step is clamped to [-0.1, 0.1], tensor RMS is kept between 1e-5 and 3.0, scalar parameters get plain Adam at 0.1 times the learning rate, and gradients are clipped at twice the recent median norm (clipping_scale=2.0 in train.py).

0.010

Relative change per step, in units of the learning rate. With a normalized gradient of about 1 per element, Adam changes a tensor by about lr / RMS in relative terms; ScaledAdam by about lr for every tensor down to param_min_rms. A schematic of Equation 5 against Equation 6, not a training measurement.

The Eden schedule

Because ScaledAdam is less prone to divergence, it does not need a long warm-up, and its learning rates are numerically much larger than Adam's (parameter RMS values are usually well below 1). Eden decays with both the step count t and the epoch e (Equation 8):

αt=αbase⋅(t2+αstep2αstep2)−0.25⋅(e2+αepoch2αepoch2)−0.25 ⋅linear⁡(αstart,twarmup,t)\begin{aligned}\alpha_t = \alpha_{\text{base}} &\cdot \left(\frac{t^2 + \alpha_{\text{step}}^2}{\alpha_{\text{step}}^2}\right)^{-0.25} \cdot \left(\frac{e^2 + \alpha_{\text{epoch}}^2}{\alpha_{\text{epoch}}^2}\right)^{-0.25} \ &\cdot \operatorname{linear}(\alpha_{\text{start}}, t_{\text{warmup}}, t)\end{aligned}

The two factors are there so that, if you change the batch size, the amount of parameter change per hour of data stays roughly constant and the schedule does not need retuning. The paper uses base 0.045, start 0.5 and a 500-step warm-up. The icefall LibriSpeech recipe uses lr_batches = 7500, lr_epochs = 3.5 and starts the warm-up at 0.1. The epoch counter is zero during the first epoch, because train.py calls scheduler.step_epoch(epoch - 1).

Eden, computed exactly as optim.py does. Warm-up is 500 steps. Steps per epoch depend on your data and batch size; 2,500 is only a starting value. The Adam comparison uses the schedule the paper gives in Section 4.0.3, αbase⋅512−0.5⋅min⁡(t−0.5, t⋅10000−1.5)\alpha_{\text{base}} \cdot 512^{-0.5} \cdot \min(t^{-0.5},\, t \cdot 10000^{-1.5}), which peaks near 0.0033 at step 10,000. Paper Figure A.2 shows both families on the same axis.

The result in Table 5: with Adam, Zipformer-M needed a BiasNorm in every module to avoid divergence, and its best run reached 2.38 / 5.51 WER on test-clean / test-other against 2.21 / 4.79 with ScaledAdam. Learning-rate factors 0.025, 0.035, 0.045 and 0.055 were tried for ScaledAdam and 2.5, 5.0, 7.5 and 10.0 for Adam; the best of each is reported.

Streaming: chunks, left context, caches

The paper evaluates full-context models only, but the icefall implementation can train the same encoder for streaming. With --causal 1, each batch picks a chunk size at random from --chunk-size (default 16,32,64,-1) and a left context from --left-context-frames (default 64,128,256,-1), both counted in 50 Hz frames after Conv-Embed; -1 means no limit. One model trained this way can be run at several chunk sizes. Attention is restricted by a block mask: a frame sees every frame of its own chunk and a fixed number of whole chunks to the left, nothing to the right.

masked(i,j)=(cj>ci)  ∨  (cj<ci−L) ci=⌊i/chunk⌋,L=max⁡ ⁣(1,⌊left/chunk⌋)\begin{aligned}\text{masked}(i, j) &= \big(c_j > c_i\big) \;\lor\; \big(c_j < c_i - L\big) \ c_i &= \lfloor i / \text{chunk} \rfloor, \qquad L = \max\!\big(1, \lfloor \text{left} / \text{chunk} \rfloor\big)\end{aligned}

The mask is built once at 50 Hz and each downsampled stack takes every k-th row and column, so chunk boundaries line up across stacks. That is why the code asserts that the chunk size is divisible by every downsampling factor, and that the left context covers half of each stack's convolution kernel.

Chunk
Left context
Seen by
keys (past to future)

attended masked

The attention mask for 192 frames at 50 Hz (3.84 s), rows are queries. Switch the stack to see the same mask subsampled by 2, 4 or 8: each stack sees the same chunk boundaries in time, with fewer frames per chunk. Computed with the formula above.

Convolution without lookahead

Attention is not the only thing that looks at the future. A centred convolution with kernel 31 sees 15 frames ahead. Causal Zipformer replaces the depthwise convolution with a chunk-causal one: a causal convolution of half the kernel width, plus a second convolution that only operates inside the current chunk and is multiplied by a learned scale that depends on the position within the chunk. A frame can still use the frames to its right inside its own chunk; nothing beyond the chunk is used.

What a streaming step costs and carries

At inference the encoder is called once per chunk. Each call takes T=2⋅chunk+13T = 2 \cdot \text{chunk} + 13 feature frames: twice the chunk, because Conv-Embed halves the rate, plus 7 frames consumed by its convolutions and 6 for the 3 frames of right padding the ConvNeXt layer needs at 50 Hz. It returns chunk / 2 output frames at 25 Hz. Between calls, every layer keeps six tensors: cached keys, cached NLA inputs, cached values for both SA modules, and the left context of both convolution modules. Keys and values hold left_context_frames // k frames for a stack with factor k. The ONNX export adds two more inputs: a Conv-Embed cache and a count of frames already processed.

Chunk
Left context
Latency follows the chunk. A frame cannot be encoded until its chunk is complete, so the first frame of a chunk waits a full chunk and the last one almost nothing; the small fixed lookahead inside the 13 extra feature frames and the compute time come on top. Cache sizes are computed from get_init_states for the Zipformer-M configuration (paper Table 1), 4 bytes per value. Chunk 8 is not in the default training list; it is shown because some deployments train with it.

The trade-off is the usual one. Smaller chunks mean lower latency and less right context per frame; longer left context means more state and more attention work per step. The paper does not cover streaming at all; the causal mode lives in the icefall code, and the accuracy cost of a given chunk size is something to measure on your own data.

What the paper reports

All numbers in this section are copied from the paper (arXiv 2310.11230v4). Models are trained with the pruned transducer loss unless stated, decoded with beam search of size 4 and no external language model. In the authors' words: "In terms of efficiency, Zipformer converges faster during training and speeds up the inference by more than 50% compared to previous studies while requiring less GPU memory." (Section 1)

The paper is also clear about a baseline problem. Its own pruned-transducer Conformers do not reach the numbers in the original Conformer paper (2.1 / 4.3 for Conformer-L), and neither do the WeNet and ESPnet Conformers it lists. The Zipformer-L trained for longer, in the last row of Table 2, reaches 2.00 / 4.38, which the authors describe as "the first model to approach Conformer-L to the best of our knowledge."

Encoder GFLOPs against LibriSpeech test-other WER for the models in paper Table 2 that report GFLOPs. 4.5 5.5 6.5 7.5 8.5 9.5 20 50 100 200 400 encoder GFLOPs for 30 s of audio (log scale) test-other WER % Squeezeformer-XS (CTC): 9M params, 18.2 GFLOPs, 3.74 / 9.09 Squeezeformer-S (CTC): 18.6M params, 33.7 GFLOPs, 3.08 / 7.47 Squeezeformer-SM (CTC): 28.2M params, 47.6 GFLOPs, 2.79 / 6.89 Squeezeformer-M (CTC): 55.6M params, 88.4 GFLOPs, 2.56 / 6.5 Squeezeformer-ML (CTC): 125.1M params, 183.3 GFLOPs, 2.61 / 6.05 Squeezeformer-L 5.97 Squeezeformer-L (CTC): 236.3M params, 333.7 GFLOPs, 2.47 / 5.97 E-Branchformer-B (CTC/AED): 41.1M params, 78.1 GFLOPs, 2.49 / 5.61 Branchformer (CTC/AED): 116.2M params, 238.3 GFLOPs, 2.4 / 5.5 E-Branchformer-L 4.55 E-Branchformer-L (CTC/AED): 148.9M params, 284.4 GFLOPs, 2.14 / 4.55 Conformer-S (reproduced) (pruned transducer): 9.8M params, 29.1 GFLOPs, 3.75 / 9.24 Conformer-M (reproduced) (pruned transducer): 28.4M params, 77 GFLOPs, 2.96 / 7.11 Conformer-L (reproduced) 5.55 Conformer-L (reproduced) (pruned transducer): 122.5M params, 294.2 GFLOPs, 2.46 / 5.55 Zipformer-S 5.73 Zipformer-S (pruned transducer): 23.3M params, 40.8 GFLOPs, 2.42 / 5.73 Zipformer-M 4.79 Zipformer-M (pruned transducer): 65.6M params, 62.9 GFLOPs, 2.21 / 4.79 Zipformer-L 4.63 Zipformer-L (pruned transducer): 148.4M params, 107.7 GFLOPs, 2.06 / 4.63
  • Zipformer, pruned transducer
  • Conformer reproduced by the authors, pruned transducer
  • (E-)Branchformer, CTC/AED
  • Squeezeformer, CTC
Paper Table 2, LibriSpeech. Every row that reports encoder GFLOPs (measured with DeepSpeed for a 30 s input). The original Conformer rows have no GFLOPs and are not plotted. Hover or focus a point for parameters and test-clean WER. Paper Figure 3 makes the same comparison with measured inference time and peak memory.
Selected rows of Table 2. WER %, LibriSpeech.
ModelTypeParams (M)GFLOPstest-cleantest-other
Conformer-L (Gulati et al.)transducer118.8-2.14.3
Conformer-L, reproducedpruned transducer122.5294.22.465.55
E-Branchformer-LCTC/AED148.9284.42.144.55
Squeezeformer-LCTC236.3333.72.475.97
Zipformer-Spruned transducer23.340.82.425.73
Zipformer-Mpruned transducer65.662.92.214.79
Zipformer-Lpruned transducer148.4107.72.064.63
Zipformer-L, longer trainingpruned transducer148.4107.72.004.38

Zipformer also trains with other heads. In Table 8, CTC-only Zipformer-S/M/L reach 2.85 / 6.91, 2.51 / 6.02 and 2.49 / 5.7, and CTC/AED versions with a 6-layer Transformer decoder reach 2.46 / 6.04, 2.22 / 4.97 and 2.09 / 4.59.

Change from the base modeltest-other WER %

  1. Zipformer-M (base) 4.79 clean 2.21, 65.6M
  2. No temporal downsampling 5.09 clean 2.23, 94.2M
  3. Double Conformer-style blocks 4.95 clean 2.18, 73.9M
  4. No NLA 4.97 clean 2.16, 58.7M
  5. No NLA, no attention weight sharing 5.10 clean 2.20, 60.9M
  6. No Bypass 4.86 clean 2.25, 65.5M
  7. LayerNorm instead of BiasNorm 4.97 clean 2.29, 65.6M
  8. Only SwooshR 5.21 clean 2.32, 65.5M
  9. Swish 5.37 clean 2.27, 65.5M
  10. Adam instead of ScaledAdam 5.51 clean 2.38, 65.6M
Paper Table 5: one change at a time on Zipformer-M. Dot is test-other WER %, the dashed line is the base model. Every change makes test-other worse; test-clean moves much less, and two variants are slightly better on test-clean (No NLA 2.16, double Conformer blocks 2.18).

Mandarin

Table 3, Aishell-1, CER %.
ModelParams (M)DevTest
Conformer, ESPnet46.24.54.9
Conformer, WeNet46.3-4.61
E-Branchformer, ESPnet37.94.24.5
Branchformer45.44.194.43
Zipformer-S30.24.44.67
Zipformer-M73.44.134.4
Zipformer-L157.34.034.28
Table 4, WenetSpeech, CER %.
ModelParams (M)DevNetMeeting
Conformer, ESPnet116.99.708.9015.90
Conformer, WeNet116.98.889.7015.59
Conformer-MoE (16e)4257.678.2813.96
Zipformer-S32.37.968.613.97
Zipformer-M75.97.327.6112.35
Zipformer-L160.97.297.2412.06

Running it: ONNX and sherpa-onnx

Training lives in icefall; deployment usually goes through k2-fsa/sherpa-onnx, a runtime for ONNX speech models with APIs for C++, C, Python, JavaScript, Java, C#, Kotlin, Swift, Go, Dart, Rust and Pascal, builds for Android, iOS, Windows, macOS, Linux and HarmonyOS, and a WebAssembly target. A streaming Zipformer is exported with a fixed chunk size and left context, and those numbers are baked into the graph.

Export a streaming transducer

The architecture flags must match training. Chunk size and left context are picked from the values the model was trained with (from 16,32,64 and 64,128,256 for the default recipe):

# in icefall/egs/librispeech/ASR, with $repo holding exp/epoch-99.pt
./zipformer/export-onnx-streaming.py \
  --tokens $repo/data/lang_bpe_500/tokens.txt \
  --use-averaged-model 0 --epoch 99 --avg 1 \
  --exp-dir $repo/exp \
  --num-encoder-layers "2,2,3,4,3,2" \
  --downsampling-factor "1,2,4,8,4,2" \
  --feedforward-dim "512,768,1024,1536,1024,768" \
  --num-heads "4,4,4,8,4,4" \
  --encoder-dim "192,256,384,512,384,256" \
  --query-head-dim 32 --value-head-dim 12 \
  --pos-head-dim 4 --pos-dim 48 \
  --encoder-unmasked-dim "192,192,256,256,256,192" \
  --cnn-module-kernel "31,31,15,15,15,31" \
  --decoder-dim 512 --joiner-dim 512 \
  --causal True --chunk-size 16 --left-context-frames 128

This writes an encoder, a decoder and a joiner. The encoder carries its streaming contract as ONNX metadata: model_type=zipformer2, decode_chunk_len (twice the chunk, in feature frames), T (decode_chunk_len + 13), left_context_len, and the per-stack layers, widths, kernels and head sizes. A runtime reads these instead of hard-coding them; check them first when a port produces garbage.

Decode a stream

import numpy as np, sherpa_onnx, soundfile as sf

rec = sherpa_onnx.OnlineRecognizer.from_transducer(
    tokens="tokens.txt",
    encoder="encoder-epoch-99-avg-1-chunk-16-left-128.onnx",
    decoder="decoder-epoch-99-avg-1-chunk-16-left-128.onnx",
    joiner="joiner-epoch-99-avg-1-chunk-16-left-128.onnx",
    num_threads=2, sample_rate=16000, feature_dim=80,
    decoding_method="greedy_search",
)
samples, sr = sf.read("test.wav", dtype="float32")
s = rec.create_stream()
s.accept_waveform(sr, samples)           # call repeatedly with live audio
s.accept_waveform(sr, np.zeros(int(0.66 * sr), dtype=np.float32))  # tail padding, as in the official example
s.input_finished()
while rec.is_ready(s):
    rec.decode_stream(s)
print(rec.get_result(s))

A CTC-only Zipformer uses OnlineRecognizer.from_zipformer2_ctc(tokens=..., model=...) instead, with one ONNX file. sherpa-onnx computes the 80-dimensional fbank features itself from 16 kHz audio, so the front end matches what icefall trained on.

On a device

Case study: a streaming phoneme recognizer for Qur'an recitation

Quran-Lab/zipformer_p-arabic-v3 is Quran Lab's Zipformer for recitation. Everything in this section comes from its public model card. The current release is v3.1, a fine-tune of v3 that retunes madd (elongation) length behaviour; it keeps the same architecture, the same streaming interface and the same token table, so it is a drop-in replacement for v3 in every format.

The card describes a 65.5M-parameter streaming phoneme recognizer for Hafs 'an 'Asim, built on Zipformer2 with a CTC head. It transcribes recitation into a 251-symbol Qur'anic phonetic alphabet that encodes madd length, gemination, ghunna, ikhfaa, qalqalah and the emphatic consonants. Its design rule is no language model anywhere: no transducer, no LM rescoring, no text prior, because a language model silently corrects a reciter's mistakes, which an assessment system must never do. zipformer_p-arabic-v3 is ranked #1 on the open Quranic ASR leaderboard (33 entries on 3 November 2026).

How the architecture shows up in the release

The card also lists measured limitations, for example that a model trained almost entirely on correct recitation with canonical labels tends to transcribe the rule rather than a reciter's deviation from it. Read the card before building on it; the model is released under the Quran-Lab No-Profit License 1.2 with gated access.

References

  1. Z. Yao, L. Guo, X. Yang, W. Kang, F. Kuang, Y. Yang, Z. Jin, L. Lin, D. Povey. Zipformer: A faster and better encoder for automatic speech recognition. ICLR 2024. arXiv 2310.11230, PDF (v4)
  2. k2-fsa/icefall, LibriSpeech Zipformer recipe: zipformer.py, scaling.py, optim.py, subsampling.py, train.py, export-onnx-streaming.py. Line links on this page point to commit 9c96676. icefall, zipformer.py, scaling.py, optim.py
  3. k2-fsa/sherpa-onnx: speech recognition runtime with ONNX Runtime. GitHub, online-decode-files.py
  4. A. Gulati et al. Conformer: Convolution-augmented Transformer for speech recognition. Interspeech 2020. arXiv 2005.08100
  5. S. Kim et al. Squeezeformer: An efficient Transformer for automatic speech recognition. NeurIPS 2022. arXiv 2206.00888
  6. F. Kuang et al. Pruned RNN-T for fast, memory-efficient ASR training. 2022. arXiv 2206.13236
  7. Quran Lab. zipformer_p-arabic-v3 model card (v3.1 current). Hugging Face
  8. Quran Lab. Quranic ASR leaderboard. Hugging Face Space

Where the paper and the current code differ, this page says so: Eden's warm-up start (0.5 in the paper, 0.1 in the LibriSpeech train.py) and the Whiten limit (10 in the paper, a 4 to 12 schedule for the block output in the code).