Skip to content

[NVBug: 5987078] Fix unified HF export of compressed NVFP4 weights (--low_memory_mode) - #2038

Open
cjluo-nv wants to merge 1 commit into
mainfrom
chenjiel/fix-lowmem-nvfp4-export
Open

[NVBug: 5987078] Fix unified HF export of compressed NVFP4 weights (--low_memory_mode)#2038
cjluo-nv wants to merge 1 commit into
mainfrom
chenjiel/fix-lowmem-nvfp4-export

Conversation

@cjluo-nv

@cjluo-nv cjluo-nv commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Type of change: Bug fix

Fixes unified HF export of already-compressed NVFP4 weights — mtq.compress and, through it, examples/llm_ptq/hf_ptq.py --low_memory_mode (NVBug 5987078). Two defects, one root cause: the NVFP4 export branch has no handling for weights that were already real-quantized, unlike the FP8_PB_REAL branch which consumes weight_quantizer._scale.

1. weight_scale was recomputed from packed data. After compression the weight is a QTensorWrapper of packed NVFP4 nibbles, and QTensorWrapper.__new__ builds the Parameter from _quantized_data, so .shape reports the packed shape (the logical shape survives only in metadata["shape"]). The export derived the block count from weight.shape[-1] and took amax over nibble-pair bytes, so it wrote a scale of half the required size with meaningless values:

weight weight_scale written expected
TinyLlama-1.1B q_proj [2048, 1024] U8 [2048, 64] [2048, 128]
DeepSeek-R1-Distill-Llama-70B q_proj [8192, 4096] U8 [8192, 256] [8192, 512]

2. An internal quantizer buffer leaked into the checkpoint. postprocess_state_dict strips weight_quantizer.<name> for every name in RealQuantLinear.list_of_scale_tensors, but that list carried "double_scale" where the buffer is _double_scale — a missing underscore. So _scale was stripped and _double_scale was not, and it reached the checkpoint as *.weight_quantizer._double_scale (560 entries in the 70B checkpoint). Downstream loaders reject it before loading any weight:

KeyError: 'layers.0.mlp.down_proj.weight_quantizer._double_scale'
RuntimeError: Engine core initialization failed.

This is why only the TensorRT backend appeared usable in the bug report — its converter tolerates the stray key, then produces !!!!!! output from the broken scales, while the PyTorch backend (vLLM / TensorRT-LLM) fails to load outright.

The fix reuses the per-block scale captured at compression time, rescaled into the exported weight_scale_2 convention, and corrects the typo above. The rescale matters: compression normalizes per-block FP8 scales against the global scale it captured at that moment, which is not the post-calibration weight_scale_2 the export writes. Exporting the stored scale as-is loads fine but leaves every block off by a constant factor (~1.96x measured), so the weight_scale * weight_scale_2 product that dequantization consumes must be preserved.

Note the typo fix also affects modelopt/torch/quantization/plugins/megatron.py:505,511, which filter on the same list — these are internal buffers so excluding them looks correct, but calling it out since it is a behavior change outside the export path.

Compression-time scale layout. TensorQuantizer._real_quantize calls NVFP4QTensor.quantize(..., try_tensorrt=True), so on an FP4-capable device with TensorRT-LLM importable the stored _scale is the cutlass-swizzled 1-D uint8 scale rather than the modelopt 2-D E4M3 layout. Confirmed on GB10 in a TRT-LLM container:

logical weight (512, 256)  -> modelopt scale should be (512, 16) e4m3
_scale : (8192,) torch.uint8  (ndim=1)     <- cutlass-swizzled
after cutlass_fp4_scale_to_modelopt_fp4_scale: (512, 16) torch.float8_e4m3fn

The export therefore normalizes it the same way NVFP4QTensor.dequantize does, and raises if tensorrt_llm cannot be imported to convert, rather than writing raw byte values.

Usage

No API change. The previously broken path now works:

python hf_ptq.py --pyt_ckpt_path <local_ckpt_dir> --qformat nvfp4 \
  --low_memory_mode --export_path <out>

