Turbo sampler: stage-aware flow step, step spacing per stage, optional polish step; funui_turbo sampler and scheduler registered in Comfy

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tw9fjUCoaPNZ8uLoL2xACR
This commit is contained in:
Claude
2026-10-29 16:25:19 +00:00
parent aacab8cb7e
commit a351ca712a
5 changed files with 428 additions and 1 deletions
+1
View File
@@ -169,6 +169,7 @@ I express my deepest gratitude to:
- NVIDIA and the NVIDIA Video Effects SDK (`nvidia-vfx`, NVIDIA's own licence) — RTX Super Resolution in Upscale and RTX Frame Generation in Interpolate run on it; FunUI installs it only when you switch the add-on on;
- NVIDIA and the authors of Sol-H3 (arXiv 2609.35110, NVlabs/Sana, Apache-2.0) — the MXFP8 loader option (which blocks stay in bf16) and the Two stages setting (the cross-resolution schedule) follow its recipe;
- LBH-123-AI ([Comfyui_Minimax_h3_latent_Upscaler](https://github.com/LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler)) and TripleHeadedMonkey ([ComfyUI-MiniMaxH3_LatentUpscaler](https://github.com/TripleHeadedMonkey/ComfyUI-MiniMaxH3_LatentUpscaler)) — the H3 latent upscaler network (rebuilt from the published weights, not copied) and its use in Second pass and as the Two stages enlarge option;
- The Turbo sampler (stochastic flow step, polish step) rests on consistency models (Song, Dhariwal, Chen, Sutskever; arXiv 2303.01469), latent consistency models (Luo, Tan, Huang, Li, Zhao; arXiv 2310.04378) and the ancestral noise injection of k-diffusion by Katherine Crowson; its second-order late steps reuse Distilled Flow;
Without all of you, this project would've been impossible.
+4
View File
@@ -195,6 +195,10 @@ def current_step(transformer_options):
STAGE = "funui_stage"
# Set on the guider by Two stages while it runs: the whole schedule, of which a stage sees only its part.
FULL = "funui_full_sigmas"
def stage_of(guider):
"""1 or 2 while a Two stages run is in that stage, None for a plain run."""
return getattr(guider, STAGE, None)
+253
View File
@@ -0,0 +1,253 @@
"""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 three 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.
* Step spacing per stage: the steps inside a stage are moved earlier or later while the stage's first and
last sigma stay put, so the hand-over point never moves. 1 = the schedule as given.
* Polish step (optional): 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 chosen sampler with a step rule made for few-step turbo runs. No extra model calls "
"(except the optional polish step). Works with any step count and with Two stages.",
},
"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.5, "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.",
"when": {"enabled": True},
},
"spacing_first": {
"type": "float", "default": 1.0, "min": 0.5, "max": 2.0, "step": 0.05,
"label": "Step spacing, first stage", "ui": "slider",
"hint": "Moves the steps inside the first stage (with Two stages) or the whole run (without). Above 1 "
"pulls them toward the end of the stage, below 1 toward its start. 1 = as scheduled.",
"when": {"enabled": True},
},
"spacing_last": {
"type": "float", "default": 1.0, "min": 0.5, "max": 2.0, "step": 0.05,
"label": "Step spacing, last stage", "ui": "slider",
"hint": "The same for the stage at the full size. Ignored without Two stages.",
"when": {"enabled": True},
},
"polish": {
"type": "float", "default": 0.0, "min": 0.0, "max": 0.3, "step": 0.01,
"label": "Polish step sigma", "ui": "slider",
"hint": "0 = off. Otherwise one extra full-size model call after the last step, from this small noise "
"level. Costs one step (about 15 to 25% of a run). 0.05 is a start.",
"when": {"enabled": True},
},
}
def warp(sigmas, k):
"""The inner sigmas of a stage moved by `k`, the first and last kept. u runs 0..1 along the stage; u' = k u / (1 + (k - 1) u)."""
if k == 1.0 or len(sigmas) < 3:
return sigmas
first, last = sigmas[0], sigmas[-1]
span = first - last
if float(span.abs()) < 1e-9:
return sigmas
u = ((first - sigmas) / span).clamp(0.0, 1.0)
moved = k * u / (1.0 + (k - 1.0) * u)
out = first - span * moved
out[0], out[-1] = first, last
return out
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
sigmas = warp(sigmas, settings["spacing_last"] if stage == 2 else settings["spacing_first"])
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, **extra_args)
return used
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 schedule(model_sampling, steps):
"""`funui_turbo` in ComfyUI's scheduler list: the evenly spaced timesteps of `sgm_uniform`, the grid the turbo
merges are trained on. The step spacing per stage is applied by the sampler, which knows the stage."""
import comfy.samplers
return comfy.samplers.normal_scheduler(model_sampling, steps, sgm=True)
def register():
"""Add the sampler and the scheduler to ComfyUI's own lists (in place: its Sampler nodes share the lists)."""
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")
if "funui_turbo" not in comfy.samplers.SCHEDULER_HANDLERS:
comfy.samplers.SCHEDULER_HANDLERS["funui_turbo"] = comfy.samplers.SchedulerHandler(schedule)
comfy.samplers.SCHEDULER_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(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)
return (f"fresh noise {settings['noise_start']:g} to {settings['noise_end']:g}, second order {settings['second_order']:g}"
+ (f", polish at {settings['polish']:g}" if settings["polish"] > 0 else ""))
PROVIDES = {"modifier": install}
+169
View File
@@ -0,0 +1,169 @@
"""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, install, make, step_to, warp
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_warp_keeps_the_ends_and_order():
s = torch.tensor([1.0, 0.8, 0.6, 0.3, 0.0])
assert torch.equal(warp(s, 1.0), s)
for k in (0.6, 1.5, 2.0):
w = warp(s, k)
assert w[0] == s[0] and w[-1] == s[-1] and len(w) == len(s)
assert (w[:-1] > w[1:]).all()
assert warp(s, 2.0)[1] < s[1] # k > 1 pulls the inner steps toward the end of the stage
assert warp(s, 0.6)[1] > s[1]
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):
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("euler"), 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(spacing_first=1.3, spacing_last=0.8, polish=0.05)}
note, out, calls = _guided(model, values, sigmas, {"enabled": True, "size": 0.5, "steps": cut})
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) # the hand-over sigma did not move
def test_without_two_stages_and_without_polish(model):
values = {"enabled": True, **_settings()}
_, 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_lists_and_runs_like_the_module(model):
import comfy.samplers
from comfy.nested_tensor import NestedTensor
assert "funui_turbo" in comfy.samplers.SAMPLER_NAMES and "funui_turbo" in comfy.samplers.SCHEDULER_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"), "funui_turbo", 4)
assert torch.allclose(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()
+1 -1
View File
@@ -44,7 +44,7 @@ def _why_not(guider, latent_image, denoise_mask, sigmas, latent_shapes, steps):
return None
FULL = "funui_full_sigmas"
FULL = dit_hooks.FULL
def keep_whole_schedule(executor, guider, sigmas, extra_args, *args, **kwargs):