After profiling GPU memory, the next natural question is: what happens if I shrink the model? Quantization reduces the number of bytes per weight, which directly affects both memory footprint and throughput. But the relationship between precision and speed is not as simple as "half the bytes, double the speed." Understanding why requires going back to the roofline model.
Why quantization helps decode
Recall that autoregressive decode is memory-bandwidth-bound. Each decode step loads the entire model weights from HBM to compute a single token. The speed of decode is determined by how fast you can stream those weights through the memory bus, not by how fast the Tensor Cores can multiply.
This is why quantization has such a dramatic effect on decode throughput. If you cut each weight from 2 bytes (FP16) to 1 byte (INT8), you halve the memory traffic per decode step. Since the memory bus was the bottleneck, you nearly double the tokens per second. Cut to 0.5 bytes (INT4) and you theoretically quadruple decode speed relative to FP16.
Prefill is different. Prefill is compute-bound because it processes the entire prompt in a large matrix multiplication. Reducing weight precision helps prefill only if the hardware has faster compute units for that precision. The H100 has INT8 Tensor Cores that run at 2x the FP16 rate, so INT8 does help prefill too, but through a different mechanism.
The three precision levels
Let me be precise about what each format means and how the math works:
- FP16 (float16): 16-bit floating point. 1 sign bit, 5 exponent bits, 10 mantissa bits. Full dynamic range, no quantization error. 2 bytes per weight. This is the baseline.
- INT8 (W8A8 or W8A16): weights stored as 8-bit integers with a per-channel or per-group scale factor. The scale converts back to floating point during computation. 1 byte per weight plus a small overhead for scales. Methods include SmoothQuant (for W8A8) and simple round-to-nearest with calibration.
- INT4 (W4A16): weights stored as 4-bit integers, typically with per-group scales (group size 128 is common). 0.5 bytes per weight. Methods include GPTQ, AWQ, and round-to-nearest with Hessian-weighted rounding. Activations remain in FP16, so the matmul dequantizes weights on the fly.
Memory footprint comparison
For Llama-3.1-8B (8.03 billion parameters):
# Weight memory by precision
params = 8.03e9
fp16_gb = params * 2 / 1e9 # 16.06 GB
int8_gb = params * 1 / 1e9 # 8.03 GB
int4_gb = params * 0.5 / 1e9 # 4.02 GB
# KV cache is always in FP16 (or FP8 with recent vLLM)
# So KV cache memory does not change with weight quantization
The memory savings are straightforward. What matters for serving is not just fitting the model, but how much HBM is left for KV cache. Using the budget from yesterday on an 80 GB H100:
- FP16: 72 - 16 - 0.7 = 55.3 GB for KV cache = ~432K tokens
- INT8: 72 - 8 - 0.7 = 63.3 GB for KV cache = ~494K tokens
- INT4: 72 - 4 - 0.7 = 67.3 GB for KV cache = ~526K tokens
Going from FP16 to INT4 frees 12 GB for KV cache, which means roughly 22% more concurrent sequences. That is a real capacity gain.
Throughput: the theory
The theoretical decode throughput on a bandwidth-limited GPU is:
tokens_per_second = memory_bandwidth / bytes_per_token
# H100: 3.35 TB/s memory bandwidth
# bytes_per_token = model_size_bytes (full weight load per token)
fp16_tps = 3.35e12 / (16.06e9) # ~209 tokens/s
int8_tps = 3.35e12 / (8.03e9) # ~417 tokens/s
int4_tps = 3.35e12 / (4.02e9) # ~833 tokens/s
These are theoretical single-sequence decode speeds assuming perfect bandwidth utilization and zero overhead. Real numbers are lower due to attention computation, KV cache reads, kernel launch overhead, and the dequantization work itself. But the ratios hold approximately: INT8 is roughly 1.5-2x faster than FP16 for decode, and INT4 is roughly 2.5-3.5x faster.
Where quality degrades
There is no free lunch. Quantization introduces error because you are rounding continuous-valued weights to a discrete grid. The question is whether that error matters for your use case.
Here is what I have observed across models and tasks:
- INT8: nearly lossless for most models. Perplexity increases by less than 0.1 on WikiText-2 for well-calibrated INT8 quantization of models like Llama-3.1-8B. Downstream task accuracy is typically within measurement noise of FP16. This is the safe default for production.
- INT4 (GPTQ/AWQ): measurable but usually acceptable degradation. Perplexity increases by 0.2-0.5 for Llama-class models. Some tasks, especially math and code generation, are more sensitive than others. AWQ tends to preserve quality slightly better than GPTQ because it identifies and protects salient weight channels.
- INT4 on smaller models: this is where trouble starts. A 1.5B model quantized to INT4 loses noticeably more quality than a 70B model quantized the same way. Larger models are more redundant and tolerate compression better.
INT8 is almost always safe. INT4 is safe for 7B+ models on most tasks. Below 3B parameters, test carefully before deploying INT4. Always validate on your actual evaluation suite, not just perplexity.
Running the benchmark
To measure throughput properly, you need to control for batch size, sequence length, and whether you are measuring prefill or decode. Here is how I set up a fair comparison using vLLM:
# Serve each quantization level
# FP16
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-8B \
--dtype float16 \
--port 8000
# INT8 (using SmoothQuant or a pre-quantized checkpoint)
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-8B \
--quantization squeezellm \
--dtype float16 \
--port 8001
# INT4 (AWQ)
python -m vllm.entrypoints.openai.api_server \
--model casperhansen/llama-3.1-8b-awq \
--quantization awq \
--dtype float16 \
--port 8002
Then use the async batch client to send the same set of prompts to each server and measure output tokens per second:
# Benchmark configuration
# - Fixed prompt: 512 tokens
# - Fixed max_tokens: 256
# - Sweep batch sizes: 1, 4, 16, 64, 128
# - Measure: output tokens/s, TTFT, total time
What the numbers actually show
The general patterns that emerge from these benchmarks, consistently across different models and hardware:
- At batch size 1: INT4 shows the largest relative speedup (often 2-3x over FP16) because single-sequence decode is purely memory-bound.
- At large batch sizes: the gap narrows. With enough sequences batched, even FP16 decode becomes partially compute-bound (the weight bytes are amortized across many tokens), and the advantage of fewer bytes diminishes.
- Prefill latency: INT8 is faster than FP16 on H100 due to INT8 Tensor Core throughput (1978 TOPS vs 989 TFLOPS). INT4 prefill depends on kernel support; with Marlin kernels, it can be very fast.
- Total throughput at saturation: INT4 wins because it uses less memory for weights, leaving more room for KV cache, which means more concurrent requests, which means higher aggregate throughput.
The Marlin kernel advantage
Not all INT4 implementations are equal. Naive INT4 dequantization can actually be slower than FP16 because the dequantization overhead eats into the bandwidth savings. The Marlin kernel (developed by IST Austria, integrated into vLLM) solves this with a carefully optimized CUDA kernel that dequantizes INT4 weights in registers during the matrix multiply, achieving near-ideal throughput.
When using vLLM with AWQ or GPTQ models, Marlin is used automatically when the hardware supports it (Ampere and later). If you see INT4 performing worse than expected, check whether Marlin is actually being used by looking at the vLLM startup logs for "Using Marlin kernel."
Quantization is the single highest-ROI optimization for LLM serving. INT8 gives you a near-free 1.5-2x throughput boost with negligible quality loss. INT4 doubles that again if your model and task can tolerate it. Measure quality on your actual workload, not on leaderboard benchmarks.
Tomorrow I will look at a very different optimization strategy: speculative decoding, where you use a small draft model to generate candidate tokens and verify them in parallel with the large model.