Vulkan 튜토리얼: 버퍼 생성하기
목표 설정
소주제의 목표인 버퍼를 조작하고 입출력하는 과정을 구체화하겠습니다. GPU라는 병렬 연산 장치의 능력을 돋보이기 위해, 우리는 0부터 1,023까지 총 1,024개의 float
을 담고 있는 버퍼를 만들고, 컴퓨트 파이프라인을 이용해 그 두 배의 값을 계산해 채워 넣겠습니다. CPU라면 총 1,024번의 곱셈 연산이 필요한 이 과정을 GPU가 어떻게 병렬로 처리하는지를 볼 수 있을 것입니다.
버퍼와 디바이스 메모리 생성하기
GPU는 컴퓨터의 CPU와는 별개로 동작하는 부품입니다. CPU(이하 호스트)가 GPU(이하 디바이스)에 명령을 내리면 디바이스는 그 명령을 수행하지만, 디바이스는 독자적인 레지스터/캐시/램을 가지고 있습니다. 그 말인즉슨 우리가 호스트에 저장한 데이터를 디바이스에서 사용하기 위해서는 그 데이터가 PCIe 케이블을 이용하여 물리적으로 전송돼야 함을 의미합니다.
위 목표와 같이 0부터 1,023까지의 데이터를 생성하는 능력은 디바이스에 존재하지 않기 때문에, 우리는 이 데이터를 호스트에서 생성한 후 디바이스에 전송할 것입니다. 이 전송 방식은 두 가지가 있습니다:
- 데이터의 전송은 디바이스가 해당 데이터를 필요로 할 때마다 호스트-디바이스 간에 전송됩니다. 모든 호스트 데이터 읽기/쓰기는 PCIe 케이블로 전송되며, 이 과정은 디바이스의 연산 능력에 비해 느리기 때문에 더 많은 시간이 걸릴 수 있습니다.
- 데이터를 디바이스의 저장 공간에 최초로 1회 전송한 후 GPU가 데이터를 제한 없이 읽고 쓸고 있게 합니다. 이는 GPU에 최적의 능력을 부여하지만, 반대로 이미 데이터가 디바이스로 가있는 상태에서 호스트는 데이터에 접근할 수 없습니다. 호스트가 접근하고 싶을 경우 명시적으로 디바이스에서 호스트로 데이터를 재전송해야 합니다.
만일 데이터가 디바이스에 오래 존재해야 하고, 호스트가 디바이스의 연산 도중 데이터에 접근할 필요가 없는 경우 2번 시나리오가 더 적합할 것입니다. 반대로 호스트가 데이터에 자주 접근해야 할 경우 1번이 더 좋은 선택지일 것입니다. 우리의 목표 시나리오에는 데이터 생성 -> 디바이스의 곱 연산 -> 데이터 불러오기이기 때문에 성능면에서는 2번이 더 좋을 것이나, 일단은 쉬운 난이도를 위해 1번 방법을 사용하겠습니다1.
위 설명을 구체화하기 위해서 우리는 두 가지 객체 vk::raii::Buffer
와 vk::raii::DeviceMemory
를 생성해야 합니다. 버퍼는 디바이스에서 읽을 수 있는 데이터의 포장지이고, 디바이스 메모리는 이 데이터가 실제로 바이트 단위로 존재하는 것입니다. 둘은 별개로 생성될 수 있으며, 우리가 포장지와 내용물을 연결짓기 위해서는 둘을 서로 바인딩해야 합니다.
버퍼 생성하기
버퍼를 생성하는 vk::BufferCreateInfo
구조체에는 다음과 같은 인수를 제공해야 합니다:
flags
: 버퍼 생성시 지정할 플래그. 지금은 필요하지 않으므로 기본값({}
)으로 전달합니다.size
: 버퍼의 크기(바이트 단위)를 나타냅니다.sizeof(float) * 1024
를 전달합니다.usage
: 버퍼의 사용 용도로,vk::BufferUsageFlags
타입을 가집니다. 이는vk::BufferUsageFlagBits
열거형 중 하나 또는 이들의 중첩이여야 합니다 (|
연산자를 이용해 열거형을 중첩할 수 있습니다). 우리의 목표에 따르면, 이 버퍼는 특별히 “컴퓨트 셰이더에서 쓰기 연산을 할수 있는” 버퍼입니다. 이 용도에는eStorageBuffer
가 제격입니다. 스토리지 버퍼는 셰이더에서 읽고 쓸 수 있는 고용량의 메모리입니다.
Subject: [PATCH] Create the buffer that holds 1,024 floats.
---
Index: src/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/main.cpp b/src/main.cpp
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -94,4 +94,10 @@
}();
const vk::Queue computeQueue = (*device).getQueue(computeQueueFamilyIndex, 0);
+
+ const vk::raii::Buffer buffer { device, vk::BufferCreateInfo {
+ {},
+ sizeof(float) * 1024,
+ vk::BufferUsageFlagBits::eStorageBuffer,
+ } };
}
\ No newline at end of file
디바이스 메모리를 생성하고 버퍼와 바인딩하기
실제 데이터를 저장할 디바이스 메모리는 버퍼와 같은 크기를 가지며, 앞서 설명한 시나리오에 따라 호스트에서 접근 가능해야 합니다. 디바이스는 여러 종류의 메모리를 가지고 있으며, 어떤 메모리는 디바이스에서만 접근 가능하고 (이 메모리는 DEVICE_LOCAL_BIT
플래그를 가집니다), 다른 메모리는 호스트에서 접근 가능할 것입니다 (이 메모리는 HOST_VISIBLE_BIT
플래그를 가집니다). VulkanCapsViewer 애플리케이션의 Memory 탭에서 가용 메모리 타입을 파악할 수 있습니다.
HOST_VISIBLE_BIT
과DEVICE_LOCAL_BIT
는 배타적인 개념이 아닙니다. (두 플래그를 모두 갖는 메모리도 있습니다!) 말 그대로DEVICE_LOCAL
은 해당 메모리가 디바이스 내 메모리라는 뜻이며,HOST_VISIBLE
은 호스트에서 접근 가능한 메모리임을 의미합니다. 다음과 같이 정리될 수 있습니다.
HOST_VISIBLE_BIT
만 가짐: 디바이스에서도 접근할 수 있으나, 데이터 전송은 PCIe 케이블의 대역폭 한계를 가질 것이고,DEVICE_LOCAL_BIT
을 갖는 메모리보다 디바이스에서 느리게 연산될 수 있습니다.DEVICE_LOCAL_BIT
만 가짐: 호스트에서 접근할 수 없으며, 디바이스에서 빠르게 접근 가능합니다. 두 메모리 플래그를 모두 가짐: 호스트와 디바이스 모두에서 접근 가능합니다.
VulkanCapsViewer로 가용 메모리 힙을 표시한 모습
일단 우리는 호스트에서 접근 가능한 메모리를 찾기 위해, 물리 디바이스의 가용 메모리 타입을 순회하며 HOST_VISIBLE_BIT
플래그를 갖는 메모리 인덱스를 찾겠습니다.
Subject: [PATCH] Create device memory for buffer and map to it.
---
Index: src/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/main.cpp b/src/main.cpp
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -100,4 +100,23 @@
sizeof(float) * 1024,
vk::BufferUsageFlagBits::eStorageBuffer,
} };
+
+ const auto getMemoryTypeIndex
+ = [memoryTypes = physicalDevice.getMemoryProperties().memoryTypes](
+ vk::MemoryPropertyFlags memoryPropertyFlags
+ ) -> std::uint32_t {
+ for (auto [idx, memoryType] : memoryTypes | std::views::enumerate) {
+ if ((memoryType.propertyFlags & memoryPropertyFlags) == memoryPropertyFlags) {
+ return idx;
+ }
+ }
+
+ throw std::runtime_error { "No suitable memory type found." };
+ };
+
+ const vk::raii::DeviceMemory bufferMemory { device, vk::MemoryAllocateInfo {
+ buffer.getMemoryRequirements().size,
+ getMemoryTypeIndex(vk::MemoryPropertyFlagBits::eHostVisible),
+ } };
+ buffer.bindMemory(*bufferMemory, 0);
}
\ No newline at end of file
물리 디바이스의 getMemoryProperties()
메서드를 이용해 메모리 정보를 얻고, 가용 메모리 타입을 람다의 memoryTypes
변수로 캡처하였습니다2. 얻은 memoryTypes
배열을 인덱스와 함께 열거하며 해당 메모리 타입이 인수로 제공한 메모리 타입을 포함하는지 확인하고 ((memoryType.propertyFlags & memoryPropertyFlags) == memoryPropertyFlags
) 해당하는 메모리 타입이 없다면 std::runtime_error
를 반환합니다.
찾은 메모리 인덱스와, 생성한 버퍼의 크기 (buffer.getMemoryRequirements().size
)를 vk::MemoryAllocateInfo
의 인수로 넘기며 bufferMemory
를 생성하였습니다.
마지막 buffer.bindMemory(*bufferMemory, 0);
구문은 버퍼와 디바이스 메모리를 같이 동작시키기 위한 바인딩(binding)이라는 과정입니다. 이 과정을 통해 버퍼가 디바이스 메모리의 어떤 영역을 가지는지 설정할 수 있습니다. 우리는 버퍼와 디바이스 메모리 크기가 같으므로 바인딩되는 디바이스 메모리의 오프셋을 0으로 설정하여 전체 영역을 바인딩하겠습니다.
버퍼에 수 채워넣기
비록 버퍼가 호스트에서 접근 가능한 메모리에 바인딩되어있지만, 우리가 CPU에서 실제로 이를 읽고 쓸수 있기 위해서는 디바이스 메모리가 어떤 참조 가능한 호스트 메모리의 주소에 연결되어 있어야 합니다. 이러한 연결을 매핑(mapping)이라고 합니다. 매핑을 통해 디바이스 메모리 주소를 void*
타입으로 얻어낼 수 있습니다.
Subject: [PATCH] Write floats from 0 to 1023 to the buffer.
---
Index: src/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/main.cpp b/src/main.cpp
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -116,7 +116,16 @@
const vk::raii::DeviceMemory bufferMemory { device, vk::MemoryAllocateInfo {
buffer.getMemoryRequirements().size,
- getMemoryTypeIndex(vk::MemoryPropertyFlagBits::eHostVisible),
+ getMemoryTypeIndex(vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent),
} };
buffer.bindMemory(*bufferMemory, 0);
+
+ // Map whole ranges of bufferMemory into host memory.
+ void* const data = (*device).mapMemory(*bufferMemory, 0, vk::WholeSize);
+
+ // Fill buffer with floats from 0 to 1023.
+ const std::span nums { static_cast<float*>(data), 1024 };
+ std::iota(nums.begin(), nums.end(), 0.f);
+
+ (*device).unmapMemory(*bufferMemory);
}
\ No newline at end of file
void*
타입의 data
변수가 디바이스 메모리의 맨 처음 바이트를 가르키고, 이 포인터를 float*
으로 캐스팅하므로써 1,024개의 float
배열 중 첫 번째 원소를 가르키게 했습니다. 연속된 타입의 배열에 대한 뷰(view)를 나타내는 std::span
의 (처음 원소 주소, 원소 개수) 생성자를 이용해 nums
를 생성했고, 이 뷰에 std::iota
를 이용하여 0.f
부터 시작하는 수 1,024개를 채워넣었습니다.
그런데 그에 앞서 getMemoryTypeIndex
의 인수에 vk::MemoryPropertyFlagBits::eHostCoherent
가 추가로 중첩되었습니다. 비록 위 std::iota
함수를 통해 nums
에 값을 쓰긴 했으나, Vulkan은 성능을 위해 해당 시점에서 바로 디바이스 메모리에 쓴 값을 기록하지 않을 수 있습니다. 이를 해결하기 위해 두 가지 방법이 있습니다.
HOST_COHERENT_BIT
플래그를 갖는 메모리 사용하기: 이 플래그를 갖는 메모리는 호스트의 메모리 쓰기 연산을 바로 반영합니다.- 디바이스의
flushMappedMemoryRanges
메서드 호출하기: 쓰기 연산 후 다음 코드를 호출하세요.1 2 3 4 5
device.flushMappedMemoryRanges(vk::MappedMemoryRange { *bufferMemory, 0, vk::WholeSize, });
0은 매핑한 디바이스 메모리의 오프셋,
vk::WholeSize
상수는 우리가 전체 디바이스 메모리 크기를 매핑했다는 뜻입니다.
다만 현재 대부분의 Vulkan 하드웨어 메모리 힙은 HOST_VISIBLE_BIT
을 갖는다면 HOST_COHERENT_BIT
도 갖고 있습니다. 따라서 우리는 첫 번째 방법을 이용하겠습니다.
마지막으로, 우리가 Vulkan-Hpp의 객체 수명을 자동으로 관리해주는 RAII 패턴을 사용하고 있긴 하지만, 소멸자는 단지 디바이스 메모리를 명시적으로 해제할 뿐 매핑을 해제하지는 않습니다. 따라서 매핑은 명시적으로 해제돼야 합니다. 디바이스의 unmapMemory
메서드를 사용하여 매핑을 명시적으로 해제하였습니다.
이렇게 성공적으로 버퍼를 생성했습니다. 버퍼는 앞으로의 모든 호스트-디바이스 간 데이터 전송에 필수적으로 사용되는 바이트 묶음이며, 우리가 Vulkan을 사용하며 얻어낼 모든 유의미한 데이터를 담는 그릇이 될 것입니다.
이번 튜토리얼의 전체 코드 변경 사항은 다음과 같습니다.
Subject: [PATCH] Write floats from 0 to 1023 to the buffer.
Create device memory for buffer and map to it.
Create the buffer that holds 1,024 floats.
---
Index: src/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/main.cpp b/src/main.cpp
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -94,4 +94,38 @@
}();
const vk::Queue computeQueue = (*device).getQueue(computeQueueFamilyIndex, 0);
+
+ const vk::raii::Buffer buffer { device, vk::BufferCreateInfo {
+ {},
+ sizeof(float) * 1024,
+ vk::BufferUsageFlagBits::eStorageBuffer,
+ } };
+
+ const auto getMemoryTypeIndex
+ = [memoryTypes = physicalDevice.getMemoryProperties().memoryTypes](
+ vk::MemoryPropertyFlags memoryPropertyFlags
+ ) -> std::uint32_t {
+ for (auto [idx, memoryType] : memoryTypes | std::views::enumerate) {
+ if ((memoryType.propertyFlags & memoryPropertyFlags) == memoryPropertyFlags) {
+ return idx;
+ }
+ }
+
+ throw std::runtime_error { "No suitable memory type found." };
+ };
+
+ const vk::raii::DeviceMemory bufferMemory { device, vk::MemoryAllocateInfo {
+ buffer.getMemoryRequirements().size,
+ getMemoryTypeIndex(vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent),
+ } };
+ buffer.bindMemory(*bufferMemory, 0);
+
+ // Map whole ranges of bufferMemory into host memory.
+ void* const data = (*device).mapMemory(*bufferMemory, 0, vk::WholeSize);
+
+ // Fill buffer with floats from 0 to 1023.
+ const std::span nums { static_cast<float*>(data), 1024 };
+ std::iota(nums.begin(), nums.end(), 0.f);
+
+ (*device).unmapMemory(*bufferMemory);
}
\ No newline at end of file