📑 목차
- 들어가며: LLM 파인튜닝, 왜 경량화가 필수일까요?
- LoRA/QLoRA, 개념부터 핵심 원리까지 파헤치기
- LoRA: 작은 어댑터로 큰 변화 만들기
- QLoRA: 양자화와 LoRA의 시너지 효과
- 실전 적용: LoRA 파인튜닝, 이렇게 시작했습니다
- 데이터셋 준비와 모델 로딩
- PEFT 라이브러리로 LoRA 설정하기
- 트레이너를 이용한 학습
- 메모리 절약의 마법: QLoRA로 더 적은 리소스 활용하기
- 4비트 양자화 모델 로딩
- LoRA 설정 및 학습
- LoRA vs QLoRA: 어떤 상황에 어떤 기법이 유리할까?
- 성능 최적화 및 주의할 점
- 하이퍼파라미터 튜닝
- 데이터셋 품질의 중요성
- 모델 병합 및 추론
- 마치며: 경량화 파인튜닝, LLM 활용의 새로운 지평을 열다
Image by Zomogy on Pixabay
들어가며: LLM 파인튜닝, 왜 경량화가 필수일까요?
거대 언어 모델(LLM)을 특정 도메인이나 태스크에 맞게 미세 조정하고 싶을 때, 대부분의 개발자는 파인튜닝을 고려합니다. 하지만 막상 시작하려 하면 엄청난 GPU 메모리와 학습 시간이 발목을 잡는 경우가 많습니다. 풀 파인튜닝은 GPT-3 같은 모델의 경우 수백 기가바이트의 VRAM을 요구하기도 하죠. 일반적인 개발 환경에서는 엄두도 내기 어려운 일입니다. 저도 처음에는 이러한 리소스 문제에 부딪혀 여러 번 좌절했습니다.
하지만 다행히도, 이러한 문제를 해결해 줄 경량화 기법들이 등장했습니다. 그중에서도 LoRA (Low-Rank Adaptation)와 QLoRA (Quantized LoRA)는 적은 리소스로도 충분히 만족스러운 성능의 LLM을 얻을 수 있게 해주는 핵심 기술입니다. 제가 직접 다양한 LLM에 이 기법들을 적용해 본 결과, 일반적인 GPU 환경에서도 특정 태스크에 특화된 모델을 만들어낼 수 있다는 것을 확인했습니다. 이 글에서는 제가 겪었던 시행착오와 함께, LoRA와 QLoRA를 실전에 어떻게 적용할 수 있는지 그 가이드를 공유하고자 합니다.
LoRA/QLoRA, 개념부터 핵심 원리까지 파헤치기
본격적인 실전 가이드에 앞서, LoRA와 QLoRA가 어떤 원리로 경량화를 가능하게 하는지 그 핵심 개념을 이해하는 것이 중요합니다. 이 두 기법은 LLM의 모든 파라미터를 학습하는 대신, 일부 파라미터만 효율적으로 학습하여 리소스를 절약합니다.
LoRA: 작은 어댑터로 큰 변화 만들기
LoRA의 핵심 아이디어는 간단합니다. 기존 LLM의 사전 학습된 가중치(Weight)는 그대로 고정하고, 각 레이어에 작은 '어댑터(Adapter)' 모듈을 추가하여 학습하는 방식입니다. 이 어댑터는 두 개의 작은 행렬로 분해(Low-Rank Decomposition)되어, 기존 가중치 행렬의 업데이트를 근사합니다. 예를 들어, 원래 W라는 가중치 행렬이 있다면, LoRA는 W + ΔW 형태로 학습을 진행하는데, 이 ΔW가 바로 두 개의 작은 행렬(A와 B)의 곱(BA)으로 표현되는 것입니다.
이렇게 하면 학습해야 할 파라미터의 수가 획기적으로 줄어듭니다. LLM 전체 파라미터의 0.01%에서 1% 정도만 학습해도 괜찮은 성능을 보인다는 연구 결과도 있습니다. 제가 직접 7B 모델에 LoRA를 적용했을 때, 전체 모델을 파인튜닝하는 것보다 VRAM 사용량이 1/10 이상 줄어드는 것을 체감했습니다. 이는 곧 훨씬 적은 GPU 리소스로도 파인튜닝이 가능하다는 것을 의미합니다.
LoRA의 장점:
- 학습 가능한 파라미터 수가 적어 메모리 사용량이 낮습니다.
- 학습 속도가 빠릅니다.
- 원본 모델의 지식을 보존하면서 특정 태스크에 특화된 학습이 가능합니다.
- 여러 태스크에 대해 각각 LoRA 어댑터를 학습시켜, 필요에 따라 교체하여 사용할 수 있습니다.
QLoRA: 양자화와 LoRA의 시너지 효과
QLoRA는 LoRA의 아이디어를 한 단계 더 발전시킨 기법입니다. 여기에 '양자화(Quantization)'라는 기술을 접목하여 메모리 효율을 극대화합니다. 기존 LLM의 가중치를 4비트와 같은 낮은 정밀도로 양자화하여 저장하고, 이 양자화된 모델 위에 LoRA 어댑터를 학습시킵니다. 중요한 점은, 양자화된 가중치를 훈련 과정에서 다시 역양자화(De-quantization)하여 사용하지만, 이 과정이 메모리 효율적으로 설계되었다는 것입니다.
제가 QLoRA를 사용해본 경험에 비추어 보면, 13B 모델을 4비트 양자화와 결합하여 파인튜닝했을 때, VRAM 24GB GPU에서 약 15~16GB 정도의 메모리만으로도 충분히 학습을 진행할 수 있었습니다. 이는 LoRA만 단독으로 사용했을 때보다도 훨씬 적은 메모리입니다. 특히나 30B 이상의 대규모 모델을 다룰 때 QLoRA는 거의 유일한 현실적인 선택지가 되기도 합니다.
QLoRA의 장점:
- 극도로 낮은 메모리 사용량: 특히 대규모 LLM 파인튜닝 시 필수적입니다.
- LoRA의 모든 장점을 계승합니다.
- 학습 과정에서 양자화된 모델을 사용하지만, 훈련은 16비트 BFloat16으로 진행되어 성능 손실을 최소화합니다.
실전 적용: LoRA 파인튜닝, 이렇게 시작했습니다
개념을 이해했으니, 이제 실제로 LoRA를 적용하여 LLM을 파인튜닝하는 과정을 살펴보겠습니다. 제가 주로 사용하는 Hugging Face의 transformers 라이브러리와 PEFT (Parameter-Efficient Fine-Tuning) 라이브러리를 기준으로 설명하겠습니다.
데이터셋 준비와 모델 로딩
파인튜닝의 첫 단계는 당연히 고품질의 데이터셋을 준비하는 것입니다. 저는 주로 JSON Lines 형식으로 질문과 답변 쌍을 구성하거나, 특정 도메인의 텍스트 데이터를 준비합니다. 데이터셋은 Hugging Face의 datasets 라이브러리를 활용하면 편리하게 로드하고 전처리할 수 있습니다.
다음으로, 베이스 LLM을 로드합니다. 이때 주의할 점은, LoRA를 적용하려면 모델을 bfloat16 또는 float16 정밀도로 로드하는 것이 좋습니다. 그렇지 않으면 나중에 LoRA 어댑터를 적용하는 과정에서 문제가 발생할 수 있습니다.
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
model_name = "mistralai/Mistral-7B-v0.1" # 예시 모델
# 토크나이저 로드
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token # 패딩 토큰 설정
# 모델 로드 (bfloat16 또는 float16으로)
model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.bfloat16, # 또는 torch.float16
device_map="auto" # 여러 GPU 사용 시 유용
)
model.config.use_cache = False
model.config.pretraining_tp = 1
PEFT 라이브러리로 LoRA 설정하기
LoRA 어댑터를 모델에 주입하기 위해서는 PEFT 라이브러리가 필수적입니다. LoraConfig 객체를 생성하여 LoRA의 다양한 하이퍼파라미터를 설정합니다.
주요 파라미터는 다음과 같습니다:
r: LoRA 랭크. 값이 클수록 더 많은 파라미터를 학습하지만, 그만큼 메모리와 학습 시간이 늘어납니다. 보통 8, 16, 32, 64 등의 값을 사용합니다. 저는 16이나 32로 시작하는 경우가 많습니다.lora_alpha: LoRA 스케일링 팩터.r값과 함께 학습 강도를 조절합니다. 일반적으로r의 두 배 값을 사용합니다 (예: r=16일 때 lora_alpha=32).target_modules: LoRA를 적용할 모델의 레이어 이름. 일반적으로는q_proj,k_proj,v_proj,o_proj와 같은 어텐션 레이어에 적용합니다. 모델 구조를 확인하여 적절한 모듈을 선택해야 합니다.lora_dropout: LoRA 어댑터에 적용할 드롭아웃 비율. 과적합 방지에 도움을 줍니다.bias: LoRA 어댑터에 바이어스를 학습할지 여부. 일반적으로 'none'으로 설정합니다.task_type: 모델의 태스크 유형. LLM 파인튜닝의 경우CAUSAL_LM을 사용합니다.
from peft import LoraConfig, get_peft_model
peft_config = LoraConfig(
lora_alpha=32,
lora_dropout=0.1,
r=16,
bias="none",
task_type="CAUSAL_LM",
target_modules=[
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj", # Mistral/Llama 등 모델에 따라 추가
"up_proj",
"down_proj",
]
)
# 모델에 LoRA 어댑터 적용
model = get_peft_model(model, peft_config)
model.print_trainable_parameters()
# 출력 예시: trainable params: 4194304 || all params: 7241775104 || trainable%: 0.05791722718915573
model.print_trainable_parameters()를 실행하면 전체 파라미터 중 실제로 학습되는 LoRA 어댑터의 파라미터 비율을 확인할 수 있습니다. 저의 경험상, 이 비율이 1% 미만으로 유지되면서도 좋은 성능을 내는 경우가 많았습니다.
트레이너를 이용한 학습
데이터셋과 LoRA 모델이 준비되면, transformers.Trainer를 사용하여 학습을 진행합니다. 학습 인자(TrainingArguments)를 통해 에포크 수, 배치 크기, 학습률 등을 설정합니다.
from transformers import TrainingArguments, Trainer
# ... 데이터셋 로딩 및 전처리 코드 ...
training_arguments = TrainingArguments(
output_dir="./lora_results", # 결과 저장 디렉토리
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=2,
optim="paged_adamw_8bit", # QLoRA 사용 시 주로 8비트 옵티마이저 사용
save_strategy="epoch",
logging_steps=100,
learning_rate=2e-4,
fp16=False, # bfloat16 사용 시 False
bf16=True, # bfloat16 사용 시 True
max_grad_norm=0.3, # 그래디언트 클리핑
max_steps=-1,
warmup_ratio=0.03,
lr_scheduler_type="cosine",
group_by_length=True,
report_to="tensorboard" # tensorboard 로깅
)
trainer = Trainer(
model=model,
train_dataset=train_dataset, # 전처리된 학습 데이터셋
eval_dataset=eval_dataset, # 전처리된 검증 데이터셋 (선택 사항)
args=training_arguments,
data_collator=data_collator, # 데이터 콜레이터
)
trainer.train()
이렇게 학습을 진행하면, 지정된 디렉토리에 LoRA 어댑터의 가중치만 저장됩니다. 나중에 추론 시에는 이 어댑터 가중치를 베이스 모델에 병합(merge)하여 사용하거나, PEFT 라이브러리를 통해 동적으로 로드하여 사용할 수 있습니다.
Image by athalia13 on Pixabay
메모리 절약의 마법: QLoRA로 더 적은 리소스 활용하기
QLoRA는 LoRA의 개념에 양자화를 더하여 메모리 효율을 극대화하는 기법입니다. 제가 경험한 바로는, QLoRA는 특히 단일 GPU 환경에서 대규모 LLM을 다룰 때 게임 체인저였습니다. QLoRA를 적용하는 과정은 LoRA와 매우 유사하지만, 모델 로딩 단계에서 몇 가지 추가 설정이 필요합니다.
4비트 양자화 모델 로딩
QLoRA를 사용하려면 모델을 4비트로 양자화하여 로드해야 합니다. Hugging Face transformers 라이브러리에서는 BitsAndBytesConfig를 사용하여 이를 쉽게 구현할 수 있습니다.
from transformers import BitsAndBytesConfig
# 4비트 양자화 설정
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # 4비트로 로드
bnb_4bit_quant_type="nf4", # NormalFloat4 양자화 타입
bnb_4bit_compute_dtype=torch.bfloat16, # 연산은 bfloat16으로
bnb_4bit_use_double_quant=True, # 더블 양자화 사용 (메모리 절약)
)
# 모델 로드 (4비트 양자화 적용)
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto"
)
model.config.use_cache = False
model.config.pretraining_tp = 1
여기서 bnb_4bit_quant_type="nf4"는 QLoRA 논문에서 제안된 NormalFloat4 양자화 방식을 사용하라는 의미입니다. bnb_4bit_compute_dtype=torch.bfloat16은 모델의 가중치는 4비트로 저장하지만, 실제 연산은 bfloat16 정밀도로 수행하여 성능 손실을 최소화하는 중요한 설정입니다. bnb_4bit_use_double_quant=True는 양자화 상수를 한 번 더 양자화하여 추가 메모리를 절약하는 옵션입니다.
이렇게 모델을 로드하면, 제 경험상 7B 모델은 8GB 미만의 VRAM으로도 로딩이 가능했고, 13B 모델도 12GB 미만으로 로딩되는 것을 확인했습니다. 이는 LoRA만 적용했을 때보다도 훨씬 큰 메모리 절약 효과입니다.
LoRA 설정 및 학습
모델을 4비트 양자화하여 로드한 후에는 LoRA 설정과 학습 과정은 앞에서 설명한 것과 거의 동일합니다. LoraConfig를 설정하고, get_peft_model로 모델에 어댑터를 주입한 뒤, Trainer를 사용하여 학습을 진행하면 됩니다. 다만, TrainingArguments에서 optim="paged_adamw_8bit"와 같은 8비트 옵티마이저를 사용하는 것이 QLoRA 환경에서 메모리 효율을 더욱 높이는 데 도움이 됩니다.
LoRA vs QLoRA: 어떤 상황에 어떤 기법이 유리할까?
LoRA와 QLoRA 모두 경량화 파인튜닝에 탁월한 기법이지만, 각각의 장단점과 적합한 상황이 다릅니다. 제가 직접 두 기법을 비교하여 적용해 본 결과는 다음과 같습니다.
| 특징 | LoRA | QLoRA |
|---|---|---|
| 메모리 사용량 | 낮음 (풀 파인튜닝 대비 1/5 ~ 1/10) | 매우 낮음 (LoRA 대비 1/2 ~ 1/3 추가 절약) |
| 성능 | 풀 파인튜닝에 근접한 성능 | LoRA에 근접한 성능, 미세한 성능 저하 가능성 |
| 구현 복잡도 | 상대적으로 간단 | 양자화 설정 추가로 약간 복잡 |
| 추론 속도 | 기존 모델 대비 큰 차이 없음 | 약간 느려질 수 있음 (양자화/역양자화 오버헤드) |
| 적합한 환경 | VRAM 24GB 이상의 GPU에서 7B~13B 모델 파인튜닝 시 | VRAM 16GB 이하의 GPU, 13B 이상의 대규모 모델 파인튜닝 시 |
결론적으로, 만약 당신이 충분한 GPU 메모리(예: 24GB 이상의 VRAM)를 가지고 있고, 가능한 한 최고의 성능을 뽑아내고 싶다면 LoRA를 먼저 시도해보는 것을 추천합니다. 하지만 메모리 제약이 심하거나, 13B 이상의 훨씬 큰 모델을 단일 GPU에서 돌려야 한다면 QLoRA가 훨씬 더 현실적인 대안이 될 것입니다. 제 경험상 QLoRA의 성능 저하는 대부분의 실전 적용에서 무시할 수 있는 수준이었습니다.
Image by elcesarpaisa on Pixabay
성능 최적화 및 주의할 점
LoRA와 QLoRA를 활용하여 파인튜닝할 때, 단순히 코드를 따라 하는 것 외에도 몇 가지 팁과 주의할 점들이 있습니다. 저 역시 이 부분에서 많은 시행착오를 겪었습니다.
하이퍼파라미터 튜닝
- LoRA 랭크 (
r): 이 값은 파인튜닝의 핵심입니다. 너무 작으면 모델이 충분히 학습되지 않고, 너무 크면 메모리 사용량이 늘고 과적합 위험이 있습니다. 8, 16, 32, 64 중에서 시작하여 검증 데이터셋 성능을 보면서 최적의 값을 찾아보세요. 저는 16이나 32에서 시작하여 좋은 결과를 얻는 경우가 많았습니다. - LoRA 알파 (
lora_alpha):r값과 함께 학습 강도를 조절합니다. 일반적으로r의 두 배 값을 사용하지만, 때로는r과 동일하게 설정하는 것도 좋은 결과를 가져올 수 있습니다. - 학습률 (
learning_rate): LLM 파인튜닝에서는 일반적으로1e-5에서5e-4사이의 낮은 학습률을 사용합니다. 너무 높으면 학습이 불안정해지고, 너무 낮으면 수렴이 느려집니다. - 배치 크기 (
per_device_train_batch_size) 및gradient_accumulation_steps: GPU 메모리에 맞춰 배치 크기를 설정하고, 배치 크기가 작다면gradient_accumulation_steps를 사용하여 사실상의 배치 크기를 늘려주는 것이 중요합니다. 예를 들어,per_device_train_batch_size=4에gradient_accumulation_steps=8이면, 총 32개의 샘플에 대한 그래디언트가 누적되어 한 번 업데이트됩니다.
데이터셋 품질의 중요성
아무리 좋은 경량화 기법을 사용하더라도, 데이터셋의 품질이 좋지 않으면 원하는 성능을 얻기 어렵습니다. 데이터셋은 특정 태스크에 충분히 다양하고, 오류가 없으며, 모델이 학습해야 할 패턴을 명확하게 보여주어야 합니다. 특히 LLM 파인튜닝에서는 데이터 포맷팅이 중요합니다. 프롬프트와 응답을 명확히 구분하고, 토크나이저의 특별 토큰(예: <s>, </s>)을 적절히 사용하는 것이 좋습니다.
모델 병합 및 추론
LoRA 또는 QLoRA로 학습된 어댑터는 베이스 모델과 분리되어 저장됩니다. 추론 시에는 이 어댑터를 베이스 모델에 병합하여 사용할 수 있습니다. PEFT 라이브러리의 merge_and_unload() 함수를 사용하면 간단하게 모델을 병합할 수 있습니다. 병합된 모델은 일반적인 LLM처럼 추론에 사용할 수 있으며, 배포 시에도 편리합니다.
from peft import PeftModel
# 학습된 LoRA 어댑터 로드
model = PeftModel.from_pretrained(
base_model, # 원본 베이스 모델
"lora_results/checkpoint-xxx" # 학습 결과 저장 경로
)
# 어댑터와 베이스 모델 병합
model = model.merge_and_unload()
# 토크나이저와 함께 모델 저장
model.save_pretrained("./merged_lora_model")
tokenizer.save_pretrained("./merged_lora_model")
마치며: 경량화 파인튜닝, LLM 활용의 새로운 지평을 열다
LLM 파인튜닝은 단순히 모델의 성능을 향상시키는 것을 넘어, 특정 산업 도메인이나 기업의 니즈에 맞춰 LLM을 '맞춤형'으로 만드는 핵심 과정입니다. 하지만 지금까지는 그 과정에서 막대한 컴퓨팅 리소스가 필요하다는 장벽이 있었습니다. LoRA와 QLoRA 같은 경량화 기법들은 이러한 장벽을 크게 낮춰주며, 개인 개발자나 중소기업도 LLM의 잠재력을 충분히 활용할 수 있는 길을 열어주었습니다.
제가 직접 이 기법들을 적용해 본 결과, 적은 리소스만으로도 특정 도메인에 특화된 챗봇이나 텍스트 생성 모델을 성공적으로 만들어낼 수 있었습니다. 물론, 완벽한 성능을 위해서는 여전히 많은 실험과 튜닝이 필요하지만, 시작점의 문턱이 낮아졌다는 것만으로도 큰 의미가 있습니다. 이 글이 LLM 파인튜닝에 도전하려는 많은 분들께 실질적인 도움이 되었기를 바랍니다.
여러분은 LoRA나 QLoRA를 적용하면서 어떤 경험을 하셨나요? 자신만의 팁이나 궁금한 점이 있다면 댓글로 자유롭게 공유해주세요!