Quantization Hacking: From SimpleCNN to CIFAR-10
Quantization makes neural networks smaller and often faster by representing weights and activations with fewer bits. It also changes the numerical behavior of a model. This hands-on guide builds a convolutional classifier, trains it with Adam and cross-entropy, converts it to INT8, and tests what changed—using CIFAR-10 in a local, reproducible lab.
Here, hacking means taking a model and its deployment pipeline apart to understand assumptions, failure modes, and integrity boundaries. This is not a recipe for breaking into a service. The experiments use a public dataset and a model you train yourself. The security question is practical: can a conversion, calibration, runtime, or artifact change make the model behave differently from the model you validated?
Lab at a glance
- Train a small CNN on the CIFAR-10 training split; evaluate on its separate test split.
- Use raw logits with cross-entropy and update floating-point weights with Adam.
- Convert a copy with post-training static INT8 quantization and calibrate only on training images.
- Compare accuracy and prediction agreement; treat any measured result as local to your machine, library versions, and backend.
1. What quantization changes
A conventional training run represents parameters and intermediate activations using floating-point numbers, often FP32. An INT8 inference path stores or computes many values using signed 8-bit integers. A simple affine mapping is:
q = clamp(round(x / scale) + zero_point, qmin, qmax)
x_hat = (q - zero_point) * scale
x is a real value, q its integer representation, scale the step size, and zero_point the integer that represents real zero. The second equation dequantizes the integer value back to an approximation x_hat. Clamping means values outside the selected range saturate at its endpoints; rounding and saturation introduce error. Small scale improves resolution near zero but clips large values more readily. Large scale preserves a wider range but rounds away small differences.
Per-tensor quantization uses one scale and zero point for a tensor. Per-channel quantization gives each output channel its own range, which often helps convolution weights whose channels have different distributions. Weights and activations may use different schemes. The CPU operator kernel and backend determine the exact supported path; “INT8” does not guarantee identical arithmetic or performance on every device.
- Dynamic quantization: commonly quantizes weights ahead of time and determines activation ranges during inference; it is useful for supported operators such as linear layers.
- Post-training static quantization (PTQ): observes representative inputs to estimate activation ranges, then converts supported operations.
- Quantization-aware training (QAT): inserts simulated quantize/dequantize effects during training so the learned model can adapt to them.
Quantization is a deployment transformation, not a security property. Smaller files or lower precision do not automatically make a model safer, more robust, or private.
2. Convolution, loss, and Adam
Convolution: the image feature extractor
A 2-D convolution slides a learned kernel over the input image. PyTorch's Conv2d uses the conventional name, although its kernel operation is technically cross-correlation (it does not reverse the kernel). For input channels Cin, output channel o, and kernel weights W, a simplified output is:
Y[n, o, i, j] = b[o] +
sum over c, r, s of X[n, c, i+r, j+s] * W[o, c, r, s]
The kernel responds to local patterns; early layers can learn edges and color transitions, and later layers combine them into more useful features. Padding, stride, and kernel size determine the output dimensions. With a 3×3 kernel, stride 1, and padding 1, a 32×32 feature map stays 32×32. A 2×2 max-pool with stride 2 halves it to 16×16.
Cross-entropy: measure the classification error
The network outputs ten raw scores, or logits, one for each class. For the correct class y, cross-entropy is the negative log-probability assigned to that class after softmax:
L = -log(exp(z[y]) / sum_j exp(z[j]))
In PyTorch, nn.CrossEntropyLoss combines the numerically stable log-softmax and negative-log-likelihood operation. Give it logits and integer class indices—not softmax probabilities. Applying softmax first changes the expected input and is a common training bug.
Adam: adapt the parameter updates
Adam tracks exponentially weighted first and second moments of the gradients, m and v. With bias-corrected moments m_hat and v_hat, a simplified update is:
m_t = beta1 * m_(t-1) + (1 - beta1) * g_t
v_t = beta2 * v_(t-1) + (1 - beta2) * g_t^2
m_hat = m_t / (1 - beta1^t)
v_hat = v_t / (1 - beta2^t)
theta_t = theta_(t-1) - learning_rate * m_hat / (sqrt(v_hat) + epsilon)
In each training step, gradients flow backward from loss, Adam updates the FP32 parameters, and the next batch is evaluated. In this lab, Adam trains the floating-point model. PTQ does not keep running Adam on integer weights; it calibrates a trained model and converts supported operators. QAT is a separate training path.
3. Build the SimpleCNN
CIFAR-10 contains 60,000 color images sized 32×32: 50,000 training examples and 10,000 test examples, split across ten classes. The model below uses two convolution and pooling stages followed by a small classifier.
Figure 1. Keep the training, calibration, and final evaluation roles distinct. The test set is for measurement, not calibration or model selection.
Tensor shapes for a batch of 128 are [128, 3, 32, 32] → [128, 32, 16, 16] → [128, 64, 8, 8] → [128, 10]. PyTorch convolution layers use channel-first tensors. The flatten operation turns each final feature map into 4,096 values before the classifier.
4. Train and evaluate on CIFAR-10
For a standard pip environment, install PyTorch and TorchVision; select the matching command from the official installer if you need a CUDA-specific build. Then save the following as train_quantize_cifar.py. The first run downloads CIFAR-10 into ./data. The script uses five epochs for a manageable demonstration; expect the accuracy to vary, and do not treat this small network or short run as a benchmark.
python -m pip install torch torchvision
import torch
from torch import nn
from torch.utils.data import DataLoader, random_split
from torchvision import datasets, transforms
class SimpleCNN(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1)
self.relu1 = nn.ReLU()
self.pool1 = nn.MaxPool2d(2)
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.relu2 = nn.ReLU()
self.pool2 = nn.MaxPool2d(2)
self.flatten = nn.Flatten()
self.fc1 = nn.Linear(64 * 8 * 8, 128)
self.relu3 = nn.ReLU()
self.fc2 = nn.Linear(128, 10)
def forward(self, x):
x = self.pool1(self.relu1(self.conv1(x)))
x = self.pool2(self.relu2(self.conv2(x)))
x = self.flatten(x)
x = self.relu3(self.fc1(x))
return self.fc2(x) # logits; do not apply softmax before CrossEntropyLoss
def evaluate(model, loader, device):
model.eval()
correct = total = loss_sum = 0
criterion = nn.CrossEntropyLoss()
with torch.inference_mode():
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
logits = model(images)
loss_sum += criterion(logits, labels).item() * labels.size(0)
correct += (logits.argmax(dim=1) == labels).sum().item()
total += labels.size(0)
return loss_sum / total, correct / total
def collect_logits(model, loader):
model.eval()
logits, labels = [], []
with torch.inference_mode():
for images, targets in loader:
logits.append(model(images).cpu())
labels.append(targets)
return torch.cat(logits), torch.cat(labels)
def main():
torch.manual_seed(7)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
transform = transforms.ToTensor() # values are in [0, 1]
full_train_set = datasets.CIFAR10("data", train=True, download=True, transform=transform)
test_set = datasets.CIFAR10("data", train=False, download=True, transform=transform)
train_set, validation_set = random_split(
full_train_set, [45000, 5000],
generator=torch.Generator().manual_seed(7),
)
train_loader = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=0)
calibration_loader = DataLoader(train_set, batch_size=64, shuffle=False, num_workers=0)
validation_loader = DataLoader(validation_set, batch_size=256, shuffle=False, num_workers=0)
test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=0)
model = SimpleCNN().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
for epoch in range(5):
model.train()
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad(set_to_none=True)
logits = model(images)
loss = criterion(logits, labels)
loss.backward()
optimizer.step()
val_loss, val_accuracy = evaluate(model, validation_loader, device)
print(f"epoch {epoch + 1}: val_loss={val_loss:.4f} "
f"val_accuracy={val_accuracy:.3%}")
# Keep the trained FP32 model as the reference for the final comparison.
float_model = model.to("cpu").eval()
backend = next(
(name for name in ("x86", "fbgemm", "qnnpack")
if name in torch.backends.quantized.supported_engines),
None,
)
if backend is None:
raise RuntimeError("No supported quantized CPU backend is available")
torch.backends.quantized.engine = backend
from torch.ao.quantization import QConfigMapping, get_default_qconfig
from torch.ao.quantization.quantize_fx import prepare_fx, convert_fx
qconfig = QConfigMapping().set_global(get_default_qconfig(backend))
example_inputs = (torch.randn(1, 3, 32, 32),)
prepared = prepare_fx(float_model, qconfig, example_inputs)
# Calibrate on training examples only; do not use the held-out test set.
with torch.inference_mode():
for batch_index, (images, _) in enumerate(calibration_loader):
prepared(images)
if batch_index == 15: # 16 batches × 64 = at most 1,024 images
break
int8_model = convert_fx(prepared).eval()
float_logits, test_labels = collect_logits(float_model, test_loader)
int8_logits, _ = collect_logits(int8_model, test_loader)
predictions_float = float_logits.argmax(dim=1)
predictions_int8 = int8_logits.argmax(dim=1)
float_loss = nn.CrossEntropyLoss()(float_logits, test_labels).item()
int8_loss = nn.CrossEntropyLoss()(int8_logits, test_labels).item()
float_accuracy = (predictions_float == test_labels).float().mean().item()
int8_accuracy = (predictions_int8 == test_labels).float().mean().item()
agreement = (predictions_float == predictions_int8).float().mean().item()
print(f"backend={backend}")
print(f"FP32: loss={float_loss:.4f}, accuracy={float_accuracy:.3%}")
print(f"INT8: loss={int8_loss:.4f}, accuracy={int8_accuracy:.3%}")
print(f"accuracy_delta={int8_accuracy - float_accuracy:+.3%}")
print(f"prediction_agreement={agreement:.3%}")
for class_index, class_name in enumerate(test_set.classes):
class_mask = test_labels == class_index
acc_float = (predictions_float[class_mask] == test_labels[class_mask]).float().mean().item()
acc_int8 = (predictions_int8[class_mask] == test_labels[class_mask]).float().mean().item()
print(f"{class_name}: FP32={acc_float:.3%}, INT8={acc_int8:.3%}")
if __name__ == "__main__":
main()
The loop shuffles the 45,000-image training subset, computes logits, evaluates cross-entropy, backpropagates gradients, and applies Adam. A separate 5,000-image validation subset tracks training progress; the official test split is used only once for the final FP32-versus-INT8 comparison. PTQ calibrates on a small sample from the training subset. The script then reports overall accuracy, class-wise accuracy, and prediction agreement on the held-out test examples.
5. Post-training INT8 quantization: what to inspect
prepare_fx inserts observers according to a backend-specific configuration. Running representative training images through the prepared graph records activation ranges. convert_fx then replaces supported patterns with quantized implementations. Operator coverage, available backends, and APIs vary between PyTorch releases and builds; use a compatible PyTorch/TorchVision installation and check the current quantization documentation before adapting this example.
Look at more than top-1 accuracy:
- Accuracy delta: INT8 test accuracy minus FP32 test accuracy, reported in percentage points.
- Prediction agreement: fraction of test images for which both models choose the same class. Equal overall accuracy can hide different per-example errors.
- Per-class results: compare each class and inspect a confusion matrix. A good aggregate can conceal a severe regression on one class.
- Latency and memory: measure the actual target hardware, after warm-up and with a defined batch size. A quantized model can be slower when kernels or data movement are a poor fit.
- Numerical drift: compare logits or confidence distributions, not just their argmax. A small logit change can flip a close decision.
There is deliberately no promised accuracy number: random initialization, library versions, hardware, kernels, and training time all affect results. Record the environment with the result—PyTorch and TorchVision versions, device, quantized backend, model hash, data transform, calibration sample, and command-line parameters—so another run can explain discrepancies.
6. Red-team the conversion safely
Quantization hacking is most useful when it becomes a controlled differential test. The comparison above already exposes a basic signal: aggregate behavior differs if the FP32 and INT8 accuracies differ. For a stronger audit, retain logits for both models and calculate prediction agreement and class-wise metrics on exactly the same held-out examples.
Then vary one trusted input at a time in a separate copy of the experiment. Examples include calibrating with a smaller but still representative subset, testing known brightness or contrast shifts, and checking whether a conversion setting or backend change causes unexpected drift. Keep the original test set untouched for the final report. Do not call a transformation an attack success merely because the model changes its prediction: first define the threat model, perturbation limits, target behavior, and success criterion.
Failure modes worth probing
- Calibration mismatch: calibration images do not represent production inputs. Ranges may clip or collapse meaningful activation differences.
- Calibration integrity: a compromised or accidentally skewed calibration pipeline can produce poor scales and a model that fails on expected traffic. Restrict, record, and version the calibration data and preprocessing.
- Conversion drift: a backend, library, graph rewrite, or unsupported operator changes the executed path. Compare conversion output to the approved baseline on fixed test vectors.
- Artifact substitution: a model file, scale, zero point, label mapping, or preprocessing asset is replaced after validation. Hashes help identify changes; signed provenance and controlled deployment determine whether an artifact is authorized.
- Boundary disagreement: quantization rounding shifts a decision near a class boundary. Measure the rate and inspect cases; quantization does not inherently create or remove adversarial robustness.
Keep the experiment honest
Never calibrate on the test split, tune repeatedly against it, or report the best run without the configuration. Separate training, calibration, validation, and final test roles. Publish the measured result with its backend and software versions rather than treating a single CIFAR-10 score as proof of production safety.
7. When post-training quantization is not enough
If PTQ causes unacceptable accuracy or class-wise regressions, consider better calibration coverage, per-channel weight quantization where supported, a different precision such as FP16, or QAT. QAT keeps trainable floating-point parameters but simulates quantization effects in the forward pass. Training can then adapt parameters to rounding and clipping noise before the final conversion.
# Conceptual PyTorch FX QAT flow; use a supported CPU backend and
# verify these APIs against the installed PyTorch version.
from torch.ao.quantization import QConfigMapping, get_default_qat_qconfig
from torch.ao.quantization.quantize_fx import prepare_qat_fx, convert_fx
qat_model = SimpleCNN().cpu()
qat_model.load_state_dict(float_model.state_dict())
qat_model.train()
qat_config = QConfigMapping().set_global(get_default_qat_qconfig(backend))
qat_model = prepare_qat_fx(qat_model, qat_config, example_inputs)
# Fine-tune qat_model on the training split using the same
# CrossEntropyLoss / Adam loop, with a smaller learning rate.
qat_optimizer = torch.optim.Adam(qat_model.parameters(), lr=1e-4)
# Run a bounded fine-tuning loop here using training data only.
qat_model.eval()
qat_int8_model = convert_fx(qat_model)
This is a flow sketch, not a turnkey recipe for every PyTorch release or architecture. QAT needs a backend-compatible model, training code, a validation protocol, and a final target-device check. It can recover some quantization-related accuracy loss; it cannot compensate for unrepresentative data, incorrect labels, a compromised build, or a broken runtime.
8. Defend the model and quantization pipeline
Protect the lifecycle, not only the inference call. A sound release process should make each transformation attributable and reproducible:
- Pin and review tooling: control PyTorch, TorchVision, conversion libraries, kernels, and build images. Review dependency changes and generate a software bill of materials where appropriate.
- Separate data roles: maintain access controls and provenance for training and calibration data; prevent test examples from silently entering calibration.
- Make conversion deterministic enough to audit: record the source checkpoint digest, code revision, preprocessing, observer configuration, backend, conversion command, and resulting artifact digest.
- Validate the released artifact: run fixed regression vectors through the FP32 and quantized versions, check class-wise behavior and thresholds, and compare the final device build to the one that passed review.
- Authenticate deployment: sign release manifests and verify them at promotion or startup. A cryptographic hash detects difference only when the expected hash comes from a trusted channel.
- Monitor for drift: observe input ranges, clipping or saturation where available, confidence shifts, class distribution, and runtime errors. Use privacy-preserving telemetry and an escalation path.
- Keep rollback possible: retain the approved previous artifact and its metadata; deploy quantized variants behind controlled rollout and rollback mechanisms.
The principle generalizes beyond vision models. Quantization is one of many compiler-like transformations that can make validated source and deployed behavior diverge. Treat the converter, calibration corpus, runtime backend, and model artifact as part of the trusted computing base.
9. Primary references
- CIFAR-10 and CIFAR-100 dataset information, University of Toronto.
- PyTorch installation selector for operating-system and accelerator-specific packages.
- PyTorch
Conv2dreference. - PyTorch
CrossEntropyLossreference. - PyTorch Adam optimizer reference.
- TorchVision CIFAR10 dataset API.
- PyTorch quantization documentation; check the version-specific API guidance for your deployment target.
Use a versioned official guide and the actual backend documentation for production work. Quantization support evolves; the FX example above is intended as an educational, local CPU lab rather than a recommendation to deploy a specific API without validation.