Skip to content
Published on

LLM 파인튜닝 실전 — LoRA, QLoRA, PEFT로 나만의 모델 만들기

Authors
  • Name
    Twitter
LLM Fine-tuning with LoRA

들어가며

7B, 13B, 70B 파라미터의 LLM을 처음부터 학습하는 것은 수십~수백 개의 GPU와 수백만 달러가 필요합니다. 하지만 파인튜닝을 활용하면 소비자급 GPU 1장으로도 나만의 특화 모델을 만들 수 있습니다.

이 글에서는 LoRA, QLoRA, PEFT 라이브러리를 활용한 실전 파인튜닝 방법을 다룹니다.

Full Fine-tuning vs Parameter-Efficient Fine-tuning

Full Fine-tuning의 문제

7B 모델을 Full Fine-tuning하려면:

  • 모델 파라미터: 7B × 4 bytes (FP32) = 28GB
  • Optimizer 상태: Adam은 파라미터의 2배 = 56GB
  • Gradient: 파라미터와 동일 = 28GB
  • 총 VRAM: 약 112GB 이상 필요

→ A100 80GB 1장으로도 부족합니다.

PEFT의 등장

Parameter-Efficient Fine-tuning(PEFT)는 전체 파라미터의 0.1~1%만 학습합니다:

방법학습 파라미터 비율VRAM (7B 기준)
Full Fine-tuning100%~112GB
LoRA~0.1-1%~16GB
QLoRA~0.1-1%~6GB

LoRA: Low-Rank Adaptation

수학적 원리

LoRA의 핵심 아이디어: 가중치 업데이트 행렬 ΔW는 저랭크(low-rank)다.

기존 선형 변환:

y=Wxy = Wx

LoRA 적용:

y=Wx+αrBAxy = Wx + \frac{\alpha}{r} \cdot BAx

여기서:

  • WRd×dW \in \mathbb{R}^{d \times d}: 원본 가중치 (freeze)
  • BRd×rB \in \mathbb{R}^{d \times r}: 저랭크 행렬 (학습)
  • ARr×dA \in \mathbb{R}^{r \times d}: 저랭크 행렬 (학습)
  • rr: 랭크 (보통 4~64, 원본 차원 대비 매우 작음)
  • α\alpha: 스케일링 팩터
원본 W (4096 × 4096) = 16M 파라미터 [freeze]

LoRA:
A (r × 4096) + B (4096 × r) = r × 8192 파라미터
r=8일 경우: 65,536 파라미터 (0.4%)

코드로 구현하기

from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForCausalLM, AutoTokenizer

# 1. 기본 모델 로드
model_name = "meta-llama/Llama-3.1-8B"
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto"
)
tokenizer = AutoTokenizer.from_pretrained(model_name)

# 2. LoRA 설정
lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=16,                          # 랭크
    lora_alpha=32,                 # 스케일링 (보통 r의 2배)
    lora_dropout=0.05,             # 드롭아웃
    target_modules=[               # LoRA를 적용할 모듈
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj"
    ],
    bias="none"
)

# 3. PEFT 모델 생성
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 41,943,040 || all params: 8,072,204,288 || trainable%: 0.5194

target_modules 선택 가이드

# 모델의 모든 Linear 레이어 확인
for name, module in model.named_modules():
    if isinstance(module, torch.nn.Linear):
        print(name, module.in_features, module.out_features)

# 일반적인 선택:
# - Attention만: ["q_proj", "v_proj"] — 최소 VRAM
# - Attention 전체: ["q_proj", "k_proj", "v_proj", "o_proj"] — 권장
# - MLP 포함: 위 + ["gate_proj", "up_proj", "down_proj"] — 최대 성능

QLoRA: 4-bit 양자화 + LoRA

QLoRA가 특별한 이유

QLoRA는 3가지 혁신으로 소비자 GPU에서 대형 모델 파인튜닝을 가능하게 합니다:

  1. 4-bit NormalFloat(NF4): 정규분포 가중치에 최적화된 양자화
  2. Double Quantization: 양자화 상수 자체도 양자화하여 메모리 추가 절감
  3. Paged Optimizers: GPU 메모리 부족 시 CPU로 자동 페이징

구현

from transformers import BitsAndBytesConfig
import torch

# 4-bit 양자화 설정
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",           # NormalFloat4
    bnb_4bit_compute_dtype=torch.bfloat16, # 연산은 bf16으로
    bnb_4bit_use_double_quant=True,        # Double Quantization
)

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

# LoRA 적용 (QLoRA = 4bit 양자화 모델 + LoRA)
model = get_peft_model(model, lora_config)

VRAM 사용량 비교

모델Full FP16LoRA FP16QLoRA 4-bit
7B~28GB~16GB~6GB
13B~52GB~30GB~10GB
70B~280GB~160GB~48GB

QLoRA를 사용하면 RTX 3090/4090 (24GB) 하나로 13B 모델까지 파인튜닝할 수 있습니다.

데이터 준비와 학습

데이터셋 포맷

Instruction 파인튜닝을 위한 데이터 포맷:

from datasets import load_dataset

# Alpaca 스타일 데이터셋
dataset = load_dataset("json", data_files="train_data.json")

# 데이터 예시
# {
#   "instruction": "다음 텍스트를 요약해주세요.",
#   "input": "Kubernetes는 컨테이너화된 워크로드와 서비스를...",
#   "output": "Kubernetes는 컨테이너 오케스트레이션 플랫폼입니다."
# }

