PEFT LoRA + QLoRA: 3B 모델을 작은 GPU에서 튜닝하는 방법
모델 전체를 학습하면 GPU 메모리가 너무 많이 듭니다. LoRA는 원래 가중치를 얼려 두고 작은 보조 행렬만 학습하고, QLoRA는 여기에 4bit 양자화를 더해 메모리를 더 줄입니다. 오늘은 3B 모델을 PEFT로 LoRA SFT와 QLoRA SFT로 튜닝하고, Full FT·LoRA·QLoRA의 VRAM·시간·학습 파라미터 수를 표로 비교합니다.
By 송민성
오늘의 목표
3B(30억 파라미터) 모델은 전체를 학습하려면 일반 소비자용 GPU 한 장에 올리기 어렵습니다. 오늘은 모델의 극히 일부만 학습하는 LoRA와, 모델을 4bit로 압축해 올리는 QLoRA로 이 문제를 풀고, 세 방식(Full FT, LoRA, QLoRA)의 메모리·시간·학습 파라미터 수를 직접 재서 비교합니다.
개념과 직관
왜 Full Fine-tuning(전체 미세조정)은 메모리를 많이 먹나
학습 중에는 GPU에 모델 가중치만 올라가는 게 아닙니다. 대략 다음이 함께 올라갑니다.
- 가중치(weights): 모델 자체
- 기울기(gradient, 각 파라미터를 어느 방향으로 바꿀지 나타내는 값): 파라미터와 같은 개수
- 옵티마이저 상태(optimizer state): Adam 같은 옵티마이저는 파라미터마다 값을 2개씩 더 기억
- 활성값(activation): 순전파 중 중간 계산 결과. 배치 크기와 시퀀스 길이에 따라 커짐
대략적인 감을 잡기 위한 계산입니다. 3B 파라미터를 16bit(2바이트)로 저장하면 가중치만 약 6GB입니다. 기울기가 약 6GB, Adam 상태는 보통 더 높은 정밀도로 저장해서 그보다 훨씬 큽니다. 여기에 활성값까지 더하면 24GB급 GPU에서도 빠듯할 수 있습니다. 정확한 수치는 설정(정밀도, 옵티마이저, 배치 크기)에 따라 달라지니 오늘 실험에서 직접 측정합니다.
핵심은 학습하는 파라미터 수에 비례해서 기울기와 옵티마이저 상태가 커진다는 점입니다. 그러면 학습하는 파라미터를 줄이면 됩니다.
LoRA: 큰 행렬은 얼리고, 얇은 보조 행렬만 학습
PEFT(Parameter-Efficient Fine-Tuning, 파라미터 일부만 학습해서 미세조정 비용을 줄이는 방법들의 총칭) 중 가장 널리 쓰이는 것이 LoRA(Low-Rank Adaptation, 저랭크 적응)입니다.
비유: 두꺼운 백과사전(원래 가중치)의 본문은 건드리지 않고, 각 페이지에 얇은 포스트잇(보조 행렬)만 붙여서 내용을 살짝 고치는 방식입니다. 책을 다시 인쇄할 필요가 없습니다.
수식으로는 이렇습니다. 어떤 층의 가중치 W가 d×k 크기의 큰 행렬이라고 합시다.
원래: y = W x
LoRA: y = W x + (alpha / r) * B A x
W : d×k (얼림, 학습 안 함)
A : r×k (학습)
B : d×r (학습)
r : 랭크(rank). d, k보다 훨씬 작은 수 (예: 8, 16)학습 파라미터 수를 비교해 봅시다. d = k = 4096, r = 8이면:
W 전체: 4096 × 4096 = 16,777,216
A + B: 8×4096 + 4096×8 = 65,536 (약 0.4%)B는 0으로, A는 작은 랜덤 값으로 초기화합니다. 그래서 학습 시작 시점에는 B A x = 0이라 모델이 원래 모델과 똑같이 동작합니다. 거기서부터 조금씩 변하는 겁니다.
주요 설정(LoraConfig)의 의미는 다음과 같습니다.
r: 보조 행렬의 랭크. 클수록 표현력이 늘고 파라미터도 늘어남lora_alpha: 보조 행렬 출력의 크기를 조절하는 값(위 수식의 alpha). 실제 배율은 alpha / rtarget_modules: LoRA를 붙일 층의 이름들(보통 attention의 q_proj, v_proj 등. 더 많이 붙이기도 함)lora_dropout: 보조 행렬 쪽에 거는 드롭아웃
QLoRA: 얼려 둔 원래 가중치를 4bit로 압축
LoRA는 학습 파라미터를 줄이지만, 얼려 둔 W 자체는 여전히 16bit로 GPU에 올라가 있습니다. QLoRA는 이 W를 4bit로 압축해서 올립니다. 보조 행렬 A, B는 16bit 등 높은 정밀도로 학습합니다.
- 양자화(quantization, 숫자를 더 적은 bit로 표현해 메모리를 줄이는 것): 16bit 실수를 4bit(값 16종류)로 근사. 사진을 저화질로 압축하는 것과 비슷합니다. 약간의 오차를 감수하고 크기를 약 1/4로 줄입니다.
- NF4(NormalFloat4): 4bit 값 16개를 어디에 배치할지 정하는 방식. 학습된 가중치는 대체로 0 근처에 몰려 종 모양으로 분포하는데, NF4는 이 분포에 맞게 값을 촘촘하게 배치합니다.
- double quantization(이중 양자화): 양자화에 쓰는 보정 상수(scale)도 다시 양자화해서 메모리를 조금 더 아끼는 기법
- bitsandbytes: 4bit/8bit 양자화 연산을 제공하는 라이브러리. 주로 NVIDIA GPU(CUDA) 환경에서 사용합니다.
계산 시에는 4bit 가중치를 그때그때 16bit로 풀어서(dequantize) 쓰고, 기울기는 보조 행렬에만 흐릅니다.
gradient checkpointing
활성값을 전부 저장하는 대신 일부만 저장하고, 역방향 계산(기울기 계산) 때 필요한 부분을 다시 계산하는 기법입니다. 시험 범위를 전부 외우는 대신 핵심만 적어 두고 필요할 때 다시 풀어 보는 것과 비슷합니다. 메모리는 줄고 시간은 늘어납니다. 이 거래(trade-off)를 오늘 실험에서 확인합니다.
코드로 직접 해보기
환경은 CUDA GPU가 있는 리눅스/Colab을 가정합니다. macOS(Apple Silicon)에서는 bitsandbytes 4bit가 제한적일 수 있으니, 이 경우 Colab 같은 외부 GPU를 쓰세요.
pip install transformers peft trl bitsandbytes datasets accelerate1단계: 원리 먼저, LoRA 층을 직접 만들어 보기
라이브러리를 쓰기 전에 위 수식을 PyTorch로 짧게 구현해서 파라미터 수를 세어 봅니다.
import torch
import torch.nn as nn
class LoRALinear(nn.Module):
def __init__(self, base: nn.Linear, r=8, alpha=16):
super().__init__()
self.base = base
for p in self.base.parameters():
p.requires_grad = False # 원래 가중치는 얼림
self.A = nn.Parameter(torch.randn(r, base.in_features) * 0.01)
self.B = nn.Parameter(torch.zeros(base.out_features, r)) # 0으로 시작
self.scale = alpha / r
def forward(self, x):
return self.base(x) + self.scale * (x @ self.A.T @ self.B.T)
base = nn.Linear(4096, 4096)
lora = LoRALinear(base, r=8)
total = sum(p.numel() for p in lora.parameters())
trainable = sum(p.numel() for p in lora.parameters() if p.requires_grad)
print(total, trainable, f"{trainable/total:.2%}")
x = torch.randn(2, 4096)
print(torch.allclose(lora(x), base(x))) # B=0이므로 Truetrainable이 약 65,536개, 전체의 0.4% 안팎으로 나오고, 마지막 줄은 True여야 합니다.
2단계: PEFT로 LoRA SFT
SFT(Supervised Fine-Tuning, 정답 예시를 보여주며 학습시키는 지도 미세조정)는 앞에서 다룬 흐름을 그대로 씁니다. 여기서는 모델을 감싸는 부분에 집중합니다. 모델 이름은 본인이 쓸 3B급 모델로 바꾸세요.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
model_name = "YOUR_3B_MODEL" # 예: 허깅페이스의 3B급 모델 이름
tok = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
model_name, torch_dtype=torch.bfloat16, device_map="auto"
)
lora_cfg = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
target_modules=["q_proj", "v_proj"], # 모델마다 층 이름이 다름
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_cfg)
model.print_trainable_parameters() # 학습 파라미터 수와 비율 출력target_modules 이름은 모델마다 다릅니다. print(model)로 층 이름을 확인하세요.
학습은 TRL의 SFTTrainer를 쓰면 짧게 쓸 수 있습니다. TRL 버전에 따라 인자 이름이 바뀌는 경우가 있으니, 설치한 버전의 문서를 확인하세요.
from datasets import load_dataset
from trl import SFTTrainer, SFTConfig
ds = load_dataset("YOUR_SFT_DATASET", split="train[:1000]") # 작게 시작
args = SFTConfig(
output_dir="out-lora",
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
learning_rate=2e-4,
max_steps=100,
logging_steps=10,
bf16=True,
)
trainer = SFTTrainer(model=model, args=args, train_dataset=ds, processing_class=tok)
trainer.train()LoRA는 보통 Full FT보다 큰 학습률(예: 1e-4~2e-4 근처)을 씁니다. 출발점일 뿐이니 loss 곡선을 보고 조정하세요.
3단계: 3B 모델을 4bit로 로드해 QLoRA SFT
바뀌는 부분은 모델 로드 방식과 준비 함수입니다.
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import prepare_model_for_kbit_training, get_peft_model
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained(
model_name, quantization_config=bnb, device_map="auto"
)
model = prepare_model_for_kbit_training(model) # gradient checkpointing 등 학습 준비
model = get_peft_model(model, lora_cfg)
model.print_trainable_parameters()이후 SFTConfig에 gradient_checkpointing=True를 추가해 학습합니다. LoRA 설정은 2단계와 같게 두어야 공정한 비교가 됩니다.
오늘의 실험
질문: 같은 모델·같은 데이터로 Full FT, LoRA, QLoRA를 돌리면 VRAM, 학습 시간, 학습 파라미터 수가 얼마나 달라지나?
통제할 것(고정): 모델, 데이터(1,000개 정도), 시퀀스 길이, 배치 크기, max_steps(예: 50~100), 시드.
바꿀 것: 학습 방식 3가지. Full FT가 GPU에 안 올라가면 OOM(Out Of Memory, GPU 메모리 부족)이 난 사실 자체가 결과입니다. 그 경우 더 작은 모델(예: 1B급)로 세 방식을 모두 돌려 비교하고, 3B는 LoRA/QLoRA만 기록해도 됩니다.
측정 방법:
import time, torch
torch.cuda.reset_peak_memory_stats()
t0 = time.time()
trainer.train()
elapsed = time.time() - t0
peak_gb = torch.cuda.max_memory_allocated() / 1024**3
print(f"시간 {elapsed:.0f}s, 최대 VRAM {peak_gb:.1f}GB")학습 파라미터 수는 sum(p.numel() for p in model.parameters() if p.requires_grad)로 셉니다. 방식마다 새 프로세스(또는 런타임 재시작)에서 측정해야 이전 실행의 메모리가 섞이지 않습니다.
기록 표(직접 채우기):
| 방식 | 학습 파라미터 수 | 최대 VRAM(GB) | 학습 시간(s) | 마지막 loss | 비고 |
|---|---|---|---|---|---|
| Full FT | OOM이면 기록 | ||||
| LoRA (r=16) | |||||
| QLoRA (r=16) | |||||
| QLoRA + grad ckpt |
관찰 포인트:
- QLoRA가 LoRA보다 VRAM은 줄지만 시간은 어떻게 변하나(4bit를 풀어 쓰는 비용이 있음)
- gradient checkpointing을 켜고 끌 때 VRAM과 시간이 각각 어떻게 변하나
- r을 8/16/64로 바꾸면 파라미터 수와 loss는?
수치를 미리 정해 두고 맞추려 하지 말고, 나온 값을 그대로 적으세요. 환경마다 값이 다릅니다.
왜 이걸 배우나 (LLM / Post-training 연결)
대부분의 연구 팀은 실험할 때마다 큰 모델 전체를 학습할 수 없으므로, 데이터나 하이퍼파라미터를 빠르게 바꿔 보는 단계에서 LoRA/QLoRA를 널리 씁니다. 이후 Project 2의 SFT 실험은 물론, 정렬(alignment) 단계에서 선호 데이터로 학습할 때도 같은 PEFT 구조를 재사용하게 됩니다. LLM Post-training 업무에서는 "제한된 GPU로 어떤 설정이 효율적인가"를 수치로 설명할 수 있어야 하는데, 오늘 만든 비교 표가 그 연습입니다.
초보자가 막히는 지점
- `target_modules` 에러: 층 이름이 모델마다 달라 "Target modules not found" 에러가 납니다.
print(model)로 실제nn.Linear층 이름을 확인해 맞추세요. - bitsandbytes 설치/CUDA 에러: CPU 전용 환경이나 일부 macOS에서는 4bit가 동작하지 않을 수 있습니다. Colab 등 CUDA GPU 환경을 쓰세요.
- OOM: 배치 크기를 줄이고
gradient_accumulation_steps를 늘려 실질 배치를 유지하세요. 시퀀스 길이를 줄이거나 gradient checkpointing을 켜는 것도 방법입니다. - "4bit면 학습도 4bit로 하는 거죠?" 오해: 아닙니다. 얼린 원래 가중치만 4bit이고, 학습되는 LoRA 행렬과 기울기는 bf16 같은 높은 정밀도입니다.
- gradient checkpointing 경고: 캐시 관련 경고(
use_cache비활성화 안내)가 뜰 수 있습니다. 학습 시에는 보통 끄는 게 맞고, 경고만으로 실패한 것은 아닙니다. - loss가 안 내려갈 때: 데이터 포맷(프롬프트 템플릿)이 맞는지, 학습률이 너무 낮거나 높지 않은지 먼저 확인하세요.
- VRAM 측정이 이상함: 같은 프로세스에서 여러 번 돌리면 이전 메모리가 남습니다. 방식마다 새로 시작하세요.
nvidia-smi값은 캐시 때문에max_memory_allocated보다 크게 보일 수 있습니다. - 저장한 어댑터만으로 추론이 안 됨: LoRA 저장 결과는 보조 행렬뿐입니다. 사용할 때는 같은 베이스 모델을 먼저 로드한 뒤 어댑터를 붙여야 합니다.
알고리즘 (병행 1시간)
LeetCode Medium 2문제(그래프)를 풉니다. 예를 들어 Number of Islands(BFS/DFS 격자 탐색), Course Schedule(위상 정렬/사이클 탐지)처럼 그래프 탐색의 기본형을 고르면 좋습니다. 문제는 본인이 골라도 됩니다.
풀이 후 문제별로 아래를 기록하세요.
- 접근: 그래프를 어떻게 표현했는지(인접 리스트/격자), BFS와 DFS 중 무엇을 왜 골랐는지
- 시간복잡도·공간복잡도: 노드 수 V, 간선 수 E 기준으로 직접 유도
- 틀린 이유: 방문 체크 누락, 재귀 깊이, 경계 조건 등 막힌 지점과 고친 방법