ai-toolkit/scripts/convert_vllm_to_comfy.py

337 lines
12 KiB
Python

"""Convert a huggingface LLM / VLM checkpoint into a single ComfyUI-format
safetensors file, quantized with convrot8 (ComfyUI's ``int8_tensorwise`` +
``convrot`` layout, which comfy_kitchen runs natively and is bit-identical to
the toolkit's convrot8 storage).
ComfyUI ships its text-generation models (the CLIPLoader / TextGenerate path)
as one safetensors file holding the huggingface state dict, with quantized
layers marked two equivalent ways, both of which this script writes:
- a ``<layer>.comfy_quant`` uint8 JSON tensor baked into the state dict
(what comfy's _load_quantized_module and the toolkit's
comfy_quant_import read)
- a ``_quantization_metadata`` JSON blob in the safetensors file metadata
(comfy's documented checkpoint format; converted to the markers at load)
Quantized layer storage (per-output-row symmetric int8 on regular-Hadamard
rotated weights, rotation block = min(256, largest power-of-4 divisor of
in_features)):
<layer>.weight int8 [out, in] (or [E, out, in] for MoE banks)
<layer>.weight_scale fp32 [out] (or [E, out])
<layer>.comfy_quant {"format": "int8_tensorwise", "convrot": true,
"convrot_groupsize": G} (+ "num_experts": E for banks)
Per-expert MoE linears (``...experts.<i>.gate_proj/up_proj/down_proj.weight``)
are fused into the 3D banks comfy's ops.MoEExperts expects:
``...experts.gate_up_proj.weight`` ([E, 2I, H], gate rows first, matching how
transformers fuses the same checkpoints) and ``...experts.down_proj.weight``
([E, H, I]).
Note: the toolkit's own import_comfy_quantized_layers only understands 2D
linear/embedding markers, not the 3D expert banks — the banks are for comfy.
Usage:
python scripts/convert_vllm_to_comfy.py Qwen/Qwen3-Omni-30B-A3B-Instruct \
/path/out/qwen3_omni_30b_thinker_convrot8.safetensors
python scripts/convert_vllm_to_comfy.py <repo_or_local_dir> out.safetensors --no-quant
"""
import argparse
import json
import os
import re
import sys
import torch
from safetensors import safe_open
from safetensors.torch import save_file
from tqdm import tqdm
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from toolkit.util.convrot_quant import (
largest_pow4_divisor,
quantize_int8_rows,
rotate,
)
# convrot8 eligibility (mirrors ConvRotInt8Quantizer.can_quantize)
MAX_ROT = 256
MIN_ROT = 16
EXPERT_KEY_RE = re.compile(
r"^(?P<bank>.*\.experts)\.(?P<idx>\d+)\.(?P<proj>gate_proj|up_proj|down_proj)\.weight$"
)
def comfy_quant_marker(conf: dict) -> torch.Tensor:
return torch.tensor(list(json.dumps(conf).encode("utf-8")), dtype=torch.uint8)
class ArchHandler:
"""Generic model: keep every key, no expert fusion.
Subclasses override to drop components, rename keys, and add
architecture-specific quantization excludes.
"""
# substrings/suffixes never quantized: output head and embeddings are
# quality-critical, MoE router logits are precision-sensitive
exclude_contains = ("embed_tokens", "pos_embed", "lm_head")
exclude_suffixes = (".mlp.gate.weight",)
def __init__(self, config: dict):
self.config = config
def map_key(self, key: str):
"""Return the output key, or None to drop the tensor."""
return key
def num_experts(self):
for path in (
("num_experts",),
("text_config", "num_experts"),
("thinker_config", "text_config", "num_experts"),
):
node = self.config
for p in path:
if not isinstance(node, dict) or p not in node:
node = None
break
node = node[p]
if isinstance(node, int):
return node
return None
def is_excluded(self, key: str) -> bool:
if any(s in key for s in self.exclude_contains):
return True
return any(key.endswith(s) for s in self.exclude_suffixes)
class Qwen3OmniHandler(ArchHandler):
"""Qwen3-Omni: keep only the thinker (the VLM — talker and code2wav are
speech synthesis, dead weight for text generation) and strip its prefix
so keys start at model./visual./audio_tower./lm_head. like comfy's other
qwen checkpoints."""
def map_key(self, key: str):
if not key.startswith("thinker."):
return None
return key[len("thinker.") :]
ARCH_HANDLERS = {
"Qwen3OmniMoeForConditionalGeneration": Qwen3OmniHandler,
}
def resolve_model_dir(name_or_path: str) -> str:
if os.path.isdir(name_or_path):
return name_or_path
from huggingface_hub import snapshot_download
return snapshot_download(
name_or_path,
allow_patterns=["*.safetensors", "model.safetensors.index.json", "config.json"],
)
def shard_files(model_dir: str):
index_path = os.path.join(model_dir, "model.safetensors.index.json")
if os.path.exists(index_path):
with open(index_path, "r") as f:
index = json.load(f)
return sorted(set(index["weight_map"].values()))
single = os.path.join(model_dir, "model.safetensors")
if os.path.exists(single):
return ["model.safetensors"]
raise FileNotFoundError(f"No model.safetensors(.index.json) in {model_dir}")
def rot_for(in_features: int) -> int:
return min(MAX_ROT, largest_pow4_divisor(in_features))
def can_quantize_2d(out_features: int, in_features: int) -> bool:
return (
in_features % 16 == 0
and out_features % 8 == 0
and rot_for(in_features) >= MIN_ROT
)
@torch.no_grad()
def quantize_rows(weight: torch.Tensor, rot: int, device):
"""convrot8: per-row symmetric int8 on the rotated weight. Accepts
[out, in] or [E, out, in]; scales come back fp32 [out, 1] / [E, out, 1]
(the trailing 1 is comfy_kitchen's per-channel scale convention — its
int8 dequant broadcasts scale directly against [out, in])."""
shape = weight.shape
w = weight.to(device=device, dtype=torch.float32)
q, scales = quantize_int8_rows(rotate(w, rot).reshape(-1, shape[-1]))
return q.reshape(shape).cpu(), scales.reshape(shape[:-1] + (1,)).cpu()
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("model", help="hf repo id or local model directory")
parser.add_argument("output", help="output .safetensors path")
parser.add_argument(
"--no-quant",
action="store_true",
help="just repack to a single file, no quantization",
)
parser.add_argument(
"--dtype",
default="bf16",
choices=["bf16", "fp16", "fp32"],
help="dtype for non-quantized float tensors",
)
parser.add_argument(
"--extra-exclude",
default="",
help="comma-separated substrings of keys to keep unquantized",
)
parser.add_argument(
"--device",
default="cuda" if torch.cuda.is_available() else "cpu",
help="device to run quantization math on",
)
args = parser.parse_args()
dtype = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}[
args.dtype
]
extra_exclude = tuple(s for s in args.extra_exclude.split(",") if s.strip())
model_dir = resolve_model_dir(args.model)
with open(os.path.join(model_dir, "config.json"), "r") as f:
config = json.load(f)
handler_cls = ArchHandler
for arch in config.get("architectures", []):
if arch in ARCH_HANDLERS:
handler_cls = ARCH_HANDLERS[arch]
break
handler = handler_cls(config)
print(f"Architecture handler: {handler_cls.__name__}")
num_experts = handler.num_experts()
quantize = not args.no_quant
out_sd = {}
quant_layers = {}
# per-(bank, proj) accumulation of expert weights until all E arrive
pending_experts = {}
def add_quantized(layer: str, weight: torch.Tensor, conf_extra=None):
rot = rot_for(weight.shape[-1])
q, scales = quantize_rows(weight, rot, args.device)
conf = {"format": "int8_tensorwise", "convrot": True, "convrot_groupsize": rot}
if conf_extra:
conf.update(conf_extra)
out_sd[f"{layer}.weight"] = q
out_sd[f"{layer}.weight_scale"] = scales
out_sd[f"{layer}.comfy_quant"] = comfy_quant_marker(conf)
quant_layers[layer] = conf
def flush_bank(bank: str):
"""Fuse a completed expert group into comfy MoEExperts banks and
quantize them. gate/up fuse into one [E, 2I, H] bank (gate rows
first), down stays its own [E, H, I] bank."""
gate = pending_experts.pop((bank, "gate_proj"), None)
up = pending_experts.pop((bank, "up_proj"), None)
down = pending_experts.pop((bank, "down_proj"), None)
if (
gate is None
or up is None
or down is None
or None in gate
or None in up
or None in down
):
raise RuntimeError(f"Incomplete expert group for {bank}")
e = len(gate)
gate_up = torch.stack([torch.cat([g, u], dim=0) for g, u in zip(gate, up)])
down = torch.stack(down)
for name, bankw in (("gate_up_proj", gate_up), ("down_proj", down)):
layer = f"{bank}.{name}"
if quantize and can_quantize_2d(bankw.shape[1], bankw.shape[2]):
add_quantized(layer, bankw, conf_extra={"num_experts": e})
else:
out_sd[f"{layer}.weight"] = bankw.to(dtype)
def bank_complete(bank: str) -> bool:
counts = [
sum(w is not None for w in pending_experts.get((bank, p), []))
for p in ("gate_proj", "up_proj", "down_proj")
]
return num_experts is not None and counts == [num_experts] * 3
shards = shard_files(model_dir)
for shard in tqdm(shards, desc="Shards"):
with safe_open(
os.path.join(model_dir, shard), framework="pt", device="cpu"
) as f:
for key in f.keys():
out_key = handler.map_key(key)
if out_key is None:
continue
tensor = f.get_tensor(key)
m = EXPERT_KEY_RE.match(out_key)
if m is not None and quantize:
bank, idx, proj = (
m.group("bank"),
int(m.group("idx")),
m.group("proj"),
)
slot = pending_experts.setdefault(
(bank, proj), [None] * (num_experts or idx + 1)
)
if idx >= len(slot):
slot.extend([None] * (idx + 1 - len(slot)))
slot[idx] = tensor
if bank_complete(bank):
flush_bank(bank)
continue
excluded = handler.is_excluded(out_key) or any(
s in out_key for s in extra_exclude
)
if (
quantize
and not excluded
and out_key.endswith(".weight")
and tensor.ndim == 2
and can_quantize_2d(*tensor.shape)
):
add_quantized(out_key[: -len(".weight")], tensor)
elif tensor.is_floating_point():
out_sd[out_key] = tensor.to(dtype)
else:
out_sd[out_key] = tensor
# experts that never completed during streaming (unknown count or
# shard-straddling groups): fuse whatever is fully populated now
for bank in sorted({b for (b, _p) in pending_experts}):
flush_bank(bank)
metadata = {
"_quantization_metadata": json.dumps(
{"format_version": "1.0", "layers": quant_layers}
)
}
total_bytes = sum(t.numel() * t.element_size() for t in out_sd.values())
print(
f"Saving {len(out_sd)} tensors ({total_bytes / 1e9:.2f} GB, "
f"{len(quant_layers)} quantized layers) to {args.output}"
)
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
save_file(out_sd, args.output, metadata=metadata)
print("Done")
if __name__ == "__main__":
main()