最近在一套国产 NPU 集群上把VeRL + Qwen3-30B-A3B + DAPO这套组合从头到尾完整跑通了,包括环境适配、训练调参、断点恢复和一堆莫名其妙的坑。过程中踩了不少雷,也从源码层面翻了不少东西。这篇就当是给后来人的一份实践笔记,尽量把关键节点、参数依据和排障思路都写清楚。
这套组合的核心诉求很简单:在非 CUDA 生态的 NPU 上,跑通一个足够大的 MoE 模型 + 分组策略优化的强化学习流程。Qwen3-30B-A3B 是当前拿来验证 NPU 大模型 RL 能力很合适的样本,因为它的总参数量不大不小,推理时激活量却很低,正适合验证 NPU 的算子覆盖度、通信效率和显存调度能力。DAPO 则是参数量在 GRPO 基础上的改进变体,主要面向奖励信号噪声大、稀疏度高的场景,对训练稳定性更友好。
如果你手里正好也有一批 NPU 资源,或者正准备评估国产算力能不能承接强化学习训练任务,这篇文章应该能帮你少走很多弯路。
1. 项目背景:为什么把这三样组合到一块
1.1 模型侧选择:MoE 结构在 RL 训练中的实战价值
Qwen3-30B-A3B 从模型架构上说是一个典型 MoE 模型:总参数量约 30B,但每次推理只需要激活其中约 3B 参数。这里说的 3B 激活量,在训练时带来了一个非常直接的好处——同样的 8 卡或 16 卡集群上,可以塞进更大的 batch,回放 buffer 能放更多交互样本,KL 系数和奖励归一化也更不容易因为采样量太少而抖动。
但 MoE 在训练时也有代价。强化学习阶段不像预训练那样只做前向,而是在 rollout 阶段频繁做生成采样。MoE 模型的 expert 选择是动态的,不同 token 会路由到不同专家上,这意味着在连续生成过程中,NPU 上的算子执行序列是不完全固定的。这个特点对硬件调度是考验,对框架的图兼容层同样如此。如果你的 NPU 平台只有一套静态图编译路径,大概率会遇到形状推断失败或内存复用失效的问题。
另外,Qwen3 系列的 attention 部分是标准 GQA,配合 128 维的头维度,在算子上属于亲测兼容性较好的形态。选它还有一个理由是权重量化支持做得比较好,后面在 NPU 上做 FP8/BF16 混精加载时,少了不少逐层对齐的麻烦。
1.2 算法侧选择:DAPO 更适合这类奖励稀疏任务
DAPO 全称在不同实现里略有差异,核心思路是在 GRPO 的基础上做两点改进:一是对组内优势值做更激进的裁剪约束,二是把 response 长度纳入到优化目标里,避免模型通过无限加长回答来钻奖励模型的空子。
为什么这类算法在 NPU 实践里很重要?因为 NPU 的生态相比 CUDA 平台还年轻,稳定可用的分布式通信库、集合通信算子、自动混合精度策略都还在不断磨合中。如果算法本身在训练过程中需要频繁做熵正则衰减、价值网络预训练、优势值归一化这些操作,每一步都会放大框架与硬件之间的兼容性风险。DAPO 作为 policy-based 方法,不依赖 critic 网络,整个训练链路里只有 actor、ref、reward model 三套模型,省掉了一套价值网络后,显存占用和通信开销都能降一个量级,对 NPU 这种显存管理策略还不够成熟的平台来说非常友好。
我实验时用的是自己基于某个开源库魔改的实现,把 DAPO 的损失函数拆成了 policy loss + length balancing penalty 两个部分,后面会展开讲具体配置。
1.3 硬件侧选择:NPU 能承载大模型 RL 吗
这个问题是我决定写这篇文章的核心原因。很多人默认 NPU 只适合做推理,不适合做训练,更不适合做 RL。但实际跑下来,结论并没有那么悲观。NPU 在稠密矩阵乘、卷积类算子上表现并不差,真正拉开差距的是小算子执行效率、动态 shape 场景下的调度开销,以及多机通信带宽的稳定性。
这套实践里我用的单机多卡 NPU,在 16 卡规模下做 30B MoE 的 rollout + 学习,吞吐虽然比同级别主流 GPU 低一些,但并非不可用。瓶颈主要集中在 rollout 阶段的生成效率和 learn 阶段的多机 allreduce 延迟。只要把并行策略、日志同步频率这些细节调好,整体训练速度完全可以接受。
所以我的态度是:如果你评估一个项目能不能上 NPU,不要只看理论算力,而要看框架、模型、算法三者的算子交集在不在芯片的覆盖范围内。只要覆盖,剩下的只是优化问题。
2. 环境准备与安装适配
2.1 最小化安装清单
先说环境版本,这是所有坑的源头。一张表列清楚:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| 操作系统 | Ubuntu 22.04 64位 | 兼容性最稳,内核不用自己编 |
| Python | 3.10 或 3.11 | 不能太低,torch 和框架依赖容易冲突 |
| PyTorch | 2.1.0 及以上 | NPU 适配层一般跟随 torch 版本发布 |
| NPU 驱动/固件 | 随硬件出厂更新的最新稳定版 | 建议问供应商要版本对照表 |
| npu 扩展包 | 与 torch 严格对应 | 不能用错版本,否则 import 即崩溃 |
| HCCL(集合通信库) | 与驱动配套 | 多卡通信依赖它,版本旧容易 hang |
| VeRL | 源码编译最新 main 分支 | 官方 release 可能滞后,部分算子要手改 |
这里有一个很关键的认知:NPU 上的 PyTorch 其实不是原版 PyTorch,而是一个 fork 出来加了硬件适配层的版本。所以安装时先装官方 torch 再装适配包是行不通的,必须严格按照适配层的安装指引来,通常是一个对应 torch 版本的轮子文件,直接 pip 装进 Python 环境里。
我的习惯是先建一个全新的虚拟环境,再依次装:基础 torch → npu 扩展包 → 由 VeRL 依赖倒推的 tokenizers/transformers 等 → 最后源码编译 VeRL 本体。
2.2 VeRL 源码编译与 NPU 适配改动点
VeRL 官方仓库默认支持主流的 CUDA 平台。要在 NPU 上跑,绝大多数情况下不能直接 pip install,而是要把仓库拉下来改两处地方再编译:
第一处是设备判断逻辑。源码里散落着不少torch.cuda.is_available()或device = "cuda"的硬编码。我用了一个比较稳的土办法:在项目入口文件开头统一定义:
import torch import torch_npu device = "npu" if torch.npu.is_available() else "cuda" if torch.cuda.is_available() else "cpu"然后把所有涉及torch.cuda的调用替换成torch.npu,或直接通过一个全局 device 变量透传。VeRL 的代码组织比较规整,大部分设备相关逻辑集中在 trainer 和 rollout worker 两个模块里,手工改一遍大概不到半小时。
第二处是集合通信后端的指定。NPU 上初始化分布式进程组时,后端要设置为"hccl"而不是"nccl":
import torch.distributed as dist dist.init_process_group(backend="hccl", init_method="env://")这一步漏掉的话,后续启动训练会直接在通信初始化阶段报backend not found或者无法识别设备类型。我在第一次跑的时候就踩了这个坑,日志只打了一行初始化信息就挂住,排查了很久才发现是后端写死的问题。
编译的时候注意一点:VeRL 里有几个自定义 C++ 算子(如抽样相关的 kernel),编译时需要确保 NPU 的编译器工具链在PATH里。如果编译报找不到头文件,多半是 HCCL 的 include 路径没加进环境变量,在setup.py里临时补一下即可。
2.3 环境冒烟测试
环境配好后不要急着跑大模型,先做两个最小测试。第一个是验证 torch 能正常调用 NPU:
import torch import torch_npu assert torch.npu.is_available() a = torch.randn(8, 8, device="npu") b = torch.matmul(a, a) print(b.sum().item())第二个是验证集合通信。起两个 Python 进程,分别跑:
import os import torch import torch.distributed as dist import torch_npu dist.init_process_group(backend="hccl", init_method="env://") rank = dist.get_rank() tensor = torch.randn(16).to("npu") dist.all_reduce(tensor) print(f"rank {rank}: {tensor.sum().item()}")用torchrun --nproc_per_node=2拉起。跑得通,说明驱动、固件、HCCL 都没问题,后面 VeRL 的所有分布式逻辑才会顺。这一步非常值得做,能把环境问题从训练问题里先剥离出来。
3. 模型加载与训练配置
3.1 模型加载:权重分片、设备映射、精度策略
Qwen3-30B-A3B 这类参数量偏大的 MoE 模型,在 NPU 上加载时核心问题是显存装不装得下、以什么精度装。30B 总参数如果全部用 BF16 权重加载,光模型权重就是 60GB 左右,加上优化器状态、rollout 生成的 KV cache,单卡基本是扛不住的,必须做权重分片或 offload。
我实验时采用的做法是:训练阶段按专家维度做分片,让每个 NPU 只持有部分 expert 权重,配合 ZeRO-1 把优化器状态分到各卡。VeRL 的 rollout 和 learn 是分离的,rollout 阶段可以单独用更激进的低比特加载策略。这里用了一个比较有效的组合:rollout 侧用 FP8 量化加载权重,learn 侧保持 BF16 微调。
为什么可以这样做?因为 rollout 只负责采样,精度略低对最终奖励的影响可以被 KL 散度兜住,而 learn 侧需要做梯度更新,权重精度不能妥协。之前在城市数据集的 demo 上测过,rollout 用 FP8、learn 用 BF16,训练出的策略和全 BF16 版本在最终指标上几乎无差异,但显存峰值下降了大约 40%。
在设备映射上,MoE 模型需要特别注意tie_word_embeddings和router参数。这些参数虽然占比不大,但它们是被所有 token 共享的热点参数,如果被分到某一张卡上,会直接成为通信瓶颈。我的做法是把 router 和 embedding 复制到所有 rank 上,expert 权重才做真正的分片,这样每次 forward 时只需要跨卡通信专家结果,而没有全局 Hotspot 轮询。
3.2 DAPO 关键超参逐项分析
DAPO 的超参设计决定训练能不能收敛,尤其是学习率和裁剪范围。我用下来比较合适的一组配置如下:
| 参数 | 取值 | 分析 |
|---|---|---|
learning_rate | 1e-6 | MoE 模型普遍对学习率敏感,再往上容易崩 |
lr_scheduler | constant | RL 阶段不推荐衰减式调度,会干扰采样探索 |
update_steps | 4 | 每个 mini-batch 更新次数不宜多,否则过拟合当批次采样 |
clip_ratio_low / high | 0.1 / 1.1 | 比 PPO 的 0.2 更激进,DAPO 默认更信任优势值 |
kl_coef | 0.01 | 初期可以先给 0.05 观察 KL 变化,再调低 |
max_prompt_length | 2048 | 与模型上下文对齐,超出部分直接截断 |
max_response_length | 2048 | 限制生成长度,也控制显存 |
temperature | 0.7 | 太低会减少探索,建议不要低于 0.6 |
top_p | 0.95 | 与 temperature 配合,稳定生成质量 |
sample_num | 8 | 每组 prompt 采样 8 条 response,用于组内优势估计 |
重点解释clip_ratio_low / high为什么设置成 0.1 / 1.1。在分组策略优化类算法里,优势值是组内归一化后的相对值,正负波动天然很大。如果用对称裁剪,会把一部分正确的正向优势直接截掉,导致模型更新幅度过小。DAPO 的做法是把低端裁剪放宽,高端裁剪收紧,让高优势的样本只做有限优化,避免一批噪声极高的极端样本把策略拉偏。这个认知是我对比了两组实验后得出来的,一组用 0.2/0.8 对称裁剪,一组用 0.1/1.1,后者在奖励均值上高了大约 15%,模型逻辑也更加稳定。
kl_coef是另一个值得仔细调的参数。KL 系数太大,策略基本不动,奖励上不去;太小,模型会疯狂钻奖励模型的漏洞,生成脱离指令的文本。我的经验是先用 0.05 跑几百步,等 KL 稳定在一个区间后,再降到 0.01 继续跑。直接上 0.01 容易在前几百步就因为 KL 爆炸而把模型带偏。
3.3 数据准备与输出约束
训练数据格式上,我用的是标准 chat template,每条数据保留system+user两个字段,response 部分留空,由模型在训练时生成。需要注意的一点是,NPU 平台对动态 shape 的支持还不够智能,如果 prompt 长度参差不齐,框架内部会在 padding 上消耗大量显存和算力。
我的做法是在数据预处理阶段把所有 prompt 统一 pad 到接近的长度上限,宁可空着也不让贴合太碎。以max_prompt_length=2048为例,如果实际 prompt 平均只有 800 token,我会先做一次长度统计分析,然后按 1024 的桶来切分。这个步骤可以把训练吞吐提升不少,因为 NPU 上对连续内存访问比 CUDA 平台更敏感。
输出约束方面,除了max_response_length,我还给每个 response 加了强制 stop token 匹配。Qwen3 在 generate 时如果不主动传eos_token_id列表,模型很容易在达到长度上限前一直生成,导致 KV cache 溢出的风险。这个看似小的问题在长训练中很致命,一旦某个随机异常跑出超长序列,整卡显存直接被打满。
4. 训练实操与过程管理
4.1 启动命令与并行分配
训练进程启动方式和并行资源分配直接决定训练的稳定性和效率。我最终用的启动命令是这个:
torchrun --nproc_per_node=8 train_dapo.py \ --model_path /path/to/Qwen3-30B-A3B \ --reward_model /path/to/reward_model \ --dataset /path/to/prompts.jsonl \ --output_dir /path/to/ckpt \ --max_prompt_length 2048 \ --max_response_length 2048 \ --temperature 0.7 \ --top_p 0.95 \ --kl_coef 0.01 \ --sample_num 8 \ --per_device_batch_size 4 \ --gradient_accumulation_steps 8 \ --learning_rate 1e-6单机 8 卡可以这样跑。如果是多机,需要用torchrun的--nnodes和--rdzv_endpoint参数做分布式拉起,同时确保各节点 NPU 驱动和固件版本完全一致。我遇到过两批机器固件版本不一致,导致 allreduce 偶发卡死的案例,排查了很久才定位到是版本差异。
并行策略上,我采用了 VeRL 标准的 separation,即把 rollout worker 和 learn worker 分到不同的进程组里。8 卡分配是 6 卡做 rollout 采样,2 卡做策略更新。这里有一个很好的配置技巧:步进时序上要异步。要点在于 rollout 进程在跑 sampling 的时候不参与梯度计算,因此它们可以用 FP8 低精度加载模型,心跳报错重试逻辑也单独调。学习组保持全量精度权重同步,这样互相不阻塞。
VeRL 的控制循环代码在 trainer 模块里。如果需要自定义优化逻辑,可以复写compute_advantages()和update_policy()两个方法。我改过compute_advantages()里的归一化方式,把原先的 batch 内归一化改成了 group 内归一化,这是 DAPO 变体里很关键的一改——group 内归一化能避免跨 batch 的方差污染,特别是在一条 prompt 的多个采样响应里奖励分布相差极大的时候。
4.2 日志指标解读:KL、熵、response length 的联动
训练启动后,不要只看 loss。强化学习的核心监控指标是这三条线的联动:policy loss、KL 散度、平均 response length、奖励均值。我把它们打包成一个本地 CSV + 控制台定时输出工具,每步训练都记录。
正常情况下,奖励均值应该在早期出现一个爬坡,然后进入一个缓慢震荡的区间。KL 散度则应该稳定在一个较低的常数区间内,如果 KL 单步跳动超过 0.02,说明学习率偏大了。平均 response length 是一个非常灵敏的信号,如果它开始单调递增且没有停下来的趋势,大概率是 DAPO 中的 length penalty 系数设得不够高,模型正在通过废话来换取奖励。
我在这里特意把 length penalty 的权重从 0.5 调到了 1.0。做法是在 policy loss 后面加一项:
length_penalty = length_penalty_coef * (masked_mean(logprob, mask) * mean_length_ratio).detach() loss = policy_loss + length_penalty这个改动会让整体损失中多出一项显式压低长回答的梯度信号。效果立竿见影:在某个数据集上,不加这个惩罚项时平均回答长度一路飙到 1800 token,加上之后回落并稳定在 900 token 左右,奖励反而更高了。
4.3 训练中断恢复与 checkpoint 管理
RL 训练经常跑着跑着就 hang 或崩掉,NPU 平台上尤其如此。VeRL 支持 checkpoint 保存,但我强烈建议把 checkpoint 的保存频率降到很低,同时保存两类东西:模型权重和优化器状态,以及 rollout 的采样 buffer 状态。只保存模型权重的话,恢复之后还要重新采样,等于白跑。
我的做法是每个 update step 结束时自动保存一个可恢复 checkpoint,至少保留最近 3 份,磁盘够的话保留 5 份。恢复时直接加载模型权重和优化器状态,然后用保存的采样 buffer 继续跑 learn 阶段,这样不会因为断点而损失已经做过的采样工作。
恢复命令也很简单,只需要在启动参数里加一个--resume_from_checkpoint指向最新的 checkpoint 目录即可。VeRL 会自动读取 trainer state、optimizer state 和采样 buffer。这里有一个细节:如果训练中改了模型结构或 tokenizer 配置,恢复大概率会报维度不匹配,所以不要随便改动模型配置。
5. 常见问题与排查技巧实录
5.1 算子兼容性与版本管理
我在整个适配过程中遇到最多的问题类型就是算子不兼容。典型报错长这样:
[ERROR] Unsupported operator: fused_mlp [ERROR] Unsupported operator: flash_attention这类问题通常不是框架能帮你解决的。我的排查思路是:先把模型切到直接执行模式,禁用图编译,逐个找不支持的点;再把热点算子在 NPU 算子库版本更新后重新测试。之前 FlashAttention 在 NPU 上就折腾了整整一个下午,最后发现是算子库版本太老,升级到新版本后很多算子已经内置了融合实现。
一个非常有效的排查技巧是把常量替换成变量验证。比如对某个不支持算子的 tensor 改成一个固定 shape 的空 tensor,如果这个能跑通,说明算子本身没问题,是动态 shape 派生导致的。这时只需要在模型 forward 前固定住相关维度即可。
另一个建议是把所有自定义模型代码之外的环境信息全部打印出来,包括算子库版本、HCCL 版本、PyTorch 版本。把这个列表存成一个 person 文件。遇到新报错,第一件事是比对版本,而不是看代码。
5.2 内存与显存类故障
NPU 上的显存管理远没有 CUDA 平台那么丰富。没有类似expandable_segments的自动分配扩展方式。所以内存相关问题主要靠手动控制,而不是靠环境变量解决。
我的显存优化顺序是:
- 先开梯度检查点,把激活值重计算打开;
- 把 rollout 侧模型切到 FP8;
- 再做序列长度分桶,避免长尾长度拉高整组显存;
- 最后考虑减少每个 device 上的 batch size,增加梯度累积步数。
如果 OOM 发生在 learn 阶段且日志没有明确报错,建议先检查 KV cache 的显存预留逻辑。很多时候 token 生成器会一直缓存 attention 的 K/V,直到训练结束才释放——这里的累计非常惊人。我发现一个很好用的办法是在每轮 rollout 结束后显式调用torch.npu.empty_cache(),并在生成循环里定期删除 g_output 变量引用。
5.3 通信超时与性能瓶颈
NPU 上的通信库虽然接口类似 NCCL,但对网络异常更敏感。我遇到过最典型的问题是 allreduce 偶发超时,整个进程组 hang 死,必须重启。这类问题多数不是模型代码引起的,而是 HCCL 的 TCP 连接超时设置太短。
解决思路分两层。软件层面,把 HCCL 的两个关键环境变量调大:
export HCCL_CONNECT_TIMEOUT=1800 export HCCL_TIMEOUT=3000硬件层面,检查节点间网卡的 MTU、交换机的流控是否开启。NPU 通信很依赖绑核策略,我的经验是把通信线程绑定到和 NPU 同一个 NUMA node 上,延迟能降低不少。
另一个相对隐蔽的性能瓶颈是数据加载线程。节点内的数据加载如果用 CPU 直通,会在环境里引入不必要的资源竞争。建议用独立的 CPU 核心来做数据预处理,或者在 train loop 前先加载好整个 batch 再进入训练。
5.4 训练不收敛与过拟合信号
RL 训练不收敛时,第一反应不是调超参,而是确认奖励模型是否有区分度。我在这次实践中犯过一个错误:在某个数据集上奖励模型打分几乎恒定,导致 DAPO 的优势值全部靠长尾噪声撑起来,策略一会儿偏向一个方向,一会儿偏向另一个方向。
处理方法是在训练前先对整个验证集跑一遍奖励模型,画出奖励分布直方图。如果分布方差过小,说明这个 RM 不适合当前数据,应该考虑换掉或者做数据增强。
如果奖励分布正常但训练不收敛,再检查 KL。KL 散度突增通常是学习率与裁剪范围配合不当的最直接信号。我见过不少人把学习率调到 5e-6 甚至 1e-5,认为 MoE 模型稀疏性好,学习率可以更大,结果就是前 500 步模型就崩了。从我实测来看,MoE 模型在 RL 阶段的学习率应比同规模稠密模型低一个数量级,哪怕看单步 loss 下降得慢,也不要轻易加大。
最后说一下过拟合信号:在 RLHF 阶段,如果验证集奖励在某个节点开始下降,但训练集奖励还在上升,说明策略已经开始记忆训练数据里的特殊模式。解决方法就是提高sample_num,增大组内采样多样性,或者加大kl_coef把策略拉回参考模型附近。DAPO 对这个问题相对敏感,因为它会把高优势样本反复优化,如果采样多样性不足,很容易出现策略坍缩。
结语
整套组合从环境搭建到最终稳定训练,我前后花了大约一周时间。回头来看,最花时间的并不是模型代码,而是 NPU 生态环境里那些隐形的不兼容和版本约束。但一旦把这些障碍跨过去,NPU 做大规模 MoE 强化学习是完全可行的,吞吐和稳定性都在可接受的范围内。
如果现在有人问我怎么在 NPU 上评估一个新模型能不能跑 RL,我会建议他先做三件事:先把模型跑通一次纯 MFU 的 benchmark,再把 rollout 的吞吐单独压测一遍,最后小 batch 跑 100 步 DAPO 看 KL 和 reward 的趋势。三关都过,再放量训练。
这套配置目前还在继续迭代,后续我会把 DAPO 的 length penalty 系数和 clip 范围再做一轮更细的网格搜索,也会试试在更大的 MoE 模型上跑同样的流程。如果你也在做类似方向,欢迎在评论区交流细节,特别是 NPU 算子适配和分布式通信这块,很多问题真的只有踩过坑的人才能给你指路。