Skip to main content
PyTorch · lesson 8 of 12
PyTorch advanced Lesson 8 of 12

PyTorch Dynamic Quantization

Shrink a trained model 4x by converting its weights to int8 after training — and find out why that doesn't always mean faster inference.

By SkillByExample · Published

Real-World Scenario

A team ships a fraud-scoring model that runs on every checkout request. The model file is 30 MB, most of it in the Linear layers doing the actual scoring. Dynamic quantization converts those weights to int8 after training — no retraining, no new dataset — and the question everyone assumes the answer to before measuring is “does that also make it faster?”

Quantizing a Model

import torch
import torch.nn as nn

torch.manual_seed(0)

class FraudDetector(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(1000, 2000), nn.ReLU(),
            nn.Linear(2000, 2000), nn.ReLU(),
            nn.Linear(2000, 1000), nn.ReLU(),
            nn.Linear(1000, 1), nn.Sigmoid(),
        )

    def forward(self, x):
        return self.net(x)

model = FraudDetector()
model.eval()   # required — quantize_dynamic assumes inference mode

quantized = torch.quantization.quantize_dynamic(
    model, {nn.Linear}, dtype=torch.qint8
)

print(quantized.net[0])
$ python quantize_basic.py
DynamicQuantizedLinear(in_features=1000, out_features=2000, dtype=torch.qint8, qscheme=torch.per_tensor_affine)

quantize_dynamic walks the model, replaces every nn.Linear with a DynamicQuantizedLinear, and leaves everything else (here, the ReLU/Sigmoid activations) untouched. {nn.Linear} is the set of layer types to convert — swap in {nn.LSTM, nn.GRU} too if the model has recurrent layers; convolutions aren’t supported by this quantization mode.

Model Size: A Real, Reliable Win

import torch
import torch.nn as nn
import os

torch.manual_seed(0)

class FraudDetector(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(1000, 2000), nn.ReLU(),
            nn.Linear(2000, 2000), nn.ReLU(),
            nn.Linear(2000, 1000), nn.ReLU(),
            nn.Linear(1000, 1), nn.Sigmoid(),
        )

    def forward(self, x):
        return self.net(x)

model = FraudDetector()
model.eval()
quantized = torch.quantization.quantize_dynamic(model, {nn.Linear}, dtype=torch.qint8)

torch.save(model.state_dict(), "fp32_model.pt")
torch.save(quantized.state_dict(), "int8_model.pt")

fp32_size = os.path.getsize("fp32_model.pt") / 1024 / 1024
int8_size = os.path.getsize("int8_model.pt") / 1024 / 1024

print(f"FP32 model size: {fp32_size:.2f} MB")
print(f"INT8 model size: {int8_size:.2f} MB")
print(f"Size reduction:  {fp32_size / int8_size:.2f}x")
$ python quantize_size.py
FP32 model size: 30.54 MB
INT8 model size: 7.66 MB
Size reduction:  3.99x

Every weight goes from 4 bytes (float32) to 1 byte (int8) — a ~4x reduction that holds regardless of model architecture or batch size, because it’s purely a property of the stored weights, not of how the model is run.

Latency: Not Automatic

import torch
import torch.nn as nn
import time
import statistics

torch.manual_seed(0)

class FraudDetector(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(1000, 2000), nn.ReLU(),
            nn.Linear(2000, 2000), nn.ReLU(),
            nn.Linear(2000, 1000), nn.ReLU(),
            nn.Linear(1000, 1), nn.Sigmoid(),
        )

    def forward(self, x):
        return self.net(x)

model = FraudDetector()
model.eval()
quantized = torch.quantization.quantize_dynamic(model, {nn.Linear}, dtype=torch.qint8)

x = torch.randn(1, 1000)

def time_calls(fn, n_calls=100, n_trials=15):
    """Return per-call latency (ms) for each of several independent trials."""
    times = []
    with torch.no_grad():
        for _ in range(20):
            fn(x)   # warm up
        for _ in range(n_trials):
            start = time.perf_counter()
            for _ in range(n_calls):
                fn(x)
            times.append((time.perf_counter() - start) / n_calls * 1000)
    return times

fp32_times = time_calls(model)
int8_times = time_calls(quantized)
fp32_median = statistics.median(fp32_times)
int8_median = statistics.median(int8_times)

print(f"FP32 median: {fp32_median:.4f} ms/call  (min {min(fp32_times):.4f}, max {max(fp32_times):.4f})")
print(f"INT8 median: {int8_median:.4f} ms/call  (min {min(int8_times):.4f}, max {max(int8_times):.4f})")
print(f"Speedup (median): {fp32_median / int8_median:.2f}x")
$ python quantize_latency.py
FP32 median: 4.3076 ms/call  (min 2.8690, max 6.4212)
INT8 median: 3.5220 ms/call  (min 2.8059, max 5.1699)
Speedup (median): 1.22x

A single warm-up-then-measure-once pass (the obvious way to write this) is not reliable — on this same machine, one such single-pass run gave a 1.58x speedup and another gave a 0.97x speedup (quantization measuring slightly slower), purely from other processes competing for CPU between runs. Running 15 independent trials and comparing medians, as above, is what actually holds up: the FP32 and INT8 ranges above overlap at the edges, but the medians consistently favor INT8 across repeated runs of this exact script. Treat a single latency number from any quantization benchmark — this one included — with suspicion; ask how many trials it’s a median of. This is on top of the 4x size reduction from the previous section, which doesn’t have this noise problem because it isn’t a timing measurement. The latency win itself also depends on the model being large enough for cheaper int8 matrix multiplies to outweigh the quantize/dequantize overhead added on every call — rerun the same methodology on a much smaller model and even the median flips:

import torch
import torch.nn as nn
import time
import statistics

torch.manual_seed(0)

class TinyDetector(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(20, 256), nn.ReLU(),
            nn.Linear(256, 256), nn.ReLU(),
            nn.Linear(256, 64), nn.ReLU(),
            nn.Linear(64, 1), nn.Sigmoid(),
        )

    def forward(self, x):
        return self.net(x)

tiny = TinyDetector()
tiny.eval()
tiny_quantized = torch.quantization.quantize_dynamic(tiny, {nn.Linear}, dtype=torch.qint8)

x = torch.randn(1, 20)

def time_calls(fn, n_calls=500, n_trials=15):
    times = []
    with torch.no_grad():
        for _ in range(20):
            fn(x)
        for _ in range(n_trials):
            start = time.perf_counter()
            for _ in range(n_calls):
                fn(x)
            times.append((time.perf_counter() - start) / n_calls * 1000)
    return times

fp32_times = time_calls(tiny)
int8_times = time_calls(tiny_quantized)

print(f"FP32 median: {statistics.median(fp32_times):.4f} ms/call")
print(f"INT8 median: {statistics.median(int8_times):.4f} ms/call")
$ python quantize_tiny.py
FP32 median: 0.9024 ms/call
INT8 median: 2.9116 ms/call

On this much smaller model (4 Linear layers, largest is 256 units), the quantized version is slower — over 3x slower by median, and this direction held across every repeat while writing this lesson, unlike the large model’s noisier result above. The per-call cost of quantizing the activation tensor and dequantizing the int8 output back to float32 outweighs any savings from cheaper int8 arithmetic, because there simply isn’t much matrix-multiply work to make cheaper. Size reduction is a property of the weights; the latency win only appears once the compute is large enough for it to matter, and even then, measure it properly (multiple trials, not one run) before trusting the number.

Practice

1. Quantize a model that has an nn.Conv2d layer and confirm the conv layer stays float32.
import torch
import torch.nn as nn

class SmallCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv = nn.Conv2d(3, 16, kernel_size=3)
        self.fc = nn.Linear(16 * 30 * 30, 10)

    def forward(self, x):
        x = self.conv(x)
        return self.fc(x.flatten(1))

model = SmallCNN()
model.eval()
quantized = torch.quantization.quantize_dynamic(model, {nn.Linear}, dtype=torch.qint8)

print(type(quantized.conv).__name__)
print(quantized.fc)
Conv2d
DynamicQuantizedLinear(in_features=14400, out_features=10, dtype=torch.qint8, qscheme=torch.per_tensor_affine)

conv is untouched — Conv2d isn’t in the {nn.Linear} set passed to quantize_dynamic, and dynamic quantization doesn’t support convolutions at all. fc did convert, but note that type(quantized.fc).__name__ would print "Linear" too — PyTorch’s quantized Linear class happens to share that __name__. Printing the module (its repr) is the reliable way to tell a quantized layer apart from an ordinary one, not comparing class names.

2. Compare quantized output against the original for a batch of 10 inputs instead of 1, and check the maximum difference.
import torch
import torch.nn as nn

torch.manual_seed(0)

class FraudDetector(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(1000, 2000), nn.ReLU(),
            nn.Linear(2000, 2000), nn.ReLU(),
            nn.Linear(2000, 1000), nn.ReLU(),
            nn.Linear(1000, 1), nn.Sigmoid(),
        )

    def forward(self, x):
        return self.net(x)

model = FraudDetector()
model.eval()
quantized = torch.quantization.quantize_dynamic(model, {nn.Linear}, dtype=torch.qint8)

x = torch.randn(10, 1000)
with torch.no_grad():
    fp32_out = model(x)
    int8_out = quantized(x)

print(f"Max absolute difference: {(fp32_out - int8_out).abs().max().item():.6f}")
Max absolute difference: 0.000294

Quantization is lossy — int8 has far less precision than float32 — so outputs shift slightly. A max difference under 0.0003 on a sigmoid-scored probability is nowhere near enough to flip a decision threshold for this model, but it’s worth checking on your own model and threshold rather than assuming it’s always this small.

3. Measure how much smaller a model with only two small Linear layers gets after quantization.
import torch
import torch.nn as nn
import os

class Tiny(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(nn.Linear(10, 10), nn.Linear(10, 1))

    def forward(self, x):
        return self.net(x)

model = Tiny()
model.eval()
quantized = torch.quantization.quantize_dynamic(model, {nn.Linear}, dtype=torch.qint8)

torch.save(model.state_dict(), "tiny_fp32.pt")
torch.save(quantized.state_dict(), "tiny_int8.pt")

fp32_size = os.path.getsize("tiny_fp32.pt")
int8_size = os.path.getsize("tiny_int8.pt")
print(f"FP32: {fp32_size} bytes")
print(f"INT8: {int8_size} bytes")
FP32: 2865 bytes
INT8: 3945 bytes

The quantized file is larger here — for a model this tiny, PyTorch’s serialization overhead (storing quantization parameters like scale/zero-point alongside the int8 weights) outweighs the actual byte savings from a handful of small weight matrices. The 4x rule of thumb needs enough total weight bytes to dominate that fixed overhead.

4. Check which quantized backend engine this machine is actually running, and why that matters.
import torch

print(f"Active quantized engine: {torch.backends.quantized.engine}")
print(f"Supported engines:       {torch.backends.quantized.supported_engines}")
Active quantized engine: onednn
Supported engines:       ['onednn']

This matters because quantization support isn’t uniform across backends. Trying dtype=torch.float16 instead of torch.qint8 in quantize_dynamic on this machine fails with RuntimeError: quantized::linear_prepack_fp16 is currently not supported by ONEDNN — the operator this call needs exists for fbgemm (typical on Linux x86) and qnnpack (ARM), but not for onednn (Windows CPU here). dtype=torch.qint8, used throughout this lesson, works on every backend; float16 dynamic quantization does not, and which backend you get depends on the OS and CPU you’re running on, not a setting you control.

Frequently Asked Questions

What is dynamic quantization?
Post-training quantization that converts a model's Linear/LSTM/GRU weights from float32 to int8, without retraining and without a calibration dataset. Activations stay float32 during the forward pass and are quantized on the fly per batch — hence 'dynamic', as opposed to static quantization, which pre-computes fixed activation ranges from a calibration set.
Is torch.quantization.quantize_dynamic still the right API to use?
It still works, but PyTorch's eager-mode quantization API (torch.quantization / torch.ao.quantization) is deprecated as of PyTorch 2.10 in favor of torchao's quantize_ API. Existing code keeps running (with a DeprecationWarning) for now; new projects should check torchao's docs before committing to an API for production use.
Why was quantized inference slower than the original model in one of the examples here?
Dynamic quantization adds a real per-call cost: activations must be quantized to int8 and the result dequantized back to float32 on every forward pass. For a small model, that overhead can exceed the savings from cheaper int8 matrix multiplies. The size reduction (roughly 4x, float32 to int8) is reliable regardless of model size; the latency win only shows up once the matrix multiplications are large enough for it to pay for the quantize/dequantize overhead.
Does dynamic quantization work on GPU?
No — PyTorch's dynamic quantization backend (fbgemm on x86, qnnpack on ARM) targets CPU inference only. If you need faster GPU inference, look at other approaches (TensorRT, torch.compile, or half-precision/bfloat16 instead of int8).
Which layers does quantize_dynamic actually quantize?
Only the layer types you pass in the qconfig_spec set — typically {nn.Linear} and, if present, {nn.LSTM, nn.GRU}. Convolutional layers are not supported by dynamic quantization (they need static or QAT quantization instead), so a CNN's conv layers stay float32 even after quantize_dynamic runs.