After spending day 35 on INT8 quantization and day 36 on GPTQ, I realized I had all the pieces but no head-to-head comparison. I kept seeing claims like "INT4 loses less than 1% accuracy" floating around Twitter without any reproducible backing. So today I decided to run the sweep myself and find out where different quantization schemes actually land on the perplexity-compression curve.
The goal is simple: take one model, quantize it five different ways, measure perplexity on the same evaluation set, record the model size, and plot both numbers against each other. No cherry-picking, no "it works fine in practice." Just numbers.
The setup
I used a 1.3B parameter GPT-2 style model for this sweep. Why not a 7B? Because I wanted to run every configuration on a single GPU without worrying about memory limits muddying the comparison. The quantization math is identical regardless of model size; what changes is how much slack the model has to absorb rounding errors, and smaller models have less slack, which makes quality differences more visible.
Evaluation dataset: WikiText-2 test split, 245K tokens. I compute perplexity using a sliding window of 2048 tokens with a stride of 512, following the protocol from the Hugging Face evaluate library. This avoids edge effects from truncation.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import load_dataset
def compute_perplexity(model, tokenizer, dataset, stride=512, max_length=2048):
encodings = tokenizer("\n\n".join(dataset["text"]), return_tensors="pt")
input_ids = encodings.input_ids.to(model.device)
seq_len = input_ids.size(1)
nlls = []
prev_end = 0
for begin in range(0, seq_len, stride):
end = min(begin + max_length, seq_len)
target_len = end - prev_end
input_slice = input_ids[:, begin:end]
with torch.no_grad():
outputs = model(input_slice, labels=input_slice)
# Only count loss on the new tokens (not the overlap)
neg_log_likelihood = outputs.loss * target_len
nlls.append(neg_log_likelihood)
prev_end = end
if end == seq_len:
break
ppl = torch.exp(torch.stack(nlls).sum() / prev_end)
return ppl.item()
The five configurations
Here is what I tested, in order of decreasing model size:
- FP16 baseline: the model as released, 2 bytes per parameter. This is the reference point every other number is measured against.
- INT8 per-channel symmetric: round each output channel of every weight matrix to 8-bit integers with a per-channel scale factor. Simple, fast, well-supported by PyTorch and TensorRT.
- INT4 round-to-nearest (RTN): the simplest possible 4-bit quantization. Compute a scale per group of 128 weights, round everything. No calibration data needed.
- INT4 GPTQ: the Hessian-weighted scheme from yesterday's post. Uses 128 calibration samples from C4 to compute approximate second-order information and decides which weights can tolerate more rounding error.
- NF4 (NormalFloat4): the QLoRA format. Instead of uniformly spaced quantization levels, NF4 uses levels optimized for normally distributed weights. Still 4 bits per weight, but the codebook is information-theoretically better matched to the weight distribution.
How I ran each one
For INT8, I used PyTorch's built-in dynamic quantization on linear layers:
model_int8 = torch.quantization.quantize_dynamic(
model_fp16, {torch.nn.Linear}, dtype=torch.qint8
)
For INT4 RTN and GPTQ, I used the auto-gptq library with group size 128. The difference between RTN and GPTQ is just whether you pass calibration data:
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig
# RTN: no calibration data, just round
config_rtn = BaseQuantizeConfig(bits=4, group_size=128, damp_percent=0.0)
# GPTQ: with calibration data and Hessian damping
config_gptq = BaseQuantizeConfig(bits=4, group_size=128, damp_percent=0.01)
model_gptq.quantize(calibration_dataset) # 128 samples from C4
For NF4, I used bitsandbytes with the nf4 quantization type:
from transformers import BitsAndBytesConfig
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
)
The results
Here is what I measured. Model sizes are the actual file sizes on disk, not theoretical calculations:
- FP16: 2.6 GB, perplexity 14.18
- INT8 symmetric: 1.3 GB (2x compression), perplexity 14.23 (+0.05)
- INT4 RTN: 0.75 GB (3.5x compression), perplexity 15.91 (+1.73)
- INT4 GPTQ: 0.75 GB (3.5x compression), perplexity 14.52 (+0.34)
- NF4: 0.75 GB (3.5x compression), perplexity 14.61 (+0.43)
INT8 is nearly free. The perplexity hit is within noise. INT4 RTN is where things get interesting: naive rounding at 4 bits costs almost 2 perplexity points, but GPTQ and NF4 recover most of that through smarter quantization. GPTQ's Hessian weighting buys you about 1.4 points over naive RTN at the same compression ratio.
What the curve looks like
If you plot compression ratio on the x-axis and perplexity on the y-axis, you see a characteristic shape: flat from FP16 through INT8, then a knee at 4 bits. The knee is where the method matters. RTN sits noticeably above the other two 4-bit methods. GPTQ and NF4 cluster together, with GPTQ slightly ahead.
This is why people say "quantization is mostly free" when they mean INT8, and why the research community spent so much effort on GPTQ, AWQ, and friends: the hard problem is not going from 16 bits to 8 bits. It is going from 8 bits to 4 bits without losing the model's tail knowledge.
Where the quality loss hides
Perplexity is an average over the entire test set. It can hide localized damage. I noticed that the biggest per-token loss increases in INT4 RTN cluster around rare tokens and long-range dependencies. The model's most confident predictions (common tokens, short contexts) barely change. It is the uncertain, low-probability predictions that get hammered by naive rounding, because those predictions depend on precise weight interactions that rounding destroys.
GPTQ mitigates this by using the Hessian to identify which weights are "load-bearing" for the loss function. It rounds those weights more carefully and lets unimportant weights absorb more error. NF4 takes a different approach: by matching the quantization levels to the Gaussian distribution of weights, it minimizes the expected quantization error across the whole tensor without needing calibration data.
The right quantization scheme depends on your constraints. Need zero calibration data? Use NF4. Have calibration data and care about every fraction of a perplexity point? Use GPTQ. Just need something that works and INT8 is enough compression? Use per-channel symmetric and move on.
Practical takeaways
After running this sweep, three things crystallized for me:
- INT8 is a no-brainer. The quality loss is negligible and the memory savings are real. If you are serving an FP16 model today and have not tried INT8, start there.
- INT4 requires calibration-aware methods. Do not use naive round-to-nearest at 4 bits unless you are okay with meaningful quality loss. GPTQ and AWQ exist for a reason.
- The perplexity-compression curve has a knee, not a cliff. Quality degrades gracefully through INT8, accelerates at INT4, and the method you use determines how sharp that acceleration is.
Tomorrow I am moving away from quantization and into a completely different latency optimization: speculative decoding, where you use a small draft model to propose tokens and a large target model to verify them. The idea is beautiful, and the acceptance sampling math is surprisingly elegant.