Nuevo algoritmo de seguimiento de GPU: trazado de ruta de frente de onda


En este artículo, exploramos el concepto importante utilizado en la plataforma Lighthouse 2. lanzada recientemente. El trazado de ruta de frente de onda , como se llama Lane, Karras y Aila de NVIDIA, o el seguimiento de ruta de transmisión, como se llamó originalmente en la tesis de maestría de Van Antwerp, juega un papel crucial en el desarrollo de trazadores de ruta eficientes en la GPU, y potencialmente trazadores de ruta en la CPU. Sin embargo, es bastante contradictorio, por lo tanto, para comprenderlo, es necesario repensar los algoritmos de trazado de rayos.

Ocupación


El algoritmo de trazado de ruta es sorprendentemente simple y puede describirse en solo unas pocas líneas de pseudocódigo:

vec3 Trace( vec3 O, vec3 D ) IntersectionData i = Scene::Intersect( O, D ) if (i == NoHit) return vec3( 0 ) // ray left the scene if (i == Light) return i.material.color // lights do not reflect vec3 R, pdf = RandomDirectionOnHemisphere( i.normal ), 1 / 2PI return Trace( i.position, R ) * i.BRDF * dot( i.normal, R ) / pdf 

La entrada es el rayo primario que pasa desde la cámara a través del píxel de la pantalla. Para este haz, determinamos la intersección más cercana con la escena primitiva. Si no hay intersecciones, la viga desaparece en el vacío. De lo contrario, si el haz llega a la fuente de luz, entonces hemos encontrado el camino de la luz entre la fuente y la cámara. Si encontramos algo más, entonces realizamos la reflexión y la recursión, con la esperanza de que el haz reflejado aún encuentre la fuente de iluminación. Tenga en cuenta que este proceso se asemeja a la ruta (de retorno) de un fotón que se refleja en la superficie de una escena.

Las GPU están diseñadas para realizar esta tarea en modo multiproceso. Al principio puede parecer que el trazado de rayos es ideal para esto. Entonces, utilizamos OpenCL o CUDA para crear una secuencia para un píxel, cada secuencia realiza un algoritmo que realmente funciona según lo previsto, y es bastante rápido: solo mire algunos ejemplos con ShaderToy para comprender cuán rápido puede ser el trazado de rayos en la GPU Pero sea como fuere, la pregunta es diferente: ¿son estos trazadores de rayos realmente tan rápidos como sea posible ?


Este algoritmo tiene un problema. El rayo primario puede encontrar la fuente de luz inmediatamente, o después de una reflexión aleatoria, o después de cincuenta reflexiones. El programador de la CPU notará un posible desbordamiento de pila aquí; el programador de la GPU debería ver el problema de ocupación . El problema es causado por la recursión de la cola condicional: la ruta puede terminar en la fuente de luz o continuar. Transfieramos esto a muchos hilos: algunos de los hilos se detendrán, y la otra parte continuará funcionando. Después de algunas reflexiones, tendremos varios subprocesos que necesitan continuar computando, y la mayoría de los subprocesos esperarán a que estos últimos subprocesos terminen de funcionar. El empleo es una medida de la porción de hilos de GPU que hacen un trabajo útil.

El problema del empleo se aplica al modelo de ejecución de dispositivos SIMT GPU. Las secuencias se organizan en grupos, por ejemplo, en GPU Pascal (equipo NVidia clase 10xx) 32 hilos se combinan en una urdimbre . Los hilos en warp tienen un contador de programa común: se ejecutan con un paso fijo, por lo que cada instrucción de programa se ejecuta con 32 hilos simultáneamente. SIMT significa una sola instrucción de múltiples hilos , que describe bien el concepto. Para un procesador SIMT, un código con condiciones es complejo. Esto se muestra claramente en la documentación oficial de Volta:


Ejecución de código con condiciones en SIMT.

Cuando una cierta condición es verdadera para algunos subprocesos en warp, las ramas de la instrucción if se serializan. Una alternativa al enfoque de "todos los hilos hacen lo mismo" es "algunos hilos están deshabilitados". En el bloque if-then-else, la ocupación promedio de warp será del 50%, a menos que todos los hilos tengan consistencia con respecto a la condición.

Desafortunadamente, el código con condiciones en el trazador de rayos no es tan raro. Los rayos de sombras se emiten solo si la fuente de luz no está detrás del punto de sombreado, diferentes caminos pueden colisionar con diferentes materiales, la integración con el método de la ruleta rusa puede destruir o dejar el camino vivo, y así sucesivamente. Resulta que la ocupación se está convirtiendo en la principal fuente de ineficiencia, y no es tan fácil prevenirla sin medidas de emergencia.

Streaming Path Tracing


El algoritmo de rastreo de ruta de transmisión está diseñado para abordar la causa raíz del problema ocupado. El seguimiento de ruta de transmisión divide el algoritmo de seguimiento de ruta en cuatro pasos:

  1. Generar
  2. Extender
  3. Sombra
  4. Conectar

Cada etapa se implementa como un programa separado. Por lo tanto, en lugar de ejecutar un trazador de ruta completo como un solo programa de GPU ("kernel", kernel), tendremos que trabajar con cuatro núcleos. Además, como veremos pronto, se ejecutan en un bucle.

La etapa 1 ("Generar") es responsable de la generación de rayos primarios. Este es un núcleo simple que crea los puntos de partida y las direcciones de los rayos en una cantidad igual al número de píxeles. La salida de esta etapa es un búfer de rayos grande y un contador que informa a la siguiente etapa de la cantidad de rayos que deben procesarse. Para rayos primarios, este valor es igual al ancho de la pantalla multiplicado por la altura de la pantalla .

La etapa 2 ("Renovar") es el segundo núcleo. Se ejecuta solo después de completar la etapa 1 para todos los píxeles. El núcleo lee el búfer generado en el paso 1 y cruza cada rayo con la escena. La salida de esta etapa es el resultado de la intersección para cada rayo almacenado en el búfer.

La etapa 3 ("Sombra") se realiza después de completar la etapa 2. Recibe el resultado de la intersección de la etapa 2 y calcula el modelo de sombreado para cada ruta. Esta operación puede o no generar nuevos rayos, dependiendo de si la ruta se ha completado. Las rutas que generan el nuevo rayo (la ruta "se extiende") escribe el nuevo rayo (el "segmento de ruta") en el búfer. Las rutas que muestrean directamente fuentes de luz ("explícitamente muestrean iluminación" o "calculan el próximo evento") escriben un haz de sombra en un segundo buffer.

La etapa 4 ("Conectar") traza los rayos de sombra generados en la etapa 3. Esto es similar a la etapa 2, pero con una diferencia importante: los rayos de la sombra necesitan encontrar cualquier intersección, mientras que los rayos que se extienden necesitan encontrar la intersección más cercana. Por lo tanto, se ha creado un núcleo separado para esto.

Después de completar el paso 4, obtenemos un búfer que contiene rayos que extienden el camino. Después de tomar estos rayos, procedemos a la etapa 2. Continuamos haciendo esto hasta que no haya rayos de extensión o hasta que alcancemos el número máximo de iteraciones.

Fuentes de ineficiencia


Un programador preocupado por el rendimiento verá muchos momentos peligrosos en este esquema de algoritmos de rastreo de ruta de transmisión:

  • En lugar de una sola llamada al núcleo, ahora tenemos tres llamadas por iteración , más un núcleo de generación. Los núcleos desafiantes significan un cierto aumento en la carga, por lo que esto es malo.
  • Cada núcleo lee un gran búfer y escribe un gran búfer.
  • La CPU necesita saber cuántos hilos generar para cada núcleo, por lo que la GPU debe decirle a la CPU cuántos rayos se generaron en el paso 3. Mover información de la GPU a la CPU es una mala idea, y debe hacerse al menos una vez por iteración.
  • ¿Cómo escribe la etapa 3 los rayos en el búfer sin crear espacios en todas partes? ¿No usa un contador atómico para esto?
  • El número de rutas activas sigue disminuyendo, entonces, ¿cómo puede ayudar este esquema?

Comencemos con la última pregunta: si transferimos un millón de tareas a la GPU, no generará un millón de hilos. El número real de subprocesos ejecutados simultáneamente depende del equipo, pero en el caso general, se ejecutan decenas de miles de subprocesos. Solo cuando la carga cae por debajo de este número notaremos problemas de empleo causados ​​por un pequeño número de tareas.

Otra preocupación es la E / S a gran escala de las memorias intermedias. Esto es realmente una dificultad, pero no tan grave como cabría esperar: el acceso a los datos es altamente predecible, especialmente cuando se escribe en buffers, por lo que el retraso no causa problemas. De hecho, las GPU se desarrollaron principalmente para este tipo de procesamiento de datos.

Otro aspecto que las GPU manejan muy bien son los contadores atómicos, lo cual es bastante inesperado para los programadores que trabajan en el mundo de las CPU. El z-buffer requiere un acceso rápido y, por lo tanto, la implementación de contadores atómicos en las GPU modernas es extremadamente efectiva. En la práctica, una operación de escritura atómica es tan costosa como una escritura no almacenada en memoria caché. En muchos casos, el retraso estará enmascarado por la ejecución paralela a gran escala en la GPU.

Quedan dos preguntas: llamadas del núcleo y transferencia de datos bidireccional para contadores. Este último es en realidad un problema, por lo que necesitamos otro cambio arquitectónico: hilos persistentes .

Las consecuencias


Antes de profundizar en los detalles, veremos las implicaciones del uso del algoritmo de trazado de ruta de frente de onda. Primero, digamos sobre los tampones. Necesitamos un búfer para generar los datos de la etapa 1, es decir. rayos primarios Para cada haz necesitamos:

  • Origen del rayo: tres valores flotantes, es decir, 12 bytes
  • Dirección del rayo: tres valores flotantes, es decir, 12 bytes

En la práctica, es mejor aumentar el tamaño del búfer. Si almacena 16 bytes para el comienzo y la dirección del haz, la GPU podrá leerlos en una operación de lectura de 128 bits. Una alternativa es una operación de lectura de 64 bits seguida de una operación de 32 bits para obtener float3, que es casi el doble de lenta. Es decir, para una pantalla de 1920 × 1080 obtenemos: 1920x1080x32 = ~ 64 MB. También necesitamos un buffer para los resultados de intersección creados por el kernel Extend. Estos son otros 128 bits por elemento, es decir, 32 MB. Además, el núcleo "Shadow" puede crear extensiones de ruta de hasta 1920 × 1080 (límite superior), y no podemos escribirlas en el búfer del que leemos. Esos son otros 64 MB. Y finalmente, si nuestro trazado de ruta emite rayos de sombra, entonces este es otro búfer de 64 MB. Una vez resumido todo, obtenemos 224 MB de datos, y esto es solo para el algoritmo de frente de onda. O aproximadamente 1 GB en resolución 4K.

Aquí necesitamos acostumbrarnos a otra función: tenemos mucha memoria. Puede parecer. ese 1 GB es mucho, y hay formas de reducir este número, pero si lo aborda de manera realista, para cuando realmente necesitemos rastrear las rutas en 4K, usar 1 GB en una GPU con 8 GB será el menor de nuestros problemas.

Más graves que los requisitos de memoria, las consecuencias serán para el algoritmo de representación. Hasta ahora, he sugerido que necesitamos generar un rayo de extensión y, posiblemente, un rayo de sombra para cada hilo en el núcleo de Shadow. Pero, ¿qué pasa si queremos realizar una oclusión ambiental con 16 rayos por píxel? Deben almacenarse 16 rayos AO en el búfer, pero, lo que es peor, aparecerán solo en la próxima iteración. Un problema similar surge cuando se trazan rayos en el estilo de Whited: es casi imposible realizar un haz de sombra para varias fuentes de luz o dividir un haz en una colisión con vidrio.

Por otro lado, el trazado de camino de frente de onda resuelve los problemas que hemos enumerado en la sección Ocupación:

  • En la etapa 1, todos los flujos sin condiciones crean rayos primarios y los escriben en el búfer.
  • En la etapa 2, todos los flujos sin condiciones cruzan los rayos con la escena y escriben los resultados de la intersección en el búfer.
  • En el paso 3, comenzamos a calcular los resultados de intersección con una ocupación del 100%.
  • En el paso 4, procesamos una lista continua de rayos de sombra sin espacios.

Cuando regresemos a la etapa 2 con los rayos supervivientes con una longitud de 2 segmentos, nuevamente tenemos un búfer de rayos compacto que garantiza el pleno empleo cuando comienza el núcleo.

Además, hay una ventaja adicional que no debe subestimarse. El código está aislado en cuatro pasos separados. Cada núcleo puede usar todos los recursos de GPU disponibles (caché, memoria compartida, registros) sin tener en cuenta otros núcleos. Esto puede permitir que la GPU ejecute el código de intersección con la escena en más hilos, porque este código no requiere tantos registros como el código del sombreador. Cuantos más subprocesos, mejor podrá ocultar los retrasos.

Máscara de retraso mejorada a tiempo completo, grabación de transmisión: todos estos beneficios están directamente relacionados con el surgimiento y la naturaleza de la plataforma GPU. Para la GPU, el algoritmo de trazado de ruta de frente de onda es muy natural.

¿Vale la pena?


Por supuesto, tenemos una pregunta: ¿el empleo optimizado justifica la E / S de las memorias intermedias y el costo de invocar núcleos adicionales?

La respuesta es sí, pero probar esto no es tan fácil.

