最近在做一个端侧AI项目,需要把一个神经网络模型部署到手机的NPU上运行。

刚开始的时候,模型在NPU上跑得特别慢,一帧推理要两三百毫秒,根本没法用。经过一系列优化,最后把推理时间降到了二十毫秒以内,满足了实时性要求。这个过程踩了很多坑,也积累了不少经验。

这篇文章就来分享一下NPU性能优化的实战经验,从模型转换到算子优化,从内存管理到量化压缩,聊聊怎么把一个慢模型一步步优化成快模型。

先搞清楚NPU是什么

在讲优化之前,先简单说一下NPU是什么。

NPU(Neural Processing Unit,神经网络处理单元)是一种专门为神经网络计算设计的处理器。和CPU的通用计算、GPU的并行渲染不同,NPU专门针对神经网络的典型运算(比如卷积、矩阵乘法、激活函数)做了硬件加速。它的特点是计算密度高、功耗低,非常适合在手机、嵌入式设备这些功耗受限的平台上运行AI模型。

现在主流的手机芯片都集成了NPU,比如高通的Hexagon NPU、联发科的APU、华为的达芬奇NPU、苹果的Neural Engine。这些NPU的算力都不弱,比如旗舰芯片的NPU算力能达到几十TOPS(每秒万亿次操作),比CPU快几十上百倍。

但NPU的算力是理论峰值,实际能跑到多少,取决于模型的结构、算子的支持情况、内存的使用效率等很多因素。很多人把模型放到NPU上跑,发现速度还不如CPU快,就是因为没有充分利用NPU的算力。

NPU性能优化的目标,就是让模型尽可能多地跑在NPU上,尽可能高效地利用NPU的计算资源,尽可能减少内存和数据传输的开销。

第一步:模型转换和兼容性检查

把模型部署到NPU上的第一步,是模型转换。

你训练好的模型通常是PyTorch或TensorFlow格式的,NPU不能直接运行,需要转换成NPU厂商支持的格式,比如高通的DLC、联发科的NeuroPilot模型、华为的OM模型、苹果的CoreML模型。

模型转换看起来简单,就是用厂商提供的转换工具跑一下,但实际上坑很多。

第一个坑是算子不支持。NPU不是支持所有的神经网络算子,它主要支持卷积、池化、全连接、激活这些常见的算子。一些比较新的或者比较复杂的算子,比如某些自定义算子、某些特殊的归一化层、某些动态形状的操作,NPU可能不支持。如果模型里有NPU不支持的算子,转换工具要么报错,要么把这些算子放到CPU上运行。

如果模型里有一部分算子跑在CPU上,一部分跑在NPU上,就会出现"算子回落"的问题。数据需要在NPU和CPU之间来回拷贝,这个拷贝的开销非常大,可能比计算本身还慢。这就是为什么有些模型在NPU上跑反而比CPU慢的原因。

所以模型转换之后,第一件事就是检查算子的支持情况。看一下转换日志,哪些算子跑在NPU上,哪些算子回落到了CPU上。如果有大量算子回落,就需要修改模型结构,把不支持的算子替换成支持的算子,或者用支持的算子组合来实现相同的功能。

我在优化的时候就遇到了这个问题。模型里用了一个比较新的注意力机制,其中有几个算子NPU不支持,导致整个模型大部分都跑在了CPU上。后来我把这几个算子替换成了NPU支持的等价实现,模型就全部跑在NPU上了,速度直接提升了十倍。

第二个坑是模型格式的差异。不同框架的模型,在转换的时候可能会出现精度损失、结构变化等问题。比如PyTorch的模型是动态图,转换的时候需要先导出成ONNX,再转换成NPU格式,这个过程中可能会出现一些不兼容的问题。建议转换之后,先在NPU上跑一下,和CPU的结果做对比,确认精度没有问题,再进行下一步的优化。

第二步:模型结构优化

模型结构对NPU性能的影响非常大。同样的精度,不同的模型结构,在NPU上的速度可能差好几倍。

第一个优化方向是减少计算量。计算量是推理时间的基础,计算量小了,速度自然就快了。可以用一些轻量级的网络结构,比如MobileNet、ShuffleNet、EfficientNet这些,它们用深度可分离卷积、分组卷积等技术,在保证精度的前提下大幅减少计算量。

如果已经有了一个大模型,可以用模型压缩的方法来减少计算量。比如剪枝,把模型中不重要的通道或层剪掉;比如蒸馏,用大模型指导小模型训练,让小模型达到接近大模型的精度。这些方法能在精度损失不大的情况下,把模型体积和计算量减少一半以上。

第二个优化方向是选择NPU友好的算子。不同的算子在NPU上的效率差异很大。比如卷积和全连接是NPU最擅长的,效率很高;而一些逐元素操作、形状变换操作,NPU的效率就比较低。在设计模型的时候,尽量多用NPU擅长的算子,少用效率低的算子。

