ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式老兵回归CNN:端侧AI部署从量化到NPU实战

嵌入式老兵回归CNN:端侧AI部署从量化到NPU实战

1. 为什么我选择从 CNN 重新切入端侧 AI

1.1 一个嵌入式老兵的困惑与回归

做了七八年嵌入式开发,从裸机寄存器到 Linux 根文件系统挂载,从按键非阻塞扫描到工装治具设计,我自认为对“资源受限”这四个字有足够深刻的理解。但这两年端侧 AI 的风口刮起来之后,我发现自己陷入了一种奇怪的状态:面试的时候能聊几句 NPU 算力、模型量化,但真让我把一个深度学习模型塞进一块只有几百 KB RAM 的 MCU 里跑起来,我心里是虚的。

问题出在哪?我复盘了很久。过去几年端侧 AI 的讨论几乎被 Transformer 和大模型占据,动辄几百兆参数、需要外部 DDR、要配 NPU 加速。这些内容当然有价值,但它们离嵌入式的本质——在极端受限的资源下做确定性的实时响应——越来越远。我意识到,如果连最基础的 CNN 卷积在端侧怎么落地都说不清楚,那谈再多端侧 AI 硬件部署都是空中楼阁。

所以我决定重新从 CNN 开始。不是因为它新,恰恰是因为它老、它经典、它的每一个计算环节都透明可控。CNN 是端侧 AI 里最适合拿来打地基的模型结构,理解了它,你再去碰 NPU、碰量化、碰端侧推理框架,心里才有底。这篇文章就是我这段时间重新梳理 CNN 到端侧部署的完整记录,适合那些有嵌入式基础、想往端侧 AI 方向转,但被各种新概念绕晕的同行。

1.2 CNN 在端侧 AI 里到底扮演什么角色

先把这个定位说清楚。端侧 AI 的模型结构大致可以分成几类:CNN 系(图像分类、目标检测、关键点)、RNN/LSTM 系(时序信号)、Transformer 系(大模型、多模态)。在嵌入式设备上,CNN 是落地最成熟、工具链最完善、算力需求最可控的一类。

为什么?因为 CNN 的核心操作是卷积,卷积的本质是乘加运算,而乘加运算恰好是 NPU 和 DSP 最擅长的事情。一个 3x3 的卷积核在特征图上滑动,每个位置做 9 次乘法和 8 次加法,这种规整的计算模式非常适合硬件加速。相比之下,Transformer 里的注意力机制涉及大量动态的矩阵运算和数据依赖,在端侧硬件上很难做到高效。

从应用场景看,CNN 在端侧覆盖了绝大多数实际需求:智能门锁的人脸检测、工业质检的缺陷识别、扫地机器人的障碍物分类、可穿戴设备的手势识别。这些场景的共同特点是:输入数据量不大(一张 224x224 的图或者一段加速度计时序)、对延迟敏感(必须实时响应)、功耗受限(电池供电)。CNN 在这些约束下依然能打,这就是我选择它的原因。

1.3 这篇文章能帮你解决什么问题

我不打算写成教科书。这篇文章的目标很明确:让你看完之后,能自己动手把一个 CNN 模型从训练到量化到部署到嵌入式设备上跑通。具体来说,你会看到:

  • CNN 的核心计算环节在端侧是怎么被拆解的,哪些操作是算力瓶颈,哪些是内存瓶颈
  • 模型量化到底在量化什么,为什么 int8 量化能把模型体积压到四分之一而精度几乎不掉
  • NPU 和 CPU 在端侧推理时的分工逻辑,什么时候该用 NPU,什么时候 CPU 反而更快
  • 一个完整的实操流程:从 PyTorch 训练一个小型 CNN,到 ONNX 导出,到量化,到在嵌入式 Linux 上跑推理
  • 我踩过的坑和对应的排查方法

如果你是有嵌入式 C 语言基础、了解 Linux 基本操作、但对深度学习部署还比较陌生的开发者,这篇文章就是为你写的。如果你已经做过端侧部署,也可以看看我在量化细节和 NPU 调度上的经验,或许能帮你省点时间。

