先把话放在前面:为什么我盯上了 Vulkan
几个月前接了个活儿,要在好几台不同品牌显卡的设备上做文本识别。手头清一色没有支持 CUDA 的机器,有的还是深度定制的嵌入式环境,驱动工具链残缺得让人头疼。我第一反应是老老实实跑 CPU 推理,但一张 1080P 图片从检测到识别走下来,单帧耗时能到 500 毫秒以上,这延迟放在生产环境根本没法交代。于是我开始认真考虑用 GPU 加速,而 GPU 加速的前提是:不能再依赖 CUDA。
翻了一圈手上的工具,最后瞄上了 Vulkan。Vulkan 是我日常工作里用了很久的图形 API,它自带 compute shader(计算着色器)能力,干的全是通用并行计算的活儿,而且跨厂商、跨平台,从桌面独显到手机 GPU 都有驱动支持。当时我就在想,既然它能算图像、能算物理、能算粒子,那能不能把它当成一个推理引擎来使。最终我把 PP-OCR 的文本检测、方向分类、文字识别三段流程全部搬进了 Vulkan,用纯 C++ 实现了完整的 GPU 推理链路,彻底摆脱了 CUDA 依赖。实测下来,推理速度虽然比不上那些深度优化的商用框架,但比 CPU 推理快了一个数量级,已经完全可以用于实际项目。
这篇文章就是这次实践从头到尾的完整复盘。如果你也面临“没有 N 卡但必须上 GPU 推理”的困境,或者纯粹好奇 Vulkan 计算着色器能不能撑起一个神经网络,这篇内容应该能帮你省下不少弯路。
1. 整体设计思路:把图形 API 改造成张量计算引擎
1.1 为什么不用 CUDA,也不退回去用 OpenCL
先交代一下选型过程。CUDA 本身没有问题,生态成熟、算子库丰富、性能优化空间大,只要你的目标平台是 NVIDIA 显卡,它就是最优解。但问题恰恰出在“NVIDIA 显卡”这个前提上。我的目标设备里有老旧的 N 卡、A 卡、核显,甚至还有一些连厂商自己都说不清 GPU 型号的工控板卡,CUDA 在这些设备上根本无从谈起。
OpenCL 是我最初考虑的替代方案,毕竟它是 Khronos 推出的通用计算标准,名义上支持所有 GPU。但实际去趟一遍坑之后发现,OpenCL 在桌面端的处境相当尴尬:各个厂商对 OpenCL 的维护力度参差不齐,Intel 核显用 OpenCL 跑还算顺,A 卡的 OpenCL 驱动偶尔会出一些莫名其妙的问题,而且 OpenCL 3.0 之后的推进速度明显变慢,API 风格偏老旧。相比之下,Vulkan 是每年新游戏都在用、各家驱动都在重点优化的一等公民,至少驱动层面的支持靠谱得多。
Vulkan 的优势可以简单概括为三点:第一,跨平台跨厂商,统一在 Khronos 旗下,任何设备只要支持 Vulkan 1.0 就能跑,而如今支持 Vulkan 的设备覆盖率其实非常高;第二,它是显式 API,所有资源管理、命令提交、同步控制都交给开发者,听起来麻烦,但对计算密集型任务来说反而意味着完全掌控;第三,compute pipeline 的设计足够底层却足够通用,能支持对张量、线程协作的精细控制,正好是神经网络推理需要的特性。
1.2 整体拆成哪几个模块
真要把 Vulkan 当成推理引擎来写,不能一上来就堆算子,得先搭好骨架。我在动手之前把整个项目分成了四个模块:
- 运行时模块(Runtime):封装 Vulkan 实例、物理设备、逻辑设备、命令池、队列,负责所有 Vulkan 对象的创建和销毁。这一层相当于操作系统,后面所有计算都建立在它之上。
- 缓冲区模块(Buffer & Memory):管理 GPU 显存分配、内存类型选择、暂存缓冲区迁移。神经网络推理本质上就是张量沿着算子树流动,张量在 GPU 里存成什么形式、放在哪个内存段,都在这一层解决。
- 算子模块(Kernels):把 Conv2d、BatchNorm、ReLU、MatMul、Softmax 这些算子分别写成 compute shader,并封装成 C++ 接口。这一层是工作量最大的部分。
- 网络模块(Model):负责加载解析后的模型结构,按照拓扑顺序调度算子,维护张量生命周期,把零散的算子串成一个完整的神经网络执行流。
这个分层思路在后期调试时帮了大忙。因为 Vulkan 对象和 GPU 命令的耦合关系比较复杂,如果一开始不分层,后面每加一个算子都得改全局状态,代码很快就变成一团乱麻。
1.3 一个重要的认识转变
刚开始做的时候,我身上带着不少从图形编程带过来的惯性思维。图形渲染里的 compute shader 通常只需要对一张贴图做局部处理,数据量小、生命周期短、结构简单。但神经网络完全不同:一个卷积层要同时读写几十个通道的特征图,各层之间的张量有严格的依赖关系,而且上游算子的输出直接决定下游算子的输入布局,绝对不能随心所欲。
举个例子,图形渲染里我们习惯把一张纹理看作 CPU 内存的“顺序拷贝”,布局是纹理所定义的;但在神经网络里,张量的 NCHW / NHWC 布局对算子的访问模式影响巨大,卷积 shader 的循环顺序、缓存命中率、线程组划分都跟布局强相关。所以从一开始,就要将张量布局和算子实现放在一起考虑,不能把它们拆成两个独立问题。
2. 拿到 PP-OCR 后,先别急着写 Shader:模型结构怎么拆
PP-OCR 不是单个网络,而是一条由三个子模型组成的完整流水线。想用 Vulkan 跑它,第一步不是写代码,而是把模型吃透。
2.1 PP-OCR 三段式结构
PP-OCR 的推理流程分三个阶段:
- 文本检测(DB 检测模型):输入原始图像,输出每个文本区域的四边形坐标。内部主体是一个类似 FPN 结构的卷积网络,最后通过可微二值化(DB)解码出文本区域的概率图和阈值图。
- 方向分类(Cls 分类模型):对检测出来的每个文本区域裁剪、缩放后,判断这个区域是正倒方向还是倒置方向,方便后续识别时对图像做旋转校正。这是一个非常浅的分类网络。
- 文本识别(Rec 识别模型):对校正后的文本区域图像逐行识别,输出一个字符序列。内部用的是卷积特征提取加序列建模的结构,常见做法是 CRNN 加 CTC 解码。
三个模型是串行执行的:检测拿文本位置 → 分类判断方向 → 识别出文字内容。这也决定了我的推理引擎必须支持串行图调度,而不能像某些纯卷积网络那样一个 dispatch 从头到尾跑完。
2.2 四个关键算子和一个解码器
虽然 PP-OCR 网络结构不算浅,但把每个网络层展开后,真正需要在 Vulkan shader 里手写的算子其实没多少。我把沿途遇到的层全部列出来,按功能归类如下:
| 算子类别 | 具体层类型 | 对应 shader 方案 |
|---|---|---|
| 卷积类 | Conv2d、Depthwise Conv2d、Conv2dTranspose | compute shader 直接实现,融合 activation |
| 归一化 | BatchNorm | 推理期融合进卷积权重,不单独实现 |
| 激活函数 | ReLU、LeakyReLU、HardSigmoid | 直接拼在算子输入输出之后,或并入卷积 shader |
| 池化 | MaxPool、AveragePool | 简单循环,注意边界处理 |
| 张量操作 | Resize、Transpose、Concat、Slice | 单独写搬运类 shader,减少 CPU 干预 |
| 线性层 | MatMul、Gemm | 分块矩阵乘法 shader |
| 概率计算 | Softmax、Argmax | Softmax 用三段式,Argmax 用并行归约 |
| 解码 | CTC 解码 | CPU 端做,直接读取 GPU 输出结果 |
BatchNorm 能融合是重中之重。推理阶段 BatchNorm 的均值、方差、缩放、偏移都可以预先折算到卷积权重和偏置里,这样一来网络里就少了一大半额外的 GPU dispatch,速度提升立竿见影。后面第 4 章专门讲怎么融合。
2.3 模型格式怎么过桥
我当时拿到的是 Paddle 训练出来的模型权重。直接用 Paddle 的模型格式去对接 Vulkan 显然不现实,因为格式本身包含了解释器和算子调度信息,我们只想要纯权重和拓扑结构。我的做法是先把权重导出成 ONNX 格式,然后写一个很小的解析器,把 ONNX 里的 Node 列表和权重张量提取成自己的中间结构体。
这一步需要注意几个细节。第一,ONNX 里的算子版本差异很大,比如 Conv 算子的 padding 参数在不同版本里有不同的表达方式,解析时要统一归一化。第二,ONNX 中有些算子会叠加小算子组合(比如 Pad + Conv),可以在解析阶段手动合并,减轻 shader 端负担。第三,权重张量的内存布局是行主序且默认 NCHW,这在后面给 shader 绑缓冲时需要保持对应关系,不能搞混。
2.4 网络实际运行的拓扑梳理
我把 PP-OCR 三个子模型实际跑起来之后,统计了一下 dispatch 数量:
| 子模型 | 输入张量形状 | 主要算子 | dispatch 次数估算 |
|---|---|---|---|
| 检测模型 | 1x3x640x640 | Conv、BN 融合、FPN 上采样、DB 头 | 80-120 次 |
| 方向分类 | 1x3x48x192 | 浅层卷积 + GAP + FC | 10-15 次 |
| 识别模型 | 1x3x48x320 | Conv、RNN 近似、CTC 头 | 70-100 次 |
这个 dispatch 次数比直接调一个成熟框架要多不少,因为 Vulkan 是显式 API,每次算子执行都要手动提交一次命令缓冲。所以后面对 dispatch 的合并优化非常重要,至少需要将 Conv + BN + ReLU 这类固定串行模式组合成一次 dispatch,而不是素一地三层各发一次。
3. 把 Vulkan API 磨成能用的推理框架
这一章干货偏硬核,先打好基础。如果对 Vulkan 完全没有概念,我把关键步骤一步步拆开讲。
3.1 最基础的 Compute Dispatch 长什么样
Vulkan 的 compute 工作流大体如下:
- 创建 VkInstance,枚举物理设备并选取一个支持 compute queue 的设备。
- 创建 VkDevice,获取 compute 队列句柄。
- 创建 descriptor set layout 和 pipeline layout,描述 shader 里要用哪些绑定资源。
- 创建 VkShaderModule,编译 GLSL 或 HLSL 计算着色器。
- 创建 VkComputePipeline。
- 分配 descriptor pool 和 descriptor set,绑定实际的 VkBuffer。
- 创建命令缓冲,往里压入 bind_pipeline、bind_descriptor_set、dispatch 命令。
- 提交到 compute queue,在完成后读取结果。
整个流程里最容易出错的是步骤 3 和 4。Descriptor set layout 必须和 shader 里的 binding 声明完全一致,否则验证层会直接报错;Shader 里定义的 push constant 结构体也必须和 C++ 端的结构体字节对齐完全一致,否则数据传进去就是乱的。
一个最小的 compute sp shader 骨架像这样(GLSL):
#version 450 layout(local_size_x = 256, local_size_y = 1, local_size_z = 1) in; layout(set = 0, binding = 0) readonly buffer InputBuf { float in_data[]; }; layout(set = 0, binding = 1) buffer OutputBuf { float out_data[]; }; void main() { uint gid = gl_GlobalInvocationID.x; // 做计算... out_data[gid] = in_data[gid] * 2.0; }对应的 C++ 端创建 compute pipeline 时,需要把这些 binding 点映射成 VkDescriptorSetLayoutBinding。buffer 本身通过 VkBufferView 交给 shader,访问模式由 readonly / writeonly 限定。
3.2 缓冲区和内存类型的选择
推理引擎里张量的存储安排直接决定性能。Vulkan 把设备内存分成几类,常见的有DEVICE_LOCAL(显存内部,GPU 访问最快)和HOST_VISIBLE | HOST_COHERENT(CPU 可写且自动同步,但访问带宽有限)。
我最终的策略是分三种内存:
- 权重张量:存放在
DEVICE_LOCAL显存中,只读。生命周期覆盖整个推理会话,不反复上传。 - 中间特征图:存放在
DEVICE_LOCAL显存中,反复创建和销毁。建议用 memory pool 批量管理,避免频繁执行vkAllocateMemory。 - 输入输出临时数据:
HOST_VISIBLE映射内存,从 CPU 上传图片时走 staging buffer 再复制到显存,读取输出时反向操作一次。
很多刚上手的人会偷懒把所有张量都放 HOST_VISIBLE 内存,这样写起来简单,但性能会明显受限。因为 GPU 访问 CPU 映射内存要经过 PCIe 通道,带宽和延迟都比不了显存内部访问,一旦网络深度上来,这个差距会被数倍放大。
3.3 同步与内存屏障必须摆上台面
相比图形流水线,compute 任务之间的同步更隐蔽。图形渲染里有 framebuffer 和 render pass 自动承担很多依赖,但在 compute 里,两个 dispatch 之间的先后关系要全靠 pipeline barrier 和 semaphore / fence 管理。
举个例子。前面的 shader 往 buffer A 写数据,后面的 shader 要从 buffer A 读数据。如果在两段命令之间不插入 barrier,GPU 不会自动保证写完成后才读;在部分驱动实现上,第二个 dispatch 可能拿到旧数据,结果就成了玄学问题。我实际遇到过这个坑:同样的代码,在一台设备上跑结果正确,换一台设备结果就乱掉,追了一天最后发现就是少插了一个VkMemoryBarrier。
正确的写法是这样,在两个 dispatch 之间插入执行依赖和内存依赖:
VkMemoryBarrier barrier{}; barrier.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER; barrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 1, &barrier, 0, nullptr, 0, nullptr);这个 barrier 的核心语义是:GPU 保证在同一队列内,srcStage 的所有写操作完成后,才会开始 dstStage 的读操作。
我还要强调一点:如果多个算子 dispatch 顺序是严格串行的,可以把它们塞进同一个 primary command buffer 连续提交,不需要每次都结束 command buffer、再提交、再等待 fence。这样能大幅减少 CPU 与 GPU 之间的往返开销。
3.4 Shader 精度和特性检查
Vulkan 对 float 和 half 的支持因设备而异。嵌入式设备或老移动 GPU 可能不支持 FP16 运算,只能在存储时用 FP16 但计算时仍转回 FP32。还有的 GPU 对整数除法、位移等运算的实现有差异,写 shader 时要留意。
建议在做工程前先跑一个特性探测函数:遍历已选逻辑设备的VkPhysicalDeviceProperties和VkPhysicalDeviceFeatures,把支持的 feature 全部打印出来。后面决定是否用 FP16 存储中间特征图时,全靠这份探测数据做判断。
4. 算子在 Shader 里的落地过程
模型结构和 Vulkan 基础设施都准备好了,接下来进入最核心的部分:把 PP-OCR 需要的每个算子用 compute shader 实现出来。我按实际开发顺序讲。
4.1 卷积 V1:暴力直接卷积法
第一个卷积 shader 我写得很保守,先把功能跑通再说。思路是最简单的滑窗遍历:每个线程负责输出特征图上的一个位置,循环累加输入通道上所有元素的乘积。
// 简化版:单组输出通道,未考虑 batch layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; layout(set = 0, binding = 0) readonly buffer Input { float in_data[]; }; layout(set = 0, binding = 1) readonly buffer Weight { float w_data[]; }; layout(set = 0, binding = 2) writeonly buffer Output { float out_data[]; }; // 通过 push constant 传 kernel 大小、padding、stride、通道数等 layout(push_constant) uniform Params { uint in_h, in_w, in_c; uint out_h, out_w, out_c; uint kernel_h, kernel_w; uint pad_h, pad_w; uint stride_h, stride_w; } params; void main() { uint ox = gl_GlobalInvocationID.x; uint oy = gl_GlobalInvocationID.y; if (ox >= params.out_w || oy >= params.out_h) return; float sum = 0.0; for (uint ic = 0; ic < params.in_c; ic++) { for (uint ky = 0; ky < params.kernel_h; ky++) { for (uint kx = 0; kx < params.kernel_w; kx++) { int iy = int(oy * params.stride_h) + int(ky) - int(params.pad_h); int ix = int(ox * params.stride_w) + int(kx) - int(params.pad_w); if (ix < 0 || iy < 0 || ix >= int(params.in_w) || iy >= int(params.in_h)) continue; uint in_idx = ic * params.in_h * params.in_w + uint(iy) * params.in_w + uint(ix); uint w_idx = ic * params.kernel_h * params.kernel_w + ky * params.kernel_w + kx; sum += in_data[in_idx] * w_data[w_idx]; } } } uint out_idx = oy * params.out_w + ox; out_data[out_idx] = sum; }这个版本问题很明显:每一对输出位置和输入通道的组合都要重新从全局内存里拉数据,几乎没有缓存复用,带宽成了瓶颈。但对于验证流程正确性来说,它够用了。跑通之后,我逐步加了共享内存做数据复用,再改成一次处理多个输出通道,性能才慢慢上来。
4.2 卷积 V2:把 BatchNorm 和激活函数融合进来
PP-OCR 网络里几乎每个卷积后面都跟着 BatchNorm 和 ReLU。与其让三个算子各发一次 dispatch,不如在数学上把它们合成一个。
BatchNorm 推理期的计算公式是:
[ Y = \gamma \cdot \frac{X - \mu}{\sqrt{\sigma^2 + \epsilon}} + \beta ]
而卷积输出是:
[ X = \sum (W \cdot I) + b ]
把两者合并,相当于给卷积的权重和偏置做一次线性变换:
[ W' = W \cdot \frac{\gamma}{\sqrt{\sigma^2 + \epsilon}} ]
[ b' = (b - \mu) \cdot \frac{\gamma}{\sqrt{\sigma^2 + \epsilon}} + \beta ]
在 C++ 侧提前算好 W' 和 b',再上传到 GPU 权重缓冲,shader 里一点都不用改,只把最后结果过一遍 ReLU 就行:
out_data[out_idx] = max(sum + bias_value, 0.0);这一步优化带来的收益非常直观。整个检测模型跑完一次大约有上百层算子在排队,其中八成以上都是卷积后接 BN 再接 ReLU。融合前每次都要把中间特征图写入显存、读出显存;融合后只写一次,至少省下三分之一的 memory traffic,整帧耗时降了 25% 左右。
这里有个注意点:Conv2dTranspose(转置卷积)后接 BN 的融合方式跟普通卷积一样,但坐标映射要反过来算,千万别用同一套公式硬套。Depthwise 卷积也是一样,只是累加循环不需要遍历输入通道,只需对单个输入通道做滑窗。
4.3 矩阵乘法和全连接层怎么整
识别模型最后面有一个全连接层(或者一个等效的 Gemm),本质就是矩阵乘法。矩阵乘法在 compute shader 里最常规的写法是每个线程独立算一个输出值,我一开始也是这么做的,但很快发现同样的内存复用问题。
后来我换成了分块矩阵乘法:让一个 16x16 的线程组协作处理一个 16x16 的输出块,同时把输入 A、B 的对应分块先加载到共享内存,再循环累加。共享内存的访问速度比全局内存快一个数量级,这一层 shader 对全连接层尤其是后面 Softmax 的前置计算帮助很大。
// 伪代码结构 shared float tileA[16][16]; shared float tileB[16][16]; for (uint k = 0; k < K; k += 16) { // 协作加载 tile tileA[local_y][local_x] = A[(m) * K + (k + local_x)]; tileB[local_y][local_x] = B[(k + local_y) * N + n]; barrier(); for (uint i = 0; i < 16; i++) { acc += tileA[i][local_x] * tileB[local_y][i]; } barrier(); }注意共享内存同步点不能少。每个线程在覆盖tileA、tileB之前必须在 barrier 上等待其他线程读完成,否则就会发生数据竞争。Vulkan 的共享内存没有自动同步机制,全靠线程组内部的barrier()指令,这个必须严格遵守。
4.4 Softmax 和 Argmax 写起来也算是体力活
Softmax 不能像 ReLU 那样一个线程算一个值就完事,因为它需要先算出整行的最大值和归一化分母。我的实现分三步走:
- 第一个 dispatch 对每行做 reduce_max,得到一个最大值数组。
- 第二个 dispatch 对每行做 exp(x - max),并累加得到行总和。
- 第三个 dispatch 每行除以总和,得到最终概率。
如果行数很少(比如 PP-OCR 分类模型只有 2 类),后两步可以合并到一个 shader 里,线程 0 把整行数据先读进共享内存,再一次性算完。这属于典型的“因地制宜”优化。
Argmax 更简单,但也别小看它。如果分类类别非常多,同样需要用并行归约的思路,每个线程先负责一块局部区域的最大值和索引,再用原子操作汇总成最终结果。否则单线程扫几百个元素虽然也能跑,但耗时反而比 GPU 其他部分的延迟更高,白白拖慢整条流水线。
4.5 CTC 解码为什么放在 CPU 上
PP-OCR 的识别模型最后输出的是一个时间步上的概率分布,需要通过 CTC 解码去掉重复字符和 blank 符号,才能得到真正的文本串。这一步我选择在 CPU 端做,没有写 GPU shader。
原因很实际:CTC 解码本质是一个逐时间步的贪心扫描或动态规划,逻辑里充满了大量分支判断,不太适合 SIMT 并行模型。而且它的输入大小很小(通常 25 个时间步,字典几千个字符),CPU 上一两毫秒就搞定,GPU 反而因为 launch 开销和同步成本会拖垮延迟。
每次推理完成后,我只把识别模型的最终概率矩阵通过 staging buffer 拷回 CPU,然后在 CPU 上做简单贪心解码,读取连续相同字符并跳过 blank,得到最终文本。
5. 完整推理链路:从一张图片到一行文字
算子和基础设施都有了,现在把它们串起来,走一遍完整的 PP-OCR 推理流程。
5.1 输入预处理:图像怎么变成网络张量
PP-OCR 三段的输入形状不一样,预处理要分开做。检测模型需要较大的方形图,我先把原图按比例缩放,然后补边凑成 640x640;分类模型和识别模型都接受 48 高度的图像,宽度不定。
预处理工作包括:BGR 转 RGB(Paddle 模型训练时通常用 RGB),归一化到 0-1 区间,减均值除方差。这些操作如果在 CPU 上做,一张大图就要多花几十毫秒。我的做法是同时写了一个简单的图像预处理 shader,让 CPU 只负责把原始像素数据搬进 staging buffer,剩下的颜色转换、归一化、resize 全在 GPU 端完成。这样不仅省了 CPU 时间,还避免了一次 CPU→GPU 的多余拷贝。
不过这里有个取舍:如果原图非常大(比如 4000x3000),一次性丢进 GPU 显存做大图缩放会占用大量临时显存。我的做法是先在 CPU 端把图像缩放到适合的中间尺寸(比如最长边 1024),再交给 GPU 做归一化和最终缩放。大部分不成熟的项目都是败在这种内存细节上,显存一爆,程序直接崩,所以宁可在 CPU 端多花几毫秒,也不让内存越界。
5.2 三段子网络怎么串联
串联方式很简单:每段网络都用自己的 command buffer 执行一遍,但在 GPU 内部保持连续执行。也就是说,整个推理过程只需要提交一次 primary command buffer,三段网络在 GPU 上接力完成,CPU 只负责教帧同步。
检测模型推理完,我需要在 CPU 端读取检测框坐标,然后对原图做裁剪、旋转校正,才能送入识别模型。这意味着 GPU 和 CPU 之间需要同步一次:提交检测模型的 command buffer,等待 fence 信号,读取坐标,再上传裁剪结果。这一轮同步是整条流水线里唯一无法避免的 CPU-GPU 往返。
为了减少后续同步,我在拿到检测框坐标后,把裁剪、缩放、归一化全部交给 GPU 的搬运 shader 处理。CPU 只需要把裁剪区域的原图数据重新 pack 一下,一次上传即可,不会反复读写 GPU 显存。
方向分类模型也很简单:检测框经过统一缩放后,分类模型判断方向,如果是倒置就翻转图像,然后继续传到识别模型。整段流程就是按检测 → 分类 → 识别顺序跑,前一个的输出布局直接作为后一个的输入。
5.3 后处理与文本行合成
识别模型输出概率矩阵后,CPU 端先做 CTC 解码拿到文本候选序列,然后调用我之前写好的过滤规则:去重空格、过滤空行、根据置信度阈值放弃低可靠结果。最后将各段文本行的坐标、方向、内容统一聚合成一个结果结构体,返回给上层调用方。
这段后处理虽然逻辑不复杂,但最容易出 bug 的是坐标映射。检测模型内部做了两次缩放(原图→640x640→可能进一步下采样),而最后拿到的四边形坐标是在模型输入分辨率下的,必须通过缩放系数反算回原图坐标系。这个系数如果不仔细处理,最终结果会出现“文本内容对但位置偏差一个大像素”这种很难发现的错误。
6. 实测数据、常见问题与排查笔记
6.1 性能数据:GPU 推理的确快
我用一台中端 AMD 显卡和一台 Intel 核显设备分别做了测试,拿真实场景里的文本图片跑了 200 次取均值。检测模型 + 分类模型 + 识别模型完整链路,最终数据大概这样(保密项目参数,取典型参考值):
| 设备类型 | 单帧体测耗时(FP32) | 开启部分 FP16 后耗时 |
|---|---|---|
| CPU 基线(i7-10700,8 线程) | 480 ms | — |
| 中端 AMD 独显(Vulkan) | 68 ms | 52 ms |
| Intel 核显(Vulkan) | 110 ms | 90 ms |
从 480 毫秒降到 68 毫秒,这个提升直接让项目重新满足实时性需求。FP16 优化对带宽受限的算子(比如卷积)提升明显,但对 Softmax、长矩阵乘这类可能有精度敏感点的算子仍保留 FP32 计算。
6.2 排查实录一:换设备结果就不同,是同步问题
这是整次开发里让我最头疼的 Bug。第一次回放测试时,模型输出在 A 卡上完全正确,换到核显上部分结果数值乱跳,甚至同一图片跑两次结果都不同。排查过程:
- 先怀疑模型权重解析错误,重新跑了一遍模型导出,结果无误。
- 在 CPU 端用同样的权重做参考推理,逐层比对中间结果,发现错误从某个池化层输出那里开始发散。
- 在池化 kernel 前后加 barrier,问题没解决。
- 最终发现是池化层 shader 里用了共享内存缓存前一行数据,但没有正确设置
local_size_y和 barrier 分组,导致某些线程组在其他线程写共享内存之前就开始读,属于典型的 race condition。
解决方案就是把共享内存的写入和读取拆成两段,中间加barrier(),并再仔细检查所有local_size相关的索引边界。自那以后,我养成了一个坏习惯:每写一个 shader,先做一组强制 barrier 的 sanity check,再开始跑逻辑。
6.3 排查实录二:精度掉底,换预处理方式就好了
另一个坑来自图像预处理归一化。刚开始在 CPU 端把 RGB 转成 float 并做了归一化,上传时用的归一化因子和模型训练时的图像配置不完全一致(训练时用的 RGB 通道顺序,我上传的是 BGR 但顺序没转),结果识别准确率直接掉到 40% 左右。
后来我对照模型预处理规范,确认了两件事:一是必须按 RGB 顺序输入,二是必须用训练时同一组均值方差。改完后准确率回到 90% 以上。这个坑提醒我,网络结构在 GPU 端跑得再快,如果预处理数据分布和训练时不匹配,后面全白搭。
6.4 一些实测验证过的优化技巧
除了前面融合算子、共享内存、FP16 之外,还有几个零散的优化点值得记录:
- 把权重打包成对齐格式:Vulkan 对不同 buffer 有对齐要求,权重 tensor 尽量按照 16 字节对齐重新排布,避免因为 alignment 问题导致 shader 里读错地址。
- 连续 dispatch 用一条命令缓冲:不要每个算子创建、提交、销毁命令缓冲,这是 CPU 端性能杀手。我的方案是每次推理前构建一次完整的命令缓冲,推理中间动态 realloc 命令缓冲。
- 特征图复用池:中间张量反复创建销毁,用内存池管理后显存占用从接近 1GB 降到 300MB 左右。
- shader 里多用
precise或避免沉重算术:密集卷积 kernel 里,很多中间值可以在 FP32 下用float直接算,但不要在 shader 里频繁做类型转换,编译器在某些驱动上会为此插入额外指令,影响性能。
最后分享一点自己的体会
项目做下来,最大的感受是:Vulkan 跑神经网络,并不是一个噱头,而是一门真正可落地的工程方案。它不需要你买特定厂商的显卡,不需要预装庞大的 CUDA 工具链,只要设备能跑 Vulkan,就能用 GPU 让推理速度大幅提升。
但也要说实话,这条路比直接用成熟推理框架要辛苦得多。算子要自己写,内存要自己管,同步要自己盯,每一个细节都可能成为性能或者正确的瓶颈。如果你的设备上刚好能装 CUDA,完全没必要自讨苦吃;但如果像我这样被跨平台需求逼到墙角,Vulkan 反而会给你打开一扇窗。
最后再分享两个经验:第一,在开始写任何算子之前,一定要先写一个 CPU 参考实现,用它做每层的答案比照对象,否则 GPU 结果错误时根本不知道是哪一层开始歪的。第二,做完一个算子就立刻做一次 shader 端 Logging 验证,积累 bug 的周期越短,后面整体调试越轻松。这套思路帮我在后续好几个跨平台推理项目里继续复用,希望也能帮你少踩几个坑。