파인튜닝과 PEFT

24
  • #AI
  • #Fine-tuning
  • #PEFT
  • #LoRA
  • #QLoRA

요즘 AI로 무언가를 만든다고 하면, 보통 GPT나 Llama 같은 이미 똑똑하게 학습된 모델을 가져다 쓰는 데서 출발한다. 모델을 처음부터 학습시키는 건 많은 리소스가 필요하기에 쉽게 할 수 있는 일이 아니기 때문이다. 그런데 이런 범용 모델을 그대로 쓰는 것만으로는 부족할 때가 있다. 만들려는 서비스에는 모델의 말투나 출력 형식이 안 맞을 수도 있고, 정작 필요한 도메인 지식을 모델이 모를 수도 있다. 그렇다면 이미 학습된 모델을 어떻게 내 서비스에 맞게 변경할 수 있을까?

방법은 여러 가지지만, 이번 글에서는 사전학습된 모델을 특정 작업이나 도메인에 맞게 추가로 학습시키는 파인튜닝(fine-tuning), 그중에서도 메모리 부담을 줄여주는 PEFT(Parameter-Efficient Fine-Tuning)를 정리해보려고 한다.

파인튜닝이란?

AI 모델은 거대한 숫자 행렬 여러 개로 이루어져 있다. 이 숫자 하나하나가 가중치(weight)이고, 여기에 모델의 "지식"이 담겨 있다. 파인튜닝은 사전학습된 모델을 가져와, 내 목적에 맞게 이 가중치를 조금 더 학습시키는 것이다. 가중치를 실제로 건드리기 때문에, 파인튜닝은 모델의 행동, 즉 말투·출력 형식·특정 작업을 푸는 방식을 바꾼다.

  • 사전학습(pretraining) — 인터넷 규모의 방대한 텍스트로 언어의 일반적인 패턴을 백지에서부터 가르치는 단계. GPT·Llama·Qwen 같은 원본 모델이 여기서 나온다. 수천 장의 GPU와 막대한 비용이 들어, 직접 하긴 어렵다.
  • 파인튜닝(fine-tuning) — 사전학습으로 만들어진 원본 모델을 가져와 훨씬 작은 데이터로 특정 작업·도메인·말투에 맞게 가중치를 마저 조정하는 단계.
사전학습은 백지 모델에서, 파인튜닝은 이미 언어를 아는 원본 모델에서 출발한다
사전학습 vs 파인튜닝 — 출발점이 다르다

다행히 원본 모델이 이미 언어의 대부분을 알고 있어서, 적은 데이터로도 원하는 방향으로 모델을 끌고 갈 수 있다.

파인튜닝의 종류

모델을 가져다 쓸 때, 가중치를 얼마나 학습시키느냐에 따라 방법이 나뉜다. 거의 안 건드리는 쪽부터 전부 다 바꾸는 쪽까지 크게 세 가지다.

풀 파인튜닝 (full fine-tuning) — 모델 전체 가중치를 업데이트하는, 가장 직관적인 방식이다. 본체부터 출력까지 모든 layer를 내 데이터에 맞춰 다시 학습시킨다. 표현력이 가장 크지만, 그만큼 학습에 드는 메모리와 저장 비용이 커서 리소스가 충분하지 않으면 어렵다.

모델의 모든 layer를 내 데이터로 다시 학습한다
풀 파인튜닝 — 전체 가중치 업데이트

특성추출 (feature extraction) — 반대로, 사전학습 모델 전체를 동결(freeze)시키고 그 위에 내 작업용 출력층(head) 하나만 새로 붙여서 그것만 학습한다. 무거운 본체는 그대로 두고 가벼운 머리만 갈아 끼운다.

사전학습 본체는 동결하고, 새로 붙인 head만 학습한다
특성추출 — 본체는 동결하고 head만 학습

본체는 입력을 "의미를 담은 숫자 벡터"로 바꿔 주는 고정된 특성 추출기로만 쓰고, head가 그 벡터를 내가 원하는 답(예: 긍정/부정)으로 변환한다. 학습할 게 head 하나뿐이라 가볍고 빠르지만, 본체를 안 건드리니 본체가 내 도메인과 너무 다르면 한계가 있다.

엄밀히 말하면 특성추출은 사전학습 가중치를 전혀 건드리지 않으니, 가중치를 조정한다는 의미의 파인튜닝이라 부르긴 어렵다. (사실 파인튜닝도 특성추출도, 사전학습으로 쌓은 지식을 내 작업에 가져다 쓰는 전이학습(transfer learning)의 갈래다.) 게다가 이 방식은 분류처럼 새 head를 붙이는 작업에 어울려서, 우리가 다룰 생성형 모델과는 결이 조금 다르다.

