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

Цикл "Принципы DOD в C++"

О чем речь?

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

Последствия оценить весьма просто — кэш-линия забита лишними данными, и на чтение данных структур, которые нужно обработать, уходит гораздо больше времени. И всё было бы хорошо, если нам нужна только пара-тройка объектов структуры. А если нам нужен миллион/миллиард таких структур, причем каждую долю секунды с ними что-то происходит? Возникает проблема с быстродействием программы из-за большого объема данных.

Как же это решить? Какие подходы к этому существуют? ��ассмотрим далее.

AoS — Array of Structures

Дадим краткое определение. Array of Structures — массив, где каждый элемент это структура со всеми полями. Стандартный подход, который все программисты на C++ пишут по умолчанию.

Сразу приведем пример, как это выглядит, в виде структуры, описывающей частицу в физическом движке:

struct Particle {
  float x, y, z; // Координаты по трем осям
  float vx, vy, vz; // Скорости по трем осям
  float ax, ay, az; // Ускорения по трем осям
};

std::vector<Particle> particles(N);

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

[x0 y0 z0 vx0 vy0 vz0 ax0 ay0 az0] [x1 y1 z1 vx1 vy1 vz1 ax1 ay1 az1] [x2 y2 z2 ...]

Каждый цикл обновления физического движка мы что-то делаем с этими структурами-частицами: обновляем координаты/скорости. И всё было бы хорошо, если бы мы всегда взаимодействовали со всеми полями структуры. Однако, в 99% случаев это не так: порой, нам нужно изменить скорости, никак не затрагивая позиции, и наоборот. Или вообще поменять значение только одного/двух полей из шестерых.

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

Приведем пример такого явления:

void ApplyGravity() {
  for (auto& particle : particles) {
    particle.ay = 9.81;
  }
}

Функция взаимодействует только с одними данными, ускорением частицы по вертикальной оси. Однако, в кэш вместо лишь 4 байт, которые составляет это поле, грузится целых 36 байт (размер структуры)! Разница в объеме данных — в 9 раз.

Как же решить данную проблему? Воспользуемся подходом разделения данных под названием hot/cold splitting.

Hot/cold splitting

Вкратце, hot/cold splitting — это идея разделения данных на основе частоты обращения, а не размера.

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

Общий вид магазина. Самые популярные товары на виду, под рукой, менее популярные уже подальше, отдельно
Общий вид магазина. Самые популярные товары на виду, под рукой, менее популярные уже подальше, отдельно

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

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

// "Горячие" данные — нужны каждый кадр
struct ParticleHot {
  float x, y, z;
  float vx, vy, vz;
};

// "Холодные" данные — нужны редко
struct ParticleCold {
  float ax, ay, az;
};

// Два параллельных ��ассива с общим индексом
std::vector<ParticleHot> particles_hot(N);
std::vector<ParticleCold> particles_cold(N);

// Горячий цикл — итерируем только hot-массив
for (size_t i = 0; i < N; ++i) {
  particles_hot[i].x += particles_hot[i].vx;
  particles_hot[i].y += particles_hot[i].vy;
  particles_hot[i].z += particles_hot[i].vz;
}

// Когда нужны холодные данные — обращаемся по тому же индексу
particles_cold[some_index].ay = -9.81f;

Поясню. ParticleHot — 24 байта. В одну кэш-линию влезает 2.66 структуры. Для сравнения, исходная Particle (36 байт) — 1.77 структуры на кэш-линию. Утилизация кэша в горячем цикле выросла, потому что мы больше не тащим ускорения, которые в данный момент не нужны.

Hot/cold splitting уже даёт ощутимый эффект. Но обратим внимание: мы разделили структуру на две группы — "горячую" и "холодную". А что, если пойти дальше? Что, если внутри "горячей" части нам всё равно не нужны все поля одно��ременно — например, мы обновляем только координаты, не трогая скорости? Тогда даже ParticleHot тащит в кэш лишние данные.

Если довести эту идею до предела и вынести каждое поле в свой отдельный массив, мы получим подход под названием Structure of Arrays (SoA).

SoA — Structure of Arrays

Обозначим определение. Structure of Arrays — это когда каждое поле хранится в своём отдельном массиве. Сразу приведем пример, чтобы было понятнее:

struct Particles {
  std::vector<float> x, y, z; // Координаты по трем осям
  std::vector<float> vx, vy, vz; // Скорости по трем осям
  std::vector<float> ax, ay, az; // Ускорения по трем осям
};

Хорошо, сделали. Теперь визуализируем расположение данных в памяти:

x: [x0 x1 x2 x3 x4 x5 x6 x7 ...]
y: [y0 y1 y2 y3 y4 y5 y6 y7 ...]
z: [z0 z1 z2 z3 z4 z5 z6 z7 ...]
vx: [vx0 vx1 vx2 vx3 vx4 vx5 vx6 vx7 ...]
и так далее

Заметили разницу? Теперь в функции ApplyGravity() для ay = 9.81 процессор читает только массив ay, не захватывая не нужные в данном контексте данные x, y, z, vx...

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

  • Сценарий 1: линейный обход, обновление одного поля;

  • Сценарий 2: линейный обход, обновление всех полей;

  • Сценарий 3: random access. Доступ к случайному элементу.

Benchmark, сценарий 1. Линейный обход, обновление одного поля

Создадим две структуры-частицы для сравнения AoS и SoA между собой, а также вспомогательную функцию Resize для выделения и заполнения векторов n элементами:

// AoS
struct ParticleAoS {
  float x, y, z;
  float vx, vy, vz;
  float ax, ay, az;
};

// SoA
struct ParticlesSoA {
  std::vector<float> x, y, z;
  std::vector<float> vx, vy, vz;
  std::vector<float> ax, ay, az;

  void Resize(int n) {
    x.resize(n, 0.0f); y.resize(n, 0.0f); z.resize(n, 0.0f);
    vx.resize(n, 1.0f); vy.resize(n, 1.0f); vz.resize(n, 1.0f);
    ax.resize(n, 0.0f); ay.resize(n, 0.0f); az.resize(n, 0.0f);
  }
};

Далее, напишем функции для бенчмарка первого сценария и запустим бенчмарк:

// Сценарий 1: линейный обход, обновление одного поля (x += vx)

static void BM_AoS_SingleField(benchmark::State& state) {
  const int n = state.range(0);
  std::vector<ParticleAoS> particles(n);

  for (auto& p : particles) {
    p.x = 0.0f;
    p.vx = 1.0f;
  }

  for (auto _ : state) {
    for (auto& p : particles) {
      p.x += p.vx;
    }
    benchmark::ClobberMemory();
  }
}

static void BM_SoA_SingleField(benchmark::State& state) {
  const int n = state.range(0);
  ParticlesSoA particles;
  particles.Resize(n);

  for (auto _ : state) {
    for (int i = 0; i < n; ++i) {
      particles.x[i] += particles.vx[i];
    }
    benchmark::ClobberMemory();
  }
}

BENCHMARK(BM_AoS_SingleField)->Range(10000, 1000000);
BENCHMARK(BM_SoA_SingleField)->Range(10000, 1000000);

BENCHMARK_MAIN();

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

Результаты первого сценария
Результаты первого сценария

Подведем итоги первого сценария:

  1. На всех тестируемых количествах частиц SoA быстрее, чем AoS;

  2. На количествах частиц до миллиона SoA быстрее AoS в среднем в 4 раза, в то время как на миллионе частиц SoA быстрее AoS уже в 12 раз.

Рассмотрим причины этих результатов:

  1. Утилизация кэша. ParticleAoS — 9 float'ов = 36 байт. При обновлении одного поля (x += vx) мы используем только 8 байт из 36. Остальные 28 байт — y, z, vy, vz, ax, ay, az — загрузились в кэш-линию, но не пригодились. Утилизация кэша: 8/36 ≈ 22%.
    В SoA массивы x и vx лежат отдельно и плотно. Каждый загруженный байт используется. Утилизация — 100%. В одну кэш-линию (64 байта) влезает 16 float'ов из массива x. В AoS — 1.77 структуры, из которых полезны только 2 float'а на структуру. Но разница в утилизации ~4.5 раза, а реальный разрыв ~12 раз. Откуда остальная прибавка к производительности? Причина кроется в так называемом SIMD.

  2. Автовекторизация (SIMD). Более подробно SIMD мы будем рассматривать в следующих статьях, сейчас же достаточно просто знать, что компилятор в Release-режиме умеет автоматически превращать простые циклы по плотным массивам в SIMD-инструкции, что оптимизирует и ускоряет операции с векторами. Цикл x[i] += vx[i] по непрерывному массиву float'ов — идеальный кандидат для SIMD, поскольку данные векторов лежат непрерывно. В AoS-варианте x и vx лежат с шагом 36 байт — компилятор не может векторизовать такой цикл, потому что данные не непрерывны.

Хорошо, первый сценарий завершен. Перейдем ко второму.

