Files
JasonHoku-ComfyUI-Ultimate-…/batch_encoding.py
T
Jason HokuandClaude Fable 5.1 198138e51c perf(encode): prepare the text encoder once per pre-encode loop, not once per prompt
ComfyUI's CLIP.encode_from_tokens() calls load_model() -> model_management.load_models_gpu()
on every encode. With the dynamic VRAM loader (comfy_aimdo, 0.4x+) that re-runs
ModelPatcherDynamic.load() for a model that is already resident: a walk over every module plus
free_memory bookkeeping, visible as one

    Model MOSSImageTEModel_ prepared for dynamic VRAM loading. 7002MB Staged. ...

log line per prompt (N prompts -> N+1 lines). USCG's pre-encode paid it once per prompt, and
again on the single-encode paths (model switch, distributed, upscale, Florence2 hires).

Measured on the 5060 Ti (ComfyUI 0.42.5), MOSS-Image TE = Qwen3.5-9B w4a8, same process state:
  template path (CLIPTextEncode), per prompt ....... 0.92 s   (re-prepare every encode)
  USCG batch encode with pinned_clip, per prompt ... 0.84 s   (one prepare per pass)
  USCG job, 21 prompts + 21 one-step images ........ 36.8 s -> 33.0 s
  for scale: SDXL 0.15 s, Krea 2 0.29 s, Qwen-Image 0.32 s per prompt on the template path
The rest is the encoder forward itself: ~0.55 s fixed + ~2 ms/token for Qwen3.5-9B (24 of its
32 layers are Gated DeltaNet run through the pure-torch chunked recurrence, fp32 compute), so
a 60-prompt MOSS grid still spends ~50 s encoding before the first image. That part is
identical to the official workflow.

batch_encoding.pinned_clip(clip, texts): load once through load_model() (sized by the longest
text so any memory estimate still runs), shadow clip.load_model with a no-op for the block,
restore on exit or exception, nesting safe. encode_pair_cached(): positive+negative with one
load, nothing when both are cached. The pre-encode summary line now prints total time and
ms/prompt.

Applied to batch_encode_with_cache, the model-switch pre-encode in the main loop, the
distributed group pre-encode and per-job encode, the deferred-upscale re-encode fallback, the
dashboard upscale runner and the Florence2 hires step.

Tests: tests/test_pinned_clip.py (10) against the real batch_encoding module; the conftest
stub gained pinned_clip/encode_pair_cached. 137 passed, same 15 pre-existing torch-stub failures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 22:16:43 -08:00

465 lines
18 KiB
Python

