- Published on
ONNX Runtime QDQ 양자화로 CPU 추론 2배 만들기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론: CPU 추론이 느린 이유와 QDQ의 목표
GPU가 없는 서버, 비용 때문에 CPU만 쓰는 배치/온라인 추론, 혹은 엣지 디바이스에서 가장 흔한 병목은 GEMM/Conv 같은 행렬 연산의 메모리 대역폭과 캐시 효율입니다. FP32로 계산하면 연산량도 크지만, 더 큰 문제는 메모리에서 가져와야 하는 데이터가 너무 크다는 점입니다.
QDQ 양자화는 모델의 가중치와(때로는) 활성값을 INT8로 바꿔 메모리 사용량을 줄이고, CPU의 VNNI/AVX512/AVX2 같은 정수 벡터 명령을 활용해 연산 처리량을 높이는 방식입니다. 현실적으로 “CPU 추론 2배”는 다음 조건을 만족할 때 가장 잘 나옵니다.
- 모델이
Conv/Gemm/MatMul비중이 높다 - 배치가 1이라도 연산이 충분히 크다(특히 NLP/비전)
- CPU가 INT8 가속 경로를 지원한다(예: Intel Xeon 계열의 VNNI)
- QDQ 그래프가 올바르게 생성되어 실제로 INT8 커널을 탄다
이미 INT8 양자화 전반(PTQ 절차, 캘리브레이션 등)을 훑고 싶다면 아래 글을 먼저 보면 맥락 잡기가 쉽습니다.
QDQ 양자화란 무엇인가
ONNX에서 양자화는 크게 두 가지 스타일로 많이 이야기됩니다.
QOperator스타일:QLinearConv,QLinearMatMul같은 양자화 연산자로 직접 치환QDQ스타일: 원래 연산(예:Conv)을 유지하되, 입력/가중치 주변에QuantizeLinear와DequantizeLinear를 삽입
QDQ의 장점은 다음입니다.
- 원래 연산 그래프 구조를 유지해 호환성이 좋고 디버깅이 쉽다
- 실행 엔진(ONNX Runtime)이 패턴을 보고 적절한 INT8 커널로 자동 퓨전 할 수 있다
- 특정 구간만 양자화하거나, 민감한 레이어는 FP32로 남기는 등 혼합 정밀도 전략이 유연하다
다만 “QDQ를 넣었다”와 “실제로 INT8로 빨라졌다”는 다릅니다. QDQ가 많아도 커널이 퓨전되지 않으면 DQ와 Q 변환 비용만 늘어 성능이 나빠질 수도 있습니다. 그래서 이 글은 QDQ 생성부터, 실제 INT8 경로 확인까지를 한 흐름으로 다룹니다.
성능 2배를 위한 사전 체크리스트
1) CPU가 INT8 가속을 지원하는지 확인
운영 환경에서 먼저 확인할 것은 CPU의 ISA입니다. 리눅스라면 다음처럼 확인합니다.
lscpu | egrep -i "avx|vnni|avx512|sse"
avx2만 있어도 일부 모델은 이득이 납니다.avx512_vnni또는vnni가 있으면 INT8 이득이 더 잘 나오는 편입니다.
2) ONNX Runtime 빌드/패키지와 Execution Provider
대부분의 경우 onnxruntime(CPU) 패키지로 충분하지만, 환경에 따라 mkl-ml 기반 최적화나 스레딩 설정이 성능에 큰 영향을 줍니다. 또한, 같은 CPU라도 스레드 수와 affinity가 흔들리면 벤치마크가 요동칩니다.
3) 벤치마크를 “같은 조건”으로 맞추기
- 워밍업 필수(캐시/메모리 할당/커널 JIT 효과)
- 동일 입력, 동일 배치, 동일 스레드 설정
latency(p50/p95)와throughput(QPS) 분리 측정
QDQ 양자화: 정적(Static) PTQ 실전 코드
아래는 ONNX Runtime의 양자화 툴을 이용해 QDQ 정적 양자화를 수행하는 예시입니다. 핵심은 캘리브레이션 데이터로 activation scale/zero-point를 추정해 품질을 지키는 것입니다.
주의: 이 글의 본문에서는 MDX 빌드 에러 방지를 위해 부등호 문자를 일반 텍스트로 노출하지 않습니다. 코드 블록 안에서는 안전합니다.
import os
import numpy as np
import onnx
from onnxruntime.quantization import (
CalibrationDataReader,
QuantFormat,
QuantType,
quantize_static,
)
class NumpyCalibrationDataReader(CalibrationDataReader):
def __init__(self, input_name: str, samples: np.ndarray, batch_size: int = 1):
self.input_name = input_name
self.samples = samples
self.batch_size = batch_size
self._iter = None
def get_next(self):
if self._iter is None:
def gen():
n = len(self.samples)
for i in range(0, n, self.batch_size):
batch = self.samples[i:i+self.batch_size]
yield {self.input_name: batch}
self._iter = gen()
return next(self._iter, None)
# 1) 원본 FP32 ONNX
fp32_model_path = "model_fp32.onnx"
int8_model_path = "model_int8_qdq.onnx"
# 2) 모델에서 입력 이름 확인
m = onnx.load(fp32_model_path)
input_name = m.graph.input[0].name
# 3) 캘리브레이션 샘플 준비(예: NCHW 이미지)
# 실제 서비스 입력 분포를 최대한 반영해야 정확도 하락을 줄일 수 있습니다.
calib_samples = np.random.randn(128, 3, 224, 224).astype(np.float32)
reader = NumpyCalibrationDataReader(input_name, calib_samples, batch_size=1)
# 4) QDQ 정적 양자화 실행
quantize_static(
model_input=fp32_model_path,
model_output=int8_model_path,
calibration_data_reader=reader,
quant_format=QuantFormat.QDQ,
activation_type=QuantType.QInt8,
weight_type=QuantType.QInt8,
per_channel=True, # Conv weight는 per-channel이 정확도에 유리
reduce_range=False,
)
print("saved:", int8_model_path)
옵션 선택 가이드
per_channel=True: 비전 모델Conv에서 정확도 방어에 매우 유리합니다.activation_type=QInt8: 대체로 성능 이득이 좋지만, 모델/CPU에 따라QUInt8가 더 유리한 경우도 있어 A/B가 필요합니다.- 캘리브레이션 샘플 수: 보통 100개에서 1,000개 사이에서 시작합니다. 분포가 복잡한 모델일수록 더 필요합니다.
“진짜로” INT8 커널을 타는지 확인하는 법
QDQ 모델을 만들었는데도 속도가 안 오르면, 대부분 아래 중 하나입니다.
- 특정 연산이 QDQ 패턴으로 퓨전되지 않았다
- 지원되지 않는 연산이 많아 중간중간 FP32로 돌아왔다
- 입력 shape이 동적으로 너무 흔들려 최적화가 깨졌다
- 스레드/메모리 설정이 잘못되어 오히려 느려졌다
ONNX Runtime에서는 세션 옵션과 프로파일링으로 확인할 수 있습니다.
import time
import numpy as np
import onnxruntime as ort
def make_session(model_path: str, intra_threads: int = 8):
so = ort.SessionOptions()
so.intra_op_num_threads = intra_threads
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
so.enable_profiling = True
return ort.InferenceSession(model_path, sess_options=so, providers=["CPUExecutionProvider"])
def bench(sess, input_name, x, warmup=10, iters=100):
for _ in range(warmup):
sess.run(None, {input_name: x})
t0 = time.perf_counter()
for _ in range(iters):
sess.run(None, {input_name: x})
t1 = time.perf_counter()
return (t1 - t0) * 1000 / iters
fp32_sess = make_session("model_fp32.onnx")
int8_sess = make_session("model_int8_qdq.onnx")
input_name = fp32_sess.get_inputs()[0].name
x = np.random.randn(1, 3, 224, 224).astype(np.float32)
fp32_ms = bench(fp32_sess, input_name, x)
int8_ms = bench(int8_sess, input_name, x)
print("fp32 ms:", fp32_ms)
print("int8 ms:", int8_ms)
print("speedup:", fp32_ms / int8_ms)
# 프로파일 파일 경로 출력
print("fp32 profile:", fp32_sess.end_profiling())
print("int8 profile:", int8_sess.end_profiling())
프로파일 JSON을 열어보면 어떤 노드가 시간을 먹는지, 어떤 커널이 실행되는지 힌트를 얻을 수 있습니다. 특히 INT8 모델에서 QuantizeLinear와 DequantizeLinear만 잔뜩 보이고 핵심 연산이 FP32 커널로 남아 있으면, 퓨전이 제대로 안 된 것입니다.
정확도 하락을 통제하는 전략
CPU 성능을 2배까지 끌어올려도, 서비스 품질이 흔들리면 의미가 없습니다. QDQ PTQ에서 정확도를 지키는 대표적인 방법은 아래와 같습니다.
1) 민감 레이어 제외(exclude) 또는 부분 양자화
모델마다 “양자화에 민감한 구간”이 있습니다. 예를 들면:
- 첫 번째
Conv - 마지막 분류기
Gemm - LayerNorm, Softmax 주변(특히 NLP)
이 구간을 FP32로 남기면 성능 이득이 조금 줄어도 정확도는 크게 방어되는 경우가 많습니다.
2) 캘리브레이션 데이터 분포를 서비스와 맞추기
랜덤 노이즈로 캘리브레이션하면 scale이 이상해져 정확도가 크게 깨질 수 있습니다. 최소한 다음을 맞추는 것이 좋습니다.
- 전처리(정규화, 리사이즈, 토크나이즈) 포함 여부
- 입력 길이 분포(특히 NLP)
- 밝기/노이즈/도메인(특히 비전)
3) per-channel weight quantization 유지
대부분의 Conv에서 per-tensor로 가면 정확도가 흔들립니다. 성능에 큰 손해 없이 정확도를 지키는 가장 쉬운 카드가 per_channel=True입니다.
QDQ에서 “2배”가 안 나오는 흔한 원인과 해결
1) 연산이 양자화 친화적이지 않다
Transformer 계열에서도 MatMul이 많아 이득이 나지만, 모델 구조에 따라 Gather, Slice, Reshape 같은 메모리 바운드 연산이 더 지배적이면 체감이 작습니다. 이 경우에는:
- 불필요한 shape 연산을 export 단계에서 줄이기
- 배치/시퀀스 길이를 고정해 최적화 여지를 만들기
2) 동적 shape로 인해 최적화가 깨진다
입력 길이가 매 요청마다 달라지면 커널 선택/메모리 계획이 불리해질 수 있습니다. 가능하면 대표 shape로 고정하거나, 몇 가지 bucket으로 나누는 것이 좋습니다.
3) 스레딩/NUMA 설정 미스
스레드 수를 무작정 늘리면 오히려 느려지는 경우가 많습니다(컨텍스트 스위칭, 캐시 경쟁). 특히 컨테이너 환경에서는 CPU quota와 affinity가 성능을 크게 흔듭니다.
Kubernetes에서 성능이 갑자기 흔들리거나 재시작이 반복되면, 모델 이슈가 아니라 리소스/환경 문제일 수 있습니다. 운영 트러블슈팅 관점에서는 아래 글의 접근법도 도움이 됩니다.
운영 적용 시 권장 체크리스트
1) 모델 파일 관리
model_fp32.onnx와model_int8_qdq.onnx를 함께 버전 관리- 동일 입력에 대한 회귀 테스트(정확도, latency) 자동화
2) 성능 회귀 방지
- CPU 타입이 다른 노드로 스케줄링되면 성능이 달라질 수 있음
- 노드 풀을 분리하거나 node affinity로 CPU 특성을 고정
3) 관측 가능성(Observability)
- p95 latency, 타임아웃, 큐 길이, 스레드 수, RSS 메모리
- 프로파일링은 상시가 아니라 문제 발생 시에만 켜기(오버헤드)
결론: QDQ는 “그래프를 바꾸는 것”이 아니라 “커널을 태우는 것”
ONNX Runtime QDQ 양자화로 CPU 추론을 2배 수준까지 올리는 핵심은 단순히 INT8 모델을 만드는 것이 아니라,
- 캘리브레이션으로 activation scale을 안정적으로 잡고
- QDQ 패턴이 실제로 INT8 커널로 퓨전되도록 만들며
- 스레딩/shape/운영 환경까지 포함해 벤치마크를 공정하게 맞추는 것
입니다.
이미 PTQ를 적용했는데도 속도가 애매하다면, 다음 순서로 점검해 보세요.
- 프로파일에서 핵심 연산이 INT8로 실행되는지 확인
- QDQ가 과도하게 삽입되어 변환 비용만 늘지 않았는지 확인
- 입력 shape과 스레딩을 고정해 재측정
- 민감 레이어만 FP32로 되돌려 정확도와 성능의 균형점 찾기
이 과정을 제대로 밟으면, “CPU만으로도 충분히 빠른” 추론 서버를 훨씬 낮은 비용으로 만들 수 있습니다.