2. CNN 核心计算环节的端侧拆解

2.1 卷积层:算力消耗的绝对大头

CNN 里最耗算力的就是卷积层。以一个标准的 3x3 卷积为例,输入特征图尺寸为 H×W×C_in,输出通道数为 C_out,那么这一层的乘加运算次数大约是 H×W×C_in×C_out×9。假设输入是 224×224×3,输出 64 通道,那这一层就是 224×224×3×64×9 ≈ 8.7 亿次乘加。这个数字在服务器上不算什么,但在主频几百 MHz 的嵌入式处理器上,就是实打实的瓶颈。

端侧部署时,卷积层的优化方向主要有三个。第一是减少通道数,用 1x1 卷积做通道压缩,这就是 MobileNet 系列深度可分离卷积的思路。第二是降低特征图分辨率,通过 stride 或者池化尽早下采样。第三是利用硬件加速,把卷积交给 NPU 或者 DSP 的 MAC 阵列。

我实测下来,在同样的精度要求下,深度可分离卷积相比标准卷积能减少 8 到 9 倍的算力,代价是精度可能掉 1 到 2 个百分点。这个 trade-off 在端侧通常是值得的,因为算力省下来意味着你可以跑更高的帧率或者更低的功耗。

2.2 激活函数与池化:容易被忽视的内存瓶颈

很多人只盯着卷积的算力,忽略了激活函数和池化的内存访问开销。ReLU 本身计算量很小,就是一次比较和取最大值,但它需要读写整个特征图。在端侧,内存带宽往往比算力更紧张。一个 224×224×64 的特征图,float32 存储就是 12.8 MB,如果每一层都要完整读写一遍,内存带宽很快就吃满了。

池化层也是类似的问题。最大池化需要读取一个窗口内的所有值再取最大,这个读取模式如果 cache 不友好,实际性能会远低于理论值。我在嵌入式 Linux 上跑推理时发现,把 ReLU 和前面的卷积融合在一起(也就是常说的算子融合),能减少大约 30% 的内存访问,推理速度提升 15% 左右。这个优化在 TensorFlow Lite 和 ONNX Runtime 里都有对应的 pass,部署时记得打开。

2.3 全连接层:参数量的隐形杀手

CNN 的最后几层通常是全连接层。以 VGG16 为例,第一个全连接层的参数量是 25088×4096 ≈ 1 亿,占了整个模型参数量的绝大部分。在端侧,全连接层的问题不是算力,而是参数量带来的存储压力。1 亿个 float32 参数就是 400 MB,这还没算中间激活值。

端侧部署时,全连接层通常会被替换成全局平均池化加一个小的全连接层。全局平均池化把 H×W×C 的特征图直接压成 1×1×C,参数量从 H×W×C×N 降到 C×N,降了几个数量级。这个改动对精度的损失很小,但对模型体积的压缩非常显著。我在一个手势识别项目里做过对比,用全局平均池化替换全连接后,模型从 80 MB 降到 3 MB,精度只掉了 0.5%。

2.4 端侧 CNN 的算力与内存预算表

为了让你有个直观的感受,我整理了一个常见端侧 CNN 模型在嵌入式设备上的资源占用对比。这些数据来自我在一块 ARM Cortex-A7 双核 800 MHz、512 MB RAM 的开发板上的实测。

模型参数量模型体积(float32)模型体积(int8)单次推理算力推理耗时(CPU)
LeNet-560K240 KB60 KB0.4 MFLOPs2 ms
MobileNetV14.2M16.8 MB4.2 MB569 MFLOPs180 ms
SqueezeNet1.2M4.8 MB1.2 MB350 MFLOPs120 ms
自定义小型 CNN200K800 KB200 KB15 MFLOPs8 ms

