unsloth/studio/backend/tests/test_diffusion_transformer_quant.py
Daniel Han 186f381bc6
Studio: diffusion UX polish and stronger auto policies (images + video) (#6885)
* Auto policies: deferred dense compile, video compile default, step cache and precision auto

Image dense loads with speed unset no longer sit at plain off: the load stays
bit-identical eager, and the 3rd generation in a session engages the default
compile profile plus the cuDNN attention upgrade mid-session (a one-off image
never pays the warmup, repeated use amortises it). Video dense loads resolve
straight to the default profile since a clip denoise amortises the compile
within a single run, and never to max.

Video also gains the image backend's tri-state auto policies: unset step cache
now decides from the default schedule and re-checks the actual step count per
generation, and unset precision (transformer_quant) hands the decision to the
hardware ladder instead of staying off. Memory badge reason now says plainly
that everything fits when no offload is planned.

* Rename Dtype to Precision, add the video Precision control, step cache Auto option

The images Advanced panel's Dtype row is now Precision (same control, clearer
name), and the video Advanced panel gains the matching Precision select wired
to the load route's existing transformer_quant field, gated to full-pipeline
loads the way the image control gates to GGUF. Step cache selects on both
pages gain an explicit Auto option as the default (the previous Off default
silently behaved as auto and never let anyone pin off), and the Speed and
Attention tooltips now state the deferred dense compile and the SageAttention
black-frame caveat.

* Model catalog: canonical diffusion model groups with device-aware routing

One canonical name per image/video model, its published artifacts (GGUF, FP8,
bnb-4bit, official BF16) as data, and pure routing helpers: suffix-stripped
canonical keys (owner-preserving; cross-owner merges only via explicit
aliases), group/artifact lookups, a flat back-compat options shim, load-spec
resolution replacing the pages' lookup tables, search matching over old ids
and format tokens, the GGUF fit ladder extracted from the variant expander,
and pickDefaultArtifact/pickDefaultQuant deciding what a bare group click
loads (downloaded first, then the best quality that fits 70 percent of VRAM,
GGUF as the safe fallback). Checked by npm run catalog:check, following the
i18n:check pattern.

* Picker: one canonical row per diffusion model with a format second level

The Images and Video pickers now render the curated catalog as one row per
model in Recommended: clicking loads the best artifact for the device (the
routed GGUF quant, a prequant FP8/bnb-4bit that fits, or the official BF16),
and a chevron opens the per-format list, with the GGUF row nesting the usual
quant expander. Live HF listing rows that belong to a group are deduplicated,
search collapses member repos into their group (old ids and format tokens
still match), and the On Device sections group cached member repos under the
same canonical name with the per-repo rows inside. Curated groups render from
the catalog rather than the HF listing, which finally surfaces LTX-2.3 in the
video Recommended list (its hub pipeline_tag is image-to-video, so the
text-to-video listing always missed it) and exposes the HunyuanVideo 720p
repack next to 480p.

Backend: /cached-models now tags trusted video-family repos text-to-video
instead of blanket text-to-image, and the pickers admit catalog-known
non-unsloth repos On Device, so cached Lightricks/Wan/Hunyuan pipelines
finally appear in the Video picker. Chat pickers pass no catalog and are
unchanged.

* Download formats, tab icons, plain-language train tips, 3-loop autoplay

The image Download button becomes a menu: PNG saves the original bytes with
the embedded recipe, JPEG and WebP re-encode client-side from the fetched
blob (JPEG flattened onto white). The video Download button gains MP4
(original, keeps audio), WebM and GIF; the latter two transcode server-side
from the stored MP4 via PyAV (VP9 realtime profile for WebM, ~12 fps adaptive
palette for GIF) behind a new gallery export route that 501s with a readable
message when a codec is missing.

Generated clips no longer loop forever: the player replays a clip three times
per selection, then pauses with controls up; a new generation or a refresh
gets its own three plays. The Create/Train tabs reuse the sidebar's New Chat
and Train icons (TestTubeOutlineIcon moved to a shared lib module), and every
Train tab helper text is now one plain sentence.

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

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

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

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

* Keep the Create/Train tab icon and label on one line

TabsTrigger renders its children inside a plain inline span and the
Tailwind preflight gives svg display:block, so the HugeiconsIcon forced
the label onto a second line. Wrap icon plus label in their own
inline flex row inside each trigger.

* Strip -int8 and -nvfp4 prequant suffixes in the model catalog key

canonicalKeyFor already lowercases before matching, so -GGUF/-FP8 in any
case were covered; -int8 and -nvfp4 were not in the suffix table, so
such repos rendered as standalone rows in Recommended and On Device
instead of standardizing into their base-name group and routing through
pickDefaultArtifact. Added both suffixes plus case-insensitivity and
routing assertions to the catalog check.

* Standardize non-catalog picker rows to their base model name

The curated catalog already collapses its own groups, but hub listing
rows and cached repos outside the catalog (ERNIE-Image, FLUX.2-klein,
Qwen-Image-Edit-2509, FLUX.2-dev) still rendered raw ids with -GGUF /
-FP8 style suffixes in Recommended and On Device.

- model-catalog.ts: new stripArtifactSuffixesForDisplay, a
  case-preserving twin of canonicalKeyFor's stripping that keeps the
  owner prefix and original casing for display.
- pickers.tsx: recommended hub rows and the downloaded GGUF/model rows
  pass their labels through it when a catalog is present, so only the
  diffusion pickers change; chat rows keep raw ids. Click targets keep
  the full repo id, and the format badge still shows the artifact kind.
- Catalog check covers the new helper across GGUF/FP8/int8/nvfp4 in
  both cases plus no-op and suffix-only names.

* Offer official BF16/FP8 artifacts per model group and fix gallery label clipping

