Найти тему
Nuances of programming

Игра на C# меньше 8 Кб

Оглавление

Источник: Nuances of Programming

Как уменьшить размер исполняемого файла C#?

Как человеку, выросшему во времена дискет и 56 Кбит модемов, мне всегда нравились небольшие программы. Я мог поместить много небольших программ на дискету, которую носил с собой. Если программа не помещалась на моем гибком диске, я начинал думать, почему: много графики? Музыка? Программа сложная или просто раздулась?

В наши дни дисковое пространство стало настолько дешевым (а огромные флешки настолько вездесущими), что люди отказались от оптимизации размера.

Единственная область, где размер еще имеет значение  —  это передача: при передаче программы по проводу мегабайты приравниваются к секундам. Быстрое соединение на 100 Мбит может пропускать только 12 мегабайт в секунду в лучшем случае. Когда на другом конце провода находится человек, ожидающий завершения загрузки, разница между пятью и одной секундами может оказать существенное влияние на восприятие. Человек может зависеть от времени передачи либо напрямую: он загружает программу по сети, либо косвенно  —  бессерверная служба развертывается для ответа на веб-запрос.

Люди обычно воспринимают что-то быстрее 0,1 секунды как мгновенное. 3 секунды  —  примерно предел непрерывности потока пользователя, и вам было бы трудно удержать пользователя после 10 секунд.

Хотя меньший размер больше не является существенным, он всё равно лучше.

Эта статья вышла как эксперимент, чтобы выяснить, насколько маленьким может быть полезный автономный исполняемый файл C. Могут ли приложения C# достичь размеров, при которых пользователи посчитают время загрузки мгновенным? Возможно ли использовать C# в тех местах, где язык не используется сейчас?

Что такое автономность?

Автономное приложение  —  это приложение, включающее в себя все необходимое для запуска на операционной системе. Компилятор C# принадлежит к группе компиляторов, нацеленных на виртуальную машину (Java и Kotlin  —  другие заметные члены группы). Выходные данные компилятора C#  —  это исполняемый файл, требующий выполнения некоторой виртуальной машины. Нельзя просто установить чистую операционную систему и ожидать, что на ней можно запускать программы, созданные компилятором C.

По крайней мере Windows  —  тот случай, когда можно полагаться на машинную установку .NET Framework для запуска выходных данных компилятора C. В настоящее время есть много инструментов без этого ограничения: IoT Nano Server, ARM64. Платформа .NET Framework также не поддерживает последние усовершенствования языка C.

Для автономности приложение на C# должно включать среду выполнения и все используемые библиотеки классов. Это много, чтобы вписаться в планируемые 8 Кб!

Игра меньше 8 Кб

Мы создадим клон змейки:

-2

Не интересна игровая механика? Не стесняйтесь переходить к интересным частям, где мы сжимаем игру с 65 мегабайт до 8 килобайт за 9 шагов, прокрутите до графика.

Игра будет работать в текстовом режиме, и мы используем поле рисования символов, чтобы нарисовать змею. Я уверен, что Vulcan или DirectX намного веселее, но мы справимся и с System.Console.

Игра без выделения памяти

Мы собираемся создать игру без выделения памяти, и под этим я не имею в виду “не выделять память в цикле игры”, распространённое среди разработчиков игр C. Я имею в виду запрет ключевого слова new со ссылочными типами во всей кодовой базе. Причины станут очевидны на заключительном шаге сжатия игры.

При таком ограничении можно задаться вопросом, есть ли вообще смысл в использовании C: без new мы не будем использовать сборщик мусора, не сможем выбрасывать исключения и т.д. То есть язык С будет работать так же хорошо.

Одна из причин использования C#  —  “потому что это возможно”. Другая причина  —  тестируемость и общий доступ к коду. Хотя игра не выделяет память для ссылочных типов, это не означает, что ее части не могут повторно использоваться в другом проекте, не имеющем таких ограничений. Например, части игры могут быть включены в проекта xUnit, чтобы покрыть приложение юнит-тестами. Если кто-то выбирает C для сборки игры, всё ограничено возможностями C, даже если код используется повторно. Но поскольку C# обеспечивает хорошее сочетание конструкций высокого и низкого уровня абстракций, мы можем использовать высокий уровень по умолчанию, а низкий уровень при необходимости. Для достижения размера развертывания в 8 Кб потребуется низкоуровневая часть.

