Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
238 lines
12 KiB
Python
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}
|