|
Professor Seleznov
|
Я работаю программистом уже более 8 лет, и, признаюсь, никогда не понимал, как на самом деле устроены компьютеры. Поэтому я решил попробовать изучить их работу путём эмуляции. Извините, Бен Итер, я пока не собираюсь ничего создавать.
 В детстве я провёл сотни часов, ловя покемонов, поэтому Game Boy был идеальным кандидатом: реальное оборудование, относительно простая архитектура и что-то, с чем у меня была сильная личная связь. Вместо того чтобы сразу же приступить к эмуляции, я сначала прошёл курс «От NAND до Тетриса». Это был отличный курс, и он помог мне по-настоящему понять основы компьютеров, такие как регистры, память и АЛУ. Затем, чтобы втянуться, я создал эмулятор CHIP-8 на F#: Fip-8. Спустя несколько месяцев, после многих ночей, когда я ложился спать в 2 часа ночи, хотя и говорил себе, что буду работать над этим всего час-два, у меня появился работающий эмулятор Game Boy: Fame Boy. Он работает со звуком и запускается как на компьютере, так и в веб-браузере. Как это работает Я хотел, чтобы эмулятор работал как на компьютере, так и в веб-браузере, поэтому сосредоточился на создании простого интерфейса между ядром эмулятора и любым фронтендом, который его запускает. Интерфейс между фронтендом и ядром по сути состоит всего из двух массивов и двух функций:
- framebuffer — массив оттенков 160x144 (белый, светлый, тёмный, чёрный);
- audiobuffer — кольцевой аудиобуфер с частотой дискретизации 32768 Гц с головками чтения и записи;
- stepEmulator() — функция, которая выполняет одну инструкцию ЦП и возвращает количество циклов;
- getJoypadState(state) — это функция обратного вызова для фронтенда, которая передает эмулятору состояние геймпада, обычно один раз за кадр. Я попытался смоделировать Game Boy аналогично реальному аппаратному обеспечению.
 Процессор, как и настоящий Sharp LR35902 в Game Boy, ничего не знает об аппаратном обеспечении, кроме карты памяти (и IoController только для сигналов прерывания). Это также самая «F#-подобная» часть моего кода, сильно опирающаяся на функциональное моделирование. Memory.fs содержит большую часть оперативной памяти, используемой в Game Boy, и выступает в качестве карты памяти и шины между процессором, контроллером ввода-вывода и картриджем. Он использует ту же ссылку на массивы VRAM и OAM RAM, что и PPU, для повышения производительности. IoController.fs появился, когда я обнаружил, что добавляю слишком много логики в Memory.fs. Хотя в аппаратном обеспечении Game Boy нет единого контроллера ввода-вывода, обработка всех аппаратных регистров через него упростила и повысила безопасность интерфейсов для отдельных компонентов. Функция stepper в файле Emulator.fs — это связующее звено, объединяющее все компоненты эмулятора, формируя отдельные функции шага:
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
В то время как все компоненты реального оборудования работают параллельно на основе центрального генератора, мой эмулятор однопоточный, поэтому компоненты должны работать последовательно. Функция шагового управления централизует выполнение и обеспечивает синхронизацию всех компонентов. Наконец, чтобы эмулятор был играбельным, он должен работать с правильным количеством циклов в секунду, около 17500 циклов ЦП на кадр 60 FPS. Фронтенды используют частоту дискретизации звука для управления эмулятором, когда звук включен, и частоту кадров для управления эмулятором, когда звук выключен. Подробнее об этом позже. Эмуляция ЦП и F# Прежде всего, я хотел бы извиниться перед приверженцами функционального программирования. В то время как мой эмулятор CHIP-8 полностью чист (нет изменяемых членов, и все массивы копируются без всяких побочных эффектов), Fame Boy использует изменяемость в больших масштабах. Game Boy работает намного быстрее, чем CHIP-8, и копирование более 16 КБ памяти миллион раз в секунду не казалось разумным решением. Так почему же F# для Fame Boy? Во-первых, я считаю, что его обширная система типизации очень хорошо подходит для моделирования инструкций ЦП. Во-вторых, и что более важно, мне просто очень нравится F#. В моей предыдущей компании я работал преимущественно с F#, поэтому всегда ищу повод продолжать его использовать. Моделирование предметной области В качестве примера того, почему я считаю, что моделирование ЦП хорошо работает в F#, я следовал «Полному техническому справочнику» Gekkio при реализации ЦП. Я сгруппировал инструкции, как в справочнике, и в итоге получил что-то вроде этого в Instructions.fs:
type LoadInstr = | Load8Immediate of uint8 | Load8Direct of Register | Load8Indirect // ... other load instructions type ArithmeticInstr = | IncrementDirect of uint8 | IncrementIndirect of Register // ... other arithmetic instructions
И дело было не только в инструкциях загрузки. Многие другие имели схожие концепции, например, расположение операнда:
- чтение байтового значения непосредственно после инструкции в памяти (immediate),
- чтение/запись в регистр ЦП (direct),
- чтение/запись в ячейку памяти, указанную регистром HL ЦП (indirect).
Хотя это небольшая область, и большинство разработчиков Game Boy знают коды операций/инструкции практически в неизменном виде, я посчитал, что их можно упорядочить. Приведённый ниже код показывает извлечение концепции расположения. В коде используются другие имена и порядок из исходного кода, чтобы сделать инструкцию загрузки более читабельной для тех, кто не знаком с DU в F#.
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 in C# // ... other instructions
Это помогло сократить количество инструкций ЦП с 512 до всего 58. Обобщение такой области знаний рискует привести к недопустимым состояниям, но использование хорошей системы типов поможет их избежать. Например, если бы я использовал тип местоположения Loc вместо типов From и To, эта инструкция скомпилировалась бы без каких-либо ошибок: Load(Loc.Direct D, Loc.Immediate) (сохранение регистра в непосредственное значение). Аппаратное обеспечение Game Boy (его область знаний) не поддерживает запись в непосредственное значение, поэтому область знаний содержала бы недопустимое состояние. Используя систему типов F# для правильного моделирования области знаний, вы получаете гарантию того, что недопустимые состояния не могут быть выражены в вашей системе. Вам не обязательно нужны модульные тесты, это просто не скомпилируется. Таким образом, с упрощёнными типами Game Boy по-прежнему точно отражает то, что поддерживает ЦП Game Boy, и ничего больше (с одним забавным исключением). Теперь внимательные разработчики эмулятора Game Boy могли бы спросить меня: «Эй, Ник, а как же код операции 0x76?», на что я бы ответил: «Монада — это моноид в категории эндофункторов», чтобы показать, что я использую функциональный язык программирования и, следовательно, умнее их. Если говорить серьёзно, это компромисс, на который я решил пойти, потому что, как мне показалось, он значительно упрощает работу с процессором. Если вы посмотрите на шаблоны, которым следуют коды операций, 0x76 будет означать Load(From.Indirect, To.Indirect), или загрузку 8-битного значения из памяти по адресу HL в память по адресу HL. В моём эмуляторе это возможно, но на самом Game Boy такой инструкции нет. Логически это NOP (Not Indirect — не опасная операция), а на практике она недостижима, поскольку считыватель кодов операций декодирует 0x76 как HALT. Но это существенный недостаток в том, что, на мой взгляд, в остальном является неплохой доменной моделью. Сейчас нечто подобное можно сделать в большинстве языков, но если вы работали с функциональным, трудно точно описать, насколько плавно ощущается работа с такими типами. После использования оператора match или Options в F#, возвращение к оператору switch кажется неуклюжим и чреватым ошибками. Всем, кто не работал с функциональным языком программирования, я бы порекомендовал попробовать. Придерживайся простоты, глупец! Поскольку целью этого проекта было изучение компьютерного оборудования, а не создание лучшего эмулятора, я почти никогда не углублялся в код других эмуляторов. Однако, просматривая исходный код CAMLBOY, я заметил такие строки: set_flags ~h:false ~z:(!a = zero) (); Мне очень понравилось, что можно передавать любое количество флагов в любом порядке. А поскольку это просто именованные параметры для метода, накладные расходы на производительность минимальны. Но я не смог создать что-то в точности такое, потому что F# избегает перегрузки методов и параметров по умолчанию благодаря своей системе типов, поддерживающей частичное применение. Вместо этого я остановился на чем-то подобном: cpu.setFlags [ Half, false; Zero, a = 0uy ] Мне это никогда не нравилось, требовался массив и тип флага (например, Half). Но я все равно продолжил, так как хотел добиться прогресса. Приближаясь к концу, я потратил много времени на переработку старого кода и рефакторинг, и хотел попытаться улучшить функцию setFlags. Поэтому после долгих раздумий и опробования других подходов я в итоге получил вот это (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)
Функции именно такие, какие мне были нужны. Легко компонуемые и тестируемые, чистые функции. Просто восхитительно. Предыдущая функция требовала преобразования значений в типы DU и помещения их в массив, и в результате setFlags была более многословной. Кроме того, поскольку функции встроены и не требуют выделения памяти в куче, новые на самом деле намного производительнее, они увеличили FPS эмулятора примерно на 10%. Думаю, этот простой модуль Flags из 16 строк — возможно, мой любимый код на F# из всех, что я когда-либо писал. Тестирование Изначально я пытался работать с процессором, используя только эту функцию и запуская ROM-файл Tetris:
match opcode with | 0x00 -> Nop | _ -> failwith "Unimplemented opcode"
И каждый раз, когда возникало исключение, я реализовывал инструкцию для этого кода операции. Вскоре я столкнулся с двумя проблемами: цикл становился немного утомительным из-за случайного перемещения по техническим ссылкам вместо того, чтобы фокусироваться на группах инструкций за раз, и я понятия не имел, правильно ли реализую инструкции. Исправить обе эти проблемы было просто: модульные тесты. Вот где ИИ действительно пригодился. Чтобы улучшить своё обучение, я хотел написать код эмулятора самостоятельно, но придумывать тестовые случаи было бы утомительно, и я мог бы зациклиться на одном и пропустить некоторые важные тесты. Итак, у меня было два задания, в которых я использовал спецификацию из технической документации и просил ИИ написать тесты для этих спецификаций, не заглядывая в код эмулятора. Пока он был занят, я сам читал спецификацию, а затем реализовывал логику, пока тесты не проходили: настоящая разработка через тестирование. Это даже помогло выявить несколько ошибок в уже реализованных инструкциях. Я регулярно пересматривал и улучшал тесты, но в целом считаю, это нисколько не мешало моему обучению и помогало направлять свою энергию на то, что действительно было интересно. Помимо ЦП PPU У Game Boy нет графического процессора (GPU), у него есть PPU, блок обработки изображений. Хотя в моём понимании это на самом деле означает блок обработки пикселей. Я больше времени уделял отдельным пикселям, чем какому-либо изображению. Вот что меня удивило в блогах других людей, создавших эмуляторы Game Boy. Многие были сосредоточены на ЦП, уделяя PPU всего несколько абзацев. Возможно, это потому, что я только что закончил работу над проектом From Nand to Tetris и эмулятором CHIP-8, и работа с процессором показалась мне естественной, в то время как понимание работы PPU заняло гораздо больше времени. Но теперь, когда я его реализовал, я понимаю, почему. Речь идёт не столько о проектировании собственной системы, сколько о простом следовании шагам, необходимым для отображения пикселей на экране, — это скорее механическая работа, чем творческий подход. В начале реализации PPU я немного растерялся, не зная, что делать. Поэтому вместо того, чтобы пытаться сразу разобраться с пиксельными FIFO и всем конвейером PPU, я решил считать карту тайлов и фона из памяти, проанализировать данные и просто вывести их на экран (правая часть скриншота ниже). Наконец-то я увидел, как работает мой процессор, и благодаря простоте Tetris я увидел нечто, что по большей части представляло собой настоящую игру для Game Boy. Было здорово наблюдать это впервые.
 А что касается PPU, то, оглядываясь назад, можно сказать, что начать с карты тайлов и фона было отличным решением. Это помогло мне практически на каждом этапе процесса, от реализации самого экрана до отладки досадных мелочей с данными спрайтов. В целом, я доволен тем, как получился PPU, но, возможно, у него самая большая аппаратная неточность в моём эмуляторе. Game Boy использует очередь FIFO для размещения пикселей на экране по одному, как на ЭЛТ-мониторе, но мой эмулятор отрисовывает всю строку развёртки в начале периода отрисовки для этой строки. Это быстрее и упростило код, плюс все игры, в которые я хотел играть, работают, поэтому я не чувствовал необходимости переходить на очереди пикселей. Есть игры, где разработчики использовали возможности аппаратного обеспечения Game Boy на пределе и эксплуатировали тайминги очереди пикселей, и они не очень хорошо работают с Game Boy. Но большинство игр не так уж сильно зависят от аппаратного обеспечения и должны запускаться. Джойстик Помимо основных элементов (PPU и APU), я также хочу поговорить о джойстике. Первоначальная реализация прошла легко. Написать тесты было просто и понятно. Но после практически любой серьёзной переработки кода всё равно возникали проблемы. Регистр аппаратного обеспечения геймпада — это регистр, из которого и процессор, и игра считывают и записывают данные, поэтому они взаимодействуют друг с другом весьма неприятным интересным образом. Например, на ранних этапах разработки эмулятора я заставил процессор записывать состояние геймпада в регистр каждый цикл. Но это неэффективно, люди не меняют кнопки миллион раз в секунду, поэтому я изменил код так, чтобы обновление происходило только один раз за кадр. После этого крестовина перестала работать. При изучении вопроса, хотя я узнал, что аппаратное обеспечение Game Boy позволяет считывать только половину кнопок за раз, то обнаружил, что игры почти всегда выполняют как минимум два чтения регистра геймпада подряд, полагаясь на изменение состояния регистра между чтениями. Игры делают это, чтобы считывать состояние всех кнопок. Но теперь регистр кэшируется и не меняется, и половина кнопок не работает. Вот так вот. В итоге я сделал так, чтобы IoController обновлял регистр джойстика только тогда, когда он считывается процессором, но, вероятно, мне следовало бы потратить время и разработать для этого интеграционный тест. Подробнее о джойстике можно прочитать в документации Pandocs для тех, кому интересно. Звук — это сложно После того, как я закончил и получил работающий эмулятор, то доработал README репозитория и готовился написать этот код. Но, экспериментируя с веб-версией, я понял, что без звука она немного пустая. Поэтому я решил добавить аудиопроцессор APU (первая ошибка). Я прочитал несколько блогов и обнаружил, что многие эмуляторы используют частоту дискретизации аудио для управления эмулятором, а не частоту кадров. Мне это показалось неправильным, поэтому я изучил динамические частоты дискретизации и решил использовать их с частотой кадров для управления эмулятором (вторая ошибка). Звук был для меня самым сложным компонентом с концептуальной точки зрения. Мне потребовалось некоторое время, чтобы понять, как работают различные звуковые регистры и каналы. Именно здесь ИИ как учитель действительно проявил себя. У меня было много раздумий перед началом кодирования. Но, как и в случае с PPU, завершение каждого канала приносило мне огромное удовлетворение. Работа над каждым каналом по отдельности помогла мне понять, как на самом деле формируется музыка. Слушая, как постепенно нарастает и усиливается музыка из Тетриса, я улыбнулся. Однако не всё было так уж безоблачно. CPU и PPU, по сути, выполняют «один раз за кадр ровно X действий», и это X легко рассчитать. А вот APU, с другой стороны, предлагает множество вариантов для выбора и настройки. Единственным простым вариантом выбора была частота дискретизации APU. APU в оригинальном Game Boy был гибким, поэтому эмуляторы могли использовать любую частоту дискретизации по своему усмотрению. Я выбрал 32768 Гц, потому что это соответствует 1 выборке за 128 циклов CPU (1 048 576 Гц, и 1 048 576 / 32 768 = 128). Таким образом, состояние моего APU может использовать целые числа и при этом оставаться идеально синхронизированным. 128 также делится на 4, поэтому я мог обрабатывать шаги APU по 4 за раз и никогда не нарушать синхронизацию с инструкциями ЦП. Однако всё остальное было гораздо сложнее. Я не звукорежиссёр, поэтому просто менял значения, надеясь на лучшее. И хуже всего то, что у каждого интерфейса были свои особенности, и у каждой платформы тоже. На ПК звук работал хорошо, но, когда я попробовал его на MacBook, он звучал как водопад. Я починил MacBook, и внезапно настольная версия на ПК перестала работать из-за состояния гонки (ошибка многопоточного проектирования, при которой результат работы программы зависит от непредсказуемого порядка выполнения потоков или процессов). После нескольких часов настройки параметров и неудачных попыток я отказался от попыток использовать динамические частоты дискретизации и перешёл к тому, чтобы звук управлял эмулятором. Это сделало его гораздо более надёжным между всеми устройствами. Это также, безусловно, самая проблемная часть интерфейса, но это потому, что звук должен быть точно синхронизирован, чтобы избежать какофонии. Управление эмулятором Чтобы объяснить разницу между аудио- и покадровым управлением, нужно понимать человеческое восприятие. Помните, когда вы слушаете что-то, и вдруг раздаётся щелчок? Произошло падение аудиосигнала, и говорящий внезапно сильно дёргается из-за резкого изменения сигнала, создавая этот щелчок. То же самое происходит, когда видео заикается: не хватает данных, поступающих вовремя, и поэтому видеоплеер вынужден пропускать один или два кадра. Только теперь он не передаёт ничего физического, поэтому это менее неприятно для наших органов чувств. Теперь вернёмся к управлению эмулятором. В Fame Boy аудио и видео идеально синхронизированы, потому что так задумано. Но компьютер, на котором он работает, имеет независимые аудио- и видеоканалы, и иногда какой-либо из них может отставать. Таким образом, когда аудио и видео во фронтенде рассинхронизированы, у него есть два варианта действий:
- поддерживать синхронизацию аудио фронтенда и эмулятора, периодически пропуская кадры;
- поддерживать синхронизацию видео фронтенда и кадров эмулятора, периодически пропуская аудио.
Таким образом, выбранный вами вариант «управляет» эмулятором, при этом стараясь поддерживать синхронизацию с другим эмулятором. Управление частотой кадров довольно простое. Вот упрощённая версия:
let mutable cycles = 0 while (runEmulator) do cycles <- cycles + targetCyclesPerMs * lastFrameTime while cycles > 0 do let cyclesTaken = stepEmulator () cycles <- cycles - cyclesTaken draw ppu.framebuffer
Со звуком немного сложнее, поскольку Raylib и Web Audio обрабатывают его по-разному. Общий алгоритм работы выглядит следующим образом:
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. Вместо этого он определяется потребностями фронтенда в аудиобуферах. samplesNeeded необходимо рассчитать таким образом, чтобы stepEmulator вызывался нужное количество раз для соответствия различным частотам дискретизации и обеспечения 60 кадров в секунду. Однако аудиобуфер фронтенда заботится только о своем заполнении, поэтому иногда он вызывает stepEmulator слишком много или слишком мало раз за кадр, что приводит к тому, что буфер кадра не обновляется вовремя. Вы можете попробовать версию веб-интерфейса, управляемую фреймами, добавив параметр запроса ?frame-driven в URL. Визуально это должно быть плавнее, но иногда будут возникать щелчки звука. Кроме того, даже веб-интерфейс с управлением звуком переключается на управление кадрами при нажатии кнопки отключения звука, поскольку эти щелчки всё равно не будут слышны. Однако моя реализация далека от совершенства. В конечном итоге я обнаружил, что щелчки звука оставляют худшее впечатление, чем заикания кадров, а отключение звука в эмуляторе создавало ощущение пустоты, поэтому я решил сделать управление звуком по умолчанию в веб-интерфейсе. Звук — одна из немногих областей Fame Boy, которой я не совсем доволен и которую хотел бы когда-нибудь пересмотреть. Перенос игры в веб с помощью Fable После того, как мне удалось более-менее наладить работу PPU и увидеть некоторые события на экране рабочего стола, я с нетерпением ждал возможности перенести Fame Boy в веб-версию. Я зашёл на страницу документации Fable, установил пакет, настроил основной цикл, добавил немного стилей, и через час-два был готов запустить игру. Я нажал Enter, и тут:
 Возможно, события этой версии Тетриса происходят зимой в Сибири. Я попытался отладить работу, но вместо того, чтобы тратить на это время, просто перешёл к WebAssembly с Blazor. Запустить его тоже было довольно легко, и на этот раз всё заработало. Но была проблема: играть было практически невозможно, частота кадров составляла, может быть, 8 FPS. Я до сих пор не уверен, в чём дело. Не думаю, что оно в самом Blazor, команда .NET опубликовала руководства по производительности, которым я пытался следовать, но в итоге они не помогли. Отладка тоже была мучительной, поэтому я с неохотой вернулся к Fable, чтобы разобраться, что может быть не так с транспиляцией в JavaScript. К моему удивлению, Fable размещает транспилированные JS-файлы прямо рядом с исходным кодом, и он на самом деле довольно читабелен.
 Это значительно упростило понимание нового кода, а также отладку в инструментах разработчика браузера. И, изучив инструменты разработчика, я заметил, что что-то не так.
 Регистры ЦП в Fame Boy (и Game Boy тоже) представляют собой 8-битные беззнаковые целые числа, то есть в диапазоне от 0 до 255. Я не эксперт, но я не думаю, что -15565461 — это 8-битное число. Я просмотрел транспилированный код и документацию Fable и обнаружил следующее:
(нестандартные) побитовые операции для 16-битных и 8-битных целых чисел используют базовую 32-битную побитовую семантику JavaScript. Результаты не обрезаются, как ожидалось, и операнды сдвига не маскируются под тип данных.
Это объясняет повсеместное использование регистра B. Прочёсывая код в поисках мест, где нужно было обрезать 8-битные значения, я обнаружил все проблемные места, и, вуаля, фронтенд заработал идеально. А поскольку это всего лишь JavaScript без среды выполнения .NET, веб-пакет занимает около 100 КБ. За исключением странной проблемы с uint8 (которой у большинства людей не должно быть), у меня был довольно приятный опыт работы с Fable. Он работал плавно, и это означало, что весь исходный код остаётся на F#. Попытка улучшить производительность После того, как всё отобразилось на экране, мне стало любопытно, какова производительность. Я добавил простой вывод FPS в консоль. На тот момент в режиме отладки было около 55-60 FPS. Не отлично, но и не ужасно. Думаю, это было связано с тем, что Raylib пытался поддерживать вертикальную синхронизацию. Когда я отключил её, показатель подскочил примерно до 70 FPS, но с подёргиваниями. Оптимизацию всегда можно провести позже, поэтому я решил продолжить работу с PPU. По мере добавления новых функций производительность постепенно падала, в итоге достигнув 45 FPS, и отключение вертикальной синхронизации не помогло. Время пришло, и я решил оптимизировать работу. Я запустил профилировщик в JetBrains Rider и увидел следующее:
 mapAddress выглядел очень подозрительно. Практически каждый компонент обращается к памяти, но почему его значение намного выше, чем у остальных? Я углубился в другие вызовы функций (например, stepPpu) и обнаружил, что все компоненты тратят на доступ к памяти больше времени, чем я ожидал. Поэтому я перешёл к проблемному коду:
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 // ... others member this.write address value = match mapAddress address with | RomBase _ -> () // ... others
Я всё ещё был увлечён своей разработкой, ориентированной на предметную область, с использованием ЦП и попытался расширить её на память. Это означало, что каждое чтение или запись в память создавало объект MemoryRegion, который необходимо было отобразить, и это имело два эффекта: выделение миллионов объектов в кучу каждую секунду и дополнительное ветвление для JIT-компилятора. Я использовал DU и функцию map, просто обращаясь к массивам напрямую, и это изменение удвоило частоту кадров. Позже тестирование показало, что основная часть улучшений, по-видимому, связана с оптимизацией JIT-компиляции в отношении ветвлений и локализованных мест вызовов, поскольку преобразование MemoryRegion в структуру DU (то есть, выделенную в стеке) улучшило производительность всего примерно на 15%, а остальные 85% пришлись на удаление DU и функции map. Было и больше случаев, когда я переходил на структуры DU или использовал другие, менее дружелюбные к F# подходы. PPU стал точкой, где потребовалась оптимизация, и мне пришлось в некоторой степени отказаться от идиоматического F#. Я постепенно улучшал производительность, регулярно изучая профилировщик, и в итоге добился примерно 120 FPS. Но затем я обнаружил наибольшее улучшение частоты кадров. Решение? Отключение отладочной сборки, что позволило эмулятору достичь молниеносной частоты в 1000 FPS. Мне потребовалось до смешного много времени, чтобы понять, что режим отладки намного хуже, чем режим выпуска. Я продолжал регулярно отслеживать производительность и вносить корректировки до самого конца. Бенчмарки Просто смотреть на показатели FPS в консоли казалось не лучшим способом измерения производительности. Поэтому в середине работы я добавил проект BenchmarkDotNet для измерения производительности настольных компьютеров, а позже создал простой веб-бенчмарк с использованием Node.js для проведения аналогичных тестов с целью оценки производительности веб-браузера. Во всех бенчмарках использовались следующие демо-ROM, предназначенные для тестирования реалистичных сценариев:
- Flag — короткий цикл без звука;
- Roboto — продолжительная (>1 мин) демо-версия, использующая множество визуальных эффектов и имеющая звук;
- Merken — похожа на Roboto, но использует ROM с выделенной памятью для тестирования памяти.
Вот показатели FPS на настольных компьютерах: ПК с Ryzen 9 7900 под управлением Windows и MacBook Air M4.
 А вот производительность веб-приложений в FPS.
 Fame Boy показывает неплохие результаты на обеих платформах. Удивительно, но APU (звук) оказал большее влияние на производительность эмулятора, чем PPU. Отключение PPU увеличивает производительность на рабочем столе примерно на 250 FPS, а отключение APU — примерно на 500 FPS. На дворе 2026 год, так что заметка об ИИ В наши дни ни один код не свободен от влияния ИИ, даже учебные проекты. В целом, я стараюсь быть прозрачным в использовании ИИ, поэтому хотел бы рассказать о том, как я его применял и о своём опыте работы с ним в рамках чисто учебного проекта. На протяжении всего процесса я старался использовать ИИ в основном как помощника. Я регулярно обращался к нему за проверкой кода, как к «доске», с которой можно обсудить идеи, и для объяснения кратких технических документов. Однако я старался свести к минимуму использование кода, написанного ИИ. Я хотел создать что-то, что мог бы показать людям и чем мог бы гордиться. Код для людей, написанный человеком. Если бы мне нужен был только эмулятор, я мог бы просто поделиться запросом. В моём проекте было два примечательных случая с ИИ. Один из них произошёл в конце, когда я решил просто сжечь немного токенов и запустил CLI в своем репозитории, чтобы попытаться найти способы повышения производительности. Я дал ИИ несколько идей и попросил попробовать все, что он захочет. Он показал себя очень хорошо, более чем удвоив производительность в некоторых бенчмарках (ссылка с подробностями). Однако ИИ внёс некоторые ошибки, которые мне пришлось исправить. Примечательно, что одно из самых значительных улучшений производительности (обновление STAT только при переходах между режимами/LY) сломало некоторые игры и демоверсии, которые требовали более частых обновлений (коммит исправления). Другой случай — это когда ИИ фактически спас этот проект, хотя я почти сдался. Если вы посмотрите историю изменений в моём репозитории, то обнаружите довольно большой пробел в какой-то момент. Я называю это «зимой таймера».
 Дело не в том, что я не работал над эмулятором, — я просто застрял на баге. Я никак не мог пройти экран с авторскими правами в Tetris, что бы ни делал. Я, вероятно, потратил более 20 часов на отладку, просмотр Discord-сервера emu-dev, создание тестов и даже перенаправлял проблему на более ранние модели ИИ. Ничего не работало. Но затем, после нескольких недель перерыва в работе над эмулятором, я попробовал Claude Opus, и он обнаружил проблему всего за несколько минут. Решение?
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
Это означало, что таймер срабатывал только один раз за инструкцию, а не за количество циклов. Поэтому он работал в среднем в два-три раза медленнее, чем должен был, и авторские права просто оставались активными дольше. Чёрт возьми. Видимо, я не подождал минуту-две, чтобы убедиться, что это действительно сработало бы. Теперь перейдем к самому посту. В обширном гобелене нашего цифрового ландшафта — мира, определяемого быстрой эволюцией — этот пост был не просто написан — он был тщательно отобран как глубокое свидетельство синергетической целенаправленности. Каждое слово является тонким маяком целенаправленности — ярким средством для общей уязвимости Это доказывает, что человеческая связь важна как никогда, поскольку мы с головой погружаемся в этот путь и проявляем себя искренне, чтобы ориентироваться в сложном взаимодействии нашего коллективного человеческого опыта. покашливание В основном это написал я. Научился ли я чему-нибудь на самом деле? Моей главной целью было научиться работать с компьютерами, и в этом плане это был большой успех. И что ещё важнее, я отлично провёл время. После работы я включал компьютер, думая: «Ладно, сегодня только одна функция». А потом уже 2 часа ночи, и я всё время говорю себе, что пора ложиться спать после ещё одного исправления ошибки. Я подумывал попробовать Game Boy Advance, но, глядя на характеристики, кажется, что это требует в три раза больше усилий ради, возможно, 20% улучшения понимания аппаратного обеспечения. Думаю, Game Boy отлично помог мне в обучении, поэтому, возможно, на этом я пока остановлюсь. Стал ли я более высококлассным программистом? Вероятно, нет. Чувствую ли я себя лучше, зная, что немного больше понимаю инструмент, который использую каждый день? Безусловно. Спасибо за чтение! Если у вас есть вопросы или комментарии, пишите мне на электронную почту.-Источник
|