• เพื่อก้าวข้ามการใช้ 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.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.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 คืนค่า uint8
    • RR rr คืนค่า uint16
    • ชนิดคืนค่าจึงต่างกันภายใน match expression เดียวกัน
  • ผู้เขียนจึงนิยามชนิดอาร์กิวเมนต์ใหม่ด้วย 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 เปลี่ยนชนิดคืนค่าตามชนิดของอาร์กิวเมนต์ได้
    • ADD8 รับได้เฉพาะ uint8 arg * uint8 arg
    • ADD16 รับได้เฉพาะ 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

กระบวนการปรับจูนประสิทธิภาพ

  • เวอร์ชันแรกที่รันบนเบราว์เซอร์ได้ยัง ช้าเกินกว่าจะเล่นได้สะดวก
    • บนเบราว์เซอร์พีซีได้ประมาณ 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 แบบเป็นขั้นเป็นตอน
  • หลังจากนั้น บนเบราว์เซอร์พีซีทำได้ 60 FPS แล้ว แต่บนสมาร์ตโฟนยังอยู่ที่ 20~40 FPS
  • เอาต์พุต JS ของ release build กลับช้ากว่า dev build และด้วยความช่วยเหลือจาก discuss.ocaml.org จึงพบว่า การ inline ของ js_of_ocaml เป็นสาเหตุที่ทำให้ประสิทธิภาพ JS ลดลง
  • หลังปิดการ 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 ออกจากกราฟ dependency Camlboy -> Bus -> Cartridge
  • ใน OOP หากเปลี่ยน constructor ของ class B ให้รับ interface C_intf แทน class จริง C ก็ไม่ได้ทำให้ชนิดของ class B เองเปลี่ยนไป
    • แต่ 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 ซึ่งใช้เพื่อประเมินขอบเขตของงานโดยคร่าว ๆ

ยังไม่มีความคิดเห็น

ยังไม่มีความคิดเห็น