深入Adreno A7xx GPU:如何榨干Mesh Shader的Threadgroup Memory性能?
在移动端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支持Wave32和Wave64两种执行模式。对于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做法(慢):
- 线程计算三角形是否可见。
- 可见的线程通过
atomicAdd累加一个共享的LDS计数器,获取写入位置。 - 将结果写入LDS缓冲区。
Subgroup指令优化(极快,零LDS占用):
利用 subgroupBallot 和 subgroupMsa 相关指令,在寄存器中直接完成前缀和(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,供各位图形工程师参考:
- 线程组大小是否设为32?(优先使用 Wave32 保证占有率)。
- Meshlet 尺寸是否控制在 $64 / 64$ 或 $64 / 84$ 内?(坚决避免 $128 / 256$ 这种PC端配置)。
- LDS结构体中的所有成员是否都完成了最小化打包?(禁止
float大杂烩,多用f16vec、uint16_t)。 - 访问LDS时是否存在步长非4字节倍数的跨步访问?(规避 Bank Conflict)。
- 是否可以通过
subgroupBallot/subgroupShuffle替代shared变量的数据交换?。 - 是否在 Task Shader 中做好了粗粒度剔除,以减少不必要的 Mesh Shader 工作组派发?
通过上述层层优化,你的Mesh Shader管线在骁龙8系列新机上将展现出惊人的吞吐量,为移动端高画质端游提供坚固的底层性能保障。