- Published on
TensorFlow Lite PTQ로 YOLOv8 4배 가속하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 돌리던 YOLOv8을 엣지 디바이스나 모바일로 옮기면, 가장 먼저 부딪히는 벽이 추론 지연시간입니다. 같은 모델이라도 런타임과 연산 정밀도에 따라 체감 성능이 크게 달라지는데, 그중 가장 비용 대비 효과가 큰 방법이 TensorFlow Lite(TFLite) + PTQ(Post-Training Quantization) 입니다.
이 글에서는 YOLOv8을 TFLite로 변환한 뒤 PTQ로 INT8 양자화를 적용해 4배 수준의 가속을 노리는 전체 파이프라인을 다룹니다. 또한 “양자화만 하면 빨라진다”가 아니라, 실제로는 연산자 지원, 입력 전처리, 대표 데이터셋, NMS 위치 같은 요소가 성능과 정확도를 좌우하므로 그 포인트를 집중적으로 정리합니다.
참고로 변환/빌드 과정에서 패키지 다운로드가 실패하거나 인증서 문제로 막히면 개발 흐름이 끊깁니다. 파이썬 환경에서 CERTIFICATE_VERIFY_FAILED가 뜬다면 Python SSL CERTIFICATE_VERIFY_FAILED 10분 해결도 같이 확인해두면 좋습니다.
목표 아키텍처: YOLOv8을 TFLite로, 그리고 INT8로
YOLOv8은 보통 PyTorch에서 학습됩니다. TFLite PTQ를 적용하려면 크게 두 가지 경로가 있습니다.
- PyTorch
->ONNX->TensorFlow SavedModel->TFLite - (가능하면) TensorFlow 계열로 직접 학습/내보내기
현실적으로는 1번이 흔합니다. 다만 변환 과정에서 연산자 호환성 문제가 생기기 쉬워서, 변환 가능한 그래프를 만들기 위한 모델 내보내기 옵션이 중요합니다.
PTQ가 왜 빠른가
PTQ는 학습을 다시 하지 않고(혹은 최소화하고) 가중치와 활성값을 INT8로 양자화합니다. 이때 TFLite는 하드웨어에 따라 다음 이점을 얻습니다.
- ARM CPU에서 INT8 GEMM 최적화 경로 활용
- Edge TPU, NPU, DSP 등에서 INT8 가속기 활용 가능성 증가
- 메모리 대역폭 감소(가중치가 4분의 1 크기)
다만 “무조건 4배”는 아닙니다. 실제 가속은 INT8로 내려간 연산 비율, delegate 적용 여부, NMS 포함 여부에 의해 결정됩니다.
성능을 좌우하는 3가지 결정: NMS, 입력 해상도, delegate
1) NMS를 어디서 하느냐
YOLO 계열은 보통 모델 출력 이후에 NMS(Non-Maximum Suppression)를 수행합니다. 여기서 선택지가 있습니다.
- NMS를 모델 그래프 안에 포함
- NMS를 앱 코드에서 CPU로 처리
TFLite에서 NMS를 그래프에 넣으면 연산자 지원 이슈가 생기거나, 오히려 느려질 수 있습니다. 실무에서는 모델은 raw output만 내고, NMS는 런타임에서 최적화된 구현을 선택하는 경우가 많습니다.
2) 입력 해상도는 “정확도 vs 지연시간”의 가장 큰 레버
640x640에서 512x512로만 내려도 지연시간이 크게 줄어드는 경우가 많습니다. PTQ로 얻는 이득과 별개로, 해상도는 연산량을 제곱으로 줄이기 때문에 가장 강력합니다.
3) delegate 적용 여부
TFLite는 기본 CPU 실행 외에 여러 delegate가 있습니다.
- Android: NNAPI delegate
- GPU delegate
- Edge TPU delegate(장치 필요)
- XNNPACK(대부분 CPU 최적화)
PTQ INT8의 “4배 가속”은 보통 XNNPACK + INT8 경로가 잘 타거나, NNAPI/NPU가 제대로 붙을 때 현실화됩니다.
변환 파이프라인: ONNX에서 TFLite까지
여기서는 가장 일반적인 파이프라인을 예시로 듭니다.
1) PyTorch에서 ONNX 내보내기
Ultralytics YOLOv8을 쓴다면 대개 내보내기 커맨드가 있습니다. 환경에 따라 옵션명이 다를 수 있으니, 핵심은 고정 입력 크기와 dynamic shape 최소화입니다.
yolo export model=yolov8n.pt format=onnx imgsz=640 opset=13
변환 안정성을 위해 작은 모델(yolov8n)로 먼저 end-to-end를 통과시키고, 이후 yolov8s/m/l로 확장하는 편이 좋습니다.
2) ONNX를 TensorFlow SavedModel로 변환
대표적으로 onnx-tf 또는 onnx2tf 같은 도구를 씁니다. 여기서는 onnx2tf 스타일의 예시를 듭니다.
pip install onnx onnxruntime onnx2tf tensorflow
onnx2tf \
-i yolov8n.onnx \
-o saved_model_yolov8n
이 단계에서 자주 터지는 문제가 연산자 변환 실패입니다. 특히 Resize, Sigmoid, Concat, Transpose는 대체로 잘 되지만, YOLO head 쪽의 reshape/permute 조합에서 문제가 생길 수 있습니다. 해결 방향은 보통 다음 중 하나입니다.
- 내보내기 opset 변경
- dynamic shape 제거
- 후처리(NMS 등)를 그래프 밖으로 빼기
3) SavedModel에서 TFLite로 변환(PTQ 적용)
핵심은 tf.lite.TFLiteConverter에서 최적화 플래그와 representative dataset을 넣는 것입니다.
import tensorflow as tf
import numpy as np
SAVED_MODEL_DIR = "saved_model_yolov8n"
def representative_dataset():
# 실제 운영 입력 분포를 반영한 이미지 샘플이 중요
# 여기서는 예시로 100개 더미를 만듭니다.
for _ in range(100):
# YOLO 전처리(리사이즈/정규화)가 모델 외부에 있다면
# 대표 데이터셋도 동일한 스케일로 맞춰야 합니다.
img = np.random.rand(1, 640, 640, 3).astype(np.float32)
yield [img]
converter = tf.lite.TFLiteConverter.from_saved_model(SAVED_MODEL_DIR)
# PTQ 활성화
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset
# INT8로 강제(가능한 범위에서)
converter.target_spec.supported_ops = [
tf.lite.OpsSet.TFLITE_BUILTINS_INT8,
tf.lite.OpsSet.TFLITE_BUILTINS,
]
# 입력/출력까지 INT8로 내리면 가장 빠르지만,
# 파이프라인(카메라 입력, 전처리)까지 고려해야 합니다.
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8
tflite_model = converter.convert()
with open("yolov8n_int8.tflite", "wb") as f:
f.write(tflite_model)
여기서 representative dataset이 부실하면 정확도가 크게 떨어질 수 있습니다. 특히 객체 탐지는 분류보다 활성값 분포가 민감하게 흔들릴 수 있어, 다음 원칙을 권장합니다.
- 운영 환경과 비슷한 조명/배경/거리/카메라 노이즈를 포함
- 최소 수백 장 이상(가능하면 1천 장 이상)
- 전처리(정규화, letterbox, 색공간)까지 동일하게
“4배 가속”을 만들려면: 병목을 숫자로 확인하기
PTQ 자체는 모델 파일을 줄이고 INT8 커널을 쓰게 만들어주지만, 실제 앱에서 체감 속도는 전처리, 후처리, 메모리 복사가 잡아먹는 경우가 많습니다. 따라서 반드시 구간별로 시간을 쪼개 측정해야 합니다.
아래는 파이썬에서 TFLite 인터프리터로 대략적인 latency를 재는 예시입니다.
import time
import numpy as np
import tflite_runtime.interpreter as tflite
interpreter = tflite.Interpreter(model_path="yolov8n_int8.tflite", num_threads=4)
interpreter.allocate_tensors()
input_details = interpreter.get_input_details()
output_details = interpreter.get_output_details()
# INT8 입력 예시
inp = np.random.randint(-128, 127, size=input_details[0]["shape"], dtype=np.int8)
# 워밍업
for _ in range(10):
interpreter.set_tensor(input_details[0]["index"], inp)
interpreter.invoke()
# 벤치
N = 100
t0 = time.perf_counter()
for _ in range(N):
interpreter.set_tensor(input_details[0]["index"], inp)
interpreter.invoke()
_ = interpreter.get_tensor(output_details[0]["index"])
t1 = time.perf_counter()
print(f"avg ms: {(t1 - t0) * 1000 / N:.3f}")
측정할 때는 다음을 지키는 게 중요합니다.
- 워밍업을 반드시 수행(캐시/주파수 스케일링 영향)
- 동일 스레드 수로 비교
- 전처리/후처리를 분리 측정
정확도 하락을 줄이는 PTQ 실전 팁
1) 입력/출력을 float로 유지하는 타협안
가장 빠른 구성은 입력/출력까지 INT8이지만, 운영 파이프라인에서 입력이 원래 float이고 후처리가 float 기반이면 변환 비용이 생깁니다. 이때는 다음 타협이 유효합니다.
- 내부 연산은 INT8
- 입력/출력은 float32
즉 converter.inference_input_type과 converter.inference_output_type을 tf.float32로 두고 내부만 INT8로 내리는 방식입니다. 속도는 조금 덜 나오지만 정확도와 구현 난이도가 좋아집니다.
2) representative dataset은 “정답 라벨”이 필요 없다
PTQ는 calibration이 목적이므로 라벨이 필요 없습니다. 대신 데이터 분포가 중요합니다. 특히 YOLO는 배경의 분포가 다양할수록 활성값 범위가 안정화되는 경향이 있습니다.
3) letterbox 전처리 일관성
YOLO 계열은 흔히 letterbox를 씁니다. 학습 때 letterbox였는데 추론 때 단순 resize로 바꾸면, PTQ 이전에도 정확도가 흔들립니다. PTQ 이후에는 그 흔들림이 더 커질 수 있습니다.
- 학습/평가/대표 데이터셋/운영 전처리를 동일하게 유지
- 색공간(BGR/RGB), 정규화(0
1, -11)도 동일하게 유지
흔한 실패 패턴과 디버깅 체크리스트
변환은 되는데 속도가 안 나오는 경우
- 실제로 INT8 커널을 타는지 확인
- XNNPACK 활성화 여부 확인(환경에 따라 기본값이 다름)
- delegate가 붙었는지 확인(Android NNAPI 등)
- 후처리(NMS)가 전체 시간의 대부분인지 확인
대부분 “모델 추론은 빨라졌는데 NMS가 병목” 패턴이 많습니다. 이 경우 NMS 최적화(벡터화, threshold 조정, 후보 박스 수 제한)가 체감 성능을 좌우합니다.
정확도가 크게 떨어지는 경우
- representative dataset이 너무 적거나 분포가 다름
- 입력 스케일/제로포인트가 전처리와 불일치
- 출력 디코딩에서 INT8을 float로 복원할 때 스케일 적용 누락
특히 INT8 출력은 scale과 zero_point를 이용해 복원해야 합니다. 아래처럼 출력 텐서의 quantization 파라미터를 확인하세요.
out_detail = output_details[0]
scale, zero = out_detail["quantization"]
print(scale, zero)
복원은 보통 다음과 같습니다.
# y_int8: np.int8
# y_float = (y_int8 - zero_point) * scale
이 과정이 누락되면 박스 좌표와 confidence가 엉망이 됩니다.
운영 관점: 재현 가능한 벤치와 CI
성능 튜닝은 “한 번 빨라졌다”로 끝나지 않고, 모델/런타임/디바이스가 바뀌면서 계속 회귀합니다. 그래서 벤치마크를 자동화해두는 편이 좋습니다.
- 모델 아티팩트(
.tflite)를 버전 관리 - 동일 입력 샘플로 latency와 mAP를 측정
- CI에서 결과를 비교해 회귀 감지
CI에서 캐시가 제대로 안 먹으면 변환/벤치 시간이 과도하게 늘어납니다. GitHub Actions를 쓴다면 GitHub Actions 캐시가 안 먹힐 때 원인 9가지도 함께 참고하면 파이프라인 안정화에 도움이 됩니다.
정리: PTQ는 “버튼”이 아니라 “파이프라인”이다
TFLite PTQ로 YOLOv8을 4배 수준으로 가속하려면, 단순히 양자화 옵션을 켜는 것만으로는 부족합니다. 다음 순서로 접근하면 성공 확률이 올라갑니다.
- 작은 모델로 변환 end-to-end 성공(
yolov8n권장) - 전처리(letterbox, 정규화) 일관성 확보
- representative dataset을 운영 분포로 구성
- INT8 커널이 실제로 적용되는지 확인(delegate, XNNPACK)
- 전처리/추론/후처리(NMS) 구간별 병목 측정
이 과정을 제대로 밟으면, 같은 디바이스에서도 float32 대비 의미 있는 수준의 latency 감소를 만들 수 있고, 특히 CPU 기반 엣지 환경에서 “체감 4배”에 가까운 개선을 얻는 경우도 많습니다.
다음 단계로는 QAT(Quantization Aware Training)로 정확도 하락을 더 줄이거나, 디바이스별 delegate(NNAPI, Edge TPU)로 최적 경로를 확정하는 것을 추천합니다.