Skip to content

1.5 AoS vs SoA

Введение

Для начала стоит вообще разобраться что это такое, зачем оно надо и как это всё использовать. Поехали.

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

WARNING

Гибрид реализован через unsafe просто по причине, что C# это всё таки не С++ и нельзя просто так взять и лейаутить массивы пользовательских структур данных.

SoA

SoA, или дословно Struct of Arrays - подход, в котором каждый элемент структуры хранится в виде массива. К такому подходу относятся все спарс-сетные ECS, а так же те архетипные ECS, в которых в рамках архетипа компоненты хранятся в отдельных массивах.

Условно говоря выглядит это так:

csharp
struct Component1 {
    public int Value;
    public int Value1; // для толстости
    public int Value2; // для толстости
    public int Value3; // для толстости
}

struct Component2 {
    public int Value;
    public int Value1; // для толстости
    public int Value2; // для толстости
    public int Value3; // для толстости
}

struct Component3 {
    public int Value;
    public int Value1; // для толстости
    public int Value2; // для толстости
    public int Value3; // для толстости
}

public struct Data {
    private Component1[] comp1;
    private Component2[] comp2;
    private Component3[] comp3;
    // ...

    // тут еще может быть какой-то код обрабатывающий это всё.
}

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

Из минусов стоит отметить:

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

Код итерации по таким данным будет иметь следующий вид:

csharp
void ForEach() {
    var n = Count;
    for (var i = 0; i < n; i++) {
        data[i].c1.Value++; // или любая другая полезная нагрузка
    }
}

Тоже просто и понятно, к скорости такого кода вернемся потом, в целом очень даже неплохо.

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

csharp
void ForEach2() {
    var n = Count;
    for (var i = 0; i < n; i++) {
        data[i].c1.Value++; // или любая другая полезная нагрузка
        data[i].c2.Value++; // или любая другая полезная нагрузка
    }
}

void ForEach3() {
    var n = Count;
    for (var i = 0; i < n; i++) {
        data[i].c1.Value++; // или любая другая полезная нагрузка
        data[i].c2.Value++; // или любая другая полезная нагрузка
        data[i].c3.Value++; // или любая другая полезная нагрузка
    }
}

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

AoS

Всё то же самое, что и SoA, но наоборот. Даже ближе к классическому ООП - имеем 1 массив элементами которого являются структуры:

csharp
struct Component1 {
    public int Value;
    public int Value1; // для толстости
    public int Value2; // для толстости
    public int Value3; // для толстости
}

struct Component2 {
    public int Value;
    public int Value1; // для толстости
    public int Value2; // для толстости
    public int Value3; // для толстости
}

struct Component3 {
    public int Value;
    public int Value1; // для толстости
    public int Value2; // для толстости
    public int Value3; // для толстости
}

public struct Data {
    struct Block {
        public Component1 c1;
        public Component2 c2;
        public Component3 c3;
        // ...
    }

    private Block[] data;

    // тут еще может быть какой-то код обрабатывающий это всё.
}

Из минусов стоит отметить:

  • Писать руками удобно, но написать массив динамических кортежей или лейаутинг блока будет сложно - это будет либо дженерик ад, либо кодогенерация, либо unmanaged код с реинтерпретацией участков памяти. Основная проблема в том, что нужны поля.
  • При итерации по такому массиву (особенно когда там много толстых компонентов) будет на порядок медленнее, чем в SoA версии просто потому, что при каждом обращении по индексу в кэш будет улетать вся пачка структур.

Итерация по такому массиву будет иметь следующий вид:

csharp
void ForEach1() {
    var n = Count;
    for (var i = 0; i < n; i++) {
        data[i].c1.Value++; // или любая другая полезная нагрузка
    }
}

void ForEach2() {
    var n = Count;
    for (var i = 0; i < n; i++) {
        data[i].c1.Value++; // или любая другая полезная нагрузка
        data[i].c2.Value++; // или любая другая полезная нагрузка
    }
}

void ForEach3() {
    var n = Count;
    for (var i = 0; i < n; i++) {
        data[i].c1.Value++; // или любая другая полезная нагрузка
        data[i].c2.Value++; // или любая другая полезная нагрузка
        data[i].c3.Value++; // или любая другая полезная нагрузка
    }
}

В целом тоже довольно удобно, не считая выше перечисленных минусов.

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

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

Hybrid

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

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

В С++ это будет шикарно работать с векторами, но в C# придется повозиться. Из-за особенностей языка у разработчика нет простых инструментов контроллировать лейаутинг элементов в памяти.

Я специально не провожу эксперименты на int'ах, с которыми можно пошаманить, именно для наглядности проблем.

По сути это SoA внутри AoS чанками по N элементов.

csharp
public struct Component1 {
    public const int Size = sizeof(int) * 4; // уже появился доп код в виде констант, что увеличивает когнитивную сложность кода + возможность допустить ошибку
    public int Value;
    public int Value1; // для толстости
    public int Value2; // для толстости
    public int Value3; // для толстости
}

public struct Component2 {
    public const int Size = sizeof(int) * 4;
    public int Value;
    public int Value1; // для толстости
    public int Value2; // для толстости
    public int Value3; // для толстости
}

public struct Component3 {
    public const int Size = sizeof(int) * 4;
    public int Value;
    public int Value1; // для толстости
    public int Value2; // для толстости
    public int Value3; // для толстости
}

// ...

private const int BlockSize = 32; // аналогично с константами в компонентах. Эта, в частности, управляет размером блока

// вот эта вся разметка, это главный бич С# - очень хотелось бы юнионы и возможность объявлять поля-массивы не только примитивов
[StructLayout(LayoutKind.Explicit, Size = (Component1.Size + Component2.Size + Component3.Size /* ... */) * BlockSize)]
public struct Block {
    [FieldOffset(0)]
    public Component1 с1;
    [FieldOffset(Component1.Size * BlockSize)] // каждое поле объявляется как нулевой элемент + подразумевается что за ним идет "хвост" из еще 32 таких же
    public Component2 c2;
    [FieldOffset((Component1.Size + Component2.Size) * BlockSize)]
    public Component3 c3;
    // ...
}

public struct Data {
    private Block[] blocks;

    // тут еще может быть какой-то код обрабатывающий это всё.
}

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

Минусы тоже у него есть:

  • когнитивная сложность: читать такой код и дебажить становится на порядок сложнее
  • менеджмент и структурные изменения требуют большего количества кода, а соответственно, операций
  • усложняется код итерации

Итерация не отстает, и выглядеть будет примерно вот так:

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

csharp
unsafe void ForEach1()
{
    var n = Count / BlockSize;
    var i = 0;
    for (; i < n; i++)
    {
        ref var block = ref blocks[i];
        Iterate(ref block, BlockSize);
    }

    var v = Count % BlockSize;
    ref var lastBlock = ref blocks[i];
    Iterate(ref lastBlock, v);
    return;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    unsafe void Iterate(ref Block block, in int n) {
        fixed (Component1* p1 = &block.c1) {
            var rc1 = p1;
            for (var j = 0; j < n; j++) {
                rc1->Value++; // или любая другая полезная нагрузка
                rc1++;
            }
        }
    }
}

unsafe void ForEach2()
{
    var n = Count / BlockSize;
    var i = 0;
    for (; i < n; i++)
    {
        ref var block = ref blocks[i];
        Iterate(ref block, BlockSize);
    }

    var v = Count % BlockSize;
    ref var lastBlock = ref blocks[i];
    Iterate(ref lastBlock, v);
    return;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    unsafe void Iterate(ref Block block, in int n)
    {
        fixed (Component1* p1 = &block.c1)
        fixed (Component2* p2 = &block.c2)
        {
            var rc1 = p1;
            var rc2 = p2;
            for (var j = 0; j < n; j++) {
                rc1->Value++; // или любая другая полезная нагрузка
                rc2->Value++; // или любая другая полезная нагрузка
                rc1++;
                rc2++;
            }
        }
    }
}

unsafe void ForEach3() {
    var n = Count / BlockSize;
    var i = 0;
    for (; i < n; i++)
    {
        ref var block = ref blocks[i];
        Iterate(ref block, BlockSize);
    }

    var v = Count % BlockSize;
    ref var lastBlock = ref blocks[i];
    Iterate(ref lastBlock, v);
    return;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    unsafe void Iterate(ref Block block, in int n)
    {
        fixed (Component1* p1 = &block.c1)
        fixed (Component2* p2 = &block.c2)
        fixed (Component3* p3 = &block.c3)
        {
            var rc1 = p1;
            var rc2 = p2;
            for (var j = 0; j < n; j++) {
                rc1->Value++; // или любая другая полезная нагрузка
                rc2->Value++; // или любая другая полезная нагрузка
                rc3->Value++; // или любая другая полезная нагрузка
                rc1++;
                rc2++;
                rc3++;
            }
        }
    }
}

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

Его всегда можно реструктурировать и дженерализировать, или даже отдать на откуп кодогенерации. It's up to you.

Так что, всё таки, использовать?

Я был бы не я, если бы не померял, что из этого быстрее.

