TensorRT部署YOLOv5(04) 构建TensorRT引擎

在上一篇文章中,已经对什么是TensorRT,使用TensorRT进行深度学习模型部署推理的完整流程进行了初步的介绍。本文将详细介绍TensorRT引擎的构建,包括tf2onnx的使用、trtexec命令行的使用和重要参数介绍、如何使用Python API进行TensorRT引擎构建等

Tensorflow模型转换为ONNX

使用以下命令安装tf2onnx

1
pip install tf2onnx -i https://pypi.tuna.tsinghua.edu.cn/simple

tf2onnx是以Python命令行模块的形式使用的,可在终端中输入以下内容,查看tf2onnx的帮助信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
python -m tf2onnx -h
optional arguments:
-h, --help show this help message and exit
--input INPUT input from graphdef
--graphdef GRAPHDEF input from graphdef
--saved-model SAVED_MODEL
input from saved model
--tag TAG tag to use for saved_model
--signature_def SIGNATURE_DEF
signature_def from saved_model to use
--concrete_function CONCRETE_FUNCTION
For TF2.x saved_model, index of func signature in __call__ (--signature_def is ignored)
--checkpoint CHECKPOINT
input from checkpoint
--keras KERAS input from keras model
--tflite TFLITE input from tflite model
--tfjs TFJS input from tfjs model
--large_model use the large model format (for models > 2GB)
--output OUTPUT output model file
--inputs INPUTS model input_names (optional for saved_model, keras, and tflite)
--outputs OUTPUTS model output_names (optional for saved_model, keras, and tflite)
--ignore_default IGNORE_DEFAULT
comma-separated list of names of PlaceholderWithDefault ops to change into Placeholder ops
--use_default USE_DEFAULT
comma-separated list of names of PlaceholderWithDefault ops to change into Identity ops using their default value
--rename-inputs RENAME_INPUTS
input names to use in final model (optional)
--rename-outputs RENAME_OUTPUTS
output names to use in final model (optional)
--use-graph-names (saved model only) skip renaming io using signature names
--opset OPSET opset version to use for onnx domain
--dequantize remove quantization from model. Only supported for tflite currently.
--custom-ops CUSTOM_OPS
comma-separated map of custom ops to domains in format OpName:domain. Domain 'ai.onnx.converters.tensorflow' is used by default.
--extra_opset EXTRA_OPSET
extra opset with format like domain:version, e.g. com.microsoft:1
--load_op_libraries LOAD_OP_LIBRARIES
comma-separated list of tf op library paths to register before loading model
--target {rs4,rs5,rs6,caffe2,tensorrt,nhwc}
target platform
--continue_on_error continue_on_error
--verbose, -v verbose output, option is additive
--debug debug mode
--output_frozen_graph OUTPUT_FROZEN_GRAPH
output frozen tf graph to file
--inputs-as-nchw INPUTS_AS_NCHW
transpose inputs as from nhwc to nchw
--outputs-as-nchw OUTPUTS_AS_NCHW
transpose outputs as from nhwc to nchw

Usage Examples:

python -m tf2onnx.convert --saved-model saved_model_dir --output model.onnx
python -m tf2onnx.convert --input frozen_graph.pb --inputs X:0 --outputs output:0 --output model.onnx
python -m tf2onnx.convert --checkpoint checkpoint.meta --inputs X:0 --outputs output:0 --output model.onnx

For help and additional information see:
https://github.com/onnx/tensorflow-onnx

tf2onnx可以接收很多种类型的深度学习模型格式,例如Tensorflow的.pb、checkpoint、.h5、tflite、tfjs等,也可以接收keras模型,在本实验中,我最开始使用.h5和checkpoint并未成功,原因未找到,.pb是可以正确转换的,后续没有再对该问题进行研究,一直使用.pb格式

将Tensorflow导出的model.pb模型转换为ONNX模型model.onnx的命令如下

1
2
python -m tf2onnx.convert --saved-model model.pb --output model.onnx \
--inputs-as-nchw input

使用--inputs-as-nchw input的原因是Tensorflow与ONNX使用的内存排布方式不同,Tensorflow中使用的是NHWC,而ONNX使用的是NCHW,因此添加该转换参数,将输入的内存排布修改未NCHW

tf2onnx的--opset可以手动指定opset version,因为ONNX和TensorRT的版本存在一些对应关系,如果ONNX模型转换TensorRT过程中存在问题,可能是opset version的问题,有可能可以通过手动指定opset version解决。本实验不存在这种问题,因此未手动指定opset,使用默认opset进行转换

