Split View: LLM 파인튜닝 실전 — LoRA, QLoRA, PEFT로 나만의 모델 만들기
LLM 파인튜닝 실전 — LoRA, QLoRA, PEFT로 나만의 모델 만들기
- 들어가며
- Full Fine-tuning vs Parameter-Efficient Fine-tuning
- LoRA: Low-Rank Adaptation
- QLoRA: 4-bit 양자화 + 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-tuning | 100% | ~112GB |
| LoRA | ~0.1-1% | ~16GB |
| QLoRA | ~0.1-1% | ~6GB |
LoRA: Low-Rank Adaptation
수학적 원리
LoRA의 핵심 아이디어: 가중치 업데이트 행렬 ΔW는 저랭크(low-rank)다.
기존 선형 변환:
LoRA 적용:
여기서:
- : 원본 가중치 (freeze)
- : 저랭크 행렬 (학습)
- : 저랭크 행렬 (학습)
- : 랭크 (보통 4~64, 원본 차원 대비 매우 작음)
- : 스케일링 팩터
원본 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에서 대형 모델 파인튜닝을 가능하게 합니다:
- 4-bit NormalFloat(NF4): 정규분포 가중치에 최적화된 양자화
- Double Quantization: 양자화 상수 자체도 양자화하여 메모리 추가 절감
- 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 FP16 | LoRA FP16 | QLoRA 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가지 핵심 혁신은?
- 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까지 포함하면 최대 성능을 얻을 수 있습니다.
Practical LLM Fine-Tuning — Building Your Own Model with LoRA, QLoRA, and PEFT
- Introduction
- Full Fine-tuning vs Parameter-Efficient Fine-tuning
- LoRA: Low-Rank Adaptation
- QLoRA: 4-bit Quantization + LoRA
- Data Preparation and Training
- Hyperparameter Tuning Guide
- Advanced Techniques
- Conclusion
- Quiz

Introduction
Training a 7B, 13B, or 70B parameter LLM from scratch requires dozens to hundreds of GPUs and millions of dollars. However, with fine-tuning, you can build your own specialized model using just a single consumer-grade GPU.
This article covers practical fine-tuning methods using LoRA, QLoRA, and the PEFT library.
Full Fine-tuning vs Parameter-Efficient Fine-tuning
The Problem with Full Fine-tuning
To fully fine-tune a 7B model, you need:
- Model parameters: 7B x 4 bytes (FP32) = 28GB
- Optimizer states: Adam requires 2x the parameters = 56GB
- Gradients: Same as parameters = 28GB
- Total VRAM: Approximately 112GB or more
Even a single A100 80GB is not enough.
The Emergence of PEFT
Parameter-Efficient Fine-tuning (PEFT) trains only 0.1-1% of the total parameters:
| Method | Trainable Parameter Ratio | VRAM (7B model) |
|---|---|---|
| Full Fine-tuning | 100% | ~112GB |
| LoRA | ~0.1-1% | ~16GB |
| QLoRA | ~0.1-1% | ~6GB |
LoRA: Low-Rank Adaptation
Mathematical Principles
The core idea of LoRA: The weight update matrix Delta-W is low-rank.
Original linear transformation:
With LoRA applied:
Where:
- : Original weights (frozen)
- : Low-rank matrix (trainable)
- : Low-rank matrix (trainable)
- : Rank (typically 4-64, very small compared to the original dimension)
- : Scaling factor
Original W (4096 x 4096) = 16M parameters [frozen]
LoRA:
A (r x 4096) + B (4096 x r) = r x 8192 parameters
With r=8: 65,536 parameters (0.4%)
Code Implementation
from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForCausalLM, AutoTokenizer
# 1. Load the base model
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 configuration
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=16, # Rank
lora_alpha=32, # Scaling (typically 2x the rank)
lora_dropout=0.05, # Dropout
target_modules=[ # Modules to apply LoRA to
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"
],
bias="none"
)
# 3. Create PEFT model
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
Guide to Selecting target_modules
# Check all Linear layers in the model
for name, module in model.named_modules():
if isinstance(module, torch.nn.Linear):
print(name, module.in_features, module.out_features)
# Common choices:
# - Attention only: ["q_proj", "v_proj"] — Minimum VRAM
# - Full attention: ["q_proj", "k_proj", "v_proj", "o_proj"] — Recommended
# - Including MLP: above + ["gate_proj", "up_proj", "down_proj"] — Maximum performance
QLoRA: 4-bit Quantization + LoRA
What Makes QLoRA Special
QLoRA enables fine-tuning large models on consumer GPUs through three innovations:
- 4-bit NormalFloat (NF4): Quantization optimized for normally distributed weights
- Double Quantization: Further memory savings by quantizing the quantization constants themselves
- Paged Optimizers: Automatic paging to CPU when GPU memory runs out
Implementation
from transformers import BitsAndBytesConfig
import torch
# 4-bit quantization configuration
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NormalFloat4
bnb_4bit_compute_dtype=torch.bfloat16, # Computation in bf16
bnb_4bit_use_double_quant=True, # Double Quantization
)
# Load the quantized model
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.1-8B",
quantization_config=bnb_config,
device_map="auto"
)
# Apply LoRA (QLoRA = 4-bit quantized model + LoRA)
model = get_peft_model(model, lora_config)
VRAM Usage Comparison
| Model | Full FP16 | LoRA FP16 | QLoRA 4-bit |
|---|---|---|---|
| 7B | ~28GB | ~16GB | ~6GB |
| 13B | ~52GB | ~30GB | ~10GB |
| 70B | ~280GB | ~160GB | ~48GB |
With QLoRA, you can fine-tune models up to 13B on a single RTX 3090/4090 (24GB).
Data Preparation and Training
Dataset Format
Data format for instruction fine-tuning:
from datasets import load_dataset
# Alpaca-style dataset
dataset = load_dataset("json", data_files="train_data.json")
# Data example
# {
# "instruction": "Please summarize the following text.",
# "input": "Kubernetes is a platform for containerized workloads and services...",
# "output": "Kubernetes is a container orchestration platform."
# }
# Apply prompt template
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)
Training with 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, # Effective batch = 4 x 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", # Paged optimizer for QLoRA
gradient_checkpointing=True, # Additional VRAM savings
)
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset["train"],
args=training_args,
)
trainer.train()
Saving and Merging the Model After Training
# Save only the LoRA adapter (tens of MB)
model.save_pretrained("./lora-adapter")
# Load the adapter later
from peft import PeftModel
base_model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.1-8B")
model = PeftModel.from_pretrained(base_model, "./lora-adapter")
# Merge adapter with the base model (for deployment)
merged_model = model.merge_and_unload()
merged_model.save_pretrained("./merged-model")
Hyperparameter Tuning Guide
Choosing the LoRA Rank (r)
# Characteristics by rank
# r=4: Fewest parameters, suitable for simple domain adaptation
# r=8: Typical starting point
# r=16: Good balance (recommended)
# r=32: Complex tasks, requires more VRAM
# r=64+: Performance close to full fine-tuning, proportionally less efficient
# Empirically, r=16 + alpha=32 works well in most cases
Learning Rate
# LoRA/QLoRA learning rates are set higher than full FT
# Full FT: 1e-5 ~ 5e-5
# LoRA: 1e-4 ~ 3e-4
# QLoRA: 2e-4 (typical)
Advanced Techniques
DoRA: Weight-Decomposed Low-Rank Adaptation
An evolution of LoRA that decomposes weights into magnitude and direction:
lora_config = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "v_proj"],
use_dora=True, # Enable DoRA
)
Combining Multiple LoRA Adapters
from peft import PeftModel
# Load multiple adapters on the base model
model = PeftModel.from_pretrained(base_model, "./adapter-korean")
model.load_adapter("./adapter-code", adapter_name="code")
# Switch adapters
model.set_adapter("code")
# Or combine adapter weights
model.add_weighted_adapter(
adapters=["default", "code"],
weights=[0.7, 0.3],
adapter_name="merged"
)
Conclusion
LoRA and QLoRA have revolutionized the accessibility of LLM fine-tuning. The ability to customize multi-billion parameter models on a single consumer GPU is at the heart of AI democratization.
Key takeaways:
- LoRA: Reduces trainable parameters to 0.1-1% through low-rank decomposition
- QLoRA: Saves an additional ~4x VRAM through 4-bit quantization
- PEFT: Hugging Face library that enables implementation with just a few lines of code
Go ahead and prepare your data, train, and deploy. It is much easier than you might think.
Quiz
Q1: What does the rank (r) mean in LoRA?
It is the low-dimensional size used when decomposing the weight update matrix Delta-W. A smaller r
means fewer trainable parameters and less VRAM consumption, but it also limits the model's
expressiveness.
Q2: How much VRAM is approximately needed to fully fine-tune a 7B model?
Approximately 112GB or more. This includes model parameters (28GB) + optimizer states (56GB) +
gradients (28GB).
Q3: What are the three key innovations of QLoRA?
- 4-bit NormalFloat (NF4) quantization, 2) Double Quantization (quantizing the quantization constants), 3) Paged Optimizers (automatic GPU to CPU paging)
Q4: What is the role of the lora_alpha parameter in LoRA?
It is the scaling factor for LoRA updates. The actual scale is calculated as alpha/r, and it is
typically set to 2x the rank (e.g., alpha=32 when r=16).
Q5: Why is NF4 quantization used in QLoRA better than standard INT4?
Since neural network weights approximately follow a normal distribution, NF4 quantization, which
is optimized for normal distributions, incurs less information loss than INT4, which assumes a
uniform distribution.
Q6: Why would you merge a LoRA adapter with the base model?
To eliminate additional computational overhead during inference. Once merged, the model has the
same structure as the original, resulting in faster inference compared to keeping the adapter
separate.
Q7: What is the effect of gradient_checkpointing=True?
It avoids storing intermediate activations from the forward pass in memory, recomputing them
during the backward pass instead. This saves VRAM but increases training time by approximately
20-30%.
Q8: How does the choice of target_modules affect performance in LoRA?
Applying LoRA to more modules improves performance but increases VRAM usage and training time.
Applying it only to q_proj and v_proj in the attention layer is the minimal configuration, while
including the MLP layers yields maximum performance.