手里同时维护过好几套扩散模型微调脚本的人应该都有体会:给 SDXL 调好的一套流程,换到 Flux 或者 SD3 上,dataloader 要重写、学习率要重估、显存优化参数要重试,连验证采样的脚本都得改一遍。SimpleTuner 这个工具箱之所以值得单独拿出来讲,就是因为它把“每换一个模型就重造一次轮子”这件事给掐断了——用一套 JSON 配置文件、一套多数据集描述格式、一套训练入口,覆盖了从 SD1.5 到 SDXL、SD3、Flux 乃至视频扩散模型的 LoRA、LyCORIS 与全量微调。它不解决“训得好不好”这种玄学问题,它解决的是“每次开新实验都要重搭一遍脚手架”的工程问题。
这篇内容面向的是已经能跑通单卡 LoRA 训练、但被流程碎片化和显存报错反复折磨的中级使用者,也适合刚接手扩散模型微调任务、想知道一套成熟工具箱长什么样的人。我会把它拆成几块来讲:配置驱动的设计逻辑到底省了什么、数据集 JSON 的多源混合机制怎么用、24G 卡上跑大模型的显存账本、学习率与优化器的推导方式、验证采样怎么提前发现训崩,以及故障排查的完整链路。所有数值和配置都是基于常见实践的合理推演,你照着改改就能落地。
1. 一个 JSON 配置就能开跑:SimpleTuner 把微调流程压成了什么
1.1 它真正解决的痛点不是“能不能训”,而是“每次都重来一遍”
扩散模型微调的开源脚本多如牛毛,随便搜一下就有几十个仓库能跑 LoRA。真正让人痛苦的是第二周:你想把 SDXL 的实验迁到 Flux 上,发现原来脚本里写死的是 UNet 的注意力层名字,Flux 用的是双流 + 单流 transformer block,一行都跑不通;你想加一路新数据,发现原来的 dataloader 只认一个文件夹;你想复现三周前那组超参,发现自己只在聊天记录里留了一句“好像 lr 是 8e-5”。
SimpleTuner 的设计哲学几乎是针对这些痛点反着来的:所有可变的东西都外置到 JSON 里,代码本身不认识具体的模型结构。你用model_family指定 sdxl、flux、sd3、pixart_sigma、kolors、sana 这些家族,用model_flavour指定同家族下的具体变体(比如 Flux 的 dev 和 schnell 训练行为完全不同),剩下的分辨率、精度、优化器、checkpoint 策略全在同一个配置文件的不同字段里。
这意味着一个实验的完整定义就是一份 config.json 加一份 multidatabackend.json。这两个文件丢进 git,三周后 checkout 回来,环境依赖没变的情况下能一模一样的重跑。这件事听起来很朴素,但在实际项目里能省下的返工时间非常可观。我自己踩过最蠢的坑是:同一个数据集,两个脚本对 caption 文件的编码处理不同,一个默认 UTF-8 一个默认系统编码,导致中文 tag 一个正常一个变问号,训出来的模型完全不是一个东西。配置文件统一之后,这类问题至少不会藏在代码深处。
它的入口也很简单,配好环境后就是:
python train.py --config config/config.json第一次用可以直接跑交互式向导:
python configure.py向导会依次问你要训练什么模型家族、走 LoRA 还是全量、数据集在哪、分辨率多少,最后生成两份 JSON。对于不熟悉字段含义的人,这是个很好的起点——先生成,再逐项读懂它写了什么,比对着文档从零手写要快得多。
1.2 配置即实验记录:一份 config.json 里的四个层次
我习惯把 SimpleTuner 的配置字段分成四个层次来看,这样调参的时候不容易乱。第一层是身份层,决定“这是什么实验”:model_family、model_flavour、pretrained_model_name_or_path、model_type(lora / lycoris / full)、output_dir、tracker_project_name。这一层错了,后面全白干。
第二层是资源层,决定“这台机器能不能跑起来”:mixed_precision、base_model_precision、gradient_checkpointing、train_batch_size、gradient_accumulation_steps、vae_batch_size、resolution与resolution_type。这一层是显存报错的高发区,也是我调整最频繁的部分。
第三层是优化层,决定“学得好不好”:learning_rate、lr_scheduler、lr_warmup_steps、optimizer、max_grad_norm、lora_rank(走 LoRA 时)、max_train_steps或num_train_epochs。
第四层是观测层,决定“我怎么知道它在正常学”:validation_prompt、validation_steps、validation_resolution、num_eval_images、checkpointing_steps、checkpoints_total_limit、resume_from_checkpoint、report_to。
分层的好处是排错时有方向感。loss 不动,先怀疑优化层;显存炸了,只看资源层;采样图糊成一团,回头查数据集和 caption。最怕的是一上来什么都改,最后不知道是哪一个改动起了作用。
有个细节值得单独说:num_train_epochs和max_train_steps是互斥的两种训练长度定义方式。跑 LoRA 时我几乎总是用max_train_steps,因为数据集大小和重复次数一旦变化,epoch 数对应的实际步数会漂移,而步数才是决定“训多久”的稳定单位。如果你希望按数据量走,就把max_train_steps设成 0,用num_train_epochs控制。
提示:
output_dir建议每次实验单独一个目录,并且和tracker_project_name、tracker_run_name保持命名一致。训练中断后resume_from_checkpoint填latest就能从最新 checkpoint 续跑,前提是目录没被复用覆盖。
1.3 上手之前先认清楚它的适用边界
这个工具箱不是万能的,有几条边界最好提前知道,省得中途卡住。
第一,它偏向“从预训练权重出发做适配”,不是从零预训练框架。你想做大规模继续预训练、要在原始分辨率之外重新定义噪声调度,那是另一个量级的事情。
第二,它对数据集的组织方式有约定——图片 + 同名 caption 文本文件(或 parquet 里带 caption 列),不符合这个约定的数据要先做转换。这个预处理步骤不能省,很多人卡在“训练跑起来了但模型什么都没学到”,最后发现是 caption 文件根本没被找到,模型在无条件下学习。
第三,它把大量能力堆在配置字段上,字段多到你第一次看会发怵。但实际上常用的也就二十来个,剩下的属于“需要时再去查”。我的做法是维护一份自己的最小配置模板,只保留跑通的字段,新增能力时再往里面加,避免一上来就复制一个三百行的示例配置把自己绕晕。
第四,视频扩散模型那条线对存储 I/O 的要求比图像高得多,配置逻辑一致但硬件门槛完全是另一个层级,如果只是做图像 LoRA,不必被那部分复杂度吓到。
2. 数据集不是文件夹:multidatabackend.json 的多源混合机制
2.1 每个子数据集是一个带概率的独立通道
绝大多数训练脚本对数据的理解是“一个目录下的所有图片”。SimpleTuner 换了个思路:数据集是一个列表,列表里每一项是一个独立的数据通道,每个通道有自己的路径、分辨率、caption 策略、增强方式和采样概率。
[ { "id": "style-core", "type": "local", "instance_data_dir": "/data/style_core", "caption_strategy": "textfile", "resolution": 1024, "resolution_type": "pixel_area", "repeats": 10, "probability": 0.7 }, { "id": "face-supplement", "type": "local", "instance_data_dir": "/data/face_supplement", "caption_strategy": "textfile", "resolution": 768, "repeats": 3, "probability": 0.3 } ]这个设计的实际价值在配比控制。传统做法是通过复制文件来调整比例,改一次比例就要动一次磁盘,数据集大了非常难受。这里repeats和probability是两个正交的旋钮:repeats影响的是“这个子集在一个采样周期里被看到多少次”,probability影响的是“抽样时选中这个子集的概率”。我自己的经验是,用probability做粗调、repeats做细调比较好理解,因为 probability 更直观——0.7 就是七成样本来自它。
一个常见的误区是把 supplement 集的 probability 设得很低就以为它“影响很小”。实际上扩散模型对少量但高信息量的样本很敏感,尤其是人脸、手部、特定材质这类稀缺特征。我一般宁愿把主集 probability 降到 0.8、辅集给 0.2,而不是给到 0.05——太低的话辅集一个 epoch 里几乎见不到几次,等于没加。
resolution也支持每个子集独立设置,这在混合不同来源数据时特别有用。老素材只有 512,新素材是 4K,混在一起统一 resize 到 1024 会让老素材变成模糊的插值图,反而带偏模型。给老素材单独设 768,让分桶机制自己处理,效果通常比强行上采样好。
2.2 caption_strategy 的四种取值与踩坑
caption 是微调里最容易被低估的变量。工具支持几种策略,含义差别很大:
| 策略 | 行为 | 适合场景 |
|---|---|---|
textfile | 读取与图片同名的 .txt 文件作为 caption | 手工标注、已有标签体系 |
instanceprompt | 整个子集共用一句固定 prompt,忽略文本文件 | 单一概念/风格注入、极简实验 |
parquet | 从 parquet 文件的 caption 列读取 | 大规模数据集、已有表格元数据 |
| 无 caption | 空字符串,走无条件下训练 | 基本不推荐,除非做特殊对照 |
instanceprompt看着粗暴,其实有它的用武之地。如果你要注入的是纯粹的画风,而数据集里每张图的详细描述反而会干扰“画风”这个概念的绑定,那句固定 prompt 加一个稀有触发词就是最干净的方案。代价是模型只学会“这个触发词 = 这种风格”,学不到内容解耦能力。
textfile是主流方案,坑也最多。第一是命名必须严格对应,图片叫img_001.png,文本就得叫img_001.txt,扩展名大小写不一致在某些系统上会导致匹配失败。第二是空文件会被当成长度为 0 的 caption,等于无条件下训练,一定要批量检查一遍有没有 0 字节的 txt。第三是编码问题,中文标签务必统一 UTF-8。
我用过一个很土但有效的检查脚本,训练前跑一遍:
import os from pathlib import Path root = Path("/data/my_dataset") imgs = {p.stem for p in root.iterdir() if p.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}} txts = {p.stem for p in root.iterdir() if p.suffix.lower() == ".txt"} print("无 caption 的图片:", sorted(imgs - txts)[:20], "共", len(imgs - txts)) print("多余的空 caption:", sorted(txts - imgs)[:20], "共", len(txts - imgs)) empty = [p.name for p in root.glob("*.txt") if p.stat().st_size < 3] print("空 caption 文件:", empty[:20], "共", len(empty))跑一次能省掉好几小时的无效训练。另外注意 caption 里如果有换行,有的读取实现会把换行转成空格,有的会截断,稳妥做法是一律单行、不含制表符。
2.3 分辨率分桶:pixel_area 与 aspect_bucket_rounding 的实际含义
扩散模型训练对输入尺寸敏感,直接把所有图拉伸成正方形是最省事也最容易毁数据的做法——人脸会被压扁,建筑会变形。分桶机制(aspect ratio bucketing)的思路是把图片按宽高比归到若干个桶里,每个桶内部尺寸一致,跨桶尺寸不同,训练时按桶成批,避免批次内尺寸冲突。
SimpleTuner 里resolution_type有两个常用取值:pixel和pixel_area。前者按短边或长边固定像素数缩放,后者按总像素面积缩放。pixel_area更接近“信息量守恒”的直觉:1024×1024 和 1536×682 面积接近,缩放后细节密度一致,训练稳定性更好。我默认都用pixel_area。
aspect_bucket_rounding控制桶的粒度。设成 2 表示宽高比按 2 像素为步长取整,桶数量多、尺寸贴合但内存占用碎;设得太大会让不同宽高比被强行归并,失去分桶的意义。实践中 2 到 6 之间比较常见,我一般用 4 左右,在桶数量和贴合度之间取平衡。
还有一个容易被忽略的参数是minimum_image_size。数据集里如果混了 300×200 的小图,不设下限的话它会被上采样到 1024 面积,糊成一团色块参与训练,本质上是在教模型生成模糊图。设一个下限(比如 512),低于它的图直接跳过,比强行缩放干净得多。
2.4 缓存目录为什么值得单独设置
训练时每个 step 都要把图片过一遍 VAE 编成 latent。VAE 编码本身开销不算小,尤其是 batch 稍大或者分辨率高的时候。工具箱支持把编码后的 latent 缓存到磁盘,后续 epoch 直接读缓存,省掉重复的 VAE 前向。
配置里对应的是cache_dir_vae这类的字段,每个子数据集可以各设一处。我的经验是把它放在读写速度稳定、容量足够的盘上——缓存文件体积大约是原始图片的几倍(取决于 latent 通道数和精度),十万张图的数据集可以轻松吃掉上百 GB。放在机械盘上,读缓存反而可能比现算还慢,这就得不偿失了。
另外一点:改了分辨率或者改了 VAE 权重之后,缓存必须清掉重算。缓存命中判断通常基于文件路径和尺寸,它不会知道你换了模型。我遇到过一次“训练速度飞快但效果极差”,查了半天才发现用的是上一次实验残留的缓存,分辨率和现在完全对不上。
3. 显存预算表:24G 卡上跑大模型 LoRA 的真实开销拆解
3.1 谁在吃显存:四块内存的账本
显存报错能不能快速解决,取决于你会不会算账。训练时的显存占用大致分四块:
第一块是常驻权重。冻结的基础模型仍然要在显存里放一份。SDXL 的 UNet 参数约 2.6B,加两个文本编码器,bf16 下合计大约 7GB 上下。Flux 是 12B 级别,bf16 直接就是 24GB 量级,24G 卡根本放不下——这就是为什么训 Flux LoRA 几乎必然要开量化或者分层加载。SD3、Sana 这类参数量介于两者之间的模型,bf16 大概 10 到 16GB,24G 卡勉强能塞,但留给激活值的空间就很紧张了。
第二块是可训练参数和优化器状态。LoRA 这一块很轻。SDXL 上 rank 16 的 LoRA 大概几十 M 参数,AdamW 的 fp32 状态按每参数 8 字节算,加上参数本身,通常不到 1GB。所以“训 LoRA 显存小”这句话的真正原因不是 LoRA 参数量小(那只是次要因素),而是不需要为整个模型保存梯度和优化器状态。全量微调就不一样了:SDXL 全量在 bf16 + AdamW fp32 状态下,光权重梯度和优化器状态就得再多出几十 GB,24G 卡做全量基本无望,除非上分片方案。
第三块是激活值。这才是真正的大头,也是唯一能通过参数有效压缩的部分。分辨率翻倍,激活值大致翻四倍。1024 面积、batch 1、开启梯度检查点的 SDXL LoRA,激活值大概 6 到 10GB;关掉检查点会直接翻几倍,瞬间 OOM。所以“开不开 gradient_checkpointing”几乎不是选择题,是必答题。
第四块是碎片和临时缓冲。VAE 编码、文本编码器前向、注意力计算的临时张量、CUDA 上下文本身,加起来往往有一两个 GB 的隐性开销。这也是为什么你把账算得刚好卡在 24GB 上,实际跑起来还是会炸——留 2GB 余量是底线。
3.2 base_model_precision 与量化档位的收益与代价
base_model_precision这个字段是 24G 卡玩家的救命稻草,常见取值包括不改变、fp8 量化、int8 量化、int4 量化这几档。收益和代价很明确:
| 档位 | 权重显存大致变化 | 主要代价 |
|---|---|---|
| no_change(bf16) | 基准 | 无额外代价,但大模型放不下 |
| fp8 | 约减半 | 需要较新硬件支持,部分算子回退 |
| int8 | 约减半,兼容性更好 | 反量化有额外计算,速度略降 |
| int4 | 约减到四分之一 | 精度损失明显,小 rank LoRA 可能学不动 |
我的实践结论是:int8 是 24G 卡上的甜点位。它能把 Flux 级别的模型权重压到 12GB 左右,配合梯度检查点和小 batch,LoRA 训练勉强能跑起来;精度损失在 LoRA 场景下通常可接受,因为低秩适配本身对基础权重的微小扰动不敏感。int4 我只有在显存实在不够、且愿意用更高 rank 和更长步数换质量的情况下才会考虑,否则经常出现“训完感觉模型变笨了”的情况。
还有一个配套参数是vae_batch_size。VAE 编码是独立于训练的,可以单独调小来削峰。如果你在训练启动的头几十步就 OOM,而正式训练的 batch 并不大,八成是 VAE 批量编码那一下冲爆了,把vae_batch_size设成 1 往往立刻解决。
3.3 gradient_checkpointing、TREAD、batch size 的取舍顺序
显存不够时该先动哪个?我的一般顺序是这样的。
第一步动 gradient_checkpointing。它用计算换显存,代价是前向算两遍,速度降 20% 到 30%,但显存收益最大。工具箱里通常支持不同档位(比如卸载到 CPU 内存的那种),24G 卡训大模型基本是必开。唯一要注意的是有些档位会显著增加 CPU 内存占用,机器内存只有 32G 的话要留意。
第二步动 batch size 和梯度累积。这里的关键认知是:train_batch_size=1+gradient_accumulation_steps=8和train_batch_size=8在梯度效果上等价,在显存上差别巨大,但在批归一化和某些统计行为上不完全等价。扩散模型没有 BatchNorm,所以基本可以放心用累积替代大 batch。我在 24G 卡上跑 SDXL LoRA 的常规配置就是 batch 1 或 2,累积 4 到 8,有效 batch 控制在 4 到 16 之间。
第三步才是考虑 TREAD 这类 token 裁剪方案。它的思路是在训练时按比例丢弃一部分 token 的计算(比如只算 50% 的 token),能省下可观的计算量和一部分激活显存,代价是信息损失。对于 LoRA 这种低秩适配,TREAD 开着通常还能收敛,但如果你发现模型在细节上总是差一口气,把它关掉对比一下是值得的。
最后才是量化。因为量化会同时影响精度和速度,属于“不得已而为之”。顺序反过来做的人不少——一上来就把基础模型压到 int4,然后发现怎么调学习率都学不动,绕了一大圈。
4. LoRA、LyCORIS 与全量微调:怎么选,以及学习率怎么定
4.1 三种模式的参数量与适用场景
工具箱支持的主要训练模式可以归成三类,选错了后面全是浪费。
标准 LoRA是最通用的选择。它只在注意力层的线性投影上挂低秩旁路,lora_rank控制容量。rank 8 到 16 适合风格、材质这类全局特征;rank 32 到 64 适合人物、特定物体这类需要更多细节容量的目标;再往上收益递减明显,而且小数据集上高 rank 极易过拟合。LoRA 的另一个优势是产出文件小,几 MB 到几十 MB,分发和切换成本极低。
LyCORIS 系列把低秩适配扩展到卷积层和更多的线性层组合,容量更大,对画风、笔触这类需要改变生成分布的任务表现通常更好。代价是文件更大、训练更慢、过拟合风险更高。工具箱里通过单独的lycoris_config配置文件控制算法类型、维度、缩放系数这些参数,和主配置分开管理。我的经验是:如果 LoRA rank 64 还是感觉“学不进去”,再考虑 LyCORIS,而不是一上来就用。
全量微调是唯一能真正改变模型基础能力的路径,但也是显存和过拟合双重压力最大的。24G 单卡基本无望,通常要 8 卡以上配合分片方案。对绝大多数应用场景来说,全量微调是过度设计——你想要的风格、人物、构图偏好,LoRA 都能拿到八成以上的效果。除非你要做的是“把这个模型改造成另一个模型”级别的事情,否则不必碰。
4.2 学习率不是拍脑袋:从 rank 和有效 batch 推
学习率是新手最容易照抄别人的参数。抄来的数值在别人的配置上有效,换到你这里可能差一个数量级。我用的推法是先确定有效 batch,再定基准。
有效 batch =train_batch_size×gradient_accumulation_steps× GPU 数。学习率大致随有效 batch 的平方根缩放(保守做法),线性缩放则更激进。以有效 batch 8 为基准点,我常用的起手值是这样:
| 模式 | rank / 规模 | 起手学习率 | 调度器 |
|---|---|---|---|
| SDXL LoRA | rank 16 | 1e-4 | constant_with_warmup |
| SDXL LoRA | rank 64 | 6e-5 | constant_with_warmup |
| 大参数模型 LoRA | rank 16~32 | 8e-5 ~ 1e-4 | constant_with_warmup |
| LyCORIS | 中等维度 | 5e-5 ~ 8e-5 | constant_with_warmup |
| 全量微调 | — | 1e-6 ~ 4e-6 | constant_with_warmup |
有效 batch 翻倍,学习率按平方根乘 1.4 左右,这是我自己的经验系数。lr_warmup_steps给总步数的 1% 到 3%,太少容易前期震荡,太多浪费训练预算。调度器方面,LoRA 场景我几乎只用constant_with_warmup——预热之后保持恒定,训练后期的“余弦衰减到零”在 LoRA 上经常导致最后一段几乎没在学,白白浪费步数。如果你想精细控制结束,用步数截断比用衰减调度更直接。
还有一个容易被忽略的字段是max_grad_norm。梯度裁剪在扩散模型训练里是稳定器,默认 1.0 一般够用;如果你观察到 loss 偶尔尖峰然后恢复,可以降到 0.5 试试;如果训练整体过于平缓、学得慢,反而要检查是不是裁剪过猛把有效梯度都削掉了。
4.3 优化器选项:几种常见档位的实际差别
工具箱里optimizer字段的可选值覆盖了主流方案,实际用下来差别不小。
bf16 版 AdamW是最省显存的常规选择,优化器状态用 bf16 存,对 LoRA 这种参数量小的场景精度损失几乎不可见,我把它当默认值。fp32 版 AdamW在 LoRA 上也没问题(参数少,状态占不了多少),在需要极致稳定的场景可以切回去。8bit 优化器主要价值在全量微调或者超大 rank 场景,LoRA 上用它的收益很小,反而引入额外量化误差。
自适应学习率的优化器(不需要你指定 lr,自己估)在数据集小、不确定学习率的时候挺有用,尤其是你完全不知道该给多少的时候,它能给一个不算离谱的起点。但它的问题是可复现性差一些,而且后期可能出现学习率被压得过低的情况。我的用法是:新任务第一次跑,用它探路看 loss 曲线形态;确定量级之后换成固定学习率重跑正式实验。
SGD 系在扩散模型微调里用得少,除非你在做非常特殊的、需要强泛化的实验。不用纠结。
顺带提一个经常被忽视的点:optimizer_beta2。默认值通常偏高,对于小 batch、噪声大的扩散训练,把 beta2 降到 0.95 左右有时能明显改善收敛稳定性,尤其在训练前期。这个改动成本极低,值得在 loss 抖动时试一次。
5. 验证采样:把“训崩了”提前三千步发现
5.1 validation_prompt 的设置原则
训练几小时之后才发现模型废了,是最伤人的情况。验证采样的作用就是在训练过程中定期出图,让你用眼睛判断方向对不对。
validation_prompt的设置有几个原则。第一,必须包含你注入的触发词或核心概念,否则验证图无法反映目标能力。第二,同时放一到两条“不该被污染”的通用 prompt,比如一条普通的人像、一条风景,用来检查模型是否发生了灾难性遗忘——如果通用 prompt 出的图明显变差,说明学习率过高或者训练过度。第三,prompt 措辞尽量贴近你实际使用时的写法,别用训练集里没有的复杂描述,验证图会失真。
配置上validation_steps控制间隔。我的建议是让验证频率和 checkpoint 频率对齐,或者验证频率略低于 checkpoint 频率,避免采样本身占用太多时间。num_eval_images给 1 到 2 张就够判断趋势,给太多纯属浪费。validation_num_inference_steps设 20 到 30 步即可,验证图不需要最高质量,需要的是可比较性——每次验证用同一套种子和步数,这样不同 step 的图能直接对比。
validation_guidance也是同理,固定住。很多人调验证的时候顺手改了 CFG,结果两张图没法比,白折腾。
5.2 从采样图读懂训练状态
采样图能告诉你的信息比 loss 曲线多得多。我按四种典型形态来判断:
形态一:完全没变化。训练几百步后验证图和基础模型出图一模一样,说明 LoRA 权重还没产生有效影响。常见原因是学习率过低、rank 过小、或者 caption 没读到(模型在无条件下学习,而你的验证 prompt 是有条件的,自然看不出变化)。先查 caption,再查学习率。
形态二:过曝、发灰、色彩崩坏。这是典型的训过头或学习率过高。扩散模型过拟合时经常表现为对比度异常、颜色饱和、细节被“烤糊”。看到这个立刻回退到更早的 checkpoint,并把总步数砍掉三分之一重跑。
形态三:构图正确但细节糊。通常是分辨率不匹配或者 rank 不够。如果数据集原图分辨率高但你训在 768 面积上,模型学到的细节上限就被限制住了。也可能是 VAE 缓存分辨率不对。
形态四:每张图都长得一样。这是数据多样性不足或者repeats过高导致的模式坍缩。检查数据集里是不是大量近似重复的图,以及 caption 是不是写得太雷同——所有 caption 都是同一句话时,模型只能学到那一个映射。
我一般的判断节奏是:前 10% 步数看“有没有开始学”,中间 50% 看“细节是否在增强”,后 30% 看“有没有开始崩”。真正可用的 checkpoint 往往在总步数的 60% 到 80% 之间,最后那一段经常是越来越差。
5.3 EMA 与 checkpoint 策略
工具箱支持权重 EMA(指数滑动平均),思路是维护一份参数的滑动平均副本,通常能得到更平滑、更稳定的生成效果。代价是显存和少量计算开销,以及需要额外的更新间隔参数控制。
我的用法是:短训练不开 EMA,长训练开。几千步以内的 LoRA,EMA 的收益不明显,反而拖慢速度;上万步的训练,EMA 版本往往比原始权重更稳定,尤其是你后面还要做融合的时候。开启之后验证时最好两边都出图对比,别默认 EMA 一定更好——在小数据集上 EMA 有时会让风格变得“温吞”。
checkpoint 策略上,checkpointing_steps和checkpoints_total_limit要配合好。总步数一万步、每 500 步存一次就是 20 个 checkpoint,如果每个几百 MB,磁盘很快吃紧。设checkpoints_total_limit为 4 到 6,让工具自动滚动删除旧的。但要注意:滚动删除可能删掉你后来发现最好的那一版。我的做法是看到验证图状态好的那一步,手动把对应的 checkpoint 复制出来另存,别指望它一直在。
resume_from_checkpoint填latest可以在中断后续训,但续训会恢复优化器状态和步数计数,如果之前改过配置(比如改了学习率),续训后的行为可能和你预期不符。改关键参数之后我倾向于开新实验而不是续训,避免状态混乱。
6. 训练崩了以后:高频故障的完整排查链路
6.1 显存溢出:先看炸在哪一步
OOM 的报错信息往往只给一行“显存不足”,但炸的位置决定了原因。我的排查顺序是这样的。
炸在加载模型阶段:权重放不下。这时候不用调 batch,直接调base_model_precision或换用分层加载。报错里如果显示的是加载权重时的分配失败,基本就是这个。
炸在 VAE 编码阶段:把vae_batch_size降到 1,或者干脆先离线把 latent 缓存算好再训练。
炸在第一个训练 step:激活值超了。开gradient_checkpointing,降train_batch_size到 1,提高累积步数补回有效 batch,降分辨率。
炸在第 N 个 step(N 不固定):多半是内存碎片或者某个特定尺寸的桶触发了峰值。分桶训练时不同宽高比的激活值不一样,某个极端比例的桶可能是罪魁祸首。用aspect_bucket_rounding调粗一点,或者对超大比例的图设上限。
炸在多卡环境:检查是不是某张卡上分了更多层。有些并行策略会不均匀分配,导致单卡先炸。
排查时的实用技巧是设环境变量让 PyTorch 输出更细的分配信息,能看到峰值出现在哪个张量上,比盲猜快得多。
6.2 loss 变 NaN 或出图全灰
NaN 是扩散训练里最经典的问题,成因有好几个层次。
第一层看数值精度。bf16 在极端情况下会溢出,尤其是注意力里的 softmax 或者大分辨率下的方差计算。切到 fp32 混合精度或者加上max_grad_norm裁剪,能解决大部分情况。
第二层看学习率。学习率过高时,前几步就可能把权重推到发散区。如果你在第 10 步就看到 loss 变成科学计数法,几乎肯定是学习率问题,砍一个数量级重跑。
第三层看数据。数据集里如果有全黑、全白、纯色图,VAE 编码出的 latent 方差接近零,计算时容易出除零。也可能有损坏的图片文件,读出来是随机噪声。批量做一次数据体检很有必要:检查图片能否正常解码、尺寸是否异常、是不是纯色。
第四层看 caption 空值比例。大量空 caption 会让模型在条件与无条件之间反复横跳,训练不稳定。设一个 caption 丢弃概率(配置里的 caption dropout)是常规操作,但那个是“主动丢弃”,和“本来就没有”是两回事,后者比例高了要修数据。
出图全灰是 NaN 的温和版本——权重还没炸但已经接近失效。表现是验证图变成均匀灰色或者只有极弱的纹理。这时候回退 checkpoint,降学习率,检查上面四层。
6.3 过拟合与“复读机”现象
过拟合在扩散模型上的表现和分类任务不太一样。典型症状是:验证 prompt 里只要包含触发词,出图就几乎是训练集的复制品,换个姿势、换个背景都不行;把触发词去掉,模型又完全正常。
这说明模型学到了“触发词 → 具体图像”的硬映射,而不是“触发词 → 概念”。成因通常是训练步数过多、数据集太小、caption 信息量太低。
对策按性价比排序:第一,减少步数,回到验证图状态最好的那一步;第二,增加 caption 的信息密度,把“一个女孩”改成“一个女孩,侧脸,逆光,浅色背景”,让模型知道哪些属性和触发词解绑;第三,增加数据多样性,同一个概念换角度、换光照、换背景;第四,降 rank,容量小了反而不容易死记硬背。
还有一个隐蔽的过拟合来源是repeats设得太高。小数据集靠 repeats 撑步数是常见做法,但 repeats 高意味着每个 epoch 里模型反复看同样的图,等价于在少数样本上过训练。更健康的做法是数据量本身够,repeats 设 1 到 3。
6.4 多卡训练的不同步与日志异常
多卡跑起来的坑集中在几个地方。
loss 曲线在多卡下比单卡更抖,这是正常的,因为每张卡看到的是不同数据,梯度聚合后噪声更大。判断标准不是抖动幅度,而是趋势是否一致向下。
有效 batch 变了但学习率没改,是最常见的隐性错误。4 卡从单卡迁过来,有效 batch 翻了四倍,学习率还按单卡的给,结果就是学得慢、看起来像收敛了其实没学到。记得按前面说的缩放规则调。
某张卡显存比其他卡高很多,通常是数据分布不均或者并行策略的问题。检查分桶是不是把所有大尺寸样本都分到了同一张卡——某些实现按桶分配批次时会失衡。
checkpoint 保存时间随卡数增加而变长,因为要聚合权重。大模型上这一步可能卡住几十秒,日志看起来像卡死。如果训练日志长时间没有新行,先确认是不是在存盘而不是真的挂了。
验证采样只在主进程跑,这是正确的设计,但如果你看的是所有卡的日志,会觉得其他卡“没输出”。别慌,看主卡的日志就行。
7. 我个人在长期使用中沉淀的几条习惯
写到这里,技术点基本覆盖完了。最后分享几个纯经验层面的做法,都是踩过坑之后固化下来的。
第一,永远先用极小数据集空跑一次。拿 20 张图、50 步、最小分辨率跑一遍完整流程,从数据读取、模型加载、训练、验证采样到存盘和恢复,全链路走通再换真数据。这个动作花十分钟,能省掉后面几小时“跑到第 800 步才发现某个配置字段名写错了”的崩溃。
第二,配置文件里每一处偏离默认值的地方都加注释。JSON 不支持注释,我就在旁边放一个同名的 markdown 记录“为什么改这一项”。半年后回来看,你会感谢当时的自己。
第三,验证采样图按 step 命名存一份到独立目录。工具默认会存,但我习惯再拷一份到按实验名组织的目录里,用文件名排序就能看出演化过程。这件事在做参数对比实验时价值极大——光看 loss 曲线根本看不出哪一版更好。
第四,别信“别人说这个学习率好用”。模型家族、数据规模、rank、有效 batch、优化器,任何一项变了学习率都得重估。我现在的做法是第一次跑总用比直觉值低一点的起点,观察前 200 步 loss 下降速度,下降太慢就按 2 倍往上试,比从高处往下调安全——训练发散之后重跑的代价,远高于多花几百步试探。
第五,量化、TREAD、EMA 这些“优化项”,一次只开一个。全开之后效果不好,你根本不知道是哪个拖的后腿。先跑通一个朴实无华的 baseline,再逐项加,每加一项对比验证图。这个流程听起来笨,但它是在一堆互相影响的开关里唯一可靠的方法。
这套工具箱真正的价值不在于它有多少功能,而在于它把这些功能收敛到一套可记录、可复现、可迁移的配置体系里。你花在读文档上的时间,会以“少返工”的形式还回来。