들어가며
딥러닝 모델이 점점 거대해지면서 추론(Inference) 비용과 메모리 요구량이 폭발적으로 증가했습니다. GPT-3는 175B 파라미터, Llama 3는 70B 파라미터에 달하며, FP32 전정밀도(Full Precision)로 저장하면 각각 700GB, 280GB의 메모리가 필요합니다. 일반 GPU로는 실행조차 불가능한 수준입니다.
**모델 양자화(Model Quantization)**는 이 문제를 해결하는 핵심 기술입니다. 32비트 부동소수점(FP32) 가중치를 8비트, 4비트 정수로 압축하여 메모리를 4~8배 줄이고 추론 속도를 2~4배 높입니다. 품질 손실은 놀랍도록 적습니다.
이 글에서는 양자화의 수학적 원리부터 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)에서 네이티브 지원
각 데이터 타입의 메모리 크기 확인
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)
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에서 주로 사용
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
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)
아웃라이어의 영향을 줄이기 위해 분포 히스토그램을 기반으로 최적 범위를 찾습니다.
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)입니다.
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로 양자화 효과를 시뮬레이션합니다.
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)
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 학습 워크플로
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입니다.
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 핵심 알고리즘 구현 (단순화 버전)
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
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
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에서 사용)
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비트
- 더 넓은 범위 표현 가능
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 양자화가 어렵습니다.
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으로 별도 저장하고 나머지는 저정밀도로 양자화합니다.
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 기준 비교
from transformers import AutoModelForCausalLM, AutoTokenizer
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 완전한 양자화 파이프라인
from transformers import AutoModelForCausalLM, AutoTokenizer
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig
from awq import AutoAWQForCausalLM
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 민주화의 핵심 기술입니다. 이 가이드에서 다룬 내용을 정리하면:
1. **기초 이해**: FP32 → INT4로 압축하는 수학적 원리 (scale, zero_point)
2. **PTQ vs QAT**: 재학습 없는 PTQ가 실용적, QAT는 극단적 압축에 필수
3. **GPTQ**: 헤시안 기반 오차 보정으로 최고의 INT4 품질
4. **AWQ**: 활성화 분포 기반으로 빠르고 효율적인 양자화
5. **GGUF**: CPU 실행에 최적, 다양한 품질 수준 지원
6. **bitsandbytes**: HuggingFace 통합, QLoRA 파인튜닝에 필수
**추천 전략**:
- 로컬 실행: GGUF Q4_K_M
- GPU 서버 배포: AWQ 4bit
- 고품질이 중요한 경우: GPTQ 4bit 또는 FP16
- 파인튜닝 필요: bitsandbytes NF4 + QLoRA
양자화 기술은 빠르게 발전하고 있으며, QuIP#, AQLM 같은 2-비트 양자화 기법도 등장하고 있습니다. 모델을 더 작고 빠르게 만드는 이 여정은 계속됩니다.
참고 자료
- GPTQ: [arXiv:2209.05433](https://arxiv.org/abs/2209.05433)
- AWQ: [arXiv:2306.00978](https://arxiv.org/abs/2306.00978)
- llama.cpp: [github.com/ggerganov/llama.cpp](https://github.com/ggerganov/llama.cpp)
- bitsandbytes: [github.com/TimDettmers/bitsandbytes](https://github.com/TimDettmers/bitsandbytes)
- SmoothQuant: [arXiv:2211.10438](https://arxiv.org/abs/2211.10438)
- SpQR: [arXiv:2306.03078](https://arxiv.org/abs/2306.03078)
- PyTorch 양자화: [pytorch.org/docs/stable/quantization.html](https://pytorch.org/docs/stable/quantization.html)
현재 단락 (1/1044)
딥러닝 모델이 점점 거대해지면서 추론(Inference) 비용과 메모리 요구량이 폭발적으로 증가했습니다. GPT-3는 175B 파라미터, Llama 3는 70B 파라미터에 달하며,...