Published on

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

Authors
Binance registration banner

서빙 비용을 줄이거나 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 최적화에서 소개한 전략을 함께 적용하면 시행착오를 크게 줄일 수 있습니다.