Vulkan Compute入門

57873 ワード

はじめに

多くのVulkanチュートリアルはGraphics Pipelineに焦点を当てていますが、複雑なため挫折する確率も高いと思います。よって本記事では、比較的簡単に作成できるCompute Pipelineを取り上げ、なるべく最短(200行程度)でGPU計算を実行してみます

本記事では以下の要素は取り扱いません。

  • Gprahics Pipeline
  • Validation Layer
  • Window & Swapchain
  • 同期

対象

初心者向けです。ただ、コードをひとつひとつ解説するのは1記事に収まらないので諦めます。

ゴール

この記事のゴールは、簡単な配列の足し算です。

dataC[i] = dataA[i] + dataB[i];

環境

以下の環境でテストしています。明確なバージョン依存は特にありません。

vulkan.hpp

この記事ではVulkanのC++ラッパーであるvulkan.hppを使います。Vulkanを書くのは面倒と言われることが多いですが、vulkan.hppを使うことでかなり簡潔に記述できます。ファイルはVulkan SDKに含まれているので追加作業はありません。

準備

プロジェクトフォルダ作成

ここではvulkan_computeというディレクトリに3つのファイルを追加しました。

/ vulkan_compute
  + main.cpp
  + CMakeLists.txt
  + compute.comp

CMakeLists.txt

本記事ではcmakeでVisual Studioソリューションを生成できるようにします。もちろん他の方法でも問題ありません。

  • C++ 20を使用
  • main.cppをプロジェクトに追加
  • $ENV{VULKAN_SDK}/Lib/vulkan-1.libをリンク
  • $ENV{VULKAN_SDK}/Includeをインクルードディレクトリに追加
CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(vulkan_compute LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)

add_executable(${PROJECT_NAME} main.cpp)

target_link_libraries(${PROJECT_NAME} 
    "$ENV{VULKAN_SDK}/Lib/vulkan-1.lib"
)

target_include_directories(${PROJECT_NAME} 
    PUBLIC "$ENV{VULKAN_SDK}/Include"
)

作成したらcmakeを走らせ、出力されたソリューションを開きます。

cd <your_path>/vulkan_compute
cmake . -Bbuild
.\build\vulkan_compute.sln

実装

Compute shader

先にCompute shaderを書いてしまいます。シェーダの起動IDを配列のIDとして利用し、足し算を行っています。

compute.comp
#version 460
layout(local_size_x = 1, local_size_y = 1) in;
layout(binding = 0) buffer Data {
    uint val[];
} data[3];

void main()
{
    int index = int(gl_GlobalInvocationID.x);
    data[2].val[index] = data[0].val[index] + data[1].val[index];
}

VulkanではGLSLではなくSPIR-Vという中間表現でシェーダを利用するため、コンパイルする必要があります。

Vulkan SDKに含まれているglslcを利用し、以下のコマンドでコンパイルしておきましょう。

%VULKAN_SDK%\bin\glslc.exe compute.comp -o compute.spv

C++

C++側を実装します。

main.cpp
#include <limits>
#include <fstream>
#include <iostream>
#include <vulkan/vulkan.hpp>

uint32_t findMemoryTypeIndex(vk::PhysicalDevice physicalDevice,
                             vk::MemoryRequirements requirements,
                             vk::MemoryPropertyFlags memoryProp)
{
    vk::PhysicalDeviceMemoryProperties memoryProperties = physicalDevice.getMemoryProperties();
    for (uint32_t index = 0; index < memoryProperties.memoryTypeCount; ++index) {
        auto propertyFlags = memoryProperties.memoryTypes[index].propertyFlags;
        bool match = (propertyFlags & memoryProp) == memoryProp;
        if (requirements.memoryTypeBits & (1 << index) && match) {
            return index;
        }
    }
    exit(EXIT_FAILURE);
}

uint32_t findQueueFamily(vk::PhysicalDevice physicalDevice, vk::QueueFlagBits queueBit)
{
    std::vector queueFamilyProperties = physicalDevice.getQueueFamilyProperties();
    for (uint32_t index = 0; index < queueFamilyProperties.size(); index++) {
        if (queueFamilyProperties[index].queueFlags & queueBit) {
            return index;
        }
    }
    exit(EXIT_FAILURE);
}

