Skip to content

1.4 Укомпакчиваем компоненты

Суть проблемы

Как я уже писал вот тут - наивно использовать EntityId как индекс в хранилищах компонентов не очень эффективно, хоть это и самый быстрый способ доступа (не считая ресайзов, конечно же). Неиспользуемая занятая память на практике с ростом количества типов сущностей будет стремиться к 100%, и я не первый кто об этом задумался.

Самым очевидным решением было бы хранить компоненты в словаре: Dictionary<entityId(int), data(TComponent)>. Просто - да, O(1) - да, эффективно - нет, компоненты будут раскиданы по куче и будет очень много "скаканий по куче" и cache miss'ов. Такое себе.

Ожидаемый результат

Тут стоит, наверное, описать к какому решению стоит стремиться и что в итоге мы хотим получить.

И так, ожидаемый результат:

  1. Доступ к элементам за О(1) как на чтение, так и на запись.
  2. Линейное расположение элементов в памяти.
  3. Минимальный оверхед на вставку \ удаление элементов.

Желаемый API:

Не забываем про CRUD

csharp
interface IStorage<T>
{
    int Count { get; }

    ReadOnlySpan<T> All();
    ReadOnlySpan<int> AllEntities();

    // [C]reate
    void Add(in int entityIid, in T value);

    // [R]ead
    bool Has(in int entityIid);

    // [R]ead/[U]pdate
    ref T Ref(in int entityIid);

    // [D]elete
    void Remove(in int entityIid);
}

Так же добавлены Count, All() и AllEntities() т.к. это довольно частая практика - итерироваться по существующим данным. Просто будем помнить что их надо прокинуть наружу.

Во всех моих примерах в качестве Entity Internal Identifier или entityIid используется int - в других фреймворках это может быть другой тип данных, это не принципиально.

Принципиально то, что тут не используется EntityId целиком и не реализуются проверки на принадлежность миру и то, жива сущность или нет - эти проверки производятся на верхнем уровне, данный элемент системы является т.н. hot spot или hot path - очень высоконагруженный элемент системы где даже самая безобидная проверка может очень ощутимо просадить производительность - быстрее всего работает код, которого нет.

В качестве отправной точки будем отталкиваться от базовой "дырявой" линейной реализации:

csharp
class Storage<T> : IStorage<T>
{
    private bool[]? has;
    private T[]? data;

    public int Count { get; private set; }
    public ReadOnlySpan<T> All() => throw new NotImplementedException();
    public ReadOnlySpan<int> AllEntities() => throw new NotImplementedException();

    public Storage() => Resize(32);
    
    public void Add(in int entityIid, in T value)
    {
        if (entityIid >= has!.Length) Resize((entityIid / 32 + 1) * 32);

        data![entityIid] = value;
        has![entityIid] = true;
        Count++;
    }

    public bool Has(in int entityIid) => entityIid < has!.Length && has[entityIid];

    public ref T Ref(in int entityIid) => ref data![entityIid];

    public void Remove(in int entityIid)
    {
        if (entityIid >= has!.Length) return;

        has[entityIid] = false;
        Count--;
    }

    private void Resize(in int size)
    {
        if (has == null)
        {
            has = new bool[size];
            data = new T[size];
            return;
        }

        var initialSize = has!.Length;
        if (initialSize >= size) return;
        
        Array.Resize(ref has, size);
        Array.Resize(ref data, size);
    }
}

Как можно заметить возможности реализовать All() и AllEntities() с текущим API эффективным способом нет, либо через IEnumerator либо аллоцировать новый массив, оба варианта не приемлемы, так что останавливаться на них, пока что, не будем.

В текущей реализации entityIid и является индексом в массиве data что очень удобно, но не эффективно - дырки всё таки.

Чтобы избавиться от дырок нужно сместить элементы массива влево, примерно вот так:

1

Компактно - да, что-то сломалось - тоже да: теперь entityIid не указывает на правильный элемент в массиве data. Простым математическим языком:

Было: ce = data[ie]

Стало: ce = data[iс] где iс = f(ie) ну или просто ce = data[f(ie)]

Что за f()? Откуда оно взялось?

f() - функция преобразования entityIid во внутренний индекс внутри массива data. Это то что проделали с элементами на анимации выше: взяли тот индекс, который равнялся entityIid и поменяли его на какой-то другой, причем такой, чтобы элементы плотненько лежали в массиве.

На самом деле выразить это через функцию возможным не представляется просто потому, что эти изменения зависят от пользовательского кода и не являются детерминированными, но можно выразить через маппинг храня пару entityIid -> index.

Тут можно использовать Dictionary!

Можно, но не будем т.к. есть вариант получше.

Sparse Set

Разжовано тут и тут так что сильно углубляться я не буду.

В двух словах - это такой хитрый маппинг добавлением, чтением, записиью и удалением в О(1) но с определенными ограничениями, а именно:

  • Может хранить только значения в диапазоне [0 : n), где n - максимальное число элементов.
  • Будет дырявым вместо нашего массива с данными (это, в принципе, приемлемо)

Как это выглядит схематически:

2

От дырок в хранилище компонентов мы избавились, компоненты лежат линейно, всё? - Нет, не всё.

Будем считать, что с тем как это всё складывается и взаимосвязано разобрались, посмотрим на код:

В общем случае код будет иметь следующий вид:

csharp
class Storage<T> : IStorage<T>
{
    private int[]? sparse;
    private int[]? dense;
    private T[]? data;

    public int Count { get; private set; }
    public ReadOnlySpan<T> All() => new(data, 0, Count);

    public Storage() => Resize(32, 32);

    public bool Has(in int entityIid) => sparse![entityIid] != -1;

    public ref T Ref(in int entityIid) => ref data![sparse![entityIid]];

    private void Resize(in int sparseSize, in int dataSize)
    {
        if (sparse == null)
        {
            sparse = new int[sparseSize];
            dense = new int[sparseSize];
            Array.Fill(dense, -1);
            Array.Fill(sparse, -1);
            data = new T[dataSize];
            return;
        }
        
        var initialSparseSize = sparse!.Length;
        if (initialSparseSize < sparseSize)
        {
            Array.Resize(ref sparse, sparseSize);
            Array.Resize(ref dense, sparseSize);
            Array.Fill(sparse, -1, initialSparseSize, sparseSize - initialSparseSize);
            Array.Fill(dense, -1, initialSparseSize, sparseSize - initialSparseSize);
        }

        var initialDataSize = data!.Length;
        if (initialDataSize < dataSize)
            Array.Resize(ref data, dataSize);
    }

    public ReadOnlySpan<int> AllEntities() => throw new NotImplementedException(); // <-- пока что игнорируем т.к. 
    public void Add(in int entityIid, in T value) => throw new NotImplementedException(); // <-- разберемся ниже
    public void Remove(in int entityIid) => throw new NotImplementedException(); // <-- разберемся ниже
}

Данный код учитывает, что компоненты уже лежат и лежат плотненько. Сигнатура метода AllEntities() под вопросом т.к. идентификаторы никогда не смогут лежать плотнячком в sparse. C добавлением и удалением будем разбираться.

У меня закрались сомнения об экономичности и эффективности такого подхода и я вооружился калькулятором и гугл таблицами.

Есть одна теория, и сейчас ее проверим

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

Ну и это надо проверить, определяем следующие значения и диапазоны:

  1. N: Общее число сущностей - пусть будет 1000.
  2. C: Размер компонента в байтах (sizeof, считаем что align = 8 и минимальный размер компонента тоже 8, шаг - тоже 8).
  3. U: Утилизаця [0:1] c шагом 0.1 (10%) - какой % от всех сущностей имеет данный компонент, т.е. имеет значение.

Для простого линейного хранилища количество потребляемой памяти расчитывается как (C + 1) * N. U не учитывается т.к. мы выделяем память потенциально под все возможные сущности.

