Делаем двоичные файлы Rust меньше по умолчанию

Kate

Administrator
Команда форума
Вы когда-нибудь пробовали компилировать program helloworld на Rust в режиме --release? Если да, то видели, какой размер двоичного файла получается? Достаточно сказать, что он не очень маленький. Или, по крайней мере, не был таким до недавнего времени. В этом посте я расскажу, как узнал об этой проблеме и попытался устранить её в Cargo.

Анализ двоичного файла​

Я участник недавно организованной рабочей группы #wg-binary-size, которая занимается поиском возможностей уменьшения размеров двоичных файлов программ и библиотек на Rust. Так как я также занимаюсь поддержкой набора бенчмарков Rust, моя основная задача в рабочей группе заключается в совершенствовании инструментария для измерения и контроля размера двоичных файлов программ на Rust.

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

Результат работы команды выглядит так:

Output of the binary analysis command

При тестировании команды я заметил нечто любопытное. При компиляции тестового двоичного файла в режиме релиза (--release) команда анализа показала, что в двоичном файле есть отладочные символы (DWARF). Поначалу я решил, что в моей команде есть баг и что я случайно выполняю компиляцию в отладочном режиме. Ведь Cargo ни за что не будет по умолчанию добавлять отладочные символы в двоичный файл в режиме релиза. Так ведь?

Я примерно пятнадцать минут искал баг, пока не понял, что в моём коде его нет. В каждом двоичном файле Rust, скомпилированном в режиме релиза, действительно есть отладочные символы, и это происходит уже долгое время. На самом деле, есть старое issue Cargo (ему почти семь лет), где упоминается эта проблема.

Почему это происходит?​

Это следствие того, как распространяется стандартная библиотека Rust. При компиляции крейта Rust стандартная библиотека не компилируется. [Если только вы не используете build-std, которая, к сожалению, по-прежнему нестабильна.] Она поставляется в уже скомпилированном виде (обычно при помощи Rustup) в компоненте rust-std. Чтобы снизить объём скачиваемых данных [и в то же время, как оказалось, чтобы увеличить размер двоичных файлов Rust на диске], она поставляется не в двух вариантах (с отладочными символами и без них), а только в наиболее общем варианте с отладочными символами.

В Linux (и на других платформах) отладочные символы по умолчанию встраиваются непосредственно в объектные файлы или в саму библиотеку (а не распространяются в отдельных файлах). Поэтому когда вы ссылаетесь на стандартную библиотеку, то в скомпилированном двоичном файле получаете «в нагрузку» эти отладочные символы, что раздувает размеры бинарника.

На самом деле, это противоречит собственной документации Cargo, в которой говорится, что при использовании debug = 0 (что по умолчанию применяется для релизных сборок), получившийся двоичный файл не будет содержать отладочных символов. Но происходит нечто совершенно иное.

Дополнение: уточню, что Cargo по умолчанию помещал в вашу программу debuginfo стандартной библиотеки Rust. По умолчанию в режиме релиза он не включал debuginfo вашего собственного крейта.

Почему это проблема?​

Если взглянуть на бинарник программы helloworld на Rust, скомпилированной в режиме релиза в Linux, то можно заметить, что его размер составляет примерно 4,3 МиБ. [Протестировано с rustc 1.75.0.] Хотя сегодня объёмы накопителей гораздо больше, чем в прошлом, это всё равно ужасно много.

Вы можете решить, что это не проблема, потому что те, кому нужны двоичные файлы меньшего размера, просто вырезают эту информацию. Это хороший аргумент — на самом деле, после вырезания отладочных символов из двоичного файла helloworld [например, при помощи strip --strip-debug <binary>] его размер снижается всего до 415 КиБ, то есть примерно до 10% от исходного. Однако дьявол скрывается в деталях; в данном случае — в деталях настроек по умолчанию.

И настройки по умолчанию очень важны! Rust рекламирует себя как язык, создающий высокоэффективный и оптимальный код, но из-за приложения helloworld, занимающего больше 4 мегабайтов на диске, складывается несколько иное впечатление. Я вполне могу представить ситуацию, как опытный разработчик на C или C++ решает попробовать Rust, компилирует небольшую программу в режиме релиза, обращает внимание на размер получившегося файла, сразу же отказывается от освоения этого языка и идёт потешаться над ним на форумах.

Даже хотя эту проблему можно решить одним вызовом strip, на мой взгляд, она всё равно остаётся проблемой. Rust пытается быть привлекательным для программистов, имеющих различный предыдущий опыт разработки, и не каждый знает, что вообще существует процесс уменьшения двоичных файлов. Поэтому важно, чтобы по умолчанию всё работало лучше.

