개요
이 편은 SSAFY < 35 >~< 37 >, < 39 >의 실습편이고, 이번 파이썬 시리즈 다섯 편의 마지막이다.
| 이론 (SSAFY 시리즈) | 실습 (이 글) |
|---|---|
< 35 > 컴퓨터의 수 체계 |
1장 — 정밀도별 메모리 계산 |
< 36 > 모델 경량화 세 가지 |
2장 — Full Fine-tuning이 막히는 지점 |
< 37 > LoRA와 QLoRA |
3~4장 — 실제로 붙여서 학습 |
< 39 > 정수연산 양자화 |
5장 — PTQ INT4 추론 |
그리고 시리즈 안에서도 한 바퀴가 닫힌다. < 10 >에서 LLM으로 몬스터 도감 합성 데이터를 만들고 Judge로 걸러냈는데, 그 데이터를 이번 편에서 학습에 쓴다. 만들고, 거르고, 이제 그걸로 모델을 고친다.
이번 글이 답할 질문 16GB짜리 그래픽카드로 대형 언어 모델을 손볼 수 있나? 줄이면 뭘 잃나?
실습 자료는 SSAFY 교육 컨텐츠라 원본을 싣지 않는다. 개념만 가져와 예제는 전부 새로 짰다. 실습은 체스 기보로 학습했는데 여기서는 몬스터 도감으로 바꿨다.
그리고 이 글의 메모리 숫자는 전부 계산값이다. 파라미터 수에 바이트를 곱한 것이고, 내가 잰 값이 아니다. 실제 사용량은 GPU와 설정에 따라 달라진다.
1. 숫자 하나에 몇 바이트를 쓸 것인가
모델은 결국 숫자 뭉치다. 7B 모델이라는 건 그 숫자가 70억 개라는 뜻이다.
그러면 숫자 하나를 몇 바이트로 저장하느냐가 그대로 용량이 된다.
| 정밀도 | 숫자 하나당 | 7B 가중치 | 무엇에 쓰나 |
|---|---|---|---|
| FP32 | 4바이트 | 26.1 GB | 학습 기본값, 옵티마이저 내부 |
| FP16 / BF16 | 2바이트 | 13.0 GB | 요즘 학습·추론 기본값 |
| INT8 | 1바이트 | 6.5 GB | 추론 최적화 |
| NF4 | 0.5바이트 | 3.3 GB | QLoRA |
계산은 단순하다. 70억 × 4바이트 ÷ 1024³ = 26.1GB. 가중치만 올려놔도 FP32는 16GB 카드에 아예 안 들어가고, FP16도 8할을 먹는다.
FP16과 BF16은 둘 다 16비트인데 나누는 방식이 다르다. FP16은 소수점 아래 자리에 비트를 더 쓰고, BF16은 표현할 수 있는 범위를 넓게 잡는다. 학습 중에는 값이 크게 튀는 일이 있어서 요즘은 BF16을 더 많이 쓴다.
NF4는 그냥 4비트가 아니다. 가중치가 0 근처에 몰려 있다는 걸 전제로 눈금을 촘촘하게 잡아둔 4비트다. 균등하게 자르는 것보다 같은 비트로 손실이 덜하다.
모델 크기라는 게 결국 숫자 개수 × 숫자 하나의 크기다. 개수를 못 줄이면 크기를 줄이는 수밖에 없다.
2. 전부 학습하려면 얼마나 드나
추론만 하면 가중치만 올리면 된다. 학습은 다르다.
| 무엇이 | 얼마나 | 7B / FP16 기준 |
|---|---|---|
| 모델 가중치 | 파라미터 × 2바이트 | 13 GB |
| 기울기(gradient) | 파라미터 × 2바이트 | 13 GB |
| 옵티마이저 상태 | 파라미터 × 8바이트 | 52 GB |
| 활성화값 | 배치·길이에 따라 | 수 GB |
옵티마이저가 제일 크다. AdamW는 파라미터마다 평균과 분산 두 개를 따로 들고 있고, 그것도 정확도 때문에 FP32로 둔다. 그래서 7e9 × 4바이트 × 2 = 52GB가 나온다.
다 합치면 80GB를 넘는다. 16GB 카드로는 근처에도 못 간다.
여기서 방향이 갈린다.
- 파라미터를 덜 학습한다 → PEFT
- 숫자를 작게 저장한다 → 양자화
- 둘 다 한다 → QLoRA
학습이 추론보다 몇 배로 비싼 이유는 가중치가 아니라 기울기와 옵티마이저 때문이다. 그러니 줄일 자리도 거기다.
3. LoRA — 옆에 얇은 행렬 두 개를 붙인다
< 11 >에서 전이학습을 하면서 “어디를 잠그고 어디를 풀 것인가” 를 봤다. LoRA는 그걸 극단까지 민 것이다.
원래 가중치는 아예 안 건드린다. 대신 그 옆에 얇은 행렬 두 개를 새로 달고 그것만 학습한다.
$$ h = Wx + \frac{\alpha}{r} BAx $$
W가 원래 가중치고 잠겨 있다. B와 A가 새로 붙인 것이다. B는 세로로 길고 A는 가로로 긴데, 곱하면 W와 같은 모양이 된다. 그래서 결과를 그냥 더할 수 있다.
숫자로 보면 왜 이득인지 바로 보인다. 4096 × 4096 행렬 하나면
전체 학습: 4096 × 4096 = 16,777,216 개
LoRA (r=8): 4096 × 8 + 8 × 4096 = 65,536 개 → 0.39%
0.4%만 학습한다. 기울기도 옵티마이저 상태도 저 0.4%에만 붙으니까, 2장에서 본 52GB짜리 옵티마이저가 사실상 사라진다.
r과 lora_alpha — 두 숫자가 하는 일이 다르다
여기서 제일 헷갈렸다. 둘 다 크게 하면 세지는 것 같은데 역할이 다르다.
| 하는 일 | 키우면 | |
|---|---|---|
r (rank) |
붙이는 행렬의 두께 | 표현력이 늘고 학습 파라미터도 는다 |
lora_alpha |
결과에 곱하는 세기 | 파라미터 수는 그대로인데 영향력만 커진다 |
수식의 α/r이 핵심이다. r을 8에서 16으로 올리면 두께가 두 배가 되는데, 그러면 값이 커지는 쪽으로 쏠린다. α를 같이 올려주면 α/r이 유지돼서 세기가 그대로 남는다.
그래서 alpha = r × 2 라는 관례가 나온다. r=8이면 alpha=16, r=16이면 alpha=32. r을 바꿔가며 실험할 때 세기까지 같이 흔들리지 않게 하려는 것이다.
target_modules — 어디에 붙일 것인가
두 번째로 헷갈린 게 이거였다. 붙일 수 있는 자리가 일곱 개나 된다.
model = FastModel.get_peft_model(
model,
r=8,
lora_alpha=16,
lora_dropout=0.0,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj", # Attention
"gate_proj", "up_proj", "down_proj", # MLP
],
)
앞의 넷은 Attention 쪽이다. < 10 >에서 손으로 짠 그 Attention이 맞다. 질문(q)·열쇠(k)·값(v)을 만드는 자리와, 나온 걸 다시 합치는 자리(o)다.
뒤의 셋은 MLP 쪽으로, Attention이 고른 정보를 실제로 가공하는 자리다.
| 어디에 붙이나 | 파라미터 | 효과 |
|---|---|---|
Attention만 (q,k,v,o) |
적다 | 무난하다. 예전에 많이 쓰던 조합 |
전부 (+ gate,up,down) |
는다 | 요즘 권장. 도메인이 많이 다를수록 유리 |
q,v만 |
제일 적다 | 원 논문 조합. 아주 아낄 때 |
적게 붙일수록 싸고, 많이 붙일수록 잘 배운다. 파라미터가 늘어도 어차피 원본의 1% 언저리라 요즘은 전부 붙이는 쪽을 많이 쓴다.
QLoRA — 잠긴 원본까지 4비트로
여기에 하나 더 얹으면 QLoRA다. 잠가둘 원본을 4비트로 눌러서 올린다.
model_name = "unsloth/gemma-3-1b-it-unsloth-bnb-4bit" # bnb-4bit 가 바로 그 부분
model, tokenizer = FastModel.from_pretrained(model_name, max_seq_length=1024)
어차피 안 배울 가중치니 정밀도를 깎아도 학습에는 크게 지장이 없다. 대신 붙이는 A와 B는 16비트로 둔다. 이쪽은 실제로 배우는 부분이라 정밀도가 필요하다.
LoRA는 얼마나 붙일지(
r)와 얼마나 세게 반영할지(α)가 다른 문제다. 그리고 QLoRA는 거기에 “잠근 건 대충 저장해도 된다”를 더한 것이다.
4. 형식을 맞춰야 학습이 된다 — Chat Template
모델을 준비했으면 데이터를 넣는데, 그냥 문장으로 넣으면 안 된다.
대화형 모델은 “누가 말했는지”를 특수 토큰으로 구분해서 학습돼 있다. 그 형식을 안 맞추면 모델이 자기가 아는 모양이 아니라고 판단한다.
< 10 >에서 만든 몬스터 도감 데이터를 이 형식으로 바꾼다.
def convert_to_chatml(example):
return {
"conversations": [
{"role": "system", "content": "너는 게임 몬스터 도감을 쓰는 작가다."},
{"role": "user", "content": example["name"]},
{"role": "assistant", "content": example["description"]},
]
}
dataset = dataset.map(convert_to_chatml)
assistant 자리에 정답이 들어간다. 모델이 맞혀야 할 답을 미리 넣어두고, 그걸 따라 쓰게 학습시키는 것이다.
add_generation_prompt — 학습과 추론이 다르다
세 번째로 헷갈린 게 여기다. 같은 함수인데 옵션이 반대로 들어간다.
# 학습 — 정답까지 들어간 완성된 대화
text = tokenizer.apply_chat_template(convo, tokenize=False,
add_generation_prompt=False)
# 추론 — 모델 차례를 열어둔 채로 넘긴다
text = tokenizer.apply_chat_template(messages, tokenize=False,
add_generation_prompt=True)
True로 두면 끝에 “이제 모델이 말할 차례”라는 토큰을 붙여준다. 대화가 열린 채로 끝나니까 모델이 그 뒤를 이어 쓴다.
학습 때는 정답이 이미 붙어 있으니 그게 필요 없다. 오히려 붙이면 빈 차례가 하나 더 생겨서 이상한 형식을 배운다.
반대로 추론에서 False로 두면 모델이 자기 차례인 줄 모른다. 대화가 닫혀 있으니 아무것도 안 쓰거나 엉뚱한 걸 이어 붙인다.
| 학습 | 추론 | |
|---|---|---|
add_generation_prompt |
False |
True |
| assistant 자리 | 정답이 들어 있다 | 비어 있다 |
| 틀리면 | 이상한 형식을 배운다 | 응답을 안 하거나 이상해진다 |
답변만 채점하기
기본 설정으로 학습하면 모든 토큰에서 손실이 잡힌다. 시스템 프롬프트도, 사용자 질문도 모델이 따라 쓰도록 배운다.
from unsloth.chat_templates import train_on_responses_only
trainer = train_on_responses_only(
trainer,
instruction_part="<start_of_turn>user\n",
response_part="<start_of_turn>model\n",
)
이게 하는 일은 손실을 어디서 잴지 정하는 것이다. 질문 구간의 labels를 -100으로 덮어버린다. 파이토치 CrossEntropyLoss는 -100인 자리를 건너뛰게 되어 있어서 그 구간은 채점에서 빠진다.
입력 토큰 [user 마커] 서리 늑대 [model 마커] 눈보라 속에서 ...
labels -100 -100 -100 눈보라 속에서 ...
(채점 안 함) (채점 안 함) (채점 안 함) (여기만 채점)
입력에서 빼는 게 아니라는 게 중요하다. 질문 토큰은 그대로 모델에 들어가고 어텐션도 다 받는다. 조건으로는 읽되 “이걸 생성해라”라고는 안 가르치는 것뿐이다.
안 그러면 모델이 답변만이 아니라 질문까지 이어 쓰는 버릇이 든다. 도감 설명을 부탁했는데 다음 질문까지 지어내는 식이다.
다만 프롬프트에도 손실을 주는 게 언제나 나쁜 건 아니다. 응답이 짧을 때는 오히려 도움이 된다는 얘기도 있어서, 무조건 켤 것이라기보다 응답이 길 때 챙기면 되는 옵션에 가깝다.
마커 문자열은 모델마다 다르다
조심할 게 하나 있다. <start_of_turn>은 Gemma 형식이다. 다른 모델에 그대로 쓰면 매칭이 안 되는데, 에러가 안 나고 조용히 지나간다. 마스킹이 안 걸린 채로 학습이 그냥 돌아버린다.
Gemma <start_of_turn>user
Llama 3 <|start_header_id|>user<|end_header_id|>
Qwen / ChatML <|im_start|>user
끝에 줄바꿈까지 넣는 것도 이유가 있다. 마커 다음 줄바꿈을 포함해야 토큰 경계가 실제 템플릿과 맞아떨어진다. 확실하지 않으면 tokenizer.apply_chat_template을 한 번 찍어보고 나온 문자열을 그대로 복사해 오는 게 안전하다.
파인튜닝에서 데이터 형식은 취향이 아니다. 그 모델이 학습된 모양 그대로 맞춰줘야 하고, 학습과 추론에서 끝나는 방식이 다르다.
5. 추론에서 다시 줄인다 — INT4와 피크 메모리의 함정
학습이 끝났으면 이제 서비스에 올릴 차례다. 여기서 다시 줄인다.
학습 때 쓴 QLoRA의 4비트와 지금 할 4비트는 목적이 다르다.
| QLoRA의 4비트 | PTQ INT4 | |
|---|---|---|
| 언제 | 학습 중 | 학습 다 끝나고 |
| 왜 | 잠긴 원본을 싸게 올리려고 | 추론을 싸게 하려고 |
| 학습 | 어댑터는 16비트로 배운다 | 아무것도 안 배운다 |
PTQ는 이미 학습이 끝난 모델을 그냥 눌러 담는 것이다. 재학습이 없어서 빠르고, 대신 품질 손실을 감수한다.
from transformers import BitsAndBytesConfig
quantization_config = BitsAndBytesConfig(
load_in_4bit=True, # 4비트로 올린다
bnb_4bit_quant_type="nf4", # 균등이 아니라 NF4 눈금으로
bnb_4bit_compute_dtype=torch.float16, # 저장은 4비트, 계산은 16비트
bnb_4bit_use_double_quant=True, # 눈금 정보까지 한 번 더 압축
)
model = AutoModelForCausalLM.from_pretrained(
model_name, quantization_config=quantization_config, device_map="auto",
)
bnb_4bit_compute_dtype이 흥미로웠다. 저장만 4비트고 계산은 16비트로 되돌려서 한다. 4비트로 곱셈을 하는 게 아니라, 쓸 때마다 풀어서 계산한다는 뜻이다.
가중치는 줄었는데 왜 별로 안 줄지
네 번째로 걸린 게 이거였다. 4비트로 바꾸면 메모리가 4분의 1이 될 줄 알았는데 그만큼 안 줄었다.
재는 게 두 가지라서 그렇다.
| 지표 | 언제 재나 | 뭐가 들어 있나 |
|---|---|---|
| 모델 메모리 | 로딩 직후 | 가중치만 |
| 피크 메모리 | 추론 중 최대 | 가중치 + 활성화값 + KV 캐시 + 임시 버퍼 |
양자화는 가중치만 압축한다. 추론 중에 생기는 활성화값과 KV 캐시는 여전히 16비트다. 그러니 모델 메모리는 크게 줄어도 피크 메모리는 그만큼 안 준다.
그래서 모델이 클수록 양자화가 잘 먹힌다. 70B쯤 되면 전체에서 가중치 비중이 압도적이라 줄인 만큼 그대로 이득이지만, 1B짜리는 가중치를 줄여도 나머지가 그대로라 체감이 적다.
4비트로 바꿨는데 메모리가 기대만큼 안 줄었다면 잘못 잰 게 아니다. 줄어드는 건 아래칸뿐이고 윗칸은 그대로다.
6. 모델을 안 고치고 입력으로 메꾼다 — TTP
줄였으면 대가를 치른다. 양자화 모델은 입력이 조금만 흐트러져도 더 크게 흔들린다.
같은 질문을 이렇게 넣어보면 차이가 난다.
| 유형 | 예 |
|---|---|
| 정상 | “서리 늑대는 어떤 몬스터야?” |
| 오타 | “서리 늑데는 어떤 몬스터야?” |
| 군더더기 | “음… 그 서리 늑대? 그거 어떤 몬스터였지 대충” |
| 맥락 부족 | “그거 뭐였지?” |
원본 모델은 오타를 그럭저럭 넘기는데, 4비트 모델은 여기서 더 자주 헛나간다.
이유가 세 가지로 갈린다.
- 오차가 쌓인다 — 가중치마다 생긴 작은 오차가 수십억 번 연산을 거치며 누적된다
- 볼 곳을 잘못 고른다 — Attention 점수가 미세하게 어긋나면 다른 토큰에 집중하게 된다
- 드문 패턴이 먼저 깨진다 — 자주 본 패턴은 가중치가 강해서 버티는데, 드문 패턴은 약해서 눌리는 순간 사라진다
여기서 모델을 다시 손대지 않고 해결하는 방법이 나온다. Test-time Prompting(TTP) — 추론할 때 프롬프트를 강화하는 것이다.
TTP_TEMPLATE = """너는 게임 몬스터 도감을 쓰는 작가다.
규칙:
- 서식지, 공격 방식, 약점을 순서대로 쓴다
- 각 항목은 한 문장으로 쓴다
- 모르는 몬스터면 "도감에 없습니다"라고만 답한다
예시)
질문: 늪지 두꺼비
답변: 습지대에 무리 지어 산다. 혀로 원거리 공격을 한다. 불 속성에 약하다.
질문: {question}
답변:"""
prompt = TTP_TEMPLATE.format(question="서리 늑데는 어떤 몬스터야?")
넣은 게 네 가지다.
| 넣은 것 | 무엇을 막나 |
|---|---|
| 규칙 명시 | 아무 형식으로나 답하는 것 |
| 출력 형식 지정 | 길이와 순서가 매번 달라지는 것 |
| 예시 한 개 | 모양을 못 잡는 것 |
| 모를 때의 답 지정 | 지어내는 것 |
재학습이 없다. 파일 하나 안 바꾸고 문자열만 갈아끼우는데 눈에 띄게 안정된다. < 10 >에서 본 Few-shot과 같은 도구인데, 여기서는 양자화로 잃은 걸 메우는 용도로 쓰인다.
물론 이게 만능은 아니다. 프롬프트가 길어지면 입력 토큰이 늘어 비용이 오르고, 애초에 깨진 지식을 되살리지는 못한다. 어디까지나 흔들리는 걸 붙잡아주는 정도다.
줄이는 쪽만 보면 손해처럼 보이는데, 잃은 걸 다른 층에서 메울 수 있다. 모델을 못 고치는 상황이 훨씬 많다는 걸 생각하면 이게 오히려 현실적인 수단이다.
정리
- 모델 크기는 숫자 개수 × 숫자 하나의 크기다. 7B 가중치만 FP32로 26.1GB, FP16으로 13.0GB, NF4로 3.3GB (파라미터 수로 계산한 값)
- 학습이 비싼 건 가중치가 아니라 기울기와 옵티마이저 때문이다. AdamW는 파라미터마다 평균·분산 두 개를 FP32로 들고 있어서 7B면 그것만 52GB다
-
LoRA는 원본을 잠그고 옆에 얇은 행렬 두 개만 학습한다.
4096 × 4096행렬 기준r=8이면 학습 대상이 0.39%로 줄어든다 -
r은 두께,lora_alpha는 세기다. 수식의α/r때문에r을 키우면α도 같이 키워야 세기가 유지된다. 그래서alpha = r × 2관례가 생겼다 -
target_modules는 적게 붙이면 싸고 많이 붙이면 잘 배운다. 요즘은 Attention과 MLP 일곱 자리에 전부 붙이는 쪽을 많이 쓴다 -
add_generation_prompt는 학습에서False, 추론에서True다. 추론에서False로 두면 모델이 자기 차례인 줄 모른다 - 양자화는 가중치만 압축한다. 활성화값과 KV 캐시는 그대로라 모델 메모리는 줄어도 피크 메모리는 덜 준다. 모델이 클수록 효과가 크다
- 줄이면 오타·모호한 입력에서 먼저 흔들린다. 그런데 모델을 안 고치고 프롬프트에 규칙·형식·예시를 넣는 것만으로 상당 부분 붙잡힌다
참고 자료
- LoRA: Low-Rank Adaptation of Large Language Models (Hu, 2021)
- QLoRA: Efficient Finetuning of Quantized LLMs (Dettmers, 2023)
- HuggingFace PEFT 문서
- Transformers — Quantization 가이드
- Unsloth 문서
한줄 평
- 다섯 편을 지나고 보니 결국 같은 질문이었다 — 어디를 잠그고 어디를 풀 것인가 -> 그리고 못 푸는 자리가 나오면 모델 밖에서 메꾸면 된다는 걸 마지막에 배웠다!