Skip to content
← Quantization & Numerics / 05
hardNewNVIDIAMetaMicrosoft (Microsoft AI)

Why do activation outliers break INT8 W8A8 on LLMs, and how does SmoothQuant fix it by moving scale into the weights?

A handful of channels with huge activations wreck the INT8 grid for every other channel in the token. The answer shows why the obvious fix cannot run on a tensor core, what SmoothQuant does instead, and why FP8 makes most of the problem disappear.

Updated Oct 2026 · Worked answers on inference mechanisms, with calculations, implementation details, and follow-up questions.

TL;DR: LLM activations carry a few channels that are tens of times larger than the rest, in every token, so a per-token INT8 scale spends its 127 levels on the outliers and leaves ordinary channels with one or two. Per-channel activation scales would fix it but vary along the GEMM's reduction axis, so they cannot be factored out. SmoothQuant divides each activation channel by sj=max⁡∣Xj∣α/max⁡∣Wj∣1−αs_j = \max|X_j|^{\alpha} / \max|W_j|^{1-\alpha} and multiplies the matching weight column by sjs_j, an exact rewrite; in a synthetic test it cuts W8A8 output error from 9.5% to 1.8%.

How to approach it

Start from where the outliers sit: along channels (the hidden dimension), consistently across tokens, not in random tokens. That one fact explains both the failure and the fix. Then ask the target format, because INT8 and FP8 respond very differently to outliers, and whether activation scales are static or dynamic per token.

A strong answer

Why INT8 fails. A W8A8 GEMM needs scales it can factor out of the dot product: per-token for activations and per-output-channel for weights, both applied in the epilogue. A per-token scale is set by that token's largest element, which is almost always in an outlier channel. Everything else in the token then shares a grid sized for the outlier:

synthetic activations, 4,096 channels, 6 outlier channels at 60x
  largest channel max / median channel max   = 73
  levels left for a typical channel          = 127 / 73 = 1.7 steps
  that is values in {-2 ... 2}, about 2.3 bits of a nominal 8

The weights are the easy side: they have no comparable outliers, and per-channel weight scales handle them.

Why the direct fix does not work. Per-input-channel activation scales sjs_j would fix the activation grid, but jj is the reduction index: ∑j(xj/sj) wj\sum_j (x_j / s_j) \, w_j cannot be rescaled after the sum. The kernel would have to apply scales inside the main loop, losing the plain INT8 tensor-core path.

What SmoothQuant does. Because XW⊤=(Xdiag⁡(s)−1)(Wdiag⁡(s))⊤XW^\top = (X \operatorname{diag}(s)^{-1})(W \operatorname{diag}(s))^\top exactly, the per-channel scale can be moved out of the runtime activations and into the weights offline. Divide activation channel jj by sjs_j, multiply weight input-column jj by sjs_j, and split the difficulty between them with one exponent α\alpha:

sj=max⁡t∣Xt,j∣αmax⁡n∣Wn,j∣1−αs_j = \frac{\max_t |X_{t,j}|^{\alpha}}{\max_n |W_{n,j}|^{1-\alpha}}

The 1/sj1/s_j on activations costs nothing at runtime: it folds into the preceding RMSNorm gain for the QKV and gate/up projections, and into the rows of the producing projection for the others. The weight columns absorb sjs_j before quantization.

import numpy as np

def q8(t, axis=None):
    """Symmetric INT8 fake-quant; axis=None per-tensor, else max over that axis."""
    amax = np.abs(t).max() if axis is None else np.abs(t).max(axis, keepdims=True)
    s = amax / 127
    return np.clip(np.round(t / s), -127, 127) * s

def w8a8_error(X, W):                       # X [tokens, in], W [out, in]
    Y = X @ W.T
    Yq = q8(X, axis=1) @ q8(W, axis=1).T    # per-token acts, per-channel weights
    return np.linalg.norm(Yq - Y) / np.linalg.norm(Y)

