- Published on
ONNX Runtime QDQ INT8 양자화 실패 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 비용을 줄이거나 CPU 추론을 가속하려고 ONNX Runtime(ORT)에서 INT8 양자화를 적용할 때, 가장 많이 부딪히는 벽이 QDQ(QuantizeLinear/DequantizeLinear) 방식의 static quantization 실패입니다.
QDQ는 모델 그래프에 QuantizeLinear/DequantizeLinear 노드를 삽입해 양자화 경계를 명시적으로 만들고, 실행 시에는 EP(Execution Provider)가 이 패턴을 인식해 INT8 커널로 대체합니다. 그래서 “양자화는 성공했는데 속도가 안 나옴”과 “양자화 단계에서 아예 실패함”이 모두 발생할 수 있습니다.
이 글은 QDQ INT8 양자화가 실패하는 원인을 로그/증상별로 분류하고, 재현 가능한 해결 절차(모델 점검 → 캘리브레이션 데이터 → quantizer 옵션 → EP/버전 → 검증)로 정리합니다. 메모리/성능 최적화 관점은 Transformers 로컬 LLM OOM - 4bit·offload 최적화도 함께 참고하면 전체 그림을 잡는 데 도움이 됩니다.
QDQ INT8 양자화의 전제: “삽입”과 “실행”은 다르다
QDQ 기반 양자화는 크게 두 단계입니다.
- 양자화 변환(offline): 캘리브레이션 데이터로 activation 범위를 추정하고, 그래프에 Q/DQ를 삽입하여 INT8 스케일/제로포인트를 결정
- 실행 시 최적화(runtime): EP가 QDQ 패턴을 인식해 INT8 커널로 fuse하거나, 그렇지 못하면 Q/DQ를 float로 처리(성능 이득 거의 없음)
따라서 실패는 다음 두 축으로 나타납니다.
- 변환 단계 실패: 예외 발생, 그래프 생성 불가, 캘리브레이션 불가
- 변환은 성공했지만 실행 단계 실패: 성능 미개선, 특정 입력에서만 런타임 오류
이 글은 우선 “변환 단계 실패”를 중심으로 다루고, 마지막에 “성능이 안 나오는 경우”도 점검합니다.
가장 흔한 실패 원인 7가지
1) 연산자/도메인 미지원: QDQ로 내릴 수 없는 노드가 있다
증상 예:
NotImplemented혹은No quantized kernel류 메시지- 특정 op에서만 양자화가 멈춤
원인:
- QDQ는 모든 연산자를 INT8로 만들지 않습니다. 특히
Resize,NonMaxSuppression, 일부LayerNormalization변형, 커스텀 op, 특정 activation 패턴 등은 EP/버전에 따라 지원이 다릅니다.
해결:
- “전체를 INT8”이 아니라 화이트리스트 기반으로 시작합니다.
- ORT quantizer의
op_types_to_quantize혹은nodes_to_quantize를 제한하고, 범위를 점진적으로 넓힙니다.
2) 모델이 opset/IR 버전/형식 제약을 위반하거나 너무 최신이다
증상 예:
- 로딩 자체가 실패하거나, quantizer가 그래프를 수정하는 도중 에러
Unsupported model IR version또는 opset 관련 경고
해결:
- ORT와 onnx 패키지 버전을 올리는 것이 1순위입니다.
- 그래도 안 되면 export 시 opset을 낮추거나(예: 17 또는 18), 불필요한 변환을 줄입니다.
3) 캘리브레이션 데이터셋/입력 이름이 모델과 불일치
증상 예:
Invalid input name/Required inputs are missing/ shape mismatch
원인:
- 양자화는 실제로 ORT 세션을 띄워서 캘리브레이션을 돌립니다. 이때 데이터 로더가 모델의 input name/shape/dtype을 정확히 맞춰야 합니다.
해결:
- 먼저 모델의 입력 메타를 출력해 정확한 이름을 확인합니다.
4) Dynamic shape(가변 길이) + 잘못된 샘플 shape
증상 예:
- 배치마다 shape가 달라져서 캘리브레이션 중 실패
Invalid rank/Got unexpected dimension등
해결:
- 캘리브레이션은 대표 shape로 고정하는 것이 안전합니다.
- NLP 계열은 padding을 고정 길이로 맞춰서 샘플을 생성합니다.
5) Per-channel/per-tensor 설정이 커널/EP와 충돌
증상 예:
- per-channel에서만 실패하거나 성능이 악화
해결:
- Conv/MatMul 계열은 per-channel이 유리한 경우가 많지만, EP/모델 구조에 따라 충돌할 수 있습니다.
- 우선 per-tensor로 성공 경로를 만든 뒤 per-channel로 확장합니다.
6) QDQ 삽입은 되었지만 EP가 패턴을 못 알아본다(성능 미개선)
증상 예:
- INT8 모델인데 속도가 거의 동일
- 프로파일링에서 Q/DQ가 그대로 실행됨
해결:
- CPU EP는 보통 QDQ 인식이 잘 되지만, 특정 패턴(Residual, Add, Mul, Transpose 섞임)에서는 fuse가 깨집니다.
- 그래프 최적화 레벨,
GraphOptimizationLevel.ORT_ENABLE_ALL설정, 또는 export 단계에서 불필요한 transpose 제거 등을 확인합니다.
7) 양자화 대상이 사실상 FP16/FP32로 고정된 모델(특히 attention 블록)
증상 예:
- Transformer 계열에서 MatMul 일부만 양자화되고 나머지 구간이 float
해결:
- QDQ INT8은 만능이 아닙니다. 특정 모델은 FP16이 더 현실적일 수 있습니다.
- 서버 자원 한계가 원인이라면 4bit/오프로딩 전략도 병행 검토하세요. 관련해서 Transformers 로컬 LLM OOM - 4bit·offload 최적화를 같이 보면 선택지가 넓어집니다.
실패를 “재현 가능하게” 만드는 최소 점검 코드
먼저 모델의 입력/출력 정보를 확인해 캘리브레이션 입력을 정확히 맞춰야 합니다.
import onnx
import onnxruntime as ort
model_path = "model.onnx"
m = onnx.load(model_path)
print("IR version:", m.ir_version)
print("Opset:", [(op.domain, op.version) for op in m.opset_import])
sess = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
print("Inputs")
for i in sess.get_inputs():
print("-", i.name, i.shape, i.type)
print("Outputs")
for o in sess.get_outputs():
print("-", o.name, o.shape, o.type)
여기서 나온 input name을 캘리브레이션 데이터 로더가 그대로 써야 합니다. 이름이 다르면 100% 실패합니다.
QDQ Static INT8 양자화: 권장 출발 설정(안전 모드)
아래는 ORT의 quantization 도구를 사용할 때, 실패율을 낮추는 “보수적” 설정입니다.
핵심 전략:
- 우선
QOperator가 아니라 QDQ로 진행 - per-channel 끄고(per-tensor) 시작
MatMul,Conv등 핵심 op만 대상으로 제한
import numpy as np
import onnxruntime as ort
from onnxruntime.quantization import (
quantize_static,
CalibrationDataReader,
QuantFormat,
QuantType,
)
class NumpyCalibrationDataReader(CalibrationDataReader):
def __init__(self, input_name, samples):
self.input_name = input_name
self.samples = samples
self._iter = iter(self.samples)
def get_next(self):
try:
x = next(self._iter)
return {self.input_name: x}
except StopIteration:
return None
model_fp32 = "model.onnx"
model_int8 = "model.int8.qdq.onnx"
# 1) 세션에서 정확한 input name을 가져온다
sess = ort.InferenceSession(model_fp32, providers=["CPUExecutionProvider"])
input_name = sess.get_inputs()[0].name
# 2) 캘리브레이션 샘플을 대표 shape로 고정
# 예: 이미지 모델 NCHW = (1, 3, 224, 224)
samples = [np.random.rand(1, 3, 224, 224).astype(np.float32) for _ in range(50)]
reader = NumpyCalibrationDataReader(input_name, samples)
quantize_static(
model_input=model_fp32,
model_output=model_int8,
calibration_data_reader=reader,
quant_format=QuantFormat.QDQ,
activation_type=QuantType.QInt8,
weight_type=QuantType.QInt8,
per_channel=False,
op_types_to_quantize=["Conv", "MatMul"],
)
print("Saved:", model_int8)
이 설정으로도 실패한다면, 문제는 대개 (1) 모델 입력 불일치 (2) 특정 노드가 quantizer를 깨뜨림 (3) 버전/도메인 문제로 압축됩니다.
에러 로그별 트러블슈팅 체크리스트
A. Invalid input name / missing required inputs
- 캘리브레이션 reader가 반환하는 dict key가 모델 input name과 동일한지 확인
- 멀티 입력 모델이면 모든 required input을 제공해야 함
멀티 입력 예시(텍스트 모델):
class MultiInputReader(CalibrationDataReader):
def __init__(self, samples):
self.samples = samples
self._iter = iter(samples)
def get_next(self):
try:
s = next(self._iter)
return {
"input_ids": s["input_ids"],
"attention_mask": s["attention_mask"],
}
except StopIteration:
return None
B. shape mismatch / rank mismatch
- 캘리브레이션 샘플 shape를 모델 입력 shape에 맞춘다
- dynamic axis가 있더라도 캘리브레이션은 대표 shape로 고정하는 편이 안전
C. 특정 op에서만 실패한다(예: Resize 근처)
op_types_to_quantize를 최소로 줄여 성공시키고 범위를 넓혀가며 범인 op를 찾는다- 범인 op가 양자화 불가라면 해당 op 주변을 float로 남기는 것이 정답일 수 있음
노드 단위로 좁히고 싶다면 nodes_to_quantize를 사용합니다.
quantize_static(
model_input=model_fp32,
model_output=model_int8,
calibration_data_reader=reader,
quant_format=QuantFormat.QDQ,
activation_type=QuantType.QInt8,
weight_type=QuantType.QInt8,
nodes_to_quantize=["Conv_12", "MatMul_33"],
)
D. 양자화는 되는데 실행이 느리거나 동일하다
이 경우는 “실패”라기보다 EP가 QDQ를 INT8 커널로 못 바꾸는 상태일 확률이 큽니다.
체크:
- ORT 세션 옵션에서 그래프 최적화 활성화
- 프로파일링으로 Q/DQ가 fuse되는지 확인
import onnxruntime as ort
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
so.enable_profiling = True
sess = ort.InferenceSession("model.int8.qdq.onnx", sess_options=so, providers=["CPUExecutionProvider"])
# 추론 1회 수행 후
profile_path = sess.end_profiling()
print("Profile saved:", profile_path)
프로파일에서 QuantizeLinear/DequantizeLinear가 빈번히 보이면, fuse가 충분히 일어나지 않은 것입니다. 이때는 다음을 시도합니다.
Conv,MatMul만 남기고 주변Add,Mul,Transpose패턴을 단순화(가능하면 export 단계에서)- ORT 버전 업(특히 QDQ fuse는 버전별 개선이 큼)
버전/환경 이슈: “같은 코드인데 서버에서만 실패”
로컬에서는 성공하는데 CI나 서버에서만 실패하는 경우가 있습니다. 원인은 대부분 다음 중 하나입니다.
- onnxruntime 패키지 버전 불일치(로컬과 서버)
- CPU feature 차이(AVX2/AVX512 지원 여부)로 커널 선택이 달라짐
- 컨테이너 베이스 이미지의 glibc/libstdc++ 차이
이런 류의 환경 차이 문제는 배포/런타임 장애 분석 방법론과 유사합니다. 추론 서버가 간헐적으로 실패하거나 지연이 튀는 상황이라면 GCP Cloud Run 503·콜드스타트 10분 지연 진단처럼 “환경/리소스/콜드스타트” 관점의 체크리스트도 같이 적용해보면 원인 좁히기가 쉬워집니다.
권장:
pip freeze를 고정하고, ORT/onnx 버전을 명시적으로 핀ning- 컨테이너에서
python -c "import onnxruntime as ort; print(ort.__version__)"를 헬스체크에 포함
실전 해결 절차(권장 순서)
- FP32 모델이 ORT에서 정상 추론되는지 먼저 확인
- 입력 메타를 출력하고 캘리브레이션 입력 이름/shape/dtype을 100% 일치시킴
op_types_to_quantize=["Conv","MatMul"],per_channel=False로 최소 성공 경로 확보- 실패하면 범인 op를 찾기 위해 대상 op/노드를 더 줄여가며 이분 탐색
- 성공 후 per-channel, 더 많은 op로 확장
- 마지막으로 프로파일링으로 QDQ fuse 여부를 확인해 성능이 실제로 나오는지 검증
마무리: QDQ INT8은 “지원 범위” 싸움이다
QDQ INT8 양자화 실패는 대부분 “내 모델 그래프 패턴”과 “현재 ORT/EP가 기대하는 패턴”이 어긋나서 발생합니다. 그래서 정답은 한 방 설정이 아니라,
- 입력/캘리브레이션을 정확히 맞추고
- 최소 범위로 성공시킨 뒤
- 지원되는 op부터 점진적으로 넓히는
이 순서로 접근하는 것입니다.
양자화가 목적이 아니라 “서빙 비용/지연”이 목적이라면, INT8이 막힐 때 FP16, 4bit, offload 같은 대안을 빠르게 비교하는 것도 중요합니다. 특히 메모리 한계 이슈까지 겹친다면 Transformers 로컬 LLM OOM - 4bit·offload 최적화에서 소개한 전략을 함께 적용하면 시행착오를 크게 줄일 수 있습니다.