WEBKT

榨干移动端GPU:Mali与Adreno的Compute Shader共享内存(LSM)极致优化

26 0 0 0

在移动端进行高性能计算(如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 依然在使用标量(floatint)逐个加载数据到 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)机制彻底失效

优化手段:

  1. 多写无同步算法(Barrier-Free Algorithms):通过重新设计算法逻辑,减少同步次数。例如,使用双缓冲区(Double Buffering)或者上面提到的 Subgroup 机制。
  2. 区分内存屏障与执行屏障
    • 如果你只是想确保 LSM 的写入对其他线程可见,但不需要严格的执行同步,可以使用 memoryBarrierShared(),而不是同时带有执行同步的 barrier()(在 HLSL 中对应 GroupMemoryBarrier()GroupMemoryBarrierWithGroupSync() 的区别)。
    • 仅在确有数据依赖(Data Dependency)时才使用 barrier()

总结:移动端 LSM 优化 Checkpoint 清单

优化维度 PC 端常规思维 移动端极致技巧
容量限制 只要不超上限,多多益善 控制在 4KB~8KB 内,优先保障 Occupancy
同步机制 频繁使用 barrier() 无所谓 极力避免屏障,优先考虑 Subgroup Shuffle 寄存器级交换
数据布局 正常声明二维数组 增加奇数 Padding(如 [N][N+1])消除 Bank Conflict
数据装载 标量逐个读取 强推 vec4(128位)宽位宽合并装载与写入

在进行移动端 GPU 优化时,建议多配合 Qualcomm Snapdragon ProfilerARM Frame Advisor。观察其中的 L1/L2 Cache MissSP Occupancy 以及 Stall Rate 指标。通过不断微调 LSM 的使用尺寸和线程组大小,你将能真正释放移动端 GPU 潜藏的惊人算力。

架构榨汁机 移动端GPULSM优化

评论点评