- Published on
PyTorch INT8 PTQ - ONNXRuntime QDQ 함정 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
PyTorch 모델을 INT8 PTQ(Post-Training Quantization)로 내리고, ONNX로 export 한 뒤 ONNXRuntime(ORT)에서 QDQ(QuantizeLinear/DequantizeLinear)로 돌리면 “분명히 양자화했는데 정확도가 깨지거나 성능이 더 느려지는” 상황을 자주 만납니다.
특히 QDQ는 그래프에 QuantizeLinear 와 DequantizeLinear 노드를 명시적으로 삽입하는 방식이라, 캘리브레이션(스케일 산정)부터 연산자 지원, fusion, EP(Execution Provider)별 최적화까지 함정이 촘촘합니다.
아래는 실무에서 가장 많이 밟는 QDQ 함정 7가지와, 재현 가능한 진단 포인트 및 해결책입니다.
전제: QDQ PTQ 파이프라인 최소 골격
먼저 “정상 동작” 기준점을 만들어야 함정을 구분할 수 있습니다. 아래는 PyTorch 모델을 ONNX로 내보내고, ORT quantization tool로 QDQ INT8 PTQ를 적용하는 최소 예시입니다.
import onnx
from onnxruntime.quantization import (
quantize_static,
CalibrationDataReader,
QuantType,
QuantFormat,
)
# 1) FP32 ONNX 로드
fp32_path = "model_fp32.onnx"
int8_path = "model_int8_qdq.onnx"
# 2) 캘리브레이션 데이터 리더 구현
class MyDataReader(CalibrationDataReader):
def __init__(self, numpy_batches, input_name):
self.data_iter = iter(numpy_batches)
self.input_name = input_name
def get_next(self):
try:
batch = next(self.data_iter)
return {self.input_name: batch}
except StopIteration:
return None
# 3) QDQ 정적 양자화
reader = MyDataReader(numpy_batches=calib_batches, input_name="input")
quantize_static(
model_input=fp32_path,
model_output=int8_path,
calibration_data_reader=reader,
quant_format=QuantFormat.QDQ,
activation_type=QuantType.QInt8,
weight_type=QuantType.QInt8,
per_channel=True,
)
# 4) 결과 확인
m = onnx.load(int8_path)
onnx.checker.check_model(m)
print("saved:", int8_path)
이제부터는 이 파이프라인을 기준으로, 어디서 무엇이 어긋나는지 7가지로 쪼개서 봅니다.
함정 1) “캘리브레이션 데이터가 추론 분포와 다름”이 아니라 “전처리 불일치”다
PTQ에서 가장 흔한 원인은 캘리브레이션 데이터 품질이 아니라, 캘리브레이션 시점 전처리와 실제 서빙 전처리가 미세하게 다르기 때문입니다.
예를 들어 PyTorch에서는 Normalize(mean, std) 를 적용했는데, 서빙에서는 채널 순서가 NCHW 가 아니라 NHWC 로 들어오거나, uint8 이미지를 float32 로 바꾸는 스케일이 1/255 가 아니라 1/256 이면 스케일이 즉시 틀어집니다. QDQ는 그 틀어진 스케일이 그래프에 고정되므로 정확도 급락이 발생합니다.
진단 체크
- 캘리브레이션 입력 텐서의
dtype, min/max, mean/std를 로깅해서 서빙 입력과 비교 - ONNX 입력이 기대하는 레이아웃과 실제 입력 레이아웃 비교
import numpy as np
def summarize(x, name):
x = x.astype(np.float32)
print(name, "dtype", x.dtype, "shape", x.shape,
"min", float(x.min()), "max", float(x.max()),
"mean", float(x.mean()), "std", float(x.std()))
summarize(calib_batches[0], "calib")
summarize(serving_sample, "serving")
해결
- 캘리브레이션 파이프라인을 서빙 입력 파이프라인과 동일한 코드 경로로 통일
- 입력 레이아웃 변환(
transpose)을 캘리브레이션에도 동일 적용
서빙 파이프라인이 복잡해 “어딘가에서 누수처럼” 상태가 틀어지는 경우, 세션/락처럼 안전 패턴으로 정리하는 습관이 도움이 됩니다. 전처리 단계가 여러 컨텍스트에 걸쳐 있다면 asynccontextmanager로 누수 잡기 - 세션·락 안전패턴 같은 패턴을 참고해 전처리 리소스와 상태를 강제 종료 가능하게 만들면 재현성이 올라갑니다.
함정 2) QDQ는 “양자화가 됐다”가 아니라 “양자화가 유지된다”까지 봐야 한다
QDQ는 그래프에 Q/DQ 노드를 넣지만, ORT 최적화 과정에서 fusion이나 constant folding으로 일부가 바뀌거나, EP에 따라 특정 패턴이 기대와 다르게 해석될 수 있습니다.
특히 다음 상황에서 “INT8로 저장했는데 실행은 FP16/FP32”가 되기도 합니다.
- CPU EP에서 특정 연산이 QDQ 경로를 타지 못해 DQ 이후 FP32로 실행
- TensorRT EP에서 QDQ 패턴이 인식되지 않아 fallback
진단 체크
- ORT 세션 생성 시 profiling을 켜서 실제 커널이 int8로 실행되는지 확인
import onnxruntime as ort
so = ort.SessionOptions()
so.enable_profiling = True
sess = ort.InferenceSession("model_int8_qdq.onnx", so, providers=["CPUExecutionProvider"])
# run once
_ = sess.run(None, {"input": serving_sample})
profile_path = sess.end_profiling()
print("profile:", profile_path)
프로파일 JSON에서 QLinearConv 혹은 int8 커널이 실제로 호출되는지 확인해야 합니다. QDQ는 노드가 보인다고 끝이 아니라, 실행 커널이 int8인지가 결론입니다.
해결
- EP별로 지원하는 QDQ 패턴을 문서 기준으로 확인
- ORT 버전을 고정하고, 모델 export 및 quantization tool 버전도 함께 고정
함정 3) per-channel 가중치 양자화가 “항상” 정확도를 올리지 않는다
Conv/MatMul 계열에서 per_channel=True 는 대체로 정확도를 살리지만, 다음 조건에서는 오히려 불안정해질 수 있습니다.
- 채널 축(axis) 설정이 잘못 추론되거나, 특정 연산자 변형에서 axis가 기대와 다르게 적용
- 레이어가 depthwise 형태거나 그룹 컨볼루션에서 채널 축 의미가 바뀌는 경우
진단 체크
- 양자화된 weight 텐서의 scale shape를 확인해 채널 축이 맞는지 확인
import onnx
from onnx import numpy_helper
m = onnx.load("model_int8_qdq.onnx")
init = {i.name: i for i in m.graph.initializer}
# 예: 어떤 weight scale initializer 이름을 알고 있을 때
scale = numpy_helper.to_array(init["Conv_12_weight_scale"])
print(scale.shape, scale[:5])
해결
- 문제가 의심되면 우선
per_channel=False로 baseline을 만들고 비교 - 특정 노드만 per-channel 제외하도록
nodes_to_exclude를 사용해 점진 적용
함정 4) Activation은 대칭, Weight는 대칭… 같은 “단순 규칙”이 모델마다 깨진다
많이들 “activation은 asymmetric, weight는 symmetric” 같은 규칙을 외우지만, QDQ에서 중요한 건 연산자와 EP가 기대하는 quantization scheme 입니다.
- 어떤 EP는
QInt8activation을 잘 처리하지만QUInt8을 더 잘 최적화하기도 함 - 레이어별 분포가 한쪽으로 치우친 경우 asymmetric이 유리
진단 체크
activation_type을QInt8와QUInt8로 바꿔 정확도와 latency를 모두 측정- 모델 전체가 아니라 “핵심 블록만” A/B 측정
from onnxruntime.quantization import quantize_static, QuantType, QuantFormat
def make(model_out, act_type):
quantize_static(
"model_fp32.onnx",
model_out,
calibration_data_reader=reader,
quant_format=QuantFormat.QDQ,
activation_type=act_type,
weight_type=QuantType.QInt8,
per_channel=True,
)
make("m_qdq_quint8.onnx", QuantType.QUInt8)
make("m_qdq_qint8.onnx", QuantType.QInt8)
해결
- “규칙” 대신 EP별 best practice 조합을 먼저 확인
- accuracy만 보지 말고, latency와 CPU 사용률까지 같이 측정
함정 5) Softmax, LayerNorm, GELU 같은 구간에서 QDQ가 정확도를 찢는다
Transformer 계열에서 PTQ를 적용하면, 대개 문제는 Conv가 아니라 다음 구간에서 터집니다.
LayerNorm계열: 분산/평균 계산이 양자화 오차에 민감Softmax: 지수 함수 근처에서 작은 오차가 확률을 크게 바꿈GELU/SiLU: 비선형이 quant noise를 증폭
QDQ는 이 민감 구간에도 기계적으로 Q/DQ를 삽입할 수 있고, 그 결과 특정 블록에서만 급격히 무너집니다.
진단 체크
- 레이어 단위로 cosine similarity 또는 MSE를 찍어 “깨지는 지점”을 찾기
- ONNX 모델을 중간 출력하도록 instrument 하거나, PyTorch에서 동일 입력으로 레이어별 비교
해결
- 민감 연산자 주변 노드를 양자화 제외 처리
- 가능하면 attention 블록은 부분적으로만 INT8 적용(예: MatMul만)
ORT quantization에서 제외 노드를 지정하는 예시는 아래처럼 접근합니다.
from onnxruntime.quantization import quantize_static
quantize_static(
model_input="model_fp32.onnx",
model_output="model_int8_qdq.onnx",
calibration_data_reader=reader,
quant_format=QuantFormat.QDQ,
activation_type=QuantType.QInt8,
weight_type=QuantType.QInt8,
per_channel=True,
nodes_to_exclude=[
"LayerNorm_42",
"Softmax_77",
],
)
노드 이름은 Netron 같은 도구로 확인하거나, ONNX 그래프에서 순회해 찾습니다.
함정 6) Dynamic shape, batch 축 때문에 캘리브레이션 통계가 엉킨다
ONNX export 시 dynamic axes를 열어두면 배포는 편하지만, PTQ 캘리브레이션에서는 다음 문제가 생깁니다.
- 배치 크기가 바뀌면 activation range가 달라지는 모델에서 scale이 과도하게 커짐
- 시퀀스 길이가 varying인 NLP에서 긴 시퀀스 몇 개가 range를 지배
즉, “대표 데이터”가 아니라 “극단 샘플”이 scale을 결정하면서 대부분 샘플에서 정밀도가 떨어집니다.
진단 체크
- 캘리브레이션 배치/시퀀스 길이 분포를 로그로 남기기
- 길이별로 accuracy를 나눠 측정해 특정 길이에서만 붕괴하는지 확인
해결
- 캘리브레이션 데이터에서 길이/배치 분포를 서빙 트래픽과 맞추기
- 극단값을 일부러 섞되 비율을 제한
- 필요하면 입력을 길이 버킷으로 나눠 모델을 여러 개로 양자화(서빙 라우팅)
함정 7) “INT8인데 메모리도 줄고 빨라질 것”이라는 기대가 성립하지 않는다
QDQ INT8는 종종 다음 이유로 성능이 역전됩니다.
- DQ 이후 FP32 연산이 많아져 오히려 overhead 증가
- 작은 배치에서 int8 커널 런치 비용이 이득을 상쇄
- EP가 QDQ 패턴을 제대로 fusion 못해 Q/DQ 노드가 잔뜩 남음
- 캐시 미스, 메모리 대역폭, 스레딩 설정 때문에 CPU에서 이득이 제한됨
진단 체크
- latency만 보지 말고,
throughput,p95,CPU util,num_threads를 같이 본다 - ORT graph optimization level을 바꿔 비교
import onnxruntime as ort
def make_session(path, threads=8):
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
so.intra_op_num_threads = threads
return ort.InferenceSession(path, so, providers=["CPUExecutionProvider"])
sess_fp32 = make_session("model_fp32.onnx")
sess_int8 = make_session("model_int8_qdq.onnx")
해결
- “QDQ 적용 후 실제 int8 커널 비율”부터 확인(함정 2)
- 배치 크기별로 성능을 재측정하고, 서빙 트래픽 배치 전략을 함께 조정
- 메모리 압박이 문제라면, 양자화 외에도 캐시/TTL/압축 같은 운영 최적화가 더 큰 효과를 내기도 합니다. 예를 들어 벡터 DB나 임베딩 캐시가 같이 있는 시스템이라면 AutoGPT 메모리 누수 막는 벡터DB TTL·압축처럼 “모델 외부” 메모리 최적화가 병목을 푸는 경우가 많습니다.
실전 체크리스트: QDQ INT8 PTQ 실패를 30분 안에 분류하기
아래 순서로 보면 원인 분류가 빠릅니다.
- 전처리 동일성: 캘리브레이션 입력과 서빙 입력의 통계가 같은가
- 실행 커널 확인: 프로파일에서 int8 커널이 실제로 도는가
- 민감 구간 제외:
LayerNorm,Softmax, 비선형 주변을 제외했을 때 회복되는가 - per-channel 토글: per-channel이 오히려 깨는 케이스인지 확인
- activation type 토글:
QInt8vsQUInt8A/B - shape 분포 점검: 길이/배치 극단값이 scale을 지배하는가
- 성능 역전 원인: Q/DQ 노드 과다, EP fallback, 스레딩 설정 문제인가
덤: QDQ 그래프에서 Q/DQ 개수로 “양자화 밀도” 대충 보기
정밀한 지표는 아니지만, Q/DQ가 거의 없다면 애초에 양자화가 제대로 안 들어갔을 확률이 큽니다.
import onnx
m = onnx.load("model_int8_qdq.onnx")
ops = [n.op_type for n in m.graph.node]
print("QuantizeLinear:", sum(o == "QuantizeLinear" for o in ops))
print("DequantizeLinear:", sum(o == "DequantizeLinear" for o in ops))
여기서 개수가 많다고 무조건 좋은 것도 아닙니다. 과도한 Q/DQ는 fusion 실패의 신호일 수 있으니, 결국은 함정 2의 프로파일링으로 결론을 내야 합니다.
마무리
PyTorch 모델 INT8 PTQ를 ONNXRuntime QDQ로 적용할 때의 핵심은 “양자화 성공 여부”가 아니라,
- 캘리브레이션이 서빙과 동일한 입력 분포를 반영하는지
- QDQ가 EP에서 실제 int8 커널로 실행되는지
- 민감 연산자 구간을 전략적으로 제외했는지
를 끝까지 확인하는 것입니다.
이 글의 7가지 함정은 대부분 서로 연결되어 있습니다. 전처리 불일치가 scale을 틀리고, 그 결과 특정 블록에서만 붕괴하며, EP fallback으로 성능이 역전되는 식입니다. 따라서 “정확도”와 “성능”을 함께 보는 실험 설계를 먼저 고정하고, 토글 가능한 변수(activation type, per-channel, exclude nodes, shape 분포)를 하나씩만 바꿔가며 원인을 분리하는 것이 가장 빠른 해결책입니다.
추가로, 양자화 적용 후 서빙 환경에서 프로세스가 재시작 루프를 타거나 리소스 제한에 걸린다면 모델 문제와 별개로 플랫폼 이슈일 수 있습니다. 운영 관점의 진단은 K8s CrashLoopBackOff 원인별 진단·해결 체크리스트도 함께 참고하면 전체 장애 시간을 줄일 수 있습니다.