Optimizaciones IL2CPP: Desvirtualización

El equipo de la máquina virtual de scripts de Unity siempre está buscando formas de hacer que su código se ejecute más rápido. Este es el primer post de una miniserie de tres partes sobre algunas microoptimizaciones realizadas por el compilador IL2CPP AOT, y cómo puede aprovecharse de ellas. Aunque nada de lo aquí expuesto hará que el código se ejecute dos o tres veces más rápido, estas pequeñas optimizaciones pueden ayudar en partes importantes de un juego, y esperamos que le den alguna idea de cómo se está ejecutando su código.
No hay otra forma de decirlo, las llamadas a métodos virtuales son siempre más caras que las llamadas a métodos directos. Hemos estado trabajando en algunas mejoras de rendimiento en la biblioteca en tiempo de ejecución libil2cpp para reducir la sobrecarga de las llamadas a métodos virtuales (más sobre esto en el próximo post), pero aún requieren una búsqueda en tiempo de ejecución de algún tipo. El compilador no puede saber a qué método se llamará en tiempo de ejecución, ¿o sí?
La desvirtualización es una táctica común de optimización del compilador que cambia una llamada a un método virtual por una llamada a un método directo. Un compilador puede aplicar esta táctica cuando puede probar exactamente qué método real será llamado en tiempo de compilación. Desgraciadamente, este hecho puede ser a menudo difícil de probar, ya que el compilador no siempre ve toda la base de código. Pero cuando es posible, puede hacer que las llamadas a métodos virtuales sean mucho más rápidas.
Como joven desarrollador, aprendí acerca de los métodos virtuales con un ejemplo animal bastante artificioso. Puede que este código también le resulte familiar:
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";
}
}A continuación, en Unity (versión 5.3.5) podemos utilizar estas clases para hacer una pequeña granja:
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());
}
}Aquí cada llamada a Hablar es una llamada a un método virtual. Veamos si podemos convencer a IL2CPP de que desvirtualice alguna de estas llamadas a métodos para mejorar su rendimiento.
Una de las características de IL2CPP que me gusta es que genera código C++ en lugar de código ensamblador. Claro, este código no se parece al código C++ que usted escribiría a mano, pero es mucho más fácil de entender que el ensamblador. Veamos el código generado para el cuerpo de ese bucle 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);He eliminado un poco del código generado para simplificar las cosas. ¿Ve esa fea llamada a Invoke? Va a buscar el método virtual adecuado en la vtable y luego lo llamará. Esta búsqueda en la vtable será más lenta que una llamada directa a una función, pero es comprensible. El Animal puede ser una Vaca o un Cerdo, o algún otro tipo derivado.
Veamos el código generado para la segunda llamada a Debug.LogFormat, que es más bien una llamada directa a un método:
// 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);
¡Incluso en este caso seguimos haciendo la llamada al método virtual! IL2CPP es bastante conservador con las optimizaciones, prefiriendo asegurar la corrección en la mayoría de los casos. Dado que no realiza el suficiente análisis de todo el programa para estar seguro de que puede tratarse de una llamada directa, opta por la más segura (y lenta) llamada a un método virtual.
Supongamos que sabemos que no hay otros tipos de vacas en nuestra granja, por lo que ningún tipo derivará de Vaca. Si hacemos explícito este conocimiento al compilador, podemos obtener un mejor resultado. Cambiemos la clase para que se defina así:
public sealed class Cow : Animal {
public override string Speak() {
return "Moo";
}
}La palabra clave sealed indica al compilador que nadie puede derivar de Cow (sealed también podría utilizarse directamente en el método Speak). Ahora IL2CPP tendrá la confianza para hacer una llamada directa al método:
// 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);La llamada a Hablar aquí no será innecesariamente lenta, ya que hemos sido muy explícitos con el compilador y le hemos permitido optimizar con confianza.
Este tipo de optimización no hará que su juego sea increíblemente más rápido, pero es una buena práctica expresar cualquier suposición que tenga sobre el código en el código, tanto para los futuros lectores humanos de ese código como para los compiladores. Si está compilando con IL2CPP, le animo a que examine detenidamente el código C++ generado en su proyecto y vea qué más puede encontrar.
La próxima vez hablaremos de por qué las llamadas a métodos virtuales son caras, y de lo que estamos haciendo para que sean más rápidas.
