这块cix p1开发板在我桌上躺了快一个月,真正让我产生兴趣的是上周末把它刷上OpenHarmony 6.0r之后,把Mali G720的Vulkan计算能力完整跑起来,最终在一个很小的端侧模型上完成了完整的推理闭环。坦白说,整个过程没有想象那么顺利,驱动适配、内存模型、同步机制,每一个环节都有坑。这篇东西我不打算写成文档式教程,而是按实际动手顺序把思路、代码骨架、测试结果和踩过的坑都摊开来说。对想了解OpenHarmony设备端GPU计算,或者打算在Mali系列GPU上做推理的开发者,应该有点参考价值。
先说结论:这套方案在技术上是走得通的。OpenHarmony 6.0r的图形栈对Vulkan支持得很完整,Mali G720本身又是一块支持Vulkan 1.3的现代GPU,两者凑在一起做端侧推理,比起传统的CPU跑NCNN、或者等厂商专用SDK,反而多了一条更通用、更可控的路。
1. 项目定位与方案选型:为什么是Vulkan而不是其他
1.1 cix p1这块板子的硬件底子
我手上这块cix p1并不是那种动不动标称几十TOPs的旗舰AI盒子,它的定位更接近一块低功耗的轻量级开发板。整颗SoC集成了ARM架构的CPU、一颗Mali G720 GPU,还有配套的内存和存储接口。从系统层面看,它跑的是OpenHarmony 6.0r,不是安卓也不是Linux,所以很多安卓上现成的推理方案没法直接搬过来用。
这颗Mali G720在GPU家族里算比较新的,对应ARM Valhall架构的迭代版本。它支持Vulkan 1.3、支持FP16运算,硬件上对计算着色器、存储buffer、图像采样这套流程都有很好的调度支持。对端侧推理来说,最核心的其实是它的统一内存模型:GPU和CPU看到的是同一片物理内存,省掉了大量拷贝,这一点在后面会反复提到。
板子的存储和接口也比较克制,常规的USB、HDMI、串口、以太网都有,跑推理实验完全够用,但别指望像大型服务器一样堆内存带宽。这种平台适合的落地场景是智能摄像头、边缘网关、小型工控设备,模型规模一般控制在几MB到几十MB,单次推理延迟在几十毫秒级别。
1.2 方案对比:Vulkan / OpenCL / 专用推理SDK
同样的端侧推理需求,摆在面前的底层方案其实有好几条,我列了个表,方便直观感受一下差异:
| 方案 | 优点 | 痛点 | 适用性 |
|---|---|---|---|
| Vulkan | 跨平台、Mali G720原生支持、OpenHarmony自带loader、可精细控制调度与内存 | 上手门槛高,所有运算得自己拆成compute shader | 推荐选这个 |
| OpenCL | 桌面端非常成熟的并行计算接口 | OpenHarmony上几乎没有可用的ICD实现,Mali驱动重心早已转向Vulkan | 不推荐 |
| 厂商专用SDK | 针对NPU/DSP优化,跑自家模型很快 | 绑定特定SoC,模型格式封闭,自定义算子非常痛苦 | 视平台支持情况而定 |
为什么最终落到Vulkan?一个很现实的原因是OpenHarmony 6.0r的图形栈本身就建立在Vulkan之上。系统自带的图形栈和驱动已经把这个loader链打通了,你不需要额外移植一堆依赖库,直接在用户态创建一个VkDevice就能访问GPU计算能力。这是最稳妥的路径,也算是一种“顺着系统已有的能力走”的思路。
OpenCL在移动端和嵌入式平台的处境比较尴尬。Mali的OpenCL驱动不是没有,但在OpenHarmony这种非安卓系统上,ICD的加载、扩展支持、版本一致性都很难保证。与其花时间搞一个随时可能崩的OpenCL环境,不如把精力集中在Vulkan上,毕竟它也是ARM官方在Mali上主推的开放标准。
专用SDK就更不用说了,它强依赖厂商对某个NPU或者DSP的私有接口。cix p1这类板子就算有AI加速单元,它的SDK也不一定对OpenHarmony提供完整适配。而Vulkan是开放的,shader自己写,数据布局自己定,底层的计算资源自己调度,所有逻辑都可以把控,出了问题也知道从哪里查起。
1.3 在OpenHarmony上做的意义
很多人会问:费这么大劲,在OpenHarmony上跑GPU推理到底图什么?
答案是这套东西不依赖某一个厂商的私有生态,代码可以跨设备复用。OpenHarmony本身面向大量轻量设备和物联网终端,这些设备很可能都集成Mali或类似的GPU核心。如果能在Vulkan层面把推理跑通,后续换一块板子、换一个芯片,只需要解决驱动和平台初始化差异,计算管线的主体逻辑可以原样搬过去。
从技术演进角度看,这相当于把传统上属于PC端、移动端的通用GPU计算能力,沉淀到一套新兴的操作系统生态里。端侧推理不一定要靠NPU,通用GPU在中小模型上的表现完全够用,而且灵活性高得多。这个意义不是跑通一个demo能体现的,更多是在为后续接入更多模型、更多场景打地基。
2. 环境搭建:OpenHarmony 6.0r系统与Mali G720驱动确认
2.1 系统刷入与基础工具链
先说刷机。OpenHarmony 6.0r的镜像面向不同开发板都有对应的编译产物或者发行包,cix p1算是适配比较积极的平台。官方文档提供了整包升级工具,烧录过程其实不复杂:先用USB线把板子切到烧录模式,再通过升级工具把镜像推进去,等它自动重启。整个过程大概几分钟,串口能看到烧录日志,状态比较透明。
刷完之后先别急着装任何推理框架,第一步是用串口登录系统,确认内核、GPU驱动、Vulkan加载库这三层是否就位。这一步非常关键,我见过太多人还没确认GPU节点存在就开始写代码,最后瓶颈其实在驱动没起来。
基础的开发工具建议直接装在Ubuntu主机上,交叉编译链、Vulkan SDK、glslangValidator这些放在主机更舒服。板子上的OpenHarmony系统保持干净,只放运行文件和必要的动态库,别把系统搞得一团糟,后面排查问题会轻松很多。
2.2 确认GPU节点与Vulkan能力
登录系统后,先看设备节点是否正常。Mali驱动通常会在/dev目录暴露节点,比如mali相关设备,同时图形栈目录下会有libGLES_mali.so或者libvulkan.so这一类动态库。我遇到过最典型的失败是节点存在、库也存在,但是Vulkan的API入口加载失败,这个时候多半是文件权限或者库路径没有配置到系统默认搜索路径里。
建议直接跑vulkaninfo。这个工具的实用程度远超你的想象,它会一次性告诉你Vulkan loader版本、驱动版本、GPU名称、支持的特性、队列族信息、内存堆属性。在cix p1上执行后,输出中应该能看到类似“Mali-G720”的设备名,API版本能到1.3,队列族里有graphics和compute位。
如果vulkaninfo都跑不出来,后面的所有工作都不用开展了。常见问题我整理了一张表:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 找不到驱动库 | 动态库路径没配好 | export LD_LIBRARY_PATH或检查配置文件 |
| 找不到设备节点 | 内核模块没加载 | dmesg查GPU probe日志,确认内核版本匹配 |
| vulkaninfo报VK_ERROR_INITIALIZATION_FAILED | loader和驱动版本不匹配 | 确认libvulkan与libGLES_mali来自同一镜像批次 |
| 权限不足 | 当前用户不在对应用户组 | 使用root或添加设备访问权限 |
版本匹配是最大的雷区。OpenHarmony 6.0r的镜像里,驱动、loader和图形栈是一整套一起编出来的,如果你自作聪明从别的地方单独覆盖某个so,轻则功能异常,重则直接启动黑屏。所以排查问题第一步永远是确认版本来源,而不是盲目替换文件。
2.3 交叉编译环境的准备
板子上的OpenHarmony不是标准Linux,它的动态库接口和工具链有自己的规范,但好消息是只要遵循它的工具链配置,编译C/C++代码并不算棘手。我在主机上用OpenHarmony官方SDK中的clang交叉编译器,配合Vulkan SDK的头文件,直接编出能在板子上运行的静态链接程序。
这里有个比较实用的经验:Vulkan头文件是纯C接口,不存在运行时依赖问题。你只需要在主机上把glslangValidator编译出的SPIR-V shader文件一并打包,运行时用VkShaderModule加载即可。所以交叉编译的复杂度其实被大大降低了,关键是shader的编译要在主机完成,板子上不做任何编译工作。
为了后续调试方便,我把整个项目的目录结构固定下来:shaders目录放GLSL源码和生成的SPIR-V,src目录放C++源码,assets目录放转换后的模型权重,run.sh负责把二进制和资源一起推到板子执行。这样每次迭代都走同一套流程,减少了很多低级错误。
3. Vulkan计算管线的完整搭建过程
3.1 Instance、Device与Queue的初始化
Vulkan的初始化步骤比较冗长,但逻辑并不复杂。先创建VkInstance,再枚举物理设备,选到Mali G720之后创建VkDevice,最后从设备上申请队列。Mali在OpenHarmony上通常只有一个queue family,里面同时拥有graphics和compute能力,所以申请队列时直接选择支持VK_QUEUE_COMPUTE_BIT的那个族即可。
这里不要惦记“给计算单独一个纯compute队列”这种事情,在Mali上并不存在多个队列族的物理基础。Graphic queue就是compute queue,所有命令最终都在同一个硬件队列上执行,代码层面完全无需特殊处理。过度设计反而会把同步逻辑搞复杂。
初始化完成后,建议打印一下VkPhysicalDeviceProperties里的deviceName和apiVersion,以及VkPhysicalDeviceMemoryProperties里的堆信息。Mali G720在OpenHarmony上通常会暴露一个同时支持VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT和DEVICE_LOCAL_BIT的统一内存堆,这意味着device buffer可以直接映射到CPU地址,也就是业内常说的zero-copy,这是后面做推理非常依赖的一个优势。
3.2 shader编译与Compute Pipeline创建
写一个最简单的compute shader验证管线是否打通,比一上来就写完整模型脑壳要轻松得多。我用的示例是给一个float数组做逐元素加法,shader如下:
#version 450 layout(local_size_x = 256) in; layout(set = 0, binding = 0) buffer InputBuffer { float data[]; } inputBuf; layout(set = 0, binding = 1) buffer OutputBuffer { float data[]; } outputBuf; void main() { uint index = gl_GlobalInvocationID.x; outputBuf.data[index] = inputBuf.data[index] + 1.0; }编译命令用主机上的glslangValidator:
glslangValidator -V add.comp -o add.comp.spv拿到SPIR-V之后,在C++里创建VkShaderModule,然后定义descriptor set layout、pipeline layout、compute pipeline。关键点在于layout的binding要和shader里的set/binding对齐,一个错位就会导致shader读到错误数据甚至VK_ERROR_LAYOUT_MISMATCH。做完这套基础流程之后,再复杂的模型也是在这个骨架上不断加内容。
3.3 内存分配与数据搬运
Vulkan不给你现成的malloc,所有buffer都得自己创建。最基础的一段代码逻辑是:先查询内存属性,找到hostVisible和deviceLocal都满足的堆,然后创建VkBuffer并用vkBindBufferMemory绑定,最后vkMapMemory得到一个指针。
由于Mali的统一内存模型,device buffer的mapped指针就是CPU可以直接读写的地址。这意味着神经网络推理里的输入、输出、中间张量都可以直接分配为这种类型,不需要另外准备staging buffer。这点和桌面独立显卡完全不同,桌面显存的host访问要走PCIe拷贝,而这套板子直接省掉了这条链路,实测对推理延迟的影响非常大。
不过要留意,如果选择VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,CPU写入后GPU无需flush就能看到正确数据。如果不选这个,CPU写入后必须调用vkFlushMappedMemoryRanges,GPU写完且CPU需要读取时还得调vkInvalidateMappedMemoryRanges,忘掉任何一个都会出现灵异数据。所以在这个平台上我强烈建议直接用coherent内存,虽然名义上可能比非coherent慢一点,但换来的是绝对稳定的可见性,这对推理框架的健壮性至关重要。
4. 接入端侧推理:从模型到compute shader
4.1 模型导出与量化准备
端侧推理不会直接拿PyTorch的权重上板子,体积和计算量都受不了。我的流程是先用PyTorch导出ONNX,然后在主机上做一个离线转换工具,把模型结构解析成自定义的二进制描述文件,把权重按shader访问友好的布局排列好,最后对权重做量化。
量化是端侧推理绕不开的一环。Mali G720在Vulkan里保存FP16很自然,硬件对FP16运算有优化。INT8量化在一些算子组合下也可以做,但需要仔细处理scale和zero point,否则精度掉得很快。考虑到第一次跑通的目标,我选择FP16起步,把权重从FP32转成FP16,体积立刻减半,带宽需求也减半,这在小带宽设备上收益非常明显。
离线转换工具要有几个输出:一个是描述算子拓扑和参数的结构文件,一个是按层排列的权重二进制,一个是每个算子的输入输出形状。转换完成之后,板子上的推理程序只需要加载这几个文件,不需要任何运行时解析,效率比直接跑ONNX Runtime高不少。
4.2 算子到compute shader的映射
模型里的主要计算无非是卷积、全连接、激活、池化、归一化这一类。卷积是整个端侧推理的大头,也是最值得优化的部分。我采用的方案是把卷积展开成矩阵乘法,再用tile分块的方式写GEMM shader。具体来说,每个线程组负责一个16x16的输出tile,每个线程负责其中若干个元素的计算,数据从buffer里按tile读取。
这里必须提醒一下:Mali GPU架构和PC显卡不一样,不能照搬图像处理或者CUDA的编程思路。尤其不要过度依赖local size很大的group以及共享内存,Mali的local memory在驱动实现的语义下并不一定对应物理上的独立存储,乱用反而可能让驱动做额外搬移,性能更差。合理做法是尽量把访问模式设计成顺序的、可预取的数据流,比如让一个vec4作为最小读取单元。
算子融合在推理里非常关键。最简单的融合是卷积后面的ReLU,可以直接在GEMM shader的输出阶段加一句max(0.0, value)。再进一步可以把BatchNorm的µ、σ、γ、β全部折叠到卷积权重和偏置里,这一步在离线转换阶段做掉,shader里就根本不需要体现BN层,计算量和内存访问都能降下来。算子融合做得好不好,往往比单算子优化更影响整体延迟。
4.3 推理流程编排与同步
推理流程本质上就是一组compute shader的排队执行。每个算子的输入来自上一个算子的输出,所以中间张量必须复用同一块显存,不能每层都新建buffer,否则分配开销会把GPU执行时间完全淹没。我的做法是给所有中间张量做一个buffer池,按形状和生命周期复用。
Command buffer的录制逻辑是核心。整个推理走一次vkBeginCommandBuffer,然后按拓扑顺序把每个算子的dispatch命令填进去,两个相邻算子之间用vkCmdPipelineBarrier做buffer memory依赖同步,结束之后vkEndCommandBuffer,最后提交到queue并等待完成。
同步上有两个选择:一是在每层之间QueueSubmit并WaitIdle,二是只提交一次然后等最终fence。前者代码简单但性能惨不忍睹,因为每一步都在让GPU停顿,我第一版就是这么写的,延迟直接翻了好几倍。正确做法是全部dispatch到同一个command buffer,最后只等一次,让驱动有充足的调度空间去重叠执行。
这里贴一段录制命令的伪代码结构:
vkBeginCommandBuffer(cmd); for (auto& op : modelOps) { if (op.hasBarrier) { vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &bufferBarrier); } vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, op.pipeline); vkCmdBindDescriptorSets(cmd, ..., op.descSet, ...); vkCmdDispatch(cmd, op.groupX, op.groupY, op.groupZ); } vkEndCommandBuffer(cmd); vkQueueSubmit(queue, &submitInfo, fence); vkWaitForFences(queue, 1, &fence, VK_TRUE, timeout);这段骨架一旦稳定,后续接入新模型只是往modelOps里堆算子和参数,管线部分基本不用改。
5. 性能优化与踩坑实录
5.1 同步与队列使用的几个大坑
第一版程序跑通之后,我第一时间测了端到端延迟,结果比预期慢不少。逐个定位后发现问题几乎都出在同步策略上。最典型的一个错误是每层dispatch完立刻QueueSubmit并WaitIdle,这会让GPU在每个算子之间都被强制同步一次,大量的时间浪费在等待而不是计算上。
第二个问题是过度使用vkQueueWaitIdle。有些时候我只是想让CPU能拿到中间结果做调试,顺手就调用了这个函数,结果整个流水线都停摆。调试期无所谓,但一旦进入性能优化阶段,就要严格限制在一次推理开始时等待上一帧完成,其余时间全部让GPU自己排队。
第三个问题是Barrier的stage设置。我在Mali上遇到过一次很奇怪的现象:看起来数据不对,但不是每次都不对,偶尔能跑出正确结果,偶尔跑错。最后查出来的原因是barrier只做了buffer内存依赖,但没有把stage设置成COMPUTE_SHADER_BIT,导致驱动认为资源还在更早的stage里使用,提前让后续dispatch读取。这类问题肉眼很难定位,最好在一开始就把访问阶段定义清楚。
5.2 带宽瓶颈与中间张量搬运
做性能profiling的时候发现,这个平台的计算能力其实不是主要瓶颈,带宽才是。小模型的特征图往往比权重还大,每一层都在反复读写中间张量。如果中间结果在shader里被多次读取,或者非对齐访问太多,带宽压力立刻上来。
优化的核心策略是减少中间张量的生命周期,能融合的算子绝对不拆开。比如一个ResNet基本块里的三次卷积中间结果,如果缓存策略不好,一次推理可能额外多出几十MB的读写流量。把这些中间张量放进复用池,并且让卷积shader直接输出到下一层需要的布局,最终端到端延迟能下降30%到40%。
还有一个容易被忽略的点:输入图像或者特征图的channel排列。不要动不动就把数据展开成NHWC,Mali上往往NC4HW4这种四通道分组的布局对缓存更友好,因为一个vec4正好承载四个通道的值,读取效率高很多。这个布局调整要在离线转换阶段就完成,运行时转换的成本也不低。
5.3 调试技巧与常见错误速查
在板子上调试Vulkan compute shader的体验比PC上差很多,RenderDoc这种图形调试器在OpenHarmony上基本连不上。所以我的做法是先把shader逻辑在主机上用软件渲染或者简单打印验证,确保数学逻辑没问题,再上板子。另一个土办法是写一个debug shader,把中间结果原样拷贝回CPU,然后和主机端输出逐元素比对,虽然笨,但能精确到算子的定位问题。
我自己整理了一份常见错误速查表,每次遇到问题先看它,能省不少事:
| 错误现象 | 原因 | 处理方式 |
|---|---|---|
| 运行时报VK_ERROR_OUT_OF_DEVICE_MEMORY | 中间buffer没有复用,或者buffer分配过大 | 检查内存池设计,统一管理所有张量 |
| shader读到全零或旧数据 | barrier缺失或stage设置错误 | 检查相邻算子的buffer依赖和stage |
| 某些shape下crash | group size和总工作项不整除,越界 | 在shader里加边界判断,或者调整dispatch数 |
| 性能不升反降 | 太多WaitIdle,或local_size设置过小 | 一次性提交,合理加大local_size |
| 模型输出精度偏差大 | FP16溢出或INT8量化丢失 | 逐算子对比中间结果,调整量化策略 |
5.4 一个实际的优化前后对比
拿一个外形很小的分类模型举例,模型结构只有三层卷积和两个全连接,参数量不到2MB。初始版本在cix p1上的单次推理延迟大约120ms,主要时间都耗在频繁WaitIdle和中间张量重复分配上。
经过三轮优化之后,延迟降到了70ms以内。第一轮改成单次提交、单fence等待,立刻降到90ms左右。第二轮对中间张量做复用池和算子融合,降到75ms左右。第三轮把数据布局改成四通道分组、并调整了tile大小,最终稳定在65ms上下。对一个没有NPU、仅靠Mali G720通用计算能力的中低端平台来说,这个水平完全可以接受。
优化过程中最容易犯的错是陷入单算子微观调优,比如为一个加法算子试图省两条指令。实际经验是,先从宏观调度找问题,WaitIdle、内存重复分配、算子拆分这三大问题解决掉,性能往往能直接翻倍,这比在shader里抠指令划算得多。
6. 一些可以复用的经验和后续计划
6.1 动手前一定要确认版本匹配
整个项目做下来,我感受最深的一点是不要在环境不确认的情况下贸然写代码。OpenHarmony版本、驱动版本、loader版本这三个必须锁定同一套镜像来源。cix p1和Mali G720的Vulkan支持本身很可靠,但如果你刷了一版系统和另一套驱动库,很多莫名其妙的问题会消耗大量时间。
具体操作上,我会把vulkaninfo的输出存成基线文件,任何改动之后都重新对比一遍。如果设备名、API版本、内存堆数量发生变化,先停下排查原因再继续。这个小习惯帮我避开了好几次花大半天调一个根本不存在的bug。
6.2 下一步可以怎么扩展
这条路走通之后,可扩展的方向不少。一个是接入更完整的推理框架接口,把上层算子调度和底层Vulkan compute解耦,这样模型侧可以继续用标准格式,底层计算自动落到Mali G720。另一个是加入异步推理机制,把视频流的解码和推理放到不同线程,用多个fence管理流水,吞吐量会更适合真实场景。
还可以考虑把Vulkan和系统图形栈结合起来,让推理结果直接用于渲染,比如在显示链路里做实时像素处理,这个方向在智能座舱和交互设备上很有实用空间。端侧推理的形态本来就不只有“跑一个模型”这一种,把它放进整条系统链路才算真正落地。
最后说一点实际体会:如果你也打算在OpenHarmony这类非安卓/非Linux通用系统上做GPU计算,不要因为Vulkan初始化代码多就绕道。它把你和硬件之间的细节都摆到了明面上,只要你愿意花两天时间把最小管线跑通,之后所有优化都变得有理有据。相比之下,那些看似开箱即用的专用方案,反而可能让你在遇到性能问题时两眼一抹黑。这套底层能力掌握了,后续无论适配什么设备、什么模型,心里都有底。