Pila DOTS: C ++ y C #

imagen

Esta es una breve introducción a nuestra nueva pila de tecnología orientada a datos ( DOTS ). Compartiremos algunas ideas para ayudarlo a comprender cómo y por qué Unity se ha vuelto así hoy, y también le diremos en qué dirección planeamos desarrollar. En el futuro, planeamos publicar nuevos artículos en el blog DOTS en el blog de Unity.

Hablemos de C ++. Este es el lenguaje en el que se escribe la Unidad moderna.
Uno de los problemas más complejos con los que un desarrollador de juegos tiene que lidiar es: el programador debe proporcionar un archivo ejecutable con instrucciones claras para el procesador de destino, y cuando el procesador ejecuta estas instrucciones, el juego debe comenzar.

En la parte del código que es sensible al rendimiento, sabemos de antemano cuáles deberían ser las instrucciones finales. Solo necesitamos una forma simple que nos permita describir constantemente nuestra lógica, y luego verificar y asegurarnos de que se generen las instrucciones que necesitamos.

Creemos que el lenguaje C ++ no es demasiado bueno para esta tarea. Por ejemplo, quiero que mi ciclo se vectorice, pero puede haber un millón de razones por las cuales el compilador no podrá vectorizarlo. O hoy se está vectorizando, y mañana no, debido a un cambio aparentemente insignificante. Incluso es difícil asegurarse de que todos mis compiladores de C / C ++ incluso vectoricen mi código.

Decidimos desarrollar nuestra propia "forma bastante conveniente de generar código de máquina" que cumpliría todos nuestros deseos. Podríamos pasar mucho tiempo para doblar ligeramente la secuencia completa del diseño de C ++ en la dirección que necesitamos, pero decidimos que sería mucho más razonable invertir nuestra energía en el desarrollo de una cadena de herramientas que resuelva por completo todos los problemas de diseño que enfrentamos. Lo desarrollaríamos teniendo en cuenta precisamente aquellas tareas que el desarrollador del juego tiene que resolver.

¿Qué factores priorizamos?

  • Rendimiento = correcto. Debería poder decir: "si por alguna razón este ciclo no está vectorizado, entonces debe ser un error del compilador y no una situación de la categoría" oh, el código comenzó a funcionar solo ocho veces más lento, pero aún así da valores verdaderos, negocios algo!
  • Plataforma cruzada. El código de entrada que escribo debe permanecer exactamente igual independientemente de la plataforma de destino, ya sea iOS o Xbox.
  • Deberíamos tener un ciclo de iteración ordenado en el que pueda ver fácilmente el código de máquina generado para cualquier arquitectura cuando cambie mi código fuente. El "visor" de código de máquina debería ser de gran ayuda con la capacitación / explicación cuando necesite comprender lo que hacen todas estas instrucciones de máquina.
  • Seguridad Como regla general, los desarrolladores de juegos no ponen la seguridad en una posición alta en su lista de prioridades, pero creemos que una de las mejores características de Unity es que es realmente muy difícil dañar la memoria. Debería haber tal modo en el que ejecutamos cualquier código, y arreglamos sin ambigüedad un error por el cual una letra grande muestra un mensaje sobre lo que sucedió aquí: por ejemplo, fui más allá de los límites al leer / escribir o traté de desreferenciar a cero.

Entonces, habiendo descubierto lo que es importante para nosotros, pasemos a la siguiente pregunta: ¿en qué idioma es mejor escribir programas a partir de los cuales se generará ese código de máquina? Digamos que tenemos las siguientes opciones:

  • Idioma propio
  • Alguna adaptación / subconjunto de C o C ++
  • Subconjunto de c #

¿Qué, C #? ¿Para nuestros circuitos internos cuyo rendimiento es especialmente crítico? Si C # es una opción completamente natural, con la que en el contexto de Unity hay muchas cosas muy bonitas:

  • Este es el idioma con el que nuestros usuarios ya están trabajando hoy.
  • Tiene un IDE excelente, tanto para edición / refactorización como para depuración.
  • Ya hay un compilador que convierte C # en un IL intermedio (estamos hablando del compilador Roslyn para C # de Microsoft), y simplemente puede usarlo en lugar de escribir el suyo. Tenemos una gran experiencia en la conversión de un lenguaje intermedio a IL, por lo que solo necesitamos realizar la generación de código y el posprocesamiento de un programa específico.
  • C # carece de muchos problemas de C ++ (infierno con la inclusión de encabezados, patrones PIMPL, tiempo de compilación prolongado)

