Shift Qwen-Image training sigmas to the inference distribution

Qwen-Image's scheduler skips its static shift under use_dynamic_shifting,
so the DiT trainer was drawing UNSHIFTED uniform-schedule sigmas for it
(mean sigma 0.50) while inference always runs the exponential mu = log 3
shift plus the shift_terminal 0.02 stretch. Add a flow_shift config lever:
"auto" (the new qwen-image default) rebuilds the training sigma table
through the scheduler's own time_shift and stretch_shift_to_terminal so
the draw matches the inference distribution exactly (mean sigma 0.72);
a numeric value applies the standard linear shift s*u/(1+(s-1)*u); 1.0
keeps the historical identity behavior and stays the default for FLUX,
Z-Image and Krea 2. The model timestep conditioning follows the shifted
sigma, gathered in fp32 so bf16 rounding never skews it.

Also wire two opt-in levers with off defaults: cfg_dropout (per-sample
empty-prompt conditioning dropout, encoded alongside the captions before
the text encoders are freed) and weighting_scheme="bell" (bsmntw-style
mid-schedule Gaussian loss weighting normalized to mean 1).

Verified with two 80-step rank-8 bf16 LoRA runs on Qwen/Qwen-Image
(identity vs auto, same seed): both converge with finite decreasing loss
and produce coherent same-seed previews. Unit tests cover the exact
transform, the shifted sampling distribution, per-family defaults and
config plumbing.
This commit is contained in:
Daniel Han 2026-07-20 06:21:49 +00:00
commit 039049b05e
3 changed files with 374 additions and 14 deletions

View file

