Search
Duplicate

TensorRT C++/ TensorRT 엔진 파일을 Build 하고 Inference 하기

개요

TensorRT는 기존의 머신러닝 모델을 사용할 때, GPU를 이용해서 더 빠른 결과를 얻기 위한 목적으로 사용된다.
기본적으로 GPU를 이용할 때는 NVIDIA의 CUDA를 사용하는데, TensorRT는 CUDA의 wrapper로써 CUDA를 직접 사용하지 않고도 동일한 효과를 낼 수 있게 해준다.
TensorRT는 오로지 성능에 대한 것이므로 TensorRT를 이용해서 기존의 머신러닝 모델이 못하던 것을 할 수 있지는 않다.
TensorRT는 크게 2 부분으로 구분되는데
기존에 다양한 플랫폼 —Tensorflow, Pytorch 등— 에서 만들어진 머신러닝 모델들 TensorRT가 사용할 수 있는 Engine 형식으로 변환하는 과정과 (build)
그렇게 변환된 Engine을 이용해서 실제 사용하는 부분 (runtime) 으로 구분된다.
여기서는 우선 기존에 만들어진 머신러닝 모델 —Onnx 파일— 이 있다고 가정하고, 그것을 TensorRT 엔진으로 변환하는 부분에 대한 내용을 다룬다.
TensorRT는 python 버전과 C++ 버전이 있는데, 여기서는 C++ 버전을 다룬다.
생각보다 두 언어의 API 사용법에 차이가 있어서 python의 코드를 그대로 C++로 변환할 수는 없다. 그래도 build 부분은 거의 유사한데, runtime 부분은 상당히 차이가 있음
우선 공식 샘플을 이용한 것이 훨씬 간단하므로 그것을 살펴보고, 그 후에 직접 구현하는 것을 사용해서 내부가 어떻게 구성되었는지를 파악하자.

Build

NVIDIA 샘플 코드를 프로젝트에 추가하는 방법은 아래 링크 참조
Trt 엔진 파일을 build 하기 위해서는 builder, network, config, parser가 필요하다. 추가로 builder와 parser를 생성하기 위해 logger가 필요한데, 이는 Sample 코드의 것을 사용하면 된다. 아래와 같이 build 코드를 만들 수 있다.
#include <string> #include <NvInfer.h> #include <NvOnnxParser.h> // onnx 파일의 parser. TensorRT는 이 외에 Caffe, UFF parser를 지원한다. #include "common/common.h" // Nvidia의 sample 코드 using namespace std; using namespace nvinfer1; using namespace nvonnxparser; using namespace samplesCommon; bool BuildEngine( const string& pathOnnxModelFile, const string& pathEngineFile, const size_t sizeWorkSpaceMax, const int sizeBatchMax, const int batchCount, const int channels, const int width, const int height ) { // builder, network, config, parser를 만든다. // builder, network, config, parser 등은 unique_ptr을 사용할 수 없기 때문에, NVIDIA Sample에서 unique_ptr 처럼 사용할 수 있도록 만들어둔 SampleUniquePtr을 사용한다. SampleUniquePtr<IBuilder> builder = SampleUniquePtr<IBuilder>(createInferBuilder(sample::gLogger.getTRTLogger())); if (builder) { // flag 값은 공식문서에 나와 있는 것을 그대로 따른다. uint32_t flag = 1U << static_cast<uint32_t>(NetworkDefinitionCreationFlag::kEXPLICIT_BATCH); SampleUniquePtr<INetworkDefinition> network = SampleUniquePtr<INetworkDefinition>(builder->createNetworkV2(flag)); if (network) { SampleUniquePtr<IBuilderConfig> config = SampleUniquePtr<IBuilderConfig>(builder->createBuilderConfig()); if (config) { SampleUniquePtr<IParser> parser = SampleUniquePtr<IParser>(createParser(*network, sample::gLogger.getTRTLogger())); if (parser) { // trt로 변환할 onnx 파일을 parse 한다. flag는 역시 공식 문서에 있는 것을 그대로 따른다 bool parsed = parser->parseFromFile(pathOnnxModelFile.c_str(), static_cast<int>(ILogger::Severity::kWARNING)); // error가 있는지 체크 for (int32_t i = 0; i < parser->getNbErrors(); ++i) { std::cout << parser->getError(i)->desc() << std::endl; } if (parsed) { // profileStream을 만드는 것은 공식 문서에는 없고, Sample 코드에만 있다 - 없어도 build는 됨 auto profileStream = samplesCommon::makeCudaStream(); if (!profileStream) { return false; } config->setProfileStream(*profileStream); config->setMaxWorkspaceSize(sizeWorkSpaceMax); builder->setMaxBatchSize(sizeBatchMax); network->getInput(0)->setDimensions(Dims4(batchCount, channels, width, height)); SampleUniquePtr<IHostMemory> engine = SampleUniquePtr<IHostMemory>(builder->buildSerializedNetwork(*network, *config)); if (engine) { // 엔진이 만들어졌으면 지정된 경로에 바이너리 형태로 저장한다. std::ofstream file(pathEngineFile, std::ios::out | std::ios::binary); file.write((char*)(engine->data()), engine->size()); file.close(); return true; } } } } } } return false; }
C++
만일 NVIDIA Sample 코드 없이 build를 구성하려면 위의 Logger와 SampleUniquePtr 부분만 직접 구현하면 된다. —makeCudaStream은 없어도 무방하니 생략
Logger는 공식문서에 나와 있는 대로 ILogger를 상속 받고 log 만 override 해서 만들면 된다. 아래 코드 참조.
#pragma once #include <NvInfer.h> #include <iostream> using namespace nvinfer1; // 클래스 이름은 자신이 원하는 것으로 정의 // logger는 전역 변수로 만들어서 여러 곳에서 공통적으로 사용할 수 있게 사용하면 된다. class TrtLogger : public ILogger { // log는 noexcept를 해야 에러나지 않는다. --공식 문서에는 noexcept가 안 써 있음 void log(Severity severity, const char* msg) noexcept override { // suppress info-level messages if (severity <= Severity::kWARNING) { std::cout << msg << std::endl; } } };
C++
SampleUniquePtr은 Deleter는 아래와 같이 생겼으며, 이를 참조하여 자신이 원하는 이름으로 바꾸어서 정의한 후 사용하면 된다.
struct InferDeleter { template <typename T> void operator()(T* obj) const { delete obj; } }; template <typename T> using SampleUniquePtr = std::unique_ptr<T, InferDeleter>;
C++

Load

