Официальная часть мероприятия
Добрый день. Я работаю программистом в компании Owlcat Games, которая выпустила одну из самых успешных российских компьютерных RPG Pathfinder: Kingmaker и сейчас работает над её продолжением, Pathfinder: Wrath of the Righteous. В ходе портирования первой игры нашей студии на консоли, мы столкнулись с проблемой поиска утечек памяти. Штатные инструменты движка Unity и целевых платформ оказались по разным причинам не слишком удобны для борьбы с утечками, и поэтому мы решили написать свой инструмент, о котором я и расскажу ниже.
Owlcat Mono Profiler предназначен для исследования использования памяти Mono в играх на движке Unity. Он доступен всем желающим в виде собранных бинарных файлов (под Windows) и исходного кода на Github. В отличие от встроенного профайлера Unity, а также пакета Memory Profiler, он не требует снятия снимков состояния памяти, а производит постоянный мониторинг Mono-кучи, что позволяет выявлять не только утечки, но и пики аллокаций, и избыточные повторяющиеся аллокации. По сравнению с платформо-специфичными инструментами, такими как Memory Analyzer для PS4, он корректно отображает события, происходящие с памятью, управляемой сборщиком мусора.
На этом покончим с формальностями, и перейдём к cool story.
Фатальный недостаток всех прочих инструментов
Началось всё с того, что мы выяснили, что память в нашей игре подтекает. На PC это не было проблемой, поскольку течёт она не то чтобы водопадом, да и памяти даже на слабых машинах в наше время будет поболее, чем у PlayStation 4 или XBox One. Плюс, Windows, когда кончается память, начинает скидывать лишнее в своп, а консоли - просто убивают твоё приложение, и иди, разбирайся, где накосячил.
Встроенные инструменты Unity пришлось отмести практически сразу: в Unity 2018.4 они с нашей игрой фактически не работали (снятие одного снимка состояния памяти могло занять 8+ часов, а на PlayStation мне ни разу не удалось его дождаться в принципе). В 2019.x стало сильно лучше, но перейти на неё мы не могли - смена мажорной версии движка в Unity ломает слишком многое.
В комплект инструментов для PlayStation 4 входит совершенно потрясающий Memory Analyzer. Серьёзно, это один из лучших инструментов для анализа потребление памяти, какие я только видел (хотя и не лишённый некоторых мелких недостатков). Уже одна только возможность помечать любые функции с подходящей сигнатурой как alloc/realloc/free делает его невероятно полезным для любой игры, использующей собственные аллокаторы, memory pool'ы и т.п.
Но есть проблема. Дело в том, что Mono, в том виде, в каком его используют в Юнити, содержит в себе видавший виды сборщик мусора BoehmGC. Это проверенный временем проект, но к сожалению он написан таким образом, что во многом представляет из себя помесь чёрного ящика и чёрной дыры, в которую можно что-то засунуть, но нельзя достать. В частности, он не предоставляет никакого способа узнать о моменте удаления объекта.
Почему сложно написать профайлер памяти для Unity
А теперь давайте сделаем шаг назад и посмотрим, как вообще работает сборщик мусора. Я до поступления в команду Owlcat Games работал, в основном, с C++, поэтому чисто теоретически про сборку мусора что-то знал, но на практике с ней не сталкивался, и имел, как потом выяснилось, в корне неверные представления о том, как этот зверь устроен. Если вы в этой области собаку съели, то дальнейшие мои объяснения вам покажутся чересчур упрощёнными и может быть даже в чём-то ошибочными, но надеюсь они подойдут для объяснения той простой мысли, что написать профайлер памяти для языка с GC - это вам не два байта переслать.
Итак, что делает сборщик мусора? Он берёт себе у системы кусок памяти… И никогда его не возвращает (во всяком случае, именно так ведёт себя BoehmGC на PS4). В этом куске памяти, он по запросу пользователя выделяет маленькие кусочки под конкретные объекты - там тоже не всё так просто, но это не важно. Важно что факт аллокации памяти отследить очень просто - есть несколько функций, которые прямо так и называются, gc_malloc_что_нибудь. А вот факт деаллокации памяти отследить слегка сложнее. Это в C++ кто-то должен сказать объекту "умри". Тут же про него просто "забывают", то есть, перестают на него ссылаться. Конечно, сборщик мусора не следит за всеми записями в память, чтобы заметить, что последняя ссылка на объект протухла. Вместо этого, раз-в-сколько-то времени (на самом деле - обычно когда для очередной аллокации не хватает памяти) он говорит "так, всем стоять, сейчас я разберусь, кто тут живой, а кто мёртвый", и отправляется шерстить всю выделенную памяти в поисках ссылок на объекты. В конце этого процесса, если есть какие-то объекты, на которые он ссылок не нашёл, их-то он и удаляет, а точнее - помечает их память как свободную и доступную для выделения.
Всё выше сказанное плюс особенности BoehmGC означает две вещи: во-первых, факт смерти объекта неплохо так (порой на десятки секунд) удалён от момента потери последней ссылки на него, и во-вторых, хрен нам, а не событие "объект удалён". Сколько я не пялился в код BoehmGC, так мне и не удалось в моём скудоумии прозреть, где же, собственно, тот волшебный момент, куда можно было бы вставить какую-то закладку, которая бы сообщила наружу, что память, которую занимал объект X, более ему не принадлежит, то есть, он мёртв. Впрочем, даже если бы я его нашёл, вряд ли бы мне это помогло, потому что по условиям задачи менять код BoehmGC у нас возможности не было - на PlayStation его просто невозможно скомпилировать самому, да и на остальных платформах это то ещё приключение (потому что компилировать придётся не только сам BoehmGC, но и Mono).
Бьёмся головой в стену.
Следующей мыслью, пришедшей мне в голову, было добавить всем вообще аллоцируемым объектам финалайзеры. Финалайзер - это такая функция, которая как раз обязательно вызывается, когда объект удаляется, вроде деструктора в C++. Но и на этом пути меня не ждала победа: да, в рамках il2cpp, имеющего открытый исходный код, я мог вставить свой костыль, но это нельзя было делать оголтело, ведь у объекта УЖЕ мог быть финалайзер, а значит надо было его как-то извлекать, запоминать и подменять своим… Наверное, если копать в этом направлении дольше, может что-то и получилось бы, но сама идея менять исходный код кусков Unity мне не нравилась, не говоря уже о том, что это решение не заработало бы на PC, где мы не используем il2cpp во имя лёгкости моддинга игры.
Дальше я отправился в Гугл, искать, а как вообще люди профилируют память в Mono? Ответ нашёлся на первой странице Гугла, в официальной документации. Вот только в версии Mono, используемой Unity, описанный там встроенный профайлер был благополучно выпилен. Кроме того, поиск так же показал, что почти все средства анализа логов, снятых при помощи встроенного профайлера, заброшены, устарели или недописаны, так что особой надежды на них не было, даже если бы мне удалось как-то вернуть эту функциональность (например, пересобрав Mono для Unity - что, правда, не сработало бы на PlayStation!).
Мы пойдём другим путём!
Однако, бродя по дебрям сети, я наткнулся на Heap-Prof, давно неактуальный и заброшенный профайлер памяти для Mono, из которого, однако, мне удалось почерпнуть интересную идею. Идея заключалась в том, чтобы тупо повторять всю работу, которую делает реальный сборщик мусора:
Регистрировать аллокации, когда они происходят, создавать событие "объект создан".
Ловить события сборки мусора (типа "сборка мусора завершена") и в этот момент проверять, какие из наших объектов всё ещё живы. Для всех, кто не жив - создавать событие "объект удалён".
Довольно быстро, я перенёс и осовременил код heap-prof в dll, которую подгрузил плагином к Юнити, достал при помощи GetProcAddress функции Mono, позволяющие всё это проделать, и… И игра упала. В функции mono_object_is_alive. Попытки понять, от чего такое происходит, и как вообще эта функция работает, привели меня к письму одного из авторов Mono, Massimiliano Mantione, опубликованному в почтовой рассылке Mono-dev в 2009 году. Во сием послании, он в точности описывал мои проблемы с heap-prof, и в частности писал, "The problem is that this is not reliable: "mono_object_is_alive" was not meant to be a public function. And in fact sometimes the heap snapshots are wrong (or the profiler crashes).". К сожалению, в качестве решения он предлагал улучшить API для профайлера в НОВОМ сборщике мусора, SGen, на который Unity в своей версии Mono так никогда и не перешли…
Тогда суровые русские программисты (в лице меня) глубоко задумались, и решили: хорошо, на mono_object_is_alive для определения живости объекта полагаться нельзя. Но как-то же сам сборщик мусора знает, жив у него объект, или нет?! Надо просто скопировать его подход, и тогда мы должны будем получить тот же результат (и без падений).
Тут надо сделать очередное небольшое отступление, и рассказать о том, как рассуждал Шульц как сборщик мусора, собственно, ищет ссылки на объекты. Опять же, в ОЧЕНЬ упрощённом виде. Ищет он их тупо - берёт память какого-нибудь объекта, и прямо по ней идёт и смотрит - вот это значение похоже на адрес в куче? Есть у нас объект с таким адресом вообще? Если на оба вопроса "да" - то это ссылка на объект, и этот объект смело можно помечать как живой. Острые умом подметят, что если эдак пройтись по всей куче итеративно несколько раз - не останется ни одного объекта (например, если в куче всего два объекта, A и B, A ссылается на B, то мы сначала удалим A - потому что на него никто не ссылается, а на следующей итерации удалим и B, потому что теперь на него тоже никто не ссылается). Для этого у сборщика мусора есть корневые объекты, которые удалять обычным образом нельзя, и вот от них уже идут ссылки на всех остальных.
BoehmGC, к сожалению, представляет собой упомянутую чёрную дыру - зарегистрировать в нём корневые объекты можно, а вот спросить у него, какие корни зарегистрированы - никак нельзя. Но Mono решает эту проблему за нас, и вызывает коллбэки каждый раз, когда регистрирует или удаляет корневые объекты. А я-то уж было приготовился лезть в переменную с адресами корневых объектов по отступу в памяти… Ладно, прячем шашку в штаны и продолжаем наши экзерсисы.
"Я вас настиг! Какой я молодец…"
Дальше всё стало делом техники и очень большого количества отладки. Каждый раз, когда приходит событие об окончании сборки мусора, мой "псевдо-сборщик мусора" повторяет эту работу. Берёт корневые объекты, идёт по их памяти, если видит в ней адрес известного нам объекта - помечает его как живой и добавляет в список на обработку, чтобы просканировать потом и его память, и так пока не обойдёт все живые объекты. После этого, все не помеченные считаются удалёнными.
Помимо, собственно, поиска умерших объектов, этот подход позволяет также составить граф объектов и по запросу проследить, кто ссылается на данный объект, что бывает очень полезно, когда не понятно, почему тот или иной объект застрял в памяти (аналогичная функция есть и во встроенном профайлере Unity).
Имея поток событий об аллокациях и освобождениях памяти, мы можем его записать, и, проиграв заново любую его часть, получить список живых объектов на тот момент времени. Это позволяет не заботиться о конкретном моменте снятия снимка памяти, а записывать, например, длинную игровую сессию, и потом анализировать потребление игрой памяти в любой её части.
Помимо прочих достоинств, наш профайлер умеет профайлить релизные билды игры, и даже без необходимости заранее встраивать плагин в сборку (можно и чужие игры профайлить, если у вас возникло желание помочь авторам). Достаточно только иметь PDB файл от нужной версии Unity Player: он нужен для того, чтобы достать адреса некоторых функций, которые нужно перехватить, в частности, для того, чтобы вовремя запустить профайлер, а также для получения событий об окончании кадра (события для удобства группируются по кадрам, а не по времени). К сожалению, Unity не предоставляет даже графическим плагинам возможности узнать о конце кадра другим способом, так что пришлось взять в руки Microsoft Detours и лезть в недра.
Есть у выбранного подхода и недостатки. Профайлер довольно заметно замедляет игру, процентов на 20 в обычных кадрах, а в момент сборки мусора может подвесить её даже на 5-10 секунд (в зависимости от количества объектов). Также, для профайлера требуется довольно много памяти на той машине, где, собственно, запущена игра: на ~2 миллиона аллокаций нужно ~200Mb памяти. Для базы клиента/UI, может потребоваться до нескольких гигабайт памяти, что представляется несущественным ограничением, так как в крайнем случае, можно запускать клиент/UI профайлера на другой машине (он соединяется с самим профайлером по сети).
По обоим важным показателям (замедление игры и дополнительная память) есть планы по их улучшению.
Текущая версия профайлера имеет интерфейс, написанный на Qt5, и теоретически должна быть относительно легко портируема на другие операционные системы (это в наших планах, но не в приоритете, так как основная часть разработчиков игр, всё-таки, работает под операционной системой Microsoft). В качестве БД для хранения событий, используется SQLite с временными (частично находящимися в памяти) базами, но есть идеи о переходе на memory mapped database для ещё большей скорости. Я обдумывал возможность интеграции профайлера в саму Unity, но это представляется не идеальным решением, так как иногда хочется попрофайлить игру в редакторе, не собирая билдов (когда пробуешь разные варианты исправлений, например), а в этом случае, профайлер, встроенный в редактор и потому также производящий аллокации managed памяти - очень плохая идея.
Дальнейшие планы
Профайлер открыт для свободного использования всеми желающими. Я надеюсь, что он окажется полезным кому-то кроме нашей компании. Несомненно, найдутся в нём и ошибки, которые надо исправлять, и возможные улучшения интерфейса и функционала. Жду ваших предложений (и пулл-риквестов!) на Гитхабе. Я надеюсь, что эта программа станет первой частью нашего инструментария для отладки игр на Unity, Owlcat Grooming Toolkit. В отдалённых планах есть так же CPU профайлер с открытым исходным кодом, который мог бы стать бесплатной альтернативой dotTrace, которую можно было бы раздавать игрокам для диагностики без зазрения совести.
Nomad1
Классно! Инструменты лишними не бывают никогда. А без Unity использовать можно? Чтобы отлаживать утечки память в произвольных Mono проектах?
MaxEdZX Автор
В теории — можно, на практике пока нет. Проблема средних размеров, но её надо решить, прежде чем всё заработает: в произвольном Моно-проекте никаких кадров нет, и там надо бы группировать аллокации таки по времени с какой-то гранулярностью. Это потребует небольшой переделки DLLки профайлера, и чуть более геморойной — UI. Возможно, правильнее было бы сделать форк для «просто Mono», ну, или всё-таки отдельный режим. В общем, для меня лично это всё несколько out of scope по причине нехватки времени и сил, но пулл-риквесты приму :)
Nomad1
Ну а если речь об игре на MonoGame, OpenTK и т.д. — можно привязаться к какому-нибудь SwapBuffers() или там IDXGISwapChain::Present() для разделения по кадрам?
MaxEdZX Автор
Да, наверное можно. Я почему не стал этого делать для Юнити — чтобы в рантайме не разбираться, что там включил разработчик — DirectX, OpenGL или Vulkan вообще. Возможно хорошим решением была бы какая-то система плагинов, которая бы для разных движков хукала разные функции самого движка (т.к. многие движки умеют разные рендереры) + отдельный режим без кадров.
EDIT: Если подумать, то вообще достаточно имя функции для перехвата в настройках, так-то…
Nomad1
Ну вот если бы была относительно простая возможность задать хук (или вручную вызвать какую-то функцию как разделитель кадров) и стартовать без Unity, я бы проверил на DX9, DX11, OpenGL и Vulkan, а может еще и на сервере. Но т.к. у меня нет Unity и в целом аллергия на него, то я даже не могу запустить профайлер и посмотреть его работу в живых условиях. А без этого патчить полностью незнакомый проект как-то не удобно.
MaxEdZX Автор
Не могу обещать, что скоро добавлю возможность, но можно Issue создать. Я просто собаку завёл недавно (после того, как основную часть кода профайлера дописал), и сейчас ни на что времени не хватает :) Как всё устаканится, вернусь к задачам обязательно.