• Fame Boy F# में बनाया गया एक Game Boy emulator है, जो sound सहित desktop और web पर चलता है, और browser playGitHub source सार्वजनिक हैं
  • emulator core और frontend के बीच इंटरफ़ेस को इस तरह सरल रखा गया कि केवल framebuffer, audiobuffer, stepEmulator(), getJoypadState(state) साझा हों, और stepper CPU·timer·serial·APU·PPU को क्रम से चलाकर single-thread synchronization बनाए रखता है
  • CPU implementation में F# के discriminated unions और match का उपयोग करके 512 opcode को 58 instructions के रूप में मॉडल किया गया, और From·To types के ज़रिए immediate value पर लिखने जैसी अवैध स्थितियों को type level पर रोका गया
  • PPU ने असली Game Boy के pixel FIFO की जगह scanline-आधारित rendering चुनी, जिससे यह तेज़ और सरल बना, लेकिन pixel queue timing पर निर्भर कुछ games सही तरह से काम नहीं कर सकते
  • web port को Fable से संभव बनाया गया, और 8-bit·16-bit bit operations में JavaScript के 32-bit semantics से जुड़ी समस्या ठीक करने के बाद यह लगभग 100KB JS bundle में चला; performance optimization और release build के साथ desktop पर लगभग 1000FPS तक पहुँचा

प्रोजेक्ट की पृष्ठभूमि और लक्ष्य

  • 8 साल से अधिक समय तक software engineer के रूप में काम करने के बावजूद लेखक को लगा कि वह वास्तव में नहीं समझता कि कंप्यूटर कैसे काम करता है, इसलिए उसने खुद emulator बनाकर सीखने का फैसला किया
  • बचपन में Pokémon बहुत खेलने की वजह से उसने Game Boy को लक्ष्य चुना; यह वास्तविक hardware था, दायरा अपेक्षाकृत सरल था, और उससे व्यक्तिगत जुड़ाव भी मज़बूत था
  • सीधे Game Boy पर जाने से पहले उसने From NAND to Tetris किया, ताकि register, memory, ALU जैसे कंप्यूटर के बुनियादी घटकों को समझ सके
  • emulator development की आदत डालने के लिए उसने पहले F# में CHIP-8 emulator Fip-8 बनाया
  • कुछ महीनों के काम के बाद उसने sound सहित desktop और web पर चलने वाला Game Boy emulator Fame Boy पूरा किया
  • इसे browser में चलाया जा सकता है, और source GitHub पर उपलब्ध है

emulator की संरचना

  • desktop और web दोनों पर चलाने के लिए emulator core और frontend के बीच इंटरफ़ेस को सरल रखा गया
  • frontend और core के बीच मुख्य इंटरफ़ेस दो arrays और दो functions से बना है
    • framebuffer: 160×144 shades का array, जिसमें सफेद, हल्का, गहरा और काला रंग रखा जाता है
    • audiobuffer: 32768Hz sample rate वाला ring audio buffer, जिसमें read·write heads होते हैं
    • stepEmulator(): एक CPU instruction चलाता है और लगे हुए cycles की संख्या लौटाता है
    • getJoypadState(state): frontend द्वारा emulator को joypad state देने वाला callback, जिसे आमतौर पर हर frame में एक बार बुलाया जाता है
  • Fame Boy को असली Game Boy hardware के समान तरीके से मॉडल किया गया है
    • CPU असली Game Boy के Sharp LR35902 की तरह memory map के बाहर के hardware को नहीं जानता, और interrupt signals के लिए केवल IoController का उपयोग करता है
    • CPU codebase का सबसे F#-जैसा हिस्सा है और इसमें functional domain modeling का काफी उपयोग हुआ है
    • Memory.fs Game Boy की अधिकांश RAM रखता है और CPU, IO Controller, cartridge के बीच memory map और bus की भूमिका निभाता है
    • performance के लिए Memory.fs PPU जैसे modules के साथ VRAM·OAM RAM array references साझा करता है
    • IoController.fs को Memory.fs में logic बहुत बढ़ जाने के बाद अलग किया गया; असली Game Boy hardware में कोई एकल IO controller नहीं होता, लेकिन hardware registers की handling एक जगह रखने से हर component का interface सरल और सुरक्षित बनता है
  • Emulator.fs का stepper function पूरे emulator को जोड़ने वाले glue की तरह काम करता है और हर component के step execution functions को संयोजित करता है
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
  • असली hardware components केंद्रीय master oscillator के आधार पर parallel चलते हैं, लेकिन Fame Boy single-threaded है, इसलिए components को क्रम से चलाना पड़ता है
  • stepper function execution को केंद्रीकृत करके सुनिश्चित करता है कि सभी components synchronized रहें
  • playable speed पाने के लिए इसे प्रति सेकंड सही संख्या में cycles पर चलना होता है, और 60FPS पर हर frame में लगभग 17500 CPU cycles चाहिए होते हैं
  • frontend sound चालू होने पर audio sampling rate के अनुसार emulator चलाता है, और mute होने पर framerate के अनुसार चलाता है

