[Nvidia] TensorRT 구현하기 (C++)

2022. 12. 21. 00:48Developers 공간 [Basic]/Embedded

728x90

TensorRT(TRT)는 Nvidia에서 제공되는 Deep Learning Inference를 위한 SDK입니다.

PyTorch, Caffe, Tensorflow 등의 Deep Learning Framework를 활용해 학습된 모델을,  여러 플랫폼에 가장 적합한 Kernel을 선택하며, 각 제품 각 아키텍쳐에 맞는 가속을 자동으로 도와 최적의 GPU 자원을 활용해 Performance를 낼 수 있도록 도와주는 엔진이라고 볼 수 있습니다.

** Kernel : GPU에서 병렬 실행되는 명령의 모음

 

TensorRT를 이용하지 않고, 직접 CUDA 를 활용해 Custom한 로직으로 최적화할 수도 있으나, Nvidia에서 제공하는 API를 활용하는 것이 작업에 유용하기도 합니다.

 

이번엔 onnx파일을 활용해 TensorRT를 구동하는 방식을 설명하고자 합니다.

 

<구성>
0. Onnx Parsing
   a. PyTorch Model에서 Onnx 만들기
   b. Tensorflow Model에서 Onnx 만들기
1. Object
   a. engine만들기
   b. 작업환경 구축하기
2. 구현하기
3. 개선하기

글효과 분류1 : 코드

글효과 분류2 : 폴더/파일

글효과 분류3 : 용어설명

글효과 분류4 : 글 내 참조

글효과 분류5 : 글 내 참조

글효과 분류6 : 글 내 참조


0. Onnx Parsing

물론 PyTorch나 Tensorflow같은 framework에서 직접 trt파일을 생성 할 수 있으나, trt파일 생성시 해당 CPU및 GPU의 자원을 보고 가장 적합한 kernel을 선택하므로 Embedded 환경에서 trt를 파싱하기 위해, 학습한 유저는 onnx파일만을 embedded환경에 제공하는 형태를 선택하기도 합니다. 

a. PyTorch Model에서 Onnx 만들기

  

pip3 install onnx onnxruntime

먼저 위와 같은 python3 라이브러리를 설치해야합니다.

import onnx
import torch 

# STEP1
dummay_input1= torch.empty(1,1000,500,3, dtype=torch.float32)
dummay_input2= torch.empty(1,20000,4, dtype=torch.float32)

# STEP2
MODEL = MODEL_NAME(dummay_input1, dummay_input2)

# STEP3
MODEL.load_sate_dict(torch.load("ABCDE.pth"))

# STEP4
torch.onnx.export(MODEL, (dummay_input1,dummay_input2), "MODELONNX.onnx", verbose=True,
				input_name=['input1_name','input2_name'],
                output_name=['output_name'],
                opset_version=11)
               
onnx.save(onnx.shape_inference_infer_shapes(onnx.load("MODELONNX.onnx")), "MODELONNX.onnx")
  • STEP1. 먼저 tensor간의 명시되지 않은 type들을 명시하며 onnx graph를 만들어내기 위해서는 dummy input을 만들어주어야합니다.
  • STEP2. 기존에 학습 했던 모델의 정보를 불러옵니다.
  • STEP3. 기존에 학습했던 모델의 weight들을 불러옵니다. pytorch는 pth 포맷을 사용합니다.
  • STEP4. onnx 라이브러리를 활용해 export해줍니다.
    • torch.onnx.export 혹은 onnx.save로 onnx로 export할 수 있습니다.
    • **Onnx Opset : torch및 onnx 라이브러리에서 제공되는 onnx의 버전에 따라 지원되는 onnx 레이어의 종류가 다양할 수 있습니다.
b. Tensorflow Model에서 Onnx 만들기

 

pip3 install onnxruntime
pip3 install git+https://github.com/onnx/tensorflow-onnx

python3 -m tf2onnx.convert --saved-model ./checkpoints --output ABC.onnx --opset 11 --verbose

먼저 위와 같은 python3 라이브러리를 설치해야합니다.

tf2onnx는 github를 통해 설치하고 해당 명령어를 통해 opset을 명시해 직접 tensorflow 체크포인트에서 onnx파일을 만들어낼 수 있습니다. 
(https://github.com/onnx/tensorflow-onnx)

하지만 코드 내에서 모델을 만들고 싶은 경우 아래와 같은 과정을 따릅니다.

import tensorflow as tf
from tf2onnx import utils, optimizer,tf_loader
from tf2onnx.tfonnx import process_tf_graph

# STEP1
dummy_input = tf.keras.Input(shape=[1000,500,3], batch_size=1, name='', dtype='float32')

# STEP2
MODEL = MODEL_NAME(dummay_input)
dummy_output = MODEL(dummy_input)
MODEL = tf.keras.Model(inputs=dummy_input, outputs=dummy_output)

# STEP3
MODEL.load_weights('ABC.ckpt.data').expect_partial()

# STEP4-1
FULL_MODEL = tf.function(lambda input : MODEL(input))
FULL_MODEL2 = FULL_MODEL.get_concrete_function(
                tf.TensorSpec(MODEL.input[0].shape, 
                            MODEL.input[0].dtype)
              )
FROZEN_FUNC = convert_variables_to_constants_v2(FULL_MODEL2)
FROZEN_FUNC.graph.as_graph_def()
tf.io.write_graph(graph_or_graph_def=FROZEN_FUNC.graph,
			logdir='./FOLDER',
            name='ABC.pb',
            as_text=False)

# STEP4-2 : refer to tf2onnx git 
graph_def, inputs,outputs = tf_loader.from_graphdef('ABC.pb', 'ABC.pb', 'ABC.onnx')
model_path = args.checkpoint

with tf.Graph().as_default() as tf_graph:
	tf.import_graph_def(graph_def, name="")
with tf_loader.tf_session(graph=tf_graph):
	g = process_tf_graph(tf_graph,
    					continue_on_error=None,
                       	target=args.target,
                        opset=args.opset,
                        custom_op_handlers=custom_ops,
                        extra_opset=extra_opset,
                        shape_override=args.shape_override,
                        input_names=inputs,
                        output_names=outputs,
                        inputs_as_nchw=args.inputs_as_nchw)
	onnx_graph = optimizer.optimize_graph(g)
	model_proto = onnx_graph.make_model("converted from {}".format(model_path))
    
	utils.save_protobuf("ABCD.onnx", model_proto)
  • STEP1~3 : 위와 같습니다.
  • STEP4-1. tensorflow는 pytorch와 다르게 먼저 ckpt 파일과는 달리 모델 구조와 가중치 값이 합쳐진 pb파일을 만들어내야 합니다.
  • STEP4-2. 그 다음 tf2onnx라이브러리를 활용해 graph에서 onnx파일을 파싱할 수 있습니다. 위 코드는 상기 언급한 tensorflow-onnx github를 통해 참고한 것으로 해당 github를 참고해 작업하시는 것을 추천드립니다.

1. Object

먼저, TensorRT를 구현하기 위해 필요한 Object들을 설명드리고자 합니다.

#include <cuda_runtime_api.h>
#include <NvOnnxParser.h>
#include <NvInfer.h>
#include <NvInferPlugin.h>

nvinfer1::ICudaEngine *engine; 

nvinfer1::IExecutionContext* context; 

cudaStream_t stream;

float *CPUinput;
cudaError_t cuda_err = cudaHostAlloc((void**)&CPUinput, 500*1000*3*sizeof(float), cudaHostAllocDefault);
if(Cuda_error != cudaSuccess){
	exit(0);
}

int *Network_output;
cudaError_t cuda_err = cudaHostAlloc((void**)&CPUinput, 10*sizeof(int), cudaHostAllocDefault);
if(Cuda_error != cudaSuccess){
	exit(0);
}

void *GPUinput;
cudaError_t cuda_err = cudaMalloc(GPUinput, 500*1000*3*sizeof(float));
if(Cuda_error != cudaSuccess){
	exit(0);
}

void *GPUoutput;
cudaError_t cuda_err = cudaMalloc(GPUoutput, 10*sizeof(int));
if(Cuda_error != cudaSuccess){
	exit(0);
}

nvinfer1::iLogger gLogger;

Iruntime* runtime;
runtime -> setDLACore(5)
  • nvinfer1::ICudaEngine *engine; : 학습한 Network를 Inference할 TRT engine 입니다.
    • '네트워크 정의'와 '학습된 파라미터'들이 정의된 엔진입니다.
    • 엔진은 작업이 끝날 때까지 반납되어서는 안됩니다.
  • nvinfer1::IExecutionContext* context[L]; : CUDA context는 CPU thread와 같이 작업 단위입니다.
    • 미리 정의된 환경에서 실행하기 위한 환경입니다. Inference중간에 생기는 activation 값들 또한 이 Context에서 관리된다고 생각하시면 됩니다. 
    • 1개의 engine을 위한 여러개의 context가 존재할 수 있으며, 이는 동일한 네트워크를 동시에 여러개의 Input에 대해서 실행하는 경우(여러개 Batch와 같은) 활용이 가능합니다. 이때, weight는 공유되어 쓰일 수 있다고 합니다.**
    • 1개의 Context에 대한 2개 이상의 kernel processing은 concurrently 실행이 불가하지만, 여러개 context에 대한 kernel들은 concurrently 실행이 가능합니다. 
  • cudaStream_t stream[L]; : 일반적인 cuda stream으로 inference kernel이 쌓일 Queue입니다.
    • Resource가 충분해지면, 메모리 복사와 Kernel 실행이 모두 진행됩니디다.
    • 두개 이상의 stream은 가능하지만, 두개 이상의 stream을 가지면 unordered형태로 overlap 가능합니다.
    • ‘Consecutive’ computation in device
    • Can work with other streams concurrently
  • float *CPUinput, CPUoutput : input과 output을 담을 CPU memory
  • void *GPUinput, GPUoutput : input과 output을 담을 GPU memory
    • GPU는 input과 output은 하나의  버퍼로 선언한 뒤 "engine-> getBindingIndex("input:0")" 함수를 통해 해당 buffer의 인덱스를 얻어내 사용하는 경우도 있습니다.
  • nvinfer1::iLogger gLogger : logging을 위한 모든 class instance들을 담기 위한 오브젝트
  • nvinfer1::iRuntime : serialized 된 nvinfer1::ICudaEngine을 deserialize 한 오브젝트입니다.
    • setDLAcore() : 사용할 DLA 코어의 개수를 명시할 수 있습니다.
    • ** 왜 serialize하고 나서 deserialize하는가? Inference를 위해서 꼭 serialize & deserialize를 해야만 하는 것은 아닙니다. 하지만 빌드되는 시간을 줄이기 위해 바로 사용하지 않는 경우 deserialize해 저장해두는 것이 유리하기 때문에 주로 serialize 해 저장해두곤 합니다.

[Engine-Context-Stream 관계]

 


2. 구현하기

TensorRT를 활용해 최적화하는 과정을 3단계로 나누어 IN1, IN2, IN3 이렇게 표현해 보았습니다.

engine을 만들어 직접 학습한 모델들을 실행할 준비를 하고, 이를 활용해 context등을 만든 뒤에 inference를 진행할 수 있으며 해당과정을 아래에서 자세히 다뤄보도록 합니다.

 

[onnx에서 TRT파일을 만들고 실행하는 과정]

a. engine만들기
// STEP0
nvinfer1::iLogger gLogger;

// STEP1
nvinfer1::iBuilder builder = createInferBuilder(gLogger);
if(!builder){
	gLogger.log(nvinfer1::ILogger::Severity::kERROR, "failed to build");
}

// STEP2
nvinfer1::iNetworkDefinition network : network = builder -> createNetwork();
// OR
auto explicitBatch = 1U << static_cast<uint32_t>(nvinfer1::NetworkDefinitionCreationFlag::kEXPLICIT_BATCH);
nvinfer1::iNetworkDefinition network : network = builder -> createNetworkV2(explicitBatch);

// STEP3
nvinfer1::IBuilderConfig config =  builder -> createBuilderConfig()
config -> setMaxWorkspaceSize((long long) 1<<30) * 2); 
if(builder->platformHasFastFp16())
	config -> setFlag(BuilderFlag::kFP16);
if(builder->platformHasFastInt8())
	config->setFlag(BuilderFlag::kINT8);

// STEP4
verbosity = 1;
nvonnxparser::Iparser parser = nvonnxparser::createParser(*network, gLogger);
parser → parseFromFile("ABC.onnx", verbosity)

// STEP5
nvinfer1::ICudaEngine engine = builder -> buildEngineWithConfig(*network, *config);

// for checking
nvinfer1::Dims mInputDims = network->getInput(0)->getDimensions();
nvinfer1::Dims mInputDims2 = engine->getBindingDimensions(0);
printf(mInputDims.nbDims);
printf(mInputDims.d[0]);
nvinfer1::Dims mOutputDims = network->getOutput(0)->getDimensions();
nvinfer1::Dims mOutputDims2 = engine->getBindingDimensions(1);
printf(mOutputDims.nbDims);
printf(mOutputDims.d[0]);

return engine → serialize();
  • IN1_OnnxToTrtModel: serialize 해서 trt파일을 만들기 위해 
    • STEP0 : Logger 
    • STEP1 : builder : builder = createInferBuilder(gLogger);
      • nvinfer1::iBuilder : 네트워크 정의를 위한 설정을 하고 엔진을 만들기 위한 빌더 입니다.
    • STEP2 : network : network = builder  createNetwork();
      • nvinfer1::iNetworkDefinition : 네트워크를 정의하는 클래스 입니다.
      • 앞서 언급한 바와 같이 원하는 특정 embedded 환경에서 정의 됩니다.
      • 2022년 12월 3일 기준 현재 createNetwork()는 deprecated 되었으며 createNetworkV2()를 사용해야합니다.
      • NetworkDefinitionCreationFlag::kDEFAULT : 기존 createNetwork()와 똑같이 동작하며 암시적으로 배치사이즈를 명시하되, 내부적으로는 모두 고정된 사이즈의 dimension을 사용하는 Implicit batch를 지원합니다. 예를들어, N개의 batch를 넣고 싶은 경우 input dimension을 [H,W,C]로만 명시를 해주게 됩니다. 
      • NetworkDefinitionCreationFlag::kEXPLICIT_BATCH : 명시적으로 배치사이즈를 주되, 내부적으로는 모두 dynamic하고 explicit한 dimension을 사용하는 Explicit batch를 지원합니다. 즉, runtime에 shape가 다양하게 변화할 수 있으며 batch로 인식하기보다는 하나의 차원으로 인식하는 것입니다. 예를들어, N개의 batch를 넣고 싶은 경우 input dimension을 [N,H,W,C]로 명시해주게 됩니다.
      • NetworkDefinitionCreationFlag::kEXPLICIT_PRECISION : 정확히 요구되는 precision으로 quantization을 진행하거나 dynamic range를 제공하지 않기 위해서 설정해주어야 합니다.
    • STEP3 : config : config  builder  createBuilderConfig()
      • nvinfer1::IBuilderConfig : builder를 정의하는 클래스 입니다.
      • config → setMaxWorkspaceSize((long long) 1<<30) * 2); : 2GB의 workspace를 만들어냅니다.
      • **max workspace : 모델에서 사용된 layer들이 사용하는 일시 메모리의 양은 제한하는 것입니다. 이는 scratch memory space로, activation과 weight를 포함하지 않기 때문에 너무 높게 잡는다면 activation과 weights들을 유용하기에 메모리가 부족할 수 있습니다.
      • config->setFlag(BuilderFlag::kFP16) : 16bit floating point로 quantization을 하기 위한 flag
      • config->setFlag(BuilderFlag::kINT8) : 8bit integer point로 quantization을 하기 위한 flag
    • STEP4 : parser : parser = nvonnxparser::createParser(*network, oLogger);
      • nvonnxparser::Iparser : onnx를 trt로 편한하기 위한 parser입니다.
      • parser → parseFromFile('ABC.onnx',verbosity)
        • 지원되지 않는 IR 버전과 지원되지 않는 opset을 제거하며 진행해야합니다.
        • ** Onnx IR(Intermediate Representation) : 어떤 식으로 model이 constructed되었는지를 설명해주는 것에 대한 부분입니다. 이 또한 Onnx opset과 같이 onnx 버전에 따라 다릅니다.
      •  initLibNvInferPlugins() 을 파싱하기 전에 선언해주면 존재하는 tensorRT 플러그인들을 모두 불러올 수 있습니다.
        ** TensorRT Plugin : trt layer에서 지원되지 않는 layer들을 직접 만들어 제공하는 경우, 혹은 타인이 만들어 놓은 layer들을 사용하는 경우 사용하는 것입니다.
    • STEP5 : engine : builder  buildEngineWithConfig(*network, *config);
    • Return : trtModelStream = engine  serialize();
      • serialize해서 return합니다.
  • IN2_Serialized Model : 위에서 return된 trt 모델을 binary로 저장하는 과정입니다. 이는 binary로 저장하는 다양한 방법을 사용할 수 있습니다.