比如,能用卷积实现的功能,就不要用一堆逐元素操作来实现;能用一个大卷积核的,就不要用多个小卷积核堆叠(虽然计算量一样,但NPU对大卷积核的并行效率更高);能合并的算子就合并,比如把卷积和激活函数合并成一个融合算子,减少中间结果的内存读写。

第三个优化方向是避免动态形状。NPU通常对固定形状的输入优化得比较好,因为可以在编译期就做很多优化(比如内存预分配、算子融合、调度优化)。如果输入形状是动态的(比如不同的图片尺寸、不同的序列长度),NPU可能需要每次都重新编译,或者退化成通用实现,性能会下降很多。

所以在部署的时候,尽量固定输入形状。如果必须支持多种输入尺寸,可以为每种尺寸编译一个模型,运行时根据输入尺寸选择对应的模型。虽然会增加一些存储空间,但能大幅提升推理速度。

第三步:量化压缩

量化是NPU性能优化中最重要的手段之一。

大部分NPU都是为整数运算(INT8、INT16)优化的,对浮点数(FP32、FP16)的支持比较差,或者效率很低。如果你的模型是FP32的,放到NPU上跑,可能会被转换成FP16或者INT8,但这个转换过程可能会有精度损失,而且效率不如直接用INT8模型。

所以,在部署到NPU之前,最好先把模型量化成INT8。INT8模型的体积是FP32的四分之一,计算量也是四分之一,在NPU上的速度通常能提升2到4倍。

量化的方法主要有两种:训练后量化(PTQ)和量化感知训练(QAT)。

训练后量化比较简单,不需要重新训练模型,只需要用一些校准数据跑一遍,统计每个张量的数值范围,然后把FP32的权重和激活值量化成INT8。这种方法速度快,大部分情况下精度损失不大,是最常用的量化方法。

量化感知训练需要在训练过程中引入量化的误差,让模型适应量化后的数值表示。这种方法精度更高,但需要重新训练模型,成本比较高。如果训练后量化的精度不满足要求,可以考虑用量化感知训练。

量化的时候要注意几个问题。

第一是校准数据的选择。校准数据应该和实际使用场景的数据分布一致,而且要有足够的多样性。如果校准数据太单一,统计出来的数值范围不准确,量化后的精度会下降很多。一般来说,用几百到几千张有代表性的校准数据就够了。

第二是敏感层的处理。模型中有些层对量化比较敏感,量化之后精度下降明显。比如模型的第一层和最后一层,或者一些小权重的层。可以对这些敏感层保留FP16或FP32,其他层用INT8,在精度和速度之间做一个平衡。

第三是量化后的精度验证。量化之后一定要在验证集上测试精度,和量化前的结果做对比。如果精度下降太多,需要检查是校准数据的问题,还是敏感层的问题,或者需要用量化感知训练。

我在优化的时候,模型从FP32量化到INT8之后,速度提升了三倍,精度只下降了0.5%,完全在可接受范围内。量化是NPU优化中投入产出比最高的一步。

第四步:内存和数据传输优化

NPU的算力很强,但内存带宽往往是瓶颈。如果内存读写效率不高,NPU的算力就发挥不出来。

第一个优化是减少中间结果的内存占用。神经网络的每一层都会产生中间结果,这些中间结果需要存在内存里,供下一层使用。如果模型很深,中间结果的内存占用会很大,可能导致内存不够用,或者频繁地进行内存分配和释放。

解决方法是内存复用。NPU的编译器通常会做内存复用的优化,把不再使用的中间结果的内存释放出来,给后面的层用。但有些情况下编译器的优化不够好,需要手动调整。比如把模型分成几个段,每段运行完之后释放中间结果的内存;或者用一些内存优化的工具,分析内存使用情况,找出内存占用大的地方进行优化。

第二个优化是减少数据在NPU和CPU之间的传输。前面提到过,如果有算子回落到CPU,数据需要在NPU和CPU之间来回拷贝,这个开销很大。即使所有算子都跑在NPU上,输入数据的预处理和输出数据的后处理通常还是在CPU上做的,这也涉及数据传输。

优化的方法是尽量把预处理和后处理也放到NPU上。比如图片的归一化、缩放、颜色空间转换,可以用NPU的算子来实现,作为模型的一部分。这样数据从摄像头出来之后,直接进NPU,预处理和推理一起完成,不需要在CPU和NPU之间来回拷贝。

第三个优化是使用DMA和零拷贝。一些高端的NPU支持DMA(直接内存访问)和零拷贝技术,可以在不经过CPU的情况下,直接把数据从摄像头或显示器的内存传输到NPU的内存。这能大幅减少数据传输的开销,特别是对于视频处理这种需要持续输入大量数据的场景。

第四个优化是合理设置batch size。NPU通常对大batch的并行效率更高,因为可以更好地利用计算单元。但batch太大也会增加内存占用和延迟。需要根据实际场景,在吞吐量和延迟之间做一个平衡。如果是实时性要求高的场景(比如视频处理),用batch=1;如果是吞吐量优先的场景(比如批量图片处理),可以用大一点的batch。

