YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
VGG16 - TensorRT benchmark (Jetson Orin Nano)
PyTorch -> ONNX (FP32/FP16) -> ONNX Runtime -> TensorRT, plus INT8 and INT4 PTQ through NVIDIA ModelOpt. Every backend is compared for accuracy on ImageNet val images and for numerical parity against one PyTorch FP32 reference.
Run it
source env.sh # source it - do NOT pipe it, the exports are lost in a subshell
bash run_all.sh
Or step by step (each step writes its own results/*.json, so anything can be
re-run in isolation):
python 0_prepare_data.py # splits + uint8 cache
python 1_pytorch_baseline.py # writes results/ref_logits.npy
python 2_export_onnx.py # FP32 + FP16 ONNX
python 3_onnx_eval.py --onnx artifacts/vgg16_fp32.onnx --tag fp32
python 3_onnx_eval.py --onnx artifacts/vgg16_fp16.onnx --tag fp16
python 4_make_calib.py # calibration npz
bash 5_quantize.sh # INT8 + INT8(hp32) + INT4
python 3_onnx_eval.py --onnx artifacts/vgg16_int8.onnx --tag int8 # SIMULATED quant accuracy
python 3_onnx_eval.py --onnx artifacts/vgg16_int4.onnx --tag int4
bash 6_build_engines.sh # trtexec
python 7_trt_eval.py --engine engines/vgg16_fp32.plan --tag fp32 --layer-precisions
python 8_report.py
Engines are not portable: a .plan built on the x86 box will not deserialize
on the Jetson. Steps 6-7 must run on the Orin Nano itself. Steps 0-5 produce only
ONNX and .npy/.npz, which are portable.
What is load-bearing
Precision comes from the ONNX file, not from trtexec flags. TensorRT 11 builds
strongly-typed networks only and has removed --fp16 / --int8 / --best /
--calib. 6_build_engines.sh passes --stronglyTyped, which is required on
TRT 10 (JetPack, where the builder would otherwise pick its own precisions and
ignore the ONNX dtypes) and a documented no-op on TRT 11. So:
| ONNX | engine |
|---|---|
vgg16_fp32.onnx |
FP32 |
vgg16_fp16.onnx |
FP16 (fp16 weights, fp32 IO) |
vgg16_int8.onnx |
INT8 (explicit Q/DQ from ModelOpt) |
vgg16_int4.onnx |
INT4 weight-only (Q/DQ on the classifier Gemms) |
One preprocessing path. Images are cached as uint8 after resize(256) + centercrop(224) but before normalization, and every backend reads the same bytes. Preprocessing drift between PyTorch/ORT/TRT is a common source of fake accuracy deltas; here it is structurally impossible.
Eval and calibration images are disjoint. Eval takes from the front of each class list, calibration from the back, asserted in step 0.
VGG16 is the easy INT8 case - and that is the point
The MobileNetV3-Small pipeline in this repo needs a hand-tuned recipe (exclude 11 depthwise convs, quantize Conv/Gemm only) or INT8 collapses from ~68 % to 0.1 % top-1. Two things cause that: depthwise convs, whose per-channel input ranges span orders of magnitude against TensorRT's ONE per-tensor activation scale, and squeeze-excite Mul outputs with max/p99.9 ratios up to 13x.
VGG16 has neither. It is 13 plain dense convs (group=1), ReLU, MaxPool and three Gemms. The default recipe - quantize everything, entropy calibration - is the correct one. Measured on 200 held-out val images, ORT-simulated:
| model | top-1 agreement vs FP32 | cosine sim |
|---|---|---|
| INT8 (default recipe) | 91.0 % | 0.9870 |
| INT4 (weight-only) | 98.0 % | 0.9994 |
So do not pre-emptively exclude nodes here: every exclusion is a layer left
in float and speedup you paid for and did not get. 5_quantize.sh int8_max and
5_quantize.sh int8_safe exist as fallbacks if your numbers disappoint, not as
the default.
Where VGG16's weights actually live
python qdq_tools.py summary --onnx artifacts/vgg16_fp32.onnx:
Conv 14.715 M (10.6 %)
Gemm 123.643 M (89.4 %)
total 138.358 M
ModelOpt's ONNX INT4 path is weight-only and quantizes Gemm/MatMul only - it is an LLM-oriented path. On MobileNet that reaches almost nothing. On VGG16 it reaches 89 % of the weights, so the file shrinks hard (528 MB -> 119 MB measured). But those Gemms are ~1 % of VGG16's 15.5 GFLOPs - the convs do the work - so expect the size win and essentially no speedup. That is the honest result, not a misconfiguration.
INT4: the CLI works here (it does not for MobileNetV3)
TensorRT requires the INT4 block size to divide the blocked axis exactly, or it
refuses to parse the model with Assertion failed: inputSize % scaleSize == 0: Inferred block size is not an integer. ModelOpt's CLI does not expose
--block_size and always uses 128.
VGG16's blocked axes are 25088 / 4096 / 4096 - all clean multiples of 128
(196 / 32 / 32) - so the plain CLI INT4 path works and is the default here.
5_quantize.sh verifies this with qdq_tools.py check-block-size before
quantizing rather than assuming it. 5b_quantize_int4.py remains for sweeping
block sizes or for a model where 128 stops dividing.
One workaround is still required. ModelOpt 0.45.0 emits blocked INT4
DequantizeLinear nodes with axis=0 while the scale tensor is laid out for
axis=1:
classifier.0.weight: weight(4096, 25088) scale(4096, 196) block=128 axis 0 -> 1
ORT and TRT both reject that (x_scale must be ceil(Di/block_size) on the quantize axis). qdq_tools.py fix-int4-axis patches it and 5_quantize.sh runs
it automatically after every INT4 quantization.
Memory on the Orin Nano
VGG16 is ~250x the FLOPs of MobileNetV3-Small and its largest activation is 12.8 MB per image, against 8 GB shared between CPU and GPU. Defaults are set for that, not for the dev box:
- eval batch 16 everywhere (
C.DEFAULT_BATCH, override withEVAL_BATCH) - calibration batch 10 -> 100 iterations over 1000 images
- engine profile min/opt/max = 1/1/16, workspace 2048 MiB on Jetson
7_trt_eval.py reads the engine's profile back and clamps --batch to it, so a
mismatch is a clear message instead of a TensorRT assertion mid-run.
Builds are the slow part. OPTLEVEL=1 bash run_all.sh cuts build time a lot at
some engine quality; WORKSPACE=1024 MAXB=8 bash 6_build_engines.sh if memory is
tight.
Files
| file | role |
|---|---|
env.sh |
venv + LD_LIBRARY_PATH + trtexec. Portable x86 / Jetson, every fix conditional |
common.py |
dataset splits, preprocessing, metrics - the single source of truth |
check_dataset.py |
images-per-class distribution of the val folder |
0_prepare_data.py |
disjoint eval/calib splits + uint8 cache |
1_pytorch_baseline.py |
FP32 baseline, writes results/ref_logits.npy |
2_export_onnx.py |
ONNX FP32 + FP16 (keep_io_types=True) |
3_onnx_eval.py |
ORT accuracy/latency/parity - works for every precision |
4_make_calib.py |
calibration npz, key validated against the real graph |
5_quantize.sh |
ModelOpt PTQ, CLI only |
5b_quantize_int4.py |
INT4 with an explicit block size (off the default path) |
qdq_tools.py |
graph summary, block-size check, sensitive-node list, INT4 axis fix |
6_build_engines.sh |
trtexec engine builds + trtexec's own profiling |
7_trt_eval.py |
TRT accuracy/latency/parity + per-layer precision histogram |
8_report.py |
collects results/*.json into one table + results/REPORT.md |
Reading the results
- ORT
Mean msincludes host<->device copies; TRTMean msis GPU compute only. They are not comparable - use TRT for the real latency story. CosSim/Agree %are againstresults/ref_logits.npy, the PyTorch FP32 logits. Accuracy alone can hide a broken backend: two models can score the same top-1 while disagreeing on a third of the images.--layer-precisionsshows what TensorRT actually chose. A highreformat_layerscount means the recipe left too many layers in float and TRT is paying to convert back and forth. On VGG16 that count should be low - the network is one long chain of convs, so an INT8 region can stay INT8 end to end.