Skip to content

Commit 575c27a

Browse files
committed
Merge main and share MXFP4 shard casting
Signed-off-by: Zhiyu Cheng <zhiyuc@nvidia.com>
2 parents c985838 + a2fbac7 commit 575c27a

5 files changed

Lines changed: 445 additions & 459 deletions

File tree

examples/_shard_cast_utils.py

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Shared primitives for streaming MXFP4-to-NVFP4 checkpoint conversion."""
17+
18+
from __future__ import annotations
19+
20+
import errno
21+
import os
22+
import shutil
23+
from collections import defaultdict
24+
from pathlib import Path
25+
from typing import TYPE_CHECKING
26+
27+
import torch
28+
29+
from modelopt.torch.quantization.qtensor import MXFP4QTensor, NVFP4QTensor
30+
from modelopt.torch.quantization.utils.numeric_utils import (
31+
E2M1_MAX,
32+
E4M3_KMAX,
33+
E4M3_KMIN,
34+
E4M3_MAX,
35+
E8M0_BIAS,
36+
mxfp4_to_nvfp4_global_amax,
37+
mxfp4_to_nvfp4_per_block_amax,
38+
)
39+
40+
if TYPE_CHECKING:
41+
from collections.abc import Callable, Collection
42+
43+
__all__ = [
44+
"build_w13_amax_overrides",
45+
"build_w13_kmax_overrides",
46+
"dequantize_mxfp4_to_bf16",
47+
"link_aux_files",
48+
"link_or_copy",
49+
"log",
50+
"mxfp4_kmax",
51+
"prepare_output_dir",
52+
"quantize_mxfp4_to_nvfp4",
53+
"quantize_mxfp4_to_nvfp4_lossless",
54+
"validate_paths",
55+
]
56+
57+
_MXFP4_BLOCK = 32
58+
_MXFP4_BYTES_PER_BLOCK = 16
59+
_NVFP4_BLOCK = 16
60+
61+
62+
def dequantize_mxfp4_to_bf16(
63+
mxfp4_weight: torch.Tensor, mxfp4_scale: torch.Tensor, device: str
64+
) -> torch.Tensor:
65+
"""Dequantize packed MXFP4 weights and E8M0 scales to BF16."""
66+
packed = mxfp4_weight.to(device).contiguous().view(torch.uint8)
67+
scale = mxfp4_scale.to(device).contiguous().view(torch.uint8)
68+
original_shape = torch.Size((*packed.shape[:-1], packed.shape[-1] * 2))
69+
assert packed.shape[:-1] == scale.shape[:-1] and (
70+
2 * packed.shape[-1] == scale.shape[-1] * _MXFP4_BLOCK
71+
), f"Incompatible MXFP4 shapes: weight {tuple(packed.shape)} vs scale {tuple(scale.shape)}"
72+
return MXFP4QTensor(original_shape, torch.bfloat16, packed).dequantize(
73+
dtype=torch.bfloat16,
74+
scale=scale,
75+
block_sizes=[_MXFP4_BLOCK],
76+
)
77+
78+
79+
def _w13_pairs(expert_bases: list[str]) -> list[tuple[str, str]]:
80+
groups: dict[str, dict[str, str]] = defaultdict(dict)
81+
for base in expert_bases:
82+
prefix, proj = base.rsplit(".", 1)
83+
if proj in {"w1", "w3"}:
84+
groups[prefix][proj] = base
85+
86+
pairs: list[tuple[str, str]] = []
87+
for prefix, paths in groups.items():
88+
if "w1" not in paths or "w3" not in paths:
89+
raise RuntimeError(
90+
"w1/w3 of one expert are split across shards, so they cannot share "
91+
f"scale_2 for the fused GEMM1: {prefix}"
92+
)
93+
pairs.append((paths["w1"], paths["w3"]))
94+
return pairs
95+
96+
97+
def build_w13_kmax_overrides(
98+
expert_bases: list[str],
99+
get_scale: Callable[[str], torch.Tensor],
100+
device: str,
101+
) -> dict[str, int]:
102+
"""Return one shared E8M0 maximum exponent for each fused w1/w3 pair."""
103+
overrides: dict[str, int] = {}
104+
for w1, w3 in _w13_pairs(expert_bases):
105+
k1 = mxfp4_kmax(get_scale(w1), device)
106+
k3 = mxfp4_kmax(get_scale(w3), device)
107+
overrides[w1] = overrides[w3] = max(k1, k3)
108+
return overrides
109+
110+
111+
def build_w13_amax_overrides(
112+
expert_bases: list[str],
113+
get_amax: Callable[[str], torch.Tensor],
114+
) -> dict[str, torch.Tensor]:
115+
"""Return one shared weight amax for each fused w1/w3 pair."""
116+
overrides: dict[str, torch.Tensor] = {}
117+
for w1, w3 in _w13_pairs(expert_bases):
118+
shared = torch.maximum(get_amax(w1).reshape(()), get_amax(w3).reshape(()))
119+
overrides[w1] = overrides[w3] = shared
120+
return overrides
121+
122+
123+
def mxfp4_kmax(mxfp4_scale: torch.Tensor, device: str = "cpu") -> int:
124+
"""Return the largest non-zero unbiased exponent in an E8M0 scale tensor."""
125+
e8m0 = mxfp4_scale.to(device).contiguous().view(torch.uint8)
126+
return mxfp4_to_nvfp4_global_amax(e8m0)[1]["k_max"]
127+
128+
129+
def quantize_mxfp4_to_nvfp4(
130+
mxfp4_weight: torch.Tensor,
131+
mxfp4_scale: torch.Tensor,
132+
weight_amax: torch.Tensor | None,
133+
device: str,
134+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, bool]:
135+
"""Dequantize MXFP4 and requantize it to NVFP4 using an optional global amax."""
136+
bf16 = dequantize_mxfp4_to_bf16(mxfp4_weight, mxfp4_scale, device)
137+
synthesized = weight_amax is None
138+
if weight_amax is None:
139+
weight_amax = bf16.abs().max()
140+
weight_scale_2 = (weight_amax.to(device).float() / (E2M1_MAX * E4M3_MAX)).reshape(())
141+
q_tensor, weight_scale, _ = NVFP4QTensor.quantize(
142+
bf16, _NVFP4_BLOCK, None, weight_scale_2, try_tensorrt=False
143+
)
144+
return q_tensor._quantized_data, weight_scale, weight_scale_2, synthesized
145+
146+
147+
def quantize_mxfp4_to_nvfp4_lossless(
148+
mxfp4_weight: torch.Tensor,
149+
mxfp4_scale: torch.Tensor,
150+
k_max: int,
151+
device: str,
152+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, int, int]:
153+
"""Closed-form MXFP4-to-NVFP4 cast with lossless-block accounting."""
154+
bf16 = dequantize_mxfp4_to_bf16(mxfp4_weight, mxfp4_scale, device)
155+
e8m0 = mxfp4_scale.to(bf16.device).contiguous().view(torch.uint8)
156+
packed = mxfp4_weight.to(bf16.device).contiguous().view(torch.uint8)
157+
blocks = packed.view(*packed.shape[:-1], e8m0.shape[-1], _MXFP4_BYTES_PER_BLOCK)
158+
per_block_amax = mxfp4_to_nvfp4_per_block_amax(blocks, e8m0)
159+
160+
weight_scale_2 = torch.tensor(
161+
2.0 ** (k_max - E4M3_KMAX), dtype=torch.float32, device=bf16.device
162+
).reshape(())
163+
per_block_scale = (
164+
(per_block_amax / (E2M1_MAX * weight_scale_2))
165+
.clamp(min=2**E4M3_KMIN, max=E4M3_MAX)
166+
.to(torch.float8_e4m3fn)
167+
)
168+
169+
k = e8m0.to(torch.int32) - E8M0_BIAS
170+
lossless = (k >= (k_max - (E4M3_KMAX - E4M3_KMIN))) | (e8m0 == 0)
171+
n_blocks = k.numel()
172+
n_lossless = int(lossless.sum().item())
173+
174+
q_tensor, weight_scale, _ = NVFP4QTensor.quantize(
175+
bf16, _NVFP4_BLOCK, per_block_scale, weight_scale_2, try_tensorrt=False
176+
)
177+
return q_tensor._quantized_data, weight_scale, weight_scale_2, n_blocks, n_lossless
178+
179+
180+
def link_or_copy(src: Path, dst: Path) -> None:
181+
"""Hard-link a file, copying when the filesystem cannot create the link."""
182+
try:
183+
os.link(src, dst)
184+
except OSError as exc:
185+
copy_errnos = {
186+
errno.EXDEV,
187+
errno.EPERM,
188+
errno.EACCES,
189+
errno.EMLINK,
190+
getattr(errno, "EOPNOTSUPP", errno.EXDEV),
191+
getattr(errno, "ENOTSUP", errno.EXDEV),
192+
}
193+
if exc.errno not in copy_errnos:
194+
raise
195+
shutil.copy2(src, dst)
196+
197+
198+
def link_aux_files(
199+
src_dir: Path,
200+
dst_dir: Path,
201+
*,
202+
skip_top_level: Collection[str] = (),
203+
skip_dir_names: Collection[str] = (),
204+
skip_file: Callable[[Path], bool] | None = None,
205+
) -> None:
206+
"""Recursively link checkpoint sidecars while applying model-specific skips."""
207+
for root, dirs, files in os.walk(src_dir):
208+
rel = Path(root).relative_to(src_dir)
209+
at_top_level = rel == Path(".")
210+
dirs[:] = [
211+
name
212+
for name in dirs
213+
if name not in skip_dir_names and not (at_top_level and name in skip_top_level)
214+
]
215+
(dst_dir / rel).mkdir(parents=True, exist_ok=True)
216+
for name in files:
217+
relative_path = rel / name
218+
if at_top_level and name in skip_top_level:
219+
continue
220+
if skip_file is not None and skip_file(relative_path):
221+
continue
222+
src = src_dir / relative_path
223+
dst = dst_dir / relative_path
224+
if dst.exists():
225+
dst.unlink()
226+
link_or_copy(src, dst)
227+
228+
229+
def log(message: str) -> None:
230+
"""Print a checkpoint-conversion progress message immediately."""
231+
print(message, flush=True)
232+
233+
234+
def validate_paths(source_ckpt: Path, output_ckpt: Path) -> None:
235+
"""Reject overlapping source and output checkpoint directories."""
236+
source_resolved = source_ckpt.resolve()
237+
output_resolved = output_ckpt.resolve()
238+
if (
239+
output_resolved == source_resolved
240+
or source_resolved in output_resolved.parents
241+
or output_resolved in source_resolved.parents
242+
):
243+
raise ValueError(
244+
"--source_ckpt and --output_ckpt must be disjoint directories; "
245+
f"got source={source_ckpt}, output={output_ckpt}"
246+
)
247+
248+
249+
def prepare_output_dir(output_ckpt: Path, overwrite: bool) -> None:
250+
"""Create an empty output directory, replacing its contents when allowed."""
251+
if output_ckpt.exists():
252+
if not output_ckpt.is_dir():
253+
raise ValueError(f"--output_ckpt exists and is not a directory: {output_ckpt}")
254+
if any(output_ckpt.iterdir()):
255+
if not overwrite:
256+
raise ValueError(
257+
f"--output_ckpt is not empty: {output_ckpt}; pass --overwrite to replace it"
258+
)
259+
for item in output_ckpt.iterdir():
260+
if item.is_dir() and not item.is_symlink():
261+
shutil.rmtree(item)
262+
else:
263+
item.unlink()
264+
output_ckpt.mkdir(parents=True, exist_ok=True)

0 commit comments

Comments
 (0)