Melhorando o desempenho do sistema de empregos em escala em 2022.2 – parte 1: Histórico e API

KEVIN MACAULAY VACHERESSE / UNITY TECHNOLOGIESLead Engineer
Feb 24, 2023|13 Min
Melhorando o desempenho do sistema de empregos em escala em 2022.2 – parte 1: Histórico e API
Esta página da Web foi automaticamente traduzida para sua conveniência. Não podemos garantir a precisão ou a confiabilidade do conteúdo traduzido. Se tiver dúvidas sobre a precisão do conteúdo traduzido, consulte a versão oficial em inglês da página da Web.

Em 2022.2 e 2021.3.14f1, melhoramos o custo de agendamento e o dimensionamento de desempenho do sistema de trabalhos do Unity . Neste artigo de duas partes, oferecerei uma breve recapitulação da programação paralela e dos sistemas de tarefas, discutirei a sobrecarga do sistema de tarefas e compartilharei a abordagem da Unity para mitigá-la.

Na primeira parte, abordamos informações básicas sobre programação paralela e a API do sistema de trabalho. Se você já estiver familiarizado com paralelismo, sinta-se à vontade para dar uma olhada e pular para a parte dois.

Antecedentes sobre paralelismo

Na versão 2017.3, uma API pública C# foi adicionada para o sistema de trabalho interno C++ Unity , permitindo que os usuários escrevam pequenas funções chamadas “trabalhos”, que são executadas de forma assíncrona. A intenção por trás do uso de jobs em vez de funções antigas é fornecer uma API que torne fácil, seguro e eficiente permitir que o código que, de outra forma, seria executado no thread principal, seja executado em threads "workers" de job, idealmente em paralelo. Isso ajuda a reduzir a quantidade total de tempo de espera que o thread principal precisa para concluir a simulação de um jogo. Usar o sistema de tarefas para o trabalho da CPU pode proporcionar melhorias significativas no desempenho e permitir que o desempenho do seu jogo seja dimensionado naturalmente conforme o hardware em que ele é executado melhora.

Se você pensar na computação como um recurso finito, um único núcleo de CPU só pode fazer uma certa quantidade de “trabalho” computacional em um determinado período de tempo. Por exemplo, se um jogo de thread única precisa que sua simulação Update() não leve mais que 16 ms, mas atualmente leva 24 ms, então a CPU tem muito trabalho a fazer – mais tempo é necessário. Para atingir a meta de 16 ms, há apenas duas opções: fazer a CPU funcionar mais rápido (por exemplo, aumentar as especificações mínimas do seu jogo – normalmente não é uma ótima opção) ou trabalhar menos.

void Update()
{
    // <lots of simulation logic...> 
}
Uma função Update() sendo executada por 24 ms no thread principal
Uma função Update() sendo executada por 24 ms no thread principal

No final das contas, você precisa eliminar 8 ms de trabalho computacional. Isso normalmente significa melhorar algoritmos, distribuir o trabalho do subsistema em vários quadros, remover trabalho redundante que pode se acumular durante o desenvolvimento, etc. Se isso ainda não atingir sua meta de desempenho, talvez seja necessário reduzir a complexidade da simulação do jogo cortando o conteúdo e a jogabilidade, por exemplo, reduzindo o número de inimigos que podem ser gerados de uma vez — o que certamente não é o ideal.

E se, em vez de eliminar trabalho, o entregássemos para outro núcleo de CPU executar? Hoje em dia, a maioria das CPUs são multi-core, o que significa que o poder computacional single-threaded disponível pode ser multiplicado pelo número de núcleos que a CPU possui. Se pudéssemos dividir magicamente e com segurança todo o trabalho atualmente na função Update() entre dois núcleos de CPU, o trabalho de Update() de 24 ms poderia ser executado em dois blocos simultâneos de 12 ms. Isso nos deixaria bem abaixo da meta de 16 ms. Além disso, se pudéssemos dividir o trabalho em quatro partes paralelas e executá-las em quatro núcleos, o Update() levaria apenas 6 ms!

Esse tipo de divisão de trabalho e execução em todos os núcleos disponíveis é conhecido como escalonamento de desempenho. Se você adicionar mais núcleos, o ideal é que você execute mais trabalho em paralelo, reduzindo o tempo de espera do Update() sem alterações no código.

void Update()
{
    // Some magic has split our logic into 4 equal parts
    // that can run in parallel. Wowee!
    PartialUpdateA();
    PartialUpdateB();
    PartialUpdateC();
    PartialUpdateD();
}
Update() foi dividido em quatro atualizações parciais, cada uma executada em seu próprio thread

Infelizmente, isso é fantasia. Nada vai dividir a função Update() em partes e executá-las em núcleos separados sem alguma ajuda. Mesmo se trocássemos para uma CPU com 128 núcleos, a atualização de 24 ms() acima ainda levaria 24 ms, desde que ambas as CPUs tenham a mesma taxa de clock. Que desperdício de potencial! Como, então, podemos escrever aplicativos para aproveitar todos os núcleos de CPU disponíveis e aumentar o paralelismo?

Uma abordagem é o multithreading. Ou seja, seu programa cria threads para executar uma função que o sistema operacional agendará para ser executada para você. Se sua CPU tiver vários núcleos, vários threads poderão ser executados ao mesmo tempo, cada um em seu próprio núcleo. Se houver mais threads do que núcleos disponíveis, o sistema operacional será responsável por determinar qual thread será executada em um núcleo – e por quanto tempo – antes de alternar para outro thread, um processo chamado troca de contexto.

No entanto, a programação multithread traz consigo uma série de complicações. No cenário mágico acima, a função Update() foi dividida igualmente em quatro atualizações parciais. Mas, na realidade, você provavelmente não seria capaz de fazer algo tão simples. Como os threads serão executados simultaneamente, você precisa ter cuidado quando eles lerem e gravarem os mesmos dados ao mesmo tempo, para evitar que eles corrompam os cálculos um do outro.

Isso geralmente envolve o uso de primitivas de sincronizaçãode bloqueio, como um mutex ou semáforo, para controlar o acesso ao estado compartilhado entre threads. Essas primitivas geralmente limitam a quantidade de paralelismo que seções específicas de código podem ter (geralmente optando por nenhuma) ao “bloquear” outras threads, impedindo-as de executar a seção até que o detentor do bloqueio termine e “desbloqueie” a seção para quaisquer threads em espera. Isso reduz o desempenho obtido ao usar vários threads, já que você não está executando em paralelo o tempo todo, mas garante que os programas permaneçam corretos.

Também provavelmente não faz sentido executar algumas partes da sua atualização em paralelo devido a dependências de dados. Por exemplo, quase todos os jogos precisam ler a entrada de um controle, armazenar essa entrada em um buffer de entrada e, então, ler o buffer de entrada e reagir com base nos 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();
}

Não faria sentido ter um código lendo o buffer de entrada para decidir se um caractere deveria pular a execução ao mesmo tempo que o código gravando no buffer de entrada para a atualização daquele quadro. Mesmo se você usasse um mutex para garantir que a leitura e a gravação em m_InputBuffer fossem seguras, você sempre desejaria que m_InputBuffer fosse gravado primeiro e, então, o código de leitura de m_InputBuffer fosse executado em segundo lugar, para que você soubesse se o botão de salto foi pressionado no quadro atual (e não em um anterior). Essas dependências de dados são comuns e normais, mas diminuirão a quantidade de paralelismo possível.

Há muitas abordagens para escrever um programa multithread. Você pode usar APIs específicas da plataforma para criar e gerenciar threads diretamente ou usar várias APIs que fornecem uma abstração para ajudar a gerenciar algumas das complicações da programação multithread.

Um sistema de empregos é uma dessas abstrações. Ele fornece os meios para dividir partes do seu código single-threaded em blocos lógicos, isolar quais dados são necessários para esse código, controlar quem acessa esses dados simultaneamente e executar tantos blocos de código em paralelo quanto possível para tentar utilizar todo o poder computacional disponível na CPU conforme necessário.

API do sistema de trabalho

Hoje, não podemos dividir funções arbitrárias em partes automaticamente, então o Unity fornece uma API de trabalho que permite aos usuários converter funções em pequenos blocos lógicos. A partir daí, o sistema de tarefas se encarrega de fazer com que essas peças sejam executadas em paralelo.

O sistema de empregos é composto por alguns componentes principais:

  • Empregos
  • Alças de trabalho
  • Agendador de tarefas
public struct MyJob : IJob
{
    public NativeArray<int> Data;
    public void Execute()
    {
        // Do some work using our Data member
    }
}

Como mencionado anteriormente, um trabalho é apenas uma função e alguns dados, mas esse encapsulamento é útil, pois reduz o escopo de quais dados específicos o trabalho lerá ou gravará.

var myJob = new MyJob() { Data = someNativeArray };
var jobHandle = myJob.Schedule();

Depois que uma instância de trabalho é criada, ela precisa ser agendada com o sistema de trabalho. Isso é feito com o método .Schedule() adicionado a todos os tipos de trabalho por meio do mecanismo de extensão do C#. Para identificar e acompanhar o trabalho agendado, um JobHandle é fornecido.

Como os identificadores de tarefas identificam tarefas agendadas, eles podem ser usados para configurar dependências de tarefas. Dependências de trabalho garantem que um trabalho agendado não começará a ser executado até que suas dependências sejam concluídas. Como resultado direto, eles também nos dizem quando diferentes tarefas podem ser executadas em paralelo, criando um gráfico de tarefas acíclicas direcionadas.

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 fim, conforme os trabalhos são agendados, o agendador de trabalhos é responsável por manter o controle dos trabalhos agendados (mapeando JobHandles para as instâncias de trabalho agendadas) e garantir que os trabalhos comecem a ser executados o mais rápido possível. A maneira como isso é feito é importante, pois os padrões de design e uso do sistema de tarefas podem potencialmente entrar em conflito de maneiras não óbvias, levando a custos indiretos que prejudicam os ganhos de desempenho da programação multithread. À medida que os usuários começaram a adotar o sistema de tarefas do C#, começamos a ver cenários em que a sobrecarga do sistema de tarefas era maior do que gostaríamos, o que levou a melhorias na implementação do sistema de tarefas interno da Unity no Tech Stream 2022.2.

Fique ligado na segunda parte, que explorará de onde vem a sobrecarga no sistema de tarefas do C# e como ela foi reduzida no Unity 2022.2.

Se você tiver dúvidas ou quiser saber mais, visite-nos no fórum do C# Job System. Você também pode se conectar comigo diretamente pelo Unity Discord com o nome de usuário @Antifreze#2763. Não deixe de ficar atento aos novos blogs técnicos de outros desenvolvedores do Unity como parte do processo contínuo SérieTech from the Trenches.