Published on

파이썬 CNN 10MB→1MB - ONNX+INT8 양자화 튜닝

Authors

서빙 환경(모바일, 엣지, 서버리스)에서는 모델 정확도만큼이나 용량, 로딩 시간, CPU 지연시간이 중요합니다. 특히 CNN 계열은 구조가 단순해 보이지만, 내보내기(Export)와 양자화(Quantization) 과정에서 작은 선택 하나로 정확도와 성능이 크게 흔들립니다.

이 글에서는 **PyTorch CNN 모델(약 10MB)**을 기준으로, ONNX 변환 후 INT8 양자화를 적용해 1MB대까지 줄이는 실전 절차를 다룹니다. 단순히 dynamic quantization 한 번 돌리는 수준이 아니라,

  • 어떤 레이어가 용량을 먹는지
  • 어떤 방식(PTQ vs QAT, dynamic vs static)이 적합한지
  • 캘리브레이션 데이터와 전처리가 왜 정확도를 좌우하는지
  • ONNX Runtime에서 실제로 어떻게 튜닝하는지

를 재현 가능한 코드와 함께 정리합니다.

관련해서 FP8이나 TensorRT까지 확장할 때의 이슈는 아래 글도 참고하면 좋습니다.


목표와 전제: 10MB가 왜 1MB가 되나

대부분의 CNN은 파라미터가 float32로 저장됩니다. 가중치 하나가 4바이트이므로,

  • float32 모델 크기 ≈ 파라미터 수 * 4 bytes
  • int8 모델 크기 ≈ 파라미터 수 * 1 byte + 스케일/제로포인트 메타데이터

이론상 4분의 1로 줄어듭니다. 여기에

  • 불필요한 초기화 상수 제거
  • 그래프 최적화(상수 폴딩, 노드 퓨전)
  • 채널 축 정렬(특정 백엔드에서 효율 향상)

이 더해지면 체감상 10MB급이 1~3MB대로 내려오는 경우가 흔합니다.

다만 정확도 하락CPU에서의 실제 속도 개선 여부는 별개입니다. 특히 x86 서버는 INT8 최적화가 잘 먹지만, ARM/모바일은 커널/연산자 지원 상태에 따라 결과가 달라집니다.


전체 파이프라인

실전에서 가장 안전한 흐름은 아래입니다.

  1. PyTorch에서 평가 지표 고정(전처리 포함)
  2. ONNX Export (정확도 검증)
  3. ONNX 그래프 최적화(선택)
  4. INT8 양자화
    • 빠르게 확인: Dynamic Quantization
    • 정확도/성능 목표: Static Quantization(캘리브레이션)
  5. ONNX Runtime에서 지연시간/정확도 측정
  6. 정확도 하락 시 튜닝(캘리브레이션, per-channel, 제외 노드 지정)

1) PyTorch 모델과 기준 정확도 고정

양자화에서 가장 흔한 실수는 캘리브레이션/추론 전처리가 학습 때와 다른 것입니다. 예를 들어 Resize 방식, 정규화 mean/std, 채널 순서(BGR/RGB) 하나만 달라도 INT8에서 오차가 크게 증폭됩니다.

아래는 예시 CNN(간단한 분류기)과 평가 루프입니다.

import torch
import torch.nn as nn
import torch.nn.functional as F

class SmallCNN(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 32, 3, stride=1, padding=1)
        self.conv2 = nn.Conv2d(32, 64, 3, stride=2, padding=1)
        self.conv3 = nn.Conv2d(64, 128, 3, stride=2, padding=1)
        self.fc = nn.Linear(128 * 8 * 8, num_classes)

    def forward(self, x):
        x = F.relu(self.conv1(x))
        x = F.relu(self.conv2(x))
        x = F.relu(self.conv3(x))
        x = x.flatten(1)
        return self.fc(x)

@torch.inference_mode()
def accuracy(model, dataloader, device="cpu"):
    model.eval().to(device)
    correct = 0
    total = 0
    for x, y in dataloader:
        x = x.to(device)
        y = y.to(device)
        logits = model(x)
        pred = logits.argmax(dim=1)
        correct += (pred == y).sum().item()
        total += y.numel()
    return correct / max(total, 1)

여기서부터는 같은 입력 텐서로 PyTorch vs ONNX vs INT8 ONNX 결과를 비교해야 원인 추적이 쉬워집니다.


2) ONNX Export: opset과 dynamic axes를 신중히

ONNX export는 단순히 파일을 만드는 작업이 아니라, 이후 양자화가 가능한 그래프를 만드는 단계입니다.

  • opset_version은 너무 낮으면 연산이 깨지고, 너무 높으면 특정 런타임/양자화 도구가 덜 지원할 수 있습니다.
  • 배치 크기 가변이 필요하면 dynamic_axes를 설정합니다.
import torch

def export_onnx(model, onnx_path="model_fp32.onnx"):
    model.eval()
    dummy = torch.randn(1, 3, 32, 32)

    torch.onnx.export(
        model,
        dummy,
        onnx_path,
        input_names=["input"],
        output_names=["logits"],
        opset_version=17,
        do_constant_folding=True,
        dynamic_axes={
            "input": {0: "batch"},
            "logits": {0: "batch"},
        },
    )
    return onnx_path

Export 후에는 바로 ONNX Runtime으로 FP32 정확도를 확인해 PyTorch와 동일한지 검증하세요. 이 단계에서부터 어긋나면 양자화는 더 악화됩니다.


3) ONNX Runtime으로 FP32 기준선 확인

import numpy as np
import onnxruntime as ort

def ort_infer(onnx_path, x_np):
    sess = ort.InferenceSession(
        onnx_path,
        providers=["CPUExecutionProvider"],
    )
    out = sess.run(["logits"], {"input": x_np})[0]
    return out

# 예: PyTorch 텐서를 그대로 numpy로
# x_torch: (N,3,32,32) float32
# x_np = x_torch.detach().cpu().numpy().astype(np.float32)

검증 팁:

  • PyTorch 출력과 ONNX 출력의 argmax가 대부분 일치하는지
  • max abs diff가 과도하게 크지 않은지

를 먼저 봅니다.


4) INT8 양자화 전략: Dynamic vs Static

Dynamic Quantization: 가장 빠른 1차 시도

장점:

  • 캘리브레이션 데이터가 없어도 됨
  • 적용이 간단

단점:

  • CNN의 Conv는 기대만큼 이득이 없을 수 있음(주로 MatMul/Gemm에 강점)
  • 정확도/성능이 애매하게 나오는 경우가 많음
from onnxruntime.quantization import quantize_dynamic, QuantType

quantize_dynamic(
    model_input="model_fp32.onnx",
    model_output="model_int8_dynamic.onnx",
    weight_type=QuantType.QInt8,
)

CNN에서 진짜 체감하려면 보통 Static Quantization으로 넘어가야 합니다.

Static Quantization(PTQ): 캘리브레이션이 핵심

Static은 활성값(activation) 범위를 캘리브레이션으로 추정해 양자화합니다. 여기서 전처리/데이터 분포가 어긋나면 정확도가 크게 떨어집니다.

import numpy as np
from onnxruntime.quantization import (
    quantize_static,
    CalibrationDataReader,
    QuantType,
    QuantFormat,
)

class ImageCalibrationReader(CalibrationDataReader):
    def __init__(self, np_batches):
        self.data_iter = iter([{"input": b.astype(np.float32)} for b in np_batches])

    def get_next(self):
        return next(self.data_iter, None)

# np_batches: 캘리브레이션용 (N,3,32,32) float32 배치들의 리스트
reader = ImageCalibrationReader(np_batches)

quantize_static(
    model_input="model_fp32.onnx",
    model_output="model_int8_static.onnx",
    calibration_data_reader=reader,
    quant_format=QuantFormat.QDQ,
    activation_type=QuantType.QUInt8,
    weight_type=QuantType.QInt8,
    per_channel=True,
)

여기서 중요한 선택:

  • QuantFormat.QDQ는 호환성이 좋은 편이고, 다양한 최적화/백엔드로 넘기기에도 유리합니다.
  • per_channel=True는 CNN에서 정확도 보존에 매우 유리한 경우가 많습니다(특히 Conv 가중치).

5) 모델 크기 비교: 실제로 10MB→1MB가 되는지

파일 크기는 단순하지만 가장 확실한 지표입니다.

from pathlib import Path

def size_mb(path):
    return Path(path).stat().st_size / (1024 * 1024)

for p in ["model_fp32.onnx", "model_int8_dynamic.onnx", "model_int8_static.onnx"]:
    print(p, f"{size_mb(p):.2f} MB")

만약 크기가 기대보다 덜 줄었다면 보통 원인은:

  • 초기화 상수(특히 큰 Constant 텐서)가 그래프에 남아 있음
  • 불필요한 노드/출력 유지
  • FP32 텐서가 일부 구간에 남아 혼합 정밀도가 됨

이때는 ONNX 그래프 최적화 도구(예: onnxsim, ORT optimizer)를 추가로 고려합니다.


6) 정확도 하락을 줄이는 실전 튜닝 포인트

(1) 캘리브레이션 데이터는 “적당히 많고, 다양하게”

경험적으로는 다음이 안정적입니다.

  • 최소 수백 장, 가능하면 1천~5천 장
  • 실제 서비스 입력 분포와 유사
  • 클래스 균형이 완벽할 필요는 없지만, 극단적으로 한쪽으로 치우치면 activation range가 왜곡됨

특히 CNN은 초반 레이어에서 입력 분포의 영향을 크게 받으므로, 캘리브레이션이 부실하면 첫 Conv부터 오차가 누적됩니다.

(2) 전처리 일치: mean/std, resize, dtype

  • uint8 이미지를 float32로 바꾸는 위치
  • Normalize 적용 순서
  • HWC vs CHW

가 1픽셀이라도 달라지면 INT8에서 더 크게 흔들립니다.

(3) per-channel을 우선 켜고, 필요 시 끄기

  • 정확도: per_channel=True가 대체로 유리
  • 성능: 특정 환경에서는 per-tensor가 더 빠를 수 있음

정확도가 목표면 per-channel부터 시도하고, 성능이 목표면 벤치마크로 결정하세요.

(4) 특정 노드/연산 제외(Selective Quantization)

모든 레이어를 INT8로 만들 필요는 없습니다. 예를 들어 출력단 Softmax나 특정 Add/Mul 패턴에서 민감도가 높으면 그 구간만 FP32로 두는 게 더 낫습니다.

ONNX Runtime 양자화는 설정으로 제외 노드를 지정할 수 있습니다(버전별 API가 달라질 수 있어, 사용 중인 onnxruntime 버전에 맞춰 문서를 확인하세요). 실무에서는 아래 방식으로 접근하면 빠릅니다.

  1. INT8 모델에서 정확도 급락 확인
  2. 레이어별 민감도 분석(가능하면 activation 통계/비교)
  3. 상위 몇 개 민감 레이어만 제외하고 재양자화

(5) QDQ vs QOperator 포맷 선택

  • QDQ는 연산자 주변에 QuantizeLinear/DequantizeLinear를 두는 방식
  • QOperator는 양자화 연산자를 직접 쓰는 방식

호환성과 디버깅 용이성을 고려하면 QDQ가 무난합니다. 특정 가속기 스택이 QOperator에 더 최적화되어 있으면 그때 바꿔보는 식이 좋습니다.


7) 성능 측정: “파일만 줄고 느려지는” 상황 피하기

INT8은 항상 빨라지지 않습니다.

  • 연산자 커널이 INT8 최적화가 아니면 오히려 변환 비용만 추가
  • 작은 배치에서는 메모리/런타임 오버헤드가 지배

따라서 지연시간을 반드시 측정해야 합니다.

import time
import numpy as np
import onnxruntime as ort

def benchmark(onnx_path, n=200, warmup=50):
    sess = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
    x = np.random.randn(1, 3, 32, 32).astype(np.float32)

    for _ in range(warmup):
        sess.run(None, {"input": x})

    t0 = time.perf_counter()
    for _ in range(n):
        sess.run(None, {"input": x})
    t1 = time.perf_counter()

    return (t1 - t0) * 1000 / n

for p in ["model_fp32.onnx", "model_int8_dynamic.onnx", "model_int8_static.onnx"]:
    print(p, f"{benchmark(p):.3f} ms")

측정 시 주의:

  • 워밍업 필수
  • 동일 스레드 설정(ORT intra_op_num_threads 등)을 맞추고 비교
  • CPU 주파수 스케일링/컨테이너 리소스 제한 영향을 고려

8) 자주 겪는 트러블슈팅 체크리스트

(1) INT8 모델이 로드되지만 결과가 이상하다

  • 입력 dtype이 float32인지 확인
  • 입력 정규화가 학습과 동일한지 확인
  • 캘리브레이션 데이터가 실제 입력과 분포가 비슷한지 확인

(2) 양자화 후 정확도가 크게 떨어진다

  • per_channel=True로 바꿔보기
  • 캘리브레이션 샘플 수 늘리기
  • 민감 레이어 제외(Selective Quantization)
  • 가능하면 QAT(Quantization Aware Training) 고려

(3) 용량은 줄었는데 속도가 안 나온다

  • Conv가 INT8 커널로 실행되는지 확인(프로파일링)
  • 배치 크기/입력 크기가 너무 작아 오버헤드가 지배하는지 확인
  • 특정 환경에서는 FP16이 더 나을 수도 있음

양자화처럼 “환경 의존” 디버깅은 로그/프로파일링이 생명입니다. 운영에서 프로세스가 예상치 않게 재시작하거나 성능이 흔들릴 때 원인 추적 루틴을 갖추는 것도 중요합니다.


9) 실무에서의 추천 레시피(10MB→1MB 목표)

  1. ONNX FP32 정확도가 PyTorch와 같은지 먼저 보장
  2. quantize_static + QDQ + per_channel=True를 기본값으로 시작
  3. 캘리브레이션 데이터는 최소 수백~수천 샘플로 구성(실제 입력 분포 기반)
  4. 정확도 하락이 크면 민감 레이어를 찾아 제외하거나, QAT로 전환
  5. 최종적으로는 파일 크기뿐 아니라 ORT 지연시간을 함께 보고 의사결정

이 과정을 제대로 밟으면 CNN도 충분히 10MB급에서 1MB대로 내려오며, 특히 CPU 서빙에서 비용/지연시간 측면의 이득이 큽니다.


마무리

ONNX+INT8 양자화는 “한 번에 끝나는 마법 버튼”이 아니라, 전처리-캘리브레이션-포맷-레이어 선택을 조합해 목표를 맞추는 튜닝 작업입니다. 핵심은 다음 두 가지입니다.

  • FP32 기준선을 깨끗하게 만든 뒤(INT8 이전에 Export 검증)
  • 캘리브레이션을 실제 입력 분포에 맞춰 설계

이 두 축이 잡히면, 모델 용량을 과감하게 줄이면서도 정확도 하락을 최소화하는 실전 최적화가 가능합니다.