- OCaml을 예제 수준에서 넘어 중간 규모 코드에 적용하기 위해 Game Boy 에뮬레이터 CAMLBOY를 만들었고, 브라우저 실행과 스마트폰 플레이 가능 성능을 목표로 삼음
- 구현은 CPU, timer, GPU를 CPU 사이클에 맞춰 따라잡게 하는 catch up method, 주소별 읽기·쓰기 라우팅을 맡는 bus, 8비트·16비트 접근 인터페이스로 구성됨
- CPU 테스트성을 높이기 위해 bus 구현을 functor로 주입했고, 명령어 인자 혼동은 GADT로 8비트·16비트 타입을 분리해 줄임
- 통합 테스트는 test ROM과
ppx_expect를 결합해 회귀를 잡고 탐색적 구현을 가능하게 했으며, 브라우저 UI는js_of_ocaml과Brr로 구현함 - Chrome profiler로 GPU·timer·
Bigstringaf병목을 줄인 뒤js_of_ocaml인라이닝을 꺼 PC 브라우저 100 FPS, 스마트폰 60 FPS에 도달함
CAMLBOY의 목표와 범위
- CAMLBOY는 OCaml로 작성된 Game Boy 에뮬레이터이며 브라우저에서 실행됨
- 데모에는 여러 홈브루 ROM이 포함됐고,
Bouncing ball과Rocket Man Demo가 추천 대상으로 꼽힘 - 최신 스마트폰 브라우저에서도 60 FPS 실행을 목표로 함
- 이후 PR을 통해
js_of_ocaml기반 WASM 실행도 가능해짐 - 저장소는 linoscope/CAMLBOY에 공개됨
왜 Game Boy 에뮬레이터를 OCaml로 만들었나
- OCaml을 몇 달간 공부한 뒤에도 단순 예제는 작성할 수 있었지만, 중간 규모 이상의 코드 구성과 고급 기능의 실전 사용 감각이 부족했음
- Game Boy 에뮬레이터는 연습 프로젝트로 적절한 조건을 갖춤
- 명세가 명확해 무엇을 구현할지 고민할 여지가 적음
- 며칠이나 몇 주 만에 끝나지 않을 만큼 복잡함
- 몇 달 안에 완성할 수 없을 만큼 과도하게 복잡하지는 않음
- Game Boy에 대한 개인적 추억이 있음
- 구현 목표는 성능보다 먼저 가독성과 유지보수성을 놓고, 브라우저 실행과 벤치마크 비교까지 포함함
- js_of_ocaml로 JavaScript에 컴파일해 브라우저에서 실행
- 스마트폰 브라우저에서 플레이 가능한 FPS 달성
- 벤치마크를 구현하고 여러 OCaml 컴파일러 백엔드 비교
에뮬레이터 구조와 메인 루프
- CAMLBOY의 주요 구성은 CPU, timer, GPU, bus, cartridge, interrupt controller, serial port, joypad 등으로 나뉨
- bus는 CPU와 여러 하드웨어 모듈 사이에서 주소에 따라 읽기·쓰기를 라우팅함
- 예를 들어
0xFFFF주소 쓰기는 interrupt controller로 전달돼 인터럽트를 활성화하거나 비활성화함 - bus에 연결된 하드웨어 모듈은
Addressable_intf.S인터페이스를 구현함 - bus는
Word_addressable_intf.S인터페이스를 구현함
- 예를 들어
- 실제 하드웨어에서는 CPU, timer, GPU가 같은 클록을 공유하지만, 에뮬레이터는 순차 실행 루프라 별도 동기화가 필요함
- 메인 루프는 catch up method로 각 모듈의 진행량을 맞춤
- CPU가 명령어 하나를 실행하고 소비한 사이클 수를 기록함
- timer를 CPU가 소비한 사이클 수만큼 진행함
- GPU도 같은 사이클 수만큼 진행함
읽기·쓰기 인터페이스와 bus 구현
- 8비트 읽기·쓰기를 지원하는 모듈들은
Addressable_intf.S시그니처를 공유함read_byte : t -> uint16 -> uint8write_byte : t -> addr:uint16 -> data:uint8 -> unitaccepts : t -> uint16 -> bool
ram.mli,gpu.mli,joypad.mli,timer.mli등은include Addressable_intf.S with type t := t형태로 같은 인터페이스를 포함함- CPU와 bus 사이에서는 16비트 읽기·쓰기도 필요하므로
Word_addressable_intf.S가Addressable_intf.S를 포함하고read_word,write_word를 추가함 - bus는 GPU, timer, RAM 등 연결된 모듈을 필드로 갖고, 주소를 기준으로 적절한 모듈에 읽기·쓰기를 전달함
0xC000주소 읽기·쓰기는 RAM으로 라우팅됨- 전체 메모리 맵은 Pandocs Memory Map을 참고함
read_word는read_byte를 두 번 호출해 16비트 읽기를 구현하며, 실제 하드웨어도 8비트 접근 두 번으로 16비트 접근을 처리함
레지스터와 CPU 테스트성 개선
- Game Boy CPU는 8비트 레지스터
A,B,C,D,E,F,H,L을 가짐 - 8비트 레지스터들은 조합돼 16비트 레지스터
AF,BC,DE,HL로도 사용됨 - 초기 CPU 구현은
registers,bus,pc등을 직접 갖는 구조였고,run_instruction에서 fetch, decode, execute를 수행함 - 이 구조는 테스트하기 어려웠음
- bus가 GPU, timer, RAM 등 많은 모듈에 의존함
- 단위 테스트에서 CPU를 생성하려면 bus와 연결 모듈들을 모두 준비해야 함
- bus와 모든 연결 모듈이 구현되기 전에는 CPU 인스턴스를 만들 수 없음
- CPU를 functor로 다시 구현해 bus의 구체 구현을 추상화함
module Make (Bus : Word_addressable_intf.S)형태로 bus 구현을 주입함- 테스트에서는 단일 byte array 기반
Mock_bus로 CPU를 인스턴스화함 - 이 변경으로 CPU 단위 테스트에서 실제 bus 대신 mock 구현을 사용할 수 있음
명령어 집합과 GADT 사용
- Game Boy 명령어 집합에는 8비트 인자를 받는 명령어와 16비트 인자를 받는 명령어가 있음
ADD8 A, 0x12는 8비트A레지스터와 8비트 즉시값을 더함ADD16 AF, 0x1234는 16비트AF레지스터와 16비트 즉시값을 더함
- 첫 시도는
Immediate8,Immediate16,R,RR같은 variant로 인자를 표현하는 방식이었음 - variant 방식에서는
read_arg의 반환 타입을 하나로 정하기 어려웠음R r은uint8을 반환함RR rr은uint16을 반환함- 같은 match 표현식 안에서 반환 타입이 달라짐
- GADT를 사용해 인자 타입을 다시 정의함
Immediate8 : uint8 -> uint8 argImmediate16 : uint16 -> uint16 argR : Registers.r -> uint8 argRR : Registers.rr -> uint16 arg
- 이 구조에서는
read_arg : type a. a Instruction.arg -> a처럼 인자의 타입에 따라 반환 타입이 달라짐ADD8은uint8 arg * uint8 arg만 받음ADD16은uint16 arg * uint16 arg만 받음- 8비트·16비트 명령어 인자 혼동을 타입 수준에서 줄일 수 있음
cartridge와 일급 모듈
- Game Boy cartridge는 단순 ROM만이 아니라, 종류에 따라 추가 하드웨어를 포함할 수 있음
ROM_ONLY타입 cartridge는 게임 데이터와 코드를 저장하는 ROM만 포함함- 예시로 Tetris가 사용됨
MBC3타입 cartridge는 ROM 외에 독립 RAM과 timer를 포함함- 예시로 Pokémon Red가 사용됨
- cartridge 타입마다 기능이 달라 각각 별도 모듈로 구현함
- 런타임에 cartridge 타입에 맞는 모듈을 선택하기 위해 일급 모듈을 사용함
Detect_cartridge.f는 ROM byte를 받아(module Cartridge_intf.S)를 반환하는 형태로 설계됨
test ROM과 ppx_expect 기반 통합 테스트
- test ROM은 에뮬레이터의 특정 기능을 검증하는 프로그램임
- 기본 산술 명령어 동작 확인
MBC1cartridge 타입 지원 확인
- test ROM은 일반 게임 ROM과 달리 실패한 기능의 범위를 알려주고, 일부 핵심 기능이 없어도 실행될 수 있어 에뮬레이터 개발에 유용함
- test ROM은 보통 테스트 결과를 화면에 출력함
- mooneye test ROMs는 실패 시 레지스터 덤프와 assertion 실패 정보를 표시함
- blargg test roms처럼 serial port로 ASCII 결과를 출력하는 test ROM도 있음
- 통합 테스트는 ppx_expect를 사용함
M.run_test_rom_and_print_framebuffer가 ROM을 실행하고 최종 화면 상태를 ASCII 문자로 출력함- 출력 문자열을
[%expect{|...|}]안의 기대값과 비교함 ppx_expect설명은 Jane Street 글을 참고함
- 이 테스트 구성은 큰 코드 변경에서도 회귀를 잡아 주고, 탐색적 프로그래밍 흐름을 가능하게 함
- 새 기능을 검증하는 test ROM을 찾음
ppx_expect테스트를 설정함- 실패한 출력을 커밋함
- 기능을 구현함
- 테스트 결과가
Test OK상태로 바뀌는지 확인함
JavaScript 컴파일과 브라우저 UI
- js_of_ocaml 덕분에 JavaScript 컴파일은 어렵지 않았음
- 브라우저에서 에뮬레이터가 동작하게 만드는 데 단일 커밋이 필요했음
- 브라우저 UI 구현에는 Brr를 사용함
- Brr는 JS 객체를 OCaml 객체가 아니라 OCaml 모듈에 매핑함
js_of_ocaml내장 브라우저 API는 JS 객체를 OCaml 객체로 매핑하므로 OCaml의 객체 지식이 필요함- Brr 사용은 OCaml 객체 모델에 대한 부담을 줄임
성능 최적화 과정
- 초기 브라우저 실행은 동작했지만 플레이하기 어려울 정도로 느렸음
- PC 브라우저에서 약 20 FPS였음
- 실제 Game Boy는 60 FPS로 동작하므로 성능을 약 3배 개선해야 했음
- Chrome profiler로 병목을 찾음
- GPU가 약 73%의 시간을 소비함
tile_data.ml이 34%,oam_table.ml이 18%,tile_map이 8%를 소비함timer.ml과 일부Bigstringaf함수도 많은 시간을 소비함
- 병목 제거는 단계적으로 FPS를 끌어올림
oam_table.ml최적화: 14 FPS → 24 FPStile_data.ml최적화: 24 FPS → 35 FPStimer.ml최적화: 35 FPS → 40 FPStile_map.ml최적화: 40 FPS → 50 FPSBigstringaf.get대신Bigstringaf.unsafe_get사용: 50 FPS → 60 FPS
- 이후 PC 브라우저에서는 60 FPS에 도달했지만, 스마트폰에서는 20~40 FPS에 머물렀음
- release build의 JS 출력이 dev build보다 느렸고, discuss.ocaml.org의 도움으로
js_of_ocaml의 인라이닝이 JS 성능 저하 원인으로 확인됨- 관련 논의는 discuss.ocaml.org 글에 있음
- 2022년 1월 12일 업데이트로 부정적 영향은 ocsigen/js_of_ocaml#1220에서 다뤄짐
- 인라이닝을 비활성화한 뒤 PC에서 100 FPS, 스마트폰에서 60 FPS를 달성함
- JS 성능 최적화는 native 성능도 개선했고, native 실행에서는 약 1000 FPS로 동작함
벤치마크와 비교 제한
- UI 없이 에뮬레이터를 실행하는 headless benchmarking mode를 구현함
- 여러 OCaml 컴파일러 백엔드에서 FPS를 측정함
- 이 벤치마크는 다른 Game Boy 에뮬레이터와 FPS를 비교하는 용도로 쓰기 어려움
- 에뮬레이터 성능은 정확도와 구현 기능 범위에 크게 좌우됨
- CAMLBOY는 APU(Audio Processing Unit)를 구현하지 않았으므로 APU 지원 에뮬레이터와 FPS를 비교하는 것은 의미가 없음
OCaml 사용 경험
- OCaml 생태계는 이전에 사용했던 약 6년 전보다 많이 개선됨
- dune 덕분에 파일을 디렉터리에 넣으면 빌드 시스템이 처리하는 경험에 가까워짐
- Merlin과 OCamlformat으로 자동완성, 코드 탐색, 자동 포맷 도입이 대체로 쉬움
- setup-ocaml을 사용하면 GitHub Actions에서 빌드와 테스트를 설정할 수 있음
- CAMLBOY 구현에는 성능 때문에 mutable state가 많이 사용됨
- 많은 모듈이
t -> ... -> unit타입의 함수를 가지며, 이는 어떤 mutable state의 변경을 뜻함 - 비“함수형” 구현임에도 OCaml의 장점을 잃었다고 느끼지 않았음
- 많은 모듈이
- 선호 지점은 “함수형” 자체보다 정적 타입, variant, pattern matching, 모듈 시스템, 좋은 타입 추론에 가까움
OCaml에서 불편했던 점
- 생태계는 개선됐지만 일부 영역은 여전히 복잡하거나 문서가 부족함
- 재현 가능한 방식으로 의존성을 해결하는 과정에서 공식 opam 문서에 명확한 안내가 부족했음
- 필요한 명령을 찾기 위해 setup-ocaml 소스를 읽었음
- 로컬에 패키지를 “publish”한 뒤 로컬 publish된 패키지를 설치해야 하는 방식이 복잡하게 느껴졌음
- 추상화 의존의 문법적 비용이 높음
B가C의 구체 구현이 아니라C_intf인터페이스에 의존하도록 만들려면B를 functor로 바꿔야 함B가 functor가 되면A가 기존처럼B.foo를 참조할 수 없어A도B_intf를 받는 functor로 바꿔야 함- 모듈을 functor로 바꾸면 그 모듈이 다른 모듈에 의존하는 방식뿐 아니라, 다른 모듈이 그 모듈에 의존하는 방식도 바뀜
- 이 문제는
Camlboy -> Bus -> Cartridge의존 그래프에서Bus -> Cartridge부분만 분리하려 할 때 발생함 - OOP에서는 class
B의 생성자가 구체 classC대신 interfaceC_intf를 받도록 바꿔도 classB자체의 타입은 바뀌지 않음- 다만 OOP에는 dynamic dispatch 비용이 있음
- OCaml의 OOP 기능은 익숙하지 않은 사람이 많아 코드 독자층을 제한할 수 있음
참고 자료
- OCaml 관련 자료
- Learn OCaml Workshop: Jane Street 내부에서 사용된 워크숍 자료로, 구멍이 있는 OCaml 코드와 테스트를 채우며 학습하는 방식임
- Real World OCaml: OCaml 기본 문법을 알거나 다른 함수형 언어 경험이 있는 사람에게 추천되는 실전 예제 중심 자료임
- Game Boy 관련 자료
- The Ultimate Game Boy Talk: Game Boy 구조를 약 1시간에 설명하는 영상
- gbops: Game Boy 명령어 집합 표
- Game Boy CPU Manual: 명령어 구현에 사용한 CPU 매뉴얼이며, 일부는 특히 register flag 주변이 부정확함
- Pandocs: GPU, timer 등 하드웨어 모듈 동작을 참고한 wiki
- Imran Nazar’s blog: JavaScript로 Game Boy 에뮬레이터를 구현하는 튜토리얼이며, 구현 범위를 대략 파악하는 데 사용됨