6장: 최적화 및 양자화

효율적인 모델 배포 기술

弘益人間 · 널리 인간을 이롭게 하라

6.1 모델 최적화 소개

모델 최적화는 특히 리소스가 제한된 장치에서 프로덕션 배포에 매우 중요합니다. 최적화 기술은 정확도를 유지하면서 모델 크기를 줄이고, 추론 속도를 개선하며, 메모리 사용량을 줄입니다. 이 장에서는 양자화, 가지치기, 지식 증류 및 하드웨어별 최적화를 다룹니다.

최적화 목표

6.2 양자화 기본

양자화는 모델 가중치와 활성화의 정밀도를 부동소수점(FP32)에서 더 낮은 비트 폭(INT8, INT4)으로 줄입니다:

정밀도 크기/가중치 일반적 사용 정확도 영향
FP32 4 바이트 훈련 기준선 없음 (기준선)
FP16 2 바이트 혼합 정밀도 훈련 최소 (<0.1%)
INT8 1 바이트 엣지 추론 적음 (<1%)
INT4 0.5 바이트 극단적 압축 보통 (1-3%)

양자화 공식

# 선형 양자화 매핑
q = round(r / scale) + zero_point

여기서:
  r = 실제 값 (FP32)
  q = 양자화된 값 (INT8)
  scale = (r_max - r_min) / (q_max - q_min)
  zero_point = q_min - round(r_min / scale)

# 역양자화
r = (q - zero_point) * scale

6.3 후처리 양자화

동적 양자화 (PyTorch)

import torch

# 훈련된 모델 로드
model = torch.load('model.pth')
model.eval()

# 동적 양자화 (가중치만)
quantized_model = torch.quantization.quantize_dynamic(
    model,
    {torch.nn.Linear, torch.nn.LSTM},  # 양자화할 레이어
    dtype=torch.qint8
)

# 양자화된 모델 저장
torch.save(quantized_model.state_dict(), 'quantized_model.pth')

# 추론 테스트
with torch.no_grad():
    output = quantized_model(test_input)

# 모델 크기 비교
import os
original_size = os.path.getsize('model.pth') / 1024 / 1024
quantized_size = os.path.getsize('quantized_model.pth') / 1024 / 1024
print(f"원본: {original_size:.2f} MB")
print(f"양자화: {quantized_size:.2f} MB")
print(f"축소율: {(1 - quantized_size/original_size) * 100:.1f}%")

정적 양자화 (TensorFlow)

import tensorflow as tf
import numpy as np

# 모델 로드
model = tf.keras.models.load_model('model.h5')

# 보정용 대표 데이터셋 생성
def representative_dataset():
    for _ in range(100):
        yield [np.random.randn(1, 224, 224, 3).astype(np.float32)]

