양자화 (Quantization)와 반올림 (Rounding)
배경
LLM을 비롯한 AI Application의 배포 타겟은 Cloud 기반에서 실행되는 서버 향도 존재하지만, 여러 장점 (개인 프라이버시, 저전력/저발열 등)을 가지고 있는 On-device도 활발히 연구/개발 중인 분야입니다. 특히 상대적으로 연산의 성능이나 메모리 제약으로 인해 On-device AI의 경우 양자화 (Quantization)을 높은 비율로 채용하게 되는데요. 양자화는 결국 정보 손실을 필연적으로 가지므로 Application의 성능 (Accuracy 등)에서 손해를 어느 정도는 감수할 수밖에 없습니다. 문제는 매번 여러 가지 양자화 실험을 한 후에 타겟 Device에 모델을 배포하고 성능을 측정하는 것이 굉장히 귀찮고 번거로운 작업일 텐데요. 따라서, 직접 하드웨어까지 배포하지 않더라도 양자화를 통해서 하드웨어에 배포했을 때의 효과를 시뮬레이션하는 도구들이 많이 존재합니다. 흔히 인기 있는 딥러닝 프레임워크인 PyTorch나 TensorFlow에서도 자체적으로 양자화 시뮬레이션을 지원하며, 특정 하드웨어 제조사 (Qualcomm 등) 역시 해당 하드웨어에 모델을 배포했을 때의 양자화 시뮬레이션을 해볼 수 있는 AIMET과 같은 도구를 오픈소스로 공개하고 있습니다. 즉, 시뮬레이터는 하드웨어의 동작을 그대로 모사할 수 있어야 하는데요. 이번 글의 동기는 시뮬레이터의 결과와 실제 하드웨어에 배포했을 때의 결과가 차이가 나는지에 대해서 팀원이 조사해서 발견한 원인에 대해 이야기해 보려고 합니다. 이런 차이가 발생하는 데는 다양한 이유가 있지만, 그중 하나였던 반올림 방식의 차이가 문제였는데요. 반올림 방식에 관해서 얘기하기 전에 양자화 (Quantization)에 대해서 먼저 간략하게 설명하는 것이 이해에 도움이 될 것 같습니다.
양자화 (Quantization) 개념
양자화라는 개념은 사실 머신러닝 외에도 디지털 신호처리 등 다양한 분야에서 사용되고 있었는데요. 간단하게 얘기하면 아래 그림과 같이 연속적인 값은 유한한 이산값들로 근사하는 과정이라고 얘기할 수 있습니다.

일반적인 머신러닝 모델들은 FP32/FP16 부동소수점 데이터 타입을 하고 있고, 이런 연속적인 값을 INT8/INT4와 같이 이산값으로 변환하여 에너지 소모 (사람도 정수 덧셈/곱셈이 실수 덧셈/곱셈보다 쉽죠?), 메모리 사용량 (FP32 -> INT4, 8배 이득) 등 스마트폰이나 Embedded 장비처럼 자원이 제약된 환경에서 애플리케이션을 잘 실행할 수 있도록 도와줄 수 있게 됩니다. 그렇다면 어떻게 실수형 연속변수를 정수형 연속변수로 변환하는지를 살펴보겠습니다. 사실 변환하는 수식은 그렇게 어렵지 않은데요. 기본적으로는 아래와 같습니다.

우선 변환할 이산변수의 bitwidth를 결정하고 (e.g., INT4라면 b=4), 양자화 파라미터인 scale과 zero point를 계산하면 연속 변수를 이산 변수로 변환할 수 있습니다.

Scale은 위와 같은 식으로 구할 수 있고, 예를 들어 현재 데이터의 분포가 [-100, 100] 사이의 값이고 INT4로 변환한다면 Scale 값은 (100 - (-100)) / (15 - 0) = 13.33과 같이 계산할 수 있습니다. Zero point 역시 아래처럼 구할 수 있는데요

위와 같은 과정을 통해서 Scale과 Zero point를 알고 있다면 이제 X라는 데이터에 대해서 Scale을 나눠준 후 반올림 연산을 하고 Zero point를 더한 후 최종적으로는 우리가 표현할 수 있는 범위 [0, 2^b - 1] 사이로 clamp를 하는 것이 전부입니다. 기본적인 양자화에 대한 수식은 이렇고, 오차를 최소화하기 위해서 단순히 min/max가 아닌 방식을 사용하거나 분포에 따라 (특히 weight 등) 연속 변수 0을 이산 변수 0으로 대응하는 symmetric quantization 같은 방식들이 있으나 해당 내용은 이번 포스트에서 다루고자 하는 내용에서는 조금 벗어나므로 키워드만 남겨놓습니다. 설명했던 것처럼 반올림 연산들이 포함되어있는데요. 시뮬레이터 상의 결과와 하드웨어 결과가 달랐던 원인 중 하나가 나중에 확인해보니 반올림 방식의 차이때문이었습니다.
반올림 (Rounding) 방식
반올림 방식은 한 두개가 아니라 너무 다양하지만, 이번 포스트에서는 크게 2가지 Rounding half to even과 Rounding half away from zero 2가지 경우에 대해서만 얘기하려고 합니다. 전자는 Python에서의 기본 Rounding 방식이며, 후자는 C에서의 Rounding 방식인데요. Rounding half to even은 5를 초과하는 값은 올림, 5 미만은 버림하며 마지막으로 5일 경우 앞자리 숫자가 짝수면 버리고 홀수면 올림하여 짝수로 맞춰줍니다. 이 방식은 통계적으로 오차가 적은 것으로 알려져있기 때문에 자연과학이나 공학에서 널리 쓰이는 방식이며 IEEE 754의 부동소수점 연산의 반올림 표준이라고 합니다. 반대로 Rounding half away from zero는 우리가 일반적으로 알고 있는 반올림 연산이라고 생각할 수 있습니다. 0에서 멀어지는 방향으로 반올림하므로 2.5는 3으로 -2.5는 -3으로 반올림하는 것이죠. 또한 구현 역시 상대적으로 더 간단한 편입니다. 파이썬에서는 아래와 같이 해당 반올림 방식을 모사해볼 수 있습니다.
def c_round(x):
return int(x + (0.5 if x >= 0 else -0.5))
-3.5부터 3.5사이의 값을 각각의 방식으로 반올림 했을 때 결과는 아래와 같습니다

한 눈에 봐도 차이가 보이죠? 다시 양자화 수식을 떠올려볼까요?

즉, 작은 bitwidth일 수록 rounding 방식에 따라 그 차이가 굉장히 벌어질 수 있음을 이제는 눈치챌 수 있을 것 같습니다. 어찌 보면 사소하지만, 큰 차이를 만들어내는 나비효과로도 볼 수 있겠네요! 시뮬레이터와의 결과 차이가 나는 이유는 이것 하나만은 아니었지만, 여러분들도 양자화 시뮬레이션을 한다면 타겟 장치에서의 반올림 연산이 어떤 방식으로 동작하는지 미리 확인해 보면 불필요한 차이 중 하나를 예방할 수 있을 것입니다.
References
- Nagel, Markus, et al. "A white paper on neural network quantization." arXiv preprint arXiv:2106.08295 (2021).
- https://www.mathsisfun.com/numbers/rounding-methods.html
- https://en.wikipedia.org/wiki/Rounding#Rounding_to_the_nearest_integer
- https://hanlab.mit.edu/courses/2024-fall-65940