diffu_test / diffu /train.py
Gabriel's picture
Diffu Studio ZeroGPU Space: app.py + vendored packages + aokit AoT + redesigned panel
333ff0e verified
Raw
History Blame Contribute Delete
46.2 kB
"""Training loop + entrypoint for Diffu (diffusers-first, Stage-1 flow matching).
Modern optimizations baked in: bf16 mixed precision (accelerate), gradient checkpointing
(diffusers), and EMA weights (diffusers EMAModel). torch.compile optional (bucket line widths
to limit recompiles). FlashAttention comes for free via SD3's SDPA attention. Single- and
multi-GPU via `accelerate launch` (see the Makefile `train-1gpu` / `train-2gpu` targets).
Stages: 0) VAE decoder fine-tune + recon-CER gate (run separately, see stage0_vae_gate.ipynb),
1) synthetic pretrain, 2) real Swedish adaptation, 3) per-collection LoRA.
Run (prefer the Makefile, which pins CUDA_VISIBLE_DEVICES so the busy GPU 2 is never touched):
make train-1gpu DATA_DIR=data_out # GPU 0 only
make train-2gpu DATA_DIR=data_out # GPU 0 + 1
Equivalent raw command (2 GPUs):
CUDA_VISIBLE_DEVICES=0,1 accelerate launch --multi_gpu --num_processes 2 --mixed_precision bf16 \
-m diffu.train --data-dir data_out --steps 100000 --batch-size 8
"""
from __future__ import annotations
import argparse
import math
import os
from functools import partial
from time import perf_counter
from typing import TYPE_CHECKING, Any
import torch
from accelerate import Accelerator
from accelerate.utils import DataLoaderConfiguration, DistributedDataParallelKwargs, set_seed
from diffusers.training_utils import EMAModel
from torch.utils.data import DataLoader
from .config import Config
from .data.collate import collate_lines
from .data.dataset import HandwritingLineDataset, load_manifest
from .model import Diffu
if TYPE_CHECKING:
from PIL import ImageFont
from PIL.Image import Image as PILImage
def trainable_parameters(model: Diffu) -> list[torch.nn.Parameter]:
"""Parameters trained in Stage 1 (flow matching).
Trained: SD3 backbone, DINOv3 style resampler, Unifont content encoder.
Frozen in Stage 1: the whole VAE — the Stage-1 forward never decodes, so it cannot receive a
gradient here (and including it would trip DDP's unused-parameter check on multi-GPU). VAE
decoder fine-tuning is Stage 0 (stage0_vae_gate.ipynb).
"""
params = list(model.backbone.parameters())
params += list(model.style.resampler.parameters())
params += list(model.style.pool.parameters()) # learned-query style attention-pool
params += list(model.glyph_content.parameters())
if model.glyph_latent is not None: # glyph_concat conv "glyph block"
params += list(model.glyph_latent.parameters())
if model.fill_ratio_mlp is not None: # fill-ratio AdaLN conditioning MLP (EMA shadows it too)
params += list(model.fill_ratio_mlp.parameters())
if model.repa_proj is not None: # REPA alignment head (when cfg.aux.repa)
params += list(model.repa_proj.parameters())
return [p for p in params if p.requires_grad]
@torch.no_grad()
def evaluate_val_loss(model: Diffu, val_loader: DataLoader, device: torch.device, max_batches: int) -> float:
"""Mean flow loss on held-out (volume-disjoint) lines.
The overfitting signal: this rising while train loss keeps falling = the model is memorizing
the training volumes instead of learning generalizable handwriting. Averaged over several
batches to damp the per-batch timestep noise so the trend is readable.
"""
model.eval()
total, seen = 0.0, 0
try:
for i, batch in enumerate(val_loader):
if i >= max_batches:
break
loss = model(batch["images"].to(device), batch["texts"], batch["style_pixel_values"].to(device))
total += loss.item()
seen += 1
finally:
model.train() # always restore train mode, even if a batch raises (shared module, rank-0 toggle)
return total / max(seen, 1)
def _label_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""A label font that can render Swedish glyphs (å ä ö). DejaVu if present, else PIL's default
(the bitmap default mangles non-ASCII, but a missing font must never crash a training run)."""
from PIL import ImageFont
for path in (
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
):
try:
return ImageFont.truetype(path, size)
except OSError:
continue
try:
return ImageFont.load_default(size=size)
except TypeError:
return ImageFont.load_default()
def _compose_sample_grid(
reals: list[PILImage],
gens: list[PILImage],
texts: list[str],
width: int,
line_h: int,
reads: list[str] | None = None,
style_texts: list[str] | None = None,
) -> PILImage:
"""Stack each (real, generated) pair into one labeled strip: a ``want: <text>`` caption, the REAL
target row (green tag) and OURS directly below (red tag). The burned-in tags make the trackio media
tab self-explanatory — which row is real vs ours is otherwise pure guesswork.
When ``reads`` (the recognizer's transcription of each OURS row) is given, a red
``HTR reads: <pred>`` caption is drawn directly UNDER the OURS row, so the PNG alone shows what
the recognizer thinks our line says (the legibility gap) at a glance."""
from PIL import Image, ImageDraw
lbl_w, cap_h = 88, 22
read_h = cap_h if reads is not None else 0
style_h = cap_h if style_texts is not None else 0
pair_h = style_h + cap_h + 2 * line_h + read_h
canvas = Image.new("RGB", (lbl_w + width, len(reals) * pair_h), "white")
draw = ImageDraw.Draw(canvas)
cap_font, tag_font = _label_font(15), _label_font(16)
for i, (real_img, gen_img, text) in enumerate(zip(reals, gens, texts, strict=False)):
y0 = i * pair_h
draw.line([(0, y0), (lbl_w + width, y0)], fill="#cccccc")
if style_texts is not None and i < len(style_texts): # the text the STYLE ref shows (purple) —
draw.text( # we ask for a DIFFERENT text, so a copy-the-style model renders THIS, not `want`
(lbl_w + 4, y0 + 4), f"style ref wrote: {style_texts[i]!r}", fill="#8250df", font=cap_font
)
top = y0 + style_h
draw.text((lbl_w + 4, top + 4), f"want: {text!r}", fill="black", font=cap_font)
canvas.paste(real_img.resize((width, line_h)), (lbl_w, top + cap_h))
canvas.paste(gen_img.resize((width, line_h)), (lbl_w, top + cap_h + line_h))
draw.text((6, top + cap_h + line_h // 2 - 8), "REAL", fill="#1a7f37", font=tag_font)
draw.text((6, top + cap_h + line_h + line_h // 2 - 8), "OURS", fill="#cf222e", font=tag_font)
if reads is not None and i < len(reads): # what the HTR recognizer reads our OURS line as
draw.text(
(lbl_w + 4, top + cap_h + 2 * line_h + 3),
f"HTR reads: {reads[i]!r}",
fill="#cf222e",
font=cap_font,
)
return canvas
@torch.no_grad()
def save_samples(
model: Diffu,
batch: dict[str, Any],
cfg: Config,
device: torch.device,
out_dir: str,
step: int,
n: int = 4,
cfg_eval_scale: float = 0.0,
) -> tuple[str, list[PILImage], list[PILImage] | None, list[str], list[PILImage], int, list[str]]:
"""Generate n held-out lines and save a labeled ``[REAL / OURS]`` strip to eyeball progress.
Each pair is captioned with the requested text; the real target (green REAL tag) sits above the
model's attempt (red OURS tag) — so legibility and any overflow are visible at a glance in the
trackio media tab without spinning up the app or decoding a 17 GB checkpoint.
Returns ``(path, gen_pils, gen_cfg_pils, texts, real_pils, width)``. ``gen_pils`` is the raw
(unguided) generation — also what the visual grid shows. If ``cfg_eval_scale > 0`` a SECOND,
CFG-guided generation is run and returned as ``gen_cfg_pils`` (else ``None``); the caller reads
both back to log gen_CER with AND without guidance, so we can see whether CFG actually helps.
``real_pils`` + ``width`` are returned so the caller can re-draw the grid with the reads burned in.
"""
from .generate import to_pil
n = min(n, len(batch["texts"]))
texts = batch["texts"][:n]
style_texts = batch.get("style_texts", [""] * len(batch["texts"]))[:n]
style = batch["style_pixel_values"][:n].to(device)
real = batch["images"][:n]
w = real.shape[-1]
latent_hw = (cfg.data.line_height // cfg.vae.downscale_factor, w // cfg.vae.downscale_factor)
# Sampling runs in eval mode; the try/finally guarantees train mode is restored even if a sampling
# step raises. Otherwise the shared module is left in eval() and the next training steps silently
# skip the CFG cond/text-dropout (it gates on `self.training`) — quietly breaking CFG training.
model.eval()
try:
# The VISUAL grid is unguided: high guidance over-saturates an undertrained model's output to
# pure white, hiding the real (on-manifold) generation. Raw output shows true progress.
gen = model.generate(texts, style, latent_hw=latent_hw, num_steps=cfg.flow.sample_steps)
gen_pils = [to_pil(gen[i]) for i in range(n)]
# The CFG variant is for MEASUREMENT only (gen_CER with guidance), not the grid — same texts/style
# so the two CER numbers are directly comparable at this step.
gen_cfg_pils: list[PILImage] | None = None
if cfg_eval_scale > 0.0:
gen_cfg = model.generate(
texts, style, latent_hw=latent_hw, num_steps=cfg.flow.sample_steps, cfg_scale=cfg_eval_scale
)
gen_cfg_pils = [to_pil(gen_cfg[i]) for i in range(n)]
finally:
model.train()
real_pils = [to_pil(real[i]) for i in range(n)]
canvas = _compose_sample_grid(
real_pils, gen_pils, list(texts), w, cfg.data.line_height, style_texts=list(style_texts)
)
sample_dir = os.path.join(out_dir, "samples")
os.makedirs(sample_dir, exist_ok=True)
path = os.path.join(sample_dir, f"step_{step:06d}.png")
canvas.save(path)
return path, gen_pils, gen_cfg_pils, list(texts), real_pils, w, list(style_texts)
def _setup_model(cfg: Config, *, grad_checkpoint: bool, compile_model: bool) -> Diffu:
"""Build the model and freeze everything Stage-1 flow matching never touches.
The VAE (encode is no_grad, decode unused) is frozen so DDP's reducer doesn't wait on gradients
that never arrive, which crashes on step 1 under ``--multi_gpu``.
"""
model = Diffu(cfg)
if grad_checkpoint:
model.backbone.enable_optimizations() # gradient checkpointing (off = ~20% faster on big VRAM)
if compile_model:
model.backbone.compile_blocks() # regional torch.compile (see Backbone.compile_blocks)
model.vae.requires_grad_(False)
return model
def _init_tracker(
track: bool, is_main: bool, config: dict[str, Any], track_space: str | None, run_name: str | None = None
) -> Any:
"""Initialise trackio on the main process (live dashboard + NaN alerts); return None otherwise.
``run_name`` (the out-dir basename) is passed so parallel runs get distinct dashboard names —
trackio auto-names collide when several runs launch at once with the same seed.
"""
if not (track and is_main):
return None
import trackio
trackio.init(project="diffu", name=run_name, config=config, space_id=track_space)
return trackio
def _run_profile(
accel: Accelerator,
model: torch.nn.Module,
dataloader: DataLoader,
params: list[torch.nn.Parameter],
out_dir: str,
profile_steps: int,
cond_dropout: float,
) -> Diffu:
"""Profile ``profile_steps`` real forward+backward steps and write the kernel report.
Mirrors the Stage-1 training step (model forward under accelerate's bf16 autocast, backward,
grad zeroing) but skips the optimizer/scheduler/EMA so the profile reflects compute only. The
report is written to ``out_dir/profile_step_table.txt`` on the main process. Called from
:func:`train` only when ``profile_steps > 0``; returns the unwrapped model immediately after.
"""
from .profiling import profile_callable, read_dcgm
batches = iter(dataloader)
def step() -> None:
nonlocal batches
try:
batch = next(batches)
except StopIteration: # small datasets: re-iterate so we always have profile_steps batches
batches = iter(dataloader)
batch = next(batches)
loss = model(batch["images"], batch["texts"], batch["style_pixel_values"], cond_dropout=cond_dropout)
accel.backward(loss)
for p in params:
p.grad = None
model.train()
report = profile_callable(
step,
warmup=2,
active=profile_steps,
trace_path=os.path.join(out_dir, "trace.json"),
with_stack=os.environ.get("DIFFU_PROFILE_STACK") == "1", # opt-in Python-line attribution
)
if accel.is_main_process:
os.makedirs(out_dir, exist_ok=True)
path = os.path.join(out_dir, "profile_step_table.txt")
with open(path, "w") as fh:
fh.write(report.render())
dcgm = read_dcgm()
if dcgm is not None:
fh.write(f"\n\n==== DCGM (GPU 0): {dcgm} ====\n")
print(f"profile written to {path}", flush=True)
print(report.render(), flush=True)
accel.wait_for_everyone()
return accel.unwrap_model(model)
def _warmup_cosine_min_lr(
optimizer: torch.optim.Optimizer,
*,
warmup_steps: int,
total_steps: int,
base_lr: float,
min_lr: float,
) -> torch.optim.lr_scheduler.LambdaLR:
"""Linear warmup then cosine decay to ``min_lr`` (a floor, not 0).
diffusers' ``get_cosine_schedule_with_warmup`` decays to exactly 0; this decays to a small ``min_lr``
instead, so late training keeps making tiny but non-zero updates.
"""
floor = min_lr / base_lr if base_lr > 0 else 0.0
def lr_lambda(step: int) -> float:
if step < warmup_steps:
return step / max(1, warmup_steps)
progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
cosine = 0.5 * (1.0 + math.cos(math.pi * min(1.0, progress)))
return floor + (1.0 - floor) * cosine
return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
def _resume_step(ckpt_dir: str) -> int:
"""Recover the optimizer-step count from an ``accelerate`` checkpoint dir named ``step_<N>`` (else 0).
The step counter isn't stored inside ``save_state``; it lives in the directory name (``step_160000``),
so the resumed run continues logging/save/val/sample cadence and the REPA early-stop from the right step.
"""
base = os.path.basename(ckpt_dir.rstrip("/"))
tail = base.removeprefix("step_")
return int(tail) if tail.isdigit() else 0
def train(
cfg: Config,
dataloader: DataLoader,
*,
steps: int = 100_000,
lr: float = 1e-4,
weight_decay: float = 1e-4,
log_every: int = 50,
save_every: int = 2000,
out_dir: str = "checkpoints",
use_ema: bool = True,
compile_model: bool = False,
grad_accum: int = 1,
save_final: bool = True,
track: bool = False,
track_space: str | None = None,
grad_checkpoint: bool = True,
max_grad_norm: float = 1.0,
warmup_steps: int = 2000,
min_lr: float = 1e-6,
cond_dropout: float = 0.0,
text_dropout: float = 0.0,
init_from: str | None = None,
resume_from: str | None = None,
val_loader: DataLoader | None = None,
val_every: int = 500,
val_batches: int = 8,
sample_every: int = 2000,
log_cer: bool = True,
profile_steps: int = 0,
run_config: dict[str, Any] | None = None,
) -> Diffu:
"""Run the Stage-1 flow-matching training loop.
Args:
cfg: Full model/data config.
dataloader: Yields batches of ``images`` / ``texts`` / ``style_pixel_values``.
steps: Total optimizer steps.
lr: AdamW learning rate.
weight_decay: AdamW weight decay.
log_every: Print the loss every N steps (main process only).
save_every: Checkpoint (``accelerate save_state``) every N steps.
out_dir: Directory for checkpoints.
use_ema: Maintain EMA weights of the trained parameters.
compile_model: ``torch.compile`` the SD3 transformer.
grad_accum: Gradient accumulation steps.
save_final: Write a final ``accelerate`` checkpoint to ``out_dir/final`` when done.
track: Log loss to trackio (live dashboard) + fire a trackio alert on non-finite loss.
track_space: HF Space id to sync trackio metrics to (persists for remote/cloud runs).
profile_steps: If > 0, run torch.profiler over this many real training steps, write the
kernel/Tensor-Core report to ``out_dir/profile_step_table.txt``, and return BEFORE the
main training loop. 0 (default) leaves the training path unchanged.
Returns:
The (unwrapped) trained model.
"""
# find_unused_parameters: under multi-GPU DDP, a parameter that receives NO gradient on a step is a
# fatal "didn't finish reduction" error. The REPA head (repa_proj) goes unused whenever REPA is
# early-stopped (repa_stop_frac < 1) — tolerate that so the run never dies at the stop step.
accel = Accelerator(
mixed_precision="bf16",
gradient_accumulation_steps=grad_accum,
# Async H2D: with pin_memory=True (build_dataloader) the prepared loader overlaps the next batch's
# host->device copy with the current step's compute. No-op without pinned memory.
dataloader_config=DataLoaderConfiguration(non_blocking=True),
# gradient_as_bucket_view: alias grads onto DDP's reduction buckets (one fewer grad copy + lower
# peak memory). broadcast_buffers=False: skip the per-step buffer broadcast — safe here (the only
# live buffers are the content ResNet's BN stats, which in train() normalize on the local minibatch,
# plus the VAE's constant lat_mean/std and per-call RoPE freqs; init is identical across ranks via
# set_seed). Both are no-ops on a single GPU.
kwargs_handlers=[
DistributedDataParallelKwargs(
find_unused_parameters=True,
gradient_as_bucket_view=True,
broadcast_buffers=False,
)
],
)
torch.set_float32_matmul_precision("high") # TF32 on fp32 GEMMs (VAE gate / generate paths)
set_seed(cfg.seed) # reproducible init + data order across ranks
model = _setup_model(
cfg,
grad_checkpoint=grad_checkpoint,
compile_model=compile_model,
)
if init_from is not None: # warm-start from prior weights (strict=False) instead of random init
from .generate import load_checkpoint
load_checkpoint(model, init_from)
if accel.is_main_process:
print(f"warm-started weights from {init_from}", flush=True)
params = trainable_parameters(model)
# fused=True: single-kernel optimizer step (vs the foreach default) — free on CUDA. bf16 autocast uses
# no GradScaler, so the fused+scaler caveat doesn't apply; fused is lazily device-checked at the first
# step() (after accel.prepare has moved params to CUDA), so constructing on CPU params here is fine.
opt = torch.optim.AdamW(
params, lr=lr, betas=(0.9, 0.99), weight_decay=weight_decay, fused=torch.cuda.is_available()
)
# Warmup -> cosine-decay LR: warm up then decay when training the generator from scratch;
# constant LR is harder to converge from random init.
# accelerate's prepared scheduler advances once PER PROCESS per optimizer step, so scale the
# horizon by num_processes — otherwise an N-GPU run consumes the whole schedule N x too fast
# (LR reaches its floor at ~steps/N and the rest of the run trains at ~0 LR).
sched_scale = accel.num_processes
lr_scheduler = _warmup_cosine_min_lr(
opt,
warmup_steps=warmup_steps * sched_scale,
total_steps=steps * sched_scale,
base_lr=lr,
min_lr=min_lr,
)
model, opt, dataloader, lr_scheduler = accel.prepare(model, opt, dataloader, lr_scheduler)
# Build EMA AFTER prepare so its shadow copies are created on the training device. accel.prepare
# moves params to CUDA in place, so `params` still references the (now CUDA-backed) objects;
# constructing EMA on the pre-prepare CPU model would crash on the first ema.step (CPU vs CUDA).
# foreach=True: vectorized (_foreach_) EMA update over the whole param list in one shot vs a Python
# per-tensor loop. use_ema_warmup: low decay early, ramp to 0.9999. (foreach isn't serialized in the EMA
# state_dict, so toggling it is resume-compatible with existing checkpoints.)
ema = EMAModel(params, use_ema_warmup=True, foreach=True) if use_ema else None
if ema is not None:
accel.register_for_checkpointing(ema)
# Full resume: load model + optimizer + LR scheduler + EMA + RNG from an `accelerate` step_N checkpoint
# (must run AFTER prepare + EMA registration so every registered object is restored). The step counter
# comes from the dir name. Unlike --init-from (weights only -> restarts optimizer/LR/step), this
# continues the run exactly. Resume on the SAME process count the checkpoint was saved with.
start_step = 0
if resume_from is not None:
accel.load_state(resume_from)
if ema is not None:
# load_state restores the EMA shadow params on CPU; re-home them to the train device or the
# next ema.step() mixes CPU shadow with CUDA params (device-mismatch crash).
ema.to(accel.device)
start_step = _resume_step(resume_from)
if accel.is_main_process:
print(f"resumed full training state from {resume_from} (step {start_step})", flush=True)
if accel.is_main_process:
os.makedirs(out_dir, exist_ok=True)
# Full experiment config to trackio so runs are comparable by every knob we sweep (batch, LR,
# white-pad, rope, fill-ratio, grad-clip, …). run_config carries the CLI-only extras (batch_size).
run_cfg: dict[str, Any] = {
"steps": steps,
"lr": lr,
"weight_decay": weight_decay,
"grad_accum": grad_accum,
"max_grad_norm": max_grad_norm,
"warmup_steps": warmup_steps,
"min_lr": min_lr,
"cond_dropout": cond_dropout,
"text_dropout": text_dropout,
"use_ema": use_ema,
"grad_checkpoint": grad_checkpoint,
"repa": cfg.aux.repa,
"ink_focal": cfg.aux.diacritic_focal_flow,
"num_processes": accel.num_processes,
"rope": cfg.backbone.rope,
"fill_ratio_cond": cfg.backbone.fill_ratio_cond,
"max_chars": cfg.cond.max_chars,
"glyph_line": cfg.cond.glyph_line,
"glyph_concat": cfg.cond.glyph_concat,
"style_in_context": cfg.cond.style_in_context,
"white_pad_prob": cfg.data.white_pad_prob,
"white_pad_max_frac": cfg.data.white_pad_max_frac,
"line_height": cfg.data.line_height,
"dim": cfg.backbone.dim,
"num_layers": cfg.backbone.num_layers,
"init_from": init_from,
"compile": compile_model,
"log_cer": log_cer,
"val_every": val_every,
"sample_every": sample_every,
"save_every": save_every,
**(run_config or {}),
}
if "batch_size" in run_cfg:
run_cfg["effective_batch"] = run_cfg["batch_size"] * accel.num_processes * grad_accum
tracker = _init_tracker(
track, accel.is_main_process, run_cfg, track_space, run_name=os.path.basename(out_dir.rstrip("/"))
)
unwrapped = accel.unwrap_model(model)
if profile_steps > 0: # off by default; returns BEFORE the main loop so training is untouched
return _run_profile(accel, model, dataloader, params, out_dir, profile_steps, cond_dropout)
# One fixed held-out batch on rank 0, reused for every sample strip so progress is comparable
# step-to-step (same texts + same style references each time).
sample_batch = next(iter(val_loader)) if val_loader is not None and accel.is_main_process else None
recognizer = None
if log_cer and sample_batch is not None: # rank-0 legibility gauge: read our lines back -> gen_CER
from .recognizer import load_recognizer
recognizer = load_recognizer(cfg.aux.htr_recognizer_eval, accel.device)
print(f"loaded HTR recognizer for live gen_CER ({cfg.aux.htr_recognizer_eval})", flush=True)
model.train()
step = start_step # 0 for a fresh run; the resumed optimizer-step count otherwise
last_log_t, last_log_step = perf_counter(), start_step
while step < steps:
seen = 0
for batch in dataloader:
seen += 1
loss_val: float | None = None
flow_val: float | None = None
repa_val: float | None = None
with accel.accumulate(model):
repa_w = (
cfg.aux.repa_weight if step < cfg.aux.repa_stop_frac * steps else 0.0
) # REPA early-stop (HASTE)
out = model(
batch["images"],
batch["texts"],
batch["style_pixel_values"],
cond_dropout=cond_dropout,
text_dropout=text_dropout,
return_losses=True,
repa_weight=repa_w,
)
loss = out["loss"]
accel.backward(loss)
if accel.sync_gradients: # gradient clipping
accel.clip_grad_norm_(params, max_grad_norm)
opt.step()
lr_scheduler.step()
opt.zero_grad()
loss_val = loss.item()
# flow is the term comparable to val_loss (REPA is training-only); log both so the
# train-flow vs val-flow overfitting check is apples-to-apples.
flow_val = out["flow"].item()
repa_val = out["repa"].item() if "repa" in out else None
msg = f"loss {loss_val:.4f} flow {flow_val:.4f}"
if repa_val is not None:
msg += f" repa {repa_val:.4f}"
msg += f" lr {lr_scheduler.get_last_lr()[0]:.2e}"
if loss_val is not None and not math.isfinite(loss_val): # NaN/inf -> training is broken
if tracker is not None:
tracker.alert(
title="Non-finite loss",
text=f"loss={loss_val} at step {step}",
level=tracker.AlertLevel.ERROR,
)
raise RuntimeError(f"non-finite loss {loss_val} at step {step}")
# Gradient accumulation: run the per-OPTIMIZER-step bookkeeping (EMA, logging, save, val,
# sample, the barrier, and the step counter) ONLY when grads actually synced — so `step`
# counts optimizer steps (matching the LR horizon / recog ramp / cadence), not micro-batches.
# Diffusers' own training loops gate on `accelerator.sync_gradients` the same way. No-op at
# grad_accum=1 (sync every iteration). The non-finite check above still runs every micro-batch.
if not accel.sync_gradients:
continue
if ema is not None:
ema.step(params)
if accel.is_main_process and step % log_every == 0:
now = perf_counter()
imgs_per_s = (
(step - last_log_step)
* batch["images"].shape[0]
* accel.num_processes
/ max(now - last_log_t, 1e-6)
)
last_log_t, last_log_step = now, step
print(f"step {step}/{steps} {msg} {imgs_per_s:.0f} img/s", flush=True)
if tracker is not None and loss_val is not None:
lr_now = lr_scheduler.get_last_lr()[0] # step= -> real-step x-axis
metrics: dict[str, float] = {"loss": loss_val, "lr": lr_now, "images_per_s": imgs_per_s}
if flow_val is not None: # flow_loss is what val_loss is comparable to
metrics["flow_loss"] = flow_val
if repa_val is not None:
metrics["repa_loss"] = repa_val
tracker.log(metrics, step=step)
if step > 0 and step % save_every == 0:
accel.save_state(os.path.join(out_dir, f"step_{step}"))
if val_loader is not None and accel.is_main_process and step > 0 and step % val_every == 0:
val_loss = evaluate_val_loss(unwrapped, val_loader, accel.device, val_batches)
print(f"step {step}/{steps} val_loss {val_loss:.4f}", flush=True)
if tracker is not None:
tracker.log({"val_loss": val_loss}, step=step)
if sample_batch is not None and step > 0 and step % sample_every == 0:
# When the recognizer is loaded, also generate a CFG-guided variant so gen_CER is
# reported WITH and WITHOUT guidance (does CFG actually help legibility here?).
cfg_eval_scale = (
5.0 if recognizer is not None else 0.0
) # classic-CFG scale for guided gen_CER (cfg=5 measured best vs 3; see docs/PERF_AUDIT.md §0c)
# Sample from the EMA weights (what inference/export uses), then restore the raw
# training weights — otherwise the gauge measures noisier raw weights and understates
# quality. EMA shadows only the trainable params, which is exactly what we swap.
if ema is not None:
ema.store(params)
ema.copy_to(params)
try:
path, gen_imgs, gen_imgs_cfg, texts, real_imgs, sample_w, style_texts = save_samples(
unwrapped,
sample_batch,
cfg,
accel.device,
out_dir,
step,
cfg_eval_scale=cfg_eval_scale,
)
finally:
if ema is not None:
ema.restore(params)
line = f"step {step}/{steps} samples -> {path}"
gen_cer: float | None = None
gen_cer_cfg: float | None = None
ctrl_gap: float | None = None
preds: list[str] = []
if recognizer is not None: # read our lines back -> gen_CER (the legibility gauge)
from .model.metrics import cer
from .recognizer import read_lines
def _cer(imgs: list[PILImage], want: list[str]) -> tuple[list[str], float]:
# micro-average (total edits / total chars), matching eval.py. A macro mean of
# per-line ratios over-weights short/empty targets (empty gt scores a flat 1.0),
# so it read ~1.4x too high on mixed-length sample batches.
p = read_lines(recognizer[0], recognizer[1], imgs)
return p, cer(p, want)
preds, gen_cer = _cer(gen_imgs, texts)
line += f" gen_CER {gen_cer:.2f}"
# Controllability: is the read closer to the REQUESTED text than to a RANDOM other
# text? Pair each read with a different sample's text (roll by 1) and take the CER
# gap. >0 = the output follows the instruction; ~0 = marginal collapse / copying (the
# output is no closer to what we asked than to a random line). Reuses `preds` (free).
if len(texts) > 1:
rand_texts = list(texts[1:]) + list(texts[:1])
ctrl_gap = cer(preds, rand_texts) - gen_cer
line += f" ctrl_gap {ctrl_gap:+.2f}"
if gen_imgs_cfg is not None:
_, gen_cer_cfg = _cer(gen_imgs_cfg, texts)
line += f" gen_CER_cfg {gen_cer_cfg:.2f}"
# redraw the grid with the reads burned in so the PNG itself shows want -> read
_compose_sample_grid(
real_imgs,
gen_imgs,
list(texts),
sample_w,
cfg.data.line_height,
reads=preds,
style_texts=style_texts,
).save(path)
print(line, flush=True)
if tracker is not None: # noise->ink + prompt->reading + gen_CER, by step, in trackio
caption = f"step {step} — top: real, bottom: generated"
if gen_cer is not None:
pairs = " | ".join(
f"style {s!r} → want {t!r} → read {p!r}"
for s, t, p in zip(style_texts, texts, preds, strict=False)
)
cfg_note = f" | gen_CER_cfg {gen_cer_cfg:.2f}" if gen_cer_cfg is not None else ""
caption = (
f"step {step} | gen_CER {gen_cer:.2f} (0=perfect, unguided){cfg_note} | "
f"image: top=real scan, bottom=ours | {pairs}"
)
payload: dict[str, object] = {"samples": tracker.Image(path, caption=caption)}
if gen_cer is not None:
payload["gen_cer"] = gen_cer
if gen_cer_cfg is not None:
payload["gen_cer_cfg"] = gen_cer_cfg
if ctrl_gap is not None: # >0 = following the text; ~0 = marginal collapse / copying
payload["ctrl_gap"] = ctrl_gap
tracker.log(payload, step=step)
# Other ranks idle through the rank-0-only val/sample blocks above; barrier here so a slow
# sample step (CFG + TrOCR generate on rank 0) can't trip the NCCL watchdog at the next
# all-reduce. Cheap when ranks are already in sync.
accel.wait_for_everyone()
step += 1
if step >= steps:
break
if seen == 0:
raise RuntimeError(
"DataLoader yielded 0 batches — the dataset is smaller than batch_size × num_processes "
"with drop_last=True, which would hang forever. Lower --batch-size or add more data."
)
accel.wait_for_everyone()
if save_final:
# Export the EMA weights as the final model (generate.py loads a plain state-dict, so without
# this the deployed model would be the raw, noisier weights and EMA's cost would be wasted).
# Periodic step_N checkpoints stay raw for clean resume; only the final export is EMA-applied.
if ema is not None:
ema.store(params)
ema.copy_to(params)
accel.save_state(os.path.join(out_dir, "final"))
if ema is not None:
ema.restore(params)
if tracker is not None:
tracker.finish()
return accel.unwrap_model(model)
def build_dataloader(
cfg: Config,
data_dir: str,
batch_size: int,
num_workers: int,
*,
split: str = "train",
shuffle: bool = True,
strict: bool = True,
white_pad_prob: float = 0.0,
bucket_width: bool = False,
) -> DataLoader:
"""Build a width-bucketing DataLoader over ``{data_dir}/{split}.jsonl`` (from ``make data``).
Args:
split: Manifest split to load (``train`` / ``val`` / ``test``).
shuffle: Shuffle and drop the last partial batch (training). ``False`` keeps every row in
order — used for the held-out val loader so the overfitting metric is stable.
strict: Raise if the split has fewer than ``batch_size × num_processes`` rows. ``True`` for
train (a too-small set hangs DDP under ``drop_last``); ``False`` for val.
white_pad_prob: Train-only right-white-pad augmentation probability (0 = off). Pass >0 only
for the train loader; val/test stay text-tight (white_pad_prob=0) for honest metrics.
Raises:
ValueError: if ``strict`` and the split has fewer than ``batch_size × num_processes`` rows.
"""
from accelerate import PartialState
rows = load_manifest(os.path.join(data_dir, f"{split}.jsonl"))
ds = HandwritingLineDataset(rows, height=cfg.data.line_height, max_width=cfg.data.max_line_width)
world = PartialState().num_processes
if strict and len(ds) < batch_size * world:
raise ValueError(
f"{split}.jsonl has {len(ds)} rows < batch_size × num_processes "
f"({batch_size} × {world} = {batch_size * world}); with drop_last=True every rank would "
f"get 0 batches and training would hang. Lower --batch-size or add more data."
)
collate = partial(
collate_lines,
max_width=cfg.data.max_line_width,
white_pad_prob=white_pad_prob,
white_pad_max_frac=cfg.data.white_pad_max_frac,
)
if bucket_width and shuffle:
# Group similar-width lines per batch -> ~54% padding-FLOPs reclaimed + stable shapes (lets
# torch.compile stop recompiling). accelerate's prepared loader shards whole batches across ranks
# (BatchSamplerShard); DDP all-reduces param-shaped grads, so per-rank differing widths are fine.
from .data.sampler import WidthBucketBatchSampler, line_widths
widths = line_widths(
rows, max_width=cfg.data.max_line_width, cache_path=os.path.join(data_dir, f"{split}.widths.json")
)
batch_sampler = WidthBucketBatchSampler(
widths, batch_size, shuffle=True, drop_last=True, seed=cfg.seed
)
return DataLoader(
ds,
batch_sampler=batch_sampler,
num_workers=num_workers,
collate_fn=collate,
pin_memory=True,
persistent_workers=num_workers > 0,
)
return DataLoader(
ds,
batch_size=batch_size,
shuffle=shuffle,
num_workers=num_workers,
collate_fn=collate,
drop_last=shuffle,
pin_memory=True,
persistent_workers=num_workers > 0,
)
def main() -> None:
ap = argparse.ArgumentParser(description="Train Diffu (Stage-1 flow matching).")
ap.add_argument("--data-dir", required=True, help="dir with train.jsonl (from `make data`)")
ap.add_argument("--out-dir", default="checkpoints")
ap.add_argument("--steps", type=int, default=100_000)
ap.add_argument("--lr", type=float, default=1e-4)
ap.add_argument("--batch-size", type=int, default=8)
ap.add_argument("--num-workers", type=int, default=8)
ap.add_argument("--grad-accum", type=int, default=1)
ap.add_argument("--save-every", type=int, default=10000, help="checkpoint every N steps (each ~17 GB)")
ap.add_argument("--no-ema", action="store_true")
ap.add_argument(
"--no-grad-checkpoint",
action="store_true",
help="disable grad checkpointing (~20%% faster, more VRAM)",
)
ap.add_argument("--compile", action="store_true")
ap.add_argument(
"--no-repa",
dest="repa",
action="store_false",
help="disable REPA representation-alignment aux loss (ON by default)",
)
ap.set_defaults(repa=True)
ap.add_argument(
"--no-fill-ratio",
dest="fill_ratio",
action="store_false",
help="disable fill-ratio conditioning (ON by default)",
)
ap.set_defaults(fill_ratio=True)
ap.add_argument(
"--no-style-tokens",
dest="style_in_context",
action="store_false",
help="inject style as the global pooled AdaLN vector ONLY, not as K attendable style tokens in "
"joint attention (those let the model COPY the reference's glyphs and ignore the text). ON by default.",
)
ap.set_defaults(style_in_context=True)
ap.add_argument("--track", action="store_true", help="log loss to trackio (live dashboard + NaN alerts)")
ap.add_argument("--track-space", default=None, help="HF Space id to sync trackio to (e.g. user/diffu)")
ap.add_argument(
"--val-every", type=int, default=500, help="held-out val loss every N steps (overfitting check)"
)
ap.add_argument(
"--sample-every", type=int, default=2000, help="save [real|generated] sample images every N steps"
)
ap.add_argument(
"--no-cer", action="store_true", help="disable the live gen_CER legibility gauge at sample steps"
)
ap.add_argument(
"--max-grad-norm", type=float, default=1.0, help="gradient clipping norm"
)
ap.add_argument("--warmup-steps", type=int, default=2000, help="LR warmup steps before cosine decay")
ap.add_argument("--min-lr", type=float, default=1e-6, help="cosine LR floor (decays toward this, not 0)")
ap.add_argument(
"--weight-decay", type=float, default=1e-4, help="AdamW weight decay (lighter than the 1e-2 default)"
)
ap.add_argument(
"--cond-dropout",
type=float,
default=0.0,
help="drop ALL conditioning prob (full CFG: text+style); 0=off",
)
ap.add_argument(
"--text-dropout",
type=float,
default=0.0,
help="drop ONLY the text/content prob, KEEP style (text-only CFG, DiffInk drop_text); 0=off",
)
ap.add_argument(
"--ink-focal", action="store_true", help="up-weight the loss on ink regions vs white paper"
)
ap.add_argument(
"--init-from", default=None, help="warm-start model weights from a checkpoint (strict=False)"
)
ap.add_argument(
"--resume",
default=None,
help="FULL resume from an accelerate step_N checkpoint dir (model+optimizer+LR+EMA+step). Unlike "
"--init-from (weights only -> restarts optimizer/LR/step), this continues the run; use the SAME "
"number of GPUs the checkpoint was saved with.",
)
ap.add_argument(
"--profile-steps",
type=int,
default=0,
help="profile N real training steps to out-dir/profile_step_table.txt, then exit (0=off)",
)
ap.add_argument(
"--white-pad-prob",
type=float,
default=0.0,
help="train-only right-white-pad augmentation prob (short-text-on-wide-canvas); 0=off",
)
ap.add_argument(
"--bucket-width",
action="store_true",
help="group similar-width lines into each batch (reclaims ~54%% padding-FLOPs + stabilizes shapes "
"so --compile stops recompiling). Batches become width-correlated (small training-dynamics change) "
"-> validate gen_CER before treating as default.",
)
ap.add_argument(
"--max-chars",
type=int,
default=None,
help="max characters per line (glyph content length cap; default from config, currently 128)",
)
ap.add_argument(
"--glyph-line",
action="store_true",
help="line-level glyph content: whole-line image -> w_t column-aligned tokens + shared-column "
"RoPE (MSRoPE), instead of per-char tokens. Smoke-validated; opt-in.",
)
ap.add_argument(
"--glyph-concat",
action="store_true",
help="CHANNEL-CONCAT a spatial glyph latent onto the noisy latent (content in the input, not "
"just attended — strongest coupling). Pair with --cond-dropout 0.1. EXPERIMENTAL.",
)
ap.add_argument(
"--logit-normal-mean",
type=float,
default=None,
help="override flow timestep logit-normal mean (default 0.0); <0 emphasizes low-noise detail",
)
args = ap.parse_args()
cfg = Config()
cfg.aux.repa = args.repa
cfg.backbone.fill_ratio_cond = args.fill_ratio
cfg.aux.diacritic_focal_flow = args.ink_focal
cfg.cond.glyph_line = args.glyph_line
cfg.cond.glyph_concat = args.glyph_concat
cfg.cond.style_in_context = args.style_in_context
if args.max_chars is not None:
cfg.cond.max_chars = args.max_chars
if args.logit_normal_mean is not None:
cfg.flow.logit_normal_mean = args.logit_normal_mean
cfg.data.white_pad_prob = args.white_pad_prob # record-keeping; only the train loader uses it
# White-pad augmentation is TRAIN-ONLY: the val loader keeps white_pad_prob=0 so val canvases stay
# text-tight and the overfitting / gen_CER metrics are honest.
dataloader = build_dataloader(
cfg,
args.data_dir,
args.batch_size,
args.num_workers,
white_pad_prob=args.white_pad_prob,
bucket_width=args.bucket_width,
)
val_loader = build_dataloader(
cfg, args.data_dir, args.batch_size, args.num_workers, split="val", shuffle=False, strict=False
)
train(
cfg,
dataloader,
steps=args.steps,
lr=args.lr,
out_dir=args.out_dir,
use_ema=not args.no_ema,
compile_model=args.compile,
grad_accum=args.grad_accum,
save_every=args.save_every,
track=args.track,
track_space=args.track_space,
grad_checkpoint=not args.no_grad_checkpoint,
max_grad_norm=args.max_grad_norm,
warmup_steps=args.warmup_steps,
min_lr=args.min_lr,
weight_decay=args.weight_decay,
cond_dropout=args.cond_dropout,
text_dropout=args.text_dropout,
init_from=args.init_from,
resume_from=args.resume,
val_loader=val_loader,
val_every=args.val_every,
sample_every=args.sample_every,
log_cer=not args.no_cer,
profile_steps=args.profile_steps,
run_config={"batch_size": args.batch_size, "num_workers": args.num_workers},
)
if __name__ == "__main__":
main()