
Vulkan 不认识 ONNX,也不知道什么是卷积、MatMul、Attention。
那么,一个 ONNX OCR 模型究竟怎样变成几百次甚至上千次 GPU Compute Dispatch?
这篇文章结合开源项目 lw.PPOCR.Vulkan 的真实代码,从 Vulkan 最基础的对象讲起,一直拆到一张图片完成 DET、CLS、REC 和 CTC 的全过程。
项目地址:
本文分析基于仓库当前 main 分支,主要涉及:
bash
src/vulkan_context.cpp
src/graph.cpp
src/onnx_import.cpp
src/graph_optimizer.cpp
src/ocr_pipeline.cpp
src/ocr_host.cpp
src/rec_batch.cpp
src/shaders/
include/lw_ppocr_vulkan.h
当前正式维护版本为 v1.0.1。
项目使用 C++17 实现 PP-OCRv6 Tiny / Small / Medium 的 Vulkan GPU 推理,默认采用 FP32 Vulkan Compute,运行时不依赖 CUDA、ONNX Runtime 或 OpenCV DNN,同时提供:
- C ABI;
- C# WinForms Demo;
- Python 示例;
- HTTP / Web 服务;
- Windows x64 / Linux x64 部署包。
这篇文章不重点讲"怎么点 Demo",而是回答一个更底层的问题:
Vulkan 到底是怎样把一个神经网络跑起来的?
一、先说结论:Vulkan 本身不会推理 ONNX
这是理解整个项目最重要的一句话。
Vulkan 是一个 GPU 图形与计算 API。
它能帮我们做这些事情:
选择 GPU
分配显存
创建 Buffer
加载 GPU Shader
创建 Compute Pipeline
绑定输入/输出 Buffer
向 GPU 提交计算任务
等待 GPU 完成
把结果读回来
但 Vulkan 完全不知道:
Conv 是什么
DepthwiseConv 是什么
MatMul 是什么
LayerNorm 是什么
Attention 是什么
Softmax 是什么
PP-OCR 是什么
ONNX 又是什么
所以:
ONNX
↓
Vulkan
中间必须存在一个推理 Runtime。
lw.PPOCR.Vulkan 做的其实就是这层工作。
整个关系可以画成:
markdown
PP-OCR ONNX
│
▼
┌───────────────────┐
│ ONNX Parser │
│ 模型解析 │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ Graph Optimizer │
│ 图转换 / 算子融合 │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ Workspace Planner │
│ Tensor 显存规划 │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ Kernel Selection │
│ 选择 Compute Shader│
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ Vulkan Runtime │
│ Buffer / Pipeline │
│ Descriptor / Cmd │
└─────────┬─────────┘
│
▼
GPU
所以项目真正有意思的地方,不只是调用 Vulkan API。
而是:
自己实现了从模型图到 GPU Compute 的完整执行链。
二、Vulkan 为什么能跑神经网络?
很多开发者第一次接触 Vulkan,是在游戏渲染领域。
例如:
Vertex Shader
Fragment Shader
Texture
Framebuffer
SwapChain
但 Vulkan 除了 Graphics Pipeline,还有:
Compute Pipeline
Compute Pipeline 不需要窗口,也不需要画三角形。
它的工作就是:
diff
给 GPU 一批数据
+
给 GPU 一个计算程序
=
让大量 GPU 线程并行执行
例如我们有两个数组:
css
C[i] = A[i] + B[i];
可以写成一个 Compute Shader:
ini
#version 450
layout(local_size_x = 256) in;
layout(binding = 0, std430) readonly buffer A {
float a[];
};
layout(binding = 1, std430) readonly buffer B {
float b[];
};
layout(binding = 2, std430) writeonly buffer C {
float c[];
};
layout(push_constant) uniform Params {
uint count;
} p;
void main()
{
uint i = gl_GlobalInvocationID.x;
if (i < p.count)
c[i] = a[i] + b[i];
}
然后 CPU 调:
scss
vkCmdDispatch(cmd, groupX, 1, 1);
GPU 就会启动大量线程并行完成这些加法。
卷积、矩阵乘法、激活函数,本质上也是把大量数学运算拆成这种并行计算。
区别只是实际 Shader 会复杂很多。
三、CUDA 和 Vulkan 可以怎么对应理解?
熟悉 CUDA 的开发者可以先看这张表。
CUDA Vulkan CUDA Device VkPhysicalDevice CUDA Context VkDevice CUDA Stream VkQueue cudaMalloc vkCreateBuffer + vkAllocateMemory cudaMemcpy vkCmdCopyBuffer / mapped memory CUDA Kernel Compute Shader <<<grid, block>>> vkCmdDispatch Kernel Parameters Descriptor + Push Constants Shared Memory GLSL shared threadIdx.x gl_LocalInvocationID.x blockIdx.x gl_WorkGroupID.x Stream Submit vkQueueSubmit CUDA Event / Sync Fence / Query
所以 Vulkan Compute 并没有神秘到完全无法理解。
例如 Shader:
ini
layout(local_size_x = 128) in;
基本可以理解为:
一个 WorkGroup 有 128 个 GPU Invocation
如果:
scss
vkCmdDispatch(cmd, 100, 20, 1);
那么 GPU 将调度:
ini
100 × 20 × 128
=
256000 个 Invocation
当然实际 GPU 硬件会按照自己的 Wave / Warp / Subgroup 进一步执行。
四、一次 Vulkan 计算,需要哪些核心对象?
我们先把 Vulkan 的核心对象全部串起来。
VkInstance
│
▼
VkPhysicalDevice
│
▼
VkDevice
│
├──────── VkQueue
│
├──────── VkBuffer
│ │
│ ▼
│ VkDeviceMemory
│
├──────── VkShaderModule
│ │
│ ▼
│ VkPipeline
│
├──────── VkDescriptorSet
│
├──────── VkCommandBuffer
│
└──────── VkFence
对应神经网络,可以这样理解:
Vulkan 对象 推理 Runtime 中的意义 VkInstance Vulkan Runtime 入口 VkPhysicalDevice RTX 4060、AMD iGPU 等真实 GPU VkDevice 应用创建的逻辑 GPU VkQueue GPU 任务提交队列 VkBuffer Tensor、权重、输入、输出 VkDeviceMemory Buffer 对应实际显存 VkShaderModule 编译后的 GPU 算子 VkPipeline 可执行的 Compute Kernel VkDescriptorSet Kernel 的 Tensor 参数 VkCommandBuffer 一串神经网络计算命令 VkFence 判断本轮 GPU 是否执行完成
下面结合项目实际代码继续往下看。
五、第一步:创建 Vulkan Instance
代码在:
bash
src/vulkan_context.cpp
项目使用 Vulkan 1.1:
ini
VkApplicationInfo app{
VK_STRUCTURE_TYPE_APPLICATION_INFO
};
app.pApplicationName = "lw.PPOCR.Vulkan";
app.apiVersion = VK_API_VERSION_1_1;
VkInstanceCreateInfo ci{
VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO
};
ci.pApplicationInfo = &app;
VkInstance instance{};
vkCreateInstance(
&ci,
nullptr,
&instance
);
VkInstance 可以理解为 Vulkan Runtime 的入口。
没有 Instance,后面连 GPU 都无法枚举。
六、第二步:枚举显卡
接下来调用:
ini
uint32_t count = 0;
vkEnumeratePhysicalDevices(
instance,
&count,
nullptr
);
得到 GPU 数量。
然后:
c
std::vector<VkPhysicalDevice> devices(count);
vkEnumeratePhysicalDevices(
instance,
&count,
devices.data()
);
这样就得到:
erlang
GPU 0
GPU 1
GPU 2
...
项目中的设备编号就是这个 Vulkan 枚举顺序。
需要特别注意:
Vulkan Device 0
不一定等于:
CUDA Device 0
多 GPU 机器上尤其不要直接假设两者编号相同。
项目因此专门提供:
scss
lwvk_device_count()
lwvk_device_get()
让应用先枚举,再明确指定设备。
七、第三步:找到 Compute Queue
GPU 内部可能有不同 Queue Family:
css
Graphics
Compute
Transfer
Video
...
OCR 推理至少需要:
VK_QUEUE_COMPUTE_BIT
项目调用:
scss
vkGetPhysicalDeviceQueueFamilyProperties(...)
然后查找:
css
families[i].queueFlags & VK_QUEUE_COMPUTE_BIT
lw.PPOCR.Vulkan 当前策略是:
优先选择 dedicated compute queue,没有的话再选择 graphics + compute queue。
找到后创建逻辑设备:
scss
vkCreateDevice(
physical,
&deviceCreateInfo,
nullptr,
&device
);
然后拿到 Queue:
scss
vkGetDeviceQueue(
device,
queueFamily,
0,
&queue
);
现在我们就有:
markdown
Physical GPU
│
▼
VkDevice
│
▼
VkQueue
后面的神经网络任务最终都会提交到这个 Queue。
八、第四步:Tensor 在 Vulkan 中是什么?
在 PyTorch 中:
ini
tensor = torch.randn(...)
在 Vulkan Runtime 中没有这种高级 Tensor 对象。
GPU 真正看到的主要还是:
diff
VkBuffer
+
VkDeviceMemory
+
offset
+
size
例如:
css
Input Tensor
Weight Tensor
Bias Tensor
Intermediate Tensor
Output Tensor
最终都可以映射成 Buffer 中的一段区域。
创建 Buffer:
ini
VkBufferCreateInfo ci{
VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO
};
ci.size = bytes;
ci.usage =
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT |
VK_BUFFER_USAGE_TRANSFER_SRC_BIT |
VK_BUFFER_USAGE_TRANSFER_DST_BIT;
vkCreateBuffer(
device,
&ci,
nullptr,
&buffer
);
接下来查询显存需求:
scss
vkGetBufferMemoryRequirements(
device,
buffer,
&requirements
);
分配 Memory:
scss
vkAllocateMemory(
device,
&allocateInfo,
nullptr,
&memory
);
最后绑定:
scss
vkBindBufferMemory(
device,
buffer,
memory,
0
);
于是:
markdown
VkBuffer
│
▼
VkDeviceMemory
建立起来了。
九、Host Buffer 和 Device Local Buffer
做 GPU 推理时,经常存在两类 Buffer。
一种 CPU 可以直接访问,例如:
HOST_VISIBLE
主要负责:
CPU → GPU 输入上传
GPU → CPU 输出回读
另一种放在 GPU 本地显存:
DEVICE_LOCAL
主要负责:
权重
中间 Tensor
计算输出
CPU 可访问的 Memory 可以:
scss
vkMapMemory(...)
拿到地址。
然后:
scss
memcpy(...)
即可写入。
项目中的 Buffer 类将这些细节封装起来:
arduino
Buffer::write()
Buffer::read()
并且对于非 HOST_COHERENT Memory,还显式处理:
scss
vkFlushMappedMemoryRanges()
vkInvalidateMappedMemoryRanges()
这非常重要。
Map 成功,不等于 CPU Cache 和 GPU Cache 自动保持一致。
十、权重不能每次推理都上传
如果每处理一张图片都:
加载 ONNX
解析权重
创建 Buffer
上传 Weight
创建 Pipeline
执行
销毁
GPU 推理性能会非常差。
正确方式应该是:
diff
初始化阶段
↓
读取 ONNX
↓
上传权重
↓
权重常驻 GPU
↓
创建 Pipeline
↓
录制 Command
↓
-------------------
之后每张图
-------------------
只更新 Input
↓
Submit
↓
Readback
lw.PPOCR.Vulkan 正是这么做的。
这也是:
scss
lwvk_ocr_create()
和:
scss
lwvk_ocr_run_bgr()
必须分开的原因。
十一、ONNX 怎样变成 Vulkan 计算?
这是整个推理 Runtime 最关键的地方。
项目没有在运行时把 ONNX 请求转给 ONNX Runtime。
而是自己解析模型。
代码:
bash
src/onnx_import.cpp
项目实现了一个受限的 ONNX Protobuf Parser。
注意这里的"受限"。
它不是准备实现:
sql
任意 ONNX 模型
任意算子
任意 Dynamic Shape
当前目标明确限定在:
scss
PP-OCRv6 Tiny
PP-OCRv6 Small
PP-OCRv6 Medium
这种设计非常重要。
通用 Runtime 和专用 Runtime 是两个完全不同的工程方向。
专用 Runtime 可以针对:
固定模型结构
固定算子集合
固定 Shape 规律
做更激进的优化。
十二、模型解析以后,还不能马上执行
ONNX 原始图通常不是最适合 GPU 执行的形式。
所以项目还有:
bash
src/graph_optimizer.cpp
用于做图优化。
例如:
Conv
↓
Bias
↓
HardSwish
如果条件满足,可以把它变成:
Conv + Bias + HardSwish
一个 Shader Epilogue 直接完成。
这样可以少一次:
Tensor 写显存
↓
Tensor 再读显存
↓
额外 Dispatch
尤其大特征图情况下:
少一次完整显存读写,往往比少几个浮点运算更值钱。
项目当前已经包含多种经过验证的融合,例如:
Conv + Bias
GELU
SiLU
HardSwish
部分 Affine / ReLU
当然并不是看到连续节点就随便融合。
还必须确认:
中间 Tensor 是否还有其他消费者
是否是 Graph Output
参数是否符合融合形式
计算顺序是否允许保持
数值结果是否通过回归测试
否则很容易出现:
速度快了
结果错了
十三、Shader 是怎样进入 Vulkan 的?
开发时我们写的是:
*.comp
例如:
conv_pointwise_wide.comp
这是 GLSL Compute Shader。
但 Vulkan GPU 最终执行的不是 GLSL 文本,而是:
SPIR-V
所以构建阶段:
GLSL
│
│ glslc
▼
SPIR-V
│
▼
嵌入程序
运行时读取对应 SPIR-V,然后:
ini
VkShaderModuleCreateInfo sm{
VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO
};
sm.codeSize = code.bytes;
sm.pCode = code.data;
vkCreateShaderModule(
device,
&sm,
nullptr,
&shaderModule
);
接下来再创建 Compute Pipeline。
十四、Compute Pipeline 是什么?
可以粗略理解为:
"这个 GPU 算子以后应该怎么执行"的可复用对象。
创建过程包括:
ShaderModule
Descriptor Layout
Push Constant Layout
Pipeline Layout
Compute Pipeline
项目中封装成:
arduino
Context::pipeline()
并且使用:
pipeline name
进行缓存。
比如:
conv_dw
conv_dw4
conv_gemm
conv_gemm_tiled
conv_pointwise
conv_pointwise_vector
conv_pointwise_wide
layernorm
attn
softmax
softmax_parallel
第一次遇到:
conv_pointwise_wide
创建 Pipeline。
后面再次使用:
直接复用
千万不要每一个神经网络节点都重新:
vkCreateShaderModule
vkCreateComputePipelines
这是非常昂贵的。
十五、DescriptorSet:Shader 怎么知道哪个 Buffer 是输入?
假设 Compute Shader:
ini
layout(binding = 0, std430)
readonly buffer X {
vec4 x[];
};
layout(binding = 1, std430)
readonly buffer W {
vec4 w[];
};
layout(binding = 2, std430)
readonly buffer B {
vec4 b[];
};
layout(binding = 3, std430)
writeonly buffer O {
vec4 o[];
};
那么 GPU 需要知道:
binding 0 → 哪个 Buffer
binding 1 → 哪个 Buffer
binding 2 → 哪个 Buffer
binding 3 → 哪个 Buffer
这就是 Descriptor 的作用。
大致过程:
markdown
VkDescriptorSetLayout
↓
VkDescriptorPool
↓
VkDescriptorSet
↓
vkUpdateDescriptorSets
例如:
ini
VkDescriptorBufferInfo info{};
info.buffer = inputBuffer;
info.offset = inputOffset;
info.range = inputBytes;
然后绑定到:
ini
binding = 0
Shader 就知道 Input 在哪里。
因此可以把 Descriptor 理解为:
GPU Kernel 的"大参数表"
十六、Push Constants:小参数不要再建 Buffer
有些参数非常小。
比如 GEMM:
M
N
K
activation flags
如果每次都专门创建 Buffer 太麻烦。
Vulkan 提供:
Push Constants
例如项目中的 Shader:
ini
layout(push_constant) uniform PC
{
uint M;
uint N;
uint K;
uint flags;
} p;
CPU 侧:
csharp
vkCmdPushConstants(
commandBuffer,
pipelineLayout,
VK_SHADER_STAGE_COMPUTE_BIT,
0,
sizeof(params),
¶ms
);
这非常适合:
css
Tensor Shape
Stride
Padding
Activation Flag
Width / Height
等小规模动态参数。
十七、真正让 GPU 计算的核心:vkCmdDispatch
到这里终于来到 Vulkan Compute 最关键的函数:
scss
vkCmdDispatch(...)
例如项目中的一个 Pointwise Shader:
ini
layout(local_size_x = 128) in;
表示每个 WorkGroup 包含 128 个 Invocation。
假设:
scss
vkCmdDispatch(
command,
8,
12,
1
);
那么:
8 × 12
个 WorkGroup 会被调度。
每组:
128 Invocation
注意:
vkCmdDispatch() 的参数不是:
元素数量
而是:
WorkGroup 数量
十八、看一个项目里的真实 FP32 GEMM Shader
项目为了优化 Medium REC 的大通道 Projection,实现了:
bash
src/shaders/conv_pointwise_wide.comp
它采用:
M32 / N128 / K32
Tile。
工作组:
ini
layout(local_size_x = 128) in;
并使用共享内存:
ini
shared float aa[1024];
shared vec4 bb[1024];
核心思想可以简化成:
vbnet
Global Memory
│
├── Input Tile
│
└── Weight Tile
│
▼
Shared Memory
│
▼
128 GPU Invocation
│
▼
FP32 FMA
│
▼
Register
│
▼
Output Buffer
为什么使用 Shared Memory?
因为如果 128 个线程都反复从全局显存读取相同 Input / Weight:
显存带宽浪费
把 Tile 先搬到:
vbnet
shared
之后同一个 WorkGroup 内重复利用,就能减少 Global Memory 访问。
这和 CUDA 的:
markdown
__shared__
是一个概念。
十九、为什么需要 barrier()?
Shader 中可以看到:
scss
barrier();
原因很简单。
假设:
vbnet
Thread 0 写 shared
Thread 1 写 shared
Thread 2 已经开始读 shared
如果没有同步:
Thread 2 可能读到还没有写完的数据
所以必须:
scss
加载 Tile
↓
barrier()
↓
所有线程开始计算
↓
barrier()
↓
覆盖 Shared Memory,加载下一 Tile
这属于 WorkGroup 内部同步。
但神经网络算子之间还需要另一层 Vulkan Pipeline Barrier。
二十、神经网络为什么需要 vkCmdPipelineBarrier?
例如:
arduino
Conv A
│
│ write
▼
Tensor X
│
│ read
▼
Conv B
GPU 是异步执行系统。
不能简单认为:
scss
vkCmdDispatch(A);
vkCmdDispatch(B);
就一定代表:
css
A 所有写操作对 B 都已经可见
所以 Runtime 需要建立正确的:
diff
Execution Dependency
+
Memory Dependency
项目里会使用:
scss
vkCmdPipelineBarrier(...)
处理类似:
Transfer → Compute
Compute → Compute
Compute → Transfer
Host → Compute
这些依赖。
这也是 Vulkan 相比很多高级推理框架"麻烦"的地方:
同步由你自己负责。
但反过来:
你也因此获得了完整控制权。
二十一、CommandBuffer:不是调用一个算子就等一次 GPU
理解 Vulkan 性能,必须理解 Command Buffer。
CPU 可以先:
scss
vkBeginCommandBuffer(...)
然后连续记录:
erlang
Bind Conv Pipeline
Bind Descriptor
Push Params
Dispatch Conv
Barrier
Bind Depthwise Pipeline
Bind Descriptor
Push Params
Dispatch Depthwise
Barrier
Bind Activation Pipeline
Dispatch
Barrier
Bind MatMul Pipeline
Dispatch
...
全部录完:
scss
vkEndCommandBuffer(...)
此时:
GPU 还不一定已经执行。
我们只是准备好了一张"计算任务清单"。
这对神经网络特别重要。
如果一个模型有几百个算子,不是必须:
erlang
CPU 提交一次
等 GPU
CPU 再提交一次
等 GPU
...
完全可以把一大串 Dispatch 录制好,然后一次提交。
二十二、真正开始执行:vkQueueSubmit
真正让 GPU 干活的是:
scss
vkQueueSubmit(...)
项目中的 Plan::submit_readback() 最核心的逻辑可以抽象成:
ini
vkResetFences(
device,
1,
&fence
);
VkSubmitInfo submit{
VK_STRUCTURE_TYPE_SUBMIT_INFO
};
submit.commandBufferCount = 1;
submit.pCommandBuffers = &command;
VkResult result = vkQueueSubmit(
queue,
1,
&submit,
fence
);
此时:
rust
CPU
│
│ vkQueueSubmit
▼
GPU Queue
│
├─ Preprocess
├─ Conv
├─ Depthwise
├─ MatMul
├─ LayerNorm
├─ Attention
├─ Softmax
└─ Output Copy
GPU 开始真正执行。
二十三、CPU 怎么知道 GPU 执行完了?
项目使用 Fence。
提交:
scss
vkQueueSubmit(
queue,
1,
&submit,
fence
);
等待:
scss
vkWaitForFences(
device,
1,
&fence,
VK_TRUE,
timeout
);
于是:
CPU
│
│ Submit
▼
GPU 正在计算
│
│
│
▼
Fence Signaled
│
▼
CPU 继续
项目还特别处理了一个重要问题:
Fence Timeout 不是 GPU Cancel
也就是说:
vkWaitForFences 超时
并不代表之前提交的 GPU 工作立即停止。
因此如果出现超时或 Device Failure,项目会把对应 Plan 标记为:
poisoned
避免继续复用可能仍被 GPU 引用的资源。
这是比较重要的工程细节。
二十四、GPU 结果怎么回到 CPU?
最终输出如果 CPU 需要使用,就要回读。
典型方式:
scss
Device Local Output
│
│ vkCmdCopyBuffer
▼
Host Visible Readback Buffer
│
▼
Fence
│
▼
CPU read()
比如 DET。
GPU 最终生成:
文字概率图
但是后续 DB 后处理仍然在 CPU。
所以需要把 Probability Map 读回来。
REC 又不完全一样。
项目为了减少巨大的分类概率回读,对 REC 可以直接在 GPU 进行:
Softmax / Argmax
然后只回读紧凑标签结果。
最后 CPU 再做:
objectivec
CTC 去重
blank 删除
字典映射
UTF-8 拼接
这样可以明显减少:
GPU → CPU 数据量
二十五、一次单网络 Vulkan 推理究竟发生了什么?
现在可以把整个流程简化成:
sql
CPU Input
│
▼
Host Visible Buffer
│
▼
Input Copy
│
▼
Device Local Arena
│
▼
┌────────────────────────┐
│ Compute Pipeline │
│ │
│ Conv │
│ Depthwise │
│ Pointwise │
│ LayerNorm │
│ Attention │
│ MatMul │
│ Softmax │
└──────────┬─────────────┘
│
▼
GPU Output
│
▼
Readback Buffer
│
▼
Fence
│
▼
CPU Result
项目中真正执行一次 Plan 的代码逻辑就是:
scss
upload_->write(input, inputBytes);
submit_readback(
command_,
output,
ctc
);
而 submit_readback() 中:
arduino
vkResetFences
↓
vkQueueSubmit
↓
vkWaitForFences
↓
Buffer::read
这就是最核心的一次 Vulkan 神经网络执行。
二十六、但是完整 OCR 不是只有一个神经网络
PP-OCR 完整流程是:
objectivec
DET
↓
CLS
↓
REC
实际上还夹杂大量图像处理和后处理。
lw.PPOCR.Vulkan 的默认完整流程更加接近:
css
BGR 原图
│
▼
GPU 上传原图
│
▼
GPU DET Resize / Normalize
│
▼
GPU DET Network
│
▼
Probability Map
│
▼
CPU DB PostProcess
│
├─ Threshold
├─ Contour
├─ Box Score
├─ Unclip
└─ Reading Order
│
▼
Text Boxes
│
▼
GPU Perspective Crop
│
▼
GPU CLS Preprocess
│
▼
GPU CLS
│
▼
CPU 判断 0° / 180°
│
▼
GPU REC Resize / Padding / Normalize
│
▼
GPU REC Network
│
▼
GPU Greedy Label
│
▼
CPU CTC Decode
│
▼
Dictionary UTF-8
│
▼
最终 OCR JSON
这才是:
一次完整 Vulkan OCR
二十七、第一阶段:原始 BGR 图片进入 GPU
项目的 C ABI 接受:
arduino
BGR8
width
height
stride
buffer bytes
而不是要求调用方:
JPEG → Base64
这样可以直接对接:
OpenCV Mat
工业相机 Buffer
视频帧
共享内存
完整 OCR API:
scss
lwvk_ocr_run_bgr(...)
图片进入后,如果 GPU 能力满足,项目默认可以启用:
scss
GPU DET Preprocess
GPU TEXT Preprocess
GPU Crop Preprocess
原图只上传一次。
这是非常重要的优化。
如果流程是:
objectivec
CPU 原图
↓
GPU DET
↓
CPU Crop
↓
GPU CLS
↓
CPU
↓
GPU REC
中间会不断:
Host ↔ Device
搬数据。
项目当前共享原图路径则尽量做到:
objectivec
原图只上传一次
DET / Crop / CLS / REC
共享同一个 Vulkan Context
CPU 主要传:
rust
Box 坐标
方向信息
控制参数
二十八、第二阶段:DET
DET 的任务是:
图片
↓
文字区域概率图
模型输入通常先经过:
css
Resize
Normalize
Layout
GPU 前处理会直接把 BGR 图转换到网络输入 Tensor。
之后运行 DET 网络。
GraphEngine 会根据每个节点选择 Shader。
例如:
css
普通卷积
Depthwise Conv
1×1 Pointwise
GEMM
Pooling
Resize
Concat
LayerNorm
Attention
Softmax
都会变成不同:
scss
dispatch(...)
最终内部还是:
vkCmdBindPipeline
vkCmdBindDescriptorSets
vkCmdPushConstants
vkCmdDispatch
二十九、第三阶段:DB 后处理为什么还留在 CPU?
DET GPU 输出只是概率图。
OCR 还需要:
rust
阈值
连通区域
轮廓
Box Score
Unclip
四点框
阅读顺序
这些属于 DB 后处理。
目前项目把这部分放在 CPU。
原因并不难理解。
网络中:
Conv
GEMM
MatMul
高度规则、计算密集,非常适合 GPU。
而:
Contour
动态候选框
几何排序
条件分支
这种操作未必马上搬到 GPU 就会更快。
所以当前架构遵循一个很实用的原则:
适合 GPU 的交给 GPU,不适合的暂时保留 CPU。
"GPU OCR"并不意味着:
所有代码都必须跑 GPU。
三十、第四阶段:GPU 透视裁剪
CPU 得到四点框之后,要把文字区域裁出来。
传统方法:
css
GPU DET
↓
概率图回 CPU
↓
CPU Perspective Crop
↓
每个 Crop 再传 GPU
项目目前在支持条件下,可以:
css
原图保持 GPU
↓
CPU 只传四点框
↓
GPU Perspective Crop
得到:
BGR8 Crop
然后继续用于:
objectivec
CLS
REC
这里项目还刻意保留:
css
Perspective Crop
和:
css
Network Resize / Normalize
两个阶段。
没有为了少一次操作强行融合成一次采样。
原因是要保留原有:
插值
BGR8 舍入
语义,避免为了性能改变 OCR 结果。
三十一、第五阶段:CLS
CLS 主要负责判断文字方向,例如:
0°
180°
CLS 输入经过:
css
Resize
Normalize
之后执行小型分类网络。
如果判定为 180°:
REC 前重新按照旋转方向采样
项目中 CLS / REC 都可以使用 GPU Text Preprocess。
三十二、第六阶段:REC
真正的识别通常是 OCR 中非常重要的一部分。
REC 输入通常是:
高度固定
宽度动态
例如:
csharp
[1, 3, 48, W]
其中:
ini
W = 32 ... 960
并按照 8 对齐。
宽度不同意味着:
Tensor Shape 不同
Workspace 不同
Dispatch 参数不同
Command Binding 可能不同
这就是为什么 REC 比固定 Shape 模型复杂。
三十三、为什么项目需要 REC Plan Cache?
假设连续识别:
erlang
宽 64
宽 128
宽 320
宽 96
宽 512
...
如果每次宽度变化都:
重新计算 Tensor Layout
重新分配 Buffer
重新更新 Descriptor
重新录制 CommandBuffer
CPU 和 Vulkan Driver 开销会非常明显。
所以项目会缓存执行 Plan。
v1.0.1 对这一块进行了专门优化:
ini
8 个 REC Slot
×
每个最多 16 个 Width Plan
=
最多 128 个缓存 Plan
但要注意:
这不是:
128 份完整神经网络显存
核心 Arena 仍然共享。
主要缓存的是:
执行计划
Descriptor
Command
相关 Metadata
这样大小图交替时,就不会频繁互相淘汰。
这是典型的:
用有限内存换稳定延迟。
三十四、REC 为什么成为 Vulkan 优化重点?
Tiny、Small、Medium 的 REC 计算规模并不一样。
尤其 Medium 中存在较大的:
1×1 Projection
GEMM
大 Channel MatMul
所以项目最近大量优化都集中在:
Pointwise
Wide Tile
Vocabulary Projection
REC Batch
例如 NVIDIA 路径针对某些大 Channel Projection 会选择:
conv_pointwise_wide
而不是普通:
conv_pointwise
为什么不所有 GPU 都使用 Wide Kernel?
因为项目实测发现:
yaml
RTX 4060
上可能加速,但同样策略在某些 AMD 集显上会退化。
所以代码里会根据:
vbnet
vendorID
shared memory
shape
task
做 Kernel Selection。
这也是自己写 Runtime 最大的价值之一:
你可以针对真实模型和真实 GPU 做调度。
三十五、GPU Greedy CTC
REC 网络最后通常会得到类似:
csharp
[T, Classes]
的输出。
如果 Classes 很大:
ini
Small / Medium = 18710
直接把:
r
T × 18710 FP32
全部传回 CPU,会产生明显 PCIe / Memory Copy 开销。
所以项目对 REC 尾部进行了:
Softmax + Argmax
GPU 化。
最终 GPU 只需要给 CPU:
css
每个时间步的 label
+
confidence
然后 CPU 做:
去 blank
去连续重复
字典映射
UTF-8 拼接
这正是专用 Runtime 可以做的优化。
并不是:
GPU 算完模型
所有 Logit 全部搬 CPU
再慢慢处理
三十六、外部程序到底怎么调用?
说完内部原理,再看应用层。
项目冻结了 C ABI v1。
完整 OCR 最主要的接口是:
scss
lwvk_ocr_config_default()
lwvk_ocr_create()
lwvk_ocr_run_bgr()
lwvk_ocr_result_json()
lwvk_ocr_result_destroy()
lwvk_ocr_destroy()
调用过程非常清晰:
javascript
枚举 GPU
↓
初始化配置
↓
加载模型
↓
创建 OCR Engine
↓
传入 BGR 图像
↓
Vulkan OCR
↓
获得 Result Handle
↓
查询 JSON 长度
↓
复制 JSON
↓
释放 Result
↓
复用 OCR Engine 处理下一张图
三十七、一个完整 C API 调用示例
下面给一个接近真实业务的调用结构:
arduino
#include "lw_ppocr_vulkan.h"
lwvk_ocr_config config{};
if (lwvk_ocr_config_default(&config) != LWVK_OK)
{
return -1;
}
config.device_index = 0;
config.enable_classifier = 1;
lwvk_ocr_handle engine = nullptr;
if (lwvk_ocr_create(
"models/onnx/ppocrv6-tiny",
&config,
&engine) != LWVK_OK)
{
printf(
"create failed: %s\n",
lwvk_last_error()
);
return -1;
}
假设我们已经有:
arduino
uint8_t* bgr
width
height
stride
bufferBytes
执行:
ini
lwvk_ocr_result_handle result = nullptr;
lwvk_status status = lwvk_ocr_run_bgr(
engine,
bgr,
bufferBytes,
width,
height,
stride,
&result
);
得到结果以后先查询长度:
ini
uint64_t required = 0;
lwvk_ocr_result_json(
result,
nullptr,
0,
&required
);
申请:
c
std::vector<char> json(required);
复制:
scss
lwvk_ocr_result_json(
result,
json.data(),
json.size(),
&required
);
现在:
perl
printf("%s\n", json.data());
即可得到完整 OCR JSON。
最后:
scss
lwvk_ocr_result_destroy(result);
lwvk_ocr_destroy(engine);
实际批量业务不要每张图片:
sql
Create → Run → Destroy
应该:
sql
Create 一次
↓
Run
Run
Run
Run
Run
↓
Destroy
这样才能复用:
权重
Pipeline
CommandBuffer
Workspace
Plan Cache
三十八、为什么 API 要传 buffer_bytes 和 stride?
项目的 BGR API 不是只收:
scss
width
height
pointer
还要求:
buffer_bytes
stride
原因是实际图像内存不一定:
arduino
width × 3
连续紧密排列。
例如相机和 Bitmap 经常存在:
css
Padding
Stride
安全的 Buffer 长度至少应该满足:
arduino
(height - 1) × stride
+
width × 3
通过显式传递:
stride
buffer_bytes
Runtime 可以在 C ABI 边界进行越界检查。
这个设计对于:
bash
工业相机
C#
C++
OpenCV
共享内存
接入都更加稳妥。
三十九、C# 为什么也能调用?
Vulkan Runtime 本身是:
C++
但项目提供稳定 C ABI。
因此 C# 只需要:
css
P/Invoke
即可调用。
大概结构:
csharp
[DllImport(
"lw.PPOCR.Vulkan.dll",
CallingConvention = CallingConvention.Cdecl)]
static extern int lwvk_ocr_run_bgr(
IntPtr engine,
IntPtr pixels,
ulong bytes,
uint width,
uint height,
uint stride,
out IntPtr result);
C# 不需要知道:
VkInstance
VkDevice
VkDescriptorSet
VkCommandBuffer
这些都封装在原生 DLL 内。
因此业务层看到的是:
javascript
Bitmap / BGR
↓
C ABI
↓
Vulkan Runtime
↓
OCR JSON
这也是项目提供 WinForms Demo 的原因。
四十、Python 也可以直接调用
项目同样提供 Python 封装。
例如完整图片 OCR:
bash
python examples/python/ocr_image.py ^
--library build/local/Release/lw.PPOCR.Vulkan.dll ^
--models models/onnx/ppocrv6-tiny ^
--image test-images/sample.jpg ^
--device 1 ^
--draw build/ocr-boxes.png
Python 只是负责:
图片读取
C ABI 调用
结果显示
真正的神经网络还是:
C++ Vulkan
四十一、一次完整调用,从 API 一路追到 GPU
现在把代码调用链完整串起来。
应用调用:
scss
lwvk_ocr_run_bgr()
进入:
bash
src/api.cpp
内部调用:
arduino
OcrEngine::run()
代码:
bash
src/ocr_pipeline.cpp
这里先建立:
objectivec
DET Graph
CLS Graph
REC Graph
如果条件满足,三者还共享:
Context / Device
然后进入:
scss
run_ocr_host()
负责整体 OCR 流程。
某个网络真正执行时:
arduino
GraphEngine
↓
Plan
↓
Plan::run()
或
Plan::run_bgr()
↓
Plan::submit_readback()
最终来到:
arduino
vkResetFences
↓
vkQueueSubmit
↓
vkWaitForFences
↓
Buffer::read
因此整个完整调用链就是:
objectivec
lwvk_ocr_run_bgr
│
▼
OcrEngine
│
▼
OCR Host
│
├──── DET GraphEngine
│ │
│ ▼
│ Plan
│ │
│ ▼
│ vkQueueSubmit
│
├──── CPU DB
│
├──── GPU Crop
│
├──── CLS GraphEngine
│ │
│ ▼
│ vkQueueSubmit
│
└──── REC GraphEngine
│
▼
Plan
│
▼
vkQueueSubmit
│
▼
CTC
│
▼
OCR JSON
这就是从业务 API 一直追到底层 Vulkan 的完整路径。
四十二、为什么 Vulkan 推理性能不能只看 Shader?
做这种 Runtime,很容易一开始只关注:
Conv 快不快
GEMM 快不快
实际上端到端耗时还包括:
javascript
图片前处理
数据上传
Plan 创建
Descriptor 更新
Command Recording
Queue Submit
GPU Execute
Fence Wait
GPU → CPU Readback
DB
Crop
CTC
JSON
所以:
erlang
某 Kernel 快 30%
并不代表:
erlang
完整 OCR 快 30%
lw.PPOCR.Vulkan 最近的很多实验也证明了这一点。
一些短 REC Kernel:
单算子 Benchmark 有提升
但放回:
完整 OCR
变尺寸流
收益不稳定,所以没有默认开启。
这个原则非常重要:
微基准快,不等于业务快。
四十三、当前项目做了哪些性能优化?
现在回头看,就很容易理解项目里的优化了。
主要包括:
markdown
1. 权重常驻 GPU
2. Pipeline Cache
3. CommandBuffer 复用
4. Tensor 生命周期显存复用
5. 有界 Plan Cache
6. FP32 Tile GEMM
7. vec4 访问
8. Shared Memory Tile
9. Depthwise Vectorization
10. GELU / SiLU / HardSwish Fusion
11. GPU DET Preprocess
12. GPU TEXT Preprocess
13. GPU Perspective Crop
14. GPU Greedy CTC
15. REC Width Cache
16. Medium REC Wide Projection
而且项目没有把:
FP16
Cooperative Matrix
Multiple REC Lane
直接作为正式默认路径。
它们目前属于:
实验路径
这是因为性能优化除了"更快",还有两个门槛:
diff
结果正确
+
不同设备稳定
四十四、为什么不是直接把 FP32 改 FP16?
很多人会说:
GPU 推理想快,全部改 FP16 不就行了吗?
事情没那么简单。
例如 OCR REC 最终输出有:
18710 类
一个很小的 Logit 变化,都可能改变:
lua
blank
repeat
character
选择。
所以:
FP32 → FP16
不能只看:
最大绝对误差
还要看:
最终文字
检测框
置信度
CER
业务准确率
因此当前默认路径保持:
FP32
FP16 / Cooperative Matrix 则单独研究和验证。
这是比"跑起来"更重要的工程问题。
四十五、Vulkan 相比 TensorRT 到底怎么样?
这个问题也需要比较客观地看。
当前项目自己的测试明确指出:
不能宣称 Vulkan 已经全面超过 TensorRT。
在 RTX 4060 Laptop GPU 的部分测试中:
Tiny / Small
差距已经比较有限
但:
scss
Medium
尤其 REC
仍明显落后于已有 FP16-enabled TensorRT 路径。
这很正常。
TensorRT 背后有 NVIDIA 多年的:
scss
Kernel Library
Graph Optimizer
Tensor Core
Tactic Search
Kernel Auto-Tuning
自己写 Vulkan Runtime,短时间内全面超过 TensorRT 并不现实。
但 Vulkan Runtime 的价值并不只在:
yaml
RTX 4060 上能不能比 TRT 快 2 ms
而在:
Runtime 完全可控
Shader 完全可改
无 CUDA Runtime
跨 GPU 厂商基础能力
可以围绕固定模型深度优化
部署依赖可控
整个执行链透明
这对于:
边缘部署
工业软件
OCR SDK
自己的推理框架研究
都很有价值。
四十六、如果自己从零写一个 Vulkan 推理 Runtime,最小流程是什么?
把这篇文章压缩成代码,其实就是:
scss
// 1. Vulkan Runtime
create_instance();
select_physical_device();
create_device();
get_compute_queue();
// 2. Model
parse_onnx();
optimize_graph();
// 3. Memory
create_weight_buffer();
upload_weights();
plan_tensor_lifetime();
create_workspace();
// 4. Kernels
for (auto& kernel : required_kernels)
{
create_shader_module(kernel);
create_compute_pipeline(kernel);
}
// 5. Execution Plan
for (auto& node : graph)
{
bind_pipeline(node);
bind_descriptors(node);
push_constants(node);
dispatch(node);
insert_barrier(node);
}
// 6. Inference
upload_input();
vkQueueSubmit(...);
vkWaitForFences(...);
read_output();
// 7. Postprocess
decode_result();
看上去只有几十行。
但真正复杂的是:
ONNX Parser
Graph Optimizer
Shape Inference
Tensor Lifetime
Workspace Planner
Kernel Library
Kernel Selection
Synchronization
Plan Cache
错误恢复
资源上限
跨 GPU 测试
精度回归
所以一个真正可用的 Vulkan Runtime,工作量远远大于:
写几个 vkCmdDispatch
四十七、我认为这个项目最值得看的,不只是 OCR
lw.PPOCR.Vulkan 当前确实是一个 PP-OCR Runtime。
但它背后的几个模块实际上已经具有更通用的价值:
markdown
ONNX Parser
│
▼
Graph Optimizer
│
▼
Workspace Planner
│
▼
Vulkan Buffer
│
▼
Compute Pipeline
│
▼
Descriptor Binding
│
▼
Command Recording
│
▼
Queue Submit
│
▼
GPU Profiling
也就是说:
如果以后想继续尝试:
目标检测
图像分割
超分辨率
分类
其他固定 ONNX 模型
真正能够复用的,正是这一层基础设施。
当然当前项目仍然明确:
它不是通用 ONNX Runtime。
支持一个新的模型,仍需要检查:
算子
Shape
Layout
Preprocess
Postprocess
Kernel
是否已经覆盖。
四十八、最后再看一次完整 Vulkan OCR
现在理解所有细节以后,再看这张流程就很清楚了:
scss
BGR Image
│
▼
Host Upload Buffer
│
▼
vkCmdCopyBuffer
│
▼
GPU DET Preprocess
│
▼
DET Network
│
多次 vkCmdDispatch
│
▼
Probability Map
│
▼
CPU DB
│
▼
Text Boxes
│
▼
GPU Perspective Crop
│
▼
CLS
│
▼
Rotation Decision
│
▼
GPU REC Preprocess
│
▼
REC
│
多次 vkCmdDispatch
│
▼
GPU Softmax / Argmax
│
▼
Compact Labels
│
▼
CPU CTC
│
▼
Dictionary
│
▼
UTF-8 Text
│
▼
OCR JSON
而网络内部每一个算子,本质上又落到:
markdown
vkCmdBindPipeline
↓
vkCmdBindDescriptorSets
↓
vkCmdPushConstants
↓
vkCmdDispatch
↓
vkCmdPipelineBarrier
整个图录制完成后:
markdown
vkQueueSubmit
↓
GPU Execute
↓
vkWaitForFences
↓
Readback
这就是一次真正的:
Vulkan GPU 推理。
结语
以前使用 ONNX Runtime 或 TensorRT 时,我们看到的是:
ini
session.Run();
一行代码。
而在这一行代码下面,实际上隐藏着:
模型解析
图优化
Tensor 生命周期
显存规划
Kernel 选择
Shader
Pipeline
Descriptor
Command Buffer
GPU Dispatch
同步
数据回读
lw.PPOCR.Vulkan 做的事情,就是把这些原来隐藏在推理框架内部的部分,一层一层重新实现出来。
所以这个项目对我来说,价值不只是:
"又做了一个 OCR Demo。"
更有意思的是:
我们开始真正控制一个神经网络从 ONNX 图,一直到 GPU 指令调度之间的整条路径。
当性能有问题的时候,可以继续往下追:
css
是 CPU Preprocess?
是 Host → Device?
是 Conv?
是 Depthwise?
是 GEMM?
是 Attention?
是 Barrier 太多?
是 Dispatch 太碎?
是 REC Width Cache?
还是 GPU 空闲降频?
这也是自己写 Runtime 最有意思的地方。
所有东西:
都能继续往下挖。
项目地址
lw.PPOCR.Vulkan
项目当前提供:
- PP-OCRv6 Tiny / Small / Medium;
- Vulkan FP32 GPU 推理;
- 原生 ONNX 解析;
- 完整 DET → CLS → REC;
- GPU 图像前处理与透视裁剪;
- C ABI;
- C# WinForms;
- Python 示例;
- HTTP / Web;
- Windows / Linux 构建与部署。
如果你正在研究:
Vulkan Compute
GPU 推理
ONNX Runtime
OCR 部署
C++ 推理引擎
自定义 GPU Kernel
欢迎一起交流。
天天代码码天天
GitHub: