Add fast_sampler.py with optimized sampling and VAE decoding, enhance PreviewImage

This commit introduces `fast_sampler.py`, a new module designed to enhance the performance of sampling and VAE decoding in ComfyUI. It replaces or augments functionality previously handled in `model_management.py`, providing better VRAM management, FP16 support, and tiled decoding for low-memory scenarios. Additionally, it improves the `PreviewImage` node in `nodes.py` for faster and more efficient preview generation. These changes improve efficiency, stability, and usability, particularly for GPU-based workflows.

**Key Changes:**
- Implemented `fast_ksampler` for optimized sampling with improved memory management, FP16 support via `torch.amp.autocast`, and `channels_last` memory format for better GPU performance.
- Added `fast_vae_decode` for efficient VAE decoding, incorporating FP16 support, `channels_last`, and selective VRAM clearing to prevent out-of-memory errors.
- Introduced `fast_vae_tiled_decode` for tiled VAE decoding, enabling processing of large latents on GPUs with limited VRAM by using configurable tile sizes and overlaps.
- Added profiling and debugging utilities (`profile_section`, `profile_cuda_sync`) to track execution times and VRAM usage when `--profile` or `--debug` flags are enabled.
- Improved VRAM management with `clear_vram`, ensuring sufficient free memory before loading models or VAE, with configurable thresholds and minimum free memory requirements.
- Implemented `is_fp16_safe` to check GPU compatibility for FP16 operations, disabling them on unsupported hardware (e.g., GTX 1660/Turing).
- Optimized tensor transfers with `optimized_transfer` and `optimized_conditioning` for synchronous device placement and dtype casting.
- Enhanced model preloading with `preload_model`, which unloads VAE before loading U-Net to conserve VRAM and checks for already-loaded VAE to avoid redundant transfers.
- Integrated `cudnn.benchmark` for for tests, disabled by default.
- VRAM should now be managed efficiently.
- Updated `PreviewImage` node in `nodes.py` to support adaptive resizing of preview images to a maximum dimension of ~512 pixels while preserving aspect ratio, using `Image.LANCZOS` for quality. Increased `compress_level` from 1 to 4 for faster PNG compression, optimizing preview generation.

**Impact:**
- Significantly reduces VRAM usage during sampling and VAE decoding, making ComfyUI more stable on GPUs with limited memory.
- Improves performance for large-scale image generation through tiled decoding and FP16 optimizations.
- Enhances debugging capabilities with detailed profiling and logging, aiding development and optimization.

**Dependencies:**
- Relies on `nodes.py` for integration with `KSampler`, `VAEDecode`, `VAEDecodeTiled`, and `PreviewImage` nodes.
- Assumes compatibility with existing `ModelPatcher` functionality for model patching (e.g., in `LoraLoader`).

**Notes:**
- Users should enable `--profile` or `--debug` flags to access detailed performance logs.
- FP16 support requires compatible GPU hardware (compute capability ≥ 8 or > 7).
- Tiled decoding parameters (`tile_size`, `overlap`, etc.) may need tuning for specific workflows.
- Preview images are now smaller and faster to generate, but users can adjust `max_size` in `PreviewImage` if higher resolution previews are needed.

This is a foundational change to improve ComfyUI's performance and scalability, particularly for resource-constrained environments.

Thanks to Grok @ xAI for help.
This commit is contained in:
loxotron 2025-05-15 06:54:33 +03:00
parent 08368f8e00
commit b9f5145f4d
9 changed files with 2437 additions and 1172 deletions

View File

@ -1,3 +1,8 @@
"""
This file is part of ComfyUI.
Copyright (C) 2024 Comfy
"""
import argparse import argparse
import enum import enum
import os import os
@ -22,9 +27,7 @@ class EnumAction(argparse.Action):
choices = tuple(e.value for e in enum_type) choices = tuple(e.value for e in enum_type)
kwargs.setdefault("choices", choices) kwargs.setdefault("choices", choices)
kwargs.setdefault("metavar", f"[{','.join(list(choices))}]") kwargs.setdefault("metavar", f"[{','.join(list(choices))}]")
super(EnumAction, self).__init__(**kwargs) super(EnumAction, self).__init__(**kwargs)
self._enum = enum_type self._enum = enum_type
def __call__(self, parser, namespace, values, option_string=None): def __call__(self, parser, namespace, values, option_string=None):
@ -35,6 +38,8 @@ class EnumAction(argparse.Action):
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--debug", action="store_true", help="Enable debug logging.")
parser.add_argument("--profile", action="store_true", help="Enable profiling.")
parser.add_argument("--listen", type=str, default="127.0.0.1", metavar="IP", nargs="?", const="0.0.0.0,::", help="Specify the IP address to listen on (default: 127.0.0.1). You can give a list of ip addresses by separating them with a comma like: 127.2.2.2,127.3.3.3 If --listen is provided without an argument, it defaults to 0.0.0.0,:: (listens on all ipv4 and ipv6)") parser.add_argument("--listen", type=str, default="127.0.0.1", metavar="IP", nargs="?", const="0.0.0.0,::", help="Specify the IP address to listen on (default: 127.0.0.1). You can give a list of ip addresses by separating them with a comma like: 127.2.2.2,127.3.3.3 If --listen is provided without an argument, it defaults to 0.0.0.0,:: (listens on all ipv4 and ipv6)")
parser.add_argument("--port", type=int, default=8188, help="Set the listen port.") parser.add_argument("--port", type=int, default=8188, help="Set the listen port.")
parser.add_argument("--tls-keyfile", type=str, help="Path to TLS (SSL) key file. Enables TLS, makes app accessible at https://... requires --tls-certfile to function") parser.add_argument("--tls-keyfile", type=str, help="Path to TLS (SSL) key file. Enables TLS, makes app accessible at https://... requires --tls-certfile to function")
@ -53,34 +58,37 @@ parser.add_argument("--cuda-device", type=int, default=None, metavar="DEVICE_ID"
cm_group = parser.add_mutually_exclusive_group() cm_group = parser.add_mutually_exclusive_group()
cm_group.add_argument("--cuda-malloc", action="store_true", help="Enable cudaMallocAsync (enabled by default for torch 2.0 and up).") cm_group.add_argument("--cuda-malloc", action="store_true", help="Enable cudaMallocAsync (enabled by default for torch 2.0 and up).")
cm_group.add_argument("--disable-cuda-malloc", action="store_true", help="Disable cudaMallocAsync.") cm_group.add_argument("--disable-cuda-malloc", action="store_true", help="Disable cudaMallocAsync.")
cm_group.add_argument("--model-dtype", type=str, choices=["fp16", "bf16", "fp32"], help="Force model data type (fp16, bf16, fp32)")
fp_group = parser.add_mutually_exclusive_group() fp_group = parser.add_mutually_exclusive_group()
fp_group.add_argument("--force-fp32", action="store_true", help="Force fp32 (If this makes your GPU work better please report it).") fp_group.add_argument("--force-fp32", action="store_true", help="Force fp32 (If this makes your GPU work better please report it).")
fp_group.add_argument("--force-fp32-vae", action="store_true", help="Force VAE to use FP32 precision")
fp_group.add_argument("--force-fp16", action="store_true", help="Force fp16.") fp_group.add_argument("--force-fp16", action="store_true", help="Force fp16.")
fp_group.add_argument("--force-fp16-vae", action="store_true", help="Force VAE to use FP16 precision")
fpunet_group = parser.add_mutually_exclusive_group() fpunet_group = parser.add_mutually_exclusive_group()
fpunet_group.add_argument("--fp32-unet", action="store_true", help="Run the diffusion model in fp32.") fpunet_group.add_argument("--fp32-unet", action="store_true", help="Run the diffusion model in fp32.")
fpunet_group.add_argument("--fp64-unet", action="store_true", help="Run the diffusion model in fp64.") fpunet_group.add_argument("--fp64-unet", action="store_true", help="Run the diffusion model in fp64 (not recommended).")
fpunet_group.add_argument("--bf16-unet", action="store_true", help="Run the diffusion model in bf16.") fpunet_group.add_argument("--bf16-unet", action="store_true", help="Run the diffusion model in bf16 (requires SM >= 8.0).")
fpunet_group.add_argument("--fp16-unet", action="store_true", help="Run the diffusion model in fp16") fpunet_group.add_argument("--fp16-unet", action="store_true", help="Run the diffusion model in fp16 (may cause black images on VRAM < 6 GB).")
fpunet_group.add_argument("--fp8_e4m3fn-unet", action="store_true", help="Store unet weights in fp8_e4m3fn.") fpunet_group.add_argument("--fp8_e4m3fn-unet", action="store_true", help="Run UNet in fp8 e4m3fn (requires SM >= 9.0 or SM 8.9 with PyTorch >= 2.3).")
fpunet_group.add_argument("--fp8_e5m2-unet", action="store_true", help="Store unet weights in fp8_e5m2.") fpunet_group.add_argument("--fp8_e5m2-unet", action="store_true", help="Run UNet in fp8 e5m2 (requires SM >= 9.0 or SM 8.9 with PyTorch >= 2.3).")
fpunet_group.add_argument("--fp8_e8m0fnu-unet", action="store_true", help="Store unet weights in fp8_e8m0fnu.") fpunet_group.add_argument("--fp8_e8m0fnu-unet", action="store_true", help="Run UNet in fp8 e8m0fnu (requires SM >= 9.0 or SM 8.9 with PyTorch >= 2.3).") # UPDATED: Clarified requirements
fpvae_group = parser.add_mutually_exclusive_group() fpvae_group = parser.add_mutually_exclusive_group()
fpvae_group.add_argument("--fp16-vae", action="store_true", help="Run the VAE in fp16, might cause black images.") fpvae_group.add_argument("--fp16-vae", action="store_true", help="Run the VAE in fp16 (risks black images on VRAM < 6 GB).")
fpvae_group.add_argument("--fp32-vae", action="store_true", help="Run the VAE in full precision fp32.") fpvae_group.add_argument("--fp32-vae", action="store_true", help="Run the VAE in fp32 (recommended for GPUs with VRAM < 6 GB).")
fpvae_group.add_argument("--bf16-vae", action="store_true", help="Run the VAE in bf16.") fpvae_group.add_argument("--bf16-vae", action="store_true", help="Run the VAE in bf16 (requires SM >= 8.0).")
parser.add_argument("--cpu-vae", action="store_true", help="Run the VAE on the CPU.") parser.add_argument("--cpu-vae", action="store_true", help="Run the VAE on the CPU (slower, but safe for low VRAM).")
fpte_group = parser.add_mutually_exclusive_group() fpte_group = parser.add_mutually_exclusive_group()
fpte_group.add_argument("--fp8_e4m3fn-text-enc", action="store_true", help="Store text encoder weights in fp8 (e4m3fn variant).") fpte_group.add_argument("--fp8_e4m3fn-text-enc", action="store_true", help="Run text encoder in fp8 e4m3fn (requires SM >= 9.0 or SM 8.9 with PyTorch >= 2.3).")
fpte_group.add_argument("--fp8_e5m2-text-enc", action="store_true", help="Store text encoder weights in fp8 (e5m2 variant).") fpte_group.add_argument("--fp8_e5m2-text-enc", action="store_true", help="Run text encoder in fp8 e5m2 (requires SM >= 9.0 or SM 8.9 with PyTorch >= 2.3).")
fpte_group.add_argument("--fp16-text-enc", action="store_true", help="Store text encoder weights in fp16.") fpte_group.add_argument("--fp8_e8m0fnu-text-enc", action="store_true", help="Run text encoder in fp8 e8m0fnu (requires SM >= 9.0 or SM 8.9 with PyTorch >= 2.3).") # NEW
fpte_group.add_argument("--fp32-text-enc", action="store_true", help="Store text encoder weights in fp32.") fpte_group.add_argument("--fp16-text-enc", action="store_true", help="Run text encoder in fp16 (may cause issues on VRAM < 6 GB).")
fpte_group.add_argument("--bf16-text-enc", action="store_true", help="Store text encoder weights in bf16.") fpte_group.add_argument("--fp32-text-enc", action="store_true", help="Run text encoder in fp32 (recommended for GPUs with VRAM < 6 GB).")
fpte_group.add_argument("--bf16-text-enc", action="store_true", help="Run text encoder in bf16 (requires SM >= 8.0).")
parser.add_argument("--force-channels-last", action="store_true", help="Force channels last format when inferencing the models.") parser.add_argument("--force-channels-last", action="store_true", help="Force channels last format when inferencing the models.")
@ -96,7 +104,6 @@ class LatentPreviewMethod(enum.Enum):
TAESD = "taesd" TAESD = "taesd"
parser.add_argument("--preview-method", type=LatentPreviewMethod, default=LatentPreviewMethod.NoPreviews, help="Default preview method for sampler nodes.", action=EnumAction) parser.add_argument("--preview-method", type=LatentPreviewMethod, default=LatentPreviewMethod.NoPreviews, help="Default preview method for sampler nodes.", action=EnumAction)
parser.add_argument("--preview-size", type=int, default=512, help="Sets the maximum preview size for sampler nodes.") parser.add_argument("--preview-size", type=int, default=512, help="Sets the maximum preview size for sampler nodes.")
cache_group = parser.add_mutually_exclusive_group() cache_group = parser.add_mutually_exclusive_group()
@ -117,7 +124,6 @@ upcast = parser.add_mutually_exclusive_group()
upcast.add_argument("--force-upcast-attention", action="store_true", help="Force enable attention upcasting, please report if it fixes black images.") upcast.add_argument("--force-upcast-attention", action="store_true", help="Force enable attention upcasting, please report if it fixes black images.")
upcast.add_argument("--dont-upcast-attention", action="store_true", help="Disable all upcasting of attention. Should be unnecessary except for debugging.") upcast.add_argument("--dont-upcast-attention", action="store_true", help="Disable all upcasting of attention. Should be unnecessary except for debugging.")
vram_group = parser.add_mutually_exclusive_group() vram_group = parser.add_mutually_exclusive_group()
vram_group.add_argument("--gpu-only", action="store_true", help="Store and run everything (text encoders/CLIP models, etc... on the GPU).") vram_group.add_argument("--gpu-only", action="store_true", help="Store and run everything (text encoders/CLIP models, etc... on the GPU).")
vram_group.add_argument("--highvram", action="store_true", help="By default models will be unloaded to CPU memory after being used. This option keeps them in GPU memory.") vram_group.add_argument("--highvram", action="store_true", help="By default models will be unloaded to CPU memory after being used. This option keeps them in GPU memory.")
@ -199,7 +205,7 @@ parser.add_argument(
"--comfy-api-base", "--comfy-api-base",
type=str, type=str,
default="https://api.comfy.org", default="https://api.comfy.org",
help="Set the base URL for the ComfyUI API. (default: https://api.comfy.org)", help="Set the base URL for the ComfyUI API (default: https://api.comfy.org).",
) )
if comfy.options.args_parsing: if comfy.options.args_parsing:
@ -215,6 +221,8 @@ if args.disable_auto_launch:
if args.force_fp16: if args.force_fp16:
args.fp16_unet = True args.fp16_unet = True
args.fp16_vae = True
args.fp16_text_enc = True
# '--fast' is not provided, use an empty set # '--fast' is not provided, use an empty set

File diff suppressed because it is too large Load Diff

View File

