본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 25. 03:17

96. LoRA: 노트북에서 10억 개의 파라미터를 가진 모델을 미세 조정하기

요약

대규모 언어 모델의 전체 미세 조정(Full fine-tuning) 시 발생하는 막대한 메모리 문제를 LoRA(Low-Rank Adaptation) 기술로 해결하는 방법을 다룹니다. 기존 가중치는 동결하고 작은 학습 가능 모듈만 추가하여 소비자용 GPU에서도 효율적인 학습이 가능함을 설명합니다.

핵심 포인트

  • 전체 미세 조정은 모델 크기에 따라 기하급수적인 GPU 메모리를 요구함
  • LoRA는 가중치 행렬 옆에 작은 모듈을 추가하여 학습 파라미터를 획기적으로 줄임
  • HuggingFace PEFT와 QLoRA를 활용한 효율적인 학습 설정 방법 제시
  • 소비자용 하드웨어 및 CPU 환경에서도 대규모 모델 미세 조정 가능

GPT-2는 1억 1,700만(117M) 개의 파라미터를 가지고 있습니다. LLaMA-2는 70억(7B) 개, GPT-3는 1,750억(175B) 개를 가지고 있습니다. 전체 미세 조정 (Full fine-tuning)은 모든 단일 파라미터를 업데이트하는 것을 의미합니다. GPT-2의 경우 이는 관리 가능한 수준입니다. 하지만 LLaMA-2의 경우 그래디언트 (Gradients)를 저장하는 데만 28GB의 GPU 메모리가 필요합니다. GPT-3의 경우 클러스터 없이는 기본적으로 불가능합니다. LoRA (Low-Rank Adaptation)가 이 문제를 해결합니다. 전체 가중치 행렬 (Weight matrices)을 업데이트하는 대신, 그 옆에 아주 작은 학습 가능한 모듈들을 추가합니다. 원래의 가중치는 동결 (Frozen) 상태로 유지됩니다. 오직 이 작은 모듈들만 학습됩니다. 마지막에 이들을 다시 병합 (Merge)합니다. 이를 통해 8개의 A100이 필요하던 상황에서 소비자용 GPU가 필요한 상황으로, 혹은 때로는 CPU만으로도 가능한 상황으로 바뀝니다.

여기서 배우게 될 내용:

  • 왜 전체 미세 조정 (Full fine-tuning)이 확장 가능하지 않은지
  • LoRA 뒤에 숨겨진 수학을 쉬운 영어로 설명
  • Rank, alpha, 그리고 dropout: 무엇을 제어하는가
  • LoRA를 적용할 레이어 (Layers) 선택
  • HuggingFace PEFT를 사용한 LoRA 설정
  • QLoRA: 소비자용 하드웨어를 위한 양자화 (Quantization) + LoRA
  • 배포를 위한 LoRA 가중치 병합
  • LoRA와 전체 미세 조정 비교

대규모 환경에서 전체 미세 조정의 문제점

미세 조정을 위한 메모리 요구 사항

def estimate_gpu_memory ( n_params_billions , dtype = ' float32 ' ):
bytes_per_param = { ' float32 ' : 4 , ' float16 ' : 2 , ' int8 ' : 1 , ' int4 ' : 0.5 }
bpp = bytes_per_param [ dtype ]
model_gb = n_params_billions * 1e9 * bpp / 1e9
# 전체 미세 조정을 위해서는 다음 사항들도 필요합니다:
# - 그래디언트 (Gradients): 가중치와 동일한 크기
# - Adam 옵티마이저 상태 (Adam optimizer states): 가중치 크기의 2배
# - 활성화 값 (Activations): 배치 크기에 따라 다름 (대략 2배로 추정)
total_gb = model_gb * ( 1 + 1 + 2 + 2 ) # 가중치 + 그래디언트 + 옵티마이저 + 활성화 값
return model_gb , total_gb

print ( f " { ' Model ' : < 15 } { ' Params ' : < 10 } { ' Weights ' : < 12 } { ' Full FT Memory ' } " )
print ( " - " * 50 )
for name , params in [( ' GPT-2 ' , 0.117 ), ( ' LLaMA-7B ' , 7 ), ( ' LLaMA-13B ' , 13 ), ( ' GPT-3 ' , 175 )]:
w_gb , total = estimate_gpu_memory ( params , ' float32 ' )
print ( f " { name : < 15 } { params : < 10 } { w_gb : . 1 f } GB { total : .

0 f } GB " )

출력: 모델 파라미터(Params) 가중치(Weights) 전체 미세 조정(Full FT) 메모리

GPT-2 0.117 0.5 GB 2 GB
LLaMA-7B 7 28.0 GB 168 GB
LLaMA-13B 13 52.0 GB 312 GB
GPT-3 175 700.0 GB 4200 GB