/* <Deserialization> */

std::ifstream trtFile('ABC.trt');

// STEP1
Iruntime* runtime = createInferRuntime(gLogger);

// STEP2
std::stringstream Buffer;
Buffer << trtFile.rdbuf();
std::string SavedBuffer = Buffer.str();

// Return
nvinfer1::ICudaEngine engine = runtime → deserializeCudaEngine(SavedBuffer->data(), SavedBuffer->size(), nullptr);

return std::shared_ptr<nvinfer1::ICudaEngine>(engine,nvinfer1::samplesCommon::InferDeleter())
  • IN3_Deserialize : 만들어진 trt 파일 혹은 instance를 받아 runtime 오브젝트를 생성해주는 과정입니다.
    • STEP1. runtime : runtime = createInferRuntime(gLogger);
    • STEP2. 파일을 읽는 과정입니다. 이미 만들어진 trt 오브젝트가 존재한다면 따로 파일을 읽어서 하지 않아도 됩니다.
    • Return : engine = runtime → deserializeCudaEngine(SavedBuffer->data(), SavedBuffer->size(), nullptr);
      • 파일 정보를 활용해 engine을 deserialize해줍니다.

b. 작업환경 구축하기

nvinfer1::IExecutionContext* context = engine ->createExecutionContext(); 

