Ориентированное на данные мышление
Дизайн, ориентированный на данные, не означает применение SoAs везде. Это просто означает проектирование архитектур с преимущественным акцентом на представление данных - в частности, с упором на эффективную компоновку памяти и доступ к памяти.
Это может привести к повторениям SoA, когда это уместно, так:
struct BallSoa
{
vector<float> x; // size n
vector<float> y; // size n
vector<float> z; // size n
vector<float> r; // size n
};
... это часто подходит для вертикальной петлевой логики, которая не обрабатывает компоненты вектора центра радиуса и радиус одновременно (четыре поля не являются одновременно горячими), но вместо этого по одному за раз (цикл через радиус, еще 3 цикла через отдельные компоненты сферных центров).
В других случаях было бы более целесообразно использовать AoS, если к полям часто обращаются вместе (если ваша циклическая логика выполняет итерации по всем полям шаров, а не по отдельности) и / или если необходим произвольный доступ к шару:
struct BallAoS
{
float x;
float y;
float z;
float r;
};
vector<BallAoS> balls; // size n
... в других случаях может быть целесообразно использовать гибрид, который сочетает в себе оба преимущества:
struct BallAoSoA
{
float x[8];
float y[8];
float z[8];
float r[8];
};
vector<BallAoSoA> balls; // size n/8
... вы можете даже сжать размер шарика до половины, используя полутопы, чтобы разместить больше полей шарика в строке / странице кэша.
struct BallAoSoA16
{
Float16 x2[16];
Float16 y2[16];
Float16 z2[16];
Float16 r2[16];
};
vector<BallAoSoA16> balls; // size n/16
... возможно, даже к радиусу не обращаются почти так же часто, как к центру сферы (возможно, ваша кодовая база часто рассматривает их как точки и очень редко, например, сферы). В этом случае вы можете применить метод расщепления поля горячего / холодного в дальнейшем.
struct BallAoSoA16Hot
{
Float16 x2[16];
Float16 y2[16];
Float16 z2[16];
};
vector<BallAoSoA16Hot> balls; // size n/16: hot fields
vector<Float16> ball_radiuses; // size n: cold fields
Ключом к проектированию, ориентированному на данные, является рассмотрение всех этих видов представлений на ранних этапах принятия проектных решений, чтобы не попасть в неоптимальное представление с открытым интерфейсом за ним.
Это делает акцент на шаблонах доступа к памяти и сопутствующих макетах, делая их значительно более серьезной проблемой, чем обычно. В некотором смысле это может даже несколько разрушить абстракции. Используя этот образ мышления, я обнаружил, что больше не смотрю std::deque
, например, с точки зрения его алгоритмических требований в той же степени, что и представление агрегированных смежных блоков, и того, как работает произвольный доступ к нему на уровне памяти. В некоторой степени акцент делается на деталях реализации, но на деталях реализации, которые, как правило, оказывают такое же или большее влияние на производительность, чем алгоритмическая сложность, описывающая масштабируемость.
Преждевременная оптимизация
Большая часть ориентированного на данные дизайна окажется, по крайней мере, на первый взгляд, опасно близкой к преждевременной оптимизации. Опыт часто учит нас, что такие микрооптимизации лучше всего применять задним числом и с профилировщиком в руках.
Тем не менее, возможно, сильное послание, которое следует извлечь из дизайна, ориентированного на данные, - это оставить место для такой оптимизации. Вот что может помочь ориентированный на данные образ мыслей:
Ориентированный на данные дизайн может оставить передышку, чтобы изучить более эффективные представления. Речь идет не обязательно о достижении совершенства компоновки памяти за один раз, а скорее о том, чтобы заранее продумать соответствующие соображения, чтобы обеспечить все более оптимальное представление.
Гранулированный объектно-ориентированный дизайн
Многие дискуссии по проектированию, ориентированному на данные, натолкнутся на классические понятия объектно-ориентированного программирования. Тем не менее, я бы предложил способ взглянуть на это, который не настолько хардкорный, как полное отклонение ООП.
Сложность объектно-ориентированного проектирования заключается в том, что он часто будет искушать нас моделировать интерфейсы на очень детальном уровне, оставляя нас в ловушке со скалярным, единичным мышлением вместо параллельного массового мышления.
В качестве преувеличенного примера представьте объектно-ориентированный дизайн мышления, примененный к одному пикселю изображения.
class Pixel
{
public:
// Pixel operations to blend, multiply, add, blur, etc.
private:
Image* image; // back pointer to access adjacent pixels
unsigned char rgba[4];
};
Надеюсь, никто на самом деле не делает это. Чтобы сделать пример действительно грубым, я сохранил обратный указатель на изображение, содержащее пиксель, чтобы он мог получить доступ к соседним пикселям для алгоритмов обработки изображений, таких как размытие.
Обратный указатель изображения немедленно добавляет явные накладные расходы, но даже если мы исключим его (делая только открытый интерфейс пикселя, обеспечивающий операции, которые применяются к одному пикселю), мы получим класс просто для представления пикселя.
Теперь нет ничего плохого с классом в смысле непосредственных издержек в контексте C ++, кроме этого обратного указателя. Оптимизация компиляторов C ++ прекрасно подходит для того, чтобы взять всю построенную нами структуру и уничтожить ее вдребезги.
Сложность в том, что мы моделируем инкапсулированный интерфейс на слишком гранулярном уровне пикселей. Это оставляет нас в ловушке с таким типом детального проектирования и данных, потенциально огромным количеством клиентских зависимостей, связывающих их с этим Pixel
интерфейсом.
Решение: уничтожить объектно-ориентированную структуру гранулярного пикселя и начать моделирование ваших интерфейсов на более грубом уровне, имея дело с большим количеством пикселей (на уровне изображения).
Благодаря моделированию на уровне объемных изображений у нас значительно больше возможностей для оптимизации. Мы можем, например, представить большие изображения в виде объединенных листов размером 16x16 пикселей, которые идеально вписываются в 64-байтовую строку кэша, но обеспечивают эффективный соседний вертикальный доступ к пикселям с обычно небольшим шагом (если у нас есть ряд алгоритмов обработки изображений, которые необходимо получить доступ к соседним пикселям по вертикали) в качестве жесткого ориентированного на данные примера.
Проектирование на более грубом уровне
Вышеприведенный пример моделирования интерфейсов на уровне изображений является своего рода несложным примером, поскольку обработка изображений является очень зрелой областью, которая была изучена и оптимизирована до смерти. Еще менее очевидной может быть частица в эмиттере частиц, спрайт против совокупности спрайтов, ребро в графе ребер или даже человек против совокупности людей.
Ключ к обеспечению ориентированной на данные оптимизации (в предвидении или ретроспективе) часто сводится к разработке интерфейсов на гораздо более грубом уровне, в целом. Идея проектирования интерфейсов для отдельных сущностей заменяется разработкой для коллекций сущностей с большими операциями, которые их массово обрабатывают. Это особенно и сразу нацелено на последовательные петли доступа, которые должны иметь доступ ко всему и не могут не иметь линейной сложности.
Проектирование, ориентированное на данные, часто начинается с идеи объединения данных для формирования совокупных данных моделирования в большом объеме. Подобное мышление перекликается с дизайном интерфейса, который его сопровождает.
Это самый ценный урок, который я извлек из проектирования, ориентированного на данные, поскольку я недостаточно разбираюсь в компьютерной архитектуре, чтобы часто находить наиболее оптимальную схему памяти для чего-то с первой попытки. Это становится чем-то, что я повторяю с профилировщиком в руке (и иногда с несколькими промахами по пути, где я не смог ускорить процесс). Тем не менее, аспект дизайна интерфейса ориентированного на данные дизайна - это то, что оставляет мне возможность искать все более и более эффективные представления данных.
Ключ в том, чтобы проектировать интерфейсы на более грубом уровне, чем мы обычно испытываем. Это также часто имеет побочные преимущества, такие как уменьшение накладных расходов на динамическую диспетчеризацию, связанных с виртуальными функциями, вызовы указателей на функции, вызовы dylib и невозможность их включения. Основная идея, которую можно извлечь из всего этого, состоит в том, чтобы рассматривать массовую обработку (когда это применимо).
ball->do_something();
сравнению сball_table.do_something(ball)
), если только вы не хотите подделать связную сущность с помощью псевдо-указателя(&ball_table, index)
.