# 양자화와 함께 변환
converter = tf.lite.TFLiteConverter.from_keras_model(model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset

# 완전 정수 양자화
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.uint8
converter.inference_output_type = tf.uint8

# 변환
quantized_tflite = converter.convert()

# 저장
with open('quantized_model.tflite', 'wb') as f:
    f.write(quantized_tflite)

6.4 양자화 인식 훈련 (QAT)

더 나은 정확도를 위해 양자화 효과를 시뮬레이션하면서 모델을 훈련합니다:

import torch
import torch.quantization

class QuantizableModel(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.quant = torch.quantization.QuantStub()
        self.conv1 = torch.nn.Conv2d(3, 64, 3)
        self.bn1 = torch.nn.BatchNorm2d(64)
        self.relu = torch.nn.ReLU()
        self.conv2 = torch.nn.Conv2d(64, 128, 3)
        self.bn2 = torch.nn.BatchNorm2d(128)
        self.dequant = torch.quantization.DeQuantStub()

    def forward(self, x):
        x = self.quant(x)
        x = self.relu(self.bn1(self.conv1(x)))
        x = self.relu(self.bn2(self.conv2(x)))
        x = self.dequant(x)
        return x

# QAT용 모델 준비
model = QuantizableModel()
model.train()

# 모듈 융합
model.fuse_model = lambda: torch.quantization.fuse_modules(
    model, [['conv1', 'bn1', 'relu'], ['conv2', 'bn2', 'relu']]
)
model.fuse_model()

# 양자화 구성
model.qconfig = torch.quantization.get_default_qat_qconfig('fbgemm')
torch.quantization.prepare_qat(model, inplace=True)

# 가짜 양자화로 훈련
for epoch in range(num_epochs):
    for data, target in train_loader:
        optimizer.zero_grad()
        output = model(data)
        loss = criterion(output, target)
        loss.backward()
        optimizer.step()

# 양자화된 모델로 변환
model.eval()
quantized_model = torch.quantization.convert(model, inplace=False)

6.5 모델 가지치기

불필요한 가중치를 제거하여 모델 크기와 계산량을 줄입니다:

크기 기반 가지치기

import torch
import torch.nn.utils.prune as prune

model = MyModel()

# Conv 레이어의 가중치 40% 가지치기
for name, module in model.named_modules():
    if isinstance(module, torch.nn.Conv2d):
        prune.l1_unstructured(module, name='weight', amount=0.4)

# 가지치기 영구 적용
for module in model.modules():
    if isinstance(module, torch.nn.Conv2d):
        prune.remove(module, 'weight')

# 전역 가지치기 (모든 레이어에 걸쳐)
parameters_to_prune = [
    (module, 'weight') for module in model.modules()
    if isinstance(module, (torch.nn.Conv2d, torch.nn.Linear))
]

prune.global_unstructured(
    parameters_to_prune,
    pruning_method=prune.L1Unstructured,
    amount=0.5  # 50% 전역 희소성
)

# 희소성 확인
def check_sparsity(model):
    zeros = 0
    elements = 0
    for param in model.parameters():
        zeros += torch.sum(param == 0).item()
        elements += param.nelement()
    return zeros / elements

print(f"희소성: {check_sparsity(model) * 100:.2f}%")

6.6 지식 증류

작은 학생 모델을 훈련시켜 큰 교사 모델을 모방하게 합니다:

import torch
import torch.nn as nn
import torch.nn.functional as F

class DistillationLoss(nn.Module):
    """증류 손실 함수"""

    def __init__(self, temperature=3.0, alpha=0.5):
        super().__init__()
        self.temperature = temperature  # 온도
        self.alpha = alpha              # 가중치
        self.ce_loss = nn.CrossEntropyLoss()

    def forward(self, student_logits, teacher_logits, labels):
        # 소프트 타겟 손실 (KL 발산)
        soft_targets = F.softmax(teacher_logits / self.temperature, dim=1)
        soft_prob = F.log_softmax(student_logits / self.temperature, dim=1)
        soft_loss = -torch.sum(soft_targets * soft_prob) / soft_prob.size(0)
        soft_loss *= (self.temperature ** 2)

        # 하드 타겟 손실
        hard_loss = self.ce_loss(student_logits, labels)

        # 결합 손실
        return self.alpha * soft_loss + (1 - self.alpha) * hard_loss

# 훈련 루프
teacher_model = LargeModel()  # 사전 훈련됨
teacher_model.eval()

student_model = SmallModel()
criterion = DistillationLoss(temperature=4.0, alpha=0.7)
optimizer = torch.optim.Adam(student_model.parameters())

for epoch in range(num_epochs):
    for images, labels in train_loader:
        # 교사 예측 (기울기 없음)
        with torch.no_grad():
            teacher_logits = teacher_model(images)

        # 학생 예측
        student_logits = student_model(images)

        # 증류 손실
        loss = criterion(student_logits, teacher_logits, labels)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

6.7 그래프 최적화

연산자 융합

# ONNX Runtime 최적화
import onnxruntime as ort

# 최적화와 함께 세션 구성
sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = \
    ort.GraphOptimizationLevel.ORT_ENABLE_ALL

# 최적화 포함:
# - Conv + BatchNorm 융합
# - Conv + ReLU 융합
# - Gemm 융합
# - Dropout 제거 (추론)
# - 상수 폴딩

session = ort.InferenceSession(
    "model.onnx",
    sess_options,
    providers=['CPUExecutionProvider']
)

# 최적화된 모델 저장
sess_options.optimized_model_filepath = "optimized_model.onnx"
session = ort.InferenceSession("model.onnx", sess_options)

6.8 하드웨어별 최적화

NVIDIA TensorRT

import tensorrt as trt
import pycuda.driver as cuda
import pycuda.autoinit

# TensorRT 빌더 생성
logger = trt.Logger(trt.Logger.WARNING)
builder = trt.Builder(logger)
network = builder.create_network(
    1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
)

# ONNX 모델 파싱
parser = trt.OnnxParser(network, logger)
with open('model.onnx', 'rb') as model:
    parser.parse(model.read())

# 빌더 구성
config = builder.create_builder_config()
config.max_workspace_size = 1 << 30  # 1GB
config.set_flag(trt.BuilderFlag.FP16)  # FP16 활성화

# 엔진 빌드
engine = builder.build_engine(network, config)

# 직렬화 및 저장
with open('model.trt', 'wb') as f:
    f.write(engine.serialize())

Intel OpenVINO

from openvino.runtime import Core

# OpenVINO 초기화
ie = Core()

# ONNX 모델 읽기
model = ie.read_model('model.onnx')

# CPU용 최적화와 함께 컴파일
compiled_model = ie.compile_model(model, 'CPU')

# 추론 요청 생성
infer_request = compiled_model.create_infer_request()

# 추론 실행
import numpy as np
input_data = np.random.randn(1, 3, 224, 224).astype(np.float32)
results = infer_request.infer({0: input_data})
output = results[0]

6.9 벤치마킹 및 프로파일링

import torch
import time
import numpy as np

class ModelBenchmark:
    """모델 성능 벤치마크 도구"""

    def __init__(self, model, input_shape):
        self.model = model
        self.input_shape = input_shape
        self.model.eval()

    def benchmark(self, num_runs=100, warmup=10):
        # 웜업
        for _ in range(warmup):
            with torch.no_grad():
                _ = self.model(torch.randn(*self.input_shape))

        # 벤치마크
        times = []
        for _ in range(num_runs):
            input_data = torch.randn(*self.input_shape)

            start = time.perf_counter()
            with torch.no_grad():
                _ = self.model(input_data)
            end = time.perf_counter()

            times.append((end - start) * 1000)  # ms

        return {
            'mean': np.mean(times),
            'std': np.std(times),
            'min': np.min(times),
            'max': np.max(times),
            'p50': np.percentile(times, 50),
            'p95': np.percentile(times, 95),
            'p99': np.percentile(times, 99)
        }

# 사용 예
benchmark = ModelBenchmark(model, (1, 3, 224, 224))
results = benchmark.benchmark()
print(f"추론 시간: {results['mean']:.2f}ms ± {results['std']:.2f}ms")
print(f"P95 지연시간: {results['p95']:.2f}ms")

6.10 트레이드오프 분석

최적화 결정 매트릭스

요약

복습 문제

  1. FP32를 INT8로 변환하는 양자화 공식을 설명하세요.
  2. 동적 양자화와 정적 양자화의 차이점은 무엇인가요?
  3. PyTorch에서 후처리 양자화를 적용하는 코드를 작성하세요.
  4. 양자화 인식 훈련이 정확도를 향상시키는 방법은?
  5. 크기 기반 가지치기와 구조적 가지치기를 비교하세요.
  6. 지식 증류 훈련 프로세스를 설명하세요.
  7. 연산자 융합이 제공하는 그래프 최적화는 무엇인가요?
  8. TensorRT를 사용하여 NVIDIA GPU용 모델을 최적화하는 방법은?
  9. 추론 지연 시간을 측정하는 벤치마킹 스크립트를 작성하세요.
  10. 다양한 최적화 기술 간의 트레이드오프를 분석하세요.
弘益人間 (홍익인간) · 널리 인간을 이롭게 하라