Model picker changes so groups are not limited to unsloth quant repos:

- model-catalog.ts: each image group that has an official vendor pipeline
  now carries its BF16 (official) artifact as the top (highest quality)
  entry - Tongyi-MAI/Z-Image-Turbo, Qwen/Qwen-Image, Qwen/Qwen-Image-2512,
  Qwen/Qwen-Image-Edit-2511, black-forest-labs/FLUX.1-dev, FLUX.1-schnell
  and FLUX.1-Kontext-dev. The LTX-2.3 video group now lists Lightricks'
  own bf16 and fp8 distilled single-file checkpoints alongside the GGUF.
  Resident sizes are set from the actual weight totals (FLUX ships a
  duplicate single-file that from_pretrained ignores, so FLUX bf16 is ~32
  GB not 54). The repos that used to be aliases are now real artifacts.
- The router already prefers the highest-quality artifact that fits the
  0.7 x GPU budget, so a datacenter GPU now defaults to official BF16
  while consumer GPUs still route to the fitting quant or GGUF. That is
  why bnb-4bit was the Z-Image-Turbo default before: it was the only
  non-GGUF artifact and it was already downloaded.
- diffusion.py: allowlist the four official image repos not previously
  trusted (qwen/qwen-image-2512, qwen/qwen-image-edit-2511,
  black-forest-labs/flux.1-schnell, flux.1-kontext-dev). All verified as
  safetensors-only diffusers model_index pipelines. The LTX-2.3
  checkpoints are already on the video trust list.
- catalog check: BF16-wins-on-datacenter, quant-wins-on-consumer, and the
  single-file load specs for the LTX-2.3 checkpoints.

Also fixes the video gallery thumbnail caption: the leading duration was
clipped by the rounded corner and selection border, so the strip now has
enough left/bottom padding to clear the curve.

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

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

* video gallery: guard export transcode against a stream-less clip

_transcode_webm and _transcode_gif indexed src.streams.video[0] before
checking the stream list, so a container with no video stream raised a bare
IndexError that the broad handlers then re-labeled as a missing libvpx or
decoder. Raise an explicit RuntimeError naming the real cause in both the
WebM and GIF paths.

* Studio: honor explicit attention/format choices, fix distilled-LTX defaults and On Device catalog routing

* Remove stray planning notes accidentally committed to the branch

* video: add transformerQuant to the load callback deps

handleLoad reads transformerQuant but omitted it from the useCallback dep array,
so after the user changes only Precision and then selects a model or clicks
Reapply, the memoized callback keeps the stale closure and loads the previous
precision. The image page's equivalent callback already lists it.

* model picker: honor the format filter when routing catalog clicks; add catalog rows to the roving list

- routedArtifactFor now scopes a group's artifacts to the active format filter
  (the same matchesFormatFilter predicate the visibility check uses) before
  pickDefaultArtifact, so a group shown only because it owns a GGUF no longer
  routes a click to a large non-GGUF download. Covers both the Recommended and
  On Device grouped paths.
- hubOptionKeys now includes the catalog-group, search-catalog-group, and grouped
  On Device row keys in exact render order, so arrow/Home/End roving reaches the
  catalog rows instead of giving them a duplicate missing id and skipping them.

* model picker: don't treat a partial base cache as downloaded

A partially-cached base repo (a cancelled download that left only some weights)
was counted as downloaded, so an On Device click routed to a fresh multi-GB
re-download instead of the complete GGUF. The picker's endpoint (/api/models/
cached-models) did not carry a partial flag at all, so a frontend-only guard
could not see it. Surface partial from that endpoint by reusing the hub inventory
scan's snapshot-partial detector, plumb it through CachedModelRepo (backend +
frontend types), and skip partial base repos when building the downloaded set.

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

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

* model picker + diffusion: drop partial/unloadable cached rows, skip defer-compile before a LoRA gen

- On Device (cached non-GGUF) rows filtered partial-download snapshots back in: sortedCachedModels
  gated on passesTaskGate + a groupForRepoId key match but, unlike downloadedSet, never checked
  c.partial, so an incomplete unsloth snapshot showed as a loadable On Device row (click errors or
  silently re-fetches multi-GB). It also admitted repos that only match the catalog by group KEY
  (a base / uncurated-quant sibling like Qwen/Qwen-Image-2512) which have no loadable artifact and
  dead-end at the trust gate. Add !c.partial and gate on artifactForRepoId (what loadSpecFor
  resolves) instead of groupForRepoId, so a cached row shows only when the backend can load it.

- Deferred speed-auto engaged the compile profile on the 3rd generation BEFORE _apply_loras. A
  compiled transformer rejects LoRA (supports_lora is False) and _apply_loras raises before its
  unchanged-selection no-op, so once compile engaged every LoRA generation on that load failed
  permanently. Skip the deferral when a LoRA is requested (compile and LoRA are mutually exclusive)
  and let it engage on a later LoRA-free generation.

* Scope the cached-model partial probe to the listed snapshot dir

list_cached_models builds each row from the largest/complete copy across HF cache
roots, but _cached_repo_partial probed is_snapshot_partial with no repo_cache_dir,
so the scan spanned every root: a stale .incomplete copy in one root would flag a
complete copy in another as partial and hide the usable model from the picker (the
click then routes to a re-download). Forward the winning snapshot's repo_path so all
three partial signals are scoped to that copy, matching the sibling inventory paths
(models/dataset cache_inventory, local_inventory).

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

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

* Do not auto-route to gated repos, prefer complete cached copies, defer compile past attached LoRA, scope group expand keys

Four fixes:
- pickDefaultArtifact's not-downloaded ladder returned the gated BF16 FLUX.1-dev / Kontext-dev
  before the open GGUF on a large GPU, so a bare group click routed to a repo the user may lack
  license/token access to. Add a gated flag and skip gated artifacts in the not-downloaded ladder
  (an already-downloaded gated artifact is still returned).
