UE Shader优化:从GPU指令执行到带宽与Wave分歧的底层原理
1. 从一条像素的旅程说起GPU 到底怎么执行一条 Shader 指令很多人做 UE 优化第一反应是打开 ProfileGPU 看哪个 Pass 红了然后开始砍材质、降分辨率、关后处理。这套流程没错但如果你不知道 GPU 在硬件层面到底怎么跑一条 Shader 指令你砍掉的东西可能根本不是瓶颈甚至会把画面砍烂却换不来一帧提升。我自己就干过这种事早期做一个移动端项目看到 Base Pass 耗时高二话不说把材质复杂度从 200 条指令压到 80 条结果帧率纹丝不动——因为真正的瓶颈是带宽不是 ALU。所以这篇东西我想换个角度聊不从引擎工具出发而是从 GPU 执行指令的底层机制出发把 UE Shader 优化这件事的为什么讲清楚。适合谁看如果你已经能写 HLSL、能看懂 UE 的材质节点但对为什么这样写会快、那样写会慢始终停留在经验层面那这篇就是写给你的。如果你是完全的新手也能看懂因为我会尽量用生活化的类比把硬件概念讲明白。先建立一个最核心的认知GPU 不是更快的 CPU它的设计哲学和 CPU 完全不同。CPU 追求的是单条指令的低延迟它有庞大的乱序执行引擎、分支预测器、多级缓存为的是让一条复杂的串行逻辑尽快跑完。GPU 追求的是吞吐量它假设你有一大堆互不相关的简单任务然后用海量的执行单元同时开工用延迟换吞吐。这个根本差异决定了 Shader 优化的所有原则。一条 Shader 指令在 GPU 上的完整旅程大致是这样的Shader 代码先被编译成 GPU 的指令集在 UE 里通常经过 HLSL → 中间表示 → 目标平台 ISA 的多级编译然后这些指令被送到流处理器SMStreaming Multiprocessor里。SM 内部有多个处理块每个块里有一堆 ALU算术逻辑单元负责算加减乘除有专门的 SFU特殊功能单元负责算 sin、cos、pow、rsqrt 这类超越函数还有 LD/ST 单元负责访存。指令从指令缓存取出来分发到这些单元上执行结果写回寄存器。这里有个关键概念叫warpNVIDIA 的叫法或 wavefrontAMD 的叫法UE 里统称 wave。一个 wave 通常是 32 或 64 个线程它们共享同一条指令流。也就是说这 32 个线程在同一时刻执行的是同一条指令只是各自操作不同的数据。这就是 SIMT单指令多线程模型。理解这一点极其重要因为后面讲的所有优化技巧本质上都是在让这 32 个线程尽量步调一致、尽量少浪费。提示很多人把 wave 和线程块thread group / workgroup搞混。线程块是你在 Compute Shader 里用 numthreads 声明的那个逻辑分组一个线程块里可能包含多个 wave。wave 是硬件调度和执行的真正单位是 SIMT 的粒度。那寄存器又是什么角色寄存器是 GPU 上最快的存储比共享内存快、比显存快几个数量级。每个线程都有自己私有的寄存器空间。Shader 里所有的临时变量、中间计算结果最终都落在寄存器上。但寄存器总量是有限的——一个 SM 上寄存器文件大小固定如果每个线程占用的寄存器越多能同时驻留的 wave 就越少这就是所谓的Occupancy占用率问题。后面我会专门用一节讲这个权衡。把这条链路串起来看你的 HLSL 代码 → 编译成 ISA 指令 → 指令在 SM 上按 wave 分发执行 → ALU/SFU/LD-ST 各司其职 → 结果写回寄存器 → 寄存器不够就降低并行度。优化的本质就是在这条链路的每一环上减少浪费。ALU 算力浪费、SFU 调用浪费、带宽浪费、寄存器浪费、wave 分歧浪费——你能识别出哪种浪费占主导优化就有的放矢。2. ALU 与 SFUShader 里最容易被忽视的算力账本2.1 ALU 指令和 SFU 指令不是一回事这是我在带新人的时候发现的最普遍的认知盲区。很多人看 Shader 指令数只看总数不看类型。但 GPU 上 ALU 和 SFU 是两套独立的硬件单元它们的吞吐量差着一个数量级。打个比方ALU 就像工厂里一排排的普通工人人多、干活快一个周期能处理一大批简单计算SFU 则像工厂里少数几个高级技师专门处理那些复杂活儿三角函数、幂运算、开方、对数人少、单个活儿耗时长。如果你把大量简单计算丢给高级技师那就是浪费反过来如果你让普通工人去干高级技师的活他们干不了只能排队等。具体到数字上以常见的桌面级 GPU 架构为例一个 SM 里 ALU 的吞吐量通常是 SFU 的 4 到 8 倍。也就是说一条sin()指令的代价可能相当于 4 到 8 条mad乘加指令。UE 材质里那些看起来很轻的节点——比如Sine、Cosine、Power、SquareRoot、Log——实际上都是 SFU 指令代价远高于加减乘。那怎么优化核心思路是用 ALU 换 SFU。举几个我实际用过的替换pow(x, 2)不要用 Power 节点直接用x * x编译器通常能识别但显式写更保险。pow(x, 3)写成x * x * x比调 SFU 的 pow 快。sqrt(x)在只需要比较或近似时可以考虑用rsqrt配合乘法或者干脆避免开方。sin/cos如果只是做周期性变化很多时候可以用三角恒等式或者查表近似替代。2.2 一个真实的优化案例水面波纹我之前做过一个风格化的水面材质最初版本用了两层Sine叠加做波纹再加一个Power做菲涅尔的衰减。Profile 一看这个材质的 SFU 占用高得离谱。后来我做了三件事第一把两层 Sine 换成一层 Sine 加一层基于 UV 的偏移采样用纹理采样替代一次三角函数计算。纹理采样的代价是带宽但在带宽不紧张的场景下这比 SFU 划算。第二菲涅尔的pow(NdotV, 5)改成手动的连乘近似。pow(x,5)可以写成x2 x*x; x4 x2*x2; result x4*x;四次乘法搞定全是 ALU 指令比一次 SFU 的 pow 快。第三把一些逐像素计算的东西挪到顶点着色器里插值。顶点数量远小于像素数量在顶点阶段算三角函数性价比高得多。改完之后这个材质的耗时降了大概 30%。注意我没有减少任何视觉复杂度只是把计算从贵的单元挪到了便宜的单元从多的像素挪到了少的顶点。2.3 指令数不等于耗时依赖链才是隐形杀手还有一个反直觉的点两条独立的指令和两条有依赖关系的指令代价完全不同。GPU 靠 wave 之间的切换来隐藏延迟——当 wave A 在等一条指令的结果时调度器会切到 wave B 去执行。但如果你的 Shader 里有一条很长的依赖链比如a f(b); c g(a); d h(c); ...那么每个 wave 都在等前一步的结果调度器能切换的余地就小了延迟藏不住。这就是为什么有时候你减少指令数性能反而没提升——因为你减掉的是并行指令留下的依赖链长度没变。反过来有时候增加一点指令数但把长依赖链拆成几条短链并行算性能反而更好。实操建议在写复杂计算时有意识地让独立计算并行展开。比如你要算四个通道的加权和不要写成串行的累加而是两两配对先算再合并缩短关键路径。这个技巧在写后处理、光照累加的时候特别有用。3. 寄存器压力与 Occupancy为什么你的 Shader 明明不复杂却很慢3.1 寄存器是稀缺资源不是免费的前面提过每个线程的临时变量都占寄存器。寄存器文件在 SM 上是固定的比如某个 SM 有 65536 个 32 位寄存器。如果每个线程用 32 个寄存器那这个 SM 最多能驻留 2048 个线程如果每个线程用 64 个寄存器就只能驻留 1024 个线程。驻留的线程越少能用来隐藏延迟的 wave 就越少一旦遇到访存延迟或者长依赖SM 就可能空转。这就是Occupancy占用率的核心。Occupancy 不是越高越好但太低一定是问题。经验上占用率低于 25% 就要警惕了除非你的 Shader 是纯计算密集、几乎没有访存和长依赖。那什么会导致寄存器用量飙升几个常见元凶过多的临时变量尤其是那些为了可读性而拆出来的中间变量编译器不一定能全部优化掉。复杂的控制流分支、循环会让编译器保守地分配寄存器因为它要保证所有路径都能跑。大数组在 Shader 里声明数组如果索引是动态的编译器可能把它放到本地内存其实是显存那性能就崩了。纹理采样器太多每个采样器都要占资源虽然不全是寄存器但会挤占整体预算。3.2 怎么查寄存器用量在 UE 里你可以用r.ShaderDevelopmentMode配合编译日志或者直接看平台工具的编译输出。NVIDIA 的nvdisasm、AMD 的RGARadeon GPU Analyzer都能反汇编 Shader告诉你每条指令用了多少寄存器、有没有 spill寄存器溢出到本地内存。注意寄存器 spill 是性能杀手。一旦发生 spill数据被写到显存再读回来延迟高得吓人。如果你在反汇编里看到大量的STLstore local和LDLload local基本可以确定寄存器压力过大了。3.3 降低寄存器压力的实操手段我常用的几招第一合并计算减少中间变量。比如你本来写了float3 a ...; float3 b ...; float3 c a b;如果 a 和 b 后面不再用直接写float3 c ... ...;让编译器少分配两个寄存器。第二控制分支。能用lerp、step、saturate这类无分支写法替代if的尽量替代。无分支代码不仅避免 wave 分歧也让寄存器分配更可预测。第三拆分复杂 Shader。如果一个材质实在太重考虑把它拆成多个 Pass或者把一部分计算烘焙到纹理里。用纹理采样换计算本质上是拿带宽换寄存器和 ALU。第四注意数组和循环。固定长度的循环编译器能展开unroll展开后寄存器分配更高效动态长度的循环尽量改成固定上限加提前退出。3.4 Occupancy 和 ILP 的权衡这里有个容易被忽略的平衡Occupancy 高靠 wave 切换隐藏延迟Occupancy 低靠指令级并行ILP隐藏延迟。两者是互补的。如果你的 Shader 天然有很高的 ILP——比如大量独立并行的计算——那即使 Occupancy 低一点也没关系因为单线程内部就有足够的并行度填满流水线。反过来如果你的 Shader 依赖链很长、又没什么并行度那就必须靠高 Occupancy 来救。所以优化的时候不要盲目追求寄存器越少越好。有时候多用几个寄存器换取更短的依赖链整体反而更快。这个只能靠实测没有银弹。4. 带宽Shader 优化里最贵的那笔账4.1 为什么带宽往往才是真瓶颈ALU 算力这些年涨得飞快但显存带宽的涨幅相对慢得多。这就导致一个普遍现象现代 GPU 上绝大多数 Shader 的瓶颈是带宽不是算力。你辛辛苦苦优化 ALU 指令可能一点用没有因为 SM 大部分时间在等纹理采样或者等显存返回数据。带宽消耗主要来自几块纹理采样、顶点数据读取、渲染目标读写、UAV 读写。其中纹理采样是大头。4.2 纹理采样的隐藏成本一次纹理采样表面上看是一条指令实际上背后是一整套流程计算 UV、算 LOD、采样各级 Mipmap、做各向异性过滤、缓存查找、未命中就去显存取。如果缓存命中率高代价还好一旦缓存频繁未命中延迟和带宽都会爆炸。影响纹理采样成本的因素纹理尺寸越大越容易缓存未命中。Mipmap没有 Mipmap 的纹理远处像素采样会严重浪费带宽因为一个像素可能触发多次采样。各向异性过滤等级等级越高采样次数越多带宽越高。采样数量一个 Shader 里采样十几次带宽压力自然大。UV 的连续性UV 跳变剧烈比如三平面映射的接缝处会导致缓存命中率骤降。4.3 降低带宽的实战技巧第一合并纹理通道。把粗糙度、金属度、环境光遮蔽打包到一张纹理的不同通道里一次采样拿到三个值。这是标准操作但很多人还是习惯一张图一个通道白白浪费采样次数。第二用更小的纹理格式。能用 8 位就不用 16 位能用 BC 压缩就用压缩格式。视觉上差别可能微乎其微带宽省一半。第三控制采样次数。能用一次采样加数学运算替代两次采样的尽量替代。比如法线贴图如果精度要求不高可以用两次采样重建 Z 分量而不是存三通道。第四注意渲染目标的读写。每个 Render Target 的写入都是带宽。移动端尤其要注意能用一张 RT 就别用两张能用低精度格式就别用高精度。第五利用缓存友好性。让相邻像素访问相邻的纹理区域提高缓存命中率。这通常意味着避免复杂的 UV 变换和随机采样。4.4 一个带宽优化的真实教训我踩过一个坑做一个体积雾效果用了大量的 3D 纹理采样。桌面端跑得好好的一上移动端就卡成幻灯片。查下来发现3D 纹理在移动 GPU 上的缓存效率极差每次采样几乎都是缓存未命中带宽直接打满。后来改成用 2D 纹理切片加插值采样次数没变但缓存命中率上去了性能立刻恢复。这个教训告诉我同样的采样次数在不同的纹理类型、不同的访问模式下代价可能差好几倍。优化带宽不能只看采了几次要看采得聪不聪明。5. Wave 分歧那个让 32 个线程互相拖后腿的隐形税5.1 分歧是怎么产生的回到 SIMT 模型一个 wave 里的 32 个线程共享同一条指令流。如果代码里有一个if分支32 个线程里有的走 true 分支、有的走 false 分支硬件没法同时执行两条路径只能先执行 true 分支此时 false 的线程被屏蔽再执行 false 分支此时 true 的线程被屏蔽。这就是 wave 分歧divergence。代价是什么如果 true 和 false 分支各占一半线程那这个 wave 实际执行了两遍代码但每遍只有一半线程在干活效率直接砍半。分支嵌套越深浪费越严重。5.2 哪些写法容易触发分歧基于像素位置的 if比如屏幕左半边用方案 A右半边用方案 B这几乎必然导致分歧。基于纹理值的 if比如如果这个像素是金属就怎样怎样相邻像素的纹理值往往不同分歧严重。动态循环循环次数依赖运行时数据wave 内线程循环次数不一致也会分歧。discard/clip透明裁剪会导致部分线程提前退出剩下的线程继续跑也是一种分歧。5.3 无分支化把 if 变成数学核心思路是用数学运算替代条件跳转。常用的工具lerp(a, b, t)t 为 0 取 a为 1 取 b中间插值。可以替代很多二选一的 if。step(edge, x)x 小于 edge 返回 0否则返回 1。可以生成 0/1 掩码。saturate(x)把值夹到 [0,1]。min/max/clamp替代范围判断。sign(x)取符号。举个例子原本写float result; if (x 0.5) { result a; } else { result b; }改成float mask step(0.5, x); float result lerp(b, a, mask);两条指令搞定无分支wave 内所有线程步调一致。5.4 什么时候分支反而更好无分支不是万能药。如果分支的两条路径计算量差异极大而且 wave 内绝大多数线程走同一条路径比如 95% 走 A5% 走 B那保留分支可能更划算——因为走 A 的时候只有 5% 的线程被屏蔽浪费很小而无分支化意味着所有线程都要算 A 和 B 两套反而更慢。判断标准看 wave 内线程走各分支的比例。如果高度一致分支没问题如果五五开无分支化收益大。这个比例可以通过工具统计也可以凭经验估计——基于屏幕位置的判断通常一致性好基于纹理值的判断通常一致性差。6. 从指令到画面把优化落到 UE 材质和 Shader 的实操上6.1 材质节点背后的指令真相UE 的材质编辑器很友好拖拖节点就能出效果但每个节点背后都是真实的指令。几个常见的节点陷阱Fresnel 节点内部用了 pow是 SFU 指令。如果只是要个边缘光可以用1 - saturate(dot(N, V))加几次乘法近似。Noise 节点内部是多次三角函数和插值非常贵。能用纹理噪声就用纹理。Texture Sample 节点每次采样都是带宽。注意 UV 的计算复杂度复杂的 UV 变换会增加采样前的开销。Custom 节点可以写 HLSL是精细控制指令的好工具但要注意别写出编译器无法优化的代码。6.2 用 Custom 节点做精细控制我经常用 Custom 节点把一些计算手动展开确保编译器生成我想要的指令。比如菲涅尔// 输入Normal世界空间法线、ViewDir视线方向 float NdotV saturate(dot(Normal, ViewDir)); float fresnel 1.0 - NdotV; float fresnel2 fresnel * fresnel; float fresnel4 fresnel2 * fresnel2; float result fresnel4 * fresnel; // 近似 pow(fresnel, 5) return result;全是 ALU 指令没有 SFU比内置 Fresnel 节点便宜。6.3 Shader 变体管理被忽视的性能维度UE 的材质系统会为不同的光照模式、不同的质量等级、不同的平台生成大量 Shader 变体。变体太多会导致编译时间爆炸改一个材质等半天。包体膨胀每个变体都要存。运行时切换开销PSO 切换有成本。优化手段用Quality Switch、Feature Level Switch控制不同等级下的复杂度用Static Switch在编译期裁剪掉不需要的分支定期清理无用变体。6.4 移动端和桌面端的优化差异移动端 GPU 是 TBDRTile-Based Deferred Rendering架构和桌面的 IMRImmediate Mode Rendering差异巨大。几个关键区别带宽更宝贵移动端带宽是硬瓶颈省带宽的优先级高于省 ALU。Overdraw 代价高TBDR 下同一个像素被画多次前面的结果可能被丢弃但带宽已经花了。所以移动端要严格控制 Overdraw。分支代价更高移动端 wave 通常更小比如 16 或 32但分歧的惩罚依然存在。精度可以降移动端用 half 精度16 位浮点往往足够能省一半寄存器和带宽。提示在 UE 里可以用half精度修饰符或者用材质里的Half相关设置。但要注意不是所有计算都能降精度涉及大范围数值或者需要高精度累加的地方要谨慎。7. 优化流程先测量再动手别凭感觉7.1 建立正确的测量习惯我见过太多人优化靠感觉——觉得这个材质重就砍它觉得那个后处理贵就关它。结果往往是砍了不痛不痒的地方真正的瓶颈纹丝不动。正确的流程是先用 ProfileGPU 定位到具体 Pass。UE 的ProfileGPU命令控制台输入ProfileGPU或按 CtrlShift,能给出每个 Pass 的耗时。再定位到具体 Shader。用r.ShaderComplexity可视化材质复杂度或者用平台工具抓帧分析。判断瓶颈类型。是 ALU 密集、SFU 密集、带宽密集还是 Occupancy 受限这决定了优化方向。针对性优化。复测验证。优化前后对比确认真的有效。7.2 常见瓶颈的快速判断现象可能瓶颈优先优化方向材质指令数高但耗时不高ALU 不是瓶颈别动 ALU查带宽纹理采样多、纹理尺寸大带宽压缩纹理、合并通道、降采样寄存器用量高、有 spillOccupancy减少临时变量、拆 Shader分支多、基于纹理值判断Wave 分歧无分支化移动端卡、桌面端流畅带宽/Overdraw控制 Overdraw、降精度顶点数多、顶点 Shader 复杂顶点处理简化顶点计算、LOD7.3 优化的优先级排序根据我的经验投入产出比从高到低大致是减少 Overdraw尤其是移动端效果立竿见影。降低带宽压缩纹理、合并通道、减少 RT。消除 Wave 分歧无分支化收益稳定。控制寄存器用量避免 spill保持合理 Occupancy。ALU/SFU 优化用 ALU 换 SFU减少指令数。注意这个排序不是绝对的。如果你的场景 ALU 确实是瓶颈比如大量粒子计算那 ALU 优化就是第一优先级。永远以测量结果为准。7.4 一个完整的优化案例复盘最后分享一个我最近做的案例。一个开放世界场景远景地形在移动端掉帧严重。Profile 发现地形材质的 Base Pass 耗时占比很高。第一步查材质复杂度发现地形材质混合了四层纹理每层都有法线、粗糙度、颜色采样次数高达 12 次以上。第二步判断瓶颈。移动端12 次采样几乎肯定是带宽瓶颈。第三步优化。把四层混合改成三层远景层用更小的纹理法线只保留两层粗糙度合并到颜色纹理的 alpha 通道。采样次数从 12 降到 6。第四步复测。Base Pass 耗时降了约 40%帧率恢复。这个案例里我一条 ALU 指令都没优化纯粹是砍带宽。因为瓶颈就在带宽。8. 一些零散但值钱的经验写到这里主体内容差不多了最后补几个零散但我觉得挺值钱的经验点。关于精度能用 half 就用 half但要注意累加场景。比如你在循环里累加很多次half 的精度可能不够会累积误差。这种地方该用 float 就用 float。关于纹理 LOD手动指定 LOD 可以避免硬件自动计算 LOD 的开销也能控制采样精度。在远景或者不需要细节的地方手动给个高 LOD能省不少带宽。关于 Shader 编译UE 的 Shader 编译很慢改一次等半天。建议用r.ShaderDevelopmentMode1开启快速迭代模式或者用RecompileShaders命令只重编改动的部分。关于工具RenderDoc 是抓帧分析的神器能看每个 Draw Call 的 Shader、纹理、带宽。NVIDIA Nsight 和 AMD RGA 能反汇编 Shader看指令和寄存器。这些工具用熟了优化效率翻倍。关于心态优化是个迭代过程不要指望一次改到位。每次改一个变量测一次记录数据。慢慢你就对什么改动会带来什么效果有了直觉。这个直觉才是真正值钱的东西。我在实际项目里最大的体会是Shader 优化不是把代码写得更聪明而是把硬件用得更对口。ALU 的活给 ALUSFU 的活给 SFU带宽省着用wave 别分歧寄存器别爆。把这些基本功做扎实比追求什么奇技淫巧都管用。