Стоит отметить, что размер отладочных символов libstd в Linux примерно равен 4 МБ, и этот размер постоянен, поэтому хотя в helloworld он занимает примерно 90% от размера бинарника, в программах большего размера его эффект будет меньше. Но всё равно, 4 мегабайта — это не мелочь, учитывая, что по умолчанию они добавляются в каждый собранный двоичный файл Rust.

Предложение изменений в Cargo​

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

На этот раз я был решительно настроен что-то изменить. Но с чего начать? Обычно достаточно спросить совета на Rust Zulip, поэтому я так и поступил. Оказалось, что не я первый задал этот вопрос, и что за несколько лет он всплывал много раз. Предлагаемое решение заключалось в вырезании по умолчанию отладочных символов из программ на Rust в режиме релиза, что устранит проблему раздувания размера двоичных файлов. Однако в прошлом этому препятствовала стабилизация поддержки strip в Cargo, но эту задачу уже решили в начале 2022 года.

Так почему же это предложение так и не было реализовано? Существуют ли какие-то серьёзные препятствия или проблемы? На самом деле, нет. Когда я задал вопрос на Zulip, почти все решили, что это хорошая идея. И несмотря на то, что раньше были попытки сделать это, никто обычно не настаивал достаточно сильно.

Итак, этого не сделали, потому что никто пока этого не сделал. Поэтому я решил исправить ситуацию. Чтобы проверить, будет ли работать вырезание отладочных символов по умолчанию, я создал PR для компилятора и запустил бенчмарк производительности. Результаты двоичных файлов (для маленьких крейтов) выглядели неплохо, и это дало мне надежду, что вырезание отладочных символов по умолчанию сможет сработать.

Забавно, что это изменение ещё и почти в два раза увеличило скорость компиляции маленьких крейтов (наподобие helloworld) в Linux! Как такое может быть, если мы проделываем больше работы, добавив в процесс компиляции операцию вырезания? Ну, оказалось, что стандартный компоновщик Linux (bfd) чудовищно медленный [однако недавно была попытка ускорить его], поэтому вырезая отладочные символы из двоичного файла, мы на самом деле уменьшаем объём работ компоновщика, что ускоряет компиляцию. К сожалению, этот эффект заметен только на очень маленьких крейтах.

Сейчас ведутся работы по тому, чтобы по умолчанию использовать в Linux более быстрый компоновщик (lld) (опять-таки, настройки по умолчанию важны).
После демонстрации этих результатов мейнтейнерам Cargo они попросили меня написать предложение по исходному issue Cargo. В этом мини-предложении я объяснил, какие изменения хочу внести в параметры Cargo по умолчанию, как они будут реализованы и чего ещё коснутся эти изменения.

Например, было замечено, что если мы по умолчанию будем вырезать отладочные символы, то обратные трассировки релизных билдов... не будут содержать отладочной информации, например, номеров строк. Это действительно так, но я утверждаю, что они всё равно не были полезны. Если в вашем бинарнике содержатся отладочные символы только для стандартной библиотеки, а не для вашего собственного кода, то даже если обратная трассировка будет содержать номера строк из stdlib, это не даст вам никакого полезного контекста (разницу можно сравнить здесь). Также были вопросы по реализации, например, как обрабатывать ситуации, когда только часть зависимостей целевой платформы требует отладочных символов. Подробности можно прочитать в предложении.

После составления предложения оно подверглось процессу FCP. Команда Cargo провела голосование по нему, и после его принятия и десятидневного периода ожидания последних вопросов (FCP) я смог реализовать предложение, что оказалось на удивление простым процессом.

PR был смержен неделю назад, и теперь исправление находится в nightly-сборке!

Если вкратце, то изменение заключается в том, что Cargo теперь по умолчанию будет использовать strip = "debuginfo" для профиля release (если вы только явным образом не запросите debuginfo для какой-то зависимости):

[profile.release]
# v Теперь используется по умолчанию, если не указано иное
strip = "debuginfo"
На самом деле, новая настройка по умолчанию будет использоваться во всех профилях, в цепочках зависимостей которых нигде не включена debuginfo, а не только в профиле release.

Возник один неразрешённый вопрос использования strip в macOS, поскольку с ним, похоже, возникли некоторые проблемы. Изменение находится в nightly-сборке уже около недели, и я не замечал никаких проблем, но если они возникнут, то мы также можем выполнить выборочное вырезание отладочных символов только на некоторых платформах (например, в Linux и Windows). Сообщите нам, если у вас возникнут проблемы с вырезанием при помощи Cargo в macOS!

Заключение​

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

 
Сверху