1 điểm bởi GN⁺ 2 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • Fame Boy là trình giả lập Game Boy được triển khai bằng F#, chạy trên desktop và web kèm âm thanh; có chơi trên trình duyệtmã nguồn GitHub công khai
  • Tác giả đơn giản hóa phần lõi giả lập và frontend để chỉ dùng chung framebuffer, audiobuffer, stepEmulator(), getJoypadState(state); stepper lần lượt chạy CPU, timer, serial, APU, PPU để giữ đồng bộ trên một luồng duy nhất
  • Phần triển khai CPU dùng discriminated union và match của F# để mô hình hóa 512 opcode thành 58 lệnh; đồng thời được thiết kế với các kiểu FromTo để ngăn trạng thái bất hợp pháp như ghi vào giá trị tức thời ở cấp độ kiểu
  • PPU chọn render theo từng scanline thay vì pixel FIFO của Game Boy thật, giúp nhanh và đơn giản hơn, nhưng một số game dựa vào timing của hàng đợi pixel có thể không chạy đúng
  • Bản port lên web được thực hiện bằng Fable; sau khi sửa vấn đề phép toán bit 8-bit và 16-bit bị áp theo ngữ nghĩa 32-bit của JavaScript, nó chạy với bundle JS khoảng 100KB; nhờ tối ưu hiệu năng và bản build release, bản desktop đạt khoảng 1000FPS

Bối cảnh và mục tiêu dự án

  • Dù đã làm kỹ sư phần mềm hơn 8 năm, tác giả vẫn cảm thấy mình chưa hiểu máy tính thực sự hoạt động như thế nào, nên quyết định tự học bằng cách tự làm một trình giả lập
  • Vì thời nhỏ chơi Pokémon rất nhiều, tác giả chọn Game Boy: đó là phần cứng thật, phạm vi tương đối đơn giản, đồng thời có sự gắn bó cá nhân mạnh
  • Trước khi đi thẳng vào Game Boy, tác giả học From NAND to Tetris để hiểu các thành phần cơ bản của máy tính như register, memory và ALU
  • Để làm quen với việc viết trình giả lập, tác giả đã triển khai trước trình giả lập CHIP-8 tên Fip-8 bằng F#
  • Sau vài tháng làm việc, tác giả hoàn thành trình giả lập Game Boy Fame Boy có âm thanh và chạy được trên cả desktop lẫn web
  • Có thể chơi trên trình duyệt, và mã nguồn được công khai trên GitHub

Kiến trúc trình giả lập

  • Để chạy được trên cả desktop và web, giao diện giữa lõi giả lập và frontend được giữ ở mức đơn giản
  • Giao diện cốt lõi giữa frontend và phần lõi gồm hai mảng và hai hàm
    • framebuffer: mảng sắc độ 160×144 chứa trắng, sáng, tối và đen
    • audiobuffer: ring buffer âm thanh với sample rate 32768Hz, có đầu đọc và đầu ghi
    • stepEmulator(): thực thi một lệnh CPU và trả về số chu kỳ đã dùng
    • getJoypadState(state): callback để frontend truyền trạng thái joypad vào trình giả lập, thường được gọi mỗi frame một lần
  • Fame Boy được mô hình hóa theo cách gần giống phần cứng Game Boy thật
    • CPU không biết gì về phần cứng ngoài memory map, giống Sharp LR35902 của Game Boy thật, và chỉ dùng IoController cho tín hiệu interrupt
    • CPU là phần “đậm chất F#” nhất trong codebase và sử dụng rất nhiều functional domain modeling
    • Memory.fs lưu phần lớn RAM của Game Boy và đóng vai trò memory map lẫn bus giữa CPU, IO Controller và cartridge
    • Vì hiệu năng, Memory.fs chia sẻ tham chiếu tới các mảng VRAM và OAM RAM với PPU
    • IoController.fs được tách ra khi logic trong Memory.fs trở nên quá nhiều; dù Game Boy thật không có một IO controller duy nhất, cách này gom việc xử lý hardware register vào một chỗ để giữ giao diện giữa các thành phần đơn giản và an toàn
  • Hàm stepper trong Emulator.fs đóng vai trò chất kết dính cho toàn bộ trình giả lập, bằng cách kết hợp các hàm chạy từng bước của mỗi thành phần
let stepper () =
    // Execute a single instruction
    // Each instruction uses a different amount of cycles
    let mCycles = stepCpu cpu io

    for _ in 1..mCycles do
        stepTimers timer io
        stepSerial serial io
        // The APU technically runs at 4x CPU-cycles, but can be batched
        stepApu apu

    let tCycles = mCycles * 4

    // The PPU operates at 4x CPU-cycles. The APU should be here too
    for _ in 1..tCycles do
        stepPpu ppu

    // Return cycles taken so the frontend runs the emulator at the right speed
    mCycles
  • Các thành phần phần cứng thật chạy song song dựa trên một master oscillator trung tâm, nhưng vì Fame Boy là đơn luồng nên các thành phần phải được chạy tuần tự
  • Hàm stepper tập trung hóa việc thực thi để mọi thành phần luôn được đồng bộ
  • Để đạt tốc độ có thể chơi được, trình giả lập phải chạy đúng số chu kỳ mỗi giây; ở 60FPS, mỗi frame cần khoảng 17500 chu kỳ CPU
  • Frontend sẽ chạy trình giả lập theo audio sample rate khi bật âm thanh, và theo frame rate khi đang tắt tiếng

Triển khai CPU và F#

  • Trình giả lập CHIP-8 trước đó được viết thuần túy, không có thành viên mutable và còn sao chép cả mảng, nhưng Fame Boy lại chủ động dùng trạng thái có thể thay đổi

  • Game Boy nhanh hơn CHIP-8 rất nhiều, nên cách sao chép hơn 16KB bộ nhớ hàng triệu lần mỗi giây là không phù hợp

  • Lý do dùng F# cho Fame Boy là vì hệ thống kiểu phong phú của F# rất hợp để mô hình hóa lệnh CPU, và đơn giản là tác giả cũng rất thích F#

  • Mô hình hóa miền bài toán

    • Khi triển khai CPU, tác giả làm theo Gekkio’s Complete Technical Reference và nhóm các lệnh giống như tài liệu này
    • Ban đầu, tác giả đặt các discriminated union theo từng loại lệnh trong Instructions.fs
    • type LoadInstr = | Load8Immediate of uint8 | Load8Direct of Register | Load8Indirect // ... other load instructions
  • type ArithmeticInstr = | IncrementDirect of uint8 | IncrementIndirect of Register // ... các chỉ thị số học khác

    • Nhiều chỉ thị cùng chia sẻ một khái niệm chung là vị trí toán hạng

      • immediate: đọc giá trị byte trong bộ nhớ ngay sau chỉ thị
      • direct: đọc và ghi các thanh ghi CPU
      • indirect: đọc và ghi vị trí bộ nhớ mà thanh ghi CPU HL trỏ tới
    • Bằng cách tách khái niệm vị trí ra thành các kiểu FromTo, tác giả biểu diễn chỉ thị load ngắn gọn hơn

    • type To = | Direct of Register | Indirect

    • type From = | Immediate of uint8 | Direct of Register | Indirect

    • type LoadInstr = | Load of From * To // These form a tuple, like Load<From, To> in C# // ... other instructions

    • Với cách này, các chỉ thị CPU được rút từ 512 opcode xuống còn 58 chỉ thị

    • Khi khái quát hóa miền bài toán, có rủi ro cho phép các trạng thái sai, nhưng có thể ngăn chặn bằng hệ thống kiểu

    • Nếu dùng một kiểu vị trí duy nhất Loc thay cho FromTo, thì chỉ thị sai như Load(Loc.Direct D, Loc.Immediate) — lưu giá trị thanh ghi vào vị trí giá trị tức thời — vẫn có thể biên dịch

    • Phần cứng Game Boy không hỗ trợ ghi vào giá trị tức thời, nên việc mô hình hóa miền bài toán đúng bằng kiểu F# có thể bảo đảm các trạng thái bất hợp pháp không được biểu diễn trong hệ thống

    • Chỉ có một ngoại lệ duy nhất là opcode 0x76

      • Nếu chỉ nhìn vào mẫu opcode, nó sẽ có dạng như Load(From.Indirect, To.Indirect), tức nạp giá trị 8-bit ở vị trí HL vào chính cùng vị trí HL đó
      • Kiểu của Fame Boy cho phép điều này, nhưng trên Game Boy thực tế không có chỉ thị này
      • Về mặt logic nó là NOP nên không nguy hiểm, và trên thực tế không thể đi tới vì bộ đọc opcode sẽ giải mã 0x76 thành HALT
    • Sau khi dùng câu lệnh matchOption của F#, mỗi khi quay lại câu lệnh switch thông thường tác giả đều thấy nó vụng về và dễ mắc lỗi, nên khuyên mọi người thử dùng ngôn ngữ hàm

  • Giữ mọi thứ đơn giản

    • Vì mục tiêu của dự án không phải là trình giả lập tốt nhất mà là học về phần cứng máy tính, tác giả không đào sâu vào mã của các trình giả lập khác

    • Khi xem mã nguồn CAMLBOY, tác giả thích đoạn mã sau vì có thể truyền tùy ý chỉ những cờ cần thiết theo bất kỳ thứ tự nào

    • set_flags ~h:false ~z:(!a = zero) ();

    • F# không thể làm theo cách đó vì hệ thống kiểu hỗ trợ partial application khiến nó tránh method overloading và tham số mặc định

    • Ban đầu tác giả triển khai theo cách truyền một mảng cùng kiểu cờ như sau

    • cpu.setFlags [ Half, false; Zero, a = 0uy ]

    • Sau đó, trong quá trình refactor, tác giả chuyển sang cách triển khai dựa trên hàm thuần như ở Cpu/State.fs L81

    • module Flags = let inline setZ (v: bool) (f: uint8) = if v then f ||| ZMask else f &&& ~~~ZMask

      let inline setH (v: bool) (f: uint8) = // ... the other flag functions and definitions

    • // Other files

    • cpu.Flags <- cpu.Flags |> setH false |> setZ (a = 0uy)

    • Các hàm mới dễ kết hợp, dễ kiểm thử và là những hàm thuần đơn giản

    • Cách triển khai cũ dài dòng hơn vì phải đưa giá trị lên kiểu discriminated union rồi đặt vào mảng

    • Các hàm mới là inline, không cần cấp phát heap nên hiệu năng cũng tốt hơn, giúp FPS của trình giả lập tăng khoảng 10%

  • Kiểm thử

    • Ở giai đoạn đầu, phần CPU được triển khai bằng cách chạy ROM Tetris rồi mỗi khi chạm tới một opcode chưa hỗ trợ thì mới cài đặt chỉ thị tương ứng
    • match opcode with
    • | 0x00 -> Nop
    • | _ -> failwith "Unimplemented opcode"
    • Cách này buộc tác giả phải nhảy qua lại ngẫu nhiên giữa các tài liệu kỹ thuật nên việc lặp đi lặp lại trở nên nhàm chán, đồng thời cũng khó biết liệu chỉ thị đã được cài đặt đúng hay chưa
    • Để giải quyết cả hai vấn đề, tác giả đưa kiểm thử đơn vị vào dự án
    • Dù mã trình giả lập được tự viết để phục vụ học tập, tác giả vẫn tận dụng AI để tạo các test case
    • Tác giả đưa đặc tả từ tài liệu kỹ thuật vào prompt và yêu cầu viết các bài kiểm thử dựa trên đặc tả mà không nhìn vào mã trình giả lập
    • Trong lúc AI sinh kiểm thử, tác giả tự đọc đặc tả và triển khai logic cho đến khi các bài kiểm thử chạy qua, theo đúng tinh thần test-driven development thực sự
    • Các bài kiểm thử cũng giúp phát hiện một vài lỗi trong những chỉ thị đã được cài đặt từ trước
    • Các bài kiểm thử được rà soát và cải thiện định kỳ, và chúng giúp tác giả dồn năng lượng vào những phần thú vị hơn thay vì cản trở việc học

Các thành phần sau CPU

  • PPU

    • Game Boy không có GPU mà có PPU, tức picture processing unit
    • Nhiều bài viết khác về việc làm trình giả lập Game Boy thường tập trung vào CPU và chỉ dành vài đoạn cho PPU, nhưng với Fame Boy thì việc hiểu PPU lại mất nhiều thời gian hơn
    • CPU cho cảm giác khá tự nhiên nhờ kinh nghiệm với From NAND to Tetris và CHIP-8, còn PPU thì giống một công việc mang tính cơ khí hơn, phải đi theo từng bước để đưa pixel lên màn hình
    • Lúc đầu, thay vì cố hiểu pixel FIFO và toàn bộ pipeline PPU cùng một lúc, tác giả bắt đầu bằng cách đọc và phân tích tile cùng background map từ bộ nhớ rồi hiển thị chúng lên màn hình
    • Cách này giúp có thể nhìn thấy CPU đang hoạt động, và nhờ sự đơn giản của Tetris, kết quả trông gần như một game Game Boy thực thụ
    • Cách tiếp cận bắt đầu từ tile và background view tiếp tục hữu ích từ việc hiện thực màn hình thật cho tới lúc debug các lỗi chi tiết trong dữ liệu sprite
    • PPU của Fame Boy có mức độ không chính xác so với phần cứng khá lớn
      • Game Boy thực tế dùng hàng đợi FIFO như màn hình CRT để đặt từng pixel lên màn hình
      • Fame Boy thì render toàn bộ scanline ngay khi bắt đầu giai đoạn vẽ của dòng đó
    • Cách này nhanh hơn, mã đơn giản hơn, và vì tất cả các game tác giả muốn chơi đều chạy được nên không thấy cần chuyển sang hàng đợi pixel
    • Những game khai thác phần cứng Game Boy tới giới hạn và tận dụng timing của hàng đợi pixel sẽ không chạy đúng trên Fame Boy, nhưng đa số game không dùng phần cứng theo kiểu mạo hiểm như vậy nên nhìn chung có lẽ vẫn chạy được
  • Joypad

    • Ngoài PPU và APU, joypad cũng được xử lý
    • Bản hiện thực ban đầu rất dễ và việc viết test cũng đơn giản
    • Nhưng sau những đợt refactor lớn thì gần như lúc nào nó cũng bị hỏng
    • Thanh ghi phần cứng của joypad có tương tác phức tạp vì cả CPU lẫn game đều đọc và ghi vào đó
    • Ban đầu, CPU được cho ghi trạng thái joypad vào thanh ghi ở mỗi chu kỳ, nhưng vì con người không thay đổi nút bấm hàng triệu lần mỗi giây nên sau đó đổi sang chỉ cập nhật một lần mỗi frame
    • Kết quả là D-pad ngừng hoạt động
    • Phần cứng Game Boy chỉ có thể đọc một nửa số nút tại một thời điểm, và các game gần như luôn đọc thanh ghi joypad hai lần hoặc nhiều hơn trong khoảng rất ngắn, dựa vào việc thanh ghi thay đổi giữa các lần đọc
    • Khi thanh ghi chỉ được cache một lần mỗi frame, nó không thay đổi giữa hai lần đọc nên một nửa số nút không hoạt động
    • Cuối cùng, IoController được hiện thực để chỉ cập nhật thanh ghi joypad khi CPU thực sự đọc nó
    • Có thể xem thêm trong tài liệu joypad của Pandocs
  • Âm thanh

    • Sau khi tạo được một trình giả lập hoạt động, tác giả chơi bản web và cảm thấy thiếu âm thanh khiến mọi thứ trống trải, nên đã thêm APU, tức audio processing unit
    • Tác giả phát hiện ra rằng nhiều trình giả lập được vận hành theo tốc độ lấy mẫu âm thanh của frontend chứ không phải theo framerate
    • Ban đầu điều này có vẻ ngược đời, nên tác giả tìm hiểu dynamic sampling rate và cố hiện thực theo hướng để framerate điều khiển trình giả lập
    • Âm thanh là thành phần khó nhất về mặt khái niệm, và phải mất thời gian để hiểu hoạt động của nhiều thanh ghi âm thanh và các kênh
    • Ở phần này AI đã giúp rất nhiều trong vai trò người hướng dẫn, với nhiều lượt hỏi đáp trước khi viết mã
    • Tương tự PPU, cảm giác hoàn thành từng kênh một rất thỏa mãn, và khi nghe nhạc Tetris dần đầy đặn hơn, tác giả cũng hiểu thêm âm nhạc được cấu thành như thế nào
    • CPU và PPU là kiểu mỗi frame thực hiện chính xác X công việc và có thể tính X khá dễ, còn APU thì có nhiều giá trị cần chọn và tinh chỉnh hơn nhiều
    • Riêng sampling rate của APU thì được quyết định khá dễ
      • APU của Game Boy thực tế khá linh hoạt nên trình giả lập có thể dùng sampling rate tùy ý
      • Fame Boy chọn 32768Hz
      • Với xung nhịp CPU 1048576Hz, 32768Hz tương đương 1 mẫu cho mỗi 128 chu kỳ CPU, nên trạng thái APU có thể đồng bộ hoàn hảo chỉ bằng số nguyên
      • 128 cũng chia hết cho 4, nên ngay cả khi xử lý theo lô 4 bước APU một lần thì vẫn không lệch căn với lệnh CPU
    • Các giá trị khác thì bất ổn hơn nhiều, và vì không phải kỹ sư âm thanh nên tác giả phải thử đi thử lại để canh chỉnh
    • Mỗi frontend, mỗi nền tảng đều có vấn đề riêng
      • Trên PC, âm thanh hoạt động tốt nhưng trên MacBook thì nghe như tiếng thác nước
      • Sau khi sửa lỗi trên MacBook, bản trên desktop PC lại không chạy vì race condition
    • Tác giả từ bỏ nỗ lực giải quyết một cách thông minh bằng dynamic sampling rate, và khi chuyển sang để âm thanh điều khiển trình giả lập thì audio ổn định hơn nhiều trên nhiều thiết bị
    • Audio là phần “rò rỉ” nhất trong giao diện giữa trình giả lập và frontend, nhưng để tránh âm thanh chói gắt thì cần đồng bộ thật chính xác

Cách vận hành trình giả lập

  • Khác biệt giữa cách chạy theo audio và theo frame có liên quan đến nhận thức của con người
  • Khi tín hiệu âm thanh bị đứt, loa sẽ di chuyển mạnh do tín hiệu thay đổi đột ngột, tạo ra tiếng pop
  • Khi video bị đứt, trình phát video sẽ bỏ qua một hai frame vì dữ liệu không đến kịp, nhưng vì không có thứ vật lý nào bị đẩy mạnh nên về cảm giác sẽ ít khó chịu hơn
  • Bên trong Fame Boy, audio và video được đồng bộ hoàn hảo theo thiết kế
  • Nhưng audio và video của máy tính đang chạy thì độc lập với nhau, và một trong hai bên đôi khi có thể bị chậm hơn
  • Nếu audio và video ở frontend bị lệch nhau thì có hai lựa chọn
    • Đồng bộ audio của frontend với audio của trình giả lập và thỉnh thoảng bỏ frame
    • Đồng bộ video của frontend với frame của trình giả lập và thỉnh thoảng bỏ audio
  • Bên nào được chọn sẽ “điều khiển” trình giả lập, còn bên kia sẽ được giữ gần nhất có thể
  • Cách chạy theo framerate tương đối đơn giản
let mutable cycles = 0

while (runEmulator) do
    cycles <- cycles + targetCyclesPerMs * lastFrameTime

    while cycles > 0 do
        let cyclesTaken = stepEmulator ()
        cycles <- cycles - cyclesTaken

    draw ppu.framebuffer
  • Cách chạy theo âm thanh khó hơn vì Raylib và Web Audio xử lý audio theo những cách khác nhau
  • Luồng chung như sau
let tryQueueAudio apu stepEmulator =
    if frontend.audioBuffer.hasSpace () then
        while apu.writeHead - apu.readHead < samplesNeeded do
            stepEmulator ()

        frontend.audioBuffer.fill apu.audioBuffer

while (runEmulator) do
    tryQueueAudio apu stepEmulator

    draw ppu.framebuffer
  • Khác biệt cốt lõi là stepEmulator không còn được điều khiển bởi lastFrameTime nữa, mà chạy theo nhu cầu của bộ đệm audio phía frontend
  • samplesNeeded phải tính được số lần gọi stepEmulator để phù hợp với các sampling rate khác nhau và vẫn tạo ra 60FPS
  • Bộ đệm audio của frontend chỉ quan tâm đến việc tự làm đầy, nên nó có thể gọi stepEmulator quá nhiều hoặc quá ít lần trong một frame, và kết quả là framebuffer có thể không được cập nhật đúng lúc
  • Frontend web có thể thử phiên bản chạy theo frame bằng cách thêm ?frame-driven vào URL
  • Phiên bản chạy theo frame mượt hơn về mặt hình ảnh nhưng thỉnh thoảng xuất hiện tiếng pop trong audio
  • Frontend web chạy theo audio cũng chuyển sang chế độ theo frame khi nút tắt tiếng được nhấn, vì lúc đó sẽ không nghe thấy tiếng pop nữa
  • Bản hiện thực chưa hoàn hảo, nhưng vì tiếng pop khó chịu hơn hiện tượng giật frame và trạng thái tắt tiếng khiến mọi thứ trống trải, nên mặc định của frontend web được đặt là chạy theo audio
  • Audio là một trong số rất ít phần của Fame Boy mà tác giả chưa hài lòng, và là chỗ muốn quay lại cải thiện vào một ngày nào đó

Đưa lên web bằng Fable

  • Sau khi PPU hoạt động ở mức nào đó và bắt đầu hiển thị được thứ gì đó trên màn hình desktop, tác giả muốn đưa Fame Boy lên web
  • Sau khi đọc tài liệu Fable, cài package, thiết lập vòng lặp chính và thêm style, chỉ mất một hai giờ là đã sẵn sàng để chạy
  • Phiên bản Fable chạy thử đầu tiên hiển thị màn hình bị lỗi, và sau khi debug một chút, để tránh tốn quá nhiều thời gian, tác giả thử WebAssembly của Blazor
  • Blazor cũng khá dễ để chạy, và lần này thực sự hoạt động, nhưng chỉ khoảng 8FPS nên gần như không thể chơi được
  • Không chắc đây có phải vấn đề của riêng Blazor hay không; tác giả cũng đã thử làm theo hướng dẫn tối ưu hiệu năng của nhóm .NET nhưng không giúp ích
  • Việc debug cũng bất tiện nên tác giả quay lại Fable để kiểm tra xem có gì sai trong quá trình chuyển đổi sang JavaScript
  • Fable đặt file JS được chuyển đổi ngay bên cạnh mã nguồn, và thực tế là khá dễ đọc
  • Nhờ vậy, việc hiểu đoạn mã mới và debug trong công cụ dành cho nhà phát triển của trình duyệt trở nên dễ dàng hơn
  • Trong công cụ dành cho nhà phát triển, tác giả phát hiện giá trị thanh ghi CPU có gì đó bất thường
    • Thanh ghi CPU của Fame Boy và Game Boy là số nguyên không dấu 8-bit nên phạm vi phải là 0–255
    • Nhưng lại xuất hiện các giá trị như -15565461
  • Trong tài liệu Fable, tác giả tìm thấy tài liệu tương thích về numeric types

Các phép toán bitwise trên số nguyên 16-bit và 8-bit (không theo chuẩn) sử dụng ngữ nghĩa bitwise 32-bit nền tảng của JavaScript. Kết quả không bị cắt ngắn như mong đợi, và toán hạng dịch bit không được che để khớp với kiểu dữ liệu.

  • Điều này khớp chính xác với mô tả rằng các phép toán bit trên số nguyên 16-bit và 8-bit dùng ngữ nghĩa bitwise 32-bit của JavaScript, nên kết quả không bị cắt như mong đợi
  • Sau khi tìm ra những điểm trong mã nơi giá trị 8-bit cần bị cắt ngắn và sửa các vấn đề liên quan, frontend web đã hoạt động đúng
  • Do chỉ dùng JS mà không cần .NET runtime, gói web chỉ khoảng 100KB
  • Ngoài vấn đề uint8 khá kỳ lạ, trải nghiệm dùng Fable nhìn chung khá dễ chịu, và toàn bộ mã nguồn vẫn có thể được giữ bằng F#

Cải thiện hiệu năng

  • Sau khi bắt đầu thấy kết quả trên màn hình, tác giả thêm log FPS đơn giản vào console
  • Ban đầu, ở chế độ debug đạt khoảng 55–60FPS, có vẻ do Raylib cố giữ v-sync
  • Khi tắt v-sync, FPS tăng lên khoảng 70FPS nhưng xuất hiện jitter
  • Sau đó, khi thêm dần tính năng, hiệu năng giảm từ từ xuống còn 45FPS, và việc tắt v-sync cũng không giúp ích
  • Khi chạy profiler của JetBrains Rider, mapAddress hiện lên như một điểm nghẽn đáng ngờ
  • Vì gần như mọi thành phần đều truy cập bộ nhớ, tác giả xác nhận chi phí truy cập bộ nhớ lớn hơn dự kiến
  • Đoạn mã gây vấn đề là cách ánh xạ địa chỉ bộ nhớ sang discriminated union MemoryRegion, rồi mới đọc và ghi
type MemoryRegion =
    | RomBase of offset: int
    // ... others

let mapAddress (addr: int) : MemoryRegion =
        match addr with
        | a when a < 0x4000 -> RomBase a
        // ... others

type DmgMemory(arr: uint8 array) =
    // Arrays for romBase etc

    member this.read address =
        match mapAddress address with
        | RomBase i -> romBase[i]
        // ... others

    member this.write address value =
        match mapAddress address with
        | RomBase _ -> ()
        // ... others
  • Tác giả từng muốn mở rộng luồng mô hình hóa miền đã có ở CPU sang cả bộ nhớ, và kết quả là mỗi lần đọc/ghi bộ nhớ đều tạo rồi ánh xạ một đối tượng MemoryRegion
  • Cách làm này cấp phát hàng triệu đối tượng lên heap mỗi giây, đồng thời tăng thêm các nhánh mà trình biên dịch JIT phải xử lý
  • Chỉ với một thay đổi là bỏ discriminated union và hàm ánh xạ, chuyển sang truy cập mảng trực tiếp, FPS đã tăng gấp đôi
  • Trong benchmark sau đó, phần lớn cải thiện hiệu năng dường như đến từ tối ưu hóa JIT đối với các nhánh và các điểm gọi được cục bộ hóa
  • Ngay cả khi đổi MemoryRegion sang struct DU để nó được cấp phát trên stack, hiệu năng cũng chỉ cải thiện khoảng 15%; 85% còn lại đến từ việc loại bỏ DU và hàm ánh xạ
  • Sau đó vẫn còn nhiều trường hợp khác được chuyển sang struct DU hoặc chọn cách tiếp cận không mấy “thân thiện với F#”
  • Ngay từ thời điểm triển khai PPU, việc tối ưu hóa đã trở nên cần thiết, và tác giả phải từ bỏ phần nào phong cách F# thành ngữ
  • Bằng cách thường xuyên xem profiler và cải thiện hiệu năng dần dần, tác giả đã đưa tốc độ lên khoảng 120FPS
  • Mức tăng FPS lớn nhất lại đến từ việc tắt debug build; ở chế độ release, tốc độ tăng lên khoảng 1000FPS
  • Tác giả tiếp tục theo dõi và tinh chỉnh hiệu năng đều đặn cho đến cuối

Benchmark

  • Tác giả cho rằng chỉ nhìn vào con số FPS trong console không phải cách đo hiệu năng tốt, nên giữa chừng dự án đã thêm một dự án BenchmarkDotNet để đo hiệu năng desktop
  • Sau đó còn tạo một trình benchmark web đơn giản dùng Node.js để ước lượng tương tự hiệu năng trong trình duyệt web
  • Benchmark dùng các demo ROM sau để kiểm thử những kịch bản thực tế
    • Flag: vòng lặp ngắn không có âm thanh
    • Roboto: demo chạy dài hơn 1 phút, sử dụng nhiều hiệu ứng hình ảnh và âm thanh
    • Merken: tương tự Roboto nhưng dùng ROM memory banking để kiểm tra bộ nhớ
  • Hiệu năng FPS trên desktop của PC Windows Ryzen 9 7900 và MacBook Air M4 như sau
CPU Flag Roboto Merken
Ryzen 9 7900 1785 1943 1422
Apple M4 1907 2508 1700
  • Hiệu năng FPS trên web như sau
CPU Flag Roboto Merken
Ryzen 9 7900 646 883 892
Apple M4 779 976 972
  • Fame Boy chạy khá ổn trên cả hai nền tảng
  • Trái với dự đoán, APU tức phần âm thanh ảnh hưởng đến hiệu năng trình giả lập nhiều hơn PPU
  • Khi tắt PPU, hiệu năng desktop tăng khoảng 250FPS, nhưng khi tắt APU thì tăng khoảng 500FPS

Sử dụng AI

  • Tác giả cho rằng ngay cả trong một dự án học tập cũng không thể hoàn toàn tránh ảnh hưởng của AI, nên đã ghi lại một cách minh bạch cách mình sử dụng AI
  • Trong toàn bộ quá trình, AI chủ yếu được dùng như một công cụ hỗ trợ
    • yêu cầu review code
    • một đối tác đối thoại để xem xét ý tưởng
    • diễn giải các tài liệu kỹ thuật ngắn gọn
  • Tác giả cố gắng giảm tối đa lượng code do AI viết
  • Vì muốn tạo ra một thành quả có thể tự hào khi cho người khác xem, tác giả không muốn chỉ chia sẻ prompt mà muốn để lại dưới dạng code do chính mình viết
  • PR cải thiện hiệu năng

    • Ở phần cuối dự án, tác giả đưa repository cho CLI và để nó tìm cách cải thiện hiệu năng
    • Nó đã đưa ra vài ý tưởng và cũng được phép thử thêm những cách khác nó muốn, kết quả là trong một số benchmark hiệu năng tăng hơn gấp đôi
    • Chi tiết có trong PR
    • Tuy nhiên, bug cũng bị đưa vào và tác giả phải tự tìm rồi sửa
    • Một trong những cải thiện hiệu năng lớn là “chỉ cập nhật STAT khi chuyển mode/LY”, nhưng điều này lại làm hỏng một số game và demo vốn phụ thuộc vào việc cập nhật thường xuyên hơn, và đã được sửa bằng commit chỉnh sửa
  • “Mùa đông của timer”

    • Trong lịch sử Git có một khoảng trống lớn, và tác giả gọi giai đoạn này là “timer winter”

    • Không phải là tác giả không làm emulator, mà là bị mắc kẹt bởi một bug không thể vượt qua màn hình bản quyền của Tetris

    • Tác giả đã debug hơn 20 giờ, tìm trong Discord emu-dev, tạo test, và ném vấn đề cho các mô hình AI đời đầu, nhưng vẫn không giải quyết được

    • Sau đó nghỉ vài tuần rồi thử Claude Opus, và chỉ trong vài phút đã tìm ra vấn đề

    • Vấn đề là timer chỉ tick một lần cho mỗi lệnh, thay vì tick theo số chu kỳ mà lệnh đó tiêu thụ

    • let stepEmulator () = let cyclesTaken = stepCpu cpu

      // Before stepTimers timer memory // only once per instruction

      // The fix for _ in 1..cyclesTaken do // cpuCycles can vary between 1 and 6 stepTimers timer memory

    • Vì chu kỳ CPU có thể dao động từ 1 đến 6, nên trong cách cài đặt cũ timer hoạt động chậm hơn thực tế trung bình khoảng 2–3 lần

    • Màn hình bản quyền thực ra chỉ ở lại lâu hơn bình thường, và vấn đề là tác giả chưa từng thử chờ trong 1–2 phút

    • Phần lớn bài viết chính đều do tác giả tự viết

Những điều học được và kết luận

  • Mục tiêu chính là học cách máy tính hoạt động, và với mục tiêu đó thì đây là một thành công lớn
  • Quá trình làm rất thú vị; sau giờ làm, tác giả thường bắt đầu với ý nghĩ “hôm nay chỉ thêm một tính năng thôi”, rồi lại mải mê đến 2 giờ sáng vì muốn sửa thêm đúng một bug nữa
  • Tác giả cũng từng nghĩ đến việc thử Game Boy Advance, nhưng nhìn vào đặc tả thì thấy mức độ hiểu thêm về phần cứng chỉ tăng khoảng 20%, trong khi công sức có vẻ phải gấp 3 lần
  • Game Boy có sự cân bằng tốt để hỗ trợ việc học, và trước mắt có thể dừng lại ở đây
  • Tác giả không chắc mình có trở thành một kỹ sư phần mềm giỏi hơn hay không, nhưng chắc chắn đã hiểu hơn một chút về những công cụ mình dùng mỗi ngày
  • Có thể gửi câu hỏi hoặc ý kiến qua email

1 bình luận

 
Ý kiến trên Hacker News
  • Thật vui khi thấy F# ở đây! Trình giả lập là cách rất tốt để học một ngôn ngữ, và thoạt nhìn thì có vẻ tác giả đã chọn dùng khá khéo giữa F# mang tính thành ngữ hơn và F# kém thành ngữ hơn tùy theo từng tác vụ
    Một cải tiến dễ làm để giảm cấp phát là thêm [<Struct>] vào discriminated union trong Instructions.fs, và có thể tái sử dụng tên trường để tái sử dụng các trường bên trong
    Chỉ là góp ý nhỏ thôi, nhưng cách xử lý một số thanh ghi hơi gây bối rối. Vì vốn đã là kiểu byte, nên trong setter có a &&& 0xFFuy dường như không bổ sung gì so với member val A = 0uy with get, set. Có lẽ là dấu vết của một thay đổi trong quá trình phát triển

    • Trong mã nguồn Register có chú thích như sau: thanh ghi cần cắt giá trị về 8 bit khi ghi, nên không thể là record type và cần có setter
      Lý do là do trình render web, và có giải thích rằng Fable chuyển uint8 thành Number trong JS nên có thể vượt quá 8 bit mà không áp dụng cắt bớt
      Vì vậy có vẻ đây là đoạn mã dọn dữ liệu một cách thận trọng do đặc tính của Fable khi target web, nơi dữ liệu bị nới rộng thành JS Number
    • Thực ra bài viết có nói đến điều này ở phần port sang Fable. Tác giả cũng nói đã thử Blazor
  • Cuối cùng cũng có ai đó bỏ ra nỗ lực thực sự của con người để học một điều gì đó, chứ không phải kiểu “LLM đã giúp làm ra X trong Y phút”
    Dù sao thì có vẻ nhân loại vẫn còn chút hy vọng

    • Kiểu đó sẽ luôn còn tồn tại. Năm 2026 vẫn có người làm mọi thứ bằng dụng cụ cầm tay, nên cứ gọi đây là lập trình thủ công đi
    • Tôi nghĩ lẽ ra phải từ bỏ hy vọng vào nhân loại từ hồi Liên Xô sụp đổ rồi
      Dù vậy, trình giả lập thực sự rất ngầu, và trình giả lập GBA là một mục tiêu rất đáng để tự thử sức
    • Tôi đã sống như một lập trình viên F# rất lâu, đồng thời cũng chịu đựng nạn bắt nạt trong giới học thuật STEM trong thời gian dài, nên tôi không dùng LLM. Lý do lớn là vì ChatGPT-3.5 lộ liễu đến mức rõ ràng là đang copy-paste từ các kho GitHub F#
      Nó hoàn toàn không cho cảm giác AGI gì cả, mà giống một cỗ máy đạo văn đã bị lột hết lớp trang trí
      Chắc hẳn đến một lúc nào đó ai đó ở Microsoft đã nhận ra và bật còi báo động RLHF, nên GPT đã khá hơn nhiều và có vẻ cũng dùng được cho F#. Nếu là một lập trình viên F# không quá nguyên tắc thì có lẽ dạo này vẫn đang làm tốt với các agent
      Nhưng thay vì nghĩ rằng “đã giải quyết được vấn đề đạo văn nên giờ hãy tạo thêm rác”, tôi lại thấy rằng “giờ ChatGPT có đạo văn thì cũng không còn lộ liễu nữa”
      Tôi không muốn tung xúc xắc d100 hay d1000 với khả năng phá hỏng hoàn toàn một trong những giá trị cốt lõi của mình chỉ để đổi lấy chút lợi ích năng suất. Tôi thà chậm chạp và thất nghiệp. Nói nghiêm túc, tôi đang chuyển hướng sang lắp đặt điện mặt trời và thu gom phế liệu
      Vấn đề “sinh viên không muốn suy nghĩ” đã tồn tại lâu hơn LLM rất nhiều. Năm 2007 tôi học một lớp phương trình vi phân riêng phần nâng cao, và vì tôi thực sự muốn học PDE nên đã làm gần như toàn bộ bài tập; do tâm lý yếu và không thể từ chối những sinh viên toán lười biếng khó ưa, hầu như ai cũng chép bài của tôi. Đến cả trong chương trình cao học toán cũng lặp lại như vậy. Thật khó tin. Nếu thế thì tôi không hiểu họ ở trong chương trình đó để làm gì
  • A, F#, tình yêu lớn nhất của tôi. Tôi ước gì phía C# nhìn vào thứ này thay vì tiếp tục biến C# thành một ngôn ngữ làm được đủ thứ nhưng khá vụng về
    Nếu dựng dự án dùng cả C# lẫn F#, tôi không hiểu sao họ lại không thấy rằng có thể có được những thứ liên tục được thêm vào C# theo cách thực sự hoạt động tốt và tiện dụng hơn nhiều. Khả năng tương tác cũng rất tuyệt

    • Tuy vậy, nếu đến từ thế giới OCaml thì cũng hơi tiếc vì F# có cảm giác bị nhốt phần nào trong cái bóng của C#
      Bạn có thể đi khá xa nếu dùng F# như một ngôn ngữ hàm, nhưng rốt cuộc rồi cũng sẽ muốn tương tác với hệ sinh thái .NET, và đến lúc đó bạn sẽ viết code theo một kiểu lai hướng đối tượng/hàm khá kỳ lạ
  • F# là một ngôn ngữ tốt, nhưng luôn có cảm giác bị mắc kẹt mãi trong cái bóng của C#. Phần lớn mã thư viện là thứ thừa hưởng từ C# và .NET, nhiều khi không phải là giao diện hay thư viện được thiết kế với F# trong đầu, và cũng thường không có tài liệu hướng dẫn dùng cho F# một cách rõ ràng

    • Việc chuyển cách dùng thư viện từ C# sang F# khá cơ học, nên tôi không chắc tài liệu riêng có thật sự cần thiết đến vậy không
      Vấn đề lớn hơn là cộng đồng C# thích hướng đối tượng, nên nếu bạn muốn làm việc theo kiểu lập trình hàm thì thường phải bọc các thư viện đó lại theo dạng “hàm” hơn
      Dù thế, tôi vẫn thấy tốt hơn rất nhiều so với không có gì cả. Tôi cũng thích Haskell hay OCaml, nhưng ở khía cạnh này thì đúng là có sự khác biệt
    • Đúng là có một mức độ gượng gạo nhất định do tương tác giữa hai bên, nhưng tôi nghĩ vấn đề lớn hơn không hẳn là một thư viện cụ thể phải được ánh xạ sao cho hợp với F#, mà là phải hiểu rõ các quy tắc tương tác và hình dạng của đầu ra nội bộ được tạo ra
      Khả năng tương tác với C# làm nới lỏng những đảm bảo mà mã F# thường dựa vào, đặc biệt là tính bất biến. Và do cách nó được ánh xạ sang C#, ngay cả generic cũng xuất hiện những giới hạn bất ngờ
  • Thật sự rất ngầu! Tôi thích F#, nhưng sau khi từng viết một trình thông dịch Smalltalk bằng F#, tôi cũng xác nhận được rằng nếu dùng nó đúng theo cách được định hướng cho kiểu công việc này thì nó không hẳn là một con quái vật tốc độ

    • Trong F#, tôi thấy hiệu năng tốt hơn nếu viết theo kiểu mệnh lệnh đến mức ngớ ngẩn, nhưng nhốt tác dụng phụ bên trong hàm. Khi đó bạn vẫn có thể giữ hàm ở trạng thái gần như “thuần” mà vẫn đạt tốc độ khá ổn
      Ví dụ, bình thường tôi thích cấu trúc dữ liệu Map, và đó là một cấu trúc bất biến khá tuyệt vời, đủ dùng cho phần lớn trường hợp. Nhưng khi hiệu năng trở nên quan trọng thì cũng không khó để chuyển sang các vòng lặp mệnh lệnh nhàm chán với hash map thông thường
      Nếu nhốt mọi thứ trong một hàm thì nhìn chung vẫn tránh được cảm giác mình đã viết thứ gì đó quá bẩn
    • Tôi tò mò không biết bạn viết trình thông dịch đó từ khi nào. Toàn bộ hệ sinh thái .NET đã được tăng tốc khủng khiếp trong vài năm qua, và nếu lần cuối bạn dùng là từ thời Framework thì khác biệt là rất lớn
      Họ thậm chí còn đầu tư vào việc cải thiện tail call, thứ mà ngay cả trình biên dịch C# cũng không tận dụng. Khoảng .NET 9 hoặc 10, F# còn được thêm một tính năng để nếu có lời gọi đệ quy không phải tail call thì trình biên dịch sẽ báo lỗi, giúp bạn không vô tình làm hỏng hiệu năng
    • Nếu cẩn thận chọn dùng tính năng nào vào lúc nào thì F# cũng có thể rất nhanh. Bạn có thể dùng mô hình hàm khi muốn, và khi cần thì viết mã mệnh lệnh mức thấp trong các hot loop
      Chỉ có điều nếu dùng linked list, sequence và kiểu dữ liệu bất biến khắp nơi thì chắc chắn không phải Rust
  • Dự án rất ngầu! Tôi thực sự thích nhìn thấy những thứ như thế này
    Mặt khác, điều này không phải đánh giá tác giả hay công việc của họ, nhưng sau khi nhìn thấy F# trong một dự án thực tế trông như thế nào thì tôi cảm thấy có lẽ mình có thể từ bỏ ý định học và dùng F#
    Phần thuần hàm thì đẹp, nhưng khi đi xuống phần mã mệnh lệnh hơn hoặc có thể thay đổi được thì trông khá xấu. Mà tiếc là trong phần lớn dự án thực tế có lẽ cuối cùng cũng phải làm vậy
    Vậy nên tôi không biết có phải nên chọn một ngôn ngữ hàm khác để lao vào hay chỉ nên tập trung áp dụng các khái niệm hàm vào ngôn ngữ mình đang dùng. Ngôn ngữ chính của tôi là C# và hỗ trợ mô hình hàm của nó vẫn đang tăng dần, nên hướng sau cũng khá dễ

  • Một trình giả lập được viết bằng ngôn ngữ hàm lúc nào cũng gây ấn tượng. Vì thông thường việc ánh xạ phần cứng sang ngôn ngữ mệnh lệnh sẽ dễ hơn nhiều. Tôi rất thích xem mọi người tạo ra những trừu tượng hàm kiểu gì

    • Không biết bạn đã xem mã chưa. F# có biến và mảng có thể thay đổi được, và dự án này cũng dùng chúng chẳng hạn cho bộ nhớ
  • F# thật sự là một ngôn ngữ rất thú vị, và đây là một công việc tuyệt vời!

  • F# là ngôn ngữ tình yêu dành cho việc viết code mà tôi sẽ không bao giờ dùng được trong công việc thực tế. Ngoài các dự án cá nhân thì tôi không có dịp nào để dùng cả :(

  • Bài viết thú vị và dễ đọc. Tôi thích phần mô hình hóa dữ liệu. Tôi đang nghịch OCaml một chút, và kiểu mô hình hóa đó là phần hay nhất
    Cũng thú vị khi biết đến CAMLBOY. Nếu góp ý cho tác giả thì tôi nghĩ nên bỏ qua bước biên tập bằng AI. Tôi có lẽ sẽ thích một bài viết có vài lỗi ngữ pháp hoặc diễn đạt kém trau chuốt hơn là một bài viết hơi nhạt như hiện tại