Меня зовут Ибрагим, и я уже много лет занимаюсь программированием на 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;
}

В начале моего знакомства с многопоточностью у меня сложилось впечатление, что для повышения производительности достаточно лишь запустить несколько потоков. Однако, как я позже осознал, ситуация оказалась сложнее.

  1. В случае, когда несколько потоков работают с данными, которые находятся в одной кэш-линии, возникает contention.

  2. Наличие блокировок — результат ситуации, когда несколько потоков пытаются одновременно получить доступ к одним и тем же данным.

Для избежания подобных ситуаций часто применяются атомарные операции и 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)


  1. rukhi7
    27.01.2025 11:38

    все хорошо, только пример подкачал практический:

    На массиве из миллиона игроков:

    нет пока такой игры в которую одновременно играют миллион связанных игроков, поэтому со структурой код гораздо лучше для такого примера. Такая оптимизация там будет не уместна (фактически в лоб).


    1. zzzzzzerg
      27.01.2025 11:38

      Ну вообще то коллега очень кратко пересказывает давно известный в геймдеве SOA против AOS. В примере не обязательно может быть миллион игроков, можно представить миллион юнитов или других элементов из игр.


      1. rukhi7
        27.01.2025 11:38

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

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


        1. feelamee
          27.01.2025 11:38

          первое что пришло в голову - миллион частиц и обновление из позиции со временем.


          1. Chaos_Optima
            27.01.2025 11:38

            Обычно такие штуки ворочает compute shader


            1. domix32
              27.01.2025 11:38

              Фактически это и есть сказ как делать нормальные compute shader для обычного процессора


              1. slonopotamus
                27.01.2025 11:38

                Нет, потому что тут однопоточка.


        1. slonopotamus
          27.01.2025 11:38

          Миллион <чего бы то ни было> и однопоточный обход - это смерть перфу, извините.


    1. KivApple
      27.01.2025 11:38

      Это могут быть не игроки, а какие нибудь юниты в RTS. И пусть не миллион даже, а десять тысяч, но зато обсчитываемые в 60fps.


    1. Jijiki
      27.01.2025 11:38

      ну например Не игровой персонаж, а единица времени зависит от реализации или она 1 от физики (там смотреть надо, например ушли далеко, легко не рисовать Не игрового персонажа, а например как быть с состояниями? их может быть много, вроде можно не обновлять состояния, конечно. Тогда Не игровые персонажи будут при появлении на дистанции отрисовки как бы находится там где игрок от них удалился)


  1. ALapinskas
    27.01.2025 11:38

    Понятно, что перебор Vector<int> значений будет быстрее, чем Vector<Player>, но ведь нужна какая-то привязка health к игрокам.


    1. KivApple
      27.01.2025 11:38

      По индексам


      1. ALapinskas
        27.01.2025 11:38

        Дак и тестировать тогда с нужно индексами, это уже другая структура будет Vector<pair<int, int>>, или Vector<Vector<int,int>>.


  1. 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


  1. segment
    27.01.2025 11:38

    Как обстоят дела с ABA в Вашем LockFreeQueue? Есть еще интересное видео для тех, кто интересуется Data Oriented Design от создателя Zig https://www.youtube.com/watch?v=IroPQ150F6c


  1. zzzzzzerg
    27.01.2025 11:38

    Как то у вас получился замах на рубль, а удар на копейку. Заявленные темы интересны сами по себе (впрочем и известны давно), но у вас кроме поверхностных сообщений нет никаких захватывающих подробностей.


  1. lczero
    27.01.2025 11:38

    Ибрагим давай попердим


  1. Seenkao
    27.01.2025 11:38

    • Если вы постоянно создаёте и удаляете объекты, то возможно вы что-то не так делаете?

    Может достаточно определить заранее будут одинаковые объекты в программе/игре и при удалении очередного объекта не удалять его, а просто пометить удалённым? Придёт другой объект и займёт уже подготовленную память, да, данные перезаписать придётся, но зато "тормошить" память постоянно не надо.

    • для многопоточных приложений не проще определить какая функциональность может работать отдельно, а какая нет?

    Понятно дело, что везде это достаточно реализовать, но определённая функциональность изначально понятна и сразу предполагает разделить на потоки. А другая функциональность не предполагает, и она будет больше тормозить очередь, чем помогать в работе.

    Просто смотрите, где какую оптимизацию лучше применить.


  1. Jijiki
    27.01.2025 11:38

    кстати, еще заметил, при любом раскладе если имеем допустим 9 террейнов соединенных в 1, допустим 9 квадратов - тоесть мест где, чтото находится, соотв получается первично надо сначала понять где игрок - на каком квадрате->и тут уже списки этого квадрата(это самое тривиальное, конечно, может есть лучше способы )


    1. MonkeyWatchingYou
      27.01.2025 11:38

      Игрок бежит вдоль шва и ругается на тормоза


  1. maxbushlya
    27.01.2025 11:38

    Увы, статья ни о чем. Есть же книжка про оптимизации на плюсах, покрыто несколько страниц всего. Современные алгоритмы управления памятью настолько крутые что я бы вообще запретил говорить что нью/дэлит медленные в общем. Докажите сперва! Только что в этом убедился на мак ос. Я же умная Клава: выделил нужные куски один раз, стэк свободных элементов без блокировки, по сути всё выделение заключалось в возврате индекса в массиве... Ну и чего вы думаете? Замеры показали что я не выиграл, только лишь приблизился к системному по времени


    1. MonkeyWatchingYou
      27.01.2025 11:38

      Система тоже не выиграла, либо она вернула вам общание, что при первом же доступе всё будет тип топ, либо завалялся чанк под ваши нужды.


      1. maxbushlya
        27.01.2025 11:38

        Пардон, не понял вас. Слова понятные по отдельности но не складываются в моей голове))


  1. ViacheslavNk
    27.01.2025 11:38

    Статья интересная, но все же было бы неплохо уведет какой-то живой пример, например мы загружаем данные из базы данных и отрисовываем карту, объекты это в вектор рисуем, удаляем. Будет ли в этом случае оптимизация эффективной, имеет ли она вообще смысл, если условно получение фичей из БД и отрисовка их занимает на пару порядков больше времени что даже самая не эффективная аллокация и вставка в вектор.


  1. slonopotamus
    27.01.2025 11:38

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

    Ну так "ускоренная скорость" - это же хорошо, э?