Adithya Balaji

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.

Bit explorersign · exponent · mantissa, rounded to nearest
signexponent = rangemantissa = precision

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.

Chapter 1

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.

FormatSign·Exp·ManLargestSmallest normalWhere it lives
FP321·8·233.4e381.2e−38Reference, optimizer master weights, accumulators
TF321·8·103.4e381.2e−38NVIDIA tensor-core math mode; stored as FP32
FP161·5·1065,5046.1e−5GPU inference default; overflow risk in LLM activations
BF161·8·73.4e381.2e−38Training default — FP32 range, coarse precision, never overflows
FP8 E4M31·4·34482−6Inference weights + activations (Hopper, Blackwell, MI300, Thor)
FP8 E5M21·5·257,3442−14Gradients — range matters more than precision
FP4 E2M11·2·161Only ±{0, 0.5, 1, 1.5, 2, 3, 4, 6}. Useless without block scales
INT8two's complement127·ss (the scale)Integer GEMMs; needs an external scale
INT4two's complement7·ssWeight-only LLM/VLA compression
FP16 vs BF16, the one-linerFP16 has more precision and less range; BF16 has less precision and FP32's range. Training blows up on FP16 without loss scaling because small gradients underflow and large activations overflow. BF16 just rounds more coarsely — which SGD tolerates.

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:

FormatElementBlockShared scaleHardware
MXFP8 / MXFP6 / MXFP4FP8 / FP6 / FP432E8M0 — a pure power of twoOCP standard; AMD MI350 (CDNA4), Blackwell
NVFP4FP4 E2M116FP8 E4M3 per block + FP32 per tensorNVIDIA Blackwell
GGUF K-quants2–6 bit intssub-blocks in 256 super-blocksquantized scales and minsllama.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.

Chapter 2

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):

q = clamp( round( x / s ) + z , qmin, qmax )
x̂ = s · ( q − z ) quantize, then dequantize. x̂ is what the network "sees". The gap x − x̂ is quantization error.

Symmetric vs asymmetric

Symmetric:   s = max|x| / (2b−1 − 1),   z = 0    (INT8: s = absmax / 127)
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).

Quantization playground4,000 samples · blue lines = representable levels · red = clipped

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:

yi ≈ sw,i · sx · Σk qw,ik · ( qx,k − zx )  + bi The sum runs entirely in integers: INT8 × INT8 products accumulated in INT32. The float scales are applied once per output, after the sum.

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

True low-precision compute (W8A8, FP8)Both operands are low precision; the tensor/matrix cores do the math in INT8 or FP8. Gains compute throughput and memory. Needs activation scales → needs calibration.
Weight-only (W4A16, W8A16 on GPU)Weights stored in 4/8 bits, dequantized inside the kernel to FP16, math in FP16. Gains memory and bandwidth only. No activation calibration. Wins big on memory-bound decode.
Field note: W8A16 on an integer NPUOn Hexagon, "W8A16" means INT16 fixed-point activations, not FP16. HTP is an integer machine; INT16 gives 65,536 levels, enough to survive transformer outliers. A ViT that falls apart at W8A8 usually comes back to near-reference quality at W8A16. That is the outlier problem, measured.
Chapter 3

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.

GranularityOne scale per…Typical useCost
Per-tensorwhole tensorstatic INT8/FP8 activations1 float
Per-channel (per-axis)output channel of a weightINT8 weights — the standardfree in GEMM (factors out)
Per-group / block32–128 consecutive weightsINT4 weight-only (GPTQ, AWQ, GGUF), MX, NVFP4~0.25–0.5 extra bits/weight
Per-tokeneach row of an activationdynamic activation quant for LLMscomputed at runtime
Granularity lab8 output channels × 64 inputs · INT4 symmetric

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.

Chapter 4

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

