☰
OLO26模型边缘轻量化改造:从120MB到29MB的完整实践
2026/10/21 12:37:38 网站建设 项目流程

最近有个项目需要在树莓派和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算力可用内存典型功耗适合的模型规模
树莓派4B4核A72,~15 GFLOPS(FP32)4~8GB(共享)5~10W轻量级分类/检测,FP32或INT8
Jetson Nano128核Maxwell GPU,~472 GFLOPS4GB5~10W小模型,FP16
Jetson OrinAmpere架构,~100 TOPS INT88GB+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推理引擎其他依赖
树莓派4BUbuntu 22.04 ARM643.10ONNX Runtime 1.16OpenCV 4.8
Jetson Orin NanoJetPack 5.13.8TensorRT 8.5PyCUDA

树莓派最容易踩的坑是ONNX Runtime的安装。直接用pip install onnxruntime会下到x86_64包,在ARM64上会直接报不兼容。正确做法是先下载对应架构的wheel文件,或者用以下命令指定平台:

# 树莓派ARM64安装onnxruntime pip install onnxruntime -f https://github.com/microsoft/onnxruntime/releases --no-cache-dir

Jetson的坑更多。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.16

3.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 MB58.2 MB19.3 MB14.7 MB(TensorRT含校准缓存)
树莓派延迟3120 ms890 ms428 ms208 ms
Jetson Orin延迟212 ms78 ms42 ms21 ms
树莓派帧率0.3 FPS1.1 FPS2.3 FPS4.8 FPS
Jetson帧率4.7 FPS12.8 FPS23.8 FPS47.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小时后温度
树莓派4B2.1W6.8W55°C61°C(加装铝散热片)
Jetson Orin Nano5.4W18.7W68°C75°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的极简版上,不过那是另一个话题了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询