Inference, Serving & Scaling
IC5IC6

Quantization: INT8, FP8, INT4 and the 2026 Sweet Spot

Trading bits for throughput — how INT8, FP8, and INT4 reshape the memory wall, and why FP8 on Blackwell is the default you'll defend in a senior interview.

15 min read · 13 sections
Prerequisites: /inference/kv-cache, /ml-foundations

1. Quick anchor

Quantization stores a model's numbers in fewer bits — 8 or 4 instead of 16 — so they take less memory and less memory bandwidth to read. That second part is the whole game: LLM decode is memory-bound, not compute-bound, so shrinking the bytes you drag across the bus per token is the most direct lever on latency and cost. But not all quantization is equal: weight-only (GPTQ, AWQ) shrinks the model on disk and helps memory-bound decode, while activation quantization (SmoothQuant, FP8) actually feeds smaller numbers into the matmul and unlocks the tensor cores for raw throughput. The hard part is quality: low-precision formats clip outliers, and a handful of outlier channels carry most of a transformer's signal. In June 2026 the default answer for a frontier serving stack is FP8 on Hopper/Blackwell for weights, activations, and KV cache — with INT4 weight-only reserved for memory-constrained, low-batch, or edge regimes where you knowingly trade some quality for fitting the model at all.

2. Why interviewers probe this

Quantization is where a candidate either parrots "INT4 makes it 4x faster" or demonstrates they've actually profiled a serving stack. The signal differs sharply by level.

  • IC5 — Can you separate the three orthogonal axes (weights vs activations vs KV cache; what's quantized vs what's computed)? Do you know that weight-only INT4 often does not speed up a compute-bound prefill, and why? Can you name GPTQ/AWQ/SmoothQuant and say what each actually changes?
  • IC6 — Can you reason about a heterogeneous fleet: which precision on which hardware, how disaggregation interacts with quant choice, how you'd budget a quality-regression gate before rollout, and how token economics shift when FP4 lands on Blackwell? Can you defend FP8 as the 2026 default against someone pushing INT4 everywhere?

The trap at both levels is conflating memory savings with speedup. They are different claims with different bottlenecks, and the interviewer is listening for whether you keep them separate.

3. Concept build-up

Beginner explainerNew here? The words first

The words first.

  • Precision / bit-width — how many bits store one number. FP16 = 16 bits, INT8 = 8, INT4 = 4. Fewer bits = less memory, less detail.
  • Weights — the model's learned parameters, fixed after training. Read-only at inference.
  • Activations — the intermediate numbers that flow through the model as it processes your specific input. Different every request.
  • Quantization — mapping a continuous range of values onto a small grid of integers using a scale factor, e.g. q = round(x / scale).
  • Outlier — a value far larger than its neighbors. A few channels in LLM activations are huge; they wreck naive quantization.
  • KV cache — stored keys/values for past tokens so you don't recompute them. Grows with sequence length; often the biggest memory hog.
  • Memory-bound — the GPU is waiting on memory reads, not arithmetic. Decode is here.
  • Compute-bound — the GPU is saturated doing math. Prefill is here.

Step by step.

  1. Pick what to quantize: weights only, or weights + activations, or also the KV cache.
  2. Choose a format: INT8, FP8, or INT4, each a different grid of representable values.
  3. Compute scale factors (per-tensor, per-channel, or per-group) that map FP16 values onto that grid.
  4. For activation quant, deal with outliers first (SmoothQuant) or they blow up the error.
  5. Store/serve the smaller numbers; dequantize on the fly or compute directly in low precision.
  6. Measure two things separately: memory saved, and tokens/sec gained. They are not the same.
  7. Gate on quality: run evals before vs after, because the loss is silent until a user hits it.

Remember this: fewer bits saves memory for sure, but only buys speed when it removes your actual bottleneck — and only ships if quality holds.

3.1 The two numbers you're trading: bytes and FLOP/byte

Start from the roofline. A matmul's throughput is bounded by either compute (FLOP/s) or memory bandwidth (bytes/s), whichever you hit first. The crossover is arithmetic intensity — FLOP done per byte read.

