15장. 모델 경량화: 양자화, 프루닝, 지식증류
양자화의 원리
온도계의 눈금을 10도 간격으로만 읽으면, 23도는 20도로 읽힙니다. 양자화는 실수를 정해진 간격의 눈금에 맞춰 반올림하는 일입니다. 간격(스케일)은 가장 큰 값이 마지막 눈금에 오도록 정합니다.
1번에서 가중치 묶음의 가장 큰 절댓값을 정수의 가장 큰 값(int8 이면 127)으로 나눠 스케일을 정합니다. 2번에서 가중치를 스케일로 나눠 반올림해 정수로 만듭니다. 3번에서 정수와 스케일만 저장합니다. 4번에서 쓸 때 정수에 스케일을 곱해 되돌립니다. 반올림한 만큼 오차가 남습니다. 여기서 온도계의 눈금 간격이 스케일입니다.
스케일을 어디마다 두나
| 방법 | 스케일 하나가 맡는 것 | 오차 | 더 드는 메모리 |
|---|---|---|---|
| 텐서마다 | 가중치 행렬 전체 | 크다. 튀는 값 하나가 간격을 넓힌다 | 거의 없다 |
| 행마다(채널마다) | 출력 하나의 가중치 줄 | 중간 | 행 수만큼 |
| 그룹마다(예: 128개) | 128개 묶음 | 작다 | 128개마다 스케일 하나(4비트면 약 0.125비트 더) |
직접 양자화해 보기
import numpy as np
w = np.array([0.02, -0.15, 0.31, -0.07, 2.4], dtype=np.float32) # 마지막 값이 유난히 크다(튀는 값)
scale = np.abs(w).max() / 127 # int8 의 가장 큰 값 127 에 맞춘다
q = np.round(w / scale).astype(np.int8)
print("정수:", q, "· 스케일:", round(float(scale), 5))
print("되돌린 값:", (q * scale).round(4), "· 오차:", np.abs(w - q * scale).round(4))코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
np.abs(w).max() | 절댓값(absolute value) 가운데 가장 큰 값이다. 여기서는 2.4 다 |
/ 127 | int8 의 가장 큰 값 127 로 나눠 눈금 하나의 간격(스케일)을 정한다 |
np.round(w / scale) | 가중치를 눈금 간격으로 나눠 가장 가까운 정수로 반올림한다 |
.astype(np.int8) | 8비트 정수(integer 8)로 바꾼다. 숫자 하나에 1바이트만 든다 |
q * scale | 정수에 스케일을 곱해 원래 크기로 되돌린다 |
.round(4) | 소수점 아래 네 자리로 반올림해 읽기 좋게 찍는다 |
이 코드가 뜻하는 것
온도계에 눈금을 새기는 일과 같습니다. 가장 높은 온도가 마지막 눈금에 오도록 간격을 정하고, 온도마다 가장 가까운 눈금 번호만 적어 둡니다. 나중에 눈금 번호에 간격을 곱하면 온도를 대략 되찾습니다. 여기서 눈금 간격이 scale, 눈금 번호가 int8 정수 q 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
w = np.array([...]) | 가중치 다섯 개를 둔다. 마지막 2.4 가 튀는 값이다 | 재야 할 온도 다섯 개 |
scale = np.abs(w).max() / 127 | 2.4 가 127번 눈금에 오도록 간격을 정한다 | 가장 높은 온도에 맞춰 눈금을 새긴다 |
q = np.round(w / scale).astype(np.int8) | 가중치를 눈금 번호로 바꾼다 | 온도마다 가까운 눈금 번호를 적는다 |
print("되돌린 값:", ...) | 번호 × 간격으로 되돌리고 오차를 찍는다 | 적어 둔 번호로 온도를 다시 읽는다 |
결과에서 스케일은 0.0189 이고, 정수는 1, -8, 16, -4, 127 입니다. 0.02 는 0.0189 로 되돌아와 오차 0.0011 이 남았습니다. 튀는 값 2.4 는 오차가 0 이지만, 그 값 때문에 간격이 넓어져 작은 값들이 몇 개의 눈금에만 놓였습니다.
작게 줄여서 눈으로 보기
스케일을 묶음마다 따로 두면 오차가 얼마나 줄어드는지 봅니다. 씨앗 0 으로 작은 가중치 여덟 개를 만들고, 마지막에 튀는 값 2.0 을 일부러 넣었습니다.
import numpy as np
np.random.seed(0)
w = np.random.normal(0, 0.1, 8).astype(np.float32)
w[7] = 2.0 # 튀는 값 하나를 일부러 넣는다
def int8_error(part):
scale = np.abs(part).max() / 127
q = np.round(part / scale)
return np.abs(part - q * scale)
whole = int8_error(w) # 스케일 하나로 여덟 개 모두
groups = np.concatenate([int8_error(w[:4]), int8_error(w[4:])]) # 네 개씩 두 묶음
print("텐서 하나 평균 오차:", round(float(whole.mean()), 5))
print("두 묶음 평균 오차:", round(float(groups.mean()), 5))
print("앞 묶음만 보면:", round(float(whole[:4].mean()), 5), "→", round(float(groups[:4].mean()), 5))텐서 하나 평균 오차: 0.00292
두 묶음 평균 오차: 0.00093
앞 묶음만 보면: 0.00435 → 0.00036| 결과 | 읽는 법 |
|---|---|
텐서 하나 평균 오차: 0.00292 | 스케일 하나를 여덟 개가 함께 쓰면, 튀는 값 2.0 에 맞춘 넓은 간격을 모두 쓴다 |
두 묶음 평균 오차: 0.00093 | 네 개씩 나눠 스케일을 따로 두니 평균 오차가 약 3분의 1 로 줄었다 |
앞 묶음만 보면: 0.00435 → 0.00036 | 튀는 값이 없는 앞 묶음은 자기에게 맞는 좁은 간격을 얻어 오차가 크게 줄었다 |
np.random.normal(0, 0.1, 8) 은 평균 0, 표준편차 0.1 인 정규분포(normal)에서 숫자 8개를 뽑습니다. np.concatenate 는 두 배열을 이어 붙입니다. 이것이 위 표의 "그룹마다" 방법이 오차가 작은 까닭입니다. 대신 묶음마다 스케일을 하나씩 더 저장해야 합니다.
주의할 점
- 튀는 값(아웃라이어)이 있으면 간격이 넓어져 나머지 작은 값들이 거의 모두 0 이나 같은 눈금에 몰립니다. 그룹을 작게 나누거나, 튀는 값을 따로 다루는 방법(AWQ, 8비트의 혼합 정밀도)을 씁니다(실습 1).
- 4비트는 눈금이 16개뿐이라 8비트보다 오차가 훨씬 큽니다. 4비트 양자화는 대개 그룹 양자화와 함께 씁니다.