493 lines
17 KiB
Python
493 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
ComfyUI-Krea2-Model-Merge
|
|
=========================
|
|
|
|
Krea 2 の標準 MODEL を直接受け取る簡易マージ/保存ノード。
|
|
|
|
接続:
|
|
[Load Diffusion Model] -- MODEL --\
|
|
[Krea2 Model Merge Simple] -- MODEL
|
|
[Load Diffusion Model] -- MODEL --/
|
|
|
|
[Krea2 Model Merge Simple] -- MODEL --> [Krea2 Model Save]
|
|
|
|
専用ローダーや独自型は使用しない。
|
|
量子化モデルの state_dict に現れる仮想キー
|
|
(weight_scale / comfy_quant など)をマージ対象から除外し、
|
|
標準 ModelPatcher の遅延パッチ機構を維持したまま MODEL を返す。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import time
|
|
import uuid
|
|
from typing import Any, Dict, Iterable, List, Mapping, Sequence, Tuple
|
|
|
|
import torch
|
|
from safetensors import safe_open
|
|
|
|
import folder_paths
|
|
import comfy.model_management
|
|
import comfy.utils
|
|
from comfy.model_patcher import get_key_weight
|
|
|
|
|
|
CATEGORY = "Krea2 Model Merge"
|
|
|
|
_QUANT_AUX_SUFFIXES = (
|
|
".weight_scale",
|
|
".weight_scale_2",
|
|
".input_scale",
|
|
".pre_quant_scale",
|
|
".comfy_quant",
|
|
".scaled_fp8",
|
|
)
|
|
|
|
_SAVE_DTYPES = ["bf16", "fp16", "fp32", "fp8_scaled"]
|
|
_DTYPE_MAP = {
|
|
"bf16": torch.bfloat16,
|
|
"fp16": torch.float16,
|
|
"fp32": torch.float32,
|
|
}
|
|
_ST_DTYPE = {
|
|
torch.float64: "F64",
|
|
torch.float32: "F32",
|
|
torch.float16: "F16",
|
|
torch.bfloat16: "BF16",
|
|
torch.int64: "I64",
|
|
torch.int32: "I32",
|
|
torch.int16: "I16",
|
|
torch.int8: "I8",
|
|
torch.uint8: "U8",
|
|
torch.bool: "BOOL",
|
|
torch.float8_e4m3fn: "F8_E4M3",
|
|
torch.float8_e5m2: "F8_E5M2",
|
|
}
|
|
_ITEMSIZE = {
|
|
"F64": 8,
|
|
"F32": 4,
|
|
"F16": 2,
|
|
"BF16": 2,
|
|
"I64": 8,
|
|
"I32": 4,
|
|
"I16": 2,
|
|
"I8": 1,
|
|
"U8": 1,
|
|
"BOOL": 1,
|
|
"F8_E4M3": 1,
|
|
"F8_E5M2": 1,
|
|
}
|
|
_FP8_MAX = float(torch.finfo(torch.float8_e4m3fn).max)
|
|
_FP8_CONF = {"format": "float8_e4m3fn"}
|
|
_FP8_CONF_BYTES = json.dumps(_FP8_CONF, separators=(",", ":")).encode("utf-8")
|
|
|
|
|
|
def _is_quant_aux_key(key: str) -> bool:
|
|
return key.endswith(_QUANT_AUX_SUFFIXES)
|
|
|
|
|
|
def _is_krea2_state_dict_key(key: str) -> bool:
|
|
"""Krea2 の主要構造。厳密な拒否判定には使わず、警告用に使う。"""
|
|
k = key[len("diffusion_model."):] if key.startswith("diffusion_model.") else key
|
|
return (
|
|
k.startswith("first.")
|
|
or k.startswith("tmlp.")
|
|
or k.startswith("txtmlp.")
|
|
or k.startswith("tproj.")
|
|
or k.startswith("txtfusion.")
|
|
or k.startswith("blocks.")
|
|
or k.startswith("last.")
|
|
)
|
|
|
|
|
|
def _actual_key_patches(model, filter_prefix: str = "diffusion_model.") -> Tuple[Dict[str, list], List[str]]:
|
|
"""
|
|
ModelPatcher.get_key_patches() と同じ形式を返すが、量子化 state_dict の
|
|
仮想補助キーを除外し、実在しない属性への getattr を避ける。
|
|
"""
|
|
model_sd = model.model_state_dict(filter_prefix)
|
|
patches: Dict[str, list] = {}
|
|
skipped: List[str] = []
|
|
|
|
backup = getattr(model, "backup", {})
|
|
hook_backup = getattr(model, "hook_backup", {})
|
|
model_patches = getattr(model, "patches", {})
|
|
|
|
for key in model_sd.keys():
|
|
if _is_quant_aux_key(key):
|
|
skipped.append(key)
|
|
continue
|
|
|
|
try:
|
|
weight, _set_func, convert_func = get_key_weight(model.model, key)
|
|
except (AttributeError, KeyError, TypeError):
|
|
# state_dict にだけ存在する仮想キーや、ModelPatcher から直接取得できない項目。
|
|
skipped.append(key)
|
|
continue
|
|
|
|
if weight is None:
|
|
skipped.append(key)
|
|
continue
|
|
|
|
bk = backup.get(key)
|
|
hbk = hook_backup.get(key)
|
|
if bk is not None:
|
|
weight = bk.weight
|
|
if hbk is not None:
|
|
weight = hbk[0]
|
|
if convert_func is None:
|
|
convert_func = lambda value, **kwargs: value
|
|
|
|
base = [(weight, convert_func)]
|
|
if key in model_patches:
|
|
base += model_patches[key]
|
|
patches[key] = base
|
|
|
|
return patches, skipped
|
|
|
|
|
|
def _shape_of_actual_key(model, key: str) -> Tuple[int, ...] | None:
|
|
try:
|
|
weight, _set_func, _convert_func = get_key_weight(model.model, key)
|
|
except (AttributeError, KeyError, TypeError):
|
|
return None
|
|
return tuple(int(v) for v in weight.shape) if weight is not None else None
|
|
|
|
|
|
def _validate_merge_shapes(model1, key_patches: Mapping[str, list]) -> None:
|
|
mismatches = []
|
|
model1_sd = model1.model_state_dict("diffusion_model.")
|
|
for key, value in key_patches.items():
|
|
if key not in model1_sd:
|
|
continue
|
|
shape1 = _shape_of_actual_key(model1, key)
|
|
try:
|
|
shape2 = tuple(int(v) for v in value[0][0].shape)
|
|
except Exception:
|
|
shape2 = None
|
|
if shape1 is not None and shape2 is not None and shape1 != shape2:
|
|
mismatches.append((key, shape1, shape2))
|
|
if len(mismatches) >= 8:
|
|
break
|
|
|
|
if mismatches:
|
|
detail = "\n".join(f" {k}: {a} != {b}" for k, a, b in mismatches)
|
|
raise ValueError(
|
|
"モデル構造またはテンソル形状が一致しないためマージできません。\n" + detail
|
|
)
|
|
|
|
|
|
def _merge_models(model1, model2, ratio: float):
|
|
ratio = float(ratio)
|
|
merged = model1.clone()
|
|
key_patches, skipped = _actual_key_patches(model2)
|
|
if not key_patches:
|
|
raise ValueError(
|
|
"model2 からマージ可能な diffusion_model の重みを取得できませんでした。"
|
|
)
|
|
|
|
_validate_merge_shapes(model1, key_patches)
|
|
|
|
accepted = 0
|
|
for key, patch in key_patches.items():
|
|
added = merged.add_patches({key: patch}, 1.0 - ratio, ratio)
|
|
if added:
|
|
accepted += 1
|
|
|
|
if accepted == 0:
|
|
raise ValueError("2つのモデルに共通するマージ可能な重みがありません。")
|
|
|
|
krea_keys = sum(1 for key in key_patches if _is_krea2_state_dict_key(key))
|
|
print(
|
|
f"[Krea2ModelMerge] merged={accepted}, ratio={ratio:.4f}, "
|
|
f"krea2_keys={krea_keys}, skipped_quant_aux={len(skipped)}"
|
|
)
|
|
return merged
|
|
|
|
|
|
def _named_internal_tensors(model) -> List[Tuple[str, torch.Tensor]]:
|
|
"""実在する parameter / buffer のみを列挙し、量子化仮想キーを含めない。"""
|
|
tensors: Dict[str, torch.Tensor] = {}
|
|
for key, value in model.model.named_parameters(recurse=True):
|
|
if key.startswith("diffusion_model."):
|
|
tensors[key] = value
|
|
for key, value in model.model.named_buffers(recurse=True):
|
|
if key.startswith("diffusion_model.") and key not in tensors:
|
|
tensors[key] = value
|
|
return sorted(tensors.items(), key=lambda item: item[0])
|
|
|
|
|
|
def _external_key(internal_key: str) -> str:
|
|
prefix = "diffusion_model."
|
|
return internal_key[len(prefix):] if internal_key.startswith(prefix) else internal_key
|
|
|
|
|
|
def _dequantize_tensor(tensor: torch.Tensor) -> torch.Tensor:
|
|
# ComfyUI QuantizedTensor
|
|
if hasattr(tensor, "dequantize"):
|
|
try:
|
|
tensor = tensor.dequantize()
|
|
except Exception:
|
|
pass
|
|
if tensor.dtype in (torch.float8_e4m3fn, torch.float8_e5m2):
|
|
tensor = tensor.to(torch.bfloat16)
|
|
return tensor
|
|
|
|
|
|
def _materialize_weight(model, key: str) -> torch.Tensor:
|
|
"""パッチ適用後の1テンソルだけを実体化する。"""
|
|
device = getattr(model, "load_device", None) or comfy.model_management.get_torch_device()
|
|
with torch.inference_mode():
|
|
value = model.patch_weight_to_device(
|
|
key,
|
|
device_to=device,
|
|
return_weight=True,
|
|
force_cast=True,
|
|
)
|
|
value = _dequantize_tensor(value)
|
|
return value.detach().to("cpu").contiguous()
|
|
|
|
|
|
def _product(shape: Sequence[int]) -> int:
|
|
out = 1
|
|
for value in shape:
|
|
out *= int(value)
|
|
return out
|
|
|
|
|
|
def _write_tensor_bytes(file_obj, tensor: torch.Tensor, chunk_size: int = 64 * 1024 * 1024) -> None:
|
|
tensor = tensor.detach().to("cpu").contiguous()
|
|
raw = tensor.reshape(-1).view(torch.uint8).numpy()
|
|
view = memoryview(raw)
|
|
for start in range(0, view.nbytes, chunk_size):
|
|
file_obj.write(view[start:start + chunk_size])
|
|
|
|
|
|
def _is_fp8_eligible(key: str, tensor: torch.Tensor) -> bool:
|
|
return key.endswith(".weight") and tensor.ndim == 2 and tensor.is_floating_point()
|
|
|
|
|
|
def _quantize_fp8_scaled(tensor: torch.Tensor, key: str) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
tensor = tensor.to(torch.float32)
|
|
max_abs = torch.amax(torch.abs(tensor)).to(torch.float32)
|
|
max_value = float(max_abs.item())
|
|
if not math.isfinite(max_value):
|
|
raise ValueError(f"NaNまたはInfを含むためFP8量子化できません: {key}")
|
|
scale_value = max_value / _FP8_MAX if max_value > 0.0 else 1.0
|
|
scale = torch.tensor(scale_value, dtype=torch.float32)
|
|
quantized = torch.clamp(tensor / scale, -_FP8_MAX, _FP8_MAX).to(torch.float8_e4m3fn)
|
|
return quantized.contiguous(), scale
|
|
|
|
|
|
def _safe_output_path(filename_prefix: str) -> str:
|
|
paths = folder_paths.get_folder_paths("diffusion_models")
|
|
if not paths:
|
|
raise ValueError("models/diffusion_models フォルダが見つかりません。")
|
|
output_dir = paths[0]
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
name = os.path.basename((filename_prefix or "").strip()) or "krea2_merged"
|
|
if not name.lower().endswith(".safetensors"):
|
|
name += ".safetensors"
|
|
path = os.path.join(output_dir, name)
|
|
if not os.path.exists(path):
|
|
return path
|
|
|
|
stem = name[:-len(".safetensors")]
|
|
index = 1
|
|
while True:
|
|
candidate = os.path.join(output_dir, f"{stem}_{index:02d}.safetensors")
|
|
if not os.path.exists(candidate):
|
|
return candidate
|
|
index += 1
|
|
|
|
|
|
def _build_save_entries(model, save_dtype: str) -> Tuple[List[dict], List[Tuple[str, torch.Tensor]]]:
|
|
named = _named_internal_tensors(model)
|
|
if not named:
|
|
raise ValueError("保存可能な diffusion_model の実テンソルが見つかりません。")
|
|
|
|
entries: List[dict] = []
|
|
for internal_key, tensor in named:
|
|
out_key = _external_key(internal_key)
|
|
shape = [int(v) for v in tensor.shape]
|
|
if save_dtype == "fp8_scaled" and _is_fp8_eligible(out_key, tensor):
|
|
entries.append({"name": out_key, "dtype": "F8_E4M3", "shape": shape, "kind": "fp8", "source": internal_key})
|
|
prefix = out_key[:-len("weight")]
|
|
entries.append({"name": prefix + "weight_scale", "dtype": "F32", "shape": [], "kind": "fp8_scale", "source": internal_key})
|
|
entries.append({"name": prefix + "comfy_quant", "dtype": "U8", "shape": [len(_FP8_CONF_BYTES)], "kind": "fp8_conf", "source": internal_key})
|
|
else:
|
|
if tensor.is_floating_point() or tensor.dtype in (torch.float8_e4m3fn, torch.float8_e5m2):
|
|
dtype = _ST_DTYPE[torch.bfloat16 if save_dtype == "fp8_scaled" else _DTYPE_MAP[save_dtype]]
|
|
else:
|
|
dtype = _ST_DTYPE.get(tensor.dtype)
|
|
if dtype is None:
|
|
raise ValueError(f"保存未対応dtype: {tensor.dtype} ({out_key})")
|
|
entries.append({"name": out_key, "dtype": dtype, "shape": shape, "kind": "tensor", "source": internal_key})
|
|
return entries, named
|
|
|
|
|
|
def _header_bytes(entries: Sequence[dict], metadata: Mapping[str, str]) -> Tuple[bytes, int]:
|
|
header: Dict[str, Any] = {"__metadata__": dict(metadata)}
|
|
offset = 0
|
|
for entry in entries:
|
|
size = _product(entry["shape"]) * _ITEMSIZE[entry["dtype"]]
|
|
header[entry["name"]] = {
|
|
"dtype": entry["dtype"],
|
|
"shape": entry["shape"],
|
|
"data_offsets": [offset, offset + size],
|
|
}
|
|
offset += size
|
|
encoded = json.dumps(header, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
encoded += b" " * ((-len(encoded)) % 8)
|
|
return encoded, offset
|
|
|
|
|
|
def _save_model(model, filename_prefix: str, save_dtype: str) -> str:
|
|
if save_dtype not in _SAVE_DTYPES:
|
|
raise ValueError(f"save_dtype が不正です: {save_dtype}")
|
|
|
|
entries, named = _build_save_entries(model, save_dtype)
|
|
entry_groups: Dict[str, List[dict]] = {}
|
|
for entry in entries:
|
|
entry_groups.setdefault(entry["source"], []).append(entry)
|
|
|
|
metadata = {
|
|
"merge_tool": "ComfyUI-Krea2-Model-Merge",
|
|
"save_dtype": save_dtype,
|
|
"created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
}
|
|
if save_dtype == "fp8_scaled":
|
|
layers = {
|
|
entry["name"][:-len(".weight")]: {"format": "float8_e4m3fn"}
|
|
for entry in entries
|
|
if entry["kind"] == "fp8"
|
|
}
|
|
metadata["_quantization_metadata"] = json.dumps(
|
|
{"format_version": "1.0", "layers": layers},
|
|
separators=(",", ":"),
|
|
)
|
|
|
|
header, data_size = _header_bytes(entries, metadata)
|
|
output_path = _safe_output_path(filename_prefix)
|
|
free_space = shutil.disk_usage(os.path.dirname(output_path)).free
|
|
expected_size = 8 + len(header) + data_size
|
|
if free_space < expected_size + 256 * 1024 * 1024:
|
|
raise OSError(f"保存先の空き容量が不足しています。必要量目安: {expected_size / (1024**3):.2f} GB")
|
|
|
|
temp_path = output_path + f".tmp-{uuid.uuid4().hex}"
|
|
progress = comfy.utils.ProgressBar(len(named))
|
|
|
|
try:
|
|
with open(temp_path, "wb", buffering=0) as file_obj:
|
|
file_obj.write(struct.pack("<Q", len(header)))
|
|
file_obj.write(header)
|
|
|
|
for internal_key, _placeholder in named:
|
|
value = _materialize_weight(model, internal_key)
|
|
group = entry_groups[internal_key]
|
|
if group[0]["kind"] == "fp8":
|
|
out_key = group[0]["name"]
|
|
quantized, scale = _quantize_fp8_scaled(value, out_key)
|
|
_write_tensor_bytes(file_obj, quantized)
|
|
_write_tensor_bytes(file_obj, scale)
|
|
_write_tensor_bytes(file_obj, torch.tensor(list(_FP8_CONF_BYTES), dtype=torch.uint8))
|
|
del quantized, scale
|
|
else:
|
|
target_dtype = {
|
|
"F64": torch.float64,
|
|
"F32": torch.float32,
|
|
"F16": torch.float16,
|
|
"BF16": torch.bfloat16,
|
|
"I64": torch.int64,
|
|
"I32": torch.int32,
|
|
"I16": torch.int16,
|
|
"I8": torch.int8,
|
|
"U8": torch.uint8,
|
|
"BOOL": torch.bool,
|
|
}[group[0]["dtype"]]
|
|
value = value.to(target_dtype)
|
|
_write_tensor_bytes(file_obj, value)
|
|
del value
|
|
progress.update(1)
|
|
|
|
file_obj.flush()
|
|
os.fsync(file_obj.fileno())
|
|
|
|
if os.path.getsize(temp_path) != expected_size:
|
|
raise IOError("保存後のファイルサイズが想定値と一致しません。")
|
|
|
|
with safe_open(temp_path, framework="pt", device="cpu") as check:
|
|
if len(check.keys()) != len(entries):
|
|
raise IOError("保存後のテンソル数が一致しません。")
|
|
|
|
os.replace(temp_path, output_path)
|
|
except Exception:
|
|
try:
|
|
if os.path.exists(temp_path):
|
|
os.remove(temp_path)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
print(f"[Krea2ModelSave] saved={output_path} dtype={save_dtype}")
|
|
return output_path
|
|
|
|
|
|
class Krea2ModelMergeSimple:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model1": ("MODEL",),
|
|
"model2": ("MODEL",),
|
|
"ratio": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("MODEL",)
|
|
RETURN_NAMES = ("model",)
|
|
FUNCTION = "merge"
|
|
CATEGORY = CATEGORY
|
|
|
|
def merge(self, model1, model2, ratio):
|
|
return (_merge_models(model1, model2, ratio),)
|
|
|
|
|
|
class Krea2ModelSave:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"filename_prefix": ("STRING", {"default": "krea2_merged"}),
|
|
"save_dtype": (_SAVE_DTYPES, {"default": "bf16"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("saved_path",)
|
|
FUNCTION = "save"
|
|
OUTPUT_NODE = True
|
|
CATEGORY = CATEGORY
|
|
|
|
def save(self, model, filename_prefix, save_dtype):
|
|
return (_save_model(model, filename_prefix, save_dtype),)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"Krea2ModelMergeSimple": Krea2ModelMergeSimple,
|
|
"Krea2ModelSave": Krea2ModelSave,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"Krea2ModelMergeSimple": "Krea2 Model Merge Simple",
|
|
"Krea2ModelSave": "Krea2 Model Save",
|
|
}
|