главная / блог / движок
Опубликовано: 12 июня 2026 г. 11 мин чтения движок
Управление памятью в C++ в игровом движке: кто съедает кадр?

Управление памятью в C++ в игровом движке: кто съедает кадр?

Вызов new внутри кадра вызывал всплески в 13 мс в профайлере; после арены кадра и пуллинга объектов пик упал до 7,9 мс. Вот как это произошло.

Трёхмиллисекундный всплеск в профайлере

Поздней зимой я профилировал боевую сцену в нашем собственном движке. Среднее время кадра составляло около 9 мс, но каждые несколько секунд появлялся кадр в 13 мс. На экране ничего явно не подтормаживало, однако при целевых 60 FPS такие всплески ощущались. Сначала мы посмотрели на рендеринг, потому что все сначала ищут там, и время рендеринга оказалось почти постоянным от кадра к кадру.

Виновником оказался один единственный вызов внутри DamageNumberSystem: new DamageLabel() для каждого удара. Во время волны с 400 ударами в секунду это означало шесть‑семь аллокаций в куче за кадр. Большинство из них занимали 60 нс, но иногда одна превышала 40 мкс и портил весь кадр. График средних значений этого не показывает, потому что 399 из 400 аллокаций дешевые.

Не каждый такой всплеск связан с аллокацией, поэтому первой задачей было отделить причины. Мы добавили счётчик к каждому вызову operator new внутри кадра, и кадры, где счётчик достигал пика, совпадали один к одному с кадрами, где пиковало время кадра. После этого аргументов не осталось.

Именно поэтому управление памятью в C++ в игровом движке — отдельная тема: важна не средняя стоимость, а худший случай. В бюджете в 16,6 мс одна задержка хвостовой латентности пропускает кадр, и игрок ощущает это раньше, чем вы увидите в цифрах.

Почему new/delete опасны внутри кадра

Общий аллокатор не детерминирован. malloc просматривает свободные списки в поисках подходящего блока, берёт блокировку и иногда запрашивает у операционной системы новые страницы. В последнем случае стоимость прыгает с наносекунд до микросекунд, и никакое чтение кода не подскажет, в каком кадре это произойдёт. На Windows большинство измеренных всплесков точно совпадали с ошибками страниц.

Вторая проблема — фрагментация. За длительную сессию тысячи короткоживущих аллокаций разного размера пробивают дырки в адресном пространстве, и спустя время запрос того же размера стоит дороже, чем раньше. За 40‑минутный тест тот же код в итоге занимал вдвое дольше, чем в первую минуту игры. На консоли хуже, потому что там нет такого же запаса виртуальной памяти.

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

Четвёртый момент — измерение само по себе. Стоимость общего аллокатора никогда не собирается в одном месте, она распределяется по сотням точек вызова, и ни одна из них не выглядит достаточно большой, чтобы привлечь внимание. Поэтому проблемы управления памятью проявляются, когда измеряете суммарно, а не когда сосредотачиваетесь на отдельной системе.

Арена кадра: один сброс за кадр

Арена‑аллокатор, также называемый линейным аллокатором, — простая идея: зарезервировать один большой блок при старте, сдвигать курсор вперёд при каждой аллокации и никогда не освобождать отдельные блоки. В конце кадра курсор возвращается в ноль и всё готово. Наш FrameArena открывается с 8 МБ, а аллокация стоит один шаг выравнивания плюс одно сложение. Мы задали размер вдвое больше измеренного пика использования и записываем количество использованных байт в телеметрию в конце каждого кадра.

Правило таково: в арену попадают только объекты, которые не живут дольше кадра: список видимости, временный массив DrawCommand, список пар из широкофазного физического расчёта, геометрия текста, которую UI собирает для этого кадра. Ни один указатель, переходящий границу кадра, не должен исходить из арены, иначе вы перезапишете его в следующем кадре, и эта ошибка будет молчаливой.

Reset() не вызывает деструкторы. Поэтому мы помещаем в арену только тривиально деструктируемые типы. Всё остальное требует отдельного списка деструкторов, что съедает часть преимущества в скорости; на практике мы этого не делали. static_assert в месте аллокации принуждает правило проверяться на этапе компиляции.

Каждому рабочему потоку была выделена своя арена. Это убрало даже последнюю атомарную операцию из пути аллокации, и скрытое соперничество между потоками полностью исчезло. Арена, которая никогда не делится, не нуждается в блокировке. Четыре арены дают в сумме 32 МБ, что ничтожно по сравнению с выигрышем в времени кадра.

ArenaAllocator.h
#pragma once #include <cstddef> #include <cstdint> // Bump-pointer arena: allocate cheaply, reset once per frame. class ArenaAllocator { public: ArenaAllocator(void* block, std::size_t bytes) : base_(static_cast<std::uint8_t*>(block)), size_(bytes), head_(0) {} // Returns nullptr when the arena is exhausted; the caller must check. void* Allocate(std::size_t bytes, std::size_t align = 16) { const std::uintptr_t start = reinterpret_cast<std::uintptr_t>(base_); const std::uintptr_t aligned = (start + head_ + align - 1) & ~(align - 1); const std::size_t next = (aligned - start) + bytes; if (next > size_) return nullptr; head_ = next; return reinterpret_cast<void*>(aligned); } // Destructors are NOT run here: store trivially destructible types only. void Reset() { head_ = 0; } private: std::uint8_t* base_; std::size_t size_; std::size_t head_; };
cppArenaAllocator.h

Пул объектов для объектов фиксированного размера

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

Приятная особенность пула памяти в том, что список свободных может находиться внутри самих блоков. Свободный блок не используется, поэтому в его первые восемь байтов записывается адрес следующего свободного блока; отдельной структуры данных и отдельного выделения не требуется. Это делает Acquire() и Release() постоянными по времени и почти без ветвлений.

Мы никогда не выдаём сырые указатели на объекты из пула. Каждый снаряд получает 32‑битный индекс плюс счётчик поколения; когда снаряд умирает, поколение инкрементируется, и любой устаревший хэндл, хранящийся где‑то ещё, автоматически становится недействительным. Это превращает use‑after‑free из краша в тихий сбой, который можно проверить с помощью assert до того, как он нанесёт вред.

Для ProjectilePool мы выбрали ёмкость 4096; пиковое использование в самой плотной волне составило 2870. Когда пул заполняется, мы не растягиваем его, а перерабатываем самый старый снаряд. Рост в середине кадра противоречил бы причине введения арены и пула. Мы пересматриваем ёмкость один раз за релиз, используя пиковое значение, которое сообщает телеметрия.

Локальность кэша, выравнивание и ложное совместное использование

Настоящее преимущество от смены аллокаторов — не время выделения, а то, что объекты оказываются рядом в памяти. 2870 снарядов из пула находятся подряд, цикл обновления читает 64‑байтовые строки кэша последовательно, и аппаратный префетчер включается. Когда мы распределяли то же количество снарядов через new, уровень промахов кэша утроился.

Сокращение горячих данных — вторая часть. Уменьшив структуру Projectile с 96 байт до 48, мы уместили два снаряда в одну строку кэша и сократили цикл обновления с 0,9 мс до 0,6 мс. Достаточно было переместить холодные поля, такие как указатель на меш и идентификатор звука, в параллельный массив. Единственное, что изменилось в измерении, — расположение полей; математика осталась той же.

Ложное совместное использование, с другой стороны, незаметно до измерения. Каждый из наших четырёх рабочих потоков инкрементировал свой собственный счётчик, но счётчики находились в одной строке кэша, поэтому каждое записывание инвалидировало строку на остальных ядрах. Разделив их с помощью alignas(64), мы сэкономили 1,2 мс на этой системе, и это была однострочная правка.

Для SIMD‑кода выравнивание обязательно. Функция Allocate арены принимает параметр выравнивания; мы передаём 16 для буфера, содержащего значения __m128, и 32 для пути AVX. Даже на платформах, где невыравненный доступ не приводит к сбою, мы измерили заметное замедление. Поскольку арена уже возвращает выровненные блоки, эта проверка находится в единственном месте.

Достаточно ли std::pmr или нужен собственный аллокатор?

Итоги измерений: в боевой сцене среднее время кадра сократилось с 9,2 мс до 7,1 мс, но реальная разница видна в 99‑м процентиле. Пик упал с 13,4 мс до 7,9 мс, а периодические всплески в профайлере полностью исчезли. Мы повторили измерения на двух разных машинах — соотношение осталось тем же. Весь процесс занял три недели и уместился в два заголовочных файла.

Большую часть этого можно было бы достичь с помощью std::pmr. std::pmr::monotonic_buffer_resource уже является ареной, а unsynchronized_pool_resource — пулом. Если вы работаете со стандартными контейнерами, сначала измерьте, а затем подложите под них ресурс памяти — это покрывает большую часть нужного, почти без затрат на обслуживание. К тому же это стандартный интерфейс, который уже знаком всей команде.

Случаи, когда действительно нужно писать собственный аллокатор, узки: горячие циклы, где даже виртуальный вызов нежелателен, системы, где вместо указателей хранятся 32‑битные индексы, специальные области памяти на консольном железе и собственная телеметрия, отслеживающая бюджет кадра. Если ни один из этих четырёх пунктов к вам не относится, оставайтесь на pmr.

Если вы делаете только одно — начните с подсчёта выделений внутри кадра. Добавить счётчик в движок и выводить количество выделений за кадр занимает полчаса, а полученное число обычно оказывается тем, о чём никто в команде не догадывался. Чем ближе это число к нулю, тем проще становится всё остальное.

← Все статьи