이미지 로딩 중...

AI 파인튜닝 4편 - QLoRA와 양자화 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 9. · 2 Views

AI 파인튜닝 4편 - QLoRA와 양자화 완벽 가이드

메모리 효율적인 대규모 언어 모델 파인튜닝 기법인 QLoRA를 다룹니다. 양자화 기술을 활용하여 제한된 GPU 메모리로도 수십억 파라미터 모델을 파인튜닝하는 실전 노하우를 제공합니다.


목차

  1. QLoRA 기본 개념
  2. 4비트 양자화(NF4)
  3. 이중 양자화(Double Quantization)
  4. LoRA 어댑터 설정
  5. QLoRA 모델 로딩
  6. 학습 데이터 준비
  7. QLoRA 트레이닝 설정
  8. 그래디언트 체크포인팅

1. QLoRA 기본 개념

시작하며

여러분이 70억 개 이상의 파라미터를 가진 대규모 언어 모델을 파인튜닝하려고 할 때, GPU 메모리 부족 에러를 만난 적 있나요? 일반적으로 7B 모델을 전체 파인튜닝하려면 최소 80GB 이상의 GPU 메모리가 필요합니다.

이런 문제는 실제 개발 현장에서 가장 큰 장벽입니다. 대부분의 개발자들은 A100이나 H100 같은 고성능 GPU에 접근하기 어렵고, 클라우드 비용도 만만치 않습니다.

결국 모델 크기를 줄이거나 파인튜닝을 포기하는 경우가 많습니다. 바로 이럴 때 필요한 것이 QLoRA(Quantized Low-Rank Adaptation)입니다.

QLoRA는 4비트 양자화와 LoRA를 결합하여, 단일 24GB GPU로도 65B 모델을 파인튜닝할 수 있게 해줍니다.

개요

간단히 말해서, QLoRA는 모델의 가중치를 4비트로 양자화하면서도 학습 품질을 유지하는 혁신적인 파인튜닝 기법입니다. QLoRA가 필요한 이유는 명확합니다.

기존 16비트 부동소수점(FP16) 대비 메모리 사용량을 4분의 1로 줄이면서도, 전체 파인튜닝과 거의 동일한 성능을 달성할 수 있기 때문입니다. 예를 들어, LLaMA-2-7B 모델을 파인튜닝할 때 80GB가 아닌 12GB 메모리만으로도 충분합니다.

기존에는 LoRA만 사용하여 메모리를 절약했다면, 이제는 양자화까지 결합하여 획기적인 메모리 절감을 달성할 수 있습니다. QLoRA는 LoRA의 PEFT(Parameter-Efficient Fine-Tuning) 이점과 양자화의 메모리 효율성을 동시에 가져옵니다.

QLoRA의 핵심 특징은 세 가지입니다: 1) 4비트 NormalFloat(NF4) 양자화로 가중치 압축, 2) 이중 양자화로 추가 메모리 절감, 3) 페이지드 옵티마이저로 메모리 스파이크 방지. 이러한 특징들이 합쳐져 실용적인 대규모 모델 파인튜닝을 가능하게 합니다.

코드 예제

from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import prepare_model_for_kbit_training, LoraConfig, get_peft_model
import torch

# 4비트 양자화 설정
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,  # 4비트 양자화 활성화
    bnb_4bit_quant_type="nf4",  # NormalFloat 4비트 사용
    bnb_4bit_compute_dtype=torch.bfloat16,  # 계산은 bf16으로
    bnb_4bit_use_double_quant=True  # 이중 양자화 활성화
)

# 모델 로딩 - 자동으로 4비트로 양자화됨
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    quantization_config=bnb_config,
    device_map="auto"  # GPU 메모리 자동 분배
)

설명

이것이 하는 일: QLoRA는 대규모 언어 모델의 가중치를 4비트로 압축하면서, 소량의 LoRA 어댑터만 학습하여 메모리 효율적인 파인튜닝을 가능하게 합니다. 첫 번째로, BitsAndBytesConfig를 통해 양자화 설정을 정의합니다.

load_in_4bit=True는 모델을 4비트로 로딩하고, bnb_4bit_quant_type="nf4"는 일반 4비트 정수가 아닌 정규분포에 최적화된 NF4 형식을 사용합니다. 이렇게 하는 이유는 모델 가중치가 보통 정규분포를 따르기 때문에, NF4가 더 적은 양자화 오류를 발생시키기 때문입니다.

두 번째로, bnb_4bit_compute_dtype=torch.bfloat16 설정이 중요합니다. 가중치는 4비트로 저장되지만, 실제 연산(forward/backward pass)은 bfloat16으로 수행됩니다.

이는 정확도를 유지하면서도 메모리는 절약하는 전략입니다. 연산 시에만 일시적으로 4비트를 16비트로 변환(dequantize)하고, 연산 후에는 다시 메모리에서 해제합니다.

세 번째로, bnb_4bit_use_double_quant=True는 양자화 상수(quantization constants) 자체도 양자화합니다. 일반적으로 4비트 양자화를 위해서는 블록별 스케일링 팩터가 필요한데, 이 팩터들도 메모리를 차지합니다.

이중 양자화는 이 팩터들까지 압축하여 추가로 약 3%의 메모리를 절약합니다. 마지막으로, device_map="auto"는 모델의 레이어들을 여러 GPU나 CPU 메모리에 자동으로 분산시킵니다.

단일 GPU로도 대규모 모델을 로딩할 수 있게 해주는 핵심 기능입니다. 여러분이 이 코드를 사용하면 기존 대비 4분의 1 수준의 GPU 메모리만으로도 대규모 모델을 로딩하고 파인튜닝할 수 있습니다.

실무에서는 A100 40GB 대신 RTX 3090 24GB로도 충분히 작업이 가능하며, 클라우드 비용도 크게 절감됩니다. 또한 양자화로 인한 성능 저하는 1-2% 미만으로 무시할 수 있는 수준입니다.

실전 팁

💡 compute_dtype은 가능하면 bfloat16을 사용하세요. float16보다 수치 안정성이 높아 학습 중 NaN이나 Inf 발생 확률이 낮습니다. 특히 대규모 모델일수록 bfloat16의 이점이 큽니다.

💡 메모리가 부족하다면 max_memory 파라미터로 각 디바이스의 최대 메모리를 명시적으로 제한하세요. 예: max_memory={0: "20GB", "cpu": "30GB"}. 이렇게 하면 OOM 에러를 사전에 방지할 수 있습니다.

💡 양자화된 모델을 저장할 때는 model.save_pretrained()가 아닌 LoRA 어댑터만 저장하세요. 베이스 모델은 양자화되지 않은 원본을 유지하고, 어댑터만 로드하는 것이 더 효율적입니다.

💡 멀티 GPU 환경에서는 device_map="auto" 대신 device_map="balanced"를 시도해보세요. 모델을 GPU 간에 더 균등하게 분배하여 병목을 줄일 수 있습니다.

💡 처음 실험할 때는 작은 모델(1B-3B)로 시작하여 QLoRA 설정이 제대로 작동하는지 확인하세요. 메모리 프로파일링 도구(torch.cuda.memory_summary())를 활용하여 실제 메모리 사용량을 모니터링하는 것도 중요합니다.


2. 4비트 양자화(NF4)

시작하며

여러분이 모델 가중치를 양자화할 때, 단순히 32비트를 4비트로 줄이면 정확도가 크게 떨어진다는 것을 경험해보셨나요? 일반적인 정수 양자화(INT4)를 사용하면 모델 성능이 10-20% 이상 저하되는 경우가 많습니다.

이런 문제는 신경망 가중치의 분포 특성을 무시하기 때문에 발생합니다. 대부분의 언어 모델 가중치는 0을 중심으로 한 정규분포를 따르는데, 균등한 간격의 정수 양자화는 이런 분포에 최적화되어 있지 않습니다.

결과적으로 중요한 가중치 값들이 부정확하게 표현됩니다. 바로 이럴 때 필요한 것이 NF4(4-bit NormalFloat) 양자화입니다.

