Wan-AI/Wan2.2-I2V-A14B-Diffusers is the image-to-video sibling of the already supported T2V-A14B: the same dual-expert WanTransformer3DModel pair (boundary_ratio 0.9 in the pipeline config) behind WanImageToVideoPipeline, conditioning through the VAE latent (no CLIP-vision image encoder in this 2.2 variant). - New wan2.2-i2v-a14b family: image_conditioned flag, card recipe defaults (40 steps, CFG 3.5, 81 frames at 16 fps), the T2V memory table (57.2 GB both experts bf16), fp32-pinned VAE, and a wan2.2-i2v generation-defaults key ahead of the generic wan 50/5.0 entry. - Source-image plumbing: /video/generate takes init_image (base64/data URL); begin_generate 400s synchronously when an image-to-video family has no image or a text-only family is given one, and generate() decodes and resizes the image to the snapped output size before threading it as the pipeline's image kwarg. status() reports image_input so the UI can gate the control. - Trust the official -Diffusers repo for pipeline loads and transfer the measured wan quant recipes: fp8 keeps condition_embedder in bf16 (zero padding-row scale), mxfp8 and nvfp4 stay denied, the UMT5 auto TE quant resolves dense, and the balanced FBCache pin carries over. All tables share the T2V DiT profile. - Tests: family detection/aliases/defaults, the dual-DiT image pipeline load, the image gates on both begin_generate and generate, init_image route pass-through, and the quant exclude/deny/auto entries. GPU-verified on a B200: bf16 resident load (offload none, 72.8 GB peak) animating a conditioning image at 832x480/33f in 28.7 s with first-frame MAE 5.7 vs the source, and an int8 load with both experts quantized (43.1 GB peak, clean output).
825 lines
35 KiB
Python
825 lines
35 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Opt-in step caching for the diffusion transformer (First-Block-Cache).
|
|
|
|
Once a DiT's trajectory settles its output changes little across steps, so most of the
|
|
transformer can be reused. FBCache computes the first block and, if its residual barely changed
|
|
from the previous step (within ``threshold``), skips the rest and reuses their cached output.
|
|
diffusers ships it natively (``transformer.enable_cache(FirstBlockCacheConfig(...))``).
|
|
|
|
Measured on Flux.1-dev (28 steps, 1024px, B200): ~1.4x on top of torch.compile (2.83 -> 2.03 s)
|
|
at LPIPS ~0.08 -- deep inside the speed-for-quality bar.
|
|
|
|
OFF by default: the win scales with step count, so a few-step distilled model (Z-Image-Turbo
|
|
~8 steps) has almost no headroom and caching is for many-step models (Flux / Qwen-Image). It
|
|
composes with torch.compile only at ``fullgraph=False`` (the cache's compiler-disabled decision
|
|
is a graph break), which the speed layer switches to automatically. Best-effort: an incompatible
|
|
model is caught and the load proceeds uncached. torch / diffusers imported lazily.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Optional
|
|
|
|
TC_OFF = "off"
|
|
TC_AUTO = "auto"
|
|
TC_FBCACHE = "fbcache"
|
|
TC_MAGCACHE = "magcache"
|
|
TC_MODES = (TC_FBCACHE, TC_MAGCACHE)
|
|
|
|
# FBCache residual thresholds: higher skips more steps (faster, lower quality). Quantised
|
|
# transformers shift the residual distribution, so they need a higher threshold to trigger at
|
|
# all (per ParaAttention's fp8 guidance).
|
|
DEFAULT_FBCACHE_THRESHOLD = 0.08
|
|
QUANT_FBCACHE_THRESHOLD = 0.12
|
|
|
|
# MagCache (diffusers >= 0.39): skips whole steps from a PRE-CALIBRATED residual-magnitude curve
|
|
# with an accumulated-error budget, a skip cap, and an early-step no-skip retention window -- so
|
|
# unlike FBCache the divergence from the uncached trajectory is bounded. Measured
|
|
# (HunyuanVideo-1.5-720p, B200, 50 steps): threshold 0.12 = 1.5x e2e at LPIPS 0.147 vs the same
|
|
# uncached stack with the SAME composition (FBCache at its 0.08 default reached 2.4x but LPIPS 0.54:
|
|
# a brighter, different clip -- why the fbcache auto policy excludes this family).
|
|
DEFAULT_MAGCACHE_THRESHOLD = 0.12
|
|
MAGCACHE_MAX_SKIP_STEPS = 3
|
|
MAGCACHE_RETENTION_RATIO = 0.2
|
|
|
|
# ── cache quality presets ──────────────────────────────────────────────────────────
|
|
# User-facing speed/accuracy knob over the cache internals (threshold, skip cap, retention
|
|
# window). "balanced" = the pre-knob shipped behaviour; "quality" trades most of the
|
|
# speedup for a near-lossless clip; "fast" skips more aggressively. An explicit
|
|
# transformer_cache_threshold always overrides the preset threshold (the preset still
|
|
# supplies the magcache skip cap / retention window).
|
|
CQ_QUALITY = "quality"
|
|
CQ_BALANCED = "balanced"
|
|
CQ_FAST = "fast"
|
|
CACHE_QUALITY_LEVELS = (CQ_QUALITY, CQ_BALANCED, CQ_FAST)
|
|
|
|
# MagCache preset -> (threshold, max_skip_steps, retention_ratio). Calibrated on
|
|
# HunyuanVideo-1.5-720p (B200, 1280x720, 33 frames, 50 steps, pairwise LPIPS vs the same
|
|
# uncached trim+cudnn+compile stack): quality (0.06, 2, 0.3) = 1.64x at LPIPS 0.050 (30
|
|
# steps: 1.63x/0.093) vs balanced (0.12, 3, 0.2) = 2.17x at 0.129 (30 steps: 2.02x/0.201).
|
|
# Skip counts bind on the cap + retention window below threshold ~0.12, so quality tightens
|
|
# all three. Re-measured at the production 121-frame default (same protocol): quality =
|
|
# 1.69x/0.042 (720p) / 1.66x/0.018 (480p) -- the 33-frame operating points transfer.
|
|
_MAGCACHE_QUALITY_PRESETS: dict[str, tuple[float, int, float]] = {
|
|
CQ_QUALITY: (0.06, 2, 0.3),
|
|
CQ_BALANCED: (DEFAULT_MAGCACHE_THRESHOLD, MAGCACHE_MAX_SKIP_STEPS, MAGCACHE_RETENTION_RATIO),
|
|
CQ_FAST: (0.24, MAGCACHE_MAX_SKIP_STEPS, MAGCACHE_RETENTION_RATIO),
|
|
}
|
|
|
|
# FBCache preset -> threshold (dense, quant-active). "balanced" keeps the measured defaults
|
|
# (0.08 dense / 0.12 quantised); "quality" halves the trigger; "fast" uses the quantised
|
|
# threshold everywhere.
|
|
_FBCACHE_QUALITY_THRESHOLDS: dict[str, tuple[float, float]] = {
|
|
CQ_QUALITY: (0.04, 0.06),
|
|
CQ_BALANCED: (DEFAULT_FBCACHE_THRESHOLD, QUANT_FBCACHE_THRESHOLD),
|
|
CQ_FAST: (QUANT_FBCACHE_THRESHOLD, 0.15),
|
|
}
|
|
|
|
# Per-family FBCache threshold overrides: (family, preset) -> (dense, quant-active).
|
|
# Wan2.2-A14B: UNSET precision auto-promotes to fp8, so the generic quant promotion
|
|
# (0.08 -> 0.12) was the EFFECTIVE default -- but fb@0.12 measures LPIPS 0.128 (2.88x,
|
|
# dense probe, B200, 1280x720/33f/50 steps), far over the <= 0.08 quality gate balanced is
|
|
# held to (adjudication assumed the 0.08 point, 1.28x/0.098). No compliant faster point
|
|
# exists (fb@0.04 is SLOWER than uncached at 0.016; MagCache measured worse), so balanced
|
|
# pins 0.08 even with quant active. With the shipped fp8 quant engaged (production
|
|
# 1280x720/81f/50 steps, pairwise vs the same fp8 uncached load): fb@0.08 = 1.08x/0.129 vs
|
|
# the old fb@0.12 = 2.58x/0.181 -- fp8's residual noise trips the gate at ANY speedup, so
|
|
# 0.08 is the least-drift cache-on point, not a compliant one.
|
|
_FAMILY_FBCACHE_THRESHOLDS: dict[tuple[str, str], tuple[float, float]] = {
|
|
("wan2.2-t2v-a14b", CQ_BALANCED): (DEFAULT_FBCACHE_THRESHOLD, DEFAULT_FBCACHE_THRESHOLD),
|
|
# I2V-A14B shares the T2V DiT pair; inherit the balanced pin (same drift profile).
|
|
("wan2.2-i2v-a14b", CQ_BALANCED): (DEFAULT_FBCACHE_THRESHOLD, DEFAULT_FBCACHE_THRESHOLD),
|
|
}
|
|
|
|
|
|
def normalize_cache_quality(value: Optional[str]) -> Optional[str]:
|
|
"""Lower/strip a requested cache quality; None / "" / "auto" -> None (loader resolves
|
|
per family via ``auto_cache_quality``). Raises ValueError for an unsupported value."""
|
|
if value is None:
|
|
return None
|
|
normalized = str(value).strip().lower()
|
|
if not normalized or normalized == "auto":
|
|
return None
|
|
if normalized not in CACHE_QUALITY_LEVELS:
|
|
raise ValueError(
|
|
f"Unsupported transformer_cache_quality '{value}'. Use one of: auto, "
|
|
f"{', '.join(CACHE_QUALITY_LEVELS)}."
|
|
)
|
|
return normalized
|
|
|
|
|
|
# Families whose UNSET cache quality resolves to the near-lossless "quality" preset
|
|
# instead of "balanced". HunyuanVideo-1.5 (both repacks): quality = 1.63-1.64x at LPIPS
|
|
# 0.05 (50 steps) / 0.09 (30 steps) vs balanced's 2.02-2.17x at 0.13-0.20 -- most of the
|
|
# speedup at under half the drift. Families without a measured quality point keep balanced.
|
|
_FAMILY_AUTO_CACHE_QUALITY: dict[str, str] = {
|
|
"hunyuanvideo-1.5": CQ_QUALITY,
|
|
"hunyuanvideo-1.5-720p": CQ_QUALITY,
|
|
}
|
|
|
|
|
|
def auto_cache_quality(family: Optional[str]) -> str:
|
|
"""The cache quality preset an UNSET request resolves to for ``family``."""
|
|
return _FAMILY_AUTO_CACHE_QUALITY.get(str(family or "").strip().lower(), CQ_BALANCED)
|
|
|
|
|
|
# The auto policy's step-count bar: FBCache's win scales with step count (each skipped
|
|
# step is a larger quality hit on a short trajectory), so auto engages it only at 20+
|
|
# steps -- full "dev"-style schedules (28+) qualify, distilled turbo models (4-9) never do.
|
|
FBCACHE_MIN_STEPS = 20
|
|
|
|
|
|
# Per-family MagCache magnitude-ratio curves (MagCacheConfig.mag_ratios), calibrated with
|
|
# diffusers' calibrate mode on the family base checkpoints at the default 50-step schedule
|
|
# (720p clip, B200). Stable where it matters: the CFG cond/uncond branches differ by <=
|
|
# 0.014 and a 30-step calibration matches the 50-step curve within 0.027, so ONE
|
|
# (conditional) curve per family is enough -- diffusers interpolates it to the step count.
|
|
#
|
|
# Frame-count transfer VALIDATED at the production default (121 frames): the curves were
|
|
# calibrated on 33-frame clips, and recalibrating each family at 121f / 50 steps moves the
|
|
# curve by <= 0.024 max abs entry diff (hv720 0.019, hv480 0.021, wan5b 0.024) -- small
|
|
# enough that the skip schedule is UNCHANGED (33f-curve and fresh-121f-curve runs produced
|
|
# byte-identical frames), so the 33f curves ship as-is. Measured at 121f with the shipped
|
|
# curves (pairwise LPIPS vs the same-load uncached compiled stack): hv720 quality
|
|
# 1.69x/0.042, hv480 quality 1.66x/0.018, wan5b balanced 1.74x/0.026 -- each inside its gate.
|
|
_MAGCACHE_720P_RATIOS = (
|
|
1.0,
|
|
1.0226,
|
|
1.0093,
|
|
1.001,
|
|
1.0008,
|
|
1.0001,
|
|
0.9995,
|
|
1.0003,
|
|
0.9998,
|
|
0.9993,
|
|
0.9994,
|
|
0.9993,
|
|
0.9997,
|
|
1.0002,
|
|
0.9994,
|
|
0.9985,
|
|
0.9987,
|
|
0.9997,
|
|
0.9979,
|
|
0.9987,
|
|
0.9985,
|
|
0.9982,
|
|
0.9977,
|
|
0.998,
|
|
0.9979,
|
|
0.9971,
|
|
0.9968,
|
|
0.9967,
|
|
0.9964,
|
|
0.9965,
|
|
0.9959,
|
|
0.9954,
|
|
0.995,
|
|
0.9938,
|
|
0.9942,
|
|
0.9924,
|
|
0.9924,
|
|
0.9907,
|
|
0.9905,
|
|
0.9878,
|
|
0.9867,
|
|
0.9845,
|
|
0.9808,
|
|
0.9773,
|
|
0.9715,
|
|
0.9652,
|
|
0.9529,
|
|
0.9347,
|
|
0.9011,
|
|
0.83,
|
|
)
|
|
_MAGCACHE_480P_RATIOS = (
|
|
1.0,
|
|
1.0077,
|
|
1.0138,
|
|
1.0043,
|
|
1.0029,
|
|
0.9986,
|
|
0.9966,
|
|
1.0,
|
|
1.0006,
|
|
0.9996,
|
|
0.9993,
|
|
0.9986,
|
|
1.0,
|
|
0.9993,
|
|
0.9966,
|
|
0.9986,
|
|
0.9988,
|
|
0.9991,
|
|
0.998,
|
|
0.9977,
|
|
0.9976,
|
|
0.9971,
|
|
0.9973,
|
|
0.9969,
|
|
0.996,
|
|
0.9961,
|
|
0.9949,
|
|
0.9958,
|
|
0.9933,
|
|
0.9942,
|
|
0.9941,
|
|
0.9926,
|
|
0.9929,
|
|
0.9916,
|
|
0.9923,
|
|
0.9887,
|
|
0.99,
|
|
0.9882,
|
|
0.9865,
|
|
0.9833,
|
|
0.9827,
|
|
0.9791,
|
|
0.9763,
|
|
0.9718,
|
|
0.9657,
|
|
0.9563,
|
|
0.9454,
|
|
0.9264,
|
|
0.8967,
|
|
0.8382,
|
|
)
|
|
# Wan2.2-TI2V-5B, calibrated at 1280x704 / 33 frames / 50 steps (B200). Cond/uncond
|
|
# branches agree within 0.0008, so one (conditional) curve serves both CFG contexts.
|
|
_MAGCACHE_WAN5B_RATIOS = (
|
|
1.0,
|
|
0.9906,
|
|
0.9996,
|
|
0.9936,
|
|
0.9968,
|
|
0.9958,
|
|
0.9956,
|
|
0.9953,
|
|
0.9957,
|
|
0.9954,
|
|
0.9941,
|
|
0.9958,
|
|
0.9933,
|
|
0.9938,
|
|
0.9948,
|
|
0.9936,
|
|
0.9948,
|
|
0.9925,
|
|
0.994,
|
|
0.9927,
|
|
0.9913,
|
|
0.9919,
|
|
0.9918,
|
|
0.9907,
|
|
0.989,
|
|
0.9901,
|
|
0.9892,
|
|
0.9903,
|
|
0.9884,
|
|
0.9868,
|
|
0.9851,
|
|
0.9848,
|
|
0.9849,
|
|
0.9831,
|
|
0.9818,
|
|
0.9804,
|
|
0.9781,
|
|
0.9756,
|
|
0.9733,
|
|
0.9717,
|
|
0.9688,
|
|
0.9646,
|
|
0.9611,
|
|
0.9559,
|
|
0.9503,
|
|
0.9443,
|
|
0.938,
|
|
0.9315,
|
|
0.9227,
|
|
0.9208,
|
|
)
|
|
|
|
# All curves are calibrated at the 50-step schedule, so a single-DiT curve has 50 entries and
|
|
# MagCacheConfig interpolates it to the actual step count. A dual-expert MoE (Wan2.2-A14B) runs each
|
|
# expert on a SLICE of the schedule and the hook counts each expert's OWN forwards from 0, so each
|
|
# expert carries its own curve (keyed "family::transformer_2" for the second), sized to that
|
|
# expert's steps in the 50-step calibration; engage-time scales it by the requested step count (the
|
|
# boundary split is a fixed fraction of the schedule).
|
|
_MAGCACHE_CALIBRATION_STEPS = 50
|
|
|
|
_MAGCACHE_FAMILY_RATIOS: dict[str, tuple[float, ...]] = {
|
|
"hunyuanvideo-1.5": _MAGCACHE_480P_RATIOS,
|
|
"hunyuanvideo-1.5-720p": _MAGCACHE_720P_RATIOS,
|
|
"wan2.2-ti2v-5b": _MAGCACHE_WAN5B_RATIOS,
|
|
}
|
|
|
|
|
|
def _magcache_ratio_key(family: Optional[str], expert: Optional[str]) -> str:
|
|
"""The `_MAGCACHE_FAMILY_RATIOS` key for a (family, expert) pair: the bare family
|
|
name for the primary ``transformer``, ``family::expert`` for a second expert."""
|
|
fam = str(family or "").strip().lower()
|
|
exp = str(expert or "").strip().lower()
|
|
if exp in ("", "transformer"):
|
|
return fam
|
|
return f"{fam}::{exp}"
|
|
|
|
|
|
# Families whose AUTO step-cache decision engages MagCache instead of FBCache. On HunyuanVideo-1.5
|
|
# FBCache free-runs and derails the trajectory (LPIPS 0.54 + a luma shift at its default), while
|
|
# MagCache holds the same composition at 1.5x. On Wan2.2-TI2V-5B both stay composition-true but
|
|
# MagCache dominates (B200, 1280x704/33f/50 steps, pairwise LPIPS): balanced MagCache 1.65x/0.034 vs
|
|
# FBCache 0.08 at 1.49x/0.031, fast points 1.73x/0.044 vs 1.71x/0.083. On Wan2.2-A14B (dual-expert
|
|
# MoE) the OPPOSITE holds (B200, 1280x720/33f/50 steps, per-expert curves): FBCache 0.12 at
|
|
# 2.88x/0.128 dominates balanced MagCache (1.80x/0.145), and FBCache 0.08 at 1.28x/0.098 beats
|
|
# MagCache quality's 1.14x/0.074 -- the 16-step high-noise expert leaves MagCache too few forwards
|
|
# to skip within its budget -- so it keeps FBCache and ships no curve (an explicit magcache request
|
|
# runs uncached with a warning). Every other family keeps FBCache. An EXPLICIT "fbcache"/"magcache"
|
|
# request always wins.
|
|
_FAMILY_AUTO_CACHE_MODE: dict[str, str] = {
|
|
"hunyuanvideo-1.5": TC_MAGCACHE,
|
|
"hunyuanvideo-1.5-720p": TC_MAGCACHE,
|
|
"wan2.2-ti2v-5b": TC_MAGCACHE,
|
|
}
|
|
|
|
|
|
def auto_cache_mode(family: Optional[str]) -> str:
|
|
"""The cache mode the AUTO policy engages for ``family`` (mode only; the step-count bar
|
|
and the engage call are the caller's job). MagCache also needs a calibrated curve: a
|
|
family routed here without one runs uncached, never silently falls back to FBCache."""
|
|
return _FAMILY_AUTO_CACHE_MODE.get(str(family or "").strip().lower(), TC_FBCACHE)
|
|
|
|
|
|
def normalize_transformer_cache(value: Optional[str]) -> Optional[str]:
|
|
"""Lower/strip a cache mode; None / "" / "none" / "off" -> None, "auto" -> TC_AUTO (loader
|
|
decides from step count). Raises ValueError for an unsupported value."""
|
|
if value is None:
|
|
return None
|
|
normalized = str(value).strip().lower().replace("-", "_")
|
|
if not normalized or normalized in ("none", "off"):
|
|
return None
|
|
if normalized == TC_AUTO:
|
|
return TC_AUTO
|
|
if normalized not in TC_MODES:
|
|
raise ValueError(
|
|
f"Unsupported transformer_cache '{value}'. Use one of: off, auto, "
|
|
f"{', '.join(TC_MODES)}."
|
|
)
|
|
return normalized
|
|
|
|
|
|
# Transformer block classes whose FBCache metadata is missing from the installed diffusers.
|
|
# The hook reads each block's (hidden_states, encoder_hidden_states) return layout from
|
|
# TransformerBlockRegistry; diffusers 0.39 registers HunyuanVideo 1.0 but not 1.5, so
|
|
# enable_cache raises "not registered" on a DiT that is otherwise fully cache-compatible
|
|
# (CacheMixin, homogeneous residual-additive dual-stream blocks with the 1.0 return
|
|
# layout). Keyed by TRANSFORMER class name so only a family that needs it pays, and probed
|
|
# via TransformerBlockRegistry.get first so a newer diffusers makes this a no-op.
|
|
# transformer class -> ((block module, block class, hs index, ehs index), ...)
|
|
#
|
|
# LTX-2 is DELIBERATELY absent: its block is also unregistered but returns (hidden_states,
|
|
# audio_hidden_states) -- a JOINT video+audio stream -- while the cache hooks skip only
|
|
# ``hidden_states`` and, on a skipped step, feed ``encoder_hidden_states`` (TEXT) into the
|
|
# second slot. A naive registration would feed text into the next block's audio input.
|
|
# LTX-2 needs a dual-stream cache, not a metadata entry; until then it runs uncached (and
|
|
# the distilled LTX-2.3 checkpoints run 8-step schedules below FBCACHE_MIN_STEPS anyway).
|
|
_EXTRA_BLOCK_METADATA: dict[str, tuple[tuple[str, str, int, Optional[int]], ...]] = {
|
|
"HunyuanVideo15Transformer3DModel": (
|
|
(
|
|
"diffusers.models.transformers.transformer_hunyuan_video15",
|
|
"HunyuanVideo15TransformerBlock",
|
|
0,
|
|
1,
|
|
),
|
|
),
|
|
}
|
|
|
|
|
|
def _ensure_block_metadata_registered(transformer: Any, logger: Any = None) -> None:
|
|
"""Register the missing FBCache block metadata for ``transformer``'s family (see
|
|
``_EXTRA_BLOCK_METADATA``). Best-effort: a failure leaves enable_cache to raise and
|
|
the load runs uncached."""
|
|
specs = _EXTRA_BLOCK_METADATA.get(type(transformer).__name__)
|
|
if not specs:
|
|
return
|
|
try:
|
|
import importlib
|
|
|
|
from diffusers.hooks._helpers import TransformerBlockMetadata, TransformerBlockRegistry
|
|
for module_name, cls_name, hs_index, ehs_index in specs:
|
|
block_cls = getattr(importlib.import_module(module_name), cls_name)
|
|
try:
|
|
TransformerBlockRegistry.get(block_cls)
|
|
continue # a newer diffusers registers it natively
|
|
except ValueError:
|
|
pass
|
|
TransformerBlockRegistry.register(
|
|
block_cls,
|
|
TransformerBlockMetadata(
|
|
return_hidden_states_index = hs_index,
|
|
return_encoder_hidden_states_index = ehs_index,
|
|
),
|
|
)
|
|
if logger is not None:
|
|
logger.info("diffusion.cache: registered %s block metadata for fbcache", cls_name)
|
|
except Exception as exc: # noqa: BLE001 -- best-effort; enable_cache surfaces the real error
|
|
_warn(logger, "block metadata registration", exc)
|
|
|
|
|
|
def _invalidate_child_registry_cache(transformer: Any) -> None:
|
|
"""Drop the HookRegistry's cached child-registry list after (un)installing hooks.
|
|
|
|
``cache_context`` propagates state through ``_get_child_registries``, which diffusers 0.39
|
|
caches on first use. An uncached generation already calls it, creating an EMPTY cached child
|
|
list -- so a later ``enable_cache`` installs block hooks ``_set_context`` never reaches and the
|
|
first cached forward dies with "No context is set". Invalidate so the next ``cache_context``
|
|
rebuilds it over the freshly hooked blocks. Best-effort."""
|
|
registry = getattr(transformer, "_diffusers_hook", None)
|
|
if registry is not None and getattr(registry, "_child_registries_cache", None) is not None:
|
|
try:
|
|
registry._child_registries_cache = None
|
|
except Exception: # noqa: BLE001 -- diffusers internals moved; leave as-is
|
|
pass
|
|
|
|
|
|
# diffusers cache hook names whose compute branch we re-point at a compiled inner forward
|
|
# (leader = measuring first block, block = the rest); both share the fn_ref layout.
|
|
_CACHE_HOOK_NAMES = (
|
|
"mag_cache_leader_block_hook",
|
|
"mag_cache_block_hook",
|
|
"fbc_leader_block_hook",
|
|
"fbc_block_hook",
|
|
)
|
|
|
|
|
|
def _compile_hooked_block_inners(transformer: Any, logger: Any = None) -> int:
|
|
"""Restore the regional compile on cache-hooked blocks' COMPUTED steps.
|
|
|
|
``enable_cache`` replaces each block's ``forward`` with the hook's ``new_forward`` (stashing
|
|
the bound method in ``fn_ref.original_forward``), whose skip decision is data-dependent Python:
|
|
MagCache ``@torch.compiler.disable``s the whole thing (compute runs EAGER), and even FBCache's
|
|
traceable ``new_forward`` graph-breaks around its disabled decision, which on some archs
|
|
(Qwen-Image) drops the compute call out of the compiled region -- so ``_compiled_call_impl`` is
|
|
never reached and the cache forfeits the compile win on every computed step. A ``torch.compile``d
|
|
callable re-enables dynamo for its own extent even inside a disabled frame, so re-pointing
|
|
``original_forward`` at a compiled wrapper restores compiled compute steps while the skip
|
|
decision stays eager. Measured: Qwen-Image FBCache computed steps 91.8 -> 71.2 ms (uncached
|
|
compiled rate), 1.21x end-to-end; FLUX.1-dev neutral (its new_forward traces); video DiT
|
|
MagCache 39.4 -> 26.9 s at 50 steps.
|
|
|
|
Only speed-layer-compiled blocks are armed (``_compiled_call_impl`` guard) and only when
|
|
``original_forward`` is a plain bound method (a stacked hook chain is skipped). Idempotent via
|
|
``_unsloth_orig_inner``; best-effort. Returns the number armed."""
|
|
try:
|
|
import torch
|
|
except Exception: # noqa: BLE001 -- no torch, nothing to arm
|
|
return 0
|
|
armed = 0
|
|
try:
|
|
for module in transformer.modules():
|
|
registry = getattr(module, "_diffusers_hook", None)
|
|
if registry is None or getattr(module, "_compiled_call_impl", None) is None:
|
|
continue
|
|
hooks = getattr(registry, "hooks", None) or {}
|
|
for name in _CACHE_HOOK_NAMES:
|
|
hook = hooks.get(name)
|
|
fn_ref = getattr(hook, "fn_ref", None) if hook is not None else None
|
|
orig = getattr(fn_ref, "original_forward", None)
|
|
if orig is None or getattr(hook, "_unsloth_orig_inner", None) is not None:
|
|
continue
|
|
if getattr(orig, "__self__", None) is None:
|
|
continue # not a plain bound method; arming would miss the block
|
|
# fullgraph=False / dynamic=True: a cache is active (its decision graph-breaks) and
|
|
# this matches the default tier. Dynamo caches per code object, so re-arming after
|
|
# a toggle is ~free (~0.03 s).
|
|
fn_ref.original_forward = torch.compile(orig, fullgraph = False, dynamic = True)
|
|
hook._unsloth_orig_inner = orig
|
|
armed += 1
|
|
except Exception as exc: # noqa: BLE001 -- best-effort: the cache still works eager
|
|
_warn(logger, "cache-hook inner compile", exc)
|
|
return armed
|
|
if armed and logger is not None:
|
|
logger.info(
|
|
"diffusion.cache: %d cache-hooked block(s) armed with compiled inner forwards",
|
|
armed,
|
|
)
|
|
return armed
|
|
|
|
|
|
def _restore_hooked_block_inners(transformer: Any) -> None:
|
|
"""Undo ``_compile_hooked_block_inners``: restore the bound methods and clear the markers.
|
|
MUST run before ``disable_cache`` -- ``remove_hook`` splices ``original_forward`` back into
|
|
``module.forward``, so a leftover compiled wrapper would pin a stale callable on the uncached
|
|
path."""
|
|
try:
|
|
modules = list(transformer.modules())
|
|
except Exception: # noqa: BLE001 -- not a torch module (tests/fakes): nothing armed
|
|
return
|
|
for module in modules:
|
|
registry = getattr(module, "_diffusers_hook", None)
|
|
if registry is None:
|
|
continue
|
|
hooks = getattr(registry, "hooks", None) or {}
|
|
for name in _CACHE_HOOK_NAMES:
|
|
hook = hooks.get(name)
|
|
orig = getattr(hook, "_unsloth_orig_inner", None) if hook is not None else None
|
|
if orig is None:
|
|
continue
|
|
try:
|
|
hook.fn_ref.original_forward = orig
|
|
hook._unsloth_orig_inner = None
|
|
except Exception: # noqa: BLE001 -- per-hook best-effort
|
|
pass
|
|
|
|
|
|
def _pipeline_opens_cache_context(pipe: Any) -> bool:
|
|
"""Whether the pipeline enters ``transformer.cache_context(...)`` in its denoise loop. The
|
|
FBCache hook requires it at run time, and a CacheMixin transformer alone doesn't guarantee it
|
|
(Flux Kontext / img2img / inpaint / controlnet reuse FluxTransformer2DModel but open none).
|
|
Read from ``__call__`` source; False when unreadable so the cache stays off."""
|
|
import inspect
|
|
|
|
call = getattr(pipe, "__call__", None)
|
|
if call is None:
|
|
return False
|
|
try:
|
|
src = inspect.getsource(call)
|
|
except (OSError, TypeError):
|
|
return False
|
|
# Match the call `cache_context(` (the paren avoids a false positive on prose).
|
|
return "cache_context(" in src
|
|
|
|
|
|
def apply_step_cache(
|
|
pipe: Any,
|
|
*,
|
|
mode: Optional[str],
|
|
threshold: Optional[float] = None,
|
|
quant_active: bool = False,
|
|
family: Optional[str] = None,
|
|
steps: Optional[int] = None,
|
|
quality: Optional[str] = None,
|
|
expert: Optional[str] = None,
|
|
logger: Any = None,
|
|
) -> Optional[str]:
|
|
"""Engage step caching on ``pipe.transformer``. Returns the engaged mode, or None when
|
|
disabled / unsupported (load runs uncached). ``threshold`` overrides the default;
|
|
``quant_active`` raises the FBCache default so the cache still triggers on a quantised
|
|
transformer. ``quality`` picks the preset (threshold + magcache skip cap / retention
|
|
window); an explicit ``threshold`` still wins. Magcache also needs ``family`` (the
|
|
calibrated curve) and ``steps`` (interpolated over the step count); a dual-expert MoE
|
|
passes ``expert`` (e.g. "transformer_2") so each expert gets ITS OWN curve -- the hook
|
|
counts each expert's forwards from 0, so one shared curve would misalign both.
|
|
Best-effort: never raises for an incompatible model."""
|
|
mode = normalize_transformer_cache(mode)
|
|
if mode is None or mode == TC_AUTO:
|
|
# AUTO is resolved by the loader before this; treat a stray auto as off.
|
|
return None
|
|
transformer = getattr(pipe, "transformer", None)
|
|
if transformer is None:
|
|
return None
|
|
quality = normalize_cache_quality(quality) or CQ_BALANCED
|
|
if mode == TC_MAGCACHE:
|
|
preset_thr, mag_skip, mag_retention = _MAGCACHE_QUALITY_PRESETS[quality]
|
|
thr = threshold if threshold is not None else preset_thr
|
|
else:
|
|
# A family override wins over the generic preset table (Wan2.2-A14B pins its
|
|
# balanced threshold to the quality-gated 0.08 even when quant is active).
|
|
dense_thr, quant_thr = _FAMILY_FBCACHE_THRESHOLDS.get(
|
|
(str(family or "").strip().lower(), quality),
|
|
_FBCACHE_QUALITY_THRESHOLDS[quality],
|
|
)
|
|
thr = threshold if threshold is not None else (quant_thr if quant_active else dense_thr)
|
|
# Engage only via the transformer's native enable_cache (the diffusers CacheMixin path):
|
|
# the lower-level apply_first_block_cache hook would install on a non-CacheMixin
|
|
# transformer too (e.g. Z-Image), whose pipeline opens no cache_context and would crash
|
|
# the first generation -- so a model without enable_cache runs uncached per the
|
|
# best-effort contract instead of being reported as cached and then failing.
|
|
enable_cache = getattr(transformer, "enable_cache", None)
|
|
if not callable(enable_cache):
|
|
_warn(logger, mode, RuntimeError("transformer has no cache_context (not a CacheMixin)"))
|
|
return None
|
|
# A CacheMixin transformer is necessary but not sufficient: the hook raises "No context is set"
|
|
# unless the PIPELINE wraps its denoise loop in cache_context(...). Flux Kontext / img2img /
|
|
# inpaint / controlnet reuse FluxTransformer2DModel yet open none, so run uncached instead.
|
|
if not _pipeline_opens_cache_context(pipe):
|
|
_warn(
|
|
logger, mode, RuntimeError("pipeline __call__ opens no cache_context; running uncached")
|
|
)
|
|
return None
|
|
# Some cache-compatible block classes are missing from diffusers' metadata registry
|
|
# (HunyuanVideo-1.5); register them before enable_cache. FBCache and MagCache share it.
|
|
_ensure_block_metadata_registered(transformer, logger)
|
|
try:
|
|
if mode == TC_MAGCACHE:
|
|
ratio_key = _magcache_ratio_key(family, expert)
|
|
ratios = _MAGCACHE_FAMILY_RATIOS.get(ratio_key)
|
|
if ratios is None:
|
|
# No silent FBCache fallback: the family was routed to magcache exactly
|
|
# because FBCache derails it, so an uncalibrated checkpoint runs uncached.
|
|
_warn(
|
|
logger,
|
|
mode,
|
|
RuntimeError(f"no calibrated mag_ratios for '{ratio_key}'"),
|
|
)
|
|
return None
|
|
if not steps or int(steps) <= 0:
|
|
_warn(logger, mode, RuntimeError("magcache needs the step count to engage"))
|
|
return None
|
|
from diffusers.hooks import MagCacheConfig
|
|
|
|
# A full-schedule curve interpolates to the requested step count directly. An
|
|
# expert SUB-curve (MoE) covers only that expert's slice and the hook indexes it
|
|
# by the expert's own forward count, so scale its step count by the same
|
|
# steps/calibration ratio (the expert runs ~len(ratios) * steps / 50 forwards).
|
|
num_steps = int(steps)
|
|
if len(ratios) != _MAGCACHE_CALIBRATION_STEPS:
|
|
num_steps = max(1, round(len(ratios) * int(steps) / _MAGCACHE_CALIBRATION_STEPS))
|
|
config: Any = MagCacheConfig(
|
|
threshold = thr,
|
|
max_skip_steps = mag_skip,
|
|
retention_ratio = mag_retention,
|
|
num_inference_steps = num_steps,
|
|
mag_ratios = list(ratios),
|
|
)
|
|
# The curve is interpolated over the CONFIGURED step count, so the marker
|
|
# carries it: the auto toggle re-engages on a step-count change.
|
|
marker = f"{mode}@{thr}#s{int(steps)}"
|
|
else:
|
|
try:
|
|
from diffusers import FirstBlockCacheConfig
|
|
except ImportError: # older diffusers exports it only from diffusers.hooks
|
|
from diffusers.hooks import FirstBlockCacheConfig
|
|
|
|
config = FirstBlockCacheConfig(threshold = thr)
|
|
marker = f"{mode}@{thr}"
|
|
enable_cache(config)
|
|
# A prior uncached generation may have frozen an empty child-registry list, so the
|
|
# block hooks just installed would never receive the cache context. Must follow
|
|
# every enable_cache.
|
|
_invalidate_child_registry_cache(transformer)
|
|
# If blocks are already regionally compiled (toggle path: compile ran at load), re-point
|
|
# the fresh hooks' compute branch at compiled inners; the load path is armed by
|
|
# _compile_repeated_blocks. No-op when nothing is compiled.
|
|
_compile_hooked_block_inners(transformer, logger)
|
|
try:
|
|
transformer._unsloth_step_cache = marker
|
|
except Exception: # noqa: BLE001 — marker is best-effort
|
|
pass
|
|
if logger is not None:
|
|
logger.info("diffusion.cache: %s engaged (threshold=%s)", mode, thr)
|
|
return mode
|
|
except Exception as exc: # noqa: BLE001 — incompatible model -> run uncached
|
|
# enable_cache can fail after hooking some blocks; drop partial hooks so the
|
|
# reported-uncached model isn't half-cached. Restore armed compiled inners FIRST
|
|
# (remove_hook splices original_forward back into module.forward).
|
|
_restore_hooked_block_inners(transformer)
|
|
disable_cache = getattr(transformer, "disable_cache", None)
|
|
if callable(disable_cache):
|
|
try:
|
|
disable_cache()
|
|
transformer._unsloth_step_cache = None
|
|
except Exception as rollback_exc: # noqa: BLE001
|
|
# Enable and its cleanup both failed: surface it so the caller reloads instead
|
|
# of generating on a half-cached model.
|
|
raise RuntimeError(
|
|
"step-cache enable failed and rollback also failed; the transformer may be "
|
|
"partially cached and must be reloaded "
|
|
f"(enable error: {exc}; rollback error: {rollback_exc})"
|
|
) from rollback_exc
|
|
_warn(logger, mode, exc)
|
|
return None
|
|
|
|
|
|
def effective_denoise_steps(steps: int, strength: Optional[float]) -> int:
|
|
"""The number of steps diffusers ACTUALLY denoises for a request.
|
|
|
|
An image-conditioned workflow with ``strength`` < 1 (img2img / upscale / inpaint) denoises
|
|
only ``init_timestep = min(int(num_inference_steps * strength), num_inference_steps)`` steps
|
|
-- FLOORED, not rounded. The auto step-cache policy keys on THIS count (e.g. a 28-step upscale
|
|
at strength 0.35 runs int(9.8) = 9 steps, the short trajectory FBCache should stay off).
|
|
``strength`` None or >= 1 -> the full count.
|
|
"""
|
|
s = int(steps)
|
|
if strength is None or float(strength) >= 1.0:
|
|
return s
|
|
return max(1, min(int(s * float(strength)), s))
|
|
|
|
|
|
def effective_request_strength(
|
|
request_strength: Optional[float],
|
|
has_init_image: bool,
|
|
pipe_accepts_strength: bool,
|
|
pipe_default_strength: Any,
|
|
) -> Optional[float]:
|
|
"""The strength the pipe will ACTUALLY apply, for keying the auto step-cache policy.
|
|
|
|
Only image-conditioned pipelines taking ``strength`` apply it (else full trajectory -> None).
|
|
When the request omits it the loader doesn't pass the kwarg, so the pipe uses its OWN signature
|
|
default (< 1 for every img2img / inpaint pipeline, e.g. 0.6); the policy keys on that default,
|
|
else FBCache engages on a fraction of the advertised steps. A non-numeric default -> None.
|
|
"""
|
|
if not (has_init_image and pipe_accepts_strength):
|
|
return None
|
|
if request_strength is not None:
|
|
return request_strength
|
|
return pipe_default_strength if isinstance(pipe_default_strength, (int, float)) else None
|
|
|
|
|
|
def _disengage_step_cache(
|
|
transformer: Any,
|
|
*,
|
|
reason: str,
|
|
logger: Any = None,
|
|
) -> bool:
|
|
"""disable_cache + clear the marker; True when the transformer is now uncached."""
|
|
disable_cache = getattr(transformer, "disable_cache", None)
|
|
if not callable(disable_cache):
|
|
return False
|
|
try:
|
|
# Restore before remove_hook splices original_forward back, so the compiled
|
|
# inner wrappers don't leak onto the uncached path.
|
|
_restore_hooked_block_inners(transformer)
|
|
disable_cache()
|
|
transformer._unsloth_step_cache = None
|
|
if logger is not None:
|
|
logger.info("diffusion.cache: step cache disengaged (%s)", reason)
|
|
return True
|
|
except Exception as exc: # noqa: BLE001 -- keep the cache rather than crash
|
|
_warn(logger, "step cache disable", exc)
|
|
return False
|
|
|
|
|
|
def maybe_toggle_step_cache(
|
|
pipe: Any,
|
|
*,
|
|
steps: int,
|
|
quant_active: bool = False,
|
|
threshold: Optional[float] = None,
|
|
mode: str = TC_FBCACHE,
|
|
family: Optional[str] = None,
|
|
quality: Optional[str] = None,
|
|
expert: Optional[str] = None,
|
|
logger: Any = None,
|
|
) -> Optional[str]:
|
|
"""Generation-time enable/disable for an AUTO cache decision, keyed on the actual step
|
|
count: engage ``mode`` at ``FBCACHE_MIN_STEPS`` or more, uncached below it. Idempotent
|
|
via the ``_unsloth_step_cache`` marker, except a magcache step-count change, which
|
|
re-engages to re-interpolate the ratio curve. Only the loader's auto path calls this.
|
|
Returns the mode now active (or None when uncached)."""
|
|
transformer = getattr(pipe, "transformer", None)
|
|
if transformer is None:
|
|
return None
|
|
engaged = getattr(transformer, "_unsloth_step_cache", None)
|
|
want = int(steps) >= FBCACHE_MIN_STEPS
|
|
if (
|
|
want
|
|
and engaged
|
|
and mode == TC_MAGCACHE
|
|
# endswith, not substring: "#s5" would match inside "#s50".
|
|
and not str(engaged).endswith(f"#s{int(steps)}")
|
|
):
|
|
# Fail closed: a failed removal would leave the old #sN curve armed while reporting
|
|
# "magcache" with the wrong ratio schedule.
|
|
if not _disengage_step_cache(
|
|
transformer, reason = f"magcache re-interpolating for {steps} steps", logger = logger
|
|
):
|
|
raise RuntimeError(
|
|
"could not disable the existing MagCache before resizing it for "
|
|
f"{steps} steps; reload the video model before generating"
|
|
)
|
|
engaged = None
|
|
if want and not engaged:
|
|
return apply_step_cache(
|
|
pipe,
|
|
mode = mode,
|
|
threshold = threshold,
|
|
quant_active = quant_active,
|
|
family = family,
|
|
steps = steps,
|
|
quality = quality,
|
|
expert = expert,
|
|
logger = logger,
|
|
)
|
|
if not want and engaged:
|
|
# Below the threshold we want uncached; a failed disable leaves the cache armed, so
|
|
# surface it instead of the stale mode.
|
|
if not _disengage_step_cache(
|
|
transformer,
|
|
reason = f"auto: {steps} steps < {FBCACHE_MIN_STEPS}",
|
|
logger = logger,
|
|
):
|
|
raise RuntimeError(
|
|
"could not disable the existing step cache for a short generation; "
|
|
"reload the video model before generating"
|
|
)
|
|
return None
|
|
return mode if engaged else None
|
|
|
|
|
|
def _warn(logger: Any, what: str, exc: Exception) -> None:
|
|
if logger is not None:
|
|
logger.warning("diffusion.cache: %s unavailable (%s); running uncached", what, exc)
|