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

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

실습 1 — 직접 양자화해 보기

Qwen2.5-0.5B-Instruct 의 가중치 행렬 하나를 텐서마다, 행마다, 128개 그룹마다 int8 과 int4 로 양자화해 오차를 견주고, 모델의 모든 선형층을 4비트로 바꿔 퍼플렉서티가 얼마나 나빠지는지 잽니다. 모델은 CPU 에서 fp32 로 불러, 실습의 숫자가 GPU 와 상관없이 같게 나오게 합니다.

1단계. 가중치 행렬 하나 꺼내기

Python
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.076

2단계. 스케일을 두는 곳에 따라 오차 견주기

Python
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.184

3단계. 퍼플렉서티를 재는 함수

Python
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.259

4단계. 모든 선형층을 4비트로 바꿔 보기

Python
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.00738비트는 스케일을 어디에 두든 원래와 4% 아래로만 달라집니다
2. int4 텐서마다상대 오차 0.6632 · 0 이 된 몫 0.78716칸짜리 자를 표 전체에 하나로 맞추자 가중치의 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 는 이보다 오차가 작습니다.
← 이전 절15장 · 실습 지도다음 절 →15장 · 실습 2 — 같은 모델을 fp16, 8비트, 4비트로 불러오기