15장. 모델 경량화: 양자화, 프루닝, 지식증류
실습 1 — 직접 양자화해 보기
Qwen2.5-0.5B-Instruct 의 가중치 행렬 하나를 텐서마다, 행마다, 128개 그룹마다 int8 과 int4 로 양자화해 오차를 견주고, 모델의 모든 선형층을 4비트로 바꿔 퍼플렉서티가 얼마나 나빠지는지 잽니다. 모델은 CPU 에서 fp32 로 불러, 실습의 숫자가 GPU 와 상관없이 같게 나오게 합니다.
1단계. 가중치 행렬 하나 꺼내기
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
name = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(name)
model = AutoModelForCausalLM.from_pretrained(name, dtype=torch.float32) # CPU, fp32
W = model.model.layers[0].mlp.down_proj.weight.detach().clone() # 첫 층 MLP 의 한 행렬
n_params = sum(p.numel() for p in model.parameters())
print("모델 파라미터:", f"{n_params:,}", "· 행렬:", tuple(W.shape), f"· fp32 로 {W.numel() * 4 / 1e6:.1f}MB")
# 결과: 가장 큰 절댓값과 99.9% 지점의 절댓값. 둘이 크게 다르면 튀는 값이 있다
print("최대 |w|:", round(W.abs().max().item(), 3), "· 99.9% |w|:", round(W.abs().quantile(0.999).item(), 3))모델 파라미터: 494,032,768 · 행렬: (896, 4864) · fp32 로 17.4MB
최대 |w|: 0.305 · 99.9% |w|: 0.0762단계. 스케일을 두는 곳에 따라 오차 견주기
def quantize(W, bits, group=None, per_row=False):
"""대칭 양자화 뒤 되돌린 행렬을 돌려준다. group 이면 그 크기 묶음마다, per_row 면 행마다 스케일을 둔다"""
qmax = 2 ** (bits - 1) - 1 # int8 이면 127, int4 면 7
X = W.reshape(W.shape[0], -1, group) if group else W
if group:
scale = X.abs().amax(dim=-1, keepdim=True) / qmax # 묶음마다
elif per_row:
scale = X.abs().amax(dim=1, keepdim=True) / qmax # 행마다
else:
scale = X.abs().amax() / qmax # 텐서 전체에 하나
Q = torch.clamp(torch.round(X / scale), -qmax - 1, qmax) # 반올림해 정수 눈금에 맞춘다
return (Q * scale).reshape(W.shape)
rows = []
for bits in (8, 4):
for how, kw in [("텐서마다", {}), ("행마다", {"per_row": True}), ("128개 묶음마다", {"group": 128})]:
Wq = quantize(W, bits, **kw)
rel = ((W - Wq).norm() / W.norm()).item() # 상대 오차
zero = (torch.round(Wq * 1e6) == 0).float().mean().item() # 0 이 된 가중치의 몫
rows.append({"비트": bits, "스케일": how, "상대 오차": round(rel, 4), "0 이 된 몫": round(zero, 3)})
import pandas as pd
print(pd.DataFrame(rows).to_string(index=False)) 비트 스케일 상대 오차 0 이 된 몫
8 텐서마다 0.0381 0.057
8 행마다 0.0114 0.016
8 128개 묶음마다 0.0073 0.010
4 텐서마다 0.6632 0.787
4 행마다 0.2049 0.274
4 128개 묶음마다 0.1314 0.1843단계. 퍼플렉서티를 재는 함수
text = open("shop_corpus.txt", encoding="utf-8").read()[:3000] # 4차시의 가상 쇼핑몰 글
ids = tok(text, return_tensors="pt")["input_ids"][:, :512] # 앞 512토큰
def perplexity(m):
"""labels 를 입력과 같게 주면 모델이 다음 토큰 예측의 평균 손실을 돌려준다. 그 exp 가 퍼플렉서티"""
with torch.no_grad():
loss = m(ids, labels=ids).loss
return round(float(torch.exp(loss)), 3)
base_ppl = perplexity(model)
print("토큰:", ids.shape[1], "· 원래 모델(fp32) 퍼플렉서티:", base_ppl)토큰: 512 · 원래 모델(fp32) 퍼플렉서티: 6.2594단계. 모든 선형층을 4비트로 바꿔 보기
originals = {n: m.weight.detach().clone() for n, m in model.named_modules()
if isinstance(m, torch.nn.Linear) and "lm_head" not in n} # 출력층(lm_head)은 그대로 둔다
results = {"fp32(원래)": base_ppl}
for how, kw in [("int8 텐서마다", {"bits": 8}), ("int4 텐서마다", {"bits": 4}), ("int4 128개 묶음", {"bits": 4, "group": 128})]:
for n, m in model.named_modules():
if n in originals:
m.weight.data = quantize(originals[n], **kw) # 양자화했다 되돌린 값으로 바꿔 끼운다
results[how] = perplexity(model)
for n, m in model.named_modules(): # 원래 가중치로 되돌린다
if n in originals:
m.weight.data = originals[n]
print(results)
print("선형층", len(originals), "개를 바꿨다"){'fp32(원래)': 6.259, 'int8 텐서마다': 6.447, 'int4 텐서마다': 39204292.0, 'int4 128개 묶음': 8.524}
선형층 168 개를 바꿨다이 실습이 뜻하는 것
이 실습은 정밀한 줄자 대신 눈금이 몇 개 없는 자로 치수를 다시 적어 보는 일과 같습니다. 가구 치수표 하나를 꺼내고(1단계), 눈금 간격을 표 전체에 하나로, 줄마다, 128칸마다 따로 맞춘 자로 다시 적어 원래와 얼마나 달라지는지 봅니다(2단계). 원래 치수로 시험을 보아 기준 점수를 재고(3단계), 집 안의 모든 치수표를 거친 자로 바꿔 적은 뒤 다시 시험을 봅니다(4단계). 여기서 치수가 가중치, 눈금의 수가 비트 수(int8 은 256칸, int4 는 16칸, int 는 integer, 정수), 눈금 간격이 스케일, 시험 점수가 퍼플렉서티입니다. 비유와 다른 점은, 퍼플렉서티는 낮을수록 좋은 점수라는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 행렬 꺼내기 | 494,032,768 · (896, 4864) · 17.4MB | 모델 전체의 숫자 약 4억 9천만 개 가운데 한 행렬(약 436만 개)을 꺼냈습니다. fp32(32비트 부동소수점, floating point)로 17.4MB(메가바이트)입니다 |
| 1. 튀는 값 | 최대 0.305 · 99.9% 지점 0.076 | 가장 큰 값이 거의 모든 값보다 네 배쯤 큽니다. 눈금을 이 몇 안 되는 값에 맞추면 간격이 넓어집니다 |
| 2. int8 | 상대 오차 0.0381, 0.0114, 0.0073 | 8비트는 스케일을 어디에 두든 원래와 4% 아래로만 달라집니다 |
| 2. int4 텐서마다 | 상대 오차 0.6632 · 0 이 된 몫 0.787 | 16칸짜리 자를 표 전체에 하나로 맞추자 가중치의 78.7% 가 0 으로 뭉개졌습니다 |
| 2. int4 행마다, 묶음마다 | 0.2049, 0.1314 | 눈금을 좁은 범위마다 맞추자 오차가 텐서마다의 3분의 1, 5분의 1 쯤으로 줄었습니다 |
| 3. 기준 | 토큰: 512 · 6.259 | 원래 모델이 쇼핑몰 글 512토큰에서 다음 토큰을 맞힐 때, 대략 보기 6개쯤 사이에서 헷갈리는 정도로 읽습니다 |
| 4. int8 텐서마다 | 6.447 | 모든 선형층을 8비트로 바꿔도 거의 그대로입니다 |
| 4. int4 텐서마다 | 39204292.0 | 모델이 완전히 무너졌습니다. 2단계에서 가중치 대부분이 0 이 된 결과입니다 |
| 4. int4 128개 묶음 | 8.524 | 나빠졌지만 쓸 만한 수준에 머물렀습니다 |
| 4. 바꾼 층 | 선형층 168 개 | 출력층(lm_head)을 뺀 선형층을 모두 바꿨습니다 |
한 줄로 줄이면, 같은 4비트라도 스케일을 어디마다 두느냐에 따라 모델이 무너지기도 하고 버티기도 한다는 것을 보인 실습입니다. 이 실습은 양자화했다 되돌린 fp32 값을 끼워 오차만 본 것이라 메모리는 줄이지 않았습니다. 실제로 줄여서 불러오는 것은 실습 2에서 합니다. 퍼플렉서티도 가상 쇼핑몰 글 512토큰 하나로 잰 것이라, 답의 품질 전체를 말해 주지는 않습니다.
코드 설명
- 1단계의 행렬은 첫 층 MLP 의
down_proj(896 × 4,864, 약 436만 개, fp32 로 17.4MB)입니다. 가장 큰 절댓값(0.305)이 99.9% 지점(0.076)의 네 배쯤이라, 몇 안 되는 튀는 값이 눈금 간격을 크게 넓힙니다. quantize()는 스케일을 텐서 전체, 행마다, 128개 묶음마다 둡니다. int8 은 어느 쪽이든 상대 오차가 4% 아래(0.038, 0.011, 0.007)입니다. int4 를 텐서마다 하면 상대 오차가 0.663 이고 가중치의 78.7% 가 0 이 됩니다. 튀는 값에 맞춘 넓은 눈금 16개에 작은 값들이 모두 0 으로 떨어진 것입니다. 행마다(0.205), 128개 묶음마다(0.131)로 좁히면 크게 줄어듭니다.model(ids, labels=ids).loss는 글의 토큰마다 다음 토큰을 맞히는 평균 손실이고,torch.exp가 퍼플렉서티로 바꿉니다. 4장의 쇼핑몰 글 앞 512토큰에서 원래 모델은 6.259 였습니다.- 4단계에서 선형층 168개를 모두 바꾸자, int8 텐서마다는 6.447 로 거의 그대로였습니다. int4 텐서마다는 39,204,292 로 모델이 완전히 무너졌고, int4 128개 묶음은 8.524 로 쓸 만한 수준에 머물렀습니다. 4비트 양자화가 그룹 양자화와 함께 쓰이는 까닭입니다. 이 실습의 4비트는 반올림만 한 가장 단순한 방법이고, 실습 2의 NF4 나 GPTQ 는 이보다 오차가 작습니다.