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의 데이터 주고 받는 부분을 구현할 수 있다.
기본적으로 Input, Output 데이터를 주고 받는 부분은 CUDA의 것과 유사하다. —사실 TensorRT는 CUDA를 머신러닝에서 사용할 수 있게 wrapping 해 준 것이기 때문에 이것은 당연하다.
기본 개념은 CUDA와 마찬가지로 CPU의 데이터를 GPU에 복사하고, GPU를 이용해 연산을 하고, 그 GPU의 결과를 CPU로 다시 복사해 오는 흐름이다. 참고로 이때 CPU 영역은 Host라고 불리고, GPU 영역은 Device라고 부른다.
우선 CPU와 GPU 간에 데이터를 복사하기 위해 CPU와 GPU에 Input, Output을 담을 수 있는 메모리 영역을 확보해야 한다. —이때 Input과 Output의 크기는 학습된 모델의 Input, Output의 크기에 의해 결정된다. 그리고 당연하지만 CPU와 GPU에서 확보된 메모리 크기는 서로 같아야 한다. 그래야 데이러를 주고 받을 수 있으니까.
예컨대 Input 데이터가 Batch Size 1, Channel Count 3, Width 64, height 64 이고 Output 데이터가 숫자 10개인 데이터를 받는 —데이터 타입은 모두 float— 메모리는 GPU에 다음과 같이 할당할 수 있다.
// batch count * channel count * width * height * data type size_t sizeInput = 1 * 3 * 64 * 64 * sizeof(float); float* deviceInput; cudaMalloc(&deviceInput, sizeInput); // batch count * Output Class count * data type size_t sizeOutput = 1 * 10 * sizeof(float); float* deviceOutput; cudaMalloc(&deviceOutput, sizeOutput);
C++
복사
CUDA와 TensorRT의 한 가지 차이점은, CUDA에서는 input과 output을 각각 따로 GPU로 넘겼던 것에 비해 TensorRT는 input과 output을 아래와 같이 하나의 배열에 모아서 보낸다는 점이다. —input, output이 여러 개인 경우도 모두 하나의 배열에 모아서 보낸다. 결과적으로 이 최종 배열은 배열들의 배열 즉, 2차원 배열이 된다.
void* buffers[2]; buffers[?] = deviceInput; buffers[?] = deviceOutput;
C++
복사
이때 주의할 점은 input과 output을 모아 놓은 배열의 index 값이다. 이 값은 코드 상에서 임의로 지정해서는 안 되고, 모델을 만들 때 사용했던 index 값을 사용해야 하는데, 이것은 engine에서 InputTensorName, OutputTensorName을 통해 찾을 수 있다. 때문에 위의 코드는 아래와 같이 고쳐야 한다.
int inputIndex = engine.getBindingIndex("모델을 만들 때 사용한 Input Tesnor Name"); int outputIndex = engine.getBindingIndex("모델을 만들 때 사용한 Output Tesnor Name"); buffers[inputIndex] = deviceInput; buffers[outputIndex] = deviceOutput;
C++
복사
위 예시는 input과 output이 각각 1개씩인 경우를 가정해서 최종 buffers가 2개의 index를 갖지만, 만일 모델에 따라 input과 output이 여러 개라면 buffers는 더 많은 index를 가질 것이다. 또한 그 Input과 Output이 서로 다른 Dimension을 가진 경우라면 각 buffer의 사이즈는 따로 계산해야 할 것이다.
이렇게 모델에 따라 다를 수 있는 input, output의 개수와 dimension의 크기를 고려하여 코드를 일반화하면 다음과 같은 코드가 만들어진다.
// buffer를 동적으로 관리하기 위해 vector를 사용 vector<void*> hostBuffers; vector<void*> deviceBuffers; vector<int> bufferSizes; // buffer 간의 copy를 위한 size 정보 // 모델을 만들 때 사용한 정보를 이용해서 buffer를 생성한다. for (int i = 0; i < engine.getBindings(); i++) { // dim size를 구하기 위해 dimension을 찾는다. Dims dims = engine.getBindingDimensions(i); // dimension의 size 구하는 방식은 TensorRT Sample에 있는 방식 size_t dimSize = std::accumulate(dims.d, dims.d + dims.nbDims, 1, std::multiplies<int64_t>()); // data type을 이용해서 element size를 구한다. // engine에서 나오는 data type은 C++의 기본 타입과는 차이가 있기 때문에 별도로 크기를 찾아줘야 한다. 아래 inline 함수 참조 int elementSize = getElementSize(engine.getBindingDataType(i)); // batch, dimension, data type을 이용해서 최종 buffer size를 구한다. int bufferSize = batchSize * dimSize * elementSize; // cpu, gpu에 size만큼 buffer 할당 void* hostBuffer = operator new(size); // void*의 new를 이용한 할당 방식. malloc을 써도 된다. 참고로 해제는 이렇게 한다 void* deviceBuffer; cudaMalloc(deviceBuffer, bufferSize); // cpu, gpu의 buffer 배열에 buffer를 추가한다. hostBuffers.emplace_back(hostBuffer); deviceBuffers.emplace_back(deviceBuffer); bufferSizes.emplace_back(bufferSize); } // getElementSize는 아래와 같이 생겼다. //inline uint32_t getElementSize(nvinfer1::DataType t) noexcept //{ // switch (t) // { // case nvinfer1::DataType::kINT32: return 4; // case nvinfer1::DataType::kFLOAT: return 4; // case nvinfer1::DataType::kHALF: return 2; // case nvinfer1::DataType::kBOOL: // case nvinfer1::DataType::kINT8: return 1; // } // return 0; //}
C++
복사
이렇게 일반화한 위의 코드는 NVIDIA Sample의 BufferManager와 비슷하게 생겼다. NVIDIA Sample은 메모리 할당과 해제를 편리하게 하기 위해 자체적으로 정의한 HostAllocator, DeviceAllocator를 사용하고 있는 반면, 위의 코드에는 그런 것이 없으니 메모리 해제를 할 buffers 내의 모든 메모리를 일일이 해제 해줘야 한다. —결국 NVIDIA Sample의 BufferManager를 사용하는 것이 더 편하다. 이 설명은 Buffer를 할당하고 CPU와 GPU가 데이터를 주고 받는 과정을 이해하고자 하는 것이다.
Host와 Device Buffer를 할당했으면, Input 데이터를 Host에 채운다. Input 데이터는 다음과 같이 engine에서 Tensor Name을 이용해 Index를 찾아 채울 수 있다.
// 데이터를 넣고자 하는 Input Index는 모델을 만들 때 사용한 Tensor Name을 이용해서 찾을 수 있다. int index = engine.getBindingIndex("모델을 만들 때 사용한 Input Tesnor Name"); float* hostInputBuffer = static_cast<float*>(hostBuffers[index].data());
C++
복사
Host에 데이터를 할당하는 부분 자체는 모델에 따라 판이하므로 생략, Host에 데이터를 채웠다고 가정하고, Host의 데이터를 Device로 넘기는 것은 다음과 같이 할 수 있다.
// binding 갯수만큼 반복한다. // 동기 버전 for (int i = 0; i < engine.getNbBindings(); i++) { // CPU -> GPU 이므로 destination는 device이고, sorce는 host이다. void* dstPtr = deviceBuffers[i]; void* srcPtr = hostBuffers[i]; int bufferSize = bufferSizes[i]; cudaMemcpy(dstPtr, srcPtr, byteSize, cudaMemcpyHostToDevice); } // 비동기 버전 - 비동기를 위해서는 stream을 먼저 만들어야 한다. cudaStream_t stream; cudaStreamCreate(&stream); for (int i = 0; i < engine.getNbBindings(); i++) { void* dstPtr = deviceBuffers[i]; void* srcPtr = hostBuffers[i]; int bufferSize = bufferSizes[i]; cudaMemcpyAsync(dstPtr, srcPtr, byteSize, cudaMemcpyHostToDevice, stream); // async }
C++
복사
Host의 데이터를 Device로 복사했으면 GPU 연산을 수행한다.
// 동기 버전 bool status = context->executeV2(deviceBuffers.data()); // 비동기 버전 - stream은 위에서 만든 cudaStream_t를 사용한다. bool status = context->enqueueV2(deviceBuffers.data(), stream, nullptr);
C++
복사
GPU 연산이 성공했으면 device buffer의 결과 데이터를 다시 host로 가져와야 한다. host를 device로 보낼 때와 순서만 다르다.
// binding 갯수만큼 반복한다. // 동기 버전 for (int i = 0; i < engine.getNbBindings(); i++) { // GPU -> CPU 이므로 destination는 host이고, sorce는 device이다. void* dstPtr = hostBuffers[i]; void* srcPtr = deviceBuffers[i]; int bufferSize = bufferSizes[i]; cudaMemcpy(dstPtr, srcPtr, byteSize, cudaMemcpyDeviceToHost); } // 비동기 버전 - 비동기를 위해서는 stream을 먼저 만들어야 한다. for (int i = 0; i < engine.getNbBindings(); i++) { void* dstPtr = hostBuffers[i]; void* srcPtr = deviceBuffers[i]; int bufferSize = bufferSizes[i]; cudaMemcpyAsync(dstPtr, srcPtr, byteSize, cudaMemcpyDeviceToHost, stream); // async } // 비동기일 경우 GPU -> CPU 복사 이후 stream을 sync하고 destroy 해야 한다. cudaStreamSynchronize(stream); cudaStreamDestroy(stream);
C++
복사
데이터를 Host로 복제해 왔다면 Host의 데이터를 후처리 —Post Processing— 를 한다. GPU에서 나오는 Output 데이터는 그저 숫자의 나열이기 때문에, 일반적으로는 그대로 사용하기 어렵고 사용자가 최종 목적에 맞게 변환 해줘야 한다. —예컨대 이미지에서 인식한 영역 (x, y, width, height)의 box를 구하려면 원본 이미지의 크기 등을 고려해서 적절히 output 데이터를 변환해줘야 한다.
Output 데이터를 Host Buffer에서 데이터를 찾는 것은 Input 데이터를 넣어줄 때와 유사하다. Output Tensor Name을 이용하면 된다.
// 데이터를 가져오고자 하는 Output Index는 모델을 만들 때 사용한 Tensor Name을 이용해서 찾을 수 있다. int index = engine.getBindingIndex("모델을 만들 때 사용한 Output Tesnor Name"); float* hostOutputBuffer = static_cast<float*>(hostBuffers[index].data());
C++
복사
실제 Output 데이터를 처리하는 것은 모델에 따라 판이하므로 생략.
후처리까지 마쳤다면, 할당된 GPU, CPU Buffer를 해제해줘야 한다.
for (int i = 0; i < engine.getBindings(); i++) { cudaFree(deviceBuffer[i]); operator delete(hostBuffers[i]); // malloc()으로 할당했다면 free()로 해제한다. }
C++
복사
위의 예시 코드에서는 생략했지만, 관리 측면에서 cuda 함수를 사용할 때는 아래와 같은 매크로문를 사용하는 것이 좋다. GPU 처리 중에 에러가 발생할 때 그 위치와 원인을 파악하기 좋기 때문.
// cuda로 시작하는 함수는 모두 아래와 같이 매크로를 사용한다. CHECK(cudaMalloc(deviceBuffer, bufferSize)); CHECK(cudaMemcpy(dstPtr, srcPtr, byteSize, cudaMemcpyHostToDevice)); CHECK(cudaStreamCreate(&stream)); CHECK(cudaMemcpy(dstPtr, srcPtr, byteSize, cudaMemcpyDeviceToHost)); CHECK(cudaMemcpyAsync(dstPtr, srcPtr, byteSize, cudaMemcpyDeviceToHost, stream)); CHECK(cudaStreamSynchronize(stream)); CHECK(cudaStreamDestroy(stream)); CHECK(cudaFree(deviceBuffer[i])); // CHECK 매크로는 아래와 같이 생겼다. #define CHECK(status) \ do \ { \ auto ret = (status); \ if (ret != 0) \ { \ sample::gLogError << "Cuda failure: " << ret << std::endl; \ abort(); \ } \ } while (0)
C++
복사

참조 자료

TensorRT
NVIDIA