Понимание дружественных к кэшу, ориентированных на данные объектов и дескрипторов

using namespace std;

Рассмотрим традиционный ООП-подход к управлению сущностями/объектами:

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 теперь будут содержать неправильный индекс, так как все было сдвинуто. Пример:


Описатель указывает на index: 2

Диаграмма 1


Объект A удаляется во время update()

Теперь дескриптор указывает на неправильную сущность.

Диаграмма 2


Как обычно решают эту проблему?

Обновлены ли индексы дескрипторов?

Заменяется ли мертвая сущность заполнителем?


Чтобы уточнить:

Это и это примеры того, что я имею в виду под дизайном, дружественным к кэшу.

Кроме того, системы компонентов, такие как Artemis, утверждают, что имеют линейный дизайн, удобный для кэширования, и используют решения, аналогичные дескрипторам. Как они решают проблему, которую я описываю в этом вопросе?


person Vittorio Romeo    schedule 15.10.2013    source источник
comment
Вы понимаете, что дружественный кэш будет иметь эффект только в том случае, если вы повторяете список, не так ли?   -  person SJuan76    schedule 15.10.2013
comment
Похоже на проблему, похожую на управление динамическим выделением памяти. Как вы справляетесь с фрагментацией при распределении блоков фиксированного размера? Обновление более поздних индексов является дорогостоящим предлогом. Аналогичным решением является ведение списка бесплатных индексов.   -  person user2784234    schedule 17.10.2013
comment
Некоторым из вашего кода потребуется акцент на кеширование индексов и дескрипторов - например. поиск столкновений/взаимодействий; другой код потребует согласованности отдельных объектов. Это не так ясно, как видно из двух размещенных вами ссылок: если вы не рассматриваете исключительно одну подсистему, которая сама по себе не работает. Попробуйте взглянуть на более широкую картину данных, например, иногда помогает переполнение индексов: если большинству обращений требуется только id, (x, y, ptr, id) избавляет большинство запросов от необходимости дерефирования ptr для его получения, но может привести к увеличению количества страниц. ошибки повторяют очень большой индекс.   -  person kfsone    schedule 21.10.2013


Ответы (7)


Insomniac сделал отличный powerpoint, их решение было примерно таким

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

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

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

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

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

Я надеюсь, что этот пример ясно демонстрирует эту идею.

person Bozemoto    schedule 18.10.2013
comment
Это здорово, если вы знаете, что ваши элементы никогда не будут больше размера SIZE. - person themean; 19.10.2013
comment
Вероятно, вы можете заменить массив вектором и при увеличении размера вектора добавить число к индексам. Как и этот if(back == entities.size()) { entities.push_back(Entity()); indices.push_back(indices.size()); }, вероятно, есть множество лучших способов оптимизировать это. - person Bozemoto; 19.10.2013
comment
У кого-нибудь есть ссылка на презентацию бессонницы? - person Yosef O; 25.11.2015
comment
@YosefO Может быть, этот? d3cw3dd2w32x2b.cloudfront.net/wp-content/ загрузки/2011/06/ - person Joshua Brookover; 04.12.2015

Если вам нужны стабильные индексы или указатели, то ваши требования к структуре данных начинают напоминать требования к распределителю памяти. Распределители памяти также являются особым типом структуры данных, но сталкиваются с требованием, что они не могут перетасовывать или перераспределять память, поскольку это сделало бы недействительными указатели, сохраненные клиентом. Так что я рекомендую посмотреть на реализации распределителя памяти, начиная с классического свободного списка.

Бесплатный список

Вот простая реализация 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;
}

Последовательность с произвольным доступом, вложенные списки свободных мест

Когда идея бесплатного списка понята, одним из возможных решений является следующее:

введите здесь описание изображения

Этот тип структуры данных даст вам стабильные указатели, которые не делают недействительными, а не только индексы. Однако это увеличивает стоимость произвольного доступа, а также последовательного доступа, если вы хотите использовать для него итератор. Он может выполнять последовательный доступ наравне с vector, используя что-то вроде метода for_each.

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

Биты параллельной занятости

Другой способ — использовать параллельный массив битов, чтобы указать, какие части массива заняты/свободны. Преимущество здесь в том, что вы можете во время последовательной итерации проверить, занято ли сразу много индексов (64 бита одновременно, и в этот момент вы можете получить доступ ко всем 64 непрерывным элементам в цикле без индивидуальной проверки, чтобы увидеть, заняты ли они). заняты). Если не все 64 индекса заняты, вы можете использовать инструкции FFS, чтобы быстро определить, какие биты установлены. .

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

На самом деле вы можете получить более быстрый последовательный доступ, чем std::vector, со списком индексов/указателей на стороне, поскольку, опять же, мы можем делать такие вещи, как проверка 64-бит сразу, чтобы увидеть, какие элементы нужно пройти внутри структуры данных, и потому что шаблон доступа всегда будет последовательным (аналогично использованию отсортированного списка индексов в массиве).

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

Список односвязных индексов

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

введите здесь описание изображения

С данными, хранящимися следующим образом:

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-битные индексные ссылки используются только для разделения их на сетку для быстрого обнаружения столкновений, позволяя частицам перемещаться в каждом отдельном кадре, и требуется изменить только пару целых чисел, чтобы перенести частицу из одного ячейку сетки в другую:

введите здесь описание изображения

В этом случае вы можете хранить сетку 1000x1000 менее чем в 4 мегабайтах — это определенно лучше, чем хранить миллион экземпляров std::list или std::vector и постоянно удалять и вставлять из/в них по мере движения частиц.

Индексы занятости

Другое простое решение, если вам нужны только стабильные индексы, — это просто использовать, скажем, std::vector с std::stack<int> свободными индексами для восстановления/перезаписи при вставках. Это соответствует принципу свободного списка удаления за постоянное время, но немного менее эффективно, поскольку требует памяти для хранения стека свободных индексов. Бесплатный список делает стек бесплатным.

Однако, если вы не перевернете его вручную и не будете использовать только std::vector<T>, вы не сможете очень эффективно заставить его запускать деструктор типа элемента, который вы сохраняете при удалении (я не следил за 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);
    }
};

Что-то в этом роде. Это оставляет нас перед дилеммой, поскольку мы не можем сказать, какие элементы были удалены из контейнера, чтобы пропустить их во время итерации. Здесь вы снова можете использовать параллельные битовые массивы или просто хранить список допустимых индексов сбоку.

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

person Community    schedule 21.12.2017

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

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

person Mark B    schedule 15.10.2013
comment
Вы имеете в виду выделение буфера (что-то вроде char[]) в стеке и использование в нем placement new? - person Vittorio Romeo; 15.10.2013

Чтобы изменить ссылочные векторные объекты на лету, измените свой проект так, чтобы индексы сохранялись в UserObject вместо прямых указателей. Таким образом, вы можете изменить указанный вектор, скопировать старые значения, и тогда все будет работать. С точки зрения кеша индексы одного указателя незначительны, а с точки зрения инструкций - то же самое.

Чтобы иметь дело с удалениями, либо игнорируйте их (если вы знаете, что их фиксированное количество), либо поддерживайте свободный список индексов. Используйте этот свободный список при добавлении элементов, а затем увеличивайте вектор только тогда, когда свободный список пуст.

person seano    schedule 17.10.2013

Я сосредоточусь на случае, когда вам требуется переменный размер для вашего вектора, например. данные часто вставляются и иногда очищаются. В этом случае использование фиктивных данных или дыр в вашем векторе почти так же «плохо», как использование данных кучи, таких как ваше первое решение.

Если вы часто выполняете итерацию непосредственно по всем данным и используете только несколько случайных доступов к «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;
}

Я не уверен, если вы указали достаточно побочных условий, почему вы хотите решить свою проблему, имея два противоречащих друг другу предположения: иметь список с быстрой итерацией и иметь стабильную ссылку на элементы этого списка. Для меня это звучит как два варианта использования, которые должны быть разделены также на уровне данных (например, копирование при чтении, фиксация изменений обратно).

person Uli Klank    schedule 21.10.2013

Давайте рассмотрим вашу фразу

кэш-дружественная линейная память.

Каково требование для «линейного»? Если у вас действительно есть такое требование, обратитесь к ответам @seano и @Mark B. Если вас не волнует линейная память, то поехали.

std::map, std::set, std::list предоставляют итераторы, которые стабильны (толерантны) к модификации контейнера - это означает, что вместо сохранения ссылки вы можете сохранить итератор:

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

Особые примечания к std::list — в одной из лекций на http://isocpp.org/ Бьярн Страуструп не рекомендовал использовать связанный список, но для вашего случая вы можете быть уверены, что Entity внутри Manager будет защищено от модификаций - так что здесь применима ссылка.

P.S. Быстро погуглив, я не нашел, предоставляют ли unordered_map стабильные итераторы, поэтому мой список выше может быть неполным.

P.P.S. После публикации вспоминаю интересную структуру данных - чанк-лист. Связанный список линейных массивов - так что вы сохраняете линейные фрагменты фиксированного размера в связанном порядке.

person Dewfy    schedule 18.10.2013
comment
Под удобной для кэширования линейной памятью я подразумеваю нединамическое распределение объектов, подобное массиву, которое обеспечивает чрезвычайно быструю итерацию благодаря удобству использования кэша. Обратитесь к: gamesfromwithin.com/data-Oriented-Design - person Vittorio Romeo; 18.10.2013
comment
@VittorioRomeo на самом деле мой PPS описывает фрагментированный список, который позволяет почти чрезвычайно быструю итерацию, как это делает линейный массив. - person Dewfy; 18.10.2013
comment
@Dewfy Но это почти то, что имеет тенденцию наносить ущерб операциям, ориентированным на данные, таким как передача миллиона полигонов на графический процессор. - person kfsone; 20.10.2013

У меня на уме два пути. Первый способ — обновить дескрипторы при удалении сущности из контейнера http://www.codeproject.com/Articles/328365/Understanding-and-Implementing-Observer-Pattern-in, во-вторых, использовать контейнер ключ/значение, такой как карта/хеш-таблица, и ваш дескриптор должен содержать ключ вместо показатель

редактировать:

первый пример решения

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
    }

};

надеюсь этого достаточно для понимания

person themean    schedule 18.10.2013
comment
Можно поподробнее о первом решении? Второй звучит неэффективно. - person Vittorio Romeo; 18.10.2013
comment
Второе решение достаточно эффективно в большинстве случаев. Если у вас есть более 100 усредненных сущностей, вы должны выбрать хеш-таблицу вместо карты, которая обеспечивает постоянный вес. Однако первое решение - это что-то простое. Вы должны реализовать шаблон наблюдателя (я предоставляю ссылку). Ваши дескрипторы должны быть наблюдателями, которые подписаны на вашего менеджера - person themean; 19.10.2013