资讯动态

绕开CUDA,用Vulkan实现PP-OCR端到端GPU推理

发布时间:2026/10/20 2:51:20 来源:尧图企业网站定制
做GPU推理很多人的第一反应是走CUDA这条路径。但换个场景——手里是非特定厂商的GPU、核显或者移动GPU又或者部署环境要求依赖尽量少、体积尽量小——CUDA这条路就不一定走通了。我最近在一个C项目里把PP-OCR这套开源OCR方案的完整流程包括文本检测、方向分类、文字识别端到端地搬到了Vulkan上跑GPU推理全程不碰CUDA。这篇文章就把途中最关键的事拆开讲为什么选Vulkan、Vulkan的计算模型长什么样、PP-OCR三个模型怎么落到计算着色器上、完整的C工程怎么串起来以及我实际踩过的那些坑。这套路适合有C基础、想理解GPU推理底层运行时的人也适合需要跨厂商部署模型的场景。如果你只是想快速调用现成OCR接口这篇确实不是最优路径但如果你想把推理真正握在自己手里它应该对你有用。1. 为什么用Vulkan绕开CUDA锁定后的算力选择1.1 传统GPU计算路径的隐藏门槛一个模型要跑在GPU上核心就是把神经网络里的矩阵乘法、卷积这类计算交给GPU的并行单元。常用的路径是借助某套GPU计算接口配合已成型的算子库。这套方案已经非常成熟几乎所有框架都无脑支持社区资料也一大把。但用久了你会发现三个隐藏门槛。第一是硬件绑定。这套接口只认特定厂商的GPU意味着你的目标设备必须装这家厂商的卡。第二是驱动兼容。部署到别的机器上需要匹配特定版本的驱动这在实际交付时非常容易翻车。我见过有人在开发机上跑得好好的换一台机器就莫名报错。第三是部署体积。一个简单的推理程序打完包带上数十个依赖库体积轻松上几百兆。这些门槛对纯个人项目还好一旦涉及跨平台交付或者嵌入式设备就会非常难受。所以当时我给自己定了个硬约束不依赖CUDA用Vulkan把推理跑通。1.2 Vulkan做计算的三个底子Vulkan常被当成图形API但它同时也是一个完整的GPU计算接口。很多人的误解在于“Vulkan是画游戏的不适合做通用计算”其实它底子非常合适。第一是队列体系。Vulkan把GPU的工作抽象成队列计算任务可以走专用的计算队列。计算队列和渲染队列是分开的所以你完全不需要初始化任何窗口系统纯粹拿Vulkan做计算是合法的。第二是存储模型。Vulkan有存储缓冲和存储图像本质上就是把显存暴露给你允许自定义张量的排布方式。第三是执行模型。Vulkan用计算着色器描述计算任务一次调度能拉起几十万个线程组很适合卷积这类天然并行的运算。用生活化的类比说Vulkan里的GPU就像一个大型加工车间。队列是流水线命令缓冲是今天要做的生产计划计算着色器是工人存储缓冲是仓库。你要做的就是把“计划”逐条写清楚然后按下生产线的启动按钮。这种暴露细节的设计恰好适合做神经网络推理。1.3 为什么不直接用现成推理引擎有人会问直接用现成的推理引擎不香吗香但不是所有场景都香。现成引擎的问题在于算子被封装成了黑盒你想改一个算子的实现细节在Vulkan里只要改一段GLSL在这些封闭生态里可能要绕过整个高层接口非常难受。另外很多引擎对自定义模型的支持并不好你从训练框架导出的权重经常要专门适配一圈才能跑起来。Vulkan的好处是极致的可控性。显存怎么分配、数据什么时候从CPU搬到GPU、连续算子之间要不要同步这些全部由你决定。坏处也明显手动管理一切缺少现成神经网络算子的标准实现。我当初就是抱着“先跑通一个最小卷积再往里面加算子”的心态入手的。这条路需要耐心但对理解GPU计算本质非常有帮助。2. 从Vulkan API到张量计算最小可运行思路2.1 设备与队列找到能算的那块GPUVulkan程序的第一步不是写shader而是找到一块支持计算任务的GPU。在Vulkan里物理设备代表一块显卡或核显逻辑设备是你对物理设备建立的一个使用连接。创建逻辑设备前需要查询队列族队列族决定这块GPU能干哪些活有图形队列、计算队列、传输队列等。选择计算队列的代码逻辑通常是这样遍历所有物理设备检查是否有设备提供带计算能力的队列族。注意有些环境里图形队列和计算队列是同一个队列族有的则是分开的。如果分开计算任务用纯计算队列理论上不影响图形任务但我们只做推理就不需要关心窗口渲染了。一个值得留意的地方是设备扩展。如果用到了某些额外能力比如未知精度、几何着色之外的功能需要在创建逻辑设备时显式开启。创建逻辑设备后还需要从队列族索引里获取一个VkQueue对象后续所有计算任务的提交都走这个队列。代码示例uint32_t queueFamilyIndex UINT32_MAX; for (uint32_t i 0; i queueFamilyCount; i) { if (queueFamilyProperties[i].queueFlags VK_QUEUE_COMPUTE_BIT) { queueFamilyIndex i; break; } } VkDeviceQueueCreateInfo queueInfo{}; queueInfo.sType VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; queueInfo.queueFamilyIndex queueFamilyIndex; queueInfo.queueCount 1; VkDeviceCreateInfo deviceInfo{}; deviceInfo.sType VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; deviceInfo.queueCreateInfoCount 1; deviceInfo.pQueueCreateInfos queueInfo; vkCreateDevice(physicalDevice, deviceInfo, nullptr, device); vkGetDeviceQueue(device, queueFamilyIndex, 0, computeQueue);这段代码看起来平平无奇但它决定了之后所有推理算子都在这条“计算流水线”上排队。如果设备选错或者队列族拿错后面再精彩的shader也跑不起来。2.2 计算着色器与SPIR-V算子怎么变成GPU程序神经网络里的一个卷积算子放到Vulkan里就是一个计算着色器。着色器通常用GLSL编写然后编译成SPIR-V字节码再交给Vulkan创建计算管线。GLSL对做图形的人来说很熟悉但做推理的人要习惯它的思维模式你不是在写一个串行函数而是在写一个“每个线程都执行一遍”的片上程序。一个简单的卷积着色器核心思路是这样每个线程负责计算输出张量里的一个元素。通过gl_GlobalInvocationID拿到当前的输出坐标然后循环累加输入通道、卷积核高度、卷积核宽度把乘积结果累加到accumulator最后加上偏置写入输出缓冲。#version 450 layout(local_size_x 8, local_size_y 8, local_size_z 1) in; layout(set 0, binding 0, std430) readonly buffer Input { float data[]; } inputTensor; layout(set 0, binding 1, std430) readonly buffer Weight { float data[]; } weightTensor; layout(set 0, binding 2, std430) readonly buffer Bias { float data[]; } biasTensor; layout(set 0, binding 3, std430) writeonly buffer Output { float data[]; } outputTensor; layout(push_constant) uniform Params { int inW; int inH; int inC; int outW; int outH; int outC; int kSize; int pad; int stride; } params; void main() { int ox int(gl_GlobalInvocationID.x); int oy int(gl_GlobalInvocationID.y); int oc int(gl_GlobalInvocationID.z); float acc 0.0; for (int ic 0; ic params.inC; ic) { for (int ky 0; ky params.kSize; ky) { int iy oy * params.stride - params.pad ky; if (iy 0 || iy params.inH) continue; for (int kx 0; kx params.kSize; kx) { int ix ox * params.stride - params.pad kx; if (ix 0 || ix params.inW) continue; int inIdx ((ic * params.inH iy) * params.inW ix); int wIdx (((oc * params.inC ic) * params.kSize ky) * params.kSize kx); acc inputTensor.data[inIdx] * weightTensor.data[wIdx]; } } } int outIdx ((oc * params.outH oy) * params.outW ox); outputTensor.data[outIdx] acc biasTensor.data[oc]; }写这个着色器的时候最需要注意的是从输出坐标反推输入坐标而不是从输入坐标推到输出坐标。后者会出现多个线程写同一个输出的竞争问题前者天然无竞争。这个思维是GPU算子并行化的核心。2.3 张量的显存布局和描述符绑定神经网络张量在训练框架里常用NCHW排布即通道维度在外。这种排布在GPU上做卷积时访存跨度大缓存命中率不理想。很多GPU推理选择NHWC布局即宽度维度在最里层这样同一行相邻像素在内存里连续向量化读取更方便。不过具体用哪种布局取决于你的着色器怎么写。我一开始用NCHW直接跑写起来简单性能一般后面优化时换成NHWC卷积内部的访存整齐很多。要提醒的是布局转换本身也有开销要么在预处理阶段统一转好要么在模型权重导出时直接重排不要在推理热路径里反复转。数据要从CPU传到GPU需要创建VkBuffer和对应的显存对象。如果这个缓冲只是GPU内部使用用设备本地显存就行如果最后要把识别结果拷回CPU输出缓冲就得分配成主机可见。描述符集合用来告诉GPU这些缓冲在哪里相当于给着色器传参数。描述符布局要提前定义好创建管线时引用它录制命令缓冲时再把实际的缓冲绑定上去。描述符这块是Vulkan推理里最容易绕晕的地方因为它的层级比较多描述符布局控制“有哪些绑定”描述符池负责“从哪分配”描述符集则是“具体绑定了哪些缓冲”。我建议在工程里封装一层“张量绑定”的辅助结构自动管理这些Vulkan对象不要在每个算子文件里手写重复代码。2.4 同步让数据在正确的时间出现在正确的位置同步问题是Vulkan里最反直觉的部分。CPU写完一批权重到显存GPU不一定马上看得到一个计算着色器写完输出另一个等着读它的着色器如果不加屏障可能读到旧数据。这是因为GPU执行命令是异步的CPU把命令丢进队列之后就撒手不管了命令在GPU上按提交顺序执行但不同队列之间并不天然有序。我的经验是区分两类同步。第一类是CPU和GPU之间的同步用vkQueueSubmit时的fence等待GPU完成或者用信号量做跨队列同步。第二类是GPU内部不同算子之间的依赖用管线屏障。每个算子在依赖前加上VkPipelineStageFlagBits把计算阶段设为目标阶段确保上一个算子的所有写操作完成后再开始当前算子。我最近一次踩坑就是漏了一条屏障结果卷积输出的概率图下半部分永远是上一帧的内容排查了整整一个下午。记住一句话在Vulkan里数据不会自己等你它们只会按你画的屏障规则流动。同步粒度宁可略粗先保证正确优化时再逐步缩窄同步范围。3. PP-OCR流水线拆解三个模型如何在Vulkan上推理3.1 从原图到文本框检测模型的GPU实现PP-OCR这套OCR方案一般分三段检测、方向分类、识别。检测模型负责定位出图像里的文本区域输出目标框。它的输入是缩放到固定尺寸后的图像输出是一张概率图表示每个像素属于文本的概率。拿到概率图后再做后处理包括二值化、膨胀、找轮廓、映射回原坐标最终得到文本框。检测模型在Vulkan里的算子主要是一串卷积、池化和激活函数。我实现时先把批归一化层融合进卷积权重这一步能省掉很多次GPU调度。融合公式很简单假设卷积权重是W偏置是B批归一化的均值为mean方差为var缩放系数为gamma偏移为betaepsilon为eps那么融合后的权重是W_fused W * gamma / sqrt(var eps)B_fused (B - mean) * gamma / sqrt(var eps) beta这个换算过程在CPU上完成之后GPU端只跑融合后的卷积。我的经验是权重越小、模型结构越简单融合收益越明显。检测模型里经常有几十个卷积层融合后计算链短了一大截。预处理我也放到了GPU上。用一张图像作为输入经过一个双线性插值着色器直接缩放成检测模型需要的大小同时顺手把RGB归一化。这样图像数据从磁盘解码到CPU内存后只需上传一次。后处理里的轮廓查找我留在CPU做因为DB算法涉及的连通域分析和多边形逼近在GPU上写起来太容易出边界问题性价比不高。3.2 文字需不需要转正方向分类模型方向分类把这模型简单得多它本质上是一个小分类网络。文本框裁剪出来之后缩放成一个固定尺寸模型判断这个文本是正向还是旋转了180度如果是旋转的后续把图片转正再送识别模型。这个小模型的计算量占比很低但少了它识别模型对倒置文本基本无能为力。它的Vulkan实现就是标准的卷积全连接Softmax。Softmax在GPU上写的时候有个小技巧先在每个线程组内部算出最大值再通过共享内存做跨线程组的规约最后算出概率。如果分类数只有2类直接用exp相除就行没必要搞复杂规约。我一开始上了完整的Softmax规约版本反而是杀鸡用了牛刀。3.3 序列识别与CTC解码识别模型是整条流水线里最复杂的部分。它接收归一化后的文本行图像输出一串字符序列的概率分布。结构上通常包含卷积特征提取、序列建模和CTC解码三块。卷积部分和检测模型类似真正麻烦的是序列建模部分标准的实现里可能包含双向循环结构。很多人听到Vulkan里跑循环结构就头疼其实思路和TFLite这类框架的做法类似把循环按时间步展开每个时间步作为一次计算调度前一步的输出作为后一步的输入中间加屏障保证依赖关系。这样做时间步多了调度次数会涨但胜在通用。我当时做了个小优化把循环结构替换成等价的矩阵乘法序列因为循环结构在推理时可以展开成固定的计算图每一层无非就是乘权重、加偏置、过激活函数。CTC解码我放在CPU上做。识别模型每帧会输出一个字符概率分布CTC解码负责去掉重复字符和空白字符最终拼出字符串。这段逻辑不复杂GPU并行化收益也不大CPU串行跑已经很快。3.4 整体数据流与动态shape三个模型串起来完整的数据流是这样的原始图像先进入检测模型得到概率图概率图后处理得到多个文本框每个文本框裁剪、缩放后送入方向分类模型分类结果可能纠正方向最后进入识别模型输出文本字符串。这里有个容易忽略的坑文本框数量不固定宽度也不固定意味着识别模型的输入形状是动态的。Vulkan的管线在创建时就确定了着色器里的常量参数我用的是最大宽度加上padding的策略预分配一块能放下最大输入量的显存缓冲短图往左上角填空余部分用0填充同时在着色器里用mask标记跳过padding区域。这种方案的缺点是多占了一些显存和计算量但好处是避免了频繁重建管线。实测下来只要最大宽度不是太夸张这个开销完全可以接受。如果你追求极致性能再考虑按宽度区间建多个管线实例。4. 实操实录C工程落地细节4.1 环境准备做这个项目时我只需要一套基础的Vulkan开发环境。核心组件包括Vulkan头文件、验证层、加载器以及一个能把GLSL编译成SPIR-V的工具。SDK装好后验证层在调试阶段非常重要它能直接报出同步问题和内存问题比如“你提交了命令但没加屏障”、“你访问了越界缓冲”这些错误信息比反复看黑屏高效得多。依赖库尽量保持精简。图像解码这种活我用了轻量库做JPEG/PNG读取其他全部自己处理。纯C工程加少量第三方依赖最终交付时不需要对方装一堆运行时部署轻松很多。这里提醒一句如果你要跑的机器驱动特别旧建议在程序启动时查询一下设备支持的Vulkan版本和扩展。别假设所有设备都支持最新版本。4.2 权重导出与转换模型文件里存的是标准网络结构的权重不可能直接丢给Vulkan shader用。我写了一个转换脚本把权重逐层导出成自定义的二进制格式。每种算子一段数据头部记录算子名字、权重维度、布局排列后面的字节就是实实在在的float数据。这个格式简单到可以用几十行代码读出来调试时也可以用十六进制工具核对。转换时要做几件事。第一把批归一化层融合到卷积层里。第二按目标shader期望的排布重新整理权重如果是NHWC布局卷积核的索引顺序也要跟着换。第三决定要不要转成半精度。半精度能让显存占用和带宽直接减半代价是某些层的精度下降。我的做法是卷积层和全连接层用半精度最后的归一化操作和输出层保持单精度这个组合在OCR任务上效果最稳。二进制权重文件可以使用内存映射方式加载避免一次性malloc大块内存再拷贝。测试的时候打印一下头部信息和导出脚本里的尺寸核对一遍基本不会错。这个环节最容易犯的错是索引顺序不一致我建议在转换脚本和C加载代码里各写一个针对小尺寸矩阵的单元测试确保排布相同再往下走。4.3 推理主循环整个推理主循环的结构其实很清晰读取图片上传并预处理跑检测后处理跑方向分类跑识别输出结果。在代码层面就是按顺序往命令缓冲里记录多个调度最后提交一次。我用一个命令缓冲把三个模型的计算任务全部记录下来。每个模型内部多个算子之间用管线屏障做依赖模型之间也用屏障隔开。好处是CPU只需要调用一次提交GPU就在内部连续执行完整个推理省掉了多次提交的开销和CPU空等时间。关键代码结构类似这样vkResetCommandBuffer(cmd, 0); VkCommandBufferBeginInfo beginInfo{}; beginInfo.sType VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; vkBeginCommandBuffer(cmd, beginInfo); recordPreprocess(cmd); // 图像缩放归一化 recordBarrier(cmd); recordDetectModel(cmd); // 检测模型的多个卷积调度 recordBarrier(cmd); recordPostProcess(cmd); // 二值化/膨胀等在shader里做的部分 recordBarrier(cmd); recordClsModel(cmd); // 方向分类 recordBarrier(cmd); recordRecModel(cmd); // 识别模型 vkEndCommandBuffer(cmd); VkSubmitInfo submitInfo{}; submitInfo.commandBufferCount 1; submitInfo.pCommandBuffers cmd; vkQueueSubmit(computeQueue, 1, submitInfo, fence); vkWaitForFences(device, 1, fence, VK_TRUE, UINT64_MAX);有个细节要注意方向分类和识别模型依赖检测模型的输出框这些框在GPU端算完之后需要回读。我在检测后处理阶段加了一个回读用一个主机可见的输出缓冲把调节后的坐标数组拷回CPU。代价是多一次PCIe传输但换来的是流程简单清晰。后续做性能优化时才把裁剪图片搬到GPU端完成。4.4 调优实测记录我最初只求跑通性能非常难看一趟完整推理在普通核显上接近秒级。后来调优主要围绕三个方向。第一是减少算子调度次数。把多个连续的小算子合并成一个着色器。比如卷积加ReLU加池化完全可以写进一个着色器的不同阶段或者至少把ReLU融到卷积的写回阶段省一次遍历。第二是调整线程组大小。不同GPU的最佳线程组大小不一样我实测在核显上local_size设为8×8比16×16更快因为在核显上过大的线程组反而导致寄存器压力增加。第三是共享显存复用。上一个算子用完的大缓冲下一个算子如果是类似的输入输出尺寸就直接复用不要反复释放再分配。优化前后的区别我整理成了表优化方向改动前改动后实际效果算子融合卷积、激活、池化分开调度合入一个着色器调度次数减少近一半线程组大小全局固定16×16按算子和设备动态调整综合耗时降低约30%显存复用每个算子独立分配大块缓冲池按需分割显存峰值明显下降调优时不要凭感觉一项项试建议先加一个计时工具逐段统计每个模型、每个算子的耗时。我就踩过这个坑花了很长时间优化某个卷积结果实际瓶颈在预处理回读上完全不需要优化。5. 常见问题与排查技巧5.1 从头排查一个算子的正确性输出结果不对时最忌讳直接去改下一个算子。GPU程序的问题有放大效应一个算子的错误会顺着数据流污染后面所有结果。我习惯的做法是写一个极小的网络来验证。比如先传一个全1的张量算一个3×3卷积预期输出可以手算出来如果对不上再逐行检查着色器的坐标映射。还有一个有效技巧输出调试通道。给着色器加一个调试模式把某个中间结果直接写到一块主机可见的缓冲里用CPU打印出来和PyTorch或NumPy的结果对比。对比时注意索引顺序先转成NCHW再看值。很多时候你以为是数值问题其实是布局转置导致的输出看起来乱七八糟但转置一下马上全部对齐。5.2 性能上不去的三个隐藏瓶颈第一频繁同步。每个算子都提交一次队列并等fenceGPU利用率会低到可怜。合并提交能立竿见影。第二小缓冲区频繁分配。Vulkan的缓冲分配不是零开销尤其驱动层还有额外管理成本。为一个临时量分配一块小显存可能比算子本身还贵。第三无意义的CPU-GPU来回拷贝。检测结果回读是必要的但中间特征图回读就完全没必要尽量让数据留在GPU端多走几步。我自己遇到过最典型的现象单看每个算子的耗时都不高但整段推理就是慢。后来发现每次dispatch之间都插了全管线屏障GPU被强制串行化了。后来改为只在真正有依赖关系的地方放窄范围屏障整体耗时就下来了。5.3 数值精度对不齐半精度模型的精度问题最麻烦。尤其图像归一化用的是mean和std这些值如果在CPU上算好再放进GPU精度损失不大。真正容易出问题的是某些层的中间结果数值很集中一旦低于半精度的最小精度梯度信息会消失结果就变成错误值。一个排查技巧是把所有能merge的归一化参数提前在CPU上算好shader里只做乘和加。如果在中间某一层发现结果从NaN开始那大概率是遇到了半精度溢出把该层换回单精度即可。批归一化融合时也要留意eps的符号不同框架实现里eps可能放在开方里还是开方外这会影响微小的数值结果。5.4 显存与内存占用优化推理时显存占用大头是中间特征图尤其检测模型的多个卷积层输出分辨率都不小。我做了个简单的“显存复用池”把每个算子的输出尺寸和生命周期算一遍凡是不重叠的分配都落到同一块显存里。这个思路很像C里的内存池实现起来不复杂收益却很直接。如果模型权重已经转成半精度但显存还是吃紧看看是不是某些输入图像没有按最大长度截断。OCR识别模型的宽度可以设一个上限超出上限的文本行做压缩就不会出现极端情况下一整块超大显存被临时占满的问题。最后别忘了在C侧定期清理任务结束后不再使用的Vulkan句柄小泄漏在长时间服务进程里会积累成不可忽视的内存膨胀。我个人的体会是第一次在Vulkan上完整跑通PP-OCR时其实没有太多兴奋感更多是一种“原来GPU计算是这样被组织起来”的顿悟。整个过程中被迫理解了同步、布局、调度这些在高层推理框架里被完全掩盖起来的东西。如果你也想把某个神经网络端到端地搬到Vulkan上我的建议很朴素先拿一个最小网络跑通全链路再逐步往里面加算子、加深层数这是最不容易被劝退的路线。等这条路走顺了后面再想接模型量化、算子自动融合就有了夯实的底子。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