File size: 15,648 Bytes
0ed6b0e | 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 | # 两阶段拆分训练
本文档介绍拆分训练,能够自动将训练过程拆分为两阶段进行,减少显存占用,同时加快训练速度。
(拆分训练是实验性特性,尚未进行大规模验证,如果在使用中出现问题,请在 GitHub 上给我们提 issue。)
## 拆分训练
在大部分模型的训练过程中,大量计算发生在“前处理”中,即“与去噪模型无关的计算”,包括 VAE 编码、文本编码等。当对应的模型参数固定时,这部分计算的结果是重复的,在多个 epoch 中每个数据样本的计算结果完全相同,因此我们提供了“拆分训练”功能,该功能可以自动分析并拆分训练过程。
对于普通文生图模型的标准监督训练,拆分过程是非常简单的,只需要把所有 [`Pipeline Units`](../Developer_Guide/Building_a_Pipeline.md#units) 的计算拆分到第一阶段,将计算结果存储到硬盘中,然后在第二阶段从硬盘中读取这些结果并进行后续计算即可。但如果前处理过程中需要梯度回传,情况就变得极其复杂,为此,我们引入了一个计算图拆分算法用于分析如何拆分计算。
## 启用拆分训练
拆分训练已支持[标准监督训练](../Training/Supervised_Fine_Tuning.md)和[直接蒸馏训练](../Training/Direct_Distill.md),在训练命令中通过 `--task` 参数控制,以 Qwen-Image 模型的 LoRA 训练为例,拆分前的训练命令为:
```shell
modelscope download --dataset DiffSynth-Studio/diffsynth_example_dataset --include "qwen_image/Qwen-Image/*" --local_dir ./data/diffsynth_example_dataset
accelerate launch examples/qwen_image/model_training/train.py \
--dataset_base_path data/diffsynth_example_dataset/qwen_image/Qwen-Image \
--dataset_metadata_path data/diffsynth_example_dataset/qwen_image/Qwen-Image/metadata.csv \
--max_pixels 1048576 \
--dataset_repeat 50 \
--model_id_with_origin_paths "Qwen/Qwen-Image:transformer/diffusion_pytorch_model*.safetensors,Qwen/Qwen-Image:text_encoder/model*.safetensors,Qwen/Qwen-Image:vae/diffusion_pytorch_model.safetensors" \
--learning_rate 1e-4 \
--num_epochs 5 \
--remove_prefix_in_ckpt "pipe.dit." \
--output_path "./models/train/Qwen-Image_lora" \
--lora_base_model "dit" \
--lora_target_modules "to_q,to_k,to_v,add_q_proj,add_k_proj,add_v_proj,to_out.0,to_add_out,img_mlp.net.2,img_mod.1,txt_mlp.net.2,txt_mod.1" \
--lora_rank 32 \
--use_gradient_checkpointing \
--dataset_num_workers 8 \
--find_unused_parameters
```
拆分后,在第一阶段中,做如下修改:
* 将 `--dataset_repeat` 改为 1,避免重复计算
* 将 `--output_path` 改为第一阶段计算结果保存的路径
* 添加额外参数 `--task "sft:data_process"`
* 在 `offload_models` 中填入不需要进行 forward 计算的模型,格式与 `model_id_with_origin_paths` 相同
* 直接删除 `--model_id_with_origin_paths` 中不需要进行 forward 计算的模型也可,但你必须确保对应的模型在 pipeline 中不会被间接调用,这意味着你必须了解 Pipeline 的运行细节
```shell
accelerate launch examples/qwen_image/model_training/train.py \
--dataset_base_path data/diffsynth_example_dataset/qwen_image/Qwen-Image \
--dataset_metadata_path data/diffsynth_example_dataset/qwen_image/Qwen-Image/metadata.csv \
--max_pixels 1048576 \
--dataset_repeat 1 \
--model_id_with_origin_paths "Qwen/Qwen-Image:text_encoder/model*.safetensors,Qwen/Qwen-Image:vae/diffusion_pytorch_model.safetensors,Qwen/Qwen-Image:transformer/diffusion_pytorch_model*.safetensors" \
--offload_models "Qwen/Qwen-Image:transformer/diffusion_pytorch_model*.safetensors" \
--learning_rate 1e-4 \
--num_epochs 5 \
--remove_prefix_in_ckpt "pipe.dit." \
--output_path "./models/train/Qwen-Image-LoRA-splited-cache" \
--lora_base_model "dit" \
--lora_target_modules "to_q,to_k,to_v,add_q_proj,add_k_proj,add_v_proj,to_out.0,to_add_out,img_mlp.net.2,img_mod.1,txt_mlp.net.2,txt_mod.1" \
--lora_rank 32 \
--use_gradient_checkpointing \
--dataset_num_workers 8 \
--find_unused_parameters \
--task "sft:data_process"
```
在第二阶段,做如下修改:
* 将 `--dataset_base_path` 改为第一阶段的 `--output_path`
* 删除 `--dataset_metadata_path`
* 添加额外参数 `--task "sft:train"`
* 在 `offload_models` 中填入不需要进行 forward 计算的模型,格式与 `model_id_with_origin_paths` 相同
* 直接删除 `--model_id_with_origin_paths` 中不需要进行 forward 计算的模型也可,但你必须确保对应的模型在 pipeline 中不会被间接调用,这意味着你必须了解 Pipeline 的运行细节
```shell
accelerate launch examples/qwen_image/model_training/train.py \
--dataset_base_path "./models/train/Qwen-Image-LoRA-splited-cache" \
--max_pixels 1048576 \
--dataset_repeat 50 \
--model_id_with_origin_paths "Qwen/Qwen-Image:text_encoder/model*.safetensors,Qwen/Qwen-Image:vae/diffusion_pytorch_model.safetensors,Qwen/Qwen-Image:transformer/diffusion_pytorch_model*.safetensors" \
--offload_models "Qwen/Qwen-Image:text_encoder/model*.safetensors,Qwen/Qwen-Image:vae/diffusion_pytorch_model.safetensors" \
--learning_rate 1e-4 \
--num_epochs 5 \
--remove_prefix_in_ckpt "pipe.dit." \
--output_path "./models/train/Qwen-Image-LoRA-splited" \
--lora_base_model "dit" \
--lora_target_modules "to_q,to_k,to_v,add_q_proj,add_k_proj,add_v_proj,to_out.0,to_add_out,img_mlp.net.2,img_mod.1,txt_mlp.net.2,txt_mod.1" \
--lora_rank 32 \
--use_gradient_checkpointing \
--dataset_num_workers 8 \
--find_unused_parameters \
--task "sft:train"
```
我们提供了样例训练脚本和验证脚本,位于 `examples/qwen_image/model_training/special/split_training`。
## 计算图拆分算法原理
训练框架通过 `DiffusionTrainingModule` 的 `split_pipeline_units` 方法拆分 `Pipeline` 中的计算单元,以下是计算图拆分算法的详细原理。
### 问题定义
为精确刻画拆分过程,本节对计算流水线进行形式化描述。设流水线由 $n$ 个计算单元([`Pipeline Unit`](../Developer_Guide/Building_a_Pipeline.md#units))构成,记单元集合为 $V=\{u_1,u_2,\dots,u_n\}$。每个单元 $u\in V$ 具有如下属性:
* 输入参数集合 $\operatorname{in}(u)$:由 `input_params`、`input_params_posi` 与 `input_params_nega` 声明,表示 $u$ 计算前必须读取的数据项;
* 输出参数集合 $\operatorname{out}(u)$:由 `output_params` 声明,表示 $u$ 计算完成后产生并写入数据缓存的数据项;
* 关联模型集合 $\mathcal{M}(u)$:由 `onload_model_names` 声明,表示 $u$ 的计算所依赖的模型。
全体参数构成参数空间 $\mathcal{P}=\bigcup_{u\in V}\left(\operatorname{in}(u)\cup\operatorname{out}(u)\right)$。
**定义 1(数据依赖边)** 设参数 $p\in\mathcal{P}$。若存在单元 $u_i,u_j\in V$,使得 $p\in\operatorname{out}(u_i)\cap\operatorname{in}(u_j)$,且 $u_i$ 为 $p$ 的最近生产者(即全部产生 $p$ 的单元中执行顺序最靠后者),则称 $u_i$ 与 $u_j$ 之间存在数据依赖边 $(u_i,u_j)$,其语义为 $u_j$ 的计算必须发生在 $u_i$ 完成之后。
由此,计算流水线被抽象为有向无环图 $G=(V,E)$,其中 $E$ 为全部数据依赖边的集合。
**定义 2(直接相关单元)** 给定需梯度回传的模型集合 $\mathcal{W}$(由 `trainable_models` 与 `lora_base_model` 指定,分别是正在训练的模型组件和正在以 LoRA 训练的模型组件)。若单元 $u\in V$ 满足 $\mathcal{M}(u)\cap\mathcal{W}\neq\varnothing$,则称 $u$ 为直接相关单元,其计算过程涉及可训练模型的调用。
**定义 3(计算图拆分问题)** 给定图 $G=(V,E)$ 与模型集合 $\mathcal{W}$,求 $V$ 的一个二分 $(V_1,V_2)$,使得 $V_1$ 为包含全部直接相关单元且满足下述闭包条件的最小集合,$V_2=V\setminus V_1$:
(C1)前向闭包:若 $u\in V_1$ 且 $(u,v)\in E$,则 $v\in V_1$,即 $V_2$ 中不存在任何依赖 $V_1$ 输出的单元;
(C2)更新链闭包:对任意参数 $p\in\mathcal{P}$,设其更新链 $\mathbf{c}(p)=(u^{(1)},u^{(2)},\dots,u^{(k)})$ 为按执行顺序产生 $p$ 的全部单元。若 $p$ 在 $V_1$ 内首次被消费于 $u^{(i)}$ 且 $i<k$,则 $u^{(i+1)},\dots,u^{(k)}\in V_1$,即 $V_1$ 内部对参数的消费与更新顺序与全图执行顺序一致。
拆分目的:$V_2$ 中单元的计算结果与模型参数无关,可在多个 epoch 间复用,故将其执行于数据预处理阶段(第一阶段)并缓存至磁盘;$V_1$ 中单元的计算需梯度回传,执行于训练阶段(第二阶段)。
### 算法设计
考虑集合算子 $T:2^V\to 2^V$:
$$
T(X)=X\cup F(X)\cup U(X),
$$
其中 $F$ 为前向可达算子,$F(X)$ 为 $X$ 沿数据依赖边(方向 producer $\to$ consumer)可达的全部后继单元的传递闭包;$U$ 为更新链回溯算子,$U(X)$ 定义为:对任意参数 $p$,若 $p$ 在 $X$ 内首次被消费于 $u^{(i)}$ 且 $i<k$($k$ 为更新链 $\mathbf{c}(p)$ 的长度),则 $U(X)$ 包含 $\mathbf{c}(p)$ 中后续更新单元 $u^{(i+1)},\dots,u^{(k)}$ 中不属于 $X$ 者。
**命题 1(单调性与终止性)** $T$ 为单调算子,即 $X\subseteq Y\Rightarrow T(X)\subseteq T(Y)$。以直接相关单元集合 $X_0=\{u\in V\mid\mathcal{M}(u)\cap\mathcal{W}\neq\varnothing\}$ 为初值,迭代 $X_{k+1}=T(X_k)$。由于 $X_k$ 单调递增且 $X_k\subseteq V$($|V|=n$),迭代至多 $n$ 步终止于最小不动点 $X^*$,满足 $T(X^*)=X^*$。
**命题 2(一致性)** 令 $V_1=X^*$,$V_2=V\setminus V_1$,则 $(V_1,V_2)$ 满足条件(C1)与(C2)。由 $X^*$ 的不动点性质可知,$F(X^*)\subseteq X^*$ 蕴含(C1)成立,$U(X^*)\subseteq X^*$ 蕴含(C2)成立。
### 算法伪代码
上述算法的实现如下:
```python
def split_pipeline_units(units, model_names):
# 步骤 1:初始化 X_0,即直接相关单元集合
related = {id for id, unit in enumerate(units)
if unit.onload_model_names is not None
and any(m in unit.onload_model_names for m in model_names)}
# 步骤 2:构建数据依赖边 E 与各参数的更新链 c(p)
edges = build_edges(units)
chains = build_chains(units)
# 步骤 3:不动点迭代 X_{k+1} = X_k ∪ F(X_k) ∪ U(X_k)
while True:
before = len(related)
related = forward_reachable(edges, related) # F(X_k)
related = updating_units(units, chains, related) # U(X_k)
if len(related) == before:
break
# 步骤 4:输出二分 (V1, V2)
related_units = [units[i] for i in sorted(related)]
unrelated_units = [units[i] for i in range(len(units)) if i not in related]
return related_units, unrelated_units
```
各子函数的构造细节如下。
`build_edges` 扫描单元序列,维护"参数 $\to$ 最近生产者"的映射:当单元 $u_j$ 的输入参数 $p$ 已存在生产者 $u_i$ 时记录边 $(u_i,u_j)$;随后以 $u_j$ 的每个输出参数 $p$ 更新该映射。
```python
def build_edges(units):
last_producer = {}
edges = []
for id, unit in enumerate(units):
for param in unit.fetch_input_params():
if param in last_producer:
edges.append((last_producer[param], id))
for param in unit.fetch_output_params():
last_producer[param] = id
return edges
```
`build_chains` 按执行顺序记录每个参数的更新链 $\mathbf{c}(p)$。
```python
def build_chains(units):
params = sorted(set(sum([unit.fetch_input_params() + unit.fetch_output_params()
for unit in units], [])))
chains = {param: [] for param in params}
for id, unit in enumerate(units):
for param in unit.fetch_output_params():
chains[param].append(id)
return chains
```
`forward_reachable` 实现前向可达算子 $F$,沿 producer$\to$consumer 方向迭代求闭包,直至不再有新单元加入。
```python
def forward_reachable(edges, related):
while True:
neighbors = {target for source, target in edges
if source in related and target not in related}
if not neighbors:
break
related |= neighbors
return related
```
`updating_units` 实现更新链回溯算子 $U$。对相关集合 $X$ 内每个单元的输入参数 $p$,确定 $p$ 在 $X$ 内首次被消费的单元 $u^{(i)}$;若 $i<k$,则将更新链 $\mathbf{c}(p)$ 中位于 $u^{(i)}$ 之后的全部单元并入 $X$,从而保证条件(C2)。
```python
def updating_units(units, chains, related):
# 按执行顺序(单元 id 升序)确定 p 在 X 内首次被消费的单元
first_consumer = {}
for unit_id in sorted(related):
for param in units[unit_id].fetch_input_params():
if param not in first_consumer:
first_consumer[param] = unit_id
for param, first in first_consumer.items():
chain = chains[param]
if first in chain and chain.index(first) != len(chain) - 1:
for later in chain[chain.index(first) + 1:]:
related.add(later)
return related
```
### 两阶段拆分流程
获得划分 $(V_1,V_2)$ 后,训练框架依据任务类型配置 `Pipeline`,并分别交由 `launch_data_process_task` 与 `launch_training_task` 执行:
```python
def split_pipeline_units(task, pipe, trainable_models, lora_base_model, ...):
models_require_backward = []
if trainable_models is not None:
models_require_backward += trainable_models.split(",")
if lora_base_model is not None:
models_require_backward.append(lora_base_model)
if task.endswith(":data_process"): # 第一阶段(数据预处理)
other_units, pipe.units = pipe.split_pipeline_units(models_require_backward)
# pipe.units = V2,仅执行与模型无关的计算(如 VAE 编码、文本编码)
# 可选:追加 GeneralUnit_RemoveCache,剔除冗余缓存项以减小缓存体积
elif task.endswith(":train"): # 第二阶段(训练)
pipe.units, _ = pipe.split_pipeline_units(models_require_backward)
# pipe.units = V1,仅执行与模型相关的计算
```
**阶段一(数据预处理)** 在 `torch.no_grad()` 模式下遍历数据集,对每个样本执行 $V_2$ 中的单元,并将中间结果按进程分片序列化至磁盘:
```python
for data in dataloader:
with torch.no_grad():
cache = model(data) # 仅执行 V2 中的单元
torch.save(cache, cache_path) # 逐样本保存为 .pth
```
**阶段二(训练)** 数据加载器以第一阶段产生的缓存为输入,执行 $V_1$ 中的单元,完成前向传播、反向传播与参数更新:
```python
for data in dataloader: # 读取第一阶段缓存
with accelerator.accumulate(model):
loss = model({}, inputs=data) # 仅执行 V1 中的单元
accelerator.backward(loss) # 梯度仅流经可训练模型
optimizer.step()
```
**命题 3(缓存可复用性)** 由定义 3 可知,$V_2$ 中的单元不依赖任何可训练模型,其计算结果与模型参数 $\theta$ 无关。因此,阶段一产生的缓存可在后续任意 epoch 中直接复用,无需重复计算。基于该性质,阶段一的 `--dataset_repeat` 应设为 1,阶段二可保留原始取值。 |