cudaStream_t stream;
cudaStreamCreate(&stream);
  • engine ->createExecutionContext() : context를 만들어 작업 환경을 정리해줍니다.
  • cudaStreamCreate(&stream) : stream을 만들어 커널을 넣어줄 큐를 만들어 놓습니다.

c. Inference 하기

  • STEP1_PreprocessNHWC
    • python 및 tensorRT에서 이미지에 대해 각기 다른 포맷을 사용합니다.
      • CV2.imread : NHWC 포맷
      • pytorch : NCHW 포맷
      • Tensorflow : NHWC, NCHW 포맷
      • TensorRT : NCHW 포맷 추천 (CUDA-core에서 더 좋은 performance를 보입니다.)
    • 어떤 것이 더 빠른가?
      • GPU  : NCHW > NHWC
        • GPU 입장에서는 reduction이 오히려 비용이 크기 때문에 HW(HeightWidth)방향으로 접근해, 연속적인 C(Channel)방향으로 loop accumulation해주는 것이 더 빠릅니다. 따라서 NC+HW가 더 빠르게 됩니다.
      • TensorCore  : NCHW < NHWC
      • CPU(SSE,AVX): NCHW < NHWC
        • FFT나 Winograd Convolution을 제외하고 일반적인 convolution을 고려했을 때,
          작은 크기의  multiply-add를 C(Channel)방향으로 해준 뒤에 HW(HeightWidth)방향으로 reduction해주는 것이 SSE AVX 최적화 덕분에 더 빠릅니다. 따라서 NHW+C가 더 빠릅니다.
        • ** SSE(Streaming SIMD Extensions) :  인텔 x86에서 보인 SIMD 명령어 집합 확장
          ** AVX(Advanced Vector eXtensions) : 인텔 x86에서 보인 SIMD Instruction Set로 SSE Instruction Set series의 후속입니다.
