Пилим движок Arcanum. Урок 03. Работа с памятью, используем полиморфные аллокаторы
Приветствую Хабравчане!
Прошлые уроки:
Пилим движок Arcanum. Урок 01. Начало
Пилим движок Arcanum. Урок 02. Работа с файлами игры, рисуем первый спрайт
Продолжаем мучить разрабатывать движок для моей любимой игры Arcanum. В данном уроке расскажу, как движок управляет памятью и какие паттерны и подходы использует.
Пояснение.
Сначала я хотел сделать урок, совместив, объяснение работы с памятью и рисованием карты и объектов на ней. При создании статьи, она получилась довольно большой, что скорее всего будет мешать восприятию и как минимум половина статьи будет просто проскроллена. Поэтому я разделили статью на две. Текущая статья, являющаяся 3-им уроком. И будущая статья номер четыре, где мы уже погрузимся в рисование карты и графических обектов.
Синопсис.
Каждая программа, движок, специализированное ПО работает с памятью. Если отбросить все нюансы, то new в языке С++ почти всегда медленно. Поэтому для ускорения аллокаций используют разные подходы, техники и дополнительные библиотеки. Которые бы ускорили выделение памяти. Самый простой и очевидный, это использование библиотек по типу jemalloc, tcmalloc. Внутри себя данные библиотеки, создают арены памяти разного размера и при выделении памяти, они стараются минимизировать обращение к системным вызовам операционной системы. Если совсем упростить. То при запросе 4-ёх байт, вначале запрашивается кусок в несколько мегабайт и уже из него выделяется так нам нужные 4 байта. Преимущество таких библиотек заключается в их простой интеграции и не требует переписывания уже написанного кода. Подключили перегрузили глобальный new и delete и вперёд, множить количество UB:)
Почему данный подход совершенно не подходит к разрабатываемому движку?
Данный движок использует библиотеки SDL1 и SDL2. SDL1 развивается с конца 90-ых годов прошлого века, портирован под множество систем и архитектур. И я бы хотел максимально использовать данную возможность. В том числе и старые системы которые интересны энтузиастам. Так вот на таких системах проблематично завести распределители памяти, так как они написаны под современные системы. И на старых могут просто не собраться или требовать титанических усилий для портирования. Что совершенно не входит в мои планы.
Поэтому рассмотрим другой вариант, так как я использую стандарт С++ 98 (если меня читают js программисты, то это как использовать jQuery первых версий для разработки современных сайтах:)) Покопаемся в STL, чем он нам может помочь.
К примеру, каждый контейнер имеет свой аллокатор, который использует общие примитивы для работы с памятью, а именно new и delete. Мы можем просто написать свои реализации, они довольно многословны, но таки работают. Но имеют серьезный недостаток, а именно отсутствие состояния и совместимость контейнеров при использовании одинаковых по типу аллокаторов. В принципе возможно создать аллокаторы которые бы обращались к статическим переменным, указывающим на аллоцированные куски памяти. Допустим мы бы выделили несколько арен для наших нужд, несколько аллокаторов которые бы строго брали память из своей арены. Но данный подход хоть и будет работать, но является не очень-то и гибким.
Если вам интересно узнать больше об аллокаторах, почему в С++ 98 они сделаны именно так, а не иначе. Рекомендую лекции Константина Владимирова.
Я тут придумал шутейку. Типа только импортозамещенные ссылки на видео. Я пытался их найти вне ютуба. Потратил минут 10. В VK нет, на rutube нет. Нашел только на яндекс видео.
Магистерский курс C++ (МФТИ, 2022–2023). Лекция 15. Аллокаторы
Магистерский курс C++ (МФТИ, 2022–2023). Лекция 16. Полиморфные аллокаторы
Очень советую потратить время и ознакомиться с данными лекциями.
Есть вариант написать, что-то своё. Как пример некий абстрактный класс аллокатора. Который будет базой для разных видов аллокаторов. И уже для нужд движка использовать самый подходящий имея единый интерфейс и возмождность динамически менять аллокатор. Сначала я так и стал делать. Но потом поразмыслив я понял, что переизобретаю полиморфные аллокаторы из С++ 17. Да кривые, ограниченные, но являющиеся похожим концептом из современного С++.
Так как я пишу код используя стандарт языка С++ 98. Для лучшей портируемости, как на новые, так и на старые и очень старые системы. Возможно ли реализовать часть функционала полиморфных алокаторов на С++ 98? Главное условие, что моя реализация должна быть совместима с новыми стандартами. При сборке на компиляторе с поддержкой начиная С++ 98 по С++ 14, использую мою кастомную реализацию. При использовании компилятора с поддержкой С++ 17 и выше, используем нативную реализацию.
А почему, собственно, и нет. Если я смогу использовать хотя бы общую концепцию, это довольно сильно упрости и унифицирует работу с памятью.
На хабре есть отличная статья с примерами полиморфных аллокаторов. Я лучше не напишу, а дублировать информацию не вижу смысла. Для понимания дальнейшей статьи прошу вас с ней ознакомиться.
Предвосхищая вопрос, да мне таки удалось реализовать малую совместимую часть функционала полиморфных аллокаторов. Реализация имеет серьезные ограничения и нерешенные проблемы, но это работает и покрывает текущий функционал движка.
В принуипе на С++ 98 можно реализовать фичи не опирающиеся на новые конструкции языка. Конечно мы не сможем реалитзовать такие полезные вещи как:
Вывод типом с помощью auto
Циклы на foreach’ах
Семантика перемещения std: move
Списки инициализации std: initializer_list
Умные указатели
Спецификаторы
Разделители разрядов
И список можно долго продолжать…
Попытка портировать уже написанное.
Итог, боль, печаль и уныние.
Первым делом я решил, возьму нужный функционал, пару контейнеров, допилю напильником, да что может пойти не так? Как оказывается всё.
Первый вариант посмотреть реализацию STL от Microsoft и версию под gcc. Я не знаю, что за сверх люди пишшут стандартную библиотку. Но мне это очень сложно парсить. Подход чик, чик и в прод меня подвёл. А значит, так как мне нужен небольшой функционал, дорогу осилит идущий. Добавляем в закладки cppreference.com и начинаем грызть кактус гранит науки.
Реализация.
Весь урок лежит в ветке ArcanumTutorial_03_DrawingMapAndObjects. Клонируем переключаемся и поехали.
Сам движок использует не так много функционала стандартной библиотеки С++. А точнее:
Контейнеры vector, unordered_map, string
Пару алгоритмов из
Стандартные типы вроде size_t
И пару тройку заголовков из С.
В будущем я планирую добавить свою реализацию используемого движком функционала из стандартной библиотеки, что бы не зависеть от библиотек компилятора. Это будет как дополнительная опция при сборке. Мне это интересно, поэтому добавил в свой бэк лог.
Всю реализацию убрал в отдельный каталог в проекте. Заменил в исходниках, все инклюд файлы стандартной библиотеки на один #include
С помощью него разруливаем какую верисю поддерживает компилятор и в зависимости от версии стандарта используем кастомную реализацию или нативную.
#ifndef litecpp_hpp
#define litecpp_hpp
#include
#include
#include
#if ((defined(_MSVC_LANG) && _MSVC_LANG >= 201103L) || __cplusplus >= 201103L)
#include
#else
#include
#include
#endif
#if ((defined(_MSVC_LANG) && _MSVC_LANG >= 201703L) || __cplusplus >= 201703L)
#include
#include
#else
#include
#include
#include
#include
#include
#endif
#include
#endif
Для начала треубется реалиpовать std: pmr: memory_resource. Это абстрактный класс от которого наследуются разные типы аллокаторов.
Реализация максимально простая. Главные методы это выделение и освобождение памяти.
#include
namespace std
{
namespace pmr
{
class memory_resource
{
public:
virtual ~memory_resource() {};
virtual void* allocate(size_t bytes) = 0;
virtual void deallocate(void* p, size_t bytes) = 0;
virtual void release() = 0;
private:
};
}
}
Следом идёт уже первый распределитель памяти это std: pmr: monotonic_buffer_resource. Главная его цель, это получить ссылку на память и размер памяти. Работает как линейный аллокатор.
При вызове метода allocate, проверяется хватает ли запрашиваемого размера во внутреннем буфере памяти. Если нет то assert, если все ок. Просто возвращаем текущую позицию указателя и увеличиваем данный указатель на количество запрошенных байт.
Главная ремарка. Оригинальный распределитель, при нехватке места во внутреннем буфере, создает второй буффер и начинает отдавать память из него. Данный функционал я не стал реализовыват за ненадобностью. Потому просто поставил assert как начальную проверку. Мне как разработчику движка, нужно будет учитывать данную реализацию и выделять буфера нужного размера.
Главное преимущество распределителя это аллокация памяти за O (1)
void* monotonic_buffer_resource::allocate(size_t bytes)
{
assert(bytes > 0);
assert(_position + bytes <= _capacity);
void* result = _content + _position;
_position += bytes;
return result;
}
В моем случае метод deallocate только проверяет память. Что бы мы туда не могли передать нулевой указатель. И возможно стоит добавить условие не передан ли указатель который выходит за границы памяти буфера.
void monotonic_buffer_resource::deallocate(void* p, size_t bytes)
{
assert(p != nullptr);
assert(bytes > 0);
}
Очистка памяти это простейшая операция. Внутрення позиция устанавливается в ноль.
void monotonic_buffer_resource::release()
{
_position = 0;
}
Следом идёт, просто классика, своя полиморфная строка:)
Главное отличие от обычной строки это возможность передать в конструктор указатель на std: pmr: memory_resource.
Особо интересного там нет. Просто минимальная реализация строки. Но она так же ведет себя и как простая строка, это достигается за счёт проверки откуда брать память при аллокации, глобльный new или из memory_resource
T* allocate(size_t count)
{
if (_resource)
{
void* ptr = _resource->allocate(count * sizeof(T));
return new (ptr) T[count];
}
else
{
return new T[count];
}
}
В итоге строка выглядит так.
namespace std
{
namespace pmr
{
typedef litecpp::base_string string;
}
}
Одно из интересных мест, это реализация std: pmr: unordered_map. Это закрытая хеш-таблица. В нее так же передается в конструктор распределитель. Я реализовал небольшой функционал. А именно вставку ключ-значение, поиск по ключу и в конструктор добавил возможность указать размер внутренней таблицы, в ячейках которых хоанятся узлы пар, ключ, значение.
Хеш таблица стостоит из массива двухсвязных списоков.
template
class list
{
public:
list() :
head(nullptr),
tail(nullptr)
{
}
void append(list_node* node)
{
node->next = nullptr;
node->prev = tail;
if (tail)
{
tail->next = node;
}
tail = node;
if (head == nullptr)
{
head = node;
}
}
list_node* head;
list_node* tail;
};
Нода это узел списка с ключем и значением.
template
class list_node
{
public:
list_node() :
next(nullptr),
prev(nullptr)
{
}
list_node* next;
list_node* prev;
std::pmr::string first;
T second;
};
Таблица не умеет увеличивать себя. Это сделано намеренно. Я знаю сколько примерно данных мне нужно добавить в каждую таблицу. И поэтому я могу сам в конструкторе указать, насколько большую таблицу создать на старте. В моём случа это позволяет экономить память.
К примеру мне нужно добавить десятки тысяч ключей. На старте оригинальная таблица имеет размер 16. Пока она достигнет нужного размера в зависимости от количетва записей. Она все время будет перестраиваться. Будет создан массив список в 32 элемента, но старые 16 элементов невозможно использовать, потом 64, 128 и т.д И предыдущуая память будет недоступна для игры.
В моем случа я могу указать оптимальный размер массив и единожды выделить для него память. Это не только экономит память, но так же экономит время ЦП при отсутсвии перестраивания внутренней таблицы и перехэширования ключей.
unordered_map(size_t bucket_count, memory_resource* source) :
_count(0),
_bucket_count(bucket_count),
_memory(source)
{
size_t list_size = sizeof(list);
size_t allocated_size = list_size * _bucket_count;
void* allocated_ptr = _memory->allocate(allocated_size);
_table = new (allocated_ptr) list[_bucket_count];
}
Я планирую всегда использовать для ключа std: pmr: string поэтому взял простую хеш функцию.
size_t HashLy(const char* str)
{
unsigned int hash = 0;
for (; *str; str++)
hash = (hash * 1664525) + (unsigned char)(*str) + 1013904223;
return hash;
}
Поиск элементарен и поти один в один как в книжках.
list_node* find(const K& key)
{
size_t index = HashLy(key.c_str()) % _bucket_count;
for (list_node* i = _table[index].head; i != nullptr; i = i->next)
{
if (strcmp(i->first.c_str(), key.c_str()) == 0)
{
return i;
}
}
return nullptr;
}
Для вставки данных я создал единственнный метод emplace. В С++ 98 нет семантики перемещения. Но данный метод позволяет унифицировать вставку и не создавать ключ значеие через std: pair. Который все равно делает аллокации при конструировании пары.
Можно сказать это лже Дмитрий emplace. Выглядит так:
void emplace(const K& key, const T& value)
{
list_node* node = new (_memory->allocate(sizeof(list_node))) list_node;
node->first = std::pmr::string(key.c_str(), _memory);
node->second = value;
size_t index = HashLy(key.c_str()) % _bucket_count;
_table[index].append(node);
_count++;
}
Используя переданный в конструктор распределитель, создаем ноду в которую копируем ключ и значение. Ключ хранится в строке std: pmr: string которая берет память из этого же распределителя.
То что мне нужно было от полиморфной хеш таблицы я добился. Так же я реализовал std: pmr: vector. Ничего интересного там нет. Он так же завсит от распределителя, но имеет один серьезный недостаток.
В моей реализации вектор не будет работать с контейнерами из стандартной библиотеки, пример.
std::pmr::vector strs;
C++ 17 при конструировании вектора, проверяет условие если это полиморфный контейнер то всем контейнерам передается родительский распределитель. Для этого нужен как минимум enable_if_v или вариации. Нормальная реализация std: pmr: polymorphic_allocator.
Если учесть что может быть так:
std::pmr::vector> strs;
И без вариадических шаблонов не реально реализовать.
Есть идеяв виде макроса, который сможет быть совместимым с оригинальными контейнерами, но только при условии, что контейнер родитель хранит, только не вложенный контейнер. Создаем в конструкторе два указателя на два распределителя. Первый распределитель будет использовать родительский контейнер, второй контейнер наследник
Для совместиомсти сделать макроc:
#define ALLOC_IF(x) (x, x)
Идея в том, что бы тот же распределитель подсунуть контейнеру наследника.
В реализации std: pmr: vector нет ничего интересного, за исклбючением того. Что я создал базовый вектор и с помощтью композиции реализовал std: pmr: vector и std: vector.
С реализацией мы разобрались. Да она не идеальна. Каждый урок я буду ее облагораживать и подкручивать. Это так сказать пре релиз:) Цель сохранить функционал и совместимость с оригиналом.
Теперь рассказ, как оно все работает в движке.
При инициализации движка я единожды аллоцирую память размером 16 мегабат. Эту память я передаю глобальному распределителю _GlobalResource. А уже другие распределители берут память из него.
Память нужна для менеджера ресурсов, спрайтов, спискe индексируемых файлов из архивов игры, буферам для распаковки и чтени файлов.
std::vector _GlobalBuffer;
std::pmr::monotonic_buffer_resource _GlobalResource;
std::pmr::monotonic_buffer_resource _DatBufferResource;
std::pmr::monotonic_buffer_resource _ResultBufferResource;
std::pmr::monotonic_buffer_resource _SptiteBufferResource;
std::pmr::monotonic_buffer_resource _DatListBufferResource;
Проблема с которой я боролся на старых системах. До старта игры, движку нужно прочитать все записи в архивах около 70 тысяч записей. До этого я использовал std: map и вставлял занчения через insert.
namespace Arcanum
{
class DatList
{
public:
DatItem* Get(const std::string& file);
void Add(const std::string& key, DatItem& file, const std::string& archive);
size_t Count();
private:
typedef std::map container;
container _Files;
};
}
Вставка
void DatList::Add(const std::string& key, DatItem& file, const std::string& archive)
{
DatItem* p = Get(key);
if (p == NULL)
{
_Files.insert(std::make_pair(key, file));
}
else
{
strcpy(p->Archive, archive.c_str());
}
}
Когда я делаю insert std: make_pair, происходит аллокация пары ключ-значение, потом при вставке идет аллокация пары в сам контейнер, старая пара уничтожается. Таких записей 70k, вы представляете сколько аллокаций и деалокаций? Поэтому я заранее выделил память для класса индексирующий файлы из архивов и теперь любая вставка записи это O (1). Что ествественно ускорило загрузку и решилась проблема с фрагментацией памяти.
Теперь так выглядит код.
void DatList::Add(const std::string& key, DatItem& file, const std::string& archive)
{
DatItem* p = Get(key);
if (p == nullptr)
{
_Files.emplace(key.c_str(), file);
}
else
{
strcpy(p->Archive, archive.c_str());
}
}
Просто заменил контейнер std: map на std: pmr: unordered_map. В следующем уроке я покажу как движок рисует карту и объекты на ней. Нам так же пригодятся распределители памяти для борьбы с фрагментацией при загрзуке карты с десятками тысяч тайлов, тысячами объектов на ней.
Что имеем по итогу:
У нас есть некоторые фичи из новых стандартов С++.
Теперь движок не фрагментирует память.
Я закрыл гештальт по написанию велосипедов:)
Не так уж и мало я вам скажу. Клево же! Рад буду советам, предложениям и критике.
Благодарности за предложения, вычитку статьи и советы. Спасибо ребят, что потратили на меня своё время, очень вам благодарен. Успехов на программерском пути!
Александру Новожилову и пользователю Setryyy.