C# 也能写 Vulkan GPU 推理:从 BEN2 一键抠图到可复用的 ONNX 部署基础工程

目录

一、为什么做这个项目?

[二、Vulkan 是什么,为什么它可以跑神经网络?](#二、Vulkan 是什么,为什么它可以跑神经网络?)

[2.1 Vulkan 是显式 GPU API](#2.1 Vulkan 是显式 GPU API)

[2.2 先认识这几个对象](#2.2 先认识这几个对象)

[2.3 Compute Shader 怎样并行?](#2.3 Compute Shader 怎样并行?)

[2.4 GLSL、SPIR-V 和 Vulkan SDK 的关系](#2.4 GLSL、SPIR-V 和 Vulkan SDK 的关系)

[三、从 ONNX 到 Vulkan:先导出计划,再执行](#三、从 ONNX 到 Vulkan:先导出计划,再执行)

[3.1 四个计划文件分别做什么?](#3.1 四个计划文件分别做什么?)

[3.2 通用执行器,配上模型适配层](#3.2 通用执行器,配上模型适配层)

[四、C# 怎么调用 Vulkan?](# 怎么调用 Vulkan?)

[4.1 业务侧最小调用](#4.1 业务侧最小调用)

[4.2 C# 到 C++ 的桥梁](# 到 C++ 的桥梁)

[五、一次完整 Vulkan 推理,底层到底发生了什么?](#五、一次完整 Vulkan 推理,底层到底发生了什么?)

[5.1 初始化:选设备、分配资源、准备计算](#5.1 初始化:选设备、分配资源、准备计算)

[5.2 CPU 预处理:图片变成模型张量](#5.2 CPU 预处理:图片变成模型张量)

[5.3 上传:从 CPU 内存到 GPU 工作区](#5.3 上传:从 CPU 内存到 GPU 工作区)

[5.4 图计算:绑定资源、设置参数、dispatch](#5.4 图计算:绑定资源、设置参数、dispatch)

[5.5 为什么还要内存屏障?](#5.5 为什么还要内存屏障?)

[5.6 提交、等待与回读](#5.6 提交、等待与回读)

[5.7 CPU 后处理:掩膜变成透明 PNG](#5.7 CPU 后处理:掩膜变成透明 PNG)

[六、完整 C# 示例:输入图片,输出透明 PNG](# 示例:输入图片,输出透明 PNG)

[七、实测:速度、显存和 GPU 利用率](#七、实测:速度、显存和 GPU 利用率)

[7.1 测试条件与计时范围](#7.1 测试条件与计时范围)

[7.2 同轮结果](#7.2 同轮结果)

[7.3 输出差异](#7.3 输出差异)

[八、为什么能把 Vulkan 做快?](#八、为什么能把 Vulkan 做快?)

[8.1 优化图,而不只是某一个算子](#8.1 优化图,而不只是某一个算子)

[8.2 根据形状选择分块](#8.2 根据形状选择分块)

[8.3 让中间张量共享工作区](#8.3 让中间张量共享工作区)

[8.4 第四轮:分块 FP32 Winograd](#8.4 第四轮:分块 FP32 Winograd)

九、拿到工程后怎样直接运行?

[十、换一个 ONNX 模型,哪些部分可以复用?](#十、换一个 ONNX 模型,哪些部分可以复用?)

可以直接复用的部分

通常需要修改的部分

十一、配套资料与分享


用一个真实项目,讲清 Vulkan 是什么、C# 如何调用它,以及一张图片怎样经过预处理、GPU 计算和后处理,最终变成透明 PNG。

本文提供 .NET Framework 4.8 WinForms 示例与完整 VS2022 解决方案。第四轮优化在 RTX 4060 Laptop GPU 上,BEN2 Vulkan FP32 平均推理为 449.3 ms ,本进程专用显存峰值约 2.19 GiB。以下同时给出 OnnxRuntime GPU 对照和测量条件。

一、为什么做这个项目?

在 C# 项目中部署 ONNX 模型,OnnxRuntime 是很常见的选择:加载模型、构造输入张量、执行推理,再把输出接回业务界面。

我们最初做的是 APISR 动漫超分辨率部署,随后把模型解析、计划生成、C# 绑定和 Vulkan 执行器提炼为基础工程,再复制一份接入 BEN2 前景分割。

本文分享的是这条完整链路:保留熟悉的 C# / WinForms 开发方式,让模型图计算由自己可修改的 Vulkan 执行器完成。

项目中有三个核心部分:

部分 负责的工作
C# 图像适配层 图片读取、缩放、RGB/NCHW 转换、输出掩膜与透明 PNG
C# 通用会话层 读取计划、按名称绑定张量、调用原生 DLL、管理会话生命周期
C++ Vulkan 执行器 管理 GPU、缓冲区、计算管线、命令提交、同步与结果回读

BEN2 界面还保留了 OnnxRuntime CPU、CUDA GPU 的对照模式,便于在同一套预处理和后处理下观察速度、显存和输出差异。

这套工程的价值既在于 BEN2 能跑起来,也在于遇到性能热点时,可以沿着"模型图 → 算子 → shader → GPU 调度"逐层分析和修改。

二、Vulkan 是什么,为什么它可以跑神经网络?

2.1 Vulkan 是显式 GPU API

Vulkan 是 Khronos 制定的跨平台图形与计算 API。开发者可以显式管理 GPU 资源、命令和同步,对硬件执行过程有更直接的控制;相应地,也要承担更多资源与同步管理工作。参见 Khronos:What is Vulkan。

本项目使用它的 Compute Shader(计算着色器) 能力。在我们的执行路径中,GPU 处理的是张量计算,不需要创建渲染窗口、交换链或绘制三角形。

卷积、矩阵乘法、归一化、激活和上采样,都可以拆解成大量并行计算。我们为这些计算编写 shader,让 Vulkan 按模型执行顺序调度它们。

这里要区分两层能力:

  • Vulkan 提供 GPU 执行机制。
  • 本项目提供 ONNX 分析、执行计划、算子实现和图调度。

Vulkan 本身不会读取 ONNX,也不会自动决定怎样实现 Conv、Softmax 或 LayerNorm。支持一个模型,需要把模型里的计算映射到执行器已有的算子;缺失算子则需要补充实现。

2.2 先认识这几个对象

下面按本项目使用方式理解常见 Vulkan 对象:

对象 在推理工程中的作用
VkInstance 建立 Vulkan 环境,开始枚举设备
VkPhysicalDevice 代表真实 GPU,查询设备能力和限制
VkDevice 应用使用的逻辑设备,创建计算资源
VkQueue 接收提交的 GPU 工作
VkBuffer / VkDeviceMemory 保存输入、权重、中间张量和输出
VkShaderModule 装载编译后的 SPIR-V shader
VkPipeline 创建可执行的计算管线
VkDescriptorSet 把 shader 的资源槽位绑定到实际缓冲区
VkCommandBuffer 记录上传、计算、屏障和回读命令
VkFence 让 CPU 等待本次提交完成

这些对象不是每张图片都重新创建。本工程把创建资源、上传权重和录制命令放在会话初始化阶段,处理后续图片时重复使用。

2.3 Compute Shader 怎样并行?

假设用一个教学示例计算 C[i] = A[i] + B[i],每个 GPU 调用负责一个元素:

复制代码
#version 450
layout(local_size_x = 128) in;

layout(set = 0, binding = 0, std430) readonly buffer InputA {
    float a[];
};
layout(set = 0, binding = 1, std430) readonly buffer InputB {
    float b[];
};
layout(set = 0, binding = 2, std430) writeonly buffer OutputC {
    float c[];
};
layout(push_constant) uniform Params {
    uint count;
} params;

void main() {
    uint i = gl_GlobalInvocationID.x;
    if (i < params.count) {
        c[i] = a[i] + b[i];
    }
}

这是理解并行执行的独立例子,不是 BEN2 的实际内核。std430 声明 shader 缓冲区的数据布局,具体对齐规则可参考 Khronos:Shader Memory Layout。

如果需要处理 N 个元素,CPU 可以录制 ceil(N / 128) 个工作组,再通过 vkCmdDispatch 发起计算。vkCmdDispatch 的参数是工作组数量 ,不是元素数量;实际工程还要检查设备的工作组限制。该命令先记录到命令缓冲,提交到队列后才执行。参见 Vulkan 规范:Dispatching Commands。

卷积和矩阵乘法会进一步做分块、共享内存复用和归约,不能简单地认为"每个输出安排一个线程"就一定足够快。

2.4 GLSL、SPIR-V 和 Vulkan SDK 的关系

开发时编写 .comp shader,通过 glslc 编译成 SPIR-V,再由原生执行器装载 shader 并创建计算管线。

本项目将已编译的 SPIR-V 冻结在源码头文件中。因此,普通 VS2022 编译和运行不需要完整 Vulkan SDK;修改 shader、重新生成冻结代码时才需要 shader 编译工具。

运行时仍需要显卡驱动提供 Vulkan 支持。当前执行器要求 Vulkan 1.1,并检查计算队列、缓冲区范围、工作组和相关设备能力。

Vulkan 的 API 具有跨平台定位;本文交付工程与性能数据验证的是 Windows x64 / NVIDIA RTX 4060 Laptop GPU,其他系统和显卡需要对应的构建适配与实测。

三、从 ONNX 到 Vulkan:先导出计划,再执行

我们没有让生产版 .NET Framework 4.8 程序在每次推理时解析整份 ONNX。

开发阶段的工作流程是:

复制代码
ONNX 模型
  → 分析算子、输入输出和形状
  → 处理常量与固定形状支路
  → 图融合、布局选择和内存规划
  → 为计算节点选择 shader 与 dispatch 参数
  → 导出 Vulkan 执行计划

运行阶段读取计划,执行已经安排好的 GPU 计算。

3.1 四个计划文件分别做什么?

文件 内容
plan.json 输入输出名称与形状、工作区大小、dispatch 列表、版本等信息
weights.bin FP32 权重,包含优化导出产生的必要权重变换数据
metadata.bin 算子参数、张量地址等二进制元数据
shape-constants.bin 计划需要的形状与辅助常量数据

当前第四轮版本是 原生 ABI 5 / 计划 schema 2 / kernel_revision 5。C# 绑定、原生 DLL、执行计划和权重应整套使用。第三轮包对应不同的内核版本与性能,不能直接套用本文的第四轮计时结果。

在开发工具目录中,可以这样重新导出 BEN2 计划:

复制代码
# 在 BEN2.Vulkan.Net48 目录执行;这些是开发阶段操作。
python tools/convert_ben2.py "Original/Onnx Demo/model/BEN2_Base.onnx" Models/BEN2_FP32.onnx

cd tools/Compiler
dotnet build App/Compiler.csproj -c Release
dotnet App/bin/Release/net8.0/Compiler.dll ../../Models/BEN2_FP32.onnx ../../Models/Vulkan

转换需要 Python 的 onnx、onnxsim 和 numpy,导出工具需要 .NET 8 SDK。生产 WinForms 仍是 .NET Framework 4.8;客户使用随包附带的计划,不必安装这些开发工具。

原 BEN2 模型内部包含 FP16 数据。当前 Vulkan 路径使用 FP32 算子,开发阶段会进行类型与图转换。因此,和原模型执行相比,计算精度与浮点运算顺序存在区别。

3.2 通用执行器,配上模型适配层

通用库接收的是命名张量,不关心图片来自相机、文件夹还是视频帧,也不负责判断 BEN2 的输出如何变成 alpha。

不同模型分别提供自己的适配逻辑:

  • BEN2:RGB /255、1024×1024 NCHW 输入,输出恢复为原图尺寸的透明掩膜。
  • APISR:按对应模型的输入、放大倍数和输出约定处理图像。
  • 新模型:分析算子与形状后,提供新的输入构造和输出解释。

此前参考了 SimdPaddleOCR 的模型解析与算子实现思路,并复用了已有 Vulkan 项目的基础设施。本工程则沿着模型解析、计划生成和 Vulkan GPU 执行继续扩展,形成可以复制修改的基础项目。

四、C# 怎么调用 Vulkan?

4.1 业务侧最小调用

添加 Lw.Vulkan.Net48 项目引用,把原生 lw_onnx_vulkan.dll 放到 EXE 目录,再指定计划目录:

复制代码
using System;
using System.Collections.Generic;
using System.IO;
using Lw.Vulkan.Net48;

string planDirectory = Path.Combine(
    AppDomain.CurrentDomain.BaseDirectory, "Models", "Vulkan");

using (var session = new VulkanPlanSession(planDirectory))
{
    Console.WriteLine(session.DeviceDescription);

    // 这里的 input 必须已经按 BEN2 的要求填充。
    float[] input = new float[3 * 1024 * 1024];

    Dictionary<string, float[]> outputs = session.Run(
        new Dictionary<string, float[]> { { "input.1", input } });

    float[] mask = outputs["17728"];
}

这段展示 API 用法。空数组并不代表正确的图片输入,下面会给出完整预处理示例。

此 BEN2 计划的输入为 input.1,形状 [1,3,1024,1024];输出为 17728,形状 [1,1,1024,1024]。输入输出名称和长度必须匹配计划,接入其他模型时应查看 session.Inputs、session.Outputs,不要沿用 BEN2 的名称。

4.2 C# 到 C++ 的桥梁

业务层调用 VulkanPlanSession.Run(),绑定层内部通过 P/Invoke 进入 C ABI:

复制代码
[DllImport("lw_onnx_vulkan.dll",
    CallingConvention = CallingConvention.Cdecl,
    EntryPoint = "lw_vk_run")]
internal static extern int Run(
    IntPtr handle,
    float[] input,
    ulong count,
    [Out] float[] output,
    ulong capacity);

原生层的对应声明是:

复制代码
int lw_vk_run(
    void* session,
    const float* packed_inputs,
    uint64_t input_count,
    float* packed_outputs,
    uint64_t output_capacity);

count 和 capacity 的单位是 float 元素数。原生代码需要字节数时再乘以 4。将元素数误当字节数,会导致数据范围错误。

完整生命周期由这些接口组成:

接口 作用
lw_vk_api_version 检查 ABI 版本
lw_vk_create 初始化 GPU 环境并上传基础权重
lw_vk_prepare 读取计划参数、建立资源绑定和录制命令
lw_vk_run 同步完成输入上传、图执行和输出回读
lw_vk_info 获取 GPU 与会话描述
lw_vk_error 获取错误信息
lw_vk_destroy 释放会话与原生资源

多输入输出由绑定层按计划顺序打包,返回时再拆回名称到数组的字典。原生层不长期保留托管数组地址。

当前 .NET 4.8 会话用锁串行处理 Run。它不是多请求并发调度器;批量处理时应先复用一个会话,逐张执行,避免每张图片重新上传权重和创建管线。

五、一次完整 Vulkan 推理,底层到底发生了什么?

一次完整的 Vulkan 推理流程

5.1 初始化:选设备、分配资源、准备计算

创建 VulkanPlanSession 时,C# 读取计划文件,检查 ABI 和 schema,再通过原生接口创建会话。

原生执行器依次完成:

  1. 创建 Vulkan Instance,枚举并选择 GPU。
  2. 创建逻辑设备和计算队列。
  3. 分配权重缓冲,上传权重。
  4. 根据计划分配中间张量工作区、元数据和辅助常量缓冲。
  5. 创建可由 CPU 访问的上传与回读缓冲。
  6. 为所需 shader 创建或复用计算管线,建立描述符绑定。
  7. 录制本计划的命令缓冲,创建提交完成所需的 Fence。

默认设备选择当前优先 NVIDIA 独立显卡,其次其他独立显卡,再选择其他硬件 GPU。构造函数也允许传入 Vulkan 枚举得到的设备序号;该序号不能直接等同于 CUDA 设备编号。

当前计划在准备阶段录制计算命令。后续图片的输入值变化时,保留相同资源与命令,更新上传缓冲后再次提交。

5.2 CPU 预处理:图片变成模型张量

BEN2 输入适配遵循原工程:

复制代码
读取原图(OpenCV BGR)
  → BGR 转 RGB
  → 双线性缩放到 1024×1024
  → 像素 /255 转 float
  → HWC 排列转 NCHW

单张输入需要 3×1024×1024 个 float,即约 12 MiB。

NCHW 下,数组先保存全部 R 通道,再保存 G 通道,最后保存 B 通道。同样的像素值,如果通道顺序或布局不同,模型也会得到不同输入。

界面可以接收不同大小的图片,但 BEN2 网络输入仍固定为 1024×1024。 输入时缩放,输出时把掩膜恢复到原尺寸,没有把此模型改成任意动态形状。

5.3 上传:从 CPU 内存到 GPU 工作区

Run() 先将命名输入复制到托管打包数组,再由原生层写入持久映射的上传缓冲。

命令缓冲中已经记录了 vkCmdCopyBuffer,执行时把输入从上传缓冲复制到图计算的工作区切片。独立 GPU 常用 staging buffer 完成这种传输;相关内存与传输机制可参考 Khronos:Memory Allocation。

本工程对非 HOST_COHERENT 内存做显式 flush;回读时则在 GPU 完成后执行必要的 invalidate。持久映射减少了反复映射的管理工作,但不意味着 CPU 与 GPU 缓存自动一致。

5.4 图计算:绑定资源、设置参数、dispatch

当前执行器每个计算任务使用五个 storage-buffer 绑定:

binding 绑定资源
0 第一个中间张量工作区
1 权重缓冲
2 算子元数据
3 第二个中间张量工作区,支持工作区分段
4 形状与辅助常量缓冲

张量通过缓冲内偏移寻址。算子输出不必在每层结束后回传 CPU,而是留在 GPU,供后续算子使用。

录制计算任务的主要 API 顺序是:

复制代码
vkCmdBindPipeline
  → vkCmdBindDescriptorSets
  → vkCmdPushConstants
  → vkCmdDispatch
  → vkCmdPipelineBarrier

计算管线决定使用哪个 shader,描述符指定实际资源,push constants 传入小规模参数,dispatch 指定工作组数量。

第四轮 BEN2 计划包含 1,335 个 dispatch。这不是 1,335 次 CPU 等待,也不等于 1,335 个原始 ONNX 节点:融合可能合并节点,Winograd 的变换和分块又可能把一个卷积拆成多个任务。

普通推理中,这些任务和输入输出复制一起录在一个命令缓冲里,随后通过一次图提交执行。

5.5 为什么还要内存屏障?

前一层写入中间张量,后一层读取同一张量,需要正确的执行与内存依赖。GPU 并行执行不能简单套用 CPU 的逐行执行直觉。Vulkan 把同步管理交给应用;屏障可以控制相关阶段的依赖关系。参见 Khronos:Synchronization。

当前执行器明确安排了:

复制代码
输入复制:Transfer → Compute
算子衔接:Compute → Compute
输出复制:Compute → Transfer
CPU 回读:Transfer → Host

这些屏障负责保证数据在下一阶段可用。当前图内同步安排比较保守,未来若优化屏障,也必须先分析真实依赖和内存复用关系。

5.6 提交、等待与回读

本项目一次 Run() 的核心原生流程可概括为下面的伪代码,省略了具体参数与错误处理细节:

复制代码
upload->write(input, input_count * sizeof(float));

vkResetFences(device, 1, &fence);
vkQueueSubmit(queue, 1, &submit_info, fence);

VkResult status = vkWaitForFences(device, 1, &fence, VK_TRUE, timeout);
check(status);  // 实际实现还处理失败后的会话状态。

readback->read(output, output_count * sizeof(float));

vkQueueSubmit 将已录制的工作提交给队列;CPU 使用 Fence 等待完成,再读取输出。参见 vkQueueSubmit 和 vkWaitForFences。

因此,C# 的 Run() 是同步接口。它返回时,输出 float 数组已经可用。只计时命令提交函数,会漏掉后面的 GPU 执行和等待,不能当作完整推理耗时。

5.7 CPU 后处理:掩膜变成透明 PNG

输出 [1,1,1024,1024] 是单通道浮点图。BEN2 适配层继续完成:

  1. 检查 NaN / Infinity。
  2. 将输出掩膜双线性恢复到原图大小。
  3. 按原工程顺序进行 Min-Max 归一化。
  4. 转成 0~255 的 alpha 通道。
  5. 将原图转为 BGRA,把 alpha 写入第四通道。
  6. 编码并保存透明 PNG。

当前图片预处理和后处理由 CPU / OpenCV 完成;模型图计算在 Vulkan GPU 上执行。不能把"GPU 推理"理解为图片读写、缩放和 PNG 编码也全部在 GPU 上完成。

六、完整 C# 示例:输入图片,输出透明 PNG

下面是可编译的 Console 示例,使用 .NET Framework 4.8 / C# 7.3 / x64,适用于随项目提供的 BEN2 计划。

在 VS2022 中新建对应的控制台项目,引用 Lw.Vulkan.Net48 项目和随包的 OpenCvSharp.dll。最方便的首次体验仍是直接运行随包 WinForms 工程,它已经配置好原生依赖和计划文件复制。

复制代码
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using Lw.Vulkan.Net48;
using OpenCvSharp;

// Console 示例:.NET Framework 4.8 / C# 7.3 / x64。
// 引用 Lw.Vulkan.Net48 和 OpenCvSharp;原生 DLL 放在 EXE 目录。
internal static class Ben2VulkanExample
{
    private const int Side = 1024;
    private const int Plane = Side * Side;

    private static int Main(string[] args)
    {
        if (args.Length != 3)
        {
            Console.WriteLine("Usage: Ben2VulkanExample.exe <image> <plan-directory> <output.png>");
            return 1;
        }

        string imagePath = Path.GetFullPath(args[0]);
        string planDirectory = Path.GetFullPath(args[1]);
        string outputPath = Path.GetFullPath(args[2]);
        var total = Stopwatch.StartNew();
        var watch = Stopwatch.StartNew();

        // 批量处理时把会话移到图片循环外,只创建一次。
        using (var session = new VulkanPlanSession(planDirectory))
        {
            double loadMs = watch.Elapsed.TotalMilliseconds;
            Console.WriteLine(session.DeviceDescription);

            // 通用会话按计划名称绑定。下面的图像适配只适用于此 BEN2 计划。
            if (session.Inputs.Count != 1 || session.Outputs.Count != 1 ||
                session.Inputs[0].Name != "input.1" ||
                !session.Inputs[0].Shape.SequenceEqual(new[] { 1, 3, Side, Side }) ||
                session.Outputs[0].Name != "17728" ||
                !session.Outputs[0].Shape.SequenceEqual(new[] { 1, 1, Side, Side }))
            {
                throw new InvalidDataException("This example requires the supplied BEN2 1024x1024 plan.");
            }

            watch.Restart();
            using (var original = Cv2.ImRead(imagePath))
            using (var rgb = new Mat())
            using (var resized = new Mat())
            {
                if (original.Empty())
                    throw new InvalidDataException("Cannot read image: " + imagePath);

                // BGR -> RGB -> 双线性缩放 -> /255 -> NCHW。
                Cv2.CvtColor(original, rgb, ColorConversionCodes.BGR2RGB);
                Cv2.Resize(rgb, resized, new Size(Side, Side), interpolation: InterpolationFlags.Linear);
                resized.ConvertTo(resized, MatType.CV_32FC3, 1.0 / 255);
                var input = new float[3 * Plane];
                Mat[] channels = Cv2.Split(resized);
                try
                {
                    for (int c = 0; c < 3; c++)
                        Marshal.Copy(channels[c].Data, input, c * Plane, Plane);
                }
                finally
                {
                    foreach (Mat channel in channels)
                        channel.Dispose();
                }
                double preprocessMs = watch.Elapsed.TotalMilliseconds;

                // Run 同步完成上传、GPU 图执行和输出回读。
                watch.Restart();
                Dictionary<string, float[]> outputs = session.Run(
                    new Dictionary<string, float[]> { { "input.1", input } });
                float[] prediction = outputs["17728"];
                double inferenceMs = watch.Elapsed.TotalMilliseconds;

                watch.Restart();
                if (prediction.Any(x => float.IsNaN(x) || float.IsInfinity(x)))
                    throw new InvalidDataException("Model output contains NaN/Infinity.");

                using (var mask = new Mat(Side, Side, MatType.CV_32FC1))
                using (var restored = new Mat())
                using (var normalized = new Mat())
                using (var alpha = new Mat())
                using (var bgra = new Mat())
                {
                    Marshal.Copy(prediction, 0, mask.Data, prediction.Length);
                    // 与原工程一致:先恢复原图大小,再做 Min-Max 归一化。
                    Cv2.Resize(mask, restored, original.Size(), interpolation: InterpolationFlags.Linear);
                    double min, max;
                    Cv2.MinMaxLoc(restored, out min, out max);
                    double range = max - min;
                    restored.ConvertTo(normalized, MatType.CV_32FC1,
                        range > 1e-8 ? 1 / range : 1,
                        range > 1e-8 ? -min / range : 0);
                    normalized.ConvertTo(alpha, MatType.CV_8UC1, 255);
                    Cv2.CvtColor(original, bgra, ColorConversionCodes.BGR2BGRA);
                    Cv2.InsertChannel(alpha, bgra, 3);
                    Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
                    File.WriteAllBytes(outputPath, bgra.ImEncode(".png"));
                }
                double postprocessMs = watch.Elapsed.TotalMilliseconds;
                Console.WriteLine("Load={0:F1} ms, Pre={1:F1} ms, Infer={2:F1} ms, Post={3:F1} ms, Total={4:F1} ms",
                    loadMs, preprocessMs, inferenceMs, postprocessMs, total.Elapsed.TotalMilliseconds);
                Console.WriteLine("Saved: " + outputPath);
            }
        }
        return 0;
    }
}

示例编译为 Ben2VulkanExample.exe 后,调用方式如下。把 EXE 与依赖放在同一运行目录,传入实际路径:

复制代码
.\Ben2VulkanExample.exe "D:\images\input.jpg" "D:\models\Vulkan" "D:\output\foreground.png"

部署自己的程序时,需要复制 Lw.Vulkan.Net48.dll、OpenCvSharp.dll、lw_onnx_vulkan.dll、OpenCvSharpExtern.dll,以及 OpenCV 的配套运行库和实际需要的托管依赖。依赖清单以随包项目的输出目录为准,不能只复制一个 Vulkan DLL。

这个独立 Vulkan 示例不需要 OnnxRuntime;完整 WinForms 程序因为保留对照模式,仍随带 ORT 依赖。CUDA 对照模式还需要匹配的 CUDA / cuDNN 运行库,Vulkan 路径不使用它们。

示例仅处理一张图片,所以记录的是该进程首次调用的耗时。连续处理时应把会话放在循环外,先预热再测量,不能直接把首次调用时间与下面的稳态均值对比。

WinForms 中,项目用 await Task.Run(() => engine.Run(...)) 执行耗时工作,同时暂时禁用操作按钮,完成后回到界面线程显示结果,避免 F5 启动后点击推理时界面长时间无响应。

七、实测:速度、显存和 GPU 利用率

7.1 测试条件与计时范围

本节数据来自第四轮正式对比记录,日期为 2026 年 10 月 9 日:

  • GPU:NVIDIA GeForce RTX 4060 Laptop GPU,8 GB;驱动 596.36。
  • 应用:.NET Framework 4.8,Release | x64。
  • 图片:Assets/test_img/1.jpg,491×487;网络输入 1024×1024。
  • 每个后端在独立进程中顺序执行,预热 3 次后测量 5 次。
  • OnnxRuntime 1.20.1;CUDA 对照配置 TF32=0、cuDNN EXHAUSTIVE。
  • 测量时关闭 profiling,没有并发训练或编译任务。

推理耗时 包含输入输出打包、上传、GPU 执行、同步及回读;总耗时还包括图片读取、预处理、原尺寸掩膜生成和 PNG 编码。首次加载不纳入稳态平均推理。

专用显存使用 WDDM 的本进程 Dedicated Usage,以 100 ms 间隔采样。GPU 利用率使用 NVML 整卡窗口采样,包含处理间隙,和单进程显存不是同一测量对象。

7.2 同轮结果

后端 平均推理 平均总耗时 本进程专用显存峰值 GPU 利用率采样均值
Vulkan 上一版 FP32 491.4 ms 530.2 ms 2.14 GiB 100.0%
Vulkan 第四轮 FP32 449.3 ms 488.1 ms 2.19 GiB 97.3%
OnnxRuntime CUDA 原 FP16 516.3 ms 548.1 ms 5.11 GiB 92.5%
OnnxRuntime CUDA 同源 FP32 555.6 ms 590.4 ms 7.10 GiB 88.9%

BEN2 同轮性能与专用显存对比

同源 FP32 CUDA 模型与 Vulkan 导出计划的模型哈希一致,原 FP16 对照使用最初的 BEN2 ONNX。FP16、FP32 是不同精度路径,应分开阅读。

在这次测量中,第四轮 Vulkan 相对 CUDA FP32:推理耗时减少约 19.1%,本进程专用显存峰值减少约 69.1%。相对原 FP16 CUDA,推理耗时减少约 13.0%,显存峰值减少约 57.1%。

这些是此模型、此图片、此设备和此配置下的结果。不能由此推导所有模型都更快,也不能由利用率高低单独判断哪个内核更高效。

单次记录中的第四轮会话加载约 1,784 ms,首次推理约 488 ms;驱动缓存状态、机器与版本会影响初始化,首次遇到新 shader 可能更慢。持续服务或连续处理图片时,会话复用非常关键。

7.3 输出差异

以这张代表图片的最终 alpha 检查:

对照 alpha 最大差 前景 IoU,alpha≥128
上一版 Vulkan FP32 1/255 1.0
CUDA 同源 FP32 1/255 1.0
CUDA 原 FP16 2/255 约 0.9999102

这是代表图片验证,不代表所有输入都逐位一致。FP32 类型相同,也可能因融合、归约顺序和 Winograd 变换出现浮点差异。

八、为什么能把 Vulkan 做快?

8.1 优化图,而不只是某一个算子

项目优化包括 GELU、LayerNorm、乘加和广播偏置等融合,以及归一化、激活和布局写回的合并。

收益来自减少中间张量读写、布局转换和额外 dispatch。处理大型特征图时,省掉一次完整写入和读取,可能比优化几条算术指令更有帮助。

8.2 根据形状选择分块

矩阵乘法和卷积采用不同分块策略,注意力中的窄矩阵也保留专门路径。

分块更大可以增加数据复用,但也会增加寄存器或共享内存压力。第四轮曾试过更大的 M128 分块,在本机反而变慢,最终撤回。这类选择需要结合实际矩阵形状和硬件计时。

8.3 让中间张量共享工作区

计划生成阶段分析张量使用情况,安排可复用的工作区位置。当前逻辑工作区约 1.71 GiB。

逻辑工作区不等于最终显存峰值;权重、元数据、暂存、管线和驱动分配还会占用显存,所以测得的是约 2.19 GiB。

8.4 第四轮:分块 FP32 Winograd

第四轮针对适合的大 3×3 卷积采用 Winograd 变换,流程是:

复制代码
输入变换 → 变换域矩阵乘法 → 输出变换 → 偏置 / 激活

其卷积加速思路可参考 Lavin / Gray:Fast Algorithms for Convolutional Neural Networks。本工程使用已有矩阵乘法内核完成变换域计算,权重变换在导出阶段完成。

当前实现只在符合条件的卷积上选择这条路径,例如 dense 3×3、stride=1、dilation=1、padding=1,并结合通道和空间大小继续筛选。其他情况保留直接卷积。

为控制工作区,变换输入和结果按 chunk 处理,每个临时缓冲最多约 128 MiB。没有为所有分块一次性创建巨大的变换图。

同轮上一版 491.4 ms,新版 449.3 ms,推理耗时减少约 8.6%,专用显存增加约 55 MiB。dispatch 数量增加,但总计算减少,所以实际更快。

九、拿到工程后怎样直接运行?

生产解决方案为 BEN2.Vulkan.Net48.sln,包含 C# WinForms、通用 .NET 4.8 绑定和 C++ 原生项目。

  1. VS2022 安装".NET 桌面开发"和"使用 C++ 的桌面开发",包含 .NET Framework 4.8 Targeting Pack / SDK、v143 和 Windows SDK。
  2. 解压完整源码包,打开 .sln,选择 Release | x64。
  3. 将 BEN2.WinForms 设为启动项目,生成并按 F5。
  4. 选择图片和 Vulkan 后端,点击"前景分割",保存透明 PNG。

也可以运行根目录 Run.cmd,它先调用 MSBuild 编译,再打开界面。

当前源码已为两个经典 C# 项目显式声明 Debug|x64 和 Release|x64。旧包若出现"设为启动项目后仍无法启动",需要应用对应项目配置补丁:单独设置 PlatformTarget=x64,不足以让 VS 正确枚举 x64 项目配置。

普通构建使用随包 Vulkan 头文件、导入库和冻结 SPIR-V,不需要安装完整 Vulkan SDK,也不需要在线 NuGet 还原。修改 shader 时再安装 SDK,运行 tools/Rebuild-Shaders.ps1 更新冻结代码,然后重新编译。

十、换一个 ONNX 模型,哪些部分可以复用?

本项目保留了两个工程层次:Lw.OnnxVulkan.VS2022 是基础工程,BEN2.Vulkan.Net48 是从基础工程复制并适配得到的模型应用。

可以直接复用的部分

  • Vulkan 设备、队列、缓冲区和计算管线管理。
  • C ABI 与 C# 会话生命周期封装。
  • 按名称绑定的多输入输出。
  • 已实现算子的 shader、图融合和工作区规划。
  • 命令复用、同步与 GPU profiling 基础设施。
  • VS2022 中 C# 与 C++ 联合编译的组织方式。

通常需要修改的部分

  • 模型类型转换与导出规则。
  • 输入形状、通道顺序、归一化等预处理。
  • 输出张量的解释与后处理。
  • 基础工程尚不支持的算子或形状行为。
  • 面向该模型热点的优化策略。

推荐流程是:复制基础工程 → 分析新模型 → 补充适配与缺失算子 → 检查代表输入与性能 → 将通用改进回写基础工程。

基础工程并不是承诺任意 ONNX 文件即插即用的完整通用运行时。模型分析遇到未支持内容会报错,当前 Vulkan 模型图执行也不会静默借用 OnnxRuntime / CPU 跑完。

对于需要 FP16、量化、复杂动态形状或新算子的模型,应先确认支持情况。基础工程的其他示例和开发工具可能使用 .NET 8;本文 BEN2 的生产 C# 项目仍为 .NET Framework 4.8。

十一、配套资料与分享

本文配套资料包含:

  • 完整 VS2022 解决方案,含 C# 与 C++ 源码。
  • BEN2 WinForms 测试界面和 Vulkan 执行计划。
  • 可复用的 ONNX → Vulkan 基础工程。
  • 本文完整 C# 图片推理示例。
  • 同轮性能记录、精度检查结果与优化说明。

如果你正在做 C# 图像算法部署,希望保留 WinForms / .NET Framework 的开发环境,同时能深入修改 GPU 推理路径,这个工程可以作为一个具体的起点。

欢迎基于它继续接入超分辨率、前景分割、检测或 OCR 等模型,把能够复用的算子与优化逐步积累回基础工程。

完整源码下载