[Высокая производительность, Программирование, Анализ и проектирование систем, Облачные сервисы, Микросервисы] Доводим эмуляцию до стандартов 21 века (перевод)

Автор Сообщение
news_bot ®

Стаж: 6 лет 3 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
08-Июл-2021 19:31

Эмуляторы это очаровательная область разработки софта: возможность вдохнуть жизнь в 30-ти летний игровой автомат на современном компьютере это невероятно приятное достижение. Увы, меня всё больше расстраивает отсутствие амбиций в сообществе эмуляторщиков. В том время как остальной мир движется в облака, к массивным распределённым архитектурам, эмуляторы крепко застряли в XX веке с его С++ в один поток. Этот проект родился из желания привнести лучшее из современного дизайна систем обратно в будущее древней истории компьютеров.
Так что же лучшее из современной архитектуры может дать эмуляторам?
  • Горячая замена кода, чтобы позволить отладку игр
  • Разные языки для разных компонентов
  • Безопасность по умолчанию (mTLS для всех вызовов функций)
  • Масштабируемость
  • Отказоустойчивость
  • Родной для облаков подход (cloud native design)
Кульминацией этого проекта стала реализация процессора 8080 с использованием современной, контейнеризованной, микросервисной архитектуры запущенной на Кубернетесе с фронтэндами для тестов CP/Mи полной версии оригинального автомата Space Invaders.Целиком проект можно найти в гитхаб-организации https://github.com/21st-century-emulation, которая содержит 60 отдельных репозиториев, каждая из которых реализует микросервис или предоставляет инфраструктуру. Эта статья описывает подробности технической архитектуры и проблемы с которыми я столкнулся на этом проекте.Ключевые вещи для начала: И наконец, скриншот эмулятора в действии:
Обзор архитектурыКартинка ниже описывает полную архитектурную модель в применении к автомату 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 роута для других сервисов:
  • /api/v1/readByte?id=${cpuId}&address=${u16} - прочитать один байт по переданному адресу
  • /api/v1/writeByte?id=${cpuId}&address=${u16}&value=${u8} - записать один байт по переданному адресу
  • /api/v1/readRange?id=${cpuId}&address=${u16}&length=${u16} - прочитать максимум length байта начиная с адреса (то есть, к примеру, 3 байта которые соответствуют инструкции)
  • /api/v1/initialise?id=${cpuId} - POST принимает base64 строку и использует её для инициализации памяти переданного ЦПУ
Тут простая и быстрая реализация, написанная на Rust с использованием высокотехнологичной in-memory базы данных: https://github.com/21st-century-emulation/memory-bus-8080. Альтернативные реализации с использованием постоянного хранилища оставлены как упражнение читателю. Самым лучшим вариантом скорее всего было бы использовать решение на базе блокчейна. Сервис прерыванийЗапуская сервис 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КГц, что даёт нам кучу времени для отладки, но делает реальную игру слегка затруднительной.Однако, теперь, когда приложение работает, мы можем посмотреть на оптимизацию и вот очевидные будущие пути для этого:
  • Переписать все на Расте. Как видно из картинки ниже, значительную часть времени занимают инструкции LXI и POP. Это понятно, ведь LXI написана на Java/Sping, а POP на Scala/Play. Оба сервиса очевидно на порядок медленнее, чем все остальные языки.
  • JSON -> Avro/Protobuf. Все знают, что сериализация/десериализация JSON слишком медленная для современных приложений, и использование более клёвого, бинарного и упакованного формата очевидно увеличит производительность.
  • Конвейеризация и спекулятивное выполнение
    • Небольшой буст можно получить если просто запускать до N следующий инструкций и сбрасывать конвейер на любой инструкции, которая изменяет счетчик инструкций. Это особенно шикарно, потому что приносит технологию из современных процессоров в древний 8080!
    • Так как внутри все операции асинхронные и ждут ввода-вывода, то мы может элементарно выполнять несколько инструкций в параллель, и дальнейшее улучшение будет следовательно спекулятивно выполнять инструкции и откатывать, если выполнение предыдущей повлияло бы на результат.
  • Кеши
    • Каждый раз ходить в шину памяти очень медленно, отметив какие инструкции могут изменять память, мы может действовать как современная виртуальная машина и кешировать память до момента записи, когда мы инвалидируем кеш и продолжаем. См. картинку ниже демонстрирующую количество запросов к /api/v1/readRangeот цикла fetch execute (который использует этот API чтобы получить следующую инструкцию)
