Привет, Хабр! Меня зовут Александр Романов, и я работаю программистом в компании Syntacore. Как и многие другие другие разработчики на C++, я слышал о преимуществах нового std::format: удобство, безопасность и высокая производительность по сравнению с более старыми способами форматирования строк. 

Моя жизнь была прекрасна и полна надежд, пока я не увидел один бенчмарк, где format оказался медленнее всех. Как же так? Неужели «устаревший» std::stringstream или даже operator+ все еще лучше? Далее расскажу о своем небольшом исследовании производительности форматирования, доступного разработчикам на C++, и о необычных результатах, которые я получил.

«Какая еще производительность форматирования? Работа с вводом и выводом всегда будет медленной», — подумаете вы. Да, измерять производительность форматирования при работе с I/O — бессмысленная задача. Само отображение результата часто занимает в разы больше времени, чем его создание. Однако форматирование строк нужно не только для вывода, но и для задач, более требовательных к производительности. В таких случаях хочется использовать самый удобный и быстрый вариант. Давайте же выясним, для каких задач подходят те или иные решения. 

Под форматированием здесь понимается процесс подстановки значений (чисел или строк) в определенные места внутри строки-шаблона.

Несколько недель назад мне попался на глаза один интересный бенчмарк. Как можно заметить, здесь на форматировании строк std::format обогнали все кому не лень:

Отмечу, что std::stringstream в этом примере обгоняет std::format не совсем честно. Дело в том, что мы должны измерять весь процесс создания строки с нуля, то есть вместе с конструированием std::stringstream. При выносе интересного нам кода в функции результат меняется:

Однако простое сложение строк все еще доминирует. Почему так? Я удивился, но результат можно понять. Все, что происходит в этом бенчмарке, — это конкатенация строк. А это единственное назначение оператора сложения. Ему, в отличие от stringstream, не нужно учитывать локаль, и этот эффект не компенсируется лишними выделениями памяти на временные объекты std::string. С std::format все уже не так просто. Может быть, всему виной неэффективное выделение памяти? Не беда, используем std::format_to и будет выводить результат в заранее аллоцированную строку. Ой…

Cтало еще хуже. Однако и этот результат можно объяснить. Да, лишние аллокации ушли, однако std::back_inserter вызывает std::string::push_back на каждый символ в строке. Эта операция обязана проверять, что мы не вылезаем за границы аллоцированной памяти. Такая проверка замедляет форматирование в разы сильнее чем аллокации. Исправим наш пример и получим более приемлемый результат:

Но сложение по-прежнему почти в два раза быстрее. Вы наверняка слышали, что стандартные std::print и std::format вдохновлялись библиотекой fmt. Давайте проверим ее производительность. На quickbench это сделать нельзя, буду запускать локально.

Приведу результаты сборки компилятором GCC 14.3.0 с оптимизацией -O3, запускал на i5-1235U.

Протестируем следующие функции:

constexpr char fmt_string[] = "//{}//{}//{}//{}//{}//{}//{}//{}//{}//{}";
std::string stream_get() {
  std::stringstream ss;
  ss << "//" << strings[0] << "//" << strings[1] << "//" << strings[2] << "//"
     << strings[3] << "//" << strings[4] << "//" << strings[5] << "//"
     << strings[6] << "//" << strings[7] << "//" << strings[8] << "//"
     << strings[9];
  return ss.str();
}

std::string format_get() {
  return std::format(fmt_string, strings[0], strings[1], strings[2], strings[3],
                     strings[4], strings[5], strings[6], strings[7], strings[8],
                     strings[9]);
}

std::string fmt_format_get() {
  return fmt::format(fmt_string, strings[0], strings[1], strings[2], strings[3],
                     strings[4], strings[5], strings[6], strings[7], strings[8],
                     strings[9]);
}

std::string add_get() {
  std::string delim = "//";
  return delim + strings[0] + delim + strings[1] + delim + strings[2] + delim +
         strings[3] + delim + strings[4] + delim + strings[5] + delim +
         strings[6] + delim + strings[7] + delim + strings[8] + delim +
         strings[9];
}
# Маленькие (size() <= 15) строки
-----------------------------------------------------------
Benchmark                 Time             CPU   Iterations
-----------------------------------------------------------
stream_bench            323 ns          322 ns      2236518
format_bench            233 ns          232 ns      3051029
fmt_format_bench        165 ns          165 ns      4257869
add_bench               132 ns          132 ns      5273972
  
