트랜스포머의 핵심, 어텐션 연산의 기본을 배워보자

어텐션을 구성하는 6가지 매트릭스
우리가 트랜스포머 블록을 구성하거나, 어탠션을 이론적으로 공부할 때 가장 먼저 만나게 되는 것은 Q, K, V라는 알파벳이다. 각각 Query, Key, Value이다. 실제론 W_q, W_k, W_v라는 학습되는 가중치와, 실시간으로 사용되는 Q, K, V 이렇게 6개가 어탠션을 구성한다. 학습되는 가중치에 대한 설명 없이 Q, K, V 만 가지고 설명을 하면 이해를 못하는 사람이 태반이다. 사실 어탠션이 획기적이고 잘 동작하는, 이미 '정립'이 된 개념이지만, 의외로 매커니즘이 대단히 추상적이기 때문이다.
나는 맛있는 사과를
이라는 문장이 입력으로 들어왔다고 하자. 그리고 공백을 기준으로 각 단어가 토큰이 된다고 하자. 그럼 "나는" "맛있는" "사과를"이 각각의 임베딩값을 가진 토큰이다. 이들이 먼저 W_q에 곱해진다.
나는 [5,8,2,9,4]
맛있는 [6,3,4,1,9]
사과를 [7,4,5,0,5]
이렇게 임베딩 벡터가 5차원이면, W_q는 [5x5] 행렬이다. 곱해서 5차원의 임베딩 벡터가 결과로 나와야 하기 때문이다. 그럼 3개의 단어를 한번씩 곱해주면 그대로 3개의 벡터가 나온다. W_q가 아래와 같다고 하자.
W_Q = [
[0.5, 0.0, 0.0, 0.1, 0.0],
[0.1, 0.7, 0.0, 0.0, 0.0],
[0.0, 0.1, 0.8, 0.0, 0.0],
[0.0, 0.0, 0.2, 0.9, 0.0],
[0.0, 0.0, 0.0, 0.0, 1.0]
]
결과는 아래와 같이 나온다.
q_나는 = [3.3, 5.9, 3.0, 8.6, 4.0]
q_맛있는 = [3.3, 2.4, 3.4, 1.5, 9.0]
q_사과를 = [3.9, 3.1, 4.4, 0.9, 5.0]
이렇게 Q가 만들어진다.
Q = [q_나는, q_맛있는, q_사과를]
즉,
Q = [
[3.3, 3.3, 3.9],
[5.9, 2.4, 3.1],
[3.0, 3.4, 4.4],
[8.6, 1.5, 0.9],
[4.0, 9.0, 5.0]
]
K, V도 동일하다. W_q, W_k, W_v는 모두 같은 크기이며, Q, K, V또한 같은 크기이다. 숫자만 다르다. 실제로 곱해지는 모습을 보기 위해서 가상의 숫자로 행렬을 만들어 보자. W_k, W_v는 생략하고, K와 V만 임의 값으로 만들어보겠다.
| Q | K | V |
| [q_나는, q_맛있는, q_사과를] | [k_나는, k_맛있는, k_사과를] | [v_나는, v_맛있는, v_사과를] |
| [3.3, 3.3, 3.9], [5.9, 2.4, 3.1], [3.0, 3.4, 4.4], [8.6, 1.5, 0.9], [4.0, 9.0, 5.0] |
[3.8, 3.9, 4.5], [4.6, 1.8, 2.5], [3.3, 3.3, 4.0], [7.5, 1.7, 0.8], [4.0, 9.0, 5.0] |
[4.4, 3.0, 3.6], [5.3, 2.3, 2.9], [4.1, 3.5, 3.8], [7.2, 1.7, 0.7], [3.6, 8.1, 4.5] |
이제 softmax(KT * Q) * V 라는 과정에 대해 살펴볼 시간이다. 전치된 K와 Q를 곱하는 것은 무슨 의미인가? 계산의 결과로는 "나는" "맛있는" "사과를"에 대한 모든 조합, 즉 데카트르 곱이 생긴다.
| KᵀQ | q_나는 | q_맛있는 | q_사과를 |
| k_나는 | q_나는 · k_나는 | q_맛있는 · k_나는 | q_사과를 · k_나는 |
| k_맛있는 | q_나는 · k_맛있는 | q_맛있는 · k_맛있는 | q_사과를 · k_맛있는 |
| k_사과를 | q_나는 · k_사과를 | q_맛있는 · k_사과를 | q_사과를 · k_사과를 |
어차피 랜덤 값이지만 그래도 숫자를 계산해보면 아래와 같이 나온다.
| KᵀQ | q_나는 | q_맛있는 | q_사과를 |
| k_나는 | 156.5 | 101.6 | 100.3 |
| k_맛있는 | 129.7 | 113.3 | 112.9 |
| k_사과를 | 108.4 | 87.8 | 92.5 |
이제 열별로 softmax를 취해야 한다.
(아래부턴 숫자를 실제 계산 결과와 다르게 적겠다. 실제로 위에서도 학습된 벡터가 아니라 랜덤 숫자를 사용한거라, 지금 값 자체는 의미가 없다. 하지만 아래는 관계를 의미하는 비율이므로 랜덤 숫자를 가지고 실제로 계산한 무작위 값보단 그럴듯한 값인 것이 이해하기가 유리할 것이다.)
그리하여, 위의 과정대로 만들어진 수의 softmax 결과가 아래와 같다고 하자.
| Key\Query | q_나는 | q_맛있는 | q_사과를 |
| k_나는 | 0.7 | 0.1 | 0.1 |
| k_맛있는 | 0.1 | 0.9 | 0.6 |
| k_사과를 | 0.2 | 0.8 | 0.3 |
이 매트릭스를 통해 우리는 어떤 단어가 어떤 단어와 연관이 깊은지(참조해야 하는지)를 알 수 있다. 예를 들어 "사과를"이라는 단어는 "맛있는"이라는 단어와 연관이 깊다는 것이다.
어떻게 이런 연관성이 계산되냐고?
그건 그런 의미를 가지도록 가중치가 학습 되었기 때문이다. 학습을 시키는 부분은 훨씬 어려운 문제이다. 일단은 곱하면 저런 의미의 값이 나온다고 일단 믿고 가면된다.
이제 이 연관성을 어떻게 쓰는가?
여기에서 V가 사용된다. 저 softmax가 적용된 매트릭스를 V와 곱한다. 그러면 다시 우리가 처음 보던 단어 임베딩 값과 같은 형태의 값이 나온다.
v_나는 = [3.5, 4.9, 3.2, 8.2, 3.8]
v_맛있는 = [4.2, 3.1, 3.5, 2.5, 8.2]
v_사과를 = [3.7, 3.5, 4.1, 1.2, 4.4]
이 값은 기존의 임베딩 값에다가 우리가 입력한 문장, 즉 문맥의 정보를 반영하여 변형한 임베딩 값인 것이다. 이렇게 변형된 값이 다음 층의 입력으로 들어간다. 이것이 여러 층에서 반복되면 인공지능은 문맥 내의 여러 관계를 고려할 수 있게 되고, 문맥을 고려하였기 때문에 결과적으로 더 정확한 예측을 할 수 있게 된다.
대형 언어 모델이 무거운 이유
이런 과정들 때문에 대형 언어 모델이 무지막지하게 무거워 지는 것인데, 이는 우리가 ChatGPT에 한번에 입력하는 문장들이 어느정도인지를 고려해보면 된다. 아무도 ChatGPT에 "나는 맛있는 사과를"이라고 3단어를 입력하고 그 다음 단어를 맞춰달라고 하지 않는다. 우리는 자주, 뉴스 기사 하나 정도의 용량을 아무렇지도 않게 붙여 넣으며 요약을 해달라거나 보고서를 써달라고 한다.
그럼 ChatGPT는 문맥을 고려하기 위해 뉴스 기사 전체를 대상으로 Q, K, V를 계산한다. 그리고 요약을 하는 과정에서도 단어 하나(토큰 하나)를 만들어 내면서, 자기 회귀(autoregressive)적으로 {모든 입력 + 현재까지 만들어진 출력}을 다시 입력으로 넣어, 추가로 Q, K, V를 계산하는 것이다.
그래서 사실 같은 계산을 반복하지 않도록 하는 KV Cache 사용이 필수적인데, 이건 다른 글에서 별도로 포스팅하겠다.
전체 모델은 더 어마어마하다
여튼 이것이 하나의 어탠션 계산이다. 하지만 여기서 또 어지러운 부분은, 트랜스포머 블록은 실제로 내가 설명한 간단한 어탠션보다 훨씬 복잡하다는 것이다.

우리가 흔히 보는 트랜스포머 블록 하나의 모습인데, 우린 지금 저 파란색의 Multi-head attention의, multi head도 아닌 그냥 싱글 헤드 어탠션 하나를 다룬 것일 뿐이다. 그 외에 다른 포워드 층, 정규화 층까지 모두 포함한 것이 하나의 트랜스포머 블록인데 GPT-2만 해도 이런 트랜스포머 블록이 12개나 있다. GPT-2는 약 117M의 파라미터였다. 하지만 요즘 나오는 모델들은 수십B를 쉽게 넘어간다. 내부 구조가 얼마나 복잡하고 거대하겠는가?
이러니 계산량이 얼마나 될지는 이 글에서 모두 다루기도 힘들고, 상상에 맡길 수 밖에 없다. 이래서 트랜스포머를 활용한 LLM은 웬만한 컴퓨팅 파워로는 훈련은 커녕 추론 조차 하기 힘든 것이다.