Skip to content

fix(quant): dequantize MXFP4 on GPU under the distributed conversion backend - #5523

Open
mikan-atomoki wants to merge 2 commits into
NVIDIA-NeMo:mainfrom
mikan-atomoki:fix/mxfp4-dequant-gpu-device
Open

fix(quant): dequantize MXFP4 on GPU under the distributed conversion backend#5523
mikan-atomoki wants to merge 2 commits into
NVIDIA-NeMo:mainfrom
mikan-atomoki:fix/mxfp4-dequant-gpu-device

Conversation

@mikan-atomoki

Copy link
Copy Markdown

What does this PR do ?

Dequantize MXFP4-packed weights on GPU instead of CPU when dequantize_mxfp4_e2m1_packed runs under the distributed GPU conversion backend, fixing a ~20-30x slowdown.

Changelog

  • src/megatron/bridge/models/conversion/quantization_utils.py: dequantize_mxfp4_e2m1_packed now moves weight_packed/scale to the current CUDA device before dequantizing, when running under torch.distributed (guarded on torch.distributed.is_initialized()). Previously it operated on whatever device the input tensors were already on; since AutoBridge.from_hf_pretrained loads HF state dicts on CPU, the whole dequantization (bit unpack, LUT gather, repeat_interleave, multiply) ran on CPU even under scripts/conversion's GPU backend, leaving GPUs idle and making a single CPU process the bottleneck for the entire distributed conversion. The single-process CPU backend (which never calls init_process_group) is unaffected.

GitHub Actions CI

N/A (external contributor without CI trigger permissions; happy to have a maintainer trigger CI).

Before your PR is "Ready for review"

Pre checks:

  • Make sure you read and followed Contributor guidelines
  • Did you write any new necessary tests? — No. The regression is about wall-clock throughput under a real multi-GPU distributed process group, not something the existing unit tests for this function (which check numerical correctness of dequantization, unaffected by this change) exercise. Verified manually instead (see Additional Information).
  • Did you add or update any necessary documentation? — No. This is a performance fix with no behavior, API, or config change.
  • Does the PR affect components that are optional to install? (Ex: Numba, Pynini, Apex etc) — No, only core torch/torch.distributed.
    • Reviewer: Does the PR have correct import guards for all optional libraries? — N/A, see above.

Additional Information

Found while converting moonshotai/Kimi-K3 (48 GPUs, TP2/PP3/EP8/ETP2). Observed ~20-30x slower than expected: the progress-bar ETA on the full model and directly measured throughput on a cheap toy-model reproduction both independently converged on ~1 weight/sec instead of completing in minutes. Root-caused with a single-process probe showing GPU utilization near 0% and CPU pegged during the dequantize step. Fixed and re-verified end to end at the same 48-GPU scale.

The same function is also reached from DeepSeek V4's bridge via maybe_dequantize_hf_quantized_weight, so this isn't Kimi-K3-specific.

Note for reviewers: dequantize_int4 (used by Kimi K2.5 VL) takes an explicit device parameter with the same intent, but its only caller (kimi_k25_vl_bridge.py) passes device=hf_state_dict[packed_key].device — i.e. the tensor's own (CPU) device — so it likely has the same CPU-bound behavior in practice. Left out of this PR to keep it focused on the reproduced/verified case; flagging in case it's worth a follow-up.

…backend

dequantize_mxfp4_e2m1_packed operated on whatever device the input
tensors were already on. AutoBridge.from_hf_pretrained loads HF state
dicts on CPU, so under scripts/conversion's GPU backend the entire
dequantization (bit unpack, LUT gather, repeat_interleave, multiply)
ran on CPU, leaving GPUs idle and making a single CPU process the
bottleneck for the whole distributed conversion.

Move the packed weight and scale to the current CUDA device first when
running under torch.distributed (guarded on is_initialized() so the
single-process CPU backend, which never calls init_process_group, is
unaffected).

Observed ~20-30x slower than expected converting moonshotai/Kimi-K3
(48 GPUs, TP2/PP3/EP8/ETP2); confirmed via matching progress-bar ETA on
the full model and directly measured throughput on a cheap toy-model
reproduction, both independently converging on ~1 weight/sec instead of
completing in minutes. Fixed and re-verified end to end at the same
48-GPU scale.

The same function is also reached from DeepSeek V4's bridge via
maybe_dequantize_hf_quantized_weight, so the fix is not Kimi-K3-specific.

Note for reviewers: dequantize_int4 (used by Kimi K2.5 VL) takes an
explicit `device` parameter with the same intent, but its only caller
(kimi_k25_vl_bridge.py) passes `device=hf_state_dict[packed_key].device`,
i.e. the tensor's own (CPU) device, so it likely has the same CPU-bound
issue in practice. Left out of this PR to keep it focused; flagging in
case it's worth a follow-up.

Signed-off-by: Ando Tomoki <tomoki.py@gmail.com>
@copy-pr-bot

copy-pr-bot Bot commented Aug 12, 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.

@yaoyu-33 yaoyu-33 added area:quant Quantization (PTQ, QAT, FP8 recipes) bug Something isn't working full-test-suite needs-more-tests Requires additional L0 and L1 test coverage before merge needs-review PR is ready for code review and waiting on a reviewer labels Aug 12, 2026

@yaoyu-33 yaoyu-33 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.

The GPU-dequantization direction makes sense, but I think the current implementation needs a bounded-memory/backend-aware design before we merge it.

