前言
自动驾驶之心推出的 《CUDA与TensorRT部署实战课程》,链接。记录下个人学习笔记,仅供自己参考
本次课程我们来学习课程第六章—部署分类器,一起来学习利用 cnpy 库加载和保存 tensor
课程大纲可以看下面的思维导图
0. 简述
本小节目标:学习 cnpy 库的使用
这节课程开始我们进入第七章节—部署 YOLOv8 检测器,这个章节偏实战,为大家准备了几个案例:
- 7.1-load-save-tensor
- 7.2-affine-transformation
- 7.3-deploy-yolo-basic
- 7.4-quantization-analysis
- 7.5-deploy-yolo-multitask
第七章节主要是教大家部署基于 YOLOv8 的检测和分割模型,整个代码其实是基于我们前面第六章节创建的推理框架,在此基础上实现不同的 task
第七章节准备的案例一共是五个,第一个是 load-save-tensor 案例,加载和保存 tensor,实现一个 tensor 在 c++ 和 python 之间的相互转换;7.2 小节主要给大家介绍仿射变换,这个本来是要在第二章节给大家介绍的,但是没有一些案例提供,像 YOLOv8 这种检测器的预处理就非常适合仿射变换,因此我们结合着具体的案例来讲可能更加方便大家的理解,同时我们理解了第二章节的双线性插值再回过头来看仿射变换会容易很多
我们先学习仿射变换的目的是为了后面的小节,像 classification 这种在预处理时只需要 resize 就行,然后推理拿到结果,没有必要将结果映射回原始的 size 大小,但是像 detection 或者 segmentation 这种我们一般是将图像仿射变换到 640x640 大小,接着推理拿到检测框的结果,但此时的结果是属于 640x640 尺寸的,我们还需要将其映射回原始的 size 大小,这个就是仿射变换逆矩阵做的事情
我们理解了 7.1 和 7.2 小节后就正式开始进入 7.3 小节 YOLO 的部署,它是根据第六章节中 classification deploy 的一个扩充,在 7.3 小节我们会感受到当一个推理框架搭建完成后,如果有新的 task 需要添加时,其实代码的修改其实是非常少的
针对 7.3 中 YOLO 量化后掉点严重的问题,我们在 7.4 小节中会详细分析在量化掉点严重的情况下我们应该怎么做,我们应该按照一个什么样的思路一步步去排查错误,这都是我们在 7.4 小节中会提到的
最后 7.5 小节我们来学习 multitask 的部署,以 YOLOv8-Seg 为例去分析多任务头的模型我们该如何去实现部署,如何和 pytorch 模型的精度对齐
本次课程我们主要学习 7.1 小节加载和保存 tensor
下面我们开始本次课程的学习🤗
1. 案例运行
在正式开始课程之前,博主先带大家跑通 7.1-load-save-tensor 这个小节的案例🤗
首先大家需要把 tensorrt_starter 这个项目给 clone 下来,指令如下:
git clone https://github.com/kalfazed/tensorrt_starter.git
也可手动点击下载,点击右上角的 Code
按键,将代码下载下来。至此整个项目就已经准备好了。也可以点击 here 下载博主准备好的源代码(注意代码下载于 2024/7/14 日,若有改动请参考最新)
整个项目后续需要使用的软件主要有 CUDA、cuDNN、TensorRT、OpenCV,大家可以参考 Ubuntu20.04软件安装大全 进行相应软件的安装,博主这里不再赘述
假设你的项目、环境准备完成,下面我们一起来运行下 7.1-load-save-tensor 小节案例代码
本小节案例需要大家安装一个 cnpy 库,安装比较简单,下面博主跟着大家一起安装下
首先我们需要把 cnpy 库给 clone 下来,终端执行如下指令:
git clone https://github.com/rogersce/cnpy.git
也可手动点击下载,点击右上角的 Code
按键,将代码下载下来。如下图所示,至此整个项目就已经准备好了。也可以点击 here 下载博主准备好的源代码
接着我们只要跟着官方提供的 README 文档一步步安装就行,指令如下:
cd cnpy
mkdir build && cd build
cmake ../
make -j64
make install
输出如下:
如果大家看到上述输出结果则说明 cnpy 库安装完成
Note:默认安装在系统目录,如果想安装在指定目录则在 cmake ../
时添加编译选项,如下所示:
cmake ../ -DCMAKE_INSTALL_PREFIX=/home/jarvis/lean/cnpy
这里博主安装的位置是自己指定的目录,还有一个点需要注意如果是像博主一样将 cnpy 库安装到指定目录则后续需要在 Makefile 中手动添加下 cnpy 的头文件和库文件路径,否则编译时会提示找不到 cnpy,这点博主后续会提到
安装完 cnpy 库之后我们还需要修改下整体的 Makefile.config,指定一些库的路径:
# tensorrt_starter/config/Makefile.config
# CUDA_VER := 11
CUDA_VER := 11.6
# opencv和TensorRT的安装目录
OPENCV_INSTALL_DIR := /usr/local/include/opencv4
# TENSORRT_INSTALL_DIR := /mnt/packages/TensorRT-8.4.1.5
TENSORRT_INSTALL_DIR := /home/jarvis/lean/TensorRT-8.6.1.6
Note:大家查看自己的 CUDA 是多少版本,修改为对应版本即可,另外 OpenCV 和 TensorRT 修改为你自己安装的路径即可
如果 cnpy 库安装的是指定目录则还需要手动指定下头文件和库文件路径,如下图所示:
接着我们就可以来执行编译,指令如下:
make -j64
输出如下:
接着执行:
./bin/trt-infer
大家可能会遇到如下问题:
这个主要是程序在运行时找不到对应的库,这里博主通过 export
指令手动添加的库路径,指令如下:
export LD_LIBRARY_PATH=/home/jarvis/lean/cnpy/lib:$LD_LIBRARY_PATH
大家也可以将其添加到 .bashrc
文件中
更新环境变量后再次执行下,输出如下:
我们再看下对应的 python 结果,如下所示:
这里我们通过 c++ 的 cnpy 库保存了一个 .npz
文件并通过 python 打印出来了,可以看到数据都是一样的,说明没有问题。当然大家也可以 python 保存然后在 c++ 上读取,这边博主就不再演示了
如果大家能够看到上述输出结果,那就说明本小节案例已经跑通,下面我们就来看看具体的代码实现
2. 补充说明
在分析代码之前我们先来看下韩君老师在这小节中写的 README 文档
作为第七章的开头,这里给大家分享一个 c++ 库叫做 cnpy
,虽然是一个比较古老的库了,但是依然很好使用,大家可以访问它的 repository 来看它具体是如何使用的:https://github.com/rogersce/cnpy
它可以用来将 python 中的 numpy 数据打包成 npy
或者 npz
格式,之后从 c++ 中读取,或者将 c++ 中的 array 或者 vector 打包成 npy
或者 npz
格式,让 python 读取
主要应用场景在于将 c++ 做的一些后处理和 pytorch 做的后处理的结果进行比较,比如说如果原本的 yolov8 模型中 pytorch 的 bbox 的位置信息和 confidence 和我们在 c++ 中实现的结果有很大的不同的话,我们需要考虑是在哪一步的处理有出入,是预处理还是后处理,我们通过对比同一个步骤的 c++ 和 pytorch 实现,提供完全相同的两个 tensor 并查看它们的结果是否存在偏差。作为一种 debug 的方式,cnpy
还是比较方便的
现在比较常见的具有类似的功能同时知名度也比较高的有 Aten
或者 Autograd
,它们也可以实现类似的供。通过这两个 lib 我们可以在 c++ 中做类似于 pytorch 的实现,但是 cnpy
由于只提供 tensor 的 save/load,也是我们在 debug 时仅仅需要的功能,所以相比下来会比 Aten
和 torchlib
要轻量很多,方便使用
下面是本小节的两个案例:
1. c++ 读取 pytorch 数据
make run
[info]Succeeded loading data from .npy/.npz!
[info]Tensor values:
[[[[0.16708092 0.12141544 0.97547305 0.43885458 ]
[0.86767215 0.19956835 0.65644139 0.26155758 ]
[0.38162416 0.99415749 0.72049564 0.80983108 ]
[0.80337048 0.04441109 0.07116123 0.19084969 ]]
[[0.32216629 0.06359515 0.57189071 0.51922035 ]
[0.28190836 0.73800033 0.54624307 0.08063673 ]
[0.08042904 0.45007208 0.33879673 0.76960266 ]
[0.48134825 0.92414659 0.21818621 0.38389835 ]]
[[0.49801108 0.25858060 0.02503417 0.96238512 ]
[0.45412001 0.43555310 0.96726465 0.56104338 ]
[0.42672521 0.35845470 0.02181065 0.86646718 ]
[0.95074230 0.02647719 0.41995925 0.04657971 ]]]]
2. pytorch 读取 c++ 数据
python src/python/create.py
[INFO]: Succeeded loaded data as .npz file!
[INFO]: Tensor shape:(3, 2, 4, 4)
[INFO]: Tensor values:
[[[[ 0.84018773 0.39438292 0.78309923 0.79844004]
[ 0.91164738 0.19755137 0.33522275 0.76822960]
[ 0.27777472 0.55396998 0.47739705 0.62887090]
[ 0.36478448 0.51340091 0.95222974 0.91619509]]
[[ 0.63571173 0.71729696 0.14160256 0.60696888]
[ 0.01630057 0.24288677 0.13723157 0.80417675]
[ 0.15667909 0.40094438 0.12979044 0.10880880]
[ 0.99892449 0.21825691 0.51293242 0.83911222]]]
[[[ 0.61263984 0.29603162 0.63755226 0.52428716]
[ 0.49358299 0.97277504 0.29251680 0.77135772]
[ 0.52674496 0.76991385 0.40022862 0.89152944]
[ 0.28331473 0.35245836 0.80772454 0.91902649]]
[[ 0.06975528 0.94932705 0.52599537 0.08605585]
[ 0.19221385 0.66322690 0.89023262 0.34889293]
[ 0.06417132 0.02002305 0.45770174 0.06309584]
[ 0.23827995 0.97063410 0.90220809 0.85091978]]]
[[[ 0.26666576 0.53976035 0.37520698 0.76024872]
[ 0.51253539 0.66772377 0.53160644 0.03928034]
[ 0.43763760 0.93183506 0.93080980 0.72095233]
[ 0.28429341 0.73853433 0.63997883 0.35404867]]
[[ 0.68786138 0.16597417 0.44010451 0.88007522]
[ 0.82920110 0.33033714 0.22896817 0.89337242]
[ 0.35036018 0.68666989 0.95646822 0.58864015]
[ 0.65730405 0.85867631 0.43955991 0.92396981]]]]
3. 代码分析
3.1 main.cpp
我们先从 main.cpp 看起:
#include "trt_logger.hpp"
#include "utils.hpp"
using namespace std;
int main(int argc, char const *argv[])
{
/*
从c++读取一个python下保存的npz file
*/
// cnpy::npz_t npz_data = cnpy::npz_load("data/data_python.npz");
// cnpy::NpyArray arr = npz_data["data_python"];
// for (int i = 0; i < arr.shape.size(); i ++){
// LOG("arr.shape[%d]: %d", i, arr.shape[i]);
// }
// LOG("Succeeded loading data from .npy/.npz!");
// LOG("Tensor values:");
// printTensorNPY(arr);
/*
在c++下保存一个python可以识别的npy/npz file
*/
const int b = 3;
const int c = 2;
const int h = 4;
const int w = 4;
int size = b * c * h * w;
float* data = (float*)malloc(size * sizeof(float));
initTensor(data, size, 0, 1, 0);
cnpy::npz_save("data/data_cpp.npz", "data_cpp", &data[0], {b, c, h, w}, "w");
cnpy::npy_save("data/data_cpp.npy", &data[0], {b, c, h, w}, "w");
LOG("Succeeded saving data as .npy/.npz!");
LOG("Tensor values:");
printTensorCXX(data, b, c, h, w);
return 0;
}
上述代码展示了如何在 C++ 中使用 cnpy
库来加载和保存 .npy
和 .npz
文件
/*
从c++读取一个python下保存的npz file
*/
// cnpy::npz_t npz_data = cnpy::npz_load("data/data_python.npz");
// cnpy::NpyArray arr = npz_data["data_python"];
// for (int i = 0; i < arr.shape.size(); i ++){
// LOG("arr.shape[%d]: %d", i, arr.shape[i]);
// }
// LOG("Succeeded loading data from .npy/.npz!");
// LOG("Tensor values:");
// printTensorNPY(arr);
这段注释的代码演示了如何从 python 中 numpy 保存的 .npz
文件中加载数据
- 首先通过
cnpy:npz_load
加载.npz
文件,该文件是 python 脚本保存的。npz_load
返回一个npz_t
类型的对象(通常是一个键值对的字典),其中包含了.npz
文件中所有数组的名字和数据 npz_data["data_python"]
通过键名data_python
从npz_data
中获取对应的数组- 接着循环遍历数组的维度并打印每个维度的大小
- 最后通过
printTensorNPY
函数打印加载的数组内容
printTensorNPY
内容如下:
string printTensor(float* tensor, int size){
int n = 0;
char buff[100];
string result;
n += snprintf(buff + n, sizeof(buff) - n, "[ ");
for (int i = 0; i < size; i++){
n += snprintf(buff + n, sizeof(buff) - n, "%8.4lf", tensor[i]);
if (i != size - 1){
n += snprintf(buff + n, sizeof(buff) - n, ", ");
}
}
n += snprintf(buff + n, sizeof(buff) - n, " ]");
result = buff;
return result;
}
void printTensorNPY(cnpy::NpyArray arr){
float* data = arr.data<float>();
int size = arr.num_bytes() / sizeof(float);
int chw_size = arr.shape[1] * arr.shape[2] * arr.shape[3];
int hw_size = arr.shape[2] * arr.shape[3];
int w_size = arr.shape[3];
printTensor(data, size, chw_size, hw_size, w_size);
}
printTensorNPY
函数首先从 cnpy::NpyArray
中提取数据,并计算一些用于表示维度的信息,然后调用 printTensor
将数据转换成字符串格式,便于输出和调试
/*
在c++下保存一个python可以识别的npy/npz file
*/
const int b = 3;
const int c = 2;
const int h = 4;
const int w = 4;
int size = b * c * h * w;
float* data = (float*)malloc(size * sizeof(float));
initTensor(data, size, 0, 1, 0);
cnpy::npz_save("data/data_cpp.npz", "data_cpp", &data[0], {b, c, h, w}, "w");
cnpy::npy_save("data/data_cpp.npy", &data[0], {b, c, h, w}, "w");
LOG("Succeeded saving data as .npy/.npz!");
LOG("Tensor values:");
printTensorCXX(data, b, c, h, w);
这段代码则展示了如何在 C++ 中创建一个数组并将其保存为 .npy
和 .npz
文件:
- 首先定义了一个四维数组,其形状为
[3, 2, 4, 4]
- 接着使用
malloc
为数组分配内存,然后调用initTensor
函数初始化数组的内容 cpny::npz_save
和cnpy::npy_save
分别用于保存数组为.npz
和.npy
文件。.npz
是一个压缩格式,可以包含多个数组,而.npy
是一个单数组的二进制格式- 最后调用
printTensorCXX
函数打印数组内容
initTensor
函数内容如下:
void initTensor(float* data, int size, int min, int max, int seed) {
srand(seed);
for (int i = 0; i < size; i ++) {
data[i] = float(rand()) * float(max - min) / RAND_MAX;
}
}
该函数通过使用 rand()
函数生成随机浮点数,填充给定的数组 data
printTensorCXX
内容如下:
void printTensor(float* data, int size, int chw_size, int hw_size, int w_size){
printf("[");
for (int i = 0; i < size; i += chw_size) {
(i == 0) ? printf("[") : printf(" [");
for (int j = 0; j < chw_size; j += hw_size) {
(j == 0) ? printf("[") : printf(" [");
for (int k = 0; k < hw_size; k += w_size) {
(k == 0) ? printf("[") : printf(" [");
for (int p = 0; p < w_size; p ++) {
printf("%.8lf", data[i + j + k + p]);
if (k != w_size - 1) printf(" ");
}
(k == hw_size - w_size) ? printf("]") : printf("]\n");
}
(j == chw_size - hw_size) ? printf("]") : printf("]\n\n");
}
(i == size - chw_size) ? printf("]") : printf("]\n\n\n");
}
printf("]\n");
}
void printTensorCXX(float* data, int b, int c, int h, int w){
int size = b * c * h * w;
int chw_size = c * h * w;
int hw_size = h * w;
int w_size = w;
printTensor(data, size, chw_size, hw_size, w_size);
}
printTensorCXX
函数依旧是调用 printTensor
将数据转换成字符串格式
3.2 create_data.py
我们再对应看下 python 脚本中是如何保存和加载 tensor 数据的,代码如下:
import os
import numpy as np
from logger import init_logger
logger = init_logger()
def createData(data_path, data_label, data_shape):
dataDict = {}
dataDict[data_label] = np.random.rand(*data_shape).astype(np.float32)
np.savez(data_path, **dataDict)
logger.info("Succeeded saving data as .npz file!")
logger.info("Tensor shape:{}".format(dataDict[data_label].shape))
logger.info("Tensor values:")
print(dataDict[data_label])
return
def loadData(data_path, data_label):
dataDict = np.load(data_path)
logger.info("Succeeded loaded data as .npz file!")
logger.info("Tensor shape:{}".format(dataDict[data_label].shape))
logger.info("Tensor values:")
print(dataDict[data_label])
return
if __name__ == "__main__":
np.set_printoptions(formatter={'float': '{: .8f}'.format})
current_path = os.path.dirname(__file__)
data_path = current_path + "/../../data/data_python.npz"
data_label = "data_python"
data_shape = (2, 3, 4, 4)
# createData(data_path, data_label, data_shape)
data_path = current_path + "/../../data/data_cpp.npz"
data_label = "data_cpp"
loadData(data_path, data_label)
该脚本主要用于创建和加载 .npz
文件中的张量数据,并输出数据的形状和内容
def createData(data_path, data_label, data_shape):
dataDict = {}
dataDict[data_label] = np.random.rand(*data_shape).astype(np.float32)
np.savez(data_path, **dataDict)
logger.info("Succeeded saving data as .npz file!")
logger.info("Tensor shape:{}".format(dataDict[data_label].shape))
logger.info("Tensor values:")
print(dataDict[data_label])
return
我们先看下 createData
函数,看它是如何保存一个 .npz
数据的:
dataDict = {}
:创建一个空字典,用于存储生成的数据dataDict[data_label] = np.random.rand(*data_shape).astype(np.float32)
:使用numpy
生成指定形状的随机张量数据,数值范围为[0,1)
,并将其转换为float32
类型。生成的张量以data_label
作为键存储在dataDict
字典中np.savez(data_path, **dataDict)
:使用numpy
的savez
函数将数据字典保存为.npz
文件- 最后日志记录和打印
def loadData(data_path, data_label):
dataDict = np.load(data_path)
logger.info("Succeeded loaded data as .npz file!")
logger.info("Tensor shape:{}".format(dataDict[data_label].shape))
logger.info("Tensor values:")
print(dataDict[data_label])
return
再来看下如何加载一个 .npz
数据:
dataDict = np.load(data_path)
:直接使用numpy
的load
函数加载.npz
文件,并返回一个包含文件中所有数组的字典- 最后日志记录和打印
if __name__ == "__main__":
np.set_printoptions(formatter={'float': '{: .8f}'.format})
current_path = os.path.dirname(__file__)
data_path = current_path + "/../../data/data_python.npz"
data_label = "data_python"
data_shape = (2, 3, 4, 4)
# createData(data_path, data_label, data_shape)
data_path = current_path + "/../../data/data_cpp.npz"
data_label = "data_cpp"
loadData(data_path, data_label)
在主函数部分,通过 createData
函数生成数据并保存,然后使用 loadData
函数读取数据
OK,以上就是使用 cnpy 库的一些案例分析,相对来说还是比较简单的
结语
本次课程我们学习了 cnpy 库,它可以将 c++ 数据保存成 .npz 的格式并在 python 中加载读取,这个其实在我们 debug 时非常有帮助,我们在模型部署时经常会遇到 c++ 和 python 精度不对齐的情况,我们可以利用这个库将 c++ 的结果和 python 结果进行对比并进一步排查问题
OK,以上就是 7.1 小节案例的全部内容了,下节我们来学习 7.2 小节的 affine-transformation 仿射变换,敬请期待😄