Published on

파이썬 CNN·Transformer 10배 줄이는 4가지 양자화 트릭

Authors

서버/엣지에서 딥러닝 모델을 굴리다 보면 결국 병목은 메모리 대역폭캐시 미스로 수렴합니다. 특히 CNN은 컨볼루션 가중치가 크고, Transformer는 Linear/MatMul이 지배적이라 파라미터·활성(activation) 저장 비용이 곧 지연시간과 비용으로 직결됩니다.

양자화(quantization)는 이 문제를 가장 빠르게 줄이는 방법입니다. FP32를 INT8, INT4로 낮추면 모델 크기와 메모리 트래픽이 크게 줄고, 하드웨어가 지원하면 처리량도 증가합니다. 다만 “그냥 INT8로 바꾸면 된다”는 식으로 접근하면 정확도 하락, 성능 역전, 배포 장애를 쉽게 만납니다.

이 글은 CNN·Transformer를 파이썬에서 10배 수준까지 줄이는 데 자주 쓰이는 4가지 트릭을, 실패 지점을 포함해 정리합니다.

  • 목표: 모델 파일 크기·VRAM·RAM을 크게 줄이면서 정확도 손실을 관리
  • 범위: PyTorch 중심, 필요 시 Hugging Face/LLM 양자화 도구 포함
  • 전제: 추론(inference) 최적화 관점

운영 환경에서 성능 튜닝은 “모델만”이 아니라 배포·재시작·롤백까지 포함됩니다. 서비스가 재시작 루프에 빠질 때 진단 흐름은 이 글도 도움이 됩니다: systemd 서비스가 계속 재시작될 때 7단계 진단

트릭 1) PTQ INT8: Per-channel + 올바른 캘리브레이션

가장 먼저 시도할 것은 PTQ(Post-Training Quantization) 입니다. 학습을 다시 하지 않고, 대표 데이터로 스케일을 잡아 INT8로 내리는 방식입니다.

핵심 포인트는 두 가지입니다.

  1. 가중치 per-channel(채널별) 양자화를 우선 고려
  2. 캘리브레이션 데이터는 “진짜 입력 분포”를 반영

왜 per-channel이 중요한가

Conv/Linear 가중치는 채널마다 분포가 다릅니다. per-tensor(전체 텐서에 스케일 1개)는 특정 채널이 과도하게 클리핑되어 정확도가 떨어지기 쉽습니다. per-channel은 채널별로 스케일을 따로 가져가서 손실을 줄입니다.

PyTorch 예시: FX Graph Mode PTQ

아래는 PyTorch의 FX 기반 PTQ 흐름 예시입니다. 백엔드는 CPU라면 fbgemm, ARM이라면 qnnpack이 일반적입니다.

import torch
import torch.nn as nn
from torch.ao.quantization import get_default_qconfig
from torch.ao.quantization.quantize_fx import prepare_fx, convert_fx

# 예시용 작은 CNN
class SmallCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 32, 3, stride=2, padding=1),
            nn.ReLU(),
            nn.Conv2d(32, 64, 3, stride=2, padding=1),
            nn.ReLU(),
        )
        self.classifier = nn.Sequential(
            nn.AdaptiveAvgPool2d((1, 1)),
            nn.Flatten(),
            nn.Linear(64, 10),
        )

    def forward(self, x):
        x = self.features(x)
        return self.classifier(x)

model = SmallCNN().eval()

# 백엔드 선택
torch.backends.quantized.engine = "fbgemm"
qconfig = get_default_qconfig(torch.backends.quantized.engine)

# 예시 입력 스펙
example_inputs = (torch.randn(1, 3, 224, 224),)

# 준비(관측기 삽입)
prepared = prepare_fx(model, {"": qconfig}, example_inputs=example_inputs)

# 캘리브레이션: 실제 서비스 입력 분포를 반영한 데이터로 여러 배치 통과
with torch.inference_mode():
    for _ in range(50):
        prepared(torch.randn(8, 3, 224, 224))

# 변환(INT8 모델 생성)
quantized_model = convert_fx(prepared)

# 동작 확인
out = quantized_model(torch.randn(1, 3, 224, 224))
print(out.shape)

캘리브레이션 실전 팁

  • 랜덤 텐서로 캘리브레이션하면 스케일이 왜곡되어 실제 정확도가 크게 떨어질 수 있습니다.
  • “정답 라벨”은 필요 없습니다. 입력 분포가 중요합니다.
  • 이미지라면 리사이즈/정규화까지 포함한 전처리를 동일하게 맞추세요.
  • Transformer는 토큰 길이 분포(짧은 입력만 넣는 실수)를 꼭 맞추세요.

언제 10배가 나오는가

  • FP32 4 bytes에서 INT8 1 byte로 가중치만 보면 4배
  • 여기에 activation 메모리, 캐시 적중, 커널 효율까지 겹치면 체감상 더 커질 수 있습니다.
  • “파일 크기만” 10배는 보통 INT4 계열에서 더 현실적입니다. 그래서 다음 트릭이 중요합니다.