NF4는 정규분포에 맞춰 양자화 구간을 비균등하게 설정하여, 정확도 손실을 최소화하면서도 메모리를 4분의 1로 줄입니다.

개요

간단히 말해서, NF4는 신경망 가중치의 정규분포 특성을 활용하여 정보 손실을 최소화하는 4비트 데이터 타입입니다. NF4가 필요한 이유는 정보 이론적 관점에서 명확합니다.

가중치가 정규분포를 따른다면, 0 근처에 값들이 밀집되어 있고 극단값은 드뭅니다. NF4는 0 근처에 더 많은 양자화 레벨을 할당하고 극단값에는 적게 할당하여, 제한된 4비트로 최대한 많은 정보를 보존합니다.

예를 들어, 금융 시계열 데이터 압축이나 센서 데이터 저장에도 유사한 원리가 사용됩니다. 기존에는 INT4(균등 간격 정수 양자화)를 사용했다면, 이제는 NF4를 사용하여 분포에 적응적인 양자화를 수행할 수 있습니다.

INT4는 -8부터 7까지 균등하게 나누지만, NF4는 정규분포의 분위수(quantile)를 기반으로 구간을 나눕니다. NF4의 핵심 특징은 세 가지입니다: 1) 정규분포의 분위수 기반 양자화 레벨 설정, 2) 정보 이론적으로 최적화된 비트 할당, 3) 추가 연산 오버헤드 없이 FP16과 동등한 처리 속도.

이러한 특징들이 QLoRA의 핵심 경쟁력을 만들어냅니다.

코드 예제

import torch
from transformers import BitsAndBytesConfig

# NF4 양자화 설정 - 정규분포에 최적화
nf4_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",  # NormalFloat 4비트
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True
)

# INT4와 비교를 위한 설정 (일반적으로 사용하지 않음)
# int4_config = BitsAndBytesConfig(
#     load_in_4bit=True,
#     bnb_4bit_quant_type="fp4",  # 균등 간격 양자화
# )

# NF4 양자화 상수 예시 - 실제 BitsAndBytes 내부 값
# 정규분포 N(0,1)의 16개 분위수에 대응
nf4_levels = torch.tensor([
    -1.0, -0.6962, -0.5251, -0.3949, -0.2844, -0.1848,
    -0.0911, 0.0, 0.0911, 0.1848, 0.2844, 0.3949,
    0.5251, 0.6962, 1.0, float('inf')
])

설명

이것이 하는 일: NF4는 신경망 가중치의 통계적 분포를 분석하여, 가장 빈번하게 나타나는 값 범위에 더 많은 표현력을 할당하는 적응적 양자화 방식입니다. 첫 번째로, bnb_4bit_quant_type="nf4" 설정이 핵심입니다.

이것은 BitsAndBytes 라이브러리에 내장된 16개의 고정된 양자화 레벨을 사용합니다. 이 레벨들은 표준 정규분포 N(0,1)의 동일 확률 구간(equal probability intervals)으로 나뉘어져 있습니다.

왜 이렇게 하냐면, 대부분의 신경망 가중치는 정규화되어 있고, 따라서 이 고정된 레벨들이 대부분의 모델에 잘 맞기 때문입니다. 두 번째로, 실제 양자화 과정을 살펴보겠습니다.

각 가중치 블록(일반적으로 64-128개 값)마다 평균과 표준편차를 계산합니다. 그 다음 가중치를 정규화(normalize)하여 N(0,1) 분포로 변환하고, nf4_levels 테이블에서 가장 가까운 레벨을 찾아 4비트 인덱스로 매핑합니다.

이 과정에서 중요한 것은 블록별로 스케일링 팩터(scaling factor)를 저장해야 역양자화(dequantization)가 가능하다는 점입니다. 세 번째로, 역양자화 과정도 이해해야 합니다.

Forward/backward pass 중에 4비트 값을 실제 계산에 사용하려면 다시 FP16이나 BF16으로 변환해야 합니다. 4비트 인덱스로 nf4_levels 테이블을 조회하고, 블록의 스케일링 팩터를 곱하여 원래 값을 근사합니다.

이 연산은 GPU에서 매우 빠르게 수행되며, 메모리 대역폭이 병목인 현대 GPU 아키텍처에서는 계산 오버헤드가 거의 무시할 수준입니다. 네 번째로, 정보 이론적 관점을 살펴보면, NF4는 엔트로피 코딩과 유사한 원리를 사용합니다.

확률이 높은 이벤트(0 근처 값)에 더 많은 코드를 할당하고, 확률이 낮은 이벤트(극단값)에는 적게 할당하여, 제한된 비트로 최대 정보를 표현합니다. 여러분이 NF4를 사용하면 INT4 대비 2-3배 낮은 양자화 오류를 얻을 수 있습니다.

실무적으로는 FP16 전체 파인튜닝 대비 0.5-1% 이내의 성능 차이만 발생하며, 이는 대부분의 애플리케이션에서 허용 가능한 수준입니다. 또한 모델 크기가 클수록(30B 이상) NF4의 이점이 더 명확하게 나타납니다.

실전 팁

💡 NF4와 FP4를 혼동하지 마세요. FP4는 floating point 4-bit로 부호, 지수, 가수를 4비트에 압축한 것이고, NF4는 정규분포 기반 lookup table 방식입니다. 언어 모델에는 NF4가 거의 항상 더 우수합니다.

💡 양자화 블록 크기는 기본값(64)을 사용하는 것이 좋습니다. 블록 크기를 너무 작게 하면 스케일링 팩터 저장 오버헤드가 커지고, 너무 크게 하면 블록 내 분포 가정이 깨질 수 있습니다.

💡 모델 가중치가 정규분포를 따르지 않는 경우(예: sparse 모델, pruned 모델)에는 NF4의 이점이 줄어듭니다. 이런 경우 양자화 전 가중치 분포를 시각화하여 확인하세요(plt.hist(model.parameters())).

💡 양자화 오류를 측정하고 싶다면, 원본 FP16 모델과 NF4 모델의 perplexity를 비교하세요. 일반적으로 perplexity 증가가 5% 미만이면 양자화가 성공적입니다.

💡 추론(inference) 전용이라면 GPTQ나 AWQ 같은 다른 양자화 방법도 고려하세요. NF4는 학습 중 양자화(quantization-aware training)에 최적화되어 있지만, 추론만 한다면 더 공격적인 양자화도 가능합니다.


3. 이중 양자화(Double Quantization)

시작하며

여러분이 4비트 양자화를 적용한 후에도, 예상보다 메모리 사용량이 크다는 것을 발견한 적 있나요? 65B 모델을 4비트로 양자화하면 이론상 약 33GB가 되어야 하는데, 실제로는 36-38GB가 필요합니다.

이런 문제는 양자화 메타데이터 때문에 발생합니다. 4비트 양자화를 위해서는 각 블록마다 스케일링 팩터와 제로 포인트를 FP32나 FP16으로 저장해야 하는데, 이것들이 전체 메모리의 약 10-15%를 차지합니다.

모델이 클수록 이 오버헤드의 절대값도 커집니다. 바로 이럴 때 필요한 것이 이중 양자화(Double Quantization)입니다.

양자화 상수들까지 다시 양자화하여, 추가로 3-5%의 메모리를 절약할 수 있습니다.

개요

간단히 말해서, 이중 양자화는 메타데이터(양자화 상수)도 저정밀도로 압축하여 메모리 효율을 극대화하는 기법입니다. 이중 양자화가 필요한 이유는 스케일링 오버헤드 때문입니다.

예를 들어 65B 파라미터 모델에서 블록 크기가 64라면, 약 10억 개의 블록이 생기고, 각 블록마다 FP32 스케일링 팩터(4바이트)를 저장하면 4GB가 필요합니다. 이는 전체 양자화 모델 크기의 12% 정도입니다.

