FastH3-Omni-FP8-Comfy / conversion /quantize_native_comfy.py
aryan5v's picture
Add native ComfyUI conversion, provenance, and validation
2de4138 verified
Raw History Blame Contribute Delete
10.9 kB
#!/usr/bin/env python3
"""Quantize a native ComfyUI single-file OmniRef checkpoint to native FP8 or NVFP4.
The input is the native ComfyUI layout produced by ``convert_omniref_comfy.py``:
Q/K/V are already fused into ``attn.qkv_proj`` and the PDD output heads use the
native first-head-plus-offset representation. The quantizer therefore sees one
fused QKV tensor and applies one Comfy weight scale to it, exactly as the
pinned ComfyUI runtime expects.
Only the per-block attention/MLP projections are quantized. The token refiner,
AdaLN curves and projections, patch/condition/output projections, norms, RoPE,
and FP32 PDD heads stay dense. This matches the native ComfyUI MiniMax-H3
quantized-layer policy used by the released V2/Trim Comfy checkpoints.
Writes a JSON receipt with source/output hashes, the exact format markers,
per-linear reconstruction metrics, and runtime versions.
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import importlib.metadata
import json
from pathlib import Path
import re
import sys
import time
import torch
# Native Comfy MiniMax-H3 block linears. qkv_proj is the fused Q/K/V module.
QUANT_LINEAR = re.compile(
r"^blocks\.\d+\.(?:attn\.(?:qkv_proj|out_proj|to_gate_compress)|mlp\.(?:fc1|fc2))\.weight$"
)
FORMATS = {
"fp8": {
"layout": "TensorCoreFP8Layout",
"tag": {"format": "float8_e4m3fn"},
"documented_name": "FP8 E4M3, per-tensor weight scale, dynamic per-tensor activation scale",
"activation": "dynamic-per-tensor",
},
"nvfp4": {
"layout": "TensorCoreNVFP4Layout",
"tag": {"format": "nvfp4"},
"documented_name": "NVFP4 E2M1, per-tensor global scale plus FP8 block scales (group 16), dynamic per-tensor activation scale",
"activation": "dynamic-per-tensor",
},
}
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
while True:
block = handle.read(64 * 1024 * 1024)
if not block:
break
digest.update(block)
return digest.hexdigest()
def tensor_digest(tensor: torch.Tensor) -> str:
return hashlib.sha256(tensor.detach().cpu().contiguous().view(torch.uint8).numpy().tobytes()).hexdigest()
def safetensors_header(path: Path) -> dict:
from safetensors import safe_open
with safe_open(str(path), framework="pt", device="cpu") as handle:
keys = list(handle.keys())
metadata = handle.metadata() or {}
return {"tensor_count": len(keys), "metadata": metadata, "keys": keys}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--src", required=True, type=Path, help="Native ComfyUI BF16 single-file checkpoint")
parser.add_argument("--dst", required=True, type=Path, help="Output quantized single-file checkpoint")
parser.add_argument("--format", required=True, choices=sorted(FORMATS))
parser.add_argument("--receipt", required=True, type=Path)
parser.add_argument("--comfyui-commit", default="b1a247c9cf9dbfda4b2c0a0c1d50950667eeaa0c")
parser.add_argument("--expect-tensors", type=int, default=None)
args = parser.parse_args()
if args.src == args.dst:
raise SystemExit("source and destination must differ")
if args.dst.exists():
raise SystemExit(f"refusing to overwrite existing output: {args.dst}")
if not torch.cuda.is_available():
raise SystemExit("CUDA is required for comfy-kitchen quantization")
import comfy_kitchen
import comfy_kitchen.tensor as ckt
from safetensors import safe_open
from safetensors.torch import load_file, save_file
started = time.time()
spec = FORMATS[args.format]
layout_cls = getattr(ckt, spec["layout"])
tag_bytes = json.dumps(spec["tag"], separators=(",", ":")).encode()
print(f"[quantize] format={args.format} src={args.src} dst={args.dst}", flush=True)
src_header = safetensors_header(args.src)
if args.expect_tensors is not None and src_header["tensor_count"] != args.expect_tensors:
raise SystemExit(f"expected {args.expect_tensors} tensors, found {src_header['tensor_count']}")
print(f"[quantize] source tensors={src_header['tensor_count']} hashing source", flush=True)
src_sha256 = sha256_file(args.src)
# The native Comfy BF16 export is ~44 GB. Keeping the pass-through tensors
# plus the quantized output in CPU RAM stays well under a GB200 node's RAM.
source = load_file(str(args.src), device="cpu")
metadata = dict(src_header["metadata"])
metadata["quantization"] = json.dumps(
{
"format": spec["tag"]["format"],
"layout": spec["layout"],
"weights": "per-tensor global scale" if args.format == "fp8" else "per-tensor global scale plus FP8 block scales, group 16",
"activations": spec["activation"],
"quantized_layers": "all 50 DiT blocks: fused qkv_proj, out_proj, to_gate_compress, mlp.fc1, mlp.fc2",
"dense_layers": "token_refiner, AdaLN, norms, embeddings, patch/condition projections, FP32 PDD heads",
"qkv_treatment": "fused Q/K/V quantized as one tensor under one weight scale",
"comfyui_commit": args.comfyui_commit,
},
separators=(",", ":"),
)
metadata["quantization_source_sha256"] = src_sha256
metadata["quantization_recipe"] = "omniref-comfy-ptq-v1"
output: dict[str, torch.Tensor] = {}
linears = []
for index, (name, tensor) in enumerate(source.items(), 1):
if not QUANT_LINEAR.match(name):
output[name] = tensor
continue
weight = tensor.to(device="cuda", dtype=torch.bfloat16).contiguous()
qdata, params = layout_cls.quantize(weight)
serialized = layout_cls.state_dict_tensors(qdata, params)
for suffix, value in serialized.items():
output[name + suffix] = value.detach().cpu().contiguous()
output[name[: -len(".weight")] + ".comfy_quant"] = torch.tensor(list(tag_bytes), dtype=torch.uint8)
dequantized = layout_cls.dequantize(qdata, params).float()
reference = weight.float()
diff = dequantized - reference
rel_l2 = float(diff.norm() / reference.norm().clamp(min=1e-30))
cosine = float(torch.nn.functional.cosine_similarity(dequantized.flatten(), reference.flatten(), dim=0))
entry = {
"name": name,
"logical_shape": list(tensor.shape),
"weight_storage_shape": list(serialized[""].shape),
"weight_storage_dtype": str(serialized[""].dtype).replace("torch.", ""),
"reconstruction_relative_l2": rel_l2,
"reconstruction_cosine": cosine,
"reconstruction_max_abs": float(diff.abs().max()),
"source_amax": float(reference.abs().max()),
}
if args.format == "fp8":
entry["weight_scale"] = float(params.scale.reshape(-1)[0])
else:
entry["weight_scale_2"] = float(params.scale.reshape(-1)[0])
entry["weight_scale_shape"] = list(serialized["_scale"].shape)
entry["weight_scale_dtype"] = str(serialized["_scale"].dtype).replace("torch.", "")
entry["block_scale_min"] = float(serialized["_scale"].to(torch.float32).min())
entry["block_scale_max"] = float(serialized["_scale"].to(torch.float32).max())
linears.append(entry)
print(
f"[quantize] {index:4d}/{len(source)} {name} relL2={rel_l2:.6f} cos={cosine:.8f}",
flush=True,
)
del weight, qdata, params, dequantized, reference, diff
torch.cuda.empty_cache()
expected_linears = 50 * 5
if len(linears) != expected_linears:
raise SystemExit(f"expected {expected_linears} quantized linears, got {len(linears)}")
print(f"[quantize] writing {len(output)} tensors to {args.dst}", flush=True)
args.dst.parent.mkdir(parents=True, exist_ok=True)
save_file(output, str(args.dst), metadata=metadata)
out_sha256 = sha256_file(args.dst)
out_header = safetensors_header(args.dst)
# Pass-through tensors must survive byte-for-byte. Compare every dense tensor
# from the written file against the source file, not against the in-memory copy.
pass_through = [name for name in source if not QUANT_LINEAR.match(name)]
identical = 0
with safe_open(str(args.src), framework="pt", device="cpu") as src_reader, safe_open(
str(args.dst), framework="pt", device="cpu"
) as dst_reader:
for name in pass_through:
left = src_reader.get_tensor(name)
right = dst_reader.get_tensor(name)
if left.dtype != right.dtype or left.shape != right.shape or not torch.equal(left, right):
raise SystemExit(f"pass-through tensor changed: {name}")
identical += 1
receipt = {
"schema": "omniref-comfy-quant-receipt-v1",
"created_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
"format": args.format,
"format_description": spec["documented_name"],
"source": {
"path": str(args.src),
"bytes": args.src.stat().st_size,
"sha256": src_sha256,
"tensor_count": src_header["tensor_count"],
"metadata": src_header["metadata"],
},
"output": {
"path": str(args.dst),
"bytes": args.dst.stat().st_size,
"sha256": out_sha256,
"tensor_count": out_header["tensor_count"],
},
"runtime": {
"torch": torch.__version__,
"cuda": torch.version.cuda,
"gpu": torch.cuda.get_device_name(0),
"comfy_kitchen": importlib.metadata.version("comfy-kitchen"),
"comfyui_commit": args.comfyui_commit,
},
"policy": {
"quantized_linears": "all 50 DiT blocks: fused qkv_proj, out_proj, to_gate_compress, mlp.fc1, mlp.fc2",
"dense_linears": "token refiner, AdaLN, norms, embeddings, patch/condition projections, FP32 PDD heads",
"qkv": "fused by the native Comfy conversion, then quantized as one tensor with one weight scale",
"activation_quantization": spec["activation"],
"native_comfy_layout": spec["layout"],
},
"quantized_linear_count": len(linears),
"quantized_linears": linears,
"pass_through_tensor_count": identical,
"pass_through_byte_identical": True,
"elapsed_seconds": round(time.time() - started, 1),
}
args.receipt.parent.mkdir(parents=True, exist_ok=True)
args.receipt.write_text(json.dumps(receipt, indent=2) + "\n")
print(json.dumps({k: receipt[k] for k in ("format", "output", "quantized_linear_count", "elapsed_seconds")}, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())