LLaMA-7B의 전체 미세 조정 (Full fine-tuning)에는 168GB의 GPU 메모리가 필요합니다. 단일 A100은 80GB를 가집니다. 이를 위해서는 최소 3개가 필요하며 비용은 30,000달러 이상이 듭니다. LoRA는 이 상황을 극적으로 변화시킵니다.

LoRA의 작동 원리: 수학적 원리
사전 학습된 가중치 행렬 (Pretrained weight matrix) $W$는 $(d_{out}, d_{in})$ 형상을 가집니다. 전체 미세 조정 (Full fine-tuning)은 $W$를 직접 업데이트합니다:
$W_{new} = W_{pretrained} + \Delta W$
$\Delta W$는 $W$와 동일한 형상을 가집니다. 이것이 문제입니다. 너무 거대합니다.

LoRA의 통찰: 업데이트 값인 $\Delta W$가 반드시 전체 계수 (Full rank)를 가질 필요는 없습니다. 미세 조정 과정에서의 가장 의미 있는 가중치 변화는 저차원 부분 공간 (Low-dimensional subspace)에 존재합니다. $\Delta W$를 직접 학습하는 대신, LoRA는 이를 두 개의 작은 행렬의 곱으로 근사합니다:
$\Delta W \approx B \times A$

여기서:
$A$는 $(r, d_{in})$ 형상을 가짐 - 랭크(Rank) $r$로 차원을 축소 투영 (Projects down)
$B$는 $(d_{out}, r)$ 형상을 가짐 - $d_{out}$으로 다시 차원을 확대 투영 (Projects back up)
$r \ll \min(d_{in}, d_{out})$

순전파 (Forward pass) 과정 중:
$output = x @ W^T + x @ A^T @ B^T \times (\alpha/r) = (사전 학습된 부분) + (LoRA 부분)$

$W$는 동결 (Frozen) 상태를 유지합니다. 오직 $A$와 $B$만 학습됩니다. 총 파라미터 수는 $d_{in} \times d_{out}$ 대신 $r \times (d_{in} + d_{out})$가 됩니다.

import torch
import torch.nn as nn
import math

class LoRALayer ( nn . Module ):
    def __init__ ( self , original_layer , rank = 8 , alpha = 16 , dropout = 0.1 ):
        super (). __init__ ( )
        self . original = original_layer
        self . rank = rank
        self . alpha = alpha
        self . scaling = alpha / rank

        # 원본 레이어를 동결 (Freeze)
        for param in self . original . parameters ():
            param . requires_grad = False

        # LoRA 행렬 A와 B
        in_features = original_layer . in_features
        out_features = original_layer . out_features
        self . lora_A = nn . Linear ( in_features , rank , bias = False )
        self . lora_B = nn . Linear ( rank , out_features , bias = False )
        self . dropout = nn . Dropout ( dropout )

        # 초기화: A는 가우시안 (Gaussian) 분포로, B는 0으로 초기화
        # B=0은 LoRA가 초기 상태에서 항등 함수 (Identity)로 시작함을 의미함 (초기 변화 없음)
        nn . init . kaiming_uniform_ ( self . lora_A . weight , a = math .

sqrt(5)) nn.init.zeros_(self.lora_B.weight) def forward(self, x):
# Original output (frozen)
original_out = self.original(x)
# LoRA delta
lora_out = self.lora_B(self.lora_A(self.dropout(x))) * self.scaling
return original_out + lora_out
def parameter_count(self):
original_params = sum(p.numel() for p in self.original.parameters())
lora_params = sum(p.numel() for p in self.lora_A.parameters()) + \
sum(p.numel() for p in self.lora_B.parameters())
return original_params, lora_params
# Test LoRA layer
original_linear = nn.Linear(768, 768) # typical BERT attention dimension
lora_linear = LoRALayer(original_linear, rank=8, alpha=16)
original_params, lora_params = lora_linear.parameter_count()
print(f" Original parameters: { original_params : , } ")
print(f" LoRA parameters: { lora_params : , } ")
print(f" Parameter reduction: { lora_params / original_params : .1% } of original ")
x = torch.randn(2, 10, 768)
out = lora_linear(x)
print(f" \n Input shape: { x.shape } ")
print(f" Output shape: { out.shape } ")
Output: Original parameters: 590,592 LoRA parameters: 12,288
Parameter reduction: 2.1% of original
Input shape: torch.Size([2, 10, 768])
Output shape: torch.Size([2, 10, 768])
12,288 parameters instead of 590,592. Same output shape.
2.1% of the original.
Rank, Alpha, and What They Control
import pandas as pd # How rank affects parameter count for a 768x768 matrix
rows = []
for rank in [1, 2, 4, 8, 16, 32, 64]:
d_in = d_out = 768
original = d_in * d_out
lora = rank * (d_in + d_out)
rows.append({'Rank': rank, 'LoRA params': lora, 'Original params': original, '% of original': f" {lora / original : .2%}", 'Reduction factor': f" {original // lora} x "})
print(pd.DataFrame(rows).to_markdown()}