이중 양자화는 이 4GB를 1GB 이하로 줄입니다. 기존에는 4비트 가중치 + FP32 스케일링 팩터 조합을 사용했다면, 이제는 4비트 가중치 + 8비트 스케일링 팩터 + FP32 상위 스케일링 팩터로 계층적 압축을 수행합니다.

마치 압축 알고리즘에서 메타데이터를 다시 압축하는 것과 비슷한 개념입니다. 이중 양자화의 핵심 특징은 세 가지입니다: 1) 블록 스케일링 팩터를 8비트로 양자화, 2) 상위 블록(256개 블록 단위)에 대한 FP32 슈퍼-스케일링 팩터 유지, 3) 정확도 손실 거의 없이(0.1% 미만) 메모리 절감.

이러한 계층적 구조가 압축 효율과 정확도의 균형을 맞춥니다.

코드 예제

from transformers import BitsAndBytesConfig
import torch

# 이중 양자화 활성화 설정
double_quant_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,  # 이중 양자화 활성화
)

# 이중 양자화 비활성화 비교용
single_quant_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=False,  # 단일 양자화만
)

# 메모리 사용량 비교 예시
# Single quantization: 65B model -> ~36GB
# Double quantization: 65B model -> ~33GB
# 절약량: ~3GB (약 8-10% 추가 절감)

설명

이것이 하는 일: 이중 양자화는 2단계 계층적 압축 구조를 사용하여, 가중치뿐 아니라 양자화에 필요한 메타데이터까지 압축합니다. 첫 번째로, 단일 양자화의 메모리 구조를 이해해야 합니다.

4비트 양자화에서는 N개의 가중치를 블록 크기 B로 나누어 N/B 개의 블록을 만듭니다. 각 블록은 B개의 4비트 값(B/2 바이트)과 1개의 FP32 스케일링 팩터(4바이트)를 가집니다.

따라서 총 메모리는 N/2 + 4N/B 바이트입니다. B=64일 때, 스케일링 팩터가 전체의 약 11%를 차지합니다.

두 번째로, 이중 양자화의 작동 방식을 살펴보겠습니다. 첫 번째 단계에서는 가중치를 4비트로 양자화하고, 각 블록의 FP32 스케일링 팩터를 계산합니다(이것을 s1이라 하겠습니다).

두 번째 단계에서는 인접한 여러 블록(보통 256개)의 스케일링 팩터들을 모아서, 이것들을 다시 8비트로 양자화합니다. 이때 상위 FP32 스케일링 팩터(s2)를 하나 더 저장합니다.

역양자화 시에는 4bit → s1_quantized → s2 → 최종값 순서로 복원합니다. 세 번째로, 메모리 계산을 구체적으로 해보겠습니다.

65B 파라미터에서 블록 크기 64를 사용하면, 1차 블록이 약 10억 개입니다. 단일 양자화에서는 10억 × 4바이트 = 4GB가 스케일링 팩터에 필요합니다.

이중 양자화에서는 10억 × 1바이트 = 1GB (8비트 양자화) + 400만 × 4바이트 = 16MB (상위 스케일링 팩터) = 약 1GB로 줄어듭니다. 3GB 절약입니다.

네 번째로, 정확도 영향을 분석하면, 스케일링 팩터는 가중치보다 변동 범위가 작고 부드럽게(smooth) 변합니다. 따라서 8비트 양자화로도 충분히 표현 가능합니다.

실험 결과에 따르면 이중 양자화로 인한 추가 성능 저하는 0.1% 미만으로, 실용적으로 무시할 수 있습니다. 이는 스케일링 팩터의 오류가 가중치 값에 곱해지기 때문에, 상대 오류가 작으면 최종 결과에 미치는 영향도 작기 때문입니다.

여러분이 이중 양자화를 사용하면 제한된 GPU 메모리로 더 큰 모델을 다룰 수 있습니다. 실무에서는 24GB GPU로 13B 모델 대신 20B 모델을 시도해볼 여유가 생기거나, 동일 모델에서 배치 크기를 늘려 학습 속도를 개선할 수 있습니다.

특히 멀티 GPU 환경에서는 각 GPU당 메모리 절약이 전체 처리량에 큰 영향을 미칩니다.

실전 팁

💡 이중 양자화는 거의 항상 활성화하는 것이 좋습니다. 정확도 손실은 무시할 수준이고, 메모리 절감은 실질적이기 때문입니다. use_double_quant=False로 할 이유는 거의 없습니다.

💡 상위 블록 크기(second-level block size)는 기본값인 256을 사용하세요. 이 값은 압축률과 정확도의 최적 균형점으로 실험적으로 검증되었습니다.

💡 메모리 프로파일링을 할 때는 torch.cuda.memory_allocated()로 실제 사용량을 측정하세요. 이론값과 실제값 사이에 5-10% 차이가 있을 수 있는데, 이는 CUDA 메모리 할당 정책 때문입니다.

💡 추론 서버를 운영한다면, 이중 양자화로 절약한 메모리를 KV 캐시 증가에 활용하세요. 더 긴 컨텍스트나 더 큰 배치 크기를 처리할 수 있어 처리량이 늘어납니다.

💡 커스텀 양자화를 구현한다면, BitsAndBytes의 functional.py를 참고하세요. 이중 양자화의 실제 구현 코드를 볼 수 있으며, CUDA 커널 최적화 기법도 배울 수 있습니다.


4. LoRA 어댑터 설정

시작하며

여러분이 QLoRA로 파인튜닝을 시작하려고 할 때, LoRA의 rank와 alpha 값을 어떻게 설정해야 할지 막막했던 적 있나요? 너무 작게 설정하면 모델이 제대로 학습되지 않고, 너무 크게 설정하면 메모리 이점이 사라집니다.

이런 문제는 LoRA의 하이퍼파라미터가 모델 용량과 학습 효율의 균형을 결정하기 때문에 발생합니다. 적절한 설정 없이는 파인튜닝 품질이 떨어지거나, QLoRA의 메모리 효율성 이점을 제대로 활용하지 못합니다.

또한 어떤 레이어에 LoRA를 적용할지도 중요한 결정 사항입니다. 바로 이럴 때 필요한 것이 체계적인 LoRA 어댑터 설정입니다.

rank, alpha, target_modules를 적절히 조정하여, 최소한의 파라미터로 최대의 학습 효과를 얻을 수 있습니다.

개요

간단히 말해서, LoRA 어댑터 설정은 학습 가능한 저랭크 행렬의 크기와 위치를 결정하여, 파인튜닝의 효율성과 성능을 제어합니다. LoRA 설정이 중요한 이유는 전체 파라미터의 0.1-1%만 학습하면서도 전체 파인튜닝의 95-99% 성능을 달성할 수 있기 때문입니다.

rank는 어댑터의 표현력을, alpha는 어댑터 업데이트의 스케일을, target_modules는 어디에 적용할지를 결정합니다. 예를 들어, 7B 모델에서 rank=8을 사용하면 약 8-16M 파라미터만 학습하여, 전체의 0.1%입니다.

기존에는 모든 레이어를 학습했다면, 이제는 query, key, value 프로젝션과 같은 핵심 레이어만 선택적으로 학습할 수 있습니다. 또한 rank를 통해 표현력과 메모리의 트레이드오프를 세밀하게 조정할 수 있습니다.

LoRA 설정의 핵심 특징은 세 가지입니다: 1) rank는 일반적으로 4-64 범위에서 설정하며 모델 크기와 태스크 복잡도에 비례, 2) alpha는 보통 rank의 1-2배로 설정하여 학습률과 균형, 3) target_modules는 attention 레이어(q, k, v, o)에 집중하되 필요시 MLP도 포함. 이러한 설정들이 QLoRA의 실용성을 결정합니다.

코드 예제

from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

# LoRA 어댑터 설정
lora_config = LoraConfig(
    r=16,  # rank: 저랭크 행렬의 차원 (4-64 권장)
    lora_alpha=32,  # scaling factor (보통 rank의 2배)
    target_modules=[  # LoRA를 적용할 레이어
        "q_proj",  # Query projection
        "k_proj",  # Key projection
        "v_proj",  # Value projection
        "o_proj",  # Output projection
        # "gate_proj", "up_proj", "down_proj"  # MLP 레이어 (선택)
    ],
    lora_dropout=0.05,  # 과적합 방지용 드롭아웃
    bias="none",  # 바이어스 학습 안 함
    task_type="CAUSAL_LM"  # 인과적 언어모델링
)