Nvidia为适配ONNX推出了一个ONNX GraphSurgeon](https://docs.nvidia.com/deeplearning/tensorrt/onnx-graphsurgeon/docs/index.html))工具,该工具可以对ONNX的模型计算图进行一些节点修改,可以通过该工具解决一些算子不匹配问题或者进行模型计算图优化。本实验未使用ONNX GraphSurgeon对模型进行修改,这部分的详细内容本专栏不涉及

BatchSize

这里的BatchSize指的是模型推理过程输入数据的BatchSize。TensorRT既支持固定的BatchSize,也支持动态BatchSize(Dynamic Shape),固定的BatchSize能够让TensorRT在构建期更好的优化模型。Dynamic Shape能够让开发者在构建期不指定InputShape,而在运行期再通过API来指定。本实验由于是在JestonNano上进行YOLOv5模型推理,JestonNano本身性能算力有限,因此本实验中在ONNX模型中手动固定了BatchSize为1,以下为使用onnx修改BatchSize的代码

1
2
3
4
5
6
7
8
9
10
11
import onnx

onnx_model = onnx.load_model('model.onnx')

batch_size = 1
inputs = onnx_model.graph.input
for input in inputs:
dim1 = input.type.tensor_type.shape.dim[0]
dim1.dim_value = batch_size
model_name = 'model-bs1.onnx'
onnx.save_model(onnx_model, model_name)

ONNX模型转换为TensorRT引擎

ONNX模型转换为TensorRT引擎有两种方式

  • trtexec命令行工具

  • TensorRT API

我比较推荐使用trtexec命令行工具来进行转换,该工具不仅可以进行推理引擎的转换,还可以进行转换后的模型推理性能测试,而且转换过程只需要选取正确的参数即可,不需要额外编写代码

使用trtexec转换模型为TensorRT引擎

默认的trtexec命令并没有添加到环境变量,而是位于/usr/src/tensorrt/bin目录下,将model-bs1.onnx转换为TensorRT引擎的命令如下

1
/usr/src/tensorrt/bin/trtexec --onnx=model-bs1.onnx --saveEngine=model.trt --workspace=3200

以上命令执行后,trtexec开始工作,这需要一段时间,trtexec首先会将ONNX格式进行解析,并进行适用于在GPU上推理的计算图优化过程,然后是各层具体实现算子及参数的自动调优,整个过程都是自动进行的,用户所需要做的仅仅是输入以上命令,然后等待即可

有几点需要说明,首先是trtexec并不仅仅可以接收ONNX格式,也可以接收一些其他格式的模型,本文仅使用了ONNX格式,因此对于其他格式不在此介绍。--saveEngine后跟要输出的引擎文件名称,这里输入的”model.trt”只是一个文件名字,可以输入任何合法的名称。--workspace是为trtexec指定的转换过程可用内存空间,单位是MB,由于JestonNano开发板内存大小为4G,以上命令是按照板子实际可用内存配置的,用户需按照自己的实际情况进行设置,太小的数值可能导致构建过程失败

转换精度

转换过程非常重要的一点是转换精度,不同精度的引擎,引擎大小及内部参数占用内内存大小差异很大,由于TensorRT使用GPU进行推理,整个过程涉及到CPU和GPU之间的内存拷贝,较低精度的模型能够在很大程度上的降低内存拷贝和访存时间,对推理速度的影响很大,代价是牺牲一些精度。默认未指定转换精度的情况下,trtexec生成的推理引擎是FP32的,JestonNano上最低支持的精度是FP16,INT8精度在Nvidia NX等更高端的计算平台上才支持,因此INT8精度转换及后续处理不在本系列讨论范围内,需要注意的是INT8精度转换过程需要进行校准操作,需要提供一组校准数据集(unlabel)以及指定校准模式等操作,详细内容可以在TensorRT官方文档中找到。要将模型转换为FP16精度,可按照以下命令操作

1
/usr/src/tensorrt/bin/trtexec --onnx=model-bs1.onnx --saveEngine=xxx-fp16.trt --fp16 --workspace=3200

只需要增加--fp16选项即可。需要清楚一点,这里使用--fp16选项,trtexec转换后的模型,输入层和输出层依然是fp32,只有中间层是fp16,这一点在后续推理章节会详细介绍。如果要让输入层和输出层也变为fp16,需要增加--inputIOFormats--outputIOFormats选项,如下

1
2
/usr/src/tensorrt/bin/trtexec --onnx=model-bs1.onnx --saveEngine=xxx-fp16.trt --explicitBatch 
--inputIOFormats=fp16:chw --outputIOFormats=fp16:chw --fp16 --workspace=3200

FP16比FP32转换所需要的时间更长

转换输出信息

trtexec转换过程末尾的输出信息非常重要,对引擎的推理性能进行了多轮推理测试,这对实际模型的部署过程性能评估具有指导意义

重点关注Throughput和Latency两个参数,这两个参数其实是互为倒数,例如图中所示,平均Latency为108.75ms,Throughput为9.18516,意味着不考虑前处理和后处理耗时,模型推理的极限帧率大概是9.18fps,实际进行推理时的推理延迟和这里的测试数值基本一致

使用TensorRT API转换模型为TensorRT引擎

使用TensorRT API的转换脚本我这里直接给出,具体内容直接看代码吧,就不详细介绍了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
import os
import sys
import logging
import argparse
import numpy as np
import tensorrt as trt
import pycuda.driver as cuda
import pycuda.autoinit



logging.basicConfig(level=logging.INFO)
logging.getLogger("Engine").setLevel(logging.INFO)
logger = logging.getLogger('Engine')



class EngineCalibrator(trt.IInt8MinMaxCalibrator):

def __init__(self, cache_file):
super().__init__()
self.cache_file = cache_file
self.calib_batcher = None
self.batch_allocation = None
self.batch_generator = None

def set_calib_batcher(self, calib_batcher: CalibBatcher):
self.calib_batcher = calib_batcher
size = int(np.dtype(self.image_batcher.dtype).itemsize * np.prod(self.image_batcher.shape))
self.batch_allocation = cuda.mem_alloc(size)
self.batch_generator = self.image_batcher.get_batch()

def get_batch_size(self):
if self.calib_batcher:
return self.calib_batcher.batch_size
return 1

def get_batch(self, names):
if not self.calib_batcher:
return None
try:
batch, _, _ = next(self.batch_generator)
logger.info("Calibrating image {} / {}".format(self.calib_batcher.image_index,
self.calib_batcher.num_images))
cuda.memcpy_htod(self.batch_allocation, np.ascontiguousarray(batch))
return [int(self.batch_allocation)]
except StopIteration:
logger.info("Finished calibration batches")
return None

def read_calibration_cache(self):
if os.path.exists(self.cache_file):
with open(self.cache_file, "rb") as f:
logger.info("Using calibration cache file: {}".format(self.cache_file))
return f.read()

def write_calibration_cache(self, cache):
if self.cache_file is None:
return
with open(self.cache_file, "wb") as f:
logger.info("Writing calibration cache data to: {}".format(self.cache_file))
f.write(cache)

def main(args):

logger = trt.Logger(trt.Logger.WARNING)
trt.init_libnvinfer_plugins(logger, namespace="")
builder = trt.Builder(logger)
network = builder.create_network(1 << int(trt.NetworkDefinitionCreateFlag.EXPLICIT_BATCH))
parser = trt.OnnxParser(network, logger)
success = parser.parse_from_file(args.onnx)
for idx in range(parser.num_errors):
print(parser.get_error(idx))

if not success:
return

inputs = [network.get_input(i) for i in range(network.num_inputs)]
outputs = [network.get_output(i) for i in range(network.num_outputs)]

logger.info('Network Description')
for input in inputs:
logger.info("Input '{}' with shape {} and dtype {}".format(input.name, input.shape, input.dtype))
for output in outputs:
logger.info("Output '{}' with shape {} and dtype {}".format(output.name, output.shape, output.dtype))

config = builder.create_builder_config()
memsize = args.mem * 1024 * 1024
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, memsize)

