[모던 Vulkan (C++ 버전)] #7: 벡터 덧셈 예제 완성

모던 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 기반 코드로 디버깅할 때 어떤 장점이 있는지, 성능 프로파일링 툴과 결합해 최적화를 진행하는 패턴을 소개하겠습니다.

반응형