unsloth/studio/backend/tests/test_diffusion_dit_trainer.py
Daniel Han d5be6fb5a3 Add FLUX.2 Klein and FLUX.2-dev DiT LoRA training
Register flux.2-klein and flux.2-dev in the DiT trainer following the
upstream DreamBooth references: latents train patchified and batch-norm
normalized from the VAE posterior mode, the packed forward reuses
step-invariant position ids, and the guidance vector (3.5) is gated on
the variant's guidance_embeds config. Conditioning stacks load per
variant (Mistral via Flux2Pipeline for dev, Qwen3 via Flux2KleinPipeline
for Klein) and are encoded and freed before the transformer lands on the
device. The fused single-stream to_qkv_mlp_proj joins the attention
projections in the LoRA targets; the single-stream out projection stays
dense because its to_out suffix would also match the double-stream
ModuleList container.

Wire both families through the training registry (family set, labels,
VRAM notes, rank 16 / lr 1e-4 defaults, bf16-only preflight), mark them
trainable with train base repos in the family registry, add FLUX.2-dev
to the gated-repo token check, and trust both official bases for
training downloads.

Verified on B200: 30-step klein int8 (19.6s) and nf4 (20.9s) and dev
int8 (52.0s) runs train with finite decreasing loss and the saved
adapters apply on the bf16 base pipeline (weight 0 reproduces the base
image exactly, weight 1 visibly restyles it).
2026-07-17 09:34:14 +00:00

