Files
SparknightLLC-ComfyUI-INT8-…/int8_model_save.py
T
ThereforeGames 722dadcbc5 Rebrand, add INT4 support & quantization modes
Rebrand the project as "ComfyUI Quantization Toolkit" and introduce native INT4 (ConvRot W4A4) support, mixed INT4/INT8 modes, and expanded quantization policies. Add an INT4 torch.compile compatibility shim, INT4-aware lazy-compile changes, richer LoRA stack nodes with autogrow, and a Quantized LoRA entry/apply pair. Replace ad-hoc prints with structured logging, bump package metadata to 2.0.0, and add documentation (advanced usage, checkpoints, policies) plus many unit tests for compile, lazy-compile, LoRA, adapter caching, saving, and modes. Misc: kernel config, model save/load, and numerous runtime/adapter refactors and robustness fixes.
2026-08-04 07:11:56 -04:00

406 lines
12 KiB
Python

import json
import logging
import os
from contextlib import contextmanager
import comfy.sd
import comfy.utils
import folder_paths
import torch
from comfy.cli_args import args
try:
from comfy.quant_ops import QuantizedTensor
except (ImportError, AttributeError):
QuantizedTensor = None
_SUPPORTED_QUANTIZATION_FORMATS = {"int8_tensorwise", "convrot_w4a4"}
def _install_lazy_casting_param_workaround():
try:
import comfy.model_patcher as comfy_model_patcher
except Exception:
return None
lazy_param_classes = [
getattr(comfy_model_patcher, "LazyCastingParam", None),
getattr(comfy_model_patcher, "LazyCastingParamPiece", None),
]
original_new_methods = []
def _safe_new(cls, *args, **kwargs):
tensor = kwargs.get("tensor", None)
if tensor is None and args:
tensor = args[-1]
requires_grad = bool(
isinstance(tensor, torch.Tensor)
and (tensor.is_floating_point() or tensor.is_complex())
)
return torch.nn.Parameter.__new__(cls, tensor, requires_grad=requires_grad)
for lazy_param_cls in lazy_param_classes:
if lazy_param_cls is None:
continue
original_new = getattr(lazy_param_cls, "__new__", None)
if original_new is None:
continue
original_new_methods.append((lazy_param_cls, original_new))
lazy_param_cls.__new__ = staticmethod(_safe_new)
if not original_new_methods:
return None
def _restore():
for lazy_param_cls, original_new in original_new_methods:
lazy_param_cls.__new__ = original_new
return _restore
def _is_int8_quantized_module(module):
if not getattr(module, "_is_quantized", False):
return False
if getattr(module, "_quant_format", None) == "convrot_w4a4":
return True
weight = getattr(module, "weight", None)
if not isinstance(weight, torch.Tensor):
return False
return weight.dtype == torch.int8
def _is_supported_quantized_module(module):
if _is_int8_quantized_module(module):
return True
quant_format = getattr(module, "quant_format", None)
if quant_format not in _SUPPORTED_QUANTIZATION_FORMATS:
return False
weight = getattr(module, "weight", None)
return QuantizedTensor is not None and isinstance(weight, QuantizedTensor)
def _model_contains_supported_quantization(model_patcher):
base_model = getattr(model_patcher, "model", None)
if base_model is not None and hasattr(base_model, "modules"):
if any(_is_supported_quantized_module(module) for module in base_model.modules()):
return True
for object_patch_map_name in ("object_patches", "object_patches_backup"):
object_patch_map = getattr(model_patcher, object_patch_map_name, None)
if not isinstance(object_patch_map, dict):
continue
if any(_is_supported_quantized_module(module) for module in object_patch_map.values()):
return True
return False
def _validate_quantized_model(model_patcher):
if not _model_contains_supported_quantization(model_patcher):
raise ValueError(
"Save Quantized Model requires a MODEL containing Toolkit INT8 or native ConvRot INT4 weights. "
"Place Enable Quantization on MODEL before Load LoRA (Quantized) and this save node."
)
get_attachment = getattr(model_patcher, "get_attachment", None)
if not callable(get_attachment):
return
lora_signature = get_attachment("int8_lora_signature")
if not isinstance(lora_signature, tuple):
return
if any(isinstance(entry, tuple) and entry and entry[0] == "Dynamic" for entry in lora_signature):
raise ValueError(
"Save Quantized Model cannot serialize Dynamic LoRAs because they are runtime-only. "
"Use Stochastic mode, or bake stock LoRAs before quantization."
)
def _module_has_non_float_weight(module):
if getattr(module, "_quant_format", None) == "convrot_w4a4":
return True
weight = getattr(module, "weight", None)
if not isinstance(weight, torch.Tensor):
return False
if weight.is_floating_point() or weight.is_complex():
return False
return True
def _resolve_source_metadata(model_patcher):
seen_patcher_ids = set()
current_patcher = model_patcher
while current_patcher is not None and id(current_patcher) not in seen_patcher_ids:
seen_patcher_ids.add(id(current_patcher))
metadata = getattr(current_patcher, "_safetensors_metadata", None)
if isinstance(metadata, dict) and metadata:
return dict(metadata)
inner_model = getattr(current_patcher, "model", None)
metadata = getattr(inner_model, "_int8_source_metadata", None)
if isinstance(metadata, dict) and metadata:
return dict(metadata)
current_patcher = getattr(current_patcher, "parent", None)
return None
def _resolve_patch_target_module(base_model, patch_key):
try:
return comfy.utils.get_attr(base_model, patch_key)
except Exception:
pass
diffusion_model = getattr(base_model, "diffusion_model", None)
if diffusion_model is None:
return None
trimmed_key = patch_key
if trimmed_key.startswith("diffusion_model."):
trimmed_key = trimmed_key[len("diffusion_model."):]
try:
return comfy.utils.get_attr(diffusion_model, trimmed_key)
except Exception:
return None
def _collect_modules_for_save_workaround(model_patcher):
base_model = getattr(model_patcher, "model", None)
if base_model is None:
return []
modules = []
seen_module_ids = set()
if hasattr(base_model, "named_modules"):
for _, module in base_model.named_modules():
if not _is_int8_quantized_module(module):
continue
module_id = id(module)
if module_id in seen_module_ids:
continue
seen_module_ids.add(module_id)
modules.append(module)
object_patches = getattr(model_patcher, "object_patches", None)
if isinstance(object_patches, dict):
for patch_key, patch_obj in object_patches.items():
if not isinstance(patch_key, str):
continue
if not patch_key.startswith("diffusion_model."):
continue
if not _is_int8_quantized_module(patch_obj):
continue
target_module = _resolve_patch_target_module(base_model, patch_key)
if target_module is None:
continue
module_id = id(target_module)
if module_id in seen_module_ids:
continue
seen_module_ids.add(module_id)
modules.append(target_module)
diffusion_model = getattr(base_model, "diffusion_model", None)
if diffusion_model is not None and hasattr(diffusion_model, "named_modules"):
for _, module in diffusion_model.named_modules():
if not getattr(module, "comfy_cast_weights", False):
continue
if not _module_has_non_float_weight(module):
continue
module_id = id(module)
if module_id in seen_module_ids:
continue
seen_module_ids.add(module_id)
modules.append(module)
return modules
def _set_comfy_patched_weights_flag(modules):
flag_states = []
for module in modules:
had_flag = hasattr(module, "comfy_patched_weights")
old_flag = getattr(module, "comfy_patched_weights", False) if had_flag else False
flag_states.append((module, had_flag, old_flag))
module.comfy_patched_weights = True
return flag_states
def _restore_comfy_patched_weights_flag(flag_states):
for module, had_flag, old_flag in flag_states:
if had_flag:
module.comfy_patched_weights = old_flag
else:
delattr(module, "comfy_patched_weights")
@contextmanager
def _temporary_save_compatibility_shim(modules):
flag_states = _set_comfy_patched_weights_flag(modules)
restore_lazy_param = _install_lazy_casting_param_workaround()
if restore_lazy_param is not None:
logging.info("Quantization Toolkit save: enabled the temporary LazyCastingParam requires_grad workaround.")
try:
yield
finally:
if restore_lazy_param is not None:
restore_lazy_param()
_restore_comfy_patched_weights_flag(flag_states)
def _apply_object_patches_for_save(model_patcher):
object_patches = getattr(model_patcher, "object_patches", None)
if not isinstance(object_patches, dict) or not object_patches:
return False
patch_model = getattr(model_patcher, "patch_model", None)
if not callable(patch_model):
return False
try:
patch_model(load_weights=False)
return True
except Exception as e:
logging.warning(f"Quantization Toolkit Save: failed to apply object patches before save ({e}).")
return False
def _summarize_saved_quantized_checkpoint(path):
try:
from safetensors import safe_open
except Exception:
return
try:
dtype_counts = {}
int8_weights = 0
total_weights = 0
weight_scales = 0
comfy_quant_layers = 0
int4_layers = 0
int8_layers = 0
with safe_open(path, framework="pt", device="cpu") as handle:
metadata = handle.metadata()
has_legacy_quant_metadata = isinstance(metadata, dict) and "_quantization_metadata" in metadata
for key in handle.keys():
tensor_slice = handle.get_slice(key)
dtype = tensor_slice.get_dtype()
dtype_counts[dtype] = dtype_counts.get(dtype, 0) + 1
if key.endswith(".weight"):
total_weights += 1
if dtype == "I8":
int8_weights += 1
elif key.endswith(".weight_scale"):
weight_scales += 1
elif key.endswith(".comfy_quant"):
comfy_quant_layers += 1
try:
quant_config = json.loads(handle.get_tensor(key).numpy().tobytes())
if quant_config.get("format") == "convrot_w4a4":
int4_layers += 1
elif quant_config.get("format") == "int8_tensorwise":
int8_layers += 1
except Exception:
pass
logging.info(
"Quantization Toolkit save: checkpoint summary "
f"(int8_weights={int8_weights}, weight_scales={weight_scales}, "
f"int4_layers={int4_layers}, int8_layers={int8_layers}, "
f"comfy_quant_layers={comfy_quant_layers}, total_weights={total_weights}, "
f"dtypes={dtype_counts})."
)
if int8_weights == 0 or weight_scales == 0:
logging.warning("Quantization Toolkit Save: saved checkpoint does not appear to contain packed INT4 or INT8 weights.")
elif comfy_quant_layers == 0 and not has_legacy_quant_metadata:
logging.warning(
"Quantization Toolkit Save: saved checkpoint does not include native ComfyUI .comfy_quant metadata."
)
elif comfy_quant_layers < int8_weights and not has_legacy_quant_metadata:
logging.warning(
"Quantization Toolkit Save: only some packed weights include native ComfyUI .comfy_quant metadata. "
"Plain INT8 and ConvRot layers can export natively; Toolkit QuaRot/HadaNorm layers require the Toolkit loader."
)
except Exception as e:
logging.warning(f"Quantization Toolkit Save: failed to inspect saved checkpoint ({e}).")
class INT8ModelSave:
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"filename_prefix": ("STRING", {"default": "quantized_models/Quantized_Model"}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ()
FUNCTION = "save"
OUTPUT_NODE = True
CATEGORY = "loaders"
DESCRIPTION = "Save MODEL outputs that include Toolkit INT8 or native ConvRot INT4 layers with a DynamicVRAM-safe save path."
def save(self, model, filename_prefix, prompt=None, extra_pnginfo=None):
_validate_quantized_model(model)
full_output_folder, filename, counter, _, _ = folder_paths.get_save_image_path(
filename_prefix,
self.output_dir,
)
prompt_info = ""
if prompt is not None:
prompt_info = json.dumps(prompt)
metadata = {}
source_metadata = _resolve_source_metadata(model)
if isinstance(source_metadata, dict):
metadata.update(source_metadata)
if not args.disable_metadata:
metadata["prompt"] = prompt_info
if extra_pnginfo is not None:
for key, value in extra_pnginfo.items():
metadata[key] = json.dumps(value)
output_checkpoint = f"{filename}_{counter:05}_.safetensors"
output_checkpoint = os.path.join(full_output_folder, output_checkpoint)
finalize_pending_int8 = getattr(model, "finalize_pending_int8", None)
if callable(finalize_pending_int8):
finalize_pending_int8()
applied_object_patches = _apply_object_patches_for_save(model)
if applied_object_patches:
logging.info("Quantization Toolkit save: applied object patches before saving quantized modules.")
modules_to_patch = _collect_modules_for_save_workaround(model)
if not modules_to_patch:
logging.warning("Quantization Toolkit Save: no target modules were found for the DynamicVRAM save workaround.")
else:
logging.info(f"Quantization Toolkit save: applying the Dynamic VRAM workaround to {len(modules_to_patch)} module(s).")
with _temporary_save_compatibility_shim(modules_to_patch):
comfy.sd.save_checkpoint(output_checkpoint, model, metadata=metadata)
_summarize_saved_quantized_checkpoint(output_checkpoint)
return {}