← All tutorials

Normalization and Quantization

What happens to each norm layer before 8-bit inference.

On this page

1 BatchNorm folds into the conv before quantizing 2 LayerNorm and RMSNorm keep their division at runtime 3 A few huge features make LLM activations hard to quantize 4 When normalization breaks quantization 5 Folding and smoothing in practice 6 References

Before a model runs in 8-bit integers, each norm layer either disappears into the layer before it, keeps running with its gain and shift moved, or forces a fix for the huge activations around it. Figure 1 maps the three cases.

1 BatchNorm: the whole layer folds conv BN new conv 2 LayerNorm, RMSNorm: the statistics stay ÷ spread × γ W ÷ spread γW 3 LLM outliers: size moves into W X W X s · W
Figure 1. ① folds (Section 1), ② keeps its statistics (Section 2), ③ needs a fix (Section 3).

Each change on this page is done once, offline, before export; the only part that still runs on every token is LayerNorm's centering and division, and RMSNorm's division [2, 5, 6].

ChangeWhen it runsWhat it uses and stores
BN foldonce, offline, after training and before exportthe trained γ, β, μ, σ²; stores nothing new and removes a layer [2]
LN gain foldonce, offlineγ moves into the next linear weights and β into its bias; centering and division still run every forward pass [6]
SmoothQuantonce, offline, from calibration samplesper-channel factors fold into the previous LayerNorm and the next linear weights; no extra runtime operation [5]

1 BatchNorm folds into the conv before quantizing

At inference, BatchNorm with fixed statistics only multiplies by a number and adds a number per channel [1]. A conv is already a multiply and add, so the two collapse into one conv with new weights, and nothing is left to quantize between them. Take one output channel of a 1 × 1 conv followed by BatchNorm2d, the Conv, BN pair that static quantization (Quantization, section 6) fuses first.

Illustrative numbers
input x = (1, 1) 1 × 1 conv w = (0.5, −1) b = 0.2 1 BatchNorm2d γ = 2, β = 0.1 μ= 1, σ² = 0.25 conv: 0.5 × 1 + (−1.0) × 1 + 0.2 = −0.3 BN: 2 × (−0.3 − 1.0)/0.5 + 0.1 = −5.1 2 fold: γ/σ= 4 3 one conv: W̃ = (2, −4), b̃ = −3.1 b̃ = 2 × (0.2 − 1.0)/0.5 + 0.1 = −3.1 W̃ x + b̃ = 2 − 4 − 3.1 = −5.1
Figure 2. ① folds into the conv at ②, and ③ gives the same −5.1 (ε = 0 here).

With ε = 0.00001 both paths give −5.0999. Put the conv output into BN's inference line and regroup.

Folding BatchNorm into the conv before it
y = γ W x + b − μ√σ² + ε + β
W holds the conv's weights, b its bias and x its input; μ and σ² are BN's running mean and variance, γ and β its learned gain and shift, ε the small constant that keeps the division safe. The conv output goes straight into line 3 of the running-statistics card in Normalization Layers, per output channel. In Figure 2, y = −5.1.
W̃ = γ√σ² + ε W
New weights: each output channel's filter times its own γ/σ. In Figure 2, W̃ = (2, −4).
b̃ = γ b − μ√σ² + ε + β,
 so y = W̃ x + b̃
The folded conv gives the same answer from one layer; Figure 2 shows both paths. A conv with no bias uses b = 0. In Figure 2, b̃ = −3.1.

Term by term, γ/σ is the per-channel scale, and it grows when σ is small, which can widen the range of weights to quantize: in MobileNet v1 the folded weights show larger outliers than the unfolded ones [3]. μ and β only move the bias. Folding removes the BN compute, its data movement and the quantization of its output [2]. In PyTorch, by hand and with the library call:

import torch
from torch import nn
from torch.nn.utils import fusion

conv = nn.Conv2d(3, 8, 3, bias=False).eval()
bn = nn.BatchNorm2d(8).eval()
with torch.no_grad():
    sd = torch.sqrt(bn.running_var + bn.eps)
    s = bn.weight / sd
    w = conv.weight * s.view(-1, 1, 1, 1)
    b = bn.bias - bn.running_mean * s