Testing

All runs on DGX Spark (GB10, sm121, aarch64). Both compression-time scale layouts are covered, since the layout depends on whether TensorRT-LLM is importable in the process:

New tests

  • tests/gpu/torch/export/test_export_weight_gpu.py::test_export_compressed_nvfp4_weight — dense E4M3 path. Asserts the per-block scale covers the logical input dim, that weight_scale * weight_scale_2 matches an uncompressed export of the same model, and that postprocess_state_dict strips both internal buffers.
  • tests/gpu_trtllm/torch/export/test_export_compressed_nvfp4.py::test_export_compressed_nvfp4_weight_trtllm_scale — cutlass-swizzled path. Asserts as a precondition that the environment really produced a 1-D uint8 scale, so it cannot silently degrade into the dense case when TensorRT-LLM is absent.

Results

suite TensorRT-LLM 1.3.0rc17 container vLLM 26.05 container
both files above 3 passed 2 passed, 1 skipped

Negative controls (each test fails without the code it guards)

  • On main with only the test file applied: test_export_compressed_nvfp4_weight fails.
  • With only the un-swizzle conversion neutered, the rest of the fix intact: test_export_compressed_nvfp4_weight_trtllm_scale fails.

End-to-end, TensorRT-LLM container (the environment from the bug report, where _scale is swizzled) — TinyLlama-1.1B, --qformat nvfp4 --low_memory_mode, plus a normal export as control. Exported checkpoints are structurally identical (0 stray _double_scale keys vs. 560 on main; 663 keys each; q_proj.weight_scale [2048, 128] FP8 in both; QKV weight_scale_2 unified in both). Loaded on the TensorRT-LLM PyTorch backend — the backend reported as unusable:

##### trt_normal #####
GEN: France and is the most populous city in the country. It is located on the Seine River...
##### trt_lowmem #####
GEN: France and is the most popular tourist destination in the country. It is a city of art, history...

On main that same load fails with KeyError: '...weight_quantizer._double_scale' before a single weight is read.

End-to-end, vLLM container (dense scale path) — TinyLlama-1.1B, NVFP4 + --low_memory_mode:

  • before: KeyError: '...weight_quantizer._double_scale', engine fails to start
  • after: loads and generates coherently ("Paris is the capital of"" France and is best known for the awe inspiring Notre-Dame C")
  • The fixed checkpoint is structurally equivalent to a normal (non-low-memory) export: identical key set (663 tensors), input_scale identical, and weight_scale_2 identical — including the values unified across fused groups, so the fused-GEMM contract (one shared weight_scale_2 per QKV / gate-up group) still holds.
  • DeepSeek-R1-Distill-Llama-70B (the model in the bug) reproduces the same signature on main and is the source of the numbers in the table above.

Known limitation: fused groups are slightly less accurate than a full-memory PTQ

This restores a working checkpoint, but --low_memory_mode NVFP4 is not numerically identical to a normal PTQ, and cannot be made so at export time.

The nibbles are packed at load time against the layer's own global scale, so the effective per-block scale baked into them is fp8_own * ws2_own. preprocess_linear_fusion later unifies weight_scale_2 across a fused group to the group max, and the format requires the per-block scale be E4M3, so the best the export can write is round_fp8(fp8_own * ws2_own / ws2_unified). For the group member owning the max amax that ratio is exactly 1 and the round trip is bit-exact; for the others it costs one extra E4M3 rounding, bounded by a half-ULP (6.25%). The uncompressed path never pays this because its weights are still high precision at export, so to_quantized_weight re-quantizes the nibbles after unification.

Measured on TinyLlama-1.1B (weight relative error vs. the source BF16 weights, 154 quantized tensors):

mean rel. error
normal PTQ 0.090248
--low_memory_mode (this PR) 0.091443

The degradation is confined to exactly 66 of 154 tensors = 22 layers x 3, i.e. the non-max members of each q/k/v and gate/up group (worst observed +0.0066, e.g. layers.11.self_attn.k_proj 0.0898 -> 0.0964). The 88 remaining tensors — group winners plus the unfused o_proj / down_proj — are bit-exact.

Removing this requires compressing a fusion group against one shared scale in the compress-on-load path (RealQuantParameterDict), where the group max first becomes known; that is a larger change and is left as a follow-up.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅
  • 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?: ✅
  • Did you get Claude approval on this PR?: ❌

Additional Information

NVBug 5987078.

Not addressed here: at 70B scale on DGX Spark, --low_memory_mode can also crash before export when the device map offloads, because QTensorWrapper.to() cannot represent a meta tensor:

accelerate/hooks.py: set_module_tensor_to_device(module, name, "meta")
RuntimeError: Attempted to call `variable.set_data(tensor)`, but `variable` and `tensor` have incompatible tensor type.

It is reproducible on demand under low free GPU memory (crashes at 41.2 GB and 58.5 GB free; succeeds at 88.1 GB) and is easy to hit on Spark's unified memory, where page cache from reading the checkpoint counts against torch.cuda.mem_get_info(). That is an independent defect and will be filed separately.

@cjluo-nv
cjluo-nv requested review from a team as code owners July 31, 2026 17:16
@cjluo-nv
cjluo-nv requested a review from meenchen July 31, 2026 17:16
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The Hugging Face exporter now supports already-compressed NVFP4 weights. It reuses and rescales stored compression scales, preserves dequantization, removes internal quantizer buffers, and adds GPU regression coverage.

Changes

Compressed NVFP4 export

Layer / File(s) Summary
Preserve compressed NVFP4 scales
modelopt/torch/export/unified_export_hf.py, modelopt/torch/quantization/nn/modules/quant_linear.py, CHANGELOG.rst
The exporter detects compressed weights, converts and rescales stored scales for exported weight_scale_2, and tracks the _double_scale buffer name.
Validate compressed export
tests/gpu/torch/export/test_export_weight_gpu.py, tests/gpu_trtllm/torch/export/test_export_compressed_nvfp4.py
GPU tests compare compressed and uncompressed NVFP4 exports, validate TensorRT-LLM scale conversion, check scale equivalence, and verify that internal buffers are absent.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: mxino

Sequence Diagram(s)

sequenceDiagram
  participant QTensorWrapper
  participant unified_export_hf
  participant TensorRTLLM
  participant Checkpoint
  QTensorWrapper->>unified_export_hf: Provide compressed NVFP4 weights and stored scales
  unified_export_hf->>TensorRTLLM: Convert swizzled block scales when required
  unified_export_hf->>unified_export_hf: Rescale block scales for weight_scale_2
  unified_export_hf->>Checkpoint: Write exported weights without internal scale buffers
Loading
🚥 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%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
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 Changed production code only adds scale handling and imports; no unsafe torch/numpy/transformers loading, eval/exec, # nosec, or dependency-manifest changes were found.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the bug fix for unified Hugging Face export of compressed NVFP4 weights in low-memory mode.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch chenjiel/fix-lowmem-nvfp4-export
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chenjiel/fix-lowmem-nvfp4-export

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
modelopt/torch/export/unified_export_hf.py (1)

668-679: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Skip the temporary packed-weight scale calculation.

get_weight_scaling_factor(...) already runs before this code detects QTensorWrapper. For a compressed NVFP4 weight, Lines 716-720 replace that result. Detect the compressed weight before generic scale registration and skip the packed-data reduction on this path. This removes an unnecessary full-weight pass during low-memory export.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelopt/torch/export/unified_export_hf.py` around lines 668 - 679, Move the
QTensorWrapper detection and retrieval of _scale/_double_scale in the export
flow before get_weight_scaling_factor(...) and generic scale registration. For
compressed weights, reuse the quantizer’s stored scales and skip the packed-data
reduction entirely; preserve the existing generic calculation for uncompressed
weights.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@modelopt/torch/export/unified_export_hf.py`:
- Around line 668-679: Move the QTensorWrapper detection and retrieval of
_scale/_double_scale in the export flow before get_weight_scaling_factor(...)
and generic scale registration. For compressed weights, reuse the quantizer’s
stored scales and skip the packed-data reduction entirely; preserve the existing
generic calculation for uncompressed weights.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 5f099001-1f77-4423-a9f0-9d7b51416b4e

