VICE(C64 에뮬레이터)를 실행하고 disk/soulplayer.d64 파일을 로드한다. 소문자로 짧은 메시지를 입력하고 엔터를 누르면 화면 테두리가 깜빡이기 시작한다. SID(C64의 사운드 칩)에서 짧은 비프음이 들릴 때마다 토큰이 하나씩 생성된다. 1MHz의 속도로 작동하는 8비트 컴퓨터에서 현대적인 LLM의 근간인 트랜스포머 모델이 작동하는 장면이다.

2.5만 파라미터와 6502 어셈블리 구현

Soul Player C64는 2계층의 디코더 전용 트랜스포머 구조를 가진다. 구체적인 제원은 4개의 어텐션 헤드와 헤드당 8차원, 32차원의 임베딩, 64개의 FFN(피드포워드 네트워크) 은닉 유닛으로 구성된다. 전체 파라미터 수는 약 25,000개이며 모두 int8(8비트 정수형)로 양자화되었다. 이 모델은 수정되지 않은 Commodore 64(8비트 홈 컴퓨터)에서 6502/6510(C64에 탑재된 중앙 처리 장치) 어셈블리로 직접 작성되어 구동된다.

추론 속도는 토큰당 약 60초가 소요되며 전체 응답을 받는 데 수분이 걸린다. 모델의 전체 크기는 플로피 디스크 한 장에 충분히 들어가는 수준이다. 사용자는 다음 과정을 통해 직접 모델을 훈련하고 빌드할 수 있다.

bash
python train.py
python build.py
python test.py

훈련 과정에서는 BPE(단어 조각 단위로 쪼개는 토큰화 방식) 토크나이저를 통해 128개의 토큰을 생성하며, QAT(훈련 단계에서 양자화 오차를 반영하는 기법)를 적용한 트랜스포머를 학습시킨다. 결과물로 models/soul.bin과 models/tokenizer.json이 생성되며, 이후 build.py를 통해 6502 어셈블리 루틴과 가중치를 결합하여 disk/soulplayer.prg 및 disk/soulplayer.d64 파일로 출력한다.

정수 연산의 한계 극복과 수치적 최적화

주목할 점은 6502 CPU에 곱셈 명령어가 없다는 사실이다. 모든 행렬 곱셈은 shift-and-add(비트 이동과 덧셈의 조합) 방식으로 처리된다. 모든 활성화 함수는 Q8.8 fixed-point(소수점 8자리 고정 소수점)인 int16 형식을 사용하며, 가중치는 텐서당 전력-2 시프트 스케일링이 적용된 int8 형식을 취한다. 편향 값은 matmul(행렬 곱셈) 누산기에 맞춰 미리 스케일링된 int16으로 처리된다.

그러나 단순한 양자화만으로는 모델이 작동하지 않았다. 특히 softmax(출력값을 확률 분포로 변환하는 함수)의 점수 정규화 과정에서 문제가 발생했다. 기존의 17비트 시프트 방식으로는 128개 항목의 exp lookup table(지수 함수 조회 테이블)이 가진 동적 범위가 부족해 어텐션 가중치가 균일하게 분포하는 현상이 나타났다. 반면 이를 14비트 시프트로 수정하자 유의미한 어텐션 가중치가 생성되며 모델이 문맥을 파악하기 시작했다.

또한 QAT 과정에서 FakeQuantI8(가짜 양자화)를 도입해 훈련 시의 연속적인 스케일과 실제 내보내기 시의 전력-2 시프트 그리드 사이의 불일치를 의도적으로 유도했다. 이는 모델이 양자화 간극에서도 살아남을 수 있는 더 넓은 로짓 마진을 학습하게 만드는 암시적 노이즈로 작용했다. 여기에 0.15의 Label smoothing(정답 레이블을 부드럽게 만드는 기법)을 적용해 int8 연산이 구별할 수 없는 수준으로 분포가 날카로워지는 것을 방지했다.

모델의 연산 흐름은 RMSNorm(신경망 출력을 일정 범위로 조절하는 정규화 방식)에서 시작해 멀티 헤드 인과적 셀프 어텐션, 잔차 연결, 다시 RMSNorm, ReLU MLP(비선형 활성화 함수를 사용하는 다층 퍼셉트론), 잔차 연결 순으로 이어진다. 마지막으로 RMSNorm과 출력 투영을 거쳐 argmax(최댓값의 인덱스를 찾는 함수)로 최종 토큰을 결정한다.

트랜스포머의 최소 작동 조건은 하드웨어의 성능이 아니라 수치적 정밀도의 제어 능력에 달려 있다.