위의 과정을 통해 Trt 엔진 파일을 만들어 저장했으면, 이후 추론을 위해 이 파일을 다시 읽어와야 한다. Trt 엔진 파일을 deserialize 하기 위해서는 아래와 같이 runtime과 cudaEngine이 필요하다. 추가로 runtime에는 logger가 필요하므로 build 때와 마찬가지로 logger를 사용한다.
vector<char> LoadFile(const string& path) { if (!path.empty()) { std::ifstream file(path, std::ios::binary); if (file.good()) { file.seekg(0, file.end); long int fileSize = file.tellg(); file.seekg(0, file.beg); vector<char> fileData(fileSize); file.read(fileData.data(), fileSize); return fileData; } } return vector<char>(); } shared_ptr<ICudaEngine> LoadEngine(const string& pathEngineFile) { // trt engine 파일을 읽어온다. vector<char> engineFile = LoadFile(pathEngineFile); if (engineFile.size() > 0) { SampleUniquePtr<IRuntime> runtime = SampleUniquePtr<IRuntime>(createInferRuntime(sample::gLogger.getTRTLogger())); if (runtime) { // 다른 것들과 달리 ICudaEngine은 shared_ptr을 사용할 수 있다. shared_ptr<ICudaEngine> engine = shared_ptr<ICudaEngine>(runtime->deserializeCudaEngine(engineFile.data(), engineFile.size())); if (engine) { return engine; } } } return nullptr; }
C++

Inference

Trt 엔진 파일을 Load 했다면, Input 데이터를 받아 Inference를 할 수 있다. Inference의 기본 흐름은 다음과 같다.
1.
buffer와 context를 생성한다.
2.
input 데이터를 전처리해서 buffer에 담는다.
전처리하는 내용은 모델에 따라 다르다.
3.
buffer에 담은 GPU로 보낸다.
4.
추론한다.
5.
buffer에 담긴 추론 결과를 CPU로 가져온다.
6.
결과를 후처리한다.
전처리와 마찬가지로 후처리 또한 모델에 따라 내용이 다르다.
여기서 추론을 위해서 필요한 context는 engine을 통해 쉽게 생성할 수 있지만, buffer를 만드는 것은 다소 까다롭다. 일단은 NVIDIA의 Sample에서 제공하는 BufferManager를 사용하여 추론 하는 코드를 살펴 보자.
아래 코드는 이미지 데이터 (Mat)를 받아서 Rectangle 영역을 추출하는 상황을 가정한 내용이다.
// C++에서 TensorRT를 Inference를 하려면 python과 달리 inputTensorName과 outputTensorName이 필요하다. // 모델에 따라 Input, Output 처리하는 방식이 달라질 수 있고, 그에 따라 파라미터 또한 달라질 수 있다. bool DoInferenceSync(const shared_ptr<ICudaEngine>& engine, const string& inputTensorName, const string& outputTensorName, const Mat& inputData, vector<Rect>& outputs) { if (engine) { // NVIDIA sample 코드의 BufferManager를 이용해서 GPU와 데이터를 주고 받을 buffer를 생성한다. BufferManager buffers(engine); // 추론을 하려면 Context가 필요하므로 생성한다. SampleUniquePtr<IExecutionContext> context = SampleUniquePtr<IExecutionContext>(engine->createExecutionContext()); if (context) { // buffer에 GPU로 보낼 Input 데이터를 넣는다. 이때 inputTensorName이 필요하다. // input 데이터를 처리하는 전처리는 모델에 따라 다르므로 내용 생략 if (PreProcessInput(buffers, inputTensorName, inputData)) { // CPU -> GPU로 데이터를 보낸다. buffers.copyInputToDevice(); // 동기 버전 추론을 수행한다. bool status = context->executeV2(buffers.getDeviceBindings().data()); if (status) { // GPU -> CPU로 데이터를 보낸다. buffers.copyOutputToHost(); // 결과 데이터를 후처리 한다. Input과 비슷하게 outputTensorName이 필요하다. // 후처리 또한 모델에 따라 다르므로 내용 생략 if (PostProcessOutput(buffers, outputTensorName, outputs)) { return true; } } } } } return false; }
C++
추론은 비동기 버전도 지원하는데, 위 코드의 비동기 버전은 아래와 같다. 몇 부분이 다르긴 하지만 전체 흐름은 동일하다.
bool DoInferenceAsync(const shared_ptr<ICudaEngine>& engine, const string& inputTensorName, const string& outputTensorName, const Mat& inputData, vector<Rect>& outputs) { if (engine) { BufferManager buffers(engine); SampleUniquePtr<IExecutionContext> context = SampleUniquePtr<IExecutionContext>(engine->createExecutionContext()); if (context) { if (PreProcessInput(buffers, inputTensorName, inputData)) { // 비동기를 위해 cudaStream을 생성한다. cudaStream_t stream; CHECK(cudaStreamCreate(&stream)); // 비동기는 Async buffers.copyInputToDeviceAsync(stream); // 비동기는 enqueue가 된다. bool status = context->enqueueV2(buffers.getDeviceBindings().data(), stream, nullptr); if (status) { buffers.copyOutputToHostAsync(stream); // 결과를 받은 후에 stream을 동기화하고 해제한다. cudaStreamSynchronize(stream); cudaStreamDestroy(stream); if (PostProcessOutput(buffers, outputTensorName, outputs)) { return true; } } } } } return false; }
C++

Buffer

Input과 Ouput을 처리하는 것은 모델에 따라 다르므로, 별도의 예제로 다루고, 여기서는 CPU와 GPU가 데이터를 주고 받는 Buffer 부분을 살펴보자. 이 부분을 이해하면 NVIDIA Sample의 BufferManager를 사용하지 않고도 CPU와 GPU의 데이터 주고 받는 부분을 구현할 수 있다.

참조 자료