CPU implementation और F#

  • CHIP-8 emulator को mutable members के बिना पूरी तरह pure style में लिखा गया था और arrays भी copy किए जाते थे, लेकिन Fame Boy में mutable state का सक्रिय रूप से उपयोग किया गया

  • Game Boy, CHIP-8 की तुलना में बहुत तेज़ है, इसलिए 16KB से अधिक memory को हर सेकंड लाखों बार copy करना व्यावहारिक नहीं है

  • Fame Boy में F# का उपयोग इसलिए किया गया क्योंकि F# का समृद्ध type system CPU instructions की modeling के लिए बहुत उपयुक्त है, और लेखक को F# स्वयं भी पसंद है

  • domain modeling

    • CPU implementation के दौरान Gekkio’s Complete Technical Reference का पालन किया गया, और उसी दस्तावेज़ की तरह instructions को समूहों में बाँटा गया
    • शुरुआत में Instructions.fs में instruction प्रकारों के अनुसार discriminated unions रखे गए
    • type LoadInstr = | Load8Immediate of uint8 | Load8Direct of Register | Load8Indirect // ... other load instructions
  • type ArithmeticInstr = | IncrementDirect of uint8 | IncrementIndirect of Register // ... other arithmetic instructions

    • कई instructions operand location की एक साझा अवधारणा साझा करते हैं

      • instruction के ठीक बाद memory में मौजूद byte value को पढ़ने वाला immediate
      • CPU register को पढ़ने और लिखने वाला direct
      • HL CPU register जिस memory location की ओर इशारा करता है, उसे पढ़ने और लिखने वाला indirect
    • location की अवधारणा को अलग निकालकर From और To type में बांटने से load instructions को अधिक संक्षेप में व्यक्त किया गया

    • 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

    • इस तरीके से CPU instructions को 512 opcodes से घटाकर 58 instructions तक लाया गया

    • domain को सामान्यीकृत करने पर गलत state की अनुमति देने का जोखिम होता है, लेकिन type system से इसे रोका जा सकता है

    • अगर From और To की जगह एक ही location type Loc इस्तेमाल किया जाए, तो Load(Loc.Direct D, Loc.Immediate) जैसा गलत instruction compile हो सकता है, जो register value को immediate location में store करता है

    • Game Boy hardware immediate value में write को support नहीं करता, इसलिए F# type से domain को सही तरह model करने पर यह सुनिश्चित किया जा सकता है कि illegal state system में व्यक्त ही न हो

    • एकमात्र अपवाद opcode 0x76 है

      • opcode pattern के आधार पर देखें तो इसका रूप Load(From.Indirect, To.Indirect) जैसा बनता है, यानी HL location के 8-bit value को उसी HL location में load करना
      • Fame Boy का type इसे अनुमति देता है, लेकिन असली Game Boy में यह instruction मौजूद नहीं है
      • तर्क की दृष्टि से यह NOP है और खतरनाक नहीं, और वास्तव में opcode reader 0x76 को HALT के रूप में decode करता है, इसलिए वहां तक पहुंचा नहीं जा सकता
    • F# के match expression और Option का उपयोग करने के बाद सामान्य switch statement पर लौटना भद्दा और गलती-प्रवण लगता है, इसलिए functional language आज़माने की सलाह दी जाती है

  • इसे सरल रखना

    • project का लक्ष्य सबसे बेहतरीन emulator बनाना नहीं, बल्कि computer hardware सीखना था, इसलिए दूसरे emulators के code को बहुत गहराई से नहीं देखा गया

    • CAMLBOY source में नीचे जैसा code देखकर यह बात पसंद आई कि मनचाहे flags को किसी भी क्रम में pass किया जा सकता है

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

    • F# का type system partial application को support करता है, इसलिए यह method overloading और default parameters से बचता है; इसी वजह से वही तरीका अपनाना संभव नहीं था

    • शुरुआत में इसे नीचे की तरह array और flag type pass करने वाले तरीके से implement किया गया

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

    • बाद में refactoring के दौरान Cpu/State.fs L81 में इसे नीचे जैसी pure function-आधारित implementation में बदला गया

    • 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)

    • नई functions आसानी से compose की जा सकती हैं, testable हैं, और सरल pure functions हैं

    • पुरानी implementation में values को discriminated union type तक उठाकर array में रखना पड़ता था, इसलिए वह अधिक verbose थी

    • नई functions inline हैं, heap allocation की जरूरत नहीं पड़ती, इसलिए performance भी बेहतर हुई और emulator का FPS लगभग 10% बढ़ गया

  • टेस्ट

    • शुरुआती CPU implementation में Tetris ROM चलाते हुए जब भी कोई unimplemented opcode आता था, तब उसी instruction को implement किया जाता था
    • match opcode with
    • | 0x00 -> Nop
    • | _ -> failwith "Unimplemented opcode"
    • इस तरीके में technical docs के बीच इधर-उधर भटकना पड़ता था, जिससे दोहराव भरा काम उबाऊ हो जाता था, और यह समझना भी मुश्किल था कि instruction सही implement हुआ है या नहीं
    • इन दोनों समस्याओं को हल करने के लिए unit tests जोड़े गए
    • सीखने के लिए emulator code खुद लिखा गया, लेकिन test case generation में AI का उपयोग किया गया
    • technical docs की specification को prompt में डालकर, emulator code देखे बिना specification-आधारित tests लिखवाए गए
    • जब AI tests बना रहा था, तब specification को खुद पढ़ते हुए tests pass होने तक logic implement किया गया, यानी वास्तव में test-driven development किया गया
    • tests के जरिए पहले से implement किए गए instructions में भी कुछ bugs मिले
    • tests की नियमित रूप से समीक्षा और सुधार किया गया, और उन्होंने सीखने में बाधा डालने के बजाय दिलचस्प हिस्सों पर ऊर्जा केंद्रित करने में मदद की

