У меня есть объектно-компонентная система ООП, которая в настоящее время работает так:
// In the component system
struct Component { virtual void update() = 0; }
struct Entity
{
bool alive{true};
vector<unique_ptr<Component>> components;
void update() { for(const auto& c : components) c->update(); }
}
// In the user application
struct MyComp : Component
{
void update() override { ... }
}
Для создания новых сущностей и компонентов я использую обычные C ++ new
а также delete
:
// In the component system
struct Manager
{
vector<unique_ptr<Entity>> entities;
Entity& createEntity()
{
auto result(new Entity);
entities.emplace_back(result);
return *result;
}
template<typename TComp, typename... TArgs>
TComp& createComponent(Entity& mEntity, TArgs... mArgs)
{
auto result(new TComp(forward<TArgs>(mArgs)...));
mEntity.components.emplace_back(result);
return result;
}
void removeDead() { /* remove all entities with 'alive == false' - 'delete' is called here by the 'unique_ptr' */ }
}
// In the user application
{
Manager m;
auto& myEntity(m.createEntity());
auto& myComp(m.createComponent<MyComp>(myEntity));
// Do stuff with myEntity and myComp
m.removeDead();
}
Система работает отлично, и мне нравится синтаксис и гибкость. Однако при постоянном добавлении и удалении объектов и компонентов в диспетчере распределение / освобождение памяти замедляет работу приложения. (Я профилировал и определил, что замедление вызвано new
а также delete
).
Я недавно читал, что можно предварительно выделить кучу памяти в C ++ — как это можно применить к моей ситуации?
Желаемый результат:
// In the user application
{
Manager m{1000};
// This manager can hold about 1000 entities with components
// (may not be 1000 because of dynamic component size,
// since the user can define it's on components, but it's ok for me)
auto& myEntity(m.createEntity());
auto& myComp(m.createComponent<MyComp>(myEntity));
// Do stuff with myEntity and myComp
m.removeDead();
// No 'delete' is called here! Memory of the 'dead' entities can
// be reused for new entity creation
}
// Manager goes out of scope: 'delete' is called here
Есть несколько вещей, которые вы можете сделать, чтобы улучшить реализацию вашего дизайна.
В вашей текущей реализации есть два выделения памяти на Entity
а также Component
, Первый размещает объект, а второй — когда объект помещается в вектор. Второе происходит, когда вектору не хватает места, он выделяет больший массив и перемещает старые элементы в новый массив.
В этом случае лучшее, что вы можете сделать, это использовать навязчивые списки. То есть каждый из Entity
а также Component
стать также списком узлов. Затем, после того, как они были выделены, нет необходимости выделять дополнительную память для помещения объекта в список. Используйте один или два связанных списка из Boost.Intrusive, или написать свой. Так ядро Linux отслеживает множество различных объектов.
Следующим шагом является предварительное распределение Entity
а также Component
элементы. Предварительное распределение может быть чем-то простым, например, глобальным массивом, или более сложным, например, Boost.Pool. Существует немало способов построить пул памяти объектов.
однажды Entity
а также Component
Предварительно распределяются и используются навязчивые списки.
Пример, который использует компоненты Boost:
#include <boost/intrusive/list.hpp>
#include <boost/pool/pool_alloc.hpp>
#include <new>
namespace bi = boost::intrusive;
// api.h
//
// Object pooling support begin.
//
template<class T>
struct Pool
{
static boost::pool_allocator<T> pool;
};
// Singleton. Although it is defined in the header, the linkers
// make sure there is only one instance of it in the application.
// It is instantiated on demand when Pool<T> is used.
template<class T>
boost::pool_allocator<T> Pool<T>::pool;
template<class Derived>
struct Pooled // use it on the most derived class only, not on intermediate base classes
{
// Automatically use the object pool for plain new/delete.
static void* operator new(size_t) { return Pool<Derived>::pool.allocate(1); }
static void operator delete(void* p) { return Pool<Derived>::pool.deallocate(static_cast<Derived*>(p), 1); }
};
//
// Object pooling support end.
//
// Using bi::list_base_hook<bi::link_mode<bi::auto_unlink> > because it automatically
// unlinks from the list when the object is destroyed. No need to manually
// remove the object from the list when an object is about to be destroyed.
struct Component
: bi::list_base_hook<bi::link_mode<bi::auto_unlink> > // make it an intrusive list node
{
virtual void update() = 0;
virtual ~Component() {}
};
struct Entity
: bi::list_base_hook<bi::link_mode<bi::auto_unlink> > // make it an intrusive list node
, Pooled<Entity> // optional, make it allocated from the pool
{
bool active = false;
bi::list<Component, bi::constant_time_size<false> > components;
~Entity() {
for(auto i = components.begin(), j = components.end(); i != j;)
delete &*i++; // i++ to make sure i stays valid after the object is destroyed
}
void update() {
for(auto& c : components)
c.update();
}
};
struct Manager
{
bi::list<Entity, bi::constant_time_size<false> > entities;
~Manager() {
for(auto i = entities.begin(), j = entities.end(); i != j;)
delete &*i++; // i++ to make sure i stays valid after the object is destroyed
}
Entity& createEntity() {
auto result = new Entity;
entities.push_back(*result);
return *result;
}
template<typename TComp, typename... TArgs>
TComp& createComponent(Entity& mEntity, TArgs... mArgs)
{
auto result = new TComp(std::forward<TArgs>(mArgs)...);
mEntity.components.push_back(*result);
return *result;
}
void removeDead() {
for(auto i = entities.begin(), j = entities.end(); i != j;) {
auto& entity = *i++;
if(!entity.active)
delete &entity;
}
}
};
// user.cc
struct MyComp
: Component
, Pooled<MyComp> // optional, make it allocated from the pool
{
void update() override {}
};
int main() {
Manager m;
auto& myEntity(m.createEntity());
auto& myComp(m.createComponent<MyComp>(myEntity));
m.removeDead();
}
В приведенном выше примере boost::pool_allocator<T>
на самом деле использует new
размещать объекты, а затем он продолжает использовать разрушенные объекты, а не вызывать delete
на них. Вы можете добиться большего успеха, предварительно выделив все объекты, но есть много способов сделать это в зависимости от ваших требований, поэтому я использую boost::pool_allocator<T>
для простоты, чтобы избежать расщепления волос здесь. Вы можете изменить реализацию Pooled<T>
что-то вроде Pooled<T, N>
где N
обозначает максимальное количество объектов, остальная часть кода остается той же, потому что она использует простой new/delete
которые переопределяются для объектов, выделенных из пула.
C ++ поддерживает классовые пулы памяти для такого рода вещей. Универсальный new
/delete
пара неизбежно торгуется среди
Основной способ получить скорость — полностью избежать этих компромиссов с помощью пользовательских распределителей, которые, как вы говорите, предварительно выделяют большой кусок памяти, рассматриваемый как простой массив свободных объектов одинакового размера. Первоначально все они связаны в свободном списке, где указатели ссылок занимают первые байты каждого блока, «наложенного», куда в конечном итоге будут поступать данные. Распределение — это просто освобождение блока от заголовка свободного списка — операция «pop», требующая около 2 инструкций. Распределение — это «толчок»: еще две инструкции. Во многих случаях аппаратное обеспечение памяти может быть настроено на генерацию прерывания, когда пул пуст, так что нет никаких накладных расходов на выделение для обнаружения
это условие ошибки. (В системах GC тот же прием используется для инициирования сбора без дополнительных затрат.)
В вашем случае вам понадобятся два пула: один для сущностей и один для компонентов.
Определить свой собственный распределитель пулов не так сложно, особенно если ваше приложение однопоточное. Увидеть этот документ для учебного лечения.
Одна из ваших проблем может быть решена путем выделения достаточного пространства в векторах при их создании.
За
vector<unique_ptr<Entity>> entities;
обеспечить достаточно места в конструкторе
Manager::Manager() : entities(10000)
{
//...
}
Таким образом, вы избегаете перераспределения и копирования на более поздних этапах.
Второй вопрос — создание вашего unique_ptr<Entity>
указатели. Здесь, поскольку вы всегда будете использовать созданные по умолчанию объекты, вы также можете использовать заранее выделенный пул объектов, из которого вы создаете указатели. Вместо вызова нового вы бы назвали собственный класс
class EntityPool
{
public:
EntityPool(unsigned int size = 10000) : pool(size), nextEntity(0)
{
}
Entity* getNext(void)
{
if (nextEntity != pool.size()) // if pool is exhausted create new
{
pool.emplace_back(Entity());
}
return pool[nextEntity++];
}
private:
vector<Entity> pool;
unsigned int nextEntity; // index into the vector to the next Entity
};
struct Manager
{
vector<unique_ptr<Entity>> entities;
Entity& createEntity()
{
entities.emplace_back(entityPoolInstance.getNext());
return *result;
}
//...
Или вы можете подключить стандартное «новое размещение». Это позволяет вам выделить большой блок памяти для конструирования (размещения) объектов по вашему выбору. Это будет держать блок в куче столько времени, сколько вам нужно, и позволит вам выделить несколько недолговечных объектов в этом блоке вместо выполнения дорогостоящих распределений и перераспределений, которые просто заканчивают фрагментацией кучи. Здесь задействовано несколько проблем, но в целом это ДЕЙСТВИТЕЛЬНО простое решение, не требующее перехода к пользовательскому маршруту менеджера памяти.
Вот отличная обработка устранения некоторых ловушек и описание размещения новых в деталях.
Я использовал простые структуры данных, такие как стек, для отслеживания следующего свободного блока, который будет выделен: помещать адрес блока, который собирается удалить, в стек. При выделении просто вытолкните следующий свободный блок из стека и используйте его в качестве аргумента для размещения нового. Супер просто и супер быстро!
Используя большинство ответов и Google в качестве ссылок, я реализовал некоторые утилиты предварительного выделения в моем Библиотека SSVUtils.
Пример:
using MemUnit = char;
using MemUnitPtr = MemUnit*;
using MemSize = decltype(sizeof(MemUnit)); // Should always be 1 byte
class MemBuffer
{
Uptr<MemUnit[]> buffer;
MemRange range;
MemBuffer(MemSize mSize) : ...
{
// initialize buffer from mSize
}
};
class PreAllocatorChunk
{
protected:
MemSize chunkSize;
MemBuffer buffer;
std::stack<MemRange> available;
public:
PreAllocatorChunk(MemSize mChunkSize, unsigned int mChunks) : ...
{
// Add "chunks" to to available...
}
template<typename T, typename... TArgs> T* create(TArgs&&... mArgs)
{
// create on first "chunk" using placement new
auto toUse(available.top().begin); available.pop();
return new (toUse) T{std::forward<TArgs>(mArgs)...};
}
};
Доступны дополнительные утилиты предварительного выделения:
PreAllocatorDynamic
: предварительно выделяет большой буфер, затем при создании объекта разделяет буфер на две части:
[buffer start, buffer start + obj size)
[buffer start + obj size, buffer end)
Когда объект уничтожен, его занятый диапазон памяти устанавливается как «доступный». Если во время создания нового объекта не найден достаточно большой «чанк», предварительный распределитель пытается объединить смежные куски памяти, прежде чем выдать исключение времени выполнения. Этот предварительный распределитель иногда быстрее, чем new/delete
, но это сильно зависит от размера предварительно выделенного буфера.
PreAllocatorStatic<T>
: унаследовано от PreAllocatorChunk
, Размер куска равен sizeof(T)
, Самый быстрый предварительный распределитель, менее гибкий. Почти всегда быстрее чем new/delete
,