Структура игры

Начнем со структуры буфера кадров. Буфер кадров  —  это компонент, содержащий пиксели (или в данном случае  —  символы), отображаемые на экране:

-3
-4

Мы предоставляем методы установки отдельных пикселей, очистки и визуализации содержимого буфера кадров System.Console. Шаг рендеринга особых случаев несколько символов, так что мы получаем красочный вывод без необходимости отслеживать цвет каждого пикселя буфера кадров.

Одна интересная вещь  —  поле fixed char _chars[Area]: это синтаксис C# для объявления фиксированного массива . Фиксированный массив  —  это массив, отдельные элементы которого являются частью структуры. Вы можете думать об этом как о ярлыке для набора полей char _char_0, _char_1, _char_2, _char_3,... _char_Area, к которым можно получить доступ как в массиве. Размер этого массива должен быть постоянной времени компиляции, чтобы размер всей структуры был фиксированным.

Мы не можем переборщить с размером фиксированного массива, потому что как часть структуры массив должен жить в стеке, а стеки, как правило, ограничены небольшим количеством байтов (обычно 1 Мб на поток) и 40*20*2 width*height* sizeof(char)  —  допустимое число.

Следующее, что нам нужно  —  генератор случайных чисел. Поставляемый с .NET генератор является ссылочным типом по уважительным причинам, а мы запретили себе new. Но простую struct сделаем:

-5

Этот генератор не слишком хорош, но нам не нужно ничего сложного. Теперь пишем обёртку для логики игры:

-6
-7
-8
-9

Состояния, которые должна отслеживать змейка:

  • Координаты каждого пикселя тела.
  • Длина змейки.
  • Текущее направление движения.
  • Прошлое направление для случая, когда нужно нарисовать символ изгиба вместо прямой линии.

Структура предоставляет метод расширения змеи на один элемент (возвращает false, если змея уже находится на полной длине), метод HitTest для теста столкновений пикселя тела, отрисовки змейки во FrameBuffer и метод обновления положения змеи как ответ на игровой тик (возвращает false, если змея съела себя). Существует также свойство, чтобы установить направление змеи.

Мы используем тот же трюк с фиксированным массивом, что и в буфере кадров, чтобы не использовать new. Это означает, что максимальная длина змеи должна быть постоянной времени компиляции. Последнее, что нам нужно  —  игровой цикл:

-10
-11
-12

Мы используем генератор случайных чисел для генерации случайного положения и направления змеи. Мы случайным образом размещаем еду на игровой поверхности так, чтобы она не перекрывала змею, и запускаем цикл игры. Внутри игрового цикла мы просим змею обновить свое положение и проверить, съела ли она сама себя. Затем рисуем змею, проверяем клавиатуру на ввод, тестируем змею с едой и отображаем всё на консоль. Давайте посмотрим, где мы находимся с точки зрения размера.

Размер змейки в .NET Core 3.0 по умолчанию

Я поместил игру в репозиторий GitHub, чтобы вы могли следить за ней. Файл проекта собирает игру в различных конфигурациях в зависимости от переданного при публикации свойства Mode . Чтобы создать конфигурацию по умолчанию с помощью CoreCLR, выполните:

dotnet publish -r win-x64 -c Release

Это приведет к созданию одного EXE-файла, имеющего колоссальный размер в 65 МБ. Этот файл включает в себя игру, рантайм .NET и библиотеки базовых классов, являющиеся стандартной частью .NET. Вы можете сказать: “Лучше, чем Electron”, но давайте посмотрим, сможем ли мы уменьшить размер.

-13

Il Linker

IL Linker  —  это инструмент, поставляемый с .NET Core 3.0. Он удаляет неиспользуемые сборки. Чтобы включить его в проект, передайте свойство PublishTrimmed при публикации:

dotnet publish -r win-x64 -c Release /p:PublishTrimmed=true

С этой настройкой игра сжимается до 25 МБ. Хорошее сокращение, но оно далеко от нашей цели.

IL Linker имеет более агрессивные настройки, не выставляемые публично, и они могут работать дальше, но в конце концов, мы ограничимся размером самой среды выполнения CoreCLR coreclr.dll в 5,3 Мбайт. Возможно, мы зашли в тупик на пути к игре на 8 Кб?

