콘월 이층집

andrej

프로젝트 microGPT - Architecture

반응형

 

이전 게시글 : 

 

프로젝트 microGPT - Dataset, Tokenizer, Autograd, Parameters

Dataset대규모 언어 모델의 연료는 결국 텍스트 데이터의 흐름(stream)이다.이 흐름은 하나의 거대한 문서일 수도 있고, 여러 개의 문서 집합으로 나뉠 수도 있다.실제 상용 모델에서는 각 문서가 인

cornwall.tistory.com

이전 주제들은 현재는 공통 과학처럼 일반적인 내용이여서 한꺼번에 다뤘지만, 이번에는 호흡을 조금은 길게 가져가보고자 한다.

최근 ViT(Vision Transformer) 관련 연구를 2건이나 했지만 아직도 트랜스포머에 대해 잘안다고 대답하지는 못하기 때문이다.

(그래서 이런 시리즈 포스팅을 시작한 이유도 있다.)


Architecture

모델 아키텍처는 상태를 내부에 저장하지 않는(stateless) 함수로 구현되어 있다.

이 함수는 다음을 입력으로 받는다.

  • 현재 토큰 ID
  • 현재 위치(position) 정보
  • 모델의 파라미터들
  • 이전 위치들에서 계산되어 캐시된 key/value 값들

그리고 시퀀스에서 다음에 어떤 토큰이 올지에 대한 점수(logits)를 반환한다.

microgpt 의 구현은 GPT-2 구조를 따르되, 몇 가지 단순화를 적용했다.

  • LayerNorm 대신 RMSNorm
  • bias 항 제거
  • GeLU 대신 ReLU 활성화 함수 사용

이제 본격적인 모델 정의에 앞서, 세 개의 간단한 보조(helper) 함수를 먼저 살펴보자. (linear, softmax, rmsnorm)

linear

def linear(x, w):
    return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]

 

linear는 행렬-벡터 곱(matrix-vector multiplication)이다.

입력 벡터 x와 가중치 행렬 w를 받아, w의 각 행(row)과 x의 내적(dot product)을 계산한다. (유사도)

이는 신경망의 가장 기본적인 연산이다.

선형 변환(linear transformation)은 신경망이 학습하는 핵심 연산 단위다.

모든 복잡한 모델도 결국 선형 연산의 반복 위에 쌓여 있다.

softmax

def softmax(logits):
    max_val = max(val.data for val in logits)
    exps = [(val - max_val).exp() for val in logits]
    total = sum(exps)
    return [e / total for e in exps]

 

softmax는 로짓(logits)을 확률 분포로 변환한다.

로짓은 음의 무한대부터 양의 무한대까지 아무 값이나 가질 수 있다.
softmax를 통과하면,  모든 값이 0과 1 사이로 변환되고 전체 합이 1이 된다. 즉, 확률 분포가 된다.

 

위 코드에서 최대값(max_val)을 따로 저장해두는 이유는 수치적 안정성(numerical stability) 때문이다.
지수 함수(exp)는 값이 조금만 커도 폭발적으로 증가하기 때문에, overflow를 방지하기 위해서 최대값을 빼준다.

수학적으로는 결과가 동일하지만 계산 과정이 훨씬 안정적이다.

rmsnorm (Root Mean Square Normalization)

def rmsnorm(x):
    ms = sum(xi * xi for xi in x) / len(x)
    scale = (ms + 1e-5) ** -0.5
    return [xi * scale for xi in x]

 

rmsnorm은 벡터의 크기를 정규화(normalize)하는 함수다.

입력 벡터의 평균 제곱(mean square)을 계산한 뒤, 그 값의 역제곱근으로 스케일링한다.

이 과정은 활성값이 지나치게 커지거나 작아지는 것을 방지하기 위함이다.

원래 GPT-2에서는 LayerNorm을 사용하지만, microGPT는 더 단순한 RMSNorm을 사용한다.

둘의 핵심 목적은 같다 : 네트워크 내부의 값이 폭주(explosion)하지 않도록 안정화한다.


 

 

이제 모델 함수(gpt) 자체를 살펴 보자. [:3]

def gpt(token_id, pos_id, keys, values):
    tok_emb = state_dict['wte'][token_id]         # token embedding
    pos_emb = state_dict['wpe'][pos_id]           # position embedding
    ...

 

아래 항목들을 입력으로 특정 시점의 하나의 토큰을 처리한다. (return logits)

  • token_id : 현재 토큰의 ID
  • pos_id : 현재 위치 ID
  • keys, values : 이전 위치들에서 저장해 둔 key/value 캐시, KV cache
  • state_dict : 그리고 전역적으로 정의된 모델 파라미터들

출력은 현재까지의 문맥을 고려했을 때, 다음에 어떤 토큰이 올지에 대한 점수(logits)이다.

 

이 함수의 진행 순서대로 살펴보자.

Embeddings

tok_emb = state_dict['wte'][token_id]
pos_emb = state_dict['wpe'][pos_id]
x = [t + p for t, p in zip(tok_emb, pos_emb)]
x = rmsnorm(x)

 

모델은 단순한 정수 ID를 처리할 수 없기에 먼저 임베딩(embedding)을 수행한다.

  • state_dict['wte']는 토큰 임베딩 테이블
  • state_dict['wpe']는 위치 임베딩 테이블

각 토큰은 하나의 벡터로 변환되고, 여기에 위치 정보 벡터를 더한다.

이렇게 하면 모델은 “무슨 단어인가?”, “어디에 위치했는가?” 를 동시에 표현할 수 있다.

그 다음 rmsnorm을 통해 값을 정규화한다.

(단, 최신 LLM은 일반적으로 위치 임베딩을 생략하고 RoPE와 같은 다른 상대 기반 위치 지정 방식을 도입한다.)

Attention Block

q = linear(x, state_dict[f'layer{li}.attn_wq'])
k = linear(x, state_dict[f'layer{li}.attn_wk'])
v = linear(x, state_dict[f'layer{li}.attn_wv'])

 

현재 토큰은 세 개의 벡터로 변환된다 : Q (Query), K (Key), V (Value)

직관적으로 보면,

  • Query → “나는 무엇을 찾고 있는가?”
  • Key → “나는 무엇을 담고 있는가?”
  • Value → “선택되면 무엇을 전달하는가?”

이전 위치의 key/value는 keys, values에 저장되어 있다. (KV cache)

각 attention head는 다음을 계산한다.

  1. 현재 Query와 이전 Key들의 내적
  2. softmax를 적용해 attention 가중치 계산
  3. 가중치를 이용해 Value들의 가중합 계산

이 과정을 통해, 현재 토큰이 과거 토큰들 중 어디에 얼마나 집중할지를 결정한다.

MLP block

Attention 이후에는 MLP(Multi-Layer Perceptron)가 따라온다.

x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
x = [xi.relu() for xi in x]
x = linear(x, state_dict[f'layer{li}.mlp_fc2'])

 

MLP는 단순한 두 층짜리 신경망이다.

  • 차원을 4배로 확장
  • ReLU 활성화
  • 다시 원래 차원으로 축소

Attention이 “토큰 간 소통”이라면, MLP는 “각 토큰이 개별적으로 계산하는 부분”이다.

Residual Connection

각 블록의 출력은 입력에 더해진다.

x = [a + b for a, b in zip(x, x_residual)]
 

이러한 방법은 깊은 네트워크 학습을 안정화하고 gradient 흐름을 개선하는 효과가 있다.

Output

마지막 hidden state를 어휘 크기(vocab size) 차원으로 투영한다. 어휘의 각 토큰당 하나의 로짓 값을 생성한다.

우리 예시에서는 총 27개의 숫자가 사용됩니다. 

 

마지막 hidden state를 어휘 크기(vocab size) 차원으로 투영한다. (현재 프로젝트에서 27개 숫자)
각 숫자는 해당 토큰이 다음에 올 가능성에 대한 점수(logit)로, 이 값에 softmax를 적용하면 확률 분포가 된다.

KV cache에 대한 보충 설명

흥미로운 점은, microGPT는 학습 과정에서도 KV cache를 사용한다는 것이다.

보통 KV cache는 추론(inference)에서만 사용하는 것으로 알려져 있다.
그래서 이 부분이 다소 낯설게 느껴질 수 있다.

 

하지만, 개념적으로 보면 KV cache는 학습 중에도 항상 존재한다.

실제 서빙 환경에서는 attention 연산이 고도로 벡터화(vectorized)되어 있기 때문에, 시퀀스의 모든 위치를 동시에 처리한다.

따라서 KV cache가 명시적으로 보이지 않을 뿐, 계산 내부에서는 동일한 정보가 사용되고 있다.

 

microGPT는 한 번에 하나의 토큰만 처리한다는 점을 주목해야한다.

  • 배치 차원 없음
  • 시간 축 병렬 처리 없음

그래서 이전 위치의 key와 value를 직접 리스트에 저장하는 방식으로 KV cache를 명시적으로 구성한다.

keys[li].append(k)
values[li].append(v)
 

또 하나 중요한 차이가 있다.

일반적인 추론 환경에서는 KV cache에 저장된 텐서들은 그래디언트 계산에서 분리(detached)되어 있다.
즉, 역전파가 그 안을 통과하지 않는다.

 

하지만, microGPT에서는 다르다.

여기서 캐시된 key와 value는 단순한 숫자 배열이 아니라 계산 그래프에 연결된 Value 객체들이다.

따라서 역전파(backpropagation)가 이 캐시를 통과해 이전 토큰의 계산까지 거슬러 올라간다.

즉, microGPT의 KV cache는 단순한 속도 최적화용 저장소가 아니라, 완전히 살아 있는 계산 그래프의 일부다.

microgpt 의 gpt 함수 부분

반응형

프로젝트 microGPT - Dataset, Tokenizer, Autograd, Parameters

반응형

Dataset

대규모 언어 모델의 연료는 결국 텍스트 데이터의 흐름(stream)이다.
이 흐름은 하나의 거대한 문서일 수도 있고, 여러 개의 문서 집합으로 나뉠 수도 있다.

실제 상용 모델에서는 각 문서가 인터넷 웹페이지, 책, 코드 파일과 같은 거대한 텍스트 단위에 해당한다.

 

microGPT는 훨씬 단순한 데이터를 예시로 사용한다. (32,033개의 이름이 한 줄에 하나씩 적혀있다.)

아래 코드를 실행하면 GitHub에서 input.txt를 내려받고, 이를 문서 목록(docs)으로 구성한다.

# Let there be an input dataset `docs`: list[str] of documents (e.g. a dataset of names)
if not os.path.exists('input.txt'):
    import urllib.request
    names_url = 'https://raw.githubusercontent.com/karpathy/makemore/refs/heads/master/names.txt'
    urllib.request.urlretrieve(names_url, 'input.txt')
docs = [l.strip() for l in open('input.txt').read().strip().split('\n') if l.strip()] # list[str] of documents
random.shuffle(docs)
print(f"num docs: {len(docs)}")

 

여기서 docs는 단순한 list[str]이다. 각 문자열이 하나의 문서이다.

 

모델의 학습하고자 하는 목표는 입력 데이터에서 통계적 패턴을 학습하고,

그 패턴을 공유하는 새로운 데이터를 생성하는 것이다.

이름 데이터셋의 경우라면, 이를 학습한 모델은 그럴듯한 새로운 이름을 생성하는 것이 목표일 것이다.

 

2026년 현재 기준으로 보면 이러한 데이터를 학습하는 작업과 목표는 다소 단순하다고 생각할 수 있다.

이해를 돕기 위한 예로, 우리는 ChatGPT와 “대화를 한다”고 생각한다.
질문을 던지고, 이해한 답변을 듣고, 상호작용한다고 느낀다.

그러나 모델의 관점에서 보면, 이 모든 것은 하나의 긴 문서일 뿐이다.

사용자가 텍스트를 시작하면, 모델은 그 뒤를 통계적으로 이어 쓰고 있을 뿐이다.

즉, 대화가 아니라 문서 완성(document completion)이다.

 

ChatGPT는 생각하지 않는다.
이해하지도 않는다.
그저 다음에 올 토큰을 예측할 뿐이다.

 

Tokenizer

모델은 내부적으로 문자가 아니라 숫자를 다루기 때문에,

정수 토큰 ID 시퀀스로 변환하고 다시 텍스트로 복원하는 방법이 필요하다.

이를 토크나이저(Tokenizer)라고 한다.

 

GPT-4에서 사용하는 tiktoken과 같은 상용 토크나이저는 효율성을 위해 문자 덩어리(서브워드) 단위로 동작한다.
microGPT는 데이터셋의 각 고유 문자에 하나의 정수를 할당하는 가장 단순한 방식을 택한다.

아래 코드처럼 전체에서 등장하는 고유 문자(소문자 a–z)를 수집한 뒤 정렬하고, 각 문자에 인덱스를 ID로 부여한다.

# Let there be a Tokenizer to translate strings to discrete symbols and back
uchars = sorted(set(''.join(docs))) # unique characters in the dataset become token ids 0..n-1
BOS = len(uchars) # token id for the special Beginning of Sequence (BOS) token
vocab_size = len(uchars) + 1 # total number of unique tokens, +1 is for BOS
print(f"vocab size: {vocab_size}")

 

여기서 중요한 점은 정수 값 자체에는 아무 의미가 없다는 것이다.
각 토큰은 단지 서로 다른 기호일 뿐이며 0, 1, 2 대신 🍎, 🚀, 🧠를 써도 본질은 변하지 않는다.

그러므로 BOS (Beginning of Sequence) 토큰이라고 하는 특별한 토큰을 추가한다.
이 토큰은 문서의 시작과 끝을 구분하는 역할을 하며, 학습 시 각 문서는 아래와 같이 감싸진다. (emma)

[BOS, e, m, m, a, BOS]

 

모델은 첫 번째 BOS가 “새 이름의 시작”임을, 두 번째 BOS가 “이름의 종료”임을 학습하게 된다.

결과적으로 최종 어휘 크기는 27개가 된다.

  • 소문자 a–z: 26개
  • BOS 토큰: 1개

총 27개의 토큰으로 구성된 작은 어휘(vocavulary)다.

Autograd

모델을 학습시키기 위해서는 반드시 기울기(gradient)가 필요하다.

학습 관점에서 알고 싶은 것은 손실(loss) 변화율이기 때문이다.

어느 파라미터를 아주 조금 변경했을 때,
최종 손실은 얼마나 변하는가?

 

이 값을 계산하는 것이 바로 미분이다. 

PyTorch 같은 프레임워크에서는 backward() 함수 하나로 미분 과정을 처리해준다.
microGPT의 Value 클래스는 이 과정을 직접 구현한 것이다.

class Value:
    __slots__ = ('data', 'grad', '_children', '_local_grads')

    def __init__(self, data, children=(), local_grads=()):
        self.data = data                   # 이 노드의 실제 값
        self.grad = 0                      # loss에 대한 미분값
        self._children = children          # 계산 그래프 상의 입력 노드들
        self._local_grads = local_grads    # 로컬 미분값

 

Value 객체는 하나의 스칼라 숫자를 감싸고, 이 숫자가 어떻게 계산되었는지를 기억한다.

덧셈, 곱셈, 로그, exp, ReLU와 같은 모든 연산 역시 새로운 Value 객체를 만들어낸다.

이렇게 Value 객체들이 서로를 참조하도록 연결되면 자연스럽게 하나의 계산 그래프(computation graph)가 형성된다.

 

이제 이 그래프를 거슬러 올라가야 한다. 최종 출력이 각 입력에 얼마나 의존하는지를 계산하기 위해서다.

이 의존 관계를 따라 역방향으로 전파하는 법칙이 바로 체인 룰(chain rule)이다.

그래프의 끝(손실)에서 시작해 각 노드의 기울기를 차례로 누적하는 것, 이것이 backward()가 하는 일이다.

 

간단한 예를 들어 해당 과정을 설명해보고자 한다.

L = a * b + a
# a = 2, b = 3이라면 :
#     dL/da = b + 1 = 3 + 1 = 4
#     dL/db = a = 2

 

 

만약 a를 0.001만큼 증가시키면, L은 약 0.004 증가한다.

기울기는 각 입력이 최종 출력에 얼마나 민감한지를 나타내는 값이다.

그리고 바로 이 값이 모델을 학습시키는 방향을 결정한다.

(단, 실제 학습에서는 손실을 줄이기 위해 기울기의 반대 방향으로 파라미터를 업데이트한다.)

Parameters

파라미터는 모델의 “지식”이다.

이들은 수많은 부동소수점 숫자들의 집합이며, 모두 Value 객체로 감싸져 있다.

처음에는 무작위(random) 값으로 시작하지만, 학습이 진행되면서 점점 최적화된다.

각 파라미터가 정확히 어떤 역할을 하는지는 이후 포스팅에서 모델의 아키텍처를 설명할 예정이다.
지금 단계에서는 우선 이 파라미터들을 초기화해야 한다는 것만 알아두자.

n_embd = 16                   # 임베딩 차원
n_head = 4                    # 어텐션 헤드 수
n_layer = 1                   # 레이어 수
block_size = 16               # 최대 시퀀스 길이
head_dim = n_embd // n_head   # 각 헤드의 차원
matrix = lambda nout, nin, std=0.08: [[Value(random.gauss(0, std)) for _ in range(nin)] for _ in range(nout)]
state_dict = {'wte': matrix(vocab_size, n_embd), 'wpe': matrix(block_size, n_embd), 'lm_head': matrix(vocab_size, n_embd)}
for i in range(n_layer):
    state_dict[f'layer{i}.attn_wq'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.attn_wk'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.attn_wv'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.attn_wo'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.mlp_fc1'] = matrix(4 * n_embd, n_embd)
    state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4 * n_embd)
params = [p for mat in state_dict.values() for row in mat for p in row]
print(f"num params: {len(params)}")
 

각 파라미터는 평균 0, 표준편차 std를 가지는 가우시안 분포에서 샘플링된다. 즉, 작은 랜덤 값으로 초기화된다. (matrix)
처음부터 큰 값으로 시작하면 학습이 불안정해질 수 있기 때문에 학습에서 일반적으로 쓰는 방식이다.

optimizer가 모든 파라미터를 순회할 수 있도록 모든 가중치를 하나의 리스트로 평탄화(flatten)해야한다.

microgpt train & inference (m1)

반응형

프로젝트 microGPT - 소개

반응형

microGPT는 지난 2월 12일에 Andrej Karpathy가 만든 GPT(Transformer) 모델의 가장 단순한 형태를 보여주는 200줄짜리 순수 Python 코드 프로젝트이다.

https://karpathy.github.io/2026/02/12/microgpt/

 

외부 라이브러리(PyTorch, NumPy 등)없이 기본 알고리즘 전체를 구현한 코드여서 공부하기에 좋다. CUDA도 사용하지 않았기에 CPU에서 구동이 가능하다. 

지난 코드컨벤션 게시글 처럼, 시간이 될 때마다 틈틈이 해당 내용을 정리해 나갈 예정이다.

반응형

+ 최근 글