Prefill processes the whole prompt as matrix-matrix multiplies: arithmetic intensity is high (200-400 FLOP/byte on H100), the GPU runs at 90-95% utilization, and it's compute-bound. Decode generates one token at a time as matrix-vector multiplies against the KV cache, with essentially zero data reuse — every generated token re-reads the entire KV cache from HBM. Intensity collapses ~5x to 60-80 FLOP/byte, utilization falls to 20-40%, and it's memory-bound. Real serving kernels confirm this: measured implementations achieve roughly 23% compute-bandwidth utilization but ~47% memory-bandwidth utilization — DRAM bandwidth is the saturated resource.

This split is the master key to the whole lesson:

  • Weight-only quantization (INT4 weights, FP16 compute) shrinks the bytes you read for weights. In memory-bound decode that directly cuts time-per-output-token. In compute-bound prefill it does almost nothing for speed — you still up-convert to FP16 and do the same FLOP, plus a dequant tax. This is why "W4A16 often fails to deliver speed in production serving" is a true and load-bearing statement.
  • Activation quantization (INT8/FP8 weights and activations) feeds smaller numbers into the matmul itself, so the tensor cores execute low-precision math. This raises effective compute throughput and helps the compute-bound regime — but it's harder, because activations have nasty outliers.

3.2 How a quantizer actually maps numbers

Quantization picks a scale s (and sometimes a zero-point z) and maps a float x to an integer q = round(x / s) + z, recovering x_hat = (q - z) * s. The error is bounded by s/2 per element, so the entire art is choosing s to be as small as possible without clipping real values.

Granularity controls how many values share one scale:

  • Per-tensor: one scale for a whole weight matrix. Cheapest, coarsest, most error.
  • Per-channel: one scale per output channel. Standard for weights.
  • Per-group: one scale per group of (say) 128 weights along the input dim. Finer, used by GPTQ/AWQ for INT4 to control error.

INT vs FP formats differ in how the grid is spaced:

  • INT8/INT4 are uniform — evenly spaced steps. Great when values are uniformly distributed, bad for the long heavy tails LLM activations actually have.
  • FP8 (E4M3 / E5M2) is non-uniform — a tiny floating point with an exponent, so it has fine resolution near zero and coarse resolution far out. That shape matches LLM value distributions far better, which is exactly why FP8 holds quality at 8 bits more gracefully than INT8 and is the 2026 default on hardware that supports it.
Per-channel INT8 weight quant — on real numbers

Symbols: x = a float weight, s = scale (largest magnitude / 127 for symmetric INT8), q = stored 8-bit integer, x_hat = recovered value.

Take one output channel's weights: [0.10, -0.42, 1.27, 0.03]. Max magnitude = 1.27, so s = 1.27 / 127 = 0.01.

Quantize: q = round(x / s)

  • 0.10 / 0.01 = 10
  • -0.42 / 0.01 = -42
  • 1.27 / 0.01 = 127
  • 0.03 / 0.01 = 3

Stored INT8: [10, -42, 127, 3] — 4 bytes instead of 8 (these were FP16). Recover: x_hat = q * s[0.10, -0.42, 1.27, 0.03]. Here error is ~0 because the values landed on grid; the worst-case error per weight is s/2 = 0.005.

What it did: replaced 16-bit floats with 8-bit integers plus one shared scale per channel, halving weight bytes — the read that decode is bottlenecked on.

3.3 The outlier problem (and why activation quant is hard)

If you try the same per-tensor INT8 trick on activations, you fail. A handful of channels in LLM activations have magnitudes 10-100x the rest. With one shared scale, those outliers force s huge, and every normal value collapses into a few grid steps — catastrophic error. This is the central obstacle that the named methods solve in different ways.

◐ InteractiveQuantization: weight memory
140 GB weights only

70B params × 2 bytes = 140 GB (FP16baseline quality). Add the KV cache and activations on top — and you need it to fit in GPU HBM (e.g. an H100 has 80 GB). Going FP16 → INT4 is a memory cut.

SmoothQuant (Xiao et al., 2022) is the elegant fix for W8A8. Its insight: the difficulty is migratable. For a linear layer Y = X · W, you can insert a per-channel scaling vector s and rewrite it as Y = (X / s) · (s · W) — mathematically identical, but now you've divided the heavy-tailed activations by s (taming outliers) and multiplied the well-behaved weights by s (which absorb the difficulty easily, since weights are flat and uniform). The scaling is folded offline into the preceding layer norm, so there's no runtime cost. Result: INT8 on both activations and weights, ~1.56x speedup and 2x memory reduction on models up to 530B params, with negligible accuracy loss.

Why it matters for the interview: without SmoothQuant (or an FP8 format whose non-uniform grid tolerates outliers natively), W8A8 quality falls off a cliff. This is the difference between "quantized the weights, easy" and "quantized the activations, knew what I was doing."

3.4 Weight-only INT4: GPTQ vs AWQ

When you only need to fit the model and you're memory-bound at low batch, INT4 weight-only wins. Two methods dominate:

  • GPTQ quantizes weights column-by-column, using second-order (Hessian) information to update the not-yet-quantized weights to compensate for the error just introduced. Accurate, 4-bit, GPU-focused, somewhat slow to produce.
  • AWQ (Activation-aware Weight Quantization) starts from a sharper observation: not all weights matter equally. The ~1% of weight channels aligned with large activation magnitudes dominate the output. AWQ protects those salient channels with per-channel scaling (derived from activation statistics, not weights), and quantizes the rest to INT4. It's faster to produce than GPTQ, often higher quality for weight-only, and broadly supported across serving frameworks.

GGUF is the format that confuses people in interviews: it is not a quantization method, it's a container format (successor to GGML) optimized for CPU and Apple Silicon inference via llama.cpp, supporting flexible precisions (Q4_K_M, Q5_K_M, etc.). If a candidate calls GGUF "a quantization algorithm," that's a tell. It's the file format your INT4 weights live in for local/edge deployment.

3.5 KV-cache quantization

The KV cache is frequently the largest and fastest-growing memory consumer — and because decode re-reads it every token, quantizing it cuts both capacity pressure and the bandwidth bottleneck directly. Two choices:

  • FP8 KV cache — supported on Hopper (H100) and Blackwell (B200). Halves KV memory and KV memory traffic versus BF16, with the non-uniform grid preserving quality. This is the recommended default on modern hardware.
  • INT8 KV cache — universally supported back to Pascal, the portable fallback when FP8 hardware isn't available (A100, RTX 4090).

The frontier is going lower: TurboQuant (ICLR 2026) reports a K=4-bit + V=2-bit combination with near-zero accuracy loss, exploiting that keys and values tolerate quantization differently. The takeaway: KV-cache quant is often the highest-leverage quant decision for long-context serving, because it attacks memory and bandwidth simultaneously.

4. Minimal implementation

Below is production-shaped vLLM serving with FP8 weights + activations and an FP8 KV cache (the 2026 Hopper/Blackwell default), plus a small standalone function that makes the per-channel INT8 math from section 3.2 concrete and runnable.

# serve_fp8.py — vLLM with FP8 weights+activations and FP8 KV cache.
# Runs on Hopper (H100) or Blackwell (B200). Requires vllm >= 0.8.
from vllm import LLM, SamplingParams
 
llm = LLM(
    model="meta-llama/Llama-3.1-70B-Instruct",
    quantization="fp8",            # weights + activations in FP8 (E4M3)
    kv_cache_dtype="fp8",          # halve KV memory + KV bandwidth
    tensor_parallel_size=2,        # shard across 2 GPUs; activations-only comms
    gpu_memory_utilization=0.92,   # leave headroom for the paged KV pool
    max_model_len=32768,
    enable_prefix_caching=True,    # reuse shared system-prompt KV across requests
)
 
# Quick A/B harness: compare FP8 output to a BF16 reference on YOUR eval set,
# because quant quality loss is silent until a user trips it.
prompts = ["Explain the memory-bandwidth wall in LLM decode."]
out = llm.generate(prompts, SamplingParams(temperature=0.0, max_tokens=256))
print(out[0].outputs[0].text)
# int8_channel.py — the actual per-channel symmetric INT8 quantizer.
# This is what "weight-only INT8" does under the hood; no framework needed.
import numpy as np
 
def quantize_per_channel_int8(W: np.ndarray):
    """W: [out_features, in_features] float weights.
    Returns int8 weights + per-channel (per-row) scales."""
    # one scale per output channel = per row; keepdims for broadcasting
    absmax = np.abs(W).max(axis=1, keepdims=True)        # [out, 1]
    scale  = absmax / 127.0                               # symmetric INT8 range
    scale  = np.maximum(scale, 1e-8)                      # avoid div-by-zero
    q      = np.round(W / scale).clip(-127, 127).astype(np.int8)
    return q, scale.astype(np.float32)
 
def dequantize(q: np.ndarray, scale: np.ndarray):
    return q.astype(np.float32) * scale                  # x_hat = q * s
 
if __name__ == "__main__":
    rng = np.random.default_rng(0)
    W = rng.standard_normal((4, 8)).astype(np.float32)   # toy weight matrix
    q, s = quantize_per_channel_int8(W)
    err = np.abs(W - dequantize(q, s)).max()
    bytes_fp16, bytes_int8 = W.size * 2, q.size + s.size * 4
    print(f"max abs error: {err:.4f}")                   # bounded by max(scale)/2
    print(f"bytes FP16={bytes_fp16}  INT8(+scales)={bytes_int8}")

The first script is what you'd actually deploy; the second is what you'd whiteboard to prove you understand the mapping. Note the deliberate temperature=0.0 A/B comment — the most common production mistake is shipping a quant config without diffing it against a full-precision reference on a real eval set.

5. Production tradeoffs

Strategy Bits (W/A/KV) Memory saving Speeds up… Quality risk When to reach for it
FP16/BF16 16/16/16 baseline none Quality ceiling / debugging reference
W8A8 SmoothQuant (INT8) 8/8/16 ~2x weights compute + memory low (outliers handled) Pre-Hopper hardware, throughput-max
FP8 (E4M3) 8/8/8 ~2x w+kv compute + memory + decode very low 2026 default on Hopper/Blackwell
W4A16 AWQ/GPTQ 4/16/16 ~4x weights decode only (memory-bound) moderate Memory-constrained, low batch, edge
GGUF Q4_K_M ~4/16/16 ~4x weights CPU/Apple decode moderate Local/edge, llama.cpp
FP8 KV cache —/—/8 ~2x KV decode (bandwidth) very low Long context, any modern GPU
FP4 (Blackwell) 4/4/— ~4x compute + memory model-dependent B200, throughput-per-dollar at scale

Cost & latency. The two metrics to keep separate: TTFT (time to first token, dominated by prefill = compute-bound) and TPOT (time per output token, dominated by decode = memory-bound). Weight-only INT4 improves TPOT in low-batch decode but barely touches TTFT, and can even raise it under compute pressure due to dequant overhead. FP8/INT8 activation quant improves both because it puts real low-precision math on the tensor cores. On Blackwell, native FP4 doubles theoretical compute versus FP8 and accelerates both phases — which is why B200 dominates throughput-per-dollar at FP4, despite higher TCO and ~1000W TDP.

Failure modes. (1) Silent quality regression — quant loss doesn't crash, it degrades reasoning, code, or long-context recall in ways unit tests miss; always gate on a held-out eval. (2) Outlier blow-up — naive activation quant without SmoothQuant/FP8 destroys accuracy. (3) Speedup mirage — measuring memory saved and claiming throughput; profile tokens/sec end-to-end. (4) Calibration drift — GPTQ/AWQ/SmoothQuant calibrate on a sample set; an unrepresentative calibration corpus bakes in error.

What changes at scale. With continuous batching, decode stops being purely memory-bound as batch size grows (you reuse weights across many sequences), which shrinks weight-only INT4's advantage and grows activation-quant's. In a disaggregated prefill/decode deployment you can even pick precision per phase — FP8/FP4 prefill on compute-dense B200, FP8 decode on high-bandwidth H200 — letting quant choice follow the bottleneck rather than forcing one config everywhere. See /inference/batching and /system-design.

6. How it's asked