- list_cached_models picked the largest duplicate cache copy and computed partial only on it, so a
  larger partial copy shadowed a smaller complete one; since partial rows are dropped from the
  picker the usable model vanished. Prefer completeness, then size.
- the deferred-speed compile engaged on a no-LoRA generation while an adapter from a prior
  generation was still attached, baking it into the compiled graph (the later unload is swallowed
  on a compiled pipe); also defer while adapters remain attached.
- routeGroupClick's GGUF fallback toggled the context-free canonicalId while the chevron toggles
  the context-scoped expandKey, leaving the format list un-collapsible in one context, dead in the
  other, and risking cross-context expansion; thread expandKey through.

* Guard video pipeline repos from deletion, drop the always-failing LTX FP8 artifact, prefer 720p Hunyuan

Three round-6 fixes:
- cached non-GGUF video repos now surface in the Video On-Device picker with the normal delete
  action, but /delete-cached only guarded chat + the Images engine, so a loaded/loading Wan / LTX /
  Hunyuan pipeline could have its HF snapshot removed from under it. Add a VideoBackend
  loading_repo_ids accessor and a video loaded/loading guard mirroring the Images one.
- the catalog advertised Lightricks/LTX-2.3-fp8 as loadable, but the LTX-2.3 loader refuses the
  official scaled-FP8 single file (.weight_scale/.input_scale) and points to GGUF/BF16, so a pick
  routed to a ~76 GB download that always fails on load. Remove the FP8 artifact.
- pickDefaultArtifact only sorts by format, so the HunyuanVideo group's 480p (listed first) beat
  the 720p even on GPUs where 720p fits the budget. List 720p first so the fit loop prefers it and
  falls back to 480p only on smaller cards.

* diffusion: add compute int8/fp8_dynamic text-encoder quant, wire into video

Add two torchao compute text-encoder quant modes to the diffusion precision
engine, alongside the existing layerwise fp8 and weight-only nvfp4:

- int8: per-token activation + per-channel weight (torch._int_mm), with per-layer
  keep-bf16 selection. int8 degrades on large encoders unless the most
  quant-sensitive decoder blocks stay bf16, so it engages only for families with
  a measured keep-bf16 schedule (qwen-image / qwen-image-edit keep first+last 6,
  flux.2-dev keeps first 3); a family without one falls back to fp8.
- fp8_dynamic: per-row fp8 compute (torch._scaled_mm), keeping the matmul in fp8
  on the tensor cores instead of upcasting each forward like the layerwise fp8.

The selective int8 caster reuses the committed transformer-quant factory
(_make_quant_config / make_filter_fn / exclude_tokens_for_scheme) plus a small
structural first/last-N block skip, so it depends only on committed APIs.

Wire text-encoder quant into the video backend, which previously loaded the
companion encoder (Gemma3 / UMT5 / Qwen2.5-VL) dense bf16 while quantising only
the DiT. text_encoder_quant is plumbed through the load request, validation, the
load chain, the resolved record, and status, mirroring the image backend; it
applies for every load kind (the encoder is dense regardless of how the DiT was
sourced). Widen the image and video load request Literals and add the video
status field.

Tests: int8 family-schedule routing and fp8 fallback, fp8_dynamic routing,
hardware gates (int8 sm_80+, fp8_dynamic sm_89+), the structural block selection,
the real int8 filter closure (keeps the first blocks plus the vision tower /
lm_head / T5 wo dense), and the video route threading and 422 validation.

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

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

* text-encoder quant: skip the torchao modes under offload (both backends)

quantize_text_encoders applied int8-with-schedule / fp8_dynamic / nvfp4 (all torchao) to the
text encoder regardless of the offload policy. An offload placement then moves the quantized encoder
with Module.to(), which torchao tensor subclasses reject (aten._has_compatible_shallow_copy_type is
unimplemented) -- a hard crash, the same one the DiT path already skips torchao quant under offload to
avoid. Add offload_active to quantize_text_encoders and skip the torchao modes when set; layerwise fp8
is not torchao and still streams under offload. Both the video and image loaders pass
offload_active = (offload policy != none).

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

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

* diffusion: skip non-bf16 linears for scaled_mm quant schemes

The fp8 / mxfp8 / nvfp4 schemes run on torch._scaled_mm and the fp4 / mx GEMMs,
which assert a bfloat16 input weight. On a mixed-precision DiT that keeps some
linears in fp32 for numerical stability (the Wan and Hunyuan video transformers
do this), quantize_ hits the first fp32 linear, raises, and the best-effort
wrapper swallows it to None, so the whole transformer stays dense with no error
and no speedup or memory saving.

Add a require_bf16 gate to make_filter_fn and pass it for the scaled_mm schemes
in quantize_transformer (and the fp8_dynamic text-encoder caster). The gate
skips non-bf16 linears so the scheme engages on the bf16 ones. int8 uses
torch._int_mm, which quantizes fp32/fp16 weights fine, so it leaves the gate off
and keeps its current coverage.

Verified on Wan2.2-TI2V-5B: fp8 and mxfp8 now quantize 303 linears via the
committed quantize_transformer path where they previously engaged 0.

* prequant builder: mirror the scaled-mm bf16 gate offline

The runtime DiT quantizer skips non-bf16 Linears for the scaled_mm schemes (fp8,
nvfp4, mxfp8) so the scheme engages on a mixed-precision transformer instead of
aborting on the first fp32 Linear. The offline prequant builder reused make_filter_fn
without that gate, so building an fp8/nvfp4/mxfp8 checkpoint for a mixed-precision DiT
(Wan, Hunyuan keep _keep_in_fp32_modules in fp32 even under torch_dtype=bf16) would hit
the same fp32 Linear and abort, breaking the builder's stated offline == runtime,
LPIPS-0 invariant. Thread require_bf16 = scheme in _SCALED_MM_SCHEMES through the builder,
record it in the checkpoint metadata, and verify it on load (mirrors the existing
exclude_name_tokens guard) so a future _SCALED_MM_SCHEMES change cannot silently load a
checkpoint built under the old filter.

