~/satyajit

NVIDIA Model Optimizer: from mtq.quantize to a 4.95-bit DeepSeek-R1

mdjsonmcp

2026-09-26 · 18 min · quantization · inference-optimization · open-source · distillation · speculative-decoding · explainer

This week @Ryrenz posted a short spotlight on NVIDIA's Model-Optimizer: 3,902 stars, one Python interface for quantization, pruning, distillation, sparsity and speculative decoding, and the pre-quantized checkpoints NVIDIA made with it on Hugging Face, such as DeepSeek-R1-FP4 and Llama-3.3-70B-Instruct-FP4. The pitch: when a model almost fits, make it smaller.

That misses the interesting part. ModelOpt is the tool NVIDIA uses to make its own releases, so the library and the checkpoints are two ends of one pipeline. I read both ends:

Every number below carries a label. Measured means I counted it from the source tree or the file headers. Reported means NVIDIA (or the post) says so. Reasoned means arithmetic on those.

NVIDIA/Model-Optimizer@23355ed · snapshot 2026-09-26
tracked files
2,222
license
Apache-2.0
branch
HEAD
tests
521 files
source
15.6 MB
commit date
2026-09-25
source by language
Python14.9 MB(1325)Shell267.0 kB(62)Jupyter Notebook245.3 kB(16)CUDA98.6 kB(8)C++30.3 kB(8)C17.6 kB(5)CSS4.8 kB(2)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

local clone, 2026-09-26 at 23355ed — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount

shallow clone: counts describe the pinned tree, not the history

Commit23355ed, 26 September 2026, Apache 2.0 (measured)
Library608 Python files, 173,439 lines under modelopt/ (measured)
Quantization81 files, 31,783 lines in modelopt/torch/quantization/ (measured)
Export44 files, 20,733 lines in modelopt/torch/export/ (measured)
Recipes37 model-level PTQ presets, 179 recipe YAMLs, 18 per-checkpoint recipes (measured)
Agent skills15 SKILL.md files under plugins/modelopt/skills/ (measured)
Stars3,902 (reported by the post)

What is in the box

The README lists six techniques. Here is where each one lives in the tree and how much code backs it (line counts measured):

TechniqueWhereWhat it does
Post-training quantizationmodelopt/torch/quantization/FP8, NVFP4, MXFP4/6/8, INT8 SmoothQuant, INT4 AWQ, W4A8 and GGML-style IQ formats, all through mtq.quantize
QAT and QADthe same quantizers, plus modelopt/torch/distill/ (2,425 lines)train through the fake quantizers; QAD adds a distillation loss against the full-precision teacher
Pruningmodelopt/torch/prune/ (3,865 lines), mtp.pruneMinitron (activation-magnitude importance over width, experts and depth) for Megatron models, FastNAS for vision
Distillationmodelopt/torch/distill/teacher/student wrapper and losses
Sparsitymodelopt/torch/sparsity/ (9,370 lines)2:4 weight sparsity by magnitude or SparseGPT, plus skip-softmax attention sparsity
Speculative decodingmodelopt/torch/speculative/ (11,807 lines), mtsp.converttrains EAGLE-3, EAGLE-MTP, Medusa and DFlash draft heads

There is also modelopt/onnx/ (32,156 lines) for ONNX PTQ and modelopt/torch/puzzletron/ (19,435 lines) for heterogeneous pruning. The training techniques mostly plug into someone else's loop (Hugging Face Trainer, Megatron-Bridge, Megatron-LM, Accelerate). Quantization and export are ModelOpt's own, 52,516 lines between them (reasoned), so that is where I spent my time. The draft heads are the subject of my EAGLE-3 piece. On pruning, the README reports that Domyn cut Colosseum-355B to 260B with Minitron pruning plus distillation (reported).

mtq.quantize is three moves

The whole post-training path, as the README uses it:

import modelopt.torch.quantization as mtq
from modelopt.torch.export import export_hf_checkpoint
 
def forward_loop(model):
    for batch in calib_loader:   # your calibration set
        model(**batch)
 
