Multithreading with Vulkan

Vulkan 多线程编程

目录

[Vulkan 多线程编程](#Vulkan 多线程编程)

简介

概述

实现

线程安全的资源管理

工作线程实现

修改计算着色器

更新主循环

高级多线程技术

次级命令缓冲区

用于动态任务分配的线程池

异步资源加载

性能考量

[调试多线程 Vulkan 应用](#调试多线程 Vulkan 应用)

总结

关键点回顾

简介

在本章中,我们将探讨如何利用 Vulkan 的多线程特性提升应用程序性能。现代 CPU 拥有多个核心,高效利用这些核心能够显著增强应用程序的性能,尤其是对于计算密集型任务。Vulkan 的显式设计使其非常适合多线程架构,允许开发者对同步和资源访问进行细粒度控制。

概述

Vulkan 从设计之初就考虑了多线程支持,相比旧版图形 API 具备多项优势:

  • 线程安全的命令缓冲区录制:多个线程可同时向不同的命令缓冲区录制命令。
  • 显式同步机制:Vulkan 要求显式同步,让你能够精确控制跨线程的资源访问。
  • 基于队列的架构:不同操作可提交至不同队列,有望实现并行执行。

然而,在 Vulkan 中实现多线程需要重点考虑以下几点:

  • 资源共享:确保跨线程安全访问共享资源。
  • 同步机制:正确同步线程间的操作。
  • 任务分配:合理分配任务以最大化并行度。

本章中,我们将在之前计算着色器的基础上实现一个多线程渲染系统。我们会创建一个粒子系统,其中:

  • 一个线程处理窗口事件和画面呈现
  • 多个工作线程为不同粒子组录制命令缓冲区
  • 一个专用线程向 GPU 提交任务

实现

让我们逐步讲解在 Vulkan 应用中实现多线程所需的核心组件:

线程安全的资源管理

首先,我们需要确保资源能够被跨线程安全访问。我们将结合多种技术实现这一目标:

cpp 复制代码
// 线程安全的资源管理器
class ThreadSafeResourceManager {
private:
    std::mutex resourceMutex;
    // 需要线程安全访问的资源
    std::vector<vk::raii::CommandPool> commandPools;
    std::vector<vk::raii::CommandBuffer> commandBuffers;

public:
    // 为每个工作线程创建一个命令池
    void createThreadCommandPools(vk::raii::Device& device, uint32_t queueFamilyIndex, uint32_t threadCount) {
        std::lock_guard<std::mutex> lock(resourceMutex);

        commandPools.clear();
        for (uint32_t i = 0; i < threadCount; i++) {
            vk::CommandPoolCreateInfo poolInfo{
                .flags = vk::CommandPoolCreateFlagBits::eResetCommandBuffer,
                .queueFamilyIndex = queueFamilyIndex
            };
            commandPools.emplace_back(device, poolInfo);
        }
    }

    // 获取指定线程的命令池
    vk::raii::CommandPool& getCommandPool(uint32_t threadIndex) {
        std::lock_guard<std::mutex> lock(resourceMutex);
        return commandPools[threadIndex];
    }

    // 为每个线程分配命令缓冲区
    void allocateCommandBuffers(vk::raii::Device& device, uint32_t threadCount, uint32_t buffersPerThread) {
        std::lock_guard<std::mutex> lock(resourceMutex);

        commandBuffers.clear();
        for (uint32_t i = 0; i < threadCount; i++) {
            vk::CommandBufferAllocateInfo allocInfo{
                .commandPool = *commandPools[i],
                .level = vk::CommandBufferLevel::ePrimary,
                .commandBufferCount = buffersPerThread
            };
            auto threadBuffers = device.allocateCommandBuffers(allocInfo);
            for (auto& buffer : threadBuffers) {
                commandBuffers.emplace_back(std::move(buffer));
            }
        }
    }

    // 获取命令缓冲区
    vk::raii::CommandBuffer& getCommandBuffer(uint32_t index) {
        std::lock_guard<std::mutex> lock(resourceMutex);
        return commandBuffers[index];
    }
};

工作线程实现

接下来,我们实现工作线程,为不同粒子组录制命令缓冲区:

cpp 复制代码
    // 创建工作线程
    void createWorkerThreads(uint32_t count) {
        threadCount = count;
        threadWorkReady.resize(threadCount, false);
        threadWorkDone.resize(threadCount, false);

        // 创建工作线程
        for (uint32_t i = 0; i < threadCount; i++) {
            workerThreads.emplace_back(&MultithreadedApplication::workerThreadFunc, this, i);
        }
    }

    void workerThreadFunc(uint32_t threadIndex) {
        while (!shouldExit) {
            // 等待任务就绪
            if (!threadWorkReady[threadIndex]) {
                std::this_thread::yield();
                continue;
            }

            // 获取该线程对应的粒子组
            const ParticleGroup& group = particleGroups[threadIndex];

            // 获取该线程的命令缓冲区
            vk::raii::CommandBuffer& cmdBuffer = resourceManager.getCommandBuffer(threadIndex);

            // 为该粒子组录制命令
            recordComputeCommandBuffer(cmdBuffer, group.startIndex, group.count);

            // 标记任务完成
            threadWorkDone[threadIndex] = true;
            threadWorkReady[threadIndex] = false;

            // 通知主线程
            workCompleteCv.notify_one();
        }
    }

    void recordComputeCommandBuffer(vk::raii::CommandBuffer& cmdBuffer, uint32_t startIndex, uint32_t count) {
        cmdBuffer.reset();
        cmdBuffer.begin({});

        // 绑定计算管线和描述符集
        cmdBuffer.bindPipeline(vk::PipelineBindPoint::eCompute, *computePipeline);
        cmdBuffer.bindDescriptorSets(vk::PipelineBindPoint::eCompute, *computePipelineLayout, 0, {*computeDescriptorSets[frameIndex]}, {});

        // 添加推送常量以指定该线程处理的粒子范围
        struct PushConstants {
            uint32_t startIndex;
            uint32_t count;
        } pushConstants{startIndex, count};

        cmdBuffer.pushConstants<PushConstants>(*computePipelineLayout, vk::ShaderStageFlagBits::eCompute, 0, pushConstants);

        // 分发计算任务
        uint32_t groupCount = (count + 255) / 256;
        cmdBuffer.dispatch(groupCount, 1, 1);

        cmdBuffer.end();
    }

    void signalThreadsToWork() {
        // 通知所有线程开始工作
        for (uint32_t i = 0; i < threadCount; i++) {
            threadWorkDone[i] = false;
            threadWorkReady[i] = true;
        }
    }

    void waitForThreadsToComplete() {
        // 等待所有线程完成任务
        std::unique_lock<std::mutex> lock(queueSubmitMutex);
        workCompleteCv.wait(lock, [this]() {
            for (uint32_t i = 0; i < threadCount; i++) {
                if (!threadWorkDone[i]) {
                    return false;
                }
            }
            return true;
        });
    }

    void cleanup() {
        // 通知线程退出并等待它们结束
        shouldExit = true;
        for (auto& thread : workerThreads) {
            if (thread.joinable()) {
                thread.join();
            }
        }

        // ... 清理其他资源 ...
    }
};

修改计算着色器

我们需要修改计算着色器,使其能够处理由推送常量指定的粒子范围:

复制代码
// 在计算着色器中 (31_shader_compute.slang)
[[vk::push_constant]]
struct PushConstants {
    uint startIndex;
    uint count;
};

[[vk::binding(0, 0)]] ConstantBuffer<UniformBufferObject> ubo;
[[vk::binding(1, 0)]] RWStructuredBuffer<Particle> particlesIn;
[[vk::binding(2, 0)]] RWStructuredBuffer<Particle> particlesOut;
PushConstants pushConstants;

[numthreads(256,1,1)]
void compMain(uint3 threadId : SV_DispatchThreadID)
{
    uint index = threadId.x;

    // 仅处理分配给本线程范围内的粒子
    if (index >= pushConstants.count) {
        return;
    }

    // 将索引调整为从分配的起始索引开始
    uint globalIndex = pushConstants.startIndex + index;

    // 处理粒子
    Particle particle = particlesIn[globalIndex];

    // 根据速度和增量时间更新粒子位置
    particle.position += particle.velocity * ubo.deltaTime;

    // 简单的边界检测并反转速度
    if (abs(particle.position.x) > 1.0) {
        particle.velocity.x *= -1.0;
    }
    if (abs(particle.position.y) > 1.0) {
        particle.velocity.y *= -1.0;
    }

    // 将更新后的粒子写入输出缓冲区
    particlesOut[globalIndex] = particle;
}

更新主循环

最后,我们更新主循环以协调工作线程:

cpp 复制代码
void drawFrame() {
    // 等待上一帧完成
    auto fenceResult = device.waitForFences(*inFlightFences[frameIndex], vk::True, UINT64_MAX);
    if (fenceResult != vk::Result::eSuccess)
    {
        throw std::runtime_error("failed to wait for fence!");
    }

    // 获取下一帧图像
    auto [result, imageIndex] = swapChain.acquireNextImage(UINT64_MAX, *imageAvailableSemaphores[frameIndex], nullptr);

    if (result == vk::Result::eErrorOutOfDateKHR || result == vk::Result::eSuboptimalKHR || framebufferResized) {
        framebufferResized = false;
        recreateSwapChain();
        return;
    }

    // 更新统一缓冲区
    updateUniformBuffer(frameIndex);

    // 通知工作线程开始录制计算命令缓冲区
    signalThreadsToWork();

    // 当工作线程忙碌时,在主线程录制图形命令缓冲区
    recordGraphicsCommandBuffer(imageIndex);

    // 等待所有工作线程完成
    waitForThreadsToComplete();

    // 收集所有线程的命令缓冲区
    std::vector<vk::CommandBuffer> computeCmdBuffers;
    for (uint32_t i = 0; i < threadCount; i++) {
        computeCmdBuffers.push_back(*resourceManager.getCommandBuffer(i));
    }

    // 提交计算任务
    vk::SubmitInfo computeSubmitInfo{
        .commandBufferCount = static_cast<uint32_t>(computeCmdBuffers.size()),
        .pCommandBuffers = computeCmdBuffers.data()
    };

    {
        std::lock_guard<std::mutex> lock(queueSubmitMutex);
        computeQueue.submit(computeSubmitInfo, nullptr);
    }

    // 在执行图形任务前等待计算任务完成
    vk::PipelineStageFlags waitStages[] = {vk::PipelineStageFlagBits::eVertexInput};

    // 提交图形任务
    vk::SubmitInfo graphicsSubmitInfo{.waitSemaphoreCount   = 1,
                                      .pWaitSemaphores      = &*imageAvailableSemaphores[frameIndex],
                                      .pWaitDstStageMask    = &waitDestinationStageMask,
                                      .commandBufferCount   = 1,
                                      .pCommandBuffers      = &*graphicsCommandBuffers[frameIndex],
                                      .signalSemaphoreCount = 1,
                                      .pSignalSemaphores    = &*renderFinishedSemaphores[imageIndex]};

    {
        std::lock_guard<std::mutex> lock(queueSubmitMutex);
        device.resetFences(*inFlightFences[frameIndex]);
        graphicsQueue.submit(graphicsSubmitInfo, *inFlightFences[frameIndex]);
    }

    // 呈现图像
    vk::PresentInfoKHR presentInfo{
        .waitSemaphoreCount = 1,
        .pWaitSemaphores = &*renderFinishedSemaphores[frameIndex],
        .swapchainCount = 1,
        .pSwapchains = &*swapChain,
        .pImageIndices = &imageIndex
    };

    result = presentQueue.presentKHR(presentInfo);

    if (result == vk::Result::eErrorOutOfDateKHR || result == vk::Result::eSuboptimalKHR || framebufferResized) {
        framebufferResized = false;
        recreateSwapChain();
    } else if (result != vk::Result::eSuccess) {
        throw std::runtime_error("failed to present swap chain image!");
    }

    frameIndex = (frameIndex + 1) % MAX_FRAMES_IN_FLIGHT;
}

高级多线程技术

除了上述基础实现外,你还可以使用以下高级技术进一步优化 Vulkan 多线程应用:

次级命令缓冲区

次级命令缓冲区可并行录制,然后由主命令缓冲区执行:

cpp 复制代码
// 在工作线程中:
vk::CommandBufferInheritanceInfo inheritanceInfo{
    .renderPass = *renderPass,
    .subpass = 0,
    .framebuffer = *framebuffers[imageIndex]
};

vk::CommandBufferBeginInfo beginInfo{
    .flags = vk::CommandBufferUsageFlagBits::eRenderPassContinue,
    .pInheritanceInfo = &inheritanceInfo
};

secondaryCommandBuffer.begin(beginInfo);
// 录制渲染命令...
secondaryCommandBuffer.end();

// 在主线程中:
primaryCommandBuffer.begin({});
primaryCommandBuffer.beginRenderPass(...);
primaryCommandBuffer.executeCommands(secondaryCommandBuffers);
primaryCommandBuffer.endRenderPass();
primaryCommandBuffer.end();

用于动态任务分配的线程池

相比于为每个线程分配固定任务,你可以使用线程池动态分配任务:

cpp 复制代码
class ThreadPool {
private:
    std::vector<std::thread> workers;
    std::queue<std::function<void()>> tasks;
    std::mutex queueMutex;
    std::condition_variable condition;
    bool stop;

public:
    ThreadPool(size_t threads) : stop(false) {
        for (size_t i = 0; i < threads; ++i) {
            workers.emplace_back([this] {
                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lock(queueMutex);
                        condition.wait(lock, [this] { return stop || !tasks.empty(); });
                        if (stop && tasks.empty()) {
                            return;
                        }
                        task = std::move(tasks.front());
                        tasks.pop();
                    }
                    task();
                }
            });
        }
    }

    template<class F>
    void enqueue(F&& f) {
        {
            std::unique_lock<std::mutex> lock(queueMutex);
            tasks.emplace(std::forward<F>(f));
        }
        condition.notify_one();
    }

    ~ThreadPool() {
        {
            std::unique_lock<std::mutex> lock(queueMutex);
            stop = true;
        }
        condition.notify_all();
        for (std::thread& worker : workers) {
            worker.join();
        }
    }
};

异步资源加载

你可以使用多线程实现异步资源加载:

cpp 复制代码
std::future<TextureData> loadTextureAsync(const std::string& filename) {
    return std::async(std::launch::async, [filename]() {
        TextureData data;
        // 从文件加载纹理数据
        return data;
    });
}

// 在代码的后续部分:
auto textureDataFuture = loadTextureAsync("texture.ktx");
// 执行其他任务...
TextureData textureData = textureDataFuture.get(); // 必要时等待加载完成
// 从加载的数据创建 Vulkan 纹理

性能考量

在 Vulkan 中实现多线程时,请牢记以下性能考量点:

  • 线程创建开销:创建线程存在开销,因此应在启动时一次性创建,而非逐帧创建。
  • 任务粒度:确保每个线程有足够的任务量,以抵消线程调度的开销。
  • 伪共享:当多个线程访问相邻内存时,需注意缓存行竞争问题。
  • 队列提交:队列提交操作应进行同步,避免竞争条件。
  • 内存屏障:正确使用内存屏障,确保跨线程的内存操作可见性。
  • 每个线程一个命令池:每个线程应拥有独立的命令池,以避免同步开销。
  • 性能测量:务必进行性能测量,确保多线程实现确实提升了性能。

调试多线程 Vulkan 应用

调试多线程应用具有一定挑战性。以下是一些实用技巧:

  • 验证层:启用 Vulkan 验证层,捕捉同步问题。
  • 线程检查工具:使用 ThreadSanitizer 等工具检测数据竞争。
  • 日志记录:实现线程安全的日志系统,跟踪执行流程。
  • 简化实现:从简单的线程模型开始,逐步增加复杂度。
  • 原子操作:对线程安全的计数器和标志使用原子操作。

总结

在本章中,我们探讨了如何利用 Vulkan 的多线程特性提升性能。我们实现了一个多线程粒子系统,其中:

  • 多个工作线程并行录制命令缓冲区
  • 主线程协调任务并处理画面呈现
  • 通过适当的同步机制确保线程安全

通过将任务分配到多个 CPU 核心,我们能够显著提升性能,尤其是对于计算密集型应用。Vulkan 的显式设计使其非常适合多线程架构,允许开发者对同步和资源访问进行细粒度控制。

在后续的 Vulkan 应用开发中,你可以思考如何利用多线程充分发挥现代 CPU 的全部性能,并始终记住通过性能测量验证你的线程模型是否适用于特定的使用场景。

C++ code

相关推荐
mxwin几秒前
Unity URP 多线程渲染:理解 Shader 变体对加载时间的影响
unity·游戏引擎·shader
千里马-horse5 天前
Using Vulkan -- Atomics
vulkan
mxwin6 天前
Unity Shader 逐像素光照 vs 逐顶点光照性能与画质的权衡策略
unity·游戏引擎·shader·着色器
mxwin6 天前
Unity URP 全局光照 (GI) 完全指南 Lightmap 采样与实时 GI(光照探针、反射探针)的 Shader 集成
unity·游戏引擎·shader·着色器
mxwin6 天前
Unity URP 溶解效果基于噪声纹理与 clip 函数实现物体渐隐渐显
unity·游戏引擎·shader
mxwin6 天前
Unity Shader 顶点色:利用模型顶点颜色传递渲染数据
unity·游戏引擎·shader
mxwin6 天前
Unity URP 下的 GPU Instancing减少 DrawCall 的关键技术
unity·游戏引擎·shader
mxwin7 天前
Unity URP SRP Batcher 完全指南 URP/HDRP 下的核心批处理机制,大幅降低 CPU 开销
unity·游戏引擎·shader·单一职责原则
mxwin7 天前
Unity Shader UV 坐标与纹理平铺Tiling & Offset 深度解析
unity·游戏引擎·shader·uv
mxwin8 天前
Unity Shader Blinn-Phong vs PBR传统经验模型与现代物理基础渲染
unity·游戏引擎·shader