Base-GPU硬件架构基础

记录一下GPU硬件里ALU、SIMD、Warp/Wave和half精度这些概念的关系。

平时看GPU参数会看到很多名词:多少个ALU、多少个Shader Core、SIMD宽度、Warp、Wave32、FP16双倍性能。这些东西属于不同层级,如果混在一起就很容易得到错误的结论。

先用一句话理解:

ALU:真正执行数学指令的电路。

SIMD/SIMT:让一条指令同时驱动多份数据的执行方式。

Warp/Wave:GPU调度和执行的一组Shader线程。

SM/CU/Shader Core:包含ALU、寄存器、调度器、缓存等资源的更大计算单元。

1.GPU为什么需要大量并行?

渲染一帧时,同一段Shader会处理大量顶点和像素。

比如一个Fragment Shader需要对200万个像素做相似的计算:

采样纹理

计算法线

计算光照

输出颜色

GPU不会像CPU一样重点优化单个线程的复杂控制流,而是安排很多线程同时处理不同数据。只要工作足够多,就可以用高吞吐量隐藏单条指令和内存访问的延迟。

2.ALU是什么?

ALU(Arithmetic Logic Unit,算术逻辑单元)是物理上执行运算的电路,可以理解为GPU里的最小计算执行单元之一。

常见运算包括:

加法、减法、乘法、乘加。

与、或、异或、移位、比较。

Min、Max、Abs等简单数学操作。

但GPU里并不是只有一种万能ALU。不同架构可能还有:

FP32/FP16浮点单元。

INT32整数单元。

SFU特殊函数单元,处理倒数、平方根、三角函数等。

Tensor/Matrix单元,处理矩阵运算。

Load/Store单元,负责内存读写。

Texture Unit,负责纹理寻址、过滤和采样。

所以Shader里的每条指令不一定使用同一种硬件单元。

ALU数量是否直接决定GPU性能?

ALU数量会影响理论计算能力,但不能单独决定GPU性能。

理论计算吞吐大致还要考虑:

ALU数量

每周期可以完成的运算数

GPU频率

指令类型和精度

实际性能还会受到:

ALU利用率

寄存器数量和Occupancy

Cache与显存带宽

纹理采样能力

分支发散

指令依赖和内存延迟

调度器能否持续提供可执行线程

两个GPU即使ALU数量相同,也可能因为架构、频率、缓存和带宽不同而性能差很多。

更准确的说法是:ALU数量决定理论计算能力的一部分,能不能把这些ALU喂满才决定实际吞吐量。

3.SIMD是什么?

SIMD(Single Instruction Multiple Data,单指令多数据)表示一条指令同时处理多份数据。

例如有4个像素需要执行相同加法:

1
2
3
4
Color0 = Color0 + Light0
Color1 = Color1 + Light1
Color2 = Color2 + Light2
Color3 = Color3 + Light3

标量执行可以理解为一条一条计算。4-Lane SIMD可以用一条指令同时驱动4条Lane:

1
[Color0, Color1, Color2, Color3] + [Light0, Light1, Light2, Light3]

SIMD并不是某个独立零件的固定名字,更像ALU的组织和执行方式。一个SIMD执行单元内部有多条Lane,每条Lane处理一个线程当前的数据。

SIMD宽度

SIMD宽度通常表示一条指令能同时覆盖多少条数据Lane。

为了方便理解,可以假设有一个128bit的执行数据通路:

4 x 32bit float

8 x 16bit half

这个模型可以帮助理解为什么原生FP16硬件有机会在同样宽度里处理更多数据。

但这不是所有GPU都真实采用的结构。GPU可能使用分周期执行、双发射、Packed Math、独立FP16管线等不同实现,不能看到“128bit”就直接推导所有指令的吞吐量。

4.SIMD和SIMT的区别

SIMD通常从硬件指令角度描述“一条指令处理多个数据”。

GPU编程更常用SIMT(Single Instruction Multiple Threads,单指令多线程)理解:

每个Shader线程有自己的寄存器和数据。

多个线程组成一个执行组。

执行组内线程共享当前指令流。

硬件再用SIMD Lane执行这些线程。

对Shader开发者来说,代码看起来是每个线程独立执行:

1
float3 color = albedo * lightColor;

实际硬件会把多个线程的这条乘法组织起来并行执行。

可以简单理解为:

SIMT是开发者看到的线程模型。

SIMD是底层硬件执行这些线程的一种方式。

5.标量ALU和矢量ALU

一些GPU架构会区分Scalar和Vector执行资源。

Vector ALU处理每个Lane不同的数据:

1
float brightness = input.color.r * input.light;

每个像素的input不同,需要每条Lane分别计算。

Scalar ALU适合执行整个Wave都相同的值:

1
float value = _MaterialScale * _GlobalIntensity;

如果两个变量在一次DrawCall里不变,没必要让Wave里每个线程重复计算相同结果。支持Scalar执行的架构可以计算一次,再广播给整个Wave。

“一个SIMD里有几个Vector ALU、几个Scalar ALU”是具体架构的设计,不能当作所有GPU的通用结构。写Shader时更重要的是区分:

Uniform数据:整个DrawCall或Wave一致。

Per-Lane数据:每个顶点、像素或线程不同。

6.Warp和Wave是什么?

Warp/Wave是GPU调度和执行的一组线程。

Nvidia称为Warp,现代Nvidia GPU一个Warp通常是32个线程。

AMD称为Wavefront或Wave,GCN常见Wave64,RDNA主要使用Wave32,也可以存在Wave64模式。

Vulkan和跨平台API通常使用Subgroup描述类似概念。

Mali、Adreno、Apple GPU也有对应的线程执行组,但宽度会随厂商、架构代际、Shader阶段和编译结果变化。不要在通用Shader代码里硬编码“Mali一定16线程”或“Adreno一定8宽”。需要Subgroup算法时,应该使用API能力查询和目标设备实测。

Warp大小和SIMD宽度是一个东西吗?

不一定。

Warp/Wave大小是逻辑执行组的线程数,SIMD宽度是物理执行管线单次覆盖的Lane数量。

假设:

一个Wave有16个线程。

物理SIMD宽度为4。

最简单的理解是,同一条Wave指令需要分4组覆盖所有线程,也就是至少经过4次Lane执行。

但是现代GPU有流水线、多组执行单元、双发射和不同指令延迟,不能简单认为所有指令都严格等于4个完整时钟周期。这个除法只适合理解“逻辑线程组可能大于物理执行宽度”。

7.SM、CU和Shader Core是什么?

比SIMD更高一层,是厂商定义的GPU计算核心:

Nvidia:SM(Streaming Multiprocessor)。

AMD:CU/WGP(Compute Unit/Work Group Processor)。

Arm:Shader Core。

Qualcomm、Apple也有自己的核心组织方式。

一个计算核心通常不只有ALU,还包含:

Warp/Wave调度器

SIMD执行单元

Scalar/Vector寄存器

Load/Store单元

Texture相关单元

Local/Shared Memory

L1 Cache

特殊函数单元

调度器每个周期从当前驻留的Warp/Wave里选择已经准备好的指令,送到对应执行单元。

当一个Wave在等待纹理或内存时,调度器可以切换到另一个Wave执行。这也是GPU用大量线程隐藏延迟的核心方式。

8.为什么ALU会填不满?

GPU优化目标之一就是让执行单元持续有有效工作,而不是让大量Lane或整个管线空闲。

1.工作量不足

如果只有很少的顶点、像素或Compute线程,无法生成足够多的Warp/Wave,GPU很多核心没有工作。

优化思路:

合并过小的Dispatch。

避免大量只有几个线程的小任务。

Compute Shader的Thread Group数量要足够覆盖GPU。

注意:最后一个Wave没有填满只是局部浪费。真正更常见的问题是整个Dispatch规模太小,或者有太多小Draw/Dispatch带来调度成本。

2.分支发散

同一个Warp/Wave里的线程进入不同分支,硬件需要使用Active Mask分别执行不同路径。

执行A分支时,B分支线程对应的Lane被屏蔽;执行B分支时相反。ALU虽然在发指令,但部分Lane没有产生有效结果。

详细原理见上一篇Shader分支文章。

3.指令依赖

1
2
3
float a = ExpensiveFunction(x);
float b = a * 2.0;
float c = b + 1.0;

后面的指令依赖前面结果,单个线程无法同时执行这些指令。如果没有其他独立指令或其他Wave可以切换,执行管线就会等待。

GPU会通过以下方式隐藏延迟:

同一线程内寻找互不依赖的指令,也就是ILP。

切换到其他已经准备好的Warp/Wave,也就是TLP。

4.内存和纹理等待

ALU需要的数据还没从Cache、显存或Texture Unit返回,只能等待。

这类Shader不是ALU瓶颈,而是Memory/Texture Bound。继续减少几条乘加指令通常没有意义,应该优化数据格式、采样数量、访问连续性和Cache命中。

5.寄存器压力过高

每个Shader线程使用的临时变量越多,消耗的寄存器越多。同一个核心能同时驻留的Warp/Wave数量就可能下降,也就是Occupancy降低。

可切换的Wave少了,纹理和指令延迟更难隐藏。

所以复杂Shader即使ALU指令不算多,也可能因为寄存器压力而变慢。

9.吞吐量和延迟的区别

延迟(Latency):一条指令从发出到结果可用需要多久。

吞吐量(Throughput):单位时间内整个GPU能完成多少工作。

GPU并不要求每条指令延迟特别低,而是依靠大量线程和流水线保持高吞吐量。

例如纹理采样可能要等待很多周期,但只要有其他Wave可以执行,ALU仍然可以保持忙碌。只有当所有驻留Wave都在等待时,硬件才真正停顿。

10.half和float是什么?

half/FP16:通常是16bit浮点数。

float/FP32:通常是32bit浮点数。

FP16占用位数更少,但表示范围和精度也更低。

粗略理解:

FP16有效精度大约3位十进制小数,最大有限值约65504。

FP32有效精度大约7位十进制小数,范围远大于FP16。

具体误差还和数值大小有关。浮点数越大,相邻可表示数之间的距离越大。

为什么half可能更快?

在原生支持FP16的GPU上,half可能有这些收益:

1.同样执行资源每周期处理更多FP16运算,某些架构可达到FP32的2倍算术吞吐。

2.临时变量占用更少寄存器,可能提高Occupancy。

3.使用真实16bit存储格式时,内存和插值带宽更低。

4.Cache一次可以容纳更多数据。

但是这些收益不是任何GPU、任何指令都有。

half一定比float快2倍或4倍吗?

不一定。

“128bit一次处理8个half、4个float,所以算术2倍;half带宽又减半,所以整体4倍”这个推导不能直接成立。

原因是:

计算吞吐和带宽瓶颈通常不会简单相乘。

Shader可能只受其中一个瓶颈限制。

有些GPU会把half提升到FP32执行。

有些指令没有FP16双速率版本。

half变量不一定按16bit存进Buffer。

数据转换和Pack/Unpack本身也可能产生指令。

纹理格式、插值器和寄存器是否真正使用16bit取决于平台和编译结果。

比较合理的结论是:在原生FP16友好的移动GPU上,half可能同时改善算术吞吐、寄存器压力和带宽,但最终收益需要看具体瓶颈。

11.Unity里的half需要注意什么?

1
2
3
half3 color;
half roughness;
float3 worldPosition;

Unity文档说明,half在部分高性能平台默认可能被编译成float。Unity 6可以通过Shader Precision Model影响跨平台精度映射。

另外,Shader里的half变量和内存里的16bit存储不是一回事。

例如:

Constant Buffer里的half可能仍按32bit大小和对齐存储。

Texture是否省带宽取决于R16F、RGBA16F、RGBA8、压缩纹理等真实格式。

StructuredBuffer是否省带宽取决于数据结构、API和真实16bit类型支持。

min16float表示“至少16bit精度”,驱动也允许使用32bit执行。

所以只把源码里的float改成half,不保证Buffer大小和纹理带宽自动减半。

12.哪些数据适合half?

通常比较适合:

0到1范围的颜色、Mask、粗糙度、金属度。

范围受控的UV和局部坐标。

归一化方向、切线空间法线。

范围明确的材质参数。

允许小误差的光照中间结果。

粒子和普通特效数据。

需要注意的是,归一化向量本身范围适合half,但高光、反射和多次累积对误差可能敏感,不能看到Normal就一律half。

通常应该保留float:

大范围World Position。

View Space Position和深度相关计算。

矩阵变换及多次累积结果。

对精度敏感的几何、阴影和重建算法。

