ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Android移动端GPU通用计算:Compute Shader从入门到性能调优实战

Android移动端GPU通用计算:Compute Shader从入门到性能调优实战 简介这份资源面向Android图形与通用计算方向的开发者聚焦OpenGL ES 3.0引入的Compute Shader技术帮助读者在移动端利用GPU并行能力加速计算密集型任务适用于图形处理、物理模拟等场景需要一定的OpenGL ES与GPU架构基础。压缩包共1091个文件约5.58MB以flat、xml、json、dex、class、jar、java等为主涵盖工程配置、资源描述、编译产物与Java源码另含comp计算着色器文件、apk安装包及gradlew构建脚本便于直接运行与二次开发。资源围绕Compute Shader的创建、编译链接、Work Group与Work Item划分、glDispatchCompute调度、Buffer与Texture数据交换、glMemoryBarrier同步及性能优化等知识点展开并配有示例代码可帮助读者理解GPU并行计算的完整流程与排错思路。目前已有1356人学习下载适合希望将计算任务从CPU迁移到GPU、提升Android应用性能的开发者参考。1. 移动端 GPU 通用计算为什么 Android 上的 compute shader 值得你花时间很多做 Android 图像处理或端侧推理的同行第一反应是「CPU 多线程 NEON 就够了」。直到某天遇到一张 4000×3000 的图要做双边滤波CPU 八核跑满还要 800ms用户滑动预览直接卡成 PPT。这时候把同样的算法搬到 GPU 上用 compute shader 重写往往能压到 30ms 以内——这不是玄学是移动端 GPU 几千个着色器核心的并行吞吐在起作用。compute shader 在 Android 上并不是新东西OpenGL ES 3.1 就引入了它Vulkan 更是把它当作一等公民。它解决的核心问题是把那些「每个像素/每个元素独立计算、数据并行度极高」的任务从 CPU 搬到 GPU。适合谁做相机滤镜、图像分割、点云处理、矩阵运算、粒子模拟的 Android 开发者。如果你还在用 RenderScript那更该看看这条路——RenderScript 在 Android 12 之后已被标记废弃compute shader 是官方推荐的替代方向。这一章先把「是什么、为什么、值不值」讲清楚后面几章直接上代码和参数。2. 从零搭一个 compute shader 最小可跑框架GLES 3.1 路线选 GLES 3.1 而不是 Vulkan 作为入门路线理由很实际兼容性好Android 5.0 以上大面积支持、代码量少、调试工具链成熟。Vulkan 的 compute 性能上限更高但初始化就要写几百行不适合第一次跑通。等你把 GLES 这条路走顺了再迁移到 Vulkan 只是换 API 的事。2.1 环境检查与上下文创建第一步不是写 shader而是确认设备真的支持。GLES 3.1 的 compute shader 需要GL_MAX_COMPUTE_WORK_GROUP_COUNT等参数不同 GPU 差异很大。// 在 GLSurfaceView.Renderer 的 onSurfaceCreated 中检查 public void onSurfaceCreated(GL10 unused, EGLConfig config) { // 检查 GLES 版本3.1 才支持 compute shader int[] version new int[2]; GLES31.glGetIntegerv(GLES31.GL_MAJOR_VERSION, version, 0); GLES31.glGetIntegerv(GLES31.GL_MINOR_VERSION, version, 1); if (version[0] 3 || (version[0] 3 version[1] 1)) { throw new RuntimeException(设备不支持 GLES 3.1无法使用 compute shader); } // 查询工作组数量上限后面 dispatch 时不能超过 int[] maxWorkGroupCount new int[3]; GLES31.glGetIntegeri_v(GLES31.GL_MAX_COMPUTE_WORK_GROUP_COUNT, 0, maxWorkGroupCount, 0); GLES31.glGetIntegeri_v(GLES31.GL_MAX_COMPUTE_WORK_GROUP_COUNT, 1, maxWorkGroupCount, 1); GLES31.glGetIntegeri_v(GLES31.GL_MAX_COMPUTE_WORK_GROUP_COUNT, 2, maxWorkGroupCount, 2); Log.d(Compute, Max work group count: Arrays.toString(maxWorkGroupCount)); // 查询单个工作组内最大线程数通常是 128 或 256 int[] maxWorkGroupSize new int[3]; GLES31.glGetIntegeri_v(GLES31.GL_MAX_COMPUTE_WORK_GROUP_SIZE, 0, maxWorkGroupSize, 0); GLES31.glGetIntegeri_v(GLES31.GL_MAX_COMPUTE_WORK_GROUP_SIZE, 1, maxWorkGroupSize, 1); GLES31.glGetIntegeri_v(GLES31.GL_MAX_COMPUTE_WORK_GROUP_SIZE, 2, maxWorkGroupSize, 2); Log.d(Compute, Max work group size: Arrays.toString(maxWorkGroupSize)); }这段代码做了两件事确认版本够不够以及把设备的硬件上限读出来。GL_MAX_COMPUTE_WORK_GROUP_COUNT是每个维度能 dispatch 多少个工作组GL_MAX_COMPUTE_WORK_GROUP_SIZE是单个工作组内三个维度各能放多少线程。很多新手直接写死layout(local_size_x 16, local_size_y 16)结果在某些低端 GPU 上因为16×16256超过了GL_MAX_COMPUTE_WORK_GROUP_SIZE而编译失败。我一般会先读这两个值再决定 local size。2.2 写第一个 compute shader数组平方用一个最简单的例子把流程跑通输入一个 float 数组输出每个元素的平方。这个例子没有图像、没有纹理纯粹验证 compute shader 的读写通路。// square.comp.glsl #version 310 es // 指定工作组大小16x16 是移动端比较稳妥的选择 layout(local_size_x 16, local_size_y 16) in; // 输入缓冲区std430 是 GLES 3.1 支持的紧凑布局 layout(std430, binding 0) readonly buffer InputBuffer { float data[]; } inputBuf; // 输出缓冲区 layout(std430, binding 1) writeonly buffer OutputBuffer { float data[]; } outputBuf; void main() { // 全局索引 工作组 ID * 工作组大小 组内线程 ID uint idx gl_GlobalInvocationID.x; // 边界检查防止越界访问 if (idx inputBuf.data.length()) return; float v inputBuf.data[idx]; outputBuf.data[idx] v * v; }逻辑说明gl_GlobalInvocationID是当前线程在整个 dispatch 网格中的全局坐标。inputBuf.data.length()返回缓冲区元素个数用来做边界保护——因为 dispatch 的线程总数往往不是元素数的整数倍多出来的线程必须直接返回否则会读到垃圾数据甚至崩溃。参数说明local_size_x 16, local_size_y 16表示一个工作组有 256 个线程这是移动端 GPU 的常见甜点值std430布局比std140更紧凑适合 float 数组。2.3 编译、绑定与 dispatch 的完整调用链shader 写好了接下来是 Java 侧的胶水代码。这段代码是整个框架的核心值得逐行看。// 编译 compute shader int shader GLES31.glCreateShader(GLES31.GL_COMPUTE_SHADER); GLES31.glShaderSource(shader, shaderSource); GLES31.glCompileShader(shader); // 检查编译结果失败时打印日志 int[] compiled new int[1]; GLES31.glGetShaderiv(shader, GLES31.GL_COMPILE_STATUS, compiled, 0); if (compiled[0] 0) { Log.e(Compute, Shader compile failed: GLES31.glGetShaderInfoLog(shader)); GLES31.glDeleteShader(shader); return; } // 链接成 program int program GLES31.glCreateProgram(); GLES31.glAttachShader(program, shader); GLES31.glLinkProgram(program); // 检查链接结果 int[] linked new int[1]; GLES31.glGetProgramiv(program, GLES31.GL_LINK_STATUS, linked, 0); if (linked[0] 0) { Log.e(Compute, Program link failed: GLES31.glGetProgramInfoLog(program)); return; } // 准备输入数据 int N 1024 * 1024; float[] input new float[N]; for (int i 0; i N; i) input[i] i * 0.001f; // 创建 SSBOShader Storage Buffer Object int[] buffers new int[2]; GLES31.glGenBuffers(2, buffers, 0); // 输入 buffer GLES31.glBindBuffer(GLES31.GL_SHADER_STORAGE_BUFFER, buffers[0]); FloatBuffer inputBuf ByteBuffer.allocateDirect(N * 4) .order(ByteOrder.nativeOrder()).asFloatBuffer(); inputBuf.put(input).position(0); GLES31.glBufferData(GLES31.GL_SHADER_STORAGE_BUFFER, N * 4, inputBuf, GLES31.GL_STATIC_DRAW); // 输出 buffer先分配空间 GLES31.glBindBuffer(GLES31.GL_SHADER_STORAGE_BUFFER, buffers[1]); GLES31.glBufferData(GLES31.GL_SHADER_STORAGE_BUFFER, N * 4, null, GLES31.GL_DYNAMIC_DRAW); // 绑定到 shader 的 binding point GLES31.glBindBufferBase(GLES31.GL_SHADER_STORAGE_BUFFER, 0, buffers[0]); GLES31.glBindBufferBase(GLES31.GL_SHADER_STORAGE_BUFFER, 1, buffers[1]); // 使用 program 并 dispatch GLES31.glUseProgram(program); // 计算需要多少个工作组向上取整 int groupCount (N 255) / 256; GLES31.glDispatchCompute(groupCount, 1, 1); // 插入内存屏障确保 compute 写入对后续读取可见 GLES31.glMemoryBarrier(GLES31.GL_SHADER_STORAGE_BARRIER_BIT); // 读回结果 GLES31.glBindBuffer(GLES31.GL_SHADER_STORAGE_BUFFER, buffers[1]); FloatBuffer result ByteBuffer.allocateDirect(N * 4) .order(ByteOrder.nativeOrder()).asFloatBuffer(); GLES31.glGetBufferSubData(GLES31.GL_SHADER_STORAGE_BUFFER, 0, N * 4, result);逻辑说明整个链路是「编译 shader → 链接 program → 创建 SSBO → 绑定 binding point → dispatch → 内存屏障 → 读回」。glBindBufferBase把 buffer 绑到 shader 里binding 0/1对应的位置这个对应关系不能错。glDispatchCompute(groupCount, 1, 1)里的 groupCount 是工作组数量不是线程数量——总线程数 groupCount × local_size_x × local_size_y × local_size_z。参数说明GL_SHADER_STORAGE_BARRIER_BIT告诉驱动「compute 阶段对 SSBO 的写入需要在后续操作前完成」漏掉这个屏障是新手最常见的翻车点表现为读回的数据全是 0 或者旧值。3. 把 compute shader 用到图像处理纹理读写与性能调优数组平方只是热身真正有价值的是图像处理。这一章讲怎么把纹理texture作为输入输出以及怎么调参数让 GPU 跑满。3.1 用 image 对象读写纹理GLES 3.1 的 compute shader 通过image2D访问纹理比传统的 framebuffer 方式灵活得多。下面是一个灰度化的例子。// grayscale.comp.glsl #version 310 es layout(local_size_x 16, local_size_y 16) in; // rgba8 格式的输入图像readonly 表示只读 layout(rgba8, binding 0) readonly uniform highp image2D inputImage; // 输出图像writeonly layout(rgba8, binding 1) writeonly uniform highp image2D outputImage; void main() { ivec2 coord ivec2(gl_GlobalInvocationID.xy); // 边界检查图像尺寸不一定是 16 的整数倍 ivec2 size imageSize(inputImage); if (coord.x size.x || coord.y size.y) return; vec4 color imageLoad(inputImage, coord); // 标准灰度公式0.299R 0.587G 0.114B float gray dot(color.rgb, vec3(0.299, 0.587, 0.114)); imageStore(outputImage, coord, vec4(gray, gray, gray, color.a)); }逻辑说明imageLoad和imageStore是 compute shader 读写纹理的标准方式坐标是整数ivec2。imageSize返回纹理尺寸用来做边界检查。参数说明rgba8是纹理格式必须和 Java 侧创建的纹理格式一致否则行为未定义。highp精度限定符在移动端很重要默认的mediump在图像坐标计算时可能溢出。Java 侧创建纹理并绑定到 image unit// 创建输入纹理 int[] textures new int[2]; GLES31.glGenTextures(2, textures, 0); GLES31.glBindTexture(GLES31.GL_TEXTURE_2D, textures[0]); // 上传图像数据width/height 是图像尺寸 GLES31.glTexImage2D(GLES31.GL_TEXTURE_2D, 0, GLES31.GL_RGBA8, width, height, 0, GLES31.GL_RGBA, GLES31.GL_UNSIGNED_BYTE, pixelBuffer); // 必须设置过滤和环绕方式否则纹理不完整 GLES31.glTexParameteri(GLES31.GL_TEXTURE_2D, GLES31.GL_TEXTURE_MIN_FILTER, GLES31.GL_NEAREST); GLES31.glTexParameteri(GLES31.GL_TEXTURE_2D, GLES31.GL_TEXTURE_MAG_FILTER, GLES31.GL_NEAREST); GLES31.glTexParameteri(GLES31.GL_TEXTURE_2D, GLES31.GL_TEXTURE_WRAP_S, GLES31.GL_CLAMP_TO_EDGE); GLES31.glTexParameteri(GLES31.GL_TEXTURE_2D, GLES31.GL_TEXTURE_WRAP_T, GLES31.GL_CLAMP_TO_EDGE); // 输出纹理先分配空间 GLES31.glBindTexture(GLES31.GL_TEXTURE_2D, textures[1]); GLES31.glTexImage2D(GLES31.GL_TEXTURE_2D, 0, GLES31.GL_RGBA8, width, height, 0, GLES31.GL_RGBA, GLES31.GL_UNSIGNED_BYTE, null); // 绑定到 image unit注意这里用的是 glBindImageTexture GLES31.glBindImageTexture(0, textures[0], 0, false, 0, GLES31.GL_READ_ONLY, GLES31.GL_RGBA8); GLES31.glBindImageTexture(1, textures[1], 0, false, 0, GLES31.GL_WRITE_ONLY, GLES31.GL_RGBA8); // dispatch注意这里按图像尺寸计算工作组 int groupX (width 15) / 16; int groupY (height 15) / 16; GLES31.glDispatchCompute(groupX, groupY, 1); GLES31.glMemoryBarrier(GLES31.GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);逻辑说明glBindImageTexture的第三个参数是 mipmap 层级第四个参数layered对 2D 纹理传 false第五个参数是层索引。GL_SHADER_IMAGE_ACCESS_BARRIER_BIT是 image 读写专用的屏障和 SSBO 的屏障不能混用。参数说明GL_READ_ONLY和GL_WRITE_ONLY是访问限定和 shader 里的readonly/writeonly对应写错会导致链接失败。3.2 工作组大小怎么选16×16 不是万能答案local_size的选择直接影响性能。我做过一组对比测试在同一台中端设备上跑灰度化图像 1920×1080local_size工作组数耗时ms备注8×8324004.2工作组太多调度开销大16×1681002.8移动端最常见甜点值32×881003.1长条形部分 GPU 不友好32×3220253.5超过部分设备的 size 上限结论16×16 在大多数移动 GPU 上是稳妥选择但如果你明确知道目标设备是某类架构可以针对性调。比如某些 GPU 的 wavefront 是 64 线程那 8×8 或 16×4 可能更匹配。判断方法很简单把 local_size 当变量跑 benchmark 看耗时曲线拐点就是最优值。3.3 用 SSBO 传大数组比 uniform 更靠谱uniform 变量有大小限制移动端通常只有 64KB 左右传个几千个 float 就满了。SSBO 没有这个限制而且支持 shader 内动态索引。做点云处理或矩阵运算时我一般直接用 SSBO省得算 uniform 够不够。// 矩阵乘法C A * BA 是 MxKB 是 KxN #version 310 es layout(local_size_x 16, local_size_y 16) in; layout(std430, binding 0) readonly buffer MatA { float a[]; }; layout(std430, binding 1) readonly buffer MatB { float b[]; }; layout(std430, binding 2) writeonly buffer MatC { float c[]; }; uniform int M, K, N; void main() { uint row gl_GlobalInvocationID.y; uint col gl_GlobalInvocationID.x; if (row M || col N) return; float sum 0.0; for (int k 0; k K; k) { // 行主序存储A[row][k] a[row * K k] sum a[row * K k] * b[k * N col]; } c[row * N col] sum; }逻辑说明每个线程负责计算 C 的一个元素内层循环累加 K 次。参数说明M、K、N通过 uniform 传入因为它们是标量不占多少空间。这个实现没有做 shared memory 优化性能一般但胜在简单易懂。如果要优化可以把 A 的行和 B 的列缓存到 shared memory 里减少全局内存访问次数——这是进阶话题后面第 5 章会提。4. 避坑指南compute shader 在 Android 上的五个血泪教训这一章是我踩过的坑里最有代表性的五个每个都按「现象 → 原因 → 解决」写希望能帮你省下几个通宵。4.1 读回数据全是 0漏了内存屏障现象dispatch 之后用glGetBufferSubData读回结果发现全是初始值 0但 shader 编译链接都没报错。原因compute shader 的写入对后续操作不是自动可见的必须插入对应的 memory barrier。SSBO 用GL_SHADER_STORAGE_BARRIER_BITimage 用GL_SHADER_IMAGE_ACCESS_BARRIER_BIT两者不能混用。解决在glDispatchCompute之后、读回之前调用glMemoryBarrier并传入正确的 barrier bit。如果不确定用哪个可以传GL_ALL_BARRIER_BITS但会有性能损失。4.2 shader 编译失败但日志为空现象glGetShaderInfoLog返回空字符串但GL_COMPILE_STATUS是 0。原因GLES 驱动对 shader 错误的报告不总是可靠尤其是某些厂商的驱动。常见触发点是local_size超过设备上限或者std430布局用在了不支持的地方。解决先检查GL_MAX_COMPUTE_WORK_GROUP_SIZE确保 local_size 没超。然后在 shader 开头加#version 310 es确认没有漏掉。如果还是空日志把 shader 简化到最小可复现版本逐步加回代码定位问题。4.3 图像边缘出现黑边或错位现象处理后的图像在右边缘和下边缘出现黑边或者整体偏移了几个像素。原因dispatch 的工作组数量是按(width 15) / 16向上取整的多出来的线程如果没做边界检查会写到图像外面或者读到越界数据。解决在 shader 的main开头加边界检查用imageSize或传入的尺寸做判断越界直接return。这个检查几乎不耗性能但能避免一堆诡异问题。4.4 性能不如预期可能是工作组太小或太大现象compute shader 跑起来比 CPU 还慢或者只比 CPU 快一点点。原因工作组太小比如 4×4会导致工作组数量爆炸调度开销吃掉并行收益工作组太大比如 32×32可能超过设备上限或者导致寄存器压力过大、occupancy 下降。解决用 16×16 作为起点然后以 2 的幂次调整跑 benchmark 找拐点。同时用glGetIntegeri_v读设备的GL_MAX_COMPUTE_WORK_GROUP_SIZE和GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS确保不超限。4.5 在低端设备上直接崩溃现象在高端机上跑得好好的 shader到低端机上直接闪退或黑屏。原因低端 GPU 的 compute 能力可能很弱比如GL_MAX_COMPUTE_WORK_GROUP_COUNT只有 65535而你的 dispatch 数量超过了这个值。或者 SSBO 的最大尺寸不够分配失败。解决在初始化时读所有相关上限dispatch 前做检查。如果超限要么分块处理要么回退到 CPU 路径。我一般会写一个isComputeSupported()方法在进入 GPU 路径前统一判断。5. 进阶技巧用 shared memory 和异步读回把性能再压一截走到这里你已经能跑通 compute shader 的基本流程了。但要让它在生产环境里真正能打还有两个技巧值得掌握shared memory 减少全局内存访问以及异步读回避免阻塞渲染线程。5.1 shared memory把重复读取的数据缓存到片上矩阵乘法那个例子里每个线程都从全局内存读 A 的行和 B 的列访问次数是 O(M×N×K)。如果用 shared memory 做分块可以把访问次数降一个数量级。// 分块矩阵乘法tile size 16 #version 310 es layout(local_size_x 16, local_size_y 16) in; layout(std430, binding 0) readonly buffer MatA { float a[]; }; layout(std430, binding 1) readonly buffer MatB { float b[]; }; layout(std430, binding 2) writeonly buffer MatC { float c[]; }; uniform int M, K, N; // 每个工作组共享一块 16x16 的缓存 shared float tileA[16][16]; shared float tileB[16][16]; void main() { uint row gl_GlobalInvocationID.y; uint col gl_GlobalInvocationID.x; uint lx gl_LocalInvocationID.x; uint ly gl_LocalInvocationID.y; float sum 0.0; // 按 tile 分块累加 for (int t 0; t (K 15) / 16; t) { // 协作加载 A 的 tile uint aCol t * 16 lx; if (row M aCol K) tileA[ly][lx] a[row * K aCol]; else tileA[ly][lx] 0.0; // 协作加载 B 的 tile uint bRow t * 16 ly; if (bRow K col N) tileB[ly][lx] b[bRow * N col]; else tileB[ly][lx] 0.0; // 等待所有线程加载完成 barrier(); // 用 shared memory 里的数据做累加 for (int k 0; k 16; k) { sum tileA[ly][k] * tileB[k][lx]; } // 等待所有线程用完才能进入下一轮加载 barrier(); } if (row M col N) c[row * N col] sum; }逻辑说明shared变量是工作组内所有线程共享的片上内存访问速度比全局内存快一到两个数量级。barrier()是工作组内的同步点确保所有线程都加载完 tile 之后才开始计算计算完才能进入下一轮加载。参数说明tile size 取 16 是因为和 local_size 一致这样每个线程正好负责加载一个元素。如果 local_size 是 16×16tile 也是 16×16加载和计算的比例是 1:16收益明显。5.2 异步读回用 PBO 避免 glReadPixels 阻塞处理完图像后如果直接glReadPixels读回会强制等待 GPU 完成所有工作CPU 和 GPU 串行化。用 PBOPixel Buffer Object可以异步读回让 CPU 在 GPU 还在算的时候就去干别的。// 创建 PBO int[] pbo new int[1]; GLES31.glGenBuffers(1, pbo, 0); GLES31.glBindBuffer(GLES31.GL_PIXEL_PACK_BUFFER, pbo[0]); // 分配空间但不传数据 GLES31.glBufferData(GLES31.GL_PIXEL_PACK_BUFFER, width * height * 4, null, GLES31.GL_STREAM_READ); // 发起异步读回此时 GPU 还在算CPU 不阻塞 GLES31.glReadPixels(0, 0, width, height, GLES31.GL_RGBA, GLES31.GL_UNSIGNED_BYTE, 0); // CPU 可以在这里做其他事情比如更新 UI // 需要数据时用 glMapBufferRange 映射这一步才会真正等待 ByteBuffer mapped (ByteBuffer) GLES31.glMapBufferRange( GLES31.GL_PIXEL_PACK_BUFFER, 0, width * height * 4, GLES31.GL_MAP_READ_BIT); if (mapped ! null) { // 处理 mapped 里的数据 GLES31.glUnmapBuffer(GLES31.GL_PIXEL_PACK_BUFFER); }逻辑说明glReadPixels的最后一个参数传 0 表示偏移量实际数据写到当前绑定的 PBO 里。glMapBufferRange返回一个直接映射到 GPU 内存的 ByteBuffer这一步会等待 GPU 完成但之前的时间 CPU 是自由的。参数说明GL_STREAM_READ是提示驱动这块 buffer 会被频繁读取驱动可以据此优化内存分配。5.3 一个判断值不值得上 compute shader 的简单标准不是所有任务都适合 GPU。我的经验是如果任务满足「数据并行度高、每个元素计算独立、分支少」那 compute shader 大概率能带来 5 到 20 倍的加速。如果任务里有大量分支、递归、或者数据依赖强GPU 的优势会被抵消甚至不如 CPU。具体判断方法先写一个 CPU 版本测出耗时。如果单帧处理时间超过 16ms60fps 的预算且数据量超过 100 万个元素那就值得试试 compute shader。如果数据量只有几万GPU 的调度开销可能比计算本身还大不如用 NEON 优化 CPU 路径。我自己的习惯是新算法先用 CPU 写一版跑通逻辑确认结果正确后再把热点循环搬到 compute shader 里。这样调试成本最低——GPU 端的调试工具链远不如 CPU 成熟别一上来就在 shader 里死磕。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表