Files
JasonHoku-ComfyUI-Ultimate-…/tests/test_pinned_clip.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

201 lines
7.4 KiB
Python

"""The text encoder is prepared once per encode loop, not once per prompt.
REGRESSION (2026-11): comfy.sd.CLIP.encode_from_tokens() calls load_model() ->
model_management.load_models_gpu() on EVERY encode, and ComfyUI's dynamic VRAM
loader re-runs the patcher's load() walk even for a resident model (the repeated
"Model MOSSImageTEModel_ prepared for dynamic VRAM loading" log line, N prompts ->
N+1 lines). For the 7 GB Qwen3.5-9B encoder that was ~0.9 s per prompt, which made
USCG's pre-encode of 60 prompts take about a minute. pinned_clip() loads the
encoder once and shadows load_model for the duration of the loop.
"""
import contextlib
import importlib.util
import os
import sys
import types
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# tests/conftest.py registers a STUB `batch_encoding` for the orchestrator's relative
# imports, so load the real module file under a private name (same trick as
# test_latent_geometry.py does for model_loader).
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_PKG = os.path.basename(_ROOT)
_mm = sys.modules.get("comfy.model_management")
if _mm is not None and not hasattr(_mm, "processing_interrupted"):
_mm.processing_interrupted = lambda: False
if _mm is not None and "comfy" in sys.modules and not hasattr(sys.modules["comfy"], "model_management"):
sys.modules["comfy"].model_management = _mm # `import comfy.model_management` on stubs does not wire the attribute
_spec = importlib.util.spec_from_file_location(f"{_PKG}.batch_encoding_real", os.path.join(_ROOT, "batch_encoding.py"))
be = importlib.util.module_from_spec(_spec)
be.__package__ = _PKG
_spec.loader.exec_module(be)
if not hasattr(be.torch, "no_grad"):
# torch stub (no real torch in this interpreter): the fast path only needs these
be.torch = types.SimpleNamespace(no_grad=contextlib.nullcontext,
cuda=types.SimpleNamespace(is_available=lambda: False))
# ---- fakes -----------------------------------------------------------------
class FakeCLIP:
"""Mimics comfy.sd.CLIP: encode_from_tokens() calls self.load_model() every time."""
def __init__(self):
self.patcher = object()
self.load_calls = 0
self.load_tokens = []
self.encode_calls = 0
self.cond_stage_model = types.SimpleNamespace() # no clip_layer -> clip_skip branch is skipped
def tokenize(self, text):
return {"qwen": [[(len(text), 1.0)]]}
def load_model(self, tokens={}):
self.load_calls += 1
self.load_tokens.append(tokens)
return self.patcher
def encode_from_tokens(self, tokens, return_pooled=False, return_dict=False):
self.load_model(tokens)
self.encode_calls += 1
return {"cond": f"cond{self.encode_calls}", "pooled_output": "pooled"}
class OldCLIP(FakeCLIP):
"""Ancient ComfyUI: load_model() took no arguments."""
def load_model(self):
self.load_calls += 1
return self.patcher
def encode_from_tokens(self, tokens, return_pooled=False, return_dict=False):
self.load_model()
self.encode_calls += 1
return {"cond": "c", "pooled_output": "p"}
class MemoryCache:
def __init__(self):
self.store = {}
def get(self, prompt, prompt_type):
return self.store.get((prompt_type, prompt))
def set(self, prompt, cond, prompt_type):
self.store[(prompt_type, prompt)] = cond
TEXTS = ["a cat", "a dog on a very long sunny beach with umbrellas", "sky", "tea", "a bird"]
# ---- pinned_clip -----------------------------------------------------------
def test_many_encodes_load_the_encoder_once():
clip = FakeCLIP()
with be.pinned_clip(clip, TEXTS):
for t in TEXTS:
be._encode_single(clip, t)
assert clip.encode_calls == len(TEXTS)
assert clip.load_calls == 1, "encoder must be prepared once for the whole loop"
def test_shadow_is_removed_after_the_block():
clip = FakeCLIP()
with be.pinned_clip(clip, TEXTS):
be._encode_single(clip, "x")
assert "load_model" not in vars(clip)
be._encode_single(clip, "y")
assert clip.load_calls == 2, "normal per-encode load_model behaviour is back"
def test_shadow_is_removed_on_exception():
clip = FakeCLIP()
with pytest.raises(RuntimeError):
with be.pinned_clip(clip, TEXTS):
raise RuntimeError("interrupted")
assert "load_model" not in vars(clip)
be._encode_single(clip, "y")
assert clip.load_calls == 2
def test_nested_blocks_restore_the_outer_shadow():
clip = FakeCLIP()
with be.pinned_clip(clip, TEXTS):
outer = vars(clip)["load_model"]
with be.pinned_clip(clip):
be._encode_single(clip, "inner")
assert vars(clip)["load_model"] is outer
be._encode_single(clip, "outer")
assert "load_model" not in vars(clip)
# the inner block's load_model call hits the outer shadow, so the encoder is
# loaded exactly once for the whole nest and never per encode
assert clip.load_calls == 1
assert clip.encode_calls == 2
def test_single_load_is_sized_by_the_longest_prompt():
clip = FakeCLIP()
with be.pinned_clip(clip, TEXTS):
pass
longest = max(TEXTS, key=len)
assert clip.load_tokens == [clip.tokenize(longest)]
def test_none_and_non_clip_pass_through():
with be.pinned_clip(None) as c:
assert c is None
plain = object()
with be.pinned_clip(plain) as c:
assert c is plain
def test_old_load_model_signature_still_works():
clip = OldCLIP()
with be.pinned_clip(clip, TEXTS):
for t in TEXTS:
be._encode_single(clip, t)
assert clip.load_calls == 1
assert "load_model" not in vars(clip)
# ---- batch_encode_with_cache / encode_pair_cached --------------------------
def test_batch_encode_with_cache_prepares_the_encoder_once(capsys):
clip = FakeCLIP()
prompts = [f"prompt number {i} with some words" for i in range(10)]
results = be.batch_encode_with_cache(clip, prompts, MemoryCache(), prompt_type="positive", batch_size=4)
assert clip.encode_calls == 10
assert clip.load_calls == 1
assert set(results) == set(prompts)
cond = results[prompts[0]]
assert isinstance(cond, list) and cond[0][0] == "cond1" and cond[0][1] == {"pooled_output": "pooled"}
out = capsys.readouterr().out
assert "10 prompts in" in out and "ms/prompt" in out
assert "load_model" not in vars(clip)
def test_batch_encode_with_cache_skips_the_encoder_on_full_cache_hit():
clip = FakeCLIP()
cache = MemoryCache()
for p in TEXTS:
cache.set(p, [["cached", {}]], "positive")
results = be.batch_encode_with_cache(clip, TEXTS, cache, prompt_type="positive")
assert clip.load_calls == 0 and clip.encode_calls == 0
assert all(results[p] == [["cached", {}]] for p in TEXTS)
def test_encode_pair_cached_loads_once_and_never_on_hits():
clip = FakeCLIP()
cache = {"positive": {}, "negative": {}}
pos, neg = be.encode_pair_cached(clip, cache, "a cat", "blurry")
assert clip.load_calls == 1 and clip.encode_calls == 2
assert pos is cache["positive"]["a cat"] and neg is cache["negative"]["blurry"]
pos2, neg2 = be.encode_pair_cached(clip, cache, "a cat", "blurry")
assert (pos2, neg2) == (pos, neg)
assert clip.load_calls == 1, "both cached -> encoder not touched"
be.encode_pair_cached(clip, cache, "a dog", "blurry")
assert clip.load_calls == 2 and clip.encode_calls == 3, "only the missing half is encoded"