了解缓存友好,面向数据的对象和句柄

时间:2013-10-15 16:02:57

标签: c++ handle data-oriented-design

using namespace std;

考虑实体/对象管理的传统 OOP方法

struct Entity { bool alive{true}; }

struct Manager {        
    vector<unique_ptr<Entity>> entities; // Non cache-friendly

    void update() {
        // erase-remove_if idiom: remove all !alive entities
        entities.erase(remove_if(begin(entities), end(entities),
            [](const unique_ptr<Entity>& e){ return !e->alive; }));
    }
};

struct UserObject {
    // Even if Manager::entities contents are re-ordered
    // this reference is still valid (if the entity was not deleted)
    Entity& entity;
};

但是,我想尝试面向数据的方法动态分配Entity个实例,但将它们存储在缓存友好的线性内存中。

struct Manager {
    vector<Entity> entities; // Cache-friendly
    void update() { /* erase-remove_if !alive entities */ }
};

struct UserObject {
    // This reference may unexpectedly become invalid
    Entity& entity;
};

看起来不错。但是......如果std::vector需要重新分配其内部数组,则对实体的所有引用都将失效。

解决方案是使用句柄类。

struct Entity { bool alive{true}; };
struct EntityHandle { int index; };

struct Manager {
    vector<Entity> entities; // Cache-friendly      
    void update() { /* erase-remove_if !alive entities */ }
    Entity& getEntity(EntityHandle h) { return entities[h.index]; }
};

struct UserObject { EntityHandle entity; };

如果我只在向量的后面添加/删除实体,它似乎有效。我可以使用getEntity方法来检索我想要的实体。

但是如果我从矢量中间删除Entity怎么办?所有EntityHandle实例现在都将保留不正确的索引,因为所有内容都已移位。例如:


句柄指向索引:2

Diagram 1


实体A在更新()

期间被删除

现在句柄指向错误的实体。

Diagram 2


这个问题通常是如何处理的?

句柄索引是否已更新?

是否将占空实体替换为占位符?


澄清:

Thisthis缓存友好型设计的含义示例。

此外, Artemis 等组件系统声称采用线性缓存友好型设计,并且他们使用与句柄类似的解决方案。他们如何处理我在这个问题中描述的问题?

7 个答案:

答案 0 :(得分:5)

失眠者做了一个伟大的powerpoint,他们的解决方案是这样的

template<typename T, int SIZE>
class ResourceManager
{
    T data[SIZE];
    int indices[SIZE];
    int back;

    ResourceManager() : back(0)
    {
        for(int i=0; i<SIZE; i++)
            indices[i] = i;
    }

    int Reserve()
    { return indices[back++]; }

    void Release(int handle)
    {
        for(int i=0; i<back; i++)
        {
            if(indices[i] == handle)
            {
                back--;
                std::swap(indices[i], indices[back]);
                return;
            }
        }
    }

    T GetData(int handle)
    { return data[handle]; }
};

我希望这个例子能够清楚地表明这个想法

答案 1 :(得分:3)

如果需要稳定的索引或指针,那么您的数据结构要求将开始类似于内存分配器。内存分配器也是一种特殊类型的数据结构,但面临着他们无法对内存进行混洗或重新分配的要求,因为这会使客户端存储的指针无效。所以我建议从经典的免费列表开始查看内存分配器实现。

免费列表

这是我编写的一个简单的C实现,用于向同事说明这个想法(不会烦恼线程同步):

typedef struct FreeList FreeList;

struct FreeList
{
    /// Stores a pointer to the first block in the free list.
    struct FlBlock* first_block;

    /// Stores a pointer to the first free chunk.
    struct FlNode* first_node;

    /// Stores the size of a chunk.
    int type_size;

    /// Stores the number of elements in a block.
    int block_num;
};

/// @return A free list allocator using the specified type and block size, 
/// both specified in bytes.
FreeList fl_create(int type_size, int block_size);

/// Destroys the free list allocator.
void fl_destroy(FreeList* fl);

/// @return A pointer to a newly allocated chunk.
void* fl_malloc(FreeList* fl);

/// Frees the specified chunk.
void fl_free(FreeList* fl, void* mem);

// Implementation:   
typedef struct FlNode FlNode;
typedef struct FlBlock FlBlock;
typedef long long FlAlignType;

struct FlNode
{
    // Stores a pointer to the next free chunk.
    FlNode* next;
};

struct FlBlock
{
    // Stores a pointer to the next block in the list.
    FlBlock* next;

    // Stores the memory for each chunk (variable-length struct).
    FlAlignType mem[1];
};

static void* mem_offset(void* ptr, int n)
{
    // Returns the memory address of the pointer offset by 'n' bytes.
    char* mem = ptr;
    return mem + n;
}

FreeList fl_create(int type_size, int block_size)
{
    // Initialize the free list.
    FreeList fl;
    fl.type_size = type_size >= sizeof(FlNode) ? type_size: sizeof(FlNode);
    fl.block_num = block_size / type_size;
    fl.first_node = 0;
    fl.first_block = 0;
    if (fl.block_num == 0)
        fl.block_num = 1;
    return fl;
}

void fl_destroy(FreeList* fl)
{
    // Free each block in the list, popping a block until the stack is empty.
    while (fl->first_block)
    {
        FlBlock* block = fl->first_block;
        fl->first_block = block->next;
        free(block);
    }
    fl->first_node = 0;
}

void* fl_malloc(FreeList* fl)
{
    // Common case: just pop free element and return.
    FlNode* node = fl->first_node;
    if (node)
    {
        void* mem = node;
        fl->first_node = node->next;
        return mem;
    }
    else
    {
        // Rare case when we're out of free elements.
        // Try to allocate a new block.
        const int block_header_size = sizeof(FlBlock) - sizeof(FlAlignType);
        const int block_size = block_header_size + fl->type_size*fl->block_num;
        FlBlock* new_block = malloc(block_size);

        if (new_block)
        {
            // If the allocation succeeded, initialize the block.
            int j = 0;
            new_block->next = fl->first_block;
            fl->first_block = new_block;

            // Push all but the first chunk in the block to the free list.
            for (j=1; j < fl->block_num; ++j)
            {
                FlNode* node = mem_offset(new_block->mem, j * fl->type_size);
                node->next = fl->first_node;
                fl->first_node = node;
            }

            // Return a pointer to the first chunk in the block.
            return new_block->mem;
        }

        // If we failed to allocate the new block, return null to indicate failure.
        return 0;
    }
}

void fl_free(FreeList* fl, void* mem)
{
    // Just push a free element to the stack.
    FlNode* node = mem;
    node->next = fl->first_node;
    fl->first_node = node;
}

随机访问序列,嵌套自由列表

了解了自由列表的想法,一个可能的解决方案就是:

enter image description here

这种类型的数据结构将为您提供不会失效的稳定指针,而不仅仅是索引。但是,如果要为其使用迭代器,它会增加随机访问和顺序访问的成本。它可以使用类似vector方法的内容与for_each进行顺序访问。

这个想法是使用上面的空闲列表的概念,除了每个块存储它自己的空闲列表,聚合块的外部数据结构存储一个空闲的块列表。只有当一个块完全填满时,它才会从空闲堆栈中弹出。

平行占用位

另一种方法是使用并行的位数组来指示数组的哪些部分被占用/空置。这里的好处是,您可以在顺序迭代期间检查是否一次占用多个索引(一次64位,此时您可以访问循环中的所有64个连续元素,而无需单独检查它们是否是占据)。当并非所有64个索引都被占用时,您可以使用FFS指令快速确定设置了哪些位。

您可以将其与空闲列表结合使用,然后使用这些位快速确定迭代期间占用的索引,同时快速插入和移除常量时间。

实际上你可以获得比std::vector更快的顺序访问,其中有一个索引/指针列表,因为我们可以同时检查64位以查看要在数据中遍历的元素结构,因为访问模式总是顺序的(类似于使用排序的索引列表到数组中)。

所有这些概念都围绕着在数组中留空空间以便在后续插入时回收,如果您不希望索引或指针无效到未被删除的元素,这将成为实际需求。容器。

单一链接索引列表

另一个解决方案是使用单链接列表,大多数人可能会认为这个列表涉及每个节点单独的堆分配和缓存在遍历上的错误,但并非必须如此。我们可以将节点连续存储在一个数组中并将它们链接在一起。如果您没有将链接列表视为容器,那么实际上就会开启一个优化机会的世界,而不仅仅是将存储在另一个容器(如数组)中的现有元素链接在一起,以允许不同的遍历和搜索模式。所有内容都存储在一个连续数组中的示例,其中索引将它们链接在一起:

enter image description here

数据存储方式如下:

struct Bucket
{
    struct Node
    {
         // Stores the element data.
         T some_data;

         // Points to either the next node in the bucket
         // or the next free node available if this node
         // has been removed.
         int next;
    };
    vector<Node> data;

    // Points to first node in the bucket.
    int head;

    // Points to first free node in the bucket.
    int free_head;
};

这不允许随机访问,如果从中间删除并经常插入,其空间局部性会降低。但是通过后处理副本来恢复它很容易。如果您只需要顺序访问并希望恒定时间删除和插入,它可能是合适的。如果您需要稳定的指针而不仅仅是索引,那么您可以将上述结构与嵌套的空闲列表一起使用。

如果你有很多非常动态的小列表(常量删除和插入),索引的SLL往往会很好。粒子连续存储的另一个例子,但32位索引链接仅用于将它们分割成网格以进行快速碰撞检测,同时允许粒子移动每一帧而只需更改几个整数以从一个粒子传输粒子网格单元格到另一个:

enter image description here

在这种情况下,你可以存储一个低于4兆字节的1000x1000网格 - 绝对可以存储一百万个std::liststd::vector的实例,并且当粒子移动时必须不断地移除和插入它们周围。

入住率指数

另一个简单的解决方案,如果你只需要稳定的索引,只需使用std::vectorstd::stack<int>个自由索引来回收/覆盖插入。这遵循了恒定时间删除的自由列表原则,但由于它需要内存来存储自由索引的堆栈,因此效率稍微低一些。免费列表使堆栈免费。

但是,除非你手动滚动并避免使用std::vector<T>,否则你无法非常有效地触发你在删除时存储的元素类型的析构函数(我没有&# 39;一直在跟上C ++,更多的是C程序员,但是可能有一种方法可以做到这一点仍然尊重你的元素析构函数而不用手动滚动你自己的std::vector - 也许是C ++专家可以参与其中)。如果您的类型是微不足道的POD类型,那可能没问题。

template <class T>
class ArrayWithHoles
{
private:
    std::vector<T> elements;
    std::stack<size_t> free_stack;

public:
    ...

    size_t insert(const T& element)
    {
        if (free_stack.empty())
        {
            elements.push_back(element);
            return elements.size() - 1;
        }
        else
        {
            const size_t index = free_stack.top();
            free_stack.pop();
            elements[index] = element;
            return index;
        }
    }

    void erase(size_t n)
    {
        free_stack.push(n);
    }
};

有这样的效果。这让我们陷入两难境地,因为我们无法告诉在迭代过程中从容器中删除了哪些元素以跳过。在这里,您可以再次使用并行位数组,也可以只存储有效索引列表。

如果这样做,有效索引列表会因内存访问模式而降级,因为它们会随着时间的推移而变得无效。一种快速修复方法是不时对索引进行基数排序,此时您已恢复顺序访问模式。

答案 2 :(得分:1)

如果您确实已经测量了缓存局部性为您带来的好处,那么我建议使用内存池方法:在最基本的层面上,如果您事先了解最大数量的元素,则可以创建三个向量,一个包含对象,一个包含活动对象指针,另一个包含自由对象指针。最初,空闲列表具有指向元素容器中所有对象的指针,然后项目在活动时变为活动状态,然后在删除时返回到空闲列表。

即使从各个容器中添加/删除指针,对象也永远不会更改位置,因此您的引用永远不会失效。

答案 3 :(得分:1)

要动态更改引用的矢量实体,请修改您的设计以在UserObject中存储索引而不是直接指针。这样你可以更改引用的向量,复制旧值,然后一切仍然有效。缓存方面,单个指针的索引可忽略不计,并且指令方式相同。

要处理删除,请忽略它们(如果您知道它们有固定数量)或维护一个免费的索引列表。添加项目时使用此空闲列表,然后仅在空闲列表为空时增加向量。

答案 4 :(得分:1)

我将重点介绍您的矢量需要可变大小的情况,例如:数据经常被插入并有时被清理干净。在这种情况下,使用向量中的虚拟数据或空洞几乎与使用堆数据一样“糟糕”,就像第一个解决方案一样。

如果您经常直接遍历所有数据,并且只使用少量随机“UsersObject”访问,则下面的内容可能是一个解决方案。它像其他人和你自己提出的那样使用一个间接级别,需要在每个删除/更新步骤上进行更新。这需要线性时间,绝对不是缓存最佳。此外,甚至更糟糕的是,没有锁定,这样的解决方案无法完成线程安全。

#include <vector>
#include <map>
#include <algorithm>
#include <iostream>
#include <mutex>

using namespace std;

typedef __int64 EntityId;

template<class Entity>
struct Manager {        
    vector<Entity>          m_entities; // Cache-friendly
    map<EntityId, size_t>   m_id_to_idx;
    mutex                   g_pages_mutex;
public:
    Manager() :
        m_entities(),
        m_id_to_idx(),
        m_remove_counter(0),
        g_pages_mutex()
    {}
    void update()
    {
        g_pages_mutex.lock();
        m_remove_counter = 0;
        // erase-remove_if idiom: remove all !alive entities

        for (vector<Entity>::iterator i = m_entities.begin(); i <  m_entities.end(); )
        {
            Entity &e = (*i);
            if (!e.m_alive)
            { 
                m_id_to_idx.erase(m_id_to_idx.find(e.m_id)); 
                i = m_entities.erase(i);
                m_remove_counter++;
                return true;
            } 
            else
            {
                m_id_to_idx[e.m_id] -= m_remove_counter;
                i++;
            }                    
        }
        g_pages_mutex.unlock();
    }
    Entity& getEntity(EntityId h)
    { 
        g_pages_mutex.lock();
        map<EntityId, size_t>::const_iterator it = m_id_to_idx.find(h);


        if (it != m_id_to_idx.end())
        {
            Entity& et =  m_entities[(*it).second];
            g_pages_mutex.unlock();
            return et;
        }
        else
        {
            g_pages_mutex.unlock();
            throw std::exception();
        }
    }
    EntityId inserEntity(const Entity& entity) 
    {
        g_pages_mutex.lock();
        size_t idx = m_entities.size();
        m_id_to_idx[entity.m_id]  = idx;
        m_entities.push_back(entity);
        g_pages_mutex.unlock();
        return entity.m_id;
    }
};

class Entity { 
    static EntityId  s_uniqeu_entity_id;
public:
    Entity (bool alive) :  m_id (s_uniqeu_entity_id++), m_alive(alive) {}
    Entity () :  m_id (s_uniqeu_entity_id++), m_alive(true) {}
    Entity (const Entity &in) : m_id(in.m_id), m_alive(in.m_alive) {}
    EntityId  m_id;
    bool m_alive; 
};

EntityId  Entity::s_uniqeu_entity_id = 0;

struct UserObject
{ 
    UserObject(bool alive, Manager<Entity>& manager) : 
        entity(manager.inserEntity(alive)) 
    {}
    EntityId entity; 
};

int main(int argc, char* argv[])
{
    Manager<Entity> manager;
    UserObject obj1(true, manager);
    UserObject obj2(false, manager);
    UserObject obj3(true, manager);
    cout << obj1.entity << "," << obj2.entity << "," << obj3.entity;
    manager.update();
    manager.getEntity(obj1.entity);
    manager.getEntity(obj3.entity);
    try
    {
        manager.getEntity(obj2.entity);
        return -1;
    }
    catch (std::exception ex)
    {
        // obj 2 should be invalid
    }
    return 0;
}

我不确定,如果您指定了足够的条件,为什么要解决具有这两个相互矛盾的假设的问题:拥有一个快速迭代的列表并且对该列表的元素有一个稳定的引用。这听起来像两个用例,它们也应该在数据级别上分开(例如,读取时复制,提交更改)。

答案 5 :(得分:0)

我脑子里有两种方式。第一种方法是从容器中擦除实体时更新句柄 http://www.codeproject.com/Articles/328365/Understanding-and-Implementing-Observer-Pattern-in ,第二个是使用键/值容器,如map / hash表,你的句柄必须包含键而不是索引

编辑:

第一个解决方案示例

class Manager:

class Entity { bool alive{true}; };
class EntityHandle 
{
public:
    EntityHandle(Manager *manager)
    {
        manager->subscribe(this);
        // need more code for index
    }

    ~EntityHandle(Manager *manager)
    {
        manager->unsubscribe(this);
    }

    void update(int removedIndex)
    {
        if(removedIndex < index)
        {
            --index;
        }
    }

    int index; 
};

class Manager {
    vector<Entity> entities; // Cache-friendly
    list<EntityHandle*> handles;

    bool needToRemove(const unique_ptr<Entity>& e)
    {
        bool result = !e->alive;

        if(result )
            for(auto handle: handles)
            {
                handle->update(e->index);
            }

        return result;
    }

    void update() 
    {
        entities.erase(remove_if(begin(entities), end(entities),
        needToRemove);
    }
    Entity& getEntity(EntityHandle h) { return entities[h.index]; }

    subscribe(EntityHandle *handle)
    {
        handles.push_back(handle);
    }

    unsubscribe(EntityHandle *handle)
    {
        // find and remove
    }

};

我希望这足以得到这个想法

答案 6 :(得分:0)

让我们回顾一下您的短语

  

缓存友好的线性内存。

'线性'的要求是什么?如果你真的有这样的要求那么请参考@seano和@Mark B的答案。如果你不关心线性记忆,那么我们就去吧。

std::mapstd::setstd::list提供对容器修改稳定(容忍)的迭代器 - 这意味着您可以保留迭代器而不是保留引用:

struct UserObject {
    // This reference may unexpectedly become invalid
    my_container_t::iterator entity;
};

关于std::list的特别说明 - 关于http://isocpp.org/的一些讲座Bjarne Stroustrup不建议使用链接列表,但对于您的情况,您可以确保EntityManager内将无法修改 - 所以参考适用于那里。

P.S。谷歌快速我没有找到unordered_map是否提供稳定的迭代器,所以我上面的列表可能不完整。

P.P.S发布后我回忆起有趣的数据结构 - 分块列表。线性数组的链接列表 - 因此您可以按链接顺序保持线性固定大小的块。