MethodRuleStrengthWeakness
Absmax / min-maxrange = observed extremes (running max or EMA)no clipping, trivialone outlier ruins the grid
Percentilerange = 99.9 / 99.99th percentile of |x|robust, cheappercentile is a hyperparameter; clips real signal if tails matter
MSE-optimalsearch clip value α minimizing ‖x − x̂(α)‖²directly minimizes errortreats all errors equally — not task loss
Entropy / KLpick threshold minimizing KL(P ‖ Q) between original and quantized histogramsTensorRT's classic INT8 default; good for CNNsslow; histogram resolution matters
ACIQ-style analyticfit Gaussian/Laplace, compute optimal clip in closed formno searchwrong 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.
Field note: calibrating diffusion and flow headsA flow-matching or diffusion head runs the same network at different noise levels, and activation statistics shift strongly with the timestep. If you calibrate by running the policy's own sampling loop, you only ever see the handful of timesteps that loop visits (four, for a four-step sampler), however many trajectories you feed. More samples can't fix a coverage gap on a different axis. Sample timesteps across the whole schedule instead, as Q-Diffusion and PTQ4DM do for image diffusion.
Chapter 5

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.

W̃ = s · clamp( ⌊W/s⌋ + h(V), qmin, qmax ),    h(V) = clip( σ(V)(ζ − γ) + γ, 0, 1 )
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.
AdaRound, brute-forced1 neuron · 8 weights · 3-bit · all 256 floor/ceil choices tried

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.

H = 2 X Xᵀ  (+ λI damping)    — from calibration inputs X
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.
GPTQ, step by step1 row · 6 weights · 3-bit · correlated inputs

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:

Q(W · diag(s)) · ( diag(s)−1 · X ),    s = sXα,   α ∈ [0,1] grid-searched per layer Scaling a channel up gives it relatively more quantization levels; the inverse scale is folded into the previous op. Weight-only, no backprop, generalizes well because it doesn't overfit the calibration set.

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:

Y = X W = ( X diag(s)−1 ) · ( diag(s) W )
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.
SmoothQuant migration8 input channels · bars = per-channel max magnitude

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:

X W = ( X Q ) ( Qᵀ W ),    Q Qᵀ = I A random Hadamard rotation spreads outlier energy evenly (incoherence processing), so both activations and weights become near-Gaussian and easy to quantize. Rotations fold into weights offline; some need a fast online Hadamard transform.

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

MethodQuantizesKey trickWhen you'd reach for it
ZeroQuantW8A8group-wise weights, token-wise activations, layer-wise distillationearly LLM W8A8
OmniQuantW4A16 → W4A4learnable weight clipping + learnable equivalent (smoothing) transformwhen SmoothQuant's closed-form α isn't enough
SqueezeLLM3–4 bit Wnon-uniform (k-means) levels weighted by sensitivity + sparse outliersmemory-bound serving
SpQR3–4 bit Wsmall groups + outlier weights kept sparse in high precisionnear-lossless small models
AQLM / QuIP#2 bit Wvector (codebook) quantization / lattice codebooks with rotationsextreme compression
HQQ2–8 bit Wcalibration-free; half-quadratic solver for scale/zerono calibration data available
NF4 (QLoRA)4 bit Wlevels at quantiles of N(0,1); double-quantized scalesfine-tuning a frozen 4-bit base
FP8 PTQ (ModelOpt, Quark)W8A8 FP8per-tensor scales usually enough thanks to exponent bitsfirst thing to try on FP8 hardware
Q-Diffusion / PTQ4DMdiffusion modelstimestep-aware calibration; split quantization of skip connectionsdiffusion and flow-matching heads
KV-cache quantizationKV in FP8/INT8/INT4per-head / per-token scales; KIVI quantizes keys per-channel, values per-tokenlong context, many concurrent sequences
Chapter 6

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.

forward:   x̂ = s · clamp( round(x/s), qmin, qmax )
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.

QAT vs QLoRA, often confusedQAT trains a model to be robust to its own quantization. QLoRA freezes an NF4-quantized base and trains FP16 LoRA adapters on top — it's a memory-saving fine-tuning method, not a deployment quantization method.
Chapter 7

Choosing a recipe

The profile follows from two questions: what bounds this workload, and what the hardware executes natively.

