- Published on
파이썬 CNN 10MB→1MB - ONNX+INT8 양자화 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경(모바일, 엣지, 서버리스)에서는 모델 정확도만큼이나 용량, 로딩 시간, 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 bytesint8모델 크기 ≈ 파라미터 수* 1 byte+ 스케일/제로포인트 메타데이터
즉 이론상 4분의 1로 줄어듭니다. 여기에
- 불필요한 초기화 상수 제거
- 그래프 최적화(상수 폴딩, 노드 퓨전)
- 채널 축 정렬(특정 백엔드에서 효율 향상)
이 더해지면 체감상 10MB급이 1~3MB대로 내려오는 경우가 흔합니다.
다만 정확도 하락과 CPU에서의 실제 속도 개선 여부는 별개입니다. 특히 x86 서버는 INT8 최적화가 잘 먹지만, ARM/모바일은 커널/연산자 지원 상태에 따라 결과가 달라집니다.
전체 파이프라인
실전에서 가장 안전한 흐름은 아래입니다.
- PyTorch에서 평가 지표 고정(전처리 포함)
- ONNX Export (정확도 검증)
- ONNX 그래프 최적화(선택)
- INT8 양자화
- 빠르게 확인: Dynamic Quantization
- 정확도/성능 목표: Static Quantization(캘리브레이션)
- ONNX Runtime에서 지연시간/정확도 측정
- 정확도 하락 시 튜닝(캘리브레이션, 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적용 순서HWCvsCHW
가 1픽셀이라도 달라지면 INT8에서 더 크게 흔들립니다.
(3) per-channel을 우선 켜고, 필요 시 끄기
- 정확도:
per_channel=True가 대체로 유리 - 성능: 특정 환경에서는 per-tensor가 더 빠를 수 있음
정확도가 목표면 per-channel부터 시도하고, 성능이 목표면 벤치마크로 결정하세요.
(4) 특정 노드/연산 제외(Selective Quantization)
모든 레이어를 INT8로 만들 필요는 없습니다. 예를 들어 출력단 Softmax나 특정 Add/Mul 패턴에서 민감도가 높으면 그 구간만 FP32로 두는 게 더 낫습니다.
ONNX Runtime 양자화는 설정으로 제외 노드를 지정할 수 있습니다(버전별 API가 달라질 수 있어, 사용 중인 onnxruntime 버전에 맞춰 문서를 확인하세요). 실무에서는 아래 방식으로 접근하면 빠릅니다.
- INT8 모델에서 정확도 급락 확인
- 레이어별 민감도 분석(가능하면 activation 통계/비교)
- 상위 몇 개 민감 레이어만 제외하고 재양자화
(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 목표)
- ONNX FP32 정확도가 PyTorch와 같은지 먼저 보장
quantize_static+QDQ+per_channel=True를 기본값으로 시작- 캘리브레이션 데이터는 최소 수백~수천 샘플로 구성(실제 입력 분포 기반)
- 정확도 하락이 크면 민감 레이어를 찾아 제외하거나, QAT로 전환
- 최종적으로는 파일 크기뿐 아니라 ORT 지연시간을 함께 보고 의사결정
이 과정을 제대로 밟으면 CNN도 충분히 10MB급에서 1MB대로 내려오며, 특히 CPU 서빙에서 비용/지연시간 측면의 이득이 큽니다.
마무리
ONNX+INT8 양자화는 “한 번에 끝나는 마법 버튼”이 아니라, 전처리-캘리브레이션-포맷-레이어 선택을 조합해 목표를 맞추는 튜닝 작업입니다. 핵심은 다음 두 가지입니다.
- FP32 기준선을 깨끗하게 만든 뒤(INT8 이전에 Export 검증)
- 캘리브레이션을 실제 입력 분포에 맞춰 설계
이 두 축이 잡히면, 모델 용량을 과감하게 줄이면서도 정확도 하락을 최소화하는 실전 최적화가 가능합니다.