
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-elseCPU会猜你走哪条路猜对了就几乎零开销猜错了才清空流水线。CPU的ALU数量不多但每个都很强配合寄存器重命名、超标量发射单线程性能拉满。GPU完全反过来。GPU的设计目标是“让海量线程同时跑”它不在乎单个线程快不快在乎的是吞吐量。一个典型的GPU有几千个ALU核心分成若干个SM流多处理器每个SM里又分成多个warpNVIDIA叫warpAMD叫wavefrontUE里统称线程组。每个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端GPUNVIDIA、AMD通常有较大的寄存器文件和较强的分支处理能力对Shader写法相对宽容。移动端GPUMali、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乘以倒数如果倒数可预计算sqrt4无但比pow(x,0.5)快pow8-16乘法、lerp、查表sin/cos8-16查表、近似多项式exp/log8-16查表、近似normalize4-8如果长度已知可省去dot1-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降到62GPU耗时从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位浮点的支持通常比float32位浮点好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优化背后那个更本质的世界。