Spaces:
Running on Zero
Running on Zero
| from __future__ import annotations | |
| import base64 | |
| import copy | |
| from datetime import datetime, timezone | |
| import gc | |
| import hashlib | |
| import importlib | |
| import json | |
| import os | |
| from pathlib import Path | |
| import time | |
| from typing import Callable | |
| import numpy as np | |
| import torch | |
| from PIL import Image | |
| try: | |
| from .wanvideo_wrapper_bridge import get_wrapper_node_class, load_wrapper_module | |
| except ImportError: # pragma: no cover | |
| from wanvideo_wrapper_bridge import get_wrapper_node_class, load_wrapper_module | |
| def _assert_zerogpu_parent_fork_clean(event: str) -> None: | |
| if os.environ.get("SPACES_ZERO_GPU", "").strip().lower() not in {"1", "true", "yes", "on"}: | |
| return | |
| try: | |
| from spaces.zero import wrappers as zero_wrappers | |
| if zero_wrappers.forked: | |
| return | |
| except Exception: | |
| pass | |
| read_fd, write_fd = os.pipe() | |
| pid = os.fork() | |
| if pid == 0: | |
| os.close(read_fd) | |
| try: | |
| os.write(write_fd, b"1" if torch.cuda._is_in_bad_fork() else b"0") | |
| finally: | |
| os.close(write_fd) | |
| os._exit(0) | |
| os.close(write_fd) | |
| verdict = os.read(read_fd, 1) | |
| os.close(read_fd) | |
| _, status = os.waitpid(pid, 0) | |
| if status != 0 or verdict != b"0": | |
| raise RuntimeError(f"ZeroGPU parent CUDA fork poisoned during {event}") | |
| print(f'[WAN_DIAG] {{"bad_fork": false, "event": "{event}"}}', flush=True) | |
| class WrapperLoopRuntime: | |
| _AOT_SHA256 = { | |
| "high": "58480b374bcdd47b50f2ea1351342bdb436828e298067a677368dcf53d473f97", | |
| "low": "f3ea9d67ec500d28e14c0ec61f77f52b812139bf440ded5dc27c211c184734a1", | |
| } | |
| def __init__( | |
| self, | |
| *, | |
| models_root: Path, | |
| high_model_name: str, | |
| low_model_name: str, | |
| clip_name: str, | |
| int8_clip_name: str, | |
| vae_name: str, | |
| sampler_name: str, | |
| scheduler_mode: str, | |
| split_step: int, | |
| riflex_k: int, | |
| loop_shift_skip: int, | |
| loop_start_percent: float, | |
| loop_end_percent: float, | |
| start_latent_strength: float, | |
| end_latent_strength: float, | |
| end_temporal_mask_strength: float, | |
| decode_end_image_hint: bool, | |
| fun_or_fl2v_model: bool, | |
| zero_end_latent_conditioning: bool, | |
| end_latent_conditioning_strength: float, | |
| low_pass_end_conditioning_strength: float, | |
| custom_sigmas: tuple[float, ...], | |
| attention_mode: str = "sdpa", | |
| text_encoder_quantization: str = "int8_weight_only", | |
| global_resident_models: bool = False, | |
| vae_tiling: bool = False, | |
| ) -> None: | |
| self.models_root = Path(models_root) | |
| self.high_model_name = high_model_name | |
| self.low_model_name = low_model_name | |
| self.clip_name = clip_name | |
| self.int8_clip_name = int8_clip_name | |
| self.vae_name = vae_name | |
| self.sampler_name = sampler_name | |
| self.scheduler_mode = scheduler_mode | |
| self.split_step = int(split_step) | |
| self.riflex_k = int(riflex_k) | |
| self.loop_shift_skip = int(loop_shift_skip) | |
| self.loop_start_percent = float(loop_start_percent) | |
| self.loop_end_percent = float(loop_end_percent) | |
| self.start_latent_strength = float(start_latent_strength) | |
| self.end_latent_strength = float(end_latent_strength) | |
| self.end_temporal_mask_strength = float(end_temporal_mask_strength) | |
| self.decode_end_image_hint = bool(decode_end_image_hint) | |
| self.fun_or_fl2v_model = bool(fun_or_fl2v_model) | |
| self.zero_end_latent_conditioning = bool(zero_end_latent_conditioning) | |
| self.end_latent_conditioning_strength = float(end_latent_conditioning_strength) | |
| self.low_pass_end_conditioning_strength = float(low_pass_end_conditioning_strength) | |
| self.custom_sigmas = tuple(float(x) for x in custom_sigmas) | |
| self.attention_mode = attention_mode | |
| self.text_encoder_quantization = text_encoder_quantization | |
| self.global_resident_models = bool(global_resident_models) | |
| self.vae_tiling = bool(vae_tiling) | |
| self._configure_model_paths() | |
| _assert_zerogpu_parent_fork_clean("parent.models_localized_clean") | |
| load_wrapper_module() | |
| _assert_zerogpu_parent_fork_clean("parent.wrapper_import_clean") | |
| self._comfy_nodes = importlib.import_module("nodes") | |
| self._model_loader = get_wrapper_node_class("WanVideoModelLoader")() | |
| self._vae_loader = get_wrapper_node_class("WanVideoVAELoader")() | |
| self._t5_loader = get_wrapper_node_class("LoadWanVideoT5TextEncoder")() | |
| self._text_encode = get_wrapper_node_class("WanVideoTextEncode")() | |
| self._image_encode = get_wrapper_node_class("WanVideoImageToVideoEncode")() | |
| self._sampler = get_wrapper_node_class("WanVideoSampler")() | |
| self._loop_args = get_wrapper_node_class("WanVideoLoopArgs")() | |
| self._decode = get_wrapper_node_class("WanVideoDecode")() | |
| # Diffusion weights are loaded just in time. High and low must never | |
| # coexist on the ZeroGPU device. | |
| self.high_model = None | |
| self.low_model = None | |
| self.vae = None | |
| self.t5 = None | |
| self.clip = None | |
| self._text_mode = "" | |
| self._text_encoder_released = False | |
| self._text_encoder_on_cuda = False | |
| self._aot_package_cache: dict[str, str] = {} | |
| self._aot_prepared_roles: set[str] = set() | |
| self._load_text_encoder() | |
| _assert_zerogpu_parent_fork_clean("parent.text_encoder_clean") | |
| if self.global_resident_models: | |
| self._ensure_text_encoder_cuda() | |
| self._preload_global_models() | |
| self._diag("runtime.ready") | |
| def _aot_package_dir(self, role: str) -> str: | |
| cached = self._aot_package_cache.get(role) | |
| if cached is not None: | |
| return cached | |
| if os.environ.get("WAN_DISABLE_AOT", "0") == "1": | |
| self._diag("aot.disabled", role=role, reason="environment") | |
| self._aot_package_cache[role] = "" | |
| return "" | |
| package_dir = Path(__file__).resolve().parent.parent / "aot_artifacts" / role / "package" | |
| package_file = package_dir / "root" / "package.pt2" | |
| if not package_file.is_file(): | |
| self._diag("aot.disabled", role=role, reason="package_missing") | |
| self._aot_package_cache[role] = "" | |
| return "" | |
| torch_base = torch.__version__.split("+", 1)[0] | |
| zerogpu_parent = False | |
| if os.environ.get("SPACES_ZERO_GPU", "").strip().lower() in {"1", "true", "yes", "on"}: | |
| try: | |
| from spaces.zero import wrappers as zero_wrappers | |
| zerogpu_parent = not zero_wrappers.forked | |
| except Exception: | |
| zerogpu_parent = True | |
| # The parent CUDA firewall must report is_available=False to keep the | |
| # later worker fork clean. ZeroGPU still exposes an sm_120 virtual | |
| # Blackwell device to global tensors, so select that pinned production | |
| # target without touching a real CUDA discovery API in the parent. | |
| if zerogpu_parent: | |
| capability = (12, 0) | |
| else: | |
| capability = torch.cuda.get_device_capability() if torch.cuda.is_available() else None | |
| if torch_base != "2.11.0" or capability != (12, 0): | |
| self._diag( | |
| "aot.disabled", | |
| role=role, | |
| reason="incompatible_runtime", | |
| torch_base=torch_base, | |
| capability=list(capability) if capability else None, | |
| ) | |
| self._aot_package_cache[role] = "" | |
| return "" | |
| digest = hashlib.sha256(package_file.read_bytes()).hexdigest() | |
| if digest != self._AOT_SHA256[role]: | |
| raise RuntimeError(f"AOT package integrity check failed for role {role}") | |
| self._diag("aot.ready", role=role) | |
| self._aot_package_cache[role] = str(package_dir) | |
| return self._aot_package_cache[role] | |
| def _gpu_memory() -> dict[str, float | int | str]: | |
| # ZeroGPU forks its GPU worker. A real CUDA call in the parent poisons | |
| # that fork with "Cannot re-initialize CUDA". Identity APIs are | |
| # emulated, but mem_get_info/allocator APIs are not, so defer all of | |
| # them until spaces marks the child worker as forked. | |
| if os.environ.get("SPACES_ZERO_GPU", "").strip().lower() in {"1", "true", "yes", "on"}: | |
| try: | |
| from spaces.zero import wrappers as zero_wrappers | |
| if not zero_wrappers.forked: | |
| return {"device": "zerogpu_parent", "cuda_initialized": False} | |
| except Exception: | |
| return {"device": "zerogpu_parent", "cuda_initialized": False} | |
| try: | |
| if not torch.cuda.is_available(): | |
| return {"cuda": "unavailable"} | |
| free, total = torch.cuda.mem_get_info() | |
| gib = 1024**3 | |
| return { | |
| "device": str(torch.cuda.get_device_name()), | |
| "allocated_gib": round(torch.cuda.memory_allocated() / gib, 3), | |
| "reserved_gib": round(torch.cuda.memory_reserved() / gib, 3), | |
| "peak_allocated_gib": round(torch.cuda.max_memory_allocated() / gib, 3), | |
| "free_gib": round(free / gib, 3), | |
| "total_gib": round(total / gib, 3), | |
| } | |
| except BaseException as exc: | |
| return {"cuda": f"stats_error:{type(exc).__name__}"} | |
| def _diag(self, event: str, **fields) -> None: | |
| payload = { | |
| "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"), | |
| "event": event, | |
| **self._gpu_memory(), | |
| **fields, | |
| } | |
| print(f"[WAN_DIAG] {json.dumps(payload, sort_keys=True)}", flush=True) | |
| def _configure_model_paths(self) -> None: | |
| import sys | |
| comfy_runtime_root = (Path(__file__).resolve().parent / "third_party" / "comfy_runtime").resolve() | |
| if str(comfy_runtime_root) not in sys.path: | |
| sys.path.insert(0, str(comfy_runtime_root)) | |
| import folder_paths | |
| for key, subdir in ( | |
| ("diffusion_models", "diffusion_models"), | |
| ("vae", "vae"), | |
| ("text_encoders", "text_encoders"), | |
| ): | |
| folder_paths.add_model_folder_path(key, str(self.models_root / subdir)) | |
| def _load_model(self, model_name: str, role: str): | |
| started = time.perf_counter() | |
| self._diag("model.load.start", role=role) | |
| quantization = "fp8_e4m3fn_scaled_fast" if role == "high" else "fp8_e4m3fn_fast" | |
| model = self._model_loader.loadmodel( | |
| model=model_name, | |
| base_precision="fp16_fast", | |
| load_device="main_device", | |
| quantization=quantization, | |
| attention_mode=self.attention_mode, | |
| block_swap_args=None, | |
| rms_norm_function="default", | |
| keep_loaded_models=self.global_resident_models, | |
| # Read into transient host staging, then let the wrapper assign the | |
| # model to main_device and delete the state dict before sampling. | |
| # This avoids holding both checkpoint and parameters on the 40 GB | |
| # GPU; it is loading staging, never runtime CPU offload. | |
| checkpoint_load_device="cpu", | |
| )[0] | |
| self._diag( | |
| "model.load.done", | |
| role=role, | |
| elapsed_s=round(time.perf_counter() - started, 3), | |
| ) | |
| return model | |
| def _ensure_diffusion_model(self, role: str): | |
| attribute = "high_model" if role == "high" else "low_model" | |
| model = getattr(self, attribute) | |
| if model is None: | |
| name = self.high_model_name if role == "high" else self.low_model_name | |
| model = self._load_model(name, role) | |
| setattr(self, attribute, model) | |
| else: | |
| self._diag("model.reused", role=role) | |
| return model | |
| def _materialize_diffusion_model(self, role: str) -> None: | |
| """Assign external checkpoint weights and bind AOT before any request.""" | |
| from ComfyUI_WanVideoWrapper.nodes_model_loading import load_weights_assign | |
| patcher = self._ensure_diffusion_model(role) | |
| wrapped = patcher.model | |
| state_dict = wrapped["sd"] | |
| if state_dict is not None: | |
| self._diag("model.materialize.start", role=role, device="cpu") | |
| assigned_parameters = load_weights_assign( | |
| wrapped.diffusion_model, | |
| state_dict, | |
| wrapped["weight_dtype"], | |
| wrapped["base_dtype"], | |
| ) | |
| wrapped["sd"] = None | |
| del state_dict | |
| gc.collect() | |
| self._diag( | |
| "model.materialize.done", | |
| role=role, | |
| device="cpu", | |
| assigned_parameters=assigned_parameters, | |
| ) | |
| transfer_started = time.perf_counter() | |
| self._diag("model.device_transfer.start", role=role, device="cuda") | |
| wrapped.diffusion_model.to(torch.device("cuda")) | |
| self._diag( | |
| "model.device_transfer.done", | |
| role=role, | |
| device="cuda", | |
| elapsed_s=round(time.perf_counter() - transfer_started, 3), | |
| ) | |
| package_dir = self._aot_package_dir(role) | |
| if package_dir and role not in self._aot_prepared_roles: | |
| import spaces | |
| self._diag("aot.bind.start", role=role) | |
| spaces.aoti_load_from_package_dir(wrapped.diffusion_model.blocks, package_dir) | |
| self._aot_prepared_roles.add(role) | |
| self._diag("aot.bind.done", role=role) | |
| def _sampling_aot_package(self, role: str) -> str: | |
| if role in self._aot_prepared_roles: | |
| return "" | |
| return self._aot_package_dir(role) | |
| def _load_vae(self) -> None: | |
| if self.vae is None: | |
| started = time.perf_counter() | |
| self._diag("vae.load.start") | |
| self.vae = self._vae_loader.loadmodel( | |
| self.vae_name, | |
| "bf16", | |
| load_device="main_device", | |
| )[0] | |
| self._diag("vae.load.done", elapsed_s=round(time.perf_counter() - started, 3)) | |
| def _preload_global_models(self) -> None: | |
| started = time.perf_counter() | |
| self._diag("global_preload.start", vae_tiling=self.vae_tiling) | |
| self._load_vae() | |
| self._materialize_diffusion_model("high") | |
| self._materialize_diffusion_model("low") | |
| self._diag( | |
| "global_preload.done", | |
| elapsed_s=round(time.perf_counter() - started, 3), | |
| vae_tiling=self.vae_tiling, | |
| ) | |
| def resolve_workflow_size(self, image: Image.Image, target_width: int, target_height: int, divisible_by: int = 16) -> tuple[int, int]: | |
| src_w, src_h = image.size | |
| if src_w <= 0 or src_h <= 0: | |
| return int(target_width), int(target_height) | |
| ratio = min(float(target_width) / float(src_w), float(target_height) / float(src_h)) | |
| width = max(int(round(src_w * ratio)), 1) | |
| height = max(int(round(src_h * ratio)), 1) | |
| if divisible_by > 1: | |
| width = max(width - (width % divisible_by), divisible_by) | |
| height = max(height - (height % divisible_by), divisible_by) | |
| return int(width), int(height) | |
| def _pil_to_comfy_image(self, image: Image.Image, width: int, height: int) -> torch.Tensor: | |
| resized = image.convert("RGB").resize((int(width), int(height)), Image.LANCZOS) | |
| array = np.asarray(resized, dtype=np.float32) / 255.0 | |
| return torch.from_numpy(array).unsqueeze(0) | |
| def _load_text_encoder(self) -> None: | |
| if self.t5 is not None or self.clip is not None: | |
| return | |
| started = time.perf_counter() | |
| self._diag("text_encoder.load.start") | |
| self.t5 = None | |
| self.clip = None | |
| if self.text_encoder_quantization == "int8_weight_only": | |
| # This is the production path. Fail fast rather than silently | |
| # recreating the multi-minute BF16 encoder path on the first job. | |
| self.t5 = self._load_quanto_text_encoder_int8() | |
| self._text_mode = "wrapper_t5" | |
| else: | |
| try: | |
| self.t5 = self._t5_loader.loadmodel( | |
| self.clip_name, | |
| "bf16", | |
| load_device="main_device", | |
| quantization="disabled", | |
| )[0] | |
| self._text_mode = "wrapper_t5" | |
| except Exception as exc: | |
| print(f"[INFO] Loop wrapper T5 non disponibile per {self.clip_name}: {exc}") | |
| self.clip = self._comfy_nodes.CLIPLoader().load_clip(self.clip_name, "wan", "default")[0] | |
| self._text_mode = "comfy_clip" | |
| self._text_encoder_on_cuda = True | |
| self._text_encoder_released = False | |
| self._diag( | |
| "text_encoder.load.done", | |
| mode=self._text_mode, | |
| quantization=self.text_encoder_quantization, | |
| elapsed_s=round(time.perf_counter() - started, 3), | |
| ) | |
| def _load_quanto_text_encoder_int8(self) -> dict: | |
| """Materialize the wrapper-native UMT5 directly from a QINT8 artifact.""" | |
| from optimum.quanto.nn import QLinear | |
| from optimum.quanto.quantize import _quantize_submodule | |
| from safetensors import safe_open | |
| from safetensors.torch import load_file | |
| from ComfyUI_WanVideoWrapper.wanvideo.modules.t5 import T5EncoderModel | |
| import folder_paths | |
| _assert_zerogpu_parent_fork_clean("parent.quanto_import_clean") | |
| model_path = folder_paths.get_full_path_or_raise("text_encoders", self.int8_clip_name) | |
| tokenizer_path = ( | |
| Path(__file__).resolve().parent | |
| / "third_party" | |
| / "ComfyUI-WanVideoWrapper" | |
| / "configs" | |
| / "T5_tokenizer" | |
| ) | |
| with safe_open(model_path, framework="pt", device="cpu") as checkpoint: | |
| metadata = checkpoint.metadata() or {} | |
| if metadata.get("quantization_format") != "quanto": | |
| raise RuntimeError("The mounted INT8 text encoder is not a Quanto checkpoint.") | |
| encoded_map = metadata.get("quantization_map_base64", "") | |
| try: | |
| quantization_map = json.loads(base64.b64decode(encoded_map).decode("utf-8")) | |
| except Exception as exc: | |
| raise RuntimeError("Invalid Quanto quantization map in the INT8 text encoder.") from exc | |
| if len(quantization_map) != 192: | |
| raise RuntimeError( | |
| f"Unexpected UMT5 quantization map: {len(quantization_map)} modules, expected 192." | |
| ) | |
| encoder = T5EncoderModel( | |
| text_len=512, | |
| dtype=torch.bfloat16, | |
| device=torch.device("cpu"), | |
| state_dict=None, | |
| tokenizer_path=str(tokenizer_path), | |
| quantization="disabled", | |
| ) | |
| _assert_zerogpu_parent_fork_clean("parent.t5_meta_model_clean") | |
| started = time.perf_counter() | |
| self._diag( | |
| "text_encoder.int8.load.start", | |
| implementation="wrapper_t5_quanto", | |
| quantized_modules=len(quantization_map), | |
| ) | |
| wrap_started = time.perf_counter() | |
| self._diag("text_encoder.int8.wrap.start") | |
| for name, module in list(encoder.model.named_modules()): | |
| qconfig = quantization_map.get(name) | |
| if qconfig is None: | |
| continue | |
| weights = None if qconfig["weights"] == "none" else qconfig["weights"] | |
| activations = None if qconfig["activations"] == "none" else qconfig["activations"] | |
| _quantize_submodule( | |
| encoder.model, | |
| name, | |
| module, | |
| weights=weights, | |
| activations=activations, | |
| ) | |
| self._diag( | |
| "text_encoder.int8.wrap.done", | |
| elapsed_s=round(time.perf_counter() - wrap_started, 3), | |
| ) | |
| _assert_zerogpu_parent_fork_clean("parent.quanto_wrap_clean") | |
| # Quanto's public requantize helper materializes the complete BF16 UMT5 | |
| # before applying QINT8. Instead, rebuild each quantized module directly | |
| # from its serialized tensors on CPU. Peak host memory stays close to the | |
| # final 8.2 GiB model. The parent keeps this prepared CPU representation; | |
| # the forked ZeroGPU worker performs the one-time CUDA transfer. | |
| materialize_started = time.perf_counter() | |
| self._diag("text_encoder.int8.materialize.start", device="cpu") | |
| state_load_started = time.perf_counter() | |
| state_dict = load_file(model_path, device="cpu") | |
| _assert_zerogpu_parent_fork_clean("parent.t5_state_load_clean") | |
| self._diag( | |
| "text_encoder.int8.state_load.done", | |
| device="cpu", | |
| tensors=len(state_dict), | |
| elapsed_s=round(time.perf_counter() - state_load_started, 3), | |
| ) | |
| handled_keys: set[str] = set() | |
| quantized_linears = 0 | |
| dequantized_embeddings = 0 | |
| checkpoint_keys = tuple(state_dict) | |
| for module_index, module_name in enumerate(quantization_map, start=1): | |
| module = encoder.model.get_submodule(module_name) | |
| prefix = f"{module_name}." | |
| module_keys = [key for key in checkpoint_keys if key.startswith(prefix)] | |
| if isinstance(module, QLinear): | |
| for name, parameter in list(module.named_parameters(recurse=False)): | |
| if parameter is not None and parameter.is_meta: | |
| setattr( | |
| module, | |
| name, | |
| torch.nn.Parameter( | |
| torch.empty_like(parameter, device="cpu"), | |
| requires_grad=parameter.requires_grad, | |
| ), | |
| ) | |
| for name, buffer in list(module.named_buffers(recurse=False)): | |
| if buffer is not None and buffer.is_meta: | |
| setattr(module, name, torch.empty_like(buffer, device="cpu")) | |
| module.load_state_dict( | |
| {key[len(prefix):]: state_dict.pop(key) for key in module_keys}, | |
| strict=True, | |
| ) | |
| quantized_linears += 1 | |
| elif isinstance(module, torch.nn.Embedding): | |
| data = state_dict.pop(f"{prefix}weight._data") | |
| scale = state_dict.pop(f"{prefix}weight._scale") | |
| module.weight = torch.nn.Parameter( | |
| (data * scale).to(torch.bfloat16), | |
| requires_grad=False, | |
| ) | |
| for key in module_keys: | |
| state_dict.pop(key, None) | |
| dequantized_embeddings += 1 | |
| else: | |
| raise RuntimeError( | |
| f"Unsupported Quanto module in UMT5 checkpoint: {type(module).__name__}" | |
| ) | |
| handled_keys.update(module_keys) | |
| if module_index % 24 == 0 or module_index == len(quantization_map): | |
| self._diag( | |
| "text_encoder.int8.modules.progress", | |
| completed=module_index, | |
| total=len(quantization_map), | |
| ) | |
| for key, value in list(state_dict.items()): | |
| parent_name, parameter_name = key.rsplit(".", 1) | |
| parent = encoder.model.get_submodule(parent_name) | |
| current = getattr(parent, parameter_name) | |
| if not isinstance(current, torch.nn.Parameter): | |
| raise RuntimeError(f"Unexpected non-parameter UMT5 tensor: {key}") | |
| setattr( | |
| parent, | |
| parameter_name, | |
| torch.nn.Parameter(value, requires_grad=current.requires_grad), | |
| ) | |
| del state_dict[key] | |
| del state_dict | |
| self._diag("text_encoder.int8.cpu_model.done") | |
| _assert_zerogpu_parent_fork_clean("parent.t5_modules_clean") | |
| meta_parameters = [name for name, parameter in encoder.model.named_parameters() if parameter.is_meta] | |
| if meta_parameters: | |
| raise RuntimeError(f"UMT5 contains unmaterialized parameters: {meta_parameters[:5]}") | |
| self._diag( | |
| "text_encoder.int8.materialize.done", | |
| device="cpu", | |
| quantized_linears=quantized_linears, | |
| dequantized_embeddings=dequantized_embeddings, | |
| elapsed_s=round(time.perf_counter() - materialize_started, 3), | |
| ) | |
| encoder.quantization = "quanto_int8" | |
| encoder.weights_prepared = True | |
| gc.collect() | |
| self._diag( | |
| "text_encoder.int8.load.done", | |
| implementation="wrapper_t5_quanto", | |
| device="cpu", | |
| elapsed_s=round(time.perf_counter() - started, 3), | |
| ) | |
| return { | |
| "model": encoder, | |
| "dtype": torch.bfloat16, | |
| "name": self.int8_clip_name, | |
| } | |
| def _ensure_text_encoder_cuda(self) -> None: | |
| if self.text_encoder_quantization != "int8_weight_only" or self._text_encoder_on_cuda: | |
| return | |
| from optimum.quanto.nn import QLinear | |
| from optimum.quanto.tensor.weights.qbytes import WeightQBytesTensor | |
| encoder = self.t5["model"] | |
| transfer_started = time.perf_counter() | |
| self._diag("text_encoder.int8.device_transfer.start", device="cuda") | |
| device_to = torch.device("cuda") | |
| for module in encoder.model.modules(): | |
| if isinstance(module, QLinear): | |
| weight = module.weight | |
| module.weight = torch.nn.Parameter( | |
| WeightQBytesTensor( | |
| weight.qtype, | |
| weight.axis, | |
| weight.size(), | |
| weight.stride(), | |
| weight._data.to(device_to), | |
| weight._scale.to(device_to), | |
| weight.activation_qtype, | |
| requires_grad=False, | |
| ), | |
| requires_grad=False, | |
| ) | |
| if module.bias is not None: | |
| module.bias = torch.nn.Parameter( | |
| module.bias.to(device_to), | |
| requires_grad=module.bias.requires_grad, | |
| ) | |
| for name, buffer in list(module.named_buffers(recurse=False)): | |
| if buffer is not None: | |
| setattr(module, name, buffer.to(device_to)) | |
| continue | |
| for name, parameter in list(module.named_parameters(recurse=False)): | |
| if parameter is not None: | |
| setattr( | |
| module, | |
| name, | |
| torch.nn.Parameter( | |
| parameter.to(device_to), | |
| requires_grad=parameter.requires_grad, | |
| ), | |
| ) | |
| for name, buffer in list(module.named_buffers(recurse=False)): | |
| if buffer is not None: | |
| setattr(module, name, buffer.to(device_to)) | |
| self._diag( | |
| "text_encoder.int8.device_transfer.done", | |
| device="cuda", | |
| elapsed_s=round(time.perf_counter() - transfer_started, 3), | |
| ) | |
| encoder.device = device_to | |
| self._text_encoder_on_cuda = True | |
| gc.collect() | |
| def _build_text_embeds_from_encoder(self, prompt: str, negative_prompt: str): | |
| if self._text_mode == "wrapper_t5": | |
| return self._text_encode.process( | |
| positive_prompt=prompt, | |
| negative_prompt=negative_prompt, | |
| t5=self.t5, | |
| force_offload=False, | |
| model_to_offload=None, | |
| use_disk_cache=False, | |
| device="gpu", | |
| )[0] | |
| positive = self._encode_comfy_wan_prompt(prompt) | |
| negative = self._encode_comfy_wan_prompt(negative_prompt or "") | |
| return { | |
| "prompt_embeds": positive, | |
| "negative_prompt_embeds": negative, | |
| "echoshot": False, | |
| } | |
| def get_text_embeds(self, prompt: str, negative_prompt: str): | |
| if self._text_encoder_released or ((self.t5 is None) and (self.clip is None)): | |
| self._load_text_encoder() | |
| self._ensure_text_encoder_cuda() | |
| return self._build_text_embeds_from_encoder( | |
| (prompt or "").strip(), | |
| (negative_prompt or "").strip(), | |
| ) | |
| def release_text_encoder(self, *, force: bool = False) -> None: | |
| if not force and self.text_encoder_quantization == "int8_weight_only": | |
| self._diag("text_encoder.retained", mode=self._text_mode) | |
| return | |
| t5, clip = self.t5, self.clip | |
| self.t5 = None | |
| self.clip = None | |
| self._text_encoder_released = True | |
| self._text_encoder_on_cuda = False | |
| del t5, clip | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| self._diag("text_encoder.destroyed") | |
| def _release_diffusion_model(self, attribute: str, *, force: bool = False) -> None: | |
| role = "high" if attribute == "high_model" else "low" | |
| if self.global_resident_models and not force: | |
| self._diag("model.retained", role=role) | |
| return | |
| self._diag("model.destroy.start", role=role) | |
| model = getattr(self, attribute, None) | |
| setattr(self, attribute, None) | |
| if model is not None: | |
| # The wrapper registers patchers in Comfy's loaded-model list while | |
| # sampling. Remove that bookkeeping reference without invoking an | |
| # unload-to-CPU path, then destroy the GPU-resident object. | |
| try: | |
| import comfy.model_management as model_management | |
| patcher = getattr(model, "patcher", model) | |
| model_management.current_loaded_models[:] = [ | |
| loaded | |
| for loaded in model_management.current_loaded_models | |
| if loaded._model() is not patcher | |
| ] | |
| except Exception: | |
| pass | |
| # The sampler assigns all checkpoint tensors as real parameters. | |
| # Sever the patcher -> WanVideoModel -> transformer chain instead | |
| # of moving it to CPU; this makes the GPU allocation collectible. | |
| try: | |
| wrapped = model.model | |
| wrapped.diffusion_model = None | |
| wrapped.pipeline.clear() | |
| model.model = None | |
| except Exception: | |
| pass | |
| try: | |
| del patcher | |
| except UnboundLocalError: | |
| pass | |
| del model | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| self._diag("model.destroy.done", role=role) | |
| def _release_vae(self, *, force: bool = False) -> None: | |
| if self.global_resident_models and not force: | |
| self._diag("vae.retained", tiled=self.vae_tiling) | |
| return | |
| self._diag("vae.destroy.start", present=self.vae is not None) | |
| vae = self.vae | |
| self.vae = None | |
| if vae is not None: | |
| del vae | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| self._diag("vae.destroy.done") | |
| def cleanup_job(self) -> None: | |
| self._diag("runtime.job_cleanup.start") | |
| self._release_diffusion_model("high_model") | |
| self._release_diffusion_model("low_model") | |
| self._release_vae() | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| self._diag("runtime.job_cleanup.done") | |
| def shutdown(self) -> None: | |
| self._diag("runtime.shutdown.start") | |
| self._release_diffusion_model("high_model", force=True) | |
| self._release_diffusion_model("low_model", force=True) | |
| self._release_vae(force=True) | |
| self.release_text_encoder(force=True) | |
| self._diag("runtime.shutdown.done") | |
| def close(self) -> None: | |
| """Backward-compatible process shutdown alias.""" | |
| self.shutdown() | |
| def _sigmas(self): | |
| scheduler_mode = (self.scheduler_mode or "").strip().lower() | |
| if scheduler_mode == "fixed": | |
| if not self.custom_sigmas: | |
| return None | |
| return torch.tensor(self.custom_sigmas, dtype=torch.float32) | |
| if scheduler_mode == "simple": | |
| return self._simple_sigmas() | |
| if scheduler_mode == "linear_quadratic": | |
| return self._linear_quadratic_sigmas() | |
| return None | |
| def _flow_sigmas(self, timesteps: int = 1000) -> torch.Tensor: | |
| t = torch.arange(1, int(timesteps) + 1, dtype=torch.float32) / float(timesteps) | |
| shift = float(self.model_sampling_shift) if hasattr(self, "model_sampling_shift") else 1.0 | |
| return shift * t / (1 + (shift - 1) * t) | |
| def _simple_sigmas(self) -> torch.Tensor: | |
| steps = max(int(getattr(self, "_current_steps", 0)), 1) | |
| sigmas = self._flow_sigmas() | |
| stride = len(sigmas) / steps | |
| values = [float(sigmas[-(1 + int(x * stride))]) for x in range(steps)] | |
| values.append(0.0) | |
| return torch.tensor(values, dtype=torch.float32) | |
| def _linear_quadratic_sigmas(self) -> torch.Tensor: | |
| steps = max(int(getattr(self, "_current_steps", 0)), 1) | |
| if steps == 1: | |
| values = [1.0, 0.0] | |
| else: | |
| threshold_noise = 0.025 | |
| linear_steps = steps // 2 | |
| quadratic_steps = steps - linear_steps | |
| linear = [i * threshold_noise / linear_steps for i in range(linear_steps)] if linear_steps else [] | |
| threshold_step_diff = linear_steps - threshold_noise * steps | |
| quadratic_coef = threshold_step_diff / (linear_steps * quadratic_steps ** 2) if linear_steps and quadratic_steps else 0.0 | |
| linear_coef = threshold_noise / linear_steps - 2 * threshold_step_diff / (quadratic_steps ** 2) if linear_steps and quadratic_steps else 0.0 | |
| const = quadratic_coef * (linear_steps ** 2) | |
| quadratic = [quadratic_coef * (i ** 2) + linear_coef * i + const for i in range(linear_steps, steps)] | |
| values = list(reversed(linear + quadratic)) | |
| values.append(0.0) | |
| return torch.tensor(values, dtype=torch.float32) | |
| def _start_end_temporal_mask(self, num_frames: int, height: int, width: int) -> torch.Tensor | None: | |
| end_strength = max(0.0, min(1.0, float(self.end_temporal_mask_strength))) | |
| if end_strength >= 1.0: | |
| return None | |
| adjusted_frames = ((int(num_frames) - 1) // 4) * 4 + 1 | |
| base_frames = adjusted_frames if self.fun_or_fl2v_model else adjusted_frames + 1 | |
| mask = torch.zeros(base_frames, int(height), int(width), dtype=torch.float32) | |
| mask[0].fill_(1.0) | |
| mask[-1].fill_(end_strength) | |
| return mask | |
| def _zero_end_latent_conditioning(self, image_embeds: dict) -> dict: | |
| if not self.zero_end_latent_conditioning and self.end_latent_conditioning_strength == 1.0: | |
| return image_embeds | |
| latent = image_embeds.get("image_embeds") | |
| if not torch.is_tensor(latent) or latent.ndim < 2 or latent.shape[1] <= 0: | |
| return image_embeds | |
| image_embeds = dict(image_embeds) | |
| latent = latent.clone() | |
| strength = 0.0 if self.zero_end_latent_conditioning else self.end_latent_conditioning_strength | |
| latent[:, -1:] *= max(0.0, float(strength)) | |
| image_embeds["image_embeds"] = latent | |
| return image_embeds | |
| def _low_pass_image_embeds(self, image_embeds: dict) -> dict: | |
| strength = max(0.0, float(self.low_pass_end_conditioning_strength)) | |
| if strength == 1.0: | |
| return image_embeds | |
| latent = image_embeds.get("image_embeds") | |
| if not torch.is_tensor(latent) or latent.ndim < 2 or latent.shape[1] <= 0: | |
| return image_embeds | |
| low_embeds = dict(image_embeds) | |
| latent = latent.clone() | |
| latent[:, -1:] *= strength | |
| low_embeds["image_embeds"] = latent | |
| return low_embeds | |
| def _encode_comfy_wan_prompt(self, text: str): | |
| tokens = self.clip.tokenize(text or "") | |
| encoded = self.clip.encode_from_tokens(tokens, return_dict=True) | |
| cond = encoded["cond"] | |
| if cond.ndim != 3: | |
| raise ValueError(f"Formato inatteso text encoder Wan: shape={tuple(cond.shape)}") | |
| return [frame.detach() for frame in cond] | |
| def generate_segment_iter( | |
| self, | |
| *, | |
| prompt: str, | |
| negative_prompt: str, | |
| start_image: Image.Image, | |
| end_image: Image.Image, | |
| width: int, | |
| height: int, | |
| num_frames: int, | |
| steps: int, | |
| cfg: float, | |
| shift: float, | |
| seed: int, | |
| progress_callback: Callable[..., None] | None = None, | |
| ): | |
| if torch.cuda.is_available(): | |
| torch.cuda.reset_peak_memory_stats() | |
| torch.cuda.synchronize() | |
| started = stage_started = time.perf_counter() | |
| metrics: dict[str, float] = {} | |
| self._diag( | |
| "generation.start", | |
| width=int(width), | |
| height=int(height), | |
| frames=int(num_frames), | |
| steps=int(steps), | |
| split_step=int(self.split_step), | |
| ) | |
| if progress_callback is not None: | |
| progress_callback(0.08, desc="Encoding prompt…") | |
| yield {"stage": "Encoding prompt…"} | |
| self._diag("text_encode.start") | |
| text_embeds = self.get_text_embeds(prompt, negative_prompt) | |
| self._diag("text_encode.done") | |
| self.release_text_encoder() | |
| if torch.cuda.is_available(): | |
| torch.cuda.synchronize() | |
| metrics["text_encode_s"] = time.perf_counter() - stage_started | |
| stage_started = time.perf_counter() | |
| yield {"stage": "Loading image encoder…"} | |
| if progress_callback is not None: | |
| progress_callback(0.18, desc="Loading image encoder…") | |
| yield {"stage": "Loading image encoder…"} | |
| self._load_vae() | |
| if progress_callback is not None: | |
| progress_callback(0.22, desc="Encoding image…") | |
| yield {"stage": "Encoding image…"} | |
| self._diag("vae.encode.start") | |
| image_embeds = self._image_encode.process( | |
| width=int(width), | |
| height=int(height), | |
| num_frames=int(num_frames), | |
| noise_aug_strength=0.0, | |
| start_latent_strength=float(self.start_latent_strength), | |
| end_latent_strength=float(self.end_latent_strength), | |
| force_offload=False, | |
| vae=self.vae, | |
| start_image=self._pil_to_comfy_image(start_image, width, height), | |
| end_image=self._pil_to_comfy_image(end_image, width, height), | |
| fun_or_fl2v_model=self.fun_or_fl2v_model, | |
| temporal_mask=self._start_end_temporal_mask(num_frames, height, width), | |
| tiled_vae=self.vae_tiling, | |
| keep_output_on_device=True, | |
| )[0] | |
| self._diag("vae.encode.done") | |
| image_embeds = self._zero_end_latent_conditioning(image_embeds) | |
| # The wrapper carries the VAE inside image_embeds for optional advanced | |
| # sampler paths. This simple I2V path does not use it while sampling. | |
| # Remove the reference and destroy the GPU VAE until final decode. | |
| image_embeds = dict(image_embeds) | |
| image_embeds["vae"] = None | |
| self._release_vae() | |
| if torch.cuda.is_available(): | |
| torch.cuda.synchronize() | |
| metrics["image_encode_s"] = time.perf_counter() - stage_started | |
| stage_started = time.perf_counter() | |
| yield {"stage": "Loading motion engine — pass 1 of 2…"} | |
| self._current_steps = int(steps) | |
| self.model_sampling_shift = float(shift) | |
| sigmas = self._sigmas() | |
| loop_args = None | |
| if self.loop_shift_skip > 0: | |
| loop_args = self._loop_args.process( | |
| shift_skip=int(self.loop_shift_skip), | |
| start_percent=float(self.loop_start_percent), | |
| end_percent=float(self.loop_end_percent), | |
| )[0] | |
| if progress_callback is not None: | |
| progress_callback(0.30, desc="Loading motion model — pass 1 of 2…") | |
| yield {"stage": "Loading motion engine — pass 1 of 2…"} | |
| self.high_model = self._ensure_diffusion_model("high") | |
| if progress_callback is not None: | |
| progress_callback(0.40, desc="Sampling motion — pass 1 of 2…") | |
| yield {"stage": "Sampling motion — pass 1 of 2…"} | |
| self._diag("sampling.start", role="high", start_step=0, end_step=int(self.split_step)) | |
| high_samples, _ = self._sampler.process( | |
| model=self.high_model, | |
| image_embeds=image_embeds, | |
| text_embeds=text_embeds, | |
| steps=int(steps), | |
| cfg=float(cfg), | |
| shift=float(shift), | |
| seed=int(seed), | |
| force_offload=False, | |
| scheduler=self.sampler_name, | |
| riflex_freq_index=int(self.riflex_k), | |
| batched_cfg=False, | |
| rope_function="comfy", | |
| sigmas=sigmas, | |
| end_step=int(self.split_step), | |
| keep_loaded_models=True, | |
| release_state_dict=True, | |
| reset_peak_memory_stats=False, | |
| keep_output_on_device=True, | |
| aot_package_dir=self._sampling_aot_package("high"), | |
| ) | |
| if torch.cuda.is_available(): | |
| torch.cuda.synchronize() | |
| self._diag( | |
| "sampling.done", | |
| role="high", | |
| elapsed_s=round(time.perf_counter() - stage_started, 3), | |
| ) | |
| metrics["high_sampling_s"] = time.perf_counter() - stage_started | |
| stage_started = time.perf_counter() | |
| self._release_diffusion_model("high_model") | |
| yield {"stage": "Loading motion engine — pass 2 of 2…"} | |
| if progress_callback is not None: | |
| progress_callback(0.54, desc="Loading motion model — pass 2 of 2…") | |
| yield {"stage": "Loading motion engine — pass 2 of 2…"} | |
| self.low_model = self._ensure_diffusion_model("low") | |
| if progress_callback is not None: | |
| progress_callback(0.64, desc="Sampling motion — pass 2 of 2…") | |
| yield {"stage": "Sampling motion — pass 2 of 2…"} | |
| self._diag( | |
| "sampling.start", | |
| role="low", | |
| start_step=int(self.split_step), | |
| end_step=int(steps), | |
| ) | |
| low_samples, _ = self._sampler.process( | |
| model=self.low_model, | |
| image_embeds=self._low_pass_image_embeds(image_embeds), | |
| text_embeds=text_embeds, | |
| samples=high_samples, | |
| steps=int(steps), | |
| cfg=float(cfg), | |
| shift=float(shift), | |
| seed=int(seed), | |
| force_offload=False, | |
| scheduler=self.sampler_name, | |
| riflex_freq_index=int(self.riflex_k), | |
| batched_cfg=False, | |
| rope_function="comfy", | |
| sigmas=sigmas, | |
| loop_args=loop_args, | |
| start_step=int(self.split_step), | |
| keep_loaded_models=True, | |
| release_state_dict=True, | |
| reset_peak_memory_stats=False, | |
| keep_output_on_device=True, | |
| aot_package_dir=self._sampling_aot_package("low"), | |
| ) | |
| if torch.cuda.is_available(): | |
| torch.cuda.synchronize() | |
| self._diag( | |
| "sampling.done", | |
| role="low", | |
| elapsed_s=round(time.perf_counter() - stage_started, 3), | |
| ) | |
| metrics["low_sampling_s"] = time.perf_counter() - stage_started | |
| stage_started = time.perf_counter() | |
| self._release_diffusion_model("low_model") | |
| yield {"stage": "Reloading image decoder…"} | |
| if not self.decode_end_image_hint: | |
| low_samples = dict(low_samples) | |
| low_samples["end_image"] = None | |
| low_samples["drop_last"] = True | |
| if progress_callback is not None: | |
| progress_callback(0.74, desc="Reloading image decoder…") | |
| yield {"stage": "Reloading image decoder…"} | |
| self._load_vae() | |
| if progress_callback is not None: | |
| progress_callback(0.78, desc="Decoding frames…") | |
| yield {"stage": "Decoding frames…"} | |
| self._diag("vae.decode.start") | |
| images = self._decode.decode( | |
| vae=self.vae, | |
| samples=low_samples, | |
| enable_vae_tiling=self.vae_tiling, | |
| tile_x=272, | |
| tile_y=272, | |
| tile_stride_x=144, | |
| tile_stride_y=128, | |
| normalization="default", | |
| keep_model_loaded=True, | |
| keep_output_on_device=True, | |
| )[0] | |
| self._diag("vae.decode.done") | |
| self._release_vae() | |
| if torch.cuda.is_available(): | |
| torch.cuda.synchronize() | |
| metrics["peak_vram_gib"] = torch.cuda.max_memory_allocated() / (1024**3) | |
| metrics["decode_s"] = time.perf_counter() - stage_started | |
| metrics["wan_total_s"] = time.perf_counter() - started | |
| self.last_metrics = metrics | |
| self._diag("generation.done", **{key: round(value, 3) for key, value in metrics.items()}) | |
| if progress_callback is not None: | |
| progress_callback(0.86, desc="Frames decoded") | |
| yield images | |