Every number in a model is a compromise.
Type any value. Watch what each format keeps and what it throws away. Everything else in this guide — calibration, SmoothQuant, GPTQ, TensorRT's precision rules — is about managing exactly this loss.
Try 70000: FP16 overflows (its ceiling is 65,504) while BF16 shrugs — same exponent width as FP32. Try 1e-5: FP8 E4M3 flushes it to zero. Try the INT8 row with absmax 1 and a value of 3: it clips. Range, precision, clipping — hold those three words, they come back in every chapter.
Number formats: range versus precision
A checkpoint is a dictionary of named tensors — each one a dtype, a shape and a flat run of bytes (.safetensors is literally a JSON header of offsets plus one byte buffer; .pt is a zip of a pickle plus raw storages). Size on disk is parameters × bytes per element. Quantization changes the bytes-per-element term. Before changing it, you need to know what each format can and cannot hold.
Floating point in one sentence
A float is (−1)sign × 1.mantissa × 2exponent − bias. Exponent bits buy range (how big and how small). Mantissa bits buy precision (how finely spaced the values are near any given magnitude). Spacing is relative: between 1 and 2, FP16 has steps of 2−10; between 1024 and 2048 its steps are 1. That relative spacing is why floats handle wide dynamic ranges gracefully and integers don't.
| Format | Sign·Exp·Man | Largest | Smallest normal | Where it lives |
|---|---|---|---|---|
| FP32 | 1·8·23 | 3.4e38 | 1.2e−38 | Reference, optimizer master weights, accumulators |
| TF32 | 1·8·10 | 3.4e38 | 1.2e−38 | NVIDIA tensor-core math mode; stored as FP32 |
| FP16 | 1·5·10 | 65,504 | 6.1e−5 | GPU inference default; overflow risk in LLM activations |
| BF16 | 1·8·7 | 3.4e38 | 1.2e−38 | Training default — FP32 range, coarse precision, never overflows |
| FP8 E4M3 | 1·4·3 | 448 | 2−6 | Inference weights + activations (Hopper, Blackwell, MI300, Thor) |
| FP8 E5M2 | 1·5·2 | 57,344 | 2−14 | Gradients — range matters more than precision |
| FP4 E2M1 | 1·2·1 | 6 | 1 | Only ±{0, 0.5, 1, 1.5, 2, 3, 4, 6}. Useless without block scales |
| INT8 | two's complement | 127·s | s (the scale) | Integer GEMMs; needs an external scale |
| INT4 | two's complement | 7·s | s | Weight-only LLM/VLA compression |
Subnormals, saturation and special values
When the exponent field is all zeros, IEEE formats switch to subnormals: the implicit leading 1 becomes 0, giving a gradual slide to zero instead of a cliff. FP16 subnormals reach down to about 6e−8. At the other end, IEEE formats overflow to inf. The OCP FP8 E4M3 variant deliberately has no infinity — it reclaims those bit patterns for extra finite values and saturates at ±448, keeping a single NaN encoding. Kernels typically clamp before casting so a large activation becomes 448, not garbage.
Microscaling: how 4-bit becomes usable
FP4 alone covers 0.5 to 6. Real tensors span orders of magnitude. The fix is block scaling baked into the format:
| Format | Element | Block | Shared scale | Hardware |
|---|---|---|---|---|
| MXFP8 / MXFP6 / MXFP4 | FP8 / FP6 / FP4 | 32 | E8M0 — a pure power of two | OCP standard; AMD MI350 (CDNA4), Blackwell |
| NVFP4 | FP4 E2M1 | 16 | FP8 E4M3 per block + FP32 per tensor | NVIDIA Blackwell |
| GGUF K-quants | 2–6 bit ints | sub-blocks in 256 super-blocks | quantized scales and mins | llama.cpp / ggml, CPU and GPU |
Smaller blocks and a finer-grained scale (NVFP4's FP8 scale vs MX's power-of-two) cost a little metadata and buy accuracy. This is per-group quantization, chapter 3, pushed into silicon.
The quantization equation
Integers have no exponent. To store real numbers in INT8 you pick a scale s (the real-valued step between neighbouring integers) and optionally a zero-point z (the integer that represents real zero):
x̂ = s · ( q − z ) quantize, then dequantize. x̂ is what the network "sees". The gap x − x̂ is quantization error.
Symmetric vs asymmetric
Asymmetric: s = (max − min) / (2b − 1), z = qmin − round(min / s) Symmetric wastes half the grid on a one-sided distribution (post-ReLU). Asymmetric uses every level but adds a zero-point term to every GEMM.
Weights are near-symmetric around zero → symmetric, per-channel. Activations after ReLU/GELU are skewed → asymmetric is tempting, but many accelerators prefer symmetric activations too because the zero-point cross-term costs an extra correction. Symmetric INT8 usually uses −127…127, not −128, so the grid stays symmetric.
The two kinds of error
Every range choice trades rounding error against clipping error:
- Rounding error — inside the range, each value moves by at most s/2. Treating it as uniform noise, its variance is s²/12. Bigger range → bigger s → more rounding noise everywhere.
- Clipping error — values outside the range get slammed to the edge. Error grows with how far outside they were.
Absmax never clips but lets one outlier inflate s for everybody. Tighter ranges clip a few values to keep the grid fine for the many. Rule of thumb: each extra bit halves s and buys about 6 dB of signal-to-quantization-noise ratio (SQNR).
Things to try. Weights at 8 bits: absmax is fine. Turn on outliers: SQNR collapses under absmax, and percentile/MSE rescue it by clipping. GELU activations: switch to asymmetric and watch SQNR jump — symmetric wastes the negative half. Drop to 2 bits: every choice is bad, which is why 2-bit needs the heavy machinery in chapter 5.
What the hardware actually computes
For a linear layer y = W x with symmetric per-channel weights (scale sw per output row) and a per-tensor activation scale sx, zero-point zx:
Then the output is requantized for the next layer: qy = round( y / sy ) + zy. In practice swsx/sy is folded into one fixed-point multiplier plus a bit shift, and the bias is pre-quantized to INT32 at scale swsx. That's why it matters that per-channel weight scales are "free": they factor out of the sum over k. A per-channel activation scale would sit inside the sum and break the integer GEMM — keep that in mind for SmoothQuant.
Two very different mechanisms behind the labels
Granularity: how many scales
One scale for a whole tensor is cheap and fragile. More scales track local ranges better at the cost of metadata and kernel complexity.
| Granularity | One scale per… | Typical use | Cost |
|---|---|---|---|
| Per-tensor | whole tensor | static INT8/FP8 activations | 1 float |
| Per-channel (per-axis) | output channel of a weight | INT8 weights — the standard | free in GEMM (factors out) |
| Per-group / block | 32–128 consecutive weights | INT4 weight-only (GPTQ, AWQ, GGUF), MX, NVFP4 | ~0.25–0.5 extra bits/weight |
| Per-token | each row of an activation | dynamic activation quant for LLMs | computed at runtime |
Per-tensor lets the loudest channel set the step for all eight; quiet channels round to almost nothing. Per-channel fixes the across-channel problem. Per-group also fixes within-channel spikes, which is why 4-bit schemes all go per-group.
Calibration: choosing ranges for activations
Weights are known offline — you can inspect every value. Activations only exist when data flows. Calibration runs a few hundred representative inputs, records each activation tensor's statistics, and picks a range.
Range-selection methods
| Method | Rule | Strength | Weakness |
|---|---|---|---|
| Absmax / min-max | range = observed extremes (running max or EMA) | no clipping, trivial | one outlier ruins the grid |
| Percentile | range = 99.9 / 99.99th percentile of |x| | robust, cheap | percentile is a hyperparameter; clips real signal if tails matter |
| MSE-optimal | search clip value α minimizing ‖x − x̂(α)‖² | directly minimizes error | treats all errors equally — not task loss |
| Entropy / KL | pick threshold minimizing KL(P ‖ Q) between original and quantized histograms | TensorRT's classic INT8 default; good for CNNs | slow; histogram resolution matters |
| ACIQ-style analytic | fit Gaussian/Laplace, compute optimal clip in closed form | no search | wrong if the distribution isn't that shape |
How the KL/entropy calibrator works
Build a histogram of |activation| (TensorRT uses 2048 bins). For each candidate threshold i (starting around bin 128): take bins 0…i as reference distribution P, fold the clipped tail mass into the last bin, re-bin P into 128 levels (the INT8 positive half) to get Q, expand Q back, and compute KL(P‖Q). Choose the threshold with minimum KL. Intuition: preserve the shape of the distribution the next layer sees, not every extreme value.
Static vs dynamic
Static: activation scales frozen from calibration. Zero runtime cost; wrong if deployment data drifts. Dynamic: scale computed per tensor/token at runtime (e.g. per-token absmax). More accurate for LLM activations, costs a reduction pass per tensor.
What makes a good calibration set
- Representative, not large. 128–512 samples often suffice; covering the input modes matters more than count.
- Deduplicated. Consecutive robot frames are near-identical; effective sample size is roughly N divided by the correlation length.
- Held-out. Evaluate on data you didn't calibrate on.
- Seeded. Unseeded shuffles near a threshold make exports flaky between runs.
- Covers every axis the model conditions on. For diffusion that includes timesteps.
PTQ algorithms: from rounding to rotating
Post-training quantization (PTQ) quantizes a finished model with a little calibration data and no full retraining. Every algorithm below attacks one of three problems: rounding is locally greedy, errors compound across weights, or activations have outliers.
The baseline: round-to-nearest (RTN)
Quantize each weight independently to its nearest level. Works at 8 bits for almost everything. At 4 bits and below it degrades, because the best choice for each weight alone isn't the best choice for the layer's output.
AdaRound — learn which way to round
Nagel et al., 2020. Key observation: what matters is the layer output Wx, not each weight. Rounding some weights "the wrong way" can let errors cancel. AdaRound keeps the scale fixed and learns, for each weight, a binary choice: floor or ceil.
minimize ‖ W x − W̃ x ‖²F + λ Σ ( 1 − |2h(V) − 1|β ) h(V) is a rectified sigmoid (ζ = 1.1, γ = −0.1). The regularizer pushes h to exactly 0 or 1; β is annealed high → low. Optimized layer by layer on calibration activations.
Rows shaded red are weights where the output-optimal choice rounds away from nearest. AdaRound approximates this search with gradient descent, because 2n is impossible for real layers.
Descendants. BRECQ (2021) reconstructs whole blocks (e.g. a residual block) instead of single layers, using second-order (Fisher) weighting — enables 2–4 bit. QDrop (2022) randomly drops activation quantization during reconstruction so weights become robust to activation noise.
GPTQ — compensate error as you go
Frantar et al., 2022, built on Optimal Brain Quantization (OBQ). Quantize one weight column at a time; after each, update the not-yet-quantized weights to absorb the error, using the inverse Hessian of the layer's reconstruction loss.
for column i: qi = quant(wi), δ = (wi − qi) / [H−1]ii
wj ← wj − δ · [H−1]ij for all j > i Correlated inputs let later weights compensate for earlier rounding. Practical GPTQ: fixed column order shared across rows, lazy batch updates in blocks of 128, Cholesky of H⁻¹ for stability. Optional "act-order" quantizes high-Hessian columns first.
Watch the unquantized weights drift after each step — that drift is error compensation. Compare the final output error against RTN. GPTQ is greedy, so on some layers it gains little; the demo picks layers where the effect is visible. GPTQ quantizes 175B-parameter models to 3–4 bits in a few GPU hours; it's the default weight-only method for LLMs alongside AWQ.
AWQ — protect the salient weights
Lin et al., 2023 (activation-aware weight quantization). Observation: about 1% of weight channels matter disproportionately, and you find them by looking at activation magnitude, not weight magnitude. Instead of keeping them in FP16 (mixed precision is hardware-unfriendly), scale them up before quantizing:
SmoothQuant — move the difficulty from activations to weights
Xiao et al., 2022. Enables true W8A8 on LLMs. Activations have a few channels with huge magnitudes; weights are well-behaved. Since per-channel activation scales can't be used inside an integer GEMM (chapter 2), SmoothQuant applies a mathematically equivalent per-channel rescale:
sj = max|Xj|α / max|Wj|1−α (α ≈ 0.5, up to 0.75–0.85 for harder models) diag(s)⁻¹ is folded offline into the preceding LayerNorm/linear, so it costs nothing at runtime. α balances difficulty between the two operands.
Start with smoothing off: two activation channels dominate, and per-tensor INT8 leaves the typical channel about ten levels. Turn it on and sweep α. At α = 0 the weights become perfectly flat and all the difficulty lands on activations; at α = 1 it all lands on the weights. Around α ≈ 0.5 both operands keep a healthy share of levels — that balance is the whole method.
LLM.int8() — just don't quantize the outliers
Dettmers et al., 2022. Vector-wise INT8, plus mixed-precision decomposition: feature dimensions whose activations exceed a threshold (magnitude 6) are pulled out and multiplied in FP16; the remaining ~99.9% run in INT8. Accurate, but the split GEMM is slower than pure INT8 — it's a memory-saving method more than a speed method.
Rotations — QuaRot and SpinQuant
If outliers live in a few channels, rotate the space so every channel gets a bit of every outlier. For any orthogonal matrix Q:
QuaRot (2024) uses Hadamard rotations to reach end-to-end W4A4 with 4-bit KV cache. SpinQuant (2024) learns the rotations (optimized on the Stiefel manifold) and beats random ones. Same idea underlies QuIP / QuIP# for 2-bit weights.
The rest of the zoo
| Method | Quantizes | Key trick | When you'd reach for it |
|---|---|---|---|
| ZeroQuant | W8A8 | group-wise weights, token-wise activations, layer-wise distillation | early LLM W8A8 |
| OmniQuant | W4A16 → W4A4 | learnable weight clipping + learnable equivalent (smoothing) transform | when SmoothQuant's closed-form α isn't enough |
| SqueezeLLM | 3–4 bit W | non-uniform (k-means) levels weighted by sensitivity + sparse outliers | memory-bound serving |
| SpQR | 3–4 bit W | small groups + outlier weights kept sparse in high precision | near-lossless small models |
| AQLM / QuIP# | 2 bit W | vector (codebook) quantization / lattice codebooks with rotations | extreme compression |
| HQQ | 2–8 bit W | calibration-free; half-quadratic solver for scale/zero | no calibration data available |
| NF4 (QLoRA) | 4 bit W | levels at quantiles of N(0,1); double-quantized scales | fine-tuning a frozen 4-bit base |
| FP8 PTQ (ModelOpt, Quark) | W8A8 FP8 | per-tensor scales usually enough thanks to exponent bits | first thing to try on FP8 hardware |
| Q-Diffusion / PTQ4DM | diffusion models | timestep-aware calibration; split quantization of skip connections | diffusion and flow-matching heads |
| KV-cache quantization | KV in FP8/INT8/INT4 | per-head / per-token scales; KIVI quantizes keys per-channel, values per-token | long context, many concurrent sequences |
Quantization-aware training
When PTQ loses too much, let the model learn to live with rounding. QAT inserts fake-quantization nodes — quantize then immediately dequantize, all in floating point — so the forward pass sees exactly the error it will see at deployment.
backward (STE): ∂x̂/∂x ≈ 1 inside the range, 0 outside round() has zero gradient almost everywhere; the straight-through estimator pretends it's the identity so gradients flow.
- LSQ (learned step size, 2020): the scale s is a trainable parameter with a gradient derived through the rounding, scaled by 1/√(N·Qp) for stable training.
- PACT (2018): learns the activation clipping value α — clipping becomes a trainable trade-off instead of a calibration heuristic.
- Recipe: start from the trained FP model, PTQ-initialize scales, fine-tune a few percent of the original schedule with a lower learning rate, freeze BatchNorm statistics late.
When to use it: 4-bit activations, small models with little redundancy (mobile CNNs, compact ViTs), or when PTQ misses the accuracy gate. Cost: a training pipeline, training data, and GPU time. For LLM-scale models people prefer PTQ plus a light fine-tune (e.g. QLoRA-style) over full QAT.
Choosing a recipe
The profile follows from two questions: what bounds this workload, and what the hardware executes natively.
| Situation | Start with | Why |
|---|---|---|
| Memory-bound: batch-1 LLM/VLA decode, big backbone | W4A16 / W8A16 weight-only (AWQ, GPTQ) | bytes moved is the cost; math stays FP16 |
| Compute-bound: ViT prefill, conv backbones, batched GEMMs | FP8 W8A8 if supported, else INT8 W8A8 (+SmoothQuant) | tensor-core throughput doubles |
| Integer NPU (Hexagon, XDNA) | W8A8 for CNNs; W8A16 for transformers | INT16 activations survive outliers |
| Diffusion / flow-matching head | FP8 or BF16; timestep-aware calibration; fewer steps first | step count dominates latency |
| Extreme memory limit | 4-bit groups + sensitive layers kept high | mixed precision per layer |
The workflow that doesn't lie to you
- Fix the reference first. Compare exported graph vs framework at FP32/FP16 before quantizing anything. If export isn't faithful, quantization results are meaningless.
- Profile the bound. Memory or compute, per component.
- Sensitivity analysis. Quantize one layer (or block) at a time, measure the drop. Rank layers.
- Keep the usual suspects high: first and last layers, LayerNorm/RMSNorm, softmax, attention score matmuls, anything feeding a residual stream with outliers.
- Calibrate on representative, held-out data covering every conditioning axis.
- Gate on the task metric — closed-loop success for a policy, not just cosine or MSE on actions.
Where time goes: the roofline
Every kernel is limited either by how fast the chip computes or by how fast it can be fed. The roofline model makes that a single plot.
attainable FLOP/s = min( peak compute, AI × memory bandwidth ) The ridge point = peak / bandwidth. Below it you're memory-bound; above it, compute-bound.
Batch-1 decode is the canonical memory-bound case. A matrix-vector product reads each FP16 weight (2 bytes) once and does 2 FLOPs with it (multiply + add): AI ≈ 1 FLOP/byte. Modern accelerators have ridge points in the hundreds. The chip idles more than 99% of its math. That's why weight-only INT4 speeds decode up almost 4×: same FLOPs, a quarter of the bytes, AI ≈ 4.
Set M = 1 (one token) then M = 512 (a prefill or a ViT with 512 patches) and watch the point cross the ridge. Same weights, same layer, different bound — which is why one model can need two quantization strategies.
Kernel fusion: stop writing to memory
Unfused, every op reads its inputs from DRAM and writes its output back. For cheap elementwise ops that round-trip costs far more than the math. Fusion keeps intermediates in registers or shared memory (LDS on AMD) and writes once.
Kinds of fusion
- Vertical (producer–consumer): conv/GEMM + bias + activation, LayerNorm + residual add. The epilogue of a GEMM kernel applies elementwise ops before the single write.
- Horizontal: independent ops with shared input launched as one kernel — Q, K, V projections become one larger GEMM.
- Reduction fusion: softmax, LayerNorm, RMSNorm as one pass with warp/wavefront shuffles instead of several kernels.
- Quantization fusion: dequantize inside the GEMM (weight-only kernels), requantize in the epilogue, fold Q/DQ into neighbours.
FlashAttention — fusion that changed the field
Naive attention materializes the N×N score matrix in DRAM: write S = QKᵀ, read it for softmax, write P, read it for PV. FlashAttention tiles Q, K and V into on-chip blocks and never materializes S. The trick that makes tiling legal is the online softmax:
ℓ ← em − mnew ℓ + rowsum( eStile − mnew )
O ← em − mnew O + eStile − mnew Vtile finally O ← O / ℓ Keep a running max m and running denominator ℓ; rescale earlier partial results when the max grows. Exact softmax, no N×N buffer, memory O(N) instead of O(N²).
It's IO-aware: same FLOPs (slightly more, due to recomputation in the backward pass), far fewer bytes. On AMD this lives in Composable Kernel and AITER.
CUDA and HIP graphs: kill the launch tax
Every kernel launch costs CPU time in the driver — a few microseconds — and the CPU has to stay ahead of the GPU. A batch-1 VLA step is hundreds of small kernels; a four-step flow-matching head multiplies that. When kernels are shorter than launch overhead, the GPU starves. On an embedded CPU also running ROS, launch timing jitters.
A graph records the sequence once (stream capture), instantiates it, and replays it with a single launch.
The rules that bite
- Static shapes. A captured graph bakes in sizes; new shapes need re-capture (or one graph per shape bucket).
- Static addresses. Copy new inputs into pre-allocated buffers; never allocate inside capture.
- No host synchronization or host-side work inside capture. In TensorRT terms: set tensor addresses and input shapes before capture, capture only
enqueueV3. - Warm up first. Run once eagerly so lazy initialization happens outside capture.
- It only removes launch cost. A compute- or bandwidth-bound kernel isn't faster in a graph.
Pruning and sparsity
Remove weights (or whole structures) that contribute little. The catch: zeros only save time if hardware can skip them.
| Type | What's removed | Speedup on real hardware |
|---|---|---|
| Unstructured | individual weights anywhere | little on GPUs below ~90% sparsity; saves storage with sparse formats |
| Semi-structured 2:4 | 2 of every 4 contiguous weights | sparse tensor cores: up to 2× math throughput, less end to end |
| Structured | channels, heads, layers, blocks | always — the model simply gets smaller and dense |
| Token pruning / merging | activations (sequence length) | attention cost falls quadratically |
Scoring what to cut
- Magnitude: drop smallest |w|. Simple, surprisingly strong with fine-tuning.
- Movement pruning: drop weights moving toward zero during fine-tuning — better for transfer learning.
- SparseGPT (2023): one-shot, GPTQ-style — prune with the inverse Hessian and update remaining weights to compensate. 50–60% unstructured on large LLMs with small loss, no retraining.
- Wanda (2023): score = |Wij| · ‖Xj‖2, prune per output row, no weight update. Activation-aware, like AWQ is for quantization.
- Structured: head importance, channel L1 norms, layer dropping (LLM-Pruner, depth pruning) — then fine-tune or distill to recover.
The 2:4 workflow
Train dense → prune each group of 4 to its 2 largest magnitudes → fine-tune to recover → export. Storage: half the values plus 2-bit indices per kept value. In TensorRT, build with --sparsity=enable and it uses sparse tactics only where they are actually faster.
Token merging
ToMe (2022) merges similar tokens in a ViT via bipartite soft matching — r tokens per layer, training-free, often ~2× throughput with a small accuracy drop. For a VLA with multiple cameras the vision tokens dominate the LLM's sequence, so token reduction is often a bigger win than any kernel.
Distillation: a small model that thinks like a big one
Train a student to match a teacher's outputs, not just hard labels. The teacher's full distribution carries "dark knowledge" — which wrong answers are nearly right.
Variants worth knowing
- Feature / hint distillation: match intermediate representations (FitNets), attention maps, or hidden states through a small projection. Useful when student and teacher widths differ.
- Sequence-level / on-policy distillation: student generates, teacher scores or corrects — avoids exposure bias.
- Regression outputs (actions): no logits, so match actions directly (MSE/L1) or match the teacher's predicted velocity field.
- Step distillation for diffusion: progressive distillation trains a student to do two teacher steps in one, halving repeatedly (Salimans & Ho, 2022). Consistency models learn to map any point on the ODE trajectory straight to its endpoint — one or two steps. Rectified flow / reflow straightens trajectories so few Euler steps suffice. For robot policies, Consistency Policy applies this to diffusion policies.
TensorRT, end to end
TensorRT is an ahead-of-time compiler plus a runtime. You hand it a graph (almost always ONNX); it spends minutes searching for the fastest implementation of that graph on this exact GPU, and serializes the result as an engine (plan file). At runtime you deserialize and execute. Build is slow and hardware-specific; run is fast.
The objects
| Object | Role |
|---|---|
IBuilder | factory for networks, configs, engines |
INetworkDefinition | the graph; filled by the ONNX parser. Created strongly typed or weakly typed |
IParser (ONNX) | ONNX → network; reports unsupported ops |
IBuilderConfig | precision flags, memory pool limits (workspace), optimization profiles, timing cache, builder optimization level, DLA, sparsity |
IOptimizationProfile | min / opt / max shape for each dynamic input |
buildSerializedNetwork() | runs the optimizer, returns the plan bytes |
IRuntime → ICudaEngine | deserialize the plan into an engine (weights + chosen kernels) |
IExecutionContext | per-inference state: setInputShape, setTensorAddress, enqueueV3(stream) |
What happens during the build
Precision: weakly typed vs strongly typed
Weakly typed (the classic mode): you set flags like FP16, INT8, FP8; TensorRT treats them as permissions and may pick any allowed precision per layer if it's faster. Great for speed, hard to reason about accuracy, and you may need layer precision constraints to pin sensitive layers.
Strongly typed (NetworkDefinitionCreationFlag::kSTRONGLY_TYPED, trtexec --stronglyTyped): tensor types come from the graph — ONNX dtypes and explicit Q/DQ nodes are authoritative. No precision autotuning. This is the modern path for FP8/NVFP4 graphs produced by ModelOpt: what you exported is what runs.
Implicit vs explicit quantization
INT8 flag + a calibrator (IInt8EntropyCalibrator2, MinMax, …) that produces a calibration cache of per-tensor ranges. TensorRT decides where INT8 is used. Deprecated in TensorRT 10 in favour of explicit Q/DQ.QuantizeLinear/DequantizeLinear pairs with scales, produced by PTQ or QAT tooling (ModelOpt, pytorch-quantization). TensorRT fuses Q/DQ into neighbouring ops: it moves DQ nodes forward and Q nodes backward to maximize the low-precision region, then fuses them into the compute kernels.Rule of thumb for Q/DQ placement: quantize the inputs of GEMM/conv (weights and activations), don't sprinkle Q/DQ on elementwise ops, and keep residual adds in higher precision unless the fusion pattern supports it.
Dynamic shapes
Mark dynamic dims as −1 in ONNX, give every dynamic input a min / opt / max in an optimization profile. TensorRT tunes kernels for opt but guarantees correctness across the range. At runtime call setInputShape before enqueue. Wide ranges cost performance; several profiles (e.g. short and long sequences) can help. Data-dependent sequence lengths — padded VLM tokens — are the usual reason a transformer engine needs a dynamic profile, or a fixed padded budget.
Plugins
For ops TensorRT can't express or fuses badly: implement IPluginV3 (TensorRT 10; older code uses IPluginV2DynamicExt) with shape inference, supported formats/types, enqueue with your CUDA kernel, and serialization; register a creator; load the .so at build and run time. The ONNX node's op type must match the plugin name. Plugins are opaque to the optimizer — fusion stops at their boundary, so they're a last resort.
Build knobs that matter
- Workspace / memory pool — scratch memory tactics may use. Too small silently excludes fast tactics.
- Builder optimization level (0–5, default 3) — higher searches more tactics, builds slower.
- Timing cache — persist tactic timings so rebuilds are fast and more reproducible.
- Tactic sources — cuBLAS, cuBLASLt, cuDNN, internal kernels; can be restricted.
- Sparsity — allow 2:4 sparse tactics.
- DLA (Jetson Orin has two DLA cores) — offload supported layers with GPU fallback; each GPU↔DLA transition costs a reformat and sync.
- Hardware / version compatibility — engines are tied to GPU architecture and TensorRT version by default; compatibility modes relax this at some performance cost.
trtexec cheat sheet
Debugging accuracy: Polygraphy
# Compare TensorRT against ONNX Runtime on the same inputs
polygraphy run model.onnx --trt --onnxrt --atol 1e-3 --rtol 1e-3
# Mark every intermediate as an output to find the first diverging layer
polygraphy run model.onnx --trt --onnxrt \
--trt-outputs mark all --onnx-outputs mark all
# Search for which layers must stay in higher precision
polygraphy debug precision model.onnx --fp16 --check ...
# Clean the graph before building
polygraphy surgeon sanitize model.onnx --fold-constants -o clean.onnx
Runtime patterns
engine = runtime.deserialize_cuda_engine(plan_bytes)
ctx = engine.create_execution_context()
for name in input_names:
ctx.set_input_shape(name, shape) # dynamic dims, before enqueue
for name, ptr in buffers.items():
ctx.set_tensor_address(name, ptr) # pre-allocated, persistent
ctx.execute_async_v3(stream) # enqueueV3; capture this in a CUDA graph
- Allocate once, reuse forever; pinned host memory for H2D copies (or none at all on unified-memory Jetsons).
- One execution context per concurrent stream.
- Don't
synchronizeafter every engine in a multi-engine pipeline — chain them on one stream. - Pin the whole stack (JetPack, CUDA, TensorRT, ModelOpt, opset) and record versions next to every benchmark.
Pitfalls that show up in practice
| Symptom | Likely cause |
|---|---|
| Engine slower than expected | workspace too small, opt shape far from real shape, reformat layers between precisions, DLA fallback ping-pong |
| FP16 output NaN/inf | activation overflow above 65,504 — keep that layer FP32 or use BF16 |
| Accuracy fine in ONNX Runtime, wrong in TensorRT | fusion/tactic bug, version regression, precision chosen by weak typing — bisect with Polygraphy |
| Accuracy wrong in both | export problem: wrong output layer, dropped op, constant folding, opset semantics |
| Engine fails to load on another device | engines are GPU-architecture and TensorRT-version specific |
| Benchmarks don't reproduce | stale engines reused, unpinned versions, clocks/power mode not fixed, no warmup |
mtq.quantize(model, cfg, forward_loop)) that inserts quantizers, calibrates them on your data, and exports ONNX with Q/DQ (FP8, INT8, INT4-AWQ, NVFP4) for a strongly typed TensorRT build. Also does sparsity and distillation.The AMD mirror
Every concept above has an AMD counterpart. Know the mapping and most NVIDIA experience transfers.
| NVIDIA | AMD | What changes |
|---|---|---|
| CUDA | HIP (HIPify converts source) | near source-compatible |
| SM, warp (32) | CU, wavefront 64 on CDNA/Instinct; RDNA runs wave32 | divergence cost and occupancy maths change; audit lane-count literals and shuffle masks |
| Shared memory | LDS (Local Data Share) | same role |
| Tensor cores | Matrix cores (MFMA instructions) | same role |
| TensorRT | MIGraphX; ONNX Runtime with MIGraphX EP | ONNX in, compiled program out; tune static shapes |
| cuBLAS(Lt) / cuDNN / CUTLASS | rocBLAS, hipBLASLt / MIOpen / Composable Kernel | library swap |
| FlashAttention, fused kernels | CK FlashAttention, AITER | used by vLLM/SGLang on ROCm |
| ModelOpt PTQ | AMD Quark (FP8, INT8, MXFP4, AWQ/GPTQ) | re-express per-layer overrides as Quark configs |
| CUDA Graphs | HIP Graphs | same capture rules |
| Nsight Systems / Compute | rocprofv3, ROCm Systems Profiler, ROCm Compute Profiler | timeline and kernel-level counters |
| NCCL | RCCL | collectives |
| DLA (Jetson) | XDNA NPU via Ryzen AI software / Vitis AI | fixed-function offload, static shapes, INT8/BF16 friendly |
Drill: cards and quiz
Click a card to flip it. Answer out loud before flipping — the point is fluency, not recognition.