ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RK3588 OpenCL硬件加速实战:视频处理性能对比与优化

RK3588 OpenCL硬件加速实战:视频处理性能对比与优化 RK3588这颗芯片在嵌入式圈子里火了好几年8核CPU加6TOPS NPU的配置让它在边缘计算、视频处理、AI推理这些场景里出镜率极高。但很多人拿到板子之后跑视频编解码或者图像预处理的时候第一反应都是用CPU硬扛结果发现帧率上不去、CPU占用率飙到80%以上风扇呼呼转。其实RK3588里面藏着一颗Mali-G610 MP4 GPU通过OpenCL把它调用起来做并行计算很多场景下性能提升是立竿见影的。这篇内容就是围绕我在RK3588上做视频处理时对比OpenCL硬件加速和纯CPU方案的实际测试过程来展开的包括环境搭建、代码实现、性能数据、踩坑记录以及什么场景该用GPU、什么场景CPU反而更合适。1. 为什么要在RK3588上折腾OpenCL1.1 纯CPU做视频处理的瓶颈在哪里先说说我一开始的做法。拿到RK3588开发板之后需要做一个视频预处理的管线主要包括图像缩放、颜色空间转换比如NV12转RGB、以及一些简单的滤波操作。最直觉的做法就是用CPU跑毕竟8个核心4个A76大核加4个A55小核主频最高2.4GHz看起来算力不差。实际跑下来发现问题了。以1080p分辨率的NV12转RGB为例单帧处理时间在CPU上大约是8到12毫秒看起来还能接受。但问题是视频是连续的30fps意味着每帧只有33毫秒的预算光一个颜色空间转换就吃掉了三分之一。再加上缩放和滤波CPU占用率直接拉到70%以上而且大核全程跑满功耗和温度都上去了。更关键的是CPU在做这种像素级并行操作的时候效率其实很低。每个像素的运算都是独立的但CPU的架构决定了它更擅长处理逻辑复杂的串行任务而不是这种暴力并行的活。你有8个核但每个核一次也就处理几个像素大量的时间花在循环控制和内存访问上了。1.2 OpenCL能带来什么改变OpenCL的全称是Open Computing Language翻译过来叫开放计算语言。它的核心思路是把那些可以并行执行的计算任务丢给GPU或者其它加速器去跑。GPU和CPU最大的区别在于CPU有少量但非常强大的核心适合处理复杂逻辑GPU有大量但相对简单的核心适合处理每个像素做同样操作这种任务。RK3588上的Mali-G610 MP4有4个执行单元每个执行单元里面有128个ALU算术逻辑单元总共512个计算单元可以同时工作。虽然每个单元的能力不如CPU核心但架不住数量多。做像素级并行运算的时候这种架构的优势就体现出来了。打个比方CPU就像是一个博士团队每个人都很聪明能解决复杂问题但人少GPU就像是一群小学生每个人只会做简单的加减乘除但有一千个人同时算总吞吐量反而更高。视频处理里的颜色空间转换、缩放、滤波恰好就是简单加减乘除类型的任务。1.3 什么场景适合用OpenCL加速不是所有任务都适合丢给GPU。我总结了几条判断标准数据并行度高每个像素或每个数据点的处理逻辑相同互不依赖。颜色空间转换、缩放、卷积滤波、直方图统计都属于这类。计算密度适中不是纯粹的内存拷贝有一定的计算量。如果只是搬数据GPU的优势发挥不出来。数据量足够大单帧1080p有200多万像素数据量够大能摊薄数据传输的开销。如果只是处理几十个像素传输时间比计算时间还长。实时性要求高需要在有限时间内处理完大量数据CPU扛不住的时候。反过来如果任务逻辑复杂、分支多、数据依赖强比如视频编码里的运动估计搜索那CPU或者专用的VPU反而更合适。2. 环境搭建从零把OpenCL跑起来2.1 系统准备与依赖安装我用的系统是RK3588官方的Ubuntu 22.04镜像内核版本5.10。如果你用的是Android或者Debian思路类似但包管理命令需要调整。首先确认GPU驱动是否正常加载ls /dev/mali* # 应该能看到 /dev/mali0如果没有这个设备节点说明Mali驱动没加载需要检查内核配置或者重新烧录带GPU驱动的固件。接下来安装OpenCL相关的库sudo apt update sudo apt install ocl-icd-opencl-dev opencl-headers clinfo这里解释一下这几个包的作用。ocl-icd-opencl-dev是OpenCL的ICD加载器它负责在运行时找到系统里可用的OpenCL实现opencl-headers提供编译时需要的头文件clinfo是一个诊断工具用来查看系统里有哪些OpenCL平台和设备。安装完成后运行clinfo检查clinfo | head -40正常的话应该能看到类似这样的输出Platform Name: ARM Platform Number of devices: 1 Device Name: Mali-G610 Device Type: GPU Max Compute Units: 4 Global Memory Size: 4GB如果clinfo报错说找不到平台大概率是Mali的OpenCL库没装好。RK3588的GPU驱动包里通常包含libmali.so这个库同时提供OpenGL ES、OpenCL和Vulkan的支持。你需要确认/usr/lib/aarch64-linux-gnu/下面有对应的so文件并且ICD配置文件在/etc/OpenCL/vendors/目录下。2.2 验证OpenCL设备可用性装好之后别急着写代码先用一个小程序验证设备能不能正常工作。我写了一个最简的枚举程序#include CL/cl.h #include stdio.h int main() { cl_platform_id platform; cl_device_id device; cl_uint num_platforms, num_devices; char name[128]; clGetPlatformIDs(1, platform, num_platforms); printf(Platforms found: %u\n, num_platforms); clGetDeviceIDs(platform, CL_DEVICE_TYPE_GPU, 1, device, num_devices); printf(GPU devices found: %u\n, num_devices); clGetDeviceInfo(device, CL_DEVICE_NAME, sizeof(name), name, NULL); printf(Device name: %s\n, name); return 0; }编译命令gcc -o cl_test cl_test.c -lOpenCL如果输出显示找到了Mali-G610说明环境没问题。这一步看起来简单但我第一次跑的时候卡了半天原因是系统里同时存在多个OpenCL平台比如还装了PoCL的CPU实现clGetPlatformIDs返回的第一个平台不一定是Mali。解决办法是遍历所有平台根据设备名称来筛选。2.3 交叉编译与板端部署的注意事项如果你是在x86主机上交叉编译然后部署到RK3588上运行有几个坑需要注意头文件版本要匹配主机上装的OpenCL头文件版本可能和板端的库版本不一致建议直接用板端SDK里的头文件。链接库的路径交叉编译时-lOpenCL链接的是主机的库需要指定板端sysroot里的库路径。运行时库的查找板端运行时如果报cannot open shared object file检查LD_LIBRARY_PATH是否包含了Mali库的路径。我个人的习惯是直接在板子上编译虽然RK3588的编译速度不如x86主机但省去了交叉编译环境配置的麻烦对于中小型项目来说更省心。3. 核心实现用OpenCL写一个视频帧处理管线3.1 整体架构设计我的测试管线是这样的输入是YUV NV12格式的视频帧需要做三件事——缩放到指定分辨率、NV12转RGB、做一个3x3的高斯模糊。输出是处理后的RGB帧。CPU版本的实现很直接就是三层循环嵌套逐像素处理。OpenCL版本则需要把这三个操作都写成kernel函数然后通过命令队列提交给GPU执行。整体流程分为几个阶段初始化阶段创建OpenCL上下文、命令队列、编译kernel程序。这个阶段只做一次。数据传输阶段把输入帧数据从CPU内存拷贝到GPU内存或者用零拷贝的映射方式。执行阶段依次执行缩放、颜色转换、模糊三个kernel。回读阶段把结果从GPU内存拷贝回CPU内存。这里有个关键的设计决策三个操作是分成三个kernel分别执行还是合并成一个kernel我两种都试过。分开写的好处是逻辑清晰、每个kernel可以独立优化合并的好处是减少kernel启动开销和中间数据的读写。对于1080p这个量级的数据kernel启动开销大概在几十微秒三个kernel加起来也就一百多微秒相对于整体几毫秒的处理时间来说可以忽略。所以我最终选择了分开写代码更好维护。3.2 NV12转RGB的kernel实现NV12是一种YUV的存储格式Y分量占一个平面UV分量交错存储在另一个平面。转RGB的公式是固定的R Y 1.402 * (V - 128) G Y - 0.344 * (U - 128) - 0.714 * (V - 128) B Y 1.772 * (U - 128)kernel代码大概长这样__kernel void nv12_to_rgb( __global const uchar* y_plane, __global const uchar* uv_plane, __global uchar* rgb_out, const int width, const int height) { int x get_global_id(0); int y get_global_id(1); if (x width || y height) return; int y_idx y * width x; int uv_idx (y / 2) * width (x / 2) * 2; float Y (float)y_plane[y_idx]; float U (float)uv_plane[uv_idx] - 128.0f; float V (float)uv_plane[uv_idx 1] - 128.0f; float R Y 1.402f * V; float G Y - 0.344f * U - 0.714f * V; float B Y 1.772f * U; int out_idx (y * width x) * 3; rgb_out[out_idx] (uchar)clamp(R, 0.0f, 255.0f); rgb_out[out_idx 1] (uchar)clamp(G, 0.0f, 255.0f); rgb_out[out_idx 2] (uchar)clamp(B, 0.0f, 255.0f); }这段代码有几个优化点值得说。第一UV平面的索引计算要注意NV12是2x2下采样的所以uv_idx要用(y/2)和(x/2)*2来算。第二用float而不是int做中间计算避免精度损失。第三clamp函数确保结果在0到255之间防止溢出。3.3 双线性缩放kernel的写法缩放用的是双线性插值比最近邻插值效果好很多但计算量也大一些。核心思路是对于目标图像的每个像素找到它在源图像中对应的浮点坐标然后取周围四个像素做加权平均。__kernel void bilinear_scale( __global const uchar* src, __global uchar* dst, const int src_w, const int src_h, const int dst_w, const int dst_h) { int dx get_global_id(0); int dy get_global_id(1); if (dx dst_w || dy dst_h) return; float scale_x (float)src_w / dst_w; float scale_y (float)src_h / dst_h; float sx (dx 0.5f) * scale_x - 0.5f; float sy (dy 0.5f) * scale_y - 0.5f; int x0 (int)floor(sx); int y0 (int)floor(sy); int x1 min(x0 1, src_w - 1); int y1 min(y0 1, src_h - 1); x0 max(x0, 0); y0 max(y0, 0); float fx sx - x0; float fy sy - y0; float v00 src[y0 * src_w x0]; float v01 src[y0 * src_w x1]; float v10 src[y1 * src_w x0]; float v11 src[y1 * src_w x1]; float result v00 * (1 - fx) * (1 - fy) v01 * fx * (1 - fy) v10 * (1 - fx) * fy v11 * fx * fy; dst[dy * dst_w dx] (uchar)clamp(result, 0.0f, 255.0f); }这里有个细节sx和sy的计算用了(dx 0.5f) * scale_x - 0.5f这是为了对齐像素中心。如果直接用dx * scale_x图像会有半个像素的偏移放大倍数大的时候肉眼能看出来。3.4 主机端代码的组织方式主机端的代码主要负责管理OpenCL的资源和调度。我把它分成了几个模块CLContext类封装平台、设备、上下文、命令队列的创建和销毁。CLProgram类负责加载kernel源码、编译、创建kernel对象。CLBuffer类封装设备内存的分配、数据上传和回读。这种封装方式的好处是主流程代码看起来很清楚CLContext ctx; CLProgram prog(ctx, kernels.cl); CLBuffer y_buf(ctx, CL_MEM_READ_ONLY, y_size); CLBuffer uv_buf(ctx, CL_MEM_READ_ONLY, uv_size); CLBuffer rgb_buf(ctx, CL_MEM_WRITE_ONLY, rgb_size); y_buf.upload(y_data); uv_buf.upload(uv_data); prog.setArg(nv12_to_rgb, 0, y_buf); prog.setArg(nv12_to_rgb, 1, uv_buf); prog.setArg(nv12_to_rgb, 2, rgb_buf); prog.setArg(nv12_to_rgb, 3, width); prog.setArg(nv12_to_rgb, 4, height); size_t global_size[2] {width, height}; clEnqueueNDRangeKernel(queue, kernel, 2, NULL, global_size, NULL, 0, NULL, NULL); rgb_buf.download(rgb_data);实际项目中我会把buffer的创建放在初始化阶段避免每帧都重新分配内存。OpenCL的内存分配是有开销的频繁分配释放会拖慢整体性能。4. 实测数据OpenCL到底快了多少4.1 测试环境与测试方法测试平台是RK3588开发板8GB内存版本系统是Ubuntu 22.04内核5.10。CPU频率锁定在大核2.4GHz避免调频影响测试结果。GPU频率也做了锁定。测试内容是处理1000帧1080p的NV12图像分别用CPU和OpenCL跑完整的管线缩放颜色转换模糊记录总耗时和平均每帧耗时。每种方案跑5次取平均值去掉第一次的预热数据。CPU版本用的是单线程和多线程两种实现。多线程用OpenMP线程数设为8。4.2 各阶段耗时对比先看单个操作的对比数据操作CPU单线程CPU多线程(8核)OpenCL(GPU)NV12转RGB (1080p)9.8ms1.6ms0.9ms双线性缩放 (1080p→720p)6.2ms1.1ms0.6ms3x3高斯模糊 (1080p)12.4ms2.0ms1.1ms完整管线28.4ms4.7ms2.6ms这个数据挺有意思的。OpenCL相比CPU单线程快了大约10倍但相比8核多线程只快了不到2倍。这说明RK3588的A76大核性能确实不错8个核一起上的时候算力并不弱。但要注意的是CPU多线程跑到4.7ms的时候8个核心基本都在满负荷运转功耗和温度都上去了。而OpenCL方案CPU占用率不到15%大核基本处于空闲状态功耗低很多。如果你的系统还需要CPU同时处理其它任务比如网络通信、业务逻辑那OpenCL方案的优势就更明显了。4.3 数据传输开销的真实影响上面的测试数据是纯计算时间没有算数据传输。实际使用中数据从CPU内存到GPU内存的拷贝是有开销的。我单独测了一下数据操作耗时上传一帧NV12 (1080p, ~3MB)0.8ms回读一帧RGB (1080p, ~6MB)1.5ms上传回读总计2.3ms这个开销不小。如果把传输时间算进去OpenCL方案的总耗时变成2.62.34.9ms和CPU多线程的4.7ms基本持平了。这就引出了一个关键问题数据传输可能吃掉GPU加速的全部收益。解决办法有两个。一是用零拷贝zero-copy的映射方式让GPU直接访问CPU内存省去拷贝。二是把多个处理步骤串起来中间结果留在GPU内存里只上传原始数据和回读最终结果。我试了第二种方案把缩放、颜色转换、模糊三个kernel串在一起中间数据不落回CPU内存。这样只需要上传NV12原始数据0.8ms和回读最终RGB数据1.5ms总耗时变成2.62.34.9ms...等等这和分开算是一样的因为中间数据本来就没回读过。真正省时间的是零拷贝方案。用clEnqueueMapBuffer把GPU buffer映射到CPU地址空间CPU直接往里面写数据省去了显式的拷贝操作。实测下来上传时间从0.8ms降到了0.3ms左右回读从1.5ms降到了0.6ms。这样总耗时变成2.60.93.5ms比CPU多线程快了25%左右。4.4 不同分辨率下的表现差异分辨率对性能的影响也很大。我测了720p、1080p和4K三种分辨率分辨率CPU多线程OpenCL(含传输)加速比720p2.1ms1.8ms1.17x1080p4.7ms3.5ms1.34x4K18.6ms11.2ms1.66x可以看到分辨率越高OpenCL的优势越明显。原因是数据传输的开销基本是线性的但GPU的计算优势在高分辨率下更能体现出来。720p的时候数据量不够大GPU的并行度没吃满加速效果有限。这也印证了前面说的数据量足够大才值得用GPU加速。如果你只是处理720p甚至更小的图像CPU多线程可能就够了没必要折腾OpenCL。5. 踩坑记录那些文档里不会告诉你的事5.1 Mali驱动对OpenCL版本的支持限制RK3588的Mali-G610驱动支持的是OpenCL 2.1部分特性可能不支持不是最新的OpenCL 3.0。这意味着有些新特性用不了比如某些新的内存模型特性。写kernel的时候要注意别用太新的语法否则编译会报错。我一开始用了一个OpenCL 2.0引入的work_group_reduce函数结果编译直接失败。后来查了驱动文档才发现这个函数在Mali驱动上支持不完整。解决办法是自己用shared memory实现一个reduce虽然麻烦点但兼容性好。5.2 全局工作组的尺寸选择clEnqueueNDRangeKernel的global size和local size设置对性能影响很大。我一开始随便设了个{1920, 1080}的global sizelocal size设为NULL让驱动自己决定。结果性能很差比预期慢了将近一倍。后来查了Mali的优化指南发现local size最好设成64的倍数因为Mali的warp size是164个warp一组。改成{64, 4}之后性能提升了30%多。另外global size最好是local size的整数倍否则会有边界处理的开销。比如1920x1080的图像local size设为64x4那global size应该设为1920x1080刚好是整数倍。如果图像尺寸不是整数倍需要把global size向上取整然后在kernel里加边界判断。5.3 内存对齐与buffer分配策略OpenCL的buffer分配对性能有影响。Mali GPU对内存对齐有要求如果buffer的起始地址没有对齐到64字节或者128字节访问效率会下降。我遇到过一个诡异的问题同样的kernel处理某些帧的时候特别慢处理另一些帧就正常。排查了半天发现是输入数据的地址没有对齐。解决办法是用clCreateBuffer的时候让驱动自己分配内存传NULL作为host_ptr然后把数据拷贝进去而不是直接把用户空间的指针传进去。如果确实需要零拷贝可以用CL_MEM_ALLOC_HOST_PTR标志让驱动分配对齐的内存然后用clEnqueueMapBuffer映射出来往里面写数据。5.4 多线程环境下OpenCL上下文的共享问题如果你的应用是多线程的多个线程都要用OpenCL那要注意上下文的共享问题。OpenCL的上下文和命令队列不是线程安全的多个线程同时往一个命令队列提交任务会导致未定义行为。我的做法是每个线程创建自己的命令队列但共享同一个上下文和程序对象。这样既能并行提交任务又不会重复编译kernel。命令队列的创建开销很小不用担心。还有一种做法是用一个专门的OpenCL线程其它线程通过队列把任务传给它。这种方式适合任务量不大但调用频繁的场景可以避免频繁创建销毁命令队列的开销。6. 什么场景该用OpenCL什么场景不该用6.1 推荐使用OpenCL的场景根据我的实测经验以下几种情况用OpenCL加速是划算的高分辨率视频处理1080p及以上数据量大GPU并行优势明显。多步骤管线多个处理步骤可以串在GPU上执行中间数据不用回读省传输开销。CPU需要同时处理其它任务OpenCL把CPU解放出来整体系统吞吐量更高。功耗敏感场景GPU的能效比CPU多线程高同样的计算量功耗更低。6.2 不建议用OpenCL的场景反过来这些情况用CPU可能更合适低分辨率或小数据量传输开销占比太高加速效果不明显。逻辑复杂、分支多的算法GPU不擅长处理这类任务强行移植可能更慢。开发时间紧张OpenCL的开发调试成本比CPU代码高不少如果项目周期紧CPU方案更稳妥。系统里没有GPU或者GPU被占用有些场景下GPU要留给显示或者其它任务这时候只能用CPU。6.3 混合方案的思路实际项目中我经常用的是混合方案把适合并行的部分丢给GPU把逻辑复杂的部分留给CPU。比如视频处理管线里颜色转换和缩放用OpenCL但码率控制、场景检测这些逻辑用CPU跑。两者通过共享内存或者消息队列通信。这种方案的好处是各取所长整体性能最优。缺点是实现复杂度高一些需要处理好CPU和GPU之间的同步。7. 几个能直接抄的优化技巧7.1 用向量化提升内存访问效率OpenCL支持向量类型比如uchar4、float4。用向量类型一次读写多个数据可以减少内存访问次数提升带宽利用率。比如颜色转换的kernel可以一次处理4个像素__kernel void nv12_to_rgb_vec4( __global const uchar* y_plane, __global const uchar* uv_plane, __global uchar* rgb_out, const int width, const int height) { int x get_global_id(0) * 4; int y get_global_id(1); if (x width || y height) return; uchar4 y_vec vload4(0, y_plane y * width x); // ... 后续处理 }实测下来向量化能带来15%到25%的性能提升具体取决于kernel的计算密度。7.2 避免在kernel里做除法GPU的除法运算比乘法慢很多。如果kernel里有除法尽量提前算好倒数用乘法代替。比如缩放kernel里的scale_x和scale_y可以在主机端算好倒数传进去kernel里直接用乘法。7.3 用异步传输重叠计算和数据搬运OpenCL支持异步的数据传输可以在GPU计算的同时CPU往另一个buffer里写下一帧的数据。这样计算和传输重叠起来整体吞吐量能提升不少。实现方式是用两个buffer做乒乓操作一个在计算的时候另一个在传输。命令队列要设置成乱序执行模式CL_QUEUE_OUT_OF_ORDER_EXEC_MODE_ENABLE然后用事件来同步。这个优化在视频流处理场景下效果特别明显能把传输开销基本隐藏掉。7.4 合理设置kernel的work group size前面提过local size要设成64的倍数但具体设多少要看kernel的寄存器使用情况。如果kernel用的寄存器多local size设太大反而会导致occupancy下降。我的经验是先用clGetKernelWorkGroupInfo查一下kernel的最大work group size和寄存器使用量然后从64开始试逐步增加到128、256看哪个性能最好。不同kernel的最优值可能不一样需要单独调。8. 从实测数据回头看架构选型折腾完这一轮我对RK3588上OpenCL和CPU的选型有了比较清晰的认识。如果你的视频处理管线是纯像素级的操作分辨率在1080p以上而且对功耗有要求那OpenCL方案值得投入时间去做。虽然开发成本比CPU方案高但运行时的收益是实打实的尤其是在多路视频或者高帧率场景下。但如果你的处理逻辑里有大量的条件分支、动态决策或者数据量本身就不大那CPU方案可能更务实。RK3588的8核CPU性能不弱配合好多线程优化很多场景下够用了。还有一个容易被忽略的点RK3588除了GPU还有VPU和NPU。VPU专门做视频编解码NPU做AI推理。如果你的管线里有编解码或者推理优先用这些专用硬件它们比GPU更高效。OpenCL适合的是那些VPU和NPU覆盖不到的通用并行计算任务。我在实际项目里最终的方案是VPU负责解码OpenCL负责颜色转换和缩放NPU负责推理CPU负责调度和业务逻辑。各司其职整体功耗和性能都达到了比较理想的状态。这套组合拳打下来比纯CPU方案快了将近4倍功耗反而更低。
返回列表