CPU के बाद के कॉम्पोनेंट्स

  • PPU

    • Game Boy में GPU नहीं, बल्कि PPU यानी picture processing unit होता है
    • दूसरे Game Boy emulator बनाने वाले लेख अक्सर CPU पर फोकस करते हैं और PPU को सिर्फ कुछ पैराग्राफ में समेट देते हैं, लेकिन Fame Boy में PPU को समझने में ज़्यादा समय लगा
    • CPU, From NAND to Tetris और CHIP-8 के अनुभव की वजह से स्वाभाविक लगा, लेकिन PPU स्क्रीन पर पिक्सेल चढ़ाने के चरणों का पालन करने वाले एक मैकेनिकल काम के ज़्यादा करीब था
    • शुरुआत में pixel FIFO और पूरे PPU pipeline को एक साथ समझने की कोशिश करने के बजाय, मेमोरी से tile और background map को पढ़कर, parse करके स्क्रीन पर दिखाने वाले तरीके से शुरू किया
    • इस तरीके से CPU को काम करते देख पाना संभव हुआ, और Tetris की सादगी की वजह से लगभग असली Game Boy गेम जैसा दिखने वाला परिणाम मिला
    • tile और background view से शुरू किया गया यह तरीका, असली स्क्रीन implementation से लेकर sprite data के बारीक bugs को debug करने तक, लगातार मददगार रहा
    • Fame Boy के PPU में हार्डवेयर-सटीकता की कमी काफ़ी है
      • असली Game Boy, CRT monitor की तरह FIFO queue का इस्तेमाल करके पिक्सेल को एक-एक करके स्क्रीन पर रखता है
      • Fame Boy उस लाइन की drawing अवधि शुरू होते ही पूरी scanline render कर देता है
    • यह तरीका तेज़ है, कोड को सरल रखता है, और जिन गेमों को चलाना था वे सभी चल गए, इसलिए pixel queue पर जाने की ज़रूरत महसूस नहीं हुई
    • जो गेम Game Boy हार्डवेयर को उसकी सीमा तक इस्तेमाल करते हैं और pixel queue timing का सहारा लेते हैं, वे Fame Boy पर सही से नहीं चलेंगे, लेकिन ज़्यादातर गेम हार्डवेयर का इतना आक्रामक इस्तेमाल नहीं करते, इसलिए आम तौर पर चलने की उम्मीद है
  • Joypad

    • PPU और APU के अलावा joypad पर भी काम किया गया
    • शुरुआती implementation बहुत आसान थी और tests लिखना भी सरल था
    • लेकिन बड़े refactoring के बाद यह लगभग हमेशा टूट जाता था
    • joypad hardware register को CPU और गेम दोनों पढ़ते और लिखते हैं, इसलिए उनका interaction जटिल है
    • शुरुआत में CPU को हर cycle में joypad state register में लिखने दिया गया था, लेकिन इंसान सेकंड में लाखों बार बटन नहीं बदलता, इसलिए इसे बदलकर frame में सिर्फ एक बार update किया गया
    • नतीजतन direction pad ने काम करना बंद कर दिया
    • Game Boy हार्डवेयर एक बार में सिर्फ आधे बटन पढ़ सकता है, और गेम लगभग हमेशा joypad register को बहुत कम अंतराल में दो या उससे ज़्यादा बार पढ़ते हैं और इस बात पर निर्भर रहते हैं कि उन reads के बीच register बदल जाए
    • frame में एक बार cache किया गया register उन दो reads के बीच नहीं बदलता था, इसलिए आधे बटन काम नहीं कर रहे थे
    • आखिरकार IoController को इस तरह implement किया गया कि joypad register सिर्फ CPU के पढ़ने पर ही update हो
    • इससे जुड़ी और जानकारी Pandocs के joypad दस्तावेज़ में देखी जा सकती है
  • साउंड

    • काम करने वाला emulator बनाने के बाद जब web version खेला, तो लगा कि बिना sound के यह अधूरा लगता है, इसलिए APU यानी audio processing unit जोड़ा गया
    • पता चला कि कई emulator frame rate से नहीं, बल्कि frontend audio sampling rate से emulator को चलाते हैं
    • शुरुआत में यह बात उलटी लगी, इसलिए dynamic sampling rate पर खोजबीन की और frame rate से emulator चलाने की कोशिश की
    • sound वैचारिक रूप से सबसे कठिन कॉम्पोनेंट था, और अलग-अलग sound registers और channels के व्यवहार को समझने में समय लगा
    • इस हिस्से में AI ने शिक्षक की तरह बहुत मदद की, और coding शुरू करने से पहले कई बार सवाल-जवाब हुए
    • PPU की तरह, channels को एक-एक करके पूरा करने पर काफ़ी संतोष मिला, और Tetris का संगीत धीरे-धीरे समृद्ध होता सुनकर यह भी समझ आया कि संगीत कैसे बनता है
    • CPU और PPU हर frame में ठीक X काम करते हैं और X को आसानी से निकाला जा सकता है, लेकिन APU में चुनने और tune करने के लिए कई मान थे
    • सिर्फ APU sampling rate तय करना आसान था
      • असली Game Boy APU लचीला है, इसलिए emulator अपनी पसंद का sampling rate इस्तेमाल कर सकता है
      • Fame Boy ने 32768Hz चुना
      • 1048576Hz CPU clock पर 32768Hz का मतलब 128 CPU cycles पर 1 sample है, इसलिए APU state को सिर्फ integer से पूरी तरह synchronize किया जा सकता है
      • 128, 4 से भी पूरी तरह विभाजित हो जाता है, इसलिए APU steps को 4-4 के batch में प्रोसेस करने पर भी CPU instructions के साथ alignment नहीं बिगड़ता
    • बाकी मान काफ़ी अस्थिर थे, और sound engineer न होने के कारण उन्हें बदल-बदलकर मिलाना पड़ा
    • हर frontend और हर platform की अपनी अलग समस्या थी
      • PC पर sound ठीक चलता था, लेकिन MacBook पर यह झरने जैसी आवाज़ करता था
      • MacBook की समस्या ठीक की, तो desktop PC version race condition की वजह से चलना बंद हो गया
    • dynamic sampling rate से समझदारी से समस्या सुलझाने की कोशिश छोड़कर, जब audio को emulator चलाने दिया गया, तो कई devices पर audio काफ़ी ज़्यादा स्थिर हो गया
    • audio, emulator और frontend interface का सबसे ज़्यादा leak होने वाला हिस्सा है, लेकिन बेसुरा शोर टालने के लिए सटीक synchronization ज़रूरी है

Emulator चलाने का तरीका

  • audio-आधारित और frame-आधारित ड्राइविंग के बीच का अंतर मानव perception से जुड़ा है
  • अगर audio signal टूटता है, तो signal में अचानक बदलाव की वजह से speaker बहुत ज़ोर से हिलता है और pop noise पैदा होती है
  • अगर video टूटता है, तो data समय पर नहीं पहुँचने के कारण video player एक-दो frames छोड़ देता है, लेकिन इसमें कोई भौतिक चीज़ धक्का नहीं खाती, इसलिए अनुभव में यह कम खटकता है
  • Fame Boy के अंदर audio और video डिज़ाइन के हिसाब से पूरी तरह synchronized हैं
  • लेकिन जिस कंप्यूटर पर यह चल रहा होता है, उसमें audio और video स्वतंत्र होते हैं, और कभी-कभी उनमें से कोई एक पीछे रह सकता है
  • अगर frontend audio और video में असंगति हो जाए, तो दो विकल्प होते हैं
    • frontend audio और emulator audio को synchronize किया जाए और कभी-कभी frame drop किया जाए
    • frontend video और emulator frame को synchronize किया जाए और कभी-कभी audio drop किया जाए
  • जो पक्ष चुना जाता है वही emulator को “चलाता” है, और दूसरे को जितना हो सके उतना पास रखा जाता है
  • frame rate-आधारित ड्राइविंग अपेक्षाकृत सरल है
let mutable cycles = 0

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

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

    draw ppu.framebuffer
  • sound-आधारित ड्राइविंग ज़्यादा पेचीदा है, क्योंकि Raylib और Web Audio audio processing को अलग तरह से संभालते हैं
  • सामान्य प्रवाह इस प्रकार है
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
  • मुख्य अंतर यह है कि stepEmulator अब lastFrameTime से नियंत्रित नहीं होता, बल्कि frontend audio buffer की ज़रूरत के हिसाब से चलता है
  • samplesNeeded को अलग-अलग sampling rates के हिसाब से और 60FPS बनाए रखने के लिए stepEmulator calls की संख्या निकालनी पड़ती है
  • frontend audio buffer सिर्फ खुद को भरने की चिंता करता है, इसलिए वह एक frame में stepEmulator को बहुत ज़्यादा या बहुत कम बार बुला सकता है, और इसके परिणामस्वरूप framebuffer समय पर update नहीं हो सकता
  • web frontend में URL पर ?frame-driven जोड़कर frame-आधारित version आज़माया जा सकता है
  • frame-आधारित version दृश्य रूप से ज़्यादा smooth है, लेकिन कभी-कभी audio pop आता है
  • audio-आधारित web frontend में भी mute button दबा होने पर pop सुनाई नहीं देता, इसलिए वह frame-आधारित मोड में स्विच कर देता है
  • implementation परफ़ेक्ट नहीं है, लेकिन audio pop, frame stutter से ज़्यादा बुरा प्रभाव देता है और mute अवस्था खाली-सी लगती थी, इसलिए web frontend का default audio-आधारित रखा गया
  • audio, Fame Boy के उन कुछ हिस्सों में से है जिनसे पूरी संतुष्टि नहीं है, और यह ऐसा हिस्सा है जिस पर कभी फिर लौटकर काम करना चाहूँगा

