[LLM] 양자화 기초: 가중치 저장과 W4A16 계산 과정
양자화의 scale, zero-point, group size와 W4A16 계산 과정을 살펴보고 메모리 절약이 추론 속도에 미치는 영향을 정리합니다.
모델을 실행할 때 AWQ, INT4, W4A16 같은 양자화 설정을 접하게 됩니다.
비트 수를 줄이면 메모리를 아낄 수 있지만 저장된 가중치를 어떻게 계산에 사용하는지까지 구분할 필요가 있습니다.
양자화의 기본 설정과 4비트 가중치가 계산에 사용되는 과정을 정리했습니다.
1. 가중치와 활성값
가중치는 모델이 학습한 파라미터입니다.
활성값은 입력을 처리하면서 생기는 숫자로, 입력의 숫자 표현과 각 층의 중간 계산값을 포함합니다.
y = w × x
w: 학습된 가중치
x: 입력 활성값
y: 계산 결과인 활성값
가중치 양자화는 w를 적은 비트로 표현하는 작업입니다.
활성값 양자화는 입력마다 생기는 x, y 같은 값을 낮은 정밀도로 표현합니다.
16비트 가중치를 4비트로 바꿔도 파라미터 개수는 유지됩니다.
각 값을 저장하는 공간이 줄어들며 표현 가능한 값에 맞춰 근사하는 과정에서 오차가 발생합니다.
2. Scale과 Zero-point
4비트로 표현할 수 있는 비트 패턴은 16개입니다.
정수 코드 0~15를 저장한다고 가정하면 각 코드가 나타내는 값을 정하는 규칙이 필요합니다.
근사값 = (저장 코드 − zero-point) × scale
- scale: 눈금 사이의 간격
- zero-point: 실제 값 0에 대응하는 저장 코드
아래는 균일한 눈금을 사용하는 정수 양자화의 설명용 예시입니다.
모든 4비트 형식이 이 규칙을 사용하는 것은 아닙니다.
| 설정 | 코드 0 | 코드 8 | 코드 15 |
|---|---|---|---|
| scale 0.1, zero-point 0 | 0 | 0.8 | 1.5 |
| scale 0.1, zero-point 8 | −0.8 | 0 | 0.7 |
| scale 0.2, zero-point 8 | −1.6 | 0 | 1.4 |
zero-point를 바꾸면 눈금의 위치가 달라집니다.
scale을 키우면 더 넓은 범위를 담을 수 있지만 눈금 간격이 벌어져 작은 차이를 표현하기 어려워집니다.
반올림과 Clipping
scale 0.1, zero-point 8을 적용하면 표현 범위는 −0.8~0.7입니다.
0.34 → 가까운 눈금 0.3으로 근사 → 코드 11 저장
0.90 → 표현 범위 밖이므로 0.7로 제한 → 코드 15 저장
0.34에서는 반올림 오차가 생기고 0.9에서는 범위 밖 값을 자르는 clipping이 발생합니다.
범위를 넓히면 clipping을 줄일 수 있지만 같은 비트 수에서는 눈금이 성겨집니다.
Scale과 zero-point의 정의는 Hugging Face 양자화 개념 가이드에서 확인할 수 있습니다.
3. Group size
Group size는 같은 scale을 공유하는 가중치 묶음의 크기입니다.
큰 값과 작은 값이 같은 그룹에 있으면 큰 값까지 담기 위해 눈금 간격이 넓어질 수 있습니다.
그룹을 나누면 각 묶음에 맞는 scale을 정할 수 있습니다.
가중치 128개
Group size 128 → 128개가 scale 하나 공유
Group size 32 → 32개씩 4개 그룹, scale도 4개
가중치 개수는 그대로이고 scale 개수만 늘어납니다.
실제 구현에서는 보통 행렬의 정해진 축을 따라 묶습니다. 값의 크기대로 자유롭게 분류하는 것은 아닙니다.
128개 가중치를 4비트로 저장하고 그룹마다 16비트 scale만 추가한다고 가정하면 다음과 같습니다.
| Group size | 가중치 저장량 | Scale 저장량 | 합계 |
|---|---|---|---|
| 128 | 64바이트 | 2바이트 | 66바이트 |
| 32 | 64바이트 | 8바이트 | 72바이트 |
다른 부가 정보는 제외한 계산입니다.
작은 그룹은 근사 오차를 줄일 여지가 있지만 scale을 더 많이 저장하고 읽어야 하므로 속도가 자동으로 빨라지지는 않습니다.
4. W4A16의 계산 과정
W4A16은 가중치 4비트, 활성값 16비트를 뜻합니다.
대표적인 실행 방식은 필요한 가중치를 읽어 FP16/BF16으로 변환한 뒤 활성값과 계산하는 것입니다.
4비트로 저장된 가중치 읽기
→ 필요한 부분을 계산용 정밀도로 변환
→ 16비트 활성값과 계산
→ 다음 활성값 생성
모델 전체를 16비트로 풀어 GPU 메모리에 보관할 필요는 없습니다.
또한 곱한 값을 더해 나가는 누산과 일부 중간값은 별도 정밀도를 사용할 수 있습니다.
Marlin은 4비트 가중치의 변환과 FP16 행렬곱을 함께 최적화한 구현입니다.
앞에서 0.34를 코드 11로 저장했다면 계산에 사용하는 값은 다음과 같습니다.
(11 − 8) × 0.1 = 0.3
16비트로 변환해도 원래 값 0.34가 돌아오지는 않습니다.
정보는 처음 양자화할 때 이미 손실됐기 때문입니다.
이진 부동소수점으로 0.3을 표현하는 작은 오차도 있지만 이는 앞선 양자화 오차와 구분합니다.
5. 메모리와 실행 속도
가중치를 메모리에서 읽어 오는 시간이 병목이라면 읽을 데이터가 줄어드는 것만으로 속도를 개선할 수 있습니다.
대신 비트를 풀고 scale을 적용하는 추가 처리가 필요합니다.
읽기와 변환을 순서대로 수행한다고 단순화한 예시입니다.
16비트: 읽기 8ms → 계산 전까지 8ms
4비트: 읽기 2ms + 변환 1ms → 계산 전까지 3ms
읽기에서 6ms를 아끼고 변환에 1ms를 사용해 5ms가 줄었습니다.
반대로 읽기에서 1ms만 절약하고 변환에 2ms가 추가되면 실행은 느려집니다.
실제 GPU에서는 읽기·변환·계산이 겹치므로 위 숫자는 실측 결과가 아닌 원리 설명용입니다.
4비트로 저장하더라도 전체 GPU 메모리가 정확히 ¼이 되지는 않습니다.
Scale, KV cache, 중간 계산값 등도 메모리를 사용합니다.
속도 역시 GPU와 실행 커널, 가중치 읽기와 계산 중 어느 쪽이 병목인지에 따라 달라집니다.
다음 글에서는 AWQ, GPTQ, SmoothQuant가 가중치와 활성값의 양자화 오차를 각각 어떻게 줄이는지 정리하겠습니다.