"""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 ``.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)): .weight int8 [out, in] (or [E, out, in] for MoE banks) .weight_scale fp32 [out] (or [E, out]) .comfy_quant {"format": "int8_tensorwise", "convrot": true, "convrot_groupsize": G} (+ "num_experts": E for banks) Per-expert MoE linears (``...experts..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 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.*\.experts)\.(?P\d+)\.(?Pgate_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()