— Are there a lot of these kinds of accidents?
— You wouldn't believe.
— Which car company do you work for?
— A major one.
(Fight Club)
Привет, Хабр. Меня зовут Павел Преблагин, я работаю в команде инжиниринга производительности Positive Technologies. Мы анализируем разные продукты компании и пытаемся так или иначе оптимизировать их изнутри. Как уже можно понять, команда наша мультипроектная: у нас нет постоянной кодовой базы, кроме некоторых инструментов анализа и тестирования. Обычно коллеги из других отделов приносят нам для изучения свою базу, написанную преимущественно на C++, если у них есть подозрения, что что-то работает не так быстро, как должно. Мы в ответ приносим им результаты замеров, патчи и рекомендации.
Хотя наша команда относительно молодая, мы уже успели пройтись по нескольким таким продуктам и нанести непоправимую пользу. Все эти проекты разные и принадлежат разным командам, но мы заметили, что некоторые проблемы встречались в той или иной комбинации везде, носили общий характер, а решались примерно одинаково и порой без серьезных усилий. Цель этой статьи — показать подборку из таких часто встречаемых ситуаций вместе с возможными вариантами их решения. Кейсы могут показаться тривиальными или даже глупыми, но факт остается фактом: подобное мы наблюдаем с определенным постоянством и видели в других компаниях, еще до прихода в Позитив.
Проекты, с которыми мы работали, — это в первую очередь разного рода потоковые обработчики событий, будь то строчки из системного журнала или сетевые пакеты. По этой причине для нас производительность измеряется в EPS, они же events per second, они же события в секунду. Если где-то по ходу статьи написано, что производительность выросла на 10%, это означает, что мы стали обрабатывать на 10% событий в секунду больше.
Большинство кейсов приведены с примерами, которые абсолютно намеренно упрощены до наивности, чтобы читателю не пришлось погружаться в предметную область, руководства пользователя и прочие детали реализации конкретного продукта. Если кому-то все же очень хочется нырнуть в них с головой, наши добрые и приветливые HR будут рады получить ваше резюме.
Начнем-с.
Для быстрой навигации по статье
Логирование
Избыточное логирование
Чем больше логируем, тем медленней работаем.
Плохо
int upper_level(char *data, size_t sz)
{
// что-то мы делали до этого момента
LOG_INFO("Going to do lower_level() with data {}", data);
LOG_INFO("Size {}", sz);
status = lower_level(data, sz);
LOG_INFO("Status {}", status);
// и что-то будем делать после
}
int lower_level(void *data, size_t sz)
{
LOG_INFO("Data pointer is {}", data);
// дальше происходит нечто великое
LOG_INFO("Data size {}", sz
}
Решение сводится к аудиту, или краткость — сестра таланта.
Во-первых, стоит посмотреть, не содержит ли функция верхнего/нижнего уровня вывод аналогичной информации. В нашем случае логируется одно и то же, что-то из этого явно избыточно.
Во-вторых, два сообщения можно объединить в одно. Один вызов лучше, чем несколько составных, и это относится не только к записи в лог.
В-третьих, можно изменить severity и выводить последнее сообщение только при ошибке.
Последнее в списке, но не последнее по значимости — решить, нужны ли вообще логи в каком-то конкретном месте. Логирование чаше всего добавляется в процессе реализации функционала для его же отладки, и далеко не все оно остается реально полезным, когда функционал готов. Порой достаточно простых assert()
, которые будут исчезать в релизных сборках.
Лучше
int upper_level(char *data, size_t sz)
{
// выводим этот лог, только если очень нужно
LOG_DEBUG("Doing lower_level(), size {}, data {}", data_size, data);
status = lower_level(data, data_size);
// обычно ошибки случаются редко, а если случились,
// то производительность, вероятно, уже не на первом плане
if(status != SUCCESS) {
LOG_ERROR("lower_level() failed with status {} for {}", status, data);
}
}
int lower_level(void *data, size_t sz)
{
// технически нам тут просто было интересно,
// насколько валидным является указатель
assert(data != nullptr);
// дальше осталось ТОЛЬКО нечто великое
}
Логирование в критических секциях
То, чего следует избегать. Даже если мы не будем прямо здесь и сейчас писать байты в файл, в библиотеках логирования обычно уже есть свои примитивы синхронизации. Это, в свою очередь, приводит к ситуации, когда «мьютекс мьютексом погоняет», и ухудшению производительности даже в тех случаях, когда сообщение не попадает под текущий уровень логирования.
Плохо
void some_sync_function()
{
std::unique_lock<std::mutex> lock{ some_mutex };
// что-то, что требует защиты примитивами синхронизации
LOG_TRACE("Critical section done!")
}
Если очень нужно что-то рассказать, то лучше это сделать, выйдя из критической секции.
Лучше
void some_sync_function()
{
{
std::unique_lock<std::mutex> lock{ some_mutex };
// что-то, что требует защиты примитивами синхронизации
// и выдохнули, отпустили мьютекс
}
// не требует блокировки мьютекса или хотя бы не того же самого
LOG_TRACE("Critical section done!");
}
Вынос буквально единственного такого сообщения из-под мютекса дал +10% к перформансу в одном из проектов.
Подготовка данных к выводу в лог без последующего логирования
Наверняка найдется читатель, который однажды увидел понравившуюся ему девушку, долго думал, что сказать, но так и не решился подойти. Похожая ситуация возникает в коде, когда мы готовим некоторые данные к выводу в лог: например, сериализуем объект JSON в строку, но сообщение в итоге никуда не печатаем.
Плохо
void debug(std::string_view s) {
// может, будем писать; может, не будем
if(debug_log_level >= current_log_level) {
// реально куда-то пишем
}
}
void do_something_with_json()
{
// здесь магически появляется JSON
// который мы сериализуем в строку
auto s = json.to_string();
// и создаем новый временный объект строки для результата конкатенации
debug("JSON is " + s);
// наверняка что-то происходит дальше
}
В примере выше метод json.to_string()
, как и создание конкатенированной строки, выполнятся вне зависимости от того, будем ли мы что-то куда-то реально выводить или нет. Если нет, то выполнялись они, очевидно, впустую.
Исправить это можно, определив функции логирования через макросы (обычно идут в комплекте с библиотекой) и избавившись от промежуточных действий.
Лучше
#define LOG_DEBUG if(debug_log_level >= current_log_level) { // реально куда-то здесь пишем и создаем объекты }
void do_something_with_json()
{
// раскрывается макрос выше, никаких новых объектов пока еще нет
LOG_DEBUG("JSON is " + json.to_string());
// что-то происходит дальше, но уже быстрее
}
Таким образом, если ничего не выведется, ничего не будет и исполняться.
Исправив с десяток подобных случаев, получили +5% к итоговому EPS.
Работа с памятью
Выбор аллокатора
Самое простое, что можно попробовать. Аллокатор из стандартной библиотеки, как правило, универсален и показывает приемлемую производительность на самых разных случаях, но для конкретных сценариев может быть не самым оптимальным выбором.
Например, прогнав микробенчмарк для одного из типовых сценариев, получили следующую картину.
Видно, что tcmalloc
быстрее стандартного аллокатора в пять раз. Этот результат подтвердился и основными тестами производительности. В разных сценариях этот график будет выглядеть по-разному, но в общем и целом tcmalloc
из gperftools
пока наш личный фаворит*.
* jemalloc способен показать относительно сопоставимые результаты, если правильно его подтюнить, — благо опций для этого много, но в данном случае он все равно проигрывает.
Правда, здесь нужно отметить, что выигрыш в производительности, получаемый подобным образом, может замаскировать те ошибки при работе с памятью, которые описаны дальше. Другими словами, лечит симптомы, а не причины.
Избыточные аллокации
Хотя само по себе отдельное выделение памяти обычно не занимает много времени, суммарно работа с аллокациями памяти может занимать и 10%, и 20% от работы программы. У нас это обычно называется «размазано тонким слоем по всему флеймграфу» (что такое «флеймграф» и с чем его едят — см. предыдущую статью).
Пурпурным выделены вызовы аллокатора.
Принципы оптимизации можно изложить следующим образом, как это сделано в презентации Марека Краевски.
Во-первых, если мы уверены, что для данных хватит места на стеке, — выделяем на стеке, это максимально быстро.
Плохо
void some_function() {
// максимальный размер определен стандартом,
// и он больше SSO: https://rrmprogramming.com/article/small-string-optimization-sso-in-c/
std::string hostname;
// когда начнем работать с хостнеймом
// и он не влезет в SSO,
// мы пойдем в аллокатор за памятью
}
Лучше
#define __USE_POSIX
#include <limits.h>
void some_function() {
// максимальный размер определен стандартом
// он больше SSO, но прекрасно помещается в стек
// если компилятор не умеет в variable-length arrays,
// то есть alloca()/_malloca()
char hostname[HOST_NAME_MAX] ={0};
// никуда не пойдем, так как хостнейм уже лежит на стеке
}
Во-вторых, если нам требуется множество одинаковых элементов, то выделяем блок сразу под все элементы.
Как это делают на парах
int** make_2d_array(size_t M, size_t N) {
// выделяем память под массив указателей
auto array = new int*[M];
// выделяем строки массива в цикле
for(size_t i = 0; i < M; ++i) {
array[i] = new int[N];
}
return array;
}
Лучше
int** make_2d_array(size_t M, size_t N) {
// выделяем память под массив указателей
auto array = new int*[M];
// выделяем память сразу под весь массив
auto data = new int[M * N];
// заполняем нужными адресами из выделенного блока
for(size_t i = 0; i < M; ++i) {
// начало строки массива — это просто смещение в блоке
array[i] = (data + i * N);
}
return array;
}
Похожим образом, к слову, это сделано в контейнерах STL: чтобы не дергать аллокатор на каждом добавлении элемента, память заранее выделяется с запасом под последующие добавления. Это, впрочем, не избавляет от необходимости вызывать reserve()
в случае, когда нам известно количество элементов.
В-третьих, если в ходе работы программы мы регулярно выделяем память под одни и те же объекты, то есть смысл сохранять их и переиспользовать, чтобы избежать порочного цикла malloc()
и free()
.
Плохо
void some_function(size_t sz) {
// каждый раз на входе просим памяти на буфер
char *buf = static_cast<char*>(std::malloc(sz));
// и дальше что-то в этот буфер пишем, например
}
Лучше
void some_function(size_t sz) {
// static здесь просто в качестве примера
static size_t allocated = sz;
static char *buf = static_cast<char*>(std::malloc(allocated));
// если память не выделена или ее недостаточно,
// то довыделяем
if(sz > allocated) {
buf = static_cast<char*>(std::realloc(buf, sz));
}
// и все еще что-то в этот буфер пишем
}
Управление памятью на основе регионов
Системный подход. Идея заключается в том, что мы, находясь в некоем контексте, выделяем память сразу большими блоками, куда потом складываем наши данные а-ля placement new. Высвобождается память, соответственно, тоже блоками при выходе из контекста. Как наш аллокатор, предоставляемый glibc
, tcmalloc
или кем-то еще, пытается сократить количество обращений непосредственно к ядру, так и мы сооружаем абстракцию, чтобы сократить количество обращений уже к аллокатору.
Хорошими примерами являются MEM_ROOT из MySQL или появившийся в C++17 std::pmr::monotonic_buffer_resource.
Сторонние компоненты
Выбор
Правильный выбор библиотек способствует повышению производительности. Ниже представлена таблица third-party-компонентов, которые были заменены на другие.
Было |
Стало |
Прирост EPS |
Комментарий |
3% |
Требовались только регулярные выражения, которые в |
||
Своя реализация case folding для UTF-8 |
3%
6% |
В рамках проекта, синтетические бенчмарки показывают разницу в несколько раз. |
|
3% |
Здесь следует уточнить, что |
||
6% |
Перекликается с прошлым разделом, но тем не менее |
Критически настроенный читатель заметит, что тянуть Boost ради одних регулярок — это с самого начала попытка убить комара строительной кувалдой, но подобный прецедент, когда в проект ради пары функций затаскивается здоровенный модуль, далеко не первый и, скорее всего, не последний.
Обновление
Рекомендуется периодически проверять выход обновлений, так как они могут содержать в себе исправления по части производительности. Так, например, переход на версию 20231101 re2 с версии 20220601 дал прирост в 1,5% на многопоточных сценариях за счет использования спинлоков.
Алгоритмы и структуры данных
Алгоритмическая сложность
Любой программист знает про O(f(n))
, оно же «O» большое, а любой программист на C хотя бы раз в жизни видел цикл следующего вида.
Тот самый цикл
#include <string.h>
// всеми правдами и неправдами подсказываем компилятору,
// что с указателем и данными ничего не произойдет
void foo(const char* __restrict const s) {
int dumb;
for(size_t i = 0; i < strlen(s); ++i) {
// при компиляции с флагом оптимизации
// компилятор выкинет цикл, посчитав бессмысленным
// поэтому вставляем такую штуку
// см. https://theunixzoo.co.uk/blog/2021-10-14-preventing-optimisations.html
asm volatile("" : "+m,r"(dumb) : : "memory");
// здесь также могли бы быть printf(), sleep() или ваша реклама
}
}
Интуитивно кажется, что мы один раз посчитаем длину строки, а затем выполним соответствующее количество итераций — другими словами, трудоемкость будет О(N)
. Однако, если мы соберем код свежим GCC с -O3
и заглянем в ассемблер, нас ждет интересное открытие.
Шокирующая правда
foo(char const*):
push r12
mov r12, rdi
push rbp
xor ebp, ebp
push rbx
jmp .L2
.L3:
add rbp, 1
.L2:
mov rdi, r12
call strlen
cmp rbp, rax
jb .L3
pop rbx
pop rbp
pop r12
ret
Компилятор не выносит расчет strlen()
наружу цикла, и мы на каждой итерации впустую проходим по строке. Интересный факт, что это происходит даже в том случае, когда функция встроена.
Попытка № 2
#include <string.h>
static size_t inline __attribute__((always_inline)) strlen2(const char* __restrict const s) {
const char *end;
for(end = s; *end; ++end);
return (size_t)(end - s);
}
void foo(const char* __restrict const s) {
int dumb;
// strlen2 здесь встраивается
// также это мог бы быть вызов лямбды
for(size_t i = 0; i < strlen2(s); ++i) {
asm volatile("" : "+m,r"(dumb) : : "memory");
}
}
// конечный результат на ассемблере
// foo(char const*):
// xor ecx, ecx
// cmp BYTE PTR [rdi], 0
// je .L1
// .L9:
// mov rax, rdi
// .L3:
// add rax, 1
// cmp BYTE PTR [rax], 0
// jne .L3
// sub rax, rdi
// cmp rcx, rax
// jnb .L1
// add rcx, 1
// cmp BYTE PTR [rdi], 0
// jne .L9
// .L1:
// ret
В любом случае трудоемкость превращается в О(N2) буквально на ровном месте. Подобный код, написанный для обхода дерева конфигурации одним инженером, потомком великой династии Цинь, приводил к замедлению старта системы на пару десятков секунд.
Решается такое просто.
Лучше
#include <string.h>
void foo(const char* s) {
int dumb;
// выносим длину наружу сами
const size_t len = strlen(s);
for(size_t i = 0; i < len; ++i) {
asm volatile("" : "+m,r"(dumb) : : "memory");
}
}
// конечный результат на ассемблере
// foo(char const*):
// push rbx
// call strlen
// test rax, rax
// je .L1
// xor edx, edx
// .L3:
// add rdx, 1
// cmp rax, rdx
// jne .L3
// .L1:
// pop rbx
// ret
Как делают настоящие ковбои
#include <string.h>
void foo(const char* s) {
int dumb;
// итерируемся по указателю
// обрабатывая строку за один проход вместо двух
for(const char *p = s; *p; ++p) {
asm volatile("" : "+m,r"(dumb) : : "memory");
}
}
// конечный результат на ассемблере
// foo(char const*):
// jmp .L5
// .L3:
// add rdi, 1
// .L5:
// cmp BYTE PTR [rdi], 0
// jne .L3
// ret
Выбор структур для хранения данных
STL предоставляет достаточно большое количество уже готовых абстракций, но после того, как мы разобрались с традиционным противостоянием std::map
против std::unordered_map
, есть смысл посмотреть на альтернативные реализации. При всем уважении к стандартной библиотеке она, как правило, не является эталоном с точки зрения производительности, что подтверждается бенчмарками.
Так, получилось ускорить пару проектов в среднем на 5%, просто заменив std::unordered_map на ankerl::unordered_dense::map. Это не означает, что последнюю следует теперь использовать везде, но когда мы говорим о производительности, всегда есть смысл держать в голове другие реализации распространенных структур данных.
Кэширование данных
Допустим, нашей программе для работы требуется находить соответствие между IP-адресом и MAC-адресом.
Возможный вариант функции
uint8_t *hwaddr_lookup(uint32_t ip, uint8_t *dst) {
// а здесь, например, запрос через netlink-сокет
}
Стандартное время жизни записи ARP-таблицы в Linux составляет 60 секунд. Соответственно, нет смысла дергать системный вызов, когда нам нужно сопоставить один адрес с другим — ведь мы можем запомнить предыдущий ответ.
Кэширование
uint8_t *hwaddr_lookup(uint32_t ip, uint8_t *dst) {
auto entry = cache.find(ip);
if(entry != cache.end()) {
// запись есть, можно вернуть
// вероятно, нужно проверить, что не просрочена
}
// не нашли, спрашиваем ОС
// добавляем запись в кэш
}
Разумеется, на практике не все так просто, и взамен мы получаем другую головную боль в виде инвалидации кэша, но это уже отдельная история.
Сериализация и десериализация
Очень часто встречается задача представить некий тип данных в виде строки или привести строку к этому типу данных, ибо все есть байты и все есть строка. Возвращаясь к предыдущему примеру, предположим, что мы хотим сериализовать IP-адрес.
Как это обычно делают студенты
std::string ip_to_string(uint32_t address) {
char buf[32];
sprintf(buf, "%d.%d.%d.%d", (address >> 24) & 0xFF, (address >> 16) & 0xFF,
(address >> 8) & 0xFF, address & 0xFF);
return std::string(buf);
}
Дэниел Лемир в своей статье описывает разные варианты такого преобразования. Попробуем закинуть их в бенчмарк.
Код бенчмарка
#include <string>
#include <cstring>
#include <charconv>
#include <arpa/inet.h>
#include <stdio.h>
#include <benchmark/benchmark.h>
constexpr auto ADDRESS_START = 0xFA002200;
constexpr auto ADDRESS_END = 0xFA0022FF;
void ip_serialize_sprintf(benchmark::State& state)
{
for (auto _ : state) {
for(auto address = ADDRESS_START; address < ADDRESS_END; ++address) {
char buf[32];
sprintf(buf, "%d.%d.%d.%d", (address >> 24) & 0xFF, (address >> 16) & 0xFF,
(address >> 8) & 0xFF, address & 0xFF);
std::string output(buf);
}
}
}
BENCHMARK(ip_serialize_sprintf);
void ip_serialize_inet_ntoa(benchmark::State& state)
{
for (auto _ : state) {
for(auto address = ADDRESS_START; address < ADDRESS_END; ++address) {
struct in_addr ip_addr;
ip_addr.s_addr = address;
std::string output(inet_ntoa(ip_addr));
}
}
}
BENCHMARK(ip_serialize_inet_ntoa);
void ip_serialize_simple(benchmark::State& state)
{
for (auto _ : state) {
for(auto address = ADDRESS_START; address < ADDRESS_END; ++address) {
std::string output = std::to_string(address >> 24);
for (int i = 2; i >= 0; i--) {
output.append(std::to_string((address >> (i * 8)) % 256) + ".");
}
}
}
}
BENCHMARK(ip_serialize_simple);
void ip_serialize_advanced(benchmark::State& state)
{
for (auto _ : state) {
for(auto address = ADDRESS_START; address < ADDRESS_END; ++address) {
std::string output(4 * 3 + 3, '\0'); // allocate just one big string
char *point = output.data();
char *point_end = output.data() + output.size();
point = std::to_chars(point, point_end, uint8_t(address >> 24)).ptr;
for (int i = 2; i >= 0; i--) {
*point++ = '.';
point = std::to_chars(point, point_end, uint8_t(address >> (i * 8))).ptr;
}
output.resize(point - output.data());
}
}
}
BENCHMARK(ip_serialize_advanced);
void ip_serialize_bulk(benchmark::State& state)
{
constexpr static const char *lookup =
".0 .1 .2 .3 .4 .5 .6 .7 .8 .9 .10 .11 .12 .13 .14 .15 .16 .17 "
".18 .19 .20 .21 .22 .23 .24 .25 .26 .27 .28 .29 .30 .31 .32 .33 .34 .35 "
".36 .37 .38 .39 .40 .41 .42 .43 .44 .45 .46 .47 .48 .49 .50 .51 .52 .53 "
".54 .55 .56 .57 .58 .59 .60 .61 .62 .63 .64 .65 .66 .67 .68 .69 .70 .71 "
".72 .73 .74 .75 .76 .77 .78 .79 .80 .81 .82 .83 .84 .85 .86 .87 .88 .89 "
".90 .91 .92 .93 .94 .95 .96 .97 .98 .99 "
".100.101.102.103.104.105.106.107.108.109.110.111.112.113.114.115.116."
"117.118.119.120.121.122.123.124.125.126.127.128.129.130.131.132.133.134."
"135.136.137.138.139.140.141.142.143.144.145.146.147.148.149.150.151.152."
"153.154.155.156.157.158.159.160.161.162.163.164.165.166.167.168.169.170."
"171.172.173.174.175.176.177.178.179.180.181.182.183.184.185.186.187.188."
"189.190.191.192.193.194.195.196.197.198.199.200.201.202.203.204.205.206."
"207.208.209.210.211.212.213.214.215.216.217.218.219.220.221.222.223.224."
"225.226.227.228.229.230.231.232.233.234.235.236.237.238.239.240.241.242."
"243.244.245.246.247.248.249.250.251.252.253.254.255";
for (auto _ : state) {
for(auto address = ADDRESS_START; address < ADDRESS_END; ++address) {
std::string output(4 * 3 + 3, '\0');
char *point = output.data();
uint8_t by;
by = address >> 24;
std::memcpy(point, lookup + by * 4 + 1, 4);
point += (by < 10) ? 1 : (by < 100 ? 2 : 3);
by = address >> 16;
std::memcpy(point, lookup + by * 4, 4);
point += (by < 10) ? 2 : (by < 100 ? 3 : 4);
by = address >> 8;
std::memcpy(point, lookup + by * 4, 4);
point += (by < 10) ? 2 : (by < 100 ? 3 : 4);
by = address >> 0;
std::memcpy(point, lookup + by * 4, 4);
point += (by < 10) ? 2 : (by < 100 ? 3 : 4);
output.resize(point - output.data());
}
}
}
BENCHMARK(ip_serialize_bulk);
BENCHMARK_MAIN();
Результаты
-----------------------------------------------------------------
Benchmark Time CPU Iterations
-----------------------------------------------------------------
ip_serialize_sprintf 26973 ns 26971 ns 25955
ip_serialize_inet_ntoa 27929 ns 27925 ns 25338
ip_serialize_simple 13911 ns 13910 ns 50322
ip_serialize_advanced 1872 ns 1872 ns 374777
ip_serialize_bulk 1587 ns 1587 ns 440684
Из результатов видно, что разница в производительности существенная.
Большинство задач, которые приходится решать программисту, в частности задачу сериализации/десериализации, не уникальны и уже решались кем-то еще. Для распространенных типов данных, будь то IP-адреса или время с датой, уже существуют эффективные алгоритмы преобразования, которые могут позволить выиграть несколько процентов к производительности.
Использование std::pair и std::tuple
Эта мысль была изложена в докладе Данилы Кутенина.
Если у нас есть составной тип данных, то представить его можно как традиционным путем, так и с помощью шаблонов, заботливо предоставляемых стандартной библиотекой. Попробуем сравнить эти варианты.
Код бенчмарка
#include <benchmark/benchmark.h>
constexpr size_t VECTOR_SIZE = 1000000;
struct pair_s {
int a = 0;
int b = 0;
};
void copy_std_tuple(benchmark::State& state)
{
std::vector<std::tuple<int, int>> v_std_tuple(VECTOR_SIZE);
decltype(v_std_tuple) tmp;
for (auto _ : state) {
tmp = v_std_tuple;
benchmark::DoNotOptimize(tmp);
}
}
BENCHMARK(copy_std_tuple);
void copy_std_pair(benchmark::State& state)
{
std::vector<std::pair<int, int>> v_std_pair(VECTOR_SIZE);
decltype(v_std_pair) tmp;
for (auto _ : state) {
tmp = v_std_pair;
benchmark::DoNotOptimize(tmp);
}
}
BENCHMARK(copy_std_pair);
void copy_pair_s(benchmark::State& state)
{
std::vector<pair_s> v_stuct(VECTOR_SIZE);
decltype(v_stuct) tmp;
for (auto _ : state) {
tmp = v_stuct;
benchmark::DoNotOptimize(tmp);
}
}
BENCHMARK(copy_pair_s);
BENCHMARK_MAIN();
Результаты
Run on (16 X 800 MHz CPU s)
CPU Caches:
L1 Data 48 KiB (x8)
L1 Instruction 32 KiB (x8)
L2 Unified 512 KiB (x8)
L3 Unified 16384 KiB (x1)
Load Average: 0.43, 0.89, 1.01
---------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------
copy_std_tuple 359752 ns 359459 ns 1946
copy_std_pair 358388 ns 358253 ns 1946
copy_pair_s 281680 ns 280752 ns 2491
Из результатов бенчмарка видно, что вариант со структурой быстрее на 22%, и если заранее известно, какого типа поля и сколько их, то лучше обойтись без использования std::pair
или std::tuple
. Помимо выигрыша в производительности, улучшается читаемость кода и ментальное состояние программистов в команде, ведь, встречая в десятый раз подряд data.first.second.second.first
, поневоле начинаешь мечтать о ядерном апокалипсисе, который уничтожит все живое и закончит этот безумный кошмар.
Многопоточные приложения
Забытые и лишние мьютексы
Банально, но факт: иногда в процессе жизни проекта он рефакторится, и мьютекс оказывается ненужным, но остается в коде артефактом былой эпохи, будучи забытым или «на всякий случай».
Другой вариант — когда мьютексом защищаются структуры данных, которые уже им защищены: например, boost::concurrent::*.
Разгрузка критических секций
Пересекается с одним из предыдущих пунктов, но охватывает больше случаев. Рассмотрим пример, похожий на один из решенных в прошлом.
Плохо
void recv()
{
// множество потоков агрегируют сообщения
// в одну защищенную мьютексом очередь
std::unique_lock<std::mutex> lock{ queue_mutex };
queue.push(msq);
if(queue.size() > QUEUE_SIZE_MAX) {
// говорим, что нужно отправить
// собранную пачку
notify_sender();
}
}
void send()
{
// Один поток-читатель из очереди
std::unique_lock<std::mutex> lock{ queue_mutex };
// забираем сообщения из очереди
auto all = queue.get_everything();
// обрабатываем и отправляем пачку дальше
xmit(all);
}
Разделяемым ресурсом в этом случае является очередь, и, соответственно, синхронизироваться должна только работа с ней. Простая модификация кода позволила получить +10–15% к производительности.
Лучше
void recv()
{
// множество потоков агрегируют сообщения
// в одну защищенную мьютексом очередь
std::unique_lock<std::mutex> lock{ queue_mutex };
queue.push(msq);
if(queue.size() > QUEUE_SIZE_MAX) {
// говорим, что нужно отправить
// собранную пачку
notify_sender();
}
}
void send()
{
// один поток-читатель из очереди
auto all = [&queue]() {
std::unique_lock<std::mutex> lock{ queue_mutex };
// забираем сообщения из очереди
// и отпускаем мьютекс
return queue.get_everything();
}();
// обрабатываем и отправляем пачку дальше
// независимо от остальных потоков
xmit(all);
}
На практике можно заметить, что идиома RAII для мьютексов оказывается не только добром. С одной стороны, можно не беспокоиться о дедлоках, с другой — увеличивается риск добавить в критическую секцию то, чему там, в общем-то, не место.
Независимые копии, шардирование и shared mutex
Одним из вариантов оптимизации является так называемый read-write-мьютекс, представленный в стандартной библиотеке как std::shared_mutex
и позволяющий нескольким потоками одновременно удерживать блокировку на чтение. Это эффективно в ситуациях, когда чтение выполняется часто, а запись редко, как, допустим, в рассмотренной ранее ситуации с таблицей MAC-адресов.
Другой вариант — так называемый шардинг, когда разделяемый ресурс поделен на куски, каждый из которых защищен своим собственным мьютексом. Поток, обращаясь к этому ресурсу, определяет, с каким именно шардом предстоит работать и блокирует именно его. Минусом такого подхода обычно является написание своих структур данных, поддерживающих шардинг.
Наконец, можно просто хранить независимую копию данных для каждого из потоков. Например, если у нас есть аналог MEM_ROOT
из предыдущего раздела, то очевидно, что потоки могут и должны иметь свой инстанс вместо того, чтобы пользоваться одним и ждать, пока освободится мьютекс.
Нередко это решается при помощи thread_local, который не совсем бесплатен, что можно заметить на флеймграфах. Перенос данных из thread_local
в абстракции над потоками в наших кейсах давал +1% к перформансу.
Заключение
Как уже было сказано в начале статьи, приведенные примеры показывают проблемы, с которыми мы сталкивались в рамках анализа производительности различных проектов и, вероятно, столкнемся еще не раз, так как они в целом не привязаны к какой-то конкретной предметной области, бизнес-логике или специфике реализации. Устранение же одних этих и только этих проблем на первой итерации непосильного труда над проектом давало в среднем от 30% до 50% прироста EPS. Другими словами, если в вашем коде не ступала нога перформанс-инженера, то есть вероятность увеличить цифры производительности в полтора раза, просто проработав распространенные кейсы и не сильно вдаваясь в нюансы конкретного проекта.
Если вдруг у кого-то из читателей промелькнул перед глазами вьетнамский флешбэк, а в голове — ситуация, которую стоило бы добавить в эту статью, то не нужно переживать это наедине с собой, милости просим в комментарии.
Комментарии (8)
dersoverflow
19.12.2024 13:40мы разобрались с традиционным противостоянием
std::map
противstd::unordered_map
, есть смысл посмотреть на альтернативные реализацииеще посмотрите ders::blob_map - в 14 раз быстрее unordered_map<string, string> на 64 потоках.
Tuxman
19.12.2024 13:40Логирование
Рейт лимит не завезли?
Выбор аллокатора
Tcmalloc? У вас там треды? Тогда надо про треды пейсать. А иначе, если топик про c++, то давайте вспомним c++17 std::pmr::polymorphic_allocator
... низкий уровень материала.
Panzerschrek
19.12.2024 13:40Про оптимизацию выкидывания strlen - недавно где-то читал (может на Хабре), что strcpy быстрее работает, если сначала пройтись по исходной строке и посчитать длину, а потом уже скопировать, заиспользовав мощь векторных инструкций. Если же сразу и проходить по строке и проверять длину, то так не выйдет сделать и компилятор не сможет код векторизовать.
redfox0
19.12.2024 13:40Там провал в производительности не из-за компилятора, а что первый проход по строке загружает данные из памяти в кеши процессора, и второй проход оказывается быстрее. Хотя с алгоритмической стороны стоимость будет одинаковой при каждом чтении.
Timur_Sennikov
19.12.2024 13:40const size_t len = strlen(s); for(size_t i = 0; i < len; ++i) { asm volatile("" : "+m,r"(dumb) : : "memory"); }
Можно написать как
for(size_t i = 0, len = strlen(s); i < len; ++i) { asm volatile("" : "+m,r"(dumb) : : "memory"); }
По идее и убывания производительности не будет, потому что длина строки вычисляется только раз, и ничего лишнего вне цикла писать не надо, если я не прав - поправьте пожалуйста
dersoverflow
попробуйте ders::mem_pool.
по результатам тестирования list<int, mp_allocator<int>> получается ускорение в 5-8 раз. здесь результаты: https://ders.by/cpp/norefs/norefs.html#4.2
а sh_ptr с mp_allocator в 6-12 раз!
но самое смешное - это ввод/вывод! ders::buf_rd дает ускорение в 10-33 раза.
vsnikolaev
Какое же язвительно изложение по ссылке...