15장. 모델 경량화: 양자화, 프루닝, 지식증류
실습 2 — 같은 모델을 fp16, 8비트, 4비트로 불러오기
bitsandbytes 로 Qwen2.5-0.5B-Instruct 를 불러올 때 8비트, 4비트(NF4)로 바꿔 GPU 메모리, 생성 속도, 퍼플렉서티, 답을 fp16 과 견줍니다. bitsandbytes 가 설치되어 있어야 하고 NVIDIA GPU 가 있어야 합니다. 교안을 만든 PC 는 GeForce GTX 1650(4GB)입니다.
1단계. 재는 함수 만들기
import gc, json, time, urllib.request
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
# Ollama 가 GPU 에 올려 둔 모델이 있으면 내려 GPU 를 비운다
for m in json.loads(urllib.request.urlopen("http://localhost:11434/api/ps").read())["models"]:
urllib.request.urlopen(urllib.request.Request("http://localhost:11434/api/generate", headers={"Content-Type": "application/json"},
data=json.dumps({"model": m["name"], "keep_alive": 0}).encode())).read()
name = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(name)
ids = tok(open("shop_corpus.txt", encoding="utf-8").read()[:3000], return_tensors="pt")["input_ids"][:, :512].to("cuda")
QUESTION = "택배 상자가 젖어서 왔어요. 어떻게 해야 하나요?"
def measure(model):
"""메모리, 퍼플렉서티, 생성 속도, 답을 잰다. 생성은 한 번 데워 본 뒤 잰다"""
with torch.no_grad():
ppl = float(torch.exp(model(ids, labels=ids).loss))
enc = tok.apply_chat_template([{"role": "user", "content": QUESTION}], add_generation_prompt=True,
return_tensors="pt", return_dict=True).to("cuda")
model.generate(**enc, max_new_tokens=8, do_sample=False) # 데우기
torch.cuda.synchronize()
t = time.perf_counter()
out = model.generate(**enc, max_new_tokens=60, min_new_tokens=60, do_sample=False) # 60토큰을 꼭 만든다
torch.cuda.synchronize()
sec = time.perf_counter() - t
answer = tok.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True).replace("\n", " ")
return {"메모리MB": round(model.get_memory_footprint() / 1e6), "최대GPU MB": round(torch.cuda.max_memory_allocated() / 1e6),
"퍼플렉서티": round(ppl, 3), "초당토큰": round(60 / sec, 1), "답": answer[:45]}
print("GPU:", torch.cuda.get_device_name(0), f"· {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f}GB")GPU: NVIDIA GeForce GTX 1650 · 4.3GB2단계. 세 가지로 불러 재기
configs = {
"fp16": {"dtype": torch.float16},
"8비트": {"dtype": torch.float16, "quantization_config": BitsAndBytesConfig(load_in_8bit=True)},
# NF4: 정규분포 모양의 가중치에 맞춘 4비트 눈금. 계산은 fp16 으로 되돌려서 한다
"4비트 NF4": {"dtype": torch.float16, "quantization_config": BitsAndBytesConfig(
load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.float16)},
}
rows = {}
for label, kw in configs.items():
torch.cuda.empty_cache()
torch.cuda.reset_peak_memory_stats()
model = AutoModelForCausalLM.from_pretrained(name, device_map="cuda", **kw)
rows[label] = measure(model)
del model
gc.collect()
torch.cuda.empty_cache()
import pandas as pd
table = pd.DataFrame(rows).T
print(table.drop(columns="답").to_string()) 메모리MB 최대GPU MB 퍼플렉서티 초당토큰
fp16 988 1782 6.257 15.2
8비트 630 1425 6.38 4.0
4비트 NF4 451 1268 7.064 12.33단계. 답 견주기
for label, r in rows.items():
print(f"{label}: {r['답']}")fp16: 택배 상자에 젖은 것을 해결하는 방법이 여러 가지 있습니다. 먼저, 상자를 꺼내고
8비트: 택배 상자를 젖은 경우에 대해 도움을 드리겠습니다. 우선, 상자 안에서 물이 뻗어
4비트 NF4: 물론이죠! 우선적으로 택배 상자가 젖은 부분을 확인하고, 그 부분에 대해 어떤 문이 실습이 뜻하는 것
이 실습은 같은 책을 원본, 축쇄판, 포켓판으로 가방에 넣어 보는 일과 같습니다. 저울과 초시계를 준비하고(1단계), 세 판을 차례로 가방에 넣어 무게, 읽는 속도, 오탈자를 잽니다(2단계). 끝으로 같은 질문의 답을 세 판에서 읽어 봅니다(3단계). 여기서 원본이 fp16(16비트 부동소수점), 축쇄판이 8비트, 포켓판이 4비트 NF4(NormalFloat 4비트, 정규분포 모양에 맞춘 눈금), 가방이 GPU(Graphics Processing Unit, 그래픽 처리 장치) 메모리입니다. 비유와 다른 점은, 작은 책은 보통 더 빨리 넘기지만 여기서는 줄인 판이 오히려 느렸다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. GPU | NVIDIA GeForce GTX 1650 · 4.3GB | 교안을 만든 PC 의 GPU 입니다. 4GB 제품을 1,000 단위로 나눠 적어 4.3GB 로 보입니다 |
| 2. 메모리MB | fp16 988, 8비트 630, 4비트 451 | 8비트는 fp16 의 약 64%, 4비트는 약 46% 입니다. 반이나 4분의 1 이 되지 않은 것은 줄이지 않는 층(임베딩층, 출력층)이 있기 때문입니다 |
| 2. 최대GPU MB | 1782, 1425, 1268 | 퍼플렉서티를 잴 때 512토큰을 한 번에 넣은 계산 중간값까지 더한 값입니다 |
| 2. 퍼플렉서티 | 6.257, 6.38, 7.064 | 줄일수록 조금씩 나빠졌습니다. 4비트에서 가장 많이 나빠졌습니다 |
| 2. 초당토큰 | 15.2, 4.0, 12.3 | 이 PC 에서는 줄인 판이 더 느렸고, 8비트는 네 배쯤 느렸습니다. 8비트가 튀는 값을 fp16 으로 따로 계산하기 때문입니다 |
| 3. 답 | 셋 다 상자를 확인하라는 내용 | 질문 하나로는 세 판의 차이를 느끼기 어렵습니다 |
한 줄로 줄이면, bitsandbytes 로 불러올 때 줄이면 메모리는 확실히 줄고 품질은 조금 잃으며, 속도는 도구와 GPU 에 따라 오히려 느려질 수 있음을 보인 실습입니다. 속도와 메모리 숫자는 이 PC(GTX 1650)의 것이라 다른 GPU 에서는 달라집니다. 품질은 질문 하나로 판단하지 않고, 실습 3에서 질문 12개로 잽니다.
코드 설명
BitsAndBytesConfig(load_in_8bit=True)는 8비트,load_in_4bit=True, bnb_4bit_quant_type="nf4"는 4비트 NF4 로 불러옵니다. NF4 는 정규분포 모양인 가중치에 맞춰 눈금을 고르게 나눈 4비트이고, 계산은bnb_4bit_compute_dtype의 fp16 으로 되돌려서 합니다. 파일을 따로 만들지 않고 불러올 때마다 줄입니다.- 모델이 차지한 메모리는 fp16 988MB, 8비트 630MB, 4비트 451MB 입니다. 반이나 4분의 1 이 되지 않은 것은 임베딩층과 출력층처럼 양자화하지 않는 부분이 있기 때문입니다. 최대 GPU 사용량(1,782, 1,425, 1,268MB)에는 512토큰을 한 번에 넣어 퍼플렉서티를 잴 때의 계산 중간값이 더해져 있습니다.
- 초당 토큰은 fp16 15.2, 4비트 12.3, 8비트 4.0 이었습니다. 이 PC 에서 양자화는 메모리를 줄였지만 속도는 오히려 느려졌고, 8비트는 네 배쯤 느렸습니다. bitsandbytes 의 8비트는 튀는 값을 fp16 으로 따로 계산하는 혼합 방식이라 계산이 더 들기 때문입니다. "줄이면 빨라진다"는 도구와 GPU 마다 다르므로 꼭 잽니다.
- 퍼플렉서티는 fp16 6.257, 8비트 6.38, 4비트 7.064 로, 줄일수록 조금씩 나빠졌습니다. 3단계의 답은 셋 다 상자를 확인하라는 비슷한 내용으로, 이 질문 하나로는 차이를 느끼기 어렵습니다. 품질은 질문 묶음으로 잽니다(실습 3).