目录
[二、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,再通过原生接口创建会话。
原生执行器依次完成:
- 创建 Vulkan Instance,枚举并选择 GPU。
- 创建逻辑设备和计算队列。
- 分配权重缓冲,上传权重。
- 根据计划分配中间张量工作区、元数据和辅助常量缓冲。
- 创建可由 CPU 访问的上传与回读缓冲。
- 为所需 shader 创建或复用计算管线,建立描述符绑定。
- 录制本计划的命令缓冲,创建提交完成所需的 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 适配层继续完成:
- 检查 NaN / Infinity。
- 将输出掩膜双线性恢复到原图大小。
- 按原工程顺序进行 Min-Max 归一化。
- 转成 0~255 的 alpha 通道。
- 将原图转为 BGRA,把 alpha 写入第四通道。
- 编码并保存透明 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++ 原生项目。
- VS2022 安装".NET 桌面开发"和"使用 C++ 的桌面开发",包含 .NET Framework 4.8 Targeting Pack / SDK、v143 和 Windows SDK。
- 解压完整源码包,打开
.sln,选择Release | x64。 - 将
BEN2.WinForms设为启动项目,生成并按 F5。 - 选择图片和 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 等模型,把能够复用的算子与优化逐步积累回基础工程。