最近在评估一台带 NPU 的端侧设备时,我遇到一个很典型的问题:模型在 PC 上跑得好端端的,一搬到 NPU 上,延迟反而比 CPU 还高。翻日志才发现,模型里某个算子不被 NPU 支持,整张计算图被回退到了 CPU 执行。这件事让我意识到,很多人对端侧 AI 的理解还停留在“框架能跑通就行”的层面,但实际上,从张量到 NPU 的底层执行链路,才是决定端侧推理性能的真正战场。这篇文章我就把这个链路彻底拆开,从张量谈起,讲清楚 NPU 为什么快、什么时候快、什么时候不快,以及你在部署端侧 AI 时会踩到的那些底层坑。
1. 从数据结构说起:为什么 AI 的眼里只有张量
1.1 张量不是玄学,就是有形状的数字盒子
很多第一次接触端侧 AI 的开发者,一看到“张量”两个字就先退三步,总觉得这是什么高深数学概念。其实完全不用怕。张量说白了就是带形状的数组:一个数字是零维张量,一条数组是一维张量,一张表格是二维张量,而一张彩色图片,在计算机里就是一个三维张量,宽度、高度、通道各占一个维度。到了深度学习里,因为要一次处理一批图片,还得加一个批次维度,于是就成了四维张量。
你可以把张量想成是一个多层货架。货架本身有长、宽、高的尺寸,每个格子里放一个数字。这个“尺寸”就是张量的 shape。PyTorch 里那种torch.randn(1, 3, 224, 224),意思就是“1 张图、3 个通道、高度 224、宽度 224”的货架,格子里装的是随机数。
那为什么 AI 计算非要统一用张量?核心原因在于,神经网络本质上就是一堆矩阵运算的复合。卷积是矩阵乘法加滑动窗口,全连接是矩阵乘法,Transformer 里的 Attention 还是矩阵乘法。既然底层的数学运算高度统一,数据结构自然也要统一。框架层面把所有输入、中间特征图、输出都抽象成 Tensor,好处非常明显:编译器可以统一做内存分配、算子调度和形状推导,不需要为每个具体的变量单独写一套逻辑。这一层抽象,是所有后续优化的地基。
1.2 数据布局与内存连续性:性能差距的源头
同样是形状为[1, 3, 224, 224]的张量,存到内存里却是不同顺序的。常见的布局有两种:NCHW 和 NHWC。NCHW 意味着先把第一个通道的整个 224x224 存完,再存第二个通道;NHWC 则是在每个像素位置上,把 RGB 三个通道的值连续存放。单看代码,只是permute一下的事,但到了物理内存层面,这个选择直接决定 NPU 能不能高效取数。
这里有一个初学者最容易忽略的概念:计算机内存是一维的线性空间,高维张量只是逻辑结构。物理上,它就是把所有数字按顺序铺开。NPU 的加速器硬件在设计时,对数据怎么堆放有强烈的偏好。很多 NPU 天然喜欢 NHWC 布局,因为它的卷积运算在遍历一个像素窗口时,可以一次性连续读到多个通道的数据,cache 命中率高,数据复用也好。而 CPU 端常用的 PyTorch 默认是 NCHW,模型里如果频繁做维度转置,数据搬运和 padding 的开销会在端侧被放大得很明显。
我实际测过,一个简单的 3x3 卷积模型,在 NPU 上如果布局不匹配,编译器为了转换数据布局,可能要多花 20%-30% 的额外推理时间。这还不是最严重的,假如模型里有个自定义算子强制张量从 NPU 计算空间搬回 CPU 内存空间,再搬回去,那这个来回拷贝的耗时,可能比算子本身的计算耗时还高一个数量级。所以在做端侧部署时,第一件事不是看模型结构,而是确认框架导出的 IR 中,张量的物理布局和你目标 NPU 的偏好是否一致。
2. NPU 的设计哲学:专门为乘加运算盖的工厂
2.1 CPU、GPU、NPU 的分工逻辑
要理解 NPU 为什么快,先得搞清楚它和 CPU、GPU 的分工差异。CPU 是一把瑞士军刀,什么都能干,分支预测、乱序执行、缓存调度全都为“逻辑复杂、分支多”的场景优化。GPU 是一条大型流水线,成百上千个线程同时跑同样的指令,适合图形渲染这种数据并行但指令流简单的场景。NPU 呢,它连大型流水线都不算,更像是一条专线:只做乘加运算、激活、池化这类固定模式的操作。
拿工厂类比是这样的:CPU 是老师傅单间,多复杂的手工活儿都能接,但产量有限。GPU 是大型装配车间,单位时间能处理很多并行任务,但建造成本高、功耗大。NPU 是专用自动化产线,只做某一种型号的零件,一旦干起这个活儿,效率和功耗都远超前两者,但要让它干别的,它立马抓瞎。
端侧 AI 的特点是:功耗预算极其有限,但推理任务高度重复——几十次的卷积、矩阵乘、激活,跑在同一个模型上千万遍。这种场景,恰好是 NPU 最强的舒适区。它不需要解决通用计算问题,只需要把“乘加”这件事做到极致,再加上尽可能轻的数据搬运开销。
2.2 算力、片上内存、数据流:NPU 的三大核心
一块 NPU 的硬件设计,说白了就围绕三个部分。第一是 MAC 阵列,也就是乘加单元阵列,这是真正干活的算力核心,INT8 算力指标就是它算出来的。第二是片上 SRAM,通常几百 KB 到几 MB 不等,用来临时存放输入、权重和中间结果。第三是 DMA 和数据流调度逻辑,负责在外部 DRAM 和片上 SRAM 之间搬运数据。
现实中制约 NPU 性能的,往往不是算力,而是“内存墙”。算法复杂度在那里放着,CPU 里的冯诺依曼瓶颈也一样存在:算力再高,数据喂不进来,一切都是空谈。所以 NPU 设计的核心不是“算得快”,而是“喂得饱”——让 MAC 阵列的每个周期的输入数据都不中断。
具体做法是数据重用和 tiling 分块。拿卷积举例,一个 3x3 卷积核在滑动窗口时,相邻窗口之间有很多重叠区域。如果每个窗口都从外部 DRAM 重新读一遍数据,访存带宽会爆炸。NPU 的策略是把输入特征图分块放到片上 SRAM,让卷积核在每个块内反复复用这些数据,把外部访存次数压到最低。Transformer 的矩阵乘也是同理,通过 tiling 把大矩阵切成小块,在片上完成计算,减少片外来回读取。
2.3 为什么 NPU 跑 CNN 和 Transformer 特别快
CNN 和 Transformer 这两大类主流模型,本质都在做同一件事:矩阵乘法加非线性激活。卷积在 im2col 之后也能变成大矩阵乘。所以 NPU 只要把 GEMM(通用矩阵乘)做扎实,就已经覆盖了绝大部分端侧 AI 场景。
NPU 里对 GEMM 的加速,最常见的是脉动阵列(Systolic Array)或类脉动数据流设计。简单讲,就是把乘加单元排成网格,数据像水流一样在网格里流动,每个单元只跟相邻单元通信,避免每算一次乘法都要从寄存器堆重新取数。这种设计极大地减少了寄存器和内存之间的读写次数,让计算单元能持续吃满。
此外,现代 NPU 普遍支持混合精度计算,INT8、INT16、FP16 混着来。权重精度高一点的网络层用 FP16,对精度不敏感的大矩阵用 INT8,算得快还能减小内存带宽压力。端侧 AI 硬件部署时,你经常听到某 NPU 能跑到多少 TOPS,那个数值多数是 INT8 算力,看着很唬人,但实际要考虑带宽、算子匹配度和数据布局之后,能打几折另说。
3. 从框架到硬件的执行链路:模型是怎么变成 NPU 指令的
3.1 计算图与中间表示:翻译的总站
你在 PyTorch 里搭好模型,不等于能直接把它丢给 NPU。框架里的模型是一个动态计算图,Python 对象、动态 shape、各种控制流都还带着,这些对硬件来说全是噪音。部署时第一件事,是把它导出成一份固定的中间表示(IR),比如 ONNX、TFLite、OpenVINO IR 等等。
一份合格的 IR 应该具备三个特点:算子是确定且闭合的——所有算子都在目标工具链的支持列表里;张量形状是静态可推导的——shape 都定死,编译器才好规划内存;量化信息已经固化——每层的 scale、zero_point 都写成常量,推理时不再重新计算。说白了,IR 就是把“动态的、灵活的 Python 代码”翻译成“静态的、死板的硬件指令蓝图”。
这个环节经常出问题。我之前遇到一个模型,里面有个torch.where的根据条件选数的操作,导出到 ONNX 后又变成好几个小算子组合。NPU 编译器本来不支持其中某个组合模式,于是回退到底层 CPU 去执行。这就是为什么导出以后最好做一次 IR 算子检查,用工具链自带的模型体检工具跑一遍,看看哪些算子在目标设备上会被拒绝。宁可早发现早处理,也不要等部署上了再翻日志。
3.2 算子的图优化与融合:性能提升的大头
IR 拿到手之后,NPU 工具链还会做一堆图优化。其中收益最大、最常见的,是算子融合。什么是融合?就是把好几个连续算子合并成一个,减少中间张量的写回和重读。
典型的是 Conv + BatchNorm + ReLU。推理阶段的 BatchNorm 其实是纯线性变换,它的参数可以和卷积层的权重做一步数学合并,而 ReLU 又是一个逐元素算子,完全可以在卷积输出的时候顺手做了。三步融合成一个算子,中间那张特征图根本不需要一次性写回 DRAM,直接在片上 SRAM 里就完成全部流程。这个优化对 INT8 模型尤其重要,因为每次往返 DRAM 都是时间和带宽的双重消耗。
我表示过这样的比喻:算子融合就像工厂里把三道工序合并成一条连续产线,一道工序做完直接滑到下一道,根本不需要把半成品运到仓库再取出来。NPU 编译器里还有常量折叠、共同的子表达式消除这些基础优化,但最关键的还在于融合。做端侧部署时,如果发现某个模型在 NPU 上性能不达标,先打开工具链的优化报告看看,算子融合率是不是偏低,这往往能直接定位问题。
3.3 算子映射与异构调度:一台设备上的多方调度
优化完了就轮到真正的算子映射。NPU 不是每个算子都能直接支持,它的编译器会把 IR 里的算子拆解成更底层的硬件原语,看看能不能拼出对应的硬件指令。能映射的就跑 NPU,不能映射的只能走回退路线:CPU、GPU,或者被拆成几个支持的模式重新组合。
这里涉及端侧最常见的“异构调度”问题。以 Android 的 NNAPI 为例,系统底层有多个可用的计算设备,每个设备支持不同的算子集合,优先级也不一样。运行时调度器会枚举模型里的算子,决定哪一部分放哪个设备。TFLite 的 Delegate、CoreML、OpenVINO 的异构执行插件也是干同样的事。
调度器听着很智能,实际坑很多。最大的坑是“层间切换开销”。如果 NPU 算一个算子,CPU 算另一个,两个设备的计算结果要互相拷贝共享内存,这种切换的耗时很可能大于算子本身的计算时间。所以好的调度器不会严格按算子粒度切开,而是尽量把连续的 NPU 可执行子图合并成一个大的执行单元,减少设备切换次数。这也是为什么一个模型里只要有一个关键算子不支持,整张图就可能全部回退到 CPU,因为硬拆成两个子图的收益可能反而是负的。
4. 量化:端侧 AI 的关键手术
4.1 为什么端侧一定要量化
FP32 模型在 PC 上跑没什么问题,但放到端侧就变得很尴尬。一是体积大,一个 100MB 的模型,光闪存和内存占用就让人肉疼;二是带宽需求高,每次推理都要把这些参数从内存喂给计算单元,FP32 是 INT8 的四倍,直接导致功耗和延迟翻着倍地涨。
量化要做的事,就是把 FP32 的权重和激活值,映射到更低比特位宽的整数空间。本质是做一个带缩放和平移的映射,公式也很朴实:对称量化是q = round(x / scale),非对称量化是q = round(x / scale) + zero_point。选择哪种取决于数据分布:如果数据分布在零附近大致对称,用对称量化;如果分布明显偏移,比如 ReLU 输出恒为非负,那非对称量化能保留更细的精度。
还有一个维度是 per-tensor 和 per-channel。per-tensor 为整个张量算一个 scale,per-channel 则为权重矩阵的每一行算一个自己的 scale。一般来说,权重因为分布相对固定,适合用 per-channel;激活值因为每次推理都在变,只能用 per-tensor,否则硬件实现成本太高。具体到端侧 NPU 的工具链里,怎么组合这些模式,往往直接决定最终精度的好坏。
4.2 校准与精度调优实战
量化之后模型精度掉多少,不是拍脑袋定的,要先做“校准”。校准的意思是,用一小批有代表性的真实数据(通常是几百张训练集之外的图片)跑一遍 FP32 模型,统计每个激活层的数值分布,然后根据这个分布来确定 scale 和 zero_point。
选什么统计方法非常关键。最简单的是 MinMax,直接拿最大值和最小值当边界。但如果激活值里有几个极端离群点,整个量化范围会被拉得特别宽,大多数数据在量化后全挤在很窄的区间里,精度直接崩掉。更稳妥的是 Percentile,比如取 99.99% 分位数,主动舍弃极小比例的极端值,换取整体更高的量化分辨率。再复杂一点的,还有基于 KL 散度去度量量化前后信息损失的方案,不少成熟工具链默认就在用。
我自己实操时的经验是:权重量化一般比较安全,真正敏感的是激活。尤其像 Sigmoid、Softmax 这类非线性输出,数值范围天然受限,但分布很极端——大量值集中在 0 或 1 附近,量化步长稍微选错,整个后续层就全部失真。遇到这种敏感算子,最省事的解决方式是保留 FP16 混合精度,或者把模型拆开,只对敏感的子图用高精度,其余部分用低精度。端侧 NPU 的收益曲线不是线性的,百分之五的算子用高精度,可能需要多花 20% 的延迟,但换回的精度提升可能是决定性的。
5. 端侧大模型与 NPU 的碰撞
5.1 大模型在端侧跑什么:内存带宽才是天花板
这两年 AMD NPU 跑大模型、Intel NPU 跑端侧大模型,甚至手机端跑 7B 模型,都成了非常热门的话题。但大模型和传统 CNN 在端侧的执行特征有很大区别。CNN 的单次推理是计算密集型,而 LLM 的逐 token 生成是访存密集型:每生成一个 token,都需要把所有权重参数从 DRAM 里读一遍。参数量越大,这个访存开销越高。这时候你会发现,NPU 算力再高,如果内存带宽跟不上,整个延迟还是会卡在“喂数据”这一环。
这也是为什么端侧 LLM 都讲究权重量化到 4bit 或 8bit。4bit 不光是为了省存储,更直接的意义是减少一次推理需要搬移的字节数。同一个 7B 模型,FP16 跑一次生成的参数读取量约 14GB,4bit 直接降到 3.5GB,对带宽的压力成倍下降。除了权重,还有 KV Cache 这个内存大户,序列长度一上来,缓存大小跟着线性增长。NPU 上做 Attention 计算时不光算得快,还要考虑 KV Cache 怎么放、分块多大才不浪费片上 SRAM。
5.2 ComfyUI 这类工具怎么用上 NPU
最近有个热门方向是把 ComfyUI 这种基于节点式的图像生成工具,接到 Intel 或 AMD 的 NPU 上跑 Stable Diffusion。原理上讲,SD 的 UNet 部分全是卷积和 Attention,理论上 NPU 干这种活应该是本职内的事。实际你去看社区的方案,多半是借助 OpenVINO 这类工具链,把模型先转换成 IR,再挂到 NPU 后端去推理。
但真实体验并不那么丝滑。ComfyUI 本身的生态是围绕 PyTorch 和 CUDA 搭的,节点和自定义逻辑相当复杂,NPU 工具链要完整支持动态形状、各种自定义节点,工作量巨大。实际部署时经常出现部分算子回退、动态 shape 导致某些节点的图优化失效等状况。我之前试过一次,UNet 的一个关键块在 NPU 上出图,另外几个块跑 CPU,整体提升有限,最后只能把部分变换操作交给 GPU,NPU 只处理卷积主体。
所以我的判断是:这类方案目前更适合做功耗优化或者说个人实验,离生产级稳定还有距离。如果你身边也有人想试,我建议先跑最原始的官方模型和官方工作流,别一上来就用一堆社区自定义节点,否则光排查兼容性问题就能耗掉一个下午。
6. 实战排查:我踩过的那些 NPU 推理坑
6.1 算子不支持导致的整图回退
这是端侧部署最常见也最隐蔽的问题。症状是:模型部署到 NPU 后,推理延迟不但没降低,反而比纯 CPU 还高,而且功率表现也不对。排查思路很简单,先打开工具链的日志,搜索 fallback、cpu 这些关键词。如果发现某个算子在 NPU 设备上不被支持并被调度器自动回退,那就看这张图回退的粒度。很多时候,回退的不是单个算子,而是整个子图——因为调度器发现一个关键算子不支持,干脆把一大段连续子图都丢给 CPU,避免来回切换。
解决手段集中在模型层面:替换算子、调整小结构,或直接改模型本身把不支持的算子绕过去。例如之前遇到过一个FloorDiv算子,NPU 编译器不支持,我直接在导出前用torch.div加取整的方式改写,恢复了对 NPU 的映射支持。
6.2 算力高但速度上不去:内存带宽瓶颈
还有一类情况是,NPU 的 TOPs 看起来很漂亮,但实际跑起来速度很一般。这时要检查的往往不是算力吃没吃满,而是内存带宽。尤其是大模型场景,权重读取的带宽消耗远大于计算单元的消化能力。遇到这类问题,处理手段也很明确:量化位宽再降一档,尝试算子融合来减少中间结果搬运,或者把模型做工程化裁剪。
一个容易忽略的细节:模型里如果有一个很大的中间特征图,比如 [1, 512, 64, 64] 的 FP32 张量,它在 NPU 上每计算一次就要被写回 DRAM 一次。如果图优化没把它和前后算子融合,这个读写开销会非常可观,远大于计算本身。实测下来,有些模型光把中间张量改成功 INT8 输出,推理速度就有显著提升,代价只是略微精度变化。
6.3 精度漂移和热降频:两个稳定性的坑
NPU 上跑 INT8 模型,和你在 GPU 上跑的 FP32 结果,数值有差异是正常的。这种差异在分类任务里可能不影响 top1,但在目标检测的边界框回归这类连续性输出上,可能造成准确率指标的轻微波动。排查精度问题时,最重要的是先确认对比条件一致:同样的输入数据、同样的预处理流程、同样的数据归一化方式。我见过不少人对齐了半天,最后发现是两边读图时的缩放插值算法不一样。
另外就是热降频问题。端侧设备散热条件差,NPU 连续高负载跑几分钟后,芯片温度上来,频率会明显下降,导致推理延迟从 20ms 飘到 40ms。这一点在做性能基准测试时经常被忽略。测试时不能只看第一次推理的峰值性能,一定要跑长循环、取稳态性能,比如连续推理 100 次以上再看平均延迟。这样才能拿到真实可用的数据。
最后分享一个小技巧:做端侧 NPU 部署时,我习惯先跑通 CPU 推理,再切 NPU 后端,并且每一步都分开测延迟,对比“哪个算子快了、哪个算子反而慢了”。这样定位问题的颗粒度会非常细。很多时候所谓 NPU 部署失败,不是算力不行,而是数据布局不对、算子覆盖不完整、图优化没生效。把执行链路一条条拆开看,问题基本藏不住。