Published on

ONNX Runtime QDQ INT8 양자화 실패 해결 가이드

Authors

서빙 비용을 줄이거나 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 기반 양자화는 크게 두 단계입니다.

  1. 양자화 변환(offline): 캘리브레이션 데이터로 activation 범위를 추정하고, 그래프에 Q/DQ를 삽입하여 INT8 스케일/제로포인트를 결정
  2. 실행 시 최적화(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__)"를 헬스체크에 포함

실전 해결 절차(권장 순서)

  1. FP32 모델이 ORT에서 정상 추론되는지 먼저 확인
  2. 입력 메타를 출력하고 캘리브레이션 입력 이름/shape/dtype을 100% 일치시킴
  3. op_types_to_quantize=["Conv","MatMul"], per_channel=False최소 성공 경로 확보
  4. 실패하면 범인 op를 찾기 위해 대상 op/노드를 더 줄여가며 이분 탐색
  5. 성공 후 per-channel, 더 많은 op로 확장
  6. 마지막으로 프로파일링으로 QDQ fuse 여부를 확인해 성능이 실제로 나오는지 검증

마무리: QDQ INT8은 “지원 범위” 싸움이다

QDQ INT8 양자화 실패는 대부분 “내 모델 그래프 패턴”과 “현재 ORT/EP가 기대하는 패턴”이 어긋나서 발생합니다. 그래서 정답은 한 방 설정이 아니라,

  • 입력/캘리브레이션을 정확히 맞추고
  • 최소 범위로 성공시킨 뒤
  • 지원되는 op부터 점진적으로 넓히는

이 순서로 접근하는 것입니다.

양자화가 목적이 아니라 “서빙 비용/지연”이 목적이라면, INT8이 막힐 때 FP16, 4bit, offload 같은 대안을 빠르게 비교하는 것도 중요합니다. 특히 메모리 한계 이슈까지 겹친다면 Transformers 로컬 LLM OOM - 4bit·offload 최적화에서 소개한 전략을 함께 적용하면 시행착오를 크게 줄일 수 있습니다.