Vulkan Compute入門
はじめに
多くのVulkanチュートリアルはGraphics Pipelineに焦点を当てていますが、複雑なため挫折する確率も高いと思います。よって本記事では、比較的簡単に作成できるCompute Pipelineを取り上げ、なるべく最短(200行程度)でGPU計算を実行してみます。
本記事では以下の要素は取り扱いません。
- Graphics Pipeline
- Validation Layer
- Window & Swapchain
- 同期
対象
初心者向けです。ただ、コードをひとつひとつ解説するのは1記事に収まらないので諦めます。
ゴール
この記事のゴールは、簡単な配列の足し算です。
dataC[i] = dataA[i] + dataB[i];
環境
以下の環境でテストしています。明確なバージョン依存は特にありません。
- Vulkan SDK 1.3.204.1
- Windows 10
- Visual Studio 2022
- cmake
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
をインクルードディレクトリに追加
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として利用し、足し算を行っています。
#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++側を実装します。
#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側でも同じ足し算をして、データサイズを変えながら速度比較してみるのもいいかもしれません。
Discussion