F#으로 Game Boy 에뮬레이터를 만들었다

1 week ago 18
  • 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.fsMemory.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로 디코딩하므로 도달할 수 없음
    • 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을 사용함
    • Flag: 사운드가 없는 짧은 루프
    • Roboto: 많은 시각 효과와 사운드를 사용하는 1분 이상의 장기 실행 데모
    • Merken: Roboto와 비슷하지만 메모리 뱅킹 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

    • 프로젝트 끝부분에서 CLI에 저장소를 넘기고 성능 개선을 찾아보게 함
    • 몇 가지 아이디어를 주고 그 외에 원하는 시도도 해보게 했으며, 일부 벤치마크에서 성능을 두 배 이상 높임
    • 자세한 내용은 PR에 있음
    • 다만 버그도 들어갔고 직접 찾아 고쳐야 했음
    • 큰 성능 개선 중 하나였던 “mode/LY 전환 시에만 STAT 업데이트”는 더 자주 업데이트되는 것에 의존하는 일부 게임과 데모를 깨뜨렸고, 수정 커밋으로 고침
  • “타이머 겨울”

    • 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는 학습을 돕는 균형이 좋았고, 당분간은 여기서 멈출 수 있음
  • 더 나은 소프트웨어 엔지니어가 됐는지는 확실하지 않지만, 매일 쓰는 도구에 대해 조금 더 이해하게 된 것은 분명함
  • 질문이나 의견은 이메일로 보낼 수 있음
Read Entire Article