榨干移动端GPU:Mali与Adreno的Compute Shader共享内存(LSM)极致优化
在移动端进行高性能计算(如GPGPU物理模拟、图像处理、深度学习推理内核)时,Compute Shader 的局部共享内存(Local Shared Memory,下文简称 LSM,在 HLSL 中称为 groupshared,GLSL 中称为 shared)是提升带宽利用率的绝对利器。
然而,如果直接套用 PC 端的 CUDA/DirectX 优化经验到移动端(ARM Mali 或 Qualcomm Adreno),往往会遇到性能不升反降的窘境。这是因为移动端 GPU 的统一内存架构(UMA)、寄存器文件设计以及着色器处理器(SP)的物理约束与桌面端有着本质区别。
本文将深入 Mali(Valhall/5th Gen)与 Adreno(A6xx/A7xx)的物理架构底层,分享几个压榨移动端 LSM 性能的硬核优化技巧。
一、 核心痛点:LSM 容量与 Occupancy(占用率)的生死博弈
在 PC 端,我们习惯于“空间换时间”,申请足额的 LSM 来缓存数据。但在移动端,LSM 的使用量直接决定了 GPU 的硬件占用率(Occupancy)。
1. Adreno 的物理约束
以 Adreno 650 标配的 SP(Shader Processor)为例,每个 SP 的 LSM(在 Qualcomm 文档中常被称为 SP SRAM 或 TGI)物理容量非常有限(通常为 32KB 或 64KB)。
- 如果你的 Thread Group(Workgroup)申请了 16KB 的 LSM,那么在一个 SP 上最多只能同时调度 2 到 4 个活跃的 Thread Group。
- 一旦 LSM 申请超标,GPU 无法部署足够的 Wavefront(Adreno 称为 Fiber Bundle)来掩盖纹理采样或全局内存(Global Memory)的延迟,导致算力闲置。
2. Mali 的共享寄存器机制
在 Mali Valhall 架构中,LSM 与寄存器文件(Register File)在物理上可能会共享相同的 SRAM 块或 L1 缓存带宽。这意味着:
- 超额使用 LSM 会直接挤占寄存器额度,导致编译器不得不进行寄存器溢出(Spill),将变量压入慢速的临时内存(Scratch Memory / Thread Local Storage),带来毁灭性的性能下滑。
极致优化对策:
- 动态计算 LSM 极限:在设计 Kernel 时,单 Workgroup 的 LSM 占用量应尽量控制在 4KB 至 8KB 以内。
- 分块尺寸(Tile Size)微调:不要盲目选择 $16 \times 16$ 的二维分块。在移动端,针对一维数据采用 $64 \times 1$ 或 $128 \times 1$,针对二维数据采用 $16 \times 8$ 或 $8 \times 8$ 的分块,往往能以极小的 LSM 消耗换取更高的 Occupancy。
二、 移动端 LSM 的 Bank Conflict 避坑指南
与 NVIDIA GPU 类似,移动端 LSM 在物理上也划分为多个可以并发访问的 Bank(通常为 32 个 Bank,每个 Bank 宽度为 4 字节/32位)。
通常的 32 Bank 映射图示:
地址: 0x00(B0) 0x04(B1) 0x08(B2) ... 0x7C(B31)
地址: 0x80(B0) 0x84(B1) 0x88(B2) ... 0xFC(B31)
当同一个 Wave/Subgroup 内的多个线程试图访问同一个 Bank 的不同地址时,就会发生 Bank Conflict,导致访问由并发退化为串行。
1. Adreno 上的特有现象
Adreno GPU 支持 Wave32 和 Wave64 模式(编译器根据寄存器使用情况自动选择)。
- 在 Wave64 模式下,1个 Wave 有 64 个 Fiber。如果访问不当,最坏情况下会产生 2 向(2-way)甚至更高级别的 Bank Conflict。
- 特别注意:
half(16位宽) 与char(8位宽) 的访问。在 PC 端,大家习惯用float(32位)。在移动端,为了省带宽我们常用half。但如果两个线程分别访问同一个 32-bit Word 的高 16 位和低 16 位,在某些 Adreno 架构上依然会触发 Bank Conflict。
极致优化对策:
- 垫片法(Padding):如果是二维紧凑矩阵,通过给主维度增加一个元素的偏移,错开 Bank 索引。
// 产生严重 Bank Conflict 的写法(16x16)
shared float local_data[16][16];
// 线程 t(x, y) 访问 local_data[y][x],当 x相同时,纵向访问会撞在同一个 Bank 上
// 优化后的写法:将列数设为奇数(如 17)
shared float local_data[16][17];
// 物理地址错开,完美规避纵向访问的 Bank Conflict
- 手动重组为 32位 传输:如果存储的是
half,尽量将其打包成half2(即一个 32 位的uint)进行 LSM 的读写,确保每个线程对 LSM 的访问对齐到 4 字节。
三、 终极奥义:用 Subgroup 跨线程洗牌指令(Shuffle)干掉 LSM
最快的 LSM 优化,就是不用 LSM。
在 Mali Valhall+ 以及 Adreno 6xx+ 上,硬件引入了极其强大的 Subgroup(子群 / Wave)内联指令。这些指令允许同一个 Subgroup 内的线程直接交换寄存器数据,无需通过 LSM 绕道。
- LSM 访问路径:寄存器 -> LSM (SRAM) -> 屏障同步(Barrier) -> 读 LSM -> 寄存器(延迟:约十几到几十个周期,且伴随 Barrier 的流水线排空惩罚)。
- Subgroup 路径:寄存器 -> 寄存器直连交换(延迟:通常仅 1~2 个周期,无需 Barrier 阻断)。
示例:计算一个 Subgroup (如 64 个线程) 的局部求和(Reduction)
传统 LSM 写法(耗费 LSM 且需要同步):
layout(local_size_x = 64) in;
shared float s_data[64];
void main() {
uint tid = gl_LocalInvocationID.x;
s_data[tid] = fetch_data();
barrier(); // 强制同步,极度消耗移动端流水线性能
for (uint s = 32; s > 0; s >>= 1) {
if (tid < s) {
s_data[tid] += s_data[tid + s];
}
barrier();
}
}
现代 Subgroup 极致优化写法(无 LSM,无 Barrier):
#extension GL_KHR_shader_subgroup_arithmetic : enable
layout(local_size_x = 64) in;
void main() {
float val = fetch_data();
// 直接在寄存器层面完成子群内求和
float sum = subgroupAdd(val);
// 此时 sum 已经是该 subgroup 内所有 val 的总和,0 耗时 LSM,0 耗时 Barrier
}
注:在 Vulkan / OpenGL ES 3.2 中,请务必查询并开启 VK_KHR_shader_subgroup 支持。对于 Adreno 6xx,其 Subgroup 大小通常为 64 或 128;Mali Valhall 通常为 16 或 32。
四、 向量化与宽位宽合并访存(Coalesced I/O)
移动端 L1/L2 缓存与 LSM 之间的总线宽度通常是 128位(16字节) 甚至是 256位。
如果你的 Compute Shader 依然在使用标量(float、int)逐个加载数据到 LSM,那么你就浪费了大量的总线控制开销。
极致优化对策:
- 使用
vec4/uvec4代替标量加载:
在将 Global Memory(如 SSBO)的数据搬运到 LSM 时,让单个线程一次性加载一个vec4(16字节),然后存入 LSM。这比单个线程加载 4 次float要快得多。
// 低效写法:线程零散读取,地址不连续
shared float s_mem[256];
s_mem[gl_LocalInvocationID.x] = input_buffer[gl_GlobalInvocationID.x];
// 高效写法:向量化合并读取
shared vec4 s_mem_vec[64]; // 256 个 float
// 每个线程读取 128 位,完美对齐 L2 缓存行
s_mem_vec[gl_LocalInvocationID.x] = input_buffer_vec4[gl_GlobalInvocationID.x];
- 合并写入(Coalesced Write):
确保相邻线程(Thread ID 连续)访问的全局内存/LSM地址是连续的。Adreno 的内存架构非常喜欢合并(Coalesced)访问。如果相邻线程访问的地址跨度(Stride)很大,硬件会将其拆分为多次单单打独斗的内存请求,带宽瞬间腰斩。
五、 极其昂贵的“屏障(Barrier)”:精细化裁剪
在移动端,groupMemoryBarrier()、memoryBarrierShared() 以及 barrier() 是极为沉重的操作。
移动端 GPU 是高度流水线化的。一个 Barrier 指令会强行清空当前的 Execution Pipeline(指令流水线),阻止后继指令的发射,直到所有线程到达同步点。这会导致移动端极为珍贵的延迟隐藏(Latency Hiding)机制彻底失效。
优化手段:
- 多写无同步算法(Barrier-Free Algorithms):通过重新设计算法逻辑,减少同步次数。例如,使用双缓冲区(Double Buffering)或者上面提到的 Subgroup 机制。
- 区分内存屏障与执行屏障:
- 如果你只是想确保 LSM 的写入对其他线程可见,但不需要严格的执行同步,可以使用
memoryBarrierShared(),而不是同时带有执行同步的barrier()(在 HLSL 中对应GroupMemoryBarrier()与GroupMemoryBarrierWithGroupSync()的区别)。 - 仅在确有数据依赖(Data Dependency)时才使用
barrier()。
- 如果你只是想确保 LSM 的写入对其他线程可见,但不需要严格的执行同步,可以使用
总结:移动端 LSM 优化 Checkpoint 清单
| 优化维度 | PC 端常规思维 | 移动端极致技巧 |
|---|---|---|
| 容量限制 | 只要不超上限,多多益善 | 控制在 4KB~8KB 内,优先保障 Occupancy |
| 同步机制 | 频繁使用 barrier() 无所谓 |
极力避免屏障,优先考虑 Subgroup Shuffle 寄存器级交换 |
| 数据布局 | 正常声明二维数组 | 增加奇数 Padding(如 [N][N+1])消除 Bank Conflict |
| 数据装载 | 标量逐个读取 | 强推 vec4(128位)宽位宽合并装载与写入 |
在进行移动端 GPU 优化时,建议多配合 Qualcomm Snapdragon Profiler 或 ARM Frame Advisor。观察其中的 L1/L2 Cache Miss、SP Occupancy 以及 Stall Rate 指标。通过不断微调 LSM 的使用尺寸和线程组大小,你将能真正释放移动端 GPU 潜藏的惊人算力。