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

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

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

Человек может быть подвержен влиянию времени передачи либо напрямую (пользователь загружает программу по сети), либо косвенно (бессерверная служба развертывается для ответа на веб-запрос).

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

Меньше уже не так важно, но все равно лучше.

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

Что такое «самодостаточный»?

Автономное приложение - это приложение, которое включает в себя все необходимое для работы в стандартной версии операционной системы.

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

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

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

Игра 8 КБ

Мы собираемся создать клон игры «Змейка». Вот готовый продукт:

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

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

Игра без распределения

Мы собираемся создать игру без выделения памяти - и под без выделения я не имею в виду «не выделять в игровом цикле», который распространен среди разработчиков игр на C #. Я имею в виду «ключевое слово new со ссылочными типами запрещено во всей кодовой базе». Причины этого станут очевидными на последнем отрезке сокращения игры.

При таком ограничении можно задаться вопросом, есть ли вообще смысл в использовании C #: без ключевого слова new мы не будем использовать сборщик мусора, мы не сможем генерировать исключения и т. Д. - такой язык, как C, будет работать. так же, как и.

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

Чтобы достичь размера развертывания 8 КБ, потребуется часть низкого уровня.

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

Начнем с struct, представляющего буфер кадра. Буфер кадра - это компонент, который содержит пиксели (или в данном случае символы), которые будут отображаться на экране.

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

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

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

Следующее, что нам понадобится, это генератор случайных чисел. Тот, который поставляется с .NET, является ссылочным типом (по уважительным причинам!), И мы запрещаем себе ключевое слово new - мы не можем его использовать. Простой struct подойдет:

Этот генератор случайных чисел не очень хорош, но нам не нужно ничего сложного.

Теперь нам нужно только что-то, что обертывает змеиную логику. Время для Snake структуры:

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

  • координаты каждого пикселя, представляющего тело змеи,
  • текущая длина змеи,
  • текущее направление змеи,
  • прошлое направление змеи (в случае, если нам нужно нарисовать символ «изгиба» вместо прямой линии)

Змея предоставляет методы для Extend длины змеи на единицу (возвращает false, если змея уже полностью), для HitTest пикселя с телом змеи, Draw змеи в FrameBuffer и Update положение змеи в качестве ответа к игровому тику (возвращает false, если змея себя съела). Также есть свойство для установки текущего Course змеи.

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

Последнее, что нам понадобится, это игровой цикл:

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

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

Вот и все. Давайте посмотрим, где мы находимся с точки зрения размеров.

Размер по умолчанию для .NET Core 3.0 Snake

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

dotnet publish -r win-x64 -c Release

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

Компоновщик IL

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

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

При этой настройке размер игры уменьшается до 25 МБ. Это хорошее сокращение на 60%, но это далеко от нашей цели в 10 КБ.

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

Небольшой объезд: моно

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

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

В результате будет создан исполняемый файл размером 12,3 МБ, зависящий от файла mono-2.0-sgen.dll, который сам имеет 5,9 МБ, так что всего мы получаем 18,2 МБ. При попытке запустить его, я нажимаю «Ошибка сопоставления файла: сбой mono_file_map_error», но я верю, что, за исключением этой ошибки, все будет работать с Mono, и результат будет 18,2 МБ.

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

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

Можем ли мы убрать время выполнения?

Понятно, что для того, чтобы приблизиться к цели в 8 КБ, нам нужно убрать время выполнения из приложения. Единственная среда выполнения .NET, где это возможно, - CoreRT. Хотя CoreRT принято называть средой выполнения, это ближе к библиотеке времени выполнения. Это не виртуальная машина, такая как CoreCLR или Mono. Среда выполнения CoreRT - это просто набор функций, которые поддерживают заранее сгенерированный собственный код, созданный опережающим компилятором CoreRT.

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

Подробнее о том, чем CoreRT отличается от CoreCLR и Mono, читайте в этой статье. Когда я читал о среде исполнения языка D, это очень напомнило мне CoreRT. Статью тоже интересно.

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

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

Это составляет 4,7 МБ. Пока что он самый маленький, но все же недостаточно хорош.

Включение умеренной экономии размера в CoreRT

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

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

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

Теперь у нас 4,3 МБ.

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

Я сгруппировал еще пару вариантов компиляции в режим «высокой экономии». В этом режиме будет удалена поддержка вещей, которые заметят многие приложения, но Snake (так как это низкоуровневая вещь) - нет.

Мы собираемся удалить:

  • Данные трассировки стека для деталей реализации фреймворка
  • Сообщения об исключениях в исключениях, созданных фреймворком
  • Поддержка неанглийских языков
  • Инструментарий EventSource
dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-High

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

Отключение отражения

Значительная часть библиотек времени выполнения CoreRT посвящена реализации области поверхности отражения .NET. Поскольку CoreRT представляет собой скомпилированную ранее реализацию .NET на основе библиотеки времени выполнения, ему не требуется большая часть структур данных, которые требуются типичной среде выполнения на основе виртуальных машин (например, CoreCLR и Mono). Эти данные включают в себя такие вещи, как имена типов, методов, сигнатур, базовых типов и т. Д. CoreRT встраивает эти данные потому, что они нужны программам, использующим отражение .NET, а не потому, что они необходимы для работы среды выполнения. Я называю эти данные «налогом на отражение», потому что это то, что нужно для среды выполнения.

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

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

