The design space: PTQ/QAT, weight-only, W+A, KV
vllm/model_executor/layers/quantization/python/sglang/srt/layers/quantization/
a556f3f · sglang 7d89325SGLang will start a server, load your quantized checkpoint, and then log "%s quantization is not fully optimized yet. The speed can be slower than non-quantized models." That warning is the whole of Part 4 in one line: quantization is not one knob, it is six coupled design choices, and an unsupported or poorly matched combination can be slower than leaving the weights unquantized.
The problem
Here is the warning, at the bottom of SGLang's config validation:
if self.quantization not in optimized_quantization_methods:
# Don't warn for MXFP4/MXFP8 on SM100 since they have optimized kernels
if not (
self.quantization in ["mxfp4", "mxfp8"] and is_sm100_supported()
):
logger.warning(
"%s quantization is not fully "
"optimized yet. The speed can be slower than "
"non-quantized models.",
self.quantization,
)
Of the 31 real method names SGLang accepts on --quantization — 32 choices, one
of which is the unquant escape hatch — 12 are absent from the
optimized_quantization_methods allow-list, including awq,
gptq, bitsandbytes, gguf and mxfp4
(python/sglang/srt/server_args.py:L143-L179,
python/sglang/srt/configs/model_config.py:L1473-L1496). They load fine and may run
slower than bf16. And even inside the allow-list, "optimized" means "there is a fast
kernel for this format", not "this will make your deployment faster" — that depends on
your batch size, and no engine can see your batch size at startup.
The second failure mode is quieter. Take Llama-3-8B to 4-bit weights and you expect 16.06 GB to become 4.0 GB. It becomes 5.73 GB, because 13% of the parameters are the embedding and the LM head and almost nobody quantizes those, and because a group-128 scale plus a zero-point is 0.156 extra bits on every weight. 2.80×, not 4×. Nothing warned you. The arithmetic is in §4.1.3.
This chapter is the map. It gives you the axes, the metadata cost of each, and a decision procedure that starts from a workload description and ends at a scheme. §4.2 does the algorithms that pick the scales, §4.3 does the kernels that consume them, and §4.4 does the measurement methodology. Number formats themselves — bit layouts, dynamic range, the affine map $x \approx s(q - z)$, accumulation dtype — belong to §0.5 and are not re-derived here.
Mental model
A quantization scheme is a point in a six-dimensional space. Five of the dimensions are independent of each other; the sixth (accumulation) is independent of all of them and is the one people forget.
What
Weights, activations, KV cache. Three separate tensor populations with three separate traffic profiles. A scheme names a subset.
When
Post-training on a calibration set, or during training. Serving engines only ever load the result; the distinction shows up as checkpoint quality, not as engine code.
Granularity
How many elements share one scale: the whole tensor, one channel, one token, one group of 32/64/128, one 2D block. Buys accuracy, costs metadata bytes and kernel complexity.
Symmetry
Is there a zero-point? Asymmetric fits skewed distributions better and costs a second metadata array plus a cross-term in the GEMM.
Static vs dynamic
Activation scales frozen at calibration, or recomputed every forward pass. Only applies to activations — weights are always static.
Accumulation
Storage dtype and accumulate dtype are separate choices. INT4 weights still accumulate in fp32. See §0.5.
vLLM encodes exactly this in one frozen dataclass. If you read nothing else in the
quantization tree, read QuantKey:
@dataclass(frozen=True)
class ScaleDesc:
"""
Class for describing a single quantization scaling factor.
dtype: data type of the scale
static: static scale if True, dynamic if False
group_shape: group shape of the scale
"""
dtype: torch.dtype
static: bool
group_shape: GroupShape
# ...
@dataclass(frozen=True)
class QuantKey:
"""
Class for identifying the type of quantization.
dtype: quantized data type
scale: scale descriptor
scale2: second-level scale descriptor
symmetric: symmetric if True, asymmetric if False
"""
# ...
dtype: torch.dtype | ScalarType
scale: ScaleDesc
scale2: ScaleDesc | None = None
symmetric: bool = True
Storage dtype, granularity (group_shape), static-vs-dynamic
(static), symmetry (symmetric), and a second-level scale for
two-tier formats like NVFP4. That is axes 1–5 minus "which tensor", and "which tensor" is
supplied by where the key is used: weight_quant_key or
activation_quant_key.
Figure 1 — one Llama-3-8B transformer block, coloured by which axis owns each tensor. Weight bytes are bf16 and per block; there are 32 such blocks. KV bytes are per token per block. Activation bytes shown at batch 1 decode. Derived from $d=4096$, $h_{kv}=8$, $d_h=128$, $d_{ff}=14336$.
The figure is the whole argument for why the axes are not interchangeable. At batch 1 with a 2k context, weights are 98.1% of the bytes a decode step moves, the KV cache is 1.9%, and activations are 0.002%. Quantizing activations at batch 1 saves you nothing, because there are no activation bytes to save. Quantizing weights is the only lever that touches the 98%.
First principles: what each axis costs
Granularity: metadata is a real tax
Let $W$ be a weight matrix of $N$ output channels by $K$ input channels, $b$ the bits per quantized element, $b_s$ the bits per scale, and $g$ the number of weights sharing one scale. The effective bits per weight is
Granularity in vLLM is a two-integer object, not a string. A negative entry means "the whole extent of that dimension":
# Use proxy as NamedTuple direct subclasses cannot have static members
class _GroupShape(NamedTuple):
row: int
col: int
class GroupShape(_GroupShape):
"""
This class describes the quantization group shape.
It includes static members for common shapes (per-tensor, per-token).
"""
# ...
def is_per_tensor(self) -> bool:
return self.row == -1 and self.col == -1
def is_per_token(self) -> bool:
return self.row == 1 and self.col == -1
def is_per_channel(self) -> bool:
return self.row == -1 and self.col == 1
def is_per_group(self) -> bool:
return self.row == 1 and self.col >= 1
GroupShape.PER_TENSOR = GroupShape(-1, -1)
GroupShape.PER_TOKEN = GroupShape(1, -1)
GroupShape.PER_CHANNEL = GroupShape(-1, 1)
PER_TOKEN and PER_CHANNEL are the same shape family applied to
different operands: one scale per row of the activation matrix, one scale per output channel
of the weight matrix. That symmetry is why W8A8 with per-channel weights and per-token activations is
the natural pairing — both scale vectors factor cleanly out of the dot product, and the
GEMM epilogue applies them as a rank-1 outer product.
Figure 2 — the granularity ladder on one tile. Each panel
shows the same 8×16 patch of a weight matrix; copper outlines enclose the elements sharing
one scale. Counts under each panel are for the real down_proj,
$N \times K = 4096 \times 14336$. Derived.
QuantKeys. Derived from $b_{\text{eff}} = b + b_s/g$; scale dtypes read from
quant_utils.py:L195-L308.| Scheme | Stored bits | Scale | Group | Overhead | Effective |
|---|---|---|---|---|---|
| INT8 per-tensor | 8 | fp32 | whole tensor | ~0 | 8.000 |
| FP8 128×128 block | 8 | fp32 | 16,384 | 0.002 | 8.002 |
| INT4 group-128, symmetric | 4 | fp16 | 128 | 0.125 | 4.125 |
| INT4 group-128, asymmetric | 4 | fp16 + 4-bit zp | 128 | 0.156 | 4.156 |
| MXFP4 | 4 | E8M0 (8 bit) | 32 | 0.250 | 4.250 |
| NVFP4 | 4 | fp8 + global fp32 | 16 | 0.500 | 4.500 |
| INT4 group-32 | 4 | fp16 | 32 | 0.500 | 4.500 |
Now the whole-model arithmetic that produced the 2.80× in §4.1.1. Llama-3-8B has $8.030\times10^{9}$ parameters, of which $6.979\times10^{9}$ ($86.9\%$) sit in the 224 linear matrices of Figure 1 and $1.051\times10^{9}$ ($13.1\%$) in the untied embedding and LM head.
| Component | Params | bf16 | W4A16 g128 |
|---|---|---|---|
| 224 linear matrices | 6.979e9 | 13.96 GB | 3.63 GB |
| embedding + lm_head | 1.051e9 | 2.10 GB | 2.10 GB |
| total | 8.030e9 | 16.06 GB | 5.73 GB |
| batch-1 decode floor at 3.35 TB/s (streamed bytes) | — | 4.48 ms | 1.40 ms |
2.80×, not 4×. Flipping lm_head_quantized on — AWQ and GPTQ both
expose it (vllm/model_executor/layers/quantization/auto_awq.py:L183-L199) —
gets you to 3.24× and puts 4-bit noise directly on the logits. Most published checkpoints
leave it off.
Symmetry: why weights are almost always symmetric
An asymmetric quantizer stores $x \approx s(q-z)$ with a per-group integer zero-point $z$; a symmetric one fixes $z=0$. For weights, three things push towards symmetric. First, weight distributions in trained transformers are close to zero-mean, so the zero-point buys little. Second, $z \neq 0$ adds a cross-term to the GEMM: $\sum_k w_k x_k$ becomes $s_w s_x \sum_k (q^w_k - z_w)(q^x_k - z_x)$, whose expansion needs a column-sum correction term that either costs a pre-pass or bloats the epilogue. Third — and this is the one that actually decides it — the low-precision tensor-core instructions take signed integers, so symmetric maps onto the hardware and asymmetric does not.
vLLM's compressed-tensors predicates say this out loud, in a comment repeated verbatim across every W8A8 and W4A8 detector:
def _is_dynamic_token_w8a8(
weight_quant: QuantizationArgs, input_quant: QuantizationArgs
) -> bool:
is_8_bits = weight_quant.num_bits == input_quant.num_bits == 8
weight_strategy = (
weight_quant.strategy == QuantizationStrategy.TENSOR.value
or weight_quant.strategy == QuantizationStrategy.CHANNEL.value
)
is_token = (
weight_strategy and input_quant.strategy == QuantizationStrategy.TOKEN.value
)
is_dynamic = not weight_quant.dynamic and input_quant.dynamic
# Both symmetric and asymmetric input quantization supported.
# Only symmetric weight quantization supported.
return is_8_bits and is_token and weight_quant.symmetric and is_dynamic
The exception is 4-bit weight-only, where the dequantised weight goes into a bf16 tensor-core
GEMM anyway, so the zero-point costs nothing at the instruction level and buys real accuracy on
skewed groups. That is why AWQ carries zero_point as a first-class constructor
argument (auto_awq.py:L183-L195) and GPTQ carries is_sym
(auto_gptq.py:L106-L131).
Static vs dynamic activation scales
Weights are known at load time, so their scales are always static. Activations are not, and you get two choices. Static: run a calibration set through the model, record the observed range per tensor, freeze it into the checkpoint. Costs one fp32 per tensor, zero runtime work, and clips anything the calibration set did not see. Dynamic: compute $s = \max|x| / q_{\max}$ over each token's row inside the kernel, every forward pass. Costs a reduction over the activation and an extra scale array. A finite exact max-based scale covers that sampled row, but overflow, scale underflow/rounding, saturation, nonfinite values and implementation choices still need explicit handling.
The line in vLLM's FP8 linear method that picks between them is the clearest statement of the axis anywhere in either tree:
self.weight_block_size = self.quant_config.weight_block_size
self.block_quant = self.weight_block_size is not None
self.act_q_static = self.quant_config.activation_scheme == "static"
if self.block_quant:
assert not self.act_q_static
assert self.weight_block_size is not None
self.activation_quant_key = create_fp8_quant_key(
static=self.act_q_static,
group_shape=GroupShape(1, self.weight_block_size[0]),
)
self.weight_quant_key = create_fp8_quant_key(
static=True, group_shape=GroupShape(*self.weight_block_size)
)
else:
self.weight_quant_key = kFp8StaticTensorSym
# Use per-token quantization for better perf if dynamic and cutlass
if self.act_q_static:
self.activation_quant_key = kFp8StaticTensorSym
elif cutlass_fp8_supported():
self.activation_quant_key = kFp8DynamicTokenSym
else:
self.activation_quant_key = kFp8DynamicTensorSym
Note the assert not self.act_q_static under block quantization: the two axes are
not fully independent in practice, because a 128×128 weight block only pairs with a
1×128 activation group, and a static scale over a 128-element slice of an activation row is
not a thing anyone calibrates. The config constructor rejects the combination with an explicit
message (fp8.py:L115-L132). Everywhere else in the space, dynamic per-token is the
a common choice because it adapts to the current row. Fusing the reduction and conversion can hide some overhead, but neither higher accuracy nor zero cost is universal.
PTQ vs QAT, and why serving only sees one of them
Quantization-aware training inserts fake-quant nodes into the forward pass and lets gradient descent adapt the weights to the rounding. It produces better low-bit models, and it costs a training run. Post-training quantization takes a finished checkpoint, runs a few hundred calibration sequences through it, and solves for scales — minutes to hours on one GPU.
Serving engines are entirely agnostic. Nothing in either registry, either
QuantizationConfig base class, or any from_config reads a field saying
how the checkpoint was produced: a QAT and a PTQ checkpoint at the same (format, granularity,
symmetry) load through identical code. The axis decides which checkpoint you download,
and PTQ dominates serving because the people who serve models are not the people who trained
them. §4.2 covers the PTQ algorithms.
Accumulation
Independent of every axis above. INT4 weights dequantised to bf16 accumulate in fp32; INT8 GEMMs accumulate in int32; FP8 GEMMs accumulate in fp32 on H100 tensor cores. Storage dtype constrains bandwidth; accumulate dtype constrains numerical drift over $K$ terms. Both matter and they are separate. §0.5 owns this and works the error bound.
The decision procedure
The roofline decides the first branch and nothing else does. From §0.4: a decode step with batch $B$ reads each weight once and does $2B$ FLOP with it, so the arithmetic intensity of the weight-stationary GEMM is
where $b_w$ is bytes per stored weight and $I^{*} = \pi/\beta$ is the ridge point. On an H100 SXM, $\pi_{\text{bf16}} = 989.4$ TFLOP/s and $\beta = 3.35$ TB/s, so $I^{*}_{\text{bf16}} = 295$; fp8 tensor cores run at $\pi_{\text{fp8}} = 1979$ TFLOP/s (§0.4, table of H100 constants), so $I^{*}_{\text{fp8}} = 591$.
| Weight format | $b_w$ (B) | Compute ceiling | $I^{*}$ | $B^{*}$ |
|---|---|---|---|---|
| bf16 | 2 | bf16, 989 TF/s | 295 | 295 |
| W8A16 (fp8/int8 weight-only) | 1 | bf16, 989 TF/s | 295 | 148 |
| W4A16 (ideal int4; metadata excluded) | 0.5 | bf16, 989 TF/s | 295 | 74 |
| W8A8 (fp8) | 1 | fp8, 1979 TF/s | 591 | 295 |
Read the last two rows against each other, because that is the entire argument. W4A16 divides the memory-bound step time by four but leaves the compute ceiling at bf16, so it runs out of runway at $B \approx 74$ and past that converges to bf16 performance minus the dequantisation overhead. W8A8-FP8 divides step time by only two in the memory-bound region — but it also doubles the ceiling, so its crossover sits at the same $B^{*} = 295$ as bf16 and it stays 2× ahead on both sides of the ridge. Weight-only wins at low batch; W+A wins at high batch; their ideal pairwise crossover is around $B=\pi_{bf16}/(2\beta)\approx148$. W4's own ridge near 74 is a different boundary: W4 remains faster than W8A8 between 74 and 148 in this model. Actual kernel and mixed-layer costs can shift the crossing.
That derived boundary agrees with the largest published sweep: the Red Hat / Neural Magic study (arXiv:2411.02355, >500,000 evaluations over the Llama-3.1 family) recommends W4A16 for synchronous, low-concurrency deployments and W8A8 for asynchronous continuous batching. Same conclusion, arrived at by measurement rather than by roofline.
Figure 3 — workload description to scheme. Thresholds are the derived $B^{*}$ values above, for an H100 SXM. $N^{*}$ is §2.5's KV crossover: $1.23\times10^{5}$ resident tokens for Llama-3-8B, $4.31\times10^{5}$ for 70B.
Three things the tree deliberately does not do. It does not branch on model size — size changes whether you must quantize, not which scheme wins. It does not branch on context length except through $N$, because context length only enters through the KV axis. And it puts the accuracy gate last, as a veto rather than a selector, because accuracy is the one axis you cannot predict from the workload description and must measure.
What the engines actually support
Both engines keep a single flat registry mapping a checkpoint's quant_method
string to a QuantizationConfig subclass. vLLM's is a Literal plus a
dict built lazily inside get_quantization_config
(vllm/model_executor/layers/quantization/__init__.py:L12-L47 and
L139-L180); SGLang's is a module-level dict, patched per platform
(python/sglang/srt/layers/quantization/__init__.py:L71-L143).
a556f3f / SGLang
7d89325. Cited from the two registry files plus each method's config class.
W / A / KV columns say which tensor populations the method's own config can describe; "—"
means the method leaves that population alone. Granularity is what the config class exposes,
not what every kernel supports. Rows group alias spellings of one method family and the
yes/no applies to the family: auto_awq, auto_gptq,
modelopt_mxfp8 and gpt_oss_mxfp4 are vLLM-only spellings with no
SGLang registry entry.| Method name | vLLM | SGLang | W | A | KV | Granularity |
|---|---|---|---|---|---|---|
compressed-tensors | yes | yes | INT4/8, FP8, NVFP4, MXFP4/8 | INT8, FP8, NVFP4 | FP8 only | per-layer config_groups: TENSOR / CHANNEL / TOKEN / GROUP / BLOCK / ATTN_HEAD |
fp8 | yes | yes | FP8 E4M3 | FP8, static or dynamic | — | per-tensor, or weight_block_size 128×128 |
awq, awq_marlin, auto_awq | yes | yes | INT4 | — | — | group_size, zero_point flag |
gptq, gptq_marlin, auto_gptq | yes | yes | INT4 / INT8 | — | — | group_size, is_sym, desc_act, per-module dynamic overrides |
modelopt, modelopt_fp4, modelopt_mixed | yes | yes | FP8 / NVFP4 | FP8 / NVFP4 | via kv_cache_quant_algo | per-tensor, group_size, exclude_modules |
modelopt_mxfp8, mxfp8 | yes | yes | FP8 | FP8 | — | MX block of 32, E8M0 scale |
mxfp4, gpt_oss_mxfp4 | yes | yes | FP4 E2M1 | varies by path | — | MX block of 32 |
quark, quark_mxfp4, quark_int4fp8_moe | partial | yes | FP8 / MXFP4 / INT4 | FP8 / MXFP4 | — | AMD Quark schema |
moe_wna16 | yes | yes | INT4/8, MoE only | — | — | group, re-reads GPTQ/AWQ configs |
humming | yes | yes | present in both registries; config class not read this session | — | ||
torchao, inc, experts_int8, deepseek_v4_fp8 | yes | no | vLLM-only entries in QuantizationMethods | |||
online + 7 shorthands | yes | no | FP8 / MXFP4/8 / NVFP4 / INT8 | optional | — | RTN at load time from a bf16 checkpoint; see below |
fbgemm_fp8, fp_quant | deprecated | no | refuse to load without --allow-deprecated-quantization | |||
w8a8_int8, w8a8_fp8, blockwise_int8 | no | yes | INT8 / FP8 | INT8 / FP8 | — | static per-channel weight, dynamic per-token activation |
w4afp8, mxfp_w4a8 | no | yes | INT4 / MXFP4 | FP8 | — | group_size=128, separate linear and MoE activation schemes |
bitsandbytes, gguf | no | yes | INT4/8, GGUF k-quants | — | — | method-specific |
petit_nvfp4, nvfp4_online | no | yes | NVFP4 | NVFP4 | — | block of 16 + global scale |
auto-round, auto-round-int8, modelslim, mlx_q4, mlx_q8 | no | yes | SGLang-only; modelslim is NPU, mlx_* is Apple Silicon | |||
kv_cache_dtype (separate flag) | 17 vLLM values / 9 SGLang values | — | — | FP8 E4M3/E5M2, NVFP4, MXFP8, INT4/INT8 per-token-head, 4 TurboQuant modes | per-tensor, per-token-head, Hadamard-rotated Lloyd-Max — §2.5 | |
Three structural facts the table makes visible.
The KV axis is not in the registry at all. vLLM's KV formats live in
CacheDType in vllm/config/cache.py:L19-L37 — seventeen values
including four TurboQuant modes and int4_per_token_head — and SGLang's in
--kv-cache-dtype's nine choices at
python/sglang/srt/server_args.py:L690-L713. A weight scheme and a KV scheme are
selected by different flags and validated by different code, but compatibility still depends on model architecture, kernel, device and checkpoint metadata. The one place they
meet is compressed-tensors, whose checkpoints can carry a kv_cache_scheme
— and vLLM restricts what it will accept there to a single point in the space:
type_ = kv_cache_scheme.get("type")
num_bits = kv_cache_scheme.get("num_bits")
if type_ != "float" or num_bits != 8:
raise NotImplementedError(
"Currently supported kv cache quantization is "
"num_bits=8, type=float, however "
f"received num_bits={num_bits}, type={type_}"
)
vLLM has an online path; SGLang's equivalent is narrower. vLLM's
_ONLINE_SHORTHANDS desugars a CLI string into a full
QuantizationConfigArgs with separate linear and moe specs,
each holding a weight QuantKey and an optional activation QuantKey:
# CLI shorthands accepted by `--quantization`. Each desugars to a full
# QuantizationConfigArgs; activation overrides go through quantization_config.
_ONLINE_SHORTHANDS: dict[str, QuantizationConfigArgs] = {
"fp8_per_tensor": QuantizationConfigArgs(
linear=QuantSpec(weight=kFp8StaticTensorSym),
moe=QuantSpec(weight=kFp8StaticTensorSym),
),
"fp8_per_block": QuantizationConfigArgs(
linear=QuantSpec(weight=kFp8Static128BlockSym),
moe=QuantSpec(weight=kFp8Static128BlockSym),
),
# Per-output-channel weight scale + dynamic per-token activation.
# Same shape as llmcompressor's FP8_DYNAMIC recipe.
"fp8_per_channel": QuantizationConfigArgs(
linear=QuantSpec(weight=kFp8StaticChannelSym),
moe=QuantSpec(weight=kFp8StaticChannelSym),
),
# ...
# INT8 weight-only on MoE; linear stays unquantized (no `linear` field).
"int8_per_channel_weight_only": QuantizationConfigArgs(
moe=QuantSpec(weight=kInt8StaticChannelSym),
),
This is the design space made addressable: QuantSpec(weight=..., activation=...)
per layer kind, so you can quantize the MoE experts and leave attention alone —
which is exactly what int8_per_channel_weight_only does. SGLang reaches the same
place differently, with a REQUANTIZATION_METHODS set that allows a CLI method to
override a checkpoint's declared method and requantize at load
(python/sglang/srt/configs/model_config.py:L1546-L1587), logging a warning that
"requantization may incur a loss in accuracy".
Neither list is stable. bitsandbytes and gguf are in SGLang's
registry and have no entry in vLLM's QuantizationMethods literal or any file in
vllm/model_executor/layers/quantization/ at a556f3f. Meanwhile vLLM has
collapsed awq/awq_marlin onto one AutoAWQConfig and
gptq/gptq_marlin/auto_gptq onto one
AutoGPTQConfig, with the backend now chosen inside the config rather than by the
method name. Version-stamp anything you copy from the table.
Worked trace: from config.json to a kernel
A quantized checkpoint declares itself with one dict in config.json:
quantization_config, whose quant_method key is the registry lookup
string. Everything else in the dict is method-specific and gets parsed by that method's
from_config.
Figure 4 — method resolution in vLLM. Two inputs, one winner, and a probe loop in between whose ordering is load-bearing.
The probe loop is the interesting part. A checkpoint that says
quant_method: "gptq" may be better served by the Marlin kernels, or by the
moe_wna16 path if it is an MoE — so every config class gets a chance to claim
it, and the order is hard-coded because several of them would claim the same checkpoint:
quant_cfg = self.model_arch_config.quantization_config
if quant_cfg is not None:
quant_method = quant_cfg["quant_method"]
# Quantization methods which are overrides (i.e. they have a
# `override_quantization_method` method) must be checked in order
# of preference (this is particularly important for GPTQ).
overrides = [
"auto_gptq",
"gptq",
"gptq_marlin",
"auto_awq",
"awq",
"awq_marlin",
"inc",
"moe_wna16",
"modelopt",
# ...
]
# ...
quantization_methods = [
q for q in supported_quantization if q not in overrides
]
# Any custom overrides will be in quantization_methods so we place
# them at the start of the list so custom overrides have preference
# over the built-in ones.
quantization_methods = quantization_methods + overrides
If no override fires and the user also passed --quantization, the two must agree
exactly or the engine refuses to start:
quant_method = quant_method if quant_method != "" else None
# Verify quantization configurations.
if self.quantization is None:
self.quantization = quant_method
elif self.quantization != quant_method:
raise ValueError(
"Quantization method specified in the model config "
f"({quant_method}) does not match the quantization "
f"method specified in the `quantization` argument "
f"({self.quantization})."
)
SGLang runs the same shape with one extra escape hatch: a
compatible_quantization_methods table lets a CLI value stand in for a checkpoint
value it is known to subsume — modelopt_fp4 over a checkpoint declaring
modelopt, w8a8_int8 over one declaring compressed-tensors
(python/sglang/srt/configs/model_config.py:L1497-L1508). vLLM has no such table; it
routes every equivalent case through override_quantization_method instead. The
tradeoff is legibility versus locality: vLLM's rule lives in each config class and needs the
hard-coded ordering above to stay deterministic; SGLang's lives in one dict you can read at a
glance but has to be updated by hand for each new pair.
Then the per-layer step. compressed-tensors is the most general scheme because
its checkpoint carries config_groups: a list of (targets, weights,
input_activations) triples, each target a layer name, a regex, or a module class name. One
checkpoint can therefore be W4A16 on attention, W8A8 on the MLP, and unquantized on
lm_head. _quantization_scheme_map_from_config flattens that into a
target-to-scheme dict (compressed_tensors.py:L299-L369), and at layer construction
get_scheme_dict matches the layer's prefix against the targets
(compressed_tensors.py:L934-L968). If nothing matches, or the layer is in
ignore, the method returns None and the layer falls back to
UnquantizedLinearMethod — the "this layer stays bf16" leaf in Figure 4. If
something does match, _get_scheme_from_parts dispatches through an ordered chain of
predicates — NVFP4, MXFP4, MXFP8, W4A8-FP8, WNA8O8, WNA8, WNA4, WNA16, then the FP8
W8A8/W8A16 pair and the INT8 static-tensor, dynamic-token and W4A8 cases
(compressed_tensors.py:L722-L883) — and returns
one of the twelve scheme modules in
compressed_tensors/schemes/.
What it costs, honestly
Quantization is lossy and the loss is not uniformly distributed across tasks. Three published results, none of them mine, none of them measurable on the hardware I have:
Aggregate accuracy hides the damage. Accuracy is Not All You Need (arXiv:2407.09141, NeurIPS 2024) evaluated six quantization schemes and found aggregate benchmark accuracy within 2% of baseline in nearly every case — while a large fraction of individual answers changed. They call these flips, and report a Spearman correlation of 0.981 between flip rate and KL divergence from the baseline model's output distribution on MMLU. A quantized model can score the same and behave differently on the requests you actually serve.
Reasoning and long-generation tasks degrade first. Quantization Hurts Reasoning? (arXiv:2504.04823, COLM 2025) swept weight, KV and activation quantization across DeepSeek-R1-distilled Qwen and Llama from 1.5B to 70B plus QwQ-32B, on AIME, MATH-500, GPQA and LiveCodeBench. W8A8 and W4A16 are near-lossless; below that, degradation is severe and task-dependent, with mathematical reasoning hit hardest. The mechanism is compounding: a long chain of thought gives per-token error many more opportunities to change a downstream token.
The format matters less than people assume, at 8 bits. arXiv:2411.02355 reports FP8 W8A8 as effectively lossless at every model scale in the Llama-3.1 family, well-tuned INT8 W8A8 at 1–3% degradation, and INT4 weight-only more competitive than expected.
Perplexity averages over every token in a corpus, which is exactly the wrong statistic for a failure mode that changes a small number of high-leverage tokens. A scheme can hold perplexity to within 1% and drop twenty points of GSM8K. §4.4 owns the methodology; the short version is: measure an exact-match task, at your real context length, at your real sampling parameters, against the same engine unquantized.
Neither this book nor either repository will hand you an accuracy table for your model. The
closest thing that ships with the code is vLLM's CI thresholds under
tests/evals/, which are lower bounds a maintainer picked, not measurements.
Pitfalls and war stories
Every method has a way to say "skip these layers", and every method spells it differently:
ignored_layers (fp8.py:L99), modules_to_not_convert
(auto_awq.py:L189), exclude_modules (modelopt.py:L133),
ignore (compressed-tensors and SGLang's w8a8_int8.py:L76). A layer
excluded in the checkpoint but not matched by the engine's mapper loads as quantized and
produces garbage; the reverse loads as bf16 and silently costs memory. When a quantized model
is subtly wrong, diff the resolved skip list against the checkpoint's before anything else.
--quantization gptq on a bf16 checkpoint does not quantize anything in vLLM.
_verify_quantization never quantizes; the online path is entered earlier, in
EngineArgs, where resolve_quantization_config desugars the flag
— and it returns None for any name outside
ONLINE_QUANT_SHORTHAND_NAMES, which is the seven
_ONLINE_SHORTHANDS keys plus the bare online
(vllm/config/quantization.py:L152-L177, called from
vllm/engine/arg_utils.py:L802-L806). On a checkpoint that declares a different
method the flag raises the mismatch ValueError above. Its real job is to
disambiguate which config class claims a checkpoint. If you want load-time
quantization in vLLM you need one of those shorthands; in SGLang you need a method in
REQUANTIZATION_METHODS.
SGLang's "not fully optimized yet" warning fires at startup, once, at WARNING level, in a log stream that also carries the entire model-loading trace. It is the only signal you get that your chosen method has no fast kernel on this hardware. Grep for it in CI.
get_min_capability is abstract on QuantizationConfig
(base_config.py:L117-L124) precisely so that a scheme whose kernels need Ada or
Hopper fails at config time rather than in the first forward pass. compressed-tensors calls
_check_scheme_supported at the end of get_scheme, per layer, so the
failure happens during model construction. The message itself names the required and the
actual compute capability, not the layer — the layer name is in the traceback and in the
"Using scheme: %s for %s" debug line immediately after
(compressed_tensors.py:L930-L932).
Download the CPU quantization and parallelism reference checks. Run python quant_parallel_checks.py: byte ledgers, GPTQ correction, scaling, nibble layouts, paired accuracy, TP projection algebra, PP schedules, routing and semantic-order checks. These test mathematical contracts, not CUDA kernels or engine performance.
Hands-on
Enumerate both registries yourself and diff them — this is the table above, regenerated at whatever SHA you are on:
python -c "from vllm.model_executor.layers.quantization import QUANTIZATION_METHODS as M; print(len(M)); print('\n'.join(sorted(M)))"
python -c "from sglang.srt.layers.quantization import QUANTIZATION_METHODS as M; print(len(M)); print('\n'.join(sorted(M)))"
python -c "from vllm.config.cache import CacheDType; from typing import get_args; print(get_args(CacheDType))"
Then check what a checkpoint actually declares before you download 40 GB of it:
huggingface-cli download <repo> config.json --local-dir /tmp/cfg
python -c "import json; c=json.load(open('/tmp/cfg/config.json'))['quantization_config']; print(c.get('quant_method')); print(json.dumps({k:v for k,v in c.items() if k!='config_groups'}, indent=2)); [print(g['targets'], g['weights'].get('num_bits'), g['weights'].get('strategy'), g['weights'].get('group_size'), g['weights'].get('symmetric'), 'act:', (g.get('input_activations') or {}).get('strategy'), (g.get('input_activations') or {}).get('dynamic')) for g in c.get('config_groups', {}).values()]"
That last line prints one row per config group: bits, strategy, group size, symmetry, and whether activations are quantized and dynamically scaled. It is the six axes of this chapter, read straight off the artefact.
Exercises
- Read the file. Open
vllm/model_executor/layers/quantization/utils/quant_utils.pyand count how many distinctQuantKeyconstants are defined between lines 195 and 315. For each, write down its (dtype, group shape, static/dynamic, symmetric) tuple. How many distinct group shapes appear? Which axis has the fewest distinct values in practice? - Derive. Llama-3-70B has $L=80$, $d=8192$, $h_{kv}=8$, $d_h=128$, $d_{ff}=28672$, and $P = 70.6\times10^{9}$ with a 128256-entry vocabulary. What fraction of its parameters sit in quantizable linear layers? Repeat the W4A16 group-128 asymmetric arithmetic from §4.1.3. Is the reduction ratio better or worse than the 8B's 2.80×, and why?
- Predict, then verify. You pass
--quantization awqto vLLM with a checkpoint whoseconfig.jsonsaysquant_method: "compressed-tensors". Trace_verify_quantizationand predict the exact behaviour. Now predict what happens with--quantization fp8_per_tensoron a plain bf16 checkpoint. Verify both againstvllm/config/model.py:L1245-L1346andvllm/config/quantization.py:L158-L194. - Design. A workload: Llama-3-70B on 4×H100, mean context 32k, p99 concurrency 12, strict 40 ms TPOT target, code-generation task. Walk Figure 3. Which axes fire, in what order, and which one does the accuracy gate most threaten?
- Find the asymmetry. SGLang's
W8A8Int8Configdocstring claims "Weight: static, per-channel, symmetric / Activation: dynamic, per-token, symmetric". Find the code that enforces each of those four properties, or show that one of them is unenforced.
Answer — 2
Per layer: $q$ is $8192\times8192$, $k$ and $v$ are $1024\times8192$ each, $o$ is $8192\times8192$; gate, up and down are $28672\times8192$ each. That is $2(6.711\times10^{7}) + 2(8.389\times10^{6}) + 3(2.349\times10^{8}) = 8.556\times10^{8}$ per layer, $\times 80 = 6.845\times10^{10}$. Embedding plus untied head is $2 \times 128256 \times 8192 = 2.101\times10^{9}$. Total $7.055\times10^{10}$ — close to the published 70.6e9, the small residue being norms. Linear share: $97.0\%$, against the 8B's $86.9\%$. At 4.156 effective bits the linear pile is $3.557\times10^{10}$ bytes, the head stays at $4.203\times10^{9}$, total $3.977\times10^{10}$ against bf16's $1.411\times10^{11}$ — 3.55×, meaningfully better than the 8B's 2.80×. The reason is pure scaling: the vocabulary is the same size in both models, so the unquantized head is a fixed cost that amortises away as the model grows.
Answer — 3
First case: quant_cfg is not None, so quant_method is
"compressed-tensors". The probe loop runs; no config class in overrides
claims a compressed-tensors checkpoint by returning "awq", so
quantization_override stays None throughout. Then
self.quantization is "awq", which is not None and does not
equal "compressed-tensors", so the mismatch ValueError at L1318-L1324
fires and the engine never starts.
Second case: quant_cfg is None, so the whole probe block is skipped.
self.quantization stays "fp8_per_tensor", which is in
QUANTIZATION_METHODS (it is listed in the Literal at
__init__.py:L40-L45), so validation passes and
get_quantization_config maps it to OnlineQuantizationConfig via the
setdefault loop at L174-L175. resolve_quantization_config then desugars
it into QuantizationConfigArgs(linear=QuantSpec(weight=kFp8StaticTensorSym), moe=...)
and the weights are quantized at load. The two flags look identical on the command line and take
completely different paths.
Answer — 4
Branch 1: 70B at bf16 is 141 GB against 4×80 GB — it fits, so weight quantization is not forced. Branch 2: if the actual decode batch is 12, W4A16 group-128 is a candidate in the weight-bandwidth model, not a guaranteed TPOT winner. KV traffic, communication, launch overhead and accuracy remain separate gates. Branch 3: $N = 12 \times 32768 = 3.93\times10^{5}$ against $N^{*}_{70B} = 4.31\times10^{5}$ — close to equality, not negligible. With bf16 weights, halving KV traffic would save about $r/[2(1+r)]\approx24\%$ of this ideal combined read volume for $r=0.912$. W4 weights further increase KV's traffic share. KV quantization can therefore affect latency as well as capacity; validate kernel overhead and long-context accuracy. Branch 4 is the one that bites: the task is code generation with long outputs, which is exactly the regime arXiv:2504.04823 reports as most fragile. Expect W4A16 to need a finer group, or to lose to W8A8 on quality at a TPOT you can still afford.
Key takeaways
- The axes are a taxonomy, not freely composable choices.
QuantKeyrecords selected representation properties; accumulation dtype, calibration, ignored layers, hardware support and the resolved kernel are additional parts of the execution contract. - Distinguish the W4 own-ridge near 74, the W4/W8A8 ideal crossover near 148, and the W8A8 own-ridge near 295 for the stated H100 model. Shape, achieved throughput, unquantized heads, dequantization and KV traffic change real winners; benchmark feasible schemes after a quality gate.
- 4-bit is not 4×. Metadata costs 0.125–0.5 bits per weight depending on group size, and the embedding plus LM head are 13% of Llama-3-8B and are almost never quantized. The real ratio is 2.80× for the 8B and 3.55× for the 70B — the gap is the fixed vocabulary cost amortising.
- Operand instruction support constrains implementations, but symmetry is not synonymous with signed arithmetic. Asymmetric quantization can use zero-point correction terms; inspect the complete kernel and scale contract.
- KV quantization is a different flag, a different validator, and a different decision. It is less than 50% of the two-term weight-plus-KV traffic below equality, not necessarily less than 2%. Weight quantization moves that equality point, and combining formats remains backend-dependent.
- The engine does not choose your scheme; the checkpoint does, and
--quantizationis mostly a tie-breaker between config classes that would both claim it. The exceptions — vLLM's seven online shorthands, SGLang's requantization set — are the only paths where the flag quantizes anything.
Further reading
- "Give Me BF16 or Give Me Death"? Accuracy-Performance Trade-Offs in LLM Quantization — arXiv:2411.02355 (ACL 2025). Over 500,000 evaluations across the Llama-3.1 family; the source of the W4A16-for-synchronous / W8A8-for-asynchronous recommendation that the roofline in §4.1.4 reproduces from first principles.
- Accuracy is Not All You Need — arXiv:2407.09141 (NeurIPS 2024). Introduces flips and shows they correlate with KL divergence at $\rho = 0.981$ on MMLU. Read this before you trust any aggregate accuracy delta.
- Quantization Hurts Reasoning? An Empirical Study on Quantized Reasoning Models — arXiv:2504.04823 (COLM 2025), code at ruikangliu/Quantized-Reasoning-Models. The task-dependence result: mathematical reasoning degrades first and hardest.
- vLLM docs —
docs/features/quantization/at this SHA carries one page per method (auto_awq.md,gptqmodel.md,modelopt.md,quark.md,torchao.md,inc.md,b12x.md) plusonline.mdfor the shorthand schema andquantized_kvcache.mdfor the KV axis. - compressed-tensors — the checkpoint format that makes the design space
addressable per layer.
QuantizationArgsandQuantizationStrategylive in the externalcompressed-tensorspackage, not in either engine tree; vLLM's consumption of them is invllm/model_executor/layers/quantization/compressed_tensors/. - SGLang quantization guide —
docs.sglang.io/advanced_features/quantization.html,
linked from
model_config.py's own requantization warning.