Xây dựng trình giả lập Game Boy bằng OCaml (2022)
(linoscope.github.io)- Để đưa OCaml vượt ra ngoài mức ví dụ và áp dụng vào mã nguồn quy mô trung bình, tác giả đã tạo trình giả lập Game Boy CAMLBOY, với mục tiêu chạy được trên trình duyệt và đạt hiệu năng đủ để chơi trên smartphone
- Việc triển khai gồm catch up method để CPU, timer và GPU bắt kịp nhau theo chu kỳ CPU, một bus đảm nhiệm định tuyến đọc/ghi theo địa chỉ, cùng giao diện truy cập 8-bit và 16-bit
- Để tăng khả năng kiểm thử của CPU, phần triển khai bus được tiêm vào bằng functor, còn sự nhầm lẫn giữa các đối số lệnh được giảm bớt bằng cách tách kiểu 8-bit và 16-bit bằng GADT
- Kiểm thử tích hợp kết hợp test ROM với
ppx_expectđể bắt lỗi hồi quy và cho phép triển khai theo kiểu khám phá, trong khi UI trình duyệt được xây dựng bằngjs_of_ocamlvàBrr - Sau khi dùng Chrome profiler để giảm các nút thắt ở GPU, timer và
Bigstringaf, rồi tắt inlining củajs_of_ocaml, dự án đạt 100 FPS trên trình duyệt PC và 60 FPS trên smartphone
Mục tiêu và phạm vi của CAMLBOY
- CAMLBOY là trình giả lập Game Boy được viết bằng OCaml và chạy trong trình duyệt
- Bản demo đi kèm nhiều ROM homebrew, trong đó
Bouncing ballvàRocket Man Demođược khuyến nghị - Mục tiêu là chạy ở 60 FPS ngay cả trên trình duyệt của smartphone đời mới
- Về sau, thông qua PR, dự án cũng có thể chạy WASM dựa trên
js_of_ocaml - Mã nguồn được công khai tại linoscope/CAMLBOY
Vì sao lại làm trình giả lập Game Boy bằng OCaml
- Sau vài tháng học OCaml, tác giả đã có thể viết các ví dụ đơn giản, nhưng vẫn thiếu cảm giác thực chiến về tổ chức mã nguồn từ quy mô trung bình trở lên và cách dùng các tính năng nâng cao
- Trình giả lập Game Boy là một dự án luyện tập phù hợp
- Đặc tả đủ rõ ràng nên không phải băn khoăn nhiều về việc cần triển khai gì
- Đủ phức tạp để không thể xong trong vài ngày hay vài tuần
- Nhưng cũng không quá phức tạp đến mức không thể hoàn thành trong vài tháng
- Tác giả có kỷ niệm cá nhân với Game Boy
- Mục tiêu triển khai ưu tiên tính dễ đọc và dễ bảo trì trước hiệu năng, đồng thời bao gồm cả chạy trên trình duyệt và so sánh benchmark
- Biên dịch sang JavaScript bằng js_of_ocaml để chạy trong trình duyệt
- Đạt FPS đủ để chơi được trên trình duyệt smartphone
- Triển khai benchmark và so sánh nhiều backend biên dịch OCaml
Kiến trúc trình giả lập và vòng lặp chính
- Các thành phần chính của CAMLBOY gồm CPU, timer, GPU, bus, cartridge, interrupt controller, serial port, joypad...
- bus định tuyến thao tác đọc/ghi giữa CPU và nhiều mô-đun phần cứng theo địa chỉ
- Ví dụ, ghi vào địa chỉ
0xFFFFsẽ được chuyển tới interrupt controller để bật hoặc tắt interrupt - Các mô-đun phần cứng gắn vào bus triển khai giao diện
Addressable_intf.S - bus triển khai giao diện
Word_addressable_intf.S
- Ví dụ, ghi vào địa chỉ
- Trên phần cứng thực, CPU, timer và GPU dùng chung một xung nhịp, nhưng trong trình giả lập chúng chạy tuần tự nên cần cơ chế đồng bộ riêng
- Vòng lặp chính dùng catch up method để đồng bộ tiến độ của từng mô-đun
- CPU thực thi một lệnh và ghi lại số chu kỳ đã tiêu tốn
- timer được chạy tiếp đúng bằng số chu kỳ CPU vừa dùng
- GPU cũng được chạy tiếp với cùng số chu kỳ đó
Giao diện đọc/ghi và cách triển khai bus
- Các mô-đun hỗ trợ đọc/ghi 8-bit cùng dùng 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... đều bao gồm cùng giao diện dưới dạnginclude Addressable_intf.S with type t := t- Giữa CPU và bus còn cần đọc/ghi 16-bit, nên
Word_addressable_intf.Sbao gồmAddressable_intf.Svà bổ sungread_word,write_word - bus giữ các mô-đun được kết nối như GPU, timer, RAM dưới dạng field và chuyển thao tác đọc/ghi đến đúng mô-đun theo địa chỉ
- Đọc/ghi tại địa chỉ
0xC000sẽ được định tuyến tới RAM - Toàn bộ memory map tham khảo tại Pandocs Memory Map
- Đọc/ghi tại địa chỉ
read_wordtriển khai đọc 16-bit bằng cách gọiread_bytehai lần; phần cứng thực tế cũng xử lý truy cập 16-bit bằng hai lần truy cập 8-bit
Thanh ghi và cải thiện khả năng kiểm thử CPU
- CPU của Game Boy có các thanh ghi 8-bit
A,B,C,D,E,F,H,L - Các thanh ghi 8-bit này có thể kết hợp thành các thanh ghi 16-bit
AF,BC,DE,HL - Bản triển khai CPU ban đầu giữ trực tiếp
registers,bus,pc... và thực hiện fetch, decode, execute trongrun_instruction - Cấu trúc này khó kiểm thử
- bus phụ thuộc vào nhiều mô-đun như GPU, timer, RAM...
- Muốn tạo CPU trong unit test thì phải chuẩn bị cả bus lẫn toàn bộ mô-đun gắn kèm
- Trước khi bus và mọi mô-đun liên quan được triển khai xong, không thể tạo instance CPU
- CPU sau đó được viết lại bằng functor để trừu tượng hóa phần triển khai cụ thể của bus
- bus được tiêm vào theo dạng
module Make (Bus : Word_addressable_intf.S) - Trong test, CPU được khởi tạo bằng
Mock_busdựa trên một mảng byte duy nhất - Nhờ vậy, unit test của CPU có thể dùng mock thay cho bus thật
- bus được tiêm vào theo dạng
Tập lệnh và việc dùng GADT
- Tập lệnh Game Boy có các lệnh nhận đối số 8-bit và các lệnh nhận đối số 16-bit
ADD8 A, 0x12cộng thanh ghi 8-bitAvới giá trị tức thời 8-bitADD16 AF, 0x1234cộng thanh ghi 16-bitAFvới giá trị tức thời 16-bit
- Cách thử đầu tiên biểu diễn đối số bằng các variant như
Immediate8,Immediate16,R,RR - Với cách dùng variant, rất khó xác định một kiểu trả về duy nhất cho
read_argR rtrả vềuint8RR rrtrả vềuint16- Các nhánh trong cùng một biểu thức match có kiểu trả về khác nhau
- Tác giả định nghĩa lại kiểu đối số bằng GADT
Immediate8 : uint8 -> uint8 argImmediate16 : uint16 -> uint16 argR : Registers.r -> uint8 argRR : Registers.rr -> uint16 arg
- Với cấu trúc này, kiểu trả về thay đổi theo kiểu đối số như
read_arg : type a. a Instruction.arg -> aADD8chỉ nhậnuint8 arg * uint8 argADD16chỉ nhậnuint16 arg * uint16 arg- Sự nhầm lẫn giữa đối số lệnh 8-bit và 16-bit được giảm ngay ở cấp kiểu
cartridge và first-class module
- cartridge của Game Boy không chỉ là ROM đơn giản mà tùy loại còn có thể chứa thêm phần cứng khác
- cartridge loại
ROM_ONLYchỉ chứa ROM lưu dữ liệu và mã game- Tetris là ví dụ sử dụng kiểu này
- cartridge loại
MBC3ngoài ROM còn có RAM và timer độc lập- Pokémon Red là ví dụ sử dụng kiểu này
- Vì mỗi loại cartridge có chức năng khác nhau nên chúng được triển khai thành các mô-đun riêng
- Để chọn đúng mô-đun theo loại cartridge ở runtime, tác giả dùng first-class module
Detect_cartridge.fđược thiết kế để nhận byte ROM và trả về dạng(module Cartridge_intf.S)
Kiểm thử tích hợp bằng test ROM và ppx_expect
- test ROM là các chương trình dùng để kiểm tra một chức năng cụ thể của trình giả lập
- Kiểm tra hoạt động của các lệnh số học cơ bản
- Kiểm tra hỗ trợ loại cartridge
MBC1
- Khác với ROM game thông thường, test ROM cho biết phạm vi chức năng bị lỗi và đôi khi vẫn chạy được dù thiếu một số tính năng cốt lõi, nên rất hữu ích khi phát triển trình giả lập
- test ROM thường xuất kết quả kiểm thử lên màn hình
- mooneye test ROMs hiển thị dump thanh ghi và thông tin assertion thất bại khi có lỗi
- Cũng có test ROM như blargg test roms xuất kết quả ASCII qua serial port
- Kiểm thử tích hợp dùng ppx_expect
M.run_test_rom_and_print_framebufferchạy ROM và in trạng thái màn hình cuối cùng dưới dạng ký tự ASCII- Chuỗi đầu ra được so sánh với giá trị kỳ vọng trong
[%expect{|...|}] - Phần giải thích về
ppx_expectcó thể xem trong bài viết của Jane Street
- Cấu hình test này giúp bắt lỗi hồi quy ngay cả khi thay đổi lớn trong mã nguồn, đồng thời hỗ trợ quy trình lập trình khám phá
- Tìm test ROM để xác minh tính năng mới
- Thiết lập test
ppx_expect - Commit đầu ra thất bại
- Triển khai tính năng
- Kiểm tra xem kết quả test có chuyển sang
Test OKhay không
Biên dịch JavaScript và UI trình duyệt
- Nhờ js_of_ocaml, việc biên dịch sang JavaScript không quá khó
- Để trình giả lập chạy được trong trình duyệt chỉ cần một commit duy nhất
- UI cho trình duyệt được triển khai bằng Brr
- Brr ánh xạ object JS sang module OCaml thay vì object OCaml
- API trình duyệt tích hợp sẵn trong
js_of_ocamlánh xạ object JS thành object OCaml, nên cần hiểu mô hình object của OCaml - Dùng Brr giúp giảm gánh nặng phải nắm mô hình object của OCaml
- API trình duyệt tích hợp sẵn trong
Quá trình tối ưu hiệu năng
- Bản chạy trên trình duyệt ban đầu đã hoạt động nhưng chậm đến mức khó chơi
- Chỉ khoảng 20 FPS trên trình duyệt PC
- Trong khi Game Boy thật chạy ở 60 FPS, nên cần cải thiện hiệu năng khoảng 3 lần
- Tác giả dùng Chrome profiler để tìm nút thắt
- GPU chiếm khoảng 73% thời gian
tile_data.mlchiếm 34%,oam_table.mlchiếm 18%,tile_mapchiếm 8%timer.mlvà một số hàmBigstringafcũng tiêu tốn nhiều thời gian
- Việc loại bỏ các nút thắt dần nâng FPS lên
- Tối ưu
oam_table.ml: 14 FPS → 24 FPS - Tối ưu
tile_data.ml: 24 FPS → 35 FPS - Tối ưu
timer.ml: 35 FPS → 40 FPS - Tối ưu
tile_map.ml: 40 FPS → 50 FPS - Dùng
Bigstringaf.unsafe_getthay choBigstringaf.get: 50 FPS → 60 FPS
- Tối ưu
- Sau đó, trên trình duyệt PC đã đạt 60 FPS nhưng trên smartphone vẫn chỉ ở mức 20~40 FPS
- Đầu ra JS của release build lại chậm hơn dev build, và nhờ cộng đồng discuss.ocaml.org, tác giả xác định inlining của
js_of_ocamllà nguyên nhân làm giảm hiệu năng JS- Thảo luận liên quan nằm tại bài viết trên discuss.ocaml.org
- Trong cập nhật ngày 12/01/2022, tác động tiêu cực này đã được xử lý tại ocsigen/js_of_ocaml#1220
- Sau khi tắt inlining, dự án đạt 100 FPS trên PC và 60 FPS trên smartphone
- Việc tối ưu hiệu năng JS cũng cải thiện hiệu năng native; khi chạy native, trình giả lập đạt khoảng 1000 FPS
Benchmark và giới hạn so sánh
- Dự án triển khai headless benchmarking mode để chạy trình giả lập không có UI
- FPS được đo trên nhiều backend biên dịch OCaml khác nhau
- Benchmark này khó dùng để so sánh FPS với các trình giả lập Game Boy khác
- Hiệu năng trình giả lập phụ thuộc rất nhiều vào độ chính xác và phạm vi tính năng được triển khai
- CAMLBOY không triển khai APU (Audio Processing Unit), nên việc so sánh FPS với trình giả lập có hỗ trợ APU là không có nhiều ý nghĩa
Trải nghiệm sử dụng OCaml
- Hệ sinh thái OCaml đã cải thiện nhiều so với khoảng 6 năm trước, khi tác giả từng dùng nó
- Nhờ dune, trải nghiệm gần hơn với kiểu chỉ cần đặt file vào thư mục là hệ thống build sẽ xử lý
- Merlin và OCamlformat giúp việc tự động hoàn thành, điều hướng mã và tự động định dạng nhìn chung khá dễ áp dụng
- Dùng setup-ocaml có thể thiết lập build và test trên GitHub Actions
- CAMLBOY dùng khá nhiều mutable state vì lý do hiệu năng
- Nhiều mô-đun có các hàm kiểu
t -> ... -> unit, tức là thay đổi một mutable state nào đó - Dù cách triển khai không “thuần hàm”, tác giả vẫn không cảm thấy mình đã mất đi các lợi thế của OCaml
- Nhiều mô-đun có các hàm kiểu
- Điểm tác giả ưa thích không hẳn là tính “hàm” mà là kiểu tĩnh, variant, pattern matching, hệ module và khả năng suy luận kiểu tốt
Những điểm bất tiện khi dùng OCaml
- Dù hệ sinh thái đã cải thiện, một số mảng vẫn còn phức tạp hoặc thiếu tài liệu
- Khi giải quyết dependency theo cách có thể tái lập, tài liệu chính thức của opam chưa có hướng dẫn đủ rõ ràng
- Tác giả phải đọc source của setup-ocaml để tìm lệnh cần dùng
- Cách phải “publish” gói lên máy cục bộ rồi cài gói đã publish cục bộ đó khiến mọi thứ trở nên khá rắc rối
- Chi phí cú pháp khi phụ thuộc vào abstraction khá cao
- Nếu muốn
Bphụ thuộc vào interfaceC_intfthay vì phần triển khai cụ thể củaC, thì phải biếnBthành functor - Khi
Btrở thành functor,Akhông thể tiếp tục tham chiếuB.foonhư cũ, nênAcũng phải thành functor nhậnB_intf - Khi biến một mô-đun thành functor, không chỉ cách mô-đun đó phụ thuộc vào mô-đun khác thay đổi, mà cả cách các mô-đun khác phụ thuộc vào nó cũng thay đổi theo
- Nếu muốn
- Vấn đề này xuất hiện khi cố tách riêng phần
Bus -> Cartridgetrong đồ thị phụ thuộcCamlboy -> Bus -> Cartridge - Trong OOP, chỉ cần đổi constructor của class
Bđể nhận interfaceC_intfthay vì class cụ thểCthì kiểu của chính classBkhông thay đổi- Tuy nhiên OOP lại có chi phí dynamic dispatch
- Và tính năng OOP của OCaml không quen thuộc với nhiều người, nên có thể làm thu hẹp nhóm độc giả hiểu mã
Tài liệu tham khảo
- Tài liệu liên quan đến OCaml
- Learn OCaml Workshop: tài liệu workshop từng được dùng trong nội bộ Jane Street, học bằng cách điền vào mã OCaml có chỗ trống và chạy test
- Real World OCaml: tài liệu thiên về ví dụ thực tế, phù hợp với người đã biết cú pháp cơ bản của OCaml hoặc từng dùng ngôn ngữ hàm khác
- Tài liệu liên quan đến Game Boy
- The Ultimate Game Boy Talk: video giải thích kiến trúc Game Boy trong khoảng 1 giờ
- gbops: bảng tập lệnh Game Boy
- Game Boy CPU Manual: tài liệu CPU dùng khi triển khai tập lệnh, dù một số phần, đặc biệt quanh register flag, không hoàn toàn chính xác
- Pandocs: wiki tham khảo hoạt động của các mô-đun phần cứng như GPU, timer
- Imran Nazar’s blog: tutorial xây dựng trình giả lập Game Boy bằng JavaScript, được dùng để ước lượng đại khái phạm vi triển khai
Chưa có bình luận nào.