TensorRT는 자동 Fusion과 런타임 최적화를 통해 모델의 쓰루풋을 개선합니다. Fusion은 그래프화된 네트워크에서 가능한 여러 개의 레이어를 하나의 레이어로 압축시키는 기술입니다. 예를 들어 Convolution과 Activation, 혹은 Absolutely Related Layer와 Batch Normalization과 같은 레이어를 하나로 합치는 것입니다. 이를 통해 모델의 정확도에 영향을 주지 않으면서 불필요한 연산을 줄일 수 있고, 결과적으로 메모리 사용량을 최적화함과 동시에 모델의 쓰루풋을 개선합니다.
한편, TensorRT의 각 레이어는 각 시스템에서의 연산에 최적화된 커널을 가지고 있습니다. 이를 통해 GPU/CPU 오버헤드로 인한 성능 저하를 줄일 수 있습니다. 또, TensorRT의 런타임은 GPU와 CPU가 각각 병렬적으로 연산할 수 있다는 장점이 있습니다. 이를 통해 TensorRT는 Keen 모드의 Pytorch는 물론, 컴파일된 TensorFlow/Flax 모델, 심지어 ONNX 모델보다 더 빠른 속도로 추론을 수행할 수 있습니다.
Pytorch 모델을 TensorRT 엔진으로 변환하는 방식에는 총 세가지가 있습니다. Pytorch mannequin -> ONNX engine -> TensorRT Engine으로 변환하는 방법, 파이토치 2.2에서 추가된 Torch-TensorRT를 활용하여 한번에 변환하는 방법이 그것입니다. Torch-TensorRT는 설정 방법이 상당히 복잡하기 때문에 다른 글에서 다루도록 하고, 이번 글에서는 Pytorch->ONNX->TensorRT SDK를 사용하는 방법만을 다루도록 하겠습니다.
Pytorch v2.2 이전만 하더라도, Pytorch는 TensorFlow와 달리 TensorRT로 바로 변환하지 못했습니다. 이때 사용했던 방법이 바로 Pytorch -> ONNX -> TensorRT의 과정을 거쳐 모델을 TensorRT engine으로 변환하는 방법입니다. Pytorch 모델을 바로 TensorRT 엔진으로 만들지는 못해도 파이토치를 통해 내보낸 ONNX 엔진을 tensorRT 엔진으로 변환하는 것은 가능했기 때문에 사용했던 꼼수입니다. 이 과정에서 반드시 ONNX 모델 파일을 생성해야 하는 단점이 있습니다.
이번 예제에서는 아래의 모델을 기반으로 모델을 TensorRT로 변환하겠습니다.
from transformers import AutoModel, AutoTokenizerPRETRAINED_MODEL_NAME = 'monologg/koelectra-base-v3-discriminator'
mannequin = AutoModel.from_pretrained(PRETRAINED_MODEL_NAME)
tokenizer = AutoTokenizer.from_pretrained(PRETRAINED_MODEL_NAME)
Python 모델을 ONNX로 변환
Pytorch 모델을 ONNX 엔진으로 변환하는 과정은 간단합니다. torch.onnx.export 함수를 이용하면 간편하게 ONNX 모델로 변환할 수 있습니다.
먼저, Dummy input을 만들고, 더미 input을 모델의 ahead pass로 통과시킵니다. dummy input의 내용은 enter type과 shape만 맞으면 어떤 내용을 넣건 상관 없으며, input을 모델에 통과시킬 때에는 반드시 추론 모드를 사용해야 한다는 점을 숙지해주시기 바랍니다.
import torchinput_ids = torch.randint(0, len(tokenizer), (8, 512))
attention_masks = torch.randint(0, 1, (8, 512))
token_type_ids = torch.randint(0, 1, (8, 512))
mannequin.eval()
with torch.no_grad():
_ = mannequin(input_ids, attention_mask=attention_masks, token_type_ids=token_type_ids)
이후, torch.onnx.export를 통해 모델을 onnx 엔진으로 변환합니다. 아래 코드를 보면 dynamic axes라는 항목이 보이는데, 이 항목은 input과 output에서 어떤 dim을 가변으로 할지 정하는 부분입니다. 이 부분을 지정하지 않으면 onnx 모델은 고정된 배치 사이즈만을 받을 수 있으며, 따라서 변환된 모델을 기반으로 한 TensorRT 엔진 역시 dynamic batch를 사용할 수 없습니다. 따라서 batch dimension 정도는 지정해주도록 합시다.
dynamic axes의 key는 모델의 인풋, 혹은 아웃풋의 이름입니다. 해당 인풋과 아웃풋은 각각 input_names 및 output_names에 지정되어야 합니다. value는 {dynamic input의 dim(정수): 해당 dim의 이름(문자열)} 형태의 딕셔너리입니다.
추후 combined precision, 혹은 dynamic quantization을 적용하려면 사용할 onnx의 버전을 지정하는 offset_version 인수를 17 이상으로 맞추어야 합니다.
dynamic_axes = {
"input_ids": {0: "batch_size"},
"attention_mask": {0: "batch_size"},
"token_type_ids": {0: "batch_size"},
"outputs": {0: "batch_size"}
}torch.onnx.export(
mannequin, # 학습된 모델 인스턴스
(input_ids, attention_masks, token_type_ids), # enter args
"weights/mannequin.onnx", # 저장 경로
export_params=True, # 모델의 학습된 파라미터를 저장할 것인지
opset_version=17, # 사용할 onnx의 버전
do_constant_folding=True,
input_names=["input_ids", "attention_mask", "token_type_ids"],
output_names=["outputs"],
dynamic_axes=dynamic_axes
)
이렇게 작성된 코드를 실행하면 weights/mannequin.onnx 경로에 onnx 엔진 파일을 저장할 수 있습니다. 저장된 파일을 통해 TensorRT 엔진으로 변환할 수 있는데, 각각 TensorRT Python SDK를 사용하는 방법, 그리고 trtexec 명령어를 사용하는 방법이 있습니다.
2–1. TensorRT SDK를 활용하여 TensorRT Engine으로 변환
TensorRT는 ONNX나 TensorFlow, Caffe 모델을 TensorRT Engine으로 변환할 수 있는 Python SDK를 제공합니다. 이번 예제에서는 Python SDK를 통해 TensorRT Engine으로 변환하는 과정에 대해서 알아보도록 하겠습니다.
import tensorrt as trt
logger = trt.Logger(min_severity=trt.Logger.INFO)
먼저 TensorRT Logger부터 선언해주겠습니다.
builder = trt.Builder(logger)
community = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH))
parser = trt.OnnxParser(community, logger)
parser.parse_from_file("weights/mannequin.onnx")
다음으로 ONNX 파일을 불러와줍니다. 이미 메모리상으로 불러온 ONNX 바이너리를 파싱하려면 parse_with_weight_descriptors라는 메소드를 대신 사용합니다. 여기서는 ONNX 파일을 불러오기 때문에 parse_from_file 메소드를 사용합니다.
2번째 라인이 눈에 띄는데요, 이 부분은 모델의 enter 혹은 output에서 어느 부분이 batch size인지 모델에 알려주는 부분입니다. Dynamic batch Input을 받기 위해서는 해당 부분을 반드시 작성해주어야 하지만, 고정된 배치 사이즈의 입력값만 받기를 원한다면 단순히 builder.create_network() 식으로 작성해도 무방합니다.
min_shape = (1, 512)
opt_shape = (16, 512)
max_shape = (32, 512)config = builder.create_builder_config()
profile = builder.create_optimization_profile()
profile.set_shape('input_ids', min_shape, opt_shape, max_shape)
profile.set_shape('attention_mask', min_shape, opt_shape, max_shape)
profile.set_shape('token_type_ids', min_shape, opt_shape, max_shape)
config.add_optimization_profile(profile)
config.set_flag(trt.BuilderFlag.FP16)
다음으로 모델의 input과 output, 그리고 precision을 설정해줍니다. enter ids, attention_mask, token_type_ids를 input으로 받는 ONNX 모델을 사용했기 때문에 각 input에 대해서 min form, decide form, max_shape를 지정하였습니다. 이렇게 하면 (1, 512) 형태의 텐서 3개로 구성된 튜플에서 (32, 512) 형태의 텐서 3개로 구성된 텐서 튜플을 input으로 받을 수 있습니다.
여기서 속도를 더 내고 싶다면 combined preicision 플래그를 세워줍니다. trt.BuilderFlag에서 FP16을 지정해서 config에 flag를 세워주면 TensorRT가 모델에서 half precision으로 변환할 수 있는 부분을 찾아 자동으로 변환해줍니다. 이렇게 되면 모델의 크기를 줄일 수 있을 뿐만 아니라 텐서코어를 더욱 효율적으로 활용할 수 있어 추론 시간에 상당한 이점을 누릴 수 있습니다.(볼타 아키텍쳐 이상 GPU 한정)
serialized_engine = builder.build_serialized_network(community, config)
with open('weights/mannequin.plan', 'wb') as engine:
engine.write(serialized_engine)
마지막으로 엔진을 빌드하고 저장합니다. 이렇게 되면 모델이 바이너리로 저장됩니다.
2–2. Pytorch -> ONNX -> 쉘 스크립트를 통해 변환
간단하게 모델의 빌드가 필요할 경우, 굳이 파이썬 코드를 짜지 않고도 쉘에서 간단하게 명령어를 사용해서 onnx 모델을 TensorRT 엔진으로 빌드할 수 있습니다. 아래의 스크립트는 ONNX 모델을 combined preicision을 활용해서 빌드하는 것입니다.
trtexec
--onnx=weights/mannequin.onnx
--saveEngine=weight/mannequin.plan
--fp16
--minShapes=input_ids:1x512,attention_mask:1x512,token_type_ids:1x512,
--optShapes=input_ids:16x512,attention_mask:16x512,token_type_ids:16x512
--maxShapes=input_ids:32x512,attention_mask:32x512,token_type_ids:32x512
--inputIOFormats=int32:chw,int32:chw,int32:chw
--outputIOFormats=fp16:chw
여기서 아까 파이썬 SDK에서 보지 못한 인자가 있는데, inputIOFormats와 outputIOFormats라는 인자가 바로 그것입니다. inputIOFormat과 outputIOFormat은 말 그대로 input과 output의 precision과 형태를 정해주는 것인데, 모델의 enter 형태에 맞게 작성해주면 됩니다. Bert의 경우 따로 특이한 enter 형태는 없기 때문에 int32:chw를 작성해주시면 됩니다.
여기까지 따라왔다면 다음과 같은 log가 뜨며 TensorRT Engine으로의 컴파일이 완료됩니다.
[04/28/2024-09:54:45] [TRT] [I] The logger handed into createInferBuilder differs from one already supplied for an present builder, runtime, or refitter. Makes use of of the worldwide logger, returned by nvinfer1::getLogger(), will return the prevailing worth.
[04/28/2024-09:54:45] [TRT] [I] [MemUsageChange] Init CUDA: CPU +0, GPU +0, now: CPU 1552, GPU 283 (MiB)
[04/28/2024-09:54:45] [TRT] [I] ----------------------------------------------------------------
[04/28/2024-09:54:45] [TRT] [I] Enter filename: weights/mannequin.onnx
[04/28/2024-09:54:45] [TRT] [I] ONNX IR model: 0.0.8
[04/28/2024-09:54:45] [TRT] [I] Opset model: 17
[04/28/2024-09:54:45] [TRT] [I] Producer identify: pytorch
[04/28/2024-09:54:45] [TRT] [I] Producer model: 2.0.1
[04/28/2024-09:54:45] [TRT] [I] Area:
[04/28/2024-09:54:45] [TRT] [I] Mannequin model: 0
[04/28/2024-09:54:45] [TRT] [I] Doc string:
[04/28/2024-09:54:45] [TRT] [I] ----------------------------------------------------------------
[04/28/2024-09:54:45] [TRT] [W] ModelImporter.cpp:420: Make sure that enter input_ids has Int64 binding.
[04/28/2024-09:54:45] [TRT] [W] ModelImporter.cpp:420: Make sure that enter attention_mask has Int64 binding.
[04/28/2024-09:54:45] [TRT] [W] ModelImporter.cpp:420: Make sure that enter token_type_ids has Int64 binding.
[04/28/2024-09:54:45] [TRT] [I] BuilderFlag::kTF32 is about however {hardware} doesn't help TF32. Disabling TF32.
[04/28/2024-09:54:45] [TRT] [I] BuilderFlag::kTF32 is about however {hardware} doesn't help TF32. Disabling TF32.
[04/28/2024-09:54:45] [TRT] [I] Native timing cache in use. Profiling outcomes on this builder go is not going to be saved.
[04/28/2024-09:55:43] [TRT] [I] Detected 3 inputs and 1 output community tensors.
[04/28/2024-09:55:43] [TRT] [I] Whole Host Persistent Reminiscence: 32
[04/28/2024-09:55:43] [TRT] [I] Whole Gadget Persistent Reminiscence: 0
[04/28/2024-09:55:43] [TRT] [I] Whole Scratch Reminiscence: 151027712
[04/28/2024-09:55:43] [TRT] [I] [BlockAssignment] Began assigning block shifts. It will take 2 steps to finish.
[04/28/2024-09:55:43] [TRT] [I] [BlockAssignment] Algorithm ShiftNTopDown took 0.009941ms to assign 2 blocks to 2 nodes requiring 176193536 bytes.
[04/28/2024-09:55:43] [TRT] [I] Whole Activation Reminiscence: 176193536
[04/28/2024-09:55:43] [TRT] [I] Whole Weights Reminiscence: 224666880
[04/28/2024-09:55:43] [TRT] [I] Engine era accomplished in 58.1176 seconds.
[04/28/2024-09:55:43] [TRT] [I] [MemUsageStats] Peak reminiscence utilization of TRT CPU/GPU reminiscence allocators: CPU 214 MiB, GPU 1533 MiB
[04/28/2024-09:55:43] [TRT] [I] [MemUsageStats] Peak reminiscence utilization throughout Engine constructing and serialization: CPU: 3808 MiB
이대로 Python이나 C++를 사용해 TensorRT + CUDA 추론 서버를 올릴 수도 있습니다. 하지만 CUDA와 TensorRT SDK를 통해 추론 백엔드를 만드는 건 상당히 번거로운 일입니다. 파이썬의 경우 pycuda라는 라이브러리를 사용해야 하는데, 이 라이브러리는 환경설정이 상당히 까다로울 뿐만 아니라 코드를 상당히 더럽게 만듭니다. 아래 예시를 봅시다.
# authentic code: https://gist.github.com/jaemin93/98c196e1c9eca9e1e7386330a996fe97#file-run_trt_1-pyimport torch
import tensorrt as trt
import pycuda.driver as cuda
import pycuda.autoinit
import os
import numpy as np
from tqdm import tqdm
TRT_LOGGER = trt.Logger()
EXPLICIT_BATCH = 1 << (int)(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
class HostDeviceMem(object):
def __init__(self, host_mem, device_mem):
self.host = host_mem
self.gadget = device_mem
def __str__(self):
return "Host:n" + str(self.host) + "nDevice:n" + str(self.gadget)
def __repr__(self):
return self.__str__()
def do_inference_v2(context, bindings, inputs, outputs, stream):
# Switch enter knowledge to the GPU.
[cuda.memcpy_htod_async(inp.device, inp.host, stream) for inp in inputs]
# Run inference.
context.execute_async_v2(bindings=bindings, stream_handle=stream.deal with)
# Switch predictions again from the GPU.
[cuda.memcpy_dtoh_async(out.host, out.device, stream) for out in outputs]
# Synchronize the stream
stream.synchronize()
# Return solely the host outputs.
return [out.host for out in outputs]
def allocate_buffers(engine):
inputs = []
outputs = []
bindings = []
stream = cuda.Stream()
for binding in engine:
dimension = trt.quantity(engine.get_binding_shape(binding)) * engine.max_batch_size
dtype = trt.nptype(engine.get_binding_dtype(binding))
# Allocate host and gadget buffers
host_mem = cuda.pagelocked_empty(dimension, dtype)
device_mem = cuda.mem_alloc(host_mem.nbytes)
# Append the gadget buffer to gadget bindings.
bindings.append(int(device_mem))
# Append to the suitable listing.
if engine.binding_is_input(binding):
inputs.append(HostDeviceMem(host_mem, device_mem))
else:
outputs.append(HostDeviceMem(host_mem, device_mem))
return inputs, outputs, bindings, stream
with open(engine_file_path, "rb") as f, trt.Runtime(TRT_LOGGER) as runtime:
engine = runtime.deserialize_cuda_engine(f.learn())
inputs, outputs, bindings, stream = allocate_buffers(engine)
# Do inference
take a look at = torch.randn(1, 1, 28, 28)
inputs[0].host = take a look at
trt_outputs = do_inference_v2(context, bindings=bindings, inputs=inputs, outputs=outputs, stream=stream)
TensorRT SDK만으로 파이썬 추론 백엔드를 작성할 시, pycuda 패키지를 설치한 뒤 호스트 메모리 상에 있는 메모리를 명시적으로 alloc하고 detach하는 등 상당히 많은 작업이 필요한 모습을 볼 수 있습니다. 배포할 모델의 개수가 많을 경우 이런 코드를 매번 일일히 짜줄 수는 없는 노릇입니다. 다행히 엔비디아에서는 간단한 작업만으로도 추론용 서버를 쉽게 올릴 수 있는 패키지를 준비했습니다. 바로 Triton Infernce Server입니다.
Triton Inference Server는 Pytorch, Tensorflow, ONNX 모델은 물론 TensorRT 엔진을 모델 레포지토리에 올려놓기만 하면 바로 모델을 고속으로 추론할 수 있다는 장점을 가지고 있습니다. 즉, 추론 서버의 백엔드 코드에서 신경써야 할 부분이 크게 줄어든다는 뜻입니다. 실제로도 triton inference server를 사용할 때 백엔드에 서버에서 신경써야 할 부분은 모델 레포지토리, nvidia container toolkit 설치 유무, 그리고 마지막으로 경우에 따라 config.pbtxt 작성 정도가 끝입니다.
그러면 실제로 triton inference server를 올려보도록 하겠습니다. 먼저, mannequin repository를 만듭니다. mannequin repository는 특정 구조를 가진 디렉토리로, 다음과 같이 만들어야 합니다.
ㄴ model_1
ㄴ 1
ㄴ mannequin.pt
ㄴ config.pbtxt
ㄴ model_2
ㄴ 1
ㄴ mannequin.plan
ㄴ 2
ㄴ mannequin.plan
ㄴ config.pbtxt
ㄴ model_3
...
위의 예시를 풀어서 써보자면 다음과 같습니다.
- model_1, model_2 …: 모델의 이름입니다. triton inference server에서 모델을 호출할 때 이 디렉토리의 이름으로 호출해야 합니다.
- 1, 2 …: 모델의 버전입니다. 모델 버전을 특정하지 않으면 가장 최신 모델을 불러옵니다.
- config.pbtxt: 모델의 config 파일입니다. pytorch 모델이나 Python 코드를 그대로 triton에 올리지 않는 이상 자동으로 작성되기 때문에 이번 예제에서는 작성하지 않으셔도 됩니다.
이렇게 mannequin repository를 만드셨다면 이제 docker image를 띄울 차례입니다. nvidia-container-toolkit을 사용하여 도커 이미지를 띄워주면 됩니다. shm-size를 1G 이상으로 만들지 않으면 모델 추론시 컨테이너가 죽어버린다는 점을 주의해주시기 바랍니다.
docker run
--runtime nvidia
--gpus='all'
-it
--rm
--shm-size=8g
-p 8000:8000
-v ${model_repository_path}:/model_dir
nvcr.io/nvidia/tritonserver:21.10
tritonserver
--model-repository=/model_dir
--strict-model-config=false
--model-control-mode=ballot
마지막으로 triton client를 사용하여 트리톤 추론 서버에 추론 요청을 보낼 함수를 만들면 됩니다.
import numpy as np
import tritonclient.http as httpclient
from tritonclient.http import InferenceServerClient
from tritonclient.utils import *CLIENT = InferenceServerClient(url=os.environ['TRITON_CLIENT_URL'], verbose=False)
def run_inference(input_ids: np.ndarray, attention_mask: np.ndarray, token_type_ids: np.ndarray, model_name: str) -> Listing[np.ndarray]:
inputs = [
httpclient.InferInput("input_ids", input_ids.shape, "INT32"),
httpclient.InferInput("attention_mask", attention_mask.shape, "INT32"),
httpclient.InferInput("token_type_ids", token_type_ids.shape, "INT32"),
]
input_datas = [
inputs[0].set_data_from_numpy(input_ids.astype(np.int32)),
inputs[1].set_data_from_numpy(attention_mask.astype(np.int32)),
inputs[2].set_data_from_numpy(token_type_ids.astype(np.int32)),
]
result_object = CLIENT.infer(
model_name=model_name,
inputs=input_datas,
outputs=[httpclient.InferRequestedOutput("output", binary_data=True)],
)
return result_object.as_numpy("output")
이렇게 되면 TensorRT와 Triton을 활용한 추론서버 배포가 완료된 것입니다.
사실 TensorRT는 위에서 소개한 방법 외에 다양한 기능을 가지고 있습니다. dynamic/static quantization, pruning 관련 기능은 물론 calibration, combined quantization등의 기능도 제공하고 있습니다. 물론 기능을 제대로 쓰기 위해서는 Python SDK뿐만 아니라 C++ SDK에도 익숙해져야 하지만, quantized된 모델을 gpu 텐서코어상에서 고속으로 돌릴 수 있다는 장점은 상당합니다.
그리고 TensorRT의 장점 중 런타임에 대한 장점을 조금 덜 쓴 감이 있는데요, 이 부분은 사실 프로파일링까지 들어가야 더 자세히 볼 수 있어서 그렇습니다. 추후 기회가 된다면 프로파일일러를 사용하여 TensorRT가 어떤 식으로 런타임을 가속하는지, 정확히 추론 속도가 어느정도 빨라지는지 역시 함께 알아보도록 하겠습니다.
TensorRT 가상환경에 설치하기
보통 TensorRT는 conda나 venv와 같은 가상환경에 설치하지 않고 docker나 로컬 환경에 설치합니다. venv나 conda에 설치하면 CUDA 경로를 찾지 못하는 문제가 발생하기 때문인데요, 코드 실행 전 다음과 같은 쉘 명령어를 통해 CUDA 경로를 수동으로 설정해주면 venv나 conda와 같은 가상환경에서도 TensorRT SDK를 실행할 수 있습니다.
export PATH=$PATH:/usr/native/cuda/bin
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/native/cuda/lib64