Unity Job System на практиці. Як ми підвищили FPS з 15 до 70 у нашій грі
Привіт всім! З вами Юрій Єнько, я головний розробник в компанії RetroStyleGames. Зараз наша команда працює над проєктом Codename: Ocean Keeper — нашою першою грою на ПК і консолі. В цій статті я хочу розповісти, як ми використовуємо Job System від Unity в нашій грі та загалом поговорити про багатопотоковість. Це дуже потужний інструмент для вирішення проблем з оптимізацією, про який в геймдеві згадують рідше, ніж хотілось би.
Що таке багатопотоковість, як вона працює і навіщо нам Job System
За замовчуванням Unity виконує код гри на одному потоці, який створюється на початку гри — він зветься головним потоком (Main Thread).
Головний потік може створювати інші потоки. Код на них буде виконуватись паралельно, а після виконання своєї роботи вони синхронізують результат. Створювати окремі потоки під кожну окрему задачу є не дуже вдалим рішенням — створення та закриття потоку є доволі важкою операцією, яку потрібно уникати.
Краще тримати небагато потоків з великою тривалістю життя — для цього в основному створюють пул (Thread Pool). Береш з пула вільний потік та виконуєш свою задачу на ньому, а по виконанню задачі повертаєш потік в пул.
Тут може виникнути проблема, що в пулі може бути більше потоків ніж ядер процесора і тоді ми зустрінемось з такою проблемою як перемикання контексту (Context Switching). Це ситуація, коли ядра процесора постійно перемикаються між обробкою різних потоків через те, що в один момент часу одне ядро може обробляти тільки один потік.
І тут ми повертаємось до Job System та того, як Unity спростили нам роботу з усім описаним вище. Job System за замовчуванням створює робочі потоки (Worker Thread) та їх обробники, по одному на кожне ядро процесора і цим самим не допускає виникнення проблеми Context Switching. Зі сторони користувача тепер необхідно тільки створити задачу, яку потрібно буде виконати на іншому потоці, та запланувати її виконання — Job System самостійно розкине задачі по вільним робочим потокам.
Safety System
Потрібно згадати, що не менш важливою і болючою проблемою при написанні багатопотокового коду є стан гонитви (Race Condition) — помилка, при якій результат виконання задачі залежить від порядку або швидкості виконання задачі на різних потоках. Частіше всього так трапляється через те, що два або більше потоки намагаються записати інформацію в одне й те саме місце одночасно і тому передбачити результат, який фінально буде записаний, майже неможливо.
Найжахливіше, що відловити цю помилку під час написання коду може бути дуже важко, а після важко зрозуміти що вона взагалі існує, оскільки часто навіть не генерує баг. А якщо баг вже стався, то дебажити важко — знову ж таки, все дуже сильно залежить від часу та порядку виконання задач. При цьому навіть точки зупину (break points) вже сильно впливають на це, тому симптоми багу часто зникають під час пошуку помилки.
І для того, щоб врятувати нас від довгих годин дебагінгу коду, Unity разом з Job System інтегрувала і Safety System, Це система, яка потрібна для того, щоб шукати та сповіщати про всі можливі ризики виникнення Race Condition та захищати гру від багів, які можуть бути ним спричиненні.
Safety System ізолює дані між різними Job’ами шляхом їх копіювання і тим самим усуває проблему Race Condition. Додатково ця система накладає багато обмежень при оголошенні даних та їх використанні і сповіщає про всі знайдені відхилення.
Job System на практиці
Подивимось як Job System працює на практиці. Спочатку на дуже базовому прикладі, щоб зрозуміти теорію, а далі розглянемо приклади з Ocean Keeper та як ми використали Job System там.
Невеликий дисклеймер. Подальший код може бути спрощений та деякі моменти можуть бути пропущені, щоб полегшити розуміння та зробити приклад більш наглядним.
Базове знайомство та тестовий приклад
Робота з Job System починається з написання задачі (Job), яку потім можна буде виконати на різних потоках. Зробити це можна, оголосивши структуру, яка наслідується від одного з інтерфейсів нижче:
- IJob — стандартний інтерфейс для створення однієї паралельної задачі;
- IJobParallelFor — інтерфейс для створення задачі, яка повинна повторитись для багатьох об’єктів;
- IJobParallelForTransform — інтерфейс для створення задачі, яка користується Transform компонентом об’єкта, яка повинна повторитись для багатьох об’єктів.
Перша задача
Візьмемо стандартний інтерфейс IJob та створимо нашу першу задачу.
В методі Execute знаходиться код, який буде виконуватись на іншому потоці. Зараз маємо там просту математичну операцію та повідомлення в консоль, щоб побачити, коли задача була виконана.
Для того, щоб запустити задачу, потрібно десь її створити й запланувати. Зробити це можна в будь-якому місці де вам буде зручно, виглядати це буде ось так:
Якщо виконати цей код, то в консолі побачимо такий порядок повідомлень.
Бачимо спочатку повідомлення з головного потоку, потім вже повідомлення з задачі. Так вийшло, тому що метод Schedule() не зупиняє головний потік, а запланована задача буде виконана тоді, коли з’явиться вільний робочий потік.
Передача даних в джоби
В джоби можна передавати дані, з якими вони будуть працювати. Для того, щоб це зробити, потрібно спочатку об’явити дані всередині Job структури, а потім під час її створення на головному потоці просто передати туди що потрібно.
Потрібно розуміти, що Job System та Job`и вміють працювати тільки з неперетворюваними типами (Blittable Type). Ми передали в структуру int, тому проблем у нас не буде.
Читання даних з виконаних задач
Тепер ми можемо передати дані в задачу, а зараз розберемось як їх звідти взяти. Для того, щоб передавати дані між головним і робочими потоками Unity підтримує спільну пам’ять, яку називають Native Container. Це безпечна обгортка над некерованою пам’яттю, яка тримає в собі вказівник на некеровану алокацію і тим самим дозволяє обійти обмеження Safety System, що тримає результат виконання задачі в ізольованій копії.
«Бій на дні океану до останнього»
Для того, щоб передати та отримати дані з задачі створюємо NativeArray (список, який підтримує спільну пам’ять) з типом int і розміром у 2 елементи. На нульовій позиції будемо зберігати вхідні дані, а на першій позиції вихідні.
Варто зазначити, що звертатись до даних контейнеру на головному потоці можна тільки тоді, коли запланована задача закінчилася. Для того, щоб впевнитись, що задача закінчилась на головному потоці потрібно отримати обробник JobHandle та викликати метод Complete(), який зупинить головний потік допоки запланована задача не закінчиться.
В консолі бачимо попередження про те що ми не звільнили контейнер і це призвело до витоку пам’яті. Щоб цього не було викликаємо на контейнері метод Dispose(), коли він нам вже не потрібен.
data.Dispose();
Користі від такої задачі зараз не багато, оскільки головний потік все одно очікує виконання задачі відразу після її оголошення, тому ми сильно нічого не оптимізували. Рекомендується викликати метод Complete() чим можна пізніше — в момент, коли дані з контейнера знадобляться.
Ініціалізація NativeContainer
Детальніше роздивимось ініціалізацію NativeArray:
NativeArray<int> data = new NativeArray<int>(2, Allocator.TempJob);
В його конструкторі бачимо два параметри. На першому місці — розмір масиву, на другому — час його життя. Роздивимось детальніше основні:
- Temp — найкоротша тривалість життя (1 кадр), але найшвидша алокація. Такий контейнер передати в Job не вийде, так як час його життя надто короткий;
- TempJob — контейнер з таким параметром існує 4 кадра і цього вже достатньо, щоб передати його в задачу, але швидкість алокації менша ніж в Temp;
- Persistent — найбільш повільна алокація, але час його життя рівний часу життя всього додатку.
Boids, майже книжковий приклад для використання Job System
Найчастіше про Job System згадують при оптимізації Boids — ройового алгоритму, який симулює поведінку більшості зграйних тварин (птахи, корови, вівці тощо). В нашій грі ми використовуємо цей алгоритм для симуляції поведінки риб.
Розберемо початковий приклад без оптимізацій.




Як бачимо, виглядає алгоритм дуже складно, оскільки для кожної окремої риби потрібно розрахувати дистанцію до всіх інших риб, а потім ще й закинути кучу фізичних обрахунків для того щоб порахувати логіку оминання перегород.
Тепер давайте оптимізуємо алгоритм за допомогою Job System. На цей раз використаємо інші інтерфейси для створення потокових задач — IJobParallelFor та IJobParallelForTransform.
Детальне знайомство з IJobParallelFor
IJobParallelFor — це інтерфейс для створення потокової задачі, яка виконується багато разів. Job System дозволяє розбити ці задачі на батчі і розкинути їх по різним потокам. Частіше всього використовується для того, щоб оптимізувати певні розрахунки для великих списків, тому в метод Execute додатково додається ще індекс.
public void Execute(int index){}
IJobParallelForTransform схожий на IJobParallelFor, але додатково дає можливість працювати з Tranform компонентом об’єкту за допомогою TransformAccess. Розберемо його трохи пізніше.
Повернемось до оптимізації алгоритму Boids. Розіб’ємо алгоритм на 3 частини:
- розрахунок прискорення риби;
- розрахунок уникнення перешкод;
- переміщення риби;
Розрахунок прискорення риби
В цю задачу будуть входити розрахунки пов’язані з сусідніми рибами та розрахунок впливу різних правил (Alignment, Cohesion, Separation) на прискорення риби. Код задачі буде виглядати ось так:




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


За замовчуванням, при оголошенні змінної, яка пов’язана з загальною пам’яттю, Job System дозволяє проводити з цією змінною операції і зчитування, і записування. Це може бути не зовсім безпечно і впливає на оптимізацію. Для того, щоб покращити продуктивність задачі, можна відразу вказати, який доступ буде мати Job до цієї змінної.
Атрибут [NativeDisableParallelForRestriction] дозволяє записувати і зчитувати дані з NativeArray’а за індексами, які початково не доступні нашій паралельній задачі(так як для IJobParallelFor виділяється своя окрема частина масиву).
Це небезпечне рішення, яке може легко призвести до RaceCondition, але в нашому випадку ми погодились використати цей атрибут для наглядності і для того, щоб можна було порахувати силу уникнення перешкод.
RayCastDirectionHits це масив, який збирає в собі результат перевірки рейкастів на найближчі перешкоди. Для кожної риби напрямків, де шукати перешкоди, може бути декілька. Тому в один масив ми записуємо всі результати рейкастів (Кількість всіх риб * кількість напрямків для перевірки перешкод), а потім отримуємо за індексом потрібні нам для конкретної риби.
Розрахунок уникнення перешкод
Для того, щоб знайти найближчі перешкоди нам потрібно запустити RayCast команди. Unity Job System дає нам зручне API за допомогою якого ми можемо звернутися до фізичного рушія з інших потоків.



Для цього, як видно в прикладі, нам потрібно оголосити два масиви — масив команд, та масив результатів. А потім за допомогою RayCastCommand.ScheduleBatch() перекласти виконання рейкастів на інші потоки.
Можна побачити, що в нас з’явилась PrepareRayCastJob — задача створена для того, щоб створити команди для кожної риби і зробити це в багатопотоковому режимі.



Важливо звернути увагу на строчки коду, де ми планували наші задачі.
В кінці кожної задачі ми передаємо посилання на JobHandle попередньої — це називається JobDependencies.
Передаючи в аргумент поточної задачи обробник іншої задачі, Job System буде знати, що перед тим, як почати виконувати поточну задачу, потрібно спочатку закінчити виконання минулої, яка була передана як аргумент. Таким чином ми можемо створювати ланцюги виконання Jobів.
Переміщення риби
В ції задачі нічого особливого не буде, окрім того, що вона буде наслідуватись від IJobParallelForTransfrom, тому в методі Execute ми додатково отримаємо ще TransformAccess структуру, як дозволить нам керувати Transform компонентом об’єкту.
Головний потік і як запустити весь цей ланцюг
Спочатку нам потрібно об’явити та ініціалізувати всі потрібні дані для рибок:
Після чого, можна перейти до створення та планування самих джобів, метод буде виглядати ось так:




Результат оптимізацій
До оптимізацій, при спавні 1000 рибок, ми мали 13 фпс.
Після наших оптимізацій, ми змогли підвищити наш frame rate до 37 кадрів.
Burst Compiler
Unity спеціально для Job System розробив BurstCompiler, який дуже сильно прискорює виконання задач. Для того, щоб Unity міг виконати Job`и використовуючи BurstCompiler потрібно тільки додати атрибут [BurstCompile] над самим задачами.
Після того, як ми додали атрибут ми маємо 67 кадрів в секунду.
Спавн ворогів
В Ocean Keeper нам потрібно спавнити багато ворогів, а шукати точки спавну для них може бути дуже дорогою операцією, так як нам потрібно щоб:
- точка спавну знаходилась на NavMesh;
- знаходилась під захисним куполом(механіка в грі, яка обмежує зону бою гравця);
- знаходилась на певній дистанції від гравця.
Пошук такої позиції для тисячі ворогів може бути дуже важкою операцією, але Job System дозволила нам знаходити хоч один мільйон таких точок без видимих просадок. Сама задача виглядає ось так:




Тут основні складнощі були:
- генератор рандомних чисел(створили його за допомогою бібліотеки Unity.Mathematics, пізніше можна буде побачити як);
- NavMesh API всередині Job System;
NavMesh API був для нас викликом, так як, насправді, інформації дуже мало, передати в Jobи щось не так легко і пакет взагалі є експериментальним. Тому використовувати його краще тільки в крайньому випадку.
На головному потоці код виглядає ось так:
Результати оптимізацій
Без оптимізацій, пошук мільйона позицій займає 140ms.

З оптимізаціями, пошук мільйона позицій займає 4ms.
P.s. Викликається метод EventSystem.Update, так як пошук починається на натискання кнопки.
Отже операція була прискорена в 35 разів.
Складності при використанні Job System
Основна проблема при роботі з Job System — дуже багато обмежень на відміну від, наприклад, стандартного ThreadPool. Але завдяки цим обмеженням Unity змогли зробити такий ефективний інструмент для багатопотоковості і тому Job набагато швидше, ніж той самий Task.
Інша проблема, яка також заважає швидко інтегрувати Job System в проекти — це мало інформації. Інструмент відносно новий, багато пакетів досі є експериментальними, на форумах інформації мало. Тому дуже часто розробка Jobів це велика кількість ітерацій та тестування.
Наостанок
Причин не використовувати Job System немає, а імпакт від того, що потенціально важкі операції відразу будуть написані на Jobа`х дуже великий. Є популярна практика оптимізовувати проєкти вже під кінець, коли будуть зрозумілі основні ботлнеки. Однак не будь-який код може бути легко переписаним під Jobи, тому бажано одразу помічати потенційно важкі операції і переводити їх на багатопотоковість. Завдяки Safety System ризик зловити якусь серйозну помилку дуже малий, тому тут нема чого втрачати. Більш того, якщо ваш проект написаний на ECS, то Job System може дозволити портувати гру хоч на калькулятор.
7 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів