1 Punkte von GN⁺ 2 시간 전 | Noch keine Kommentare. | Auf WhatsApp teilen
  • Fame Boy ist ein in F# implementierter Game-Boy-Emulator, der inklusive Sound auf Desktop und im Web läuft; Spielen im Browser und der GitHub-Quellcode sind öffentlich verfügbar.
  • Der Emulator-Kern und das Frontend wurden so vereinfacht, dass sie nur framebuffer, audiobuffer, stepEmulator() und getJoypadState(state) gemeinsam nutzen; der stepper führt CPU, Timer, Serial, APU und PPU nacheinander aus, um die Synchronisation in einem Single-Thread sicherzustellen.
  • Die CPU-Implementierung modelliert mit F#-Discriminated-Unions und match 512 Opcodes als 58 Instruktionen; mit den Typen From und To wurde das Design so angelegt, dass unzulässige Zustände wie Schreiben auf Immediate-Werte auf Typebene verhindert werden.
  • Die PPU rendert statt mit dem Pixel-FIFO des echten Game Boy auf Scanline-Basis, was schneller und einfacher ist; einige Spiele, die das Timing der Pixel-Queue ausnutzen, könnten dadurch jedoch nicht korrekt laufen.
  • Das Web-Porting wurde mit Fable gelöst; nachdem Probleme mit 8-Bit- und 16-Bit-Bitoperationen behoben wurden, die der 32-Bit-Semantik von JavaScript folgen, lief der Emulator mit einem JS-Bundle von etwa 100 KB, und durch Performance-Optimierung sowie Release-Builds wurden auf dem Desktop bis zu etwa 1000 FPS erreicht.

Projekthintergrund und Ziele

  • Obwohl der Autor seit mehr als 8 Jahren als Software Engineer arbeitet, hatte er das Gefühl, nicht zu verstehen, wie Computer tatsächlich funktionieren, und beschloss daher, durch den Bau eines eigenen Emulators zu lernen.
  • Da er in seiner Kindheit viel Pokémon gespielt hatte, fiel die Wahl auf den Game Boy: echte Hardware, relativ überschaubar im Umfang und mit starkem persönlichem Bezug.
  • Bevor er direkt in den Game Boy einstieg, absolvierte er From NAND to Tetris, um grundlegende Computerbausteine wie Register, Speicher und ALU zu verstehen.
  • Um sich mit dem Bau von Emulatoren vertraut zu machen, implementierte er zunächst den CHIP-8-Emulator Fip-8 in F#.
  • Nach mehreren Monaten Arbeit stellte er den Game-Boy-Emulator Fame Boy fertig, der inklusive Sound auf Desktop und im Web läuft.
  • Er kann im Browser gespielt werden, und der Quellcode ist auf GitHub veröffentlicht.

Emulator-Architektur

  • Damit der Emulator sowohl auf dem Desktop als auch im Web funktioniert, wurde die Schnittstelle zwischen Emulator-Kern und Frontend bewusst einfach gehalten.
  • Die zentrale Schnittstelle zwischen Frontend und Kern besteht aus zwei Arrays und zwei Funktionen.
    • framebuffer: ein 160×144-Graustufen-Array mit Weiß, hellen, dunklen und schwarzen Pixeln
    • audiobuffer: ein Ring-Audiobuffer mit 32768 Hz Samplerate sowie Lese- und Schreibkopf
    • stepEmulator(): führt eine CPU-Instruktion aus und gibt die dafür benötigte Zahl an Zyklen zurück
    • getJoypadState(state): ein Callback, mit dem das Frontend den Joypad-Status an den Emulator übergibt; wird normalerweise einmal pro Frame aufgerufen
  • Fame Boy ist ähnlich zur echten Game-Boy-Hardware modelliert.
    • Die CPU kennt wie der echte Sharp LR35902 des Game Boy außer der Memory-Map keine Hardware und nutzt nur den IoController für Interrupt-Signale.
    • Die CPU ist der F#-typischste Teil der Codebasis und nutzt stark funktionale Domain-Modellierung.
    • Memory.fs hält den Großteil des RAM des Game Boy und übernimmt die Rolle von Memory-Map und Bus zwischen CPU, IO Controller und Cartridge.
    • Aus Performance-Gründen teilt Memory.fs Referenzen auf VRAM- und OAM-RAM-Arrays mit der PPU.
    • IoController.fs wurde abgespalten, als in Memory.fs zu viel Logik landete; obwohl die echte Game-Boy-Hardware keinen einzelnen IO-Controller besitzt, bündelt diese Datei die Verarbeitung von Hardware-Registern an einer Stelle und macht die Schnittstellen der einzelnen Komponenten einfacher und sicherer.
  • Die stepper-Funktion in Emulator.fs fungiert als Klebstoff, der den gesamten Emulator zusammenhält, indem sie die Schrittfunktionen der einzelnen Komponenten kombiniert.
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
  • Echte Hardware-Komponenten laufen parallel auf Basis eines zentralen Master-Oszillators, aber da Fame Boy Single-Threaded ist, müssen die Komponenten nacheinander ausgeführt werden.
  • Die stepper-Funktion zentralisiert die Ausführung und sorgt dafür, dass alle Komponenten synchron bleiben.
  • Um eine spielbare Geschwindigkeit zu erreichen, muss der Emulator mit der korrekten Anzahl an Zyklen pro Sekunde laufen; bei 60 FPS sind pro Frame etwa 17.500 CPU-Zyklen nötig.
  • Das Frontend treibt den Emulator bei aktiviertem Sound mit der Audio-Samplerate an und im stummgeschalteten Zustand mit der Framerate.

