1. 当NPU跑不过GPU时,先别急着换硬件
做过端侧推理的兄弟大概率都遇到过这种诡异场景:同一份模型,同一批输入,在GPU上跑得好好的,换到NPU上推理耗时直接翻了几倍,甚至比纯CPU还慢。你打开任务管理器或者npu-smi一看,NPU利用率低得可怜,CPU反而满载。这时候很多人第一反应是"NPU性能不行"或者"驱动有问题",然后开始折腾固件版本、换推理框架、甚至怀疑板子坏了。
我踩过这个坑不止一次。真相往往很朴素:你的算子根本没跑在NPU上,而是静默回退到了CPU执行。所谓"静默",就是框架不报错、不警告,日志里也看不出明显异常,模型照样出结果,只是慢。这种问题在昇腾NPU、RK3588的NPU、以及各类端侧加速卡上都非常常见,尤其是你从PyTorch或ONNX直接转过去的时候。
这篇文章面向的是正在做NPU部署、模型移植、端侧推理优化的工程师,以及被"NPU比GPU慢"这个问题困扰过的开发者。我会把算子回退的识别方法、定位手段、排查流程和修复思路完整拆一遍,尽量做到你照着做就能找到自己模型里到底哪些算子偷偷跑了CPU。核心关键词就几个:NPU、GPU、算子、CPU、fallback,全文围绕它们展开。
先说结论:NPU跑得慢,九成不是算力问题,而是算子覆盖率和图切分的问题。GPU生态成熟,CUDA算子库几乎覆盖了所有常见操作;NPU的算子支持列表是有限的,遇到不支持的算子,推理框架的处理策略通常是"切图"——把支持的子图丢给NPU,不支持的算子留在CPU,中间插入数据搬运。搬运一次两次还好,如果模型里到处是不支持的算子,NPU就在那儿反复等CPU喂数据,利用率自然上不去。
2. 算子回退到底是怎么发生的
2.1 从计算图到设备切分的完整链路
要理解回退,得先搞清楚一个模型从框架到NPU上执行,中间经历了什么。以PyTorch模型转到昇腾NPU为例,大致链路是这样的:PyTorch模型先导出成ONNX或者直接走torch_npu的图模式,然后经过图优化、算子融合、算子选择,最后把计算图切分成若干子图,分别指派到NPU和CPU上执行。
关键就在"算子选择"这一步。推理框架内部维护了一张算子支持表,每个算子会标注它在目标设备上是否支持、支持什么数据类型、支持什么shape范围。当某个算子不在支持表里,或者虽然支持但当前的数据类型/shape不满足条件,框架就会把这个算子标记为"CPU执行"。这个过程大多数时候是静默的,因为框架认为"能跑就行",不把回退当成错误。
我见过最典型的情况是:模型里用了一个torch.nn.functional.pad的某种mode,NPU只支持constant模式,你用了reflect,于是这个pad就回退CPU了。pad本身计算量不大,但它前后连着卷积,一回退就导致前后两个卷积子图被切断,NPU要等CPU算完pad再继续,流水线直接断掉。
2.2 静默回退为什么这么难发现
静默回退难发现,核心原因是它不报错。框架的日志级别默认是INFO或WARNING,算子回退的信息往往打在DEBUG级别,你不主动开根本看不到。而且回退之后模型输出是对的,精度没问题,只是慢,这就让人很难联想到"算子没上NPU"。
另一个原因是性能数据会误导你。你看到NPU利用率低,可能以为是batch size太小、数据加载慢、或者NPU频率没拉满。实际上是因为NPU在等CPU算那些回退的算子,利用率当然上不去。这时候你去调batch size、调数据管道,都是白费功夫。
还有一个坑是回退可能发生在图编译阶段而不是运行阶段。有些框架在第一次推理时编译图,编译日志里会列出哪些算子跑在哪个设备,但如果你没保存这份日志,后面就再也看不到了。所以第一次跑模型时一定要把详细日志留下来。
2.3 回退的几种典型触发条件
根据我的经验,算子回退主要有这么几类触发条件,你可以对照自己的模型排查:
| 触发类型 | 典型场景 | 排查难度 |
|---|---|---|
| 算子不支持 | 用了NPU算子库没有的算子,如某些自定义op、特殊激活函数 | 低,查支持列表即可 |
| 数据类型不匹配 | 算子支持但只支持FP16,你用了FP32 | 中,需要看算子约束 |
| Shape动态 | 动态shape导致算子无法静态编译 | 高,需要固定shape或改动态逻辑 |
| 属性/参数不支持 | 算子支持但某个attribute不支持,如pad的reflect模式 | 中,需要看算子文档 |
| 多算子融合失败 | 融合模式不匹配,导致整块回退 | 高,需要看融合日志 |
提示:不同框架的算子支持表更新频率不一样,昇腾的CANN版本、RKNN的toolkit版本都会影响支持列表。排查前先确认你的工具链版本,别拿旧版本的结论套新版本。
3. 识别算子回退的四种实战手段
3.1 打开框架的详细日志,让回退现形
最直接的办法就是把日志级别拉满。以昇腾的torch_npu为例,你可以通过环境变量控制日志:
export ASCEND_GLOBAL_LOG_LEVEL=1 export ASCEND_SLOG_PRINT_TO_STDOUT=1 export TASK_QUEUE_ENABLE=2ASCEND_GLOBAL_LOG_LEVEL=1是DEBUG级别,会把图编译、算子选择、子图切分的信息全打出来。跑一次推理,然后在日志里搜关键词:fallback、cpu、not supported、unsupported、AICPU。昇腾的日志里,跑在NPU上的算子通常标AI_CORE,跑在CPU上的标AI_CPU或者HOST_CPU。你数一下AI_CPU出现的次数和位置,基本就能定位回退的算子。
RK3588的RKNN工具链类似,在转换模型时加--verbose参数,或者在推理时设置RKNN_LOG_LEVEL=0,能看到每个算子的执行设备。我实测下来,RKNN的日志比昇腾更直白,它会直接告诉你"operator xxx is not supported, fallback to CPU"。
3.2 用性能分析工具看算子级耗时
日志能告诉你哪些算子回退了,但有时候日志太多不好定位。这时候用性能分析工具更高效。昇腾有msprof,可以采集算子级的执行时间和设备信息:
msprof --application="python infer.py" --output=./profiling跑完之后用msprof --export导出,你会得到一张算子耗时表,里面有一列是执行设备。按耗时排序,如果某个CPU算子耗时占比很高,那它就是瓶颈。我一般会重点看两类:一是耗时绝对值大的CPU算子,二是夹在两个NPU算子中间的CPU算子,后者即使耗时不大也会破坏流水线。
GPU这边可以用Nsight Systems或者PyTorch Profiler,NPU这边msprof是标配。如果你用的是其他NPU平台,找对应的profiler就行,思路是一样的:看算子级设备归属和耗时。
3.3 对比GPU和NPU的算子执行序列
这个方法比较土但很有效:同一份模型,分别在GPU和NPU上跑,用profiler导出算子执行序列,然后对比。GPU上所有算子都在CUDA上跑,序列是连续的;NPU上如果出现CPU算子,序列里就会有设备切换。你把两个序列并排看,NPU序列里多出来的CPU算子就是回退的。
我一般用PyTorch Profiler导出chrome trace格式,然后在浏览器里打开对比。GPU的trace里全是cuda开头的kernel,NPU的trace里如果混着cpu开头的op,一眼就能看出来。这个方法的好处是直观,不需要你去翻日志,而且能看到算子之间的依赖关系,方便判断回退的影响范围。
3.4 二分法定位:逐层注释,缩小范围
如果模型很大,日志和profiler都不好定位,可以用二分法。把模型从中间切开,只跑前半部分,看回退算子在哪一半;然后再切,直到定位到具体层。这个方法笨但可靠,尤其适合那些日志被淹没、profiler数据量太大的情况。
具体操作是:在模型forward里加断点,或者用hook把中间层输出截出来,逐段跑。我一般会先按模块切,比如backbone、neck、head分别跑,看哪个模块回退最多。定位到模块后再按层切。虽然费时间,但比瞎猜强。
4. 定位到回退算子之后怎么修
4.1 优先找等价算子替换
定位到回退算子后,第一反应应该是找等价替换。很多回退是因为用了一个NPU不支持的算子变体,但功能上可以用支持的算子组合出来。举几个我实际遇到过的例子:
F.pad的reflect模式不支持,改成constant模式,或者用torch.cat手动拼边界。- 某些特殊激活函数不支持,用NPU支持的激活函数近似,或者拆成基础算子组合。
torch.nn.functional.interpolate的某些mode不支持,换成NPU支持的mode,或者用转置卷积替代。- 自定义op不支持,把它拆成几个基础op的组合。
替换的时候要注意数值等价性。有些近似替换会带来精度损失,需要跑一遍精度对比。我一般会用相同的输入跑替换前后的输出,算最大绝对误差和相对误差,误差在1e-3以内基本可以接受,具体看你的任务要求。
4.2 调整数据类型和shape约束
如果回退是因为数据类型不匹配,那就把数据类型改成NPU支持的。昇腾NPU对FP16的支持最好,很多算子只支持FP16。你可以用混合精度,把回退算子前后的部分转成FP16。PyTorch里可以用torch.npu.amp.autocast,或者手动.half()。
Shape问题更麻烦一些。动态shape会导致算子无法静态编译,从而回退。解决办法是固定shape,把动态维度固定成具体值。如果业务上必须支持动态shape,那就把动态部分放到CPU上,或者用NPU支持的动态shape算子。昇腾新版本对动态shape的支持在变好,但老版本还是建议固定。
4.3 算子融合与图优化
有时候单个算子都支持,但融合之后不支持,导致整块回退。这时候可以尝试关闭部分融合,让算子单独执行。昇腾的图优化可以通过环境变量控制:
export ASCEND_OPP_FUSION_SWITCH=0关掉融合后,算子单独跑,可能就都上NPU了。代价是少了融合带来的性能收益,但总比回退到CPU强。这个需要实测对比,有时候融合收益大于回退损失,有时候反过来。
另一个思路是手动融合。把几个小算子合并成一个NPU支持的大算子,比如把conv+bn+relu手动合成一个,减少算子数量,也就减少了回退机会。
4.4 实在修不了的回退怎么兜底
有些算子确实没有NPU实现,也找不到等价替换,那就只能接受回退。这时候要做的是减少回退的影响:
- 把回退算子集中到一起,避免和NPU算子交替执行,减少设备切换开销。
- 把回退算子的输入输出尽量放在CPU上,避免频繁的H2D/D2H拷贝。
- 如果回退算子计算量大,考虑用CPU多线程加速,或者用SIMD指令优化。
- 评估是否可以把这部分逻辑移到模型外,用预处理/后处理的方式在CPU上做。
我遇到过一个案例:模型里有个自定义的归一化算子,NPU不支持,回退到CPU后耗时占了总时间的40%。后来把这个归一化移到模型外,用numpy在CPU上做,反而更快,因为numpy的向量化比框架的CPU算子实现更高效。
5. 常见问题速查与避坑指南
5.1 算子回退排查速查表
| 现象 | 可能原因 | 排查手段 | 解决方向 |
|---|---|---|---|
| NPU利用率低,CPU满载 | 大量算子回退 | 开DEBUG日志搜AI_CPU | 替换/融合算子 |
| 推理耗时比GPU慢几倍 | 回退算子破坏流水线 | profiler看设备切换 | 集中回退算子 |
| 模型输出正确但慢 | 静默回退 | 对比GPU/NPU算子序列 | 定位并替换 |
| 首次推理慢,后续正常 | 图编译阶段回退 | 保存首次编译日志 | 固定shape/类型 |
| 部分batch慢,部分正常 | 动态shape触发回退 | 固定shape测试 | 固定shape或改逻辑 |
5.2 几个容易踩的坑
坑一:只看平均耗时,不看算子级数据。平均耗时会把回退的影响稀释掉,你以为只是"稍微慢一点",实际上某个算子可能慢了几十倍。一定要看算子级数据。
坑二:以为日志级别调高就行。有些框架的日志需要同时设置多个环境变量才生效,比如昇腾既要设ASCEND_GLOBAL_LOG_LEVEL,又要设ASCEND_SLOG_PRINT_TO_STDOUT,还要确认日志输出路径有写权限。我见过有人设了变量但日志没出来,以为没回退,其实是日志根本没打。
坑三:忽略工具链版本差异。同一个算子,CANN 6.0支持,CANN 5.0不支持。你拿网上的结论套自己的环境,可能完全对不上。排查前先npu-smi info看版本,再查对应版本的算子支持表。
坑四:修了回退但没测性能。替换算子后一定要重新跑profiler,确认回退消失了,且整体性能提升了。有时候替换后的算子虽然上了NPU,但实现效率低,反而更慢。
坑五:忘了检查数据搬运开销。回退算子前后如果有H2D/D2H拷贝,这部分开销可能比算子本身还大。用profiler看memcpy的时间占比,如果很高,优先优化搬运。
5.3 我的实操心得
分享几个我踩坑总结出来的经验。第一,新模型上NPU前,先跑一遍算子支持检查。昇腾有atc工具的--check_op选项,RKNN有模型分析功能,能在转换阶段就告诉你哪些算子不支持,别等到推理时才发现。第二,保持模型结构简单。花哨的算子组合在GPU上没问题,在NPU上大概率回退。能用基础算子就用基础算子。第三,固定shape和数据类型。动态shape和混合类型是回退的重灾区,端侧部署尽量固定。第四,profiler和日志一起看。日志告诉你哪些回退,profiler告诉你回退的影响有多大,两者结合才能决定优化优先级。
还有一点,别迷信"NPU比GPU快"的宣传。NPU的优势在能效比和特定算子,通用算力不一定比GPU强。如果你的模型算子覆盖率低,NPU跑不过GPU是正常的。这时候要么优化模型适配NPU,要么老老实实用GPU。选型阶段就要做算子覆盖率评估,别等部署了才发现。
6. 从识别到定位的完整排查流程
把上面的内容串起来,我给一个完整的排查流程,你可以照着走:
第一步,确认现象。跑一次推理,记录总耗时,同时用npu-smi或类似工具看NPU利用率。如果利用率低于50%且CPU占用高,基本可以怀疑回退。
第二步,开日志。设置DEBUG级别日志,跑一次推理,保存日志文件。在日志里搜AI_CPU、fallback、unsupported,列出所有回退算子。
第三步,跑profiler。用msprof或对应工具采集算子级数据,导出耗时表,按设备分类。确认回退算子的耗时占比和位置。
第四步,对比GPU。同一模型在GPU上跑profiler,导出算子序列,和NPU序列对比,确认回退算子的影响范围。
第五步,逐个修复。按耗时占比排序,优先修影响大的。先试等价替换,再试类型/shape调整,最后试融合开关。
第六步,验证。修复后重新跑日志和profiler,确认回退消失,性能提升。同时跑精度对比,确认数值没问题。
第七步,记录。把回退算子、替换方案、性能数据记下来,形成自己的算子适配清单。下次遇到类似模型可以直接查。
这个流程我用了很多次,基本能覆盖90%以上的回退问题。剩下的10%可能是框架bug或者硬件限制,那就只能等版本更新或者换方案了。
最后说一句,算子回退这个问题,早发现早治疗。模型设计阶段就考虑NPU算子支持,比部署阶段再改要省事得多。如果你正在做NPU部署,建议先把算子支持检查加进CI流程,每次模型改动都跑一遍,别等到上线才发现慢。