File size: 14,478 Bytes
3afd6d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
"""Exercise eviction against ComfyUI's real scheduler and cache containers.

Only model/UI imports are stubbed: these tests do not need a GPU or H3 weights.
Weak references verify tensor lifetime, rather than just counting dictionary keys.
"""
from __future__ import annotations

import asyncio
import importlib.util
import logging
import sys
import types
import weakref
from pathlib import Path

import pytest
import torch


def _load(name, path, monkeypatch):
    spec = importlib.util.spec_from_file_location(name, path)
    module = importlib.util.module_from_spec(spec)
    monkeypatch.setitem(sys.modules, name, module)
    spec.loader.exec_module(module)
    return module


@pytest.fixture
def runtime(monkeypatch):
    root = Path(__file__).resolve().parents[1]
    comfy_root = root.parent.parent
    package = types.ModuleType("comfy_execution")
    package.__path__ = [str(comfy_root / "comfy_execution")]
    monkeypatch.setitem(sys.modules, "comfy_execution", package)
    provider = types.ModuleType("comfy_execution.cache_provider")
    provider._has_cache_providers = lambda: False
    provider._get_cache_providers = lambda: ()
    provider._contains_self_unequal = lambda value: False
    provider.CacheValue = object
    provider._logger = logging.getLogger(__name__)
    monkeypatch.setitem(sys.modules, "comfy_execution.cache_provider", provider)
    node_types = types.ModuleType("comfy.comfy_types.node_typing")
    node_types.ComfyNodeABC = object
    node_types.InputTypeDict = dict
    node_types.InputTypeOptions = dict
    monkeypatch.setitem(sys.modules, "comfy.comfy_types.node_typing", node_types)
    nodes = types.ModuleType("nodes")
    nodes.NODE_CLASS_MAPPINGS = {}
    monkeypatch.setitem(sys.modules, "nodes", nodes)
    patcher = types.ModuleType("comfy.model_patcher")
    patcher.is_model_patcher_output = lambda value: False
    monkeypatch.setitem(sys.modules, "comfy.model_patcher", patcher)
    comfy = types.ModuleType("comfy")
    comfy.model_management = types.SimpleNamespace(soft_empty_cache=lambda: None)
    monkeypatch.setitem(sys.modules, "comfy", comfy)
    _load("comfy_execution.graph_utils", comfy_root / "comfy_execution/graph_utils.py", monkeypatch)
    graph = _load("comfy_execution.graph", comfy_root / "comfy_execution/graph.py", monkeypatch)
    caches = _load("project_test_caching", comfy_root / "comfy_execution/caching.py", monkeypatch)
    memory = _load("project_memory_under_test", root / "utils/project_memory.py", monkeypatch)
    memory.install_project_memory_cleanup()
    return graph, caches, memory


def _node(segment=None, saved=False, **inputs):
    metadata = {} if segment is None else {"easy_media_segment": segment}
    if saved:
        metadata["easy_media_segment_saved"] = True
    return {"class_type": "Test", "inputs": inputs, "_meta": metadata}


def _cache(caches, mode):
    if mode == "classic":
        return caches.HierarchicalCache(caches.CacheKeySetID)
    if mode == "lru":
        return caches.LRUCache(caches.CacheKeySetID, max_size=100)
    if mode == "ram":
        return caches.RAMPressureCache(caches.CacheKeySetID)
    return caches.NullCache()


def _setup(runtime, mode, segments=3):
    graph, caches, _ = runtime
    prompt = graph.DynamicPrompt({"project": _node(), "external": _node()})
    for index in range(segments):
        previous = {} if not index else {"previous": [f"saved{index - 1}", 0], "tail": [f"tail{index - 1}", 0]}
        prompt.add_ephemeral_node(f"latent{index}", _node(index, **previous), "project", "project")
        prompt.add_ephemeral_node(f"video{index}", _node(index, latent=[f"latent{index}", 0]), "project", "project")
        prompt.add_ephemeral_node(f"tail{index}", _node(index, latent=[f"latent{index}", 0]), "project", "project")
        prompt.add_ephemeral_node(f"saved{index}", _node(index, True, video=[f"video{index}", 0], latent=[f"latent{index}", 0]), "project", "project")
    cache = _cache(caches, mode)
    asyncio.run(cache.set_prompt(prompt, prompt.original_prompt, None))
    asyncio.run(cache.ensure_subcache_for("project", prompt.ephemeral_prompt))
    scheduler = graph.ExecutionList(prompt, cache)
    scheduler.pendingNodes = dict.fromkeys(prompt.all_node_ids(), True)
    return scheduler


def _finish(scheduler, node_id, value):
    asyncio.run(scheduler.output_cache.set(node_id, types.SimpleNamespace(outputs=[value])))
    scheduler.staged_node_id = node_id
    scheduler.blocking[node_id] = {}
    scheduler.pendingNodes[node_id] = True
    scheduler.complete_node_execution()


@pytest.mark.parametrize("mode", ["classic", "lru", "ram", "none"])
def test_saved_segments_release_tensors_and_keep_only_next_context(runtime, mode):
    scheduler = _setup(runtime, mode, segments=6)
    external = torch.ones(4)
    _finish(scheduler, "external", external)
    previous_tail = None
    for index in range(6):
        latent = torch.ones(1024)
        video = torch.ones(4096)
        tail = latent[-4:].clone()
        latent_ref, video_ref, tail_ref = weakref.ref(latent), weakref.ref(video), weakref.ref(tail)
        _finish(scheduler, f"latent{index}", latent)
        _finish(scheduler, f"video{index}", video)
        _finish(scheduler, f"tail{index}", tail)
        # The artifact's live inputs must also be dropped by normal completion.
        scheduler.execution_cache[f"saved{index}"] = {"latent": latent, "video": video}
        del latent, video, tail
        assert latent_ref() is not None and video_ref() is not None
        _finish(scheduler, f"saved{index}", "demo")
        assert latent_ref() is None and video_ref() is None
        if previous_tail is not None:
            assert previous_tail() is None
        if mode != "none" and index < 5:
            assert tail_ref() is not None
            assert scheduler.output_cache.get_local(f"tail{index}").outputs[0].shape == (4,)
        else:
            assert tail_ref() is None
        previous_tail = tail_ref
    if mode != "none":
        assert scheduler.output_cache.get_local("external").outputs[0] is external
        assert scheduler.output_cache.get_local("saved5").outputs == ["demo"]


def test_saved_segment_releases_unused_output_nodes_from_parent_execution_cache(runtime):
    scheduler = _setup(runtime, "classic")
    video = torch.ones(4096)
    video_ref = weakref.ref(video)
    video_entry = types.SimpleNamespace(outputs=[video])
    asyncio.run(scheduler.output_cache.set("video0", video_entry))
    scheduler.cache_link("video0", "project", 0)
    scheduler.cache_update("video0", video_entry)

    saved_entry = types.SimpleNamespace(outputs=["demo"])
    asyncio.run(scheduler.output_cache.set("saved0", saved_entry))
    scheduler.cache_link("saved0", "project", 0)
    scheduler.cache_update("saved0", saved_entry)

    _finish(scheduler, "video0", video_entry)
    _finish(scheduler, "saved0", "demo")
    del video, video_entry, saved_entry

    assert video_ref() is None
    parent_cache = scheduler.execution_cache["project"]
    assert "video0" not in parent_cache
    assert parent_cache["saved0"].outputs == ["demo"]


def test_parent_execution_cache_does_not_accumulate_saved_segment_videos(runtime):
    scheduler = _setup(runtime, "classic", segments=4)
    tensors = []
    for index in range(4):
        video = torch.ones(4096)
        tensors.append(weakref.ref(video))
        entry = types.SimpleNamespace(outputs=[video])
        asyncio.run(scheduler.output_cache.set(f"video{index}", entry))
        scheduler.cache_link(f"video{index}", "project", 0)
        scheduler.cache_update(f"video{index}", entry)

        saved = types.SimpleNamespace(outputs=[f"demo{index}"])
        asyncio.run(scheduler.output_cache.set(f"saved{index}", saved))
        scheduler.cache_link(f"saved{index}", "project", 0)
        scheduler.cache_update(f"saved{index}", saved)

        _finish(scheduler, f"video{index}", entry)
        _finish(scheduler, f"saved{index}", saved)
        del video, entry, saved

        parent_cache = scheduler.execution_cache["project"]
        assert all(
            f"video{previous}" not in parent_cache
            for previous in range(index + 1)
        )
        assert all(
            parent_cache.get(f"saved{previous}") is not None
            for previous in range(index + 1)
        )
    assert all(reference() is None for reference in tensors)


@pytest.mark.parametrize("mode", ["classic", "lru", "ram"])
def test_pending_tail_copy_protects_full_latent_until_copy_completes(runtime, mode):
    scheduler = _setup(runtime, mode)
    latent = torch.ones(1024)
    reference = weakref.ref(latent)
    _finish(scheduler, "latent0", latent)
    _finish(scheduler, "video0", "saved video")
    del latent
    _finish(scheduler, "saved0", "demo")
    assert reference() is not None
    _finish(scheduler, "tail0", reference()[-4:].clone())
    assert reference() is None
    assert scheduler.output_cache.get_local("tail0") is not None


@pytest.mark.parametrize("mode", ["classic", "ram"])
def test_nested_expansions_are_released_and_other_projects_are_preserved(runtime, mode):
    scheduler = _setup(runtime, mode)
    prompt = scheduler.dynprompt
    prompt.add_ephemeral_node("nested", _node(), "video0", "project")
    asyncio.run(scheduler.output_cache.ensure_subcache_for("video0", {"nested"}))
    tensor = torch.ones(1024)
    reference = weakref.ref(tensor)
    _finish(scheduler, "nested", tensor)
    _finish(scheduler, "video0", tensor)
    del tensor
    # A different project with the same segment index must not be evicted.
    prompt.original_prompt["other_project"] = _node()
    prompt.add_ephemeral_node("other_video", _node(0), "other_project", "other_project")
    asyncio.run(scheduler.output_cache.cache_key_set.add_keys({"other_project"}))
    asyncio.run(scheduler.output_cache.ensure_subcache_for("other_project", {"other_video"}))
    other_tensor = torch.ones(16)
    _finish(scheduler, "other_video", other_tensor)
    _, _, memory = runtime
    assert memory._segment_scope(prompt, "other_video") == ("other_project", 0)
    _finish(scheduler, "saved0", "demo")
    assert reference() is None
    assert scheduler.output_cache.get_local("other_video").outputs[0] is other_tensor


def test_shared_cache_key_is_preserved_for_live_node(runtime):
    scheduler = _setup(runtime, "ram")
    _, _, memory = runtime
    cache = scheduler.output_cache
    _finish(scheduler, "video0", torch.ones(4))
    key = cache.cache_key_set.get_data_key("video0")
    cache.cache_key_set.keys["external"] = key
    assert memory._evict_outputs(cache, {"video0"}) == 0
    assert cache.get_local("external") is not None


def test_cleanup_installs_once_and_does_not_run_before_save(runtime):
    scheduler = _setup(runtime, "classic")
    graph, _, memory = runtime
    wrapper = graph.ExecutionList.complete_node_execution
    memory.install_project_memory_cleanup()
    assert graph.ExecutionList.complete_node_execution is wrapper
    _finish(scheduler, "video0", torch.ones(4))
    assert scheduler.output_cache.get_local("video0") is not None
    # PENDING/failed saves never call complete_node_execution, so cannot evict.
    scheduler.staged_node_id = "saved0"
    assert scheduler.output_cache.get_local("video0") is not None


@pytest.mark.parametrize("mode", ["classic", "lru", "ram", "none"])
@pytest.mark.parametrize("cleanup", [False, True])
def test_real_scheduler_runs_each_segment_once_with_bounded_tensor_retention(
    runtime, monkeypatch, mode, cleanup,
):
    graph, _, _ = runtime
    if not cleanup:
        monkeypatch.setattr(
            graph.ExecutionList, "complete_node_execution",
            graph.ExecutionList.complete_node_execution.__wrapped__,
        )

    class Node:
        FUNCTION = "execute"

        @classmethod
        def INPUT_TYPES(cls):
            return {}

        def execute(self):
            return None

    monkeypatch.setitem(sys.modules["nodes"].NODE_CLASS_MAPPINGS, "Test", Node)
    prepared = _setup(runtime, mode, segments=12)
    scheduler = graph.ExecutionList(prepared.dynprompt, prepared.output_cache)
    scheduler.add_node("saved11")
    tensors = []
    executed = set()

    async def run():
        while not scheduler.is_empty():
            node_id, error, exception = await scheduler.stage_node_execution()
            assert error is None and exception is None
            assert node_id not in executed, "Cache cleanup must not replay a saved segment"
            executed.add(node_id)
            inputs = {
                key: scheduler.get_cache(value[0], node_id).outputs[value[1]]
                for key, value in scheduler.dynprompt.get_node(node_id)["inputs"].items()
            }
            if node_id.startswith("latent"):
                if "tail" in inputs:
                    assert inputs["tail"].shape == (4,)
                output = torch.ones(1024)
            elif node_id.startswith("video"):
                output = inputs["latent"].repeat(4)
            elif node_id.startswith("tail"):
                output = inputs["latent"][-4:].clone()
            else:
                output = "demo"
            if isinstance(output, torch.Tensor):
                tensors.append(weakref.ref(output))
            entry = types.SimpleNamespace(outputs=[output])
            scheduler.cache_update(node_id, entry)
            await scheduler.output_cache.set(node_id, entry)
            del inputs, output, entry
            scheduler.complete_node_execution()
            if cleanup and node_id.startswith("saved"):
                # A tail not yet copied may briefly retain one full latent.
                assert sum(reference() is not None for reference in tensors) <= 2

    asyncio.run(run())
    assert len([node for node in executed if node.startswith("saved")]) == 12
    alive = sum(reference() is not None for reference in tensors)
    assert alive == (0 if cleanup or mode == "none" else len(tensors))


def test_cleanup_failure_is_logged_without_breaking_completed_save(runtime, monkeypatch, caplog):
    scheduler = _setup(runtime, "classic")
    _, _, memory = runtime

    def fail(*args):
        raise RuntimeError("unsupported cache layout")

    monkeypatch.setattr(memory, "_release_saved_outputs", fail)
    _finish(scheduler, "saved0", "demo")
    assert scheduler.staged_node_id is None
    assert scheduler.output_cache.get_local("saved0").outputs == ["demo"]
    assert "unsupported cache layout" in caplog.text