로봇 ML 모델의 경량화 1부: 훈련 후 양자화
이 글에서는 고성능 서버 환경과 실외 자율주행 로봇 환경의 차이점을 살펴보고, 그로부터 ML 모델 경량화의 필요성을 이해한 후, ML 모델 경량화 방법 중 하나인 양자화의 원리와 적용 방법을 알아보겠습니다. 양자화의 원리와 적용 방법에 대해서는 NVIDIA의 공식 문서를 참고하였으며, 이러한 기술들은 로봇을 포함한 다양한 NVIDIA 하드웨어 기반 컴퓨터에 응용할 수 있습니다.
로봇이 실외에서 자율주행을 하려면?
그림 1. 우아한형제들 로보틱스LAB의 실외 자율주행 배달로봇 ‘딜리’
- 충격, 진동, 온도, 습도, 물, 먼지 등을 견디는 내구성
- 긴 배터리 수명과 낮은 발열을 위한 전성비
- 좁은 로봇 내부 공간에 맞는 작은 크기의 부품들
- 다양한 센서를 연결하기 위한 많은 포트 및 높은 호환성
NVIDIA GPU와 Jetson 플랫폼의 특징
NVIDIA에서 공개한 문서 “NVIDIA TensorRT – Inference 최적화 및 가속화를 위한 NVIDIA의 Toolkit”에 따르면, TensorRT는 양자화(quantization), 그래프 최적화(graph optimization), 커널 자동 튜닝(kernel auto-tuning), 동적 텐서 메모리(dynamic tensor memory), 다중 스트림 실행(multi-stream execution)과 같은 기법들을 통해 모델의 추론 속도를 향상시킵니다. 이 문서에 제시된 실험 결과에 따르면 이러한 TensorRT의 최적화만으로도 ResNet-50 모델의 성능이 8배 이상 향상되었다고 합니다. NVIDIA는 다른 문서 “TensorRT Integration Speeds Up TensorFlow Inference”에서도 TensorRT를 사용했을 때의 성능 향상이 [그림 2]의 그래프처럼 매우 큼을 보였습니다. TensorRT가 이처럼 큰 차이를 만들기 때문에, 배포 단계에서 모델을 TensorRT 엔진으로 최적화하는 일은 필수입니다.
그림 2. TensorFlow vs TensorRT ResNet-50 성능 비교 (출처: NVIDIA)
ML 모델의 경량화는 크게 모델링(modeling) 및 훈련(training) 단계에서의 경량화, 그리고 훈련 완료 후의 경량화로 나눌 수 있습니다. 다음 챕터에서는 훈련 완료 후의 경량화 방법인 양자화에 대해 알아보겠습니다.
양자화
1. 먼저 FP32 값과 INT8 값을 매핑하기 위한 양자화 수식을 설정합니다. 문서에서는 아래 수식과 같이 선형 관계로 표현하고 있습니다. 여기서 Q는 양자화된 텐서, X는 원본 텐서, s는 스케일(scale)입니다.
2. 양자화하고자 하는 텐서의 분포를 구합니다. 텐서의 분포는 일반적으로 모델 내부의 다양한 정규화(normalization) 연산에 의해 [그림 3]과 같이 0을 중심으로 하는 정규 분포가 됩니다.
3. 분포 내의 모든 실숫값을 정숫값에 1대 1 매핑할 수 없으므로, 매핑 범위의 임곗값([그림 3]에서 amax)을 설정합니다. 그리고 절댓값이 해당 임곗값을 넘는 모든 값을 임곗값으로 바꿔줍니다(이 연산을 Clip이라고 합니다). 이를 양자화 수식에 반영하면 아래와 같습니다.
4. 임곗값 이내의 범위인 [-amax, amax] 내의 실수와 정수 데이터 타입의 전체 범위(예: INT8이라면 [-128, 127])를 매핑할 수 있는 스케일값을 구합니다. 이를 수식으로 나타내면 아래와 같습니다.
5. 충분한 양의 학습 데이터에 대해 위의 과정을 반복합니다. 이를 통해, 타깃 데이터를 양자화하기 전과 후 사이의 오차를 가장 많이 줄여주는 임곗값과 스케일값을 찾고, 이 값들을 저장합니다.
그림 3. 캘리브레이션의 원리 (출처: NVIDIA)
이 글에서는 훈련 후 양자화 방법부터 자세히 다루고, 양자화 인식 훈련에 대한 자세한 내용은 “로봇 ML 모델의 경량화” 시리즈의 다음 글에서 다룰 예정입니다.
TensorRT를 이용한 최적화 방법
표 1. 예시 코드의 테스트 환경
PyTorch 모델을 TensorRT로 추론하는 방법 비교
1. ONNX 모델 변환 후 TensorRT 엔진 변환
2. Torch-TensorRT를 이용한 추론
그림 4. Torch-TensorRT의 런타임 구조 (출처: NVIDIA)
자율주행과 같이 추론 속도가 매우 중요한 분야에서는 온전한 TensorRT 엔진을 사용하는 “ONNX 모델 변환 후 TensorRT 엔진 변환” 방식을 추천드립니다. 이 글의 예시 코드에도 그러한 방식을 사용하였습니다.
ONNX 모델 변환
import torch
import torchvision
# Load ResNet-18 ImageNet-pretrained model using torchvision.
model = torchvision.models.resnet18(pretrained=True)
model.eval()
# Dummy input with the model input size.
dummy_input = torch.randn(1, 3, 224, 224)
# Save ONNX model to ONNX_PATH.
torch.onnx.export(model,
dummy_input,
ONNX_PATH,
opset_version=13,
input_names=["input"],
output_names=["output"])
torch.onnx.export
를 이용하면 PyTorch의 모델을 간단하게 ONNX 모델로 변환할 수 있습니다. 각 인자의 역할은 다음과 같습니다.
model
: 변환할 PyTorch 모델 객체를 입력합니다.dummy_input
: PyTorch 모델의 입력과 같은 크기의 임의의 텐서를 입력합니다.ONNX_PATH
: ONNX 모델을 저장할 경로를 지정합니다.opset_version
: ONNX의 연산자 버전을 지정합니다. 자신이 사용할 TensorRT 버전이 지원하는 범위 내의 버전으로 입력해 주어야 합니다.input_names
: ONNX 포맷에서 사용되는 입력의 이름을 지정합니다. 입력이 여러 개일 경우를 위해 list 형태로 입력합니다. 지정된 이름은 추론 단계에서 입력dict
의key
로 사용됩니다.output_names
: ONNX 포맷에서 사용되는 출력의 이름을 지정합니다. 출력이 여러 개일 경우를 위해 list 형태로 입력합니다. 지정된 이름은 추론 단계에서 출력dict
의key
로 사용됩니다.
만약 PyTorch 모델에 ONNX에서 지원하지 않는 레이어가 포함되어 있다면 이러한 변환이 불가능하므로, ONNX 공식 문서를 참고하여 커스텀 레이어를 만들어 사용해야 합니다.
Polygraphy를 이용한 훈련 후 양자화 및 TensorRT 엔진 변환
이번 챕터에서는 Polygraphy를 이용한 훈련 후 양자화 및 TensorRT 엔진으로 변환하는 방법을 알아보겠습니다.
1. Calibrator
객체 생성
Calibrator
클래스를 사용해야 합니다. 이 Calibrator
클래스는 입력 데이터를 generator
의 형태로 받습니다. 따라서 추론 환경과 동일한 전처리가 적용된 입력 데이터를 생성하는 generator
를 만들고, 이를 Calibrator
에게 인자로 전달해야 합니다.
generator
정의 및 Calibrator
객체 생성 예시입니다.
import os
from PIL import Image
from polygraphy.backend import trt as poly_trt
from torchvision import transforms
# Preprocess of ResNet-18 training.
transform = transforms.Compose([transforms.Resize((256, 256)),
transforms.CenterCrop((224, 224)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406],
[0.229, 0.224, 0.225])])
# Polygraphy needs a generator-type data loader.
val_list = os.listdir(IMAGE_DIR)
def data_generator():
for image in val_list:
# Preprocess.
image_path = os.path.join(IMAGE_DIR, image)
image = transform(Image.open(image_path).convert("RGB"))
# Add batch dimension.
image = image.unsqueeze(0)
# Polygraphy uses numpy input.
image = image.numpy()
# Dict key must be the same as ONNX input name.
yield {"input": image}
calibrator = poly_trt.Calibrator(data_loader=data_generator())
data_generator
는 IMAGE_DIR
에서 입력 이미지를 읽어와 ResNet-18과 동일한 전처리 과정을 수행한 후, 앞서 만든 ONNX 모델의 입력 형식과 동일한 형식의 dict
를 반환합니다. 그리고 Calibrator
타입의 객체를 만드는데, 이때 위에서 만든 generator
를 인자로 사용합니다.
2. ONNX 파일 로드 및 IBuilderConfig
객체 생성
Calibrator
를 생성한 후에는, ONNX 모델을 로드하고, TensorRT 엔진 빌드에 필요한 각종 설정을 담고 있는 IBuilderConfig
객체를 만들어야 합니다.
IBuilderConfig
객체 생성 예시입니다.
builder, network, parser = poly_trt.network_from_onnx_path(path=ONNX_PATH)
# Each type flag must be set to true.
builder_config = poly_trt.create_config(builder=builder,
network=network,
int8=True,
fp16=True,
calibrator=calibrator)
network_from_onnx_path
를 이용해 ONNX 모델을 로드하면 builder
, network
, parser
세 가지 객체가 반환됩니다. 이 중 builder
와 network
는 IBuilderConfig
객체를 만드는 create_config
의 인자로 사용됩니다.
IBuilderConfig
객체를 만들 때는 정수 타입을 지원하지 않는 레이어를 위해 FP16 타입 변환에 대한 옵션을 추가해야 합니다. 여기서 주의할 점은, IBuilderConfig
은 각 타입에 대한 인자를 해당 타입 사용 여부를 나타내는 플래그로 사용한다는 것입니다. 따라서, INT8과 FP16 타입을 모두 사용하려면 create_config
에서 이들 타입 각각에 해당되는 인자를 모두 True
로 설정해야 합니다.
마지막으로, 앞서 만든 Calibrator
객체를 인자로 추가하면 IBuilderConfig
객체 생성이 완료됩니다.
3. TensorRT 엔진 빌드 및 저장
engine = poly_trt.engine_from_network(network=(builder, network, parser),
config=builder_config)
# TensorRT engine will be saved to ENGINE_PATH.
poly_trt.save_engine(engine, ENGINE_PATH)
builder
, network
, parser
객체들과 IBuilderConfig
객체를 engine_from_network
에 인자로 넣으면, TensorRT 엔진이 빌드됩니다.
마지막으로, 빌드된 TensorRT 엔진 객체를 save_engine
을 이용하여 저장하면 TensorRT 엔진 변환 과정이 완료됩니다.
4. 추론 방법
# Load serialized engine using 'open'.
engine = poly_trt.engine_from_bytes(open(ENGINE_PATH, "rb").read())
with poly_trt.TrtRunner(engine) as runner:
# Preprocess.
image = transform(Image.open(IMAGE_PATH).convert("RGB"))
image = image.unsqueeze(0).numpy()
# Input dict keys are the same as 'input_names' arg in 'torch.onnx.export'.
output_dict = runner.infer({"input": image})
# Output dict keys are the same as 'output_names' arg in 'torch.onnx.export'.
output = output_dict["output"]
engine_from_bytes
를 이용해 TensorRT 엔진을 로드한 후, TrtRunner
를 사용하여 로드된 엔진을 추론할 수 있는 runner
객체를 생성합니다. 이 runner
객체는 추론 시 runner.infer
를 사용하며, 입력과 출력 모두 dict
타입을 사용합니다. 이때 입력 dict
의 key
로는 ONNX 모델 생성 시 torch.onnx.export
에 인자 input_names
로 전달했던 값을, 출력 dict
의 key
로는 인자 output_names
로 전달했던 값을 사용해야 합니다. 즉, 이미지를 전처리한 후 {input_names: image}
형태의 dict
로 변형하여 runner.infer
에 입력합니다. 또한, 출력 dict
의 key
로 output_names
를 이용해 value
에 접근하여 모델의 출력을 얻을 수 있습니다.
양자화 단계별 성능 비교
비교군 설정
1. FP32 원본 모델
builder_config = poly_trt.create_config(builder=builder,
network=network)
create_config
에서 정밀도 및 Calibrator
관련 인자를 제거하여 FP32 타입만으로 모델을 빌드하도록 변경하였습니다.
2. 랜덤 데이터를 Calibrator
에 통과시킨 INT8 모델
# Generate random data instead of real data.
def data_generator():
for _ in range(1):
image = torch.randn(1, 3, 224, 224).numpy()
yield {"input": image}
# Set calibrator with a new data generator.
calibrator = poly_trt.Calibrator(data_loader=data_generator())
data_generator
가 원래의 학습 데이터 대신 torch.randn
으로 생성한 랜덤 데이터를 반환하도록 하였습니다.
실험 방식
import os
import time
from PIL import Image
from polygraphy.backend import trt as poly_trt
import tqdm
# Load TensorRT engine from ENGINE_PATH.
engine = poly_trt.engine_from_bytes(open(ENGINE_PATH, "rb").read())
# Refer to Hugging Face for IMAGENET2012_CLASSES.
class_list = list(IMAGENET2012_CLASSES.keys())
val_list = os.listdir(IMAGE_DIR)
total_infer_time = 0.0
total_correct = 0
with poly_trt.TrtRunner(engine) as runner:
for image in tqdm.tqdm(val_list):
# Get label from file name.
label = image.split("_")[-1].split(".")[0]
# Preprocess.
image_path = os.path.join(IMAGE_DIR, image)
image = transform(Image.open(image_path).convert("RGB"))
image = image.unsqueeze(0).numpy()
# Get inference time using 'time' module.
before_infer = time.time()
output_dict = runner.infer({"input": image})
total_infer_time += time.time() - before_infer
output = output_dict["output"]
predicted = output.argmax()
# Calculate total number of correct outputs.
total_correct += class_list[predicted.item()] == label
# Calculate and print average values.
accuracy = 100 * total_correct / len(val_list)
avg_infer_time_ms = 1000 * total_infer_time / len(val_list)
print(f"Accuracy: {accuracy:.2f} %")
print(f"Average inference time: {avg_infer_time_ms:.2f} ms")
total_infer_time
과 total_correct
에 누적합니다. 추론 시간은 runner.infer
실행 전후에 time.time
을 사용하여 계산하였고, 정답 여부는 class_list
에서 인덱스로 구한 클래스 이름이 이미지 이름에 포함된 클래스 이름과 일치하는지 체크하여 알아내도록 하였습니다.
모든 합산이 끝난 후엔 누적된 값들을 이미지 개수로 나누어 평균 추론 시간과 정확도를 출력하도록 하였습니다.
실험 결과 및 결론
표 2. 양자화 단계별 성능 비교 실험 결과
이로부터, Polygraphy를 이용한 학습 데이터 기반 캘리브레이션 및 양자화는 모델의 정확도를 거의 유지하면서도 추론 속도를 크게 향상시키고 모델 크기를 줄일 수 있음을 알 수 있습니다.
마치며
로봇 ML 모델 경량화 시리즈의 두 번째 글에서는 모델 훈련 과정에서 사용할 수 있는 또 다른 양자화 방법인 양자화 인식 훈련에 대해 다룰 예정이니 많은 관심 부탁드립니다.
우아한형제들 로보틱스LAB에서 컴퓨터비전과 머신러닝 연구 개발을 담당하고 있습니다.