fused = fusion.fuse_conv_bn_eval(conv, bn)
  1. 1The pair, both in eval mode.
  2. 2Lines 2 and 3 by hand: s is γ/σ, and b uses b = 0 because bias=False.
  3. 3The library call asserts eval mode ("Fusion only for eval!"): failure row 1 [8].

During quantization-aware training the batch statistics would make these folded weights change every step, so BN switches to its running statistics after a delay; the schedule and the PyTorch recipe live in Freezing observers and BatchNorm statistics in QAT (Quantization, section 13).


2 LayerNorm and RMSNorm keep their division at runtime

LayerNorm and RMSNorm take their statistics from the token in front of them, so the mean and spread are new for every token and cannot be baked into weights. What can move is the gain: γ multiplies each feature right before the next linear layer, so it folds into that layer's weights as diag(γ) Win, where diag(γ) is the matrix with γ on its diagonal and Win the next layer's weights [6]. β folds into that layer's bias as β Win. That works only when the norm's output feeds linear layers alone, the Pre-LN case.

Pre-LN: γ feeds only the next weights ÷ RMS × γ 1 Win ÷ RMS diag(γ) Win after folding Post-LN: the residual reads it too ÷ RMS × γ next W residual add 2 cannot move
Figure 3. ① folds into weights that are its only reader; in Post-LN ② cannot move.

3 A few huge features make LLM activations hard to quantize

LLM.int8() measured a few huge activation features, the outliers, in language models [4]; earlier work it cites traces them to LayerNorm.

