Files
esh-pfi-infrastructure/tools/mistral-small4-nvfp4/convert_hf_to_native.py
T
vh dd3a5c93fd feat(tools): Mistral Small 4 NVFP4 build pipeline (quant + HF->native converter)
Quantize a HF-format Mistral Small 4 (Mistral3ForConditionalGeneration MoE) to
NVFP4 with the vision tower intact, then convert HF NVFP4 -> Mistral native so
vLLM can serve it (there is no HF Mistral4 serving path in any vLLM version).

Built + validated end-to-end on ana-ml2 for the abliterated character-model
successor (darkc0de/Mistral-Small-4-119B-2603-heretic): quant -> dry-run (clean
vs the official native NVFP4 reference) -> convert -> serve-test (loads on the
native loader, correct text, vision functional).

Converter scaffold came from worldtree-codex (bf16 bin maps + fused-expert
split); fixed here: NVFP4 layer regexes (keep the `model.` prefix) + non-mmap
shard reads (ZFS large-mmap ENOMEM). nvfp4_quant.py is local. README documents
the pipeline + every gotcha that cost a failed run. Homed here per operator
direction (not Worldtree).
2026-06-17 22:19:58 -07:00

526 lines
18 KiB
Python

#!/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<layer>\d+)\.(?P<rest>.+)$"
)
TEXT_LAYER_NVFP4_RE = re.compile(
r"^model\.language_model\.model\.layers\.(?P<layer>\d+)\.(?P<rest>.+)$"
)
VISION_LAYER_BF16_RE = re.compile(
r"^model\.vision_tower\.transformer\.layers\.(?P<layer>\d+)\.(?P<rest>.+)$"
)
VISION_LAYER_NVFP4_RE = re.compile(
r"^model\.vision_tower\.transformer\.layers\.(?P<layer>\d+)\.(?P<rest>.+)$"
)
NVFP4_EXPERT_RE = re.compile(
r"^mlp\.experts\.(?P<expert>\d+)\."
r"(?P<proj>gate_proj|up_proj|down_proj)\."
r"(?P<artifact>weight_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())