[IC5] You serve a 70B model at FP16 and the GPU OOMs at batch 8. Walk me through whether weight-only INT4 actually buys you throughput, and what you'd measure to confirm. INT4 weight-only roughly quarters weight memory, so it solves the OOM and lets you raise batch size — that's the real win here, capacity, not raw speed. Whether it buys throughput depends on the regime: in low-batch memory-bound decode, smaller weight reads cut TPOT directly; but if your workload is prefill-heavy or you batch large, you're compute-bound and INT4 weights up-convert to FP16 anyway, adding a dequant tax with little speedup. I'd measure tokens/sec end-to-end (not just memory freed), broken into TTFT and TPOT, at the new batch size — and A/B output quality against the FP16 reference on a real eval set before trusting it.
[IC5] Explain SmoothQuant in one paragraph. Why does it exist, what does it move, and what would break without it? SmoothQuant exists because activations have a few outlier channels 10-100x larger than the rest, and a single INT8 scale forced by those outliers crushes all the normal values into a handful of grid steps. It rewrites X·W as (X/s)·(s·W) — mathematically identical — dividing the heavy-tailed activations by a per-channel s to tame outliers, and multiplying the flat, well-behaved weights by s, which absorb that difficulty easily. The scaling folds offline into the prior layer norm, so there's no runtime cost. Without it, W8A8 activation quant suffers catastrophic accuracy loss; you'd be forced back to weight-only INT8 and lose the activation-quant throughput gains. FP8's non-uniform grid is an alternative that tolerates outliers natively without the rewrite.
[IC6] Design the precision plan for a multi-tenant inference fleet on mixed H100/B200 hardware. Where does FP8 go, where does INT4 go, and how do you keep quality regressions from shipping? Default everything to FP8 (weights, activations, KV cache) on both H100 and B200 — it's the 2026 sweet spot: ~2x memory and bandwidth savings, tensor-core acceleration on both phases, and a non-uniform grid that holds quality. On B200 I'd promote throughput-critical, latency-tolerant batch traffic to native FP4 for the compute-per-dollar win, gated per-model since FP4 quality is model-dependent. INT4 weight-only (AWQ) is reserved for memory-pinned tenants — very large models on smaller GPUs, or low-batch latency-sensitive paths where decode is memory-bound. If I disaggregate, precision follows the bottleneck: FP4/FP8 prefill on B200, FP8 decode on high-bandwidth nodes. The regression gate is non-negotiable: every quant config ships behind an automated eval comparing against the BF16 reference on task-representative suites (code, reasoning, long-context recall), plus a canary with online quality telemetry, because quant loss is silent.
[IC6] Someone on your team wants INT4 everywhere to cut cost. Push back from first principles. INT4-everywhere assumes memory is always the bottleneck, but it isn't. In compute-bound prefill and in large-batch continuous decode, INT4 weights are up-converted to FP16 for the matmul — you pay a dequant tax and get little or no speedup, while eating a real quality hit from the coarse uniform 4-bit grid clipping outliers. The cost lever you actually want at scale is FP8 (or FP4 on Blackwell), which does low-precision math on the tensor cores and accelerates both phases with far less quality loss. INT4 weight-only is a fitting tool — use it when you genuinely can't afford the HBM — not a default throughput strategy. The right framing is: pick precision per bottleneck and per hardware, gate on quality, and let FP8 be the baseline.

7. Pitfalls & flashcards

  • Memory saved ≠ speed gained. They're different claims with different bottlenecks. Always profile tokens/sec, split into TTFT and TPOT.
  • Weight-only INT4 doesn't help compute-bound prefill. Weights up-convert to FP16; you pay dequant overhead for nothing.
  • Activations have outliers; weights mostly don't. That asymmetry is why activation quant needs SmoothQuant or FP8, while weight quant is comparatively easy.
  • FP8's non-uniform grid is the point. It matches LLM value distributions, which is why FP8 beats INT8 on quality at the same 8 bits.
  • GGUF is a format, not a method. Q4_K_M etc. are precision presets inside a llama.cpp container.
  • Quant loss is silent. No crash, just degraded reasoning/code/recall. Gate every config on a real eval vs full-precision reference.
  • KV-cache quant is high-leverage for long context. It cuts memory and the decode bandwidth bottleneck at once.
  • Calibration corpus matters. GPTQ/AWQ/SmoothQuant bake in error from an unrepresentative calibration sample.

Flashcard. Decode is memory-bound, so shrinking bytes-per-token is the master lever — but only activation/FP8 quant feeds small numbers into the matmul and speeds up both phases; weight-only INT4 only helps memory-bound decode and is a tool for fitting the model, not a default for throughput. In 2026, FP8 on Hopper/Blackwell is the baseline; INT4 is the exception.

8. Further reading

Next: /inference/speculative-decoding — the other lossless-ish throughput lever, and how it composes with quantization.

Primary sources
← More in Inference, Serving & Scaling