使用C++部署TensorRT
本文介绍了可以使用C ++ API执行的TensorRT用户目标和任务。 进一步的细节在 Samples 部分提供,并在适当的情况下链接到下面。
假设您从一个已经训练好的模型开始。 本章将介绍使用TensorRT的以下必要步骤:
- 从模型中创建 TensorRT 网络定义
- 调用 TensorRT 构建器以从网络创建优化的运行时引擎
- 序列化和反序列化引擎,以便在运行时快速重新创建
- 喂入数据为引擎提供执行推理
C++ API vs Python API
从本质上讲,C++ API和 Python API在支持您的需求方面应该完全相同。 C++ API应该用于任何性能关键场景,以及安全性很重要的场合,例如汽车行业。
Python API的主要好处是数据预处理和后处理易于使用,因为您可以使用各种库,如 NumPy 和 SciPy。 有关 Python API 的更多信息,请参阅使用Python部署TensorRT.
1. C++实例化TensorRT对象
要运行推理,需要使用 IExecutionContext
对象。 要创建 IExecutionContext
类型的对象,首先需要创建 ICudaEngine
类型的对象(引擎)。
可以通过以下两种方式之一创建引擎:
- 通过用户模型的网络定义。 在这种情况下,可以选择将引擎序列化并保存以供以后使用。
- 通过从磁盘读取序列化引擎。 在这种情况下,性能更好,因为绕过了解析模型和创建中间对象的步骤。
需要全局创建 ILogger
类型的对象。 它用作 TensorRT API
的各种方法的参数。 一个演示Logger创建的简单示例如下所示:
class Logger : public ILogger
{
void log(Severity severity, const char* msg) override
{
// suppress info-level messages
if (severity != Severity::kINFO)
std::cout << msg << std::endl;
}
} gLogger;
名为 createInferBuilder(gLogger)
的全局 TensorRT API
方法用于创建 IBuilder
类型的对象,如下图所示。有关更多信息,请参阅 C++ IBuilder class reference。
使用IBuilder::createNetworkV2()
的方法用于创建 INetworkDefinition
类型的对象,如下图所示。
使用 INetwork
定义作为输入创建一个可用的解析器:
- ONNX:
IParser* parser = nvonnxparser::createParser(*network, gLogger);
- NVCaffe:
ICaffeParser* parser = createCaffeParser();
- UFF:
IUffParser* parser = createUffParser();
调用来自 IParser
类型的对象的名为 parse()
的方法来读取模型文件并填充 TensorRT 网络:
调用 IBuilder
的一个名为 buildCudaEngine()
的方法来创建一个 ICudaEngine
类型的对象,如图所示:
可以选择将引擎序列化并转储到文件中。
执行上下文用于执行推理。
如果序列化引擎被保留并保存到文件中,则可以绕过上述大多数步骤。
名为 createInferRuntime(gLogger)
的全局 TensorRT API 方法用于创建 iRuntime
类型的对象,如图所示:
有关 TensorRT 运行时的更多信息,请参阅 IRuntime class reference。 通过调用运行时方法 deserializeCudaEngine()
来创建引擎。
对于这两种使用模型,其推断是相同的。
尽管可以避免创建 CUDA 上下文(将为您创建默认上下文),但这是不可取的。 建议在创建运行时IRuntime
或构建器对象IBuilder
之前创建和配置 CUDA 上下文。
将使用与创建线程关联的 GPU 上下文创建构建器或运行时。 虽然如果默认上下文尚不存在,但会创建它,但建议在创建运行时或构建器对象之前创建和配置 CUDA 上下文。
2. C++创建网络定义
使用 TensorRT
进行推理的第一步是从您的模型创建 TensorRT
网络。
实现此目的的最简单方法是使用 TensorRT
解析器库导入模型,该解析器库支持以下格式的序列化模型:
- Object Detection With A TensorFlow SSD Network (sampleMNIST)(both BVLC and NVCaffe)
- “Hello World” For TensorRT From ONNX (sampleOnnxMNIST)
- Import A TensorFlow Model And Run Inference (sampleUffMNIST) (used for TensorFlow)
另一种方法是使用 TensorRT API
直接定义模型。 这要求您进行少量 API
调用以定义网络图中的每个层,并为模型的训练参数实现自己的导入机制。
在任何一种情况下,都要告诉 TensorRT
需要哪些张量作为推断的输出。 未标记为输出的张量被认为是可由iBuilder
优化的瞬态值。 输出张量的数量没有限制,但是,将张量标记为输出可能会禁止对张量进行一些优化。 输入和输出张量也必须给出名称(使用 ITensor :: setName()
)。 在推理时,您将为引擎提供一个指向输入和输出缓冲区的指针数组。 为了确定引擎对这些指针的预期顺序,您可以使用张量名称进行查询。
TensorRT
网络定义的一个重要方面是它包含指向模型权重的指针,这些指针由IBuilder
复制到优化引擎中。 如果网络是通过解析器创建的,则解析器将拥有权重占用的内存,因此在IBuilder
运行之前,不应删除解析器对象。
2.1. 使用C++ API从头开始创建网络定义
您也可以通过网络定义 API
直接将网络定义到 TensorRT
,而不是使用解析器。 此方案假定在网络创建期间,每层权重已准备好在主机内存中传递给 TensorRT
。
在下面的示例中,我们将创建一个包含Input
,Convolution
,Pooling
,FullyConnected
,Activation
和 SoftMax
层的简单网络。 要查看整体中的代码,请参阅位于 /usr/src/tensorrt/samples/sampleMNISTAPI
目录中的 Building A Simple MNIST Network Layer By Layer (sampleMNISTAPI)。
- 创建builder和network:
IBuilder* builder = createInferBuilder(gLogger);
INetworkDefinition* network = builder->createNetwork();
- 使用输入维度的方式将输入层添加到网络。 网络可以有多个输入,但在此示例中只有一个:
auto data = network->addInput(INPUT_BLOB_NAME, dt, Dims3{1, INPUT_H, INPUT_W});
- 添加具有隐藏层输入节点的
Convolution
图层,该层还带有过滤器和偏差的步幅和权重。 为了从图层中检索张量参考,我们可以使用:
layerName->getOutput(0)
auto conv1 = network->addConvolution(*data->getOutput(0), 20, DimsHW{5, 5}, weightMap["conv1filter"], weightMap["conv1bias"]);
conv1->setStride(DimsHW{1, 1});
注意:传递给 TensorRT
层的权重在主机内存中。
- 添加 Pooling 层:
auto pool1 = network->addPooling(*conv1->getOutput(0), PoolingType::kMAX, DimsHW{2, 2});
pool1->setStride(DimsHW{2, 2});
- 添加全连接和激活函数层:
auto ip1 = network->addFullyConnected(*pool1->getOutput(0), 500, weightMap["ip1filter"], weightMap["ip1bias"]);
auto relu1 = network->addActivation(*ip1->getOutput(0), ActivationType::kRELU);
- 添加 SoftMax 层以计算最终概率并将其设置为输出:
auto prob = network->addSoftMax(*relu1->getOutput(0));
prob->getOutput(0)->setName(OUTPUT_BLOB_NAME);
- 标记输出:
network->markOutput(*prob->getOutput(0));
2.2. C++使用解析器导入模型
要使用 C++ Parser API
导入模型,您需要执行以下步骤:
- 创建builder和network.
IBuilder* builder = createInferBuilder(gLogger);
nvinfer1::INetworkDefinition* network = builder->createNetwork();
-
创建特定格式的解析器
ONNXauto parser = nvonnxparser::createParser(*network, gLogger);
UFF
auto parser = createUffParser();
NVCaffe
ICaffeParser* parser = createCaffeParser();
-
使用解析器解析导入的模型并填充网络
parser->parse(args);
具体的 args 取决于使用什么格式的解析器。 有关更多信息,请参阅 TensorRT API 中解析器的文档。
必须在network之前创建builder,因为它充当网络的工厂。 不同的解析器具有用于标记网络输出的不同机制。
2.3. 使用C++解析器API导入Caffe模型
以下步骤说明了如何使用 C ++ Parser API 导入 Caffe 模型。 有关更多信息,请参阅 sampleMNIST。
-
创建builder和network:
IBuilder* builder = createInferBuilder(gLogger); INetworkDefinition* network = builder->createNetwork();
-
创建Caffe parser:
ICaffeParser* parser = createCaffeParser();
-
解析导入的模型:
const IBlobNameToTensor* blobNameToTensor = parser->parse("deploy_file" , "modelFile", *network, DataType::kFLOAT);
这将填充
Caffe
模型中的TensorRT
网络。 最后一个参数指示解析器生成权重为 32 位浮点数的网络。 使用DataType::kHALF
将生成具有 16 位权重的模型。除了填充网络定义之外,解析器还返回一个字典,该字典从 Caffe blob 名称映射到 TensorRT 张量。 与 Caffe 不同,TensorRT 网络定义没有in-place操作的概念。 当 Caffe 模型使用in-place操作时,字典中返回的 TensorRT 张量对应于对该blob 的最后一次写入。 例如,如果卷积写入 blob 并且后跟in-place ReLU,则该 blob 的名称将映射到 TensorRT 张量,该张量是 ReLU 的输出。
-
指定网络的输出:
for (auto& s : outputs) network->markOutput(*blobNameToTensor->find(s.c_str()));
2.4. 使用C++ UFF解析器API导入TensorFlow模型
注意:对于新项目,建议使用 TF-TRT集成作为转换 TensorFlow 网络以使用 TensorRT 进行推理的方法。 有关集成说明,请参阅 Accelerating Inference In TF-TRT User Guide。
从 TensorFlow 框架导入要求您将 TensorFlow 模型转换为中间格式 UFF(Universal Framework Format)。 有关转换的更多信息,请参阅 Converting A Frozen Graph To UFF。
以下步骤说明了如何使用C++ Parser API导入TensorFlow模型。 有关UFF导入的更多信息,请参阅 sampleUffMNIST。
- 创建builder和network:
IBuilder* builder = createInferBuilder(gLogger);
INetworkDefinition* network = builder->createNetwork();
- 创建UFF parser:
IUFFParser* parser = createUffParser();
- 声明网络的输入和输出到UFF parser中:
parser->registerInput("Input_0", DimsCHW(1, 28, 28), UffInputOrder::kNCHW);
parser->registerOutput("Binary_3");
注意:TensorRT 期望输入张量为 CHW 顺序。 从 TensorFlow 导入时,请确保输入张量符合所需顺序,如果不是,请将其转换为 CHW。
- 解析导入的模型来填充网络:
parser->parse(uffFile, *network, nvinfer1::DataType::kFLOAT);
2.5. 使用C++ 解析器API导入ONNX模型
通常,较新版本的 ONNX Parser 旨在向后兼容,因此,遇到早期版本的 ONNX 导出器生成的模型文件不应该导致问题。 当更改不向后兼容时,可能会有一些例外。 在这种情况下,将早期的 ONNX 模型文件转换为以后支持的版本。 有关此主题的更多信息,请参阅 ONNX Model Opset Version Converter。
用户模型也可能是由支持后来的 opset 的导出工具生成的,而不是 TensorRT 附带的 ONNX 解析器所支持的。在这种情况下,请检查发布到 GitHub onnx-tensorrt 的最新版本的 TensorRT 是否支持所需的版本。 支持的版本由 onnx_trt_backend.cpp
中的 BACKEND_OPSET_VERSION
变量定义。 从 GitHub 下载并构建最新版本的 ONNX TensorRT Parser。 有关构建的说明,请访问:TensorRT backend for ONNX。
有关 ONNX 导入的详细信息,请参阅 sampleOnnxMNIST。
注意: 在TensorRT7.0,ONNX解析器只支持全维模式,意味着你的网络定义必须使用explicitBatch
标志来创建。
-
创建builder和network:
IBuilder* builder = createInferBuilder(gLogger); const auto explicitBatch = 1U << static_cast<uint32_t>(NetworkDefinitionCreationFlag::kEXPLICIT_BATCH); INetworkDefinition* network = builder->createNetworkV2(explicitBatch);
-
创建ONNX解析器:
nvonnxparser::IParser* parser = nvonnxparser::createParser(*network, gLogger);
-
解析模型:
parser->parseFromFile(onnx_filename, ILogger::Severity::kWARNING); for (int i = 0; i < parser.getNbErrors(); ++i) { std::cout << parser->getError(i)->desc() << std::endl; }
3. C++构建engine
下一步是调用 TensorRT 构建器来创建优化的运行时。 构建器的一个功能是搜索其 CUDA 内核目录以获得最快的可用实现,因此必须使用相同的 GPU 来构建优化引擎将运行的 GPU。
构建器具有许多属性,您可以设置这些属性以控制网络应运行的精度,以及自动调整参数,例如 TensorRT 在确定哪个最快时(多次迭代会导致更长的运行时间,但是对噪声的敏感性较低)应该为每个内核计时多少次 。您还可以查询构建器,以找出硬件本身支持的精简类型。
一个特别重要的属性是最大工作空间大小。
- 层算法通常需要临时工作空间。 此参数限制网络中任何层可以使用的最大大小。 如果提供的缓存不足,则TensorRT可能无法找到给定层的实现。
-
使用builder对象构建engine:
IBuilderConfig* config = builder->createBuilderConfig(); config->setMaxWorkspaceSize(1 << 20); ICudaEngine* engine = builder->buildEngineWithConfig(*network, *config);
在构建引擎时,TensorRT会复制权重。
-
如果使用一次,销毁network, builder, and parser.
engine->destroy(); network->destroy(); builder->destroy();
3.1. Builder层时间缓存
构建引擎可能很耗时,因为builder需要为每一层的候选内核计时。为了减少builder时间,TensorRT设置了一个层定时缓存,以在builder阶段保留层分析信息。
如果有其他层具有相同的输入/输出张量配置和层参数,则builder将跳过分析,并将缓存结果重新用于重复的层。默认情况下,层定时缓存处于打开状态。可以通过设置生成器标志将其关闭。
...
config->setFlag(BuilderFlag::kDISABLE_TIMING_CACHE);
4. C++序列化模型
要进行序列化,您要将引擎转换为一种格式,以便以后存储和使用以进行推理。 要用于推理,您只需反序列化引擎即可。 序列化和反序列化是可选的。 由于从网络定义创建引擎可能非常耗时,因此每次应用程序重新生成时都可以通过序列化一次并在推理时对其进行反序列化来避免重建引擎。 因此,在构建引擎之后,用户通常希望将其序列化以供以后使用。
构建可能需要一些时间,因此一旦构建了引擎,您通常需要将其序列化以供以后使用。 在将模型用于推理之前,并非绝对有必要对模型进行序列化和反序列化 - 如果需要,可以直接使用引擎对象进行推理。
注意:序列化引擎不能跨平台或TensorRT版本移植。 引擎特定于它们构建的精确GPU模型(除了平台和TensorRT版本)。
-
将builder作为预先的脱机步骤运行,然后序列化:
IHostMemory *serializedModel = engine->serialize(); // store model to disk // <…> serializedModel->destroy();
-
创建runtime对象反序列化:
IRuntime* runtime = createInferRuntime(gLogger); ICudaEngine* engine = runtime->deserializeCudaEngine(modelData, modelSize, nullptr);
最后一个参数是使用自定义图层的应用程序的插件层工厂。 有关更多信息,请参阅 Extending TensorRT With Custom Layers。
5. C++执行推理
以下步骤说明了如何使用引擎在C++中执行推理。
-
创建一些空间来存储中间激活值。 由于引擎保持网络定义和训练的参数,因此需要额外的空间。 这些都保存在执行上下文中:
IExecutionContext *context = engine->createExecutionContext();
引擎可以具有多个执行上下文,允许一组权重用于多个重叠推理任务。 例如,您可以使用一个引擎和每个流一个上下文在并行 CUDA 流中处理图像。 每个上下文将在与引擎相同的 GPU 上创建。
-
使用输入和输出blob名来获取输入和输出索引:
int inputIndex = engine.getBindingIndex(INPUT_BLOB_NAME); int outputIndex = engine.getBindingIndex(OUTPUT_BLOB_NAME);
-
使用这些索引, 设置一个指向GPU上输入输出缓存的缓存矩阵指针:
void* buffers[2]; buffers[inputIndex] = inputbuffer; buffers[outputIndex] = outputBuffer;
-
TensorRT执行通常是异步的,因此将内核排队到CUDA流上:
context.enqueueV2(batchSize, buffers, stream, nullptr);
通常在内核之前和之后将异步 memcpy() 排入队列以从 GPU 移动数据(如果尚未存在)。 enqueue() 的最后一个参数是一个可选的 CUDA 事件,当输入缓冲区被占用并且可以安全地重用它们的内存时,它将被发出信号。
要确定内核(以及可能的 memcpy() )何时完成,请使用标准 CUDA 同步机制(如事件)或等待流。
6. C++内存管理
TensorRT 提供了两种机制,允许应用程序更多地控制设备内存。
默认情况下,在创建 IExecutionContext 时,会分配持久设备内存来保存激活数据。 要避免此分配,请调用 createExecutionContextWithoutDeviceMemory。 然后应用程序负责调用IExecutionContext :: setDeviceMemory() 来提供运行网络所需的内存。 ICudaEngine :: getDeviceMemorySize() 返回内存块的大小。
此外,应用程序可以通过实现 IGpuAllocator
接口提供在构建和运行时使用的自定义分配器。如果你的应用程序希望控制所有GPU内存并将其分配给TensorRT而不是让TensorRT直接从CUDA分配,则这个接口是有用的。
一旦实现接口后,在 IBuilder
或 IRuntime
接口上请调用
setGpuAllocator(&allocator)
。 然后将通过此接口分配和释放所有设备内存。
7. 改装engine
TensorRT可以使用新权重改装engine,而不需要重新构建。这个engine必须按照可改装的方式构建,因为这种方式下engine是可优化的。如果你改变权重,你也许不得不也提供一些其他权重。这个接口会告诉你额外的权重需要被提供。
-
在构建之前要求一个可改装的engine:
... config->setFlag(BuilderFlag::kREFIT) builder->buildEngineWithConfig(network, config);
-
创建一个refitter对象:
ICudaEngine* engine = ...; IRefitter* refitter = createInferRefitter(*engine,gLogger)
-
更新你想要更新的权重。例如,给一个叫"MyLayer"的卷积层更新kernel权重:
Weights newWeights = ...; refitter->setWeights("MyLayer",WeightsRole::kKERNEL, newWeights);
新权重应该与原来用于构建engine的权重有同样的数量。
如果一些错误发生则setWeights
返回false
,例如一个错误层名或者角色,又或者权重数量的改变。 -
找出哪些其他权重必须被提供。这通常需要两次调用
IRefitter::getMissing
,第一次获取必须被提供的权重数,第二次获取它们的layers和roles。const int n = refitter->getMissing(0, nullptr, nullptr); std::vector<const char*> layerNames(n); std::vector<WeightsRole> weightsRoles(n); refitter->getMissing(n, layerNames.data(), weightsRoles.data());
-
提供缺少的weights(顺序无所谓)
for (int i = 0; i < n; ++i) refitter->setWeights(layerNames[i], weightsRoles[i], Weights{...});
仅提供缺少权重不将产生更多权重的需要。提供额外权重可能出发更多权重的需要。
-
使用所有被提供的权重更新engine
bool success = refitter->refitCudaEngine(); assert(success);
如果
success
的值为false
,可以检查一下诊断日志,也许有些权重仍然缺失。 -
销毁refitter:
refitter->destroy();
如果想要查看engine中所有可重新调整的权重,可以使用
refitter-> getAll(...)
,类似于步骤4中的如何使用getMissing
。