Недавно анализатор PVS-Studio начал выдавать предупреждения о возможностях оптимизации кода в проектах под Unity Engine. Какие они, эти предупреждения? Как анализатор понимает, какой код стоит оптимизировать? Почему это сделано именно для Unity? Ответы в заметке.
На момент написания этой статьи в PVS-Studio есть 4 правила, указывающих на возможности оптимизации кода проектов под Unity:
Эти простые на первый взгляд правила были сделаны на основе официальных рекомендаций в документации к Unity Engine.
Указанные диагностические правила находятся в группе Optimization. Их можно включать и выключать в настройках. По умолчанию правила из этой группы включены. Также стоит отметить, что описанные здесь диагностики работают исключительно на проектах под Unity Engine (в следующем разделе станет ясно, почему).
Главной особенностью этих диагностик является то, что они стараются выдавать предупреждения исключительно на код, который потенциально выполняется часто. Очевидно, что оптимизировать такой код полезно.
Примечание
Конечно, мы могли бы сделать правила, которые выдавали бы предупреждения, скажем, вообще на все случаи упаковки или захвата переменных. Это привело бы к тому, что пользователям PVS-Studio пришлось бы разбирать просто тонны срабатываний. При этом большая часть из них была бы не очень интересна.
Именно поэтому мы стараемся минимизировать количество предупреждений, указывающих на места, где оптимизация точно не принесёт видимого результата.
На первый взгляд всё просто. Проекты под Unity Engine содержат большое количество специальных методов, которые вызываются очень часто (например, Update, UpdateFixed и другие). В первую очередь именно код в этих методах PVS-Studio будет проверять на возможность внесения оптимизаций.
Однако внутри этих "первичных" часто вызываемых методов тоже могут быть различные вызовы. Пример:
class Test : MonoBehaviour
{
struct ValueStruct { int a; int b; }
ValueStruct _previousValue;
void Update()
{
ValueStruct newValue = ....
if (CheckValue(newValue))
....
}
bool CheckValue(ValueStruct value)
{
if(_previousValue.Equals(value))
....
}
}
Код в методе Update выполняется каждый кадр, поэтому различные оптимизации кода для него актуальны. Однако в самом методе Update оптимизировать нечего — здесь есть лишь обычное присваивание и вызов метода. С другой стороны, очевидно, что код метода CheckValue выполняется так же часто, как и Update.
Получается, что оптимизация метода CheckValue также была бы полезна. Что же можно тут оптимизировать?
Метод Equals, вызываемый у _previousValue, принимает в качестве аргумента тип object. Соответственно, при передаче value будет произведена упаковка. Чтобы её избежать, нужно лишь добавить в определение структуры ValueStruct метод Equals, принимающий в качестве аргумента тип ValueStruct.
За счёт подобного анализа вызовов PVS-Studio и понимает, какой код может нуждаться в оптимизации. Для предыдущего примера диагностика V4001 сгенерировала бы предупреждение, указывающее, что в методе Update есть вызов CheckValue, в котором производится упаковка:
V4001. The frequently called 'Update' method contains the 'CheckValue(newValue)' call which performs boxing. This may decrease performance.
Из сообщения может быть неясно, где конкретно производится упаковка. Однако предупреждение содержит в себе все позиции, связанные со срабатыванием. Для указанного примера это будут:
Средства просмотра отчёта анализатора (например, плагины для Visual Studio, VS Code, Rider) позволяют легко переходить к фрагментам кода, о которых говорит предупреждение. Это позволяет понять, где именно производится упаковка (или другая операция), которую можно оптимизировать.
Глубина анализа
После прочтения предыдущего раздела может возникнуть вопрос: А что, если код, нуждающийся в оптимизации, будет глубже?
К примеру, в методе Update может вызываться метод Foo, внутри которого может вызываться метод Foo2, внутри которого может вызываться Foo3 (и так далее). И в некотором FooN в этой цепочке вызовов производится, скажем, упаковка.
В этом случае анализатор также выдаст предупреждение о возможности оптимизации. Глубина вызова для PVS-Studio не важна. Важно лишь, чтобы код напрямую или опосредовано был связан с методом Update или подобным ему.
В прошлом разделе мы рассмотрели методы, подобные Update и вызываемые очень часто. Можно ли из этого сделать вывод, что весь код в таком методе будет часто выполняться?
Конечно же, нет. Практически всегда в коде можно заметить ветвления и циклы, из-за чего частота выполнения различных фрагментов будет разной. Анализатор старается учитывать это, но обычно нельзя предсказать, часто ли условие имеет значение true. Однако есть ряд паттернов, в которых PVS-Studio чётко видит код, выполняющийся редко.
Например, код может выполняться только при нажатии какой-то кнопки (т. е. когда Input.GetKeyDown или GUI.Button возвращают true). Скорее всего, оптимизации в таком коде не принесут больших результатов. Безусловно, из этого правила могут быть исключения, но анализатор всё-таки должен ориентироваться на общий случай.
Другой кейс — когда код производит инициализацию, выполняемую единожды (или по крайней мере, редко). Пример:
class Test : MonoBehaviour
{
private bool _initialized;
void Update()
{
if (!_initialized)
{
Initialize();
_initialized = true;
}
}
}
В данном примере видно, что Initialize вызывается, только если поле имеет значение false. Сразу после вызова полю присваивается значение true. Резонно предположить, что при последующих вызовах Update метод Initialize отрабатывать не будет. Соответственно, микрооптимизации внутри него, скорее всего, не принесут заметных результатов. Поэтому и предупреждений на тему производительности внутри Initialize не будет.
Есть и другие случаи, когда анализатор избегает выдачи предупреждений. PVS-Studio старается указывать только на фрагменты, которые действительно можно и нужно оптимизировать.
К примеру, правило V4002 указывает на возможность использования StringBuilder вместо конкатенации строк. Однако оно не будет ругаться на все конкатенации подряд. Вместо этого правило отслеживает случаи многократного добавления строк к одной и той же переменной.
Как обычно, мы тестировали диагностики на разных Open Source проектах и вносили коррективы в анализатор на основе получаемых результатов. В итоге, кажется, получилось добиться выдачи неплохих ненавязчивых советов по микрооптимизациям на разных проектах.
Например, на проекте Daggerfall правило V4001 указало на ряд случаев упаковки при вызове метода string.Format. Один из них представлен ниже:
public static string GetTerrainName(int mapPixelX, int mapPixelY)
{
return string.Format("DaggerfallTerrain [{0},{1}]",
mapPixelX,
mapPixelY);
}
Здесь вызывается перегрузка string.Format, имеющая сигнатуру string.Format(string, object, object). Соответственно, при вызове будет производится упаковка, что может негативно сказываться на производительности. При этом избавиться от упаковки легко — достаточно лишь вызвать у переменных mapPixelX и mapPixelY метод ToString.
Судя по моим экспериментам, нет. Сперва я решил просто посмотреть IL — там вполне чётко видно команды 'box'. Потом я решил попробовать в runtime — вдруг оптимизацию выполняет JIT?
Я использовал профилировщик, встроенный в Visual Studio, чтобы проверить наличие разницы при использовании ToString и без него. Заставив простое приложение вызвать string.Format некоторое количество раз, я увидел, что количество аллокаций при использовании ToString колоссально меньше. Из этого можно сделать вывод, что вызывать ToString у аргументов string.Format определённо имеет смысл (для значимых типов, конечно).
Вызов GetTerrainName опосредованно производится из метода Update класса StreamingWorld. Сложно сказать, действительно ли сам GetTerrainName вызывается часто, но обратить внимание на фрагмент стоит.
Ещё одним примером предлагаемых микрооптимизаций являются предупреждения V4003 о захвате переменных на проекте jyx2:
public BattleBlockData GetBlockData(int xindex, int yindex)
{
return _battleBlocks.FirstOrDefault(x => x.BattlePos.X == xindex
&& x.BattlePos.Y == yindex);
}
public BattleBlockData GetRangelockData(int xindex, int yindex)
{
return _rangeLayerBlocks.FirstOrDefault(x => x.BattlePos.X == xindex
&& x.BattlePos.Y == yindex);
}
Анонимные функции, использованные в этих методах, захватывают переменные xindex и yindex. Соответственно, при каждом вызове будет производиться создание дополнительного объекта, чего вполне легко можно тут избежать, переписав вызовы FirstOrDefault на foreach.
А в проекте hogwarts правило V4002 обнаружило хорошее место для использования StringBuilder:
private void OnGUI()
{
if (!this.pView.isMine)
{
return;
}
string subscribedAndActiveCells = "Inside cells:\n";
string subscribedCells = "Subscribed cells:\n";
for (int index = 0; index < this.activeCells.Count; ++index)
{
if (index <= this.cullArea.NumberOfSubdivisions)
{
subscribedAndActiveCells += this.activeCells[index] + " | ";
}
subscribedCells += this.activeCells[index] + " | ";
}
....
}
И на этих, и на других проектах есть ещё ряд предупреждений анализатора, но думаю, что для первой демонстрации достаточно и того, что я уже показал. Если вам интересно посмотреть, какие советы по оптимизации даст PVS-Studio для других проектов на Unity (например, вашего), то можете бесплатно загрузить анализатор здесь.
Также стоит сказать, что мы находимся в поиске идей для новых правил. Если у вас есть мысли по поводу того, что было бы полезно искать с помощью анализатора, то пишите в комментариях :).
Спасибо за прочтение и удачи!