Post

Vulkan 튜토리얼: 그래픽스 큐 설정하기

이번 튜토리얼에서는 드디어 컴퓨트 파이프라인이 아닌 그래픽스 파이프라인(graphics pipeline)을 이용해 삼각형을 렌더링하는 소주제에 돌입합니다. 이는 이전까지의 과정과 판이한 구조를 가집니다. 이 과정을 전반적으로 요약하면 다음과 같습니다.

  1. 그래픽스 파이프라인 생성. 컴퓨트 파이프라인과 달리 이는 버텍스 셰이더(vertex shader) 스테이지와 프라그멘트 셰이더(fragment shader)라 불리는 두 개의 스테이지를 거칩니다1.
  2. 렌더링 컨텍스트 설정. 이는 렌더패스(render pass)다이나믹 렌더링(dynamic rendering)을 의미하며, 우리는 후자를 사용할 것입니다.
  3. 그리기 연산 수행. 이는 그래픽스 큐(graphics queue)를 사용해야 합니다.

Vulkan은 OpenGL 그래픽스 파이프라인과 달리 상태(state)에 대한 기본값이 정해진 것이 아닌, 모든 상태를 개발자가 직접 설정해야 합니다. 여러 Hello Triangle 튜토리얼에서 파이프라인을 정의하는 코드만 300줄이 넘어가는 것도 이러한 탓입니다. 우리는 그래픽스 파이프라인의 모든 상태를 짚고 넘어갈 것은 아니지만, 적어도 컴퓨트 파이프라인보다는 복잡할 테니 마음의 준비를 하시길 바랍니다.

조건을 만족하는 물리 디바이스를 선택하는 견고한 방법

그래픽스 파이프라인을 구성하기에 앞서, 먼저 우리는 본격적으로 그래픽스 연산(래스터라이징 등)을 수행할 수 있는 하드웨어가 필요합니다. 이는 그래픽스 기능이 활성화된 큐 패밀리로부터 얻어낼 수 있습니다. 그러나 (물론 디스플레이가 있는 여러분의 컴퓨터에서는 당연히 이러한 큐 패밀리가 존재하겠지만,) Vulkan은 앞서 설명했듯 GPGPU 프레임워크를 지향함에 따라 이러한 그래픽스 기능이 없을 수 있습니다. 따라서, 우리는 물리 디바이스를 선택할 때 그래픽스 연산이 가능한 물리 디바이스를 선택해야 합니다.

앞선 튜토리얼에서는 모든 물리 디바이스가 컴퓨트 기능을 지원하기에 가용 물리 디바이스 중 첫 번째 것을 골랐지만, 이제는 물리 디바이스 중 그래픽스가 가능한 것을 골라야 되며 이를 선별(select)해야함을 의미합니다.

이 튜토리얼에서는 오직 그래픽스 큐만 쓰이겠지만, 어떻게 여러 개의 큐를 갖도록 디바이스를 구성할 지에 대해 파악하기 위해 이전에 만든 컴퓨트 큐를 유지한 상태에서 진행하겠습니다. 먼저, 두 번째 튜토리얼의 compute-image CMake 타겟에 해당하는 폴더를 복제하여, 새로운 hello-triangle 타겟을 만들겠습니다.

QueueFamilyIndices 구조체

먼저, 우리는 컴퓨트 및 그래픽스 큐 패밀리 인덱스를 같이 보관할 수 있는 구조체 QueueFamilyIndices를 정의하고, 그 생성자로 물리 디바이스를 전달받아 해당 물리 디바이스로부터 큐 패밀리 인덱스를 얻어내겠습니다.

Subject: [PATCH] New struct: QueueFamilyIndices.
---
Index: src/03_hello-triangle/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/03_hello-triangle/main.cpp b/src/03_hello-triangle/main.cpp
--- a/src/03_hello-triangle/main.cpp
+++ b/src/03_hello-triangle/main.cpp
@@ -30,6 +30,32 @@
 }
 }
 
+struct QueueFamilyIndices {
+    std::uint32_t compute;
+    std::uint32_t graphics;
+
+    explicit QueueFamilyIndices(
+        vk::PhysicalDevice physicalDevice
+    ) {
+        std::optional<std::uint32_t> _compute{}, _graphics{};
+        for (auto [idx, properties] : physicalDevice.getQueueFamilyProperties() | std::views::enumerate) {
+            if (!_compute && properties.queueFlags & vk::QueueFlagBits::eCompute) {
+                _compute = idx;
+            }
+            if (!_graphics && properties.queueFlags & vk::QueueFlagBits::eGraphics) {
+                _graphics = idx;
+            }
+            if (_compute && _graphics) {
+                compute = *_compute;
+                graphics = *_graphics;
+                return;
+            }
+        }
+
+        throw std::runtime_error { "Physical device doesn't have the required queue families." };
+    }
+};
+
 [[nodiscard]] auto readFile(
     const std::filesystem::path &path
 ) -> std::vector<std::uint32_t> {

기존과 마찬가지로 물리 디바이스의 큐 패밀리 성질을 열거하는 getQueueFamilyProperties 메서드를 이용해 인덱스와 함께 열거했으며, queueFlags에 각각 eComputeeGraphics가 있는 패밀리 인덱스를 찾아 구조체 필드에 할당합니다. 마지막으로 인수의 물리 디바이스에서 컴퓨트 큐나 그래픽스 큐 중 어느 하나를 찾지 못했을 때 std::runtime_error를 던지게 됩니다.

std::optional<T>T의 값 또는 없음을 나타낼 수 있는 C++17에 도입된 자료형입니다. 위 코드에서 두 std::optional<std::uint32_t>는 처음 std::nullopt (값이 없는 상태)로 초기화되며, 물리 디바이스로부터 해당 큐 패밀리를 찾았을 때 std::uint32_t 타입의 인덱스가 할당됩니다 (값이 존재하는 상태). 이 자료형은 값의 존재 여부에 따라 참/거짓으로 암시적(implicitly) 변환되므로, && 연산자와 결합하여 두 객체 모두에 값이 존재하는지을 확인할 수 있습니다.

물리 디바이스 선택하기

물리 디바이스에서 큐 패밀리 인덱스를 파악하는 구조체를 작성했으므로, 물리 디바이스를 순회하며 해당 디바이스가 필요한 큐 패밀리를 갖는지 확인해봅시다.

Subject: [PATCH] Select adequate physical device and queue families.
---
Index: src/03_hello-triangle/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/03_hello-triangle/main.cpp b/src/03_hello-triangle/main.cpp
--- a/src/03_hello-triangle/main.cpp
+++ b/src/03_hello-triangle/main.cpp
@@ -104,7 +104,42 @@
         } };
     }();
 
-    const vk::raii::PhysicalDevice physicalDevice = instance.enumeratePhysicalDevices().front();
+    const vk::raii::PhysicalDevice physicalDevice = [&] {
+        // The enumerated physical devices must be declared by variable in here,
+        // otherwise adequatePhysicalDevices will not models borrwed_range
+        // and then std::ranges::max_element(adequatePhysicalDevice, ...) will return std::ranges::dangling.
+        // See https://en.cppreference.com/w/cpp/ranges/dangling for detail.
+        std::vector physicalDevices = instance.enumeratePhysicalDevices();
+
+        auto adequatePhysicalDevices
+            = physicalDevices
+            | std::views::filter([](const vk::raii::PhysicalDevice &physicalDevice) {
+                try {
+                    const QueueFamilyIndices queueFamilyIndices { *physicalDevice }; (void)queueFamilyIndices;
+                    return true;
+                }
+                catch (const std::runtime_error&) {
+                    return false;
+                }
+            });
+        if (adequatePhysicalDevices.empty()) {
+            throw std::runtime_error { "Vulkan instance has no adequate physical device." };
+        }
+
+        // Since adequatePhysicalDevice is not empty range, the return iterator is guaranteed to be not end iterator.
+        return *std::ranges::max_element(adequatePhysicalDevices, {}, [](const vk::raii::PhysicalDevice &physicalDevice) {
+            std::uint32_t score = 0;
+
+            const vk::PhysicalDeviceProperties properties = physicalDevice.getProperties();
+            if (properties.deviceType == vk::PhysicalDeviceType::eDiscreteGpu) {
+                score += 100;
+            }
+
+            score += properties.limits.maxImageDimension2D;
+
+            return score;
+        });
+    }();
 
     const auto getMemoryTypeIndex
         = [memoryTypes = physicalDevice.getMemoryProperties().memoryTypes](
@@ -119,16 +154,7 @@
             throw std::runtime_error { "No suitable memory type found." };
         };
 
-    const std::uint32_t computeQueueFamilyIndex = [&] {
-        for (auto [idx, properties] : physicalDevice.getQueueFamilyProperties() | std::views::enumerate) {
-            if (properties.queueFlags & vk::QueueFlagBits::eCompute) {
-                return idx;
-            }
-        }
-
-        // Since Vulkan specifies that a compute queue must be present, this should never happen.
-        throw std::runtime_error { "No compute queue family in the physical device." };
-    }();
+    const QueueFamilyIndices queueFamilyIndices { *physicalDevice };
 
     const vk::raii::Device device = [&] {
         constexpr std::array queuePriorities { 1.f };

인스턴스로부터 물리 디바이스를 열거하고, 해당 물리 디바이스 각각을 QueueFamilyIndices의 생성자 인수에 넣었을 때 예외를 던지는지 여부에 따라 필터링했습니다(std::views::filter). 그리고 앞선 예제처럼 단순히 첫 번째 가용 물리 디바이스를 선택하는 대신, getProperties 메서드를 이용해 물리 디바이스의 성질을 파악한 뒤,

  1. 물리 디바이스가 외장 그래픽카드(eDiscreteGpu)인 경우 100점을 주고 (대개 외장 그래픽카드는 시스템에 한 개 있으며, 다른 종류의 디바이스(예: 내장 그래픽, CPU 기반 그래픽 카드 등)보다 우수한 성능을 가집니다),
  2. 해당 물리 디바이스가 가용할 수 있는 이미지의 최대 크기(maxImageDimension2D)를 점수로 하여, 이 점수를 앞선 점수에 합산하였습니다.

이후 가장 높은 점수를 가지는 물리 디바이스를 선택하였습니다.

getProperties()가 반환한 물리 디바이스의 성질을 파악하여, 여러분만의 선택 기준을 만들어보세요. 물리 디바이스를 선택한 후에는 이 디바이스가 필요한 큐 패밀리를 가짐이 분명하니, 이를 queueFamilyIndices 변수로 선언하였습니다.

std::ranges::max_elementC++20 부터 추가된 constrained 알고리즘으로, 주어진 범위(range) 내 기준(criteria)를 만족하는 최대의 원소 이터레이터(iterator)를 반환합니다. 그 인수로 범위·비교 함수(이는 std::less<T>로 기본값을 가짐)·투영(projection)을 가지는데, 이 코드에서는 해당 범위의 원소를 인수로 받아 std::uint32_t를 받는 투영 함수를 사용하여 최대치를 계산합니다.

주석에 기재된 바와 같이 instance.enumeratePhysicalDevices()에 바로 std::views::filter를 적용할 수 없습니다. 자세한 내용은 주석의 링크를 참조하시기 바랍니다. 또한, 열거한 물리 디바이스 중 어떠한 것도 필요한 큐 패밀리 인덱스를 포함하지 않는 경우 std::ranges::max_element는 인수로 전달된 범위의 end()에 해당하는 이터레이터를 반환하므로, 해당 함수 호출 전 범위가 비었는지(empty) 확인이 선행되어야 합니다.

그래픽스 큐 가져오기

마찬가지로 사용할 큐 또한 컴퓨트 및 그래픽스 큐 두 개가 필요하니, 이를 묶어 구조체 Queues로 만들어 봅시다.

Subject: [PATCH] Add Queues struct.
---
Index: src/03_hello-triangle/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/03_hello-triangle/main.cpp b/src/03_hello-triangle/main.cpp
--- a/src/03_hello-triangle/main.cpp
+++ b/src/03_hello-triangle/main.cpp
@@ -54,6 +54,36 @@
 
         throw std::runtime_error { "Physical device doesn't have the required queue families." };
     }