-14

Моно

Mono  —  еще одна среда выполнения .NET, для многих  —  синоним Xamarin. Чтобы создать исполняемый файл C#, мы используем mkbundle, поставляемый с Mono:

mkbundle SeeSharpSnake.dll --simple -o SeeSharpSnake.exe

Команда создаст исполняемый файл размером 12,3 МБ, зависящий от mono-2.0-sgen.dll, а она сама по себе весит 5,9 МБ, так что мы получили 18,2 MB в общей сложности. При попытке запустить его я получил сообщение Error mapping file: mono_file_map_error failed, но это ожидаемый баг. За исключением этой ошибки, все работает и наш результат  —  18,2 МБ.

В отличие от CoreCLR, Mono также зависит от распространяемой библиотеки среды выполнения Visual C++, недоступной в установке Windows по умолчанию: чтобы сохранить автономность приложения, нам нужно зашить эту библиотеку в приложение. Это увеличивает объем ещё на один мегабайт или около того.

Мы, вероятно, сможем сделать приложение меньше, добавив Il Linker, но тогда столкнемся с той же проблемой, что и с CoreCLR  —  размером среды выполнения mono-2.0-sgen.dll, он составляет 5,9 МБ. Плюс размер библиотек времени выполнения C++ поверх него. Это предел оптимизаций уровня IL.

-15

Среда выполнения

Чтобы получить размер 8 Кб, нам нужно изъять из приложения среду выполнения или её часть. Единственная среда выполнения .NET, где это возможно  —  CoreRT. Хотя обычно CoreRT называют “средой выполнения”, она ближе к тому, чтобы быть “библиотекой среды выполнения”. Это не виртуальная машина, как CoreCLR или Mono. Среда выполнения CoreRT  —  это просто набор функций, поддерживающих заранее созданный компилятором Core RT машинный код.

CoreRT поставляется с библиотеками, делающими CoreRT похожим на любую другую среду выполнения .NET: есть библиотека, добавляющая GC, библиотека поддержки рефлексии, библиотека, добавляющая JIT, библиотека, добавляющая интерпретатор и т.д. Но все они необязательны, включаяGC.

Давайте посмотрим, где мы находимся теперь с конфигурацией CoreRT по умолчанию:

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT

4,7 МБ. Файл пока самый маленький, но этого недостаточно.

-16

Умеренная экономия размера в CoreRT

Компилятор CoreRT предлагает огромное количество настроек, влияющих на генерацию кода. По умолчанию компилятор пытается максимизировать скорость сгенерированного кода и совместимость с другими средами выполнения .NET за счет размера сгенерированного исполняемого файла.

Компилятор имеет встроенный компоновщик, удаляющий неиспользуемый код. Параметр “CoreRT-Moderate”, определяемый в проекте Snake, ослабляет одно из ограничений на удаление неиспользуемого кода, что позволяет удалить ещё больше. Мы также просим компилятор обменять скорость программы на дополнительные байты. Большинство программ .NET работает просто отлично в этом режиме.

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-Moderate

Сейчас мы на уровне 4,3 МБ.

-17

Включение сильной экономии в CoreRT

Я сгруппировал еще несколько вариантов компиляции в режим “сильной экономии”. Режим удаляет поддержку возможностей, важных для многих приложений, но не для нашей змейки. Мы удаляем:

  • Данные трассировки стека для деталей реализации фреймворка.
  • Сообщения об исключениях в рамках фреймворка.
  • Поддержку неанглийских языков.
  • Инструментарий EventSource.

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-High

Мы достигли 3,0 МБ, это 5% от начального размера, но у CoreRT есть еще один трюк.

-18

Отключение рефлексии

Существенная часть библиотек времени выполнения CoreRT посвящена реализации рефлексии .NET. Поскольку CoreRT  —  это заранее скомпилированная реализация .NET на основе библиотеки времени выполнения, она не нуждается в большинстве структур данных, необходимых типичной среде выполнения на основе виртуальной машины. Эти данные включают такие вещи, как имена типов, методы, подписи, базовые типы и т.д. CoreRT внедряет эти данные, потому что они нужны программам, использующим рефлексию .NET, но не потому, что это необходимо для работы среды выполнения. Я называю эти данные “налогом на рефлексию”, потому что это то, что нужно для выполнения.

CoreRT поддерживает режим без рефлексии, позволяющий избежать оверхеда. Вы можете чувствовать, что много кода .NET не работает без рефлексии, и вы правы, но удивительное количество вещей всё-таки работает: Gui.cs, System.IO.Pipelines или даже базовое приложение WinForms. Змейка точно заработает, так что включаем этот режим:

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-ReflectionFree

Сейчас мы на уровне 1,2 МБ. Оверхед на рефлексию довольно значителен.

-19

Пачкаем руки

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

Как мы уже видели ранее, CoreRT — это набор библиотек времени выполнения в сочетании с опережающим компилятором. Что делать, если мы заменим библиотеки времени выполнения с минимальным переопределением? Мы решили не использовать сборщик мусора, и это делает работу намного более выполнимой. Начнём с простого:

-20

Мы просто переопределили Thread.SleepиEnvironment.TickCount64(для Windows), избегая всех зависимостей от существующей библиотеки времени выполнения. Делаем то же самое для подмножества System.Console, используемого игрой:

-21
-22
-23
-24
-25

Пересоберём игру с заменой фреймворка:

dotnet publish-r win-x64-C Release / p: Mode=CoreRT-ReflectionFree /p: IncludePal=true

Неудивительно, что это не слишком эффективно. Заменяемые API уже относительно легки, переписывание только добавляет пару килобайт, о которых не стоит упоминать. Но это важная ступенька к последнему шагу нашего путешествия.

Замена библиотек среды выполнения

Оставшиеся 1,2 МБ кода и данных в игре  —  это поддержка вещей, которые мы не видим, но они есть, они готовы, если нам понадобятся. Есть сборщик мусора, поддержка обработки исключений, код для форматирования и печати трассировок стека на консоль, когда происходит необработанное исключение, и многие другие вещи под капотом.

Компилятор может обнаружить, что ничего этого не требуется, и избежать их генерации, но то, что мы пытаемся сделать, настолько странно, что неплохо добавить функции компилятора для его поддержки. Способ избежать этого  —  просто предоставить альтернативную библиотеку времени выполнения. Начнем с переопределения минимальной версии базовых типов:

-26
-27
-28

Теперь откажемся от файла проекта и dotnet CLI, запустим отдельные инструменты напрямую. Мы начинаем с запуска компилятора C# (CSC). Я рекомендую запускать эти команды из “x64 Native Tools Command Prompt for VS 2019” — он находится в меню Пуск, если у вас установлена Visual Studio.

/noconfig,/nostdlib, и /runtimemetadataversion  —  волшебные параметры, необходимыми для компиляции чего-то, определяющего System.Object. Я выбрал расширение .ilexe, потому что .exe мы используем для готового продукта.

csc.exe /debug /O /noconfig /nostdlib /runtimemetadataversion:v4.0.30319 MiniBCL.cs Game\FrameBuffer.cs Game\Random.cs Game\Game.cs Game\Snake.cs Pal\Thread.Windows.cs Pal\Environment.Windows.cs Pal\Console.Windows.cs /out:zerosnake.ilexe /langversion:latest /unsafe

Это позволит успешно скомпилировать версию байт-кода IL игры с компилятором C. Нам все еще нужна какая-то среда выполнения, чтобы выполнить приложение. Попробуем передать это в CoreRT для создания нативного кода из IL. Если вы выполнили описанные выше шаги, вы найдете ilc.exe, компилятор CoreRT, в вашем кэше пакетов NuGet где-то в %USERPROFILE%\.nuget\packages\runtime.win-x64.microsoft.dotnet.ilcompiler\1.0.0-alpha-27402–01\Tools.

ilc.exe zerosnake.ilexe -o zerosnake.obj --systemmodule zerosnake --Os -g

Произойдет сбой Expected type ‘Internal.Runtime.CompilerHelpers.StartupCodeHelpers’ not found in module ‘zerosnake’. Оказывается, помимо очевидного минимума, ожидаемого разработчиком управляемого кода, есть также минимум, в котором компилятор CoreRT нуждается для компиляции ввода. Добавим необходимое:

-29
-30

