微调提速 6 倍:Qwen 27B 从 7 小时 44 分到 1 小时 17 分
原文:Databricks Blog - 《Reducing Qwen training time by 6x on AI Runtime》(https://www.databricks.com/blog/reducing-qwen-training-time-6x-ai-runtime)
一、起点:GPU 利用率已经 98%
Databricks 这条优化的基线很值得注意:微调 Qwen 3.8 27B、固定数据集,一次训练 7 小时 44 分钟,GPU 平均利用率 98%,吞吐 9,396 tokens/s。任何做过性能优化的人看到 98% 都会先愣一下------利用率这么高,还有什么可挤的?
答案是有的,而且挤出了 6 倍。这也直接说明了第一条经验:GPU 利用率不等于没有浪费,它只告诉你 GPU 在干活,不告诉你它在干什么活。
原文没有给出具体的卡型和卡数,只说明在 27B 这个量级上,纯数据并行放不下权重,所以用 FSDP2 把模型分片到多卡。
二、第一步不是优化,是测量
优化从一次单步 trace 开始,用 torch.profiler 抓一个 step 的 CPU 与 CUDA 活动:
python
with torch.profiler.profile(
activities=[
torch.profiler.ProfilerActivity.CPU,
torch.profiler.ProfilerActivity.CUDA,
],
schedule=torch.profiler.schedule(
wait=1, # 先跳过 1 步,让运行时预热
warmup=0, # 这次单步 trace 不做额外 warmup
active=1, # 只抓 1 个 step
repeat=1
)) as prof:
<training code here>
print(prof.key_averages().table(sort_by="self_cuda_time_total", row_limit=10))
torch.profiler.schedule 这几个参数是配套用的:wait 让前几步跑完预热,active 决定抓几个 step,repeat 决定这套 schedule 重复几轮;sort_by 换成本次关心的 self_cuda_time_total,row_limit 只留前十名,避免一次打印几千行。
trace 给出的结果是这样的:CPU 侧 14.4 秒、1,714,382 次调用;GPU 侧 10.3 秒、269,448 个算子。CPU 的调用次数是 GPU 算子数的六倍多------瓶颈不在算力,在"按 token 逐个调度"这件事本身的固定开销。
三、优化一:给非标准层装对口的内核
第一个改动很小:装上 flash-linear-attention 和 causal-conv1d 两个包。原因是 Qwen 3.8 的某些层不是标准注意力,而是 gated delta network 这类线性注意力结构,通用算子在它们上面效率不高。
装上之后端到端时间从 7 小时 44 分降到 2 小时 5 分,提速 3.7 倍。这一步没有任何算法改动,纯粹是让算子对上硬件。非标准层要配对应的融合内核,这条在任何自定义架构上成立。
四、优化二:把 2D 的 padding 换成 1D 打包
第二个改动看着更不起眼,但收益同样大。原来的数据加载器把样本打包成 2D 矩形张量,每批 padding 到 8192 个 token;padding 出来的 token 也要参与前向和反向,等于白烧算力。
改法是把 2D 矩形张量换成 1D 的打包序列,再传入正确的 position_ids,让每个样本只 attend 到自己内部的 token。padding 本身也在消耗算力,而且这一改动把总批次数减少了大约 36%。
结果:2 小时 5 分降到 1 小时 17 分。两次改动叠起来,就是 7 小时 44 分到 1 小时 17 分,总体 6 倍。
优化后的单步 profiling 也变干净了:CPU 调用从 1,714,382 次降到 118,316 次,GPU 算子从 269,448 个降到 18,060 个,CPU 与 GPU 两边的耗时都降到 3.9 秒。
五、这套配置本身就是性能的一部分
除了上面两步,原文给出的训练骨架也值得抄一遍,因为分片、检查点和精度策略本身就是性能的一部分:
python
# 数据加载器
data_loader = make_dataloader("HuggingFaceH4/ultrachat_200k")
model = AutoModelForImageTextToText.from_pretrained(
"Qwen/Qwen3.8-27B", torch_dtype=torch.bfloat16)
# 梯度检查点,压低显存占用
model.gradient_checkpointing_enable(
gradient_checkpointing_kwargs={"use_reentrant": False})
# FSDP2:逐层分片,再对整体分片
mp = MixedPrecisionPolicy(param_dtype=torch.bfloat16, reduce_dtype=torch.float32)
layers = model.model.language_model.layers
for layer in layers:
fully_shard(layer, mp_policy=mp)
fully_shard(model, mp_policy=mp)
model.cuda()
opt = torch.optim.AdamW(
model.parameters(), lr=1e-5, betas=(0.9, 0.95), weight_decay=0.0)
model.train()
for batch in data_loader:
out = model(input_ids=batch["inputs"], labels=batch["labels"])
out.loss.backward()
opt.step()
opt.zero_grad(set_to_none=True)
几处细节:fully_shard 先逐层调用、再对整个模型调用,这是 FSDP2 的标准顺序,先分层再整体才能让参数、梯度和优化器状态都按同一套分片走。MixedPrecisionPolicy 里参数用 bfloat16、梯度归约用 float32,是为了在省显存的同时不让归约精度拖累收敛。use_reentrant=False 是新一代梯度检查点的写法,前者已不推荐。zero_grad(set_to_none=True) 把梯度置为 None 而不是零,省一次写显存。
六、四条能直接搬走的经验
- 只看 GPU 利用率会骗人:98% 的利用率下仍然有 6 倍空间,必须看时间花在哪;
- 单步 trace 就够:不用跑完整训练,抓一个 step 的 CPU 与 CUDA 活动就能定位大头;
- 自定义结构要主动找融合内核:线性注意力、卷积之类的非标准层,通用算子很容易成为瓶颈;
- padding 是隐性浪费:矩形打包看起来整洁,实际上让模型算了一堆无意义的 token。
七、小结
这次提速没有引入任何新的算法或模型技巧,三件事分别对应"测量、算子、数据管线"。真正有价值的是顺序:先测量再动手,而不是先换框架或调超参。
对做 Agent 的同学来说,这条链路同样有用------给 Agent 配一个自己微调的小模型时,训练时间直接决定迭代速度,而上面这些优化大多不依赖特定平台,本地多卡环境也能照做。具体 API 与推荐配置请以 PyTorch 与 Databricks 官方文档为准,本文未验证最新版本。