Skip to content

Export unquantized FP8Linear layers in the export dtype, not fp32 - #2651

Merged
shengliangxu merged 3 commits into
NVIDIA:mainfrom
tritsystem:fix/fp8-unpack-export-dtype
Oct 6, 2026
Merged

shengliangxu merged 3 commits into
NVIDIA:mainfrom
tritsystem:fix/fp8-unpack-export-dtype

Conversation

@tritsystem

@tritsystem tritsystem commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Type of change: Bug fix

When an HF FP8 checkpoint is exported with a recipe that leaves some FP8 layers unquantized (for example --qformat nvfp4_mlp_only), those layers are written to the checkpoint as fp32, while the rest of the model is in the export dtype (bf16/fp16).

Mechanism: _dispatch_export_handler() (modelopt/torch/export/unified_export_hf.py) calls _QuantFP8Linear.unpack_weight() on every FP8 linear. unpack_weight() dequantizes to torch.get_default_dtype(), which is fp32 unless the caller changed it. For a module whose format is QUANTIZATION_NONE, _export_quant_linear returns early, and nothing casts the weight back to the export dtype. The DeepSeek example scripts don't show the problem because they call torch.set_default_dtype(torch.bfloat16). examples/hf_ptq/hf_ptq.py doesn't, so the main HF path does show it.

Effects: those tensors take 2× the bytes, their dtype doesn't match config.torch_dtype, and the in-memory model can't run forward after export (expected mat1 and mat2 to have the same dtype ... BFloat16 != float).

Fix: after unpack_weight(), cast the weight to ctx.dtype only when the module's quantization format is QUANTIZATION_NONE. Quantized layers are deliberately untouched. My first attempt passed the export dtype into unpack_weight() itself, but that changed the NVFP4 packed bytes (12 of 18 MLP weight/scale tensors differed), because the per-block scales were then computed from a bf16 dequant instead of fp32. The version here keeps the fp32 dequant for quantized layers, and their output is byte-identical to main.

Usage

No API change.

# FP8 HF checkpoint, attention left unquantized by the recipe
model = mtq.quantize(model, mtq.NVFP4_MLP_ONLY_CFG, forward_loop)
export_hf_checkpoint(model, export_dir=out)
# before: self_attn.*_proj.weight saved as F32; after: BF16 (matches config.torch_dtype)

Testing

All of the following ran on an RTX 5060 (sm_120), Windows, torch 2.11+cu128, transformers 4.57.1, base 6a10687.

  • End-to-end repro via the public API: I built a tiny LlamaForCausalLM (bf16) and replaced every projection with transformers FP8Linear (block 128×128). I quantized MLP only and exported with export_hf_checkpoint, then read the safetensors dtypes.
    • main: self_attn.{q,k,v,o}_proj.weight = F32, layernorms = BF16, MLP = U8 (NVFP4) / I8 (INT8 weight-only variant).
    • This PR: self_attn.* = BF16, everything else unchanged.
  • Quantized path unchanged: the 18 NVFP4 MLP tensors (weights + scales, 2 layers) are byte-identical between main and this PR (torch.equal on the uint8 views, 0 differing).
  • New test tests/gpu/torch/export/test_export_fp8_linear.py, parametrized over bf16 and fp16. It checks that the unquantized FP8 layer's exported weight has the export dtype and equals the fp32 dequant cast to that dtype exactly (torch.equal).
    • Fails on main with assert torch.float32 == torch.float16.
    • Passes with this PR (2/2).
    • It lives under tests/gpu/ because unpack_weight() runs under torch.cuda.device.
  • Existing suites: I ran tests/unit/torch/export/test_unified_export_hf.py, test_quant_aware_conversion.py and tests/unit/torch/quantization/plugins/test_huggingface.py. Both main and this PR give 70 passed, 50 skipped, and 4 failed. The same 4 fail on main without this change (test_autoquantize_huggingface[*-gradient], test_quantized_transformers_save_restore[*]), so I believe they come from my Windows environment rather than this change.
  • ruff check / ruff format --check are clean on the new test. ruff check on unified_export_hf.py reports two PLR0917 hits, but they're on untouched functions and also show up on main with my local ruff version.