def smooth(X, W, alpha=0.5):
    s = np.abs(X).max(0) ** alpha / np.abs(W).max(0) ** (1 - alpha)   # [in]
    return X / s, W * s                     # X W^T is unchanged

rng = np.random.default_rng(0)
X = rng.normal(size=(512, 4096))
X[:, rng.choice(4096, 6, replace=False)] *= 60       # a few outlier channels
W = rng.normal(size=(1024, 4096)) * 0.02
Xs, Ws = smooth(X, W)
print(f"before {w8a8_error(X, W):.4f}  after {w8a8_error(Xs, Ws):.4f}")

It prints before 0.0949 after 0.0181. Sweeping α\alpha shows the trade directly: the outlier ratio moves from activations to weights.

alpha   act max/median   weight max/median   W8A8 relative error
0.25    25.4             2.9                 3.5%
0.50     8.6             8.6                 1.8%   (balanced, the minimum here)
0.75     2.9            25.4                 3.5%
1.00     1.0            74.8                 9.8%   (all difficulty pushed to weights)
synthetic data; the SmoothQuant authors use 0.5 for most models and a larger
alpha where activation outliers are more severe (arXiv 2211.10438)

The reversal condition is the format. FP8 E4M3 is floating point, so a small value keeps about 3 mantissa bits of relative precision however large the token's maximum is, until it falls below the subnormal range. On the same synthetic data with per-token FP8 activations, the error is 3.3% before smoothing and 2.9% after. So on Hopper and Blackwell, where FP8 W8A8 runs at twice the BF16 tensor-core rate, SmoothQuant is a minor refinement. It earns its keep on INT8 paths: Ampere's A100 (INT8 tensor cores, no FP8), many edge NPUs, and per-tensor static activation scales, where the same data shows 21% error dropping to 2.9%.

What interviewers probe next

"Why not keep outlier channels in FP16 like LLM.int8()?" That splits the GEMM into a dense INT8 part and a sparse FP16 part with a gather, which costs throughput; SmoothQuant keeps one dense INT8 GEMM.

"Which layers can be smoothed for free?" Any linear whose input passes through an op that can absorb a per-channel factor offline: a norm gain, or a preceding linear's output rows. A residual-stream input with no such op needs an explicit scale multiply.

"Now the target is W4A4." Smoothing alone is not enough at 4 bits. Rotation methods that spread outliers across all channels, or block-scaled FP4 with a scale per 16 values, are the tools there.

How it's scored

Weak: says outliers "hurt accuracy" and proposes clipping them, or proposes per-channel activation scales without seeing they block the INT8 GEMM.

Hire: explains the channel-wise structure of outliers, why per-token scales waste the grid, and the exact rewrite that moves scale into the weights.

Strong Hire: quantifies levels lost to the outlier, explains the alpha trade with numbers, folds 1/s1/s into the preceding op, and states that FP8's floating-point grid makes smoothing marginal on Hopper.

Common mistakes

  • Clipping outlier activations. Those channels carry real signal, and clipping them damages the model more than coarse rounding.
  • Applying the smoothing factor at runtime as a separate multiply, adding a pass over the activations.
  • Choosing alpha 1.0 because it eliminates activation outliers, then discovering the weights now carry them.
  • Computing smoothing factors from a calibration set too small to contain the outlier channels' true maxima.
You just read a free answer. Every topic opens its first 5 answers to anyone. Sign in and the count per topic goes to 10, and your progress through each topic is saved.Google sign-in · free · no payment details
HOW DID IT GO?
0
READING SIGNED OUT

Signing in doubles your free answers, from 5 to 10 per topic, and the site starts remembering you: mastery per topic, bookmarks, and a next-focus recommendation. Free, no card.

Sign in free
UP NEXT ON YOUR JOURNEY
DISCUSSION · 0

Nobody has commented on this answer. Add a correction or another way to work it.