Я написал на C# консольный клон Wordle, но попытался сделать двоичный файл максимально крошечным.
Я взял популярную игру Wordle с такими правилами:

Для каждой попытки уменьшения двоичного файла мы создадим отдельный проект, чтобы удобно было сравнивать предпринятые действия.
В работе с консолью мне помогла игра Console Games - Snake
Проект запускается в новой консоли dotnet.
Краткое описание игры

Почему все попытки я помещаю в разные папки, а не создаю ветви? Честно говоря, я не думал, что это зайдёт так далеко, поэтому изначально код нескольких попыток просто слился в одну. Но это очень интересный проект, поэтому я продолжал работу.
Уменьшение
Идею с ужиманием двоичного файла я хотел попробовать реализовать, прочитав потрясающий пост Building a self-contained game in C# under 8 kilobytes Михала Стреховски. Его исходники можно посмотреть в репозитории GitHub: SeeSharpSnake
Результатом каждой попытки уменьшения размера будет опубликованный файл .exe в режиме Release. Исполняемый файл будет способен запускаться автономно, без необходимости .NET на машине. Этого можно достичь, выполнив публикацию как Single File Application при помощи изменения файла .csproj:
<PropertyGroup>
<PublishSingleFile>true</PublishSingleFile>
</PropertyGroup>
Однако нашей целевой платформой будет Windows x64. И чтобы убедиться, что проект работает, я после каждой попытки буду запускать игру и проверять её на работоспособность.
Оригинал
В папке оригинала 00 находится проект, предназначенный в первую очередь для создания работающей игры без размышлений об эффективности. Он оставлен без изменений, со всеми недостатками. В нём есть неэффективные части, бесполезные присваивания и так далее. Но это часть игры, файл которой мы будем ужимать — нам нужно на чём-то основываться.
dotnet publish -r win-x64 -c Release
Total binary size: 62,091 KB
Попытка 1 (-50902 КБ)
Наверно, наибольший выигрыш можно получить, настроив опции обрезки.
Мы можем выполнять обрезку (trimming), или добавляя её, как аргумент публикации, или с помощью файла .csproj. Я решил добавить PublishTrimmed в файл .csproj:
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>
dotnet publish -r win-x64 -c Release
Total binary size: 11,189 KB
Попытка 2 (-0 КБ)
Также мы можем настроить опции обрезки, и проще всего будет переключиться в TrimMode. Однако из двух опций:
-
link
-
copyused
.NET6 по умолчанию использует более агрессивную. Это значит, что мы не увидим никаких изменений размера, даже если в явном виде зададим этот режим в файле .csproj.
<PropertyGroup>
<TrimMode>link</TrimMode>
</PropertyGroup>
dotnet publish -r win-x64 -c Release
Total binary size: 11,189 KB
Попытка 3 (- 16 КБ)
Продолжив обрезку, мы можем обрезать ассемблерный код, чтобы сэкономить ещё немного места.
<ItemGroup>
<TrimmableAssembly Include="TinyWordle" />
</ItemGroup>
dotnet publish -r win-x64 -c Release
Total binary size: 11,173 KB
Попытка 4 (-6825 КБ)
Пришло время для экспериментального нативного AOT. Насколько я понимаю, это потомок CoreRT. В этой попытке мы просто добавим его в проект, воспользовавшись инструкцией.
Примечание: чтобы это сработало, нужно будет установить в Visual Studio модуль C++ Development.
Примечание: мне пришлось удалить из файла .csproj элемент PublishSingleFile, потому что в противном случае при использовании AOT публикация завершалась неудачно.
dotnet publish -r win-x64 -c Release
Total binary size: 4,348 KB
Попытка 5 (-221 КБ)
После изучения корневой документации оказалось, что мы можем получить оптимизацию!
<PropertyGroup>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
dotnet publish -r win-x64 -c Release
Total binary size: 4,127 KB
Попытка 6 (-69 КБ, круто)
На той же странице оптимизации описывается и опция IlcOptimizationPreference, которую мы можем задать как размер.
<PropertyGroup>
<IlcOptimizationPreference>Size</IlcOptimizationPreference>
</PropertyGroup>
dotnet publish -r win-x64 -c Release
Total binary size: 4,058 KB
Попытка 7 (-199 КБ)
Далее мы зададим IlcFoldIdenticalMethodBodies, которая, согласно документации, может привести к странностям в трассировках стека.
<PropertyGroup>
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
</PropertyGroup>
dotnet publish -r win-x64 -c Release
Total binary size: 3,859 KB
Попытка 8 (-2692 КБ)
Настало время опций метаданных. Первым делом настроим IlcDisableReflection, которая отключает генерацию всех метаданных, полученных рефлексией. Однако как только мы начнём избавляться от рефлексии в .NET, всё может стать немного странным.
<PropertyGroup>
<IlcDisableReflection>true</IlcDisableReflection>
</PropertyGroup>
dotnet publish -r win-x64 -c Release
Total binary size: 1,167 KB
Попытка 9 (-0 КБ)
Метаданные? Да кому они нужны. Настало время IlcTrimMetadata. Однако оказалось, что, возможно, я удалил основную их часть, поэтому это практически ни к чему не привело.
<PropertyGroup>
<IlcTrimMetadata>true</IlcTrimMetadata>
</PropertyGroup>
dotnet publish -r win-x64 -c Release
Total binary size: 1,167 KB
Попытка 10 (-129 КБ)
Следующей в списке попыток урезания байтов будет IlcGenerateStackTraceData.
<PropertyGroup>
<IlcGenerateStackTraceData>false</IlcGenerateStackTraceData>
</PropertyGroup>
dotnet publish -r win-x64 -c Release
Total binary size: 1,038 KB
Попытка 11 (-10 КБ)
Теперь, когда мы исчерпали все опции, которые я смог найти, настало время вернуться к изучению кода.
Теоретический минимум
Без экспериментов с линтером и тулчейном сборки похоже, что наименьшим размером для .exe будет примерно 975 КБ. Я протестировал это, удалив все ссылки на сам код TinyWordle и введя в program.cs простую строку Console.Writeline("");
.
То есть хорошей целью для нас будет сокращение кодовой базы до менее чем одного мегабайта, или 1024 КБ, и, согласно попытке 10, нам для этого нужно избавиться всего лишь от 14 байтов.
Record Struct (-0 КБ)
В частности, уменьшить количество создаваемых MSIL. Для начала мы изучим структуры GuessedWord и GuessedLetter. Судя по примеру с SharpLap.io, запись создаёт гораздо больше кода.
Однако хотя это и может повлиять на окончательный размер, в случае с двумя моими struct их преобразование из записей не изменило размер файла.
Random (-0 КБ)
Изначально я выбирал слово, создавая новый объект Random. Однако можно вызвать случайное число и при помощи статического вызова Random.Shared.Next(). Замена объекта на статический вызов не повлияла на размер итогового файла.
Random v2 (-2 КБ)
Использовав структуру random из одного небольшого проекта, мы сэкономили 2 КБ.
dotnet publish -r win-x64 -c Release
Total binary size: 1,036 KB
Избавляемся от .ToLower() (-5 КБ)
Если пользователь не будет использовать символы в верхнем регистре, то всё будет в порядке. Уменьшили размер ещё на 5 КБ.
dotnet publish -r win-x64 -c Release
Total binary size: 1,031 KB
Больше никаких .WriteLine() (-1 КБ)
Так как мне нужен Console.Write()
для раскраски символов, можно удалить вызовы WriteLine()
и просто добавлять к нужным вызовам . n
dotnet publish -r win-x64 -c Release
Total binary size: 1,030 KB
Убираем .Contains() (- 512 Б)
Очень небольшое улучшение, мы всего лишь создаём простой метод, чтобы заменить встроенный метод string.Contains()
. Впрочем, это сэкономило не так много места, чтобы существенно повлиять на округлённый размер двоичного файла. Ну, по крайней мере, на этой попытке, в отличие от других, размер изменился.
dotnet publish -r win-x64 -c Release
Total binary size: 1,030 KB
Вырезаем Console.ReadKey() (-2 КБ)
Чтобы пользователь мог выбрать, продолжить игру или выйти из неё после выигрыша/проигрыша, изначально я решил использовать вызов ReadKey()
, чтобы пользователь мог сразу вернуться в игру. Теперь я заменил его на ReadLine()
, то есть для продолжения пользователю нужно будет нажимать на Enter. Но это стоит сэкономленного места.
dotnet publish -r win-x64 -c Release
Total binary size: 1,028 KB
Вырезаем while (-0 КБ)
Я подумал, что если уж использую for
, то можно использовать и синтаксис бесконечного цикла for(;;)
вместо while(true)
. Оказалось, что для IL они идентичны.
Попытка 12 (-0 КБ)
Краткий список неудачно завершившихся проб:
-
Переключение между классами/структурами/записями и так далее
-
Удаление
new Random
и прописывание жёсткого значения -
Множество других
Потратив какое-то время на самостоятельное изучение кода, я решил, что настало время посмотреть на сам вывод для публикации. Раньше я использовал Visual Studio Performance Profiler, чтобы разбираться с местами выполнения вызовов, но теперь готов посмотреть, что можно извлечь из вывода AOT.
Я буду использовать следующие свойства:
<PropertyGroup>
<IlcGenerateDgmlFile>true</IlcGenerateDgmlFile>
<IlcGenerateMapFile>true</IlcGenerateMapFile>
<IlcDumpGeneratedIL>true</IlcDumpGeneratedIL>
</PropertyGroup>
Судя по дампам того, что было скомпоновано, всё это элементы базовых библиотек наподобие System.Threading, System.Collections, а также что-то типа различных примитивных типов. Теперь я понимал, почему автор игры «Змейка» изучал тулчейн и смотрел, что компоновалось в проекте.
Пока я недостаточно разобрался с Interop и импортом DLL, и у меня нет желания экспериментировать с тулчейном.
Попытка 13 (-1 КБ)
Снова не касаемся тулчейна, просто пробуем разные идеи, время от времени приходившие в голову.
Удаляем Console.ResetColor() (-0 КБ)
Попробовал заменить вызовы Console.ResetColor()
на Console.BackgroundColor =
ConsoleColor.Black
Удаляем ? и enable (-0 KB)
Удаляем проверки на нулевые значения и задаём <Nullable>disable</Nullable> в csproj.
Не используем new для создания структур (-0 КБ)
Используем структуры Random, GuessedLetter и GuessedWord в качестве примитивов. Впрочем, похоже, это ничего не изменило, если, конечно, я что-то не напутал.
Не используем string.IsNullOrEmpty() (-0 КБ)
Решил вручную проверять строки на пустоту и null. Однако я думаю, что, поскольку это часть базовой библиотеки, даже если классы/методы не используются, они всё равно добавляются.
Удаляем вызов Console.Write(char value) с вызовом Console.Write(string? value) (-1 КБ)
Каждая буква выводится реализацией char
Console.Write()
, однако строковая версия тоже присутствует. А поскольку метод .ToString()
присутствует в базовой библиотеке и не обрезается, мы можем превратить каждый выводимый char
в string
.
dotnet publish -r win-x64 -c Release
Total binary size: 1,027 KB
Используем Console.SetCursorPosition() вместо Console.Clear() (-0 КБ)
Не сработало. Файл увеличился на 2 КБ.
Переопределяем базовые вызовы (-0 КБ)
Я подумал о переопределении ToString()
и тому подобного. Посмотрел на изменения в SharpLab.io: хотя JIT ASM действительно становится короче, обрезчик и так удаляет их, потому что их не используют пользовательские типы.
Другие переключатели (-0 КБ)
Попробовал другие переключатели из документации по обрезке
(-0 КБ)
Не-а, ничего не поменялось.
Собственный app.manifest (-512 Б)
В двоичные файлы встроен файл manifest. Не совсем понимаю, для чего он нужен, но игра запускается и без него. Я добавил новый пустой app.manifest и поместил его в файл .csproj file:
<ApplicationManifest>app.manifest</ApplicationManifest>

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
Не забудьте удалить папку obj после изменения манифеста, иначе будет следующее:
LINK : fatal error LNK1123: failure during conversion to COFF: file invalid or corrupt
К сожалению, этого оказалось недостаточно для того, чтобы изменить округлённый размер в КБ:
dotnet publish -r win-x64 -c Release
Total binary size: 1,027 KB
Избавляемся от System.SpanHelpers.SequenceEqual() (-0 КБ)
Чтобы проверить, хочет ли пользователь выйти, используется вызов:
shouldContinue == "q"
Согласно sharplab.io, он превращается в
call System.SpanHelpers.SequenceEqual(Byte ByRef, Byte ByRef, UIntPtr)
Чтобы обойти это, я упростил его:
shouldContinue[0] == 'q'
Благодаря чему он превратился в простые сравнения на ассемблере IL
Однако это ничего не изменило; я думаю, причина в том, что System.SpanHelpers
— часть ядра, поэтому они всё равно загружаются.
[MethodImpl(MethodImplOptions.AggressiveInlining)] (-1 КБ)
Судя по моим крайне ограниченным данным, встраивание может увеличить размер кода, потому что код просто копипастится компилятором и вбрасывается в последовательном порядке; он не скачет по всей кодовой базе в поисках следующего вызова.
Однако в моём случае оказалось, что вставка определённых функций уменьшила размер кода.
dotnet publish -r win-x64 -c Release
Total binary size: 1,026 KB
Изменяем функцию Contains()
// старая
public static bool Contains(string stringToSearch, char characterToFind)
{
for (int i = 0; i < stringToSearch.Length; i++)
{
if (stringToSearch[i] == characterToFind)
{
return true;
}
}
return false;
}
// новая
public static bool Contains2(string s, char c)
{
return s.IndexOf(c) != -1;
}
Это ничего не поменяло: похоже, компилятор знает более хитрые способы.
Попытка 14 (-0 КБ)
В этой попытке используется dnSpy (ныне устаревший; вместо него можно использовать ILSpy) для проверки того, что находится в составе .exe.
В этой же попытке мы избавились от использования рекомендуемых практик кодинга.
Удаляем из пользовательского кода все структуры (-0 КБ)
Не-а. Не помогло. Я надеялся, что даже если структуры являются частью стандартной библиотеки, сгенерированный двоичный файл окажется меньше. Похоже, что это не так.
Удаляем ссылки на тип char (-0 КБ)
Тоже не помогло.
Переход к единому файлу
На самом деле, это увеличило его размер.
Берём внутренние реализации функций dotnet
Теоретически это в дальнейшем позволит избавиться от guard и другой лишней траты ресурсов. Но оказалось, что это слишком масштабно для моего проекта, потому что мне пришлось бы разбираться слишком во многом.
Попытка 15 (-16 КБ)
Этап небольших экспериментов с компоновщиком; я пробовал флаги из документации компоновщика MSVC. Было протестировано больше опций, чем здесь написано, но большинство не принесли никакой пользы.
DYNAMICBASE:NO (-15 КБ)
Добавляем в файл .csproj аргументы компоновщика, в частности, отключаем рандомизацию структуры адресного пространства при помощи DYNAMICBASE:NO.
<ItemGroup>
<LinkerArg Include="/DYNAMICBASE:NO" />
</ItemGroup>
dotnet publish -r win-x64 -c Release
Total binary size: 1,011 KB
/SUBSYSTEM:CONSOLE (-0 КБ)
Из экспериментов с флагом subsystem ничего не вышло.
/ASSEMBLYDEBUG:DISABLE (-0 КБ)
Ничего не вышло из экспериментов с флагом assemblydebug.
/FILEALIGN:2 (-0 КБ)
Насколько я понимаю, этот флаг связан с выравниванием размеров в памяти. Возможно, что-то типа такого: если бит сохранён, но выравнивание равно 1 байту, то этот 1 бит занимает пространство в 1 байт. Согласно документации, этот флаг должен иметь значение степени двойки, а 0 ухудшает ситуацию.
За исключением момента генерации .exe. Он отказывается запускаться.
Результат
Результаты вы можете посмотреть самостоятельно в папке релизов попытки 15

