|
| 1 | +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +"""MiMo-V2.6 text checkpoint conversion using the existing MiMo-V2-Flash layers.""" |
| 16 | + |
| 17 | +import json |
| 18 | +from collections.abc import Iterable, Mapping |
| 19 | +from copy import deepcopy |
| 20 | +from functools import lru_cache |
| 21 | +from pathlib import Path |
| 22 | + |
| 23 | +import torch |
| 24 | +from megatron.core.models.gpt.gpt_model import GPTModel |
| 25 | +from megatron.core.transformer.transformer_config import TransformerConfig |
| 26 | +from torch import nn |
| 27 | + |
| 28 | +from megatron.bridge.models.conversion import quantization_utils |
| 29 | +from megatron.bridge.models.conversion.mapping_registry import MegatronMappingRegistry |
| 30 | +from megatron.bridge.models.conversion.model_bridge import ( |
| 31 | + HFSourcedWeightTuple, |
| 32 | + HFWeightTuple, |
| 33 | + MegatronModel, |
| 34 | + MegatronModelBridge, |
| 35 | + WeightConversionTask, |
| 36 | +) |
| 37 | +from megatron.bridge.models.conversion.param_mapping import AutoMapping |
| 38 | +from megatron.bridge.models.conversion.peft_bridge import AdapterWeight |
| 39 | +from megatron.bridge.models.conversion.utils import unwrap_model |
| 40 | +from megatron.bridge.models.hf_pretrained.causal_lm import PreTrainedCausalLM |
| 41 | +from megatron.bridge.models.hf_pretrained.state import SafeTensorsStateSource |
| 42 | +from megatron.bridge.models.mimo_v2_flash.mimo_v2_flash_bridge import MiMoV2FlashBridge, MiMoV2FlashQKVMapping |
| 43 | +from megatron.bridge.models.mimo_v2_flash.mimo_v2_flash_provider import MiMoV2FlashModelProvider |
| 44 | + |
| 45 | + |
| 46 | +@lru_cache(maxsize=8) |
| 47 | +def _checkpoint_tp_size(index_path: Path) -> int: |
| 48 | + if not index_path.exists(): |
| 49 | + return 1 |
| 50 | + tp_size = json.loads(index_path.read_text()).get("metadata", {}).get("tp_size", 1) |
| 51 | + if not isinstance(tp_size, int) or isinstance(tp_size, bool) or tp_size < 1: |
| 52 | + raise ValueError(f"Invalid MiMo checkpoint tp_size: {tp_size!r}") |
| 53 | + return tp_size |
| 54 | + |
| 55 | + |
| 56 | +def _reorder_qkv( |
| 57 | + weight: torch.Tensor, sizes: tuple[int, int, int], source_chunks: int, target_chunks: int |
| 58 | +) -> torch.Tensor: |
| 59 | + """Repartition [Q0,K0,V0,...] without changing rows within a projection.""" |
| 60 | + if weight.shape[0] != sum(sizes) or any(s % c for s in sizes for c in (source_chunks, target_chunks)): |
| 61 | + raise ValueError("MiMo fused QKV dimensions must be divisible by checkpoint and attention group counts") |
| 62 | + trailing = weight.shape[1:] |
| 63 | + parts = weight.reshape(source_chunks, -1, *trailing).split([s // source_chunks for s in sizes], dim=1) |
| 64 | + return torch.cat( |
| 65 | + [part.reshape(target_chunks, size // target_chunks, *trailing) for part, size in zip(parts, sizes)], |
| 66 | + dim=1, |
| 67 | + ).reshape(weight.shape) |
| 68 | + |
| 69 | + |
| 70 | +class MiMoV2FusedQKVMapping(AutoMapping): |
| 71 | + """Convert checkpoint TP chunks to Megatron's per-KV-head QKV groups.""" |
| 72 | + |
| 73 | + def __init__(self, megatron_param: str, hf_param: str, checkpoint_tp_size: int = 1) -> None: |
| 74 | + super().__init__(megatron_param, hf_param) |
| 75 | + self.checkpoint_tp_size = checkpoint_tp_size |
| 76 | + |
| 77 | + @staticmethod |
| 78 | + def _sizes(config: TransformerConfig) -> tuple[int, int, int]: |
| 79 | + return ( |
| 80 | + config.num_attention_heads * config.kv_channels, |
| 81 | + config.num_query_groups * config.kv_channels, |
| 82 | + config.num_query_groups * config.v_head_dim, |
| 83 | + ) |
| 84 | + |
| 85 | + def hf_to_megatron(self, hf_weights: torch.Tensor, megatron_module: nn.Module) -> torch.Tensor: |
| 86 | + """Reorder before the ordinary column-parallel scatter.""" |
| 87 | + if self.tp_rank == 0: |
| 88 | + config = self._get_config(megatron_module) |
| 89 | + hf_weights = _reorder_qkv( |
| 90 | + hf_weights, self._sizes(config), self.checkpoint_tp_size, config.num_query_groups |
| 91 | + ) |
| 92 | + return super().hf_to_megatron(hf_weights, megatron_module) |
| 93 | + |
| 94 | + def megatron_to_hf( |
| 95 | + self, megatron_weights: torch.Tensor | None, megatron_module: nn.Module | None |
| 96 | + ) -> dict[str, torch.Tensor]: |
| 97 | + """Gather and restore the source checkpoint's fused row order.""" |
| 98 | + config = self._get_config(megatron_module) if megatron_module is not None else None |
| 99 | + shape = (self._sizes(config), config.num_query_groups) if config is not None else None |
| 100 | + shape = self.broadcast_obj_from_pp_rank(shape, "mimo_qkv_shape") |
| 101 | + result = super().megatron_to_hf(megatron_weights, megatron_module) |
| 102 | + return { |
| 103 | + name: _reorder_qkv(weight, shape[0], shape[1], self.checkpoint_tp_size) for name, weight in result.items() |
| 104 | + } |
| 105 | + |
| 106 | + def resolve(self, captures: tuple[str, ...]) -> "MiMoV2FusedQKVMapping": |
| 107 | + """Preserve checkpoint partitioning when resolving layer wildcards.""" |
| 108 | + megatron_param, hf_param = self._resolve_names(captures) |
| 109 | + return type(self)(megatron_param, hf_param, self.checkpoint_tp_size) |
| 110 | + |
| 111 | + |
| 112 | +@MegatronModelBridge.register_bridge( |
| 113 | + source="MiMoV2ForCausalLM", |
| 114 | + target=GPTModel, |
| 115 | + provider=MiMoV2FlashModelProvider, |
| 116 | + model_type="mimo_v2", |
| 117 | +) |
| 118 | +class MiMoV2Bridge(MiMoV2FlashBridge): |
| 119 | + """Text-backbone conversion for MiMo-V2.6-Flash-RL (309B total / 15B active). |
| 120 | +
|
| 121 | + Attention and MoE execution reuse MiMo-V2-Flash. Source-format export |
| 122 | + preserves the quantized representation and untouched multimodal/MTP weights. |
| 123 | + """ |
| 124 | + |
| 125 | + # The generic GPT builder cannot represent this family's custom attention. |
| 126 | + MODEL_CONFIG_CLASS = None |
| 127 | + |
| 128 | + _HF_PASSTHROUGH_PREFIXES = ("visual.", "audio_encoder.", "speech_embeddings.", "model.mtp.") |
| 129 | + |
| 130 | + def provider_bridge(self, hf_pretrained: PreTrainedCausalLM) -> MiMoV2FlashModelProvider: |
| 131 | + """Reuse the text provider and retain the full source configuration.""" |
| 132 | + provider = super().provider_bridge(hf_pretrained) |
| 133 | + if hf_pretrained.config.attention_projection_layout != "fused_qkv": |
| 134 | + raise ValueError("MiMoV2Bridge requires the fused_qkv checkpoint layout") |
| 135 | + provider.mimo_v2_hf_config = deepcopy(hf_pretrained.config.to_dict()) |
| 136 | + provider.mtp_num_layers = 0 |
| 137 | + # The reference router casts logits to FP32, regardless of metadata dtype. |
| 138 | + provider.moe_router_dtype = "fp32" |
| 139 | + provider.moe_router_bias_update_rate = 0.0 |
| 140 | + return provider |
| 141 | + |
| 142 | + @classmethod |
| 143 | + def megatron_to_hf_config(cls, provider: MiMoV2FlashModelProvider) -> dict[str, object]: |
| 144 | + """Preserve V2 architecture, quantization, and untrained encoder metadata.""" |
| 145 | + if provider.mimo_v2_hf_config is None: |
| 146 | + raise ValueError("MiMo-V2 export requires a provider created from the source HF configuration") |
| 147 | + return deepcopy(provider.mimo_v2_hf_config) |
| 148 | + |
| 149 | + def _source_tp_size(self) -> int: |
| 150 | + state = getattr(getattr(self, "hf_pretrained", None), "state", None) |
| 151 | + source = getattr(state, "source", None) |
| 152 | + if isinstance(source, SafeTensorsStateSource): |
| 153 | + return _checkpoint_tp_size(source.path / "model.safetensors.index.json") |
| 154 | + return 1 # In-memory HF models use canonical [Q,K,V]. |
| 155 | + |
| 156 | + def mapping_registry(self) -> MegatronMappingRegistry: |
| 157 | + """Reuse all text mappings except the newly fused QKV representation.""" |
| 158 | + mappings = [] |
| 159 | + for mapping in super().mapping_registry().mappings: |
| 160 | + if mapping.megatron_param.startswith("mtp."): |
| 161 | + continue |
| 162 | + if isinstance(mapping, MiMoV2FlashQKVMapping): |
| 163 | + mapping = MiMoV2FusedQKVMapping( |
| 164 | + mapping.megatron_param, |
| 165 | + mapping.hf_param["q"].replace(".q_proj.", ".qkv_proj."), |
| 166 | + self._source_tp_size(), |
| 167 | + ) |
| 168 | + mappings.append(mapping) |
| 169 | + return MegatronMappingRegistry(*mappings) |
| 170 | + |
| 171 | + @staticmethod |
| 172 | + def get_hf_import_param_names( |
| 173 | + hf_param: str | dict[str, str], available_hf_param_names: set[str] | None = None |
| 174 | + ) -> tuple[str, ...]: |
| 175 | + """Include MXFP4 scale sidecars in incremental import dependencies.""" |
| 176 | + names = list(MiMoV2FlashBridge.get_hf_import_param_names(hf_param, available_hf_param_names)) |
| 177 | + mapped = (hf_param,) if isinstance(hf_param, str) else hf_param.values() |
| 178 | + if available_hf_param_names is not None: |
| 179 | + names.extend(f"{name}_scale" for name in mapped if f"{name}_scale" in available_hf_param_names) |
| 180 | + return tuple(dict.fromkeys(names)) |
| 181 | + |
| 182 | + def _fp8_chunks( |
| 183 | + self, name: str, weight: torch.Tensor, scale: torch.Tensor |
| 184 | + ) -> Iterable[tuple[torch.Tensor, torch.Tensor]]: |
| 185 | + chunks = self._source_tp_size() if name.endswith(".qkv_proj.weight") else 1 |
| 186 | + if weight.shape[0] % chunks: |
| 187 | + raise ValueError(f"{name}: FP8 rows are not divisible by checkpoint tp_size={chunks}") |
| 188 | + block = quantization_utils.FP8_BLOCK_SIZE |
| 189 | + expected = ( |
| 190 | + chunks * ((weight.shape[0] // chunks + block - 1) // block), |
| 191 | + (weight.shape[1] + block - 1) // block, |
| 192 | + ) |
| 193 | + if tuple(scale.shape) != expected: |
| 194 | + raise ValueError(f"{name}: expected FP8 scales {expected}, got {tuple(scale.shape)}") |
| 195 | + return zip(weight.chunk(chunks, dim=0), scale.chunk(chunks, dim=0)) |
| 196 | + |
| 197 | + def maybe_modify_loaded_hf_weight( |
| 198 | + self, hf_param: str | dict[str, str], hf_state_dict: Mapping[str, torch.Tensor] |
| 199 | + ) -> torch.Tensor | dict[str, torch.Tensor]: |
| 200 | + """Decode MXFP4 experts and separately block-scaled FP8 QKV chunks.""" |
| 201 | + if isinstance(hf_param, dict): |
| 202 | + return {role: self.maybe_modify_loaded_hf_weight(name, hf_state_dict) for role, name in hf_param.items()} |
| 203 | + weight = hf_state_dict[hf_param] |
| 204 | + if weight.dtype == torch.uint8: |
| 205 | + return quantization_utils.dequantize_mxfp4_e2m1_packed(weight, hf_state_dict[f"{hf_param}_scale"]) |
| 206 | + if weight.dtype == torch.float8_e4m3fn: |
| 207 | + scale = hf_state_dict[f"{hf_param}_scale_inv"] |
| 208 | + return torch.cat( |
| 209 | + [ |
| 210 | + quantization_utils.dequantize_fp8_blockwise(w, s) |
| 211 | + for w, s in self._fp8_chunks(hf_param, weight, scale) |
| 212 | + ] |
| 213 | + ) |
| 214 | + return weight |
| 215 | + |
| 216 | + def maybe_modify_converted_hf_weight( |
| 217 | + self, |
| 218 | + task: WeightConversionTask, |
| 219 | + converted_weights_dict: dict[str, torch.Tensor], |
| 220 | + hf_state_dict: Mapping[str, torch.Tensor], |
| 221 | + ) -> dict[str, torch.Tensor]: |
| 222 | + """Restore source quantization after conversion and any LoRA merge.""" |
| 223 | + result = {} |
| 224 | + for name, weight in converted_weights_dict.items(): |
| 225 | + mxfp4_scale, fp8_scale = f"{name}_scale", f"{name}_scale_inv" |
| 226 | + if task.weight_dtype is not None and (mxfp4_scale in hf_state_dict or fp8_scale in hf_state_dict): |
| 227 | + raise ValueError( |
| 228 | + "MiMo source-format export preserves quantization; weight_dtype overrides are unsupported" |
| 229 | + ) |
| 230 | + if mxfp4_scale in hf_state_dict: |
| 231 | + packed, scale = quantization_utils.quantize_mxfp4_e2m1_like_scale( |
| 232 | + weight, hf_state_dict[mxfp4_scale], name=name |
| 233 | + ) |
| 234 | + result[name], result[mxfp4_scale] = packed.view(torch.uint8), scale |
| 235 | + elif fp8_scale in hf_state_dict: |
| 236 | + chunks = [ |
| 237 | + quantization_utils.quantize_fp8_e4m3fn_like_scale(w, s, name=name) |
| 238 | + for w, s in self._fp8_chunks(name, weight, hf_state_dict[fp8_scale]) |
| 239 | + ] |
| 240 | + result[name] = torch.cat([w for w, _ in chunks]) |
| 241 | + result[fp8_scale] = torch.cat([s for _, s in chunks]) |
| 242 | + else: |
| 243 | + result[name] = weight |
| 244 | + return result |
| 245 | + |
| 246 | + def _qkv_adapter_rows( |
| 247 | + self, megatron_model: list[MegatronModel], name: str, weight: torch.Tensor, target_chunks: int |
| 248 | + ) -> torch.Tensor: |
| 249 | + layer = int(name.split(".")[2]) |
| 250 | + config = unwrap_model(megatron_model)[0].config |
| 251 | + groups = ( |
| 252 | + config.swa_num_query_groups |
| 253 | + if config.hybrid_attention_pattern[layer] |
| 254 | + else config.full_attn_num_query_groups |
| 255 | + ) |
| 256 | + sizes = ( |
| 257 | + config.num_attention_heads * config.kv_channels, |
| 258 | + groups * config.kv_channels, |
| 259 | + groups * config.v_head_dim, |
| 260 | + ) |
| 261 | + return _reorder_qkv(weight, sizes, groups, target_chunks) |
| 262 | + |
| 263 | + def _get_fused_adapter_linear_out_slices( |
| 264 | + self, |
| 265 | + megatron_model: list[MegatronModel], |
| 266 | + base_hf_weight_names: list[str], |
| 267 | + linear_out_tensor: torch.Tensor, |
| 268 | + is_expert: bool = False, |
| 269 | + ) -> dict[str, torch.Tensor] | None: |
| 270 | + """Export adapter B in canonical HF Q/K/V order, independent of base storage.""" |
| 271 | + if len(base_hf_weight_names) == 1 and base_hf_weight_names[0].endswith(".self_attn.qkv_proj.weight"): |
| 272 | + name = base_hf_weight_names[0] |
| 273 | + return {name: self._qkv_adapter_rows(megatron_model, name, linear_out_tensor, 1)} |
| 274 | + return super()._get_fused_adapter_linear_out_slices( |
| 275 | + megatron_model, base_hf_weight_names, linear_out_tensor, is_expert=is_expert |
| 276 | + ) |
| 277 | + |
| 278 | + def _merge_lora_adapter_weights( |
| 279 | + self, |
| 280 | + megatron_model: list[MegatronModel], |
| 281 | + converted_weights_dict: dict[str, torch.Tensor], |
| 282 | + adapter_weights: list[AdapterWeight], |
| 283 | + ) -> dict[str, torch.Tensor]: |
| 284 | + """Merge fused QKV adapters in the source checkpoint's row order.""" |
| 285 | + names = list(converted_weights_dict) |
| 286 | + if len(names) != 1 or not names[0].endswith(".self_attn.qkv_proj.weight"): |
| 287 | + return super()._merge_lora_adapter_weights(megatron_model, converted_weights_dict, adapter_weights) |
| 288 | + (adapter,) = adapter_weights |
| 289 | + name = names[0] |
| 290 | + linear_out = self._qkv_adapter_rows( |
| 291 | + megatron_model, name, adapter.linear_out_weight.weight, self._source_tp_size() |
| 292 | + ) |
| 293 | + return { |
| 294 | + name: self._merge_single_adapter_weight( |
| 295 | + converted_weights_dict[name], adapter.alpha, adapter.dim, adapter.linear_in_weight.weight, linear_out |
| 296 | + ) |
| 297 | + } |
| 298 | + |
| 299 | + def stream_weights_megatron_to_hf( |
| 300 | + self, |
| 301 | + megatron_model: MegatronModel | list[MegatronModel], |
| 302 | + hf_pretrained: PreTrainedCausalLM, |
| 303 | + cpu: bool = True, |
| 304 | + show_progress: bool = True, |
| 305 | + conversion_tasks: list[WeightConversionTask] | None = None, |
| 306 | + *, |
| 307 | + merge_adapter_weights: bool = True, |
| 308 | + weight_dtype: torch.dtype | None = None, |
| 309 | + with_megatron_names: bool = False, |
| 310 | + ) -> Iterable[HFWeightTuple | HFSourcedWeightTuple]: |
| 311 | + """Export text updates while preserving untrained encoder and MTP tensors.""" |
| 312 | + if weight_dtype is not None and getattr(hf_pretrained.config, "quantization_config", None): |
| 313 | + raise ValueError( |
| 314 | + "MiMo source-format export preserves quantization; weight_dtype overrides are unsupported" |
| 315 | + ) |
| 316 | + yield from super().stream_weights_megatron_to_hf( |
| 317 | + megatron_model, |
| 318 | + hf_pretrained, |
| 319 | + cpu=cpu, |
| 320 | + show_progress=show_progress, |
| 321 | + conversion_tasks=conversion_tasks, |
| 322 | + merge_adapter_weights=merge_adapter_weights, |
| 323 | + weight_dtype=weight_dtype, |
| 324 | + with_megatron_names=with_megatron_names, |
| 325 | + ) |
| 326 | + state = hf_pretrained.state |
| 327 | + for name in state.source.get_all_keys(): |
| 328 | + if name.startswith(self._HF_PASSTHROUGH_PREFIXES): |
| 329 | + yield from HFWeightTuple(name, state[name]).iter_finalized( |
| 330 | + cpu=cpu, megatron_param_names=() if with_megatron_names else None |
| 331 | + ) |
0 commit comments