Перестроим IL с добавленным кодом и повторно запустим ILC.

csc.exe /debug /O /noconfig /nostdlib /runtimemetadataversion:v4.0.30319 MiniRuntime.cs MiniBCL.cs Game\FrameBuffer.cs Game\Random.cs Game\Game.cs Game\Snake.cs Pal\Thread.Windows.cs Pal\Environment.Windows.cs Pal\Console.Windows.cs /out:zerosnake.ilexe /langversion:latest /unsafeilc.exe zerosnake.ilexe -o zerosnake.obj --systemmodule zerosnake --Os -g

Теперь у нас есть zerosnake.obj  —  стандартный объектный файл, который ничем не отличается от объектных файлов, создаваемых другими нативными компиляторами, такими как C или C++. Последний шаг  —  связать его. Воспользуемся link.exe, он должен быть в “x64 Native Tools Command Prompt”. Возможно, вам потребуется установить средства разработки C/C++ в Visual Studio.

link.exe /debug:full /subsystem:console zerosnake.obj /entry:__managed__Main

Имя символа __managed__Main является контрактом с компилятором — это имя управляемой точки входа программы, созданной ILC. Но команда не работает:

-31

Некоторые из этих символов кажутся знакомыми: компоновщик не знает, где искать вызываемые API Windows. Добавим библиотеки для них:

link.exe /debug:full /subsystem:console zerosnake.obj /entry:__managed__Main kernel32.lib ucrt.lib

Выглядит лучше, всего 4 неразрешенных символа:

-32

Остальные отсутствующие символы  —  это помощники, которые компилятор ожидает найти в библиотеке времени выполнения. Их отсутствие обнаруживается только во время связывания, потому что эти помощники обычно реализуются в сборке, и компилятор ссылается на них только по их символическому имени в отличие от других типов и методов, необходимых компилятору и предоставленных нами выше.

Помощники добавляют и удаляют кадры стека, когда машинный код вызывается управляемым или управляемый  —  машинным. Это необходимо, чтобы работала сборка мусора. Поскольку у нас нет GC, давайте заглушим их кодом C# и другим волшебным атрибутом, который поймёт наш компилятор:

-33

После перестроения исходного кода с этими изменениями и повторного запуска ILC, связывание, наконец, будет успешным. Мы сейчас на 27 килобайтах. Игра работает!

-34

Возня с линкером

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

  • Отключить инкрементное связывание.
  • Обрезать информацию о релокации.
  • Объединить похожие разделы внутри исполняемого файла.
  • Установить внутреннее выравнивание в небольшое значение

link.exe /debug:full /subsystem:console zerosnake.obj /entry:__managed__Main kernel32.lib ucrt.lib /merge:.modules=.rdata /merge:.pdata=.rdata /incremental:no /DYNAMICBASE:NO /filealign:16 /align:16

8176 байт! Игра все еще работает и, что интересно, она полностью отлаживаема. Вы можете отключить оптимизацию в ILC, чтобы сделать исполняемый файл еще более отладочным: просто удалите аргумент --Os.

-35

Ещё меньше?

Исполняемый файл ещё содержит несущественные данные  —  компилятор ILC просто не предоставляет параметры командной строки, отключающие их генерацию.

Одна из этих избыточных структур данных  —  информация GC для отдельных методов. В CoreRT есть точный сборщик мусора, требующий, чтобы каждый метод описывал, где находятся ссылки на кучу GC в каждой инструкции тела метода. 

Поскольку у нас нет сборщика мусора, эти данные не нужны. Другие среды выполнения  —  например Mono  —  используют консервативный сборщик, не требующий этих данных. Он просто предполагает, что любая часть стека и регистров процессора может быть ссылкой GC. Консервативный сборщик торгует производительностью GC ради экономии размера. Точный сборщик CoreRT также может работать в консервативном режиме, но он еще не подключен. Это потенциальное будущее дополнение, которое мы могли бы использовать, чтобы сделать программу ещё меньше. Может, однажды мы сможем сделать упрощенную версию нашей игры в 512 байт загрузочного сектора. А до тех пор  —  счастливого кода!

Читайте также:

Читайте нас в телеграмме и vk

Перевод статьи Michal Strehovský: Building a self-contained game in C# under 8 kilobytes