Моє запитання полягає в тому, що я не ітерую лінійно один суміжний масив одночасно в цих випадках, чи я негайно жертвую підвищення продуктивності від розподілу компонентів таким чином?
Можливо, ви отримаєте менше пропусків кеша в цілому з окремими "вертикальними" масивами на тип компонента, ніж переплетення компонентів, приєднаних до об'єкта, у "горизонтальний" блок змінного розміру, так би мовити.
Причина полягає в тому, що, по-перше, "вертикальне" представлення буде, як правило, використовувати менше пам'яті. Вам не доведеться турбуватися про вирівнювання однорідних масивів, виділених безперервно. З неоднорідними типами, виділеними в пул пам'яті, вам доведеться турбуватися про вирівнювання, оскільки перший елемент масиву може мати зовсім інші вимоги до розміру та вирівнювання від другого. Як результат, вам часто потрібно буде додавати прокладки, як-от простий приклад:
// 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'
};
Якщо ви забираєте більше пам’яті в контексті послідовного доступу, не покращуючи значущих шаблонів доступу, то, як правило, ви матимете більше пропусків кешу. На додаток до цього кроки, щоб перейти від однієї сутності до наступної, збільшується і змінюється розмір, завдяки чому вам доведеться робити стрибки в змінному розмірі, щоб перейти від однієї сутності до наступної, просто щоб побачити, які з них мають компоненти, які ви ' Вас цікавить.
Тому використання "вертикального" подання для зберігання типів компонентів насправді є більш оптимальним, ніж "горизонтальні" альтернативи. Однак, проблема з помилками кешу у вертикальному поданні може бути пояснена тут:
Там, де стрілки просто вказують, що суб’єкт "володіє" компонентом. Ми можемо бачити, що якби ми намагалися отримати доступ до всіх компонентів руху і візуалізації сутностей, які мають обидва, ми в кінцевому підсумку стрибаємо всюди в пам'яті. Цей тип спорадичного доступу може змусити вас завантажувати дані в кеш-рядок для доступу, скажімо, до компонента руху, потім отримувати доступ до більшої кількості компонентів і вилучати колишні дані, лише щоб знову завантажити ту саму область пам’яті, яка вже була виселена для іншого руху компонент. Отже, це може дуже марно завантажувати ці ж регіони пам’яті не один раз у рядок кешу, щоб просто проглянути та отримати доступ до списку компонентів.
Давайте трохи приберемо цей безлад, щоб ми побачили чіткіше:
Зауважте, що якщо ви стикаєтесь з подібним сценарієм, зазвичай це триває після запуску гри, після того як багато компонентів і об'єктів додано та видалено. Як правило, коли гра починається, ви можете додати всі сутності та відповідні компоненти разом, і тоді вони можуть мати дуже впорядкований, послідовний шаблон доступу з хорошою просторовою локальністю. Однак після багатьох видалень та вставок ви можете отримати щось подібне до вищезгаданого безладу.
Дуже простий спосіб покращити цю ситуацію - просто сортувати свої компоненти на основі ідентифікатора / індексу сутності, який їм належить. У цей момент ви отримуєте щось подібне:
І це набагато більш зручна кеш-схема. Це не ідеально, оскільки ми можемо бачити, що нам тут і там потрібно пропускати деякі компоненти візуалізації та руху, оскільки наша система зацікавлена лише у суб'єктів, які мають обидва , а деякі об'єкти мають лише компонент руху, а деякі лише компонент візуалізації. , але ви принаймні зможете обробити деякі суміжні компоненти (більше на практиці, як правило, оскільки часто ви додаєте відповідні компоненти, що цікавлять, як, можливо, більшість об'єктів у вашій системі, які мають компонент руху, матимуть компонент візуалізації, ніж ні).
Найголовніше, що після їх сортування ви не будете завантажувати дані області пам’яті в кеш-рядок лише для того, щоб потім перезавантажити її в єдиний цикл.
І для цього не потрібна надзвичайно складна конструкція, просто раз у раз проходить лінійний часовий сорт, можливо, після того, як ви вставили та вилучили купу компонентів для певного типу компонентів, і тоді ви можете позначити його як потребує сортування. Розумно-реалізований сортинг radix (ви навіть можете паралельно це зробити, що я і роблю) може сортувати мільйон елементів приблизно за 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
результатів до і після сортування). І я припускаю, що більшу частину часу у вас фактично не буде мільйона + компонентів для сортування, тому вам слід дуже легко мати можливість прокрастись це там і там, не викликаючи помітних заїкань частоти кадрів.