순수 x86-64 어셈블리로 신경망 구현하기

라이브러리도 없고 지름길도 없다. 오직 CPU만으로 XOR 문제를 푸는 신경망을 어셈블리로 직접 구현한다.

필요 조건: NASM + GCC가 설치된 Linux 환경, 신경망 기초 지식(레이어/가중치/활성화 함수)


네트워크 아키텍처

XOR 문제는 단층 퍼셉트론으로 풀 수 없어 적어도 하나의 은닉층이 필요하다.

Input Layer (2 neurons)
       ↓
Hidden Layer (2 neurons, sigmoid activation)
       ↓
Output Layer (1 neuron, sigmoid activation)

구조: 2 → 2 → 1

XOR 훈련 데이터

x1x2target
0.00.00.0
0.01.01.0
1.00.01.0
1.01.00.0

핵심 개념

순전파 (Forward Pass) 수식

손실 함수 (MSE)

역전파 (Backpropagation)

시그모이드 미분의 편리한 성질: σ’(x) = σ(x) × (1 - σ(x))

d_output = error * sigmoid_derivative(output)
d_h1 = d_output * w5 * sigmoid_derivative(h1)
d_h2 = d_output * w6 * sigmoid_derivative(h2)

가중치 업데이트

weight = weight + learning_rate * delta * input_to_that_weight

어셈블리 구현의 핵심 포인트

1. 메모리 레이아웃

어셈블리에는 float weights[6] 같은 배열 선언이 없다. .data.bss 섹션에 직접 바이트를 예약하고 관리한다.

  • dd 0.5: 32비트 float(4바이트)를 값과 함께 정의
  • resd 1: 4바이트 공간을 미초기화 상태로 예약
  • 훈련 데이터는 플랫 배열: 4 samples × 3 floats × 4 bytes = 48 bytes

2. 레지스터 보존 문제 (가장 중요!)

System V AMD64 호출 규약에서 xmm0-xmm7caller-saved — 함수 호출 시 덮어써질 수 있다.

sigmoid 호출 전에 입력값을 반드시 메모리에 저장하고, 호출 후 다시 로드해야 한다:

movss [save_x1], xmm0   ; sigmoid 호출 전 x1 저장
call sigmoid
movss xmm0, [save_x1]   ; 호출 후 x1 다시 로드

3. Sigmoid 구현 — x87 FPU 활용

어셈블리에는 exp() 명령어가 없다. e^x = 2^(x × log2(e)) 항등식으로 구현:

fld dword [rsp]    ; -x를 FPU 스택에 로드
fldl2e             ; log₂(e) 상수 로드 (CPU에 내장!)
fmulp              ; -x * log₂(e) 계산

f2xm1은 입력값이 [-1, 1] 범위에서만 동작하므로 정수부와 소수부로 분리해 처리한다:

fld st0      ; 복사
frndint      ; 정수부
fsub st1,st0 ; 소수부 = 원래값 - 정수부
fxch
f2xm1        ; 2^(소수부) - 1
fld1
faddp        ; 2^(소수부)
fscale       ; × 2^(정수부) = exp(-x) 완성

4. 포인터 산술로 배열 순회

lea r14, [train_data]  ; r14 = 훈련 데이터 시작 주소
; ...
add r14, 12            ; 다음 샘플로 이동 (3 floats × 4 bytes)

5. printf 호출 시 주의사항

  • cvtss2sd: printf%f는 64비트 double을 요구하므로 32비트 float을 변환해야 한다
  • mov rax, 4: 가변 인자 함수 호출 전 xmm 레지스터 사용 개수를 rax에 명시해야 한다

빌드 및 실행

# 어셈블
nasm -f elf64 nn.asm -o nn.o
 
# 링크 (libc for printf)
gcc nn.o -o nn -lm -no-pie
 
# 실행
./nn

예상 출력

Training complete (10000 epochs)
Input: 0.0, 0.0  |  Target: 0.0  |  Output: 0.0432
Input: 0.0, 1.0  |  Target: 1.0  |  Output: 0.9541
Input: 1.0, 0.0  |  Target: 1.0  |  Output: 0.9538
Input: 1.0, 1.0  |  Target: 0.0  |  Output: 0.0517

출력이 전부 0.5 근처면 수렴 실패 → 초기 가중치를 바꾸거나 에포크 수를 늘릴 것


x87 FPU 주요 명령어 참고

명령어동작
fld dword [mem]32비트 float을 FPU 스택에 push
fldl2elog₂(e) 상수를 스택에 push (하드웨어 내장)
fmulpST0 × ST1, 결과 push
frndintST0을 가장 가까운 정수로 반올림
f2xm12^(ST0) - 1 계산 (ST0 ∈ [-1, 1])
fscaleST0 × 2^(ST1)

다음 단계

  • ReLU 활성화 함수로 교체 — 어셈블리에서 훨씬 단순하고 빠름
  • SIMD 최적화: mulssmulps로 4개 뉴런을 한 번에 처리
  • 랜덤 가중치 초기화: 하드코딩 대신 난수 생성기 사용

관련 개념

  • 신경망 (Neural Network)
  • 역전파 알고리즘 (Backpropagation)
  • SIMD 최적화
  • x86-64 어셈블리