Эмуляторы это очаровательная область разработки софта: возможность вдохнуть жизнь в 30-летний игровой автомат на современном компьютере это невероятно приятное достижение. Увы, меня всё больше расстраивает отсутствие амбиций в сообществе эмуляторщиков. В том время как остальной мир движется в облака, к массивным распределённым архитектурам, эмуляторы крепко застряли в XX веке с его С++ в один поток.

Этот проект родился из желания привнести лучшее из современного дизайна систем обратно в будущее древней истории компьютеров.

Так что же лучшее из современной архитектуры может дать эмуляторам?

  • Горячая замена кода, чтобы позволить отладку игр

  • Разные языки для разных компонентов

  • Безопасность по умолчанию (mTLS для всех вызовов функций)

  • Масштабируемость

  • Отказоустойчивость

  • Родной для облаков подход (cloud native design)

Кульминацией этого проекта стала реализация процессора 8080 с использованием современной, контейнеризованной, микросервисной архитектуры запущенной на Кубернетесе с фронтэндами для тестов CP/M и полной версии оригинального автомата Space Invaders.

Целиком проект можно найти в гитхаб-организации, которая содержит 60 отдельных репозиториев, каждая из которых реализует микросервис или предоставляет инфраструктуру. Эта статья описывает подробности технической архитектуры и проблемы с которыми я столкнулся на этом проекте.

Ключевые вещи для начала:

  1. Основанный на реакте дизассемблер 8080, запущенный на github pages

  2. Тестовая обвязка CP/M, использованная для валидации процессора (просто используйте docker-compose up --build для запуска приложения).

  3. Space Invaders UI (запускайте локально с помощью docker-compose up --build или используйте проект из следующего пункта чтобы задеплоить в кубер).

  4. Конфигурация и деплой в Кубернетес

    1. Обратите внимание, что эта репа предполагает, что у вас есть доступ к кластеру, который может выдержать ~200 новых подов.

И наконец, скриншот эмулятора в действии:

Обзор архитектуры

Картинка ниже описывает полную архитектурную модель в применении к автомату Space Invaders, ключевые компоненты затем обрисованы в дальнейших секциях.

Основной цикл Fetch Execute

Все эмуляторы старой школы делятся на два лагеря: или они выполняют инструкции ЦПУ по одной и затем обновляют остальные компоненты или они обновляют все компоненты (включая ЦПУ) каждый такт. 8080 как он использован в автомате space invaders не нуждается в точной эмуляции по тактам, поэтому этот эмулятор использует первый вариант. Сервис Fetch Execute Loop (цикл Получить Выполнить) -- это сервис, который выполняет этот главный цикл и в целом выглядит следующим образом:

while true:
  Call microservice to check if interrupts should occur
    If so then run RST x instruction
  
  Get next instruction from memory bus microservice

  Call corresponding opcode microservice

Это всё. Чтобы реально использовать этот микросервис мы ещё предоставляем команды `/api/v1/start` и `/api/v1/state`, которые запускают новый экземпляр процессора и получают статус текущего ЦПУ соответственно.

Микросервисы опкодов

Каждый опкод соответствует микросервису, который должен иметь POST апи `/api/v1/execute`, принимающий JSON такого формата:

{
  "id": "uuid",
  "opcode": 123, // Текущий опкод, чтобы разделять вызовы к разным командам, например, MOV (MOV B,C or MOV B,D)
  "state": {
    "a": 0,
    "b": 0,
    "c": 0,
    "d": 0,
    "e": 0,
    "h": 0,
    "l": 0,
    "flags": {
      "sign": false,
      "zero": false,
      "auxCarry": false,
      "parity": false,
      "carry": false,
    },
    "programCounter": 100,
    "stackPointer": 1000,
    "cyclesTaken": 2000,
    "interruptsEnabled": false,
  }
}

Шина памяти

Шина памяти нужна, чтобы предоставлять постоянное хранилище для сервисов и должна иметь 4 роута для других сервисов:

  1. /api/v1/readByte?id=${cpuId}&address=${u16} - прочитать один байт по переданному адресу

  2. /api/v1/writeByte?id=${cpuId}&address=${u16}&value=${u8} - записать один байт по переданному адресу

  3. /api/v1/readRange?id=${cpuId}&address=${u16}&length=${u16} - прочитать максимум length байта начиная с адреса (то есть, к примеру, 3 байта которые соответствуют инструкции)

  4. /api/v1/initialise?id=${cpuId} - POST принимает base64 строку и использует её для инициализации памяти переданного ЦПУ