📥 Commits

Reviewing files that changed from the base of the PR and between a23390d and c11d9cb.

📒 Files selected for processing (3)
  • CHANGELOG.rst
  • modelopt/torch/export/unified_export_hf.py
  • tests/gpu/torch/export/test_export_weight_gpu.py

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 69.65%. Comparing base (8813b70) to head (2314cb7).
⚠️ Report is 24 commits behind head on main.

Files with missing lines Patch % Lines
modelopt/torch/export/unified_export_hf.py 94.73% 1 Missing ⚠️

❗ There is a different number of reports uploaded between BASE (8813b70) and HEAD (2314cb7). Click for more details.

HEAD has 2 uploads less than BASE
Flag BASE (8813b70) HEAD (2314cb7)
examples 13 11
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2038      +/-   ##
==========================================
- Coverage   75.76%   69.65%   -6.12%     
==========================================
  Files         518      518              
  Lines       58269    58287      +18     
==========================================
- Hits        44148    40597    -3551     
- Misses      14121    17690    +3569     
Flag Coverage Δ
examples 43.23% <55.00%> (-0.20%) ⬇️
gpu 32.84% <95.00%> (-18.11%) ⬇️
regression 15.13% <20.00%> (+0.07%) ⬆️

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.

@cjluo-nv
cjluo-nv force-pushed the chenjiel/fix-lowmem-nvfp4-export branch 2 times, most recently from b96737c to f749100 Compare July 31, 2026 20:30
)

if NVFP4QTensor._is_static_quantizer(weight_quantizer):
if (

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

for readability: should we name this branch condition as real_nvfp4_quantized or something similar?

@cjluo-nv cjluo-nv changed the title Fix unified HF export of compressed NVFP4 weights (--low_memory_mode) Fix unified HF export of compressed NVFP4 weights (--low_memory_mode) [NVBug 5987078] Jul 31, 2026
@cjluo-nv cjluo-nv changed the title Fix unified HF export of compressed NVFP4 weights (--low_memory_mode) [NVBug 5987078] [NVBug: 5987078] Fix unified HF export of compressed NVFP4 weights (--low_memory_mode) Jul 31, 2026

@meenchen meenchen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bot review (bedrock-claude-opus-5) — DM the bot to share feedback.

The rescale approach is correct for the environment the author tested (no TensorRT-LLM in the process), and the new GPU test is a meaningful regression test. Three findings:

  1. weight_quantizer._scale is not always the modelopt per-block E4M3 layout. TensorQuantizer._real_quantize() calls NVFP4QTensor.quantize(..., try_tensorrt=True); when fp4_compatible() (SM100+), block_size == 16, and tensorrt_llm is importable, it returns the cutlass-swizzled 1-D uint8 scale factor instead (see NVFP4QTensor.dequantize, which explicitly detects scale.dtype == torch.uint8 and scale.ndim == 1 and converts via cutlass_fp4_scale_to_modelopt_fp4_scale). The new export branch has no such guard, so on Blackwell inside a TRT-LLM container --low_memory_mode NVFP4 export would write a 1-D scale built from raw uint8 byte values — the same class of bug this PR fixes, just in a different environment. The DGX Spark / vLLM validation wouldn't hit it, and the new test lives in tests/gpu (not tests/gpu_trtllm), so CI won't either. Please either convert or raise loudly.

  2. Root cause of the _double_scale leak looks like a typo in RealQuantLinear.list_of_scale_tensors ("double_scale" instead of "_double_scale"). postprocess_state_dict already strips weight_quantizer._scale via that list and would strip _double_scale too if the name matched. Worth confirming whether the one-word fix there is the better place (it also covers any other consumer of that list) rather than adding a second, export-path-only deletion mechanism.

  3. Minor: the new .to(torch.float8_e4m3fn) skips the clamp that _cast_per_block_scale_to_fp8 applies on every other NVFP4 path.

No licensing or design concerns; size and CHANGELOG are fine.

# ``preprocess_linear_fusion`` unifies ``weight_scale_2`` over a fused group the ratio
# below is 1 only for the member owning the group max; the others take one extra E4M3
# rounding (<= half-ULP, 6.25%). Avoiding that needs a shared scale at compress time.
weight_scale = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bot comment.

_scale isn't guaranteed to be the modelopt per-block E4M3 tensor. TensorQuantizer._real_quantize() calls NVFP4QTensor.quantize(..., try_tensorrt=True), and when fp4_compatible() (device capability >= (10, 0)), block_size == 16, the weight is CUDA fp16/bf16, and tensorrt_llm imports, it returns the cutlass-format scale — a 1-D uint8 tensor in the 128x4 swizzled layout — and stores that in _scale. NVFP4QTensor.dequantize handles this case explicitly:

if kwarg["scale"].dtype == torch.uint8 and kwarg["scale"].ndim == 1:
    kwarg["scale"] = cutlass_fp4_scale_to_modelopt_fp4_scale(kwarg["scale"], self.metadata["shape"][-2:])

Here compressed_weight_scale.float() on that tensor yields raw byte values and a 1-D shape, so the exported weight_scale would again be wrong (wrong shape and meaningless values) — on Blackwell + TRT-LLM, which is a primary target for hf_ptq.py. The DGX Spark/vLLM validation and the new tests/gpu test both avoid this branch (no tensorrt_llm in-process), so neither catches it.

Suggest either converting via cutlass_fp4_scale_to_modelopt_fp4_scale before the rescale, or at minimum asserting compressed_weight_scale.dtype == torch.float8_e4m3fn and compressed_weight_scale.dim() == weight_scale_2.dim() + ... and raising a clear NotImplementedError, so the failure is loud rather than a silently malformed checkpoint.

compressed_weight_scale.float()
* compressed_weight_scale_2.float().to(compressed_weight_scale.device)
/ weight_scale_2.float().to(compressed_weight_scale.device)
).to(torch.float8_e4m3fn)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bot comment.

Every other NVFP4 path funnels the fp8 cast through _cast_per_block_scale_to_fp8 (in modelopt/torch/quantization/qtensor/nvfp4_tensor.py), which clamps to [2**-9, E4M3_MAX] specifically to avoid underflow→0 / overflow→NaN. Since weight_scale_2 here is the (possibly group-unified, i.e. >=) scale, the ratio is <= 1 and small block scales can underflow to 0 after the cast — a zero per-block scale silently zeroes that block at dequant. Reusing the existing helper (or at least clamping min=2**-9) would keep this consistent with the uncompressed path.


# Internal to the quantizer: left registered they leak into the checkpoint as
# ``*.weight_quantizer._double_scale``, which downstream loaders reject with a KeyError.
if is_compressed_weight:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bot comment.

postprocess_state_dict already has a mechanism for exactly this:

if any(key.endswith("weight_quantizer." + q_key) for q_key in RealQuantLinear.list_of_scale_tensors)

and RealQuantLinear.list_of_scale_tensors = ["_scale", "double_scale", "_scale_zeros"] — note "double_scale" is missing its leading underscore, which is precisely why _scale never leaked but _double_scale did (matching the 560 stray *.weight_quantizer._double_scale keys and the absence of _scale keys in the bug report). Fixing that entry looks like the actual root cause and covers every consumer of that list, rather than adding a second deletion mechanism in this one export path. If you keep the delattr here (it does free memory earlier, which is a fair reason for --low_memory_mode), please still fix the list entry and say so in the CHANGELOG, otherwise the typo stays as a latent leak.

weight_quantizer = getattr(compressed_module, quantizer_attrs.weight_quantizer)
assert getattr(weight_quantizer, "_scale", None) is None
assert getattr(weight_quantizer, "_double_scale", None) is None
assert not any("_double_scale" in key for key in compressed_module.state_dict())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bot comment.

This test only exercises the pure-PyTorch compression path. If tensorrt_llm is importable on a SM100+ runner, _real_quantize takes the torch.ops.trtllm.fp4_quantize branch and _scale becomes a 1-D uint8 cutlass scale — the assertions here would fail (or the export silently writes a bad scale in production). Consider adding coverage under tests/gpu_trtllm/ or explicitly monkeypatching fp4_compatible/the trtllm import to cover both layouts, so the behaviour is pinned in both environments.

mtq.compress (used by hf_ptq --low_memory_mode) replaces the weight with
packed NVFP4 nibbles and stores the per-block scale on the quantizer. The
NVFP4 export branch had no handling for this and recomputed the scale from
the weight, which is now uint8 packed data: QTensorWrapper reports the
packed shape, so the block count came out as in_dim/32 instead of in_dim/16
and the amax was taken over nibble-pair bytes. On DeepSeek-R1-Distill-
Llama-70B this wrote weight_scale [8192, 256] instead of [8192, 512] with
meaningless values, which dequantizes to garbage.

The internal _scale / _double_scale buffers also stayed registered and were
serialized as *.weight_quantizer._double_scale entries (560 of them in the
70B checkpoint), so vLLM and the TensorRT-LLM PyTorch backend failed with
KeyError before loading any weight.

Reuse the compression-time per-block scale, rescaled into the exported
weight_scale_2 convention so the weight_scale * weight_scale_2 product that
dequantization consumes is preserved, and drop the internal buffers after
use. Verified on DGX Spark: the exported checkpoint now matches a normal
export (identical key set, weight_scale_2 and input_scale identical, all
154 quantized tensors at the same dequantization error) and serves
correctly instead of failing to load.

NVBug 5987078

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>
@cjluo-nv
cjluo-nv force-pushed the chenjiel/fix-lowmem-nvfp4-export branch from f749100 to 2314cb7 Compare July 31, 2026 21:25
@cjluo-nv
cjluo-nv requested a review from a team as a code owner July 31, 2026 21:25
@cjluo-nv
cjluo-nv requested a review from mxinO July 31, 2026 21:25
@cjluo-nv

Copy link
Copy Markdown
Collaborator Author

Thanks — all four addressed in 2314cb7f0.

1. Swizzled cutlass scale — confirmed and fixed. You were right, and it reproduces on the hardware I was testing on. In a TRT-LLM container on GB10 (sm121):

fp4_compatible: True   tensorrt_llm: 1.3.0rc17   trtllm.fp4_quantize op: True
logical weight (512, 256)  -> modelopt scale should be (512, 16) e4m3
_scale : (8192,) torch.uint8  (ndim=1)     <- cutlass-swizzled
after cutlass_fp4_scale_to_modelopt_fp4_scale: (512, 16) torch.float8_e4m3fn

My earlier validation ran in the vLLM container, where tensorrt_llm doesn't import, so NVFP4QTensor.quantize fell through to the dense E4M3 path — the bug reporter's environment takes the other branch, so the original patch would have been broken exactly where it mattered. New _compressed_per_block_scale() mirrors the guard in NVFP4QTensor.dequantize: converts when the scale is 1-D uint8, and raises if tensorrt_llm can't be imported to convert rather than writing raw bytes.

2. list_of_scale_tensors typo — agreed, that's the root cause; fixed there instead. "double_scale" -> "_double_scale", and I dropped my export-path deletion loop. Corroborating evidence from the broken checkpoint: weight_quantizer._double_scale leaked but weight_quantizer._scale did not — exactly what a missing underscore predicts, since _scale matched the list and was stripped. Note this also affects megatron.py:505,511, which filter on the same list; that looks like the desired behavior (these are internal buffers) but it is a behavior change in that path, so flagging it explicitly rather than slipping it in.

