순수 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 훈련 데이터
| x1 | x2 | target |
|---|---|---|
| 0.0 | 0.0 | 0.0 |
| 0.0 | 1.0 | 1.0 |
| 1.0 | 0.0 | 1.0 |
| 1.0 | 1.0 | 0.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-xmm7은 caller-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 |
fldl2e | log₂(e) 상수를 스택에 push (하드웨어 내장) |
fmulp | ST0 × ST1, 결과 push |
frndint | ST0을 가장 가까운 정수로 반올림 |
f2xm1 | 2^(ST0) - 1 계산 (ST0 ∈ [-1, 1]) |
fscale | ST0 × 2^(ST1) |
다음 단계
- ReLU 활성화 함수로 교체 — 어셈블리에서 훨씬 단순하고 빠름
- SIMD 최적화:
mulss→mulps로 4개 뉴런을 한 번에 처리 - 랜덤 가중치 초기화: 하드코딩 대신 난수 생성기 사용
관련 개념
- 신경망 (Neural Network)
- 역전파 알고리즘 (Backpropagation)
- SIMD 최적화
- x86-64 어셈블리