File size: 22,057 Bytes
b3c38d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
"""LoRA trainer for Fibo 2.0 (edit and text-to-image) on the diffusers `BriaFibo2Pipeline`.

Self-contained: needs only torch, diffusers (with `BriaFibo2Pipeline`: until PR
huggingface/diffusers#14991 is merged, branch ali/fibo2 of muhammadali-afridi/diffusers, tested
at e158bdea), torchvision (Qwen3-VL's processor needs it), transformers, peft, safetensors,
pillow, numpy and click.

What it does
------------
1. Pre-encodes every training sample once with the pipeline's own methods (`encode_prompt`,
   `prepare_image_latents`, `image_processor`, the pipeline's caption cleaning and its text
   images, drawn in the base repo's fonts), writes the result to
   `<cache-dir>/<idx>.safetensors`, then frees Qwen3-VL and the VAE.
2. Loads the transformer in bf16 (frozen), adds a LoRA to every `nn.Linear`, keeps the LoRA
   weights in fp32, and trains them with flow matching (batch 1, gradient accumulation).
3. Saves `<output-dir>/checkpoint-<step>/` every `--save-every` steps and `<output-dir>/final/`
   (`pytorch_lora_weights.safetensors`, see "Load the result"). Also writes `loss.csv`.

Data
----
edit mode (`<data>/edits.jsonl` exists, or `--mode edit`): one JSON object per line,
    {"sources": ["a/src.jpg", ...1 to 4 paths], "target": "a/tgt.jpg", "instruction": "..."}
paths are relative to `<data>`. The instruction may refer to the sources as <image_1>, <image_2>.
"instruction" is a plain string, or a structured JSON caption of the target (an object, or that
object serialised) with an "edit_instruction" key; the pipeline splits it into the two.
t2i mode: images (png/jpg/jpeg/webp) anywhere under `<data>`. Each image's caption is a file next
to it with the same name: `<name>.json` (a structured caption object, re-serialised compact) or
`<name>.txt` (a plain sentence, used as is). The .json wins if both exist; uncaptioned images are
skipped. The README has an example.

Text images: a JSON caption with `text_render` entries gets an image of each entry's text, drawn
and shown to Qwen3-VL as the pipeline does at inference. Text-to-image samples also cache the
caption without them, used above noise level 0.9, where inference leaves them out.

Sizing: the result size is chosen on the 16-pixel grid within 10% of `--resolution`^2 pixels,
cropping the least from the first source (edit) or the target (t2i), exactly as the pipeline does
at inference (`image_processor.get_image_size` with area `--resolution`^2). Targets and extra
sources are cover-cropped to their sizes by `image_processor.preprocess`.

Run (1024 / rank 128 / grad accum 8 peaked at 31 GiB, about 58 s per step on an L40S)
--------------------------------------------------------------------------------------
    python train_lora.py --base <hf-repo-or-local-dir> --data my_edits/ --output-dir out/ \\
        --resolution 1024 --rank 64 --lr 1e-4 --steps 1000 --grad-accum 8

Load the result (`weight_name` is required: without it diffusers looks for a `.bin`):
    pipe = BriaFibo2Pipeline.from_pretrained(base, dtype=torch.bfloat16)
    pipe.transformer.load_lora_adapter("out/final", weight_name="pytorch_lora_weights.safetensors")

Recipe: sigma ~ sigmoid(N(shift, 1)) with prob 0.9 else U(0, 1), shift = 0.5 + 0.65 * (n - 256) /
3840 for n target tokens; x_t = sigma * noise + (1 - sigma) * x0; target = noise - x0; fp32 MSE.
Text dropout swaps in the null caption embedding; context dropout (edit) removes the ref latents
but keeps what Qwen3-VL saw. AdamW (wd 0.01), constant LR, grad clip 1.0.
"""

import csv
import gc
import json
import math
import random
import time
from pathlib import Path
from typing import Any

import click
import numpy as np
import torch
from PIL import Image, ImageOps
from safetensors import SafetensorError, safe_open
from safetensors.torch import load_file, save_file
from torch import nn

DEFAULT_BASE = "briaai/Fibo-2.0-dev"
EXPECTED_ADAPTED_MODULES = 415  # nn.Linear layers of the Fibo 2.0 transformer
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
FONT_FILES = ("Aileron-Regular.otf", "DejaVuSans.ttf")
SHIFT_BASE, SHIFT_MAX, SEQ_BASE, SEQ_MAX = 0.5, 1.15, 256, 4096
# text-to-image leaves out the text images above this noise level, as the pipeline does
TEXT_IMAGES_MAX_SIGMA = 0.9


# ------------------------------------------------------------------------------------ data
def compact(caption: dict) -> str:
    return json.dumps(caption, separators=(",", ":"), ensure_ascii=False)


def as_prompt(value: str | dict) -> str:
    """A caption object serialised compactly, as Fibo 2.0 was trained, or a plain string as is."""
    if isinstance(value, dict):
        return compact(value)
    return value


def edit_prompt(instruction: str | dict) -> str:
    """An edit row's prompt. A JSON caption must hold the "edit_instruction" key."""
    prompt = as_prompt(instruction)
    try:
        caption = json.loads(prompt)
    except json.JSONDecodeError:
        return prompt
    if not isinstance(caption, dict):
        return prompt
    if "edit_instruction" not in caption:
        raise click.ClickException(
            f'JSON instruction has no "edit_instruction" key: {prompt[:200]}'
        )
    if "structured_caption" in caption and (
        not isinstance(caption["structured_caption"], (dict, str)) or len(caption) > 2
    ):
        raise click.ClickException(
            'a "structured_caption" instruction holds only "structured_caption" (an object or a'
            f' string) and "edit_instruction": {prompt[:200]}'
        )
    return prompt


def load_samples(data: Path, mode: str) -> list[dict[str, Any]]:
    """Return [{'sources': [Path], 'target': Path, 'prompt': str}] (sources empty for t2i)."""
    samples: list[dict[str, Any]] = []
    if mode == "edit":
        for line in (data / "edits.jsonl").read_text().splitlines():
            if not line.strip():
                continue
            row = json.loads(line)
            if not 1 <= len(row["sources"]) <= 4:
                raise click.ClickException(f"need 1-4 sources, got {len(row['sources'])}")
            samples.append(
                {
                    "sources": [data / s for s in row["sources"]],
                    "target": data / row["target"],
                    "prompt": edit_prompt(row["instruction"]),
                }
            )
    else:
        for img in sorted(p for p in data.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES):
            if img.with_suffix(".json").exists():
                prompt = as_prompt(json.loads(img.with_suffix(".json").read_text()))
            elif img.with_suffix(".txt").exists():
                prompt = img.with_suffix(".txt").read_text().strip()
            else:
                continue
            samples.append({"sources": [], "target": img, "prompt": prompt})
    if not samples:
        raise click.ClickException(f"no samples found in {data} (mode={mode})")
    return samples


def load_image(path: Path) -> Image.Image:
    return ImageOps.exif_transpose(Image.open(path)).convert("RGB")


# ----------------------------------------------------------------------- pre-encode phase
def cache_ok(path: Path, meta: dict[str, str]) -> bool:
    if not path.exists():
        return False
    try:
        with safe_open(path, "pt") as f:
            got = f.metadata() or {}
    except (OSError, SafetensorError):  # partial or corrupt file: re-encode
        return False
    return all(got.get(k) == v for k, v in meta.items())


def load_fonts(base: str) -> list[Any]:
    """The fonts the base model's training drew text images with, from its `fonts` folder."""
    from diffusers.pipelines.bria_fibo2.text_images import load_font
    from huggingface_hub import hf_hub_download

    paths = [
        str(Path(base) / "fonts" / name)
        if Path(base).is_dir()
        else hf_hub_download(base, name, subfolder="fonts")
        for name in FONT_FILES
    ]
    return [load_font(path) for path in paths]


def text_images(caption: dict | str | None, fonts: list[Any]) -> list[Image.Image] | None:
    """Images of the caption's text_render entries that have text, drawn as the pipeline does."""
    from diffusers.pipelines.bria_fibo2.text_images import draw_text_image

    entries = (caption.get("text_render") or []) if isinstance(caption, dict) else []
    images = [
        draw_text_image(e["text"], fonts)
        for e in entries
        if isinstance(e, dict) and isinstance(e.get("text"), str) and e["text"]
    ]
    return images or None


def pre_encode(
    base: str,
    samples: list[dict[str, Any]],
    mode: str,
    resolution: int,
    cache_dir: Path,
    dev: torch.device,
) -> None:
    from diffusers import BriaFibo2Pipeline
    from diffusers.pipelines.bria_fibo2.pipeline_bria_fibo2 import (
        DEFAULT_NEGATIVE_PROMPT,
    )

    cache_dir.mkdir(parents=True, exist_ok=True)
    # "encoding" changes whenever the cached embeddings would: older caches are re-encoded
    meta = {"resolution": str(resolution), "mode": mode, "encoding": "3"}
    todo = [i for i in range(len(samples)) if not cache_ok(cache_dir / f"{i}.safetensors", meta)]
    need_null = not cache_ok(cache_dir / "null.safetensors", meta)
    print(f"[encode] {len(samples)} samples, {len(todo)} to encode (cache: {cache_dir})")
    if not todo and not need_null:
        return

    pipe = BriaFibo2Pipeline.from_pretrained(base, transformer=None, dtype=torch.bfloat16)
    pipe.to(dev)
    pipe.text_encoder.eval()
    proc = pipe.image_processor
    area = resolution * resolution
    fonts = load_fonts(base)
    tdtype = torch.bfloat16

    def pack(emb: torch.Tensor, mask: torch.Tensor | None) -> dict[str, torch.Tensor]:
        out = {"emb": emb.to(tdtype).cpu().contiguous()}
        if mask is not None:
            out["mask"] = mask.to(torch.uint8).cpu().contiguous()
        return out

    with torch.no_grad():
        if need_null:
            emb, mask = pipe.encode_prompt(DEFAULT_NEGATIVE_PROMPT, None, dev)
            save_file(pack(emb, mask), cache_dir / "null.safetensors", metadata=meta)
        for n, i in enumerate(todo):
            s = samples[i]
            target = load_image(s["target"])
            caption, _ = pipe._parse_prompt(s["prompt"])
            texts = text_images(caption, fonts)
            extra = {}
            if mode == "edit":
                srcs = [load_image(p) for p in s["sources"]]
                width, height = proc.get_image_size(srcs[0].width / srcs[0].height, area=area)
                # one source: cropped to the result size; several: each to its own aspect's size
                vae_srcs, thumbs = proc.preprocess(
                    srcs,
                    height=height,
                    width=width,
                    area=area,
                    text_encoder_image_size=pipe.text_encoder_image_size,
                )
                emb, mask = pipe.encode_prompt(s["prompt"], thumbs, dev, text_images=texts)
                refs = pipe.prepare_image_latents(vae_srcs, 1, dev, torch.float32)
            else:
                width, height = proc.get_image_size(target.width / target.height, area=area)
                # a JSON caption in its trained form; a plain sentence as is
                prompt = s["prompt"] if isinstance(caption, str) else compact(caption or {})
                emb, mask = pipe.encode_prompt(prompt, None, dev, text_images=texts)
                refs = []
                if texts:
                    # the caption without its text images, which inference uses at high noise
                    base_emb, base_mask = pipe.encode_prompt(
                        compact(pipe._mark_text_images(json.loads(prompt), "")), None, dev
                    )
                    extra = {f"base_{k}": v for k, v in pack(base_emb, base_mask).items()}
            tgt, _ = proc.preprocess(target, height=height, width=width)
            x0 = pipe.prepare_image_latents(tgt, 1, dev, torch.float32)[0]
            tensors = {"x0": x0.cpu().contiguous(), **pack(emb, mask), **extra}
            for k, r in enumerate(refs):
                tensors[f"ref{k}"] = r.cpu().contiguous()
            save_file(tensors, cache_dir / f"{i}.safetensors", metadata=meta)
            if (n + 1) % 20 == 0 or n + 1 == len(todo):
                print(f"[encode] {n + 1}/{len(todo)}")
    del pipe
    gc.collect()
    torch.cuda.empty_cache()


def load_cached(path: Path, dev: torch.device) -> dict[str, Any]:
    t = load_file(path)
    refs = [t[f"ref{k}"].to(dev) for k in range(4) if f"ref{k}" in t]

    def mask(key: str) -> torch.Tensor | None:
        return t[key].to(dev).bool() if key in t else None

    return {
        "x0": t["x0"].to(dev),
        "emb": t["emb"].to(dev),
        "mask": mask("mask"),
        "base_emb": t["base_emb"].to(dev) if "base_emb" in t else None,
        "base_mask": mask("base_mask"),
        "refs": refs or None,
    }


# ---------------------------------------------------------------------------------- model
def build_model(
    base: str, rank: int, alpha: int, grad_ckpt: bool, expect: int, dev: torch.device
) -> tuple[nn.Module, list[nn.Parameter]]:
    from diffusers import BriaFibo2Transformer2DModel
    from peft import LoraConfig
    from peft.tuners.lora import LoraLayer

    model = BriaFibo2Transformer2DModel.from_pretrained(
        base, subfolder="transformer", dtype=torch.bfloat16
    )
    model.to(dev).requires_grad_(False)
    linears = [n for n, m in model.named_modules() if isinstance(m, nn.Linear)]
    model.add_adapter(
        LoraConfig(r=rank, lora_alpha=alpha, init_lora_weights=True, target_modules=linears)
    )
    adapted = sum(isinstance(m, LoraLayer) for m in model.modules())
    print(f"[model] nn.Linear layers: {len(linears)}, LoRA-adapted modules: {adapted}")
    assert adapted == len(linears), "some Linear layers were not adapted"
    if expect:
        assert adapted == expect, f"expected {expect} adapted modules, got {adapted}"
    params = []
    for name, p in model.named_parameters():
        if "lora_" in name:
            # fp32 LoRA weights: bf16 would round small AdamW updates to zero
            p.data = p.data.float()
            p.requires_grad_(True)
            params.append(p)
        else:
            assert not p.requires_grad, name
    assert all(p.dtype == torch.float32 for p in params)
    print(f"[model] LoRA params: {sum(p.numel() for p in params) / 1e6:.1f}M, all fp32")
    if grad_ckpt:
        model.enable_gradient_checkpointing()
    model.train()
    return model, params


def save_adapter(model: nn.Module, directory: Path) -> None:
    """Write the LoRA in the diffusers pipeline format: keys and metadata prefixed `transformer.`.

    (`transformer.save_lora_adapter` writes unprefixed keys, which `load_lora_adapter` with its
    default prefix silently ignores, leaving the model un-adapted.)
    """
    from peft.utils import get_peft_model_state_dict

    state = get_peft_model_state_dict(model, adapter_name="default")
    state = {f"transformer.{k}": v.detach().contiguous() for k, v in state.items()}
    cfg = {
        k: sorted(v) if isinstance(v, set) else v
        for k, v in model.peft_config["default"].to_dict().items()
    }
    meta = {f"transformer.{k}": v for k, v in cfg.items()}
    directory.mkdir(parents=True, exist_ok=True)
    save_file(
        state,
        directory / "pytorch_lora_weights.safetensors",
        metadata={
            "format": "pt",
            "lora_adapter_metadata": json.dumps(meta, indent=2, sort_keys=True),
        },
    )


def sample_sigma(gen: torch.Generator, n_tokens: int) -> float:
    if torch.rand(1, generator=gen).item() < 0.1:
        return torch.rand(1, generator=gen).item()
    shift = SHIFT_BASE + (SHIFT_MAX - SHIFT_BASE) * (n_tokens - SEQ_BASE) / (SEQ_MAX - SEQ_BASE)
    z = torch.randn(1, generator=gen).item() + shift
    return 1.0 / (1.0 + math.exp(-z))


# ------------------------------------------------------------------------------------ CLI
@click.command()
@click.option("--base", default=DEFAULT_BASE, show_default=True, help="HF repo id or local dir.")
@click.option(
    "--data",
    type=click.Path(exists=True, file_okay=False, path_type=Path),
    required=True,
)
@click.option("--mode", type=click.Choice(["edit", "t2i"]), default=None, help="Default: auto.")
@click.option("--output-dir", type=click.Path(path_type=Path), required=True)
@click.option("--resolution", default=1024, show_default=True, help="Target side; area = side^2.")
@click.option("--rank", default=64, show_default=True)
@click.option("--alpha", type=int, default=None, help="LoRA alpha. Default: rank.")
@click.option("--lr", default=1e-4, show_default=True)
@click.option("--steps", default=1000, show_default=True, help="Optimizer steps.")
@click.option("--grad-accum", default=8, show_default=True)
@click.option("--save-every", default=250, show_default=True)
@click.option("--text-drop", default=0.1, show_default=True)
@click.option("--context-drop", default=0.2, show_default=True, help="Edit mode only.")
@click.option("--seed", default=0, show_default=True)
@click.option("--cache-dir", type=click.Path(path_type=Path), default=None)
@click.option("--gradient-checkpointing/--no-gradient-checkpointing", default=True)
@click.option(
    "--expect-modules",
    default=EXPECTED_ADAPTED_MODULES,
    show_default=True,
    help="Assert this many adapted Linear layers (0 disables; for other bases).",
)
def main(
    base: str,
    data: Path,
    mode: str | None,
    output_dir: Path,
    resolution: int,
    rank: int,
    alpha: int | None,
    lr: float,
    steps: int,
    grad_accum: int,
    save_every: int,
    text_drop: float,
    context_drop: float,
    seed: int,
    cache_dir: Path | None,
    gradient_checkpointing: bool,
    expect_modules: int,
) -> None:
    """Train a LoRA on Fibo 2.0 from a folder of edit pairs or captioned images."""
    if not torch.cuda.is_available():
        raise click.ClickException("a CUDA GPU is required")
    dev = torch.device("cuda")
    mode = mode or ("edit" if (data / "edits.jsonl").exists() else "t2i")
    alpha = alpha if alpha is not None else rank
    cache_dir = cache_dir or output_dir / "cache"
    output_dir.mkdir(parents=True, exist_ok=True)
    (output_dir / "train_args.json").write_text(
        json.dumps({k: str(v) for k, v in locals().items() if k != "dev"}, indent=2)
    )
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)

    samples = load_samples(data, mode)
    print(f"[data] mode={mode}, {len(samples)} samples, resolution={resolution}")
    pre_encode(base, samples, mode, resolution, cache_dir, dev)
    nt = load_file(cache_dir / "null.safetensors")
    null_emb = nt["emb"].to(dev)
    null_mask = nt["mask"].to(dev).bool() if "mask" in nt else None

    model, params = build_model(base, rank, alpha, gradient_checkpointing, expect_modules, dev)
    opt = torch.optim.AdamW(params, lr=lr, betas=(0.9, 0.999), eps=1e-8, weight_decay=0.01)
    torch.cuda.reset_peak_memory_stats()

    gen = torch.Generator().manual_seed(seed)
    order: list[int] = []
    log_path = output_dir / "loss.csv"
    with open(log_path, "w", newline="") as f:
        csv.writer(f).writerow(["step", "loss", "mean_sigma", "seconds_per_step"])
    patch = model.config.patch_size

    for step in range(1, steps + 1):
        t0 = time.time()
        losses: list[float] = []
        sigmas: list[float] = []
        for _ in range(grad_accum):
            if not order:
                order = torch.randperm(len(samples), generator=gen).tolist()
            s = load_cached(cache_dir / f"{order.pop()}.safetensors", dev)
            x0 = s["x0"]
            n_tokens = (x0.shape[2] // patch) * (x0.shape[3] // patch)
            sigma = sample_sigma(gen, n_tokens)
            emb, mask, refs = s["emb"], s["mask"], s["refs"]
            if s["base_emb"] is not None and sigma > TEXT_IMAGES_MAX_SIGMA:
                emb, mask = s["base_emb"], s["base_mask"]
            if torch.rand(1, generator=gen).item() < text_drop:
                emb, mask = null_emb, null_mask
            if mode == "edit" and torch.rand(1, generator=gen).item() < context_drop:
                refs = None
            noise = torch.randn(x0.shape, generator=gen).to(dev)
            x_t = sigma * noise + (1.0 - sigma) * x0
            pred = model(
                hidden_states=x_t.to(torch.bfloat16),
                timestep=torch.full((1,), sigma, dtype=torch.float32, device=dev),
                encoder_hidden_states=emb,
                encoder_attention_mask=mask,
                context_latents=None if refs is None else [r.to(torch.bfloat16) for r in refs],
                return_dict=False,
            )[0]
            loss = torch.nn.functional.mse_loss(pred.float(), noise - x0)
            (loss / grad_accum).backward()
            losses.append(loss.item())
            sigmas.append(sigma)
        torch.nn.utils.clip_grad_norm_(params, 1.0)
        opt.step()
        opt.zero_grad(set_to_none=True)

        dt = time.time() - t0
        mean_loss = float(np.mean(losses))
        if not math.isfinite(mean_loss):
            raise RuntimeError(f"non-finite loss at step {step}")
        with open(log_path, "a", newline="") as f:
            csv.writer(f).writerow(
                [step, f"{mean_loss:.6f}", f"{np.mean(sigmas):.4f}", f"{dt:.2f}"]
            )
        if step == 1 or step % 10 == 0 or step == steps:
            peak = torch.cuda.max_memory_allocated() / 2**30
            print(f"step {step}/{steps} loss {mean_loss:.4f} {dt:.1f}s/step peak {peak:.1f} GiB")
        if step % save_every == 0 and step != steps:
            save_adapter(model, output_dir / f"checkpoint-{step}")
    save_adapter(model, output_dir / "final")
    if steps % save_every == 0:
        save_adapter(model, output_dir / f"checkpoint-{steps}")
    print(f"peak memory allocated: {torch.cuda.max_memory_allocated() / 2**30:.2f} GiB")
    print(f"saved to {output_dir}")


if __name__ == "__main__":
    main()