Тут простая и быстрая реализация, написанная на Rust с использованием высокотехнологичной in-memory базы данных. Альтернативные реализации с использованием постоянного хранилища оставлены как упражнение читателю. Самым лучшим вариантом скорее всего было бы использовать решение на базе блокчейна.

Сервис прерываний

Запуская сервис fetch execute loop вы можете по желанию указать (через переменную окружения) адрес сервиса проверки прерываний, который будет вызываться перед выполнением каждой инструкции. Этот API обязан принимать такой же JSON как сервисы опкодов и будет возвращать опциональное значение, которое показывает какую RST инструкцию нужно выполнить (или none, если прерываний не было).

Архитектура развёртывания

Хотя приложение и можно запустить локально с помощью docker-compose, ни один уважающий себя архитектор облачных решений не будет довольствоваться этим из-за рисков связанных с использованием всего одной машины. Следовательно, этот проект также предоставляет чарт helm, который можно найти тут.

Имея этот репозиторий и достаточно большой кластер кубера (прим: мы настоятельно рекомендуем выбрать для этого топового облачного провайдера, такого как IBM), все компоненты можно установить просто запустив ./install.sh.

Архитектура кубернетеса описана в https://github.com/21st-century-emulation/space-invaders-kubernetes-infrastructure/blob/main/README.md, но для краткости ниже диаграмма:

Производительность

В современном дизайне систем всегда необходимо придерживаться модели "сначала заставить работать, затем сделать быстрее" и это то, что этот проект принимает близко к сердцу. В 1974 году, когда 8080 был выпущен, он достигал невероятных 8МГц. Наш новый, современный, контейнеризованный, облачный проект немного не дотягивает до этого с первой попытки. Как видно из скриншота выше, Space Invaders запущенная на кластере AKS работает со скоростью ~1КГц, что даёт нам кучу времени для отладки, но делает реальную игру слегка затруднительной.

Однако, теперь, когда приложение работает, мы можем посмотреть на оптимизацию и вот очевидные будущие пути для этого:

  1. Переписать все на Расте. Как видно из картинки ниже, значительную часть времени занимают инструкции LXI и POP. Это понятно, ведь LXI написана на Java/Sping, а POP на Scala/Play. Оба сервиса очевидно на порядок медленнее, чем все остальные языки.

  2. JSON -> Avro/Protobuf. Все знают, что сериализация/десериализация JSON слишком медленная для современных приложений, и использование более клёвого, бинарного и упакованного формата очевидно увеличит производительность.

  3. Конвейеризация и спекулятивное выполнение

    1. Небольшой буст можно получить если просто запускать до N следующий инструкций и сбрасывать конвейер на любой инструкции, которая изменяет счетчик инструкций. Это особенно шикарно, потому что приносит технологию из современных процессоров в древний 8080!

    2. Так как внутри все операции асинхронные и ждут ввода-вывода, то мы может элементарно выполнять несколько инструкций в параллель, и дальнейшее улучшение будет следовательно спекулятивно выполнять инструкции и откатывать, если выполнение предыдущей повлияло бы на результат.

  4. Кеши

    1. Каждый раз ходить в шину памяти очень медленно, отметив какие инструкции могут изменять память, мы может действовать как современная виртуальная машина и кешировать память до момента записи, когда мы инвалидируем кеш и продолжаем. См. картинку ниже демонстрирующую количество запросов к /api/v1/readRangeот цикла fetch execute (который использует этот API чтобы получить следующую инструкцию)

Подробности реализации

Одна из многих прекрасных вещей в микросервисной архитектуре -- благодаря тому, что вызовы функций это теперь HTTP поверх TCP, мы больше не ограничены одним языком в нашем окружении. Это позволяет нам реально воспользоваться лучшим, что современный http api подход предоставляет.

Следующая таблица показывает выбор языка для каждого опкода, как вы можете видеть, это позволяет получить преимущества безопасных операций с целыми числами в Rust, в то же время используя надёжность Deno для таких важных операций как CALL и RET.

Опкод

Язык

Описание

Размер образа

Производительность (средняя задержка)

MOV

Swift

Moves data from one register to another

257MB

4.68ms

MVI

Javascript

Puts 8 bits into register, or memory

118MB

3.43ms

LDA

VB

Puts 8 bits at location Addr into A Register

206MB

4.56ms

STA

C#

Stores 8 bits at location Addr

206MB

4.61ms

LDAX

Typescript

Loads A register with 8 bits from location in BC or DE

365MB

6.22ms

STAX

Python

Stores A register at location in BC or DE

59MB

5.24ms

LHLD

Ruby

Loads HL register with 16 bits found at Addr and Addr+1

898MB!

13.63ms

SHLD

Perl

Stores HL register contents at Addr and Addr+1

930MB!

12.68ms

LXI

Java + Spring

Loads 16 bits into B,D,H, or SP

415MB

6.84ms

PUSH

Lua

Puts 16 bits of BP onto stack SP=SP-2

385MB

4.42ms

POP

Scala + Play

Takes top of stack, puts it in RP SP=SP+2

761MB

13.99ms

XTHL

D

Exchanges HL with top of stack

156MB

26.54ms

SPHL

F#

Puts contents of HL into SP (stack pointer)

114MB

3.25ms

PCHL

Kotlin

Puts contents of HL into PC (program counter) [=JMP (HL)]

445MB

7.61ms

XCHG

C++

Exchanges HL and DE

514MB

2.16ms

ADD

Rust

Add accumulator and register/(HL)

123MB

1.95ms

ADC

Rust

Add accumulator and register/(HL) (with carry)

123MB

2.00ms

ADI

Rust

Add accumulator and immediate

123MB

2.16ms

ACI

Rust

Add accumulator and immediate (with carry)

123MB

2.22ms

SUB

Rust

Sub accumulator and register/(HL)

123MB

1.95ms

SBB

Rust

Sub accumulator and register/(HL) (with borrow)

123MB

1.70ms

SUI

Rust

Sub accumulator and immediate

123MB

2.15ms

SBI

Rust

Sub accumulator and immediate (with carry)

123MB

1.91ms

ANA

Rust

And accumulator and register/(HL)

123MB

2.68ms

ANI

Rust

And accumulator and immediate

123MB

1.93ms

XRA

Rust

Xor accumulator and register/(HL)

123MB

1.70ms

XRI

Rust

Xor accumulator and immediate

123MB

1.57ms

ORA

Rust Nim

Or accumulator and register/(HL)

74MB

11.36ms

ORI

Rust

Or accumulator and immediate

123MB

1.40ms

DAA

Rust

Decimal adjust accumulator

123MB

2.26ms

CMP

Rust

Compare accumulator and register/(HL)

123MB

1.70ms

CPI

Rust

Compare accumulator and immediate

123MB

1.90ms

DAD

PHP

Adds contents of register RP to contents of HL register

430MB

17.2 ms

INR

Crystal

Increments register

23MB

1.98ms

DCR

Crystal

Decrements register

23MB

2.06ms

INX

Crystal

Increments register pair

23MB

2.01ms

DCX

Crystal

Decrements register pair

23MB

1.99ms

JMP

Powershell

Unconditional Jump to location Addr

294MB

6.51ms

CALL

Deno

Unconditional Subroutine call to location Addr

154MB

6.04ms

RET

Deno

Unconditional return from subroutine

154MB

6.43ms

RLC

Go

Rotate left carry

6MB

2.28ms

RRC

Go

Rotate right carry

6MB

2.19ms

RAL

Go

Rotate left accumulator

6MB

2.39ms

RAR

Go

Rotate right accumulator

6MB

2.29ms

IN

Data from Port placed in A register

OUT

Data from A register placed in Port

CMC

Haskell

Complement Carry Flag

90MB

2.50ms

CMA

Haskell

Complement A register

90MB

2.54ms

STC

Haskell

Set Carry Flag = 1

90MB

2.52ms

HLT

Halt CPU and wait for interrupt

NOOP

C

No operation

70MB

1.89ms