model = mtq.quantize(model, mtq.NVFP4_DEFAULT_CFG, forward_loop)
export_hf_checkpoint(model, export_dir="model-nvfp4")

quantize() in modelopt/torch/quantization/model_quant.py does three things in order.

1. Convert. convert_to_quantized_model calls replace_quant_module. That walks named_children() and swaps every module whose type is in QuantModuleRegistry for its quantized twin: nn.Linear becomes _QuantLinear, one of 95 registrations in 23 files covering Hugging Face, Megatron, Transformer Engine, vLLM and PEFT layers (measured). The twin's _setup (in nn/modules/quant_module.py) registers three TensorQuantizer children: input_quantizer, weight_quantizer, and output_quantizer, which starts disabled.

The neat part is the weight. The twin registers weight as a dynamic attribute, so inside forward any read of self.weight returns weight_quantizer(weight). The module's original F.linear call runs unchanged and simply sees quantized weights. When the swap finishes, the code prints Inserted N quantizers.

2. Configure. set_quantizer_by_cfg applies an ordered list of entries to quantizer names with fnmatch, and later entries win. The NVFP4 preset (modelopt_recipes/configs/ptq/presets/model/nvfp4.yaml) is three imports:

  1. disable everything;
  2. enable *weight_quantizer and *input_quantizer with the NVFP4 numerics;
  3. apply default_disabled_quantizers, which switches *lm_head*, *router*, *mlp.gate.*, mtp.*, vision towers, BatchNorm and Embedding back off.

The numerics file is the format, in full:

# modelopt_recipes/configs/numerics/nvfp4.yaml
num_bits: e2m1        # the 4-bit element
block_sizes:
  -1: 16              # one scale per 16 values along the last dim
  type: dynamic
  scale_bits: e4m3    # the block scale is FP8
effective_bits: 4.5   # 4 value bits + 8/16 scale bits

Then _check_weight_quantization_took_effect raises if the config asked for weight quantization and no weight quantizer is enabled. Its docstring says why: MoE models whose module names missed the wildcards used to sail through export as a checkpoint reading "quant_algo": null, with nothing quantized.

3. Calibrate. calibrate() puts the model in eval mode and runs whatever the config's algorithm field names. Both the nvfp4 and fp8 presets say max.

Inside TensorQuantizer.forward, calibration mode calls collect() and passes the tensor through untouched; quantize mode calls _fake_quantize, which rounds and dequantizes back to the input dtype (or _real_quantize, which returns packed storage). Fake quantization is what makes QAT work: the backward is a straight-through estimator (pass_through_bwd defaults to True), so you fine-tune through the rounding with an ordinary optimizer. QAD swaps the label loss for a distillation loss against the BF16 teacher.

Where the scales come from

A quantizer needs one number per scaling group, amax: the magnitude that maps to the format's largest code. Calibration is the search for it. ModelOpt offers several rules, all in model_calib.py and calib/.

Max. MaxCalibrator.collect keeps a running torch.max of the absolute maxima. Before any data flows, max_calibrate calibrates every weight quantizer directly on its weight tensor (weight_only_quantize). That way an MoE expert the calibration tokens never reach still gets a scale. Then forward_loop runs for the activations.

Percentile. HistogramCalibrator builds a 2,048-bin histogram, and compute_amax("percentile") returns the bin edge where the CDF crosses 99.99% by default. It is per-tensor only, and no shipped recipe selects it (measured). Percentiles return in the newer nvfp4_act_headroom algorithm, which sets the NVFP4 activation global amax to max(rho × anchor, upper) from the 1st and 99.99th percentiles of the per-block activation amaxes, to leave headroom for activations calibration never saw.

SmoothQuant. This path collects per-channel activation amax. It then multiplies each input channel by s=max⁡∣W∣1−α/max⁡∣X∣αs = \max|W|^{1-\alpha} / \max|X|^{\alpha} and the matching weight column by 1/s1/s. That moves activation outliers into the weights, where per-channel INT8 absorbs them. The default is α=1.0\alpha = 1.0, and the path is INT8 only.