На самом деле, раз уж мы упарываемся в оптимизации, для линейной реализации можно уменьшить потребление памяти используя битмаску и формула будет иметь вид C * N + ceil(N / 8) - т.е. 1 байт на каждые 8 сущностей.

Для хранилища со спарс сетом это будет sizeof(int) * 2 * N + C * N * U.

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

Горизонтальная - утилизация

SizeLINEAR0.10.20.30.40.50.60.70.80.91
88125880096001040011200120001280013600144001520016000
16161259600112001280014400160001760019200208002240024000
241412510400128001520017600200002240024800272002960032000
323212511200144001760020800240002720030400336003680040000
404012512000160002000024000280003200036000400004400048000
484812512800176002240027200320003680041600464005120056000
565612513600192002480030400360004160047200528005840064000
646412514400208002720033600400004640052800592006560072000
727212515200224002960036800440005120058400656007280080000
808012516000240003200040000480005600064000720008000088000
888812516800256003440043200520006080069600784008720096000
9696125176002720036800464005600065600752008480094400104000
1041041251840028800392004960060000704008080091200101600112000
1121121251920030400416005280064000752008640097600108800120000
12012012520000320004400056000680008000092000104000116000128000
12812812520800336004640059200720008480097600110400123200136000

Можно заметить, что использование спарс сетов с точки зрения потребления памяти не выгодно в двух случаях:

  • Очень маленькие компоненты.
  • Очень плотная утилизация.

C дроблением компонентов на спарс-сетных ECS злоупотреблять не стоит, просто потратите память в пустую ради мнимой оптимизации.

Добавление / Удаление

Методы добавления и удаления тесно связаны друг с другом, так что как всегда немножко теории.

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

Сразу оговорюсь, что идеальной стратегии нет - в каждой из них свои плюсы и минусы.

Списочный подход

Тут в целом всё просто:

csharp
public void Add(in int entityIid, in T value)
{
    // убедились, что влезаем
    Resize((entityIid / 32 + 1) * 32, data!.Length == Count + 1 ? data.Length + 32 : data.Length)

    sparse![entityIid] = Count; // проставили индексы
    dense![Count] = entityIid;
    data![Count] = value; // записали данные
    Count++; // инкрементровали счетчик
}
csharp
public void Remove(in int entityIid)
{
    var arrayIndex = sparse![entityIid];
    Array.Copy(data!, arrayIndex + 1, data!, arrayIndex, Count - arrayIndex - 1);

    for (var i = arrayIndex; i < Count - 1; i++)
    {
      sparse![dense![i + 1]] = i;
        dense![i] = dense![i + 1];
    }

    Count--;
    sparse![entityIid] = -1;
    dense![Count] = -1;
}

Посмотрим теперь как это всё выглядит схематически:

Добавление

3

Удаление

4

Если с Add() все более менее ок, то в Remove() имеем цикл - в худшем случае удалив первый добавленный компонент придется поправить все индексы, O(n) короче говоря - не соответствует нашим требованиям.

Лечится?

Да, лечится, поехали дальше.

Remove and Swap Back

Очень интересный механизм удаления элементов массива очаровывающий своей простотой.

Суть его заключается в том, что вместо того чтобы сдвигать все элементы справа влево удаленный элемент просто заменяется последним. Это позволяет намного быстрее удалять элементы по индексу за счет того, что копируется только 1 элемент. Как результат имеем удаление за константное время (не путать с константной сложностью).

Вот так это выглядит в коде:

Такие штуки очень удобно реализовывать как методы расширения.

csharp
    public static void RemoveAndSwapBack<T>(in T[] array, in int index, ref int count)
        => array[index] = array[--count];

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

Схематически это выглядит вот так:

5

Классно и красиво, но есть и негативные стороны:

  1. Нарушается порядок хранения элементов - в общем случае это не представляет большой проблемы, например, когда итерация происходит по одному типу компонента. Но как мы выше разбирались - обращение будет происходить по айди сущности смаппленому к индексу в массиве и это будет скорее случайный доступ по индексам что приведет к большому количеству cache misses (и скажется на производительности, соответственно). Вот хорошее видео по этой теме.

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

