Во всех устройствах YADRO, будь то системы хранения данных, серверы или коммутаторы, есть система BMC, через которую администраторы гибко управляют серверами.
Мы поддерживаем продукты в конкурентоспособном состоянии и выпускаем обновления с исправлениями багов, поддержкой новых стандартов безопасности и новой функциональностью. Но чем больше новшеств, тем больше места бинарный код занимает на накопителе с прошивкой устройства. На старых платформах уже нет возможности расширить накопитель, поэтому мы ищем, как уменьшить прошивку, сохранив всю нужную функциональность.
Меня зовут Максим Гончаров, и я расскажу, как мы оптимизировали кодовую базу на C++ по размеру конечного образа, чтобы новые фичи были доступны на всех уже работающих у заказчиков серверах.
Мы не будем говорить о производительности, так как для BMC нет требований по заоблачным RPS или необходимости выжимать все соки из CPU. Основная часть оптимизаций производительности происходит за счет алгоритмов в продукте. Оптимизировать каждую копию или векторизовать циклы, уменьшая промахи кеша, зачастую и не нужно.

Поговорим о том, что я использовал в решении задачи.
Bloaty
Bloaty, инструмент для профилирования размера бинарных файлов, может эффективно анализировать застрипленные исполняемые файлы, подключая отдельно отладочную информацию. Это позволяет проводить детальный анализ размера, даже если в основном двоичном файле отсутствуют символьная информация или отладочные данные. Такие возможности были крайне важны для нашего проекта.
Для запуска Bloaty с отладочным файлом используется флаг --debug-file.
bloaty --debug-file=/path/to/debug_symbols_file /path/to/my_stripped_executable
Bloaty проверяет соответствие двоичного и отладочного файлов и предотвращает использование несовпадающей отладочной информации.
Для анализа нашего проекта мы также использовали флаги: -n 0, чтобы отключить лимит количество выводимой информации, а также флаг -d и его различные опции. В основном использовалась опция symbols, которая крайне удобно группирует инстанциации шаблонных символов в одну строчку и считает суммарный размер, что дает оценить масштаб проблем, созданных шаблоном. Пример вывода для конструктора шаблонного класса std::vector по умолчанию:
FILE SIZE VM SIZE
-------------- --------------
0.1% 6.21Ki 0.1% 6.21Ki std::vector<>::vector()
Если такой вывод неудобен и надо выяснить, какая инстанциация была наиболее проблемной, то это можно отключить.
В выводе первый столбец (FILE SIZE) говорит, что символ занимает определенный процент и объем памяти в бинарном файле, а второй столбец (VM SIZE) представляет объем виртуальной памяти и процент, занимаемый символом на рантайме. Эти объемы могут различаться. Мы оптимизируем именно размер бинарных данных в файле, а не потребляемый объем оперативной памяти.
Кстати, этот инструмент можно использовать и в Compiler Explorer, чтобы поиграть без локальной установки. Для эксперимента можете попробовать посмотреть на вывод Bloaty для одного из примеров, рассмотренных ниже.
Чтобы стать ближе к разработчикам на С++, подпишитесь на их рассылку. Письма пишут инженеры YADRO (и я в том числе) раз в месяц. Коллеги уже рассказали о фиаско в корутинах, сложных задачах с технических собеседований и о раскрытии кортежа в пачку аргументов. Не пропустите следующее письмо 29 октября!
Сборочные флаги как первый рубильник размера
В процессе оптимизации размера мы провели ревизию наших флагов компиляции и линковки.
Эти флаги доступны в компиляторе GCC. Мы используем 14.2, на других версиях флагов может не быть или эффективность оптимизации может отличаться. В нашем случае мы просто обновили компилятор с версии 13.1 на 14.2 без изменения флагов — объем бинарного файла уменьшился.
Ниже детальнее расскажу, какие флаги могут помочь.
Флаги компилятора
-Os/-Oz
Флаг -Os говорит компилятору оптимизировать код с приоритетом на минимальный размер бинарного файла, а не на максимальную производительность, как -O3. При компиляции он активирует все оптимизации уровня -O2, которые не увеличивают размер кода, и дополнительно применяет алгоритмы оптимизации, специально направленные на уменьшение объема инструкций:
встраивает функции только если это сокращает общий размер,
выбирает более компактные последовательности команд,
удаляет неиспользуемые ветви кода.
При этом производительность остается на приемлемом уровне.
-Oz задает агрессивную оптимизацию на минимальный размер кода, еще более жесткую, чем -Os. При компиляции он отключает почти все оптимизации, которые могут увеличить бинарник, даже если они дают выигрыш в скорости. Результат — максимально компактный исполняемый файл ценой потенциально заметного падения производительности.
-ffunction-sections и -fdata-sections
Когда включен -ffunction-sections, каждая функция компилируется в свою собственную секцию, а -fdata-sections делает то же самое для глобальных переменных. Это позволяет линкеру при использовании флага –gc-sectionsудалить неиспользуемые функции и данные, что существенно уменьшает размер конечного бинарного файла.
Особенно полезно это при статической линковке, когда в бинарь могут попасть лишние символы из внешних библиотек. Важно учитывать, что использование этих флагов может немного увеличить время сборки и размер объектных файлов, но итоговый размер бинарного файла уменьшится.
В нашем случае флаги были включены, но их отключение никак не повлияло на размер. Причиной такого поведения стал уже включенный LTO (Link-Time Optimization). Об этом флаге ниже будет рассказано подробнее.
-fmerge-all-constants
Этот флаг заставляет компилятор унифицировать («слить») все конст��нты, которые имеют идентичное битовое представление, независимо от их типа и области видимости.
В режиме по умолчанию компилятор разрешает только строковые литералы и некоторые другие «очевидные» дубликаты. При -fmerge-all-constants в общую секцию .rodata попадает ровно один экземпляр каждого уникального набора байтов, а все встретившиеся ссылки во всех единицах трансляции перенаправляются на него. Это уменьшает размер статических данных в исполняемом файле и может слегка улучшить кеш-локальность, однако требует осторожности: если программа полагается на разные адреса у «разных» констант (например, сравнивает указатели вместо значений), появляется потенциально неопределенное поведение, так как оптимизация нарушает strict-aliasing.
-fno-threadsafe-statics
Отключает генерацию кода, защищающего локальные статические переменные C++ от одновременной инициализации в многопоточной среде. В режиме по умолчанию компилятор вставляет перед обращением к такой переменной скрытую проверку «уже инициализировано?» и блокировку на глобальном мьютексе при инициализации. Это гарантирует, что конструктор объекта выполнится ровно один раз, даже если несколько потоков одновременно обращаются к переменной, но это несет за собой оверхед. Код проверки и мьютекс занимают место в бинарнике. Когда программа гарантированно стала однопоточной, эта защита не нужна, и ключ -fno-threadsafe-statics убирает и мьютекс, и служебные guard-переменные. Размер бинарника уменьшается.
-fno-exceptions и -fno-rtti
-fno-exceptions — отключает генерацию данных о посадочных площадках catch и развертки стека (__cxa_throw, __cxa_begin_catch, .gcc_except_table, .eh_frame). Проще говоря, нельзя использовать исключения и библиотеки, построенные на них.
-fno-rtti — отключает генерацию type_info-объектов в виртуальных таблицах классов, что запрещает использование dynamic_cast и typeid оператора.
В нашем коде мы активно используем и исключение, и dynamic_cast. Менять архитектуру, переписывать ключевые аспекты функциональности слишком затратно. Отказываться от библиотек, которые построены на исключениях (nlohmann json), мы не готовы. Мы вообще не хотим ограничивать какие-либо фичи языка из-за оптимизации. Таким образом, и исключения, и RTTI остались в проекте, хоть и могли бы дать значительный выигрыш в оптимизируемой метрике.
Ищем коллег в направление BIOS/BMC: инженера-эксперта технической поддержки L3 и Platform Software Engineer. Переходите на карьерный портал и оставляйте отклики.
Флаги линковки
-Wl,–exclude-libs,ALL
Сообщает линковщику, что все символы из статических библиотек должны считаться «локальными» и не попадать в финальную символьную таблицу исполняемого файла. В результате таблица символов становится короче, линковщик может агрессивнее удалять мертвый код. Так как мы используем статические библиотеки при сборке, наш флаг сработал.
-flto
Включает Link-Time Optimization: компилятор переводит каждую единицу трансляции не в машинный код, а в промежуточное представление. GIMPLE — это промежуточный язык, который разбивает выражения на более простые инструкции с тремя операндами. Именно это представление сохраняется внутри объектных файлов. На этапе линковки все эти куски объединяются в единый граф вызовов, и над ним запускается тот же набор оптимизаций, что и внутри одного файла с помощью вызова линкером компилятора.
Поэтому при сборке статически слинкованного исполняемого файла флаг -flto становится ключевым рычагом: без него каждая единица трансляции тащит в бинарник свой набор символов. А с ним линковщик получает единый оптимизированный модуль, в котором остается только тот код, который действительно выполняется.
–WI,–strip-all
Сообщает линковщику, что нужно сразу выкинуть из исполняемого файла все символы, секции .debug_*, .comment, .note и таблицу строк. Превращает ELF в «голый» бинарник, который нельзя отладить и из которого невозможно восстановить имена функций. Но экономия памяти здесь наиболее заметна.
Как альтернативу этому флагу на этапе после линковки мы используем утилиту strip. Она разделяет дебажную информацию от исполняемого файла. Это позволяет получить бинарник, в котором содержится только то, что нужно, при этом все еще иметь возможность подключиться к нему в отладчике GDB, передав ему путь к файлу символов.
Сборочные флаги — самый быстрый способ уменьшить бинарник: зачастую не требуются изменения исходного кода, результат виден сразу после перекомпиляции. Часто именно на этом этапе оптимизацию удается закончить. Дальнейшие оптимизации уже точно потребуют переработки кода и архитектурных решений.
В нашем проекте комбинация описанных ключей уже обеспечивает ощутимую экономию без потери функциональности.
Оптимизации кода
std::variant
В ходе анализа вывода bloaty на фоне всех функций выделялась функция std::__detail::__variant::__gen_vtable_impl<>::__visit_invoke():
FILE SIZE VM SIZE
------------- -------------
15.5% 784Ki 15.4% 784Ki std::__detail::__variant::__gen_vtable_impl<>::__visit_invoke()
Стало понятно: из-за архитектуры хранения данных внутри std::variant и постоянного вызова std::visit мы сильно раздуваем код. Для std::visit существуют альтернативы.
Замена std::visit на if с std::holds_alternative и std::get_if
Такое решение актуально, только если в std::variant содержится какая-то конкретная альтернатива или же кр��йне мал набор подходящих альтернатив. Например:
std::visit([](const auto& v){
if constexpr (std::is_same_v<decltype(v), std::string>) {
// какие-то действия с v
}
}, some_variant);
В этом коде компилятор обязан инстанциировать все функторы для всех альтернатив std::variant.
После чего с помощью __gen_vtable_impl он совмещает их в таблицу переходов Svtable, по которой по индексу альтернативы переходит на выполнение нужного кода. Подобная оптимизация стандартной библиотеки призвана улучшить производительность. Но в нашем случае, где важна только одна альтернатива, можно проверить именно ее и выполнить нужную логику.
if (std::holds_alternative<std::string>(some_variant)) {
auto v = *std::get_if<std::string>(&some_variant);
// какие-то действия с v
}
Код позволит не инстанцировать функтор для всех альтернатив и не составлять таблицу переходов. Кроме того, код намеренно использует std::get_if вместо std::get, что позволяет избежать генерации повторной проверки и выброса исключения, что в нашем случае не нужно. Почему-то не всегда на практике компилятор справляется с оптимизацией этой проверки и пониманием, что в коде уже есть проверка альтернативы.
Замена std::visit на самописное решение
Для оптимизации можно заменить std::visit на свою реализацию с логикой, описанной выше, только внутри обобщенной функции:
template<typename Cb, typename T, std::int64_t N =
std::variant_size_v<std::remove_cvref_t<T>> - 1>
constexpr decltype(auto) visit(Cb&& cb, T&& v)
{
if constexpr (N >= 0)
{
if (v.index() == N) return cb(*std::get_if<N>(&v));
return visit<Cb, T, N - 1>(std::forward<Cb>(cb), std::forward<T>(v));
}
else std::unreachable();
}
Это решение может подойти, когда большинство альтернатив std::variant важны. Тогда инстанциации callback будут оправданы, но при этом код, в отличии от std::visit, не будет генерировать таблицу переходов, что как раз экономит память.
std::shared_ptr
В коде мы активно используем std::shared_ptr в std::vector для хранения полиморфных объектов. Например, многие классы имеют общий предок IAction — абстрактный класс, который объявляет интерфейс всех экшенов, что в итоге попадают в массив для их последовательного выполнения. Контейнер наполняется с помощью многочисленных вызовов std::make_shared с различными классами-реализациями IAction.
В выводе bloaty были следующие строчки, позволившие понять, что довольно большой объем бинаря занимает именно реализации std::shared_ptr:
FILE SIZE VM SIZE
------------- -------------
0.8% 39.4Ki 0.8% 39.4Ki std::_Sp_counted_ptr_inplace<>::_M_get_deleter()
0.7% 35.4Ki 0.7% 35.4Ki std::make_shared<>()
На первом этапе оптимизации мы заменили std::make_shared на простой вызов конструктора std::shared_ptr на IAction от сырого указателя на дочерний класс:
std::vector<std::shared_ptr<IAction>> actions = {
std::shared_ptr<IAction>(new Action1()),
std::shared_ptr<IAction>(new Action2())
};
Это уже заметно снизило размер бинарника. Но мы потеряли важную оптимизацию, которую предлагает нам std::make_shared: он аллоцировал объект в куче сразу вместе со счетчиком ссылок, что сокращало количество аллокаций и используемой памяти во время исполнения. Кроме того, работа с сырыми указателями опасна, так как необходимо вручную обрабатывать исключения и управлять удалением объекта.
Очень быстро стало ясно, что нам совсем не нужен std::shared_ptr. Никакого общего владения мы не используем, а просто хотим иметь контейнер, в котором будут храниться экшены. На ум приходит std::vector<std::unique_ptr> и инициализация его с помощью std::make_unique. Это решение еще оптимальнее по памяти, но есть одно неудобство — невозможность инициализации через список инициализации.
std::vector<std::unique_ptr<IAction>> actions = {
std::make_unique<Action1>(),
std::make_unique<Action2>(),
std::make_unique<Action3>()
};
Такой код не компилируется из-за невозможности инициализации значениями различных типов — std::unique_ptr от от разных экшенов.
Перепишем следующим образом:
std::vector<std::unique_ptr<IAction>> vec{
std::unique_ptr<IAction>(std::make_unique<Action1>()),
std::unique_ptr<IAction>(std::make_unique<Action2>())
};
Код все равно не компилируется, и, судя по выводу компилятора, здесь происходит копирование std::unique_ptr (move-only тип). Вероятно, std::initializer_list конструирует массив своих инициализаторов, что заставляет компилятор при конструировании std::vector на рантайме выполнять копирование инициализаторов.
Попробуем убрать конструирование от std::initializer_list:
std::vector<std::unique_ptr<IAction>> vec{};
vec.push_back(std::make_unique<Action1>());
vec.push_back(std::make_unique<Action2>());
Все работает, компилятор не ругается. Но у кода появляется больше аллокаций по сравнению с конструктором std::vector от std::initializer_list. При добавлении элемента в std::vector внутренний буфер std::vector может расшириться. При этом:
Происходит аллокация нового буфера.
Элементы массива переносятся в новый буфер через вызов конструкторов перемещения
std::unique_ptr.Старый буфер деаллоцируется.
От этих действий можно избавиться, если использовать метод reserve. Но тут стоит вспомнить, что мы не оптимизируем под размер потребляемой памяти во время выполнения или под наименьшее количество переаллокаций, а хотим сократить размер бинарного кода. А так как создание и наполнение вектора экшенов происходит в шаблоне, то reserve только добавит новых операций. Все равно при вызове push_back будет проверка: не переполнился ли вектор, не надо ли снова аллоцировать. Поэтому тут мы остановились на варианте с std::vector<std::unique_ptr> без резервации.
Мы полностью ушли от std::make_shared, оверхед которого мертвым грузом висел в бинаре.
Дешаблонизация
В ходе анализа обнаружили, что некоторые действия бесконтрольно порождают тяжелые методы в бинаре. Как они это делают:
template<typename T, typename U>
class Action42 : public IAction {
// функциональность, завязанная на шаблонные параметры
void someHeavyMethod()
{
// никак не используются T и U
}
}
Тяжелый метод всегда инстанцировался и обязывал линкер оставлять его в бинаре, если он не встраивался сам. Но так как метод тяжелый, вероятность встраивания была крайне мала. Так, во многих местах удалось применить следующий принцип:
class BaseAction42 : public IAction
{
void someHeavyMethod() {
// имплементация
}
};
template<typename T, typename U>
class Action42 : public BaseAction42 {
// функциональность завязанная на шаблонные параметры
};
Теперь инстанциация шаблона Action42 не инстанцирует тяжелый метод, и в конечном счете у нас остается всего один метод someHeavyMethod в бинаре.
На самом деле есть оптимизация ICF (identical code folding), которая позволяет слить одинаковые методы в один, но по какой-то причине она не работает. Нам удалось произвести такую оптимизацию вручную, но не во всех кейсах ее можно применить. Например, бывает, что метод использует шаблонный параметр только для одной операции:
template<typename T, typename U>
class Action42 : public IAction {
// функциональность завязанная на шаблонные параметры
void someHeavyMethod()
{
// никак не используются T и U
// только этот вызов использует T
T::doSomething();
}
};
В этом случае мы можем разделить класс так:
class BaseAction42 : public IAction {
void someHeavyMethod() {
// никак не используются T и U
doSomething();
}
virtual void doSomething() = 0;
};
template<typename T, typename U>
class Action42 : public BaseAction42 {
// функциональность завязанная на шаблонные параметры
void doSomething() override {
T::doSomething();
}
};
Такой подход добавляет значительные расходы на виртуальный вызов, что может оказаться критичным в некоторых случаях и все-таки раздуть бинарь и ухудшить производительность. Но все эти проблемы проявятся, если doSomething станет простым. Метрики кода это не ухудшило, а размер бинаря уменьшился.
Важно понимать: если есть виртуальная таблица, то добавление нового виртуального метода уже не так значительно влияет на размер бинаря, поскольку во все таблицы в иерархии наследования добавляется всего лишь по одному указателю на новую функцию. По сравнению с выигрышем от выноса одной функции из-под шаблона — это совсем небольшая плата.
Мы рассмотрели основные подходы к оптимизации размеров итогового исполняемого файла. Стоит учесть, что не все, что было эффективно для нас, может подойти для кодовой базы вашего проекта. В нашем коде было много наследования, инстанциаций, и статических данных. У вас могут быть другие проблемные точки. В их выявлении и анализе вам поможет Bloaty, а дальше путем проб и ошибок вы найдете то самое изменение, от которого ваш бинарь похудеет к релизу.
Комментарии (18)

tiolal1981
23.10.2025 12:35Круто, что фокус сместился с грубой мощности на умную оптимизацию - так рождается настоящая инженерия!

OlegMax
23.10.2025 12:35if (std::holds_alternative<std::string>(some_variant)) { auto v = *std::get_if<std::string>(&some_variant); // какие-то действия с v }Зачем тут
holds_alternative?
Serpentine
23.10.2025 12:35Используя
holds_alternativeпроще и быстрее сразу проверить: содержит лиsome_variantзначение типа строки (без извлечения этого значения), чем сразу с помощьюstd::get_ifизвлекать из варианта нечто и для неудачных исходов обрабатыватьnullptr.Или у вас другое мнение?

OlegMax
23.10.2025 12:35Ох. Мне кажется, вам пока не хватает интуиции в таких низкоуровневых делах. Проверяйте себя бенчмарками или Godbolt. Свою точку зрения обосновывать, конечно же, не буду

Jijiki
23.10.2025 12:35holds проверяет но get_if как я понял тоже проверит они оба берут значение из варианта, наверно(ну да я не уверен)

ReadOnlySadUser
23.10.2025 12:35Все равно при вызове
push_backбудет проверка: не переполнился ли вектор, не надо ли снова аллоцировать. Поэтому тут мы остановились на варианте сstd::vector<std::unique_ptr>без резервации.Но почему push_back?) C emplace_back было бы компактнее))
std::vector<std::unique_ptr<I>> v; v.emplace_back(new A); v.emplace_back(new B);
Azakarka
23.10.2025 12:35Если я правильно помню, это утечка памяти, если emplace_back кинет исключение

