unsloth/studio/backend/core/inference/diffusion_precision.py
Daniel Han 06691e1858 Report the fp8-cast compute dtype without swapping the encoder class
The dtype override swapped encoder.__class__ to a dynamic subclass, which
breaks transformers' kwargs-based output recording: a fp8-cast
Qwen3VLModel stopped returning hidden_states and every krea-2 generation
with text_encoder_quant=fp8 crashed at encode_prompt (regression from the
HiDream TE4 change; caught by the krea hosted-TE live smoke). The
override is now a property shadowed on the ORIGINAL class that prefers a
per-instance compute-dtype attribute, so class identity is preserved and
uncast instances keep the stock behaviour. The idempotency test now pins
exact class identity and the uncast-sibling fallback.
2026-07-18 10:25:14 +00:00

589 lines
28 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 low-precision casting of the diffusion pipeline's text encoder(s).
The transformer arrives quantised in the GGUF, but the companion text encoder loads dense
(bf16) and is often the largest resident component (Qwen3 / T5-XXL / Mistral run to many GB).
This shrinks it in place, with four backends:
fp8 - diffusers layerwise casting: 8-bit (e4m3) storage, upcast per layer. ~2x
smaller. Any fp8-capable CUDA card (cc >= 8.9).
fp8_dynamic - torchao dynamic fp8 COMPUTE (per-row): keeps the matmul in fp8 on the tensor
cores (torch._scaled_mm) instead of upcasting. ~2x smaller + speedup; cc >= 8.9.
int8 - torchao dynamic int8 COMPUTE (per-token act + per-channel weight, _int_mm),
with per-layer keep-bf16 selection. Degrades on large encoders unless the
sensitive decoder blocks stay bf16, so applied only for families with a
measured schedule (else falls back to fp8). ~2x smaller; cc >= 8.0.
nvfp4 - torchao NVFP4 weight-only: 4-bit float, two-level microscaling, Blackwell
sm_100+ FP4 cores. ~4x smaller (lowest VRAM) but a steeper quality cost.
All keep normalisations / embeddings full precision and are a memory-vs-quality tradeoff.
``auto`` (the loader default) walks ``select_te_quant_scheme``'s per-GPU ladder for the best
accurate scheme (fp8_dynamic / int8 / layerwise fp8), falling back to dense; ``none``/``off``
stays dense, an explicit scheme forces it. They pair well with streamed (group) offload, where
the resident text encoder dominates the companion footprint. Quantify the quality cost per model
with scripts/diffusion_quality.py and scripts/diffusion_quant_builder.py. torch / diffusers /
torchao imported lazily.
"""
from __future__ import annotations
from typing import Any, Optional
TE_QUANT_FP8 = "fp8"
TE_QUANT_NVFP4 = "nvfp4"
TE_QUANT_INT8 = "int8"
TE_QUANT_FP8_DYNAMIC = "fp8_dynamic"
TE_QUANT_AUTO = "auto"
# Concrete schemes (excludes "auto") the casters dispatch on.
TE_QUANT_MODES = (TE_QUANT_FP8, TE_QUANT_NVFP4, TE_QUANT_INT8, TE_QUANT_FP8_DYNAMIC)
# Pipeline attributes that hold a text encoder, in order.
_TEXT_ENCODER_ATTRS = ("text_encoder", "text_encoder_2", "text_encoder_3")
# int8 degrades on large text encoders unless the quant-sensitive decoder blocks stay bf16.
# Per-family (skip_first, skip_last) blocks to keep dense, from measured hidden-state fidelity
# (per-token cosine vs bf16 at the consumed layer): keeping first blocks stops early-layer error
# seeding, last blocks protect the read layer. Families absent have no schedule clearing the bar,
# so int8 falls back to fp8.
# qwen-image (Qwen2.5-VL-7B): first+last 6 -> ~0.997 cosine (both ends; outlier-bound).
# flux.2-dev (Mistral-Small-24B): first 3 -> ~0.98 cosine (early-layer seeding).
_TE_INT8_SKIP: dict[str, tuple[int, int]] = {
"qwen-image": (6, 6),
"qwen-image-edit": (6, 6),
"flux.2-dev": (3, 0),
}
def normalize_te_quant(value: Optional[str]) -> Optional[str]:
"""Lower/strip a requested text-encoder quant; None / "" / "none" / "off" -> None, "auto" ->
"auto" (resolved later by select_te_quant_scheme). 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 == TE_QUANT_AUTO:
return TE_QUANT_AUTO
if normalized not in TE_QUANT_MODES:
raise ValueError(
f"Unsupported text_encoder_quant '{value}'. Use one of: "
f"{', '.join((TE_QUANT_AUTO,) + TE_QUANT_MODES)}, none/off."
)
return normalized
def te_quant_supported(target: Any, mode: str) -> bool:
"""Whether ``mode`` is usable for ``target``: a CUDA bf16 device plus the tensor-core class
each backend needs -- fp8 dtype (fp8), fp8 GEMM sm_89+ (fp8_dynamic), int8 sm_80+ (int8),
Blackwell sm_100+ (nvfp4)."""
if getattr(target, "device", None) != "cuda":
return False
try:
import torch
if getattr(target, "dtype", None) is not torch.bfloat16:
return False
if mode == TE_QUANT_FP8:
return hasattr(torch, "float8_e4m3fn")
if mode == TE_QUANT_FP8_DYNAMIC:
# fp8 GEMM needs Ada sm_89+ / Hopper / Blackwell.
return hasattr(torch, "float8_e4m3fn") and torch.cuda.get_device_capability() >= (8, 9)
if mode == TE_QUANT_INT8:
return torch.cuda.get_device_capability()[0] >= 8 # int8 cores: Ampere sm_80+
if mode == TE_QUANT_NVFP4:
return torch.cuda.get_device_capability()[0] >= 10 # NVFP4 cores: Blackwell sm_100+
except Exception:
return False
return False
# Per-arch preference order for text-encoder ``auto`` (best-first), mirroring the transformer's
# ``_AUTO_LADDER``. fp8_dynamic (compute fp8) leads on fp8-GEMM silicon; int8 sits second but only
# engages for a family with a measured keep-bf16 schedule; layerwise ``fp8`` (storage cast) is the
# universal fallback and the only scheme that survives group offload. nvfp4 stays explicit-only.
_TE_AUTO_LADDER: tuple[tuple[tuple[int, int], tuple[str, ...]], ...] = (
((8, 9), (TE_QUANT_FP8_DYNAMIC, TE_QUANT_INT8, TE_QUANT_FP8)), # Ada sm_89 / Hopper / Blackwell
(
(8, 0),
(TE_QUANT_INT8, TE_QUANT_FP8),
), # Ampere sm_80/86: no fp8 GEMM -> int8 or layerwise fp8
)
# Text encoders whose activation ranges break a scheme at the MODEL level (measured hidden-state
# cosine vs bf16). A denied scheme is skipped by ``auto`` and refused when requested explicitly.
# int8 already gates on a per-family keep-bf16 schedule (``_TE_INT8_SKIP``), so this covers the
# rarer case where a scheme breaks the encoder outright.
# ltx-2 / fp8_dynamic: compute-fp8 on the Gemma3-27B encoder BLACK-FRAMES the whole clip (B200,
# pairwise vs the dense encoder: mean luma 137.9 -> 0.0, LPIPS 0.78). Layerwise fp8 on the same
# encoder is near-lossless (LPIPS 0.0043) at the same ~2x shrink, so auto falls through to it.
_TE_FAMILY_SCHEME_DENY: dict[str, frozenset[str]] = {
"ltx-2": frozenset({TE_QUANT_FP8_DYNAMIC}),
}
# Families whose AUTO text-encoder quant resolves dense: measured out-of-bar trajectory drift for
# zero speed win. Unlike the deny table this only steers the AUTO default; an explicit scheme is
# still honored.
# wan2.2-t2v-a14b: TE fp8_dynamic ALONE moves the clip to pairwise LPIPS 0.1195 vs the dense-TE
# stack (B200, 1280x720/33f/50 steps) for 146.7 -> 142.7 s e2e -- the UMT5 encoder runs once per
# generation, so the 1.03x is noise next to being the dominant accuracy cost. The MoE trajectory
# amplifies the perturbation ~3x harder than on wan2.2-ti2v-5b (0.0396, kept quantized there for
# a real 1.09x on its faster DiT).
_TE_AUTO_DENSE_FAMILIES: frozenset[str] = frozenset(
# wan2.2-i2v-a14b inherits the T2V entry: same UMT5 encoder and MoE trajectory.
{"hunyuanvideo-1.5", "hunyuanvideo-1.5-720p", "wan2.2-t2v-a14b", "wan2.2-i2v-a14b"}
)
# Map a TE torchao scheme to the transformer smoke-probe scheme (same GEMM), so ``auto`` degrades
# gracefully when a build lacks a kernel. Layerwise fp8 has no torchao GEMM to probe.
_TE_SMOKE_SCHEME = {TE_QUANT_FP8_DYNAMIC: "fp8", TE_QUANT_INT8: "int8", TE_QUANT_NVFP4: "nvfp4"}
def _te_family_denied(family: Optional[str], scheme: str) -> bool:
return scheme in _TE_FAMILY_SCHEME_DENY.get((family or "").strip().lower(), frozenset())
# nvfp4 TE casts WEIGHT-ONLY (see _cast_nvfp4), a different kernel from the transformer's
# dynamic-activation NVFP4 GEMM, so it gets its own cached smoke probe.
_TE_NVFP4_PROBE_CACHE: dict[str, bool] = {}
def _te_nvfp4_weightonly_probe(device: str) -> bool:
"""True iff weight-only NVFP4 (the ``_cast_nvfp4`` config) runs one forward on this build. The
transformer ``_smoke_probe`` tests the DYNAMIC-activation NVFP4 GEMM, a different kernel: a
Blackwell build can carry the weight-only path without the dynamic one, so an explicit TE
``nvfp4`` needs this dedicated probe. Cached per device."""
if device in _TE_NVFP4_PROBE_CACHE:
return _TE_NVFP4_PROBE_CACHE[device]
ok = False
try:
import torch
from torchao.prototype.mx_formats import NVFP4WeightOnlyConfig
from torchao.quantization import quantize_
from .diffusion_transformer_quant import make_filter_fn
lin = torch.nn.Linear(512, 512, bias = False).to(device = device, dtype = torch.bfloat16)
quantize_(lin, NVFP4WeightOnlyConfig(), filter_fn = make_filter_fn(0))
x = torch.randn(32, 512, device = device, dtype = torch.bfloat16)
with torch.no_grad():
lin(x)
torch.cuda.synchronize()
ok = True
except Exception: # noqa: BLE001 -- an unavailable kernel just means stay dense
ok = False
_TE_NVFP4_PROBE_CACHE[device] = ok
return ok
def _te_scheme_probe(scheme: str, device: str) -> bool:
"""True iff ``scheme`` runs on this build. Layerwise fp8 (no torchao GEMM) always passes; nvfp4
probes its weight-only kernel; the other torchao modes reuse the transformer's cached smoke
test."""
tq = _TE_SMOKE_SCHEME.get(scheme)
if tq is None:
return True
# nvfp4 casts weight-only, so probe that kernel rather than the transformer's dynamic GEMM.
if scheme == TE_QUANT_NVFP4:
return _te_nvfp4_weightonly_probe(device)
try:
from .diffusion_transformer_quant import _smoke_probe
return _smoke_probe(tq, device)
except Exception:
return False
def select_te_quant_scheme(
target: Any,
requested: Optional[str],
*,
family: Optional[str] = None,
offload_active: bool = False,
) -> Optional[str]:
"""Resolve the concrete text-encoder scheme to apply, or None to stay dense bf16.
An explicit scheme is returned as-is. ``auto`` walks ``_TE_AUTO_LADDER`` for this GPU and
returns the first scheme that survives offload (torchao needs pinned residency -> only fp8),
has a keep-bf16 schedule if int8, is not family-denied, is hardware-supported, and passes a
kernel smoke test. Returns None when nothing qualifies."""
requested = normalize_te_quant(requested)
if requested is None or requested != TE_QUANT_AUTO:
return requested
# AUTO resolves dense for these families regardless of hardware. TE quant perturbs the
# CONDITIONING and a multi-step video trajectory amplifies that chaotically: on
# HunyuanVideo-1.5-720p (B200, 720p/33f/30 steps) TE fp8_dynamic ALONE moves the clip to LPIPS
# 0.236 vs the bit-exact reference while the rest of the stack sits at 0.052-0.053, for ZERO
# speed win (35.48 vs 35.36 s e2e). The ~6.7 GB saved isn't worth being the dominant accuracy
# cost. (VAE fp8 stays in auto: 0.053, decode-only, no trajectory to amplify.)
if (family or "").strip().lower() in _TE_AUTO_DENSE_FAMILIES:
return None
from .diffusion_transformer_quant import _capability, _is_consumer_gpu
cap = _capability()
if cap is None:
return None
device = str(getattr(target, "device", "cuda"))
for floor, schemes in _TE_AUTO_LADDER:
if cap >= floor:
# Consumer GDDR parts run int8 full-rate but halve fp8 FP32-accumulate: prefer int8.
ordered = (
(TE_QUANT_INT8,) + tuple(s for s in schemes if s != TE_QUANT_INT8)
if TE_QUANT_INT8 in schemes
and schemes[0] != TE_QUANT_INT8
and _is_consumer_gpu(device)
else schemes
)
for scheme in ordered:
# torchao tensors reject Module.to(); only layerwise fp8 streams, so skip the
# torchao modes under offload.
if offload_active and scheme in (
TE_QUANT_INT8,
TE_QUANT_FP8_DYNAMIC,
TE_QUANT_NVFP4,
):
continue
# int8 only clears the bar on a family with a measured keep-bf16 schedule.
if scheme == TE_QUANT_INT8 and (family or "").lower() not in _TE_INT8_SKIP:
continue
if _te_family_denied(family, scheme):
continue
if not te_quant_supported(target, scheme):
continue
if not _te_scheme_probe(scheme, device):
continue
return scheme
return None
return None
def quantize_text_encoders(
pipe: Any,
target: Any,
*,
mode: Optional[str],
family: Optional[str] = None,
offload_active: bool = False,
logger: Any = None,
) -> Optional[str]:
"""Quantise each present text encoder in place with ``mode`` (auto / fp8 / fp8_dynamic / int8 /
nvfp4). Returns the applied mode, or None when disabled, unsupported, or nothing was cast.
``auto`` resolves via ``select_te_quant_scheme``. ``int8`` needs a per-family keep-bf16 schedule
(``_TE_INT8_SKIP``); a family without one falls back to ``fp8``. Under offload the torchao modes
are skipped (their tensors reject Module.to()); layerwise ``fp8`` still engages. Best-effort:
any failure leaves the encoder dense."""
mode = normalize_te_quant(mode)
if mode is None:
return None
if mode == TE_QUANT_AUTO:
mode = select_te_quant_scheme(
target, TE_QUANT_AUTO, family = family, offload_active = offload_active
)
if mode is None:
return None
skip: Optional[tuple[int, int]] = None
if mode == TE_QUANT_INT8:
skip = _TE_INT8_SKIP.get((family or "").lower())
if skip is None:
_note(logger, f"int8 has no keep-bf16 schedule for family '{family}'; using fp8")
mode = TE_QUANT_FP8
# A denied scheme is refused even when requested explicitly. Gate the FINAL concrete mode so
# an int8 -> fp8 fallback is re-checked too (auto already filtered in select_te_quant_scheme).
if _te_family_denied(family, mode):
_note(
logger,
f"text-encoder '{mode}' denied for family '{family}' (out-of-bar; staying dense)",
)
return None
# The torchao modes (int8, fp8_dynamic, nvfp4) produce tensors that reject Module.to(), which
# an offload placement crashes on; layerwise fp8 streams fine. Skip the torchao modes here.
if offload_active and mode in (TE_QUANT_INT8, TE_QUANT_FP8_DYNAMIC, TE_QUANT_NVFP4):
_note(
logger,
f"text-encoder '{mode}' skipped under offload (torchao tensors reject Module.to()); "
"pin a resident memory mode or use fp8",
)
return None
if not te_quant_supported(target, mode):
return None
# An EXPLICIT torchao mode can clear the capability gate yet fail the real GEMM on a build
# where quantize_ wraps the encoder but the kernel is broken (the caster's try/except catches
# the cast, not the first forward). Run the auto ladder's kernel smoke test so a failing kernel
# falls back to dense here instead of crashing at generation. No-op for layerwise fp8.
device = str(getattr(target, "device", "cuda"))
if not _te_scheme_probe(mode, device):
_note(logger, f"text-encoder '{mode}' failed the kernel smoke test; staying dense")
return None
if mode == TE_QUANT_INT8:
first, last = skip # type: ignore[misc]
def caster(enc: Any, tgt: Any) -> None:
_cast_int8_selective(enc, tgt, first, last)
elif mode == TE_QUANT_FP8_DYNAMIC:
caster = _cast_fp8_dynamic
elif mode == TE_QUANT_NVFP4:
caster = _cast_nvfp4
else:
caster = _cast_fp8
cast: list[str] = []
for attr in _TEXT_ENCODER_ATTRS:
encoder = getattr(pipe, attr, None)
if encoder is None:
continue
try:
caster(encoder, target)
cast.append(attr)
except Exception as exc: # noqa: BLE001 — leave this encoder dense
# A mid-pass caster failure may have left the encoder PARTIALLY quantized (can't
# run as dense), so fail the load for that; a clean miss stays best-effort dense.
# raise_if_partially_quantized only recognises torchao parameter subclasses, so it
# cannot see a partial layerwise fp8 mutation (diffusers apply_layerwise_casting installs
# upcast hooks + fp8 storage in place, leaving no torchao params). Detect a leftover
# layerwise hook directly and fail closed there too; a clean failure stays dense.
from .diffusion_transformer_quant import raise_if_partially_quantized
if mode == TE_QUANT_FP8 and _has_layerwise_casting(encoder):
raise RuntimeError(
f"text_encoder_quant fp8:{attr} failed after partially installing layerwise "
"casting (leftover fp8 hooks); reload the model instead of a dense fallback "
f"(original error: {exc})"
) from exc
raise_if_partially_quantized(encoder, what = f"text_encoder_quant {mode}:{attr}", exc = exc)
_warn(logger, f"{mode}:{attr}", exc)
return mode if cast else None
def _te_exclude_tokens(encoder: Any) -> tuple[str, ...]:
"""fqn tokens whose Linears stay bf16 in a torchao TE quant: the VLM vision tower, the unused
lm_head, and the encoder's own fp32-kept modules (T5 ``wo``, which explodes in low precision)."""
tokens = ["visual", "vision_tower", "lm_head"]
tokens += [str(m).lower() for m in (getattr(encoder, "_keep_in_fp32_modules", None) or ())]
return tuple(dict.fromkeys(tokens))
def _keep_bf16_block_fqns(encoder: Any, skip_first: int, skip_last: int) -> set[str]:
"""FQNs of decoder blocks to keep bf16: the first ``skip_first`` and last ``skip_last`` of
each top-level ``nn.ModuleList`` stack. Structural, so no per-architecture table."""
import torch
keep: set[str] = set()
for name, module in encoder.named_modules():
if not isinstance(module, torch.nn.ModuleList):
continue
n = len(module)
if n <= skip_first + skip_last:
continue
for i in list(range(skip_first)) + list(range(n - skip_last, n)):
keep.add(f"{name}.{i}" if name else str(i))
return keep
def _cast_int8_selective(encoder: Any, target: Any, skip_first: int, skip_last: int) -> None:
# torchao dynamic int8 on the FLOP-heavy Linears, keeping the first/last decoder blocks (and
# vision tower / lm_head / T5 wo) bf16. Reuses the transformer-quant factory so config never drifts.
from torchao.quantization import quantize_
from .diffusion_transformer_quant import (
TQ_INT8,
DEFAULT_MIN_LINEAR_FEATURES,
_make_quant_config,
make_filter_fn,
exclude_tokens_for_scheme,
)
base = make_filter_fn(
DEFAULT_MIN_LINEAR_FEATURES,
exclude_tokens_for_scheme(TQ_INT8) + _te_exclude_tokens(encoder),
)
keep = _keep_bf16_block_fqns(encoder, skip_first, skip_last)
def filter_fn(module: Any, fqn: str = "") -> bool:
if not base(module, fqn):
return False
return not any(fqn == k or fqn.startswith(k + ".") for k in keep)
quantize_(encoder, _make_quant_config(TQ_INT8), filter_fn = filter_fn)
def _weight_has_zero_output_row(module: Any) -> bool:
"""True when a Linear's weight has an all-zero OUTPUT row. torchao per-row fp8 derives a
per-channel scale from that row's amax, so a dead row gives scale 0 -> 0/0 = NaN through the
forward. Real checkpoints ship such rows: SDXL's text_encoder_2 (OpenCLIP ViT-bigG) has one in
``text_model.encoder.layers.2.self_attn.out_proj`` -- B200: every fp8_dynamic SDXL render came
out black until this Linear is left dense. Cheap (one amax per Linear); False on any error."""
try:
weight = getattr(module, "weight", None)
if weight is None or weight.ndim != 2:
return False
return bool((weight.abs().amax(dim = -1) == 0).any().item())
except Exception: # noqa: BLE001 -- unreadable weight: let quantize_ decide
return False
def _cast_fp8_dynamic(encoder: Any, target: Any) -> None:
# torchao dynamic fp8 COMPUTE, per-row (torch._scaled_mm on the fp8 cores). Unlike layerwise
# `fp8` this keeps the matmul in fp8 instead of upcasting. Robust across encoder sizes, so no
# per-layer keep-bf16; only the vision tower / lm_head / T5 wo are excluded.
from torchao.quantization import quantize_
from .diffusion_transformer_quant import (
TQ_FP8,
DEFAULT_MIN_LINEAR_FEATURES,
_make_quant_config,
make_filter_fn,
)
# require_bf16: scaled_mm asserts a bf16 weight, so skip any stray non-bf16 Linear rather than
# aborting the pass (belt-and-suspenders over the named T5 wo exclusion).
base = make_filter_fn(
DEFAULT_MIN_LINEAR_FEATURES, _te_exclude_tokens(encoder), require_bf16 = True
)
# An all-zero output row NaNs under per-row scaling (scale 0 -> 0/0); keep those dense.
def filter_fn(module: Any, fqn: str = "") -> bool:
return base(module, fqn) and not _weight_has_zero_output_row(module)
quantize_(encoder, _make_quant_config(TQ_FP8), filter_fn = filter_fn)
def _cast_fp8(encoder: Any, target: Any) -> None:
import re
import torch
from diffusers.hooks import apply_layerwise_casting
from diffusers.hooks.layerwise_casting import DEFAULT_SKIP_MODULES_PATTERN
# Idempotent: a pre-cast encoder (diffusion_te_prequant) arrives with the layerwise hooks
# already installed, and re-registering the same hook name raises -- which would make
# quantize_text_encoders report the (actually engaged) cast as failed. Keyed on the explicit
# completion marker this function sets, NOT on hook presence alone: leftover hooks from a
# cast that failed mid-pass must still fail closed, not read as "already cast".
if getattr(encoder, "_unsloth_te_cast_complete", False) and _has_layerwise_hooks(encoder):
return
# Layerwise casting stores each leaf's weights in fp8 and upcasts per forward. Two things on a
# transformers encoder push an fp8 weight/activation into an op that can't handle it, both
# crashing only at generation, so skip the offending modules:
skip = tuple(DEFAULT_SKIP_MODULES_PATTERN)
# (1) dtype-sensitive modules the encoder flags. T5 keeps "wo" in fp32: its gated FF reads
# self.wo.weight.dtype and casts activations to match BEFORE calling wo (transformers#20287),
# racing the upcast hook so F.linear sees fp8 input vs bf16 weight. Literal substrings.
skip += tuple(re.escape(m) for m in (getattr(encoder, "_keep_in_fp32_modules", None) or ()))
# (2) an output projection tied to the input embedding. A CausalLM encoder (FLUX.2's Qwen3)
# ties lm_head.weight to embed_tokens.weight; casting lm_head (an nn.Linear) to fp8 drags the
# shared embedding down, which then emits fp8 activations that crash the first RMSNorm. Skip
# the tied projection so the shared tensor stays dense (lm_head is unused for prompt encoding).
get_out, get_in = (
getattr(encoder, "get_output_embeddings", None),
getattr(encoder, "get_input_embeddings", None),
)
out_emb = get_out() if callable(get_out) else None
in_emb = get_in() if callable(get_in) else None
if out_emb is not None and in_emb is not None and out_emb.weight is in_emb.weight:
tied_name = next((n for n, m in encoder.named_modules() if m is out_emb), None)
if tied_name:
skip += (rf"^{re.escape(tied_name)}$",)
apply_layerwise_casting(
encoder,
storage_dtype = torch.float8_e4m3fn,
compute_dtype = target.dtype,
skip_modules_pattern = skip,
# Keep token-embedding tables full precision: the diffusers default only skips vision
# pos/patch embeds, and fp8'ing nn.Embedding quantizes every prompt token to the coarse
# fp8 grid, hurting fidelity.
skip_modules_classes = (torch.nn.Embedding,),
)
# Module.dtype reports the first floating parameter, which is now fp8 STORAGE; pipelines
# derive tensor dtypes from encoder.dtype (Flux2 casts prompt embeds to it and feeds the
# result to randn_tensor, which has no fp8 kernel; VLM pipelines cast pixel_values to it,
# racing the upcast hooks). The encoder computes in target.dtype, so report that -- via a
# property shadowed on the ORIGINAL class reading a per-instance override. Swapping
# __class__ to a dynamic subclass instead breaks transformers' kwargs-based output
# recording (Qwen3VLModel returned hidden_states=None and krea-2 crashed at encode).
compute_dtype = getattr(target, "dtype", None)
try:
if compute_dtype is not None:
_install_dtype_override(type(encoder))
encoder._unsloth_te_compute_dtype = compute_dtype
# Marks the cast COMPLETE (hooks fully installed), enabling the idempotent early return
# above. Best-effort like the dtype override: a non-Module double without settable
# attributes still counts as cast, it just re-casts on a repeat call.
encoder._unsloth_te_cast_complete = True
except Exception: # noqa: BLE001 — real HF encoders are heap-type nn.Modules; only doubles fail
pass
def _install_dtype_override(cls: type) -> None:
"""Shadow ``cls.dtype`` with a property preferring the per-instance compute-dtype
override ``_cast_fp8`` sets; instances without it keep the original behaviour. Class
identity is untouched, applied once per class."""
existing = cls.__dict__.get("dtype")
if getattr(getattr(existing, "fget", None), "_unsloth_te_dtype_override", False):
return
# The property object itself when accessed through the class (property.__get__(None, cls)).
original_fget = getattr(getattr(cls, "dtype", None), "fget", None)
def _dtype(self):
override = self.__dict__.get("_unsloth_te_compute_dtype")
if override is not None:
return override
if original_fget is not None:
return original_fget(self)
raise AttributeError("dtype")
_dtype._unsloth_te_dtype_override = True
cls.dtype = property(_dtype)
def _has_layerwise_hooks(encoder: Any) -> bool:
"""True when any submodule already carries the diffusers layerwise-casting hook."""
return _has_layerwise_casting(encoder)
def _cast_nvfp4(encoder: Any, target: Any) -> None:
# Weight-only NVFP4: linear weights become 4-bit NVFP4 on Blackwell FP4 cores; norms /
# embeddings untouched. Exclude the VLM vision tower / lm_head / T5 wo and sub-512 projections
# like the int8/fp8 TE modes (4-bit-ing a VLM image tower degrades the edit conditioning);
# require_bf16 skips non-bf16 Linears so the cast engages instead of aborting.
from torchao.quantization import quantize_
from torchao.prototype.mx_formats import NVFP4WeightOnlyConfig
from .diffusion_transformer_quant import DEFAULT_MIN_LINEAR_FEATURES, make_filter_fn
filter_fn = make_filter_fn(
DEFAULT_MIN_LINEAR_FEATURES, _te_exclude_tokens(encoder), require_bf16 = True
)
quantize_(encoder, NVFP4WeightOnlyConfig(), filter_fn = filter_fn)
def _has_layerwise_casting(module: Any) -> bool:
"""True when any submodule still carries a diffusers layerwise-casting hook -- i.e. an
``apply_layerwise_casting`` pass installed an fp8-storage upcast hook before failing. torchao's
partial-quant detector cannot see these, so a mid-pass layerwise failure would otherwise report
a dense fallback over a half-cast encoder. Best-effort: a module without ``.modules()`` or a
moved diffusers internal returns False (defer to the torchao check)."""
try:
hook_name = "layerwise_casting"
try:
from diffusers.hooks.layerwise_casting import _LAYERWISE_CASTING_HOOK
hook_name = _LAYERWISE_CASTING_HOOK
except Exception: # noqa: BLE001 -- const moved: fall back to the stable literal
pass
for sub in module.modules():
registry = getattr(sub, "_diffusers_hook", None)
get_hook = getattr(registry, "get_hook", None)
if callable(get_hook) and get_hook(hook_name) is not None:
return True
except Exception: # noqa: BLE001 -- unqueryable module: defer to the torchao check
return False
return False
def _warn(logger: Any, what: str, exc: Exception) -> None:
if logger is not None:
logger.warning("diffusion.precision: text-encoder quant (%s) failed: %s", what, exc)
def _note(logger: Any, msg: str) -> None:
if logger is not None:
logger.info("diffusion.precision: %s", msg)