在我用昇思 MindSpore 做大模型训练的一年多时间里,被问得最多的两个问题,一个是“你怎么判断训练有没有跑好”,另一个是“为什么我的训练这么慢”。评估体系和性能优化,看起来是两个方向,实际是同一件事的两面:没有可靠的量化方式,优化就是瞎调;没有清晰的瓶颈定位,所有参数都像在碰运气。这篇文章就把我在 MindSpore 上做大模型训练时,从评估指标搭建到性能优化的完整实践梳理一遍,希望对正在折腾大模型训练的你有点实际帮助。
1. 评估体系:先搞清楚“训练得好”到底怎么量化
很多同学一上来就把眼光钉死在训练 loss 上,loss 降了就觉得万事大吉。大模型训练里这个习惯特别危险,因为 loss 下降并不能说明训练效率高、资源利用合理、模型没有在悄悄退化。我习惯把评估体系分成四个维度来做,每个维度各管一件事。
1.1 四个核心评估维度
第一个维度是训练效率,核心指标是 tokens/s 和 step time。tokens/s 衡量整个训练管线每秒能处理多少 token,这是大模型训练真正意义上的“吞吐”;step time 则是每个训练步的墙钟时间,它和 tokens/s 互为表里,看到 step time 突然拉长,第一反应应该是数据或通信出问题了。
第二个维度是收敛质量,也就是大家最熟悉的 loss 和困惑度(PPL)。但我会更关注 loss 曲线的形状,而不是单点数值。正常的大模型训练 loss 曲线应该是平滑下降、偶尔小抖,如果出现周期性的尖峰,往往是学习率策略或数据顺序出了问题,需要单独排查。
第三个维度是资源利用率。我习惯同时盯 GPU/Ascend 算力利用率、显存占用峰值、通信耗时占比这三项。算力利用率低说明计算没吃饱,通信占比高说明并行策略可能需要调整,显存贴着上限跑则随时有 OOM 风险。
第四个维度是稳定性,包括梯度范数、loss 是否出现 NaN、训练进程是否反复中断。大模型训练动辄跑几周,稳定性和前面三个维度同样重要,一条异常梯度就可能毁掉整个 checkpoint。
| 维度 | 核心指标 | 观测手段 |
|---|---|---|
| 训练效率 | tokens/s、step time | 训练日志、TimeMonitor |
| 收敛质量 | loss 曲线、PPL | TensorBoard、定期验证 |
| 资源利用 | 算力利用率、显存、通信占比 | MindSpore Profiler、npu-smi/nvidia-smi |
| 稳定性 | 梯度范数、NaN、中断次数 | 回调函数、日志告警 |
1.2 比 loss 更值得长期盯的 4 个指标
第一个是有效吞吐。只看 tokens/s 还不够,如果 tokens/s 很高但 loss 不降,说明数据被反复喂了没意义的重复序列,有效信息量其实很低。我会额外统计“每个 step 的 loss 下降幅度”,并且用这个幅度去反推数据质量。
第二个是梯度范数。梯度范数突然飙升,往往是学习率过大或数据中出现异常样本的征兆。我在训练脚本里加了一个自定义 Callback,每个 step 结束后把梯度范数打进日志,一旦发现梯度范数超过设定阈值,马上触发梯度裁剪或者降低学习率,而不是等 loss 飞了再手忙脚乱去救。
第三个是 loss 的 EMA 曲线。原始 loss 曲线噪声太大,直接看会让人焦虑。我做了一个滑动平均版本的 loss 曲线,每 50 步记录一次 EMA 值,用它来判断真正的收敛趋势,噪声就被过滤掉了。
第四个是显存占用曲线。大模型训练最怕的就是训练到一半突然 OOM。我每隔固定 step 记录一次显存峰值,并且观察显存是否随着训练时长缓慢增长,如果出现缓慢增长,基本可以断定存在显存碎片泄漏,需要检查 checkpoint 保存逻辑或者动态图算子缓存。
1.3 评估结果如何反哺训练决策
评估体系建好之后,不是拿来看的,是用来做决策的。我在实际训练中经常遇到这样的情况:有效吞吐连续 500 步没有提升,于是把 batch size 调大一倍,loss 曲线立刻有了明显下降。另一次是梯度范数频繁报警,我把 warmup 步数从 1000 拉到 2000,loss 的尖峰立刻减少。
验证集的 PPL 同样关键。我习惯每 1000 个 step 在验证集上算一次 PPL,如果训练 loss 在降、但验证 PPL 在升,就说明模型开始过拟合了,这时候要么增加数据多样性,要么调整正则化策略。把评估结果和训练超参形成闭环,这才叫真正的评估体系,而不是训练结束后才补一张测试报告。
2. 性能画像:动手优化前先定位瓶颈
性能优化最大的误区是一上来就调并行策略、换优化器,结果发现瓶颈根本不在那里。我做性能优化的第一步永远是先做性能画像,把整个训练过程拆开来看,时间到底花在哪里。
2.1 MindSpore Profiler 的正确打开方式
MindSpore 自带 Profiler 工具,可以在训练过程中采集算子耗时、数据处理耗时、通信耗时等关键数据。我这里以 GPU 环境为例,Ascend 环境的方式类似,只是底层采集的数据项名称略有差异。
import mindspore as ms from mindspore import Profiler ms.set_context(mode=ms.GRAPH_MODE, device_target='GPU') profiler = Profiler(output_path='./profiler_data') # 这里是正常的训练流程,跑几十个 step 后停止 # ... # 训练结束时调用 analyse 生成分析结果 profiler.analyse()我使用 Profiler 的经验是:不要从头到尾开着它,因为它本身有性能开销,会影响采集数据的真实性。我通常只在完成一次稳定的训练启动后,额外跑 30~50 个 step 做性能采集,采集完立刻关闭,然后把profiler_data目录下的分析结果导出来看。
这个工具输出的核心信息有三块:一是各算子的平均耗时和调用次数,二是数据管线各环节的耗时占比,三是通信算子的耗时分布。拿到这三块数据,基本可以判断训练慢的原因到底在哪一层。
2.2 四类常见瓶颈的判断清单
把 Profiler 数据拉出来之后,我习惯按下面这张表去对号入座。这里的场景很典型:训练慢,但慢的原因千差万别。
| 表现 | 可能原因 | 优化方向 |
|---|---|---|
| 数据队列经常为空、GPU 算力利用率低 | 数据加载太慢,预处理阻塞 | 加大 num_parallel_workers、使用数据缓存、精简预处理逻辑 |
| 单个算子耗时特别突出 | 计算瓶颈,或算子实现不高效 | 混合精度、算子融合、替换等价高效算子 |
| AllReduce 等通信算子占比高 | 并行策略通信开销过大 | 梯度融合、通信掩蔽、优化并行切分策略 |
| 显存峰值接近上限且偶尔 OOM | 显存容量瓶颈 | 重计算、优化器状态切分、梯度累积、减小 batch |
这里我想特别强调一个容易被忽略的现象:数据加载瓶颈。很多人用 MindSpore 时只注意了num_parallel_workers的大小,却忘了shuffle和map的顺序,导致数据预处理反复执行。数据管线的优化往往不花一分钱,却能带来 10% 到 30% 的训练提速,性价比极高。
2.3 基线数据记录是提速的第一步
每次做优化之前,我都会先记录一份完整的基线数据,包括 batch size、step time、tokens/s、算力利用率、显存峰值、通信占比。为什么这样做?因为性能优化是一个迭代过程,你改了一个参数,效果是好是坏,不能靠感觉,必须和基线对比。
我自己的基线记录表大概长这样:
| 项目 | 数值 |
|---|---|
| 模型规模 | 7B |
| 并行策略 | 数据并行 8 卡 |
| batch size per device | 4 |
| step time | 3.2s |
| tokens/s | 约 4200 |
| 算力利用率 | 68% |
| 显存峰值 | 38.2GB / 40GB |
| 通信耗时占比 | 11% |
记录完之后,每次只改一个变量,跑 50 个 step 再看数据。不要同时改 batch size 和并行策略,否则出了问题你根本不知道是哪个改动造成的。这条经验我踩过很多次坑,后来老老实实遵守,效率反而高了。
3. 大模型训练优化的五个关键动作
定位到瓶颈之后,接下来就是动手优化。我把自己在 MindSpore 上实践过、并且验证有效的优化动作按性价比排序,整理成五个关键动作。
3.1 数据管线:并行度和顺序都要对
数据管线是第一优先级,因为它最容易改、效果最直接。MindSpore 的GeneratorDataset和mindspore.dataset内置算子都支持多线程并行,我用的是类似下面的配置:
import mindspore as ms from mindspore import dataset as ds # source 可以是自定义生成器,也可以从文件读取 data = ds.GeneratorDataset(source, column_names=["input_ids", "attention_mask"]) data = data.shuffle(buffer_size=10000) data = data.map(operations=tokenize_op, num_parallel_workers=8) data = data.batch(batch_size=4, drop_remainder=True)这里有一个我踩过的坑:map里的operations如果包含 Python 自定义函数,多线程并行度提得太高反而会引发 GIL 竞争,导致性能下降。所以我通常先把num_parallel_workers从 4 开始试,用 Profiler 观察数据队列空置率,逐步加到 8、16,找到最合适的值。
另外一个容易出问题的地方是shuffle和map的顺序。如果map里的操作很重,先shuffle再map会让每个线程对同一批数据反复做预处理;反过来先map再shuffle能利用缓存减少预处理次数。对于大模型这种数据量大、预处理逻辑复杂的场景,我基本都采用“先 map 后 shuffle”的顺序。
3.2 混合精度:最划算的提速手段
混合精度几乎是所有大模型训练的标配。MindSpore 里开启混合精度最直接的方式是在构造Model时指定amp_level,也可以用auto_mixed_precision接口对网络做转换。我常用的写法是这样的:
from mindspore import Model from mindspore import amp # 方式一:在 Model 中指定 model = Model(network, loss_fn=loss, optimizer=optimizer, amp_level="O2") # 方式二:先转换网络再训练 network = amp.auto_mixed_precision(network, amp_level="O2")为什么要用 O2 而不是 O0 或 O1?O0 是全精度,基本没有提速效果;O1 是部分算子转半精度,速度提升有限;O2 是尽量多地把算子转成 FP16,只保留一批必须用 FP32 的算子,在大模型场景下收益最明显。
但 O2 不是万能的。我之前在一个 LLaMA 结构的模型上直接套 O2,结果训练中期 loss 开始震荡,排查了半天才发现是 LayerNorm 被转成了 FP16,精度不够导致数值不稳定。后来我把 LayerNorm 和最后的 Softmax 相关部分手动保留在 FP32,问题立刻消失。在 MindSpore 里可以通过回调或者自定义混合精度策略实现这一点,核心思路就一句话:哪些算子必须守住 FP32,要心里有数。
3.3 梯度累积与显存受限下的微批量策略
显存不够的时候,梯度累积是比强行减小 batch size 更聪明的方案。梯度累积的意思是:先把多个微批次的梯度算出来,累积到一起,再统一更新一次参数。它能在不增大显存占用的情况下,模拟更大的有效 batch size。
在 MindSpore 里配置梯度累积,我不同版本用过不同方式。以我目前常用的版本为例,可以在Model中指定gradient_accumulation_steps,也可以在优化器中设置累积步数:
# 有效 batch size = 4 * 8 = 32 model = Model(network, loss_fn=loss, optimizer=optimizer, amp_level="O2", gradient_accumulation_steps=8)使用梯度累积要注意的是:BatchNorm 类算子在累积模式下统计分布会有偏差。好在大多数大模型结构里没有 BatchNorm,基本是 LayerNorm 或 RMSNorm,所以影响不大。另外梯度累积会让参数更新频率变低,收敛轨迹会有些变化,需要同步调整学习率,通常我会在累积步数变大时略微提高学习率。
微批量策略还有一个容易被忽视的细节:微批次大小不是越小越好。每个微批次太小,算子计算效率会断崖式下降,因为固定的 kernel 启动开销摊薄不过去。我一般建议微批次大小不要小于 1,也不要小于模型并行切分后的最小计算单元要求。具体多少合适,还得靠基线对比。
3.4 图模式与算子融合
MindSpore 有 PY Native 和 Graph 两种执行模式。PY Native 模式调试方便,但算子反复调度、Python 开销大;Graph 模式下,MindSpore 会把整个计算图做编译优化,算子调度、内存复用、融合都更好。跑大模型训练,我基本只用图模式:
ms.set_context(mode=ms.GRAPH_MODE, device_target="GPU")图模式带来的提速非常明显,尤其是模型结构复杂、算子多的场景,提速幅度能到 20% 到 40%。但图模式也有代价:第一次编译要花不少时间,改动网络结构后还会触发重新编译。为了解决这个问题,我开启了编译缓存:
ms.set_context(enable_compile_cache=True)编译缓存能把编译产物保存下来,第二次运行同样的网络结构时,直接加载缓存,省掉一大段编译时间。我在一个 7B 模型上,首次编译要花 90 多秒,开缓存之后第二次启动只花不到 20 秒,体验完全不一样。
算子融合这块,MindSpore 在 Graph 模式下会自动做一些融合,比如把连续的矩阵乘和激活函数融合成一个算子。但自动融合并不总是最优,我在实际项目里会手动把一些频繁调用的小算子组合成复合算子,减少 kernel 启动次数。比如把Linear + GELU写成一个自定义 Cell,既方便复用,也方便框架做融合优化。
3.5 分布式并行策略:从数据并行到流水线并行
分布式并行是大模型训练绕不开的话题。很多人一听模型有 10B 参数,就觉得必须上模型并行。其实先把数据并行做到极致,往往能解决大部分问题。
数据并行是最简单的并行策略,每张卡持有完整模型副本,只同步梯度。MindSpore 里配置数据并行非常直接:
ms.set_auto_parallel_context(parallel_mode=ms.ParallelMode.DATA_PARALLEL, gradients_mean=True)数据并行的主要开销在通信:每步都要做梯度 AllReduce。通信优化的关键是梯度融合,把大量小的梯度张量合并成几个大的梯度张量再通信,能明显降低通信次数。MindSpore 的优化器里通常有这个优化开关,或通过配置项控制,开启后通信耗时占比能从 15% 降到 8% 左右。
当模型大到单卡装不下的时候,就必须考虑算子级并行或流水线并行。我的经验是:先看显存瓶颈出在参数、梯度还是优化器状态,再决定切分策略。比如参数 7B、FP16 存储,光参数就要 14GB,再加上梯度和优化器状态,4 张卡未必够。这时候可以把 Embedding、Attention 的权重矩阵做行列切分,实现算子级并行。流水分线并行则是把网络按层切成多个 stage,每个 stage 放在不同设备上,设备间通过 pipeline 方式传递中间激活值,适合层数特别深的模型。这两种并行方式在 MindSpore 里都有相应配置,但复杂度比数据并行高不少,建议先在数据并行上把性能榨干,再考虑升级。
4. 实战案例:7B 模型在 8 卡环境上的优化全程
理论讲再多,不如看一个完整的优化过程。这里我用自己的一个 7B 规模 GPT 风格模型为例,训练环境是 8 张 40GB 显卡,任务是在约 300B token 的中文语料上做继续预训练。
4.1 初始状态与基线数据
这个模型刚搭建起来的时候,配置是数据并行 8 卡,每卡 batch size 4,FP32 全精度,PY Native 模式已经换成了 Graph 模式。初始跑起来的数据让我非常不满意:
| 指标 | 初始值 |
|---|---|
| step time | 3.2s |
| tokens/s | 约 4200 |
| 算力利用率 | 68% |
| 显存峰值 | 38.2GB / 40GB |
| 通信耗时占比 | 11% |
| loss(1000 步后) | 2.18 |
显存离上限只剩 1.8GB,算力利用率只有 68%,说明 GPU 有大把时间在等待,不是计算打满的状态。先用 Profiler 做了 40 个 step 的性能画像,发现数据队列偶发空置,同时通信占比有 11%,整体优化空间集中在数据、精度和通信三个方向。
4.2 优化步骤与效果对比
我按“先改便宜的后改贵的”原则,分步做了五轮优化,每一轮都只改一个变量。
第一轮优化数据管线。把num_parallel_workers从 4 调到 8,同时调整了shuffle和map的顺序,让预处理结果可以被缓存复用。跑 50 个 step 后,数据队列空置率明显下降,step time 从 3.2s 微降到 3.1s。效果不大,但说明数据管线已经不是主要瓶颈了,这一步主要是排除干扰项。
第二轮开混合精度 O2。把model的amp_level改成 "O2",同时对 LayerNorm 做了 FP32 保护。这一步效果极其显著,step time 从 3.1s 直接降到 2.2s,tokens/s 提升到 6000 左右。算力利用率也升到了 78%。混合精度果然是大模型训练最划算的提速手段。
第三轮优化通信。开启梯度融合,把大量小的梯度合并通信,并配置了通信掩蔽,也就是在当前微批次计算的时候,同时进行上一步的梯度通信,让通信和计算重叠。step time 从 2.2s 降到 1.8s,通信占比从 11% 降到 6%。
第四轮调整梯度累积。我之前是纯数据并行,没有用梯度累积。为了进一步模拟更大的有效 batch,我用gradient_accumulation_steps=4,同时把每卡 batch size 微调为 2,这样有效 batch size 不变(2 × 4 × 8 = 64),但显存峰值降下来了,还剩下不少余量。这轮优化后显存占用从 38.2GB 降到 31.5GB。
第五轮把省下来的显存换成更大的 batch。每卡 batch size 从 2 调回 4,同时保持梯度累积 4 步,有效 batch size 达到 128。虽然梯度累积数变了之后单 step 微批次计算量加大,但吞吐整体仍然在涨,最终 step time 稳定在 1.9s 左右,tokens/s 达到约 7800。
五轮优化后的最终数据对比:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| step time | 3.2s | 1.9s |
| tokens/s | 约 4200 | 约 7800 |
| 算力利用率 | 68% | 85% |
| 显存峰值 | 38.2GB / 40GB | 35.6GB / 40GB |
| 通信耗时占比 | 11% | 6% |
| loss(1000 步后) | 2.18 | 2.21 |
loss 数值有轻微差异是因为有效 batch size 变大、优化轨迹变了,并不是模型变差,看趋势到 2000 步时已经追上并反超了。整个优化过程大概花了两天,换来的是接近 86% 的吞吐提升,这个投入产出比我觉得非常值。
4.3 关键代码梳理
把优化后训练脚本里的几个关键片段整理出来,方便你对照自己的配置。训练入口的 context 配置:
import mindspore as ms from mindspore import Model ms.set_context(mode=ms.GRAPH_MODE, device_target="GPU") ms.set_context(enable_compile_cache=True) ms.set_auto_parallel_context(parallel_mode=ms.ParallelMode.DATA_PARALLEL, gradients_mean=True)训练时用到的自定义 Callback,用来记录 step time 和梯度范数:
import time from mindspore.train import Callback class TrainMonitor(Callback): def __init__(self, log_every=50): super().__init__() self.log_every = log_every self.step_start_time = None def step_begin(self, run_context): self.step_start_time = time.time() def step_end(self, run_context): if self.step_start_time is None: return cost = time.time() - self.step_start_time cb_params = run_context.original_args() cur_step = cb_params.cur_step_num loss = cb_params.net_outputs if cur_step % self.log_every == 0: print(f"step {cur_step}, loss {loss:.4f}, " f"time {cost:.3f}s, tokens {tokens_per_step / cost:.0f}/s")其中tokens_per_step是每步处理的 token 总数,在实际代码里根据 batch size 和序列长度提前算好即可。
验证集 PPL 的计算,我单独写了一个脚本,用一个简单的函数完成核心逻辑:
import mindspore as ms from mindspore import ops def compute_ppl(logits, labels): # logits: [batch, seq_len, vocab] # labels: [batch, seq_len] shift_logits = logits[:, :-1, :] shift_labels = labels[:, 1:] ce_loss = ops.cross_entropy( shift_logits.reshape(-1, shift_logits.shape[-1]), shift_labels.reshape(-1) ) return ops.exp(ce_loss.mean()).asnumpy().item()PPL 我一般每 1000 步算一次,和训练 loss 放在一起看,避免出现训得越快越偏的情况。
5. 常见问题与排查技巧实录
在实际训练过程中,我遇到过不少让人抓狂的诡异问题。这里整理成速查表,再分享几个独家的避坑经验。
5.1 高频问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| loss 不降,甚至缓慢上升 | 学习率过大、数据顺序异常、混合精度数值不稳 | 看梯度范数曲线,确认是否频繁触发裁剪,检查 FP32 保护算子 |
| step time 周期性飙升 | 数据队列周期性空置,或 checkpoint 保存与训练重叠 | 用 Profiler 看数据加载耗时变化,把 checkpoint 保存移到异步线程 |
| GPU 利用率只有 50% 左右 | 数据加载慢、通信等待、算子串行 | 先看 Profiler 的 AI Core 空闲率,再分别查数据和通信 |
| 训练中途 OOM | 显存碎片、batch 过大、激活值缓存过多 | 减小 batch、开启重计算、优化器切分、检查是否有张量泄漏 |
| 多卡训练 loss 不一致 | 数据并行中每个卡数据分布不均,或梯度同步失效 | 检查每个 step 的 loss 打印是否来自 rank 0,必须统一从主卡日志观察 |
| 图模式编译慢 | 编译缓存未开启或网络结构频繁改动 | 开启 enable_compile_cache,适当冻结不常变化的子图 |
| 梯度范数突然爆掉 | 学习率步长跨度过大、数据异常样本 | 设置梯度裁剪,同时把学习率 warmup 步数拉长 |
5.2 三个独家避坑经验
第一个经验:不要盲目加大 batch size。吞吐并不总是随着 batch 增大而线性上升,当 batch 大到一定程度,算子计算效率进入平台期,反而可能因为显存压力导致重计算频率上升,吞吐掉头向下。我每次调整 batch 后都会用 Profiler 看一次算子效率,只有算力利用率同步提升,这个调整才真正有效。
第二个经验:多卡训练时,日志输出和 loss 监控必须做收敛。在多卡环境下,每个 rank 都会打印自己的 loss,这些数值在数据并行下通常会接近,但不完全一样。如果打印顺序混乱,很容易误判为训练不稳定。我的做法是在自定义 Callback 里判断当前是不是 rank 0,只在主卡打印统计信息,其他 rank 全部静默。
第三个经验:性能优化一定要留足“回滚点”。每完成一轮有效优化,我都会保存一份当时的训练脚本和超参配置,标注好当时的 step time 和显存数据。这样如果后续优化反而让性能下降,可以快速回滚到上一个稳定状态,而不是靠记忆重写配置。这个习惯帮我节省了大量试错时间。
最后聊一点我自己的体会:评估体系和性能优化这套东西,单独拿出来看都很简单,难的是把它们结合到日常训练流程里,形成稳定的习惯。每改动一个参数前先问自己“我要解决什么问题,如何量化效果”,这比任何优化技巧都重要。希望这篇实践梳理能帮你少走一些弯路。