Mejora del escalado del rendimiento del sistema de trabajo en 2022.2 - parte 1: Antecedentes y API

En 2022.2 y 2021.3.14f1, hemos mejorado el coste de programación y el escalado de rendimiento del sistema de trabajos de Unity. En este artículo en dos partes, ofreceré una breve recapitulación de la programación paralela y los sistemas de trabajos, hablaré de la sobrecarga del sistema de trabajos y compartiré el enfoque de Unity para mitigarla.
En la primera parte, cubrimos los antecedentes de la programación paralela y la API del sistema de trabajos. Si ya está familiarizado con el paralelismo, no dude en hojearlo y pasar a la segunda parte.
En la versión 2017.3, se añadió una API pública de C# para el sistema interno de trabajos de Unity en C++, lo que permite a los usuarios escribir pequeñas funciones llamadas "trabajos" que se ejecutan de forma asíncrona. La intención de utilizar trabajos en lugar de funciones simples es proporcionar una API que haga fácil, seguro y eficiente permitir que el código que de otro modo se ejecutaría en el hilo principal se ejecute en su lugar en hilos "trabajadores" de trabajos, idealmente en paralelo. Esto ayuda a reducir la cantidad total de tiempo de pared que el hilo principal necesita para completar la simulación de un juego. Utilizar el sistema de trabajo para su CPU puede proporcionar mejoras significativas en el rendimiento y permitir que el rendimiento de su juego escale de forma natural a medida que mejora el hardware en el que se ejecuta su juego.
Si piensa en la computación como un recurso finito, un único núcleo de CPU sólo puede realizar una cantidad determinada de "trabajo" computacional en un periodo de tiempo determinado. Por ejemplo, si un juego monohilo necesita que su simulación Update() no tarde más de 16 ms, pero actualmente tarda 24 ms, entonces la CPU tiene demasiado trabajo que hacer: se necesita más tiempo. Para alcanzar un objetivo de 16 ms, sólo hay dos opciones: hacer que la CPU vaya más rápido (por ejemplo, aumentar las especificaciones mínimas de su juego - normalmente no es una gran opción), o hacer menos trabajo.
void Update()
{
// <lots of simulation logic...>
}
En última instancia, lo que necesita es eliminar 8 ms de trabajo computacional. Eso suele significar mejorar los algoritmos, repartir el trabajo de los subsistemas entre varias tramas, eliminar el trabajo redundante que puede acumularse durante el desarrollo, etc. Si con esto sigue sin alcanzar su objetivo de rendimiento, es posible que tenga que reducir la complejidad de la simulación del juego recortando el contenido y la jugabilidad, por ejemplo, reduciendo el número de enemigos que pueden aparecer a la vez, lo que sin duda no es lo ideal.
¿Y si, en lugar de eliminar el trabajo, se lo damos a otro núcleo de la CPU para que lo ejecute? Hoy en día, la mayoría de las CPU son multinúcleo, lo que significa que la potencia computacional disponible de un solo hilo puede multiplicarse por el número de núcleos que tenga la CPU. Si pudiéramos dividir de forma mágica y segura todo el trabajo que realiza actualmente la función Update() entre dos núcleos de CPU, el trabajo de 24 ms de Update() podría ejecutarse en dos trozos simultáneos de 12 ms. Esto nos situaría muy por debajo del objetivo de 16 ms. Además, si pudiéramos dividir el trabajo en cuatro trozos paralelos y ejecutarlos en cuatro núcleos, ¡entonces la Update() tardaría sólo 6 ms!
Este tipo de división del trabajo y su ejecución en todos los núcleos disponibles se conoce como escalado del rendimiento. Si añade más núcleos, lo ideal es que pueda ejecutar más trabajo en paralelo, reduciendo el tiempo de ejecución de Update() sin cambios en el código.
void Update()
{
// Some magic has split our logic into 4 equal parts
// that can run in parallel. Wowee!
PartialUpdateA();
PartialUpdateB();
PartialUpdateC();
PartialUpdateD();
}
Por desgracia, esto es fantasía. Nada va a dividir la función Update() en trozos y ejecutarlos en núcleos separados sin algo de ayuda. Incluso si cambiáramos a una CPU con 128 núcleos, el Update() de 24 ms anterior seguiría tardando 24 ms, siempre que ambas CPU tengan la misma velocidad de reloj. ¡Qué desperdicio de potencial! Entonces, ¿cómo podemos escribir aplicaciones para aprovechar todos los núcleos de CPU disponibles y aumentar el paralelismo?
Un enfoque es el multihilo. Es decir, su programa crea hilos para ejecutar una función que el sistema operativo programará para que se ejecute por usted. Si su CPU tiene varios núcleos, entonces pueden ejecutarse varios hilos al mismo tiempo, cada uno en su propio núcleo. Si hay más hilos que núcleos disponibles, el sistema operativo se encarga de determinar qué hilo consigue ejecutarse en un núcleo -y durante cuánto tiempo- antes de cambiar a otro hilo, un proceso denominado cambio de contexto.
Sin embargo, la programación multihilo conlleva un montón de complicaciones. En el escenario mágico anterior, la función Update() se dividió uniformemente en cuatro actualizaciones parciales. Pero en realidad, probablemente no sería capaz de hacer algo tan sencillo. Dado que los hilos se ejecutarán simultáneamente, debe tener cuidado cuando lean y escriban en los mismos datos al mismo tiempo, para evitar que corrompan los cálculos de los demás.
Esto suele implicar el uso de primitivas de sincronización de bloqueo, como un mutex o un semáforo, para controlar el acceso al estado compartido entre hilos. Estas primitivas suelen limitar cuánto paralelismo pueden tener secciones específicas de código (normalmente se opta por ninguno) mediante el "bloqueo" de otros hilos, impidiéndoles ejecutar la sección hasta que el poseedor del bloqueo termine y "desbloquee" la sección para cualquier hilo en espera. Esto reduce el rendimiento que se obtiene al utilizar varios hilos, ya que no se está ejecutando en paralelo todo el tiempo, pero garantiza que los programas sigan siendo correctos.
También es probable que no tenga sentido ejecutar algunas partes de su actualización en paralelo debido a las dependencias de los datos. Por ejemplo, casi todos los juegos necesitan leer la entrada de un controlador, almacenar esa entrada en un búfer de entrada y, a continuación, leer el búfer de entrada y reaccionar en función de los valores.
void PartialUpdateA()
{
// Write to m_InputBuffer with the controller state
ReadControllerState(out m_InputBuffer);
}
void PartialUpdateB()
{
// Read m_InputBuffer and start a player
// jump animation if the jump button was pressed
if(m_InputBuffer.IsJumpPressed())
PlayerJump();
}No tendría sentido que el código que lee la memoria intermedia de entrada para decidir si un personaje debe saltar se ejecutara al mismo tiempo que el código que escribe en la memoria intermedia de entrada para la actualización de ese fotograma. Aunque utilizara un mutex para asegurarse de que la lectura y escritura en m_InputBuffer fuera segura, siempre querrá que primero se escriba en m_InputBuffer y que después se ejecute el código de lectura de m_InputBuffer, para saber si se ha pulsado el botón de salto para el fotograma actual (y no uno en el pasado). Estas dependencias de datos son comunes y normales, pero disminuirán la cantidad de paralelismo posible.
Existen muchos enfoques para escribir un programa multihilo. Puede utilizar API específicas de la plataforma para crear y gestionar hilos directamente, o utilizar varias API que proporcionan una abstracción para ayudar a gestionar algunas de las complicaciones de la programación multihilo.
Un sistema de puestos de trabajo es una de esas abstracciones. Proporciona los medios para dividir partes de su código de un solo hilo en bloques lógicos, aislar qué datos necesita ese código, controlar quién accede a esos datos simultáneamente y ejecutar tantos bloques de código en paralelo como sea posible para intentar utilizar toda la potencia de cálculo disponible en la CPU según sea necesario.
Hoy en día, no podemos dividir funciones arbitrarias en trozos de forma automática, por lo que Unity proporciona una API de trabajo que permite a los usuarios convertir funciones en pequeños bloques lógicos. A partir de ahí, el sistema de trabajo se encarga de hacer que esas piezas funcionen en paralelo.
El sistema de puestos de trabajo está formado por unos pocos componentes básicos:
- Empleo
- Asas de trabajo
- Programador de trabajos
public struct MyJob : IJob
{
public NativeArray<int> Data;
public void Execute()
{
// Do some work using our Data member
}
}Como ya se ha mencionado, un trabajo es sólo una función y algunos datos, pero esta encapsulación es útil, ya que reduce el alcance de qué datos específicos leerá o escribirá el trabajo.
var myJob = new MyJob() { Data = someNativeArray };
var jobHandle = myJob.Schedule();Una vez creada una instancia de trabajo, es necesario programarla con el sistema de trabajos. Esto se hace con el método .Schedule() añadido a todos los tipos de trabajo a través del mecanismo de extensión de C#. Para identificar y hacer un seguimiento del trabajo programado, se proporciona un JobHandle.
Dado que los gestores de trabajos identifican los trabajos programados, pueden utilizarse para establecer dependencias entre trabajos. Las dependencias de trabajos garantizan que un trabajo programado no comience a ejecutarse hasta que sus dependencias hayan finalizado. Como resultado directo, también nos indican cuándo se permite que diferentes trabajos se ejecuten en paralelo mediante la creación de un grafo de trabajos acíclico dirigido.
var myJob = new MyJob() { Data = someNativeArray };
var jobHandle = myJob.Schedule();
// WritingJob writes to someNativeArray so make sure it runs
// after MyJob is done (since it uses someNativeArray as well).
// That is, declare writingJob to have a dependency on myJob by
// passing in the JobHandle for MyJob to writingJob.Schedule
var writingJob = new WritingJob() { Data = someNativeArray };
var writingJobHandle = writingJob.Schedule(jobHandle);Por último, a medida que se programan los trabajos, el programador de trabajos se encarga de realizar un seguimiento de los trabajos programados (asignando JobHandles a las instancias de trabajo programadas) y de garantizar que los trabajos comiencen a ejecutarse lo antes posible. Cómo se haga esto es importante, ya que el diseño y los patrones de uso del sistema de trabajo pueden entrar potencialmente en conflicto de formas no obvias, provocando gastos generales que se coman las ganancias de rendimiento de la programación multihilo. A medida que los usuarios empezaron a adoptar el sistema de trabajos en C#, empezamos a ver escenarios en los que la sobrecarga del sistema de trabajos era mayor de lo que nos gustaría, lo que condujo a las mejoras de la implementación del sistema de trabajos interno de Unity en la Tech Stream 2022.2.
Permanezca atento a la segunda parte, que explorará de dónde procede la sobrecarga en el sistema de trabajos de C# y cómo se ha reducido en Unity 2022.2.
Si tiene preguntas o quiere saber más, visítenos en el foro del sistema de empleo C#. También puede conectar conmigo directamente a través del Discord de Unity en el nombre de usuario @Antifreeze#2763. Asegúrese de estar atento a los nuevos blogs técnicos de otros desarrolladores de Unity como parte de laserie en curso Tech from the Trenches.
