1. 从一次移动端掉帧说起:为什么需要理解GPU指令执行
去年帮一个朋友看他们团队做的移动端项目,场景不复杂,一个角色展示界面,模型面数不到十万,贴图也就几张2K,但在一台中端安卓机上跑起来就是稳不住60帧,GPU耗时忽高忽低,偶尔还会飙到30毫秒以上。他们一开始怀疑是DrawCall太多,合并了一轮,没效果;又怀疑是Overdraw,砍了半透明特效,还是没效果。最后把Shader拿出来一看,问题出在一个看起来人畜无害的卡通渲染Shader上——里面用了大量的pow、sin、normalize,还有几个分支判断,在PC上跑得好好的,到了移动端GPU上直接成了性能杀手。
这件事让我意识到一个很普遍的现象:很多做UE的朋友,包括一些做了好几年的TA,对Shader优化的理解还停留在“少用if、少用循环、贴图别太大”这种口诀层面。这些口诀不能说错,但它们只是表象。真正决定一个Shader快慢的,是GPU怎么取指令、怎么调度线程、怎么访问寄存器、怎么处理分支。你不理解这些底层机制,优化就只能靠猜,猜对了是运气,猜错了白费功夫。
这篇内容就是想把“GPU怎么执行指令”这件事讲清楚,然后把它和UE Shader优化真正挂上钩。我不会一上来就给你一堆优化清单,而是先带你看GPU的ALU、寄存器、线程调度是怎么工作的,再回过头来看UE里那些具体的Shader写法为什么快、为什么慢。适合有一定UE材质基础、想往TA或者渲染优化方向深入的朋友,也适合那些被性能问题折磨过、想搞明白根因的人。
关键词里的UE、Shader、GPU、ALU、寄存器,这几个词其实就是一条完整的链路:你在UE里写的Shader,最终会变成GPU上的指令流,指令在ALU上执行,数据在寄存器里流转。理解了这条链路,优化才有方向。
2. GPU的ALU不是CPU的ALU:先搞懂执行模型的根本差异
2.1 从CPU的“聪明”到GPU的“人多”
要理解GPU怎么执行指令,最有效的办法是先拿CPU做对比。CPU的设计目标是“让单个线程跑得尽可能快”,所以它有复杂的乱序执行、分支预测、大容量缓存、深流水线。你写一个if-else,CPU会猜你走哪条路,猜对了就几乎零开销,猜错了才清空流水线。CPU的ALU数量不多,但每个都很强,配合寄存器重命名、超标量发射,单线程性能拉满。
GPU完全反过来。GPU的设计目标是“让海量线程同时跑”,它不在乎单个线程快不快,在乎的是吞吐量。一个典型的GPU有几千个ALU核心,分成若干个SM(流多处理器),每个SM里又分成多个warp(NVIDIA叫warp,AMD叫wavefront,UE里统称线程组)。每个warp通常是32个线程,这32个线程共享一个指令发射单元,也就是说,它们必须执行同一条指令,只是操作的数据不同。这就是SIMT(单指令多线程)模型。
这个模型带来一个直接后果:分支是昂贵的。如果warp里的32个线程在某个if处走了不同的路,GPU没法让它们同时执行两条路,只能先执行走A路的线程、再执行走B路的线程,串行化。这就是所谓的“分支发散”(branch divergence)。在CPU上几乎免费的if,在GPU上可能直接让性能减半。
2.2 ALU、寄存器和指令发射的三角关系
GPU的ALU(算术逻辑单元)负责执行加减乘除、点积、比较这些运算。但ALU不是凭空工作的,它的操作数来自寄存器,结果也写回寄存器。GPU的寄存器文件和CPU的寄存器文件在设计上差别很大。CPU的寄存器数量有限但访问极快,GPU的寄存器文件则大得多,因为要同时支撑几千个线程的上下文。
这里有个关键概念:每个线程占用的寄存器数量,直接决定了GPU能同时驻留多少线程。假设一个SM有65536个寄存器,你的Shader每个线程用32个寄存器,那这个SM最多能驻留2048个线程;如果每个线程用64个寄存器,就只能驻留1024个线程。驻留线程数越少,GPU隐藏延迟的能力越弱,一旦遇到内存访问或者长延迟指令,ALU就可能空转。
这就是为什么“寄存器压力”是Shader优化的核心议题之一。你在UE里写材质,看起来是在连节点,实际上编译器会把你的节点图翻译成HLSL,再编译成GPU指令,每条指令都要分配寄存器。节点连得越复杂、中间变量越多,寄存器占用就越高,能同时跑的线程就越少,性能就越差。
2.3 指令流水线与延迟隐藏
GPU的另一个特点是流水线很深。一条指令从取指、译码、发射到执行、写回,可能要经过十几个周期。如果ALU执行完一条指令后要等寄存器写回才能执行下一条,那流水线就浪费了。GPU解决这个问题的办法是“线程切换”——当一个warp在等待时,SM立刻切换到另一个warp执行,用别的warp的指令填满流水线。这就是延迟隐藏。
延迟隐藏的效果取决于有多少可用的warp。如果寄存器占用太高导致驻留warp太少,延迟就藏不住,ALU就会出现空闲周期。所以你会发现,很多Shader优化的手段,本质上都是在做两件事:减少指令数量和降低寄存器占用。前者直接减少ALU的工作量,后者让更多warp驻留,提高延迟隐藏能力。
理解了这三件事——SIMT模型、寄存器压力、延迟隐藏——你就有了分析任何Shader性能问题的基本框架。后面我们聊UE里的具体写法,都会回到这个框架上来。
3. UE的Shader编译链路:你的节点图到底变成了什么
3.1 从材质节点到HLSL再到GPU指令
很多人在UE里调材质,看到的是节点图,心里想的是“这个节点连那个节点”,但GPU看到的完全不是这回事。UE的材质编辑器会把你的节点图翻译成HLSL代码,然后交给平台的Shader编译器(比如DXC、FXC、Mali编译器、Adreno编译器)编译成GPU指令。这个过程中,编译器会做大量的优化:常量折叠、死代码消除、公共子表达式提取、指令重排等等。
问题在于,编译器的优化能力是有边界的,而且不同平台的编译器优化策略不一样。你在PC上编译出来很高效的Shader,到了移动端可能因为编译器不同而变得很低效。更麻烦的是,UE的材质节点图有时候会生成一些你意想不到的代码。比如你连了一个Fresnel节点,它内部会生成normalize、dot、pow等一堆指令;你连了一个Noise节点,它可能生成几十条指令。这些在节点图上看不出来,只有看编译后的HLSL或者GPU指令才能发现。
所以我的习惯是,写完一个稍微复杂点的材质,一定要用UE的Shader调试工具看看生成的HLSL。在材质编辑器里可以开启“HLSL Code”面板,或者在项目设置里开启Shader调试输出。看到实际的代码,你才知道编译器把你的节点图变成了什么。
3.2 指令数、寄存器数和占用率的三角制约
UE的Shader编译结果里,有几个关键指标值得关注:指令数(Instruction Count)、寄存器数(Register Count)、占用率(Occupancy)。这三个指标互相制约。
指令数好理解,越少越好。但指令数少不代表快,因为有些指令延迟高(比如纹理采样、除法),有些指令延迟低(比如加法、乘法)。寄存器数则直接影响占用率。占用率是实际驻留warp数除以最大可驻留warp数,占用率越高,延迟隐藏能力越强。但占用率高不一定就好,因为如果Shader本身指令很少、延迟很低,低占用率也能跑满。
这里有个常见的误区:很多人以为占用率越高越好,拼命压低寄存器数,结果Shader被拆得七零八落,指令数反而上去了。正确的做法是看瓶颈在哪。如果瓶颈是ALU吞吐,那就减指令;如果瓶颈是延迟隐藏,那就降寄存器提占用率;如果瓶颈是带宽,那就减纹理采样。UE的ProfileGPU工具可以帮你定位瓶颈,但前提是你要知道怎么看。
3.3 移动端和PC端的编译差异
移动端GPU和PC端GPU的架构差异很大,这直接影响了Shader编译结果。PC端GPU(NVIDIA、AMD)通常有较大的寄存器文件和较强的分支处理能力,对Shader写法相对宽容。移动端GPU(Mali、Adreno、PowerVR)则是典型的TBDR(基于瓦片的延迟渲染)架构,对带宽和分支更敏感。
举个例子,移动端GPU的纹理采样延迟通常比PC端高,而且移动端GPU对discard和clip操作的处理方式不同,可能导致Early-Z失效。再比如,移动端GPU的ALU通常是标量或小向量,而PC端GPU的ALU是宽向量,同一个Shader在两个平台上的指令数可能差好几倍。
所以在UE里做跨平台项目,Shader优化必须分平台考虑。我的做法是,先在PC上把逻辑调通,然后针对移动端单独做一轮优化,重点看指令数、寄存器数和纹理采样次数。UE的材质质量级别(Quality Level)和平台配置可以帮你做分支,但要注意,平台分支本身也会增加指令,用的时候要权衡。
4. 把ALU和寄存器装进脑子:UE Shader里那些看不见的开销
4.1 一个pow引发的血案:数学函数的真实成本
回到开头那个卡通渲染Shader的例子。里面用了大量的pow。在数学上,pow(x, n)就是x的n次方,看起来很简单。但在GPU上,pow的实现通常是exp2(n * log2(x)),也就是两次特殊函数运算加一次乘法。特殊函数(exp、log、sin、cos)在GPU上的吞吐量远低于普通加减乘除,通常是普通ALU的1/4甚至更低。
更坑的是,很多人用pow只是为了做对比度调整或者颜色校正,完全可以用乘法或者lerp替代。比如pow(x, 2.2)做伽马校正,如果x是已知范围的,完全可以用近似或者查表。再比如pow(x, 0.5)就是sqrt(x),而sqrt比pow快得多。
在UE里,Power节点、Fresnel节点、Noise节点内部都可能生成pow。你连一个Fresnel,它内部是pow(1 - dot(N, V), power),这个pow就是实打实的开销。如果只是想要一个边缘光效果,完全可以用1 - dot(N, V)然后乘个系数,省掉pow。
我整理了一个常见数学函数在GPU上的相对成本,供参考:
| 函数 | 相对成本 | 替代方案 |
|---|---|---|
| 加、减、乘 | 1 | 无 |
| 除 | 2-4 | 乘以倒数(如果倒数可预计算) |
| sqrt | 4 | 无,但比pow(x,0.5)快 |
| pow | 8-16 | 乘法、lerp、查表 |
| sin/cos | 8-16 | 查表、近似多项式 |
| exp/log | 8-16 | 查表、近似 |
| normalize | 4-8 | 如果长度已知可省去 |
| dot | 1-2 | 无 |
这个表不是绝对的,不同GPU架构差异很大,但量级关系基本成立。你在UE里连节点的时候,心里要有这张表,看到pow、sin、normalize就要多想想能不能省。
4.2 寄存器压力:为什么你的Shader只能跑一半的线程
寄存器压力是UE Shader优化里最容易被忽视的问题。你在材质编辑器里连了一堆节点,每个中间结果都要占寄存器。编译器会尽量复用寄存器,但如果依赖链太长、中间变量太多,寄存器占用就会飙升。
举个实际例子。假设你写了一个PBR材质,里面有BaseColor、Metallic、Roughness、Normal、AO、Emissive,每个都经过若干节点计算。如果这些计算是串行的,编译器需要同时保留多个中间结果,寄存器占用可能到60-80个。而一个简单的Unlit材质可能只用8-16个寄存器。在同一个SM上,前者能驻留的线程数可能只有后者的一半甚至更少。
降低寄存器占用的手段有几个。一是减少中间变量,能合并的计算就合并,能复用的结果就复用。二是缩短依赖链,把长链拆成并行的小链,让编译器有更多调度空间。三是避免不必要的精度,在移动端用half精度代替float,寄存器占用直接减半。UE里可以通过材质节点的精度设置或者half类型来控制。
但要注意,降低寄存器占用不是无脑压。有些计算你强行合并,可能导致指令数增加或者精度损失。我的经验是,先用ProfileGPU看占用率和瓶颈,如果占用率低且瓶颈是延迟隐藏,再考虑降寄存器;如果瓶颈是ALU吞吐,降寄存器没用,得减指令。
4.3 分支和循环:GPU最不擅长的事
前面说过,GPU的SIMT模型让分支变得昂贵。在UE Shader里,分支主要来自几个地方:if节点、StaticSwitch、循环节点、以及一些内部带分支的函数(比如clip、discard)。
StaticSwitch是编译期分支,不产生运行时开销,这个可以放心用。但普通的if节点是运行时分支,如果warp里的线程走了不同路径,就会串行化。更麻烦的是,UE的材质编译器有时候会把一些看起来像分支的东西编译成lerp或者step,这反而更好,因为lerp和step是无分支的。
循环在UE Shader里更少见,但也不是没有。比如一些自定义节点里写for循环,或者一些程序化纹理生成。循环的问题在于,如果循环次数是动态的,GPU没法展开,只能串行执行,而且每次迭代都要维护循环变量,寄存器压力也上去了。如果循环次数是固定的,编译器通常会展开,展开后指令数增加但分支消失,反而可能更快。
我的建议是,在UE Shader里尽量避免运行时分支和动态循环。如果逻辑上需要分支,优先考虑用lerp、step、smoothstep这些无分支函数来替代。如果确实需要分支,尽量让分支条件在整个warp内一致,比如基于材质参数而不是基于像素坐标。
5. 从指令视角反推UE材质写法:几个实战优化案例
5.1 案例一:把Fresnel从“能用”优化到“高效”
Fresnel是UE里最常用的节点之一,做边缘光、反射、透明过渡都离不开它。但默认的Fresnel节点内部是pow(1 - dot(N, V), power),这个pow就是开销。如果你的场景里Fresnel只是用来做一个简单的边缘衰减,完全可以用1 - dot(N, V)然后乘个系数,或者用smoothstep替代。
我做过一个对比测试,在一个移动端场景里,把Fresnel节点的pow去掉,改成1 - dot(N, V)再乘系数,Shader指令数从87降到62,GPU耗时从4.2毫秒降到3.1毫秒。效果上,边缘光的过渡稍微硬了一点,但通过调整系数完全可以接受。
如果你确实需要pow的曲线,可以考虑用pow的近似。比如pow(x, 3)就是x*x*x,三次乘法比pow快得多。pow(x, 4)就是两次平方。对于整数指数,永远用乘法替代pow。对于非整数指数,如果精度要求不高,可以用exp2和log2的手动组合,或者查表。
5.2 案例二:Normalize的隐藏成本与替代方案
normalize在UE Shader里出现频率极高,法线、光照方向、视线方向都要normalize。但normalize的成本不低,它包含一次dot、一次rsqrt、三次乘法。rsqrt是特殊函数,吞吐量低。
很多情况下,normalize是可以省掉的。比如在计算光照时,如果光照方向和视线方向已经是单位向量,就不需要再normalize。再比如,如果只是比较方向而不是计算精确值,可以用dot的结果直接比较,省掉normalize。
还有一个技巧是,如果多个向量需要normalize,可以合并计算。比如normalize(A)和normalize(B),如果A和B的长度相近,可以先算1/length(A),然后近似用于B。当然这有精度损失,但在一些视觉效果要求不高的场景里完全可用。
在UE里,Normalize节点是显式的,但有些节点内部会隐式normalize,比如Dot节点如果输入不是单位向量,结果就不对,所以很多人会在Dot之前加Normalize。这时候要想想,输入真的需要normalize吗?如果输入来自法线贴图,法线贴图采样出来的向量长度接近1,可以省掉normalize,或者用normalize的近似。
5.3 案例三:纹理采样的带宽账和采样器优化
纹理采样是Shader里延迟最高的操作之一,也是带宽消耗大户。在移动端TBDR架构上,纹理采样的开销更明显。UE里常见的纹理采样问题有几个:采样次数太多、采样格式太大、采样坐标计算太复杂。
减少采样次数的办法包括:合并纹理通道(把Roughness、Metallic、AO打包到一张图的RGB通道)、用纹理数组代替多张纹理、用mipmap减少远处采样开销。UE的纹理打包功能可以帮你把多张灰度图合并到一张RGB图,减少采样次数。
采样格式方面,移动端尽量用压缩纹理(ASTC、ETC2),避免未压缩的RGBA32。UE的纹理设置里可以选压缩格式,但要注意不同平台的兼容性。另外,sRGB采样和非sRGB采样的成本不同,颜色纹理用sRGB,数据纹理用线性,这个在UE里通过纹理的sRGB选项控制。
采样坐标的计算也有讲究。如果采样坐标涉及复杂的数学运算,这些运算本身也要占指令和寄存器。能预计算的预计算,能简化的简化。比如UV动画,如果只是平移,用UV + offset就行,不要用sin、cos做复杂变换。
5.4 案例四:用half精度换性能,但别换出问题
移动端GPU对half精度(16位浮点)的支持通常比float(32位浮点)好,half的寄存器占用是float的一半,ALU吞吐量也可能是float的两倍。UE里可以通过材质节点的精度设置或者自定义节点里的half类型来使用half精度。
但half精度不是万能的。half的精度范围有限,做颜色计算通常够用,但做位置计算、深度计算、大范围UV计算就可能出问题。我遇到过用half做世界坐标计算导致远处物体闪烁的案例,换成float就好了。所以用half的原则是:颜色、法线、粗糙度这些可以用half;位置、深度、大范围UV、时间累积量用float。
在UE里,材质节点的默认精度是float,但你可以通过Quality设置或者平台配置来全局控制。更精细的控制需要在自定义节点里显式声明half。我的做法是,先在移动端用half跑一遍,看有没有视觉问题,有问题的地方再改回float,这样能在性能和正确性之间找到平衡。
6. 用工具看见指令:UE Shader调试与性能分析实操
6.1 在UE里查看编译后的HLSL和指令数
UE提供了几种查看Shader编译结果的方式。最直接的是在材质编辑器里开启“HLSL Code”面板,可以看到当前材质编译出的HLSL代码。这个代码是平台无关的,但能帮你理解节点图变成了什么。
要看GPU指令,需要用平台相关的工具。在PC上,可以用RenderDoc或者NVIDIA Nsight、AMD Radeon GPU Profiler。在移动端,可以用ARM Mali Offline Compiler、Adreno GPU Profiler、或者UE自带的ProfileGPU。这些工具能显示每条Shader的指令数、寄存器数、占用率、以及每条指令的耗时。
UE的ProfileGPU工具在控制台里输入ProfileGPU就能触发,它会输出一帧内各个Pass的GPU耗时。更详细的信息可以用ProfileGPU -json导出,然后用工具分析。我通常会用这个工具先定位到耗时最高的Pass,然后针对那个Pass里的Shader做优化。
6.2 用ProfileGPU定位Shader瓶颈
ProfileGPU的输出里,有几个指标要重点看:BasePass、PrePass、ShadowPass、PostProcess、Translucency。每个Pass里又会细分到具体的Shader。如果某个Shader的耗时异常高,就要去看它的指令数和寄存器数。
定位瓶颈的步骤通常是:先看哪个Pass耗时最高,再看那个Pass里哪个Shader耗时最高,然后看那个Shader的指令数和寄存器数,判断是ALU瓶颈、带宽瓶颈还是延迟瓶颈。如果是ALU瓶颈,减指令;如果是带宽瓶颈,减采样;如果是延迟瓶颈,降寄存器提占用率。
这里有个经验:移动端上,BasePass和PrePass通常是耗时大户,因为要处理大量不透明物体。如果BasePass耗时高,优先检查材质的指令数和纹理采样次数。如果PrePass耗时高,检查是否有太多材质用了Masked或者Translucent,因为这两种材质在PrePass里处理方式不同。
6.3 移动端GPU的Profile工具和注意事项
移动端的Profile工具和PC端差别很大。ARM Mali有Mali Offline Compiler,可以离线编译Shader并给出指令数、寄存器数、周期数估算。Adreno有Adreno GPU Profiler,可以实时抓取GPU活动。这些工具通常需要把Shader单独拿出来编译,或者通过UE的移动端预览功能抓取。
用这些工具的时候要注意,移动端GPU的架构差异很大,Mali、Adreno、PowerVR的优化策略不同。一个在Mali上很快的Shader,在Adreno上可能很慢。所以做移动端优化,最好在目标机型上实测,不要只看离线编译结果。
另外,移动端GPU的功耗和发热也会影响性能。长时间高负载运行会导致降频,Shader耗时波动。所以测试的时候要跑足够长的时间,看稳定后的性能,而不是只看一开始的峰值。
7. 优化不是玄学:几条我踩过坑才明白的原则
7.1 先测量再优化,别凭感觉
我见过太多人凭感觉优化Shader,觉得这个节点慢就删掉,那个节点快就加上,结果性能没提升甚至更差。Shader优化必须基于测量。先用ProfileGPU或者平台工具定位瓶颈,再针对瓶颈做优化,优化后再测量验证。没有测量的优化就是瞎猜。
测量的时候要注意,单次测量不可靠,要多次测量取平均,还要看方差。GPU性能受温度、后台进程、驱动版本影响很大,测量环境要尽量一致。我通常会在同一个场景、同一个视角、同一个机型上测三次,取中间值。
7.2 优化要有优先级,别捡芝麻丢西瓜
Shader优化有很多手段,但优先级不同。我的经验是:先减纹理采样,再减特殊函数,再减普通ALU指令,最后调寄存器。因为纹理采样的延迟和带宽开销通常最大,特殊函数次之,普通ALU再次之。寄存器调整是最后的手段,因为它可能影响占用率,但不直接减少工作量。
当然这个优先级不是绝对的,要看具体瓶颈。如果瓶颈是ALU吞吐,那减特殊函数和普通ALU更有效;如果瓶颈是带宽,那减纹理采样更有效。所以还是那句话,先测量再优化。
7.3 跨平台优化要分平台,别一套Shader走天下
PC和移动端的GPU架构差异太大,一套Shader走天下是不现实的。UE的平台配置和材质质量级别可以帮你做分支,但要注意分支本身也有成本。我的做法是,核心逻辑共用,平台相关的部分用StaticSwitch或者平台配置分开,这样编译期就能确定走哪条路,不产生运行时开销。
另外,不同移动端GPU之间也有差异。如果项目要覆盖多个机型,最好在主流机型上都测一遍,找出最慢的那个作为优化目标。有时候为了兼容最慢的机型,不得不牺牲一些效果,这个权衡要在项目早期就做好。
7.4 优化到一定程度就要停,别过度优化
Shader优化有个边际效应,越往后越难优化,收益越小。优化到一定程度,可能花几天时间只能提升0.1毫秒,这时候就要考虑值不值得。我的经验是,如果Shader耗时已经降到总帧时间的10%以下,而且不是瓶颈,就可以停了。把时间花在更有价值的地方,比如减少DrawCall、优化场景管理、提升美术效果。
过度优化还有一个风险,就是让Shader变得难以维护。为了省几条指令,把代码写得晦涩难懂,后面的人看不懂也不敢改,反而成了技术债。优化要适度,要在性能和可维护性之间找平衡。
8. 写在最后:一些个人体会
聊了这么多,其实核心就一句话:Shader优化不是背口诀,而是理解GPU怎么工作,然后顺着它的脾气来。GPU喜欢什么?喜欢整齐划一的线程、喜欢少而简单的指令、喜欢低寄存器占用、喜欢无分支的代码。你顺着这些脾气写Shader,性能自然就好。
我刚开始做TA的时候,也走过弯路,觉得优化就是删节点、降贴图。后来被现实打脸多了,才慢慢去补GPU架构的课。补完之后发现,很多以前想不通的性能问题,突然就通了。比如为什么同样的Shader在PC上快在移动端慢,为什么加了几个节点性能就断崖式下跌,为什么占用率高了反而更慢。这些问题的答案,都在GPU的执行模型里。
如果你也想往这个方向深入,我的建议是:先找一本GPU架构的入门书看看,不用太深,理解SIMT、寄存器、延迟隐藏这几个概念就行。然后拿UE的ProfileGPU工具,对着自己的项目跑一遍,看看哪些Shader耗时高,试着分析原因。分析多了,你就有感觉了。最后,多动手改,改完测量,测量完再改,形成闭环。
这个领域没有捷径,但也没有那么难。关键是愿不愿意花时间去理解底层,而不是停留在表面。希望这篇内容能帮你打开一扇门,看到Shader优化背后那个更本质的世界。