跳过正文

Metatron Dev. VII: GPU

·2554 字·6 分钟
Graphics Rendering Metatron Metal Vulkan
目录

新版本决定添加GPU后端, 基于Metal做一套Metal/Vulkan RHI. 相比于CUDA/HIP, graphics API无法直接复用cpp代码, metatron最终实现了一套简易的上传机制, 代码可从cpp copy到slang, 稍作修改即可.

stl::buf
#

大量资源需要通过std::vectorstd::array来分配, 由于Metal/Vulkan都支持指针, 可以实现自定义的stl::buf用于动态分配, 上传时替换其中的指针即可.

stl::buf的数据结构如下, 其中handle用于存储底层GPU buffer对象的指针, 加速结构构建等操作需要直接获取该对象; idx为在全局分配池中的序号.

struct buf {
    mut<byte> ptr = nullptr;
    uptr handle = 0;
    u32 bytelen = 0;
    u32 idx = math::maxv<u32>;
};

全局分配池的实现为stl::stack, 其成员如下. bufs存储程序运行期间所有分配的stl::buf, 上传GPU时可直接从这里获取. deleters为分配时添加的析构函数. flag用于多线程加锁.

struct stack final: singleton<stack> {
    using deleter = std::function<void(mut<buf>)>;
    std::vector<mut<buf>> bufs;
    std::vector<deleter> deleters;
    std::atomic_flag flag;
}

为便于使用, 在metatron的全局命名空间添加了buf<T>, 可直接通过长度或std::span来构造.

template<typename T>
struct buf final: stl::buf {
    buf(usize size) noexcept;
    template<typename U>
    requires std::is_same_v<T, std::remove_const_t<U>>
    buf(std::span<U> range) noexcept: buf(range.size());
};

场景初始化过程会伴随着大量的移动构造/赋值, 这会导致原来存储在bufs中的指针失效, stl::stack实现了线程安全的swap来解决该问题.

auto swap(mut<buf> buf) noexcept -> void {
    if (buf->idx == math::maxv<u32>) return;
    while (flag.test_and_set(std::memory_order::acquire));
    if (bufs[buf->idx] != buf) bufs[buf->idx] = buf;
    flag.clear(std::memory_order::release);
}

stl::vector
#

类似于ECS, 全局资源都存储在对应的stl::vector<T>中, 特化的stl::vector<byte>用于实现实际的分配. 为实现高校的并发分配, stl::vector<byte>::spin()会一次性分配block_size个对象需要的空间, 序号小于已分配对象数量的线程可以并发构造, 当前可容纳的所有对象分配完成后再进行下一次分配与后续构造.

auto spin() noexcept -> std::tuple<mut<byte>, u32> {
    auto idx = length.fetch_add(1);
    auto block = idx / block_size;
    auto start = block * block_size;
    auto local_idx = idx % block_size;
    if (idx >= max_idx) stl::abort("vector overflow");

    while (fetched.load(std::memory_order::acquire) < start);
    if (local_idx == 0) {
        blocks.push_back(mut<byte>(std::malloc(bytelen * block_size)));
        pathes.resize(start + block_size);
        allocated.fetch_add(1, std::memory_order::release);
    } else while (allocated.load(std::memory_order::acquire) <= block);

    auto ptr = blocks[block] + local_idx * bytelen;
    return {ptr, idx};
}

特化的stl::vector<>用于存储全局所有正在使用的stl::vector<T>, 每个stl::vector<T>会存储在其中的序号, 由于下文提到的tag<T>的限制数量被限定为256.


template<>
struct vector<void> final: singleton<vector<void>> {
    auto constexpr static max_idx = 256;
    std::array<stl::vector<byte>, max_idx> storage;
};

对于多态类型, 也就是本项目中符合pro::facade的类型, 需要执行emplace_type来为派生类型分配需要, 运行时通过tag<T>中的序号还确定底层类型并执行需要的操作, 例如reinterpreter用于获取派生类对象并转为基类指针.

template<typename T>
requires poliable<F, T>
auto emplace_type() noexcept -> void {
    if (map.contains(typeid(T))) return;
    if (sid.size() >= max_idx) stl::abort("facade vector overflow");
    sid.push_back(vector<void>::instance().push<T>());
    map[typeid(T)] = sid.size() - 1;
    length.push_back(sizeof(T));
    reinterpreter.push_back([](view<byte> ptr) {
        return make_mut<F>(*(mut<T>)ptr);
    });
    if constexpr (F::copyability != pro::constraint_level::none)
        copier.push_back([](view<byte> ptr) {
            auto x = *(mut<T>)ptr; return make_obj<F, T>(std::move(x));
        });
}

所有通过stl::vector<T>管理的资源都可以通过tag<T>访问, 其本身只存储一个uint, 0-20位存储在vector中的偏移量/索引, 20-23存储当前多态类型的序号, 24-31存储当前对象数组在stl::vector<>::storage中的序号.


template<typename T>
struct tag final {
    using vec = stl::vector<T>;

    tag(): idx(math::maxv<u32>) {};
    tag(u32 idx): idx(idx) {}
};

Shader
#

glsl不便于cpp代码迁移. hlsl不支持指针, 两大最流行的shader language都被排除. 基于cpp17的MSL是理想选择, 但官方只支持转译到dxil, 无法支持指针, 社区未提供任何spirv转译方案. 若使用slang原生的MSL后端, 指针与光追等特性都不被支持. 因此最终选择slang编译到spirv, 再通过支持指针与光追的spirv-cross反编译到MSL.

在slang中GPU全局数据结构如下, 由于Metal无法支持多重指针, 即slang中的Ptr<Ptr<...Ptr<<T>>, 这里使用uptr类型, 使用时强制转为指针.

public struct Resources {
    public uptr vectors;
    public uptr volumes;
    [vk_binding(1, 1)] public RaytracingAccelerationStructure accel;
}
[vk_binding(0, 1)] public ParameterBlock<Resources> resources;

buf<T>直接强转为指针再访问元素.

public struct buf<T> {
    public uptr ptr = 0;
    public uptr handle = 0;
    public u32 bytelen = 0;
    public u32 idx = 0xffffffffu;
    public __subscript(u32 i) -> T { get { return Ptr<T>(ptr)[i]; } }
}

tag<T>由于MSL不支持多重指针, 这里需要先转为Ptr, 再转为Ptr.

public struct tag<T> {
    public u32 idx;
    public func get() -> Ptr<T> {
        let base = Ptr<uptr>(resources.vectors);
        let arr = Ptr<T>(base[storage()]);
        return arr + index();
    }
}

对于多态类型的tag<T>, slang本身直接使用基类对象, 但由于GPU不支持基于指针的动态分派, 基类大小为所有派生类之和, 对部分类型过于浪费了, 因此shader中针对每种多态实现各自的tag<T>, 通过分支选择调用的函数, 例如Spectrum_Tag.

public struct Spectrum_Tag: Spectrum {
    tag<byte> idx = {};

    public f32 operator()(f32 lambda) {
        if (idx.type() == 0) return tag<Constant_Spectrum>(idx).get()(lambda);
        else if (idx.type() == 1) return tag<Rgb_Spectrum>(idx).get()(lambda);
        else if (idx.type() == 2) return tag<Blackbody_Spectrum>(idx).get()(lambda);
        else if (idx.type() == 3) return tag<Visible_Spectrum>(idx).get()(lambda);
        else if (idx.type() == 4) return tag<Discrete_Spectrum>(idx).get()(lambda);
        else return {};
    }

    public func empty() -> bool { return idx.empty(); }
}

RHI
#

考虑到易用性, RHI本身基于Metal3实现, 即使Metal4与引入大量与D3D12对齐的特性后更方便实现RHI.

命令录制遵循Metal3的流程, 通过queue分配buffer, 使用encoder来更紧凑的录制. 为控制Vulkan下的多queue并行, 在Metal的基础上额外支持family transfer.

针对metatron需要的光追功能, Metal由于有原生的函数指针, 没有定义额外的ray tracing pipeline, 直接在render/compute pipeline中调用, 额外添加intersection function用于过程几何求交. spirv-cross尚未支持将ray tracing pipeline转译为visible functions, 因此最终基于ray query实现求交, 只支持compute shader.

对于command buffer间的同步, MTLEvent和timeline下的VkSemaphore具有相同的功能, 需要注意的是Metal调用eventWait时只保证后续录制的命令会acquire该event, 因此需要在录制前生命等待的timeline, 而Vulkan是在提交到queue时声明.

Metal不支持资源本身的状态同步, 但command encoder之间在untracked模式下是需要同步的, 因此每个command encoder在录制前与录制后会acquire/release MTLFence.