Но вернемся к реализациям и посмотрим как оно будет выглядеть в контехсте хранилища компонентов.

csharp
public void Remove(in int entityIid)
{
    var arrayIndex = sparse![entityIid];
    var lastEntityIid = dense![--Count]; 
    data![arrayIndex] = data![Count]; 
    dense![arrayIndex] = lastEntityIid; 
    sparse[lastEntityIid] = arrayIndex; 
    Array.Copy(data!, arrayIndex + 1, data!, arrayIndex, Count - arrayIndex - 1); 

    for (var i = arrayIndex; i < Count - 1; i++) 
    { 
      sparse![dense![i + 1]] = i; 
      dense![i] = dense![i + 1]; 
    } 

    Count--; 
    dense![Count] = -1; 
    sparse![entityIid] = -1;

}

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

Но кто я такой чтобы не померять это всё
TypeMethodCountMeanErrorStdDevMedian
RemoveAndSwapBackReverse1000041.68 us0.825 us1.209 us41.79 us
RemoveAndSwapBackLinear1000042.82 us0.851 us1.399 us42.83 us
RemoveAndSwapBackRandom1000045.59 us0.903 us1.075 us45.50 us
ListReverse1000061.86 us1.353 us3.725 us61.48 us
RemoveAndSwapBackReverse100000290.98 us5.730 us8.217 us286.89 us
RemoveAndSwapBackLinear100000295.25 us5.882 us7.002 us295.54 us
RemoveAndSwapBackRandom100000409.35 us7.702 us7.565 us405.67 us
ListReverse100000440.00 us8.429 us7.472 us437.75 us
RemoveAndSwapBackReverse250000687.89 us3.690 us3.081 us687.25 us
RemoveAndSwapBackLinear250000691.51 us3.748 us3.322 us691.50 us
ListReverse2500001,059.04 us16.636 us15.561 us1,057.12 us
RemoveAndSwapBackRandom2500001,064.65 us19.698 us36.512 us1,048.54 us
ListRandom1000028,061.13 us490.183 us434.534 us27,916.44 us
ListLinear1000057,214.52 us686.687 us642.328 us57,218.58 us
ListRandom1000002,898,043.15 us57,450.454 us72,656.430 us2,859,386.54 us
ListLinear1000005,824,036.29 us73,614.984 us61,471.846 us5,812,698.46 us
ListRandom25000017,908,060.10 us249,017.326 us232,930.961 us17,889,613.81 us
ListLinear25000036,972,363.97 us472,965.859 us442,412.558 us36,850,371.92 us

Ожидаемо, да, в разы быстрее ну и понятно, в принцпе почему. Но тут можно сделать еще ряд интересных наблюдений:

  • Random() быстрее Linear() в случае List, а самым быстрым оказался Reverse().

Это связано с тем, что в при линейной итерации от начала к концу количество и объем копированной памяти будет максимальным, т.е. мы всегда удаляем первый элемент и копируем весь "хвост". В случае с Random мы копируем в среднем половину всей длинны. А в случае Reverse - удалении последнего элемента мы копируем элемент сам в себя, только 1 (этот кейс можно обработать отдельно и просто декрементить Count).

  • Random() медленнее Linear() в случае RemoveAndSwapBack, а Reverse() быстрее.

Поскольку у нас операция атомарная и нет необходимости манипулировать "хвостом", то всё сводится к скорости доступа к элементам массива. При линейной итерации максимизируется утилизация prefetcher'а (почитать об этом можно вот тут. При случайном доступе чаще происходят промахи в кэше, что, собственно, и прослеживается в бенчмарке. Reverse() быстрее потому что нет необходимости прыгать между текущим индексом и последним, таким образом избегаются промахи в кэше.

Можно ли еще быстрее?

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

Как еще ускорить in-place версию я не знаю =)

Выводы

Sparse Sets - не идеальное решение, по крайней мере не для всех случаев.

Укомпакчивать in-place лучше через RemoveAndSwapBack.

Код бенчмарков можно найти тут, а код хранилищ тут.