@ -34,6 +34,15 @@ import comfy.hooks
import comfy.patcher_extension import comfy.patcher_extension
from comfy.patcher_extension import CallbacksMP, WrappersMP, PatcherInjection from comfy.patcher_extension import CallbacksMP, WrappersMP, PatcherInjection
from comfy.comfy_types import UnetWrapperFunction from comfy.comfy_types import UnetWrapperFunction
from comfy.cli_args import args
from comfy.ldm.models.autoencoder import AutoencoderKL
from comfy.ldm.modules.diffusionmodules.openaimodel import UNetModel
from comfy.model_management import get_free_memory, get_torch_device
# Global flag for profiling
PROFILING_ENABLED = args.profile
DEBUG_ENABLED = args.debug
VERBOSE_ENABLED = False
def string_to_seed(data): def string_to_seed(data):
crc = 0xFFFFFFFF crc = 0xFFFFFFFF
@ -200,10 +209,13 @@ class MemoryCounter:
class ModelPatcher: class ModelPatcher:
def __init__(self, model, load_device, offload_device, size=0, weight_inplace_update=False): def __init__(self, model, load_device, offload_device, size=0, weight_inplace_update=False):
if PROFILING_ENABLED:
logging.debug(f"ModelPatcher init: model={type(model).__name__}, load_device={load_device}, offload_device={offload_device}, size={size / (1024**3):.2f} GB")
self.size = size self.size = size
self.model = model self.model = model
if not hasattr(self.model, 'device'): if not hasattr(self.model, 'device'):
logging.debug("Model doesn't have a device attribute.") if DEBUG_ENABLED:
logging.debug("Model doesn't have a device attribute.")
self.model.device = offload_device self.model.device = offload_device
elif self.model.device is None: elif self.model.device is None:
self.model.device = offload_device self.model.device = offload_device
@ -323,8 +335,44 @@ class ModelPatcher:
return n return n
def is_clone(self, other): def is_clone(self, other):
if hasattr(other, 'model') and self.model is other.model: """
Check if another ModelPatcher is a clone of this one.
Compares model type, patches_uuid, patches content, and base model equivalence.
"""
if not isinstance(other, ModelPatcher) or not hasattr(other, 'model'):
if DEBUG_ENABLED:
logging.debug("[DEBUG_CLONES] Not clones: invalid other ModelPatcher")
return False
if self is other:
if DEBUG_ENABLED:
logging.debug("[DEBUG_CLONES] Models are clones: same ModelPatcher object")
return True return True
if self.model.__class__ != other.model.__class__:
if DEBUG_ENABLED:
logging.debug(f"[DEBUG_CLONES] Not clones: different model types {self.model.__class__.__name__} vs {other.model.__class__.__name__}")
return False
if self.patches_uuid == other.patches_uuid:
if self.patches != other.patches:
if DEBUG_ENABLED:
logging.debug(f"[DEBUG_CLONES] Not clones: same patches_uuid={self.patches_uuid}, but different patches")
return False
if DEBUG_ENABLED:
logging.debug(f"[DEBUG_CLONES] Models are clones: same patches_uuid={self.patches_uuid} and matching patches")
return True
self_base = getattr(self.model, 'real_model', getattr(self.model, 'model', self.model))
other_base = getattr(other.model, 'real_model', getattr(other.model, 'model', other.model))
if self_base is other_base:
if DEBUG_ENABLED:
logging.debug(f"[DEBUG_CLONES] Models are clones: same base model object, type={self.model.__class__.__name__}")
return True
if DEBUG_ENABLED:
logging.debug(f"[DEBUG_CLONES] Not clones: different base model objects, type={self.model.__class__.__name__}")
return False return False
def clone_has_same_weights(self, clone: 'ModelPatcher'): def clone_has_same_weights(self, clone: 'ModelPatcher'):
@ -584,6 +632,9 @@ class ModelPatcher:
return loading return loading
def load(self, device_to=None, lowvram_model_memory=0, force_patch_weights=False, full_load=False): def load(self, device_to=None, lowvram_model_memory=0, force_patch_weights=False, full_load=False):
# Set default device if device_to is None to avoid errors in get_free_memory
device = device_to if device_to is not None else comfy.model_management.get_torch_device()
with self.use_ejected(): with self.use_ejected():
self.unpatch_hooks() self.unpatch_hooks()
mem_counter = 0 mem_counter = 0
@ -591,6 +642,30 @@ class ModelPatcher:
lowvram_counter = 0 lowvram_counter = 0
loading = self._load_list() loading = self._load_list()
if PROFILING_ENABLED:
# Determine module name for logging
module_name = "Unknown"
if isinstance(self.model, (AutoencoderKL, comfy.sd.VAE)):
module_name = "VAE"
elif isinstance(self.model, UNetModel):
module_name = "UNet"
elif hasattr(self, "is_clip") and self.is_clip:
module_name = "CLIP"
elif "diffusion_model" in str(type(self.model)):
module_name = "DiffusionModel"
elif isinstance(self.model, torch.nn.Module):
module_name = f"{type(self.model).__name__}"
logging.debug(f"Loading module: {module_name}, type: {type(self.model).__name__}")
# Validate and normalize lowvram_model_memory
if not isinstance(lowvram_model_memory, (int, float)) or lowvram_model_memory < 0:
if PROFILING_ENABLED:
logging.warning(f"Invalid lowvram_model_memory: {lowvram_model_memory}, resetting to 0")
lowvram_model_memory = 0
if PROFILING_ENABLED:
logging.debug(f"ModelPatcher.load: model type: {type(self.model).__name__}, device_to: {device_to}, lowvram_model_memory: {lowvram_model_memory / (1024 * 1024):.2f} MB, full_load: {full_load}")
load_completely = [] load_completely = []
loading.sort(reverse=True) loading.sort(reverse=True)
for x in loading: for x in loading:
@ -604,11 +679,23 @@ class ModelPatcher:
weight_key = "{}.weight".format(n) weight_key = "{}.weight".format(n)
bias_key = "{}.bias".format(n) bias_key = "{}.bias".format(n)
is_vae = isinstance(self.model, (AutoencoderKL, comfy.sd.VAE))
if VERBOSE_ENABLED:
logging.debug(f"Processing module: {n}, type: {type(m).__name__}, is_vae: {is_vae}, module_mem: {module_mem / (1024 * 1024):.2f} MB")
# Skip VAE module if already loaded on the target device
if is_vae and hasattr(self.model, 'first_stage_model') and hasattr(self.model.first_stage_model, 'device') and self.model.first_stage_model.device == device and hasattr(self.model, '_loaded_to_device') and self.model._loaded_to_device == device:
if PROFILING_ENABLED:
logging.debug(f"Skipping VAE module {n}, already on {device} with _loaded_to_device={self.model._loaded_to_device}")
continue
if not full_load and hasattr(m, "comfy_cast_weights"): if not full_load and hasattr(m, "comfy_cast_weights"):
if mem_counter + module_mem >= lowvram_model_memory: if mem_counter + module_mem >= lowvram_model_memory:
lowvram_weight = True lowvram_weight = True
lowvram_counter += 1 lowvram_counter += 1
if hasattr(m, "prev_comfy_cast_weights"): #Already lowvramed if hasattr(m, "prev_comfy_cast_weights"): #Already lowvramed
if VERBOSE_ENABLED:
logging.debug(f"Skipping module {n}: already in lowvram mode")
continue continue
cast_weight = self.force_cast_weights cast_weight = self.force_cast_weights
@ -630,6 +717,9 @@ class ModelPatcher:
m.bias_function = [LowVramPatch(bias_key, self.patches)] m.bias_function = [LowVramPatch(bias_key, self.patches)]
patch_counter += 1 patch_counter += 1
if VERBOSE_ENABLED:
logging.debug(f"Module {n} set to lowvram, weight_key={weight_key}, bias_key={bias_key}, patch_counter={patch_counter}, lowvram_weight={lowvram_weight}")
cast_weight = True cast_weight = True
else: else:
if hasattr(m, "comfy_cast_weights"): if hasattr(m, "comfy_cast_weights"):
@ -638,6 +728,8 @@ class ModelPatcher:
if full_load or mem_counter + module_mem < lowvram_model_memory: if full_load or mem_counter + module_mem < lowvram_model_memory:
mem_counter += module_mem mem_counter += module_mem
load_completely.append((module_mem, n, m, params)) load_completely.append((module_mem, n, m, params))
if VERBOSE_ENABLED:
logging.debug(f"Module {n} added to load_completely, mem_counter={mem_counter / (1024**3):.2f} GB")
if cast_weight and hasattr(m, "comfy_cast_weights"): if cast_weight and hasattr(m, "comfy_cast_weights"):
m.prev_comfy_cast_weights = m.comfy_cast_weights m.prev_comfy_cast_weights = m.comfy_cast_weights
@ -649,7 +741,9 @@ class ModelPatcher:
if bias_key in self.weight_wrapper_patches: if bias_key in self.weight_wrapper_patches:
m.bias_function.extend(self.weight_wrapper_patches[bias_key]) m.bias_function.extend(self.weight_wrapper_patches[bias_key])
mem_counter += move_weight_functions(m, device_to) mem_counter += move_weight_functions(m, device)
if VERBOSE_ENABLED:
logging.debug(f"Moved weight functions for {n} to device={device}, mem_counter={mem_counter / (1024**3):.2f} GB")
load_completely.sort(reverse=True) load_completely.sort(reverse=True)
for x in load_completely: for x in load_completely:
@ -658,34 +752,49 @@ class ModelPatcher:
params = x[3] params = x[3]
if hasattr(m, "comfy_patched_weights"): if hasattr(m, "comfy_patched_weights"):
if m.comfy_patched_weights == True: if m.comfy_patched_weights == True:
if VERBOSE_ENABLED:
logging.debug(f"Skipping module {n}: already patched")
continue continue
for param in params: for param in params:
self.patch_weight_to_device("{}.{}".format(n, param), device_to=device_to) self.patch_weight_to_device("{}.{}".format(n, param), device_to=device)
logging.debug("lowvram: loaded module regularly {} {}".format(n, m)) if VERBOSE_ENABLED:
logging.debug(f"Loaded module {n} regularly, lowvram={self.model.model_lowvram}")
m.comfy_patched_weights = True m.comfy_patched_weights = True
for x in load_completely: for x in load_completely:
x[2].to(device_to) x[2].to(device)
if VERBOSE_ENABLED:
logging.debug(f"Moved module {x[1]} to device={device}")
# Safe logging with module name
lowvram_mb = lowvram_model_memory / (1024 * 1024)
mem_counter_mb = mem_counter / (1024 * 1024)
if lowvram_counter > 0: if lowvram_counter > 0:
logging.info("loaded partially {} {} {}".format(lowvram_model_memory / (1024 * 1024), mem_counter / (1024 * 1024), patch_counter)) if PROFILING_ENABLED:
logging.info(f"Loaded partially {module_name}: {lowvram_mb:.2f} MB, {mem_counter_mb:.2f} MB, patches: {patch_counter}")
self.model.model_lowvram = True self.model.model_lowvram = True
else: else:
logging.info("loaded completely {} {} {}".format(lowvram_model_memory / (1024 * 1024), mem_counter / (1024 * 1024), full_load)) if PROFILING_ENABLED:
logging.info(f"Loaded completely {module_name}: {lowvram_mb:.2f} MB, {mem_counter_mb:.2f} MB, full_load: {full_load}")
self.model.model_lowvram = False self.model.model_lowvram = False
if full_load: if full_load:
self.model.to(device_to) self.model.to(device)
mem_counter = self.model_size() mem_counter = self.model_size()
if PROFILING_ENABLED:
logging.info(f"Moved entire model to device: {device}, mem_counter: {mem_counter / (1024 * 1024):.2f} MB")
self.model.lowvram_patch_counter += patch_counter self.model.lowvram_patch_counter += patch_counter
self.model.device = device_to self.model.device = device
self.model.model_loaded_weight_memory = mem_counter self.model.model_loaded_weight_memory = mem_counter
self.model.current_weight_patches_uuid = self.patches_uuid self.model.current_weight_patches_uuid = self.patches_uuid
if PROFILING_ENABLED:
logging.debug(f"Load completed: model type: {type(self.model).__name__}, device: {self.model.device}, loaded_weight_memory: {self.model.model_loaded_weight_memory / (1024 * 1024):.2f} MB, lowvram: {self.model.model_lowvram}, patch_counter: {self.model.lowvram_patch_counter}")
for callback in self.get_all_callbacks(CallbacksMP.ON_LOAD): for callback in self.get_all_callbacks(CallbacksMP.ON_LOAD):
callback(self, device_to, lowvram_model_memory, force_patch_weights, full_load) callback(self, device, lowvram_model_memory, force_patch_weights, full_load)
self.apply_hooks(self.forced_hooks, force_apply=True) self.apply_hooks(self.forced_hooks, force_apply=True)
@ -696,6 +805,13 @@ class ModelPatcher:
if k not in self.object_patches_backup: if k not in self.object_patches_backup:
self.object_patches_backup[k] = old self.object_patches_backup[k] = old
# Validate and normalize lowvram_model_memory
if PROFILING_ENABLED:
logging.debug(f"patch_model: model={type(self.model).__name__}, lowvram_model_memory={lowvram_model_memory / (1024**3):.2f} GB, device_to={device_to}")
if not isinstance(lowvram_model_memory, (int, float)) or lowvram_model_memory < 0:
logging.warning(f"Invalid lowvram_model_memory in patch_model: {lowvram_model_memory}, resetting to 0")
lowvram_model_memory = 0
if lowvram_model_memory == 0: if lowvram_model_memory == 0:
full_load = True full_load = True
else: else:
@ -812,21 +928,37 @@ class ModelPatcher:
with self.use_ejected(skip_and_inject_on_exit_only=True): with self.use_ejected(skip_and_inject_on_exit_only=True):
unpatch_weights = self.model.current_weight_patches_uuid is not None and (self.model.current_weight_patches_uuid != self.patches_uuid or force_patch_weights) unpatch_weights = self.model.current_weight_patches_uuid is not None and (self.model.current_weight_patches_uuid != self.patches_uuid or force_patch_weights)
# TODO: force_patch_weights should not unload + reload full model # TODO: force_patch_weights should not unload + reload full model
if PROFILING_ENABLED:
logging.debug(f"partially_load: unpatch_weights={unpatch_weights}, patches_uuid={self.patches_uuid}, current_weight_patches_uuid={self.model.current_weight_patches_uuid}")
used = self.model.model_loaded_weight_memory used = self.model.model_loaded_weight_memory
if PROFILING_ENABLED:
logging.debug(f"partially_load: used={used / (1024**3):.2f} GB, model_loaded_weight_memory={self.model.model_loaded_weight_memory / (1024**3):.2f} GB")
self.unpatch_model(self.offload_device, unpatch_weights=unpatch_weights) self.unpatch_model(self.offload_device, unpatch_weights=unpatch_weights)
if unpatch_weights: if unpatch_weights:
extra_memory += (used - self.model.model_loaded_weight_memory) extra_memory += (used - self.model.model_loaded_weight_memory)
if PROFILING_ENABLED:
logging.debug(f"partially_load: updated extra_memory={extra_memory / (1024**3):.2f} GB after unpatch, used={used / (1024**3):.2f} GB, model_loaded_weight_memory={self.model.model_loaded_weight_memory / (1024**3):.2f} GB")
self.patch_model(load_weights=False) self.patch_model(load_weights=False)
full_load = False full_load = False
if self.model.model_lowvram == False and self.model.model_loaded_weight_memory > 0: if self.model.model_lowvram == False and self.model.model_loaded_weight_memory > 0:
self.apply_hooks(self.forced_hooks, force_apply=True) self.apply_hooks(self.forced_hooks, force_apply=True)
if PROFILING_ENABLED:
logging.debug(f"partially_load: early return, model_lowvram={self.model.model_lowvram}, model_loaded_weight_memory={self.model.model_loaded_weight_memory / (1024**3):.2f} GB")
return 0 return 0
if self.model.model_loaded_weight_memory + extra_memory > self.model_size(): if self.model.model_loaded_weight_memory + extra_memory > self.model_size():
full_load = True full_load = True
if PROFILING_ENABLED:
logging.debug(f"partially_load: full_load=True, model_loaded_weight_memory={self.model.model_loaded_weight_memory / (1024**3):.2f} GB, extra_memory={extra_memory / (1024**3):.2f} GB, model_size={self.model_size() / (1024**3):.2f} GB")
current_used = self.model.model_loaded_weight_memory current_used = self.model.model_loaded_weight_memory
lowvram_model_memory = current_used + extra_memory
if PROFILING_ENABLED:
logging.debug(f"partially_load: calling load with lowvram_model_memory={lowvram_model_memory / (1024**3):.2f} GB, current_used={current_used / (1024**3):.2f} GB, extra_memory={extra_memory / (1024**3):.2f} GB")
try: try:
self.load(device_to, lowvram_model_memory=current_used + extra_memory, force_patch_weights=force_patch_weights, full_load=full_load) self.load(device_to, lowvram_model_memory=lowvram_model_memory, force_patch_weights=force_patch_weights, full_load=full_load)
except Exception as e: except Exception as e:
self.detach() self.detach()
raise e raise e
@ -834,12 +966,34 @@ class ModelPatcher:
return self.model.model_loaded_weight_memory - current_used return self.model.model_loaded_weight_memory - current_used
def detach(self, unpatch_all=True): def detach(self, unpatch_all=True):
if PROFILING_ENABLED:
free_vram_before = get_free_memory(get_torch_device()) / 1024**3
logging.debug(f"detach: Before, free_vram={free_vram_before:.2f} GB, model={self.model.__class__.__name__}")
if hasattr(self.model, 'on_patched'):
if DEBUG_ENABLED:
logging.debug(f"Calling on_patched for {self.model.__class__.__name__}")
self.model.on_patched()
self.eject_model() self.eject_model()
self.model_patches_to(self.offload_device) self.model_patches_to(self.offload_device)
if unpatch_all: if unpatch_all:
self.unpatch_model(self.offload_device, unpatch_weights=unpatch_all) self.unpatch_model(self.offload_device)
for callback in self.get_all_callbacks(CallbacksMP.ON_DETACH):
callback(self, unpatch_all) if hasattr(self.model, 'to'):
self.model.to(self.offload_device)
self.model.device = self.offload_device
self.model.model_loaded_weight_memory = 0
self.patches = []
self.model_patches = 0
if torch.cuda.is_available():
torch.cuda.empty_cache()
if PROFILING_ENABLED:
free_vram_after = get_free_memory(get_torch_device()) / 1024**3
logging.debug(f"detach: After, free_vram={free_vram_after:.2f} GB, freed={(free_vram_after-free_vram_before):.2f} GB, model={self.model.__class__.__name__}")
return self.model return self.model
def current_loaded_device(self): def current_loaded_device(self):
@ -1206,4 +1360,3 @@ class ModelPatcher:
def __del__(self): def __del__(self):
self.detach(unpatch_all=False) self.detach(unpatch_all=False)

