- Published on
PyTorch INT8 양자화로 추론 지연 3배 줄이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경에서 모델 지연(latency)을 줄이는 가장 확실한 방법 중 하나가 INT8 양자화(quantization) 입니다. 특히 CPU 추론이 병목인 서비스에서는 FP32 대비 2~4배 수준의 속도 개선이 현실적으로 자주 관측됩니다. 이 글에서는 PyTorch에서 INT8 양자화로 지연을 3배 수준까지 줄이기 위해 필요한 선택지(동적/정적/PTQ/QAT), 측정 방법, 캘리브레이션, 함정과 디버깅 포인트를 실전 관점으로 정리합니다.
아래 내용은 기본적으로 CPU 추론을 기준으로 설명합니다. GPU에서는 INT8이 “그냥” 빨라지지 않으며, TensorRT 같은 별도 스택이 필요하거나 커널/레이아웃/배치 조건이 맞아야 효과가 큽니다.
왜 INT8이 빨라지나: 병목을 바꾸는 3가지 포인트
INT8 양자화는 단순히 정밀도를 낮추는 것이 아니라, CPU에서의 병목을 구조적으로 바꿉니다.
메모리 대역폭 절감
- 가중치가 FP32 4바이트에서 INT8 1바이트로 줄어듭니다.
- L3 캐시 적중률이 올라가고, 메모리 로드 비용이 줄어듭니다.
벡터화/커널 최적화 경로 사용
- x86에서는 AVX2/AVX512-VNNI, ARM에서는 dotprod 같은 정수 연산 최적화가 적용됩니다.
- PyTorch는 백엔드로 FBGEMM(x86) 또는 QNNPACK(ARM) 등을 사용합니다.
연산 그래프 단순화(일부 연산 fusion)
- 정적 양자화에서는
Conv/Linear + ReLU같은 패턴이 양자화 친화적으로 변환됩니다.
- 정적 양자화에서는
다만 “언제나 3배”는 아닙니다. 모델 구조, 입력 크기, 배치, 스레딩, 백엔드, 캘리브레이션 품질에 따라 결과가 크게 달라집니다.
어떤 양자화를 선택할까: 동적 vs 정적, PTQ vs QAT
PyTorch의 대표적인 선택지는 다음과 같습니다.
동적 양자화(Dynamic Quantization)
- 주로
Linear계열(Transformer의 FFN, RNN, MLP)에 효과적입니다. - 활성값(activation)은 런타임에 스케일을 추정해 양자화합니다.
- 적용이 매우 쉽고, 데이터셋 캘리브레이션이 필요 없는 경우가 많습니다.
- CNN의
Conv2d에는 효과가 제한적입니다.
정적 양자화(Static / Post-Training Quantization, PTQ)
Conv2d포함 전반에 적용 가능합니다.- 캘리브레이션 데이터로 activation 통계를 수집한 뒤 고정된 스케일/제로포인트를 사용합니다.
- 정확도 저하를 더 잘 제어할 수 있지만, 준비 과정이 필요합니다.
QAT(Quantization Aware Training)
- 학습 중에 양자화 노이즈를 모델이 “견디도록” 학습합니다.
- PTQ로 정확도가 많이 떨어지는 모델에서 강력합니다.
- 비용은 큽니다. 학습 파이프라인과 검증 체계가 갖춰져 있어야 합니다.
실전 선택 가이드:
- Transformer 기반 텍스트 모델, MLP 위주: 동적 양자화부터
- CNN/비전 모델, Conv 비중 큼: 정적 PTQ 우선
- PTQ로 목표 정확도 미달: QAT 고려
측정이 먼저다: 지연 3배를 “재현 가능하게” 측정하기
양자화 성능은 측정 방법에 따라 결과가 흔들립니다. 아래 원칙을 지키면 “진짜” 개선인지 판단하기 쉬워집니다.
- 워밍업 필수(캐시/스레드 풀 초기화)
torch.inference_mode()사용- CPU 스레드 수 고정(
torch.set_num_threads) - 동일한 입력 텐서, 동일한 배치, 동일한 전처리 비용 분리
import time
import torch
def benchmark(model, example_input, iters=200, warmup=50):
model.eval()
# 스레드 수는 환경에 맞게 고정
torch.set_num_threads(8)
with torch.inference_mode():
for _ in range(warmup):
_ = model(example_input)
t0 = time.perf_counter()
for _ in range(iters):
_ = model(example_input)
t1 = time.perf_counter()
return (t1 - t0) * 1000 / iters
# 예시 입력
x = torch.randn(1, 768)
지연이 기대만큼 안 줄면, 먼저 다음을 확인하세요.
- 모델이 정말 CPU에서 돌고 있는지(
model.to("cpu")) - 연산 대부분이
Linear인지Conv인지 - 배치가 너무 작아 오버헤드가 지배적인지
- 스레드/NUMA 설정이 뒤섞이지 않았는지
추론 서버에서 메모리 압박까지 동시에 겪는다면, 양자화는 메모리도 줄이므로 OOM 대응에도 도움이 됩니다. 운영 측면의 메모리 진단은 리눅스 OOM Killer 로그로 메모리 누수 추적하기도 함께 참고하면 좋습니다.
1) 가장 쉬운 시작: Linear 중심 모델에 동적 양자화 적용
동적 양자화는 “일단 해보고” 효과를 확인하기 좋습니다.
import torch
import torch.nn as nn
# 예시 MLP
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(768, 2048),
nn.ReLU(),
nn.Linear(2048, 768),
)
def forward(self, x):
return self.net(x)
fp32_model = MLP().eval()
# 동적 양자화: Linear 계열을 qint8로
int8_model = torch.quantization.quantize_dynamic(
fp32_model,
{nn.Linear},
dtype=torch.qint8,
).eval()
x = torch.randn(1, 768)
fp32_ms = benchmark(fp32_model, x)
int8_ms = benchmark(int8_model, x)
print("FP32 ms:", fp32_ms)
print("INT8 ms:", int8_ms)
print("Speedup:", fp32_ms / int8_ms)
동적 양자화로 3배가 나오는 케이스는 보통 다음 조건을 만족할 때입니다.
Linear비중이 매우 높고- 입력 차원이 크며
- 메모리 대역폭/캐시 미스가 병목이고
- CPU가 VNNI 등 정수 최적화에 유리한 경우
반대로 모델이 작거나, 전처리/후처리가 지연의 대부분이면 양자화 효과가 제한적입니다.
2) Conv 포함 모델에서 “제대로” 줄이기: 정적 PTQ의 핵심 절차
정적 양자화는 절차가 조금 복잡하지만, Conv 기반 모델에서는 이쪽이 정답인 경우가 많습니다.
큰 흐름은 다음과 같습니다.
- 백엔드 선택(
fbgemm또는qnnpack) fuse_modules로 가능한 레이어 fusionqconfig지정prepare로 관측기(observer) 삽입- 캘리브레이션 데이터로 forward 수행(통계 수집)
convert로 INT8 모델 생성
아래는 torch.ao.quantization 기반의 전형적인 패턴입니다.
import torch
import torch.nn as nn
import torch.ao.quantization as aq
class SmallCNN(nn.Module):
def __init__(self):
super().__init__()
self.conv = nn.Conv2d(3, 16, 3, stride=2, padding=1)
self.relu = nn.ReLU()
self.pool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(16, 10)
def forward(self, x):
x = self.relu(self.conv(x))
x = self.pool(x)
x = torch.flatten(x, 1)
return self.fc(x)
def calibrate(model, dataloader, num_batches=10):
model.eval()
with torch.inference_mode():
for i, (images, _) in enumerate(dataloader):
_ = model(images)
if i + 1 >= num_batches:
break
# 1) 백엔드 선택
torch.backends.quantized.engine = "fbgemm" # x86 서버 기준
fp32 = SmallCNN().eval()
# 2) Fusion(모델 구조에 따라 가능한 패턴만)
# 여기서는 conv+relu를 fuse
fused = torch.ao.quantization.fuse_modules(fp32, [["conv", "relu"]], inplace=False)
# 3) qconfig 지정
fused.qconfig = aq.get_default_qconfig("fbgemm")
# 4) prepare
prepared = aq.prepare(fused, inplace=False)
# 5) 캘리브레이션
# dataloader는 대표 입력 분포를 담아야 함
# calibrate(prepared, dataloader)
# 예시로 랜덤 입력으로 간단 캘리브레이션(실전에서는 금지에 가깝습니다)
with torch.inference_mode():
for _ in range(20):
_ = prepared(torch.randn(1, 3, 224, 224))
# 6) convert
int8 = aq.convert(prepared, inplace=False).eval()
x = torch.randn(1, 3, 224, 224)
print("FP32 ms:", benchmark(fp32, x))
print("INT8 ms:", benchmark(int8, x))
캘리브레이션 데이터가 성패를 좌우한다
정적 PTQ에서 정확도 저하를 줄이는 핵심은 “대표 분포”입니다.
- 실서비스 입력 분포를 반영한 샘플을 쓰기
- 전처리까지 포함한 텐서를 넣기
- 너무 적은 배치로 끝내지 않기(보통 수십~수백 배치)
캘리브레이션이 부실하면 activation 스케일이 왜곡되어 정확도가 급락합니다. 지연만 보고 배포했다가 품질 사고가 나는 전형적인 원인입니다.
3) 정확도 저하를 줄이는 실전 팁
퍼채널(per-channel) 가중치 양자화가 가능한지 확인
많은 백엔드에서 Conv/Linear 가중치에 대해 퍼채널 양자화를 사용하면 정확도 손실이 줄어듭니다. 기본 qconfig가 이를 포함하는 경우가 많지만, 커스텀 설정을 쓰면 놓치기 쉽습니다.
민감 레이어는 FP32로 남기는 “부분 양자화”도 고려
모델 전체를 INT8로 바꾸는 대신, 병목 레이어만 양자화해도 지연이 크게 줄 수 있습니다. 특히 출력단, 정규화 계열, 특정 attention 블록이 민감한 경우가 있습니다.
QAT는 최후의 카드가 아니라 “목표 정확도” 기준으로 결정
PTQ에서 정확도가 1~2%p만 떨어져도 비즈니스적으로 치명적일 수 있습니다. 이 경우 QAT가 비용 대비 가장 빠른 해결책이 되기도 합니다.
4) 지연 3배를 위한 시스템 레벨 체크리스트
양자화 모델 자체 외에도, CPU 추론 성능은 시스템 설정에 크게 좌우됩니다.
스레딩과 배치
torch.set_num_threads(n)을 고정하고 실험- 배치가 너무 작으면 커널 호출 오버헤드가 지배
- 반대로 배치를 키우면 p99가 나빠질 수 있으니 p50/p95/p99를 함께 확인
전처리/후처리 분리 측정
모델만 빨라졌는데 API 지연이 그대로라면, 대부분 전처리/후처리 또는 네트워크가 병목입니다. 특히 이미지 디코딩, 토크나이징이 흔한 범인입니다.
컨테이너 리소스 제한과 노드 아키텍처
- CPU 제한이 낮으면 스레드가 놀거나 스케줄링 오버헤드가 커집니다.
- ARM(Graviton) 환경에서는
qnnpack경로가 유리할 수 있고, x86에서는fbgemm이 일반적입니다. - 비용 최적화 관점에서 인퍼런스 노드를 ARM으로 옮기는 전략도 종종 유효합니다. 이때는 EKS 비용 40%↓ - Karpenter+Graviton 전환 실전 같은 운영 경험이 도움이 됩니다.
5) 배포 관점: 저장, 로딩, 호환성
저장/로딩
양자화 모델은 일반적으로 state_dict 저장이 가능하지만, 변환된 모듈 타입이 달라지므로 로딩 코드 경로를 분리하는 것이 안전합니다.
# 저장
torch.save(int8.state_dict(), "model_int8.pt")
# 로딩 시에는 동일한 양자화 구조로 모델을 구성한 뒤 state_dict 로드
model = SmallCNN().eval()
model = torch.ao.quantization.fuse_modules(model, [["conv", "relu"]], inplace=False)
model.qconfig = torch.ao.quantization.get_default_qconfig("fbgemm")
prepared = torch.ao.quantization.prepare(model, inplace=False)
# 캘리브레이션을 다시 할지, 혹은 convert된 구조를 별도로 직렬화할지 전략 필요
# 보통은 convert까지 끝낸 모델을 통째로 torch.jit 또는 torch.export 기반으로 패키징하는 쪽이 운영에 유리합니다.
실무에서는 다음 중 하나로 정리하는 경우가 많습니다.
- TorchScript로 패키징해 런타임 의존성을 줄이기
- 버전 고정(특히 PyTorch 마이너 버전 변경 시 양자화 경로가 달라질 수 있음)
6) 흔한 함정과 디버깅 포인트
함정 1: 기대한 연산이 양자화되지 않았다
정적 양자화에서 prepare와 convert를 했는데도 속도가 안 나오는 경우, 실제로는 많은 연산이 FP32로 남아 있을 수 있습니다. 원인은 보통 다음입니다.
- fuse 가능한 패턴을 fuse하지 못함
- 지원되지 않는 연산자 포함
- 백엔드 엔진 설정 누락
이때는 변환된 모델의 모듈 타입을 출력해 확인하세요.
for name, m in int8.named_modules():
if "Quant" in m.__class__.__name__ or "quant" in m.__class__.__name__.lower():
print(name, type(m))
함정 2: 정확도는 괜찮은데 p99가 나빠졌다
INT8이 평균 지연은 줄여도 tail latency가 나빠지는 케이스가 있습니다.
- 스레드 경쟁(컨테이너 CPU 제한, 다른 워크로드 간섭)
- 배치/큐잉 정책 문제
- GC나 파이썬 레벨 오버헤드
서빙 레이어에서 레이트리밋이나 큐를 둔다면 토큰 버킷 같은 방식으로 안정화하는 것도 방법입니다. 트래픽 제어 관점은 OpenAI Responses API 429 레이트리밋 토큰버킷으로 끝내기 글의 아이디어를 내부 시스템에 응용할 수 있습니다.
함정 3: 캘리브레이션 데이터가 실제 입력과 달라 품질이 무너졌다
캘리브레이션은 “대충 랜덤 텐서”로 하면 안 됩니다. 최소한 다음을 지키세요.
- 프로덕션 입력에서 샘플링
- 계절성/카테고리/언어 등 분포 다양성 반영
- 전처리 포함
결론: 3배 지연 감소를 만드는 현실적인 접근 순서
- 측정 환경 고정: 워밍업, 스레드, 입력, 전처리 분리
- 동적 양자화로 빠른 승부:
Linear중심 모델이면 즉시 시도 - Conv 모델은 정적 PTQ: fuse, 캘리브레이션 품질이 핵심
- 정확도 목표 미달이면 QAT: 비용이 크지만 가장 확실
- 운영 체크: CPU 제한, 노드 아키텍처, p99, 롤백 플랜
INT8 양자화는 “모델 최적화”이면서 동시에 “운영 최적화”입니다. 모델만 바꿔서는 3배가 안 나오는 경우가 많고, 측정/스레딩/캘리브레이션/배포 방식까지 같이 정리할 때 재현 가능한 성과가 나옵니다.