Fable के साथ वेब पर प्रकाशित करना

  • PPU कुछ हद तक काम करने लगा और डेस्कटॉप स्क्रीन पर कुछ दिखाई देने लगा, उसके बाद Fame Boy को वेब पर ले जाने की कोशिश की
  • Fable के दस्तावेज़ देखकर पैकेज इंस्टॉल किए, main loop सेट किया और style जोड़ा, और एक-दो घंटे में इसे चलाने के लिए तैयार कर लिया
  • Fable का पहला version चलाने पर स्क्रीन अजीब दिखी, और थोड़ा debugging करने के बाद ज़्यादा समय खर्च न करने के लिए Blazor के WebAssembly को आज़माया
  • Blazor भी चलाना अपने आप में आसान था, और इस बार यह सच में काम भी किया, लेकिन लगभग 8FPS पर यह लगभग खेल सकने लायक नहीं था
  • यह निश्चित नहीं था कि समस्या खुद Blazor की थी या नहीं; .NET टीम की performance guide भी follow की, लेकिन मदद नहीं मिली
  • debugging भी असुविधाजनक थी, इसलिए फिर से Fable पर लौटकर देखा कि JavaScript conversion process में क्या गड़बड़ हो रही थी
  • Fable converted JS file को source code के ठीक बगल में रखता है, और वह वास्तव में काफ़ी पढ़ने योग्य थी
  • इससे नया code समझना और browser developer tools में debugging करना आसान हो गया
  • developer tools में CPU register values अजीब दिखीं
    • Fame Boy और Game Boy के CPU register 8-bit unsigned integers हैं, इसलिए उनकी range 0–255 होनी चाहिए
    • लेकिन -15565461 जैसी values दिख रही थीं
  • Fable के दस्तावेज़ में numeric types compatibility document मिला

(non-standard) Bitwise operations for 16 bit and 8 bit integers use the underlying JavaScript 32 bit bitwise semantics. Results are not truncated as expected, and shift operands are not masked to fit the data type.

  • यह ठीक उसी व्याख्या से मेल खाता था कि 16-bit और 8-bit integers के bit operations JavaScript के 32-bit bitwise semantics का उपयोग करते हैं, और परिणाम अपेक्षित रूप से truncate नहीं होते
  • code में उन जगहों को खोजकर जहाँ 8-bit values को truncate होना चाहिए था, और संबंधित समस्याएँ ठीक करने के बाद web frontend सही से काम करने लगा
  • .NET runtime के बिना सिर्फ JS इस्तेमाल होने के कारण web bundle लगभग 100KB का है
  • इस अनोखी uint8 समस्या को छोड़ दें तो Fable इस्तेमाल करने का अनुभव काफ़ी सुखद था, और सारा source code F# में बनाए रखा जा सका

प्रदर्शन में सुधार

  • स्क्रीन पर output दिखाई देने के बाद एक साधारण FPS console log जोड़ा
  • शुरुआत में debug mode में लगभग 55–60FPS मिल रहा था, और लगता है Raylib v-sync बनाए रखने की कोशिश कर रहा था
  • v-sync बंद करने पर यह लगभग 70FPS तक गया, लेकिन jitter आ गया
  • बाद में features जुड़ते गए और performance धीरे-धीरे गिरकर 45FPS तक पहुँच गई, और v-sync बंद करने पर भी मदद नहीं मिली
  • JetBrains Rider profiler चलाने पर mapAddress एक संदिग्ध bottleneck के रूप में सामने आया
  • चूँकि लगभग हर component memory access करता है, इसलिए यह स्पष्ट हुआ कि memory access की लागत अनुमान से अधिक थी
  • समस्या वाला code memory address को discriminated union MemoryRegion में map करके फिर read और write करता था
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
  • CPU domain modeling से मिला flow memory पर भी बढ़ाने की कोशिश की गई, और नतीजतन हर memory read/write पर MemoryRegion object बनता और map होता था
  • इस तरीके में हर सेकंड लाखों objects heap पर allocate होते थे, और JIT compiler को संभालने के लिए branches भी बढ़ जाती थीं
  • discriminated union और mapping function को हटाकर arrays को सीधे access करने वाले एक बदलाव से FPS दोगुना हो गया
  • बाद के benchmarks में लगा कि performance improvement का अधिकांश हिस्सा branches और localized call sites पर JIT optimization से आया
  • MemoryRegion को struct DU में बदलकर stack पर allocate होने देने पर भी performance सिर्फ लगभग 15% सुधरी; बाकी 85% सुधार DU और mapping function हटाने से आया
  • इसके बाद भी कई बार struct DU पर जाना पड़ा या ऐसे approaches अपनाने पड़े जो F#-friendly नहीं थे
  • PPU implementation के समय से ही optimization की ज़रूरत पड़ने लगी, और कुछ हद तक idiomatic F# छोड़ना पड़ा
  • profiler को नियमित रूप से देखते हुए performance को धीरे-धीरे सुधारकर लगभग 120FPS तक ले जाया गया
  • सबसे बड़ा FPS improvement debug build बंद करने से मिला, और release mode में यह लगभग 1000FPS तक पहुँच गया
  • अंत तक performance की नियमित निगरानी और tuning की जाती रही

बेंचमार्क

  • सिर्फ console FPS numbers देखना अच्छी performance measurement नहीं लगा, इसलिए project के बीच में desktop performance मापने के लिए BenchmarkDotNet project जोड़ा गया
  • इसके बाद Node.js का उपयोग करने वाला एक साधारण web benchmarker बनाया गया, ताकि web browser performance का भी लगभग वैसा ही अनुमान लगाया जा सके
  • benchmarks में वास्तविक scenarios टेस्ट करने के लिए ये demo ROM इस्तेमाल किए गए
    • Flag: बिना sound वाला छोटा loop
    • Roboto: बहुत से visual effects और sound का उपयोग करने वाला 1 मिनट से ज़्यादा चलने वाला demo
    • Merken: Roboto जैसा, लेकिन memory को टेस्ट करने के लिए memory banking ROM का उपयोग करता है
  • Ryzen 9 7900 Windows PC और M4 MacBook Air पर desktop FPS performance इस प्रकार थी
CPU Flag Roboto Merken
Ryzen 9 7900 1785 1943 1422
Apple M4 1907 2508 1700
  • web FPS performance इस प्रकार थी
CPU Flag Roboto Merken
Ryzen 9 7900 646 883 892
Apple M4 779 976 972
  • Fame Boy दोनों platforms पर काफ़ी ठीक से चलता है
  • अपेक्षा के विपरीत, APU यानी sound का emulator performance पर PPU की तुलना में अधिक प्रभाव पड़ता है
  • PPU बंद करने पर desktop performance लगभग 250FPS बढ़ती है, लेकिन APU बंद करने पर लगभग 500FPS बढ़ती है