Замеры производились на M1Max, во всех компонентах по 4 интовых поля для создания нагрузки на prefetcher.

Step[N] по это итерация по 1, 2, 3 и т.д. компонентам соответственно, в рамках одного прохода.

Количество элементов 1kk (один миллионов).

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

| Type   | Method | Count    | Mean         | Error     | StdDev    |
|------- |------- |--------- |-------------:|----------:|----------:|
| AoS    | Step1  | 10000000 |    35.162 ms | 0.2250 ms | 0.1488 ms |
| AoS    | Step2  | 10000000 |    31.674 ms | 0.6115 ms | 0.4045 ms |
| AoS    | Step3  | 10000000 |    33.846 ms | 0.5115 ms | 0.3044 ms |
| AoS    | Step4  | 10000000 |    35.002 ms | 0.3747 ms | 0.2478 ms |
| AoS    | Step5  | 10000000 |    37.280 ms | 0.4044 ms | 0.2675 ms |
| AoS    | Step6  | 10000000 |    37.641 ms | 0.4709 ms | 0.3115 ms |
| AoS    | Step7  | 10000000 |    39.376 ms | 0.2620 ms | 0.1733 ms |
| AoS    | Step8  | 10000000 |    39.791 ms | 0.2997 ms | 0.1567 ms |
| AoS    | Step9  | 10000000 |    40.939 ms | 0.6126 ms | 0.4052 ms |
| AoS    | Step10 | 10000000 |    42.324 ms | 0.8813 ms | 0.5245 ms |
| AoS    | Step11 | 10000000 |    42.589 ms | 0.4914 ms | 0.2924 ms |
| AoS    | Step12 | 10000000 |    44.138 ms | 0.5851 ms | 0.3870 ms |
| AoS    | Step13 | 10000000 |    45.769 ms | 0.6103 ms | 0.4037 ms |
| AoS    | Step14 | 10000000 |    47.140 ms | 0.8175 ms | 0.5407 ms |
| AoS    | Step15 | 10000000 |    46.995 ms | 0.2223 ms | 0.1470 ms |
| AoS    | Step16 | 10000000 |    48.818 ms | 0.3407 ms | 0.2028 ms |
| AoS    | Step17 | 10000000 |    49.678 ms | 0.3939 ms | 0.2606 ms |
| AoS    | Step18 | 10000000 |    52.187 ms | 0.2884 ms | 0.1908 ms |
| AoS    | Step19 | 10000000 |    53.463 ms | 0.1817 ms | 0.1202 ms |
| AoS    | Step20 | 10000000 |    54.774 ms | 0.2482 ms | 0.1298 ms |