AWQ lite, the search. Two passes over forward_loop. The first caches, per input channel, the mean ∣x∣|x| (xˉc\bar{x}_c) and the mean block-normalized ∣w∣|w| (wˉc\bar{w}_c). The second tries every α\alpha in 0, 0.1, …, 1.0 (11 candidates at the default alpha_step of 0.1) and builds a per-channel scale for each:

sc=xˉc αwˉc 1−αs_c = \frac{\bar{x}_c^{\,\alpha}}{\bar{w}_c^{\,1-\alpha}}

The scale is clamped to [1e-4, 1e4] and normalized by max⁡s⋅min⁡s\sqrt{\max s \cdot \min s}. The weight is multiplied by ss and the input by 1/s1/s, the quantized layer runs, and the mean squared error against the unquantized output accumulates.

The α\alpha with the least error wins, and the scale is folded into the weights with its inverse kept as the input's pre_quant_scale. awq_clip searches a clip ratio from 0.5 to 1.0 in steps of 0.05 instead, and awq_full does both.

NVFP4's own rules. For NVFP4 the block scale itself can be searched.

Here is what the three rules do to the values that reach the 4-bit cast:

Histogram of scaled weight magnitudes W over s, 0 to 8, with dashed lines at the E2M1 grid values. The Max curve falls smoothly and has a bump between about 5.6 and 6.4; the MSE and Local-Hessian curves cluster on the grid lines.
Scaled weights W/s for the first gate projection of Qwen3.8-27B under three block-scale rules. Max scaling piles a bump around 6, the largest E2M1 value; MSE and local-Hessian pull the mass onto grid points. The bump's spread from about 5.6 to 6.4 is the FP8 rounding of each block's scale (ModelOpt local-Hessian post, Figure 2).

That bump is the max rule doing exactly what its formula says. Each block's largest value is sent to 6. The scale that does the sending is itself rounded to E4M3's 3 mantissa bits, so it can be off by up to about 1/16 either way, and the maximum lands anywhere from about 5.6 to 6.4 (reasoned). Values above 6 round down to 6.

NVIDIA reports what the smarter scales buy on Qwen3.5-9B under W4A4. The average drop against BF16 across MMLU, HellaSwag, WinoGrande and GSM8K is:

Scale ruleAverage drop (points)
max5.10
four-over-six4.75
MSE3.87
local-Hessian3.10
local-Hessian + GPTQ2.94

All reported. The same post measures this on the 27B model it shipped:

Grouped bar chart of Qwen3.8-27B scores for BF16, Max and Local-Hessian NVFP4 on Terminal-Bench 2.1, IFBench, SciCode, MMMU-Pro and GPQA Diamond. An inset table gives the mean accuracy drop from BF16: Max 2.31, Local-Hessian 0.77.
Qwen3.8-27B in NVFP4 with max scales against local-Hessian scales. The mean drop from BF16 falls from 2.31 to 0.77 points, with nothing changed at inference: the scale is computed once, at export. NVIDIA's numbers (ModelOpt local-Hessian post, Figure 1).

NVFP4 in the code: two scales and a nibble

I covered NVFP4 as a format in the Nemotron piece. Here is the same format as ModelOpt computes it, in qtensor/nvfp4_tensor.py. For a weight matrix WW and a block bb of 16 consecutive weights along the input dimension:

g=max⁡i∣Wi∣6×448,sb=E4M3 ⁣(max⁡i∈b∣Wi∣6 g),W^i=E2M1 ⁣(Wisb g)sb gg = \frac{\max_i |W_i|}{6 \times 448}, \qquad s_b = \mathrm{E4M3}\!\left(\frac{\max_{i \in b} |W_i|}{6\,g}\right), \qquad \hat{W}_i = \mathrm{E2M1}\!\left(\frac{W_i}{s_b\, g}\right) s_b\, g

The symbols:

The 448 in gg's denominator is the whole trick. The block that holds the tensor's largest value gets a scale of exactly 448, the top of the E4M3 range. Every other block gets a smaller code (reasoned from the formula). So the FP32 scale absorbs the tensor's overall magnitude, and the 8-bit block scale only has to span the ratio between blocks.

The element cast, _cast_fp4, finds each value's position among the bounds [0.25, 0.75, 1.25, 1.75, 2.5, 3.5, 5] with searchsorted. Ties at 0.75, 1.75 and 3.5 are bumped up, which amounts to round-half-to-even on the grid {0, 0.5, 1, 1.5, 2, 3, 4, 6}. A sign bit makes the code 4 bits. Then (q[..., 1::2] << 4) | q[..., 0::2] packs two codes into each byte.

Here is that arithmetic on 32 weights in two blocks, with an outlier you can grow in block A:

NVFP4 block quantizer · 32 weights, two blocks of 16ModelOpt's arithmetic
block AE4M3 code 448 · s = code × g · max |w|/s 6.00block BE4M3 code 44 · s = code × g · max |w|/s 5.99+7.87−7.87outlierNVFP4: |w| / s before rounding, on the E2M1 grid (values past 6 clip to 6)00.511.52346AB
outlier in block A (drag)6× the block's next-largest value
block scale
overlay
formatbits / weightrel. RMS error, all 32block B only
NVFP4 · max scales4.57.8%10.4%
FP8 E4M3 · one scale81.0%1.4%
INT4 · one scale4 + scale21.4%84.5%

With the outlier at 6 times its block's next-largest value, NVFP4 with max scales has 7.8% relative RMS error, FP8 1.0% and single-scale INT4 21.4%. On the untouched block B the errors are 10.4%, 1.4% and 84.5%. The per-tensor scale is g = 0.00293, which is amax / (6 × 448). Under max scaling each block's largest value lands at 6, give or take the rounding of its FP8 scale code, so block B keeps a fine grid however large the outlier in A grows. INT4 here shares one scale across all 32 values, which is kinder to it than the 128-value blocks of the real recipe.

Grow the outlier and watch block B. Its own FP8 scale code shrinks to keep its largest value near 6, so its error barely moves. INT4 with one scale across all 32 values has no such escape: in the default state it rounds most of block B to zero, 84.5% relative error on that block against 10.4% for NVFP4 (illustrative, seed 1, outlier at 6×). Per-tensor FP8 wins on error at every setting, because every value keeps 3 mantissa bits; it just costs 8 bits a weight instead of 4.5. Switch the block scale to the MSE sweep and the codes move. Some blocks pick a smaller code, clipping their maximum to 6 in exchange for a finer grid under it; others pick a larger one. That is the mechanism behind Figure 1.

On disk, each quantized linear becomes four tensors. The shapes below are for one DeepSeek-R1 expert's gate_proj, a 2048×7168 matrix (measured):

TensorDtypeShapeBits per weight
weightU82048 × 3584, two E2M1 codes per byte4
weight_scaleF8_E4M32048 × 448, one per 16 weights0.5
weight_scale_2F32scalar, the gg above≈ 0
input_scaleF32scalar, the activations' ggnone

The file confirms effective_bits: 4.5: across DeepSeek-R1-FP4's 44,727 quantized linears, the E4M3 scale bytes are exactly one eighth of the packed-weight bytes, 41,103,261,696 against 328,826,093,568 (measured).

Activations get only the scalar input_scale, which is calibrated amax over (6 × 448). The YAML says type: dynamic, and the file has nowhere to keep per-block activation scales, so the kernel must compute them at inference time (reasoned from the layout).

Export: one checkpoint, three runtimes

export_hf_checkpoint calls _export_quantized_weight (in export/unified_export_hf.py) on each quantized module. For NVFP4 it registers weight_scale_2, computes weight_scale against it, packs the weight, and registers input_scale from the input quantizer's amax. Tensor names stay the Hugging Face names, so the checkpoint is the original layout plus scale tensors, and two configs:

That single tag is what the runtimes key on: LLM(model=path, quantization="modelopt") in vLLM, sgl.Engine(model_path=path, quantization="modelopt") in SGLang, and a plain LLM(model=path) in TensorRT-LLM. There is still a legacy export_tensorrt_llm_checkpoint for the TensorRT-LLM C++ backend.

The README is honest about how thin the support matrix's evidence is: its entries come from tests/examples/hf_ptq/test_deploy.py, whose cases "are marked release and run out-of-band — no workflow currently passes --run-release", and each is a load-and-generate smoke test. My vLLM and SGLang reads cover the other side of that tag.

What is in nvidia/DeepSeek-R1-FP4

nvidia/DeepSeek-R1-NVFP4@4bedb8a · snapshot 2026-09-26
announced
FP4, ~1.6x smaller than FP8
measured
396,767,013,632
parameters
396.77B
repo size
423.64 GB
architecture
DeepseekV3ForCausalLM
task
text-generation
license
mit
safetensors
80 shards
largest file
5.37 GB
files
90
downloads
1.5K
likes
283
parameters by dtype
BF1626.84BF3215.1KF8_E4M341.10BU8328.83B

The Hub counts storage elements as parameters, so its figure (396.77B when I fetched it) treats each packed byte of two FP4 weights, and each FP8 block scale, as one. Counted as weights, the file stores 684.49B.

repo last modified 2025-06-06

Summing the tensor sizes in the headers of all 80 shards gives 180,364 tensors and 423,605,060,024 bytes (measured). By dtype:

DtypeBytesShare
U8 (packed E2M1)328,826,093,56877.63%
BF1653,675,286,52812.67%
F8_E4M3 (block scales)41,103,261,6969.70%
F32 (tensor and input scales)418,2320.00%

hf_quant_config.json names modelopt 0.23.0 as producer, NVFP4 with group_size 16, no KV-cache quantization, and 245 exclude_modules (measured):

So every attention projection is BF16. The card's line, "only the weights and activations of the linear operators within transformers blocks are quantized," is looser than the file: attention projections are linear operators inside transformer blocks too.

For the baseline I read the headers of deepseek-ai/DeepSeek-R1's 163 shards the same way: 688,574,839,360 bytes (measured), FP8 with F32 scales per 128×128 block. Its index file's total_size claims 1,369,062,772,000, about double, which is why I sum headers. Both files store the same 684,489,845,504 parameters. The FP8 original is 688.6 GB at 8.05 bits a weight; the FP4 release is 423.6 GB at 4.95 bits a weight, 1.63x smaller (measured). The card's "approximately 1.6x" holds (reported).

where the bytes go · source vs NVFP4 checkpointfrom safetensors headers
model
deepseek-ai/DeepSeek-R1 (FP8)688.57 GB · measuredrouted expertsnvidia/DeepSeek-R1-FP4423.61 GB · measuredrouted experts
tensorsparams (B)source GBNVFP4 GBbits / weight
routed experts653.91654.07367.824.50
attention projections11.4111.4222.8316.00
MTP layer 6113.4615.4226.9316.00
embeddings + lm_head1.853.713.7116.00
shared experts, dense MLPs, router, norms3.853.962.324.82

DeepSeek-R1: 688.57 GB in the source at 8.05 bits a weight, 423.61 GB in the NVFP4 checkpoint at 4.95, a 1.63× shrink. The routed experts go from 8 bits to 4.5 and carry the whole saving. Attention and the MTP layer were FP8 in DeepSeek's release and are BF16 in NVIDIA's, so those two groups grow by about 23 GB.

The ledger explains the missing half of a 2x.

Leave out the MTP layer, which only matters if you serve with multi-token prediction, and the rest averages 4.73 bits (reasoned from the measured counts). The file does not say why attention was excluded rather than kept at FP8.

nvidia/Llama-3.3-70B-Instruct-NVFP4@f792b70 · snapshot 2026-09-26
announced
FP4, ~3.3x smaller than BF16
measured
40,606,376,096
parameters
40.61B
repo size
42.76 GB
architecture
LlamaForCausalLM
license
llama3.3
safetensors
9 shards
largest file
4.99 GB
files
19
downloads
258.8K
likes
50
parameters by dtype
BF162.10BF8_E4M34.28BU834.23B

The Hub's parameter count (40.61B when I fetched it) counts packed bytes and FP8 scales as parameters. Counted as weights, the file stores 70.55B.

repo last modified 2025-08-22

nvidia/Llama-3.3-70B-Instruct-FP4 is the simpler case, with nine shards and 42,709,045,632 bytes (measured):

The claims, marked reported

Accuracy. Both cards give one accuracy table and no speed numbers (all reported):

BenchmarkDeepSeek-R1 FP8DeepSeek-R1 FP4Llama-3.3-70B BF16Llama-3.3-70B FP4
MMLU90.890.783.381.1
GSM8K (Llama: GSM8K_COT)96.396.195.392.6
AIME202480.080.0
GPQA Diamond69.769.2
MATH-50095.494.2
ARC Challenge93.793.3
IFEVAL92.192.0

R1's baseline is DeepSeek's FP8 release, not BF16. Both checkpoints name modelopt 0.23.0, published on PyPI on 28 January 2025, with cnn_dailymail as R1's calibration set; the local-Hessian scales above arrived about 19 months later.

Speed. The repo's examples/benchmark.md gives H200 numbers (modelopt 0.21.1, TensorRT-LLM 0.15, 2,048 tokens in and 128 out, speedup normalized per GPU). For Llama3.1-8B, all reported:

BatchFP8INT4 AWQW4A8 AWQ
11.41x1.33x1.38x
81.31x0.75x1.00x
641.30x0.83x1.15x

The same page reports an MMLU loss of 1.50% for FP8 and 5.66% for INT4 AWQ on the 8B model, and 0.38% and 1.07% on the 70B model.

Weight-only 4-bit helps at batch 1 and hurts once the batch grows: decode is memory-bound, so fewer weight bytes pay at small batches, and at larger ones the dequantization shows. The newest post in the repo finds the same for NVFP4 on Blackwell, on Qwen3.6-35B-A3B in vLLM on 4× GB200 (all reported):

Mixed precision is the other knob. AutoQuantize scores every layer's sensitivity with one backward pass and solves an integer program for the best format assignment under an effective-bits budget:

Two line charts of MMLU against effective bits, 4 to 9, for Qwen3.5-2B and 9B. The search over NVFP4, FP8 and BF16 sits above the NVFP4-plus-BF16 search at every budget; an orange triangle marks the NVFP4 default, above 4.5 bits.
MMLU against effective bits under AutoQuantize for Qwen3.5-2B and 9B. Adding FP8 to the menu beats NVFP4 plus BF16 at every budget. The NVFP4 defaults sit well above 4.5 bits because lm_head stays BF16, the same effect as in the checkpoints above. NVIDIA's measurements (ModelOpt AutoQuantize post, Figure 1).

What I'd take from it

For runtime tricks that go below 4 bits, see runtime dynamic compression. For the KV cache side, see TurboQuant.


Sources: NVIDIA/Model-Optimizer at 23355ed (Apache 2.0), NVIDIA's model cards, and the safetensors headers of nvidia/DeepSeek-R1-FP4, deepseek-ai/DeepSeek-R1 and nvidia/Llama-3.3-70B-Instruct-FP4. Figures are reproduced from the repository's documentation for commentary; the widgets are my illustrations of ModelOpt's arithmetic.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "NVIDIA Model Optimizer: from mtq.quantize to a 4.95-bit DeepSeek-R1", ai.thesatyajit.com, September 2026.

bibtex
@misc{ghana2026nvidiamodeloptimizer,
  author = {Satyajit Ghana},
  title  = {NVIDIA Model Optimizer: from mtq.quantize to a 4.95-bit DeepSeek-R1},
  url    = {https://ai.thesatyajit.com/articles/nvidia-model-optimizer},
  year   = {2026}
}
share