시작하기에 앞서 간단하게 LoRA 에 대해서 이야기하고 지나가겠습니다.
이 과정에서 LoRA 는 원래 모델의 가중치 W를 학습하지 않고 추가적인 low-rank matrix인 A, B 만 학습함으로써 특정 목적에 적합한 모델로 학습시키는 방향으로 너무 큰 메모리를 VRAM 에 올리는 문제를 개선하고자 했습니다.
하지만 LoRA라고 해도 기본 모델은 Forward 계산에 반드시 필요합니다. 이 Forward 계산 과정에서 필요한 가중치는 기존의 7B Base Model 이 fronzen, GPU 에 몰리지 않지만 계산을 수행할 경우에는 해당 가중치가 계산에는 필요합니다. 이를 개선하기 위해서 등장한 것이 QLoRA 입니다.
Fine-tuning
기존의 선형층은 대략 과 같이 가중치를 계산합니다. 여기서 W는 사전학습된 거대한 가중치에 해당하고 LoRA 에서는 W 자체를 수정하지 않고 작은 행렬 A, B를 추가해서 로 계산을 수행합니다.
학습된 가중치를 변경하지 않더라도 동일한 입력값 X가 기존의 모델의 가중치 W 와 LoRA 어댑터의 A-> B, 두 경로로 들어가고 마지막에 두 결과를 더함으로서 특정 목적, 문제에 맞춰 모델을 학습시키는 것이 가능해졌습니다.
LoRA 는 학습 대상을 줄여 Gradient 와 Optimizer 의 상태를 극적으로 절약했습니다. 하지만 기본 모델의 가중치 자체는 여전히 FP16/BF16 고정밀도로 GPU에 올라가 있기 때문에 예를들어 7B 모델일 경우 여전히 높은 가중치를 갖고 있다는 문제가 발생합니다.
QLoRA는 기존 모델 W까지 4-bit로 양자화해서 보관합니다. 학습된 모델을 NF4로 양자화해 메모리를 1/4로 줄이고, 그 위에 LoRA 어댑터를 얹어 학습을 수행하는 방향으로 파인튜닝을 개선하고자 하였습니다. 이를 기존의 데이터 값과 비교를 해보면
FP 16
NF4, 4-bit
위와 같이 많은 양의 데이터를 줄일 수 있게 되었습니다. 이렇게 기존 base model 의 가중치 또한 압축을 시켜 fine tuning 의 비용을 개선하고자 했는데 이 과정에서 사용한 방법론이 Double Quantization 와 Paged Optimizer 가 사용이 됩니다.
양자화
가장 기본적인 선형 양자화 공식은 다음과 같습니다.
위와 같이 연산을 수행하기 위해서 알아야 하는 값은 s 값인 Scale 값을 구해야 합니다. 이 s 를 구하는 방법은 다음과 같습니다.
original_numbers = [0.81, -0.42, 0.05, 0.93, -0.77, 0.10, -0.05, 0.60]
# -----------------------------------------------------------------
# 1단계: scale 값 구하기
# -----------------------------------------------------------------
# INT8은 -127 ~ 127 사이의 정수만 표현할 수 있습니다.
# 원본 숫자 중 절댓값이 가장 큰 값을 127에 대응시키면,
# 나머지 숫자들도 자연스럽게 -127~127 범위 안으로 들어오게 됩니다.
max_value = max(abs(n) for n in original_numbers)
scale = max_value / 127
print(f"\n절댓값이 가장 큰 값: {max_value}")
print(f"scale 값 = {max_value} / 127 = {scale:.6f}")
위의 과정은 기존의 데이터 FP 값을 INT8의 범위에 맞춰 압축하기 위해 한 칸의 크기인 Scale를 구하는 과정을 의미합니다. 위의 과정에서 원본의 값들의 절댓값의 최댓값을 구하는 이유는 양수와 음수 양쪽에서 가장 큰 크기를 알아야 하기 때문입니다.
이러한 이유는 양수·음수 전체 범위를 하나의 동일한 간격, scale 으로 나누어, 제한된 INT8 표현 공간 안에 모두 넣기 위해 가장 큰 절댓값을 찾는 것입니다. 예를들어 INT8을 대칭적으로 -127 ~ 127 로 사용하려면 하나의 scale 로 모든 값을 표현해야 합니다.
원본 범위 INT8 범위
-10 ──────────────────── -127
-2 ───────────────────── -25
0 ───────────────────── 0
1 ───────────────────── 13
3 ───────────────────── 38
10 ───────────────────── 127
scale 과정에서 가장 큰 값을 기준으로 수행을 하지 않았을 경우에는 양자와 시킨 값을 다시 복원 시키는 과정에서 기존의 수의 범위를 벗어나기 때문에 이로 인한 특징으로 인해 정보 손실이 매우 커질 수 있기 때문에 원본 값 중 절댓값의 크기가 가장 큰 값을 기준으로 스케일링을 수행하는 것입니다.
quantized_numbers = []
for n in original_numbers:
q = round(n / scale) # scale로 나눈 뒤 정수로 반올림
quantized_numbers.append(q)
# [111, -57, 7, 127, -105, 14, -7, 82]
앞서 수행한 스케일링한 값들을 원본의 숫자 값들에 스케일 값을 을 수행하여 양자화를 수행합니다.
# -----------------------------------------------------------------
# 3단계: 다시 실수로 복원 (역양자화)
# -----------------------------------------------------------------
dequantized_numbers = [q * scale for q in quantized_numbers]
# [0.8128346456692914, -0.41740157480314966, 0.051259842519685045, 0.93, -0.7688976377952756, 0.10251968503937009, -0.051259842519685045, 0.600472440944882]
마지막 단계로 데이터가 필요한 경우 이전의 스케일링한 값을 다시 역양자화를 수행하여 복원시키는데 이 과정에서 기존의 원본 값에 비해 손실이 발생하는데 이 손실 값의 경우 큰 데이터의 손실이 발생하지 않기 때문에 유의미한 압축과정으로 활용이 됩니다.
위의 값에서 의 값은 원래 W 와 완전히 동일한 값이 아닌 quantize 를 통해서 생성된 값을 dequantize의 과정을 거친 근사값을 의미합니다.
Inference
이제 앞서 다룬 과정의 마지막 단계인 추론 과정입니다. 이 QLoRA 가 추론 과정에서 사용되는 가중치 값들은 데이터의 표현방식을 양자화를 통해 4bit 의 데이터로 줄이게 될 경우 그 정보만큼의 정보 손실이 발생할 수 있다는 위험성이 존재합니다.
| 양자화 | 대략적인 최대 오차 |
|---|---|
| FP32 | 사실상 기준, 차이 없음 |
| INT8 | 최대값 기준 약 0.4% |
| INT4 | 최대값 기준 약 6~7% |
| INT2 | 매우 큼, 일반적인 가중치 보존에는 어려움 |
하지만 이 손실의 크기는 변환하는 데이터의 타입에 따라서 크게 차이가 나며 이러한 차이를 고려하여 어떠한 타입의 데이터로 변환할 것인지에 대해서 고려하여 적용할 필요가 있습니다.