Databricks:如何让 Qwen 3.8 27B 微调快 6 倍
推荐理由性能剖析揭示高 GPU 利用率仍可能掩盖 CPU 调度开销。训练团队可学习先定位瓶颈,再比较融合内核与批次打包的收益和正确性代价。
读前速览
Databricks 分享如何通过融合内核与一维打包批处理,在 GPU 利用率已达 98% 的情况下,将 Qwen 3.8 27B 微调耗时从 7 小时 44 分压缩到 1 小时 17 分,提速 6 倍。
GPU 利用率 98% 具有误导性,不能反映 GPU 被使用的充分程度
标准利用率只统计 GPU 处于工作状态的时间占比。作者用 torch profiler 追踪单步发现,CPU 侧准备时间(14.4 秒)反而长于 GPU 执行时间(10.3 秒),说明 GPU 在等待 CPU 投递算子,仍有优化空间。
手动融合内核是本次提速的最大来源,带来 3.7 倍加速
Qwen 的门控 Delta 网络层产生大量小算子,单步批量矩阵乘法内核运行超 3 万次。安装 flash-linear-attention 与 causal-conv1d 后,算子总数降至约十五分之一,训练从 7 小时 44 分降至 2 小时 5 分。作者指出这类成熟融合方案是 torch.compile 无法自行发现的。
一维打包批处理消除 padding 浪费,再获 1.6 倍提速
默认二维矩形张量需补齐到批次最长样本,浪费显存与算力。改为一维打包张量并传入正确的 position_ids 后,批次数减少约 36%,耗时从 2 小时 5 分降至 1 小时 17 分。代价是输入流水线更复杂,容易引入影响模型质量的错误。
优化方案存在明确取舍,不能无脑套用
CUDA graphs 虽能减少 CPU 提交次数,但与 FSDP2 的动态行为冲突;torch.compile 对已手动融合的大模型算子没有进一步优化空间;按长度排序减少 padding 可能让相邻样本产生意外相关性,导致模型偏差。
平台化封装可让高性能路径成为默认选择
作者介绍 AI Runtime 已内置最新 CUDA 与 Flash Attention、自动跟踪 GPU 利用率与温度、保持 torch 与性能分析工具更新,并有内部 Agent Genie Code 寻找优化机会,UCVolumeDataset 优化数据读取。
完整摘要
Databricks 工程师 Bruce Fontaine 与 Pardis Beikzadeh 撰文复盘 Qwen 3.8 27B 微调提速 6 倍的过程。基线训练使用 transformers、FSDP2 与激活检查点,在 ultrachat_200k 上耗时 7 小时 44 分钟,GPU 平均利用率显示 98%,但 torch profiler 显示 CPU 每步准备时间超过 GPU 执行时间,且单步存在数十万个小算子。作者据此采取两项优化:一是安装 flash-linear-attention 与 causal-conv1d 手动融合内核,替代门控 Delta 网络层中的大量逐元素操作,算子总数降至约十五分之一,训练提速 3.7 倍至 2 小时 5 分;二是将二维补齐批次改为一维打包张量并传入正确的 position_ids,使批次数减少约 36%,耗时进一步降至 1 小时 17 分。文章也说明了 CUDA graphs 与 FSDP2 冲突、torch.compile 对成熟融合内核无额外收益、打包易引入质量错误等取舍,并介绍了 AI Runtime 内置的相关优化。
目录 · 7 节
GPU 平均利用率已经达到98%,训练却还能快6倍。Databricks 这篇博文写下了排查过程、算子变化和数据打包细节。以下为全文翻译,文中的“我们”指原作者 Bruce Fontaine 和 Pardis Beikzadeh。

让大模型训练发挥最佳性能的两个简单方法。
训练或微调一个数十亿参数的基础模型,需要大量计算资源,往往要耗费数百乃至数千个 GPU 小时。但优化大模型的训练性能并不容易,GPU 利用率这样的高层指标,也未必能反映 GPU 实际用得有多充分。本文将介绍如何穿透这些抽象层,在 AI Runtime 上将最新 Qwen 3.8 27B 模型的微调提速6倍。通过系统性的性能优化,即使原来的 GPU 利用率已经达到98%,我们仍将固定数据集上的微调耗时从7小时44分钟缩短到了1小时17分钟。
我们会深入介绍这些改进背后的方法:应该测量什么、如何实现,以及每项优化涉及哪些设计取舍。
如果你想马上动手,可以直接查看 AI Runtime 上的示例代码,自己试一试!
模型设置
我们从一个标准训练循环开始,使用 transformers 库和监督微调数据集 HuggingFaceH4/ultrachat_200k,训练 Qwen 3.8 27B。
# Create the Dataloader
data_loader = make_dataloader("HuggingFaceH4/ultrachat_200k")
model = AutoModelForImageTextToText.from_pretrained(
"Qwen/Qwen3.8-27B", torch_dtype=torch.bfloat16)
# Enabled gradient checkpointing to reduce memory requirements
model.gradient_checkpointing_enable(
gradient_checkpointing_kwargs={"use_reentrant": False})
# Apply 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)
-
我们使用了 FSDP2,这是 torch 最新的模型分片技术。要让模型权重和优化器状态装进 GPU,就需要这种技术。在这个模型规模下,单纯使用分布式数据并行,无法把模型权重放进单张 GPU。
-
我们还开启了激活检查点。这种技术会在反向传播时用额外计算换取内存空间,从而增大每张 GPU 可以承载的 batch size。
-
数据加载器将数据组成二维批次,每批最大 token 数为8192,并将批内样本补齐到最长样本的长度。
初始训练基准
基线模型在完整数据集上训练需要7小时44分钟,训练期间 GPU 的平均利用率显示为98%。训练吞吐量为每秒9,396个 token。
这个 GPU 利用率看起来很高,但正如前面提到的,它具有误导性。标准的 GPU 利用率指标,只统计 GPU 处于工作状态的时间占比,因此无法告诉我们:GPU 活跃时,模型究竟把它用得有多充分。要弄清楚这一点,需要继续往下看。
一种办法是使用 torch 的追踪机制。我们可以像下面这样调整训练循环,同时限制步数,以便尽快得到结果:
with torch.profiler.profile(
activities=[
torch.profiler.ProfilerActivity.CPU,
torch.profiler.ProfilerActivity.CUDA, # Remove if running only on CPU
],
schedule=torch.profiler.schedule(
wait=1, # Step 1: Skip tracing to let things warm up
warmup=0, # Skip warmup for this specific single-step trace
active=1, # Step 2: Trace exactly 1 step
repeat=1
)) as prof:
<training code here>
print(prof.key_averages().table(sort_by="self_cuda_time_total", row_limit=10))
以某一个训练步为例,可以得到下面这些数据:
|
操作 |
调用次数 |
平均耗时(微秒) |
总耗时(毫秒) |
|---|---|---|---|
|
aten::bmm |
31,105 |
50.5 |
1,571.80 |
|
aten::mul |
46,878 |
16.2 |
758.9 |
|
MmBackward0 |
497 |
1,111.4 |
552.4 |
|
aten::copy_ |
69,444 |
7.2 |
500.6 |
|
aten::add |
45,623 |
8.0 |
366.7 |
|
aten::select |
68,645 |
3.4 |
230.5 |
|
aten::exp |
9,504 |
21.7 |
206.5 |
|
aten::sum |
16,194 |
12.2 |
196.8 |
|
aten::empty |
40,709 |
4.7 |
190.5 |
|
aten::as_strided |
218,214 |
0.8 |
173.3 |
表1:CPU,总耗时14.4秒,共1,714,382次调用。表中列出了按总耗时排序的主要操作。
|
操作 |
调用次数 |
平均耗时(微秒) |
总耗时(毫秒) |
|---|---|---|---|
|
nvjet GEMM |
1923 |
1,163.1 |
2,236.8 |
|
逐元素浮点加法 |
42,657 |
51.1 |
2,180.5 |
|
逐元素浮点乘法 |
35,012 |
24 |
841.8 |
|
逐元素浮点填充 |
33,557 |
21.5 |
723.1 |
|
逐元素浮点复制 |
59,282 |
11.8 |
700.7 |
|
Memcpy DtoD |
5,585 |
90.5 |
505.2 |
|
sm80_xmma GEMM |
31,105 |
20.9 |
651.3 |
表2:GPU,总耗时10.3秒,共269,448个操作。表中列出了按总耗时排序的主要操作。
可以改进的地方
从这些表格中,可以发现当前模型存在几个整体性问题:
-
CPU 为每个训练步做准备的时间,比 GPU 执行它的时间还长。这不太正常,因为 GPU 执行时,CPU 侧的操作通常是异步的,也就是说,CPU 应当远远跑在 GPU 前面。这里的主要影响是 GPU 会停下来等待,因此还有改进空间。
-
操作太多了!上面的表格里有数十万个小操作。这里的问题在于,CPU 无法以足够快的速度将 GPU 操作加入队列,让 GPU 持续保持忙碌。关键思路是减少 CPU 向 GPU 提交的操作数量。有几种通用方法可以考虑,其中一种适合我们这次的情况:
- torch 的 CUDA graphs 可以记录执行过的 CUDA 内核,再通过一次 CPU 调用重放它们。它很强大,但也有不少限制,而且与 FSDP2 的动态行为存在冲突,所以我们暂时不采用。
- torch.compile 会追踪代码,并在可能的地方使用 Triton 自动融合内核。本例中我们将使用手动融合内核;对于这些大模型操作,手动融合后就没有给 torch.compile 留下进一步优化的空间。
- 手动融合内核针对特定操作使用专门的融合技术,这些技术是 torch 编译器无法自行应用的。好在社区里已经有不少成熟的预构建内核,例如 Flash Attention。它在本例中带来了一点性能提升。
此外,批次中还有 padding 过多的问题:输入张量必须是矩形,每一行都要补齐到该批次的最大 token 数。接下来我们会介绍一种适用于大模型的技巧,尽量减少这部分浪费。
下面将逐一深入这些技术,介绍它们是什么、推荐的实践方式,以及应用时需要考虑的主要取舍。
融合内核
你可能注意到了,有些内核出现的次数特别多。以 Qwen 为例,批量矩阵乘法内核在一个训练步中就运行了超过30,000次。
我们先研究融合内核。融合内核会把多个较小的 GPU 内核合成一个执行单元。这样可以避免 GPU 高带宽显存与带宽更高的片上存储之间代价高昂的数据搬运,从而提高性能;同时,也能让 GPU 核心的调度更加高效。
面对这么多额外内核,即使 torch.compile 能起作用,运行它也要花很长时间。因此,先用手动融合内核减少操作总数,是一个不错的起点。而且,Flash Attention 这类成熟的融合方案,也不是 torch.compile 自己能够发现的。
对于标准注意力层,transformers 库默认使用 torch 的缩放点积注意力(SDPA)。不过,我们看到的许多小操作,其实来自非标准的门控 Delta 网络层。幸运的是,针对这些层,已经有 causal-conv1d 和 flash-linear-attention 两种内核,可以融合每一层里的许多操作。我们是怎么发现它们的?transformers 库中 Qwen 的实现,已经写明了支持哪些内核:
-
flash-linear-attention
-
causal-conv1d
获取这些内核,主要有两种方式:
-
自行安装。具体方式包括:
- 直接通过 pip 从 PyPI 安装,例如 flash-linear-attention。 - 使用 git clone 克隆对应仓库,然后编译。
- 如果编译耗时太长,也可以寻找其他提供预编译包的来源。
-
使用 Hugging Face 的 Kernel Hub 获取预编译内核。
我们通常推荐第一种方式,尤其是结合我们的 Docker 容器支持:在构建容器时就装好所有内核,以减少训练任务的启动时间。
Kernel Hub 虽然方便,但内核往往明确依赖所安装的 torch 和 CUDA 版本,因此未必有适配你当前配置的可用内核。这时,transformers 模型会静默回退到未经优化、性能较低的内核。
安装 flash-linear-attention 和 causal-conv1d 两个 Python 包之后,我们的端到端训练提速了3.7倍,耗时降至2小时5分钟。下表中高亮标出的新内核,替代了前面表格里的逐元素函数。执行的操作总数也大幅下降,减少至原来的约十五分之一:
|
操作 |
调用次数 |
平均耗时(微秒) |
总耗时(毫秒) |
|---|---|---|---|
|
cudaMemsetAsync |
1,854 |
283.9 |
526.3 |
| ChunkGatedDeltaRuleFunction | 96 | 871.7 | 83.7 |
|
aten::mm |
1,987 |
39.6 |
78.6 |
|
MmBackward0 |
497 |
134.2 |
66.7 |
| ChunkGatedDeltaRuleFunctionBackward | 48 | 1,159.9 | 55.7 |
| DaoAILab::_causal_conv1d_fwd_cpp | 96 | 55.8 | 5.4 |
| CausalConv1dFn | 96 | 51.4 | 4.9 |
表3:CPU,总耗时3.9秒,共118,316次调用。表中列出了按总耗时排序的主要操作。
|
操作 |
调用次数 |
平均耗时(微秒) |
总耗时(毫秒) |
|---|---|---|---|
|
nvjet GEMM |
1,923 |
1,183.3 |
2,275.5 |
|
逐元素浮点乘法 |
2184 |
128.1 |
279.9 |
|
逐元素复制与类型转换 |
1255 |
101.1 |
126.9 |
| chunk_cat flash-linear-attention | 65 | 1,402.0 | 91.2 |
|
flash_attention_3 forwards |
16 |
2,693.8 |
43.1 |
| chunk_gated_delta_rule_fwd | 144 | 255.6 | 36.8 |
|
flash_attention_3 backwards |
32 |
965.6 |
30.9 |
表4:GPU,总耗时3.9秒,共18,060次调用。表中列出了按总耗时排序的主要操作。
注意:训练时,前向和反向传播都需要对应内核。有些内核只支持前向计算,因为它们的实现仅用于加速推理。
要点
-
可以考虑使用常见模型已有的成熟、高度优化的内核。借助这些现成内核,通常只需较少工作,就能获得显著的性能提升。此次训练速度提升了3.7倍,训练耗时从7小时44分钟降至2小时5分钟。
批处理、分词与填充
现在 Qwen 模型已经相当快了,但我们还能再快一些。默认的批处理方式采用二维矩形张量,长度为8,192个 token,因为这是不发生显存不足时能够容纳的最大长度。缺点是,padding 往往会浪费张量中相当多的空间。
如果 Qwen 只使用标准注意力机制,torch 现在已经提供了较完善的 NestedTensor 支持,可以完全移除 padding,从而降低这部分开销。但 flash-linear-attention 只支持更传统的一维打包张量方式:

左侧是补齐后的二维批次 右侧是一维打包张量及边界偏移量
不过,我们不能随意这样做。预训练时,直接把一维张量交给模型或许可以接受;但微调时,必须确保每个样本中的 token 只关注同一个样本内的 token。为此,需要将正确的样本 position_ids 传给模型。模型再把这些信息传给融合注意力内核,由内核应用正确的注意力掩码。在示例代码中,可以看到我们如何计算所需输入,让这套方式正常工作。
经过这个看起来很简单的改动,训练耗时从2小时5分钟进一步缩短到了1小时17分钟。
原因有两个:
-
主要原因是,这种打包方式增加了每个批次中的序列数量,使总批次数减少了约36%。需要注意,这个比例取决于数据集中样本的长度及其分布。
-
次要原因是,Flash Linear Attention 内核需要针对每一种输入形状进行即时编译。此前输入形状是动态变化的,因此额外增加了几分钟编译时间。
取舍在于:这种改动会让输入流水线更加复杂,而且很容易引入影响模型质量的错误。但在本例中,节省36%的批次值得这样做。还有其他减少 padding 的办法,例如保留原来的批处理方式,先按长度对所有输入排序,尽量减少填充。不过,这种排序可能让相邻样本之间出现意料之外的相关性,进而使模型产生偏差。
要点:当训练数据集中的输入短于模型可训练的最大 token 长度时,例如本例中的8192,高效地将数据打包成批次,可以节省大量时间。在这个数据集上,我们获得了1.6倍的训练提速,耗时从2小时5分钟降至1小时17分钟。
结论
本文介绍了如何将训练效率提升至6倍。归结起来有三点:(1)先测量真正重要的东西,不只看 GPU 利用率,还要看训练时间究竟花在了哪里;(2)借助 Flash Linear Attention 等成熟库优化并融合内核;(3)针对模型调整数据批次的输入方式。
要规模化地做到这些,需要一个能把高性能路径变成默认选择的平台。AI Runtime(AIR)已经内置了其中许多改进:基础环境打包了最新的 CUDA 和 Flash Attention;自动跟踪 GPU 利用率、温度及健康状况等核心指标;确保 torch 和性能分析工具保持更新。我们的内部 Agent——Genie Code,也专门调优过,会寻找这类机会,进一步优化训练。此外,UCVolumeDataset 还优化了从 Databricks 读取数据的过程。
欢迎在下一次训练任务中试试 AIR!想开始使用,可以查看 AI Runtime cookbook,其中详细介绍了如何自行运行这个模型。
参考链接
-
原文:https://www.databricks.com/blog/reducing-qwen-training-time-6x-ai-runtime
-
UltraChat 200k 数据集:https://huggingface.co/datasets/HuggingFaceH4/ultrachat_200k
-
FSDP2 文档:https://docs.pytorch.org/tutorials/intermediate/FSDP_tutorial.html
-
CUDA graphs:https://pytorch.org/blog/accelerating-pytorch-with-cuda-graphs/
-
torch.compile 文档:https://docs.pytorch.org/docs/2.14/user_guide/torch_compiler/compile/programming_model.html
-
Flash Attention 3:https://pytorch.org/blog/flashattention-3/
-
缩放点积注意力文档:https://docs.pytorch.org/docs/2.14/generated/torch.nn.functional.scaled_dot_product_attention.html
-
transformers 中的 Qwen 实现:https://github.com/huggingface/transformers/blob/a10365f95226614b2e9b9501e24bba36088c6b4f/src/transformers/models/qwen3_5/modular_qwen3_5.py
-
Flash Linear Attention:https://github.com/fla-org/flash-linear-attention
-
causal-conv1d:https://github.com/Dao-AILab/causal-conv1d
-
AI Runtime Docker 容器文档:https://docs.databricks.com/aws/en/machine-learning/ai-runtime/cli/docker-images
-
NestedTensor 文档:https://docs.pytorch.org/docs/2.14/nested.html
-
Databricks 数据加载优化:https://www.databricks.com/blog/fast-fault-tolerant-pytorch-training-ai-runtime