대형 언어 모델을 자기 도메인에 맞게 파인튜닝하려 할 때 가장 먼저 부딪히는 벽은 VRAM입니다. 70억 파라미터 모델을 전체 파인튜닝(full fine-tuning)하려면 가중치·그래디언트·옵티마이저 상태를 모두 올려야 하고, 이 셋만으로도 파라미터 하나당 대략 16바이트가 필요합니다. 7B 기준 100GB를 넘기기 일쑤라, 24GB짜리 소비자용 GPU 한 장으로는 시작조차 못 합니다.
이 글에서는 그 벽을 낮추는 두 기법, LoRA(Low-Rank Adaptation)와 QLoRA(Quantized LoRA)를 실전 관점에서 다룹니다. 왜 메모리가 그렇게 드는지, 어댑터가 무엇을 학습하는지, 어떤 하이퍼파라미터가 결과를 좌우하는지, 24GB 한 장으로 실제 학습을 돌리는 설정까지 순서대로 정리합니다. 예시 코드는 Hugging Face의 peft·transformers·bitsandbytes 조합을 기준으로 합니다.
메모리는 어디로 사라지는가
파인튜닝 중 VRAM을 잡아먹는 항목은 네 가지입니다. 모델 가중치, 그래디언트, 옵티마이저 상태, 그리고 순전파 중 저장되는 활성값(activation)입니다. Adam 계열은 파라미터마다 모멘텀 두 개를 fp32로 들고 있어, 이 넷을 합치면 전체 파인튜닝은 파라미터당 14~16바이트에 이릅니다.
- 가중치: fp16 기준 파라미터당 2바이트. 7B면 약 14GB.
- 그래디언트: 학습 가능한 파라미터당 2~4바이트.
- 옵티마이저 상태: Adam은 파라미터당 8바이트(모멘텀 2개 fp32).
- 활성값: 배치·시퀀스 길이·레이어 수에 비례. 가장 예측이 어려운 항목.
핵심 통찰은 이것입니다. 그래디언트와 옵티마이저 상태는 학습 가능한 파라미터에만 붙습니다. 대부분의 가중치를 얼려 버리고 아주 작은 부분만 학습하면 이 두 항목이 극적으로 줄어드는데, LoRA가 정확히 이 지점을 공략합니다.
LoRA의 아이디어: 델타를 저랭크로 근사
파인튜닝이란 결국 가중치 W에 변화량 ΔW를 더하는 일입니다(W' = W + ΔW). LoRA의 가정은 이 ΔW가 낮은 랭크(low rank)로 충분히 근사된다는 것입니다. 즉 d×k 큰 행렬을 통째로 학습하는 대신 ΔW = B·A로 쪼개 A(r×k)와 B(d×r)만 학습합니다. r은 랭크로, 보통 8~64의 작은 값입니다.
d=k=4096 레이어라면 전체 파인튜닝은 약 1,600만 파라미터를 학습하지만, r=8 LoRA는 4096×8 + 8×4096 = 6.5만 개만 학습합니다. 약 250배 적고, 원본 W는 얼어 있으므로 그래디언트·옵티마이저 상태가 아예 필요 없습니다.
# LoRA 순전파: 원본 W 는 얼어 있고(requires_grad=False) A·B 만 학습
import torch
def lora_forward(x, W, A, B, alpha, r):
base = x @ W.T # 원본 경로: 그래디언트 흐르지 않음
delta = (x @ A.T) @ B.T # 저랭크 델타 경로: 여기만 학습
scaling = alpha / r # 스케일 팩터
return base + scaling * delta
A는 정규분포로, B는 0으로 초기화합니다. 그러면 학습 시작 시점에 B·A = 0이 되어 어댑터가 원본 출력을 바꾸지 않은 상태에서 안전하게 출발하고, 학습이 진행되며 델타가 점점 유의미해집니다.
QLoRA: 얼어 있는 가중치를 4비트로
LoRA는 그래디언트·옵티마이저 상태를 줄이지만, 얼어 있는 원본 가중치는 여전히 fp16으로 통째로 VRAM에 올라갑니다. 7B면 14GB로 이 부분만으로도 소비자용 GPU를 넘깁니다. QLoRA는 이 베이스를 4비트로 양자화해 올려, 14GB를 약 4GB로 줄입니다.
QLoRA의 요점은 세 가지입니다. 첫째 NF4(4-bit NormalFloat)라는, 정규분포 가중치에 정보이론적으로 최적화된 데이터 타입을 씁니다. 둘째 이중 양자화로 양자화 상수 자체를 다시 양자화해 메모리를 더 아낍니다. 셋째 옵티마이저 상태를 CPU로 내보내는 paged optimizer로 OOM 스파이크를 흡수합니다. 베이스는 4비트로 얼려 두되 어댑터는 bf16으로 학습하고, 순전파 시 4비트 가중치를 필요한 순간 bf16으로 역양자화해 계산하므로 정밀도 손실을 어댑터가 보상할 여지가 생깁니다.
# QLoRA: bitsandbytes 로 베이스 모델을 4비트로 로드
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # 4비트 양자화 활성화
bnb_4bit_quant_type="nf4", # NF4: 정규분포 가중치에 최적
bnb_4bit_use_double_quant=True, # 이중 양자화로 추가 절약
bnb_4bit_compute_dtype=torch.bfloat16, # 실제 계산은 bf16 로 수행
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf",
quantization_config=bnb_config,
device_map="auto", # 단일 GPU면 전부 cuda:0 로
)
어떤 레이어에 어댑터를 붙일 것인가
흔한 오해가 “어텐션의 쿼리·밸류 두 곳에만 붙이면 된다”는 초기 관례입니다. 실제로는 어텐션 투영과 MLP 전체에 골고루 붙일수록 성능이 안정적이라, QLoRA 실전에서는 모든 선형 레이어를 대상으로 삼는 것이 기본에 가깝습니다. 대상 모듈 이름은 아키텍처마다 다른데, LLaMA 계열이라면 어텐션의 q_proj·k_proj·v_proj·o_proj와 MLP의 gate_proj·up_proj·down_proj가 표준입니다.
# PEFT LoraConfig: 대상 모듈과 랭크·알파 지정
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
model = prepare_model_for_kbit_training(model) # 4비트 학습용 전처리(레이어놈 fp32 고정 등)
lora_config = LoraConfig(
r=16, # 랭크: 표현력과 메모리의 트레이드오프
lora_alpha=32, # 스케일 = alpha/r = 2.0
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj", # MLP 까지 전부
],
lora_dropout=0.05,
bias="none", # bias 는 학습 제외
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 예: trainable params: 40M || all: 6.7B || trainable%: 0.6
랭크와 알파, 그리고 학습률
세 하이퍼파라미터가 LoRA 결과를 사실상 결정합니다. r(랭크), lora_alpha, 그리고 학습률입니다.
- 랭크 r: 어댑터의 표현력. 너무 작으면 언더피팅, 너무 크면 작은 데이터셋에서 과적합합니다. 지시 튜닝은
r=8~16, 새 지식·복잡한 추론은r=32~64를 시도해 볼 만합니다. - lora_alpha: 실효 스케일은
alpha/r입니다. 관례적으로alpha = 2r로 두면 스케일이 2로 고정돼,r을 바꿔도 델타 크기 감이 흔들리지 않아 튜닝이 수월합니다. - 학습률: LoRA는 훨씬 높은 학습률을 견딥니다.
1e-4 ~ 3e-4가 흔한 출발점이고, 전체 파인튜닝의2e-5를 그대로 쓰면 학습이 거의 진행되지 않습니다.
실전 주의점은 랭크를 올렸으면 알파도 같이 올리라는 것입니다. alpha를 고정한 채 r만 키우면 실효 스케일 alpha/r이 작아져, 랭크를 늘렸는데도 델타의 영향력은 오히려 줄어듭니다.
24GB 한 장으로 7B 학습하기
실제 학습 루프는 transformers의 Trainer(또는 trl의 SFTTrainer)에 지금까지의 조각을 끼우면 됩니다. 24GB에서 7B를 QLoRA로 돌릴 때 핵심은 배치를 작게 잡고 그래디언트 누적으로 실효 배치를 키우며, 그래디언트 체크포인팅으로 활성값 메모리를 시간과 맞바꾸는 것입니다.
# Trainer 설정: 메모리를 아끼는 핵심 플래그들
from transformers import TrainingArguments, Trainer
args = TrainingArguments(
output_dir="./out",
per_device_train_batch_size=1, # 물리 배치는 최소로
gradient_accumulation_steps=16, # 실효 배치 = 1 x 16
gradient_checkpointing=True, # 활성값을 재계산 → VRAM 절약(속도 희생)
learning_rate=2e-4, # LoRA 는 높은 학습률을 견딘다
bf16=True, # bf16 혼합정밀
optim="paged_adamw_8bit", # paged 8bit Adam: OOM 흡수
warmup_ratio=0.03,
lr_scheduler_type="cosine",
num_train_epochs=3,
max_grad_norm=0.3, # 그래디언트 클리핑으로 안정화
)
gradient_checkpointing은 순전파 중 활성값을 저장하지 않고 역전파 때 다시 계산해, 메모리를 크게 줄이는 대신 속도를 20~30% 희생합니다. 7B QLoRA는 4비트 베이스(~4GB) + 어댑터·그래디언트·옵티마이저(~1GB) + 활성값(수 GB) 정도라, 시퀀스 길이를 2048 안팎으로 잡으면 24GB에 들어갑니다. 길이를 늘리면 활성값이 급증하니 VRAM이 빠듯할 때 여기서 먼저 조절합니다.
학습이 끝난 뒤: 어댑터 병합과 배포
LoRA 학습의 산출물은 원본이 아니라 수십 MB짜리 어댑터 파일뿐입니다. 배포는 두 갈래입니다.
- 병합:
W' = W + (alpha/r)·B·A를 미리 계산해 어댑터를 원본에 흡수시킵니다. 추론 시 델타 경로가 사라져 오버헤드가 0이 됩니다. 단, fp16 원본에 병합해야 정밀도 손실이 적습니다. - 비병합(핫스왑): 하나의 베이스 위에 여러 어댑터를 갈아 끼웁니다. 도메인별 어댑터를 수십 개 운영할 때 베이스 하나만 메모리에 두면 됩니다.
# 어댑터 병합: 4비트가 아니라 fp16 원본 위에서 해야 정밀도 손실이 적다
from peft import PeftModel
from transformers import AutoModelForCausalLM
import torch
base = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf",
torch_dtype=torch.float16, # 병합은 고정밀 베이스 위에서
)
merged = PeftModel.from_pretrained(base, "./out/checkpoint-final")
merged = merged.merge_and_unload() # 델타를 W 에 흡수, 어댑터 제거
merged.save_pretrained("./merged-model")
흔한 함정과 검증
QLoRA 학습에는 반복적인 실수들이 있습니다. 코드가 에러 없이 돌아가도 학습이 조용히 무의미해지는 경우가 많아 검증이 중요합니다.
- 학습률이 너무 낮음: 전체 파인튜닝의
2e-5를 그대로 쓰면 로스가 안 내려갑니다. LoRA는1e-4이상에서 출발합니다. - 4비트 상태로 병합: 양자화된 베이스 위에서
merge_and_unload를 하면 4비트 오차가 굳어 품질이 떨어집니다. fp16 원본에 병합합니다. - 대상 모듈 누락:
target_modules가 아키텍처와 안 맞으면 어댑터가 안 붙어 학습 파라미터가 0에 가깝게 나옵니다.print_trainable_parameters()로 확인합니다. - 마스킹 오류: 프롬프트 부분까지 로스에 넣으면 모델이 질문을 따라 하게 됩니다. 응답 토큰에만 로스를 걸어야 합니다.
검증은 로스 곡선만 믿지 말고, 학습 전후 모델에 동일한 프롬프트를 넣어 출력을 직접 비교하는 것이 확실합니다. 로스가 잘 내려갔는데 출력이 안 변했다면 어댑터가 실제로 로드·적용되는지부터 의심해야 합니다.
마무리
LoRA는 “델타는 저랭크로 충분하다”는 가정으로 학습 대상을 0.1~1%로 줄여 그래디언트·옵티마이저 메모리를 걷어내고, QLoRA는 여기에 베이스를 4비트로 눌러 담아 소비자용 GPU 한 장으로도 7B급 파인튜닝을 현실로 만듭니다. 결과를 가르는 것은 화려한 트릭이 아니라 기본기입니다. 대상 모듈을 넓게 잡고, 랭크에 맞춰 알파를 조정하고, 충분히 높은 학습률을 쓰고, 병합은 fp16 원본 위에서 하는 것. 그리고 무엇보다, 로스 숫자가 아니라 실제 출력으로 학습이 됐는지를 눈으로 확인하는 습관이 가장 값집니다.