Пропуски кеш-пам'яті та зручність використання в Entity Systems


18

Останнім часом я досліджував та впроваджував систему сутності для моєї бази. Я думаю, що я прочитав більшість статей, червоних відтінків і запитань з цього приводу, які міг знайти, і поки що думаю, що досить добре сприймаю цю ідею.

Однак вона порушила деякі питання щодо загальної поведінки на C ++, мови, в якій я впроваджую сутнісну систему, а також деяких проблем юзабіліті.

Таким чином, одним із підходів було б безпосередньо зберігати масив компонентів в об'єкті, що я не робив, оскільки це руйнує місце кешу при ітерації даних. Через це я вирішив мати один масив на тип компонента, тому всі компоненти одного типу є суміжними в пам'яті, що повинно бути оптимальним рішенням для швидкої ітерації.

Але, коли я перебираю масиви компонентів, щоб зробити щось із ними із системи на реальній геймплейній реалізації, я помічаю, що майже завжди працюю відразу з двома або більше типами компонентів. Наприклад, система візуалізації використовує компонент "Трансформація" та "Модель" разом, щоб фактично здійснити виклик візуалізації. Моє запитання полягає в тому, що я в цих випадках лінійно не повторюю один суміжний масив одночасно, чи я негайно жертвую прибутковістю від розподілу компонентів таким чином? Чи є проблемою, коли я повторюю два різних суміжних масиви в C ++ і використовую дані обох на кожному циклі?

Ще одна річ, про яку я хотів запитати, - це те, як слід зберігати посилання на компоненти або сутності, оскільки сама природа того, як компоненти закладаються в пам'ять, може легко перемикати позиції в масиві або масив може бути перерозподілений для розширення або скорочуючись, залишаючи покажчики або ручки компонентів недійсними. Як ти рекомендуєш обробляти ці випадки, оскільки я часто відчуваю, що хочу працювати з перетвореннями та іншими компонентами кожного кадру, і якщо мої ручки або покажчики недійсні, це досить безладно робити пошук кожного кадру.


4
Я б не переймався вкладати компоненти в постійну пам'ять, а просто розподіляти пам'ять для кожного компонента динамічно. Постійна пам'ять навряд чи дає вам якісь підвищення продуктивності кешу, оскільки ви, швидше за все, отримаєте доступ до компонентів у досить випадковому порядку.
JarkkoL

@Grimshaw Ось цікава стаття, яку слід прочитати: шкідливо.cat
v.org/

@JarkkoL -10 балів. Це дуже шкодить продуктивності, якщо ви будуєте кеш-пам'ять системи та отримуєте доступ до неї випадковим чином, це дурно лише за її звучанням. Сенс його в доступі до нього лінійним способом . Мистецтво ECS та підвищення продуктивності полягає у написанні C / S, доступ до якого здійснюється лінійним способом.
wondra

@Grimshaw не забувай кеш більший, ніж одне ціле число. У вас є кілька КБ кеш-пам’яті L1 (і МБ інших), якщо ви не робите нічого чудовищного, вам слід добре отримати доступ до декількох систем одночасно і при цьому керувати кешем.
wondra

2
@wondra Як би ви забезпечили лінійний доступ до компонентів? Скажімо, якщо я збираю компоненти для візуалізації та хочу, щоб об'єкти оброблялися у порядку зменшення від камери. Компоненти візуалізації цих об'єктів не матимуть лінійного доступу до пам'яті. Хоча те, що ви говорите, це теоретично приємна річ, я не бачу, що це працює на практиці, але я радий, якщо ви
докажете

Відповіді:


13

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

  • Кожна сутність містить вектор загальних ручок компонентів, який може представляти будь-який тип.
  • Кожна складова ручка може бути скасована, щоб отримати необроблений покажчик T *. *Дивіться нижче.
  • У кожного типу компонентів є свій пул, безперервний блок пам'яті (у моєму випадку фіксований розмір).