cudaMemcpyAsync(GPUinput, CPUinput, 500*1000*sizeof(float), cudaMemcpyHostToDevice, stream);

context->enqueueV2(buffers, stream, nullptr);

cudaMemcpyAsync(CPUoutput, GPUoutput, 10*sizeof(int), cudaMemcpyDeviceToHost, stream);
  • STEP2_doInference
    • cudaMemcpyAsync(GPUinput, CPUinput, SIZE, cudaMemcpyHostToDevice, Stream);
      • CPU에서 GPU로 input을 복사합니다.
      • ** synchronous : memcpy한 후에 작업이 완료되면 return
      • ** asynchronous : memcpy하고 즉시 return
    • context->enqueueV2(buffers, stream, nullptr) :  큐에 커널을 넣어주는 함수입니다.
      • nullptr : 인풋 버퍼가 새로운 데이터로 차는 경우에의 event를 저장할 cudaEvent_t 오브젝트입니다.
      • enqueue 버전
        • enqueue :  network 정의시에 implicit한 배치 사이즈를 가진 경우, 사용할 배치의 크기와 함께 넣어주는 enqueue함수 입니다.
        • enqueueV2 : 위 network 정의시에 kEXPLICIT_BATCH 옵션이 켜져있었다면, batch사이즈를 명시적으로 주고 싶기 때문에 사용하는 enqueue입니다. 
        • enqueueV3 : 원래 V2와 비슷한 기능을 하고 있지만 V2가 2022년 11월25일 기준으로 deprecated되었기 때문에 현재 V3기능을 합니다.
      • ** synchronous : 작업을 enqueue한 후에 완료까지 기다리는 것
      • ** asynchronous : 작업을 enqueue한 후에 완료까지 기다리지 않고 즉시 return
    • cudaMemcpyAsync(CPUoutput, GPUoutput, SIZE, cudaMemcpyDeviceToHost, Stream);
      • GPU에서 CPU로 output을 복사합니다.

 


3. 개선하기

[Multi-stream 최적화 예시]

if(cudaStreamQuery(stream)==cudaSuccess)
	return true;
  • Multi-Stream 최적화를 사용하면 memory bound의 작업들을 computation bound로 바꾸는데 유용합니다. 혹은 분리된 태스크들을 분리해서 처리하기 위해서 사용합니다.
    • NetSyncInit() : 본인의 방식으로 모든 stream이 실행되는 것을 체크해둡니다.
    • NetDoneCheck(): 각각의 stream에 해당하는 context작업이 마쳤는지 확인을 해, 각각의 stream이 병렬적으로 마무리될 때마다 다음 task를 실행합니다.
      • cudaStreamQuery : 각각의 stream에 대해 async하게 완료되었는지를 확인하는 함수입니다.

https://docs.nvidia.com/deeplearning/tensorrt/developer-guide/index.html

https://developer.nvidia.com/docs/drive/drive-os/archives/6.0.3/tensorrt/api-reference/docs/index.html

 

728x90