PEFT (Parameter-Efficient Fine-Tuning) — 두 방식의 장점을 절충한, 요즘 가장 널리 쓰이는 접근이다. 본체는 특성추출처럼 대부분 동결해 두되, 그 안에 새로 끼워 넣은 아주 작은 파라미터(어댑터)만 학습한다. 풀 파인튜닝처럼 모델 내부를 건드려 행동을 바꾸면서도, 학습하는 양은 전체의 1%도 안 되는 경우가 많다.

본체는 동결하고, 끼워 넣은 작은 어댑터만 학습한다
PEFT — 본체는 동결하고 작은 어댑터만 학습

세 가지를 한 축에 늘어놓으면 "가중치를 얼마나 학습시키느냐"에 따른 여러 단계가 된다.

특성추출(≈0%) → PEFT(약 0.x%) → 풀 파인튜닝(100%)
파인튜닝의 종류 — 얼마나 건드리나

PEFT

앞서 언급했듯 풀 파인튜닝은 리소스가 많이 들어간다. 그럼 어떤 리소스가 얼마나 드는지 먼저 짚어보고, PEFT는 이걸 어떻게 해결하는지 알아보자.

풀 파인튜닝은 왜 부담스러운가

파라미터(parameter)는 가중치와 같은 말이다 — 모델 안에서 학습되는 숫자들. 모델 이름에 붙는 "7B", "8B" 같은 숫자가 곧 이 가중치의 개수를 의미한다. Qwen2.5 7B는 약 70억 개, Llama 3 8B는 약 80억 개, 큰 것은 Llama 3 70B처럼 700억 개나 된다. 그리고 파인튜닝이 바꾸는 건 결국 이 가중치다.

문제는 가중치를 "어떻게 바꿀지" 계산하는 동안, 가중치마다 딸려 다니는 임시 데이터가 메모리를 잔뜩 차지한다는 점이다.

  • 기울기(gradient) — 가중치 하나를 어느 쪽으로 움직일지 알려주는 값. 가중치마다 하나씩.
  • 옵티마이저 상태 — 그 움직임을 부드럽게 조절하려고 들고 다니는 보조 값. Adam은 가중치마다 두 개씩.

이 둘은 가중치(파라미터)가 아니라, 가중치를 고치려고 잠깐 만들어둔 작업용 데이터다. 학습이 끝나면 버려지고 완성된 모델엔 안 남지만, 학습하는 동안에는 자리를 차지한다.

그래서 가중치가 70억 개면 기울기도 70억 개, 옵티마이저 상태는 그 두 배인 140억 개가 함께 올라간다. 여기에 정밀한 계산용 가중치 사본까지 더하면, 가중치 하나당 다 합쳐 대략 16byte다 — 정작 가중치 자체는 그중 2byte뿐이고 나머지는 전부 학습용 임시 데이터다.

params = 7_000_000_000     # 가중치(파라미터) 70억 개
bytes_per_param = 16       # 가중치 + 기울기 + 옵티마이저 상태 등, 1개당 약 16byte
 
gb = params * bytes_per_param / 1e9
print(gb)                  # 112.0

모델을 그냥 쓰기만 하면 가중치만 있으면 되니 7B 모델이 약 14GB지만, 학습시키려면 딸린 데이터까지 합쳐 그 8배인 약 112GB가 든다. (긴 입력을 처리하며 쌓이는 중간 계산값은 뺀, 가중치에 딸린 메모리만 센 값이다.) 아직까지 우리가 흔히 쓰는 GPU는 보통 한 장에 16–24GB이니 한참 모자란다.

풀 파인튜닝 메모리 — 가중치보다 학습에 딸려 오는 데이터가 더 크다
풀 파인튜닝 메모리 — 파라미터당 약 16byte

PEFT의 발상 — 작은 부품만 학습한다

PEFT의 발상은 단순하다. 무거운 본체는 동결시켜두고(안 배우고), 새로 끼운 작은 부품만 학습한다. 학습하는 파라미터가 전체의 1%도 안 되니, 거기 딸려 오는 기울기·옵티마이저 데이터도 그만큼만 덜어진다.

풀 파인튜닝은 본체 전체에 기울기·옵티마이저 데이터가 붙지만, PEFT는 작은 어댑터에만 붙어 메모리가 확 줄어든다
PEFT 메모리 절감 — 풀 파인튜닝 vs PEFT

이때 새로 끼우는 작은 부품을 어댑터(adapter)라고 부른다. 두꺼운 책은 그대로 두고 필요한 곳에 포스트잇만 붙이는 것과 같다 — 원래 지식은 안 건드리고, 작은 추가분만 새로 만든다. 그리고 이 어댑터를 만드는 방법 중 현재 가장 많이 쓰이는 것이 LoRA(Low-Rank Adaptation)다.

