FakeQuantizer

What rounding to 8, 4 and 2 bits costs a fine-tuned ResNet-18 on Imagenette, measured through fastai

FakeQuantizer rounds weights and activations onto a narrower grid and leaves the result in float32, so the model is no smaller and no faster: what it measures is what a width costs in accuracy.

Everything below is one fine-tune of one architecture and one validation pass per configuration — single run, no repetition.

Setup

import math
import torch, torch.nn as nn
from fastai.vision.all import *
from fasterai.quantize.fake_quantizer import FakeQuantizer
from fasterai.core.precision import fake_quant_spec
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'torch {torch.__version__} on {device}, matmul precision {torch.get_float32_matmul_precision()}')
torch 2.9.1+cu128 on cuda, matmul precision highest

Imagenette at 160 pixels, one resnet18, one fine_tune(1), set_seed before the loader and before the learner. Normalize is named on the loader, so the calibration set built with dls.test_dl sees the validation loader’s batch pipeline — the two printed pipelines are that guard.

set_seed(42, reproducible=True)
path = untar_data(URLs.IMAGENETTE_160)
dls = ImageDataLoaders.from_folder(path, valid='val', item_tfms=Resize(160), bs=32,
                                   batch_tfms=Normalize.from_stats(*imagenet_stats))
N = len(dls.valid_ds)

