Files

238 lines
12 KiB
Python

"""Turbo sampler: the step maths on toy models, then the real wrapper through ComfyUI's guider on the tiny H3 (CPU)."""
import pytest
import torch
from modules.sampling.turbo import SETTINGS, churn, install, make, make_on_top, step_to
from modules._core import dit_hooks
from modules.sampling.two_stage.tests.test_two_stage import model # noqa: F401 -- the module-scoped fixture
def _settings(**over):
values = {name: spec["default"] for name, spec in SETTINGS.items() if name != "enabled"}
values.update(over)
return values
class Behind:
"""A guider stand-in: which stage Two stages says it is in, and the whole schedule."""
def __init__(self, stage=None, full=None):
setattr(self, dit_hooks.STAGE, stage)
setattr(self, dit_hooks.FULL, full)
class Oracle:
"""A model that always knows the clean picture `target` (a flow model with a perfect prediction)."""
def __init__(self, target, behind=None):
self.target, self.calls, self.inner_model = target, [], behind
def __call__(self, x, sigma, **kw):
self.calls.append(float(sigma[0]))
return self.target.to(x)
def test_eta_zero_is_euler():
x, x0 = torch.randn(1, 4, 2, 3, 3), torch.randn(1, 4, 2, 3, 3)
sigma, to = torch.tensor(0.8), torch.tensor(0.5)
euler = x + ((x - x0) / sigma) * (to - sigma)
assert torch.allclose(step_to(x, x0, sigma, to, 0.0, None), euler, atol=1e-5)
def test_perfect_model_lands_on_the_target_for_any_step_count_and_split():
target = torch.randn(1, 4, 2, 3, 3)
for sigmas in ([1.0, 0.5, 0.0], [1.0, 0.9, 0.7, 0.4, 0.2, 0.0]):
s = torch.tensor(sigmas)
for behind in (None, Behind(2, torch.tensor([1.0] * 4 + sigmas))):
oracle = Oracle(target, behind)
out = make(_settings(noise_start=0.8, polish=0.0))(oracle, torch.randn_like(target), s)
assert torch.allclose(out, target, atol=1e-5)
assert oracle.calls == pytest.approx([float(v) for v in sigmas[:-1]], rel=1e-6)
def test_polish_adds_one_call_in_the_last_stage_only():
target = torch.randn(1, 4, 2, 3, 3)
s = torch.tensor([1.0, 0.5, 0.0])
for last, calls in ((True, 3), (False, 2)):
oracle = Oracle(target, Behind(None if last else 1, torch.tensor([1.0, 0.5, 0.3, 0.0])))
out = make(_settings(polish=0.05))(oracle, torch.randn_like(target), s)
assert len(oracle.calls) == calls and torch.allclose(out, target, atol=1e-5)
if last:
assert oracle.calls[-1] == pytest.approx(0.05)
def test_same_seed_same_result_and_noise_actually_used():
target = torch.randn(1, 4, 2, 3, 3)
s = torch.tensor([1.0, 0.7, 0.4, 0.0])
noisy_start = torch.randn_like(target)
class Sloppy(Oracle): # answers differ with x, so injected noise shows in the result
def __call__(self, x, sigma, **kw):
return 0.5 * self.target.to(x) + 0.5 * x * (1 - sigma[0])
run = lambda eta, seed: make(_settings(noise_start=eta, noise_end=eta, second_order=0.0))(
Sloppy(target), noisy_start.clone(), s, extra_args={"seed": seed})
assert torch.equal(run(0.7, 1), run(0.7, 1))
assert not torch.allclose(run(0.7, 1), run(0.7, 2))
assert not torch.allclose(run(0.7, 1), run(0.0, 1))
def test_follows_the_input_dtype_and_nested_sound_gets_no_noise():
from comfy.nested_tensor import NestedTensor
video, audio = torch.randn(1, 4, 2, 3, 3, dtype=torch.float64), torch.randn(1, 6, 2, 3, dtype=torch.float64)
seen = []
class Sound:
def __call__(self, x, sigma, **kw):
seen.append(x.unbind()[1].clone())
return NestedTensor((torch.zeros_like(x.unbind()[0]), torch.zeros_like(x.unbind()[1])))
s = torch.tensor([1.0, 0.6, 0.3, 0.0], dtype=torch.float32) # sigmas in another dtype than the latent
out = make(_settings(noise_start=1.0, polish=0.1, second_order=1.0))(
Sound(), NestedTensor((video, audio)), s)
v, a = out.unbind()
assert v.dtype == torch.float64 and a.dtype == torch.float64
# the sound is stepped by plain Euler toward zero: x * (to / sigma) each time
assert torch.allclose(seen[1], audio * (0.6 / 1.0), atol=1e-9)
def _guided(model, values, sigmas, with_two_stages=None, sampler="euler"):
import comfy.samplers
from comfy.nested_tensor import NestedTensor
patched = model.clone()
if with_two_stages:
from modules.sampling.two_stage import install as two
two(patched, with_two_stages, "funui.two_stage")
note = install(patched, values, "funui.turbo")
calls = []
hook = patched.model.diffusion_model.register_forward_pre_hook(
lambda m, a, kw: calls.append(float(kw["transformer_options"]["sigmas"].reshape(-1)[0])), with_kwargs=True)
guider = comfy.samplers.CFGGuider(patched)
cond = [[torch.randn(1, 5, 48), {}]]
guider.set_conds(cond, cond)
guider.set_cfg(1.0)
latent = NestedTensor((torch.zeros(1, 24, 2, 8, 8), torch.zeros(1, 32, 2, 3)))
noise = NestedTensor((torch.randn(1, 24, 2, 8, 8), torch.randn(1, 32, 2, 3)))
try:
out = guider.sample(noise, latent, comfy.samplers.sampler_object(sampler), torch.tensor(sigmas),
seed=1, disable_pbar=True)
finally:
hook.remove()
return note, out, calls
def test_off_installs_nothing(model):
assert install(model.clone(), {"enabled": False}, "k") is None
@pytest.mark.parametrize("sigmas,cut", [((1.0, 0.8, 0.5, 0.25, 0.0), 2), ((1.0, 0.9, 0.7, 0.55, 0.4, 0.2, 0.0), 3),
((1.0, 0.6, 0.3, 0.0), 1)])
def test_runs_through_two_stages_with_any_split(model, sigmas, cut):
values = {"enabled": True, **_settings(polish=0.05)}
note, out, calls = _guided(model, values, sigmas, {"enabled": True, "size": 0.5, "steps": cut, "back_up": 0.0})
assert "fresh noise" in note
v, a = out.unbind()
assert torch.isfinite(v).all() and torch.isfinite(a).all()
assert len(calls) == len(sigmas) - 1 + 1 # one call per step plus the polish call
assert calls[cut - 1] != calls[cut] and calls[-1] == pytest.approx(0.05)
assert calls[cut] == pytest.approx(sigmas[cut], rel=1e-4)
def test_without_two_stages_and_without_polish(model):
values = {"enabled": True, **_settings(polish=0.0)}
_, out, calls = _guided(model, values, (1.0, 0.7, 0.3, 0.0))
assert len(calls) == 3 and torch.isfinite(out.unbind()[0]).all()
def test_registered_in_comfys_sampler_list_and_runs_like_the_module(model):
import comfy.samplers
from comfy.nested_tensor import NestedTensor
assert "funui_turbo" in comfy.samplers.SAMPLER_NAMES
patched = model.clone()
guider = comfy.samplers.CFGGuider(patched)
cond = [[torch.randn(1, 5, 48), {}]]
guider.set_conds(cond, cond)
guider.set_cfg(1.0)
sigmas = comfy.samplers.calculate_sigmas(patched.get_model_object("model_sampling"), "sgm_uniform", 4)
noise = NestedTensor((torch.randn(1, 24, 2, 8, 8), torch.randn(1, 32, 2, 3)))
latent = NestedTensor((torch.zeros(1, 24, 2, 8, 8), torch.zeros(1, 32, 2, 3)))
out = guider.sample(noise, latent, comfy.samplers.sampler_object("funui_turbo"), sigmas, seed=1, disable_pbar=True)
assert torch.isfinite(out.unbind()[0]).all()
def test_polish_is_on_by_default_at_a_quarter(model):
assert SETTINGS["polish"]["default"] == 0.25
_, _, calls = _guided(model, {"enabled": True, **_settings()}, (1.0, 0.7, 0.3, 0.0))
assert len(calls) == 4 and calls[-1] == pytest.approx(0.25)
@pytest.mark.parametrize("sampler", ["euler", "er_sde", "dpmpp_2m", "res_multistep", "dflow_ord1", "uni_pc", "ddim"])
def test_the_switch_takes_over_whatever_sampler_is_chosen(model, sampler):
"""The same rule runs whichever sampler was picked: one model call per step plus the polish call, and the
last call is the polish sigma (a sampler's own loop would make a different number of calls)."""
import modules.sampling.distilled_flow # noqa: F401 -- registers the dflow samplers
values = {"enabled": True, **_settings(polish=0.05)}
note, out, calls = _guided(model, values, (1.0, 0.7, 0.3, 0.0), sampler=sampler)
assert "fresh noise" in note and len(calls) == 4 and calls[-1] == pytest.approx(0.05)
assert torch.isfinite(out.unbind()[0]).all()
def test_churn_with_no_fresh_noise_changes_nothing_and_follows_dtype_and_device():
x, d = torch.randn(1, 3, 2, 4, 4, dtype=torch.float64), torch.randn(1, 3, 2, 4, 4, dtype=torch.float64)
n = torch.randn_like(x)
sigma = torch.tensor([0.6], dtype=torch.float32) # a different dtype from the picture
assert torch.equal(churn(x, d, sigma, 0.0, n), d)
out = churn(x, d, sigma, 0.5, n)
assert out.dtype == torch.float64 and out.device == x.device and not torch.equal(out, d)
def test_an_euler_step_from_the_churned_answer_moves_along_the_new_noise_direction():
x, d, n = torch.randn(1, 3, 2, 4, 4), torch.randn(1, 3, 2, 4, 4), torch.randn(1, 3, 2, 4, 4)
sigma, to, eta = torch.tensor(0.7), torch.tensor(0.4), 0.45
answer = churn(x, d, sigma, eta, n)
implied = (x - (1 - sigma) * d) / sigma
fresh = (1 - eta ** 2) ** 0.5 * implied + eta * n
euler = x + ((x - answer) / sigma) * (to - sigma) # what Euler does with an answer
assert torch.allclose(euler, x + (fresh - d) * (to - sigma), atol=1e-5)
assert torch.allclose(euler, (1 - to) * d + sigma * implied + (to - sigma) * fresh, atol=1e-5)
def _euler(model, x, sigmas, extra_args=None, callback=None, disable=None, **_):
ones = x.new_ones([x.shape[0]])
for i in range(len(sigmas) - 1):
d = (x - model(x, sigmas[i] * ones, **(extra_args or {}))) / sigmas[i]
x = x + d * (sigmas[i + 1] - sigmas[i])
return x
def test_on_top_of_a_sampler_with_no_fresh_noise_and_no_polish_is_that_sampler():
target, sigmas = torch.randn(1, 3, 2, 4, 4), torch.tensor([1.0, 0.7, 0.3, 0.0])
noise = torch.randn_like(target)
plain = _euler(Oracle(target), noise.clone(), sigmas)
top = make_on_top(_euler, _settings(noise_start=0.0, noise_end=0.0, polish=0.0))(Oracle(target), noise.clone(), sigmas)
assert torch.equal(plain, top)
def test_on_top_of_a_sampler_adds_fresh_noise_before_the_last_step_and_one_polish_call():
target, sigmas = torch.randn(1, 3, 2, 4, 4), torch.tensor([1.0, 0.7, 0.3, 0.0])
noise = torch.randn_like(target)
oracle = Oracle(target)
run = make_on_top(_euler, _settings(noise_start=0.5, noise_end=0.5, polish=0.1))
a = run(oracle, noise.clone(), sigmas, extra_args={"seed": 3})
b = run(Oracle(target), noise.clone(), sigmas, extra_args={"seed": 3})
assert torch.equal(a, b) # the same seed gives the same picture
assert oracle.calls == pytest.approx([1.0, 0.7, 0.3, 0.1]) # one call per step, then the polish call
assert not torch.equal(a, _euler(Oracle(target), noise.clone(), sigmas))
def test_the_polish_call_is_marked_as_after_the_last_step_and_the_steps_are_not():
seen = []
class Watching(Oracle):
def __call__(self, x, sigma, **kw):
seen.append(dit_hooks.last_step(((kw.get("model_options") or {}).get("transformer_options"))))
return super().__call__(x, sigma, **kw)
target = torch.randn(1, 4, 2, 3, 3)
make(_settings(polish=0.05))(Watching(target), torch.randn_like(target), torch.tensor([1.0, 0.5, 0.0]),
extra_args={"model_options": {"transformer_options": {"other": 1}}})
assert seen == [True, True, False] # no schedule given: each step reads as last, the polish call never
assert dit_hooks.after_last(None)["model_options"]["transformer_options"] == {dit_hooks.AFTER_LAST: True}