LLM 25일 코스 - Day 19: LoRA와 QLoRA 이해

Day 19: LoRA와 QLoRA 이해

LoRA는 대규모 모델의 가중치를 직접 수정하지 않고, 작은 행렬 쌍을 추가하여 효율적으로 파인튜닝하는 기법입니다. QLoRA는 여기에 4bit 양자화를 결합하여 메모리를 더욱 절약합니다.

LoRA의 핵심 원리

원래 가중치 행렬 W (d x d)를 업데이트할 때, Full FT는 전체 W를 수정합니다. LoRA는 W를 고정하고 저랭크 행렬 분해 W + BA(B: d x r, A: r x d, r ≪ d)만 학습합니다. rank r이 작을수록 파라미터가 적습니다.

import torch
import torch.nn as nn

# LoRA의 핵심 개념을 간단한 코드로 이해
class LoRALayer(nn.Module):
    """LoRA 어댑터: 원래 가중치는 고정, 저랭크 행렬만 학습"""

    def __init__(self, original_layer, rank=8, alpha=16):
        super().__init__()
        in_features = original_layer.in_features
        out_features = original_layer.out_features

        self.original = original_layer
        self.original.weight.requires_grad = False  # 원래 가중치 고정

        # 저랭크 행렬 A, B (학습 대상)
        self.lora_A = nn.Linear(in_features, rank, bias=False)
        self.lora_B = nn.Linear(rank, out_features, bias=False)
        self.scaling = alpha / rank  # 스케일링 팩터

        # A는 정규분포, B는 0으로 초기화 (초기에는 원래 모델과 동일)
        nn.init.kaiming_normal_(self.lora_A.weight)
        nn.init.zeros_(self.lora_B.weight)

    def forward(self, x):
        original_output = self.original(x)
        lora_output = self.lora_B(self.lora_A(x)) * self.scaling
        return original_output + lora_output

# 파라미터 수 비교
d = 4096  # LLM의 일반적인 히든 사이즈
rank = 8
original_params = d * d                    # 16,777,216
lora_params = (d * rank) + (rank * d)      # 65,536
print(f"원래 파라미터: {original_params:,}")
print(f"LoRA 파라미터: {lora_params:,} ({lora_params/original_params*100:.2f}%)")

rank와 alpha 파라미터 이해

# rank와 alpha의 관계 및 권장값
configs = [
    {"rank": 4,  "alpha": 8,  "용도": "가벼운 스타일 변경"},
    {"rank": 8,  "alpha": 16, "용도": "일반적인 파인튜닝 (기본 추천)"},
    {"rank": 16, "alpha": 32, "용도": "복잡한 태스크, 도메인 적응"},
    {"rank": 64, "alpha": 128,"용도": "Full FT에 근접한 성능 필요 시"},
]

for cfg in configs:
    scaling = cfg["alpha"] / cfg["rank"]
    d = 4096
    params = 2 * d * cfg["rank"]
    print(f"rank={cfg['rank']:3d}, alpha={cfg['alpha']:3d}, "
          f"scaling={scaling:.1f}, 파라미터={params:,}, "
          f"용도: {cfg['용도']}")

# 핵심 규칙:
# - rank가 높을수록 표현력 증가, 메모리도 증가
# - alpha는 보통 rank의 2배로 설정 (alpha = 2 * rank)
# - scaling = alpha / rank, 이 값이 LoRA의 학습률 조절

QLoRA: 4bit 양자화 + LoRA

QLoRA는 기본 모델을 4bit로 양자화하여 메모리를 대폭 줄이고, 그 위에 LoRA를 적용합니다. 7B 모델을 소비자 GPU(RTX 3090/4090)에서도 파인튜닝할 수 있게 해줍니다.

from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch

# 4bit 양자화 설정 (QLoRA)
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,                     # 4bit 양자화 활성화
    bnb_4bit_quant_type="nf4",             # NormalFloat4 타입 (권장)
    bnb_4bit_compute_dtype=torch.bfloat16, # 계산은 bfloat16
    bnb_4bit_use_double_quant=True,        # 이중 양자화 (추가 메모리 절약)
)

# 4bit 양자화된 모델 로드
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-3.1-8B-Instruct",
    quantization_config=bnb_config,
    device_map="auto",
)

# 메모리 사용량 비교표
memory_comparison = {
    "정밀도":    ["FP32", "FP16", "INT8", "INT4(QLoRA)"],
    "7B 모델":  ["28GB", "14GB", "7GB",  "3.5GB"],
    "13B 모델": ["52GB", "26GB", "13GB", "6.5GB"],
    "70B 모델": ["280GB","140GB","70GB", "35GB"],
}
for key, vals in memory_comparison.items():
    print(f"{key:10} | {'  |  '.join(vals)}")

QLoRA를 사용하면 7B 모델을 약 6GB VRAM으로 파인튜닝할 수 있습니다. 이는 RTX 3060(12GB)에서도 충분한 수준입니다.

오늘의 연습문제

  1. 위의 LoRALayer 클래스를 확장하여 rank를 4, 8, 16, 32로 바꿔가며 학습 가능 파라미터 수와 전체 파라미터 대비 비율을 표로 정리해보세요.
  2. BitsAndBytesConfig에서 load_in_8bit=Trueload_in_4bit=True의 실제 메모리 사용량 차이를 측정해보세요. model.get_memory_footprint()을 활용합니다.
  3. LoRA에서 alpha/rank 비율(scaling)이 학습에 미치는 영향을 조사하고, scaling이 너무 크거나 작을 때 어떤 문제가 발생하는지 정리해보세요.

이 글이 도움이 되었나요?