Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
286 lines
14 KiB
Python
286 lines
14 KiB
Python
"""Turbo sampler: a step rule made for few-step turbo runs, aware of Two stages, for any step count.
|
|
|
|
Replaces the chosen sampler for the run (the model calls are the same: one per step, no extra cost) with a
|
|
flow step that has these dials:
|
|
|
|
* Fresh noise per step. After each model answer the next point is `(1 - s) * picture + s * noise`; the
|
|
noise is the model's own implied noise, blended with fresh noise by `eta` (0 = plain Euler, 1 = a full
|
|
re-draw as in consistency sampling). `eta` runs from "Noise at the start" to "Noise at the end" across the
|
|
WHOLE schedule, so it means the same for 3+2 steps, 4+4 or a run without Two stages.
|
|
* Second order late in the run: the picture guesses of the last steps are extrapolated from the one before
|
|
(as in Distilled Flow's `dflow_ord2_ab2`), faded in with the run's progress. History starts fresh in each
|
|
stage, because a Two stages hand-over changes the size.
|
|
* Polish step (on by default at 0.25): after the last step, noise the finished picture back to a small sigma and let the
|
|
model answer once more at full size. One extra model call, in the last stage only.
|
|
|
|
The sound is stepped without fresh noise and without second order. Samplers that call the model twice per
|
|
step are replaced, not wrapped; the other sampler wrappers still run.
|
|
|
|
Ideas and credit: consistency models (Song et al., arXiv 2303.01469) and latent consistency models (Luo et
|
|
al., arXiv 2310.04378) for the re-noise-and-answer multistep rule; k-diffusion (Katherine Crowson) for
|
|
ancestral-style noise injection; NVIDIA Sol-H3 (arXiv 2609.35110) for the cross-resolution schedule this is
|
|
tuned around. See the README Thank you section.
|
|
"""
|
|
|
|
import math
|
|
|
|
import torch
|
|
from comfy.patcher_extension import WrappersMP
|
|
|
|
from ..._core import dit_hooks, log
|
|
|
|
ID = "turbo_sampler"
|
|
TITLE = "Turbo sampler"
|
|
MOUNT = "generation.sampling"
|
|
STAGE = "load"
|
|
CATEGORY = "sampling"
|
|
STATUS = "experimental"
|
|
REQUIRES = ["audio_stream"]
|
|
|
|
SETTINGS = {
|
|
"enabled": {
|
|
"type": "bool", "default": False,
|
|
"label": "Turbo sampler",
|
|
"hint": "Replaces the sampler with a step rule made for few-step turbo runs. No extra model calls; works with any step count and Two stages.",
|
|
},
|
|
"keep_sampler": {
|
|
"type": "bool", "default": False,
|
|
"label": "Keep the chosen sampler",
|
|
"hint": "The chosen sampler keeps the steps; Turbo adds fresh noise and a polish step on top.",
|
|
"when": {"enabled": True},
|
|
},
|
|
"noise_start": {
|
|
"type": "float", "default": 0.4, "min": 0.0, "max": 1.0, "step": 0.05,
|
|
"label": "Noise at the start", "ui": "slider",
|
|
"hint": "How much of each early step's noise is drawn fresh. 0 = plain Euler, 1 = a full re-draw.",
|
|
"when": {"enabled": True},
|
|
},
|
|
"noise_end": {
|
|
"type": "float", "default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05,
|
|
"label": "Noise at the end", "ui": "slider",
|
|
"hint": "The same for the last steps; it fades from the start value to this one across the whole run.",
|
|
"when": {"enabled": True},
|
|
},
|
|
"second_order": {
|
|
"type": "float", "default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05,
|
|
"label": "Second order late", "ui": "slider",
|
|
"hint": "How much the late steps lean on the step before. 0 = off. It fades in over the run. Strong values can distort the picture.",
|
|
"when": {"enabled": True, "keep_sampler": False},
|
|
},
|
|
"polish": {
|
|
"type": "float", "default": 0.25, "min": 0.0, "max": 0.6, "step": 0.01,
|
|
"label": "Polish step sigma", "ui": "slider",
|
|
"hint": "0: off. Otherwise adds one extra full-size polish pass from this noise level. From about 0.4 the sound is redrawn too.",
|
|
"when": {"enabled": True},
|
|
},
|
|
}
|
|
|
|
|
|
def _parts(t):
|
|
return list(t.unbind()) if getattr(t, "is_nested", False) else [t]
|
|
|
|
|
|
def _join(like, parts):
|
|
if getattr(like, "is_nested", False):
|
|
from comfy.nested_tensor import NestedTensor
|
|
return NestedTensor(tuple(parts))
|
|
return parts[0]
|
|
|
|
|
|
def _noise(like, generator):
|
|
return torch.randn(like.shape, generator=generator, device=like.device, dtype=like.dtype)
|
|
|
|
|
|
def step_to(x, x0, sigma, to, eta, noise):
|
|
"""One flow step from `sigma` to `to` given the picture guess `x0`; `eta` of the next point's noise is fresh `noise`."""
|
|
sigma = sigma.to(device=x.device, dtype=x.dtype)
|
|
to = to.to(device=x.device, dtype=x.dtype)
|
|
implied = (x - (1 - sigma) * x0) / sigma
|
|
if eta > 0.0:
|
|
implied = math.sqrt(max(0.0, 1.0 - eta * eta)) * implied + eta * noise
|
|
return (1 - to) * x0 + to * implied
|
|
|
|
|
|
def make(settings):
|
|
"""The sampler function. It reads the stage from the guider behind `model` (set by Two stages), so the same
|
|
function serves the registered `funui_turbo` sampler and the settings module."""
|
|
n_start, n_end = settings["noise_start"], settings["noise_end"]
|
|
order, tail = settings["second_order"], settings["polish"]
|
|
|
|
@torch.no_grad()
|
|
def sample(model, x, sigmas, extra_args=None, callback=None, disable=None, **_ignored):
|
|
extra_args = {} if extra_args is None else extra_args
|
|
guider = getattr(model, "inner_model", None)
|
|
stage = dit_hooks.stage_of(guider)
|
|
full = getattr(guider, dit_hooks.FULL, None)
|
|
total = (len(full) - 1) if full is not None else len(sigmas) - 1
|
|
n0 = total - (len(sigmas) - 1) if stage == 2 else 0 # global index of this run's first step
|
|
last_stage = stage != 1
|
|
steps = len(sigmas) - 1
|
|
log.once(f"turbo:{stage}:{steps}:{n0}", log.INFO, TITLE,
|
|
f"{'stage ' + str(stage) if stage else 'run'}: {steps} step(s) from step {n0 + 1} of {total}"
|
|
+ (", polish step after the last" if tail > 0 and last_stage else ""))
|
|
base = int(extra_args.get("seed") or 0)
|
|
parts_like = _parts(x)
|
|
gens = []
|
|
for p in parts_like:
|
|
g = torch.Generator(device=p.device)
|
|
g.manual_seed(base + 1000003 * (n0 + 1))
|
|
gens.append(g)
|
|
ones = x.new_ones([x.shape[0]])
|
|
before = before_h = None
|
|
used = x
|
|
for i in range(steps):
|
|
sigma, to = sigmas[i], sigmas[i + 1]
|
|
denoised = model(x, sigma * ones, **extra_args)
|
|
if callback is not None:
|
|
callback({"x": x, "i": i, "sigma": sigma, "sigma_hat": sigma, "denoised": denoised})
|
|
progress = (n0 + i) / max(1, total - 1)
|
|
eta = n_start + (n_end - n_start) * progress
|
|
h = float((sigma - to).abs())
|
|
d_parts = _parts(denoised)
|
|
used_parts = list(d_parts)
|
|
if order > 0.0 and before is not None and before_h > 1e-7 and h > 1e-7:
|
|
r = min(5.0, max(0.1, h / before_h))
|
|
used_parts[0] = d_parts[0] + order * progress * (d_parts[0] - before[0]) * (r / 2) # picture only
|
|
before, before_h = d_parts, h
|
|
used = _join(denoised, used_parts)
|
|
if float(to) == 0:
|
|
break
|
|
nxt = []
|
|
for k, (xp, up) in enumerate(zip(_parts(x), used_parts)):
|
|
e = eta if k == 0 else 0.0 # the sound gets no fresh noise
|
|
nxt.append(step_to(xp, up, sigma, to, e, _noise(xp, gens[k]) if e > 0.0 else None))
|
|
x = _join(x, nxt)
|
|
else:
|
|
return x
|
|
if tail > 0.0 and last_stage:
|
|
noisy = []
|
|
for k, up in enumerate(_parts(used)):
|
|
t = torch.as_tensor(tail, device=up.device, dtype=up.dtype)
|
|
noisy.append((1 - t) * up + t * _noise(up, gens[k]))
|
|
t = torch.as_tensor(tail, device=parts_like[0].device, dtype=parts_like[0].dtype)
|
|
used = model(_join(used, noisy), t * ones, **dit_hooks.after_last(extra_args)) # not a step: nothing is captured from it
|
|
return used
|
|
return sample
|
|
|
|
|
|
def churn(x, denoised, sigma, eta, noise):
|
|
"""The answer `denoised` of a model call at `sigma` on `x`, changed so that a sampler stepping from it takes in
|
|
some fresh noise. With D the answer and e = (x - (1 - sigma) D) / sigma the noise it implies, the new answer is
|
|
D + sigma * (e - e'), e' = sqrt(1 - eta^2) e + eta * noise. A sampler that steps along (x - D) / sigma, Euler for
|
|
one, then moves along e' - D instead of e - D. This is a nudge like Karras' churn, not the exact Turbo step: the
|
|
point's old noise stays in x, so the new point carries a little more noise than the Turbo step would give
|
|
(about 3% more noise at eta 0.2, 13% at 0.45, for a step from 0.7 to 0.4). Other samplers read it as a nearby
|
|
picture guess."""
|
|
sigma = torch.as_tensor(sigma, device=x.device, dtype=x.dtype).reshape(-1)[0]
|
|
implied = (x - (1 - sigma) * denoised) / sigma
|
|
keep = math.sqrt(max(0.0, 1.0 - eta * eta))
|
|
return denoised + sigma * ((1.0 - keep) * implied - eta * noise)
|
|
|
|
|
|
def make_on_top(inner, settings):
|
|
"""`inner` (the chosen sampler's own function) with Turbo's fresh noise on each step's answer and its polish
|
|
step after the run. The sampler is not replaced; it sees a model whose answers carry the changes."""
|
|
n_start, n_end, tail = settings["noise_start"], settings["noise_end"], settings["polish"]
|
|
|
|
@torch.no_grad()
|
|
def sample(model, x, sigmas, extra_args=None, callback=None, disable=None, **options):
|
|
extra_args = {} if extra_args is None else extra_args
|
|
guider = getattr(model, "inner_model", None)
|
|
stage = dit_hooks.stage_of(guider)
|
|
full = getattr(guider, dit_hooks.FULL, None)
|
|
steps = len(sigmas) - 1
|
|
total = (len(full) - 1) if full is not None else steps
|
|
n0 = total - steps if stage == 2 else 0
|
|
base = int(extra_args.get("seed") or 0)
|
|
gen = torch.Generator(device=_parts(x)[0].device)
|
|
gen.manual_seed(base + 1000003 * (n0 + 1))
|
|
ones = x.new_ones([x.shape[0]])
|
|
log.once(f"turbo_top:{stage}:{steps}:{n0}", log.INFO, TITLE,
|
|
f"{'stage ' + str(stage) if stage else 'run'}: the chosen sampler keeps its steps, fresh noise and polish on top")
|
|
|
|
class Churned:
|
|
def __getattr__(self, name):
|
|
return getattr(model, name)
|
|
|
|
def __call__(self, latent, sigma, **kwargs):
|
|
answer = model(latent, sigma, **kwargs)
|
|
s0 = sigma.reshape(-1)[0]
|
|
hit = torch.nonzero(torch.isclose(sigmas.to(device=s0.device, dtype=s0.dtype), s0, rtol=1e-4))
|
|
if not len(hit):
|
|
return answer # a call between steps (a midpoint): left alone
|
|
i = int(hit[0])
|
|
if i >= steps or float(sigmas[i + 1]) == 0.0:
|
|
return answer # the last step must land on the clean picture
|
|
eta = n_start + (n_end - n_start) * ((n0 + i) / max(1, total - 1))
|
|
if eta <= 0.0:
|
|
return answer
|
|
lx, la = _parts(latent), _parts(answer)
|
|
la[0] = churn(lx[0], la[0], s0, eta, _noise(lx[0], gen)) # the picture only; the sound gets none
|
|
return _join(answer, la)
|
|
|
|
result = inner(Churned(), x, sigmas, extra_args=extra_args, callback=callback, disable=disable, **options)
|
|
if tail > 0.0 and stage != 1:
|
|
noisy = []
|
|
for p in _parts(result):
|
|
t = torch.as_tensor(tail, device=p.device, dtype=p.dtype)
|
|
noisy.append((1 - t) * p + t * _noise(p, gen))
|
|
t = torch.as_tensor(tail, device=_parts(result)[0].device, dtype=_parts(result)[0].dtype)
|
|
result = model(_join(result, noisy), t * ones, **dit_hooks.after_last(extra_args)) # not a step: nothing is captured from it
|
|
return result
|
|
return sample
|
|
|
|
|
|
def _settings(values):
|
|
return {name: float(values.get(name, spec["default"])) for name, spec in SETTINGS.items() if name != "enabled"}
|
|
|
|
|
|
_CURRENT = {} # the settings of the run in hand, for the registered sampler
|
|
|
|
|
|
def registered(model, x, sigmas, extra_args=None, callback=None, disable=None, **options):
|
|
"""`funui_turbo` in ComfyUI's sampler list: the settings when the module is on, else the defaults."""
|
|
return make(_CURRENT or _settings({}))(model, x, sigmas, extra_args=extra_args, callback=callback, disable=disable, **options)
|
|
|
|
|
|
def register():
|
|
"""Add the sampler to ComfyUI's own list (in place: its Sampler nodes share the list)."""
|
|
import comfy.k_diffusion.sampling as ksampling
|
|
import comfy.samplers
|
|
if "funui_turbo" not in comfy.samplers.SAMPLER_NAMES:
|
|
ksampling.sample_funui_turbo = registered
|
|
comfy.samplers.SAMPLER_NAMES.append("funui_turbo")
|
|
|
|
|
|
register()
|
|
|
|
|
|
def install(patcher, values, key):
|
|
_CURRENT.clear()
|
|
if not values.get("enabled"):
|
|
return None
|
|
settings = _settings(values)
|
|
_CURRENT.update(settings)
|
|
|
|
def sampler_sample(executor, guider, sigmas, *args, **kwargs):
|
|
sampler = executor.class_obj
|
|
inner = getattr(sampler, "sampler_function", None)
|
|
if inner is None:
|
|
log.once("turbo:shape", log.INFO, TITLE, "off this run: the sampler is not a plain sampler function")
|
|
return executor(guider, sigmas, *args, **kwargs)
|
|
sampler.sampler_function = (make_on_top(inner, settings) if settings["keep_sampler"] else make(settings)) # swapped for this call and passed ON: later wrappers still run
|
|
try:
|
|
return executor(guider, sigmas, *args, **kwargs)
|
|
finally:
|
|
sampler.sampler_function = inner
|
|
|
|
patcher.add_wrapper_with_key(WrappersMP.SAMPLER_SAMPLE, key, sampler_sample)
|
|
kept = bool(settings["keep_sampler"])
|
|
return (f"fresh noise {settings['noise_start']:g} to {settings['noise_end']:g}"
|
|
+ ("" if kept else f", second order {settings['second_order']:g}") + (", on the chosen sampler" if kept else "")
|
|
+ (f", polish at {settings['polish']:g}" if settings["polish"] > 0 else ""))
|
|
|
|
|
|
PROVIDES = {"modifier": install}
|