386 lines
17 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 the flow-matching DiT LoRA trainer (FLUX.1 / FLUX.2 / Qwen-Image / Z-Image).
CPU-only: cover family resolution, the per-family spec table, the QLoRA prequant
heuristic, the bf16-only guard, and the gated-repo name check. The full training loop is
exercised by the live GPU smokes, not here."""
from __future__ import annotations
import sys
import types
import pytest
from core.training.diffusion_dit_trainer import (
_FLUX2_TARGETS,
_FLUX_TARGETS,
_GATED_TRAIN_REPOS,
_QWEN_TARGETS,
_SPECS,
_ZIMAGE_TARGETS,
_apply_mxfp8_training,
_assert_gated_access,
_mx_module_filter,
_repo_is_prequantized,
_resolve_base_precision,
_select_lora_targets,
_should_compile,
run_dit_lora_training,
)
from core.training.diffusion_train_common import (
DEFAULT_LORA_TARGETS,
DiffusionLoraConfig,
family_train_infos,
train_precision_modes,
)
def test_specs_cover_the_dit_families():
assert set(_SPECS) == {
"flux.1", "qwen-image", "z-image", "krea-2", "flux.2-klein", "flux.2-dev"
}
# FLUX / Qwen share the added-kv attention target set; Z-Image and Krea 2 are single-stream.
assert "add_q_proj" in _SPECS["flux.1"].lora_targets
assert "add_q_proj" in _SPECS["qwen-image"].lora_targets
assert "add_q_proj" not in _SPECS["z-image"].lora_targets
assert "add_q_proj" not in _SPECS["krea-2"].lora_targets
# Z-Image, Qwen, Krea 2 and both FLUX.2 variants are bf16-only.
assert _SPECS["z-image"].force_bf16 is True
assert _SPECS["qwen-image"].force_bf16 is True
assert _SPECS["krea-2"].force_bf16 is True
assert _SPECS["flux.2-klein"].force_bf16 is True
assert _SPECS["flux.2-dev"].force_bf16 is True
def test_flux2_specs_share_targets_and_split_conditioners():
# dev and Klein share the transformer (and so the LoRA target set) but load different
# conditioning pipelines and save through their own pipeline class.
klein, dev = _SPECS["flux.2-klein"], _SPECS["flux.2-dev"]
assert klein.lora_targets == dev.lora_targets == _FLUX2_TARGETS
# The fused single-stream projection is targeted; the plain to_out suffix is not (it
# would also match the double-stream ModuleList container, which peft cannot wrap).
assert "to_qkv_mlp_proj" in _FLUX2_TARGETS
assert "to_out.0" in _FLUX2_TARGETS
assert "to_out" not in _FLUX2_TARGETS
assert klein.load_conditioners is not dev.load_conditioners
assert klein.save is not dev.save
assert klein.load_transformer is dev.load_transformer
# The Mistral stack makes dev far heavier than the 4B Klein.
assert dev.dense_bf16_gb > klein.dense_bf16_gb
def test_select_lora_targets_uses_family_default_for_generic_config():
# normalized() fills lora_target_modules with the generic DEFAULT_LORA_TARGETS when a
# caller doesn't set it, so that value must resolve to the family's targets (which add
# the DiT-specific projections), not stay stuck on the generic SDXL list.
assert _select_lora_targets(DEFAULT_LORA_TARGETS, _FLUX_TARGETS) == _FLUX_TARGETS
assert _select_lora_targets(DEFAULT_LORA_TARGETS, _QWEN_TARGETS) == _QWEN_TARGETS
assert _select_lora_targets(DEFAULT_LORA_TARGETS, _ZIMAGE_TARGETS) == _ZIMAGE_TARGETS
def test_select_lora_targets_explicit_override_wins():
# Any OTHER explicit tuple is a deliberate override and must win over the family spec.
override = ("to_q", "to_k")
assert _select_lora_targets(override, _FLUX_TARGETS) == override
# The default request path (config carrying the generic default) reaches the spec.
cfg = DiffusionLoraConfig(
base_model = "black-forest-labs/FLUX.1-dev", data_dir = "d", output_dir = "o"
).normalized()
assert cfg.lora_target_modules == DEFAULT_LORA_TARGETS
assert (
_select_lora_targets(cfg.lora_target_modules, _SPECS["flux.1"].lora_targets)
== _FLUX_TARGETS
)
@pytest.mark.parametrize(
"repo, expected",
[
("unsloth/Qwen-Image-2512-unsloth-bnb-4bit", True),
("unsloth/Z-Image-Turbo-unsloth-bnb-4bit", True),
("some/model-int4", True),
("black-forest-labs/FLUX.1-dev", False),
("Tongyi-MAI/Z-Image-Turbo", False),
],
)
def test_prequant_heuristic(repo, expected):
assert _repo_is_prequantized(repo) is expected
def test_zimage_rejects_fp16_before_loading():
# bf16-only families must refuse an explicit fp16 request up front (no model load).
cfg = DiffusionLoraConfig(
base_model = "Tongyi-MAI/Z-Image-Turbo",
data_dir = "does-not-exist",
output_dir = "o",
mixed_precision = "fp16",
)
with pytest.raises(ValueError, match = "bf16"):
run_dit_lora_training(cfg)
def test_flux2_rejects_fp16_before_loading():
# Both FLUX.2 variants resolve from their repo names, and both are bf16-only: an
# explicit fp16 request fails in normalized() itself, before anything loads. Klein's
# base is ungated, so this exercises the precision guard directly (no token in play).
ok = DiffusionLoraConfig(
base_model = "black-forest-labs/FLUX.2-klein-4B", data_dir = "d", output_dir = "o"
).normalized()
assert ok.resolved_family == "flux.2-klein"
assert (
DiffusionLoraConfig(
base_model = "black-forest-labs/FLUX.2-dev", data_dir = "d", output_dir = "o"
)
.normalized()
.resolved_family
== "flux.2-dev"
)
cfg = DiffusionLoraConfig(
base_model = "black-forest-labs/FLUX.2-klein-4B",
data_dir = "does-not-exist",
output_dir = "o",
mixed_precision = "fp16",
)
with pytest.raises(ValueError, match = "bf16"):
run_dit_lora_training(cfg)
def test_flux2_bases_pass_the_trusted_base_gate():
# The FLUX.2 bases are training-side additions to the loader's trust allowlist, so the
# pre-download trust gate must accept them (and still refuse an arbitrary repo).
from core.training.diffusion_train_common import _assert_trusted_base_model
_assert_trusted_base_model("black-forest-labs/FLUX.2-klein-4B")
_assert_trusted_base_model("black-forest-labs/FLUX.2-dev")
with pytest.raises(ValueError, match = "untrusted"):
_assert_trusted_base_model("someone/random-flux2-finetune")
def test_gated_access_requires_token():
assert "black-forest-labs/flux.1-dev" in _GATED_TRAIN_REPOS
assert "black-forest-labs/flux.2-dev" in _GATED_TRAIN_REPOS
# No token -> clear, actionable error before any download.
with pytest.raises(ValueError, match = "gated"):
_assert_gated_access("black-forest-labs/FLUX.1-dev", None)
with pytest.raises(ValueError, match = "gated"):
_assert_gated_access("black-forest-labs/FLUX.1-dev", " ")
with pytest.raises(ValueError, match = "gated"):
_assert_gated_access("black-forest-labs/FLUX.2-dev", None)
# With a token, or for a non-gated repo, it is a no-op.
_assert_gated_access("black-forest-labs/FLUX.1-dev", "hf_realtoken")
_assert_gated_access("black-forest-labs/FLUX.2-dev", "hf_realtoken")
_assert_gated_access("Tongyi-MAI/Z-Image-Turbo", None)
_assert_gated_access("black-forest-labs/FLUX.2-klein-4B", None) # Klein is open
def test_family_train_infos_lists_dit_families():
infos = {i["name"]: i for i in family_train_infos()}
for fam in ("sdxl", "flux.1", "qwen-image", "z-image", "flux.2-klein", "flux.2-dev"):
assert fam in infos, f"{fam} missing from family_train_infos"
assert infos[fam]["default_base"]
assert infos[fam]["base_repos"]
assert "resolution" in infos[fam]["defaults"]
# FLUX default bases are the gated dev repos; their notes flag the license requirement.
assert infos["flux.1"]["default_base"] == "black-forest-labs/FLUX.1-dev"
assert "gated" in infos["flux.1"]["vram_note"].lower()
assert infos["flux.2-dev"]["default_base"] == "black-forest-labs/FLUX.2-dev"
assert "gated" in infos["flux.2-dev"]["vram_note"].lower()
# Klein-4B is open.
assert infos["flux.2-klein"]["default_base"] == "black-forest-labs/FLUX.2-klein-4B"
assert "gated" not in infos["flux.2-klein"]["vram_note"].lower()
# Z-Image defaults to the prequant nf4 repo for QLoRA.
assert "4bit" in infos["z-image"]["default_base"].lower()
def test_family_train_infos_sdxl_supports_compile_without_precision_modes(monkeypatch):
# Regional compile now applies to every family (the SDXL trainer compiles its U-Net
# blocks too), but base_precision stays DiT-only, so SDXL advertises no precision modes
# while a DiT family (z-image) keeps its own. Pin the precision list so the assertion
# holds regardless of the test host's GPU capability.
import core.training.diffusion_train_common as dtc
monkeypatch.setattr(dtc, "train_precision_modes", lambda: (["nf4", "bf16", "auto"], "auto"))
infos = {i["name"]: i for i in family_train_infos()}
assert infos["sdxl"]["supports_compile"] is True
assert infos["sdxl"]["precision_modes"] == []
assert infos["z-image"]["supports_compile"] is True
assert infos["z-image"]["precision_modes"] == ["nf4", "bf16", "auto"]
# ── mxfp8 base precision (DiT dense speed mode) ───────────────────────────────
def _linear(
in_features,
out_features,
bias = False,
):
import torch.nn as nn
return nn.Linear(in_features, out_features, bias = bias)
def test_mx_module_filter_accepts_dense_block_linear():
# A bias-free 3072x3072 attention/FFN linear at a normal block fqn is a valid mxfp8 target.
assert _mx_module_filter(_linear(3072, 3072), "blocks.0.ff.up") is True
def test_mx_module_filter_skips_biased_linear():
# The torchao 0.17 MX training path drops the bias term (its linear override computes
# input @ weight_t only), so an mxfp8'd biased FROZEN linear would silently lose its bias and
# corrupt the base output the LoRA regresses against. Biased linears must stay bf16.
assert _mx_module_filter(_linear(3072, 3072, bias = True), "blocks.0.ff.up") is False
def test_resolve_base_precision_explicit_mxfp8_requires_blackwell(monkeypatch):
# An explicit mxfp8 request on a non-Blackwell CUDA GPU must fail fast: its MX GEMM has no
# kernel below sm100 and would otherwise crash at the first training step, after a full dense
# transformer load. /info only advertises mxfp8 on sm100+, so this mirrors that gate.
import torch
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda *a, **k: (8, 9))
cfg = types.SimpleNamespace(base_precision = "mxfp8", mixed_precision = "bf16", base_model = "x")
with pytest.raises(ValueError, match = "Blackwell"):
_resolve_base_precision(cfg, None, "cuda")
def test_resolve_base_precision_explicit_mxfp8_ok_on_blackwell(monkeypatch):
import torch
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda *a, **k: (10, 0))
cfg = types.SimpleNamespace(base_precision = "mxfp8", mixed_precision = "bf16", base_model = "x")
assert _resolve_base_precision(cfg, None, "cuda") == "mxfp8"
def test_mx_module_filter_skips_lora_and_proj_out():
# LoRA-owned modules (adapters stay high precision) and the output projection are
# excluded, mirroring the fp8 filter's guards.
lin = _linear(3072, 3072)
assert _mx_module_filter(lin, "blocks.0.attn.to_q.lora_A.default") is False
assert _mx_module_filter(lin, "proj_out") is False
assert _mx_module_filter(lin, "x.proj_out.y") is False
def test_mx_module_filter_rejects_non_block_aligned_dims():
# MX block scaling tiles 32-wide, so a dim not divisible by 32 (3000) is rejected.
assert _mx_module_filter(_linear(3000, 3072), "blocks.0.ff.up") is False
def test_mx_module_filter_rejects_non_linear():
import torch.nn as nn
# A non-Linear module is never a target even if it exposes matching feature counts.
assert _mx_module_filter(nn.LayerNorm(3072), "blocks.0.norm") is False
def test_should_compile_auto_mxfp8_on_cuda():
# auto compiles the dense speed modes on cuda; int8 stays eager (torchao subclass);
# an explicit "off" wins over the mode.
cfg = DiffusionLoraConfig(base_model = "b", data_dir = "d", output_dir = "o")
assert _should_compile(cfg, False, "cuda", base_precision = "mxfp8") is True
assert _should_compile(cfg, False, "cuda", base_precision = "int8") is False
off = DiffusionLoraConfig(
base_model = "b", data_dir = "d", output_dir = "o", compile_transformer = "off"
)
assert _should_compile(off, False, "cuda", base_precision = "mxfp8") is False
def test_apply_mxfp8_training_failure_falls_back_with_warning(monkeypatch):
# An unavailable torchao MX path must never be fatal: force both API revisions' imports to
# raise, then assert the helper returns False and emits exactly one warning naming mxfp8.
monkeypatch.setitem(sys.modules, "torchao.prototype.mx_formats", None)
monkeypatch.setitem(sys.modules, "torchao.prototype.moe_training.config", None)
events = []
ok = _apply_mxfp8_training(object(), lambda e: events.append(e))
assert ok is False
warnings = [e for e in events if e["type"] == "warning"]
assert len(warnings) == 1
assert "mxfp8" in warnings[0]["message"]
def test_mxfp8_training_config_falls_back_to_the_torchao_0_17_api(monkeypatch):
# torchao 0.17 removed prototype.mx_formats.MXLinearConfig in favour of the
# MXFP8TrainingOpConfig recipe API; the config helper must fall back to it so the advertised
# mxfp8 mode keeps engaging on those installs instead of silently training dense bf16.
from types import SimpleNamespace
from core.training.diffusion_dit_trainer import _mxfp8_training_config
calls = {}
class _Recipe:
MXFP8_RCEIL = "mxfp8_rceil"
class _OpConfig:
@staticmethod
def from_recipe(recipe):
calls["recipe"] = recipe
return "cfg-0.17"
fake_config = SimpleNamespace(MXFP8TrainingOpConfig = _OpConfig, MXFP8TrainingRecipe = _Recipe)
monkeypatch.setitem(sys.modules, "torchao.prototype.mx_formats", None)
monkeypatch.setitem(
sys.modules, "torchao.prototype.moe_training", SimpleNamespace(config = fake_config)
)
monkeypatch.setitem(sys.modules, "torchao.prototype.moe_training.config", fake_config)
assert _mxfp8_training_config() == "cfg-0.17"
assert calls["recipe"] == _Recipe.MXFP8_RCEIL
def _patch_capability(monkeypatch, capability):
# Drive train_precision_modes' GPU probe: pretend CUDA is present at the given tensor
# core capability (fp8 needs sm89+, mxfp8 needs sm100+). The torchao probe is stubbed
# functional so these tests exercise the CAPABILITY gate on hosts without torchao
# (the CPU-only CI runner does not install it). is_bf16_supported must be stubbed True
# too: the dense modes gate on it, and an Ada/Blackwell GPU is by definition bf16-capable,
# so without this the modes collapse to nf4 on a CPU runner where the real probe is False
# (the test otherwise only passes on a bf16 GPU host).
import torch
import core.training.diffusion_train_common as dtc
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(torch.cuda, "is_bf16_supported", lambda *a, **k: True)
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda *a, **k: capability)
monkeypatch.setattr(dtc, "has_functional_torchao", lambda: True)
def test_train_precision_modes_blackwell_lists_mxfp8(monkeypatch):
# sm100 (Blackwell) exposes both fp8 and mxfp8, ordered before the "auto" pick.
_patch_capability(monkeypatch, (10, 0))
modes, recommended = train_precision_modes()
assert "mxfp8" in modes and "fp8" in modes
assert modes.index("mxfp8") < modes.index("auto")
assert modes.index("fp8") < modes.index("auto")
assert recommended == "auto"
def test_train_precision_modes_ada_has_fp8_without_mxfp8(monkeypatch):
# sm89 (Ada) is fp8-capable but not block-scaled mxfp8-capable.
_patch_capability(monkeypatch, (8, 9))
modes, _ = train_precision_modes()
assert "fp8" in modes
assert "mxfp8" not in modes
def test_train_precision_modes_newer_blackwell_has_mxfp8(monkeypatch):
# Any capability >= sm100 keeps mxfp8 (sm120 here).
_patch_capability(monkeypatch, (12, 0))
modes, _ = train_precision_modes()
assert "mxfp8" in modes
def test_train_precision_modes_pre_ampere_is_nf4_only(monkeypatch):
# A pre-Ampere GPU EMULATES bf16 (is_bf16_supported() True) but has no native bf16 tensor
# cores; the DiT trainer requires native bf16, so /info must offer nf4 only. Otherwise it
# advertises a start that evicts resident models and then fails the trainer's bf16 guard.
import torch
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(
torch.cuda, "is_bf16_supported", lambda *a, **k: True
) # emulation reports True
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda *a, **k: (7, 5)) # Turing
modes, recommended = train_precision_modes()
assert modes == ["nf4"]
assert recommended == "nf4"