Попытки |
Размер (КБ) |
Снижение размера |
---|---|---|
Оригинал |
62091 |
- |
1 |
11189 |
81.98% |
2 |
11189 |
81.98% |
3 |
11173 |
82.01% |
4 |
4348 |
93.00% |
5 |
4127 |
93.35% |
6 |
4058 |
93.46% |
7 |
3859 |
93.78% |
8 |
1167 |
98.12% |
9 |
1167 |
98.12% |
10 |
1038 |
98.33% |
11 |
1028 |
98.34% |
12 |
1028 |
98.34% |
13 |
1027 |
98.35% |
14 |
1026 |
98.35% |
15 |
1011 |
98.37% |
Идеи на будущее
-
Изменить тулчейн так, чтобы необходимы были только стандартные функции
-
Использовать stackalloc?
-
Использовать инструмент для декомпозиции, чтобы изучить IL и упакованные импорты, чтобы проверить, какие нестандартные библиотеки можно удалить.
Секретная попытка 16
Разрозненные заметки для себя об изучении более новых версий .NET
Изучаем попытку 15 с новыми инструментами для сборки
В .NET 7, начиная с Preview 3, теперь, вроде, добавили Native AOT; было бы интересно поглядеть, как проделанная нами работа выглядит в этой новой версии. Но сначала нужно создать состояние, необходимое для её использования.
Однако у нас возникает проблема. При обновлении Visual Studio (VS2022) с версии 17.0 до 17.2 обновились и инструменты для сборки примерно с версии 17.0 на 17.2. Поэтому вывод при сборке теперь выглядит так:
Microsoft (R) Build Engine version 17.2.0+41abc5629 for .NET
(Чтобы найти свои предыдущие версии, я зашёл %temp% и начал искать мои логи обновления VS, начинающиеся с dd_setup.)
Из-за этого двоичные файлы стали больше! Давайте попробуем собрать оригинал 00 при помощи обычной команды:
dotnet publish -r win-x64 -c Release
|
Старый |
Новый |
---|---|---|
Размер |
62091 КБ |
64275 КБ |
Чтобы подробно разобраться в этом, я изучил, как выпускаются MSBuild. Изначально я думал, что они просто поставляются с Visual Studio, но оказалось, что они идут в комплекте с SDK. Новые установки .NET SDK привносят собственный движок сборки, что логично, ведь MSBuild должен поставляться с .NET SDK. Например, MSBuild 17.2.0 поставляется с .NET SDK 6.0.300.
Я провёл следующие эксперименты:
-
На другом PC, на котором была только VS2019, я установил Visual Studio Build Tools 2022 LTSC 17.0 с инструментами C++ (для CoreCLR AOT)
-
По очереди установил каждую версию .NET 6, по необходимости удаляя старые
Двух выделенных строках ниже представлен корректный размер оригинала 00, ожидаемый согласно всему этому проекту.
Build Engine |
Версия .NET |
Размер 00 |
Размер 15 |
---|---|---|---|
17.0.0 |
6.0.100 |
61321 KB |
1047 KB |
17.0.0 |
6.0.101 |
61998 KB |
1047 KB |
17.0.0 |
6.0.102 |
61991 KB |
1047 KB |
17.0.0 |
6.0.103 |
62091 KB |
1047 KB |
17.0.0 |
6.0.104 |
62120 KB |
1047 KB |
17.0.0 |
6.0.105 |
62120 KB |
1047 KB |
17.1.0 |
6.0.200 |
61991 KB |
1047 KB |
17.1.0 |
6.0.201 |
62091 KB |
1047 KB |
17.1.1 |
6.0.202 |
62120 KB |
1047 KB |
17.1.1 |
6.0.203 |
62120 KB |
1047 KB |
17.2.0 |
6.0.300 |
62120 KB |
1047 KB |
Похоже, это означает, что даже с одинаковым <TargetFramework>net6.0</TargetFramework>
в файле .csproj выходной двоичный файл будет зависеть от младшей версии .NET и версии MSBuild! Возможно, в ретроспективе это кажется очевидным, но мне никогда это не приходило в голову, потому что меня особо не волновали выходные байты.
Ещё одно наблюдение заключается в том, что размер попытки 15 никогда не отклонялся от 1047 КБ, и это число точно не равно 1011 КБ. Предполагаю, что самая маленькая версия не изменялась из-за установленных мной инструментов C++. Изучим ситуацию на моей машине после установки VS 17.2 и .NET 7.0.100 Preview 4:
Build Engine |
Версия .NET |
Размер 00 |
Размер 15 |
---|---|---|---|
Оригинал |
6.x |
62091 KB |
1011 KB |
17.3.0-preview-22226-04 |
7.0.100-preview.4.22252.9 |
62419 KB |
1048 KB |
17.3.0-preview-22306-01 |
7.0.100-preview.5.22307.18 |
62419 KB |
1047 KB |
Попытка 17
Она уже не такая секретная. Мы возьмём последнюю обычную попытку (15) и запустим её в .NET 8 preview. Для этого нужно зайти в репозиторий dotnet/installer.
На эту попытку меня вдохновил твит Михала Стреховски.
Вот, что мы будем использовать: версия SDK: 8.0.100-preview.1.23114.33 и версия MSBuild 17.6.0-preview-23108-10+51df47643, запускаемые обычным образом:
dotnet publish -r win-x64 -c Release
Однако у нас возникла пара проблем:
1. PublishTrimmed ещё не обновлён
error NU1101: Unable to find package Microsoft.NET.ILLink.Tasks. No packages exist with this id in source(s): dotnet-experimental, nuget
Покопавшись, я понял, что этот пакет, похоже, используется <PublishTrimmed>true</PublishTrimmed>
. И если задать false
, то мы получим работающую сборку, но не как единый пакет.
2. Настройка net8.0 ломает пакеты
Ради интереса я затем задал TargetFramework, и всё поломалось ещё больше.
При сборке:
Unable to find package Microsoft.NETCore.App.Runtime.win-x64 with version (= 8.0.0-preview.1.23110.8)
Unable to find package Microsoft.WindowsDesktop.App.Runtime.win-x64 with version (= 8.0.0-preview.1.23112.2)
Unable to find package Microsoft.AspNetCore.App.Runtime.win-x64 with version (= 8.0.0-preview.1.23112.2)
Вернувшись к исходной проблеме, я установил 8.0.100-preview.2.23114.23, но столкнулся с той же проблемой. Думаю, команды разработчиков, занимавшиеся другими пакетами, не закончили свою работу; это оправданно, ведь это очень ранняя версия 8.0.
Я вернусь к этому, когда .NET 8 немного повзрослеет.
Или мы можем поразвлечься.
Сборка из исходников
Моя проблема была связана с Microsoft.NET.ILLink.Tasks. Поэтому, поискав, я нашёл репозиторий ILLink.Tasks. То есть мы можем взять целиком репозиторий среды выполнения, перейти в папку этой области и выполнить команду:
dotnet restore illink.sln
dotnet pack illink.sln
Так мы получим файл .nupkg, на который я указал моему проекту, добавившему в nuget.config новую запись:
<add key="Locally packaged dotnet source" value="C:dotnetruntimeartifactspackagesReleaseShipping" />
И сборка выполнилась успешно! К сожалению, даже с теми же настройками это уже был не один файл:

Если присвоить PublishTrimmed
значение false
, то генерируется ГОРАЗДО больше файлов. Так что, возможно, показанное на скриншоте выше нужно подвергнуть AOT?

Хм, наверно, всё-таки стоит немного подождать релиза.
Попытка 18 (-13 КБ)
.NET 8 выпустили!
Взяв попытку 16 (попытку 17 мы подгоняли под .NET 8 preview), мы наконец-то добились какого-то прогресса! Здесь не добавилось ничего особо сложного, по сути, просто публикация. Экономия прямо из коробки!
dotnet publish -r win-x64 -c Release
Total binary size: 998 KB
Попытка 19 (-99 КБ)
Тестируем новые флаги при помощи новых опций обрезки .NET 8. Упоминать буду только те флаги, которые изменили размер проекта. Предполагаю, что большая доля этих флагов никак не изменила размер TinyWordle, потому что обрезаемое даже не было включено в двоичный файл из-за простоты игры.
StackTraceSupport (-99 КБ)
Важная опция .NET 8+.
<StackTraceSupport>false</StackTraceSupport>
dotnet publish -r win-x64 -c Release
Total binary size: 899 KB
Удаляем необязательные флаги
Сейчас TinyWordle содержит множество флагов из различных версий .NET. Я решил один за другим удалять их, чтобы подчистить файл .csproj.
Чтобы гарантировать отсутствие регрессий, я сделаю следующее:
-
Удалю флаг из файла .csproj
-
Проверю, что выходные файлы соответствуют ожиданиям (единый .exe)
-
Проверю, что игра запускается
-
Проверю размер на диске
-
Использую Sizeoscope Михала Стреховски для сравнения опубликованных файлов .mstat, чтобы убедиться в отсутствии изменений. Кстати, Михал и написал игру «Змейка», которая вдохновила меня на создание этого проекта.
До (хаос):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<SelfContained>true</SelfContained>
<PublishTrimmed>true</PublishTrimmed>
<InvariantGlobalization>true</InvariantGlobalization>
<IlcOptimizationPreference>Size</IlcOptimizationPreference>
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
<IlcDisableReflection>true</IlcDisableReflection>
<IlcTrimMetadata>true</IlcTrimMetadata>
<IlcGenerateStackTraceData>false</IlcGenerateStackTraceData>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>none</DebugType>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<PublishAot>true</PublishAot>
<IlcGenerateMstatFile>true</IlcGenerateMstatFile>
<IlcGenerateDgmlFile>true</IlcGenerateDgmlFile>
<IlcGenerateMapFile>true</IlcGenerateMapFile>
<IlcDumpGeneratedIL>true</IlcDumpGeneratedIL>
<ApplicationManifest>app.manifest</ApplicationManifest>
<NoConfig>true</NoConfig>
<Optimize>true</Optimize>
<StackTraceSupport>false</StackTraceSupport>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
</PropertyGroup>
<ItemGroup>
<TrimmableAssembly Include="TinyWordle" />
</ItemGroup>
<ItemGroup>
<LinkerArg Include="/DYNAMICBASE:NO" />
</ItemGroup>
</Project>
После (всё красиво):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<PublishAot>true</PublishAot>
<SelfContained>true</SelfContained>
<InvariantGlobalization>true</InvariantGlobalization>
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
<IlcDisableReflection>true</IlcDisableReflection>
<DebugType>none</DebugType>
<ApplicationManifest>app.manifest</ApplicationManifest>
<StackTraceSupport>false</StackTraceSupport>
<IlcGenerateMstatFile>true</IlcGenerateMstatFile>
<IlcGenerateDgmlFile>true</IlcGenerateDgmlFile>
<IlcGenerateMapFile>true</IlcGenerateMapFile>
<IlcDumpGeneratedIL>true</IlcDumpGeneratedIL>
</PropertyGroup>
<ItemGroup>
<LinkerArg Include="/DYNAMICBASE:NO" />
</ItemGroup>
</Project>
Я удивился, что PublishTrimmed не потребовалось, однако возникла такая ошибка:
error : PublishTrimmed is implied by native compilation and cannot be disabled.
Как и в случае с началом попытки 19, часть удалённых файлов всё равно могла оставаться актуальной, просто не по какой-то причине не влияла на TinyWordle. Это быстро меняющаяся область .NET, и не удивлюсь, что поверхность продолжит меняться.
Попытка 20 (-122 КБ)
Завершив с подчисткой, изучим последние изменения, которые потенциально могут что-нибудь сломать. Прочитаем сырую документацию с GitHub. Из всех новых флагов в этой документации на TinyWordle повлияли только перечисленные ниже.
OptimizationPreference (-122 КБ)
Постойте-ка, разве я только что не удалил OptimizationPreference в попытке 19? Вообще да, изначально используемый мной флаг назывался IlcOptimizationPreference. Думаю, он вышел из пространства Intermediate Language Compiler в обычное пространство. Из-за этого произошло нечто любопытное: допустим, флаг с префиксом ILC перестал работать в .NET 8, а двоичный файл всё равно оказался меньше, чем в попытках с .NET 7. Это означает, что .NET 8 проделал большую работу, чтобы компенсировать отсутствие работающего IlcOptimizationPreference, и снова уменьшил размер файла для попытки 18!
Мы можем увидеть в новом документе Optimize AOT deployments, ссылка на который есть в представленной выше документации с GitHub, что теперь это просто OptimizationPreference. Давайте попробуем его.
<OptimizationPreference>Size</OptimizationPreference>
dotnet publish -r win-x64 -c Release
Total binary size: 777 KB
Попытка 20, часть 2 (-4 КБ)
Выполнял сборку на другой машине; не знаю точно, что поменялось, но экономия меня радует.
dotnet publish -r win-x64 -c Release
Total binary size: 773 KB
Попытка 21 (-50 КБ)
P/Invoke для Console.Write() (- 0 КБ)
Я написал Михалу Стреховски письмо, попросив помощи с Sizoscope, и он дал мне новые рекомендации! На этот раз мы изучим удаление вызовов Console.Write() и замену их на вызовов P/Invoke для printf. Теоретически, это приведёт к обрезке вызовов Write() и сэкономит нам ещё немного места. Можно распространить это и на вызовы ReadLine(), тогда мы сможем удалить все вызовы Console.
[DllImport("msvcr120.dll")]
public static extern int printf(string format);
Сложность здесь заключается в том, чтобы заставить работать форматирование в консоли. Мне пришлось изучить цвета ANSI. Очень помог ответ на StackOverflow о цветах ANSI и о записи непосредственно в консольный вывод C#.
К сожалению, никакого выигрыша это не дало. Двоичный файл увеличился с 773 КБ до 781 КБ. Изучив его при помощи Sizoscope, я понял, что атрибут DLLImport добавляет проверки безопасности для нового двоичного файла.

На скриншоте выше сравниваются попытка 20 (слева) и попытка 21 (справа). Внизу написано, что попытка 21 на 7 КБ больше попытки 20.
Я не буду публиковать разные скриншоты со сравнениями, просто скажу, что появилось множество вспомогательных функций:
-
Вспомогательные функции для путей файловой системы
-
Обеспечения безопасности для путей
-
Отслеживания загруженных двоичных файлов
-
Множество преобразований и проверок строк
-
Приличное количество новых типов Collections
Однако мне было очень интересно потратить несколько часов на эту низкоуровневую работу. Я впервые занимался чем-то подобным, не повторяя чужие инструкции.
Мне также помогли эти ссылки:
P/Invoke для всего, что связано с Console (- 50 КБ)
То есть, в конечном итоге мы просто обрезали функцию Write()
из Console
, наряду ещё с парой моментов. Но, как мы поняли ранее, благодаря использованию P/Invoke можно также избавиться от вызовов Console.ReadLine()
и Console.Clear()
. Это значит, что у нас совершенно не будет зависимостей от Console
.
В конечном итоге я создал небольшой статический класс TinyConsole:
public static class TinyConsole
{
[DllImport("msvcr120.dll")]
public static extern int printf(string format);
[DllImport("msvcr120.dll")]
public static extern int system(string command);
[DllImport("msvcr120.dll")]
private static extern IntPtr gets(StringBuilder value);
public static string ReadLine()
{
var value = new StringBuilder();
gets(value);
return value.ToString();
}
}
Мне бы хотелось исправить часть со StringBuilder, то есть использовать её многократно, и я просто вызываю делегатов; но я решил, что это наименьший размер, до которого я могу их ужать. Возможно, подумаю над решением в будущем.
Так-так-так. Сравниваем при помощи Sizoscope попытку 20 и попытку 21:

Экономия в 50,5 КБ! То есть даже несмотря на то, что мы прибавили лишние 18 КБ из-за необходимости всего управления двоичным файлом, в целом устранение Console
оказалось выгодным!
dotnet publish -r win-x64 -c Release
Total binary size: 723 KB
Спасибо, Михал!
Попытка 22 (-20 КБ)
Непосредственный вызов P/Invoke (-19 КБ)
Михал дал ещё один совет: непосредственные вызовы P/Invoke. Обычные вызовы P/Invoke лениво происходят в среде выполнения и содержат множество проверок, как мы видели в попытке 21. Непосредственные вызовы P/Invoke позволяют избежать этих затрат, приказывая компилятору генерировать прямые вызовы. Что это будет значить для проекта? Мы можем избавиться от всех этих проверок и продолжить уменьшать итоговый двоичный файл.
Поначалу у меня возникли большие сложности с пониманием того, как заставить непосредственные вызовы P/Invoke работать с вызовами msvcr120.dll, как я делал ранее. Однако добавление:
<ItemGroup>
<DirectPInvoke Include="msvcr120.dll" />
</ItemGroup>
привело к следующему:
TinyWordle.obj : error LNK2001: unresolved external symbol printf
Благодаря DUMPBIN стало понятно, что prinf
определённо присутствует!
dumpbin /EXPORTS C:WindowsSystem32msvcr120.dll > C:Tempdump.txt

Также я попытался указать элемент <NativeLibrary>:
<ItemGroup>
<DirectPInvoke Include="msvcr120.dll" />
<NativeLibrary Include="C:WINDOWSSYSTEM32msvcr120.dll"/>
</ItemGroup>
Насколько я понял, непосредственные вызовы P/Invokes принимают только файлы .lib для Windows, то есть .dll не заработают. Впрочем, я в этом не уверен.
Я узнал, что может помочь заранее составленный список непосредственных методов P/Invoke, доступный для всех поддерживаемых версий Windows, так что начал изучать эти функции.
В дальнейшем я попытался разобраться, как можно взаимодействовать с консолью при помощи методов, доступных через Windows. Оглядываясь назад, могу сказать, что странные методы наподобие StringCbPrintfA(), вероятно, не подошли бы, даже если бы были частью Win32 API.
В конечном итоге выяснилось, что существуют более удобные низкоуровневые функции консоли. Они не такие простые, как обычный printf
, но всё равно удобнее, чем некоторые из более сырых API.
В итоге я использовал следующие функции:
Функция |
Краткое описание |
Цель |
---|---|---|
Получает дескриптор стандартного ввода, стандартного вывода или стандартного устройства вывода ошибок. |
Позволяет получить дескриптор консоли, чтобы с ней можно было взаимодействовать. |
|
Получает текущий режим ввода буфера ввода консоли или текущий режим вывода для экранного буфера консоли. |
Помогает настроить цвета ANSI. |
|
Задаёт режим ввода буфера ввода консоли или режим вывода экранного буфера консоли. |
Помогает настроить цвета ANSI. |
|
Считывает ввод символа из буфера ввода консоли и удаляет его из буфера. |
Используется вместо |
|
Записывает строку символов в экранный буфер консоли, начиная с текущего местоположения курсора. |
Используется вместо |
|
Получает информацию об указанном экранном буфере консоли. |
Часть замены |
|
Записывает символ в экранный буфер консоли указанное количество раз. |
Часть замены |
|
Задаёт позицию курсора в указанном экранном буфере консоли. |
Часть замены |
Некоторые замечания:
-
Цвета ANSI будут работать с отладкой Visual Studio без настройки в явном виде, но приведут к сбоям в опубликованном .exe; поэтому полезны
GetConsoleMode()
иSetConsoleMode()
. -
Мне потребовалось какое-то время, чтобы разобраться в том, как пользоваться
ReadConsole()
. В конце моего ввода возникали случайные символы. Я пробовал выполнять сброс после операций чтения и записи, но оказалось, что мой буфер слишком маленький, что приводит к его переполнению и появлению случайных символов. Иногда это всё равно случалось, но только после интересующих меня символов, так что я просто вырезал всё ненужное мне. -
Я хотел реализовать замену StringBuilder, это даже отдельный пункт анализа кода: CA1838: Avoid StringBuilder parameters for P/Invokes, но, похоже, мне не удалось заставить её работать. Возможно, придётся вернуться к этому позже.
Давайте взглянем на статистику. Проведём сравнение:
-
Вернём Console вместо этих трудов с P/Invoke. Так мы получим базовый пример для сравнения.
-
Новой реализации с P/Invoke, но удалим заранее составленный список для непосредственных вызовов P/Invokes. То есть оставим только обычные вызовы P/Invoke. В моём случае это они находились в
%UserProfile%.nugetpackagesmicrosoft.dotnet.ilcompiler8.0.0buildWindowsAPIs.txt
-
Наконец, с непосредственными вызовами P/Invoke
Версия |
Размер |
---|---|
Console |
773 KB |
Обычные P/Invoke |
721 KB |
Непосредственные P/Invoke |
704 KB |
Это значит, что:
-
Наличие объекта Console для чтения, записи и очистки «стоит» 52 КБ
-
При переходе на непосредственные P/Invoke мы сэкономим 17 КБ
Ссылки по теме:
-
Native AOT application linking with external static library or object files #89044
-
Marshalling Data with Platform Invoke (or, which datatypes map to what)
Разное (-1 КБ)
Я приложил дополнительные усилия к реализации очень маленьких улучшений порядка единиц или десятков байтов, но, согласно файловой системе, они округлились до 1 КБ.
-
Заменил
Environment.TickCount64
соответствующим вызовом Kernel32 -
Агрессивно встраивал функции только с одним вызывающим оператором
-
Удалил необязательные вызовы функций, изменяя переменную и вызывая функцию в конце ветвлений вместо того, чтобы вызывать функцию в каждой ветви. (Нашёл это благодаря изучению ILSpy)
-
Удалил функции, связанные со сложными интерполяциями строк, гарантировав, что всё интерполируемое будет оказываться строкой (а не char) (Нашёл благодаря изучению ILSpy)
В конечном итоге получаем:
dotnet publish -r win-x64 -c Release
Total binary size: 703 KB
Спасибо ещё раз, Михал!
Попытка 23 (-23 КБ)
Вышел .NET 9!
Воспользовавшись ровно тем же кодом из попытки 22, мы без дополнительных усилий получили хороший прогресс.
dotnet publish -r win-x64 -c Release
Total binary size: 680 KB
Попытка 24
Удаляем интерполяцию строк (-0 КБ)
Я изучил в Sizoscope структуру кода в .NET 9, чтобы понять, можно ли удалить некоторые части, как в попытке 11. Заметил, что есть пара строковых методов, которые, похоже, использует только мой код. Самая сложная работа со строками в проекте:
// Упрощение интерполяции добавляет в код методы прибавления char, принимающие
// ToString(), что сильно упрощает код конкатенации
TinyConsole.Write($"{ansiColour}{guessedLetter.Letter.ToString()}u001b[0m");
Раньше это приходилось делать вручную, чтобы уменьшить размер двоичного файла. Однако похоже, теперь появляются перегрузки ReadOnlySpan<char>
; это имеет смысл, поскольку обычно нам нужна хорошая производительность, и мы получаем её неявным образом. Однако в данном случае замена её на string.Format()
удаляет перегрузку string.Concat()
. Новый код выглядит так:
TinyConsole.Write(string.Format("{0}{1}u001b[0m", ansiColour, guessedLetter.Letter.ToString()));
Судя по Sizoscope, размер уменьшился примерно на 326 байтов. Однако при сравнении значений на диске оказалось, что он остался тем же. Думаю, это связано с заполнителями.

