Quantos tópicos eu devo ter e para quê?


81

Devo ter threads separados para renderização e lógica, ou até mais?

Estou ciente da imensa queda de desempenho causada pela sincronização de dados (sem falar nos bloqueios mutex).

Eu estive pensando em levar isso ao extremo e fazer threads para todos os subsistemas concebíveis. Mas eu estou preocupado que isso possa atrasar as coisas também. (Por exemplo, é sensato separar o encadeamento de entrada dos segmentos de renderização ou lógica do jogo?) A sincronização de dados necessária o tornaria inútil ou até mais lento?


6
qual plataforma? PC, console NextGen, smartphones?
Ellis

Há uma coisa em que posso pensar que exigiria multithreading; rede.
Ensaboado

Para sair de exageros, não há uma velocidade "imensa" quando há bloqueios. isso é uma lenda urbana e um preconceito.
v.oddou

Respostas:


61

A abordagem comum para tirar proveito de múltiplos núcleos é, francamente, simplesmente equivocada. A separação de seus subsistemas em diferentes segmentos de fato dividirá parte do trabalho em vários núcleos, mas há alguns problemas importantes. Primeiro, é muito difícil trabalhar com isso. Quem quer mexer com bloqueios, sincronização, comunicação e outras coisas, quando poderia apenas escrever código de renderização ou física? Segundo, a abordagem realmente não aumenta. Na melhor das hipóteses, isso permitirá que você aproveite talvez três ou quatro núcleos, e isso se você realmente souber o que está fazendo. Existem tantos subsistemas em um jogo e, dentre esses, menos ainda, que consomem grande parte do tempo da CPU. Existem algumas boas alternativas que eu conheço.

Uma é ter um thread principal junto com um thread de trabalho para cada CPU adicional. Independentemente do subsistema, o encadeamento principal delega tarefas isoladas aos encadeamentos de trabalho por meio de algum tipo de fila (s); essas tarefas também podem criar outras tarefas. O único objetivo dos encadeamentos de trabalho é cada tarefa de captura da fila, uma por vez, e executada. O mais importante, porém, é que, assim que um encadeamento precisa do resultado de uma tarefa, se a tarefa for concluída, ele poderá obter o resultado e, se não, poderá remover com segurança a tarefa da fila e prosseguir com a execução. tarefa em si. Ou seja, nem todas as tarefas acabam sendo agendadas em paralelo. Ter mais tarefas do que pode ser executado em paralelo é uma boacoisa neste caso; isso significa que é provável que seja dimensionado à medida que você adiciona mais núcleos. Uma desvantagem disso é que exige muito trabalho antecipadamente para projetar uma fila decente e um loop de trabalho, a menos que você tenha acesso a um tempo de execução de biblioteca ou idioma que já o forneça. A parte mais difícil é garantir que suas tarefas sejam realmente isoladas e seguras, e garantir que suas tarefas estejam em um meio termo feliz entre granulação grossa e granulação fina.

Outra alternativa para os encadeamentos do subsistema é paralelizar cada subsistema isoladamente. Ou seja, em vez de executar renderização e física em seus próprios encadeamentos, escreva o subsistema de física para usar todos os seus núcleos de uma só vez, escreva o subsistema de renderização para usar todos os seus núcleos de uma só vez e faça com que os dois sistemas simplesmente executem sequencialmente (ou intercalados, dependendo de outros aspectos da arquitetura do seu jogo). Por exemplo, no subsistema de física, você pode pegar todas as massas de pontos do jogo, dividi-las entre seus núcleos e fazer com que todos os núcleos os atualizem de uma só vez. Cada núcleo pode trabalhar com seus dados em loop apertado com boa localidade. Esse estilo de paralelismo de etapa de bloqueio é semelhante ao que uma GPU faz. A parte mais difícil aqui é garantir que você esteja dividindo seu trabalho em pedaços finos, de modo que o divida uniformementena verdade, resulta em uma quantidade igual de trabalho em todos os processadores.

No entanto, às vezes é mais fácil, devido à política, código existente ou outras circunstâncias frustrantes, dar a cada subsistema um encadeamento. Nesse caso, é melhor evitar criar mais threads do SO do que núcleos para cargas de trabalho pesadas da CPU (se você tiver um tempo de execução com threads leves que, por acaso, se equilibram entre os núcleos, isso não é tão importante). Além disso, evite comunicação excessiva. Um bom truque é tentar pipelining; cada subsistema principal pode estar trabalhando em um estado de jogo diferente por vez. O pipelining reduz a quantidade de comunicação necessária entre seus subsistemas, pois nem todos eles precisam acessar os mesmos dados ao mesmo tempo, e também pode anular alguns dos danos causados ​​por gargalos. Por exemplo, se o subsistema de física demorar muito para ser concluído e o subsistema de renderização acabar sempre esperando por ele, sua taxa de quadros absoluta poderá ser maior se você executar o subsistema de física para o próximo quadro enquanto o subsistema de renderização ainda estiver trabalhando no anterior quadro, Armação. De fato, se você tiver esses gargalos e não puder removê-los de outra maneira, o pipelining pode ser o motivo mais legítimo para se preocupar com os threads do subsistema.


"assim que um encadeamento precisar do resultado de uma tarefa, se a tarefa for concluída, poderá obter o resultado e, caso contrário, poderá remover com segurança a tarefa da fila e prosseguir e executar a tarefa". Você está falando de uma tarefa gerada pelo mesmo segmento? Nesse caso, não faria mais sentido se essa tarefa fosse executada pelo encadeamento que gerou a própria tarefa?
jmp97

isto é, o encadeamento poderia, sem agendar a tarefa, executá-la imediatamente.
Jsp97

3
O ponto é que o encadeamento não necessariamente sabe de antemão se seria melhor executar a tarefa em paralelo ou não. A idéia é estimular especulativamente o trabalho que você precisará executar e, se outro segmento estiver ocioso, ele poderá prosseguir e fazer esse trabalho para você. Se isso não acontecer no momento em que você precisar do resultado, você mesmo poderá retirar a tarefa da fila. Esse esquema é para balancear dinamicamente uma carga de trabalho em vários núcleos, e não estaticamente.
Jake McArthur

Desculpe por demorar tanto para retornar a esta discussão. Não estou prestando atenção ao gamedev ultimamente. Esta é provavelmente a melhor resposta, direta, mas objetiva e extensa.
j riv

1
Você está correto no sentido em que deixei de falar sobre cargas de trabalho pesadas de E / S. Minha interpretação da pergunta era que se tratava apenas de cargas de trabalho pesadas da CPU.
21415 Jake McArthur

30

Há algumas coisas a considerar. É fácil pensar na rota do encadeamento por subsistema, pois a separação do código é bastante aparente desde o início. No entanto, dependendo da quantidade de intercomunicação que seus subsistemas precisam, a comunicação entre threads pode realmente prejudicar seu desempenho. Além disso, isso é dimensionado apenas para N núcleos, em que N é o número de subsistemas que você abstrai em encadeamentos.

Se você está apenas procurando multithread um jogo existente, este é provavelmente o caminho de menor resistência. No entanto, se você estiver trabalhando em alguns sistemas de mecanismo de baixo nível que possam ser compartilhados entre vários jogos ou projetos, eu consideraria outra abordagem.

Pode demorar um pouco, mas se você puder dividir as coisas como uma fila de tarefas com um conjunto de threads de trabalho, a escala será muito melhor a longo prazo. À medida que os melhores e mais recentes chips são lançados com um zilhão de núcleos, o desempenho do seu jogo será escalado junto com ele, apenas inicie mais threads de trabalho.

Então, basicamente, se você estiver procurando um paralelismo com um projeto existente, eu paralelo entre os subsistemas. Se você estiver construindo um novo mecanismo a partir do zero, com escalabilidade paralela em mente, analisarei uma fila de trabalhos.


O sistema que você menciona é muito parecido com o sistema de agendamento mencionado na resposta dada pelo Outro James, ainda com bons detalhes nessa área, de modo que +1 é adicionado à discussão.
James

3
um wiki da comunidade sobre como configurar uma fila de tarefas e threads de trabalho seria bom.
Bot01

23

Essa pergunta não tem a melhor resposta, pois depende do que você está tentando realizar.

O xbox possui três núcleos e pode lidar com alguns threads antes que a sobrecarga de alternância de contexto se torne um problema. O pc pode lidar com muito mais.

Muitos jogos geralmente têm um único encadeamento para facilitar a programação. Isso é bom para a maioria dos jogos pessoais. A única coisa para a qual você provavelmente precisará ter outro segmento é Rede e Áudio.

O Unreal possui um segmento de jogo, segmento de renderização, segmento de rede e segmento de áudio (se bem me lembro). Isso é bastante padrão para muitos mecanismos de geração atual, embora ser capaz de suportar um encadeamento de renderização separado possa ser uma dor e envolva muitas bases.

O mecanismo idTech5 que está sendo desenvolvido para o Rage, na verdade, usa qualquer número de encadeamentos, dividindo as tarefas do jogo em 'trabalhos' que são processados ​​com um sistema de tarefas. Seu objetivo explícito é fazer com que seu mecanismo de jogo seja bem dimensionado quando o número de núcleos no sistema de jogo médio aumentar.

A tecnologia que eu uso (e escrevi) tem um segmento separado para rede, entrada, áudio, renderização e agendamento. Em seguida, ele possui qualquer número de threads que podem ser usados ​​para executar tarefas do jogo, e isso é gerenciado pelo thread de agendamento. Um monte de trabalho entrou em conseguir que todos os segmentos para jogar bem uns com os outros, mas parece estar funcionando bem e ficando muito bom uso sistemas multicore, por isso talvez seja missão cumprida (por agora, eu poderia quebrar áudio / rede / input trabalha apenas em 'tarefas' que os threads do trabalhador podem atualizar).

Realmente depende do seu objetivo final.


+1 para a menção de um sistema de agendamento .. geralmente um bom lugar para centralizar a comunicação thread / sistema :)
James

Por que o voto negativo, downvoter?
jcora

12

Um encadeamento por subsistema é o caminho errado a seguir. De repente, seu aplicativo não aumenta, porque alguns subsistemas exigem muito mais do que outros. Essa foi a abordagem de encadeamento adotada pelo Supreme Commander e não ultrapassou dois núcleos, porque eles tinham apenas dois subsistemas que ocupavam uma quantidade substancial de renderização de CPU e lógica de física / jogo, apesar de terem 16 threads, os outros threads quase não resultou em nenhum trabalho e, como resultado, o jogo foi escalado para apenas dois núcleos.

O que você deve fazer é usar algo chamado pool de threads. Isso reflete um pouco a abordagem adotada nas GPUs - ou seja, você publica o trabalho, e qualquer encadeamento disponível simplesmente aparece e o faz e depois volta a aguardar o trabalho - pense nele como um buffer de anel, de encadeamentos. Essa abordagem tem a vantagem da escala N-core e é muito boa para a contagem de núcleos baixo e alto. A desvantagem é que é muito difícil trabalhar com a propriedade do encadeamento para essa abordagem, pois é impossível saber qual encadeamento está executando o que funciona a qualquer momento, portanto, é necessário ter os problemas de propriedade bem fechados. Também dificulta o uso de tecnologias como o Direct3D9, que não suportam vários threads.

Os pools de threads são muito difíceis de usar, mas oferecem os melhores resultados possíveis. Se você precisar de uma escala extremamente boa ou tiver tempo suficiente para trabalhar nela, use um pool de threads. Se você está tentando introduzir paralelismo em um projeto existente com problemas de dependência desconhecidos e tecnologias de thread único, essa não é a solução para você.


Só para ser um pouco mais preciso: as GPUs não usam conjuntos de threads, o agendador de threads é implementado no hardware, o que torna muito barato criar novos threads e alternar threads, em oposição a CPUs onde a criação de threads e as alternâncias de contexto são caras. Consulte o Guia do Programador Nvidias CUDA para exemplo.
Nils

2
+1: Melhor resposta aqui. Eu usaria até construções mais abstratas do que conjuntos de threads (por exemplo, filas de trabalho e trabalhadores) se sua estrutura permitir. É muito mais fácil pensar / programar nesses termos do que em threads / bloqueios puros / etc. Mais: Dividir seu jogo em renderização, lógica etc. não faz sentido, pois a renderização precisa aguardar a conclusão da lógica. Em vez disso, crie tarefas que possam realmente ser executadas em paralelo (por exemplo: Calcule o AI para um npc para o próximo quadro).
Dave O.

@DaveO. O seu ponto "Plus" é tão, tão verdadeiro.
Engenheiro de

11

Você está certo que a parte mais crítica é evitar a sincronização sempre que possível. Existem algumas maneiras de conseguir isso.

  1. Conheça seus dados e armazene-os na memória de acordo com suas necessidades de processamento. Isso permite planejar cálculos paralelos sem a necessidade de sincronização. Infelizmente, isso é na maioria das vezes bastante difícil de alcançar, pois os dados são frequentemente acessados ​​de diferentes sistemas em momentos imprevisíveis.

  2. Defina tempos de acesso claros para dados. Você pode separar seu tick principal em x fases. Se você tiver certeza de que o Thread X lê os dados apenas em uma fase específica, também sabe que esses dados podem ser modificados por outros threads em uma fase diferente.

  3. Faça um buffer duplo de seus dados. Essa é a abordagem mais simples, mas aumenta a latência, pois o Thread X está trabalhando com os dados do último quadro, enquanto o Thread Y está preparando os dados para o próximo quadro.

Minha experiência pessoal mostra que cálculos refinados são a maneira mais eficaz, pois eles podem ser dimensionados muito melhor do que as soluções baseadas em subsistemas. Se você encadear seus subsistemas, o tempo de quadro será vinculado ao subsistema mais caro. Isso pode levar a todos os encadeamentos, exceto um, até que o caro subsistema finalmente termine seu trabalho. Se você conseguir separar grandes partes do seu jogo em pequenas tarefas, essas tarefas poderão ser agendadas de acordo para evitar núcleos ociosos. Mas isso é algo difícil de realizar se você já possui uma grande base de código.

Para levar em consideração algumas restrições de hardware, tente nunca exagerar na assinatura do hardware. Com oversubscribe, quero dizer ter mais threads de software do que os threads de hardware da sua plataforma. Especialmente nas arquiteturas PPC (Xbox360, PS3), uma troca de tarefas é realmente cara. É claro que tudo bem se você tiver alguns threads com excesso de assinaturas que são acionados apenas por um pequeno período de tempo (uma vez um quadro, por exemplo) Se você direcionar o PC, lembre-se de que o número de núcleos (ou melhor HW -Threads) está em constante crescimento, portanto, você deseja encontrar uma solução escalável, que aproveite a CPU-Power adicional. Portanto, nessa área, você deve tentar projetar seu código o mais baseado em tarefas possível.


3

Regra geral prática para segmentar um aplicativo: 1 thread por CPU Core. Em um PC com quatro núcleos, isso significa 4. Como foi observado, o XBox 360, no entanto, possui 3 núcleos, mas 2 threads de hardware cada, portanto, 6 threads neste caso. Em um sistema como o PS3 ... bem, boa sorte nesse :) As pessoas ainda estão tentando descobrir.

Eu sugeriria projetar cada sistema como um módulo independente que você poderia encadear, se quisesse. Isso geralmente significa ter caminhos de comunicação muito claramente definidos entre o módulo e o restante do motor. Eu particularmente gosto de processos somente leitura, como renderização e áudio, bem como processos 'já estamos lá', como a leitura da entrada do player para que as coisas sejam eliminadas. Para tocar na resposta dada pelo AttackingHobo, quando você estiver renderizando 30-60fps, se seus dados estiverem 1 / 30th-1 / 60th de segundo desatualizados, isso realmente não prejudicará a sensação de resposta do seu jogo. Lembre-se sempre de que a principal diferença entre software aplicativo e videogame é fazer tudo de 30 a 60 vezes por segundo. Na mesma nota, no entanto,

Se você projetar os sistemas de seu mecanismo suficientemente bem, qualquer um deles poderá ser movido de um segmento para outro para equilibrar sua carga de maneira mais apropriada, conforme o jogo. Em teoria, você também pode usar seu mecanismo em um sistema distribuído, se necessário, onde sistemas de computadores totalmente separados executam cada componente.


2
O X360 tem 2 hardwarethreads por núcleo, de modo que o número óptimo de fios é 6.
DarthCoder

Ah, um :) Eu estava sempre restrita às áreas de rede do 360 e PS3, hehe :)
James

0

Eu crio um thread por núcleo lógico (menos um, para explicar o Main Thread, que aliás é responsável pela renderização, mas que também atua como um Thread de trabalho).

Coleto eventos do dispositivo de entrada em tempo real em um quadro, mas não os aplico até o final do quadro: eles terão efeito no próximo quadro. E eu uso uma lógica semelhante para renderizar (estado antigo) versus atualizar (novo estado).

Uso eventos atômicos para adiar operações não seguras até mais tarde no mesmo quadro e uso mais de uma fila de eventos (fila de trabalhos) para implementar uma barreira de memória que fornece uma garantia de ferro em relação à ordem das operações, sem travar ou esperar (bloqueie filas simultâneas livres em ordem de prioridade do trabalho).

Vale ressaltar que qualquer trabalho pode emitir submersos (que são mais refinados e se aproximam da atomicidade) na mesma fila de prioridade ou em uma que seja mais alta (servida posteriormente no quadro).

Como tenho três filas desse tipo, todos os threads, exceto um, podem ficar paralisados ​​exatamente três vezes por quadro (enquanto aguardam que outros threads concluam todos os trabalhos pendentes emitidos no nível de prioridade atual).

Parece um nível aceitável de inatividade do encadeamento!


Meu quadro começa com MAIN renderizando o OLD STATE a partir do passo de atualização do quadro anterior, enquanto todos os outros threads começam imediatamente a calcular o estado do quadro PRÓXIMO, só estou usando Eventos para dobrar as alterações do estado do buffer até um ponto no quadro em que ninguém mais está lendo .
Homer

0

Normalmente, uso um thread principal (obviamente) e adiciono um thread toda vez que percebo uma queda de desempenho de 10 a 20%. Para amenizar essa queda, uso as ferramentas de desempenho do visual studio. Eventos comuns estão (des) carregando algumas áreas do mapa ou fazendo alguns cálculos pesados.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.