Skip to content

Fix NVFP4 multi-GPU export device handling - #2197

Draft
realAsma wants to merge 2 commits into
mainfrom
asma/fix-nvfp4-multi-gpu-export
Draft

Fix NVFP4 multi-GPU export device handling#2197
realAsma wants to merge 2 commits into
mainfrom
asma/fix-nvfp4-multi-gpu-export

Conversation

@realAsma

Copy link
Copy Markdown
Contributor

What does this PR do?

Type of change: Bug fix

NVFP4 weight export could allocate intermediate tensors on the process's current CUDA device instead of the input tensor's device. This caused export failures when a model shard lived on a different GPU. This change scopes NVFP4 quantization to the input device while restoring the caller's current device afterward.

The export path also now uses the quantizer's public is_enabled state instead of inferring that state from its formatted representation.

Usage

N/A — this fixes existing multi-GPU export behavior without changing the public API.

Testing

  • pre-commit run --files modelopt/torch/export/unified_export_hf.py modelopt/torch/quantization/qtensor/nvfp4_tensor.py tests/gpu/torch/quantization/test_qtensor_cuda.py tests/unit/torch/export/test_export_weight.py
  • pytest tests/unit/torch/export/test_export_weight.py::test_export_quantized_weight_does_not_repr_input_quantizer -q -x
  • CUDA_VISIBLE_DEVICES=1,2 pytest tests/gpu/torch/quantization/test_qtensor_cuda.py::TestQTensor::test_nvfp4_export_uses_input_device -q -x

Before your PR is "Ready for review"

Make sure you read and follow Contributor guidelines and your commits are signed (git commit -s -S).

Make sure you read and follow the Security Best Practices (e.g. avoiding hardcoded trust_remote_code=True, torch.load(..., weights_only=False), pickle, etc.).

  • 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?: N/A — focused bug fix; no critical or previously documented release issue
  • Did you get Claude approval on this PR?: N/A — draft preparation only

Additional Information

No new dependency or public API is introduced.

Signed-off-by: realAsma <akuriparambi@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 60f459c8-b10f-4157-81d7-5849e1ecfd33

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://NVIDIA.github.io/Model-Optimizer/pr-preview/pr-2197/

Built to branch gh-pages at 2026-08-14 21:46 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 73.07692% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.96%. Comparing base (5e887aa) to head (25819df).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...odelopt/torch/quantization/qtensor/nvfp4_tensor.py 68.18% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2197      +/-   ##
==========================================
- Coverage   78.97%   78.96%   -0.01%     
==========================================
  Files         522      522              
  Lines       60606    60612       +6     
==========================================
  Hits        47862    47862              
- Misses      12744    12750       +6     
Flag Coverage Δ
unit 55.57% <73.07%> (+<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.

Signed-off-by: realAsma <akuriparambi@nvidia.com>
@realAsma

Copy link
Copy Markdown
Contributor Author

/claude review

Comment on lines +129 to +141
def test_export_quantized_weight_does_not_repr_input_quantizer(monkeypatch):
model = ToyModel(dims=[32, 256, 32])
mtq.quantize(model, partial_fp8_config, lambda x: x(torch.randn(1, 4, 32)))
input_quantizer = model.linears[1].input_quantizer

monkeypatch.setattr(
input_quantizer,
"extra_repr",
lambda: pytest.fail("export should inspect is_enabled without formatting the quantizer"),
)

_export_quantized_weight(model.linears[1], torch.float32, "weight")
assert hasattr(model.linears[1], "input_scale")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT TestCoverage] This test never reaches the line the PR changes, so it passes identically with and without the fix.

partial_fp8_config gives linears[1] a per-tensor (4,3) weight quantizer, so get_quantization_format() returns QUANTIZATION_FP8 and _export_quantized_weight_impl takes the if quantization_format == QUANTIZATION_FP8: branch (unified_export_hf.py:659). That branch registers input_scale from hasattr(input_quantizer, "_amax") and never evaluates the enabled-state check at all. The repr(...)is_enabled hunk lives in the else: branch (unified_export_hf.py:709-712), which only runs for non-FP8 formats.

