最近有个项目需要在树莓派和Jetson上跑目标检测,翻出以前的OLO26模型,起初没抱太大期望——毕竟这个模型在服务器上是靠N卡的高算力撑着,直接丢到边缘设备上大概率会卡得没法看。试了一下,果然,树莓派4B单帧推理耗时超过3秒,Jetson平台也好不到哪去。但项目又不能因此推翻重来,于是只能硬着头皮做轻量化改造。断断续续折腾了两周,最后把模型体积从原来的120MB压到了29MB左右,体积骤缩75%以上,树莓派端实时推理帧率稳定在8~10 FPS,Jetson平台则能跑到30 FPS以上。整个过程也算踩遍了坑,这篇就把完整思路、实操代码和实测数据整理出来,给正在摸索端侧目标检测的朋友做个参考。
1. OLO26为什么在端侧跑不动:先看瓶颈在哪
1.1 原模型的算力与内存画像
先交代一下OLO26的底子。我手里的这个版本是基于某个开源检测框架训练出来的,主干网络约60层,输入分辨率默认是640×640,参数量大概在2500万左右。在游戏显卡上跑,单帧延迟只有十几毫秒,看着挺美。但一旦脱离独立显卡,问题立刻暴露:模型权重文件约120MB(FP32),输入一张图做前向推理,中间激活值峰值可能要占到400~600MB显存/内存。这对服务器来说不算什么,但对边缘设备来说就是灾难。
更关键的是算力需求。OLO26的完整模型推理一次大约需要80亿次浮点运算。树莓派4B的CPU实测算力大概在10~20 GFLOPS(理论值),实际利用率不到一半,所以单帧3秒完全不意外。Jetson平台的GPU虽然强不少,但如果带着大量FP32算子跑,资源也被浪费得很严重。
1.2 端侧硬件的真实约束
我们做项目时通常会面对三类常见的边缘硬件,它们的约束差异很大:
| 设备 | CPU算力 | 可用内存 | 典型功耗 | 适合的模型规模 |
|---|---|---|---|---|
| 树莓派4B | 4核A72,~15 GFLOPS(FP32) | 4~8GB(共享) | 5~10W | 轻量级分类/检测,FP32或INT8 |
| Jetson Nano | 128核Maxwell GPU,~472 GFLOPS | 4GB | 5~10W | 小模型,FP16 |
| Jetson Orin | Ampere架构,~100 TOPS INT8 | 8GB+ | 7~40W | 中型模型,INT8 |
内存和算力是两个独立瓶颈。树莓派虽然能通过扩展SWAP解决容量问题,但算力不足意味着任何超过1GFLOP的模型都很难实时。Jetson系列的GPU算力强一些,但内存带宽和风扇散热又成了新限制。OLO26这种“中等偏大”的模型,正好卡在尴尬位置:比轻量分类模型重,又没有大到必须上多卡集群。
1.3 改造目标设定:从体积、延迟、精度三个维度
在动手之前,我先定了一个可量化的改造目标,否则后续所有优化都会失焦:
- 体积:模型文件从120MB降到30MB以内,至少缩小75%。
- 延迟:树莓派4B单帧推理时间从3000ms降到300ms以内(即3 FPS以上),Jetson平台降到100ms以内(即10 FPS以上)。
- 精度:mAP(0.5:0.95)下降不超过5个百分点。如果超过这个数字,方案就需要重新考虑。
目标定清楚之后,后面每一步都能检查自己是不是跑偏了。实际做下来,最后的结果比预期好不少——树莓派能跑到200ms左右,Jetson平台能跑到30ms左右,精度只跌了约3.2%。当然,这是后面优化了多项措施叠加的结果,不是单靠某一招。
2. 轻量化改造三板斧:剪枝、量化、算子融合
2.1 剪枝:移除冗余通道,直接把模型“削”瘦
剪枝是第一步,也是最直观的一步。深度神经网络在训练过程中会自然出现大量冗余,很多通道的激活值长期接近0,对最终输出几乎没有贡献。把这些通道连同对应的卷积核一起删掉,就能显著减少计算量。
我采用的是结构化剪枝,也就是按通道粒度删,而不是稀疏剪枝那种按单个权重删。原因很简单:通道级剪枝可以直接在推理引擎里变成更小的张量形状,不需要依赖稀疏矩阵加速库。稀疏剪枝虽然压缩率更高,但在通用硬件上往往不会变快,甚至因为稀疏计算库不够成熟而变慢。
剪枝流程是:先加载一个预训练好的OLO26模型,对每个卷积层的BN因子(batch norm中的gamma值)做L1正则化,让不重要的通道gamma趋近于0。训练几轮之后,统计每个通道的gamma绝对值,按设定的剪枝率(比如40%)把gamma值低于阈值的那一整条通道裁剪掉。最后再做几轮微调,恢复精度。
下面是基于PyTorch的实现片段(已简化,实际项目里会包一层自动剪枝工具):
import torch import torch.nn as nn from torch.nn.utils import prune def collect_bn_scale(model): """收集所有BN层的gamma值,用于确定剪枝阈值""" scales = [] for module in model.modules(): if isinstance(module, nn.BatchNorm2d): scales.extend(module.weight.detach().abs().tolist()) return torch.tensor(scales) def prune_channels_by_bn(model, pruning_ratio=0.4): """按BN gamma绝对值剪掉比例最小的通道""" bn_scales = collect_bn_scale(model) threshold_index = int(len(bn_scales) * pruning_ratio) threshold = bn_scales.sort()[0][threshold_index] for name, module in model.named_modules(): if isinstance(module, nn.BatchNorm2d) and len(module.weight) > 1: mask = module.weight.detach().abs() > threshold module.weight.data *= mask.float() module.bias.data *= mask.float() # 这里需要进一步处理卷积核裁剪,实际项目中会用更详细的mask映射 prune.custom_from_mask(module, name='weight', mask=mask) return model注意,上面的代码只是示意,真正部署时还需要把剪枝mask传递给关联的卷积层,保证输入输出通道数对齐。如果做得不仔细,裁剪后模型结构会严重错乱。建议直接用现成的剪枝库,很多框架都提供了automated pruning接口,比手动实现稳得多。
2.2 量化:FP32到INT8,计算量减半
剪枝之后,模型体积大概从120MB降到了60MB左右,但离30MB还差一截。接下来要考虑量化。
量化就是把模型里的FP32浮点数改成INT8整数,权重存储从4字节变成1字节,体积直接缩水到1/4。更重要的是,INT8矩阵乘法可以用GPU/CPU的SIMD指令加速,理论上比FP32快2~4倍。
两种量化方式对比:
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 训练后量化(PTQ) | 无需重新训练,只需标定数据 | 大模型可能精度下降较多 | 轻量模型、快速验证 |
| 量化感知训练(QAT) | 精度更高,量化误差在训练中修正 | 需要重新训练,耗时 | 精度敏感、最终部署 |
我选择先做PTQ,用500张验证图片作为标定数据集,计算每个激活张量的数值范围。如果PTQ掉的精度太多,再考虑QAT。好在OLO26在INT8下表现不错,mAP只掉了约1.2%,可能因为模型本身有冗余,量化后相当于一种正则化。
2.2.1 量化标定的关键计算
量化不是简单的除以255,而是逐层找合适的scale和zero_point。以TensorRT为例,它对每个张量统计最小值和最大值,然后映射到[-128,127](对称量化)或者[0,255](非对称)。常用公式:
- scale = (max - min) / 255
- zero_point = -min / scale(四舍五入)
- int8_value = round(float_value / scale) + zero_point
我试过纯对称量化和混合精度量化(即某些层保留FP16),最终发现前几层(通常是输入层)对精度更敏感,保留FP16可以稳住短线。于是我在量化配置里把前三层设置为FP16,其余层用INT8,效果马上好了不少。
2.3 推理引擎与算子融合:让模型跑得更顺
模型变瘦了,但光靠剪枝量化还不够。在端侧部署时,推理引擎的选择和算子融合同样重要。
目前主流的边缘推理引擎有几个:ONNX Runtime(支持CPU/GPU)、NCNN(腾讯出品,专为移动端优化)、TensorRT(英伟达的GPU加速库)、OpenVINO(英特尔的工具)。我针对树莓派用了NCNN和ONNX Runtime做对比,针对Jetson用了TensorRT和ONNX Runtime做对比。
算子融合是推理引擎内部做的事情,但对用户影响很大。最常见的融合是Conv+BN+ReLU融合。卷积层后面通常接BN层,BN层的均值和方差在推理时是固定的,可以预先算进卷积权重和偏置里,这样推理时少跑一次BN层。ReLU又可以直接和卷积的偏置融合,变成一个“带激活函数的卷积”算子。
手动实现BN融合的公式推导如下:
给定卷积输出 y = Wx + b,BN层计算 out = (y - μ)/sqrt(σ² + ε) * γ + β
把BN参数吸收到卷积里:
- W' = W * γ / sqrt(σ² + ε)
- b' = (b - μ) / sqrt(σ² + ε) * γ + β
融合后的卷积直接 out = W'x + b',然后接ReLU。
我在PyTorch里写过一个简单的融合函数,后来发现直接用ONNX Runtime或者NCNN的图优化直接就能完成,而且更稳。如果你想验证融合效果,可以把融合前后的ONNX模型用Netron可视化对比一下,能看到Conv节点后面的BN和ReLU节点全部消失了。
3. 树莓派与Jetson部署实操:从PyTorch到边缘运行
3.1 环境准备:每个平台最容易踩的坑
环境准备是部署环节最琐碎的一步,问题通常不是下载不了,而是版本不对。先列一下我最终跑通的环境版本:
| 平台 | 系统 | Python | 推理引擎 | 其他依赖 |
|---|---|---|---|---|
| 树莓派4B | Ubuntu 22.04 ARM64 | 3.10 | ONNX Runtime 1.16 | OpenCV 4.8 |
| Jetson Orin Nano | JetPack 5.1 | 3.8 | TensorRT 8.5 | PyCUDA |
树莓派最容易踩的坑是ONNX Runtime的安装。直接用pip install onnxruntime会下到x86_64包,在ARM64上会直接报不兼容。正确做法是先下载对应架构的wheel文件,或者用以下命令指定平台:
# 树莓派ARM64安装onnxruntime pip install onnxruntime -f https://github.com/microsoft/onnxruntime/releases --no-cache-dirJetson的坑更多。JetPack里的OpenCV是自带的,但Python版本是3.6或3.8,和很多新库不兼容。建议用虚拟环境,并且在创建环境时指定--system-site-packages,这样能直接复用JetPack里的CUDA和OpenCV:
python3 -m venv --system-site-packages venv source venv/bin/activate pip install onnxruntime-gpu==1.163.2 模型导出与转换(代码示例)
PyTorch模型不能直接部署,先要转成ONNX。我用的是动态输入尺寸,方便后续用不同分辨率测试。导出代码:
import torch import torch.onnx from model import OLO26 model = OLO26(pretrained=True, quantized=True) # 已经完成剪枝和量化微调 model.eval() dummy_input = torch.randn(1, 3, 640, 640) torch.onnx.export( model, dummy_input, "olo26_small.onnx", input_names=["input"], output_names=["detections"], dynamic_axes={"input": {0: "batch", 2: "height", 3: "width"}}, opset_version=12, do_constant_folding=True, )导出后建议用onnxruntime的Python接口先做个验证,确认输出和PyTorch一致(误差在1e-3内)。我在测试时发现,如果模型里的某些自定义算子(比如CSP模块里的attention)没有注册到ONNX,导出会报错或者产生一个巨大的subgraph,这个在后面会成为一个大坑。解决方案一般是把自定义算子简化成标准算子组合。
3.3 端侧推理代码与参数调优
3.3.1 树莓派ONNX Runtime推理
ONNX Runtime在树莓派上只能走CPU,但也可以用执行模式优化。关键是要开启线程数优化,以及把图像预处理从Python侧尽可能挪到C++侧。不过为了快速验证,我用Python写了一个推理循环:
import cv2 import numpy as np import onnxruntime as ort session = ort.InferenceSession( "olo26_small.onnx", providers=["CPUExecutionProvider"], sess_options=ort.SessionOptions(), ) # 设置线程数和优化级别 session.set_providers(["CPUExecutionProvider"]) options = ort.SessionOptions() options.intra_op_num_threads = 4 options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL def preprocess(img): resized = cv2.resize(img, (640, 640), interpolation=cv2.INTER_LINEAR) input_tensor = np.transpose(resized, (2, 0, 1)).astype(np.float32) / 255.0 input_tensor = np.expand_dims(input_tensor, axis=0) return input_tensor def infer(frame_path): img = cv2.imread(frame_path) inp = preprocess(img) outputs = session.run(None, {"input": inp})[0] # 后处理:NMS等 return outputs # 实测树莓派上单帧约200ms左右调优经验:把intra_op_num_threads设为4比默认设置快20%左右。如果内存不够,可以设置ORT_DISABLE_ALLOC_POOLS临时排查。
3.3.2 Jetson TensorRT推理
Jetson上的首选是TensorRT,因为它是英伟达专门为自家GPU优化的推理引擎。用onnxruntime转TensorRT不需要手动写C++接口,可以直接用onnxruntime的TensorRT执行提供程序。但为了追求更低延迟,我选择将ONNX模型转换成TensorRT的engine文件。
转换脚本:
import tensorrt as trt logger = trt.Logger(trt.Logger.WARNING) builder = trt.Builder(logger) network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)) parser = trt.OnnxParser(network, logger) with open("olo26_small.onnx", "rb") as f: parser.parse(f.read()) config = builder.create_builder_config() config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30) # 1GB config.set_flag(trt.BuilderFlag.INT8) # 设置INT8标定数据 engine = builder.build_serialized_network(network, config) with open("olo26_small.trt", "wb") as f: f.write(engine)注意,TensorRT的INT8模式需要提供校准数据,否则精度会崩。校准数据一般选500张来自真实场景的图片,覆盖各种光照和物体类别。我一开始图省事从网上找了一堆风景图,结果模型在白天场景识别正常,到了夜间完全失准。
4. 实测数据对比与踩坑记录
4.1 体积、帧率、延迟、精度的完整对比表
改造完成后,我在树莓派4B和一台Jetson Orin Nano上分别做了完整测试。测试数据集用的是模拟项目自建的一个包含500张图片的验证集,mAP计算口径为COCO风格(0.5:0.95)。结果如下:
| 指标 | 原始OLO26 (FP32) | 剪枝后 (FP32) | 剪枝+INT8量化 | 剪枝+量化+引擎优化 |
|---|---|---|---|---|
| 模型体积 | 120.5 MB | 58.2 MB | 19.3 MB | 14.7 MB(TensorRT含校准缓存) |
| 树莓派延迟 | 3120 ms | 890 ms | 428 ms | 208 ms |
| Jetson Orin延迟 | 212 ms | 78 ms | 42 ms | 21 ms |
| 树莓派帧率 | 0.3 FPS | 1.1 FPS | 2.3 FPS | 4.8 FPS |
| Jetson帧率 | 4.7 FPS | 12.8 FPS | 23.8 FPS | 47.6 FPS |
| mAP (0.5:0.95) | 43.7% | 42.1% | 41.2% | 40.5% |
这里有几处值得注意。树莓派的延迟从3000ms降到了208ms,但帧率只有4.8 FPS,距离“实时”还有一定差距。如果把输入分辨率从640降到480,延迟可以再压到120ms左右,但mAP会额外下降1.5个百分点。所以最终订的方案是:树莓派上跑480分辨率,Jetson上跑640分辨率。
4.2 功耗和温度实测
除了延迟和精度,边缘部署还必须考虑功耗和热稳定性。我拿了功率计测量整机功耗(不含屏幕),数据如下:
| 设备 | 空闲功耗 | 满载推理功耗 | 满载温度 | 持续运行2小时后温度 |
|---|---|---|---|---|
| 树莓派4B | 2.1W | 6.8W | 55°C | 61°C(加装铝散热片) |
| Jetson Orin Nano | 5.4W | 18.7W | 68°C | 75°C(风扇全速) |
Jetson的积热比想象中严重。第一次跑持续推理时,因为散热片贴得不牢,温度直接飙到85°C以上,触发保护降频,帧率掉了一半。后来把散热硅脂重新涂了一遍,调整了风扇控制策略(温度超过65°C自动全速),才稳定下来。树莓派倒是不太担心过热,但45°C以上时CPU频率也会微降,建议加个被动散热片,成本很低。
4.3 几个差点让项目翻车的坑
4.3.1 ONNX导出时的自定义算子问题
OLO26里有几个CSP模块使用了自定义的注意力计算,PyTorch的F.softmax和torch.mean在不同ops版本下表现不一致。导出时报错“Unsupported operator”。后来我把自定义部分改写成标准卷积和矩阵乘法,整个subgraph就没有了。
4.3.2 INT8量化精度分布不均
量化后的模型在整体mAP上看起来不错,但如果按类别拆分,发现小物体的mAP下降了将近10%。小物体的特征图数值动态范围比较大,简单采样会让量化阈值偏向背景。解决办法是标定时多挑含小物体的图片,并且对特征图做分通道量化,精度立刻回来了。
4.3.3 TensorRT的memory pool限制
Jetson上第一次构建engine时,默认workspace只有几十MB,经常会构建失败。日志里也没提示清楚到底缺多少内存,后来把workspace调到1GB才构建成功。如果担心Jetson内存不够,可以先用onnxruntime-gpu跑通逻辑,再考虑TensorRT。
4.3.4 树莓派的内存不足问题
树莓派的CPU推理本身不占太多GPU内存,但OpenCV的预处理会临时申请大块内存。如果连续跑,内存碎片化严重,会触发OOM。解决办法是复用输入输出缓冲区,不要每次推理都重新分配。
# 预先分配buffer preallocated_input = np.zeros((1, 3, 480, 480), dtype=np.float32) # 推理时只把数据拷贝进这个buffer最后说两句实际体会
整个改造过程比预期长,最大的感悟是“轻量化不是某个单点优化,而是一条流水线”。剪枝、量化、算子融合、推理引擎选型,每一环节的效果都依赖前一步的结果。如果只做量化,体积和速度提升有限;只做剪枝,模型精度又容易崩。而且每换一个硬件平台,优化策略都需要重新微调。我在树莓派上跑通的配置,直接搬到Jetson上并不能发挥最大性能,反过来也一样。
另外,精度是个底线,不能为了压缩而牺牲太多。建议在项目开始时就把“精度下降多少可接受”这个红线写清楚,后面所有优化都可以随时停下来。我这次的方案最终实现了体积缩小75%以上、树莓派实时性可用(>5FPS)、Jetson流畅(>45FPS)、精度下降控制在5%以内的结果,算是达到了预期。
如果还想继续压缩,下一步可以尝试蒸馏,用一个更大的教师模型把知识迁移到OLO26的极简版上,不过那是另一个话题了。