LLM 엔지니어링15장 · 모델 경량화: 양자화, 프루닝, 지식증류5 / 22← 이전목차다음 →MetaContents

15장. 모델 경량화: 양자화, 프루닝, 지식증류

양자화의 원리

온도계의 눈금을 10도 간격으로만 읽으면, 23도는 20도로 읽힙니다. 양자화는 실수를 정해진 간격의 눈금에 맞춰 반올림하는 일입니다. 간격(스케일)은 가장 큰 값이 마지막 눈금에 오도록 정합니다.

가장 큰 절댓값으로 스케일을 정하고, 나눠서 반올림해 정수로 바꾸고, 저장하고, 쓸 때 스케일을 곱해 되돌리는 네 단계 흐름도

1번에서 가중치 묶음의 가장 큰 절댓값을 정수의 가장 큰 값(int8 이면 127)으로 나눠 스케일을 정합니다. 2번에서 가중치를 스케일로 나눠 반올림해 정수로 만듭니다. 3번에서 정수와 스케일만 저장합니다. 4번에서 쓸 때 정수에 스케일을 곱해 되돌립니다. 반올림한 만큼 오차가 남습니다. 여기서 온도계의 눈금 간격이 스케일입니다.

스케일을 어디마다 두나

방법스케일 하나가 맡는 것오차더 드는 메모리
텐서마다가중치 행렬 전체크다. 튀는 값 하나가 간격을 넓힌다거의 없다
행마다(채널마다)출력 하나의 가중치 줄중간행 수만큼
그룹마다(예: 128개)128개 묶음작다128개마다 스케일 하나(4비트면 약 0.125비트 더)

직접 양자화해 보기

Python
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 다
/ 127int8 의 가장 큰 값 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() / 1272.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 을 일부러 넣었습니다.

Python
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비트 양자화는 대개 그룹 양자화와 함께 씁니다.
← 이전 절15장 · 숫자의 정밀도다음 절 →15장 · 양자화를 하는 때와 도구