Este post faz parte da série que fala sobre unity DOTS. O primeiro post está disponível a partir deste link.
Desta vez, conheceremos o Unity Job System .

Tabela de conteúdo:

Neste post, você verá o seguinte conteúdo:

Sobre Multi-Threading e o Job System da Unity:

O principal objetivo do Unity Job Systems é facilitar a escrita de código multi-thread. Em um cenário comum, nosso código é executado de maneira encadeada na thread principal, ou seja: Um bloco só é executado quando o anterior terminou.

Como a grande maioria dos processadores hoje em dia possui mais de um núcleo, não faz muito sentido que tudo seja executado no thread principal enquanto os outros ficam ociosos.

O Job Systems divide nosso código e faz com que ele seja multi-encadeado, ou seja, executa vários pedaços do nosso código ao mesmo tempo em diferentes threads. 

Escrever códigos Multi-thread é uma tarefa difícil e muito propensa a erros, produzindo problemas que são muito difíceis de depurar. 

O Job Systems faz todo o trabalho pesado que inclui a criação e gerenciamento dos trheads, e nossa única tarefa é criar os Workers e agendá-los. 

Existem vários benefícios em escrever um código multi-thread, como por exemplo aumentar o desempenho de seu jogo e, consequentemente a taxa de quadros, prolongar a vida útil de baterias em caso de dispositivos móveis e etc.

Outro aspecto do Job System da Unity é que todo o código compartilha internamente os threads de trabalho, evitando automáticamente a criação de mais threads que a quantidade de núcleos da CPU, o que causaria  a contenção nos recursos da CPU.

Agendamento de trabalhos e suas dependências

O sistema de trabalhos ou Job System, é responsável por agendar e gerenciar nosso código em vários núcleos. Ele coloca todos os nossos trabalhos em uma fila e o sistema de tarefas pega itens desta fila e os executa na ordem apropriada respeitando suas dependências caso existam.

Geralmente em jogos, um trabalho (job) está preparando os dados para um próximo trabalho. Se o trabalho A depender dos dados do trabalho B para ser executado, o sistema de tarefas irá garantir que o trabalho A não seja executado até que o trabalho B seja concluído.

Condições de corrida – O fantasma do código Multi-Thread

Se você já programou um sistema multi-thread, provavelmente já sofreu com condições de corrida. 

Uma condição de corrida acontece quando o resultado de uma operação depende do tempo de outro processo não controlado. Geralmente é difícil depurar uma condição de corrida já que ela depende do tempo não controlado e sua reprodução dificilmente pode ser executada.

Por exemplo: 

Se o sistema de trabalhos está enviando uma referência aos dados do seu código no thread principal para um trabalho, não é possível verificar se o thread principal está lendo estes dados enquanto o nosso job grava neles. Isto geraria uma condição de corrida, pois não saberíamos se os dados estão sendo alterados durante sua leitura.

O Job Systems resolve este problema enviando para cada trabalho uma cópia dos dados que ele precisa para operar em seu agendamento, ao invés de uma referência no thread principal. Assim, os dados são isolados e uma possível condição de corrida é eliminada. A Unity utiliza memcopy para colocar os dados na memória nativa, isso significa que estes dados podem ser apenas tipos bittable.

Native Containers

A desvantagem de trabalhar com cópias de dados para resolver o problema das condições de corrida é que os resultados do trabalho também são isolados nesta cópia. Felizmente, para contornar isso existem os Native Containers.

Pense nos Native Containers como um ponteiro para a alocação de memória destes dados, isso permite que trabalhemos com dados reais compartilhados com o thread principal em nossos trabalhos, ao invés de cópias de dados. Mas isso tem suas particularidades e deve ser utilizado de maneira correta.

Observação: Native Containers fazem parte do namespace unity.collections que deverá ser instalado através do package manager da unity para ser utilizado.

Os tipos de Native Containers são:

  • NativeList:
    Semelhante a listas C#, são NativeArrays redimensionáves;
  • Native Arrays:
    Semelhante aos C# arrays.
  • NativeHashMap:
    Pares de chave e valor. Semelhante ao C# Dictionary.
  • NativeMultiHashMap:
    Múltiplos valores por chave. Pense em um dicionário C# mas com múltiplos valores por chave.
  • NativeQueue:
    Fila FIFO ( Primeiro a entrar, primeiro a sair ).

O sistema de segurança está incluído em todos os tipos de Native Containers e verifica quem está lendo ou gravando neles.

Por padrão, quando um job tem acesso a um Native Container, ele tem acesso a leitura e gravação e isso pode diminuir o desempenho. 

O Unity Jobs também não permite que que um trabalho tenha acesso a gravação de um Native Container que está sendo gravado por outro trabalho. Isso geraria uma exceção.

Se um job não precisar alterar os dados de um container, é altamente recomendável utilizar o atributo [ReadyOnly]. Desta maneira, outros trabalhos poderão ter acesso somente leitura ao mesmo tempo que o primeiro. Exemplo:

[ReadOnly]
public NativeArray<int> input;

Ao instanciar um Native Container, você precisa especificar o seu tipo de alocação dependendo do caso de uso. Lembre-se de que estes dados são alocados na memória, logo eles também precisam ser liberados após seu uso ou a memória ficará cheia rapidamente.

Existem três tipos de alocação de Native Containers:

Allocator.Temp:


É o tipo de alocação mais rápido e a vida útil dos dados é de um quadro ou menos. Este tipo de alocação NÃO deve ser utilizado em um job, mas ainda precisa ser liberado após seu uso utilizando o método Dispose().

Allocator.TempJob:


É o tipo de alocação mais comum e mais utilizado. Apesar de ser um pouco mais lento que Allocator.Temp é seguro utilizá-lo em um job. Sua vida útil é de 4 quadros e se o método Dispose não for chamado neste período, uma exceção será gerada.

Allocator.Persistent:


É a alocação mais lenta, mas pode durar a vida útil de todo o aplicativo. De acordo com a documentação da Unity, não deve ser utilizado onde o desempenho é cruscial.

Exemplo de alocação de um NativeArray:

NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

Tipos de Trabalhos

Antes de criarmos o nosso primeiro job, vou falar sobre os dois tipos de trabalho (interfaces) mais importantes. Existem outros, mas são utilizados em situações mais específicas.

  • IJob:
    Permite agendar um trabalho único em paralelo ao thread princial e a outros threads. O método Execute será chamado em um Thread de trabalho.
  • IJobParallelFor:
    Imagine que você precisa executar um trabalho várias vezes em objetos diferentes percorrendo um laço for ou foreach, um cenário comum em um jogo. IJobParallelFor utiliza um NativeArray para fazer isso, executando o mesmo trabalho várias vezes em vários núcleos para cada elemento.
    Neste caso, o método execute é executado uma uma vez em cada elemento do NativeArray passado. Não se preocupe se parecer complicado agora, pois não é.

Trabalhando com com IJob

No exemplo abaixo, estamos criando e agendando um trabalho simples utilizando a Interface IJob. Como dito anteriormente, os trabalhos utilizam uma cópia de dados bittable para evitar condições de corrida e é exatamente por isso que utilizamos um struct para criar um trabalho.

Iremos criar um trabalho que soma dois números inteiros e armazena o resultado no primeiro elemento de um NativeArray. Em seguida, iremos agendar este trabalho através de um método normal em um MonoBehaviour e imprimir o resultado.

Certifique-se de estar utilizando os seguintes Namespaces:

using UnityEngine;
using Unity.Jobs;
using Unity.Collections;

Criamos a estrutura de nosso trabalho contendo os campos para os dados necessários e no método Execute(), realizamos a soma e armazenamos o resultado no primeiro elemento do nosso NativeArray:

public struct SumNumbers : IJob
    {
        public int numA;
        public int numB;

        public NativeArray<int> result;
        public void Execute()
        {
            result[0] = numA + numB;
        }
    }

Com o nosso trabalho criado, precisamos dizer ao JobSystem que ele deve ser agendado. O agendamento sempre é feito no thread principal.

Por questões de boas práticas ( e talvez um pouco de mania ) eu gosto de criar métodos para o agendamento de trabalhos. Assim eu posso reaproveitar o código, além de as coisas fazerem mais sentido para mim. 

Abaixo, o método responsável pelo agendamento. O código está comentado e é auto-explicativo:

public JobHandle ScheduleSimplejob(int numA, int numB, ref int resultOutPut)
    {
        //NativeContainer do tipo Nativearray responsável por armazenar o nosso resultado.
        NativeArray<int> m_result = new NativeArray<int>(1, Allocator.TempJob);

        //Cria uma instância do nosso trabalho (struct) contendo os dados necessários.
        SumNumbers jobData = new SumNumbers { 
            numA = numA, 
            numB = numB, 
            result = m_result
        };

        //Agenda o nosso trabalho.
        JobHandle handle = jobData.Schedule();

        // Aguarda até que o trabalho esteja completo.
        handle.Complete();

        //Seta a variável de saída com o resultado antes de liberá-la da memória.
        resultOutPut = m_result[0];
        //Libera o NativeArray da memória.
        m_result.Dispose();

        //Retorna o Handle para utilização caso necessário.
        return handle;
    }

Nota:
A razão de utilizarmos um NativeArray para armazenar o resultado está descrita no tópico Native Containers.

Com nosso trabalho e método para agendamento prontos, podemos chamar o método normalmente, Abaixo, chamamos o método através do método Start():

public class SimpleJob: MonoBehaviour
{
    //Armazenará o resultado de nosso trabalho.
    int JobResult;
    private void Start()
    {
            //Chamamos o método de agendamento do  nosso trabalho passando a variável JobResult que receberá o nosso resultado.
            JobHandle handle = ScheduleSimplejob(Random.Range(1, 1000), Random.Range(1, 1000), ref JobResult);

            Debug.Log(JobResult);
    }
[...]

O código completo deverá se parecer com isso:

using UnityEngine;
using Unity.Jobs;
using Unity.Collections;

public class SimpleJob: MonoBehaviour
{
    //Armazenará o resultado de nosso trabalho.
    int JobResult;
    private void Start()
    {
        //Chamamos o método de agendamento do  nosso trabalho passando a variável JobResult que receberá o nosso resultado.
        JobHandle handle = ScheduleSimplejob(Random.Range(1, 1000), Random.Range(1, 1000), ref JobResult);

        Debug.Log(JobResult);
        
    }


    public struct SumNumbers : IJob
    {
        public int numA;
        public int numB;

        public NativeArray<int> result;
        public void Execute()
        {
            result[0] = numA + numB;
        }
    }


    public JobHandle ScheduleSimplejob(int numA, int numB, ref int resultOutPut)
    {
        //NativeContainer do tipo Nativearray responsável por armazenar o nosso resultado.
        NativeArray<int> m_result = new NativeArray<int>(1, Allocator.TempJob);

        //Cria uma instância do nosso trabalho (struct) contendo os dados necessários.
        SumNumbers jobData = new SumNumbers { 
            numA = numA, 
            numB = numB, 
            result = m_result
        };

        //Agenda o nosso trabalho.
        JobHandle handle = jobData.Schedule();

        // Aguarda até que o trabalho esteja completo.
        handle.Complete();

        //Seta a variável de saída com o resultado antes de liberá-la da memória.
        resultOutPut = m_result[0];
        //Libera o NativeArray da memória.
        m_result.Dispose();

        //Retorna o Handle para utilização caso necessário.
        return handle;
    }
}

Se quiséssemos, poderíamos agendar nosso trabalho diversas vezes dentro do método Update(), por exemplo. Desta maneira, o agendador de trabalhos da Unity agendaria o trabalho várias vezes em vários threads:

private void Update()
    {
        for (int i = 0; i < 50; i++)
        {
            //Chamamos o método de agendamento do  nosso trabalho passando a variável JobResult que receberá o nosso resultado.
            JobHandle handle = ScheduleSimplejob(Random.Range(1, 1000), Random.Range(1, 1000), ref JobResult);

            Debug.Log(JobResult);
        }
    }

No exemplo acima, agendamos o nosso trabalho 50 vezes por quadro. O agendador de trabalhos da unity distribui os trabalhos entre os threads disponíveis automaticamente.

Resultado do Profile. (Os pontos azuis referem-se ao nosso SimpleJob:SumNumbers).

Trabalhando com com IJobParallelFor

Um IJobParallelFor se comporta de maneira semelhante a um IJob, mas ao invés de executar a tarefa uma única vez, ele executa o trabalho uma vez para cada índice do NativeArray passado.

Quando utilizamos IJobParallelFor, internamente a Unity divide os trabalhos em lotes e distribui estes trabalhos entre os núcleos do processador atômicamente. Para otimizar este processo, precisamos definir a quantidade de lotes que cada núcleo receberá.

Para o próximo exemplo, vamos criar um trabalho com três matrizes. O Job irá varrer as duas primeiras somando seus elementos em sequência e armazenando o resultado na terceira matriz. A diferença aqui, é que IJobParallelFor varrerá estas matrizes e “distribuirá” o método Execute entre os threads disponíveis paralelamente.
Criando o nosso trabalho:

public struct MyParallelJob : IJobParallelFor
{
    [ReadOnly]
    public NativeArray<float> a;
    [ReadOnly]
    public NativeArray<float> b;
    public NativeArray<float> result;

    public void Execute(int i)
    {
        result[i] = a[i] + b[i];
    }
}

Como de costume, vamos criar um método para agendar o nosso trabalho. O código está comentado e é auto-descritivo:

public JobHandle ScheduleParallelJob(int CalcSize, ref NativeArray<float> result)
    {
        NativeArray<float> elements_A = new NativeArray<float>(CalcSize, Allocator.TempJob);
        NativeArray<float> elements_B = new NativeArray<float>(CalcSize, Allocator.TempJob);
        NativeArray<float> m_result = new NativeArray<float>(CalcSize, Allocator.TempJob);

        //Preenche os arrays com os números a serem somados.
        for (int i = 0; i < CalcSize; i++)
        {
            elements_A[i] = Random.Range(1, 2000);
            elements_B[i] = Random.Range(1, 2000);
        }

        //Cria a instância de nosso IJobParallelFor com os dados necessários.
        MyParallelJob jobData = new MyParallelJob
        {
            a = elements_A,
            b = elements_B,
            result = m_result
        };

        //Agenda o nosso trabalho passando o tamanho da matriz e a quantidade de trabalhos por thread.
        JobHandle handle = jobData.Schedule(CalcSize, 1);
        //Aguarda até que os trabalhos estejam completos.
        handle.Complete();
        //Libera os NativeContainers da memória.
        elements_A.Dispose();
        elements_B.Dispose();
        //Copia a matriz de resultados para a nossa referência de saída.
        m_result.CopyTo(result);
        //Libera m_result da memória.
        m_result.Dispose();

        return handle;
    }

E por fim, podemos chamar o nosso método:

public class ParallelForTest : MonoBehaviour
{
    private void Start()
    {
        //Container que guardará nossos resultados temporáriamente.
        NativeArray<float> result = new NativeArray<float>(10, Allocator.Temp);
        ScheduleParallelJob(10, ref result);
        //Debugamos os nossos resultados.
        for (int i = 0; i < result.Length; i++)
            Debug.Log("Resultado [" + i + "]: " + result[i]);
        //Liberamos o nosso container temporário da memória.
        result.Dispose();
    }
[...]

O código completo deverá se parecer com isto:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Jobs;
using Unity.Collections;

public class ParallelForTest : MonoBehaviour
{
    private void Start()
    {
        //Container que guardará nossos resultados temporáriamente.
        NativeArray<float> result = new NativeArray<float>(10, Allocator.Temp);
        ScheduleParallelJob(10, ref result);
        //Debugamos os nossos resultados.
        for (int i = 0; i < result.Length; i++)
            Debug.Log("Resultado [" + i + "]: " + result[i]);
        //Liberamos o nosso container temporário da memória.
        result.Dispose();
    }

    public JobHandle ScheduleParallelJob(int CalcSize, ref NativeArray<float> result)
    {
        NativeArray<float> elements_A = new NativeArray<float>(CalcSize, Allocator.TempJob);
        NativeArray<float> elements_B = new NativeArray<float>(CalcSize, Allocator.TempJob);
        NativeArray<float> m_result = new NativeArray<float>(CalcSize, Allocator.TempJob);

        //Preenche os arrays com os números a serem somados.
        for (int i = 0; i < CalcSize; i++)
        {
            elements_A[i] = Random.Range(1, 2000);
            elements_B[i] = Random.Range(1, 2000);
        }

        //Cria a instância de nosso IJobParallelFor com os dados necessários.
        MyParallelJob jobData = new MyParallelJob
        {
            a = elements_A,
            b = elements_B,
            result = m_result
        };

        //Agenda o nosso trabalho passando o tamanho da matriz e a quantidade de trabalhos por thread.
        JobHandle handle = jobData.Schedule(CalcSize, 1);
        //Aguarda até que os trabalhos estejam completos.
        handle.Complete();
        //Libera os NativeContainers da memória.
        elements_A.Dispose();
        elements_B.Dispose();
        //Copia a matriz de resultados para a nossa referência de saída.
        m_result.CopyTo(result);
        //Libera m_result da memória.
        m_result.Dispose();

        return handle;
    }
}


public struct MyParallelJob : IJobParallelFor
{
    [ReadOnly]
    public NativeArray<float> a;
    [ReadOnly]
    public NativeArray<float> b;
    public NativeArray<float> result;

    public void Execute(int i)
    {
        result[i] = a[i] + b[i];
    }
}

Dependências entre Trabalhos:

Como dito anteriormente, quando agendamos um trabalho, podemos declarar outros trabalhos como dependências. Muito útil quando precisamos que um trabalho aguarde até que outro esteja completo. 

No exemplo a seguir, temos dois trabalhos onde o primeiro faz a soma de dois inteiros e o segundo adiciona 100 ao resultado. Para que isso seja possível, precisamos que o segundo trabalho saiba onde está o primeiro resultado na memória. Além disso, também ordenamos a execução deles fazendo com que Job2 tenha como dependência o Job1.

Nossos trabalhos:

public struct JobOne : IJob
    {
        [ReadOnly]
        public int numA;
        [ReadOnly]
        public int numB;
        [WriteOnly]
        public NativeArray<int> results;
        public void Execute()
        {
            results[0] = numA + numB;
        }
    }

 public struct JobTwo : IJob
    {
        public NativeArray<int> results;
        public void Execute()
        {
            results[0] = results[0] + 100;
        }
    }

Agendando os trabalhos ordenadamente e combinando suas dependências:
Mais uma vez, o código está comentado e é auto-descritivo.

void Start()
    {
        //Armazenará nosso resultado.
        NativeArray<int> m_results = new NativeArray<int>(1,Allocator.TempJob);

        //Criamos e agendamos nosso primeiro trabalho.
        JobOne job1 = new JobOne
        {
            numA = 25,
            numB = 25,
            results = m_results
        };

        JobHandle handleOne = job1.Schedule();
        
        
        //Criamos e agendamos o segundo Job passando como dependência o job1.
        JobTwo job2 = new JobTwo
        {
            results = m_results
        };

        JobHandle handleTwo = job2.Schedule(handleOne);
        

        //Criamos um NativeArray do tipo JobHandle para guardar os handles dos trabalhos que terão suas dependências combinadas.
        //Em nosso exemplo a dependência é o NativeArray results.
        NativeArray<JobHandle> JobsToExecute = new NativeArray<JobHandle>(2, Allocator.Temp);
        JobsToExecute[0] = handleOne;
        JobsToExecute[1] = handleTwo;

        //Combinamos as dependências de fato.
        JobHandle.CombineDependencies(JobsToExecute);
        //Faz com que o Thread principal aguarde até que nossos Jobs estejam completos.
        JobHandle.CompleteAll(JobsToExecute);

        Debug.Log("Resultado de JOB1 + resultado de JOB2 = " + m_results[0]);

        //Liberamos o container da memória.
        m_results.Dispose();
    }

Código completo:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Jobs;
using Unity.Collections;

public class JobDependencies : MonoBehaviour
{
    void Start()
    {
        //Armazenará nosso resultado.
        NativeArray<int> m_results = new NativeArray<int>(1,Allocator.TempJob);

        //Criamos e agendamos nosso primeiro trabalho.
        JobOne job1 = new JobOne
        {
            numA = 25,
            numB = 25,
            results = m_results
        };

        JobHandle handleOne = job1.Schedule();
        
        
        //Criamos e agendamos o segundo Job passando como dependência o job1.
        JobTwo job2 = new JobTwo
        {
            results = m_results
        };

        JobHandle handleTwo = job2.Schedule(handleOne);
        

        //Criamos um NativeArray do tipo JobHandle para guardar os handles dos trabalhos que terão suas dependências combinadas.
        //Em nosso exemplo a dependência é o NativeArray results.
        NativeArray<JobHandle> JobsToExecute = new NativeArray<JobHandle>(2, Allocator.Temp);
        JobsToExecute[0] = handleOne;
        JobsToExecute[1] = handleTwo;

        //Combinamos as dependências de fato.
        JobHandle.CombineDependencies(JobsToExecute);
        //Faz com que o Thread principal aguarde até que nossos Jobs estejam completos.
        JobHandle.CompleteAll(JobsToExecute);

        Debug.Log("Resultado de JOB1 + resultado de JOB2 = " + m_results[0]);

        //Liberamos o container da memória.
        m_results.Dispose();
    }


    public struct JobOne : IJob
    {
        [ReadOnly]
        public int numA;
        [ReadOnly]
        public int numB;
        [WriteOnly]
        public NativeArray<int> results;
        public void Execute()
        {
            results[0] = numA + numB;
        }
    }

    public struct JobTwo : IJob
    {
        public NativeArray<int> results;
        public void Execute()
        {
            results[0] = results[0] + 100;
        }
    }
}

Burst

Antes de começarmos a comparar o desempenho de nosso código MultiThread em relação ao Mono, devemos conhecer esta que na minha opinião é a cereja do bolo.
O Brust é um compilador de alto desempenho que foi projetado principalmente para melhorar a eficiência do Job System. Ele está disponível no gerenciador de pacotes da Unity, e obviamente é preciso instalá-lo para que seja utilizado.

O aumento drástico no desempenho se deve ao fato de o Brust converter o código em uma versão nativa e altamente otimizada utilizando LLVM.
Para utilizar o Burst basta marcar  as estruturas com a flag [BurstCompile]:

    [BurstCompile]
    public struct ReallyToughJob : IJob
    {
        [ReadOnly]
        public float calcs;
        public void Execute()
        {
            float value = 0f;
            for (int i = 0; i < calcs; i++)
            {
                value = math.exp10(math.sqrt(value));
            }
        }
    }

Também é necessário marcar a opção Enable Compilation no menu Jobs>Burst:

Mathematics

Mathematics é uma bibliteca matemática que fornece tipos de vetores e operações matemáticas. Seu grande diferencial é ser amigável ao compilador burst, fazendo assim com que seja possível tirar proveito de todos os seus recursos.

Para utilizá-la é necessário realizar sua instalação através do gerenciador de pacotes e fazer uso do namespace Unity.Mathematics.

Comparando o desempenho

É hora de compararmos o desempenho de um código MultiThread utilizando Burst+Mathematics em relação ao mesmo código SingleThread.

Para efetuarmos o teste, criaremos um trabalho e um método, ambos fazendo um cálculo complexo o mesmo número de vezes:

/// <summary>
    /// Executa um cálculo complexo determinada quantidade de vezes.
    /// </summary>
    private void ReallyToughTask()
    {
        float value = 0f;
        for (int i = 0; i < Calcs; i++)
        {
            value = math.exp10(math.sqrt(value));
        }
    }

    /// <summary>
    /// Nosso trabalho executa o mesmo cálculo de ReallyToughTask, mas utilizando 
    /// JobSystem e Burst.
    /// </summary>
    [BurstCompile]
    public struct ReallyToughJob : IJob
    {
        [ReadOnly]
        public float calcs;
        public void Execute()
        {
            float value = 0f;
            for (int i = 0; i < calcs; i++)
            {
                value = math.exp10(math.sqrt(value));
            }
        }
    }

Criamos uma variável booleana que determinará se estaremos utilizando o JobSystem e outra para determinar a quantidade de cálculos executados de cada vez:

public class PerformanceTest : MonoBehaviour
{
    //Determina se usaremos Jobs ou não.
    public bool UseJobs = false;
    //Número de cálculos a serem processados.
    public int Calcs;
   
    private void Update()
    {
        if (UseJobs)
        {
            //Cria e agenda o trabalho caso UseJobs seja verdadeiro.
            ReallyToughJob job = new ReallyToughJob { calcs = Calcs };
            JobHandle handle = job.Schedule();
            handle.Complete();
        }
        else
        {
            //Caso não use jobs, chama o método ReallyToughTask.
            ReallyToughTask();
        }
    }
[...]

O script completo deverá se parecer com este:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Jobs;
using Unity.Burst;
using Unity.Collections;
using Unity.Mathematics;

public class PerformanceTest : MonoBehaviour
{
    //Determina se usaremos Jobs ou não.
    public bool UseJobs = false;
    //Número de cálculos a serem processados.
    public int Calcs;
   
    private void Update()
    {
        if (UseJobs)
        {
            //Cria e agenda o trabalho caso UseJobs seja verdadeiro.
            ReallyToughJob job = new ReallyToughJob { calcs = Calcs };
            JobHandle handle = job.Schedule();
            handle.Complete();
        }
        else
        {
            //Caso não use jobs, chama o método ReallyToughTask.
            ReallyToughTask();
        }
    }

    /// <summary>
    /// Executa um cálculo complexo determinada quantidade de vezes.
    /// </summary>
    private void ReallyToughTask()
    {
        float value = 0f;
        for (int i = 0; i < Calcs; i++)
        {
            value = math.exp10(math.sqrt(value));
        }
    }

    /// <summary>
    /// Nosso trabalho executa o mesmo cálculo de ReallyToughTask, mas utilizando 
    /// JobSystem e Burst.
    /// </summary>
    [BurstCompile]
    public struct ReallyToughJob : IJob
    {
        [ReadOnly]
        public float calcs;
        public void Execute()
        {
            float value = 0f;
            for (int i = 0; i < calcs; i++)
            {
                value = math.exp10(math.sqrt(value));
            }
        }
    }
}

Arraste o script para um objeto vazio na cena e, defina a quantidade de cáculos de acordo com o seu processador. Em meu i5 velho de guerra, defini o valor para 300000:

O Resultado:

Saltamos de uma média de 57ms(17.8 FPS) para 2.6ms (385 FPS). É um aumento de desempenho considerável, e que ainda pode ser melhorado!

Considerações

Algumas bibliotecas utilizadas aqui ainda estão em modo preview e seu uso pode mudar até que elas sejam consideradas estáveis. Trabalhar com um sistema MultiThread já multiplica o desempenho do código por si só. Aliar isso a uma biblioteca matemática otimizada e ao compilador Burst aumenta drasticamente o desempenho do nosso código. 

Aqui nós vimos apenas algumas das otimizações  possíveis utilizando o JobSystem. Mais adiante, veremos como aumentar ainda mais o desempenho de nossos jogos aliando isso ao ECS.

artplayer

Author artplayer

More posts by artplayer

Join the discussion 4 Comments

Deixe uma resposta

Receber notícias

Digite seu endereço de e-mail para receber notificações de novas publicações por e-mail.
%d blogueiros gostam disto: