1. 为什么非得转成bmodel,不能直接用best.pt
1.1 best.pt是给GPU跑的,不是给NPU跑的
拿到yolo26训练完的best.pt,第一反应自然是本地验证一下mAP,跑个demo看看效果,都是在GPU或者CPU上用PyTorch框架做前向推理。但是一旦你想把它部署到边缘设备上,就是另一码事了。边缘设备上跑的推理芯片,比如算能系的那几款,不认识PyTorch的权重格式,更不支持直接把best.pt塞进去做inference。它只认自己的指令集,需要一种专门的模型二进制格式——bmodel。
很多人第一次接触会不理解:模型不就是一堆张量参数吗,换层皮不就行了?还真不是。PyTorch的推理过程包含完整的算子执行逻辑,框架一启动就带上了整套Python运行时和CUDA/Torch的依赖栈。而bmodel是一个静态编译产物,里面的每层计算都已经被映射成了目标芯片上可执行的指令序列,不再依赖任何外部框架,是真正“冷酷”的部署格式。这个过程有点像你把一份带有大量批注、依赖各种宏定义的C++源码,直接扔给嵌入式设备它看不懂,需要先经过编译器和链接器,变成可执行文件,bmodel就是那个可执行文件。
从best.pt到bmodel,核心路径是:PyTorch权重 -> ONNX -> MLIR -> bmodel。整个转换链路中,ONNX充当了中间交换格式,TPU-MLIR编译器负责把ONNX描述的计算图翻译成目标芯片的底层指令。
1.2 bmodel和TPU-MLIR到底是啥
bmodel是算能平台(BM168x系列芯片)专用的模型文件格式。它不是简单的权重打包,而是包含了模型结构、权重参数、算子执行顺序以及芯片调度信息在内的完整可执行包。在算能平台跑模型,无论你用的是BM1684、BM1684X还是BM1688,最终给到推理引擎的都是bmodel。可以理解成不同芯片平台各自有一套“方言”,bmodel就是这套方言的实词表。
而TPU-MLIR,就是生成bmodel的那套开源编译工具链。它基于MLIR框架,能读入ONNX、Caffe、TFLite等格式的模型描述,通过多级pass做算子映射、形状推断、量化信息注入,最后输出编译好的bmodel。这套工具链有一套自己的操作流程:先用model_transform.py把ONNX转成MLIR文件,再用model_deploy.py对MLIR文件做量化、编译,最后生成bmodel。
2. 准备工作:从best.pt导出干净的ONNX
2.1 Ultralytics环境下导出ONNX的具体命令
先说结论,ultralytics框架里导ONNX是最省力的,但它给你提供的是“一键导出”,却不是“干净导出”。我用的是ultralytics标准的CLI命令,但做了几个关键调整,避免后续踩坑。
yolo export model=best.pt format=onnx opset=11 dynamic=False simplify=True如果你直接在Python代码里导出,等价写法是这个:
from ultralytics import YOLO model = YOLO("best.pt") model.export(format="onnx", opset=11, dynamic=False, simplify=True, imgsz=[640, 640])这里有几个参数值得说清楚。
opset=11为什么用11而不是更新的16?TPU-MLIR对ONNX算子opset的支持范围目前覆盖到11左右最稳妥,新版opset里的一些算子语义变化,编译器未必跟得上。实测用opset=17导完的模型,在model_transform阶段大概率会碰到不支持的算子报错。宁可导低版本,后面省心。
dynamic=False强制固定输入尺寸。边缘设备部署最忌动态shape,动态shape会导致内存分配调度极度复杂,同时会显著拉低NPU利用率。bmodel对动态shape的支持非常弱,后续性能也会打折,所以导出时直接钉死成640×640。如果你的服务需要多分辨率,后面可以做多batch多分辨率版本,而不是用动态输入。
simpl=True是开启onnx-simplifier,会做常量折叠、去冗余节点、简化算子结构,减少最终onnx的计算图规模。这一步能让后续编译器工作压力小很多,尤其是在含有大量形状转换、拼接操作的检测模型上,效果很明显。
imgsz必须和训练时保持一致。如果训练用的分辨率是640,导出就写640。如果你训练时用了增强,涉及随机尺寸、马赛克等,那导出时的基准尺寸就以训练主尺度为准。
提示:导出的onnx会生成在当前工作目录下,名字是best.onnx。建议立即重命名,加上日期或版本号。转换过程中会衍生多个中间文件,命名混乱会让你在后面排查时怀疑人生。
2.2 拿到ONNX之后先做一次“体检”
导出ONNX只是第一步,接下来我用很短的时间跑一遍“体检”。别小看这一步,很多问题到MLIR阶段才爆出来,那时候定位成本高得多。
检查用的核心工具是onnxruntime,直接加载模型做一次推理,再用visualizer或者手动打印节点结构。核心检查三件事:输入输出张量名、输入输出shape、算子类型列表。
import onnx import onnxruntime as ort import numpy as np model = onnx.load("best.onnx") onnx.checker.check_model(model) print("opset:", model.opset_import[0].version) sess = ort.InferenceSession("best.onnx", providers=["CPUExecutionProvider"]) for inp in sess.get_inputs(): print("Input:", inp.name, inp.shape, inp.type) for out in sess.get_outputs(): print("Output:", out.name, out.shape, out.type) x = np.random.randn(1, 3, 640, 640).astype(np.float32) outs = sess.run(None, {sess.get_inputs()[0].name: x}) for out in outs: print("Out shape:", out.shape)理论上你应该看到一个输入,名字通常是images,shape是[1,3,640,640],三个输出,对应三个不同尺度的检测头。
这里有一个非常重要的操作偏好:输出里不应该有NMS/后处理节点。如果你导出的ONNX输出数量是1或包含NonMaxSuppression算子,就说明框架自动带了后处理封装,这种模型转bmodel会非常麻烦,建议重新导出,在export参数里去掉了NMS相关的选项。ultralytics的onnx导出默认是不带NMS的,但如果你用别的仓库训练的YOLO,这一步就要警觉。
2.3 导出前就该处理掉的算子问题
体检完算子列表后,我会关心有没有以下几类敏感算子。这些算子是TPU-MLIR支持得不好,或者完全没支持的:
第一类:NMS全家桶。ONNX里如果有NonMaxSuppression、NonZero、Gather等问题算子,转MLIR时十有八九会报错。即使编译器硬扛过去,生成的bmodel也会非常大,推理耗时极高。后处理在CPU上做就行,速度远快于NPU内部实现,而且灵活可控。
第二类:GridSample/GatherND这类高阶索引算子。凡涉及动态索引、动态形状的操作,编译器基本都歇菜。常见于一些加了可变形卷积(DCN)或者特殊采样机制的魔改版YOLO。如果任务精度要求没那么苛刻,建议在导出前就把这些算子替换成标准卷积或双线性插值。
第三类:一些没必要的Transpose/Reshape。虽然simplify能优化掉一部分,但多余维度变换仍然存在。这不影响能不能转成功,但会影响模型的分析效率,拖慢编译时间和内存占用。
我的原则很简单:能在导出前改的,不要拖到转换后改。因为一旦进了MLIR阶段,图被降级成底层指令,算子层面的问题就极其难定位了。
3. TPU-MLIR环境搭建与bmodel生成全流程
3.1 工具链获取:用Docker省去一堆依赖编译
TPU-MLIR的运行环境依赖一堆底层库,官方提供了Docker镜像环境,这是我最推荐的获取途径,你直接拉取官方tpu-mlir镜像就能获得完整编译环境。镜像拉下来之后,进入容器就有一整套环境变量预备好,不需要自己装什么。
docker pull tpu-mlir:latest docker run -v $PWD:/workspace -it tpu-mlir:latest进入容器后,验证一下环境是否就绪:
source /workspace/tpu-mlir/envsetup.sh model_transform --version model_deploy --version正常情况下会输出版本号。这里提醒一下,镜像版本和芯片型号有匹配关系,如果你用的是BM1684X,就选对应支持BM1684X的镜像版本,否则后面编译时容易报“unsupported chip”之类的错。我一般会在容器里先用model_transform跑一个小模型做冒烟测试,确认芯片支持列表正常,再开始正式转换。
3.2 ONNX转MLIR:model_transform.py的参数到底怎么填
环境就绪后,开始第一步:ONNX到MLIR。用TPU-MLIR提供的model_transform.py,这个脚本负责把ONNX模型解析成MLIR格式,同时完成输入形状指定、颜色空间转换配置、预处理参数绑定等。
命令模板如下:
model_transform \ --model_name yolov26 \ --model_def best.onnx \ --input_shapes "[[1,3,640,640]]" \ --pixel_format rgb \ --keep_aspect_ratio false \ --output_names "output1,output2,output3" \ --mlir yolov26.mlir参数逐一解释:
--model_name仅是命名,会作为后续所有输出文件的前缀。建议和你的模型版本对齐,比如yolov26_640_v1。
--input_shapes固定为[[1,3,640,640]]。这是batch=1、CHW标准格式。如果你的模型最终要部署为batch=4,这里就先写[[4,3,640,640]],或者后续用动态batch工具。建议第一次先跑batch=1,把链路跑通,再考虑batch合并。
--pixel_format rgb。这里必须和训练时的图像预处理对齐。训练时如果 normalized 输入(除以255),那这里填rgb——TPU-MLIR会在硬件层面自动完成HWC到CHW、RGB顺序调整以及反量化。如果你的预处理还涉及减均值除方差,那么还得配置mean和scale参数。YOLO训练标准一般就一个除以255,所以rgb即可。
--keep_aspect_ratio false。这个参数决定运行时是否保持宽高比。如果推理服务端会做letterbox预处理,那这里填false,让模型直接吃640×640的拉伸图。如果你希望模型内部自己处理letterbox,需要填true并使能芯片上的硬件缩放模块,但一般没必要,逻辑放在上位机CPU做更灵活。
这一步跑完,目录里会出现yolov26.mlir。我会顺手查看下输出信息里的算子统计,重点关注有没有“float_op”和“unknown_op”。有unknown说明有算子没识别出来,后面model_deploy必挂。
实操心得:model_transform阶段如果报shape不支持,九成是ONNX里有动态维度。回去重新导出ONNX,设dynamic=False,问题直接消失。
3.3 MLIR转bmodel:model_deploy.py与量化方式选择
MLIR文件生成之后,进入最核心的编译阶段。model_deploy.py会完成算子选择、指令生成、权重打包,最终输出bmodel。
基础命令(fp16推理)如下:
model_deploy \ --mlir yolov26.mlir \ --chip bm1684x \ --model yolov26_f16.bmodel \ --quantize F16这条命令生成的bmodel就是可推理的最终产物。在算能SDK环境下,直接加载并推理。但如果你追求极致性能,那就需要INT8量化:
model_deploy \ --mlir yolov26.mlir \ --chip bm1684x \ --model yolov26_int8.bmodel \ --quantize INT8 \ --calibration_table yolov26_calibration_table两个关键选择说明:
chip参数必须和实际目标芯片一致。BM1684X、BM1684、BM1688这几代芯片的指令集各不相同,不能混用。曾遇到过某项目拿BM1684X的bmodel去跑在BM1684上,直接加载失败。型号选错,后面全白干。
quantize选择F16还是INT8,取决于精度和速度的取舍。我的默认判断路径是:先跑F16,确保功能正确、精度预期正常,然后再做INT8量化对比。INT8的推理速度相比FP16一般会有明显提升,内存占用也更低,适合边缘端高吞吐场景。代价是精度可能漂移,尤其注意力机制相关的层对量化敏感,后面需要多校准几轮。
如果是FP32模型直接部署,也可以选F32。但检测模型转F32意义不大,F16精度的推理结果和FP32几乎无差别,速度却快一倍。所以除非你用FP32跑out了非常奇怪的错误,否则直接F16走起。
4. 精度不翻车:校准集、后处理与输出比对
4.1 INT8量化校准集怎么准备才靠谱
如果你跑INT8量化,就必须准备校准数据集。这个过程经常被轻视,但它对最终精度的影响甚至比模型结构还大。
TPU-MLIR的校准流程要求一组真实图片加上对应的标签标注,最终生成lmdb格式的校准集,供量化工具分析每层激活值的分布范围。一个常见误区是随便扔几十张网上找的图进去凑数。这样出来的量化模型,轻则mAP掉5个点,重则直接检测不出任何目标。
我做校准集的方法是:覆盖所有预期的部署场景,图片数量控制在300~500张。太少统计不出正确的激活值分布,太多编译时间暴涨收益却递减。具体操作上,从一个多月真实场景图像中随机抽取,并保证以下维度都有覆盖:不同光照条件(白天、黑夜、逆光)、不同目标大小(近距离大目标、远距离小目标)、不同背景复杂度。然后对每个图片用训练好的FP32模型先跑一遍前向,保存检测框,当做校准集的标签。这个方式能快速搞到和训练集分布一致的标注,省去大量标注时间。
校准集准备命令大致如下:
# 生成lmdb需要调用工具链里的convert_imageset工具 convert_imageset \ --images_path ./cali_images/ \ --labels_path ./cali_labels/ \ --output ./calibration_lmdb \ --image_width 640 \ --image_height 640校准集质量不行的直观反应是:INT8模型评估时,小目标显著变差,召回率骤降。排查方向先看校准集图片里有没有足够多的小目标样本。
4.2 后处理解码要自己写,千万别让模型带NMS
导出的ONNX是不带后处理的,所以bmodel推理得到的原始输出,还需要在后面做解码:将网格输出解析成边框坐标、置信度和类别,再做NMS。这部分工作落在CPU上位机代码里。
YOLO系列输出头的结构大致类似:每个输出头对应一个特征图尺度,每个位置有若干anchor,预测内容包括边界框坐标、objectness、各类别概率。用NumPy或者opencv做解码,性能完全够。举个例子,某个版本的输出shape是[1, 8400, 116],其中8400是三个尺度所有anchor点的总和,116代表每个候选框里的属性维度。解码时按anchor信息切成[1, 8400, 4]的框坐标、[1, 8400, 1]的置信度、[1, 8400, 80]的类别概率,然后做阈值过滤,再做NMS。
解码逻辑本身不复杂,但有一个点必须注意:输出数据的布局依赖转换时的设置。我的经验是,在部署时第一步就打印bmodel实际输出的shape和数值范围,导出一些固定样例数据来对比,确保解码是正确的。数值范围如果和PyTorch输出偏差巨大,猜再久也没意义,先查预处理是不是对齐了。
4.3 用余弦相似度和mAP卡住质量关
部署上线前,一定要做质量对比测试,不能只凭肉眼觉得“好像还行”。
我最常用的方法是双轨验证。第一轨:取100~200张典型测试图片,在GPU上用PyTorch推理得到检测结果;在边缘设备上用bmodel推理,比较两者输出的特征图或者解码后的边界框,算平均余弦相似度或IoU。第二轨:如果手头有标准标注集,直接跑mAP评估。
具体做法上,我会把bmodel推理输出和PyTorch输出拉回来逐层对比。TPU-MLIR提供了模型推理调试工具,但通常我更喜欢自己写一个简单的测试脚本:调ONNX模型把中间层输出导出来,再调bmodel拿到对应层输出,同位置比对。余弦相似度在0.99以上,基本稳了;低于0.95就要警惕,检查是否量化过度或预处理不匹配。
有些时候,FP16模型精度接近100%,但INT8只有92%,这时候我的取舍标准是:如果场景对精度不敏感(比如人流量统计),就直接INT8上线;如果场景需要精确识别车牌号或者小物体,就会采用混合量化方式,对敏感层保持FP16,其余层INT8。
5. 常见报错与性能调优实录
5.1 转换失败速查表(我踩过的坑)
整个转换链路里,报错信息和真实原因之间往往有一层迷雾。下面是我踩过的几个高频坑,直接整理成速查表。
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| model_transform报“Not supported op: XXX” | ONNX里有编译器不支持的算子 | 回ONNX阶段做simplify,或替换成标准算子后重新导出 |
| model_deploy报“internal compiler error” | 模型中存在动态shape或者极端的图结构 | 检查是否使用了dynamic输入,固定shape后重导ONNX |
| bmodel加载失败“chip mismatch” | chip参数和目标设备型号不匹配 | 确认设备型号,重新编译对应chip的bmodel |
| INT8精度下降严重 | 校准集质量差或敏感算子被量化 | 扩充校准集,尝试混合精度量化 |
| 推理结果边框偏移 | 预处理与训练不一致 | 主要检查RGB/BGR顺序、归一化系数、letterbox逻辑 |
| F16模型跑出NaN | 权重里有异常值,或网络层数值溢出 | 检查训练是否收敛,导出前重新评估best.pt |
报错里面最容易误导人的就是“Not supported op”。它提示的位置往往不是真正有问题的算子,而是编译器在处理到某个后续节点时才爆炸。我的排查顺序是:先定位到具体是哪个输出子图出了问题,看该子图里头尾算子,然后把ONNX里对应部分用Netron可视化出来,人工比对。如果还不行,就做一个剪枝实验:把最后有问题的输出头砍掉,只转换前半段,能转成功就说明问题定位到了。
5.2 推起来之后还能再榨的性能
bmodel编译成功只是第一步,部署后性能调优才是把设备潜力榨干的关键。算法层面我能给到几个方向。
第一个方向:batch合并。如果业务侧一次需要处理多张图,尽量合并成batch=4一起推理。NPU的并行计算特性决定了batch=4的耗时远小于batch=1的4倍,有时候只有单张的2倍多。这类优化收益最大。
第二个方向:多核并行与流水线。算能芯片有多核NPU,编译器会自动分配,但有些版本或者某些自定义编译参数会导致单核跑。检查一下bmodel的核数配置,如果确实只用了单核,可以指定多核编译参数重新生成。同时流水线设计上,把图像预处理(CPU)和模型推理(NPU)以及后处理(CPU)做成双缓冲队列,让三个环节重叠执行,吞吐能再上一个台阶。
第三个方向:省掉不必要的预处理。很多人的预处理代码里包含cv2.resize、cvtColor等,都很耗时。如果bmodel的输入是固定shape,且chip支持硬件缩放,可以利用芯片自带的JPEG解码和缩放模块,把图像解码和resize下沉到硬件,CPU侧开销能降到最低。实测这部分的收益在某些场景下比模型量化还明显。
最后千万记得:上线前检查热循环。边缘设备长期运行,内存泄露和抖动的排查难度比模型精度问题高一个量级。我当时用板子跑推理服务,连续跑了三天,发现内存占用稳步上涨,排查后发现是后处理阶段频繁申请numpy数组没有释放,最后统一改成对象池复用才解决。
从best.pt到bmodel的链路本质上是:把PyTorch动态、灵活、高精度但沉重的东西,压缩成一个让NPU高速运转、确定性强、轻快的静态产物。我个人在实际操作中的体会是:整个流程中最耗时间的往往不是命令本身,而是前期对模型的“洁癖”——导出前把不需要的算子摘干净、固定好shape、确认预处理路径。这些基础工作做得越扎实,后面越顺利。如果你正卡在某一步报错里,建议先别急着调参,把ONNX重新导一次,可能几个问题就一起消失了。