Files
ryanontheinside-ComfyUI_Rea…/stream_sampler.py
T
2025-02-17 16:55:24 -05:00

462 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import torch
from .base.control_base import ControlNodeBase
import comfy.model_management
import comfy.samplers
import random
import math
class StreamBatchSampler(ControlNodeBase):
@classmethod
def INPUT_TYPES(cls):
inputs = super().INPUT_TYPES()
inputs["required"].update({
"latent": ("LATENT",),
"num_timesteps": ("INT", {
"default": 2,
"min": 1,
"max": 10,
"step": 1,
"tooltip": "Number of denoising steps to use. More steps = better quality but MUCH slower. StreamDiffusion's speed comes from using just 2 steps for img2img (32,45) or 4 for txt2img (0,16,32,45)"
}),
"frame_buffer_size": ("INT", {
"default": 1,
"min": 1,
"max": 10,
"step": 1,
"tooltip": "How many frames to process together for temporal consistency. Each frame is processed at each timestep (total batch = frame_buffer_size * num_timesteps). Higher values reduce flickering but use more VRAM and don't improve speed"
}),
"cfg_type": (["none", "full", "self", "initialize"], {
"default": "self",
"tooltip": "'self' is fastest and most memory efficient, 'full' is standard SD behavior but slower, 'initialize' is a middle ground, 'none' disables guidance"
}),
"guidance_scale": ("FLOAT", {
"default": 1.2,
"min": 1.0,
"max": 20.0,
"step": 0.1,
"tooltip": "How closely to follow the prompt. StreamDiffusion works best with low values (1.2-1.5). Higher values = stronger prompt influence but more artifacts"
}),
"delta": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 5.0,
"step": 0.1,
"tooltip": "Only used with 'self' CFG. Controls strength of self-guidance. Higher values = stronger guidance but more artifacts. Default 1.0 works well"
}),
"similarity_threshold": ("FLOAT", {
"default": 0.98,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Skip frames that are too similar to save compute. 0 = process all frames. Try 0.98 if you want to skip static scenes. Higher = more skipping"
#TODO: see if this is backwards from original
}),
"max_skip_frames": ("INT", {
"default": 10,
"min": 1,
"max": 100,
"step": 1,
"tooltip": "Maximum frames to skip when using similarity filter. Prevents getting stuck on static scenes. Lower = more responsive to changes"
}),
})
inputs["optional"] = {
"prompt": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "Optional text prompt. Enables KV caching for faster processing. Leave empty to disable. Same prompt across frames = faster"
}),
}
return inputs
RETURN_TYPES = ("SAMPLER", "LATENT",)
FUNCTION = "update"
CATEGORY = "real-time/sampling"
DESCRIPTION="Implements batched denoising for faster inference by processing multiple frames in parallel at different denoising steps (StreamDiffusion)"
def __init__(self):
super().__init__()
self.frame_buffer_size = None
self.frame_buffer = None # Will be a tensor instead of list
self.x_t_latent_buffer = None
self.stock_noise = None
self.cached_noise = {} # Cache for different expanded versions
self.debug = False # Debug flag for print statements
self.last_batch_shape = None # Track last batch shape
# Add CFG state
self.cfg_type = "self"
self.guidance_scale = 1.2
self.delta = 1.0
# Add cross attention state
self.prompt = ""
self.last_prompt = None
self.kv_cache = None
self.cross_attention_hook = None
self.last_model_hash = None
# Add similarity filter state
self.similarity_threshold = 0.0
self.max_skip_frames = 10
self.similarity_filter = None
# Add buffer state
self.num_timesteps = None
# Add caching for alpha/beta
self.cached_sigmas = None
self.cached_alpha_prod_t = None
self.cached_beta_prod_t = None
# Add noise sequence and step counter
self.noise_sequence = None
self.step_counter = 0
def get_expanded_noise(self, batch_size, scale=1.0):
"""Get expanded noise with caching based on batch size and scale"""
cache_key = (batch_size, scale)
if self.stock_noise is None:
return None
if cache_key not in self.cached_noise:
self.cached_noise[cache_key] = self.stock_noise.unsqueeze(0).expand(batch_size, -1, -1, -1) * scale
if self.debug:
print(f"[StreamBatchSampler] Cached expanded noise for batch_size={batch_size}, scale={scale}")
return self.cached_noise[cache_key]
def invalidate_noise_cache(self):
"""Clear all cached noise when stock noise changes"""
self.cached_noise.clear()
def process_with_cfg(self, model, x, sigma_batch, extra_args):
"""Apply classifier-free guidance based on current cfg_type"""
# Handle no guidance cases first
if self.cfg_type == "none" or self.guidance_scale <= 1.0:
# No guidance - process batch normally
return model(x, sigma_batch, **extra_args)
# Apply CFG based on type
if self.cfg_type == "full":
# Double the batch for cond/uncond
x_double = torch.cat([x, x], dim=0)
sigma_double = torch.cat([sigma_batch, sigma_batch], dim=0)
model_output = model(x_double, sigma_double, **extra_args)
noise_pred_uncond, noise_pred_text = model_output.chunk(2)
elif self.cfg_type == "initialize":
# Add single uncond at start
x_plus_uc = torch.cat([x[0:1], x], dim=0)
sigma_plus_uc = torch.cat([sigma_batch[0:1], sigma_batch], dim=0)
model_output = model(x_plus_uc, sigma_plus_uc, **extra_args)
noise_pred_uncond = model_output[0:1]
noise_pred_text = model_output[1:]
# Update stock noise with uncond prediction
self.stock_noise = noise_pred_uncond[0].clone()
self.invalidate_noise_cache()
else: # self
# Use stock noise for uncond
model_output = model(x, sigma_batch, **extra_args)
noise_pred_text = model_output
#TODO: Eq.7 ϵ_cfg = δϵ̃ + γ(ϵ_c - δϵ̃)
noise_pred_uncond = self.get_expanded_noise(x.shape[0], scale=self.delta)
# Combine predictions with guidance
return noise_pred_uncond + self.guidance_scale * (noise_pred_text - noise_pred_uncond)
def setup_cross_attention(self, model, prompt):
"""Set up cross attention optimization for the model"""
if not prompt: # Don't use optimization if no prompt provided
return model
def optimized_attention_forward(module, x, context=None, mask=None):
"""Optimized cross attention with KV caching and ComfyUI's optimizations"""
batch_size = x.shape[0]
# Generate query from input
q = module.to_q(x)
# Check if we need to recompute KV cache
if self.kv_cache is None or self.last_prompt != prompt:
if self.debug:
print("[StreamBatchSampler] Computing new KV cache")
k = module.to_k(context)
v = module.to_v(context)
self.kv_cache = (k, v)
self.last_prompt = prompt
else:
if self.debug:
print("[StreamBatchSampler] Using cached KV pairs")
k, v = self.kv_cache
# Handle batch size expansion if needed
if k.shape[0] == 1 and batch_size > 1:
k = k.expand(batch_size, -1, -1)
v = v.expand(batch_size, -1, -1)
# Use ComfyUI's optimized attention
from comfy.ldm.modules.attention import optimized_attention
out = optimized_attention(q, k, v, module.heads, mask=mask)
return module.to_out[0](out)
# Get the actual model from the sampler
if hasattr(model, "model"):
unet = model.model
else:
return model
# Only set up hooks if prompt has changed
if self.last_prompt != prompt:
# Remove old hooks if they exist
if self.cross_attention_hook is not None:
self.cross_attention_hook.remove()
def create_cross_attn_patch(module, input, output):
"""Hook to replace cross attention modules with optimized version"""
if isinstance(module, torch.nn.Module) and hasattr(module, "to_q"):
if not hasattr(module, "_original_forward"):
module._original_forward = module.forward
module.forward = lambda *args, **kwargs: optimized_attention_forward(module, *args, **kwargs)
return output
# Register hooks on cross attention blocks
def register_hooks(module):
if isinstance(module, torch.nn.Module) and hasattr(module, "to_q"):
self.cross_attention_hook = module.register_forward_hook(create_cross_attn_patch)
unet.apply(register_hooks)
self.last_prompt = prompt
return model
def apply_similarity_filter(self, x_0_pred_out):
"""Apply similarity filter to skip similar frames if enabled"""
if not self.similarity_threshold > 0:
return x_0_pred_out
if self.similarity_filter is None:
from .controls.similar_image_filter import SimilarImageFilter
self.similarity_filter = SimilarImageFilter(
threshold=self.similarity_threshold,
max_skip_frame=self.max_skip_frames
)
filtered_out = self.similarity_filter(x_0_pred_out)
if filtered_out is not None:
return filtered_out
return x_0_pred_out
def compute_alpha_beta(self, sigmas):
"""Pre-compute alpha and beta terms for the given sigmas"""
self.cached_sigmas = sigmas.clone()
# Adjust alpha/beta calculation to use actual timesteps
self.cached_alpha_prod_t = torch.ones((self.num_timesteps,1,1,1), device=sigmas.device)
for i in range(self.num_timesteps):
self.cached_alpha_prod_t[i] = sigmas[i] / sigmas[0]
self.cached_beta_prod_t = (1 - self.cached_alpha_prod_t)
if self.debug:
print("[StreamBatchSampler] Computed new alpha/beta terms")
def sample(self, model, noise, sigmas, extra_args=None, callback=None, disable=None):
"""Sample with staggered batch denoising steps"""
extra_args = {} if extra_args is None else extra_args
# Set up cross attention optimization if prompt provided
if self.prompt:
model = self.setup_cross_attention(model, self.prompt)
# Get number of frames in batch and available sigmas
batch_size = noise.shape[0]
# Calculate total batch size based on frame_buffer_size and number of timesteps
total_batch_size = self.frame_buffer_size * self.num_timesteps
if batch_size != total_batch_size:
raise ValueError(f"Batch size ({batch_size}) must match frame_buffer_size * num_timesteps ({total_batch_size})")
# Pre-compute alpha and beta terms if not already computed
if self.cached_sigmas is None:
self.compute_alpha_beta(sigmas)
# Replace stock_noise initialization with sequence-based
if self.stock_noise is None:
# Get current step's noise from pre-generated sequence
current_noise = self.noise_sequence[self.step_counter % self.num_timesteps]
self.stock_noise = current_noise.clone()
# Update step counter for next frame
self.step_counter += 1
# Scale noise for each frame based on its sigma - vectorized with caching
sigma_scaled_noise = self.get_expanded_noise(batch_size)
x = noise + sigma_scaled_noise * sigmas[:self.num_timesteps].view(-1, 1, 1, 1)
# Initialize frame buffer if needed
if self.x_t_latent_buffer is None and self.num_timesteps > 1:
self.x_t_latent_buffer = x[0].clone() # Initialize with noised first frame
# Use buffer for first frame to maintain temporal consistency
if self.num_timesteps > 1:
x = torch.cat([self.x_t_latent_buffer.unsqueeze(0), x[1:]], dim=0)
# Run model on entire batch at once
with torch.no_grad():
# Process all frames in parallel
sigma_batch = sigmas[:self.num_timesteps]
# Process with CFG handling inside process_with_cfg
denoised_batch = self.process_with_cfg(model, x, sigma_batch, extra_args)
# Update buffer with intermediate results
if self.num_timesteps > 1:
# Store result from first frame as buffer for next iteration
self.x_t_latent_buffer = denoised_batch[0].clone()
# Return result from last frame
x_0_pred_out = denoised_batch[-1].unsqueeze(0)
else:
x_0_pred_out = denoised_batch
self.x_t_latent_buffer = None
# Apply similarity filter if enabled
x_0_pred_out = self.apply_similarity_filter(x_0_pred_out)
# Call callback if provided
if callback is not None:
callback({'x': x_0_pred_out, 'i': 0, 'sigma': sigmas[0], 'sigma_hat': sigmas[0], 'denoised': x_0_pred_out})
return x_0_pred_out
def buffer(self, latent, frame_buffer_size=1, num_timesteps=2, always_execute=True):
"""Add new frame to buffer and return batch when ready"""
self.frame_buffer_size = frame_buffer_size
self.num_timesteps = num_timesteps
# Extract latent tensor from input and remove batch dimension if present
x = latent["samples"]
if x.dim() == 4: # [B,C,H,W]
x = x.squeeze(0) # Remove batch dimension -> [C,H,W]
# Initialize or resize frame buffer if needed
if self.frame_buffer is None or self.frame_buffer.shape[0] != self.frame_buffer_size:
# Pre-allocate tensor buffer
self.frame_buffer = torch.zeros((self.frame_buffer_size,) + x.shape, dtype=x.dtype, device=x.device)
if self.debug:
print(f"[StreamFrameBuffer] Pre-allocated tensor buffer for {self.frame_buffer_size} frames")
# Rotate frames and add new frame using tensor operations
if self.frame_buffer_size > 1:
self.frame_buffer = torch.roll(self.frame_buffer, -1, dims=0)
self.frame_buffer[-1] = x
# Create batch by repeating frames for each timestep
batch = self.frame_buffer.repeat_interleave(self.num_timesteps, dim=0) # [buffer_size*num_timesteps,C,H,W]
# Return as latent dict
return {"samples": batch}
def update(self, latent, num_timesteps, frame_buffer_size=1, cfg_type="self", guidance_scale=1.2, delta=1.0, prompt="",
similarity_threshold=0.0, max_skip_frames=10, always_execute=True):
"""Create sampler with specified settings and process frame"""
self.frame_buffer_size = frame_buffer_size
self.cfg_type = cfg_type
self.guidance_scale = guidance_scale
self.delta = delta
self.prompt = prompt
self.num_timesteps = num_timesteps
# Update similarity filter settings
self.similarity_threshold = similarity_threshold
self.max_skip_frames = max_skip_frames
if self.similarity_filter is not None:
self.similarity_filter.set_threshold(similarity_threshold)
self.similarity_filter.set_max_skip_frame(max_skip_frames)
# Create sampler with cusatom sample function
sampler = comfy.samplers.KSAMPLER(self.sample)
# Process frame
buffered = self.buffer(latent)
# Add noise sequence initialization
if self.noise_sequence is None or self.noise_sequence.shape[0] != num_timesteps:
latent_shape = latent["samples"].shape[1:] # [C,H,W]
self.noise_sequence = torch.randn(
(num_timesteps,) + latent_shape, # Proper size format
device=comfy.model_management.get_torch_device(),
dtype=latent["samples"].dtype
)
return (sampler, buffered)
class StreamScheduler(ControlNodeBase):
"""Implements StreamDiffusion's efficient timestep selection"""
RETURN_TYPES = ("SIGMAS",)
FUNCTION = "update"
CATEGORY = "real-time/sampling"
def __init__(self):
super().__init__()
self.cached_indices = None
self.last_t_index_list = None
@classmethod
def INPUT_TYPES(cls):
inputs = super().INPUT_TYPES()
inputs["required"].update({
"model": ("MODEL",),
"t_index_list": ("STRING", {
"default": "32,45",
"tooltip": "Comma-separated list of timesteps to actually use for denoising. For LCM: use '32,45' for img2img or '0,16,32,45' for txt2img. For SDXL Turbo: use '45,49' for 2 steps or '49' for 1 step (Turbo works best with steps near the end of denoising)"
}),
"num_inference_steps": ("INT", {
"default": 50,
"min": 1,
"max": 1000,
"step": 1,
"tooltip": "Total number of timesteps in schedule. StreamDiffusion uses 50 by default. Only timesteps specified in t_index_list are actually used."
}),
})
return inputs
def parse_timesteps(self, t_index_list):
"""Parse and sort timesteps, with caching"""
if t_index_list == self.last_t_index_list and self.cached_indices is not None:
return self.cached_indices
try:
# Parse and sort in reverse order once
indices = sorted([int(t.strip()) for t in t_index_list.split(",")], reverse=True)
self.last_t_index_list = t_index_list
self.cached_indices = indices
return indices
except ValueError as e:
print(f"Error parsing timesteps: {e}. Using default [32,45]")
self.last_t_index_list = "32,45"
self.cached_indices = [45, 32] # Already sorted in reverse
return self.cached_indices
def update(self, model, t_index_list="32,45", num_inference_steps=50, always_execute=True):
# Get model's sampling parameters
model_sampling = model.get_model_object("model_sampling")
# Get parsed and sorted timesteps
t_indices = self.parse_timesteps(t_index_list)
# Create full schedule using normal scheduler
full_sigmas = comfy.samplers.normal_scheduler(model_sampling, num_inference_steps)
# Select only the sigmas at our desired indices
# No need to sort t_indices here since they're already sorted
selected_sigmas = []
for t in t_indices:
if t < 0 or t >= num_inference_steps:
print(f"Warning: timestep {t} out of range [0,{num_inference_steps}), skipping")
continue
selected_sigmas.append(float(full_sigmas[t]))
# Add final sigma
selected_sigmas.append(0.0)
# Create tensor directly on target device
device = comfy.model_management.get_torch_device()
selected_sigmas = torch.tensor(selected_sigmas, dtype=torch.float32, device=device)
return (selected_sigmas,)