"""
Batch CLIP Encoding Module
Handles efficient batch encoding of prompts with caching.
Supports prompt combinators: AND, CAT, AVG(), BREAK (native).
"""
import contextlib
import re
import time
import torch
import gc
import comfy.model_management
# ============================================================
# === KEEP THE TEXT ENCODER RESIDENT ACROSS A RUN OF ENCODES ===
# ============================================================
@contextlib.contextmanager
def pinned_clip(clip_model, sample_texts=None):
"""
Load a CLIP/text encoder ONCE and keep ComfyUI from re-preparing it on every
encode inside the block.
comfy.sd.CLIP.encode_from_tokens() calls self.load_model() ->
comfy.model_management.load_models_gpu([patcher]) on EVERY encode. Since the
dynamic VRAM loader (comfy_aimdo, ComfyUI 0.4x+) load_models_gpu() re-runs
ModelPatcherDynamic.load() even for a model that is already resident: a Python
walk over every module of the encoder plus free_memory()/get_free_memory()
bookkeeping. That is the repeated
Model MOSSImageTEModel_ prepared for dynamic VRAM loading. 7002MB Staged. ...
log line, once per prompt (N prompts -> N+1 lines). For a ~500-module LLM text
encoder (Qwen3.5-9B for MOSS-Image, Qwen2.5-VL for Qwen-Image, Qwen3-VL for
Krea 2) the re-prepare costs about as much as the encode itself; SDXL's small
CLIPs hide it, which is why SDXL pre-encoding always looked fast.
Inside this context the encoder is loaded once through the normal load_model()
path (sized by the longest of ``sample_texts`` so any memory estimate still
runs) and ``clip_model.load_model`` is shadowed by a no-op returning the
already-loaded patcher. Nothing in a plain encode loop touches model
management, so the encoder stays resident. The shadow is removed on exit, even
on exceptions, so the CLIP behaves normally afterwards (sampling may then evict
it as usual). Nesting is safe: the inner block restores the outer shadow.
"""
if clip_model is None or not hasattr(clip_model, "load_model"):
yield clip_model
return
tokens = {}
if sample_texts:
try:
longest = max(sample_texts, key=len)
tokens = clip_model.tokenize(longest)
except Exception:
tokens = {}
try:
patcher = clip_model.load_model(tokens)
except TypeError:
patcher = clip_model.load_model() # very old ComfyUI: load_model() took no tokens
if patcher is None:
patcher = getattr(clip_model, "patcher", None)
inst = vars(clip_model)
had_shadow = "load_model" in inst
previous = inst.get("load_model")
clip_model.load_model = lambda tokens={}: patcher
try:
yield clip_model
finally:
if had_shadow:
clip_model.load_model = previous
else:
inst.pop("load_model", None)
def encode_pair_cached(clip_model, cache, positive, negative, clip_skip=0):
"""
Fill cache["positive"][positive] / cache["negative"][negative] if missing,
loading the encoder at most once for the pair, and return (pos_cond, neg_cond).
Does not touch the encoder at all when both are already cached.
"""
need_pos = positive not in cache["positive"]
need_neg = negative not in cache["negative"]
if need_pos or need_neg:
with pinned_clip(clip_model):
if need_pos:
cache["positive"][positive] = encode_prompt_with_combinators(clip_model, positive, clip_skip)
if need_neg:
cache["negative"][negative] = encode_prompt_with_combinators(clip_model, negative, clip_skip)
return cache["positive"][positive], cache["negative"][negative]
# ============================================================
# === PROMPT COMBINATOR ENCODING (AND / CAT / AVG / BREAK) ===
# ============================================================
def _encode_single(clip_model, text, clip_skip=0):
"""
Encode a single text segment. BREAK is handled natively by clip.tokenize().
Args:
clip_model: CLIP model instance
text: Prompt text (may contain BREAK keyword)
clip_skip: CLIP layer skip value
Returns:
Conditioning list: [[cond_tensor, pooled_dict]]
"""
original_layer = None
if clip_skip != 0 and hasattr(clip_model.cond_stage_model, 'clip_layer'):
original_layer = clip_model.cond_stage_model.clip_layer
clip_model.cond_stage_model.set_clip_options({"layer": clip_skip})
tokens = clip_model.tokenize(text) # BREAK handled natively here
pooled_dict = clip_model.encode_from_tokens(tokens, return_pooled=True, return_dict=True)
cond = pooled_dict.pop("cond")
result = [[cond, pooled_dict]]
if original_layer is not None:
clip_model.cond_stage_model.set_clip_options({"layer": original_layer})
return result
def _parse_and_segments(prompt):
"""
Split prompt on AND keyword. Returns list of (text, weight) tuples.
Examples:
"a cat AND a dog" -> [("a cat", 1.0), ("a dog", 1.0)]
"a cat :1.5 AND a dog :0.5" -> [("a cat", 1.5), ("a dog", 0.5)]
"a cat AND a dog :0" -> [("a cat", 1.0), ("a dog", 0.0)]
"""
parts = re.split(r'\s+AND\s+', prompt)
segments = []
for part in parts:
part = part.strip()
# Check for trailing weight like "cat :1.5" (space before colon distinguishes from (word:weight) syntax)
weight_match = re.match(r'^(.*?)\s+:(\d+\.?\d*)\s*$', part)
if weight_match:
segments.append((weight_match.group(1).strip(), float(weight_match.group(2))))
else:
segments.append((part, 1.0))
return segments
def _parse_avg_segments(text):
"""
Split text on AVG(weight) markers. Returns list of (text, avg_weight) tuples.
The first segment has weight=None (it's the base). Subsequent segments have their AVG weight.
Examples:
"realistic AVG(0.3) anime" -> [("realistic", None), ("anime", 0.3)]
"a AVG() b" -> [("a", None), ("b", 0.5)] (default weight 0.5)
"no avg here" -> [("no avg here", None)]
"""
parts = re.split(r'\s*AVG\((\d*\.?\d*)\)\s*', text)
if len(parts) == 1:
return [(text, None)] # No AVG found
segments = []
for i in range(0, len(parts), 2):
segment_text = parts[i].strip() if i < len(parts) else ""
if not segment_text:
continue
if i == 0:
# First segment is the base — no weight
segments.append((segment_text, None))
else:
# Subsequent segments get the weight from the preceding capture group
weight_str = parts[i - 1] if i - 1 < len(parts) else ""
weight = float(weight_str) if weight_str else 0.5
segments.append((segment_text, weight))
return segments if segments else [(text, None)]
def _parse_cat_segments(text):
"""
Split text on CAT keyword. Returns list of text segments.
Example:
"a cat CAT in a garden" -> ["a cat", "in a garden"]
"""
return [s.strip() for s in re.split(r'\s+CAT\s+', text) if s.strip()]
def _conditioning_combine(cond1, cond2):
"""
ConditioningCombine: list concatenation.
Each conditioning entry becomes a separate guidance signal during sampling.
Same as ComfyUI's ConditioningCombine node (nodes.py).
"""
return cond1 + cond2
def _conditioning_concat(cond_to, cond_from):
"""
ConditioningConcat: tensor concatenation along dim 1.
Extends the token context window by appending from-conditioning tokens.
Same as ComfyUI's ConditioningConcat node (nodes.py).
"""
out = []
cond_from_tensor = cond_from[0][0]
for i in range(len(cond_to)):
t1 = cond_to[i][0]
tw = torch.cat((t1, cond_from_tensor), 1)
n = [tw, cond_to[i][1].copy()]
out.append(n)
return out
def _conditioning_average(cond_to, cond_from, strength):
"""
ConditioningAverage: weighted blend of conditioning tensors.
strength=1.0 means 100% cond_to, strength=0.0 means 100% cond_from.
Same as ComfyUI's ConditioningAverage node (nodes.py).
"""
out = []
cond_from_tensor = cond_from[0][0]
pooled_from = cond_from[0][1].get("pooled_output", None)
for i in range(len(cond_to)):
t1 = cond_to[i][0]
pooled_to = cond_to[i][1].get("pooled_output", pooled_from)
t0 = cond_from_tensor[:, :t1.shape[1]]
if t0.shape[1] < t1.shape[1]:
t0 = torch.cat([t0, torch.zeros((1, t1.shape[1] - t0.shape[1], t1.shape[2]))], dim=1)
tw = torch.mul(t1, strength) + torch.mul(t0, 1.0 - strength)
t_to = cond_to[i][1].copy()
if pooled_from is not None and pooled_to is not None:
t_to["pooled_output"] = torch.mul(pooled_to, strength) + torch.mul(pooled_from, 1.0 - strength)
elif pooled_from is not None:
t_to["pooled_output"] = pooled_from
out.append([tw, t_to])
return out
def encode_prompt_with_combinators(clip_model, prompt, clip_skip=0):
"""
Encode a prompt that may contain AND, CAT, or AVG() combinators.
Processing order (matching comfyui-prompt-control):
1. Split on AND (creates separate conditioning entries via ConditioningCombine)
2. Within each AND-segment, process AVG(weight) (weighted blend via ConditioningAverage)
3. Within each segment, process CAT (tensor concat via ConditioningConcat)
4. BREAK is handled natively by clip.tokenize() within each segment
Syntax:
- "prompt1 AND prompt2" → ConditioningCombine
- "prompt1 AND prompt2 :0.5" → AND with weight scaling
- "prompt1 CAT prompt2" → ConditioningConcat
- "prompt1 AVG(0.3) prompt2" → ConditioningAverage (0.3 = 30% prompt2)
- "prompt1 BREAK prompt2" → Separate 77-token chunks (native)
Args:
clip_model: CLIP model instance
prompt: Prompt text possibly containing combinators
clip_skip: CLIP layer skip value
Returns:
Conditioning list: [[cond_tensor, pooled_dict], ...]
"""
# Fast path: if no combinators present, use direct encoding
if ' AND ' not in prompt and ' CAT ' not in prompt and 'AVG(' not in prompt:
return _encode_single(clip_model, prompt, clip_skip)
# 1. Split on AND
and_segments = _parse_and_segments(prompt)
all_conditionings = []
for segment_text, and_weight in and_segments:
if and_weight == 0:
continue # Skip zero-weighted AND segments
# 2. Process AVG() within this AND-segment
avg_segments = _parse_avg_segments(segment_text)
if len(avg_segments) > 1:
# Has AVG — encode first segment as base, then blend with subsequent segments
result = _encode_single(clip_model, avg_segments[0][0], clip_skip)
for avg_text, avg_weight in avg_segments[1:]:
next_cond = _encode_single(clip_model, avg_text, clip_skip)
w = avg_weight if avg_weight is not None else 0.5
result = _conditioning_average(result, next_cond, w)
else:
# 3. Process CAT within this segment
cat_parts = _parse_cat_segments(segment_text)
if len(cat_parts) > 1:
result = _encode_single(clip_model, cat_parts[0], clip_skip)
for cat_text in cat_parts[1:]:
next_cond = _encode_single(clip_model, cat_text, clip_skip)
result = _conditioning_concat(result, next_cond)
else:
result = _encode_single(clip_model, segment_text, clip_skip)
# Apply AND weight by scaling the conditioning tensor
if and_weight != 1.0:
result = [[torch.mul(r[0], and_weight), r[1]] for r in result]
all_conditionings.append(result)
# 4. Combine all AND segments via ConditioningCombine
if not all_conditionings:
return _encode_single(clip_model, "", clip_skip)
final = all_conditionings[0]
for additional in all_conditionings[1:]:
final = _conditioning_combine(final, additional)
return final
def batch_encode_with_cache(clip_model, prompts, cond_cache, prompt_type="positive", batch_size=64, clip_skip=0):
"""
Batch encode prompts while checking persistent cache first.
Only encodes prompts that aren't already cached.
Args:
clip_model: CLIP model to use for encoding
prompts: Set or list of prompt strings to encode
cond_cache: ConditioningCache instance
prompt_type: "positive" or "negative"
batch_size: Number of prompts to encode at once
clip_skip: Number of CLIP layers to skip from the end (0 = use last layer, -1 = skip 1 layer, -2 = skip 2 layers)
Returns:
dict: Mapping of prompt text to conditioning tensors
"""
results = {}
prompts_to_encode = []
# Check cache first
print(f"[GridTester] 🔍 Checking cache for {len(prompts)} {prompt_type} prompts...")
for prompt in prompts:
cached = cond_cache.get(prompt, prompt_type)
if cached is not None:
results[prompt] = cached
else:
prompts_to_encode.append(prompt)
cache_hits = len(results)
cache_misses = len(prompts_to_encode)
print(f"[GridTester] 📊 Cache: {cache_hits} hits, {cache_misses} misses")
# Batch encode uncached prompts
if prompts_to_encode:
clip_skip_msg = f" (clip_skip={clip_skip})" if clip_skip != 0 else ""
print(f"[GridTester] 🚀 Batch encoding {len(prompts_to_encode)} {prompt_type} prompts{clip_skip_msg} (batch_size={batch_size})")
# The encoder is loaded ONCE by pinned_clip (below) and stays resident for
# the whole loop; without it ComfyUI re-prepares it on every single encode.
_t_enc_start = time.time()
prompts_list = list(prompts_to_encode)
total_batches = (len(prompts_list) + batch_size - 1) // batch_size
with torch.no_grad(), pinned_clip(clip_model, prompts_list):
for batch_idx in range(0, len(prompts_list), batch_size):
batch_prompts = prompts_list[batch_idx:batch_idx + batch_size]
current_batch = (batch_idx // batch_size) + 1
# Encode batch
for prompt in batch_prompts:
# Check for interrupt before each prompt encoding
if comfy.model_management.processing_interrupted():
print(f"\n[GridTester] 🛑 INTERRUPTED during {prompt_type} encoding - Stopping all encoding")
raise comfy.model_management.InterruptProcessingException()
try:
# Encode with combinator support (AND/CAT/AVG/BREAK)
conditioning = encode_prompt_with_combinators(clip_model, prompt, clip_skip)
results[prompt] = conditioning
cond_cache.set(prompt, conditioning, prompt_type)
except comfy.model_management.InterruptProcessingException:
print(f"\n[GridTester] 🛑 INTERRUPTED during {prompt_type} encoding - Stopping all encoding")
raise # Re-raise to stop all encoding
except Exception as e:
print(f"[GridTester] ⚠️ Failed to encode {prompt_type} prompt: {e}")
print(f"[GridTester] ⚠️ Prompt was: {prompt[:80]}...")
import traceback
traceback.print_exc()
# Store None so we can detect this downstream with a clear error
results[prompt] = None
# Progress
encoded_count = min(batch_idx + batch_size, len(prompts_list))
if current_batch % 5 == 0 or current_batch == total_batches:
print(f"[GridTester] Batch {current_batch}/{total_batches} | Encoded {encoded_count}/{len(prompts_list)}")
_enc_elapsed = time.time() - _t_enc_start
_per_prompt_ms = (_enc_elapsed / len(prompts_list) * 1000.0) if prompts_list else 0.0
print(f"[GridTester] ✅ Batch encoding complete! {len(prompts_list)} prompts in {_enc_elapsed:.1f}s "
f"({_per_prompt_ms:.0f} ms/prompt, encoder prepared once)")
return results
def batch_encode_prompts(patched_clip, unique_positives, unique_negatives, cond_cache, clip_skip=0, enable_disk_cache=True):
"""
Batch encode both positive and negative prompts.
Args:
patched_clip: Patched CLIP model
unique_positives: Set of unique positive prompts
unique_negatives: Set of unique negative prompts
cond_cache: ConditioningCache instance
clip_skip: Number of CLIP layers to skip from the end
enable_disk_cache: Whether to save cache to disk (passed through to cache)
Returns:
dict: conditioning_cache with "positive" and "negative" keys
"""
conditioning_cache = {"positive": {}, "negative": {}}
print(f"[GridTester] 🧠 Found {len(unique_positives)} unique positive prompts")
print(f"[GridTester] 🧠 Found {len(unique_negatives)} unique negative prompts")
# Use ComfyUI's model management to prevent memory leak warnings during batch encoding
# The model needs to stay loaded for the entire encoding loop
with torch.no_grad():
# Encode all positive prompts
print(f"[GridTester] 🧠 Encoding {len(unique_positives)} unique positive prompts...")
conditioning_cache["positive"] = batch_encode_with_cache(
patched_clip,
unique_positives,
cond_cache,
prompt_type="positive",
batch_size=16,
clip_skip=clip_skip
)
conditioning_cache["negative"] = batch_encode_with_cache(
patched_clip,
unique_negatives,
cond_cache,
prompt_type="negative",
batch_size=16,
clip_skip=clip_skip
)
# Save and print cache stats (only saves if disk cache is enabled)
if cond_cache is not None:
cond_cache.save()
# cond_cache.print_stats() # for debugging
# Final cleanup after all encoding is complete
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
print(f"[GridTester] ✅ All prompts pre-encoded!")
print(f"[GridTester] 💾 Cache size: {len(conditioning_cache['positive'])} positive, {len(conditioning_cache['negative'])} negative")
return conditioning_cache