Files
CauchyInno 5752b3b51f Add Anima TeaCache custom node
Implement native ComfyUI Anima caching with documented sampler presets, example workflows, and validation coverage.
2026-07-12 23:06:04 +08:00

308 lines
10 KiB
Python

import math
import sys
import types
from pathlib import Path
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from nodes import ( # noqa: E402
ANIMA_COEFFICIENTS,
_STEP_INFO_KEY,
AnimaTeaCache,
AnimaTeaCacheRuntime,
CacheDecision,
poly1d,
)
def step_options(index, total=5, labels=None, uuids=None):
options = {
_STEP_INFO_KEY: {
"index": index,
"total": total,
"percent": index / total,
"schedule_key": (total,),
}
}
if labels is not None:
options["cond_or_uncond"] = labels
if uuids is not None:
options["uuids"] = uuids
return options
def runtime(threshold=0.05, start=0.0, end=1.0):
return AnimaTeaCacheRuntime(threshold, start, end, torch.device("cpu"))
def seed_residual(cache_runtime, decision, tensor):
cache_runtime.commit_residual(decision, torch.full_like(tensor, 3.0))
def test_poly1d_is_quartic_with_five_coefficients():
value = 0.125
expected = sum(
coefficient * value ** (len(ANIMA_COEFFICIENTS) - index - 1)
for index, coefficient in enumerate(ANIMA_COEFFICIENTS)
)
assert math.isclose(poly1d(ANIMA_COEFFICIENTS, value), expected)
def test_first_and_last_steps_force_full():
cache_runtime = runtime(threshold=100.0)
tensor = torch.ones(1, 1, 1, 1, 2)
first = cache_runtime.decide(tensor, step_options(0))
assert first.should_calc
seed_residual(cache_runtime, first, tensor)
middle = cache_runtime.decide(tensor * 1.001, step_options(1))
assert not middle.should_calc
last = cache_runtime.decide(tensor * 1.002, step_options(4))
assert last.should_calc
def test_threshold_refreshes_residual():
cache_runtime = runtime(threshold=0.01)
tensor = torch.ones(1, 1, 1, 1, 2)
first = cache_runtime.decide(tensor, step_options(0))
seed_residual(cache_runtime, first, tensor)
decision = cache_runtime.decide(tensor * 2, step_options(1))
assert decision.should_calc
assert cache_runtime.entries[0].accumulated_distance == 0.0
def test_cfg_entries_are_separate_and_reused_in_batch_order():
cache_runtime = runtime(threshold=100.0)
tensor = torch.stack((torch.ones(2), torch.ones(2) * 2)).reshape(2, 1, 1, 1, 2)
options = step_options(0, labels=[1, 0], uuids=["negative", "positive"])
first = cache_runtime.decide(tensor, options)
assert first.keys == [(1, "negative", 0), (0, "positive", 0)]
cache_runtime.commit_residual(
first,
torch.stack((torch.ones(2) * 3, torch.ones(2) * 4)).reshape(2, 1, 1, 1, 2),
)
options = step_options(1, labels=[1, 0], uuids=["negative", "positive"])
second = cache_runtime.decide(tensor * 1.001, options)
assert not second.should_calc
reused = cache_runtime.reuse_residual(second, torch.zeros_like(tensor))
assert torch.equal(reused[0], torch.full_like(reused[0], 3.0))
assert torch.equal(reused[1], torch.full_like(reused[1], 4.0))
def test_reentering_step_zero_resets_interrupted_run():
cache_runtime = runtime(threshold=100.0)
tensor = torch.ones(1, 1, 1, 1, 2)
first = cache_runtime.decide(tensor, step_options(0))
seed_residual(cache_runtime, first, tensor)
cache_runtime.decide(tensor * 1.001, step_options(1))
restarted = cache_runtime.decide(tensor, step_options(0))
assert restarted.should_calc
assert cache_runtime.entries[0].previous_residual is None
def test_new_schedule_and_shape_change_reset_cache():
cache_runtime = runtime(threshold=100.0)
tensor = torch.ones(1, 1, 1, 1, 2)
first = cache_runtime.decide(tensor, step_options(0))
seed_residual(cache_runtime, first, tensor)
new_schedule = step_options(1)
new_schedule[_STEP_INFO_KEY]["schedule_key"] = (99,)
assert cache_runtime.decide(tensor, new_schedule).should_calc
cache_runtime.commit_residual(
CacheDecision(True, True, [0], [slice(0, 1)]),
tensor,
)
larger = torch.ones(1, 1, 1, 2, 2)
assert cache_runtime.decide(larger, step_options(2)).should_calc
def test_outside_percent_range_forces_full():
cache_runtime = runtime(threshold=100.0, start=0.25, end=0.75)
tensor = torch.ones(1, 1, 1, 1, 2)
first = cache_runtime.decide(tensor, step_options(0))
seed_residual(cache_runtime, first, tensor)
assert not cache_runtime.decide(tensor * 1.001, step_options(2)).should_calc
assert cache_runtime.decide(tensor * 1.002, step_options(4)).should_calc
def test_missing_step_metadata_disables_cache():
cache_runtime = runtime()
decision = cache_runtime.decide(torch.ones(1, 1, 1, 1, 2), {})
assert decision.should_calc
assert not decision.cacheable
assert cache_runtime.entries == {}
def test_build_step_info_matches_current_sigma():
cache_runtime = runtime()
sigmas = torch.tensor([1.0, 0.7, 0.4, 0.1, 0.0])
info = cache_runtime._build_step_info(torch.tensor([0.69]), sigmas)
assert info["index"] == 1
assert info["total"] == 4
assert math.isclose(info["percent"], 1 / 4)
def test_first_block_signal_matches_norm1_formula():
class Block:
use_adaln_lora = True
layer_norm_self_attn = torch.nn.Identity()
@staticmethod
def adaln_modulation_self_attn(embedding):
return torch.cat((embedding, embedding * 2, embedding * 3), dim=-1)
class Model:
blocks = [Block()]
cache_runtime = runtime()
hidden = torch.ones(1, 1, 1, 1, 2)
embedding = torch.tensor([[[1.0, 2.0]]])
adaln_lora = torch.ones(1, 1, 6)
extra = torch.full_like(hidden, 2.0)
result = cache_runtime._first_block_modulated_input(
Model(), hidden, embedding, adaln_lora, extra
)
shift = embedding + 1
scale = embedding * 2 + 1
expected = (hidden + extra) * (1 + scale[:, :, None, None, :]) + shift[
:, :, None, None, :
]
assert torch.equal(result, expected)
def test_diffusion_wrapper_preserves_later_wrappers_and_replaces_original():
cache_runtime = runtime()
calls = []
cache_runtime.forward = lambda model, *args, **kwargs: calls.append(model) or "teacache"
class Executor:
class_obj = object()
original = lambda: "original"
def __call__(self, *args, **kwargs):
return self.original(*args, **kwargs)
executor = Executor()
result = cache_runtime.diffusion_model_wrapper(
executor,
transformer_options={
"sigmas": torch.tensor([1.0]),
"sample_sigmas": torch.tensor([1.0, 0.0]),
},
)
assert result == "teacache"
assert calls == [executor.class_obj]
def test_node_registers_diffusion_wrapper_in_model_options():
class FakeAnima:
pass
class FakeModelPatcher:
def __init__(self):
self.diffusion_model = FakeAnima()
self.model_options = {}
def get_model_object(self, _name):
return self.diffusion_model
def clone(self):
clone = FakeModelPatcher()
clone.diffusion_model = self.diffusion_model
clone.model_options = {
key: value.copy() if isinstance(value, dict) else value
for key, value in self.model_options.items()
}
return clone
def add_wrapper_with_key(wrapper_type, key, wrapper, options, is_model_options=False):
assert is_model_options
transformer_options = options.setdefault("transformer_options", {})
transformer_options.setdefault("wrappers", {}).setdefault(wrapper_type, {}).setdefault(
key, []
).append(wrapper)
modules = {
"comfy": types.ModuleType("comfy"),
"comfy.ldm": types.ModuleType("comfy.ldm"),
"comfy.ldm.anima": types.ModuleType("comfy.ldm.anima"),
"comfy.ldm.anima.model": types.ModuleType("comfy.ldm.anima.model"),
"comfy.model_management": types.ModuleType("comfy.model_management"),
"comfy.patcher_extension": types.ModuleType("comfy.patcher_extension"),
}
modules["comfy.ldm.anima.model"].Anima = FakeAnima
modules["comfy.model_management"].get_torch_device = lambda: torch.device("cpu")
modules["comfy.patcher_extension"].WrappersMP = types.SimpleNamespace(
DIFFUSION_MODEL="diffusion_model"
)
modules["comfy.patcher_extension"].add_wrapper_with_key = add_wrapper_with_key
modules["comfy"].model_management = modules["comfy.model_management"]
modules["comfy"].patcher_extension = modules["comfy.patcher_extension"]
previous = {name: sys.modules.get(name) for name in modules}
sys.modules.update(modules)
try:
patched = AnimaTeaCache().apply_teacache(
FakeModelPatcher(), 0.05, 0.0, 1.0, "cpu"
)[0]
wrappers = patched.model_options["transformer_options"]["wrappers"]
assert len(wrappers["diffusion_model"]["anima_teacache"]) == 1
finally:
for name, module in previous.items():
if module is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = module
def test_non_anima_model_is_rejected():
class FakeModelPatcher:
@staticmethod
def get_model_object(_name):
return object()
class FakeAnima:
pass
comfy_module = types.ModuleType("comfy")
ldm_module = types.ModuleType("comfy.ldm")
anima_package = types.ModuleType("comfy.ldm.anima")
anima_model_module = types.ModuleType("comfy.ldm.anima.model")
anima_model_module.Anima = FakeAnima
previous = {
name: sys.modules.get(name)
for name in ("comfy", "comfy.ldm", "comfy.ldm.anima", "comfy.ldm.anima.model")
}
sys.modules.update(
{
"comfy": comfy_module,
"comfy.ldm": ldm_module,
"comfy.ldm.anima": anima_package,
"comfy.ldm.anima.model": anima_model_module,
}
)
try:
try:
AnimaTeaCache().apply_teacache(FakeModelPatcher(), 0.05, 0.0, 1.0, "cpu")
except TypeError as error:
assert "only supports" in str(error)
else:
raise AssertionError("Expected non-Anima model rejection")
finally:
for name, module in previous.items():
if module is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = module