양자화(Quantization) — 모델을 가볍고 빠르게
양자화(Quantization) — 모델을 가볍고 빠르게
모델이 커질수록 추론할 때 필요한 메모리와 연산 비용도 같이 커져요. 이걸 줄이는 대표적인 기법이 바로 양자화(Quantization) 인데요, 가중치와 활성값을 보통 32비트 부동소수점(float32) 대신 8비트 정수(int8) 같은 저정밀 데이터 타입으로 표현하는 방법이에요. 비트 수를 줄이면 모델을 저장하는 데 쓰는 메모리가 줄고, 정수 연산이라 행렬 곱셈 같은 계산도 훨씬 빨라져요. 지금부터 양자화가 정확히 어떻게 동작하는지, 어떤 경우에 어떤 방법을 골라야 하는지 강의하듯 차근차근 풀어볼게요.
양자화의 기본 아이디어
양자화의 핵심은 아주 단순해요. 가중치와 활성값을 높은 정밀도(보통 float32)에서 낮은 정밀도 데이터 타입으로 옮기는 것이 전부예요. 흔히 쓰는 저정밀 타입은 이렇게 있어요.
float16, 누적(accumulation) 데이터 타입float32bfloat16, 누적 데이터 타입float32int16, 누적 데이터 타입int32int8, 누적 데이터 타입int32
여기서 누적 데이터 타입은 말 그대로 그 타입의 값들을 더하고 곱하는 연산 결과를 담는 타입이에요. 예를 들어 int8 값 두 개, A = 127과 B = 127을 더한다고 해볼게요.
C = A + B
결과는 int8이 표현할 수 있는 최댓값인 127을 훨씬 넘어버려요. 이때 결과를 담을 더 큰 정밀도의 타입이 없으면 정밀도 손실이 커져서 양자화 자체가 무의미해지죠. 그래서 누적 타입은 따로 지정하는 거예요.
float32 -> float16 양자화
가장 흔한 두 가지 양자화는 float32 -> float16과 float32 -> int8이에요. float16으로 가는 경우는 두 타입이 같은 표현 방식을 따르기 때문에 비교적 간단해요. 다만 연산을 float16으로 바꿀 때 스스로 물어볼 질문이 있어요.
- 내 연산에
float16구현이 있나요? - 내 하드웨어가
float16을 지원하나요? 예를 들어 Intel CPU는float16을 저장 타입으로는 지원하지만 계산은float32로 변환해서 수행해요. 완전한 지원은 Cooper Lake와 Sapphire Rapids에서 나온답니다. - 내 연산이 저정밀도에 민감하지 않나요?
마지막 질문이 실제로 터지는 지점이에요. LayerNorm의 epsilon 값은 보통 1e-12처럼 아주 작은데, float16이 표현할 수 있는 가장 작은 값은 대략 6e-5거든요. 그러면 NaN 문제가 생길 수 있어요. 큰 값도 마찬가지예요.
float32 -> int8 양자화와 affine 기법
int8로 가는 건 좀 더 까다로워요. int8은 256개의 값만 표현할 수 있는데 float32는 아주 넓은 범위의 값을 담을 수 있거든요. 핵심은 [a, b] 범위의 float32 값을 int8 공간으로 잘 투영(project)하는 방법을 찾는 거예요. 이를 affine 양자화 기법이라고 불러요.
x = S * (x_q - Z)
x_q는x에 대응하는 양자화된int8값S는 스케일(scale), 양수인float32Z는 제로 포인트(zero-point),float32영역의0에 대응하는int8값
0을 정확히 표현할 수 있는 게 왜 중요하냐면, 머신러닝 모델 곳곳에서 0이 쓰이기 때문이에요. [a, b] 범위에 있는 x의 양자화 값은 이렇게 구해요.
x_q = round(x/S + Z)
범위 밖의 float32 값은 표현 가능한 가장 가까운 값으로 잘라내요(clip).
x_q = clip(round(x/S + Z), round(a/S + Z), round(b/S + Z))
대칭(symmetric) 양자화
affine 기법의 흔한 특수 사례가 대칭 양자화예요. [-a, a]처럼 대칭 범위만 고려하는 방식인데, 이때 정수 공간은 보통 [-127, 127]을 써요. 정규 int8 범위인 [-128, 127]에서 -128을 빼는 거죠. 범위가 대칭이면 Z = 0이 되거든요. 표현 가능한 256개 값 중 하나를 잃는 대신 더하기 연산을 많이 생략할 수 있어서 속도가 빨라져요.
per-tensor vs per-channel
양자화 파라미터의 입자(granularity) 도 조절할 수 있어요.
- per-tensor: 텐서 하나에
(S, Z)쌍 하나를 사용 - per-channel: 텐서의 어느 한 차원을 따라 원소별로
(S, Z)쌍을 저장. 예를 들어[N, C, H, W]모양 텐서의 두 번째 차원에 per-channel을 적용하면C개의(S, Z)쌍이 생겨요. 정확도는 좋아지지만 메모리는 더 필요하죠.
캘리브레이션(Calibration)
[a, b] 범위는 어떻게 정할까요? 바로 캘리브레이션이라는 단계에서 정해져요. 가중치는 양자화 시점에 실제 범위를 알 수 있어서 쉬운데, 활성값은 그렇지 않아요. 접근법이 세 가지예요.
- 포스트 트레이닝 동적 양자화(Post training dynamic): 활성값 범위를 런타임에 그때그때 계산해요. 별다른 노력 없이 좋은 결과를 주지만, 매번 범위를 계산하는 오버헤드 때문에 정적 양자화보다 느릴 수 있고, 하드웨어에 따라선 아예 지원되지 않아요.
- 포스트 트레이닝 정적 양자화(Post training static): 활성값 범위를 양자화 시점에 미리 계산해요. 대표 데이터를 모델에 통과시키며 활성값을 기록하는 방식이죠. 옵저버(observer)를 활성값에 붙여 값을 기록하고, 캘리브레이션 데이터셋으로 수십~수백 번(약
200개 예시면 충분) 포워드 패스를 돌린 뒤, 어떤 캘리브레이션 기법으로 각 연산의 범위를 계산해요. - 양자화 인지 학습(Quantization aware training, QAT): 활성값 범위를 학습 시점에 계산해요. 정적 양자화와 같은 아이디어지만, 옵저버 대신 fake quantize 연산자를 써서 값만 기록하는 게 아니라 양자화로 인한 오차를 모델이 스스로 적응하도록 시뮬레이션해요.
캘리브레이션 기법으로는 이런 것들이 가장 흔해요.
- Min-max: 범위를
[관측 최솟값, 관측 최댓값]으로 잡아요. 가중치에 잘 맞아요. - Moving average min-max: 이동평균 기반의 최소·최댓값. 활성값에 잘 맞아요.
- Histogram: 값의 히스토그램을 기록해 두고 기준에 따라 선택해요.
- Entropy: 전체 정밀도와 양자화된 데이터 사이 오차를 최소화하는 범위
- Mean Square Error: 둘 사이의 평균 제곱 오차를 최소화하는 범위
- Percentile: 관측값의
p%가 범위 안에 들어오도록 특정 백분위수p로 결정. affine 양자화에선 가능하지만 대칭 양자화에선 정확히 맞추기 어려울 때가 있어요.
int8 양자화 실전 순서
실제로 모델을 int8로 양자화할 땐 이 순서를 밟아요.
- 어떤 연산자를 양자화할지 고른다. 선형 투영(linear projection)이나 행렬 곱셈처럼 계산 시간을 지배하는 연산이 좋아요.
- 포스트 트레이닝 동적 양자화를 먼저 시도한다. 충분히 빠르면 여기서 멈추고, 아니면 3번으로.
- 포스트 트레이닝 정적 양자화를 시도한다. 동적보다 빠를 수 있지만 정확도가 떨어지기 쉬워서, 양자화할 자리에 옵저버를 붙여요.
- 캘리브레이션 기법을 고르고 수행한다.
- 모델을 양자화 형태로 변환한다. 옵저버를 제거하고
float32연산자를int8대응 연산자로 바꿔요. - 양자화된 모델을 평가한다. 정확도가 충분하면 끝, 아니면 이번엔 양자화 인지 학습(QAT)으로 3번부터 다시.
🤗 Optimum에서 양자화를 지원하는 도구들
Optimum은 대상에 따라 서로 다른 도구로 양자화 API를 제공해요.
optimum.onnxruntime패키지 — ONNX Runtime 도구로 ONNX 모델을 양자화하고 실행optimum.intel패키지 — 정확도와 지연 시간 제약을 지키면서 Transformers 모델을 양자화optimum.fx패키지 — PyTorch 양자화 함수를 감싸 그래프 모드 양자화를 지원. 위 두 개보다 저수준이라 유연성은 크지만 작업량이 더 필요해요.optimum.gptq패키지 — GPTQ로 LLM을 양자화하고 실행