Neste artigo, exploramos o importante conceito usado na plataforma Lighthouse 2., lançada recentemente. O
rastreamento de caminho da frente de onda , como é chamado Lane, Karras e Aila da NVIDIA, ou o rastreamento de caminho de streaming, como foi originalmente chamado na
tese de mestrado de Van Antwerp, desempenha um papel crucial na o desenvolvimento de rastreadores de caminho eficientes na GPU e potencialmente rastreadores de caminho na CPU. No entanto, é bastante contra-intuitivo, portanto, para entendê-lo, é necessário repensar os algoritmos de rastreamento de raios.
Ocupação
O algoritmo de rastreamento de caminho é surpreendentemente simples e pode ser descrito em apenas algumas linhas de pseudocódigo:
vec3 Trace( vec3 O, vec3 D ) IntersectionData i = Scene::Intersect( O, D ) if (i == NoHit) return vec3( 0 )
A entrada é o
raio primário que passa da câmera pelo pixel da tela. Para esse feixe, determinamos a interseção mais próxima da primitiva da cena. Se não houver interseções, o feixe desaparecerá no vazio. Caso contrário, se o feixe atingir a fonte de luz, encontramos o caminho da luz entre a fonte e a câmera. Se encontrarmos outra coisa, realizamos reflexão e recursão, esperando que o feixe refletido ainda encontre a fonte de iluminação. Observe que esse processo se assemelha ao caminho (de retorno) de um fóton refletindo na superfície de uma cena.
As GPUs são projetadas para executar esta tarefa no modo multithread. A princípio, pode parecer que o traçado de raios é ideal para isso. Portanto, usamos o OpenCL ou CUDA para criar um fluxo para um pixel, cada fluxo executa um algoritmo que realmente funciona conforme o esperado e é bastante rápido: basta ver alguns exemplos com o ShaderToy para entender o
quão rápido o rastreamento de raios pode ser na GPU. Seja como for, a questão é diferente: esses traçadores de raios são realmente o
mais rápido possível ?
Este algoritmo tem um problema. O raio primário pode encontrar a fonte de luz imediatamente, ou após uma reflexão aleatória ou após cinquenta reflexões. O programador da CPU notará um potencial estouro de pilha aqui; o programador da GPU deve ver
o problema de ocupação . O problema é causado por recursão condicional da cauda: o caminho pode terminar na fonte de luz ou continuar. Vamos transferir isso para muitos threads: alguns deles serão interrompidos e a outra parte continuará funcionando. Após algumas reflexões, teremos vários threads que precisam continuar computando, e a maioria deles aguardará o término do trabalho desses últimos threads.
O emprego é uma medida da parte dos encadeamentos da GPU que fazem um trabalho útil.
O problema de emprego se aplica ao modelo de execução dos dispositivos SIMT GPU. Os fluxos são organizados em grupos, por exemplo, na GPU Pascal (classe de equipamento NVidia 10xx) 32 threads são combinados em um
warp . Os threads no warp têm um contador de programa comum: eles são executados com uma etapa fixa; portanto, cada instrução do programa é executada por 32 threads simultaneamente. SIMT significa
thread único de instrução única , que descreve bem o conceito. Para um processador SIMT, um código com condições é complexo. Isso é mostrado claramente na documentação oficial de Volta:
Execução de código com condições no SIMT.Quando uma determinada condição é verdadeira para alguns threads no warp, as ramificações da
instrução if são serializadas. Uma alternativa à abordagem "todos os threads fazem o mesmo" é "alguns threads estão desativados". No bloco if-then-else, a ocupação média de warp será de 50%, a menos que todos os threads tenham consistência em relação à condição.
Infelizmente, código com condições no ray tracer não é tão raro. Raios de sombras são emitidos somente se a fonte de luz não estiver atrás do ponto de sombreamento, caminhos diferentes podem colidir com materiais diferentes, a integração com o método da roleta russa pode destruir ou deixar o caminho vivo e assim por diante. Acontece que a ocupação está se tornando a principal fonte de ineficiência, e não é tão fácil evitá-la sem medidas de emergência.
Rastreamento de caminho de streaming
O algoritmo de rastreamento do caminho de streaming foi desenvolvido para solucionar a causa raiz do problema ocupado. O rastreamento de caminho de streaming divide o algoritmo de rastreamento de caminho em quatro etapas:
- Gerar
- Estender
- Sombra
- Conectar
Cada estágio é implementado como um programa separado. Portanto, em vez de executar um rastreador de caminho completo como um único programa de GPU (“kernel”, kernel), teremos que trabalhar com
quatro núcleos. Além disso, como veremos em breve, eles são executados em um loop.
O estágio 1 ("Gerar") é responsável pela geração de raios primários. Este é um núcleo simples que cria os pontos de partida e as direções dos raios em uma quantidade igual ao número de pixels. A saída desse estágio é um grande buffer de raios e um contador que informa o estágio seguinte do número de raios que precisam ser processados. Para raios primários, esse valor é igual à
largura da tela vezes a
altura da tela .
O estágio 2 ("Renovar") é o segundo núcleo. É executado somente após a conclusão do estágio 1 para todos os pixels. O kernel lê o buffer gerado na etapa 1 e cruza cada raio com a cena. A saída desse estágio é o resultado da interseção para cada raio armazenado no buffer.
O estágio 3 (“Sombra”) é executado após a conclusão do estágio 2. Ele recebe o resultado da interseção do estágio 2 e calcula o modelo de sombreamento para cada caminho. Esta operação pode ou não gerar novos raios, dependendo se o caminho foi concluído. Os caminhos que geram o novo raio (o caminho “se estende”) grava o novo raio (o “segmento do caminho”) no buffer. Os caminhos que amostram diretamente as fontes de luz ("amostram explicitamente a iluminação" ou "calculam o próximo evento") gravam um feixe de sombra em um segundo buffer.
O estágio 4 ("Conectar") rastreia os raios das sombras gerados no estágio 3. É semelhante ao estágio 2, mas com uma diferença importante: os raios da sombra precisam encontrar
qualquer interseção, enquanto os raios que se estendem precisam encontrar a interseção mais próxima. Portanto, um núcleo separado foi criado para isso.
Depois de concluir a etapa 4, obtemos um buffer contendo raios que estendem o caminho. Depois de capturar esses raios, prosseguimos para o estágio 2. Continuamos fazendo isso até que não haja raios de extensão ou até atingirmos o número máximo de iterações.
Fontes de ineficiência
Um programador preocupado com o desempenho verá muitos momentos perigosos nesse esquema de algoritmos de rastreamento de caminho de streaming:
- Em vez de uma única chamada do kernel, agora temos três chamadas por iteração , mais um kernel de geração. Núcleos desafiadores significam um certo aumento na carga, portanto isso é ruim.
- Cada núcleo lê um buffer enorme e grava um buffer enorme.
- A CPU precisa saber quantos threads gerar para cada núcleo; portanto, a GPU deve informar à CPU quantos raios foram gerados na etapa 3. Mover informações da GPU para a CPU é uma má ideia e precisa ser feita pelo menos uma vez por iteração.
- Como o estágio 3 grava os raios no buffer sem criar espaços em todos os lugares? Ele não usa um contador atômico para isso?
- O número de caminhos ativos ainda está diminuindo. Então, como esse esquema pode ajudar?
Vamos começar com a última pergunta: se transferirmos um milhão de tarefas para a GPU, ela não gerará um milhão de threads. O número real de threads executados simultaneamente depende do equipamento, mas no caso geral, dezenas de milhares de threads são executados. Somente quando a carga cair abaixo desse número, perceberemos problemas de emprego causados por um pequeno número de tarefas.
Outra preocupação é a E / S em grande escala de buffers. Essa é realmente uma dificuldade, mas não tão séria quanto você poderia esperar: o acesso aos dados é altamente previsível, especialmente ao gravar em buffers, para que o atraso não cause problemas. De fato, as GPUs foram desenvolvidas principalmente para esse tipo de processamento de dados.
Outro aspecto que as GPUs lidam muito bem são os contadores atômicos, o que é bastante inesperado para programadores que trabalham no mundo da CPU. O buffer z requer acesso rápido e, portanto, a implementação de contadores atômicos nas GPUs modernas é extremamente eficaz. Na prática, uma operação de gravação atômica é tão cara quanto uma gravação não armazenada em cache na memória global. Em muitos casos, o atraso será mascarado pela execução paralela em larga escala na GPU.
Duas perguntas permanecem: chamadas do kernel e transferência de dados bidirecional para contadores. O último é realmente um problema, por isso precisamos de outra alteração na arquitetura:
threads persistentes .
As consequências
Antes de nos aprofundarmos nos detalhes, examinaremos as implicações do uso do algoritmo de rastreamento de caminho da frente de onda. Primeiro, digamos sobre buffers. Precisamos de um buffer para gerar os dados do estágio 1, ou seja, raios primários. Para cada viga precisamos:
- Origem do raio: três valores de flutuação, ou seja, 12 bytes
- Direção do raio: três valores de flutuação, ou seja, 12 bytes
Na prática, é melhor aumentar o tamanho do buffer. Se você armazenar 16 bytes para o início e a direção do feixe, a GPU poderá lê-los em uma operação de leitura de 128 bits. Uma alternativa é uma operação de leitura de 64 bits seguida por uma operação de 32 bits para obter o float3, que é quase duas vezes mais lento. Ou seja, para uma tela de 1920 × 1080, obtemos: 1920x1080x32 = ~ 64 MB. Também precisamos de um buffer para os resultados da interseção criados pelo kernel Extend. São outros 128 bits por elemento, ou seja, 32 MB. Além disso, o kernel “Shadow” pode criar extensões de caminho de até 1920 × 1080 (limite superior), e não podemos gravá-las no buffer do qual lemos. Isso é outros 64 MB. E finalmente, se o nosso rastreador de caminho emitir raios de sombra, esse será outro buffer de 64 MB. Depois de resumir tudo, obtemos 224 MB de dados, e isso é apenas para o algoritmo de frente de onda. Ou cerca de 1 GB em resolução 4K.
Aqui precisamos nos acostumar com outro recurso: temos bastante memória. Pode parecer. esse 1 GB é muito, e há maneiras de reduzir esse número, mas se você abordar isso de forma realista, quando realmente precisarmos rastrear os caminhos em 4K, usar 1 GB em uma GPU com 8 GB será o menor dos nossos problemas.
Mais graves que os requisitos de memória, as consequências serão para o algoritmo de renderização. Até agora, sugeri que precisamos gerar um raio de extensão e, possivelmente, um raio de sombra para cada thread no núcleo da sombra. Mas e se quisermos realizar a oclusão ambiental usando 16 raios por pixel? 16 raios AO precisam ser armazenados no buffer, mas, pior ainda, eles aparecerão apenas na próxima iteração. Um problema semelhante surge ao rastrear raios no estilo Whited: emitir um feixe de sombra para várias fontes de luz ou dividir um feixe em uma colisão com vidro é quase impossível de se perceber.
Por outro lado, o rastreamento do caminho da frente de onda resolve os problemas listados na seção Ocupação:
- No estágio 1, todos os fluxos sem condições criam raios primários e os gravam no buffer.
- No estágio 2, todos os fluxos sem condições cruzam os raios com a cena e escrevem os resultados da interseção no buffer.
- Na etapa 3, começamos a calcular os resultados da interseção com 100% de ocupação.
- Na etapa 4, processamos uma lista contínua de raios de sombra sem espaços.
Quando retornamos ao estágio 2 com os raios sobreviventes com 2 segmentos de comprimento, novamente temos um buffer de raios compacto que garante o pleno emprego quando o kernel é iniciado.
Além disso, há uma vantagem adicional que não deve ser subestimada. O código é isolado em quatro etapas separadas. Cada núcleo pode usar todos os recursos GPU disponíveis (cache, memória compartilhada, registros) sem levar em conta outros núcleos. Isso pode permitir que a GPU execute o código de interseção com a cena em mais threads, porque esse código não requer tantos registros quanto o código de sombreador. Quanto mais threads, melhor você pode ocultar os atrasos.
Mascaramento de atraso aprimorado em tempo integral, gravação de streaming: todos esses benefícios estão diretamente relacionados ao surgimento e à natureza da plataforma GPU. Para a GPU, o algoritmo de rastreamento de caminho da frente de onda é muito natural.
Vale a pena?
Obviamente, temos uma pergunta: o emprego otimizado justifica a E / S dos buffers e o custo de chamar núcleos adicionais?
A resposta é sim, mas provar isso não é tão fácil.
Se voltarmos aos rastreadores de pista com o ShaderToy por um segundo, veremos que a maioria deles usa uma cena simples e codificada. Substituí-lo por uma cena completa não é uma tarefa trivial: para milhões de primitivas, cruzar o feixe e a cena se torna um problema complexo, cuja solução costuma ser deixada para NVidia (
Optix ), AMD (
Radeon-Rays ) ou Intel (
Embree ). Nenhuma dessas opções pode substituir facilmente a cena codificada no traçador de raios artificial CUDA. No CUDA, o analógico mais próximo (Optix) requer controle sobre a execução do programa. A incorporação na CPU permite rastrear feixes individuais a partir do seu próprio código, mas o custo disso é uma sobrecarga significativa de desempenho: ele prefere rastrear grandes grupos de feixes em vez de feixes individuais.
Tela de It's About Time renderizada com Brigade 1.O rastreamento do caminho da frente de onda será mais rápido do que sua alternativa (o megakernel, como Lane e colegas o chamam) depende do tempo gasto nos núcleos (cenas grandes e shaders caros reduzem o custo relativo excedido pelo algoritmo de frente de onda), no comprimento máximo do caminho , emprego mega núcleo e diferenças na carga dos registros em quatro etapas. Em uma versão inicial do
Brigade Path Tracer original, descobrimos que mesmo uma cena simples com uma mistura de superfícies refletivas e Lambert rodando no GTX480 se beneficiava do uso da frente de onda.
Rastreamento de caminho de streaming no farol 2
A plataforma Lighthouse 2 possui dois rastreadores de rastreamento de caminho de frente de onda. O primeiro utiliza o Optix Prime para a implementação dos estágios 2 e 4 (estágios da interseção de raios e cenas); no segundo, o Optix é usado diretamente para implementar essa funcionalidade.
Optix Prime é uma versão simplificada do Optix que lida apenas com a interseção de um conjunto de vigas com uma cena composta por triângulos. Diferente da biblioteca Optix completa, ela não suporta código de interseção personalizado e apenas intercepta triângulos. No entanto, é exatamente isso que é necessário para o rastreador de caminho da frente de onda.
O rastreador de caminho de frente de onda baseado em Optix Prime é implementado no
rendercore.cpp
projeto
rendercore.cpp
. A inicialização do Optix Prime inicia na função
Init
e usa
rtpContextCreate
. A cena é criada usando
rtpModelCreate
. Vários buffers de raio são criados na função
rtpBufferDescCreate
usando
rtpBufferDescCreate
. Observe que, para esses buffers, fornecemos os ponteiros usuais do dispositivo: isso significa que eles podem ser usados no Optix e nos núcleos regulares do CUDA.
A renderização começa no método
Render
. Para preencher o buffer de raios primário,
generateEyeRays
um núcleo CUDA chamado
generateEyeRays
. Depois de preencher o buffer, o Optix Prime é chamado usando
rtpQueryExecute
. Com isso, os resultados da interseção são gravados no
extensionHitBuffer
. Observe que todos os buffers permanecem na GPU: com exceção das chamadas do kernel, não há tráfego entre a CPU e a GPU. O estágio "Shadow" é implementado no núcleo de cores CUDA regular. Sua implementação está no
pathtracer.cu
.
Vale ressaltar alguns detalhes de implementação do
optixprime_b
. Primeiro, os raios das sombras são traçados fora do ciclo da frente de onda. Isso está correto: um raio de sombra afeta um pixel apenas se não estiver bloqueado, mas em todos os outros casos, seu resultado não é necessário em nenhum outro lugar. Ou seja, o feixe de sombra é
descartável , pode ser rastreado a qualquer momento e em qualquer ordem. No nosso caso, usamos isso agrupando os raios da sombra para que o lote finalmente rastreado seja o maior possível. Isso tem uma conseqüência desagradável: com
N iterações do algoritmo de frente de onda e
raios primários X, o limite superior do número de raios de sombra é igual a
XN .
Outro detalhe é o processamento de vários contadores. Os estágios “Renovar” e “Sombra” devem saber quantos caminhos estão ativos. Os contadores para isso são atualizados na GPU (atomicamente), o que significa que eles são usados na GPU, mesmo sem retornar à CPU. Infelizmente, em um dos casos, isso é impossível: a biblioteca Optix Prime precisa saber o número de raios rastreados. Para fazer isso, precisamos retornar as informações dos contadores uma vez que uma iteração.
Conclusão
Este artigo explica o que é o rastreamento de caminho da frente de onda e por que é necessário executar efetivamente o rastreamento de caminho na GPU. Sua implementação prática é apresentada na plataforma Lighthouse 2, que é de código aberto e
disponível no Github .