去年6月份拿到开发板到现在,转眼已经过去大半年了,这个博客11月初就在写,断断续续写到现在。C++部署需要考虑的问题很多,如果只给个简单部署教程的话,就算整理出来,感觉帮助也不大,各位开发时候我遇到的坑,你们也会重新踩一遍。这段时间我一直在思考作为开发者需要的是什么,应该如何安全的使用一套工具,要以何种方式呈现出来,要如何将一件事情清晰的说清楚。草稿改改变变,最后决定以大流程的形式,从量化到C++部署,进行一遍完整的梳理,整理一套流程,让各位安全、稳定地操作BPU。
BPU部署有几个经典大坑,这些坑说白了就是流程不规范各位多少都会遇见,下面这两个比较常见的问题都会在后续的详解中梳理清楚。
之前写教程时候一直以Python为主,是因为其代码简洁,方便各位理解流程及原理。但这一段时间总会有人问我怎么用C++落地,文档不易理解总是部署失败。在开发社区里有人提供了一个Cpp的教程,《动手实践之一个文件实现分割、检测cpp代码部署》,但Demo不足以了解整个部署的流程与思想,因此我后面也规范了下C++部署模型的流程。值得注意的是,官方API以C语言分割为主,因此我也将相关函数用C++二次包装,这样可以更好的使用相关的API,轻松带各位理解BPU的C++部署方式。特别地,我参考《Effective C++》设计了一套开源库WDR,让各位不需要在C++部署上花费太多精力,这个在后面细说。
既然是大流程,想带给各位的就是“知其然,知其所以然,知何由以知其所以然”。所以本博客作为BPU部署教程三部曲中的最后一部,目的是将部署流程刻在心里,真正成为自己算法落地的一项有效工具。后续相关BPU教程主要以调优或者与一些设备联动为主。
特别感谢晟哥、富哥、振兄、均兄和诺师弟的技术支持
目录这些内容:部署导图、每个阶段的构建流程是非常关键的。其他的内容,可以当作字典来使用,遇到问题找对应的内容去分析研究。
本博客关联的文件存放在https://github.com/Li-Zhaoxi/OpenWanderary中,整个过程依赖/生产的数据存放在百度云(提取码:0a09 )中的文件夹OpenWanderary/projects/torchdnn/data/unet
中,下载后直接复制到代码OpenWanderary对应位置即可。
首先,对于BPU部署,我希望每个开发人员都可以:
走一次就能部署成功,查一次就能定位问题
没有这个前提,就没有后面的一切。很多人在部署模型的时候,很难一下子就在BPU上成功启动,而且排查问题非常耗时,且麻烦。模型部署无非三个部分:模型文件、数据预处理、数据后处理。思路不难,工具链也不难用,我一直在尝试部署各种不同的模型,在思考到底是什么让模型部署变得这么复杂,而这个问题,实际上是最简单,也最容易忽略的,那就是部署规范化。规范化的目的就是解耦问题,在哪个步骤没通过质检,就说明这步骤是存在问题,直接focus这个地方修改bug即可,不用再回退前面的步骤去排查。
下面通过对比Pytorch和BPU的三个核心部分,来列出部署失败都有哪些风险项。PS:import torch
占用较多内存,在开发板中不适合安装torch。
基于这些,下面列出模型部署失败都有哪些可能性,一共9种潜在的问题,排查问题的成本较高。
所以,上面的风险,但凡一个发生了,模型在开发板上就不可能部署成功。因此,非常有必要将部署流程以及每一步的可靠性进行规范。
导图的目的是解除风险项质检的耦合,一步一步走踏实了,这样既可以快速定位问题,又可以安全可靠的一次就把事情做好。为了更好的理解这个大流程,请各位按序走完以下博客。做好Docker,理解何为模型转换,理解BPU在开发板的推理流程,利用提供的demo输出正确的结果。后面C++部署也是基于相似的推理流程。
规范化导图的设计改了多个版本,最终BPU整个部署大流程如下图所示,从Pytorch模型开始,到最终开发板BPU推理出目标结果为止,展示了整个环节需要处理的内容。整个流程看似节点较多,较为繁琐。但实际上,只需要重点关注绿色框相关内容,剩下部分就是固定化操作。在部署的整个流程中,切记要保持转换函数/模型的结果一致性,简单来说,在整个部署流程中,我们要保证预处理后处理函数,转换后的模型能够跟原始模型有一样的输出。
建议各位把这个图片保存到本地,对着后面的内容去理解,这样会更清晰哦~~~
整个流程的演示我以医疗应用为背景,基于UNet完成训练、量化、部署整个流程。PS:之前想以细胞分割来展示,但是GT视觉效果比较密恐,所以找师弟要了一套看来舒服一点的医疗数据集重新训练更换效果图。
projects/torchdnn
。datasets/008 - 2DMRACerebrovascular.zip
文件夹中。训练测试UNet我使用的指令如下,在projects/torchdnn
目录下执行:
python train.py --task baseline --fold 0 --train-gpus 0 \
--dataset=2DMRACerebrovascular --dataroot="D:/01 - datasets/008 - 2DMRACerebrovascular" \
--resultroot="D:/01 - datasets/008 - 2DMRACerebrovascular/Experiments" \
--train-batch-size=8 --train-workers=4 --name=unet
python test.py --task baseline --fold 0 --train-gpus 0 \
--dataset=2DMRACerebrovascular --dataroot="D:/01 - datasets/008 - 2DMRACerebrovascular" \
--resultroot="D:/01 - datasets/008 - 2DMRACerebrovascular/Experiments" 、
--test-save-flag=true --name=unet
每个阶段我都会分为构造流程和校验流程,如果部署到板端之后,没有得到期望结果,可以参考校验流程去定位问题。这部分重点在于部署/校验的思想,对于其他类型的输入,或者混合类型的输入需要注意下用法。
这个阶段需要输出三个关键项:ONNX模型、无torch依赖的预处理和后处理函数。对于ONNX的转换和推理方法,我在构造流程中给出了相关参考代码。
① Pytorch转ONNX模型。代码细节见torch2onnx.py,在torchdnn的根目录下执行python demos/unet/torch2onnx.py
。导出onnx的核心代码调用的是torch.onnx.export
,转换细节参考如下代码:
import os
import sys
sys.path.append(os.getcwd())
import cv2
import numpy as np
import torch
from networks.unet import UNet
import onnx
# 1. 加载Pytorch模型
dataroot = "data/unet"
modelpath = os.path.join(dataroot, "checkpoint_0.pth.tar")
net = UNet(3, 2) # 定义模型,参数1表示输入图像是3通道,参数2表示类别个数
net = torch.nn.DataParallel(net).cpu() # ※这行代码不能删,否则模型参数无法加载成功
state_dict = torch.load(modelpath)
net.load_state_dict(state_dict["state_dict"]) # 把参数拷贝到模型中
net.eval() # ※这个要有
# 2. 转换ONNX
onnxpath = os.path.join(dataroot, "unet.onnx") # 定义onnx文件保存目录
im = torch.randn(1, 3, 256, 256).cpu() # 定义输入变量,维度重要,内容不重要
# 下面是转onnx的基本配置,为了能够用在BPU上,按照下面这个方式配置参数即可
# 对于多输入输出的模型,参考连接:https://www.cnblogs.com/WenJXUST/p/16334151.html
# 下面这个Warning可以忽略不管
# Warning: Constant folding - Only steps=1 can be constant folded for opset >= 10 onnx::Slice op. Constant folding not applied.
torch.onnx.export(net.module,
im,
onnxpath,
verbose=False,
training=torch.onnx.TrainingMode.EVAL,
do_constant_folding=True,
input_names=['images'],
output_names=['output'],
dynamic_axes=None,
opset_version=11)
# 3. 检查ONNX,如果ONNX有问题,这里会输出一些日志
print('Start check onnx')
model_onnx = onnx.load(onnxpath)
onnx.checker.check_model(model_onnx)
② 构建无torch的预处理和后处理函数。深度学习模型输出前的处理和模型推理后的数据处理,难度并不大,这步构建一次即可,其他类似的需求也就是模型参数不同,构建后的预处理后处理记录在prepare_functions.py。
对于数据输入的预处理,无非就是resize、归一化、换维度等等。在我这套代码里,数据预处理部分代码如下所示,关联的变换为图像Resize、图像归一化、HWC变换为CHW
import albumentations as A
from albumentations.pytorch import ToTensorV2
# 基于torch的数据预处理:输入维度[h,w,c],返回的数据排布为[c,h,w]
def preprocess_torch(img, modelh, modelw):
transform = A.Compose([A.Resize(modelh, modelw),
A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
ToTensorV2()])
return transform(image = img)
在构造无torch依赖的预处理函数时要注意两点:
albumentations
和BPU的归一化参数的差异。在albumentations
中,归一化计算方式为 i m g = i m g − m e a n ⋅ m a x ( i m g ) s t d ⋅ m a x ( i m g ) img = \dfrac{img - mean \cdot max(img)}{std \cdot max(img)} img=std⋅max(img)img−mean⋅max(img), m a x ( i m g ) max(img) max(img)表示图像的像素最大值,一般为255。而BPU的归一化计算方式为 i m g = s c a l e b p u ⋅ ( i m g − m e a n b p u ) img = scale_{bpu}\cdot(img - mean_{bpu}) img=scalebpu⋅(img−meanbpu)。albumentations
归一化参数为 m e a n = ( 0.485 , 0.456 , 0.406 ) , s t d = ( 0.229 , 0.224 , 0.225 ) mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225) mean=(0.485,0.456,0.406),std=(0.229,0.224,0.225),则BPU的归一化参数为 m e a n b p u = m e a n ⋅ 255 = ( 123.675 , 116.28 , 103.53 ) , s c a l e b p u = 1 s t d ∗ 255 = ( 0.01712475 , 0.017507 , 0.01742919 ) mean_{bpu}=mean\cdot 255=(123.675,116.28,103.53), scale_{bpu}=\dfrac{1}{std * 255}=(0.01712475,0.017507,0.01742919) meanbpu=mean⋅255=(123.675,116.28,103.53),scalebpu=std∗2551=(0.01712475,0.017507,0.01742919)。# 无torch依赖的预处理函数:img为RGB通道,排布HWC
def preprocess(img: np.ndarray, modelh, modelw) -> np.ndarray:
img = cv2.resize(img, (modelw, modelh))# Resize图像尺寸
img = img.transpose(2, 0, 1) # 通道由HWC变为CHW
img = np.expand_dims(img, 0) # 增加一维,此时维度为1CHW
# 图像归一化操作
img = img.astype("float32")
mu = np.array([123.675,116.28,103.53], dtype=np.float32)
s= np.array([0.01712475,0.017507,0.01742919], dtype=np.float32)
for c in range(img.shape[1]):
img[:, c, :, :] = (img[:, c, :, :] - mu[c]) * s[c]
return img
对于模型推理输出数据的后处理,不同任务不一样,在当前二分类任务中,基于torch的后处理函数如下。
# 基于torch的数据后处理: 输入[b,c,h,w],输出[b,h,w]
def postprocess_torch(dataout: torch.Tensor) -> np.ndarray:
y = torch.nn.Softmax(dim=1)(dataout)[:, 1].cpu().detach().numpy()
pred = (y > 0.5).astype(np.uint8) * 255
return pred
将postprocess_torch
转换为无torch依赖的函数postprocess
。
def postprocess(outputs: np.ndarray) -> np.ndarray: # 输入[b,c,h,w],输出[b,h,w]
# 元素归一化到[0,1]之后,选择前景部分的数据
y_list = softmax(outputs, axis = 1)[:, 1, :, :]
# 大于0.5的就是前景
y_list = (y_list > 0.5).astype(np.uint8) * 255
return y_list
③ ONNX推理验证。到这里三个关键输出项都已经处理完成,这时候基于ONNX进行推理来验证这些项的有效性。代码细节见detect_onnx.py,在torchdnn的根目录下执行python demos/unet/detect_onnx.py
。
import os
import numpy as np
import cv2
import scipy
import onnxruntime
from prepare_functions import get_rgb_image, preprocess, postprocess
dataroot = "data/unet"
imgpath = os.path.join(dataroot, "mra_img_12.jpg")
onnxpath = os.path.join(dataroot, "unet.onnx")
# 加载图像和ONNX模型
img = get_rgb_image(imgpath) # 获取RGB的图像
sess_options = onnxruntime.SessionOptions()
sess_options.intra_op_num_threads = 4 # 设置线程数
session = onnxruntime.InferenceSession(onnxpath, sess_options = sess_options)
# ONNX推理
datain = preprocess(img, 256, 256)
inputs = {session.get_inputs()[0].name: datain} # 构建onnx输入,是个dict
outputs = session.run(None, inputs)
# outputs是个列表,记录了模型的所有输出,unet输出只有一个所以选择[0]
pred = postprocess(outputs[0])
for j in range(pred.shape[0]):
cv2.imwrite(os.path.join(dataroot, f"pred_onnx_b{j}.png"), pred[j].astype(np.uint8))
执行完代码之后,得到ONNX结果,看起来没啥问题,可以进行阶段2的相关数据转换了。
由于各种问题,转换必然存在各种Bug,如果ONNX推理验证之后,没有得到期望结果,可以从下面的3项来校验阶段1关联的数据/函数。其中,校验②③依赖校验①生成的数据,使用的时候请注意这一点。
python demos/unet/detect_torch.py
。python demos/unet/check_onnx_funs.py
。在部署前,各位已经拿到了模型的Pytorch文件,为了保证后续的有效性,这里需要构建一个建议的推理流程,验证模型的有效性。
在测试模型时,要注意代码一定要以CPU模式进行推理,不要用GPU进行推理,我测试时候发现两种模式推理结果的部分数据有0.004左右的精度差异,但这个精度差异并未影响最终pred。目前没确定具体原因,初步怀疑内部调用了不同的库导致的精度差异。
import os
import sys
sys.path.append(os.getcwd()) # 保证UNet可被import
import cv2
import numpy as np
import torch
from networks.unet import UNet
import albumentations as A
from albumentations.pytorch import ToTensorV2
from prepare_functions import get_rgb_image
# 这里贴上在构建流程中介绍的函数preprocess_torch和postprocess_torch
# ............................
dataroot = "data/unet"
# 1. Pytorch 模型
modelpath = os.path.join(dataroot, "checkpoint_0.pth.tar")
# 2. 模型加载
net = UNet(3, 2, 2)
net = torch.nn.DataParallel(net)
state_dict = torch.load(modelpath)
net.load_state_dict(state_dict["state_dict"])
net.eval()
net = net.module.cpu() # 一定要指定为CPU
# 3. 图像数据(模型以RGB为输入)
imgpath = os.path.join(dataroot, "mra_img_12.jpg")
img = get_rgb_image(imgpath)
# 4. 数据预处理
datain = preprocess_torch(img, 256, 256)['image'] # cxhxw
datain = torch.unsqueeze(datain, dim=0).cpu() # 1xcxhxw
# 5. 模型推理:推理输出维度[1,2,256,256]
dataout = net(datain)
# 6. 数据后处理:pred维度[1,256,256]
pred = postprocess_torch(dataout)
for j in range(pred.shape[0]):
cv2.imwrite(os.path.join(dataroot, f"pred_torch_b{j}.png"), pred[j].astype(np.uint8))
# 7. 保存校验数据
data = {"image": img,
"datain": datain.cpu().detach().numpy(),
"dataout": dataout.cpu().detach().numpy(),
"pred": pred}
np.savez(os.path.join(dataroot, "unet_checkstage1.npz"), **data)
此外,这里介绍下数据集格式和训练测试的脚本,如果各位有需求,可以训练自己的数据集。其中csv文件的生成利用了pandas
,生成文件部分的代码如下所示。如果需要修改数据集加载方式的话修改dataloaders/dataload.py中class DataFolder
。目前这套代码仅支持二分类问题,其他任务的支持请关注torchdnn文件夹里面的README。
import pandas as pd
csvpath = os.path.join(dsroot, "filenames.csv")
csvlist = list(zip(imgnames, gtnames))
filenamefile = pd.DataFrame(data=csvlist, columns=['imagename', 'gtname'])
filenamefile.to_csv(csvpath, index=False)
准备好相关的数据之后,可以利用如下脚本进行训练\测试,使用时候删掉\
和注释。
# 数据最终保存在{resultroot}/{task}/{name}/fold_{fold}文件夹中,dataset目前没啥用
python train.py --task baseline --fold 0 --name=unet --dataset=2DMRACerebrovascular \
--dataroot="D:/01 - datasets/008 - 2DMRACerebrovascular" \
--resultroot="D:/01 - datasets/008 - 2DMRACerebrovascular/Experiments" \
--train-gpus 0 --train-batch-size=8 --train-workers=4 \
python test.py --task baseline --fold 0 --name=unet --dataset=2DMRACerebrovascular \
--dataroot="D:/01 - datasets/008 - 2DMRACerebrovascular" \
--resultroot="D:/01 - datasets/008 - 2DMRACerebrovascular/Experiments" \
--train-gpus 0 \
--test-save-flag=true
ONNX模型的校验流程如下图所示,利用校验①的预处理数据,校验ONNX推理输出和理论推理输出的差异。
ONNX的模型校验代码在check_onnx_funs.py
中,用法在校验①之前说过了。核心代码如下所示,datain
和dataout
是校验①中保存的数据,如果onnx模型有效,则outputs[0]
应该与dataout
无明显差异,矩阵差异由函数check_matrix_equal
检查。
onnxpath = os.path.join(dataroot, "unet.onnx")
session = onnxruntime.InferenceSession(onnxpath)
inputs = {session.get_inputs()[0].name: datain}
outputs = session.run(None, inputs)
check_matrix_equal(outputs[0], dataout, 1e-4, dataroot, "onnx")
函数check_matrix_equal
定义在prepare_functions.py,用于检查两个矩阵的内容是否一致。check_matrix_equal
目前仅支持[2,3,4]维矩阵,且最后两个维度表示图像的行和列HW。函数会输出每一个 H ⋅ W H\cdot W H⋅W矩阵的检查结果,元素差异大于阈值thre
的像素点会被标记维白色,并保存到本地来可视化。各位可以参考下面这个代码来理解check_matrix_equal
:
def check_matrix_equal(src: np.ndarray, dst: np.ndarray, thre, saveroot, name):
assert isinstance(src, np.ndarray), f"src must be np.ndarray, but it is {type(src)}"
assert isinstance(dst, np.ndarray), f"dst must be np.ndarray, but it is {type(dst)}"
assert len(src.shape) in [2, 3, 4] and src.shape == dst.shape, f"the length of the shape must be 4 and the shapes must be equal. src: {src.shape}, dst: {dst.shape}"
if len(src.shape) == 4:
for idxb in range(src.shape[0]):
for idxc in range(src.shape[1]):
diff = np.abs(src[idxb, idxc, ...] - dst[idxb, idxc, ...])
if np.max(diff) < thre:
continue
imgerr = (diff >= thre).astype(np.uint8) * 255
print(f"Discovered an invalid matrix at (b:{idxb}, c:{idxc}), max diff: {np.max(diff)}")
cv2.imwrite(os.path.join(saveroot, f"err_{name}_{idxb}_{idxc}.png"), imgerr)
elif len(src.shape) == 3:
for idxb in range(src.shape[0]):
diff = np.abs(src[idxb, ...] - dst[idxb, ...])
if np.max(diff) < thre:
continue
imgerr = (diff >= thre).astype(np.uint8) * 255
print(f"Discovered an invalid matrix at (b:{idxb}), max diff: {np.max(diff)}")
cv2.imwrite(os.path.join(saveroot, f"err_{name}_{idxb}.png"), imgerr)
elif len(src.shape) == 2:
diff = np.abs(src - dst)
if np.max(diff) >= thre:
imgerr = (diff >= thre).astype(np.uint8) * 255
print(f"Discovered an invalid matrix, max diff: {np.max(diff)}")
cv2.imwrite(os.path.join(saveroot, f"err_{name}.png"), imgerr)
print(f"finish the check task: {name}")
以当前使用的UNet为例,output[0]
维度为[1,2,256,256],在batch:0, channel:0的位置处,出现了部分元素不匹配问题。输出信息为Discovered an invalid matrix at (b:0, c:0), max diff: 0.0001010894775390625
,表示不匹配的元素差异误差最大值为0.000101。这个差异并不验证,因此认为通过了ONNX校验。
这里image,datain,dataout,pred
都是校验①中生成的参考数据,调用函数check_matrix_equal
来检查构造的预处理/后处理函数的有效性,与onnx校验不同,这里一定要保证输出的结果不能有任何差异,如果有差异,就需要定位自己构造的函数的问题。
校验数据的核心代码如下所示,很简单,没有多余内容,check_matrix_equal
的描述参考校验②。
###### 检查预处理函数preprocess
datainsrc = preprocess(image, 256, 256)
check_matrix_equal(datainsrc, datain, 1e-4, dataroot, "preprocess")
###### 检查后处理函数preprocess
predsrc = postprocess(dataout)
check_matrix_equal(predsrc, pred, 1e-4, dataroot, "postprocess")
模型量化部分一定要看1.2节中给出的两个博客教程,这样才能了解这个阶段介绍的内容的目的。这个阶段需要输出的关键项为:
在构造流程的最后一段里,给各位一种不基于开发板的量化模型的检查方法→_→。
① 拆分预处理函数。
看到这里,还记得我在阶段1的构建流程里,说过要将归一化节点放在最后嘛,因为在这里,我们直接把归一化删掉即可,减少代码Bug风险。拆分后的预处理函数也同样记录在定义在prepare_functions.py中。
mean_value: '123.675 116.28 103.53'
,scale_value: '0.01712475 0.017507 0.01742919'
preprocess
的图像归一化操作部分扔掉,得到函数preprocess_calibration
。注意这里输入的图像是RGB哦。# 校验数据预处理函数, 注意输入的img是RGB通道
def preprocess_calibration(img: np.ndarray, modelh, modelw) -> np.ndarray:
img = cv2.resize(img, (modelw, modelh))# Resize图像尺寸
img = img.transpose(2, 0, 1) # 通道由HWC变为CHW
img = np.expand_dims(img, 0) # 增加一维,此时维度为1CHW
return img
transpose
就可以丢弃了,得到函数preprocess_onboard
。# 板端数据预处理函数, 注意输入的img是bgr通道
def preprocess_onboard(img: np.ndarray, modelh, modelw) -> np.ndarray:
img = cv2.resize(img, (modelw, modelh))# Resize图像尺寸
img = np.expand_dims(img, 0) # 增加一维,此时维度为1HWC
img = np.ascontiguousarray(img) # 板端的推理是封装的C++,安全起见这里约束矩阵内存连续
return img
② 转换ONNX为板端BIN模型。这里转换根之前介绍的博客流程一样,先把yaml和校验数据准备好后,开始走流程。校验数据的转换存放在prepare_calibratedata.py,在torchdnn
文件夹下输入python demos/unet/prepare_calibratedata.py
生成转换模型所用的标定数据。
import numpy as np
import cv2
import os
from prepare_functions import get_rgb_image, preprocess_calibration
dataroot = "data/unet"
imgroot = os.path.join(dataroot, "images")
calibroot = os.path.join(dataroot, "calibration")
for imgname in os.listdir(imgroot):
img = get_rgb_image(os.path.join(imgroot, imgname))
calibdata = preprocess_calibration(img, 256, 256) # 校验数据预处理函数
calibdata.astype(np.uint8).tofile(os.path.join(calibroot, imgname + ".rgbchw"))
下面是转换模型使用的yaml文件(其实这里非常建议各位train和rt都用一样的值,免得有其他问题),
model_parameters:
onnx_model: 'unet.onnx'
output_model_file_prefix: 'unet'
march: 'bernoulli2'
input_parameters:
# 校验数据的输入排布为NCHW RGB
input_type_train: 'rgb'
input_layout_train: 'NCHW'
# 前面说明的归一化参数放在这里
norm_type: 'data_mean_and_scale'
mean_value: '123.675 116.28 103.53'
scale_value: '0.01712475 0.017507 0.01742919'
# 板端的输入数据信息
input_type_rt: 'bgr'
input_layout_rt: 'NHWC'
calibration_parameters:
cal_data_dir: 'Calibration'
calibration_type: 'max'
max_percentile: 0.9999
compiler_parameters:
compile_mode: 'latency'
optimize_level: 'O3'
debug: False
core_num: 2
准备好这些之后,打开OE docker(docker相关的使用请查看[BPU部署教程] 一文带你轻松走出模型部署新手村),参考下面的指令挂载代码和数据文件夹,Windows系统记得删除\
并将指令合为一行。
docker run -it --rm \
-v "D:\05 - 项目\01 - 旭日x3派\horizon_xj3_open_explorer_v2.2.3_20220617":/open_explorer \
-v "D:\05 - 项目\05 - OpenWanderary\OpenWanderary\projects\torchdnn\data\unet":/data/horizon_x3/data \
-v "D:\05 - 项目\05 - OpenWanderary\OpenWanderary\projects\torchdnn\demos\unet":/data/horizon_x3/codes \
openexplorer/ai_toolchain_centos_7:v1.13.6
进入数据文件夹/data/horizon_x3/data
,模型转换数据将会存放在这里,前面步骤中构建的ONNX模型文件、Yaml配置文件、标定数据文件都存放在这个目录下。依次输入下述指定,每步都成功执行后,则得到最终转换后的bin模型。注意:模型转换中使用了绝对路径,如果路径有空格,会出错误。
hb_mapper checker --model-type onnx --march bernoulli2 --model unet.onnx
。相关日志文件名为hb_mapper_checker.log
hb_mapper makertbin --config unet.yaml --model-type onnx
。相关日志文件名为hb_mapper_makertbin.log
。最后我们需要的bin文件存储在model_output/unet.bin
中,这个文件用于在X3中进行网络推理。
③ 量化模型验证。这里我介绍一种在电脑上就能初步验证模型有效性的办法,我们在转换模型时候,主要关注的是unet.bin
,但实际上还有个模型unet_quantized_model.onnx
,这个模型是量化后的模型,在docker内就可以使用的。
利用Netron工具可以打开这个模型,模型的大部分层都用BPU相关的算子替换了,而且在最开始插入了一个预处理算子,前面的归一化参数就是集成在这个算子里了。这个onnx有几点需要注意:
int8
,正常图像类型为uint8
,利用img = (img.astype(np.int32) - 128).astype(np.int8)
可以完成格式的转换。/data/horizon_x3/codes
文件夹下输入python3 detect_quantized_onnx.py
生成量化onnx模型的推理结果。horizon_tc_ui import HB_ONNXRuntime
,HB_ONNXRuntime
是horizon_onnxruntime
的扩展,为了使得用法跟之前onnx的推理保持一致,我还是偏向使用horizon_onnxruntime
。### 注意:改代码仅能在OE docker中运行
import numpy as np
import cv2
import os
from prepare_functions import get_rgb_image, preprocess_onboard, postprocess
# horizon_nn 是在docker中才有的包
from horizon_nn import horizon_onnxruntime
from horizon_nn import horizon_onnx
dataroot = "/data/horizon_x3/data"
imgpath = os.path.join(dataroot, "mra_img_12.jpg")
onnxpath = os.path.join(dataroot, "model_output", "unet_quantized_model.onnx")
# 加载图像和ONNX模型
img = get_rgb_image(imgpath)
session = horizon_onnxruntime.InferenceSession(onnxpath)
# 校验预处理函数
datain = preprocess_onboard(img, 256, 256) # 1x256x256x3
# 构建输入并推理,记得要转为int8
inputs = {session.get_inputs()[0].name: (datain.astype(np.int32) - 128).astype(np.int8)}
outputs = session.run(None, inputs)
# 后处理并保存结果
pred = postprocess(outputs[0])[0]
cv2.imwrite(os.path.join(dataroot, "pred_quantized_onnx.png"), pred)
从下图可以看出,量化ONNX的推理结果和原始ONNX推理结果,主体上是相似的,局部有细微的差异(量化必然存在或多或少的精度损失,大部分情况下是可用的)。
其实我最开始以为onnx的输入preprocess_calibration
,运行时候报错,错误信息说维度应该是[1,256,256,3],然后我回去看onnx结构才发现预处理节点名叫HzSQuantizedPreprocess
,剩余的两个onnx节点名是HzPreprocess
。
INVALID_ARGUMENT : Got invalid dimensions for input: images for the following indices
index: 1 Got: 3 Expected: 256
index: 3 Got: 256 Expected: 3
Please fix either the inputs or the model.
如果本阶段的构建流程无法得到有效量化模型推理结果,则需要按照下面的校验项依序处理。
关于模型转换过程中的三个ONNX需要输入何种数据,手册里写的比较含糊。通过查看OE包中samples中的代码,输入的数据种类(BGR/RGB等)用的都是input_type_rt
的信息,而输入的数据排布不完全相同,即 original_float_model
和optimized_float_model
模型用的是input_layout_train
的排布,而quantized_model
用的是input_layout_rt
的排布。我其实并不理解官方这样设计的目的,从用户的角度来说,整个流程基于一个预处理,其他任何问题都应该交给开发人员来处理才比较合理。
如果校验①未通过,则需要仔细排查板端预处理(数据排布用的是input_train_layout) 是否有问题,如果没问题则可认为是模型转换初期就有问题,需要在地平线社区反馈问题交给技术人员检查。注意这里的预处理有点不同,用的是input_layout_train
排布,input_type_rt
图像类型。代码细节参考check_float_onnx.py,在docker /data/horizon_x3/codes
文件夹下输入python3 check_float_onnx.py
生成original_float_model.onnx
的推理结果。
##### 这里省略了各种import
# 板端数据预处理函数, 注意这里用的是input_layout_train排布,input_type_rt类型
def preprocess_floatmodel(img: np.ndarray, modelh, modelw) -> np.ndarray:
img = cv2.resize(img, (modelw, modelh))# Resize图像尺寸
img = img.transpose(2, 0, 1) # 通道由HWC变为CHW
img = np.expand_dims(img, 0) # 增加一维,此时维度为1CHW
img = np.ascontiguousarray(img) # 板端的推理是封装的C++,安全起见这里约束矩阵内存连续
return img
dataroot = "/data/horizon_x3/data"
imgpath = os.path.join(dataroot, "mra_img_12.jpg")
onnxpath = os.path.join(dataroot, "model_output", "unet_original_float_model.onnx")
# 加载图像和ONNX模型
img = get_rgb_image(imgpath)
session = horizon_onnxruntime.InferenceSession(onnxpath)
# 校验预处理函数
datain = preprocess_floatmodel(img, 256, 256) # 1x256x256x3
# 构建输入并推理,记得要转为float32
inputs = {session.get_inputs()[0].name: (datain.astype(np.int32) - 128).astype(np.float32)}
outputs = session.run(None, inputs)
# 后处理并保存结果
pred = postprocess(outputs[0])[0]
cv2.imwrite(os.path.join(dataroot, "pred_original_onnx.png"), pred)
校验②和校验①的模型推理,使用的是同一个预处理函数preprocess_floatmodel
,唯一不同的是这里使用的onnx模型不同,使用的是optimized_float_model
,是original_float_model
模型的优化。因此,若校验①可以出正常结果,校验②无法得到正常结果,则认为在模型优化这一步除了问题,可以直接交给技术人员排查。代码主体与校验①一样,差异部分如下所示。
# onnxpath = os.path.join(dataroot, "model_output", "unet_original_float_model.onnx")
onnxpath = os.path.join(dataroot, "model_output", "unet_optimized_float_model.onnx")
# cv2.imwrite(os.path.join(dataroot, "pred_original_onnx.png"), pred)
cv2.imwrite(os.path.join(dataroot, "pred_optimized_onnx.png"), pred)
校验③的代码同前面构建流程的最后一步,这里的预处理函数preprocess_onboard
与前两个校验所用的函数是不一样的。主体流程如图下图所示,如果校验②有正常结果,校验③无结果,则认为模型在量化阶段除了问题,需要交由技术人员处理。
此外,为了方便后续流程的校验,在这步还需要保存一些中间变量(img
,datain
,dataout
,pred
),每个数据的维度以及保存方式参考下面的代码,这段代码放在detect_quantized_onnx.py的最后面。
# 保存校验数据
# img: 256x256x3, datain: 1x256x256x3, dataout: 1x2x256x256, pred: 1x256x256
data = {"image": img,
"datain": datain,
"dataout": outputs[0],
"pred": pred}
np.savez(os.path.join(dataroot, "unet_checkstage2.npz"), **data)
理论上,阶段2校验③走通了,就一定能在板子上成功推理出来,因为这两者主要区别是模型加载方式不同(一个是onnx一个是bin),因此该阶段的构建流程代码与阶段2校验③的代码高度相似。
但代码跨平台很容易出现问题,因此安全起见,也要规范一下校验流程来定位问题,因此,构建流程部分没有得到正确结果的话,请参考本部分的校验流程。
基于Python的部署主要调用的是pyeasy_dnn,这个包里面的一些函数/类/数据类型的用法我会在后面详细解释。
BIN模型的推理代码如下所示,prepare_functions
中的函数get_rgb_image, preprocess_onboard, postprocess
在这里可以直接复用。而模型的推理和加载也非常简单。代码细节见detect_bin.py,在torchdnn的根目录下执行sudo python demos/unet/detect_bin.py
(这个代码只能在开发板中运行)。
import numpy as np
import cv2
import os
from prepare_functions import get_bgr_image, preprocess_onboard, postprocess
from hobot_dnn import pyeasy_dnn as dnn
# 记得要安装一些包
# sudo pip3 install scipy opencv-contrib-python
def get_hw(pro):
if pro.layout == "NCHW":
return pro.shape[2], pro.shape[3]
else:
return pro.shape[1], pro.shape[2]
dataroot = "data/unet"
imgpath = os.path.join(dataroot, "mra_img_12.jpg")
binpath = os.path.join(dataroot, "model_output/unet.bin")
# 加载图像和BIN模型
img = get_bgr_image(imgpath)
models = dnn.load(binpath)
# 图像数据预处理,这里对几个地方进行解释:
# models[0]:BPU支持加载多个模型,这里只有一个unet,因此[0]表示访问第一个模型
# inputs[0]:模型输入可能多种,unet的输入只有一个,因此[0]表示获取第一个输入的参数
model_h, model_w = get_hw(models[0].inputs[0].properties)
datain = preprocess_onboard(img, model_h, model_w) # 1x256x256x3
# 模型推理:相比于onnx推理,这里不用再重新构造一个inputs
t1 = cv2.getTickCount()
outputs = models[0].forward(datain)
t2 = cv2.getTickCount()
print('time consumption {0} ms'.format((t2-t1)*1000/cv2.getTickFrequency()))
# 后处理并保存结果,这里对几个地方进行解释
# outputs数据类型为tuple,每个元素的数据类型是pyDNNTensor
# 因此想要获取输出的矩阵的话,需要调用buffer
pred = postprocess(outputs[0].buffer) # outputs[0].buffer: (1, 2, 256, 256)
cv2.imwrite(os.path.join(dataroot, "pred_bin.png"), pred[0])
对比unet.bin
和unet_quantized_model.onnx
,结果是一模一样的,所以,转换模型后,在docker里就可以直接验证我们的量化模型是否可以用在开发板上。
如果本阶段的构建流程无法得到有效推理结果,则需要按照下面的校验项依序处理,只要校验不通过,说明unet_quantized_model.onnx
转unet.bin
的过程除了问题,在地平线社区反馈问题交给技术人员检查。
PS:如果校验都通过,那就认真检查下推理的前后处理吧,肯定是某个细节写错了→_→。
其实这里的校验流程应该需要补充个unet_quantized_model.onnx
在开发板的校验,但是推理这个onnx,依赖from horizon_nn import horizon_onnxruntime
,这个包只能在docker中运行,不能在开发板运行(里面有个so文件依赖docker)。(希望官方后续能在开发板支持horizon_nn的使用)
这里的校验BIN,就是直接输入推理数据,与理论的输出数据进行对比(datain
和dataout
是阶段2校验③中生成的校验数据),解除前后处理的耦合影响。代码细节见detect_bin.py,在torchdnn的根目录下执行sudo python demos/unet/check_bin_onboard.py
(这个代码只能在开发板中运行)。
dataroot = "data/unet"
data = np.load(os.path.join(dataroot, "unet_checkstage2.npz"))
datain = data["datain"]
dataout = data["dataout"]
###### 检查量化BIN有效性
binpath = os.path.join(dataroot, "model_output/unet.bin")
models = dnn.load(binpath)
outputs = models[0].forward(datain)
check_matrix_equal(outputs[0].buffer, dataout, 1e-4, dataroot, "onnxonboard")
Python版本的推理包hobot_dnn
是C++推理的封装,只留下了简单的推理过程,因此在实际落地应用时,建议使用C++部署。hobot_dnn
里面只有一个pyeasy_dnn.so
,存放地址/usr/local/lib/python3.8/dist-packages/hobot_dnn/pyeasy_dnn.so
。利用from hobot_dnn import pyeasy_dnn as dnn
导入包之后,执行print(help(dnn))
可以看到dnn内部的注释信息。
为了更好的理解dnn里面都有哪些内容,我在这里进行了详细分析,官网手册《5.4. 模型推理接口说明》里给出了简单的介绍。我个人觉得pyeasy_dnn的设计还是可以的,至少用户在操作时,不需要掌握太多的新知识(学习成本低)。里面有三种数据类型Model
,TensorProperties
,pyDNNTensor
。
为了更好的理解所有Class和Functions之间的关系,我下面给出一个思维导图,利用这张图在部署时候,就能随意调取相关的属性,完成自己的算法落地。
Python适用于快速算法验证,验证无误后,需要转换为C++落地。任何嵌入式应用基本都无法脱离C++,因为相比于Python,C++执行的速度更快,能够节省嵌入式本来就有限的资源。BPU提供的SDK是C接口,可以根据自己的需求做优化。BPU的相关API文档参考链接:5.2. BPU SDK API手册。
C语言接口,在提高了开发灵活性的同时,也降低了开发的安全性。因为操作基于指针,内存的分配与释放、数据对齐拷贝由用户来管理,很容易出现部署失败但不知如何Debug出问题,本节会讲清楚C语言部署/校验的流程。
此外,考虑到部署的不安全性,我自己在C接口的基础上,补充了一个C++的API,以减少学习成本,提高部署安全性,这个会在下一章节(wdr::BPU部署)介绍。
大部分Linux的代码都通过CMake进行编译,C++推理依赖项整理如下:
/usr/include/dnn/
,配置时候不需要利用include_directories
指定头文件目录,大部分的BPU部署直接在代码中添加下面两行代码即可#include
和#include
。若有函数找不到,就去dnn根目录下查找对应的函数头文件。/usr/lib/hbbpu/
中,因此需要在CMakeList.txt中补充库目录link_directories(/usr/lib/hbbpu/)
,编译最终可执行文件时,在target_link_libraries
中补充相关库-ldnn -lcnn_intf -lhbrt_bernoulli_aarch64
。(PS:除了这个还有libhlog.so
,这个就是打印日志用的,我觉得可以用glog替代,就不使用这个了。)博客里相关的C++代码都放置在github上:https://github.com/Li-Zhaoxi/OpenWanderary,编译流程如下:
# 安装OpenCV:不要用X3自带的opencv,版本低,不完整。
sudo apt-get install libopencv-dev
# 安装boost:如果编译出错,再安装这两个libboost-filesystem1.71-dev libboost-wave1.71-dev
sudo apt-get install libboost1.71-all-dev
git clone https://github.com/Li-Zhaoxi/OpenWanderary.git
cd OpenWanderary
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release ..
# 根据内存情况选择-j4还是-j2
# 这里编译会编译所有项目,后续会补充选择性编译的功能,欢迎关注OpenWanderary仓库
make -j4
下面我们开始着手写C++部署代码,代码细节见infer_unet_standalong.cpp中的函数void infer_unet()
,在OpenWanderary的根目录下执行sudo ./build/examples/BPU/infer_unet_standalong --mode infer
(这个代码只能在开发板中运行)。
下面对代码中的一些关键内容进行讲解。
① 通过代码判断代码执行是否用了sudo。在介绍正式内容之前,我先说下咱们开发常遇见的一个坑:调用BPU是需要sudo权限的,但我们经常会忘记用sudo。使用getuid
函数(在#include
里) 可以避免这个问题,在main函数里面第一行加入如下代码
int main(int argc, char **argv)
{
if (getuid())
CV_Error(cv::Error::StsError, "You must use ROOT or SUDO to use these BPU functions.");
/// 各种代码
}
② 构建C++版本的预/后处理函数。对比着前面Python版本的预处理函数preprocess_onboard
和后处理函数postprocess
,复现对应的C++版本,关键代码细节如下所示。具体细节没什么好说的,我这里特别介绍下C++代码里多维矩阵定义和使用的技巧。
void preprocess_onboard(const cv::Mat img, int modelh, int modelw, cv::Mat &datain)
{
cv::Mat tmp;
// Python: img = cv2.resize(img, (modelw, modelh))
cv::resize(img, tmp, cv::Size(modelw, modelh));
// Python: img = np.expand_dims(img, 0)
// Python: img = np.ascontiguousarray(img)
std::vector<int> dims = {1, tmp.rows, tmp.cols, tmp.channels()};
datain.create(dims.size(), dims.data(), CV_MAKETYPE(img.depth(), 1));
memcpy(datain.data, tmp.data, tmp.total() * tmp.elemSize());
}
void postprocess(const cv::Mat outputs, cv::Mat &pred)
{
// 格式检查:保证outputs的维度是[b,2,h,w],数据类型为float
CV_Assert(outputs.size.dims() == 4 && outputs.channels() == 1 && outputs.type() == CV_32F);
int b = outputs.size[0], c = outputs.size[1], h = outputs.size[2], w = outputs.size[3];
CV_Assert(c == 2);
// Python: y_list = softmax(outputs, axis = 1)[:, 1, :, :]
// Python:y_list = (y_list > 0.5).astype(np.uint8) * 255
// 这里可以简化为,比较[:, 1, :, :]和[:, 0, :, :]的大小,若前景大于背景,Label给255
std::vector<int> dims = {b, h, w};
pred.create(dims.size(), dims.data(), CV_8UC1);
for(int i = 0; i < b; i++)
{
float *_bdata = ((float*)outputs.data) + i * c * h * w; // 背景指针
float *_fdata = _bdata + h * w; // 前景指针
unsigned char *_label = pred.data + i * h * w;
int total_hw = h * w;
for(int k = 0; k < total_hw; k++, _label++, _bdata++, _fdata++)
*_label = *_fdata > *_bdata ? 255 : 0;
}
}
多维矩阵的构建 。正常的opencv矩阵的宽高通过其中的rows和cols访问,通道数调用channels()这个函数获取。对于多维矩阵,比如1x256x256x3的矩阵,我们就得用下面这种方式定义:
cv::Mat datain;
std::vector<int> dims = {1, 256, 256, 3}; // 定义矩阵维度信息
datain.create(dims.size(), dims.data(), CV_32FC1); // 构建多维矩阵,C1固定不要变
如果用了这种方式构建矩阵,有一些地方需要注意下:
datain.rows
和datain.cols
的值均为-1,维度个数可通过datain.size.dims()
获取,其第k维大小可通过datain.size[k]
获取。datain.at(i,j)
这种访问元素的形式失效。只能利用数据指针(float*)datain.data
来访问元素。HB_CHECK_SUCCESS
来检查是否成功执行,比如HB_CHECK_SUCCESS(hbDNNInitializeFromFiles(&pPackedNets, cpaths, pathnum), "hbDNNInitializeFromFiles failed");
,为了方便理解流程,下面的代码省去了HB_CHECK_SUCCESS
。下面这个代码片给出的定义。#define HB_CHECK_SUCCESS(value, errmsg) \
do { \
/*value can be call of function*/ \
auto ret_code = value; \
if (ret_code != 0) { \
LOG(ERROR) << errmsg << ", error code:" << ret_code; \
abort(); \
} \
} while (0);
详细BPU的C++推理代码如下所示,执行之后会保存推理结果,推理结果跟Python版本推理结果是一样的,这里就不再展示了。
void infer_unet()
{
std::string dataroot = "projects/torchdnn/data/unet/";
std::string binpath = dataroot + "model_output/unet.bin";
std::string imgpath = dataroot + "mra_img_12.jpg";
// -----------------模型加载部分--------------------
// 1. 加载BIN模型集
hbPackedDNNHandle_t packed_dnn_handle; // 模型集合指针
const char *model_file_name = binpath.c_str();
hbDNNInitializeFromFiles(&packed_dnn_handle, &model_file_name, 1);
// 2. 提取模型集中所有的模型名称
const char **model_name_list;
int model_count = 0;
hbDNNGetModelNameList(&model_name_list, &model_count, packed_dnn_handle);
for (int k = 0; k < model_count; k++) // 输出提取出的所有模型的名称
LOG(INFO) << "Parsed Model Name: " << std::string(model_name_list[k]);
// 3. 利用目标模型名提取模型指针
hbDNNHandle_t dnn_handle; // ※模型指针
hbDNNGetModelHandle(&dnn_handle, packed_dnn_handle, model_name_list[0]);
// -----------------输入输出内存分配--------------------
// 1. 获取输入/输出Tensor个数
int input_tensornum = 0, output_tensornum = 0;
hbDNNGetInputCount(&input_tensornum, dnn_handle);
hbDNNGetOutputCount(&output_tensornum, dnn_handle);
LOG(INFO) << "input tensor num: " << input_tensornum << ", output tensor num: " << output_tensornum;
// 2. 获取输入/输出Tensor参数
std::vector<hbDNNTensorProperties> input_properties, output_properties; // 输入/输出Tensor参数
input_properties.resize(input_tensornum), output_properties.resize(output_tensornum);
for (int k = 0; k < input_tensornum; k++)
hbDNNGetInputTensorProperties(&input_properties[k], dnn_handle, k);
for (int k = 0; k < output_tensornum; k++)
hbDNNGetOutputTensorProperties(&output_properties[k], dnn_handle, k);
// 3. 利用参数分配Tensor内存
std::vector<hbDNNTensor> input_tensors, output_tensors; // ※输入/输出Tensor
input_tensors.resize(input_tensornum), output_tensors.resize(output_tensornum);
for (int k = 0; k < input_tensornum; k++)
{
const auto &property = input_properties[k];
input_tensors[k].properties = property;
hbSysAllocCachedMem(&input_tensors[k].sysMem[0], property.alignedByteSize);
}
for (int k = 0; k < output_tensornum; k++)
{
const auto &property = output_properties[k];
output_tensors[k].properties = property;
hbSysAllocCachedMem(&output_tensors[k].sysMem[0], property.alignedByteSize);
}
LOG(INFO) << "Finish initializing input/output tensors";
Tensor详细属性信息如下:
// input[0]:
// valid shape: (1,256,256,3,)
// aligned shape: (1,256,256,4,)
// tensor type: HB_DNN_IMG_TYPE_BGR
// tensor layout: HB_DNN_LAYOUT_NHWC
// quanti type: SHIFT
// shift data: 0,0,0,
// output[0]:
// valid shape: (1,2,256,256,)
// aligned shape: (1,2,256,256,)
// tensor type: HB_DNN_TENSOR_TYPE_F32
// tensor layout: HB_DNN_LAYOUT_NCHW
// quanti type: NONE
// -----------------模型推理:预处理→BPU推理→后处理--------------------
cv::Mat img, datain, dataout, pred;
// 1. 加载图像&&图像预处理
get_bgr_image(imgpath, img);
cv::Size tensorhw = get_hw(input_tensors[0].properties);
LOG(INFO) << "Loaded img size: " << img.size() << ", target size: " << tensorhw;
preprocess_onboard(img, tensorhw.height, tensorhw.width, datain);
LOG(INFO) << "Finish preprocess_onboard";
// 由于输入和BPU的Tensor存在不对齐问题,因此需要配置为自动对齐
// input_tensors[0]: valid shape: (1,256,256,3,),aligned shape: (1,256,256,4,)
auto &tensor = input_tensors[0];
tensor.properties.alignedShape = tensor.properties.validShape;
// 2. 预处理数据memcpy至BPU
memcpy(tensor.sysMem[0].virAddr, datain.data, datain.total() * datain.elemSize());
// 3. 刷新CPU数据到BPU
hbSysFlushMem(&tensor.sysMem[0], HB_SYS_MEM_CACHE_CLEAN);
// 4. 推理模型
hbDNNTaskHandle_t task_handle = nullptr; // 任务句柄
hbDNNInferCtrlParam infer_ctrl_param;
HB_DNN_INITIALIZE_INFER_CTRL_PARAM(&infer_ctrl_param);
auto ptr_outtensor = output_tensors.data();
hbDNNInfer(&task_handle, &ptr_outtensor, input_tensors.data(), dnn_handle, &infer_ctrl_param);
// 5. 等待任务结束
hbDNNWaitTaskDone(task_handle, 0);
// 6. 释放任务
hbDNNReleaseTask(task_handle);
// 7. 刷新BPU数据到CPU
hbSysFlushMem(&(output_tensors[0].sysMem[0]), HB_SYS_MEM_CACHE_INVALIDATE);
// 8. 从Tensor地址memcpy后处理数据
dataout.create(output_tensors[0].properties.alignedShape.numDimensions,
output_tensors[0].properties.alignedShape.dimensionSize, CV_32FC1);
memcpy(dataout.data, (unsigned char *)output_tensors[0].sysMem[0].virAddr, dataout.total() * dataout.elemSize());
LOG(INFO) << "Finish infer";
// 9. 数据后处理+保存最终分割结果
postprocess(dataout, pred);
int offset = pred.size[1] * pred.size[2] * pred.elemSize();
for(int k = 0; k < pred.size[0]; k++)
{
cv::Mat batchpred(pred.size[1], pred.size[2], CV_8UC1, pred.data + k * offset);
cv::imwrite(dataroot + "pred_bin_cpp_b" + std::to_string(k) + ".png", batchpred);
}
模型推理:预处理→BPU推理→后处理
// 1. 释放内存
for (auto &input : input_tensors)
hbSysFreeMem(&(input.sysMem[0]));
for (auto &output : output_tensors)
hbSysFreeMem(&(output.sysMem[0]));
// 2. 释放模型
hbDNNRelease(packed_dnn_handle);
}
从Python代码转C++代码,一下次就成功是很难的,这里的校验就非常关键了。这阶段的校验过程包含三个阶段:预处理、后处理、BIN模型校验,相比于其他几个阶段,这里的校验相互独立。
这个阶段的校验流程如下所示,所有校验过程存在infer_unet_standalong.cpp中的函数void check_all()
中,在OpenWanderary的根目录下执行sudo ./build/examples/BPU/infer_unet_standalong --mode check
保存所属有输出结果为npy文件,之后在torchdnn的根目录下执行python3 demos/unet/check_cpp_onboard.py
完成各个阶段的输出数据校验。
值得注意,npz文件在python下的读写很简单,但在校验过程中,大部分代码在C++中实现,有npz文件读写的需求。因此我们使用了一个库cnpy
来满足我们的需求,github地址为:https://github.com/rogersce/cnpy。我将其中的核心代码复制到OpenWanderary/3rdparty/cnpy
中,编译时候已经链接了这个库了。void check_all()
的代码细节如下所示,所有的校验过程代码都记录在这里。
void check_all()
{
std::string dataroot = "projects/torchdnn/data/unet/";
std::string npzpath = dataroot + "unet_checkstage2.npz";
std::string binpath = dataroot + "model_output/unet.bin";
std::string savepath = dataroot + "unet_checkcppresults.npz";
// 加载各阶段理论值npz文件
cnpy::npz_t datanpz = cnpy::npz_load(npzpath);
cv::Mat datain, dataout, pred;
// (1) 预处理校验过程:输入理论图像数据,返回预处理结果
LOG(INFO) << "Start check_preprocess";
// 通过字符串可直接访问npz中的数据,返回cnpy::NpyArray
check_preprocess(datanpz["image"], datain);
// (2) 后处理校验过程:输入推理理论输出,返回后处理预测结果
LOG(INFO) << "Start check_postprocess";
check_postprocess(datanpz["dataout"], pred);
// (3) 推理校验过程:输入推理所需的理论值,返回推理结果
LOG(INFO) << "Start check_infer";
check_infer(binpath, datanpz["datain"], dataout);
// 保存各个阶段的输出值到npz文件
LOG(INFO) << "Start saving results";
cnpy::npz_save(savepath, "datain", (unsigned char*)datain.data, get_shape(datain), "w");
cnpy::npz_save(savepath, "dataout", (float*)dataout.data, get_shape(dataout), "a");
cnpy::npz_save(savepath, "pred", (unsigned char*)pred.data, get_shape(pred), "a");
LOG(INFO) << "Finish saving results in " << savepath;
}
板端预处理校验就是检查C++版的preprocess_onboard
是否正确,函数输入是标准的图像格式(不是多维矩阵的构造方式),也就是img.rows>0
。
arr_image
的输入维度我们是预先知道的,矩阵排布为 H W C HWC HWC,数据类型为uint8
,因此利用img.create(arr_image.shape[0], arr_image.shape[1], CV_MAKETYPE(CV_8U, arr_image.shape[2]));
完成图像矩阵的定义,假如图像为3通道,则CV_MAKETYPE(CV_8U, arr_image.shape[2])
等价于CV_8UC3
。
img.total() * img.elemSize()
是这个矩阵的总共字节数,利用memcpy实现内存的拷贝。
预处理函数校验的代码细节如下所示:
void check_preprocess(const cnpy::NpyArray &arr_image, cv::Mat &datain)
{
// Load image
cv::Mat img;
CV_Assert(arr_image.shape.size() == 3);
img.create(arr_image.shape[0], arr_image.shape[1], CV_MAKETYPE(CV_8U, arr_image.shape[2]));
memcpy(img.data, arr_image.data<unsigned char>(), img.total() * img.elemSize());
// Get datain
preprocess_onboard(img, 256, 256, datain);
}
板端后处理校验就是检查C++版的postprocess
是否正确。要注意,函数输入是多维矩阵,这时构造的后处理输入矩阵dataout.rows<0
。
我们已经预先知道了后处理输入是一个4维float的矩阵,因此数据类型指定为CV_32FC1即可,后处理函数校验的代码细节如下所示:
void check_postprocess(const cnpy::NpyArray &arr_dataout, cv::Mat &pred)
{
// Load dataout
cv::Mat dataout;
std::vector<int> dims = get_dims(arr_dataout);
dataout.create(dims.size(), dims.data(), CV_32FC1);
memcpy(dataout.data, arr_dataout.data<unsigned char>(), dataout.total() * dataout.elemSize());
// Get pred
postprocess(dataout, pred);
}
板端推理校验就是检查C++版的BPU推理是否正确,我们只需要给它推理输入即可。要注意,函数输入是多维矩阵,这时构造的后处理输入矩阵dataout.rows<0
。我们已经预先知道了后处理输入是一个4维float的矩阵,因此数据类型指定为CV_32FC1即可。
这个校验过程的代码包含一堆BPU模型加载/初始化/释放相关的代码,为了减少冗余,我只放上不一样的地方。
void check_infer(const std::string &binpath, const cnpy::NpyArray &arr_datain, cv::Mat &dataout)
{
// Load datain
cv::Mat datain;
CV_Assert(arr_datain.shape.size() == 4);
std::vector<int> dims = get_dims(arr_datain);
datain.create(dims.size(), dims.data(), CV_8UC1);
memcpy(datain.data, arr_datain.data<unsigned char>(), datain.total() * datain.elemSize());
....... // 省略模型加载/Tensor初始化等代码
// 预处理数据memcpy至BPU。在推理过程:这里是要加载图像→预处理得到 datain。
memcpy(tensor.sysMem[0].virAddr, datain.data, datain.total() * datain.elemSize());
....... // 省略模型推理,任务释放等代码
// 记录模型推理输出到dataout。在推理过程:这里需要利用dataout进行后处理得到最终pred推理结果。
dataout.create(output_tensors[0].properties.alignedShape.numDimensions,
output_tensors[0].properties.alignedShape.dimensionSize, CV_32FC1);
memcpy(dataout.data, (unsigned char *)output_tensors[0].sysMem[0].virAddr, dataout.total() * dataout.elemSize());
....... // 省略模型释放/Tensor内存释放等代码
}
从上面内容的介绍,我们可以了解了整体BPU的部署方案,整个方案是比较长的,特别是C++部署。
为了降低部署BPU的各种不安全性和开发成本,我总结了自己开发过程中遇到的一些问题,设计了一个BPU部署工具OpenWanderary(WDR)。WDR开发了2个月,利用业余时间开发完成,代码量接近3k行,开发模式参考了Effective C++,尽可能参考其中的条款。WDR的设计,是简化用户操作难度,加速部署效率,我走过的坑不希望你们重复走。剩下的时间做些更有意义的事情。
这套工具具有以下几个优点:
net[i]
即可。datain
输入到第i个tensor中,则bpumats[i] << datain
即可。将第i个tensor数据输出到Mat矩阵dataout
中,则bpumats[i] >> dataout
即可。这样极大降低用户操作成本。CV_Error
来判断用户的输入是否合法,不合法的输入将会给出详细的报错信息。WDR中由三个关键的Class,这里简要说明下其作用,更多功能可以查看wanderary/BPU/bpu.h
。
BpuNets
:多模型加载,初始化Tensor,以及推理。BpuMats
:模型的输入/输出组,用于推理。BpuMat
:每个Tensor的数据交互,BpuMats[idx]
返回的就是BpuMat
类型。这个工具目前还在不断优化中,这里会给出一些demo来展示开发的库的方便性,文档之类的,待经过大量验证, 成熟了之后会单独发版,如果各位在使用时候出现Bug,欢迎反馈,一起调试。(业余时间开发的,时间很紧张,使用时候出现的问题求各位轻喷 )
下面基于WDR工具,给出两种C++部署功能。
代码细节见examples/BPU/print-infos.cpp中的函数test_class
,在OpenWanderary的根目录下执行sudo ./build/examples/BPU/print-infos --binpath projects/torchdnn/data/unet/model_output/unet.bin --mode class
,即可输出模型的各种信息。
int test_class(const boost::filesystem::path &binpath)
{
// 0. 加载模型
wdr::BPU::BpuNets nets;
nets.readNets({binpath.string()}); // 加载模型
// 1. 打印模型个数,以及模型名称
std::stringstream ss;
ss << "model num: " << nets.total() << ", model names: ";
for (int k = 0; k < nets.total(); k++)
ss << nets.index2name(k) << ", ";
LOG(INFO) << ss.str(), ss.clear();
// I0623 18:25:22.159559 1268383 print-infos.cpp:61] model num: 1, model names: unet
// 2. 打印第一个模型的输入Tensor的第一个Tensor
LOG(INFO) << nets[0][wdr::BPU::NET_INPUT][0];
// I0623 18:25:22.159651 1268383 print-infos.cpp:64] [hbDNNTensorProperties] List Properties:
// {
// "alignedShape": "[hbDNNTensorShape] dim: 4 [1x256x256x4]",
// "quantiType": 262144,
// "scale": "[hbDNNQuantiScale] scaleLen: 0, scaleData: 0 [], zeroPointLen: 0, zeroPointData: []",
// "shift": "[hbDNNQuantiShift] shiftLen: 3, shiftData: [ 0, 0, 0]",
// "tensorLayout": "[hbDNNTensorLayout]: HB_DNN_LAYOUT_NHWC",
// "tensorType": "[hbDNNDataType]: HB_DNN_IMG_TYPE_BGR",
// "validShape": "[hbDNNTensorShape] dim: 4 [1x256x256x3]"
// }
// 3. 打印第一个模型的输出Tensor的第一个Tensor
LOG(INFO) << nets[0][wdr::BPU::NET_OUTPUT][0];
// I0623 18:25:22.160048 1268383 print-infos.cpp:67] [hbDNNTensorProperties] List Properties:
// {
// "alignedShape": "[hbDNNTensorShape] dim: 4 [1x2x256x256]",
// "quantiType": 524288,
// "scale": "[hbDNNQuantiScale] scaleLen: 0, scaleData: 0 [], zeroPointLen: 0, zeroPointData: []",
// "shift": "[hbDNNQuantiShift] shiftLen: 0, shiftData: []",
// "tensorLayout": "[hbDNNTensorLayout]: HB_DNN_LAYOUT_NCHW",
// "tensorType": "[hbDNNDataType]: HB_DNN_TENSOR_TYPE_F32",
// "validShape": "[hbDNNTensorShape] dim: 4 [1x2x256x256]"
// }
// 4. 打印所有模型参数信息
for (int k = 0; k < nets.total(); k++)
LOG(INFO) << nets.index2name(k) << ", " << nets[k];
// I0623 18:25:22.160290 1268383 print-infos.cpp:71] unet, [Net] All Input && Output Tensor Properties:
// {
// "Input": [
// {
// "alignedShape": "[hbDNNTensorShape] dim: 4 [1x256x256x4]",
// "quantiType": 262144,
// "scale": "[hbDNNQuantiScale] scaleLen: 0, scaleData: 0 [], zeroPointLen: 0, zeroPointData: []",
// "shift": "[hbDNNQuantiShift] shiftLen: 3, shiftData: [ 0, 0, 0]",
// "tensorLayout": "[hbDNNTensorLayout]: HB_DNN_LAYOUT_NHWC",
// "tensorType": "[hbDNNDataType]: HB_DNN_IMG_TYPE_BGR",
// "validShape": "[hbDNNTensorShape] dim: 4 [1x256x256x3]"
// }
// ],
// "Output": [
// {
// "alignedShape": "[hbDNNTensorShape] dim: 4 [1x2x256x256]",
// "quantiType": 524288,
// "scale": "[hbDNNQuantiScale] scaleLen: 0, scaleData: 0 [], zeroPointLen: 0, zeroPointData: []",
// "shift": "[hbDNNQuantiShift] shiftLen: 0, shiftData: []",
// "tensorLayout": "[hbDNNTensorLayout]: HB_DNN_LAYOUT_NCHW",
// "tensorType": "[hbDNNDataType]: HB_DNN_TENSOR_TYPE_F32",
// "validShape": "[hbDNNTensorShape] dim: 4 [1x2x256x256]"
// }
// ],
// "name": "unet"
// }
return 0;
}
代码细节见examples/BPU/infer-unet.cpp中的函数test_class
,在OpenWanderary的根目录下执行sudo ./build/examples/BPU/infer-unet --mode class
,即可输出模型的各种信息。
int test_class()
{
const std::string modelname = "unet";
// 1. 加载模型
wdr::BPU::BpuNets nets;
nets.readNets({binpath});
int idxmode = nets.name2index(modelname);
LOG(INFO) << "model index: " << idxmode;
CV_Assert(idxmode >= 0);
// 2. 加载图像
cv::Mat img;
wdr::get_bgr_image(imgpath, img);
LOG(INFO) << "Finish load bgr image";
// 3. 内存分配
wdr::BPU::BpuMats input_mats, output_mats;
nets.init(idxmode, input_mats, output_mats, true);
LOG(INFO) << "input tensor num: " << input_mats.size() << ", output tensor num: " << output_mats.size();
// 3. 构造预处理输出,模型输入是256,256
cv::Mat datain;
cv::Size modsize = input_mats[0].size(false);
LOG(INFO) << "Input model size: " << modsize;
wdr::preprocess_onboard_NHWC(img, modsize.height, modsize.width, datain);
input_mats[0] << datain; // datain数据拷贝到Tensor里
input_mats.bpu(); // 更新数据到BPU中
LOG(INFO) << "Finish preprocess";
// 4. 模型推理
cv::Mat dataout;
nets.forward(idxmode, input_mats, output_mats);
output_mats.cpu(); // 从BPU中下载数据
output_mats[0] >> dataout; // 从Tensor里拷出数据到dataout
LOG(INFO) << "Finish infer";
// 5. 构造后处理数据,并保存最终预测结果
std::vector<cv::Mat> preds;
wdr::parseBinarySegmentResult(dataout, preds);
for (int k = 0; k < preds.size(); k++)
cv::imwrite(saveroot + "pred_cpp_wdr_" + std::to_string(k) + ".png", preds[k]);
// 6. 保存校验数据
std::string savepath = saveroot + "unet_check_wdrresults.npz";
LOG(INFO) << "Start saving results";
cnpy::npz_save(savepath, "datain", (unsigned char *)datain.data, wdr::get_shape(datain), "w");
cnpy::npz_save(savepath, "dataout", (float *)dataout.data, wdr::get_shape(dataout), "a");
cnpy::npz_save(savepath, "pred", (unsigned char *)preds[0].data, wdr::get_shape(preds[0]), "a");
LOG(INFO) << "Finish saving results in " << savepath;
// 内存释放由代码的析构函数自动完成,无需主动调用
return 0;
}
代码执行之后,会输出如下信息,预测结果图保存在projects/torchdnn/data/unet/pred_cpp_wdr_0.png
,校验信息存在projects/torchdnn/data/unet/unet_check_wdrresults.npz
,通过调用projects/torchdnn/demos/unet/check_wdr_onboard.py
,确保了C++推理结果和Python版本的推理结果是一致的。
I0623 18:30:25.858119 1282517 infer-unet.cpp:38] mode: class
[BPU_PLAT]BPU Platform Version(1.3.1)!
[HBRT] set log level as 0. version = 3.14.5
[DNN] Runtime version = 1.9.7_(3.14.5 HBRT)
I0623 18:30:26.150298 1282517 infer-unet.cpp:58] model index: 0
I0623 18:30:26.158201 1282517 infer-unet.cpp:64] Finish load bgr image
I0623 18:30:26.158665 1282517 infer-unet.cpp:69] input tensor num: 1, output tensor num: 1
I0623 18:30:26.158731 1282517 infer-unet.cpp:74] Input model size: [256 x 256]
I0623 18:30:26.161554 1282517 infer-unet.cpp:79] Finish preprocess
I0623 18:30:26.261695 1282517 infer-unet.cpp:86] Finish infer
I0623 18:30:26.266692 1282517 infer-unet.cpp:96] Start saving results
I0623 18:30:26.274824 1282517 infer-unet.cpp:100] Finish saving results in projects/torchdnn/data/unet/unet_check_wdrresults.npz
去年11月-6月,历时8月,凝练出这个博客。创作空间统计的字数是4w+,预估阅读时间超过1个小时,哈哈哈。为了讲明白一件事,我自己不断的优化文案,废弃的文案也大概1w了。不断在打磨,就是在想办法提供给读者干货,删了一堆,最开始我想讲怎么配置cmake,vscode插件,C接口api也要想写个文档。后来发现,这没意义,我需要提供个各位的是部署意识,而不是字典类型的博客。从表达上也可能直接说清楚。
个人认为BPU部署三部曲,足够让不了解BPU的人能够踏入开发的大门。本系列的结束,是另一个内容的开始。这段时间内的整理,也发现BPU部署工具中存在一些地方可以优化,优化后可以让每个开发者轻松上阵,优化点整理如下:
horizon_nn
。对于部署的未来工作:
博客内容较多,可能会存在一些错误,错误修复后我会及时更新在WDR仓库里,希望各位多多关注。