Подробности реализацииОдна из многих прекрасных вещей в микросервисной архитектуре -- благодаря тому, что вызовы функций это теперь HTTP поверх TCP, мы больше не ограничены одним языком в нашем окружении. Это позволяет нам реально воспользоваться лучшим, что современный http api подход предоставляет. Следующая таблица показывает выбор языка для каждого опкода, как вы можете видеть, это позволяет получить преимущества безопасных операций с целыми числами в Rust, в то же время используя надёжность Deno для таких важных операций как CALL и RET. ОпкодЯзыкОписаниеРазмер образаПроизводительность (средняя задержка)MOVSwiftMoves data from one register to another257MB4.68msMVIJavascriptPuts 8 bits into register, or memory118MB3.43msLDAVBPuts 8 bits at location Addr into A Register206MB4.56msSTAC#Stores 8 bits at location Addr206MB4.61msLDAXTypescriptLoads A register with 8 bits from location in BC or DE365MB6.22msSTAXPythonStores A register at location in BC or DE59MB5.24msLHLDRubyLoads HL register with 16 bits found at Addr and Addr+1898MB!13.63msSHLDPerlStores HL register contents at Addr and Addr+1930MB!12.68msLXIJava + SpringLoads 16 bits into B,D,H, or SP415MB6.84msPUSHLuaPuts 16 bits of BP onto stack SP=SP-2385MB4.42msPOPScala + PlayTakes top of stack, puts it in RP SP=SP+2761MB13.99msXTHLDExchanges HL with top of stack156MB26.54msSPHLF#Puts contents of HL into SP (stack pointer)114MB3.25msPCHLKotlinPuts contents of HL into PC (program counter) [=JMP (HL)]445MB7.61msXCHGC++Exchanges HL and DE514MB2.16msADDRustAdd accumulator and register/(HL)123MB1.95msADCRustAdd accumulator and register/(HL) (with carry)123MB2.00msADIRustAdd accumulator and immediate123MB2.16msACIRustAdd accumulator and immediate (with carry)123MB2.22msSUBRustSub accumulator and register/(HL)123MB1.95msSBBRustSub accumulator and register/(HL) (with borrow)123MB1.70msSUIRustSub accumulator and immediate123MB2.15msSBIRustSub accumulator and immediate (with carry)123MB1.91msANARustAnd accumulator and register/(HL)123MB2.68msANIRustAnd accumulator and immediate123MB1.93msXRARustXor accumulator and register/(HL)123MB1.70msXRIRustXor accumulator and immediate123MB1.57msORARust NimOr accumulator and register/(HL)74MB11.36msORIRustOr accumulator and immediate123MB1.40msDAARustDecimal adjust accumulator123MB2.26msCMPRustCompare accumulator and register/(HL)123MB1.70msCPIRustCompare accumulator and immediate123MB1.90msDADPHPAdds contents of register RP to contents of HL register430MB17.2 msINRCrystalIncrements register23MB1.98msDCRCrystalDecrements register23MB2.06msINXCrystalIncrements register pair23MB2.01msDCXCrystalDecrements register pair23MB1.99msJMPPowershellUnconditional Jump to location Addr294MB6.51msCALLDenoUnconditional Subroutine call to location Addr154MB6.04msRETDenoUnconditional return from subroutine154MB6.43msRLCGoRotate left carry6MB2.28msRRCGoRotate right carry6MB2.19msRALGoRotate left accumulator6MB2.39msRARGoRotate right accumulator6MB2.29msINData from Port placed in A registerOUTData from A register placed in PortCMCHaskellComplement Carry Flag90MB2.50msCMAHaskellComplement A register90MB2.54msSTCHaskellSet Carry Flag = 190MB2.52msHLTHalt CPU and wait for interruptNOOPCNo operation70MB1.89msDIDartDisable Interrupts79MB2.37msEIDartEnable Interrupts79MB2.21msRSTDenoCall interrupt vector154MB7.34msNim немного припозднился на вечеринку, так что получил только один опкод, да и всё равно оказался медленным. Подробности о коде
───────────────────────────────────────────────────────────────────────────────
Язык                    Файлов     Строк   Пустых  Комментариев Сложность кода
───────────────────────────────────────────────────────────────────────────────
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)
───────────────────────────────────────────────────────────────────────────────
ПроблемыЕстественно, я столкнулся с множеством новых проблем с этим подходом. Я перечислил некоторые ниже, чтобы дать представление о типах проблем с этой архитектурой
  • Github Actions
    • Случайные таймауты логина в ghcr.io, случайные таймауты заливки образов. В целом просто всякие разные спонтанные ошибки. Это реально продемонстрировало как весело управлять системой разработки для микросервисной архитектуры.
  • Образа компилятора Haskell
    • Ох, блин. Haskell получает награду как мое самое нелюбимое окружение для разработки только за образ SDK размером абсурдные 3.5 гигабайта! Это достаточно много, чтобы было невозможно собирать сервисы на базе хаскеля в CI без тюнинга образа до 3.4 гигов (лимит github actions).
  • Случайные периодические ошибки сети в AKS
    • Хотя AKS и показал надёжность в четыре девятки для всех микросервисов, но там были спонтанные 504-ки между микросервисами.
    • С хороший стороны, так как мы использовали linkerd как сервис меш, чтобы получить безопасные подключения по TCP между микросервисами, это также позволило использовать его автоматические повторы и забыть об этой проблеме! Прямо как и положено в современной архитектуре!
  • Кеширование DNS (или нет)
    • Из всех языков, только у node.js были проблемы, когда она будет долбать DNS сервер натурально при каждом запросе, пока тот не сказал идинах и следующий запрос отвалился #justnodethings
  • Логи на большом масштабе
    • Сначала я поставил Loki как бэкенд для логов, потому что он новый и значит хороший, но потом обнаружил, что библиотеки C# для него периодически отправляют запросы не по порядку и в конце концов Loki сдается и просто перестаёт принимать логи. К счастью, fluentd всё ещё канает по духу проекта и по-настоящему привязывает проект к кубернетесу, так что это очевидно изначально было лучшим решением.
  • Оркестрация изменений между сервисами
    • Странно, но управлять ~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х ускорения:
  • Запись в память по методу "выстрелил и забыл"
    • Записанное в память практически никогда не читается немедленно, так что пихаем команду в шину и не паримся с блокировкой пока она пишется. Может быть часть записей потеряется? Это нормально. Будь как Mongo. fsync для скучных сишников и современные разработчики не обязаны разбираться со всякими стрёмными сложными штуками типа CAP теоремы, в конце концов. Скорее всего кубер с этим разберётся за нас.
  • Мы можем выполнять множество операций в параллель и валидировать правильной результатов потом
    • Это очевидно ускорит операции, такие как memset которые делаются простыми алгоритмами типа `MVI (HL) d8 -> DCX HL -> JNZ` и каждая группа может выполняться параллельно.
  • Если бы каждый опкод знал следующую инструкцию то мы могли бы избежать второй половины каждого раундтрипа и не возвращаться к сервису `fetch execute loop`, пока поток инструкций не закончится.
    • Это практически гарантированное ускорение в 2 раза.
Выводы? Я думаю, это может быть возможно в каких-то идеальных условиях, предполагая почти нулевую задержку по сети, но я не собираюсь больше думать об этом!
===========
Источник:
habr.com
===========

===========
Автор оригинала: David Tyler
===========
Похожие новости: Теги для поиска: #_vysokaja_proizvoditelnost (Высокая производительность), #_programmirovanie (Программирование), #_analiz_i_proektirovanie_sistem (Анализ и проектирование систем), #_oblachnye_servisy (Облачные сервисы), #_mikroservisy (Микросервисы), #_satira (сатира), #_mikroservisy (микросервисы), #_jumor (юмор), #_node.js, #_vysokaja_proizvoditelnost (
Высокая производительность
)
, #_programmirovanie (
Программирование
)
, #_analiz_i_proektirovanie_sistem (
Анализ и проектирование систем
)
, #_oblachnye_servisy (
Облачные сервисы
)
, #_mikroservisy (
Микросервисы
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 14-Май 10:18
Часовой пояс: UTC + 5