大参数或大范围UV。

长循环累加、积分和误差会持续放大的计算。

对三角函数、指数函数结果精度敏感的部分。

最容易发现的half精度问题:

颜色Banding。

高光闪烁。

远处顶点抖动。

深度重建错误。

UV跳变和纹理采样不稳定。

13.Mali和Adreno的SIMD宽度怎么理解?

“Mali SIMD宽度为4,Adreno SIMD为8或更宽”可以作为某些具体代际资料里的例子,但不能推广到所有Mali和Adreno。

移动GPU架构变化很快:

执行组大小可能变化。

物理ALU宽度和逻辑Subgroup大小可能不同。

同一GPU对不同Shader阶段和精度可能使用不同执行方式。

编译器可能合并、拆分或重新调度指令。

因此跨平台优化时更有价值的结论不是背一个固定宽度,而是:

保持足够的并行工作量。

减少Wave内发散。

控制寄存器压力。

让内存访问连续。

在支持的平台合理使用FP16。

用厂商工具查看真实ISA和性能计数器。

14.从Shader代码怎么对应到硬件?

1
2
3
half3 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv).rgb;
half ndotl = saturate(dot(normal, lightDir));
half3 color = albedo * lightColor * ndotl;

大致会经过:

1.Texture Unit根据UV做纹理采样。

2.采样结果返回寄存器。

3.ALU执行dot、saturate和乘法。

4.多个Shader线程组成Warp/Wave。

5.调度器把同一条指令发给SIMD执行单元。

6.如果使用原生FP16,编译器和硬件可能使用Packed FP16提高吞吐。

7.如果纹理结果还没返回,调度器切换到其他Wave隐藏延迟。

这就是Shader源码、Warp/Wave、SIMD和ALU之间的完整关系。

15.GPU硬件角度的Shader优化

1.先判断瓶颈

ALU Bound:数学指令和特殊函数多。

Texture Bound:纹理采样和过滤压力大。

Bandwidth Bound:大量读写显存。

Latency Bound:并行度或Occupancy不足,无法隐藏等待。

瓶颈不同,优化方向完全不同。

2.提高ALU有效利用率

保持足够工作量。

减少高频分支发散。

减少长指令依赖链。

控制临时变量和寄存器压力。

3.合理使用half

从颜色、Mask、材质参数等安全数据开始。

World Position、Depth和精确几何保留float。

同时检查真实纹理和Buffer格式,不能只改变量声明。

4.提高内存连续性

相邻线程尽量访问相邻数据。

使用合适纹理格式和压缩。

避免随机Buffer访问。

减少不必要的中间RenderTarget读写。

5.查看编译结果

源码不能完全代表GPU执行结果。编译器可能:

合并乘加指令。

删除无用计算。

把half提升到float。

把分支改成Select。

增加Pack/Unpack和类型转换。

应该结合平台工具查看ISA、寄存器、Occupancy和性能计数器。

常用工具:

Nvidia Nsight Graphics

AMD Radeon GPU Analyzer / Radeon GPU Profiler

Arm Performance Studio / Mali Offline Compiler

Snapdragon Profiler

Xcode GPU Frame Capture

16.核心总结

GPU计算架构可以按这个顺序理解:

Shader产生大量线程。

线程组成Warp/Wave/Subgroup。

调度器选择准备好的Warp/Wave指令。

SIMD执行单元让多条Lane同时工作。

ALU在每条Lane上真正完成数学和逻辑运算。

当纹理和内存产生延迟时,GPU切换其他Warp/Wave继续执行;当工作量、Occupancy或并行性不足时,ALU就会空闲。

half优化的核心也不是“位数减半就必然快4倍”,而是看目标GPU是否真正支持更高FP16吞吐、更低寄存器压力和真实16bit存储。

最后一句话:

ALU决定能做计算,SIMD决定怎么并行做,Warp/Wave决定哪些线程一起做,调度器和内存系统决定ALU能不能一直有活干。


引用参考:

NVIDIA CUDA C++ Programming Guide

AMD GPUOpen - RDNA Performance Guide

Unity Manual - Use 16-bit precision in shaders

Microsoft HLSL - Scalar data types

Khronos Vulkan Specification - Subgroup operations

Arm Mali GPU Best Practices