- Published on
파이썬 ONNX Runtime로 CNN 10배 경량화·가속
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경에서 CNN 추론이 느려지는 이유는 대개 3가지로 정리됩니다. (1) 불필요하게 큰 연산 그래프, (2) CPU/GPU에 맞지 않는 커널 선택, (3) 입출력·전처리·배치 전략의 비효율입니다. ONNX Runtime(이하 ORT)은 모델을 ONNX 그래프로 표준화하고, 하드웨어별 Execution Provider(EP)로 최적 커널을 붙여주며, 그래프 레벨 최적화와 양자화까지 한 번에 묶어 “같은 모델을 더 싸고 빠르게” 돌리기 좋은 스택입니다.
이 글에서는 파이썬 기준으로 CNN을 ONNX로 변환하고, FP16/INT8로 경량화, ORT 세션 옵션으로 속도 튜닝, 정확도·성능 검증까지 이어지는 실전 플로우를 다룹니다. 목표는 “환경이 맞으면 10배까지도” 가능한 최적화 여지를 최대한 끌어내는 것입니다.
1) 전체 전략: 10배 가속이 나오는 조건
10배는 아무 때나 나오지 않습니다. 다음 조합에서 현실적으로 크게 튑니다.
- CPU 추론인데 PyTorch eager로 돌리고 있었다: ORT 그래프 최적화 + MKL/oneDNN 경로로 큰 폭 개선 가능
- GPU 추론인데 FP32만 쓰고 있었다: FP16(TensorRT/CUDA EP)로 메모리 대역·연산량 감소
- 모바일/엣지: INT8 양자화가 매우 강력(특히 Conv가 많은 CNN)
- 전처리/후처리 병목이 큰데 모델만 최적화했다: 전체 파이프라인 최적화가 필요
또한 “지연시간(latency)”과 “처리량(throughput)”은 튜닝 방향이 다릅니다. 실시간 1장 추론이면 배치 크기 1에서의 p50/p95가 중요하고, 배치 처리면 큰 배치에서 images/sec가 중요합니다.
2) 준비: 설치와 버전 체크
CPU만이면 onnxruntime로 충분하고, GPU면 CUDA/TensorRT를 고려합니다.
pip install onnx onnxruntime
# GPU(CUDA EP)
pip install onnxruntime-gpu
# 양자화 도구
pip install onnxruntime-tools
환경 확인은 “EP가 제대로 붙었는지”가 핵심입니다.
import onnxruntime as ort
print(ort.get_available_providers())
출력에 CUDAExecutionProvider 또는 TensorrtExecutionProvider가 없으면, 설치/드라이버/CUDA 버전 매칭부터 점검해야 합니다.
3) PyTorch CNN을 ONNX로 내보내기(Export)
가속의 출발점은 정확한 export입니다. CNN은 보통 export가 잘 되지만, 다음을 신경 쓰면 이후 최적화가 쉬워집니다.
eval()모드로 export(드롭아웃/배치정규화 고정)- 입력 텐서 shape를 명시하고, 필요하면 dynamic axes 설정
- opset은 너무 낮지 않게(대개 13~17 권장)
import torch
def export_onnx(model, onnx_path="model.onnx", opset=17):
model.eval()
dummy = torch.randn(1, 3, 224, 224)
torch.onnx.export(
model,
dummy,
onnx_path,
export_params=True,
opset_version=opset,
do_constant_folding=True,
input_names=["input"],
output_names=["logits"],
dynamic_axes={
"input": {0: "batch"},
"logits": {0: "batch"}
},
)
# export_onnx(my_cnn_model)
Export 후 기본 검증
import onnx
m = onnx.load("model.onnx")
onnx.checker.check_model(m)
print("ONNX OK")
여기서 실패하면, 해당 연산이 ONNX로 변환 불가이거나 opset/패턴 이슈일 수 있습니다. CNN에서 흔한 문제는 커스텀 op, 특정 interpolation 옵션, 일부 비표준 activation 조합입니다.
4) ONNX Runtime 기본 추론: I/O 바인딩과 세션 옵션
ORT의 성능은 “세션 생성 옵션”에서 크게 갈립니다. 기본값은 안전하지만 빠르지 않을 수 있습니다.
4-1) 세션 옵션: 그래프 최적화 레벨
import onnxruntime as ort
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess = ort.InferenceSession(
"model.onnx",
sess_options=so,
providers=["CPUExecutionProvider"],
)
ORT_ENABLE_ALL은 Conv, BN, ReLU 같은 패턴을 fuse하고 상수 folding 등을 적극적으로 수행합니다.
4-2) 스레드 튜닝(특히 CPU)
CPU는 스레드 설정이 체감 성능에 직결됩니다.
intra_op_num_threads: 하나의 op 내부 병렬inter_op_num_threads: op 간 병렬
so = ort.SessionOptions()
so.intra_op_num_threads = 8
so.inter_op_num_threads = 1
so.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess = ort.InferenceSession("model.onnx", sess_options=so, providers=["CPUExecutionProvider"])
일반적으로 단일 입력 지연시간 최적화는 inter_op를 낮추고 intra_op를 코어 수에 맞추는 쪽이 유리한 경우가 많습니다. 반대로 배치 처리량은 inter_op를 올려볼 여지가 있습니다.
4-3) 입력/출력 복사 줄이기: I/O binding(고급)
GPU에서 특히 중요한데, 텐서를 GPU에 올렸다가 다시 CPU로 내리는 복사 비용이 지연시간을 갉아먹습니다. ORT는 I/O binding으로 디바이스 상에서 입출력을 묶을 수 있습니다(EP에 따라 지원 범위 차이).
5) FP16로 경량화: GPU에서 가장 쉬운 승부수
FP16은 “정확도 손실이 작고” “메모리·대역폭을 절약”해 CNN에서 성능이 잘 나옵니다. 선택지는 2가지입니다.
- TensorRT EP로 FP16 엔진 빌드
- ONNX 모델 자체를 FP16으로 변환(단, 특정 op에서 수치 이슈 가능)
5-1) TensorRT EP에서 FP16 사용
TensorRT EP는 환경 구축 난도가 있지만, 맞으면 가장 큰 가속을 줍니다.
import onnxruntime as ort
providers = [
("TensorrtExecutionProvider", {
"trt_fp16_enable": True,
"trt_engine_cache_enable": True,
"trt_engine_cache_path": "./trt_cache",
}),
"CUDAExecutionProvider",
"CPUExecutionProvider",
]
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess = ort.InferenceSession("model.onnx", sess_options=so, providers=providers)
print(sess.get_providers())
주의할 점:
- 첫 실행은 엔진 빌드로 느립니다(캐시 켜면 이후 빨라짐)
- dynamic shape가 많으면 엔진 최적화가 어려워질 수 있습니다
6) INT8 양자화로 10배에 근접하기: QDQ와 Calibration
INT8은 “정확도 vs 속도” 트레이드오프가 있지만, CNN에서는 정확도 손실을 관리하면서도 큰 가속을 얻기 쉽습니다. ORT 양자화는 크게 두 계열입니다.
- Dynamic quantization: 주로 FC/MatMul 계열에 유리
- Static quantization: Conv까지 포함 가능, 대신 calibration 필요
CNN은 보통 static quantization이 핵심입니다.
6-1) Calibration 데이터 준비
대표 입력 분포를 반영하는 이미지 샘플이 필요합니다. 수백~수천 장이면 보통 출발점으로 충분합니다.
6-2) ORT quantization: static QDQ 예시
from onnxruntime.quantization import (
quantize_static,
CalibrationDataReader,
QuantType,
QuantFormat,
)
import numpy as np
class ImageDataReader(CalibrationDataReader):
def __init__(self, npy_list):
self.data = [{"input": x.astype(np.float32)} for x in npy_list]
self.it = iter(self.data)
def get_next(self):
return next(self.it, None)
# 예: 전처리 완료된 NCHW float32 텐서 리스트
calib_samples = [np.random.randn(1, 3, 224, 224) for _ in range(200)]
reader = ImageDataReader(calib_samples)
quantize_static(
model_input="model.onnx",
model_output="model.int8.onnx",
calibration_data_reader=reader,
quant_format=QuantFormat.QDQ,
activation_type=QuantType.QInt8,
weight_type=QuantType.QInt8,
per_channel=True,
reduce_range=False,
)
튜닝 포인트:
QuantFormat.QDQ는 호환성이 좋고 EP 최적화 경로가 명확합니다per_channel=True는 Conv weight에 특히 중요(정확도 손실 감소)- calibration 샘플이 입력 분포를 대표하지 못하면 정확도가 크게 흔들립니다
6-3) 정확도 검증 루프(필수)
양자화는 성능보다 먼저 “정확도 회귀”를 잡아야 합니다.
import onnxruntime as ort
import numpy as np
def run(sess, x):
return sess.run(None, {"input": x})[0]
def compare(fp32_path, int8_path, xs):
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
s1 = ort.InferenceSession(fp32_path, sess_options=so, providers=["CPUExecutionProvider"])
s2 = ort.InferenceSession(int8_path, sess_options=so, providers=["CPUExecutionProvider"])
diffs = []
for x in xs:
y1 = run(s1, x.astype(np.float32))
y2 = run(s2, x.astype(np.float32))
diffs.append(np.mean(np.abs(y1 - y2)))
return float(np.mean(diffs)), float(np.max(diffs))
xs = [np.random.randn(1, 3, 224, 224) for _ in range(50)]
print(compare("model.onnx", "model.int8.onnx", xs))
실제 서비스에서는 top-1 정확도, mAP, F1 등 태스크 지표로 비교해야 합니다. 단순 L1 diff는 “이상 징후 감지” 정도로만 쓰는 게 안전합니다.
7) ORT 실행 가속 옵션: 프로파일링으로 병목 찾기
“모델을 ONNX로 바꿨는데 별로 안 빨라요”는 대부분 병목이 모델 밖에 있거나, EP가 기대대로 동작하지 않기 때문입니다.
7-1) ORT 프로파일링 켜기
import onnxruntime as ort
so = ort.SessionOptions()
so.enable_profiling = True
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess = ort.InferenceSession("model.onnx", sess_options=so, providers=["CPUExecutionProvider"])
# ... 추론 몇 번 수행
profile_path = sess.end_profiling()
print(profile_path)
프로파일 JSON을 보면 어떤 op가 시간을 잡아먹는지, fuse가 되었는지 힌트를 얻을 수 있습니다.
7-2) 입력 파이프라인 최적화(자주 놓침)
CNN 추론에서 실제로는 다음이 병목이 되기 쉽습니다.
- JPEG decode, resize, normalize가 Python 레벨에서 느림
- HWC to CHW 변환과 메모리 복사가 과다
- 배치 구성 시 리스트 append 등 오버헤드
가능하면 전처리를 벡터화하고, numpy contiguous를 유지하고, 멀티프로세싱/스레딩을 조절하세요.
8) “10배”에 가까워지는 실전 레시피
아래 조합은 현업에서 가장 재현성이 좋았습니다.
- PyTorch eager 대비 ORT로 이동:
ORT_ENABLE_ALL+ 스레드 튜닝 - CPU라면 INT8 static QDQ + per-channel
- GPU라면 TensorRT EP + FP16 + 엔진 캐시
- 전처리를 병렬화하거나 C++/CUDA/TVM 계열로 내리기 전에, 최소한 numpy 레벨로 정리
특히 CPU에서 FP32 모델을 그대로 돌리던 경우, INT8 + 스레드 튜닝으로 “한 자릿수 배” 개선이 나오는 사례가 많습니다. GPU는 FP16만으로도 큰 폭이 나지만, 입력이 작거나 배치가 1이면 커널 런치 오버헤드가 상대적으로 커서 기대만큼 안 나올 수 있습니다.
9) 운영 관점: 서빙, 카나리, 롤백까지
최적화 모델은 정확도뿐 아니라 “운영 리스크”가 있습니다. INT8/FP16은 엣지 케이스에서 예측이 달라질 수 있고, EP/드라이버 변경에 민감합니다. 그래서 배포는 반드시 카나리로 시작하는 게 안전합니다. GPU 서빙을 쿠버네티스에서 운영한다면 카나리 트래픽 분산과 롤백 전략을 함께 준비하세요.
- GPU 모델 카나리 배포 패턴은 KServe+Istio로 GPU 모델 카나리 배포 실전 가이드 흐름을 참고하면, “최적화 버전”을 소량 트래픽에 먼저 태워 안전하게 검증할 수 있습니다.
또한 성능 튜닝은 결국 “측정”이 전부입니다. 서버 전체 지연시간이 튀면 모델 때문인지, 네트워크/프록시 때문인지 분리해야 합니다. 인그레스 레벨에서 클라이언트 중단이 늘어 499가 폭주하면 모델이 빨라져도 체감이 안 날 수 있습니다. 이 경우 EKS NGINX Ingress 499 폭주 원인과 해결처럼 앞단 병목도 같이 점검하는 편이 좋습니다.
10) 벤치마크 코드: 지연시간/처리량 측정 템플릿
최적화는 “내 환경에서” 재현 가능한 벤치가 있어야 합니다.
import time
import numpy as np
import onnxruntime as ort
def benchmark(model_path, provider="CPUExecutionProvider", warmup=20, iters=200, batch=1):
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess = ort.InferenceSession(model_path, sess_options=so, providers=[provider])
x = np.random.randn(batch, 3, 224, 224).astype(np.float32)
for _ in range(warmup):
sess.run(None, {"input": x})
t0 = time.perf_counter()
for _ in range(iters):
sess.run(None, {"input": x})
t1 = time.perf_counter()
avg_ms = (t1 - t0) * 1000 / iters
ips = batch / ((t1 - t0) / iters)
return avg_ms, ips
print("fp32", benchmark("model.onnx"))
print("int8", benchmark("model.int8.onnx"))
주의:
- GPU는 동기화가 필요할 수 있습니다(측정이 과소/과대평가될 수 있음)
- 첫 실행은 캐시/엔진 빌드 영향이 있으니 warmup을 충분히 둡니다
11) 자주 터지는 함정 체크리스트
- EP가 기대대로 안 붙는다:
sess.get_providers()로 확인 - dynamic axes가 과도해 엔진 최적화가 깨진다: 실사용 shape 범위를 좁히거나 profile을 나눔
- INT8에서 정확도 급락: calibration 데이터 분포/전처리 불일치, per-channel 미적용, 특정 레이어 제외 필요
- 전처리 병목: 모델만 빨라져도 전체는 그대로
- 배치 전략이 서비스 목표와 불일치: 실시간이면 배치 키우면 오히려 p95가 나빠질 수 있음
12) 마무리: “모델 최적화”가 아니라 “시스템 최적화”
ORT는 CNN을 빠르게 만드는 가장 현실적인 도구 중 하나지만, 성능은 모델 파일 하나로 결정되지 않습니다. 그래프 최적화 + EP 선택 + 정밀도(FP16/INT8) + 스레드/메모리 + 전처리 + 운영 배포 전략이 합쳐져야 10배에 가까운 개선이 나옵니다.
다음 액션을 추천합니다.
- 현재 PyTorch 대비 ORT FP32로 baseline 측정
- CPU면 INT8 static QDQ를 먼저 시도하고 정확도 회귀를 지표로 검증
- GPU면 TensorRT EP FP16 + 엔진 캐시로 지연시간 안정화
- 카나리 배포로 실제 트래픽에서 p95/p99와 오류율을 확인
이 순서로 진행하면 “빠른데 위험한 최적화”가 아니라, 빠르고 안전한 최적화에 가깝게 갈 수 있습니다.