3. Missing clamp — fixed. Now routed through _cast_per_block_scale_to_fp8. Worth noting the live risk here is underflow, not overflow: fusion unifies weight_scale_2 to the group max, so the rescale ratio is <= 1 and scales shrink — which is what the 2**-9 floor guards.

4. Redundant computation — fixed. get_weight_scaling_factor() is now skipped when the compressed scale will be used (it was both wasted block-amax work over packed bytes and a value immediately overwritten). Scoped to the NVFP4 family so other compressed formats still get their scale registered.

Testing. New tests/gpu_trtllm/torch/export/test_export_compressed_nvfp4.py covers the swizzled path and asserts as a precondition that the environment really produces a 1-D uint8 scale, so it can't silently degrade into the dense case. Results on GB10:

  • TRT-LLM container (swizzled path): 3 passed
  • vLLM container (dense path): 2 passed, 1 skipped
  • Negative control: neutering only the un-swizzle conversion makes the new test fail, so it guards this specific regression rather than just the original bug.

@meenchen meenchen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bot review (bedrock-claude-opus-5) — DM the bot to share feedback.

Re-review at 2314cb7f0: all four previously-raised items are addressed, and I found no new correctness problems.

Critical items — resolved

  1. Cutlass-swizzled _scale (correctness, would have written raw byte values on Blackwell + TRT-LLM). New _compressed_per_block_scale() mirrors the guard in NVFP4QTensor.dequantize (1-D uint8 → cutlass_fp4_scale_to_modelopt_fp4_scale, hard ImportError if tensorrt_llm can't be imported to convert). Verified the detection condition matches the producing branch in NVFP4QTensor.quantize(..., try_tensorrt=True). New tests/gpu_trtllm/torch/export/test_export_compressed_nvfp4.py pins the swizzled layout and asserts the precondition so it can't silently degrade into the dense case.
  2. Root cause of the _double_scale leak. Fixed at the source: RealQuantLinear.list_of_scale_tensors "double_scale""_double_scale", and the export-path delattr loop was dropped. I checked the other consumers of that list (megatron.py _parameter_to_keep_in_quantizer_state_dict uses k in key, _get_shard_axis_dict uses endswith("double_scale")) — both already matched ._double_scale before, so the megatron path is unchanged, not a behavior change. postprocess_state_dict's exact "weight_quantizer." + q_key match is the only place the typo mattered, which is consistent with _scale being stripped and _double_scale leaking in the bug report. The gpu test asserts both keys are gone.
  3. Missing E4M3 clamp. Now routed through _cast_per_block_scale_to_fp8, so the 2**-9 floor / 448 ceiling apply as on every other NVFP4 path — important here since group-unified weight_scale_2 makes the rescale ratio ≤ 1 (underflow direction).
  4. Redundant packed-data reduction. get_weight_scaling_factor() is now skipped via elif not use_compressed_scale, scoped to the NVFP4 family so other formats still register their scale. Confirmed the later if weight_scale is not None: register_buffer(...) still writes the buffer, and to_quantized_weight() short-circuits on QTensorWrapper, so the packed nibbles are passed through untouched.

Also checked: BMM-style fused experts (gate_up_proj) are never compressed by pack_real_quantize_weight (no module.weight), so the transpose-orientation mismatch I was worried about for stored _scale can't be reached; the naming ask from cjluo-nv is covered by uses_compressed_nvfp4_scale / use_compressed_scale; the new file's header matches LICENSE_HEADER verbatim, so no licensing concern; size (+186/-3) and CHANGELOG are fine.

Minor, non-blocking: the CHANGELOG entry still describes the buffers as "removed after use" and doesn't mention the list_of_scale_tensors typo (the actual root cause) or the swizzled-scale conversion — worth a one-line touch-up on the way in. And if weight_scale_2 were ever None for an NVFP4 format, the compressed branch would fall through to recomputing from packed data (the original bug) rather than failing loudly; practically unreachable since weight_scale_2 is registered unconditionally for these formats.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants