모던 Vulkan (C++ 버전) 시리즈의 일곱 번째 글입니다. 이제까지 인스턴스 생성, 디바이스/큐 확보, 커맨드 버퍼, 메모리 관리, 버퍼 생성, Compute 파이프라인 및 디스크립터 구성까지 모두 C++ RAII 패턴과 Vulkan-HPP를 활용해 재정비했습니다. 이제 드디어 실제 GPGPU 연산을 수행하는 벡터 덧셈 예제를 Modern C++ 스타일로 완성해봅시다.
입문 시리즈에서 C 스타일로 구현했던 벡터 덧셈 과정(Host에서 A, B를 준비한 뒤 GPU로 올리고, Compute 셰이더로 A+B를 C에 기록, 다시 Host로 결과를 가져오는 과정)을 이제 Vulkan-HPP 기반 C++ RAII 패턴, 예외 처리, STL 컨테이너와 함께 사용해 깔끔하게 재작성하겠습니다.
목표
- 호스트에서 std::vector A, B, C 준비
- 메모리 관리, 버퍼 생성 과정 (Host Visible 메모리) 통해 A, B를 GPU에 업로드
- Compute 파이프라인 (add.comp.spv) 및 디스크립터 셋 구성
- 커맨드 버퍼 녹화: 파이프라인 바인딩, 디스패치(Dispatch) 명령 기록
- 큐 제출 후 대기, 결과 C 버퍼를 다시 Host에서 읽어 검증
- 모든 과정에서 RAII로 리소스 자동 정리, 예외 발생 시 예외 처리로 에러 핸들링 단순화
벡터 덧셈 로직 복습
Compute 셰이더(add.comp) 예제 (binding=0: A, binding=1: B, binding=2: C):
#version 450
layout(local_size_x = 256) in;
layout(std430, binding = 0) buffer ABuffer { float A[]; };
layout(std430, binding = 1) buffer BBuffer { float B[]; };
layout(std430, binding = 2) buffer CBuffer { float C[]; };
void main() {
uint idx = gl_GlobalInvocationID.x;
C[idx] = A[idx] + B[idx];
}
gl_GlobalInvocationID.x를 통해 각 스레드가 하나의 요소를 처리. N개 요소가 있다면 (N+255)/256
블록 수로 Dispatch.
코드 예제
아래 예제는 인스턴스 > 디바이스 > 메모리 > 파이프라인 > 커맨드버퍼 > 큐 모든 단계를 앞서 만든 코드를 종합한 형태로 보여줍니다. 실제로는 이전 글의 코드를 하나의 프로젝트로 통합한 뒤 일부 변경하면 되지만, 여기서는 독립된 예제로 간략화합니다.
디렉토리 구조
my_vulkan_hpp_vector_add/
├─ CMakeLists.txt
├─ shaders/
│ └─ add.comp.spv
├─ src/
│ └─ main.cpp
└─ build/
main.cpp 코드 (핵심 로직만 예시)
#include <iostream>
#include <vector>
#include <vulkan/vulkan.hpp>
#include <stdexcept>
#include <cstring> // for memcpy
// 유틸 함수: readFile, findMemoryTypeIndex 가정 (이전 글 코드 재사용)
// 예외 모드 활성화 가정: 실패 시 vk::SystemError 또는 std::runtime_error 던짐
uint32_t findMemoryTypeIndex(const vk::PhysicalDeviceMemoryProperties& memProperties,
uint32_t typeFilter,
vk::MemoryPropertyFlags properties);
std::vector<char> readFile(const std::string& filename);
int main() {
// 1. 인스턴스 생성
vk::ApplicationInfo appInfo("VulkanVectorAddApp", 1, "NoEngine", 1, VK_API_VERSION_1_3);
std::vector<const char*> layers = { "VK_LAYER_KHRONOS_validation" };
std::vector<const char*> extensions = { "VK_EXT_debug_utils" };
vk::InstanceCreateInfo instanceInfo({}, &appInfo, (uint32_t)layers.size(), layers.data(), (uint32_t)extensions.size(), extensions.data());
vk::UniqueInstance instance = vk::createInstanceUnique(instanceInfo);
// 2. 물리 디바이스 선택 & 로지컬 디바이스, 큐 확보
auto physicalDevices = instance->enumeratePhysicalDevices();
if (physicalDevices.empty()) throw std::runtime_error("No GPU found!");
vk::PhysicalDevice chosenDevice = VK_NULL_HANDLE;
uint32_t computeQueueFamilyIndex = UINT32_MAX;
for (auto& pd : physicalDevices) {
auto qf = pd.getQueueFamilyProperties();
for (uint32_t i = 0; i < qf.size(); i++) {
if (qf[i].queueFlags & vk::QueueFlagBits::eCompute) {
chosenDevice = pd;
computeQueueFamilyIndex = i;
break;
}
}
if (chosenDevice) break;
}
if (!chosenDevice) throw std::runtime_error("No compute capable GPU!");
float queuePriority = 1.0f;
vk::DeviceQueueCreateInfo queueCI({}, computeQueueFamilyIndex, 1, &queuePriority);
vk::DeviceCreateInfo deviceCI({}, 1, &queueCI);
vk::UniqueDevice device = chosenDevice.createDeviceUnique(deviceCI);
vk::Queue computeQueue = device->getQueue(computeQueueFamilyIndex, 0);
vk::PhysicalDeviceMemoryProperties memProperties = chosenDevice.getMemoryProperties();
// 3. N개 벡터 준비
int N = 1024;
std::vector<float> A(N), B(N), C(N, 0.0f);
for (int i = 0; i < N; i++) {
A[i] = (float)i;
B[i] = (float)(N - i);
}
// 4. 버퍼 3개 생성(A,B,C) & 메모리 할당 & 업로드
auto createBuffer = [&](vk::DeviceSize size) {
vk::BufferCreateInfo bCI({}, size, vk::BufferUsageFlagBits::eStorageBuffer);
return device->createBufferUnique(bCI);
};
vk::UniqueBuffer A_buf = createBuffer(sizeof(float)*N);
vk::UniqueBuffer B_buf = createBuffer(sizeof(float)*N);
vk::UniqueBuffer C_buf = createBuffer(sizeof(float)*N);
auto A_req = device->getBufferMemoryRequirements(*A_buf);
uint32_t A_memType = findMemoryTypeIndex(memProperties, A_req.memoryTypeBits,
vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent);
// 동일 메모리 타입으로 A,B,C 할당 (단순화)
// 실전에서는 각각 따로 요구사항 확인해야 함
vk::UniqueDeviceMemory A_mem = device->allocateMemoryUnique({A_req.size, A_memType});
device->bindBufferMemory(*A_buf, *A_mem, 0);
auto B_req = device->getBufferMemoryRequirements(*B_buf);
vk::UniqueDeviceMemory B_mem = device->allocateMemoryUnique({B_req.size, A_memType});
device->bindBufferMemory(*B_buf, *B_mem, 0);
auto C_req = device->getBufferMemoryRequirements(*C_buf);
vk::UniqueDeviceMemory C_mem = device->allocateMemoryUnique({C_req.size, A_memType});
device->bindBufferMemory(*C_buf, *C_mem, 0);
// 업로드 (A,B)
{
void* ptr = device->mapMemory(*A_mem, 0, A_req.size);
std::memcpy(ptr, A.data(), A_req.size);
device->unmapMemory(*A_mem);
}
{
void* ptr = device->mapMemory(*B_mem, 0, B_req.size);
std::memcpy(ptr, B.data(), B_req.size);
device->unmapMemory(*B_mem);
}
// 5. Compute 파이프라인 & 디스크립터 셋 구성
std::vector<char> code = readFile("shaders/add.comp.spv");
vk::ShaderModuleCreateInfo smCI({}, code.size(), reinterpret_cast<const uint32_t*>(code.data()));
vk::UniqueShaderModule shaderModule = device->createShaderModuleUnique(smCI);
vk::DescriptorSetLayoutBinding bindings[] = {
{0, vk::DescriptorType::eStorageBuffer, 1, vk::ShaderStageFlagBits::eCompute},
{1, vk::DescriptorType::eStorageBuffer, 1, vk::ShaderStageFlagBits::eCompute},
{2, vk::DescriptorType::eStorageBuffer, 1, vk::ShaderStageFlagBits::eCompute},
};
vk::DescriptorSetLayoutCreateInfo dslCI({}, 3, bindings);
vk::UniqueDescriptorSetLayout dsl = device->createDescriptorSetLayoutUnique(dslCI);
vk::PipelineLayoutCreateInfo plCI({}, 1, &(*dsl));
vk::UniquePipelineLayout pipelineLayout = device->createPipelineLayoutUnique(plCI);
vk::PipelineShaderStageCreateInfo stageInfo({}, vk::ShaderStageFlagBits::eCompute, *shaderModule, "main");
vk::ComputePipelineCreateInfo cpCI({}, stageInfo, *pipelineLayout);
vk::UniquePipeline computePipeline = device->createComputePipelineUnique(nullptr, cpCI);
// 디스크립터 풀 & 셋 할당
vk::DescriptorPoolSize poolSize(vk::DescriptorType::eStorageBuffer, 3);
vk::DescriptorPoolCreateInfo dpCI({}, 1, 1, &poolSize);
vk::UniqueDescriptorPool descriptorPool = device->createDescriptorPoolUnique(dpCI);
vk::DescriptorSetAllocateInfo dsAI(*descriptorPool, 1, &(*dsl));
std::vector<vk::DescriptorSet> descriptorSets = device->allocateDescriptorSets(dsAI);
vk::DescriptorBufferInfo A_info(*A_buf, 0, VK_WHOLE_SIZE);
vk::DescriptorBufferInfo B_info(*B_buf, 0, VK_WHOLE_SIZE);
vk::DescriptorBufferInfo C_info(*C_buf, 0, VK_WHOLE_SIZE);
std::vector<vk::WriteDescriptorSet> writes = {
{descriptorSets[0], 0, 0, 1, vk::DescriptorType::eStorageBuffer, nullptr, &A_info},
{descriptorSets[0], 1, 0, 1, vk::DescriptorType::eStorageBuffer, nullptr, &B_info},
{descriptorSets[0], 2, 0, 1, vk::DescriptorType::eStorageBuffer, nullptr, &C_info}
};
device->updateDescriptorSets(writes, {});
// 6. 커맨드 풀 & 버퍼
vk::CommandPoolCreateInfo poolCI({}, computeQueueFamilyIndex);
vk::UniqueCommandPool commandPool = device->createCommandPoolUnique(poolCI);
vk::CommandBufferAllocateInfo cbAI(*commandPool, vk::CommandBufferLevel::ePrimary, 1);
std::vector<vk::CommandBuffer> cbs = device->allocateCommandBuffers(cbAI);
vk::CommandBuffer cb = cbs[0];
vk::CommandBufferBeginInfo beginInfo;
cb.begin(beginInfo);
cb.bindPipeline(vk::PipelineBindPoint::eCompute, *computePipeline);
cb.bindDescriptorSets(vk::PipelineBindPoint::eCompute, *pipelineLayout, 0, descriptorSets, {});
uint32_t groupCountX = (N + 255) / 256;
cb.dispatch(groupCountX, 1, 1);
cb.end();
// 7. 큐 제출 & 대기
vk::SubmitInfo submitInfo({}, {}, cb);
computeQueue.submit(submitInfo);
computeQueue.waitIdle();
// 8. 결과 회수
{
void* ptr = device->mapMemory(*C_mem, 0, C_req.size);
float* cData = reinterpret_cast<float*>(ptr);
std::cout << "C[0] = " << cData[0] << ", C[100] = " << cData[100] << "\n";
// 기대값: C[i] = A[i] + B[i] = i + (N - i) = N
std::cout << "Expected all C[i] = " << N << "\n";
device->unmapMemory(*C_mem);
}
std::cout << "Vector addition completed successfully with Vulkan-HPP!\n";
return 0;
}
CMakeLists.txt 예제
cmake_minimum_required(VERSION 3.10)
project(vulkan_hpp_vector_add)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Vulkan REQUIRED)
add_executable(vulkan_hpp_vector_add src/main.cpp)
target_include_directories(vulkan_hpp_vector_add PRIVATE ${Vulkan_INCLUDE_DIRS})
target_link_libraries(vulkan_hpp_vector_add Vulkan::Vulkan)
빌드 및 실행
mkdir build
cd build
cmake ..
make
./vulkan_hpp_vector_add
"C[0] = 1024, C[100] = 1024", "Expected all C[i] = 1024" 등의 출력과 "Vector addition completed successfully with Vulkan-HPP!"가 나오면 성공입니다.
주요 포인트
- 모든 단계(인스턴스
디바이스파이프라인커맨드버퍼메모리디스크립터커맨드 제출)를 Modern C++ 스타일로 정리 - RAII로 자원 정리 자동화, 예외 처리로 에러 핸들링 단순화
- STL 컨테이너와 Vulkan-HPP API 결합으로 코드 가독성 및 유지보수성 향상
- C 스타일 대비 코드 줄 수 감소, 명확한 타입, enum class, Unique 핸들로 실수 예방
정리 및 다음 글 예고
이번 글에서는 벡터 덧셈 예제를 Modern C++ 스타일로 완성하며, Vulkan-HPP와 RAII, 예외 처리가 결합된 깔끔한 GPGPU 파이프라인을 구축했습니다.
다음 글(#8)에서는 디버깅, Validation Layer, 프로파일링 툴 사용 시 Modern C++ 코드가 주는 이점에 대해 다시 살펴보고, RAII 기반 코드로 디버깅할 때 어떤 장점이 있는지, 성능 프로파일링 툴과 결합해 최적화를 진행하는 패턴을 소개하겠습니다.
'개발 이야기 > Vulkan' 카테고리의 다른 글
[모던 Vulkan (C++ 버전)] #9: 정리 및 다음 단계로의 길잡이 (0) | 2024.12.19 |
---|---|
[모던 Vulkan (C++ 버전)] #8: 디버깅, Validation Layer, 성능 프로파일링 재점검 (0) | 2024.12.19 |
[모던 Vulkan (C++ 버전)] #6: Compute 파이프라인 & 디스크립터 구성 (RAII 기반 Modern C++) (0) | 2024.12.19 |
[모던 Vulkan (C++ 버전)] #5: 메모리 관리 & 버퍼 생성 (RAII 기반 Modern C++) (0) | 2024.12.19 |
[모던 Vulkan (C++ 버전)] #4: 커맨드 버퍼, 커맨드 풀, 큐 제출 (RAII 기반) (0) | 2024.12.19 |