| Type   | Method | Count    | Mean         | Error     | StdDev    |
|------- |------- |--------- |-------------:|----------:|----------:|
| SoA    | Step1  | 10000000 |     6.267 ms | 0.0414 ms | 0.0274 ms |
| SoA    | Step2  | 10000000 |    11.904 ms | 0.0133 ms | 0.0070 ms |
| SoA    | Step3  | 10000000 |    19.064 ms | 0.1273 ms | 0.0842 ms |
| SoA    | Step4  | 10000000 |    30.275 ms | 0.0571 ms | 0.0340 ms |
| SoA    | Step5  | 10000000 |   238.250 ms | 0.2649 ms | 0.1752 ms |
| SoA    | Step6  | 10000000 |   424.348 ms | 0.2432 ms | 0.1272 ms |
| SoA    | Step7  | 10000000 |   622.912 ms | 1.0963 ms | 0.6524 ms |
| SoA    | Step8  | 10000000 |   828.956 ms | 0.5696 ms | 0.3768 ms |
| SoA    | Step9  | 10000000 |   877.664 ms | 2.9837 ms | 1.9735 ms |
| SoA    | Step10 | 10000000 | 1,145.340 ms | 1.3835 ms | 0.9151 ms |
| SoA    | Step11 | 10000000 | 1,202.752 ms | 3.9214 ms | 2.5938 ms |
| SoA    | Step12 | 10000000 | 1,141.439 ms | 2.9407 ms | 1.9451 ms |
| SoA    | Step13 | 10000000 | 1,520.562 ms | 1.6564 ms | 1.0956 ms |
| SoA    | Step14 | 10000000 | 1,761.528 ms | 1.5312 ms | 1.0128 ms |
| SoA    | Step15 | 10000000 | 1,855.929 ms | 1.7290 ms | 1.1436 ms |
| SoA    | Step16 | 10000000 | 1,830.698 ms | 1.3030 ms | 0.8619 ms |
| SoA    | Step17 | 10000000 | 1,679.585 ms | 1.3195 ms | 0.7852 ms |
| SoA    | Step18 | 10000000 | 1,619.472 ms | 1.6495 ms | 1.0910 ms |
| SoA    | Step19 | 10000000 | 1,973.583 ms | 8.6983 ms | 5.1762 ms |
| SoA    | Step20 | 10000000 | 2,293.830 ms | 1.6763 ms | 0.9975 ms |
| Type   | Method | Count    | Mean         | Error     | StdDev    |
|------- |------- |--------- |-------------:|----------:|----------:|
| Hybrid | Step1  | 10000000 |    30.764 ms | 0.3450 ms | 0.1805 ms |
| Hybrid | Step2  | 10000000 |    40.182 ms | 0.3125 ms | 0.2067 ms |
| Hybrid | Step3  | 10000000 |    50.265 ms | 0.1783 ms | 0.1061 ms |
| Hybrid | Step4  | 10000000 |    51.241 ms | 0.2616 ms | 0.1730 ms |
| Hybrid | Step5  | 10000000 |    76.930 ms | 0.9220 ms | 0.6099 ms |
| Hybrid | Step6  | 10000000 |    83.634 ms | 0.2978 ms | 0.1557 ms |
| Hybrid | Step7  | 10000000 |    89.111 ms | 0.7560 ms | 0.4499 ms |
| Hybrid | Step8  | 10000000 |    93.006 ms | 0.5081 ms | 0.3361 ms |
| Hybrid | Step9  | 10000000 |   101.911 ms | 1.5217 ms | 1.0065 ms |
| Hybrid | Step10 | 10000000 |   108.056 ms | 2.6164 ms | 1.7306 ms |
| Hybrid | Step11 | 10000000 |   119.447 ms | 1.8171 ms | 1.0813 ms |
| Hybrid | Step12 | 10000000 |   116.118 ms | 6.6427 ms | 4.3938 ms |
| Hybrid | Step13 | 10000000 |   109.516 ms | 1.0092 ms | 0.6005 ms |
| Hybrid | Step14 | 10000000 |   121.404 ms | 1.8718 ms | 1.2381 ms |
| Hybrid | Step15 | 10000000 |   117.430 ms | 6.6916 ms | 3.9821 ms |
| Hybrid | Step16 | 10000000 |   120.022 ms | 4.9221 ms | 3.2556 ms |
| Hybrid | Step17 | 10000000 |    91.136 ms | 0.6531 ms | 0.4320 ms |
| Hybrid | Step18 | 10000000 |    85.625 ms | 0.4673 ms | 0.3091 ms |
| Hybrid | Step19 | 10000000 |    96.137 ms | 3.2057 ms | 2.1204 ms |
| Hybrid | Step20 | 10000000 |    98.116 ms | 1.9292 ms | 1.2760 ms |

Код можно посмотреть тут.

И так, выводы:

  • При итерации по до 5 компонентов AoS проигрывает. Это обусловлено тем, что в случае SoA prefetcher затягивает в кэш больше валидных данных. Гибрид проигрывает еще больше из-за накладных расходов на итерации.
  • При итерации по 5+ компонентам AoS и гибрид выигрывают. Это обусловлено тем, что в случае SoA prefetcher затягивает в кэш новые данные затирая предыдущие (которые були бы актуальны на следующей итерации). С гибридом - аналогично.
  • Кривая деградация производительности в случае AoS плавнее, даже при том, что SoA обгоняет на относительно "простых" итерациях. В перспективе (или в "среднем" случае) AoS будет эффективнее при итерациях (!).
  • Цифры гибрида довольно сильно скачут, причем имеют тенденцию к уменьшению при увеличении сложности операций - возможно я просто криворукий.

Если в двух словах: Если система оперирует одним-двумя компонентами, то эффективнее использовать SoA, если 5+ - то AoS. При этом выигрыш хоть и составляет почти 50%, в выражении времени выполнения им можно пожертвовать в целях поддержания единообразности подхода. Гибрид имеет смысл использовать, если у вас C++ и такие конструкции можно реализовать нативно (ну или если у вас руки прямее чем у меня).

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

Выводы

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

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

ECS дисклеймер

В контексте ECS ценой быстрой итерации будет высокая цена структурных изменений. При "толстых" сущностях (читай - наборах данных) добавление или удаление одного компонента даже в случае использования Remove and Swap Back, о котором я писал ранее, копирование будет довольно ресурсоёмким и будет по скорости уступать sparse-set'ным решениям.

Использовать SoA обработку имеет смысл, ИМХО, в ситуациях, когда составныые части архетипа не связаны друг с другом или связаны косвенно минимальным количеством пересечений в рамках логики обработки.