# 모델을 kbit 학습용으로 준비 (그래디언트 체크포인팅 등)
model = prepare_model_for_kbit_training(model)

# LoRA 어댑터 추가
model = get_peft_model(model, lora_config)

# 학습 가능한 파라미터 확인
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Trainable parameters: {trainable_params:,}")  # 예: 약 8-20M

설명

이것이 하는 일: LoRA 어댑터는 원본 가중치 행렬 W에 저랭크 분해 행렬 ΔW = BA를 추가하여, 전체 파라미터의 극히 일부만 학습하면서도 효과적인 파인튜닝을 수행합니다. 첫 번째로, rank(r) 파라미터를 이해해야 합니다.

W가 d×k 행렬이라면, LoRA는 d×r 행렬 B와 r×k 행렬 A를 학습합니다. r이 작을수록 파라미터 수가 줄어들지만 표현력도 감소합니다.

r=16이면 d×k 대신 r(d+k)개 파라미터만 필요합니다. 예를 들어 4096×4096 행렬에서, 전체 학습은 16M 파라미터지만 r=16 LoRA는 단 131K 파라미터만 사용합니다(99.2% 감소).

두 번째로, lora_alpha의 역할을 살펴보겠습니다. 실제 모델 업데이트는 W' = W + (alpha/r) × BA입니다.

alpha가 클수록 LoRA의 영향력이 커집니다. alpha=32, r=16이면 스케일링 팩터는 2입니다.

이는 학습률과 상호작용하여 최종 학습 동역학을 결정합니다. alpha를 rank의 2배로 설정하는 것이 일반적인 관행이며, 이는 다양한 실험에서 안정적인 학습을 보였기 때문입니다.

세 번째로, target_modules 선택이 매우 중요합니다. Transformer의 attention 메커니즘에서 q_proj, k_proj, v_proj는 입력을 Query, Key, Value로 변환하는 핵심 레이어입니다.

대부분의 태스크 특화 정보가 이 레이어들에 인코딩되므로, 여기에 LoRA를 적용하는 것이 가장 효과적입니다. o_proj(출력 프로젝션)도 포함하면 더 좋습니다.

MLP 레이어(gate_proj, up_proj, down_proj)는 선택사항인데, 포함하면 표현력은 증가하지만 학습 파라미터도 2-3배 늘어납니다. 네 번째로, lora_dropout은 과적합 방지 장치입니다.

작은 데이터셋(<10K 샘플)을 사용할 때는 0.1 정도로 높이고, 큰 데이터셋(>100K)에서는 0.05나 0으로 낮추는 것이 좋습니다. dropout은 학습 중에만 적용되며 추론 시에는 비활성화됩니다.

다섯 번째로, prepare_model_for_kbit_training의 역할도 중요합니다. 이 함수는 양자화된 모델을 학습 가능하게 만듭니다.

구체적으로는 1) 입력 임베딩과 출력 레이어를 FP32로 유지(안정성), 2) 그래디언트 체크포인팅 활성화(메모리 절약), 3) LoRA 레이어만 requires_grad=True 설정을 수행합니다. 여러분이 적절한 LoRA 설정을 사용하면 7B 모델에서 8-20M 파라미터만 학습하여, 메모리 사용량을 크게 줄이면서도 전체 파인튜닝의 95% 이상 성능을 달성할 수 있습니다.

실무에서는 작은 태스크(분류, 간단한 생성)에는 r=8, 중간 복잡도 태스크(요약, QA)에는 r=16, 복잡한 태스크(복잡한 추론, 장문 생성)에는 r=32-64를 시작점으로 사용합니다.

실전 팁

💡 rank를 결정할 때는 데이터셋 크기를 고려하세요. 데이터가 적으면(<1K) r=4-8로 낮게, 데이터가 많으면(>100K) r=32-64로 높게 설정하여 과적합을 방지하거나 표현력을 높입니다.

💡 여러 LoRA 설정을 실험할 때는 작은 subset(10-20%)으로 먼저 테스트하세요. rank를 4, 8, 16, 32로 변경하며 validation loss를 확인하고, 최적점을 찾은 후 전체 데이터로 학습합니다.

💡 target_modules를 자동으로 찾으려면 model.named_modules()로 모든 레이어를 출력하고, Linear 레이어들을 확인하세요. 모델 아키텍처마다 레이어 이름이 다를 수 있습니다(LLaMA: q_proj, GPT: c_attn 등).

💡 MLP 레이어를 포함할지는 검증 성능으로 결정하세요. Attention만으로 충분하면 불필요한 파라미터를 추가하지 마세요. 일반적으로 도메인 적응(domain adaptation)에는 Attention만, 새로운 스킬 학습에는 MLP도 포함하는 것이 효과적입니다.

💡 여러 태스크를 동시에 학습한다면, 태스크별로 다른 LoRA 어댑터를 만들고 추론 시 스위칭할 수 있습니다. model.load_adapter("task_a") 형태로 사용하면 단일 베이스 모델로 여러 전문화 버전을 관리할 수 있습니다.


5. QLoRA 모델 로딩

시작하며

여러분이 양자화와 LoRA 설정을 모두 완료했는데, 실제로 모델을 로딩할 때 예상치 못한 에러나 메모리 부족 문제를 겪은 적 있나요? device_map 설정을 잘못하면 모델이 여러 GPU에 비효율적으로 분산되거나, CPU에 밀려나서 학습 속도가 크게 느려질 수 있습니다.

이런 문제는 QLoRA의 복잡한 메모리 관리 때문에 발생합니다. 양자화된 가중치, LoRA 어댑터, 옵티마이저 상태, 그래디언트 등이 모두 다른 정밀도와 위치에 저장되어야 합니다.

또한 모델 로딩 순서도 중요한데, 잘못하면 일시적으로 FP16 모델 전체가 메모리에 올라가 OOM이 발생할 수 있습니다. 바로 이럴 때 필요한 것이 체계적인 QLoRA 모델 로딩 전략입니다.

BitsAndBytes 설정, PEFT 준비, device mapping을 올바른 순서로 수행하여 안정적이고 효율적인 파인튜닝 환경을 구축할 수 있습니다.

개요

간단히 말해서, QLoRA 모델 로딩은 양자화 설정과 LoRA 어댑터를 통합하여 메모리 효율적이면서도 학습 가능한 모델을 준비하는 과정입니다. QLoRA 로딩이 중요한 이유는 이 단계에서 메모리 레이아웃이 결정되기 때문입니다.

적절히 로딩하면 24GB GPU로 70B 모델도 다룰 수 있지만, 잘못하면 7B 모델도 OOM이 발생합니다. 양자화 설정이 모델 로딩 시점에 적용되므로, 이후에는 변경할 수 없습니다.

예를 들어, device_map="auto"는 모델 레이어들을 사용 가능한 GPU와 CPU 메모리에 자동으로 분배하여 최대 모델 크기를 지원합니다. 기존에는 모델을 먼저 로딩하고 나중에 양자화를 적용했다면(post-training quantization), QLoRA에서는 로딩과 동시에 양자화가 이루어집니다(load-time quantization).

이는 메모리 피크를 크게 줄여줍니다. QLoRA 로딩의 핵심 특징은 세 가지입니다: 1) BitsAndBytesConfig를 from_pretrained에 전달하여 즉시 양자화, 2) prepare_model_for_kbit_training으로 학습 준비(그래디언트 활성화 등), 3) get_peft_model로 LoRA 어댑터 주입.

이러한 단계별 접근이 안정적인 메모리 관리를 보장합니다.

코드 예제

from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import prepare_model_for_kbit_training, LoraConfig, get_peft_model
import torch

# 1단계: 양자화 설정 정의
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True
)

# 2단계: 모델 로딩 (자동 양자화)
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    quantization_config=bnb_config,
    device_map="auto",  # 자동 디바이스 분배
    trust_remote_code=True,  # 커스텀 코드 허용
    torch_dtype=torch.bfloat16  # 비양자화 부분의 dtype
)

# 3단계: kbit 학습 준비
model = prepare_model_for_kbit_training(model)

# 4단계: LoRA 설정
lora_config = LoraConfig(
    r=16, lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    lora_dropout=0.05, bias="none", task_type="CAUSAL_LM"
)

# 5단계: LoRA 어댑터 추가
model = get_peft_model(model, lora_config)

# 6단계: 토크나이저 로딩
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
tokenizer.pad_token = tokenizer.eos_token  # 패딩 토큰 설정

# 학습 가능 파라미터 출력
model.print_trainable_parameters()
# 출력 예시: trainable params: 20M || all params: 7B || trainable%: 0.29%

설명

이것이 하는 일: QLoRA 모델 로딩 과정은 대규모 모델을 압축된 형태로 메모리에 적재하고, 소량의 학습 가능한 파라미터를 추가하여 파인튜닝 준비를 완료합니다. 첫 번째로, BitsAndBytesConfig를 from_pretrained의 quantization_config 파라미터로 전달하는 것이 핵심입니다.

이렇게 하면 모델 가중치가 디스크에서 읽혀질 때 즉시 4비트로 변환되어 GPU 메모리에 저장됩니다. 만약 이 설정 없이 로딩하면, 먼저 FP16으로 전체 모델이 메모리에 올라가고(예: 14GB) 그 후 양자화되는데, 이 과정에서 메모리 피크가 두 배가 되어 OOM이 발생할 수 있습니다.

두 번째로, device_map="auto"의 동작 방식을 이해해야 합니다. 이 설정은 Accelerate 라이브러리를 사용하여 모델의 각 레이어를 사용 가능한 디바이스에 분배합니다.

GPU 메모리를 먼저 채우고, 부족하면 CPU 메모리로 오프로드합니다. 예를 들어 24GB GPU에 33B 모델을 로딩하면, 처음 20-30개 레이어는 GPU에, 나머지는 CPU에 배치됩니다.

CPU 레이어는 느리지만, 학습에서는 대부분 시간이 GPU 레이어에서 소요되므로 전체 속도 저하는 10-20% 정도입니다. 세 번째로, prepare_model_for_kbit_training의 내부 동작을 살펴보겠습니다.

이 함수는 1) 모든 파라미터를 requires_grad=False로 설정(양자화된 가중치는 학습 안 함), 2) 모델 입력에 gradient checkpointing을 활성화하여 메모리 절약, 3) 입력 임베딩 레이어를 FP32로 유지하여 안정성 확보 등을 수행합니다. 특히 gradient checkpointing은 activation을 재계산하여 메모리를 40-50% 추가로 절약하는 중요한 기법입니다.

네 번째로, get_peft_model이 하는 일은 지정된 target_modules에 LoRA 레이어를 주입(inject)하는 것입니다. 원본 Linear 레이어를 LoraLinear로 래핑하고, BA 행렬을 초기화합니다.

A는 정규 분포로, B는 0으로 초기화되어, 학습 초기에는 LoRA가 아무 영향을 주지 않습니다(ΔW=0). 학습이 진행되면서 점진적으로 모델을 태스크에 맞게 조정합니다.

다섯 번째로, 토크나이저 설정도 중요합니다. pad_token을 명시적으로 설정하지 않으면, 배치 처리 시 에러가 발생합니다.

일반적으로 eos_token을 pad_token으로 사용하며, attention mask를 통해 패딩 부분은 무시됩니다. 여러분이 이 로딩 절차를 따르면 메모리 관련 에러를 최소화하고, 재현 가능한 파인튜닝 환경을 만들 수 있습니다.

실무에서는 이 코드를 스크립트나 노트북 첫 부분에 배치하여, 환경 설정 단계로 사용합니다. 로딩 시간은 모델 크기에 따라 1-5분 정도 소요되며, 이후 학습은 정상 속도로 진행됩니다.

실전 팁

💡 device_map을 커스터마이징하려면 딕셔너리 형태로 레이어별 디바이스를 지정할 수 있습니다. 예: device_map={"model.layers.0-15": 0, "model.layers.16-31": 1}으로 멀티 GPU에 수동 분배 가능합니다.

💡 모델 로딩 시간이 길다면, low_cpu_mem_usage=True 파라미터를 추가하세요. 모델을 청크 단위로 로딩하여 CPU 메모리 피크를 줄입니다(특히 노트북 환경에서 유용).

💡 trust_remote_code=True는 보안 리스크가 있으므로, 신뢰할 수 있는 모델에만 사용하세요. 커스텀 아키텍처(예: Falcon, MPT)가 아니라면 생략 가능합니다.

💡 메모리 사용량을 모니터링하려면 로딩 후 print(torch.cuda.memory_summary())로 상세 정보를 확인하세요. 어떤 레이어가 어느 디바이스에 있는지, 얼마나 메모리를 사용하는지 알 수 있습니다.

💡 여러 실험을 반복한다면, 로딩된 모델을 disk에 캐싱하는 것도 고려하세요. model.save_pretrained("cached_model")로 양자화된 상태로 저장하면, 다음 로딩 시 시간을 절약할 수 있습니다(단, 디스크 공간 필요).


6. 학습 데이터 준비

시작하며

여러분이 QLoRA 모델을 준비했는데, 학습 데이터를 어떤 형식으로 만들어야 할지, 어떻게 효율적으로 로딩할지 고민해본 적 있나요? 잘못된 데이터 형식은 학습 실패로 이어지고, 비효율적인 데이터 로딩은 GPU 유휴 시간을 증가시켜 학습 시간과 비용을 낭비합니다.

이런 문제는 언어 모델 파인튜닝의 데이터 요구사항이 일반적인 ML 태스크와 다르기 때문에 발생합니다. instruction-response 쌍, prompt template, attention mask 등을 올바르게 구성해야 하고, 메모리 제약상 데이터를 스트리밍이나 배치로 효율적으로 로딩해야 합니다.

토크나이징 전략도 성능에 큰 영향을 미칩니다. 바로 이럴 때 필요한 것이 체계적인 학습 데이터 준비 파이프라인입니다.

데이터셋 포맷 설계, 효율적 토크나이징, 그리고 DataLoader 최적화를 통해 QLoRA 학습의 효율성을 극대화할 수 있습니다.

개요

간단히 말해서, 학습 데이터 준비는 raw 텍스트를 모델이 학습할 수 있는 토큰 시퀀스로 변환하고, 메모리 효율적으로 로딩하는 과정입니다. 학습 데이터 준비가 중요한 이유는 데이터 품질과 효율성이 최종 모델 성능과 학습 속도를 직접적으로 결정하기 때문입니다.

instruction following 태스크에서는 "instruction", "input", "output" 필드를 명확히 구분해야 하고, 적절한 prompt template을 사용해야 모델이 의도를 이해합니다. 예를 들어, Alpaca 형식은 "Below is an instruction...

Instruction: ... ### Response: ..." 같은 명확한 구조를 사용합니다.

기존에는 전체 데이터셋을 메모리에 로딩하고 토큰화했다면, 대규모 데이터에서는 streaming이나 on-the-fly 토큰화로 메모리를 절약할 수 있습니다. 또한 dynamic padding 대신 fixed length를 사용하면 학습 속도가 향상됩니다.

학습 데이터 준비의 핵심 특징은 세 가지입니다: 1) instruction-response 형식으로 구조화하여 모델이 학습 목표를 명확히 인식, 2) 토큰 시퀀스를 적절한 길이(512-2048)로 자르거나 패딩, 3) Hugging Face datasets 라이브러리로 효율적 로딩 및 캐싱. 이러한 준비 과정이 성공적인 파인튜닝의 기반이 됩니다.

코드 예제

from datasets import load_dataset
from transformers import AutoTokenizer

# 토크나이저 로딩
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"  # 오른쪽 패딩 (인과적 LM에 적합)

# Alpaca 형식 데이터셋 로딩
dataset = load_dataset("tatsu-lab/alpaca", split="train")

# Prompt 템플릿 정의
def format_instruction(sample):
    """Instruction, Input, Output을 하나의 텍스트로 결합"""
    instruction = sample["instruction"]
    input_text = sample["input"]
    output_text = sample["output"]

    # Alpaca 형식 prompt 구성
    if input_text:
        prompt = f"""Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.

### Instruction:
{instruction}

### Input:
{input_text}

### Response:
{output_text}"""
    else:
        prompt = f"""Below is an instruction that describes a task. Write a response that appropriately completes the request.

### Instruction:
{instruction}

### Response:
{output_text}"""

    return {"text": prompt}

# 토큰화 함수
def tokenize_function(examples):
    """배치 토큰화 - 효율성을 위해 벡터화"""
    outputs = tokenizer(
        examples["text"],
        truncation=True,
        max_length=512,  # 최대 시퀀스 길이
        padding="max_length",  # 고정 길이로 패딩
        return_tensors=None  # 리스트로 반환
    )
    return outputs

# 데이터셋 전처리
formatted_dataset = dataset.map(format_instruction, remove_columns=dataset.column_names)
tokenized_dataset = formatted_dataset.map(
    tokenize_function,
    batched=True,  # 배치 처리로 속도 향상
    num_proc=4,  # 멀티프로세싱
    remove_columns=["text"]
)

print(f"Dataset size: {len(tokenized_dataset)}")
print(f"Sample: {tokenized_dataset[0]['input_ids'][:20]}")  # 첫 20개 토큰

설명

이것이 하는 일: 학습 데이터 준비 파이프라인은 raw 텍스트 데이터를 모델이 이해할 수 있는 구조화된 형식으로 변환하고, 토큰 ID 시퀀스로 인코딩하여 메모리 효율적인 데이터셋을 만듭니다. 첫 번째로, prompt template 설계가 매우 중요합니다.

format_instruction 함수는 instruction, input, output을 특정 형식으로 결합합니다. "### Instruction:", "### Response:" 같은 명확한 구분자를 사용하여, 모델이 어디가 사용자 입력이고 어디가 원하는 출력인지 학습하게 합니다.

이런 구조화가 없으면 모델은 랜덤한 텍스트를 생성하거나 instruction을 무시할 수 있습니다. Alpaca, ChatML, Vicuna 등 다양한 template이 있으며, 일관성이 중요합니다.

두 번째로, 토큰화 전략을 살펴보겠습니다. tokenize_function은 텍스트를 정수 시퀀스(토큰 ID)로 변환합니다.

truncation=True는 max_length를 초과하는 텍스트를 자르고, padding="max_length"는 짧은 텍스트를 패딩 토큰으로 채워 모든 샘플을 동일 길이로 만듭니다. 고정 길이는 배치 처리를 단순화하고 GPU 효율을 높이지만, 메모리를 더 사용합니다.

대안으로 padding=False를 사용하고 DataLoader에서 dynamic padding을 할 수도 있습니다. 세 번째로, dataset.map의 효율성 최적화가 중요합니다.

batched=True는 한 번에 여러 샘플을 처리하여 토크나이저의 벡터화된 연산을 활용합니다(10-100배 빠름). num_proc=4는 4개 CPU 코어를 병렬로 사용하여 처리 속도를 높입니다.

대규모 데이터셋(>100K)에서는 이런 최적화가 필수입니다. 또한 map 결과는 자동으로 디스크에 캐싱되어, 다음 실행 시 재처리하지 않습니다.

네 번째로, padding_side="right" 설정을 이해해야 합니다. 인과적 언어 모델(causal LM)에서는 오른쪽 패딩이 적합합니다.

왜냐하면 모델은 왼쪽에서 오른쪽으로 토큰을 생성하며, 패딩은 실제 텍스트 뒤에 와야 attention mask로 무시될 수 있기 때문입니다. Encoder-decoder 모델(T5, BART)에서는 왼쪽 패딩을 사용하기도 합니다.

다섯 번째로, max_length 선택도 중요합니다. 512는 많은 태스크에 충분하지만, 긴 문서 요약이나 대화에는 1024-2048이 필요할 수 있습니다.

길이가 길수록 메모리 사용량이 제곱으로 증가합니다(attention matrix O(n²)). QLoRA의 메모리 제약을 고려하여 적절한 균형을 찾아야 합니다.

여러분이 이런 데이터 준비 파이프라인을 사용하면 고품질의 학습 데이터를 효율적으로 생성할 수 있습니다. 실무에서는 데이터의 5-10%로 먼저 실험하여 prompt template이 잘 작동하는지 확인하고, 전체 데이터로 확장합니다.

또한 학습 중간에 생성 품질을 샘플링하여, 데이터 형식 문제가 있는지 조기에 발견합니다.

실전 팁

💡 response 부분만 loss를 계산하려면 labels를 직접 구성하세요. instruction 부분의 labels를 -100으로 설정하면 해당 토큰은 loss 계산에서 제외됩니다. 이렇게 하면 모델이 response 생성에만 집중합니다.

💡 데이터 품질이 모델 성능보다 중요합니다. 10K 고품질 샘플이 100K 저품질 샘플보다 낫습니다. 중복 제거, 오타 수정, 길이 필터링 등 전처리에 시간을 투자하세요.

💡 streaming 데이터셋을 사용하면 메모리를 절약할 수 있습니다. load_dataset("...", streaming=True)를 사용하면 전체 데이터를 메모리에 로딩하지 않고 필요할 때만 가져옵니다. 특히 수백 GB 데이터셋에 유용합니다.

💡 데이터 밸런싱도 중요합니다. 특정 카테고리가 과대 대표되면 모델이 편향됩니다. 카테고리별 샘플 수를 확인하고, 필요시 오버샘플링/언더샘플링을 적용하세요.

💡 validation set을 반드시 분리하세요. dataset.train_test_split(test_size=0.1)로 10%를 떼어놓고, 학습 중 과적합을 모니터링합니다. validation loss가 증가하기 시작하면 early stopping을 고려하세요.


7. QLoRA 트레이닝 설정

시작하며

여러분이 모델과 데이터를 모두 준비했는데, 실제 학습을 시작하려니 TrainingArguments의 수많은 파라미터 중 무엇을 설정해야 할지 막막했던 적 있나요? 학습률, 배치 크기, 그래디언트 누적 등의 하이퍼파라미터를 잘못 설정하면 학습이 불안정하거나 매우 느려집니다.

이런 문제는 QLoRA의 특수한 요구사항 때문에 발생합니다. 일반 파인튜닝과 달리, QLoRA는 4비트 가중치와 16비트 그래디언트를 혼합하여 사용하므로, 학습률과 옵티마이저 설정이 달라야 합니다.

또한 제한된 메모리에서 효율성을 극대화하려면 배치 크기와 그래디언트 누적을 신중히 조정해야 합니다. 바로 이럴 때 필요한 것이 QLoRA에 최적화된 트레이닝 설정입니다.

SFTTrainer와 적절한 TrainingArguments를 사용하여 안정적이고 효율적인 학습을 진행할 수 있습니다.

개요

간단히 말해서, QLoRA 트레이닝 설정은 학습 동역학을 제어하는 하이퍼파라미터와 최적화 전략을 정의하여, 효율적이면서도 고품질의 파인튜닝을 가능하게 합니다. 트레이닝 설정이 중요한 이유는 같은 모델과 데이터라도 설정에 따라 결과가 크게 달라지기 때문입니다.

학습률이 너무 높으면 loss가 발산하고, 너무 낮으면 수렴이 느립니다. 배치 크기가 작으면 그래디언트 노이즈가 커지고, 너무 크면 메모리가 부족합니다.

예를 들어, QLoRA에서는 일반적으로 학습률 2e-4, 배치 크기 4-16, 그래디언트 누적 4-8 정도가 좋은 시작점입니다. 기존에는 Adam 옵티마이저를 사용했다면, QLoRA에서는 paged_adamw_32bit를 사용하여 메모리 스파이크를 방지할 수 있습니다.

또한 gradient checkpointing과 함께 사용하면 메모리 효율이 극대화됩니다. QLoRA 트레이닝 설정의 핵심 특징은 세 가지입니다: 1) SFTTrainer(Supervised Fine-Tuning Trainer)로 언어 모델 특화 학습 루프 제공, 2) 메모리 효율적인 paged optimizer와 bf16 mixed precision 사용, 3) 그래디언트 누적으로 효과적 배치 크기 증가.

이러한 설정들이 제한된 하드웨어에서도 대규모 모델 파인튜닝을 가능하게 합니다.

코드 예제

from transformers import TrainingArguments
from trl import SFTTrainer

# 학습 하이퍼파라미터 설정
training_args = TrainingArguments(
    output_dir="./qlora-llama2-7b",  # 체크포인트 저장 경로
    num_train_epochs=3,  # 전체 에폭 수
    per_device_train_batch_size=4,  # 디바이스당 배치 크기
    gradient_accumulation_steps=4,  # 그래디언트 누적 (effective batch = 4*4=16)
    learning_rate=2e-4,  # 학습률 (LoRA는 높은 lr 가능)
    lr_scheduler_type="cosine",  # learning rate 스케줄러
    warmup_ratio=0.03,  # 웜업 스텝 비율
    logging_steps=10,  # 로깅 주기
    save_strategy="steps",  # 저장 전략
    save_steps=100,  # 저장 주기
    evaluation_strategy="steps",  # 평가 전략
    eval_steps=100,  # 평가 주기
    bf16=True,  # BFloat16 mixed precision
    optim="paged_adamw_32bit",  # 메모리 효율적 옵티마이저
    gradient_checkpointing=True,  # 메모리 절약
    max_grad_norm=0.3,  # 그래디언트 클리핑
    group_by_length=True,  # 유사 길이끼리 배치 (효율성)
    report_to="tensorboard"  # 모니터링 도구
)

# SFTTrainer 초기화
trainer = SFTTrainer(
    model=model,
    train_dataset=tokenized_dataset,
    eval_dataset=eval_dataset,  # validation set
    peft_config=lora_config,  # LoRA 설정
    dataset_text_field="text",  # 텍스트 필드명 (토큰화 전 데이터 사용 시)
    max_seq_length=512,  # 최대 시퀀스 길이
    tokenizer=tokenizer,
    args=training_args
)

# 학습 시작
trainer.train()

# 모델 저장 (LoRA 어댑터만)
trainer.model.save_pretrained("./final-qlora-adapter")

설명

이것이 하는 일: QLoRA 트레이닝 설정은 학습 루프의 모든 측면을 제어하여, 제한된 메모리에서 최대 성능을 끌어내면서 모델을 태스크에 맞게 조정합니다. 첫 번째로, 배치 크기와 그래디언트 누적의 관계를 이해해야 합니다.

per_device_train_batch_size=4는 각 forward/backward pass에서 4개 샘플을 처리합니다. gradient_accumulation_steps=4는 4번의 pass 후에 옵티마이저 업데이트를 수행합니다.

따라서 effective batch size는 4×4=16입니다. 이 전략은 메모리를 절약하면서도(작은 배치), 안정적인 그래디언트(큰 effective 배치)를 얻게 해줍니다.

메모리가 충분하다면 per_device를 늘리고 accumulation을 줄이는 것이 학습 속도가 빠릅니다. 두 번째로, 학습률 설정이 중요합니다.

learning_rate=2e-4는 전체 파인튜닝(1e-5)보다 10-20배 높습니다. 왜냐하면 LoRA는 작은 파라미터만 학습하므로, 높은 학습률이 필요합니다.

너무 낮으면 수렴이 매우 느리거나 안 됩니다. lr_scheduler_type="cosine"은 학습률을 점진적으로 감소시켜 안정적인 수렴을 돕습니다.

warmup_ratio=0.03은 처음 3% 스텝 동안 학습률을 0에서 2e-4로 선형 증가시켜, 초기 불안정성을 방지합니다. 세 번째로, optim="paged_adamw_32bit"의 중요성을 살펴보겠습니다.

일반 AdamW는 각 파라미터마다 2개의 모멘텀 상태(32비트)를 유지하므로, 20M LoRA 파라미터에 대해 약 160MB가 필요합니다. paged_adamw는 자주 사용하지 않는 옵티마이저 상태를 CPU 메모리로 오프로드하여, GPU 메모리 피크를 줄입니다.

특히 메모리가 빠듯한 상황에서 OOM을 방지하는 핵심 기법입니다. 네 번째로, gradient_checkpointing=True는 메모리를 추가로 40-50% 절감합니다.

forward pass 중 중간 activation을 저장하지 않고 버리고, backward pass에서 필요할 때 재계산합니다. 시간은 약 20% 늘지만, 메모리 절약으로 더 큰 배치나 긴 시퀀스를 사용할 수 있어 전체적으로 이득입니다.

다섯 번째로, max_grad_norm=0.3은 그래디언트 클리핑으로 안정성을 높입니다. 가끔 매우 큰 그래디언트가 발생하여 loss가 급증하는 것을 방지합니다.

0.3-1.0이 일반적인 값입니다. 여섯 번째로, group_by_length=True는 학습 효율을 10-20% 높입니다.

비슷한 길이의 샘플들을 같은 배치로 묶어, 패딩을 최소화합니다. 패딩이 적으면 계산 낭비가 줄어듭니다.

여러분이 이런 설정을 사용하면 QLoRA 학습을 안정적으로 진행할 수 있습니다. 실무에서는 작은 subset(1K 샘플)으로 1-2 에폭 학습하여 loss가 정상적으로 감소하는지 확인한 후, 전체 데이터로 확장합니다.

또한 TensorBoard(tensorboard --logdir ./qlora-llama2-7b)로 학습 곡선을 모니터링하여 이상 징후를 조기에 발견합니다.

실전 팁

💡 학습률을 찾으려면 learning rate finder를 사용하세요. trainer.lr_find()는 여러 학습률로 실험하여 최적값을 제안합니다. 일반적으로 loss가 가장 빠르게 감소하는 지점의 1/10 정도가 좋습니다.

💡 멀티 GPU 학습에는 deepspeed 또는 fsdp 전략을 추가하세요. TrainingArguments에 deepspeed="ds_config.json"을 설정하면 모델과 옵티마이저 상태를 GPU 간에 분산하여 더 큰 모델을 학습할 수 있습니다.

💡 과적합을 방지하려면 weight decay를 추가하세요. weight_decay=0.01은 L2 정규화를 적용하여 모델이 훈련 데이터에 과도하게 맞춰지는 것을 방지합니다.

💡 evaluation 중 생성 품질을 확인하려면 커스텀 callback을 만드세요. 몇 개 샘플에 대해 실제 생성을 수행하고 출력을 로깅하면, validation loss 외에 질적 평가도 가능합니다.

💡 재개 학습(resume training)을 위해 resume_from_checkpoint 파라미터를 사용하세요. 학습 중 중단되어도 마지막 체크포인트부터 재시작할 수 있어 시간을 절약합니다.


8. 그래디언트 체크포인팅

시작하며

여러분이 QLoRA 학습 중 배치 크기를 늘리려고 할 때, 여전히 OOM 에러가 발생하는 경험을 해본 적 있나요? 양자화와 LoRA로 모델 크기는 줄였지만, activation 메모리가 병목이 되어 큰 배치나 긴 시퀀스를 처리하지 못합니다.

이런 문제는 forward pass 중 저장되는 중간 activation 때문에 발생합니다. 신경망 학습에서 backward pass를 위해 각 레이어의 출력을 메모리에 보관해야 하는데, 대규모 모델에서는 이 activation이 모델 자체보다 더 많은 메모리를 차지합니다.

예를 들어 7B 모델에서 배치 크기 4, 시퀀스 길이 512로 학습 시, 모델은 3.5GB지만 activation은 10-15GB를 차지할 수 있습니다. 바로 이럴 때 필요한 것이 그래디언트 체크포인팅(Gradient Checkpointing)입니다.

중간 activation을 저장하지 않고 필요시 재계산하여, 메모리를 40-50% 절감하면서 학습을 가능하게 합니다.

개요

간단히 말해서, 그래디언트 체크포인팅은 메모리와 계산 시간을 교환하는 기법으로, activation을 재계산하여 메모리 사용량을 크게 줄입니다. 그래디언트 체크포인팅이 필요한 이유는 activation 메모리가 배치 크기와 시퀀스 길이에 선형으로 증가하기 때문입니다.

체크포인팅 없이는 모든 레이어의 activation을 저장해야 하지만, 체크포인팅을 사용하면 일부 checkpoint만 저장하고 나머지는 버립니다. backward 시 checkpoint부터 forward를 재실행하여 필요한 activation을 복원합니다.

예를 들어, 32개 레이어 모델에서 4개 레이어마다 checkpoint를 두면, 저장해야 할 activation이 8배 줄어듭니다. 기존에는 모든 activation을 메모리에 유지했다면, 그래디언트 체크포인팅은 선택적으로 저장하고 재계산하여 메모리 효율을 극대화합니다.

이는 제한된 메모리에서 더 큰 모델이나 배치를 학습할 수 있게 해줍니다. 그래디언트 체크포인팅의 핵심 특징은 세 가지입니다: 1) activation 메모리를 40-50% 절감하여 더 큰 배치나 긴 시퀀스 사용 가능, 2) 약 20-30% 학습 시간 증가(재계산 오버헤드), 3) 투명하게 통합되어 코드 변경 최소화.

이러한 트레이드오프가 메모리 제약 환경에서 필수적입니다.

코드 예제

from transformers import TrainingArguments
from peft import prepare_model_for_kbit_training

# 1. 모델 준비 시 체크포인팅 활성화
model = prepare_model_for_kbit_training(
    model,
    use_gradient_checkpointing=True  # 그래디언트 체크포인팅 활성화
)

# 2. TrainingArguments에서도 명시적 활성화
training_args = TrainingArguments(
    output_dir="./output",
    gradient_checkpointing=True,  # 체크포인팅 활성화
    per_device_train_batch_size=8,  # 체크포인팅 덕분에 더 큰 배치 가능
    max_steps=1000,
    # ... 기타 설정
)

# 3. 메모리 사용량 비교
# 체크포인팅 OFF: 배치=4, 시퀀스=512 -> ~18GB
# 체크포인팅 ON:  배치=8, 시퀀스=512 -> ~15GB
# 결과: 2배 큰 배치를 더 적은 메모리로 처리

# 4. 커스텀 체크포인팅 (고급)
import torch.utils.checkpoint as checkpoint

def custom_forward_with_checkpointing(model, x):
    """레이어 그룹별 체크포인팅"""
    # 4개 레이어마다 checkpoint
    def create_custom_forward(module):
        def custom_forward(*inputs):
            return module(*inputs)
        return custom_forward

    # checkpoint로 래핑하여 실행
    return checkpoint.checkpoint(
        create_custom_forward(model.layers[0:4]),
        x,
        use_reentrant=False  # PyTorch 2.0+ 권장
    )

설명

이것이 하는 일: 그래디언트 체크포인팅은 forward pass의 중간 결과물을 선택적으로 저장하고, backward pass에서 필요한 부분만 재계산하여 메모리 사용량을 극적으로 줄입니다. 첫 번째로, 일반 학습의 메모리 사용을 이해해야 합니다.

Forward pass에서 입력 x₀가 레이어들을 거쳐 x₁, x₂, ..., x_n이 됩니다. Backward pass에서 각 레이어의 그래디언트를 계산하려면 해당 레이어의 입력 x_i가 필요합니다.

따라서 모든 x_i를 메모리에 보관해야 합니다. n=32 레이어, 각 activation이 500MB라면 총 16GB입니다.

두 번째로, 체크포인팅의 작동 원리를 살펴보겠습니다. 모든 x_i를 저장하는 대신, 일부(예: x₀, x₈, x₁₆, x₂₄, x₃₂)만 저장합니다.

Backward pass에서 x₁₀의 그래디언트를 계산해야 할 때, 가장 가까운 checkpoint인 x₈부터 레이어 9-10을 다시 실행하여 x₉, x₁₀을 복원합니다. 이렇게 하면 저장 공간은 1/8로 줄지만, forward를 부분적으로 재실행해야 하므로 시간이 증가합니다.

세 번째로, 메모리-시간 트레이드오프를 분석해보겠습니다. Checkpoint 간격이 클수록(예: 8 레이어) 메모리는 많이 절약되지만 재계산이 많아집니다.

간격이 작으면(예: 2 레이어) 재계산은 적지만 메모리 절약도 적습니다. 실험적으로 4-8 레이어 간격이 최적 균형점입니다.

전체 학습 시간은 20-30% 증가하지만, 메모리 절약으로 배치 크기를 2배 늘릴 수 있어 전체 처리량은 오히려 증가할 수 있습니다. 네 번째로, prepare_model_for_kbit_training의 체크포인팅 구현을 살펴보면, 이 함수는 모델의 각 Transformer 블록에 체크포인팅을 자동으로 적용합니다.

사용자는 단순히 플래그만 활성화하면 되고, 내부적으로 모든 처리가 투명하게 이루어집니다. PyTorch의 torch.utils.checkpoint.checkpoint() 함수를 래핑하여 사용합니다.

다섯 번째로, use_reentrant=False 설정이 중요합니다. PyTorch 2.0부터는 비재진입(non-reentrant) 체크포인팅이 권장됩니다.

이는 메모리 안전성이 높고, 복잡한 control flow(if문, 루프 등)에서도 잘 작동합니다. 구버전 PyTorch에서는 이 파라미터를 제거해야 할 수 있습니다.

여러분이 그래디언트 체크포인팅을 활성화하면 제한된 GPU 메모리로 더 효율적인 학습이 가능합니다. 실무에서는 먼저 체크포인팅 없이 최대 배치 크기를 찾고, 체크포인팅을 켜서 배치를 2배로 늘려봅니다.

배치가 클수록 그래디언트가 안정적이고 학습이 빠르므로, 시간 증가를 감안해도 이득인 경우가 많습니다. 특히 긴 시퀀스(1024+)를 사용하는 태스크에서 효과가 큽니다.

실전 팁

💡 체크포인팅은 항상 활성화하는 것이 좋습니다. 메모리 제약이 있는 환경에서는 필수이고, 여유가 있어도 더 큰 배치를 사용할 수 있어 이득입니다. 끄는 것은 추론(inference) 시에만 고려하세요.

💡 메모리 프로파일링으로 체크포인팅 효과를 측정하세요. torch.cuda.max_memory_allocated()로 피크 메모리를 비교하면 정확한 절감량을 알 수 있습니다.

💡 mixed precision(bf16/fp16)과 함께 사용하면 시너지 효과가 있습니다. Mixed precision은 activation 크기를 절반으로 줄이고, 체크포인팅은 저장할 activation 수를 줄여, 결합하면 메모리가 극적으로 감소합니다.

💡 DeepSpeed ZeRO-2/3와 함께 사용하면 더 큰 모델을 학습할 수 있습니다. ZeRO는 옵티마이저와 모델 상태를 분산하고, 체크포인팅은 activation을 줄여, 각각 다른 메모리 병목을 해결합니다.

💡 커스텀 모델 아키텍처를 사용한다면, checkpoint 위치를 수동으로 지정할 수 있습니다. 계산 비용이 큰 레이어(예: attention)는 체크포인트로, 가벼운 레이어(예: LayerNorm)는 저장으로 설정하여 최적화할 수 있습니다.


#AI#QLoRA#Quantization#FineTuning#PEFT

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.