第五步:编译和调度优化

NPU模型在运行之前,通常需要经过一个编译的过程,把模型转换成NPU可以执行的指令。编译的质量直接影响运行的性能。

第一个优化是选择合适的编译选项。NPU的编译器通常有很多优化选项,比如算子融合、内存优化、调度策略、精度模式等。不同的选项组合,对性能的影响很大。建议仔细阅读编译器的文档,尝试不同的选项组合,找到最优的配置。

比如算子融合,把相邻的几个小算子合并成一个大算子,可以减少中间结果的内存读写和函数调用开销。大部分NPU编译器默认会做算子融合,但有些情况下需要手动开启或调整融合的策略。

第二个优化是利用NPU的多核架构。很多高端NPU是多核的,比如有两个或四个计算核心。编译器通常会自动把模型的不同部分分配到不同的核心上并行执行。但有些情况下,自动分配的效果不好,需要手动调整。比如把模型分成几个子图,指定每个子图运行在哪个核心上,或者用流水线的方式,让多个核心同时处理不同的帧。

第三个优化是预热和常驻。NPU模型第一次运行的时候,需要做一些初始化的工作(比如加载模型、分配内存、编译缓存),这个过程比较慢。如果是实时应用,应该在应用启动的时候就加载模型并做一次预热推理,让初始化的开销在启动时就完成,而不是在第一次推理的时候才做。

另外,模型加载到NPU之后,尽量不要频繁地加载和卸载。如果应用中有多个模型需要切换,尽量把常用的模型都常驻在NPU内存中,避免重复加载的开销。如果NPU内存不够,需要换入换出,也要设计好换入换出的策略,减少对实时性的影响。

第六步:性能分析和持续优化

性能优化不是一步到位的,需要反复地分析、优化、验证。

首先要建立性能基准。在优化之前,先测一下模型在NPU上的原始性能,包括推理时间、内存占用、功耗、精度。这些数据是优化的基准,后续的优化效果都要和这个基准做对比。

然后要用性能分析工具找出瓶颈。每个NPU厂商都有性能分析工具,比如高通的Snapdragon Profiler、华为的Profiling工具。这些工具可以告诉你每一层的执行时间、内存使用情况、算子的运行位置(NPU还是CPU)、NPU的利用率等。通过这些数据,你可以找出性能瓶颈在哪里,是某个算子太慢,还是内存传输太多,还是NPU利用率不高。

找出瓶颈之后,针对性地进行优化。如果是某个算子太慢,看看能不能替换成更高效的算子,或者用多个小算子组合实现。如果是内存传输太多,看看能不能减少CPU和NPU之间的数据交互。如果是NPU利用率不高,看看能不能增加计算密度,或者调整模型结构让NPU更好地并行。

优化之后要重新测试性能和精度,确认优化有效且精度没有下降。然后再分析、再优化,直到达到性能目标或者没有优化空间了。

性能优化是一个持续的过程。NPU的驱动和编译器在不断更新,每次更新可能会带来性能提升,也可能引入新的问题。模型也可能会迭代更新,每次更新都需要重新做性能优化。所以要建立一套自动化的性能测试流程,每次模型或驱动更新之后,自动跑一遍性能测试,及时发现和解决问题。

我的优化成果

最后说一下我的优化成果。

原始模型:FP32,在NPU上推理一帧需要280毫秒,其中大部分算子因为不支持回落到了CPU,NPU利用率只有20%左右。

优化过程:

  1. 模型结构调整,把不支持的算子替换成NPU支持的算子,全部跑在NPU上,推理时间降到80毫秒。
  2. 模型轻量化,用深度可分离卷积替换标准卷积,计算量减少40%,推理时间降到50毫秒。
  3. INT8量化,速度提升2.5倍,推理时间降到20毫秒,精度下降0.5%。
  4. 内存优化,把预处理合并到模型中,减少数据传输,推理时间降到18毫秒。
  5. 编译优化,开启算子融合和多核调度,推理时间降到15毫秒。

最终模型:INT8,推理一帧15毫秒,NPU利用率85%以上,精度满足业务要求,达到了实时处理的目标。

整个优化过程花了大概两周时间,踩了很多坑,但也学到了很多。NPU性能优化是一个系统性的工作,需要从模型结构、量化、内存、编译等多个方面入手,每一个环节都可能成为瓶颈。

写在最后

NPU性能优化是一个很有挑战性但也很有成就感的工作。当你看到一个原本跑得很慢的模型,经过你的优化,变得飞快,那种成就感是很难用语言形容的。

这篇文章分享的是我个人的实战经验,不同的NPU平台、不同的模型、不同的应用场景,优化的方法可能会有所不同。但核心思路是一样的:了解硬件、分析瓶颈、针对性优化、持续迭代。

随着端侧AI的普及,NPU会越来越重要,NPU性能优化也会成为一个越来越重要的技能。希望这篇文章能给正在做或者将要做NPU优化的朋友一些参考。

如果你也有NPU优化的经验或者问题,欢迎交流讨论。