AI का उपयोग

  • यह मानते हुए कि सीखने वाले प्रोजेक्ट में भी AI के प्रभाव से पूरी तरह बचना संभव नहीं है, AI के उपयोग के तरीके को पारदर्शी रूप से दर्ज किया गया
  • पूरे प्रोसेस में AI का इस्तेमाल मुख्य रूप से एक सहायक टूल के रूप में किया गया
    • code review का अनुरोध
    • आइडिया की समीक्षा के लिए बातचीत का साथी
    • संक्षिप्त technical documentation की व्याख्या
  • AI द्वारा लिखे गए कोड को यथासंभव कम रखने की कोशिश की गई
  • ऐसा परिणाम बनाना था जिसे लोगों को दिखाया जा सके और जिस पर गर्व हो, इसलिए सिर्फ prompts साझा करने के बजाय सीधे खुद लिखे गए कोड के रूप में छोड़ना चाहा
  • performance improvement PR

    • प्रोजेक्ट के अंतिम हिस्से में repository को CLI में देकर performance improvements खोजने को कहा गया
    • कुछ ideas दिए गए और इसके अलावा जो चाहे वह भी आज़माने दिया गया, और कुछ benchmarks में performance दोगुने से भी अधिक बढ़ गई
    • विस्तृत जानकारी PR में है
    • हालांकि, bugs भी आ गए थे और उन्हें खुद ढूँढकर ठीक करना पड़ा
    • बड़े performance improvements में से एक, “mode/LY स्विच होने पर ही STAT update करना”, कुछ games और demos को तोड़ रहा था जो अधिक बार updates होने पर निर्भर थे, और इसे सुधार commit से ठीक किया गया
  • “timer winter”

    • Git history में एक बड़ा खाली अंतराल है, और इस अवधि को “timer winter” कहा गया

    • emulator पर काम न करने की वजह नहीं थी, बल्कि Tetris की copyright screen को पार न कर पाने वाले bug ने रोक रखा था

    • 20 घंटे से अधिक debugging की, emu-dev Discord में खोजा, tests बनाए, और शुरुआती AI models को भी समस्या दी, लेकिन समाधान नहीं मिला

    • कुछ हफ्ते का ब्रेक लेने के बाद Claude Opus आज़माया, और उसने कुछ ही मिनटों में समस्या पकड़ ली

    • समस्या यह थी कि timer हर instruction पर सिर्फ एक बार tick हो रहा था, instruction द्वारा consumed cycles की संख्या के अनुसार tick नहीं कर रहा था

    • 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

    • CPU cycles 1 से 6 तक बदल सकते हैं, इसलिए पुराने implementation में timer औसतन वास्तविकता की तुलना में 2~3 गुना धीमा चल रहा था

    • copyright screen बस ज़्यादा देर तक बनी रहती थी, और समस्या यह थी कि 1~2 मिनट तक इंतज़ार करके नहीं देखा गया था

    • मुख्य लेखन का अधिकांश हिस्सा खुद लिखा गया

सीखी गई बातें और निष्कर्ष

  • मुख्य लक्ष्य यह सीखना था कि कंप्यूटर कैसे काम करते हैं, और उस लक्ष्य के लिहाज़ से यह बड़ी सफलता थी
  • यह काम बहुत मज़ेदार था, और दफ़्तर के बाद “आज सिर्फ एक feature” कहकर शुरू करने के बाद रात 2 बजे तक “बस एक bug और ठीक कर लूँ” दोहराते हुए गहराई से डूब जाना आम बात थी
  • Game Boy Advance को भी आज़माने का विचार आया, लेकिन specs देखने पर लगा कि hardware की समझ में बढ़ोतरी लगभग 20% होगी, जबकि मेहनत करीब 3 गुना लगेगी
  • Game Boy सीखने के लिए संतुलित विकल्प था, और फिलहाल यहीं रुकना ठीक लग सकता है
  • यह पक्का नहीं है कि इससे मैं बेहतर software engineer बना या नहीं, लेकिन इतना तय है कि रोज़ इस्तेमाल होने वाले tools के बारे में थोड़ी और समझ बनी है
  • सवाल या राय ईमेल पर भेजे जा सकते हैं

अभी कोई टिप्पणी नहीं है.

अभी कोई टिप्पणी नहीं है.