std::vector<char> readFile(const std::string& filename)
{
    std::ifstream file(filename, std::ios::ate | std::ios::binary);
    if (!file.is_open()) {
        throw std::runtime_error("failed to open file!");
    }

    size_t fileSize = file.tellg();
    std::vector<char> buffer(fileSize);
    file.seekg(0);
    file.read(buffer.data(), fileSize);
    file.close();
    return buffer;
}

template <typename T>
struct StorageBuffer
{
    StorageBuffer(vk::Device device, vk::PhysicalDevice physicalDevice, const std::vector<T>& data)
    {
        // Create buffer
        memorySize = sizeof(T) * data.size();
        vk::BufferCreateInfo bufferInfo{ {}, memorySize,  vk::BufferUsageFlagBits::eStorageBuffer };
        buffer = device.createBufferUnique(bufferInfo);

        // Allocate memory
        vk::MemoryRequirements requirements = device.getBufferMemoryRequirements(*buffer);
        uint32_t memoryTypeIndex = findMemoryTypeIndex(physicalDevice, requirements,
                                                       vk::MemoryPropertyFlagBits::eHostVisible |
                                                       vk::MemoryPropertyFlagBits::eHostCoherent);
        vk::MemoryAllocateInfo memoryInfo{ requirements.size, memoryTypeIndex };
        memory = device.allocateMemoryUnique(memoryInfo);

        // Bind buffer
        device.bindBufferMemory(*buffer, *memory, 0);

        // Copy
        mapped = reinterpret_cast<T*>(device.mapMemory(*memory, 0, memorySize));
        memcpy(reinterpret_cast<void*>(mapped), data.data(), memorySize);
    }

    void print()
    {
        for (int i = 0; i < 10; i++) {
            std::cout << mapped[i] << " ";
        }
        std::cout << std::endl;
    }

    T* mapped;
    uint64_t memorySize;
    vk::UniqueBuffer buffer;
    vk::UniqueDeviceMemory memory;
};

int main()
{
    // Create instance
    vk::InstanceCreateInfo instanceInfo{};
    vk::UniqueInstance instance = vk::createInstanceUnique(instanceInfo);

    // Pick first physical device
    vk::PhysicalDevice physicalDevice = instance->enumeratePhysicalDevices().front();

    // Find compute queue family
    uint32_t queueFamily = findQueueFamily(physicalDevice, vk::QueueFlagBits::eCompute);

    // Create device
    float queuePriority = 0.0f;
    vk::DeviceQueueCreateInfo queueInfo{ {}, queueFamily, 1, &queuePriority };
    vk::DeviceCreateInfo deviceInfo{ {}, queueInfo };
    vk::UniqueDevice device = physicalDevice.createDeviceUnique(deviceInfo);

    // Get queue
    vk::Queue queue = device->getQueue(queueFamily, 0);

    // Create command pool
    vk::CommandPoolCreateInfo commandPoolInfo{};
    commandPoolInfo.setFlags(vk::CommandPoolCreateFlagBits::eResetCommandBuffer);
    commandPoolInfo.setQueueFamilyIndex(queueFamily);
    vk::UniqueCommandPool commandPool = device->createCommandPoolUnique(commandPoolInfo);

    // Create data
    constexpr uint32_t dataSize = 1'000'000;
    std::vector<uint32_t> dataA;
    std::vector<uint32_t> dataB;
    std::vector<uint32_t> dataC;
    dataA.resize(dataSize);
    dataB.resize(dataSize);
    dataC.resize(dataSize);
    std::fill(dataA.begin(), dataA.end(), 3);
    std::fill(dataB.begin(), dataB.end(), 5);
    std::fill(dataC.begin(), dataC.end(), 0);

    // Create buffer
    StorageBuffer<uint32_t> bufferA{ *device, physicalDevice, dataA };
    StorageBuffer<uint32_t> bufferB{ *device, physicalDevice, dataB };
    StorageBuffer<uint32_t> bufferC{ *device, physicalDevice, dataC };

    // Create descriptor pool
    vk::DescriptorPoolSize poolSize{ vk::DescriptorType::eStorageBuffer, 10 };
    vk::DescriptorPoolCreateInfo descPoolInfo{};
    descPoolInfo.setFlags(vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet);
    descPoolInfo.setPoolSizes(poolSize);
    descPoolInfo.setMaxSets(1);
    vk::UniqueDescriptorPool descPool = device->createDescriptorPoolUnique(descPoolInfo);

    // Create shader module
    std::vector<char> shaderCode = readFile("../compute.spv");
    vk::ShaderModuleCreateInfo shaderModuleInfo{};
    shaderModuleInfo.setCodeSize(shaderCode.size());
    shaderModuleInfo.setPCode(reinterpret_cast<const uint32_t*>(shaderCode.data()));
    vk::UniqueShaderModule shaderModule = device->createShaderModuleUnique(shaderModuleInfo);

    // Create descriptor set layout
    vk::DescriptorSetLayoutBinding binding{ 0, vk::DescriptorType::eStorageBuffer, 3, vk::ShaderStageFlagBits::eCompute };
    vk::DescriptorSetLayoutCreateInfo descSetLayoutInfo{ {}, binding };
    vk::UniqueDescriptorSetLayout descSetLayout = device->createDescriptorSetLayoutUnique(descSetLayoutInfo);

    // Create pipeline layout
    vk::PipelineLayoutCreateInfo pipelineLayoutInfo{ {}, *descSetLayout };
    vk::UniquePipelineLayout pipelineLayout = device->createPipelineLayoutUnique(pipelineLayoutInfo);

    // Create compute pipeline
    vk::PipelineShaderStageCreateInfo shaderStageInfo{};
    shaderStageInfo.setStage(vk::ShaderStageFlagBits::eCompute);
    shaderStageInfo.setModule(*shaderModule);
    shaderStageInfo.setPName("main");

    vk::ComputePipelineCreateInfo computePipelineInfo{ {}, shaderStageInfo, *pipelineLayout };
    std::vector pipelines = device->createComputePipelinesUnique({}, computePipelineInfo).value;

    // Allocate desc set
    vk::DescriptorSetAllocateInfo descSetInfo{ *descPool, *descSetLayout };
    std::vector descSets = device->allocateDescriptorSetsUnique(descSetInfo);

    // Update desc set
    std::vector<vk::DescriptorBufferInfo> descBufferInfos{
        { *bufferA.buffer, 0, bufferA.memorySize },
        { *bufferB.buffer, 0, bufferB.memorySize },
        { *bufferC.buffer, 0, bufferC.memorySize },
    };
    vk::WriteDescriptorSet writeDescSet;
    writeDescSet.setDstSet(*descSets[0]);
    writeDescSet.setDstBinding(0);
    writeDescSet.setDescriptorType(vk::DescriptorType::eStorageBuffer);
    writeDescSet.setDescriptorCount(3);
    writeDescSet.setBufferInfo(descBufferInfos);
    device->updateDescriptorSets(writeDescSet, nullptr);

    // Create command buffer
    vk::CommandBufferAllocateInfo commandBufferInfo{};
    commandBufferInfo.setCommandPool(*commandPool);
    commandBufferInfo.setLevel(vk::CommandBufferLevel::ePrimary);
    commandBufferInfo.setCommandBufferCount(1);
    std::vector commandBuffers = device->allocateCommandBuffersUnique(commandBufferInfo);

    // Record command buffer
    commandBuffers[0]->begin(vk::CommandBufferBeginInfo{});
    commandBuffers[0]->bindPipeline(vk::PipelineBindPoint::eCompute, *pipelines[0]);
    commandBuffers[0]->bindDescriptorSets(vk::PipelineBindPoint::eCompute, *pipelineLayout, 0, *descSets[0], nullptr);
    commandBuffers[0]->dispatch(dataSize, 1, 1);
    commandBuffers[0]->end();

    // Submit and wait
    vk::SubmitInfo submitInfo{ {}, {}, *commandBuffers[0] };
    queue.submit(submitInfo);
    queue.waitIdle();

    std::cout << "result: " << std::endl;
    bufferA.print();
    bufferB.print();
    bufferC.print();
}

bufferAは3、bufferBは5、bufferCは0で初期化しており、

std::fill(dataA.begin(), dataA.end(), 3);
std::fill(dataB.begin(), dataB.end(), 5);
std::fill(dataC.begin(), dataC.end(), 0);

出力結果ではbufferCが8となっているはずです。

result:
3 3 3 3 3 3 3 3 3 3
5 5 5 5 5 5 5 5 5 5
8 8 8 8 8 8 8 8 8 8

おわり

最短で動かすことが目標だったので退屈な結果ですが、実際にGPUを動かして結果を見てみるのは重要なことだと思います。

CPU側でも同じ足し算をして、データサイズを変えながら速度比較してみるのもいいかもしれません。