SituationStart withWhy
Memory-bound: batch-1 LLM/VLA decode, big backboneW4A16 / W8A16 weight-only (AWQ, GPTQ)bytes moved is the cost; math stays FP16
Compute-bound: ViT prefill, conv backbones, batched GEMMsFP8 W8A8 if supported, else INT8 W8A8 (+SmoothQuant)tensor-core throughput doubles
Integer NPU (Hexagon, XDNA)W8A8 for CNNs; W8A16 for transformersINT16 activations survive outliers
Diffusion / flow-matching headFP8 or BF16; timestep-aware calibration; fewer steps firststep count dominates latency
Extreme memory limit4-bit groups + sensitive layers kept highmixed precision per layer

The workflow that doesn't lie to you

  1. Fix the reference first. Compare exported graph vs framework at FP32/FP16 before quantizing anything. If export isn't faithful, quantization results are meaningless.
  2. Profile the bound. Memory or compute, per component.
  3. Sensitivity analysis. Quantize one layer (or block) at a time, measure the drop. Rank layers.
  4. Keep the usual suspects high: first and last layers, LayerNorm/RMSNorm, softmax, attention score matmuls, anything feeding a residual stream with outliers.
  5. Calibrate on representative, held-out data covering every conditioning axis.
  6. Gate on the task metric — closed-loop success for a policy, not just cosine or MSE on actions.
Classic trapOffline action MSE can look perfect while closed-loop success drops, because small errors compound over a rollout and push the robot into states the model never saw. Open-loop metrics are a filter, not a verdict.
Chapter 8

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.

arithmetic intensity   AI = FLOPs / bytes moved
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.

Roofline calculatorlog–log · illustrative hardware presets
Custom GEMM

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.

Field note: falling off a bandwidth tierOn NPUs with a small on-chip memory, a transformer can read several times its own weight size from DRAM per inference once activations spill. The tell is a latency cliff from batch 1 to batch 2: the kernel drops from the on-chip bandwidth tier to the DRAM tier. Same roofline, two ceilings.
Chapter 9

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.

Fusion ledgerGEMM → +bias → GELU → +residual, FP16 activations

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:

for each K/V tile:   mnew = max(m, rowmax(Stile))
   ℓ ← 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.

Chapter 10

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.

Launch timelinetop: eager · bottom: graph replay · 20 simulated runs

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.
Field note: graphs around a denoising loopA multi-step denoising loop (action encoder → DiT → decoder → Euler step, repeated) captures cleanly with the rules above: bind addresses outside capture, enqueue inside, re-capture when the sequence length changes, keep an eager fallback. Expect a modest gain when the DiT engines themselves dominate, which is exactly what the widget shows when kernels are long.
Chapter 11

Pruning and sparsity

Remove weights (or whole structures) that contribute little. The catch: zeros only save time if hardware can skip them.

TypeWhat's removedSpeedup on real hardware
Unstructuredindividual weights anywherelittle on GPUs below ~90% sparsity; saves storage with sparse formats
Semi-structured 2:42 of every 4 contiguous weightssparse tensor cores: up to 2× math throughput, less end to end
Structuredchannels, heads, layers, blocksalways — the model simply gets smaller and dense
Token pruning / mergingactivations (sequence length)attention cost falls quadratically
2:4 versus unstructured4 × 16 weights · underline marks each group of 4

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.

Reporting a pruning resultLead with what was pruned (heads, channels, tokens), how it was scored, and what it bought on the target device. The method name comes last.
Chapter 12

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.

L = α · T² · KL( softmax(zt/T) ‖ softmax(zs/T) )  +  (1 − α) · CE( y, softmax(zs) ) Temperature T > 1 softens both distributions to expose secondary probabilities. T² keeps gradient magnitudes comparable as T changes (Hinton et al., 2015).
Temperatureteacher logits for 6 candidate grasps

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.
Field note: latency of generative action headsWhen the action head is the bottleneck (N denoising steps × a deep DiT), no kernel beats cutting steps. Step distillation or consistency training on the head is the upstream fix; quantization and graphs are downstream. A shallower, distilled head is a cheap second lever.
Chapter 13

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

ObjectRole
IBuilderfactory for networks, configs, engines
INetworkDefinitionthe graph; filled by the ONNX parser. Created strongly typed or weakly typed
IParser (ONNX)ONNX → network; reports unsupported ops
IBuilderConfigprecision flags, memory pool limits (workspace), optimization profiles, timing cache, builder optimization level, DLA, sparsity
IOptimizationProfilemin / opt / max shape for each dynamic input
buildSerializedNetwork()runs the optimizer, returns the plan bytes
IRuntime → ICudaEnginedeserialize the plan into an engine (weights + chosen kernels)
IExecutionContextper-inference state: setInputShape, setTensorAddress, enqueueV3(stream)

What happens during the build

Build pipelineclick a stage

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

Implicit (legacy INT8)FP graph + 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.
Explicit (Q/DQ)The ONNX graph contains 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
Field note: a TensorRT-only accuracy regressionSymptom: action error many times PyTorch's at every precision, so not quantization. Ablate by component (swap one engine at a time into the pipeline) to find the one carrying the error. Then ablate by runtime: if ONNX Runtime matches PyTorch on that graph and TensorRT does not, the export is faithful and the compiler isn't. Reproduce on the vendor's unmodified reference, then fix the most upstream cause, which can be as boring as pinning a different TensorRT release. Method: isolate by component, isolate by runtime, reproduce on a reference, then fix upstream. Polygraphy's layer-wise compare shortens the second step.

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 synchronize after 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

SymptomLikely cause
Engine slower than expectedworkspace too small, opt shape far from real shape, reformat layers between precisions, DLA fallback ping-pong
FP16 output NaN/infactivation overflow above 65,504 — keep that layer FP32 or use BF16
Accuracy fine in ONNX Runtime, wrong in TensorRTfusion/tactic bug, version regression, precision chosen by weak typing — bisect with Polygraphy
Accuracy wrong in bothexport problem: wrong output layer, dropped op, constant folding, opset semantics
Engine fails to load on another deviceengines are GPU-architecture and TensorRT-version specific
Benchmarks don't reproducestale engines reused, unpinned versions, clocks/power mode not fixed, no warmup
ModelOpt in one lineNVIDIA TensorRT Model Optimizer: PyTorch-side PTQ/QAT (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.
Chapter 14

The AMD mirror

Every concept above has an AMD counterpart. Know the mapping and most NVIDIA experience transfers.

NVIDIAAMDWhat changes
CUDAHIP (HIPify converts source)near source-compatible
SM, warp (32)CU, wavefront 64 on CDNA/Instinct; RDNA runs wave32divergence cost and occupancy maths change; audit lane-count literals and shuffle masks
Shared memoryLDS (Local Data Share)same role
Tensor coresMatrix cores (MFMA instructions)same role
TensorRTMIGraphX; ONNX Runtime with MIGraphX EPONNX in, compiled program out; tune static shapes
cuBLAS(Lt) / cuDNN / CUTLASSrocBLAS, hipBLASLt / MIOpen / Composable Kernellibrary swap
FlashAttention, fused kernelsCK FlashAttention, AITERused by vLLM/SGLang on ROCm
ModelOpt PTQAMD Quark (FP8, INT8, MXFP4, AWQ/GPTQ)re-express per-layer overrides as Quark configs
CUDA GraphsHIP Graphssame capture rules
Nsight Systems / Computerocprofv3, ROCm Systems Profiler, ROCm Compute Profilertimeline and kernel-level counters
NCCLRCCLcollectives
DLA (Jetson)XDNA NPU via Ryzen AI software / Vitis AIfixed-function offload, static shapes, INT8/BF16 friendly
The concepts carry over directly — HIP is close to CUDA at the source level, HIP Graphs follow the same capture rules, Quark covers the same PTQ recipes as ModelOpt. The thing to relearn carefully is wavefront 64 on Instinct: it changes divergence cost and occupancy, so kernels tuned for warp 32 don't port optimally as-is.
Chapter 15

Drill: cards and quiz

Click a card to flip it. Answer out loud before flipping — the point is fluency, not recognition.

Quiz