1 puan yazan GN⁺ 2 시간 전 | Henüz yorum yok. | WhatsApp'ta paylaş
  • Fame Boy, F# ile yazılmış bir Game Boy emülatörüdür; ses desteğiyle birlikte masaüstü ve web’de çalışır, ayrıca tarayıcıda oynama ve GitHub kaynak kodu herkese açıktır
  • Emülatör çekirdeği ile arayüz, yalnızca framebuffer, audiobuffer, stepEmulator() ve getJoypadState(state) paylaşacak şekilde sadeleştirildi; stepper ise CPU, zamanlayıcı, seri birim, APU ve PPU’yu sırayla çalıştırarak tek iş parçacıklı senkronizasyonu sağlar
  • CPU uygulaması, F#’ın discriminated union ve match yapısını kullanarak 512 opcode’u 58 komut olarak modelledi; ayrıca From ve To tipleriyle anlık değerlere yazma gibi geçersiz durumlar tip seviyesinde engellenecek şekilde tasarlandı
  • PPU, gerçek Game Boy’daki piksel FIFO yerine tarama satırı bazlı render yaklaşımını seçti; bu daha hızlı ve daha basit olsa da, piksel kuyruğu zamanlamasını kullanan bazı oyunlar düzgün çalışmayabilir
  • Web’e taşıma işi Fable ile çözüldü; 8 bit ve 16 bit bit işlemlerinin JavaScript’in 32 bit semantiğine uymasıyla ilgili sorun düzeltildikten sonra yaklaşık 100KB’lık JS paketiyle çalıştı ve performans optimizasyonlarıyla release build’de masaüstünde yaklaşık 1000FPS’ye ulaşıldı

Projenin arka planı ve hedefi

  • 8 yıldan uzun süredir yazılım mühendisi olarak çalışsa da bilgisayarların gerçekte nasıl çalıştığını anlamadığını hissedince, bunu bir emülatör yaparak öğrenmeye karar verdi
  • Çocukken Pokémon’u çok oynadığı için hedef olarak Game Boy’u seçti; bu hem gerçek bir donanımdı hem de kapsamı görece daha basitti ve kişisel bağı daha güçlüydü
  • Doğrudan Game Boy’a başlamadan önce From NAND to Tetris eğitimini alarak register, bellek ve ALU gibi bilgisayarın temel bileşenlerini öğrendi
  • Emülatör geliştirmeye alışmak için önce F# ile CHIP-8 emülatörü Fip-8’i yazdı
  • Birkaç aylık çalışmanın sonunda, ses destekli ve hem masaüstünde hem web’de çalışan Game Boy emülatörü Fame Boy’u tamamladı
  • Buna tarayıcıda oynanabilir ve kaynak kodu GitHub’da açık olarak bulunuyor