LoRA — 큰 행렬을 작은 행렬 두 개로

LoRA에서 어댑터는 원본 행렬 위에 더해지는 별도의 가중치다. 개수가 원본의 1%도 안 될 만큼 적은데, 학습이 끝나면 모델은 '원본 가중치 + 새로 학습한 어댑터'로 출력을 만든다. 그렇다면 어떻게 이렇게 적은 가중치만 더하고도 모델을 원하는 대로 바꿀 수 있을까?

모델의 가중치는 숫자가 가득 찬 거대한 행렬(matrix)이다. 가로·세로로 줄지어 늘어선 숫자 표라고 보면 된다. 흔한 가중치 행렬은 4096×4096 크기여서, 칸(숫자)이 약 1,677만 개나 된다. 풀 파인튜닝은 이 행렬을 통째로 다시 학습하기 때문에, 원본만큼 큰 양을 전부 새로 배워야 해서 부담이 크다.

LoRA는 원본 행렬을 다시 학습하지 않는다. 그대로 동결해 두고, 원본보다 훨씬 작은 행렬 두 개로 이뤄진 어댑터만 새로 붙여 학습한다.

어떻게 그게 가능한지는 구구단표를 떠올리면 쉽다. 아래 3×3 행렬은 칸이 9개지만, 세로 줄 [1, 2, 3]과 가로 줄 [10, 20, 30]만 있으면 전부 채워진다. 각 칸은 '세로 값 × 가로 값'일 뿐이다. 9개를 일일이 저장할 것 없이 세로 3개 + 가로 3개, 숫자 6개만 있으면 된다.

          10   20   30     ← 가로 줄
   1  │   10   20   30
   2  │   20   40   60
   3  │   30   60   90
   ↑
   세로 줄

행렬이 클수록 이 효과는 더 커진다. 4096×4096 행렬(약 1,677만 칸)도 길이 4096짜리 세로 줄 하나와 가로 줄 하나, 곧 숫자 8,192개로 만들 수 있다.

4096×4096 행렬도 세로 줄 하나와 가로 줄 하나의 곱으로 — 숫자 8,192개면 복원된다
LoRA — 큰 행렬을 세로 줄 하나 × 가로 줄 하나로

다만 줄을 한 쌍만 쓰면 담을 수 있는 변화가 너무 단순하다. 그래서 실제로는 이런 세로·가로 줄을 여러 쌍 겹쳐 쓰는데, 이 겹친 쌍의 개수가 바로 r(랭크)이다. 세로 줄을 r개 옆으로 붙이면 작은 행렬 하나(크기 4096×r), 가로 줄을 r개 붙이면 또 하나(크기 r×4096)가 된다. 이 두 행렬이 곧 어댑터이고, 둘을 곱하면 원본에 더할 큰 행렬이 만들어진다. r이 클수록 더 복잡한 변화까지 담지만 학습할 숫자도 그만큼 늘어나니, 보통 8이나 16처럼 작게 잡는다.

줄을 r쌍 겹쳐 작은 행렬 두 개(4096×r, r×4096)로 만든다 — r은 겹친 줄 쌍의 개수(랭크)
LoRA — 줄을 r쌍 겹쳐 작은 행렬 두 개로

전체 가중치를 통째로 다시 학습하는 풀 파인튜닝에 비해, LoRA는 얼마나 적게 학습하면 되는지 숫자로 보자:

d, k, r = 4096, 4096, 8
 
full = d * k          # 더할 행렬 통째로 — 약 1,677만 개
lora = r * (d + k)    # 작은 행렬 두 개만 — 약 6.5만 개
 
print(full)           # 16777216
print(lora)           # 65536
print(lora / full)    # 0.00390625  →  약 0.4%

전체의 0.4%만 학습하는 셈이다. 이렇게 만든 행렬은 가능한 모든 변화를 담지는 못하지만, 그런데도 풀 파인튜닝에 버금가는 결과를 낸다. 파인튜닝이 본래 없던 능력을 새로 심기보다, 이미 가진 능력 중 필요한 쪽을 끌어올리는 일에 가깝기 때문이다. 더할 행렬이 원래 단순해서 r이 작아도 잘 통하고, 어떤 작업에서는 r=1까지 낮춰도 된다.

학습을 시작할 땐 두 행렬 중 하나를 0으로 둬, 첫 순간의 변화를 0으로 맞춘다. 원본을 흔들지 않고 출발해야 안정적으로 학습되기 때문이다.

어댑터를 어디에 붙이는지도 알아보자. 모델은 layer를 여러 겹 쌓은 것이고, 한 layer 안에도 가중치 행렬이 여럿이다. 어텐션의 q·k·v·o, 그리고 MLP 등이다. 어댑터는 각 layer의 행렬마다 따로 붙는데, 전부에 붙일 필요는 없다. 예산이 정해져 있으면 한 행렬에 몰아주기보다 여러 행렬(보통 q·v)에 작게 나눠 붙이는 편이 대체로 더 효과적이다.

한 layer 속 여러 가중치 행렬 중 고른 행렬(q·v)에만 어댑터가 붙는다
PEFT — layer 속 고른 가중치 행렬에만 어댑터

LoRA의 장점은 분명하다.

  • 학습이 가볍다 — 학습하는 가중치가 전체의 1%도 안 되니 메모리가 가볍다.
  • 저장이 가볍다 — 태스크마다 통째로 저장할 필요 없이 어댑터(수 MB)만 남기면 된다. 원본 모델은 공유하고 어댑터만 갈아 끼운다.
  • 추론이 느려지지 않는다 — 배포할 땐 작은 행렬 두 개를 원본에 미리 더해 합치면, 크기도 속도도 원본과 똑같다.

대신 한계도 있다.

  • 표현력에 한계가 있다 — 작은 행렬 두 개로는 모든 변화를 담지 못해, 원본과 결이 많이 다른 작업에서는 풀 파인튜닝에 못 미칠 수 있다.
  • r과 붙일 위치를 골라야 한다 — 너무 작으면 부족하고 키우면 이점이 줄어, 적당한 값을 찾는 노력이 필요하다.
  • 원본 모델은 여전히 통째로 메모리에 올라간다 — 줄어든 건 학습하는 양일 뿐, 동결한 원본 모델(7B면 14GB)은 학습 내내 그대로 자리를 차지한다.

QLoRA — LoRA에 양자화를 더하기

QLoRA(Quantized Low-Rank Adaptation)는 LoRA 다음에 나온 방식으로, 무거운 원본 모델이 차지하는 메모리를 줄인다. 이름 앞에 붙은 'Q', 곧 양자화(quantization)로 원본 모델의 크기를 줄이는 것이다.

양자화는 원본 모델의 가중치를 원래보다 더 적은 bit로 저장하는 것이다. 가중치는 결국 0.0731, -0.4412 같은 소수인데, 보통 하나를 16bit(2byte)로 저장한다. 16bit면 한 숫자를 약 6만 5천 단계로 촘촘하게 표현하지만, 4bit(0.5byte)로 줄이면 쓸 수 있는 값이 16단계뿐이다. 그래서 각 가중치를 그중 가장 가까운 값으로 반올림한다(가령 0.07310.08로 — 이해를 돕기 위한 예시 값이다). 한 숫자가 차지하는 용량이 16bit에서 4bit로, 1/4로 준다.

bit 한 자리는 0/1, 자리가 늘수록 경우의 수가 2배 — 4bit는 16가지, 16bit는 65,536가지
bit 수가 표현할 수 있는 값의 개수를 정한다

여기서 중요한 건 4bit는 저장 형식일 뿐, 계산까지 4bit로 하는 게 아니다. 입력에 가중치를 곱할 땐 쓰는 가중치만 잠깐 16bit로 되돌려 곱하고 버린다. 핵심은 모델 전체를 한꺼번에 펴지 않는다는 것이다. 70억 개는 4bit로 누운 채, 그때그때 쓰는 한 줌만 16bit로 떴다 사라진다. 압축한 책을 책장에 꽂아두고 읽을 페이지만 펴 보는 셈이라, 메모리에 상주하는 모델은 압축된 크기 그대로다.

모델 전체는 4bit로 상주하고, 곱셈할 차례가 된 한 칸만 잠깐 16bit로 펴서 쓰고 버린다
4bit는 저장 형식 — 계산할 땐 한 칸만 16bit로 편다

다만 모델의 모든 부분이 4bit인 건 아니다. 원본 모델과 어댑터는 쓰는 bit 수가 다르다.

  • 원본 모델 — 4bit로 압축해 동결. 읽기만 하니 bit를 줄여도 된다.
  • LoRA 어댑터 — 16bit 그대로 학습.

차이는 값이 바뀌느냐다. 원본 모델은 계산에 값을 꺼내 곱할 뿐 값 자체는 그대로라 4bit로 저장해도 된다. 반면 어댑터는 학습 내내 값이 조금씩(가령 +0.0003씩) 갱신된다. 갱신한 값을 4bit로 도로 저장하면 그 작은 변화가 반올림에 묻혀 사라지고, 값이 제자리에 묶여 학습이 나아가지 못한다. 그래서 갱신이 쌓이는 학습 중에는 16bit를 유지해야 한다.

학습이 끝난 어댑터도 4bit로 압축할 수는 있다. 다만 어댑터가 차지하는 메모리가 전체의 1%도 안 되는 경우가 많아, 굳이 압축하지 않고 16bit 그대로 두는 것이 일반적이다.

16bit 원본 모델을 4bit로 압축해 동결하고, 그 위에 LoRA 어댑터만 학습한다
QLoRA — 4bit로 압축한 원본 모델 + LoRA 어댑터

원본 모델이 차지하는 메모리가 얼마나 줄어드는지 숫자로 보면:

params = 7_000_000_000           # 가중치 70억 개
 
fp16_gb = params * 2   / 1e9     # 16bit(2byte)로 저장 — 약 14GB
int4_gb = params * 0.5 / 1e9     # 4bit(0.5byte)로 저장 — 약 3.5GB
 
print(fp16_gb)                   # 14.0
print(int4_gb)                   # 3.5

14GB가 약 3.5GB로 떨어진다. 여기에 LoRA의 학습 메모리 절감까지 더해지면, 24GB짜리 소비자용 GPU 한 장에서 7B 모델 파인튜닝이 가능해진다. 풀 파인튜닝이었다면 같은 7B에 112GB가 필요했다.

다만 그냥 4bit로 줄이기만 하면 곳곳에서 문제가 생긴다. QLoRA는 세 가지 기법으로 이를 메운다 — 반올림 오차를 줄이고(①), 압축에 딸려오는 부가 메모리를 줄이고(②), 학습 중 메모리 폭증을 막는다(③).

① NF4(NormalFloat 4-bit) — 쓸 수 있는 값을 0 근처에 몰아준다. 4bit로 쓸 수 있는 16개의 값이 정확히 어떤 숫자인지는 고정돼 있지 않다. 각 번호가 어떤 실제 값을 뜻하는지는 미리 정해 둔 표(코드북)가 정하고, 양자화는 가중치를 가장 가까운 값으로 반올림해 그 번호(4bit)만 저장한다. 읽을 땐 번호로 표를 찾아 값을 되살린다.

NF4 코드북 — 4bit 번호마다 실제 값을 정해 둔 표. 0 근처는 촘촘, 양 끝은 듬성
NF4 코드북 — 번호마다 실제 값을 정해 둔 표

그럼 표의 16개 값은 어떻게 정할까? 정규분포를 '넓이'로 16등분하는 게 핵심이다. 모델 가중치는 대부분 0.03, -0.05처럼 0에 몰린 종 모양 분포를 이룬다. 곡선 아래 면적을 똑같은 16조각으로 자르고, 각 조각을 대표하는 값(조각의 가운데) 하나씩을 표에 적는다. 값이 빽빽한 0 근처는 좁은 폭에 한 조각이 차서 칸이 촘촘해지고, 값이 드문 양 끝은 넓은 폭이 한 조각으로 묶여 듬성해진다. 표가 가중치 분포에 저절로 맞춰지는 셈이라, 이름의 'Normal'도 정규분포(normal distribution)에서 왔다.

정규분포 아래 면적을 똑같은 16조각으로 자르면, 0 근처는 촘촘 양 끝은 듬성한 값이 나온다
NF4 표 만들기 — 넓이로 16등분

같은 가중치 0.03을 반올림해 보면 차이가 분명하다. (실제 코드북 값이 아니라, 이해를 돕기 위해 고른 예시 숫자다.)

  • 고르게 편 표 — 가장 가까운 값이 멀찍한 0 → 오차 0.03
  • NF4 표 — 바로 옆이 0.04 → 오차 0.01 (1/3로)

NF4의 16개 값은 모델마다 새로 계산하지 않는다. QLoRA 연구진이 한 번 정해 둔 고정 상수로, 어느 모델에든 양자화 라이브러리 bitsandbytes에서 정한 같은 표를 그대로 가져다 쓴다.

② 이중 양자화 — 양자화에 딸린 숫자까지 양자화한다. NF4 표가 아는 값은 -1~1뿐인데, 실제 가중치는 묶음마다 크기가 제각각이다. 그래서 가중치를 64개씩 묶고, 묶음마다 그 크기를 적어두는 기준 숫자를 하나씩 둔다. 표의 값에 기준 숫자를 곱하면 원래 크기로 되돌아간다. 4bit 번호가 16bit 값으로 펼쳐지는 게 바로 곱셈 한 번이다.

문제는 기준 숫자가 정밀해야 해서 32bit인데, 묶음마다 하나씩이라 7B 모델이면 1억 개가 넘는다. 다 모으면 수백 MB다. 이중 양자화는 이 기준 숫자들마저 8bit로 한 번 더 양자화해 용량을 1/4로 줄인다. 절감 폭이 크진 않지만, 품질을 거의 해치지 않으면서 메모리를 더 아낄 수 있다.

묶음마다 딸린 기준 숫자(32bit)를 8bit로 한 번 더 양자화해 1/4로 줄인다
이중 양자화 — 기준 숫자까지 한 번 더 줄인다

③ 페이지 옵티마이저 — 메모리가 넘치면 CPU로 잠깐 대피. 학습 중 메모리 사용량은 일정하지 않다. 긴 입력이나 특정 단계에서 순간적으로 확 치솟을 때가 있는데, 평소엔 GPU에 딱 맞아도 이 순간 넘치면 학습이 멈춘다(OOM, Out Of Memory). 페이지 옵티마이저는 넘치는 부분을 CPU 메모리로 잠깐 옮겼다가 고비가 지나면 다시 가져온다. 운영체제가 램이 모자랄 때 디스크를 빌려 쓰는(가상 메모리) 것과 같은 요령이라, 잠깐의 스파이크로 학습이 죽는 걸 막는다. 사실 이건 QLoRA 전용이라기보다 범용 기법이지만, GPU 한 장의 메모리 한계까지 밀어붙이는 QLoRA에서 특히 효과가 커서, QLoRA의 기법 중 하나로 함께 다뤄진다.

학습 중 GPU 메모리가 넘치면, 넘치는 부분만 CPU 메모리로 잠깐 옮겨 OOM을 막는다
페이지 옵티마이저 — 넘치면 CPU로 잠깐 대피

QLoRA의 장점은 분명하다.

  • 메모리가 가장 적게 든다 — 원본 모델까지 4bit로 압축해, LoRA보다도 적은 메모리로 돌아간다. 24GB 한 장으로 7B는 물론, 48GB 한 장이면 65B급 모델까지 파인튜닝할 수 있다.
  • 품질은 거의 그대로 — 16bit 풀 파인튜닝에 맞먹는 결과를 낸다.

대신 한계도 있다.

  • 손실이 완전 0은 아니다 — 양자화는 값을 반올림하는 손실 압축이라, 약간의 품질 저하 가능성은 남는다.
  • 계산이 조금 느려진다 — 가중치를 쓸 때마다 4bit를 16bit로 되돌리는 계산이 더 붙어서, 그만큼 학습·추론 시간이 늘어난다.

미니 실험 — LoRA vs QLoRA

아래 실습은 Colab 노트북에서 직접 실행해 볼 수 있다.

지금까지 정리한 내용을 실제 코드로 확인해 보자. 같은 설정으로 두 번 학습한다 — 16bit 원본에 어댑터를 붙인 LoRA, 4bit로 압축한 원본에 붙인 QLoRA. 이렇게 다음 세 가지를 직접 실험해 본다.

  • 파인튜닝 효과 — 프롬프트만으로는 못 풀던 작업을 학습 뒤엔 풀게 되는지, 정답률 숫자로 확인한다.
  • LoRA의 장점 — 학습하는 파라미터와 저장 파일이 얼마나 작은지
  • QLoRA의 장단점 — 메모리는 얼마나 줄고 시간은 얼마나 늘어나는지

이 실험에서는 뉴스 기사를 World / Sports / Business / Sci/Tech 네 분야로 분류한다. 정답이 있고 채점할 수 있어서 학습 전후를 정답률로 비교할 수 있다. 모델은 사전학습만 된 Qwen/Qwen2.5-0.5B(16bit로 약 1GB), 데이터는 분류 데이터셋 fancyzhx/ag_news에서 1,000건만 쓴다.

① 공통 준비 — 데이터와 어댑터 설정

지시와 출력을 ### Instruction / ### Response 형식으로 잇고 출력 자리에 정답 라벨을 넣는다. 채점용 테스트 50건은 학습에 쓰지 않는 별도 테스트 split에서 가져온다.

import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer
 
MODEL = "Qwen/Qwen2.5-0.5B"
tokenizer = AutoTokenizer.from_pretrained(MODEL)
 
LABELS = ["World", "Sports", "Business", "Sci/Tech"]
TEMPLATE = (
    "### Instruction:\n"
    "Classify the news into one of: World, Sports, Business, Sci/Tech.\n\n"
    "{}\n\n"
    "### Response:\n{}"
)
 
# 학습용 1,000건 — 지시·기사·정답 라벨을 한 문자열로 합치고 끝에 EOS를 붙인다
raw = load_dataset("fancyzhx/ag_news", split="train").shuffle(seed=42).select(range(1000))
data = raw.map(
    lambda r: {"text": TEMPLATE.format(r["text"], LABELS[r["label"]]) + tokenizer.eos_token},
    remove_columns=raw.column_names,
)
 
# 정답률 측정용 테스트 50건 (학습에는 쓰지 않음)
test = load_dataset("fancyzhx/ag_news", split="test").shuffle(seed=42).select(range(50))
 