# Большие (size() >= 33) строки
-----------------------------------------------------------
Benchmark                 Time             CPU   Iterations
-----------------------------------------------------------
stream_bench            502 ns          501 ns      1304711
format_bench            401 ns          400 ns      1767739
fmt_format_bench        376 ns          375 ns      1864122
add_bench               234 ns          233 ns      3032922

Видим, что fmt::format работает стабильно быстрее стандартного аналога, но все еще проигрывает сложению. На этом этапе я готов отдать победу оператору сложения строк. 

Но не волнуйтесь, не стоит бояться std::format или считать его ужасно медленным. Все мои замеры относились исключительно к конкатенации строк — задаче, для которой был создан operator+ — и не включали форматирование чисел, выравнивание вывода и другие задачи, являющиеся профильными для std::format

Если же начнем форматировать числа, результат станет совершенно иным. Рассмотрим в ставку заранее сгенерированных случайных чисел в ту же шаблонную строчку:

constexpr char fmt_string[] = "//{}//{}//{}//{}//{}//{}//{}//{}//{}//{}";
std::string format_get() {
  return std::format(fmt_string, numbers[0], numbers[1], numbers[2], numbers[3],
                     numbers[4], numbers[5], numbers[6], numbers[7], numbers[8],
                     numbers[9]);
}

std::string fmt_format_get() {
  return fmt::format(fmt_string, numbers[0], numbers[1], numbers[2], numbers[3],
                     numbers[4], numbers[5], numbers[6], numbers[7], numbers[8],
                     numbers[9]);
}

std::string add_get() {
  std::string delim = "//";
  return delim + std::to_string(numbers[0]) + delim +
         std::to_string(numbers[1]) + delim + std::to_string(numbers[2]) +
         delim + std::to_string(numbers[3]) + delim +
         std::to_string(numbers[4]) + delim + std::to_string(numbers[5]) +
         delim + std::to_string(numbers[6]) + delim +
         std::to_string(numbers[7]) + delim + std::to_string(numbers[8]) +
         delim + std::to_string(numbers[9]);
}

Получим намного более ожидаемый результат:

-----------------------------------------------------------
Benchmark                 Time             CPU   Iterations
-----------------------------------------------------------
format_bench            408 ns          407 ns      1743585
fmt_format_bench        225 ns          225 ns      3108524
add_bench               447 ns          446 ns      1601618

std::format все еще проигрывает сложению, но fmt::format вырывается на уверенное первое место. Тенденция сохраняется и для чисел с плавающей точкой (любого диапазона значений), но об их форматировании при помощи std::to_string и operator+ тяжело рассуждать — std::to_string порождает неизвестное и ненастраиваемое число знаков после запятой. В этом случае любой format превращается в очевидный выбор.

Отмечу, что я использовал fmt 10.2.1, а в последней версии 12.0.0 форматирование чисел с плавающей точкой ускорили еще на 60%. Такое ускорение делаетfmt::format как самым удобным, так и самым быстрым способом форматирования чисел.

Разумеется, все проведенные мной замеры — не безусловная истина, а лишь промежуточные результаты. Несмотря на это, сделаю несколько осторожных выводов:

  • Для каждой задачи нужно использовать специализированный инструмент. operator+ идеален для конкатенации строк. Его создали для этого, он не делает лишней работы и великолепно оптимизируется компиляторами.

  • fmt::format подходит для форматирования строк, но выходит на первое место в форматировании чисел. Конкатенируйте строки при помощи сложения. Форматируйте произвольные типы при помощи format.

  • К моему большому сожалению, std::format отстает от fmt::format во всем: как в скорости, так и в функциональности. На мой взгляд, эти проблемы связаны с лишними копированиями в libstdc++, но их можно исправить (производительность libc++ в этом вопросе я пока не исследовал). Остается ждать и надеяться, что разработчики стандартных библиотек смогут лучше оптимизировать std::format в будущем.

  • Аккуратно используйте format_to в местах, где важна производительность, и проверяйте, как он повлиял на производительность в вашем конкретном случае.

Если остались вопросы, задавайте их в комментариях. А узнать больше о разработке на «плюсах» можно на System Level Meetup. Регистрируйтесь на трек С/С++, чтобы послушать доклады Константина Владимирова, Ильи Шишкова, Антона Полухина и других экспертов.

Комментарии (0)