目录
- 前言
- 0. 简述
- 1. 案例运行
- 2. 代码分析
- 2.1 main.cpp
- 2.2 preprocess.cpp
- 3. 补充说明
- 结语
- 下载链接
- 参考
前言
自动驾驶之心推出的 《CUDA与TensorRT部署实战课程》,链接。记录下个人学习笔记,仅供自己参考
本次课程我们来学习课程第六章—部署分类器,一起来学习 CPU 端图像预处理方法以及速度对比
课程大纲可以看下面的思维导图
0. 简述
本小节目标:学习 CPU 端 bgr2rgb + normalization + hwc2chw 等图像预处理操作以及它们的性能比较
这节课程开始我们进入第六章节—部署分类器,这个章节偏实战,为大家准备了几个案例:
- 6.0-preprocess-speed-compare
- 6.1-deploy-classification
- 6.2-deploy-classification-advanced
- 6.3-int8-calibration
- 6.4-trt-engine-inspector
第六章节准备的案例一共是五个,第一个是 preprocess-speed-compare,这个小节主要教大家如果用 CPU 做图像预处理都有哪些方法,哪种方法访问图像速度更快;6.1 小节主要给大家介绍初步的分类器部署该怎么做,这个小节为了方便大家理解整个代码写得比较简单,也没有涉及到任何 C++ 的设计模式,所以整个代码看起来有很多缺陷;6.2 小节是针对 6.1 小节的一个扩展,主要是把 6.1 小节中的很多问题给解决掉,另外我们自己在写推理框架的时候应该考虑哪些东西
6.3 小节给大家介绍 int8 calibration,我们在前面或多或少都有涉及到量化这个概念,其中校准是量化的一个重要环节,校准包括很多校准器比如 MinMaxCalibrator、EntropyCalibrator、LegacyCalibrator 等等,我们在部署时该如何使用这些校准器呢?选择哪个校准器呢?这都是我们在 6.3 小节需要讨论的问题;最后 6.4 小节主要给大家介绍 TensorRT 官方工具 trt-engine-explorer,这个工具主要是帮助大家观察经过 TensorRT 优化前后的推理引擎在架构上有什么不同,去理解 TensorRT 做了哪些优化,哪些层融合了,哪些节点添加了,哪些节点被删除了,这个方便我们更好的去理解 TensorRT 的优化,同时可以帮助我们分析 TensorRT 中哪些优化是可以进一步改善的
后续的一些案例主要是讲 Transformer 的一些部署以及面临的一些问题,比如 attention 计算瓶颈,LayerNormalization 这种节点和 CNN 中 Conv 这种节点相比推理性能差异又在哪里,还有纯 Transformer 模型和纯 CNN 模型以及 CNN+Transformer 模型相比它们的计算效率以及计算密度有什么不同(目前 2024/8/17 尚未更新)
下面我们开始本次课程的学习🤗
1. 案例运行
在正式开始课程之前,博主先带大家跑通 6.0-preprocess-speed-compare 这个小节的案例🤗
源代码获取地址:https://github.com/kalfazed/tensorrt_starter
首先大家需要把 tensorrt_starter 这个项目给 clone 下来,指令如下:
git clone https://github.com/kalfazed/tensorrt_starter.git
也可手动点击下载,点击右上角的 Code
按键,将代码下载下来。至此整个项目就已经准备好了。也可以点击 here 下载博主准备好的源代码(注意代码下载于 2024/7/14 日,若有改动请参考最新)
整个项目后续需要使用的软件主要有 CUDA、cuDNN、TensorRT、OpenCV,大家可以参考 Ubuntu20.04软件安装大全 进行相应软件的安装,博主这里不再赘述
假设你的项目、环境准备完成,下面我们一起来运行下 6.0-preprocess-speed-compare 小节案例代码
我们需要修改下整体的 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 修改为你自己安装的路径即可
接着我们就可以来执行编译,指令如下:
make -j64
输出如下:
接着执行:
./bin/trt-infer
输出如下:
我们这里对比了 CPU 端进行 bgr2rgb+norm+hwc2chw 预处理的五种不同方法执行的速度对比,在终端可以看到各个方法处理的时间,另外在 data 文件夹下保存着处理后的图片,如下所示:
Note:博主的 CPU 是 12th Gen Intel® Core™ i5-12400F,每次执行结果都不一样会有波动这个很正常,不同 CPU 测量出来的结果可能会有差异,另外图像分辨率不同时间也会有差异
如果大家能够看到上述输出结果,那就说明本小节案例已经跑通,下面我们就来看看具体的代码实现
2. 代码分析
2.1 main.cpp
我们先从 main.cpp 看起:
#include <iostream>
#include <memory>
#include "model.hpp"
#include "utils.hpp"
#include "timer.hpp"
#include "preprocess.hpp"
using namespace std;
void bgr2rgb_cpu_speed_test(){
Timer timer;
string imagePath = "data/fox.png";
string savePath = "";
cv::Mat src = cv::imread(imagePath);
cv::Mat tar(src.rows, src.cols, CV_8UC3);
LOG("Starting cpu bgr2rgb speed test...");
timer.start_cpu();
preprocess_cv_cvtcolor(src, tar);
timer.stop_cpu();
timer.duration_cpu<Timer::ms>("Using cv::cvtcolor takes");
savePath = "data/fox-cvtcolor.png";
cv::imwrite(savePath, tar);
timer.start_cpu();
preprocess_cv_mat_at(src, tar);
timer.stop_cpu();
timer.duration_cpu<Timer::ms>("Using cv::Mat::at takes");
savePath = "data/fox-mat-at.png";
cv::imwrite(savePath, tar);
timer.start_cpu();
preprocess_cv_mat_iterator(src, tar);
timer.stop_cpu();
timer.duration_cpu<Timer::ms>("Using cv::MatIterator_ takes");
savePath = "data/fox-mat-iterator.png";
cv::imwrite(savePath, tar);
timer.start_cpu();
preprocess_cv_mat_data(src, tar);
timer.stop_cpu();
timer.duration_cpu<Timer::ms>("Using cv::Mat data takes");
savePath = "data/fox-mat-data.png";
cv::imwrite(savePath, tar);
timer.start_cpu();
preprocess_cv_pointer(src, tar);
timer.stop_cpu();
timer.duration_cpu<Timer::ms>("Using pointer takes");
savePath = "data/fox-pointer.png";
cv::imwrite(savePath, tar);
}
void bgr2rgb_norm_hwc2chw_cpu_speed_test(){
Timer timer;
string imagePath = "data/fox.png";
cv::Mat src = cv::imread(imagePath);
int size = src.cols * src.rows * src.channels();
float* tar = (float*)malloc(size * sizeof(float));
int width = 224;
int height = 224;
int channel = 3;
int classes = 1000;
float mean[3] = {0.406, 0.456, 0.485};
float std[3] = {0.225, 0.224, 0.229};
cv::resize(src, src, cv::Size(width, height));
LOG("Starting cpu bgr2rgb + normalization + hwc2chw speed test...");
timer.start_cpu();
preprocess_cv_mat_at(src, tar, mean, std);
timer.stop_cpu();
timer.duration_cpu<Timer::ms>("Using cv::Mat::at takes");
timer.start_cpu();
preprocess_cv_mat_iterator(src, tar, mean, std);
timer.stop_cpu();
timer.duration_cpu<Timer::ms>("Using cv::MatIterator_ takes");
timer.start_cpu();
preprocess_cv_mat_data(src, tar, mean, std);
timer.stop_cpu();
timer.duration_cpu<Timer::ms>("Using cv::Mat data takes");
timer.start_cpu();
preprocess_cv_pointer(src, tar, mean, std);
timer.stop_cpu();
timer.duration_cpu<Timer::ms>("Using pointer takes");
timer.start_cpu();
preprocess_cv_array(src, tar, mean, std);
timer.stop_cpu();
timer.duration_cpu<Timer::ms>("Using array takes");
}
int main(int argc, char const *argv[])
{
// bgr2rgb_cpu_speed_test();
bgr2rgb_norm_hwc2chw_cpu_speed_test();
}
上述代码主要用于测试不同方法在 OpenCV 中进行图像预处理操作时的速度,主要有 bgr2rgb_cpu_speed_test()
和 bgr2rgb_norm_hwc2chw_cpu_speed_test()
两个函数,它们分别测量了不同方法在特定预处理操作中的耗时情况
bgr2rgb_cpu_speed_test() 函数
- 该函数测试了使用不同方法将图像从 BGR 色彩空间转换为 RGB 色彩空间的速度
preprocess_cv_cvtcolor()
方法- 使用
cv::cvtColor
函数将图像从 BGR 转换为 RGB
- 使用
preprocess_cv_mat_at()
方法- 使用
cv::Mat::at
方法逐元素访问并修改图像
- 使用
preprocess_cv_mat_iterator()
方法- 使用
cv::MatIterator_
遍历图像矩阵
- 使用
preprocess_cv_mat_data()
方法- 直接通过指针访问图像数据
preprocess_cv_pointer()
方法- 手动通过指针运算来访问和修改元素
bgr2rgb_norm_hwc2chw_cpu_speed_test() 函数
- 该函数测试了进行 BGR 到 RGB 转换、归一化以及 HWC 到 CHW 转换组合操作的速度
preprocess_cv_mat_at()
方法- 使用
cv::Mat::at
方式访问和处理像素
- 使用
preprocess_cv_mat_iterator()
方法- 使用
cv::MatIterator_
进行像素处理
- 使用
preprocess_cv_mat_data()
方法- 通过指针直接访问图像数据进行处理
preprocess_cv_pointer()
方法- 使用手动指针运算
在 main.cpp 中我们实现了一个初步的在 CPU 端的前处理的性能比较,比较的是对于访问 CV::Mat
的数据,哪一种方式会比较快。因为有的时候我们可能会考虑在 CPU 上做前处理,比如说图像前处理放在 GPU 上时并不能充分把硬件资源吃满,导致硬件资源浪费,如果出现这种情况的话,我们可能会考虑把前处理放在 CPU 上,而把 DNN 的 forward 部分放在 GPU 上进行异步的推理
这里主要比较四种方法:
- 使用
cv::Mat::at
- 使用
cv::MatIterator_
- 使用
cv::Mat.data
- 使用
cv::Mat.ptr
同时我们在 main.cpp 中也比较了一下 CPU 端做 bgr2rgb 和 bgr2rgb + normalization + hwc2chw 的性能比较
2.2 preprocess.cpp
下面我们一起来看具体的函数实现,我们先从 bgr2rgb 函数中的方法实现看起
preprocess_cv_cvtcolor()
方法具体实现代码如下:
void preprocess_cv_cvtcolor(cv::Mat src, cv::Mat tar){
cv::cvtColor(src, tar, cv::COLOR_RGB2BGR);
}
该方法非常的简单,主要是利用 OpenCV 提供的 cv::cvtColor
函数来完成从 RGB 到 BGR 色彩空间的转换
preprocess_cv_mat_at()
方法具体实现代码如下:
void preprocess_cv_mat_at(cv::Mat src, cv::Mat tar){
for (int i = 0; i < src.rows; i++) {
for (int j = 0; j < src.cols; j++) {
tar.at<cv::Vec3b>(i, j)[2] = src.at<cv::Vec3b>(i, j)[0];
tar.at<cv::Vec3b>(i, j)[1] = src.at<cv::Vec3b>(i, j)[1];
tar.at<cv::Vec3b>(i, j)[0] = src.at<cv::Vec3b>(i, j)[2];
}
}
}
该方法通过双重循环遍历图像中的每一个像素,外层循环遍历图像每一行,内层循环遍历图像每一列,接着通过 .at<cv::Vec3b>
访问目标图像和源图像中的每一个像素并进行相应的通道转换,其中 cv::Vec3b
表示一个包含三个 uchar
值的向量即一个像素,它对应 BGR 三个通道的值
这种方法的优点在于直接访问和操作,可以允许你精确地控制每个像素地操作,在需要对像素级别进行复杂的自定义操作时非常有用,但是它的速度较慢,当图像较大时这种方法的效率会成为一个瓶颈
preprocess_cv_mat_iterator()
方法具体实现代码如下:
void preprocess_cv_mat_iterator(cv::Mat src, cv::Mat tar){
cv::MatIterator_<cv::Vec3b> src_it = src.begin<cv::Vec3b>();
cv::MatIterator_<cv::Vec3b> tar_it = tar.begin<cv::Vec3b>();
cv::MatIterator_<cv::Vec3b> end = src.end<cv::Vec3b>();
for (; src_it != end; src_it++, tar_it++) {
(*tar_it)[2] = (*src_it)[0];
(*tar_it)[1] = (*src_it)[1];
(*tar_it)[0] = (*src_it)[2];
}
}
该方法利用 OpenCV 提供的 cv::MatIterator_
迭代器来遍历和操作图像的每个像素,相较于 cv::Mat::at
方法,迭代器提供了一种更直接的方式来遍历矩阵,并且在某些情况下可以提高代码的执行效率。首先我们初始化三个迭代器,其中 src_it
指向源图像的起始位置,tar_it
指向目标图像的起始位置,end
指向源图像的结束位置,接着我们进行迭代循环和转换操作
该方法使用迭代器的方式使得代码更加简洁,并且迭代器的运算通常比 cv::Mat::at
更加高效,但是它的性能未必最佳,对于需要更高性能的场景直接操作指针或 cv::Mat::data
可能更合适
preprocess_cv_mat_data()
方法具体实现如下:
void preprocess_cv_mat_data(cv::Mat src, cv::Mat tar){
int height = src.rows;
int width = src.cols;
int channels = src.channels();
for (int i = 0; i < height; i ++) {
for (int j = 0; j < width; j ++) {
int index = i * width * channels + j * channels;
tar.data[index + 2] = src.data[index + 0];
tar.data[index + 1] = src.data[index + 1];
tar.data[index + 0] = src.data[index + 2];
}
}
}
该方法直接使用指针访问图像数据,逐个像素进行 BGR 到 RGB 的转换,相比于之前的方法,这种方式更加接近底层操作,能够提高更高的性能,但代码的可读性相对较低
首先外层循环遍历图像的每一行,内层循环遍历图像的每一列,接着计算像素索引,通过像素索引去访问每一个像素从而实现颜色通道交换。其中 src.data
和 tar.data
都是一个 uchar*
类型的指针,指向图像数据在内存中的起始位置,通过直接操作这个指针,可以访问和修改图像的原始数据
preprocess_cv_pointer()
方法具体实现如下:
void preprocess_cv_pointer(cv::Mat src, cv::Mat tar){
for (int i = 0; i < src.rows; i ++) {
cv::Vec3b* src_ptr = src.ptr<cv::Vec3b>(i);
cv::Vec3b* tar_ptr = tar.ptr<cv::Vec3b>(i);
for (int j = 0; j < src.cols; j ++) {
tar_ptr[j][2] = src_ptr[j][0];
tar_ptr[j][1] = src_ptr[j][1];
tar_ptr[j][0] = src_ptr[j][2];
}
}
}
该方法通过使用指针来遍历和操作图像的每个像素进行 BGR 到 RGB 的颜色变换,相比直接访问图像数据,这种方法利用了指针的优势,同时保持了相对较好的可读性和操作的灵活性
首先我们逐行指针操作,外层循环遍历图像的每一行,接着进行行指针初始化,然后我们逐列操作,内存循环遍历图像的每一列,此时通过指针 src_ptr[i]
和 tar_ptr[j]
可以直接访问和操作每个像素
我们执行下看下输出如下所示:
我们从图中可以看到速度最快的是最后两个使用指针操作的方法,相比之下直接使用 cv::cvtColor
方法耗时最严重
Note:博主每次执行的结果都不尽相同,但是总的来说使用指针操作的方法是速度最快的,大家以自己实际的 CPU 执行结果为准就行
下面我们来看 bgr2rgb_norm_hwc2chw 函数的实现,那大部分其实和 bgr2rgb 的方法差不多,只是添加了一个 normalization 以及 hwc2chw 操作,这里我们就简单过一下
preprocess_cv_mat_at()
方法具体实现如下:
void preprocess_cv_mat_at(cv::Mat src, float* tar, float* mean, float* std){
float* ptar_ch0 = tar + src.rows * src.cols * 0;
float* ptar_ch1 = tar + src.rows * src.cols * 1;
float* ptar_ch2 = tar + src.rows * src.cols * 2;
for (int i = 0; i < src.rows; i++) {
for (int j = 0; j < src.cols; j++) {
(*ptar_ch2++) = (src.at<cv::Vec3b>(i, j)[0] / 255.0f - mean[0]) / std[0];
(*ptar_ch1++) = (src.at<cv::Vec3b>(i, j)[1] / 255.0f - mean[1]) / std[1];
(*ptar_ch0++) = (src.at<cv::Vec3b>(i, j)[2] / 255.0f - mean[2]) / std[2];
}
}
}
该方法首先初始化目标指针,其中 ptar_ch0
、ptar_ch1
、ptar_ch2
分别指向目标数组 tar
中 R、G、B 三个通道的起始位置,这种布局方式确保图像数据在内存中按通道顺序存储,而不是按像素顺序存储,即 CHW 格式。接着双重循环遍历图像中的每个像素,外层循环遍历图像的每一行,内存循环遍历图像的每一列,最后进行颜色空间转换和归一化
preprocess_cv_mat_iterator()
方法具体实现如下:
void preprocess_cv_mat_iterator(cv::Mat src, float* tar, float* mean, float* std){
float* ptar_ch0 = tar + src.rows * src.cols * 0;
float* ptar_ch1 = tar + src.rows * src.cols * 1;
float* ptar_ch2 = tar + src.rows * src.cols * 2;
cv::MatIterator_<cv::Vec3b> it = src.begin<cv::Vec3b>();
cv::MatIterator_<cv::Vec3b> end = src.end<cv::Vec3b>();
for (; it != end; it++) {
(*ptar_ch2++) = ((*it)[0] / 255.0f - mean[0]) / std[0];
(*ptar_ch1++) = ((*it)[1] / 255.0f - mean[1]) / std[1];
(*ptar_ch0++) = ((*it)[2] / 255.0f - mean[2]) / std[2];
}
}
该方法使用迭代器 cv::MatIterator_
来遍历图像数据,并在遍历过程中完成图像数据的 BGR 到 RGB 转换、归一化操作以及 HWC 到 CHW 格式的转换
preprocess_cv_mat_data()
方法具体实现如下:
void preprocess_cv_mat_data(cv::Mat src, float* tar, float* mean, float* std){
float* ptar_ch0 = tar + src.rows * src.cols * 0;
float* ptar_ch1 = tar + src.rows * src.cols * 1;
float* ptar_ch2 = tar + src.rows * src.cols * 2;
int height = src.rows;
int width = src.cols;
int channels = src.channels();
for (int i = 0; i < height; i ++) {
for (int j = 0; j < width; j ++) {
int index = i * width * channels + j * channels;
(*ptar_ch2++) = (src.data[index + 0] / 255.0f - mean[0]) / std[0];
(*ptar_ch1++) = (src.data[index + 1] / 255.0f - mean[1]) / std[1];
(*ptar_ch0++) = (src.data[index + 2] / 255.0f - mean[2]) / std[2];
}
}
}
该方法直接操作图像的内存数据,通过计算每个像素在内存中的索引来进行 BGR 到 RGB 的转换、归一化处理以及从 HWC 到 CHW 格式的转换,相比于使用迭代器或 cv::Mat::at
方法,这种方法更加接近底层,通常在性能上更有优势
preprocess_cv_pointer()
方法具体实现如下:
void preprocess_cv_pointer(cv::Mat src, float* tar, float* mean, float* std){
int area = src.rows * src.cols;
int offset_ch0 = area * 0;
int offset_ch1 = area * 1;
int offset_ch2 = area * 2;
for (int i = 0; i < src.rows; i ++) {
cv::Vec3b* src_ptr = src.ptr<cv::Vec3b>(i);
for (int j = 0; j < src.cols; j ++) {
tar[offset_ch2++] = (src_ptr[j][0] / 255.0f - mean[0]) / std[0];
tar[offset_ch1++] = (src_ptr[j][1] / 255.0f - mean[1]) / std[1];
tar[offset_ch0++] = (src_ptr[j][2] / 255.0f - mean[2]) / std[2];
}
}
}
该方法同时使用指针来访问和处理图像数据,完成 BGR 到 RGB 的颜色空间转换、归一化处理以及从 HWC 到 CHW 格式的转换,这种方法结合了指针的高效性,通常在性能上有不错的表现
我们执行下看下输出如下所示:
我们从图中可以看到速度最快的依旧是指针操作的方法,速度最慢的是逐像素访问的方法
Note:博主每次执行的结果都不尽相同,但是总的来说使用指针操作的方法是速度最快的,大家以自己实际的 CPU 执行结果为准就行
3. 补充说明
值得注意的是这个小节的 Makefile 也有一些改动,主要体现在 bear 工具的使用,如下所示:
ifeq (, $(shell which bear))
BEARCMD :=
else
ifeq (bear 3.0.18, $(shell bear --version))
BEARCMD := bear --output config/compile_commands.json --
else
BEARCMD := bear -o config/compile_commands.json
endif
endif
我们前面有提到过 bear 是一个用于生成 compile_commands.json
文件的工具,这个文件通常用于启用基于 clangd 或其他类似工具的代码分析和自动补全功能。bear 工具会拦截编译命令并记录它们,从而生成描述如何构建项目中每个源文件的 JSON 数据
另外在不同的 Ubuntu 系统中,bear 的指令可能有所不同,这个主要取决于安装的 bear 版本,在最新的 Ubuntu22.04 系统中推荐使用的 bear 版本是 3.0.18,比较老的 Ubuntu18.04 和 Ubuntu20.04 推荐使用的 bear 版本是 2.6 或者 2.7
关于 bear 的安装我们可以直接通过 apt-get 安装,指令如下:
sudo apt-get update
sudo apt-get install bear
当然我们也可以从源码编译安装,指令如下:
git clone https://github.com/rizsotto/Bear.git
cd Bear
git checkout tags/3.0.18
mkdir build && cd build
cmake .. && make
sudo make install
结语
本次课程我们学习了 CPU 端图像预处理的几种方法,包括逐像素方法,迭代器方法以及指针方法,其中指针方法耗时最慢,速度最快。当我们处理的图像较小在 GPU 端计算密度不高时我们可以采用这个小节所说的几种在 CPU 端处理的方法
OK,以上就是 6.0 小节案例的全部内容了,下节我们来学习 6.1 小节分类器的简单部署实现,敬请期待😄
下载链接
- tensorrt_starter源码
参考
- Ubuntu20.04软件安装大全
- https://github.com/kalfazed/tensorrt_starter.git