지난 글에서 파인튜닝의 종류와 전체 파이프라인을 파악했다. 이번에는 그 가운데 가장 자주 부딪히는 갈림길 하나를 붙든다. 모델의 파라미터를 전부 학습할 것인가, 극히 일부만 학습할 것인가다. 앞쪽을 Full Fine-tuning(전체 파인튜닝), 뒤쪽을 PEFT(Parameter-Efficient Fine-Tuning, 파라미터 효율적 파인튜닝)라 부른다.
여기서 파라미터는 모델 안에 든 숫자들, 곧 가중치 행렬의 원소다. 파인튜닝은 이미 학습된 그 숫자들을 우리 데이터에 맞게 조금 옮기는 일이고, 둘의 차이는 어느 숫자를 옮기도록 허락하느냐에 있다. 이 글은 그 차이가 GPU 메모리에서 얼마가 되는지를 자리마다 세는 데서 시작한다. 계산을 따라가면 PEFT의 대표 기법인 LoRA가 무엇을 줄이고 무엇은 못 줄이는지, 그래서 QLoRA가 왜 필요했는지가 저절로 드러난다.
전체 학습의 메모리
자리별 메모리
7B, 곧 파라미터 70억 개짜리 모델을 전체 학습한다고 하자. GPU에 올라가야 하는 것은 네 가지다. 모델 가중치, 가중치마다 하나씩 붙는 그래디언트, 옵티마이저가 들고 있는 상태, 그리고 순전파 중에 만들어진 활성값이다.
가중치를 bf16(2바이트)으로 두면 70억 × 2바이트 = 14GB다. 그래디언트는 「이 가중치를 어느 쪽으로 얼마나 옮기면 손실이 줄어드는가」를 적은 값이라 가중치와 개수가 같고, 같은 bf16이면 또 14GB다. Adam 옵티마이저는 가중치마다 이동 평균 두 개(1차·2차 모멘트)를 들고 있으므로 bf16이어도 28GB가 든다. 여기까지 56GB다.
실제로는 이보다 더 든다. 흔히 쓰는 혼합 정밀도 학습은 계산은 bf16으로 하되, 작은 갱신이 반올림에 묻히지 않도록 가중치의 fp32 사본을 따로 두고 Adam 상태도 fp32로 둔다. 그러면 파라미터 하나에 bf16 가중치 2 + bf16 그래디언트 2 + fp32 사본 4 + Adam 상태 8 = 16바이트가 들어, 7B 모델은 112GB가 된다. A100 80GB 한 장에는 활성값을 넣기 전부터 안 들어간다.
활성값
활성값은 순전파 동안 각 층이 만든 중간 결과다. 역전파는 그래디언트를 계산하려고 이 값을 다시 읽어야 하므로, 순전파가 끝날 때까지 전부 메모리에 남겨 둔다. 활성값의 크기는 파라미터 수가 아니라 배치 크기 × 시퀀스 길이 × 은닉 차원 × 층 수에 비례한다.
GPT 계열 트랜스포머의 활성값을 어림한 연구가 쓰는 식은 층 하나당 약 34 × (시퀀스 길이) × (배치) × (은닉 차원) 바이트다(어텐션 점수를 저장하지 않는 FlashAttention 기준). 은닉 4,096, 32층 모델에 2,048토큰짜리 시퀀스 하나를 넣으면 34 × 2,048 × 4,096 ≈ 285MB가 층마다 쌓여 약 9GB가 된다. 배치를 4로 올리면 36GB다. 가중치·옵티마이저 쪽 수와 달리 이 수는 설정에 따라 크게 흔들리므로, 「파인튜닝에 GPU가 몇 GB 필요한가」라는 질문에 한 숫자로 답할 수 없는 까닭이 여기 있다.
70B 계산
같은 셈을 70B에 대입하면 문제의 규모가 보인다. 혼합 정밀도로 파라미터 하나에 16바이트면 1,120GB다. A100 80GB로 나누면 가중치·그래디언트·옵티마이저만 담는 데 14장이 필요하고, 활성값과 통신 버퍼까지 넣으면 16장 이상이 현실적인 하한이다. 이만큼을 여러 카드에 나눠 담는 데는 옵티마이저 상태와 그래디언트까지 쪼개 흩어 두는 ZeRO-3나 FSDP 같은 기법이 따로 필요하다.
이 비용을 감당할 수 있는 조직은 많지 않다. 그리고 감당할 수 있더라도, 결과물이 원래 모델과 같은 크기의 체크포인트 하나 — 70B면 140GB — 라서 과제마다 따로 만들면 저장과 배포도 과제 수만큼 무거워진다. PEFT는 이 두 문제를 한꺼번에 겨냥한다.
저랭크 분해
파라미터 수
LoRA(Low-Rank Adaptation)는 2021년 Microsoft 연구팀이 제안한 PEFT 기법이다. 원래 가중치 행렬 W를 얼려 두고 건드리지 않은 채, 그 옆에 학습할 변화량 ΔW를 따로 붙인다. 핵심은 ΔW를 통째로 학습하지 않고 두 개의 작은 행렬 B(d×r)와 A(r×d)의 곱으로 쓰는 것이다.
W' = W + ΔW = W + (α / r) · B × A
r은 랭크라 부르는 작은 수로, 보통 4~64다. d가 4,096이고 r이 16이면 ΔW를 통째로 학습할 때 4,096 × 4,096 = 16,777,216개였던 것이 4,096 × 16 + 16 × 4,096 = 131,072개로 준다. 0.78%다. d×d 행렬 전체를 자유롭게 움직이는 대신, 「r개의 방향으로만 움직일 수 있다」는 제약을 건 셈이다.
이 제약이 성능을 크게 해치지 않는 근거는 관측에서 나왔다. 사전 학습된 모델을 과제에 맞출 때 가중치가 실제로 움직이는 폭은 d차원 전체에 퍼지지 않고 몇 안 되는 방향에 몰린다는 것이다. 논문은 이것을 적응의 「내재 랭크」가 낮다고 표현했다. 새 지식을 대량으로 넣는 일이 아니라 이미 아는 것을 과제 쪽으로 돌려 세우는 일이라면, 몇 개의 방향으로 충분하다.
초기화
LoRA는 A를 작은 무작위 값으로, B를 0으로 초기화한다. 그러면 학습을 시작하는 순간 B × A = 0이라 W' = W, 곧 모델이 원래 모델과 정확히 같은 출력을 낸다. 파인튜닝은 이미 잘 작동하는 모델에서 출발해야 하는데, 두 행렬을 모두 무작위로 두면 첫 스텝부터 원래 가중치에 잡음을 더한 모델로 시작하게 된다.
둘 다 0으로 두면 안 되는 이유도 있다. B가 0이고 A도 0이면 A의 그래디언트가 B에, B의 그래디언트가 A에 곱해져 둘 다 0이 되어 영원히 안 움직인다. 한쪽만 0이어야 출발점은 원래 모델이면서 그래디언트는 흐른다.
α/r 스케일
위 식의 α/r이 스케일 계수다. 랭크를 8에서 16으로 올리면 B × A의 원소가 더해지는 개수가 늘어 ΔW의 크기가 커지는데, α/r로 나눠 주면 랭크를 바꿔도 ΔW의 크기가 대략 유지된다. 논문은 α를 처음 시도한 r 값에 고정하고 따로 조정하지 않았다고 적었다 — 랭크를 바꿀 때마다 학습률을 다시 찾지 않아도 되게 하려는 장치다. 실무에서는 α = 2r이 흔한 출발점이다. 랭크와 α를 실제로 어떻게 고르는지는 다음 글에서 따로 다룬다.
from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Meta-Llama-3-8B", torch_dtype="auto"
)
lora_config = LoraConfig(
r=16,
lora_alpha=32, # α = 2r
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
lora_dropout=0.05,
bias="none",
task_type=TaskType.CAUSAL_LM,
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 41,943,040 || all params: 8,072,204,288 || trainable%: 0.5196
출력의 수는 손으로도 셀 수 있다. Llama 3 8B는 은닉 4,096, 층 32개이고, K·V 투영은 GQA 때문에 출력이 1,024, FFN 중간 차원은 14,336이다. 층 하나에서 q·o가 16 × (4,096 + 4,096)씩, k·v가 16 × (4,096 + 1,024)씩, gate·up·down이 16 × (4,096 + 14,336)씩이라 합이 1,310,720이고, 32층이면 41,943,040이다. 학습 대상이 전체의 0.52%다.
메모리 절감
줄어드는 몫
학습 대상이 0.52%로 줄면 그래디언트와 옵티마이저 상태도 그 0.52%에만 붙는다. 얼린 가중치에는 그래디언트를 저장하지 않고 Adam 상태도 만들지 않는다. 7B 모델에 같은 설정을 걸면 LoRA 파라미터가 약 4천만 개이고, 여기에 bf16 가중치 2 + 그래디언트 2 + fp32 Adam 상태 8 + 사본 4를 붙여도 0.6GB가 안 된다. 앞 절의 112GB에서 가중치 14GB를 뺀 98GB가 0.6GB로 줄어든 것이다.
그래서 LoRA의 메모리는 사실상 얼린 가중치 + 활성값이다. 7B bf16이면 14GB에 활성값을 더한 값이 된다. 결과물도 달라진다. 저장해야 하는 것은 원래 모델 전체가 아니라 A·B 행렬뿐이라 체크포인트가 수십~수백 MB다. 과제가 열 개여도 원래 모델 하나에 어댑터 열 개를 두면 된다.
남는 활성값
그런데 활성값은 줄지 않는다. LoRA 어댑터는 첫 층부터 마지막 층까지 흩어져 있고, 첫 층 어댑터의 그래디언트를 구하려면 역전파가 마지막 층에서 첫 층까지 전부 거슬러 올라가야 한다. 거슬러 가는 길에 있는 층은 얼려 있더라도 활성값을 읽어야 그래디언트를 앞으로 넘길 수 있다. 학습 대상이 얼마나 적든 활성값은 전체 학습과 거의 같다.
이것이 「LoRA로 돌렸는데 OOM이 났다」의 가장 흔한 원인이다. 앞 절의 계산대로 2,048토큰에 배치 4면 활성값만 36GB이고, 가중치 14GB를 더하면 50GB라 A100 40GB에도 안 들어간다. 시퀀스 길이를 두 배로 늘리면 활성값도 두 배가 된다.
gradient checkpointing
gradient checkpointing은 활성값을 전부 남기지 않고 층 경계의 값만 남긴 뒤, 역전파 때 필요한 구간을 순전파로 다시 계산해 쓰는 기법이다. 메모리는 층 수에 비례하던 것이 경계 값만큼으로 크게 줄고, 대신 순전파를 한 번 더 도는 만큼 계산이 는다. 역전파가 순전파의 약 두 배 계산이라 한 스텝이 순전파 3번분에서 4번분이 되고, 스텝 시간은 대략 30% 안팎 늘어난다.
LoRA와 이 기법은 거의 늘 함께 쓴다. LoRA가 가중치 쪽 비용을 없애고 checkpointing이 활성값 쪽 비용을 누르면, 남는 가장 큰 덩어리는 얼린 가중치 자체다. 다음 절의 QLoRA가 바로 그 덩어리를 겨냥한다.
4비트 위의 학습
NF4
QLoRA는 얼린 가중치를 4비트로 눌러 저장하고 그 위에 LoRA를 학습하는 기법으로, 2023년 워싱턴대 연구팀이 발표했다. 얼린 가중치는 학습 중에 바뀌지 않으므로 정밀도를 낮춰도 갱신이 반올림에 묻힐 걱정이 없다. 문제는 4비트, 곧 값 16개로 원래 가중치를 얼마나 잘 흉내 내느냐다.
균등 int4는 최솟값과 최댓값 사이를 16칸으로 똑같이 나눈다. 그런데 신경망 가중치는 0 근처에 몰린 정규분포에 가깝다. 똑같이 나누면 값이 드문 양 끝에도 칸이 쓰이고 값이 몰린 가운데는 칸이 모자란다. NF4(4-bit NormalFloat)는 표준 정규분포의 분위수에 맞춰 16개 값을 배치한다 — 가운데는 촘촘하고 끝은 성기다. 가중치가 정말 정규분포를 따른다면 16칸 각각에 비슷한 수의 값이 떨어지므로, 같은 4비트로 정보를 가장 많이 담는다.
이중 양자화
4비트 값만으로는 원래 크기를 알 수 없으므로 가중치 64개 블록마다 크기를 되돌릴 양자화 상수를 하나씩 둔다. 상수를 fp32로 두면 32비트 ÷ 64 = 파라미터당 0.5비트가 더 든다. 4비트의 12.5%라 작지 않다.
이중 양자화는 이 상수들을 다시 8비트로 누른다. 상수 256개마다 fp32 상수를 하나 두면 파라미터당 8/64 + 32/(64 × 256) ≈ 0.127비트가 되어 약 0.37비트를 아낀다. 논문은 이것으로 65B 모델에서 약 3GB를 줄였다고 적었다. 작은 수 같지만 GPU 한 장의 경계에 걸린 모델에서는 이 3GB가 들어가느냐 마느냐를 가른다.
저장과 계산의 정밀도
QLoRA에서 4비트는 저장 형식일 뿐이다. 행렬 곱을 할 때마다 그 층의 4비트 가중치를 bf16으로 풀어 계산하고, 계산이 끝나면 풀었던 값을 버린다. 설정의 bnb_4bit_compute_dtype=torch.bfloat16이 그 계산 정밀도다. 그래서 QLoRA는 LoRA보다 메모리는 훨씬 적게 쓰지만 스텝마다 풀어 쓰는 비용이 붙어 더 느리다.
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import prepare_model_for_kbit_training, get_peft_model
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # 정규분포 분위수 눈금
bnb_4bit_use_double_quant=True, # 양자화 상수도 8비트로
bnb_4bit_compute_dtype=torch.bfloat16, # 계산은 bf16으로 풀어서
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Meta-Llama-3-8B",
quantization_config=bnb_config,
device_map="auto",
)
model = prepare_model_for_kbit_training(model) # checkpointing 등을 켠다
model = get_peft_model(model, lora_config)
24GB에서의 7B
이제 첫 절의 표에 다시 대입해 보자. 7B의 얼린 가중치가 4비트면 약 3.5GB이고 이중 양자화한 상수가 0.1GB쯤 붙는다. 임베딩과 출력층은 보통 16비트로 남겨 두므로 1GB 안팎이 더해진다. LoRA 쪽 학습 상태가 0.6GB, checkpointing을 켠 활성값이 몇 GB다. 합쳐도 10GB 안팎이라 RTX 4090의 24GB에 여유 있게 들어간다. 전체 학습에 112GB가 들던 모델이다.
QLoRA 논문의 제목에 걸린 주장도 이 계산 위에 서 있다. 65B 모델을 48GB GPU 한 장에서 파인튜닝했고, 16비트 전체 파인튜닝과 비슷한 성능을 냈다고 보고했다. 비슷한 성능이 어디서 오는지는 다음 절에서 볼 「어느 층에 붙이는가」와 이어진다.
어댑터 대상 층
어텐션 투영
LoRA는 모든 선형 층에 붙일 필요가 없고, 어디에 붙이느냐가 결과를 바꾼다. 트랜스포머 한 층에는 어텐션 쪽 투영 넷(q·k·v·o)과 FFN 쪽 투영 셋(gate·up·down)이 있다. 원래 LoRA 논문은 GPT-3 175B에서 학습 파라미터 예산을 고정해 두고 어느 행렬에 나눠 줄지를 비교했는데, q와 v 둘에 나눠 주는 것이 가장 좋았다. 같은 예산이면 한 행렬에 높은 랭크를 주는 것보다 여러 행렬에 낮은 랭크를 나눠 주는 편이 나았다는 결과이기도 하다.
그래서 target_modules=["q_proj", "v_proj"]는 오래 기본값처럼 쓰였다. 학습 파라미터가 가장 적고, 말투나 형식을 바꾸는 과제처럼 모델이 이미 아는 것을 다르게 꺼내게 하는 일에는 이것으로 충분한 경우가 많다.
FFN 투영
QLoRA 논문은 반대쪽 결과를 보고했다. 4비트 위에서 16비트 전체 파인튜닝 성능을 따라잡으려면 어텐션만이 아니라 모든 선형 층에 LoRA를 붙여야 했다는 것이다. 흔한 해석은 FFN이 사실 정보를 담는 자리라는 관찰과 이어진다. 새 도메인의 용어와 사실을 넣어야 하는 과제라면 gate·up·down을 더하는 쪽이 낫다.
더하면 늘어나는 것도 분명하다. 앞 절의 Llama 3 8B 계산에서 q·v만 붙이면 층당 16 × 8,192 + 16 × 5,120 = 212,992개이고, 일곱 투영 전부면 1,310,720개로 여섯 배가 넘는다. FFN 셋이 층당 884,736개로 가장 크다. 그래도 전체의 0.5%라 옵티마이저 메모리는 여전히 작지만, 어댑터 계산이 층마다 붙어 스텝 시간은 늘어난다.
대상 층 측정
어느 쪽이 맞는지는 우리 과제에서 재 봐야 안다. 비교할 때는 세 값을 같이 기록한다. 학습 파라미터 수(print_trainable_parameters), 최대 메모리(torch.cuda.max_memory_allocated), 스텝 시간이다. 여기에 검증 세트 손실과 과제 지표를 붙여 한 표로 놓으면, 「q·v만으로 지표가 얼마까지 나오고 FFN을 더하면 몇 점을 더 얻는 대신 스텝이 얼마나 느려지는가」가 한 줄로 읽힌다.
측정은 짧게 한다. 전체 데이터로 끝까지 돌릴 필요 없이 같은 몇백 스텝만 돌려 손실 곡선의 기울기를 보는 것으로 대개 충분하다. 설정 둘의 곡선이 몇백 스텝 동안 겹친다면 싼 쪽을 고른다.
병합
병합의 맞바꿈
학습이 끝난 어댑터는 두 방식으로 쓸 수 있다. 얹은 채로 쓰면 매 순전파에서 입력 x에 대해 W·x에 더해 B·(A·x)를 한 번 더 계산한다. 합치면 W' = W + (α/r)·B·A를 미리 계산해 새 가중치 하나로 만들고, 그 뒤로는 원래 모델과 똑같은 비용으로 돈다.
merged_model = model.merge_and_unload() # W' = W + (α/r)·B·A
merged_model.save_pretrained("./merged_model")
얻는 것은 추론 비용이고 잃는 것은 유연성이다. 합친 가중치는 그 어댑터 전용이 되어 다른 과제에 다시 쓸 수 없고, 저장 크기도 어댑터 몇십 MB에서 원래 모델 크기로 돌아간다. 4비트 위에서 학습한 어댑터를 16비트 모델에 합칠 때 어떤 순서로 해야 정밀도가 새지 않는지는 파인튜닝 파이프라인 실습의 병합 단계에서 다룬다.
서빙 형태별 선택
어느 쪽을 고를지는 서비스가 과제를 몇 개 들고 있느냐로 갈린다. 챗봇 하나에 어댑터 하나로 끝난다면 합치는 쪽이 낫다. 추가 계산이 없고 서빙 엔진이 LoRA를 지원하는지 따질 필요도 없다.
과제가 여럿이면 답이 뒤집힌다. 고객사마다, 언어마다 다른 어댑터를 쓰는 서비스라면 원래 모델 하나를 GPU에 올려 두고 요청마다 어댑터를 갈아 끼우는 편이 합친 모델 열 개를 따로 띄우는 것보다 훨씬 싸다. vLLM 같은 서빙 엔진이 한 배치 안에서 서로 다른 어댑터를 쓰는 요청을 함께 처리하는 기능을 갖추고 있다. 이때 어댑터를 얹은 채 쓰는 추가 계산은 GPU 여러 장을 아끼는 대가로 기꺼이 치르는 값이다.
GPU 예산별 선택
세 구간
앞의 계산을 GPU 메모리 구간으로 다시 정리하면 선택지가 좁혀진다. 수는 모두 활성값을 checkpointing으로 누르고 배치를 작게 잡은 경우다.
| GPU 메모리 | 7~8B | 13B | 70B |
|---|---|---|---|
| 24GB 미만 | QLoRA | QLoRA (빠듯함) | 불가 |
| 24~80GB | LoRA (bf16) | QLoRA 또는 LoRA | QLoRA (48GB 이상) |
| 80GB 여러 장 | Full 가능 | Full 가능 | LoRA, Full은 16장 이상 |
24GB 미만에서는 7B의 bf16 가중치 14GB만으로도 여유가 거의 없어 QLoRA가 사실상 유일한 길이다. 24~80GB에서는 7B를 bf16 LoRA로 돌릴 수 있고, 70B는 4비트 가중치가 35GB 안팎이라 48GB 이상 카드에서 QLoRA로 들어간다. 여러 장이 있어야 비로소 전체 학습이 선택지에 오른다.
올라가는 순서
흔히 권하는 순서는 QLoRA로 시작해 부족하면 LoRA, 그래도 부족하면 Full로 올라가는 것이다. 근거는 비용이 계단처럼 뛰기 때문이다. QLoRA에서 LoRA로 가면 메모리가 서너 배, LoRA에서 Full로 가면 다시 여러 배가 된다. 싼 단계에서 원하는 결과가 나오면 거기서 멈추는 것이 합리적이고, 싼 단계에서 쌓은 데이터·평가 세트는 비싼 단계로 그대로 가져갈 수 있다.
올라갈지 말지를 판단하려면 무엇이 부족한지를 구분해야 한다. 형식을 못 맞추거나 지시를 잘 안 따른다면 대개 데이터 문제라 방법을 바꿔도 안 낫는다. 도메인 사실을 계속 틀린다면 대상 층을 FFN까지 넓히고 랭크를 올려 본 다음, 그래도 안 되면 Full을 검토한다.
Full이 필요한 경우
「모델의 근본 행동을 바꿔야 할 때」라는 말은 구체적으로 이런 경우다. 모델이 거의 모르는 언어를 새로 가르칠 때, 수십억 토큰 규모의 도메인 문서로 사전 학습을 이어 갈 때, 그리고 모델의 기본 말투·안전 성향을 넓게 바꿔야 할 때다. 셋 다 몇 개의 방향으로 돌려 세우는 일이 아니라 많은 방향을 새로 옮기는 일이다.
반대 방향의 증거도 있다. 2024년의 한 연구는 LoRA가 전체 학습보다 새 도메인을 덜 배우는 대신 원래 능력도 덜 잊는다고 보고했다. 적게 움직이는 것은 약점이자 안전장치다. 원래 모델의 일반 능력을 지키면서 과제 하나를 얹는 것이 목표라면, 그 성질이 오히려 LoRA를 고를 이유가 된다.
LoRA와 QLoRA는 파인튜닝을 GPU 클러스터 없는 팀에게로 끌어내린 기술이다. 다음 글은 LoRA 하나를 붙들고, 여기서 잠깐 스친 랭크·α·대상 층 세 하이퍼파라미터를 어떻게 고르는지를 수식과 코드로 차례로 풀어 본다.
읽어주셔서 감사합니다. 😊