LLM.int8() measurement [4]Result
ModelsGPT-2, Fairseq and GPT-J, 125M to 13B parameters; 8-bit inference on OPT (Meta's open GPT-style LLMs) up to 175B
Outlier sizeup to 20 times the other dimensions
Whereabout 25% of layers at first; all layers from about 6.7B parameters
At 6.7B150,000 outliers per sequence, in only 6 feature dimensions
Zeroing themperplexity (how surprised the model is by real text, lower is better) rises 600 to 1000%

One outlier channel stretches the range for the whole activation tensor, so every other channel gets only a few of the 256 levels. Why not give each activation channel its own scale, like the per-channel weight scales in Quantization? Per-channel activation scales do not map well to hardware-accelerated matrix-multiply (GEMM) kernels [5].

3.1 SmoothQuant moves the outlier size into the weights

Weights are easy to quantize and activations are not, so shift part of the size to the weights and fold the division into the LayerNorm gain from Section 2. Take 2 illustrative tokens shaped like an OPT layer's input, entering a linear layer, with input channel 1 standing for an outlier channel (real outliers are about 100 times larger than most activations [5]). With one int8 range for the whole activation tensor, channel 2 uses only 8 of the 256 levels.

Illustrative numbers
X: tokens × channels 641 −602 1 max 64max 2 W: row j = channel j 0.50.2 0.4−0.5 max 0.5max 0.5 channel 2 levels: 256 × 2/64 = 8 s₁ = √64 / √0.5 = 11.31, s₂ = √2 / √0.5 = 2.0 64 / 11.31 = 5.66 = 0.5 × 11.31 = √64 × 0.5 XW = (32.4, 12.3; −29.2, −13.0), before and after first entry: 64 × 0.5 + 1 × 0.4 = 32.4
Figure 4. Outlier ①: at α = 0.5 its activation and weight maxima both become 5.66.

Divide each activation channel by a factor and multiply its weight row by the same factor.

SmoothQuant: divide the activations, multiply the weights
Y = (X diag(s)−1) (diag(s) W)
X holds the tokens' activations, W the layer's weights and Y its output. diag(s) is the matrix with s on its diagonal: divide input channel j by sj, multiply weight row j by the same sj, and the product does not change, as Figure 4 shows. Y = (32.4, 12.3; −29.2, −13.0).
sj = max|Xj|αmax|Wj|1 − α
α (alpha), the migration strength, sets how much of the size moves to the weights. With α = 0.5, channel 1's activation max and its weight row max become equal: the two share the difficulty. Here s = (11.31, 2.0).

Term by term, diag(s)−1 shrinks the outlier channel, and channel 2 rises from 8 to about 45 levels; diag(s) grows the matching weights, whose tensor max rises from 0.5 to 5.66, so weight row 2 also gets about 45 levels instead of all 256. The difficulty is split, not removed. Because X comes out of a LayerNorm, the division by s folds offline into that LayerNorm's γ and β, so no extra operation runs [5], the same gain-folding move as Section 2. Slide α to move the difficulty:

Illustrative numbers
1 act ch 1 act ch 2 W row 1 W row 2 0.25141664 int8 levels for activation channel 2 2 int8 levels for weight row 2

α = 0.5: activation channel 2 gets 45 of 256 levels, weight row 2 gets 45

Figure 5. Faint bars: before smoothing. Raising α moves size from ① into the weights.

The dashed ticks ② mark the levels before smoothing. At α = 0, s = 1/max|W| = 2 halves both activation maxima, so the levels stay at ②. In PyTorch, smoothing one LayerNorm and linear pair from recorded activations:

import torch
from torch import nn

ln, lin = nn.LayerNorm(2), nn.Linear(2, 2, bias=False)
acts = torch.tensor([[64., 1.], [-60., 2.]])
x_max = acts.abs().amax(0)
w_max = lin.weight.abs().amax(0)
alpha = 0.5
s = x_max**alpha / w_max**(1 - alpha)
with torch.no_grad():
    ln.weight.div_(s)
    ln.bias.div_(s)
    lin.weight.mul_(s)
  1. 1The pair and acts, the LayerNorm outputs recorded on 2 calibration tokens (Figure 4's X).
  2. 2Line 2. nn.Linear stores its weight as out × in, so amax(0) gives one max per input channel [7]. The weights here are random, so s differs from (11.31, 2.0).
  3. 3Line 1, folded into LayerNorm's γ and β; mul_(s) broadcasts s along the input dimension.

4 When normalization breaks quantization

SymptomCauseFixWhat to log
fuse_conv_bn_eval raises "Fusion only for eval!"the modules are still in training mode [8]call eval() on both firstnothing; it fails loudly
an LLM of about 6.7B parameters or more loses accuracy under plain 8-bit activation quantizationoutlier features set the range [4]mixed-precision decomposition (LLM.int8()) or SmoothQuantper-channel activation max on calibration data
outputs change after folding a LayerNorm gainthe norm's output also fed the residual stream (Post-LN)fold γ only into layers that are the norm's only reader (Figure 3)outputs before and after the fold

5 Folding and smoothing in practice

BN folding has no knob. SmoothQuant has one:

KnobDefaultWhat it does
α, migration strength0.5larger α moves more of the outlier's size into the weights (Figure 5); SmoothQuant uses 0.5 for all OPT and BLOOM (BigScience's open multilingual LLM) models and a larger α such as 0.75 when activation outliers are more significant [5]

When accuracy still drops after folding and smoothing, Debugging a Quantized Model shows how to measure where the error comes from.


6 References

  1. Ioffe, S., and Szegedy, C. (2015). Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift. ICML 2015. arXiv:1502.03167.
  2. Nagel, M., et al. (2021). A White Paper on Neural Network Quantization. arXiv:2106.08295.
  3. Krishnamoorthi, R. (2018). Quantizing deep convolutional networks for efficient inference: A whitepaper. arXiv:1806.08342.
  4. Dettmers, T., et al. (2022). LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale. NeurIPS 2022. arXiv:2208.07339.
  5. Xiao, G., et al. (2023). SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models. ICML 2023. arXiv:2211.10438.
  6. Ashkboos, S., et al. (2024). SliceGPT: Compress Large Language Models by Deleting Rows and Columns. ICLR 2024. arXiv:2401.15024.
  7. PyTorch 2.15 documentation: BatchNorm2d, LayerNorm, Linear.
  8. PyTorch source, torch/nn/utils/fusion.py (fuse_conv_bn_eval, fuse_conv_bn_weights). GitHub.