- Published on
PyTorch PTQ INT8 양자화 정확도 급락 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 비용 절감이나 CPU 추론 가속을 위해 PyTorch의 PTQ(Post-Training Quantization)로 INT8 양자화를 적용했는데, 정확도가 갑자기 크게 떨어지는 경우가 흔합니다. 특히 CNN이나 Transformer 계열 모델에서 FP32 대비 INT8 정확도가 “조금”이 아니라 “급락”한다면, 단순히 양자화 특성 탓이 아니라 설정/캘리브레이션/연산자 조합 문제일 가능성이 큽니다.
이 글은 다음 순서로 문제를 해결합니다.
- 증상 분류: 어디서부터 망가졌는지(입력, activation, weight, 특정 레이어)
- 원인 진단 체크리스트: 캘리브레이션 데이터, 관측기(observer), per-tensor/per-channel, backend, fusion
- 실전 처방: 모델 구조별 권장 설정 + 코드 레시피
- “정확도 급락”을 막는 운영 팁: 회귀 테스트, 레이어 단위 디버깅
1) PTQ에서 정확도 급락이 생기는 구조적 이유
PTQ는 학습 없이 통계(최솟값/최댓값 혹은 히스토그램)를 수집해 스케일과 제로포인트를 정합니다. 여기서 통계가 조금만 잘못 잡혀도 다음 문제가 발생합니다.
- Activation outlier: 소수의 큰 값 때문에 스케일이 커지고, 대부분의 값이
INT8에서 촘촘히 표현되지 못해 정보가 사라짐 - 레이어별 분포 차이: 한 레이어는 좁은 분포, 다른 레이어는 넓은 분포인데 전역 혹은 부적절한 observer를 쓰면 특정 구간이 과도하게 손실
- 연산자 비호환/폴백: 일부 연산이
INT8로 가지 못하고FP32또는FP16로 섞이며 스케일 변환이 반복돼 오차가 커짐 - 잘못된 backend:
fbgemm(x86)과qnnpack(ARM) 차이, 지원 op 차이로 결과가 달라짐
정확도 급락을 “양자화는 원래 그래”로 넘기면, 실제로는 1~2개의 설정만 바꿔도 회복되는 케이스를 놓치게 됩니다.
2) 가장 먼저 확인할 것: 현재가 Eager PTQ인지 FX PTQ인지
PyTorch 양자화는 크게 두 갈래가 있습니다.
- Eager Mode Quantization: 모듈에 직접
QuantStub/DeQuantStub넣고prepare/convert - FX Graph Mode Quantization: 그래프를 트레이스해 패턴 기반으로 fusion/prepare/convert
최근에는 FX가 더 자동화가 잘 되어 있고, fusion 실수로 인한 급락을 줄이기 쉽습니다. 이미 Eager로 하고 있다면, 급락 이슈가 반복될 때 FX로 전환하는 것 자체가 해결책이 되기도 합니다.
3) 급락 원인 Top 7 체크리스트
3.1 캘리브레이션 데이터가 “실제 입력 분포”를 대표하는가
PTQ의 캘리브레이션은 통계를 수집하는 단계라서, 데이터가 편향되면 스케일이 망가집니다.
- 전처리(정규화/리사이즈/패딩)가 학습과 동일한지
- 서빙 입력의 조명/해상도/길이 분포를 반영했는지
- 배치 크기 1만으로만 캘리브레이션해서 분포가 왜곡되지 않았는지
권장: 최소 수백~수천 샘플, 실제 추론 파이프라인과 동일한 전처리.
3.2 Observer 선택이 맞는가: MinMax vs Histogram
활성값 분포에 outlier가 있으면 MinMax는 급격히 불리해집니다. 이때 HistogramObserver 또는 MovingAverageMinMaxObserver가 도움이 됩니다.
- outlier 많음: 히스토그램 기반(또는 클리핑 기반) 권장
- 분포 안정적: moving average 계열로 안정화
3.3 Weight는 per-channel인가
Conv/Linear weight는 per-channel(채널별 스케일)이 일반적으로 정확도에 유리합니다. per-tensor로 두면 특정 채널이 손실을 크게 겪을 수 있습니다.
3.4 Activation quantization dtype과 scheme
대부분은 activation을 quint8(unsigned)로 둡니다. scheme은 per_tensor_affine가 기본인데, 모델에 따라 대칭(symmetric) 쪽이 더 안정적일 때도 있습니다.
3.5 Fusion이 되었는가 (특히 Conv+BN+ReLU)
BN이 남아 있거나, ReLU가 분리돼 있으면 양자화 오차가 커질 수 있습니다. fusion이 제대로 되었는지 확인하세요.
3.6 backend가 CPU/디바이스와 맞는가
- x86 서버:
fbgemm - ARM(모바일/라즈베리파이):
qnnpack
backend가 다르면 지원되는 qconfig, 연산자, 성능/정확도 특성이 달라집니다.
3.7 “양자화에 불리한 레이어”를 무리하게 INT8로 보내지 않았는가
대표적으로 다음은 급락의 진원지일 수 있습니다.
- Softmax, LayerNorm, GELU
- attention score 계산 경로
- 작은 채널 수의 depthwise conv(모델에 따라)
이때는 부분 양자화(문제 레이어만 FP32 유지)가 현실적인 해법입니다.
4) 재현 가능한 진단 방법: 레이어 단위로 오차를 측정하라
정확도 급락을 빨리 잡는 방법은 “최종 accuracy”만 보지 말고, 레이어별 출력 차이를 추적하는 것입니다.
아래는 간단한 방식으로, FP32 모델과 양자화 모델의 특정 모듈 출력 L2 차이를 비교하는 예시입니다.
import torch
import torch.nn as nn
@torch.no_grad()
def collect_activations(model: nn.Module, x, module_names):
acts = {}
hooks = []
name_to_module = dict(model.named_modules())
for n in module_names:
m = name_to_module[n]
def _hook(name):
def fn(mod, inp, out):
acts[name] = out.detach().cpu()
return fn
hooks.append(m.register_forward_hook(_hook(n)))
_ = model(x)
for h in hooks:
h.remove()
return acts
@torch.no_grad()
def compare_models(fp32_model, int8_model, x, module_names):
fp32_model.eval()
int8_model.eval()
a = collect_activations(fp32_model, x, module_names)
b = collect_activations(int8_model, x, module_names)
for n in module_names:
diff = (a[n].float() - b[n].float()).pow(2).mean().sqrt().item()
print(n, "RMSE:", diff)
여기서 RMSE가 특정 블록부터 급격히 커지면, 그 블록의 observer/qconfig/fusion/부분 FP32 유지가 해결 포인트입니다.
5) FX PTQ로 “안전한 기본값”부터 만들기
다음 코드는 FX graph mode 기반의 PTQ 뼈대입니다. 모델 종류에 따라 세부 qconfig는 조정하되, 먼저 이 기본 파이프라인으로 “급락이 재현되는 최소 케이스”를 만드세요.
주의: 아래 코드처럼 부등호가 포함될 수 있는 표현은 MDX에서 문제를 일으킬 수 있으니, 본문에서는 항상 인라인 코드로 처리합니다. 예를 들어 제네릭 표기나 a->b 같은 텍스트는 반드시 백틱으로 감싸는 습관이 안전합니다.
import torch
from torch.ao.quantization import get_default_qconfig
from torch.ao.quantization.quantize_fx import prepare_fx, convert_fx
# x86 서버라면 보통 fbgemm
torch.backends.quantized.engine = "fbgemm"
def build_int8_fx(model_fp32, example_inputs, calib_loader, num_calib_batches=20):
model_fp32.eval()
qconfig = get_default_qconfig(torch.backends.quantized.engine)
qconfig_dict = {"": qconfig}
prepared = prepare_fx(model_fp32, qconfig_dict, example_inputs)
# calibration: 통계 수집
with torch.no_grad():
for i, batch in enumerate(calib_loader):
if i >= num_calib_batches:
break
x = batch[0] if isinstance(batch, (tuple, list)) else batch
prepared(x)
converted = convert_fx(prepared)
return converted
이 상태에서 정확도가 급락한다면, 다음 절의 “정확도 회복 레버”를 하나씩 적용해보면 됩니다.
6) 정확도 회복 레버 6가지 (우선순위 순)
6.1 캘리브레이션을 늘리고, 전처리를 완전히 동일하게 맞추기
가장 흔한 실수는 캘리브레이션에 학습용 augmentation을 넣거나, 반대로 서빙 전처리와 다르게 넣는 것입니다.
- 학습 augmentation은 제거
- 서빙과 동일한 normalize/resize/pad 적용
- 샘플 수를 늘려 outlier 통계를 안정화
6.2 Observer를 outlier 친화적으로 바꾸기
MinMax 기반에서 급락한다면, 히스토그램 기반으로 바꿔보세요.
import torch
from torch.ao.quantization import QConfig
from torch.ao.quantization.observer import HistogramObserver, PerChannelMinMaxObserver
act_obs = HistogramObserver.with_args(
dtype=torch.quint8,
qscheme=torch.per_tensor_affine,
reduce_range=False,
)
wt_obs = PerChannelMinMaxObserver.with_args(
dtype=torch.qint8,
qscheme=torch.per_channel_symmetric,
ch_axis=0,
)
custom_qconfig = QConfig(activation=act_obs, weight=wt_obs)
그리고 FX에서는 qconfig_dict에 이를 넣습니다.
qconfig_dict = {"": custom_qconfig}
6.3 Weight per-channel을 강제하기
앞 코드처럼 weight observer를 PerChannelMinMaxObserver로 바꾸면, Conv/Linear에서 손실이 크게 줄어드는 경우가 많습니다.
6.4 문제 레이어만 FP32로 유지(부분 양자화)
Transformer 계열에서 Softmax/LayerNorm 경로가 문제인 경우가 많습니다. FX에서는 모듈 이름 패턴으로 특정 서브모듈의 qconfig를 None으로 만들어 양자화를 끌 수 있습니다.
from torch.ao.quantization import get_default_qconfig
qconfig = get_default_qconfig(torch.backends.quantized.engine)
qconfig_dict = {
"": qconfig,
# 예: layernorm 계열은 양자화 제외
"module_name": [
("encoder.layernorm", None),
("ln", None),
],
}
프로젝트마다 모듈 이름이 다르므로, model.named_modules()로 실제 이름을 확인해 매칭해야 합니다.
6.5 Fusion을 명시적으로 확인하기
FX는 비교적 자동으로 fusion을 해주지만, 모델이 커스텀 블록을 많이 쓰면 패턴 매칭이 실패할 수 있습니다. 이때는 블록을 표준 패턴(예: Conv2d 다음 BatchNorm2d 다음 ReLU)으로 정리하거나, 가능하면 TorchVision 레퍼런스 블록을 사용해 fusion 성공률을 높이세요.
6.6 대표 입력 길이/해상도에서 캘리브레이션하기
NLP에서 시퀀스 길이가 짧은 데이터로만 캘리브레이션하면 긴 입력에서 activation 범위가 달라져 급락할 수 있습니다. 비전도 마찬가지로 특정 해상도에서만 캘리브레이션하면 다른 해상도에서 분포가 변합니다.
- 서빙에서 가장 흔한 길이/해상도 분포를 반영
- “최악 케이스”도 일부 섞어서 스케일이 터지지 않게
7) 정확도 회귀를 막는 실전 운영 팁
7.1 INT8 전용 평가 스위트를 따로 둔다
FP32 평가만 통과하면 배포하는 흐름에서는, PTQ 설정이 조금만 바뀌어도 급락을 놓칩니다.
FP32,INT8를 같은 데이터셋/전처리로 동시에 평가- 주요 지표(Top-1, F1, mAP 등)와 latency를 함께 기록
7.2 “레이어 단위 RMSE”를 스모크 테스트로 추가
앞서 소개한 레이어 출력 비교를 CI 스모크 테스트로 두면, 특정 PR에서 observer나 fusion이 깨졌을 때 빠르게 감지할 수 있습니다.
7.3 성능 지표도 함께 본다
정확도만 회복하고 실제로는 INT8 커널을 못 타면 의미가 없습니다. 일부 연산이 폴백되면 속도도 떨어지고 오차도 커질 수 있습니다.
- 프로파일링으로
quantized::conv2d같은 커널이 실제로 호출되는지 확인 - 폴백이 많다면 부분 양자화 범위를 재조정
8) 자주 묻는 상황별 처방 요약
- CNN에서 급락:
Conv+BN+ReLUfusion 확인, weight per-channel 강제, activation observer를 히스토그램으로 전환 - Transformer에서 급락: LayerNorm/Softmax/GELU를 FP32로 유지하는 부분 양자화부터 시도, 캘리브레이션 길이 분포를 서빙과 일치
- 데이터가 다양한 서비스: 캘리브레이션 샘플을 늘리고 outlier 대응 observer 사용, 대표 트래픽 샘플링 파이프라인 구축
9) 마무리: “급락”은 대부분 설정 문제다
PTQ는 모델을 바꾸지 않고도 큰 이득을 주지만, 그만큼 통계 수집과 qconfig 선택에 민감합니다. 정확도가 급락한다면 다음 3가지만 먼저 하세요.
- 캘리브레이션 데이터/전처리부터 서빙과 동일하게 맞추기
- activation은 히스토그램 계열, weight는 per-channel로 바꾸기
- 의심 레이어는 과감히 FP32로 남기는 부분 양자화 적용
추가로, 모델 성능을 수치로 “측정하고 개선”한다는 관점에서는 검색/랭킹에서 nDCG나 MRR로 튜닝하듯, 양자화도 레이어별 오차와 최종 지표를 함께 보며 반복 개선하는 접근이 가장 빠릅니다. 관련해 실험 설계 관점은 RAG 리랭커 도입 - nDCG·MRR로 성능 2배 글의 방법론이 의외로 도움이 됩니다.
또한 배포 환경에서 성능 문제가 함께 터지는 경우가 많으니, 런타임 지연/콜드스타트 같은 운영 이슈는 GCP Cloud Run 504와 콜드스타트 지연 해결 가이드처럼 “측정 지점부터 고정”하는 방식으로 병행 점검하는 것을 권장합니다.