트릭 2) QAT: 정확도 방어를 위한 최소 학습 재개

PTQ로 정확도가 충분히 나오지 않으면 QAT(Quantization-Aware Training) 으로 넘어갑니다. QAT는 학습 중에 양자화 오차를 모사해 모델이 오차에 적응하도록 만듭니다.

QAT는 비용이 들지만, 다음 상황에서 효과가 큽니다.

  • MobileNet류처럼 이미 압축된 CNN
  • 작은 데이터셋에서 PTQ가 불안정한 경우
  • Transformer에서 LayerNorm/Residual 주변이 민감한 경우

PyTorch QAT의 최소 골격

아래 코드는 “전체 파이프라인”이 아니라 QAT의 핵심 흐름을 보여주는 뼈대입니다.

import torch
import torch.nn as nn
from torch.ao.quantization import get_default_qat_qconfig
from torch.ao.quantization.quantize_fx import prepare_qat_fx, convert_fx

model = SmallCNN()
model.train()

torch.backends.quantized.engine = "fbgemm"
qat_qconfig = get_default_qat_qconfig(torch.backends.quantized.engine)

example_inputs = (torch.randn(1, 3, 224, 224),)

prepared = prepare_qat_fx(model, {"": qat_qconfig}, example_inputs=example_inputs)

opt = torch.optim.AdamW(prepared.parameters(), lr=1e-4)
loss_fn = nn.CrossEntropyLoss()

for step in range(200):
    x = torch.randn(16, 3, 224, 224)
    y = torch.randint(0, 10, (16,))
    logits = prepared(x)
    loss = loss_fn(logits, y)
    opt.zero_grad(set_to_none=True)
    loss.backward()
    opt.step()

prepared.eval()
quantized_model = convert_fx(prepared)

QAT에서 자주 터지는 지점

  • 학습률 과대: 양자화 노이즈가 들어오므로 보통 더 낮은 LR이 안정적
  • Freeze/Unfreeze 전략 부재: 백본은 고정하고 마지막 블록만 QAT하는 식으로 시작하면 리스크가 줄어듭니다.
  • 관측기(observer) 설정: per-channel, histogram observer 등 선택에 따라 품질이 크게 달라집니다.

트릭 3) Transformer 활성 양자화: SmoothQuant로 “INT8이 안 되는 구간”을 밀어내기

Transformer는 가중치만 INT8로 내려도 활성(activation) 이 FP16/FP32로 남으면 메모리·대역폭 이득이 제한적입니다. 문제는 활성 분포가 레이어별로 튀고(outlier), 특히 LayerNorm 이후 값이 커져 INT8에서 손실이 커질 수 있다는 점입니다.

여기서 자주 쓰는 트릭이 SmoothQuant 입니다.

  • 아이디어: 활성의 outlier를 가중치 쪽으로 “재분배”해서 활성 양자화를 쉽게 만듦
  • 효과: W8A8(가중치 INT8 + 활성 INT8) 경로를 현실적으로 가능하게 함

개념 스케치(수식 대신 직관)

XW 형태의 연산에서 X의 특정 채널이 너무 크면 INT8 양자화 시 클리핑이 발생합니다. SmoothQuant는 채널별 스케일 s를 도입해

  • X' = X / s
  • W' = W * s

로 바꿔 X'W'는 동일하지만 X'의 분포가 더 “양자화 친화적”이 되게 합니다.

Hugging Face 계열에서의 적용 포인트

프레임워크마다 구현은 다르지만, 실무적으로는 다음을 점검하면 됩니다.

  • 캘리브레이션용 텍스트(프롬프트) 샘플을 충분히 준비
  • 토큰 길이 분포를 실제 트래픽과 맞춤
  • attention보다 MLP(FFN) Linear에서 이득이 큰지 확인

SmoothQuant를 직접 구현하기보다, TensorRT-LLM, ONNX Runtime, Intel Neural Compressor 같은 도구체인을 통해 적용하는 경우가 많습니다. 파이썬 레벨에서 “원리 이해 + 캘리브레이션 관리”가 성패를 가릅니다.

트릭 4) INT4 가중치 양자화: GPTQ·AWQ로 10배급 압축을 현실화

“10배”를 모델 파일 크기 관점에서 가장 직접적으로 노리는 방법은 INT4(4비트) 가중치 양자화입니다.

  • FP16 대비: 2 bytes에서 0.5 bytes4배
  • FP32 대비: 4 bytes에서 0.5 bytes8배
  • 여기에 그룹 양자화, shared scale, packing까지 포함하면 저장 포맷에 따라 체감이 더 커질 수 있습니다.

대표 접근은 두 갈래입니다.

  • GPTQ: 레이어별로 2차 정보(근사)를 활용해 가중치 양자화 오차를 최소화
  • AWQ: activation 분포를 고려해 “중요 채널”을 보호하는 방식(스케일링/클리핑 전략)