Benchmark, сценарий 2. Линейный обход, обновление всех полей

Оставим AoS и SoA структуры из первого сценария и используем во втором, а затем напишем сам код для симуляции сценария и запустим:

// Сценарий 2: линейный обход, обновление всех полей

static void BM_AoS_AllFields(benchmark::State& state) {
  const int n = state.range(0);
  std::vector<ParticleAoS> particles(n);

  for (auto& p : particles) {
    p.x = 0.0f; p.y = 0.0f; p.z = 0.0f;
    p.vx = 1.0f; p.vy = 1.0f; p.vz = 1.0f;
    p.ax = 0.1f; p.ay = -9.81f; p.az = 0.0f;
  }

  for (auto _ : state) {
    for (auto& p : particles) {
      p.vx += p.ax;
      p.vy += p.ay;
      p.vz += p.az;
      p.x += p.vx;
      p.y += p.vy;
      p.z += p.vz;
    }
    benchmark::ClobberMemory();
  }
}

static void BM_SoA_AllFields(benchmark::State& state) {
  const int n = state.range(0);
  ParticlesSoA particles;
  particles.Resize(n);

  for (auto _ : state) {
    for (int i = 0; i < n; ++i) {
      particles.vx[i] += particles.ax[i];
      particles.vy[i] += particles.ay[i];
      particles.vz[i] += particles.az[i];
      particles.x[i] += particles.vx[i];
      particles.y[i] += particles.vy[i];
      particles.z[i] += particles.vz[i];
    }
    benchmark::ClobberMemory();
  }
}

BENCHMARK(BM_AoS_AllFields)->Range(10000, 1000000);
BENCHMARK(BM_SoA_AllFields)->Range(10000, 1000000);

BENCHMARK_MAIN();

Построим график ровно как и в первом сценарии:

Результаты второго сценария
Результаты второго сценария

Результаты уже не такие очевидные. Снова сделаем выводы по получившимся значениям и по графикам:

  1. SoA все еще имеет преимущество (кроме момента кол-ва частиц 262144), но уже не такое выраженное.

  2. В среднем разница в производительности составляет ~6% против ~76% в первом сценарии, а на количестве частиц в 262144 вообще составляет 1%, что говорит о снижении эффективности преимуществ SoA по сравнению с AoS.

Проанализируем полученные результаты. Откуда взялось такое ухудшение производительности SoA подхода? Или всё же это, наоборот, увеличение быстродействия AoS?

  1. Утилизация кэша. Теперь AoS не проигрывает. В первом сценарии AoS тратил ~76% пропускной способности впустую. Теперь мы обращаемся ко всем 9 полям — используем все 36 байт каждой структуры. Утилизация кэша в AoS стала 100%. Преимущество SoA по кэшу исчезло. Более того, в AoS все 9 полей одной частицы лежат в пределах 36 байт — одна-две кэш-линии, один линейный поток данных. Prefetcher, который параллельно с вычислениями загружает из RAM данные, которые, по его предсказанию, понадобятся в ближайшем будущем, справляется идеально.
    В SoA теперь считывается и пишется 9 отдельных массивов в одном цикле. Это 9 независимых потоков данных. Prefetcher может отслеживать порядка 8-16 потоков — он на пределе. Это уже не идеальные условия для SoA.

  2. Автовекторизация (SIMD). SoA всё ещё имеет потенциал, но он ограничен. Компилятор всё ещё может векторизовать операции над плотными SoA-массивами. Но теперь в цикле 6 операций сложения с 9 массивами. Регистров процессора ограниченное количество, и компилятору сложнее построить эффективный SIMD-код, когда нужно одновременно работать с таким количеством потоков данных одновременно.

По итогу, получаем небольшое, в ~6%, преимущество SoA, иногда переходящее в паритет. Перейдем, наконец, к третьему сценарию, чтобы сделать окончательный вывод по эффективности SoA.

Benchmark, сценарий 3. Random access. Доступ к случайному элементу.

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

// Сценарий 3: random access, чтение всех полей

