Оптимизация IL2CPP: Девиртуализация

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jul 26, 2016|5 Мин
Оптимизация IL2CPP: Девиртуализация
Эта веб-страница была переведена с помощью машинного перевода для вашего удобства. Мы не можем гарантировать точность или надежность переведенного контента. Если у вас есть вопросы о точности переведенного контента, обращайтесь к официальной английской версии веб-страницы.

Команда разработчиков виртуальной машины сценариев в Unity постоянно ищет способы заставить Ваш код работать быстрее. Это первый пост из трех частей мини-сериала о нескольких микро-оптимизациях, выполняемых компилятором IL2CPP AOT, и о том, как Вы можете воспользоваться их преимуществами. Хотя ничто здесь не заставит код работать в два или три раза быстрее, эти небольшие оптимизации могут помочь в важных частях игры, и мы надеемся, что они дадут Вам некоторое представление о том, как выполняется Ваш код.

Девиртуализация

По-другому и не скажешь: вызов виртуальных методов всегда обходится дороже, чем вызов прямых методов. Мы работали над некоторыми улучшениями производительности в библиотеке времени выполнения libil2cpp, чтобы сократить накладные расходы на вызовы виртуальных методов (подробнее об этом в следующем посте), но они по-прежнему требуют какого-то поиска во время выполнения. Компилятор не может знать, какой метод будет вызван во время выполнения - или может?

Девиртуализация - это распространенная тактика оптимизации компилятора, которая меняет вызов виртуального метода на прямой вызов метода. Компилятор может применить эту тактику, когда он может точно доказать, какой именно метод будет вызван во время компиляции. К сожалению, этот факт часто бывает трудно доказать, поскольку компилятор не всегда видит всю кодовую базу. Но когда это возможно, это может сделать вызовы виртуальных методов намного быстрее.

Канонический пример

Будучи молодым разработчиком, я узнал о виртуальных методах на довольно надуманном примере с животными. Этот код может быть Вам знаком:

public abstract class Animal {
  public abstract string Speak();
}

public class Cow : Animal {
   public override string Speak() {
       return "Moo";
   }
}

public class Pig : Animal {
    public override string Speak() {
        return "Oink";
   }
}

Затем в Unity (версия 5.3.5) мы можем использовать эти классы для создания небольшой фермы:

public class Farm: MonoBehaviour {
   void Start () {
       Animal[] animals = new Animal[] {new Cow(), new Pig()};
       foreach (var animal in animals)
           Debug.LogFormat("Some animal says '{0}'", animal.Speak());

       var cow = new Cow();
       Debug.LogFormat("The cow says '{0}'", cow.Speak());
   }
}

Здесь каждый вызов Speak - это вызов виртуального метода. Давайте посмотрим, сможем ли мы убедить IL2CPP девиртуализировать любой из этих вызовов методов, чтобы улучшить их производительность.

Сгенерированный код на C++ не так уж плох

Одна из особенностей IL2CPP, которая мне нравится, заключается в том, что он генерирует код на C++ вместо кода на ассемблере. Конечно, этот код не похож на код на C++, который Вы написали бы вручную, но его гораздо легче понять, чем ассемблер. Давайте посмотрим сгенерированный код для тела этого цикла for each:

// Set up a local variable to point to the animal array
AnimalU5BU5D_t2837741914* L_5 = V_2;
int32_t L_6 = V_3;
int32_t L_7 = L_6;

// Get the current animal from the array
V_1 = ((L_5)->GetAt(static_cast<il2cpp_array_size_t>(L_7)));
Animal_t3277885659 * L_9 = V_1;

// Call the Speak method
String_t* L_10 = VirtFuncInvoker0< String_t* >::Invoke(4 /* System.String AssemblyCSharp.Animal::Speak() */, L_9);

Я удалил часть сгенерированного кода, чтобы упростить ситуацию. Видите этот уродливый вызов Invoke? Он найдет нужный виртуальный метод в vtable и вызовет его. Такой поиск в таблице vtable будет медленнее, чем прямой вызов функции, но это вполне объяснимо. Животное может быть коровой, свиньей или другим производным типом.

Давайте посмотрим на сгенерированный код для второго вызова Debug.LogFormat, который больше похож на прямой вызов метода:

// Create a new cow
Cow_t1312235562 * L_14 = (Cow_t1312235562 *)il2cpp_codegen_object_new(Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;

// Call the Speak method
String_t* L_17 = VirtFuncInvoker0< String_t* >::Invoke(4 /* System.String AssemblyCSharp.Cow::Speak() */, L_16);

Даже в этом случае мы все еще выполняем вызов виртуального метода! IL2CPP довольно консервативен в вопросах оптимизации, предпочитая в большинстве случаев обеспечивать корректность. Поскольку он не проводит достаточного анализа всей программы, чтобы быть уверенным, что это может быть прямой вызов, он выбирает более безопасный (и более медленный) вызов виртуального метода.

Предположим, мы знаем, что на нашей ферме нет других типов коров, поэтому ни один тип никогда не произойдет от Cow. Если мы сделаем эти знания явными для компилятора, мы сможем получить лучший результат. Давайте изменим класс, чтобы он был определен следующим образом:

public sealed class Cow : Animal {
   public override string Speak() {
       return "Moo";
   }
}

Ключевое слово sealed сообщает компилятору, что никто не может получить производное от Cow (sealed также может быть использовано непосредственно в методе Speak). Теперь IL2CPP будет уверен в том, что сможет сделать прямой вызов метода:

// Create a new cow
Cow_t1312235562 * L_14 = (Cow_t1312235562 *)il2cpp_codegen_object_new(Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;

// Look ma, no virtual call!
String_t* L_17 = Cow_Speak_m1607867742(L_16, /*hidden argument*/NULL);

Вызов Speak здесь не будет излишне медленным, поскольку мы были очень откровенны с компилятором и позволили ему оптимизировать с уверенностью.

Такая оптимизация не сделает Вашу игру невероятно быстрой, но это хорошая практика - выражать все свои предположения о коде в коде, как для будущих читателей этого кода, так и для компиляторов. Если Вы компилируете с помощью IL2CPP, я рекомендую Вам просмотреть сгенерированный код C++ в Вашем проекте и посмотреть, что еще Вы можете найти!

В следующий раз мы обсудим, почему вызовы виртуальных методов обходятся дорого, и что мы делаем, чтобы сделать их быстрее.