LLM 학습 기법: QLoRA

동진·2025년 11월 20일

논문 리뷰

목록 보기
3/9

이번에는 다른 PEFT 기법인 QLoRA를 공부해보겠다. 마찬가지 이 기술에 대한 자료는 모두 "QLORAEfficient Finetuning of Quantized LLMs" 논문의 자료를 참고 하였다.

QLoRA 논문 요약

1. 배경: 왜 이 기술을 만들게 되었는가?

문제 상황

LoRA가 메모리를 줄여주긴 했지만, 대규모 모델의 Fine-tuning은 여전히 어려운 상황이었습니다.

모델Full Fine-tuning 메모리접근성
LLaMA 7B~60GB전문 GPU 1개
LLaMA 33B~200GB다중 GPU 서버
LLaMA 65B780GB 이상대규모 클러스터 필요

기존 양자화의 한계

  • 기존 양자화 기법(GPTQ, LLM.int8 등)은 추론(inference) 시에만 적용 가능
  • 학습(training) 시에는 양자화가 성능 저하를 야기
  • 4-bit 모델에 gradient를 backpropagation하면 성능이 크게 떨어짐

핵심 질문

"양자화된 4-bit 모델을 Fine-tuning해도 16-bit Full Fine-tuning과 동일한 성능을 낼 수 있을까?"


2. 주요 방법: QLoRA의 3가지 핵심 혁신

(1) 4-bit NormalFloat (NF4)

기존 양자화의 문제

  • 일반적인 4-bit Float/Int는 outlier에 취약
  • 양자화 bin이 불균등하게 사용됨

NF4의 핵심 아이디어

신경망 가중치가 정규분포를 따른다는 사실을 활용:

가중치 분포: N(0, σ) → 정규화 → [-1, 1] 범위로 매핑
  • 정규분포의 quantile을 기반으로 양자화 bin 설계
  • 각 bin에 동일한 개수의 값이 할당되도록 최적화
  • 정보 이론적으로 정규분포 데이터에 최적인 양자화

NF4 vs 기존 방식

데이터 타입Pile Perplexity
Int434.34
Float4 (E2M1)31.07
Float4 (E3M0)29.48
NF4 + DQ27.41

(2) Double Quantization (DQ)

문제

양자화 상수(quantization constant)도 메모리를 차지

  • Block size 64, FP32 상수 → 0.5 bits/param 추가 오버헤드

해결책

양자화 상수를 한 번 더 양자화

1차 양자화: W (FP32 → NF4) with c₂ (FP32)
2차 양자화: c₂ (FP32 → FP8) with c₁ (FP32)

메모리 절약

  • 기존: 32/64 = 0.5 bits/param
  • DQ 적용: 8/64 + 32/(64×256) = 0.127 bits/param
  • 0.373 bits/param 절약 (65B 모델에서 약 3GB)

(3) Paged Optimizers

문제

Gradient checkpointing 시 긴 시퀀스에서 메모리 스파이크 발생 → OOM

해결책

NVIDIA Unified Memory 활용

  • Optimizer state를 paged memory로 할당
  • GPU 메모리 부족 시 자동으로 CPU RAM으로 이동
  • 필요 시 다시 GPU로 페이징
GPU (VRAM) ↔ CPU (RAM)
   자동 페이징

QLoRA 전체 수식

# Forward pass
Y = X @ dequant(dequant(c₁, c₂), W_NF4) + X @ L₁ @ L₂
#    ↑ 4-bit 베이스 모델 (frozen)      ↑ LoRA adapter (trainable)

# Computation flow:
# 1. Storage: NF4 (4-bit)
# 2. Computation: BF16 (16-bit)
# 3. Gradient: LoRA 파라미터에만 계산

중요한 발견: 모든 Linear Layer에 LoRA 적용

기존 LoRA는 Query, Value에만 적용했지만, QLoRA에서는 모든 linear layer에 적용해야 16-bit 성능 재현 가능


3. 효과: 어떤 결과를 얻었는가?

메모리 효율성

모델Full FT 메모리QLoRA 메모리감소율
LLaMA 7B~60GB5GB12x
LLaMA 33B~200GB21GB10x
LLaMA 65B780GB+48GB16x

핵심 성과:

  • 65B 모델을 단일 48GB GPU에서 학습 가능
  • 33B 모델을 단일 24GB 소비자 GPU에서 학습 가능

성능 비교: QLoRA = 16-bit Full Fine-tuning