Transformers + bitsandbytes 예시(간단 적용)

가장 쉬운 출발점은 bitsandbytes 기반 4비트 로드입니다. 모델에 따라 NF4가 품질이 좋은 편입니다.

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

model_id = "gpt2"  # 예시. 실제로는 4bit 지원/검증된 모델 사용 권장

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.float16,
    bnb_4bit_use_double_quant=True,
)

tok = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    device_map="auto",
    quantization_config=bnb_config,
)

inputs = tok("quantization reduces memory", return_tensors="pt").to(model.device)
with torch.inference_mode():
    out = model.generate(**inputs, max_new_tokens=30)
print(tok.decode(out[0], skip_special_tokens=True))

GPTQ/AWQ를 선택하는 기준

  • 정확도 최우선 + 오프라인 양자화 가능: GPTQ 계열이 강한 경우가 많음
  • 추론 속도/커널 최적화(특히 GPU)와 결합: AWQ가 더 잘 맞는 스택도 많음
  • 어떤 방법이든 “캘리브레이션 프롬프트” 품질이 결과를 크게 좌우합니다.

4비트에서 흔한 함정

  • 커널 미지원이면 오히려 느려질 수 있습니다. 즉 “압축률”과 “지연시간”은 별개입니다.
  • KV cache는 보통 FP16으로 남아 메모리의 다른 축을 차지합니다. 긴 컨텍스트에서 VRAM이 안 줄어드는 느낌이 들 수 있습니다.
  • 배포 환경 CUDA 버전, 드라이버, 라이브러리 조합이 꼬이면 재현이 어려운 장애가 납니다. 운영에서 스트리밍/재시도까지 고려한 패턴은 다음 글이 참고됩니다: OpenAI SSE 스트리밍 끊김·중복 토큰 재시도 패턴

CNN vs Transformer: 어디에 어떤 트릭을 먼저 쓰나

정리하면 다음 우선순위가 실전에서 무난합니다.

CNN

  1. PTQ INT8 + per-channel + 캘리브레이션
  2. 정확도 부족 시 QAT로 최소 재학습
  3. 엣지라면 백엔드(ARM)별 커널 최적화 확인

Transformer

  1. 가중치 INT8 또는 INT4로 먼저 압축(메모리 절감 즉시 체감)
  2. W8A8이 필요하면 SmoothQuant 같은 활성 친화화 기법 검토
  3. 품질이 중요하면 GPTQ/AWQ로 오프라인 양자화 파이프라인 구축

“10배 줄이기”를 숫자로 관리하는 체크리스트

양자화는 감으로 하면 실패합니다. 아래 지표를 함께 기록하세요.

  • 모델 파일 크기: state_dict 저장 용량 또는 safetensors 크기
  • 로딩 후 GPU 메모리: torch.cuda.max_memory_allocated()
  • 지연시간: p50/p95, batch size와 시퀀스 길이별
  • 정확도/품질: CNN은 top-1, LLM은 task eval 또는 최소한 perplexity/샘플링 휴먼 체크

간단한 메모리 측정 예시는 다음처럼 시작할 수 있습니다.

import torch

def report_cuda_mem(tag: str):
    if not torch.cuda.is_available():
        print(f"{tag}: cuda not available")
        return
    torch.cuda.synchronize()
    print(tag, "max_alloc(MB)=", torch.cuda.max_memory_allocated() / 1024 / 1024)

# 모델 로드 전후로 찍어 비교
report_cuda_mem("before")
# ... load model / run inference ...
report_cuda_mem("after")

마무리: 트릭은 4개, 승부는 “데이터·커널·운영”에서 난다

  • 트릭 1(PTQ INT8)은 가장 싸고 빠른 첫 단추이며, per-channel과 캘리브레이션이 품질을 좌우합니다.
  • 트릭 2(QAT)는 PTQ의 정확도 한계를 넘기 위한 보험입니다.
  • 트릭 3(SmoothQuant)은 Transformer에서 활성 양자화를 가능하게 만들어 W8A8 경로를 현실화합니다.
  • 트릭 4(INT4, GPTQ/AWQ)는 10배급 압축을 가장 직접적으로 노리되, 커널/스택 호환성과 품질 검증이 필수입니다.

마지막으로, 양자화된 모델을 서비스에 붙일 때는 “성능이 좋아졌다”로 끝내지 말고, 장애 시 빠르게 원복할 수 있는 배포 단계를 함께 설계하세요. 운영 자동화/진단 루틴을 갖추면 양자화 실험을 훨씬 공격적으로 할 수 있습니다.

추가로, 배포 파이프라인에서 커밋이 꼬이거나 히스토리를 정리해야 한다면 다음 글도 같이 참고해두면 좋습니다: Git rebase 후 강제푸시 없이 PR 정리하는 법