- Published on
파이썬 ONNX Runtime로 CNN INT8 양자화 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
대규모 CNN을 서비스 환경에 올리면 가장 먼저 부딪히는 벽이 latency 와 cost 입니다. FP32로는 GPU가 있더라도 동시 요청이 늘 때 비용이 급격히 상승하고, CPU 추론이라면 배치가 커질수록 지연시간이 쉽게 한계를 넘습니다. 이때 현실적인 카드가 INT8(8bit) 양자화인데, 막상 적용하면 정확도가 떨어지거나 특정 레이어에서만 성능이 안 나오는 경우가 흔합니다.
이 글은 파이썬 + ONNX Runtime 기준으로, 대규모 CNN을 INT8로 양자화하고 튜닝하는 실전 절차를 다룹니다. 특히 다음을 목표로 합니다.
- 정확도 하락을 최소화하는 캘리브레이션(대표 데이터) 구성
QOperator와QDQ포맷 선택 기준- per-channel / per-tensor, symmetric / asymmetric 결정 포인트
- ORT 세션 옵션과 EP(Execution Provider) 조합으로 지연시간 튜닝
- 병목 레이어와 폴백(일부 레이어 FP16/FP32 유지) 접근
이미 PyTorch에서 ONNX로 내보내고 TensorRT까지 가는 흐름이 궁금하다면, 전체 파이프라인 관점에서는 이 글도 같이 보면 맥락이 이어집니다: PyTorch→ONNX→TensorRT INT8 양자화 실전 가이드
1) 양자화 전에 확인할 것: 모델과 목표 하드웨어
INT8은 만능이 아닙니다. 성능 이득은 하드웨어 커널 지원과 그래프가 얼마나 INT8로 유지되는지에 달려 있습니다.
- CPU: 보통
AVX2/AVX512-VNNI여부가 핵심입니다. - GPU:
CUDA EP만으로는 INT8 속도 이득이 제한적일 수 있고, 보통은 TensorRT EP로 가야 “진짜 INT8” 성능이 나옵니다. - CNN 구조: Conv-BN-ReLU 패턴은 INT8 친화적이지만,
Resize/GridSample류 연산이나 특이한Activation이 섞이면 중간에 FP로 폴백되어 이득이 줄어듭니다.
또한 ONNX 그래프 최적화(상수 폴딩, Conv+BN fusion 등)가 선행되면 양자화 품질과 성능이 모두 좋아지는 경우가 많습니다.
2) ONNX Runtime 양자화 방식: PTQ 중심으로
ONNX Runtime의 대표적인 양자화는 PTQ(Post-Training Quantization)입니다.
- Dynamic quantization: 주로
MatMul/Gemm계열(Transformer에 유리). CNN에는 효과가 제한적일 때가 많습니다. - Static quantization: 캘리브레이션 데이터를 사용해 activation scale/zero-point를 결정. CNN에는 보통 이게 정답입니다.
Static quantization은 다시 그래프 표현에 따라 다음으로 나뉩니다.
QOperator: 양자화된 연산자 자체(QLinearConv등)로 치환QDQ:QuantizeLinear/DequantizeLinear노드를 삽입해 원래 연산자를 감싸는 방식
일반적으로 하드웨어/EP 호환성은 QDQ가 더 무난하고, 특정 런타임 최적화는 QOperator가 유리한 경우가 있습니다. 최근 생태계에서는 QDQ가 더 널리 쓰입니다.
3) 설치 및 기본 준비
pip install onnx onnxruntime onnxruntime-tools numpy pillow
- CPU만 쓸 거면
onnxruntime으로 충분합니다. - GPU라면 환경에 맞춰
onnxruntime-gpu를 고려하되, 실제 INT8 성능은 TensorRT EP까지 포함한 구성이 필요할 수 있습니다.
4) 캘리브레이션 데이터가 성패를 가른다
PTQ에서 가장 중요한 것은 캘리브레이션 데이터가 실제 분포를 대표하는가입니다.
권장 전략:
- 서비스 트래픽에서 샘플링한 데이터(가능하면 최근 데이터)
- 클래스 불균형이 심하면 “자주 나오는 클래스”만으로 캘리브레이션하지 말 것
- 전처리(리사이즈, 정규화, 색공간)가 학습/추론과 100% 동일해야 함
- 데이터 수량은 경험적으로
100~1000사이에서 시작해 튜닝(모델 크기/도메인에 따라 다름)
아래는 이미지 폴더를 읽어 NCHW 배치로 반환하는 간단한 캘리브레이션 데이터리더 예시입니다.
import os
import numpy as np
from PIL import Image
class ImageFolderCalibrationDataReader:
def __init__(self, image_dir, input_name, size=(224, 224), mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225), batch_size=1, limit=200):
self.image_dir = image_dir
self.input_name = input_name
self.size = size
self.mean = np.array(mean, dtype=np.float32).reshape(1, 1, 3)
self.std = np.array(std, dtype=np.float32).reshape(1, 1, 3)
self.batch_size = batch_size
self.limit = limit
self._paths = [
os.path.join(image_dir, f)
for f in sorted(os.listdir(image_dir))
if f.lower().endswith((".jpg", ".jpeg", ".png"))
][:limit]
self._idx = 0
def _preprocess(self, path):
img = Image.open(path).convert("RGB").resize(self.size)
x = np.asarray(img).astype(np.float32) / 255.0
x = (x - self.mean) / self.std
x = np.transpose(x, (2, 0, 1))
return x
def get_next(self):
if self._idx >= len(self._paths):
return None
batch_paths = self._paths[self._idx:self._idx + self.batch_size]
self._idx += self.batch_size
batch = np.stack([self._preprocess(p) for p in batch_paths], axis=0)
return {self.input_name: batch}
def rewind(self):
self._idx = 0
주의: 캘리브레이션은 “정확도 평가 데이터”가 아니라 “분포 추정용”입니다. 정확도 평가는 별도의 검증셋으로 해야 합니다.
5) Static INT8 양자화 실행 코드
아래 코드는 ONNX Runtime quantization API를 사용해 static INT8 모델을 생성합니다.
from onnxruntime.quantization import (
quantize_static,
CalibrationMethod,
QuantFormat,
QuantType,
)
fp32_model_path = "model_fp32.onnx"
int8_model_path = "model_int8_qdq.onnx"
# 모델 입력 이름은 netron 등으로 확인하거나, ORT로 세션 생성 후 확인할 수 있습니다.
input_name = "input"
dr = ImageFolderCalibrationDataReader(
image_dir="./calib_images",
input_name=input_name,
size=(224, 224),
batch_size=8,
limit=400,
)
quantize_static(
model_input=fp32_model_path,
model_output=int8_model_path,
calibration_data_reader=dr,
quant_format=QuantFormat.QDQ,
activation_type=QuantType.QUInt8,
weight_type=QuantType.QInt8,
calibrate_method=CalibrationMethod.MinMax,
per_channel=True,
reduce_range=False,
)
print("saved:", int8_model_path)
여기서 튜닝 포인트는 다음입니다.
per_channel=True: CNN에서 Conv weight는 per-channel이 정확도에 유리한 경우가 많습니다.activation_type=QUInt8와weight_type=QInt8: 흔한 조합입니다.CalibrationMethod.MinMax: 빠르고 단순하지만 outlier에 약합니다. outlier가 심하면 다른 방법을 고려해야 합니다.
6) 정확도 하락을 줄이는 튜닝 체크리스트
6.1 캘리브레이션 방법 바꾸기
MinMax는 activation 분포에 outlier가 있으면 scale이 과도하게 커져 양자화 해상도가 떨어질 수 있습니다. 이런 경우엔 percentile 기반이 유리한데, ORT 버전/옵션에 따라 지원 범위가 다를 수 있어 릴리스 노트를 확인해야 합니다.
실무 팁:
- outlier가 강한 입력(야간, 역광, 과노출)을 캘리브레이션 샘플에 포함
- 캘리브레이션 배치 크기를 너무 키우면 I/O 병목으로 시간이 늘 뿐 품질이 크게 안 좋아질 수도 있음
6.2 레이어별로 “양자화 제외”하기
정확도 민감 레이어(첫 Conv, 마지막 FC/Conv, 특정 attention-like 블록)가 있을 수 있습니다. 이 경우 일부 노드를 FP로 유지하는 전략이 효과적입니다.
아래는 개념 예시입니다. 실제로는 모델마다 제외할 노드 이름을 찾아 적용해야 합니다.
from onnxruntime.quantization import quantize_static, QuantFormat, QuantType, CalibrationMethod
nodes_to_exclude = [
"Conv_0", # 예: 첫 Conv
"Gemm_123", # 예: 마지막 분류기
]
quantize_static(
model_input="model_fp32.onnx",
model_output="model_int8_exclude.onnx",
calibration_data_reader=dr,
quant_format=QuantFormat.QDQ,
activation_type=QuantType.QUInt8,
weight_type=QuantType.QInt8,
calibrate_method=CalibrationMethod.MinMax,
per_channel=True,
nodes_to_exclude=nodes_to_exclude,
)
튜닝 순서로는:
- 전체 양자화로 baseline
- 정확도 크게 깨지는 케이스를 수집
- 민감 레이어를 추적(프로파일링 + ablation)
- 제외 노드 최소화(너무 많이 제외하면 성능 이득이 줄어듦)
6.3 입력 전처리 불일치 제거
INT8에서 전처리 불일치가 더 치명적으로 보이는 경우가 많습니다.
- 학습은
BGR인데 추론은RGB - 학습은
mean/std정규화인데 추론은0~1스케일만 적용 - 리사이즈 방식이 다름(
bilinearvsbicubic)
이 문제는 “양자화가 원인”처럼 보이지만 실제로는 파이프라인 문제인 경우가 많습니다.
7) 성능 튜닝: ORT 세션 옵션과 프로파일링
양자화 모델을 만들었는데도 느리면, 모델 자체보다 런타임 설정이 병목일 수 있습니다.
7.1 기본 세션 옵션
import onnxruntime as ort
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
so.intra_op_num_threads = 8
so.inter_op_num_threads = 1
sess = ort.InferenceSession(
"model_int8_qdq.onnx",
sess_options=so,
providers=["CPUExecutionProvider"],
)
print([i.name for i in sess.get_inputs()])
intra_op_num_threads: 단일 연산 내부 병렬화(Conv 등)inter_op_num_threads: 연산 간 병렬 실행
실무에서는 보통 inter_op는 낮게, intra_op를 코어 수에 맞추는 쪽이 안정적인 경우가 많습니다. 단, 모델 구조와 배치 크기에 따라 반대가 유리할 수도 있어 벤치마크로 결정해야 합니다.
7.2 ORT 프로파일링으로 병목 찾기
import time
import numpy as np
import onnxruntime as ort
so = ort.SessionOptions()
so.enable_profiling = True
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess = ort.InferenceSession("model_int8_qdq.onnx", sess_options=so, providers=["CPUExecutionProvider"])
input_name = sess.get_inputs()[0].name
x = np.random.randn(1, 3, 224, 224).astype(np.float32)
# warmup
for _ in range(10):
sess.run(None, {input_name: x})
t0 = time.perf_counter()
for _ in range(100):
sess.run(None, {input_name: x})
t1 = time.perf_counter()
profile_path = sess.end_profiling()
print("avg ms:", (t1 - t0) * 1000 / 100)
print("profile:", profile_path)
프로파일 JSON을 보면 어떤 노드가 오래 걸리는지, INT8 커널을 타는지(혹은 FP로 폴백되는지) 실마리를 얻을 수 있습니다.
8) 대규모 CNN에서 자주 터지는 이슈와 대응
8.1 메모리와 I/O가 병목
대규모 CNN은 계산보다 메모리 대역폭이 병목인 경우가 많습니다. INT8로 줄이면 가중치/activation 메모리 풋프린트가 줄어 이득이 나지만, 전처리나 입력 복사가 느리면 체감이 약합니다.
- 입력 파이프라인을 벤치마크에서 분리(전처리 시간 포함 여부를 명확히)
numpy배열 contiguous 여부 확인- 배치가 커지면 L3 캐시 미스가 늘어 오히려 느려질 수 있음
학습/추론에서 OOM이나 속도 저하를 다루는 접근 자체는 비슷한 면이 있어, 대규모 모델 튜닝 감각을 잡는 데는 이 글도 참고가 됩니다: SDXL LoRA 학습 OOM·속도저하 해결 7가지
8.2 일부 연산이 INT8 미지원이라 폴백
그래프에 INT8 미지원 op가 끼면, 그 주변에 QuantizeLinear/DequantizeLinear가 반복 삽입되어 오버헤드가 커집니다.
대응:
- 해당 op를 대체 가능한지 검토(예: 특정 Resize 모드 변경)
- 폴백 구간을 크게 묶어 FP로 유지(오히려 변환 오버헤드 감소)
- 가능하면 하드웨어/EP가 더 잘 지원하는 그래프 형태로 export 단계에서 조정
8.3 정확도는 괜찮은데 특정 클래스만 무너짐
이 경우 캘리브레이션 데이터의 분포가 특정 클래스/조건을 대표하지 못했을 가능성이 큽니다.
- 실패 케이스를 캘리브레이션 샘플에 추가
- per-channel 유지 + 민감 레이어 제외를 조합
- 마지막 분류기 레이어만 FP로 유지해도 회복되는 경우가 있음
9) 실전 워크플로우 요약
아래 순서로 진행하면 시행착오를 줄일 수 있습니다.
- FP32 ONNX를 최적화 가능한 형태로 export(불필요한 op 최소화)
- 캘리브레이션 데이터셋 구성(전처리 동일성 검증)
QDQ + per-channelstatic INT8로 1차 양자화- 정확도 평가(전체 지표 + 실패 케이스 분석)
- 민감 레이어 제외, 캘리브레이션 샘플 보강으로 정확도 회복
- ORT 프로파일링으로 폴백/병목 확인
- 세션 옵션 스레드 튜닝 및 EP 선택 최적화
운영 환경에서는 성능 문제는 모델만이 아니라 인프라 상태에서도 발생합니다. 예를 들어 쿠버네티스에서 Pod가 재시작 루프에 빠지면 모델 튜닝 이전에 서비스가 불안정해집니다. 배포 운영 관점의 트러블슈팅은 K8s CrashLoopBackOff - readinessProbe 실패 7원인도 함께 체크해두면 좋습니다.
10) 마무리
ONNX Runtime에서 CNN INT8 양자화는 “한 번 버튼 누르면 끝”이 아니라, 캘리브레이션 데이터 품질과 그래프 호환성, 런타임 프로파일링이 합쳐져야 성능과 정확도를 동시에 잡을 수 있습니다.
가장 효과가 큰 팁만 압축하면 다음 3가지입니다.
- 캘리브레이션 데이터는 실제 트래픽 분포를 대표하게 구성
QDQ + per-channel을 기본값으로 시작하고, 민감 레이어는 최소한으로 제외- 프로파일링으로 INT8 유지율과 폴백 구간을 확인한 뒤 세션 옵션/EP를 튜닝
원하면 다음 단계로, TensorRT EP 연동, mixed precision(일부 FP16/FP32 유지) 전략, 혹은 모델별(ResNet/EfficientNet/ConvNeXt) 튜닝 포인트까지 확장해서 정리해줄 수 있습니다.