unsloth/studio/backend/tests/test_diffusion_precision.py
Daniel Han 39147e4b68
Report the precision actually in use, and refuse an explicit one we cannot honor (#8165)
* Studio: report the precision that actually ran, and refuse one that cannot

The loader already knew the truth and threw it away at the API boundary. Status
reported the ENGAGED transformer / text-encoder precision, but nothing echoed
back what the caller ASKED for, so once a fallback happened the request was
gone: the Advanced panel kept its dropdown on FP8 while a Q4_K_M GGUF ran, the
"Auto: X" badge was suppressed for exactly the case that needed it
(source !== "auto" rendered nothing), and a successfully generated image or
saved clip carried no evidence of the precision behind it.

Backend
- DiffusionResolvedControl gains `requested` (the raw ask, null when left to the
  backend) and `status` ("applied" | "fell_back" | "unsupported") beside the
  existing value/source/reason. Both default, so an older payload still parses.
- build_resolved_record keeps the request beside the engaged value and derives a
  mismatch for the controls that answer in the vocabulary they are asked in.
  memory_mode and attention_backend do not, so they are classified by the call
  site instead of compared blindly.
- Every transformer decline site now records WHY, in the caller's terms: an
  uncached hosted prequant, a re-plan that still needs offload, a dense-fit
  miss, a failed quant build, an unsupported scheme, and the wrong load kind.
- quantize_text_encoders returns a TEQuantOutcome (mode + reason + status). The
  int8 -> fp8 downgrade, the offload skip and the unsupported-device path were
  all bare `return None`; the last one had no log line at all.
- Explicit precision fails closed. Host-level impossibilities are refused in
  begin_load, so /images/load and /video/load answer 409 before anything is
  evicted; footprint-dependent declines raise inside the load and surface on
  load-progress. `auto` still falls back silently, and
  UNSLOTH_DIFFUSION_ALLOW_PRECISION_FALLBACK=1 restores the old behaviour.
- Saved output metadata: images add text_encoder_quant / memory_mode /
  offload_policy; video clips gain the whole build block images already had.
  All read from the engaged state, none added to the required-key sets, so
  older PNGs and sidecars still list.

Frontend
- resolved-precision.ts holds the badge/select decisions as pure functions. A
  declined request now renders "FP8 -> OFF" in a warning tone with the reason in
  the tooltip, instead of nothing.
- The Advanced selects reseed from the loaded build, so a declined scheme stops
  advertising itself, and a "Loaded build" summary reports the transformer and
  text-encoder precision plus the memory mode and resolved offload behaviour.
- A 409 refusal is surfaced as a titled, actionable toast; transformer_quant is
  no longer sent for load kinds that cannot use it.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Raise the precision refusal before the GPU handoff, not inside it

The 409 exists to preserve two things: the chat model holding the GPU,
and the several GB the load would otherwise pull down before failing.
The check was made in begin_load, which is too late for both.

begin_load runs inside acquire_for, and acquire_for evicts the current
owner under the arbiter lock BEFORE it runs the register callback. On
the image path it also runs after select_and_activate_engine, which
unloads the resident model on an engine switch. So an impossible
explicit precision was refused having already destroyed exactly what
the refusal was meant to protect: the user got a 409 and an empty GPU.

Both checks are now made by the route, alongside the sibling refusals
that already run there (the unloadable pick, the gated companion), and
before the device is taken. The copy in begin_load stays, since it is
the load path's own invariant and other callers reach it directly.

Diffusers only, on the image path. The native sd.cpp engine accepts
transformer_quant / text_encoder_quant for interface parity and ignores
them, so gating that path on a torchao capability would refuse loads
that work today. A probe failure leaves pending_name None and skips the
route check, which is the pre-existing behaviour rather than a new one.
`auto` is never refused, so a caller that left the precision to the
backend cannot reach any of this.

* Stop the Advanced reseed firing on generation-time record rewrites

Two separate ways the resolved record was read too literally.

The reseed effect keyed on JSON.stringify(resolved). That record is not
load-time-only: the backend rewrites entries of it during GENERATION.
speed_mode and attention_backend change when the deferred compile
profile engages on the 3rd image, and transformer_cache changes
whenever the step-cache threshold flips. Each of those moved the key
with no load behind it, so the effect re-ran and overwrote a Precision
the user had picked but not yet loaded. An edit made after a load is
meant to survive until the next LOAD replaces it. resolvedSeedKey
covers only the three controls the effect actually writes, and for
attention only the request side, since that is the field the reseed
reads for an auto or honored request and the one a rewrite leaves
alone. A real reload still re-fires: it always moves a request or an
engaged value on one of the three.

isResolvedHonored treated every status that was not "applied" as a
decline. `status` is typed wider than the backend's union on purpose,
so a newer backend can add a value, but that reading threw the
tolerance away: an unknown status painted a red "FP8 -> FP8" over a
request that was honored, and on memory_mode (asked "low_vram",
answered "sequential") a "LOW_VRAM -> SEQUENTIAL" that never happened.
Only the two statuses that mean a decline are now read as one. Staying
quiet is the safe direction, since the build that adds a status ships
the frontend that understands it.

* Name the fault behind a refused precision, and stop caching an OOM as one

Two things the fail-closed 409 made load-bearing that were fine while a
declined explicit scheme fell back quietly.

select_transformer_quant_scheme answers None for three different faults
and the refusal reported all of them as "'fp8' is not usable for family
'X' on this GPU". Measured here on a B200: torchao could not import at
all (cannot import name 'ScalingType' from torch.nn.functional, a
torch/torchao version skew), the smoke probe swallowed that, and every
explicit scheme was refused with a message blaming Blackwell hardware
that runs all of them. A skew is fixed by a pip install; a GPU limit is
not; a family the accuracy gate rules out is neither. explain_unusable_
scheme separates the three, shared by the image and video resolvers so
they cannot drift.

The smoke probe also cached an out-of-memory as a verdict on the scheme.
That probe now runs on the ROUTE thread, which is the point of raising
the refusal before the GPU handoff, so it meets a full GPU by design:
the resident chat model has not been evicted yet. One transient OOM
therefore refused that scheme for the rest of the process, on a host
that runs it fine seconds later. Allocator failures are no longer
remembered; every other failure still is, because those really are
properties of the build. torch.OutOfMemoryError subclasses RuntimeError
rather than MemoryError and has moved between torch and torch.cuda, so
both names are tried with the message as the backstop.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Correct a comment the reseed-key change left behind

The dependency is no longer the serialized record; it is the load-time
projection of it, which is the whole point of the previous commit.

* Close the three places an explicit precision was still reported wrong

The native engine was exempt from the precision gate, on the grounds that refusing there would
break loads that work today. But the loads it works for are exactly the silent mismatch this
change exists to remove: sd.cpp accepts transformer_quant and text_encoder_quant for interface
parity, ignores them, and reports null, so an explicit FP8 succeeded having quantised nothing.
The diffusers path already refuses on the same CPU-only host, so the exemption also left the two
engines disagreeing about one request. It now refuses, with a message naming the engine; auto,
none and an omitted value still pass through untouched, and the existing escape hatch waives it.

The Loaded build panel labelled any non-GGUF load BF16, but a single-file safetensors keeps
whatever precision it was saved in and FP8 checkpoints are explicitly supported, so the one
panel whose job is to say what actually loaded was asserting a wrong number. It reads "As in
checkpoint" for single_file now, on the video page as well.

And the video loader rewrites an omitted transformer_quant to "off" under speed_mode="off"
before building the resolved record, so the record claimed the user had pinned bf16: the Auto
badge disappeared and the Precision select reseeded to none, leaving quantisation pinned off
after a Speed change and reload. The raw request is captured before the rewrite and reported.

Four tests, each confirmed against a mutation.

* Report the truth on partial casts, native builds and unprovable probes

- text-encoder quant that cast one encoder and not its sibling reported
  "applied" and both loaders let the load through, recording the requested
  mode while conditioning ran off a mixture; it is now reported as a
  fallback and refused like any other declined explicit precision.
- the refusal message pointed users at "Choose Auto" for text_encoder_quant,
  which both request models reject, so following it returned a 422.
- the native sd.cpp engine reports dtype "gguf" and no model_kind, so the
  Loaded build panel labelled every native checkpoint BF16; the label rule
  is now one shared helper covering both pages.
- native generation results carried no offload state, so every native image
  recipe persisted it as null.
- the video load route probed CUDA precision before the training guard, which
  allocated next to a training subprocess for a load about to be refused.
- a smoke-probe OOM before the arbiter eviction is not a verdict on the
  scheme, but it reached the new route gate as one and refused the load the
  eviction was about to make room for.
- the torchao import error is interpolated into the 409 detail and names the
  absolute file that raised it; paths are stripped there and logged in full.

* Do not read a native null text-encoder quant as BF16

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Refuse an unhonourable precision before the download plan is staged

The load routes refuse an explicit precision this host cannot honour, but the UI
plans and stages first, so the refusal arrived after the GGUF and its companions
(tens of GB on the video side) had already been pulled. Both checks are
network-free, so /images/download-plan and /video/download-plan now make them
before building the plan, and map the refusal to the same 409 the load routes
give.

Also fixes three Loaded-build panel rows that were only correct for diffusers:
the dense dtype label no longer calls a float16/float32 load BF16, the attention
row names the native sd.cpp engine instead of Native SDPA, and the Memory row
renders when an offload is active but no memory mode is set.

* Keep the plan-time precision check off the GPU, and off the load's blind spot

The plan runs before the load's training guard, so an uncached scheme sent
assert_precision_available into its quantise-and-matmul smoke probe and
initialised CUDA in the Studio process beside a running trainer. Staging needs no
GPU, so the check is skipped while training is active on both the image and video
plan routes; the load still refuses the same pick afterwards.

The video page also asked for its plan without the selected precision while
sending it on the load, so the plan cleared a scheme the load would reject and
staged the pipeline first. It now sends it under the same pipeline-only rule.

A single_file transformer is no longer labelled 'As in checkpoint':
from_single_file is handed the resolved torch_dtype, so an fp8 checkpoint is
upcast on load and the panel was hiding the dtype it actually runs in.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Keep two video route tests off the host's precision support

Both assert that a text_encoder_quant reaches the backend, and both now run
through a precision gate whose answer depends on the machine: a GPU-less runner
refuses fp8 with a 409 and the forwarding under test never happens. They run
under the product's own fallback escape hatch instead.

* Gate diffusion precision on the engine and mode that actually run

Three places reported or refused a precision that was not the one the
runtime would use.

The load route asked predict_engine which gate to apply, and a probe
failure left pending_name None, skipping both arms. Selection could then
land on sd.cpp anyway, which accepts the knobs and ignores them, so an
explicit fp8 loaded, quantised nothing and reported null. The gate is now
re-asked of the engine that was actually activated, and only when the
prediction missed, so a correct one is never paid twice.

quantize_text_encoders rewrites an int8 request to layerwise fp8 on any
family with no keep-bf16 schedule, and that path needs no torchao. Both
precision asserts consulted te_quant_supported about the raw int8 and so
refused loads the runtime would run and report as fell_back. New
effective_te_quant() resolves the downgrade before support is consulted.

The Recipe popover's Memory row substituted "auto" for a null memory_mode,
which is what the native engine always records, claiming the memory planner
had picked a mode on the one path that never runs it. An absent mode now
reports the offload alone.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Refuse an offload-bound precision up front, and read the video precision live

An explicit dense precision with Memory=balanced/low_vram (or the legacy
cpu_offload flag) is incompatible on its face: those requests name their
offload policy without measuring anything, offload hooks move modules with
Module.to(), and torchao tensors do not survive it, so the loader skips the
dense build. The strict refusal then landed after the resident image model
had been torn down. The pre-handoff gate now takes the memory request and
refuses the pair before the GPU is acquired. fast and auto are decided from
the measured footprint and are untouched.

The video page's loadOrStage is memoized on [stage, pickGuard], so its plain
capture of transformerQuant froze at whatever was selected when the callback
was built. The ordinary auto to FP8 change then asked the plan with no
precision, skipping the pre-download refusal, and staged tens of GB before
the load rejected the same pick. It reads through a ref now, the same way
handleLoad already does.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Extend the offload precision gate to the encoder and to video

The pre-handoff gate refused an offload-bound dense transformer quant but
not the two adjacent cases.

quantize_text_encoders reports the torchao encoder modes (int8,
fp8_dynamic, nvfp4) unsupported once offload is active, for the same reason:
the hooks move modules with Module.to() and those tensor subclasses do not
survive it. The image gate now refuses them alongside an offload-forcing
memory request. Layerwise fp8 is a dtype cast and is untouched.

assert_video_precision_available took no memory request at all, so a video
load with an explicit precision and balanced or low_vram passed the route
preflight and was refused inside load_pipeline, after acquire_for and the
teardown had evicted the resident model. It takes memory_mode now and
applies both rules.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Refuse a torchao encoder mode a host cannot import, and plan with the memory request

te_quant_supported only asks the device: a CUDA bf16 host with a broken or
absent torchao passed every capability check, and the casters import torchao
only after the pipeline has been downloaded and built, so the refusal came
through load-progress instead of the pre-load 409. Both gates now ask
torchao_quantize_importable() for the torchao-backed encoder modes. Layerwise
fp8 is a plain dtype cast and does not need it.

The video staged plan sent the precision but not the memory mode, and the
route refuses the incompatible pair only when it can see both -- so the plan
succeeded and tens of GB were staged before /video/load rejected the same
pick. It reads the memory mode through a live ref, like the precision.

---------

Co-authored-by: Daniel Han <moonshotaisubstack@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-09 01:15:13 -07:00

521 lines
22 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
"""Unit tests for text-encoder quantisation (``diffusion_precision.py``).
Hermetic: torch + the diffusers / torchao casters are stubbed via ``sys.modules`` so
gating and the apply path run without a GPU, real diffusers, or real torchao.
"""
from __future__ import annotations
import sys
import types
import pytest
import core.inference.diffusion_precision as dp
from core.inference.diffusion_precision import (
TE_QUANT_FP8,
TE_QUANT_FP8_DYNAMIC,
TE_QUANT_INT8,
TE_QUANT_NVFP4,
_cast_int8_selective,
_cast_nvfp4,
_keep_bf16_block_fqns,
effective_te_quant,
normalize_te_quant,
quantize_text_encoders,
te_quant_supported,
)
def _target(
*,
device = "cuda",
dtype = "bfloat16",
cc = (10, 0),
):
return types.SimpleNamespace(device = device, dtype = dtype, _cc = cc)
def _stub_torch(
monkeypatch,
*,
with_fp8 = True,
cc = (10, 0),
):
torch = types.ModuleType("torch")
torch.bfloat16 = "bfloat16"
torch.float16 = "float16"
if with_fp8:
torch.float8_e4m3fn = "float8_e4m3fn"
# _cast_fp8 skips nn.Embedding tables and _keep_bf16_block_fqns walks nn.ModuleList stacks, so the stub torch exposes both.
torch.nn = types.SimpleNamespace(
Embedding = type("Embedding", (), {}),
ModuleList = type("ModuleList", (list,), {}),
)
torch.cuda = types.SimpleNamespace(get_device_capability = lambda *a: cc)
monkeypatch.setitem(sys.modules, "torch", torch)
return torch
def _stub_casters(monkeypatch, recorder):
# diffusers fp8 layerwise casting
hooks = types.ModuleType("diffusers.hooks")
casting = types.ModuleType("diffusers.hooks.layerwise_casting")
casting.DEFAULT_SKIP_MODULES_PATTERN = ("norm",)
hooks.apply_layerwise_casting = lambda module, **kw: recorder.append(("fp8", module))
monkeypatch.setitem(sys.modules, "diffusers.hooks", hooks)
monkeypatch.setitem(sys.modules, "diffusers.hooks.layerwise_casting", casting)
# torchao nvfp4: quantize_ now receives the vision-tower exclusion filter_fn; accept + ignore.
tq = types.ModuleType("torchao.quantization")
tq.quantize_ = lambda module, config, filter_fn = None: recorder.append(("nvfp4", module))
mx = types.ModuleType("torchao.prototype.mx_formats")
mx.NVFP4WeightOnlyConfig = lambda: "nvfp4cfg"
monkeypatch.setitem(sys.modules, "torchao.quantization", tq)
monkeypatch.setitem(sys.modules, "torchao.prototype.mx_formats", mx)
# _cast_nvfp4 / _cast_fp8_dynamic pull the shared linear filter from the transformer-quant module.
dtq = types.ModuleType("core.inference.diffusion_transformer_quant")
dtq.DEFAULT_MIN_LINEAR_FEATURES = 512
dtq.make_filter_fn = lambda min_features, exclude = (), *, require_bf16 = False: (
lambda module, fqn = "": True
)
monkeypatch.setitem(sys.modules, "core.inference.diffusion_transformer_quant", dtq)
# ── normalisation ─────────────────────────────────────────────────────────────
def test_normalize_te_quant():
assert normalize_te_quant(None) is None
assert normalize_te_quant("") is None
assert normalize_te_quant("none") is None
assert normalize_te_quant("FP8") == TE_QUANT_FP8
assert normalize_te_quant("NVFP4") == TE_QUANT_NVFP4
assert normalize_te_quant("int8") == TE_QUANT_INT8
# Hyphens fold to underscores so "fp8-dynamic" is accepted.
assert normalize_te_quant("FP8-Dynamic") == TE_QUANT_FP8_DYNAMIC
with pytest.raises(ValueError):
normalize_te_quant("int2")
# ── gating ────────────────────────────────────────────────────────────────────
def test_fp8_supported_requires_cuda_bf16_and_fp8(monkeypatch):
_stub_torch(monkeypatch, with_fp8 = True)
assert te_quant_supported(_target(), TE_QUANT_FP8) is True
assert te_quant_supported(_target(device = "cpu"), TE_QUANT_FP8) is False
assert te_quant_supported(_target(dtype = "float16"), TE_QUANT_FP8) is False
def test_nvfp4_supported_requires_blackwell(monkeypatch):
_stub_torch(monkeypatch, cc = (10, 0))
assert te_quant_supported(_target(), TE_QUANT_NVFP4) is True
# Hopper (cc 9.0) has no NVFP4 tensor cores.
_stub_torch(monkeypatch, cc = (9, 0))
assert te_quant_supported(_target(), TE_QUANT_NVFP4) is False
def test_int8_supported_requires_sm80(monkeypatch):
# int8 tensor cores (torch._int_mm) need Ampere sm_80+.
_stub_torch(monkeypatch, cc = (8, 0))
assert te_quant_supported(_target(), TE_QUANT_INT8) is True
_stub_torch(monkeypatch, cc = (7, 5))
assert te_quant_supported(_target(), TE_QUANT_INT8) is False
# Still needs CUDA + bf16 like every mode.
_stub_torch(monkeypatch, cc = (8, 0))
assert te_quant_supported(_target(device = "cpu"), TE_QUANT_INT8) is False
def test_fp8_dynamic_supported_requires_sm89_and_fp8(monkeypatch):
# Compute fp8 (torch._scaled_mm) needs fp8-GEMM silicon: Ada sm_89+ / Hopper / Blackwell.
_stub_torch(monkeypatch, cc = (8, 9))
assert te_quant_supported(_target(), TE_QUANT_FP8_DYNAMIC) is True
_stub_torch(monkeypatch, cc = (9, 0))
assert te_quant_supported(_target(), TE_QUANT_FP8_DYNAMIC) is True
# Ampere (8.0) has int8 but not fp8 GEMM.
_stub_torch(monkeypatch, cc = (8, 0))
assert te_quant_supported(_target(), TE_QUANT_FP8_DYNAMIC) is False
# No fp8 dtype at all -> unsupported regardless of arch.
_stub_torch(monkeypatch, with_fp8 = False, cc = (9, 0))
assert te_quant_supported(_target(), TE_QUANT_FP8_DYNAMIC) is False
# ── apply ─────────────────────────────────────────────────────────────────────
def test_quantize_disabled_returns_none(monkeypatch):
_stub_torch(monkeypatch)
pipe = types.SimpleNamespace(text_encoder = object())
assert quantize_text_encoders(pipe, _target(), mode = None).mode is None
assert quantize_text_encoders(pipe, _target(), mode = "none").mode is None
def test_quantize_fp8_casts_all_encoders(monkeypatch):
_stub_torch(monkeypatch)
recorder: list = []
_stub_casters(monkeypatch, recorder)
te1, te3 = object(), object()
pipe = types.SimpleNamespace(text_encoder = te1, text_encoder_2 = None, text_encoder_3 = te3)
outcome = quantize_text_encoders(pipe, _target(), mode = "fp8")
assert outcome.mode == TE_QUANT_FP8
assert outcome.status == "applied"
assert recorder == [("fp8", te1), ("fp8", te3)]
def test_quantize_nvfp4_uses_torchao(monkeypatch):
_stub_torch(monkeypatch, cc = (10, 0))
recorder: list = []
_stub_casters(monkeypatch, recorder)
te = object()
pipe = types.SimpleNamespace(text_encoder = te)
outcome = quantize_text_encoders(pipe, _target(), mode = "nvfp4")
assert outcome.mode == TE_QUANT_NVFP4
assert recorder == [("nvfp4", te)]
def test_quantize_nvfp4_unsupported_on_hopper_is_noop(monkeypatch):
_stub_torch(monkeypatch, cc = (9, 0))
recorder: list = []
_stub_casters(monkeypatch, recorder)
pipe = types.SimpleNamespace(text_encoder = object())
outcome = quantize_text_encoders(pipe, _target(cc = (9, 0)), mode = "nvfp4")
assert outcome.mode is None
# An unsupported request is now REPORTED rather than silently skipped.
assert outcome.status == "unsupported" and "nvfp4" in outcome.reason
assert recorder == []
def test_quantize_tolerates_caster_failure(monkeypatch):
_stub_torch(monkeypatch)
hooks = types.ModuleType("diffusers.hooks")
casting = types.ModuleType("diffusers.hooks.layerwise_casting")
casting.DEFAULT_SKIP_MODULES_PATTERN = ("norm",)
def _boom(module, **kwargs):
raise RuntimeError("fp8 unsupported for this layer")
hooks.apply_layerwise_casting = _boom
monkeypatch.setitem(sys.modules, "diffusers.hooks", hooks)
monkeypatch.setitem(sys.modules, "diffusers.hooks.layerwise_casting", casting)
pipe = types.SimpleNamespace(text_encoder = object())
# The only encoder fails to cast -> nothing applied -> None, reported as a fallback.
outcome = quantize_text_encoders(pipe, _target(), mode = "fp8")
assert outcome.mode is None and outcome.status == "fell_back"
# ── int8 (selective) + fp8_dynamic routing ─────────────────────────────────────
def test_quantize_int8_uses_family_keep_bf16_schedule(monkeypatch):
# int8 for a family with a measured schedule routes to the selective caster with that family's (skip_first, skip_last); qwen-image keeps first+last 6 blocks bf16.
_stub_torch(monkeypatch, cc = (10, 0))
calls: list = []
monkeypatch.setattr(
dp, "_cast_int8_selective", lambda enc, tgt, first, last: calls.append((enc, first, last))
)
te = object()
pipe = types.SimpleNamespace(text_encoder = te)
outcome = quantize_text_encoders(pipe, _target(), mode = "int8", family = "qwen-image")
assert outcome.mode == TE_QUANT_INT8
assert outcome.status == "applied"
assert calls == [(te, 6, 6)]
def test_quantize_int8_unknown_family_falls_back_to_fp8(monkeypatch):
# A family without an int8 keep-bf16 schedule falls back to layerwise fp8 (logged), never silent full int8 that would degrade the encoder.
_stub_torch(monkeypatch, cc = (10, 0))
int8_calls: list = []
fp8_calls: list = []
monkeypatch.setattr(dp, "_cast_int8_selective", lambda *a: int8_calls.append(a))
monkeypatch.setattr(dp, "_cast_fp8", lambda enc, tgt: fp8_calls.append(enc))
te = object()
pipe = types.SimpleNamespace(text_encoder = te)
outcome = quantize_text_encoders(pipe, _target(), mode = "int8", family = "wan-umt5")
assert outcome.mode == TE_QUANT_FP8
# The downgrade is reported, not silent: this is what the status badge renders.
assert outcome.status == "fell_back"
assert "no measured keep-bf16 schedule" in outcome.reason and "wan-umt5" in outcome.reason
assert int8_calls == [] and fp8_calls == [te]
def test_quantize_fp8_dynamic_uses_compute_caster(monkeypatch):
# fp8_dynamic routes to the torchao per-row compute caster (not the layerwise one) and needs no per-family schedule.
_stub_torch(monkeypatch, cc = (9, 0))
calls: list = []
monkeypatch.setattr(dp, "_cast_fp8_dynamic", lambda enc, tgt: calls.append(enc))
te = object()
pipe = types.SimpleNamespace(text_encoder = te)
outcome = quantize_text_encoders(pipe, _target(), mode = "fp8_dynamic")
assert outcome.mode == TE_QUANT_FP8_DYNAMIC
assert calls == [te]
def test_quantize_int8_unsupported_hw_is_noop(monkeypatch):
# int8 on pre-Ampere silicon (no int8 tensor cores) applies nothing.
_stub_torch(monkeypatch, cc = (7, 5))
monkeypatch.setattr(dp, "_cast_int8_selective", lambda *a: pytest.fail("must not cast"))
pipe = types.SimpleNamespace(text_encoder = object())
assert quantize_text_encoders(pipe, _target(), mode = "int8", family = "qwen-image").mode is None
def test_quantize_te_skips_torchao_modes_under_offload(monkeypatch):
# The torchao modes produce tensor subclasses that reject Module.to(), which an offload hook uses, so they are skipped under
# offload. Hardware supports every mode here, so a None result proves the skip; the casters fail if wrongly invoked.
_stub_torch(monkeypatch, cc = (10, 0))
monkeypatch.setattr(
dp, "_cast_fp8_dynamic", lambda *a: pytest.fail("torchao caster must not run")
)
monkeypatch.setattr(dp, "_cast_nvfp4", lambda *a: pytest.fail("torchao caster must not run"))
monkeypatch.setattr(
dp, "_cast_int8_selective", lambda *a: pytest.fail("torchao caster must not run")
)
pipe = types.SimpleNamespace(text_encoder = object())
skipped = quantize_text_encoders(pipe, _target(), mode = "fp8_dynamic", offload_active = True)
assert skipped.mode is None and skipped.status == "unsupported"
assert "offload" in skipped.reason
assert quantize_text_encoders(pipe, _target(), mode = "nvfp4", offload_active = True).mode is None
assert (
quantize_text_encoders(
pipe, _target(), mode = "int8", family = "qwen-image", offload_active = True
).mode
is None
)
# Layerwise fp8 is not torchao and streams fine under offload, so it still engages.
fp8_calls: list = []
monkeypatch.setattr(dp, "_cast_fp8", lambda enc, tgt: fp8_calls.append(enc))
assert (
quantize_text_encoders(pipe, _target(), mode = "fp8", offload_active = True).mode
== TE_QUANT_FP8
)
assert len(fp8_calls) == 1
# ── block selection + real int8 filter closure ─────────────────────────────────
def test_keep_bf16_block_fqns_selects_first_and_last(monkeypatch):
torch = _stub_torch(monkeypatch)
module_list = torch.nn.ModuleList
layers = module_list([object() for _ in range(10)])
# A short stack (at most skip_first + skip_last) contributes nothing, since keeping it all would leave no interior to quantise.
short = module_list([object() for _ in range(4)])
enc = types.SimpleNamespace()
enc.named_modules = lambda: [("", enc), ("model.layers", layers), ("aux.blocks", short)]
keep = _keep_bf16_block_fqns(enc, 3, 2)
assert keep == {
"model.layers.0",
"model.layers.1",
"model.layers.2",
"model.layers.8",
"model.layers.9",
}
def _stub_transformer_quant(monkeypatch, captured):
# Reuse the committed factory's names but record what the int8 caster hands quantize_().
dtq = types.ModuleType("core.inference.diffusion_transformer_quant")
dtq.TQ_INT8 = "int8"
dtq.TQ_FP8 = "fp8"
dtq.DEFAULT_MIN_LINEAR_FEATURES = 512
dtq._make_quant_config = lambda scheme, *a, **k: f"cfg:{scheme}"
dtq.exclude_tokens_for_scheme = lambda scheme: ("modulation",)
def _make_filter_fn(
min_features,
exclude_name_tokens = (),
*,
require_bf16 = False,
):
def _f(module, fqn = ""):
return not any(tok in fqn for tok in exclude_name_tokens)
return _f
dtq.make_filter_fn = _make_filter_fn
monkeypatch.setitem(sys.modules, "core.inference.diffusion_transformer_quant", dtq)
tq = types.ModuleType("torchao.quantization")
def _quantize_(
module,
config,
filter_fn = None,
):
captured["config"] = config
captured["filter_fn"] = filter_fn
tq.quantize_ = _quantize_
monkeypatch.setitem(sys.modules, "torchao.quantization", tq)
# _cast_nvfp4 builds its config from here.
mx = types.ModuleType("torchao.prototype.mx_formats")
mx.NVFP4WeightOnlyConfig = lambda: "nvfp4cfg"
monkeypatch.setitem(sys.modules, "torchao.prototype.mx_formats", mx)
def test_int8_filter_keeps_blocks_and_towers_dense(monkeypatch):
# The real selective closure: interior Linears quantise while the kept first blocks, the vision tower, lm_head and the encoder's fp32-kept modules (T5 "wo") stay bf16.
torch = _stub_torch(monkeypatch)
captured: dict = {}
_stub_transformer_quant(monkeypatch, captured)
layers = torch.nn.ModuleList([object() for _ in range(8)])
enc = types.SimpleNamespace(_keep_in_fp32_modules = ["wo"])
enc.named_modules = lambda: [("model.layers", layers)]
_cast_int8_selective(enc, _target(), 3, 0)
assert captured["config"] == "cfg:int8"
ff = captured["filter_fn"]
# Kept first-3 decoder blocks stay bf16.
assert ff(object(), "model.layers.0.self_attn.q_proj") is False
assert ff(object(), "model.layers.2.mlp.gate_proj") is False
# An interior block is quantised.
assert ff(object(), "model.layers.5.self_attn.q_proj") is True
# Vision tower / lm_head / T5 wo are excluded by the shared token filter.
assert ff(object(), "visual.blocks.0.attn.qkv") is False
assert ff(object(), "lm_head") is False
assert ff(object(), "model.decoder.wo") is False
def test_nvfp4_filter_keeps_vision_tower_dense(monkeypatch):
# Weight-only NVFP4 on a text encoder must exclude the VLM vision tower / lm_head / T5 "wo" like the int8 / fp8 TE modes,
# since 4-bit-ing a Qwen2.5-VL image tower degrades the edit conditioning. _cast_nvfp4 used to quantise every nn.Linear.
_stub_torch(monkeypatch)
captured: dict = {}
_stub_transformer_quant(monkeypatch, captured)
enc = types.SimpleNamespace(_keep_in_fp32_modules = ["wo"])
_cast_nvfp4(enc, _target())
assert captured["config"] == "nvfp4cfg"
ff = captured["filter_fn"]
assert ff is not None # a filter is passed now, not None (which quantised everything)
# Vision tower / lm_head / T5 wo stay bf16; an interior projection still quantises.
assert ff(object(), "visual.blocks.0.attn.qkv") is False
assert ff(object(), "vision_tower.encoder.layers.0.mlp.fc1") is False
assert ff(object(), "lm_head") is False
assert ff(object(), "model.decoder.wo") is False
assert ff(object(), "model.layers.5.self_attn.q_proj") is True
# ── zero-output-row guard (per-row fp8 NaN protection) ───────────────────────────
class _FakeAmaxVec:
def __init__(self, vals):
self._vals = vals
def __eq__(self, other): # noqa: PLW0642 -- tensor-style elementwise compare
return _FakeAmaxVec([v == other for v in self._vals])
def any(self):
return _FakeScalar(any(self._vals))
class _FakeScalar:
def __init__(self, v):
self._v = v
def item(self):
return self._v
class _FakeWeight:
"""Tensor-shaped stand-in supporting the exact chain the guard runs:
``weight.abs().amax(dim = -1) == 0 -> .any().item()``."""
ndim = 2
def __init__(self, rows):
self._rows = rows
def abs(self):
return _FakeWeight([[abs(v) for v in r] for r in self._rows])
def amax(self, dim = -1):
return _FakeAmaxVec([max(r) for r in self._rows])
def test_weight_zero_output_row_detection():
# A dead output row NaNs torchao's per-row fp8 (scale 0 -> 0/0), and SDXL's text_encoder_2 really ships one in
# layers.2.self_attn.out_proj: every fp8_dynamic SDXL render was black until the row is kept dense.
zero_row = types.SimpleNamespace(weight = _FakeWeight([[0.1, 0.2], [0.0, 0.0]]))
dense = types.SimpleNamespace(weight = _FakeWeight([[0.1, 0.2], [0.3, 0.0]]))
assert dp._weight_has_zero_output_row(zero_row) is True
assert dp._weight_has_zero_output_row(dense) is False
# Non-2D / absent weights are not the per-row scheme's input: never flagged.
w3 = _FakeWeight([[1.0]])
w3.ndim = 3
assert dp._weight_has_zero_output_row(types.SimpleNamespace(weight = w3)) is False
assert dp._weight_has_zero_output_row(types.SimpleNamespace()) is False
# An unreadable weight falls through to quantize_'s own handling.
class _Boom:
@property
def weight(self):
raise RuntimeError("meta tensor")
assert dp._weight_has_zero_output_row(_Boom()) is False
def test_fp8_dynamic_filter_skips_zero_row_linear(monkeypatch):
# The fp8_dynamic caster leaves a zero-output-row Linear dense while the rest of the encoder still quantises (a family-wide deny would forfeit the win).
_stub_torch(monkeypatch)
captured: dict = {}
_stub_transformer_quant(monkeypatch, captured)
enc = types.SimpleNamespace(_keep_in_fp32_modules = [])
dp._cast_fp8_dynamic(enc, _target())
ff = captured["filter_fn"]
dead = types.SimpleNamespace(weight = _FakeWeight([[0.5, 0.5], [0.0, 0.0]]))
live = types.SimpleNamespace(weight = _FakeWeight([[0.5, 0.5], [0.5, 0.5]]))
assert ff(dead, "text_model.encoder.layers.2.self_attn.out_proj") is False
assert ff(live, "text_model.encoder.layers.2.mlp.fc1") is True
def test_quantize_partial_cast_is_reported_as_a_mixture(monkeypatch):
# One encoder takes the cast and its sibling does not. The mode DID engage, so the old code
# returned "applied" and both loaders' fail-closed checks (which only look at mode is None)
# let the load through, recording the requested mode as the engaged precision -- while the
# prompt was conditioned by one quantised and one dense bf16 tower.
_stub_torch(monkeypatch)
good, bad = object(), object()
def _caster(enc, tgt):
if enc is bad:
raise RuntimeError("fp8 unsupported for this layer")
monkeypatch.setattr(dp, "_cast_fp8", _caster)
pipe = types.SimpleNamespace(text_encoder = good, text_encoder_2 = bad)
outcome = quantize_text_encoders(pipe, _target(), mode = "fp8")
assert outcome.mode == TE_QUANT_FP8
assert outcome.partial is True
assert outcome.status == "fell_back"
assert "text_encoder_2" in outcome.reason
def test_quantize_full_cast_is_not_partial(monkeypatch):
# The other side of the same fence: every present encoder cast, so nothing is a mixture and
# the loaders must not refuse.
_stub_torch(monkeypatch)
monkeypatch.setattr(dp, "_cast_fp8", lambda enc, tgt: None)
pipe = types.SimpleNamespace(text_encoder = object(), text_encoder_2 = object())
outcome = quantize_text_encoders(pipe, _target(), mode = "fp8")
assert outcome.partial is False and outcome.status == "applied"
def test_int8_without_a_schedule_reports_fp8_as_the_effective_mode():
# quantize_text_encoders rewrites an int8 request to layerwise fp8 on any family with no
# keep-bf16 schedule, and that path never touches torchao. A gate that asks about the raw
# int8 therefore refuses loads the runtime would happily run and report as fell_back: on a
# host whose torchao cannot do int8 while fp8 still works, every unscheduled family died.
assert effective_te_quant(TE_QUANT_INT8, "z-image-turbo") == TE_QUANT_FP8
assert effective_te_quant(TE_QUANT_INT8, None) == TE_QUANT_FP8
# A family WITH a schedule really does run int8, so the gate must keep asking about int8.
assert effective_te_quant(TE_QUANT_INT8, "qwen-image") == TE_QUANT_INT8
assert effective_te_quant(TE_QUANT_INT8, "Flux.2-Dev") == TE_QUANT_INT8
# Every other mode is its own effective mode, and absent stays absent.
assert effective_te_quant(TE_QUANT_FP8_DYNAMIC, "z-image-turbo") == TE_QUANT_FP8_DYNAMIC
assert effective_te_quant(None, "qwen-image") is None