diffusion_train_extras hosts the opt-in training extras: LoRAEMA shadows only the trainable adapter params (warmup-ramped decay, default 0.99, exported as a second adapter under output_dir/ema), PersistentConditioningCache stores latent posterior stats and caption embeddings as safetensors keyed by content hash + family + resolution, and the aspect-ratio bucketing helpers group mixed-aspect datasets into same-area divisor-snapped shapes. The DiT trainer wires the first two behind config flags that default to the current behavior: ema_decay (0 disables) and cond_cache_dir (None disables). A fully warm cache skips loading the VAE and text encoders entirely; a cache hit is bit-identical to a fresh encode, including the per-channel qwen latent normalization. Also fixes the stale _gather_sigmas call in the perf test that still passed the scheduler instead of the sigma table.
264 lines
9.8 KiB
Python
264 lines
9.8 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 diffusion training extras: LoRA EMA math, the persistent
|
|
conditioning cache, aspect-ratio bucketing, and the short-run preset plumbing.
|
|
|
|
CPU-only; the full trainer integration is exercised by the live GPU smokes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from core.training.diffusion_train_extras import (
|
|
BUCKET_DIVISOR,
|
|
BucketBatchSampler,
|
|
LoRAEMA,
|
|
PersistentConditioningCache,
|
|
assign_buckets,
|
|
compute_bucket,
|
|
)
|
|
from core.training.diffusion_train_common import (
|
|
DiffusionLoraConfig,
|
|
FAMILY_TRAIN_DEFAULTS,
|
|
train_defaults,
|
|
)
|
|
|
|
|
|
class _TinyLoRAish(torch.nn.Module):
|
|
"""Two params: one trainable (the 'LoRA'), one frozen (the 'base')."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.lora_A = torch.nn.Parameter(torch.ones(3))
|
|
self.base = torch.nn.Parameter(torch.full((2,), 7.0), requires_grad = False)
|
|
|
|
|
|
# ── LoRA EMA ──────────────────────────────────────────────────────────────────
|
|
def test_ema_tracks_only_trainable_params():
|
|
m = _TinyLoRAish()
|
|
ema = LoRAEMA(m, decay = 0.9, warmup = False)
|
|
assert len(ema) == 1
|
|
assert set(ema.state_dict()) == {"lora_A"}
|
|
|
|
|
|
def test_ema_fixed_decay_math():
|
|
m = _TinyLoRAish()
|
|
ema = LoRAEMA(m, decay = 0.9, warmup = False)
|
|
with torch.no_grad():
|
|
m.lora_A.fill_(2.0)
|
|
ema.update(m)
|
|
# shadow = 0.9 * 1 + 0.1 * 2 = 1.1
|
|
assert torch.allclose(ema.state_dict()["lora_A"], torch.full((3,), 1.1))
|
|
ema.update(m)
|
|
# shadow = 0.9 * 1.1 + 0.1 * 2 = 1.19
|
|
assert torch.allclose(ema.state_dict()["lora_A"], torch.full((3,), 1.19))
|
|
|
|
|
|
def test_ema_warmup_ramp_is_responsive_early_and_capped_late():
|
|
m = _TinyLoRAish()
|
|
ema = LoRAEMA(m, decay = 0.99, warmup = True)
|
|
# First update: decay = min(0.99, 1/11) -- the shadow mostly adopts the new value.
|
|
assert ema.effective_decay() == pytest.approx(1 / 11)
|
|
with torch.no_grad():
|
|
m.lora_A.fill_(2.0)
|
|
ema.update(m)
|
|
d = 1 / 11
|
|
assert torch.allclose(ema.state_dict()["lora_A"], torch.full((3,), d * 1.0 + (1 - d) * 2.0))
|
|
# Far into the run the ramp caps at the configured decay.
|
|
ema.updates = 10_000
|
|
assert ema.effective_decay() == pytest.approx(0.99)
|
|
|
|
|
|
def test_ema_copy_to_and_restore_roundtrip():
|
|
m = _TinyLoRAish()
|
|
ema = LoRAEMA(m, decay = 0.5, warmup = False)
|
|
with torch.no_grad():
|
|
m.lora_A.fill_(3.0)
|
|
ema.update(m) # shadow = 2.0
|
|
backup = ema.copy_to(m)
|
|
assert torch.allclose(m.lora_A.detach(), torch.full((3,), 2.0))
|
|
ema.restore(m, backup)
|
|
assert torch.allclose(m.lora_A.detach(), torch.full((3,), 3.0))
|
|
# The frozen base param is never touched.
|
|
assert torch.allclose(m.base.detach(), torch.full((2,), 7.0))
|
|
|
|
|
|
def test_ema_rejects_bad_decay():
|
|
with pytest.raises(ValueError):
|
|
LoRAEMA(_TinyLoRAish(), decay = 1.0)
|
|
with pytest.raises(ValueError):
|
|
LoRAEMA(_TinyLoRAish(), decay = -0.1)
|
|
|
|
|
|
# ── persistent conditioning cache ─────────────────────────────────────────────
|
|
def _make_image(tmp_path, name = "a.png", color = (255, 0, 0)):
|
|
from PIL import Image
|
|
|
|
p = tmp_path / name
|
|
Image.new("RGB", (8, 8), color).save(p)
|
|
return str(p)
|
|
|
|
|
|
def test_cache_roundtrip_is_bit_identical(tmp_path):
|
|
cache = PersistentConditioningCache(tmp_path / "cc", "qwen-image", 512)
|
|
img = _make_image(tmp_path)
|
|
key = cache.latent_key(img, (0.25, 0.75, True))
|
|
# Posterior stats exactly as the trainer holds them: fp32, normalisation folded in.
|
|
a = torch.randn(1, 16, 1, 64, 64, dtype = torch.float32)
|
|
b = torch.randn(1, 16, 1, 64, 64, dtype = torch.float32)
|
|
assert not cache.has(key)
|
|
cache.put(key, (a, b))
|
|
assert cache.has(key)
|
|
ra, rb = cache.get(key)
|
|
assert torch.equal(ra, a) and torch.equal(rb, b)
|
|
assert ra.dtype == torch.float32
|
|
|
|
|
|
def test_cache_preserves_none_slots_for_deterministic_families(tmp_path):
|
|
cache = PersistentConditioningCache(tmp_path / "cc", "flux.2-klein", 512)
|
|
a = torch.randn(4, 4)
|
|
key = "lat_manual_key"
|
|
cache.put(key, (a, None))
|
|
ra, rb = cache.get(key)
|
|
assert torch.equal(ra, a)
|
|
assert rb is None
|
|
|
|
|
|
def test_cache_text_entries_and_variable_tuples(tmp_path):
|
|
cache = PersistentConditioningCache(tmp_path / "cc", "qwen-image", 512)
|
|
key = cache.text_key("a photo of sks dog")
|
|
pe = torch.randn(1, 13, 3584)
|
|
mask = torch.ones(1, 13, dtype = torch.int64)
|
|
cache.put(key, (pe, mask))
|
|
rpe, rmask = cache.get(key)
|
|
assert torch.equal(rpe, pe) and torch.equal(rmask, mask)
|
|
# A different caption gets a different key.
|
|
assert cache.text_key("another caption") != key
|
|
|
|
|
|
def test_cache_key_tracks_content_family_and_resolution(tmp_path):
|
|
img = _make_image(tmp_path, "x.png")
|
|
c1 = PersistentConditioningCache(tmp_path / "cc", "flux.1", 512)
|
|
c2 = PersistentConditioningCache(tmp_path / "cc", "flux.1", 768)
|
|
c3 = PersistentConditioningCache(tmp_path / "cc", "qwen-image", 512)
|
|
v = (0.5, 0.5, False)
|
|
k1 = c1.latent_key(img, v)
|
|
assert c2.latent_key(img, v) != k1 # resolution in the key
|
|
assert c3.latent_key(img, v) != k1 # family in the key
|
|
assert c1.latent_key(img, (0.5, 0.5, True)) != k1 # variant in the key
|
|
# Editing the file content invalidates the key; a pure rename does not.
|
|
img2 = _make_image(tmp_path, "y.png", color = (0, 255, 0))
|
|
assert c1.latent_key(img2, v) != k1
|
|
import shutil
|
|
|
|
renamed = tmp_path / "renamed.png"
|
|
shutil.copy(img, renamed)
|
|
assert c1.latent_key(str(renamed), v) == k1
|
|
|
|
|
|
def test_cache_corrupt_entry_returns_none(tmp_path):
|
|
cache = PersistentConditioningCache(tmp_path / "cc", "flux.1", 512)
|
|
cache.path_for("bad_key").write_bytes(b"not a safetensors file")
|
|
assert cache.get("bad_key") is None
|
|
assert cache.get("absent_key") is None
|
|
|
|
|
|
# ── aspect-ratio bucketing ────────────────────────────────────────────────────
|
|
def test_square_bucket_is_exactly_base_resolution():
|
|
assert compute_bucket(1000, 1000, 512) == (512, 512)
|
|
assert compute_bucket(64, 64, 768) == (768, 768)
|
|
|
|
|
|
def test_buckets_preserve_area_and_divisor():
|
|
for w, h in ((1920, 1080), (1080, 1920), (800, 600), (512, 768)):
|
|
bw, bh = compute_bucket(w, h, 512)
|
|
assert bw % BUCKET_DIVISOR == 0 and bh % BUCKET_DIVISOR == 0
|
|
# Same-area constraint: within ~20% of base^2 after snapping.
|
|
assert 0.8 < (bw * bh) / (512 * 512) < 1.25
|
|
# Orientation preserved.
|
|
assert (bw >= bh) == (w >= h)
|
|
|
|
|
|
def test_extreme_ratios_clamp():
|
|
bw, bh = compute_bucket(10_000, 100, 512, max_ratio = 2.0)
|
|
assert bw / bh <= 2.0 + 1e-6
|
|
|
|
|
|
def test_assign_buckets_groups_by_shape():
|
|
sizes = [(1000, 1000), (998, 1004), (1920, 1080), (1080, 1920)]
|
|
buckets = assign_buckets(sizes, 512)
|
|
assert buckets[(512, 512)] == [0, 1]
|
|
assert sum(len(v) for v in buckets.values()) == len(sizes)
|
|
|
|
|
|
def test_bucket_batch_sampler_never_mixes_shapes_and_covers_all():
|
|
buckets = {(512, 512): [0, 1, 2], (640, 384): [3, 4]}
|
|
sampler = BucketBatchSampler(buckets, random.Random(0))
|
|
seen: set[int] = set()
|
|
for _ in range(50):
|
|
shape, idxs = sampler.next_batch(2)
|
|
assert len(idxs) == 2
|
|
assert set(idxs) <= set(buckets[shape])
|
|
seen.update(idxs)
|
|
assert seen == {0, 1, 2, 3, 4}
|
|
|
|
|
|
def test_bucket_batch_sampler_wraps_small_bucket():
|
|
sampler = BucketBatchSampler({(512, 512): [7]}, random.Random(1))
|
|
shape, idxs = sampler.next_batch(3)
|
|
assert shape == (512, 512) and idxs == [7, 7, 7]
|
|
|
|
|
|
def test_bucket_batch_sampler_is_seed_deterministic():
|
|
buckets = {(512, 512): [0, 1, 2], (640, 384): [3, 4]}
|
|
a = BucketBatchSampler(buckets, random.Random(42))
|
|
b = BucketBatchSampler(buckets, random.Random(42))
|
|
assert [a.next_batch(2) for _ in range(10)] == [b.next_batch(2) for _ in range(10)]
|
|
|
|
|
|
def test_bucket_batch_sampler_rejects_empty():
|
|
with pytest.raises(ValueError):
|
|
BucketBatchSampler({}, random.Random(0))
|
|
|
|
|
|
# ── preset plumbing ───────────────────────────────────────────────────────────
|
|
def test_flow_families_carry_warmup_presets():
|
|
for family in ("flux.1", "qwen-image", "flux.2-klein", "flux.2-dev"):
|
|
assert FAMILY_TRAIN_DEFAULTS[family]["lr_warmup_steps"] > 0
|
|
assert train_defaults(family)["lr_warmup_steps"] > 0
|
|
# Families without a measured warmup preset keep their previous defaults untouched.
|
|
assert "lr_warmup_steps" not in FAMILY_TRAIN_DEFAULTS["sdxl"]
|
|
|
|
|
|
def _cfg(**kw):
|
|
return DiffusionLoraConfig(
|
|
base_model = "stabilityai/stable-diffusion-xl-base-1.0",
|
|
data_dir = "d",
|
|
output_dir = "o",
|
|
**kw,
|
|
)
|
|
|
|
|
|
def test_config_defaults_keep_current_behavior():
|
|
n = _cfg().normalized()
|
|
assert n.ema_decay == 0.0 # EMA off by default
|
|
assert n.cond_cache_dir is None # persistent cache off by default
|
|
|
|
|
|
def test_config_ema_decay_validation_and_coercion():
|
|
assert _cfg(ema_decay = "0.99").normalized().ema_decay == 0.99
|
|
with pytest.raises(ValueError):
|
|
_cfg(ema_decay = 1.0).normalized()
|
|
with pytest.raises(ValueError):
|
|
_cfg(ema_decay = -0.1).normalized()
|
|
with pytest.raises(ValueError):
|
|
_cfg(ema_decay = "not-a-number").normalized()
|
|
|
|
|
|
def test_config_blank_cond_cache_dir_means_off():
|
|
assert _cfg(cond_cache_dir = " ").normalized().cond_cache_dir is None
|
|
assert _cfg(cond_cache_dir = "/tmp/cc").normalized().cond_cache_dir == "/tmp/cc"
|