Published on

파이썬 ONNX Runtime로 CNN 10배 경량화·가속

Authors

서빙 환경에서 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배”에 가까워지는 실전 레시피

아래 조합은 현업에서 가장 재현성이 좋았습니다.

  1. PyTorch eager 대비 ORT로 이동: ORT_ENABLE_ALL + 스레드 튜닝
  2. CPU라면 INT8 static QDQ + per-channel
  3. GPU라면 TensorRT EP + FP16 + 엔진 캐시
  4. 전처리를 병렬화하거나 C++/CUDA/TVM 계열로 내리기 전에, 최소한 numpy 레벨로 정리

특히 CPU에서 FP32 모델을 그대로 돌리던 경우, INT8 + 스레드 튜닝으로 “한 자릿수 배” 개선이 나오는 사례가 많습니다. GPU는 FP16만으로도 큰 폭이 나지만, 입력이 작거나 배치가 1이면 커널 런치 오버헤드가 상대적으로 커서 기대만큼 안 나올 수 있습니다.

9) 운영 관점: 서빙, 카나리, 롤백까지

최적화 모델은 정확도뿐 아니라 “운영 리스크”가 있습니다. INT8/FP16은 엣지 케이스에서 예측이 달라질 수 있고, EP/드라이버 변경에 민감합니다. 그래서 배포는 반드시 카나리로 시작하는 게 안전합니다. 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배에 가까운 개선이 나옵니다.

다음 액션을 추천합니다.

  1. 현재 PyTorch 대비 ORT FP32로 baseline 측정
  2. CPU면 INT8 static QDQ를 먼저 시도하고 정확도 회귀를 지표로 검증
  3. GPU면 TensorRT EP FP16 + 엔진 캐시로 지연시간 안정화
  4. 카나리 배포로 실제 트래픽에서 p95/p99와 오류율을 확인

이 순서로 진행하면 “빠른데 위험한 최적화”가 아니라, 빠르고 안전한 최적화에 가깝게 갈 수 있습니다.