* Keep nvfp4 fp32 linears quantised (bf16 gate is fp8/mxfp8 only)

Verified on torchao 0.17 / B200: fp8 per-row asserts 'PerRow quantization only
works for bfloat16 precision input weight' and mxfp8 asserts 'Only supporting bf16
out dtype', but NVFP4's high-precision conversion quantises an fp32 weight fine
(forward included). So the bf16 skip-gate must be fp8/mxfp8 only, not all scaled_mm
schemes -- otherwise nvfp4 leaves large fp32 projections dense, losing the intended
memory/speed gain. Rename _SCALED_MM_SCHEMES -> _REQUIRE_BF16_SCHEMES = (fp8, mxfp8)
and thread it through the runtime filter, the offline builder, and the loader
require_bf16 verification (offline == runtime preserved).

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-06 17:48:39 -07:00

586 lines
25 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 transformer quantisation (``diffusion_transformer_quant.py``).
Hermetic: torch + torchao are stubbed via ``sys.modules``, and the per-scheme smoke
probe (``_scheme_supported`` / ``_smoke_probe``) is monkeypatched where the test cares
about the selection ladder rather than the GPU probe, so everything runs CPU-only.
"""
from __future__ import annotations
import sys
import types
import pytest
import core.inference.diffusion_transformer_quant as tq
from core.inference.diffusion_transformer_quant import (
TQ_FP8,
TQ_INT8,
TQ_MXFP8,
TQ_NVFP4,
dense_transformer_supported,
make_filter_fn,
normalize_transformer_quant,
quantize_transformer,
select_transformer_quant_scheme,
)
def _target(*, device = "cuda", dtype = "bfloat16"):
return types.SimpleNamespace(device = device, dtype = dtype)
def _stub_torch(
monkeypatch,
*,
cc = (10, 0),
with_fp8 = True,
cuda_available = True,
device_name = "NVIDIA B200",
):
torch = types.ModuleType("torch")
torch.bfloat16 = "bfloat16"
torch.float16 = "float16"
if with_fp8:
torch.float8_e4m3fn = "float8_e4m3fn"
torch.cuda = types.SimpleNamespace(
is_available = lambda: cuda_available,
get_device_capability = lambda *a: cc,
# data-center name by default so the ladder tests get the data-center order;
# consumer tests pass a GeForce name (or monkeypatch _is_consumer_gpu).
get_device_name = lambda *a: device_name,
)
monkeypatch.setitem(sys.modules, "torch", torch)
return torch
# ── normalisation ─────────────────────────────────────────────────────────────
def test_normalize_transformer_quant():
assert normalize_transformer_quant(None) is None
assert normalize_transformer_quant("") is None
assert normalize_transformer_quant("none") is None
assert normalize_transformer_quant("off") is None
assert normalize_transformer_quant("AUTO") == "auto"
assert normalize_transformer_quant("INT8") == TQ_INT8
assert normalize_transformer_quant("fp8") == TQ_FP8
with pytest.raises(ValueError):
normalize_transformer_quant("int2")
# ── dense-source gate ───────────────────────────────────────────────────────────
def test_dense_transformer_supported_requires_cuda_bf16(monkeypatch):
_stub_torch(monkeypatch)
assert dense_transformer_supported(_target()) is True
assert dense_transformer_supported(_target(device = "cpu")) is False
assert dense_transformer_supported(_target(dtype = "float16")) is False
# ── scheme selection ladder ─────────────────────────────────────────────────────
def _allow(monkeypatch, allowed):
"""Force ``_scheme_supported`` to accept only ``allowed`` (simulates smoke results)."""
monkeypatch.setattr(tq, "_scheme_supported", lambda scheme, device: scheme in allowed)
def test_auto_blackwell_prefers_fp8_then_falls_back(monkeypatch):
_stub_torch(monkeypatch, cc = (10, 0))
# Even with every scheme available, auto picks fp8 on Blackwell: measured on a B200
# (torch 2.11 + torchao CUTLASS FP4), fp8 is both faster and more accurate than nvfp4
# for the DiT's shapes -- nvfp4's FP4 GEMM only wins on very large GEMMs, not here.
_allow(monkeypatch, {TQ_NVFP4, TQ_MXFP8, TQ_FP8, TQ_INT8})
assert select_transformer_quant_scheme(_target(), "auto") == TQ_FP8
# fp8 unavailable: nvfp4 is the next pick (above mxfp8 / int8).
_allow(monkeypatch, {TQ_NVFP4, TQ_MXFP8, TQ_INT8})
assert select_transformer_quant_scheme(_target(), "auto") == TQ_NVFP4
# Only mxfp8 + int8 left -> mxfp8 (still above int8).
_allow(monkeypatch, {TQ_MXFP8, TQ_INT8})
assert select_transformer_quant_scheme(_target(), "auto") == TQ_MXFP8
# Only int8 usable -> int8.
_allow(monkeypatch, {TQ_INT8})
assert select_transformer_quant_scheme(_target(), "auto") == TQ_INT8
def test_auto_consumer_blackwell_prefers_int8(monkeypatch):
# Consumer Blackwell (RTX 50xx): fp8 FP32-accumulate is throughput-halved while int8 is
# full-rate, so auto prefers int8 even though fp8 is available (the data-center default).
_stub_torch(monkeypatch, cc = (10, 0), device_name = "NVIDIA GeForce RTX 5090")
_allow(monkeypatch, {TQ_NVFP4, TQ_MXFP8, TQ_FP8, TQ_INT8})
assert select_transformer_quant_scheme(_target(), "auto") == TQ_INT8
# int8 unavailable -> falls back to the rest of the tier (fp8 next).
_allow(monkeypatch, {TQ_NVFP4, TQ_MXFP8, TQ_FP8})
assert select_transformer_quant_scheme(_target(), "auto") == TQ_FP8
def test_auto_consumer_ada_prefers_int8(monkeypatch):
# Consumer Ada (RTX 4090): int8 runs ~2x fp8's nerfed FP32-accumulate rate.
_stub_torch(monkeypatch, cc = (8, 9), device_name = "NVIDIA GeForce RTX 4090")
_allow(monkeypatch, {TQ_FP8, TQ_INT8})
assert select_transformer_quant_scheme(_target(), "auto") == TQ_INT8
def test_auto_workstation_unknown_prefers_int8(monkeypatch):
# Unknown / workstation name -> treated as consumer (the safe default) -> int8 first.
_stub_torch(monkeypatch, cc = (8, 9), device_name = "NVIDIA RTX A5000")
_allow(monkeypatch, {TQ_FP8, TQ_INT8})
assert select_transformer_quant_scheme(_target(), "auto") == TQ_INT8
def test_auto_professional_rtx_prefers_fp8(monkeypatch):
# Professional parts (RTX PRO 6000 Blackwell, RTX 6000 Ada) are classified datacenter
# by the rest of the backend, so auto keeps fp8 first (not int8) -- matching llama_cpp.
for device_name, cc in (
("NVIDIA RTX PRO 6000 Blackwell Server Edition", (10, 0)),
("NVIDIA RTX 6000 Ada Generation", (8, 9)),
):
_stub_torch(monkeypatch, cc = cc, device_name = device_name)
_allow(monkeypatch, {TQ_FP8, TQ_INT8})
assert select_transformer_quant_scheme(_target(), "auto") == TQ_FP8
def test_auto_ada_hopper_prefers_fp8(monkeypatch):
# Data-center Ada (L40S) / Hopper (H100): not nerfed -> fp8 first.
_stub_torch(monkeypatch, cc = (8, 9), device_name = "NVIDIA L40S")
_allow(monkeypatch, {TQ_NVFP4, TQ_MXFP8, TQ_FP8, TQ_INT8})
assert select_transformer_quant_scheme(_target(), "auto") == TQ_FP8
_stub_torch(monkeypatch, cc = (9, 0), device_name = "NVIDIA H100 80GB HBM3") # Hopper
assert select_transformer_quant_scheme(_target(), "auto") == TQ_FP8
def test_auto_ampere_prefers_int8(monkeypatch):
_stub_torch(monkeypatch, cc = (8, 0))
_allow(monkeypatch, {TQ_FP8, TQ_INT8}) # fp8 cores absent on Ampere -> int8 only in ladder
assert select_transformer_quant_scheme(_target(), "auto") == TQ_INT8
_stub_torch(monkeypatch, cc = (8, 6))
assert select_transformer_quant_scheme(_target(), "auto") == TQ_INT8
def test_auto_pre_ampere_unsupported(monkeypatch):
_stub_torch(monkeypatch, cc = (7, 5)) # Turing: below the int8-dynamic floor
_allow(monkeypatch, {TQ_INT8, TQ_FP8})
assert select_transformer_quant_scheme(_target(), "auto") is None
def test_explicit_scheme_honored_or_none(monkeypatch):
_stub_torch(monkeypatch, cc = (8, 0))
_allow(monkeypatch, {TQ_INT8})
assert select_transformer_quant_scheme(_target(), "int8") == TQ_INT8
# Explicit unsupported scheme is NOT silently downgraded -> None (-> GGUF fallback).
assert select_transformer_quant_scheme(_target(), "fp8") is None
assert select_transformer_quant_scheme(_target(), "nvfp4") is None
def test_select_none_when_disabled_or_non_cuda(monkeypatch):
_stub_torch(monkeypatch)
_allow(monkeypatch, {TQ_INT8, TQ_FP8, TQ_NVFP4})
assert select_transformer_quant_scheme(_target(), None) is None
assert select_transformer_quant_scheme(_target(device = "cpu"), "auto") is None
# ── _scheme_supported / _smoke_probe ────────────────────────────────────────────
def test_scheme_supported_shortcircuits(monkeypatch):
# No CUDA -> False without running the smoke probe.
_stub_torch(monkeypatch, cuda_available = False)
monkeypatch.setattr(tq, "_smoke_probe", lambda *a: pytest.fail("probe should not run"))
assert tq._scheme_supported(TQ_INT8, "cuda") is False
# fp8 requested but the fp8 dtype is missing -> False before the probe.
_stub_torch(monkeypatch, with_fp8 = False)
monkeypatch.setattr(tq, "_smoke_probe", lambda *a: pytest.fail("probe should not run"))
assert tq._scheme_supported(TQ_FP8, "cuda") is False
def test_smoke_probe_caches_and_tolerates_failure(monkeypatch):
tq._SMOKE_CACHE.clear()
calls = {"n": 0}
class _Lin:
def __init__(self, *a, **k):
pass
def to(self, **k):
return self
torch = types.ModuleType("torch")
torch.bfloat16 = "bfloat16"
torch.nn = types.SimpleNamespace(Linear = _Lin)
torch.randn = lambda *a, **k: object()
torch.no_grad = lambda: __import__("contextlib").nullcontext()
torch.cuda = types.SimpleNamespace(is_available = lambda: True, synchronize = lambda: None)
monkeypatch.setitem(sys.modules, "torch", torch)
tqz = types.ModuleType("torchao.quantization")
def _quantize_ok(
module,
config,
filter_fn = None,
):
calls["n"] += 1
tqz.quantize_ = _quantize_ok
tqz.Int8DynamicActivationInt8WeightConfig = lambda: "int8cfg"
tqz.Float8DynamicActivationFloat8WeightConfig = lambda: "fp8cfg"
monkeypatch.setitem(sys.modules, "torchao.quantization", tqz)
# _Lin is callable? No -> the forward lin(x) would fail. Make instances callable.
_Lin.__call__ = lambda self, x: x
assert tq._smoke_probe(TQ_INT8, "cuda") is True
assert tq._smoke_probe(TQ_INT8, "cuda") is True # cached, no second quantize_
assert calls["n"] == 1
# A scheme whose quantize_ raises -> probe False (and cached).
tq._SMOKE_CACHE.clear()
def _quantize_boom(
module,
config,
filter_fn = None,
):
raise RuntimeError("kernel unavailable")
tqz.quantize_ = _quantize_boom
assert tq._smoke_probe(TQ_FP8, "cuda") is False
# ── consumer-vs-datacenter detection (fp8 fast-accumulate gate) ──────────────────
def _stub_device_name(monkeypatch, name):
torch = types.ModuleType("torch")
torch.cuda = types.SimpleNamespace(get_device_name = lambda device = None: name)
monkeypatch.setitem(sys.modules, "torch", torch)
@pytest.mark.parametrize(
"name",
[
"NVIDIA GeForce RTX 5090",
"NVIDIA GeForce RTX 4090",
"NVIDIA RTX A4000", # workstation: A4000 token, NOT the data-center A40
"NVIDIA RTX A5000", # workstation: A5000 token, not professional/datacenter
"NVIDIA Some Future Card 9000", # unknown -> default consumer (fast accum is free on DC)
],
)
def test_is_consumer_gpu_true(monkeypatch, name):
_stub_device_name(monkeypatch, name)
assert tq._is_consumer_gpu() is True
@pytest.mark.parametrize(
"name",
[
"NVIDIA B200",
"NVIDIA B300", # Blackwell Ultra (matches llama_cpp datacenter regex)
"NVIDIA GH200 480GB", # Grace-Hopper superchip (was misread as consumer)
"NVIDIA H100 80GB HBM3",
"NVIDIA A100-SXM4-80GB",
"NVIDIA A40", # data-center Ampere (distinct token from RTX A4000)
"NVIDIA L40S",
"NVIDIA L4",
"Tesla V100-SXM2-16GB",
"NVIDIA RTX PRO 6000 Blackwell Server Edition", # professional -> datacenter-class
"NVIDIA RTX 6000 Ada Generation", # professional -> datacenter-class
],
)
def test_is_consumer_gpu_false_for_datacenter(monkeypatch, name):
_stub_device_name(monkeypatch, name)
assert tq._is_consumer_gpu() is False
def test_is_consumer_gpu_defaults_true_on_probe_failure(monkeypatch):
# No torch / no device name available -> assume consumer (safe: fast accum is free
# on data center and a win on consumer).
torch = types.ModuleType("torch")
torch.cuda = types.SimpleNamespace() # no get_device_name
monkeypatch.setitem(sys.modules, "torch", torch)
assert tq._is_consumer_gpu() is True
# ── filter ──────────────────────────────────────────────────────────────────────
def test_make_filter_fn(monkeypatch):
class _Lin:
def __init__(self, i, o):
self.in_features, self.out_features = i, o
torch = types.ModuleType("torch")
torch.nn = types.SimpleNamespace(Linear = _Lin)
monkeypatch.setitem(sys.modules, "torch", torch)
keep = make_filter_fn(512)
assert keep(_Lin(1024, 4096), "blocks.0.attn.to_q") is True
assert keep(_Lin(256, 4096), "time_proj") is False # small in_features -> skip
assert keep(_Lin(4096, 256), "out_proj") is False # small out_features -> skip
assert keep(object(), "not_linear") is False # non-Linear -> skip
assert keep(types.SimpleNamespace(), "no_attrs") is False
def test_require_bf16_schemes_excludes_nvfp4():
# fp8 and mxfp8 assert a bf16 weight (torchao 0.17 / B200: "PerRow quantization only works for
# bfloat16 ..." and "Only supporting bf16 out dtype ..."), so they gate on it; nvfp4 quantises an
# fp32 weight fine, so it is NOT gated (leaving its large fp32 projections quantised, not dense).
from core.inference.diffusion_transformer_quant import (
_REQUIRE_BF16_SCHEMES,
TQ_FP8,
TQ_MXFP8,
TQ_NVFP4,
TQ_INT8,
)
assert TQ_FP8 in _REQUIRE_BF16_SCHEMES
assert TQ_MXFP8 in _REQUIRE_BF16_SCHEMES
assert TQ_NVFP4 not in _REQUIRE_BF16_SCHEMES
assert TQ_INT8 not in _REQUIRE_BF16_SCHEMES
def test_make_filter_fn_require_bf16_skips_non_bf16(monkeypatch):
# fp8 / mxfp8 assert a bf16 weight, so require_bf16 must skip a fp32 Linear (which Wan / Hunyuan
# video DiTs keep) while keeping the bf16 ones -- otherwise a single fp32 layer raises inside
# quantize_ and no-ops the whole pass. int8 and nvfp4 leave it off (they quantise fp32 fine).
torch = types.ModuleType("torch")
torch.bfloat16, torch.float32 = "bf16", "fp32"
class _Lin:
def __init__(self, i, o, dtype):
self.in_features, self.out_features = i, o
self.weight = types.SimpleNamespace(dtype = dtype)
torch.nn = types.SimpleNamespace(Linear = _Lin)
monkeypatch.setitem(sys.modules, "torch", torch)
gated = make_filter_fn(512, require_bf16 = True)
assert gated(_Lin(1024, 4096, torch.bfloat16), "blocks.0.attn.to_q") is True
assert gated(_Lin(1024, 4096, torch.float32), "blocks.0.attn.to_q") is False # fp32 -> skip
assert gated(types.SimpleNamespace(in_features = 1024, out_features = 4096), "no_weight") is False
# int8 (require_bf16 off, the default) still quantises the fp32 linear.
assert make_filter_fn(512)(_Lin(1024, 4096, torch.float32), "blocks.0.attn.to_q") is True
def test_make_filter_fn_int8_excludes_modulation_and_embedders(monkeypatch):
# The int8 path skips the large M=1 AdaLN modulation / conditioning-embedder projections
# (they crash torch._int_mm's M>16), while keeping the attention / FFN compute layers and
# the sequence embedders. fp8 (no exclusion) keeps everything.
from core.inference.diffusion_transformer_quant import _INT8_EXCLUDE_NAME_TOKENS
class _Lin:
def __init__(self, i, o):
self.in_features, self.out_features = i, o
torch = types.ModuleType("torch")
torch.nn = types.SimpleNamespace(Linear = _Lin)
monkeypatch.setitem(sys.modules, "torch", torch)
keep = make_filter_fn(512, exclude_name_tokens = _INT8_EXCLUDE_NAME_TOKENS)
big = lambda: _Lin(3072, 18432) # noqa: E731 — large enough to pass min_features
# Excluded (M=1 modulation / conditioning embedders), despite large features:
for fqn in (
"transformer_blocks.0.norm1.linear",
"transformer_blocks.0.norm1_context.linear",
"single_transformer_blocks.0.norm.linear",
"norm_out.linear",
"transformer_blocks.0.img_mod.1",
"transformer_blocks.0.txt_mod.1",
"double_stream_modulation_img.linear",
"time_text_embed.timestep_embedder.linear_2",
"time_text_embed.guidance_embedder.linear_2",
"time_guidance_embed.timestep_embedder.linear_2",
):
assert keep(big(), fqn) is False, fqn
# Kept (M=seq compute layers + sequence embedders), NOT matched by the modulation tokens:
for fqn in (
"transformer_blocks.0.attn.to_q",
"transformer_blocks.0.ff.net.0.proj",
"single_transformer_blocks.0.proj_mlp",
"single_transformer_blocks.0.attn.to_qkv_mlp_proj",
"context_embedder", # "context" contains "text" -> must NOT be excluded
"txt_in",
):
assert keep(big(), fqn) is True, fqn
# Without the exclusion (fp8 path), the modulation layer is kept.
assert make_filter_fn(512)(big(), "transformer_blocks.0.norm1.linear") is True
# A None / empty fqn must not crash the exclusion check (defensive against the callback
# passing no name); with no name nothing matches the exclusion tokens -> kept.
assert keep(big(), None) is True
assert keep(big(), "") is True
def test_exclude_tokens_for_scheme_shared_by_runtime_and_builder():
# The runtime quantiser and the offline prequant builder must apply the SAME int8
# exclusion, or an int8 prequant artifact quantises the M=1 modulation/embedder linears
# and reintroduces the torch._int_mm crash. int8 gets the exclusion; others get none.
from core.inference.diffusion_transformer_quant import (
_INT8_EXCLUDE_NAME_TOKENS,
exclude_tokens_for_scheme,
)
assert exclude_tokens_for_scheme(TQ_INT8) == _INT8_EXCLUDE_NAME_TOKENS
for scheme in (TQ_FP8, TQ_NVFP4, TQ_MXFP8):
assert exclude_tokens_for_scheme(scheme) == ()
def test_exclude_tokens_for_scheme():
# The shared scheme->exclusion decision used by BOTH the runtime quantise path and the offline
# prequant-checkpoint builder, so an int8 checkpoint built ahead of time skips exactly the
# layers the runtime path skips (offline == runtime). int8 excludes the M=1 modulation /
# embedder tokens; every scaled_mm scheme excludes nothing.
from core.inference.diffusion_transformer_quant import (
_INT8_EXCLUDE_NAME_TOKENS,
exclude_tokens_for_scheme,
)
assert exclude_tokens_for_scheme(TQ_INT8) == _INT8_EXCLUDE_NAME_TOKENS
assert exclude_tokens_for_scheme(TQ_FP8) == ()
assert exclude_tokens_for_scheme(TQ_NVFP4) == ()
assert exclude_tokens_for_scheme(TQ_MXFP8) == ()
# ── apply ───────────────────────────────────────────────────────────────────────
def test_resolve_fast_accum(monkeypatch):
# None auto-detects by GPU class; an explicit bool forces it.
monkeypatch.setattr(tq, "_is_consumer_gpu", lambda *a: True)
assert tq._resolve_fast_accum(None) is True
monkeypatch.setattr(tq, "_is_consumer_gpu", lambda *a: False)
assert tq._resolve_fast_accum(None) is False
assert tq._resolve_fast_accum(True) is True # forced on (e.g. on a data-center card)
assert tq._resolve_fast_accum(False) is False # forced off (e.g. on a consumer card)
def test_fp8_config_uses_per_row_granularity():
"""FP8 must use PerRow (per-token activation + per-channel weight) scaling. torchao's
default is per-TENSOR: on a DiT with extreme activation outliers (z-image's ~6.6e4) one
outlier forces a tensor-wide scale that pushes normal values below fp8 resolution and the
denoise collapses to noise. This is the regression guard for that fix (validated on B200:
per-tensor fp8 = noise, per-row fp8 = matches bf16)."""
torchao_quant = pytest.importorskip("torchao.quantization")
per_row = getattr(torchao_quant, "PerRow", None)
if per_row is None:
pytest.skip("torchao build without PerRow granularity")
cfg = tq._make_quant_config(TQ_FP8)
gran = getattr(cfg, "granularity", None)
assert gran is not None, "fp8 config must set an explicit granularity, not torchao's default"
grans = gran if isinstance(gran, (list, tuple)) else [gran]
assert grans and all(isinstance(g, per_row) for g in grans), f"expected all PerRow, got {gran}"
def test_quantize_transformer_applies_and_marks(monkeypatch):
monkeypatch.setattr(
tq, "select_transformer_quant_scheme", lambda target, mode, family = None: TQ_FP8
)
seen: dict = {}
def _mk(scheme, fast_accum = None):
seen["scheme"], seen["fast_accum"] = scheme, fast_accum
return f"{scheme}cfg"
monkeypatch.setattr(tq, "_make_quant_config", _mk)
recorder: list = []
tqz = types.ModuleType("torchao.quantization")
tqz.quantize_ = lambda module, config, filter_fn = None: recorder.append(
(module, config, filter_fn)
)
monkeypatch.setitem(sys.modules, "torchao.quantization", tqz)
transformer = types.SimpleNamespace()
pipe = types.SimpleNamespace(transformer = transformer)
assert quantize_transformer(pipe, _target(), mode = "fp8", fast_accum = False) == TQ_FP8
assert len(recorder) == 1 and recorder[0][0] is transformer and recorder[0][1] == "fp8cfg"
assert callable(recorder[0][2]) # a filter_fn was passed
assert transformer._unsloth_runtime_quant == TQ_FP8 # diagnostic marker set
assert seen["fast_accum"] is False # the override is forwarded into the config
def test_quantize_transformer_none_when_unsupported(monkeypatch):
monkeypatch.setattr(
tq, "select_transformer_quant_scheme", lambda target, mode, family = None: None
)
pipe = types.SimpleNamespace(transformer = types.SimpleNamespace())
assert quantize_transformer(pipe, _target(), mode = "auto") is None
def test_quantize_transformer_tolerates_failure(monkeypatch):
monkeypatch.setattr(
tq, "select_transformer_quant_scheme", lambda target, mode, family = None: TQ_INT8
)
monkeypatch.setattr(tq, "_make_quant_config", lambda scheme: "cfg")
tqz = types.ModuleType("torchao.quantization")
def _boom(
module,
config,
filter_fn = None,
):
raise RuntimeError("partial quant failure")
tqz.quantize_ = _boom
monkeypatch.setitem(sys.modules, "torchao.quantization", tqz)
pipe = types.SimpleNamespace(transformer = types.SimpleNamespace())
# A quantise failure returns None (caller falls back to GGUF), never raises.
assert quantize_transformer(pipe, _target(), mode = "int8") is None
# ── family scheme deny (measured model-level breakage) ────────────────────────
def test_family_deny_auto_skips_fp8_for_qwen(monkeypatch):
# B200 with every scheme available: auto must NOT pick fp8 / nvfp4 / mxfp8 for the
# Qwen DiT (per-row fp8 renders black frames on it; see _FAMILY_SCHEME_DENY) and
# falls through the ladder to int8, which measures excellent on Qwen.
_stub_torch(monkeypatch, cc = (10, 0))
_allow(monkeypatch, {TQ_FP8, TQ_NVFP4, TQ_MXFP8, TQ_INT8})
assert select_transformer_quant_scheme(_target(), "auto", family = "qwen-image") == TQ_INT8
assert select_transformer_quant_scheme(_target(), "auto", family = "qwen-image-edit") == TQ_INT8
def test_family_deny_refuses_explicit_fp8_for_qwen(monkeypatch):
# An explicit fp8 request on qwen-image returns None (same contract as an
# unsupported scheme: the caller builds the GGUF pipeline instead). int8 stays
# honored on qwen, and fp8 stays honored on families outside the deny table.
_stub_torch(monkeypatch, cc = (10, 0))
_allow(monkeypatch, {TQ_FP8, TQ_INT8})
assert select_transformer_quant_scheme(_target(), "fp8", family = "qwen-image") is None
assert select_transformer_quant_scheme(_target(), "int8", family = "qwen-image") == TQ_INT8
assert select_transformer_quant_scheme(_target(), "fp8", family = "z-image") == TQ_FP8
def test_family_deny_no_family_keeps_ladder(monkeypatch):
# Without a family (or an unknown one) the ladder is unchanged: fp8 first on B200.
_stub_torch(monkeypatch, cc = (10, 0))
_allow(monkeypatch, {TQ_FP8, TQ_INT8})
assert select_transformer_quant_scheme(_target(), "auto") == TQ_FP8
assert select_transformer_quant_scheme(_target(), "auto", family = "sdxl") == TQ_FP8
def test_quantize_transformer_threads_family(monkeypatch):
# quantize_transformer passes the family down to the selector, so a denied
# (family, scheme) pair never reaches torchao.
_stub_torch(monkeypatch, cc = (10, 0))
_allow(monkeypatch, {TQ_FP8, TQ_INT8})
pipe = types.SimpleNamespace(transformer = types.SimpleNamespace())
called = {}
tqz = types.ModuleType("torchao.quantization")
def _quantize(
module,
config,
filter_fn = None,
):
called["scheme"] = True
tqz.quantize_ = _quantize
tqz.Int8DynamicActivationInt8WeightConfig = lambda: "int8-cfg"
tqz.Float8DynamicActivationFloat8WeightConfig = lambda **kw: "fp8-cfg"
tqz.PerRow = lambda: "per-row"
monkeypatch.setitem(sys.modules, "torchao.quantization", tqz)
assert quantize_transformer(pipe, _target(), mode = "fp8", family = "qwen-image") is None
assert called == {}