# 어댑터 설정 — LoRA와 QLoRA가 공유한다
lora = LoraConfig(
    r=16, lora_alpha=32,
    target_modules="all-linear",   # 빠른 실험을 위해 모든 선형층에 부착
    lora_dropout=0.05, task_type="CAUSAL_LM",
)

적은 스텝으로도 변화가 또렷이 보이도록 어댑터를 모든 선형층에 붙였지만(all-linear), 그래도 학습하는 양은 전체의 1.7%뿐이다.

② 학습 함수 — 양자화 여부를 인자로 받는 공통 함수

코드에서 둘의 차이는 원본 모델을 4bit로 로드하느냐뿐이라, 양자화 여부만 인자로 받는 함수 하나로 두 실험을 다 돌린다.

def train(quantize):
    # QLoRA의 'Q' — 원본을 4bit로 압축해서 로드 (LoRA면 None)
    bnb = BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",               # ① NF4
        bnb_4bit_use_double_quant=True,          # ② 이중 양자화
        bnb_4bit_compute_dtype=torch.float16,    # T4는 bf16 미지원이라 fp16
    ) if quantize else None
 
    model = AutoModelForCausalLM.from_pretrained(
        MODEL, quantization_config=bnb, dtype=torch.float16, device_map="auto")
    print(f"상주 메모리: {model.get_memory_footprint() / 1e9:.1f} GB")
 
    torch.cuda.reset_peak_memory_stats()
    trainer = SFTTrainer(
        model=model, train_dataset=data, peft_config=lora,
        args=SFTConfig(
            max_steps=100, per_device_train_batch_size=4,
            learning_rate=2e-4, max_length=256, packing=True,
            fp16=True, warmup_steps=10, logging_steps=10,
        ),
    )
 
    # 4bit 로드 시 학습 파라미터(어댑터)가 bf16으로 만들어지는데 T4는 bf16 미지원이다.
    # fp16 AMP는 "어댑터=fp32 마스터, 연산만 fp16"을 전제하므로 어댑터를 fp32로 올린다.
    if quantize:
        for p in trainer.model.parameters():
            if p.requires_grad:
                p.data = p.data.to(torch.float32)
 
    trainer.model.print_trainable_parameters()
    runtime = trainer.train().metrics["train_runtime"]
    print(f"피크 메모리: {torch.cuda.max_memory_allocated() / 1e9:.1f} GB")
    print(f"학습 시간: {runtime / 60:.1f}분")
    trainer.model.eval()   # 학습이 끝났으니 dropout을 끄고 생성 모드로
    return trainer

fp16=True는 T4에서 학습을 안정시키는 설정, packing=True는 짧은 예제를 이어 붙여 학습을 빠르게 하는 설정이다. 본문에서 다룬 페이지 옵티마이저는 0.5B엔 메모리 여유가 충분해 쓰지 않았다.

③ LoRA — 파인튜닝 전후 정답률 비교

trainer = train(quantize=False)
# 상주 메모리: 1.0 GB
# trainable params: 8,798,208 || all params: 502,830,976 || trainable%: 1.7497

학습한 가중치는 전체의 1.7%뿐이다(LoRA 장점). 대신 동결한 16bit 원본 1GB는 학습 내내 통째로 메모리에 올라가 있다(LoRA 한계).

이제 테스트 50건을 어댑터를 끄고(disable_adapter, 베이스) / 켜고(학습본) 풀려서 정답률을 측정한다.

def classify(model, text):
    prompt = TEMPLATE.format(text, "")                 # "### Response:\n"까지만
    ids = tokenizer(prompt, return_tensors="pt").to(model.device)
    out = model.generate(**ids, max_new_tokens=8)
    gen = tokenizer.decode(out[0][ids.input_ids.shape[-1]:], skip_special_tokens=True)
    pos = {l: gen.find(l) for l in LABELS if l in gen}   # 가장 먼저 등장한 라벨 = 예측
    return min(pos, key=pos.get) if pos else None
 
def accuracy(model):
    correct = sum(classify(model, r["text"]) == LABELS[r["label"]] for r in test)
    print(f"정답률: {correct}/{len(test)} = {correct/len(test):.0%}")
 
with trainer.model.disable_adapter():
    accuracy(trainer.model)   # 학습 전 (베이스)
accuracy(trainer.model)       # 학습 후 (LoRA)
[before · 베이스]   정답률: 7/50 = 14%
[after  · LoRA]    정답률: 40/50 = 80%

정답률 차이가 어디서 오는지는, 같은 기사를 베이스와 학습본에 각각 풀려서 출력을 나란히 보면 분명하다.

