LLM Engineering · lesson 21 of 27
Beginner
8Intermediate
11- 8 Structured Outputs in Production
- 9 LLM APIs and Provider Abstraction
- 10 LLM Tool Use and Function Calling
- 11 RAG — Retrieval-Augmented Generation
- 12 RAG: Advanced Retrieval and Reranking
- 13 LLM Embeddings and Vector Search
- 14 RAG Failure Diagnosis
- 15 Multimodal LLMs
- 16 LLM Context Engineering
- 17 Long-Context Economics and Prompt Caching
- 18 RAG vs. Fine-Tuning vs. Prompting
Advanced
8Also part of LLM Engineering
4Quantization
Measure the real memory and quality trade-off of FP32, FP16, and INT8 precision, and where GPTQ, AWQ, and GGUF fit in production.
By SkillByExample · Published
A 7-billion-parameter model trained and stored at full 32-bit precision needs roughly 28GB of VRAM just to hold its weights — more than any consumer GPU has. The model’s knowledge isn’t 28GB of information; a large fraction of that is precision the model doesn’t actually need to produce good output. Quantization is spending less of that memory on precision.
Real Model Size at FP32 vs FP16
import torch
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("distilgpt2")
def model_size_bytes(m):
return sum(p.numel() * p.element_size() for p in m.parameters())
fp32_bytes = model_size_bytes(model)
print(f"FP32: {fp32_bytes:,} bytes = {fp32_bytes / 1024**2:.1f} MB")
model_fp16 = AutoModelForCausalLM.from_pretrained("distilgpt2", dtype=torch.float16)
fp16_bytes = model_size_bytes(model_fp16)
print(f"FP16: {fp16_bytes:,} bytes = {fp16_bytes / 1024**2:.1f} MB")
print(f"Reduction: {(1 - fp16_bytes / fp32_bytes) * 100:.1f}%")
w = model.transformer.h[0].mlp.c_fc.weight
w16 = model_fp16.transformer.h[0].mlp.c_fc.weight
print(f"\nSame weight, dtype {w.dtype}: {w[0, :3].tolist()}")
print(f"Same weight, dtype {w16.dtype}: {w16[0, :3].tolist()}")
$ python size_fp16.py
FP32: 327,650,304 bytes = 312.5 MB
FP16: 163,825,152 bytes = 156.2 MB
Reduction: 50.0%
Same weight, dtype torch.float32: [0.12394002079963684, 0.10340151190757751, -0.00964804645627737]
Same weight, dtype torch.float16: [0.12396240234375, 0.1033935546875, -0.00965118408203125]
Exactly half the memory, as expected (32 bits down to 16 bits per weight), and the individual
values barely moved — 0.12394... became 0.12396.... FP16 is still floating-point; it just
has less mantissa precision, and 82M-parameter distilgpt2’s weights mostly fit comfortably in
that reduced range.
Manual INT8 Quantization Has Real, Measurable Error
import torch
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("distilgpt2")
w = model.transformer.h[0].mlp.c_fc.weight.detach()
def quantize_int8(tensor):
scale = tensor.abs().max() / 127.0
q = torch.round(tensor / scale).clamp(-127, 127).to(torch.int8)
return q, scale
def dequantize(q, scale):
return q.float() * scale
q, scale = quantize_int8(w)
reconstructed = dequantize(q, scale)
print(f"Original dtype/size: {w.dtype}, {w.numel() * w.element_size():,} bytes")
print(f"Quantized dtype/size: {q.dtype}, {q.numel() * q.element_size():,} bytes (+4 bytes for the scale)")
print(f"Scale factor: {scale.item():.6f}")
print()
print(f"Original values: {w[0, :5].tolist()}")
print(f"Int8 codes: {q[0, :5].tolist()}")
print(f"Dequantized values: {reconstructed[0, :5].tolist()}")
print()
mae = (w - reconstructed).abs().mean().item()
max_err = (w - reconstructed).abs().max().item()
print(f"Mean absolute error: {mae:.6f}")
print(f"Max absolute error: {max_err:.6f}")
print(f"Original value range: [{w.min().item():.4f}, {w.max().item():.4f}]")
$ python manual_int8.py
Original dtype/size: torch.float32, 9,437,184 bytes
Quantized dtype/size: torch.int8, 2,359,296 bytes (+4 bytes for the scale)
Scale factor: 0.041376
Original values: [0.12394002079963684, 0.10340151190757751, -0.00964804645627737, 0.08984390646219254, -0.10196784138679504]
Int8 codes: [3, 2, 0, 2, -2]
Dequantized values: [0.12412916123867035, 0.0827527716755867, 0.0, 0.0827527716755867, -0.0827527716755867]
Mean absolute error: 0.010344
Max absolute error: 0.020688
Original value range: [-2.3516, 5.2548]
This is the simplest possible quantization scheme: one scale factor for the entire tensor
(symmetric, per-tensor quantization). The size drops 4x (32-bit float to 8-bit int) but the
error is visible — 0.10340 became 0.08275 after round-tripping through an 8-bit integer,
a >20% relative error on that individual value. Production INT8/INT4 methods (GPTQ, AWQ)
don’t use one scale for the whole tensor; they use per-channel or even per-group scales, and
they choose which weights to protect using calibration data, which is why they achieve much
smaller quality loss than this naive version.
The Quality Trade-off Is Real But Small at FP16
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained("distilgpt2")
model_fp32 = AutoModelForCausalLM.from_pretrained("distilgpt2")
model_fp16 = AutoModelForCausalLM.from_pretrained("distilgpt2", dtype=torch.float16)
prompt = "The capital of France is"
ids = tok(prompt, return_tensors="pt")
with torch.no_grad():
logits32 = model_fp32(**ids).logits[0, -1]
logits16 = model_fp16(**ids).logits[0, -1].float()
top5_32 = torch.topk(torch.softmax(logits32, dim=-1), 5)
top5_16 = torch.topk(torch.softmax(logits16, dim=-1), 5)
print("FP32 top 5:")
for p, i in zip(top5_32.values, top5_32.indices):
print(f" {tok.decode([i]):>10} {p.item():.4f}")
print("FP16 top 5:")
for p, i in zip(top5_16.values, top5_16.indices):
print(f" {tok.decode([i]):>10} {p.item():.4f}")
same_top1 = top5_32.indices[0].item() == top5_16.indices[0].item()
print(f"\nSame top-1 prediction: {same_top1}")
$ python quality_check.py
FP32 top 5:
the 0.1177
home 0.0823
now 0.0499
a 0.0493
being 0.0356
FP16 top 5:
the 0.1182
home 0.0812
now 0.0508
a 0.0493
being 0.0349
Same top-1 prediction: True
The FP16 probabilities shift by less than a percentage point and the top-1 prediction is identical — this is why FP16/BF16 is close to a free lunch and is the default for serving most production models. The bigger memory savings (and bigger risk to quality) come from INT8 and INT4, which is where GPTQ, AWQ, and llama.cpp’s GGUF format come in: real algorithms for choosing scales carefully enough that the error from the previous section stays small even at 4 bits per weight.
Practice
1. Measure the quantized model's size relative to FP32 as a percentage, and compare it to the theoretical 4x number for INT8.
import torch
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("distilgpt2")
def total_bytes_as_int8(m):
return sum(p.numel() * 1 for p in m.parameters()) # 1 byte per int8 weight
def total_bytes_fp32(m):
return sum(p.numel() * p.element_size() for p in m.parameters())
fp32 = total_bytes_fp32(model)
int8 = total_bytes_as_int8(model)
print(f"FP32: {fp32 / 1024**2:.1f} MB")
print(f"INT8 (weights only): {int8 / 1024**2:.1f} MB")
print(f"Actual reduction: {(1 - int8/fp32)*100:.1f}% (theoretical max for 32->8 bit: 75%)")
FP32: 312.5 MB
INT8 (weights only): 78.1 MB
Actual reduction: 75.0% (theoretical max for 32->8 bit: 75%)
Exactly the theoretical number — 4 bytes per weight down to 1 byte is a 75% reduction, with no overhead beyond the per-tensor scale factors (a handful of floats, negligible against millions of weights).
2. Quantize a different weight tensor (the attention projection instead of the MLP) and compare its error.
import torch
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("distilgpt2")
w = model.transformer.h[0].attn.c_attn.weight.detach()
scale = w.abs().max() / 127.0
q = torch.round(w / scale).clamp(-127, 127).to(torch.int8)
reconstructed = q.float() * scale
mae = (w - reconstructed).abs().mean().item()
print(f"attn.c_attn value range: [{w.min().item():.4f}, {w.max().item():.4f}]")
print(f"Mean absolute error: {mae:.6f}")
attn.c_attn value range: [-2.1074, 2.3830]
Mean absolute error: 0.004691
A narrower value range (-2.11 to 2.38, vs. the MLP layer’s -2.35 to 5.25) produces a smaller scale factor and less error per weight — the same 127-level integer grid covers less numeric range, so each level represents a finer step (roughly half the error of the MLP layer’s 0.010344). This is exactly why per-channel/per-tensor scaling matters: different layers have different value ranges, and one global scale for an entire model wastes precision on the layers with narrow ranges.
3. Confirm INT4 (16 levels instead of 256) has visibly more reconstruction error than INT8 on the same tensor.
import torch
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("distilgpt2")
w = model.transformer.h[0].mlp.c_fc.weight.detach()
for bits, levels in [(8, 127), (4, 7)]:
scale = w.abs().max() / levels
q = torch.round(w / scale).clamp(-levels, levels)
reconstructed = q * scale
mae = (w - reconstructed).abs().mean().item()
print(f"INT{bits}: mean absolute error = {mae:.6f}")
INT8: mean absolute error = 0.010344
INT4: mean absolute error = 0.102348
Roughly 10x more error at 4 bits than 8 bits, even though the bit width only halved — going from 254 representable levels down to 14 shrinks the grid far faster than linearly, so error grows much faster than linearly too. This is exactly why production INT4 methods (GPTQ, AWQ) don’t use this naive uniform scheme — they need much smarter per-group scaling to keep 4-bit models usable at all.