@ -27,6 +27,7 @@ optimizer state and the frozen 4-bit base sit in VRAM during the loop.
from __future__ import annotations
import gc
import math
import os
import random
import time
@ -146,17 +147,62 @@ class _FamilySpec:
# ── shared flow-matching helpers ──────────────────────────────────────────────
def _gather_sigmas(scheduler, indices, device, dtype, n_dim):
def _gather_sigmas(sigma_table, indices, device, dtype, n_dim):
"""Gather per-sample sigmas for schedule ``indices`` and broadcast to ``n_dim``.
Index-based (no per-item search): ``indices`` are the positions ``_sample_timesteps``
drew from ``scheduler.timesteps``, and ``scheduler.sigmas`` is aligned with it, so this
returns exactly what the diffusers ``get_sigmas`` timestep-matching helper would."""
sigma = scheduler.sigmas[indices].to(device = device, dtype = dtype).flatten()
drew from ``scheduler.timesteps``, and ``sigma_table`` (the scheduler's own sigmas, or
the shifted copy from ``_training_sigma_table``) is aligned with it, so the identity
table returns exactly what the diffusers ``get_sigmas`` helper would."""
sigma = sigma_table[indices].to(device = device, dtype = dtype).flatten()
while sigma.ndim < n_dim:
sigma = sigma.unsqueeze(-1)
return sigma
def _training_sigma_table(scheduler, flow_shift):
"""The sigma table training draws index into, per ``cfg.flow_shift``.
``1.0`` (every non-Qwen family's default) returns ``scheduler.sigmas`` unchanged: the
historical behavior, correct for the families whose schedule already matches training
convention. ``"auto"`` (the qwen-image default) reproduces the family's INFERENCE sigma
distribution, which the scheduler never bakes into ``sigmas`` when
``use_dynamic_shifting`` is true (the static ``shift`` at init is skipped, so Qwen-Image
otherwise trains on unshifted uniform sigmas): apply the scheduler's own ``time_shift``
at mu = ``max_shift`` (Qwen pins base_shift = max_shift = log 3, so the inference mu is
constant at every resolution) followed by its ``stretch_shift_to_terminal`` -- using the
scheduler's methods keeps the transform faithful across diffusers versions. A numeric
value applies the standard linear shift s*u/(1+(s-1)*u) (musubi/kohya style
discrete_flow_shift)."""
sigmas = scheduler.sigmas
if flow_shift == "auto":
sc = scheduler.config
if not getattr(sc, "use_dynamic_shifting", False):
return sigmas # static-shift family: its init already baked the shift in
mu = float(getattr(sc, "max_shift", None) or math.log(3.0))
shifted = scheduler.time_shift(mu, 1.0, sigmas)
if getattr(sc, "shift_terminal", None):
# The stretch scales off the table's own final sigma, so it must see the full
# descending schedule (as it does at inference set_timesteps), never a batch.
shifted = scheduler.stretch_shift_to_terminal(shifted)
return shifted
s = float(flow_shift)
if s == 1.0:
return sigmas
return s * sigmas / (1.0 + (s - 1.0) * sigmas)
def _bell_loss_weights(num_train_timesteps):
"""bsmntw-style bell loss-weight table over the training schedule: a Gaussian bell
centered mid-schedule, floored at 0 and normalized to mean 1 (so the expected loss
scale is unchanged). Indexed by round(sigma * num_train_timesteps)."""
import torch
steps = num_train_timesteps
t = torch.arange(steps, dtype = torch.float32)
w = torch.exp(-2.0 * ((t - steps / 2) / steps) ** 2)
w = w - w.min()
return w * (steps / w.sum())
def _sample_timesteps(scheduler, batch_size, device):
"""Logit-normal density timestep sampling (weighting_scheme='logit_normal'), returning
(timesteps, indices) into the scheduler's schedule."""
@ -1498,8 +1544,12 @@ def _train_dit(cfg, spec, pairs, rng, device, weight_dtype, on_event, _check_sto
image_paths = [p for p, _ in pairs]
captions = [c for _, c in pairs]
uniq = sorted(set(captions))
encoded = spec.encode_prompts(pipe, uniq, device)
caption_embeds = {cap: emb for cap, emb in zip(uniq, encoded)}
# CFG dropout swaps a sample's conditioning for the empty prompt, so its embedding must
# be precomputed alongside the captions (the encoders are freed right after this).
cfg_dropout = float(getattr(cfg, "cfg_dropout", 0.0) or 0.0)
to_encode = uniq + ([""] if cfg_dropout > 0 and "" not in uniq else [])
encoded = spec.encode_prompts(pipe, to_encode, device)
caption_embeds = {cap: emb for cap, emb in zip(to_encode, encoded)}
_free_text_encoders(pipe)
gc.collect()
if device == "cuda":
@ -1596,6 +1646,19 @@ def _train_dit(cfg, spec, pairs, rng, device, weight_dtype, on_event, _check_sto
scheduler = FlowMatchEulerDiscreteScheduler.from_pretrained(
cfg.base_model, subfolder = "scheduler", token = cfg.hf_token
)
# Timestep-shift + loss-weighting setup (see _training_sigma_table). getattr defaults
# keep an un-normalized config (tests, direct callers) on the historical behavior.
flow_shift = getattr(cfg, "flow_shift", None)
if flow_shift is None:
flow_shift = "auto" if spec.family == "qwen-image" else 1.0
sigma_table = _training_sigma_table(scheduler, flow_shift)
shift_active = sigma_table is not scheduler.sigmas
num_train_ts = scheduler.config.num_train_timesteps
bell_weights = (
_bell_loss_weights(num_train_ts).to(device)
if str(getattr(cfg, "weighting_scheme", "none") or "none") == "bell"
else None
)
# The LR schedule advances once per optimizer update, so warmup/decay are counted in
# optimizer steps (matching the SDXL trainer; multiplying by the accumulation factor would
# stretch warmup past the run and never reach the decay).
@ -1652,11 +1715,24 @@ def _train_dit(cfg, spec, pairs, rng, device, weight_dtype, on_event, _check_sto
noise = torch.randn_like(latents)
timesteps, t_indices = _sample_timesteps(scheduler, latents.shape[0], device)
sigmas = _gather_sigmas(scheduler, t_indices, device, weight_dtype, latents.ndim)
sigmas = _gather_sigmas(sigma_table, t_indices, device, weight_dtype, latents.ndim)
if shift_active:
# The model's timestep conditioning must follow the shifted sigma (the
# scheduler convention is timestep = sigma * num_train_timesteps). Gather
# in fp32 from the table so bf16 rounding never skews the conditioning.
timesteps = (
sigma_table[t_indices].to(device = device, dtype = torch.float32).flatten()
* num_train_ts
)
noisy = (1.0 - sigmas) * latents + sigmas * noise
embeds = spec.collate(
[caption_embeds[captions[i]] for i in idxs],
[
caption_embeds[""]
if cfg_dropout > 0 and rng.random() < cfg_dropout
else caption_embeds[captions[i]]
for i in idxs
],
device,
weight_dtype,
pad_to = qwen_pad_to,
@ -1666,7 +1742,17 @@ def _train_dit(cfg, spec, pairs, rng, device, weight_dtype, on_event, _check_sto
transformer, noisy, timesteps, sigmas, embeds, cfg, device, weight_dtype
)
target = noise - latents
loss = F.mse_loss(model_pred.float(), target.float(), reduction = "mean")
if bell_weights is None:
loss = F.mse_loss(model_pred.float(), target.float(), reduction = "mean")
else:
per = F.mse_loss(model_pred.float(), target.float(), reduction = "none")
w_idx = (
(sigmas.flatten().float() * num_train_ts)
.long()
.clamp(0, num_train_ts - 1)
)
w = bell_weights[w_idx].view(-1, *([1] * (per.ndim - 1)))
loss = (per * w).mean()
(loss / cfg.gradient_accumulation_steps).backward()
step_loss += float(loss.detach()) / cfg.gradient_accumulation_steps

View file

@ -247,15 +247,25 @@ def get_trainer(family: str) -> Callable[..., str]:
# Families absent here fall back to the DiffusionLoraConfig defaults.
FAMILY_TRAIN_DEFAULTS: dict[str, dict[str, Any]] = {
"sdxl": {"lora_rank": 16, "learning_rate": 1e-4, "resolution": 1024},
"flux.1": {"lora_rank": 16, "learning_rate": 1e-4, "resolution": 512},
"qwen-image": {"lora_rank": 16, "learning_rate": 5e-5, "resolution": 512},
# Warmup defaults: a short LR ramp keeps the first adapter updates from overshooting on
# the big flow-matching DiTs (whose logit-normal timestep draw concentrates loss mass
# mid-schedule); the small warmups below are scaled for Studio's short-run step budgets.
"flux.1": {"lora_rank": 16, "learning_rate": 1e-4, "resolution": 512, "lr_warmup_steps": 20},
"qwen-image": {
"lora_rank": 16, "learning_rate": 5e-5, "resolution": 512, "lr_warmup_steps": 20,
},
"z-image": {"lora_rank": 16, "learning_rate": 1e-4, "resolution": 768},
# The Krea 2 authors' recommended starting point (their DreamBooth script defaults):
# rank/alpha 32, lr 3e-4, 512px.
"krea-2": {"lora_rank": 32, "learning_rate": 3e-4, "resolution": 512},
# The upstream FLUX.2 DreamBooth references default to rank 16 / lr 1e-4.
"flux.2-klein": {"lora_rank": 16, "learning_rate": 1e-4, "resolution": 512},
"flux.2-dev": {"lora_rank": 16, "learning_rate": 1e-4, "resolution": 512},
# The upstream FLUX.2 DreamBooth references default to rank 16 / lr 1e-4; FLUX.2's
# uniform timestep draw benefits most from a warmup ramp.
"flux.2-klein": {
"lora_rank": 16, "learning_rate": 1e-4, "resolution": 512, "lr_warmup_steps": 20,
},
"flux.2-dev": {
"lora_rank": 16, "learning_rate": 1e-4, "resolution": 512, "lr_warmup_steps": 20,
},
}
@ -496,6 +506,19 @@ class DiffusionLoraConfig:
# "fp8" (torchao float8 training on the frozen linears, Ada/Hopper/Blackwell + compile), or
# "auto" (by free VRAM + GPU class). Non-nf4 modes need a dense base repo. SDXL ignores it.
base_precision: str = "nf4"
# Training-time timestep shift applied to the flow-matching sigma draw. None resolves
# per family in normalized(): "auto" for qwen-image (reproduce the family's inference
# sigma distribution: the scheduler's exponential time_shift at mu = max_shift, then the
# shift_terminal stretch), 1.0 (identity, the historical behavior) for every other
# family. A numeric value applies the standard linear shift s*u/(1+(s-1)*u); 1.0 is a
# no-op. "auto" on a family without dynamic shifting falls back to identity.
flow_shift: Optional[Any] = None # float | "auto" | None
# Per-sample probability of replacing the caption conditioning with the empty prompt
# (classifier-free-guidance dropout). 0.0 (default) disables it entirely.
cfg_dropout: float = 0.0
# Per-sample loss weighting over the drawn timestep: "none" (default, unweighted MSE)
# or "bell" (bsmntw-style Gaussian bell centered mid-schedule, normalized to mean 1).
weighting_scheme: str = "none"
# How often to emit a progress event (in optimizer steps).
log_every: int = 1
# Optional explicit family override ("sdxl" / "flux.1" / ...); None = detect from base_model.
@ -576,6 +599,34 @@ class DiffusionLoraConfig:
f"{resolved_family}: its activations exceed fp8's range and corrupt the "
f"trained result. Use 'nf4', 'int8', 'bf16', or 'auto'."
)
# flow_shift: None resolves to the family default ("auto" only for qwen-image, whose
# scheduler skips its static shift under use_dynamic_shifting and would otherwise
# train on unshifted uniform sigmas); an explicit value is validated and kept.
flow_shift = self.flow_shift
if flow_shift is None:
flow_shift = "auto" if resolved_family == "qwen-image" else 1.0
if isinstance(flow_shift, str):
flow_shift = flow_shift.strip().lower()
if flow_shift != "auto":
try:
flow_shift = float(flow_shift)
except ValueError as exc:
raise ValueError(
f"flow_shift must be a positive number or 'auto', got {self.flow_shift!r}"
) from exc
if not isinstance(flow_shift, str):
flow_shift = float(flow_shift)
if flow_shift <= 0:
raise ValueError("flow_shift must be > 0 (1.0 disables the shift), or 'auto'")
try:
cfg_dropout = float(self.cfg_dropout or 0.0)
except (TypeError, ValueError) as exc:
raise ValueError(f"cfg_dropout must be a number, got {self.cfg_dropout!r}") from exc
if not 0.0 <= cfg_dropout <= 1.0:
raise ValueError("cfg_dropout must be between 0 and 1")
weighting_scheme = str(self.weighting_scheme or "none").strip().lower()
if weighting_scheme not in ("none", "bell"):
raise ValueError("weighting_scheme must be one of none / bell")
# A zero/negative gamma would zero out (or invert) the min-SNR weight and
# silently train on a degenerate loss; None is the documented disable.
if self.snr_gamma is not None and float(self.snr_gamma) <= 0:
@ -604,6 +655,9 @@ class DiffusionLoraConfig:
cache_variants = int(self.cache_variants),
compile_transformer = compile_transformer,
base_precision = base_precision,
flow_shift = flow_shift,
cfg_dropout = cfg_dropout,
weighting_scheme = weighting_scheme,
resolved_family = resolved_family,
)

View file

@ -0,0 +1,220 @@
# 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 DiT trainer's timestep-shift / CFG-dropout / loss-weighting levers.
CPU-only: cover the flow_shift config resolution (qwen-image defaults to "auto", every
other family stays on the identity 1.0), the exact sigma transform for the auto and
numeric modes, the shifted sampling distribution, and the bell weight table. The full
training loop is exercised by the live GPU smokes, not here."""
from __future__ import annotations
import math
import pytest
from core.training.diffusion_dit_trainer import (
_bell_loss_weights,
_gather_sigmas,
_sample_timesteps,
_training_sigma_table,
)
from core.training.diffusion_train_common import DiffusionLoraConfig
QWEN_SHIFT_TERMINAL = 0.02
def _qwen_scheduler():
# The Qwen/Qwen-Image scheduler config: shift=1.0 is SKIPPED at init because
# use_dynamic_shifting is true, base_shift = max_shift = log 3 (constant inference mu),
# exponential time shift, terminal stretch to 0.02.
from diffusers import FlowMatchEulerDiscreteScheduler
return FlowMatchEulerDiscreteScheduler(
num_train_timesteps = 1000,
shift = 1.0,
use_dynamic_shifting = True,
base_shift = math.log(3.0),
max_shift = math.log(3.0),
shift_terminal = QWEN_SHIFT_TERMINAL,
time_shift_type = "exponential",
)
def _flux_static_scheduler():
# A static-shift scheduler (shift baked into sigmas at init, no dynamic shifting).
from diffusers import FlowMatchEulerDiscreteScheduler
return FlowMatchEulerDiscreteScheduler(num_train_timesteps = 1000, shift = 3.0)
# ── config resolution ─────────────────────────────────────────────────────────
def test_flow_shift_defaults_per_family():
qwen = DiffusionLoraConfig(
base_model = "Qwen/Qwen-Image", data_dir = "d", output_dir = "o"
).normalized()
assert qwen.resolved_family == "qwen-image"
assert qwen.flow_shift == "auto"
flux = DiffusionLoraConfig(
base_model = "black-forest-labs/FLUX.1-dev", data_dir = "d", output_dir = "o"
).normalized()
assert flux.flow_shift == 1.0
zimg = DiffusionLoraConfig(
base_model = "Tongyi-MAI/Z-Image-Turbo", data_dir = "d", output_dir = "o"
).normalized()
assert zimg.flow_shift == 1.0
def test_flow_shift_explicit_values_and_validation():
cfg = DiffusionLoraConfig(
base_model = "Qwen/Qwen-Image", data_dir = "d", output_dir = "o", flow_shift = 2.2
).normalized()
assert cfg.flow_shift == 2.2
# String numerics from the Studio config path coerce; "auto" passes through.
assert (
DiffusionLoraConfig(
base_model = "b", data_dir = "d", output_dir = "o", flow_shift = "3.0"
)
.normalized()
.flow_shift
== 3.0
)
assert (
DiffusionLoraConfig(
base_model = "b", data_dir = "d", output_dir = "o", flow_shift = "AUTO"
)
.normalized()
.flow_shift
== "auto"
)
with pytest.raises(ValueError, match = "flow_shift"):
DiffusionLoraConfig(
base_model = "b", data_dir = "d", output_dir = "o", flow_shift = 0.0
).normalized()
with pytest.raises(ValueError, match = "flow_shift"):
DiffusionLoraConfig(
base_model = "b", data_dir = "d", output_dir = "o", flow_shift = "bogus"
).normalized()
def test_cfg_dropout_and_weighting_scheme_validation():
cfg = DiffusionLoraConfig(base_model = "b", data_dir = "d", output_dir = "o").normalized()
assert cfg.cfg_dropout == 0.0
assert cfg.weighting_scheme == "none"
on = DiffusionLoraConfig(
base_model = "b",
data_dir = "d",
output_dir = "o",
cfg_dropout = 0.1,
weighting_scheme = "bell",
).normalized()
assert on.cfg_dropout == 0.1
assert on.weighting_scheme == "bell"
with pytest.raises(ValueError, match = "cfg_dropout"):
DiffusionLoraConfig(
base_model = "b", data_dir = "d", output_dir = "o", cfg_dropout = 1.5
).normalized()
with pytest.raises(ValueError, match = "weighting_scheme"):
DiffusionLoraConfig(
base_model = "b", data_dir = "d", output_dir = "o", weighting_scheme = "sigma_sqrt"
).normalized()
def test_config_from_dict_plumbs_the_new_fields():
from core.training.diffusion_train_common import _config_from_dict
cfg = _config_from_dict(
{
"base_model": "Qwen/Qwen-Image",
"data_dir": "d",
"output_dir": "o",
"flow_shift": "auto",
"cfg_dropout": 0.05,
"weighting_scheme": "bell",
}
)
assert cfg.flow_shift == "auto"
assert cfg.cfg_dropout == 0.05
assert cfg.weighting_scheme == "bell"
# ── sigma table transforms ────────────────────────────────────────────────────
def test_auto_table_matches_the_exact_qwen_transform():
import torch
sched = _qwen_scheduler()
table = _training_sigma_table(sched, "auto")
base = sched.sigmas
# Exponential shift at mu = log 3 with sigma exponent 1 is exp(mu)/(exp(mu) + 1/u - 1)
# = 3u/(1 + 2u), then the terminal stretch maps the schedule's last sigma to 0.02.
shifted = 3.0 * base / (1.0 + 2.0 * base)
scale = (1.0 - shifted[-1]) / (1.0 - QWEN_SHIFT_TERMINAL)
expected = 1.0 - (1.0 - shifted) / scale
assert torch.allclose(table, expected, atol = 1e-6)
# Fixed-point spot checks: sigma 1.0 stays 1.0, the terminal sigma lands on 0.02, and
# the midpoint u = 0.5 rises to ~0.754 (3u/(1+2u) = 0.75 before the stretch).
assert abs(float(table[0]) - 1.0) < 1e-6
assert abs(float(table[-1]) - QWEN_SHIFT_TERMINAL) < 1e-6
assert abs(float(table[499]) - 0.75427) < 1e-3
# The table stays a valid descending schedule in (0, 1].
assert bool((table[:-1] > table[1:]).all())
def test_numeric_table_applies_the_linear_shift():
import torch
sched = _qwen_scheduler()
table = _training_sigma_table(sched, 2.2)
base = sched.sigmas
assert torch.allclose(table, 2.2 * base / (1.0 + 1.2 * base), atol = 1e-6)
# u = 0.5 under shift s maps to s/(s+1).
assert abs(float(table[499]) - 2.2 / 3.2) < 1e-3
def test_identity_and_static_families_are_untouched():
# flow_shift 1.0 must return the scheduler's own table object (no numeric drift for
# FLUX / Z-Image / Krea 2), and "auto" on a static-shift scheduler is a no-op too:
# its init already baked the shift into sigmas.
sched = _qwen_scheduler()
assert _training_sigma_table(sched, 1.0) is sched.sigmas
static = _flux_static_scheduler()
assert _training_sigma_table(static, "auto") is static.sigmas
assert _training_sigma_table(static, 1.0) is static.sigmas
def test_sampled_sigma_distribution_shifts_under_auto():
import torch
torch.manual_seed(0)
sched = _qwen_scheduler()
auto_table = _training_sigma_table(sched, "auto")
_, idx = _sample_timesteps(sched, 4096, "cpu")
base = _gather_sigmas(sched.sigmas, idx, "cpu", torch.float32, 1)
shifted = _gather_sigmas(auto_table, idx, "cpu", torch.float32, 1)
# Unshifted logit-normal draws center at 0.5; the mu = log 3 shift + terminal stretch
# pushes the mass toward high noise (mean ~0.72). Shift raises EVERY sample.
assert abs(float(base.mean()) - 0.5) < 0.03
assert float(shifted.mean()) > 0.68
assert bool((shifted >= base - 1e-6).all())
def test_gather_sigmas_broadcasts_to_ndim():
import torch
sched = _qwen_scheduler()
sig = _gather_sigmas(sched.sigmas, torch.tensor([0, 499, 999]), "cpu", torch.float32, 4)
assert sig.shape == (3, 1, 1, 1)
assert abs(float(sig[0].flatten()) - 1.0) < 1e-6
# ── bell weighting ────────────────────────────────────────────────────────────
def test_bell_weights_shape_peak_and_normalization():
w = _bell_loss_weights(1000)
assert w.shape == (1000,)
assert float(w.min()) >= 0.0
# Peak at mid-schedule, mean 1 so the expected loss scale is unchanged.
assert int(w.argmax()) == 500
assert abs(float(w.mean()) - 1.0) < 1e-5
assert float(w[500]) > float(w[0])
assert float(w[500]) > float(w[999])