การสร้างอีมูเลเตอร์ Game Boy ด้วย OCaml (2022)
(linoscope.github.io)- เพื่อก้าวข้ามการใช้ OCaml แค่ในระดับตัวอย่าง ผู้เขียนจึงสร้างอีมูเลเตอร์ Game Boy ชื่อ CAMLBOY เพื่อทดลองใช้กับ โค้ดขนาดกลาง โดยตั้งเป้าให้รันในเบราว์เซอร์และมีประสิทธิภาพพอสำหรับเล่นบนสมาร์ตโฟน
- การออกแบบประกอบด้วย catch up method ที่ทำให้ CPU, timer และ GPU ไล่ตามรอบการทำงานของ CPU ให้ทัน, bus ที่ทำหน้าที่ route การอ่าน·เขียนตามแอดเดรส และอินเทอร์เฟซสำหรับการเข้าถึงแบบ 8 บิต·16 บิต
- เพื่อเพิ่มความสามารถในการทดสอบ CPU ผู้เขียนฉีด implementation ของ bus ผ่าน functor และใช้ GADT แยกชนิด 8 บิต·16 บิตเพื่อลดความสับสนของอาร์กิวเมนต์คำสั่ง
- การทดสอบแบบบูรณาการผสาน test ROM กับ
ppx_expectเพื่อจับ regression และเปิดทางให้พัฒนาแบบ exploratory implementation ได้ ส่วน UI บนเบราว์เซอร์สร้างด้วยjs_of_ocamlและBrr - หลังใช้ Chrome profiler เพื่อลดคอขวดใน GPU, timer และ
Bigstringafแล้ว ผู้เขียนปิดการ inline ของjs_of_ocamlจนได้ 100 FPS บนเบราว์เซอร์พีซี และ 60 FPS บนสมาร์ตโฟน
เป้าหมายและขอบเขตของ CAMLBOY
- CAMLBOY คืออีมูเลเตอร์ Game Boy ที่เขียนด้วย OCaml และทำงานในเบราว์เซอร์
- เดโมมี homebrew ROM หลายตัวรวมอยู่ด้วย และ
Bouncing ballกับRocket Man Demoถูกแนะนำเป็นพิเศษ - ตั้งเป้าให้ทำงานที่ 60 FPS แม้บนเบราว์เซอร์ของสมาร์ตโฟนรุ่นใหม่
- ต่อมามี PR ที่ทำให้สามารถรันแบบ WASM บนพื้นฐาน
js_of_ocamlได้ด้วย - โค้ดถูกเผยแพร่ไว้ที่ linoscope/CAMLBOY
ทำไมถึงสร้างอีมูเลเตอร์ Game Boy ด้วย OCaml
- หลังเรียน OCaml มาหลายเดือน แม้จะเขียนตัวอย่างง่าย ๆ ได้แล้ว แต่ผู้เขียนยังขาดความคุ้นเคยกับการจัดโครงสร้าง โค้ดขนาดกลางขึ้นไป และการใช้ฟีเจอร์ขั้นสูงในงานจริง
- อีมูเลเตอร์ Game Boy มีคุณสมบัติที่เหมาะกับการเป็นโปรเจกต์ฝึกฝน
- สเปกชัดเจน จึงไม่ต้องเสียเวลาคิดมากว่าจะต้องทำอะไร
- ซับซ้อนพอที่จะไม่จบในไม่กี่วันหรือไม่กี่สัปดาห์
- แต่ก็ไม่ซับซ้อนเกินไปจนทำไม่เสร็จภายในไม่กี่เดือน
- ผู้เขียนมีความทรงจำส่วนตัวกับ Game Boy
- เป้าหมายของการพัฒนาคือให้ความสำคัญกับ ความอ่านง่ายและการดูแลรักษา ก่อนเรื่องประสิทธิภาพ และรวมถึงการรันในเบราว์เซอร์กับการเปรียบเทียบ benchmark ด้วย
- คอมไพล์เป็น JavaScript ด้วย js_of_ocaml เพื่อรันในเบราว์เซอร์
- ทำ FPS ให้เล่นได้บนเบราว์เซอร์สมาร์ตโฟน
- สร้าง benchmark และเปรียบเทียบ backend ของคอมไพเลอร์ OCaml หลายแบบ
โครงสร้างอีมูเลเตอร์และเมนลูป
- องค์ประกอบหลักของ CAMLBOY แบ่งเป็น CPU, timer, GPU, bus, cartridge, interrupt controller, serial port, joypad เป็นต้น
- bus ทำหน้าที่ route การอ่าน·เขียนตามแอดเดรสระหว่าง CPU กับโมดูลฮาร์ดแวร์ต่าง ๆ
- ตัวอย่างเช่น การเขียนไปยังแอดเดรส
0xFFFFจะถูกส่งต่อไปยัง interrupt controller เพื่อเปิดหรือปิด interrupt - โมดูลฮาร์ดแวร์ที่เชื่อมต่อกับ bus จะ implement อินเทอร์เฟซ
Addressable_intf.S - ส่วน bus จะ implement อินเทอร์เฟซ
Word_addressable_intf.S
- ตัวอย่างเช่น การเขียนไปยังแอดเดรส
- บนฮาร์ดแวร์จริง CPU, timer และ GPU ใช้นาฬิกาเดียวกัน แต่ในอีมูเลเตอร์เป็นลูปทำงานแบบลำดับ จึงต้องมีการซิงก์แยกต่างหาก
- เมนลูปใช้ catch up method เพื่อทำให้ความคืบหน้าของแต่ละโมดูลตรงกัน
- CPU รันคำสั่งหนึ่งคำสั่งแล้วบันทึกจำนวน cycle ที่ใช้ไป
- timer เดินหน้าตามจำนวน cycle ที่ CPU ใช้ไป
- GPU ก็เดินหน้าตามจำนวน cycle เท่ากัน
อินเทอร์เฟซการอ่าน·เขียนและการทำ bus
- โมดูลที่รองรับการอ่าน·เขียน 8 บิตใช้ signature ร่วมกันคือ
Addressable_intf.Sread_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จะถูก route ไปยัง RAM - memory map ทั้งหมดอ้างอิงจาก Pandocs Memory Map
- การอ่าน·เขียนแอดเดรส
read_wordเรียกread_byteสองครั้งเพื่ออ่านค่า 16 บิต ซึ่งฮาร์ดแวร์จริงก็จัดการการเข้าถึง 16 บิตด้วยการเข้าถึง 8 บิตสองครั้งเช่นกัน
รีจิสเตอร์และการปรับปรุงความสามารถในการทดสอบ CPU
- CPU ของ Game Boy มีรีจิสเตอร์ 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 ใน unit test ต้องเตรียมทั้ง bus และโมดูลที่เชื่อมต่อทั้งหมด
- ก่อนจะ implement bus และโมดูลที่เชื่อมต่อครบทั้งหมด ก็ยังสร้างอินสแตนซ์ CPU ไม่ได้
- ผู้เขียนจึงเขียน CPU ใหม่เป็น functor เพื่อทำให้ implementation ของ bus เป็น abstraction
- ใช้รูปแบบ
module Make (Bus : Word_addressable_intf.S)เพื่อฉีด implementation ของ bus เข้ามา - ในการทดสอบ สามารถสร้างอินสแตนซ์ CPU ด้วย
Mock_busที่อิงกับ byte array เดียวได้ - การเปลี่ยนแปลงนี้ทำให้ unit test ของ CPU ใช้ mock implementation แทน bus จริงได้
- ใช้รูปแบบ
ชุดคำสั่งและการใช้ GADT
- ชุดคำสั่งของ Game Boy มีทั้งคำสั่งที่รับอาร์กิวเมนต์ 8 บิตและ 16 บิต
ADD8 A, 0x12จะบวกรีจิสเตอร์Aแบบ 8 บิตกับ immediate value 8 บิตADD16 AF, 0x1234จะบวกรีจิสเตอร์AFแบบ 16 บิตกับ immediate value 16 บิต
- ความพยายามแรกใช้ variant เช่น
Immediate8,Immediate16,R,RRเพื่อแทนอาร์กิวเมนต์ - แนวทางแบบ variant ทำให้กำหนดชนิดคืนค่าของ
read_argให้เป็นแบบเดียวได้ยากR rคืนค่าuint8RR rrคืนค่าuint16- ชนิดคืนค่าจึงต่างกันภายใน match expression เดียวกัน
- ผู้เขียนจึงนิยามชนิดอาร์กิวเมนต์ใหม่ด้วย 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 argADD16รับได้เฉพาะuint16 arg * uint16 arg- จึงลดความสับสนระหว่างอาร์กิวเมนต์ของคำสั่ง 8 บิตกับ 16 บิตได้ในระดับ type
cartridge และ first-class module
- cartridge ของ Game Boy ไม่ได้มีแค่ ROM ธรรมดา แต่บางชนิดมีฮาร์ดแวร์เสริมในตัวด้วย
- cartridge แบบ
ROM_ONLYมีเพียง ROM สำหรับเก็บข้อมูลเกมและโค้ด- ตัวอย่างคือ Tetris
- cartridge แบบ
MBC3มีทั้ง ROM, RAM แยก และ timer- ตัวอย่างคือ Pokémon Red
- เนื่องจากความสามารถต่างกันในแต่ละชนิดของ cartridge จึงมีการ implement แยกเป็นคนละโมดูล
- เพื่อเลือกโมดูลให้ตรงกับชนิดของ cartridge ตอนรันไทม์ ผู้เขียนใช้ first-class module
Detect_cartridge.fถูกออกแบบให้รับ byte ของ ROM แล้วคืนค่าเป็น(module Cartridge_intf.S)
การทดสอบแบบบูรณาการด้วย test ROM และ ppx_expect
- test ROM คือโปรแกรมที่ใช้ตรวจสอบความสามารถเฉพาะของอีมูเลเตอร์
- ตรวจสอบการทำงานของคำสั่งเลขคณิตพื้นฐาน
- ตรวจสอบการรองรับ cartridge แบบ
MBC1
- ต่างจากเกม ROM ทั่วไป test ROM สามารถบอกขอบเขตของฟังก์ชันที่ล้มเหลวได้ และบางครั้งยังรันได้แม้ฟังก์ชันสำคัญบางส่วนยังไม่มี จึงมีประโยชน์มากต่อการพัฒนาอีมูเลเตอร์
- โดยทั่วไป test ROM จะแสดงผลการทดสอบออกทางหน้าจอ
- mooneye test ROMs จะแสดง register dump และข้อมูล assertion ที่ล้มเหลวเมื่อทดสอบไม่ผ่าน
- test ROM บางชุด เช่น blargg test roms จะส่งผลลัพธ์ ASCII ออกผ่าน serial port
- การทดสอบแบบบูรณาการใช้ ppx_expect
M.run_test_rom_and_print_framebufferจะรัน ROM แล้วพิมพ์สถานะหน้าจอสุดท้ายออกมาเป็นอักขระ ASCII- จากนั้นเปรียบเทียบสตริงผลลัพธ์กับค่าที่คาดไว้ใน
[%expect{|...|}] - ดูคำอธิบายของ
ppx_expectได้จาก บทความของ Jane Street
- โครงสร้างการทดสอบนี้ช่วยจับ regression ได้แม้มีการเปลี่ยนโค้ดขนาดใหญ่ และเปิดทางให้ทำงานแบบ exploratory programming
- หา test ROM ที่ใช้ยืนยันฟีเจอร์ใหม่
- ตั้งค่าเทสต์ด้วย
ppx_expect - commit เอาต์พุตที่ล้มเหลว
- implement ฟีเจอร์
- ตรวจว่าผลการทดสอบเปลี่ยนเป็น
Test OKหรือไม่
การคอมไพล์เป็น JavaScript และ UI บนเบราว์เซอร์
- ด้วย js_of_ocaml การคอมไพล์เป็น JavaScript จึงไม่ใช่เรื่องยาก
- การทำให้อีมูเลเตอร์ทำงานในเบราว์เซอร์ได้ต้องใช้เพียง คอมมิตเดียว
- UI บนเบราว์เซอร์ถูกสร้างด้วย Brr
- Brr ทำ mapping JS object ไปยัง OCaml module แทนที่จะเป็น OCaml object
- API เบราว์เซอร์ที่มากับ
js_of_ocamlจะ map JS object เป็น OCaml object จึงต้องมีความรู้เรื่อง object ของ OCaml - การใช้ Brr ช่วยลดภาระในการทำความเข้าใจกับ object model ของ OCaml
- API เบราว์เซอร์ที่มากับ
กระบวนการปรับจูนประสิทธิภาพ
- เวอร์ชันแรกที่รันบนเบราว์เซอร์ได้ยัง ช้าเกินกว่าจะเล่นได้สะดวก
- บนเบราว์เซอร์พีซีได้ประมาณ 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 FPS - ปรับจูน
tile_data.ml: 24 FPS → 35 FPS - ปรับจูน
timer.ml: 35 FPS → 40 FPS - ปรับจูน
tile_map.ml: 40 FPS → 50 FPS - เปลี่ยนจาก
Bigstringaf.getเป็นBigstringaf.unsafe_get: 50 FPS → 60 FPS
- ปรับจูน
- หลังจากนั้น บนเบราว์เซอร์พีซีทำได้ 60 FPS แล้ว แต่บนสมาร์ตโฟนยังอยู่ที่ 20~40 FPS
- เอาต์พุต JS ของ release build กลับช้ากว่า dev build และด้วยความช่วยเหลือจาก discuss.ocaml.org จึงพบว่า การ inline ของ
js_of_ocamlเป็นสาเหตุที่ทำให้ประสิทธิภาพ JS ลดลง- การพูดคุยที่เกี่ยวข้องอยู่ใน โพสต์บน discuss.ocaml.org
- ในอัปเดตวันที่ 12 มกราคม 2022 ผลกระทบด้านลบนี้ถูกจัดการใน ocsigen/js_of_ocaml#1220
- หลังปิดการ inline ก็ทำได้ 100 FPS บนพีซี และ 60 FPS บนสมาร์ตโฟน
- การปรับจูนประสิทธิภาพฝั่ง JS ยังช่วยให้ประสิทธิภาพแบบ native ดีขึ้นด้วย โดยในโหมด native ทำได้ราว 1000 FPS
Benchmark และข้อจำกัดของการเปรียบเทียบ
- มีการทำ headless benchmarking mode สำหรับรันอีมูเลเตอร์โดยไม่มี UI
- วัด FPS บน backend ของคอมไพเลอร์ OCaml หลายแบบ
- อย่างไรก็ตาม benchmark นี้ไม่เหมาะจะใช้เปรียบเทียบ FPS กับอีมูเลเตอร์ Game Boy ตัวอื่น
- ประสิทธิภาพของอีมูเลเตอร์ขึ้นกับความแม่นยำและขอบเขตของฟีเจอร์ที่ implement เป็นอย่างมาก
- CAMLBOY ยังไม่ได้ implement APU(Audio Processing Unit) ดังนั้นการเทียบ FPS กับอีมูเลเตอร์ที่รองรับ APU จึงไม่มีความหมายมากนัก
ประสบการณ์ใช้งาน OCaml
- ecosystem ของ OCaml ดีขึ้นมากเมื่อเทียบกับครั้งก่อนที่ผู้เขียนใช้งานเมื่อราว 6 ปีก่อน
- dune ทำให้ประสบการณ์ใกล้เคียงกับการแค่วางไฟล์ลงในไดเรกทอรีแล้วระบบ build จัดการต่อให้
- Merlin และ OCamlformat ทำให้การใช้ autocomplete, การนำทางโค้ด และการจัดรูปแบบอัตโนมัติเป็นเรื่องที่ติดตั้งได้ค่อนข้างง่าย
- ใช้ setup-ocaml เพื่อตั้งค่า build และ test บน GitHub Actions ได้
- การ implement CAMLBOY ใช้ mutable state ค่อนข้างมากเพราะเหตุผลด้านประสิทธิภาพ
- หลายโมดูลมีฟังก์ชันชนิด
t -> ... -> unitซึ่งหมายถึงการเปลี่ยนแปลง mutable state บางอย่าง - แม้การ implement จะไม่ค่อย “functional” นัก แต่ผู้เขียนก็ไม่รู้สึกว่าสูญเสียข้อดีของ OCaml ไป
- หลายโมดูลมีฟังก์ชันชนิด
- จุดที่ชอบจริง ๆ ไม่ใช่คำว่า “functional” เอง แต่เป็น static type, variant, pattern matching, module system และ type inference ที่ดี
สิ่งที่ไม่สะดวกใน OCaml
- แม้ ecosystem จะดีขึ้น แต่บางส่วนยังซับซ้อนหรือมีเอกสารไม่เพียงพอ
- ตอนแก้ปัญหา dependency ให้ reproducible ได้ ผู้เขียนพบว่า เอกสาร opam อย่างเป็นทางการ ยังไม่มีคำแนะนำที่ชัดเจนพอ
- ผู้เขียนต้องไปอ่านซอร์สของ setup-ocaml เพื่อหาคำสั่งที่ต้องใช้
- วิธีที่ต้อง “publish” แพ็กเกจไว้ในเครื่องก่อน แล้วค่อยติดตั้งแพ็กเกจที่ publish ไว้ในเครื่องนั้นอีกที ดูซับซ้อนเกินจำเป็น
- ต้นทุนทางไวยากรณ์ของการพึ่งพา abstraction สูง
- ถ้าต้องการให้
Bพึ่งพาอินเทอร์เฟซC_intfแทน implementation จริงของCก็ต้องเปลี่ยนBให้เป็น functor - เมื่อ
Bกลายเป็น functor แล้วAก็ไม่สามารถอ้างถึงB.fooแบบเดิมได้ จึงต้องเปลี่ยนAให้เป็น functor ที่รับB_intfเช่นกัน - เมื่อโมดูลหนึ่งถูกเปลี่ยนเป็น functor ไม่ได้เปลี่ยนแค่วิธีที่โมดูลนั้นพึ่งพาโมดูลอื่น แต่ยังเปลี่ยนวิธีที่โมดูลอื่นพึ่งพาโมดูลนั้นด้วย
- ถ้าต้องการให้
- ปัญหานี้เกิดขึ้นตอนพยายามแยกเฉพาะส่วน
Bus -> Cartridgeออกจากกราฟ dependencyCamlboy -> Bus -> Cartridge - ใน OOP หากเปลี่ยน constructor ของ class
Bให้รับ interfaceC_intfแทน class จริงCก็ไม่ได้ทำให้ชนิดของ classBเองเปลี่ยนไป- แต่ OOP มีต้นทุนจาก dynamic dispatch
- และฟีเจอร์ OOP ของ OCaml ก็ไม่คุ้นสำหรับหลายคน จนอาจทำให้กลุ่มผู้อ่านโค้ดแคบลง
แหล่งอ้างอิง
- แหล่งข้อมูลเกี่ยวกับ OCaml
- Learn OCaml Workshop: เอกสารเวิร์กช็อปที่ใช้ภายใน Jane Street โดยเรียนผ่านการเติมโค้ด OCaml ที่มีช่องว่างพร้อมชุดทดสอบ
- Real World OCaml: แหล่งข้อมูลเชิงปฏิบัติที่แนะนำสำหรับผู้ที่รู้ไวยากรณ์พื้นฐานของ OCaml แล้ว หรือมีประสบการณ์กับภาษา functional อื่น
- แหล่งข้อมูลเกี่ยวกับ Game Boy
- The Ultimate Game Boy Talk: วิดีโออธิบายโครงสร้างของ Game Boy ภายในประมาณ 1 ชั่วโมง
- gbops: ตารางชุดคำสั่งของ Game Boy
- Game Boy CPU Manual: คู่มือ CPU ที่ใช้ตอน implement คำสั่ง โดยบางส่วน โดยเฉพาะเรื่อง register flag มีความไม่แม่นยำ
- Pandocs: wiki ที่ใช้อ้างอิงการทำงานของโมดูลฮาร์ดแวร์อย่าง GPU และ timer
- Imran Nazar’s blog: บทสอนสร้างอีมูเลเตอร์ Game Boy ด้วย JavaScript ซึ่งใช้เพื่อประเมินขอบเขตของงานโดยคร่าว ๆ
ยังไม่มีความคิดเห็น