从表里能看出来,模型体积和算力差一个数量级,推理耗时也差一个数量级。端侧选模型的时候,不要一上来就上 MobileNet,先看看你的任务复杂度。很多嵌入式场景,一个 200K 参数的小 CNN 就够用了,推理只要几毫秒,功耗也低。

3. 模型量化:端侧部署的关键一步

3.1 量化到底在做什么

量化的本质是用更少的比特数来表示原本的浮点数值。float32 有 32 位,int8 只有 8 位,理论上模型体积能压到四分之一,内存带宽需求也降到四分之一。但量化不是简单地把浮点数截断成整数,那样精度会崩。真正的量化是找一个映射关系,把浮点数的动态范围线性映射到整数范围。

以对称量化为例,假设某一层的权重范围是 [-2.5, 2.5],要映射到 int8 的 [-127, 127],那么 scale = 2.5/127 ≈ 0.0197。量化后的权重就是 round(原始权重/scale),反量化就是量化值乘以 scale。这个过程中会有舍入误差,但只要 scale 选得合理,误差对最终精度的影响很小。

3.2 训练后量化与量化感知训练的选择

端侧部署时,量化有两条路:训练后量化(PTQ)和量化感知训练(QAT)。PTQ 是拿训练好的 float32 模型直接量化,不需要重新训练,操作简单,几分钟就能搞定。QAT 是在训练过程中模拟量化误差,让模型学会适应量化,精度通常比 PTQ 高 1 到 2 个百分点,但需要重新训练,周期长。

我的经验是:先试 PTQ,如果精度掉得不多(比如掉 1% 以内),就直接用。如果掉得厉害,再考虑 QAT。大多数端侧 CNN 任务,PTQ 就够了。但要注意,PTQ 需要一个校准数据集,通常从训练集里抽 100 到 500 张图就够了。校准数据集要覆盖实际部署时的数据分布,否则量化参数会偏。

3.3 量化实操:从 PyTorch 到 int8 模型

下面是我实际用的量化流程,基于 PyTorch 和 ONNX Runtime。假设你已经训练好了一个 CNN 模型,保存为 model.pth。

第一步,导出 ONNX 模型:

import torch import torch.onnx model = MyCNN() model.load_state_dict(torch.load('model.pth')) model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export(model, dummy_input, 'model.onnx', input_names=['input'], output_names=['output'], opset_version=11)

第二步,用 ONNX Runtime 做训练后量化:

from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic('model.onnx', 'model_int8.onnx', weight_type=QuantType.QInt8)

第三步,验证量化后的精度。这一步很关键,很多人量化完直接部署,结果精度崩了都不知道为什么。验证方法是用同一个测试集分别跑 float32 和 int8 模型,对比输出差异。

import onnxruntime as ort import numpy as np sess_fp32 = ort.InferenceSession('model.onnx') sess_int8 = ort.InferenceSession('model_int8.onnx') test_input = np.random.randn(1, 3, 224, 224).astype(np.float32) out_fp32 = sess_fp32.run(None, {'input': test_input})[0] out_int8 = sess_int8.run(None, {'input': test_input})[0] diff = np.abs(out_fp32 - out_int8).max() print(f'Max output diff: {diff}')

如果 diff 在 0.1 以内,通常没问题。如果超过 0.5,就要检查校准数据集或者考虑 QAT。

3.4 量化中的常见陷阱与应对

量化最常遇到的问题是对异常值敏感。如果某一层的权重或者激活值有一个特别大的离群点,scale 会被这个离群点拉大,导致其他正常值的量化精度下降。解决办法是在量化前做权重裁剪,把超过某个阈值的值截断。ONNX Runtime 的量化工具支持 percentile 参数,可以按百分位裁剪。

另一个坑是量化后的模型在某些算子上的支持问题。不是所有算子都有 int8 实现,遇到不支持的算子,量化工具会回退到 float32,导致模型里混着 int8 和 float32,推理时反而更慢。部署前一定要用工具检查一下量化覆盖率,确保关键算子都量化了。

4. NPU 与 CPU 的协同:端侧推理的硬件分工

4.1 NPU 到底加速了什么

NPU 的核心是 MAC 阵列,也就是乘加单元阵列。一个典型的 NPU 可能有 256 个 MAC 单元,每个时钟周期能完成 256 次乘加。相比之下,CPU 的 SIMD 指令一次可能只能做 4 到 8 次乘加。这就是 NPU 算力优势的来源。

但 NPU 不是万能的。它擅长的是规整的、数据并行的计算,比如卷积。对于控制流复杂、数据依赖强的操作,NPU 反而效率低。而且 NPU 通常有固定的内存层次,数据搬运的开销可能比计算本身还大。我在一个项目里对比过,同一个 MobileNet 模型,NPU 推理耗时 12 ms,CPU 推理耗时 180 ms,NPU 确实快了一个数量级。但换成一个小型自定义 CNN,NPU 耗时 3 ms,CPU 耗时 8 ms,差距就没那么大了,因为小模型的计算量太小,NPU 的启动和调度开销占比高。

4.2 什么时候该用 NPU,什么时候 CPU 更合适

我的判断标准是这样的:如果模型的算力超过 100 MFLOPs,优先考虑 NPU;如果低于 10 MFLOPs,CPU 可能更合适;中间地带要看具体硬件和框架的支持情况。

另外还要考虑功耗。NPU 在满载时功耗可能比 CPU 高,但在完成同样任务时,NPU 因为速度快,总能耗可能更低。这个需要实测,不能只看峰值功耗。我在一块带 NPU 的板子上测过,跑同一个模型,NPU 方案的总能耗比 CPU 方案低 40%,因为 NPU 在 12 ms 内就干完活了,CPU 要磨 180 ms。

4.3 端侧推理框架的选型对比

端侧推理框架的选择直接影响部署难度和性能。我主要用过三个:TensorFlow Lite、ONNX Runtime、NCNN。下面是我的对比。

框架优点缺点适用场景
TensorFlow Lite生态完善,量化工具成熟,NPU 委托支持好依赖 TensorFlow 生态,模型转换有时踩坑Android、Linux 端侧
ONNX Runtime模型格式通用,跨框架支持好,量化方便端侧优化不如 TFLite 激进多框架模型部署
NCNN纯 C++ 实现,无依赖,体积小,ARM 优化好量化工具相对简单,NPU 支持有限资源极度受限的嵌入式 Linux

如果你的板子有 NPU 并且厂商提供了 TFLite 委托,那 TFLite 是首选。如果是纯 CPU 推理,NCNN 在 ARM 上的性能往往最好。ONNX Runtime 适合模型来源多样、需要快速验证的场景。

4.4 NPU 部署的实操要点

以 TFLite 的 NPU 委托为例,部署流程大致是:先把模型转成 TFLite 格式,然后加载 NPU 委托库,最后创建解释器时指定委托。

import tflite_runtime.interpreter as tflite # 加载 NPU 委托 delegate = tflite.load_delegate('libnpu_delegate.so') # 创建解释器时指定委托 interpreter = tflite.Interpreter( model_path='model_int8.tflite', experimental_delegates=[delegate] ) interpreter.allocate_tensors()

这里有个坑:不是所有算子都能被 NPU 委托接管。如果模型里有 NPU 不支持的算子,TFLite 会自动回退到 CPU,但回退过程涉及数据在 NPU 和 CPU 之间搬运,开销很大。部署前一定要用 TFLite 的 benchmark 工具看一下有多少算子被委托了,如果委托率低于 80%,NPU 的加速效果可能还不如纯 CPU。

5. 完整实操:从训练到嵌入式部署

5.1 项目背景与模型设计

我拿一个实际做过的项目来演示:工业质检里的螺丝缺陷检测。输入是 128x128 的灰度图,输出是三类:正常、划痕、缺牙。数据集不大,每类 500 张,总共 1500 张。

模型设计上,我没有用现成的 MobileNet,而是自己搭了一个小 CNN。原因是输入分辨率低、类别少,大模型容易过拟合,而且算力浪费。我的模型结构是:两个卷积块(每个块是 3x3 卷积 + ReLU + 2x2 最大池化),然后全局平均池化,最后接一个三分类的全连接层。参数量大约 50K,模型体积 200 KB。

import torch.nn as nn class ScrewCNN(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.gap = nn.AdaptiveAvgPool2d(1) self.fc = nn.Linear(32, 3) def forward(self, x): x = self.features(x) x = self.gap(x) x = x.view(x.size(0), -1) return self.fc(x)

这个模型在服务器上训练 50 个 epoch,准确率能到 98%。训练细节这里不展开,重点讲部署。

5.2 模型导出与量化

训练完后,导出 ONNX 并量化:

import torch model = ScrewCNN() model.load_state_dict(torch.load('screw_cnn.pth')) model.eval() dummy = torch.randn(1, 1, 128, 128) torch.onnx.export(model, dummy, 'screw_cnn.onnx', input_names=['input'], output_names=['output'], opset_version=11) from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic('screw_cnn.onnx', 'screw_cnn_int8.onnx', weight_type=QuantType.QInt8)

量化后模型体积从 200 KB 降到 55 KB。用测试集验证,准确率从 98% 掉到 97.5%,完全可以接受。

5.3 嵌入式 Linux 上的推理部署

目标板是一块 ARM Cortex-A7 的开发板,跑的是嵌入式 Linux,根文件系统通过 NFS 挂载。部署步骤如下。

第一步,交叉编译 ONNX Runtime。这一步比较耗时,因为 ONNX Runtime 依赖比较多。我用的方法是先在 x86 上编译好 ARM 版本,然后拷贝到板子上。编译时记得打开 ARM NEON 优化。

./build.sh --config Release --arm --update --build \ --cmake_extra_defines ONNXRUNTIME_ENABLE_INSTRUMENT=OFF

第二步,把模型和推理程序拷到板子上。推理程序用 C++ 写,核心代码是加载模型、创建 session、跑推理。

#include <onnxruntime_cxx_api.h> Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "test"); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(2); session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); Ort::Session session(env, "screw_cnn_int8.onnx", session_options); // 准备输入 std::vector<float> input_data(1 * 1 * 128 * 128, 0.5f); std::vector<int64_t> input_shape = {1, 1, 128, 128}; auto memory_info = Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); auto input_tensor = Ort::Value::CreateTensor<float>( memory_info, input_data.data(), input_data.size(), input_shape.data(), input_shape.size()); // 跑推理 const char* input_names[] = {"input"}; const char* output_names[] = {"output"}; auto output_tensors = session.Run( Ort::RunOptions{nullptr}, input_names, &input_tensor, 1, output_names, 1);

第三步,实测性能。在板子上跑 1000 次推理,平均耗时 6 ms,峰值内存占用 8 MB。这个性能对于工业质检场景完全够用,产线速度是每分钟 300 个螺丝,也就是 200 ms 一个,6 ms 的推理时间绰绰有余。

5.4 部署后的性能调优

部署完不是终点,还有优化空间。我做了三件事。

第一,把输入图像的预处理从 float 转成 uint8,在 CPU 上做归一化,减少数据搬运。这一步省了大约 1 ms。

第二,调整 ONNX Runtime 的线程数。板子是双核,我把 IntraOpNumThreads 设成 2,比默认的单线程快了 40%。但设成 4 反而变慢,因为线程切换开销上来了。

第三,把模型输入从 128x128 降到 96x96。精度掉了 0.3%,但推理时间从 6 ms 降到 3.5 ms。对于产线场景,这个 trade-off 是值得的。

6. 常见问题与排查技巧实录

6.1 量化后精度暴跌怎么排查

精度暴跌是量化最常见的问题。我的排查顺序是:先看校准数据集,再看异常值,最后看算子支持。

校准数据集要覆盖实际数据分布。我有一次偷懒,用训练集的前 100 张做校准,结果这 100 张恰好都是正常样本,量化参数偏了,部署后划痕类全部误判。后来改成每类抽 100 张,问题解决。

异常值检查可以用 Netron 打开 ONNX 模型,看每一层的权重分布。如果某一层有特别大的离群点,考虑加权重裁剪。ONNX Runtime 的量化工具支持CalibrationMethod和Percentile参数,可以按百分位裁剪。

算子支持问题用onnxruntime.quantization.quantize_static的op_types_to_quantize参数控制,确保关键算子被量化。

6.2 NPU 委托失败或性能不升反降

NPU 委托失败通常有两个原因:算子不支持,或者委托库版本不匹配。先用 TFLite 的interpreter.get_tensor_details()看哪些算子被委托了。如果委托率低,考虑换模型结构,把不支持的算子替换成支持的。

性能不升反降的情况我也遇到过。原因是模型太小,NPU 的调度开销比计算本身还大。解决办法是增大 batch size,让 NPU 一次处理多张图,摊薄调度开销。或者干脆回退到 CPU,小模型在 CPU 上跑反而更稳。

6.3 嵌入式 Linux 上的内存与线程问题

嵌入式 Linux 上跑推理,内存和线程是两个容易出问题的地方。内存方面,ONNX Runtime 默认会预分配一大块内存池,在内存紧张的板子上可能直接 OOM。解决办法是设置session_options.DisableMemPattern()和session_options.SetExecutionMode(ORT_SEQUENTIAL),减少内存占用。

线程方面,如果板子是单核或者双核,线程数设多了反而慢。我的经验是线程数等于物理核心数,不要超。另外,如果推理程序和其他任务共享 CPU,考虑用nice或者cgroups限制推理进程的 CPU 占用,避免影响其他实时任务。

6.4 常见问题速查表

问题现象可能原因排查方法解决方案
量化后精度暴跌校准集分布偏检查校准集类别分布每类均匀采样
量化后精度暴跌权重异常值Netron 看权重分布加权重裁剪
NPU 加速无效算子不支持查委托率替换算子或回退 CPU
NPU 加速无效模型太小对比 CPU 耗时增大 batch 或回退 CPU
推理 OOM内存池预分配看内存占用曲线关闭内存模式
推理速度慢线程数不合理试不同线程数设为物理核心数
推理速度慢算子未融合看 graph 优化级别开启 ENABLE_ALL

7. 我在这条路上踩过的坑和几点体会

回过头看,从嵌入式转到端侧 AI,最大的障碍不是技术本身,而是思维方式的切换。嵌入式讲究的是确定性和实时性,每一个时钟周期都要算清楚。深度学习讲究的是概率和近似,精度掉一点没关系,速度上来就行。这两种思维在端侧 AI 里必须融合,但融合的过程很痛苦。

我踩过的最大的坑是过早追求大模型。一开始总想着把 ResNet、MobileNet 往板子上塞,结果不是跑不动就是功耗爆炸。后来想明白了,端侧 AI 的核心不是模型有多大,而是模型和场景匹配得有多好。一个 50K 参数的小 CNN,在特定任务上可能比 4M 参数的 MobileNet 更合适。

另一个体会是,量化不是可选项,是必选项。端侧部署不上量化,模型体积和内存带宽就是两道过不去的坎。但量化也不是万能的,它需要你对模型结构有理解,知道哪些层敏感、哪些层可以激进量化。这个判断力只能靠实际项目积累。

最后说一个具体的技巧:部署前一定要在目标硬件上做端到端的 benchmark,不要只看 PC 上的模拟结果。PC 上的 x86 和板子上的 ARM,性能特征完全不同。我在 PC 上测出来 2 ms 的模型,在板子上跑了 20 ms,差了十倍。原因就是 ARM 的 NEON 指令和 x86 的 AVX 指令在数据对齐和流水线上的行为不一样。只有实测,才能知道真实性能。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进