Emülatör mimarisi

  • Hem masaüstünde hem web’de çalışabilmesi için, emülatör çekirdeği ile arayüz arasındaki yapı basit tutuldu
  • Arayüz ile çekirdek arasındaki temel arayüz iki dizi ve iki fonksiyondan oluşur
    • framebuffer: beyaz, açık ton, koyu ton ve siyahı içeren 160×144’lük bir gölgelendirme dizisi
    • audiobuffer: 32768Hz örnekleme oranına sahip, okuma ve yazma başlıkları bulunan bir halka ses tamponu
    • stepEmulator(): tek bir CPU komutunu çalıştırır ve harcanan çevrim sayısını döndürür
    • getJoypadState(state): arayüzün joypad durumunu emülatöre ilettiği callback’tir; genelde her frame’de bir kez çağrılır
  • Fame Boy, gerçek Game Boy donanımına benzer şekilde modellendi
    • CPU, gerçek Game Boy’daki Sharp LR35902 gibi bellek haritası dışındaki donanımı bilmez ve kesme sinyalleri için yalnızca IoController’ı kullanır
    • CPU, kod tabanındaki en F# tarzı bölümdür ve yoğun biçimde fonksiyonel domain modelleme kullanır
    • Memory.fs, Game Boy RAM’inin büyük kısmını tutar ve CPU, IO Controller ve kartuş arasındaki bellek haritası ile veri yolu görevini üstlenir
    • Performans için Memory.fs, PPU gibi bileşenlerle VRAM ve OAM RAM dizisi referanslarını paylaşır
    • IoController.fs, Memory.fs içinde mantık çok fazla büyüyünce ayrıldı; gerçek Game Boy donanımında tek bir IO controller yoktur, ancak donanım register işlemlerini tek yerde toplayarak her bileşenin arayüzünü daha basit ve güvenli hale getirir
  • Emulator.fs içindeki stepper fonksiyonu, tüm emülatörü birbirine bağlayan yapıştırıcı görevi görür ve her bileşenin adım çalıştırma fonksiyonlarını birleştirir
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
  • Gerçek donanım bileşenleri merkezi bir master osilatöre göre paralel çalışır, ancak Fame Boy tek iş parçacıklı olduğundan bileşenlerin sırayla çalıştırılması gerekir
  • stepper fonksiyonu yürütmeyi merkezileştirerek tüm bileşenlerin senkronize kalmasını sağlar
  • Oynanabilir hız elde etmek için saniye başına doğru çevrim sayısında çalışması gerekir; 60FPS’de her frame için yaklaşık 17500 CPU çevrimi gerekir
  • Arayüz, ses açıksa emülatörü ses örnekleme oranına göre çalıştırır; sessizde ise frame hızına göre çalıştırır

