Comments 25
Замерил я работу fmt::format, в обоих вариантах. Чтобы было интересней, делал не просто конкатенацию строк и числа, а вот такой код:
for (unsigned i = 1; i <= 100'000; i *= 10) {
std::string str = fmt::format(FMT_COMPILE("art {:#010o} end"), i);
benchmark::DoNotOptimize(str);
}
То есть строка плюс число в восьмеричной форме с префиксом 0, дополненное нулями, общей шириной 10, плюс ещё строка. То есть замеряем время построения таких строк:
art 0000000001 end
art 0000000012 end
art 0000000144 end
art 0000001750 end
art 0000023420 end
art 0000303240 end
В simstr есть упрощённые аналоги std::format и std::vformat, но они не поддерживают флагов форматирования внутри форматной строки, аргументами для них должны быть уже готовые строковые выражения в нужном формате, но с такой задачей они справятся.
Результаты на моём Xeon E5-2682v4 2.5GHz получились такие:
Benchmark | Win10| Ub22 | Ub22 |
|Clang19|Clang21|CGG-13 |
-----------------------------------------------------------------------------
"art "_ss + i / 0x8a010_fmt + " end" | 103 ns| 119 ns|92.6 ns|
e_subst("art {} end", i / 0x8a010_fmt) | 156 ns| 178 ns| 139 ns|
e_vsubst(pattern, i / 0x8a010_fmt) | 304 ns| 363 ns| 326 ns|
fmt::format(FMT_COMPILE("art {:#010o} end"), i) | 850 ns| 512 ns| 489 ns|
fmt::format("art {:#010o} end", i) |1233 ns| 738 ns| 733 ns|
std::format("art {:#010o} end", i) |1400 ns| 986 ns|1069 ns|
std::vformat(pattern, std::make_format_args(i)) |1381 ns|1011 ns| 873 ns|
strm << "art 0" << std::oct << std::setw(9) << std:)|7312 ns|2052 ns|2011 ns|
Да, работает быстрее, чем просто format, но...
Ладно, ладно... Я немного схитрил в этом замере. Здесь замеры для моих способов проводились при конкатенации в мой строковый тип stringa. А в нём размер буфера для SSO не 15 байт, как в std::string, а 23. Поэтому у него результат не требовал аллокаций :) Вот замеры, если во всех тестах конкатенировать в std::string (сразу видим, какой в Win тормозной дефолтный аллокатор):
Benchmark | Win10| Ub22 | Ub22 |
|Clang19|Clang21|CGG-13 |
-----------------------------------------------------------------------------
"art "_ss + i / 0x8a010_fmt + " end" | 599 ns| 203 ns| 207 ns|
e_subst("art {} end", i / 0x8a010_fmt) | 737 ns| 280 ns| 272 ns|
e_vsubst(pattern, i / 0x8a010_fmt) | 896 ns| 467 ns| 545 ns|
fmt::format(FMT_COMPILE("art {:#010o} end"), i) | 840 ns| 488 ns| 527 ns|
fmt::format("art {:#010o} end", i) |1235 ns| 718 ns| 739 ns|
std::format("art {:#010o} end", i) |1359 ns| 938 ns|1068 ns|
std::vformat(pattern, std::make_format_args(i)) |1360 ns| 962 ns| 910 ns|
strm << "art 0" << std::oct << std::setw(9) << std:)|6565 ns|2017 ns|1991 ns|
И всё-равно в результате
My string fu is stronger than yours
Хотя, если уж начинать использовать simstr, зачем отказываться от stringa? Она на 8 байт меньше, чем std::string, а буфер SSO на 8 байт больше.
Программисты на других языках без излишних мудрствований строки просто "складывают", даже не особо задумываясь об этой операции
Везде конкатенируют )
В v8 (js) под капотом используется Rope, и потому "сложение" не такое грустное, как на первый взгляд. Хотя конечно медленнее специализированного варианта.
Ну, описываемый мной метод по сути и строит в compile-time древовидную структуру из кусочков строк, и при выполнении схлопывает их в результат. Только здесь эти "кусочки" могут быть не только существующими строками, но и "генераторами", могущими создавать строки на лету. В библиотеке у меня довольно приличное количество разных генераторов, на разные случаи.
проблема глубже, поидее, на мой не экспертный взгляд тут проблема владения, у большой строки, даже если она строка должно быть выделено фиксированное количество символов,
доставщики должны быть фиксированные поидее, фиксация за кадр должна быть 1 если это критично
это чем-то похоже на воксели, я теперь везде воксели(кстати советую, там можно сделать не на компут шейдерах, будет прям мотоцикл в миниатюре, с буферами и память не перевыделяется) вижу, потомучто тут красивейшая структура управления ресурсами памяти
если это очень большие данные можно замапать 3и буфера - как три страницы(1х-4096), 1 страница - это временный буфер(но удалять его нельзя, только очищать это циркуляция данных), 2 другие рабочие типо, если такие строки с доставщиками правильно отстроить выделение памяти остановится поидее
Если что, эта техника называется "expression templates". Главное здесь - правильно следить за лайфтаймами, а то можно случайно... да... Константные ссылки в полях класса, как известно, не продлевают лайфтайм.
Совершенно верно. Я тут даже статью размещал.
Вообще, Expression Templates были ещё аж в 1994 году придуманы. И даже ещё на том C++ уже могли работать, до всяких концептов и прочего. Только чуть многословнее получались.
На счёт времени жизни - вы тоже совершенно правы, я в статье как-раз сделал оговорку, что
Второе - то, что в C++ промежуточные временные объекты во время выполнения выражения живут до его конца, до точки с запятой
Где-то в документации у меня даже обращается внимание, что "передавать строковое выражение в функцию безопасно, а вот возвращать - нет". Но если очень хочется, то в simstr есть таки способ.
std::format меделнный, потому что в runtime всё делает.
Можете для эксперимента добавить вариант на популярной библиотеке {fmt} с compile-time выражением?
#include <fmt/format.h>
#include <fmt/compile.h>
std::string make_answer_fmt(std::string_view str_answer, int count) {
return fmt::format(FMT_COMPILE("The answer is {}, count is {}"), str_answer, count);
}
C обычным fmt::format я для себя сравнивал, там результаты примерно одинаковые с std::format.
Спасибо за наводку с FMT_COMPILE - обязательно попробую и дополню - и бенчмарки, и статью, и комментарий. Будет интересно сравнить со своим e_subst, который примерно то же самое умеет делать.
Замерил я работу fmt::format, в обоих вариантах. Чтобы было интересней, делал не просто конкатенацию строк и числа, а вот такой код:
for (unsigned i = 1; i <= 100'000; i *= 10) {
std::string str = fmt::format(FMT_COMPILE("art {:#010o} end"), i);
benchmark::DoNotOptimize(str);
}
То есть строка плюс число в восьмеричной форме с префиксом 0, дополненное нулями, общей шириной 10, плюс ещё строка. То есть замеряем время построения таких строк:
art 0000000001 end
art 0000000012 end
art 0000000144 end
art 0000001750 end
art 0000023420 end
art 0000303240 end
В simstr есть упрощённые аналоги std::format и std::vformat, но они не поддерживают флагов форматирования внутри форматной строки, аргументами для них должны быть уже готовые строковые выражения в нужном формате, но с такой задачей они справятся.
Результаты на моём Xeon E5-2682v4 2.5GHz получились такие:
Benchmark | Win10| Ub22 | Ub22 |
|Clang19|Clang21|CGG-13 |
-----------------------------------------------------------------------------
"art "_ss + i / 0x8a010_fmt + " end" | 103 ns| 119 ns|92.6 ns|
e_subst("art {} end", i / 0x8a010_fmt) | 156 ns| 178 ns| 139 ns|
e_vsubst(pattern, i / 0x8a010_fmt) | 304 ns| 363 ns| 326 ns|
fmt::format(FMT_COMPILE("art {:#010o} end"), i) | 850 ns| 512 ns| 489 ns|
fmt::format("art {:#010o} end", i) |1233 ns| 738 ns| 733 ns|
std::format("art {:#010o} end", i) |1400 ns| 986 ns|1069 ns|
std::vformat(pattern, std::make_format_args(i)) |1381 ns|1011 ns| 873 ns|
strm << "art 0" << std::oct << std::setw(9) << std:)|7312 ns|2052 ns|2011 ns|
Да, работает быстрее, чем просто format, но...
Ладно, ладно... Я немного схитрил в этом замере. Здесь замеры для моих способов проводились при конкатенации в мой строковый тип stringa. А в нём размер буфера для SSO не 15 байт, как в std::string, а 23. Поэтому у него результат не требовал аллокаций :) Вот замеры, если во всех тестах конкатенировать в std::string (сразу видим, какой в Win тормозной дефолтный аллокатор):
Benchmark | Win10| Ub22 | Ub22 |
|Clang19|Clang21|CGG-13 |
-----------------------------------------------------------------------------
"art "_ss + i / 0x8a010_fmt + " end" | 599 ns| 203 ns| 207 ns|
e_subst("art {} end", i / 0x8a010_fmt) | 737 ns| 280 ns| 272 ns|
e_vsubst(pattern, i / 0x8a010_fmt) | 896 ns| 467 ns| 545 ns|
fmt::format(FMT_COMPILE("art {:#010o} end"), i) | 840 ns| 488 ns| 527 ns|
fmt::format("art {:#010o} end", i) |1235 ns| 718 ns| 739 ns|
std::format("art {:#010o} end", i) |1359 ns| 938 ns|1068 ns|
std::vformat(pattern, std::make_format_args(i)) |1360 ns| 962 ns| 910 ns|
strm << "art 0" << std::oct << std::setw(9) << std:)|6565 ns|2017 ns|1991 ns|
И всё-равно в результате
My string fu is stronger than yours
Хотя, если уж начинать использовать simstr, зачем отказываться от stringa? Она на 8 байт меньше, чем std::string, а буфер SSO на 8 байт больше.
В том то и дело - не понятно что профилируете, толи форматтер, толи конкатенатор, толи вообще аллокатор. Кому нужна скорость, каким-нибудь геймдевам, то за лишнюю аллокацию удавятся, хотя бы format_to(). Если руками не хочется писать преобразование числа в символы, то хотя бы std::to_chars.
Так в том и есть основная сложность в работе со строками, что там все эти вещи влияют и взаимоувязаны. И на практике надо балансировать всеми составляющими - уменьшать количество реаллокаций, уменьшать количество перекладываний из буфера в буфер, аллоцировать быстрее, копировать быстрее, преобразовывать в строку быстрее. Рассмотренный в статье подход из библиотеки simstr - решает две задачи из этого списка - уменьшить количество реалокаций, уменьшить количество перекладываний символов. За счёт того, что сначала считается общая длина строки, и нужный буфер гарантировано выделяется только один раз и все символы гарантированно сразу попадают на своё место и не требуется их перекопирования в другое место.
Но пользователю ничего не мешает добавить к этому при желании более быстрый аллокатор или форматер.
Есть в библиотеке и способ для быстрейшей временной аллокации. Допустим, нам результирующая строка нужна только здесь и сейчас, и сохранять её не нужно, а достаточно только куда-то передать в вызов функции, причём там она гарантированно не меняется, и достаточно константной строки. При этом мы примерно знаем размер результата. Используем lstring подходящего размера. Допустим, нам надо найти в тексте "prefx" + искомая строка + "suffix":
size_t pos = src.find(lstringa<255>{"prefx" + search_string + "suffix"});
lstringa<256> - прямо на стеке выделяется буфер в 256 байт, в нем формируется строка для поиска (если получится длиннее, чем 255, выделит память динамически), после поиска если уложились в буфер - никаких delete. То есть почти всегда ни аллокаций, ни деалокаций.
Ещё забыли классику - char buf[1024] /* Должно хватить :-) */; snprintf(buf, sizeof(buf), ...; return std::string(buf). Можете добавить в бенчмарк для общей статистики?
Ничего и не забыли, что вы.
Результаты у snprintf, мягко говоря, не блещут, ещё и с буферами возиться.
Хотя, для любителей, в своём классе lstring я возможность форматировать строку через snprintf сделал.
Естественно, с автоматическим увеличением размера строки при необходимости.
Невыдуманные истории о которых невозможно молчать
а я правильно понял, что генератор тут это i / 0x8a010_fmt ? А свои генераторы можно/легко писать? Если мне надо сконкатенировать со строкой например вектор моих типов каких-то?
Преусмотрено 4 способа - документация, примеры
вот гляньте у меня так получилось
https://godbolt.org/z/KTE9fra7e
с поправкой без числа!
Ещё можно развить идею и не конкатенировать строковое выражение в одну строку а, например, записывать выражение в файл, или дать возможность итерации по такой "строке".
Если заменить в методе place на какой-либо insert iterator...
Я глубоко в ваш код не копал, но выглядит так, что можно из строкового выражения сделать диапозон с input_iterator (генерировать значения на лету для случая преобразователей чисел в строки или ленивых генераторов). Так получится полная совместимость с C++20 ranges и всеми её плюшками. Ну и то что я озвучил выше, на этой основе можно реализовать.
"Можно, а зачем?" (С)
При желании конечно можно это сделать, но я такой цели себе не ставил - подгонять под чьи то придуманные абстракции. Рэнжи (диапазоны) - отличный механизм для определённых задач, (и работает кстати тоже на expression templates), но зачем уподобляться человеку с молотком в руке, которому всё вокруг начинает казаться гвоздями?
Цель была именно работа со строками и только со строками, без лишних абстракций.
И ключевой момент в том, что объект-строковое выражение позволяет делать по себе ДВА прохода - один раз для подсчёта длины результата, второй раз - для размещения результата. В рэнжах же проход делается один раз, то есть к нему надо прикручивать какой-либо бекинсертер, который будет последовательно добавлять символы в результат, а это именно та проблема, которую я и решаю. Потому что бэкинсертер - это абстракция, что у нас есть "резиновая" строка, к которой можно невозбранно добавлять символы в конец, но как все абстракции, она "протекает". И на физическом уровне резиновой строки нет, под слоем абстракции приходится выделять новую память и перекладывать символы.
Но в целом да, сделать совместимость с range вполне возможно, если стоит задача выводить не в строку, а куда-то в другое место, более приспособленное к последовательному выводу.
А если такой вариант протестировать (с сохранением типов std::string_view str_answer, int count в аргументах):
thread_local std::string buffer;
std::string format(std::string_view str_answer, int count) {
buffer.clear(); // Очищаем содержимое, но сохраняем выделенную память
if (buffer.capacity() < 1024) {
buffer.reserve(1024); // Минимальный размер (упрощенно, не в конструкторе)
}
buffer.append("The answer is ").append(str_answer).append(", count is ");
std::format_to(std::back_inserter(buffer), "{}", count); // C++20: Пишем число в буфер без аллокаций
return buffer;
}Такой подход вполне хорош, если нам не надо куда-то сохранять результат в другую строку, а нужно просто временно создать строку, которую надо как константную передать какой-то функции, и потом она не нужна.
Либо строку надо формировать как-то сложнее, чем просто конкатенация.
А так у вас получается, сначала в одном буфере собираете строку, а потом перекладываете её в результат, а это снова аллокация и копирование символов. А строковые выражения позволяют сразу собирать строку в буфере результата. Впрочем, и для подобного подхода в библиотеке есть решения, для быстрейшей временной аллокации. Допустим, нам надо найти в тексте "prefx" + искомая строка + "suffix":
size_t pos = src.find(lstringa<255>{"prefx" + search_string + "suffix"});
lstringa<256> - прямо на стеке выделяется буфер в 256 байт, в нем формируется строка для поиска (если получится длиннее, чем 255, выделит память динамически), после поиска если уложились в буфер - никаких delete. То есть почти всегда ни аллокаций, ни деалокаций.
Ну так и в перфомансе – вы сами решаете, где нужна вам аллокация и копирование, – а где только временный объект. Но, никто не мешает создать чанки на стеке через alloca и спец объект (класс), но по принципу "Парето" простого решения достаточно в 80% случаев.
Например, если для целей логирования, то аллокации на стеке совсем будут не нужны. А для вставки, например, в map вы ее сделаете сами, возможно сразу в placement new выделенный буфер для ключей (чтобы сохранить локальность данных).
Так как же всё-таки быстро конкатенировать строки в C++?