# 프롬프트 템플릿 적용
def format_instruction(sample):
    if sample["input"]:
        text = f"""### Instruction:
{sample["instruction"]}

### Input:
{sample["input"]}

### Response:
{sample["output"]}"""
    else:
        text = f"""### Instruction:
{sample["instruction"]}

### Response:
{sample["output"]}"""
    return {"text": text}

dataset = dataset.map(format_instruction)

SFTTrainer로 학습

from trl import SFTTrainer, SFTConfig

training_args = SFTConfig(
    output_dir="./output",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,    # 효과적 배치 = 4 × 4 = 16
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    max_seq_length=2048,
    bf16=True,
    logging_steps=10,
    save_strategy="epoch",
    optim="paged_adamw_8bit",         # QLoRA용 페이징 옵티마이저
    gradient_checkpointing=True,      # VRAM 추가 절감
)

trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset["train"],
    args=training_args,
)

trainer.train()

학습 후 모델 저장 및 병합

# LoRA 어댑터만 저장 (수십 MB)
model.save_pretrained("./lora-adapter")

# 나중에 어댑터 로드
from peft import PeftModel
base_model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.1-8B")
model = PeftModel.from_pretrained(base_model, "./lora-adapter")

# 원본 모델과 어댑터 병합 (배포용)
merged_model = model.merge_and_unload()
merged_model.save_pretrained("./merged-model")

하이퍼파라미터 튜닝 가이드

LoRA 랭크(r) 선택

# 랭크별 특성
# r=4:   가장 적은 파라미터, 간단한 도메인 적응에 적합
# r=8:   일반적인 시작점
# r=16:  좋은 균형 (추천)
# r=32:  복잡한 태스크, 더 많은 VRAM
# r=64+: Full Fine-tuning에 가까운 성능, 그만큼 비효율

# 실험적으로, r=16 + alpha=32가 대부분의 경우에 잘 작동

학습률

# LoRA/QLoRA 학습률은 Full FT보다 높게 설정
# Full FT: 1e-5 ~ 5e-5
# LoRA:    1e-4 ~ 3e-4
# QLoRA:   2e-4 (일반적)

고급 기법

DoRA: Weight-Decomposed Low-Rank Adaptation

LoRA의 발전형으로, 가중치를 크기(magnitude)와 방향(direction)으로 분해합니다:

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj"],
    use_dora=True,   # DoRA 활성화
)

여러 LoRA 어댑터 조합

from peft import PeftModel

# 기본 모델에 여러 어댑터 로드
model = PeftModel.from_pretrained(base_model, "./adapter-korean")
model.load_adapter("./adapter-code", adapter_name="code")

# 어댑터 전환
model.set_adapter("code")

# 또는 어댑터 가중치 조합
model.add_weighted_adapter(
    adapters=["default", "code"],
    weights=[0.7, 0.3],
    adapter_name="merged"
)

마무리

LoRA와 QLoRA는 LLM 파인튜닝의 접근성을 혁명적으로 바꿨습니다. 소비자 GPU 1장으로 수십억 파라미터 모델을 커스터마이징할 수 있다는 것은 AI 민주화의 핵심입니다.

핵심 요약:

  • LoRA: 저랭크 분해로 학습 파라미터를 0.1~1%로 줄임
  • QLoRA: 4-bit 양자화로 VRAM을 추가로 ~4배 절감
  • PEFT: Hugging Face 라이브러리로 몇 줄의 코드로 적용 가능

직접 데이터를 준비하고, 학습하고, 배포해보세요. 생각보다 훨씬 쉽습니다.

퀴즈

Q1: LoRA에서 랭크(r)가 의미하는 것은? 가중치 업데이트 행렬 ΔW를 분해할 때의 저차원 크기. r이 작을수록 학습 파라미터가 적고 VRAM 소비가 줄지만, 모델의 표현력도 제한됩니다.

Q2: 7B 모델을 Full Fine-tuning하려면 약 얼마의 VRAM이 필요한가? 약 112GB 이상. 모델 파라미터(28GB) + Optimizer 상태(56GB) + Gradient(28GB)이 필요합니다.

Q3: QLoRA의 3가지 핵심 혁신은?
  1. 4-bit NormalFloat(NF4) 양자화, 2) Double Quantization(양자화 상수의 양자화), 3) Paged Optimizers(GPU→CPU 자동 페이징)

Q4: LoRA의 lora_alpha 파라미터의 역할은? LoRA 업데이트의 스케일링 팩터. 실제 스케일은 alpha/r로 계산되며, 보통 r의 2배로 설정합니다 (r=16이면 alpha=32).

Q5: QLoRA에서 사용하는 NF4 양자화가 일반 INT4보다 나은 이유는? 신경망 가중치가 대략 정규분포를 따르므로, 정규분포에 최적화된 NF4 양자화가 균일 분포를 가정하는 INT4보다 정보 손실이 적습니다.

Q6: LoRA 어댑터를 원본 모델과 병합(merge)하는 이유는? 추론 시 추가 연산 오버헤드를 없애기 위해. 병합하면 원본 모델과 동일한 구조가 되어 추론 속도가 어댑터 분리 상태보다 빠릅니다.

Q7: gradient_checkpointing=True의 효과는? 순전파의 중간 활성값을 메모리에 저장하지 않고, 역전파에서 재계산합니다. VRAM을 절약하지만 학습 시간은 약 20-30% 증가합니다.

Q8: LoRA의 target_modules 선택이 성능에 미치는 영향은? 더 많은 모듈에 LoRA를 적용할수록 성능은 향상되지만 VRAM과 학습 시간이 증가합니다. Attention의 q_proj, v_proj만 적용하는 것이 최소 설정이고, MLP까지 포함하면 최대 성능을 얻을 수 있습니다.