트랜스포머 — 어텐션을 손으로 계산한다 · 정수 양자화가 정확도에 주는 영향 · 이론
int8 로 접었다 펴면 무엇이 얼마나 달라지는가
한 줄 요약
양자화는 실수를 배율 하나와 정수 하나로 바꾸는 일이고, 잃는 것은 배율이 정한다. 그 배율을 정하는 것은 목록 안에서 가장 큰 절대값 하나다.
왜 이게 필요했나
모델을 int8 로 바꾸면 가중치가 4분의 1로 줄고 정수 곱셈기를 쓸 수 있다. 그래서 다들 한 번은 해 본다. 문제는 그다음이다 — 정확도가 조금 떨어지는데 어디서 떨어졌는지 말할 수가 없다.
도구는 한 줄로 끝난다. 양자화 함수를 부르면 모델이 나온다. 그 안에서 무슨 산술이 일어났는지 모르면 할 수 있는 일은 옵션을 바꿔 가며 다시 돌려 보는 것뿐이고, 그건 고치는 게 아니라 운을 시험하는 것이다.
그래서 이 글과 다음 실습은 도구를 쓰지 않는다. 값 여덟 개짜리 목록 하나를 손으로 접었다 펴 보는 것에서 시작한다. 거기서 보이는 것이 큰 모델에서도 그대로 보인다.
접는다는 게 무슨 뜻인가
실수 목록 하나를 int8 로 옮기려면 두 가지를 정해야 한다. 배율(scale) 과 영점(zero point) 이다.
가장 단순한 것이 대칭 양자화다. 배율을 max(|x|) / 127 로 잡고, 값을 배율로 나눠 반올림한다. 가장 큰 절대값이 코드 127 에 닿고, 0 은 코드 0 에 그대로 떨어진다.
scale = max(abs(x) for x in values) / 127codes = [round(x / scale) for x in values] # -127 부터 127 까지back = [c * scale for c in codes] # 편 값. 원본이 아니다비대칭(어파인) 양자화는 값이 한쪽으로 쏠렸을 때 쓴다. 폭을 (max - min) / 255 로 잡아 256칸을 다 쓰고, 실수 0 이 어느 코드에 떨어지는지를 영점으로 따로 들고 다닌다. 복원은 (code - zero_point) * scale 이다. [정수 산술만으로 추론하는 방법을 정리한 논문](https://arxiv.org/abs/1712.05877)이 이 식을 그대로 쓴다.
두 방식 모두 잃는 것은 같은 자리에 있다. 배율의 절반이다. 배율이 0.007 이면 어떤 값이든 최대 0.0035 만큼 틀어진다. 값이 크든 작든 똑같이 그만큼이다.
반올림부터 못 박아야 한다
여기서 사람들이 먼저 걸린다. 파이썬의 round 는 학교에서 배운 반올림이 아니다.
round(0.5) # 0 — 1 이 아니다round(1.5) # 2round(2.5) # 2 — 3 이 아니다정확히 0.5 인 자리를 짝수 쪽으로 보낸다. 0.5 를 늘 위로 올리면 반올림한 값들의 평균이 조금씩 위로 밀리기 때문에 이렇게 정해져 있다. 양자화는 배열 전체에 반올림을 한 번씩 하는 일이라, 이 밀림이 그대로 모델의 편향이 된다.
문제는 규칙이 아니라 규칙이 두 가지라는 사실이다. 어떤 구현은 짝수로 보내고 어떤 구현은 0 에서 먼 쪽으로 보낸다. 같은 가중치를 같은 배율로 접었는데 코드가 한 칸씩 다르면, 그 뒤의 모든 비교가 의미를 잃는다. 그래서 양자화 코드를 읽을 때 배율 식보다 먼저 확인할 것이 반올림 규칙이다.
배율을 정하는 것은 값 하나다
대칭 양자화의 배율은 max(|x|) / 127 이다. 이 식에는 평균도 분산도 없다. 가장 큰 절대값 하나가 전부다.
그래서 목록에 유난히 큰 값이 하나 섞이면, 그 하나가 나머지 전부의 정밀도를 정한다. 나머지가 전부 1 아래인데 하나가 42 라면 배율이 42/127 이 되고, 1 아래의 값들은 코드 0 부터 3 사이로 뭉개진다. 256칸을 두고도 네 칸만 쓰는 것이다.
이것이 [LLM.int8() 논문](https://arxiv.org/abs/2208.07339)의 출발점이다. 큰 언어 모델의 은닉 상태에는 다른 값보다 훨씬 큰 성분이 나타나는데, 그 몇 개 때문에 나머지 전부가 못 쓰게 된다는 관찰이었다. 해결의 방향도 거기서 나온다 — 배율을 더 작은 단위로 잡거나, 큰 것들을 따로 빼내거나.
배율을 더 작은 단위로 잡는 쪽이 먼저 할 일이다. 행렬 전체를 배율 하나로 접는 대신 행마다(또는 열마다) 배율을 따로 잡으면, 작은 행이 큰 행에 끌려가지 않는다. 다만 공짜는 아니다. 배율을 행마다 들고 다녀야 하고, 행렬 곱이 성립하려면 배율이 행이나 열 단위여야 한다. 값마다 배율이 다르면 정수 누적 안에서 배율을 빼낼 수 없다.
정수로 곱하면 무엇이 남는가
정수 추론의 핵심은 곱셈과 누적이 전부 정수라는 것이다. 코드끼리 곱하고 더하는 동안에는 반올림이 한 번도 일어나지 않는다. 다 더한 뒤 마지막에 배율 두 개를 곱해 실수로 편다.
acc = sum(a_code[k] * b_code[k] for k in range(d)) # 정수만value = acc * a_scale * b_scale # 마지막에 한 번그래서 행렬 곱에서 생기는 오차는 누적에서 불어난 것이 아니라 처음 접을 때 이미 생긴 것이다. 원인을 찾을 자리가 하나뿐이라는 뜻이고, 이건 좋은 소식이다.
남은 질문은 그 오차가 뒤에서 어떻게 되느냐다. 어텐션 점수는 소프트맥스를 지나 확률이 된다. 소프트맥스는 차이를 지수로 벌리므로, 점수의 작은 오차가 확률에서 커질 수도 있고 오히려 묻힐 수도 있다. 어느 쪽인지는 재 보기 전에는 말할 수 없다. 다음 실습에서 직접 잰다.
현장에서 만나는 모습
첫째, 정확도가 조금 떨어졌는데 어디서 떨어졌는지 모른다. 도구가 한 줄이라 안쪽을 볼 눈이 없다. 배율·반올림·단위 중 무엇을 바꿨을 때 무엇이 움직이는지 손으로 한 번 해 본 사람만 짚을 수 있다.
둘째, 같은 모델을 두 도구로 양자화했더니 결과가 다르다. 배율 식이 같아도 반올림 규칙이 다르면 코드가 한 칸씩 어긋난다. 어느 쪽이 맞는지가 아니라 무엇이 다른지를 먼저 확인해야 한다.
셋째, 텐서 단위로 접었더니 특정 층에서만 무너진다. 그 층의 가중치 분포에 유난히 큰 값이 있는 경우다. 전체 평균 오차는 멀쩡해 보이는데 작은 행들의 상대 오차만 폭발한다.
넷째, 활성값(activation) 이 가중치보다 훨씬 까다롭다. 가중치는 고정이라 한 번 재면 끝이지만 활성값은 입력마다 달라진다. 보정(calibration) 자료로 범위를 재는데, 그 자료가 실제 입력과 다르면 배율이 틀어진다.
다섯째, 크기와 속도를 같은 것으로 말한다. 파일이 4분의 1이 된 것과 실제로 빨라진 것은 다른 일이다. 정수 커널이 실제로 돌았는지는 따로 확인해야 한다.
실무에서 진짜 중요한 것
- 배율이 무엇으로 정해지는지 먼저 본다.
max(|x|)하나면, 그 하나를 찾는 것이 진단의 시작이다. - 반올림 규칙을 문서에 적어 둔다. 두 구현을 견줄 때 가장 먼저 어긋나는 자리다.
- 평균 오차 말고 작은 쪽의 상대 오차를 본다. 최대 절대 오차는 가장 큰 행이 정하므로 문제를 가린다.
- 단위를 키우기 전에 좁혀 본다. 텐서 하나에서 행 단위로 내리는 것만으로 풀리는 경우가 많다.
- 뒤에서 무엇이 되는지 재 본다. 점수의 오차가 확률에서 커지는지 묻히는지는 짐작할 것이 아니라 재는 것이다. [Attention Is All You Need](https://arxiv.org/abs/1706.03762) 의 점수 계산이 어디로 흘러가는지 알고 있으면 잴 자리도 보인다.
다음 실습에서 할 것
/root/work/tf-quant/quant.py 를 한 단계씩 키운다. 도구를 부르지 않고 산술을 직접 한다 — 이 파드의 시스템 파이썬에는 numpy 가 없다(numpy 는 /opt/onnx-lab 안에만 있다). 그래서 목록과 목록의 목록을 표준 라이브러리로 다룬다. [파이썬 math 모듈](https://docs.python.org/3/library/math.html) 의 floor·exp 면 충분하다.
반올림 규칙 두 가지를 만들어 어디서 갈리는지 확인하는 것에서 시작한다. 그다음 대칭·비대칭 양자화를 만들고, 접었다 편 값이 원본에서 얼마나 벌어졌는지 재는 자를 만든다. 여기까지가 절반이다.
나머지 절반이 이 실습의 요점이다. 평범한 값들에 큰 값 하나를 붙여 놓고 나머지 값들이 몇 칸으로 줄어드는지 센다. 행마다 폭이 크게 다른 행렬을 두고 텐서 단위와 행 단위를 나란히 재 본다. 코드끼리 정수로만 곱해 누적이 정확히 맞는지 확인하고, 마지막으로 그 오차가 소프트맥스를 지난 뒤 확률에서 얼마가 되는지 잰다. 이상치를 심은 키로 한 번 더 재서 앞에서 본 일이 어텐션에서 어떻게 나타나는지 본다.
채점기는 여러분이 적어 둔 설명을 믿지 않는다. 여러분의 모듈을 실제로 불러 매번 다른 입력으로 함수를 두드려 보고, 채점기가 따로 계산한 값과 대조한다. 대조의 대부분은 정수 배열끼리다 — 코드 한 칸이 어긋나면 바로 드러난다.