|
| 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