В этой статье речь пойдёт о повышении скорости компиляции библиотеки {fmt} до уровня библиотеки ввода-вывода Cи stdio.
Дня начала немного теории. {fmt} – это популярная открытая библиотека С++, представляющая более эффективную альтернативу С++ библиотеке iostreams и библиотеке Си stdio. Последнюю она обошла по целому ряду аспектов:
-
Безопасность типов с проверками форматирующих строк во время компиляции. Эти проверки включены по умолчанию начиная с С++ 20, и присутствуют в качестве дополнения для С++ 14/17. Форматирующие строки среды выполнения в {fmt} также оказываются безопасными, чего невозможно достичь в
printf
. - Расширяемость. Определяемый пользователем тип можно сделать форматируемым. При этом большинство типов стандартных библиотек, например, контейнеры и пакеты для обработки даты и времени, предлагают возможность форматирования изначально.
-
Производительность. {fmt} намного быстрее любой распространённой реализации
printf
, порой на несколько порядков (например, в форматировании чисел с плавающей запятой). - Возможность переноса поддержки Unicode.
Тем не менее одной из областей, в которой stdio по-прежнему опережала {fmt}, являлось время компиляции.
Мы вложили немало усилий в оптимизацию времени компиляции {fmt}, применив стирание типов на уровне аргументов и вывода, ограничив шаблоны небольшим слоем API верхнего уровня и добавив
fmt/core.h
с минимальным числом зависимостей.В итоге {fmt} стала компилироваться быстрее таких альтернатив С++, как iostreams, Boost Format и Folly Format, но до скорости stdio всё равно не дотягивала. Мы понимали, что узким местом является зависимость
<string>
, но она была необходима для основного API, fmt::format
.Со временем стало понятно, что в некоторых случаях использование
std::string
не является необходимым. Процитирую комментарий Sean Middleditch с GitHub:Если я не используюstd::string
(а так оно и есть), то не хочу привлекать тяжёлые зависимости для этого заголовка и для каждой единицы трансляции, которая может выполнять какое-либо форматирование (а значит, требует доступа к специализациямformatter<>
).
{fmt} стала всё чаще использоваться для ввода-вывода и библиотек логирования, где объекты
std::string
могут появляться только в виде аргументов в некоторых точках вызова.И самым важным случаем использования их всех, естественно, является проект Godbolt, в котором {fmt} часто применяют для вывода, особенно не поддерживаемого
printf
, и здесь несколько сотен накладных миллисекунд оказываются заметны.С другой стороны, в С++ трудно избежать
<string>
. При использовании любой части библиотеки она наверняка будет подтягиваться транзитивно. К тому же, время компиляции оказывалось вполне терпимым, и поскольку у меня были другие задачи, то этим вопросом я долгое время не занимался.Однако с выходом С++20 ситуация сильно изменилась. Взгляните на следующую программу Hello World с простым форматируемым выводом (hello.cc):
#include <fmt/core.h>
int main() {
fmt::print("Hello, {}!\n", "world");
}
В случае C++11 её компиляция через Clang на моём M1 MacBook Pro заняла ~225 мс (здесь и ниже я привожу лучший результат из трёх выполнений):
% time c++ -c hello.cc -I include -std=c++11
c++ -c hello.cc -I include -std=c++11 0.17s user 0.04s system 90% cpu 0.225 total
Теперь же при работе в C++20 тот же процесс занимает ~319 мс, то есть оказывается на 40% дольше:
% time c++ -c hello.cc -I include -std=c++20
c++ -c hello.cc -I include -std=c++20 0.26s user 0.05s system 95% cpu 0.319 total
К сравнению, вот равноценная программа на Си (hello-stdio.c):
#include <stdio.h>
int main() {
printf("Hello, %s!\n", "world");
}
И она компилируется всего за ~33 мс:
% time cc -c hello-stdio.c
cc -c hello-stdio.c 0.01s user 0.01s system 68% cpu 0.033 total
Получается, ввиду неконтролируемого раздувания стандартной библиотеки между версиями С++11 и С++20 компиляция стала примерно в 10 раз медленнее в сравнении с
printf
– и всё из-за включения <string>
. Можно ли с этим что-то сделать?Как оказалось, стирание типов минимизировало присутствующую в
fmt/core.h
зависимость от std::string
, поэтому я решил попробовать её удалить. Но сначала рассмотрим процесс компиляции подробнее путём трассировки:c++ -ftime-trace -c hello.cc -I include -std=c++20
Также откроем hello.json в Chrome с помощью
chrome://tracing/
:Время, проведённое в самом
fmt/core.h
, составляет всего 7,5 мс и в основном состоит из:-
<iterator>
: ~71 мс; -
<memory>
: ~37 мс; -
<string>
: ~122 мс (выделены в трейсе выше).
Хорошо,
<string>
действительно выполняется дольше всех, но что насчёт остальных? К сожалению, удаление других компонентов ситуацию не изменит, поскольку объём транзитивно подтягиваемого материала останется примерно таким же. Эти заголовочные файлы отражаются в трейсе, только потому, что включены до <string>
.Хорошенько погуглив вопрос, я выяснил, что, благодаря
_LIBCPP_REMOVE_TRANSITIVE_INCLUDES
, можно кое-что проделать в libc++. Попробуем:% time c++ -D_LIBCPP_REMOVE_TRANSITIVE_INCLUDES -c hello.cc -I include -std=c++20
c++ -D_LIBCPP_REMOVE_TRANSITIVE_INCLUDES -c hello.cc -I include -std=c++20 0.18s user 0.03s system 91% cpu 0.231 total
Итак, это сократило время компиляции до ~231 мс, почти до уровня С++11. Неплохо, хотя до stdio ещё далеко.
Но в отсутствии транзитивных зависимостей теперь есть смысл избавиться от
<iterator>
и <memory>
.<memory>
используется всего в одном месте для std::addressof
в качестве обхода сломанной реализации std::vector<bool>::reference
в libc++, которая обеспечивает инновационный способ перегрузки унарного оператора &
. Вот это место:custom.value = const_cast<value_type*>(std::addressof(val));
Мы можем заменить её несколькими операциями приведения, поплатившись за это утратой возможности непосредственного форматирования
std::vector<bool>::reference
во время компиляции, с чем я вполне могу смириться:if constexpr (std::is_same<decltype(&val), T*>::value)
custom.value = const_cast<value_type*>(&val);
if (!is_constant_evaluated())
custom.value = const_cast<char*>(&reinterpret_cast<const char&>(val));
Теперь, когда у нас больше нет
<memory>
(я бы предпочёл забыть об этом обходном решении (здесь игра слов, don't have memory of, — прим. пер.)), время компиляции сократилось до ~195 мс, уже лучше, чем изначальный показатель в С++11.Удаление окажется более хитрой задачей, поскольку мы используем
back_insert_iterator
для обнаружения и оптимизации форматирования в неразрывных контейнерах. К сожалению, обнаружить это нельзя даже с помощью SFINAE, потому что back_insert_iterator
имеет ту же форму API, что и front_insert_iterator
. У этой проблемы есть разные решения, например, перемещение оптимизации в fmt/format.h
. Я же пока добавил простую локальную замену, fmt::back_insert_iterator
. Без <iterator>
время компиляции сократилось до ~178 мс.Здесь наступает подходящий момент для того, чтобы взяться за
<string>
, но, как оказывается, мы также ненамеренно включили <string_view>
, или <experimental/string_view>
(вздох). Это не добавляет непосредственных издержек, потому что всё равно подтягивается из <string>
, но нам нужно удалить одно, чтобы избавиться от другого. У нас в диапазонах уже есть класс свойств (trait) для обнаружения API, похожего на std::string_view
, и мы можем применить его с некоторым упрощением:template <typename T, typename Enable = void>
struct is_string_like : std::false_type {};
// Эвристика для обнаружения std::string и std::string_view.
template <typename T>
struct is_string_like<T, void_t<decltype(std::declval<T>().find_first_of(
typename T::value_type(), 0))>> : std::true_type {
};
Это может дать ложные положительные результаты, но они окажутся безобидны, поскольку в худшем случае это приведёт к тому, что тип, который выглядит как строка, будет отформатирован как строка. Если что, вы всегда можете от этого отказаться.
Вот мы и подошли к финальному боссу,
<string >
. В fmt/core.h
было очень мало ссылок на std::string
. Тем не менее у нас также был std::char_traits
, который мы использовали в резервной реализации string_view
, необходимой для совместимости с C++11. char_traits
не имел особой ценности, поэтому его было легко заменить функциями Си, такими как strlen
и её резервными вариантами для constexpr
.Единственным API, использовавшим
std::string
, был fmt::format
. Один из вариантов заключался в его перемещении в fmt/format.h
. Но это бы стало критическим изменением, поэтому я решил пойти на ужасный, но ничего не нарушающий, шаг и предварительно объявить std::basic_string
. Подобные действия не одобряются, но это не худшее, что нам пришлось проделать в {fmt}, чтобы обойти ограничения стандартных библиотек Си и С++. Вот немного упрощённая версия:#ifdef FMT_BEGIN_NAMESPACE_STD
FMT_BEGIN_NAMESPACE_STD
template <typename Char>
struct char_traits;
template <typename T>
class allocator;
template <typename Char, typename Traits, typename Allocator>
class basic_string;
FMT_END_NAMESPACE_STD
#else
# include <string>
#endif
FMT_BEGIN_NAMESPACE_STD
и FMT_END_NAMESPACE_STD
определяются в зависимости от реализации. Сейчас поддерживаются обе ведущие стандартные библиотеки, libstdc++
и libc++
.Естественно, с нашим определением
fmt::format
это не сработало:template <typename... T>
FMT_NODISCARD FMT_INLINE auto format(format_string<T...> fmt, T&&... args)
-> basic_string<char> {
return vformat(fmt, fmt::make_format_args(args...));
}
И мы получили следующую ошибку:
In file included from hello.cc:1:
include/fmt/core.h:2843:31: error: implicit instantiation of undefined template 'std::basic_string<char, std::char_traits<char>, std::allocator<char>>'
FMT_NODISCARD FMT_INLINE auto format(format_string<T...> fmt, T&&... args)
^
Как это часто бывает в C++, решением стало использование дополнительных
template <typename... T, typename Char = char>
FMT_NODISCARD FMT_INLINE auto format(format_string<T...> fmt, T&&... args)
-> basic_string<Char> {
return vformat(fmt, fmt::make_format_args(args...));
}
Теперь проверим, стоило ли оно того:
% time c++ -c hello.cc -I include -std=c++20
c++ -c hello.cc -I include -std=c++20 0.04s user 0.02s system 81% cpu 0.069 total
Мы сократили время компиляции с ~319 мс до ~69 мс и при этом больше не нуждаемся в
_LIBCPP_REMOVE_TRANSITIVE_INCLUDES
. В результате всех оптимизаций fmt/core.h
стал сопоставим с stdio.h
по времени компиляции – тестирование показало лишь 2х кратное отличие в скорости. Думаю, это разумная плата за повышенную безопасность, быстродействие и расширяемость.▍ P.S.
После оптимизации
stdio.h
стал вторым по тяжести включением, увеличивающим компиляцию на целые 5 мс.Скидки, итоги розыгрышей и новости о спутнике RUVDS — в нашем Telegram-канале ????
Комментарии (24)
Kelbon
12.01.2024 13:33+3в С++20 уже можно использовать import std которое должно эти проблемы решить
equeim
12.01.2024 13:33+2Модули не помогут в случае использования шаблонов (они также создаются на месте использования отдельно для каждого юнита компиляции), а именно от них происходит большая часть замедления времени компиляции. Хотя возможно не в этот конкретном случае, хз.
(и import std это C++23 кстати)
Kelbon
12.01.2024 13:33+2В этом случае нет, шаблоны нужно будет создавать в любом случае и это уже оптимизировано (не знаю насколько хорошо)
(и import std это C++23 кстати)
все реализации сделали это расширением в С++20
sv91
12.01.2024 13:33Както замерял время компиляции в своих проектах. И оно большое не из-за шаблонов. Скорее всего, оно большое просто из-за количества кода в хидерах - их ведь надо загрузить, подставить, раскрыть макросы, спарсить...
justice_872
12.01.2024 13:33+2Очень интересно все это выглядит. Я когда пришел на проект занялся как раз оптимизацией времени сборки и расхода ресурсов: за 4 месяца работы удалось снизить время сборки с 11 до 8 минут, расход рам с 211 до 170 и по процу почти 15% (сугубо по стресс тесту - сервер мморпг )
спасибо за статью
1dNDN
12.01.2024 13:33за 4 месяца работы
расход рам с 211 до 170
Иногда дешевле купить в сервер планку памяти, чем оплачивать время разработчика, чтобы влезать в существующую память. Впрочем да, это не всегда актуально
chnav
12.01.2024 13:33+2У меня маленькие любительские проектики, "stdio.h" хватает за глаза, но иногда не уследишь за форматированием. Существуют ли какие-то утилиты, которые можно натравливать на исходники в Pre-Build Event ? Этакий статистический анализатор, заточенный на (v)printf и scanf ?
<spoiler title="Осторожно, боль ))">
Мой первый справочник по С, табличка строки форматирования... Сколько было радости (после Фортрана) от этой тоненькой книжки, 96 страниц чуть шире формата A6. Когда в начале 90-х в руки попал толстый кирпич Страутсрупа с вырвиглазными << и >> я понял, что не смогу привыкнуть к потоковуму вводу-выводу. Один << endl вместо "\n" чего стоит.
</spoiler>
chnav
12.01.2024 13:33+2>> Этакий статистический анализатор
Извиняюсь, речь конечно же про статический анализатор. И ещё непонятно почему у меня не спрятался спойлер.
Panzerschrek
12.01.2024 13:33Экономия по факту бессмысленная. В подавляющем большинстве пользовательских программ наверняка уже
<string>
включается почти уж везде, а уж тем более, где нужна эта библиотека fmt.
remova
12.01.2024 13:33+2Спасибо за интересную статью!
Простите за оффтоп, но есть небольшой вопрос. Библиотека {fmt}, про которую идет речь, пример классной полезной библиотеки. Однако я, как программист С++ с 10+летним опытом, про нее ничего не знал до этой статьи. В моем случае это объяснятся двумя факторами: а) я пишу на Qt, в котором есть почти все, что можно захотеть, б) задачи, которые я решаю, специфичные, нешаблонные, неповторяемые по большей части. Так вот вопрос такой:
Есть ли какие-то более-менее централизованные базы/опиcания/списки/дайджесты библиотек на С++? В мире python, как кажется со стороны, в этом плане все неплохо.
Если более конкретно, то прямо сейчас меня интересуют:
1) библиотеки для создания видео (здесь Qt подкачал, к сожалению) - порождение кадров и склейка их со сжатием и наложением звука. Пока знаю только про ffmeg.
2) неcтандартные виджеты на Qt. Изредка обращаюсь на эту тему к github`у, но никогда оттуда ничего интересного на эту тему не видел. Под нестандартными имею в виду что-то типа такого.
Заранее спасибо.eao197
12.01.2024 13:33+2Есть ли какие-то более-менее централизованные базы/опиcания/списки/дайджесты библиотек на С++?
rukhi7
12.01.2024 13:331) библиотеки для создания видео (здесь Qt подкачал, к сожалению) - порождение кадров и склейка их со сжатием и наложением звука. Пока знаю только про ffmeg.
вот здесь:
https://habr.com/ru/articles/738350/
я попытался сформулировать что-то о том как выглядит работа с видео изнутри, но вы, как мне кажется, ищите с точки зрения того как это выглядит снаружи. Если найдете что то полезное, мне будет приятно. Под Линукс была аналогичная технология GStreamer называлась, не знаю что от нее осталось.
rukhi7
почему то приходят на ум кроты из старого мультфильма про Дюймовочку:
Skykharkov
Ну, справедливости ради, это очень хорошее сокращение.
Я вот как-то комп себе девелоперский обновил, на 90% из-за того что на старом, один long term проект полностью компилился полторы-две минуты. Задолбался я каждые 10-15 минут ждать по минуте. Для разработки хватало, а время компиляции раздражало жутко. Так что ускорение компиляции примерно в пять раз - вполне себе достойная цель.
MiraclePtr
это вы еще Хромиум не компилировали, который на средненькой девелоперской машине с нуля полностью компилируется часа так четыре
Skykharkov
Раз в пару месяцев компилю. :)
На старой машинке - одиннадцать часов. На новой - три.
rukhi7
Я что-то припоминаю у меня ядро какого-то Линукса компилировалось кроскомпиляцией в 2006 году, ну примерно может минут десять-полчаса, в общем кажется не больше часа.
Что ж вы за зверей разводите? Они на библиотеки ни как не делятся? Может построили не правильно?
Я думаю при таких масштабах как у вас вам никакие локальные советы-улучшения, как в этой статье, все равно не помогут.
Skykharkov
Я про компиляцию хрома. :)
Ему числомолотилка особо мощная не нужна. Ему потоков побольше. На четырех ядрах (i5) ~ 10-11 часов. На 16 (i7 средний) ~ 3 часа, но еще от опций зависит, конечно.
fshp
Ловите гентушника!
Skykharkov
А что сразу гентушник! Привыкли чуть что, гентууууушник... :)
Виндузятник на 80%. Просто специфические вещи приходится делать. Например, по дефолту, хромиум компилится без поддержки проприетарных кодеков. А мне надо! Или биндинги наружу выставить специфические. Начинал как все, с CefSharp. Ну а потом покатился по наклонной. И вот меня уже застают за компиляцией хрома. Первые разы было стыдно. Позорище. Горе в семье.
Потом родные привыкли, только шушукаются за спиной. И ребенка больше не спрашивают в школе, чем папа занимается.
hapcode
А я поддержу гентушника). Разработчики Хрома постоянно какую-то фигню делают - прячут http\https из адреса, увеличивают размеры UI контролов, или впихивают ненужные мне элементы. Вот и приходится исправлять и пересобирать.
Но у меня как-то шустрее собирается: На i5-12600 (macos) — 2,5 часа, на i7-13700 (win11) — чуть больше часа. Оба на SSD.
NN1
Сборка Хрома тот ещё квест.
Целый блог ведётся про постоянные проблемы при сборке
https://randomascii.wordpress.com/2023/03/08/when-debug-symbols-get-large/
equeim
В C++ это надо умножать на количество юнитов компиляции где этот заголовок используется. Большие проекты состоят из десятков тысяч юнитов, а учитывая что fmt используется в основном в связке с логированием то достаточно большая часть будет его инклюдить.