不依赖 CUDA:C++ 如何用 Vulkan 跑神经网络?从 Vulkan API 到一次完整 PP-OCR GPU 推理

Vulkan 不认识 ONNX,也不知道什么是卷积、MatMul、Attention。

那么,一个 ONNX OCR 模型究竟怎样变成几百次甚至上千次 GPU Compute Dispatch?

这篇文章结合开源项目 lw.PPOCR.Vulkan 的真实代码,从 Vulkan 最基础的对象讲起,一直拆到一张图片完成 DET、CLS、REC 和 CTC 的全过程。

项目地址:

github.com/lxw112190/l...

本文分析基于仓库当前 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

github.com/lxw112190/l...

项目当前提供:

  • 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:

github.com/lxw112190

相关推荐
threerocks1 小时前
「全语录+省流版」邵艾伦 × 孙宇晨:聊风口、聊个人IP、聊年轻人如何抓住 AI 时代的机会
人工智能
风巽·剑染春水1 小时前
【扩散模型原理】(六)A Unified and Systematic Lens on Diffusion Models(2)
人工智能·生成模型·diffusion·扩散模型
杨航 AI1 小时前
Agent+模型 评测榜单网站:详细设计
人工智能
诚小纯1 小时前
跑了 21 次只有 1 次透明:聊聊一个生图 API 的"透明继承"机制
人工智能·github
镜子AI1 小时前
教培机构AI推荐系统实战:概念漂移检测算法——为什么“去年有效的推荐今年可能失效“
人工智能·算法
用户2991422196802 小时前
GitHub 热榜项目:日榜(2026-10-09)
人工智能·开源·github
AliCloudROS2 小时前
计算巢Agent部署:免费试用,降低企业 AI 应用探索门槛
人工智能
组工部管理能手李哥2 小时前
政务办公场景下,私有化知识库+智能体方案的技术实践与思考
大数据·人工智能
俊哥V2 小时前
每日 AI 研究简报 · 2026-10-08
人工智能·ai