A mí mismo realmente me gusta escribir código en C #. Sin embargo, C # tradicional no es el mejor lenguaje en términos de rendimiento. El equipo de desarrollo de C #, los equipos responsables de la biblioteca estándar y el tiempo de ejecución en los últimos años han hecho un gran progreso en esta área. Sin embargo, mientras trabaja con C #, es imposible controlar exactamente dónde se encuentran sus datos en la memoria. Y es precisamente este problema el que debemos resolver para aumentar la productividad.

Además, la biblioteca estándar de este lenguaje está organizada alrededor de "objetos en el montón" y "objetos que tienen punteros a otros objetos".

Al mismo tiempo, al trabajar con un fragmento de código en el que el rendimiento es crítico, puede prescindir casi por completo de una biblioteca estándar (adiós a Linq, StringFormatter, List, Dictionary), prohibir las operaciones de selección (= sin clases, solo estructuras), reflexión, deshabilitar el recolector de basura y virtual llamadas y agregue algunos contenedores nuevos que se les permite usar (NativeArray y compañía). En este caso, los elementos restantes del lenguaje C # ya se ven muy bien. Consulte el blog de Aras para ver ejemplos, donde describe un proyecto de trazado de ruta improvisado.

Tal subconjunto nos ayudará a hacer frente fácilmente a todas las tareas que son relevantes al trabajar con ciclos activos. Como se trata de un subconjunto completo de C #, puede trabajar con él como con C # normal. Podemos recibir errores asociados con el viaje al extranjero al intentar acceder, obtendremos excelentes mensajes de error, admitiremos el depurador y la velocidad de compilación será tal que ya lo haya olvidado cuando trabaje con C ++. A menudo nos referimos a este subconjunto como High Performance C # o HPC #.

Compilador de ráfagas: ¿qué pasa hoy?


Escribimos un generador / compilador de código llamado Burst. Está disponible en la versión Unity 2018.1 y superior como un paquete en el modo "vista previa". Queda mucho trabajo por hacer con él, pero hoy estamos satisfechos con él.

A veces logramos trabajar más rápido que en C ++, a menudo, aún más lentamente que en C ++. La segunda categoría incluye errores de rendimiento que, estamos convencidos, podrán hacer frente.

Sin embargo, simplemente comparar el rendimiento no es suficiente. No menos importante es lo que hay que hacer para lograr dicho rendimiento. Ejemplo: tomamos el código de eliminación de nuestro procesador actual de C ++ y lo portamos a Burst. El rendimiento no ha cambiado, pero en la versión de C ++ tuvimos que hacer un increíble acto de equilibrio para persuadir a nuestros compiladores de C ++ para que hagan la vectorización. La versión con Burst era aproximadamente cuatro veces más compacta.

Honestamente, la historia completa con "debe reescribir su código crítico para el rendimiento en C #" a primera vista no atrajo a nadie en el equipo interno de Unity. Para la mayoría de nosotros, sonaba como "¡más cerca del hardware!" Al trabajar con C ++. Pero ahora la situación ha cambiado. Con C #, controlamos completamente todo el proceso desde la compilación del código fuente hasta la generación del código de la máquina, y si no nos gusta ningún detalle, simplemente lo tomamos y lo arreglamos.

Vamos a portar lenta pero seguramente todos los códigos críticos de rendimiento de C ++ a HPC #. En este idioma, es más fácil lograr el rendimiento que necesitamos, es más difícil escribir un error y es más fácil trabajar con él.

Aquí hay una captura de pantalla de Burst Inspector, donde puede ver fácilmente qué instrucciones de ensamblaje se generaron para sus diversos hot loops:

imagen

La unidad tiene muchos usuarios diferentes. Algunos de ellos pueden recordar todo el conjunto de instrucciones arm64 de memoria, mientras que otros simplemente crean con entusiasmo, incluso sin un doctorado en ciencias de la computación.
Todos los usuarios ganan cuando acelera la fracción del tiempo de trama que se dedica a ejecutar el código del motor (generalmente 90% o más). La parte de trabajar con el código ejecutable del paquete Asset Store se está acelerando realmente, ya que los autores del paquete Asset Store están adoptando HPC #.

Los usuarios avanzados también se beneficiarán del hecho de que pueden escribir su propio código de alto rendimiento en HPC #.

Optimización de puntos


En C ++, es muy difícil hacer que el compilador tome diferentes decisiones de compromiso para optimizar el código en diferentes partes de su proyecto. La optimización más detallada con la que puede contar es una indicación archivo por archivo del nivel de optimización.

Burst está diseñado para que pueda aceptar el único método de este programa como entrada, a saber: el punto de entrada al bucle activo. Burst compila esta función, así como todo lo que llama (se debe garantizar que dichos elementos llamados sean conocidos de antemano: no permitimos funciones virtuales o punteros de función).

Dado que Burst opera solo en una parte relativamente pequeña del programa, establecemos el nivel de optimización en 11. Burst incorpora casi todos los sitios de llamadas. Elimine las verificaciones if, que de lo contrario no se eliminarían, ya que en el formulario incrustado obtenemos información más completa sobre los argumentos de la función.

¿Cómo ayuda a resolver problemas comunes de enhebrado?


C ++ (así como C #) no ayudan particularmente a los desarrolladores a escribir código seguro para subprocesos.

Incluso hoy, más de una década después de que un procesador de juegos típico comenzara a equiparse con dos o más núcleos, es muy difícil escribir programas que usen eficientemente varios núcleos.

La carrera de datos, el no determinismo y los puntos muertos son los principales desafíos que hacen que sea tan difícil escribir código multiproceso. En este contexto, necesitamos características de la categoría de "asegúrese de que esta función y todo lo que llama nunca comenzarán a leer o escribir el estado global". Queremos que todas las violaciones de esta regla den errores de compilación y no sigan siendo "reglas a las que esperamos que se adhieran todos los programadores". Burst arroja un error de compilación.

Recomendamos encarecidamente a los usuarios de Unity (y mantenemos lo mismo en su círculo) que escriban código para que todas las transformaciones de datos planificadas en él se dividan en tareas. Cada tarea es "funcional" y, como efecto secundario, gratuita. Indica explícitamente las memorias intermedias de solo lectura y las memorias intermedias de lectura / escritura con las que tiene que trabajar. Cualquier intento de acceder a otros datos provocará un error de compilación.
El Programador de tareas garantiza que nadie escriba en su búfer de solo lectura mientras se ejecuta su tarea. Y garantizamos que durante la duración de la tarea nadie leerá de su búfer, diseñado para leer y escribir.

Cada vez que asigne una tarea que viole estas reglas, recibirá un error de compilación. No solo en un evento tan desafortunado como las condiciones de la carrera. El mensaje de error explicará que está intentando asignar una tarea que debería leer desde el búfer A, pero previamente ha asignado una tarea que escribirá en A. Por lo tanto, si realmente desea hacer esto, la tarea anterior debe especificarse como una dependencia .

Creemos que dicho mecanismo de seguridad ayuda a detectar muchos errores antes de que se corrijan y, por lo tanto, garantiza el uso eficiente de todos los núcleos. Se hace imposible provocar condiciones de carrera o punto muerto. Se garantiza que los resultados serán deterministas, independientemente de cuántos subprocesos tenga o cuántas veces se interrumpa un subproceso debido a la intervención de algún otro proceso.

Domina toda la pila


Cuando podemos llegar al fondo de todos estos componentes, también podemos asegurarnos de que sean conscientes unos de otros. Por ejemplo, una razón común para la falla de vectorización es esta: el compilador no puede garantizar que dos punteros no apunten al mismo punto de memoria (aliasing). Sabemos que dos NativeArray de ninguna manera se superpondrán de esta manera, ya que han escrito una biblioteca de colección, y podemos usar este conocimiento en Burst, por lo que no nos negaremos a optimizar solo por temor a que dos punteros puedan dirigirse a uno el mismo recuerdo

Del mismo modo, escribimos la biblioteca matemática Unity.Mathematics . Burst se la conoce "completamente". Burst (en el futuro) podrá señalar la opción de no optimizarse en casos como math.sin (). Dado que para Burst, math.sin () no es solo un método ordinario de C # que debe compilarse, también comprenderá las propiedades trigonométricas de sin (), comprenderá que sin (x) == x para valores pequeños de x (que Burst puede probar independientemente ), entenderá que puede ser reemplazado por la expansión en la serie Taylor, sacrificando en parte la precisión. En el futuro, Burst también planea implementar un determinismo multiplataforma y de diseño con un punto flotante; creemos que tales objetivos son alcanzables.

Las diferencias entre el código del motor del juego y el código del juego son borrosas


Cuando escribimos el código de tiempo de ejecución de Unity en HPC #, el motor del juego y el juego como tal están escritos en el mismo idioma. Podemos distribuir los sistemas de tiempo de ejecución que convertimos a HPC # como código fuente. Todos pueden aprender de ellos, mejorarlos, adaptarlos por sí mismos. Tendremos un campo de juego de cierto nivel, y nada impedirá que nuestros usuarios escriban un mejor sistema de partículas, física del juego o un renderizador de lo que escribimos. Al acercar nuestros procesos de desarrollo interno a los procesos de desarrollo del usuario, también podemos sentirnos mejor en el lugar del usuario, por lo que pondremos todos nuestros esfuerzos en crear un flujo de trabajo único, en lugar de dos diferentes.

Source: https://habr.com/ru/post/444526/


All Articles