CPU uygulaması ve F#

  • CHIP-8 emülatörü mutable üyeler olmadan tamamen saf şekilde yazılmış ve diziler de kopyalanmıştı; ancak Fame Boy değiştirilebilir durumu yoğun biçimde kullanır

  • Game Boy, CHIP-8’den çok daha hızlıdır ve 16KB’den fazla belleği saniyede milyonlarca kez kopyalamak uygun değildir

  • Fame Boy’da F# kullanılmasının nedeni, F#’ın zengin tip sisteminin CPU komutlarını modellemeye çok uygun olması ve ayrıca F#’ın özellikle sevilmesidir

  • Domain modelleme

    • CPU uygulanırken Gekkio’s Complete Technical Reference takip edildi ve komutlar bu belgede olduğu gibi gruplandı
    • İlk aşamada Instructions.fs içinde komut türlerine göre discriminated union’lar tanımlandı
    • type LoadInstr = | Load8Immediate of uint8 | Load8Direct of Register | Load8Indirect // ... other load instructions
  • type ArithmeticInstr = | IncrementDirect of uint8 | IncrementIndirect of Register // ... diğer aritmetik komutlar

    • Birçok komut, işlenen konumu şeklindeki ortak kavramı paylaşır

      • Komutun hemen ardından bellekteki bayt değerini okuyan immediate
      • CPU register'ını okuyup yazan direct
      • HL CPU register'ının işaret ettiği bellek konumunu okuyup yazan indirect
    • Konum kavramını soyutlayıp From ve To türlerine ayırarak load komutlarını daha özlü ifade etti

    • type To = | Direct of Register | Indirect

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

    • type LoadInstr = | Load of From * To // Bunlar bir tuple oluşturur; C#'taki Load<From, To> gibi // ... diğer komutlar

    • Bu yaklaşımla CPU komutları 512 opcode'dan 58 komuta indirildi

    • Alanı genellemek, hatalı durumlara izin verme riski taşır; ancak bu, tip sistemiyle önlenebilir

    • From ve To yerine tek bir konum tipi Loc kullanılırsa, Load(Loc.Direct D, Loc.Immediate) gibi register değerini immediate konumuna yazan hatalı bir komut derlenebilir

    • Game Boy donanımı immediate'a yazmayı desteklemediğinden, alanı F# tipleriyle doğru modellemek sistemde geçersiz durumların ifade edilememesini garanti edebilir

    • Tek istisna olarak opcode 0x76 vardır

      • Yalnızca opcode desenine bakıldığında, HL konumundaki 8 bitlik değeri yine aynı HL konumuna yükleyen Load(From.Indirect, To.Indirect) gibi görünür
      • Fame Boy'un tipleri buna izin verir, ancak gerçek Game Boy'da böyle bir komut yoktur
      • Mantıksal olarak NOP'tur ve tehlikeli değildir; ayrıca opcode okuyucu 0x76 değerini HALT olarak decode ettiği için buna ulaşılamaz
    • F#'taki match ifadesi ve Option kullandıktan sonra normal switch ifadesine dönmek kaba ve hataya açık hissettiriyor; bu yüzden işlevsel bir dil denemenizi öneriyor

  • Basit tutmak

    • Projenin amacı en iyi emülatörü yapmak değil, bilgisayar donanımını öğrenmek olduğu için diğer emülatörlerin kodlarına derinlemesine bakmadı

    • CAMLBOY kaynağında aşağıdaki gibi bir kod görüp, istediği flag'leri istenen sırayla geçirebilmesini beğendi

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

    • F#, kısmi uygulamayı destekleyen tip sistemi nedeniyle metot overloading ve varsayılan parametrelerden kaçındığı için bunu aynı şekilde yapamadı

    • Başta bunu, aşağıdaki gibi dizi ve flag tipi geçirerek uyguladı

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

    • Daha sonra refactoring sırasında bunu Cpu/State.fs L81 içindeki aşağıdaki saf fonksiyon tabanlı uygulamaya dönüştürdü

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

      let inline setH (v: bool) (f: uint8) = // ... diğer flag fonksiyonları ve tanımları

    • // Other files

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

    • Yeni fonksiyonlar kolayca birleştirilebiliyor, test edilebiliyor ve basit saf fonksiyonlar

    • Önceki uygulama, değerleri ayrımlı birleşim tipine yükseltip bir diziye koymayı gerektirdiği için daha ayrıntılıydı

    • Yeni fonksiyonlar inline olduğundan heap allocation gerektirmiyor, bu yüzden performansları da daha iyi; emülatörün FPS'ini yaklaşık %10 artırdı

  • Testler

    • İlk CPU uygulaması, Tetris ROM'unu çalıştırıp uygulanmamış bir opcode'a her ulaşıldığında ilgili komutu ekleme şeklindeydi
    • match opcode with
    • | 0x00 -> Nop
    • | _ -> failwith "Unimplemented opcode"
    • Bu yöntem, teknik belgeler arasında rastgele gidip gelmeyi gerektirdiği için tekrarlı ve sıkıcıydı; ayrıca komutların doğru uygulanıp uygulanmadığını anlamak da zordu
    • Bu iki sorunu çözmek için birim testleri ekledi
    • Öğrenme amacıyla emülatör kodunu kendisi yazdı, ancak test vakaları üretmek için yapay zekadan yararlandı
    • Teknik belgelerdeki spesifikasyonları prompt'a koyup, emülatör kodunu görmeden spesifikasyona dayalı testler yazdırdı
    • Yapay zeka testleri üretirken kendisi de spesifikasyonları okuyup, testler geçene kadar mantığı uygulayarak gerçek anlamda test odaklı geliştirme yaptı
    • Testler sayesinde daha önce uyguladığı komutlardaki birkaç hatayı da buldu
    • Testleri düzenli olarak gözden geçirip iyileştirdi; bu da öğrenmeyi engellemek yerine enerjisini ilginç kısımlara harcamasına yardımcı oldu

CPU Sonrası Bileşenler

  • PPU

    • Game Boy’da GPU değil, PPU yani picture processing unit bulunur
    • Diğer Game Boy emülatörü geliştirme yazıları çoğunlukla CPU’ya odaklanıp PPU’yu birkaç paragrafla geçse de, Fame Boy’da PPU’yu anlamak daha uzun sürdü
    • CPU, From NAND to Tetris ve CHIP-8 deneyimi sayesinde doğal hissettiriyordu; PPU ise pikselleri ekrana koymak için adımları izleyen mekanik bir işe daha yakındı
    • Başta piksel FIFO’sunu ve tüm PPU hattını tek seferde anlamaya çalışmak yerine, bellekten tile ve arka plan haritasını okuyup ayrıştırarak ekranda göstermeyle başladım
    • Bu sayede CPU’nun çalıştığını görebildim ve Tetris’in sadeliği sayesinde neredeyse gerçek bir Game Boy oyunu gibi görünen bir sonuç elde ettim
    • Tile ve arka plan görünümünden başlayan bu yaklaşım, gerçek ekranı uygulamaktan sprite verilerindeki ayrıntılı bug’ları ayıklamaya kadar sürekli yardımcı oldu
    • Fame Boy’un PPU’sunda donanımsal doğruluktan önemli ölçüde ödün verilmiş durumda
      • Gerçek Game Boy, CRT monitör gibi FIFO kuyruğu kullanarak pikselleri ekrana tek tek yerleştirir
      • Fame Boy ise ilgili satırın çizim süresi başlarken tüm scanline’ı render eder
    • Bu yöntem daha hızlı ve kodu daha basit; ayrıca oynamak istediğim oyunların hepsi çalıştığı için piksel kuyruğuna geçme ihtiyacı duymadım
    • Game Boy donanımını sınırlarına kadar kullanan ve piksel kuyruğu zamanlamasından yararlanan oyunlar Fame Boy’da düzgün çalışmaz, ancak çoğu oyun donanımı bu kadar agresif kullanmadığı için genel olarak çalışması beklenir
  • Joypad

    • PPU ve APU dışında joypad’i de ele aldım
    • İlk uygulama oldukça kolaydı ve test yazmak da basitti
    • Ama büyük bir refactoring sonrasında neredeyse her zaman bozuluyordu
    • Joypad donanım yazmacı hem CPU hem de oyun tarafından okunup yazıldığı için etkileşim karmaşık
    • Başta CPU’nun her çevrimde joypad durumunu yazmaca yazmasını sağladım, ancak bir insan saniyede milyonlarca kez tuş değiştirmeyeceği için bunu kare başına bir kez güncelleyecek şekilde değiştirdim
    • Sonuç olarak yön tuşları çalışmamaya başladı
    • Game Boy donanımı bir seferde tuşların yalnızca yarısını okuyabilir ve oyunlar neredeyse her zaman joypad yazmacını kısa aralıklarla iki veya daha fazla kez okuyup iki okuma arasında yazmacın değişmesine dayanır
    • Kare başına bir kez önbelleğe alınan yazmaç, iki okuma arasında değişmediği için tuşların yarısı çalışmıyordu
    • Sonunda IoController’ı, CPU okuma yaptığında joypad yazmacını güncelleyecek şekilde uyguladım
    • Konuyla ilgili daha fazlası için Pandocs’un joypad belgesine bakılabilir
  • Ses

    • Çalışan bir emülatör yaptıktan sonra web sürümünü oynarken ses olmayınca ortamın boş hissettirdiğini fark edip APU yani audio processing unit ekledim
    • Birçok emülatörün kare hızına göre değil, frontend’in ses örnekleme hızına göre çalıştırıldığını keşfettim
    • Başta bu bana ters geldiği için dinamik örnekleme hızını araştırdım ve emülatörü kare hızının sürmesini sağlamaya çalıştım
    • Ses kavramsal olarak en zor bileşendi ve çeşitli ses yazmaçları ile kanalların davranışını anlamak zaman aldı
    • Bu kısımda yapay zeka öğretmen gibi çok yardımcı oldu ve kod yazmadan önce birçok kez soru-cevap yaptım
    • PPU’da olduğu gibi, kanalları teker teker tamamlamak çok tatmin ediciydi; Tetris müziğinin giderek zenginleşmesini dinlerken müziğin nasıl kurulduğunu da anladım
    • CPU ve PPU, her karede tam olarak X kadar iş yapan yapılardı ve X’i hesaplamak kolaydı, fakat APU’da seçilip ayarlanması gereken çok daha fazla değer vardı
    • Yalnızca APU örnekleme hızını belirlemek kolaydı
      • Gerçek Game Boy APU’su esnek olduğundan emülatör istediği örnekleme hızını kullanabilir
      • Fame Boy 32768Hz’i seçiyor
      • 1048576Hz CPU clock’unda 32768Hz, her 128 CPU çevriminde 1 örnek demek olduğundan APU durumu yalnızca tamsayılarla kusursuz biçimde senkronize edilebilir
      • 128 ayrıca 4’e de tam bölündüğü için APU adımlarını 4’lük gruplar halinde işlemek CPU komutlarıyla hizayı bozmaz
    • Diğer değerler çok daha oynaktı ve ses mühendisi olmadığım için deneme-yanılmayla ayarlamam gerekti
    • Her frontend’in ve platformun kendine özgü sorunları vardı
      • PC’de ses iyi çalışıyordu ama MacBook’ta şelale sesi gibi geliyordu
      • MacBook sorununu düzelttiğimde bu kez masaüstü PC sürümü yarış durumu yüzünden çalışmadı
    • Dinamik örnekleme hızıyla akıllıca çözme girişiminden vazgeçip, sesi emülatörü sürecek şekilde değiştirdiğimde ses birçok cihazda çok daha kararlı hale geldi
    • Ses, emülatör ile frontend arayüzündeki en sorunlu alanlardan biri, ancak uyumsuz tınlamaları önlemek için doğru senkronizasyon gerekiyor

Emülatörün Çalıştırılma Biçimi

  • Ses tabanlı çalışma ile kare tabanlı çalışma arasındaki fark insan algısıyla ilgilidir
  • Ses sinyali kesildiğinde hoparlör, sinyaldeki ani değişim yüzünden büyük hareket yapar ve pop gürültüsü oluşur
  • Video kesildiğinde veriler zamanında gelmediği için video oynatıcı bir iki kare atlar, ancak fiziksel bir şeyi itip çekmediğinden duyusal olarak daha az rahatsız eder
  • Fame Boy’un içinde ses ve video tasarım gereği kusursuz şekilde senkronizedir
  • Ancak çalışan bilgisayarın sesi ile videosu birbirinden bağımsızdır ve bazen biri diğerinin gerisinde kalabilir
  • Frontend’in sesi ile videosu kayarsa iki seçenek vardır
    • Frontend sesi ile emülatör sesini senkronize edip ara sıra kare düşürmek
    • Frontend videosu ile emülatör karelerini senkronize edip ara sıra ses düşürmek
  • Seçilen taraf emülatörü “sürer”, diğeri ise ona olabildiğince yakın tutulur
  • Kare hızı tabanlı çalışma nispeten basittir
let mutable cycles = 0

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

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

    draw ppu.framebuffer
  • Ses tabanlı çalışma, Raylib ve Web Audio’nun sesi işleme biçimleri farklı olduğu için daha zordur
  • Genel akış şu şekildedir
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
  • Temel fark, stepEmulator’ın artık lastFrameTime ile kontrol edilmemesi ve frontend ses tamponunun ihtiyacına göre çalışmasıdır
  • samplesNeeded, farklı örnekleme hızlarına uyum sağlayıp 60FPS üretebilmek için stepEmulator çağrı sayısını hesaplamalıdır
  • Frontend ses tamponu yalnızca kendini doldurmakla ilgilendiğinden, kare başına stepEmulator çok fazla ya da çok az kez çağrılabilir; bunun sonucunda framebuffer zamanında güncellenmeyebilir
  • Web frontend’inde URL’ye ?frame-driven eklenerek kare tabanlı sürüm denenebilir
  • Kare tabanlı sürüm görsel olarak daha akıcıdır ama ara sıra ses pop’ları üretir
  • Ses tabanlı web frontend’i de sessize alma düğmesine basıldığında pop duyulmadığı için kare tabanlı moda geçer
  • Uygulama kusursuz değil, ancak ses pop’ları kare takılmalarından daha kötü bir izlenim bıraktığı ve sessiz durum da boş hissettirdiği için web frontend’inin varsayılanı ses tabanlı olarak seçildi
  • Ses, Fame Boy’da tatmin edici olmayan az sayıdaki alandan biri ve bir gün yeniden ele almak istediğim bir kısım

Fable ile web’e taşıma

  • PPU bir ölçüde çalışıp masaüstü ekranında bir şeyler görünmeye başladıktan sonra, Fame Boy’u web’e taşımaya çalıştım
  • Fable belgelerine bakıp paketi kurdum, ana döngüyü ayarladım ve stilleri ekledim; bir iki saat içinde çalıştırmaya hazır hale geldi
  • İlk çalıştırdığım Fable sürümünde ekran garip görünüyordu; biraz debug ettikten sonra fazla zaman harcamamak için Blazor WebAssembly’yi denedim
  • Blazor’da da çalıştırmak kolaydı ve bu kez gerçekten çalıştı, ama yaklaşık 8FPS civarında kaldığı için neredeyse oynanamaz durumdaydı
  • Bunun doğrudan Blazor kaynaklı olup olmadığından emin değilim; .NET ekibinin performans kılavuzlarını da izledim ama fayda etmedi
  • Debug etmek de kullanışsız gelince yeniden Fable’a döndüm ve JavaScript’e dönüştürme sürecinde neyin yanlış gittiğine baktım
  • Fable, dönüştürülen JS dosyasını doğrudan kaynak kodun yanına koyuyor ve gerçekten de oldukça okunabilirdi
  • Bu sayede yeni kodu anlamak ve tarayıcı geliştirici araçlarında debug etmek kolaylaştı
  • Geliştirici araçlarında CPU register değerlerinin garip olduğunu fark ettim
    • Fame Boy ve Game Boy’un CPU register’ları 8 bit işaretsiz tamsayı, yani aralıklarının 0–255 olması gerekiyor
    • Ama -15565461 gibi değerler görünüyordu
  • Fable belgelerinde numeric types uyumluluk dokümanını buldum

16 bit ve 8 bit tamsayılar için bit düzeyi işlemler, JavaScript’in alttaki standart dışı 32 bit bitwise semantiğini kullanır. Sonuçlar beklendiği gibi kırpılmaz ve shift işlenenleri veri tipine sığacak şekilde maskelenmez.

  • Bu açıklama, 16 bit ve 8 bit tamsayıların bit işlemlerinde JavaScript’in 32 bit bitwise semantiğini kullandığı ve sonuçların beklendiği gibi kırpılmadığı durumla birebir örtüşüyordu
  • Kodda 8 bit değerlerin kırpılması gereken yerleri bulup ilgili sorunları düzelttikten sonra web frontend’i düzgün çalıştı
  • .NET runtime olmadan yalnızca JS kullandığı için web bundle’ı yaklaşık 100KB
  • Bu tuhaf uint8 sorunu dışında Fable kullanma deneyimi oldukça rahattı ve tüm kaynak kodu F# olarak tutabildim

Performans iyileştirmeleri

  • Ekranda sonuçlar görünmeye başladıktan sonra konsola basit bir FPS log’u ekledim
  • Başlangıçta debug modunda yaklaşık 55–60FPS alıyordum; bunun Raylib’in v-sync’i korumaya çalışmasından kaynaklandığını düşünüyorum
  • v-sync’i kapatınca yaklaşık 70FPS’ye çıktı ama jitter oluştu
  • Sonrasında özellikler eklendikçe performans yavaş yavaş düştü ve 45FPS’ye kadar indi; v-sync’i kapatmak da yardımcı olmadı
  • JetBrains Rider profiler’ını çalıştırdığımda mapAddress şüpheli bir bottleneck olarak öne çıktı
  • Neredeyse tüm bileşenler memory’ye eriştiği için, memory erişim maliyetinin beklediğimden daha yüksek olduğunu fark ettim
  • Sorunlu kod, memory adreslerini MemoryRegion adlı discriminated union’a eşleyip ardından okuma ve yazma yapıyordu
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 alanını modellemekten elde ettiğim akışı memory’ye de genişletmeye çalıştım; bunun sonucu olarak her memory okuma ve yazma işleminde MemoryRegion nesnesi oluşturulup eşleniyordu
  • Bu yaklaşım saniyede milyonlarca nesneyi heap üzerinde allocate ediyor, ayrıca JIT compiler’ın işlemesi gereken branch sayısını da artırıyordu
  • Discriminated union ve mapping fonksiyonunu kaldırıp doğrudan dizilere erişecek şekilde yaptığım tek bir değişiklik FPS’yi ikiye katladı
  • Sonraki benchmark’larda performans kazanımının büyük bölümünün, branch’ler ve yerelleşmiş çağrı noktaları üzerindeki JIT optimizasyonlarından geldiği göründü
  • MemoryRegion’ı stack’e allocate edilsin diye struct DU’ya çevirsem de performans yalnızca yaklaşık %15 arttı; kalan %85’lik iyileşme DU ve mapping fonksiyonunun kaldırılmasından geldi
  • Bundan sonra da struct DU’ya geçtiğim ya da F# açısından pek idiomatic olmayan yaklaşımlar seçtiğim başka yerler oldu
  • PPU’yu uygulamaya başladığım noktadan itibaren optimizasyon gerekli hale geldi ve bir miktar idiomatic F#’tan vazgeçmek zorunda kaldım
  • Profiler’a düzenli olarak bakıp performansı yavaş yavaş iyileştirerek yaklaşık 120FPS’ye ulaştım
  • FPS’yi en çok artıran şey debug build’i kapatmak oldu; release modunda yaklaşık 1000FPS’ye kadar çıktı
  • En sona kadar performansı düzenli olarak izleyip ince ayar yapmayı sürdürdüm

Benchmark

  • Sadece konsoldaki FPS sayılarına bakmanın iyi bir performans ölçüm yöntemi olmadığını düşündüğüm için, projenin ortalarında masaüstü performansını ölçmek üzere bir BenchmarkDotNet projesi ekledim
  • Sonrasında Node.js kullanan basit bir web benchmark aracı yaparak web tarayıcısı performansını da benzer şekilde tahmin ettim
  • Benchmark’larda gerçekçi senaryoları test etmek için şu demo ROM’ları kullandım
    • Flag: sesi olmayan kısa bir döngü
    • Roboto: yoğun görsel efektler ve ses kullanan, bir dakikadan uzun çalışan bir demo
    • Merken: Roboto’ya benzer ama memory testi için memory banking ROM kullanan bir demo
  • Ryzen 9 7900’lü Windows PC ve M4 MacBook Air üzerindeki masaüstü FPS performansı şöyleydi
CPU Flag Roboto Merken
Ryzen 9 7900 1785 1943 1422
Apple M4 1907 2508 1700
  • Web FPS performansı ise şöyleydi
CPU Flag Roboto Merken
Ryzen 9 7900 646 883 892
Apple M4 779 976 972
  • Fame Boy her iki platformda da gayet makul çalışıyor
  • Beklentimin aksine APU, yani ses sistemi, PPU’dan daha fazla emülatör performansını etkiliyor
  • PPU kapatıldığında masaüstü performansı yaklaşık 250FPS artıyor, ama APU kapatıldığında yaklaşık 500FPS artıyor

Yapay zeka kullanımı

  • Öğrenme projesinde bile yapay zekanın etkisinden tamamen kaçınılamayacağını düşündüğüm için, yapay zekayı nasıl kullandığımı şeffaf biçimde kaydettim
  • Tüm süreç boyunca yapay zeka çoğunlukla yardımcı araç olarak kullanıldı
    • Kod incelemesi isteme
    • Fikirleri gözden geçirmek için bir konuşma ortağı
    • Kısa teknik dokümanları yorumlama
  • Yapay zekanın yazdığı kodu olabildiğince azaltmaya çalıştım
  • İnsanlara gösterip gurur duyabileceğim bir çıktı üretmek istediğim için, sadece prompt paylaşmak yerine doğrudan benim yazdığım kodun kalmasını istedim
  • Performans iyileştirme PR'ı

    • Projenin sonlarına doğru depoyu CLI'a verip performans iyileştirmeleri bulmasını istedim
    • Birkaç fikir verdim, bunun dışında istediği denemeleri de yapmasına izin verdim ve bazı benchmark'larda performansı iki katın üzerine çıkardı
    • Ayrıntılar PR içinde yer alıyor
    • Ancak içine hatalar da girdi ve onları bizzat bulup düzeltmem gerekti
    • Büyük performans iyileştirmelerinden biri olan “mode/LY geçişinde yalnızca STAT güncelleme”, daha sık güncellenmesine dayanan bazı oyunları ve demoları bozdu; bunu düzeltme commit'i ile düzelttim
  • “Zamanlayıcı kışı”

    • Git geçmişinde büyük bir boşluk var ve bu döneme “timer winter” diyorum

    • Emülatör üzerinde çalışmayı bırakmış değildim; Tetris'in telif hakkı ekranını geçemeyen bir bug'a takılmıştım

    • 20 saatten fazla debug yaptım, emu-dev Discord'da arama yaptım, testler oluşturdum ve ilk dönem yapay zeka modellerine bile problemi verdim ama çözülmedi

    • Birkaç hafta ara verdikten sonra Claude Opus'u denedim ve birkaç dakika içinde sorunu buldu

    • Sorun, zamanlayıcının komut başına yalnızca bir kez tick etmesi ve komutun harcadığı çevrim sayısı kadar tick etmemesiydi

    • 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 çevrimleri 1 ile 6 arasında değişebildiği için, önceki uygulamada zamanlayıcı ortalama olarak gerçekte olması gerekenden 2-3 kat daha yavaş çalışıyordu

    • Telif hakkı ekranı sadece daha uzun süre kalıyordu; asıl sorun benim 1-2 dakika beklemeyi hiç denemememdi

    • Metnin kendisini ise büyük ölçüde ben yazdım

Öğrendiklerim ve sonuç

  • Asıl hedefim bilgisayarların nasıl çalıştığını öğrenmekti ve bu hedef açısından büyük bir başarıydı
  • Çalışma çok eğlenceliydi; işten sonra "bugün sadece bir özellik" diye başlayıp, gece 2'ye kadar "bir bug daha" düzeltmeye çalışarak kendimi kaptırıyordum
  • Game Boy Advance'i de denemeyi düşündüm ama teknik özelliklerine bakınca, donanım anlayışındaki artış yaklaşık %20 olacakken gereken çaba 3 kat gibi görünüyordu
  • Game Boy, öğrenmeye yardımcı olan iyi bir denge sundu ve şimdilik burada durabilirim
  • Daha iyi bir yazılım mühendisi olup olmadığımdan emin değilim ama her gün kullandığım araçları biraz daha iyi anladığım kesin
  • Soru veya yorumlarınızı e-posta ile gönderebilirsiniz

Henüz yorum yok.

Henüz yorum yok.