O que você está procurando?
Engine & platform

Aprimoramento da escala de desempenho do sistema de trabalho em 2022.2 - parte 2: Sobrecarga

KEVIN MACAULAY VACHERESSE / UNITY TECHNOLOGIESLead Engineer
Mar 14, 2023|20 Min
Aprimoramento da escala de desempenho do sistema de trabalho em 2022.2 - parte 2: Sobrecarga
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.

As versões 2022.2 e 2021.3.14f1 melhoraram o custo de agendamento e a escala de desempenho do sistema de trabalho do Unity. Na primeira parte deste artigo de duas partes sobre o que há de novo nos sistemas de trabalho, ofereci algumas informações básicas sobre programação paralela e por que o senhor pode usar um sistema de trabalho. Na segunda parte, vamos nos aprofundar no que é a sobrecarga do sistema de trabalho e na abordagem da Unity para mitigá-la.

Custos indiretos do sistema de trabalho

Overhead significa qualquer tempo que a CPU gasta sem executar o trabalho, desde o momento em que o senhor começa a programá-lo até o momento em que ele termina, desbloqueando os trabalhos em espera. Em termos gerais, há duas áreas em que o tempo é gasto:

1. A camada da API de trabalho do C#

2. O agendador de trabalhos nativo (que gerencia e executa todos os trabalhos agendados em C# e, internamente, em C++)

Sobrecarga da API de trabalho em C#

O objetivo da API de trabalho do C# é fornecer um meio seguro de acessar o sistema de trabalho nativo. Embora essa seja uma camada de vinculação para a transição de C# para C++, também é uma camada que permite que o senhor evite o agendamento acidental de trabalhos em C# que se depararão com condições de corrida ou deadlocks ao acessar NativeContainers de dentro de um trabalho.

Além disso, essa separação proporciona uma maneira mais rica de criar empregos. Na camada C++, os trabalhos são apenas um ponteiro para alguns dados e um ponteiro de função. Mas com a API C# na parte superior, é possível personalizar os tipos de trabalhos programados, permitindo um melhor controle sobre como os dados do trabalho devem ser divididos e paralelizados para se adequarem aos casos de uso específicos do usuário.

Ao programar um trabalho, a camada de vinculação de trabalho do C# copia a estrutura do trabalho em uma alocação de memória não gerenciada. Isso permite que o tempo de vida da estrutura do trabalho em C# seja desconectado do tempo de vida do trabalho no sistema de trabalho, uma vez que isso é afetado pelas dependências do trabalho e pela carga geral na plataforma. Em seguida, o sistema de trabalho realiza condicionalmente verificações de segurança nas compilações do Editor playmode para garantir que um trabalho seja seguro para execução.

Essas etapas são importantes, mas não são gratuitas e contribuem para a sobrecarga do sistema de trabalho. Como o tamanho do trabalho pode variar, assim como o número de NativeContainers e dependências que um trabalho pode ter, o custo para copiar trabalhos e validar sua segurança não é fixo. Por isso, é importante que a Unity mantenha os custos baixos e restritos à complexidade computacional linear.

No Tech Stream 2021.2, a equipe de engenharia fez melhorias significativas no sistema de segurança do trabalho, armazenando em cache o resultado da verificação de segurança para alças de trabalho individuais. Isso é particularmente importante, pois o sistema de segurança precisa entender cadeias inteiras de dependências de trabalhos e cada referência de memória nativa que todos os trabalhos contêm para entender quais informações de dependência podem estar faltando e a qual trabalho uma dependência deve ser adicionada. Isso pode resultar em uma quantidade não linear de itens a serem iterados durante o agendamento (ou seja, para cada trabalho e suas dependências, verifique o acesso de leitura/gravação para cada NativeContainer ao qual o trabalho se refere e qualquer trabalho que se refira aos NativeContainers).

No entanto, a Unity pode aproveitar o fato de que os trabalhos em C# são agendados apenas um de cada vez e verificar a segurança durante esse agendamento. Em vez de verificar novamente todos os trabalhos a cada agendamento, podemos determinar rapidamente se a revalidação das cadeias de dependência de trabalho é necessária ou não, permitindo que grandes quantidades de trabalho sejam ignoradas. Mesmo para pequenas cadeias de dependência de trabalho, isso reduz drasticamente o custo das verificações de segurança do trabalho. Idealmente, não deveria haver motivo para desativar as verificações de segurança do trabalho durante o desenvolvimento (as verificações de segurança do trabalho não estão ativadas nas compilações de jogador/transporte).

Agendador de trabalhos

Sempre que um trabalho em C# ou C++ é agendado para execução, ele passa pelo agendador de trabalhos. A função do planejador é:

  • Rastrear trabalhos por meio de identificadores de trabalho
  • Gerenciar as dependências do trabalho, garantindo que os trabalhos só comecem a ser executados depois que todas as dependências forem concluídas
  • Gerenciar "threads de trabalho", que são os threads que executarão os trabalhos
  • Garantir que os trabalhos sejam executados o mais rápido possível, o que geralmente significa que eles devem ser executados em paralelo quando as dependências permitirem

Além disso, embora a API de trabalho do C# só permita que os trabalhos sejam agendados a partir do thread principal, o agendador de trabalho precisa oferecer suporte a vários threads que agendam trabalhos ao mesmo tempo. Isso ocorre porque o mecanismo subjacente do Unity usa muitos threads que agendam trabalhos e podem até mesmo agendar trabalhos dentro de trabalhos. Essa funcionalidade tem seus prós e contras, mas exige muito mais controle da correção e acrescenta o requisito de que o agendador de tarefas seja seguro para threads.

Na versão 2017.3, a aparência básica do agendador de tarefas era a seguinte:

  • Fila de espera para trabalhos
  • Pilha de empregos
  • Semáforo
  • Conjunto de threads de trabalho

O uso típico segue esse padrão: À medida que os trabalhos são agendados, eles são enfileirados em uma fila global, livre de bloqueios, com vários produtores e vários consumidores, que representa os trabalhos que estão prontos para serem manipulados por um thread de trabalho. Em seguida, o thread principal sinaliza usando um semáforo para despertar os threads de trabalho.

O número de workers que devem ser ativados depende do tipo de job que está sendo agendado - jobs únicos, como o IJob, ativam apenas um único worker, pois esse tipo de job não distribui o trabalho entre vários threads de workers. Os trabalhos IJobParallelFor, entretanto, representam várias partes do trabalho que podem ser executadas em paralelo. Enquanto um trabalho é programado, pode haver muitas peças para alguns ou todos os trabalhadores ajudarem ao mesmo tempo. Assim, o agendador calcula quantos funcionários podem ajudar e desperta esse número.

Uma vez acordados, os threads de trabalho são onde o trabalho real acontece. Em 2017.3, eles eram responsáveis por retirar um trabalho da fila de trabalhos, garantindo que todas as dependências relevantes do trabalho fossem concluídas. Se ainda não estivessem concluídos, o trabalho e as dependências incompletas seriam adicionados a uma pilha livre de bloqueios como uma forma de pular para a frente da fila para tentar executar novamente. Os threads de trabalho fazem isso em um loop até que o mecanismo sinalize que deseja encerrar ou que não haja mais trabalhos na pilha e na fila. Nesse momento, os threads de trabalho entram em suspensão, aguardando um sinal do semáforo do thread principal.

while(!scheduler.isQuitting)
{
    // Usually empty unless we need to prioritize a dependency
    // to unblock a job we got from the queue. Alternatively 
    // pieces of work from a IJobParallelFor job can end up here to let
    // many workers help finish IJobParallelFor work quickly
    Job* pJob = m_stack.pop();
    if(!pJob)
        Job* pJob = m_queue.dequeue();

    if(pJob) {
        // ExecuteJob if all dependencies are complete, otherwise
        // push this job and the dependencies to the stack and try again
        if(EnsureDependenciesAreCompleteOtherwiseAddToStack(pJob))
            ExecuteJob(pJob);
    }
    else
    {
        // Put the thread to sleep until more jobs are scheduled
        m_semaphore.Wait(1);
    }
}

O agendador de tarefas cria tantos threads de trabalho quanto o número de núcleos virtuais na CPU, menos um por padrão. A intenção aqui é que cada thread de trabalho seja executado em seu próprio núcleo de CPU, deixando um núcleo de CPU livre para que o thread principal continue em execução. Na prática, em plataformas em que um núcleo não é reservado para processos que não sejam do jogo, pode ser melhor reduzir a quantidade de threads de trabalho para que a computação feita pelo sistema operacional ou pelos threads de driver não concorra com os threads de trabalho principais ou de trabalho do jogo.

Como a thread principal é o principal local de onde os trabalhos são agendados, é muito importante não atrasar a thread principal. Isso afeta diretamente o número de trabalhos que entram no sistema de trabalhos e, portanto, o grau de paralelismo que pode ocorrer em um quadro.

Com o thread principal teoricamente agendando muitos trabalhos e o restante dos núcleos da CPU executando esses trabalhos, devemos ser capazes de maximizar a quantidade de trabalho paralelo que pode ser feito na CPU e permitir que o desempenho seja escalonado à medida que o hardware muda. Se tivéssemos mais threads de trabalho do que núcleos, o sistema operacional poderia alternar o contexto do thread principal e alternar para um thread de trabalho. Ter um thread de trabalho adicional em execução pode ajudar a esvaziar a fila de trabalhos mais rapidamente, mas certamente impediria que novos trabalhos entrassem na fila, o que, em última análise, tem um efeito negativo maior sobre o desempenho.

Sobrecarga de sinalização de thread

Há alguns problemas em potencial com a abordagem do agendador de trabalhos acima que podem levar à sobrecarga do sistema de trabalhos. Vamos dar uma olhada em alguns exemplos.

A thread principal agenda um IJob (trabalho não paralelo) sem dependências:

  • Um trabalho é adicionado à fila e um thread de trabalho é sinalizado para ser ativado
  • Um thread de trabalho é ativado
  • O trabalhador executa o trabalho
  • O trabalhador verifica se há mais trabalhos a serem executados
  • O trabalhador vai dormir, pois não há mais empregos

Quando a thread principal sinalizar usando o semáforo do agendador de tarefas, uma das threads de trabalho adormecidas (não necessariamente a worker 0) será ativada. O despertar e a troca de contexto levam algum tempo no núcleo de trabalho. Isso ocorre porque, enquanto o thread de trabalho está adormecido, o núcleo da CPU no qual o thread de trabalho acabará sendo executado provavelmente estava fazendo alguma coisa - talvez executando outro thread gerado pelo jogo ou algum outro processo na máquina que estava usando o thread.

Para permitir que os threads sejam pausados e retomados posteriormente, o estado do registro de um thread precisa ser salvo, os pipelines de instrução precisam ser liberados e o estado do thread alternado precisa ser restaurado. Até mesmo a sinalização do thread leva tempo no núcleo do thread principal, uma vez que a notificação de qual thread deve ser ativado é tratada pelo sistema operacional. Em última análise, tudo isso significa que o trabalho está sendo feito no núcleo do thread principal e no núcleo do thread de trabalho que não é nosso trabalho e, portanto, é uma sobrecarga que queremos reduzir.

Imagem
Um trabalho é agendado no thread principal e, por fim, é executado no thread Worker 0. A execução do trabalho é atrasada pela sobrecarga de sinalização do Worker 0 para acordar no thread principal, o tempo de troca de contexto no thread do Worker 0 e o tempo que o sistema de trabalho leva para encontrar o trabalho a ser executado.

A rapidez com que os funcionários podem ser notificados e o tempo que um trabalho individual leva para ser executado também podem ter um impacto no sistema. Por exemplo, se o senhor usar o caso de uso acima, mas agendar dois trabalhos em vez de um:

  • Um trabalho é adicionado à fila e um thread de trabalho é sinalizado para ser ativado
  • O segundo trabalho é adicionado à fila e um thread de trabalho é sinalizado para ser ativado
  • Em alguma ordem, mas duas vezes:
  • Um thread de trabalho é ativado
  • Um trabalhador executa o trabalho
  • O trabalhador verifica se há mais trabalhos a serem executados
  • O trabalhador vai dormir, pois não há mais empregos

Se o tempo for adequado, o senhor terá dois trabalhadores trabalhando paralelamente na tarefa.

Imagem
Um trabalho paralelo é agendado no thread principal e, por fim, é executado no thread Worker 0 e Worker 1 simultaneamente.

No entanto, se um dos trabalhos for muito pequeno e/ou levar muito tempo para sinalizar e despertar os dois trabalhadores, um deles poderá roubar todo o trabalho da fila e, como resultado, sinalizamos um trabalhador sem motivo.

Imagem
Dois trabalhos são agendados no thread principal, mas ambos são executados no Worker 0 porque o Worker 1 não acorda antes que o Worker 0 consuma todos os trabalhos na fila de trabalhos. Pode haver muitos threads que não são de trabalho no sistema ocupando núcleos de CPU, ou os trabalhos são muito pequenos para dar aos threads de trabalho tempo suficiente para acordar em média.

Esse tipo de fome de trabalho e o ciclo wake <-> sleep podem acabar sendo bastante caros e limitar a quantidade de paralelismo que o sistema de trabalho oferece.

O senhor pode estar pensando: "A sobrecarga da sinalização de threads e da alternância de contexto não é um custo de fazer negócios ao lidar com threads?" O senhor certamente não está errado. Mas, embora o senhor não tenha controle direto sobre o custo da sinalização ou da ativação de threads, pode controlar a frequência com que essas operações ocorrem.

Uma solução para evitar acordar os funcionários sem motivo é acordá-los somente quando o senhor suspeitar que há muitos itens de trabalho na fila para os funcionários assumirem, justificando o custo de acordar. Isso pode ser feito por meio de lotes: Em vez de sinalizar os trabalhadores assim que o senhor programar um trabalho, adicione o trabalho a uma lista e, em horários específicos, envie esse lote de trabalhos para o sistema de trabalho, despertando uma quantidade adequada de trabalhadores ao mesmo tempo.

Imagem
Dois trabalhos são agendados em um lote e, em seguida, todo o lote é descarregado, despertando dois trabalhadores quase ao mesmo tempo. Essa abordagem de lotes aumenta as chances de ambos os trabalhadores encontrarem trabalho quando acordarem.

Ainda há o risco de a ativação real demorar muito, de os trabalhos em lote serem muito pequenos ou de o número de trabalhos em um lote não ser muito alto. Em geral, quanto mais trabalhos o senhor incluir no lote, maior será a probabilidade de evitar a sobrecarga de despertar threads sem motivo. A Unity mantém um lote global que é liberado sempre que uma chamada para JobHandle.Complete() é feita. Portanto, se o senhor precisar esperar explicitamente pela conclusão de um trabalho, tente fazer isso o mais tarde e com menos frequência possível e, em geral, prefira agendar trabalhos com dependências de trabalho para controlar melhor o acesso seguro aos dados.

O senhor também pode estar se perguntando: "Se a sinalização de threads e a espera para que eles acordem/adormeçam são apenas despesas gerais, por que não mantemos nossos threads acordados o tempo todo procurando trabalho?" Quando há muitos trabalhos na fila, isso pode ocorrer naturalmente. A menos que o sistema operacional considere que o thread de trabalho tem prioridade mais baixa do que algum outro trabalho (ou que seja explicitamente dividido no tempo e deva ser trocado para dar a outros threads sua parte justa do tempo de CPU - isso depende da sua plataforma), os threads de trabalho continuarão a trabalhar com prazer.

No entanto, assim como nas funções PartialUpdateA e PartialUpdateB que vimos na primeira parte, nem todos os trabalhos são paralelizáveis e livres de dependências de dados. Dessa forma, o senhor geralmente precisa aguardar a conclusão de um subconjunto de trabalhos antes de executar outros. Como resultado, vemos gargalos no paralelismo de um gráfico de trabalho quando há menos trabalhos executáveis (trabalhos sem dependências pendentes) do que threads de trabalho, o que faz com que alguns trabalhadores não tenham mais nada de produtivo para fazer.

Se o senhor não permitir que os threads de trabalho durmam, poderá ter vários problemas. Quando os threads de trabalho verificam constantemente se há novos trabalhos e não encontram nenhum, isso é considerado "espera ocupada" ou trabalho desnecessário que não faz o programa progredir. Manter todos os núcleos em execução com o máximo de paralelismo, mas sem progredir no jogo, consome a vida útil da bateria. Além disso, se um núcleo não tiver tempo ocioso, sem resfriamento suficiente, a temperatura da CPU aumentará, o que levará ao downclocking , ou seja, à execução mais lenta para evitar danos causados pelo superaquecimento. De fato, em plataformas móveis, não é incomum que núcleos inteiros da CPU sejam temporariamente desativados se ficarem muito quentes. Para um sistema de trabalho, a capacidade de usar núcleos de forma eficiente é muito importante, de modo que há um equilíbrio entre colocar os funcionários para dormir e fazer com que eles fiquem constantemente em loop procurando novos trabalhos, esperando que tenham sorte.

Sobrecarga de comparação e troca

Outra área que pode gerar sobrecarga no projeto acima é a fila e a pilha sem bloqueio. Não entraremos em todas as nuances da implementação dessas estruturas de dados, mas uma característica comum das implementações sem bloqueio é o uso de um loop CAS ( compare-and-swap ). Os algoritmos sem bloqueio não usam primitivos de sincronização de bloqueio para fornecer acesso seguro ao estado compartilhado, mas, em vez disso, usam instruções atômicas para criar cuidadosamente operações atômicas de ordem superior, como inserir um item em uma fila de forma segura para o thread. No entanto, talvez de forma não intuitiva, os algoritmos sem bloqueio ainda podem impedir que um thread progrida até que outro esteja concluído. Eles também podem ter efeitos secundários na instrução da CPU e nos pipelines de memória, prejudicando o dimensionamento do desempenho. (Os algoritmos "wait-free" permitiriam que todos os threads sempre progredissem, mas isso nem sempre proporciona o melhor desempenho geral na prática).

Aqui está um exemplo forçado de adição de um número a uma variável membro, m_Sum, com um loop CAS:

int Add(int val)
{
    int newSum;
    do
    {
        // Load the current value we want to update
        var oldSum = m_Sum;

        // Compute new value we want to store
        newSum = oldSum + val;

        // Attempt to write the new value. CompareExchange returns 
        // the value seen inside m_Sum when writing newSum to m_Sum. 
        // If newSum doesn't match oldSum, we will retry the loop 
        // since it means another thread wrote to the memory before us.
        // If we wrote our value without this check, we might 
        // write an incorrect value
    }while (oldSum != Interlocked.CompareExchange(ref m_Sum, newSum, oldSum));

    return newSum ;
}

Os loops CAS dependem da instrução compare-and-swap (aqui usamos a biblioteca C# Interlocked, abstraindo as especificidades da plataforma), que "compara dois valores para verificar a igualdade e, se forem iguais, substitui o primeiro valor". Como queremos que os usuários da função Add() não se preocupem com a possibilidade de falha dessa função, um loop é usado para tentar novamente se ela falhar porque algum outro thread nos ultrapassou na atualização de m_Sum.

Esse loop de repetição é, em essência, um loop de "espera ocupada". Isso tem uma implicação desagradável para o dimensionamento do desempenho: Se vários threads entrarem no loop do CAS ao mesmo tempo, apenas um sairá por vez, serializando as operações que cada thread está executando. Felizmente, os loops CAS geralmente fazem uma quantidade intencionalmente pequena de trabalho, mas ainda assim podem ter grandes impactos negativos no desempenho. À medida que mais núcleos executam o loop em paralelo, cada thread levará mais tempo para concluir o loop enquanto os threads estiverem em contenção.

Além disso, como os loops CAS dependem de leituras e gravações atômicas na memória compartilhada, cada thread geralmente exige que suas linhas de cache sejam invalidadas a cada iteração, causando sobrecarga adicional. Essa sobrecarga pode ser muito cara em comparação com o custo de refazer os cálculos dentro do loop do CAS (no caso acima, refazer o trabalho de somar dois números). Portanto, o alto custo pode não ser óbvio à primeira vista.

No agendador de trabalhos 2017.3, quando os threads de trabalho não estavam executando trabalhos, eles estavam procurando trabalho em uma pilha ou fila compartilhada e sem bloqueio. Ambas as estruturas de dados usaram pelo menos um loop CAS para remover o trabalho da estrutura de dados. Assim, à medida que mais núcleos se tornaram disponíveis, o custo de retirar trabalho da pilha ou da fila aumentou quando as estruturas de dados tinham contenção. Em particular, quando os trabalhos eram pequenos, os threads de trabalho passavam proporcionalmente mais tempo procurando trabalho na fila ou na pilha.

Em um pequeno projeto, gerei gráficos de trabalho determinísticos que um jogo típico pode ter para sua atualização de quadros. O gráfico abaixo é composto de trabalhos únicos e trabalhos paralelos (cada um deles paralelizado em 1-100 trabalhos paralelos), em que cada trabalho pode ter de 0 a 10 dependências de trabalho e o thread principal tem pontos de sincronização explícitos ocasionais em que deve aguardar a conclusão de determinados trabalhos antes de agendar outros. Se eu gerar 500 trabalhos no gráfico de trabalhos e fizer com que cada um leve um tempo fixo para ser executado (cada parte de um trabalho paralelo também leva esse tempo), o senhor poderá ver que, à medida que mais núcleos são usados, a sobrecarga no sistema de trabalhos aumenta.

Windows 11 AMD Ryzen 9 3950X
Windows 11 AMD Ryzen 9 3950X

Para trabalhos que levam 0,5 μs, quando há 20 trabalhadores, o quadro é atualizado tão rapidamente quanto não usar o sistema de trabalho e é executado quase duas vezes mais devagar ao usar todos os núcleos da minha máquina. Por padrão, todos os núcleos são usados no Unity, portanto, com trabalhos de 1μs, quase não há melhoria no desempenho, apesar de usar 31 threads de trabalho. Esse é o resultado direto da alta contenção na fila e na pilha sem bloqueio. Felizmente, os trabalhos do usuário tendem a ser maiores e podem ocultar essa sobrecarga. No entanto, o problema de dimensionamento existe, e os trabalhos pequenos ainda são bastante comuns (especialmente para trabalhos paralelos). Mesmo ao usar trabalhos maiores, seus padrões de agendamento e o tempo do trabalhador podem causar grandes quantidades de sobrecarga devido à contenção com a pilha e a fila globais e sem bloqueios no agendador de trabalhos.

2022.2 Agendador de trabalhos

A esta altura, o senhor pode ver que há algumas áreas que nossa equipe precisou abordar para reduzir a sobrecarga no sistema de trabalho, tanto do lado da Unity quanto do lado do criador do jogo:

  • Evitar paralisações no thread principal:
  • A sinalização para ativar os threads de trabalho é cara - mantenha isso no mínimo.
  • A modificação do estado no thread principal compartilhado com os threads de trabalho provavelmente levará à invalidação do cache e à possível espera de trabalho.
  • A thread principal deve agendar trabalhos com frequência - evite esperar explicitamente que os trabalhos .Complete(). Em vez disso, prefira enviar trabalhos com dependências.
  • Evitar paralisações em threads de trabalho:
  • A eficiência do thread de trabalho afeta diretamente o paralelismo. Evite disputar recursos compartilhados sempre que possível.
  • As esperas ocupadas em threads de trabalho esgotam a vida útil da bateria e podem resultar em downnclocking devido ao aumento da temperatura.

Embora a Unity não possa alterar quantos trabalhos os usuários enviam em seus jogos, há um número razoável de problemas que nossos engenheiros podem resolver com uma abordagem de agendamento de trabalho diferente. Na versão 2022.2, o agendador de tarefas, em um nível elevado, divide-se em alguns componentes básicos:

  • Conjunto de threads de trabalho
  • Conjunto de filas para trabalhos
  • Conjunto de semáforos

Isso é muito semelhante ao agendador de tarefas anterior. No entanto, a principal diferença é a remoção do estado compartilhado entre o thread principal e os threads de trabalho. Em vez disso, tornamos as filas e os semáforos (ou futex em plataformas que os suportam) locais para cada thread de trabalho. Agora, quando o thread principal programar um trabalho, ele será enfileirado na fila do thread principal em vez de em uma fila global.

Da mesma forma, se um thread de trabalho precisar agendar um trabalho (por exemplo, um trabalho agenda um trabalho em seu Execute), esse trabalho será agendado na fila do próprio worker e não na fila do thread principal. Isso reduz o tráfego de memória, pois os trabalhadores reduzem a frequência de invalidação das linhas de cache quando escrevem em uma fila. Dessa forma, os funcionários não leem/gravam em todas as diferentes filas com a mesma frequência.

O loop de trabalho também foi alterado, agora que há mais filas para trabalhar:

while(!scheduler.isQuitting)
{
    // Take a job from our worker thread’s local queue
    Job* pJob = m_worker_queue[m_workerId].dequeue();
    // If our queue is empty try to steal work from someone
    // else's queue to help them out.
    if(pJob == nullptr) {
        pJob = StealFromOtherQueues()
    }

    if(pJob) {
        // If we found work, there may be more conditionally
        // wake up other workers as necessary
        WakeWorkers();
        ExecuteJob(pJob);
    }
    // Conditionally go to sleep (perhaps we were told there is a 
    // parallel job we can help with)
    else if(ShouldSleep())
    {
        // Put the thread to sleep until more jobs are scheduled
        m_semaphores[m_workerId].Wait(1);
    }
}

Os trabalhadores procuram trabalho em sua própria fila e só olham para as filas de outros trabalhadores quando a sua está vazia. Como os funcionários preferem suas próprias filas para retirar e colocar o trabalho em fila, a quantidade de contenção em qualquer fila é reduzida.

Outra diferença é como os threads são sinalizados para despertar. Os threads de trabalho agora são responsáveis por despertar outros threads de trabalho, e o thread principal é responsável por garantir que pelo menos um thread de trabalho esteja desperto quando ele programar um trabalho.

Essa mudança de responsabilidade permite que o thread principal remova a sobrecarga excessiva, pois não precisa mais ser o único responsável por despertar os threads quando os trabalhos paralelos são enviados. Em vez disso, o sistema de trabalho realiza o rastreamento para saber se precisa acordar algum funcionário. O thread principal pode garantir que um trabalhador esteja sempre acordado para progredir nos trabalhos e, quando os trabalhadores acordam e encontram um trabalho em sua própria fila ou na fila de outro, os trabalhadores podem sinalizar para que outros trabalhadores acordem e ajudem a esvaziar a fila, se necessário.

Windows 11 AMD Ryzen 9 3950X
Windows 11 AMD Ryzen 9 3950X
Windows 11 AMD Ryzen 9 3950X
Windows 11 AMD Ryzen 9 3950X

A separação de filas para os trabalhadores também oferece uma margem de manobra interessante para configuração e otimizações, que nossa equipe continua a adicionar e aprimorar. Na versão 2022.2, os usuários devem ver um custo reduzido no thread principal para ativar os threads de trabalho e um melhor rendimento dos trabalhos nos threads de trabalho, independentemente do número de núcleos da plataforma. Além disso, embora a Unity não tenha feito o backport da separação de filas para o 2021.3 LTS, trouxemos de volta a mudança de design para tornar os threads de trabalho responsáveis por sinalizar uns aos outros em vez de apenas o thread principal. A alta sobrecarga do sistema de trabalho na thread principal devido à sinalização do semáforo global não deve mais ser um problema a partir da versão 2021.3.14f1.

Se o senhor tiver dúvidas ou quiser saber mais, visite-nos no fórum do C# Job System. O senhor também pode se conectar comigo diretamente pelo Discord da Unity com o nome de usuário @Antifreeze#2763. Não deixe de acompanhar os novos blogs técnicos de outros desenvolvedores da Unity como parte da série Tech from the Trenches.