fix(quant): dequantize MXFP4 on GPU under the distributed conversion backend - #5523
fix(quant): dequantize MXFP4 on GPU under the distributed conversion backend#5523mikan-atomoki wants to merge 2 commits into
Conversation
…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>
yaoyu-33
left a comment
There was a problem hiding this comment.
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:
- 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.
- 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. - 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.
- 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>
|
Thanks for the detailed review. Pushed a commit addressing the device-explicitness and memory-bounding points:
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, 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. |
What does this PR do ?
Dequantize MXFP4-packed weights on GPU instead of CPU when
dequantize_mxfp4_e2m1_packedruns under the distributed GPU conversion backend, fixing a ~20-30x slowdown.Changelog
src/megatron/bridge/models/conversion/quantization_utils.py:dequantize_mxfp4_e2m1_packednow movesweight_packed/scaleto the current CUDA device before dequantizing, when running undertorch.distributed(guarded ontorch.distributed.is_initialized()). Previously it operated on whatever device the input tensors were already on; sinceAutoBridge.from_hf_pretrainedloads HF state dicts on CPU, the whole dequantization (bit unpack, LUT gather,repeat_interleave, multiply) ran on CPU even underscripts/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 callsinit_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:
torch/torch.distributed.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 explicitdeviceparameter with the same intent, but its only caller (kimi_k25_vl_bridge.py) passesdevice=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.