Меня зовут Ибрагим, и я уже много лет занимаюсь программированием на C++. За это время мне довелось поработать с высоконагруженными системами, игровыми движками и даже embedded-проектами! Сегодня я хочу рассказать об опыте оптимизации, применениях которого я сталкивался на практике. Мы порассуждаем о кэш-локальности, кастомных аллокаторах, многопоточности, и как они часто остаются не замеченными в литературе. Тем не менее, они принципиально важны при нашем стремлении писать быстрый код.
Однако прежде чем мы начнем, короткое предупреждение. Если вы думаете, что оптимизация, это просто «быстрый код», то предупреждаем, будете удивлены. Современные процессоры это не просто «быстрые калькуляторы». Они больше похожи на капризных художников, которым необходимо подать данные именно в таком порядке, иначе они откажутся работать быстро.
Когда я впервые слышал о кэш-локальности, я думал, что это что-то связанное с кэшем процессора. Оказывается это одно из самых сущностных понятий в оптимизации производительности. Современные процессоры имеют несколько уровней кэша (L1, L2, L3), и доступ к данным в кэше в десятки раз быстрее доступа к данным в оперативной памяти. Если ваши данные не умещаются в кэш, процессор вынужден ждать пока они подгрузятся из RAM. Такой промах называется cache miss, и он одна из главных причин замедления программ.
Представьте, что у вас есть структура данных, которая представляет собой массив структур:
struct Player {
int health;
int armor;
float position[3]; //... ещё куча полей
};
std::vector players;
Теперь допустим, что вам нужно обновить здоровье всех игроков. Вы пишете простой цикл:
for (auto& player : players) {
player.health -= damage;
}
Суть проблемы в том, что структура Player может быть очень большой (например 64 байта и более). Когда Вы обращаетесь к ее полю health, аппарат загружает в кэш не только это поле, но и всю структуру Player. И если структуры Player в памяти будут идти не плотным массивом, это означает, что при чтении всем полям health кэш будет промахиваться многократно.
Один из способов улучшить кэш-локальность — использование структуры данных, ориентированной на данные (Data-Oriented Design). Сохранение всех данных в отдельной структуре приводит к использованию нескольких массивов:
std::vector<int> playerHealth;
std::vector<int> playerArmor;
std::vector<float> playerPositionsX;
std::vector<float> playerPositionsY;
std::vector<float> playerPositionsZ;
Теперь, когда вы обновляете здоровье, процессор загружает в кэш только те данные, которые вам нужны:
for (auto& health : playerHealth) {
health -= damage;
}
Я провёл быстрый тест на своём ноутбуке (Intel i7-8750H, 16 ГБ ОЗУ). На массиве из миллиона игроков:
Было (один массив) -
120 мс
Стало (отдельные списки) -
40 мс
То есть в 3 раза меньше!
И это только начало.
Работая над игровым движком, я столкнулся с серьезной проблемой аллокации памяти. Стандартные аллокаторы, такие как new/delete
, хороши для простых случаев, но оказываются неэффективны для систем с высокой нагрузкой.
После множества выделений и освобождений памяти она может стать фрагментированной, что ускорит скорость поиска свободного блока. Постоянные вызовы new и delete могут вызвать contention при работе в многопоточных приложениях.
Но существуют способы решения проблемы — например, использовать кастомные аллокаторы. К примеру, аллокатор, который выделяет память блоками фиксированного размера (memory pool). И это особенно полезно, если вы часто создаёте и удаляете объекты одного типа.
Пример простого аллокатора на основе pmr
из С++ 17:
#include <memory_resource>
#include <vector>
#include <iostream>
class CustomAllocator : public std::pmr::memory_resource {
public:
void* do_allocate(size_t bytes, size_t alignment) override {
void* ptr = std::aligned_alloc(alignment, bytes);
if (!ptr) throw std::bad_alloc();
return ptr;
}
void do_deallocate(void* ptr, size_t bytes, size_t alignment) override {
std::free(ptr);
}
bool do_is_equal(const memory_resource& other) const noexcept override {
return this == &other;
}
};
int main() {
CustomAllocator allocator;
std::pmr::vector<int> vec{&allocator};
for (int i = 0; i < 100; ++i) {
vec.push_back(i);
}
for (int i : vec) {
std::cout << i << " ";
}
return 0;
}
В начале моего знакомства с многопоточностью у меня сложилось впечатление, что для повышения производительности достаточно лишь запустить несколько потоков. Однако, как я позже осознал, ситуация оказалась сложнее.
В случае, когда несколько потоков работают с данными, которые находятся в одной кэш-линии, возникает contention.
Наличие блокировок — результат ситуации, когда несколько потоков пытаются одновременно получить доступ к одним и тем же данным.
Для избежания подобных ситуаций часто применяются атомарные операции и lock-free структуры данных. Например, здесь приведена реализация простейшей многопоточной очереди:
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
template <typename T>
class LockFreeQueue {
public:
LockFreeQueue() : head(new Node(T())), tail(head.load()) {}
~LockFreeQueue() {
while (Node* oldHead = head.load()) {
head.store(oldHead->next);
delete oldHead;
}
}
void push(const T& value) {
Node* newNode = new Node(value);
Node* oldTail = tail.load();
while (!tail.compare_exchange_weak(oldTail, newNode)) {}
oldTail->next.store(newNode);
}
bool pop(T& value) {
Node* oldHead = head.load();
while (oldHead && !head.compare_exchange_weak(oldHead, oldHead->next.load())) {}
if (!oldHead) return false;
value = oldHead->data;
delete oldHead;
return true;
}
private:
struct Node {
T data;
std::atomic<Node*> next;
Node(const T& value) : data(value), next(nullptr) {}
};
std::atomic<Node*> head;
std::atomic<Node*> tail;
};
void producer(LockFreeQueue<int>& queue, int id) {
for (int i = 0; i < 10; ++i) {
queue.push(id * 10 + i);
std::this_thread::sleep_for(std::chrono::milliseconds(10)); // Имитация работы
}
}
void consumer(LockFreeQueue<int>& queue, int id) {
int value;
while (queue.pop(value)) {
std::cout << "Consumer " << id << " popped: " << value << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(20)); // Имитация работы
}
}
int main() {
LockFreeQueue<int> queue;
// Создаём производителей
std::vector<std::thread> producers;
for (int i = 0; i < 2; ++i) {
producers.emplace_back(producer, std::ref(queue), i + 1);
}
// Создаём потребителей
std::vector<std::thread> consumers;
for (int i = 0; i < 2; ++i) {
consumers.emplace_back(consumer, std::ref(queue), i + 1);
}
// Ждём завершения производителей
for (auto& producerThread : producers) {
producerThread.join();
}
// Ждём завершения потребителей
for (auto& consumerThread : consumers) {
consumerThread.join();
}
return 0;
}
Оптимизация на C++ — это далеко не только скорость. Это поиск понимания, как работает железо, где какие данные и как ими управлять. Эти заметки я попытался собирать примерно два года. Так что приступайте к осуществлению вашего БОЛЕЕ ЭФФЕКТИВНОГО КОДА. Не забывайте об измерениях производительности перед оптимизацией. Иногда самый примитивный код оказывается самым быстрым.
Комментарии (25)
ALapinskas
27.01.2025 11:38Понятно, что перебор Vector<int> значений будет быстрее, чем Vector<Player>, но ведь нужна какая-то привязка health к игрокам.
KivApple
27.01.2025 11:38По индексам
ALapinskas
27.01.2025 11:38Дак и тестировать тогда с нужно индексами, это уже другая структура будет Vector<pair<int, int>>, или Vector<Vector<int,int>>.
qweururu
27.01.2025 11:38То есть в 3 раза меньше!
https://godbolt.org/z/G8rxPhn4v - вы хоть разбирались бы, чем обусловлена разница, а не просто вбрасывали лозунги про кеши.
f1(player (&) [1048576]): leaq 20971520(%rdi), %rax .L2: subl $10, (%rdi) subl $10, 20(%rdi) addq $40, %rdi cmpq %rax, %rdi jne .L2 ret f2(int (&) [1048576]): movl $-10, %eax leaq 4194304(%rdi), %rdx vmovd %eax, %xmm1 vpbroadcastd %xmm1, %ymm1 .L7: vpaddd (%rdi), %ymm1, %ymm0 addq $32, %rdi vmovdqu %ymm0, -32(%rdi) cmpq %rdi, %rdx jne .L7 vzeroupper ret
segment
27.01.2025 11:38Как обстоят дела с ABA в Вашем LockFreeQueue? Есть еще интересное видео для тех, кто интересуется Data Oriented Design от создателя Zig https://www.youtube.com/watch?v=IroPQ150F6c
zzzzzzerg
27.01.2025 11:38Как то у вас получился замах на рубль, а удар на копейку. Заявленные темы интересны сами по себе (впрочем и известны давно), но у вас кроме поверхностных сообщений нет никаких захватывающих подробностей.
Seenkao
27.01.2025 11:38Если вы постоянно создаёте и удаляете объекты, то возможно вы что-то не так делаете?
Может достаточно определить заранее будут одинаковые объекты в программе/игре и при удалении очередного объекта не удалять его, а просто пометить удалённым? Придёт другой объект и займёт уже подготовленную память, да, данные перезаписать придётся, но зато "тормошить" память постоянно не надо.
для многопоточных приложений не проще определить какая функциональность может работать отдельно, а какая нет?
Понятно дело, что везде это достаточно реализовать, но определённая функциональность изначально понятна и сразу предполагает разделить на потоки. А другая функциональность не предполагает, и она будет больше тормозить очередь, чем помогать в работе.
Просто смотрите, где какую оптимизацию лучше применить.
Jijiki
27.01.2025 11:38кстати, еще заметил, при любом раскладе если имеем допустим 9 террейнов соединенных в 1, допустим 9 квадратов - тоесть мест где, чтото находится, соотв получается первично надо сначала понять где игрок - на каком квадрате->и тут уже списки этого квадрата(это самое тривиальное, конечно, может есть лучше способы )
maxbushlya
27.01.2025 11:38Увы, статья ни о чем. Есть же книжка про оптимизации на плюсах, покрыто несколько страниц всего. Современные алгоритмы управления памятью настолько крутые что я бы вообще запретил говорить что нью/дэлит медленные в общем. Докажите сперва! Только что в этом убедился на мак ос. Я же умная Клава: выделил нужные куски один раз, стэк свободных элементов без блокировки, по сути всё выделение заключалось в возврате индекса в массиве... Ну и чего вы думаете? Замеры показали что я не выиграл, только лишь приблизился к системному по времени
MonkeyWatchingYou
27.01.2025 11:38Система тоже не выиграла, либо она вернула вам общание, что при первом же доступе всё будет тип топ, либо завалялся чанк под ваши нужды.
maxbushlya
27.01.2025 11:38Пардон, не понял вас. Слова понятные по отдельности но не складываются в моей голове))
ViacheslavNk
27.01.2025 11:38Статья интересная, но все же было бы неплохо уведет какой-то живой пример, например мы загружаем данные из базы данных и отрисовываем карту, объекты это в вектор рисуем, удаляем. Будет ли в этом случае оптимизация эффективной, имеет ли она вообще смысл, если условно получение фичей из БД и отрисовка их занимает на пару порядков больше времени что даже самая не эффективная аллокация и вставка в вектор.
slonopotamus
27.01.2025 11:38После множества выделений и освобождений памяти она может стать фрагментированной, что ускорит скорость поиска свободного блока
Ну так "ускоренная скорость" - это же хорошо, э?
rukhi7
все хорошо, только пример подкачал практический:
нет пока такой игры в которую одновременно играют миллион связанных игроков, поэтому со структурой код гораздо лучше для такого примера. Такая оптимизация там будет не уместна (фактически в лоб).
zzzzzzerg
Ну вообще то коллега очень кратко пересказывает давно известный в геймдеве SOA против AOS. В примере не обязательно может быть миллион игроков, можно представить миллион юнитов или других элементов из игр.
rukhi7
я может отстал от жизни, но что-то я не могу себе представить чтобы во время игры надо было каким-то миллиону юнитов что-то вдруг прибавить или убавить, эта тема про базы данных скорее, но там это связано с особенностями энергонезависимого хранения и тоже в чистом виде вряд ли встречается.
Вот когда вы этих юнитов на сюрфейс выводите вот там наверно можно что-то подобное найти, потому что с точки зрения сюрфейса все юниты это просто картинки грубо говоря, но там тоже миллионов нет и есть всякие оптимизации чтобы перерисовать только то что изменяется.
feelamee
первое что пришло в голову - миллион частиц и обновление из позиции со временем.
Chaos_Optima
Обычно такие штуки ворочает compute shader
domix32
Фактически это и есть сказ как делать нормальные compute shader для обычного процессора
slonopotamus
Нет, потому что тут однопоточка.
slonopotamus
Миллион <чего бы то ни было> и однопоточный обход - это смерть перфу, извините.
KivApple
Это могут быть не игроки, а какие нибудь юниты в RTS. И пусть не миллион даже, а десять тысяч, но зато обсчитываемые в 60fps.
Jijiki
ну например Не игровой персонаж, а единица времени зависит от реализации или она 1 от физики (там смотреть надо, например ушли далеко, легко не рисовать Не игрового персонажа, а например как быть с состояниями? их может быть много, вроде можно не обновлять состояния, конечно. Тогда Не игровые персонажи будут при появлении на дистанции отрисовки как бы находится там где игрок от них удалился)