MMLU 5-shot 정확도

LLaMA7B13B33B65B평균
BF1638.447.257.761.853.0
FP437.247.355.961.352.2
NF4+DQ39.047.557.361.853.1

→ NF4+DQ가 BF16과 동등하거나 더 나은 성능

Guanaco: State-of-the-art 챗봇

OASST1 데이터셋(9,209 샘플)으로 학습한 Guanaco 모델:

Vicuna Benchmark 결과 (ChatGPT 대비)

모델파라미터메모리ChatGPT 대비 성능
GPT-4--114.5%
Guanaco 65B65B41GB99.3%
Guanaco 33B33B21GB97.8%
Vicuna 13B13B26GB94.9%
ChatGPT--100%
Guanaco 7B7B5GB87.0%
Alpaca 13B13B10GB69.4%

Elo Rating (Human Evaluation)

모델Elo메모리
GPT-41176-
Guanaco 65B102341GB
Guanaco 33B100921GB
ChatGPT916-

핵심 발견

  • Guanaco 65B가 ChatGPT를 능가하는 성능
  • 24시간 학습만으로 달성 (단일 GPU)
  • 데이터 품질 > 데이터 양: 9K OASST1이 450K FLAN v2보다 챗봇 성능 우수

학습 효율성

항목Guanaco 33BGuanaco 65B
학습 시간12시간 미만24시간
GPU소비자 24GB전문가 48GB
데이터셋9,209 샘플9,209 샘플

4. 결론

QLoRA의 핵심 기여

  1. 4-bit NormalFloat: 정규분포 가중치에 최적화된 양자화
  2. Double Quantization: 양자화 상수도 양자화하여 추가 메모리 절약
  3. Paged Optimizers: 메모리 스파이크 방지
  4. 모든 레이어에 LoRA: 16-bit 성능 완전 재현

실용적 의미

  • 민주화: 대규모 LLM Fine-tuning이 소비자 GPU에서 가능
  • 비용 절감: 클라우드 비용 대폭 감소
  • 접근성: 연구자, 스타트업, 개인 개발자 모두 접근 가능
  • 모바일 배포: 7B 모델이 5GB로 스마트폰에서 실행 가능

핵심 통찰

"데이터셋 품질이 크기보다 중요하다"

  • 9K 고품질 데이터(OASST1) > 450K 대량 데이터(FLAN v2)
  • MMLU 성능 ≠ 챗봇 성능 (벤치마크 다양성 필요)

한계점

  • 33B/65B 스케일에서 Full 16-bit와의 완전한 동등성은 미검증
  • 3-bit 등 더 공격적인 양자화 미탐구
  • 다른 PEFT 방법(Prefix-tuning, IA3 등)과의 비교 부족

핵심 요약 (한 문장)

QLoRA는 4-bit NormalFloat 양자화, Double Quantization, Paged Optimizers를 결합하여 65B 모델을 단일 48GB GPU에서 학습하면서도 16-bit Full Fine-tuning과 동일한 성능을 달성하는 혁신적인 기법이다.


LoRA vs QLoRA 비교

항목LoRAQLoRA
베이스 모델16-bit (frozen)4-bit (frozen)
Adapter16-bit16-bit
메모리 감소3x16x
65B 학습다중 GPU 필요단일 48GB GPU
핵심 혁신Low-rank decompositionNF4 + DQ + Paged Opt

QLoRA 예시 코드

1. 필수 라이브러리 설치

pip install torch transformers peft accelerate bitsandbytes datasets trl

2. 기본 QLoRA 설정 (HuggingFace PEFT + BitsAndBytes)

import torch
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
    TrainingArguments,
)
from peft import (
    LoraConfig,
    get_peft_model,
    prepare_model_for_kbit_training,
    TaskType,
)
from trl import SFTTrainer
from datasets import load_dataset

# 1. 4-bit 양자화 설정 (NF4 + Double Quantization)
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,                    # 4-bit 양자화 활성화
    bnb_4bit_quant_type="nf4",            # NormalFloat4 사용
    bnb_4bit_use_double_quant=True,       # Double Quantization 활성화
    bnb_4bit_compute_dtype=torch.bfloat16 # 연산은 BF16으로
)

# 2. 모델 로드 (4-bit 양자화 적용)
model_name = "beomi/Llama-3-Open-Ko-8B"  # 또는 원하는 모델

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)

tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"

# 3. kbit 학습을 위한 모델 준비
model = prepare_model_for_kbit_training(model)

# 4. LoRA 설정 (모든 linear layer에 적용)
lora_config = LoraConfig(
    r=16,                          # LoRA rank
    lora_alpha=32,                 # 스케일링 팩터
    lora_dropout=0.05,             # 드롭아웃
    bias="none",
    task_type=TaskType.CAUSAL_LM,
    target_modules=[               # 모든 linear layer에 적용 (QLoRA 권장)
        "q_proj",
        "k_proj", 
        "v_proj",
        "o_proj",
        "gate_proj",
        "up_proj",
        "down_proj",
    ]
)

# 5. LoRA 적용
model = get_peft_model(model, lora_config)

# 학습 가능한 파라미터 확인
model.print_trainable_parameters()

출력 예시:

trainable params: 41,943,040 || all params: 8,030,261,248 || trainable%: 0.5222

3. 데이터셋 준비 및 학습

# 데이터셋 로드 (예: 한국어 instruction 데이터)
dataset = load_dataset("your_dataset_name", split="train")

# 또는 직접 데이터 생성
train_data = [
    {"text": "<s>[INST] 서울의 인구는? [/INST] 서울의 인구는 약 950만 명입니다.</s>"},
    {"text": "<s>[INST] 파이썬이란? [/INST] 파이썬은 간결하고 읽기 쉬운 프로그래밍 언어입니다.</s>"},
    # ... 더 많은 데이터
]

from datasets import Dataset
dataset = Dataset.from_list(train_data)

# 학습 설정
training_args = TrainingArguments(
    output_dir="./qlora_output",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=2e-4,
    weight_decay=0.001,
    fp16=False,
    bf16=True,                      # BF16 사용
    logging_steps=10,
    save_strategy="epoch",
    optim="paged_adamw_32bit",      # Paged Optimizer 사용
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    gradient_checkpointing=True,    # 메모리 절약
    max_grad_norm=0.3,
)

# SFTTrainer로 학습
trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=dataset,
    tokenizer=tokenizer,
    dataset_text_field="text",
    max_seq_length=512,
    packing=False,
)

# 학습 시작
trainer.train()

# 모델 저장 (LoRA 가중치만)
trainer.model.save_pretrained("./qlora_adapter")
tokenizer.save_pretrained("./qlora_adapter")

4. 학습된 QLoRA 모델 로드 및 추론

from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import PeftModel
import torch

# 1. 베이스 모델 로드 (4-bit)
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.bfloat16
)

base_model = AutoModelForCausalLM.from_pretrained(
    "beomi/Llama-3-Open-Ko-8B",
    quantization_config=bnb_config,
    device_map="auto",
)

tokenizer = AutoTokenizer.from_pretrained("beomi/Llama-3-Open-Ko-8B")

# 2. LoRA 어댑터 로드
model = PeftModel.from_pretrained(base_model, "./qlora_adapter")

# 3. 추론
def generate_response(prompt):
    inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
    
    with torch.no_grad():
        outputs = model.generate(
            **inputs,
            max_new_tokens=256,
            temperature=0.7,
            top_p=0.9,
            do_sample=True,
            pad_token_id=tokenizer.eos_token_id,
        )
    
    response = tokenizer.decode(outputs[0], skip_special_tokens=True)
    return response

# 테스트
prompt = "[INST] 머신러닝과 딥러닝의 차이점을 설명해주세요. [/INST]"
response = generate_response(prompt)
print(response)

5. 메모리 사용량 비교 (실제 측정)

import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig

def get_model_memory(model_name, load_in_4bit=False, load_in_8bit=False):
    """모델의 GPU 메모리 사용량 측정"""
    
    torch.cuda.empty_cache()
    
    if load_in_4bit:
        bnb_config = BitsAndBytesConfig(
            load_in_4bit=True,
            bnb_4bit_quant_type="nf4",
            bnb_4bit_use_double_quant=True,
            bnb_4bit_compute_dtype=torch.bfloat16
        )
        model = AutoModelForCausalLM.from_pretrained(
            model_name,
            quantization_config=bnb_config,
            device_map="auto"
        )
    elif load_in_8bit:
        model = AutoModelForCausalLM.from_pretrained(
            model_name,
            load_in_8bit=True,
            device_map="auto"
        )
    else:
        model = AutoModelForCausalLM.from_pretrained(
            model_name,
            torch_dtype=torch.float16,
            device_map="auto"
        )
    
    memory_gb = torch.cuda.memory_allocated() / 1024**3
    print(f"GPU Memory: {memory_gb:.2f} GB")
    
    del model
    torch.cuda.empty_cache()
    
    return memory_gb

# 메모리 비교 (예: 7B 모델)
model_name = "beomi/Llama-3-Open-Ko-8B"

print("16-bit (FP16):")
mem_16bit = get_model_memory(model_name, load_in_4bit=False)

print("\n8-bit (Int8):")
mem_8bit = get_model_memory(model_name, load_in_8bit=True)

print("\n4-bit (NF4 + DQ):")
mem_4bit = get_model_memory(model_name, load_in_4bit=True)

print(f"\n메모리 절약: {mem_16bit/mem_4bit:.1f}x")

예상 출력:

16-bit (FP16):
GPU Memory: 14.5 GB

8-bit (Int8):
GPU Memory: 8.2 GB

4-bit (NF4 + DQ):
GPU Memory: 5.1 GB

메모리 절약: 2.8x

6. 가중치 병합 (Optional)

추론 속도 향상을 위해 LoRA 가중치를 베이스 모델에 병합:

from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

# 1. 16-bit로 베이스 모델 로드 (병합을 위해)
base_model = AutoModelForCausalLM.from_pretrained(
    "beomi/Llama-3-Open-Ko-8B",
    torch_dtype=torch.float16,
    device_map="auto",
)

# 2. LoRA 어댑터 로드
model = PeftModel.from_pretrained(base_model, "./qlora_adapter")

# 3. 가중치 병합
merged_model = model.merge_and_unload()

# 4. 병합된 모델 저장
merged_model.save_pretrained("./merged_model")
tokenizer = AutoTokenizer.from_pretrained("beomi/Llama-3-Open-Ko-8B")
tokenizer.save_pretrained("./merged_model")

print("모델 병합 완료!")

7. 전체 파이프라인 (복사-붙여넣기용)

"""
QLoRA Fine-tuning 전체 파이프라인
"""

import torch
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
    TrainingArguments,
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
from datasets import load_dataset

# =====================
# 1. 설정
# =====================
MODEL_NAME = "beomi/Llama-3-Open-Ko-8B"
OUTPUT_DIR = "./qlora_output"
DATASET_NAME = "your_dataset"  # 변경 필요

# =====================
# 2. 4-bit 양자화 설정
# =====================
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.bfloat16
)

# =====================
# 3. 모델 & 토크나이저 로드
# =====================
model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    quantization_config=bnb_config,
    device_map="auto",
)

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
tokenizer.pad_token = tokenizer.eos_token

# =====================
# 4. QLoRA 설정
# =====================
model = prepare_model_for_kbit_training(model)

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj"
    ]
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

# =====================
# 5. 데이터셋 로드
# =====================
dataset = load_dataset(DATASET_NAME, split="train")

# =====================
# 6. 학습 설정
# =====================
training_args = TrainingArguments(
    output_dir=OUTPUT_DIR,
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=2e-4,
    bf16=True,
    logging_steps=10,
    save_strategy="epoch",
    optim="paged_adamw_32bit",
    gradient_checkpointing=True,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
)

# =====================
# 7. 학습
# =====================
trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=dataset,
    tokenizer=tokenizer,
    dataset_text_field="text",
    max_seq_length=512,
)

trainer.train()

# =====================
# 8. 저장
# =====================
trainer.model.save_pretrained(f"{OUTPUT_DIR}/final_adapter")
tokenizer.save_pretrained(f"{OUTPUT_DIR}/final_adapter")

print("QLoRA 학습 완료!")

핵심 설정 요약

설정권장값설명
bnb_4bit_quant_type"nf4"NormalFloat4 (최적 양자화)
bnb_4bit_use_double_quantTrueDouble Quantization
bnb_4bit_compute_dtypetorch.bfloat16연산 정밀도
lora_r8~64LoRA rank (성능에 큰 영향 없음)
target_modules모든 linearQLoRA는 모든 레이어에 적용 권장
optim"paged_adamw_32bit"Paged Optimizer
gradient_checkpointingTrue메모리 절약

참고사항

  1. GPU 메모리: 7B 모델 기준 약 6~8GB로 학습 가능
  2. 학습 시간: 데이터셋 크기에 따라 다르지만, 소비자 GPU에서도 수 시간 내 완료
  3. 성능: 16-bit Full Fine-tuning과 동등한 성능 달성 가능
profile
AI 개발공부 일지

0개의 댓글