AI Кастомные аллокаторы для игровых движков: arena, pool и slab на C++

AI

Редактор
Регистрация
23 Август 2023
Сообщения
3 603
Лучшие ответы
0
Реакции
0
Баллы
243
Offline
#1
Стандартный malloc — универсальный инструмент, но в геймдеве универсальность часто означает «недостаточно быстро». Когда бюджет кадра 16 мс, а каждый кадр рождаются тысячи объектов, имеет смысл разобраться в специализированных аллокаторах.

Рассмотрим три основных типа: arena, pool и slab — когда какой использовать, как реализовать, и какие подводные камни ждут.

malloc не всегда подходит


malloc на Linux устроен сложно. Для мелких объектов (до ~256 байт) используется tcache — thread‑local кеш, это быстро. Для объектов покрупнее — поиск по bins, спискам свободных блоков разного размера. Для больших (от ~128 КБ) — системный вызов mmap напрямую.

В среднем аллокация занимает 50–200 наносекунд. Но «в среднем» — ключевое слово. В худшем случае — микросекунды: нужно расширить heap, или память фрагментирована, или сработал lock на глобальной арене.

Три проблемы, важных для игр:

Непредсказуемость. Время аллокации зависит от состояния кучи, которое постоянно меняется. Один вызов malloc — 50 нс, следующий — 5 мкс. В играх это проявляется как микрофризы: 59 кадров идут гладко, 60й не особо.

Фрагментация. После множества аллокаций и деаллокаций разного размера память превращается в много маленьких свободных блоков, но ни один не подходит для нового большого объекта. Приходится просить память у системы, хотя свободной вроде бы полно.

Cache locality. Объекты, выделенные в разное время, разбросаны по памяти.

Arena (linear/bump allocator)


Самый простой и самый быстрый аллокатор. Идея элементарна: берём большой кусок памяти, держим указатель «где мы сейчас». Аллокация — сдвигаем указатель вперёд. Деаллокация отдельных объектов невозможна — освобождаем всё разом через reset.

class Arena {
uint8_t* m_buffer;
size_t m_capacity;
size_t m_offset = 0;

public:
explicit Arena(size_t capacity)
: m_buffer(new uint8_t[capacity])
, m_capacity(capacity) {}

~Arena() { delete[] m_buffer; }

void* allocate(size_t size, size_t alignment = alignof(std::max_align_t)) {
// Выравниваем текущую позицию
size_t aligned_offset = (m_offset + alignment - 1) & ~(alignment - 1);

if (aligned_offset + size > m_capacity) {
return nullptr; // Или throw, или grow
}

void* ptr = m_buffer + aligned_offset;
m_offset = aligned_offset + size;
return ptr;
}

void reset() { m_offset = 0; }

size_t used() const { return m_offset; }
size_t available() const { return m_capacity - m_offset; }
};

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

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

// Глобальная или thread-local арена для кадра
thread_local Arena g_frame_arena(4 * 1024 * 1024); // 4 МБ

void Game::update(float dt) {
g_frame_arena.reset(); // Начало кадра — сброс

// Временные буферы для culling
auto* visible = g_frame_arena.allocate_array<Entity*>(max_entities);
size_t visible_count = frustum_cull(all_entities, visible);

// Временные данные для сортировки
auto* sorted = g_frame_arena.allocate_array<RenderCommand>(visible_count);

// ... рендеринг ...

} // Конец кадра — все временные данные автоматически «исчезают»

Обратите внимание: никаких delete, free, деструкторов для временных данных. Reset в начале кадра — и память снова доступна.

Дополнительная возможность — savepoints (маркеры):

struct ArenaMarker {
size_t offset;
};

ArenaMarker Arena::save() const {
return {m_offset};
}

void Arena::restore(ArenaMarker marker) {
m_offset = marker.offset;
}

Это позволяет делать временные аллокации внутри функции и откатываться:

void calculate_something(Arena& arena) {
auto marker = arena.save();

// Временные буферы для вычислений
auto* temp = arena.allocate_array<float>(1000);
// ... вычисления ...

arena.restore(marker); // Откат — temp больше не занимает место
}
Pool allocator


Arena не умеет освобождать отдельные объекты. Для долгоживущих объектов одного типа с произвольным временем жизни нужен pool.

Идея: память нарезана на слоты одинакового размера. Свободные слоты связаны в односвязный список. Аллокация — берём первый свободный. Деаллокация — возвращаем в начало списка.

template<typename T>
class Pool {
struct FreeNode {
FreeNode* next;
};

uint8_t* m_buffer = nullptr;
size_t m_capacity = 0;
FreeNode* m_free_list = nullptr;

public:
explicit Pool(size_t count) {
static_assert(sizeof(T) >= sizeof(FreeNode*), "Object too small for pool");

m_capacity = count;
m_buffer = new uint8_t[count * sizeof(T)];

// Инициализируем free list
for (size_t i = 0; i < count; ++i) {
auto* node = reinterpret_cast<FreeNode*>(m_buffer + i * sizeof(T));
node->next = m_free_list;
m_free_list = node;
}
}

T* allocate() {
if (!m_free_list) return nullptr; // Или grow

void* ptr = m_free_list;
m_free_list = m_free_list->next;
return static_cast<T*>(ptr);
}

void deallocate(T* ptr) {
auto* node = reinterpret_cast<FreeNode*>(ptr);
node->next = m_free_list;
m_free_list = node;
}

// Удобные обёртки с конструктором/деструктором
template<typename... Args>
T* create(Args&&... args) {
T* ptr = allocate();
if (ptr) new(ptr) T(std::forward<Args>(args)...);
return ptr;
}

void destroy(T* ptr) {
ptr->~T();
deallocate(ptr);
}
};

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

O(1) аллокация, O(1) деаллокация. Нет фрагментации — все слоты одного размера, любой свободный подходит. Объекты лежат в непрерывном буфере.

Пример системы частиц:

class ParticleSystem {
Pool<Particle> m_pool{10000};
std::vector<Particle*> m_active;

public:
void emit(const Vec3& position, const Vec3& velocity) {
Particle* p = m_pool.create();
if (!p) return; // Пул исчерпан

p->position = position;
p->velocity = velocity;
p->lifetime = 2.0f;
m_active.push_back(p);
}

void update(float dt) {
for (size_t i = 0; i < m_active.size(); ) {
Particle* p = m_active;
p->lifetime -= dt;
p->position += p->velocity * dt;

if (p->lifetime <= 0) {
m_pool.destroy(p);
// Swap-and-pop для O(1) удаления из вектора
m_active = m_active.back();
m_active.pop_back();
} else {
++i;
}
}
}
};

Тысячи частиц рождаются и умирают каждый кадр. С malloc это было бы заметно в профилировщике. С пулом практически бесплатно.

Slab allocator


Pool хорош, когда все объекты одного размера. Но часто нужны объекты разных размеров, и создавать отдельный пул под каждый тип — муторно.

Slab allocator — обобщение pool для объектов разных размеров.

Создаём несколько пулов для разных «классов размеров». При аллокации округляем запрошенный размер вверх до ближайшего класса и берём слот из соответствующего пула.

Классы обычно — степени двойки: 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096 байт.

class SlabAllocator {
static constexpr size_t NUM_CLASSES = 10; // 8 .. 4096
static constexpr size_t MAX_SIZE = 4096;
static constexpr size_t SLAB_SIZE = 65536; // 64 КБ на slab

struct Slab {
std::vector<uint8_t> buffer;
void* free_list = nullptr;
size_t object_size;

explicit Slab(size_t obj_size) : object_size(obj_size) {
size_t count = SLAB_SIZE / obj_size;
buffer.resize(count * obj_size);

for (size_t i = 0; i < count; ++i) {
void* ptr = buffer.data() + i * obj_size;
*reinterpret_cast<void**>(ptr) = free_list;
free_list = ptr;
}
}
};

std::array<std::vector<Slab>, NUM_CLASSES> m_slabs;
std::mutex m_mutex; // Для многопоточности

public:
void* allocate(size_t size) {
if (size > MAX_SIZE) {
return ::operator new(size); // Fallback на системный
}

size_t class_idx = size_to_class(size);
std::lock_guard lock(m_mutex);

auto& class_slabs = m_slabs[class_idx];

// Ищем slab со свободным слотом
for (auto& slab : class_slabs) {
if (slab.free_list) {
void* ptr = slab.free_list;
slab.free_list = *reinterpret_cast<void**>(ptr);
return ptr;
}
}

// Все slabs заняты — создаём новый
size_t obj_size = class_to_size(class_idx);
class_slabs.emplace_back(obj_size);
return class_slabs.back().allocate();
}

void deallocate(void* ptr, size_t size) {
if (size > MAX_SIZE) {
::operator delete(ptr);
return;
}

size_t class_idx = size_to_class(size);
std::lock_guard lock(m_mutex);

// Находим slab, которому принадлежит ptr, и возвращаем в free list
// (упрощённо — в реальности нужен способ найти slab по указателю)
}

private:
static size_t size_to_class(size_t size) {
// Округляем до степени двойки, минимум 8
if (size <= 8) return 0;
return std::bit_width(size - 1) - 2; // C++20
}

static size_t class_to_size(size_t class_idx) {
return size_t(8) << class_idx;
}
};

Запросил 33 байта — получил слот на 64 (ближайшая степень двойки). Да, оверхед есть — в среднем ~25% памяти теряется на округление. Зато O(1) аллокация и никакой фрагментации внутри класса.

Linux использует три реализации slab в ядре: SLAB (классический, много метаданных), SLUB (дефолт в современных ядрах, оптимизирован для SMP), SLOB (для embedded‑систем с минимумом памяти).

Сравнение производительности


Бенчмарк: 100 000 аллокаций случайного размера 8–256 байт, затем освобождение в случайном порядке.

Результаты на моем обычном ноуте:

Аллокатор​

Alloc​

Free​

Примечание​

glibc malloc​

~85 ns​

~65 ns​

Средние значения​

jemalloc​

~50 ns​

~45 ns​

Оптимизирован для многопоточности​

slab​

~12 ns​

~8 ns​

Наша реализация​

pool​

~5 ns​

~4 ns​

Фиксированный размер​

arena​

~3 ns​

N/A​

reset() ~1 ns​



Slab в 7 раз быстрее glibc malloc. Pool — в 15 раз. Arena — в 25 раз.

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

Нюансы


Thread safety. Примеры выше используют mutex, что конечно же плохо влияет на производительность. Решения:


  • Thread‑local арены/пулы — каждый поток работает со своим, никаких блокировок


  • Lock‑free структуры — сложно, но возможно для free list


  • Sharding — несколько пулов, выбор по thread ID

Alignment. SSE требует 16-байтное выравнивание, AVX — 32-байтное. Забыть про alignment — словить SIGBUS на некоторых архитектурах или тихую деградацию производительности на x86.

void* allocate(size_t size, size_t alignment = alignof(std::max_align_t)) {
// Всегда учитывайте alignment
}

Debugging. Valgrind и AddressSanitizer не понимают кастомные аллокаторы — для них вся память выглядит валидной. Добавляйте свои проверки:

#ifdef DEBUG_ALLOCATOR
static constexpr uint64_t CANARY_BEGIN = 0xDEADBEEFCAFEBABE;
static constexpr uint64_t CANARY_END = 0xFEEDFACEDEADC0DE;

void* debug_allocate(size_t size) {
// Выделяем size + 2 * sizeof(canary)
// Пишем canary в начало и конец
// При освобождении проверяем — если повреждены, buffer overflow
}
#endif

Деструкторы. Arena не вызывает деструкторы при reset(). Если объекты владеют ресурсами (файлы, сокеты), нужно явно вызывать деструкторы перед reset или использовать только trivially destructible типы.

Что выбрать

Сценарий​

Аллокатор​

Почему​

Временные данные кадра​

Arena​

Сброс за O(1), никаких деаллокаций​

Много объектов одного типа​

Pool​

O(1), нет фрагментации​

Разные размеры, нужна скорость​

Slab​

Компромисс между скоростью и гибкостью​

Редкие аллокации, разные размеры​

malloc​

Не усложняйте без необходимости​



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



Если тема аллокаторов зацепила, логичный следующий шаг — системно прокачать С++ до уровня, где такие решения пишутся и отлаживаются уверенно. На курсе «C++ Developer. Professional» разбирают современные стандарты до C++23, корректность кода, многопоточность и работу с памятью через практику (14 работ) и разбор с экспертами. Готовы к серьезному обучению? Пройдите входной тест.

А чтобы узнать больше о формате обучения и задать вопросы экспертам, приходите на бесплатные демо-уроки:


  • 28 января в 20:00. «Паттерны проектирования на С++». Записаться


  • 9 февраля в 20:00. «Lock-free в C++: Без блокировок к высокой производительности». Записаться


  • 19 февраля в 20:00. «С++ под капотом — что стоит за кодом, который мы пишем». Записаться
 
Яндекс.Метрика Рейтинг@Mail.ru
Сверху Снизу