+
+    [[nodiscard]] auto isDifferentQueueFamily() const noexcept -> bool {
+        return compute != graphics;
+    }
+};
+
+struct Queues {
+    vk::Queue compute;
+    vk::Queue graphics;
+
+    Queues(
+        vk::Device device,
+        const QueueFamilyIndices &queueFamilyIndices
+    ) noexcept
+        : compute { device.getQueue(queueFamilyIndices.compute, 0) },
+          graphics { device.getQueue(queueFamilyIndices.graphics, 0) } { }
+
+    static auto getQueueCreateInfos(
+        const QueueFamilyIndices &queueFamilyIndices
+    ) -> std::vector<vk::DeviceQueueCreateInfo> {
+        static constexpr std::array queuePriorities { 1.f };
+        return queueFamilyIndices.isDifferentQueueFamily()
+            ? std::vector {
+                vk::DeviceQueueCreateInfo { {}, queueFamilyIndices.compute, queuePriorities },
+                vk::DeviceQueueCreateInfo { {}, queueFamilyIndices.graphics, queuePriorities },
+            }
+            : std::vector {
+                vk::DeviceQueueCreateInfo { {}, queueFamilyIndices.compute, queuePriorities },
+            };
+    }
 };
 
 [[nodiscard]] auto readFile(

QueueFamilyIndices와 마찬가지로 디바이스와 물리 디바이스로부터 얻어낸 QueueFamilyIndices를 인수로 받아 디바이스로부터 큐를 가져오는 생성자를 만들었습니다. 또한 getQueueCreateInfos라는 static 지정자의 함수를 만들어냈는데, 이는 인수의 queueFamilyIndices로부터 디바이스에 어떤 큐 생성 정보 구조체(vk::DeviceQueueCreateInfo)를 사용할지를 결정합니다. 해당 구조체는 필요한 모든 큐 패밀리별로 만들어 전달해야 하므로, queueFamilyIndices 필드에 할당된 두 큐 패밀리가 같은지 다른지에 따라 다른 개수를 전달하게 됩니다. 따라서 QueueFamilyIndices 구조체에 isDifferentQueueFamily 메서드를 추가하여 두 큐 패밀리가 다른지를 확인하고, 각 경우에 대해 다른 원소를 가지는 std::vector를 반환하도록 하였습니다.

queuePrioritiesstatic 지정자를 사용해 만들어졌음을 유의하세요. 만일 해당 변수가 스택에 할당된 경우, 함수가 종료된 직후 이는 소멸하여 vk::DeviceQueueCreateInfo가 댕글링 포인터를 포함하게 됩니다. 해당 구조체 생성자 인수의 형식 vk::ArrayProxyNoTemporaries<T>는 이 구조체를 이용해 어떤 Vulkan의 객체가 성공적으로 생성되기 전까지 해당 구조체가 참조하는 모든 변수의 수명이 유지될 것을 요구하며, 이는 Vulkan-Hpp를 사용하는 개발자의 책임입니다.

Subject: [PATCH] Create device queues using Queues struct.
---
Index: src/03_hello-triangle/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/03_hello-triangle/main.cpp b/src/03_hello-triangle/main.cpp
--- a/src/03_hello-triangle/main.cpp
+++ b/src/03_hello-triangle/main.cpp
@@ -187,12 +187,7 @@
     const QueueFamilyIndices queueFamilyIndices { *physicalDevice };
 
     const vk::raii::Device device = [&] {
-        constexpr std::array queuePriorities { 1.f };
-        const vk::DeviceQueueCreateInfo queueCreateInfo {
-            {},
-            computeQueueFamilyIndex,
-            queuePriorities,
-        };
+        const std::vector queueCreateInfos = Queues::getQueueCreateInfos(queueFamilyIndices);
         const std::vector<const char*> deviceExtensions {
 #if __APPLE__
             "VK_KHR_portability_subset",
@@ -200,13 +195,13 @@
         };
         return vk::raii::Device { physicalDevice, vk::DeviceCreateInfo {
             {},
-            queueCreateInfo,
+            queueCreateInfos,
             {},
             deviceExtensions,
         } };
     }();
 
-    const vk::Queue computeQueue = (*device).getQueue(computeQueueFamilyIndex, 0);
+    const Queues queues { *device, queueFamilyIndices };
 
     // Create image that to be processed by compute shader.
     const vk::raii::Image image { device, vk::ImageCreateInfo {

큐 생성에 필요한 대부분의 코드를 Queues 구조체에 양도하여, 실제 디바이스 생성 코드가 더 간략해졌습니다. getQueueCreateInfos 함수 반환값을 디바이스 생성자에 전달하고, 이후 Queues 구조체를 이용해 컴퓨트 및 그래픽스 큐를 가져왔습니다.


이렇게 기존 애플리케이션에서 그래픽스 큐를 성공적으로 도입했습니다. Vulkan을 비롯한 현대적인 그래픽스 API는 한 디바이스에서 여러 큐를 사용하는 것을 권장하며, 하드웨어적으로 어떤 큐는 다른 큐보다 특정 작업에 유리하여 이를 전략적으로 활용하면 큰 성능 향상을 기대할 수 있습니다2. 또한 서로 다른 큐에는 스레드의 경쟁 상태(race condition) 없이 커맨드 버퍼를 제출할 수 있으므로, 멀티스레딩시 여러 개의 큐를 사용하는 것이 좋습니다.

다음 튜토리얼에서는 본격적으로 삼각형을 렌더링할 그래픽스 셰이더(graphics shader) 코드를 작성하고, 그래픽스 파이프라인을 생성하겠습니다. 이번 튜토리얼의 전체 코드 변경 사항은 다음과 같습니다.

  1. 원래는 버텍스-테셀레이션 컨트롤-테셀레이션 계산-지오메트리-프라그멘트로 다섯 스테이지를 거치나, 중간 세 스테이지는 대개 기본값으로 사용하는 경우가 많습니다. 사실 프라그멘트 스테이지 없이 깊이(depth)만 렌더링하는 파이프라인도 가능하며, 현대 GPU에서는 버텍스 셰이더 대신 메시 셰이더(mesh shader)를 사용하여 더 자유로운 프리미티브 조합(primitive assembling) 알고리즘을 사용할 수도 있습니다. 이 튜토리얼에서는 통상적인 두 스테이지의 과정을 따르겠습니다. 

  2. https://github.com/KhronosGroup/Vulkan-Guide/blob/main/chapters/queues.adoc. 어떤 큐 패밀리가 오직 TRANSFER_BIT만 가질 경우 (또는 이에 더해 SPARSE_BINDING_BIT도 가질 수 있습니다) DMA(Direct memory access)가 가능한 큐일 가능성이 높습니다. 

This post is licensed under CC BY 4.0 by the author.