1. 端侧 AI 到底在跑什么:从一次模型部署翻车说起
去年帮一个做智能门锁的团队看问题,他们的活体检测模型在 PC 上跑得好好的,量化成 int8 塞进设备之后,误识率直接飙到没法用。代码没改,权重没改,唯一变的就是执行后端从 CPU 换成了 NPU。这件事让我意识到,很多人对端侧 AI 的理解停留在“把模型转个格式丢进去”这个层面,但真正决定成败的,是张量在底层怎么被搬运、怎么被切分、怎么被 NPU 的算子单元吃掉。
这篇内容我想聊的就是这条链路:一个张量从框架里出来,经过图优化、内存规划、指令下发,最后落到 NPU 的 MAC 阵列上执行,中间到底发生了什么。核心关键词是张量、NPU、端侧 AI、底层执行逻辑。适合谁看?如果你正在做端侧模型部署,或者准备把大模型往手机、边缘盒子上搬,又或者你只是好奇“NPU 到底比 CPU 快在哪”,这篇应该能给你一些能直接用的东西。
我不会只讲概念。每一段我都会尽量落到“为什么这么设计”“参数怎么算”“踩过什么坑”上。因为端侧 AI 这件事,纸面理论和实际落地之间的差距,比大多数人想象的大得多。
2. 张量不是数组:端侧执行视角下的数据容器
2.1 张量的本质是“带布局的内存视图”
很多人第一次接触张量,会觉得它就是个多维数组。这个理解在写 Python 的时候没问题,但一旦进入端侧执行层,就会出问题。张量在底层不是一个“数据块”,而是一个描述符加一块内存。描述符里包含 shape、stride、dtype、内存偏移、对齐要求,甚至还有量化参数。真正被 NPU 读取的是那块内存,而描述符决定了 NPU 怎么解释这块内存。
举个具体的例子。一个 shape 为[1, 3, 224, 224]的 NCHW 张量,和一个 shape 为[1, 224, 224, 3]的 NHWC 张量,在内存里可能是同一块数据,但 stride 完全不同。NPU 的卷积单元通常对通道对齐有要求,比如通道数必须是 4 或 8 的倍数。如果你的张量是 NHWC 布局,通道在最后一维,连续访问很自然;但如果是 NCHW,通道跨步访问,NPU 的 DMA 就得做额外的 gather 操作,效率直接掉一截。
这就是为什么很多端侧推理框架在转换模型时会强制做 layout 转换。不是它想多此一举,而是 NPU 的硬件特性决定的。你在 PC 上跑 ONNX Runtime 感觉不到,因为 CPU 有 cache 和乱序执行帮你兜底,NPU 没有这些,它就是一个高度并行的计算阵列,喂给它的数据必须“顺”。
2.2 量化张量:int8 背后的 scale 和 zero_point
端侧 AI 绕不开量化。一个 fp32 的权重占 4 字节,int8 只占 1 字节,模型体积直接降到四分之一,带宽压力也小很多。但量化不是简单地把浮点数截断成整数,它需要一个映射关系:
real_value = (int_value - zero_point) * scale这里的scale和zero_point就是量化参数。scale是浮点步长,zero_point是整数零点。对于对称量化,zero_point通常是 0;对于非对称量化,zero_point是一个非零整数,用来对齐真实零点和整数零点。
为什么这个细节重要?因为 NPU 在做卷积时,通常是整数乘加,然后通过一个“requantize”步骤把累加结果重新映射回 int8。这个 requantize 的 scale 是输入 scale 和权重 scale 的乘积,再除以输出 scale。如果你在转换模型时没有正确传递这些参数,NPU 算出来的结果就会整体偏移,表现就是“模型没报错,但输出全是乱的”。
我踩过的一个坑是:某个框架在导出量化模型时,默认把zero_point设成 0,但实际校准出来的zero_point是 -3。结果就是 NPU 推理结果和 CPU 参考实现差了 5% 的精度。排查了两天才发现是量化参数没对齐。所以你在做端侧部署时,一定要拿一个小的校准集,把 NPU 输出和 CPU 浮点输出做逐层对比,不要只看最终精度。
2.3 张量的生命周期:从分配、复用到释放
端侧设备的内存通常很紧张,尤其是那些带 NPU 的 MCU 或者低端 SoC,可用内存可能只有几百 KB。这时候张量的内存管理就成了关键。一个典型的推理流程里,张量的生命周期是这样的:
- 输入张量从外部缓冲区拷贝进来,或者直接映射到 NPU 可访问的内存。
- 中间张量在每一层计算时产生,如果每一层都新分配内存,峰值内存会非常高。
- 输出张量写回外部缓冲区。
成熟的端侧推理框架会做内存复用。原理很简单:如果张量 A 在第二层之后就不再被使用,张量 B 在第三层才产生,那么 A 和 B 可以共享同一块内存。框架会通过静态分析计算出每个张量的活跃区间,然后做内存池的分配。
但这里有个坑:NPU 通常要求输入输出内存是物理连续的,而且有对齐要求,比如 64 字节或 128 字节对齐。如果你用普通 malloc 分配,可能拿到的是非对齐地址,NPU 直接报错或者性能暴跌。所以端侧部署时,内存分配器往往要自己实现,或者用框架提供的专用分配器。
提示:在做内存复用规划时,一定要把 NPU 的对齐要求考虑进去。我见过一个项目,因为中间张量没有按 128 字节对齐,NPU 的 DMA 效率掉了 40%,推理时间从 8ms 涨到 13ms。
3. NPU 的硬件脾气:它到底擅长什么、怕什么
3.1 MAC 阵列:NPU 的计算核心
NPU 和 CPU 最大的区别在于计算单元的组织方式。CPU 有少量的通用 ALU,靠高主频和乱序执行来提升性能;NPU 有大量的 MAC(乘加)单元,靠并行度来提升吞吐。一个典型的 NPU 可能包含 256 个或 1024 个 MAC 单元,排成二维阵列,每个周期能完成数千次乘加运算。
以卷积为例。一个 3x3 的卷积核在某个通道上滑动,每个位置做 9 次乘加。如果 NPU 有 256 个 MAC,理论上一个周期能算 256 个乘加。但前提是数据要能及时喂进来。如果数据带宽跟不上,MAC 阵列就会空转。这就是为什么 NPU 的性能瓶颈往往不在计算,而在数据搬运。
我实测过一款带 NPU 的芯片,理论算力是 1 TOPS(每秒一万亿次操作),但跑一个 MobileNetV2 的时候,实际利用率只有 30% 左右。原因就是输入特征图太大,DMA 搬运时间超过了计算时间。后来把输入分辨率从 224 降到 160,利用率才提到 55%。所以你在选型时,不要只看 TOPS 这个数字,要看它的内存带宽和 DMA 能力。
3.2 算子支持度:NPU 不是万能的
NPU 的算子支持是有限的。它通常对卷积、全连接、池化、激活这些常见算子有硬件加速,但对一些特殊算子,比如Gather、Scatter、TopK、动态 shape 的操作,支持就很差,甚至完全不支持。这时候框架会做算子回退,把不支持的算子放到 CPU 上跑。
回退本身没问题,但问题在于数据搬运。如果 NPU 和 CPU 之间频繁切换,每次切换都要把张量在两边内存之间拷贝,开销可能比计算本身还大。我见过一个模型,因为中间有一个Resize算子不支持,导致整个网络被切成三段,NPU 和 CPU 来回切换了六次,推理时间比纯 CPU 还慢。
所以你在做模型设计或者选型时,一定要先确认目标 NPU 的算子支持列表。如果模型里有大量不支持的算子,要么换模型结构,要么接受性能损失。常见的做法是:在模型训练阶段就把不支持的算子替换成等效的支持算子。比如用Conv2D加Resize的組合来替代某些上采样操作,或者用固定 shape 替代动态 shape。
3.3 多核与多 NPU 的协同
高端一点的端侧芯片可能有多个 NPU 核心,或者 NPU 和 GPU、DSP 共存。这时候就涉及任务划分的问题。一个常见的策略是:把卷积层放在 NPU 上,把后处理逻辑放在 CPU 上,把一些图像预处理放在 GPU 或 DSP 上。
但多核协同的难点在于同步。如果 NPU 算完一层,CPU 要等它完成才能做后处理,这个等待时间就是浪费。理想情况下,应该做流水线并行:NPU 算第 N 层的时候,CPU 已经在处理第 N-1 层的后处理了。但这需要框架支持异步执行和事件通知机制。
我在一个视频分析项目里用过这种方案。NPU 负责推理,CPU 负责解码和画框,GPU 负责缩放。通过双缓冲和事件回调,整体帧率比串行执行提升了 40% 左右。但代码复杂度也上去了,调试起来很痛苦。如果你的团队没有足够的底层经验,建议先从单 NPU 串行做起,稳定之后再考虑并行。
4. 从张量到指令:一次完整的端侧推理拆解
4.1 图优化:在张量进入 NPU 之前
模型转换的第一步是图优化。框架会读取原始模型(比如 ONNX 或 TFLite),然后做一系列 pass:
- 常量折叠:把编译期就能算出来的子图提前算好,减少运行时计算量。
- 算子融合:把
Conv + Bias + ReLU融合成一个算子,减少中间张量和内存访问。 - 布局转换:把 NCHW 转成 NHWC,或者插入 transpose 算子来适配 NPU 的偏好。
- 量化插入:在合适的位置插入量化/反量化节点,把浮点图转成整数图。
这些 pass 的顺序很重要。比如你先做算子融合再做量化,融合后的算子可能没有对应的量化版本,就得回退。通常的做法是:先做与量化无关的优化,再做量化感知的优化,最后做布局调整。
我建议你在转换模型时,把每一步的中间图都 dump 出来看看。很多框架提供了--dump_graph之类的选项。这样你能清楚地看到哪些算子被融合了,哪些被回退了,哪些张量被插入了 transpose。这些信息对后续性能调优非常关键。
4.2 内存规划与张量分配
图优化之后,框架会做内存规划。这一步的目标是:在满足 NPU 对齐要求的前提下,用最少的内存容纳所有张量。
具体做法是:先分析每个张量的生命周期,然后做内存复用。假设有四个张量 A、B、C、D,生命周期分别是 [0,2]、[1,3]、[2,4]、[3,5]。那么 A 和 C 可以共享内存,B 和 D 可以共享内存。框架会用一个内存池来管理这些块,每个块的大小和对齐都按最严格的要求来。
这里有个细节:NPU 的输入输出张量通常需要是物理连续的,但中间张量可以是虚拟连续的,只要 stride 满足要求就行。所以内存规划时,输入输出要单独分配,中间张量可以放在一个大的内存池里。
注意:如果你的模型有动态 shape,内存规划会变得非常复杂。很多 NPU 不支持动态 shape,或者只支持有限的动态范围。这时候你可能需要把动态 shape 固定下来,或者做多份编译。
4.3 指令下发与执行
内存规划完成后,框架会生成 NPU 能执行的指令序列。这些指令通常包括:
- DMA 指令:把数据从外部内存搬到 NPU 的本地内存(比如 SRAM)。
- 计算指令:配置 MAC 阵列,指定输入输出地址、卷积参数、激活函数等。
- 同步指令:等待某个 DMA 完成,或者触发一个事件。
这些指令会被写成一个 command buffer,然后一次性提交给 NPU 驱动。NPU 驱动负责把指令翻译成硬件寄存器操作,然后启动 NPU。
这里的关键是流水线。如果 DMA 和计算是串行的,NPU 的利用率会很低。好的框架会把 DMA 和计算重叠起来:在计算第 N 层的时候,DMA 已经在搬运第 N+1 层的数据了。这需要双缓冲或者多缓冲的支持。
我实测过一个优化良好的推理引擎,在同样的硬件上,比朴素实现快了 2.3 倍。差距主要就在流水线上。所以你在评估一个端侧推理框架时,不要只看它支持多少算子,要看它的调度器是否支持 DMA 和计算的重叠。
4.4 一个具体的计算示例:3x3 卷积在 NPU 上怎么跑
假设我们有一个输入特征图,shape 是[1, 32, 56, 56],NHWC 布局,int8 量化。卷积核是 3x3,输出通道 64,stride 1,padding 1。
NPU 的执行流程大致如下:
- DMA 把输入特征图从外部内存搬到 NPU 的 SRAM。大小是 1 * 32 * 56 * 56 = 100352 字节,约 98 KB。
- DMA 把权重搬到 NPU 的权重缓存。大小是 3 * 3 * 32 * 64 = 18432 字节,约 18 KB。
- NPU 配置卷积参数:输入通道 32,输出通道 64,卷积核 3x3,stride 1,padding 1。
- NPU 开始计算。每个输出位置需要 3 * 3 * 32 = 288 次乘加。输出特征图大小是 1 * 64 * 56 * 56 = 200704 个元素。总乘加次数约 57.8 百万次。
- 如果 NPU 有 256 个 MAC,理论需要 57.8M / 256 ≈ 225K 个周期。假设主频 500 MHz,理论计算时间约 0.45 ms。
- 但实际时间还要加上 DMA 搬运时间。如果 DMA 带宽是 1 GB/s,搬运 98 KB 需要约 0.1 ms。如果 DMA 和计算不能重叠,总时间就是 0.55 ms;如果能重叠,总时间接近 0.45 ms。
这个计算说明了一个问题:当计算量不够大时,DMA 开销占比很高。所以对于小模型或者浅层网络,NPU 的优势可能不明显,甚至不如 CPU。只有当计算密度足够高时,NPU 的并行优势才能体现出来。
5. 端侧部署实操:从模型到设备的完整流程
5.1 模型准备与格式转换
第一步是拿到一个干净的模型。如果你是从 PyTorch 或 TensorFlow 训练出来的,先导出成 ONNX 或 TFLite。导出时要注意:
- 固定输入 shape,避免动态维度。
- 去掉训练专用的节点,比如 Dropout、BatchNorm 的训练模式。
- 确认算子版本,有些新算子老框架不支持。
然后做量化。量化的方式有两种:训练后量化(PTQ)和量化感知训练(QAT)。PTQ 简单,但精度损失可能较大;QAT 精度好,但需要重新训练。对于端侧部署,我通常建议先试 PTQ,如果精度不达标再上 QAT。
PTQ 的关键是校准集。校准集不需要很大,几百张图片就够了,但必须覆盖真实场景的分布。我见过一个项目,校准集全是白天图片,结果模型在夜间场景下精度暴跌。所以校准集一定要有代表性。
5.2 使用 C# 创建 OpenVINO 输入张量
如果你在用 OpenVINO 做端侧部署,C# 是一个常见的选择,尤其是在 Windows 或 Linux 的桌面端应用里。下面是一个创建输入张量的示例:
using OpenVinoSharp; // 假设模型输入是 [1, 3, 224, 224],fp32 var shape = new long[] { 1, 3, 224, 224 }; var inputTensor = new Tensor(shape, ElementType.Float32); // 填充数据 float[] imageData = LoadAndPreprocessImage("test.jpg"); inputTensor.SetData(imageData); // 获取推理请求并设置输入 var inferRequest = compiledModel.CreateInferRequest(); inferRequest.SetInputTensor("input", inputTensor); inferRequest.Infer();这里有几个细节:
shape的顺序要和模型定义一致。OpenVINO 默认是 NCHW,如果你的模型是 NHWC,要么在转换时改,要么在这里做 transpose。SetData会做一次内存拷贝。如果数据量大,可以考虑用GetData拿到指针直接写,避免拷贝。- 如果模型是量化过的,输入张量的类型可能是
UInt8或Int8,这时候预处理要做对应的量化。
提示:在 C# 里做图像预处理时,尽量用
Span<T>或者Memory<T>来避免不必要的数组分配。端侧设备上 GC 压力很敏感,频繁分配大数组会导致卡顿。
5.3 在设备上做性能剖析
模型跑起来之后,下一步是剖析性能。你需要知道时间花在哪里:是预处理、推理、还是后处理?
常用的工具:
- OpenVINO 的 benchmark_app:可以测推理时间和吞吐。
- perf 或 ftrace:可以看 CPU 和 NPU 的调度情况。
- 自定义计时:在代码里插桩,记录每个阶段的时间。
我通常会在代码里加一个简单的计时器:
var sw = Stopwatch.StartNew(); // 预处理 sw.Stop(); Console.WriteLine($"Preprocess: {sw.ElapsedMilliseconds} ms"); sw.Restart(); // 推理 inferRequest.Infer(); sw.Stop(); Console.WriteLine($"Inference: {sw.ElapsedMilliseconds} ms");如果推理时间远大于预期,先检查是不是有算子回退。OpenVINO 提供了GetPerformanceCounts接口,可以看到每个层在哪个设备上执行。如果发现大量层在 CPU 上,说明 NPU 不支持这些算子,需要做模型调整。
5.4 多线程与异步推理
端侧设备通常有多个核心,合理利用多线程可以提升吞吐。OpenVINO 支持异步推理,你可以同时提交多个推理请求,让 NPU 和 CPU 并行工作。
var inferRequests = new InferRequest[2]; for (int i = 0; i < 2; i++) { inferRequests[i] = compiledModel.CreateInferRequest(); } // 异步提交 inferRequests[0].StartAsync(); inferRequests[1].StartAsync(); // 等待完成 inferRequests[0].Wait(); inferRequests[1].Wait();但异步不是越多越好。如果 NPU 只有一个计算核心,提交太多请求只会增加调度开销。我一般会先测一下单请求的延迟,然后根据延迟和帧率要求来决定并发数。比如延迟是 10ms,要求 30fps,那么至少需要 1 个请求;如果要求 60fps,就需要 2 个请求。
6. 常见问题与排查技巧实录
6.1 推理结果不对:从量化参数查起
这是最常见的问题。模型能跑,但输出全是乱的,或者精度差很多。排查顺序:
- 检查量化参数:确认 scale 和 zero_point 是否正确传递。可以用一个简单的输入,对比 CPU 浮点输出和 NPU 量化输出。
- 检查布局:确认输入张量的布局和模型定义一致。NCHW 和 NHWC 搞反了,结果肯定不对。
- 检查预处理:确认归一化参数、通道顺序、resize 方式是否和训练时一致。
- 检查算子回退:如果某些层回退到 CPU,确认回退层的输入输出是否正确。
我遇到过一个案例:模型在 NPU 上跑,输出全是 0。查了半天发现是输入张量的zero_point设错了,导致所有输入都被量化成了 0。所以量化参数一定要仔细核对。
6.2 性能不达标:先看 DMA,再看计算
如果推理时间比预期长很多,按这个顺序排查:
| 排查项 | 可能原因 | 解决方法 |
|---|---|---|
| DMA 带宽 | 数据搬运量太大 | 减少中间张量,做算子融合 |
| 算子回退 | NPU 不支持某些算子 | 替换算子或调整模型结构 |
| 内存对齐 | 张量地址未对齐 | 使用专用分配器,确保 64/128 字节对齐 |
| 流水线 | DMA 和计算未重叠 | 启用双缓冲,调整调度策略 |
| 频率 | NPU 降频 | 检查散热和功耗策略 |
我实测过一个模型,推理时间从 20ms 降到 8ms,只做了一件事:把中间张量的内存对齐从 4 字节改成 128 字节。所以对齐这件事,看起来小,影响很大。
6.3 内存不足:张量复用与释放策略
端侧设备内存有限,如果模型太大,可能会 OOM。解决方法:
- 减少 batch size:如果 batch 是 4,改成 1,内存直接降四分之三。
- 降低输入分辨率:从 224 降到 160,中间张量大小降一半。
- 使用内存复用:确保框架开启了内存复用。
- 分段推理:把模型切成几段,分段加载权重,分段推理。
分段推理的代价是延迟增加,因为每段之间要保存和恢复中间状态。但如果内存实在不够,这是唯一的办法。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 推理报错 | 输入 shape 不匹配 | 打印模型输入 shape 和实际输入 shape |
| 输出全 0 | 量化参数错误 | 检查 scale 和 zero_point |
| 输出偏移 | 布局错误 | 检查 NCHW/NHWC |
| 精度下降 | 量化损失 | 用校准集对比浮点和量化输出 |
| 速度慢 | 算子回退 | 用性能剖析工具查看每层执行设备 |
| 内存溢出 | 张量未复用 | 检查内存规划配置 |
| NPU 不工作 | 驱动或权限问题 | 检查设备节点和驱动版本 |
提示:遇到问题时,先用最小可复现的模型做测试。比如用一个只有一层卷积的模型,确认 NPU 能正常工作,再逐步加层。这样能快速定位是哪一层出了问题。
7. 一些实操心得与后续扩展方向
做端侧 AI 部署这几年,我最大的体会是:不要迷信算力数字,要相信实测数据。一个标称 4 TOPS 的 NPU,在实际模型上可能只跑出 1 TOPS 的有效算力。差距就在数据搬运、算子支持、内存对齐这些细节上。
另一个体会是:量化不是万能的,但不量化是万万不能的。端侧设备的带宽和内存决定了,fp32 模型很难跑出好性能。但量化带来的精度损失需要认真对待,尤其是对于检测、分割这类对位置敏感的任务。我的建议是:先在 PC 上做量化仿真,确认精度可接受,再上设备。
后续如果继续深入,可以往这几个方向走:
- 混合精度:对敏感层用 fp16,对不敏感层用 int8,平衡精度和性能。
- 动态 shape:支持可变输入分辨率,适应不同场景。
- 多模型并行:在一个设备上同时跑多个模型,共享 NPU 资源。
- 自动调优:用工具自动搜索最优的量化参数和调度策略。
最后分享一个小技巧:在调试 NPU 问题时,把 NPU 的寄存器状态和 DMA 描述符 dump 出来看,往往能发现一些框架层面看不到的问题。虽然麻烦,但值得。