파인튜닝과 PEFT
- #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) — 사전학습으로 만들어진 원본 모델을 가져와 훨씬 작은 데이터로 특정 작업·도메인·말투에 맞게 가중치를 마저 조정하는 단계.
다행히 원본 모델이 이미 언어의 대부분을 알고 있어서, 적은 데이터로도 원하는 방향으로 모델을 끌고 갈 수 있다.
파인튜닝의 종류
모델을 가져다 쓸 때, 가중치를 얼마나 학습시키느냐에 따라 방법이 나뉜다. 거의 안 건드리는 쪽부터 전부 다 바꾸는 쪽까지 크게 세 가지다.
풀 파인튜닝 (full fine-tuning) — 모델 전체 가중치를 업데이트하는, 가장 직관적인 방식이다. 본체부터 출력까지 모든 layer를 내 데이터에 맞춰 다시 학습시킨다. 표현력이 가장 크지만, 그만큼 학습에 드는 메모리와 저장 비용이 커서 리소스가 충분하지 않으면 어렵다.
특성추출 (feature extraction) — 반대로, 사전학습 모델 전체를 동결(freeze)시키고 그 위에 내 작업용 출력층(head) 하나만 새로 붙여서 그것만 학습한다. 무거운 본체는 그대로 두고 가벼운 머리만 갈아 끼운다.
본체는 입력을 "의미를 담은 숫자 벡터"로 바꿔 주는 고정된 특성 추출기로만 쓰고, head가 그 벡터를 내가 원하는 답(예: 긍정/부정)으로 변환한다. 학습할 게 head 하나뿐이라 가볍고 빠르지만, 본체를 안 건드리니 본체가 내 도메인과 너무 다르면 한계가 있다.
엄밀히 말하면 특성추출은 사전학습 가중치를 전혀 건드리지 않으니, 가중치를 조정한다는 의미의 파인튜닝이라 부르긴 어렵다. (사실 파인튜닝도 특성추출도, 사전학습으로 쌓은 지식을 내 작업에 가져다 쓰는 전이학습(transfer learning)의 갈래다.) 게다가 이 방식은 분류처럼 새 head를 붙이는 작업에 어울려서, 우리가 다룰 생성형 모델과는 결이 조금 다르다.
PEFT (Parameter-Efficient Fine-Tuning) — 두 방식의 장점을 절충한, 요즘 가장 널리 쓰이는 접근이다. 본체는 특성추출처럼 대부분 동결해 두되, 그 안에 새로 끼워 넣은 아주 작은 파라미터(어댑터)만 학습한다. 풀 파인튜닝처럼 모델 내부를 건드려 행동을 바꾸면서도, 학습하는 양은 전체의 1%도 안 되는 경우가 많다.
세 가지를 한 축에 늘어놓으면 "가중치를 얼마나 학습시키느냐"에 따른 여러 단계가 된다.
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이니 한참 모자란다.
PEFT의 발상 — 작은 부품만 학습한다
PEFT의 발상은 단순하다. 무거운 본체는 동결시켜두고(안 배우고), 새로 끼운 작은 부품만 학습한다. 학습하는 파라미터가 전체의 1%도 안 되니, 거기 딸려 오는 기울기·옵티마이저 데이터도 그만큼만 덜어진다.
이때 새로 끼우는 작은 부품을 어댑터(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개로 만들 수 있다.
다만 줄을 한 쌍만 쓰면 담을 수 있는 변화가 너무 단순하다. 그래서 실제로는 이런 세로·가로 줄을 여러 쌍 겹쳐 쓰는데, 이 겹친 쌍의 개수가 바로 r(랭크)이다. 세로 줄을 r개 옆으로 붙이면 작은 행렬 하나(크기 4096×r), 가로 줄을 r개 붙이면 또 하나(크기 r×4096)가 된다. 이 두 행렬이 곧 어댑터이고, 둘을 곱하면 원본에 더할 큰 행렬이 만들어진다. r이 클수록 더 복잡한 변화까지 담지만 학습할 숫자도 그만큼 늘어나니, 보통 8이나 16처럼 작게 잡는다.
전체 가중치를 통째로 다시 학습하는 풀 파인튜닝에 비해, 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)에 작게 나눠 붙이는 편이 대체로 더 효과적이다.
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.0731이 0.08로 — 이해를 돕기 위한 예시 값이다). 한 숫자가 차지하는 용량이 16bit에서 4bit로, 1/4로 준다.
여기서 중요한 건 4bit는 저장 형식일 뿐, 계산까지 4bit로 하는 게 아니다. 입력에 가중치를 곱할 땐 쓰는 가중치만 잠깐 16bit로 되돌려 곱하고 버린다. 핵심은 모델 전체를 한꺼번에 펴지 않는다는 것이다. 70억 개는 4bit로 누운 채, 그때그때 쓰는 한 줌만 16bit로 떴다 사라진다. 압축한 책을 책장에 꽂아두고 읽을 페이지만 펴 보는 셈이라, 메모리에 상주하는 모델은 압축된 크기 그대로다.
다만 모델의 모든 부분이 4bit인 건 아니다. 원본 모델과 어댑터는 쓰는 bit 수가 다르다.
- 원본 모델 — 4bit로 압축해 동결. 읽기만 하니 bit를 줄여도 된다.
- LoRA 어댑터 — 16bit 그대로 학습.
차이는 값이 바뀌느냐다. 원본 모델은 계산에 값을 꺼내 곱할 뿐 값 자체는 그대로라 4bit로 저장해도 된다. 반면 어댑터는 학습 내내 값이 조금씩(가령 +0.0003씩) 갱신된다. 갱신한 값을 4bit로 도로 저장하면 그 작은 변화가 반올림에 묻혀 사라지고, 값이 제자리에 묶여 학습이 나아가지 못한다. 그래서 갱신이 쌓이는 학습 중에는 16bit를 유지해야 한다.
학습이 끝난 어댑터도 4bit로 압축할 수는 있다. 다만 어댑터가 차지하는 메모리가 전체의 1%도 안 되는 경우가 많아, 굳이 압축하지 않고 16bit 그대로 두는 것이 일반적이다.
원본 모델이 차지하는 메모리가 얼마나 줄어드는지 숫자로 보면:
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.514GB가 약 3.5GB로 떨어진다. 여기에 LoRA의 학습 메모리 절감까지 더해지면, 24GB짜리 소비자용 GPU 한 장에서 7B 모델 파인튜닝이 가능해진다. 풀 파인튜닝이었다면 같은 7B에 112GB가 필요했다.
다만 그냥 4bit로 줄이기만 하면 곳곳에서 문제가 생긴다. QLoRA는 세 가지 기법으로 이를 메운다 — 반올림 오차를 줄이고(①), 압축에 딸려오는 부가 메모리를 줄이고(②), 학습 중 메모리 폭증을 막는다(③).
① NF4(NormalFloat 4-bit) — 쓸 수 있는 값을 0 근처에 몰아준다. 4bit로 쓸 수 있는 16개의 값이 정확히 어떤 숫자인지는 고정돼 있지 않다. 각 번호가 어떤 실제 값을 뜻하는지는 미리 정해 둔 표(코드북)가 정하고, 양자화는 가중치를 가장 가까운 값으로 반올림해 그 번호(4bit)만 저장한다. 읽을 땐 번호로 표를 찾아 값을 되살린다.
그럼 표의 16개 값은 어떻게 정할까? 정규분포를 '넓이'로 16등분하는 게 핵심이다. 모델 가중치는 대부분 0.03, -0.05처럼 0에 몰린 종 모양 분포를 이룬다. 곡선 아래 면적을 똑같은 16조각으로 자르고, 각 조각을 대표하는 값(조각의 가운데) 하나씩을 표에 적는다. 값이 빽빽한 0 근처는 좁은 폭에 한 조각이 차서 칸이 촘촘해지고, 값이 드문 양 끝은 넓은 폭이 한 조각으로 묶여 듬성해진다. 표가 가중치 분포에 저절로 맞춰지는 셈이라, 이름의 'Normal'도 정규분포(normal distribution)에서 왔다.
같은 가중치 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로 줄인다. 절감 폭이 크진 않지만, 품질을 거의 해치지 않으면서 메모리를 더 아낄 수 있다.
③ 페이지 옵티마이저 — 메모리가 넘치면 CPU로 잠깐 대피. 학습 중 메모리 사용량은 일정하지 않다. 긴 입력이나 특정 단계에서 순간적으로 확 치솟을 때가 있는데, 평소엔 GPU에 딱 맞아도 이 순간 넘치면 학습이 멈춘다(OOM, Out Of Memory). 페이지 옵티마이저는 넘치는 부분을 CPU 메모리로 잠깐 옮겼다가 고비가 지나면 다시 가져온다. 운영체제가 램이 모자랄 때 디스크를 빌려 쓰는(가상 메모리) 것과 같은 요령이라, 잠깐의 스파이크로 학습이 죽는 걸 막는다. 사실 이건 QLoRA 전용이라기보다 범용 기법이지만, GPU 한 장의 메모리 한계까지 밀어붙이는 QLoRA에서 특히 효과가 커서, QLoRA의 기법 중 하나로 함께 다뤄진다.
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.0GB | 0.5GB |
| 학습 피크 메모리 | 3.5GB | 3.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
- https://arxiv.org/abs/2106.09685 — Hu et al. 2021, "LoRA: Low-Rank Adaptation of Large Language Models"
- https://arxiv.org/abs/2305.14314 — Dettmers et al. 2023, "QLoRA: Efficient Finetuning of Quantized LLMs"