if args.precision == 'fp16':
if not builder.platform_has_fast_fp16:
logger.warning("FP16 is not supported natively on this platform/device")
config.set_flag(trt.BuilderFlag.FP16)
elif args.precision == 'int8':
if not builder.platform_has_fast_int8:
logger.warning("INT8 is not supported natively on this platform/device")
config.set_flag(trt.BuilderFlag.INT8)
config.int8_calibrator = EngineCalibrator(args.calib_cache)
if args.calib_cache is None or not os.path.exists(args.calib_cache):
calib_shape = [args.calib_batch_size] + list(inputs[0].shape[1:])
calib_dtype = trt.nptype(inputs[0].dtype)
config.int8_calibrator.set_image_batcher(
CalibBatcher(args.calib_path, calib_shape, calib_dtype,
args.calib_num, exact_batch=True)
)

serialized_engine = builder.builder_serialized_network(network, config)
with open(args.output, 'wb') as f:
logger.info("Serializing engine to file: {:}".format(args.engine))
f.write(serialized_engine)

def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument('--onnx', required=True, help='input onnx file')
parser.add_argument('--engine', default='engine.trt', help='output engine filename')
parser.add_argument('--precision', default='fp32', choices=['fp32', 'fp16', 'int8'],
help='the precision of mode, fp32/fp16/int8')
parser.add_argument('--mem', default=32, type=int, help='memory pool limit(MiB)')
parser.add_argument('--calib_path', help='the int8 calibration images directory')
parser.add_argument('--calib_cache', default='./calibration.cache',
help='the int8 calibration cache save path')
parser.add_argument('--calib_num', default=5000, type=int,
help='the int calibration images number, default 5000')
parser.add_argument('--calib_batch_size', default=8, type=int,
help='the int calibration batch size, default 8')
return parser.parse_args()


if __name__ == '__main__':
args = parse_args()
if args.precision == 'int8' and not args.calib_path:
logger.error("int8 --calib_path is required")
sys.exit(1)
try:
main(args)
except SystemError:
pass