LLM Systems Assignment 5: Distributed Training and Fine-Tuning
本次 homework 的任务是:实现 DP/PP/LoRA 的核心代码,整体代码量非常少,本文对实现要点和一些注意事项进行介绍。
1. Data Parallel
DP 部分的待实现代码非常简单,唯一需要提一点的是 ranndom
的相关使用方法:作业规定了不能使用普通的 random.shuffle
而是必须手动从指定 seed 构造 gen = random.Random(seed)
对象,然后调用 gen.shuffle(lst)
进行随机顺序打乱,这是因为:
random.shuffle依赖于全局的 random state(seed),其是每个进程启动是随机初始化的,每个进程不同。- DP
在进行数据集划分时,需要保证全量数据集被不遗漏不重复的均匀划分到每个进程,
partition_indices函数使用单独的 seed 和 random stage,就是为了让每个进程计算得到的数据集 index shuffle 结果完全相同,然后每个进程取自己 rank 对应的 index 进行数据读取即可。
2. Pipeline Parallel
2.1 Clock-Cycle Schedule
以 3 microbatch、3 stage 为例,第 k 拍各 stage 在跑哪个
microbatch(行是时钟,元素写成
(microbatch, stage))示意如下:
| k | device 0 | 1 | 1 |
|---|---|---|---|
| 0 | (0,0) | ||
| 1 | (1,0) | (0,1) | |
| 2 | (2,0) | (1,1) | (0,2) |
| 3 | (2,1) | (1,2) | |
| 4 | (2,2) |
每个节拍满足不变量:第 k 拍恰好是所有
i + j == k(且
0<=i<microbatches、0<=j<stages)的
(i,j),按 stage 递增排。总拍数
microbatches + stages - 1。
核心代码实现如下(省略参数校验):
1 | |
2.2 Pipeline Forward and Backward
2.2.1 StageWorker 接口
runtime.py 的 StageWorkers
是流水线执行引擎。每个 stage
一个后台线程,每个线程拥有两个队列(任务、结果)。
submit(stage, fn):无参 callable 塞进任务队列,立刻返回,非阻塞。receive(stage):阻塞等结果(30s 超时);worker 里抛的异常原样raise。close():往队列塞None哨兵让 worker 退出。
几个直接影响写法的细节:
- CUDA 当前 device 是线程局部的,worker 自己
with torch.cuda.device(...)绑卡。所以提交的函数里算 tensor 不用每次显式.to()。 - 梯度开关也是线程局部的。
submit时记录主线程此刻的is_grad_enabled(),worker 执行前set_grad_enabled还原。 - worker 用
try/except BaseException把异常打包成(False, exc),receive再raise。所以 forward 里不用写 try/except。
2.2.2 Python lambda 特性
Python lambda
特性是:闭包晚绑定。lambda: model(micro_input.to(dev))
里那几个名字,lambda 体在执行时(worker
线程,比定义晚)才读,那时循环早跑到最后一轮,所有 lambda
看到同一份最后的值。正确实现方式是在 submit 时把参数作为
xxx=xxx
显式初始化,这样参数立即求值构造为可调用对象提交到后台线程:
1 | |
lambda 体只能是表达式,不能是语句。如果在 lambda 里填写:
lambda ...: outputs[(m,s)] = model(x),会报错:语法错误——赋值是
statement。正确实现方式是 lambda 只算并返回,结果在 receive 时保存。
跨设备 .to(dev) 一定写进被提交的 callable 里,让传输在
worker 线程、和各 stage 并行。
2.2.3 实现
一个 wave 循环,每拍先全 submit 再全 receive:
1 | |
- 数据依赖跨 wave。
(m,s)的输入是上一 stage 的(m,s-1),而(m,s-1)在上一拍(m+s-1)已经 receive 进outputs。所以这次 submit 读outputs[(m,s-1)]时一定存在。outputs字典在拍间传中间结果。 - 先提交完整 wave 再接收,以让各个 worker 并发执行。
- 一个 stage 一拍里最多出现一次,结果队列 FIFO,所以 receive
回来的就是当前
(m,s)的结果,按与 submit 相同顺序 receive 即可获得结果。 - 不要 detach。
micro_input.to(dev)保留计算图,跨 stage 梯度链才连得上,bwd 才能把梯度传回输入和各 stage 参数。 - 拼接按
m递增正好是原始顺序,最后在self.devices[-1]上 cat。
3. DeepSpeed ZeRO and LoRA
3.1 测试 DeepSpeed 初始化流程及与 TODO 代码交互关系
任务 3
的主要测试代码在:grading/distributed.py,其中涉及到
DeepSpeed 初始化以及调用我们需要实现的 TODO 代码。具体解释如下:
- 测试启动流程:
main()先用socket.bind(("127.0.0.1", 0))检测一个空闲端口供 DeepSpeed 使用;进程组汇合用一个临时目录下的私有文件store(init_method="file://...")。mp.spawn(worker, nprocs=2)起两个进程,把port、store、case、accelerator传进去。- 每个
worker(rank, ...)先写MASTER_ADDR/PORT/RANK/LOCAL_RANK/WORLD_SIZE环境变量,set_device(cuda:rank),然后dist.init_process_group("nccl", init_method="file://"+store, rank=rank, world_size=2, timeout=90s)。CPU 运行时后端是gloo。 torch.manual_seed(123)固定随机种子,建net = model()和它的ref副本、loss_fn = MSELoss()。
- 与 TODO 代码交互关系:
zero_runtime/zero_updates分支:先reference_adapters(net)用 grader 自己的LoRALinear包住net[0]、net[2](net.requires_grad_(False),所以只有 lora_A/lora_B 可训),ref = deepcopy(net)作为单进程参考。optimizer = SGD(可训练参数)。- 配置来源:
zero_runtime用finetune.build_config(2, 2, 2, "fp32"),即调 TODO 的build_config函数。 engine = deepspeed.initialize(model=net, optimizer=optimizer, config=config, dist_init_required=False)把模型和 SGD 包成DeepSpeedEngine(内部是DeepSpeedZeroOptimizer)。- 训练循环里,
zero_updates每个 microbatch 调 TODO 的train_microbatch(engine, 切片, ...)。
3.2 模型 LoRA 权重替换:apply_lora
首先看 runtime.py 内已经提供的 LoRA 模块实现:
1 | |
其对应公式:
\[ h = W_0 x + b + \frac{\alpha}{r} B A x \] 其中 \(W_0\) 和 \(b\) 表示原始参数(freeze,不允许更新),\(A\) 和 \(B\) 表示可学习的 LoRA 参数。
apply_lora 函数的功能是:传入model
和需要替换的参数名列表 target_names 以及 LoRA 参数
rank 和 alpha,替换 model
内的需要替换的参数名对应的 Module 类型为上述的 LoRALinear
,从而实现模型的低参数量微调。
几个注意点:
- 替换
LinearModule 为LoRALinearModule 方式: 根据 parameter name 先获取 parent module,然后通过setattr设置LoRALinear替换原本模块。 - 冻结参数先用
model.requires_grad_(False)让模型全部参数不可学习,再替换目标 Module 为LoRALinear。
代码实现:
1 | |
3.3 Save and Load Adapters
- Save:提取模型中的 LoRA 相关参数,保存为 flatten dict(name:tensor)。
- Load:加载 checkpoint flatten dict,拷贝从文件加载的 LoRA 参数到模型对应的 parameter。
代码实现如下:
1 | |
4. Conclusion
本次 homework 主要收获点:
- DP:
- 数据集切分注意事项
- PP:
- wave 生成
- forward submit/receive 流程与中间 tensor 保存
- LoRA
- LoRA 模块实现
- LoRA checkpoint 保存与加载