В мире компьютерной графики и визуализации реалистичных изображений трассировка лучей по-прежнему остаётся самым востребованным и одновременно самым ресурсоёмким методом рендеринга. С появлением специализированных RT-ядер в графических процессорах NVIDIA RTX, вычисления стали гораздо эффективнее, но ограничение в виде аппаратного ускорения для треугольников и сложных геометрических примитивов всё ещё существует. В своём проекте я решил пойти другим путём — разработать трассировщик лучей полностью на CUDA, обходясь без аппаратной поддержки RT-ядер, и при этом добиться в два раза большей производительности, чем реализованный на Vulkan RTX-рендерер. Эта статья — рассказ о моём пути, достижениях и неожиданных сложностях, которые раскрывают тонкости настоящей GPU-оптимизации. Моим основным эталоном для сравнения послужил RayTracingInVulkan от GPSnoopy — известный проект с поддержкой RTX-ускорения, использующий Vulkan API.
Задача не сводилась лишь к простой портировке кода на CUDA, а включала глубокий анализ узких мест, поэтапную оптимизацию и детальное профилирование, позволяющее выжать максимум производительности из архитектуры GPU. При этом использовался один и тот же комплект аппаратного обеспечения — RTX 3080 с 10 ГБ памяти и процессором i5 13600KF. Любопытно, что после корректировки алгоритма трассировки (включения русского рулетки) разрыв в скорости между CUDA и RTX сократился с первоначальных заявленных 3.6x до примерно 2x, что всё равно остаётся впечатляющим результатом. Почему же CUDA с программной реализацией без RT-ядер может превзойти аппаратно-ускоренную трассировку от NVIDIA? Причина кроется в природе рабочего процесса и особенностях архитектуры GPU.
Процедурные сферы, которые мы рендерили, оказываются относительно дешёвыми по вычислительной части — и RT-ядер проще устанавливать обход структуры BVH (Bounding Volume Hierarchy), но существенно больше ресурсов требуется для её обхода. На карте NVIDIA RTX 3080 вычислительные блоки GPU простаивают, в то время как RT-ядра перегружены обработкой BVH. По сути, трассировка с процедурными примитивами сильно ограничена производительностью RT-ядер, и можно получить выигрыш, если отказаться от их использования и делать весь обход и пересечения через CUDA ядра. Вдобавок, традиционный RTX pipeline подразумевает частую передачу данных между разными этапами шейдеров, что генерирует значительную нагрузку на пропускную способность видеопамяти. В моём CUDA-решении же всё содержится непосредственно в регистрах потоков, минимизируя трафик в глобальной памяти — это очень сильно повышает скорость.
Реализация inline ray tracing (встроенная трассировка лучей без промежуточного аппаратного RT pipeline) показала себя гораздо эффективнее в нашем сценарии. Путём демонтажа, я пришёл к пониманию того, что CUDA даёт невероятный уровень контроля над исполнением кода. Можно самому управлять как ядра запускаются, как распределяется память, как скрываются задержки, и наблюдать реальные причины задержек. Однако у этой свободы есть свои ловушки. Одной из них стала рекурсия — она неумолимо взрывала регистровое давление и приводила к spill-ам — ситуации, когда данные перестают умещаться в регистры и начинают обращаться к медленной локальной памяти.
Это снижало производительность и уменьшало количество потоков, работающих параллельно (occupancy). Для решения проблемы был переписан обход BVH с рекурсивного на итеративный, с использованием явного стека, расположенного в регистрах или на стеке GPU. Такой подход позволил эффективно переиспользовать регистры, уменьшить число состояний, устранить ненужные вызовы и ветвления, что уменьшило время кадра на порядки. Фактически, кадры с 2.5 секунд превратились в 300 миллисекунд после смены стратегии навигации.
Другой значимый шаг — переход от наследования с виртуальными функциями (virtual calls) к структуре данных типа Structure of Arrays (SoA). Азартная графика, без виртуальных вызовов и динамического определения типов, позволяет организовать данные последовательно в памяти, что улучшает местоположение данных и повышает эффективность кэширования. Особенности NVIDIA CUDA требуют предсказуемых и согласованных шаблонов доступа для максимальной производительности, и отказ от классической ООП-абстракции в пользу плоских массивов данным оказался важным моментом. Оптимизации касаются и математических вычислений. Переход на использование встроенных в CUDA функций <cmath> вроде fmaxf, fminf и fmaf позволил избежать сложных условных переходов, которые вели к ветвлению и большему количеству инструкций.
Это дало избавление от предупреждений компилятора и снизило количество циклов на проверку пересечений и вычисление областей BVH. Не менее важными оказались оптимизации памяти. Выровненные в 16 байт структуры для векторов и габаритных коробок (AABBs) существенно улучшили поведение кэша и сократили количество глобальных обращений. Применение __constant__ памяти для параметров рендера (постоянных и одинаковых для каждого потока) позволило освободить регистры и улучшить локальность данных благодаря аппаратному кешу константной памяти CUDA. Мои собственные генераторы случайных чисел (LCG+PCG-хэш), заменившие мощный, но тяжелый curand, снизили нагрузку на регистры и позволили генерировать случайные значения без существенных затрат, что в критичных по вычислениям местах положительно сказалось на скорости.
Bootleg-вариант без ветвлений при обработке материалов заменил множественные условия одним плавным смешиванием различных шейдеров материалов (от диффузных до диэлектриков), минимизировал расходимость потоков в варпах и увеличил согласованность исполнения. Ещё одно удивительное улучшение — интеграция CUDA с OpenGL через межпроцессный обмен графическими ресурсами, что позволило избавиться от копирования результатов рендера через CPU и снизило задержку GPU↔CPU↔GPU, ускорив итоговое отображение кадра. Измерения производительности показали, что на RTX 3080 при разрешении 1280х720 на один кадр у меня получилось добиться порядка 8–9 миллисекунд, что в два-три раза быстрее исходной RTX реализации. Это говорит о высоком потенциале производственной трассировки лучей без явного использования RT-ядер, особенно в сценах с процедурными примитивами. Будущими планами является исследование архитектуры Wavefront path tracing, которые позволяют снизить дивергенцию, разбивая трассировку на этапы и обрабатывая большие группы похожих лучей, а также добавление поддержки треугольников через современные BVH библиотеки, потенциально комбинируя CUDA свои наработки с аппаратным ускорением, например OptiX.