UPL8——影响 GPU 性能的主要因素
UPL8——影响 GPU 性能的主要因素
影响 GPU 性能的主要因素
在开发时注意这些优化点,可以减少性能问题的发生
当发生性能问题时 先定位问题,再针对性解决问题
结合性能分析窗口、帧调试窗口来分析定位问题,不要凭感觉
填充率
填充率(Fill Rate)是 GPU 的一个性能指标,是最经典的衡量图形处理器片元阶段吞吐能力的指标之一
表示 GPU 在单位时间内能处理(写入)的像素数量
一般用 像素 / 秒 (Pixels per Second,GPixel/s = 十亿像素/秒,MPixel/s = 百万像素每秒) 来衡量
相当于填充率决定了每帧能处理多少个像素
假设 RTX4090 GPU 的理论填充率为 450 Gpixel/s = 4500 亿像素/s
一个 60 FPS的游戏,每帧该 GPU 理论上能处理 4500亿 / 60 = 75亿像素
即 每帧可以处理 75 亿 个片元
如果是一个4K显示器 3840 × 2160 ≈ 829 万个像素
每个像素只处理一次的话
理论上来说,RTX4090 可以每帧处理 4K 画面 75亿 / 829万 ≈ 905次
而一般的高端移动设备的 GPU 填充率 一般是 20 ~ 40 Gpixel/s = 200 ~ 400 亿像素/s
那一个 60 FPS 的游戏,每帧该 GPU 理论上能处理 200 ~ 400亿 / 60 = 3.33 ~ 6.66 亿像素
即 每帧可以处理 3.33 ~ 6.66 亿个片元
如果是一个 4K 显示器 3840 × 2160 ≈ 829 万个像素
每个像素只处理一次的话,理论上来说,高端移动设备的 GPU 可以每帧处理 4K 画面 3.33 ~ 6.66亿 / 829万 ≈ 40 ~ 80次
说人话:GPU 填充率就是 GPU 每秒能画多少个像素
为什么填充率会影响性能
屏幕上每个像素都可能需要执行片元着色器、纹理采样、混合写入等操作,这些工作就是在消耗填充率
如果我们把 GPU 想象成一个喷漆机器,屏幕分辨率越高,需要喷的点就越多,特效越复杂(模糊、透明、Bloom 等),每个点需要喷好几层
当喷漆速度赶不上需求,就会产生性能问题
造成性能瓶颈的原因:
-
过高的分辨率
名称 分辨率 像素总数 720P 1280 × 720 92 万多 1080P 1920 × 1080 207 万多 2K 2560 × 1440 369 万多 3K 2880 × 1620 466 万多 4K 3840 × 2160 829 万多 8K 7680 × 4320 3318 万多 随着分辨率的增加,GPU 每帧要处理的像素(片元)会指数增加,会大幅消耗填充率
-
全屏特效或后处理效果
如果我们在游戏开发中使用屏幕后处理效果
比如边缘检测、Bloom、运动模糊、高斯模糊、全局雾效、景深(DOF)等等效果
它们会对每个像素进行多次采样和计算,会大幅消耗填充率 -
多重采样抗锯齿(MSAA)
4×MSAA 相当于每个像素算 4 次,填充率需求翻倍
- 普通渲染:1 像素 = 1 个采样点(像素中心)
- 4×MSAA:1 像素 = 4 个采样点,判断覆盖率,最后合成一个像素颜色
-
透明/半透明物体
每一层都要计算片元,实际填充量成倍增加
对于不透明物体来说,可以通过深度写入丢弃大量遮挡的片元,只用算一次
但是对于透明/半透明物体来说,必须逐层混合,每个片元需要重复计算多次 -
多 Pass Shader
每个 Pass 都会单独进行计算,Pass 中会重算片元(像素)
假设一个物体表面有 1000 个可见片元- 1 个 Pass 带来 1000 次片元着色计算
- 3 个 Pass 带来 3000 次片元着色计算
相当于在同一像素上多次覆盖绘制
等等
说人话:填充率始终有上限,当单位时间内处理的像素(片元)数量增加,就会消耗填充率,当达到一定瓶颈,就可能会给GPU带来压力
一般增加压力的情况为:分辨率高、屏幕后处理多、半透明多、多 Pass Shader 、抗锯齿 等
一切会增加片元计算次数的行为都增加 GPU 开销
关于填充率的优化思路
了解了填充率影响性能的原理,那么优化思路就很容易得到了
-
降低要处理的像素(片元)数量
-
降低分辨率
-
动态分辨率(Dynamic Resolution Scaling)
基本原理:
在 GPU 压力大时,降低渲染分辨率,然后再放大到屏幕分辨率输出
在 GPU 空闲时,提高渲染分辨率,获得更清晰的画面
目标是保持 稳定帧率,URP 项目中有动态分辨率功能开关 -
注视点渲染(Foveated Rendering)
基本原理:
人眼的视觉分辨率,中央注视点区域最清晰,周边区域分辨率敏感度低
在中央区域渲染高分辨率,在边缘区域渲染低分辨率,从而减少像素处理量
一般 VR 项目中会使用
一般 VR 设备会提供对应功能,通过设备 API 开启 -
降低项目分辨率
基于目标设备,合理设定项目目标分辨率
-
-
减少或优化屏幕后处理效果的使用
在 1/2 或 1/4 分辨率下渲染,或直接不使用
-
降低抗锯齿采样次数或关闭
4×MSAA ——> 2×MSAA 或关闭
-
-
减少单个像素(片元)计算量
-
控制 Shader Pass 数量
能合并的效果放在一个 Pass,避免片元被重复算
-
减少半透明的使用
减少层数,必要时用 透明测试(AlphaTest)
可见:US3S3L4——透明度测试 -
合理使用光照模式
少用逐像素实时光照
-
减少纹理采样次数
合并贴图、使用 Mipmap
-
-
避免无效像素(片元)计算
-
减少 OverDraw
-
优化UI系统
减少叠层,避免全屏大半透明面板,合并图集减少 DrawCall
-
剔除不可见对象
用 Occlusion Culling 和 视锥剔除
-
OverDraw
OverDraw(过度绘制、叠加绘制) 指同一个屏幕像素在一帧中被重复绘制多次,上一次计算结果会被覆盖或叠加
举例:
-
不透明物体
后面被挡住的像素,仍然会运行片元着色器,只是最后写入时可能被深度测试(ZTest)丢弃
-
半透明物体
因为需要 叠加混合 (Alpha Blend),所以每个透明层都会真实运行片元着色器并参与混合
-
UI、粒子、屏幕特效
当 UI 元素、粒子特效、屏幕后处理效果层层叠加时,OverDraw 现象更严重
为什么 OverDraw 会影响性能
之所以 OverDraw 会影响性能,主要原因就是会增加计算开销,消耗内存带宽,消耗填充率
-
片元着色器重复执行
不透明物体即便被遮挡,也需要跑一次片元着色器
半透明物体一定要逐层计算,无法依赖深度丢弃 -
带宽浪费
每次片元计算都会 读取纹理 并 写入帧缓冲,增加显存带宽消耗
-
填充率压力
屏幕像素总数固定,但 OverDraw 会造成 每个像素要算多次
GPU 的填充率预算会被快速耗尽
在以下场景时,很容易出现 OverDraw 现象
- UI 界面
- 粒子特效
- 密集树叶
等等
这些画面看起来挺简单,但是其中隐藏的 OverDraw 会让 GPU 消耗暴涨
关于 OverDraw 的优化思路
根据刚才的知识学习,优化 OverDraw 的主要思路为:
-
针对不透明物体优化
-
从前向后渲染
Unity 内部处理不透明物体就已经采用了这种方案,会自动按 相机到物体的距离,从近到远 排序进行渲染
我们也可以手动调整渲染队列或排序层,让对象先渲染,这样后面挡住的片元会被 Early-Z 丢弃,不跑片元 ShaderEarly-Z(提前深度测试)
在片元着色器运行之前,GPU 先把即将绘制的片元的深度和 Z-Buffer 比较
如果判定它在后面(被遮挡),直接丢弃,根本不跑片元 Shader,这样可以节省大量 GPU 开销
当透明或半透明物体时,关闭深度写入等情况,它会失效 -
遮挡剔除(Occlusion Culling)
使用 Unity 提供的 Occlusion Culling,可避免完全不可见的物体进入渲染
等等
-
-
针对半透明物体优化
-
减少透明层数、面积
UI 背景、HUD 尽量合并图层,少用大面积 透明混合
-
Alpha Test 替代 Alpha Blend
树叶、栅栏,用 Alpha Test(Clip)代替 Alpha Blend,让深度测试能提前丢弃片元
-
排序优化
透明物体按从远到近绘制,减少不必要的重复混合
-
特殊替代方案
粒子系统远处改用 广告牌效果,降低粒子数量和透明叠加
等等
-
-
针对UI系统优化
-
图集 + 批处理
减少 DrawCall,但也要避免大图集带来过多 OverDraw
-
减少叠层
UI 设计时避免多层全屏半透明面板
-
RectMask2D /Stencil 替代全屏Mask用普通
Mask 相当于多了一层全屏绘制,浪费填充率,如果只需要矩形裁剪,使用RectMask2D
RectMask2D 是 Unity 提供的一个 更轻量级的 UI Mask
它不是通过额外绘制Mask图层,而是用 矩形裁剪(裁剪坐标系)
不会产生额外的全屏 OverDraw,性能比普通 Mask 好很多如果想要复杂形状裁剪,用
Stencil
Stencil Buffer(模板缓冲)是 GPU 提供的一种逐像素标记机制
我们可以在自定义 Shader 中利用它来实现 -
UI Renderer 独立渲染
避免和 3D 场景互相叠加 OverDraw,相当于 UI 和场景用两个摄像机单独进行处理
-
等等
内存带宽
内存带宽指的是,GPU 在单位时间内,显存(VRAM)和 GPU 内部之间能传输的数据量,常用单位:GB/s(每秒多少 GB)
- PC显卡:100 GB ~ 1 TB/s
- 游戏主机:200 ~ 600 GB/s
- 移动设备:30 ~ 200 GB/s
GPU 内部
-
ALU(算数逻辑单元,Arithmetic Logic Unit)
最基础的计算单元,负责加减乘除、位运算、逻辑运算等
比如着色器中的数学计算,就是由它来处理的
它 是 GPU 的最小 “工人”,做具体数学计算的 -
SM(流式多处理器,Streaming Multiprocessor)
NVIDIA 架构里的术语(AMD 里叫 CU (计算单元, Compute Unit) )
一个 SM 中包含很多 ALU(算数逻辑单元)、寄存器、共享内存、调度器 等等
它的主要功能就是负责调度和并行执行成千上万个线程
它 是 GPU 里的一个小工厂,里面有很多工人(ALU) 和 管理员(调度器) 以及仓库(寄存器、缓存) -
着色器核心
厂商宣传时最常说的单位
- NVIDIA 叫 CUDA Core(统一计算设备架构核心)
- AMD 叫 Stream Processor(流处理器)
- Intel 或 移动设备 GPU 叫 Shader Core(着色器核心)
它是 ALU(算数逻辑单元)集合体,负责运行着色器指令(顶点、片元、计算等)
它是 GPU 对外宣传的工人数目,里面的工人就是 ALU(算数逻辑单元)
GPU 外部
显存(VRAM)一般是 GPU 封装在一起的 独立芯片
- 独显:显存独立,紧贴 GPU 芯片
- 集显 / 移动端(大部分游戏主机):显存 = 系统内存,GPU 和 CPU 共用同一内存池
显存是 GPU 外部大仓库,里面存储着 顶点数据、纹理、Shader 常量、渲染目标(RenderTarget)、深度/颜色缓冲等
SM(流式多处理器)需要从显存中搬运数据过来(会放在寄存器或共享内存中),然后把数据分给 着色器核心 进行计算
着色器核心中的 ALU(算数逻辑单元)需要不断从寄存器、缓存,必要时还会直接从显存中拿数据进行计算
说人话:
- ALU(算数逻辑单元)是一个工人,只会做加减乘除、逻辑运算
- Shader Core (着色器核心) 是一组工人,负责跑一条着色器指令
- SM(流式多处理器)是一个小工厂,里面有成百上千个工人,还有仓库(寄存器、共享内存),管理器(调度器)
GPU 就是一个大工厂,里面有几十到上百个小工厂(SM),可以同时开工,显存 就是存储原料(各种数据)的一个大仓库
内存带宽 就是一条从大仓库运原料到小工厂的输送带,决定单位时间内可以运送多少原料(数据)
为什么内存带宽会影响性能
GPU 渲染过程中会频繁的访问显存读取或写入数据
GPU 会读取:顶点数据、纹理、贴图、深度缓冲 等等数据
GPU 会写入:颜色缓冲、深度缓冲、后处理结果 等等数据
GPU 内部的计算单元(ALU、SM、着色器核心)执行速度是很快的
但它们需要不断从显存中存取数据,如果内存带宽不足,数据就不能及时送到计算单元,让内存带宽成为性能瓶颈
相当于 GPU 中计算单元计算很快,但是迟迟拿不到数据,GPU 就会游手好闲的等待数据
其实并不是 GPU 算力不足,而是由于内存带宽造成了 GPU 的性能浪费,不能达到最好的状态
说人话:内存带宽带来性能影响的原因是,输送带(内存带宽)数据搬运速度跟不上工厂中工人(Shader Core / ALU)的计算处理速度,导致 GPU(大工厂)算力浪费
什么原因可能造成内存带宽瓶颈
-
分辨率和填充率过高
-
高分辨率渲染(4k、8k)
像素数巨增,颜色缓冲、深度缓冲数据量暴涨
-
多重采样抗锯齿(MSAA 2× / 4× / 8×)
每像素要写多份数据,带宽需求成倍增加
-
大量 OverDraw
一个像素被写多次,重复读写显存,
一般 UI 半透明、粒子特效、后处理效果叠加都会导致带宽消耗剧增
-
-
纹理访问压力大
-
使用 超高分辨率纹理,GPU 每次采样需要从显存搬很多数据
-
缺少 Mipmap 或 错误采样(远处物体还用 4K 贴图)
-
未压缩纹理格式(RGBA32:4 字节/像素,ASTC 压缩:可能 <1 字节/像素)
-
多贴图采样(法线、粗糙度、金属度分开存,采样次数多)
移动 GPU 尤其容易因此卡死
-
-
RenderTarget过大 / 过多
RenderTarget 指 GPU 渲染管线里 片元着色器输出结果要写入的缓冲区,Unity 里常用的就是RenderTexture
任何 GPU 可以写入的缓冲区都可以称为RenderTarget
使用高精度RenderTexture,每像素字节数非常高,使用延迟渲染时,一次写多个缓冲(法线、深度、材质参数)
屏幕后处理频繁Blit 全屏,每一步都要读写一整张RenderTexture -
硬件和系统限制
显存位宽不足,显存频率较低
等等
说人话:只要是游戏中单位时间内当 GPU 的数据读写需求大于显存带宽承载能力时,就会形成内存带宽瓶颈
-
4K 显示器:830 万像素
-
帧率:60FPS
-
渲染目标格式(一帧的颜色缓冲所占大小):
- RGBA8:4 字节/像素 = 33MB
- RGBA16F:8 字节/像素 = 66MB
- RGBA32F:16 字节/像素 = 133MB
-
1s 带宽需求(假设一秒 60 帧)
- RGBA8:33MB * 60 = 2GB/s
- RGBA16F:66MB * 60 = 4GB/s
- RGBA32F:133MB * 60 = 8GB/s
高画质游戏、各种缓冲区就可能吃掉几十 GB/s 内存带宽,
再加上 纹理、深度、后处理等等 渲染处理内容的话,带宽需求轻轻松松就会超过 100 GB/s
关于内存带宽的优化思路
内存带宽的主要优化思路就是:减少显存的读写量,让 GPU 少搬运,多干活
-
减少分辨率和像素相关的开销
- 降低渲染分辨率(动态分辨率、注视点渲染)
- 降低抗锯齿采样
- 减少 OverDraw
-
优化纹理相关的开销
-
使用 Mipmap
远处物体采样低分辨率贴图,减少带宽浪费
-
压缩纹理格式
ASTC / ETC2等压缩格式,可以显著减少带宽占用
-
贴图合并
可以把粗糙度、金属度纹理合并,减少采样次数
-
合理分辨率
角色贴图、UI 纹理尽量在满足需求的情况下使用小分辨率图片
-
-
提高缓存利用率
-
访问连续化
避免 Shader 中随机纹理采样,尽量连续访问,增加 缓存 命中率
-
减少冗余读取
能一次采样合并的就合并;不要重复读取深度、颜色等数据
-
前向渲染排序
不透明物体 从前到后 排序,让 Early-Z(提前深度测试)提前丢掉被挡片元,减少显存读写
-
-
减少不必要的数据存储
-
避免冗余拷贝
有时项目里会把同一贴图拷到多个 RT,检查是否必要
-
利用 GPU 共享内存
一些重复计算的数据,可以在共享内存里缓存,避免回显存,可以在 Shader 代码中声明共享内存
-
压缩 G-Buffer(延迟渲染阶段 GPU 用来存储几何信息的一组缓冲区)
通过编码/优化,把原来需要 4~6 张全精度 RT 的数据,压缩到更少的通道/更低的位宽里,从而减少显存和带宽开销
比如法线压缩、八面体编码、位置压缩、材质参数压缩,降低精度等等
-
-
优化 RenderTarget
-
降低精度
使用
RenderTexture时可以用低精度格式替代,SRP 项目中可以设置缓冲区精度,可以在 Shader 中设置更低精度缓冲区 -
半分辨率渲染屏幕后处理效果
屏幕后处理效果可以用 1/2、1/4 分辨率处理后放大
-
减少全屏
Blit多个后处理效果尽量合并到一个
Pass,避免重复拷贝整屏
-
-
平台相关优化
-
移动端:纹理压缩、半分辨率后处理、避免大面积透明 UI 等等
-
PC/主机:带宽相对高,但高分辨率、高精度 HDR、高 MSAA 下仍容易成为瓶颈
可通过 降低 RT 精度 + 减少全屏
Pass(屏幕后处理效果) 缓解
-
等等
顶点和片元
- 顶点:就像建房子的“钢筋骨架”。
- 片元:就是把框架之间的“砖块”一块块填上
GPU渲染时:先有骨架(顶点),再填满砖块(片元),最后变成完整画面
顶点和片元如果单独理解
顶点
一般指模型的几何数据,是组成三角形网格的点,一个 3D 模型实际上就是由很多三角形拼接而成的,而三角形的角就是顶点
作用:是网格的框架点,决定了模型的形状
着色器阶段:一般会在着色器中的 顶点着色器 中进行处理和计算
顶点着色器 主要任务
- 将模型空间的顶点转换到裁剪空间、屏幕空间
- 把需要的数据(UV、法线、颜色等)传递给片元阶段
片元
一般指光栅化阶段产生的、候选的像素点
当三角形被投影到屏幕后,会覆盖一片像素区域
每个像素位置对应一个片元,GPU 会为它准备一份数据(位置、UV、颜色等)
作用:决定屏幕上每个像素的最终颜色
着色器阶段:一般会在着色器中的 片元着色器 中进行处理和计算
主要任务
- 计算像素颜色(光照、材质、纹理采样)
- 执行深度测试、混合、透明处理
- 最终写入帧缓冲,形成屏幕像素
为什么顶点和片元会影响性能
-
顶点:
由于顶点数据需要进行计算,因此以下因素会影响性能开销
-
模型的顶点数越多,计算量越大
- 高顶点数模型,如未优化的高模、过密网格
- 复杂骨骼蒙皮,需要大量骨骼权重计算
-
复杂的顶点着色器逻辑会放大开销
比如顶点动画(流体波动、噪声扰动、形变等等)
-
-
片元:
由于片元数据需要进行计算,因此以下因素会影响性能开销
-
分辨率和覆盖率
片元数直接与屏幕分辨率和绘制面积成正比
- 屏幕分辨率越高,片元数就越多,那么计算消耗就越大
- 全屏效果(后处理、屏幕空间特效)通常直接消耗片元性能
-
过度绘制(OverDraw)
一个像素被多个物体反复覆盖计算
-
高开销片元运算
复杂的片元着色器逻辑(多层纹理采样、分支、循环、PBR、阴影采样、SSR、体积雾、全屏后处理),非常吃性能
-
顶点和片元的优化思路
顶点的主要优化思路就是:从本质上减少顶点以及顶点相关的计算
-
降低顶点数
-
LOD
-
网格简化
-
优化远距离对象(用Impostor(替身)技术)
- Billboard(广告牌)
- 多角度烘焙(在球面或半球面上采样多个角度,把结果打包到一张大纹理图集)
- Octahedral Impostor(八面体编码,把球面角度映射到 2D 纹理,通过一种高效编码方式存储多角度外观)
-
-
蒙皮优化
减少骨骼数量、减少顶点权重、合并骨骼等
-
顶点动画
把复杂的程序形变改为VAT(顶点动画纹理),能贴图查表就别每帧算噪声
VAT(顶点动画纹理):
把复杂的顶点动画(顶点位置 / 法线随时间变化)预先烘焙进贴图里,在运行时通过 Shader 从贴图中查表来驱动顶点,而不是实时在 CPU 或 GPU 上做骨骼/物理计算
-
顶点着色器输出精简
减少
v2f 里插值数(TEXCOORD通道),避免为片元传一堆用不到的数据 -
顶点着色器精度和数学
- 移动端着色器中数值类型尽量用
half - 避免大量
sin /cos /pow - 能自己写计算过程,就别调用自带的一些高开销函数,比如:
pow(x, 2.0) →x * x - 把复杂函数结果预计算在纹理里,运行时只采样
- 移动端着色器中数值类型尽量用
-
合批处理
使用各种批处理技术(可见:UPL6——图形渲染优化),减少 DrawCall
-
剔除先于变化
使用遮挡剔除、视锥剔除,减少要处理的内容
等等
片元的主要优化思路就是,从本质上减少片元以及片元相关的计算
-
降低分辨率
动态分辨率、注视点渲染等
-
降低片元工作量
尽量减少纹理采样次数与层数(合并贴图通道、使用贴图图集)
片元着色器中减少循环 / 分支语法的使用,把复杂函数结果预计算在纹理里,运行时只采样
减少 OverDraw -
减少全屏
Pass屏幕后处理中,能半分辨率就半分辨率,
将多种屏幕后处理效果合并在一个 Pass 中处理 -
避免 Early-Z(提前深度测试)失效
比如透明测试就会导致其失效
-
Shader 分支收敛
尽量不要使用
if /else语法,可以用一些函数替代,比如:1
2
3
4
5if (mask > 0.5) {
color = a;
} else {
color = b;
}用插值函数替代
1
color = lerp(b, a, step(0.5, mask));
-
光照和阴影
降低光照和阴影质量,光照能用逐顶点就别用逐片元
-
合理使用 Mipmap 功能
等等
缓存和显存
显存就像仓库,缓存像桌上的小抽屉,去仓库取货很慢,但存储量大,从面前的抽屉里取货快的多,但存储量小
显存
在之前的内存带宽相关知识中,我们已经了解过显存了,它是 GPU 外部的专用大容量存储,主要用于存放
- 顶点
- 纹理
- 渲染目标(RT)
- 阴影贴图
- 后处理缓冲
等等
特点:
- 容量大,以 GB 计,带宽高,但是延迟大(相比缓存慢得多)
- GPU 工作时几乎会不停地从显存中读写内容
缓存
它是 GPU 内部的小容量高速存储,主要用于存放从显存取出的热点数据,方便我们重复利用
所谓热点数据(Hot Data),一般在 CPU 和 GPU 的优化领域中指的是:在短时间内被高频访问的数据
因为它被用的多,所以放在更快的存储空间中(比如寄存器、缓存)能极大的提升性能
比如在 GPU 中的常见热点数据为:
-
顶点缓存
渲染一个网格时,某些顶点会被多个三角形复用
GPU 会把最近变换过的顶点存在 缓存 里,避免重复计算 -
纹理缓存
当片元连续访问相邻的 UV 时,缓存会把周边像素块一起取进来
-
常量缓存
灯光参数、矩阵等,几乎每个片元都要用
特点:
- 容量小,以 KB ~ MB 计,速度快
- 命中(缓存中找得到对应数据)时快,不命中(找不到)时必须回显存
缓存的工作原理
-
缓存
缓存不会一字节一字节存,而是一次性把一大块连续的数据搬进来,
原因:利用 空间局部性,取了一个地址,附近的数据很可能也会用 -
索引映射
缓存的容量有限,需要一个规则来决定某个内存地址的数据放在哪个缓存槽里
常见规则:
- 直接映射(Direct Mapped):一个内存块只能放在某个固定的缓存槽
- 组相联(Set Associative):一个内存块可以放到一组缓存槽里,组内用替换策略选位置
- 全相联(Fully Associative):一个内存块可以放到任何缓存槽(灵活但查找慢)
- 高组相联(High Associativity):当一个内存地址映射到某一组时,它有很多个槽位可以选择存放,几乎接近全相联
GPU 中一般使用 高组相联缓存
-
替换策略
当缓存满了,替换策略常见规则
- LRU(Least Recently Used,最近最少使用)
- FIFO(先进先出)
- Random(随机,GPU 上常见,因为简单+并行)
显存和缓存的关系
注意:GPU 和 CPU 中都存在这个概念,原理是一致的
-
取数据时
当 GPU 或 CPU 访问某个地址
- 缓存中命中(在缓存中找到了):直接从缓存中读取,速度快
- 缓存未命中(在缓存中没找到):去显存中去,同时把这块数据搬进缓存
-
往缓存里放数据时
缓存不是一字节一字节放,而是一次性搬一整块(通常 32B / 64B / 128B)
这样就能利用空间局部性,如果你要用的数据附近的数据也很可能会用,就顺便搬进来 -
替换缓存数据时
- 如果缓存还有空:直接放进去
- 如果缓存满了:根据数据替换规则直接覆盖之前的数据(GPU 一般采用 Random 随机替换、CPU 一般采用 LRU 最近最少使用的替换)
为什么缓存和显存会影响性能
-
显存
性能瓶颈主要来自于:
- 内存带宽不足(上文详细讲过)
- 容量不足
说人话:显存决定能装多少,能传多快,当超出上限,就存在性能问题
-
缓存
性能瓶颈主要来自于:
-
未命中
比如:在 Shader 中进行随机采样纹理、UV 跨度过大,就会导致缓存命中率低
当未命中就睡频繁的去显存中取数据,导致获取数据缓慢,延迟变高 -
OverDraw、重复访问
一个像素被多次覆盖,导致反复的读取纹理等信息,不停替换缓存中内容,缓存中内容还没有重复使用就被替换了
让缓存命中率下降,缓存的加速作用大幅削弱
说人话:缓存决定能不能就近取,取的快不快,当取不到,就存在性能问题
-
缓存和显存的优化思路
显存主要的优化思路就是 容量 和 带宽 的优化,其中由于在上文内存带宽讲解过带宽的优化思路,这里不赘述,这里主要讲解 容量 优化思路
-
资源压缩
-
纹理压缩
Unity 选择更节约空间的压缩格式,比如 ASTC、ETC2 等
-
法线压缩等
双通道压缩,只存法线的 X 和 Y 分量,Z 分量在运行时计算
可以在 Unity 中选择 ETC2、EAC、RG 等压缩格式
等等
-
-
降低资源规格
-
降低贴图分辨率
-
减少 G-Buffer(延迟渲染中缓冲区)和 Render Target(各种缓冲区、颜色、深度、法线等等,包括
RenderTexture)数量可以通过合并属性,比如合并金属度、粗糙度等属性
可以降低 RT 的分辨率,屏幕后处理效果使用半分辨率
移动平台 使用前向渲染路径,避免延迟渲染 等等
等等
-
-
Mipmap + Streaming
使用 Mipmap,远处只保留低分辨率层级,在 Texture Importer 面板勾选 Streaming Mipmaps,按需加载纹理(Texture Streaming)
Unity 会根据相机和屏幕大小,自动决定加载哪些 Mipmap 层,可以减少显存占用和显存带宽消耗 -
静态资源烘焙
- 光照烘焙:将静态光照放入光照烘焙贴图里,避免实时阴影、光照贴图常驻内存
- 反射探针烘焙:静态场景用烘焙 Cubemap,避免大量实时探针
等等
片元的主要优化思路:
-
提高局部性
-
纹理访问局部性
UV 连续,避免大跨度跳跃
合并图集 -
顶点缓存命中
- 三角形索引优化:相邻三角形尽量复用顶点
- 避免退化三角形:面积为零的三角形,也就是三个顶点中有两个或三个是重合的情况
-
-
减少缓存未命中
-
降低 OverDraw
-
避免 依赖采样(所谓 依赖采样 指的是在片元着色器里,纹理采样的坐标不是直接来自插值,而是运行时计算出来的)
GPU 的纹理单元本来可以提前准备需要取的区域,但依赖采样让它必须等前一步结果
- 普通采样 UV 连续,相邻像素会访问相邻内容,缓存命中率高
- 依赖采样 UV 可能随机跳,缓存命中率低,频繁回显存
我们应该尽量减少采样坐标依赖前一次计算的情况,可以采用预计算预烘焙到纹理中直接获取相关信息,要尽量简化 UV 的计算
-
-
减少数据占用
-
数据轻量化
顶点属性尽量用低精度的,比如用
half 替代float
顶点着色器的输出结构体中v2f,只传必要数据 -
共享内存
在 Shader 中,用共享内存替代反复访问
-
等等
并行度与调度
- 并行度:表示 有没有足够的线程让 GPU 忙起来
- 调度:表示 GPU 能否在等待延迟时把空闲单元塞满
GPU 的 执行模型
GPU 的线程不是单独执行的,而是 一批一批 按 线程组(Warp / Wavefront) 执行
NVIDIA:一个 Warp = 32 个线程
AMD:一个 Wavefront = 64 个线程而一批次内的线程中,同一条指令同时在所有线程上跑,只是每个线程的数据不同
理想情况:所有线程执行同一条路径,利用率 100%
并行度
GPU 天然是 大规模并行处理器,成千上万个小核心(算数逻辑单元)在同时执行,并行度 指的是能同时被 GPU 执行的任务数量和有效利用率
常见的并行度层次(GPU 在不同维度的并行能力分类)有:
-
数据并行
大量像素 / 顶点 / 线程同时计算
-
任务并行
不同 Pass 或 Shader 阶段并行执行
-
指令并行
单个线程里,独立指令是否能被调度器交错执行,从而减少空等、提高 GPU 硬件单元利用率
调度
GPU 内部调度器负责在 不同的线程组(Warp / Wavefront) 之间切换,
把 ALU(算数逻辑单元)、内存访问、纹理采样、光栅化等任务交错执行,
调度的目标是 尽量保持硬件单元忙碌
为什么并行度与调度会影响性能
并行度影响性能的主要原因有:
-
并行度不足
GPU 核心会出现空转(闲置状态),算力浪费,比如:
-
小批量 DrawCall(每次只处理少量像素/顶点),并行度就会不足
-
Shader 中大量分支,线程分化,造成部分线程空跑
当 Shader 里有 条件分支
if/else、switch、loop 时,线程组(Warp)内的线程可能走不同路径比如:
1
2
3
4
5if (color.r > 0.5) {
doSomething(); // 分支 A
} else {
doSomethingElse(); // 分支 B
}假设 Warp 内 32 个线程,有一半满足条件走 A,另一半走 B,GPU 不能同时执行两条不同的指令
调度器会把分支拆开,先执行 A 的线程,再执行 B 的线程
当执行 A 时,走 B 的线程空转;当执行 B 时,走 A 的线程空转,这就是所谓的线程分化- 分支完全对齐时:32 线程并行, 1 次执行完成
- 分支分化时:等于串行化,执行时间翻倍(甚至更差),就造成了 ALU(算数逻辑单元) 利用率下降
-
-
并行度过高
内存带宽、寄存器压力增加,反而造成瓶颈,比如:假设我们在片元着色器中对4张4k纹理进行采样,4K ≈ 830 万像素,如果是 60 FPS的游戏
那每秒采样 830万 * 60 * 4 = 20 亿次纹理访问,这样 并行度虽然非常高(数百万像素同时跑)
但是会造成 GPU 的 内存带宽被吃满,ALU(算数逻辑单元)很空闲,还能算更多,但 带宽不足,取不到数据,造成 GPU 利用率下降
调度影响性能的主要原因:如果调度不合理(或没有足够可切换的任务),GPU 会出现 ALU(算数逻辑单元)闲置的情况,比如:
-
等待内存,ALU(算数逻辑单元)没事儿干
比如,我们在片元着色器中进行纹理采样时
1
2
3
4
5
6
7
8float4 frag(v2f i) : SV_Target
{
// 从显存里采样一张大纹理
float4 texColor = tex2D(_MainTex, i.uv);
// 得到采样结果才能计算
float brightness = dot(texColor.rgb, float3(0.3, 0.59, 0.11));
return float4(brightness.xxx, 1.0);
}当我们从纹理中采样时,需要去缓存或显存中获取数据,那么这里就会存在延迟等待,
在采样结果没有回来之前,后面的相关计算,都无法执行,相当于就是在等待内存,
此时的 ALU(算数逻辑单元)在此期间就无事可做 -
算数、依赖链过长
比如 我们执行以下指令
1
2
3float a = heavyFunc1(x);
float b = heavyFunc2(a);
float c = heavyFunc3(b);必须等前一条执行完才能执行下一条,调度器无法插入别的计算
等等
并行度与调度的优化思路
总体思路:让GPU永远有足够的独立的事情可做,避免任何单元长时间空闲
并行度优化主要目标:保证 GPU 有足够多的线程在跑,避免核心闲置
-
提高批处理力度
用之前学习的批处理技术,减少 DrawCall,让每次处理更多的顶点和像素
GPU Instancing 和 SRP Batcher 还可以让 GPU 同时处理更多对象 -
避免 线程组(Warp)内线程分化
减少 Shader 内部的
if/else 的使用
用数学函数(lerp()、step())替代分支 -
控制寄存器压力
避免单个 Shader 太臃肿,中间变量过多,用
half 代替float(移动端尤为重要)
拆分 Shader Pass,降低寄存器占用,让更多 线程组(Warp) 能并行驻留 -
保证任务规模足够大
避免超小网格、超小计算任务(否则并行度不足)
如果使用 Compute Shader(计算着色器)要合理设计工作组大小,让 GPU 核心填满
调度优化主要目标:让 GPU 能在等待(内存 / 采样 / 依赖)时切换别的任务,避免管线停滞
-
内存访问优化
- 减少带宽需求:纹理压缩(ASTC / BCn / ETC2)、MipMap、合理精度格式
- 提高缓存命中率:让相邻像素访问相邻内存(UV 连续、把多种需要的数据合并到一张纹理里,而不是分散存放)
- 减少依赖采样:UV 复杂运算/链式采样容易停顿,尽量预计算。
-
指令调度优化
打散指令依赖链,让调度器有空间插入别的运算
-
负载均衡
避免片元过重、顶点过轻,减少 Overdraw,
动态分辨率等优化分辨率方案来控制片元数量
等等
影响 GPU 性能的其它因素
目前我们已经了解了:填充率、OverDraw、内存带宽、顶点/片元、缓存与显存、并行度与调度
这些因素都可能会对GPU造成性能影响
除此之外,我们还应该注意以下一些因素,这些因素可能已经被我们在以上因素中提及过,在这里再次进行强调
-
DrawCall
每个 DrawCall 都有 CPU 到 GPU 的指令调度开销,频繁切换材质、着色器、渲染目标会造成流水线停滞
因此减少 DrawCall 不仅仅可以优化 CPU 性能,同样也可以优化 GPU 性能 -
纹理采样
-
优化采样次数可以提高 GPU 性能
纹理查询是片元着色器的主要瓶颈之一,Mipmap、LOD 能减少带宽和缓存压力
-
有规律的进行采样,可以提高缓存命中率
如果片元访问的纹理坐标分布离散,纹理缓存命中率下降,会大量访问显存,我们应该有规律的连续采样
-
采样模式的复杂度越高,越消耗性能
在 Inspector > Texture Import Settings 里有一个 Filter Mode:
- Point(最近点)
- Bilinear(双线性)
- Trilinear(三线性)
性能消耗:三线性 > 双线性 > 最近点
-
尽量减少多纹理绑定
过多材质切换也会增加开销
-
-
着色器
着色器复杂度越高,分支越多,越耗性能
我们应该避免或减少分支或循环的使用
减少高开销的内部函数调用 -
精度和数据格式
-
Shader 中尽量用低精度减少性能消耗
移动端使用
half精度可大幅减少寄存器与能耗 -
RenderTexture格式在满足需求的情况下,尽量选择精度低的格式RGBA32F 带宽和存储开销远高于 RGBA8
-
尽量在对应平台上使用合适的压缩格式
-
在顶点着色器中输出的结构体
v2f,应该尽量避免在其中包含无意义数据,因为会进行插值计算,消耗性能
-
-
后处理和特效
- 尽量减少全屏屏幕后处理效果,它们对带宽和片元算力消耗很大
- 半透明效果会大幅提升 OverDraw,需要谨慎使用
- 逐像素光照、多阴影贴图采样会显著增加片元着色开销,需要谨慎使用
