- Published on
PyTorch 2.x PTQ로 INT8 양자화 정확도 하락 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 추론 비용을 줄이려고 INT8 양자화를 적용했는데, 기대한 지연 시간은 줄어도 정확도가 눈에 띄게 떨어지는 경우가 많습니다. 특히 PyTorch 2.x의 torch.ao.quantization(AOQuant) 기반 PTQ는 “돌아가게 만드는 것”은 쉽지만, 정확도 하락을 최소화하는 설정은 모델/데이터/연산자 조합에 따라 달라서 시행착오가 큽니다.
이 글은 PyTorch 2.x에서 PTQ로 INT8을 적용할 때 정확도 하락이 생기는 이유를 분해하고, 캘리브레이션 데이터 구성, 옵서버 선택, per-channel 적용, 레이어 제외, QAT로의 전환 기준까지 한 번에 정리합니다.
추론 파이프라인 전체 최적화 관점이 필요하다면, 로컬 LLM 튜닝 관점의 메모리/성능 트레이드오프도 참고할 만합니다: HuggingFace 로컬 LLM RAG 성능·메모리 튜닝
PTQ INT8에서 정확도가 떨어지는 구조적 이유
INT8 PTQ는 학습을 다시 하지 않고(또는 최소화하고) FP32/FP16 모델을 INT8로 바꿉니다. 이때 손실이 생기는 핵심 지점은 다음입니다.
스케일과 제로포인트 추정 오차
- 캘리브레이션 데이터로 activation 분포를 관측하고, 이를 INT8 범위로 매핑합니다.
- 분포 꼬리(outlier)가 크면 스케일이 커져서 대부분 값의 유효 비트가 줄어듭니다.
레이어별 민감도 차이
- 첫/마지막 레이어, attention의 특정 projection, layernorm 근처 연산 등은 양자화 오차에 민감합니다.
- “전부 INT8”이 항상 최선이 아닙니다.
per-tensor vs per-channel
- weight를 per-tensor로 양자화하면 채널별 분포 차이를 못 따라가서 손실이 커질 수 있습니다.
- 일반적으로
Linear/Convweight는 per-channel이 유리합니다.
연산자 폴딩/퓨즈로 인한 분포 변화
Conv + BN + ReLU같은 패턴을 fuse하거나 fold하면 activation 분포가 달라집니다.- 캘리브레이션은 “최종 그래프” 기준으로 해야 합니다.
대표성 부족한 캘리브레이션
- 실제 트래픽 분포를 반영하지 못하면 스케일이 잘못 잡힙니다.
- 특히 입력 길이/해상도/도메인이 섞인 모델에서 치명적입니다.
PyTorch 2.x PTQ 기본 흐름 (정확도 관점)
PyTorch 2.x에서 eager 모드 PTQ의 큰 흐름은 다음과 같습니다.
- 모델을
eval()로 전환 - (가능하면) fuse
qconfig지정 (옵서버/양자화 방식)prepare로 옵서버 삽입- 캘리브레이션 데이터로 forward 수행
convert로 실제 INT8 모듈로 변환
아래는 가장 단순한 스켈레톤입니다.
import torch
import torch.ao.quantization as aq
model = model.eval()
# (선택) fuse: 모델 구조에 따라 fuse 가능한 패턴이 다릅니다.
# 예: model = torch.quantization.fuse_modules(model, [...]) # 모델별 커스텀 필요
qconfig = aq.get_default_qconfig("x86")
model.qconfig = qconfig
prepared = aq.prepare(model, inplace=False)
# 캘리브레이션: 대표 데이터로 forward만 수행
with torch.no_grad():
for batch in calib_loader:
x = batch[0].to("cpu")
prepared(x)
quantized = aq.convert(prepared, inplace=False)
여기서 정확도를 좌우하는 포인트는 사실상 qconfig와 캘리브레이션입니다.
정확도 하락을 줄이는 1순위: 캘리브레이션을 “진짜 입력 분포”로 만들기
PTQ에서 가장 흔한 실패는 캘리브레이션이 “데모용 몇 배치”로 끝나는 것입니다. 다음 체크리스트를 권합니다.
캘리브레이션 데이터 체크리스트
- 실제 서비스 입력과 동일한 전처리를 적용했는가
- 클래스/도메인/길이(텍스트)/해상도(비전)가 실제 분포를 반영하는가
- outlier가 포함되는가 (예: 매우 긴 문장, 야간 이미지 등)
- 배치 크기/패딩 정책이 실제와 비슷한가
- 최소 수백~수천 샘플(모델 크기/다양성에 따라)로 안정화되는가
캘리브레이션 수를 늘릴 때의 요령
- 옵서버 통계를 안정화하려면 “다양성”이 “수량”보다 중요할 때가 많습니다.
- 입력이 길이 기반으로 분포가 갈리면, 길이 버킷별로 샘플을 섞어 넣습니다.
옵서버/스케일링 전략: MinMax보다 Histogram/Percentile을 검토
activation에 outlier가 있으면 MinMax 기반은 스케일이 커져서 정보 손실이 커집니다. 대안은 다음입니다.
- Histogram 기반(예: KL divergence): 분포를 보존하는 방향으로 클리핑 지점을 찾습니다.
- Percentile 클리핑: 상위
p퍼센타일을 잘라내고 스케일을 잡습니다.
PyTorch에서 제공하는 기본 qconfig는 백엔드별로 다르며, 정확도/성능 트레이드오프가 있습니다. 아래는 커스텀 옵서버를 섞는 예시입니다.
import torch.ao.quantization as aq
activation_observer = aq.HistogramObserver.with_args(
dtype=torch.quint8,
qscheme=torch.per_tensor_affine,
reduce_range=False,
)
weight_observer = aq.PerChannelMinMaxObserver.with_args(
dtype=torch.qint8,
qscheme=torch.per_channel_symmetric,
)
qconfig = aq.QConfig(activation=activation_observer, weight=weight_observer)
model.qconfig = qconfig
핵심은 다음 두 줄입니다.
- activation:
HistogramObserver로 outlier 내성을 높여보기 - weight:
PerChannelMinMaxObserver로 채널별 스케일을 허용하기
per-channel weight는 “기본값”으로 생각하기
대부분의 Linear/Conv는 채널마다 weight 분포가 다릅니다. per-tensor로 묶으면 특정 채널이 스케일을 지배해 나머지 채널이 양자화 노이즈에 묻힙니다.
- 정확도가 민감한 모델일수록 weight per-channel을 우선 적용
- activation은 보통 per-tensor가 일반적(하드웨어/백엔드 제약 포함)
레이어별로 “양자화 제외”를 걸어 정확도를 복구하기
PTQ 실전에서 가장 효과가 큰 테크닉 중 하나는 민감한 레이어만 FP32로 남기는 것입니다.
대표적으로 다음 후보를 먼저 의심합니다.
- 입력에 가까운 첫
Conv/Linear - 출력 logits 직전의 마지막
Linear - attention의
q_proj/k_proj/v_proj/out_proj중 특정 모듈 - layernorm 주변(특히 잔차 연결이 큰 구간)
아래는 모듈 이름 패턴으로 qconfig를 끄는 방식의 예시입니다.
import torch.ao.quantization as aq
def disable_quant_for(module: torch.nn.Module):
module.qconfig = None
for name, m in model.named_modules():
if name.endswith("classifier") or name.endswith("fc_out"):
disable_quant_for(m)
if "layernorm" in name.lower() or "ln" == name.lower():
disable_quant_for(m)
이 접근은 “완전 INT8” 대비 속도 이득이 조금 줄 수 있지만, 정확도 복구율이 매우 높고 디버깅 비용이 낮습니다.
정확도 하락 원인 찾기: 레이어별 오차를 수치로 측정
감으로 레이어를 제외하기보다, FP32와 INT8의 중간 activation 차이를 측정하면 빠릅니다.
- FP32 모델과 PTQ 모델에 같은 입력을 넣기
- 관심 레이어 출력에 hook을 걸어 텐서를 저장
- cosine similarity, MSE, max abs error를 비교
import torch
from collections import defaultdict
acts_fp32 = {}
acts_int8 = {}
def save_act(store, key):
def hook(m, inp, out):
store[key] = out.detach().float().cpu()
return hook
# 예: 특정 레이어만 비교
layer_name = "encoder.layers.0.self_attn"
m_fp32 = dict(model_fp32.named_modules())[layer_name]
m_int8 = dict(model_int8.named_modules())[layer_name]
h1 = m_fp32.register_forward_hook(save_act(acts_fp32, layer_name))
h2 = m_int8.register_forward_hook(save_act(acts_int8, layer_name))
with torch.no_grad():
y1 = model_fp32(x)
y2 = model_int8(x)
h1.remove(); h2.remove()
a = acts_fp32[layer_name].flatten()
b = acts_int8[layer_name].flatten()
cos = torch.nn.functional.cosine_similarity(a, b, dim=0).item()
mse = torch.mean((a - b) ** 2).item()
maxe = torch.max(torch.abs(a - b)).item()
print({"cos": cos, "mse": mse, "max_abs": maxe})
- cosine이 급락하는 레이어가 “민감 레이어” 후보
- 그 레이어만 FP32로 남기거나, activation 옵서버를 바꿔 재캘리브레이션
흔한 함정: 캘리브레이션 중 train()/dropout/BN 업데이트
PTQ 준비/캘리브레이션은 반드시 eval()에서 수행해야 합니다.
- dropout이 켜지면 activation 분포가 왜곡됩니다.
- BN이 학습 모드면 running stats가 바뀌고, 캘리브레이션이 불안정해집니다.
또한 캘리브레이션은 gradient가 필요 없으므로 no_grad()를 강제해 불필요한 오버헤드를 줄이고, 동일 입력에 대해 재현 가능하게 만드세요.
백엔드(엔진) 선택이 정확도에 미치는 영향
PyTorch INT8은 주로 x86에서 FBGEMM, ARM에서 QNNPACK 같은 백엔드를 사용합니다. 백엔드별로 지원 연산과 양자화 세부 동작이 달라 결과가 달라질 수 있습니다.
- 동일 모델이라도
get_default_qconfig("x86")와get_default_qconfig("qnnpack")결과가 다를 수 있음 - 운영 환경 CPU 아키텍처와 동일한 환경에서 캘리브레이션/검증 권장
성능만 보지 말고 “정확도-지연시간”을 같이 최적화하기
INT8의 목적은 대개 지연 시간/비용 절감입니다. 하지만 정확도 복구를 위해 레이어를 FP32로 남기거나, per-channel을 켜면 성능 특성이 바뀝니다. 이때는 다음처럼 지표를 함께 보세요.
- task metric: top-1, F1, BLEU, MRR 등
- latency: p50/p95
- CPU utilization, thread 수, batch 크기
실서비스에서 지연 시간 지표를 다룰 때는 브라우저 성능이든 서버 성능이든 “측정과 원인 추적”이 중요합니다. 접근 방식 자체는 유사합니다: Chrome INP 점수 급락? Long Task 추적·해결
PTQ로 안 되면 QAT로 넘어가야 하는 신호
다음 조건이면 PTQ만으로는 한계가 있을 가능성이 큽니다.
- outlier가 매우 큰 activation(특히 LLM류)로 인해 클리핑을 해도 손실이 큼
- 소량의 레이어 제외로도 정확도가 복구되지 않음
- 특정 소수 클래스에서만 급격히 무너짐(분포 꼬리 문제)
이때는 QAT(Quantization-Aware Training)를 고려합니다. QAT는 학습 중 양자화 노이즈를 모사해 모델이 적응하도록 하므로, PTQ 대비 정확도 복구 여지가 큽니다(대신 학습 비용 증가).
실전 추천 순서(정확도 복구 플레이북)
- 캘리브레이션 데이터부터 점검: 전처리/도메인/길이/해상도 분포
- weight를 per-channel로 전환
- activation 옵서버를 Histogram/클리핑 계열로 변경 후 재캘리브레이션
- 첫/마지막/민감 레이어를 FP32로 제외
- 레이어별 activation 유사도 측정으로 “정확도 파괴 레이어”를 특정
- 그래도 부족하면 QAT로 전환
마무리
PyTorch 2.x PTQ INT8은 “변환” 자체보다 정확도 하락을 통제하는 과정이 핵심입니다. 캘리브레이션 대표성, 옵서버 선택, per-channel weight, 민감 레이어 제외만 제대로 적용해도 많은 모델에서 체감 가능한 수준으로 품질이 돌아옵니다.
다음 단계로는, 운영 환경에서의 스루풋/지연 시간 측정(스레딩, 배치, NUMA, 모델 서빙 프레임워크)까지 묶어서 최적화하면 INT8의 비용 절감 효과를 안정적으로 가져갈 수 있습니다.