— 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

Комментарий

Boost

SRELL

3%

Требовались только регулярные выражения, которые в SRELL работают быстрее. Бонусом выкинули boost из зависимостей, что помогло с локальностью кода

ICU

simdutf

 

Своя реализация case folding для UTF-8

3%

 

6%

В рамках проекта, синтетические бенчмарки показывают разницу в несколько раз.

Своя реализация case folding появилась из-за отсутствия таковой в simdutf. Нельзя сказать (пока), что работает сильно быстрее ICU, но тут мы выигрываем на кэше инструкций

RapidJSON

simdjson

3%

Здесь следует уточнить, что simdjson работает с документами read-only — то есть область применения ограничена

jemalloc

tcmalloc

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 из предыдущего раздела, то очевидно, что потоки могут и должны иметь свой инстанс вместо того, чтобы пользоваться одним и ждать, пока освободится мьютекс.

У каждого потока свой MEMROOT
У каждого потока свой MEMROOT

Нередко это решается при помощи thread_local, который не совсем бесплатен, что можно заметить на флеймграфах. Перенос данных из thread_local в абстракции над потоками в наших кейсах давал +1% к перформансу.

Заключение

Как уже было сказано в начале статьи, приведенные примеры показывают проблемы, с которыми мы сталкивались в рамках анализа производительности различных проектов и, вероятно, столкнемся еще не раз, так как они в целом не привязаны к какой-то конкретной предметной области, бизнес-логике или специфике реализации. Устранение же одних этих и только этих проблем на первой итерации непосильного труда над проектом давало в среднем от 30% до 50% прироста EPS. Другими словами, если в вашем коде не ступала нога перформанс-инженера, то есть вероятность увеличить цифры производительности в полтора раза, просто проработав распространенные кейсы и не сильно вдаваясь в нюансы конкретного проекта.

Если вдруг у кого-то из читателей промелькнул перед глазами вьетнамский флешбэк, а в голове — ситуация, которую стоило бы добавить в эту статью, то не нужно переживать это наедине с собой, милости просим в комментарии.

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


  1. dersoverflow
    19.12.2024 13:40

    Видно, что tcmalloc быстрее стандартного аллокатора в пять раз

    попробуйте 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 раза.


    1. vsnikolaev
      19.12.2024 13:40

      Какое же язвительно изложение по ссылке...


  1. dersoverflow
    19.12.2024 13:40

    мы разобрались с традиционным противостоянием std::map против std::unordered_map, есть смысл посмотреть на альтернативные реализации

    еще посмотрите ders::blob_map - в 14 раз быстрее unordered_map<string, string> на 64 потоках.

    здесь: https://ders.by/cpp/memdb/memdb.html#5.1


  1. dersoverflow
    19.12.2024 13:40

    (dup)


  1. Tuxman
    19.12.2024 13:40

    Логирование

    Рейт лимит не завезли?

    Выбор аллокатора

    Tcmalloc? У вас там треды? Тогда надо про треды пейсать. А иначе, если топик про c++, то давайте вспомним c++17 std::pmr::polymorphic_allocator

    ... низкий уровень материала.


  1. Panzerschrek
    19.12.2024 13:40

    Про оптимизацию выкидывания strlen - недавно где-то читал (может на Хабре), что strcpy быстрее работает, если сначала пройтись по исходной строке и посчитать длину, а потом уже скопировать, заиспользовав мощь векторных инструкций. Если же сразу и проходить по строке и проверять длину, то так не выйдет сделать и компилятор не сможет код векторизовать.


    1. redfox0
      19.12.2024 13:40

      Там провал в производительности не из-за компилятора, а что первый проход по строке загружает данные из памяти в кеши процессора, и второй проход оказывается быстрее. Хотя с алгоритмической стороны стоимость будет одинаковой при каждом чтении.


  1. Timur_Sennikov
    19.12.2024 13:40

      const 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");
    }

    По идее и убывания производительности не будет, потому что длина строки вычисляется только раз, и ничего лишнего вне цикла писать не надо, если я не прав - поправьте пожалуйста