LLM 엔지니어링15장 · 모델 경량화: 양자화, 프루닝, 지식증류19 / 22← 이전목차다음 →MetaContents

15장. 모델 경량화: 양자화, 프루닝, 지식증류

실습 6 — LLM 은 가지치기에 얼마나 견디나

실습 4의 작은 신경망은 90% 를 지워도 잘 버텼습니다. 같은 비정형 프루닝을 Qwen2.5-0.5B-Instruct 의 선형층에 10%, 30%, 50% 해서 퍼플렉서티와 답이 어떻게 변하는지, 파일 크기와 속도는 줄어드는지 봅니다. GPU 에서 fp16 으로 돌립니다.

1단계. 모델 불러와 기준 재기

Python
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단계. 층마다 크기가 작은 가중치를 지우기

Python
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, 12

3단계. 0 이 많아져도 크기는 그대로

Python
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(모든 층을 한데 모아 경계 하나)와 달리 층마다 같은 몫을 지웁니다.
← 이전 절15장 · 실습 5 — 지식증류: 큰 모델의 답으로 작은 모델 가르치기다음 절 →15장 · 프로젝트 1 — 우리 GPU 에 맞는 모델 고르기