2 điểm bởi GN⁺ 2025-07-06 | Chưa có bình luận nào. | Chia sẻ qua WhatsApp
  • Để đư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ằng js_of_ocamlBrr
  • Sau khi dùng Chrome profiler để giảm các nút thắt ở GPU, timer và Bigstringaf, rồi tắt inlining của js_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 ballRocket 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ỉ 0xFFFF sẽ đượ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
  • 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.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... đều bao gồm cùng giao diện dưới dạng include 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.S bao gồm Addressable_intf.S và bổ sung read_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ỉ 0xC000 sẽ được định tuyến tới RAM
    • Toàn bộ memory map tham khảo tại Pandocs Memory Map
  • read_word triển khai đọc 16-bit bằng cách gọi read_byte hai 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 trong run_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_bus dự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

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, 0x12 cộng thanh ghi 8-bit A với giá trị tức thời 8-bit
    • ADD16 AF, 0x1234 cộng thanh ghi 16-bit AF vớ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_arg
    • R r trả về uint8
    • RR rr trả 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 arg
    • Immediate16 : uint16 -> uint16 arg
    • R : Registers.r -> uint8 arg
    • RR : 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 -> a
    • ADD8 chỉ nhận uint8 arg * uint8 arg
    • ADD16 chỉ nhận uint16 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_ONLY chỉ 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 MBC3 ngoà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_framebuffer chạ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_expect có 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 OK hay 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

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.ml chiếm 34%, oam_table.ml chiếm 18%, tile_map chiếm 8%
    • timer.ml và một số hàm Bigstringaf cũ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
  • 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_ocaml là nguyên nhân làm giảm hiệu năng JS
  • 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ý
    • MerlinOCamlformat 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
  • Đ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 B phụ thuộc vào interface C_intf thay vì phần triển khai cụ thể của C, thì phải biến B thành functor
    • Khi B trở thành functor, A không thể tiếp tục tham chiếu B.foo như cũ, nên A cũng phải thành functor nhận B_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
  • Vấn đề này xuất hiện khi cố tách riêng phần Bus -> Cartridge trong đồ thị phụ thuộc Camlboy -> Bus -> Cartridge
  • Trong OOP, chỉ cần đổi constructor của class B để nhận interface C_intf thay vì class cụ thể C thì kiểu của chính class B khô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.

Chưa có bình luận nào.