개요
Yolov5
네트워크를 TensorRT로 최적화하여 객체감지를 돌리는 프로젝트에서 기존 객체감지 모델과 마찬가지로 입력 이미지를 전처리하는 것에 NumPy
를, GPU 메모리에 입력과 출력 할당에 PyCUDA
라이브러리를 사용하였다. 이제는 CuPy
를 이용하여 GPU기반 연산 및 메모리 사용으로 더 빠르게 수행해보는 내용을 정리한다.
기존 NumPy + PyCuda
집중적으로 살펴볼 곳은 NumPy
를 통한 이미지 전처리 부분과 PyCUDA
를 사용한 입출력 GPU 메모리 할당 부분이다. Yolov5
객체감지를 기반에 두고 있음을 참고한다.
입출력 할당
GPU 메모리에 입력(inputs
)과 출력(outputs
)에 할당하는 배열 영역을 생성하고 그 주소를 bindings
에 묶어준다.
PyCUDA
라이브러리를 통해 GPU 메모리에 기반한 배열 생성
"""
각종 tensorrt 튜토리얼이나 예제 코드에서 allocate_buffer()에 해당하는 부분이다.
"""
import pycuda.driver as cuda
inputs = []
outputs = []
bindings = []
for binding in engine:
size = trt.volume(engine.get_binding_shape(binding)) * engine.max_batch_size
dtype = trt.nptype(engine.get_binding_dtype(binding))
host_mem = cuda.pagelocked_empty(size, dtype)
device_mem = cuda.mem_alloc(host_mem.nbytes)
# Append the device buffer to device bindings.
# int(device_mem) is memory address
bindings.append(int(device_mem))
# HostDeviceMem class is simple namespace
if engine.binding_is_input(binding):
inputs.append(HostDeviceMem(host_mem, device_mem))
else:
outputs.append(HostDeviceMem(host_mem, device_mem))
전처리
요약된 전처리 과정은 다음과 같다.
- 이미지 리사이즈 (비율 유지 한 채로 기준 크기에 맞게)
- 이미지 패딩 (정사각형에 맞게 모자란 곳 보더 생성)
- transpose (HWC to CHW)
- type cast (unit8 to flaot32)
- divide ([0,255] to [0,1])
- sort c-array(메모리에 배열 정렬)
- GPU 메모리(입력)에 이미지 복사
"""
일반적인 cv tensorrt 연산에서 preprocessing에 해당하는 부분이다.
"""
import numpy as np
import cv2
# image resize (tw, th는 yolov5 전처리에 맞는 계산값)
img = cv2.resize(image, (tw, th))
# Pad the short side with (128,128,128)
img = cv2.copyMakeBorder(img, ty1, ty2, tx1, tx2, cv2.BORDER_CONSTANT, None, (128, 128, 128))
# HWC to CHW format
img = np.transpose(img, [2, 0, 1])
# Type Cast
img = img.astype(np.float32)
# Normalize to [0,1]
img /= 255.0
# Convert the image to row-major order, also known as "C order":
img = np.ascontiguousarray(img)
# Image COPY to input memory(GPU Memory)
np.copyto(inputs[0].host, img.ravel())
추론(Inference)
네트워크 모델에 대해 입력 배열을 가지고 추론 시에도 PyCUDA
의 일정 개입이 들어간다. 모듈의 이름이 혼동되는 것을 막기 위해 import
는 같이 명시한다. htod
, dtoh
등의 메모리 복사 연산들이 수행되는 것으로 보아 cpu 메모리(RAM)과 gpu 메모리 간의 이동이 주기적으로 일어날 것 임을 알 수 있다.
"""
각종 tensorrt 튜토리얼이나 예제 코드에서 do_inference()에 해당하는 부분이다.
"""
import pycuda.driver as cuda
cuda.init()
device = cuda.Device(gpu_num)
# PyCUDA create ctx(context)
ctx = device.make_context()
# TensorRT engine create context
context = engine.create_execution_context()
ctx.push()
# Transfer input data to the GPU.
[cuda.memcpy_htod_async(inp.device, inp.host, stream) for inp in inputs]
# Run inference.
context.execute_async(batch_size=batch_size, bindings=bindings, stream_handle=stream.handle)
# Transfer predictions back from the GPU.
[cuda.memcpy_dtoh_async(out.host, out.device, stream) for out in outputs]
# Synchronize the stream
stream.synchronize()
ctx.pop()
# Return only the host outputs.
res = [out.host for out in outputs]
CuPy
CuPy
라이브러리는 NumPy
와 SciPy
를 대체하기 위해 개발된 라이브러리라 opencv
를 대체하기엔 아직 조금 문제가 있다. 또한 PyCUDA
도 공식 도큐먼트에 언급은 되어있지 않지만 GPU 기반 메모리 할당과 배열을 사용한다는 점에서 충분히 대체가 가능하다.
입출력 할당
위의 전처리와 비슷하지만 호스트와 디바이스 메모리를 할당하는 부분을 Cupy
가 처리한다.
inputs = []
outputs = []
bindings = []
for binding in engine:
# Append the device buffer to device bindings.
shape = engine.get_binding_shape(binding)
dtype = trt.nptype(engine.get_binding_dtype(binding))
# Allocate buffers
cuda_mem = cp.zeros(shape=shape, dtype=dtype)
bindings.append(cuda_mem.data.ptr)
if engine.binding_is_input(binding):
inputs.append(cuda_mem)
else:
outputs.append(cuda_mem)
전처리
NumPy
과정과 전처리는 전반적으로 동일하다. 배열을 다루기 전의 CuPy
의 ndarray
타입으로 변경 후 NumPy
대신 CuPy
라이브러리로 연산들을 수행한다. 마지막 copyto
수행 시 img.ravel()
이 없는 것을 볼 수 있는데, 기존 PyCUDA
로 바인딩에 추가한 입력(input)은 binding
의 shape
을 trt.volume
으로 감싸서 1차원의 직렬 바인딩으로 메모리영역을 할당하는 반면, CuPy
의 전처리는 입력의 shape
자체로 cp.zeros
배열을 바인딩에 추가하기에 3차원 혹은 배치를 포함한 4차원의 shape
로 메모리영역을 할당한다.
import cupy as cp
import cv2
# image resize (tw, th는 yolov5 전처리에 맞는 계산값)
img = cv2.resize(image, (tw, th))
# Pad the short side with (128,128,128)
img = cv2.copyMakeBorder(img, ty1, ty2, tx1, tx2, cv2.BORDER_CONSTANT, None, (128, 128, 128))
# cpu array to gpu array
img = cp.asarray(img)
# HWC to CHW format
img = cp.transpose(img, [2, 0, 1])
# Type Cast
img = img.astype(cp.float32)
# Normalize to [0,1]
img /= 255.0
# Convert the image to row-major order, also known as "C order":
img = cp.ascontiguousarray(img)
# adjust shape for input shape (batch)
img = cp.expand_dims(img, axis=0)
# Image COPY to binding from cupy array
cp.copyto(self.inputs[0], img)
추론(Inference)
기존 PyCUDA
추론 방식에 비해 상당히 간단하고 코드도 몇 줄 안된다.
"""
각종 tensorrt 튜토리얼이나 예제 코드에서 do_inference()에 해당하는 부분이다.
"""
import pycuda.driver as cuda
with cp.cuda.Stream(non_blocking=False) as stream:
# Run inference.
self.context.execute_async(bindings=self.bindings, stream_handle=stream.ptr)
# Synchronize the stream
stream.synchronize()
# Return the outputs.
res = outputs[0]
입력 및 출력에 대한 shape 비교
기존 PyCUDA
는 입력과 출력 배열에 대한 메모리 할당을 1차원으로 수행했다. 예를 들어 배치 사이즈가 1이고 입력 형상이 (3, 1280, 1280)
인 Yolov5
네트워크는 1 * 3 * 1280 * 1280
을 수행하여 49152000
크기의 영역을 GPU 메모리에 할당한다. 또한, 아직 NMS를 수행하지 않은 원시 출력의 형상 (1, 102000, 10)
도 1 * 102000 * 10
으로 1020000
크기의 영역을 GPU 메모리에 할당한다. 이를 사용자가 추론의 전과 후에 입력과 출력을 전처리, 후처리하여 사용한다.
그에 비해, CuPy
는 TensorRT
엔진이 요구하는 입력과 출력의 형상을 그대로 가져간다. 위와 같은 엔진이라면 입력에 대해 (1, 3, 1280, 1280)
의 CuPy
- ndarray
를 만들어 GPU에 할당하고, 출력에 대해 (1, 102000, 10)
의 배열을 만들어 GPU에 할당한다.
이렇기 때문에, PyCUDA
와 CuPy
의 전처리, 후처리는 조금 다를 수 밖에 없음에 주의한다.
참고: CuPy 개발자 문서
참고: CuPy github issue
댓글남기기