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>
465 lines
18 KiB
Python
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 |