torch.distributed.is_initialized() is not a GPU conversion backend contract. This helper runs before hf_to_megatron() decides the TP/ETP source/owner, so every rank that has the task first moves and dequantizes the full HF tensor. In TP/ETP mappings, non-source ranks then discard that full result while rank 0 splits/scatters it. It can also change the device semantics of this otherwise tensor-local helper in any future distributed caller that happens to have CUDA visible.

The transient memory is much larger than the packed input: both nibble indices are int64, both LUT gathers and logical are FP32, repeat_interleave materializes a full FP32 scale tensor, the multiply creates another full FP32 tensor, and the BF16 result coexists during the final cast. The rough peak is about 20-22.5 bytes per logical element, before the Megatron model shard and any mapping/scatter copies. For a Kimi K3 expert matrix of roughly 3584 x 7168, that is around 0.5 GiB of transient GPU memory per participating rank for one weight; gate/up retention and low-headroom model shards make OOM realistic.

Could we instead:

  1. Pass an explicit execution/target device from the GPU backend (preserving the input device by default), rather than infer the backend from process-group state.
  2. Dequantize in bounded row chunks, following dequantize_mxfp4(): move only each packed/scale slice to CUDA, write the BF16 slice to the intended destination, and release intermediates.
  3. Arrange for only the mapping source/owner rank to perform the full source transform. If the final destination is CPU, copy each completed chunk back; if this is the GPU backend, keep the result on GPU. Moving the complete result back only at the end would still create an avoidable peak and PCIe burst.
  4. Add focused coverage for CPU device preservation, CUDA numerical/device behavior, and the chunked path.

Direct use of the GPU conversion backend is the right fast path for these large models; it just needs to make GPU execution explicit and memory-bounded.

Addresses review feedback on the previous commit:

- dequantize_mxfp4_e2m1_packed no longer infers the GPU backend via
  torch.distributed.is_initialized(). It now takes an explicit `device`
  parameter (default None preserves the input tensor's device, matching
  dequantize_int4's convention), and the decision to pass a CUDA device
  moves to the call site in kimi_k3_bridge.py's _load_one_hf_weight,
  which is where "are we running under the GPU conversion backend" is
  an appropriate question to ask.

- Rewrote the body to process rows_per_chunk rows at a time (default
  matches dequantize_mxfp4's), gathering the LUT lookups directly into
  interleaved slices of a single preallocated output tensor and applying
  the scale in place, instead of materializing separate full-size fp32
  buffers for the stacked LUT gather, the broadcast scale, and the
  product. This bounds peak transient memory regardless of tensor size
  instead of scaling it up by roughly an order of magnitude over the
  packed input, which was flagged as a realistic OOM risk for large
  expert matrices under low headroom.

- Added tests: CPU device preserved by default, explicit CUDA device
  matches CPU numerically (skipped without a GPU), and chunked output
  matches unchunked output.

Manually re-verified (roundtrip, uint8 E8M0 scale, geometry rejection,
CPU-default, explicit CUDA device + non-mutation of inputs, and chunked
vs. unchunked equivalence) on a GPU node before pushing.

Not addressed here: only the TP/ETP mapping owner rank needs the full
dequantized tensor; other ranks in the same TP group currently still
do the full transform and discard it. Fixing that means threading
ownership into maybe_modify_loaded_hf_weight's call signature, which is
shared by ~10 other model bridges - posting on the PR to check the
preferred approach before touching that contract.

Signed-off-by: Ando Tomoki <tomoki.py@gmail.com>
@mikan-atomoki

Copy link
Copy Markdown
Author

Thanks for the detailed review. Pushed a commit addressing the device-explicitness and memory-bounding points:

  • dequantize_mxfp4_e2m1_packed now takes an explicit device param (default None preserves the input's device); torch.distributed.is_initialized() is gone from the function itself and the decision moved to the call site in kimi_k3_bridge.py.
  • Rewrote the body to match dequantize_mxfp4()'s chunked pattern: preallocate the output once, gather LUT lookups directly into interleaved output slices, apply the scale in place, process rows_per_chunk rows at a time. This bounds peak transient memory instead of scaling it up with several coexisting full-size fp32 buffers.
  • Added CPU-default, explicit-CUDA-device, and chunking-equivalence tests, and manually re-verified all of the existing + new cases (roundtrip, uint8 E8M0 scale, geometry rejection, CPU default, explicit CUDA device + non-mutation of inputs, chunked vs. unchunked) on a GPU node before pushing.

On point 3 (only the TP/ETP mapping owner rank needs the full dequantized tensor) - I didn't want to guess at the right shape for this before checking with you, since it's not contained to this function. As I understand the call path, maybe_modify_loaded_hf_weight (and this function through it) runs in model_bridge.py's loop before task.mapping.hf_to_megatron() decides the TP/ETP source rank, and maybe_modify_loaded_hf_weight is overridden by ~10 model bridges with the same 2-arg signature. The most direct fix I can see is threading ownership info (e.g. an is_owner: bool = True-style default-safe kwarg) through that shared signature so non-owner ranks can skip the expensive transform - which is mechanical for the bridges that don't care, but does touch the shared contract.

Is that the direction you'd want, or is there a narrower way to gate this you'd prefer? Happy to send it as a separate PR once we agree on the shape, rather than bundling a framework-wide signature change into this one.

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

Labels

area:quant Quantization (PTQ, QAT, FP8 recipes) bug Something isn't working community-request full-test-suite needs-more-tests Requires additional L0 and L1 test coverage before merge needs-review PR is ready for code review and waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants