WEBKT

深入Adreno A7xx GPU:如何榨干Mesh Shader的Threadgroup Memory性能?

17 0 0 0

在移动端GPU技术演进中,高通Adreno A7xx系列(如Snapdragon 8 Gen 2的Adreno 740、Gen 3的Adreno 750等)对硬件级Mesh Shading(网格着色器)的支持,彻底改变了传统顶点的处理管线。

然而,许多从PC端移植到移动端的Mesh Shader算法在Adreno上表现并不尽如人意。核心瓶颈往往不在于算力,而在于Threadgroup Memory(LDS,Local Data Share,局部数据共享内存)的使用不当,导致Shader Processor(SP)的Occupancy(占用率)雪崩

本文将深入底层,剖析Adreno A7xx的硬件特性,并提供一套榨干Threadgroup Memory性能的Mesh Shader实战优化策略。


一、 Adreno A7xx 的 Mesh Shader 硬件执行模型

要优化Threadgroup Memory,首先必须理解Adreno A7xx的底层执行逻辑。

1. SP与物理寄存器、LDS的动态分配

Adreno A7xx 的每个着色器处理器(SP)内部,物理寄存器(Register File)和Threadgroup Memory(下文统称 LDS)是所有活动Wave(波面/线程束)共享的

  • 物理限制:LDS容量是有限的(通常为每个SP 64KB或128KB,视具体微架构而定)。
  • Occupancy公式
    $$\text{Max Active Waves} = \min\left(\frac{\text{Total Registers}}{\text{Regs per Wave}}, \frac{\text{Total LDS}}{\text{LDS per Workgroup}} \times \text{Workgroups per SP}\right)$$
  • 如果你的Mesh Shader声明了过大的 shared 变量,GPU能同时调度的活跃Wave数量就会急剧减少,导致算力单元在等待纹理或内存延迟时无法被有效隐藏,性能直接断崖式下跌。

2. Wave32 还是 Wave64?

Adreno A7xx支持Wave32Wave64两种执行模式。对于Mesh Shader:

  • 强烈建议强制指定 Wave32(即Workgroup大小设为32的倍数,通常为32)。
  • 在Wave32模式下,寄存器和LDS的分配粒度更细,能够更好地填满硬件的流处理器槽位,减少分支分歧(Branch Divergence)的影响。

二、 移动端 Meshlet 的“黄金尺寸”

在PC端(如RTX 30/40系列),业界推荐的Meshlet尺寸通常是 64个顶点 / 126个三角形,甚至 128个顶点 / 256个三角形

但在Adreno A7xx这类移动端硬件上,盲目套用PC端尺寸是性能杀手

1. 为什么移动端需要更小的 Meshlet?

Mesh Shader需要将每个Meshlet的顶点属性临时缓存在LDS中,以便进行顶点复用和属性插值。

  • 假设一个顶点属性包含 Position(vec3)Normal(vec3)UV(vec2),共 32 字节。
  • 若采用 128 顶点:仅顶点属性就需要 $128 \times 32 = 4\text{ KB}$ 的LDS空间。加上索引表($256 \times 3\text{ Bytes} = 768\text{ Bytes}$),单个Workgroup的LDS占用将突破 5KB。
  • 这意味着单个SP上能并行的Workgroup数量将被严重压缩。

2. 推荐的黄金配置

经过真机Profilers(如Snapdragon Profiler)反复验证,在Adreno A7xx上:

  • 常规复杂网格:推荐 max_vertices = 64, max_primitives = 64(或 84)
  • 极致优化/极简顶点(如Shadow Pass,仅需Position):推荐 max_vertices = 64, max_primitives = 126

利用这种配置,可确保LDS占用控制在 2KB 以内,释放极高的硬件并发度。


三、 极致压缩:Threadgroup Memory 的数据打包艺术

在编写 GLSL / HLSL 时,声明 shared 数组的格式直接决定了LDS的物理开销。我们必须摒弃直接使用标准32位浮点数(float)或大结构体的习惯。

1. 顶点属性的16位量化(Half-float & Octahedron)

不要在LDS中存储原始的 vec3 法线或 vec2 纹理坐标。

  • 位置信息:如果Meshlet已经做了局部空间(Local Space)量化,可以使用 f16vec3(或在HLSL中用 half3)。
  • 法线/切线:使用八面体变换(Octahedron Mapping)将 $3 \times 32\text{-bit}$ 的法线压缩为 $2 \times 16\text{-bit}$(甚至 $2 \times 8\text{-bit}$ SNorm)。
  • UV坐标:通常 f16vec2 的精度足够满足Mesh Shader内部的临时计算或传递。

优化前代码(反面教材):

struct VertexLDS {
    vec3 position;  // 12 bytes
    vec3 normal;    // 12 bytes
    vec2 uv;        // 8 bytes
}; // 32 bytes per vertex

shared VertexLDS s_Vertices[64]; // 2048 bytes

优化后代码(推荐):

#extension GL_EXT_shader_explicit_arithmetic_types_float16 : require
#extension GL_EXT_shader_explicit_arithmetic_types_int16 : require

struct PackedVertexLDS {
    f16vec3 position; // 6 bytes
    uint32_t packedNormal; // 4 bytes (Octahedron encoded in 2x fp16 or UNorm16)
    f16vec2 uv;       // 4 bytes
}; // 14 bytes per vertex - 压缩率达 56%

shared PackedVertexLDS s_Vertices[64]; // 896 bytes!

四、 规避 LDS 银行冲突(Bank Conflicts)

Adreno的LDS在物理上划分为32个等宽的内存银行(Banks),每个Bank宽度为4字节(32-bit)。

  • 当一个Wave中的多个线程同时访问同一个Bank中不同地址的数据时,就会发生Bank Conflict(银行冲突),访问将被迫串行化,导致严重的时钟周期浪费。
  • 黄金法则:尽量保证第 $i$ 个线程访问的数据偏移对应第 $i$ 个 Bank(或其倍数)。

1. 结构体数组(AoS) vs 数组结构体(SoA)

在LDS中,SoA(Structure of Arrays) 通常比 AoS(Array of Structures) 更有利于规避冲突。

// SoA 声明方式
shared float s_PosX[64];
shared float s_PosY[64];
shared float s_PosZ[64];

当线程 $t$ 访问 s_PosX[t] 时,线程 $0\sim31$ 刚好完美映射到 32 个不同的 Banks,没有任何冲突。

2. 完美的索引表(Index Buffer)存储

Meshlet的局部索引通常只需要 8-bit(因为顶点数 $\le 64$)。
一个三角形需要3个顶点索引,如果直接声明:

shared uint8_t s_Indices[64 * 3]; // 容易引发非4字节对齐的乱序Bank冲突

更优方案:将3个8位索引打包进一个32位整型中存储,或者使用 uint 数组,由每个线程负责写入一个三角形的完整索引。

// 每个三角形的 3 个索引打包到一个 uint 中 (1字节留空或用于其他标记)
shared uint s_PackedTriangles[64]; 

void WritePrimitive(uint threadId, uint i0, uint i1, uint i2) {
    // 完美的 32-bit 对齐写入,绝无 Bank Conflict
    s_PackedTriangles[threadId] = i0 | (i1 << 8) | (i2 << 16);
}

五、 黑科技:利用 Subgroup 指令彻底干掉 LDS

很多时候,我们使用LDS是为了在线程间进行数据交换(例如:计算Meshlet内所有顶点的Bounding Box,或者计算被剔除(Cull)后的活动顶点重排)。

在Adreno A7xx上,Subgroup(子群/Warp)内建指令是不经过LDS的。它们直接在寄存器层面利用硬件的数据交换通路(Shuffle Network)完成通信,延迟比LDS低一个数量级。

1. 剔除(Culling)后的顶点索引重排(Compaction)

在Mesh Shader中,我们会并行进行锥体剔除(Cone Culling)或视锥体剔除。被保留下来的三角形需要重新组织索引。

传统LDS做法(慢):

  1. 线程计算三角形是否可见。
  2. 可见的线程通过 atomicAdd 累加一个共享的LDS计数器,获取写入位置。
  3. 将结果写入LDS缓冲区。

Subgroup指令优化(极快,零LDS占用):

利用 subgroupBallotsubgroupMsa 相关指令,在寄存器中直接完成前缀和(Prefix Sum)计算:

#extension GL_KHR_shader_subgroup_ballot : require
#extension GL_KHR_shader_subgroup_arithmetic : require

void main() {
    bool primitiveVisible = EvaluateCulling(...);

    // 获取当前 Subgroup 中所有可见三角形的掩码
    uvec4 ballot = subgroupBallot(primitiveVisible);
    
    // 计算当前线程在所有可见三角形中的排位 (Prefix Sum)
    uint writeIndex = subgroupBallotExclusiveBitCount(ballot);

    if (primitiveVisible) {
        // 直接写入输出寄存器,不经过任何 LDS 转发
        gl_PrimitiveTriangleIndicesEXT[writeIndex] = uvec3(idx0, idx1, idx2);
    }
    
    // 动态获取总的可见三角形数,用于设置工作组输出总量
    uint totalActivePrims = subgroupBallotBitCount(ballot);
    if (subgroupElect()) {
        gl_PrimitiveCountEXT = totalActivePrims;
    }
}

通过这种方式,我们不仅消除了LDS的物理空间开销,还避免了原子操作(Atomic)的锁竞争,完美契合了Adreno A7xx的硬核算力。


六、 总结与真机调优 Checklist

在Adreno A7xx平台上开发高效的Mesh Shader,核心思想是**“精打细算每一字节的LDS,能用Subgroup就绝不用LDS”**。

以下是上线前的优化Checklist,供各位图形工程师参考:

  1. 线程组大小是否设为32?(优先使用 Wave32 保证占有率)。
  2. Meshlet 尺寸是否控制在 $64 / 64$ 或 $64 / 84$ 内?(坚决避免 $128 / 256$ 这种PC端配置)。
  3. LDS结构体中的所有成员是否都完成了最小化打包?(禁止 float 大杂烩,多用 f16vecuint16_t)。
  4. 访问LDS时是否存在步长非4字节倍数的跨步访问?(规避 Bank Conflict)。
  5. 是否可以通过 subgroupBallot / subgroupShuffle 替代 shared 变量的数据交换?
  6. 是否在 Task Shader 中做好了粗粒度剔除,以减少不必要的 Mesh Shader 工作组派发?

通过上述层层优化,你的Mesh Shader管线在骁龙8系列新机上将展现出惊人的吞吐量,为移动端高画质端游提供坚固的底层性能保障。

图形硬核玩家 Adreno GPUVulkan优化

评论点评