Использование технологии LINQ на примере игр Unity
Статьи про код мы еще не публиковали. Этот материал, посвященный синтаксису LINQ, — наш пробный камень вместе с программистом Дмитрием Старокожевым, работавшим в том числе над такими играми, как War Robots, Everbright и Fableborne. У него, к слову, 25 сентября стартует курс «Погружение в Unity».
Дмитрий Старокожев
Введение
За много лет работы в геймдеве, в разработке игр на Unity в частности, я неоднократно сталкивался с тем, что разработчики недолюбливают LINQ. У этого есть понятная причина. LINQ генерирует избыточное количество аллокаций. Выделяет память там, где ее можно было бы не выделять.
Библиотека часто используется в серверной разработке и сильно облегчает процесс написания кода. Если не копать глубоко, то это куча синтаксического сахара поверх коллекций. Когда нам надо посчитать сумму параметров всех элементов списка, в первую очередь хочется выбрать просто метод Sum из подсказок IDE и идти дальше. Проблема всегда была в том, что игровая разработка очень чувствительна к работе Garbage Collector. Мы до сих пор не влияем на его жизненный цикл, поэтому выгоднее избавляться от всех лишних аллокаций, чем рисковать FPS в основном геймплее. Для серверной разработки этот момент не так чувствителен, потому что, во-первых, код сервера исполняется именно на том железе, которое выбирается технической командой, а во-вторых, даже если производительности не хватает, команда может ее увеличить, добавив памяти или заменив CPU.
Давайте сравним это с разработкой игр. Сразу же можно догадаться, что коллекции на сервере и коллекции на клиенте не очень сопоставимы. Серверный код работает с базами данных, которые содержат информацию обо всех пользователях игры. Клиент же отвечает только сам за себя. Обойти свой инвентарь или обойти 20 млн инвентарей. Есть разница, да? Уже кажется, что LINQ для клиента тут чересчур «жирно» будет. Второй момент касается железа. Разработчик не может повлиять на то, какими устройствами пользуются его игроки. Разве что отсечь часть аудитории системными требованиями, но за такое продюсер откусит голову, вариант не лучший. Ну и третий пункт, самый важный в контексте этой статьи, — FPS, количество кадров в секунду.
Игра сама по себе представляет бесконечный цикл, который исполняет весь код так быстро, как только может. Из этого у нас и складывается производительность. Если весь код отрабатывает за 16 мс, мы с гордостью заявляем, что получили 60 FPS. 16 (шестнадцать) миллисекунд, КАРЛ! Все твои миллионы строк кода должны отработать за 16 мс, а потом еще раз, а потом еще раз. И тут вдруг в одном из кадров сборщик мусора вычищает всю выделенную, но уже неиспользуемую память. Это тоже требует процессорного времени, то есть он буквально крадет немножко из твоих 16 мс. Кстати, вот эти миллисекунды принято называть бюджетом на кадр. А бюджет хочется тратить разумно и по собственному желанию, без страха быть ограбленным GC.
Garbage Collector в C# и некоторых других языках отвечает за работу с памятью, снимая эту ответственность с разработчика. То есть он вроде бы наш друг. Это действительно ускоряет разработку и уменьшает количество багов и утечек памяти в финальном билде. Подробнее о его работе я предлагаю почитать в официальной документации, чтобы не останавливаться на деталях.
Эксперимент
Все написанное выше я обязательно рассказываю своим студентам во всех подробностях, но тут решил собственными руками проверить, насколько LINQ вредит разработке игры прямо сейчас. Время все-таки идет, движки и языки программирования развиваются, а проблема никогда не была большой тайной, поэтому можно предположить, что за годы моей практики что-то уже изменилось.
Для замеров производительности я установил последнюю версию Unity LTS 2022.3.40f1. В качестве языка в ней используется C# версии 9.0. Следовательно, производительность я буду смотреть именно на этом сетапе. Все тесты провожу прямо в редакторе Unity, не собирая под устройство. CMD+P, CMD+7 и погнали.
_list[0].Do(); _list.First().Do(); // LINQ
Сначала давайте посмотрим на самый банальный и не слишком полезный метод LINQ, метод First. Бесполезным я его назвал, потому что обратиться к элементу коллекции по индексу 0 не так сложно, чтобы это оборачивать в специальный метод. Отличие двух таких обращений будет в том, что LINQ использует итератор для этой цели. Тут у нас, ожидаемо, никакой разницы не наблюдается.
var find = _list.FindAll(x => x.Number < 1000000); var where = _list.Where(x => x.Number < 1000000); // LINQ
Теперь переходим к фильтрации списка. List имеет для этого метод FindAll, который использует предикат, передающийся входящим параметром, чтобы вернуть новый список. Выходит, что здесь мы тоже нарываемся на выделение памяти. Но ситуация не вполне однозначная. LINQ здесь не обвинить в избыточных аллокациях уж точно, но вот процессорного времени он жрет больше. Поэтому в реальном проекте я бы сравнил эти две реализации, а не полагался на синтетические тесты. К тому же, одно слово в строчке кода поменять, невелика задача.
_list.Sort((x, y) => x.Number.CompareTo(y.Number)); var order = _list.OrderBy(x => x.Number);
Сортировка коллекции — одна из самых частых задач для сервера, но клиенту она не настолько нужна. Более того, разработчики часто перекладывают эту задачу на сервер, который присылает клиенту уже отсортированный список. Но все же пройти мимо сортировки я не могу. И снова интересный результат. Sort использует алгоритм быстрой сортировки, здесь он наиболее эффективен. При это жрет процессор как не в себя, но ничего не аллоцирует. Он меняет порядок элементов в уже существующем списке, в то время как LINQ создает новый. Если бы в игре был один кадр, я бы выбрал LINQ. Здесь же предпочту Sort.
_list.CopyTo(new Incrementer[_list.Count], 0); var linqArray = _list.ToArray(); // LINQ
Теперь попробую привести List к простому массиву. В обоих случаях это все еще можно сделать одной строкой. С использованием LINQ строка выглядит посимпатичнее, с таким легче работать глазу, но во всем остальном практически идентичный результат. Можно не париться и спокойно использовать LINQ в этом случае.
var cSum = 0; foreach (var item in _list) { cSum += item.Number; } var linqSum = _list.Sum(x => x.Number); // LINQ
Ну и конечно, мне хочется закончить тем, с чего я начал. Метод Sum, очень полезный и соблазнительный, ибо одной строчкой его, увы, не заменишь, а задача ведь кажется такой пустяковой. Здесь результат сомнений не оставляет. LINQ проигрывает по всем параметрам. Старый добрый цикл, написанный ручками, работает эффективнее и предсказуемее. Тут всегда на помощь придет extension method, запилил и забыл, но об этом стоит написать отдельную статью, если найдутся желающие ее прочитать.
Итог
Таким образом, сравнив несколько простых кейсов работы с коллекциями, можно сделать вывод, что в 2024 году LINQ в C# версии 9.0 уже не настолько страшен, как казалось раньше. Да, библиотека действительно все еще выделяет память тогда, когда этого можно избежать. Но не всегда разработчик выигрывает в долгосрочной перспективе, отказываясь от LINQ. Сейчас это просто еще один инструмент для работы, которые необходимо тестировать в каждом конкретном случае, чтобы добиться максимальной производительности игры.
На этом все, спасибо, что дочитали до конца. Буду рад здоровой критике в комментариях. А если понравилось, то предлагайте, о чем мне написать следующую статью.
P.S. Код доступен по ссылке.