ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

GPU指令执行原理与Shader优化实战:从移动端掉帧到性能翻倍

GPU指令执行原理与Shader优化实战:从移动端掉帧到性能翻倍 1. 从一次移动端掉帧说起为什么必须理解 GPU 的指令执行去年帮一个朋友看他们团队做的移动端项目场景不复杂一个角色加几个特效在编辑器里跑得挺欢打包到手机上直接掉到二十几帧。他第一反应是模型面数太高把模型砍了一半帧数纹丝不动。第二反应是DrawCall 太多合批做完涨了三帧。折腾了两天最后发现问题出在一个看起来人畜无害的材质上——那个材质里塞了七八个if分支和一堆pow、sin运算在 PC 上完全看不出来到了移动 GPU 上直接成了性能黑洞。这件事让我意识到一个很普遍的问题很多人做 Shader 优化靠的是听说和经验口诀——听说if不好就全改成step听说pow慢就全换成乘法听说纹理采样贵就拼命减采样次数。这些口诀有些是对的有些是半对有些在特定硬件上完全是错的。根本原因在于大家不知道 GPU 到底是怎么执行这些指令的。你写的 HLSL 或 Shader Graph最终会被编译成一串 GPU 能懂的机器指令这些指令在 GPU 的 ALU算术逻辑单元上执行中间要经过寄存器读写、指令调度、线程束Warp/Wavefront切换。优化的本质不是让代码看起来简洁而是让 GPU 的指令流水线跑得更满、寄存器压力更小、线程束发散更少。不理解这一层你所有的优化都是盲人摸象。这篇内容适合谁看如果你写过 UE 的材质和自定义 Shader知道什么是节点、什么是 HLSL但对为什么这样写就快、那样写就慢没有底层的判断依据那这篇就是写给你的。我不会一上来就丢一堆架构图而是从 GPU 执行一条指令的完整链路讲起把 ALU、寄存器、线程束这些概念落到你每天写的 Shader 代码上最后给出可以直接抄的优化清单和实测数据。需要提前说明的是不同厂商、不同架构的 GPU 在细节上差异很大比如桌面独显和移动端 GPU 的 ALU 宽度、寄存器文件大小、线程束调度策略都不一样。我下面讲的是一套通用的心智模型具体到某个平台时我会标注出需要注意的差异点。你不需要记住每个架构的参数但需要建立我写的这行代码在 GPU 上大概会变成什么的直觉。2. GPU 执行一条 Shader 指令的完整链路2.1 从 HLSL 到机器码编译器在背后做了什么你在 UE 里写一个材质节点或者写一段自定义 HLSL点下应用的那一刻背后发生了一长串事情。UE 的材质编译器会先把节点图翻译成 HLSL 代码然后交给平台的 Shader 编译器比如 DXC、FXC 或者移动端的离线编译器编译成中间表示再经过驱动层的优化和转换最终变成 GPU 能执行的机器码。这个链路里有两个关键点值得注意。第一你写的代码和最终执行的代码之间隔了好几层编译器和驱动会做大量的优化比如常量折叠、死代码消除、指令重排。这意味着有些优化其实编译器早就帮你做了你手动改反而可能干扰它。第二不同平台的编译器行为差异巨大。同一个材质在 PC 上编译出来可能是 50 条指令在移动端可能是 80 条因为移动端编译器对某些指令的支持更保守或者会插入额外的精度转换指令。我实测过一个案例一个用了smoothstep的材质在 PC 上编译后是 4 条指令在某个移动平台上编译后变成了 11 条因为该平台没有原生的smoothstep指令编译器把它展开成了clamp、multiply、subtract等一堆基础指令。这就是为什么跨平台项目不能只看 PC 上的表现。2.2 ALU 到底在算什么一条指令的生命周期GPU 的 ALU 是真正干活的单元它负责执行加减乘除、比较、位运算这些算术逻辑指令。但 ALU 不是单独工作的它执行一条指令的完整流程大致是这样的取指从指令缓存里取出下一条要执行的指令。译码解析这条指令要做什么操作操作数在哪里。读寄存器从寄存器文件里读取源操作数。执行ALU 进行计算。写回把结果写回寄存器。听起来和 CPU 差不多但关键差异在于GPU 是 SIMT单指令多线程架构。一个 ALU 不是处理一个像素而是同时处理一整个线程束NVIDIA 叫 Warp通常是 32 个线程AMD 叫 Wavefront通常是 64 个线程。也就是说一条指令发出后32 个或 64 个线程同时执行同一条指令只是各自的数据不同。这个特性带来一个非常重要的后果如果同一个线程束里的线程走了不同的分支GPU 只能串行执行两个分支。比如你写了一个if (x 0.5)线程束里一半线程满足条件、一半不满足那 GPU 会先让满足的线程执行 if 块不满足的线程被屏蔽再让不满足的线程执行 else 块满足的线程被屏蔽。原本一条指令能搞定的事变成了两条性能直接打对折。这就是所谓的线程束发散Warp Divergence。2.3 寄存器Shader 性能的隐形战场寄存器是 GPU 上最快的存储ALU 读写寄存器几乎不花时间。但寄存器的数量是有限的而且它直接决定了 GPU 能同时跑多少个线程束。这里有个关键机制寄存器是分配给线程的。假设一个 GPU 的寄存器文件有 65536 个寄存器你的 Shader 每个线程用了 64 个寄存器那最多能同时驻留 1024 个线程。如果每个线程只用 32 个寄存器就能驻留 2048 个线程。驻留的线程越多GPU 就越容易在某个线程束等待数据比如等纹理采样结果时切换到其他线程束把 ALU 的空闲时间填满。所以寄存器压力过大是 Shader 性能的隐形杀手。它不像指令数那样直观但影响可能更大。我见过一个 Shader指令数不多但因为中间变量太多寄存器用量飙到 128 个导致 Occupancy占用率极低GPU 大部分时间都在等帧数怎么都上不去。后来把中间变量复用、拆分成两个 Pass寄存器降到 48 个帧数直接翻倍。在 UE 里你可以通过r.ShaderDevelopmentMode和平台相关的 Shader 统计工具查看寄存器用量。移动端尤其要关注因为移动 GPU 的寄存器文件通常比桌面小得多。2.4 纹理采样与 ALU 的配合为什么采样不占 ALU很多人以为纹理采样也是 ALU 干的活其实不是。纹理采样由专门的纹理单元TMU负责它和 ALU 是并行的。这意味着纹理采样和 ALU 计算可以重叠执行——当 ALU 在算东西的时候TMU 可以同时在采样纹理。这个特性是优化的关键杠杆。如果你的 Shader 是采样一次、算半天、再采样一次、再算半天的串行结构那 TMU 和 ALU 都没跑满。更好的做法是把多个采样集中在一起发出让 TMU 连续工作同时 ALU 处理上一批采样的结果。UE 的材质编译器在一定程度上会自动做指令调度但如果你手动写 HLSL这个顺序就得自己控制。不过要注意纹理采样虽然不占 ALU但它有延迟。采样结果不是立刻就能用的通常需要几十个时钟周期。GPU 靠切换线程束来掩盖这个延迟——这也是为什么 Occupancy 这么重要。如果驻留的线程束不够采样延迟就掩盖不住ALU 就会空转。3. 把优化口诀翻译成 GPU 能听懂的话3.1 if 分支慢的真相不是慢是发散Shader 里不要用 if这句话流传很广但它其实不准确。准确的说法是如果 if 的条件在同一个线程束内不一致就会导致发散性能下降如果条件在整个线程束内一致那 if 几乎不花钱。举个例子如果条件是if (MaterialFlag 0.5)而 MaterialFlag 是一个全局常量那同一个线程束里所有线程的这个值都一样GPU 会直接跳过不执行的分支没有发散性能没有损失。但如果条件是if (texColor.r 0.5)每个像素的纹理颜色都不同线程束内必然发散那就要付出代价。所以正确的做法不是消灭所有 if而是判断这个分支会不会发散。对于会发散的分支可以考虑用step、lerp、saturate这些无分支写法替代。比如// 会发散的写法 float result; if (x 0.5) { result a; } else { result b; } // 无分支写法 float result lerp(b, a, step(0.5, x));但要注意无分支写法不一定总是更快。如果两个分支的计算量都很大无分支写法会把两边都算一遍反而更慢。只有当分支内的计算量很小、且发散严重时无分支写法才有优势。这个判断需要结合具体场景没有万能公式。3.2 pow 和 sin 很贵贵在指令吞吐量pow、sin、cos、exp、log这些超越函数在 GPU 上确实比加减乘除贵。原因在于加减乘除通常一条指令就能完成而这些函数需要多条指令或者调用特殊的函数单元SFU。以pow(x, 2)为例很多编译器会把它优化成x * x这就是一条乘法指令。但pow(x, 3.7)就没法这么优化了需要走完整的幂运算流程。sin和cos通常由 SFU 执行SFU 的吞吐量比 ALU 低——比如 ALU 一个周期能处理 32 个线程的乘法SFU 可能只能处理 8 个。优化思路很直接能用乘法代替的就用乘法。pow(x, 2)写成x * xpow(x, 4)写成(x * x) * (x * x)。对于sin、cos如果精度要求不高可以用多项式近似但大多数情况下直接用就行因为 SFU 虽然吞吐低但它是独立单元可以和 ALU 并行工作不一定成为瓶颈。真正要警惕的是在循环里调用超越函数或者对每个像素都做高精度幂运算。这种场景下考虑用查找表LUT纹理替代把计算转移到采样上。3.3 精度选择half 和 float 的取舍移动端 GPU 普遍支持 half 精度16 位浮点而且 half 的运算速度通常是 float 的两倍寄存器占用也减半。UE 里可以通过half类型或者材质里的精度设置来控制。但精度不是越低越好。half 的精度范围有限对于世界坐标、大数值计算、需要高精度的光照计算用 half 会导致明显的精度问题比如 Z-fighting、颜色断层。我的经验是颜色、UV、法线的中间计算可以用 half位置、深度、需要累加的数值用 float。具体到每个变量需要实际测试看有没有可见的精度损失。还有一个坑half 和 float 混用会触发隐式转换指令。如果你一个表达式里既有 half 又有 float编译器会插入转换指令反而增加开销。所以要么统一用 half要么统一用 float不要混着来。4. 在 UE 里落地从材质到自定义 Shader 的优化实操4.1 材质节点层面的优化先看统计再看代码UE 的材质编辑器有一个非常有用的功能Shader 复杂度统计在材质编辑器里点平台统计或者看指令数。它会告诉你这个材质编译后有多少条指令、用了多少纹理采样、多少寄存器。这是优化的第一步——先量化再优化。我通常的流程是这样的打开材质的统计面板记录当前指令数和采样数。找到指令数最多的那几个节点逐个分析。对每个节点问自己这个计算能不能移到顶点着色器能不能用常量替代能不能用更便宜的指令改完再看统计对比前后数据。有几个高频的优化点把逐像素计算移到逐顶点如果某个计算变化很平缓比如大范围的环境光遮蔽可以在顶点着色器算好插值到像素。代价是精度降低但省下大量像素级指令。用材质参数替代运行时计算如果一个值是固定的别在 Shader 里算直接在材质实例里设成常量。合并纹理采样把多张灰度图打包到一张 RGBA 纹理的不同通道一次采样拿到四个值。这是最经典的优化能直接把采样次数降到四分之一。4.2 自定义 Shader 里的指令级优化当你写自定义 Shader 节点或者改引擎源码时优化粒度就更细了。这里分享几个我常用的手法。第一减少中间变量控制寄存器用量。编译器会尽量复用寄存器但如果你写了大量中间变量它可能来不及复用。一个技巧是把不用的变量及时释放——虽然 HLSL 没有显式的释放语法但你可以通过重新赋值来暗示编译器这个寄存器可以复用了。第二注意指令的顺序。前面说过纹理采样和 ALU 可以并行。如果你把采样都放在函数开头然后集中处理结果TMU 和 ALU 的重叠度会更高。反过来如果采样和计算交替出现重叠度就低。第三善用 mad 指令。a * b c这种模式GPU 通常有一条mad乘加指令能一次完成比先乘后加快。编译器一般会自动识别但如果你写成(a * b) (c * d)就需要两条 mad 或者一条 mad 加一条 mul。尽量把表达式写成乘加的形式让编译器有更多机会用 mad。第四避免不必要的类型转换。前面提过 half 和 float 混用的问题。另外整数和浮点之间的转换也有开销能避免就避免。4.3 一个真实的优化案例从 120 条指令到 45 条我拿一个实际项目里的材质举例。这是一个角色皮肤材质原始版本编译后 120 条指令在移动端跑起来明显吃力。我做了以下几件事第一原材质里有三个pow调用指数分别是 2、3、4。我把它们全部展开成乘法pow(x,2)改成x*xpow(x,3)改成x*x*xpow(x,4)改成(x*x)*(x*x)。这一项省了大约 15 条指令。第二原材质对同一张噪声纹理采样了三次分别取 R、G、B 通道。我改成一次采样拿到 float3分别用.r、.g、.b。省了两次采样和相关的指令。第三原材质里有一个if分支判断是否启用某个效果条件来自一张遮罩纹理。这个分支必然发散。我改成用lerp混合两个结果虽然两边都算但省掉了发散的开销。这一项在移动端收益明显。第四把一些逐像素的法线计算移到了顶点着色器因为法线变化很平缓插值精度足够。改完之后指令数从 120 降到 45移动端帧数从 28 涨到 52。这个案例说明优化不是靠某一个神奇技巧而是把一堆小优化叠加起来。5. 那些没人告诉你但一定会踩的坑5.1 编辑器里快不等于真机快这是最经典的坑。UE 编辑器在 PC 上跑用的是桌面 GPU 和桌面驱动编译出来的 Shader 和移动端完全不同。你在编辑器里看到的帧数对移动端几乎没有参考价值。正确的做法是尽早打包到真机测试而且要用目标机型测试。不同移动 GPU比如不同厂商的 Mali、Adreno对同一段 Shader 的编译结果可能差异很大。有条件的话至少覆盖两三个主流机型。另外UE 的移动端预览Mobile Preview虽然比编辑器接近真机但它仍然是在 PC 上模拟的不能完全替代真机。我一般把移动预览当作快速验证最终判断还是看真机。5.2 寄存器溢出看不见的性能悬崖寄存器用量有一个临界点。当用量超过某个阈值时Occupancy 会断崖式下降性能突然变差。这个阈值因架构而异但通常在 64 到 128 个寄存器之间。问题是寄存器用量不会在你的代码里直接显示。你只能通过编译后的统计或者 GPU 调试工具查看。我建议在优化过程中定期检查寄存器用量尤其是在增加新功能之后。如果发现用量突然跳升就要警惕了。降低寄存器用量的方法减少中间变量、拆分 Pass、把一些计算移到顶点着色器、用更低的精度。有时候把一个复杂的 Shader 拆成两个简单的 Pass虽然多了一次渲染但寄存器压力下来了总体反而更快。5.3 纹理采样的隐藏成本带宽和缓存纹理采样不占 ALU但它占带宽。移动端的带宽非常宝贵频繁采样大纹理会导致带宽瓶颈。而且纹理采样有缓存命中率的问题——如果采样坐标很分散比如随机 UV缓存命中率低每次采样都要从显存读延迟和带宽开销都大。优化纹理采样的思路降低纹理分辨率、用纹理压缩、提高采样坐标的局部性。比如如果一张纹理只用来做低频变化可以用很小的分辨率比如 64x64靠硬件插值来平滑。如果采样坐标是随机的考虑用噪声函数替代纹理把开销从带宽转移到 ALU。5.4 平台差异同一个 Shader不同的命运最后强调一下平台差异。桌面 GPU 和移动 GPU 的架构差异很大同一个 Shader 在两个平台上的表现可能完全相反。比如桌面 GPU 通常有更大的寄存器和缓存对指令数不那么敏感移动 GPU 则对寄存器用量和带宽极其敏感。所以跨平台项目的 Shader 优化必须分平台做。UE 支持通过#if宏或者材质里的平台开关来写不同平台的代码。我的建议是先保证移动端能跑再针对桌面端做增强。反过来做的话你会发现移动端怎么优化都达不到要求。6. 一份可以直接抄的 Shader 优化检查清单把上面的内容整理成一份清单你在优化时可以逐条对照。检查项判断标准优化手段指令数移动端单材质超过 80 条要警惕展开 pow、合并计算、移计算到顶点寄存器用量超过 64 个要关注超过 128 个要处理减少中间变量、拆 Pass、降精度分支发散条件依赖逐像素数据改无分支写法或接受发散纹理采样超过 4 次要考虑合并通道打包、降分辨率、用噪声替代精度half 和 float 混用统一精度避免隐式转换超越函数循环内或逐像素高频调用用乘法替代、查找表、多项式近似平台适配只测了 PC真机测试分平台写代码这份清单不是教条每一条都要结合具体场景判断。比如指令数有些复杂材质就是需要 100 多条指令硬压到 80 条可能损失效果。关键是知道每条指令的代价然后做有意识的取舍。7. 我个人的几条经验之谈做了这么多年 Shader 优化有几个体会是反复被验证的。第一先测量再优化。不要凭感觉猜瓶颈在哪里。用工具看指令数、寄存器用量、帧数找到真正的瓶颈再动手。我见过太多人花大力气优化了一个根本不是瓶颈的地方。第二优化是取舍不是消灭。没有免费的优化。降低精度可能带来瑕疵减少指令可能损失效果拆 Pass 可能增加 DrawCall。你要做的是在效果和性能之间找到平衡点而不是追求极致的性能数字。第三理解原理比记住口诀重要。口诀会过时硬件会更新但GPU 怎么执行指令这套底层逻辑是相对稳定的。理解了它你就能自己推导出优化方案而不是到处问这个该怎么优化。第四真机测试是唯一的真相。编辑器、预览、模拟器都只是参考。最终判断标准只有一个目标机型上的实际表现。早点打包多测几个机型比什么都强。最后分享一个小技巧如果你不确定某个优化有没有效果可以做一个 A/B 对比——保留原始版本复制一份改过的版本在同一个场景里切换对比帧数。UE 的材质实例可以很方便地做这种切换。实测数据比任何理论分析都有说服力。
返回列表