None
%load_ext watermark
%load_ext autoreload
%autoreload 2
import os
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from llmcompressor import oneshot
from llmcompressor.modifiers.quantization import QuantizationModifier
%watermark -a 'Ethen' -d -u -v -iv
Why Quantization
The diagram below shows the GPU memory hierarchy, simplified/adapted from [4]. Quantization helps at each level:
┌──────────────────────────────────────┐
│ TENSOR CORES │ ← compute AX+b
│ (compute units inside each SM; │ ×N per forward pass
│ read operands from SRAM) │
└───────────────────▲──────────────────┘
│ operands
┌───────────────────┴──────────────────┐
│ SM SRAM (on-chip) │ ← registers, L1, shared mem
│ smallest, fastest │ one pool per SM
└───────────────────▲──────────────────┘
│
════════════════════════════════════╪════════════════════════════════════
HBM → SM bandwidth │ ▶ every forward pass streams the
│ layer weights across this link
│ ▶ quantization shrinks the bytes
│ that cross it
════════════════════════════════════╪════════════════════════════════════
│
┌───────────────────┴──────────────────┐
│ GPU HBM (off-chip DRAM) │ ← what nvidia-smi reports
│ smaller than CPU RAM, but faster │
│ ▶ model weights │
│ ▶ KV cache │
│ ▶ activations │
└───────────────────▲──────────────────┘
│
PCIe / NVLink │ ×1 at model load
│ (one-time cost)
┌───────────────────┴──────────────────┐
│ CPU MAIN MEMORY (DRAM) │
│ largest, slowest │
│ ▶ checkpoint loaded from disk │
└──────────────────────────────────────┘
As the empirical results later in this notebook show, FP8 quantization delivers these gains with minimal quality loss.
FP8 Representation
Just as 96.0 = 1.5 × 64 = 1.5 × $2^6$ in scientific notation, to represent a decimal number like 96.0 using only 8 bits (eight 0s or 1s) in binary notation is comprised of three parts:
FP8 e4m3 format encodes exactly these three parts in 8 bits:
[S][E E E E][M M M]
1 4 3 = 8 bits total
Putting it together, 96.0 in FP8 e4m3 = 0 1101 100. If our real number is 95.0, it would get "rounded" to the closest FP8 value of 96, that's the quantization error.
E4M3 vs E5M2
Two FP8 formats exist, optimized for different roles:
┌────────┬───────┬────────┬───────┬─────────────────────────┐
│ Format │ S/E/M │ Range │ Steps │ Typical use │
├────────┼───────┼────────┼───────┼─────────────────────────┤
│ E4M3 │ 1/4/3 │ ±448 │ 8 │ Weights & activations │
├────────┼───────┼────────┼───────┼─────────────────────────┤
│ E5M2 │ 1/5/2 │ ±57344 │ 4 │ Gradients │
└────────┴───────┴────────┴───────┴─────────────────────────┘
print(torch.finfo(torch.float8_e4m3fn))
print(torch.finfo(torch.float8_e5m2))
To convert high precision tensor into lower precision ones like FP8, we apply a scaling factor to map values into representable range:
$$ X_{FP8}=\text{clip}\left(\text{round}\left(X\times S\right),-V_{max},V_{max}\right) $$Where:
To dequantize back to high precision during computation, we multiply by the inverse:
$$ X_{high\_res}=X_{FP8}\times \frac{1}{S} $$Because FP8 has a narrower dynamic range, picking the right scaling factor $S$ is critical to avoid severe accuracy loss. Two primary strategies are used:
Static Scaling
Dynamic Scaling
# Example tensor in bf16
tensor = torch.randn(4, 4, dtype=torch.bfloat16)
# Quantize: X_FP8 = clip(round(X × S), -V_max, V_max)
V_max = 448.0
S = V_max / tensor.abs().max()
# .to(float8) handles rounding to nearest representable FP8 value
# No explicit torch.round() needed — that's carryover notation for integer type quantization
tensor_fp8 = (tensor * S).clamp(-V_max, V_max).to(torch.float8_e4m3fn)
# Dequantize
tensor_restored = tensor_fp8.to(torch.bfloat16) / S
# Check error
print(f"Original:\n{tensor}")
print(f"fp8:\n{tensor_fp8}")
print(f"Restored:\n{tensor_restored}")
def relative_error(approx, ref):
"""A relative Frobenius-norm error measures the difference between a true tensor and its approximation,
scaled by the magnitude of the true tensor.
"""
return (approx - ref).float().norm() / ref.float().norm()
print(f"original vs restored error: {relative_error(tensor, tensor_restored):.6f}")
The speedup comes from H100/H200 tensor cores that natively multiply FP8 operands:
Input A (FP8) × Input B (FP8) → Accumulate in FP32 → Output (BF16 or FP8)
Both inputs are stored and loaded in FP8 (half the memory bandwidth), the multiply-accumulate runs in FP32 to prevent rounding errors from compounding across dot products, finally the output is cast back to a desired output type.
A_bf16 = torch.randn(128, 4096, dtype=torch.bfloat16, device="cuda")
B_bf16 = torch.randn(512, 4096, dtype=torch.bfloat16, device="cuda")
# ── Ground truth: full bf16 matmul ──
C_bf16 = A_bf16 @ B_bf16.T
C_bf16
# ── FP8 matmul with FP32 accumulation (what H100 tensor cores do) ──
# Step 1: Quantize both operands to FP8
V_max = 448.0
scale_A = V_max / A_bf16.abs().max()
scale_B = V_max / B_bf16.abs().max()
A_fp8 = (A_bf16 * scale_A).clamp(-V_max, V_max).to(torch.float8_e4m3fn)
B_fp8 = (B_bf16 * scale_B).clamp(-V_max, V_max).to(torch.float8_e4m3fn)
# Step 2: Matmul — upcast to FP32 for the multiply-accumulate
# (On real H100 hardware, this happens inside the tensor core automatically.
# Here we simulate it explicitly since torch.matmul doesn't support FP8 directly.)
C_fp8_accum32 = (A_fp8.to(torch.float32) @ B_fp8.T.to(torch.float32))
# Step 3: Undo the scaling and cast output to bf16
C_fp8_accum32 = (C_fp8_accum32 / (scale_A * scale_B)).to(torch.bfloat16)
print(C_fp8_accum32)
We can also perform a parity check with pytorch native _scaled_mm method, which is one of the method vllm uses if other hardware specialized kernels aren't available.
C_hardware = torch._scaled_mm(
A_fp8, # [M, K] row major
B_fp8.T, # [K, N] column major
# scale should be float32
scale_a=(1.0 / scale_A).to(torch.float32),
scale_b=(1.0 / scale_B).to(torch.float32),
# accumulates in FP32 internally, outputs bf16
out_dtype=torch.bfloat16,
)
C_hardware
print(f"FP8 + FP32 accumulate vs BF16: {relative_error(C_fp8_accum32, C_bf16):.6f}")
print(f"FP8 + FP32 accumulate vs scale mm: {relative_error(C_fp8_accum32, C_hardware):.6f}")
Apart from scaling strategy, we can also determine scaling granularity. i.e. how many elements share a single scale factor e.g.
Per-tensor -- one scale for the entire matrix:
Weight W [4096 x 4096]:
scale = 448 / max(|W|) # 1 scalar
Per-channel -- one scale per output row:
Weight W [4096 x 4096]:
scale[i] = 448 / max(|W[i, :]|)
Per-block, e.g. [128x128] -- one scale per 128x128 tile:
Weight W [4096 x 4096]:
For each 128x128 block:
scale[i,j] = 448 / max(|block_ij|)
We use llmcompressor to implement a FP8_DYNAMIC quantization [1]. This is a W8A8 method where:
Layers:
As a rule of thumb for quantization, we target only Linear layers, since FP8 tensor cores main purpose is to accelerate matrix multiplications. Normalization layers and embeddings are division sensitive or comprised of elementwise/lookup operations with comparatively fewer parameters, so quantizing them offers very negligible speedup or memory savings. Among the Linear layers, we further exclude those where small numerical errors can flip discrete decisions, such as lm_head, mtp speculative head (token selection), MOE router gates (expert assignment). We also exclude linear attention layers, where errors accumulate through recurrent state.
model_name = "Qwen3-Next-80B-A3B-Instruct"
cache_dir = "/shared/data"
model_path = f"{cache_dir}/{model_name}"
# Save to disk in compressed-tensors format (vLLM compatible)
quantized_model_name = model_name.rstrip("/").split("/")[-1] + "-FP8-Dynamic"
quantized_model_path = f"{cache_dir}/{quantized_model_name}"
model = AutoModelForCausalLM.from_pretrained(model_path, cache_dir=cache_dir)
tokenizer = AutoTokenizer.from_pretrained(model_path, cache_dir=cache_dir)
recipe = QuantizationModifier(
targets=["Linear"],
scheme="FP8_DYNAMIC",
ignore=[
"lm_head",
# moe router and shared expert gate
"re:.*mlp.gate$",
"re:.*mlp.shared_expert_gate$",
# linear attention layers (GatedDeltaNet)
"re:.*linear_attn.*",
# mtp speculative head
# "re:^mtp.*",
],
)
# Apply quantization (no calibration data needed for FP8_DYNAMIC)
oneshot(model=model, recipe=recipe)
#!du -h -d 1 $model_path
#!du -h -d 1 $quantized_model_path
model.save_pretrained(quantized_model_path, save_compressed=True)
tokenizer.save_pretrained(quantized_model_path)
We can check disk usage of the quantized checkpoint to confirm its lower size, and vllm serve this quantized checkpoint to check for performance parity as subsequent step.
To close out this document, we will take a closer look at some empirical numbers from a quantization study on Deepseek-R1 distilled models [3]


Inference speedups: The same study profiled throughput across chat, instruction following, summarization, RAG, and coding workloads on A6000, A100, and H100 GPUs: