300 lines
14 KiB
Python
300 lines
14 KiB
Python
import folder_paths
|
|
import comfy.sd
|
|
import comfy.model_patcher
|
|
import comfy.model_management
|
|
import json
|
|
import os
|
|
import logging
|
|
import torch
|
|
from comfy.cli_args import args
|
|
|
|
|
|
def _is_dynamic_lora_enabled():
|
|
try:
|
|
from .int8_quant import Int8TensorwiseOps
|
|
return bool(getattr(Int8TensorwiseOps, "dynamic_lora", False))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _resolve_source_metadata(model):
|
|
"""Walk the patcher clone chain and the inner model object to recover the
|
|
original safetensors metadata that was stashed by UNetLoaderINTW8A8.
|
|
|
|
ComfyUI's ``ModelPatcher.clone()`` builds a fresh patcher and does not copy
|
|
over arbitrary attributes set on the source patcher. INT8GroupedLora and
|
|
other downstream nodes call ``model.clone()``, which would otherwise drop
|
|
the ``_safetensors_metadata`` stash and produce a checkpoint missing the
|
|
``int8_quantized`` / ``int8_model_type`` / ``config`` (LTX2) flags that the
|
|
loader relies on for round-trips.
|
|
"""
|
|
seen = set()
|
|
|
|
def _walk(m):
|
|
if m is None or id(m) in seen:
|
|
return None
|
|
seen.add(id(m))
|
|
meta = getattr(m, "_safetensors_metadata", None)
|
|
if isinstance(meta, dict) and meta:
|
|
return meta
|
|
inner = getattr(m, "model", None)
|
|
if inner is not None:
|
|
inner_meta = getattr(inner, "_int8_source_metadata", None)
|
|
if isinstance(inner_meta, dict) and inner_meta:
|
|
return inner_meta
|
|
parent = getattr(m, "parent", None)
|
|
return _walk(parent)
|
|
|
|
return _walk(model)
|
|
|
|
|
|
class INT8ModelSave:
|
|
def __init__(self):
|
|
self.output_dir = folder_paths.get_output_directory()
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {"required": { "model": ("MODEL",),
|
|
"filename_prefix": ("STRING", {"default": "int8_models/INT8_Model"}),},
|
|
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},}
|
|
RETURN_TYPES = ()
|
|
FUNCTION = "save"
|
|
OUTPUT_NODE = True
|
|
|
|
CATEGORY = "loaders"
|
|
|
|
def save(self, model, filename_prefix, prompt=None, extra_pnginfo=None):
|
|
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir)
|
|
prompt_info = ""
|
|
if prompt is not None:
|
|
prompt_info = json.dumps(prompt)
|
|
|
|
metadata = {}
|
|
# Preserve source safetensors metadata (int8_quantized, int8_model_type,
|
|
# ltx2 config, etc.). Walk the patcher chain because INT8GroupedLora's
|
|
# clone strips this attribute from the local patcher.
|
|
src_meta = _resolve_source_metadata(model)
|
|
if isinstance(src_meta, dict):
|
|
metadata.update(src_meta)
|
|
if not src_meta:
|
|
logging.warning(
|
|
"INT8 Save: source safetensors metadata could not be located on the patcher chain. "
|
|
"The output checkpoint will be saved without int8_quantized/int8_model_type/config metadata, "
|
|
"which may break re-loading for some models (notably LTX2)."
|
|
)
|
|
# if not args.disable_metadata:
|
|
# metadata["prompt"] = prompt_info
|
|
# if extra_pnginfo is not None:
|
|
# for x in extra_pnginfo:
|
|
# metadata[x] = json.dumps(extra_pnginfo[x])
|
|
|
|
output_checkpoint = f"{filename}_{counter:05}_.safetensors"
|
|
output_checkpoint = os.path.join(full_output_folder, output_checkpoint)
|
|
|
|
extra_keys = {}
|
|
|
|
patched_modules = []
|
|
patched_module_ids = set()
|
|
|
|
def mark_module_for_direct_save(module):
|
|
module_id = id(module)
|
|
if module_id in patched_module_ids:
|
|
return
|
|
had_flag = hasattr(module, "comfy_patched_weights")
|
|
old_flag = getattr(module, "comfy_patched_weights", False)
|
|
patched_modules.append((module, had_flag, old_flag))
|
|
patched_module_ids.add(module_id)
|
|
module.comfy_patched_weights = True
|
|
|
|
def module_has_int8_param(module):
|
|
for attr in ("weight", "bias"):
|
|
tensor = getattr(module, attr, None)
|
|
if isinstance(tensor, torch.Tensor) and tensor.dtype == torch.int8:
|
|
return True
|
|
return False
|
|
|
|
def iter_model_modules(model_patcher):
|
|
if hasattr(model_patcher, "model") and hasattr(model_patcher.model, "named_modules"):
|
|
yield from model_patcher.model.named_modules()
|
|
|
|
def materialize_int8_lora_patches(model_patcher):
|
|
"""Bake active non-dynamic INT8 LoRA low-VRAM functions into weights.
|
|
|
|
The newer ComfyUI low-VRAM integration stores non-dynamic INT8 LoRA
|
|
patches as module.weight_lowvram_function instead of immediately
|
|
mutating the int8 parameter. That is correct for sampling memory,
|
|
but this save node marks int8 modules as directly savable, which
|
|
intentionally bypasses LazyCastingParam. Without this pre-pass the
|
|
checkpoint would serialize the base int8 weight and drop the LoRA.
|
|
"""
|
|
if _is_dynamic_lora_enabled() or not hasattr(model_patcher, "patch_weight_to_device"):
|
|
return
|
|
|
|
patches = getattr(model_patcher, "patches", None)
|
|
if not patches:
|
|
return
|
|
|
|
load_device = getattr(model_patcher, "load_device", None)
|
|
materialized = 0
|
|
for name, module in iter_model_modules(model_patcher):
|
|
if not getattr(module, "_is_quantized", False):
|
|
continue
|
|
|
|
weight_key = name + ".weight" if name else "weight"
|
|
if weight_key not in patches:
|
|
continue
|
|
|
|
try:
|
|
current_weight = getattr(module, "weight", None)
|
|
device_to = load_device if load_device is not None else getattr(current_weight, "device", None)
|
|
model_patcher.patch_weight_to_device(weight_key, device_to=device_to)
|
|
if hasattr(module, "weight_lowvram_function"):
|
|
module.weight_lowvram_function = None
|
|
materialized += 1
|
|
except Exception as e:
|
|
logging.warning(
|
|
f"INT8 Save: failed to materialize LoRA patch for {weight_key}: {e}. "
|
|
"The saved checkpoint may miss this LoRA patch."
|
|
)
|
|
|
|
if materialized > 0:
|
|
logging.info(f"INT8 Save: materialized {materialized} INT8 LoRA patched weight(s) before saving.")
|
|
|
|
# Finalize any deferred INT8 layers (Aimdo/Windows deferred-load path sets
|
|
# _pending_int8_finalize instead of quantizing immediately). Without this,
|
|
# those modules still have _is_quantized=False at save time and no
|
|
# comfy_quant keys are emitted.
|
|
finalize_fn = getattr(model, "finalize_pending_int8", None)
|
|
if finalize_fn is not None:
|
|
finalize_fn()
|
|
|
|
# CRITICAL: Apply any pending LoRA / model patches BEFORE collecting
|
|
# extra_keys and BEFORE save_checkpoint runs its own load_models_gpu().
|
|
#
|
|
# Why: when LoRAs were stacked via INT8GroupedLora (or the standard
|
|
# LoRA loader), the patches live on the model patcher and are only
|
|
# baked into the int8 weights when ``patch_model`` runs. ``save_checkpoint``
|
|
# internally calls ``load_models_gpu`` which does trigger the bake, but
|
|
# we also need to observe the post-bake module state to emit accurate
|
|
# ``comfy_quant`` / scalar ``weight_scale`` extra_keys (and to be sure
|
|
# ``module.comfy_patched_weights`` is set so ``model_state_dict_for_saving``
|
|
# emits the int8 weight directly instead of wrapping it in a
|
|
# LazyCastingParam, which assumes float dtypes).
|
|
#
|
|
# ``force_full_load=True`` keeps every patched layer on-device so we
|
|
# see consistent int8 weights for every module, even on lowvram setups.
|
|
try:
|
|
comfy.model_management.load_models_gpu([model], force_full_load=True)
|
|
except Exception as e:
|
|
logging.warning(
|
|
f"INT8 Save: full-load pre-pass failed ({e}); falling back to "
|
|
"default load_models_gpu without force_full_load."
|
|
)
|
|
try:
|
|
comfy.model_management.load_models_gpu([model])
|
|
except Exception as e2:
|
|
logging.warning(
|
|
f"INT8 Save: load_models_gpu fallback also failed ({e2}); "
|
|
"continuing best-effort. The saved checkpoint may be "
|
|
"incomplete if LoRA patches were not applied."
|
|
)
|
|
|
|
# Re-finalize after load_models_gpu in case any aimdo deferred layers
|
|
# were materialized only during the load pass.
|
|
if finalize_fn is not None:
|
|
finalize_fn()
|
|
|
|
materialize_int8_lora_patches(model)
|
|
|
|
# Collect comfy_quant and (scalar) weight_scale extra_keys based on
|
|
# the post-patch module state.
|
|
if hasattr(model, "model"):
|
|
for name, module in iter_model_modules(model):
|
|
if module_has_int8_param(module):
|
|
# ComfyUI's LazyCastingParam subclasses torch.nn.Parameter
|
|
# with requires_grad=True by default, which is invalid for
|
|
# int8 tensors. Mark all int8 modules for direct save.
|
|
mark_module_for_direct_save(module)
|
|
|
|
if getattr(module, "_is_quantized", False):
|
|
use_convrot = bool(getattr(module, "_use_convrot", False))
|
|
quant_conf = {"convrot": use_convrot}
|
|
# Always emit a groupsize when convrot is on, even if the
|
|
# module is using the default. Older save paths only wrote
|
|
# this field when ``_convrot_groupsize`` had been set
|
|
# explicitly, which left on-the-fly-quantized layers with
|
|
# an unspecified groupsize and forced the loader to fall
|
|
# back to ``CONVROT_GROUP_SIZE`` (which happens to match
|
|
# today, but is fragile if the default ever changes).
|
|
if use_convrot:
|
|
try:
|
|
from .int8_quant import CONVROT_GROUP_SIZE
|
|
except Exception:
|
|
CONVROT_GROUP_SIZE = 256
|
|
quant_conf["convrot_groupsize"] = int(
|
|
getattr(module, "_convrot_groupsize", CONVROT_GROUP_SIZE)
|
|
)
|
|
|
|
# Track granularity so the loader can pick the matching
|
|
# forward kernel without re-inspecting tensor shapes.
|
|
quant_conf["per_row"] = bool(getattr(module, "_is_per_row", False))
|
|
|
|
# Prepend 'model.' as comfy.sd.save_checkpoint adds this
|
|
# prefix to all weights; extra_keys are NOT auto-prefixed
|
|
# so we must do it ourselves to keep them aligned with the
|
|
# owning weight tensor.
|
|
prefix = "model." + name + "." if name else "model."
|
|
|
|
extra_keys[prefix + "comfy_quant"] = torch.tensor(
|
|
list(json.dumps(quant_conf).encode('utf-8')), dtype=torch.uint8
|
|
)
|
|
|
|
# Handle scalar weight_scale which is not registered as a
|
|
# persistent buffer (so it would be missing from
|
|
# state_dict() entirely).
|
|
if getattr(module, "_weight_scale_scalar", None) is not None:
|
|
extra_keys[prefix + "weight_scale"] = torch.tensor(module._weight_scale_scalar)
|
|
|
|
mark_module_for_direct_save(module)
|
|
|
|
original_lazy_new = comfy.model_patcher.LazyCastingParam.__new__
|
|
original_lazy_piece_new = comfy.model_patcher.LazyCastingParamPiece.__new__
|
|
|
|
def lazy_casting_param_new(cls, model, key, tensor):
|
|
requires_grad = tensor.is_floating_point() or tensor.is_complex()
|
|
return torch.nn.Parameter.__new__(cls, tensor, requires_grad=requires_grad)
|
|
|
|
def lazy_casting_param_piece_new(cls, caster, state_dict_key, tensor):
|
|
requires_grad = tensor.is_floating_point() or tensor.is_complex()
|
|
return torch.nn.Parameter.__new__(cls, tensor, requires_grad=requires_grad)
|
|
|
|
had_save_flag = hasattr(model, "_int8_save_materialized_lora")
|
|
old_save_flag = getattr(model, "_int8_save_materialized_lora", False)
|
|
|
|
try:
|
|
model._int8_save_materialized_lora = True
|
|
comfy.model_patcher.LazyCastingParam.__new__ = staticmethod(lazy_casting_param_new)
|
|
comfy.model_patcher.LazyCastingParamPiece.__new__ = staticmethod(lazy_casting_param_piece_new)
|
|
comfy.sd.save_checkpoint(output_checkpoint, model, metadata=metadata, extra_keys=extra_keys)
|
|
finally:
|
|
comfy.model_patcher.LazyCastingParam.__new__ = original_lazy_new
|
|
comfy.model_patcher.LazyCastingParamPiece.__new__ = original_lazy_piece_new
|
|
if had_save_flag:
|
|
model._int8_save_materialized_lora = old_save_flag
|
|
else:
|
|
try:
|
|
delattr(model, "_int8_save_materialized_lora")
|
|
except AttributeError:
|
|
pass
|
|
# Restore module states so we don't break dynamic VRAM management
|
|
for module, had_flag, old_flag in patched_modules:
|
|
if had_flag:
|
|
module.comfy_patched_weights = old_flag
|
|
else:
|
|
try:
|
|
delattr(module, "comfy_patched_weights")
|
|
except AttributeError:
|
|
pass
|
|
|
|
return {}
|