ReadOnlySadUser
23.10.2025 12:35Так все конструкторы у unique_ptr - они noexcept.
Ну, есть вариант, что контейнер память не сможет выделить, то так ли стоит переживать об утечках, если память кончилась?)

Panzerschrek
23.10.2025 12:35Выкидывание лишнего барахла, которое GNU-шный компилятор по одним ему ведомым причинам оставляет в исполняемом файле - дело хорошее. Но вот менять сам код, чтобы сэкономить какие-то крохи размера файла - сомнительная затея. Идиоматичность кода должна быть важнее его итогового размера. Да и сколько так можно сэкономить в итоге? Десять килобайт на мегабайт исполняемого файла? Стоит ли овчинка выделки?

alexey_lukyanchyk
23.10.2025 12:35Стоит ли овчинка выделки?
В случае из статьи, у вас нет выбора. На старых устройствах просто не хватает памяти для новых прошивок. И поэтому приходится оптимизировать, что бы хоть как-то обновлять старые устройства.

RepppINTim
23.10.2025 12:35Ответ на ваш вопрос в самом начале статьи:
На старых платформах уже нет возможности расширить накопитель
Овчинка стоит выделки, когда альтернатива - это сказать заказчику, что его дорогое железо больше не будет получать обновления

RepppINTim
23.10.2025 12:35Полезный разбор полезный, но хотелось бы увидеть больше цифр. Например, "включили флаг -Os - выиграли 200 КБ. Заменили std::visit - еще 50 КБ. Перешли с shared_ptr на unique_ptr - 30 КБ". Без этого сложно оценить реальный вклад каждого из методов

MasterMentor
23.10.2025 12:35Хозяйке на заметку:
Из интервью с автором Total Commander - Кристианом Гислером. 2011 год
-- Широко известный факт, что вы до сих пор пишете свой файл-менеджер Total Commander на "допотопном" Delphi 2. С чем это связано?
-- Я являюсь обладателем лицензионных версий всех последних Delphi, поэтому достаточно хорошо представляю себе их возможности. Но дело тут вот в чем: компиляция exe-файла в Delphi 2 дает на выходе файл ощутимо меньший по размеру, чем, например, в Delphi 7. Кроме того, тестирование показывает, что exe-шник из-под Delphi 2 работает заметно быстрее, чем его полный аналог, выпущенный компилятором Delphi 7. Я сталкиваюсь с тем, когда люди часто удивляются, что Total по-прежнему работает очень быстро - я собираюсь сохранить эту его особенность, и, отчасти, секрет тут в правильно выбранном компиляторе.
...разработка 32-битной версии TC останется на Delphi 2.
...Добавлю, что кроме этого Delphi 2 генерирует очень универсальный код, например, с полной поддержкой 16-битных приложений или Windows 95/98 - у меня до сих пор хватает таких клиентов.пора, брат, пора.
zzzzzzerg
Статья интересная (без шуток) - спасибо!
Но все таки - как уменьшить кодовую базу? Потому что, например, ваш пример с дешаблонизацией кодовую базу только увеличит.
Jijiki
там если уходить от виртуал не получается нужен общий конструктор и ситуации всякие
https://godbolt.org/z/jc5vPaPW6 ну у меня так получилось
RepppINTim
В embedded мире часто приходится жертвовать идиоматичностью и красотой кода ради соответствия жестким ограничениям по памяти или производительности, пример с дешаблонизацией как раз из этой оперы - код становится чуть сложнее для чтения, но зато прошивка влезает в чип
zzzzzzerg
Я понимаю разницу. Автор поменял название статьи - в оригинале было - как уменьшить кодовую базу.
В текущем названии вопросов нет.