Why it matters: the PR's Testing section cites this test as the evidence for the is_enabled change, but extra_repr was never going to be called on this path — reverting the source hunk leaves the test green, so a future regression back to a repr-based check would not be caught.

Fix: drive a format that lands in the else: branch. partial_w4a8_config on linears[2] (already imported in this file, QUANTIZATION_W4A8_AWQ, input quantizer enabled with an amax) does hit the changed condition:

def test_export_quantized_weight_does_not_repr_input_quantizer(monkeypatch):
    model = ToyModel(dims=[32, 256, 256, 32])
    mtq.quantize(model, partial_w4a8_config, lambda x: x(torch.randn(1, 4, 32)))
    input_quantizer = model.linears[2].input_quantizer

    monkeypatch.setattr(
        input_quantizer,
        "extra_repr",
        lambda: pytest.fail("export should inspect is_enabled without formatting the quantizer"),
    )

    _export_quantized_weight(model.linears[2], torch.float32, "weight")
    assert hasattr(model.linears[2], "input_scale")

Comment on lines +107 to +126
def test_export_quantized_weight_uses_weight_device_context(monkeypatch):
model = ToyModel(dims=[32, 32])
mtq.quantize(model, mtq.NVFP4_DEFAULT_CFG, lambda m: m(torch.randn(1, 4, 32)))
linear = model.linears
entered = False

@contextmanager
def record_device_context(weight):
nonlocal entered
assert weight is linear.weight
entered = True
yield

monkeypatch.setattr(
"modelopt.torch.export.unified_export_hf.same_device_as", record_device_context
)

_export_quantized_weight(linear, torch.float32)

assert entered

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] This test asserts an implementation detail rather than behavior: it replaces same_device_as with a stub that does nothing and then checks the stub was entered. It can only ever verify "_export_quantized_weight calls a module-level name same_device_as with getattr(module, weight_name)" — it cannot detect a wrong device, and it breaks on any refactor that keeps the behavior (e.g. inlining torch.cuda.device(...), or moving the guard into the impl body).

The behavioral coverage already exists in tests/gpu/torch/export/test_export_weight_gpu.py::test_export_nvfp4_modules_uses_each_weight_device and test_qtensor_cuda.py::test_nvfp4_export_uses_input_device. Consider dropping this one, or — if you want a CPU-runnable guard — assert something observable instead, e.g. that all registered scale buffers land on the weight's device.

# Make sure this utils is available for dequantize
from tensorrt_llm._torch.auto_deploy.utils.quantization_utils import (
cutlass_fp4_scale_to_modelopt_fp4_scale, # noqa: F401
with same_device_as(input):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] Format-asymmetric coverage: this guard fixes NVFP4QTensor.quantize only, while the sibling real-quantize paths keep the original exposure.

Within this file the only current-device-sensitive allocation is torch.ops.trtllm.fp4_quantize (_cast_fp4 already keys its lookup tables off weight.device via get_e2m1_bounds, and every other op derives its device from input). The same class of exposure exists for MXFP8QTensor / FP8QTensor / INT4QTensor when reached through TensorQuantizer.forward_real_quantize, which — unlike _fake_quantize (tensor_quantizer.py:1205, already wrapped in same_device_as) — has no device guard:

            if self.fake_quant:
                with same_device_as(inputs):
                    outputs = self._fake_quantize(inputs)
            elif not self._dequantize:
                outputs = self._real_quantize(inputs)   # <- unguarded

Adding with same_device_as(inputs): around self._real_quantize(inputs) would make the compress/mtq.compress path symmetric with fake-quant for every format in one place, rather than per-QTensor-class. Non-blocking, and it doesn't replace this hunk — to_quantized_weight() calls NVFP4QTensor.quantize directly, which is what the new test_nvfp4_export_uses_input_device exercises.