DI

Dart

Disable Interrupts

79MB

2.37ms

EI

Dart

Enable Interrupts

79MB

2.21ms

RST

Deno

Call interrupt vector

154MB

7.34ms

Nim немного припозднился на вечеринку, так что получил только один опкод, да и всё равно оказался медленным.

Подробности о коде


-------------------------------------------------------------------------------
Язык                    Файлов     Строк   Пустых  Комментариев Сложность кода
-------------------------------------------------------------------------------
YAML                       144      6873      859       396     5618          0
Dockerfile                 112      2007      505       353     1149        248
JSON                       106     15383      240         0    15143          0
Shell                       64      2448      369       195     1884        257
Plain Text                  59       404      171         0      233          0
Docker ignore               56       295        0         0      295          0
Markdown                    56       545      165         0      380          0
gitignore                   56       847      129       178      540          0
C#                          35      1803      198        10     1595         51
TypeScript                  22      1335      116        30     1189        275
Rust                        18      1825      241         7     1577         79
TOML                        18       245       20         0      225          0
Java                         7       306       66         0      240          1
Haskell                      6       207       24         0      183          0
Visual Basic                 6       119       24         0       95          0
MSBuild                      5        53       13         0       40          0
Crystal                      4       330       68         4      258          5
Go                           4       255       33         0      222          6
JavaScript                   4       147        8         1      138          5
License                      4        84       16         0       68          0
PHP                          4       147       21        43       83          2
Swift                        4       283       15         4      264          1
C++                          3        32        4         0       28          0
Emacs Lisp                   3        12        0         0       12          0
Scala                        3       112       15         0       97          6
XML                          3        97       13         1       83          0
C Header                     2        24        2         0       22          0
CSS                          2        44        6         0       38          0
Dart                         2        58        6         0       52         18
HTML                         2       361        1         0      360          0
Lua                          2        65        8         0       57         15
Properties File              2         2        1         0        1          0
C                            1        63        8         0       55         18
CMake                        1        68       10        15       43          4
D                            1        71        6         2       63         14
F#                           1        88       11         3       74          0
Gemfile                      1         9        4         0        5          0
Gradle                       1        32        4         0       28          0
Kotlin                       1        40        9         0       31          0
Makefile                     1        16        4         0       12          0
Nim                          1        82        9         0       73          2
Perl                         1        49        6         3       40          4
Powershell                   1        78        9         0       69         10
Python                       1        37        6         0       31          1
Ruby                         1        28        6         0       22          0
SVG                          1         3        0         0        3          0
TypeScript Typings           1         1        0         1        0          0
-------------------------------------------------------------------------------
Всего                      833     37413     3449      1246    32718       1022
-------------------------------------------------------------------------------
Оценочная стоимость разработки   $1,052,256
Оценочное время разработки       14.022330 месяцев
Оценочное количество людей       6.666796
-------------------------------------------------------------------------------
Обработано 1473797 байт, 1.474 мегабайт (SI)
-------------------------------------------------------------------------------

Проблемы

Естественно, я столкнулся с множеством новых проблем с этим подходом. Я перечислил некоторые ниже, чтобы дать представление о типах проблем с этой архитектурой

  1. Github Actions

    1. Случайные таймауты логина в ghcr.io, случайные таймауты заливки образов. В целом просто всякие разные спонтанные ошибки. Это реально продемонстрировало как весело управлять системой разработки для микросервисной архитектуры.

  2. Образа компилятора Haskell

    1. Ох, блин. Haskell получает награду как мое самое нелюбимое окружение для разработки только за образ SDK размером абсурдные 3.5 гигабайта! Это достаточно много, чтобы было невозможно собирать сервисы на базе хаскеля в CI без тюнинга образа до 3.4 гигов (лимит github actions).

  3. Случайные периодические ошибки сети в AKS

    1. Хотя AKS и показал надёжность в четыре девятки для всех микросервисов, но там были спонтанные 504-ки между микросервисами.

    2. С хороший стороны, так как мы использовали linkerd как сервис меш, чтобы получить безопасные подключения по TCP между микросервисами, это также позволило использовать его автоматические повторы и забыть об этой проблеме! Прямо как и положено в современной архитектуре!

  4. Кеширование DNS (или нет)

    1. Из всех языков, только у node.js были проблемы, когда она будет долбать DNS сервер натурально при каждом запросе, пока тот не сказал идинах и следующий запрос отвалился #justnodethings

  5. Логи на большом масштабе

    1. Сначала я поставил Loki как бэкенд для логов, потому что он новый и значит хороший, но потом обнаружил, что библиотеки C# для него периодически отправляют запросы не по порядку и в конце концов Loki сдается и просто перестаёт принимать логи. К счастью, fluentd всё ещё канает по духу проекта и по-настоящему привязывает проект к кубернетесу, так что это очевидно изначально было лучшим решением.

  6. Оркестрация изменений между сервисами

    1. Странно, но управлять ~50 репозиториями было лишь едва сложнее, чем одним. Изменять что-то, чтобы (скажем) добавить флаг interruptsEnabled в ЦПУ нужно было одновременно по всем микросервисам. К счастью, я хорош в написании отвратительных скриптов на баше как и любой уважающий себя девопс инженер.

Неужели это на самом деле возможно?

Ок, если вы дочитали до сюда, то наверное уже догадались, что весь проект что-то типа шутки. Однако, это ещё и интересное умственное упражнение, чтобы оценить реально ли добиться >=2МГц с этой архитектурой.

Стартовая точка для достижения 2МГц: мы должны исполнять одну инструкцию за 2 микросекунды.

2MHz = 2,000,000 тактов в секунду 
Каждая инструкция 4-17 тактов, так что нам нужно в худшем случае выполнять 2,000,000 / 4 = 500,000 инструкций в секунду. 
Это даёт 1/500,000 seconds = ~2?s на операцию

То как реализовано сейчас даёт 3 вызова HTTP на инструкцию, один для получения инструкции, один для выполнения и один для проверки прерываний.

Предположим ради аргумента, что мы сделали такие оптимизации:

  • Уберём проверку прерываний с горячего пути

  • Кешируем весь ROM в сервисе fetch execute loop и предположим, что приложение исполняется из ROM (для Space Invaders так и было)

    • Это приводит нас к ~1 инструкции на операцию

  • Заменим JSON с кодировкой UTF8 на отправку массива байт для состояния ЦПУ

    • Это уменьшает размер запроса до ~256 байт и устраняет всю сериализацию/десериализацию (просто держим указатель на структуру указывающую на массив)

Тогда мы придём к хорошей начальной позиции с одним запросом для каждого опкода. Какая же минимальная стоимость запроса по сети?

Этот ответ из 2015 бенчмаркает локалхост и получает ~2 микросекунды с размером запроса <= 256 байт.

Предполагая, что тот чувак знает о чём говорит, то ответ конкретное нет. Мы никогда не получим требуемую задержку по сети (особенно учитывая так себе сети в облачных датасентрах).

Но давайте не будем сдавать. Мы уже совсем недалеко от требуемой скорости, так что можем постараться выжать нужные 2х.

Несколько мыслей по методам, как добиться этих последних 2х ускорения:

  1. Запись в память по методу "выстрелил и забыл"

    1. Записанное в память практически никогда не читается немедленно, так что пихаем команду в шину и не паримся с блокировкой пока она пишется. Может быть часть записей потеряется? Это нормально. Будь как Mongo. fsync для скучных сишников и современные разработчики не обязаны разбираться со всякими стрёмными сложными штуками типа CAP теоремы, в конце концов. Скорее всего кубер с этим разберётся за нас.

  2. Мы можем выполнять множество операций в параллель и валидировать правильной результатов потом

    1. Это очевидно ускорит операции, такие как memset которые делаются простыми алгоритмами типа `MVI (HL) d8 -> DCX HL -> JNZ` и каждая группа может выполняться параллельно.

  3. Если бы каждый опкод знал следующую инструкцию то мы могли бы избежать второй половины каждого раундтрипа и не возвращаться к сервису `fetch execute loop`, пока поток инструкций не закончится.

    1. Это практически гарантированное ускорение в 2 раза.

Выводы? Я думаю, это может быть возможно в каких-то идеальных условиях, предполагая почти нулевую задержку по сети, но я не собираюсь больше думать об этом!