При работе с ILSpy мы можем посмотреть на различия в куче строк в метаданных DLL. Должен признать, что не совсем понимаю описанное ниже, но это интересно!
Во-первых, разница в строках. В старой версии присутствовала «замороженная» строка (я почти уверен, что это константа) размером 30 байтов. В новой «замороженная» строка имеет размер 42 байта.
Изучая «замороженные» объекты в Sizoscope, я заметил, что в старой версии есть строка на 30 Б, относящаяся к изменённому методу, отсутствующему в новой версии, как гласит сравнение. Аналогично, есть строка на 42 Б, присутствующая только в новой версии. Похоже, строка стала больше на 12 байтов. Ниже показаны разные примеры строк и их местоположения. Знаю, в этом сложновато разобраться.

Так что это за изменившаяся строка? Мы можем воспользоваться ILSpy, чтобы проверить это, и здесь я по-прежнему мало что понимаю.
В новой версии куча строк содержит на одну строку меньше: ReadOnlySpan'1. Предположу, это вызвано исчезновением перегрузки ReadOnlySpan concat.
Из-за новой строки форматирования пользовательская куча строк содержит другое значение:

Я не знаю, как это всё вычисляется:
-
Почему исходная строка длиной 4 весит 30 байтов?
-
Почему новая строка длиной 10 весит всего на 12 байтов больше?
Эту загадку мне предстоит решить в будущем. Возможно, также стоит потом разобраться, как убрать часть «замороженных» строк.
Удаляем ToString() (-0 КБ)
Получаем
TinyConsole.Write(string.Format("{0}{1}u001b[0m", ansiColour, guessedLetter.Letter.ToString()));
TinyConsole.Write(string.Format("{0}{1}u001b[0m", ansiColour, guessedLetter.Letter));
Sizoscope говорит об уменьшении на 15 Б, но в двоичном файле на диске изменений не произошло. Надеюсь, эти маленькие биты суммарно дадут уменьшение в будущем.
Автор: PatientZero