Saltar al contenido
Tutorial para principiantes sobre el sistema de Jobs y Burst de Unity: ¿Cómo usarlos correctamente?

Tutorial para principiantes sobre el sistema de Jobs y Burst de Unity: ¿Cómo usarlos correctamente?

2024-12-09 16:56

Si el único propósito es la multitarea, usar Async para cambiar de hilo o System.Threading es el método más conveniente y claro. Burst también se puede llamar directamente, no necesariamente se requieren Jobs. Consulta los artículos sobre Async y Llamada directa. Pero si se trata de una gran cantidad de cálculos pequeños, entonces es cuando debes considerar el sistema de Jobs.

La documentación de Jobs de Unity es extremadamente incompleta. Este artículo es un registro de experiencia de uso a largo plazo y debería ser bastante completo.

Propósito y limitaciones de los Jobs

A diferencia del enfoque general de multitarea, el sistema de Jobs de Unity es un sistema que simula el alto rendimiento de la GPU mediante múltiples hilos, lo que significa que el costo de asignar tareas es extremadamente bajo.

Los Jobs solo pueden manejar la cantidad de cálculo dentro de 1 fotograma, y al igual que las funciones de cuda/shader, son núcleos pequeños. Los cálculos pesados aún afectarán los FPS, porque a veces los Jobs se asignan al hilo principal para su ejecución. Si deseas ejecutar cálculos de larga duración, o los divides en muchos Jobs pequeños, o los hilos de .Net siguen siendo la mejor opción.

Entonces surge la pregunta: si es similar a la GPU, ¿por qué no usar ComputeShader directamente? Correcto, ComputeShader puede hacer todo lo que hace un Job, y es más conveniente. A menos que:

  1. Si es necesario intercambiar datos con la CPU con frecuencia, la GPU no es buena en esto, y los Jobs basados en CPU tienen ventaja.
  2. El sistema ECS (DOTS) está basado en Jobs, por la misma razón anterior.

Nota: WebGL no admite Jobs ni ComputeShader, ni la aceleración de Burst, pero:

  • WebGL está siendo reemplazado por WebGPU, que sí admite ComputeShader, y los navegadores nuevos ya lo admiten (en IOS17 hay que activarlo en la configuración, en la versión 19 de vista previa ya está activado por defecto).
  • En la configuración, WebAssembly 2023 puede activar la multitarea para admitir Jobs, y el soporte del navegador es más completo. Pero oficialmente dicen que ni siquiera es una función experimental, no se puede usar. En Unity6 LTS, activarlo causará un Crash. La multitarea en wasm les ha traído dilemas durante muchos años, y además actualmente DOTS recibe más atención, junto con el soporte de multitarea wasm en .Net8, tal vez estén esperando que Unity7 admita .Net8.

Rendimiento

Proporcionar métricas de rendimiento ayuda a comprender la intención del diseño y la aplicabilidad. También puedes leer el artículo primero y luego volver a ver las pruebas de rendimiento.

Se probaron 8 proyectos, primero se probó el rendimiento de 3 tipos de asignadores:

  • BenchAllocatorTemp: Ejecutar 100,000 asignaciones de Allocator.Temp
  • BenchAllocatorTempJob: Igual que el anterior, asignador Allocator.TempJob
  • BenchAllocatorPersistent: Igual que el anterior, asignador Allocator.Persistent

El resultado fue que TempJob es el más rápido, seguido de Persistent.

Luego se probó el rendimiento de 4 modos de Job:

  • BenchBaseLine: Usar un bucle For para ejecutar 100,000 cálculos simples como línea de referencia base
  • BenchIJob: Tiempo de programación de 100,000 Jobs
  • BenchIJobParallelFor: Tiempo de programación por lotes de 100,000 Jobs en modo paralelo
  • BenchIJobParallelForBurst: Igual que el anterior, pero con Burst activado
  • BenchIJobParallelForBurstLoopVectorization: Programar 10 Jobs, cada Job calcula 10,000 veces con un bucle For, y activar la vectorización de Burst

El siguiente es el efecto de la prueba en mi PC:

|2x

Median es la mediana del tiempo de ejecución del proyecto de prueba, en milisegundos.

Se puede ver que el costo de programación de Jobs es pequeño, diseñado para ejecutar una gran cantidad de tareas. Por supuesto, aquí solo realicé cálculos simples de multiplicación, por lo que la mejora de los Jobs es limitada.

Tipos de datos

Primero, Burst no admite tipos administrados de C#, solo se pueden usar tipos con la misma longitud que C, que se pueden copiar directamente con memcpy (sin necesidad de serialización/marshalling), llamados blittable. Incluyen tipos básicos como int, etc. (char, string y bool a veces son administrados, no los uses), y arrays estilo C unidimensionales de tipos blittable (new int[5]). Y los Jobs inevitablemente se usan en combinación con Burst, por lo que siguen esta limitación.

Unity encapsula un tipo seguro para hilos llamado NativeArray específicamente para usar con Jobs. Estos tipos pueden compartir datos con el hilo principal sin necesidad de copiar, porque al copiar solo se pasa el puntero de datos, y múltiples copias hacen referencia a la misma área de memoria. Los derivados son NativeList, NativeQueue, NativeHashMap, NativeHashSet, NativeText, etc.

Nota: No se puede usar código como nativeArray[0].x = 1.0f, o nativeArray[0]++;, el valor no cambiará porque lo que devuelve no es una referencia.

Seguridad de hilos

La seguridad de hilos se logra mediante la restricción de la programación. La misma instancia de NativeArray solo puede tener un Job escribiendo en ella, de lo contrario se lanzará una excepción. Si los datos se pueden paralelizar mediante segmentación, se puede usar IJobParallelFor para ejecutar por lotes en el NativeArray. Si los datos son de solo lectura, se pueden identificar al definir la variable miembro, por ejemplo: [ReadOnly] public NativeArray<int> input;.

Cuando un Job está escribiendo, el hilo principal no puede leer el NativeArray, dará error, hay que esperar a que termine.

Asignación de memoria (allocate)

Primero, los tipos Native necesitan que los Dispose() manualmente después de usarlos, no se destruyen automáticamente. Para esto, Unity agregó seguimiento de fugas de memoria.

Al crear (new) un tipo Native, debes elegir entre 3 tipos de asignadores: Temp, TempJob, Persistent. La velocidad de asignación va de rápida a lenta. Temp tiene un ciclo de vida de 1 fotograma, TempJob de 4 fotogramas. ¿Qué significan?

  • Temp significa que lo usas dentro de la función actual y debes hacer Dispose() antes de que termine la función. Por lo tanto, si olvidas el Dispose, Unity reportará un error inmediatamente en el siguiente renderizado, pero esta asignación en realidad es bastante lenta.
  • TempJob tiene condiciones de error más flexibles, en realidad aún debes usarlo dentro de 1 fotograma, pero puedes hacer Dispose en el siguiente fotograma.
  • Persistent no reporta errores, debes tener cuidado tú mismo.

El proyecto BenchAllocator en la prueba de rendimiento anterior probó el rendimiento de estos 3. Se puede ver que Allocator.Temp tardó 4 veces más que TempJob. La documentación dice que Temp es el más rápido, esto es un BUG o un problema del modo Editor.

Ejecutar un Job de un solo hilo

El proceso completo es escribir tu propia clase IJob, programarla (Schedule) desde el hilo principal, y luego llamar a Complete para bloquear y esperar a que el Job termine.

public struct MyJob : IJob {
    public NativeArray<float> result;

    public void Execute() {
        for (int j = 0; j < result.Length; j++)
            result[j] = result[j] * result[j];
    }
}

void Update() {
    result = new NativeArray<float>(100000, Allocator.TempJob);

    MyJob jobData = new MyJob{
        result = result
    };

    handle = jobData.Schedule();
}

private void LateUpdate() {
    handle.Complete();
    result.Dispose();
}

Pero el problema es que usamos Jobs para una gran cantidad de tareas, esta tarea única no es muy útil. El modo paralelo, similar a la GPU, es más útil.

Modo paralelo (Parallel Job)

Cambiar el código anterior de heredar de IJob a heredar de IJobParallelFor es el modo paralelo.

public struct MyJob : IJobParallelFor {
    public NativeArray<float> result;

    public void Execute(int i) {
        result[i] = result[i] * result[i];
    }
}

void Update() {
    result = new NativeArray<float>(100000, Allocator.TempJob);

    MyJob jobData = new MyJob{
        result = result
    };

    handle = jobData.Schedule(result.Length, result.Length / 10);
}

private void LateUpdate() {
    handle.Complete();
    result.Dispose();
}

El modo paralelo no requiere que escribas el bucle For, ejecutará Execute una vez para cada elemento, similar a un Shader.

Schedule(result.Length, result.Length / 10) significa ejecutar Execute para cada unidad desde 0 hasta la longitud result.Length del array, distribuyéndolo en 10 workers.

Para ver la diferencia de rendimiento entre IJob e IJobParallelFor, consulta la prueba de rendimiento anterior.

Limitaciones del paralelismo

En IJobParallelFor solo puedes escribir en el elemento i, y no sabe en qué Array miembro vas a escribir, por lo que todos los Arrays solo pueden escribir en el elemento i. Pero puedes agregar el atributo [NativeDisableParallelForRestriction] al NativeArray para desactivar la verificación de seguridad, asegurándote tú mismo de que no haya conflictos de escritura.

El modo de solo lectura no tiene restricciones para todos los contenedores Native.

Además, IJobParallelFor no puede activar la vectorización de bucles, a menos que tu cálculo ya use vectorización (llamando a otras funciones ya vectorizadas), de lo contrario el rendimiento no será óptimo.

Usar contenedores como NativeList en paralelo

Los contenedores distintos de Array, como NativeList, en paralelo solo pueden estar en modo de solo lectura. Entonces, ¿cómo escribir en ellos? En realidad, el diseño divide NativeList en dos estados de trabajo: Add y Set. El patrón de uso correcto es que un Job realice operaciones Add y un segundo Job realice operaciones Set.

Para Add, se puede usar ParallelWriter y AsParallelWriter, de la siguiente manera:

    public struct AddListJob : IJobParallelFor {
        public NativeList<float>.ParallelWriter result;

        public void Execute(int i) {
            result.AddNoResize(i);
        }
    }

    public void RunIJobParallelForList() {
        var results = new NativeList<float>(10, Allocator.TempJob);
        var jobData = new AddListJob() {
            result = results.AsParallelWriter(),
        };
        var handle = jobData.Schedule(10, 1);
        handle.Complete();
        Debug.Log(String.Join(",", results.ToArray(Allocator.TempJob)));
        results.Dispose();
    }

En este estado, NativeList tiene capacidad fija, debes preasignar memoria antes de iniciar, y solo puedes operar con AddNoResize(). Este método se implementa mediante un bloqueo atómico de la propiedad Length, lo que conlleva una gran pérdida de rendimiento.

Luego, usa la conversión sin pérdidas de NativeList a NativeArray: NativeList.AsDeferredJobArray(). El NativeArray devuelto por este método es perezoso, solo se convierte cuando el Job se ejecuta realmente, por lo que se puede pasar antes de que se ejecuten los 2 Jobs:

var addJob = new AddListJob { result = results.AsParallelWriter() };
var jobHandle = addJob.Schedule(10, 1);

var setJob = new SetListJob { array = results.AsDeferredJobArray() };
setJob.Schedule(10, 1, jobHandle).Complete();

Nota: AsDeferredJobArray o AsArray devuelven una Vista (View), es decir, una vista de los datos originales. Lo que aún debe Dispose son los datos de origen.

Modo paralelo para arrays bidimensionales

IJobParallelFor solo puede paralelizar por elemento individual del Array, pero en realidad es más útil paralelizar por cada fila de un array bidimensional, y además puede activar la vectorización de bucles, obteniendo un rendimiento mayor. Se puede usar IJobParallelForBatch para realizar esta operación.

Primero creamos un array bidimensional plano de [10*15], luego programamos con IJobParallelForBatch.Schedule(int length, int batchCount). batchCount indica cuántos datos maneja cada job, Execute se ejecutará length/batchCount veces.

var results = new NativeArray<float>(10*15, Allocator.TempJob);
var jobData = new MyJob2D {
    result = results
};
var handle = jobData.Schedule(10*15, 15);
handle.Complete();
Debug.Log(String.Join(",", results));
results.Dispose();

Luego está la implementación de MyJob2D.

[BurstCompile]
public struct MyJob2D : IJobParallelForBatch {
    public NativeArray<float> result;

    public void Execute(int i, int count) {
        for (int j = i; j < i + count; j++) {
            result[j] = i;
        }
    }
}

Resultado de la ejecución:

0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,
4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,
5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,
6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,
7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,
8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,
9,9,9,9,9,9,9,9,9,9,9,9,9,9,9
UnityEngine.Debug:Log (object)

Este método puede activar automáticamente la vectorización de bucles mediante Burst, por lo que en la prueba de rendimiento, el tiempo para calcular 100,000 veces fue de 0.09 ms, siendo el más rápido.

Otras limitaciones

  • No puedes iniciar un Job dentro de un Job.

Combinación con Async

El caso de uso anterior programa el Job en Update y lo completa en LateUpdate, con el objetivo de acelerar el código de Update. Para tareas únicas no es necesario tanto lío, se puede usar Async para esperar directamente sin bloquear el renderizado. Se puede usar el método de extensión CompleteAsync del paquete:

async void GenerateMesh() {
    result = new NativeArray<float>(100000, Allocator.Persistent);

    MyJob jobData = new MyJob{
        result = result
    };

    handle = jobData.Schedule();

    await handle.CompleteAsync();
}

Nota: Este modo debe usar el asignador Persistent, porque no necesariamente completarás en 1 fotograma.

Burst

Burst, basado en LLVM, es un subconjunto de C# llamado “C# de alto rendimiento”, básicamente código C, generalmente de 10 a 100 veces más rápido que Mono, lo que también demuestra que Mono es lento.

Burst puede mejorar aún más la velocidad de ejecución de los Jobs. Para el ejemplo anterior, solo agrega esta línea:

 [BurstCompile]
 public struct MyJob : IJobParallelFor {
     ...
 }

En la prueba de rendimiento de IJobParallelFor, solo con esta línea, el tiempo de ejecución mejoró de 5.16 ms a 0.21 ms. En este punto, la velocidad de ejecución del Job finalmente superó al bucle For.

Nota: Las pruebas de rendimiento anteriores utilizaron 10 Workers. Ajustar finamente el número de Workers puede dar resultados de rendimiento diferentes.

Vectorización

La vectorización consiste empaquetar múltiples cálculos en una instrucción, por ejemplo, el cálculo de float3 es naturalmente vectorizado. Para la vectorización es mejor usar los tipos y métodos de la biblioteca Unity.Mathematics, de lo contrario puede fallar.

Si no realizas cálculos vectorizados, aún puedes vectorizar bucles. La prueba de rendimiento anterior mejoró así a 0.09 ms, consulta el capítulo anterior sobre arrays bidimensionales. La vectorización de bucles permite que algunos cálculos de bucles For que se pueden paralelizar se completen en un conjunto de instrucciones, Burst juzgará y optimizará automáticamente.

¿Cómo saber si un Job se vectorizó correctamente?

Abre la herramienta Burst Inspector (en el menú Jobs) |2x

Selecciona tu función, mira si el Assembly tiene código de instrucciones avx, y mira si hay advertencias en IR Optimisation. Si no se vectorizó correctamente, mostrará:

---------------------------
Remark Type: Analysis
Message:     test.cs:30:0: loop not vectorized: call instruction cannot be vectorized
Pass:        loop-vectorize
Remark:      CantVectorizeInstructionReturnType

Las comunes son:

  • loop not vectorized: call instruction cannot be vectorized Se refiere a que se llamó a una función externa que no se puede vectorizar.
  • loop not vectorized: instruction return type cannot be vectorized Generalmente esto es porque se llamó a una función ya optimizada, por lo que no se puede vectorizar por segunda vez, es normal.

Conversión entre datos de Job y Unity

Lo más doloroso de usar Jobs y Burst es convertir varios datos a NativeArray.

Por ejemplo, Vector3 debe cambiarse a float3. Si tienen el mismo tamaño, se puede convertir directamente mediante conversión forzada. Ejemplo:

var floats = new NativeArray<float3>(100, Allocator.TempJob);
NativeArray<Vector3> vertices = floats.Reinterpret<Vector3>();
Vector3[] verticesArray = vertices.ToArray();
floats.Dispose();

También se puede reinterpretar como una estructura, por ejemplo, convertir 3 float1 en 1 vector3:

var floats = new NativeArray<float>(new float[] {1,2,3}, Allocator.TempJob);
NativeArray<Vector3> aaa = floats.Reinterpret<Vector3>(sizeof(float));
Debug.Log(string.Join("\n", aaa.Select(v => v.ToString())));
floats.Dispose();
(1.00, 2.00, 3.00)

Para conversiones de tipo cast como NativeArray<int> a NativeArray<ushort>, es necesario crear un Job de conversión propio.

JobSystem que ejecuta automáticamente por lotes en la plataforma WebGL

El código de JobSystem en la plataforma WebGL lo ejecuta el hilo principal, por lo que IJobParallelFor con muchas tareas bloqueará directamente el juego.

Puedes crear tu propia interfaz AdaptSchedule, que juzgue automáticamente si el entorno actual es WebGL o multitarea. En WebGL, ejecuta fotograma a fotograma según el número de workers. Cada paso hará yield return Awaitable, dando un respiro al hilo principal.

Conclusión

Burst es en realidad una forma de compromiso para acelerar el código, lo que también genera una mentalidad de querer que todo sea compatible con Burst, resultando en código feo y compilación lenta. La biblioteca DOTS también está llena de estos rastros. Unity, con el aumento de la entropía, se vuelve cada vez más hinchado y la compilación más lenta. Para una aplicación a mayor escala de DOTS, este problema debe resolverse.

Unity7 admitirá .Net8+ y CoreCLR, lo que aumentará la velocidad de compilación del Editor, y también permitirá usar muchas características nuevas de .Net, reduciendo el costo de comunicación con el código C. Esperemos un poco, tal vez en el futuro no sea necesario usar Burst con tanta frecuencia en muchos lugares.

Edición de febrero: El director responsable de CoreCLR renunció debido a diferencias de opinión. Todos, disolverse.

Última edición
hugo-builder
hugo-builder · · AI Translated 2... · 47c675c
Otros colaboradores
...