Si volvemos a los trazadores de ruta con ShaderToy por un segundo, veremos que la mayoría de ellos usan una escena simple y codificada. Reemplazarlo con una escena completa no es una tarea trivial: para millones de primitivas, la intersección del haz y la escena se convierte en un problema complejo, cuya solución a menudo se deja a NVidia ( Optix ), AMD ( Radeon-Rays ) o Intel ( Embree ). Ninguna de estas opciones puede reemplazar fácilmente la escena codificada en el trazador de rayos artificiales CUDA. En CUDA, el análogo más cercano (Optix) requiere control sobre la ejecución del programa. Embree en la CPU le permite rastrear haces individuales desde su propio código, pero el costo de esto es una sobrecarga de rendimiento significativa: prefiere rastrear grandes grupos de haces en lugar de haces individuales.


Pantalla de It's About Time renderizada con Brigade 1.

El trazado del camino del frente de onda será más rápido que su alternativa (el megakernel, como lo llaman Lane y sus colegas), depende del tiempo que pasen en los núcleos (las escenas grandes y los sombreadores costosos reducen el exceso relativo de costos por el algoritmo del frente de onda), en la longitud máxima del camino , empleo mega-core y diferencias en la carga de los registros en cuatro etapas. En una versión anterior del Trazador de ruta de brigada original , descubrimos que incluso una escena simple con una mezcla de superficies reflectantes y Lambert que se ejecutan en la GTX480 se benefició del uso de wavefront.

Streaming Path Tracing en el faro 2


La plataforma Lighthouse 2 tiene dos trazadores de trazado de ruta de frente de onda. El primero usa Optix Prime para la implementación de las etapas 2 y 4 (etapas de la intersección de rayos y escenas); en el segundo, Optix se usa directamente para implementar esa funcionalidad.

Optix Prime es una versión simplificada de Optix que solo se ocupa de la intersección de un conjunto de haces con una escena que consiste en triángulos. A diferencia de la biblioteca Optix completa, no admite códigos de intersección personalizados y solo intersecta triángulos. Sin embargo, esto es exactamente lo que se requiere para el trazado de ruta de frente de onda.

El trazado de ruta de frente de onda basado en Optix Prime se implementa en rendercore.cpp proyecto rendercore.cpp . La inicialización de Optix Prime comienza en la función Init y usa rtpContextCreate . La escena se crea usando rtpModelCreate . Se crean varios buffers de rayos en la función rtpBufferDescCreate usando rtpBufferDescCreate . Tenga en cuenta que para estos búferes proporcionamos los punteros habituales del dispositivo: esto significa que se pueden usar tanto en Optix como en núcleos CUDA normales.

El renderizado comienza en el método Render . Para llenar el búfer de rayos primario, generateEyeRays un núcleo CUDA llamado generateEyeRays . Después de llenar el búfer, se llama a Optix Prime usando rtpQueryExecute . Con él, los resultados de la intersección se escriben en extensionHitBuffer . Tenga en cuenta que todos los buffers permanecen en la GPU: con la excepción de las llamadas del kernel, no hay tráfico entre la CPU y la GPU. La etapa "Shadow" se implementa en el núcleo de shade CUDA normal. Su implementación está en pathtracer.cu .

optixprime_b pena mencionar algunos detalles de implementación para optixprime_b . Primero, los rayos de sombra se trazan fuera del ciclo del frente de onda. Esto es correcto: un rayo de sombra afecta a un píxel solo si no está bloqueado, pero en todos los demás casos su resultado no es necesario en ningún otro lugar. Es decir, el haz de sombra es desechable , se puede rastrear en cualquier momento y en cualquier orden. En nuestro caso, usamos esto agrupando los rayos de la sombra para que el lote finalmente trazado sea lo más grande posible. Esto tiene una consecuencia desagradable: con N iteraciones del algoritmo de frente de onda y X rayos primarios, el límite superior del número de rayos de sombra es igual a XN .

Otro detalle es el procesamiento de varios contadores. Las etapas "Renovar" y "Sombra" deben saber cuántos caminos están activos. Los contadores para esto se actualizan en la GPU (atómicamente), lo que significa que se usan en la GPU, incluso sin volver a la CPU. Desafortunadamente, en uno de los casos esto es imposible: la biblioteca Optix Prime necesita saber la cantidad de rayos trazados. Para hacer esto, necesitamos devolver la información de los contadores una vez que se realiza una iteración.

Conclusión


Este artículo explica qué es el trazado de ruta de frente de onda y por qué es necesario realizar de manera efectiva el trazado de ruta en la GPU. Su implementación práctica se presenta en la plataforma Lighthouse 2, que es de código abierto y está disponible en Github .

Source: https://habr.com/ru/post/461017/


All Articles