Files
ai-toolkit/toolkit/util/comfy_quant_export.py
Jaret Burkett 9113420b61 Phase 2
2026-08-27 15:37:13 -06:00

105 lines
4.4 KiB
Python

"""Export toolkit-quantized modules into ComfyUI ``comfy_quant`` checkpoints —
the inverse of toolkit/util/comfy_quant_import.py.
Every quantized OstrisLinear whose backend has a comfy storage format emits
its quantized tensors plus the ``<prefix>.comfy_quant`` uint8 JSON marker:
- convrot8 (int8_tensorwise + convrot): weight int8 [out, in], fp32
weight_scale [out, 1] (comfy_kitchen's per-channel convention)
- nvfp4: high-nibble-first packed fp4 pairs, e4m3 block scales re-swizzled
to the cuBLAS 128x4 tile layout, fp32 weight_scale_2 per-tensor scale and
optional AWQ pre_quant_scale
- float8_e4m3fn: fp8_e4m3 weight + one fp32 per-tensor weight_scale
- convrotcomfyw4a4: via convrot_quant.export_comfy_convrot_w4a4
Biases are NOT emitted here — they are ordinary parameters and flow through
the regular state_dict path.
"""
import json
from typing import Dict, List, Tuple
import torch
from toolkit.util.ostris_quant import OstrisLinear
def comfy_quant_marker(conf: dict) -> torch.Tensor:
return torch.tensor(list(json.dumps(conf).encode("utf-8")), dtype=torch.uint8)
@torch.no_grad()
def export_comfy_quantized_layers(
root: torch.nn.Module,
) -> Tuple[Dict[str, torch.Tensor], List[str], List[str]]:
"""Comfy-format state-dict entries for every quantized OstrisLinear in
``root``. Returns ``(entries, exported_names, unexportable_names)`` —
entries are keyed by module path in root's layout (run
convert_state_dict_on_save afterwards for the checkpoint layout);
unexportable_names lists quantized modules whose backend has no comfy
storage format (the caller decides whether to dequantize instead)."""
from toolkit.util.convrot_quant import (
ConvRotComfyW4A4Quantizer,
export_comfy_convrot_w4a4,
)
from toolkit.util.nvfp4_quant import swap_nvfp4_nibbles, swizzle_nvfp4_scales
entries: Dict[str, torch.Tensor] = {}
exported: List[str] = []
unexportable: List[str] = []
for name, module in root.named_modules():
if not isinstance(module, OstrisLinear):
continue
if hasattr(module, "cr8_qdata"):
rot = int(getattr(module, "cr8_rot_size", 1) or 1)
conf = {"format": "int8_tensorwise"}
if rot > 1:
conf.update({"convrot": True, "convrot_groupsize": rot})
entries[f"{name}.weight"] = module.cr8_qdata.detach().cpu().contiguous()
entries[f"{name}.weight_scale"] = (
module.cr8_scales.view(torch.float32)
.detach()
.cpu()
.reshape(module.out_features, 1)
.contiguous()
)
entries[f"{name}.comfy_quant"] = comfy_quant_marker(conf)
elif hasattr(module, "nv4_qdata"):
entries[f"{name}.weight"] = swap_nvfp4_nibbles(
module.nv4_qdata.detach().cpu()
)
scales = module.nv4_scales.view(torch.float8_e4m3fn).detach().cpu()
entries[f"{name}.weight_scale"] = swizzle_nvfp4_scales(
scales.reshape(module.out_features, module.in_features // 16)
).view(torch.float8_e4m3fn)
entries[f"{name}.weight_scale_2"] = (
module.nv4_pts.view(torch.float32).detach().cpu().reshape(())
)
if hasattr(module, "nv4_pre_scale"):
entries[f"{name}.pre_quant_scale"] = (
module.nv4_pre_scale.view(torch.float32).detach().cpu().contiguous()
)
entries[f"{name}.comfy_quant"] = comfy_quant_marker({"format": "nvfp4"})
elif hasattr(module, "f8_qdata"):
entries[f"{name}.weight"] = module.f8_qdata.detach().cpu().contiguous()
entries[f"{name}.weight_scale"] = (
module.f8_scale.view(torch.float32).detach().cpu().reshape(())
)
entries[f"{name}.comfy_quant"] = comfy_quant_marker(
{"format": "float8_e4m3fn", "full_precision_matrix_mult": True}
)
elif isinstance(module.ostris_quantizer, ConvRotComfyW4A4Quantizer):
layer_entries = export_comfy_convrot_w4a4(module, f"{name}.")
layer_entries.pop(f"{name}.bias", None)
entries.update(
{k: v.detach().cpu() if torch.is_tensor(v) else v for k, v in layer_entries.items()}
)
else:
unexportable.append(name)
continue
exported.append(name)
return entries, exported, unexportable