files = sorted(get_image_files(path/'train'))
calib_dl = dls.test_dl(files[::len(files)//160][:160], bs=32)

print(f'{len(dls.train_ds)} training images, {N} validation images, {dls.c} classes')
print(f'calibration set  {len(calib_dl)} batches')
print(f'valid pipeline   {[type(t).__name__ for t in dls.valid.after_batch]}')
print(f'calib pipeline   {[type(t).__name__ for t in calib_dl.after_batch]}')
9469 training images, 3925 validation images, 10 classes
calibration set  5 batches
valid pipeline   ['IntToFloatTensor', 'Normalize']
calib pipeline   ['IntToFloatTensor', 'Normalize']
def wilson(k, n):
    "Wilson 95% interval for k of n, as percentages"
    z = 1.96
    p, d = k / n, 1 + z * z / n
    c = (p + z * z / (2 * n)) / d
    h = z / d * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
    return 100 * max(0., c - h), 100 * min(1., c + h)

def score(learn):
    "Validation accuracy of `learn`, as a percentage and as k/n"
    with learn.no_bar():
        acc = float(learn.validate()[1])
    return 100 * acc, round(acc * N), N

def predictions(learn):
    "Predicted class for every validation image"
    with learn.no_bar():
        preds, _ = learn.get_preds(dl=dls.valid)
    return preds.argmax(1)

set_seed(42, reproducible=True)
learn = vision_learner(dls, resnet18, metrics=accuracy)
with learn.no_bar(), learn.no_logging():
    learn.fine_tune(1)

float_pred = predictions(learn)
pct, k, n = score(learn)
lo, hi = wilson(k, n)
print(f'floating point   {pct:.2f}% ({k}/{n})   Wilson 95% [{lo:.2f}, {hi:.2f}]')
floating point   96.28% (3779/3925)   Wilson 95% [95.64, 96.83]

That is the reference every row below is read against: 96.28% (3779/3925), with a Wilson 95% interval of [95.64, 96.83].

Round the weights

quantize_model() writes the rounded values into the same float32 tensors and keeps a copy of the originals; print_precision() names the width every layer carries, and remove() puts the floating-point weights back.

before = {key: t.detach().clone() for key, t in learn.model.state_dict().items()}

fq = FakeQuantizer(learn.model, weight_bits=8)
fq.quantize_model()

pct, k, n = score(learn)
flips = int((predictions(learn) != float_pred).sum())
print(f'W8 weights       {pct:.2f}% ({k}/{n})   {flips} of {n} predictions changed')
print(f'spec             {fake_quant_spec(learn.model).label}')

fq.print_precision()

fq.remove()
print(f'\nweights restored byte for byte  '
      f'{all(torch.equal(before[key], t) for key, t in learn.model.state_dict().items())}')
print(f'spec after remove()             {fake_quant_spec(learn.model)}')
W8 weights       96.25% (3778/3925)   8 of 3925 predictions changed
spec             W8AF

Simulated Precision Report:
--------------------------------------------------------------------------------
Layer                            Type           Weight     Act        Weight axis 
--------------------------------------------------------------------------------
0.0                              Conv2d         8 bits     float      per_channel 
0.4.0.conv1                      Conv2d         8 bits     float      per_channel 
0.4.0.conv2                      Conv2d         8 bits     float      per_channel 
0.4.1.conv1                      Conv2d         8 bits     float      per_channel 
0.4.1.conv2                      Conv2d         8 bits     float      per_channel 
0.5.0.conv1                      Conv2d         8 bits     float      per_channel 
0.5.0.conv2                      Conv2d         8 bits     float      per_channel 
0.5.0.downsample.0               Conv2d         8 bits     float      per_channel 
0.5.1.conv1                      Conv2d         8 bits     float      per_channel 
0.5.1.conv2                      Conv2d         8 bits     float      per_channel 
0.6.0.conv1                      Conv2d         8 bits     float      per_channel 
0.6.0.conv2                      Conv2d         8 bits     float      per_channel 
0.6.0.downsample.0               Conv2d         8 bits     float      per_channel 
0.6.1.conv1                      Conv2d         8 bits     float      per_channel 
0.6.1.conv2                      Conv2d         8 bits     float      per_channel 
0.7.0.conv1                      Conv2d         8 bits     float      per_channel 
0.7.0.conv2                      Conv2d         8 bits     float      per_channel 
0.7.0.downsample.0               Conv2d         8 bits     float      per_channel 
0.7.1.conv1                      Conv2d         8 bits     float      per_channel 
0.7.1.conv2                      Conv2d         8 bits     float      per_channel 
1.4                              Linear         8 bits     float      per_channel 
1.8                              Linear         8 bits     float      per_channel 
--------------------------------------------------------------------------------
Overall                          W8AF          
Rounded in floating point: every tensor is still stored at its original width, and the model holds a floating-point copy of every weight it rounds until remove().

weights restored byte for byte  True
spec after remove()             None

Eight-bit weights give 96.25% (3778/3925) against 96.28% (3779/3925) in floating point, one image apart and well inside that interval, with 8 of 3925 predictions changed. After remove() the state dict is byte for byte what it was, and fake_quant_spec reads None again.

Three widths

measure rounds the model to one configuration, scores it, counts the validation images whose predicted class differs from the floating-point model’s, and restores the weights.

def measure(**kw):
    "Round the model to one configuration, score it, and restore the floating-point weights"
    fq = FakeQuantizer(learn.model, **kw)
    try:
        if fq.act_bits is not None: fq.calibrate(calib_dl, n_batches=5)
        fq.quantize_model()
        pct, k, n = score(learn)
        return pct, k, n, int((predictions(learn) != float_pred).sum())
    finally:
        fq.remove()

for bits in (8, 4, 2):
    pct, k, n, flips = measure(weight_bits=bits, qscheme='per_channel')
    print(f'W{bits} per_channel   {pct:.2f}% ({k}/{n})   {flips} flips')
W8 per_channel   96.25% (3778/3925)   8 flips
W4 per_channel   90.14% (3538/3925)   332 flips
W2 per_channel   10.42% (409/3925)   3524 flips

Weights only, one scale per output row: 8 bits stays at 96.25% (3778/3925), 4 bits gives 90.14% (3538/3925) with 332 flips, and 2 bits gives 10.42% (409/3925) with 3524 of 3925 predictions changed, which is where a 10-class guess lands.

The activations too

act_bits hooks every rounded module and rounds its output as well. Under observer='static' those scales are frozen by calibrate, which observes the fixed calibration set before quantize_model() runs.

for bits in (8, 4):
    pct, k, n, flips = measure(weight_bits=bits, act_bits=8, qscheme='per_channel')
    print(f'W{bits}A8 static, per_channel   {pct:.2f}% ({k}/{n})   {flips} flips')
W8A8 static, per_channel   96.13% (3773/3925)   30 flips
W4A8 static, per_channel   90.01% (3533/3925)   335 flips

Adding 8-bit activations gives 96.13% (3773/3925) under 8-bit weights and 90.01% (3533/3925) under 4-bit ones, against 96.25% (3778/3925) and 90.14% (3538/3925) for the same weight widths alone. Both rows land within a handful of images of their weights-only row.

Where the scale comes from

qscheme picks the axis the scale is fitted on: per_tensor fits one scale to the whole weight tensor, per_channel one per output row.

for qscheme in ('per_channel', 'per_tensor'):
    pct, k, n, flips = measure(weight_bits=4, qscheme=qscheme)
    print(f'W4 {qscheme:<12}   {pct:.2f}% ({k}/{n})   {flips} flips')

fq = FakeQuantizer(learn.model, weight_bits=4, qscheme='per_channel')
fq.quantize_model()
widest = max(int(row.unique().numel()) for m in learn.model.modules()
             if isinstance(m, (nn.Conv2d, nn.Linear)) for row in m.weight.detach().flatten(1))
fq.remove()
print(f'\nwidest output channel holds {widest} distinct values, of 2**4 = {2 ** 4}')
W4 per_channel    90.14% (3538/3925)   332 flips
W4 per_tensor     24.23% (951/3925)   2976 flips

widest output channel holds 15 distinct values, of 2**4 = 16

At 4 bits the axis decides the outcome — 90.14% (3538/3925) per channel against 24.23% (951/3925) per tensor, with 2976 of 3925 predictions changed. One scale has to cover the loudest row of the tensor, and the quiet rows round away. The widest output channel holds 15 of the 16 values a 4-bit grid carries: the symmetric grid puts a row’s extreme on the highest integer and leaves the lowest unused.

One layer held at floating point

layer_bits maps a layer name to its own width, and None holds that layer in floating point. Here '0.0', the stem convolution of the fastai body, stays in floating point while the rest of the model goes to 4 bits.

pct, k, n, flips = measure(weight_bits=4, qscheme='per_channel', layer_bits={'0.0': None})
print(f"W4 per_channel, '0.0' float   {pct:.2f}% ({k}/{n})   {flips} flips")
W4 per_channel, '0.0' float   92.25% (3621/3925)   244 flips

That moves the 4-bit row from 90.14% (3538/3925) to 92.25% (3621/3925).


Summary

Call What it does
FakeQuantizer(model, weight_bits=8, act_bits=8) a quantizer bound to this model; None on either width leaves those tensors in floating point
fq.calibrate(calib_dl, n_batches=5) observes the activation ranges and freezes the static scales
fq.quantize_model() rounds the weights in place and hooks the activations
fq.remove() restores the floating-point weights and drops every hook and buffer
fq.print_precision() the per-layer width report
layer_bits={'0.0': None} one weight width per layer, None holding that layer in floating point

Measured on: Imagenette-160, 9469 training images and 3925 validation images at 160 pixels, on the torch build and device printed at the top of the page. One fine_tune(1) under set_seed(42, reproducible=True), then one learn.validate() per configuration on the same 3925 images, and one fixed five-batch calibration set for the static rows. Single run: no repetition, and no dispersion beyond the Wilson interval printed for the reference.

See Also