#!/usr/bin/env python3 """Convert Mistral Small 4 HF weights to Mistral native consolidated format. The converter targets the BF16 path: HF Mistral3ForConditionalGeneration-style checkpoint -> Mistral native `params.json` + `consolidated*.safetensors` It copies native runtime assets from an official native reference directory, strips NVFP4 quantization config from `params.json`, rewrites tensor names, and splits fused HF MoE expert tensors into native per-expert weights. """ from __future__ import annotations import argparse import json import math import re import shutil from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path from typing import Any import torch from safetensors import safe_open from safetensors.torch import save_file TEXT_LAYER_BF16_RE = re.compile( r"^model\.language_model\.model\.layers\.(?P\d+)\.(?P.+)$" ) TEXT_LAYER_NVFP4_RE = re.compile( r"^model\.language_model\.model\.layers\.(?P\d+)\.(?P.+)$" ) VISION_LAYER_BF16_RE = re.compile( r"^model\.vision_tower\.transformer\.layers\.(?P\d+)\.(?P.+)$" ) VISION_LAYER_NVFP4_RE = re.compile( r"^model\.vision_tower\.transformer\.layers\.(?P\d+)\.(?P.+)$" ) NVFP4_EXPERT_RE = re.compile( r"^mlp\.experts\.(?P\d+)\." r"(?Pgate_proj|up_proj|down_proj)\." r"(?Pweight_packed|weight_scale|weight_global_scale|input_global_scale)$" ) TOP_LEVEL_MAP = { "model.language_model.model.embed_tokens.weight": "tok_embeddings.weight", "language_model.model.embed_tokens.weight": "tok_embeddings.weight", "model.language_model.model.norm.weight": "norm.weight", "language_model.model.norm.weight": "norm.weight", "language_model.lm_head.weight": "output.weight", "model.vision_tower.ln_pre.weight": "vision_encoder.ln_pre.weight", "vision_tower.ln_pre.weight": "vision_encoder.ln_pre.weight", "model.vision_tower.patch_conv.weight": "vision_encoder.patch_conv.weight", "vision_tower.patch_conv.weight": "vision_encoder.patch_conv.weight", "model.multi_modal_projector.linear_1.weight": "vision_language_adapter.w_in.weight", "multi_modal_projector.linear_1.weight": "vision_language_adapter.w_in.weight", "model.multi_modal_projector.linear_2.weight": "vision_language_adapter.w_out.weight", "multi_modal_projector.linear_2.weight": "vision_language_adapter.w_out.weight", "model.multi_modal_projector.norm.weight": "pre_mm_projector_norm.weight", "multi_modal_projector.norm.weight": "pre_mm_projector_norm.weight", "model.multi_modal_projector.patch_merger.merging_layer.weight": ( "patch_merger.merging_layer.weight" ), "multi_modal_projector.patch_merger.merging_layer.weight": ( "patch_merger.merging_layer.weight" ), } TEXT_LAYER_MAP = { "input_layernorm.weight": "attention_norm.weight", "post_attention_layernorm.weight": "ffn_norm.weight", "self_attn.q_a_proj.weight": "attention.wq_a.weight", "self_attn.q_b_proj.weight": "attention.wq_b.weight", "self_attn.q_a_layernorm.weight": "attention.q_a_norm.weight", "self_attn.kv_a_proj_with_mqa.weight": "attention.wkv_a_with_mqa.weight", "self_attn.kv_a_layernorm.weight": "attention.kv_a_norm.weight", "self_attn.kv_b_proj.weight": "attention.wkv_b.weight", "self_attn.o_proj.weight": "attention.wo.weight", "mlp.gate.weight": "gate.weight", "mlp.shared_experts.gate_proj.weight": "shared_experts.w1.weight", "mlp.shared_experts.up_proj.weight": "shared_experts.w3.weight", "mlp.shared_experts.down_proj.weight": "shared_experts.w2.weight", } VISION_LAYER_MAP = { "attention.q_proj.weight": "attention.wq.weight", "attention.k_proj.weight": "attention.wk.weight", "attention.v_proj.weight": "attention.wv.weight", "attention.o_proj.weight": "attention.wo.weight", "attention_norm.weight": "attention_norm.weight", "ffn_norm.weight": "ffn_norm.weight", "feed_forward.gate_proj.weight": "feed_forward.w1.weight", "feed_forward.up_proj.weight": "feed_forward.w3.weight", "feed_forward.down_proj.weight": "feed_forward.w2.weight", } NVFP4_EXPERT_PROJ_MAP = { "gate_proj": "w1", "down_proj": "w2", "up_proj": "w3", } ASSET_FILES = ( "params.json", "tekken.json", "tokenizer_config.json", "processor_config.json", "chat_template.jinja", ) @dataclass(frozen=True) class TensorRef: name: str shard: str @dataclass class ConvertStats: copied: int = 0 split: int = 0 bytes_written: int = 0 shards_written: int = 0 class ShardWriter: def __init__(self, out_dir: Path, max_shard_bytes: int) -> None: self.out_dir = out_dir self.max_shard_bytes = max_shard_bytes self.pending: dict[str, torch.Tensor] = {} self.pending_bytes = 0 self.weight_map: dict[str, str] = {} self.shard_paths: list[Path] = [] self.stats = ConvertStats() def add(self, name: str, tensor: torch.Tensor) -> None: owned = tensor.detach().cpu().contiguous() size = tensor_nbytes(owned) if self.pending and self.pending_bytes + size > self.max_shard_bytes: self.flush() self.pending[name] = owned self.pending_bytes += size def flush(self) -> None: if not self.pending: return shard_idx = len(self.shard_paths) + 1 shard_name = f"consolidated-{shard_idx:05d}.safetensors" shard_path = self.out_dir / shard_name save_file(self.pending, shard_path) for name in self.pending: self.weight_map[name] = shard_name self.stats.bytes_written += self.pending_bytes self.stats.shards_written += 1 self.shard_paths.append(shard_path) self.pending = {} self.pending_bytes = 0 def write_index(self) -> None: self.flush() total_size = sum(path.stat().st_size for path in self.shard_paths) index = { "metadata": {"total_size": total_size}, "weight_map": dict(sorted(self.weight_map.items())), } (self.out_dir / "consolidated.safetensors.index.json").write_text( json.dumps(index, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) def tensor_nbytes(tensor: torch.Tensor) -> int: return tensor.numel() * tensor.element_size() def parse_size_gib(value: str) -> int: size = float(value) if not math.isfinite(size) or size <= 0: raise argparse.ArgumentTypeError("--max-shard-size-gb must be positive") return int(size * 1024**3) def load_weight_map(hf_dir: Path) -> list[TensorRef]: index_path = hf_dir / "model.safetensors.index.json" data = json.loads(index_path.read_text(encoding="utf-8")) weight_map = data.get("weight_map") if not isinstance(weight_map, dict): raise ValueError(f"{index_path} does not contain a weight_map object") return [TensorRef(name=name, shard=shard) for name, shard in sorted(weight_map.items())] _SHARD_CACHE: dict = {} def read_tensor(hf_dir: Path, ref: TensorRef) -> torch.Tensor: # Non-mmap shard read: safetensors safe_open() mmaps the whole shard, which # ENOMEMs on /tank (ZFS) for large shards (the 50 GB NVFP4 shard) regardless of # free RAM or overcommit (a MAP_SHARED file mmap never consults the commit limit). # Read the shard with a plain read() and deserialize from the in-memory buffer # instead. Caches one shard at a time, so the copy loop must iterate refs grouped # by shard (see the sorted(...) in convert()). global _SHARD_CACHE if _SHARD_CACHE.get("shard") != ref.shard: from safetensors.torch import load as _st_load with open(hf_dir / ref.shard, "rb") as _fh: _SHARD_CACHE = {"shard": ref.shard, "tensors": _st_load(_fh.read())} return _SHARD_CACHE["tensors"][ref.name] def write_assets(native_ref_dir: Path, out_dir: Path, *, keep_quantization_config: bool) -> None: for filename in ASSET_FILES: src = native_ref_dir / filename if not src.exists(): continue if filename == "params.json": params = json.loads(src.read_text(encoding="utf-8")) if not keep_quantization_config: params.pop("quantization_config", None) (out_dir / filename).write_text( json.dumps(params, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) else: shutil.copy2(src, out_dir / filename) def mapped_name(name: str, *, output_format: str) -> str | None: if name in TOP_LEVEL_MAP: return TOP_LEVEL_MAP[name] text_match = ( TEXT_LAYER_NVFP4_RE.match(name) if output_format == "nvfp4" else TEXT_LAYER_BF16_RE.match(name) ) if text_match: layer = text_match.group("layer") rest = text_match.group("rest") suffix = TEXT_LAYER_MAP.get(rest) if suffix is not None: return f"layers.{layer}.{suffix}" if output_format == "nvfp4": expert_match = NVFP4_EXPERT_RE.match(rest) if expert_match: expert = expert_match.group("expert") native_proj = NVFP4_EXPERT_PROJ_MAP[expert_match.group("proj")] artifact = expert_match.group("artifact") return f"layers.{layer}.experts.{expert}.{native_proj}.{artifact}" for hf_proj, native_proj in NVFP4_EXPERT_PROJ_MAP.items(): prefix = f"mlp.shared_experts.{hf_proj}." if rest.startswith(prefix): artifact = rest.removeprefix(prefix) if artifact in { "weight_packed", "weight_scale", "weight_global_scale", "input_global_scale", }: return f"layers.{layer}.shared_experts.{native_proj}.{artifact}" return None vision_match = ( VISION_LAYER_NVFP4_RE.match(name) if output_format == "nvfp4" else VISION_LAYER_BF16_RE.match(name) ) if vision_match: layer = vision_match.group("layer") rest = vision_match.group("rest") suffix = VISION_LAYER_MAP.get(rest) if suffix is not None: return f"vision_encoder.transformer.layers.{layer}.{suffix}" return None return None def split_experts( *, writer: ShardWriter, layer: int, gate_up: torch.Tensor, down: torch.Tensor, expert_hidden_dim: int, ) -> None: if gate_up.ndim != 3: raise ValueError(f"layer {layer}: gate_up_proj must be rank 3, got {gate_up.shape}") if down.ndim != 3: raise ValueError(f"layer {layer}: down_proj must be rank 3, got {down.shape}") if gate_up.shape[1] != expert_hidden_dim * 2: raise ValueError( f"layer {layer}: gate_up second dim {gate_up.shape[1]} != " f"2 * expert_hidden_dim {expert_hidden_dim}" ) if gate_up.shape[0] != down.shape[0]: raise ValueError( f"layer {layer}: gate_up experts {gate_up.shape[0]} != down experts {down.shape[0]}" ) for expert in range(gate_up.shape[0]): writer.add( f"layers.{layer}.experts.{expert}.w1.weight", gate_up[expert, :expert_hidden_dim, :].clone(), ) writer.add( f"layers.{layer}.experts.{expert}.w3.weight", gate_up[expert, expert_hidden_dim:, :].clone(), ) writer.add(f"layers.{layer}.experts.{expert}.w2.weight", down[expert].clone()) def text_layer_id(name: str, suffix: str) -> int | None: match = TEXT_LAYER_BF16_RE.match(name) if match and match.group("rest") == suffix: return int(match.group("layer")) return None def convert( *, hf_dir: Path, native_ref_dir: Path, out_dir: Path, max_shard_bytes: int, expert_hidden_dim: int, output_format: str, dry_run: bool, ) -> dict[str, Any]: refs = load_weight_map(hf_dir) if output_format == "bf16": gate_up_by_layer = { layer: ref for ref in refs if (layer := text_layer_id(ref.name, "mlp.experts.gate_up_proj")) is not None } down_by_layer = { layer: ref for ref in refs if (layer := text_layer_id(ref.name, "mlp.experts.down_proj")) is not None } else: gate_up_by_layer = {} down_by_layer = {} unmapped: list[str] = [] mapped: dict[str, str] = {} split_layers = sorted(set(gate_up_by_layer) | set(down_by_layer)) for ref in refs: if output_format == "bf16" and ( text_layer_id(ref.name, "mlp.experts.gate_up_proj") is not None or text_layer_id(ref.name, "mlp.experts.down_proj") is not None ): continue native_name = mapped_name(ref.name, output_format=output_format) if native_name is None: unmapped.append(ref.name) else: mapped[ref.name] = native_name if dry_run: return { "mapped_tensors": len(mapped), "split_layers": split_layers, "unmapped": unmapped, "native_reference_diff": reference_name_diff( native_ref_dir=native_ref_dir, candidate_names=predicted_native_names(mapped.values(), split_layers), output_format=output_format, ), } out_dir.mkdir(parents=True, exist_ok=True) if any(out_dir.iterdir()): raise FileExistsError(f"output directory is not empty: {out_dir}") write_assets( native_ref_dir, out_dir, keep_quantization_config=output_format == "nvfp4", ) writer = ShardWriter(out_dir=out_dir, max_shard_bytes=max_shard_bytes) for ref in sorted(refs, key=lambda r: (r.shard, r.name)): if ref.name not in mapped: continue writer.add(mapped[ref.name], read_tensor(hf_dir, ref)) writer.stats.copied += 1 for layer in split_layers: gate_ref = gate_up_by_layer.get(layer) down_ref = down_by_layer.get(layer) if gate_ref is None or down_ref is None: raise ValueError(f"layer {layer}: missing gate_up or down fused expert tensor") split_experts( writer=writer, layer=layer, gate_up=read_tensor(hf_dir, gate_ref), down=read_tensor(hf_dir, down_ref), expert_hidden_dim=expert_hidden_dim, ) writer.stats.split += 1 writer.write_index() report = { "copied_tensors": writer.stats.copied, "split_layers": writer.stats.split, "shards_written": writer.stats.shards_written, "bytes_written": writer.stats.bytes_written, "unmapped": unmapped, "native_reference_diff": reference_name_diff( native_ref_dir=native_ref_dir, candidate_names=writer.weight_map.keys(), output_format=output_format, ), } (out_dir / "conversion_report.json").write_text( json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) return report def predicted_native_names(mapped_names: Iterable[str], split_layers: Iterable[int]) -> set[str]: names = set(mapped_names) for layer in split_layers: for expert in range(128): for weight in ("w1", "w2", "w3"): names.add(f"layers.{layer}.experts.{expert}.{weight}.weight") return names def iter_native_reference_names(native_ref_dir: Path, *, output_format: str) -> Iterable[str]: index_path = native_ref_dir / "consolidated.safetensors.index.json" if not index_path.exists(): return () data = json.loads(index_path.read_text(encoding="utf-8")) names = data.get("weight_map", {}).keys() if output_format == "nvfp4": return set(names) normalized = set() for name in names: if name.endswith(".weight_packed"): normalized.add(name.removesuffix("weight_packed") + "weight") elif not ( name.endswith(".weight_scale") or name.endswith(".weight_global_scale") or name.endswith(".input_global_scale") ): normalized.add(name) return normalized def reference_name_diff( *, native_ref_dir: Path, candidate_names: Iterable[str], output_format: str ) -> dict[str, list[str]]: reference_names = set( iter_native_reference_names(native_ref_dir, output_format=output_format) ) if not reference_names: return {"missing_from_output": [], "extra_in_output": []} candidates = set(candidate_names) return { "missing_from_output": sorted(reference_names - candidates), "extra_in_output": sorted(candidates - reference_names), } def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--hf-dir", type=Path, required=True) parser.add_argument("--native-ref-dir", type=Path, required=True) parser.add_argument("--out-dir", type=Path, required=True) parser.add_argument( "--max-shard-size-gb", type=parse_size_gib, default=parse_size_gib("20"), help="Approximate max safetensors shard size in GiB before flushing.", ) parser.add_argument( "--expert-hidden-dim", type=int, default=2048, help="Mistral Small 4 routed expert hidden dim used to split gate_up_proj.", ) parser.add_argument( "--format", choices=("bf16", "nvfp4"), default="bf16", help="Output checkpoint format. BF16 splits fused HF experts; NVFP4 renames per-expert quant artifacts.", ) parser.add_argument( "--dry-run", action="store_true", help="Only report mapped/unmapped tensors; do not read or write weight shards.", ) return parser def main() -> int: args = build_parser().parse_args() report = convert( hf_dir=args.hf_dir, native_ref_dir=args.native_ref_dir, out_dir=args.out_dir, max_shard_bytes=args.max_shard_size_gb, expert_hidden_dim=args.expert_hidden_dim, output_format=args.format, dry_run=args.dry_run, ) print(json.dumps(report, indent=2, sort_keys=True)) return 1 if report.get("unmapped") else 0 if __name__ == "__main__": raise SystemExit(main())