Not tested: a real large FP8 checkpoint (e.g. Qwen3-FP8) through hf_ptq.py, the streaming/offload export path, or Linux CI. The repro uses the same FP8Linear class and the same export_hf_checkpoint entry point, just at toy size.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ (unquantized FP8 layers change from fp32 to the export dtype; quantized layers are unchanged)
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: ❌ (small fix; happy to add an entry if wanted)
  • Did you get Claude approval on this PR?: N/A (external contributor, can't trigger /claude review)

Additional Information

Related: #1962 introduced the shared _dequantize_weight() helper and kept torch.get_default_dtype() in unpack_weight(). This PR leaves that default as is and only fixes the export call site.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved export handling for FP8 linear layers: quantized layers retain packed weights, while unquantized weights are converted to the requested export dtype.
  • Tests
    • Added GPU coverage for exporting mixed quantized and unquantized FP8 layers in bfloat16 and float16.

_dispatch_export_handler() unpacks every HF FP8Linear via
unpack_weight(), which dequantizes to torch.get_default_dtype() (fp32).
For a layer the recipe leaves unquantized (QUANTIZATION_NONE, e.g.
nvfp4_mlp_only on an FP8 checkpoint), the export handler returns early,
so the fp32 weight is written as-is into an otherwise bf16/fp16
checkpoint.

Cast those layers to the export dtype after unpacking. Quantized layers
are unchanged: they still dequantize in fp32 before scale computation,
and their packed output is byte-identical to before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: tritsystem <gbranaa4@gmail.com>
@tritsystem
tritsystem requested review from a team as code owners October 3, 2026 07:39
@tritsystem
tritsystem requested a review from Edwardf0t1 October 3, 2026 07:39
@copy-pr-bot

copy-pr-bot Bot commented Oct 3, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: NVIDIA/Model-Optimizer/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Enterprise
  • Run ID: e4ef68cd-4516-4a6d-ae82-3d6167b68da9
📥 Commits

Reviewing files that changed from the base of the PR and between 08700d1 and 10fffda.

📒 Files selected for processing (1)
  • modelopt/torch/export/unified_export_hf.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.


📝 Walkthrough

Walkthrough

The export handler now casts unpacked, unquantized FP8 weights to the requested export dtype. CUDA tests cover models with NVFP4-quantized MLP weights and FP8 attention weights.

Changes

FP8 export weight handling

Layer / File(s) Summary
Export weight handling and validation
modelopt/torch/export/unified_export_hf.py, tests/gpu/torch/export/test_export_fp8_linear.py
The handler separates packed-weight handling from low-element-size FP8 weight handling. It casts unpacked FP8 weights to the requested dtype when the quantization format is QUANTIZATION_NONE. Parameterized CUDA tests check packed MLP weights and attention weights converted to bfloat16 or float16.

Priority: ➖ Normal

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 10fff

Unquantized FP8 weights are converted to the requested export dtype before the exporter collects its state, while the quantized path remains unchanged. No actionable merge risk is evident, though the reported CUDA tests were not independently run here.

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: unquantized FP8Linear weights are exported in the requested dtype instead of fp32.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns ✅ Passed No listed security anti-pattern was introduced. The authoritative diff changes only modelopt/torch/export/unified_export_hf.py and adds a test file. The production-code additions only unpack and dty…
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@shengliangxu shengliangxu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks for working on the fix!

@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 78.44%. Comparing base (9f902ae) to head (10fffda).

Files with missing lines Patch % Lines
modelopt/torch/export/unified_export_hf.py 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2651      +/-   ##
==========================================
+ Coverage   69.19%   78.44%   +9.25%     
==========================================
  Files         620      618       -2     
  Lines       69588    69588              
==========================================
+ Hits        48150    54588    +6438     
+ Misses      21438    15000    -6438     
Flag Coverage Δ
examples-diffusers 21.05% <40.00%> (-0.01%) ⬇️
examples-gpt-oss 13.42% <0.00%> (-0.03%) ⬇️
examples-hf_ptq 22.86% <40.00%> (-0.04%) ⬇️
examples-llm_distill 13.48% <0.00%> (-0.02%) ⬇️
examples-llm_eval 17.39% <40.00%> (+<0.01%) ⬆️
examples-llm_qat 17.55% <40.00%> (+<0.01%) ⬆️
examples-llm_sparsity 15.82% <0.00%> (-0.02%) ⬇️
examples-megatron_bridge 26.77% <0.00%> (-0.15%) ⬇️
examples-specdec_bench 13.19% <0.00%> (-0.02%) ⬇️
examples-speculative_decoding 17.73% <40.00%> (-0.07%) ⬇️
examples-torch_onnx 21.60% <0.00%> (-0.01%) ⬇️
examples-torch_trt 15.21% <0.00%> (+<0.01%) ⬆️
examples-vllm_serve 13.66% <0.00%> (-0.02%) ⬇️
gpu 58.43% <80.00%> (+36.82%) ⬆️
regression 15.07% <0.00%> (-0.02%) ⬇️
unit 58.60% <40.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@shengliangxu

Copy link
Copy Markdown
Collaborator

/ok to test 10fffda

@shengliangxu
shengliangxu merged commit 33e3117 into NVIDIA:main Oct 6, 2026
55 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants