Skip to content

Commit f8442f3

Browse files
committed
Merge LightX2V memory management
2 parents fa0259e + 9edac02 commit f8442f3

9 files changed

Lines changed: 435 additions & 35 deletions

File tree

‎README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ Qwen Image, and SeedVR2. The retired `*_distill` values remain selectable as
6767
compatibility aliases for saved ComfyUI workflows and are normalized before
6868
LightX2V's runner is built.
6969

70+
The wrapper keeps one LightX2V GPU runner resident across these node groups.
71+
Switching between Wan/SekoTalk and SeedVR2/SwiftVR releases the previous
72+
runner before loading the next one. Cached restoration loader outputs remain
73+
usable; their runner is rebuilt when selected again.
74+
75+
When LightX2V loads a runner, it asks ComfyUI to unload its resident models
76+
first. When ComfyUI next frees GPU memory for one of its own models, the wrapper
77+
evicts the resident LightX2V runner. This also applies to cached restoration
78+
loader outputs; they rebuild their runner on the next sampler call. An active
79+
LightX2V request is protected from eviction until it finishes. Switching
80+
models can therefore incur a reload, but resident model allocations do not
81+
accumulate across the two model managers. This hook uses ComfyUI's
82+
`model_management.free_memory` entry point; changes to that internal API
83+
require revalidation.
84+
7085
## Model files
7186

7287
Place each model in its own directory under `ComfyUI/models/lightx2v/`. LoRA

‎README_CN.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ git submodule update --init --recursive
6161
变体、SekoTalk、Qwen Image 和 SeedVR2。旧 workflow 使用的 `*_distill`
6262
值仍作为兼容别名保留,构建 LightX2V runner 前会自动归一化。
6363

64+
这些节点组共用一个常驻的 LightX2V GPU runner。Wan/SekoTalk 与
65+
SeedVR2/SwiftVR 切换时,插件先释放旧 runner,再加载新模型;ComfyUI
66+
缓存的超分加载器输出仍可复用,再次使用时会重建 runner。
67+
68+
LightX2V 加载 runner 前会先让 ComfyUI 卸载它管理的常驻模型;ComfyUI
69+
随后为原生模型清理 GPU 显存时,插件会释放 LightX2V 的常驻 runner。
70+
缓存的超分加载器输出会在下次采样时重建 runner。正在运行的 LightX2V
71+
推理不会被中途卸载。切换模型需要重新加载,但两套模型管理器的常驻模型
72+
显存不会因连续切换而一直累积。该机制接入 ComfyUI 的
73+
`model_management.free_memory`;此内部接口变动时需要重新验证。
74+
6475
## 模型目录
6576

6677
每个模型放在 `ComfyUI/models/lightx2v/` 下的独立目录中。LoRA 可以放在

‎__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,8 @@ def _register_lightx2v_submodule() -> None:
4444
_register_lightx2v_submodule()
4545

4646
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS # noqa: E402
47+
from .nodes._runner_memory import install_comfyui_memory_hook # noqa: E402
48+
49+
install_comfyui_memory_hook()
4750

4851
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]

‎nodes/_lightx2v_runner.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def temporary_runner_config(runner, values):
4848

4949
def run_request(runner, request, *, runtime_config=None):
5050
"""Validate one modern request and execute it through the runner API."""
51-
with temporary_runner_config(runner, runtime_config or {}):
51+
from ._runner_memory import active_lightx2v_request
52+
53+
with active_lightx2v_request(), temporary_runner_config(runner, runtime_config or {}):
5254
input_info = runner.prepare_request(dict(request))
5355
return runner.run_request(input_info)

‎nodes/_runner_memory.py‎

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""Keep at most one resident LightX2V runner across ComfyUI node caches.
2+
3+
ComfyUI can retain loader outputs after a workflow stops using them. Holding a
4+
runner directly in each output therefore keeps old models on the GPU when the
5+
next workflow loads another model. Handles remain cacheable, but their runner
6+
slot can be cleared and rebuilt when that workflow is used again.
7+
"""
8+
9+
import functools
10+
import gc
11+
import logging
12+
import sys
13+
import threading
14+
import weakref
15+
from contextlib import contextmanager
16+
17+
import torch
18+
19+
from ._lightx2v_runner import build_runner
20+
21+
_LOCK = threading.RLock()
22+
_HANDLES = weakref.WeakValueDictionary()
23+
_ACTIVE_REQUESTS = 0
24+
_RELEASING = False
25+
26+
27+
@contextmanager
28+
def active_lightx2v_request():
29+
"""Keep ComfyUI's memory hook from evicting a runner during inference."""
30+
global _ACTIVE_REQUESTS
31+
with _LOCK:
32+
_ACTIVE_REQUESTS += 1
33+
try:
34+
yield
35+
finally:
36+
_ACTIVE_REQUESTS -= 1
37+
38+
39+
def dispose_runner(runner):
40+
"""Drop GPU modules even when an upstream callback still references runner."""
41+
if runner is None:
42+
return
43+
children = getattr(runner, "clip_generators", None)
44+
if isinstance(children, dict):
45+
for child in children.values():
46+
dispose_runner(child)
47+
del runner.clip_generators
48+
# DefaultRunner.__del__ covers most of these, but only runs once *all*
49+
# external references disappear. SwiftVR's restorer also holds its own
50+
# transformer and VAE references and is not covered by that destructor.
51+
for name in ("restorer", "model", "text_encoders", "image_encoder", "vae_encoder", "vae_decoder", "inputs"):
52+
if name in getattr(runner, "__dict__", {}):
53+
delattr(runner, name)
54+
55+
56+
def _collect_cuda():
57+
# LightX2V freezes the GC generation after a successful request. Unfreeze
58+
# before dropping runners so cycles containing CUDA tensors can be collected.
59+
gc.unfreeze()
60+
gc.collect()
61+
torch.cuda.empty_cache()
62+
63+
64+
def release_restoration_runners(keep=None):
65+
"""Drop SwiftVR/SeedVR runners, including cached ComfyUI loader outputs."""
66+
with _LOCK:
67+
gc.unfreeze()
68+
released_seedvr = False
69+
released_swiftvr = False
70+
for handle in list(_HANDLES.values()):
71+
if handle is not keep:
72+
runner = handle["runner"]
73+
released_seedvr |= runner is not None and handle.startup_config.get("model_cls") == "seedvr2"
74+
released_swiftvr |= runner is not None and handle.startup_config.get("model_cls") == "swiftvr"
75+
dispose_runner(runner)
76+
handle["runner"] = None
77+
del runner
78+
if released_seedvr:
79+
# This @lru_cache is keyed by `self`: without clearing it, every
80+
# SeedVR RoPE instance (and its GPU model graph) remains reachable.
81+
rope_module = sys.modules.get("lightx2v.models.networks.seedvr.utils.rope")
82+
if rope_module is not None:
83+
rope_module.RotaryEmbeddingBase.get_axial_freqs.cache_clear()
84+
if released_swiftvr:
85+
attention_module = sys.modules.get("lightx2v.models.networks.swiftvr.attention")
86+
if attention_module is not None:
87+
attention_module.WindowLayout.clear()
88+
_collect_cuda()
89+
90+
91+
def _release_modular_runner():
92+
from .inference import LightX2VModularInferenceV2
93+
94+
if LightX2VModularInferenceV2._current_runner is not None:
95+
LightX2VModularInferenceV2._release_runner()
96+
97+
98+
def release_all_runners():
99+
"""Evict every resident LightX2V model before ComfyUI allocates VRAM."""
100+
global _RELEASING
101+
with _LOCK:
102+
if _ACTIVE_REQUESTS or _RELEASING:
103+
return False
104+
from .inference import LightX2VModularInferenceV2
105+
106+
if LightX2VModularInferenceV2._current_runner is None and not any(handle["runner"] is not None for handle in _HANDLES.values()):
107+
return False
108+
_RELEASING = True
109+
try:
110+
logging.info("Releasing resident LightX2V models for ComfyUI memory management")
111+
_release_modular_runner()
112+
release_restoration_runners()
113+
return True
114+
finally:
115+
_RELEASING = False
116+
117+
118+
def unload_comfyui_models():
119+
"""Release ComfyUI-owned models before LightX2V constructs a GPU runner."""
120+
import comfy.model_management as model_management
121+
122+
model_management.unload_all_models()
123+
model_management.soft_empty_cache()
124+
125+
126+
def install_comfyui_memory_hook():
127+
"""Evict LightX2V when ComfyUI's native model manager frees GPU memory.
128+
129+
Native loads call free_memory through the model_management module, so this
130+
also covers call sites holding an early-bound reference to load_models_gpu.
131+
"""
132+
import comfy.model_management as model_management
133+
134+
original = model_management.free_memory
135+
if getattr(original, "_lightx2v_memory_hook", False) is True:
136+
return
137+
138+
@functools.wraps(original)
139+
def free_memory_with_lightx2v(*args, **kwargs):
140+
device = args[1] if len(args) > 1 else kwargs.get("device")
141+
if device is None or getattr(device, "type", None) != "cpu":
142+
release_all_runners()
143+
return original(*args, **kwargs)
144+
145+
free_memory_with_lightx2v._lightx2v_memory_hook = True
146+
model_management.free_memory = free_memory_with_lightx2v
147+
148+
149+
def make_runner_handle(config, **metadata):
150+
"""Build a runner after releasing every other LightX2V model."""
151+
with _LOCK:
152+
release_restoration_runners()
153+
_release_modular_runner()
154+
unload_comfyui_models()
155+
runner = build_runner(config)
156+
handle = RunnerHandle(config, runner, **metadata)
157+
_HANDLES[id(handle)] = handle
158+
return handle
159+
160+
161+
def get_runner(model):
162+
"""Rebuild an evicted cached loader output before a sampler uses it."""
163+
if not isinstance(model, RunnerHandle):
164+
return model["runner"]
165+
with _LOCK:
166+
runner = model["runner"]
167+
if runner is None:
168+
release_restoration_runners(keep=model)
169+
_release_modular_runner()
170+
unload_comfyui_models()
171+
runner = build_runner(model.startup_config)
172+
model["runner"] = runner
173+
return runner
174+
175+
176+
class RunnerHandle(dict):
177+
"""ComfyUI-compatible model output whose GPU state can be evicted."""
178+
179+
def __init__(self, startup_config, runner, **metadata):
180+
super().__init__(runner=runner, **metadata)
181+
self.startup_config = dict(startup_config)

‎nodes/inference.py‎

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,16 @@ def INPUT_TYPES(cls):
6363

6464
@classmethod
6565
def _release_runner(cls):
66-
"""Drop the singleton runner + force VRAM teardown via DefaultRunner.__del__.
66+
"""Drop the singleton runner and its GPU modules before another load.
6767
6868
Callers MUST drop their own local refs to the old runner *before* invoking
6969
this — otherwise the refcount stays > 0, __del__ doesn't fire, and the
7070
next model load OOMs (model_a + model_b alive on GPU at the same time).
7171
"""
72+
from ._runner_memory import dispose_runner
73+
74+
gc.unfreeze()
75+
dispose_runner(cls._current_runner)
7276
cls._current_runner = None
7377
cls._current_config_hash = None
7478
gc.collect()
@@ -211,6 +215,13 @@ def generate(self, prepared_config):
211215
# switching v2.5 s2v -> v2.7 rs2v).
212216
current_runner = None
213217
self._release_runner()
218+
# ComfyUI may still cache SwiftVR/SeedVR loader outputs from a
219+
# prior workflow. Evict those GPU runners before loading a
220+
# Wan/SekoTalk model, while retaining rebuildable handles.
221+
from ._runner_memory import release_restoration_runners, unload_comfyui_models
222+
223+
release_restoration_runners()
224+
unload_comfyui_models()
214225
if config.get("task") == "rs2v":
215226
from ..lightx2v.lightx2v.shot_runner.rs2v_infer import ShotRS2VPipeline
216227

@@ -252,30 +263,29 @@ def update_progress(current_step, _total):
252263
config["return_result_tensor"] = True
253264
config["save_result_path"] = ""
254265
config["negative_prompt"] = config.get("negative_prompt", "")
255-
if config.get("task") == "rs2v":
256-
# ShotRS2VPipeline delegates request validation to its clip
257-
# runner. Passing the prepared config wholesale worked with
258-
# the old InputInfo path, but LightX2V 0.6 rejects startup
259-
# fields (model paths, quantization flags, etc.) as request
260-
# fields. Filter the request against the modern runner schema
261-
# before handing it to the shot pipeline.
262-
if _modern_build_runner is not None and _modern_build_startup_config is not None:
263-
shot_runner = current_runner.clip_generators["rs2v_clip"]
264-
request_data = self._build_modern_request(config, shot_runner)
265-
result_dict = current_runner.run_pipeline(request_data)
266+
from ._runner_memory import active_lightx2v_request
267+
268+
with active_lightx2v_request():
269+
if config.get("task") == "rs2v":
270+
# ShotRS2VPipeline delegates request validation to its clip
271+
# runner. Filter prepared config against the request schema.
272+
if _modern_build_runner is not None and _modern_build_startup_config is not None:
273+
shot_runner = current_runner.clip_generators["rs2v_clip"]
274+
request_data = self._build_modern_request(config, shot_runner)
275+
result_dict = current_runner.run_pipeline(request_data)
276+
else:
277+
result_dict = current_runner.run_pipeline(config)
278+
elif _modern_build_runner is not None and _modern_build_startup_config is not None:
279+
request_data = self._build_modern_request(config, current_runner)
280+
input_info = current_runner.prepare_request(request_data)
281+
result_dict = current_runner.run_request(input_info)
266282
else:
267-
result_dict = current_runner.run_pipeline(config)
268-
elif _modern_build_runner is not None and _modern_build_startup_config is not None:
269-
request_data = self._build_modern_request(config, current_runner)
270-
input_info = current_runner.prepare_request(request_data)
271-
result_dict = current_runner.run_request(input_info)
272-
else:
273-
if _legacy_init_empty_input_info is None or _legacy_update_input_info_from_dict is None:
274-
raise RuntimeError("No compatible LightX2V input-info API found in the installed LightX2V submodule")
275-
input_data = _legacy_init_empty_input_info(config.task)
276-
_legacy_update_input_info_from_dict(input_data, config)
277-
current_runner.set_config(config)
278-
result_dict = current_runner.run_pipeline(input_data)
283+
if _legacy_init_empty_input_info is None or _legacy_update_input_info_from_dict is None:
284+
raise RuntimeError("No compatible LightX2V input-info API found in the installed LightX2V submodule")
285+
input_data = _legacy_init_empty_input_info(config.task)
286+
_legacy_update_input_info_from_dict(input_data, config)
287+
current_runner.set_config(config)
288+
result_dict = current_runner.run_pipeline(input_data)
279289

280290
images = result_dict.get("video", None)
281291
audio = result_dict.get("audio", None)

‎nodes/seedvr.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
import torch
3030
from comfy.utils import ProgressBar
3131

32-
from ._lightx2v_runner import build_runner, run_request
32+
from ._lightx2v_runner import run_request
33+
from ._runner_memory import get_runner, make_runner_handle
3334
from .file_input import probe_video_file, resolve_input_video_path
3435

3536
logger = logging.getLogger(__name__)
@@ -373,7 +374,7 @@ def load(
373374
else:
374375
config["dit_original_ckpt"] = str(ckpt_path)
375376

376-
runner = build_runner(config)
377+
model = make_runner_handle(config, precision=precision, ckpt=ckpt_name)
377378
logger.info(
378379
"[SeedVR2Loader] loaded %s (%s); cpu_offload=%s, tile_vae=%s, tile=%s, overlap=%s, slice=%s, mem_limit=%sGiB",
379380
ckpt_name,
@@ -385,7 +386,7 @@ def load(
385386
vae_causal_slice_size,
386387
vae_memory_limit_gb,
387388
)
388-
return ({"runner": runner, "precision": precision, "ckpt": ckpt_name},)
389+
return (model,)
389390

390391

391392
class LightX2VSeedVR2Sampler:
@@ -463,7 +464,7 @@ def sample(
463464
filename_prefix,
464465
color_fix,
465466
):
466-
runner = model["runner"]
467+
runner = get_runner(model)
467468

468469
if int(infer_steps) != 1:
469470
raise ValueError("SeedVR2 requires infer_steps=1")
@@ -644,7 +645,7 @@ def sample(
644645
"color_fix": str(color_fix),
645646
}
646647

647-
runner = model["runner"]
648+
runner = get_runner(model)
648649
progress = ProgressBar(100)
649650
logger.info(
650651
"[SeedVR2FileSampler] input=%s (%sx%s @ %.3f fps), target=%sx%s, segment=%s/%s",

0 commit comments

Comments
 (0)