개요

38편에서 이런 이야기를 했다. 가속기가 모르는 연산이 모델 안에 하나라도 있으면, 그 부분만 CPU로 되돌아간다. 그리고 그 왕복이 제일 비싸다고.

그때는 그 “모르는 연산”을 연산자 종류 문제로만 봤다. 커스텀 연산을 직접 짜 넣었다거나, opset 버전이 안 맞는다거나.

그런데 자료를 더 보니 그게 아니었다. 연산자 이름이 멀쩡해도 걸리는 게 있었다. 가속기는 소수를 모른다. 아무리 흔한 곱셈이라도 소수가 끼어 있으면 그 계층은 못 맡는다.

38편에서 NPU와 TPU를 “행렬 곱 전용 회로”라고만 소개하고 넘어갔는데, 정확히는 정수 행렬 곱 전용이었던 것이다.

35편에서 양자화를 배울 때는 “32비트를 8비트로 줄인다” 정도로 이해하고 넘어갔다. 그런데 그게 끝이 아니었다. 값을 정수로 바꿔 저장해놔도, 계산하는 도중에 실수가 되살아난다. 그리고 그 순간 가속기는 손을 뗀다.

이번 글이 답할 질문 양자화를 했는데도 왜 실수 연산이 남아있고, 그걸 어떻게 끝까지 없애는가.

교육 과정 자료는 대외비라 원본 자료는 싣지 않는다.


1. 가속기는 정수만 안다 — 모르면 CPU로 돌려보낸다

자료가 실제 칩 두 개를 예로 들었다.

가속기 지원하는 것 못 하는 걸 만나면
Google Coral Edge TPU 완전 8비트 정수 모델만 해당 연산을 CPU로 넘긴다
ARM Ethos-U 시리즈 (U55/U65/U85) 8비트·16비트 정수 모델 해당 연산을 CPU로 넘긴다

“완전(fully) 8비트 정수 모델만” 이라는 표현이 핵심이다. 일부가 아니라 전부여야 한다.

이게 왜 이렇게 빡빡하냐면, 이 칩들은 정수 곱셈·덧셈 회로만 잔뜩 박아놓은 물건이기 때문이다. 실수를 다루는 회로 자체가 없다. 38편에서 본 그 이야기 — 전용 칩은 한 가지만 잘하는 대신 다른 걸 아예 못 한다.

정수 8개 중 7개가 정수라서 87.5점을 받는 시험이 아니다. 하나라도 실수면 0점이고, 그 계층은 CPU로 간다.


2. 일반 양자화의 함정 — 저장은 정수인데 계산은 실수다

35편에서 배운 양자화 식을 다시 꺼내자. 실수 r을 정수 q로 담는 방법이었다.

$$ r = S (q - Z) $$

  • S스케일. 실수 한 칸이 정수 한 칸으로 얼마나 줄어드는지의 비율
  • Z오프셋(영점). 실수 0이 정수 몇 번에 앉는지

관심 범위를 r_min부터 r_max까지로 정해놓고, 8비트 정수 0~255에 나눠 담으면 이렇게 정해진다.

$$ S = \frac{r_{max} - r_{min}}{2^8 - 1}, \quad Z = -\frac{r_{min}}{S} $$

여기서 문제가 시작된다. Z는 정수인데 S는 실수다.

예를 들어 값이 -3.0에서 3.0 사이에 있다면 S는 6/255 = 0.0235… 가 된다. 딱 떨어지지 않는다. 애초에 실수를 정수 칸에 우겨넣는 게 목적이었으니, 그 비율이 실수인 건 어찌보면 당연한 말 이지만.

그래서 이런 일이 벌어진다.

저장은 정수인데 계산에 실수가 섞인다

정수끼리 곱하고 더해서 결과를 냈는데, 그 결과를 원래 크기로 되돌리려면 S를 곱해야 한다. 그 순간 실수 연산이 끼어든다.

더 쉬운 비유로

재료를 전부 그램 단위 정수로 계량했다. 그런데 레시피가 “마지막에 0.0235를 곱하시오” 라고 써 있다.

앞에서 아무리 깔끔하게 정수로 재도, 마지막 한 줄 때문에 저울이 아니라 계산기가 필요해진다. 가속기 입장에선 그 계산기가 없다.

값이 정수인 것과 계산이 정수인 것은 다른 이야기다. 100% 정수연산 양자화가 노리는 건 뒤쪽이다.


3. 행렬 곱 — 실수 세 개를 하나로 묶는다

그럼 실수를 어떻게 없앨까. 자료가 행렬 곱을 예로 들어 차근차근 유도했는데, 이 전개가 이번 편에서 제일 재밌었다.

상황은 이렇다. 실수 행렬 r1r2를 곱해서 r3를 만든다. 그런데 셋 다 각자 자기 양자화 규격을 갖고 있다.

  • r1(S1, Z1)을 써서 q1으로
  • r2(S2, Z2)를 써서 q2
  • r3(S3, Z3)을 써서 q3

우리가 알고 싶은 건 q3를 정수만으로 구하는 법이다. 하나씩 대입해보자.

원래 하려던 계산은 r3 = r1 × r2의 행렬 곱이니까, 각 자리를 양자화 식으로 바꿔 쓰면 이렇게 된다.

$$ S_3 (q_3 - Z_3) = \sum_{j=1}^{N} S_1 (q_1 - Z_1) S_2 (q_2 - Z_2) $$

여기서 S1S2는 각 항마다 똑같은 값이니 시그마 밖으로 뺄 수 있다. 그리고 양변을 S3로 나누고 Z3를 넘기면,

$$ q_3 = Z_3 + M \sum_{j=1}^{N} (q_1 - Z_1)(q_2 - Z_2), \quad M = \frac{S_1 S_2}{S_3} $$

이게 이 편의 하이라이트다. 흩어져 있던 실수 세 개가 M 하나로 묶였다.

시그마 안을 보면 전부 정수다. q1, q2, Z1, Z2 전부 정수고, 곱셈도 덧셈도 정수다. 실수는 오직 M 하나뿐이고, 그것도 시그마 밖에 딱 한 번 있다.

더 쉬운 비유로

계산기를 100번 두드릴 일이 한 번으로 줄었다.

원래는 곱셈 한 번마다 실수 스케일이 붙어 있었다. 그걸 다 모아 밖으로 빼내니, 안쪽은 순수 정수 계산이 되고 밖에 실수 하나만 남는다.

그리고 남은 건 하나니까, 이제 이 하나만 처리하면 된다.

문제를 없앤 게 아니라 한 군데로 몰았다. 몰아놓고 나면 방법이 생긴다.


4. 비트 시프팅 — 소수를 못 쓰면 잠깐 부풀렸다 내린다

3절에서 시그마 안쪽은 전부 정수 계산으로 끝났다. 그러니 이 시점에는 정수 누적 결과가 하나 나와 있는 상태다. 출력 한 칸을 예로 들어, 그 값이 12,000이었다고 하자.

남은 일은 여기에 M을 곱하는 것뿐이다. 그런데 M = S1·S2/S3 는 실제로 계산해보면 거의 항상 0과 1 사이 값이 나온다. 0.0234 같은.

정수로 못 쓴다. 0.0234를 정수로 반올림하면 0이 되고, 그럼 결과가 전부 0이 되어버린다.

여기서 쓰는 게 비트 시프팅이다. 발상은 단순하다.

$$ M \approx 2^{-n} \cdot (2^n M) $$

M에 2를 n번 곱하면 소수점이 왼쪽으로 밀리면서 정수 자리로 올라온다. 그 상태로 계산을 끝낸 다음, 마지막에 2를 n번 나눠서 원래 크기로 되돌린다.

비트 시프팅 3단계

그런데 왜 하필 2일까. 2로 나누는 건 비트를 오른쪽으로 한 칸 미는 것과 완전히 같기 때문이다. 이진수로 1000(8)을 오른쪽으로 한 칸 밀면 0100(4)이 된다.

즉 나눗셈을 자릿수 이동으로 대체할 수 있다. 나눗셈은 회로에서 제일 비싼 연산이고, 자릿수 이동은 제일 싼 연산이다. 사실상 공짜다.

더 쉬운 비유로

0.5킬로를 재려니 저울이 소수를 못 읽는다. 그럼 그램으로 바꿔서 500이라 읽고, 마지막에 1000으로 나눈다.

단위를 잠깐 바꿔서 정수 범위로 끌어올린 다음, 다 끝나고 되돌리는 것이다. 다만 여기선 10 대신 2를 쓴다. 컴퓨터한테는 2가 훨씬 싸니까.

물론 공짜는 아니다. 2^n·M을 정수로 반올림하는 순간 오차가 생긴다. 그래서 n을 충분히 크게 잡아야 하는데(실무에선 16비트나 32비트 고정소수점을 쓴다), 너무 키우면 중간 계산이 정수 범위를 넘쳐버린다. 정확도와 자릿수 넘침 사이의 줄타기다.

실수를 없앤 게 아니라, 실수를 쓰던 자리에 자릿수 이동을 끼워 넣었다.


5. 배치 정규화 폴딩 — 계층을 앞으로 밀어 넣는다

행렬 곱은 해결됐다. 그런데 실제 모델에는 행렬 곱만 있는 게 아니다.

23편에서 본 배치 정규화(Batch Normalization) 가 대표적이다. 학습 중에 입력 분포가 계속 흔들리는 걸 잡아주는 계층이었다.

$$ BN(x) = \gamma \cdot \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} + \beta $$

보면 알겠지만 여기도 실수투성이다. 평균 μ도 표준편차 σ도 실수고, 나눗셈에 제곱근까지 있다.

그럼 이 계층도 따로 양자화하면 되지 않나 싶은데, 그러면 비효율적이다. 계층 하나 늘 때마다 스케일 파라미터가 늘고, 정수화할 지점이 늘고, 오차가 쌓인다.

그래서 쓰는 게 폴딩(folding) 이다. 아예 없애버린다.

배치 정규화 폴딩 전후

없앨 수 있는 이유가 있다. 학습이 끝난 뒤의 배치 정규화는 그냥 상수를 곱하고 상수를 더하는 일이다. μσ가 더 이상 안 바뀌니까 고정된 숫자일 뿐이다.

그런데 바로 앞의 합성곱도 하는 일이 똑같다. 가중치를 곱하고 편향을 더한다. 곱하고 더하는 일을 두 번 연달아 하는 거라면, 미리 합쳐서 한 번으로 만들 수 있다.

$$ W_{fold} = \frac{W}{\sigma}, \quad b_{fold} = b - \frac{\mu}{\sigma} $$

가중치를 미리 표준편차로 나눠두고, 편향에 평균 보정을 미리 반영해둔다. 그러면 배치 정규화 계층은 모델에서 사라지고, 결과는 완전히 똑같다.

더 쉬운 비유로

할인 쿠폰을 계산대에서 매번 꺼낼 것인가, 아예 할인된 가격표를 미리 붙여둘 것인가.

계산 결과는 같다. 그런데 후자는 계산대에서 할 일이 하나 줄어든다. 추론은 수백만 번 도는데 가격표는 한 번만 바꿔 붙이면 되니, 미리 붙이는 쪽이 압도적으로 이득이다.

폴딩은 학습이 끝난 뒤에만 할 수 있는 계산이다. 학습 중에는 평균과 표준편차가 배치마다 바뀌니까 미리 못 합친다.


6. 비전 트랜스포머 — 7편과 13편에서 미뤄뒀던 계산서

여기까지가 CNN 이야기다. 그런데 요즘 영상 모델은 트랜스포머 계열이 많고, 거기엔 CNN엔 없던 게 세 개 더 있다.

공교롭게도 셋 다 이미 이 블로그에서 다룬 적이 있다. 그때는 “이런 게 있다” 로 넘어갔는데, 여기서 전부 청구서로 돌아왔다.

연산 다뤘던 편 그때 했던 설명 정수화가 어려운 이유
Softmax 13편 (Self-Attention) 점수를 확률처럼 바꿔주는 함수 지수 연산이 들어있다
GELU 7편 (활성화 함수) ReLU보다 부드럽게 꺾인다 그 부드러움이 적분·시그모이드다
LayerNorm 13편 (층 정규화) 한 샘플 안에서 정규화한다 제곱근이 들어있다

7편에서 GELU를 두고 “계산이 ReLU보다 조금 비싸다”고 적었었는데, 그 조금이 여기서 발목을 잡는다. 정수 회로에는 지수도 제곱근도 없다.

셋 다 우회하는 방법이 있다

Softmax — 밑을 바꾼다. 지수 함수 자체는 못 하지만, 밑이 e인 지수를 밑이 2인 지수로 바꾸면 이야기가 달라진다. 2의 거듭제곱은 앞에서 본 비트 시프팅이다! 그래서 밑을 e에서 2로 변환한 뒤, 지수를 정수 부분과 소수 부분으로 쪼갠다. 정수 부분은 시프팅으로 처리하고, 남은 소수 부분만 근사식으로 때운다.

GELU — 아는 함수로 바꿔 탄다. GELU는 이렇게 근사할 수 있다.

$$ GELU(x) \approx x \cdot \sigma(1.702 x) $$

여기서 σ는 시그모이드다. 그리고 시그모이드는 Softmax와 형태가 같아서, 방금 만든 Softmax 정수화 방식을 그대로 재활용할 수 있다. 남은 상수 1.702는 다시 비트 시프팅으로 근사한다.

LayerNorm — 제곱근을 반복해서 찾는다. 표준편차는 분산의 제곱근이다. 제곱근 회로는 없지만, 답을 찍고 → 확인하고 → 좁히는 걸 반복하면 정수 연산만으로 제곱근에 도달할 수 있다. 여기서도 범위를 좁히는 데 비트 시프팅을 쓴다.

세 가지 우회법이 결국 다 같은 도구 하나로 돌아온다는 게 재밌었다.

비트 시프팅은 M 하나 처리하려고 꺼낸 잔기술인 줄 알았는데, 정수 세계의 만능 열쇠였다!


정리

  • AI 가속기는 정수 연산 회로만 갖고 있다. Coral Edge TPU는 완전 8비트 정수 모델만, ARM Ethos-U는 8·16비트 정수만 돌린다. 못 하는 연산은 CPU로 되돌려 보내고, 그 왕복이 제일 비싸다
  • 일반 양자화는 r = S(q - Z)인데 스케일 S가 실수다. 값은 정수로 저장돼도 계산 도중에 실수가 되살아난다
  • 행렬 곱을 전개하면 흩어져 있던 실수 세 개가 M = S1·S2/S3 하나로 묶인다. 시그마 안쪽은 전부 정수가 된다
  • 남은 M은 0과 1 사이라 정수로 못 쓴다. 비트 시프팅으로 잠깐 2^n배 부풀려 정수로 만들고, 계산 후 n칸 밀어 내린다. 2로 나누기 = 비트 한 칸 밀기라 사실상 공짜다
  • 배치 정규화는 폴딩으로 아예 없앤다. 추론 시점의 배치 정규화는 상수 곱셈·덧셈일 뿐이라, 앞의 합성곱 가중치에 미리 흡수시킬 수 있다
  • 트랜스포머에는 Softmax·GELU·LayerNorm이 더 있다. 지수는 밑을 2로 바꿔서, GELU는 시그모이드로 갈아타서, 제곱근은 반복 탐색으로 우회한다. 셋 다 결국 비트 시프팅으로 수렴한다

35편부터 여기까지

35편 숫자를 담는 법 → 36편 모델을 깎는 법 → 37편 줄인 채로 튜닝하는 법 → 38편 올릴 자리 → 39편 그 자리에 맞추는 마지막 한 걸음.

35편에서 양자화를 배웠을 때는 솔직히 “값을 8비트로 줄인다” 로만 이해했다. 그게 절반이었다는 걸 이제 알았다.

양자화의 진짜 목표는 값을 줄이는 게 아니라, 실수 연산을 한 번도 안 하는 것이었다.

값만 줄이면 모델 파일이 작아진다. 연산까지 정수로 만들어야 가속기가 그 모델을 맡아준다. 목적지가 정수 회로였으니, 거기 맞춰 소수점을 끝까지 지워내는 게 이 편의 이야기였다.


참고 자료

한줄 평

실수 하나 못 쓴다는 제약 하나에서 이렇게까지 파고들 줄 몰랐는데, 결국 다 비트 하나 미는 만능 치트키를 발견해서 재밌었다.