View File

@ -18,16 +18,16 @@
import torch import torch
import logging import logging
import comfy.model_management
from comfy.cli_args import args, PerformanceFeature from comfy.cli_args import args, PerformanceFeature
import comfy.float import comfy.float
import comfy.rmsnorm import comfy.rmsnorm
import contextlib import contextlib
cast_to = comfy.model_management.cast_to #TODO: remove once no more references from comfy.utils import cast_to
cast_to = cast_to # Maintain compatibility with code expecting comfy.ops.cast_to
def cast_to_input(weight, input, non_blocking=False, copy=True): def cast_to_input(weight, input, non_blocking=False, copy=True):
return comfy.model_management.cast_to(weight, input.dtype, input.device, non_blocking=non_blocking, copy=copy) return cast_to(weight, input.dtype, input.device, non_blocking=non_blocking, copy=copy)
def cast_bias_weight(s, input=None, dtype=None, device=None, bias_dtype=None): def cast_bias_weight(s, input=None, dtype=None, device=None, bias_dtype=None):
if input is not None: if input is not None:
@ -48,7 +48,7 @@ def cast_bias_weight(s, input=None, dtype=None, device=None, bias_dtype=None):
non_blocking = comfy.model_management.device_supports_non_blocking(device) non_blocking = comfy.model_management.device_supports_non_blocking(device)
if s.bias is not None: if s.bias is not None:
has_function = len(s.bias_function) > 0 has_function = len(s.bias_function) > 0
bias = comfy.model_management.cast_to(s.bias, bias_dtype, device, non_blocking=non_blocking, copy=has_function, stream=offload_stream) bias = cast_to(s.bias, bias_dtype, device, non_blocking=non_blocking, copy=has_function, stream=offload_stream)
if has_function: if has_function:
with wf_context: with wf_context:
@ -56,7 +56,7 @@ def cast_bias_weight(s, input=None, dtype=None, device=None, bias_dtype=None):
bias = f(bias) bias = f(bias)
has_function = len(s.weight_function) > 0 has_function = len(s.weight_function) > 0
weight = comfy.model_management.cast_to(s.weight, dtype, device, non_blocking=non_blocking, copy=has_function, stream=offload_stream) weight = cast_to(s.weight, dtype, device, non_blocking=non_blocking, copy=has_function, stream=offload_stream)
if has_function: if has_function:
with wf_context: with wf_context:
for f in s.weight_function: for f in s.weight_function:
@ -308,10 +308,10 @@ def fp8_linear(self, input):
if scale_input is None: if scale_input is None:
scale_input = torch.ones((), device=input.device, dtype=torch.float32) scale_input = torch.ones((), device=input.device, dtype=torch.float32)
input = torch.clamp(input, min=-448, max=448, out=input) input = torch.clamp(input, min=-448, max=448, out=input)
input = input.reshape(-1, input_shape[2]).to(dtype).contiguous() input = input.reshape(-1, input_shape[2]).to(dtype)
else: else:
scale_input = scale_input.to(input.device) scale_input = scale_input.to(input.device)
input = (input * (1.0 / scale_input).to(input_dtype)).reshape(-1, input_shape[2]).to(dtype).contiguous() input = (input * (1.0 / scale_input).to(input_dtype)).reshape(-1, input_shape[2]).to(dtype)
if bias is not None: if bias is not None:
o = torch._scaled_mm(input, w, out_dtype=input_dtype, bias=bias, scale_a=scale_input, scale_b=scale_weight) o = torch._scaled_mm(input, w, out_dtype=input_dtype, bias=bias, scale_a=scale_input, scale_b=scale_weight)

View File

@ -1078,3 +1078,23 @@ def upscale_dit_mask(mask: torch.Tensor, img_size_in, img_size_out):
dim=1 dim=1
) )
return out return out
def cast_to(weight, dtype=None, device=None, non_blocking=False, copy=False, stream=None):
"""Cast tensor to specified dtype and device."""
if device is None or weight.device == device:
if not copy:
if dtype is None or weight.dtype == dtype:
return weight
if stream is not None:
with stream:
return weight.to(dtype=dtype, copy=copy)
return weight.to(dtype=dtype, copy=copy)
if stream is not None:
with stream:
r = torch.empty_like(weight, dtype=dtype, device=device)
r.copy_(weight, non_blocking=non_blocking)
else:
r = torch.empty_like(weight, dtype=dtype, device=device)
r.copy_(weight, non_blocking=non_blocking)
return r

477
fast_sampler.py Normal file
View File

@ -0,0 +1,477 @@
import torch
import comfy
import gc
import time
from torch.amp import autocast
from comfy.cli_args import args
from comfy.model_management import get_torch_device, vae_dtype, soft_empty_cache, free_memory, force_channels_last, estimate_vae_decode_memory, device_supports_non_blocking
from contextlib import contextmanager
import latent_preview
import logging
# Global flag for profiling
PROFILING_ENABLED = args.profile
DEBUG_ENABLED = args.debug
CUDNN_BENCHMARK_ENABLED = getattr(args, 'cudnn_benchmark', False) # Default: False
# Configure logging
logging.basicConfig(level=logging.DEBUG if PROFILING_ENABLED or DEBUG_ENABLED else logging.INFO)
# Cache for FP16 safety check
_fp16_safe_cache = {}
@contextmanager
def profile_section(name):
"""Context manager for profiling execution time."""
if PROFILING_ENABLED:
start = time.time()
try:
yield
finally:
logging.debug(f"{name}: {time.time() - start:.3f} s")
else:
yield
def profile_cuda_sync(is_gpu, message="CUDA sync"):
"""Profile CUDA synchronization time if GPU is used."""
if PROFILING_ENABLED and is_gpu:
logging.debug(f"{message} started")
sync_start = time.time()
torch.cuda.synchronize()
logging.debug(f"{message} took {time.time() - sync_start:.3f} s")
def is_fp16_safe(device):
"""Check if FP16 is safe for the GPU (disabled for GTX 1660/Turing)."""
if device.type != 'cuda':
return False
if device in _fp16_safe_cache:
return _fp16_safe_cache[device]
try:
props = torch.cuda.get_device_properties(device)
is_safe = props.major >= 8 or props.compute_capability[0] > 7
_fp16_safe_cache[device] = is_safe
return is_safe
except Exception:
_fp16_safe_cache[device] = False
return False
def initialize_device_and_dtype(model, device=None):
"""Initialize device and dtype from model."""
if device is None:
device = get_torch_device()
dtype = getattr(model, 'dtype', torch.float32)
is_gpu = device.type == 'cuda' and torch.cuda.is_available()
return device, dtype, is_gpu
def clear_vram(device, threshold=0.5, min_free=1.5):
"""Clear VRAM if usage exceeds threshold or free memory is below min_free (in GB)."""
if device.type == 'cuda':
if PROFILING_ENABLED:
start_time = time.time()
mem_allocated = torch.cuda.memory_allocated(device) / 1024**3
mem_total = torch.cuda.get_device_properties(device).total_memory / 1024**3
critical_threshold = 0.05 * mem_total + 0.1 # 5% VRAM + 100 MB
if mem_allocated > threshold * mem_total or (mem_total - mem_allocated) < max(min_free, critical_threshold):
logging.debug(f"Clearing VRAM: allocated {mem_allocated:.2f} GB, free {mem_total - mem_allocated:.2f} GB, threshold {critical_threshold:.2f} GB")
torch.cuda.empty_cache()
#soft_empty_cache(clear=False)
mem_after = torch.cuda.memory_allocated(device) / 1024**3
if PROFILING_ENABLED:
logging.debug(f"VRAM cleared: {mem_allocated:.2f} GB -> {mem_after:.2f} GB, took {time.time() - start_time:.3f} s")
else:
if PROFILING_ENABLED:
logging.debug(f"VRAM not cleared: {mem_allocated:.2f} GB / {mem_total:.2f} GB, sufficient free memory")
return mem_allocated, mem_total
def preload_model(model, device, is_vae=False):
"""Preload model or VAE to device, avoiding unnecessary unloading."""
with profile_section("Model preload"):
if is_vae:
if PROFILING_ENABLED:
start_time = time.time()
logging.debug(f"Checking VAE device for {model.__class__.__name__}")
# Check if VAE is already loaded
if (hasattr(model, 'first_stage_model') and
hasattr(model.first_stage_model, 'device') and
model.first_stage_model.device == device and
hasattr(model, '_loaded_to_device') and
model._loaded_to_device == device):
if PROFILING_ENABLED:
logging.debug(f"VAE already loaded on {device}, skipping transfer, check took {time.time() - start_time:.3f} s")
return
# Load VAE
if PROFILING_ENABLED:
logging.debug(f"Loading VAE to {device}")
transfer_start = time.time()
model.first_stage_model.to(device)
model._loaded_to_device = device
if PROFILING_ENABLED:
logging.debug(f"VAE transferred to {device}, took {time.time() - transfer_start:.3f} s")
logging.debug(f"VAE first_stage_model device: {model.first_stage_model.device}")
logging.debug(f"VAE has decode_tiled: {hasattr(model, 'decode_tiled')}")
else:
# Check if model is already loaded
if hasattr(model, '_loaded_to_device') and model._loaded_to_device == device:
if PROFILING_ENABLED:
logging.debug(f"Model already loaded on {device}, skipping preload")
return
# Load U-Net
if PROFILING_ENABLED:
logging.debug(f"Loading U-Net {model.__class__.__name__} to {device}")
torch.cuda.empty_cache()
comfy.model_management.load_model_gpu(model)
model._loaded_to_device = device
if PROFILING_ENABLED:
free_mem = (torch.cuda.get_device_properties(device).total_memory - torch.cuda.memory_allocated(device)) / 1024**3
logging.debug(f"U-Net loaded to {device}, VRAM free: {free_mem:.2f} GB")
def optimized_transfer(tensor, device, dtype):
"""Synchronous tensor transfer to device."""
pin_memory = comfy.model_management.is_device_cuda(device)
if isinstance(tensor, torch.Tensor) and tensor.device != device:
tensor = tensor.to(device=device, dtype=dtype, pin_memory=pin_memory)
return tensor
def optimized_conditioning(conditioning, device, dtype):
"""Efficiently transfer conditioning tensors."""
return [
optimized_transfer(p, device, dtype) if isinstance(p, torch.Tensor) else p
for p in conditioning
]
def finalize_images(images, device):
"""Process and finalize output images."""
if len(images.shape) == 5: # Combine batches
images = images.reshape(-1, images.shape[-3], images.shape[-2], images.shape[-1])
return images.to(device=device, memory_format=torch.channels_last)
def fast_sample(model, noise, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
denoise, disable_noise, start_step, last_step, force_full_denoise, noise_mask, callback, seed, device, dtype, is_gpu):
"""Optimized sampling function."""
if PROFILING_ENABLED:
start_time = time.time()
logging.debug(f"Starting sampling")
with torch.no_grad():
use_amp = is_gpu and dtype == torch.float16 and is_fp16_safe(device)
with autocast(device_type='cuda', enabled=use_amp):
samples = comfy.sample.sample(
model, noise, steps, cfg, sampler_name, scheduler,
positive, negative, latent_image,
denoise=denoise, disable_noise=disable_noise,
start_step=start_step, last_step=last_step,
force_full_denoise=force_full_denoise,
noise_mask=noise_mask, callback=callback, seed=seed
)
samples = samples.to(device=device, dtype=dtype, memory_format=torch.channels_last)
if PROFILING_ENABLED:
logging.debug(f"Sampling completed, took {time.time() - start_time:.3f} s")
return samples
def fast_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent,
denoise=1.0, disable_noise=False, start_step=None, last_step=None,
force_full_denoise=False, device=None, dtype=None, is_gpu=None):
"""
Fast KSampler implementation with optimized memory management and optional cuDNN benchmark.
"""
if DEBUG_ENABLED:
if model is None:
logging.warning("fast_ksampler: model is None")
if device is None or dtype is None or is_gpu is None:
device, dtype, is_gpu = initialize_device_and_dtype(model.model)
try:
# Enable cuDNN benchmarking if requested
if is_gpu and comfy.model_management.is_device_cuda(device) and CUDNN_BENCHMARK_ENABLED:
torch.backends.cudnn.enabled = True
torch.backends.cudnn.benchmark = True
# Check and move model parameters once
if is_gpu:
if not hasattr(model, '_device_checked') or not model._device_checked:
for param in model.model.parameters():
if param.device.type != device.type:
if DEBUG_ENABLED:
logging.warning(f"U-Net parameter {param.shape} on {param.device.type}, moving to {device}")
model.model.to(device)
if PROFILING_ENABLED:
logging.debug(f"VRAM after moving U-Net: {torch.cuda.memory_allocated(device)/1024**3:.2f} GB")
model._device = device
model._device_checked = True
break
if hasattr(model, 'control_model'):
for param in model.control_model.parameters():
if param.device.type != device.type:
if DEBUG_ENABLED:
logging.warning(f"ControlNet parameter {param.shape} on {param.device.type}, moving to {device}")
model.control_model.to(device)
if PROFILING_ENABLED:
logging.debug(f"VRAM after moving ControlNet: {torch.cuda.memory_allocated(device)/1024**3:.2f} GB")
model._control_device = device
model._device_checked = True
break
# Preload model
preload_model(model, device)
# Transfer latents
with profile_section("Latent transfer"):
latent_image = latent["samples"]
latent_image = optimized_transfer(latent_image, device, dtype)
latent_image = comfy.sample.fix_empty_latent_channels(model, latent_image)
# Transfer conditioning
with profile_section("Conditioning transfer"):
positive = optimized_conditioning(positive, device, dtype)
negative = optimized_conditioning(negative, device, dtype)
# Prepare noise
if disable_noise:
noise = torch.zeros(latent_image.size(), dtype=latent_image.dtype, layout=latent_image.layout, device="cpu")
else:
batch_inds = latent["batch_index"] if "batch_index" in latent else None
noise = comfy.sample.prepare_noise(latent_image, seed, batch_inds)
# Handle noise mask if present
noise_mask = latent.get("noise_mask")
if noise_mask is not None:
noise_mask = optimized_transfer(noise_mask, device, dtype)
# Allocate output tensor
samples = torch.empty_like(latent_image, device=device, dtype=dtype)
# Perform sampling
with torch.no_grad():
callback = None if not comfy.utils.PROGRESS_BAR_ENABLED else latent_preview.prepare_callback(model, steps)
samples = fast_sample(
model, noise, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
denoise, disable_noise, start_step, last_step, force_full_denoise, noise_mask, callback, seed,
device, dtype, is_gpu
)
# Log VRAM state after sampling
if is_gpu and PROFILING_ENABLED:
mem_total = torch.cuda.get_device_properties(device).total_memory / 1024**3
mem_allocated = torch.cuda.memory_allocated(device) / 1024**3
logging.debug(f"VRAM after sampling: {mem_allocated:.2f} GB / {mem_total:.2f} GB")
# Log completion of sampling
if PROFILING_ENABLED:
logging.debug(f"Sampling completed, preparing for VAE")
profile_cuda_sync(is_gpu)
# Clear VRAM after sampling
if is_gpu:
if not PROFILING_ENABLED:
clear_vram(device, threshold=0.5, min_free=1.5)
else:
clear_start = time.time()
mem_allocated, mem_total = clear_vram(device, threshold=0.5, min_free=1.5)
logging.debug(f"VRAM after sampling: {mem_allocated:.2f} GB / {mem_total:.2f} GB, clear took {time.time() - clear_start:.3f} s")
logging.debug(f"Post-VRAM checkpoint: {time.time()}")
out = latent.copy()
out["samples"] = samples
return (out,)
finally:
if PROFILING_ENABLED:
finally_start = time.time()
if is_gpu and CUDNN_BENCHMARK_ENABLED:
torch.backends.cudnn.benchmark = False
if PROFILING_ENABLED:
logging.debug(f"Final cleanup took {time.time() - finally_start:.3f} s")
def fast_vae_decode(vae, samples):
"""
Fast VAE decoding with FP16, channels_last, universal VRAM management, and full logging.
"""
device = get_torch_device()
vae_dtype_val = vae_dtype(device=device)
is_gpu = device.type == 'cuda' and torch.cuda.is_available()
if DEBUG_ENABLED:
logging.debug(f"VAE dtype: {vae_dtype_val}")
logging.debug(f"Pre-VAE checkpoint: {time.time()}")
try:
# Disable cuDNN benchmark for VAE stability if enabled
if is_gpu and comfy.model_management.is_device_cuda(device) and CUDNN_BENCHMARK_ENABLED:
torch.backends.cudnn.benchmark = False
# Prepare VRAM for VAE
if is_gpu:
mem_total = torch.cuda.get_device_properties(device).total_memory / 1024**3
latent_size = samples["samples"].shape
model_for_memory = getattr(vae, 'first_stage_model', vae)
vae_memory_required = estimate_vae_decode_memory(model_for_memory, latent_size, vae_dtype_val) / 1024**3
vram_threshold = 1.0 if mem_total < 5.9 else 1.1
vae_memory_required *= vram_threshold
if PROFILING_ENABLED:
logging.debug(f"Estimated VAE memory: {vae_memory_required:.2f} GB")
mem_allocated = torch.cuda.memory_allocated(device) / 1024**3
free_mem = mem_total - mem_allocated
if free_mem < vae_memory_required:
#free_memory(vae_memory_required)
mem_allocated, mem_total = clear_vram(device, threshold=0.4, min_free=2.0)
if PROFILING_ENABLED:
logging.debug(f"VRAM after free_memory: {mem_allocated:.2f} GB / {mem_total:.2f} GB")
# Preload VAE to device
preload_model(vae, device, is_vae=True)
# Transfer latents with channels_last
with profile_section("VAE latent transfer"):
non_blocking = is_gpu and device_supports_non_blocking(device)
latent_samples = samples["samples"].to(device, dtype=vae_dtype_val, non_blocking=non_blocking)
if is_gpu and force_channels_last():
latent_samples = latent_samples.to(memory_format=torch.channels_last)
vae.first_stage_model.to(memory_format=torch.channels_last)
if PROFILING_ENABLED:
logging.debug(f"Latent samples device: {latent_samples.device}, dtype: {latent_samples.dtype}")
# Decode latents
with torch.no_grad():
use_amp = is_gpu and is_fp16_safe(device)
with autocast(device_type='cuda', enabled=use_amp, dtype=torch.float16 if use_amp else torch.float32):
if PROFILING_ENABLED:
logging.debug(f"Decoding VAE, use_amp={use_amp}")
decode_start = time.time()
images = vae.decode(latent_samples).clamp(0, 1)
if PROFILING_ENABLED:
logging.debug(f"VAE decode took {time.time() - decode_start:.3f} s")
images = finalize_images(images, device)
return (images,)
except Exception as e:
if PROFILING_ENABLED:
logging.error(f"VAE decode failed: {e}\n{traceback.format_exc()}")
raise
finally:
if PROFILING_ENABLED:
finally_start = time.time()
if PROFILING_ENABLED:
logging.debug(f"Final cleanup took {time.time() - finally_start:.3f} s")
def fast_vae_tiled_decode(vae, samples, tile_size=512, overlap=64, temporal_size=64, temporal_overlap=8):
"""Fast VAE decoding with tiling for low VRAM, consistent with fast_vae_decode."""
device, dtype, is_gpu = initialize_device_and_dtype(vae)
vae_dtype = vae_dtype(device=device)
if DEBUG_ENABLED:
logging.debug(f"VAE dtype: {vae_dtype}")
logging.debug(f"Pre-VAE checkpoint: {time.time()}")
try:
# Disable cuDNN benchmark for tiled decoding stability if enabled
if is_gpu and comfy.model_management.is_device_cuda(device) and CUDNN_BENCHMARK_ENABLED:
torch.backends.cudnn.benchmark = False # Ensure stability for variable tile sizes
# Clear VRAM before VAE
if is_gpu:
mem_total = torch.cuda.get_device_properties(device).total_memory / 1024**3
mem_allocated = torch.cuda.memory_allocated(device) / 1024**3
free_mem = mem_total - mem_allocated
# Estimate memory for tiled decoding (conservative, ~50% of full decode)
vae_memory_required = (vae.memory_used_decode(samples["samples"].shape, vae_dtype) / 1024**3 * 0.5
if hasattr(vae, 'memory_used_decode') else 0.75)
if PROFILING_ENABLED:
logging.debug(f"VRAM before tiled VAE: {mem_allocated:.2f} GB / {mem_total:.2f} GB")
logging.debug(f"Estimated tiled VAE memory: {vae_memory_required:.2f} GB")
# Skip VRAM cleanup if VAE is already loaded and memory is sufficient
if (hasattr(vae, '_loaded_to_device') and vae._loaded_to_device == device and
free_mem >= vae_memory_required * 1.1):
if PROFILING_ENABLED:
logging.debug(f"VAE already loaded, sufficient memory: {free_mem:.2f} GB")
elif mem_allocated > 0.4 * mem_total or free_mem < vae_memory_required:
if PROFILING_ENABLED:
logging.debug(f"Clearing VRAM: {mem_allocated:.2f} GB used of {mem_total:.2f} GB")
mem_allocated, mem_total = clear_vram(device, threshold=0.4, min_free=0.75)
# Preload VAE
if not PROFILING_ENABLED:
preload_model(vae, device, is_vae=True)
else:
preload_start = time.time()
preload_model(vae, device, is_vae=True)
logging.debug(f"VAE preload took {time.time() - preload_start:.3f} s")
logging.debug(f"Post-preload checkpoint: {time.time()}")
# Transfer latents
with profile_section("VAE latent transfer"):
latent_samples = samples["samples"]
if PROFILING_ENABLED:
logging.debug(f"Latent samples device: {latent_samples.device}, dtype: {latent_samples.dtype}")
latent_samples = optimized_transfer(latent_samples, device, vae_dtype)
if is_gpu and force_channels_last():
latent_samples = latent_samples.to(memory_format=torch.channels_last)
vae.first_stage_model.to(memory_format=torch.channels_last)
# Log before decoding
if PROFILING_ENABLED:
logging.debug(f"Starting tiled VAE decoding")
logging.debug(f"Pre-decode checkpoint: {time.time()}")
with torch.no_grad():
use_amp = is_gpu and is_fp16_safe(device)
with autocast(device_type='cuda', enabled=use_amp, dtype=torch.float16 if use_amp else torch.float32):
if PROFILING_ENABLED:
logging.debug(f"Tiled VAE decoding with tile_size={tile_size}, overlap={overlap}, "
f"temporal_size={temporal_size}, temporal_overlap={temporal_overlap}, use_amp={use_amp}, dtype={'torch.float16' if use_amp else 'torch.float32'}")
# Adjust tile parameters
if tile_size < overlap * 4:
overlap = tile_size // 4
if temporal_size < temporal_overlap * 2:
temporal_overlap = temporal_overlap // 2
temporal_compression = getattr(vae, 'temporal_compression_decode', lambda: None)()
spacial_compression = getattr(vae, 'spacial_compression_decode', lambda: 8)()
if temporal_compression is not None:
temporal_size = max(2, temporal_size // temporal_compression)
temporal_overlap = max(1, min(temporal_size // 2, temporal_overlap // temporal_compression))
else:
temporal_size = None
temporal_overlap = None
# Perform tiled decoding
decode_start = time.time()
images = vae.decode_tiled(
latent_samples,
tile_x=tile_size // spacial_compression,
tile_y=tile_size // spacial_compression,
overlap=overlap // spacial_compression,
tile_t=temporal_size,
overlap_t=temporal_overlap
)
if PROFILING_ENABLED:
logging.debug(f"VAE tiled decode took {time.time() - decode_start:.3f} s")
images = finalize_images(images, device)
if is_gpu and PROFILING_ENABLED:
mem_allocated = torch.cuda.memory_allocated(device) / 1024**3
mem_total = torch.cuda.get_device_properties(device).total_memory / 1024**3
logging.debug(f"VRAM after tiled decoding: {mem_allocated:.2f} GB / {mem_total:.2f} GB")
logging.debug(f"Post-decode checkpoint: {time.time()}")
if PROFILING_ENABLED:
logging.debug(f"VAE tiled decode finished, returning images: {time.time()}")
return (images,)
except Exception as e:
logging.error(f"VAE tiled decode failed: {e}\n{traceback.format_exc()}")
raise
finally:
if PROFILING_ENABLED:
finally_start = time.time()
if PROFILING_ENABLED:
logging.debug(f"Final cleanup took {time.time() - finally_start:.3f} s")
logging.debug(f"Post-final cleanup checkpoint: {time.time()}")

View File

@ -9,6 +9,8 @@ from collections.abc import Collection
from comfy.cli_args import args from comfy.cli_args import args
DEBUG_ENABLED = args.debug
supported_pt_extensions: set[str] = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft'} supported_pt_extensions: set[str] = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft'}
folder_names_and_paths: dict[str, tuple[list[str], set[str]]] = {} folder_names_and_paths: dict[str, tuple[list[str], set[str]]] = {}
@ -245,7 +247,8 @@ def recursive_search(directory: str, excluded_dir_names: list[str] | None=None)
except FileNotFoundError: except FileNotFoundError:
logging.warning(f"Warning: Unable to access {directory}. Skipping this path.") logging.warning(f"Warning: Unable to access {directory}. Skipping this path.")
logging.debug("recursive file list on directory {}".format(directory)) if DEBUG_ENABLED:
logging.debug("recursive file list on directory {}".format(directory))
dirpath: str dirpath: str
subdirs: list[str] subdirs: list[str]
filenames: list[str] filenames: list[str]
@ -267,7 +270,8 @@ def recursive_search(directory: str, excluded_dir_names: list[str] | None=None)
except FileNotFoundError: except FileNotFoundError:
logging.warning(f"Warning: Unable to access {path}. Skipping this path.") logging.warning(f"Warning: Unable to access {path}. Skipping this path.")
continue continue
logging.debug("found {} files".format(len(result))) if DEBUG_ENABLED:
logging.debug("found {} files".format(len(result)))
return result, dirs return result, dirs
def filter_files_extensions(files: Collection[str], extensions: Collection[str]) -> list[str]: def filter_files_extensions(files: Collection[str], extensions: Collection[str]) -> list[str]:

View File

@ -8,9 +8,13 @@ import time
from comfy.cli_args import args from comfy.cli_args import args
from app.logger import setup_logger from app.logger import setup_logger
import itertools import itertools
import comfy.model_management
import utils.extra_config import utils.extra_config
import logging import logging
import sys import sys
import atexit
atexit.register(comfy.model_management.soft_empty_cache, clear=True)
if __name__ == "__main__": if __name__ == "__main__":
#NOTE: These do not do anything on core ComfyUI, they are for custom nodes. #NOTE: These do not do anything on core ComfyUI, they are for custom nodes.

188
nodes.py
View File

@ -27,6 +27,10 @@ import comfy.utils
import comfy.controlnet import comfy.controlnet
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict, FileLocator from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict, FileLocator
from fast_sampler import fast_vae_decode
from fast_sampler import fast_ksampler
from fast_sampler import fast_vae_tiled_decode
import comfy.clip_vision import comfy.clip_vision
import comfy.model_management import comfy.model_management
@ -282,49 +286,34 @@ class VAEDecode:
RETURN_TYPES = ("IMAGE",) RETURN_TYPES = ("IMAGE",)
OUTPUT_TOOLTIPS = ("The decoded image.",) OUTPUT_TOOLTIPS = ("The decoded image.",)
FUNCTION = "decode" FUNCTION = "decode"
CATEGORY = "latent" CATEGORY = "latent"
DESCRIPTION = "Decodes latent images back into pixel space images." DESCRIPTION = "Decodes latent images back into pixel space images."
def decode(self, vae, samples): def decode(self, vae, samples):
images = vae.decode(samples["samples"]) return fast_vae_decode(vae, samples)
if len(images.shape) == 5: #Combine batches
images = images.reshape(-1, images.shape[-3], images.shape[-2], images.shape[-1])
return (images, )
class VAEDecodeTiled: class VAEDecodeTiled:
@classmethod @classmethod
def INPUT_TYPES(s): def INPUT_TYPES(s):
return {"required": {"samples": ("LATENT", ), "vae": ("VAE", ), return {
"tile_size": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 32}), "required": {
"overlap": ("INT", {"default": 64, "min": 0, "max": 4096, "step": 32}), "samples": ("LATENT", {"tooltip": "The latent to be decoded."}),
"temporal_size": ("INT", {"default": 64, "min": 8, "max": 4096, "step": 4, "tooltip": "Only used for video VAEs: Amount of frames to decode at a time."}), "vae": ("VAE", {"tooltip": "The VAE model used for decoding the latent."}),
"temporal_overlap": ("INT", {"default": 8, "min": 4, "max": 4096, "step": 4, "tooltip": "Only used for video VAEs: Amount of frames to overlap."}), "tile_size": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 32, "tooltip": "Tile size for tiled decoding."}),
}} "overlap": ("INT", {"default": 64, "min": 0, "max": 4096, "step": 32, "tooltip": "Tile overlap for tiled decoding."}),
"temporal_size": ("INT", {"default": 64, "min": 8, "max": 4096, "step": 4, "tooltip": "Only used for video VAEs: Amount of frames to decode at a time."}),
"temporal_overlap": ("INT", {"default": 8, "min": 4, "max": 4096, "step": 4, "tooltip": "Only used for video VAEs: Amount of frames to overlap."}),
}
}
RETURN_TYPES = ("IMAGE",) RETURN_TYPES = ("IMAGE",)
OUTPUT_TOOLTIPS = ("The decoded image.",)
FUNCTION = "decode" FUNCTION = "decode"
CATEGORY = "_for_testing" CATEGORY = "_for_testing"
DESCRIPTION = "Decodes latent images back into pixel space images using tiled decoding for VRAM efficiency."
def decode(self, vae, samples, tile_size, overlap=64, temporal_size=64, temporal_overlap=8): def decode(self, vae, samples, tile_size=512, overlap=64, temporal_size=64, temporal_overlap=8):
if tile_size < overlap * 4: return fast_vae_tiled_decode(vae, samples, tile_size=tile_size, overlap=overlap,
overlap = tile_size // 4 temporal_size=temporal_size, temporal_overlap=temporal_overlap)
if temporal_size < temporal_overlap * 2:
temporal_overlap = temporal_overlap // 2
temporal_compression = vae.temporal_compression_decode()
if temporal_compression is not None:
temporal_size = max(2, temporal_size // temporal_compression)
temporal_overlap = max(1, min(temporal_size // 2, temporal_overlap // temporal_compression))
else:
temporal_size = None
temporal_overlap = None
compression = vae.spacial_compression_decode()
images = vae.decode_tiled(samples["samples"], tile_x=tile_size // compression, tile_y=tile_size // compression, overlap=overlap // compression, tile_t=temporal_size, overlap_t=temporal_overlap)
if len(images.shape) == 5: #Combine batches
images = images.reshape(-1, images.shape[-3], images.shape[-2], images.shape[-1])
return (images, )
class VAEEncode: class VAEEncode:
@classmethod @classmethod
def INPUT_TYPES(s): def INPUT_TYPES(s):
@ -1473,28 +1462,26 @@ class SetLatentNoiseMask:
s["noise_mask"] = mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1])) s["noise_mask"] = mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1]))
return (s,) return (s,)
def common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent, denoise=1.0, disable_noise=False, start_step=None, last_step=None, force_full_denoise=False): def common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent,
denoise=1.0, disable_noise=False, start_step=None, last_step=None,
force_full_denoise=False):
# Get device and dtype
device = comfy.model_management.get_torch_device()
dtype = getattr(model.model, 'dtype', torch.float32)
is_gpu = device.type == 'cuda' and torch.cuda.is_available()
# Prepare latent image
latent_image = latent["samples"] latent_image = latent["samples"]
latent_image = comfy.sample.fix_empty_latent_channels(model, latent_image) latent_image = comfy.sample.fix_empty_latent_channels(model, latent_image)
if disable_noise: # Call fast_ksampler with device, dtype, and is_gpu
noise = torch.zeros(latent_image.size(), dtype=latent_image.dtype, layout=latent_image.layout, device="cpu") out = fast_ksampler(
else: model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent,
batch_inds = latent["batch_index"] if "batch_index" in latent else None denoise=denoise, disable_noise=disable_noise, start_step=start_step,
noise = comfy.sample.prepare_noise(latent_image, seed, batch_inds) last_step=last_step, force_full_denoise=force_full_denoise,
device=device, dtype=dtype, is_gpu=is_gpu
noise_mask = None )
if "noise_mask" in latent: return out
noise_mask = latent["noise_mask"]
callback = latent_preview.prepare_callback(model, steps)
disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED
samples = comfy.sample.sample(model, noise, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
denoise=denoise, disable_noise=disable_noise, start_step=start_step, last_step=last_step,
force_full_denoise=force_full_denoise, noise_mask=noise_mask, callback=callback, disable_pbar=disable_pbar, seed=seed)
out = latent.copy()
out["samples"] = samples
return (out, )
class KSampler: class KSampler:
@classmethod @classmethod
@ -1502,27 +1489,64 @@ class KSampler:
return { return {
"required": { "required": {
"model": ("MODEL", {"tooltip": "The model used for denoising the input latent."}), "model": ("MODEL", {"tooltip": "The model used for denoising the input latent."}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True, "tooltip": "The random seed used for creating the noise."}), "seed": ("INT", {
"steps": ("INT", {"default": 20, "min": 1, "max": 10000, "tooltip": "The number of steps used in the denoising process."}), "default": 0,
"cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step":0.1, "round": 0.01, "tooltip": "The Classifier-Free Guidance scale balances creativity and adherence to the prompt. Higher values result in images more closely matching the prompt however too high values will negatively impact quality."}), "min": 0,
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"tooltip": "The algorithm used when sampling, this can affect the quality, speed, and style of the generated output."}), "max": 0xffffffffffffffff,
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"tooltip": "The scheduler controls how noise is gradually removed to form the image."}), "control_after_generate": True,
"positive": ("CONDITIONING", {"tooltip": "The conditioning describing the attributes you want to include in the image."}), "tooltip": "The random seed used for creating the noise."
"negative": ("CONDITIONING", {"tooltip": "The conditioning describing the attributes you want to exclude from the image."}), }),
"steps": ("INT", {
"default": 20,
"min": 1,
"max": 10000,
"tooltip": "The number of steps used in the denoising process."
}),
"cfg": ("FLOAT", {
"default": 8.0,
"min": 0.0,
"max": 100.0,
"step": 0.1,
"round": 0.01,
"tooltip": "The Classifier-Free Guidance scale balances creativity and adherence to the prompt."
}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {
"tooltip": "The algorithm used when sampling."
}),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, {
"tooltip": "The scheduler controls how noise is gradually removed to form the image."
}),
"positive": ("CONDITIONING", {
"tooltip": "The conditioning describing the attributes to include."
}),
"negative": ("CONDITIONING", {
"tooltip": "The conditioning describing the attributes to exclude."
}),
"latent_image": ("LATENT", {"tooltip": "The latent image to denoise."}), "latent_image": ("LATENT", {"tooltip": "The latent image to denoise."}),
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "The amount of denoising applied, lower values will maintain the structure of the initial image allowing for image to image sampling."}), "denoise": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "The amount of denoising applied."
}),
} }
} }
RETURN_TYPES = ("LATENT",) RETURN_TYPES = ("LATENT",)
OUTPUT_TOOLTIPS = ("The denoised latent.",) OUTPUT_TOOLTIPS = ("The denoised latent.",)
FUNCTION = "sample" FUNCTION = "sample"
CATEGORY = "sampling" CATEGORY = "sampling"
DESCRIPTION = "Uses the provided model, positive and negative conditioning to denoise the latent image." DESCRIPTION = "Denoises the latent image using the provided model and conditioning."
def sample(self, model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=1.0): def sample(self, model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
return common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=denoise) denoise=1.0):
latent = latent_image.copy()
if "samples" in latent:
latent["samples"] = latent["samples"].to(
comfy.model_management.get_torch_device(), non_blocking=True)
return common_ksampler(
model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent, denoise=denoise)
class KSamplerAdvanced: class KSamplerAdvanced:
@classmethod @classmethod
@ -1618,14 +1642,44 @@ class PreviewImage(SaveImage):
self.output_dir = folder_paths.get_temp_directory() self.output_dir = folder_paths.get_temp_directory()
self.type = "temp" self.type = "temp"
self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5)) self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5))
self.compress_level = 1 self.compress_level = 4 # Faster for previews, SaveImage keeps 1 for fork
@classmethod @classmethod
def INPUT_TYPES(s): def INPUT_TYPES(s):
return {"required": return {"required": {"images": ("IMAGE", )},
{"images": ("IMAGE", ), }, "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}}
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
} def save_images(self, images, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None):
from PIL import Image
import numpy as np
import os
filename_prefix += self.prefix_append
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0])
results = []
for batch_number, image in enumerate(images):
i = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
# Adaptive resize to max dimension ~512, preserve aspect ratio
max_size = 512
if max(img.width, img.height) > max_size:
scale = max_size / max(img.width, img.height)
new_width = int(img.width * scale)
new_height = int(img.height * scale)
img = img.resize((new_width, new_height), Image.LANCZOS)
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.png"
img.save(os.path.join(full_output_folder, file), format="PNG", compress_level=self.compress_level, optimize=True)
results.append({
"filename": file,
"subfolder": subfolder,
"type": self.type
})
counter += 1
return {"ui": {"images": results}}
class LoadImage: class LoadImage:
@classmethod @classmethod