static void BM_AoS_RandomAccess(benchmark::State& state) {
  const int n = state.range(0);
  std::vector<ParticleAoS> particles(n);

  for (auto& p : particles) {
    p.x = 1.0f; p.y = 2.0f; p.z = 3.0f;
    p.vx = 1.0f; p.vy = 1.0f; p.vz = 1.0f;
    p.ax = 0.1f; p.ay = -9.81f; p.az = 0.0f;
  }

  std::vector<int> indices(n); // Создаем вектор индексов
  std::iota(indices.begin(), indices.end(), 0); // Заполняем вектор индексов
  std::mt19937 rng(42); // Создаем генератор псевдослучайных чисел
  std::shuffle(indices.begin(), indices.end(), rng); // Перемешиваем вектор индексов

  for (auto _ : state) {
    float sum = 0.0f;
    for (int idx : indices) {
      auto& p = particles[idx];
      sum += p.x + p.y + p.z + p.vx + p.vy + p.vz + p.ax + p.ay + p.az;
    }
    benchmark::DoNotOptimize(sum);
  }
}

static void BM_SoA_RandomAccess(benchmark::State& state) {
  const int n = state.range(0);
  ParticlesSoA particles;
  particles.Resize(n);

  std::vector<int> indices(n); // Создаем вектор индексов
  std::iota(indices.begin(), indices.end(), 0); // Заполняем вектор индексов
  std::mt19937 rng(42); // Создаем генератор псевдослучайных чисел
  std::shuffle(indices.begin(), indices.end(), rng); // Перемешиваем вектор индексов

  for (auto _ : state) {
    float sum = 0.0f;
    for (int idx : indices) {
      sum += particles.x[idx] + particles.y[idx] + particles.z[idx]
           + particles.vx[idx] + particles.vy[idx] + particles.vz[idx]
           + particles.ax[idx] + particles.ay[idx] + particles.az[idx];
    }
    benchmark::DoNotOptimize(sum);
  }
}

BENCHMARK(BM_AoS_RandomAccess)->Range(10000, 1000000);
BENCHMARK(BM_SoA_RandomAccess)->Range(10000, 1000000);

BENCHMARK_MAIN();

Визуализируем полученные результаты.

Результаты третьего сценария
Результаты третьего сценария

Ого, уже что-то интересное! Ситуация повернулась на 180 градусов: теперь AoS быстрее SoA в среднем в 1,6 раза, то есть на ~35%. Откуда это взялось?

Random access убивает prefetcher. Обращения идут по случайным индексам, никакого паттерна нет, префетчеру предсказывать нечего. Каждое обращение к новому индексу — это гарантированный кэш-промах. И вот тут разница между AoS и SoA становится критичной.

В AoS все 9 полей частицы лежат в одном блоке 36 байт. Когда мы обращаемся к particles[idx], процессор загружает одну кэш-линию (64 байта) — и все 9 полей уже в кэше. Один промах — и все данные доступны.

В SoA обращение к particles.x[idx] загружает кэш-линию из массива x. Потом particles.y[idx] — промах, потому что массив y лежит в совершенно другом месте памяти. Потом particles.z[idx] — снова промах. И так для каждого из 9 массивов. Итого: 9 кэш-промахов на одну частицу вместо одного.

При этом из каждой загруженной кэш-линии используется только один float (4 байта из 64). Утилизация — 6%. Остальные 15 float'ов в кэш-линии — это значения x соседних частиц, но следующий индекс случайный, и эти соседние значения не пригодятся.

Итоги исследования. Что же лучше?

Скажу сразу — победителя нет и не будет. SoA не панацея для оптимального хранения данных, а лишь ситуативный метод их хранения, ровно как и AoS. Каждый из методов эффективен в разных случаях. Давайте же попробуем расписать эту "карту" для выбора, что использовать в каждом случае — AoS или SoA. Основной критерий: быстродействие.

  • Если доступ производится только к части полей структуры одновременно, используем SoA;

  • Если доступ производится ко всем или почти ко всем полям структуры одновременно, предпочтительнее AoS, если нету других причин для SoA;

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

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

Моё субъективное мнение — AoS легче в плане удобства, поскольку код для взаимодействия менее объемен и более структурирован. Приведу пример кода для добавления объекта в AoS и в SoA:

// Попробуем добавить частицу в AoS
particles.push_back(new_particle);

// Попробуем добавить частицу в SoA
particles.x.push_back(0.0f);
particles.y.push_back(0.0f);
particles.z.push_back(0.0f);
particles.vx.push_back(1.0f);
particles.vy.push_back(1.0f);
particles.vz.push_back(1.0f);
particles.ax.push_back(0.0f);
particles.ay.push_back(0.0f);
particles.az.push_back(0.0f);

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

Что дальше?

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

Спасибо за внимание! Если Вам нравится мое творчество, ставьте лайк и подписывайтесь на мой блог, чтобы не пропустить новых исследований :-)

Если у Вас есть какие-либо комментарии, замечания или просто идеи, пишите в комментарии или в ЛС. Буду рад обсудить.