feat(lora): add GPU compilation speedups and V2 TE CPU offload loaders
This commit is contained in:
+1
-12
@@ -58,7 +58,6 @@ venv.bak/
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.cursor/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -84,14 +83,4 @@ main_version.txt
|
||||
pr48_*.txt
|
||||
|
||||
# Local/tool tracking list (do not track in repo)
|
||||
.tracking
|
||||
|
||||
# Ignore scripts folder
|
||||
scripts/
|
||||
|
||||
# Ignore backups
|
||||
backups/
|
||||
backup/
|
||||
# Ignore reflection files
|
||||
反省文*
|
||||
反省文/
|
||||
.tracking
|
||||
+13
-21
@@ -2,7 +2,7 @@ import logging
|
||||
import os
|
||||
|
||||
# Version information - must be at module level for ComfyUI Manager
|
||||
__version__ = "2.5.1"
|
||||
__version__ = "2.4.0"
|
||||
|
||||
# Get log level from environment variable (default to INFO)
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
@@ -26,6 +26,8 @@ ZIMAGETURBO_V2_NODES = {}
|
||||
ZIMAGETURBO_V2_NAMES = {}
|
||||
ZIMAGETURBO_V4_NODES = {}
|
||||
ZIMAGETURBO_V4_NAMES = {}
|
||||
TE_V2_NODES = {}
|
||||
TE_V2_NAMES = {}
|
||||
|
||||
# --- Nunchaku Monkey Patch Application ---
|
||||
try:
|
||||
@@ -53,7 +55,11 @@ try:
|
||||
# Z-Image-Turbo V3 is deprecated - removed from registration
|
||||
# from .nodes.lora.zimageturbo_v3 import GENERATED_NODES as ZIMAGETURBO_V3_NODES, GENERATED_DISPLAY_NAMES as ZIMAGETURBO_V3_NAMES
|
||||
from .nodes.lora.zimageturbo_v4 import GENERATED_NODES as ZIMAGETURBO_V4_NODES, GENERATED_DISPLAY_NAMES as ZIMAGETURBO_V4_NAMES
|
||||
from .nodes.lora.krea2_controlnet_lora import Krea2ControlNetLoraLoader
|
||||
|
||||
try:
|
||||
from .nodes.te_offload.nunchaku_te_v2 import GENERATED_NODES as TE_V2_NODES, GENERATED_DISPLAY_NAMES as TE_V2_NAMES
|
||||
except Exception as _te_v2_err:
|
||||
logger.warning(f"TE V2 loader nodes could not be loaded (ComfyUI-nunchaku not found?): {_te_v2_err}")
|
||||
|
||||
# Add version to classes before creating NODE_CLASS_MAPPINGS
|
||||
NunchakuQwenImageLoraLoader.__version__ = __version__
|
||||
@@ -73,7 +79,8 @@ try:
|
||||
# node_class.__version__ = __version__
|
||||
for node_class in ZIMAGETURBO_V4_NODES.values():
|
||||
node_class.__version__ = __version__
|
||||
Krea2ControlNetLoraLoader.__version__ = __version__
|
||||
for node_class in TE_V2_NODES.values():
|
||||
node_class.__version__ = __version__
|
||||
|
||||
NODE_CLASS_MAPPINGS["NunchakuQwenImageLoraLoader"] = NunchakuQwenImageLoraLoader
|
||||
NODE_CLASS_MAPPINGS["NunchakuQwenImageLoraStack"] = NunchakuQwenImageLoraStack
|
||||
@@ -85,21 +92,10 @@ try:
|
||||
# Z-Image-Turbo V3 registration removed
|
||||
# NODE_CLASS_MAPPINGS.update(ZIMAGETURBO_V3_NODES)
|
||||
NODE_CLASS_MAPPINGS.update(ZIMAGETURBO_V4_NODES)
|
||||
NODE_CLASS_MAPPINGS["Krea2ControlNetLoraLoader"] = Krea2ControlNetLoraLoader
|
||||
NODE_CLASS_MAPPINGS.update(TE_V2_NODES)
|
||||
except ImportError:
|
||||
logger.exception("LoRA nodes import failed:")
|
||||
|
||||
# Try to import ControlNet node separately - it may fail if comfy.ldm.lumina.controlnet is not available
|
||||
try:
|
||||
from .nodes.controlnet import NunchakuQwenImageDiffsynthControlnet
|
||||
NunchakuQwenImageDiffsynthControlnet.__version__ = __version__
|
||||
NODE_CLASS_MAPPINGS["NunchakuQwenImageDiffsynthControlnet"] = NunchakuQwenImageDiffsynthControlnet
|
||||
logger.info("ControlNet node loaded successfully")
|
||||
except ImportError:
|
||||
logger.warning("ControlNet node not available (comfy.ldm.lumina.controlnet not found). LoRA nodes will still work.")
|
||||
except Exception as e:
|
||||
logger.warning(f"ControlNet node failed to load: {e}. LoRA nodes will still work.")
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"NunchakuQwenImageLoraLoader": "Nunchaku Qwen Image LoRA Loader",
|
||||
"NunchakuQwenImageLoraStack": "Nunchaku Qwen Image LoRA Stack (Legacy)",
|
||||
@@ -110,13 +106,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
**ZIMAGETURBO_V2_NAMES,
|
||||
# Z-Image-Turbo V3 registration removed
|
||||
# **ZIMAGETURBO_V3_NAMES,
|
||||
**ZIMAGETURBO_V4_NAMES
|
||||
**ZIMAGETURBO_V4_NAMES,
|
||||
**TE_V2_NAMES,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS["Krea2ControlNetLoraLoader"] = "Krea2 controlnet lora loader"
|
||||
|
||||
# Add ControlNet display name only if the node was successfully loaded
|
||||
if "NunchakuQwenImageDiffsynthControlnet" in NODE_CLASS_MAPPINGS:
|
||||
NODE_DISPLAY_NAME_MAPPINGS["NunchakuQwenImageDiffsynthControlnet"] = "NunchakuQI&ZITDiffsynthControlnet"
|
||||
|
||||
# Register JavaScript extensions
|
||||
WEB_DIRECTORY = "js"
|
||||
|
||||
@@ -63,6 +63,12 @@ app.registerExtension({
|
||||
node.cachedApplyAwqMod = applyAwqModWidget;
|
||||
}
|
||||
|
||||
// Cache save_precompiled_lora widget
|
||||
const savePrecompiledWidget = all.find(w => w.name === "save_precompiled_lora");
|
||||
if (savePrecompiledWidget) {
|
||||
node.cachedSavePrecompiled = savePrecompiledWidget;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const wName = all.find(w => w.name === `lora_name_${i}`);
|
||||
const wStrength = all.find(w => w.name === `lora_strength_${i}`);
|
||||
@@ -141,6 +147,11 @@ app.registerExtension({
|
||||
this.widgets.push(node.cachedApplyAwqMod);
|
||||
}
|
||||
|
||||
// Add save_precompiled_lora widget from cache
|
||||
if (node.cachedSavePrecompiled) {
|
||||
this.widgets.push(node.cachedSavePrecompiled);
|
||||
}
|
||||
|
||||
// Add only visible LoRA slots (non-visible widgets are removed from array)
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const pair = this.cachedWidgets[i];
|
||||
@@ -155,8 +166,9 @@ app.registerExtension({
|
||||
const SLOT_H = 54;
|
||||
const CPU_OFFLOAD_H = node.cachedCpuOffload ? 40 : 0;
|
||||
const APPLY_AWQ_MOD_H = node.cachedApplyAwqMod ? 30 : 0;
|
||||
const SAVE_PRECOMPILED_H = node.cachedSavePrecompiled ? 30 : 0;
|
||||
const PADDING = 20;
|
||||
const targetH = HEADER_H + CPU_OFFLOAD_H + APPLY_AWQ_MOD_H + (count * SLOT_H) + PADDING;
|
||||
const targetH = HEADER_H + CPU_OFFLOAD_H + APPLY_AWQ_MOD_H + SAVE_PRECOMPILED_H + (count * SLOT_H) + PADDING;
|
||||
|
||||
this.setSize([this.size[0], targetH]);
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ app.registerExtension({
|
||||
const toggleAllWidget = all.find(w => w.name === "toggle_all");
|
||||
if (toggleAllWidget) node.cachedToggleAll = toggleAllWidget;
|
||||
|
||||
const savePrecompiledWidget = all.find(w => w.name === "save_precompiled_lora");
|
||||
if (savePrecompiledWidget) node.cachedSavePrecompiled = savePrecompiledWidget;
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const wEnabled = all.find(w => w.name === `enabled_${i}`);
|
||||
const wName = all.find(w => w.name === `lora_name_${i}`);
|
||||
@@ -132,6 +135,8 @@ app.registerExtension({
|
||||
|
||||
if (node.cachedCpuOffload) this.widgets.push(node.cachedCpuOffload);
|
||||
|
||||
if (node.cachedSavePrecompiled) this.widgets.push(node.cachedSavePrecompiled);
|
||||
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const pair = this.cachedWidgets[i];
|
||||
if (pair && pair.length >= 3) {
|
||||
@@ -152,8 +157,9 @@ app.registerExtension({
|
||||
const SLOT_H = 80;
|
||||
const TOGGLE_ALL_H = toggleAllWidget ? 30 : 0;
|
||||
const CPU_OFFLOAD_H = node.cachedCpuOffload ? 30 : 0;
|
||||
const SAVE_PRECOMPILED_H = node.cachedSavePrecompiled ? 30 : 0;
|
||||
const PADDING = 20;
|
||||
const targetH = HEADER_H + TOGGLE_ALL_H + CPU_OFFLOAD_H + (count * SLOT_H) + PADDING;
|
||||
const targetH = HEADER_H + TOGGLE_ALL_H + CPU_OFFLOAD_H + SAVE_PRECOMPILED_H + (count * SLOT_H) + PADDING;
|
||||
this.setSize([this.size[0], targetH]);
|
||||
if (app.canvas) app.canvas.setDirty(true, true);
|
||||
};
|
||||
|
||||
+54
-11
@@ -33,17 +33,21 @@ class NunchakuQwenImageLoraLoader:
|
||||
Node for loading and applying a LoRA to a Nunchaku Qwen Image model.
|
||||
"""
|
||||
@classmethod
|
||||
def IS_CHANGED(s, model, lora_name, lora_strength, cpu_offload="disable", *args, **kwargs):
|
||||
def IS_CHANGED(s, lora_name, lora_strength, cpu_offload="disable", save_precompiled_lora=False, **kwargs):
|
||||
"""
|
||||
Detect changes to trigger node re-execution.
|
||||
Returns a hash of relevant parameters to detect changes.
|
||||
Note: model is intentionally excluded — ComfyUI does not pass MODEL-type
|
||||
inputs (which come from other nodes' outputs) to IS_CHANGED.
|
||||
Including it would cause a TypeError, which ComfyUI converts to NaN,
|
||||
making the node always re-execute on every generation.
|
||||
"""
|
||||
import hashlib
|
||||
m = hashlib.sha256()
|
||||
m.update(lora_name.encode())
|
||||
m.update(str(lora_strength).encode())
|
||||
m.update(str(model).encode())
|
||||
m.update(cpu_offload.encode())
|
||||
m.update(str(save_precompiled_lora).encode())
|
||||
return m.digest().hex()
|
||||
|
||||
@classmethod
|
||||
@@ -78,7 +82,22 @@ class NunchakuQwenImageLoraLoader:
|
||||
"tooltip": "CPU offload setting. 'auto' enables offload when VRAM is low, 'enable' forces offload, 'disable' disables offload.",
|
||||
},
|
||||
),
|
||||
}
|
||||
},
|
||||
"optional": {
|
||||
"save_precompiled_lora": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"When enabled, saves a pre-fused structural cache of this LoRA to "
|
||||
"ComfyUI/models/SVDQLora/<name>_precompiled.safetensors after the first "
|
||||
"load. Subsequent loads skip the regex classify and tensor fusion stages, "
|
||||
"reducing per-generation CPU latency. Strength scaling still applies at "
|
||||
"inference time. Disable to force a full re-fuse (e.g. after updating the LoRA)."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL",)
|
||||
@@ -88,7 +107,7 @@ class NunchakuQwenImageLoraLoader:
|
||||
CATEGORY = "Nunchaku"
|
||||
DESCRIPTION = "LoRAs are used to modify the diffusion model, altering the way in which latents are denoised."
|
||||
|
||||
def load_lora(self, model, lora_name: str, lora_strength: float, cpu_offload: str = "disable"):
|
||||
def load_lora(self, model, lora_name: str, lora_strength: float, cpu_offload: str = "disable", save_precompiled_lora: bool = False):
|
||||
if abs(lora_strength) < 1e-5:
|
||||
return (model,)
|
||||
|
||||
@@ -203,9 +222,12 @@ class NunchakuQwenImageLoraLoader:
|
||||
ret_model_wrapper.model = transformer
|
||||
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||
# Carry the save_precompiled flag as a metadata dict in the 3-tuple so the
|
||||
# wrapper can forward it to compose_loras_v2 without changing the public
|
||||
# (path, strength) contract used elsewhere.
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength, {"save_precompiled": save_precompiled_lora}))
|
||||
|
||||
logger.info(f"LoRA added: {lora_name} (strength={lora_strength})")
|
||||
logger.info(f"LoRA added: {lora_name} (strength={lora_strength}, save_precompiled={save_precompiled_lora})")
|
||||
logger.debug(f"Total LoRAs: {len(ret_model_wrapper.loras)}")
|
||||
|
||||
return (ret_model,)
|
||||
@@ -216,16 +238,20 @@ class NunchakuQwenImageLoraStack:
|
||||
Node for loading and applying multiple LoRAs to a Nunchaku Qwen Image model with dynamic UI.
|
||||
"""
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, model, lora_count, cpu_offload="disable", **kwargs):
|
||||
def IS_CHANGED(cls, lora_count, cpu_offload="disable", save_precompiled_lora=False, **kwargs):
|
||||
"""
|
||||
Detect changes to trigger node re-execution.
|
||||
Returns a hash of relevant parameters to detect changes.
|
||||
Note: model is intentionally excluded — ComfyUI does not pass MODEL-type
|
||||
inputs (which come from other nodes' outputs) to IS_CHANGED.
|
||||
Including it would cause a TypeError, which ComfyUI converts to NaN,
|
||||
making the node always re-execute on every generation.
|
||||
"""
|
||||
import hashlib
|
||||
m = hashlib.sha256()
|
||||
m.update(str(model).encode())
|
||||
m.update(str(lora_count).encode())
|
||||
m.update(cpu_offload.encode())
|
||||
m.update(str(save_precompiled_lora).encode())
|
||||
# Hash all LoRA parameters
|
||||
for i in range(1, 11):
|
||||
m.update(kwargs.get(f"lora_name_{i}", "").encode())
|
||||
@@ -264,6 +290,20 @@ class NunchakuQwenImageLoraStack:
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"save_precompiled_lora": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"When enabled, saves a pre-fused structural cache for each LoRA in the "
|
||||
"stack to ComfyUI/models/SVDQLora/. Subsequent loads skip the regex "
|
||||
"classify and tensor fusion stages, reducing per-generation CPU latency. "
|
||||
"Strength scaling still applies at inference time."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# Add all LoRA inputs (up to 10 slots)
|
||||
@@ -292,7 +332,7 @@ class NunchakuQwenImageLoraStack:
|
||||
CATEGORY = "Nunchaku"
|
||||
DESCRIPTION = "Apply multiple LoRAs to a diffusion model in a single node with dynamic UI control. v1.0.3"
|
||||
|
||||
def load_lora_stack(self, model, lora_count, cpu_offload="disable", **kwargs):
|
||||
def load_lora_stack(self, model, lora_count, cpu_offload="disable", save_precompiled_lora: bool = False, **kwargs):
|
||||
loras_to_apply = []
|
||||
|
||||
# Process only the number of LoRAs specified by lora_count
|
||||
@@ -422,8 +462,11 @@ class NunchakuQwenImageLoraStack:
|
||||
|
||||
for lora_name, lora_strength in loras_to_apply:
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||
logger.debug(f"LoRA added to stack: {lora_name} (strength={lora_strength})")
|
||||
# Carry the save_precompiled flag as a metadata dict in the 3-tuple so the
|
||||
# wrapper can forward it to compose_loras_v2 without changing the public
|
||||
# (path, strength) contract used elsewhere.
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength, {"save_precompiled": save_precompiled_lora}))
|
||||
logger.debug(f"LoRA added to stack: {lora_name} (strength={lora_strength}, save_precompiled={save_precompiled_lora})")
|
||||
|
||||
logger.info(f"Total LoRAs in stack: {len(ret_model_wrapper.loras)}")
|
||||
|
||||
|
||||
@@ -76,7 +76,20 @@ class NunchakuQwenImageLoraStackV1:
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(),
|
||||
"optional": FlexibleOptionalInputType({
|
||||
"save_precompiled_lora": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"When enabled, saves a pre-fused structural cache for each LoRA to "
|
||||
"ComfyUI/models/SVDQLora/. Subsequent loads skip regex classify and tensor "
|
||||
"fusion, reducing per-generation CPU latency. Strength scaling still applies "
|
||||
"at inference time."
|
||||
),
|
||||
},
|
||||
),
|
||||
}),
|
||||
# Use 'hidden' here to let JS pass dynamic LoRA data
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
@@ -93,7 +106,7 @@ class NunchakuQwenImageLoraStackV1:
|
||||
CATEGORY = "Nunchaku"
|
||||
DESCRIPTION = "Apply multiple LoRAs to a diffusion model in a single node with dynamic UI control."
|
||||
|
||||
def load_lora_stack(self, model, cpu_offload="disable", apply_awq_mod=True, stack_enabled=True, **kwargs):
|
||||
def load_lora_stack(self, model, cpu_offload="disable", apply_awq_mod=True, stack_enabled=True, save_precompiled_lora=False, **kwargs):
|
||||
# Dynamic widgets passed from JS will appear in kwargs
|
||||
# Named lora_1, lora_2... with values as dictionaries: {'enabled': bool, 'lora_name': str, 'lora_strength': float}
|
||||
lora_keys = [key for key in kwargs.keys() if key.startswith("lora_")]
|
||||
@@ -231,8 +244,8 @@ class NunchakuQwenImageLoraStackV1:
|
||||
|
||||
for lora_name, lora_strength in loras_to_apply:
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||
logger.debug(f"LoRA added to stack: {lora_name} (strength={lora_strength})")
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength, {"save_precompiled": save_precompiled_lora}))
|
||||
logger.debug(f"LoRA added to stack: {lora_name} (strength={lora_strength}, save_precompiled={save_precompiled_lora})")
|
||||
|
||||
logger.info(f"Total LoRAs in stack: {len(ret_model_wrapper.loras)}")
|
||||
return (ret_model,)
|
||||
|
||||
@@ -33,7 +33,7 @@ class NunchakuQwenImageLoraStackV2:
|
||||
Node for loading and applying multiple LoRAs to a Nunchaku Qwen Image model with dynamic UI.
|
||||
"""
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, model, lora_count, cpu_offload="disable", **kwargs):
|
||||
def IS_CHANGED(cls, model, lora_count, cpu_offload="disable", save_precompiled_lora=False, **kwargs):
|
||||
"""
|
||||
Detect changes to trigger node re-execution.
|
||||
Returns a hash of relevant parameters to detect changes.
|
||||
@@ -44,6 +44,7 @@ class NunchakuQwenImageLoraStackV2:
|
||||
m.update(str(lora_count).encode())
|
||||
m.update(cpu_offload.encode())
|
||||
m.update(str(kwargs.get("apply_awq_mod", False)).encode())
|
||||
m.update(str(save_precompiled_lora).encode())
|
||||
# Hash all LoRA parameters
|
||||
for i in range(1, 11):
|
||||
m.update(kwargs.get(f"lora_name_{i}", "").encode())
|
||||
@@ -89,7 +90,20 @@ class NunchakuQwenImageLoraStackV2:
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {},
|
||||
"optional": {
|
||||
"save_precompiled_lora": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"When enabled, saves a pre-fused structural cache for each LoRA to "
|
||||
"ComfyUI/models/SVDQLora/. Subsequent loads skip regex classify and tensor "
|
||||
"fusion, reducing per-generation CPU latency. Strength scaling still applies "
|
||||
"at inference time."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# Add all LoRA inputs (up to 10 slots) as optional
|
||||
@@ -118,7 +132,7 @@ class NunchakuQwenImageLoraStackV2:
|
||||
CATEGORY = "Nunchaku"
|
||||
DESCRIPTION = "Apply multiple LoRAs to a diffusion model in a single node with dynamic UI control. v1.0.3"
|
||||
|
||||
def load_lora_stack(self, model, lora_count, cpu_offload="disable", apply_awq_mod=False, **kwargs):
|
||||
def load_lora_stack(self, model, lora_count, cpu_offload="disable", apply_awq_mod=False, save_precompiled_lora=False, **kwargs):
|
||||
loras_to_apply = []
|
||||
|
||||
# Process only the number of LoRAs specified by lora_count
|
||||
@@ -277,8 +291,8 @@ class NunchakuQwenImageLoraStackV2:
|
||||
|
||||
for lora_name, lora_strength in loras_to_apply:
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||
logger.debug(f"LoRA added to stack: {lora_name} (strength={lora_strength})")
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength, {"save_precompiled": save_precompiled_lora}))
|
||||
logger.debug(f"LoRA added to stack: {lora_name} (strength={lora_strength}, save_precompiled={save_precompiled_lora})")
|
||||
|
||||
logger.info(f"Total LoRAs in stack: {len(ret_model_wrapper.loras)}")
|
||||
|
||||
|
||||
+27
-17
@@ -14,9 +14,6 @@ current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
lora_loader_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||
if lora_loader_dir not in sys.path:
|
||||
sys.path.insert(0, lora_loader_dir)
|
||||
print(f"[DEBUG] Added to sys.path: {lora_loader_dir}")
|
||||
print(f"[DEBUG] wrappers dir exists: {os.path.exists(os.path.join(lora_loader_dir, 'wrappers'))}")
|
||||
print(f"[DEBUG] qwenimage.py exists: {os.path.exists(os.path.join(lora_loader_dir, 'wrappers', 'qwenimage.py'))}")
|
||||
|
||||
import folder_paths
|
||||
|
||||
@@ -35,18 +32,22 @@ class NunchakuQwenImageLoraStackV2:
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, model, lora_count, cpu_offload="disable", toggle_all=True, **kwargs):
|
||||
def IS_CHANGED(cls, lora_count, cpu_offload="disable", toggle_all=True, save_precompiled_lora=False, **kwargs):
|
||||
"""
|
||||
Detect changes to trigger node re-execution.
|
||||
Returns a hash of relevant parameters to detect changes.
|
||||
Note: model is intentionally excluded — ComfyUI does not pass MODEL-type
|
||||
inputs (which come from other nodes' outputs) to IS_CHANGED.
|
||||
Including it would cause a TypeError, which ComfyUI converts to NaN,
|
||||
making the node always re-execute on every generation.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
m = hashlib.sha256()
|
||||
m.update(str(model).encode())
|
||||
m.update(str(lora_count).encode())
|
||||
m.update(cpu_offload.encode())
|
||||
m.update(str(toggle_all).encode())
|
||||
m.update(str(save_precompiled_lora).encode())
|
||||
# Hash all LoRA parameters
|
||||
for i in range(1, 11):
|
||||
m.update(kwargs.get(f"lora_name_{i}", "").encode())
|
||||
@@ -93,7 +94,20 @@ class NunchakuQwenImageLoraStackV2:
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {},
|
||||
"optional": {
|
||||
"save_precompiled_lora": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"When enabled, saves a pre-fused structural cache for each LoRA to "
|
||||
"ComfyUI/models/SVDQLora/. Subsequent loads skip regex classify and tensor "
|
||||
"fusion, reducing per-generation CPU latency. Strength scaling still applies "
|
||||
"at inference time."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# Add all LoRA inputs (up to 10 slots) as optional
|
||||
@@ -129,7 +143,7 @@ class NunchakuQwenImageLoraStackV2:
|
||||
CATEGORY = "Nunchaku"
|
||||
DESCRIPTION = "Apply multiple LoRAs to a diffusion model in a single node with dynamic UI control."
|
||||
|
||||
def load_lora_stack(self, model, lora_count, cpu_offload="disable", toggle_all=True, **kwargs):
|
||||
def load_lora_stack(self, model, lora_count, cpu_offload="disable", toggle_all=True, save_precompiled_lora=False, **kwargs):
|
||||
loras_to_apply = []
|
||||
|
||||
# Log toggle_all state
|
||||
@@ -185,8 +199,7 @@ class NunchakuQwenImageLoraStackV2:
|
||||
if lora_state_dict and NUNCHAKU_LOG_ENABLED:
|
||||
detection = _detect_lora_format(lora_state_dict)
|
||||
_log_lora_format_detection(str(_fn), detection)
|
||||
# First LoRA: Detailed key inspection (same as zimageturbo_v4.py lines 219-240)
|
||||
print(f"--- DEBUG: Inspecting keys for LoRA 1 (Strength: {_fs}) ---")
|
||||
# First LoRA: Detailed key inspection (only when verbose logging is on)
|
||||
_first_detection = _detect_lora_format(lora_state_dict)
|
||||
if _first_detection["has_standard"]:
|
||||
for key in lora_state_dict.keys():
|
||||
@@ -194,14 +207,11 @@ class NunchakuQwenImageLoraStackV2:
|
||||
if parsed_res:
|
||||
group, base_key, comp, ab = parsed_res
|
||||
mapped_name = f"{base_key}.{comp}.{ab}" if comp and ab else (f"{base_key}.{ab}" if ab else base_key)
|
||||
print(f"Key: {key} -> Mapped to: {mapped_name} (Group: {group})")
|
||||
logger.debug(f"Key: {key} -> Mapped to: {mapped_name} (Group: {group})")
|
||||
else:
|
||||
print(f"Key: {key} -> UNMATCHED (Ignored)")
|
||||
logger.debug(f"Key: {key} -> UNMATCHED (Ignored)")
|
||||
else:
|
||||
print("⚠️ Unsupported LoRA format detected (No standard keys).")
|
||||
print(f" Skipping detailed key inspection of {len(lora_state_dict)} keys to prevent console freeze.")
|
||||
print(" Note: This LoRA will likely have no effect or will be skipped entirely.")
|
||||
print("--- DEBUG: End key inspection ---")
|
||||
logger.debug("⚠️ Unsupported LoRA format detected (No standard keys). Skipping key inspection.")
|
||||
|
||||
model_wrapper = model.model.diffusion_model
|
||||
|
||||
@@ -281,8 +291,8 @@ class NunchakuQwenImageLoraStackV2:
|
||||
|
||||
for lora_name, lora_strength in loras_to_apply:
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||
logger.debug(f"LoRA added to stack: {lora_name} (strength={lora_strength})")
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength, {"save_precompiled": save_precompiled_lora}))
|
||||
logger.debug(f"LoRA added to stack: {lora_name} (strength={lora_strength}, save_precompiled={save_precompiled_lora})")
|
||||
|
||||
logger.info(f"Total LoRAs in stack: {len(ret_model_wrapper.loras)}")
|
||||
return (ret_model,)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# TE offload nodes package
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
nodes/te_offload/nunchaku_te_v2.py
|
||||
|
||||
V2 text encoder loader nodes for all nunchaku TE architectures.
|
||||
|
||||
Adds a single ``offload_after_encode`` toggle that moves the entire encoder
|
||||
(LLM + ViT for edit model, LLM-only for text-only / Klein) from CUDA to CPU
|
||||
the moment encode_token_weights() returns, freeing ~4-5 GB of VRAM for the
|
||||
diffusion model during KSampler passes.
|
||||
|
||||
On the *next* encode call the encoder is automatically moved back to CUDA
|
||||
before the forward pass, so second/third cycle workflows work correctly.
|
||||
|
||||
Covers all three nunchaku TE types:
|
||||
- NunchakuQwenImageEditEncoderLoaderV2 (Qwen2.5-VL edit, vision-aware)
|
||||
- NunchakuQwenImageTextEncoderLoaderV2 (Qwen2.5-VL text-only path)
|
||||
- NunchakuQwen3TextEncoderLoaderV2 (FLUX.2 Klein, Qwen3-4B/8B)
|
||||
|
||||
Drop-in replacements for the corresponding nunchaku loader nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import torch
|
||||
import comfy.model_management
|
||||
import folder_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Nunchaku TE module accessor
|
||||
# ===========================================================================
|
||||
|
||||
_nunchaku_te_mod = None
|
||||
_nunchaku_te_tried = False
|
||||
|
||||
|
||||
def _get_nunchaku_te_module():
|
||||
"""
|
||||
Lazily obtain nunchaku's ``qwen_text_encoder`` module.
|
||||
|
||||
Strategy:
|
||||
1. Check ``sys.modules`` (fastest — module already loaded by ComfyUI).
|
||||
2. Load it from disk via ``importlib`` using ``folder_paths.base_path``
|
||||
to construct the path robustly without hard-coding drive letters.
|
||||
|
||||
Returns the module object or raises ``RuntimeError`` if not found.
|
||||
"""
|
||||
global _nunchaku_te_mod, _nunchaku_te_tried
|
||||
if _nunchaku_te_tried:
|
||||
if _nunchaku_te_mod is None:
|
||||
raise RuntimeError(
|
||||
"[TEv2] ComfyUI-nunchaku not found. "
|
||||
"Install it in custom_nodes/ and restart ComfyUI."
|
||||
)
|
||||
return _nunchaku_te_mod
|
||||
|
||||
_nunchaku_te_tried = True
|
||||
|
||||
# 1. Already in sys.modules?
|
||||
for _name, _mod in sys.modules.items():
|
||||
if (
|
||||
_mod is not None
|
||||
and "qwen_text_encoder" in _name
|
||||
and hasattr(_mod, "NunchakuQwenImageEditEncoderLoader")
|
||||
):
|
||||
_nunchaku_te_mod = _mod
|
||||
logger.debug("[TEv2] Found nunchaku TE module in sys.modules: %s", _name)
|
||||
return _nunchaku_te_mod
|
||||
|
||||
# 2. Load from disk
|
||||
te_path = os.path.join(
|
||||
folder_paths.base_path,
|
||||
"custom_nodes", "ComfyUI-nunchaku",
|
||||
"nodes", "models", "qwen_text_encoder.py",
|
||||
)
|
||||
if not os.path.exists(te_path):
|
||||
raise RuntimeError(
|
||||
f"[TEv2] ComfyUI-nunchaku not found at expected path: {te_path}"
|
||||
)
|
||||
|
||||
spec = importlib.util.spec_from_file_location("_nunchaku_qwen_te_v2_ref", te_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
_nunchaku_te_mod = mod
|
||||
logger.debug("[TEv2] Loaded nunchaku TE module from %s", te_path)
|
||||
return _nunchaku_te_mod
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Core offload wrapper
|
||||
# ===========================================================================
|
||||
|
||||
def _wrap_clip_with_offload(clip: object, offload_after_encode: bool) -> object:
|
||||
"""
|
||||
Patch ``clip.cond_stage_model.encode_token_weights`` in-place so that
|
||||
after each successful encode the nunchaku encoder is moved to CPU and
|
||||
CUDA cache is freed. Before the *next* call, the encoder is moved back
|
||||
to CUDA automatically.
|
||||
|
||||
Key subtleties handled:
|
||||
* ``nunchaku_encoder`` is stored via ``object.__setattr__``, bypassing
|
||||
``nn.Module`` submodule registration. ``nn.Module.to()`` ignores it;
|
||||
we must call ``encoder.to()`` directly.
|
||||
* ``_encoder_device`` on the TE model class records where tensors live.
|
||||
Keeping it in sync prevents device-mismatch errors on encode calls.
|
||||
* ``_inv_freq_fixed`` (edit encoder only) is a one-shot guard that runs
|
||||
``_move_cpu_tensors_to_device`` once after load. After a CPU/CUDA
|
||||
round-trip, rotary ``original_inv_freq`` ends up on CPU again, so we
|
||||
reset the flag so the fix re-runs on the next encode.
|
||||
|
||||
Returns the same ``clip`` object (mutated).
|
||||
"""
|
||||
if not offload_after_encode:
|
||||
return clip
|
||||
|
||||
te_model = clip.cond_stage_model
|
||||
|
||||
# Retrieve the encoder reference (bypasses nn.Module submodule tracking)
|
||||
encoder = getattr(te_model, "nunchaku_encoder", None)
|
||||
if encoder is None:
|
||||
logger.warning(
|
||||
"[TEv2] nunchaku_encoder not found on %s — offload skipped.",
|
||||
type(te_model).__name__,
|
||||
)
|
||||
return clip
|
||||
|
||||
# Does this TE class use the _inv_freq_fixed safety guard?
|
||||
# Present on the edit encoder (Qwen2.5-VL rotary embedding CPU-buffer quirk).
|
||||
has_inv_freq_guard = hasattr(te_model, "_inv_freq_fixed")
|
||||
|
||||
# Capture the original bound method once, before we replace it.
|
||||
original_encode = te_model.encode_token_weights
|
||||
|
||||
def encode_with_offload(token_weight_pairs):
|
||||
compute_device = comfy.model_management.get_torch_device()
|
||||
|
||||
# ── 1. Re-load to CUDA if previously offloaded ─────────────────────
|
||||
try:
|
||||
current_device = next(encoder.parameters()).device
|
||||
except StopIteration:
|
||||
# Encoder has no registered parameters (edge case).
|
||||
current_device = torch.device("cpu")
|
||||
|
||||
if current_device.type != compute_device.type:
|
||||
logger.info(
|
||||
"[TE Offload] ↑ %s: CPU → %s",
|
||||
type(te_model).__name__,
|
||||
compute_device,
|
||||
)
|
||||
encoder.to(compute_device)
|
||||
# Sync _encoder_device so encode_token_weights targets the right device
|
||||
object.__setattr__(te_model, "_encoder_device", compute_device)
|
||||
# Reset the one-shot inv_freq guard so it runs again after the move
|
||||
if has_inv_freq_guard:
|
||||
object.__setattr__(te_model, "_inv_freq_fixed", False)
|
||||
|
||||
# ── 2. Run the original encode ──────────────────────────────────────
|
||||
result = original_encode(token_weight_pairs)
|
||||
|
||||
# ── 3. Offload encoder to CPU ───────────────────────────────────────
|
||||
logger.info("[TE Offload] ↓ %s: CUDA → CPU", type(te_model).__name__)
|
||||
encoder.to("cpu")
|
||||
object.__setattr__(te_model, "_encoder_device", torch.device("cpu"))
|
||||
# Reset guard so it runs cleanly on the *next* re-load to CUDA
|
||||
if has_inv_freq_guard:
|
||||
object.__setattr__(te_model, "_inv_freq_fixed", False)
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
return result
|
||||
|
||||
# Assign as a plain function on the *instance* (not a method descriptor).
|
||||
# When called as `te_model.encode_token_weights(tokens)`, Python looks up
|
||||
# the instance dict first, finds our function, and calls it without auto-
|
||||
# prepending `self` — exactly what we want.
|
||||
te_model.encode_token_weights = encode_with_offload
|
||||
|
||||
logger.info(
|
||||
"[TEv2] Post-encode CPU offload patched on %s (inv_freq_guard=%s)",
|
||||
type(te_model).__name__,
|
||||
has_inv_freq_guard,
|
||||
)
|
||||
return clip
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Shared filename helper
|
||||
# ===========================================================================
|
||||
|
||||
def _te_filename_list() -> list[str]:
|
||||
try:
|
||||
# Try nunchaku's own helper (filters to TE checkpoints)
|
||||
mod = _get_nunchaku_te_module()
|
||||
# nunchaku uses get_filename_list from its utils
|
||||
from_nunchaku = getattr(mod, "get_filename_list", None)
|
||||
if from_nunchaku is not None:
|
||||
return from_nunchaku("text_encoders")
|
||||
except Exception:
|
||||
pass
|
||||
return folder_paths.get_filename_list("text_encoders")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Base class — shared loading + wrapping logic
|
||||
# ===========================================================================
|
||||
|
||||
class _BaseNunchakuTELoaderV2:
|
||||
"""
|
||||
Shared base for all V2 TE loader nodes.
|
||||
|
||||
Subclasses set ``_NUNCHAKU_LOADER_CLS`` to the name of the corresponding
|
||||
nunchaku loader class (string), and ``TITLE`` for the UI display.
|
||||
"""
|
||||
|
||||
_NUNCHAKU_LOADER_CLS: str # e.g. "NunchakuQwenImageEditEncoderLoader"
|
||||
|
||||
RETURN_TYPES = ("CLIP",)
|
||||
FUNCTION = "load_text_encoder"
|
||||
CATEGORY = "Nunchaku"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _delegate_load(self, model_path: str) -> object:
|
||||
"""Instantiate nunchaku's loader and return its CLIP output."""
|
||||
mod = _get_nunchaku_te_module()
|
||||
cls = getattr(mod, self._NUNCHAKU_LOADER_CLS, None)
|
||||
if cls is None:
|
||||
raise RuntimeError(
|
||||
f"[TEv2] {self._NUNCHAKU_LOADER_CLS} not found in "
|
||||
f"nunchaku qwen_text_encoder module."
|
||||
)
|
||||
logger.info("[TEv2] Delegating load to %s", self._NUNCHAKU_LOADER_CLS)
|
||||
return cls().load_text_encoder(model_path)[0]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_text_encoder(self, model_path: str, offload_after_encode: bool = True) -> tuple:
|
||||
clip = self._delegate_load(model_path)
|
||||
clip = _wrap_clip_with_offload(clip, offload_after_encode)
|
||||
return (clip,)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V2 Loader node — Qwen2.5-VL Edit (vision-aware, primary use-case)
|
||||
# ===========================================================================
|
||||
|
||||
class NunchakuQwenImageEditEncoderLoaderV2(_BaseNunchakuTELoaderV2):
|
||||
"""
|
||||
V2 loader for the Nunchaku Qwen2.5-VL edit (multimodal) text encoder.
|
||||
|
||||
Identical to ``NunchakuQwenImageEditEncoderLoader`` in nunchaku, but with
|
||||
an ``offload_after_encode`` toggle that frees ~4-5 GB of VRAM after each
|
||||
encode call, making that memory available to the diffusion model during
|
||||
KSampler passes. The encoder is automatically restored to CUDA before the
|
||||
next encode, so multi-KSampler workflows function correctly on every cycle.
|
||||
|
||||
Drop-in replacement — swap this node for the nunchaku loader, enable the
|
||||
toggle, and enjoy extra free VRAM during sampling.
|
||||
"""
|
||||
|
||||
_NUNCHAKU_LOADER_CLS = "NunchakuQwenImageEditEncoderLoader"
|
||||
TITLE = "Nunchaku Qwen2.5-VL Edit Encoder Loader V2 (TE Offload)"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model_path": (
|
||||
_te_filename_list(),
|
||||
{
|
||||
"tooltip": (
|
||||
"Nunchaku-quantised Qwen2.5-VL edit/multimodal encoder "
|
||||
"checkpoint (.safetensors)."
|
||||
)
|
||||
},
|
||||
),
|
||||
"offload_after_encode": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": (
|
||||
"After each encode_token_weights call, move the entire text "
|
||||
"encoder (LLM + vision ViT, ~4-5 GB) to CPU and free VRAM. "
|
||||
"The encoder is automatically restored to CUDA before the next "
|
||||
"encode, so multi-KSampler workflows work correctly on every run."
|
||||
),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V2 Loader node — Qwen2.5-VL text-only
|
||||
# ===========================================================================
|
||||
|
||||
class NunchakuQwenImageTextEncoderLoaderV2(_BaseNunchakuTELoaderV2):
|
||||
"""
|
||||
V2 loader for the Nunchaku Qwen2.5-VL text-only encoder (QwenImage path).
|
||||
|
||||
Adds the same ``offload_after_encode`` toggle as the edit variant.
|
||||
"""
|
||||
|
||||
_NUNCHAKU_LOADER_CLS = "NunchakuQwenImageTextEncoderLoader"
|
||||
TITLE = "Nunchaku Qwen2.5-VL Text Encoder Loader V2 (TE Offload)"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model_path": (
|
||||
_te_filename_list(),
|
||||
{
|
||||
"tooltip": (
|
||||
"Nunchaku-quantised Qwen2.5-VL text-only encoder "
|
||||
"checkpoint (.safetensors)."
|
||||
)
|
||||
},
|
||||
),
|
||||
"offload_after_encode": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": (
|
||||
"After encoding, move the text encoder to CPU and free its VRAM "
|
||||
"for the diffusion model. Encoder restores to CUDA before the next encode."
|
||||
),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V2 Loader node — Qwen3 / FLUX.2 Klein
|
||||
# ===========================================================================
|
||||
|
||||
class NunchakuQwen3TextEncoderLoaderV2(_BaseNunchakuTELoaderV2):
|
||||
"""
|
||||
V2 loader for the Nunchaku Qwen3 text encoder (FLUX.2 Klein, Qwen3-4B/8B).
|
||||
|
||||
Adds the same ``offload_after_encode`` toggle. Klein does not have the
|
||||
Qwen2.5-VL rotary-buffer quirk, so the offload/restore cycle is simpler.
|
||||
"""
|
||||
|
||||
_NUNCHAKU_LOADER_CLS = "NunchakuQwen3TextEncoderLoader"
|
||||
TITLE = "Nunchaku Qwen3 Text Encoder Loader V2 (TE Offload)"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model_path": (
|
||||
_te_filename_list(),
|
||||
{
|
||||
"tooltip": (
|
||||
"Nunchaku-quantised Qwen3 text encoder checkpoint "
|
||||
"(qwen3-4b or qwen3-8b, .safetensors)."
|
||||
)
|
||||
},
|
||||
),
|
||||
"offload_after_encode": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": (
|
||||
"After encoding, move the Qwen3 text encoder to CPU and free VRAM. "
|
||||
"Encoder restores to CUDA automatically before the next encode call."
|
||||
),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Node registration dicts (consumed by __init__.py)
|
||||
# ===========================================================================
|
||||
|
||||
GENERATED_NODES = {
|
||||
"NunchakuQwenImageEditEncoderLoaderV2": NunchakuQwenImageEditEncoderLoaderV2,
|
||||
"NunchakuQwenImageTextEncoderLoaderV2": NunchakuQwenImageTextEncoderLoaderV2,
|
||||
"NunchakuQwen3TextEncoderLoaderV2": NunchakuQwen3TextEncoderLoaderV2,
|
||||
}
|
||||
|
||||
GENERATED_DISPLAY_NAMES = {
|
||||
"NunchakuQwenImageEditEncoderLoaderV2": "Nunchaku Qwen2.5-VL Edit Encoder Loader V2 (TE Offload)",
|
||||
"NunchakuQwenImageTextEncoderLoaderV2": "Nunchaku Qwen2.5-VL Text Encoder Loader V2 (TE Offload)",
|
||||
"NunchakuQwen3TextEncoderLoaderV2": "Nunchaku Qwen3 Text Encoder Loader V2 (TE Offload)",
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
lora_cache.py — Pre-compiled LoRA structural cache utilities.
|
||||
|
||||
Provides save/load helpers for caching the result of the expensive
|
||||
classify → fuse pipeline in compose_loras_v2. The cache stores the
|
||||
raw fused (A, B, alpha) tensor pairs *before* strength scaling so that
|
||||
the strength slider still works correctly at inference time.
|
||||
|
||||
Cache layout on disk:
|
||||
ComfyUI/models/SVDQLora/
|
||||
<lora_stem>_precompiled.safetensors ← fused tensors
|
||||
<lora_stem>_precompiled.safetensors.meta.json ← mtime sidecar
|
||||
|
||||
Tensor key format inside the .safetensors file:
|
||||
"<module_key>__A" → fused A tensor [rank, in_features]
|
||||
"<module_key>__B" → fused B tensor [out_features, rank] (NOT strength-scaled)
|
||||
"<module_key>__alpha" → alpha scalar as float32 1-D tensor (optional, omitted if None)
|
||||
|
||||
Sidecar JSON format:
|
||||
{
|
||||
"source_mtime": <float>, # os.path.getmtime() of the original .safetensors
|
||||
"source_path": <str> # absolute path of original LoRA (informational)
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Separator used to encode module_key and tensor role inside a flat safetensors dict.
|
||||
# Double-underscore is safe because module keys use dot-notation (e.g. "transformer_blocks.0.attn.to_qkv").
|
||||
_SEP = "__"
|
||||
|
||||
# Roles stored per module entry
|
||||
_ROLE_A = "A"
|
||||
_ROLE_B = "B"
|
||||
_ROLE_ALPHA = "alpha"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Directory helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_cache_dir(comfy_base_dir: str) -> Path:
|
||||
"""
|
||||
Return (and create if needed) the dedicated SVDQLora cache directory.
|
||||
|
||||
Args:
|
||||
comfy_base_dir: Root of the ComfyUI installation, e.g. ``"C:/ComfyUI"``.
|
||||
|
||||
Returns:
|
||||
``Path`` pointing to ``<comfy_base_dir>/models/SVDQLora/``.
|
||||
"""
|
||||
cache_dir = Path(comfy_base_dir) / "models" / "SVDQLora"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
|
||||
def get_cache_path(lora_path: str | Path, cache_dir: Path) -> Path:
|
||||
"""
|
||||
Derive the cache .safetensors path for a given LoRA source file.
|
||||
|
||||
The cache filename includes a short hash of the LoRA's parent directory so
|
||||
that two LoRAs with identical filenames in different subdirectories (e.g.
|
||||
``Qwen-Edit/v1/lora.safetensors`` vs ``Qwen-Edit/v2/lora.safetensors``)
|
||||
never collide to the same cache entry.
|
||||
|
||||
Args:
|
||||
lora_path: Full path to the original LoRA file.
|
||||
cache_dir: Directory returned by :func:`get_cache_dir`.
|
||||
|
||||
Returns:
|
||||
``Path`` like ``<cache_dir>/<stem>_<dir_hash8>_precompiled.safetensors``.
|
||||
"""
|
||||
lora_path = Path(lora_path)
|
||||
stem = lora_path.stem
|
||||
# Hash the parent directory path so same-named LoRAs in different dirs
|
||||
# produce distinct cache keys.
|
||||
parent_hash = hashlib.sha256(str(lora_path.parent).encode()).hexdigest()[:8]
|
||||
return cache_dir / f"{stem}_{parent_hash}_precompiled.safetensors"
|
||||
|
||||
|
||||
def _get_meta_path(cache_path: Path) -> Path:
|
||||
"""Return the sidecar JSON path for a given cache file."""
|
||||
return cache_path.with_suffix(cache_path.suffix + ".meta.json")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_cache_valid(lora_path: str | Path, cache_path: Path) -> bool:
|
||||
"""
|
||||
Return ``True`` iff a usable, up-to-date cache exists for *lora_path*.
|
||||
|
||||
Validity criteria:
|
||||
1. ``cache_path`` exists on disk.
|
||||
2. The mtime sidecar JSON exists.
|
||||
3. The recorded ``source_mtime`` matches the current mtime of *lora_path*.
|
||||
|
||||
A missing or unreadable sidecar is treated as invalid (forces re-fuse).
|
||||
|
||||
Args:
|
||||
lora_path: Full path to the original LoRA file.
|
||||
cache_path: Path as returned by :func:`get_cache_path`.
|
||||
"""
|
||||
if not cache_path.is_file():
|
||||
return False
|
||||
|
||||
meta_path = _get_meta_path(cache_path)
|
||||
if not meta_path.is_file():
|
||||
logger.debug(f"[CACHE] No sidecar for {cache_path.name}, treating as invalid.")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(meta_path, "r", encoding="utf-8") as fh:
|
||||
meta = json.load(fh)
|
||||
recorded_mtime: float = meta["source_mtime"]
|
||||
except (KeyError, json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning(f"[CACHE] Failed to read sidecar {meta_path.name}: {exc}. Treating as invalid.")
|
||||
return False
|
||||
|
||||
try:
|
||||
current_mtime = os.path.getmtime(str(lora_path))
|
||||
except OSError as exc:
|
||||
logger.warning(f"[CACHE] Cannot stat source LoRA {lora_path}: {exc}. Treating cache as invalid.")
|
||||
return False
|
||||
|
||||
if abs(current_mtime - recorded_mtime) > 1e-3:
|
||||
logger.info(
|
||||
f"[CACHE] Stale: source mtime changed for {Path(lora_path).name}. "
|
||||
f"Recorded={recorded_mtime:.3f}, current={current_mtime:.3f}. "
|
||||
"Will re-fuse and overwrite cache."
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Save
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def save_precompiled(
|
||||
processed_groups: Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]],
|
||||
lora_path: str | Path,
|
||||
cache_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Serialise the fused ``processed_groups`` dict to *cache_path*.
|
||||
|
||||
``processed_groups`` has the shape produced by the classify+fuse stage of
|
||||
``compose_loras_v2``: ``{ module_key: (A, B, alpha) }``.
|
||||
|
||||
All tensors are moved to CPU before saving (the cache is always CPU-side;
|
||||
device placement happens later when ``_apply_lora_to_module`` runs).
|
||||
|
||||
Args:
|
||||
processed_groups: Fused tensor pairs from the classify+fuse stage.
|
||||
lora_path: Full path to the original LoRA file (used for mtime).
|
||||
cache_path: Destination .safetensors file.
|
||||
"""
|
||||
# Lazy import so safetensors is only required when actually saving.
|
||||
try:
|
||||
from safetensors.torch import save_file as st_save_file
|
||||
except ImportError as exc:
|
||||
logger.error(f"[CACHE] Cannot save precompiled cache: safetensors not available. {exc}")
|
||||
return
|
||||
|
||||
if not processed_groups:
|
||||
logger.warning("[CACHE] processed_groups is empty — nothing to save.")
|
||||
return
|
||||
|
||||
flat: Dict[str, torch.Tensor] = {}
|
||||
|
||||
for module_key, (A, B, alpha) in processed_groups.items():
|
||||
if A is None or B is None:
|
||||
logger.debug(f"[CACHE] Skipping {module_key}: A or B is None.")
|
||||
continue
|
||||
|
||||
# Validate tensor shapes before saving
|
||||
if A.ndim != 2 or B.ndim != 2:
|
||||
logger.warning(
|
||||
f"[CACHE] Skipping {module_key}: expected 2-D tensors, "
|
||||
f"got A.ndim={A.ndim}, B.ndim={B.ndim}."
|
||||
)
|
||||
continue
|
||||
|
||||
# Move to CPU; safetensors does not accept CUDA tensors.
|
||||
flat[f"{module_key}{_SEP}{_ROLE_A}"] = A.detach().cpu().contiguous()
|
||||
flat[f"{module_key}{_SEP}{_ROLE_B}"] = B.detach().cpu().contiguous()
|
||||
|
||||
if alpha is not None:
|
||||
# Normalise to a 1-D float32 scalar tensor for portability.
|
||||
if isinstance(alpha, torch.Tensor):
|
||||
flat[f"{module_key}{_SEP}{_ROLE_ALPHA}"] = alpha.detach().cpu().float().reshape(1)
|
||||
else:
|
||||
flat[f"{module_key}{_SEP}{_ROLE_ALPHA}"] = torch.tensor([float(alpha)], dtype=torch.float32)
|
||||
|
||||
if not flat:
|
||||
logger.warning("[CACHE] No valid tensors to write after filtering — cache not saved.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Write to a temp file first; atomically rename to the final path.
|
||||
# This prevents a partially-written file from being mistaken as valid
|
||||
# if ComfyUI is interrupted mid-write (which would corrupt the cache).
|
||||
tmp_path = cache_path.with_suffix(".safetensors.tmp")
|
||||
st_save_file(flat, str(tmp_path))
|
||||
os.replace(str(tmp_path), str(cache_path))
|
||||
logger.info(f"[CACHE SAVE] Written {len(processed_groups)} module entries → {cache_path}")
|
||||
except Exception as exc:
|
||||
logger.error(f"[CACHE] Failed to write {cache_path}: {exc}")
|
||||
# Clean up temp file if it was created
|
||||
try:
|
||||
tmp_path = cache_path.with_suffix(".safetensors.tmp")
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Write mtime sidecar
|
||||
meta_path = _get_meta_path(cache_path)
|
||||
try:
|
||||
source_mtime = os.path.getmtime(str(lora_path))
|
||||
meta = {
|
||||
"source_mtime": source_mtime,
|
||||
"source_path": str(Path(lora_path).resolve()),
|
||||
}
|
||||
with open(meta_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(meta, fh, indent=2)
|
||||
logger.debug(f"[CACHE] Sidecar written: {meta_path.name}")
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
f"[CACHE] Could not write mtime sidecar {meta_path.name}: {exc}. "
|
||||
"Cache will be treated as invalid on next run."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_precompiled(
|
||||
cache_path: Path,
|
||||
) -> Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]]:
|
||||
"""
|
||||
Load a precompiled cache file and reconstruct the ``processed_groups`` dict.
|
||||
|
||||
Returns:
|
||||
``{ module_key: (A, B, alpha_or_None) }`` — identical structure to
|
||||
what ``compose_loras_v2`` produces after the classify+fuse stage.
|
||||
Returns an empty dict on any error so the caller can fall back to
|
||||
a full re-fuse gracefully.
|
||||
"""
|
||||
try:
|
||||
from safetensors.torch import load_file as st_load_file
|
||||
except ImportError as exc:
|
||||
logger.error(f"[CACHE] Cannot load precompiled cache: safetensors not available. {exc}")
|
||||
return {}
|
||||
|
||||
try:
|
||||
flat: Dict[str, torch.Tensor] = st_load_file(str(cache_path), device="cpu")
|
||||
except Exception as exc:
|
||||
logger.error(f"[CACHE] Failed to load {cache_path}: {exc}")
|
||||
return {}
|
||||
|
||||
# Reconstruct { module_key: { "A": T, "B": T, "alpha": T|None } }
|
||||
raw: Dict[str, Dict[str, torch.Tensor]] = {}
|
||||
for flat_key, tensor in flat.items():
|
||||
# flat_key format: "<module_key>__<role>"
|
||||
# module_key itself may contain dots but never "__"
|
||||
sep_idx = flat_key.rfind(_SEP)
|
||||
if sep_idx == -1:
|
||||
logger.warning(f"[CACHE] Unrecognised flat key format: '{flat_key}' — skipping.")
|
||||
continue
|
||||
module_key = flat_key[:sep_idx]
|
||||
role = flat_key[sep_idx + len(_SEP):]
|
||||
raw.setdefault(module_key, {})[role] = tensor
|
||||
|
||||
processed_groups: Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]] = {}
|
||||
for module_key, parts in raw.items():
|
||||
A = parts.get(_ROLE_A)
|
||||
B = parts.get(_ROLE_B)
|
||||
alpha_tensor = parts.get(_ROLE_ALPHA) # 1-D float32 scalar tensor or None
|
||||
|
||||
if A is None or B is None:
|
||||
logger.warning(f"[CACHE] Incomplete entry for '{module_key}' (missing A or B) — skipping.")
|
||||
continue
|
||||
|
||||
# Convert alpha back to the same form compose_loras_v2 expects:
|
||||
# a torch.Tensor scalar (not a Python float).
|
||||
alpha: Optional[torch.Tensor] = None
|
||||
if alpha_tensor is not None:
|
||||
alpha = alpha_tensor # keep as tensor; compose_loras_v2 calls .item() on it
|
||||
|
||||
processed_groups[module_key] = (A, B, alpha)
|
||||
|
||||
logger.info(f"[CACHE LOAD] Loaded {len(processed_groups)} module entries ← {cache_path.name}")
|
||||
return processed_groups
|
||||
+259
-47
@@ -10,6 +10,20 @@ import torch
|
||||
import torch.nn as nn
|
||||
from safetensors import safe_open
|
||||
|
||||
|
||||
def _get_compute_device() -> torch.device:
|
||||
"""Return the best compute device, deferring to ComfyUI's memory manager.
|
||||
|
||||
Using comfy.model_management.get_torch_device() keeps us fully compatible
|
||||
with ComfyUI's VRAM tracking and offloading context. Falls back to plain
|
||||
CUDA or CPU if ComfyUI is not available.
|
||||
"""
|
||||
try:
|
||||
import comfy.model_management as _mm
|
||||
return _mm.get_torch_device()
|
||||
except Exception:
|
||||
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
# It's assumed these functions from your project are available for import.
|
||||
# If not, you'll need to provide their definitions.
|
||||
from nunchaku.lora.flux.nunchaku_converter import (
|
||||
@@ -1159,22 +1173,39 @@ def _apply_lora_to_module(module: nn.Module, A: torch.Tensor, B: torch.Tensor, m
|
||||
# Handle Nunchaku LoRA-ready modules
|
||||
if hasattr(module, "proj_down") and hasattr(module, "proj_up"):
|
||||
pd, pu = module.proj_down.data, module.proj_up.data
|
||||
pd = unpack_lowrank_weight(pd, down=True)
|
||||
pu = unpack_lowrank_weight(pu, down=False)
|
||||
|
||||
base_rank = pd.shape[0] if pd.shape[1] == module.in_features else pd.shape[1]
|
||||
# Use ComfyUI-aware device to stay compatible with its VRAM manager.
|
||||
# This avoids allocating CUDA memory at a time ComfyUI has moved things to CPU.
|
||||
compute_device = _get_compute_device()
|
||||
pd_gpu = pd.to(compute_device)
|
||||
pu_gpu = pu.to(compute_device)
|
||||
A_gpu = A.to(compute_device)
|
||||
B_gpu = B.to(compute_device)
|
||||
|
||||
if pd.shape[1] == module.in_features: # [rank, in]
|
||||
new_proj_down = torch.cat([pd, A], dim=0)
|
||||
pd_unpacked = unpack_lowrank_weight(pd_gpu, down=True)
|
||||
pu_unpacked = unpack_lowrank_weight(pu_gpu, down=False)
|
||||
del pd_gpu, pu_gpu # free intermediates promptly
|
||||
|
||||
base_rank = pd_unpacked.shape[0] if pd_unpacked.shape[1] == module.in_features else pd_unpacked.shape[1]
|
||||
|
||||
if pd_unpacked.shape[1] == module.in_features: # [rank, in]
|
||||
new_proj_down = torch.cat([pd_unpacked, A_gpu], dim=0)
|
||||
axis_down = 0
|
||||
else: # [in, rank]
|
||||
new_proj_down = torch.cat([pd, A.T], dim=1)
|
||||
new_proj_down = torch.cat([pd_unpacked, A_gpu.T], dim=1)
|
||||
axis_down = 1
|
||||
del pd_unpacked, A_gpu
|
||||
|
||||
new_proj_up = torch.cat([pu, B], dim=1)
|
||||
new_proj_up = torch.cat([pu_unpacked, B_gpu], dim=1)
|
||||
del pu_unpacked, B_gpu
|
||||
|
||||
module.proj_down.data = pack_lowrank_weight(new_proj_down, down=True)
|
||||
module.proj_up.data = pack_lowrank_weight(new_proj_up, down=False)
|
||||
packed_down = pack_lowrank_weight(new_proj_down, down=True)
|
||||
packed_up = pack_lowrank_weight(new_proj_up, down=False)
|
||||
del new_proj_down, new_proj_up
|
||||
|
||||
# Move back to parameter's original device
|
||||
module.proj_down.data = packed_down.to(pd.device)
|
||||
module.proj_up.data = packed_up.to(pu.device)
|
||||
module.rank = base_rank + A.shape[0]
|
||||
|
||||
if not hasattr(model, "_lora_slots"):
|
||||
@@ -1238,10 +1269,22 @@ def compose_loras_v2(
|
||||
model: torch.nn.Module,
|
||||
lora_configs: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]],
|
||||
apply_awq_mod: Union[bool, str] = "auto",
|
||||
save_precompiled_lora: bool = False,
|
||||
cache_dir: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Resets and composes multiple LoRAs into the model with individual strengths.
|
||||
"""
|
||||
import time
|
||||
try:
|
||||
from tqdm import tqdm as _tqdm
|
||||
except ImportError:
|
||||
def _tqdm(iterable, *args, **kwargs):
|
||||
return iterable
|
||||
|
||||
t_start = time.perf_counter()
|
||||
report_entries = []
|
||||
|
||||
logger.info(f"Composing {len(lora_configs)} LoRAs (Direct Fix V6)...")
|
||||
reset_lora_v2(model)
|
||||
_first_detection = None
|
||||
@@ -1270,35 +1313,111 @@ def compose_loras_v2(
|
||||
logger.info("⚠️ AWQ Modulation Layer LoRA Injection ENABLED (via override).")
|
||||
|
||||
# --- 3. Debug Inspection (First LoRA) ---
|
||||
# OPTIMIZATION: Cache first LoRA state dict for reuse in processing loop
|
||||
_cached_first_lora_state_dict = None
|
||||
# Safe unpack: entries may be 2-tuples (path, strength) or 3-tuples
|
||||
# (path, strength, meta_dict) when the cache meta-dict is attached by the node.
|
||||
if lora_configs:
|
||||
first_lora_path_or_dict, first_lora_strength = lora_configs[0]
|
||||
first_lora_state_dict = _load_lora_state_dict_robust(first_lora_path_or_dict)
|
||||
_cached_first_lora_state_dict = first_lora_state_dict # Cache for reuse
|
||||
_first_entry = lora_configs[0]
|
||||
first_lora_path_or_dict = _first_entry[0]
|
||||
first_lora_strength = _first_entry[1]
|
||||
|
||||
# Simple logging of format detection
|
||||
_first_detection = _detect_lora_format(first_lora_state_dict)
|
||||
_log_lora_format_detection(str(first_lora_path_or_dict)[:50], _first_detection)
|
||||
# Optimize: Check if cache is valid for the first LoRA. If so, skip loading the raw safetensors file
|
||||
_cache_path = None
|
||||
_cache_is_valid = False
|
||||
if cache_dir is not None and isinstance(first_lora_path_or_dict, (str, Path)):
|
||||
from nunchaku_code.lora_cache import get_cache_dir as _get_cache_dir, get_cache_path as _get_cache_path, is_cache_valid as _is_cache_valid
|
||||
try:
|
||||
_real_cache_dir = _get_cache_dir(cache_dir)
|
||||
_cache_path = _get_cache_path(first_lora_path_or_dict, _real_cache_dir)
|
||||
_cache_is_valid = _is_cache_valid(first_lora_path_or_dict, _cache_path)
|
||||
except Exception:
|
||||
_cache_is_valid = False
|
||||
|
||||
if _cache_is_valid:
|
||||
_first_detection = {"has_standard": True, "details": "Standard LoRA (Precompiled Cache)"}
|
||||
else:
|
||||
first_lora_state_dict = _load_lora_state_dict_robust(first_lora_path_or_dict)
|
||||
# Simple logging of format detection
|
||||
_first_detection = _detect_lora_format(first_lora_state_dict)
|
||||
_log_lora_format_detection(str(first_lora_path_or_dict)[:50], _first_detection)
|
||||
|
||||
aggregated_weights: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
# Track names of LoRA files that contain skipped weights
|
||||
skipped_lora_names = set()
|
||||
|
||||
# --- 4. Main Loading Loop ---
|
||||
for idx, (lora_path_or_dict, strength) in enumerate(lora_configs):
|
||||
# Resolve cache directory once (None disables the cache path entirely).
|
||||
_cache_dir_path = None
|
||||
if cache_dir is not None:
|
||||
from pathlib import Path as _Path
|
||||
_cache_dir_path = _Path(cache_dir)
|
||||
|
||||
for idx, _entry in enumerate(lora_configs):
|
||||
# Entries may be plain 2-tuples (path, strength) or 3-tuples
|
||||
# (path, strength, meta_dict) carrying per-LoRA flags from the node UI.
|
||||
lora_path_or_dict = _entry[0]
|
||||
strength = _entry[1]
|
||||
_entry_meta: dict = _entry[2] if len(_entry) > 2 else {}
|
||||
|
||||
if abs(strength) < 1e-5:
|
||||
continue
|
||||
|
||||
|
||||
t_lora_start = time.perf_counter()
|
||||
|
||||
lora_name = str(lora_path_or_dict)
|
||||
if isinstance(lora_path_or_dict, dict):
|
||||
lora_name = f"lora_{idx}"
|
||||
|
||||
# OPTIMIZATION: Reuse cached first LoRA state dict to avoid duplicate file I/O
|
||||
if idx == 0 and _cached_first_lora_state_dict is not None:
|
||||
lora_state_dict = _cached_first_lora_state_dict
|
||||
else:
|
||||
lora_state_dict = _load_lora_state_dict_robust(lora_path_or_dict)
|
||||
# Per-entry save flag: honour node-level flag OR the global function arg.
|
||||
_should_save = save_precompiled_lora or bool(_entry_meta.get("save_precompiled", False))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# CACHE FAST PATH #
|
||||
# Only available for file-backed LoRAs (not inline dicts) and only #
|
||||
# when a cache directory is configured. #
|
||||
# ------------------------------------------------------------------ #
|
||||
_cache_path = None
|
||||
if _cache_dir_path is not None and isinstance(lora_path_or_dict, (str, Path)):
|
||||
from nunchaku_code.lora_cache import (
|
||||
get_cache_dir as _get_cache_dir,
|
||||
get_cache_path as _get_cache_path,
|
||||
is_cache_valid as _is_cache_valid,
|
||||
load_precompiled as _load_precompiled,
|
||||
save_precompiled as _save_precompiled,
|
||||
)
|
||||
_real_cache_dir = _get_cache_dir(_cache_dir_path)
|
||||
_cache_path = _get_cache_path(lora_path_or_dict, _real_cache_dir)
|
||||
|
||||
if _is_cache_valid(lora_path_or_dict, _cache_path):
|
||||
# ---- CACHE HIT: skip disk I/O + classify + fuse ---- #
|
||||
processed_groups = _load_precompiled(_cache_path)
|
||||
if processed_groups:
|
||||
logger.info(
|
||||
f"[CACHE HIT] Skipping classify+fuse for '{Path(lora_path_or_dict).name}'. "
|
||||
f"Loaded {len(processed_groups)} pre-fused entries from cache."
|
||||
)
|
||||
for module_key, (A, B, alpha) in processed_groups.items():
|
||||
aggregated_weights[module_key].append(
|
||||
{"A": A, "B": B, "alpha": alpha, "strength": strength, "source": lora_name}
|
||||
)
|
||||
t_lora_end = time.perf_counter()
|
||||
report_entries.append({
|
||||
"name": Path(lora_path_or_dict).name if isinstance(lora_path_or_dict, (str, Path)) else f"lora_{idx}",
|
||||
"strength": strength,
|
||||
"format": "Standard LoRA (Precompiled)",
|
||||
"load_type": "Cache Hit",
|
||||
"elapsed": t_lora_end - t_lora_start,
|
||||
})
|
||||
continue # skip normal fuse path for this LoRA
|
||||
else:
|
||||
logger.warning(
|
||||
f"[CACHE] Cache file loaded but returned empty groups for "
|
||||
f"'{Path(lora_path_or_dict).name}'. Falling back to full fuse."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# NORMAL (FULL) FUSE PATH #
|
||||
# ------------------------------------------------------------------ #
|
||||
lora_state_dict = _load_lora_state_dict_robust(lora_path_or_dict)
|
||||
if not lora_state_dict:
|
||||
continue
|
||||
|
||||
@@ -1323,17 +1442,19 @@ def compose_loras_v2(
|
||||
# Process groups
|
||||
processed_groups = {}
|
||||
special_handled = set()
|
||||
|
||||
for base_key, lw in lora_grouped.items():
|
||||
|
||||
items_to_process = list(lora_grouped.items())
|
||||
pbar_desc = f"Fusing weights for {Path(lora_path_or_dict).name[:25]}"
|
||||
for base_key, lw in _tqdm(items_to_process, desc=pbar_desc, unit="group", leave=True):
|
||||
if base_key in special_handled:
|
||||
continue
|
||||
|
||||
# Standard Logic Copied from Original
|
||||
if "lokr_w1" in lw or "lokr_w2" in lw:
|
||||
continue # LoKR skipped
|
||||
continue # LoKR skipped
|
||||
|
||||
A, B, alpha = None, None, None
|
||||
|
||||
|
||||
if "qkv" in base_key:
|
||||
A, B, alpha = (lw.get("A"), lw.get("B"), lw.get("alpha")) if "A" in lw else _fuse_qkv_lora(lw, model=model, base_key=base_key)
|
||||
elif "w1_A" in lw or "w3_A" in lw:
|
||||
@@ -1349,19 +1470,44 @@ def compose_loras_v2(
|
||||
if A is not None and B is not None:
|
||||
processed_groups[base_key] = (A, B, alpha)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# OPTIONAL CACHE SAVE #
|
||||
# Persist the freshly fused processed_groups so the next run can #
|
||||
# skip straight to aggregation. B is stored un-scaled so the #
|
||||
# strength slider keeps working at inference time. #
|
||||
# ------------------------------------------------------------------ #
|
||||
load_type = "Cache Miss (Compiled)"
|
||||
if _should_save and _cache_path is not None and processed_groups:
|
||||
try:
|
||||
_save_precompiled(processed_groups, lora_path_or_dict, _cache_path)
|
||||
load_type = "Cache Miss (Compiled & Saved)"
|
||||
except Exception as _cache_save_exc:
|
||||
logger.warning(f"[CACHE] Failed to save precompiled cache: {_cache_save_exc}")
|
||||
|
||||
for module_key, (A, B, alpha) in processed_groups.items():
|
||||
aggregated_weights[module_key].append(
|
||||
{"A": A, "B": B, "alpha": alpha, "strength": strength, "source": lora_name}
|
||||
)
|
||||
|
||||
t_lora_end = time.perf_counter()
|
||||
report_entries.append({
|
||||
"name": Path(lora_path_or_dict).name if isinstance(lora_path_or_dict, (str, Path)) else f"lora_{idx}",
|
||||
"strength": strength,
|
||||
"format": detection["has_standard"] and "Standard LoRA (Rank-Decomposed)" or "Unknown/Other",
|
||||
"load_type": load_type,
|
||||
"elapsed": t_lora_end - t_lora_start,
|
||||
})
|
||||
|
||||
# --- 5. Application Loop (with AWQ Fix) ---
|
||||
t_apply_start = time.perf_counter()
|
||||
applied_modules_count = 0
|
||||
skipped_modules_count = 0
|
||||
mod_layer_applied_count = 0
|
||||
|
||||
all_A = [] # Initialization for safety
|
||||
|
||||
for module_name_key, weight_list in aggregated_weights.items():
|
||||
apply_pbar_desc = "Applying weights to modules"
|
||||
for module_name_key, weight_list in _tqdm(list(aggregated_weights.items()), desc=apply_pbar_desc, unit="mod", leave=True):
|
||||
# Correctly resolving module (handling tuple return if needed from _resolve_module_name, but it returns tuple (name, mod) or just (name, mod) in signature, wait.)
|
||||
# _resolve_module_name defined in this file returns Tuple[str, Optional[nn.Module]]
|
||||
# Wait, if module is None, it returns None? line 416 returns name, m. line 409 returns None.
|
||||
@@ -1428,16 +1574,22 @@ def compose_loras_v2(
|
||||
# Standard Linear / Nunchaku
|
||||
if hasattr(module, "proj_down"):
|
||||
target_dtype = module.proj_down.dtype
|
||||
target_device = module.proj_down.device
|
||||
# Nunchaku: skip the CPU→CPU device pre-transfer.
|
||||
# _apply_lora_to_module will move these to the compute device (CUDA)
|
||||
# itself. Pre-transferring to CPU here is a redundant memcpy (~960
|
||||
# copies per generation) that wastes CPU memory bandwidth.
|
||||
all_A.append(A.to(dtype=target_dtype))
|
||||
all_B_scaled.append((B * scale).to(dtype=target_dtype))
|
||||
elif hasattr(module, "weight"):
|
||||
target_dtype = module.weight.dtype
|
||||
target_device = module.weight.device
|
||||
all_A.append(A.to(dtype=target_dtype, device=target_device))
|
||||
all_B_scaled.append((B * scale).to(dtype=target_dtype, device=target_device))
|
||||
else:
|
||||
target_dtype = torch.float16
|
||||
target_device = "cuda"
|
||||
|
||||
all_A.append(A.to(dtype=target_dtype, device=target_device))
|
||||
all_B_scaled.append((B * scale).to(dtype=target_dtype, device=target_device))
|
||||
all_A.append(A.to(dtype=target_dtype, device=target_device))
|
||||
all_B_scaled.append((B * scale).to(dtype=target_dtype, device=target_device))
|
||||
|
||||
|
||||
if not all_A:
|
||||
@@ -1490,6 +1642,10 @@ def compose_loras_v2(
|
||||
logger.info(f"[APPLY] LoRA applied to: {resolved_name}")
|
||||
applied_modules_count += 1
|
||||
|
||||
t_apply_end = time.perf_counter()
|
||||
t_apply_elapsed = t_apply_end - t_apply_start
|
||||
t_total_elapsed = t_apply_end - t_start
|
||||
|
||||
if skipped_lora_names:
|
||||
logger.warning("""\n⚠️ Some weights from the following LoRAs were automatically skipped
|
||||
because modulation layers (img_mod/txt_mod) are extremely sensitive to Nunchaku quantization.
|
||||
@@ -1498,7 +1654,42 @@ def compose_loras_v2(
|
||||
short_name = os.path.basename(source) if os.path.sep in source else source
|
||||
logger.warning(f" - {short_name}")
|
||||
|
||||
logger.info(f"Sampled LoRA composition complete. Applied: {applied_modules_count}, Mod-patched: {mod_layer_applied_count}, Skipped: {skipped_modules_count}")
|
||||
# Display Report Card
|
||||
card_width = 70
|
||||
border = "┌" + "─" * (card_width - 2) + "┐"
|
||||
divider = "├" + "─" * (card_width - 2) + "┤"
|
||||
bottom = "└" + "─" * (card_width - 2) + "┘"
|
||||
|
||||
def _log_line(left_text: str, right_text: str = ""):
|
||||
padding_len = card_width - 4 - len(left_text) - len(right_text)
|
||||
if padding_len < 0:
|
||||
left_text = left_text[:card_width - 8 - len(right_text)] + "..."
|
||||
padding_len = card_width - 4 - len(left_text) - len(right_text)
|
||||
logger.info(f"│ {left_text}{' ' * padding_len}{right_text} │")
|
||||
|
||||
logger.info(border)
|
||||
title = "NUNCHAKU QWEN LORA COMPOSITION REPORT"
|
||||
title_padding = (card_width - 2 - len(title)) // 2
|
||||
logger.info(f"│{' ' * title_padding}{title}{' ' * (card_width - 2 - len(title) - title_padding)}│")
|
||||
logger.info(divider)
|
||||
|
||||
for i, entry in enumerate(report_entries, 1):
|
||||
_log_line(f"{i}. {entry['name']}")
|
||||
_log_line(f" - Strength: {entry['strength']}")
|
||||
_log_line(f" - Format: {entry['format']}")
|
||||
_log_line(f" - Status: {entry['load_type']}")
|
||||
_log_line(f" - Time: {entry['elapsed']:.3f}s")
|
||||
if i < len(report_entries):
|
||||
logger.info("│" + " " * (card_width - 2) + "│")
|
||||
|
||||
logger.info(divider)
|
||||
_log_line(f"Total Processed: {len(report_entries)} LoRA(s)")
|
||||
_log_line(f"Layers Applied: {applied_modules_count} (Attention/MLP)")
|
||||
_log_line(f"Modulation Patched: {mod_layer_applied_count} (AWQ manual planar)")
|
||||
_log_line(f"Weight Apply Time: {t_apply_elapsed:.3f}s")
|
||||
_log_line(f"Total Composition: {t_total_elapsed:.3f}s")
|
||||
logger.info(bottom)
|
||||
|
||||
return True
|
||||
|
||||
def update_lora_params_v2(
|
||||
@@ -1515,6 +1706,8 @@ def compose_loras_v2_v2(
|
||||
model: torch.nn.Module,
|
||||
lora_configs: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]],
|
||||
apply_awq_mod: bool | str | None = None,
|
||||
save_precompiled_lora: bool = False,
|
||||
cache_dir: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
V2 node-specific LoRA composition function with AWQ modulation control.
|
||||
@@ -1547,7 +1740,13 @@ def compose_loras_v2_v2(
|
||||
|
||||
# Call compose_loras_v2 with the determined flag
|
||||
# Pass as bool explicitly to override default "auto" behavior
|
||||
return compose_loras_v2(model, lora_configs, apply_awq_mod=apply_awq_mod_flag)
|
||||
return compose_loras_v2(
|
||||
model,
|
||||
lora_configs,
|
||||
apply_awq_mod=apply_awq_mod_flag,
|
||||
save_precompiled_lora=save_precompiled_lora,
|
||||
cache_dir=cache_dir,
|
||||
)
|
||||
|
||||
|
||||
def set_lora_strength_v2(model: nn.Module, strength: float) -> None:
|
||||
@@ -1561,7 +1760,9 @@ def set_lora_strength_v2(model: nn.Module, strength: float) -> None:
|
||||
|
||||
for name, info in model._lora_slots.items():
|
||||
module = _get_module_by_name(model, name)
|
||||
if module is None or info.get("appended", 0) <= 0:
|
||||
# Skip if module not found, no appended weights, or no base_rank recorded
|
||||
# (awq_w4a16 modulation slots don't store base_rank — they're handled by forward patching)
|
||||
if module is None or info.get("appended", 0) <= 0 or "base_rank" not in info:
|
||||
continue
|
||||
|
||||
base_rank, appended = info["base_rank"], info["appended"]
|
||||
@@ -1576,11 +1777,6 @@ def reset_lora_v2(model: nn.Module) -> None:
|
||||
if not hasattr(model, "_lora_slots") or not model._lora_slots:
|
||||
return
|
||||
|
||||
for name, info in model._lora_slots.items():
|
||||
module = _get_module_by_name(model, name)
|
||||
if module is None:
|
||||
continue
|
||||
|
||||
for name, info in model._lora_slots.items():
|
||||
module = _get_module_by_name(model, name)
|
||||
if module is None:
|
||||
@@ -1591,17 +1787,28 @@ def reset_lora_v2(model: nn.Module) -> None:
|
||||
if module_type == "nunchaku":
|
||||
base_rank = info["base_rank"]
|
||||
with torch.no_grad():
|
||||
pd = unpack_lowrank_weight(module.proj_down.data, down=True)
|
||||
pu = unpack_lowrank_weight(module.proj_up.data, down=False)
|
||||
|
||||
# Use ComfyUI-aware device to stay compatible with its VRAM manager
|
||||
compute_device = _get_compute_device()
|
||||
orig_pd_device = module.proj_down.data.device
|
||||
orig_pu_device = module.proj_up.data.device
|
||||
|
||||
pd_gpu = module.proj_down.data.to(compute_device)
|
||||
pu_gpu = module.proj_up.data.to(compute_device)
|
||||
|
||||
pd = unpack_lowrank_weight(pd_gpu, down=True)
|
||||
pu = unpack_lowrank_weight(pu_gpu, down=False)
|
||||
del pd_gpu, pu_gpu # free intermediates promptly
|
||||
|
||||
if info.get("axis_down", 0) == 0: # [rank, in]
|
||||
pd_reset = pd[:base_rank, :].clone()
|
||||
else: # [in, rank]
|
||||
pd_reset = pd[:, :base_rank].clone()
|
||||
pu_reset = pu[:, :base_rank].clone()
|
||||
|
||||
module.proj_down.data = pack_lowrank_weight(pd_reset, down=True)
|
||||
module.proj_up.data = pack_lowrank_weight(pu_reset, down=False)
|
||||
del pd, pu
|
||||
|
||||
module.proj_down.data = pack_lowrank_weight(pd_reset, down=True).to(orig_pd_device)
|
||||
module.proj_up.data = pack_lowrank_weight(pu_reset, down=False).to(orig_pu_device)
|
||||
del pd_reset, pu_reset
|
||||
module.rank = base_rank
|
||||
|
||||
elif module_type == "linear":
|
||||
@@ -1629,4 +1836,9 @@ def reset_lora_v2(model: nn.Module) -> None:
|
||||
|
||||
model._lora_slots.clear()
|
||||
model._lora_strength = 1.0
|
||||
if hasattr(model, "_applied_loras"):
|
||||
try:
|
||||
delattr(model, "_applied_loras")
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("All LoRA weights have been reset from the model.")
|
||||
+2
-122
@@ -1,133 +1,14 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from typing import Callable, Optional, Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_svdq_from_linear_patched: bool = False
|
||||
_qwen_apply_rotary_emb_compat_applied: bool = False
|
||||
|
||||
_ROTARY_SHIM_TAG = "_qwen_lora_loader_rotary_shim"
|
||||
_NUNCHAKU_QWENIMAGE_APPLY_ROTARY_PATTERNS = (
|
||||
re.compile(
|
||||
r"from\s+comfy\.ldm\.qwen_image\.model\s+import\s+\([^)]*\bapply_rotary_emb\b",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
),
|
||||
re.compile(
|
||||
r"from\s+comfy\.ldm\.qwen_image\.model\s+import\s+[^\n#]*\bapply_rotary_emb\b",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rotary_compat_disabled_by_env() -> bool:
|
||||
value = os.environ.get("QWENIMAGE_ROTARY_COMPAT", "").strip().lower()
|
||||
return value in ("0", "false", "no", "off", "disable", "disabled")
|
||||
|
||||
|
||||
def _mark_rotary_shim(fn: Callable) -> Callable:
|
||||
setattr(fn, _ROTARY_SHIM_TAG, True)
|
||||
return fn
|
||||
|
||||
|
||||
def _rotary_shim_installed_on(qwen_image_model) -> bool:
|
||||
fn = getattr(qwen_image_model, "apply_rotary_emb", None)
|
||||
return fn is not None and getattr(fn, _ROTARY_SHIM_TAG, False)
|
||||
|
||||
|
||||
def _comfyui_has_native_apply_rotary_emb(qwen_image_model) -> bool:
|
||||
return (
|
||||
getattr(qwen_image_model, "apply_rotary_emb", None) is not None
|
||||
and not _rotary_shim_installed_on(qwen_image_model)
|
||||
)
|
||||
|
||||
|
||||
def _find_nunchaku_qwenimage_py() -> Optional[str]:
|
||||
custom_nodes = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)
|
||||
)
|
||||
for folder in ("ComfyUI-nunchaku", "ComfyUI_Nunchaku", "comfyui-nunchaku"):
|
||||
path = os.path.join(custom_nodes, folder, "models", "qwenimage.py")
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _nunchaku_qwenimage_still_imports_apply_rotary_emb() -> Optional[bool]:
|
||||
"""
|
||||
True: nunchaku still imports apply_rotary_emb (compat may be needed).
|
||||
False: nunchaku source no longer imports it (skip shim).
|
||||
None: qwenimage.py not found (apply only if symbol missing).
|
||||
"""
|
||||
path = _find_nunchaku_qwenimage_py()
|
||||
if path is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||
text = handle.read()
|
||||
except OSError as exc:
|
||||
logger.debug("Could not read %s for rotary compat probe: %s", path, exc)
|
||||
return None
|
||||
|
||||
for pattern in _NUNCHAKU_QWENIMAGE_APPLY_ROTARY_PATTERNS:
|
||||
if pattern.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def apply_qwen_image_apply_rotary_emb_compat() -> bool:
|
||||
"""
|
||||
ComfyUI >= 0.24 removed comfy.ldm.qwen_image.model.apply_rotary_emb.
|
||||
ComfyUI-nunchaku still imports it; alias to comfy.ldm.flux.math.apply_rope1
|
||||
only when the import is still required and ComfyUI has not restored the symbol.
|
||||
"""
|
||||
global _qwen_apply_rotary_emb_compat_applied
|
||||
if _qwen_apply_rotary_emb_compat_applied:
|
||||
return True
|
||||
|
||||
if _rotary_compat_disabled_by_env():
|
||||
logger.info(
|
||||
"apply_rotary_emb compat skipped (QWENIMAGE_ROTARY_COMPAT is disabled)"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
import comfy.ldm.qwen_image.model as qwen_image_model
|
||||
from comfy.ldm.flux.math import apply_rope1
|
||||
|
||||
if _rotary_shim_installed_on(qwen_image_model):
|
||||
_qwen_apply_rotary_emb_compat_applied = True
|
||||
return True
|
||||
|
||||
if _comfyui_has_native_apply_rotary_emb(qwen_image_model):
|
||||
logger.info(
|
||||
"apply_rotary_emb compat skipped: ComfyUI already exports apply_rotary_emb"
|
||||
)
|
||||
return False
|
||||
|
||||
nunchaku_needs = _nunchaku_qwenimage_still_imports_apply_rotary_emb()
|
||||
if nunchaku_needs is False:
|
||||
logger.info(
|
||||
"apply_rotary_emb compat skipped: ComfyUI-nunchaku no longer imports apply_rotary_emb"
|
||||
)
|
||||
return False
|
||||
|
||||
qwen_image_model.apply_rotary_emb = _mark_rotary_shim(apply_rope1)
|
||||
_qwen_apply_rotary_emb_compat_applied = True
|
||||
logger.info(
|
||||
"Patched comfy.ldm.qwen_image.model.apply_rotary_emb -> apply_rope1 "
|
||||
"(ComfyUI-nunchaku Qwen Image compat)"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to apply apply_rotary_emb compat patch: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _torch_device_fallback() -> torch.device:
|
||||
@@ -358,7 +239,6 @@ def apply_nunchaku_patch():
|
||||
Apply ComfyUI-nunchaku compatibility patches (LoRA planar injection + lazy Linear fixes).
|
||||
Returns True if at least one patch was applied or was already active.
|
||||
"""
|
||||
rotary_compat = apply_qwen_image_apply_rotary_emb_compat()
|
||||
lazy_from = apply_svdqw4a4_lazy_linear_patch()
|
||||
lazy_fuse = apply_nunchaku_zimage_fuse_lazy_linear_patch()
|
||||
if not lazy_fuse:
|
||||
@@ -395,4 +275,4 @@ def apply_nunchaku_patch():
|
||||
except Exception as e:
|
||||
logger.error("Failed to apply Nunchaku planar patch: %s", e)
|
||||
|
||||
return planar_ok or lazy_from or rotary_compat
|
||||
return planar_ok or lazy_from
|
||||
|
||||
+143
-81
@@ -1,9 +1,11 @@
|
||||
from typing import Callable, List, Tuple, Union
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
import comfy.model_management
|
||||
import folder_paths
|
||||
import logging
|
||||
|
||||
from nunchaku import NunchakuQwenImageTransformer2DModel
|
||||
@@ -66,6 +68,13 @@ class ComfyQwenImageWrapper(nn.Module):
|
||||
# Track last seen device to detect CPU/GPU moves that require re-compose
|
||||
self._last_device = None
|
||||
|
||||
# Track whether this wrapper has ever held any LoRAs. A wrapper that was
|
||||
# stamped onto the INPUT model by load_lora's side-effect mutation but never
|
||||
# received any LoRAs (i.e. the bypass-path wrapper W1) must NOT reset the
|
||||
# shared transformer, because the ret_model wrapper (W2) may have already
|
||||
# composed LoRA into it.
|
||||
self._has_ever_had_loras = False
|
||||
|
||||
def process_img(self, x, index=0, h_offset=0, w_offset=0):
|
||||
"""
|
||||
Delegate to the inner model so Qwen Image ControlNet (e.g. Fun ControlNet)
|
||||
@@ -156,18 +165,30 @@ class ComfyQwenImageWrapper(nn.Module):
|
||||
if self.model is None:
|
||||
raise RuntimeError("Model has been unloaded or garbage collected. Cannot perform forward pass.")
|
||||
|
||||
# Update LoRA-manager flag: once this wrapper holds any LoRAs it is
|
||||
# considered a LoRA manager for the lifetime of the object.
|
||||
if self.loras:
|
||||
self._has_ever_had_loras = True
|
||||
|
||||
# model_is_dirty: the transformer has LoRA applied but this wrapper expects
|
||||
# none. Only relevant if we were once a LoRA manager (i.e. we previously
|
||||
# composed into the transformer). A bypass-path wrapper that has never
|
||||
# managed LoRAs must NOT declare the transformer dirty — another wrapper
|
||||
# (the ret_model wrapper) may have legitimately composed into it.
|
||||
model_is_dirty = (
|
||||
not self.loras and # We expect no LoRA
|
||||
hasattr(self.model, "_lora_slots") and self.model._lora_slots # But the model actually has LoRA
|
||||
self._has_ever_had_loras and # We were (or are) a LoRA manager
|
||||
not self.loras and # We currently expect no LoRA
|
||||
hasattr(self.model, "_lora_slots") and self.model._lora_slots # But the model has LoRA
|
||||
)
|
||||
|
||||
# Deep comparison of LoRA stacks to detect any changes
|
||||
# This ensures we catch changes in weights, paths, or order
|
||||
# Check against the shared model's currently composed LoRAs to handle multiple wrappers sharing the model.
|
||||
model_applied = getattr(self.model, "_applied_loras", None)
|
||||
loras_changed = False
|
||||
if self._applied_loras is None or len(self._applied_loras) != len(self.loras):
|
||||
if model_applied is None or len(model_applied) != len(self.loras):
|
||||
loras_changed = True
|
||||
else:
|
||||
for applied, current in zip(self._applied_loras, self.loras):
|
||||
for applied, current in zip(model_applied, self.loras):
|
||||
if applied != current:
|
||||
loras_changed = True
|
||||
break
|
||||
@@ -181,10 +202,18 @@ class ComfyQwenImageWrapper(nn.Module):
|
||||
|
||||
# Check if the LoRA stack has been changed by a loader node
|
||||
if loras_changed or model_is_dirty or device_changed:
|
||||
# The compose function handles resetting before applying the new stack
|
||||
reset_lora_v2(self.model)
|
||||
self._applied_loras = self.loras.copy()
|
||||
|
||||
# Guard: only reset/recompose the shared transformer when this wrapper
|
||||
# is (or has been) an active LoRA manager. The bypass-path wrapper W1
|
||||
# (created as a side-effect of load_lora mutating the input model) has
|
||||
# loras=[] and _has_ever_had_loras=False on its very first forward pass.
|
||||
# Without this guard it would call reset_lora_v2(shared_transformer),
|
||||
# wiping whatever LoRA the ret_model wrapper W2 had composed.
|
||||
is_lora_manager = bool(self.loras) or self._has_ever_had_loras
|
||||
|
||||
if is_lora_manager:
|
||||
# The compose function handles resetting before applying the new stack
|
||||
reset_lora_v2(self.model)
|
||||
|
||||
# Reset cache when LoRAs change to prevent stale cache in multi-stage workflows
|
||||
# This ensures that when switching between different LoRA sets in different stages,
|
||||
# the cache is invalidated and recreated with the new LoRA composition
|
||||
@@ -193,91 +222,124 @@ class ComfyQwenImageWrapper(nn.Module):
|
||||
self._prev_timestep = None
|
||||
logger.debug("Cache reset due to LoRA change")
|
||||
|
||||
# --- NEW DYNAMIC VRAM CHECK (conditionally applied) ---
|
||||
if is_lora_manager:
|
||||
# --- NEW DYNAMIC VRAM CHECK (conditionally applied) ---
|
||||
|
||||
# 1. Check if offload is *already* enabled (from loader setting "enable" or "auto" on low-vram)
|
||||
offload_is_on = hasattr(self.model, "offload_manager") and self.model.offload_manager is not None
|
||||
# 1. Check if offload is *already* enabled (from loader setting "enable" or "auto" on low-vram)
|
||||
offload_is_on = hasattr(self.model, "offload_manager") and self.model.offload_manager is not None
|
||||
|
||||
# 2. Decide if we *need* to turn it on
|
||||
should_enable_offload = offload_is_on
|
||||
# 2. Decide if we *need* to turn it on
|
||||
should_enable_offload = offload_is_on
|
||||
|
||||
# 3. Only run the dynamic VRAM check if:
|
||||
# - The user's original setting was "auto"
|
||||
# - Offloading is not *already* on
|
||||
# - We are actually loading new LoRAs
|
||||
if self.cpu_offload_setting == "auto" and not offload_is_on and self.loras:
|
||||
try:
|
||||
# Use the VRAM margin from the loader node
|
||||
free_vram_gb = comfy.model_management.get_free_memory() / (1024 ** 3)
|
||||
|
||||
if free_vram_gb < self.vram_margin_gb:
|
||||
logger.info(
|
||||
f"Free VRAM is {free_vram_gb:.2f}GB (below safety margin of {self.vram_margin_gb}GB) and 'cpu_offload' is 'auto'. Force-enabling CPU offload for LoRA composition.")
|
||||
should_enable_offload = True
|
||||
else:
|
||||
logger.info(
|
||||
f"Free VRAM is {free_vram_gb:.2f}GB (>= {self.vram_margin_gb}GB margin). LoRAs will be composed without enabling CPU offload.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during VRAM check for LoRA offloading: {e}. Offload will not be enabled.")
|
||||
elif self.cpu_offload_setting == "disable" and not offload_is_on:
|
||||
logger.debug("CPU offload is 'disable' and not on. Skipping VRAM check.")
|
||||
elif self.cpu_offload_setting == "enable" and offload_is_on:
|
||||
logger.debug("CPU offload is 'enable'. Will rebuild offload manager for LoRAs.")
|
||||
|
||||
# --- END NEW VRAM CHECK ---
|
||||
|
||||
# 4. Compose LoRAs. This changes internal tensor shapes.
|
||||
# 4. Compose LoRAs. This changes internal tensor shapes.
|
||||
# Returns True if successful (supported format), False if unsupported (skipped).
|
||||
is_supported_format = compose_loras_v2(self.model, self.loras, apply_awq_mod=self.apply_awq_mod)
|
||||
|
||||
# Validate composition result; if 0 targets after a crash/transition, retry once
|
||||
# But ONLY if the format was supported. If unsupported, retrying is pointless.
|
||||
if is_supported_format:
|
||||
try:
|
||||
has_slots = hasattr(self.model, "_lora_slots") and bool(self.model._lora_slots)
|
||||
except Exception:
|
||||
has_slots = True
|
||||
if self.loras and not has_slots:
|
||||
logger.warning("LoRA composition reported 0 target modules. Forcing reset and one retry.")
|
||||
# 3. Only run the dynamic VRAM check if:
|
||||
# - The user's original setting was "auto"
|
||||
# - Offloading is not *already* on
|
||||
# - We are actually loading new LoRAs
|
||||
if self.cpu_offload_setting == "auto" and not offload_is_on and self.loras:
|
||||
try:
|
||||
reset_lora_v2(self.model)
|
||||
compose_loras_v2(self.model, self.loras, apply_awq_mod=self.apply_awq_mod)
|
||||
# Use the VRAM margin from the loader node
|
||||
free_vram_gb = comfy.model_management.get_free_memory() / (1024 ** 3)
|
||||
|
||||
if free_vram_gb < self.vram_margin_gb:
|
||||
logger.info(
|
||||
f"Free VRAM is {free_vram_gb:.2f}GB (below safety margin of {self.vram_margin_gb}GB) and 'cpu_offload' is 'auto'. Force-enabling CPU offload for LoRA composition.")
|
||||
should_enable_offload = True
|
||||
else:
|
||||
logger.info(
|
||||
f"Free VRAM is {free_vram_gb:.2f}GB (>= {self.vram_margin_gb}GB margin). LoRAs will be composed without enabling CPU offload.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LoRA re-compose retry failed: {e}")
|
||||
else:
|
||||
logger.warning("Skipping retry because LoRA format is unsupported.")
|
||||
logger.error(f"Error during VRAM check for LoRA offloading: {e}. Offload will not be enabled.")
|
||||
elif self.cpu_offload_setting == "disable" and not offload_is_on:
|
||||
logger.debug("CPU offload is 'disable' and not on. Skipping VRAM check.")
|
||||
elif self.cpu_offload_setting == "enable" and offload_is_on:
|
||||
logger.debug("CPU offload is 'enable'. Will rebuild offload manager for LoRAs.")
|
||||
|
||||
# 5. Re-build offload manager if it's supposed to be on
|
||||
# This block now runs if offload was on *or* if our new check decided to turn it on.
|
||||
if should_enable_offload:
|
||||
# --- END NEW VRAM CHECK ---
|
||||
|
||||
# Store settings if it was already on, otherwise use defaults
|
||||
if offload_is_on:
|
||||
manager = self.model.offload_manager
|
||||
offload_settings = {
|
||||
"num_blocks_on_gpu": manager.num_blocks_on_gpu,
|
||||
"use_pin_memory": manager.use_pin_memory,
|
||||
}
|
||||
# 4. Build cache args. The ComfyUI base dir is the parent of the
|
||||
# 'models' folder, which folder_paths knows about.
|
||||
try:
|
||||
_comfy_base = str(Path(folder_paths.models_dir).parent)
|
||||
except Exception:
|
||||
_comfy_base = None
|
||||
|
||||
# Separate per-LoRA meta dicts from the (path, strength[, meta]) tuples
|
||||
# and derive the global save_precompiled flag (True if ANY entry requests it).
|
||||
_lora_configs_clean = []
|
||||
_any_save_flag = False
|
||||
for _entry in self.loras:
|
||||
_lc_path = _entry[0]
|
||||
_lc_strength = _entry[1]
|
||||
_lc_meta: dict = _entry[2] if len(_entry) > 2 else {}
|
||||
_lora_configs_clean.append((_lc_path, _lc_strength, _lc_meta))
|
||||
if _lc_meta.get("save_precompiled", False):
|
||||
_any_save_flag = True
|
||||
|
||||
# 5. Compose LoRAs. This changes internal tensor shapes.
|
||||
# Returns True if successful (supported format), False if unsupported (skipped).
|
||||
is_supported_format = compose_loras_v2(
|
||||
self.model,
|
||||
_lora_configs_clean,
|
||||
apply_awq_mod=self.apply_awq_mod,
|
||||
save_precompiled_lora=_any_save_flag,
|
||||
cache_dir=_comfy_base,
|
||||
)
|
||||
|
||||
# Validate composition result; if 0 targets after a crash/transition, retry once
|
||||
# But ONLY if the format was supported. If unsupported, retrying is pointless.
|
||||
if is_supported_format:
|
||||
try:
|
||||
has_slots = hasattr(self.model, "_lora_slots") and bool(self.model._lora_slots)
|
||||
except Exception:
|
||||
has_slots = True
|
||||
if self.loras and not has_slots:
|
||||
logger.warning("LoRA composition reported 0 target modules. Forcing reset and one retry.")
|
||||
try:
|
||||
reset_lora_v2(self.model)
|
||||
compose_loras_v2(
|
||||
self.model,
|
||||
_lora_configs_clean,
|
||||
apply_awq_mod=self.apply_awq_mod,
|
||||
save_precompiled_lora=_any_save_flag,
|
||||
cache_dir=_comfy_base,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"LoRA re-compose retry failed: {e}")
|
||||
else:
|
||||
# Not previously on, so use defaults from nodes/models/qwenimage.py
|
||||
offload_settings = {
|
||||
"num_blocks_on_gpu": 1,
|
||||
"use_pin_memory": False, # 'disable' maps to False
|
||||
}
|
||||
logger.info("Building new CPU offload manager due to LoRA VRAM check.")
|
||||
logger.warning("Skipping retry because LoRA format is unsupported.")
|
||||
|
||||
# Step 1: Completely disable and clear any old offloader (safe to call even if off)
|
||||
self.model.set_offload(False)
|
||||
# 5. Re-build offload manager if it's supposed to be on
|
||||
# This block now runs if offload was on *or* if our new check decided to turn it on.
|
||||
if should_enable_offload:
|
||||
|
||||
# Step 2: Re-enable offloading with the correct settings
|
||||
# This builds the manager based on the *newly composed* tensor shapes.
|
||||
self.model.set_offload(True, **offload_settings)
|
||||
# Store settings if it was already on, otherwise use defaults
|
||||
if offload_is_on:
|
||||
manager = self.model.offload_manager
|
||||
offload_settings = {
|
||||
"num_blocks_on_gpu": manager.num_blocks_on_gpu,
|
||||
"use_pin_memory": manager.use_pin_memory,
|
||||
}
|
||||
else:
|
||||
# Not previously on, so use defaults from nodes/models/qwenimage.py
|
||||
offload_settings = {
|
||||
"num_blocks_on_gpu": 1,
|
||||
"use_pin_memory": False, # 'disable' maps to False
|
||||
}
|
||||
logger.info("Building new CPU offload manager due to LoRA VRAM check.")
|
||||
|
||||
# Step 1: Completely disable and clear any old offloader (safe to call even if off)
|
||||
self.model.set_offload(False)
|
||||
|
||||
# Step 2: Re-enable offloading with the correct settings
|
||||
# This builds the manager based on the *newly composed* tensor shapes.
|
||||
self.model.set_offload(True, **offload_settings)
|
||||
|
||||
# --- END MODIFIED SECTION ---
|
||||
|
||||
# Update last known device signature after (re)composition
|
||||
# Always update state tracking so subsequent calls see a consistent snapshot.
|
||||
self.model._applied_loras = self.loras.copy()
|
||||
self._applied_loras = self.loras.copy()
|
||||
self._last_device = current_device
|
||||
|
||||
# Caching logic
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from typing import Callable, List, Tuple, Union
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
import comfy.model_management
|
||||
import folder_paths
|
||||
import logging
|
||||
|
||||
from nunchaku import NunchakuQwenImageTransformer2DModel
|
||||
@@ -136,12 +138,13 @@ class ComfyQwenImageWrapperV2(nn.Module):
|
||||
)
|
||||
|
||||
# Deep comparison of LoRA stacks to detect any changes
|
||||
# This ensures we catch changes in weights, paths, or order
|
||||
# Check against the shared model's currently composed LoRAs to handle multiple wrappers sharing the model.
|
||||
model_applied = getattr(self.model, "_applied_loras", None)
|
||||
loras_changed = False
|
||||
if self._applied_loras is None or len(self._applied_loras) != len(self.loras):
|
||||
if model_applied is None or len(model_applied) != len(self.loras):
|
||||
loras_changed = True
|
||||
else:
|
||||
for applied, current in zip(self._applied_loras, self.loras):
|
||||
for applied, current in zip(model_applied, self.loras):
|
||||
if applied != current:
|
||||
loras_changed = True
|
||||
break
|
||||
@@ -157,6 +160,7 @@ class ComfyQwenImageWrapperV2(nn.Module):
|
||||
if loras_changed or model_is_dirty or device_changed:
|
||||
# The compose function handles resetting before applying the new stack
|
||||
reset_lora_v2(self.model)
|
||||
self.model._applied_loras = self.loras.copy()
|
||||
self._applied_loras = self.loras.copy()
|
||||
|
||||
# Reset cache when LoRAs change to prevent stale cache in multi-stage workflows
|
||||
@@ -201,10 +205,32 @@ class ComfyQwenImageWrapperV2(nn.Module):
|
||||
|
||||
# --- END NEW VRAM CHECK ---
|
||||
|
||||
# 4. Compose LoRAs using v2-specific function. This changes internal tensor shapes.
|
||||
# Returns True if successful (supported format), False if unsupported (skipped).
|
||||
# 4. Build cache args — same pattern as the V1 wrapper.
|
||||
try:
|
||||
_comfy_base = str(Path(folder_paths.models_dir).parent)
|
||||
except Exception:
|
||||
_comfy_base = None
|
||||
|
||||
# Separate per-LoRA meta dicts and derive the global save_precompiled flag.
|
||||
_lora_configs_clean = []
|
||||
_any_save_flag = False
|
||||
for _entry in self.loras:
|
||||
_lc_path = _entry[0]
|
||||
_lc_strength = _entry[1]
|
||||
_lc_meta: dict = _entry[2] if len(_entry) > 2 else {}
|
||||
_lora_configs_clean.append((_lc_path, _lc_strength, _lc_meta))
|
||||
if _lc_meta.get("save_precompiled", False):
|
||||
_any_save_flag = True
|
||||
|
||||
# 5. Compose LoRAs using v2-specific function.
|
||||
logger.info(f"[V2 Node] 🔧 Forward pass: calling compose_loras_v2_v2 with apply_awq_mod={self.apply_awq_mod} (type: {type(self.apply_awq_mod).__name__})")
|
||||
is_supported_format = compose_loras_v2_v2(self.model, self.loras, apply_awq_mod=self.apply_awq_mod)
|
||||
is_supported_format = compose_loras_v2_v2(
|
||||
self.model,
|
||||
_lora_configs_clean,
|
||||
apply_awq_mod=self.apply_awq_mod,
|
||||
save_precompiled_lora=_any_save_flag,
|
||||
cache_dir=_comfy_base,
|
||||
)
|
||||
|
||||
# Validate composition result; if 0 targets after a crash/transition, retry once
|
||||
# But ONLY if the format was supported. If unsupported, retrying is pointless.
|
||||
@@ -217,7 +243,13 @@ class ComfyQwenImageWrapperV2(nn.Module):
|
||||
logger.warning("[V2 Node] LoRA composition reported 0 target modules. Forcing reset and one retry.")
|
||||
try:
|
||||
reset_lora_v2(self.model)
|
||||
compose_loras_v2_v2(self.model, self.loras, apply_awq_mod=self.apply_awq_mod)
|
||||
compose_loras_v2_v2(
|
||||
self.model,
|
||||
_lora_configs_clean,
|
||||
apply_awq_mod=self.apply_awq_mod,
|
||||
save_precompiled_lora=_any_save_flag,
|
||||
cache_dir=_comfy_base,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[V2 Node] LoRA re-compose retry failed: {e}")
|
||||
else:
|
||||
@@ -251,7 +283,9 @@ class ComfyQwenImageWrapperV2(nn.Module):
|
||||
|
||||
# --- END MODIFIED SECTION ---
|
||||
|
||||
# Update last known device signature after (re)composition
|
||||
# Always update state tracking so subsequent calls see a consistent snapshot.
|
||||
self.model._applied_loras = self.loras.copy()
|
||||
self._applied_loras = self.loras.copy()
|
||||
self._last_device = current_device
|
||||
|
||||
# Caching logic
|
||||
|
||||
Reference in New Issue
Block a user