- Published on
PTQ vs QAT - PyTorch int8 양자화 에러 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 비용을 줄이려고 int8 양자화를 적용하면, 예상보다 많은 팀이 “속도는커녕 에러부터 난다”를 먼저 겪습니다. 특히 PyTorch의 eager mode 정적 양자화는 단계가 많고(모듈 fuse, observer 삽입, 캘리브레이션, convert), 한 단계라도 어긋나면 런타임에서 dtype 불일치나 지원되지 않는 연산 에러가 터집니다.
이 글은 PTQ(사후 양자화)와 QAT(양자화 인지 학습)를 비교하면서, PyTorch int8 양자화에서 흔히 발생하는 에러 패턴을 원인별로 분류하고, 재현 가능한 체크리스트와 코드로 해결하는 방법을 정리합니다.
아래 내용은 PyTorch eager mode(torch.ao.quantization) 기준이며, 모델 구조에 따라 FX graph mode가 더 적합할 수도 있습니다.
PTQ vs QAT: 무엇이 다르고, 에러가 어디서 갈리나
PTQ( Post-Training Quantization )
- 학습된
fp32모델을 유지한 채, 대표 데이터로 캘리브레이션하여 scale, zero point를 추정하고int8로 변환합니다. - 장점: 빠르고 간단(이론상). 학습 파이프라인 변경이 적음.
- 단점: 분포가 민감한 모델(특히 Transformer, 작은 배치, LayerNorm 주변)에서 정확도 하락이 큼. 캘리브레이션 데이터 품질/분포에 강하게 의존.
- 에러 포인트: observer 삽입/캘리브레이션/convert 단계에서 설정이 꼬이거나,
fuse가 되지 않아 지원되지 않는 연산이 남는 경우.
QAT( Quantization-Aware Training )
- 학습 중에 fake quantization을 삽입해
int8오차를 미리 반영합니다. - 장점: 정확도 유지에 유리.
- 단점: 학습 시간이 늘고, 학습 코드 변경이 큼. 여전히 convert 단계에서 backend 제약을 맞춰야 함.
- 에러 포인트: QAT용
qconfig를 잘못 쓰거나, convert 시점에 모듈이 예상과 다르게 남아 dtype mismatch가 발생.
실무에서는 PTQ로 먼저 시도하고, 정확도나 특정 레이어에서만 문제가 생기면 QAT로 넘어가는 흐름이 일반적입니다.
가장 흔한 PyTorch int8 양자화 에러 7가지와 해결법
아래 에러들은 서로 연쇄적으로 발생할 수 있습니다. 중요한 건 “증상”이 아니라 “원인 레이어”를 찾는 것입니다.
1) QuantizedCPU 커널/백엔드 미설정 에러
증상
NotImplementedError: Could not run 'quantized::conv2d' with arguments from the 'CPU' backend- 혹은
quantized::linear관련 NotImplementedError
원인
torch.backends.quantized.engine가 환경과 맞지 않거나 기본값이 비활성.- x86에서는 보통
fbgemm, ARM 계열은qnnpack을 사용.
해결
import torch
# x86 서버라면 대부분 fbgemm
torch.backends.quantized.engine = "fbgemm"
# ARM(모바일/라즈베리파이 등)이라면 qnnpack
# torch.backends.quantized.engine = "qnnpack"
print("quant engine:", torch.backends.quantized.engine)
추가로, 서버에서 컨테이너 이미지가 CPU feature(AVX2 등)를 제대로 노출하지 못하는 경우도 있습니다. 이럴 땐 빌드 옵션이나 베이스 이미지부터 점검해야 합니다. CI 환경에서 이상한 캐시/빌드 결과 때문에 재현이 흔들리면, 캐시 이슈부터 정리하는 것도 도움이 됩니다: GitHub Actions 캐시가 안먹을 때 9가지 원인
2) fuse_modules 누락으로 int8 변환이 실패하거나 느림
증상
- convert 이후에도
Conv와ReLU가 분리되어 남음 - 성능이 기대보다 안 나오거나, 일부 연산이
fp32로 fallback
원인
- 정적 양자화는
Conv + BN + ReLU같은 패턴을 fuse 해야 최적화/지원이 잘 됩니다.
해결(대표적인 CNN 예시)
import torch
import torch.nn as nn
from torch.ao.quantization import fuse_modules
class SmallCNN(nn.Module):
def __init__(self):
super().__init__()
self.conv = nn.Conv2d(3, 16, 3, stride=1, padding=1)
self.bn = nn.BatchNorm2d(16)
self.relu = nn.ReLU(inplace=False)
self.fc = nn.Linear(16 * 32 * 32, 10)
def forward(self, x):
x = self.relu(self.bn(self.conv(x)))
x = x.flatten(1)
return self.fc(x)
model = SmallCNN().eval()
# fuse는 eval 모드에서 수행
model_fused = fuse_modules(model, [["conv", "bn", "relu"]], inplace=False)
print(model_fused)
inplace=True를 쓸 때는 모듈 참조가 다른 곳에서 공유되는지 주의하세요.
3) prepare/convert 순서 실수 또는 eval() 누락
증상
- observer가 안 붙거나, convert 후에도
QuantStub/DeQuantStub가 이상하게 남음 - 캘리브레이션이 반영되지 않아 출력이 NaN 또는 품질 급락
원인
- 정적 양자화는 기본적으로
eval()기준 동작을 기대합니다. - 순서는 보통
fuse→qconfig설정 →prepare→ 캘리브레이션 forward →convert.
해결 템플릿(PTQ, eager mode)
import torch
import torch.nn as nn
from torch.ao.quantization import QuantStub, DeQuantStub
from torch.ao.quantization import get_default_qconfig, prepare, convert
class QuantReadyCNN(nn.Module):
def __init__(self):
super().__init__()
self.quant = QuantStub()
self.conv = nn.Conv2d(3, 16, 3, padding=1)
self.relu = nn.ReLU()
self.fc = nn.Linear(16 * 32 * 32, 10)
self.dequant = DeQuantStub()
def forward(self, x):
x = self.quant(x)
x = self.relu(self.conv(x))
x = x.flatten(1)
x = self.fc(x)
x = self.dequant(x)
return x
model = QuantReadyCNN().eval()
torch.backends.quantized.engine = "fbgemm"
model.qconfig = get_default_qconfig(torch.backends.quantized.engine)
# 1) observer 삽입
prepared = prepare(model, inplace=False)
# 2) 캘리브레이션(대표 데이터로 몇 배치라도 forward)
with torch.inference_mode():
for _ in range(10):
x = torch.randn(4, 3, 32, 32)
prepared(x)
# 3) int8 모델로 변환
quantized = convert(prepared, inplace=False)
print(quantized)
4) dtype 불일치: 입력은 float인데 레이어는 quantized를 기대
증상
RuntimeError: Could not run 'quantized::conv2d' with arguments from the 'CPU' backend와 함께 dtype 힌트가 보이거나Expected quantized tensor for argument ...류의 에러
원인
- 모델 일부만 quantize 되었거나,
QuantStub/DeQuantStub위치가 잘못되었거나, forward 중간에float연산이 끼어듦.
해결
- 입력부터 첫 양자화 지점까지
QuantStub으로 감싸고, 출력 직전에DeQuantStub을 둡니다. - 중간에
x = x + residual같은 연산이 있다면, 그 residual 경로도 동일한 quantization domain에 있어야 합니다.
점검용으로는 모듈 출력 dtype을 훅으로 찍는 방식이 효과적입니다.
def dtype_hook(name):
def _hook(module, inp, out):
# out이 tuple일 수 있으니 단순화
t = out[0] if isinstance(out, (tuple, list)) else out
if hasattr(t, "dtype"):
print(name, "->", t.dtype)
return _hook
handles = []
for n, m in quantized.named_modules():
if any(k in n for k in ["quant", "conv", "fc", "dequant"]):
handles.append(m.register_forward_hook(dtype_hook(n)))
with torch.inference_mode():
quantized(torch.randn(1, 3, 32, 32))
for h in handles:
h.remove()
MDX 렌더링 환경에서는 -> 같은 기호를 본문에 그대로 쓰면 안 되는 경우가 있으니, 위 코드는 코드블록 안에서만 사용하세요.
5) 지원되지 않는 연산: LayerNorm, GELU, 일부 addmm 패턴
증상
- convert는 되지만 실행 시 특정 op에서 NotImplementedError
- 또는 일부 구간이 자동으로
fp32로 남아 성능이 안 나옴
원인
- PyTorch quantized kernel은 모든 연산을 커버하지 않습니다. 특히 Transformer 계열은 정적
int8로 전부 밀어붙이기 어렵습니다.
해결 전략
- 선별 양자화: Conv/Linear만
int8, 정규화/활성화 일부는fp16또는fp32유지. - FX graph mode 또는 backend 최적화 라이브러리(예: TensorRT, oneDNN, IPEX 등) 검토.
- QAT로 넘어가더라도 “지원되지 않는 op 자체”는 해결되지 않습니다. QAT는 정확도 문제를 주로 해결합니다.
6) 캘리브레이션 데이터 부족으로 스케일이 망가짐(정확도 급락)
증상
- 에러는 없는데 결과가 심각하게 틀림
- 특정 클래스만 과도하게 쏠리거나 출력이 상수처럼 나옴
원인
- observer가 본 분포가 실제 서빙 분포와 다르거나, 배치 수가 너무 적음.
해결
- 캘리브레이션 데이터는 “학습 데이터 일부”가 아니라 “서빙 입력 분포를 대표”해야 합니다.
- 최소 수십~수백 배치로 늘려보고, 특히 입력 스케일이 다양한 케이스를 포함.
7) QAT에서 qconfig를 PTQ용으로 써서 학습이 불안정
증상
- QAT 학습 중 loss 폭발, 수렴이 안 됨
- convert 후 성능이 기대보다 나쁨
원인
- QAT는 fake quant 모듈이 들어가므로 QAT 전용 qconfig를 써야 합니다.
해결(QAT 기본 템플릿)
import torch
from torch.ao.quantization import get_default_qat_qconfig, prepare_qat, convert
torch.backends.quantized.engine = "fbgemm"
model = QuantReadyCNN() # 앞에서 정의한 QuantStub/DeQuantStub 포함 모델
model.train()
model.qconfig = get_default_qat_qconfig(torch.backends.quantized.engine)
qat_model = prepare_qat(model, inplace=False)
# 이후 일반 학습 루프 수행
# optimizer.step() 등
# 학습 후 convert는 eval에서 수행
qat_model.eval()
quantized = convert(qat_model, inplace=False)
QAT는 학습 파이프라인에 들어가는 만큼, 재현성/디버깅이 중요합니다. 비동기 데이터 로더나 커스텀 데코레이터가 섞여 있다면, await 누락 같은 문제도 양자화 이슈처럼 보일 수 있습니다. 관련 디버깅 관점은 다음 글이 도움이 됩니다: Python async 데코레이터에서 await 누락 버그 잡기
실전 체크리스트: “에러를 줄이는” 양자화 적용 순서
1) 목표를 먼저 분리
- 목표가 지연시간인지, 메모리인지, CPU 비용인지 명확히 합니다.
- 정확도가 조금이라도 민감하면 PTQ로 빠르게 검증 후 QAT로 넘어갈지 결정합니다.
2) 모델 구조 점검
- Conv 중심 CNN은 PTQ 성공 확률이 높습니다.
- Transformer 계열은 “전부
int8” 목표를 버리고, Linear 일부만 또는 embedding 제외 등으로 접근하는 편이 현실적입니다.
3) backend 고정
- x86 서버:
fbgemm. - ARM:
qnnpack. - 같은 모델도 backend에 따라 지원 op와 성능이 달라집니다.
4) fuse 가능한 패턴은 최대한 fuse
Conv + BN + ReLU.Linear + ReLU.- fuse가 안 되는 구조면 FX graph mode로 전환을 고려합니다.
5) PTQ 파이프라인은 반드시 이 순서
eval()fuse_modulesqconfig설정prepare- 캘리브레이션 forward
convert
6) 에러가 나면 “dtype 흐름”부터 확인
- 입력이 quantize 되었는지
- 중간에 float 연산이 끼는지
- residual/concat 경로가 같은 domain인지
디버깅 팁: 양자화 모델을 눈으로 확인하는 방법
모듈 트리에서 quantized 레이어가 생성됐는지 확인
for n, m in quantized.named_modules():
if "Quantized" in m.__class__.__name__ or "quant" in n:
print(n, type(m))
state dict 크기 비교로 “진짜 int8로 바뀌었는지” 감 잡기
fp32대비int8는 대체로 state dict가 줄어듭니다.- 다만 observer 파라미터 등으로 단순 비교가 흔들릴 수 있어 참고용으로만 봅니다.
결론: PTQ는 파이프라인, QAT는 제품 전략
PyTorch int8 양자화 에러는 대부분 “지원되지 않는 op” 때문이 아니라, backend 설정, fuse 누락, prepare/convert 순서, QuantStub 위치 같은 파이프라인 불일치에서 시작합니다. PTQ는 이 파이프라인을 안정화하는 작업이고, QAT는 정확도까지 챙기기 위한 제품 전략에 가깝습니다.
추천 접근은 다음과 같습니다.
- CNN 또는 단순 MLP: PTQ부터 시작하고, 캘리브레이션과 fuse를 제대로.
- 정확도 민감: QAT로 전환하되, convert 단계의 제약은 동일하다는 점을 전제로 설계.
- Transformer: 전면
int8집착을 버리고, 선별 양자화 또는 다른 최적화 스택을 병행.
다음 단계로는 FX graph mode 양자화, per-channel quantization, 그리고 모델별로 “양자화해도 되는 블록”을 찾는 실험 설계를 권합니다.