Published on

PyTorch int8 양자화 후 정확도 급락 7가지 해결

Authors

서빙 비용을 줄이려고 PyTorch 모델을 int8로 양자화(quantization)했는데, 속도는 빨라졌지만 정확도가 눈에 띄게 떨어지는 경우가 많습니다. 특히 비전 모델은 그나마 버티는 편이지만, 트랜스포머 계열이나 분포가 예민한 모델은 int8에서 급락이 자주 발생합니다.

이 글은 “왜 떨어지는지”를 원인-증상-해결책으로 연결해, 재현 가능한 체크리스트 형태로 정리합니다. 목표는 단순히 quantize_dynamic 한 줄로 끝내는 게 아니라, 어떤 레이어를 어떻게 다뤄야 정확도를 회복하는지까지 포함하는 것입니다.

참고로 운영 환경에서 디버깅은 재시도/백오프처럼 “관측 가능성 + 점진적 완화”가 중요합니다. 비슷한 접근법은 OpenAI API 429 Rate Limit 재시도·백오프 설계에서도 맥락이 통합니다.

0. 먼저 확인: 지금 하는 양자화가 무엇인지

PyTorch에서 흔히 만나는 int8 양자화는 크게 3가지입니다.

  • 동적 양자화(Dynamic Quantization): 가중치만 int8, 활성값은 런타임에 스케일 계산. 주로 Linear, LSTM에 효과적.
  • 정적 양자화(Static/PTQ): 가중치와 활성값 모두 int8. 캘리브레이션 데이터로 스케일/제로포인트를 고정.
  • QAT(Quantization Aware Training): 학습 중 가짜 양자화(fake quant)로 오차를 학습이 흡수.

정확도 급락은 보통 정적 PTQ에서 캘리브레이션이 부실하거나, 트랜스포머에서 민감 레이어를 무리하게 int8로 밀어 넣었거나, 백엔드/옵저버 설정이 모델 특성과 안 맞을 때 발생합니다.

아래 7가지는 실무에서 가장 자주 맞닥뜨리는 원인과 해결입니다.


1) 캘리브레이션 데이터가 실제 분포를 못 담는다

증상

  • PTQ 후 정확도가 크게 떨어지고, 특히 특정 클래스/길이/밝기 조건에서 붕괴
  • 배치 크기나 입력 전처리 조금만 바꿔도 결과가 흔들림

원인

정적 양자화는 캘리브레이션 데이터로 activation 범위(min/max 또는 히스토그램)를 잡습니다. 이 데이터가 실제 추론 분포를 대표하지 못하면 스케일이 틀어져 포화(saturation) 또는 양자화 노이즈가 커집니다.

해결

  • 캘리브레이션 샘플 수를 늘리고, 실제 트래픽 분포를 반영
  • 전처리(정규화, 리사이즈, 패딩)까지 서빙과 동일하게
  • 가능하면 히스토그램 기반 옵저버로 전환

코드: PTQ 캘리브레이션 루프 예시

import torch
import torch.ao.quantization as tq

model_fp32.eval()
model_fp32.qconfig = tq.get_default_qconfig("fbgemm")

# Fuse는 가능하면 수행 (Conv+BN+ReLU 등)
# model_fp32_fused = tq.fuse_modules(model_fp32, [...])
model_prepared = tq.prepare(model_fp32)

with torch.inference_mode():
    for i, (x, _) in enumerate(calib_loader):
        model_prepared(x)
        if i >= 200:  # 예: 200 배치 이상
            break

model_int8 = tq.convert(model_prepared)

캘리브레이션은 “몇 장”이 아니라 “분포를 얼마나 담았는가”가 핵심입니다. 로그/모니터링으로 입력 통계를 뽑아 샘플링 전략을 잡는 편이 안전합니다.


2) 레이어 융합(fusion)을 안 해서 스케일 경계가 늘어난다

증상

  • CNN 계열에서 PTQ 성능이 유독 나쁨
  • ConvBatchNorm이 분리된 채로 남아 있음

원인

Conv + BN (+ ReLU) 같은 패턴은 융합하면 수치적으로 더 안정적이고, 양자화 포인트도 줄어듭니다. 융합을 안 하면 중간 activation이 더 많이 양자화/역양자화되며 오차가 누적됩니다.

해결

  • 모델을 eval()로 두고 fuse 가능한 모듈을 명시적으로 fuse
  • torchvision 레퍼런스 구현을 참고

코드: 간단한 fusion 예시

import torch.ao.quantization as tq

model.eval()

# 예: Sequential 내부에 conv, bn, relu가 있을 때
# 실제 모듈 이름은 print(model)로 확인해서 맞춰야 합니다.
tq.fuse_modules(model, [["conv1", "bn1", "relu1"]], inplace=True)

융합은 “정확도”뿐 아니라 “속도”에도 영향을 줍니다. 특히 CPU 백엔드에서 차이가 큽니다.


3) 백엔드(qengine)와 qconfig가 하드웨어와 안 맞는다

증상

  • 어떤 서버에서는 정확도/속도가 괜찮고, 다른 서버에서는 결과가 달라짐
  • ARM/서버 CPU 종류에 따라 편차

원인

PyTorch 양자화는 백엔드에 따라 커널/정밀도/지원 op가 다릅니다. 대표적으로 fbgemm(x86 서버)과 qnnpack(ARM/모바일)이 있고, qconfig도 다르게 잡는 게 일반적입니다.

해결

  • 서버 CPU면 대체로 fbgemm을 우선 고려
  • 환경에서 torch.backends.quantized.engine를 명확히 설정

코드: 엔진 설정

import torch

# x86 서버에서 흔히 사용
torch.backends.quantized.engine = "fbgemm"

# ARM 계열이면 보통
# torch.backends.quantized.engine = "qnnpack"

엔진이 섞이면 재현성이 깨질 수 있습니다. CI에서 동일한 엔진으로 변환/검증 파이프라인을 고정하는 편이 좋습니다.


4) 민감 레이어를 무리하게 int8로 내려서 정보가 깨진다

증상

  • 트랜스포머에서 PTQ 후 정확도 급락
  • 특히 LayerNorm, Softmax, attention score 주변이 취약

원인

모든 레이어가 int8에 친화적인 건 아닙니다. LayerNorm처럼 분산/평균 기반 연산은 양자화 오차에 민감하고, attention은 작은 스케일 차이가 확률 분포를 크게 흔듭니다.

해결

  • 혼합 정밀도 전략: 민감 레이어는 fp16 또는 fp32로 유지
  • 백엔드가 지원하지 않는 op는 애초에 quantize 대상에서 제외

코드: 특정 모듈만 양자화 제외하는 패턴

import torch.nn as nn
import torch.ao.quantization as tq

def set_qconfig(module: nn.Module):
    for name, child in module.named_children():
        # 예: LayerNorm은 제외
        if isinstance(child, nn.LayerNorm):
            child.qconfig = None
        else:
            set_qconfig(child)

model.eval()
model.qconfig = tq.get_default_qconfig("fbgemm")
set_qconfig(model)

prepared = tq.prepare(model)
# 캘리브레이션 후 convert

실무에서는 “전부 int8”보다 “핵심 연산만 int8”이 정확도/성능/개발비의 균형이 더 좋을 때가 많습니다.


5) per-tensor 대신 per-channel 양자화가 필요한데 안 했다

증상

  • 컨볼루션/선형 레이어에서 채널별 스케일 차이가 큰 모델이 급락
  • 특정 채널만 과하게 포화

원인

가중치를 한 스케일로 묶는 per-tensor 방식은 채널별 분포 차이를 못 담습니다. 특히 Conv는 out-channel별로 분포가 달라 per-channel이 안정적인 경우가 많습니다.

해결

  • 가능한 경우 가중치 per-channel을 사용
  • 기본 qconfig가 이미 per-channel인 경우도 있지만, 모델/버전에 따라 다르므로 명시 확인

코드: qconfig 확인 포인트

import torch.ao.quantization as tq

qconfig = tq.get_default_qconfig("fbgemm")
print(qconfig)
# weight observer가 per_channel인지, activation observer가 무엇인지 확인

정확도 급락이 “특정 레이어 몇 개”에서 시작되는 경우가 많으니, 레이어별 출력 통계를 찍어 병목 레이어를 먼저 찾는 게 효율적입니다.


6) 옵저버(Observer) 설정이 모델 분포에 안 맞는다

증상

  • 캘리브레이션을 늘려도 개선이 제한적
  • outlier가 많은 데이터에서 성능이 특히 나쁨

원인

기본 MinMaxObserver는 outlier에 매우 취약합니다. outlier 하나가 스케일을 늘려버리면 대부분 값이 양자화 그리드에서 촘촘하게 표현되지 못합니다.

해결

  • activation은 HistogramObserver 또는 MovingAverageMinMaxObserver 고려
  • 필요하면 quantile 기반 클리핑(직접 구현 또는 FX 그래프 기반 커스터마이즈)

코드: 커스텀 qconfig로 옵저버 변경

import torch.ao.quantization as tq

act_observer = tq.HistogramObserver.with_args(
    dtype=torch.quint8,
    qscheme=torch.per_tensor_affine
)

weight_observer = tq.PerChannelMinMaxObserver.with_args(
    dtype=torch.qint8,
    qscheme=torch.per_channel_symmetric
)

qconfig = tq.QConfig(activation=act_observer, weight=weight_observer)
model.eval()
model.qconfig = qconfig

옵저버는 “정확도 급락”을 가장 드라마틱하게 되돌리는 레버 중 하나입니다. 다만 모델/데이터마다 정답이 달라 A/B로 확인해야 합니다.


7) PTQ로는 한계: QAT로 정확도를 회복해야 한다

증상

  • 위의 모든 조치를 해도 목표 정확도에 못 미침
  • 특히 작은 모델, 경계가 타이트한 태스크에서 int8 손실이 큼

원인

PTQ는 학습 없이 스케일만 맞추는 방식이라, 모델이 양자화 노이즈를 “적응”하지 못합니다. 특정 태스크는 QAT 없이는 목표를 못 맞추는 경우가 있습니다.

해결

  • QAT로 몇 epoch만 파인튜닝해도 큰 폭으로 회복되는 경우가 많음
  • 학습 시에는 train() 모드, 드롭아웃/BN 동작을 의도대로 관리

코드: QAT 워크플로우 뼈대

import torch
import torch.ao.quantization as tq

model.train()
model.qconfig = tq.get_default_qat_qconfig("fbgemm")

# 필요 시 fuse 먼저
# tq.fuse_modules(model, [...], inplace=True)

qat_model = tq.prepare_qat(model)

optimizer = torch.optim.AdamW(qat_model.parameters(), lr=1e-5)

for epoch in range(3):
    for x, y in train_loader:
        optimizer.zero_grad()
        out = qat_model(x)
        loss = criterion(out, y)
        loss.backward()
        optimizer.step()

qat_model.eval()
int8_model = tq.convert(qat_model)

QAT는 비용이 들지만, “정확도 급락”을 구조적으로 해결하는 가장 확실한 방법입니다.


정확도 급락 디버깅을 빠르게 하는 체크리스트

  1. 동적 양자화로도 급락하는지 먼저 확인 (Linear 위주 모델이면 동적이 더 안전)
  2. 정적 PTQ라면 캘리브레이션 데이터 품질/전처리 동일성 점검
  3. CNN이면 fusion 적용 여부 확인
  4. 엔진 fbgemm/qnnpack 고정 및 환경 차이 제거
  5. 민감 레이어(LayerNorm, attention 주변)는 제외 또는 상위 정밀도 유지
  6. activation 옵저버를 Histogram 계열로 바꿔 outlier 내성 확보
  7. 그래도 안 되면 QAT로 파인튜닝

이 과정을 자동화하려면, “어디서부터 깨지는지”를 레이어별로 기록하는 로깅이 중요합니다. 운영 장애에서 원인을 분해하는 방식은 GitHub Actions OIDC 403·권한거부 원인 7가지처럼 원인 카테고리를 쪼개는 접근이 도움이 됩니다.

마무리

int8 양자화는 단순 변환 문제가 아니라 데이터 분포, 레이어 민감도, 백엔드 제약이 동시에 얽힌 최적화 문제입니다. 정확도 급락이 발생하면 “양자화가 나쁘다”가 아니라, 대개는 다음 중 하나입니다.

  • 캘리브레이션/옵저버가 분포를 잘못 잡았다
  • 융합/백엔드 설정이 맞지 않는다
  • 민감 레이어를 무리하게 int8로 내렸다
  • PTQ 한계라 QAT가 필요하다

위 7가지를 순서대로 적용하면, 대다수 케이스에서 int8의 성능 이점을 유지하면서 정확도를 실무 수준으로 끌어올릴 수 있습니다.