Во всех устройствах 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, а дальше путем проб и ошибок вы найдете то самое изменение, от которого ваш бинарь похудеет к релизу.