to_string ( index = False )) 출력: Rank LoRA 파라미터(params) 원본 파라미터(Original params) 원본 대비 비율(% of original) 감소 계수(Reduction factor)
1 1536 589824 0.26% 384x
2 3072 589824 0.52% 192x
4 6144 589824 1.04% 96x
8 12288 589824 2.08% 48x
16 24576 589824 4.17% 24x
32 49152 589824 8.33% 12x
64 98304 589824 16.67% 6x

Rank (r): 저차원 근사 (low-rank approximation)에서 사용할 차원의 수입니다. Rank가 높을수록 파라미터가 많아지고 표현력 (expressive)이 좋아지지만, 전체 미세 조정 (full fine-tuning)에 가까워집니다.
- r=4 또는 r=8: 가장 일반적인 시작점
- r=16 ~ r=32: 더 많은 용량이 필요한 어려운 작업용
- r=64+: 전체 미세 조정 (full fine-tuning) 영역에 근접

Alpha (α): LoRA 출력의 스케일링 계수 (scaling factor)입니다. 동결된 모델 (frozen model) 대비 LoRA가 얼마나 많은 영향력을 가질지 제어합니다. 보통 다음과 같이 설정합니다.
- alpha = rank (scaling = 1.0)
- alpha = 2 * rank (scaling = 2.0, LoRA의 영향력이 더 커짐)
- 일반적인 설정: rank=8, alpha=16 (scaling=2)

Dropout: LoRA 내부의 규제 (regularization) 기법입니다. 일반적으로 0.05에서 0.1 사이를 사용합니다.

LoRA를 적용할 레이어 (Which Layers to Apply LoRA To)
Transformer 모델에서 어텐션 메커니즘 (attention mechanism)은 레이어당 4개의 가중치 행렬 (weight matrices), 즉 Q, K, V, 그리고 출력 투영 (output projection)을 가집니다. 피드포워드 레이어 (feed-forward layers)에는 2개의 행렬이 더 있습니다.

# 다양한 아키텍처를 위한 일반적인 LoRA 대상 모듈 (target modules)
lora_targets = {
    ' BERT / RoBERTa ' : {
        ' targets ' : [ ' query ' , ' key ' , ' value ' , ' dense ' ],
        ' note ' : ' 모든 어텐션 투영 (All attention projections) '
    },
    ' GPT-2 ' : {
        ' targets ' : [ ' c_attn ' , ' c_proj ' ],
        ' note ' : ' QKV와 출력 투영이 결합됨 (Combined QKV and output projection) '
    },
    ' LLaMA / Mistral ' : {
        ' targets ' : [ ' q_proj ' , ' k_proj ' , ' v_proj ' , ' o_proj ' ],
        ' note ' : ' 모든 어텐션 투영, 때로는 gate_proj도 포함 (All attention projections, sometimes gate_proj too) '
    },
    ' Minimal (fastest) ' : {
        ' targets ' : [ ' q_proj ' , ' v_proj ' ],
        ' note ' : ' Query와 Value만 적용, 파라미터는 적지만 종종 충분함 (Only query and value, fewer params but often enough) '
    }
}

for arch , info in lora_targets . items ():
    print ( f "\n { arch } : " )
    print ( f " Targets: { info [ ' targets ' ] } " )
    print ( f " Note: { info [ ' note ' ] } " )

연구에 따르면, Q와 V에만 LoRA를 적용하고 (K를 건너뜀) 파라미터를 적게 사용하면서도 4개 모두에 적용했을 때와 거의 유사한 성능을 내는 경우가 많습니다.

LoRA를 HuggingFace PEFT로 사용합니다. `pip install peft`를 실행하고, `from transformers import AutoModelForSequenceClassification`, `AutoTokenizer` 및 `from peft import LoraConfig`, `get_peft_model`, `TaskType`을 가져옵니다.

```python
import torch
model_name = 'roberta-base'
tokenizer = AutoTokenizer.from_pretrained(model_name)
base_model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=3) # Configure LoRA
lora_config = LoraConfig(
    task_type=TaskType.SEQ_CLS, # sequence classification
    r=8, # rank
    lora_alpha=16, # alpha
    lora_dropout=0.1, # dropout
    target_modules=['query', 'value'], # apply to Q and V only
    bias='none', # don't train biases
    inference_mode=False
)
# Wrap the model with LoRA
model = get_peft_model(base_model, lora_config)
# Check trainable parameters
model.print_trainable_parameters()

출력:
trainable params: 629,764 || all params: 125,277,444 || trainable%: 0.5025

전체 파라미터 중 0.5%만 학습 가능하며, 나머지 모든 것은 고정됩니다.

# Training with LoRA is identical to regular fine-tuning
from transformers import TrainingArguments, Trainer, DataCollatorWithPadding
from datasets import load_dataset
import evaluate
import numpy as np

# Load data
dataset = load_dataset('imdb')
small_train = dataset['train'].select(range(2000))
small_val = dataset['test'].select(range(500))
def tokenize(examples):
    return tokenizer(examples['text'], truncation=True, max_length=256)
train_ds = small_train.map(tokenize, batched=True, remove_columns=['text'])
val_ds = small_val.map(tokenize, batched=True, remove_columns=['text'])
train_ds = train_ds.rename_column('label', 'labels')
val_ds = val_ds.rename_column('label', 'labels')
accuracy = evaluate.load('accuracy')
def compute_metrics(eval_pred):
    preds = np.argmax(eval_pred.predictions, axis=-1)
    return accuracy.compute(predictions=preds, references=eval_pred.references)

label_ids ) training_args = TrainingArguments ( output_dir = ' ./lora_model ' , num_train_epochs = 3 , per_device_train_batch_size = 16 , per_device_eval_batch_size = 32 , learning_rate = 3e-4 , # LoRA can use higher LR than full fine-tuning weight_decay = 0.01 , evaluation_strategy = ' epoch ' , save_strategy = ' epoch ' , load_best_model_at_end = True , report_to = ' none ' , fp16 = torch . cuda . is_available () ) trainer = Trainer ( model = model , args = training_args , train_dataset = train_ds , eval_dataset = val_ds , tokenizer = tokenizer , data_collator = DataCollatorWithPadding ( tokenizer ), compute_metrics = compute_metrics ) trainer . train () results = trainer . evaluate () print ( f " LoRA fine-tuning accuracy: { results [ ' eval_accuracy ' ] : . 3 f } " )

Saving and Loading LoRA Weights
LoRA의 또 다른 큰 장점은 저장된 체크포인트가 매우 작다는 것입니다. 전체 모델이 아닌 LoRA 행렬만 저장합니다.
from peft import PeftModel # LoRA 가중치만 저장하기
model . save_pretrained ( ' ./lora_weights ' ) # adapter_config.json 및 adapter_model.bin을 저장합니다
print ( " LoRA 가중치 저장 완료" )
import os
for f in os . listdir ( ' ./lora_weights ' ): size = os . path . getsize ( f ' ./lora_weights/ { f } ' ) / 1e6
print ( f " { f } : { size : . 1 f } MB " )
Output: LoRA 가중치 저장 완료
adapter_config.json : 0.001 MB
adapter_model.bin : 2.4 MB <- 500MB 이상이 아닌 단지 2.4MB!

로드하기: 기본 모델로 시작한 다음 LoRA 어댑터를 로드합니다

base_model_for_load = AutoModelForSequenceClassification . from_pretrained ( model_name , num_labels = 3 )
loaded_lora_model = PeftModel . from_pretrained ( base_model_for_load , ' ./lora_weights ' )
loaded_lora_model . eval ()
print ( " LoRA 모델 로드 성공" )

Merging LoRA for Deployment
배포를 위해 LoRA를 병합하는 방법
학습 후, LoRA 가중치를 기본 모델에 병합할 수 있습니다. 그러면 추론 시 오버헤드가 없는 깨끗한 단일 모델을 갖게 됩니다.

LoRA를 기본 모델에 병합하기

merged_model = model .

merge_and_unload() # 이제 merged_model은 LoRA 오버헤드가 없는 일반적인 모델입니다.
print(f"Merge 후 타입: {type(merged_model)}")

병합된 모델 저장

merged_model.save_pretrained('./merged_model')
tokenizer.save_pretrained('./merged_model')

일반적인 모델처럼 불러오기

from transformers import AutoModelForSequenceClassification
final_model = AutoModelForSequenceClassification.from_pretrained('./merged_model')
print("병합된 모델이 일반 모델로 로드되었습니다.")

확인: LoRA 파라미터 없이 전체 모델만 존재함

n_params = sum(p.numel() for p in final_model.parameters())
print(f"파라미터 수: {n_params:,}")

QLoRA: 4-bit 양자화 (Quantization) + LoRA
QLoRA는 양자화 (가중치 정밀도를 4-bit로 낮춤)와 LoRA를 결합한 방식입니다. 이를 통해 단일 소비자용 GPU에서 7B 이상의 모델을 미세 조정 (Fine-tuning)할 수 있습니다.

pip install bitsandbytes
from transformers import AutoM

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0