Minor: wrapping the whole body re-indents ~60 lines, which makes the actual one-line intent hard to see in the diff. An early with same_device_as(input): scoped to just the trtllm branch (or an ExitStack) would keep the diff surgical, per the "prefer simple, surgical changes" guidance in CONTRIBUTING.md.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — Fix NVFP4 multi-GPU export device handling

Scope: full review (trigger comment carried no scoping instructions). All 5 changed files opened: modelopt/torch/export/unified_export_hf.py, modelopt/torch/quantization/qtensor/nvfp4_tensor.py, and the 3 test files. Traced same_device_as and is_enabled to their definitions and all call sites.

Findings

Severity Count
CRITICAL 0
IMPORTANT 1
SUGGESTION 2

IMPORTANT — test_export_quantized_weight_does_not_repr_input_quantizer does not exercise the line it is meant to cover. partial_fp8_config makes linears[1] per-tensor FP8, so export takes the quantization_format == QUANTIZATION_FP8 branch, which registers input_scale off hasattr(input_quantizer, "_amax") and never evaluates the enabled-state check. The repr(...)is_enabled hunk lives in the else: branch. The test is green with or without the source change, so the repr-based check could regress silently. Switching to partial_w4a8_config on linears[2] reaches the changed condition.

SUGGESTIONs (non-blocking): the CPU unit test for the device context is tautological — it stubs out same_device_as and asserts the stub ran, which can't detect a wrong device; and the nvfp4_tensor.py guard leaves _real_quantize unprotected for MXFP8/FP8/INT4 while re-indenting ~60 lines for a one-line intent.

What I verified as correct

  • repris_enabled is semantically equivalent and is the established convention. extra_repr emits the literal "disabled" iff self._disabled, and is_enabled is exactly not self._disabled (tensor_quantizer.py:519-521); nothing else in extra_repr can contain that substring. quant_utils.py:524 already uses the identical input_quantizer is not None and input_quantizer.is_enabled form for the same purpose in the TRT-LLM path, so this aligns unified_export_hf with it. Container quantizers delegate is_enabled to member 0 per _QuantizerContainerBase._delegated_properties, and enable/disable broadcast to all members, so mixed-state containers aren't reachable in practice.
  • The _export_quantized_weight wrapper is safe for every entry path. same_device_as short-circuits to nullcontext() for non-CUDA tensors, so meta-offloaded weights still reach the existing weight.is_meta RuntimeError unchanged, and CPU-only unit tests are unaffected. The unconditional getattr(sub_module, weight_name) in the wrapper — now ahead of the QUANTIZATION_NONE early return — is fine: all three call sites in hf_export_handlers.py (142/158/186) gate on get_quantization_format(...) != QUANTIZATION_NONE first, and the moe_utils.py:268 wrapper module sets .weight immediately before the call. QTensorWrapper is an nn.Parameter, so .is_cuda/.device resolve for the compressed path, and FSDP2 callers wrap in fsdp2_aware_weight_update before the getattr.
  • The root cause is correctly identified. torch.cuda.device() only redirects allocations that key off the current device — inside this file that is torch.ops.trtllm.fp4_quantize, which matches the reported failure. _cast_fp4 was already device-correct via the e2m1_bounds/e2m1_values per-device caches. The guard is thread-local (CUDA current device is per-thread), so the layer-by-layer streaming writer is safe.
  • No public API, mode registration, config schema, or modelopt_state surface is touched; no CHANGELOG entry needed per the repo's own criteria. No CPU-GPU sync added.

Risk

Low. The production change is a narrow, correctly-targeted device-scoping fix with no behavioral change on single-GPU exports, plus a strictly-better replacement of a string-matching hack. The one blocking item is test-only — the multi-GPU behavior is genuinely covered by the two new GPU tests; it is the is_enabled half of the change that currently has no effective regression test.

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.

1 participant