CPU-Implementierung und F#

  • Der CHIP-8-Emulator wurde ohne mutable-Member rein funktional geschrieben und kopierte sogar Arrays, aber Fame Boy nutzt veränderbaren Zustand aktiv.

  • Der Game Boy ist deutlich schneller als CHIP-8, und mehr als 16 KB Speicher millionenfach pro Sekunde zu kopieren, ist kein sinnvoller Ansatz.

  • F# wurde für Fame Boy gewählt, weil sich das ausdrucksstarke Typsystem von F# gut für die Modellierung von CPU-Instruktionen eignet und der Autor F# einfach mag.

  • Domain-Modellierung

    • Bei der CPU-Implementierung orientierte sich der Autor an Gekkio’s Complete Technical Reference und gruppierte die Instruktionen wie dort beschrieben.
    • Anfangs lagen in Instructions.fs Discriminated Unions für die einzelnen Instruktionsarten.
    • type LoadInstr = | Load8Immediate of uint8 | Load8Direct of Register | Load8Indirect // ... other load instructions
  • type ArithmeticInstr = | IncrementDirect of uint8 | IncrementIndirect of Register // ... other arithmetic instructions

    • Mehrere Instruktionen teilen das gemeinsame Konzept einer Operandenposition

      • immediate, das den Byte-Wert im Speicher direkt nach der Instruktion liest
      • direct, das CPU-Register liest und schreibt
      • indirect, das die Speicherposition liest und schreibt, auf die das CPU-Register HL zeigt
    • Durch das Herauslösen des Positionskonzepts und die Aufteilung in die Typen From und To ließen sich Ladeinstruktionen kompakter ausdrücken

    • 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

    • Auf diese Weise wurden die CPU-Instruktionen von 512 Opcodes auf 58 Instruktionen reduziert

    • Wenn man die Domäne verallgemeinert, besteht das Risiko, ungültige Zustände zuzulassen, aber das lässt sich mit dem Typsystem verhindern

    • Wenn man statt From und To einen einzelnen Positionstyp Loc verwendet, kann eine ungültige Instruktion wie Load(Loc.Direct D, Loc.Immediate) kompiliert werden, die einen Registerwert an einer Immediate-Position speichert

    • Die Game-Boy-Hardware unterstützt kein Schreiben auf Immediate-Werte, daher kann eine korrekte Modellierung der Domäne mit F#-Typen garantieren, dass illegale Zustände im System nicht darstellbar sind

    • Es gibt genau eine Ausnahme: Opcode 0x76

      • Betrachtet man nur das Opcode-Muster, ergibt sich etwas wie Load(From.Indirect, To.Indirect), also das Laden des 8-Bit-Werts an der HL-Position in dieselbe HL-Position
      • Der Typ von Fame Boy erlaubt das, aber auf dem echten Game Boy gibt es diese Instruktion nicht
      • Logisch ist es ein NOP und ungefährlich; tatsächlich kann es nicht erreicht werden, weil der Opcode-Reader 0x76 als HALT dekodiert
    • Nachdem man in F# match und Option verwendet hat, wirkt die Rückkehr zu einer normalen switch-Anweisung grob und fehleranfällig, daher wird empfohlen, funktionale Sprachen auszuprobieren

  • Einfach halten

    • Da das Ziel des Projekts nicht der beste Emulator, sondern das Lernen über Computerhardware war, wurde der Code anderer Emulatoren nicht tiefgehend untersucht

    • Im Source-Code von CAMLBOY fiel folgender Code auf, und es gefiel, dass sich genau die gewünschten Flags in beliebiger Reihenfolge übergeben lassen

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

    • F# vermeidet wegen seines Typsystems mit Unterstützung für partielle Anwendung Methodenüberladung und Default-Parameter, daher ließ sich das nicht auf dieselbe Weise umsetzen

    • Anfangs wurde es so implementiert, dass ein Array und ein Flag-Typ übergeben wurden

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

    • Später wurde es im Zuge eines Refactorings in Cpu/State.fs L81 auf folgende Implementierung mit reinen Funktionen umgestellt

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

    • Die neuen Funktionen lassen sich leicht kombinieren und testen und sind einfache reine Funktionen

    • Die frühere Implementierung war ausführlicher, weil Werte in einen diskriminierten Union-Typ gehoben und in ein Array gepackt werden mussten

    • Die neuen Funktionen sind inline, benötigen keine Heap-Allokation und waren auch in der Performance besser; sie erhöhten die FPS des Emulators um etwa 10 %

  • Tests

    • Die anfängliche CPU-Implementierung entstand, indem beim Ausführen der Tetris-ROM jeweils die Instruktion implementiert wurde, sobald ein nicht implementierter Opcode erreicht wurde
    • match opcode with
    • | 0x00 -> Nop
    • | _ -> failwith "Unimplemented opcode"
    • Dieser Ansatz war auf Dauer ermüdend, weil man ständig zufällig zwischen technischen Dokumentationen hin- und herspringen musste, und es war schwer zu erkennen, ob eine Instruktion korrekt implementiert war
    • Um beide Probleme zu lösen, wurden Unit-Tests eingeführt
    • Der Emulator-Code wurde zum Lernen selbst geschrieben, aber für die Generierung der Testfälle wurde KI genutzt
    • Die Spezifikation aus den technischen Dokumenten wurde in den Prompt gegeben, und es wurden spezifikationsbasierte Tests schreiben lassen, ohne dass der Emulator-Code gesehen wurde
    • Während die KI die Tests erzeugte, wurde die Spezifikation selbst gelesen und die Logik so lange implementiert, bis die Tests bestanden, also echte testgetriebene Entwicklung
    • Über die Tests wurden auch einige Bugs in bereits implementierten Instruktionen gefunden
    • Die Tests wurden regelmäßig überprüft und verbessert; sie störten das Lernen nicht, sondern halfen dabei, die Energie auf die interessanten Teile zu konzentrieren

Komponenten nach der CPU

  • PPU

    • Der Game Boy hat keine GPU, sondern eine PPU, also eine Picture Processing Unit
    • Viele andere Artikel über den Bau von Game-Boy-Emulatoren konzentrieren sich auf die CPU und behandeln die PPU nur in ein paar Absätzen, aber bei Fame Boy dauerte das Verständnis der PPU deutlich länger
    • Die CPU fühlte sich dank From NAND to Tetris und der Erfahrung mit CHIP-8 natürlich an, aber die PPU war eher eine mechanische Arbeit, die den Schritten folgt, mit denen Pixel auf den Bildschirm gebracht werden
    • Statt anfangs zu versuchen, die Pixel-FIFO und die gesamte PPU-Pipeline auf einmal zu verstehen, begann ich damit, Tiles und Background-Maps aus dem Speicher zu lesen, zu parsen und auf dem Bildschirm anzuzeigen
    • So konnte ich sehen, wie die CPU arbeitet, und dank der Einfachheit von Tetris bekam ich ein Ergebnis, das fast wie ein echtes Game-Boy-Spiel aussah
    • Dieser Ansatz, mit Tiles und der Hintergrundansicht zu beginnen, half durchgehend weiter – von der eigentlichen Bildschirmausgabe bis zum Debugging feiner Bugs in den Sprite-Daten
    • Die PPU von Fame Boy ist hardwareseitig stark ungenau
      • Ein echter Game Boy verwendet wie ein CRT-Monitor eine FIFO-Queue, um Pixel einzeln auf den Bildschirm zu setzen
      • Fame Boy rendert stattdessen die gesamte Scanline zu Beginn der Zeichenphase dieser Zeile
    • Dieser Ansatz ist schneller und der Code ist einfacher, und da alle Spiele, die ich spielen wollte, funktionierten, sah ich keinen Bedarf, auf eine Pixel-Queue umzusteigen
    • Spiele, die die Game-Boy-Hardware bis an ihre Grenzen ausreizen und das Timing der Pixel-Queue ausnutzen, funktionieren in Fame Boy nicht korrekt, aber die meisten Spiele verwenden die Hardware nicht so aggressiv und dürften daher im Großen und Ganzen laufen
  • Joypad

    • Neben PPU und APU wurde auch das Joypad behandelt
    • Die erste Implementierung war sehr einfach, und auch Tests dafür zu schreiben war unkompliziert
    • Nach größeren Refactorings ging es aber fast immer kaputt
    • Das Hardware-Register des Joypads wird sowohl von der CPU als auch vom Spiel gelesen und beschrieben, wodurch die Interaktion komplex ist
    • Anfangs ließ ich die CPU in jedem Zyklus den Joypad-Zustand ins Register schreiben, aber da ein Mensch Tasten nicht millionenfach pro Sekunde ändert, stellte ich auf ein Update nur einmal pro Frame um
    • Das führte dazu, dass das Steuerkreuz nicht mehr funktionierte
    • Die Game-Boy-Hardware kann immer nur die Hälfte der Tasten gleichzeitig lesen, und Spiele lesen das Joypad-Register fast immer zwei- oder mehrmals in kurzen Abständen und verlassen sich darauf, dass sich das Register zwischen diesen Lesevorgängen ändert
    • Ein nur einmal pro Frame gecachtes Register ändert sich zwischen zwei Lesevorgängen nicht, weshalb die Hälfte der Tasten nicht funktionierte
    • Am Ende implementierte ich es so, dass der IoController das Joypad-Register nur dann aktualisiert, wenn die CPU es liest
    • Mehr dazu findet sich in der Joypad-Dokumentation von Pandocs
  • Sound

    • Nachdem ich einen funktionierenden Emulator gebaut hatte, spielte ich die Web-Version und fand, dass sie ohne Sound leer wirkte, also fügte ich die APU hinzu, also die Audio Processing Unit
    • Dabei stellte ich fest, dass viele Emulatoren nicht von der Framerate, sondern von der Audio-Sampling-Rate des Frontends getaktet werden
    • Das kam mir zunächst verkehrt herum vor, also untersuchte ich dynamische Sampling-Raten und versuchte, eine Implementierung zu bauen, bei der die Framerate den Emulator antreibt
    • Sound war konzeptionell die schwierigste Komponente, und es dauerte, das Verhalten der verschiedenen Sound-Register und Kanäle zu verstehen
    • In diesem Teil war KI als Lehrkraft eine große Hilfe, und ich führte vor dem Codieren mehrere Fragerunden und Antworten
    • Ähnlich wie bei der PPU war es sehr befriedigend, die Kanäle einen nach dem anderen fertigzustellen, und beim Hören, wie die Tetris-Musik allmählich voller wurde, verstand ich auch besser, wie Musik zusammengesetzt ist
    • CPU und PPU haben die Form, pro Frame genau X Arbeitsschritte auszuführen, und X lässt sich leicht berechnen, aber bei der APU gab es viele Werte, die gewählt und abgestimmt werden mussten
    • Nur die Sampling-Rate der APU ließ sich leicht festlegen
      • Die echte Game-Boy-APU ist flexibel, daher kann der Emulator jede gewünschte Sampling-Rate verwenden
      • Fame Boy wählte 32768Hz
      • Bei einem CPU-Takt von 1048576Hz entspricht 32768Hz genau 1 Sample pro 128 CPU-Zyklen, sodass der APU-Zustand allein mit Ganzzahlen perfekt synchronisiert werden kann
      • 128 ist außerdem durch 4 teilbar, sodass APU-Schritte auch in Viererblöcken verarbeitet werden können, ohne die Ausrichtung zu CPU-Instruktionen zu verlieren
    • Andere Werte waren deutlich instabiler, und da ich kein Sound Engineer bin, musste ich sie durch Ausprobieren abstimmen
    • Jedes Frontend und jede Plattform brachte eigene Probleme mit sich
      • Auf dem PC funktionierte der Sound gut, auf dem MacBook klang er jedoch wie ein Wasserfall
      • Nachdem ich das MacBook-Problem behoben hatte, startete die Desktop-PC-Version wegen einer Race Condition nicht mehr
    • Ich gab den Versuch auf, das elegant mit dynamischer Sampling-Rate zu lösen, und als ich stattdessen den Audio-Output den Emulator antreiben ließ, wurde der Ton auf verschiedenen Geräten deutlich stabiler
    • Audio ist die undichteste Stelle in der Schnittstelle zwischen Emulator und Frontend, braucht aber präzise Synchronisation, um Dissonanzen zu vermeiden

Wie der Emulator angetrieben wird

  • Der Unterschied zwischen audio-basiertem und frame-basiertem Betrieb hängt mit der menschlichen Wahrnehmung zusammen
  • Wenn ein Audiosignal aussetzt, bewegen sich Lautsprecher wegen der abrupten Signaländerung stark, wodurch hörbare Pop-Geräusche entstehen
  • Wenn Video aussetzt, überspringt der Videoplayer ein oder zwei Frames, weil die Daten nicht rechtzeitig kommen, aber da nichts Physisches angestoßen wird, ist das subjektiv weniger störend
  • Innerhalb von Fame Boy sind Audio und Video per Design perfekt synchron
  • Audio und Video auf dem laufenden Computer sind jedoch unabhängig voneinander, und gelegentlich kann eines von beiden zurückfallen
  • Wenn Frontend-Audio und -Video auseinanderlaufen, gibt es zwei Möglichkeiten
    • Frontend-Audio und Emulator-Audio synchronisieren und gelegentlich Frames droppen
    • Frontend-Video und Emulator-Frames synchronisieren und gelegentlich Audio droppen
  • Die gewählte Seite „treibt“ den Emulator an, die andere wird so nah wie möglich daran gehalten
  • Framerate-basierter Betrieb ist vergleichsweise einfach
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-basierter Betrieb ist kniffliger, weil sich die Audioverarbeitung von Raylib und Web Audio unterscheidet
  • Der allgemeine Ablauf sieht so aus
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
  • Der entscheidende Unterschied ist, dass stepEmulator nicht mehr über lastFrameTime gesteuert wird, sondern vom Bedarf des Frontend-Audio-Buffers
  • samplesNeeded muss die Anzahl der Aufrufe von stepEmulator so berechnen, dass unterschiedliche Sampling-Raten berücksichtigt werden und dennoch 60FPS erreicht werden können
  • Der Frontend-Audio-Buffer kümmert sich nur darum, sich selbst zu füllen, daher kann stepEmulator pro Frame zu oft oder zu selten aufgerufen werden, wodurch framebuffer möglicherweise nicht rechtzeitig aktualisiert wird
  • Im Web-Frontend kann man die frame-basierte Version testen, indem man der URL ?frame-driven hinzufügt
  • Die frame-basierte Version wirkt visuell flüssiger, erzeugt aber gelegentlich Audio-Pops
  • Auch das audio-basierte Web-Frontend schaltet auf frame-basiert um, wenn der Mute-Button gedrückt wird, weil dann keine Pops hörbar sind
  • Die Implementierung ist nicht perfekt, aber weil Audio-Pops einen schlechteren Eindruck hinterlassen als Frame-Drops und ein stummgeschalteter Zustand leer wirkte, wurde das Web-Frontend standardmäßig auf audio-basiert gesetzt
  • Audio ist einer der wenigen Bereiche von Fame Boy, mit denen ich nicht zufrieden bin, und ein Teil, den ich irgendwann noch einmal überarbeiten möchte

Mit Fable ins Web bringen

  • Nachdem die PPU einigermaßen funktionierte und auf dem Desktop-Bildschirm überhaupt etwas zu sehen war, wollte ich Fame Boy ins Web portieren
  • Ich sah mir die Fable-Dokumentation an, installierte die Pakete, richtete die Main Loop ein und fügte Styles hinzu; nach ein bis zwei Stunden war alles startklar
  • Die erste Fable-Version zeigte den Bildschirm fehlerhaft an, und nachdem ich etwas debuggt hatte, probierte ich, um nicht zu viel Zeit zu verlieren, WebAssembly von Blazor aus
  • Auch Blazor ließ sich leicht starten und funktionierte diesmal tatsächlich, lief aber mit etwa 8 FPS und war damit praktisch unspielbar
  • Ob das wirklich ein Problem von Blazor selbst war, ist nicht sicher; ich folgte auch dem Performance-Leitfaden des .NET-Teams, aber das half nicht
  • Da auch das Debugging unbequem war, ging ich zurück zu Fable und prüfte, was bei der Umwandlung nach JavaScript schiefgelaufen war
  • Fable legt die erzeugten JS-Dateien direkt neben den Quellcode, und sie waren tatsächlich ziemlich gut lesbar
  • Dadurch war es leicht, den neuen Code zu verstehen und in den Browser-Developer-Tools zu debuggen
  • In den Developer-Tools fiel mir auf, dass die Werte der CPU-Register seltsam waren
    • Die CPU-Register von Fame Boy und dem Game Boy sind 8-Bit-Unsigned-Integer, ihr Bereich sollte also 0–255 sein
    • Stattdessen tauchten Werte wie -15565461 auf
  • In der Fable-Dokumentation fand ich das Dokument zur Kompatibilität numerischer Typen

(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.

  • Das passte genau zu der Erklärung, dass Bit-Operationen auf 16-Bit- und 8-Bit-Integern die 32-Bit-Bitoperator-Semantik von JavaScript verwenden und die Ergebnisse nicht wie erwartet abgeschnitten werden
  • Nachdem ich im Code die Stellen gefunden hatte, an denen 8-Bit-Werte abgeschnitten werden mussten, und die entsprechenden Probleme behoben hatte, funktionierte das Web-Frontend korrekt
  • Da nur JS und keine .NET-Runtime verwendet wird, ist das Web-Bundle nur etwa 100 KB groß
  • Abgesehen von dem merkwürdigen uint8-Problem war die Nutzung von Fable ziemlich angenehm, und ich konnte den gesamten Quellcode in F# behalten

Performance verbessern

  • Sobald auf dem Bildschirm Ergebnisse zu sehen waren, fügte ich ein einfaches FPS-Logging in der Konsole hinzu
  • Anfangs lag die Rate im Debug-Modus bei etwa 55–60 FPS, was offenbar daran lag, dass Raylib v-sync einzuhalten versuchte
  • Nachdem ich v-sync deaktiviert hatte, stieg der Wert auf etwa 70 FPS, allerdings mit Jitter
  • Als danach weitere Funktionen hinzukamen, sank die Performance nach und nach auf 45 FPS, und auch das Abschalten von v-sync half nicht
  • Als ich den Profiler von JetBrains Rider laufen ließ, fiel mapAddress als verdächtiger Flaschenhals auf
  • Da fast alle Komponenten auf den Speicher zugreifen, stellte sich heraus, dass die Kosten für Speicherzugriffe größer waren als erwartet
  • Problematisch war Code, der Speicheradressen auf die diskriminierte Union MemoryRegion abbildete und dann darüber las und schrieb
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
  • Ich hatte versucht, den aus der Modellierung der CPU gewonnenen Ansatz auch auf den Speicher auszuweiten; das führte dazu, dass bei jedem Lese- und Schreibzugriff auf den Speicher ein MemoryRegion-Objekt erzeugt und zugeordnet wurde
  • Dadurch wurden pro Sekunde Millionen von Objekten auf dem Heap allokiert, und zugleich nahm die Zahl der Verzweigungen zu, die der JIT-Compiler verarbeiten musste
  • Mit einer einzigen Änderung, bei der ich die diskriminierte Union und die Mapping-Funktion entfernte und direkt auf Arrays zugriff, verdoppelte sich die FPS-Zahl
  • Spätere Benchmarks deuteten darauf hin, dass der Großteil der Performance-Verbesserung von JIT-Optimierungen bei Verzweigungen und lokalisierten Call Sites kam
  • Selbst wenn MemoryRegion in eine Struct-DU umgewandelt wurde, sodass sie auf dem Stack allokiert wurde, verbesserte sich die Performance nur um etwa 15 %; die restlichen 85 % kamen durch das Entfernen der DU und der Mapping-Funktion
  • Danach gab es weitere Fälle, in denen ich auf Struct-DUs umstieg oder Ansätze wählte, die nicht besonders F#-typisch waren
  • Ab dem Zeitpunkt der PPU-Implementierung wurde Optimierung notwendig, und ich musste teilweise auf idiomatisches F# verzichten
  • Ich beobachtete den Profiler regelmäßig und verbesserte die Performance schrittweise bis auf etwa 120 FPS
  • Der größte FPS-Gewinn kam allerdings dadurch, den Debug-Build abzuschalten; im Release-Modus stieg die Rate auf etwa 1000 FPS
  • Bis zum Ende überwachte und justierte ich die Performance regelmäßig

Benchmarks

  • Da ich reine FPS-Zahlen aus der Konsole nicht für eine gute Performance-Messung hielt, fügte ich mitten im Projekt ein BenchmarkDotNet-Projekt hinzu, um die Desktop-Performance zu messen
  • Danach baute ich einen einfachen Web-Benchmark mit Node.js, um die Browser-Performance auf ähnliche Weise abzuschätzen
  • In den Benchmarks wurden die folgenden Demo-ROMs verwendet, um realistische Szenarien zu testen
    • Flag: eine kurze Schleife ohne Sound
    • Roboto: eine mehr als eine Minute laufende Demo mit vielen visuellen Effekten und Sound
    • Merken: ähnlich wie Roboto, verwendet aber ein ROM mit Memory Banking, um den Speicher zu testen
  • Die Desktop-FPS auf einem Windows-PC mit Ryzen 9 7900 und auf einem MacBook Air mit M4 sahen wie folgt aus
CPU Flag Roboto Merken
Ryzen 9 7900 1785 1943 1422
Apple M4 1907 2508 1700
  • Die Web-FPS sahen wie folgt aus
CPU Flag Roboto Merken
Ryzen 9 7900 646 883 892
Apple M4 779 976 972
  • Fame Boy läuft auf beiden Plattformen ordentlich
  • Entgegen der Erwartung hat die APU, also der Sound, einen größeren Einfluss auf die Emulator-Performance als die PPU
  • Wenn die PPU deaktiviert wird, steigt die Desktop-Performance um etwa 250 FPS; wenn die APU deaktiviert wird, steigt sie um etwa 500 FPS

Einsatz von AI

  • Auch bei einem Lernprojekt ließ sich der Einfluss von AI aus meiner Sicht nicht vollständig vermeiden, deshalb habe ich transparent festgehalten, wie ich AI eingesetzt habe
  • Im gesamten Prozess wurde AI hauptsächlich als Hilfswerkzeug verwendet
    • zum Anfordern von Code Reviews
    • als Gesprächspartner zum Prüfen von Ideen
    • zum Interpretieren knapper technischer Dokumentation
  • Ich habe versucht, von AI geschriebenen Code so weit wie möglich zu minimieren
  • Weil ich ein Ergebnis schaffen wollte, das ich Menschen zeigen kann und auf das ich stolz sein kann, wollte ich nicht nur Prompts teilen, sondern Code hinterlassen, den ich selbst geschrieben habe
  • PR zur Leistungsverbesserung

    • Gegen Ende des Projekts habe ich dem CLI das Repository übergeben und nach Performance-Verbesserungen suchen lassen
    • Es hat einige Ideen geliefert, ich habe es zusätzlich frei experimentieren lassen, und in manchen Benchmarks wurde die Leistung mehr als verdoppelt
    • Details stehen im PR
    • Allerdings wurden dabei auch Bugs eingebaut, die ich selbst finden und beheben musste
    • Eine der großen Performance-Verbesserungen, „STAT nur bei mode/LY-Wechsel aktualisieren“, hat einige Spiele und Demos kaputtgemacht, die davon abhängen, dass häufiger aktualisiert wird, und wurde mit diesem Fix-Commit behoben
  • „Timer-Winter“

    • In der Git-Historie gibt es eine große Lücke, und ich nenne diese Zeit den „timer winter“

    • Das lag nicht daran, dass ich nicht am Emulator gearbeitet hätte, sondern daran, dass ich an einem Bug festhing, durch den ich nicht über den Copyright-Bildschirm von Tetris hinauskam

    • Ich habe über 20 Stunden debuggt, den emu-dev-Discord durchsucht, Tests geschrieben und das Problem sogar frühen AI-Modellen vorgelegt, aber nichts davon hat geholfen

    • Nach ein paar Wochen Pause habe ich Claude Opus ausprobiert, und es hat das Problem in wenigen Minuten gefunden

    • Das Problem war, dass der Timer nur einmal pro Instruktion tickte und nicht entsprechend der Anzahl der von der Instruktion verbrauchten Zyklen

    • 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

    • Da CPU-Zyklen zwischen 1 und 6 variieren können, lief der Timer in der bisherigen Implementierung im Schnitt 2- bis 3-mal langsamer als in der Realität

    • Der Copyright-Bildschirm blieb also einfach nur länger stehen, und das eigentliche Problem war, dass ich nie 1 bis 2 Minuten gewartet hatte

    • Den Haupttext selbst habe ich größtenteils direkt geschrieben

Erkenntnisse und Fazit

  • Das Hauptziel war, zu lernen, wie Computer funktionieren, und in dieser Hinsicht war das Projekt ein großer Erfolg
  • Die Arbeit hat sehr viel Spaß gemacht, und ich bin oft völlig darin aufgegangen: Nach Feierabend mit „heute nur noch ein Feature“ angefangen und dann bis 2 Uhr nachts nur noch einen weiteren Bug gefixt
  • Ich habe kurz überlegt, auch den Game Boy Advance auszuprobieren, aber nach Blick auf die Spezifikation schien der Gewinn an Hardware-Verständnis nur etwa 20 % höher zu sein, während der Aufwand ungefähr dreimal so groß wäre
  • Der Game Boy bot eine gute Balance fürs Lernen, und fürs Erste kann ich hier aufhören
  • Ob ich dadurch ein besserer Software Engineer geworden bin, weiß ich nicht sicher, aber ich verstehe die Werkzeuge, die ich jeden Tag benutze, auf jeden Fall ein Stück besser
  • Fragen oder Kommentare können per E-Mail geschickt werden

Noch keine Kommentare.

Noch keine Kommentare.