- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며
딥러닝 모델이 점점 거대해지면서 추론(Inference) 비용과 메모리 요구량이 폭발적으로 증가했습니다. GPT-3는 175B 파라미터, Llama 3는 70B 파라미터에 달하며, FP32 전정밀도(Full Precision)로 저장하면 각각 700GB, 280GB의 메모리가 필요합니다. 일반 GPU로는 실행조차 불가능한 수준입니다.
**모델 양자화(Model Quantization)**는 이 문제를 해결하는 핵심 기술입니다. 32비트 부동소수점(FP32) 가중치를 8비트, 4비트 정수로 압축하여 메모리를 48배 줄이고 추론 속도를 24배 높입니다. 품질 손실은 놀랍도록 적습니다.
이 글에서는 양자화의 수학적 원리부터 GPTQ, AWQ, GGUF, bitsandbytes 같은 최신 기법까지 완전히 파헤칩니다.
1. 양자화 기초: 수 표현 방식 이해
1.1 부동소수점 표현 (Floating Point)
현대 딥러닝에서 사용되는 부동소수점 형식을 이해하는 것이 양자화의 시작입니다.
FP32 (Float32)
- 부호(1비트) + 지수(8비트) + 가수(23비트) = 총 32비트
- 표현 범위: 약 -3.4e38 ~ 3.4e38
- 정밀도: 약 7자리 소수
FP16 (Float16)
- 부호(1비트) + 지수(5비트) + 가수(10비트) = 총 16비트
- 표현 범위: -65504 ~ 65504 (FP32 대비 훨씬 좁음)
- 정밀도: 약 3자리 소수
- 오버플로우 위험이 있어 학습 시 gradient scaling 필요
BF16 (Brain Float16)
- 부호(1비트) + 지수(8비트) + 가수(7비트) = 총 16비트
- FP32와 동일한 지수 범위를 유지하면서 가수만 줄임
- 오버플로우 위험 없음, 딥러닝 학습에 더 안전
- Google Brain에서 개발, 최신 GPU(A100, H100)에서 네이티브 지원
import torch
import numpy as np
# 각 데이터 타입의 메모리 크기 확인
x_fp32 = torch.tensor([1.5, -2.3, 0.7], dtype=torch.float32)
x_fp16 = torch.tensor([1.5, -2.3, 0.7], dtype=torch.float16)
x_bf16 = torch.tensor([1.5, -2.3, 0.7], dtype=torch.bfloat16)
print(f"FP32: {x_fp32.element_size()} bytes per element") # 4 bytes
print(f"FP16: {x_fp16.element_size()} bytes per element") # 2 bytes
print(f"BF16: {x_bf16.element_size()} bytes per element") # 2 bytes
# 모델 메모리 계산 예시 (7B 파라미터 모델)
params = 7e9
fp32_memory_gb = params * 4 / 1e9
fp16_memory_gb = params * 2 / 1e9
int8_memory_gb = params * 1 / 1e9
int4_memory_gb = params * 0.5 / 1e9
print(f"\n7B 모델 메모리 요구량:")
print(f"FP32: {fp32_memory_gb:.1f} GB") # 28.0 GB
print(f"FP16: {fp16_memory_gb:.1f} GB") # 14.0 GB
print(f"INT8: {int8_memory_gb:.1f} GB") # 7.0 GB
print(f"INT4: {int4_memory_gb:.1f} GB") # 3.5 GB
1.2 정수형 표현 (Integer)
양자화의 핵심은 부동소수점 값을 정수로 매핑하는 것입니다.
INT8: -128 ~ 127 (부호 있음) 또는 0 ~ 255 (부호 없음) INT4: -8 ~ 7 (부호 있음) 또는 0 ~ 15 (부호 없음) INT2: -2 ~ 1 (부호 있음) 또는 0 ~ 3 (부호 없음)
1.3 양자화 수식
부동소수점 값 x를 정수 q로 변환하는 기본 수식:
q = clamp(round(x / scale) + zero_point, q_min, q_max)
역양자화 (Dequantization):
x_approx = scale * (q - zero_point)
여기서:
- scale: 양자화 스케일 팩터 (scale = (max_val - min_val) / (q_max - q_min))
- zero_point: 정수 0이 나타내는 실수 값의 오프셋
- q_min, q_max: 정수 범위 (-128, 127 for INT8)
import torch
import numpy as np
def symmetric_quantize(x: torch.Tensor, num_bits: int = 8):
"""대칭 양자화 구현"""
q_max = 2 ** (num_bits - 1) - 1 # INT8의 경우 127
q_min = -q_max # -127
# 스케일 계산
max_abs = x.abs().max()
scale = max_abs / q_max
# 양자화
q = torch.clamp(torch.round(x / scale), q_min, q_max).to(torch.int8)
return q, scale
def asymmetric_quantize(x: torch.Tensor, num_bits: int = 8):
"""비대칭 양자화 구현"""
q_max = 2 ** num_bits - 1 # UINT8의 경우 255
q_min = 0
# 스케일과 zero_point 계산
min_val = x.min()
max_val = x.max()
scale = (max_val - min_val) / (q_max - q_min)
zero_point = q_min - torch.round(min_val / scale)
zero_point = torch.clamp(zero_point, q_min, q_max).to(torch.int32)
# 양자화
q = torch.clamp(torch.round(x / scale) + zero_point, q_min, q_max).to(torch.uint8)
return q, scale, zero_point
def dequantize(q: torch.Tensor, scale: torch.Tensor, zero_point: torch.Tensor = None):
"""역양자화"""
if zero_point is None:
return scale * q.float()
return scale * (q.float() - zero_point.float())
# 테스트
x = torch.randn(100)
print(f"원본 데이터 범위: [{x.min():.4f}, {x.max():.4f}]")
# 대칭 양자화
q_sym, scale_sym = symmetric_quantize(x)
x_reconstructed_sym = dequantize(q_sym, scale_sym)
error_sym = (x - x_reconstructed_sym).abs().mean()
print(f"대칭 양자화 평균 오차: {error_sym:.6f}")
# 비대칭 양자화
q_asym, scale_asym, zp_asym = asymmetric_quantize(x)
x_reconstructed_asym = dequantize(q_asym, scale_asym, zp_asym)
error_asym = (x - x_reconstructed_asym).abs().mean()
print(f"비대칭 양자화 평균 오차: {error_asym:.6f}")
1.4 대칭 vs 비대칭 양자화
대칭 양자화(Symmetric Quantization)
- zero_point = 0
- 양수/음수 범위가 대칭
- 가중치 양자화에 적합 (대부분 0 중심 분포)
- 연산이 단순: x_approx = scale * q
비대칭 양자화(Asymmetric Quantization)
- zero_point != 0
- 임의의 범위 표현 가능
- 활성화 양자화에 적합 (ReLU 이후 항상 양수)
- 연산이 복잡: x_approx = scale * (q - zero_point)
1.5 양자화 그래뉼레이티 (Quantization Granularity)
같은 scale/zero_point를 얼마나 많은 파라미터에 적용할지 결정합니다.
Per-Tensor: 전체 텐서에 하나의 scale 사용
- 메모리 오버헤드 최소
- 정밀도 손실 가장 큼
Per-Channel (Per-Row/Column): 각 채널마다 개별 scale
- 가중치 행렬의 각 행/열에 별도 scale
- 채널별 분포 차이를 효과적으로 처리
Per-Group (Per-Block): 일정 크기 그룹마다 개별 scale
- group_size = 128 이 일반적
- Per-Channel과 Per-Tensor의 절충점
- GPTQ, AWQ에서 주로 사용
import torch
def per_group_quantize(weight: torch.Tensor, group_size: int = 128, num_bits: int = 4):
"""Per-Group 양자화 구현"""
rows, cols = weight.shape
# 그룹 단위로 분할
weight_grouped = weight.reshape(-1, group_size)
# 각 그룹의 최대/최소값
max_vals = weight_grouped.max(dim=1, keepdim=True)[0]
min_vals = weight_grouped.min(dim=1, keepdim=True)[0]
q_max = 2 ** num_bits - 1 # 15 for INT4
# 스케일 계산
scales = (max_vals - min_vals) / q_max
zero_points = torch.round(-min_vals / scales)
# 양자화
q = torch.clamp(torch.round(weight_grouped / scales) + zero_points, 0, q_max)
# 역양자화
weight_dequant = scales * (q - zero_points)
weight_dequant = weight_dequant.reshape(rows, cols)
return q, scales, zero_points, weight_dequant
# 예시: Transformer 가중치 양자화
weight = torch.randn(4096, 4096) # Llama-style weight
q, scales, zp, weight_dequant = per_group_quantize(weight, group_size=128, num_bits=4)
error = (weight - weight_dequant).abs().mean()
print(f"Per-Group INT4 양자화 평균 오차: {error:.6f}")
print(f"압축률: {weight.element_size() * weight.numel() / (q.numel() / 2 + scales.numel() * 4):.2f}x")
2. Post-Training Quantization (PTQ)
PTQ는 이미 학습된 모델을 재학습 없이 양자화하는 방법입니다. 실용성이 높아 가장 많이 사용됩니다.
2.1 보정 데이터 (Calibration Dataset)
PTQ는 소량의 보정 데이터를 사용하여 적절한 scale/zero_point를 결정합니다.
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
from datasets import load_dataset
def collect_calibration_data(model_name: str, num_samples: int = 128):
"""보정 데이터 수집"""
tokenizer = AutoTokenizer.from_pretrained(model_name)
# WikiText-2 또는 C4 데이터셋 사용이 일반적
dataset = load_dataset("wikitext", "wikitext-2-raw-v1", split="train")
texts = []
for item in dataset:
if len(item['text'].strip()) > 100:
texts.append(item['text'].strip())
if len(texts) >= num_samples:
break
# 토크나이즈
encoded = [
tokenizer(text, return_tensors="pt", max_length=2048, truncation=True)
for text in texts
]
return encoded
# 보정 데이터로 활성화 통계 수집
def collect_activation_stats(model, calibration_data, layer_name: str):
"""특정 레이어의 활성화 통계 수집"""
stats = {"min": float("inf"), "max": float("-inf"), "histogram": []}
def hook_fn(module, input, output):
with torch.no_grad():
act = output.detach().float()
stats["min"] = min(stats["min"], act.min().item())
stats["max"] = max(stats["max"], act.max().item())
# 훅 등록
target_layer = dict(model.named_modules())[layer_name]
handle = target_layer.register_forward_hook(hook_fn)
# 보정 데이터 실행
model.eval()
with torch.no_grad():
for batch in calibration_data[:32]:
model(**batch)
handle.remove()
return stats
2.2 최소-최대 보정 (Min-Max Calibration)
가장 단순한 방법으로, 보정 데이터 전체의 최솟값과 최댓값을 사용합니다.
class MinMaxCalibrator:
"""최소-최대 보정기"""
def __init__(self):
self.min_val = float("inf")
self.max_val = float("-inf")
def update(self, tensor: torch.Tensor):
self.min_val = min(self.min_val, tensor.min().item())
self.max_val = max(self.max_val, tensor.max().item())
def compute_scale_zp(self, num_bits: int = 8, symmetric: bool = True):
q_max = 2 ** (num_bits - 1) - 1 if symmetric else 2 ** num_bits - 1
if symmetric:
max_abs = max(abs(self.min_val), abs(self.max_val))
scale = max_abs / q_max
zero_point = 0
else:
scale = (self.max_val - self.min_val) / q_max
zero_point = -round(self.min_val / scale)
return scale, zero_point
2.3 히스토그램 보정 (Histogram Calibration)
아웃라이어의 영향을 줄이기 위해 분포 히스토그램을 기반으로 최적 범위를 찾습니다.
import numpy as np
from scipy import stats
class HistogramCalibrator:
"""히스토그램 기반 보정기 (KL Divergence 최소화)"""
def __init__(self, num_bins: int = 2048):
self.num_bins = num_bins
self.histogram = None
self.bin_edges = None
def update(self, tensor: torch.Tensor):
data = tensor.detach().float().numpy().flatten()
if self.histogram is None:
self.histogram, self.bin_edges = np.histogram(data, bins=self.num_bins)
else:
new_hist, _ = np.histogram(data, bins=self.bin_edges)
self.histogram += new_hist
def compute_optimal_range(self, num_bits: int = 8):
"""KL Divergence를 최소화하는 최적 범위 탐색"""
num_quantized_bins = 2 ** num_bits - 1
best_kl = float("inf")
best_threshold = None
# 다양한 threshold 탐색
for i in range(num_quantized_bins, len(self.histogram)):
# 히스토그램을 num_quantized_bins 개로 압축
reference = self.histogram[:i].copy().astype(float)
reference /= reference.sum()
# KL Divergence 계산 (근사)
quantized = np.zeros(i)
bin_size = i / num_quantized_bins
for j in range(num_quantized_bins):
start = int(j * bin_size)
end = int((j + 1) * bin_size)
quantized[start:end] = reference[start:end].sum() / (end - start)
# 0인 구간 처리
quantized = np.where(quantized == 0, 1e-10, quantized)
reference_clipped = np.where(reference == 0, 1e-10, reference)
kl = stats.entropy(reference_clipped, quantized)
if kl < best_kl:
best_kl = kl
best_threshold = self.bin_edges[i]
return -best_threshold, best_threshold
2.4 퍼플렉시티에 미치는 영향
양자화 품질을 측정하는 가장 일반적인 지표는 퍼플렉시티(Perplexity, PPL)입니다.
import torch
import math
from transformers import AutoModelForCausalLM, AutoTokenizer
def compute_perplexity(model, tokenizer, text: str, device: str = "cuda"):
"""퍼플렉시티 계산"""
encodings = tokenizer(text, return_tensors="pt")
input_ids = encodings.input_ids.to(device)
max_length = 1024
stride = 512
nlls = []
prev_end_loc = 0
for begin_loc in range(0, input_ids.size(1), stride):
end_loc = min(begin_loc + max_length, input_ids.size(1))
trg_len = end_loc - prev_end_loc
input_ids_chunk = input_ids[:, begin_loc:end_loc]
target_ids = input_ids_chunk.clone()
target_ids[:, :-trg_len] = -100
with torch.no_grad():
outputs = model(input_ids_chunk, labels=target_ids)
neg_log_likelihood = outputs.loss
nlls.append(neg_log_likelihood)
prev_end_loc = end_loc
if end_loc == input_ids.size(1):
break
ppl = torch.exp(torch.stack(nlls).mean())
return ppl.item()
# 모델별 PPL 비교 예시
# FP16: PPL ≈ 5.68
# INT8: PPL ≈ 5.71 (약 0.5% 증가)
# INT4 (GPTQ): PPL ≈ 5.89 (약 3.7% 증가)
# INT4 (naive): PPL ≈ 6.52 (약 14.8% 증가)
3. Quantization-Aware Training (QAT)
QAT는 학습 중에 양자화를 시뮬레이션하여 모델이 양자화 노이즈에 적응하도록 합니다.
3.1 가짜 양자화 (Fake Quantization)
실제 INT8 연산 대신 FP32로 양자화 효과를 시뮬레이션합니다.
import torch
import torch.nn as nn
import torch.nn.functional as F
class FakeQuantize(nn.Module):
"""가짜 양자화 모듈"""
def __init__(self, num_bits: int = 8, symmetric: bool = True):
super().__init__()
self.num_bits = num_bits
self.symmetric = symmetric
self.register_buffer('scale', torch.tensor(1.0))
self.register_buffer('zero_point', torch.tensor(0))
self.register_buffer('fake_quant_enabled', torch.tensor(1))
if symmetric:
self.q_min = -(2 ** (num_bits - 1))
self.q_max = 2 ** (num_bits - 1) - 1
else:
self.q_min = 0
self.q_max = 2 ** num_bits - 1
def forward(self, x: torch.Tensor) -> torch.Tensor:
if self.fake_quant_enabled[0] == 0:
return x
# 스케일 업데이트 (이동 평균)
if self.training:
with torch.no_grad():
if self.symmetric:
max_abs = x.abs().max()
new_scale = max_abs / self.q_max
else:
new_scale = (x.max() - x.min()) / (self.q_max - self.q_min)
# 지수 이동 평균으로 스케일 업데이트
self.scale.copy_(0.9 * self.scale + 0.1 * new_scale)
# 가짜 양자화: 양자화 후 역양자화
x_scaled = x / self.scale
x_clipped = torch.clamp(x_scaled, self.q_min, self.q_max)
x_rounded = torch.round(x_clipped)
x_dequant = x_rounded * self.scale
return x_dequant
### 3.2 STE (Straight-Through Estimator)
```python
class STERound(torch.autograd.Function):
"""Straight-Through Estimator for round()"""
@staticmethod
def forward(ctx, x):
return torch.round(x)
@staticmethod
def backward(ctx, grad_output):
# 역전파 시 round()를 통과하여 gradient 전달 (항등함수로 근사)
return grad_output
class STEClamp(torch.autograd.Function):
"""Straight-Through Estimator for clamp()"""
@staticmethod
def forward(ctx, x, min_val, max_val):
ctx.save_for_backward(x)
ctx.min_val = min_val
ctx.max_val = max_val
return torch.clamp(x, min_val, max_val)
@staticmethod
def backward(ctx, grad_output):
x, = ctx.saved_tensors
# clamp 범위 내에서만 gradient 전달
grad = grad_output * ((x >= ctx.min_val) & (x <= ctx.max_val)).float()
return grad, None, None
class QATLinear(nn.Module):
"""QAT를 적용한 Linear 레이어"""
def __init__(self, in_features, out_features, num_bits=8):
super().__init__()
self.linear = nn.Linear(in_features, out_features)
self.weight_fake_quant = FakeQuantize(num_bits=num_bits)
self.act_fake_quant = FakeQuantize(num_bits=num_bits, symmetric=False)
def forward(self, x):
# 활성화 양자화
x_q = self.act_fake_quant(x)
# 가중치 양자화
w_q = self.weight_fake_quant(self.linear.weight)
# FP32 연산 (실제로는 INT8)
return F.linear(x_q, w_q, self.linear.bias)
3.3 언제 QAT가 필요한가?
- PTQ로 품질 손실이 너무 클 때: 특히 작은 모델(BERT-small 등)에서 효과적
- INT4 이하로 양자화할 때: 극단적인 압축에서 품질 유지에 필수
- 특수 태스크: Object detection, ASR 등 정밀도에 민감한 태스크
# QAT 학습 워크플로
import torch.optim as optim
from torch.quantization import prepare_qat, convert
def train_qat_model(model, train_loader, num_epochs=10):
"""QAT 학습 예시"""
# QAT 준비
model.qconfig = torch.quantization.get_default_qat_qconfig('fbgemm')
model_prepared = prepare_qat(model.train())
optimizer = optim.Adam(model_prepared.parameters(), lr=1e-5)
for epoch in range(num_epochs):
for batch in train_loader:
inputs, labels = batch
outputs = model_prepared(inputs)
loss = F.cross_entropy(outputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# INT8 모델로 변환
model_prepared.eval()
model_quantized = convert(model_prepared)
return model_quantized
4. PyTorch 양자화 API
4.1 torch.ao.quantization
PyTorch의 공식 양자화 API입니다.
import torch
from torch.ao.quantization import (
get_default_qconfig,
get_default_qat_qconfig,
prepare,
prepare_qat,
convert
)
# 정적 양자화 (PTQ)
def static_quantization_example():
"""정적 양자화 예시"""
model = MyModel()
model.eval()
# 백엔드 설정 (fbgemm: x86, qnnpack: ARM)
model.qconfig = get_default_qconfig('fbgemm')
# 보정 준비
model_prepared = prepare(model)
# 보정 데이터로 통계 수집
with torch.no_grad():
for data in calibration_loader:
model_prepared(data)
# INT8 모델로 변환
model_quantized = convert(model_prepared)
return model_quantized
# 동적 양자화 (LSTM, Linear에 효과적)
def dynamic_quantization_example():
"""동적 양자화 예시"""
model = MyModel()
model_quantized = torch.quantization.quantize_dynamic(
model,
{nn.Linear, nn.LSTM}, # 양자화할 레이어 타입
dtype=torch.qint8
)
return model_quantized
4.2 FX Graph Mode Quantization
더 유연하고 강력한 양자화 방법입니다.
from torch.ao.quantization.quantize_fx import prepare_fx, convert_fx
from torch.ao.quantization import QConfigMapping
def fx_quantization_example(model, calibration_data):
"""FX Graph Mode 양자화"""
model.eval()
# QConfig 설정
qconfig_mapping = QConfigMapping().set_global(
get_default_qconfig('fbgemm')
)
# 예시 입력
example_inputs = (torch.randn(1, 3, 224, 224),)
# FX 그래프 기반 준비
model_prepared = prepare_fx(
model,
qconfig_mapping,
example_inputs
)
# 보정
with torch.no_grad():
for batch in calibration_data:
model_prepared(batch)
# 변환
model_quantized = convert_fx(model_prepared)
return model_quantized
5. GPTQ: Accurate Post-Training Quantization
GPTQ는 2022년 발표된 LLM 특화 양자화 알고리즘으로, INT4 양자화에서도 품질 손실을 최소화합니다. (arXiv:2209.05433)
5.1 GPTQ 알고리즘 원리
GPTQ는 OBQ(Optimal Brain Quantization)를 기반으로 합니다. 핵심 아이디어는 레이어별로 가중치를 순차적으로 양자화하면서, 이미 양자화된 가중치의 오차를 나머지 가중치에 보정하는 것입니다.
OBQ 오차 최소화 목적함수:
argmin_Q ||WX - QX||_F^2
여기서 W는 원본 가중치, Q는 양자화된 가중치, X는 입력 활성화입니다.
헤시안 기반 가중치 업데이트:
각 가중치를 양자화한 후 발생하는 오차를 헤시안 역행렬 H^(-1)을 이용해 나머지 가중치에 전파합니다.
# GPTQ 핵심 알고리즘 구현 (단순화 버전)
import torch
import math
def gptq_quantize_weight(weight: torch.Tensor,
hessian: torch.Tensor,
num_bits: int = 4,
group_size: int = 128,
damp_percent: float = 0.01):
"""
GPTQ 알고리즘으로 가중치 양자화
Args:
weight: [out_features, in_features] 가중치 행렬
hessian: [in_features, in_features] 헤시안 행렬 (H = 2 * X @ X.T)
num_bits: 양자화 비트 수
group_size: 그룹 크기
damp_percent: 헤시안 안정화를 위한 댐핑 비율
"""
W = weight.clone().float()
n_rows, n_cols = W.shape
# 헤시안 댐핑 (수치 안정성)
H = hessian.clone().float()
dead_cols = torch.diag(H) == 0
H[dead_cols, dead_cols] = 1
W[:, dead_cols] = 0
damp = damp_percent * H.diag().mean()
H.diagonal().add_(damp)
# 헤시안 역행렬 (Cholesky 분해 이용)
H_inv = torch.linalg.cholesky(H)
H_inv = torch.cholesky_inverse(H_inv)
H_inv = torch.linalg.cholesky(H_inv, upper=True)
Q = torch.zeros_like(W)
Losses = torch.zeros_like(W)
q_max = 2 ** (num_bits - 1) - 1
for col_idx in range(n_cols):
w_col = W[:, col_idx] # 현재 컬럼의 가중치
h_inv_diag = H_inv[col_idx, col_idx] # 헤시안 역행렬의 대각 요소
# 그룹별 스케일 계산
if group_size != -1 and col_idx % group_size == 0:
group_end = min(col_idx + group_size, n_cols)
w_group = W[:, col_idx:group_end]
max_abs = w_group.abs().max(dim=1)[0].unsqueeze(1)
scale = max_abs / q_max
scale = torch.clamp(scale, min=1e-8)
# 양자화
q_col = torch.clamp(torch.round(w_col / scale.squeeze()), -q_max, q_max)
q_col = q_col * scale.squeeze()
Q[:, col_idx] = q_col
# 양자화 오차
err = (w_col - q_col) / h_inv_diag
Losses[:, col_idx] = err ** 2 / 2
# 오차를 나머지 가중치에 전파 (핵심!)
W[:, col_idx + 1:] -= err.unsqueeze(1) * H_inv[col_idx, col_idx + 1:].unsqueeze(0)
return Q, Losses
def collect_hessian(model_layer, calibration_data, device='cuda'):
"""보정 데이터로 헤시안 수집"""
hessians = {}
def make_hook(name):
def hook(module, input, output):
inp = input[0].detach().float()
if inp.dim() == 3:
inp = inp.reshape(-1, inp.size(-1))
if name not in hessians:
hessians[name] = torch.zeros(inp.size(1), inp.size(1), device=device)
hessians[name] += 2 * inp.T @ inp
return hook
handles = []
for name, module in model_layer.named_modules():
if isinstance(module, torch.nn.Linear):
handles.append(module.register_forward_hook(make_hook(name)))
with torch.no_grad():
for batch in calibration_data:
model_layer(batch.to(device))
for h in handles:
h.remove()
return hessians
5.2 AutoGPTQ 사용법
실용적인 GPTQ 양자화는 AutoGPTQ 라이브러리를 사용합니다.
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig
from transformers import AutoTokenizer
import torch
def quantize_with_gptq(
model_name: str,
output_dir: str,
bits: int = 4,
group_size: int = 128
):
"""AutoGPTQ로 모델 양자화"""
tokenizer = AutoTokenizer.from_pretrained(model_name, use_fast=True)
# 양자화 설정
quantize_config = BaseQuantizeConfig(
bits=bits, # 4 또는 8
group_size=group_size, # 128 권장
damp_percent=0.01, # 헤시안 댐핑
desc_act=False, # 활성화 재정렬 (품질 향상, 속도 감소)
sym=True, # 대칭 양자화
true_sequential=True # 순차적 레이어 양자화
)
# 모델 로드
model = AutoGPTQForCausalLM.from_pretrained(
model_name,
quantize_config=quantize_config
)
# 보정 데이터 준비
from datasets import load_dataset
dataset = load_dataset("wikitext", "wikitext-2-raw-v1", split="train")
calibration_data = []
for text in dataset["text"][:128]:
if len(text.strip()) > 50:
encoded = tokenizer(
text.strip(),
return_tensors="pt",
max_length=2048,
truncation=True
)
calibration_data.append(encoded["input_ids"].squeeze())
# GPTQ 양자화 실행
print(f"GPTQ {bits}bit 양자화 시작...")
model.quantize(calibration_data)
# 저장
model.save_quantized(output_dir, use_safetensors=True)
tokenizer.save_pretrained(output_dir)
print(f"양자화 완료: {output_dir}")
return model, tokenizer
def load_gptq_model(model_dir: str, device: str = "cuda"):
"""GPTQ 양자화 모델 로드"""
model = AutoGPTQForCausalLM.from_quantized(
model_dir,
device=device,
use_triton=False, # Triton 커널 사용 여부
disable_exllama=False, # ExLlama 커널 사용 (속도 향상)
inject_fused_attention=True,
inject_fused_mlp=True
)
tokenizer = AutoTokenizer.from_pretrained(model_dir)
return model, tokenizer
# 사용 예시
# model, tokenizer = quantize_with_gptq("meta-llama/Llama-2-7b-hf", "./llama2-7b-gptq-4bit")
# model, tokenizer = load_gptq_model("./llama2-7b-gptq-4bit")
6. AWQ: Activation-aware Weight Quantization
AWQ는 2023년 발표된 기법으로, 활성화 분포를 분석하여 중요한 가중치 채널을 보호합니다. (arXiv:2306.00978)
6.1 GPTQ와의 차이
| 항목 | GPTQ | AWQ |
|---|---|---|
| 접근 방식 | 헤시안 기반 오차 보정 | 활성화 기반 스케일링 |
| 보정 데이터 | 필요 (128+ 샘플) | 필요 (32+ 샘플) |
| 속도 | 느림 (1-4시간) | 빠름 (수십 분) |
| 품질 | 우수 | 우수 (비슷하거나 더 좋음) |
| 특징 | 채널별 최적화 | 활성화 아웃라이어 처리 |
6.2 AWQ 핵심 아이디어
LLM 가중치에는 중요한 채널이 존재합니다. 이 채널들의 활성화 크기가 크며, 양자화 시 오차가 크면 전체 성능에 큰 영향을 미칩니다. AWQ는 중요 채널의 가중치를 스케일 팩터로 확대하여 양자화 오차를 줄입니다.
from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer
def quantize_with_awq(
model_name: str,
output_dir: str,
bits: int = 4,
group_size: int = 128
):
"""AutoAWQ로 모델 양자화"""
tokenizer = AutoTokenizer.from_pretrained(
model_name,
trust_remote_code=True
)
model = AutoAWQForCausalLM.from_pretrained(
model_name,
low_cpu_mem_usage=True,
use_cache=False
)
# AWQ 양자화 설정
quant_config = {
"zero_point": True, # 비대칭 양자화
"q_group_size": group_size,
"w_bit": bits,
"version": "GEMM" # GEMM 또는 GEMV (작은 배치에 최적)
}
# 양자화 실행
print(f"AWQ {bits}bit 양자화 시작...")
model.quantize(tokenizer, quant_config=quant_config)
# 저장
model.save_quantized(output_dir)
tokenizer.save_pretrained(output_dir)
print(f"AWQ 양자화 완료: {output_dir}")
return model
def load_awq_model(model_dir: str, device: str = "cuda"):
"""AWQ 양자화 모델 로드"""
model = AutoAWQForCausalLM.from_quantized(
model_dir,
fuse_layers=True, # 레이어 퓨전으로 속도 향상
trust_remote_code=True,
safetensors=True
)
tokenizer = AutoTokenizer.from_pretrained(model_dir)
return model, tokenizer
# Hugging Face transformers와 통합
from transformers import AutoModelForCausalLM
def load_awq_with_transformers(model_dir: str):
"""transformers로 AWQ 모델 로드"""
model = AutoModelForCausalLM.from_pretrained(
model_dir,
device_map="auto"
)
tokenizer = AutoTokenizer.from_pretrained(model_dir)
return model, tokenizer
7. GGUF/GGML: llama.cpp 생태계
GGUF(GPT-Generated Unified Format)는 llama.cpp 프로젝트의 모델 포맷으로, CPU에서도 효율적으로 LLM을 실행할 수 있습니다.
7.1 GGUF 포맷 이해
GGUF는 2023년에 GGML 포맷을 대체하여 도입되었습니다. 모델 메타데이터, 하이퍼파라미터, 토크나이저 정보를 단일 파일에 포함합니다.
GGUF 파일 구조:
┌─────────────────────────────┐
│ 매직 넘버 (GGUF) │
│ 버전 │
│ 텐서 개수 │
│ 메타데이터 KV 쌍 │
│ - 모델 아키텍처 │
│ - 컨텍스트 길이 │
│ - 어텐션 헤드 수 │
│ - 임베딩 차원 │
├─────────────────────────────┤
│ 텐서 인포 (이름, 타입, 형태) │
├─────────────────────────────┤
│ 텐서 데이터 │
└─────────────────────────────┘
7.2 양자화 수준 비교
| 포맷 | 비트 | 메모리(7B) | PPL 증가 | 권장 용도 |
|---|---|---|---|---|
| Q2_K | 2.6 | 2.8 GB | 높음 | 극단적 압축 |
| Q3_K_S | 3.0 | 3.3 GB | 중간 | 메모리 절약 |
| Q4_0 | 4.0 | 3.8 GB | 낮음 | 균형 |
| Q4_K_M | 4.1 | 4.1 GB | 매우 낮음 | 일반 권장 |
| Q5_0 | 5.0 | 4.7 GB | 최소 | 고품질 |
| Q5_K_M | 5.1 | 4.8 GB | 최소 | 고품질 권장 |
| Q6_K | 6.0 | 5.5 GB | 거의 없음 | FP16 근접 |
| Q8_0 | 8.0 | 7.2 GB | 없음 | 참조용 |
| F16 | 16.0 | 13.5 GB | 없음 | 기준선 |
K-quants (Q4_K_M, Q5_K_M 등)는 레이어의 일부를 더 높은 정밀도로 유지하여 품질을 향상시킵니다.
7.3 llama.cpp 빌드 및 사용
# llama.cpp 클론 및 빌드
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
# CUDA 지원 빌드
cmake -B build -DGGML_CUDA=ON
cmake --build build --config Release -j $(nproc)
# CPU 전용 빌드
cmake -B build
cmake --build build --config Release -j $(nproc)
# HuggingFace 모델을 GGUF로 변환
python convert_hf_to_gguf.py \
--model meta-llama/Llama-2-7b-hf \
--outfile llama2-7b-f16.gguf \
--outtype f16
# GGUF 양자화 (Q4_K_M)
./build/bin/llama-quantize \
llama2-7b-f16.gguf \
llama2-7b-q4_k_m.gguf \
Q4_K_M
# 추론 실행
./build/bin/llama-cli \
-m llama2-7b-q4_k_m.gguf \
-p "The future of AI is" \
-n 100 \
--ctx-size 4096 \
--threads 8 \
--n-gpu-layers 35
7.4 Python 바인딩 (llama-cpp-python)
from llama_cpp import Llama
# 모델 로드
llm = Llama(
model_path="./llama2-7b-q4_k_m.gguf",
n_ctx=4096, # 컨텍스트 길이
n_gpu_layers=35, # GPU로 오프로드할 레이어 수 (-1: 전체)
n_threads=8, # CPU 스레드 수
verbose=False
)
# 텍스트 생성
output = llm(
"Once upon a time",
max_tokens=200,
temperature=0.7,
top_p=0.9,
stop=["</s>", "\n\n"]
)
print(output["choices"][0]["text"])
# 채팅 완성 형식
response = llm.create_chat_completion(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is machine learning?"}
],
max_tokens=500,
temperature=0.7
)
print(response["choices"][0]["message"]["content"])
# 스트리밍 출력
for chunk in llm.create_chat_completion(
messages=[{"role": "user", "content": "Tell me a joke"}],
stream=True
):
delta = chunk["choices"][0].get("delta", {})
if "content" in delta:
print(delta["content"], end="", flush=True)
8. bitsandbytes: LLM 양자화 라이브러리
bitsandbytes는 Tim Dettmers가 개발한 라이브러리로, HuggingFace transformers와 완벽히 통합됩니다.
8.1 LLM.int8() - 8비트 혼합 정밀도
LLM.int8()은 행렬 곱셈 중 활성화 아웃라이어를 FP16으로 처리하고 나머지는 INT8을 사용합니다.
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
# INT8 모델 로드
model_8bit = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf",
load_in_8bit=True,
device_map="auto"
)
# 메모리 사용량 확인
def print_model_size(model, label):
"""모델 메모리 사용량 출력"""
total_params = sum(p.numel() for p in model.parameters())
total_bytes = sum(
p.numel() * p.element_size() for p in model.parameters()
)
print(f"{label}: {total_params/1e9:.2f}B params, {total_bytes/1e9:.2f} GB")
print_model_size(model_8bit, "INT8 모델")
# INT8 모델: 6.74B params, ~7.0 GB
8.2 4비트 양자화 (QLoRA에서 사용)
import bitsandbytes as bnb
from transformers import BitsAndBytesConfig
# NF4 양자화 설정 (QLoRA)
bnb_config_nf4 = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NF4 또는 FP4
bnb_4bit_compute_dtype=torch.bfloat16, # 연산 시 데이터 타입
bnb_4bit_use_double_quant=True, # 이중 양자화 (양자화 상수도 양자화)
)
# FP4 양자화 설정
bnb_config_fp4 = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="fp4",
bnb_4bit_compute_dtype=torch.float16,
)
# 모델 로드
model_4bit = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf",
quantization_config=bnb_config_nf4,
device_map="auto"
)
print_model_size(model_4bit, "NF4 모델")
# NF4 모델: 6.74B params, ~4.0 GB (이중 양자화 포함)
# QLoRA 파인튜닝 설정
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
model_4bit = prepare_model_for_kbit_training(model_4bit)
lora_config = LoraConfig(
r=64,
lora_alpha=16,
target_modules=["q_proj", "v_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
model_lora = get_peft_model(model_4bit, lora_config)
model_lora.print_trainable_parameters()
# trainable params: 4,194,304 || all params: 3,504,607,232 || trainable%: 0.1197
8.3 NF4 vs FP4
NF4 (Normal Float 4)
- 정규 분포를 가정한 비선형 4비트 양자화
- 가중치 분포가 정규분포에 가깝다는 점을 활용
- 같은 비트 수에서 더 좋은 표현력
FP4 (Float 4)
- 부동소수점 기반 4비트
- 더 넓은 범위 표현 가능
import numpy as np
import matplotlib.pyplot as plt
# NF4 양자화 포인트 시각화
def get_nf4_quantization_points():
"""NF4 16개 양자화 포인트"""
# 정규 분포의 1/16 분위수
nf4_points = []
for i in range(16):
quantile = (i + 0.5) / 16
nf4_points.append(scipy.stats.norm.ppf(quantile))
# 정규화
max_val = max(abs(p) for p in nf4_points)
nf4_points = [p / max_val for p in nf4_points]
return nf4_points
# NF4: [-1.0, -0.6961, -0.5250, -0.3949, -0.2844, -0.1848, -0.0911, 0.0000,
# 0.0796, 0.1609, 0.2461, 0.3379, 0.4407, 0.5626, 0.7230, 1.0]
9. SmoothQuant: W8A8 양자화
SmoothQuant는 가중치(W)와 활성화(A) 모두 INT8로 양자화하여 더 빠른 추론을 달성합니다.
9.1 활성화 아웃라이어 문제
LLM의 활성화 분포는 특정 채널에서 매우 큰 값(아웃라이어)이 발생합니다. 이로 인해 W8A8 양자화가 어렵습니다.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
def analyze_activation_outliers(model, tokenizer, text: str, threshold: float = 100.0):
"""활성화 아웃라이어 분석"""
activations = {}
def make_hook(name):
def hook(module, input, output):
act = output.detach().float()
max_val = act.abs().max().item()
outlier_ratio = (act.abs() > threshold).float().mean().item()
activations[name] = {
"max": max_val,
"outlier_ratio": outlier_ratio,
"std": act.std().item()
}
return hook
handles = []
for name, module in model.named_modules():
if isinstance(module, torch.nn.Linear):
handles.append(module.register_forward_hook(make_hook(name)))
input_ids = tokenizer(text, return_tensors="pt").input_ids.cuda()
with torch.no_grad():
model(input_ids)
for h in handles:
h.remove()
# 아웃라이어가 많은 레이어 순으로 정렬
sorted_acts = sorted(
activations.items(),
key=lambda x: x[1]["max"],
reverse=True
)
print("아웃라이어가 큰 레이어 Top 10:")
for name, stats in sorted_acts[:10]:
print(f" {name}: max={stats['max']:.1f}, outlier_ratio={stats['outlier_ratio']:.3%}")
return activations
9.2 마이그레이션 스케일링
SmoothQuant의 핵심: 활성화의 어려움을 가중치로 이전합니다.
Y = (X * diag(s)^(-1)) * (diag(s) * W)
= X_smooth * W_smooth
def smooth_quantize(
model,
calibration_samples,
alpha: float = 0.5
):
"""
SmoothQuant 적용
Args:
alpha: 마이그레이션 강도 (0=가중치만, 1=활성화만)
권장값: 0.5 (균등 분배)
"""
# 활성화 통계 수집
act_scales = {}
def collect_scales(name):
def hook(module, input, output):
inp = input[0].detach()
if inp.dim() == 3:
inp = inp.reshape(-1, inp.size(-1))
channel_max = inp.abs().max(dim=0)[0]
if name not in act_scales:
act_scales[name] = channel_max
else:
act_scales[name] = torch.maximum(act_scales[name], channel_max)
return hook
handles = []
for name, module in model.named_modules():
if isinstance(module, torch.nn.Linear):
handles.append(module.register_forward_hook(collect_scales(name)))
with torch.no_grad():
for sample in calibration_samples:
model(**sample)
for h in handles:
h.remove()
# 스케일 계산 및 적용
for name, module in model.named_modules():
if isinstance(module, torch.nn.Linear) and name in act_scales:
act_scale = act_scales[name]
weight_scale = module.weight.abs().max(dim=0)[0]
# 마이그레이션 스케일 계산
smooth_scale = (act_scale ** alpha) / (weight_scale ** (1 - alpha))
smooth_scale = torch.clamp(smooth_scale, min=1e-5)
# 가중치에 스케일 적용
module.weight.data = module.weight.data / smooth_scale.unsqueeze(0)
# 이전 레이어 (LayerNorm 등)의 출력 스케일에 역스케일 적용
# (실제 구현에서는 이전 레이어를 찾아서 수정)
return model, act_scales
10. SpQR: 희소-양자화 표현
SpQR는 중요한 가중치(아웃라이어)는 FP16으로 별도 저장하고 나머지는 저정밀도로 양자화합니다.
import torch
def spqr_quantize(weight: torch.Tensor,
num_bits: int = 3,
outlier_threshold_percentile: float = 1.0):
"""
SpQR 양자화 (단순화 버전)
핵심: 상위 p% 아웃라이어를 FP16으로 저장, 나머지는 저비트 양자화
"""
# 아웃라이어 임계값 계산
threshold = torch.quantile(weight.abs(), 1 - outlier_threshold_percentile / 100)
# 아웃라이어 마스크
outlier_mask = weight.abs() > threshold
# 아웃라이어 저장 (FP16)
outlier_values = weight.clone()
outlier_values[~outlier_mask] = 0
# 나머지 양자화
regular_weight = weight.clone()
regular_weight[outlier_mask] = 0
# Per-group 양자화 적용
q_max = 2 ** (num_bits - 1) - 1
group_size = 16
rows, cols = regular_weight.shape
regular_grouped = regular_weight.reshape(-1, group_size)
max_abs = regular_grouped.abs().max(dim=1, keepdim=True)[0]
scales = max_abs / q_max
scales = torch.clamp(scales, min=1e-8)
q = torch.clamp(torch.round(regular_grouped / scales), -q_max, q_max).to(torch.int8)
regular_dequant = (scales * q.float()).reshape(rows, cols)
# 최종 재구성
reconstructed = regular_dequant + outlier_values
error = (weight - reconstructed).abs().mean().item()
# 메모리 사용량 계산
outlier_memory = outlier_mask.sum().item() * 2 # FP16 = 2 bytes
regular_memory = (~outlier_mask).sum().item() * (num_bits / 8)
total_memory = outlier_memory + regular_memory
original_memory = weight.numel() * weight.element_size()
compression_ratio = original_memory / total_memory
print(f"아웃라이어 비율: {outlier_mask.float().mean():.2%}")
print(f"평균 재구성 오차: {error:.6f}")
print(f"압축률: {compression_ratio:.2f}x")
return q, scales, outlier_values, outlier_mask
11. 양자화 벤치마크 비교
11.1 Llama-2-7B 기준 비교
import time
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
import psutil
import GPUtil
def benchmark_quantization(model, tokenizer, device="cuda", num_runs=50):
"""양자화 모델 벤치마크"""
prompt = "The history of artificial intelligence began"
inputs = tokenizer(prompt, return_tensors="pt").to(device)
# 메모리 사용량
if device == "cuda":
torch.cuda.synchronize()
gpu = GPUtil.getGPUs()[0]
memory_used_gb = gpu.memoryUsed / 1024
else:
memory_used_gb = psutil.virtual_memory().used / 1e9
# 워밍업
with torch.no_grad():
for _ in range(5):
outputs = model.generate(
**inputs,
max_new_tokens=50,
do_sample=False
)
# 속도 측정
if device == "cuda":
torch.cuda.synchronize()
start = time.time()
with torch.no_grad():
for _ in range(num_runs):
outputs = model.generate(
**inputs,
max_new_tokens=50,
do_sample=False
)
if device == "cuda":
torch.cuda.synchronize()
elapsed = time.time() - start
avg_time = elapsed / num_runs
tokens_per_second = 50 / avg_time
return {
"memory_gb": memory_used_gb,
"avg_time_ms": avg_time * 1000,
"tokens_per_second": tokens_per_second
}
# 결과 예시 (A100 80GB 기준, Llama-2-7B)
benchmark_results = {
"FP16": {"memory_gb": 13.5, "tokens_per_second": 52.3, "ppl": 5.68},
"INT8 (bitsandbytes)": {"memory_gb": 7.8, "tokens_per_second": 38.1, "ppl": 5.71},
"INT4 GPTQ": {"memory_gb": 4.5, "tokens_per_second": 65.2, "ppl": 5.89},
"INT4 AWQ": {"memory_gb": 4.3, "tokens_per_second": 68.7, "ppl": 5.86},
"Q4_K_M (GGUF)": {"memory_gb": 4.1, "tokens_per_second": 45.2, "ppl": 5.91}, # CPU
"INT4 NF4": {"memory_gb": 4.0, "tokens_per_second": 31.5, "ppl": 5.94},
}
print("=" * 80)
print(f"{'방법':<25} {'메모리(GB)':<12} {'토큰/초':<12} {'PPL':<8}")
print("=" * 80)
for method, stats in benchmark_results.items():
print(f"{method:<25} {stats['memory_gb']:<12.1f} {stats['tokens_per_second']:<12.1f} {stats['ppl']:<8.2f}")
12. 실전 가이드: 최적 양자화 방법 선택
12.1 모델 크기에 따른 전략
7B 이하 소형 모델:
- GGUF Q4_K_M: 로컬 CPU 실행에 최적
- AWQ INT4: GPU 서버 배포에 권장
- FP16도 고려 가능 (24GB GPU 이하)
13B-30B 중형 모델:
- GPTQ INT4 또는 AWQ INT4: 24GB GPU 1장에 실행 가능
- GGUF Q4_K_M: 16GB RAM에서도 실행 가능
70B 이상 대형 모델:
- GPTQ INT4: A100 80GB 1장에 실행 가능
- GPTQ INT2: 극단적 압축 필요 시
- 멀티 GPU + Tensor Parallel 조합
12.2 태스크에 따른 전략
def recommend_quantization(
task: str,
model_size_b: float,
gpu_memory_gb: float,
cpu_only: bool = False,
fine_tuning_needed: bool = False
):
"""태스크와 환경에 따른 양자화 추천"""
recommendations = []
if cpu_only:
recommendations.append({
"method": "GGUF Q4_K_M",
"reason": "CPU 추론에 최적화, llama.cpp 기반",
"library": "llama-cpp-python"
})
return recommendations
if fine_tuning_needed:
recommendations.append({
"method": "bitsandbytes NF4 + QLoRA",
"reason": "파인튜닝 가능, 4GB 추가 메모리로 LoRA 어댑터 학습",
"library": "bitsandbytes + peft"
})
return recommendations
# 메모리 요구량 계산
fp16_memory = model_size_b * 2 # FP16 = 2 bytes per param
int8_memory = model_size_b * 1 # INT8 = 1 byte per param
int4_memory = model_size_b * 0.5 # INT4 = 0.5 bytes per param
if fp16_memory <= gpu_memory_gb * 0.8:
recommendations.append({
"method": "FP16 (기본)",
"reason": "메모리 여유 있음, 최고 품질",
"memory_gb": fp16_memory
})
if int8_memory <= gpu_memory_gb * 0.8:
if task in ["chat", "completion", "summarization"]:
recommendations.append({
"method": "AWQ INT8",
"reason": "품질과 속도의 최적 균형",
"library": "autoawq",
"memory_gb": int8_memory
})
if int4_memory <= gpu_memory_gb * 0.8:
recommendations.append({
"method": "AWQ INT4",
"reason": "고속 추론, 우수한 품질",
"library": "autoawq",
"memory_gb": int4_memory
})
recommendations.append({
"method": "GPTQ INT4",
"reason": "최고의 INT4 품질, 느린 양자화",
"library": "auto-gptq",
"memory_gb": int4_memory
})
return recommendations
# 사용 예시
recommendations = recommend_quantization(
task="chat",
model_size_b=7.0,
gpu_memory_gb=16.0,
fine_tuning_needed=False
)
for rec in recommendations:
print(f"\n방법: {rec['method']}")
print(f"이유: {rec['reason']}")
if 'library' in rec:
print(f"라이브러리: {rec['library']}")
if 'memory_gb' in rec:
print(f"예상 메모리: {rec['memory_gb']:.1f} GB")
12.3 완전한 양자화 파이프라인
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig
from awq import AutoAWQForCausalLM
import json
import os
class QuantizationPipeline:
"""통합 양자화 파이프라인"""
def __init__(self, model_name: str, output_base_dir: str):
self.model_name = model_name
self.output_base_dir = output_base_dir
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
os.makedirs(output_base_dir, exist_ok=True)
def quantize_gptq(self, bits: int = 4, group_size: int = 128):
"""GPTQ 양자화"""
output_dir = os.path.join(self.output_base_dir, f"gptq-{bits}bit")
config = BaseQuantizeConfig(
bits=bits,
group_size=group_size,
sym=True,
desc_act=False
)
model = AutoGPTQForCausalLM.from_pretrained(
self.model_name,
quantize_config=config
)
# 보정 데이터
calibration_data = self._prepare_calibration_data()
model.quantize(calibration_data)
model.save_quantized(output_dir)
self.tokenizer.save_pretrained(output_dir)
print(f"GPTQ {bits}bit 저장: {output_dir}")
return output_dir
def quantize_awq(self, bits: int = 4, group_size: int = 128):
"""AWQ 양자화"""
output_dir = os.path.join(self.output_base_dir, f"awq-{bits}bit")
model = AutoAWQForCausalLM.from_pretrained(
self.model_name,
low_cpu_mem_usage=True
)
quant_config = {
"zero_point": True,
"q_group_size": group_size,
"w_bit": bits,
"version": "GEMM"
}
model.quantize(self.tokenizer, quant_config=quant_config)
model.save_quantized(output_dir)
self.tokenizer.save_pretrained(output_dir)
print(f"AWQ {bits}bit 저장: {output_dir}")
return output_dir
def _prepare_calibration_data(self, num_samples: int = 128):
"""보정 데이터 준비"""
from datasets import load_dataset
dataset = load_dataset("wikitext", "wikitext-2-raw-v1", split="train")
data = []
for text in dataset["text"]:
if len(text.strip()) > 50:
encoded = self.tokenizer(
text.strip(),
return_tensors="pt",
max_length=2048,
truncation=True
)
data.append(encoded["input_ids"].squeeze())
if len(data) >= num_samples:
break
return data
def evaluate_all(self, test_text: str = None):
"""모든 양자화 모델 평가"""
if test_text is None:
from datasets import load_dataset
dataset = load_dataset("wikitext", "wikitext-2-raw-v1", split="test")
test_text = " ".join(dataset["text"][:10])
results = {}
# FP16 기준선
model_fp16 = AutoModelForCausalLM.from_pretrained(
self.model_name,
torch_dtype=torch.float16,
device_map="auto"
)
# PPL 계산
from transformers import pipeline
# 각 모델별 평가 결과 출력
print("\n=== 양자화 평가 결과 ===")
print(f"모델: {self.model_name}")
print(f"{'방법':<20} {'PPL':<10} {'메모리(GB)':<12}")
print("-" * 42)
return results
# 전체 파이프라인 실행
pipeline = QuantizationPipeline(
model_name="meta-llama/Llama-2-7b-hf",
output_base_dir="./quantized_models"
)
# GPTQ 4bit 양자화
gptq_dir = pipeline.quantize_gptq(bits=4)
# AWQ 4bit 양자화
awq_dir = pipeline.quantize_awq(bits=4)
마무리
모델 양자화는 LLM 민주화의 핵심 기술입니다. 이 가이드에서 다룬 내용을 정리하면:
- 기초 이해: FP32 → INT4로 압축하는 수학적 원리 (scale, zero_point)
- PTQ vs QAT: 재학습 없는 PTQ가 실용적, QAT는 극단적 압축에 필수
- GPTQ: 헤시안 기반 오차 보정으로 최고의 INT4 품질
- AWQ: 활성화 분포 기반으로 빠르고 효율적인 양자화
- GGUF: CPU 실행에 최적, 다양한 품질 수준 지원
- bitsandbytes: HuggingFace 통합, QLoRA 파인튜닝에 필수
추천 전략:
- 로컬 실행: GGUF Q4_K_M
- GPU 서버 배포: AWQ 4bit
- 고품질이 중요한 경우: GPTQ 4bit 또는 FP16
- 파인튜닝 필요: bitsandbytes NF4 + QLoRA
양자화 기술은 빠르게 발전하고 있으며, QuIP#, AQLM 같은 2-비트 양자화 기법도 등장하고 있습니다. 모델을 더 작고 빠르게 만드는 이 여정은 계속됩니다.
참고 자료
- GPTQ: arXiv:2209.05433
- AWQ: arXiv:2306.00978
- llama.cpp: github.com/ggerganov/llama.cpp
- bitsandbytes: github.com/TimDettmers/bitsandbytes
- SmoothQuant: arXiv:2211.10438
- SpQR: arXiv:2306.03078
- PyTorch 양자화: pytorch.org/docs/stable/quantization.html