Published on

파이썬 ONNX Runtime로 INT8 양자화 실전 가이드

Authors

서빙 비용을 낮추거나 CPU 추론 지연을 줄이려면 결국 INT8 양자화가 가장 강력한 카드 중 하나입니다. 특히 GPU가 없거나, GPU가 있어도 트래픽 변동이 커서 CPU 폴백 경로가 중요한 환경에서는 FP32 그대로의 모델은 비용과 지연 모두에서 불리해집니다.

이 글은 파이썬에서 ONNX Runtime(이하 ORT)의 양자화 툴체인을 사용해 실제로 INT8 모델을 만들고, 정확도와 성능을 검증하며, 실전에서 자주 부딪히는 함정을 피하는 흐름으로 구성했습니다.

추론 서비스를 쿠버네티스에 올려 자동 확장까지 염두에 둔다면, 양자화는 단순한 모델 최적화가 아니라 인프라 비용을 바꾸는 레버가 됩니다. GPU 서빙 자동 확장 관점은 KServe+Knative로 GPU 추론 자동스케일 구축 글도 함께 참고하면 전체 그림이 이어집니다.

INT8 양자화, 무엇이 달라지나

양자화는 FP32(또는 FP16) 가중치/활성값을 더 낮은 비트 정밀도로 표현해 연산량과 메모리 대역폭을 줄입니다. INT8은 대표적으로 다음 효과가 있습니다.

  • 모델 크기 감소: 가중치가 FP32에서 INT8로 바뀌면 단순 계산으로 4분의 1 수준
  • CPU 추론 속도 개선: 특히 AVX2/AVX512/VNNI 등 INT8 최적화가 잘 된 환경에서 효과 큼
  • 캐시 효율 증가: 메모리 대역폭 병목이 줄어 전체 지연이 내려갈 가능성

대신 다음 리스크가 있습니다.

  • 정확도 하락(특히 활성값까지 INT8로 가는 정적 양자화에서)
  • 일부 연산자/그래프 패턴에서 양자화가 제대로 적용되지 않거나 성능이 오히려 악화
  • 캘리브레이션 데이터 품질이 낮으면 손실이 커짐

ONNX Runtime 양자화 방식 선택: 동적 vs 정적

ORT의 대표적인 INT8 양자화는 크게 두 가지입니다.

동적 양자화(Dynamic Quantization)

  • 가중치INT8로 바꾸고, 활성값은 런타임에 통계(스케일/제로포인트)를 계산해 처리
  • 캘리브레이션 데이터가 필요 없거나 최소화 가능
  • 주로 MatMul, Gemm(FC 계열)에 잘 먹힘
  • 정확도 손실이 상대적으로 적은 편

정적 양자화(Static Quantization)

  • 가중치와 활성값 모두 INT8로 고정
  • 대표적으로 QDQ(Quantize-Dequantize) 형태로 그래프에 삽입
  • 캘리브레이션 데이터로 활성값 범위를 추정해야 하므로 준비가 중요
  • CNN/Transformer 등 더 넓은 연산에 적용 가능하지만 정확도 민감

실전 팁:

  • 텍스트 모델(Transformer)에서 CPU 추론을 빠르게 만들 목적이면 먼저 동적 양자화로 안전하게 시작
  • 비전 모델이나 컨볼루션 비중이 크면 정적 양자화를 검토(다만 캘리브레이션이 관건)

준비물: 패키지 설치와 버전 체크

아래는 일반적인 설치 예시입니다.

pip install onnx onnxruntime onnxruntime-tools numpy

CUDA가 아니라 CPU 기준 최적화가 목적이면 onnxruntime(CPU 빌드)로 충분합니다. 서버 환경에서 성능을 더 끌어올리려면, 실행 머신이 AVX512/VNNI를 지원하는지 확인하는 것도 중요합니다.

워크플로 개요

이 글에서 다룰 실전 워크플로는 다음 순서입니다.

  1. FP32 ONNX 모델 준비
  2. FP32 기준 정확도/지연 측정(베이스라인)
  3. 동적 또는 정적 INT8 양자화 수행
  4. INT8 모델 정확도/지연 재측정
  5. 성능이 안 나올 때 체크리스트로 원인 좁히기

베이스라인: FP32 추론 성능 측정 코드

양자화 전후 비교는 반드시 같은 입력과 같은 측정 방식으로 해야 합니다. 아래는 ORT에서 단일 모델의 지연을 측정하는 최소 코드입니다.

import time
import numpy as np
import onnxruntime as ort


def make_session(model_path: str, threads: int = 1):
    so = ort.SessionOptions()
    so.intra_op_num_threads = threads
    so.inter_op_num_threads = 1
    so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL

    providers = ["CPUExecutionProvider"]
    return ort.InferenceSession(model_path, sess_options=so, providers=providers)


def benchmark(session: ort.InferenceSession, feed: dict, warmup: int = 10, iters: int = 100):
    for _ in range(warmup):
        session.run(None, feed)

    t0 = time.perf_counter()
    for _ in range(iters):
        session.run(None, feed)
    t1 = time.perf_counter()

    return (t1 - t0) * 1000.0 / iters


# 예시: 입력 하나짜리 모델이라고 가정
fp32_model = "model_fp32.onnx"

sess = make_session(fp32_model, threads=4)
input_name = sess.get_inputs()[0].name
input_shape = sess.get_inputs()[0].shape

# 동적 배치/시퀀스 길이면 None이 있을 수 있으니 적절히 채워야 함
shape = [d if isinstance(d, int) else 1 for d in input_shape]
feed = {input_name: np.random.randn(*shape).astype(np.float32)}

lat_ms = benchmark(sess, feed)
print(f"FP32 latency: {lat_ms:.3f} ms")

측정 팁:

  • 첫 실행은 커널/메모리 초기화로 느릴 수 있으니 워밍업 필수
  • 스레드 수는 실제 서비스 환경과 맞추기
  • 컨테이너 환경이라면 CPU quota와 NUMA 영향도 고려

동적 INT8 양자화: 가장 빠른 성공 경로

동적 양자화는 onnxruntime.quantization 모듈로 간단히 적용할 수 있습니다.

from onnxruntime.quantization import quantize_dynamic, QuantType

fp32_model = "model_fp32.onnx"
int8_dynamic_model = "model_int8_dynamic.onnx"

quantize_dynamic(
    model_input=fp32_model,
    model_output=int8_dynamic_model,
    weight_type=QuantType.QInt8,  # 보통 QInt8 또는 QUInt8 중 선택
)

print("saved:", int8_dynamic_model)

실전에서 자주 쓰는 옵션/판단 기준:

  • QuantType.QInt8 vs QuantType.QUInt8
    • CPU 커널/연산자에 따라 유리한 타입이 달라질 수 있어 A/B 측정 권장
  • 동적 양자화는 모든 레이어가 INT8로 바뀌는 게 아니라, 주로 MatMul 계열 중심으로 바뀝니다

정적 INT8 양자화: 캘리브레이션이 성패를 가른다

정적 양자화는 캘리브레이션 데이터 리더가 필요합니다. 핵심은 “실제 서빙 입력 분포를 대표하는 샘플”을 넣는 것입니다.

캘리브레이션 데이터 리더 구현

아래는 입력이 1개인 모델을 가정한 단순 예시입니다.

import numpy as np
from onnxruntime.quantization import CalibrationDataReader


class NumpyCalibrationDataReader(CalibrationDataReader):
    def __init__(self, input_name: str, samples: list[np.ndarray]):
        self.input_name = input_name
        self.samples = samples
        self._iter = iter(self.samples)

    def get_next(self):
        try:
            x = next(self._iter)
        except StopIteration:
            return None
        return {self.input_name: x}

    def rewind(self):
        self._iter = iter(self.samples)

정적 양자화 실행

from onnxruntime.quantization import (
    quantize_static,
    QuantFormat,
    QuantType,
    CalibrationMethod,
)
import onnxruntime as ort

fp32_model = "model_fp32.onnx"
int8_static_model = "model_int8_static.onnx"

# 세션으로 입력 이름을 얻어온 뒤, 캘리브레이션 샘플을 준비
sess = ort.InferenceSession(fp32_model, providers=["CPUExecutionProvider"])
input_name = sess.get_inputs()[0].name
input_shape = sess.get_inputs()[0].shape
shape = [d if isinstance(d, int) else 1 for d in input_shape]

# 실제로는 서비스 트래픽에서 샘플링한 전처리 결과를 쓰는 것이 이상적
samples = [np.random.randn(*shape).astype(np.float32) for _ in range(200)]
calib_reader = NumpyCalibrationDataReader(input_name, samples)

quantize_static(
    model_input=fp32_model,
    model_output=int8_static_model,
    calibration_data_reader=calib_reader,
    quant_format=QuantFormat.QDQ,           # 보통 QDQ 권장
    activation_type=QuantType.QInt8,
    weight_type=QuantType.QInt8,
    calibrate_method=CalibrationMethod.MinMax,  # Entropy 등도 비교 가능
)

print("saved:", int8_static_model)

캘리브레이션 실전 팁:

  • 샘플 수는 보통 100~1000에서 시작해 정확도 변화를 보며 조정
  • 전처리(정규화/리사이즈/토크나이즈)까지 포함된 “모델 입력 텐서” 기준으로 샘플을 만들 것
  • 입력 분포가 긴 꼬리를 가지면 MinMax가 과도하게 범위를 넓혀 정밀도가 떨어질 수 있습니다. 이때는 다른 캘리브레이션 방식을 비교합니다.

정확도 검증: 양자화 전후를 같은 지표로 비교

분류 모델이라면 top-1 정확도, 회귀라면 MSE/MAE 등 태스크에 맞는 지표를 사용합니다. 아래는 “모델 출력이 로짓”이라는 가정 하에, 간단히 예측 결과가 얼마나 달라졌는지 확인하는 예시입니다.

import numpy as np
import onnxruntime as ort


def run_outputs(model_path: str, feed: dict):
    sess = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
    return sess.run(None, feed)


def cosine_sim(a: np.ndarray, b: np.ndarray, eps: float = 1e-9) -> float:
    a = a.reshape(-1).astype(np.float32)
    b = b.reshape(-1).astype(np.float32)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + eps))


fp32 = "model_fp32.onnx"
int8 = "model_int8_dynamic.onnx"

sess = ort.InferenceSession(fp32, providers=["CPUExecutionProvider"])
input_name = sess.get_inputs()[0].name
shape = [d if isinstance(d, int) else 1 for d in sess.get_inputs()[0].shape]

x = np.random.randn(*shape).astype(np.float32)
feed = {input_name: x}

out_fp32 = run_outputs(fp32, feed)[0]
out_int8 = run_outputs(int8, feed)[0]

print("cosine:", cosine_sim(out_fp32, out_int8))
print("max_abs_diff:", float(np.max(np.abs(out_fp32 - out_int8))))

실전에서는 랜덤 입력이 아니라 검증 데이터셋으로 비교해야 합니다. 다만 위 코드는 “양자화가 뭔가 잘못되어 출력이 완전히 깨지는지”를 빠르게 감지하는 스모크 테스트로 유용합니다.

성능 검증: INT8인데 왜 빨라지지 않나

양자화 후에도 지연이 비슷하거나 느려지는 경우가 꽤 흔합니다. 원인을 빠르게 좁히려면 아래를 확인하세요.

1) 실제로 INT8 커널이 타는지 확인

  • 모델 그래프에 QuantizeLinear/DequantizeLinear가 과도하게 들어가면 오히려 오버헤드가 커질 수 있습니다.
  • 일부 연산은 양자화가 적용되지 않아 FP32로 남고, 중간에 형 변환이 반복될 수 있습니다.

모델을 시각화하려면 Netron 같은 도구로 INT8 모델을 열어 QDQ 패턴이 어디에 생겼는지 확인합니다.

2) 스레드/바인딩/배치 크기 영향

  • CPU 스레드를 늘리면 항상 빨라지지 않습니다. 특히 작은 배치에서는 스레드 오버헤드가 큽니다.
  • 배치가 너무 작으면 메모리/런타임 오버헤드가 지배적이라 INT8 이득이 제한될 수 있습니다.

3) 전처리가 병목이면 모델만 최적화해도 체감이 없다

실서비스에서는 전처리(이미지 디코딩, 토크나이즈, pandas 기반 피처 엔지니어링 등)가 지연의 상당 부분을 차지합니다. 예를 들어 pandas 파이프라인이 병목이면 모델 추론만 줄여도 전체 지연이 잘 안 내려갑니다. pandas 쪽 함정/개선은 pandas SettingWithCopyWarning 완전 해결 5패턴도 함께 보면 운영 코드 품질을 올리는 데 도움이 됩니다.

4) 컨테이너/클러스터에서의 리소스 제한

쿠버네티스에서 CPU limit이 타이트하면 스레드를 늘려도 실효가 없고, 오히려 컨텍스트 스위칭으로 느려질 수 있습니다. 또한 gRPC로 텐서를 크게 주고받는 구조라면 메시지 크기 제한이나 프록시 설정이 병목이 될 수 있습니다. 대용량 payload를 다루는 경우 EKS에서 413 없이 502? gRPC 최대 메시지 해결 같은 운영 이슈도 같이 점검해야 합니다.

실전 체크리스트: 정확도 손실을 줄이는 방법

  • 먼저 동적 양자화를 적용해 “이득이 있는지”를 확인한 뒤 정적 양자화로 확장
  • 정적 양자화에서 캘리브레이션 샘플은 반드시 실제 입력 분포를 대표해야 함
  • MinMax로 정확도가 크게 떨어지면 다른 캘리브레이션 방법을 비교
  • 레이어별 민감도가 큰 모델은 일부 노드만 양자화하거나(선택적 양자화) FP16 같은 대안도 고려

운영 관점: INT8 모델 배포 시 주의점

  • 모델 파일명에 fp32/int8를 명확히 포함하고, 롤백이 쉬운 배포 전략을 잡습니다.
  • 같은 입력에 대해 FP32INT8를 동시에 실행해 지표를 비교하는 “섀도우 테스트”를 일정 기간 운영하면 안전합니다.
  • CI에서 모델 아티팩트를 캐시/재사용하면 빌드 시간이 줄어듭니다. 파이프라인 캐시가 기대대로 동작하지 않을 때는 GitHub Actions 캐시가 안 먹을 때 - key·restore-keys·권한 체크리스트가 도움이 됩니다.

마무리

파이썬과 ONNX Runtime만으로도 INT8 양자화를 충분히 실전 수준으로 적용할 수 있습니다. 핵심은 다음입니다.

  • 빠른 성과는 동적 양자화로 얻고
  • 더 큰 최적화는 정적 양자화로 확장하되
  • 캘리브레이션 데이터와 성능 검증(스레드/배치/전처리 병목)을 함께 관리해야 합니다.

다음 단계로는 (1) 실제 데이터셋 기반의 정확도 리포트 자동화, (2) 노드/연산자 단위로 양자화 적용 범위를 조절하는 전략, (3) 서빙 계층에서의 배치/스레드 튜닝까지 묶어 “엔드투엔드 지연”을 최적화하는 것을 권합니다.