Слід зазначити, що ні, ви не зможете просто завжди проходити складовий пул і робити ідеальну, чисту річ. Як ви вже говорили, між компонентами є неминучі зв’язки, в яких вам дійсно потрібно обробляти речі об'єктом за один раз.

Однак, є випадки (як я з'ясував), коли дійсно ви можете буквально написати цикл для певного типу компонентів і відмінно використовувати лінії кешу CPU. Для тих, хто не знає чи бажає дізнатися більше, подивіться на https://en.wikipedia.org/wiki/Locality_of_reference . У цій же примітці, коли це можливо, намагайтеся зберегти розмір компонента менше або рівний розміру рядка кешу CPU. Розмір мого рядка становив 64 байти, що, на мою думку, є загальним.

У моєму випадку, докладати зусиль щодо впровадження системи, варто того варто. Я бачив помітні підвищення продуктивності (звичайно, профільовані). Вам потрібно буде вирішити для себе, чи це гарна ідея. Найбільший приріст у роботі я бачив у 1000+ об'єктах.

Ще одна річ, про яку я хотів запитати, - це те, як слід зберігати посилання на компоненти або сутності, оскільки сама природа того, як компоненти закладаються в пам'ять, вони можуть легко перемикати позиції в масиві або масив може бути перерозподілений для розширення або скорочуючись, залишаючи покажчики або ручки компонентів недійсними Як ти рекомендуєш обробляти ці випадки, оскільки я часто відчуваю, що хочу працювати з перетвореннями та іншими компонентами кожного кадру, і якщо мої ручки або покажчики недійсні, це досить безладно робити пошук кожного кадру.

Я також вирішив це питання особисто. У мене виникла система, де:

  • Кожна складова ручка містить посилання на індекс пулу
  • Коли компонент 'видалено' або 'видалено' з пулу, останній компонент у цьому пулі переміщується (буквально за допомогою std :: move) у теперішнє вільне місце, або жоден, якщо ви просто видалили останній компонент.
  • Коли відбувається "swap", у мене є зворотний виклик, який повідомляє всіх слухачів, щоб вони могли оновити будь-які конкретні вказівники (наприклад, T *).

* Я виявив, що намагання завжди відмінювати ручки компонентів під час виконання в певних розділах коду високого використання з кількістю об'єктів, з якими я мав справу, - це продуктивність. Через це я зараз підтримую декілька необроблених Т-покажчиків у важливих частинах мого проекту, але в іншому випадку я використовую загальні ручки компонентів, які слід використовувати, де це можливо. Я вважаю їх дійсними, як було сказано вище, із системою зворотного виклику. Можливо, вам не потрібно буде йти так далеко.

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

Чи допомагає це? Я спробую прояснити все, що незрозуміло. Також вдячні будь-які виправлення.


Наголошений, це була справді хороша відповідь, і хоча це може бути не срібною кулею, все ж добре побачити, що хтось мав подібні дизайнерські ідеї. У мене також є деякі ваші хитрощі, реалізовані в моєму навчальному закладі, і вони здаються практичними. Дуже дякую! Не соромтесь коментувати подальші ідеї, якщо вони з’являться.
Grimshaw

5

Щоб відповісти лише на це:

Моє запитання полягає в тому, що я в цих випадках лінійно не повторюю один суміжний масив одночасно, чи я негайно жертвую прибутковістю від розподілу компонентів таким чином? Чи є проблемою, коли я повторюю два різних суміжних масиви в C ++ і використовую дані обох на кожному циклі?

Ні (принаймні, не обов'язково). У більшості випадків кеш-контролер повинен ефективно працювати з читанням з декількох суміжних масивів. Важлива частина - спробувати, де можливо, отримати доступ до кожного масиву лінійно.

Щоб продемонструвати це, я написав невеликий орієнтир (застосовні звичайні застереження).

Починаючи з простої векторної структури:

struct float3 { float x, y, z; };

Я виявив, що цикл, що підсумовує кожен елемент двох окремих масивів і зберігає результат у третьому, виконує точно так само, як і версію, де вихідні дані були переплетені в одному масиві, а результат - у третьому. Однак я виявив, що якщо переплутати результат з джерелом, продуктивність постраждала (приблизно в 2 рази).

Якщо я отримав доступ до даних випадковим чином, показники страждали від коефіцієнта між 10 і 20.

Хронометраж (1000000 елементів)

лінійний доступ

  • окремі масиви 0,21s
  • переплетене джерело 0,21с
  • перемежоване джерело і результат 0,48

випадковий доступ (без коментарів random_shuffle)

  • окремі масиви 2.42s
  • переплетене джерело 4.43с
  • переплетене джерело і результат 4.00s

Джерело (укладено з Visual Studio 2013):

#include <Windows.h>
#include <vector>
#include <algorithm>
#include <iostream>

struct float3 { float x, y, z; };

float3 operator+( float3 const &a, float3 const &b )
{
    return float3{ a.x + b.x, a.y + b.y, a.z + b.z };
}

struct Both { float3 a, b; };

struct All { float3 a, b, res; };


// A version without any indirection
void sum( float3 *a, float3 *b, float3 *res, int n )
{
    for( int i = 0; i < n; ++i )
        *res++ = *a++ + *b++;
}

void sum( float3 *a, float3 *b, float3 *res, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        res[*index] = a[*index] + b[*index];
}

void sum( Both *both, float3 *res, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        res[*index] = both[*index].a + both[*index].b;
}

void sum( All *all, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        all[*index].res = all[*index].a + all[*index].b;
}

class PerformanceTimer
{
public:
    PerformanceTimer() { QueryPerformanceCounter( &start ); }
    double time()
    {
        LARGE_INTEGER now, freq;
        QueryPerformanceCounter( &now );
        QueryPerformanceFrequency( &freq );
        return double( now.QuadPart - start.QuadPart ) / double( freq.QuadPart );
    }
private:
    LARGE_INTEGER start;
};

int main( int argc, char* argv[] )
{
    const int count = 10000000;

    std::vector< float3 > a( count, float3{ 1.f, 2.f, 3.f } );
    std::vector< float3 > b( count, float3{ 1.f, 2.f, 3.f } );
    std::vector< float3 > res( count );

    std::vector< All > all( count, All{ { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f } } );
    std::vector< Both > both( count, Both{ { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f } } );

    std::vector< int > index( count );
    int n = 0;
    std::generate( index.begin(), index.end(), [&]{ return n++; } );
    //std::random_shuffle( index.begin(), index.end() );

    PerformanceTimer timer;
    // uncomment version to test
    //sum( &a[0], &b[0], &res[0], &index[0], count );
    //sum( &both[0], &res[0], &index[0], count );
    //sum( &all[0], &index[0], count );
    std::cout << timer.time();
    return 0;
}

1
Це дуже допомагає при моїх сумнівах щодо місцевості кешу, дякую!
Grimshaw

Проста, але цікава відповідь, що мені також здається заспокійливою :) Мені було б цікаво подивитися, як ці результати змінюються для різних підрахунків елементів (тобто 1000 замість 10 000 000?) Або якщо у вас більше масивів значень (тобто підсумовування елементів 3 -5 окремих масивів і зберігання значення в інший окремий масив).
Awesomania

2

Короткий відповідь: профіль потім оптимізуйте.

Довга відповідь:

Але, коли я перебираю масиви компонентів, щоб зробити щось із ними із системи на реальній геймплейній реалізації, я помічаю, що майже завжди працюю відразу з двома або більше типами компонентів.

Чи є проблемою, коли я повторюю два різних суміжних масиви в C ++ і використовую дані обох на кожному циклі?

C ++ не несе відповідальності за помилки кеша, оскільки це стосується будь-якої мови програмування. Це пов'язано з тим, як працює сучасна архітектура процесора.

Ваша проблема може бути хорошим прикладом того, що можна назвати передзрілою оптимізацією .

На мою думку, ви занадто рано оптимізували місце кешу, не дивлячись на схеми доступу до пам'яті програми. Але важливіше питання - чи справді вам потрібен такий тип оптимізації?

Туман Agner пропонує, що ви не повинні робити оптимізацію перед тим, як профілювати заявку та / або точно знати, де є вузькі місця. (Про це все сказано у його чудовому посібнику. Посилання нижче)

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

На жаль, те, що ви зробили, насправді припускає, що виділення одного компонента типу на масив дасть вам кращу продуктивність, тоді як насправді ви могли б спричинити більше пропусків кешу або навіть суперечки кешу.

Ви обов'язково повинні ознайомитися з його чудовим посібником із оптимізації C ++ .

Ще одна річ, про яку я хотів запитати, - це те, як слід зберігати посилання на компоненти або сутності, оскільки сама природа того, як компоненти закладаються в пам'ять.

Особисто я буду виділяти найбільш використовувані компоненти разом в одному блоці пам'яті, щоб вони мали "поруч" адреси. Наприклад, масив буде виглядати так:

[{ID0 Transform Model PhysicsComp }{ID10 Transform Model PhysicsComp }{ID2 Transform Model PhysicsComp }..] а потім почніть оптимізувати звідти, якщо продуктивність не була "достатньо хорошою".


Моє запитання стосувалося наслідків, які може мати моя архітектура на продуктивність, справа не в тому, щоб оптимізувати, а вибрати спосіб організувати речі внутрішньо. Незалежно від того, як це відбувається всередині, я хочу, щоб мій ігровий код взаємодіяв з ним однорідним чином, якщо я хочу змінити його згодом. Ваша відповідь була хорошою, навіть якщо вона могла б дати додаткові пропозиції щодо збереження даних. Отримано.
Грімшо

З того, що я бачу, є три основні способи зберігання компонентів, всі з’єднані в одному масиві на одну сутність, усі разом з'єднані за типом в окремих масивах, і якщо я правильно зрозумів, ви пропонуєте постійно зберігати різні сутності у великому масиві, і на кожну організацію є всі її компоненти разом?
Грімшо

@Grimshaw Як я вже згадував у відповіді, ваша архітектура не гарантовано дає кращі результати, ніж звичайна схема розподілу. Оскільки ви дійсно не знаєте шаблон доступу до ваших програм. Такі оптимізації, як правило, проводяться після деяких досліджень / доказів. Що стосується моєї пропозиції, зберігайте пов'язані компоненти разом в одній пам’яті та інших компонентах у різних місцях. Це середина між усіма або нічого. Тим не менш, я все ще припускаю, що важко передбачити, як ваша архітектура вплине на результат, враховуючи, скільки умов буде втілено.
concept3d

Нижній працівник хоче пояснити? Просто вкажіть на проблему у моїй відповіді. Ще краще дати кращу відповідь.
concept3d

1

Моє запитання полягає в тому, що я не ітерую лінійно один суміжний масив одночасно в цих випадках, чи я негайно жертвую підвищення продуктивності від розподілу компонентів таким чином?

Можливо, ви отримаєте менше пропусків кеша в цілому з окремими "вертикальними" масивами на тип компонента, ніж переплетення компонентів, приєднаних до об'єкта, у "горизонтальний" блок змінного розміру, так би мовити.

Причина полягає в тому, що, по-перше, "вертикальне" представлення буде, як правило, використовувати менше пам'яті. Вам не доведеться турбуватися про вирівнювання однорідних масивів, виділених безперервно. З неоднорідними типами, виділеними в пул пам'яті, вам доведеться турбуватися про вирівнювання, оскільки перший елемент масиву може мати зовсім інші вимоги до розміру та вирівнювання від другого. Як результат, вам часто потрібно буде додавати прокладки, як-от простий приклад:

// 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результатів до і після сортування). І я припускаю, що більшу частину часу у вас фактично не буде мільйона + компонентів для сортування, тому вам слід дуже легко мати можливість прокрастись це там і там, не викликаючи помітних заїкань частоти кадрів.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.