2P by GN⁺ | ★ favorite | 댓글 1개
  • 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_ocamlBrr로 구현함
  • Chrome profiler로 GPU·timer·Bigstringaf 병목을 줄인 뒤 js_of_ocaml 인라이닝을 꺼 PC 브라우저 100 FPS, 스마트폰 60 FPS에 도달함

CAMLBOY의 목표와 범위

  • CAMLBOY는 OCaml로 작성된 Game Boy 에뮬레이터이며 브라우저에서 실행됨
  • 데모에는 여러 홈브루 ROM이 포함됐고, Bouncing ballRocket 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 -> uint8
    • write_byte : t -> addr:uint16 -> data:uint8 -> unit
    • accepts : 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.SAddressable_intf.S를 포함하고 read_word, write_word를 추가함
  • bus는 GPU, timer, RAM 등 연결된 모듈을 필드로 갖고, 주소를 기준으로 적절한 모듈에 읽기·쓰기를 전달함
    • 0xC000 주소 읽기·쓰기는 RAM으로 라우팅됨
    • 전체 메모리 맵은 Pandocs Memory Map을 참고함
  • read_wordread_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 ruint8을 반환함
    • RR rruint16을 반환함
    • 같은 match 표현식 안에서 반환 타입이 달라짐
  • GADT를 사용해 인자 타입을 다시 정의함
    • Immediate8 : uint8 -> uint8 arg
    • Immediate16 : uint16 -> uint16 arg
    • R : Registers.r -> uint8 arg
    • RR : Registers.rr -> uint16 arg
  • 이 구조에서는 read_arg : type a. a Instruction.arg -> a처럼 인자의 타입에 따라 반환 타입이 달라짐
    • ADD8uint8 arg * uint8 arg만 받음
    • ADD16uint16 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은 에뮬레이터의 특정 기능을 검증하는 프로그램임
    • 기본 산술 명령어 동작 확인
    • MBC1 cartridge 타입 지원 확인
  • 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를 끌어올림
  • 이후 PC 브라우저에서는 60 FPS에 도달했지만, 스마트폰에서는 20~40 FPS에 머물렀음
  • release build의 JS 출력이 dev build보다 느렸고, discuss.ocaml.org의 도움으로 js_of_ocaml인라이닝이 JS 성능 저하 원인으로 확인됨
  • 인라이닝을 비활성화한 뒤 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 덕분에 파일을 디렉터리에 넣으면 빌드 시스템이 처리하는 경험에 가까워짐
    • MerlinOCamlformat으로 자동완성, 코드 탐색, 자동 포맷 도입이 대체로 쉬움
    • setup-ocaml을 사용하면 GitHub Actions에서 빌드와 테스트를 설정할 수 있음
  • CAMLBOY 구현에는 성능 때문에 mutable state가 많이 사용됨
    • 많은 모듈이 t -> ... -> unit 타입의 함수를 가지며, 이는 어떤 mutable state의 변경을 뜻함
    • 비“함수형” 구현임에도 OCaml의 장점을 잃었다고 느끼지 않았음
  • 선호 지점은 “함수형” 자체보다 정적 타입, variant, pattern matching, 모듈 시스템, 좋은 타입 추론에 가까움

OCaml에서 불편했던 점

  • 생태계는 개선됐지만 일부 영역은 여전히 복잡하거나 문서가 부족함
    • 재현 가능한 방식으로 의존성을 해결하는 과정에서 공식 opam 문서에 명확한 안내가 부족했음
    • 필요한 명령을 찾기 위해 setup-ocaml 소스를 읽었음
    • 로컬에 패키지를 “publish”한 뒤 로컬 publish된 패키지를 설치해야 하는 방식이 복잡하게 느껴졌음
  • 추상화 의존의 문법적 비용이 높음
    • BC의 구체 구현이 아니라 C_intf 인터페이스에 의존하도록 만들려면 B를 functor로 바꿔야 함
    • B가 functor가 되면 A가 기존처럼 B.foo를 참조할 수 없어 AB_intf를 받는 functor로 바꿔야 함
    • 모듈을 functor로 바꾸면 그 모듈이 다른 모듈에 의존하는 방식뿐 아니라, 다른 모듈이 그 모듈에 의존하는 방식도 바뀜
  • 이 문제는 Camlboy -> Bus -> Cartridge 의존 그래프에서 Bus -> Cartridge 부분만 분리하려 할 때 발생함
  • OOP에서는 class B의 생성자가 구체 class C 대신 interface C_intf를 받도록 바꿔도 class B 자체의 타입은 바뀌지 않음
    • 다만 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 에뮬레이터를 구현하는 튜토리얼이며, 구현 범위를 대략 파악하는 데 사용됨

댓글과 토론

Hacker News 의견들
  • 에뮬레이터, 가상 머신, 바이트코드 인터프리터 등을 작성할 때 특별히 더 나은 프로그래밍 언어가 있다고 볼 수 있을까?
    여기서 더 낫다는 성능이나 구현 오류 감소보다는, 그 언어로 에뮬레이터를 구현하는 경험이 더 보람 있고 직관적이며, 에뮬레이터와 언어 자체를 더 많이 배우게 해주는지를 뜻함
    Erlang이 분산 시스템 영역에서 그런 언어인 것처럼, 기계를 코드로 기술하는 문제 영역을 목표로 설계된 언어가 있는지 궁금함

    • Haskell은 도메인 특화 언어(DSL) 와 컴파일러에 필요한 데이터 조작에 강함
      OCaml, Lisp, 그리고 대수적 자료형(ADT) 같은 기능을 지원하는 언어들도 잘 맞음
      현대 C++의 variant 같은 타입으로도 가능하지만 그렇게 예쁘지는 않을 것임
      실제로 에뮬레이터에서 게임을 돌리고 싶다면 C나 C++가 주류이고, Rust도 가능하겠지만 저수준 메모리 조작 쪽은 자세히 말하긴 어려움
    • 개인적으로는 C, C++, Rust, Zig 같은 시스템 언어가 가장 만족스러웠음
      방법론이 훨씬 직교적이어서 uint8은 메모리의 바이트를 직접 나타내고, memcpy는 블릿과 거의 같음
      JavaScript의 Number 타입을 비트 시프트용 바이트나 워드처럼 다루려고 씨름하는 시간이 줄어듦
      다만 어떤 언어든 화면에 그릴 수 있고 에뮬레이션 대상 기계를 담을 메모리가 있다면 대체로 비슷하므로, 결국 가장 익숙한 언어가 에뮬레이터 작성에 가장 즐거운 언어가 됨
    • SML, 특히 MLton 방언이 좋음
      OCaml이 좋은 이유와 같은 장점을 갖고 있으면서, 개인적으로는 ML 계열 언어의 훨씬 더 나은 버전이라고 봄
      OCaml에서 부러운 건 applicative functor 정도인데, 결국 약간 다른 모듈 스타일로 이어질 뿐임
    • 브라우저에서 재미로 해볼 또 다른 선택지는 Elm
      비슷한 예전 프로젝트로 https://github.com/Malax/elmboy가 있음
    • 전혀 그렇지 않음
      에뮬레이션은 배열, 즉 임의 인덱스의 상수 시간 조회와 비트 연산만 있으면 어느 언어에서나 매우 쉽게 구현할 수 있음
      JIT를 고려하기 전까지는 그렇고, 함수형 언어에도 배열과 비트 연산은 있음
  • OCaml뿐 아니라 Game Boy 에뮬레이터 구현까지 잘 설명한 좋은 글임
    예전부터 어셈블러 편집기와 어셈블러/링커/로더를 갖춘 단일 페이지 앱을 만들어, 브라우저에서 Game Boy 홈브루를 할 수 있게 하면 멋질 것 같다고 생각했음
    접근성 좋은 임베디드 개발 교육 기회가 될 수 있음

  • 가능성은 낮겠지만, Game Boy 에뮬레이터의 사운드 구현 튜토리얼을 아는 사람이 있을까?
    이런 튜토리얼 대부분은 그 부분을 다루지 않고, 직접 해보려 하면 제대로 구현하기도 어렵고 참고 자료를 구현할 만큼 이해하기도 힘듦

  • 멋짐
    다만 데모가 너무 빠르게 실행됨
    throttle 체크박스가 실제로는 별로 바꾸지 않고, 해제하면 오히려 더 느려지는 듯함
    throttle 상태에서는 240fps, 끄면 180fps로 돌며, 체크박스가 켜져 있을 때 에뮬레이터의 1초가 이미 약 4초 정도 됨
    내 경우 화면 주사율이 240Hz라서 관련이 있는 것 같음

    • 아마 requestAnimationFrame()을 호출하면서 deltaTime을 고려하지 않는 듯함
  • 훌륭한 글이고 멋진 프로젝트임
    제목에 2022가 필요해 보임

  • 글이 아름다움
    Rust로 Game Boy 에뮬레이터를 만들고 싶었는데, 이 블로그 글 덕분에 시작할 동기가 생김
    북마크해 둠

  • 좋음
    functor와 GADT를 잘 활용했음
    CHIP-8이나 NES 에뮬레이터와 비교해 보고 싶고, ocaml-wasm으로 CAMLBOY를 WASM에 포팅해 보고 싶음

    • js_of_ocaml의 새 WASM 백엔드wasm_of_ocaml 덕분에 이미 CAMLBOY를 WASM에서 실행할 수 있을 것임