10000 llamadas a Update()

VALENTIN SIMONOV / UNITY TECHNOLOGIESCollaborator
Dec 23, 2015|9 minutos
10000 llamadas a Update()
Para tu comodidad, tradujimos esta página mediante traducción automática. No podemos garantizar la precisión ni la confiabilidad del contenido traducido. Si tienes alguna duda sobre la precisión del contenido traducido, consulta la versión oficial en inglés de la página web.
Unity tiene el llamado sistema de Mensajería que te permite definir un montón de métodos mágicos en tus scripts que serán llamados en eventos específicos mientras tu juego está corriendo. Se trata de un concepto muy sencillo y fácil de entender, especialmente bueno para los nuevos usuarios. Sólo tienes que definir un método de actualización como éste y se llamará una vez por fotograma.

Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.

Para un desarrollador experimentado este código es un poco extraño.

1. No está claro cómo se llama exactamente a este método.

2. No está claro en qué orden se llaman estos métodos si tienes varios objetos en una escena.

3. Este estilo de código no funciona con intellisense.

Cómo se llama la actualización

No, Unity no utiliza System.Reflection para encontrar un método mágico cada vez que necesita llamar a uno.

En su lugar, la primera vez que se accede a un MonoBehaviour de un tipo determinado se inspecciona el script subyacente a través del tiempo de ejecución de scripts (ya sea Mono o IL2CPP) para ver si tiene algún método mágico definido y esta información se almacena en caché. Si un MonoBehaviour tiene un método específico se añade a una lista apropiada, por ejemplo si un script tiene definido el método Update se añade a una lista de scripts que necesitan ser actualizados cada frame.

Durante el juego Unity sólo itera a través de estas listas y ejecuta los métodos de la misma - así de simple. Además, esta es la razón por la que no importa si tu método Update es público o privado.

En qué orden se ejecutan las actualizaciones

La orden se especifica mediante la configuración de la orden de ejecución del script (menú: Editar > Configuración del proyecto > Orden de ejecución del script). Puede que no sea la mejor forma de establecer manualmente el orden de 1000 scripts pero si quieres que un script se ejecute después de todos los demás esta forma es aceptable. Por supuesto, en el futuro queremos tener una forma más conveniente de especificar el orden de ejecución, utilizando un atributo en el código, por ejemplo.

No funciona con intellisense

Todos usamos un IDE de algún tipo para editar nuestros scripts de C# en Unity, a la mayoría de ellos no les gustan los métodos mágicos para los que no pueden averiguar dónde son llamados, si es que lo hacen. Esto genera advertencias y dificulta la navegación por el código.

A veces los desarrolladores añaden una clase abstracta que extiende MonoBehaviour, la llaman BaseMonoBehaviour o similar y hacen que cada script en su proyecto extienda esta clase. Pusieron algunas funcionalidades básicas útiles en él junto con un montón de métodos mágicos virtuales así:

Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.

Esta estructura hace que el uso de MonoBehaviours en su código sea más lógico, pero tiene un pequeño defecto. Apuesto a que ya te lo has imaginado...

Todos tus MonoBehaviours estarán en todas las listas de actualización que Unity usa internamente, todos estos métodos serán llamados cada frame por todos tus scripts, ¡en su mayoría sin hacer nada en absoluto!

Cabe preguntarse por qué debería alguien preocuparse por un método vacío. La cuestión es que estas son las llamadas desde la tierra nativa de C++ a la tierra gestionada de C#, tienen un coste. Veamos cuál es este coste.

Llamada a 10000 actualizaciones

Para este post he creado un pequeño proyecto de ejemplo que está disponible en Github. Tiene 2 escenas que se pueden cambiar tocando en un dispositivo o pulsando cualquier tecla del editor:

(1) En la primera escena se crean 10000 MonoBehaviours con este código en su interior:

Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.

(2) En la segunda escena se crean otros 10000 MonoBehaviours pero en lugar de tener un Update tienen un método personalizado UpdateMe que es llamado por un script gestor cada frame de esta forma:

Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.

El proyecto de prueba se ejecutó en 2 dispositivos iOS compilados con Mono e IL2CPP en modo no Desarrollo en configuración Release. El tiempo se midió de la siguiente manera:

Configure un Cronómetro en la primera Actualización llamada (configurada en Orden de Ejecución de Script),

Detener el cronómetro en LateUpdate,

Haz una media de los tiempos durante unos minutos.

Versión Unity: 5.2.2f1
Versión para iOS: 9.0

Mono
Imagen

¡WOW! ¡Esto es mucho! Debe de haber algún problema con la prueba.

En realidad, se me olvidó poner la Optimización de Llamadas de Script en Rápido pero sin Excepciones, pero ahora podemos ver qué impacto tiene esta configuración en el rendimiento... no es que a nadie le importe ya con IL2CPP.

Mono (rápido pero sin excepciones)
Imagen

Vale, esto está mejor. Cambiemos a IL2CPP.

IL2CPP
ImageImage

Aquí vemos dos cosas:

1. Esta optimización concreta sigue teniendo sentido en IL2CPP.

2. IL2CPP todavía tiene margen de mejora y mientras escribo este post los equipos de Scripting e IL2CPP están trabajando duro para aumentar el rendimiento. Por ejemplo, la última rama Scripting contiene optimizaciones que hacen que las pruebas se ejecuten un 35% más rápido.

En unos momentos explicaré lo que hace Unity bajo el capó. Pero ahora vamos a cambiar el código de nuestro Gestor para que sea 5 veces más rápido.

Llamadas a interfaces, llamadas virtuales y acceso a matrices

Si aún no has leído esta magnífica serie de posts sobre las funciones internas de IL2CPP, deberías hacerlo en cuanto termines de leer este.

Resulta que si quisieras iterar a través de una lista de 10000 elementos cada fotograma sería mejor usar un array en lugar de una Lista porque en este caso el código C++ generado es más simple y el acceso a los arrays es simplemente más rápido.

En la siguiente prueba he cambiado List<ManagedUpdateBehavior> por ManagedUpdateBehavior[].

Imagen

¡Esto tiene mucho mejor aspecto!

Actualización: Hice la prueba con array en Mono y obtuve 0,23ms.

Instrumentos al rescate

Nos dimos cuenta que llamar funciones de C++ a C# no es rápido, pero averigüemos que está haciendo Unity realmente cuando llama Updates en todos estos objetos. La forma más sencilla de hacerlo es utilizar Time Profiler de Apple Instruments.

Tenga en cuenta que no se trata de un Mono contra Mono. Prueba IL2CPP: la mayoría de las cosas que se describen a continuación también son válidas para una compilación Mono de iOS.

Lancé la prueba en el iPhone 6 con Time Profiler, grabé unos minutos de datos y seleccioné un intervalo de un minuto para inspeccionar. Nos interesa todo lo que parta de esta línea:

Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.

Si no ha utilizado Instruments antes, a la derecha verá las funciones ordenadas por tiempo de ejecución y otras funciones a las que llaman. La columna más a la izquierda es el tiempo de CPU en ms y % de estas funciones y funciones que llaman combinadas, la segunda columna a la izquierda es el tiempo de autoejecución de la función. Note que como el CPU no fue usado completamente por Unity durante este experimento vemos 10 segundos de tiempo de CPU gastado en nuestras Actualizaciones en un intervalo de 60 segundos. Obviamente, nos interesan las funciones que tardan más tiempo en ejecutarse.

He utilizado mis conocimientos de Photoshop y he codificado por colores algunas zonas para que entiendas mejor lo que ocurre.

Imagen
UpdateBehavior.Update()

En el medio ves nuestro método Update o como IL2CPP lo llama - UpdateBehavior_Update_m18. Pero antes de llegar allí, Unity hace muchas otras cosas.

Iterar sobre todos los comportamientos

Unity repasa todos los Comportamientos para actualizarlos. Una clase especial de iterador, SafeIterator, garantiza que nada se rompa si alguien decide borrar el siguiente elemento de la lista. Sólo iterar sobre todos los comportamientos registrados lleva 1517ms de un total de 9979ms.

Comprobar si la llamada es válida

A continuación, Unity hace un montón de comprobaciones para asegurarse de que está llamando a un método válido existente en un GameObject activo que ha sido inicializado y su método Start llamado. No querrás que tu juego se bloquee si destruyes un GameObject durante la actualización, ¿verdad? Estas comprobaciones tardan otros 2188ms de un total de 9979ms.

Preparar la invocación del método

Unity crea una instancia de ScriptingInvocationNoArgs (que representa una llamada del lado nativo al lado administrado) junto con ScriptingArguments y ordena a la máquina virtual IL2CPP que invoque el método (función scripting_method_invoke). Este paso tarda 2061 ms de un total de 9979 ms.

Llamar al método

La función scripting_method_invoke comprueba que los argumentos pasados son válidos (900ms) y luego llama al método Runtime::Invoke de la máquina virtual IL2CPP (1520ms). En primer lugar, Runtime::Invoke comprueba si existe dicho método (1018ms). A continuación, llama a una función RuntimeInvoker generada para la firma del método (283ms). A su vez llama a nuestra función Actualizar que según Time Profiler tarda 42ms en ejecutarse.

Y una bonita mesa de colores.

Imagen
Actualizaciones gestionadas

Ahora vamos a utilizar Time Profiler con la prueba del gestor. Se puede ver en la captura de pantalla que hay los mismos métodos (algunos de ellos toman menos de 1ms en total por lo que ni siquiera se muestran), pero la mayor parte del tiempo de ejecución es en realidad va a la función UpdateMe (o cómo IL2CPP lo llama - ManagedUpdateBehavior_UpdateMe_m14). Además, IL2CPP inserta una comprobación de nulos para asegurarse de que el array sobre el que estamos iterando no es nulo.

La siguiente imagen utiliza los mismos colores.

Imagen

Entonces, ¿qué opinas ahora, hay que preocuparse por una pequeña llamada de método?

Algunas palabras sobre la prueba

Para ser sinceros, esta prueba no es del todo justa. Unity hace un gran trabajo protegiéndote a ti y a tu juego de comportamientos no deseados y cuelgues: ¿Está activo este GameObject? ¿No se destruyó durante este bucle de actualización? ¿Existe el método Update en el objeto? ¿Qué hacer con un MonoBehaviour creado durante este bucle de actualización? - mi script gestor no maneja nada de eso, sólo itera a través de una lista de objetos a actualizar.

En el mundo real, el script de gestión probablemente habría sido más complicado y lento de ejecutar. Pero en este caso yo soy el desarrollador - sé lo que mi código se supone que debe hacer y yo arquitecto mi clase manager sabiendo qué comportamiento es posible y cuál no en mi juego. Desgraciadamente, Unity no posee esos conocimientos.

¿Qué debe hacer?

Por supuesto todo depende de tu proyecto, pero en el campo no es raro ver un juego usando un gran número de GameObjects en la escena cada uno ejecutando alguna lógica cada frame. Normalmente se trata de un pequeño trozo de código que no parece afectar a nada, pero cuando el número crece mucho la sobrecarga de llamar a miles de métodos de actualización empieza a ser notable. En este punto puede que ya sea demasiado tarde para cambiar la arquitectura del juego y refactorizar todos estos objetos en el patrón de gestor.

Ya tiene los datos, piense en ellos al inicio de su próximo proyecto.