def generate(model, text):
    prompt = TEMPLATE.format(text, "")
    ids = tokenizer(prompt, return_tensors="pt").to(model.device)
    out = model.generate(**ids, max_new_tokens=8)
    return tokenizer.decode(out[0][ids.input_ids.shape[-1]:], skip_special_tokens=True).strip()
 
for r in test.select(range(3)):
    with trainer.model.disable_adapter():
        base = generate(trainer.model, r["text"])   # 베이스
    lora = generate(trainer.model, r["text"])        # 학습본
    print(f"[정답 {LABELS[r['label']]}] {r['text'][:50]}...")
    print(f"  베이스: {base!r}")
    print(f"  LoRA : {lora!r}")
[정답 Sports] Indian board plans own telecast of Australia series...
  베이스: 'The Indian cricket board has decided to broadcast'
  LoRA : 'Sports'
[정답 Business] Stocks Higher on Drop in Jobless Claims A sharp dr...
  베이스: 'The news is that the stock market is'
  LoRA : 'Business'
[정답 Sports] Nuggets 112, Raptors 106 Carmelo Anthony scored 3...
  베이스: 'The news is about a basketball game between'
  LoRA : 'Sports'

라벨 목록까지 줬는데도 베이스는 분류는커녕 뉴스 문장을 그대로 이어 쓰고, 학습본은 라벨 하나로 정확히 답한다. 프롬프트만으로는 제대로 된 출력을 못 만들던 모델이 작은 학습으로 쓸 만한 분류 모델이 된 것이다.

저장은 어댑터만 하면 되고(LoRA 장점), 다음 실험을 위해 GPU 메모리를 비워 둔다.

trainer.save_model("adapter-lora")   # 어댑터만 수십 MB — 1GB짜리 원본은 저장할 필요 없다
 
del trainer
import gc; gc.collect()
torch.cuda.empty_cache()

④ QLoRA — 메모리 절감 확인

trainer = train(quantize=True)
accuracy(trainer.model)   # LoRA 학습본과 비슷한 정답률

상주 메모리가 LoRA보다 줄어든다. 줄어든 폭과 학습 시간·정답률은 아래 표에서 LoRA와 나란히 정리한다.

⑤ 결과 정리

LoRA (16bit 원본)QLoRA (4bit 원본)
상주 메모리1.0GB0.5GB
학습 피크 메모리3.5GB3.0GB
학습 시간0.9분1.0분
학습한 가중치전체의 1.7%전체의 1.7%
분류 정답률 (학습 전 14%)80%약 80%

전체의 1.7%만 학습해서 정답률이 14% → 80%로 올랐다.

  • LoRA — 학습·저장 대상이 작다. 어댑터만 수십 MB로 저장한다.
  • QLoRA — 원본을 4bit로 눌러 상주 메모리를 절반(1.0 → 0.5GB)으로 줄였다. 대신 압축·복원 오버헤드로 시간이 조금 더 걸렸고(0.9 → 1.0분), 정답률은 LoRA와 대략 비슷하게 유지됐다.

마무리

지금까지 파인튜닝, 그중에서도 PEFT에 대해 정리해 봤다. 그럼 이런 파인튜닝 방법은 실무에서 과연 언제 사용하면 좋을까?

앞서 봤듯 파인튜닝을 통해 바뀌는 건 베이스 모델의 지식이 아니라 행동이다. 그래서 말투나 페르소나를 일관되게 유지하고 싶을 때, 출력 형식을 정해진 틀로 고정하고 싶을 때, 혹은 매번 길게 붙이던 지시를 모델에 새겨 프롬프트 비용을 줄이고 싶을 때 사용한다.

다만 원하는 동작이 안 나온다고 해서 곧장 파인튜닝부터 시작하는 경우는 드물다. 보통은 더 싸고 빠른 방법부터 시도한다. 먼저 프롬프팅으로 지시와 예시를 다듬어 보고, 그래도 모델이 모르는 사실 때문에 막힌다면 관련 문서를 찾아 프롬프트에 넣어 주는 RAG로 푼다. 파인튜닝은 이 둘로도 원하는 행동이 나오지 않을 때 마지막으로 꺼내는 방법이다.

물론 이 순서가 반드시 싼 것부터 차례로 거쳐야 하는 단계인 것은 아니다. 세 방법은 애초에 푸는 문제가 다르기 때문이다. 모르는 사실 때문에 막혔다면 RAG가, 행동이 원하는 대로 잡히지 않아 막혔다면 파인튜닝이 답이다. 최신 정보나 사내 문서 같은 사실을 파인튜닝으로 학습시키려는 시도가 잘 통하지 않는 것도, 그건 RAG가 더 잘하는 일이기 때문이다.


이 글은 사실관계나 해석에 오류가 있을 수 있습니다. 잘못된 내용이나 질문이 있으면 댓글로 편하게 남겨 주세요.

Reference