Сейчас у нас 1,2 МБ. Налог на отражение - довольно высокий налог!

Пачкаем руки

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

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

Начнем с простых вещей:

Вот так - мы просто повторно реализовали Thread.Sleep и Environment.TickCount64 (для Windows), избегая при этом всех зависимостей от существующей библиотеки времени выполнения.

Сделаем то же самое для подмножества System.Console, которое используется в игре:

Давайте перестроим игру с помощью этой замены фреймворка:

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

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

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

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

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

Начнем с переопределения минимальной версии базовых типов:

На этом этапе давайте откажемся от файла проекта и интерфейса командной строки dotnet и запустим отдельные инструменты напрямую. Начнем с запуска компилятора C # (CSC). Я рекомендую запускать эти команды из «командной строки x64 Native Tools для VS 2019» - она ​​находится в меню «Пуск», если у вас установлена ​​Visual Studio. Правильная версия инструментов находится в ПУТИ в этом окне.

/noconfig, /nostdlib и /runtimemetadataversion - это волшебные переключатели, необходимые для компиляции чего-то, что определяет System.Object. Я выбрал расширение файла .ilexe вместо .exe, потому что .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

Произойдет сбой, если «Ожидаемый тип Internal.Runtime.CompilerHelpers.StartupCodeHelpers» не найден в модуле «zerosnake». Оказывается, помимо очевидного минимума, которого ожидает управляемый разработчик, существует еще минимум, необходимый компилятору CoreRT для компиляции входных данных.

Давайте перейдем к делу и добавим то, что нужно:

Давайте перестроим байт-код 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 /unsafe
ilc.exe zerosnake.ilexe -o zerosnake.obj --systemmodule zerosnake --Os -g

Теперь у нас есть zerosnake.obj - стандартный объектный файл, который ничем не отличается от объектных файлов, созданных другими собственными компиляторами, такими как C или C ++. Последний шаг - это привязка. Мы будем использовать инструмент link.exe, который должен быть в PATH нашей «x64 Native Tools Command Prompt» (вам может потребоваться установить инструменты разработки C / C ++ в Visual Studio).

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

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

Но не работает:

error LNK2001: unresolved external symbol RhpPInvoke
error LNK2001: unresolved external symbol SetConsoleTextAttribute
error LNK2001: unresolved external symbol WriteConsoleW
error LNK2001: unresolved external symbol GetStdHandle
...
fatal error LNK1120: 17 unresolved externals

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

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

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

error LNK2001: unresolved external symbol RhpPInvoke
error LNK2001: unresolved external symbol RhpPInvokeReturn
error LNK2001: unresolved external symbol RhpReversePInvoke2
error LNK2001: unresolved external symbol RhpReversePInvokeReturn2
fatal error LNK1120: 4 unresolved externals

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

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

После перекомпоновки исходного кода C # с этими изменениями и повторного запуска ILC компоновка, наконец, будет успешной.

Сейчас у нас 27 килобайт, а игра все еще работает!

Возиться с компоновщиком

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

Шли в:

  • Отключить добавочное связывание
  • Информация о перемещении полосы
  • Объединить похожие разделы в исполняемом файле
  • Установите внутреннее выравнивание в исполняемом файле на небольшое значение
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 байт!

Игра по-прежнему работает, и, что интересно, она по-прежнему полностью отлажена - не стесняйтесь открывать EXE в Visual Studio (Файл - ›Открыть решение), откройте один из файлов C #, которые являются частью игры, установите в нем точку останова, нажмите F5, чтобы запустить EXE, и увидеть, как срабатывает точка останова. Вы можете отключить оптимизацию в ILC, чтобы сделать исполняемый файл еще более отлаживаемым - просто отбросьте аргумент --Os.

Можем ли мы сделать вещи меньше этого?

Исполняемый файл по-прежнему содержит некоторые данные, которые не являются важными - компилятор ILC просто не предоставляет параметры командной строки, чтобы отключить их создание.

Одна из тех структур данных, которые генерируются, но нам не нужны, - это информация GC для отдельных методов. CoreRT имеет точный сборщик мусора, который требует, чтобы каждый метод описывал, где ссылки на кучу сборщика мусора находятся в каждой инструкции тела метода. Поскольку в игре Snake нет сборщика мусора, эти данные не нужны. Другие среды выполнения (например, Mono) используют консервативный сборщик мусора, который не требует этих данных (он просто предполагает, что любая часть стека и регистры ЦП могут быть ссылкой на сборщик мусора) - консервативный сборщик мусора жертвует производительностью сборщика мусора для дополнительной экономии размера. Точный сборщик мусора, используемый в CoreRT, также может работать в консервативном режиме, но он еще не подключен. Это потенциальное дополнение к будущему, которое мы могли бы затем использовать, чтобы сделать вещи еще меньше.

Возможно, однажды мы сможем сделать упрощенную версию нашей игры, умещающуюся в 512-байтовом загрузочном секторе. А пока желаю удачного взлома!