15장. 모델 경량화: 양자화, 프루닝, 지식증류
실습 6 — LLM 은 가지치기에 얼마나 견디나
실습 4의 작은 신경망은 90% 를 지워도 잘 버텼습니다. 같은 비정형 프루닝을 Qwen2.5-0.5B-Instruct 의 선형층에 10%, 30%, 50% 해서 퍼플렉서티와 답이 어떻게 변하는지, 파일 크기와 속도는 줄어드는지 봅니다. GPU 에서 fp16 으로 돌립니다.
1단계. 모델 불러와 기준 재기
import gc, time
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.float16).to("cuda")
ids = tok(open("shop_corpus.txt", encoding="utf-8").read()[:3000], return_tensors="pt")["input_ids"][:, :512].to("cuda")
enc = tok.apply_chat_template([{"role": "user", "content": "택배 상자가 젖어서 왔어요. 어떻게 해야 하나요?"}],
add_generation_prompt=True, return_tensors="pt", return_dict=True).to("cuda")
def check(m):
with torch.no_grad():
ppl = float(torch.exp(m(ids, labels=ids).loss))
torch.cuda.synchronize()
t = time.perf_counter()
out = m.generate(**enc, max_new_tokens=40, min_new_tokens=40, do_sample=False)
torch.cuda.synchronize()
answer = tok.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True).replace("\n", " ")
return round(ppl, 2), round(40 / (time.perf_counter() - t), 1), answer[:50]
originals = {n: mod.weight.detach().clone() for n, mod in model.named_modules()
if isinstance(mod, torch.nn.Linear) and "lm_head" not in n}
check(model) # 데우기
print("선형층:", len(originals), "개 · 기준:", check(model))선형층: 168 개 · 기준: (6.26, 17.1, '택배 상자에 젖은 것을 해결하는 방법이 여러 가지 있습니다. 먼저, 상자를 꺼내고 물을 빨')2단계. 층마다 크기가 작은 가중치를 지우기
rows = []
for amount in (0.1, 0.3, 0.5):
for n, mod in model.named_modules():
if n in originals:
w = originals[n]
k = int(w.numel() * amount)
threshold = w.abs().flatten().float().kthvalue(k).values # 층마다 아래 amount 만큼의 경계값
mod.weight.data = torch.where(w.abs() > threshold, w, torch.zeros_like(w))
ppl, tps, answer = check(model)
rows.append({"지운 몫": f"{amount:.0%}", "퍼플렉서티": ppl, "초당토큰": tps, "답": answer})
for n, mod in model.named_modules(): # 원래대로 되돌린다
if n in originals:
mod.weight.data = originals[n]
import pandas as pd
print(pd.DataFrame(rows).drop(columns="답").to_string(index=False))
for r in rows:
print(r["지운 몫"], "→", r["답"])지운 몫 퍼플렉서티 초당토큰
10% 6.44 18.1
30% 8.64 18.2
50% 285.88 18.0
10% → 물론이죠! 택배 상자에서 젖어드는 것은 매우 위험한 사고입니다. 우선, 먼저 상자를 꺼내서
30% → 물론이죠, 저는 여러분의 도움을 원하신다고 생각합니다. 우선 물건을 보고 있는지 확인하고,
50% → 기를 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 123단계. 0 이 많아져도 크기는 그대로
zeros = sum((mod.weight == 0).sum().item() for n, mod in model.named_modules() if n in originals)
size_mb = sum(p.numel() * p.element_size() for p in model.parameters()) / 1e6
print(f"되돌린 모델의 0 인 가중치: {zeros:,} · 메모리: {size_mb:.0f}MB")
print("50% 를 지운 모델도 같은 꼴의 행렬이라 메모리는 같다. 0 을 건너뛰는 희소 형식과 계산이 있어야 준다")
del model
gc.collect()
torch.cuda.empty_cache()되돌린 모델의 0 인 가중치: 489 · 메모리: 988MB
50% 를 지운 모델도 같은 꼴의 행렬이라 메모리는 같다. 0 을 건너뛰는 희소 형식과 계산이 있어야 준다이 실습이 뜻하는 것
이 실습은 실습 4의 정원 가위를 훨씬 큰 나무에 대 보는 일과 같습니다. 먼저 나무의 지금 상태를 재고(1단계), 가지마다 가는 쪽부터 10%, 30%, 50% 를 잘라 봅니다(2단계). 끝으로 잘린 자리가 많아져도 나무의 무게가 그대로인지 저울에 올립니다(3단계). 여기서 큰 나무가 LLM(Large Language Model, 대형 언어 모델)인 Qwen2.5-0.5B-Instruct, 가지마다가 선형층마다, 나무의 상태가 퍼플렉서티와 답입니다. 비유와 다른 점은, 진짜 나무는 가지를 자르면 가벼워지지만 여기서는 0 이 된 가중치도 같은 자리를 차지해 무게가 그대로라는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 기준 | 선형층: 168 개 · (6.26, 17.1, …) | 지우기 전 퍼플렉서티 6.26, 초당 17.1 토큰이고, 답도 자연스럽습니다 |
| 2. 10% | 6.44 | 거의 그대로입니다 |
| 2. 30% | 8.64 | 눈에 띄게 나빠졌고, 답이 "여러분의 도움을 원하신다고"처럼 엇나가기 시작합니다 |
| 2. 50% | 285.88 · 기를 1, 2, 3, … | 모델이 무너져 뜻 없는 글을 냅니다 |
| 2. 초당토큰 | 18.1, 18.2, 18.0 | 지워도 속도는 그대로입니다 |
| 3. 0 인 가중치 | 489 | 원래대로 되돌린 모델에는 0 인 가중치가 489개뿐입니다. 원래 모델에 있던 0 입니다 |
| 3. 메모리 | 988MB | 행렬의 꼴이 그대로라 50% 를 지운 동안에도 메모리는 같습니다 |
한 줄로 줄이면, LLM 은 다시 학습 없이 가지치기하면 30% 에서 이미 나빠지고 50% 에서 무너지며, 크기와 속도도 줄지 않는다는 실습입니다. 실습 4의 작은 신경망이 90% 를 지워도 버틴 것과 대조적입니다. 다만 이 실습은 크기만 보고 지우는 가장 단순한 방법을 다시 학습 없이 해 본 것이라, 다른 프루닝 방법이나 다시 학습한 경우까지 말해 주지는 않습니다. 이 장의 프로젝트는 그래서 양자화 단계와 모델 크기를 견주어 모델을 고릅니다.
코드 설명
- 1단계의 기준은 퍼플렉서티 6.26, 초당 17.1 토큰입니다. 실습 1처럼 원래 가중치를 복사해 두었다가 2단계 끝에 되돌립니다.
- 층마다 10% 를 지우면 퍼플렉서티가 6.44, 30% 에서 8.64, 50% 에서 285.88 로 무너졌고, 50% 의 답은 "기를 1, 2, 3, 4, …"처럼 뜻이 없는 글이 되었습니다. 실습 4의 작은 신경망이 90% 를 지워도 버틴 것과 대조적입니다. 다시 학습 없이 LLM 을 가지치기하기는 어렵습니다. 속도는 17~18 토큰/초 로 그대로였습니다.
- 되돌린 모델에 원래 0 인 가중치는 489개뿐이고, 메모리는 988MB 입니다. 50% 를 지운 동안에도 행렬의 꼴은 같아서 메모리와 속도는 줄지 않았습니다. 비정형 프루닝으로 실제 이득을 보려면 희소 행렬을 다루는 형식과 계산(예: 2:4 희소를 지원하는 최신 GPU)이 있어야 합니다.
kthvalue(k)는 k 번째로 작은 값을 돌려줍니다. 층마다 절댓값의 경계를 따로 정하므로, 실습 4의global_unstructured(모든 층을 한데 모아 경계 하나)와 달리 층마다 같은 몫을 지웁니다.