Мой вопрос заключается в том, что, поскольку в этих случаях я не выполняю линейную итерацию по одному непрерывному массиву за раз, немедленно ли я жертвую выигрышем в производительности от такого распределения компонентов?
Скорее всего, в целом вы получите меньше пропусков кэша с отдельными «вертикальными» массивами для каждого типа компонента, чем чередование компонентов, прикрепленных к объекту, в «горизонтальном» блоке переменного размера, так сказать.
Причина в том, что, во-первых, «вертикальное» представление будет иметь тенденцию использовать меньше памяти. Вам не нужно беспокоиться о выравнивании для однородных массивов, расположенных последовательно. С неоднородными типами, выделенными в пул памяти, вам нужно беспокоиться о выравнивании, поскольку первый элемент в массиве может иметь совершенно другие требования к размеру и выравниванию по сравнению со вторым. В результате вам часто нужно будет добавлять отступы, как в простом примере:
// Assuming 8-bit chars and 64-bit doubles.
struct Foo
{
// 1 byte
char a;
// 1 byte
char b;
};
struct Bar
{
// 8 bytes
double opacity;
// 8 bytes
double radius;
};
Допустим, мы хотим чередовать Foo
и Bar
хранить их прямо рядом друг с другом в памяти:
// Assuming 8-bit chars and 64-bit doubles.
struct FooBar
{
// 1 byte
char a;
// 1 byte
char b;
// 6 bytes padding for 64-bit alignment of 'opacity'
// 8 bytes
double opacity;
// 8 bytes
double radius;
};
Теперь вместо 18 байтов для хранения Foo и Bar в отдельных областях памяти требуется 24 байта для их объединения. Неважно, если вы поменяете порядок:
// Assuming 8-bit chars and 64-bit doubles.
struct BarFoo
{
// 8 bytes
double opacity;
// 8 bytes
double radius;
// 1 byte
char a;
// 1 byte
char b;
// 6 bytes padding for 64-bit alignment of 'opacity'
};
Если вы берете больше памяти в контексте последовательного доступа без значительного улучшения шаблонов доступа, то вы, как правило, будете чаще пропускать кэш. Вдобавок к этому увеличивается шаг к переходу от одного объекта к другому и к переменному размеру, что заставляет вас совершать скачки в памяти переменного размера, чтобы переходить от одного объекта к следующему, просто чтобы увидеть, какие из них имеют компоненты, которые у вас есть ». заинтересованы в.
Таким образом, использование «вертикального» представления для хранения типов компонентов на самом деле более вероятно, чем «горизонтальные» альтернативы. Тем не менее, проблема с отсутствием кэша с вертикальным представлением может быть проиллюстрирована здесь:
Где стрелки просто указывают, что объект «владеет» компонентом. Мы можем видеть, что, если бы мы попытались получить доступ ко всем компонентам движения и рендеринга сущностей, которые имеют и то и другое, мы в конечном итоге перепрыгнули через место в памяти. Такой тип спорадического шаблона доступа может привести к загрузке данных в строку кэша для доступа, скажем, к компоненту движения, а затем к большему количеству компонентов и удалению прежних данных, только чтобы снова загрузить ту же область памяти, которая уже была удалена для другого движения компонент. Так что это может быть очень расточительным, загружая одни и те же области памяти более одного раза в строку кэша, чтобы просто просмотреть и просмотреть список компонентов.
Давайте немного исправим этот беспорядок, чтобы лучше видеть:
Обратите внимание, что если вы сталкиваетесь с подобным сценарием, то обычно через много времени после запуска игры после добавления и удаления многих компонентов и объектов. В общем, когда игра начинается, вы можете добавить все объекты и соответствующие компоненты вместе, и в этот момент у них может быть очень упорядоченный последовательный шаблон доступа с хорошей пространственной локализацией. Однако после многих удалений и вставок вы можете получить что-то похожее на описанный выше беспорядок.
Очень простой способ улучшить эту ситуацию - это просто отсортировать компоненты по идентификатору / индексу объекта, которому они принадлежат. В этот момент вы получите что-то вроде этого:
И это гораздо более дружественный к кешу шаблон доступа. Это не идеально, так как мы видим, что нам нужно пропустить некоторые компоненты рендеринга и движения тут и там, поскольку наша система заинтересована только в объектах, которые имеют оба из них, а некоторые сущности имеют только компонент движения, а некоторые имеют только компонент рендеринга , но вы, по крайней мере, в конечном итоге сможете обрабатывать некоторые смежные компоненты (чаще на практике, как правило, так как часто вы будете прикреплять соответствующие компоненты, представляющие интерес, например, возможно, больше объектов в вашей системе, имеющих компонент движения, будут иметь компонент рендеринга, чем не).
Самое главное, что после их сортировки вы не будете загружать данные из области памяти в строку кэша, а затем перезагружать их в одном цикле.
И это не требует какого-то чрезвычайно сложного дизайна, просто время прохода радикальной сортировки по линейному времени, может быть, после того, как вы вставили и удалили группу компонентов для определенного типа компонента, после чего вы можете пометить его как нужно быть отсортированным. Разумно реализованная радикальная сортировка (вы даже можете распараллелить ее, что я и делаю) может отсортировать миллион элементов за 6 мс на моем четырехъядерном i7, как показано здесь:
Sorting 1000000 elements 32 times...
mt_sort_int: {0.203000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_sort: {1.248000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_radix_sort: {0.202000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
std::sort: {1.810000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
qsort: {2.777000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
Выше указано, что нужно отсортировать миллион элементов 32 раза (включая время до memcpy
результатов до и после сортировки). И я предполагаю, что большую часть времени у вас фактически не будет более миллиона компонентов для сортировки, поэтому вы очень легко сможете уловить это время от времени, не вызывая заметного заикания частоты кадров.