F#으로 Game Boy 에뮬레이터를 만들었다
(nickkossolapov.github.io)- Fame Boy는 F#으로 구현된 Game Boy 에뮬레이터로, 사운드를 포함해 데스크톱과 웹에서 실행되며 브라우저 플레이와 GitHub 소스가 공개됨
- 에뮬레이터 코어와 프런트엔드는
framebuffer,audiobuffer,stepEmulator(),getJoypadState(state)만 공유하도록 단순화했고,stepper가 CPU·타이머·시리얼·APU·PPU를 순차 실행해 단일 스레드 동기화를 맞춤 - CPU 구현은 F#의 판별 공용체와
match를 활용해 512개 opcode를 58개 명령으로 모델링했으며,From·To타입으로 즉시값에 쓰는 불법 상태를 타입 수준에서 막도록 설계됨 - PPU는 실제 Game Boy의 픽셀 FIFO 대신 스캔라인 단위 렌더링을 택해 더 빠르고 단순해졌지만, 픽셀 큐 타이밍을 활용하는 일부 게임은 제대로 동작하지 않을 수 있음
- 웹 이식은 Fable로 해결했고, 8비트·16비트 비트 연산이 JavaScript 32비트 의미론을 따르는 문제를 수정한 뒤 약 100KB JS 번들로 동작했으며, 성능 최적화와 릴리스 빌드로 데스크톱에서 약 1000FPS까지 도달함
프로젝트 배경과 목표
- 소프트웨어 엔지니어로 8년 넘게 일했지만 컴퓨터가 실제로 어떻게 동작하는지 이해하지 못한다고 느껴, 직접 에뮬레이터를 만들며 배우기로 함
- 어린 시절 Pokémon을 많이 플레이했기 때문에 Game Boy를 대상으로 삼았고, 실제 하드웨어이면서 범위가 비교적 단순하고 개인적 연결도 강했음
- 곧바로 Game Boy에 들어가기 전에 From NAND to Tetris를 수강해 레지스터, 메모리, ALU 같은 컴퓨터 기본 요소를 이해함
- 에뮬레이터 제작에 익숙해지기 위해 F#으로 CHIP-8 에뮬레이터 Fip-8을 먼저 구현함
- 몇 달 동안 작업한 끝에 사운드를 포함하고 데스크톱과 웹에서 실행되는 Game Boy 에뮬레이터 Fame Boy를 완성함
- 브라우저에서 플레이할 수 있고, 소스는 GitHub에 공개됨
에뮬레이터 구조
- 데스크톱과 웹 양쪽에서 동작하도록, 에뮬레이터 코어와 프런트엔드 사이 인터페이스를 단순하게 유지함
- 프런트엔드와 코어 사이의 핵심 인터페이스는 두 배열과 두 함수로 구성됨
framebuffer: 흰색, 밝은 색, 어두운 색, 검은색을 담는 160×144 음영 배열audiobuffer: 32768Hz 샘플레이트의 링 오디오 버퍼이며 읽기·쓰기 헤드를 가짐stepEmulator(): CPU 명령 하나를 실행하고 소요 사이클 수를 반환함getJoypadState(state): 프런트엔드가 조이패드 상태를 에뮬레이터에 전달하는 콜백이며 보통 프레임마다 한 번 호출됨
- Fame Boy는 실제 Game Boy 하드웨어와 비슷한 방식으로 모델링됨
- CPU는 실제 Game Boy의 Sharp LR35902처럼 메모리 맵 외의 하드웨어를 알지 못하며, 인터럽트 신호를 위해 IoController만 사용함
- CPU는 코드베이스에서 가장 F#다운 부분이며 함수형 도메인 모델링을 많이 사용함
- Memory.fs는 Game Boy의 RAM 대부분을 보관하고 CPU, IO Controller, 카트리지 사이의 메모리 맵과 버스 역할을 함
- 성능을 위해 Memory.fs는 PPU와 같은 VRAM·OAM RAM 배열 참조를 공유함
- IoController.fs는 Memory.fs에 로직이 너무 많아지면서 분리됐고, 실제 Game Boy 하드웨어에는 단일 IO 컨트롤러가 없지만 하드웨어 레지스터 처리를 한곳에 모아 각 컴포넌트 인터페이스를 단순하고 안전하게 만듦
- Emulator.fs의
stepper함수가 전체 에뮬레이터를 묶는 접착제 역할을 하며, 각 컴포넌트의 단계 실행 함수를 조합함
let stepper () =
// Execute a single instruction
// Each instruction uses a different amount of cycles
let mCycles = stepCpu cpu io
for _ in 1..mCycles do
stepTimers timer io
stepSerial serial io
// The APU technically runs at 4x CPU-cycles, but can be batched
stepApu apu
let tCycles = mCycles * 4
// The PPU operates at 4x CPU-cycles. The APU should be here too
for _ in 1..tCycles do
stepPpu ppu
// Return cycles taken so the frontend runs the emulator at the right speed
mCycles
- 실제 하드웨어 컴포넌트는 중앙 마스터 오실레이터를 기준으로 병렬 실행되지만, Fame Boy는 단일 스레드이므로 컴포넌트를 순차 실행해야 함
stepper함수는 실행을 중앙화해 모든 컴포넌트가 동기화되도록 만듦- 플레이 가능한 속도를 내려면 초당 올바른 사이클 수로 실행돼야 하며, 60FPS 프레임당 약 17500 CPU 사이클이 필요함
- 프런트엔드는 사운드가 켜져 있으면 오디오 샘플링 레이트로 에뮬레이터를 구동하고, 음소거 상태에서는 프레임레이트로 구동함
CPU 구현과 F#
- CHIP-8 에뮬레이터는
mutable멤버 없이 순수하게 작성하고 배열도 복사했지만, Fame Boy는 변경 가능 상태를 적극적으로 사용함 - Game Boy는 CHIP-8보다 훨씬 빠르며, 16KB 이상의 메모리를 매초 수백만 번 복사하는 방식은 적절하지 않음
- Fame Boy에 F#을 쓴 이유는 F#의 풍부한 타입 시스템이 CPU 명령 모델링에 잘 맞고, F# 자체를 좋아하기 때문임
-
도메인 모델링
-
CPU 구현 때 Gekkio’s Complete Technical Reference를 따랐고, 해당 문서처럼 명령을 그룹화함
-
초기에는 Instructions.fs에 명령 종류별 판별 공용체를 둠
-
-
type LoadInstr = | Load8Immediate of uint8 | Load8Direct of Register | Load8Indirect // ... other load instructions
-
type ArithmeticInstr = | IncrementDirect of uint8 | IncrementIndirect of Register // ... other arithmetic instructions
-
-
여러 명령이 피연산자 위치라는 공통 개념을 공유함
- 명령 바로 뒤 메모리의 바이트 값을 읽는
immediate - CPU 레지스터를 읽고 쓰는
direct - HL CPU 레지스터가 가리키는 메모리 위치를 읽고 쓰는
indirect
- 명령 바로 뒤 메모리의 바이트 값을 읽는
-
위치 개념을 추출해
From과To타입으로 나누면서 로드 명령을 더 간결하게 표현함 -
-
type To = | Direct of Register | Indirect
-
type From = | Immediate of uint8 | Direct of Register | Indirect
-
type LoadInstr = | Load of From * To // These form a tuple, like Load<From, To> in C# // ... other instructions
-
-
이 방식으로 CPU 명령을 512개 opcode에서 58개 명령으로 줄임
-
도메인을 일반화하면 잘못된 상태를 허용할 위험이 있지만, 타입 시스템으로 방지할 수 있음
-
From과To대신 단일 위치 타입Loc을 쓰면Load(Loc.Direct D, Loc.Immediate)처럼 레지스터 값을 즉시값 위치에 저장하는 잘못된 명령이 컴파일될 수 있음 -
Game Boy 하드웨어는 즉시값에 쓰기를 지원하지 않으므로, F# 타입으로 도메인을 올바르게 모델링하면 불법 상태가 시스템에 표현되지 않도록 보장 가능함
-
단 하나의 예외로 opcode
0x76이 있음- opcode 패턴만 보면
Load(From.Indirect, To.Indirect)처럼 HL 위치의 8비트 값을 같은 HL 위치에 로드하는 형태가 됨 - Fame Boy의 타입은 이를 허용하지만 실제 Game Boy에는 이 명령이 없음
- 논리적으로는 NOP이며 위험하지 않고, 실제로 opcode 리더가
0x76을HALT로 디코딩하므로 도달할 수 없음
- opcode 패턴만 보면
-
F#의
match문과 Option을 쓴 뒤 일반switch문으로 돌아가면 투박하고 실수하기 쉽다고 느껴, 함수형 언어를 써보길 권함
-
-
단순하게 유지하기
-
프로젝트 목표가 최고의 에뮬레이터가 아니라 컴퓨터 하드웨어 학습이었기 때문에 다른 에뮬레이터 코드를 깊이 보지는 않음
-
CAMLBOY 소스에서 다음과 같은 코드를 보고, 원하는 플래그만 임의 순서로 전달할 수 있다는 점을 좋게 봄
-
-
set_flags ~h:false ~z:(!a = zero) ();
-
-
F#은 부분 적용을 지원하는 타입 시스템 때문에 메서드 오버로딩과 기본 매개변수를 피하므로 같은 방식으로 만들 수 없었음
-
처음에는 다음처럼 배열과 플래그 타입을 전달하는 방식으로 구현함
-
-
cpu.setFlags [ Half, false; Zero, a = 0uy ]
-
-
이후 리팩터링 과정에서 Cpu/State.fs L81에 다음과 같은 순수 함수 기반 구현으로 바꿈
-
-
module Flags = let inline setZ (v: bool) (f: uint8) = if v then f ||| ZMask else f &&& ~~~ZMask
let inline setH (v: bool) (f: uint8) = // ... the other flag functions and definitions
-
// Other files
-
cpu.Flags <- cpu.Flags |> setH false |> setZ (a = 0uy)
-
-
새 함수들은 쉽게 조합되고 테스트 가능하며 단순한 순수 함수임
-
이전 구현은 값을 판별 공용체 타입으로 끌어올리고 배열에 넣어야 해 더 장황했음
-
새 함수는 inline이고 힙 할당이 필요 없어 성능도 더 좋았으며, 에뮬레이터 FPS를 약 10% 높임
-
-
테스트
- 초기 CPU 구현은 Tetris ROM을 실행하면서 미구현 opcode에 도달할 때마다 해당 명령을 구현하는 방식이었음
-
- match opcode with
- | 0x00 -> Nop
- | _ -> failwith "Unimplemented opcode"
-
- 이 방식은 기술 문서를 무작위로 오가야 해 반복이 지루했고, 명령을 올바르게 구현했는지도 알기 어려웠음
- 두 문제를 해결하기 위해 단위 테스트를 도입함
- 학습을 위해 에뮬레이터 코드는 직접 작성했지만, 테스트 케이스 생성에는 AI를 활용함
- 기술 문서의 사양을 프롬프트에 넣고, 에뮬레이터 코드는 보지 않은 상태에서 사양 기반 테스트를 작성하게 함
- AI가 테스트를 생성하는 동안 직접 사양을 읽고, 테스트가 통과할 때까지 로직을 구현하는 방식으로 진짜 테스트 주도 개발을 진행함
- 이미 구현한 명령의 버그 몇 개도 테스트를 통해 발견함
- 테스트는 정기적으로 검토하고 개선했으며, 학습을 방해하기보다 흥미로운 부분에 에너지를 쓰는 데 도움을 줌
CPU 이후의 컴포넌트
-
PPU
- Game Boy에는 GPU가 아니라 PPU, 즉 picture processing unit이 있음
- 다른 Game Boy 에뮬레이터 제작 글들은 CPU에 집중하고 PPU는 몇 문단만 다룬 경우가 많았지만, Fame Boy에서는 PPU 이해에 더 오래 걸림
- CPU는 From NAND to Tetris와 CHIP-8 경험 덕분에 자연스럽게 느껴졌지만, PPU는 픽셀을 화면에 올리기 위한 단계를 따르는 기계적 작업에 가까웠음
- 처음에는 픽셀 FIFO와 전체 PPU 파이프라인을 한 번에 이해하려 하기보다, 메모리에서 타일과 배경 맵을 읽고 파싱해 화면에 표시하는 방식으로 시작함
- 이 방식으로 CPU가 동작하는 모습을 볼 수 있었고, Tetris의 단순함 덕분에 거의 실제 Game Boy 게임처럼 보이는 결과를 확인함
- 타일과 배경 뷰에서 시작한 접근은 실제 화면 구현부터 스프라이트 데이터의 세부 버그 디버깅까지 계속 도움이 됨
- Fame Boy의 PPU에는 하드웨어 부정확성이 큼
- 실제 Game Boy는 CRT 모니터처럼 FIFO 큐를 사용해 픽셀을 하나씩 화면에 놓음
- Fame Boy는 해당 라인의 그리기 기간 시작 시 전체 스캔라인을 렌더링함
- 이 방식은 더 빠르고 코드가 단순하며, 플레이하려던 게임들은 모두 동작했기 때문에 픽셀 큐로 옮길 필요를 느끼지 않음
- Game Boy 하드웨어를 한계까지 활용하고 픽셀 큐 타이밍을 이용한 게임들은 Fame Boy에서 제대로 동작하지 않지만, 대부분의 게임은 그렇게 모험적으로 하드웨어를 쓰지 않아 대체로 동작할 것으로 보임
-
Joypad
- PPU와 APU 외에 조이패드도 다룸
- 초기 구현은 매우 쉬웠고 테스트 작성도 간단했음
- 하지만 큰 리팩터링 뒤에는 거의 항상 깨졌음
- 조이패드 하드웨어 레지스터는 CPU와 게임이 모두 읽고 쓰기 때문에 상호작용이 복잡함
- 초기에는 CPU가 매 사이클 조이패드 상태를 레지스터에 쓰게 했지만, 사람이 버튼을 초당 수백만 번 바꾸지는 않으므로 프레임당 한 번만 업데이트하도록 바꿈
- 그 결과 방향 패드가 동작하지 않게 됨
- Game Boy 하드웨어는 한 번에 버튼 절반만 읽을 수 있고, 게임들은 거의 항상 조이패드 레지스터를 짧은 간격으로 두 번 이상 읽어 두 읽기 사이에 레지스터가 바뀌는 것에 의존함
- 프레임당 한 번 캐시된 레지스터는 두 읽기 사이에 바뀌지 않아 버튼 절반이 동작하지 않았음
- 최종적으로 IoController가 CPU가 읽을 때만 조이패드 레지스터를 업데이트하도록 구현함
- 관련 내용은 Pandocs의 joypad 문서에서 더 볼 수 있음
-
사운드
- 동작하는 에뮬레이터를 만든 뒤 웹 버전을 플레이하다가 사운드가 없으면 비어 보인다고 느껴 APU, 즉 audio processing unit을 추가함
- 여러 에뮬레이터는 프레임레이트가 아니라 프런트엔드 오디오 샘플링 레이트로 에뮬레이터를 구동한다는 사실을 발견함
- 처음에는 이를 거꾸로 느껴 동적 샘플링 레이트를 조사했고, 프레임레이트가 에뮬레이터를 구동하도록 구현하려 함
- 사운드는 개념적으로 가장 어려운 컴포넌트였으며, 여러 사운드 레지스터와 채널의 동작을 이해하는 데 시간이 걸림
- 이 부분에서는 AI가 교사 역할로 큰 도움이 됐고, 코딩 전에 여러 차례 질문과 답변을 주고받음
- PPU와 비슷하게 채널을 하나씩 완성할 때 만족감이 컸고, Tetris 음악이 점점 풍성해지는 과정을 들으며 음악이 어떻게 구성되는지도 이해하게 됨
- CPU와 PPU는 프레임마다 정확히 X개의 작업을 수행하는 형태이고 X를 쉽게 계산할 수 있지만, APU는 선택하고 조율할 값이 많았음
- APU 샘플링 레이트만은 쉽게 정함
- 실제 Game Boy APU는 유연하므로 에뮬레이터가 원하는 샘플링 레이트를 쓸 수 있음
- Fame Boy는 32768Hz를 선택함
- 1048576Hz CPU 클록에서 32768Hz는 128 CPU 사이클당 1샘플이므로, APU 상태가 정수만으로도 완벽히 동기화될 수 있음
- 128은 4로도 나누어떨어지므로 APU 단계를 4개씩 배치 처리해도 CPU 명령과 정렬이 어긋나지 않음
- 다른 값들은 훨씬 불안정했고, 사운드 엔지니어가 아니기 때문에 값을 바꿔가며 맞춰야 했음
- 프런트엔드마다, 플랫폼마다 고유한 문제가 있었음
- PC에서는 사운드가 잘 동작했지만 MacBook에서는 폭포 소리처럼 들림
- MacBook 문제를 고치자 데스크톱 PC 버전이 경쟁 조건 때문에 실행되지 않음
- 동적 샘플링 레이트로 똑똑하게 해결하려던 시도를 포기하고, 오디오가 에뮬레이터를 구동하도록 바꾸자 여러 장치에서 오디오가 훨씬 안정적이 됨
- 오디오는 에뮬레이터와 프런트엔드 인터페이스에서 가장 새는 부분이지만, 불협화음을 피하려면 정확한 동기화가 필요함
에뮬레이터 구동 방식
- 오디오 기반 구동과 프레임 기반 구동의 차이는 인간 지각과 관련됨
- 오디오 신호가 끊기면 스피커가 신호의 급격한 변화 때문에 크게 움직이며 팝 노이즈가 생김
- 비디오가 끊기면 데이터가 제때 오지 않아 비디오 플레이어가 프레임 하나둘을 건너뛰지만, 물리적인 것을 밀어내는 것이 아니어서 감각적으로 덜 거슬림
- Fame Boy 내부에서는 오디오와 비디오가 설계상 완벽히 동기화됨
- 하지만 실행 중인 컴퓨터의 오디오와 비디오는 독립적이며 어느 한쪽이 때때로 뒤처질 수 있음
- 프런트엔드 오디오와 비디오가 어긋나면 두 선택지가 있음
- 프런트엔드 오디오와 에뮬레이터 오디오를 동기화하고 가끔 프레임을 드롭함
- 프런트엔드 비디오와 에뮬레이터 프레임을 동기화하고 가끔 오디오를 드롭함
- 선택한 쪽이 에뮬레이터를 “구동”하며, 다른 쪽은 최대한 가까이 유지함
- 프레임레이트 기반 구동은 비교적 단순함
let mutable cycles = 0
while (runEmulator) do
cycles <- cycles + targetCyclesPerMs * lastFrameTime
while cycles > 0 do
let cyclesTaken = stepEmulator ()
cycles <- cycles - cyclesTaken
draw ppu.framebuffer
- 사운드 기반 구동은 Raylib와 Web Audio의 오디오 처리 방식이 달라 더 까다로움
- 일반 흐름은 다음과 같음
let tryQueueAudio apu stepEmulator =
if frontend.audioBuffer.hasSpace () then
while apu.writeHead - apu.readHead < samplesNeeded do
stepEmulator ()
frontend.audioBuffer.fill apu.audioBuffer
while (runEmulator) do
tryQueueAudio apu stepEmulator
draw ppu.framebuffer
- 핵심 차이는
stepEmulator가 더 이상lastFrameTime으로 제어되지 않고, 프런트엔드 오디오 버퍼의 필요에 따라 구동된다는 점임 samplesNeeded는 서로 다른 샘플링 레이트에 맞고 60FPS를 만들 수 있도록stepEmulator호출 횟수를 계산해야 함- 프런트엔드 오디오 버퍼는 자신을 채우는 것만 신경 쓰므로, 프레임당
stepEmulator를 너무 많이 또는 너무 적게 호출할 수 있고, 그 결과framebuffer가 제때 업데이트되지 않을 수 있음 - 웹 프런트엔드는 URL에 ?frame-driven를 추가하면 프레임 기반 버전을 시험할 수 있음
- 프레임 기반 버전은 시각적으로 더 부드럽지만 가끔 오디오 팝이 생김
- 오디오 기반 웹 프런트엔드도 음소거 버튼이 눌리면 팝이 들리지 않으므로 프레임 기반으로 전환함
- 구현은 완벽하지 않지만, 오디오 팝이 프레임 끊김보다 더 나쁜 인상을 주고 음소거 상태는 비어 보였기 때문에 웹 프런트엔드의 기본값을 오디오 기반으로 정함
- 오디오는 Fame Boy에서 만족스럽지 않은 몇 안 되는 영역이며, 언젠가 다시 손보고 싶은 부분임
Fable로 웹에 올리기
- PPU가 어느 정도 동작해 데스크톱 화면에 무언가 보이기 시작한 뒤, Fame Boy를 웹으로 옮기려 함
- Fable 문서를 보고 패키지를 설치하고 메인 루프를 설정하고 스타일을 추가해 한두 시간 만에 실행 준비를 마침
- 처음 실행한 Fable 버전은 화면이 이상하게 나왔고, 디버깅을 조금 하다가 시간을 너무 쓰지 않기 위해 Blazor의 WebAssembly를 시도함
- Blazor도 실행 자체는 쉽게 됐고 이번에는 실제로 동작했지만, 약 8FPS 정도로 거의 플레이할 수 없었음
- Blazor 자체 문제인지는 확실하지 않으며, .NET 팀의 성능 가이드도 따라봤지만 도움이 되지 않음
- 디버깅도 불편해 다시 Fable로 돌아가 JavaScript 변환 과정에서 무엇이 잘못됐는지 확인함
- Fable은 변환된 JS 파일을 소스 코드 바로 옆에 두며, 실제로 꽤 읽기 쉬웠음
- 이 덕분에 새 코드를 이해하고 브라우저 개발자 도구에서 디버깅하기가 쉬웠음
- 개발자 도구에서 CPU 레지스터 값이 이상한 것을 발견함
- Fame Boy와 Game Boy의 CPU 레지스터는 8비트 부호 없는 정수라 범위가 0–255여야 함
- 그런데
-15565461같은 값이 보임
- Fable 문서에서 numeric types 호환성 문서를 찾음
(non-standard) Bitwise operations for 16 bit and 8 bit integers use the underlying JavaScript 32 bit bitwise semantics. Results are not truncated as expected, and shift operands are not masked to fit the data type.
- 16비트와 8비트 정수의 비트 연산이 JavaScript의 32비트 비트 연산 의미론을 사용하고, 결과가 예상처럼 잘리지 않는다는 설명과 맞아떨어짐
- 코드에서 8비트 값이 잘려야 하는 지점을 찾아 관련 문제들을 수정하자 웹 프런트엔드가 제대로 동작함
- .NET 런타임 없이 JS만 쓰므로 웹 번들은 약 100KB임
- 특이한
uint8문제를 제외하면 Fable 사용 경험은 꽤 쾌적했고, 모든 소스 코드를 F#으로 유지할 수 있었음
성능 개선
- 화면에 결과가 보이기 시작한 뒤 간단한 FPS 콘솔 로그를 추가함
- 초기에는 디버그 모드에서 약 55–60FPS였고, Raylib가 v-sync를 유지하려 한 영향으로 보임
- v-sync를 끄자 약 70FPS까지 올라갔지만 지터가 생김
- 이후 기능이 추가되면서 성능이 점차 떨어져 45FPS에 도달했고, v-sync를 꺼도 도움이 되지 않음
- JetBrains Rider 프로파일러를 실행하자
mapAddress가 의심스러운 병목으로 나타남 - 거의 모든 컴포넌트가 메모리에 접근하므로 메모리 접근 비용이 예상보다 큰 것을 확인함
- 문제가 된 코드는 메모리 주소를 판별 공용체인
MemoryRegion으로 매핑한 뒤 읽고 쓰는 방식이었음
type MemoryRegion =
| RomBase of offset: int
// ... others
let mapAddress (addr: int) : MemoryRegion =
match addr with
| a when a < 0x4000 -> RomBase a
// ... others
type DmgMemory(arr: uint8 array) =
// Arrays for romBase etc
member this.read address =
match mapAddress address with
| RomBase i -> romBase[i]
// ... others
member this.write address value =
match mapAddress address with
| RomBase _ -> ()
// ... others
- CPU 도메인 모델링에서 얻은 흐름을 메모리에도 확장하려 했고, 그 결과 모든 메모리 읽기·쓰기마다
MemoryRegion객체가 만들어지고 매핑됨 - 이 방식은 매초 수백만 개 객체를 힙에 할당하고, JIT 컴파일러가 처리해야 할 분기도 늘림
- 판별 공용체와 매핑 함수를 제거하고 배열에 직접 접근하도록 바꾸는 한 번의 변경으로 FPS가 두 배가 됨
- 이후 벤치마크에서 성능 개선 대부분은 분기와 지역화된 호출 지점에 대한 JIT 최적화에서 온 것으로 보임
MemoryRegion을 struct DU로 바꿔 스택에 할당되게 해도 성능은 약 15%만 개선됐고, 나머지 85%는 DU와 매핑 함수 제거에서 나옴- 이후에도 struct DU로 옮기거나 F# 친화적이지 않은 접근을 택한 경우가 더 있었음
- PPU 구현 시점부터 최적화가 필요해졌고, 어느 정도 관용적인 F#을 포기해야 했음
- 프로파일러를 정기적으로 보며 성능을 천천히 개선해 약 120FPS까지 올림
- 가장 큰 FPS 개선은 디버그 빌드를 끄는 것이었고, 릴리스 모드에서 약 1000FPS까지 올라감
- 끝까지 성능을 정기적으로 모니터링하고 조정함
벤치마크
- 콘솔 FPS 숫자만 보는 것은 좋은 성능 측정 방식이 아니라고 보고, 프로젝트 중간에 BenchmarkDotNet 프로젝트를 추가해 데스크톱 성능을 측정함
- 이후 Node.js를 사용하는 간단한 웹 벤치마커를 만들어 웹 브라우저 성능도 비슷하게 추정함
- 벤치마크에는 실제적인 시나리오를 테스트하기 위해 다음 데모 ROM을 사용함
- Ryzen 9 7900 Windows PC와 M4 MacBook Air의 데스크톱 FPS 성능은 다음과 같음
| CPU | Flag | Roboto | Merken |
|---|---|---|---|
| Ryzen 9 7900 | 1785 | 1943 | 1422 |
| Apple M4 | 1907 | 2508 | 1700 |
- 웹 FPS 성능은 다음과 같음
| CPU | Flag | Roboto | Merken |
|---|---|---|---|
| Ryzen 9 7900 | 646 | 883 | 892 |
| Apple M4 | 779 | 976 | 972 |
- Fame Boy는 두 플랫폼 모두에서 준수하게 동작함
- 예상과 달리 APU, 즉 사운드가 PPU보다 에뮬레이터 성능에 더 큰 영향을 줌
- PPU를 끄면 데스크톱 성능이 약 250FPS 증가하지만, APU를 끄면 약 500FPS 증가함
AI 사용
- 학습 프로젝트에서도 AI의 영향을 완전히 피할 수 없다고 보고, AI 사용 방식을 투명하게 남김
- 전체 과정에서 AI는 주로 보조 도구로 사용함
- 코드 리뷰 요청
- 아이디어를 검토하는 대화 상대
- 간결한 기술 문서 해석
- AI가 작성한 코드는 최대한 줄이려 함
- 사람에게 보여주고 자랑스러울 수 있는 결과물을 만들고 싶었기 때문에, 프롬프트만 공유하는 방식이 아니라 직접 만든 코드로 남기려 함
-
성능 개선 PR
-
“타이머 겨울”
-
Git 히스토리에는 큰 공백이 있으며, 이 기간을 “timer winter”라고 부름
-
에뮬레이터 작업을 하지 않은 것이 아니라 Tetris의 저작권 화면을 넘기지 못하는 버그에 막혀 있었음
-
20시간 넘게 디버깅하고, emu-dev Discord를 검색하고, 테스트를 만들고, 초기 AI 모델에도 문제를 던졌지만 해결되지 않음
-
몇 주 쉬었다가 Claude Opus를 시도했고, 몇 분 만에 문제를 찾음
-
문제는 타이머가 명령당 한 번만 tick되고, 명령이 소비한 사이클 수만큼 tick되지 않았다는 점이었음
-
-
let stepEmulator () = let cyclesTaken = stepCpu cpu
// Before stepTimers timer memory // only once per instruction
// The fix for _ in 1..cyclesTaken do // cpuCycles can vary between 1 and 6 stepTimers timer memory
-
-
CPU 사이클은 1에서 6까지 달라질 수 있으므로, 기존 구현에서는 타이머가 평균적으로 실제보다 2~3배 느리게 동작했음
-
저작권 화면은 단지 더 오래 남아 있었을 뿐이며, 1~2분 기다려보지 않았던 것이 문제였음
-
본문 자체는 대부분 직접 작성함
-
배운 점과 결론
- 주된 목표는 컴퓨터가 어떻게 동작하는지 배우는 것이었고, 그 목표에서는 큰 성공이었음
- 작업은 매우 재미있었고, 퇴근 뒤 “오늘은 기능 하나만”이라고 시작했다가 새벽 2시까지 버그 하나만 더 고치겠다고 반복하는 식으로 몰입함
- Game Boy Advance도 시도해볼까 생각했지만, 사양을 보면 하드웨어 이해 증가는 약 20%인 반면 노력은 3배쯤 필요해 보였음
- Game Boy는 학습을 돕는 균형이 좋았고, 당분간은 여기서 멈출 수 있음
- 더 나은 소프트웨어 엔지니어가 됐는지는 확실하지 않지만, 매일 쓰는 도구에 대해 조금 더 이해하게 된 것은 분명함
- 질문이나 의견은 이메일로 보낼 수 있음
Hacker News 의견들
-
여기서 F# 을 보니 반가움! 에뮬레이터는 언어를 배우기에 좋은 방법이고, 처음 보기엔 작업별로 관용적인 F#과 덜 관용적인 F# 사이를 잘 골라 쓴 것 같음
할당을 줄이는 쉬운 개선점으로는Instructions.fs의 구별 공용체(discriminated union)에[<Struct>]를 붙이고, 필드 이름을 재사용해 내부 필드를 재사용하게 할 수 있음
사소한 지적이지만 일부 레지스터 처리가 헷갈림. 이미byte타입이라 setter에서a &&& 0xFFuy를 해도member val A = 0uy with get, set보다 추가되는 게 없어 보임. 아마 개발 중에 바뀐 흔적 같음Register소스에 이런 주석이 있음: 레지스터는 쓰기 시 값을 8비트로 잘라야 하므로 레코드 타입이 될 수 없고 setter가 필요하다고 되어 있음
웹 렌더러 때문에 그렇고, Fable이 JS에서uint8을Number로 변환해 8비트를 넘길 수 있으며 잘라내기를 적용하지 않는다는 설명임
그래서 웹 대상에서 JSNumber로 넓어지는 Fable 특성 때문에 보수적으로 데이터를 정리하는 코드로 보임- 실제로 글에서 Fable로 포팅하는 부분에 이 내용이 다뤄짐. Blazor도 시도했다고 함
-
드디어 누군가가 무언가를 배우기 위해 실제 인간의 노력을 들였고, “LLM이 X를 Y분 만에 만들게 도와줬다”가 아니라서 좋음
그래도 인류에게 아직 희망이 조금은 있는 듯함- 그런 방식은 늘 남아 있을 것임. 2026년에도 손도구로 뭔가를 만드는 사람들이 있으니, 이걸 수공예 코딩이라고 부르자
- 인류에 대한 희망은 소련이 무너질 무렵쯤 이미 접었어야 했다고 봄
그래도 에뮬레이터는 정말 멋지고, GBA 에뮬레이터는 직접 도전해 보기 좋은 대상임 - 오랫동안 F# 개발자로 살아왔고, STEM 학계에서 괴롭힘도 오래 겪어온 입장에서 LLM을 쓰지 않음. 큰 이유는 ChatGPT-3.5가 F# GitHub 저장소에서 복붙한 티를 너무 노골적으로 냈기 때문임
AGI라는 느낌은 전혀 없었고, 장식이 벗겨진 표절 기계처럼 보였음
언젠가 Microsoft의 누군가가 알아채고 RLHF 경보를 울렸을 테니 GPT는 꽤 나아졌고, F#에도 제법 쓸 만해 보임. 원칙 없는 F# 개발자라면 요즘 에이전트로 잘 해내고 있을지도 모름
하지만 “표절 문제를 해결했으니 이제 잡동사니를 생성하자”가 아니라, “이제 ChatGPT가 표절해도 더는 노골적으로 드러나지 않겠구나”라고 느꼈음
생산성 이득을 얻자고 내 핵심 가치 하나를 완전히 훼손할 확률을 d100이나 d1000으로 굴리고 싶지 않음. 느리고 무직인 채로 지내겠음. 진지하게, 태양광 설치와 폐품 수거 쪽으로 들어가고 있음
“학생들이 생각하고 싶어 하지 않는다”는 문제는 LLM보다 훨씬 오래됨. 2007년에 고학년 편미분방정식 수업을 들었는데, PDE를 진짜 공부하려던 내가 숙제를 거의 다 풀었고, 심리적으로 약해서 못된 게으른 수학 전공자들을 거절하지 못해 거의 모두가 내 숙제를 베꼈음. 대학원 수학 과정에서도 또 그랬음. 정말 믿기 어려움. 그럴 거면 왜 그 과정에 있는 건지 모르겠음
-
아, F#, 내 가장 큰 사랑. C# 쪽 사람들이 C#을 계속 이것저것 다 되지만 어설픈 언어로 망가뜨리지 말고 이걸 봤으면 좋겠음
C#과 F#을 함께 쓰는 프로젝트를 만들면, C#에 계속 추가되는 것들을 실제로 잘 작동하고 인체공학적으로 얻을 수 있다는 걸 왜 못 보는지 모르겠음. 상호운용성도 훌륭함- 다만 OCaml 세계에서 오면 F#이 C#의 그림자에 조금 갇혀 있는 것처럼 느껴져서 아쉬움
F#을 함수형 언어처럼 써도 꽤 멀리 갈 수 있지만, 결국 .NET 생태계와 상호운용하고 싶어지고, 그 순간 이상한 객체지향/함수형 하이브리드 스타일로 코딩하게 됨
- 다만 OCaml 세계에서 오면 F#이 C#의 그림자에 조금 갇혀 있는 것처럼 느껴져서 아쉬움
-
F# 은 좋은 언어지만 영원히 C#의 그림자에 갇힌 느낌임. 라이브러리 코드 상당수가 C#과 .NET에서 물려받은 것이고, F#을 염두에 두고 설계된 인터페이스나 라이브러리가 아닌 경우가 많으며, F# 사용법 문서도 명시적으로 없는 일이 흔함
- C#에서 F#으로 라이브러리 사용법을 옮기는 건 꽤 기계적인 작업이라, 별도 문서가 꼭 필요한지는 잘 모르겠음
더 큰 문제는 C# 커뮤니티가 객체지향을 좋아해서, 함수형 프로그래밍 방식으로 일하고 싶다면 이런 라이브러리들을 더 “함수형”인 형태로 감싸야 하는 경우가 많다는 점임
그래도 아무것도 없는 것보다는 훨씬 낫다고 봄. Haskell이나 OCaml도 좋아하지만 그런 면에서는 비교가 됨 - 둘의 상호작용 때문에 어느 정도 어색함이 생기는 건 맞지만, 특정 라이브러리가 F#에 잘 맞게 매핑되어야 한다기보다는 상호운용 규칙과 생성되는 내부 출력의 형태를 잘 이해하는 문제가 더 크다고 봄
C# 상호운용성은 F# 코드가 일반적으로 의존하는 보장, 특히 불변성을 느슨하게 만듦. C#으로 매핑되는 방식 때문에 제네릭에서도 의외의 한계가 나타남
- C#에서 F#으로 라이브러리 사용법을 옮기는 건 꽤 기계적인 작업이라, 별도 문서가 꼭 필요한지는 잘 모르겠음
-
정말 멋짐! F#을 좋아하지만, 작은 Smalltalk 인터프리터를 F#으로 써본 결과 이런 종류의 작업에서 의도된 방식대로 쓰면 정확히 속도 괴물은 아니라는 건 확인했음
- F#에서는 멍청할 정도로 명령형으로 짜되, 부작용을 함수 안에 가둬두면 성능이 더 잘 나오는 걸 봄. 그러면 함수는 사실상 “순수”하게 유지하면서도 괜찮은 속도를 얻을 수 있음
예를 들어 보통Map자료구조를 좋아하고, 꽤 훌륭한 불변 구조라 대부분의 용도에는 충분함. 하지만 성능이 중요해지면 일반 해시 맵을 쓰는 지루한 명령형 루프로 들어가는 것도 어렵지 않음
모든 걸 함수 하나 안에 가둬두면 대체로 너무 더럽게 짰다는 느낌을 피할 수 있음 - 그 인터프리터를 언제 썼는지 궁금함. .NET 생태계 전체가 지난 몇 년 동안 엄청난 속도 개선을 겪었고, 특히 Framework 시대에 마지막으로 써본 사람이라면 차이가 큼
심지어 C# 컴파일러도 활용하지 않는 꼬리 호출 개선에도 공을 들였음. .NET 9나 10 즈음에는 F#에 꼬리 호출이 아닌 재귀 호출이 있으면 컴파일러 오류를 내게 하는 특성도 추가돼서, 실수로 망치지 않게 해줌 - 어떤 기능을 언제 쓸지 조심하면 F#도 매우 빠를 수 있음. 원할 때는 함수형 패러다임을 쓰고, 필요하면 뜨거운 루프에서 저수준 명령형 코드를 쓰면 됨
다만 연결 리스트, 시퀀스, 불변 자료형을 사방에 쓰면 Rust는 절대 아님
- F#에서는 멍청할 정도로 명령형으로 짜되, 부작용을 함수 안에 가둬두면 성능이 더 잘 나오는 걸 봄. 그러면 함수는 사실상 “순수”하게 유지하면서도 괜찮은 속도를 얻을 수 있음
-
멋진 프로젝트임! 이런 걸 보니 정말 좋음
한편으로, 이건 작성자나 작업 자체에 대한 평가는 아니지만 실제 프로젝트에서 F# 코드가 어떻게 보이는지 보고 나니 F#을 배우고 쓰고 싶던 마음은 접어도 되겠다고 느꼈음
순수 함수형 부분은 아름답지만, 더 명령형이거나 변경 가능한 코드로 내려가면 보기 꽤 못생겼다고 느낌. 불행히도 대부분의 실제 프로젝트에서는 결국 그렇게 해야 할 것 같음
그래서 다른 함수형 언어를 골라 뛰어들어야 하는 건지, 아니면 이미 쓰는 언어에 함수형 개념을 적용하는 데 집중해야 하는 건지 모르겠음. 주 언어가 C#이고 함수형 패러다임 지원이 계속 늘고 있어서 후자는 꽤 쉬운 편임 -
함수형 언어로 작성된 에뮬레이터는 늘 인상적임. 하드웨어를 명령형 언어에 매핑하는 편이 보통 훨씬 쉽기 때문임. 사람들이 어떤 함수형 추상화를 만들어내는지 보는 게 즐거움
- 코드를 봤는지 궁금함. F#에는 변경 가능한 변수와 배열이 있고, 이 프로젝트도 예를 들어 메모리에 그걸 사용함
-
F#은 정말 재미있는 언어이고, 멋진 작업임!
-
F# 은 내가 절대 실무에서는 쓰지 못하는 코딩 사랑의 언어임. 개인 프로젝트 밖에서는 쓸 기회가 없음 :(
-
흥미롭고 즐거운 글임. 데이터 모델링 부분이 좋았음. OCaml을 조금 만져보고 있는데 그런 식의 모델링이 가장 좋은 부분임
CAMLBOY를 알게 된 것도 흥미로웠음. 작성자에게 피드백하자면, AI 편집 단계는 건너뛰는 편이 좋겠음. 지금처럼 조금 밋밋한 글보다는 문법 오류나 덜 세련된 표현이 있는 쪽을 더 선호했을 것 같음