实时渲染管线:(三)逻辑管线[转]

作者:木头骨头石头
原文地址:https://zhuanlan.zhihu.com/p/440593877

书接上文:实时渲染管线:(二)CPU/GPU 体系结构简介。介绍了 CPU/GPU 的体系结构,事实上图形计算才是 GPU 的拿手好戏。为了控制 GPU 完成图形渲染,也为了方便开发者理解,图形 API 层面会把渲染算法(光栅化算法和光线追踪算法)拆分成一些可并行的阶段,这些阶段具体在 GPU 中是如何调度和执行的我们不得而知,但开发者可以通过图形接口操作这些阶段,实现各种渲染效果。这些阶段组合在一起,就是逻辑管线,目前图形 API 制定了四种管线:

  1. 光栅化渲染管线
  2. 计算管线
  3. 光线追踪渲染管线
  4. 网格渲染管线

另外,并不是所有 GPU 都支持这四种管线,详见:实时渲染管线:(一)基本概念与发展简史

1 光栅化图形渲染管线

光栅化图形渲染管线是最常见的实时渲染管线,早期 GPU 硬件设计都是服务于光栅化管线。参考《Real-Time Rendering Fourth Edition》,将光栅化算法的拆分为如下四个阶段:

其中,应用程序阶段在 CPU 端执行,其行为完全由开发者决定。在应用程序阶段主要工作是准备场景数据:光源、几何模型、摄像机…… 然后调用图形 API ,将这些数据发送到显存,同时设置 GPU 状态,命令 GPU 开始渲染。从几何阶段开始,在 GPU 端执行,才是真正的进入光栅化图形管线,也称为 GPU 逻辑管线。另外,在应用程序阶段通常会完成一些非图形渲染的任务,比如:

硬件输入:应用阶段需要处理诸如键盘、鼠标、手柄等外部设备的输入。硬件输入都是操作系统提供的功能,应用阶段需要处理这些输入,生成相应的反馈。比如鼠标点击,图形旋转。

排序(Sorting):在光栅化算法中就已经介绍了渲染顺序的重要性,也即在光栅化算法前,我们需要决定场景中每一个物体绘制的先后顺序,这是我们可以掌控的。

碰撞检测(Collision Detection):碰撞检测也就是几何体的相交测试,这是物理模拟和加速算法的重要基础。为了简化几何之间的碰撞检测,我们通常会基于几何模型生产包围体(Bounding Volume)作近似处理。

动画相关:生成动画有两种方式,一是在 DCC 软件中制作,然后实时播放,比如蒙皮动画。另一种是基于物理实时模拟。

批处理(Batch):是由 CPU/GPU 异构体系本身而带来的概念。对于顺序执行的光栅化算法本身,绘制一个 1000 个三角形所需要的时间是固定的。但 CPU/GPU 是两个并行的处理器,CPU 和 GPU 之间又互相独立,绘制工作需要它们配合完成,因此不同的绘制策略自然会带来不同的耗时。一个场景的三角形数据本身是存储在硬盘中的,CPU 需要把它们从硬盘中读取出来放到内存中,再经系统总线发送到显存中,然后发送绘制指令(Draw Call)命令 GPU 绘制三角形。GPU 是一个高度平行的处理器,它同时可以绘制大量的三角形。如果 CPU 将这 1000 个三角形一个一个提交,那么自然会有大量的时间浪费在总线上,而且 GPU 的计算核心大多也会处于闲置,此外绘制指令也会占用 CPU 时间,这些无意义的消耗对实时渲染来说应该尽可能避免。因此考虑花费一点 CPU 时间将这 1000 个三角形(带宽允许的情况下)同时发送给 GPU,然后发送一个(或少数几个)绘制指令,让 GPU 同时绘制这些三角形。

此外对于纹理我们也可以作类似的批处理,也即为了避免将一些小图一个个的提交到 GPU 端,我们将这些小图合成一张大图,一次提交给 GPU。如果这张小图是用在模型上的,还需重新设置顶点的 UV 坐标

加速算法(Acceleration Algorithm):根据场景本身的特点和观察角度,我们并不需要将场景如实绘制,我们可以在渲染管线之前,花费一点额外的计算时间减少场景需要绘制的元素,那么可以显著的减少渲染时间。加速算法分三类:

  1. 空间数据结构(Spatial Data Structure):空间数据结构虽然没有减少物体或三角形的数量,但它却是所有加速算法的基础。常见的空间数据结构会将空间划分成一些包围体,并用一个树结构组织起来,以加速物体的查询。
  2. 剔除算法(Culling):场景中的有的部分对摄像机来说并不可见,有的可能是因为在视锥体之外,有的可能是因为被其他物体遮挡。如果在绘制之前花费一点时间将这些不见的物体全部剔除,只绘制我们看得见的物体,那么可以极大的缩短渲染时间。与之相似的是光栅化算法的视口裁剪算法、背面剔除算法和提前深度/模板测试,它们的结果都是避免场景中不可见部分的绘制。这里的剔除算法是更加粗粒度的,物体级别的剔除。
  3. 细节层级(Level of Details,LoD):对于透视投影来说,距离视点较远的模型最后可能只占屏幕空间很小一块区域。如果仍以原始的模型精度进行渲染无疑会产生很多小三角形,而且模型太小很多细节根本看不清。因此根据模型距离视点的距离动态调整模型的面数和纹理的质量可以有效的提升渲染效率。

不管是光栅化图形管线还是光线追踪图形管线,都有应用程序阶段,应用程序阶段的主要任务是准备好渲染所需的数据,设置管线的状态,处理输入输出…… 。随着 GPGPU 的发展,越来越多的应用程序阶段的任务可以交由 Computer Shader 完成,比如加速算法、动画模拟等。

在应用阶段,准备好的渲染数据,设置好管线状态,调用图形 API 的绘制函数(Draw Call)开始一趟 Pass 的绘制。离开应用程序阶段,剩下的工作都是在 GPU 中并行执行,下图来自:Life of a triangle – NVIDIA’s logical pipeline

在 GPU 内部有许多 Crossbar 用于管线各个阶段之间数据的传递

下图是 D3D11 的光栅化图形渲染管线,其中矩形代表固定功能阶段,圆角矩形代表可编程管线阶段,这一逻辑管线延续到 D3D12 :

下图是 OpenGL 4.6 的光栅化图形渲染管线,其中粉色方框代表固定功能阶段,黄色方框代表可编程管线阶段,蓝色方块表示管线资源(数据),图中还详细的画出了计算管线与图形管线的交互。

1.1 几何处理阶段

几何处理阶段是在 GPU 中执行的第一个阶段,它可以进一步细分为:输入装配、顶点着色、细分曲面、几何着色、流输出五个阶段。

顶点与索引:我们在应用阶段需要准备模型的顶点与索引数据,放到显存中。顶点数据包含了顶点坐标、纹理坐标、法线方向等,索引数据引用顶点构成图元。以 *.obj 格式:

v -0.500000 -0.500000 0.500000
v 0.500000 -0.500000 0.500000
...

vt 0.001992 0.001992
vt 0.998008 0.001992
vt 0.001992 0.998008
...

vn 0.000000 0.000000 1.000000
vn 0.000000 0.000000 1.000000
vn 0.000000 0.000000 1.000000
...

s 1
f 1/1/1 2/2/2 3/3/3
f 3/3/3 2/2/2 4/4/4
s 2
f 3/13/5 4/14/6 5/15/7
f 5/15/7 4/14/6 6/16/8
...

其中,以 v 开头的一行,表示一个顶点的位置坐标;以 vt 开头的一行,表示一个顶点的纹理坐标;以 vn 开头的一行,表示一个顶点的法线方向。这里顶点数据是按 SoA 布局存放的。以 f 开头的一行表示一个三角形图元三个顶点的索引,每个顶点的坐标、纹理、法线索引用 / 隔开。索引是可有可无的,但对于三角形网格模型来说,有的顶点会被相邻三角形公用,如果没有索引,这些公用的顶点将重复出现在不同三角形中,增加了存储和带宽开销,因此大多数情况下都会使用索引。

输入装配(Input Assembly Stage):是几何处理阶段中第一个固定功能阶段,在 OpenGL 中称为 Vertex Pull,它会根据输入布局装配顶点数据,根据图元类型和索引将顶点装配成图元,然后交给顶点着色阶段处理。

顶点着色阶段(Vertex Shading Stage):必选的可编程管线阶段。顶点着色阶段获取顶点(Fetch Vertex)有两种方式:一种是无索引,直接从顶点缓存中读取顶点处理;另一种是有索引,遍历所有索引,根据索引读取顶点。对于第二种情况,遍历索引取顶点处理会使那些多个图元公用的顶点被重复 Fetch,因此现代 GPU 都会有一个 Cache 去缓存已经处理过的顶点,避免重复处理。在 GPU 中,获取顶点的操作是由 Poly Morph Engine 中一个硬件专门完成,当取到 32 个顶点后,就启动线程。并行处理。如果没有细分曲面、几何着色等阶段,我们通常会在顶点着色器中把顶点坐标变换到齐次裁剪坐标下,供光栅化阶段使用。在光栅化之前,会对图元进行裁剪,硬件的裁剪算法为 guard-band 裁剪。这在光栅化算法一文中已经介绍过了。

细分曲面阶段(Tessellation Stage):可选的管线阶段,如果启用细分曲面阶段,需要将图元装配的模式设置为控制点块(Control Point Patch)。细分曲面阶段可进一步细分为三个子阶段。

  1. 外壳着色阶段(Hull Shading Stage):在 OpenGL 中称为 Tessellation Control Shadering Stage。该阶段会遍历模型的每一个 Patch,并且将 Patch 中所有的控制点输入开发者指定的外壳着色器程序。在外壳着色阶段需要完成两件事情:一是计算曲面细分因子,也即该曲面细分的程度,通常根据 Patch 与观察点的距离动态变化;二是根据需求变化每个控制点的坐标,甚至可以增加控制点。外壳着色阶段需要输出曲面细分因子和变化后的控制点
  2. 细分曲面阶段(Tessellator Stage):在 OpenGL 中称为 Tessellation Primitive Generation Stage。固定功能阶段,根据曲面细分因子和控制点细分曲面,输出产生的新顶点。该阶段是在 SM 中的 PolyMorph Engine 中执行的
  3. 域着色阶段(Domain Shading Stage):在 OpenGL 中称为 Tessellation Evaluation Shading Stage。Patch 细分之后会得到更多的顶点,该阶段会遍历 Patch 产生的所有新的顶点,将曲面细分因子、新顶点的域坐标(Domain Location)、Patch 控制点输入开发者指定的域着色器程序中。我们需要根据 Patch 控制点的属性插值新顶点的属性,插值的依据是顶点的域坐标(Domain Location),输出变化后的新顶点。

几何着色阶段(Geometry Shading Stage):可选的管线阶段。几何着色阶段会遍历每个图元,将图元输入到开发者指定的几何着色器中。几何着色器可以将一中图元变换成另一种图元,比如将一个点图元扩张成一个三角形图元。几何着色器也可以选择删除一个图元。因此几何着色阶段也会改变网格模型顶点的数量。

在几何处理阶段结束后会进行视口变换(Viewport Transform),将图元坐标变换到屏幕空间。然后 Work Distribution Crossbar 会把图元光栅化的任务根据其屏幕坐标分配到不同的 GPC 的光栅化引擎中进一步处理,同一个三角形可能被分配到不同的 GPC 中。因为光栅化和像素处理都是基于屏幕空间,NVIDIA GPU 的一个 GPC 会负责屏幕的一个区域,将图元分配到相应区域的 GPC 中既可以加快光栅化的速度又可以保证局部性原理。比如在片元着色器会采样纹理,位置相近的三角形可能属于同一个模型,同一个模型的会使用同一张纹理。那么就可以把这张纹理放到该 GPC 中 SM 的 L1 Cache 中,加快采样的速度。

几何处理阶段的工作繁琐且重复,因此在最新的网格渲染管线中,决定将几何处理阶段的任务简化为两个着色器 Task Shader、Mesh Shader 处理

1.2 光栅化阶段

几何处理阶段输出的图元是矢量图形,经过光栅化阶段,会将它们离散成像素图形。在《Real-Time Rendering Fourth Edition》中把光栅阶分为两个子阶段,以三角形图元为例:

  1. 三角形设置(Triangle Setup):计算三角形的 AABB、边界函数等数据,为三角形遍历作准备
  2. 三角形遍历(Triangle Traversal):遍历三角形,找到三角形的覆盖的像素(片元)。在没有开启反走样模式时,会以像素中心坐标采样三角形,找到三角形覆盖的像素;如果开启多重采样反走样(MSAA),那么每个像素的采样数将大于 1,采样点也不再是像素中心,而是在一个像素中按某种规律分布

1.2.1 三角形的边界函数

三角形遍历阶段的首要任务是判断平面空间中某一采样点是否在三角形内。在光栅化算法中,介绍了利用重心坐标判断一点是否在三角形内。这一算法并没有的利用 GPU SIMD 处理器的特性,下面介绍的是在光栅化引擎中使用的算法 —— 边界函数法(Edge Function)

边界函数:三角形的 [公式] 边可以表示为:

[公式]

其中, [公式] 是边 [公式] 的法线,指向三角形内; [公式] 表示该边上的任意一点坐标。三角形三个顶点 [公式] 是按照逆时针绕序遍历的,我们通过向量 [公式] 逆时针旋转 [公式] 就得到 [公式] 。假设 [公式] ,那么 [公式] 。

式 (1) 是一条直线方程,改写成一般式:

[公式]

设三角形任意一条边为 [公式] ,那么该三角形用边界方程表示为:

[公式]

我们在三角形设置阶段算出 [公式] 。

利用边界函数判断点与三角形的位置关系:由上图可以发现边 [公式] 将平面分成两个部分,一部分与 [公式] 同侧,另一部分与 [公式] 异侧。我们将采样点坐标 [公式] 代入 [公式] ,发现:

  • 如果该点与 [公式] 在同侧的区域,那么 [公式]
  • 如果该点与 [公式] 在异侧的区域,那么 [公式]

我们把这一采样点坐标代入三条边的边缘方程中,如果这三条边同时成立: [公式] 则说明 [公式] 在三角形内。

top-left 法则:下面讨论采样点刚好在边缘上的情况,也即 [公式],这条边可能是两个三角形的公共边,那么这一采样点应该属于哪个三角形呢?为了避免一个像素被光栅化两次,在 Direct3D 中,使用的是 top-left 法则,当一个像素点在一个三角形的左边或上边时,这个采样点就属于这个三角形。在 D3D 中,左边和上边的定义为:

  • “上边”的定义:这条边是水平的,另外两条边在均在这条下面。上边方程中 [公式]
  • “左边”的定义:这条边是非水平的,而且它在三角形的左侧。一个三角形最多有两条“左边”。左边的方程中 [公式]

定点数坐标表示:在输入光栅化阶段的顶点坐标是用浮点(float-point)数表示的屏幕空间坐标,为了高效的光栅化,图形 API 会把它们转换成定点(fixed-point)数表示的坐标。比如将一个浮点坐标转换为 1.14.8 定点坐标:其中 1 个符号位;14 位表示屏幕坐标的整数部分;8 位表示一个像素内的分数部分,那么定点数坐标的每个分量的取值范围是: [公式] 。这种转换需要在计算边界函数之前进行。

边缘函数的自增性:因为屏幕空间采样点的坐标之间只相差一个整数, [公式] 表示某一像素中心坐标,那么其相邻像素中心坐标为: [公式] 。带入边缘函数 (3):

[公式]

该公式成立的条件是屏幕像素坐标是离散的,称为边界函数的自增性(incremental property)。利用这一性质,我们把屏幕像素以 [公式] 个像素为一个 tile,判断三角形的覆盖情况。只要计算 tile 中一个像素的边界函数值,利用边界函数的性质,可以同时得到其他 63 个像素的边界值。这里也体现了光栅化阶段数据使用顶点数表示的优势,因为边界函数值的计算在一个 tile 中只是简单加减法,定点数的加法器比浮点数计算器实现简单很多。

重心坐标:平面上任意一点 p 的重心坐标 [公式] 与三角形三个顶点的坐标的关系为:

[公式]

如果该点在三角形内,那么重心坐标还有一个性质:

[公式]

其中 [公式] 是 [公式] 的面积,其中 [公式] 是 [公式] 的面积,其中 [公式] 是 [公式] 的面积。因为三角形的面积是不变的,为了加快重心坐标的计算,可以在三角形设置阶段算出 [公式] 。

将 [公式] 代入边缘方程 [公式] :

[公式]

其中 [公式] 是由 [公式] 逆时针旋转 [公式] 得到的,所以 [公式] 刚好等于 [公式] 的长度。 [公式] 是向量 [公式] 在 [公式] 方向上的投影的长度。所以 [公式] 的结果刚好就是 [公式] 的两倍。

1.2.2 基于 tile 的三角形遍历

介绍了边界函数法,下面介绍在光栅化引擎中,三角形遍历的过程。三角形会根据其屏幕空间,会被分配到不同的 GPC 中的光栅化引擎中。一个光栅化引擎只会遍历该 GPC 中的三角形。

三角形遍历是并行执行,屏幕像素通常以 [公式] 个像素为一个 tile 遍历三角形。在三角形设置阶段,计算好 [公式] 这 64 个值,存储在寄存器中。现在判断 tile 该三角形覆盖覆盖的情况,先计算左上角像素的边界函数值,然后 tile 中每个像素加上提前计算好的 [公式] ,就得到每个像素的边界函数值。如果 tile 中有像素被三角形覆盖,则得到片元,插值出片元属性交给像素处理阶段。对于没有被三角形覆盖的像素,则会被遮掩掉。

为了加快基于 tile 的三角形遍历,可以先计算 tile 是否与三角形的 AABB 相交。或者基于层级的,用一个更大的 tile,比如 [公式] 判断是否与三角形 AABB 相交,然后再作 [公式] 个像素的 tile 与 AABB 相交。

以 tile 的方式遍历三角形还有利于后面的片元着色阶段。

1.2.3 多重采样

假设屏幕分辨率为 [公式] ,屏幕空间的像素可以看成一个个矩形,像素中心坐标为 [公式] ,其中 [公式] 。我们通常会用像素中心坐标采样矢量图元,如果图元片元没有覆盖采样点,采样点的颜色为 但这会得到一幅走样的图像。

MSAA 得到硬件支持,开启 MSAA 后,仍按 tile 遍历三角形。一个 tile 中相邻像素同位置采样点,依然满足边缘函数的可加性。假设一个像素有四个采样点,那么需要计算左上角像素四个像素的边缘函数值,然后根据可加性,同时得到其他所有像素每个像素的边缘函数值。

开启 MSAA 之后,需要更大的帧缓存。假设一个像素被两个三角形覆盖,上图中采样点 0,2,3 被红色三角形覆盖,以像素中心为着色点计算红色三角形的颜色,填入采样点的颜色缓存中。采样点 1 被蓝色三角形覆盖,同样以像素中心为着色点计算蓝色三角形的颜色。最后,把每个采样点的颜色加权平均得到该像素的实际的颜色,这一过程称为 MSAA Resolve。

对于 MSAA 光栅化阶段只会得到采样点的覆盖信息,还要经过深度模板测试,才能决定采样点究竟被那个三角形覆盖。可以在 D3D11 的官方文档中,查看更多的光栅化细节:D3D11 Rasterization Rules

1.2.4 保守光栅化

在 Direct3D 11 中引入了保守光栅化的新三角形遍历模式:保守光栅化(Conservative Rasterization,CR)。保守光栅化特性非常有用,常用于图像空间的碰撞检测、遮挡剔除、阴影计算和反走样。CR 有两种类型:

  1. 高估保守光栅化(overestimated CR,OCR):当像素与三角形重叠就算作三角形片元,如下图中蓝色像素
  2. 低估保守光栅化(underestimated CR,UCR):当像素被三角形完全覆盖才算作三角形片元,如下图中绿色像素

黄色像素是没有开启 MSAA 的,用像素中心像素采样三角形得到的片元,属于标准光栅化。

1.2.5 线段与点的遍历

直线的遍历:在光栅化算法中介绍了 Bresenham 算法光栅化一条直线。如果说让硬件实现 Bresenham 算法是非常麻烦的。实际上,在遍历一条直线时,是把这一条直线当成一个像素宽的矩形,然后再将这个矩形拆成两个三角形遍历。

点的遍历:在光栅化引擎中,把这个点当成一个像素宽的正方形,然后再将这个正方形拆成两个三角形进行光栅化。

这么做的目的自然是减轻硬件设计的负担,使用一套规则完成所有图元的光栅化。

1.3 像素处理阶段

图元经过光栅化阶段,从矢量图形变成像素图形,接下进行像素处理阶段,进一步可分为两个子阶段:片元着色阶段与合并阶段。

片元着色阶段(Fragment Shading Stage):必选的可编程管线阶段,在 OpenGL 中称为片元着色,在 D3D 中称为像素着色(Pixel Shading)。片元着色阶段会遍历光栅化产生的所有片元(像素),执行开发者指定的片元着色器程序。

在 GPU 的一个 Warp 中,片元着色是 [公式] 个像素为一个 quad,一个 Warp 8 个 quads 并行执行。因为光栅化阶段是按 tile 遍历三角形的,从 tile 中可以很容易拆分出 quad 。一个 quad 是片元着色线程分配的最小单元。之所以要按 [公式] 个像素为一组着色,是因为要在片元着色器中计算变量的偏导数,片元着色器虽然不能直接改变或获取向量片元的数据,但通过 ddx() 或 ddy() 函数可以得到某一数据的偏导数,间接获知向量像素的信息。比如计算片元纹理坐标(uv 坐标)的偏导数,从而确定纹理采样的 mip 层级。

以 quad 为一组作像素着色会有一个问题,对于只覆盖一个像素的三角形或者一个细长的三角形,会造成严重的性能浪费。比如一个三角形只覆盖 quad 中一个像素,但仍会启用 4 个线程,其中 3 个线程被浪费掉了。在下一篇文档中介绍如何剔除小三角形

片元着色阶段的输出可以是合并阶段,也可以是开发者指定的渲染目标(Render Target),也即一张纹理,这需要显卡支持 MRT 技术

合并阶段(Merging Stage):合并阶段会进行深度/模板测试(Stencil/Depth Test)和颜色混合(Blending)。属于可配置管线阶段。片元着色阶段输出的结果会经过 Crossbar 发送到 ROP(Render Output)单元中合并,并把结果输出到帧缓存(Frame Buffer,FB)中。合并阶段也是基于 Tile 的,提高缓存命中率。


2 计算管线

在 2011 年,CUDA 和 OpenCL 已经取得了广泛的应用。在图形开发者的要求下,DirectX 11 将 DirectX 10 的 DirectCompute API 升级为 Compute Shader Pipeline,作为 Direct3D 11 标准的一部分,从而更好的利用 GPU 通用计算特性,辅助图形渲染。计算管线只有一个着色器,称为 Compute Shader。计算管线能利用 GPU 并行计算能力,在传统的渲染管线之外,使用 GPU 加速完成非渲染任务,比如后处理、粒子模拟、物理模拟、剔除等。通过转换资源状态,Compute Shader 能轻松与渲染管线交互。

计算管线不涉及图像的绘制与提交,因此开始计算管线的命令是线程分配函数 Dispatch 而不是光栅化渲染管线的 Draw Call 函数。调用 Dispatch(x,y,z) 函数,会为任务分配线程组(Threads Group),同时指定线程组的布局(Layout)。

一个线程组内有多个线程,在着色器代码中,还需指定线程组中线程的布局。比如在 HLSL 中是用 numthreads(x,y,z) 属性指定线程的布局。线程的布局和线程组的布局都是一种三维网格布局,如果我们指定 z =0,那么布局也可以是二维的,这取决于用户的选择。

比如 Dispatch(5,3,2) 一共会分配 5 * 3 * 2 = 30 个线程组,构成一个 x=5, y = 3, z = 3 的线程组网格。numberthreads(10,8,3) 指定一个线程组中,一共有 10 * 8 * 3 = 240 个线程,一个线程组的线程构成一个 x=10, y = 8, z = 3 的线程网格。那么总计会给计算任务分配 30 * 240 = 7200 个线程。引用微软官方文档的配图:

在 Shader 代码中,通过工作组索引(Workgroup Index),可以知道该 Shader 代码是在那个线程组中的哪一个线程执行,比如在 HLSL 中用 SV_GroupThreadID、 SV_GroupID …… 等语义修饰的参数。在分配线程时,线程数量最好是 32 的整数倍,这正好是 NVIDIA GPU 中一个 Warp 的线程数。如果是 AMD GPU,则分配的线程数最好是 64 的整数倍,这是一个 Wavefront 的线程数。

Compute Shader 能随机读写显存资源,因此除了Workgroup Index,Compute Shader 没有任何输入。在计算管线中,一个线程组还有 32 KB 的共享内存。共享内存是在 L1 Cache 上的,供线程组内所有线程使用,加速读写效率。线程组内的线程也可以利用共享内存通信。因为 Compute Shader 可以写入资源,所以引入了屏障函数,用以在不同线程间同步。


3 光线追踪渲染管线

光线追踪管线是 2018 年之后,在最新的显卡和图形 API 上支持的图形渲染管线,这一块资料不如光栅化管线和计算管线那么丰富。在 NVIDIA Turing 架构的 SM 中,开始搭载加速光线与物体相交的硬件 —— RT Core。在 RT Core 中会遍历场景的 BVH,找到距离光线起点最近的一个交点。

光线追踪图形管线的简化流程如下,其中有五个可编程管线阶段、一个固定功能阶段:

  • Ray Generation Shader:光线追踪管线的入口,必选的可编程管线阶段。每 Pass 调用一次。在该着色器中调用 TraceRay() 函数,发射光线。
  • Acceleration Structure Traversal:固定功能阶段。调用 TraceRay() 函数后,光线会遍历场景的加速结构,寻找光线与场景的交点
  • Intersection Shader:可选的可编程管线阶段,编写光线与程序化几何模型的相交算法。如果是三角形网格模型,则不需要这一阶段,寻找光线与三角形的交点由了 RT Core 硬件加速
  • Any Hit Shader:可选的可编程管线阶段,用于验证交点是否有效,常用于 Alpha 测试
  • Closest Hit Shader:必选的可编程管线阶段。当光线遍历结束,找到了场景中距离光线起点最近的交点,调用该 Shader。该阶段可以再次调用 TraceRay(),发射光线,实现光线追踪算法的递归过程。
  • Miss Shader:必选的可编程管线阶段。当光线遍历结束,没有找到了场景中距离光线起点最近的交点,调用该 Shader。该阶段可以再次调用 TraceRay(),发射光线,实现光线追踪算法的递归过程。

下图是光线追踪管线更加详细的流程图:

首先在 Ray Generation Shader 中调用 TraceRay() 函数,生成一条光线,一条光线会携带一个自定义的 PayLoad 数据,用于在各个可编程光线阶段中通信。光线会在遍历加速结构,寻找与光线起点最近的交点。如果是三角形网格模型,相交算法有 RT Core 硬件支持;如果是程序化几何模型,需要编写 Intersection Shader,定义几何体与光线的相交算法。

在遍历的过程中,每找到一个交点,需要判断交点距离光线起点的距离是否比记录中的距离小,如果是则需要调用交点物体的 Any-Hit Shader 验证相交是否有效。如果相交无效则继续遍历场景,如果相交有效则更新记录。

遍历结束,如果有距离光线起点最近的交点,则调用交点物体的 Closet-Hit Shader。在 Close-Hit Shader 中可以编写物体的着色方式,也可以再调用 TraceRay() 函数,继续追踪得到间接光照。如果没有距离光线起点追踪的交点,则调用 Miss Shader,可以返回一个固定色,或者采样天空盒。在 Miss Shader 中也可以再 TraceRay() 函数,发射光线。

目前光线追踪管线主要还是辅助光栅化管线,它不能直接渲染到帧缓存,只能渲染到纹理,也即只支持 off-screen 渲染。如果希望把渲染的结果呈现到屏幕上,需要把纹理拷贝到后台缓存中。光线追踪管线和计算管线很像,开始光线追踪管线的 API 函数不是 Draw Call 而是 Dispatch Rays。

3.1 场景遍历与加速结构

光栅化算法会利用剔除技术,只需要将可见的物体放入显存就可以开始渲染了。但光线追踪不同,光线可以在场景中任意弹射,所以在渲染前需要把场景的所有数据放到显存中。寻找光线与场景交点是光线追踪算法中最费时的阶段,为了加速场景遍历,光线追踪管线把场景用两层加速结构组织起来,参考 DXR 文档:Geometry and acceleration structures

  • Bottom Level Acceleration Structure(BLAS):存储所有三角形网格模型或程序化几何模型。如果是三角形网格模型,则需要存储该模型的顶点数据和索引数据,还有一个 3*4 的仿射变化矩阵;如果是程序化几何模型,则只需要存储该模型的 AABB。如果一个动画会添加或删除模型的三角形,需要重建 BLAS
  • Top Level Acceleration Structure(TLAS):存储所有模型实例。一个 BLAS 中的模型可以创建多个实例,每个实例需要有一个独一无二的 Inst-ID。同一模型的不同实例可以有不同的“材质”,在光线追踪管线中,材质由 any hit shader、closet hit shader、intersection shader 和输入 Shader 的参数共同决定。另外实例中还需要有一个 3*4 的仿射变换矩阵,用于局部-世界坐标变换,可以每帧更新。

TLAS 最终会被组织成一个 BVH,叶子节点存储各个实例。在调用 TraceRay() 函数后,光线需要不断遍历这个 BVH,直到找到最近的交点。RT Core 遍历 BVH 的方式官方并没介绍,而硬件加速光线遍历在学术界已经研究了多年,参考:Ray Tracing 学习之 Traversal 了解更多的遍历细节。

3.2 着色器表与着色器记录

在光栅化管线中,一个物体的材质由 Vertex Shader、Fragment Shader …… 以及输入 Shader 的参数共同描述,在渲染该物体时,需要把相应的 Shader 绑定到渲染管线上。在光栅化管线中,这种绑定意味着管线状态的变换,开销较大。如果在渲染一帧画面的过程中频繁切换管线状态,那么大部分时间可能会浪费在管线状态切换上。因此在光栅化管线中,会把拥有相同 Shader 的物体合成一个批次(Batch)一起绘制或者连续绘制,避免管线状态的切换。

在光线追踪管线中,光线可能会与任何物体相交,因此我们需要提前把所有的 Shader 以及输入 Shader 的纹理、常量、采样器都绑定到管线上。为了更好的组织这些程序和数据,在 DXR 中定义了 Shader Table ,管理所有的 Shader 和 Shader 参数。

在 Shader Table 中,相同类型的 Shader 是连续存放的。Shader Table 中每个元素称为 Shader Record,每个 Shader Record 包含一个 Shader Identifier 指向一个 Shader 程序,另外还包含输入该 Shader 的参数。

在光线追踪管线中,与物体“材质”相关的 Shader 有三个,分别是 Intersection Shader、Any-Hit Shader 和 Closet-Hit Shader,它们统称为 HitGroup,一个 HitGroup 对应一个 Shader Identifier。在 TLAS 中,一个实例至少需要关联一个 HitGroup。如果一个实例关联多个 HitGroup,那么与该光线追踪 Pass 的光线类型有关。有多少种光线类型实例就要关联多少个 HitGroup。所谓光线的类型用于区别光线的行为,比如发射做阴影的光线和做间接光照的光线与物体相交后的行为肯定不一样,因此实例的 HitGroup 数量要与光线类型相同。

不同的实例也可以索引到同一个 HitGroup,实例和 HitGroup 是多对多的关系

3.3 纹理采样

在采样纹理时,我们需要指定在哪一层的 mipmap 上采样,避免走样。在光栅化管线中,片元着色器会以 2*2 个的 quad 执行,而且像素着色器是锁步执行的,因此可以通过函数 ddx(),ddy() 计算得到相邻像素某一数据的偏导数。而在顶点着色器,没有这样的机制,所以需要指定采样纹理的层级。

如果在光线追踪管线的中,每条光线相互独立,而且也不可能锁步执行,所以不可能有 ddx(), ddy() 这种函数。为了在光线追踪中正确的采样纹理,《RTR4》中介绍了两种方案:

  1. Tracing Ray Differentials
  2. Ray Tracing with Cones

4 混合图形渲染管线

目前业界通常采用结合光栅化管线、计算管线和光线追踪管线的混合管线(Hybrid Rendering Pipeline)。详见 EA 在 GDC 2018 的分享 Shiny Pixels and Beyond: Real-Time Raytracing at SEED。用其中的一副图概括:

首先是延迟着色的几何阶段,通过光栅管线生成不透明物体的 G-Buffer,G-Buffer 中包含可见片元的各种信息,如世界坐标、反照率、法线方向等。生成 G-Buffer 的过程相当于从相机发出 Primary Ray,寻找可见的着色点,解决物体的可见性问题。然后是延迟着色的光照阶段,混合管线将计算光照的任务进行拆分,分别交由合适的管线去完成:

  1. 直接阴影(Direct Shadow):直接阴影是图形渲染研究最成熟的课题。如果用光栅化管线去做,那么可以是传统的 Shadow Map,Shadow Volume 等;如果用光线追踪去做,那么从 G-Buffer 中的着色点发出 Shadow Ray,总之选择非常丰富,效果都还不错;
  2. 直接光照(Direct Lighting):直接光照传统的做法是在光栅化管线的片元着色器中完成,这里选择单开一条计算管线去做,本质是一样的,还能节约一个顶点着色阶段和光栅化阶段。如果在计算着色器中做直接光照,需要解决纹理过滤的 mipmap 层级。这不算困难,因为线程组之间,内存是共享的。还可以放到 Shared Memery 上,加速这一过程。
  3. 反射与全局光照(Reflections & Global Illumination):这都都属于间接光照任务,是光线追踪的强项,自然交给光线追踪管线去完成。从 G-Buffer 中的着色点发出散射光,计算间接光照。(在光栅化管线中作间接光照是过去 20 年实时渲染研究的难点,产生了大量的算法,现在有了硬件光线追踪,算是一个终极的解决方案。但并不意味着过去的研究没有价值,在性能孱弱的设备上,传统算法依然有用武之地)
  4. 环境光遮蔽(Ambient Occlusion):解决物体之间的 Contact Shadow ,也是光线追踪管线的强项。但传统的基于屏幕空间的环境光遮蔽效果也还不错,反正 G-Buffer 已经有了,所以用计算管线做也可以。
  5. 透明物体和半透明物体(Transparency & Translucency):用光线追踪算法解决光线在透明物体和半透明物体内部的散射与吸收,在离线渲染领域有大量的论文
  6. 后处理(Post-processing):实现各种风格化后期处理特效,用计算管线实现

5 网格渲染管线

在光栅化图形渲染管线,与几何处理相关的阶段有:输入装配、顶点着色、图元装配、外壳着色、细分曲面、域着色、几何着色、流输出、视口变换与裁剪。其中有可编程管线阶段也有固定功能阶段。比如输入装配、图元装配、细分曲面、流输出、视口变换是固定功能阶段,由 GPU 中特定硬件执行;其他阶段都是可编程管线阶段,在流处理器中执行。

可以看到光栅化管线的几何处理非常繁琐且重复,这其中有如下几个问题:

  • 裁剪是在几何处理完之后才进行,不可见的图元依然走完了完整的几何处理流程
  • 输入装配和图元装配是固定功能阶段,因此顶点缓存和索引缓存的输入非常不灵活,需要额外的数据指定顶点的输入布局
  • 几何处理流程繁琐,所有可编程管线阶段都是在流处理器上完成,徒增任务切换的消耗
  • 几何处理流程重复,比如域着色中会处理每个细分之后的顶点,然在细分曲面阶段开始之前,仍然会经历一个什么也不做的顶点着色阶段

光栅化渲染管线有许多历史包袱,因此在 2019 年,NVIDIA 与 Microsoft 推出 Mesh Shading Pipeline,将几何阶段的工作交由 Amplification Shader 和 Mesh Shader 完成。Mesh Shading Pipeline 的设计更接近 Compute Pipeline,利用 GPU 通用计算特性完成几何处理阶段。

在 Vulkan 中,称 Amplification Shader 为 Task Shader

Mesh Shading Pipeline 调用 DispatchMesh(x,y,z) 函数分配线程组开始工作,而不是光栅化渲染管线的 Draw Call 函数;在 Amplification/Mesh Shader 代码中需要通过 NumThreads(x,y,z) 属性指定一个线程组中线程的数量与布局。目前,Mesh Shading Pipeline 一个线程组中最多可以分配 128 个线程,一个线程组的共享内存不超过 28 KB,一个线程组可以共享不超过 28 KB 的共享内存。Amplification/Mesh Shader 和 Compute Shader 一样,都可以可以读写显存,因此除了标记该 Shader 的 Workgroup Index,没有其他任何输入。

模型数据的输入:因为 Mesh Shader 和 Compute Shader 一样,可以读写显存,所以输入 Mesh Shading Pipeline 的数据可以更加灵活。我们依然需要把模型的顶点和索引数据放到显存中,另外 Mesh Shading Pipeline 引入了 Meshlet 的概念,明确 Mesh Shader 需要处理的任务。

Meshlet:是网格模型的一个子集,一个网格模型可以划分出若干个 Meshlet。一个 Meshlet 可以包含 32-200 个顶点,以及由这些顶点构成的若干个图元。Meshlet 的划分是在渲染前预计算的,通过遍历模型的索引,每遍历 64 个顶点或者 126 个三角形,就划分出一个 Meshlet,Meshlet 中实际存储的是顶点/索引数和顶点/索引在顶点/索引缓存中的偏移。下图中不同的颜色块就是一个 Meshlet。

Mesh Shader:必选的可编程管线阶段,用以替代 VS+GS 或者 DS+GS。如果没有 Amplification Shader,调用 DispatchMesh 函数后,分配线程组开始执行。通常,我们让一个线程组处理一个 Meshlet,线程组中一个线程处理一个图元。我们可以在 Mesh Shader 中作视锥剔除、小三角形剔除、背面剔除等加速算法。Mesh Shader 需要输出处理后的 Meshlet 的顶点和索引,并且指定图元拓扑。之后的过程和光栅化渲染管线一样,图元装配,光栅化、像素(片元)着色器 ……

Amplification Shader:可选的可编程管线阶段,用以替代 VS+HS。细分曲面会增加或减少模型的面数,因此 Meshlet 的数量也会变化。在 Mesh Shader 之前引入一个可选阶段,称为 Amplification Shader(Vulkan 中称 Task Shader)。Amplification Shader 的作用与 HS 类似,需要决定曲面细分的程度,并根据细分程度分配相应数量的 Mesh Shader 处理顶点和图元,因此在 Amplification Shader 最后需要再次调用 DispatchMesh 分配线程组执行 Mesh Shader。DispatchMesh 函数需要传入自定义的 Payload 数据,这与光线追踪管线类似,这个 Payload 数据用于在 Amplification Shader 和 Mesh Shader 之间传递信息。

实时渲染管线:(二)CPU/GPU 体系结构简介[转]

作者:木头骨头石头
原文地址:https://zhuanlan.zhihu.com/p/440586327

书接上文:实时渲染管线:(一)基本概念与发展简史。介绍了实时渲染管线的发展简史,下面进入正题,简单介绍 CPU/GPU 的体系结构。实时渲染管线通常运行在 CPU/GPU 异构计算(Heterogeneous Compute)系统上。异构计算是由不同指令集架构(Instruction Set Architecture,ISA)的处理器组成的计算系统。笔者是一个游戏开发人员,没有硬件相关的开发经验,以下内容请谨慎阅读,参考资料如下:

有关于 GPU 架构、指令集、开发者文档,在各家硬件厂商的官方网站中都能查到:

几款典型的 GPU 架构白皮书:

1 CPU 与 GPU 的区别

中央处理器(Central Processing Unit,CPU) :通常包含以下单元:

  1. 控制单元(Control Unit,CU):是 CPU 中最复杂的一部分,负责整台计算机工作
  2. 执行单元(Execution Unit,EU):是 CPU 执行指令的单元,执行单元可进一步细分为算数逻辑单元(Arithmetic Logic Unit,ALU)负责算数逻辑指令、浮点运算单元(Floating-point Unit,FPU)负责浮点数运算指令、加载存储单元(Load/Store Unit,LSU)负责内存存取指令、地址生成单元(Address Generation Unit,AGU)负责计算内存地址
  3. 寄存器(Register):CPU 寄存器会根据用途分为不同类型寄存器。比如通用寄存器(General Purpose Register,GPR)和专用寄存器(Special Purpose Register,SPR)。通用寄存器可以用来存放指令执行过程中计算的结果;专用寄存器用于程序计数。更多寄存器分类详见:Processor register
  4. 内存管理单元(Memory Management Unit,MMU):负责逻辑地址到内存物理地址的映射

CPU 和 GPU 都有多级存储器。不同时代,不同用途的电脑,多级存储结构是不一样的。

CPU 多级存储结构

  • 寄存器(Register):在 CPU 核心内部,容量仅有数千字节,速度最快,时延仅 1 个时钟周期
  • L1 Cache:容量有 64 KB,时延 2-4 个时钟周期,使用造价高容量小的 SRAM
  • L2 Cache:容量有 256 KB,时延有10-20 个时钟周期,同样使用 SRAM
  • L3 Cache:容量有数 8-32MB,时延20-40 个时钟周期,同样使用 SRAM
  • 内存(Memory):容量有数 4-64 GB,时延 200-300 个时钟周期,在多核 CPU 中所有核心共享内存,内存通常使用 DRAM
  • 外存(Storage):容量 256 GB- 4 TB,属于外部设备,时延 100000-10000000 个时钟周期

上述结论来自:Approximate cost to access various caches and main memory? 通常,CPU 一个核心一个时钟周期可以执行一条指令

多核处理器(Multi-Core Processor):在一个处理器上集成多个独立的处理单元(Processing Unit)也即核心(Core),每个核心有自己寄存器、控制器、执行单元。多核 CPU 每个核心对有自己私有的 L1/L2 Cache,所有核心共享 L3 Cache 和 Memory。

图形处理单元(Graphics Processing Unit,GPU):可以看成是将单核 CPU“复制粘贴”数千份,同时简化控制单元,并搭载一些固定功能的电路单元,比如光栅器、纹理采样器、光线追踪单元……不同时代、不同厂商的 GPU 架构不一样,但自 2006 年统一着色架构提出以后,GPU 的基本结构也就定型了。

2006 年,D3D10 规范合并了顶点着色器与片元着色器,称为流处理器(Stream Processer,SP),NVIDIA 特称流处理器为 CUDA Core。一个流处理器相当于 CPU 的算数逻辑单元(ALU),流处理器可进一步分为整数运算单元(int unit)浮点数运算单元(FP unit)。单个流处理器不能工作,需要将多个流处理器为一组执行指令。在运行时,一个流处理器就是一个线程。

多个流处理器、寄存器、指令调度器、L1 Cache、加载存储单元、纹理单元、特殊函数单元…… 为一组构成一个更大的处理器单元,NVIDIA 称为流多处理器(Stream Multiprocessor,SM),AMD 称为计算单元(Compute Unit,CU)

在 NVIDIA 的显卡中,若干个流多处理器、L2 Cache、光栅器、显存控制器……一起构成一个完整的 GPU。在 AMD 的显卡中,若干个计算单元、L2 Cache、显存控制器……一起构成一个完整的 GPU。下图是 NVIDIA TU102 核心的架构图,其中有 72 个 SM,每个 SM 又有 64 个 CUDA Cores,一共有 4608 个 CUDA Cores。

GPU 多级存储结构

  • 寄存器(Register):GPU 有数千个流处理器会同时工作,因此需要数量庞大的寄存器。在 GPU 中,每个 SM 中有数千个寄存器,每个寄存器 32-bit,称为寄存器文件(Register File)。在 NVIDIA Fermi 架构 GPU 中,一个 SM 拥有 32768 个寄存器,寄存器文件大小 128 KB;在较新的 NVIDIA Turing 架构 GPU 中,一个 SM 拥有 16384*4 个寄存器,寄存器文件大小 256 KB。虽然 GPU 的寄存器数量远多于 CPU,但依然是非常珍贵的资源,GPU 寄存器读写时延同样只有 1 个时钟周期
  • L1 Cache:在一个 SM(或 CU)之中,供其中所有的流处理器共享,时延有 1-32 个时钟周期,容量 64-69 KB
  • L2 Cache:在 SM(或 CU)外,由多个 SM(或 CU)共享,时延有 32-64 个时钟周期,容量 2000-6000 KB
  • 显存(Video memory):对于 Dedicated GPU,其拥有专门为适应大数据吞吐设计的存储器 GDRAM 。对所有的 SM(或 CU)共享,时延有 400-600 个时钟周期,容量 2 GB – 20 GB

2 指令集

在指令集中会定义一台设备可以执行的所有指令,不同处理器的指令集不同,CPU 和 GPU 都有各自的指令集。一条指令对应处理器一种操作,人为的重排一系列指令,就是程序。

CPU 的指令集:按照指令集的复杂程度,可以把 CPU 指令集分成两类:

  1. 复杂指令集计算机(Complex Instruction Set Computer,CISC)
  2. 精简指令集计算机(Reduced Instruction Set Computer,RISC)

CISC 和 RISC 的区别这里就不介绍。其中,Intel 和 AMD CPU 使用的 x86 指令集架构是一类典型的 CISC ;在移动设备 CPU 上常用的 ARM 指令集架构属于 RISC。不管是 x86 还是 ARM,指令都是按流水线的方式执行,以提高 CPU 的使用率,在下面的章节中详细介绍。

指令流水线(Instruction Pipeline):一条指令通常被分成若干个阶段交由处理器中不同单元执行,有分三步的,也有分六步的。按照 RISC-V 标准,指令流水线被划分成五个阶段:

  • 指令获取(Instruction Fetch,IF):控制单元从程序计数器(Program Counter)寄存器中取出即将执行的指令的地址。当指令获取后,PC 寄存器的值会指向下一条指令的地址。指令获取所需的时钟周期取决于指令所在的存储器,因此我们要保证尽可能保证局部性原理
  • 指令解码(Instruction Decode,ID):指令解码器将获取到的指令解码成成控制CPU其他部分的信号。
  • 执行(Execute,EX):指令进入执行单元,如 ALU、FPU、LSU、AGU 中执行
  • 内存访问(Memory Access,MEM):访问内存,读写数据
  • 寄存器回写(Register Write Back,WB):将执行结果或内存读取结果写入到通用寄存器(GPR)中

GPU 的指令集:与 CPU 的指令集不同,GPU 的架构每代变化很大,不同厂商的 GPU 架构也不同,ISA 很难做到统一。GPU 指令同样会划分出取指令、指令解码、执行……等流水线阶段,GPU 指令的执行也在后面的章节详细介绍

软件人员需要调用图形 API 才能控制 GPU 工作。图形程序可以使用高级着色语言(High Level Shading Language)比如:HLSL、GLSL,对渲染管线的可编程阶段编程,比如:顶点着色阶段、片元着色阶段。得益于同一着色架构,所有的可编程着色阶段都使用相同 ISA。这些着色语言会被先编译成中间语言(Intermediate Language),HLSL 编译成 DXIL(DirectX Intermediate Language) ,GLSL 编程成 SPIR(Standard Portable Intermediate Representation) ,然后驱动再将中间语言翻译成目标 GPU 的二进制指令。

对于 GPU 通用计算程序,非图形程序员也开始接触 GPU 编程,为了追求至极的性能优化,硬件厂商开始发布官方文档介绍旗下 GPU 的指令集。AMD 公开了近十年各代 GPU 的 ISA:Developer Guides, Manuals & ISA Documents ; NVIDIA 从未公布任何文档介绍旗下 GPU 的 ISA,取而代之提供了 CUDA 程序编译之后会得到的 PTX(Parallel Thread Execution) 指令集代码。PTX 指令集抽象了各代 GPU 指令集的差异,是一种面向软件开发者的 GPU 抽象指令集,其地位类似于 CUDA 程序的汇编。PTX 指令需要驱动进一步编译,才能得到目标 GPU 的二进制指令。实际上,NVIDIA 官网还介绍了比 PTX 更底层的 CUDA 程序编译后的二进制码,只与目标 GPU 兼容。官方对其的介绍不多,参考:CUDA Binary Utilities 。


3 内存管理

存储器是计算系统最重要的资源,但存储器的空间并不是无限的,而且存储的读写速度也是有限的。现实是,存储器读写速度越快,单位容量的造价越高。为了平衡速度、容量、造价三者之间的关系,现代计算机都采用了多级存储结构(Memory Hierarchy)。高速存储器容量小,速度快,读写时延小,距离核心更近;低速存储器容量大,速度慢,读写时延大,距离核心更源。多级存储结构对计算机技术影响极大,高效的内存管理也关乎整个计算机的效率。

3.1 局部性原理

因为每级存储器读写的时延依次增大,为了提高计算机的效率,我们应将频繁使用或即将使用的指令或数据放在高速存储器中,如果能在缓存中取出即将使用的指令或数据称为缓存命中(Cache Hit),最好的情况是 CPU 每一次存储器访问都能 Cache Hit,但高速存储器的空间有限,不常使用的指令和数据应该放在低速存储器中。如果我们将即将要使用或频繁使用的数据缓存高速存储器,比如我们无法从缓存中拿到想要的数据,就称为缓存缺失(Cache Miss),需要访问内存,那么会极大影响计算效率。

随着软件规模的扩大以及允许多道程序同时运行,内存空间的限制越来越明显,操作系统都提供了虚拟内存技术,用外存拓展内存空间。如果即将使用的指令或数据不在内存中,会导致缺页中断,需要访问外存,这可能进一步影响计算效率

为了更好的利用计算机的多级存储结构,软件设计应该尽可能满足局部性(Locality)

局部性(Locality) 是指处理器在短时间内重复访问存储器同一区间的趋势。因为处理器多级存储结构的存在,如果我们编写的程序能保证局部性,那么我们就可以放心的把数据或指令放在高速存储器中,提高读写效率。局部性进一步可分为两种:

  1. 时间局部性(Temporal Locality):某一存储空间被访问后,在短时间内,处理器会再次访问它。因为在短时间内会重复访问同一地址,那么就可以把该数据放到高速存储器中,不用担心缓存不命中或缺页中断
  2. 空间局部性(Spatial Locality):某一存储空间被访问后,在短时间内,处理器会访问它相邻的存储空间。因为各级存储器之间置换的数据地址都是连续的,在短时间内会重复访问某一连续区间的地址,那么就可以把该区间数据放到高速存储器中,不用担心缓存不命中或缺页中断

《Computer Architecture:A Quantitative Approach》 一书中介绍了十种提高缓存命中率的策略,有硬件层面的,有软件层面的。下面简单介绍两个最常见的策略:

循环交换(Loop Interchange):在一些循环语句中可能会以非连续的方式访问存储器,比如下面的代码:

for(int j=0; j<100; j++)
{
    for(int i=0; i<5000; i++)
    {
        x[i][j] = 2 * x[i][j];
    }
}

因为 x[i][j] 与 x[i][j+1] 是连续存放的,x[i][j] 与 x[i+1][j] 是不连续存放的,所以上面的内存访问方式不满足空间局部性,容易造成 Cache Miss,因此编译器会交换循环嵌套方式,以提高缓存命中率:

for(int i=0; i<5000; i++)
{
    for(int j=0; j<100; j++)
    {
        x[i][j] = 2 * x[i][j];
    }
}

预取(Prefetching):在处理器访问存储器之前,预测处理器需要的数据,提前将数据放到缓存中,提高缓存命中率,对指令和数据都可以预取。硬件预取机制的实现是依赖过去代码的执行特点,预测未来代码的行为。如果预取失败,可能需要将预取出来的指令和数据换出缓存,重新从内存中读取。因此,我们可以利用编译器或硬件的预取策略,优化代码,提高预取成功率。

数据预取:在循环代码中顺序访问数组元素是方便预取的,我们可以根据之前遍历的情况,提前将数组中的数据预取到缓存中:

for(int j=0; j<100; j++)
{
    a[j] = a[j + 1] + 5;
}

但如果是随机访问数组,那么预取机制将失效:

for(int j=0; j<100; j++)
{
    a[j] = b[rand()%100];
}

指令预取:指令通常是顺序执行的,因此很容易预取。但如果程序中出现跳转语句,那么指令预取将失效,这就是 goto 语句饱受诟病的原因。相比于 goto 语句, if … else … 同样会造成指令跳转,但比 goto 语句更好预测。比如下面的程序:

int a[10] = {1,2,3,4,5,6,7,8,9,10};
for(int i=0; i<10; i++)
{
    int p, q = 0;
    if(a[i] < 6) {
        p++;
    }
    else {
        q++;
    }
}

上面的循环中,if(a[i] < 6) 在前 5 次执行为 true,执行 p++ 指令;后 5 次执行为 false,执行 q++ 指令。根据这一特点,前五次执行时可预取 p++ 指令,放入缓存中;第六次预测失败,需要把 q++ 放入缓存。上面的代码保证了时间局部性,使分支预测成为可能。如果数组元素随机排列, if(a[i] < 6) 一会为 true,一会儿为 false,无法保证时间局部性,分支预测也会失败:

int a[10] = {2,1,6,7,5,3,9,10,4,8};
for(int i=0; i<10; i++)
{
    int p, q = 0;
    if(a[i] < 6) {
        p++;
    }
    else {
        q++;
    }
}

总的来说,不管是在 CPU 上运行的程序还是 GPU 上的程序,为了提高缓存命中率,软件编写都应该保证局部性

3.2 异构计算系统内存管理

CPU/GPU 异构计算的内存管理涉及:CPU 的缓存策略,内存地址管理,存储空间的申请与释放,内存-显存之间的数据拷贝……内存管理需要有一个置换策略,当高速存储空间不足时,把数据换入低速存储器,同时尽量避免即将要使用的数据或指令被换出高速存储器,引起缓存缺失或缺页中断。

置换算法(Replacement Algorithm):常见的置换算法有以下三种:

  1. 最佳置换算法:选择被换出的数据是未来永不使用,或者是最长时间不再被访问的。但我们并不知道未来的情况是怎么样的,最佳置换算法无法实现,常用作检验其他置换算法的标志。
  2. 先进先出置换算法(First in first out,FIFO):优先换出最早进入高速存储器的数据,该算法实现简单,但数据换入高速存储器的时间和访问的频率并不相关,可能会换出未来常使用的数据
  3. 最近最久未使用置换算法(Least recently used,LRU):选择换出最近最长时间未访问的数据,因为该算法假设近期不常访问的数据在未来也不会经常访问。LRU 算法的性能最好,但实现复杂,需要额外的硬件支持

更多的置换算法详见:缓存置换算法(Cache Replacement Algorithm) 与 页面置换算法(Page Replacement Algorithm)

3.2.1 CPU 缓存管理

缓存行:数据从内存拷贝到缓存时,是以固定大小连续地址的内存块拷贝,通常是 64 Bytes,称为缓存行(Cache Line)或称缓存块(Cache Block)。缓存行中会包含数据本身和一些其他数据。当处理器需要读写内存时,会检测是否有 Cache Line 映射到这一数据。如果有,则缓存命中(Cache Hit),CPU 直接读写缓存;否则缓存缺失(Cache Miss) ,需要创建一个新的 Cache Line,并把数据拷贝到其中。

缓存行与内存地址的映射:内存空间远远大于缓存空间,因此内存地址与缓存行肯定不是一一映射的关系,为了建立缓存行中地址与内存地址的关联。有如下三种策略:

  • 直接映射(Direct Mapping)
  • N 路组相连(N-Way Set Associativity)
  • 全相连(Fully Associativity)

直接映射(Direct Mapping):多对一映射,多个内存块对应同一个 Cache Line。

假设内存空间以 64 Bytes 连续地址划分出若干内存块,划分出 32 块。内存的最小单元是字节,一个内存块有 64 个连续内存地址,总共 64 * 32 = 2048 Bytes = 2 KB。缓存中可以存放 8 个 Cache Line,那么 4 个内存块会映射到同一个 Cache Line。这种映射关系可以用取模确定,也即:

[公式]

其中,  I是 Cache Line 索引,  A是内存块索引,  N是 Cache Line 数量。那么,0、8、16、32 这四个内存块会映射到 0 号 Cache Line 中,如上图左一。因此一个 Cache Line 不仅包含数据本身,还需要有一个 Tag 标记该 Cache Line 对应哪一个内存块。

直接映射实现简单,但当有新的内存块需要换入缓存时,相应 Cache Line 中的数据就要被换出,置换缺乏灵活性,因为缓存的 Cache Line 可能是未来会使用或者经常使用的,会降低缓存命中率。因此在直接映射的基础上,提出 N 路组相连模式。

N 路组相连(N-Way Set Associativity):多个 Cache Line 组成一个组(Set),如果一个 Set 中有两个 Cache Line,就称为 2 路组相连;如果有四个 Cache Line,就称为 4 路组相连。多个内存块映射到同一个缓存组中任意一个 Cache Line 中。内存块与 Cache Line 是多对多映射,但内存块与 Cache Set 是多对一映射。内存块与 Cache Set 的映射关系可以用取模得到。

以 2 路组相连为例,0、8、16、32 这四个内存块会映射到 0 号 Cache Set 中,并任选一个 Cache Line 存放,如上图左 2。那么当一个内存块映射到一个 Cache Set 时,可以选择最近最久最少使用的 Cache Line 换出,可以有效的提高缓存命中率。缺点是,N 路组相连模式增加了电路设计的复杂性,而且当 CPU 读写缓存时,需要遍历一个 Cache Set 才能判断缓存是否命中。

直接映射也可以看成 1 路组相连

全相连(Fully Associativity):全相连可以是 N 路组相连的极端情况,只有一个 Cache Set,一个 Cache Set 包含所有的 Cache Line,也即一个内存块可以映射到任意一个 Cache Line 中,如上图右 1。那么 CPU 读写缓存时,需要遍历整个缓存才能确定缓存是否命中,适合缓存比较小的计算机使用。

写入策略:因为一份数据在缓存和内存中都一个副本,所以需要有一个策略保证该数据在所有存储器中的一致性。内存写入通常有两种策略:

  1. 写直达(Write Through):如果数据在缓存中,将数据同时写入内存和缓存;如果数据不在缓存中,将数据直接写入内存
  2. 写回(Write Back):如果数据在缓存中,将数据写入缓存,并将该 Cache Line 标记为脏(dirty),当该 Cache Line 被替换时,才把数据写入内存。如果数据不在缓存中,还是将数据写入缓存,先检查目标 Cache Line 是否为脏,如果是,则该 Cache Line 存有别的内存地址需要写入的数据,把该数据写回到内存,然后替换 Cache Line;如果 Cache Line 不为脏,则直接替换并写入

写直达实现简单,但不管数据是否在缓存中,都需要写入内存,影响效率;写回实现复杂,在有必要时才将数据写入内存,效率更高,因此大多数 CPU 都采用回写策略。

缓存一致性(Cache Coherency):在多核 CPU 下,内存供多个核心共享。同时,每个核心独占 L1/L2 缓存,因此同一数据在不同核心的 Cache 中都有一个副本。为了保证缓存一致性,某一 CPU 核心会在写入操作时,通知其他 CPU 核心更新缓存。另外引入锁,写入操作串行化。随着处理器中核数量的增多,保证缓存一致性的开销越来越大,这也是 CPU 核心不能无限扩展的重要原因。

3.2.2 CPU 内存管理

内存管理是操作系统最重要的任务之一,总结起来,分以下四个方面:

  • 内存空间的分配与回收:内存分配与回收是操作系统的任务,现代操作系统通常采用非连续分配,它允许将一个进程分配到不连续的内存区域执行。
  • 地址变换:在编写程序时,开发者只关心程序的逻辑地址,需要根据实际情况,将逻辑地址变换到内存的物理地址,不同的内存分配策略会有不同的地址变换方式
  • 内存保护:操作系统允许多道程序同时运行,内存分配和地址变换策略既要避免用户进程相互影响,也要避免用户进程影响操作系统的地址空间
  • 虚拟内存:因为内存资源是有限的,不能将所有进程全部放入内存执行,操作系统在保证局部性原理的前提下,利用磁盘资源扩展内存空间

为了减轻开发者负担,也避免程序在运行时影响到其他进程的地址空间或者操作系统的地址空间,开发者不需要与物理内存打交道,开发者分配和回收的都是逻辑内存空间,逻辑内存空间的地址就是逻辑地址(或称虚拟地址)。CPU 的 MMU 会把逻辑地址映射到物理地址。

传统的内存分配方式是连续分配,具体有两种策略:

  1. 固定分区分配:为了支持多道程序同时执行不会相互影响,将物理内存区域划分成一些不同大小的分区,一种大小的分区有若干个。一道程序,程序需要全部放入一个分区中才能进行。程序可能用不完一个分区全部的空间,因此会有空间浪费,这种浪费称为内部碎片
  2. 动态分区分配:固定分区分配实现简单,但非常不灵活。动态分区分配不预先设置每个分区的大小,而是在程序装入是动态决定。随着内存的分配和回收,分区与分区之间会产生空隙,称为外部碎片,如下图:

连续分配策略要求进程放到连续的物理空间中,并且要求空间能完全容纳下程序所需的空间,当没有足够的连续空间时,程序只有等待。不管是固定分区还是动态分区,内存的利用率都非常低。因此提出非连续内存空间分配策略,只要有足够的物理空间就尽可能为进程分配,不需要物理地址一定连续。

非连续内存分配允许将一个进程分散装入到不相邻的内存分区中,根据分区大小是否固定也可分为两种:一、基本分页式;二、基本分段式。

基本分页式(分区大小固定):把物理内存划分成等大的小分区,称为页框,或页帧(Page Frame);把进程的逻辑空间也为一些小块称为页(Page)。在程序运行时,将一页装入一个页框中,并且用一个额外的内存空间记录页与页框的对应关系,称为页表(Page Table)。分页式内存管理除了最后一个页框会产生内部碎片外,其他的页框都是装满的。每个页框的大小应该是 2 的整数次幂,不易过大或过小。过大的页框会使外部碎片增加;过小的页框会使页表过大。

基本分页式地址变换:基本分页式的逻辑地址包含两个信息:一、页号(该地址属于哪一页);二、页内偏移(该地址在页内的排第几)

页表包含两个信息,页表在内存中连续存放:一、页号(该页在逻辑空间中的编号);二、块号(该页在物理空间中映射到哪一个页框)

另外还需要一个页表寄存器,其中包含两个信息:一、存放页表在内存的起始地址;二、页表的长度

那么变换的过程可以叙述为:通过逻辑地址的页号加上页表寄存器中的页表起始地址(如果页号大于页表长度,会发生缺页中断),算出页表中该页的物理地址,取出物理块号 B,假设每页大小为 L,页内偏移为 S,那么物理地址为:B * L + S。如下图所示:

基本分页式地址变换,需要两次内存访问,才能从内存中取出数据。因此为了提高转换速度,可以把最近使用的页表项放到 MMU 的转译后背缓存(Translation Lookaside Buffer,TLB) 中,这是一个高速缓存,可以加快转换速度。

基本分段式(分区大小不固定):把进程的逻辑空间拆分出不同段(Segment),比如汇编程序分为代码段、数据段、栈段三部分;C++ 称为代码段、数据段、BSS、栈、堆五部分…… 在装入时,为每个段分配连续的空间,但段与段之间不需要连续。用一个额外的内存空间记录段到物理地址的对象关系,称为段表(Segment Table)。分段式内存管理会产生外部碎片,但相比连续分配的动态分区分配,内存利用率还是要高很多。

基本分段式地址转换:基本分段式的逻辑地址包含两个信息:一、段号(该地址属于哪一段);二、段内偏移(该地址在段内的排第几)

段表包含三个信息,段表在内存中连续存放:一、段号;二、段长;三、基址(段对应的内存物理起始地址)

另外还需要一个段表寄存器,其中包含两个信息:一、存放段表在内存的起始地址;二、段表的长度

那么变换的过程可以叙述为:通过逻辑地址的段号加上段表寄存器中的段表起始地址(如果段号大于段表长度,会发生越界中断),算出段表中该段的物理地址,取出该段对应的内存物理起始地址 B,段内偏移为 W,那么物理地址为:B + W。如下图所示:

基本段页式:基本分页式能极大提高内存的利用率,但进程的分页对开发者是一个黑盒;基本分段式由开发者决定进程的拆分,并且方便管理在多个作业之间共享的数据,但内存利用率相对较低。因此综合两种管理方式,现代操作系统通常采用段页式。段页式管理首先将进程的逻辑空间拆分成不同的段,然后再将每个段分成固定大小的页。在运行时,将段页放进固定大小的内存页框中。然后一个进程有一个段页表,每个段有一个页表,段页表和页表共同构建起逻辑地址到物理地址的对应关系。

基本段页式地址变换:基本段页式逻辑地址包含三个信息:段号:该地址属于哪个段;页号:该地址属于段中哪一页;页内偏移量:该地址在页内排第几

段页式的段页表包含三个信息:一、段号;二、页表长度(该段中有分有几个页);三、页表起始地址(该段页的页表在内存中的起始地址)

段中的页的页表包含两个信息:一、页号;二、块号

段页式需要一个段页表寄存器:一、段页表起始地址;二、段页表长度

那么变换的过程如下图所示:

段页式地址变换需要三次存储访问才能取到数据,因此访问的效率要低于基本分页式和基本分段式。

虚拟内存(Virtual Memory):内存的物理空间非常有限,现代操作系统都实现了虚拟内存技术,使用磁盘空间扩充内存的物理空间。虚拟内存建立在非连续内存分配策略的基础上,不同的内存分配策略有不同的虚拟内存实现方式。在基本分页式的基础上有请求分页式,在基本分段式的基础上有请求分段式,在基本段页式的基础上有请求段页式。

请求分页式:在分页式内存分配策略上,只需将进程的一部分页装入内存便可执行。当所要访问的页不在内存时,触发中断机制,将缺页装入内存;同时,还可以将暂时不用的页换出到外存上。请求分页式的页表需要增加一些信息:一、状态位(标识该页是否在内存中);二、访问字段(用以记录该页多长时间未被访问);三、修改位(该页在调入内存后是否被修改);四、外存地址(页的外存地址;)

请求分页式地址转换:请求分页式和请求分段式的页表和段表都变得更加复杂,因此地址变换也更加复杂,下图以请求分页式为例,刻画了地址变换的流程:

触发缺页中断时进程将被阻塞,等待页面调入内存,这一过程非常缓慢,严重会导致应用卡顿。因此虚拟内存的管理也要有置换策略,通常使用 LRU 算法,将最近最久最少使用的页面换出内存。

请求分段式:在分段式内存分配策略上,只需将进程的一部分段装入内存便可执行。当所要访问的段不在内存时,触发中断机制,将缺段装入内存;同时,还可以将暂时不用的段换出到外存上。请求分段式的段表需要增加一些信息:一、状态位(标识该页是否在内存中);二、访问字段(用以记录该段多长时间未被访问);三、修改位(该段在调入内存后是否被修改);四、外存地址(段的外存地址)

请求段页式:略

3.2.3 GPU 内存管理与 CUDA 内存模型

对于运行在 CPU 上的软件,现代计算机系统在硬件和操作系统层面做很多工作。比如保证共享数据的一致性、内存地址管理、内存的分配与回收、预取机制、置换机制……因此软件开发者只需关注程序逻辑的实现,不需要关注存储器的使用。

对于运行在 GPU 上的软件稍微麻烦一点。GPU 的核心数多,对于共享数据,同步成本高,因此运行在 GPU 上的程序线程之间应该尽可能独立,少同步。GPU 没有提供复杂的置换机制,需要开发者自己指定数据存放到哪一级存储器上,因此 GPU 内存管理更多是软件开发者的任务。CUDA 提供了一个内存模型简化开发者内存管理的难度,如下图:

线程组与线程:在 CUDA 程序一开始,需要给任务分配线程组和线程,一个线程组有若干线程,一个线程对应一个流处理器。

寄存器(Register)是在 SM 中,每个流处理器私有若干个寄存器。寄存器最主要的作用是用来暂存该流处理器线程执行指令过程中产生的局部变量。每个寄存器大小 32 bit,在早期的 GPU 中,一个流处理器最多可以独占 60 多个寄存器,存放 60 个单精度浮点数;如果是较新的 GPU,一个流处理器可以独占 200 多个寄存器,存放 200 个单精度浮点数。

这些寄存器除了用来暂存局部变量外,还用于线程切换,寄存器可以保持当前线程的上下文,然后切换到当另一作业执行。GPU 使用寄存器保存上线问,因此线程切换的代价很小,只用一个时钟周期即可完成上下文切换。GPU 这一特性常用到隐藏显存读写的时延,这在后面详细介绍。

流处理器私有的寄存器数据不会主动写入到 Cache 或显存中,除非该线程在执行过程中申请了超过 200 个局部变量。寄存器中的局部变量在该线程指令执行完之后释放,如果希望存储该线程执行的结果,需要把数据写入到全局内存区或共享内存区中。

局部内存(Local Memory):每个流处理器有自己私有的局部内存,该局部内存是分配在显存上的,用来储存寄存器存不下的局部变量。从显存中读写数据自然会影响执行效率,因此我们需要控制每个线程需要的申请的局部变量的数量。局部内存中的数据在该线程指令执行完之后释放。

共享内存(Shared Memory):线程组私有,分配在 SM 中的 L1 Cache 上,组中所有线程共享。共享内存同样是非常珍贵的资源,在 NVIDIA Fermi 架构 GPU 中,32 个 CUDA Cores 共享 64 KB Shared Memory/L1 Cache;在 NVIDIA Turing 架构 GPU 中,64 个 CUDA Cores 共享 96 KB Shared Memory/L1 Cache。共享内存中的数据在该线程组的所有的指令执行完之后释放。

在 NVIDIA GPU 中,共享内存会被分成多个 Bank,可供多个流处理器同时访问,Bank 数量通常和流处理器数量相同。如果每个流处理器与其访问的 Bank 一一对应,那么不需要同步。如果多个流处理器访问同一个 Bank 会导致 Bank Conflicts。下图中从左到右,1、3、4 没有 Bank Conflicts;2、5、6 有 Bank Conflicts。如果发生 Bank Conflict,需要给共享内存数据设置屏障,访问将会串行化。当所有线程的访问结束后,整个线程组的指令才会继续执行。发生 Bank Conflicts 之后,必然会影响性能,应该尽可能避免。

[公式]

常量/纹理内存(Constant/Texture Memory)是分配在显存上的,流处理器只读的存储区,不需要同步,所有线程共享。CPU 通过 PCI-E 总线将数据写入常量\纹理内存,但因为 CPU 和 GPU 是异步执行的,所以 CPU 写入常量\纹理内存时需要跟 GPU 同步。另外,如果宿主程序是多线程的,那么这些 CPU 线程之间也需要同步。在保证局部性原理的情况下,可以将常量/纹理数据换入缓存中,加速读取速度。比如,纹理在图形渲染中通常是一张图片,这张图片由 Host 端发送到显存中。图形程序通常需要并行采样这张纹理,为了保证局部性原理,一个线程组(SM 或 CU)最好只采样纹理  的连续区域,称为一个 tile,那么我们就可以把这个 tile 放到 L1 Cache 中,加速采样过程。如果使用 mipmap 技术,我们可以不需要原始分辨率的纹理,某一 mip 层级纹理的 tile 肯定小于原图的 tile,这样可以进步节约缓存空间。

常量内存和纹理内存的区别:常量内存中的数据是线性连续存放的,纹理内存中的数据是分块(tile)存放的。对于顺序访问的数据,建议放在常量内存中,比如顶点缓存、索引缓存,能提高缓存命中率;对于分块访问的数据,建议放在纹理内存中,比如纹理,能提高缓存命中率。

全局内存(Global Memory):CPU/GPU 都可以读写的区域,分配在显存上,供所有线程共享。通常,可以把 Shared Memory 的数据或者寄存器中的数据写回到全局内存中,释放高速缓存区的存储空间,这一过程时延很长,通常是把计算结果写入到全局内存中。CPU 读写全局内存区也是通过 PCI-E 总线,比如把 GPU 计算的结果回读到内存中,这一过程非常缓慢。和共享内存一样,全局内存访问冲突时,需要给数据设置屏障,使访问串行化,当所有线程的访问结束后,整个线程组的指令才会继续执行。发生冲突之后,必然会影响性能,应该尽可能避免。

常量/纹理内存与全局内存中的数据在主机发送销毁指令后或程序执行完后释放。

3.2.4 CPU/GPU 的通信

GPU 在开始工作前,需要把指令和数据放到制定的存储区,GPU 才能开始工作。这涉及 CPU/GPU 的通信,CPU/GPU 有不同的存储架构,通信方式也有所不同,CPU/GPU 异构计算系统有如下两种常见的存储架构:

左图为独立显卡(Discrete GPU 或 Dedicated GPU),Dedicated GPU 属于外部设备,通过总线(如 PCIe )与 CPU 相连。Dedicated GPU 有专用的显存(Video Memory),CPU 与 Dedicated GPU 的存储架构属于非统一内存访问(Non-uniform memory access,NUMA)架构,也即不同计算核心有独立的存储器。

右图为集成显卡(Integrated GPU),Integrated GPU 与 CPU 集成到同一张芯片,GPU 没有独立的显存,需要与 CPU 共享内存空间,属于统一内存访问(Uniform Memory Access,UMA)架构。

对于 Dedicated GPU,其不能直接读写内存,需要在工作之前,把数据从内存中经总线放到显存中。在 CUDA 程序中,GPU 显存地址访问经历了三个不同的时期:

  1. 分离式内存管理:需要区分 CPU 内存地址、GPU 显存地址、GPU 寄存器地址。如果我们需要传输大量数据到 GPU 中,通常考虑将数据拷贝到显存中,称为 Memory Copy,这由一个拷贝函数负责;如果只需要更新某一个变量,通常考虑将数据拷贝到寄存器中,称为 Zero Copy,这由另一个拷贝函数负责;CPU 可以从显存和寄存器中回读数据,这又由两个拷贝函数负责
  2. 统一虚拟地址(Unified Virtual Address,UVA):CUDA 4.0 后为了简化数据拷贝,将内存地址、GPU 显存地址、GPU 寄存器地址统一成一套虚拟地址,在拷贝时 CUDA Runtime 会判断源和数据的地址,从而将拷贝函数也变成了一个,简化拷贝操作
  3. 统一内存(Unified Memory):CUDA 6.0 之后开始支持,这里的 UM 不是 Integrated GPU 的 UMA 架构,GPU 显存依然是独立,而是将内存地址、GPU 显存地址、GPU 寄存器地址统一成一套虚拟地址。UM 与 UMA 的区别于实现了数据的按需迁移,在过去 CPU 不能一次开辟超过显存容量的空间,一个大数据只能分多次迁移或者将数据从显存中置换出来。而 UM 提供了类似操作系统虚拟内存的机制,当 GPU 发生“缺页”时,自动将数据经 PCIe 迁移到 GPU 显存中

工业界似乎很少讨论 Dedicated GPU 绕过 CPU 直接读写内存(Direct Memory Access,DMA) 的技术,因为这种技术对 Dedicated GPU 意义不大,首先是 Dedicated GPU 有独立的显存,不需要内存扩充其地址空间,即使显存空间不够,那内存扩充其地址空间读写时延过大;第二是 GPU 的设计是希望其能尽可能脱离 CPU 独立运行,因此没有访问内存的必要。 当然,如果数据发送到显存,需要从外存中读入内存,再从内存经总线发送到显存。两次 I/O 无疑是非常慢。NVIDIA 认识这一问题,为 CUDA 开发者提供了 NVIDIA GPUDirect 技术,它可以使 GPU 绕过 CPU 直接从外存中读取数据。GPUDirect 技术还支持其他外部设备(如网卡或另一个 GPU)绕过 CPU 直接从显存中读取数据。

UVA 和 UM 的出现是为了简化 CUDA 程序编写的难度,而在 PCIe 中,有一项标准称为 内存映射 I/O(Memory Mapped I/O,MMIO) 。它能将设备内存和主机物理内存映射到同一个逻辑地址空间。从 CPU 角度,当访问一个地址时,可能映射到主机物理内存地址,也可能映射到设备内存地址或设备寄存器地址。有了 MMIO 技术,CPU 就可以用访问主机内存的指令访问设备内存。在软件开发者眼中,内存地址和设备内存地址都是指针。

MMIO 或许是 UVA 或者 UM 实现的基础,但我在官方文档没有找到相关解释,对 PCIe 标准也不了解,需要进一步求证

对于 Integrated GPU,CPU 与 GPU 通信不需要经过外部总线,只需要将地址重新映射就可以了,这种技术称为 GART(Graphics address remapping table) ,GPU 可以直接绕过 CPU 直接访问内存。笔者对于 Integrated GPU 了解不多,日后有机会再作补充。

3.3 内存对齐

内存对齐不是内存管理的任务,是编译器和软件编写人员的任务,内存对齐在程序开发中非常重要,所以还是简单提一下。

CPU 和 GPU 在内存访问时都要求数据是内存对齐(Memory Alignment)的。以 CPU 为例,一次内存(或缓存)访问,可以并行读写 2 的整数次幂个字节的数据,称为存取粒度。比如 32 位的 CPU 一次可以读写 4 Bytes 数据、64 位 CPU 可一次读写 8 Bytes 数据、16 位 CPU 一次可读写 2 Bytes 数据。这 2 的整数次幂个字节的数据为一组,就将内存划分成一个一个的格子。在 32 位机上,一个 int 数据占 4 个字节,如果刚好放到一个内存“格子”中,那么 CPU 只需要一次内存访问就可以读出这个 int 数据,这就是内存对齐。如果内存不对齐,一个 int 跨越了两个“格子”,那么 CPU 就需要两次内存访问,还要遮掩掉无效的位,并把有效的位拼接起来才能得到一个 int 数据,如下图:

因此连续存放的数据结构,其成员需要考虑内存对齐,并且其首地址也要内存对齐,比如在 32 位机上的如下的 C++ 代码:

struct A{
    int b;
    char a;
    char c;
};

struct B{
    char a;
    int b;
    char c;
};

sizeof(A); // 占 8 字节
sizeof(B); // 占 12 字节

结构体可以看成是可以放置不同类型数据的数组,那么为了使内存对齐,成员变量之间就会出现“空隙”,而且不仅成员变量之间需要对齐,结构体对象的首地址也需要对齐。内存对齐是编译器自动完成的,但我们理解了对齐规则后,合理的安排结构体中成员的布局,可以减少成员与成员之间的内存空隙。32 位 CPU 是按照 4 字节对齐,那么上面 Struct A 和 Struct B 的内存布局如下:

对于 GPU 来说,也需要数据存放是对齐的,GPU 中每个线程的内存访问也有存取粒度,通常是 16 字节。显存中对齐的数据在访问时还能获得优化——访问合并。

GPU 访问合并:GPU 为了优化全局内存的访问,如果多个线程的内存访问是连续对齐的话,这些线程的访问请求会合并成一个请求,然后一次返回所有数据。


4 并行计算处理器

在渲染算法中,我们需要处理大量相似的任务,任务之间相互独立(每个像素的渲染没有关联性),适合并行计算。并行计算(Parallel computing)是计算科学的一大分支,并行的核心在于任务(Task)的同时执行。任务是一个比较宽泛的概念,它可以是处理器需要执行的指令、可以是处理器需要处理的数据、还可以是多道指令组成的函数(或者说线程 Thread)。

与并行非常接近的概念是并发(Concurrency),并发是指一段时间内同时处理多道任务,任务也可以分时间片执行。并行强调在一个时钟周期,同时执行多道任务。一个并发但不并行的例子是在单核 CPU 上,多道函数分时间片执行,这需要引入上下文切换机制

并行计算表面的原因是为了加速任务的完成,更深层次的原因是避免处理器闲置,增大处理器的吞吐量。为了设计高性能并行处理器,计算机科学家弗林提出了一种分类标准称为:弗林分类法(Flynn’s Taxonomy) 。他根据指令流(Instruction Stream)和数据流(Data Stream)两个维度区分不同的处理器:

SISD(单指令流、单数据流处理器):在任何一个时钟周期,只有一条指令流被处理单元执行,一条数据流被处理单元处理。在这种处理器中,通常只有一个执行单元(Processing Unit,PU),属于串行处理器。

SIMD(单指令流、多数据流):在任何一个时钟周期,所有执行单元执行同一条指令,但每个执行单元输入的数据可以不同。在这种处理器适用于向量和矩阵运算,比如 GPU 就是一种 SIMD 处理器,属于并行处理器。

MISD(多指令流、单数据流):在同一个时钟周期,不同处理器单元执行不同的指令,但输入处理器单元的数据是相同。这种并行处理器并不常见。

MIMD(多指令流、多数据流):在一个时钟周期,每个处理单元执行不同的指令流,处理不同的数据流。指令之间可以同步或异步执行,在如今的超级计算、分布式系统、个人电脑多核 CPU 都是基于 MIMD

4.1 指令级并行

次标量处理器:一个单核 CPU 指令执行最简单的方式为,CPU 一个时钟周期只能执行一条指令的一个阶段,假设每个指令的每个阶段需要一个时钟周期,那么执行三条指令需要十五个时钟周期。

这种 CPU 平均一个时钟周期最多只有一条指令在执行(IPC < 1,Instructions Per Machine Circle),称为次标量处理器(Subscalar Processor)。其处理器资源利用率很低,假设某一指令的一个阶段消耗多于一个时钟周期,那么后面的阶段只有等待。

标量处理器:为了提高 CPU 资源利用率,一种最常见的方式是让 CPU 在一个时钟周期能执行多条指令的某一个阶段:

这种 CPU 平均一个时钟周期有一条指令在执行(IPC = 1),属于一种标量处理器(Scalar Processor)。这是一种最简单的指令级并行(Instruction-Level Parallelism,ILP)手段。指令流水线效率取决于程序指令的可并行性,当某一条指令依赖之前指令的执行结果时,将不能以流水线方式执行,还是会按照第一种方式执行,这种情况称为障碍(Hazard)

超标量处理器:更进一步,在一个单核 CPU 中搭载多个执行单元(Execution Unit),比如多个 ALU、FPU、LSU 等。那么一个时钟周期可以将多条指令的一个阶段分发到不同功能单元上执行:

这处理器平均一个时钟周期有多条指令在执行(IPC > 1),属于一种 超标量处理器(Superscaler Processor) 。超标量处理器指令并行程度同样取决于程序指令的独立性。现代所有的通用 CPU 几乎都是超标量的。Superscaler Processor 同样要避免 Hazard。

并行障碍:因为指令之间的依赖性,后续指令需要等待前一指令结果的返回,这时需要改进硬件,处理障碍。下面介绍三种策略:1. 管线气泡;2. 操作数前移;3. 乱序执行

管线气泡(Pipeline Bubble):管线气泡是最简单的解决方案。在一条指令在解码阶段被识别需要依赖前面指令执行的结果时,会创建一个管线气泡占据解码阶段,使当前管线的解码阶段处于空闲等待状态,管线气泡对导致后续指令被延迟。如下图,紫色指令依赖于绿色指令将执行结果写会寄存器,在其解码阶段会创建一个指令气泡,在第 5 个时钟周期,绿色指令的结果写回寄存器,紫色指令从寄存器读取绿色指令执行的结果,进入执行单元执行,在第 6 个时钟周期,管线气泡被挤出指令管线。

操作数前移:当发生并行障碍时,后一条指令依赖前一条指令将执行结果回写到寄存器,再从寄存器读取结果才能执行。如果前一条指令在执行后需要内存访问,再写回寄存器,那么后续指令等待的时钟周期将不止一个。如果前一条指令能直接将执行结果直接传递给后一条指令,不经过寄存器,将大大减少等待的时间。操作数迁移技术需要改进硬件,使解码器能探测这种依赖性,并增加一条电路直接获取前一条指令的值。

乱序执行:乱序执行允许指令执行的顺序与程序编写时的顺序不一样,但执行结果依然正确。指令乱序执行的前提是,该指令所需的数据已经计算出来,而且它不依赖前一条(或多条)指令的结果,那么它可以先于前一条(或多条)指令执行。那么乱序执行的步骤为:

  1. 获取指令
  2. 将指令分配到一个指令队列
  3. 指令在指令队列中等待,直到其输入操作数可用,此时它可以早于自己前面指令被执行
  4. 执行指令
  5. 将指令输出结果保存在一个队列中
  6. 只有当一个指令之前所有指令被执行完毕并将结果写回寄存器后,才将该指令的结果写回寄存器,以保证执行结果的顺序一致性

超长指令字:不管是 Scalar Processor 还是 Superscalar Processor,都是通过增加硬件的复杂度,决定哪些指令能在一个时钟周期并行执行。另一种不增加硬件复杂度的前提下,依然能使多条指令在一个时钟周期同时执行的架构称为超长指令字(Very Long Instruction Word,VLIW) 。VLIW 是不同于 CISC 和 RISC 的第三种指令集架构,它将多个相互无依赖的指令由编译器打包成一条超长指令,交由 CPU 核心中对应数量的执行单元并行执行,指令级并行的发现和指令执行顺序的调度完全由编译器完成,处理器只负责指令的执行。AMD TeraScale 架构 GPU 的流处理器就是 VLIW 架构,一个 TeraScale 流处理有 5 个 ALU:

4.2 数据级并行

Scalar Processor 和 SuperScalar Processor 都是 SISD,也即单指令、单数据,适合标量运算。在数学领域,除了标量运算,还有向量运算。如果用 SISD 处理器执行一个四维向量的加法,它需要八个内存访问指令取出数据,四条加法指令,四条寄存器写回指令。

向量运算天然适合并行执行,四个分量的互相独立,SuperScalar Processor 因为有多个执行单元,四条加法指令可以在一个指令周期并行完成,勉强可以完成短向量的运算,但读写数据是串行的,是整个指令流水线的瓶颈。如果数据读写也能并行执行那将极大的加速向量运算,这种并行称为数据级并行(Data-Level Parrallelism,DLP) 。

向量处理器:在超标量处理器的基础上,为了支持任意维度的向量的运算,我们需要增加更多的执行单元,搭载更大容量的向量寄存器(Vector register),称为向量处理器(Vector Processer) 。向量处理器最大的不同是内存访问是流水线化,因此以同时读写多条数据,并将数据同时分发到不同的执行单元。Vector Processor 是一种 SIMD 处理器。那么上面的四维向量加法,只需要两条内存访问指令,一条向量加法指令和一条向量寄存器写回指令。对于长向量或者矩阵运算,一个处理器核心中不可能集成如此多的执行单元和寄存器,需要拆分成多条向量指令,因此向量处理器也支持向量指令流水线并行执行。

数据级并行对数据的局部性要求更高,因为 SIMD 处理器中的执行单元和内存访问单元都是并行执行,如果缓存缺失,会导致更多资源资源闲置。

4.3 线程级并行

进程与线程:在高级编程语言中,一堆指令的集合称为函数(function),一个程序可以只有一个函数,也可以将重复使用的指令“打包”到一个函数中。调用函数意味着执行其中的指令,只需要知道函数的地址就可以调用该函数。如果函数的之间没有依赖关系,那么这些函数可以同时执行。当程序调入内存运行时,称为一个进程(Process),这些可以同时执行的函数称为线程(Thread)

操作系统允许多道进程并发执行,同一个进程包含一条或多条线程,线程是操作系统调度的最小单位。操作系统会维护一个线程池(Threads Pool),每个线程会赋予一定优先级,分时间片发送到到处理器上执行。软件线程本质上是工作的拆分,在处理器层面,线程中的指令只能在 SISD、SIMD 处理器上分时间片执行,这其实是一种并发(Concurrency)。线程级并行(Thread-Level Parrallelism) 要求处理能同时执行来自多条线程的指令,这需要设计更复杂的电路。

同时多线程(Simultaneous Multithreading,SMT):在超标量处理器的基础上,搭载更多指令获取器和程序计数器,可以在一个时钟从多道线程中获取指令并行执行。同时 SMT 处理器有更多的寄存器,当一条线程遇到内存访问指令,需要多个时钟周期才能返回结果,它就将该线程的上下文用寄存器保存下来,并从另一条线程取指令继续执行。当内存访问结果返回时,可以在一个时钟周期内从寄存器恢复被挂起的线程。SMT 技术使处理器保持忙碌,隐藏内存访问时延。

超标量处理器与 SMT 处理器的区别在于,超标量处理器只能从一个线程中取多道指令并行执行,而 SMT 处理器可以从不同线程中取多道指令并行执行。因此 SMT 处理器是一种 MIMD 处理器。Intel 的超线程技术(Hyper threading) 也是一种 SMT

还有一种多线程技术称为时分多线程(Temporal Multithreading,TMT),它一个时钟周期只允许来自一条线程的一个指令执行,同时它拥有更多的寄存器,方便保存线程上下文。

从标量处理器到超标量处理器,再从超标量处理器到向量处理器,最后从向量处理器到多线程处理器。单核处理器的并行潜力被充分挖掘后,如果希望同时完成更多的任务,只有在一块芯片上集成更多的核心,也就是多核处理器。

多核处理器(Multi-Core Processor):多核处理器能在一个时钟周期,同时执行来自不同线程的指令,属于 MIMD 处理器。操作系统负责将用户线程分发到不同处理器核心上执行,处理器的核心数通常少于用户的线程数,因此多道线程依然是分时间片在单个核心上执行。

4.4 GPU 并行计算

在图形渲染中,每个像素可以独立渲染,渲染过程中涉及非常多的向量/矩阵运算,因此可以把渲染任务拆分成不同的阶段(线程),交由一个对称多核 SIMD 处理器并行执行,这个处理器就是 GPU。GPU 指令也会划分出取指令、指令解码、指令执行等基本阶段,下图来自《General-Purpose Graphics Programming Archtecture》

GPU 指令执行过程是单指令流多数据流,NVIDIA 称其 GPU 是单指令多线程(Single Instruction Multiple Threads,SIMT),其实本质与 SIMD 差不多。GPU 将一条指令分发到多个流处理器上同时执行,不同的流处理器执行相同的指令处理不同的数据,每个流处理器就是一个线程。

NVIDIA GPU 将 32 个线程为一组,称为一个 Warp;AMD GPU 将 64 个线程为一组,称为 Wavefront。Warp 或者说 Wavefront 就是 GPU 调度的最小单位。在编写 GPGPU 程序时,首先需要为程序分配线程,分配线程的数量最好是 32 的整数倍。如果一个作业需要多于 32 个线程,那么会把这个作业拆分到多个 Warp 中执行;如果一个作业需要不足 32 个线程,那么依然会为它分配一个 Warp,用不到的线程会被遮掩(Mask Out),直白地说就是“闲着(Stall)”,等待其他线程任务的完成。

一个 Warp 的线程会以锁步(Lock-Step)的方式执行这些指令。所谓的锁步就是一起开始,一起结束,每个 流处理器都执行相同的指令,但处理不同的数据。锁步的工作方式就是为实时渲染管线设计的,比如一个 SM 分配到 1000 个顶点着色的任务,所有顶点执行相同的着色程序因此每个流处理接受到的指令是相同的,每个流处理器处理不同的顶点,因此数据是不同的。处理这 1000 个顶点需要 floor(1000/32) + 1 个 Warp。NVIDIA 2018 年推出的消费级旗舰显卡 RTX 2080 ti 有 4352 个 CUDA Cores,同时可以分出 136 个 Warp。

分支语句于循环语句:现代 GPU 都支持分支语句与循环语句,但分支语句和循环语句的引入会影响 GPU 执行的效率。假设一个 Warp 需要为 8 个顶点进行着色,如下图左,顶点着色器代码如下图右。该代码存在两个分支,那么这 8 个线程有的会进入 true 分支,有的会进入 false 分支。如果此时执行 true 分支的指令,那么进入 false 分支的线程将会被遮掩,等待 true 分支指令的完成。当 true 分支的指令执行完之后,遮掩进入 true 分支的线程,激活进入 false 分支的线程,执行 false 分支的指令。Warp 中有的线程进入 true 分支,有的进入 false,那么线程被遮掩的时钟周期将会被浪费。同理循环语句的也是一样,当不同线程的循环次数不同时,循环次数少的线程会被遮掩,等待循环次数长的线程指令的完成。这就是不推荐 Shader 中使用控制流语句的原因。但如果一个 Warp 都进入相同的分支或者循环的次数都一样,那么将不会有线程被遮掩,也就不会影响性能。

时延隐藏:当缓存缺失时,GPU 访问显存的时延是非常高的,当一个 Warp 所有线程遇到线程读写指令时,可能需要数百个时钟周期才会把数据返回。因此,Warp 调度器会在寄存器中保存当前 Warp 的上下文,然后迅速把这 32 个流处理器切换到另一个 Warp 上继续执行。当数据返回时,可以从寄存器中恢复上下文,继续执行。GPU 通过保持忙碌,隐藏时延。因为上下文保存在寄存器中,所以上下文切换可以在一个时钟周期内完成。但寄存器是一个 GPU 中非常珍贵的资源,当有大量寄存器用于保存上下文时,就没有足够的寄存器去执行计算任务,仍然会使处理器闲置(Stall)

实时渲染管线:(一)基本概念与发展简史[转]

作者:木头骨头石头
原文地址:https://zhuanlan.zhihu.com/p/440584180

实时渲染管线是一个非常大的话题,这一技术随着游戏市场的扩大发展了二、三十年。有无数科学家和工程师投入其中,笔者能力有限,只能对他们的部分工作做一些简单注解。下面的内容总结自是笔者学习游戏开发的过程中搜集到的资料,原文会表明出处。其中关于硬件的部分,笔者没有任何相关背景和开发经验,可能会有很多错误,还望指出。

1 实时渲染管线基本概念

实时渲染管线可以拆分出两个关键词,一是实时渲染(Real-time Rendering),二是管线(Pipeline)

在计算机图形学中,渲染算法有两大框架:一是光线投射(光线追踪);二是光栅化;实时渲染就是基于这两大框架,并要求在 33ms 内完成一副图像的渲染。这两种算法都需要解决三个核心问题:

  1. 找到三维空间点,对应图像空间的哪个像素(投影)
  2. 计算着色点的颜色
  3. 解决着色点与着色点之间的遮挡关系

光线投射算法的灵感来自于物理上的光线传播,利用光的可逆性原理,从图像空间的每个像素发出射线(Ray),利用解析几何的手段,找到光线与场景中距离观察点最近的交点,然后着色,同时解决上面三大问题。

光栅化算法是从线性变换的角度解决投影问题,因为投影变换是一个线性变换,所有的线性变换都可以用一个矩阵表示,所以光栅化算法是遍历场景中的每一个物体的每一个顶点乘以投影矩阵,把顶点变换到图像空间。然后通过解析几何的手段,找到投影后的几何图形覆盖的像素区域,计算这些像素的颜色。最后用一个额外的存储空间,记录距离观察点最近的片元,解决遮挡问题。

下面用伪代码表示这两种算法,假设场景中的物体都是三角形网格模型:

RayCasting()
{
    foreach pixel in Image {
        cast ray;
        foreach object in Scene {
            foreach triangle in object {
                find visible shading point
            }
        }
    }
}

Rasterization(){
    foreach object in Scene {
        foreach triangle in object {
            find visible shading point;
        }
    }
}

不管是光线投射算法还是光栅化算法,都需要遍历大量的物体,处理大量的像素,而且对每个像素、每个物体进行的操作都很相似,因此可以将循环算法改成并行算法,可以显著地缩短渲染时间。我们将图形渲染任务,拆分成相互独立的流水线阶段,每个阶段交由多核处理器并行执行。通过不断的优化算法,将有可能在 33ms 内完成渲染任务。

这个多核处理器就是 GPU,GPU 中有数千颗计算核心,适合光栅化算法和光线追踪算法这种数据密集型算法。CPU 和 GPU 最大的差异在于核心数,CPU 专门为复杂串行作业(Serial Task)设计,主流 CPU 的核心数是 4 核或 8 核。CPU 需要负责整个计算机的运行,因此有复杂的控制单元。CPU 对内存通常是随机读写,要求高可靠,低延迟(通常 200-300 个时钟周期),同时不需要太高的带宽,主流的高速 DDR4 内存的带宽可达 25.6 GB/s。

GPU 的出现不是为了取代 CPU,而是辅助 CPU 完成复杂的计算任务。GPU 不能独立运行,需要 CPU 派发指令控制 GPU 执行。GPU 不是单纯的 many cores CPU,虽然 GPU 能作通用计算,但并不是所有的算法都适和 GPU 执行。GPU 是专门为大数据并行作业(Parallel Task)设计,并且每个线程相互独立(比如图形渲染)。GPU 的核心数量是通常有好几千个,GPU 同一时间会处理大量相似的数据、执行大量相似的指令,产生大量计算结果,因此显存的设计也突出大吞吐(Throughput)、高带宽(Bandwidth),主流旗舰显卡的 GDDR6 显存的带宽是 512GB/s,是 CPU 与内存带宽的数十倍。显存高带宽的代价是读写高延迟,通常有 400 到 600 个时钟周期。 GPU 只负责特定任务的执行,不需要控制整个计算机,因此缩小了控制单元的面积,并将核心大部分面积让给了计算单元。

开发者可以在应用程序中调用专用的接口控制 GPU 运行。在图形程序或游戏中,可以使用 Direct3D 12、Direct3D 11、OpenGL、OpenGL ES、Vulkan、Metal 等接口,在 GPU 通用计算领域,可以使用 CUDA、OpenCL 等。这些程序都是运行在 CPU 上,操作系统和显卡驱动会将接口翻译成 GPU 可执行的指令,提交给 GPU。图形接口的任务是控制 GPU 完成图形渲染的任务,在 API 层面抽象出了四种渲染管线:

  1. 光栅化渲染管线
  2. 计算管线
  3. 光线追踪渲染管线
  4. 网格渲染管线

并不是所有的 GPU 都支持这四种管线,下面简单介绍一下 GPU 与图形 API 的发展史,了解相关技术的由来。


2 GPU 与图形 API 发展史

以下内容总结自 TechSpot 上的系列文章以及 WIKI 百科,简单介绍技术的变迁,不涉及商业冲突或阵营矛盾,如有错误还望指出,推荐阅读原文:

2.1 1970-1990 年图形加速设备出现

上世纪 70 年代,拥有图形用户界面的操作系统成为主流。此时,图形界面的绘制工作由 CPU 一个像素一个像素的绘制。随着界面越来越复杂,屏幕分辨率越来越高,CPU 已经难堪重负,所以有公司开始设计专门的图形加速设备。图形加速设备属于计算机体系的外部设备,专门用于图形图像显示。上世纪 70 年代也是雅达利游戏机的鼎盛时期,雅达利游戏搭载的就是自家的 ANTIC 2D 图形设备。上世纪 80 年代,雅达利退出游戏市场,家用游戏机进入任天堂时代。个人电脑上的图形加速设备也越来越多,Intel,IBM 也推出了自家的图形加速设备,同时专门设计图形加速卡的公司开始成立。1981 年,Silicon Graphics, Inc(SGI) 成立;1985 年,VideoLogic 成立,后来改名为 Imagination Technologies;1985 年,ATI TechnologiesInc 成立,后来被 AMD 收购;1989 年 S3 Graphics 成立。

2.2 1990 年-2000 年图形应用程序接口出现

上世纪 90 年代,N64、PlayStation、Sega Saturn 等游戏主机推出,进入 3D 游戏时代。此时电脑上有太多的图形加速设备,各家的标准不同,导致软件人员开发困难。1992 年,SGI 公司决定将自家的 IRIS GL API 转变为一项开放标准,OpenGL 诞生,并成立了 OpenGL 架构评审委员。OpenGL 允许在非 SGI 的设备上运行。OpenGL 一开始定位于专业图形渲染领域,但因其易用性,越来越多的开发者使用它制作游戏。1995 年,微软推出了用于 Windows 操作系统的 3D 图形接口 —— Direct3D

DirectX 是一个接口集合,其在不同年代包含的接口不同。早期的 DirectX 包含 Direct3D 和 DirectDraw 分别是 3D 图形程序接口和 2D 图形程序接口。此外还有 DirectSound,以及 DirectDraw 的替代者 Direct2D。下面主要介绍 Direct3D

90 年代,加入图形硬件市场的有两家重量级公司:1993 年,NVIDIA ;1994 年,3D/fx, Inc 。1996 年,3D/fx 公司的 Voodoo 加速卡上市,这是历史上最成功的图形加速卡,真正意义上的初代“核弹”,市占率高达 85%。Voodoo 卡的诞生使个人电脑进入 3D 时代。ATI 为了抗衡 3D/fx,推出了 Range Pro 加速卡。此时,NVIDIA 还在蓄势待发,不过 PC 端图形卡的性能竞赛才刚刚开始。3D/fx 的称霸也只是暂时的。

同一时期,VideoLogic 公司开发的 PowerVR 加速卡是市面上首次采用 TBDR(Tiled Based Deferred Rendering)技术的设备。TBDR 技术的优势在于减少帧缓存的带宽消耗,因此相比其他图形卡更加节能,TBDR 也是未来移动端 GPU 主流架构。

1999 年,世纪之交,NVIDIA 推出 GeForce 256,首次提出了 GPU 的概念。此前的 3D 图形加速设备只限于完成图形的光栅化任务,而 GeForce 256 提供了完整的 3D 渲染流水线,其不仅能快速的完成光栅化任务,还能硬件加速 T&L(Transform & lighting),也就是能在 GPU 中加速顶点的坐标变换,计算片元的光照。在此之前,这一技术在家用游戏机上已经普及,但在 PC 上这些工作都是 CPU 的任务。GeForce 256 支持 Direct3D 7.0 和 OpenGL 1.2。此时,ATI 正在开发旗下新的显卡产品 Radeon,也开始支持硬件 T&L。

2.3 2000-2006 年可编程渲染管线出现

2001 年,微软发布 Direct3D 8 ,同时提出了 Shader Model 1.0。Shader Model 是微软提出的概念,其要求显卡厂商支持其中提到的功能和指令。SM 1.0 提到了顶点着色器和像素着色器,用户可以使用微软提供的 HLSL 语言对这两个阶段编程,标志着可编程渲染管线出现。同年 NVIDIA 和 ATI 搭载顶点着色器和像素着色器的 GPU 发布,宣布支持 Direct3D 8 标准 。

不同于微软的财大气粗,OpenGL 是通过拓展(extension)使用 GPU 的新特性。此时,OpenGL 的发展也陷入停滞与迷茫,新特性的支持也总是慢于 DirectX。OpenGL 1.3 以拓展的形式支持可编程渲染管线,OpenGL 提供的着色器语言为 GLSL。直到 2004 年,OpenGL 2.0 发布,可编程渲染管线与 GLSL 才正式成为 OpenGL 标准的一部分。

2002 年,Direct3D 9 发布,引入 Multiple Render Targets(MRT) 技术,至此,渲染管线的渲染目标可以不再是帧缓存,可以是自定义纹理。OpenGL 2.0 开始支持 MRT。

这一时期,GPU 硬件的性能大战也进入最终阶段。3D/fx 被 NVIDIA 收购;ATI 和 AMD 合并;Imagination Technologies 放弃桌面端显卡市场,转向移动端 GPU 的研发。SGI 宣布破产,OpenGL 架构评审委员会投票决定将 OpenGL API 标准的控制权交给 Khronos Group

2.4 2006 年-2013 年 GPU 通用计算

2006 年,主机游戏进入 PlayStation3 和 XBOX 360 时代。同一年,Direct3D 10 发布,一并提出的还有 Shader Model 4.0。SM4.0 新增几何着色器(Geometry Shader)。同时 SM 4.0 还提出统一着色架构(Unified Shader Architecture)的概念。最早宣布支持统一着色架构的硬件是 ATI 推出的,使用在 XBOX 360 上的 GPU。所谓的统一着色器架构是相对于分离式着色架构而言的,分离式着色架构是指顶点着色器和像素着色器运行在不同的 ALU 上,这些处理器使用不同的指令集,因为顶点和像素处理的数量的差别,可能出现像素着色处理器繁忙但顶点着色处理器闲置的情况。统一着色器架构是将顶点处理器和像素处理器合二为一,称为流处理器(Streaming processors,SP),它们使用同一套指令集,拥有能运行顶点着色器代码、几何着色器代码和像素着色器代码的所有指令,GPU 负责调度这些流处理器的均衡,避免处理器闲置,提高计算效率。

2006 年,NVIDIA 以科学家 Tesla 命名的显卡架构支持统一着色架构;AMD 和 ATI 合作的第一代显卡架构 TeraScale 也支持统一着色架构。GPU 支持统一着色架构意味着 GPU 计算核心的指令集相比过去更加复杂,那么也能完成更加通用的计算任务,GPGPU(General Purpose GPU Programming)的概念出现。利用 GPU 多核处理架构和大数据吞吐能力,可以加速一些数据密集型任务。NVIDIA 提出统一计算设备架构(Compute Unified Device Architecture,CUDA),并为软件开发者提供 CUDA SDK ,能在非图形程序中为控制 GPU 执行特定算法;而 AMD/ATI、Apple 等与 Khronos Group 合作,制定开源的 OpenCL(Open Computing Language)标准,是一种跨平台的 GPGPU 接口。

2010 年,Direc3D 11 推出,一并提出的还有 Shader Model 5.0。SM 5.0 给渲染流水线新增了一个可选阶段 —— 细分曲面阶段,加快模型 LOD 的计算。更重要的是,D3D11 受到 CUDA 和 OpenCL 的启发,以及开发者的强烈要求,正式为图形渲染引入一条新管线 —— 计算管线(Compute Pipeline),计算管线是独立于光栅化渲染管线的另一条管线,它利用了 GPU 通用计算特性,通过计算着色器(Compute Shader),可以完成一些非图形渲染任务。

2.4.1 集成显卡

2011 年,AMD 整合旗下资源,第一次将 CPU 和 GPU 集成到一个芯片上,称为 APU(Accelerated Processing Unit),APU 上的显卡称为集成显卡(Integrated GPU)。在过去 GPU 或者说更早的图形加速卡,都是作为外部设备通过总线接入电脑,而 APU 中的多个核心共用主机内存,属于统一内存访问(Uniform Memory Access,UMA)架构,这里 GPU 不再有专用的显存,而是从内存分出连续的区域模拟显存。因为都共享内存了 CPU 和 GPU 的通信就不用经过外部总线,只需要将地址重新映射就可以了,这种技术称为 GART(Graphics Address Remapping Table)。统一内存的好处是缩小了电脑的尺寸,降低了功耗。因为主机内存的带宽不如独立显卡(Dedicated GPU)的显存,而且受芯片面积、功耗、发热的限制,集成显卡的性能能也相对孱弱。

2010 年之后,智能手机的兴起,移动端 GPU 在迅猛发展。2002 年,PowerVR 已经竞争不过 N/A 两家的显卡,转而将重心移到移动端 GPU 的开发上。出于尺寸和功耗的考量,移动端计算设备都是 SoC(System on A Chip)集成电路,也即将 CPU、GPU、内存、外存、网卡、声卡…… 都集成到一张芯片上。移动端 GPU 主要有四:1. ARM-Mali GPU;2.Qualcomm-Adreno GPU;3. PowerVR;4. Apple A Series SoC 集成的 GPU,这些 GPU 都采用 TBDR 技术节省带宽,移动端的 CPU/GPU 也是 UMA 架构。移动端 GPU 支持的图形接口是 OpenGL ES,算是 OpenGL 的子集。在苹果的设备上,使用自家的 Metal API。另外 Vulkan 也开始在移动设备上得到支持。

2.5 2013 年-至今 实时光线追踪

2013 年,PlayStation 4 和 Xbox One 游戏机开始在全球发售,这一时代游戏机都采用了 AMD 提供的 GCN 架构 Integrated GPU。此时,在各个领域都被竞争对手压一头的 AMD 主导推出了一套新的图形接口 Mantle。并在 PlayStation 4,XBox One 等主机上得到支持。但 Mantle API 不久后被 AMD 砍掉,随后将其捐赠给了 Kronos Group。Mantle 短暂的生命周期却影响了下一个世代图形 API 的设计思路,Direc3D 12,Vulkan,Metal 都能看到 Mantle 的影子。

Mantle 最大的突破是认识到 GPU 性能的突飞猛进,CPU 逐渐成为渲染管线的性能瓶颈。Mantle 优化了绘制命令从 CPU 到 GPU 中冗余的操作,并且向开发者开放了命令缓存(Command Buffer)命令队列(Command Queue)的接口。所谓的命令队列是在显卡驱动上的队列,调用图形接口后,CPU 会把相关命令放在这个队列上,GPU 从这个队列中取命令执行。如果命令队列满了,CPU 会等待 GPU 完成命令;如果命令队列为空,GPU 会等待 CPU 发出命令。在过去 GPU 的性能较差,很少会发生 GPU 等待 CPU 的情况,因此在 Mantle 之前的图形接口都是基于单核设计的,CPU 只有一个线程向命令队列提交命令,这足以使 GPU 满负荷工作。随着 GPU 性能的提升,单核 CPU 命令提交的速度逐渐跟不上 GPU 处理的速度。为了让 CPU 和 GPU 都能满负荷运行,发挥 CPU 多核并行潜力,Mantle 开放了命令缓存接口,图形程序可以启用多个线程,每个线程将命令放到各自的命令缓存中,然后将命令缓存中的命令提交(Submit)到命令队列中供 GPU 执行。

Mantle 带来的改进肯定不止如此,开发者如果能合理利用新特性,可以充分发挥 CPU 和 GPU 的并行特性,使它们都满负荷运行。2015 年,Direct3D 12 推出,Shader Model 也升级到 6.0,其继承了 Mantle 设计的核心理念,API 的设计更加底层,给了开发者更多的操作权。同一年,Khronos Group 宣布停止 OpenGL 的更新,新时代的 3D 图形接口变为 Vulkan。Vulkan 是 Mantle 真正的继承者。

2.5.1 实时光线追踪

2018 年,NVIDIA 推出搭载加速光线追踪核心 RT Core 的 Turing 架构。同一年,Direct3D 12 推出 DXR,供软件开发者使用。2020 年,AMD 的 RDNA2 架构 GPU 也宣布支持光线追踪。Vulkan 也在近期的更新中,推出了光线追踪拓展。光线追踪管线不同于光栅化管线,GPU 为光线追踪管线提供了加速光线与物体相交的硬件,属于光线追踪管线的固定功能阶段。光线追踪的其他阶段都是基于 GPGPU 的。目前,工业界普遍采用的是结合了光栅化管线、光线追踪管线、计算管线的混合图形渲染管线。用光线追踪完成软阴影、环境光遮蔽、间接光照、半透明物体等传统光栅化非常难做的任务。2020 年,PlayStation 5,XBox Series X 发售,新时代主机使用了 AMD RDNA2 架构定制 GPU,支持实时光线追踪。

2.5.2 网格渲染管线

光栅化图形渲染管线软硬件技术已经发展了 20 多年,自 2001 年可编程渲染管线出现之后,光栅化管线的基本流程没有大的变化,也即输入装配、顶点着色、光栅化、像素着色、输出合并。如此拆分渲染管线是有历史原因的,在 GPGPU 时代之前,显卡中有非常多的专用集成电路(Application Specific Integrated Circuit ,ASIC)单元,它们分别负责光栅化流水线的一个阶段的工作。

而在 GPGPU 已经普及的今天,几何处理阶段仍然分为多个阶段:顶点着色、外壳着色、细分曲面、域着色、几何着色,其中大部分任务都是运行在流处理上,拆分成如此多的阶段不仅增加显卡驱动的开销(追踪和验证每个阶段状态)、还会增加数据在阶段之间传递的开销。受到 GPU Driven Pipeline 的影响,越来越多的开发者呼吁利用 GPGPU 的特性重构几何处理阶段,使其更加符合现代 GPU 的设计。

2017 年,AMD 率先做出尝试,它将所有光栅化管线的几何处理任务交给一个可编程的 Primitive Shader。不同于顶点着色器,开发者可以把一个图元所有的数据输入 Primitive Shader,让它完成所有的几何处理任务(比如图元剔除、细分曲面、LOD、MVP 变换),而顶点着色器只能输入一个顶点的数据。但这一想法最终并没有落实到 AMD 的新显卡上。

2018,NVIDIA 宣布 Turning 架构 GPU 上将支持的全新着色器 Task Shader 和 Mesh Shader,取代传统光栅化管线的几何处理阶段,称为 Mesh Shader Pipeline。2019 年,Direct3D 12 和 Vulkan 宣布支持 Mesh Shader Pipeline。目前,这一管线还在初期阶段,还没有得到大规模的应用。

GPU渲染管线和硬件架构 [转]

作者:landonwang,腾讯IEG游戏客户端开发工程师

序言

  • 联发科的工程师团队在对我们游戏进行了性能分析之后,建议我们将草地的PreZ移除掉试试,或许可以提高游戏性能。这与我们的传统认知有出入。但是印象中,确实有某些机器开不开PreZ差别不大。这个矛盾点促使我们对PreZ做了进一步的研究和测试。
  • 在测试过程中我发现,如果对GPU的渲染管线不够了解的话,很有可能连测试用例都是错误的。所以后面又花了大量时间查阅了GPU硬件架构的资料。过去一些模糊的概念也变得清晰起来。
  • 本文简述了GPU的渲染管线和硬件架构,对一些常见问题进行了讨论和分析。特此分享出来,与君共勉。当然,由于本人并未从事过硬件开发的工作,文中有错漏之处在所难免,欢迎批评指正。
  • 本文内容量很大,总结下来有以下核心内容:
    • 移动平台渲染管线TBDR的介绍
    • GPU缓存体系的介绍
    • Warp的执行机制
    • 常见的如AlphaTest或者分支对性能的影响

一、GPU渲染管线

1.1 渲染管线简述 所谓渲染管线,就是CPU传送给GPU一堆数据(顶点、纹理等),经过一系列处理,最后渲染得出来一副二维图像。有以下几个阶段。

1.1.1 应用程序阶段

粗粒度剔除、渲染状态设置、准备数据。

我们在游戏引擎中所做的视锥剔除、遮挡剔除等,都是粗粒度剔除,是基于模型级别的。

这一步是在CPU进行的,后面的步骤都是在GPU内部进行的。

1.1.2 顶点处理阶段

顶点着色器、曲面细分、几何着色器、顶点裁剪、屏幕映射。

这里会做背面剔除等裁剪,确保只有真正需要绘制的图元才会进入光栅化。

顶点处理是可编程的(Vertex Shader,Geometry Shader和Compute Shader)。

1.1.3 光栅化阶段

三角形设置、三角形遍历、片元着色器。

光栅化引擎会将图元(Primitive)映射为与屏幕像素对应的片元(Fragment)。片元包含每个像素的坐标、颜色、深度、法线、导数、纹理坐标等信息。这个数据经过片元着色器的计算得到最终的颜色值。

像素处理是可编程的(OpenGL中叫做片元着色器,Fragment Shader,DirectX中叫做像素着色器,Pixel Shader)。这里通常是性能瓶颈所在,所以现代GPU做了很多的优化来尽可能避免执行无效的像素处理,比如EarlyZ、隐面剔除等。

1.1.4 逐片元操作

裁剪测试、深度测试、模板测试、混合。

光栅化阶段得到的颜色值通过一些列的测试、混合,最终写入到FrameBuffer中。

ROP (Raster Operations),是由一个独立的硬件单元来完成的。这个硬件单元的数量和性能限制了GPU每秒写入FrameBuffer的数据量。在一些低端机这个阶段也非常有可能成为性能瓶颈,即每秒ROP处理数据量。即便使用最简单的全屏半透明shader,多叠加一两层就会因overdraw而严重降帧,很有可能就是ROP瓶颈。

1.2 IMR: Immediate Mode Rendering

上图展示了一个非常经典的IMR渲染管线,也是桌面端最常见的GPU架构。GPU会在每个drawcall提交中,按照管线的顺序处理每个图元。

1.2.1 优势:

其优势是渲染管线没有中断,有利于提高GPU的最大吞吐量,最大化的利用GPU性能。同时从vertex到raster的处理都是在GPU内部的on-chip buffer上进行的,这意味着只需要很少的带宽(bandwidth),就可以存取(storing and retrieving)处理过程中的图元数据。

所以桌面GPU天然就可以处理大量的DrawCall和海量的顶点。而移动端GPU则对这两者异常敏感。这不仅仅是GPU性能差异,架构差异也至关重要。

1.2.2 劣势:

IMR是全屏绘制的。当前绘制的图元可能存在于屏幕的任何位置。这意味着我们需要一个全屏的FrameBuffer。这个buffer的内存很大(比如30MB,大小跟屏幕分辨率和渲染目标的格式有关),所以只能放在系统内存(DRAM)中。而在光栅化之后的fragment shading, depth testing, stencil testing, blending等阶段,都会大量的与系统内存进行交互,消耗大量的带宽。GPU的L1/L2缓存可以缓解这个问题,但是对于移动端,依然是不可接受的开销。

1.3 TBR: Tile-based Rendering 1.3.1 为什么要使用TBR架构

对移动端设备而言,控制功耗是非常重要的。功耗高意味着耗电、发热、降频,这会导致我们的游戏出现严重的卡顿或者帧率降低。带宽是功耗的第一杀手,大量的带宽开销会带来明显的耗电和发热。

移动端GPU的带宽本来就跟桌面端GPU不是一个量级,又无法像独立显卡一样独占大量带宽,所以减少带宽开销变得异常重要。因此移动端GPU普遍使用的是TBR/TBDR架构。

1.3.2 TBR架构的原理

TBR跟IMR不同之处在于,它并不是基于全屏直接绘制。而是把屏幕分成一个一个的Tile,GPU一次只绘制一个Tile。绘制完毕再将绘制结果写入到系统内存的FrameBuffer中。

TBR架构的绘制过程分成两个部分

第一步,处理所有顶点,生成一个 tile list 的中间数据(FrameData)。这个数据保存了每个图元归属于哪个屏幕上的Tile。PowerVR一个Tile是32×32大小,而Mali则是16×16大小(即将发布的Mali-G710也修改为32×32大小了)。

第二步,针对每个Tile执行像素处理过程(光栅化、片元着色器等),每个Tile处理完毕,将结果一起写入到系统内存中。

1.3.3 优势

TBR架构最大的优势就是减少了对主存的访问,也即减少了带宽开销。每个Tile足够小,其framebuffer是可以做到on-chip memory上的。on-chip memory紧挨着GPU的shader core,访问速度极快。

不仅仅是fragment shader,depth testing、blending等操作也是在on-chip memory进行的。大幅减少像素处理阶段对系统内存的访问。

还有一个明显优势是,depth buffer和stencil buffer只在Tile处理内部有用。它们是不需要写回系统内存的,这进一步节省了带宽开销。

TBR架构里,一些原本非常昂贵的,消耗大量的带宽的算法,会变得高效起来。比如MSAA (Multi-Sample Anti-Aliasing)。

1.3.4 劣势

GPU要处理所有的顶点,生成tile list,然后才可以进行光栅化。跟IMR相比,这里会有明显的“延迟(latency)”。

生成的这个tile list数据是要存到系统内存中的。这同样会有带宽开销。顶点越多,计算的压力就越大,带宽消耗也会越多。像曲面细分(tessellation),在TBR架构下就异常的昂贵。

所以移动端游戏对顶点数量更加敏感。如果顶点数量过大的话,会导致性能严重下降。

1.4 TBDR: Tile-Based Deferred Rendering

TBDR和TBR模式基本类似,唯一的区别在于,多了一个 隐面剔除(Hidden Surface Removal) 的过程。就是上图中HSR & Depth Test这个步骤。通过HSR,无论以什么顺序提交drawcall,最终只有对屏幕产生贡献的像素会执行像素着色器。被遮挡的片元会被直接丢弃掉。

不同的GPU有自己的隐面剔除技术,比如PowerVR就是Hidden Surface Removal (HSR),Adreno就是Low Resolution Z (LRZ),Mali就是Forward Pixel Kill (FPK)。其原理和实现各不相同,不过最终目的都是为避免执行无效的像素着色器。

1.5 总结

IMR是桌面端GPU的主流架构。NVIDIA较新的显卡也部分支持了Tile based的特性。不过这个Tile是较大的Tile,而不是像Mali芯片这样16×16的小Tile。

TBR是移动端GPU的主流架构,通过拆分成一个个Tile绘制,减少与主存的交互,进而减少带宽开销。

TBDR一开始专指PowerVR,其光栅化之后并不是立即渲染,而是多了隐面剔除的过程。后来Adreno和Mali也分别提出了自己的隐面剔除方案。所以认为现在的移动端GPU都是TBDR架构也不为过。

推荐阅读 GPU Framebuffer Memory: Understanding Tiling 这篇文章,更加直观的了解不同架构的绘制过程。

二、GPU硬件架构 2.1 GPU和CPU的差异

这张图展示了CPU和GPU的硬件差异。

CPU核心数量少(计算单元少),每个核心都有控制单元。内存设计上是大缓存、低延迟。

而GPU则恰好相反,计算单元非常多,多个计算单元共享一个控制单元。内存设计上追求高带宽,可以接受较高延迟。

所以CPU中习以为常的分支控制,逻辑运算,在GPU中成了奢侈品。而面对海量数据并发计算的场景,GPU则比CPU快好几个数量级。

CPU和GPU的差异可以描述在下面表格中:

CPUGPU
延迟容忍度
并行目标任务(Task)数据(Data)
核心架构多线程核心SIMT核心
线程数量级别1010000
吞吐量
缓存需求量
线程独立性

2.2 CPU的缓存体系和指令执行过程

虽然本文主要讲的是GPU架构,不过CPU和GPU有很多地方是相通的,同时又有一些设计方向是相反的。了解CPU可以帮助我们更好的理解GPU的执行过程。

2.2.1 内存的硬件类型

  • SRAM(Static Random Access Memory,静态随机存取内存)具有静止存取数据的作用,但是断电后数据会消失,不需要刷新电路就能够保存数据,速度较DRAM快很多。一般用作片内缓存(On-chip Cache),例如L1 Cache、L2 Cache。
  • DRAM(Dynamic Random Access Memory,动态随机存取内存)需要不停地刷新电路,否则内部的数据将会消失,因此被称为“动态”存储器。常用于内存,容量较SRAM大。一般用作系统内存(System Memory)。现在桌面端的内存都是DDR (DDR SDRAM,Double Data Rate Synchronous Dynamic Random-Access Memory,简称DDR)。
  • GDDR(Graphic DDR),用作显存。时钟频率更高,耗电量更少。早期显存也是使用DDR的,不过后面独立发展为GDDR。DDR现在还处于DDR4标准,而GDDR已经发展到GDDR6了。
  • LPDDR SDRAM,简称LPDDR(Low Power Double Data Rate)。是移动设备常用的一种低功耗SDRAM,以低功耗和小体积著称。FrameBuffer便存放于此。芯片参数中说的LPDDR4/LPDDR5,说的便是这个,代表了系统内存的性能。
  • UFS(Universal Flash Storage)。通用闪存存储,如ufs2.1/ufs3.1等。代表了移动设备“磁盘”的性能。

2.2.2 CPU的缓存体系

  • CPU的缓存有L1/L2/L3三级缓存。L1缓存和L2缓存是在CPU核心内部的(每个核心都配有独立的L1/L2缓存),L3缓存是所有核心共享的。缓存是SRAM,速度比系统内存(DRAM)要快非常多。
  • L1/L2缓存是片上缓存,速度很快,但是通常比较小。比如L1 cache通常在32KB~256KB这个级别。而L3 cache可以达到8MB~32MB这个级别。像苹果的M1芯片(CPU和GPU等单元在一个硬件上,SoC),L3缓存是给所有硬件单元使用的,所以也被称为System Level Cache。
  • L1缓存分为指令缓存(I-Cache)和数据缓存(D-Cache),CPU针对指令和数据有不同的缓存策略。
  • L1缓存不可能设计的很大。因为增大L1缓存虽然会减少L1 cache missing,但是会增加访问的时钟周期,也就是说降低了L1 cache的性能。
  • CPU的L1/L2缓存需要处理缓存一致性问题。即不同核心之间的L1缓存之间的数据应该是一致的。当一个核心的L1中的数据发生变化,其他核心的L1中的相应数据需要标记无效。而GPU的缓存不需要处理这个问题。
  • CPU查找数据的时候按照L1–&gtL2–&gtL3–&gtDRAM的顺序进行。当数据不在缓存中时,需要从主存中加载,就会有很大的延迟。
  • 缓存对提高CPU的执行性能有着非常重要的意义。如上面的Intel i7 die shot所示,很多时候缓存会占据芯片中一半以上的晶体管和面积。苹果的A14/A15/M1芯片性能上碾压同档次的其他SoC,跟超大缓存密不可分,其L2/L3缓存一般是其他SoC的两三倍。

2.2.3 CPU指令的执行过程

  • 经典的指令流水线有下面五个阶段
    • Instruction Fetch,取指令。从指令缓存(I-Cache)中取出指令,放在指令寄存器中。
    • Instruction Decode,指令解码。这里还会通过寄存器文件(Register File)取到指令的源操作数。
    • Execute,执行指令。
    • Memory Access,如果需要存储器取数据(load/store指令),则通过数据缓存(D-Cache)取数据。不访问存储器的指令此阶段不做任何事情。
    • Register Write Back,将指令执行结果写入目的寄存器。
  • 访问主存,无论是GPU还是CPU都会出现较大的延迟。面对延迟,CPU和GPU的解决方案不一样。CPU是通过大容量高速缓存,分支预测,乱序执行(Out-of-Order)等手段来遮掩延迟。CPU缓存容量比GPU要大,比如GPU就没有L3缓存。而且CPU的缓存是以低延迟为目标设计的。GPU是另外一种思路,通过切换线程Warp来规避指令延迟带来处理单元的停顿,下文介绍GPU的Warp机制的时候还会提到这一点。
  • 如果出现分支,CPU是通过分支预测,来提高流水线的执行性能。现代CPU的分支预测准确率很高,可以达到90%以上。当然一些糟糕的代码影响了分支预测,就会出现性能问题。GPU没有分支预测单元,所以并不擅长执行分支。
  • CPU可以同时发射多条指令,让指令能够并行计算,而不是一条流水线串行计算,这样可以更好的利用计算单元。这个就是超标量设计(Super Scalar)。现代CPU基本都是超标量的。
  • 如果按照原本的指令顺序执行,可能指令之间有依赖无法并行执行,或者频繁出现高延迟指令。所以CPU会在保证执行结果正确性的基础上,修改指令的执行顺序,让指令能够更加高效的执行,从而减少执行等待,提升管线性能。这个就是乱序执行
  • CPU的线程切换会有明显的上下文(Context)切换开销。因为切换到其他线程需要将寄存器和程序计数器保存起来,等切换回来的时候还需要恢复寄存器和程序计数器。所以CPU会尽可能避免频繁的线程切换。而GPU因为寄存器数量很多,线程切换时不需要保存上下文,所以就可以通过零成本的切换线程来遮掩延迟。
  • Intel CPU也有使用GPU的思路,准备两套寄存器,CPU核心在两个线程之间切换的成本就非常低。这样一个核心就可以当做两个核心来使用。这就是超线程技术。不过CPU毕竟不像GPU有大量寄存器,核心在两个线程之间切换,并不一定能够保证降低延迟,同时不能准确控制每个线程的执行时间。所以很多游戏以及高性能计算程序都是关闭超线程的。

2.3 GPU渲染过程

具体渲染过程,其实就是经典的渲染管线的执行过程。可以跟上一部分的渲染管线流程图对照阅读。推荐阅读 Life of a triangle – NVIDIA&aposs logical pipeline 一文。

  • 应用程序通过图形API(DirectX、OpenGL、Vulkan、Metal)发出渲染命令,通过驱动传输数据给GPU。GPU通过主机接口(Host Interface)接受这些命令,并通过前端(Front End)处理这些命令。
  • SM (Streaming Multiprocessor) 负责处理执行顶点着色器。现代GPU都是统一着色器架构(Unified Shader Architecture),顶点着色器和像素着色器使用相同的处理核心执行。这样GPU可以更好的做负载均衡,以适应顶点任务重或者像素任务重的不同工作情景。
  • 处理过的三角形会被裁剪,然后分配给光栅化引擎。在光栅化阶段,会把三角形离散为与屏幕对应的栅格信息。
  • 光栅化后的片元,经过EarlyZ剔除,生成像素线程。32个线程为一个线程束(Warp)。这是GPU计算核心的最小工作单元。
  • 接下来是在SM中执行像素着色器。一个Warp中执行的指令是一样的,但是数据不一样(SIMD/SIMT)。
  • 执行完像素着色器之后,数据会交给ROP (Raster Operations)。因为像素着色器执行有快有慢,所以这里会有排序过程,保证执行ROP的顺序和原始API的调用顺序是一致的。一个ROP内部有很多ROP单元,在ROP单元中处理深度测试,和Framebuffer的混合。深度测试和颜色写入必须是原子操作,否则两个不同的三角形在同一个像素点就有可能会有冲突和错误。

2.4 桌面端GPU硬件架构

上图展示的是NVIDIA Fermi架构的示意图。不同的GPU,架构差异较大,但是大体都包含下列核心组件:

  • SM、SMX、SMM (Streaming Multiprocessor)。GPU的核心,执行Shader指令的地方。一个GPU由多个SM构成。Mali中类似的单元叫做Shader Core,PowerVR中叫做Unified Shading Cluster (USC)。
  • Core。真正执行指令的地方。NVIDIA叫做CUDA Core。Mali中叫做Execution Engine或者Execution Core。PowerVR中叫做Pipeline。当然由于硬件结构差异,Execution Engine、Pipeline和CUDA Core并不等价。后面还会对此再做分析。
  • Raster Engine。光栅化引擎。
  • ROP (Raster Operations)。depth testing, blending等操作都是在这里完成的。
  • Register File, L1 Cache, L2 Cache。寄存器和各级缓存。

2.5 Shader Core的主要构成单元

  • 32个运算核心 (CUDA Core,也叫流处理器Stream Processor)
  • 16个LD/ST(Load/Store)模块来加载和存储数据
  • 4个SFU(Special function units)执行特殊数学运算(sin、cos、log等)
  • 128KB寄存器(Register File)3万个32-bit的寄存器,大寄存器设计
  • 64KB L1缓存 (On-Chip memory)
  • 纹理读取单元 (Texture Unit)
  • 指令缓存(Instruction Cache)
  • Warp Schedulers:这个模块负责warp调度,一个warp由32个线程组成,warp调度器的指令通过Dispatch Unit送到Core执行。

2.6 GPU的内存结构

2.6.1 UMA (Unified Memory Architeture)

  • 这张图展示了桌面端和移动端的内存结构差异。桌面端独立显卡是左边的分离式架构,CPU和GPU使用独立的物理内存。而移动端是右侧的统一内存架构,CPU和GPU共用一个物理内存。桌面端的集成显卡也是UMA架构。
  • UMA并不是说CPU和GPU的内存就在一起了,实际上它们所使用的内存区域并不一样。物理内存中有一块儿专有区域由GPU自己管理。CPU到GPU的数据通信依然是有拷贝过程的。如果狭义上说,像主机平台或者苹果M1芯片这样可以实现CPU和GPU的零拷贝数据传输的架构才是真正的UMA,移动端这种架构只能算是共享物理内存。
  • 移动芯片都是SoC(System on Chip),CPU和GPU等元件都在一个芯片上,芯片面积(die size)寸土寸金。自然不可能像桌面端一样给显卡配备GDDR显存,通过独立的北桥(PCI-e)进行通信。在移动端CPU和GPU使用同一个物理内存也更加灵活一些,操作系统可以决定分配给GPU的显存大小。当然副作用就是CPU和GPU很多时候会抢占带宽,这会进一步限制GPU能使用的带宽。
  • GPU使用独立的显存空间的好处是,GPU可以对Buffer或者Texture做进一步优化,比如对GPU更加友好的内存排布。显存中存储的数据可能并不是我们实际Upload的数据。所以即便在手机上,CPU和GPU共用的是一块儿物理内存,我们依然需要通过MapBuffer的形式来完成数据的拷贝。反过来说,如果CPU和GPU直接使用相同的数据,那么GPU就无法对数据做优化,可能会降低性能。
  • CUDA后面有推出统一虚拟地址(Unified Virtual Address,UVA)和统一内存(Unified Memory,UM)的技术,将内存和显存的虚拟地址统一。不过这个跟物理内存是合并的还是分离的没有关系。其目的是为了减化开发者写CUDA程序的内存管理的负担。

2.6.2 GPU缓存的分类 GPU缓存结构

  • L1缓存是片上缓存(On-Chip),每个Shader核心都有独立的L1缓存,访问速度很快。移动GPU还会有TileMemory,也就是片上内存(On-Chip Memory)。
  • L2缓存是所有的Shader核心共享的。属于片外缓存,离Shader核心略远,所以访问速度较L1缓存要慢。
  • DRAM是主存(系统内存,可以叫做System Memory,Global Memory或Device Memory),访问速度是最慢的。FrameBuffer是放在主存上的。

内存访问速度

内存的存取速度从寄存器到系统内存依次变慢:

存储类型RegisterShared MemoryL1 CacheL2 CacheTexture/Const MemorySystem Memory
访问周期11~321~3232~64400~600400~600
  • 寄存器访问速度是最快的,GPU的寄存器数量很多。
  • Shared Memory和L1 Cache是同一个硬件单元,Shared Memory是可以由开发者控制的片上内存,而L1缓存是GPU控制的,开发者无法访问。部分移动芯片如Mali,是没有Shared Memory的,这个主要影响OpenCL开发。
  • Local Memory和Texture/Const Memory都是主存上的一块儿内存区域,所以访问速度很慢。

NVIDIA的内存分类

查资料的时候经常会看到这些概念,但是NVIDIA的内存分类是为CUDA开发服务的,与游戏开发或者移动GPU还是有一些差异的。所以这里只需要简单了解即可。

  • 全局内存(Global memory)。主存,Device Memory。
  • 本地内存(Local memory)。Local Memory是Global Memory中的一部分。是每个线程私有的。主要用于处理寄存器溢出(Register spilling,寄存器不够用了),或者超大的uniform数组。访问速度很慢。
  • 共享内存(Shared memory)。Shared Memory是片上内存,访问速度很快。是一个Shader核心内的所有线程共享的。
  • 寄存器内存(Register memory)。访问速度最快。
  • 常量内存(Constant memory)。Constant Memory和Local Memory类似,都是Global Memory中的一块儿区域,所以访问速度很慢。部分GPU会有专门的Constant cache。
  • 纹理内存(Texture memory)。与Constant Memory类似,也在主存上。部分GPU有Texture cache。

2.6.3 Cache line

  • GPU和缓存之间的内存交换不是以字节为单位,而是以Cache line为单位的。Cache line是固定的大小,比如CPU的Cache line是64字节,GPU是128字节。
  • Cache line不仅仅是为了字节对齐。也有现实意义。想要知道是否缓存命中,是否写入主存,肯定要有标记位。所以一个Cache line就是标记位+地址偏移+实际数据。
  • 缓存命中与否性能差异巨大。对一块儿内存进行顺序访问比随机访问,性能可能要好很多。我们纹理使用Mipmap可以是提高纹理的缓存命中率进而提升性能。Unity的ECS系统也是期望通过Cache友好的数据布局来提升性能。

2.6.4 Memory Bank和Bank Conflict

  • 为了提高对内存的访问性能,获得更高带宽,Shared Memory/L1 Cache被设计为一个个的Memory Bank(L2 cache可能也有类似设计)。bank数量一般与warp大小或者CUDA core数量对应,例如32个core就把SMEM划分为32个bank,每个bank包含多个cache line。Bank可以理解为Memory的对外窗口,有10个窗口可以访问,肯定要比只有1个窗口要高效。
  • 如果同一个warp中的不同线程,访问的是不同的bank,那么就可以并行执行,最大化利用带宽,性能最高。
  • 如果访问的是一个bank中的同一个cache line,那么可以通过广播机制同步到其他线程。一次访问即可取得数据,也不会有性能问题。
  • 如果访问的是同一个bank中的不同的cache line,那么就必须阻塞等待,串行访问。这个会严重阻碍GPU的并行性,产生明显的性能下降。这个阻塞等待的情况,被称为Bank Conflict
  • 如果不同的线程,对同一个cacheline有写操作,那么也必须要阻塞等待。必须等上一个线程写完毕,才能执行后面的读取或者写入操作。

2.7 GPU的运算系统

2.7.1 SIMD (Single Instruction Multiple Data) 和 SIMT (Single Instruction Multiple Thread)

  • 在游戏引擎内,我们常会使用SSE加速计算(比如视锥体裁剪的计算)。这里利用的就是SIMD,单个指令计算多个数据。
  • 而GPU的设计是为了满足大规模并行的计算(其处理的任务就是天然并行的)。因此GPU是典型的SIMD/SIMT执行模式。在其内部,若干相同运算的输入会被打包成一组并行执行。
  • 在介绍SIMT之前,我们需要先介绍下Vector processorScalar processor的概念。早期GPU是Vector processor(对应SIMD)。因为早期GPU处理的都是颜色值,就是rgba四个分量。在此架构下,编译器会尽可能把数据打包成vec4来进行计算。但是随着图形渲染以及GPGPU的发展,计算变得越来越复杂,数据并不一定能够打包成vec4,这就可能会导致性能的浪费。所以现代GPU都改进为Scalar processor(对应SIMT)。后面介绍Mali的架构演进的时候还会提到这一点。
  • 现代GPU都是SIMT的执行架构。传统SIMD是一个线程调用向量处理单元(Vector ALU)操作向量寄存器完成运算,而SIMT往往由一组标量处理单元(Scalar ALU)构成,每个处理单元对应一个像素线程。所有ALU共享控制单元,比如取指令/译码模块。它们接收同一指令共同完成运算,每个线程,可以有自己的寄存器,独立的内存访问寻址以及执行分支。
  • 传统的SIMD是数据级并行,DLP (Data Level Parallelism)。而SIMT是线程级并行,TLP (Thread Level Parallelism)。更进一步的超标量(Super Scalar)是指令级并行,ILP (Instruction Level Parallelism)
  • Mali的Midgard是VLIM(超长指令字,Very long instruction word)设计。它可以通过128bit-wide的计算单元并行计算4个FP32或者8个FP16等类型的数据。编译器和GPU会合并指令以充分利用处理器资源。这也是一种指令级并行(ILP)。
  • PowerVR、Adreno的GPU,以及Mali最新的Valhall架构的GPU都支持Super Scalar。可以同时发射多个指令,由空闲的ALU执行。也就是说,同一个Pipeline内的多个ALU元件是可以并行执行指令序列中的指令的。
  • 无论使用哪种架构,GPU的计算单元都是并行处理大量数据,所以有的文章也会直接把GPU的计算单元称作SIMD引擎,或者简称为SIMD。
  • 如上图所示,左侧是Vector处理器,而右侧是Scalar处理器。对于Vector处理器而言,它是在一个cycle内计算(x,y,z)三个值,如果没有填满的话,就会产生浪费。如果是Scalar处理器,它是在3个时钟周期内分别计算(x,y,z)三个值,不过它可以4个线程同时计算,这样就不会浪费处理器性能。
  • 合并单个计算为向量计算,在Scalar processor上没有优化效果。因为处理器计算的时候还是会把向量拆散。之前是一个vec4在一个cycle内完成计算。现在是一个vec4在4个cycle内完成计算,每个cycle计算一个单位。如果是vec3的话,就是3个cycle。

2.7.2 Warp 线程束

  • Warp是典型的单指令多线程(SIMT)的实现。32个线程为一组线程束(Warp)。这32个线程同时执行一样的指令,只是线程数据不一样,这就是锁步(lock step)执行。这样的好处就是一个Warp只需要一个套控制单元对指令进行解码和执行就可以了,芯片可以做的更小更快。Mali早期的GPU并不是基于warp的,性能表现不佳。因为每个核心都有控制单元,占用了过多的晶体管,产生了很多的overhead,进而导致功耗的增加。
  • Warp Scheduler会将数据存入寄存器,然后将着色器代码存入指令缓存,并要求指令分派单元(DispatchUnit)从指令缓存中读取指令分派给计算核心(ALU)执行。
  • Warp中的所有线程执行的是相同的指令,如果遇到分支,那么就可能会出现线程不激活执行的情况(例如当前的指令是true的分支,但是当前线程数据的条件是false),此时线程会被遮掩(masked out),其执行结果会被丢弃掉。shader中的分支会显著增加时间消耗,在一个warp中的分支除非32个线程都走到if或者else里面,否则相当于所有的分支都走了一遍。
  • 线程不能独立执行指令而是以warp为单位,而这些warp之间才是独立的。warp是GPU执行的最小单位。如果一个shader对应的像素数量填不满32个线程,它也会占用一个warp来执行。这种是明显warp利用率低的情况,部分核心在工作,部分核心在陪跑。
  • 一个Warp中的像素线程可以来自不同的图元。只不过其shader指令是一致的。
  • Warp中的线程数量和SM中的CUDA core数量并不一定是一致的。完全可以Warp为32,但是CUDA core只有16个。这种情况下,每个core,两个cycle完成一个warp的计算就行了。PowerVR就是类似的设计。

2.7.3 Stall 和 Latency Hiding (延迟隐藏)

  • 指令从开始到结束消耗的clock cycle称为指令的latency。延迟通常是由对主存的访问产生的。比如纹理采样、读取顶点数据、读取Varying等。像纹理采样,如果cache missing的情况下可能需要消耗几百个时钟周期。
  • CPU通过分支预测、乱序执行、大容量缓存等技术来隐藏延迟。而GPU则是通过warp切换来隐藏延迟。
  • 对CPU而言,上线文切换是一个有明显开销的行为。所以CPU是尽可能避免频繁的线程切换的。而GPU在Warp之间切换几乎是无开销的,所以当一个Warp stall了,GPU就切换到下一个Warp。等之前的Warp获得需要的数据了,再切换回来继续执行。
  • 之所以能够实现这个机制,得益于GPU的大寄存器设计。GPU中的寄存器数量远超CPU。比如前文提到的费米架构的GPU,每个SM的寄存器是3万个。而每个线程能够使用的最大寄存器数量限制在255。所以即便每个线程都占满寄存器,也只消耗了总寄存器数量的四分之一。
  • 每个SM会被同时分配多个warp来执行,warp一旦进入SM内就不会再离开,直到执行完毕。每个线程会在一开始就分配好所需的寄存器和Local Memory。当一个Warp产生了Stall,GPU的Core会直接切换到另外的warp来执行。因为不需要保存和恢复寄存器状态,所以这个切换几乎没有成本,可以在一个cycle内完成。
  • SM中warp调度器每个cycle会挑选Active warp送去执行,一个被选中的warp称为Selected warp。没被选中,但是已经做好准备被执行的称为Eligible warp,没准备好要执行的称为Stalled warp。warp适合执行需要满足两个条件:32个CUDA core有空以及所有当前指令的参数都准备就绪。
  • 如果Shader里面使用的变量越多(Shader写的很长),占用的寄存器数量就越多,留给Warp切换的寄存器就会变少。分配给SM的Warp数量就减少了,也就是Active warp降低了。这会降低GPU隐藏延迟的能力,会降低GPU的利用率。
  • 关于GPU的执行过程,知乎上洛城的这篇回答非常有趣,生动形象的展示了GPU的硬件构成和常见概念。如果对其还不了解的同学,强烈推荐阅读。

2.7.4 Warp Divergence

  • 由于Warp是锁步执行的,Warp中的32个线程执行的是同样的指令。当我们的shader中有if-else的时候,如果Warp内有的线程需要走if分支,有的线程需要走else分支,就会出现Warp divergence。GPU对此的处理方式是,两个分支都走一遍,通过Mask遮蔽掉不要的结果。
  • 如果Warp内所有线程都走的是分支的一侧,则没有太大影响。所以动态分支就相当于两条分支都走一遍,对性能影响较大,而静态分支则还好。当然,实际情况可能还会更加复杂一些,后面会再详细讨论。

2.8 其他重要概念

2.8.1 Pixel quad

  • 光栅化阶段,栅格离散化的粒度虽然最终是像素级,但是离散化模块输出的单位却不是单个像素,而是Pixel Quad(2×2像素)。其中原因可能是单个像素无法计算ddx、ddy,从而在PS当中判断选用贴图的mipmap层级会发生困难。进行EarlyZ判定的最小单位也是Pixel Quad。
  • 如上图所示,可以看出像素点网格被划分成了2X2的组,这样的组就是 Quad。一个三角形,即使只覆盖了一个Quad中的一个像素,整个Quad中的四个像素都需要执行像素着色器。Quad中未被覆盖的像素被称为”辅助像素”。比较小的三角形在渲染时,辅助像素的比例会更高,从而造成性能浪费。
  • 请注意,辅助像素其实依然在管线内参与整个PS计算,只不过计算结果被丢弃而已。而又因为GPU和内存之间有cache line的存在,cache line一次交换的数据大小是固定的,所以这些被丢弃的像素很多时候也不会节省带宽。他们会原样读入原样写出,带宽消耗还是那么多。所以,尽可能避免大量小图元的绘制,可以更有效的利用Warp。

2.8.2 EarlyZS

  • Depth test和Stencil test是一个硬件单元(ROP中的硬件单元)。Early depth test的阶段同样是可以做Early stencil test的。所以很多文档会描述这个阶段为Early ZS。Early-Z技术可以将很多无效的像素提前剔除,避免它们进入耗时严重的像素着色器。Early-Z剔除的最小单位不是1像素,而是像素块(Pixel Quad)。
  • 传统的渲染管线中,depth test在像素着色器之后进行。进行深度测试,发现自己被遮挡了,然后丢弃掉。这显然会出现大量的无用计算,因为overdraw是不可避免的。因此现代GPU中运用了Early-Z的技术,在像素着色器执行之前,先进行一次深度测试,如果深度测试失败,就不必进行像素着色器的计算了,因此在性能上会有很大的提升。
  • AlphaTest会影响EarlyZ的执行。一方面是自身不能执行EarlyZ write操作,因为只有当像素着色器执行完毕之后才知道自己要不要丢弃,如果提前写入深度会有错误的执行结果。另外一方面只有当自己执行完像素着色器,写入深度之后,相同位置的后续片元才能继续执行,否则就必须阻塞等待其返回结果,这会阻塞管线。关于这一点后面还会再做详细分析。
  • 其他如在像素着色器里面修改深度,或者使用Alpha to coverage等,也会影响EarlyZ的执行。

2.8.3 Hierarchical-z和Tile-based Rasteration

这两个是硬件提供的优化。

  • Hierarchical Z-culling,也称为Z-cull。是NVIDIA硬件支持的粗粒度的裁剪方案。有点像Adreno的LRZ技术,通过低分辨率的Z-buffer来做剔除。不过它只精确到8×8的像素块,而非像LRZ一样可以精确到Quad(2×2)。移动平台GPU有其他技术做裁剪剔除,所以猜想是没有使用这个技术的。另外,不要把它和EarlyZ弄混,也不要把它和我们引擎实现的Hi-Z GPU Occlusion Culling弄混。
  • Tile-based Rasteration技术。光栅化也是可以Tile-based,这同样是硬件厂商的优化技术。光栅化阶段通常不会成为性能瓶颈。不过游戏性能优化杂谈中介绍了一个有趣的案例,原神中对树叶使用Stencil,期望通过抠图实现半透明效果来提高性能。但是却因为影响了Tile-based Rasteration的优化,反而导致性能下降。在PC平台原本会有一些优化习惯,比如通过discard或者其他手段剔除掉像素,避免其进入到像素着色器(减少计算)或者ROP(减少访问主存)阶段,以此来提高性能。不过这些习惯在移动平台通常都是负优化。
  • 大量小三角形绘制是GPU非常不擅长的工作情景。GPU对顶点着色器和光栅化的优化手段有限,又因为光栅化的输出是PixelQuad,那么大量像素级的小三角形就必然会导致warp中的有效像素大大减少。所以UE5的Nanite会使用ComputeShader自己实现软光栅,来替代硬件光栅化处理这些像素级的小三角形,以此获得几倍的性能提升。相关细节可以参考UE5渲染技术简介这篇文章。

2.8.4 Register Spilling和Active Warp

  • GPU核心的寄存器虽然很多,但是数量还是有限的。GPU核心执行一个Warp的时候,会在一开始就把寄存器分配给每条线程。如果Shader占用的寄存器过多,那么能够分配到GPU核心来执行的Warp就更少。也就是Active Warp降低。这会降低GPU隐藏延迟的能力,进而影响GPU的性能。比如,原本在一个Warp加载纹理产生Stall的时候,会切换到下一个Warp,如果Active Warp过少,就可能所有Warp都在等待纹理加载,那么此时GPU核心就真的产生Stall了,只能空置等待结果返回。
  • 寄存器文件会用多少,在shader编译完就确定了。每个变量、临时变量、部分符合条件的uniform变量,都会占用寄存器文件。如果Shader使用的寄存器文件过多,比如超过64或者128,会产生更加严重的性能问题,就是Register Spilling。GPU会将寄存器文件存储到Local Memory上,之前我们介绍过,LocalMemory就是主存的一块儿区域,访问速度是很慢的,所以Register Spilling会大大降低Shader的执行性能。
  • Shader占用的寄存器文件多少,指令数多少,是否发生Spilling,都可以使用Mali offline compiler查看。

2.8.5 Mipmap

  • 我们传递给GPU一个带Mipmap的纹理,GPU会在运行时通过(ddx, ddy)偏导选取合适的Mipmap Level的纹理。
  • Mipmap有利于节省带宽,并不是说我们传递给GPU的纹理数据变小的(相反是增加了)。而是最终渲染的时候相邻的像素更有可能在一个CacheLine里面,这就提高了Texture cache的命中率。因为减少了对主存的交互,所以减少带宽。
  • 前面我们介绍GPU内存的时候有提到,当需要访问主存的时候,需要消耗几百个时钟周期。这会产生严重的Stall。提升Texture Cache命中率就可以减少这种情况的出现。我们通过一些GPU性能分析工具优化游戏性能的时候,Texture L1/L2 Cache Missing是一个非常重要的指标,通常要控制在一个很低的数值才是合理的。
  • Mipmap本身是会多消耗1/3的内存的(多了低级别的mipmap图),不过我们是可以决定纹理Upload给GPU的最高mipmap level。我们通过引擎动态控制纹理的最高mipmap level,反而可以有效的控制纹理的内存用量,这就是Unity引擎的Texture Streaming机制。基于Texture Streaming,纹理的内存总量是固定的,把不重要的纹理换出成高level的mipmap就可以减少纹理的内存占用。当然如果重新切换到mipmap0,可能会有纹理加载的过程,不过这个是引擎内部实现的,上层开发者是无感知的。我们看到很多3D游戏图片会有从模糊到清晰的过程,有可能就是Texture Streaming在起作用。
  • 关于纹理的内存占用这里可以再做补充说明。前面介绍移动平台GPU内存的时候我们有提到,虽然CPU和GPU是共用一块儿物理内存,但是其内存空间是分离的。所以纹理提交给GPU是需要Upload的。当纹理Upload给GPU之后,CPU端的纹理内存就会被释放掉。这种情况下,我们将显存中的纹理的内存释放掉,也就相当于释放掉纹理内存。
  • 在Unity中,还有一部分纹理是需要CPU端读写数据的,或者编辑器下某个纹理导入选项中勾选了Read/Write Enabled。对这些纹理而言,CPU端的内存就不会被释放掉。此时该纹理就占用了两份内存,CPU一份,GPU一份。

2.8.6 纹理采样和纹理过滤

  • 纹理过滤有几种模式:
    • 临近点采样 Nearest Point Sampling
    • 双线性插值 Bilinear Interpolation
    • 三线性插值 Trilinear Interpolation
    • 各向异性过滤 Anisotropic Filter
  • 现代GPU都支持一个cycle内完成一个Bilinear的采样。从性能上说,Point Sampling和Bilinear Filtering是一样的。较新的高端GPU,如Mali-G78可以在0.25个cycle完成一个Bilinear的采样。也就是说它可以在一个cycle内完成一个Quad的采样。
  • Trilinear Filtering需要采样两层mipmap做插值,所以消耗是Bilinear的两倍,也就是两个cycle一个采样。
  • N倍各向异性就是N倍开销。

2.9 从硬件角度理解GPU的执行逻辑 2.9.1 GPU中的可编程元件和固定管线元件

  • 顶点和像素处理是可编程,在Shader Core中执行着色器指令。
  • 光栅化是不可编程的,由光栅化引擎负责。
  • EarlyZ、LateZ、Blend,是固定管线,由ROP单元负责。
  • 固定管线的单元负责特定工作,硬件制作更加简单,性能更好,功耗更低。

2.9.2 从硬件角度看EarlyZ

  • 我们在游戏引擎级别看,每个drawcall有它对应的RenderState,以此来决定是否是AlphaBlend、是否要写深度、是否是AlphaTest等等。但是对于硬件而言,每个图元并不知道自己是不是AlphaBlend。当前RenderState是AlphaBlend的话,那么图元就按照AlphaBlend绘制。当前的ZWrite是Off的,那么LateZ就不写深度。
  • 执行EarlyZ的是硬件单元(ROP),所以不应该用代码的思维去理解EarlyZ的执行过程,更恰当的比喻应该是流水线上的阀门,它可以控制片元是否通过。有一些我们用软件实现起来显而易见的算法,在硬件上却是非常昂贵,难以实现的方案。

2.9.3 GPU核心的乱序执行和保序

  • GPU的计算核心是乱序执行的,不同Warp执行耗时不一致。受分支、cache missing等因素影响。GPU会尽可能填充任务到计算核心。
  • 但是同一个像素的写入顺序是可以得到保证的。先执行的drawcall的像素一定是先写入到Framebuffer中的。不同像素的写入顺序通常也是有序的。
  • GPU在每个阶段的输出结果其实都是有序的。不同阶段之间,通过FIFO队列,保证顺序。随着技术的发展,可能使用的技术不限于FIFO,但是最终目的都是保序。
  • 这个机制是有现实意义的。对于半透明物体,如果ROP是乱序的,那么得到的是错误的结果。而对于不透明物体,虽然有Depth Test的机制,乱序也可以保证结果正确,但是保序对性能有好处,且可以缓解Z-fighting。

三、移动平台GPU架构

3.1 PowerVR架构

3.1.1 PowerVR GPU管线

  • A10之前(iPhone7),都是Imagination PowerVR的GPU,GPU架构可以参考Imagination的文档。A11 (iPhone8/iPhoneXR)开始使用的是苹果自研GPU,苹果应该是得到了Imagination的授权,所以HSR等特性依然是保留的。苹果自研的GPU相关资料较少,暂时理解为是PowerVR的增强衍生版本。
  • TBDR的第一步Tiling的结果存放在Parameter Buffer中。Parameter Buffer是System memory上的一块儿数据区。它的大小是有限的。所以可能会出现PB已经被填充满,但是还有drawcall未执行的情况。当这种情况出现的时候,硬件会进行Flush,以便后续的渲染能够继续执行下去。这带来的问题是Flush前和Flush后的对象是两次HSR处理,即便存在遮挡也无法合理的进行剔除,导致overdraw增加。当PB填充满的时候,可能会导致性能急剧下降。所以应该简化场景,避免出现这种情况。
  • GPU通过ISP单元进行HSR (Hidden Surface Removal,隐面剔除)处理。ISP同样会处理深度回读(Visibility feedback)的情况。HSR是EarlyZ的完全替代品。可以像素级的剔除被遮挡的片元。HSR处理结果存放在TagBuffer中,TagBuffer在片上缓存里,通过TagBuffer就可以得到最终需要绘制的片元。只有最终对屏幕产生贡献的像素才会被绘制。
  • 关于AlphaTest对HSR的影响可以参考后面专门对AlphaTest的讨论。简单说就是AlphaTest不会影响自身的剔除判定,但是会卡管线。它会打断ISP处理覆盖同一像素的几何体。ISP要得到PS执行后的结果才能正确进行HSR,在这一过程中,所有覆盖了带有discard操作像素的几何体全部都要等待。

3.1.2 PowerVR GPU硬件架构

  • PowerVR Rouge架构的GPU包含了N个Unified Shading Cluster,这个USC就是GPU的核心。每个USC包含16条Pipeline。每个Pipeline包含N个ALU。ALU就是真正执行指令的地方。ALU的数目是GPU性能的重要指标。
  • 从上图我们可以看到PowerVR是两个USC共享一个Texture Unit。
  • 图中的MCU就是L2缓存。每个USC和TU都配备独立的L1缓存。每个USC中还都有一块儿Tile Memory也就是我们之前说的On-chip memory。
  • Rouge架构中,每个Pipeline包含4个FP16的ALU,2个FP32的ALU和1个SFU。可以看到FP16和FP32的ALU是分离的。虽然这会占用更多的芯片面积,但是可以大幅减少功耗。FP16速度更快,占用带宽更小,功耗更小。
  • PowerVR是Scalar ALU。支持超标量(Super Scalar),可以同时发射多条指令,让空闲的ALU来执行。所以一个Pipeline内部的多个ALU是可以被充分利用起来的。
  • 每个Pipeline内多个ALU的设计跟其他GPU也有些不一样。比如 NVIDIA的CUDA Core就只有两个 ALU,一个 FP32 ALU 和一个INT ALU。所以虽然PowerVR GPU的核心(USC)数量虽然不多,但是ALU数量反而会比同档次GPU要多。
  • PowerVR的Warp是32大小。但是它的Pipeline是16。所以它是两个cycle处理一个Warp。

3.2 Mali架构

3.2.1 Mali GPU管线

  • Mali GPU中有两条并行的管线,Non-Fragment(处理Vertex Shader、Compute Shader)和Fragment(处理Fragment Shader)。
  • 上面的图中可以确认,FPK (Mali的隐面剔除) 是在EarlyZ之后的。
  • Execution Core中,有WarpManager负责Warp的调度。指令执行单元有FMA(fused multiply-accumulate,混合乘加,基础浮点计算)、CVT(convert,类型转换)、SFU(special functions unit,特殊函数计算)三个元件。在Valhall架构下支持SuperScalar,这三个元件是可以并行执行指令的。

3.2.2 Mali GPU四代架构演变

Mali GPU的架构演变非常直观的展示了移动GPU的进化过程。再加上Mali的开发资料比较多,所以这里分别介绍了Mali的四代架构。这里可以和上文介绍的GPU管线和硬件架构的理论形成参考和对照。

Utgard (2007)

  • 这是Mali的第一代架构。对应 Mali-4xx 系列的GPU。
  • 这代GPU并非Unified shader core,Vertex和Pixel使用不同的计算单元。在市面上几乎看不到了。

Midgard (2012)

  • Midgard是Mali的第二代GPU架构,见于Mali-T8xx, Mali-T7xx和Mali-T6xx。市面上并不多见了,可能在智能电视芯片中还可见到。
  • Midgard的Shader核心已经是Unified shader core。指令执行单元叫做Tripipe,内部包含三个单元:
    • ALU(s) — Arithmetic Pipeline,执行指令的地方。可能有2~3个。
    • Texture Unit,配有L1缓存
    • Load/Store Unit,配有L1缓存
  • Midgard是Vector processor,通过SIMD实现并行计算的。此时并没有Warp机制。使用128-bit wide的ALU进行计算。可以混合处理不同类型的数据,比如4个FP32,8个FP16或者16个INT8。
  • 网上可能会见到的在shader中做vector处理合并数据来提高性能,对应的就是Madgard这种Vector处理器。这种优化措施对后面的Scalar处理器已经不再适用。
  • Midgard的Shader Core是以指令级并行(ILP,Instruction Level Parallelism)为主的设计,采用的是超长指令字(VLIW)指令格式。为了最大程度地利用Midgard的Shader Core,需要提取尽可能多的指令(4条FP32并发指令),以便填充Shader Core中的所有槽。这种设计非常适合基本的图形渲染工作,因为4种颜色分量RGBA非常适合VLIW-4设计的4条通道。
  • 随着移动GPU技术的发展,解决方案逐渐向标量处理转移,即以线程级并行(TLP,Thread Level Parallelism)为中心的体系结构设计。这也正是其下一代Bifrost架构的发展方向。指令向量化不一定能够完美执行,可能有的标量无法向量化,导致时钟周期的浪费。新的设计不会从单个线程中提取4条指令,而是将4个线程组合在一起并从每个线程中执行一条指令。以TLP为中心设计的优势在于它无需花费大量精力即可从线程中提取指令级并行性。同时对编译器也更加友好,编译器可以实现的更加简单。

Bifrost (2016)

  • Bifrost是Mali的第三代架构GPU,见于Mali-G71、G72、G76和Mali-G5x。
  • Mali的着色器核心数量是可变的。从上面的die shot可以看到,Mali-G76MP10包含10个Shader core,MP代表了核心数量。不同核心数量性能差异非常大。所以同样是Mali-G72架构,Mali-G72MP12能跑标准画质,而Mali-G72MP3就只能跑流畅画质了,其性能甚至还不如 Mali-G71MP8。
  • Bifrost每Shader core包含3个Execution Engine(指令执行单元),中低端的Mali-G5x可能每个Shader core只包含2个EE。
  • Bifrost的执行核心不再是Tripipe结构。Bifrost把TextureUnit和L/S Unit从Execute Engine中拆分开了。变成Shader core中的独立单元。这样可以避免负载不均衡导致TU的能力被浪费,同时也更容易扩展ALU,增强GPU的计算能力。
  • 从这一代开始,Mali GPU从Vector处理器转变为Scalar处理器。对应的也加入了Warp机制。一个Warp是4个线程,Mali称其为Quad。相比于NVIDIA或者PowerVR的32线程Warp,Bifrost的Warp要小很多。Warp小,那么出现上文介绍的Warp Divergence的时候就可以避免浪费,也就是说if-else分支对其影响较小。不过Warp小,意味着需要更多的控制单元,比如32线程的Warp只需要1个控制单元,而到Mali这边就需要8个控制单元。控制单元过多,会占用更多的晶体管和芯片面积,限制了ALU的数量。同时也意味着更多的功耗。
  • Mali-G76在此架构基础上增加了一条通路,每个EE可以同时处理8条线程,也就是说Warp大小扩展为8了。架构没有改变,把Warp提高一倍,就带来了大幅的性能提升。可见之前4-wide warp的设计并不是正确的选择。
  • Mali GPU的ALU是不区分FP32和FP16的。像Midgard一样,GPU会做指令的分解和融合。其ALU每个时钟周期可以处理1个INT32、2个INT16或4个INT8。像PowerVR,FP32和FP16 ALU是独立的,这也是在空间和功率效率之间进行权衡的选择。独立ALU会占用更多的芯片面积,但是会减少功耗。从结果上看,PowerVR的设计更加合理,后面会介绍的Mali-G78重写的FMA(Fused-Multiply-Add)也修改为FP32和FP16独立ALU的设计了。

Valhall (2019)

  • 这是Mali最新的GPU架构,Mali-G77、G78以及最新推出的G710都是这个架构。对应的中低端架构为G5XX。
  • 从这一代开始,不再是每个Shader core三个Execution Engine了。而是一个Execution Engine。不过EE改进为两个16-wide的结构。也就是说从Mali-G77开始Warp大小修改为16了。同时这代开始的GPU,都是Super Scalar设计,可以更好的利用ALU空闲单元,提升流水线性能。
  • 前面有提到,G78这一代,重写了FMA引擎,其ALU也变为FP32和FP16独立元件了。
  • 现在衡量GPU性能的一个重要指标是Floating-point Operations的能力。结合GPU核心的时钟频率就可以得到FLOPS(Floating-point Operations Per Second),也就是我们在跑分软件里面看到的GFLOPS。需要注意的是,FMA(Fused-Multiply-Add,a x b + c)或者叫MAD,也就是乘加,一次执行记做两个FLOP。
  • 下面列举不同架构,单核心每个时钟周期的FP32 operations数量(FP32 operations/clock)。
    • Mali-G72是 3 x 4 x 2 = 24
    • Mali-G76是 3 x 8 x 2 = 48
    • Mali-G77是 16 x 2 x 2 = 64
    • Mali-G710是 16 x 2 x 2 x 2 = 128
  • 可以看到,Mali-G77虽然只有一个EE,但是计算能力相比G72和G76却大幅提升。同时由于控制单元就更少,其控制单元的overhead就更少。执行相同的运算的功耗就更低。最新的Mali-G710,架构不变,EE扩展为两个,性能再次大幅提升。
  • 上述数据可以在这里 Arm Mali GPU datasheet 下载到。这个文档包含Mali每个GPU的详细数据,比如Warp大小,L1 cache大小等等。

3.2.3 Mali GPU其他技术 Forward Pixel Kill

  • FPK是Mali的隐面剔除技术。通过EarlyZ的片元会进入到一个FIFO的FPK队列。如果后面的片元发现前面的片元被遮挡住了,那么就可以将其终止掉。正常情况下一个片元通过EarlyZ判定之后,就产生了pixel thread,会提交给Shader core来执行,这个thread是无法终止的。不过Mali的FPK技术却可以终止掉不需要绘制的thread,从而避免overdraw。
  • 相比PowerVR的HSR,FPK更像是一个EarlyZ的硬件补丁,来弥补EarlyZ的不足。当EarlyZ失效的时候,FPK肯定也是失效的。FPK并不能保证被遮挡的不透明像素一定不会被绘制(比如队列中的Fragment已经被处理了)。所以Mali推荐还是使用排序的方式以充分利用EarlyZ进行剔除。这篇官方文档的优化建议中也提到了排序的问题。虽然有FPK,但是不要过分依赖它,该排序还是要排序的。

IDVS: Index-Driven Vertex Shading

  • Vertex shading被拆成两个部分,Position Shading和Varying Shading。计算完position shading就可以进行裁剪,只有通过裁剪的图元才会执行varying shading。这样就被裁掉的图元就不用fetch各种属性甚至纹理了。
  • 所以对于Mali GPU而言,把Mesh的position单独拆分一个stream可以有效节省带宽。其他GPU应该也有类似的技术。而对高通的Adreno而言,因为LRZ需要先跑一遍VertexShader中的Position部分,得到低分辨率深度图,所以对其而言拆分position可以获得更大的收益。

AFBC: Arm Frame Buffer Compression

AFBC是FrameBuffer的快速无损压缩。可以节省带宽,也可以降低显存占用。这个对开发者是无感知的。其他平台也都有类似的压缩技术。

Transaction Elimination

Transaction Elimination也是一种很有效的降低带宽的方法。在有些情况下,只有部分Tile中的内容会变化(例如摄像机不动,一个Tile中只有静态物体)。此时通过比较该Tile前一次和本次的渲染结果的CRC值,可得到Tile是否变化,如果不变,那么就没有必要执行Tile到System Memory的写回操作了,有效地降低了带宽占用。

Hierarchical Tiling

根据图元的大小选择合适的Hierarchy Level的Tile。降低Tiling阶段对主存的读取和写入开销。

Shared Memory

  • Mali GPU没有Shared Memory。所有对Shared Memory的操作其实都是寄存器或者主存。不过这个影响比较大的是OpenCL计算或者ComputeShader。普通的shader也操作不了Shared Memory。
  • Adreno GPU有Shared Memory。

3.3 Adreno架构

  • Adreno3xx, 4xx, 5xx, 6xx 是市面上常见的型号。都是Scalar架构。
    • 3xx在一些非常低配的手机上还可以见到。
    • 5xx一般见于中低配手机。这一代开始加入了LRZ技术。
    • 6xx是近几年新出的型号。630~660是高配,如骁龙888配备是Adreno660芯片。
  • Adreno一个非常显著的特点是它的核心数量很少,但是每个核心配备一个非常大的GMEM,这个GMEM是On-chip的,大小可以达到256k~1M。比如只有Adreno630只有2核,GMEM大小为1MB。
  • Adreno上的Bin(也就是Tile)并不是固定大小。而是根据GMEM大小和RenderTarget格式决定。其大小通常比Mali的16×16要大非常多。因此,如果渲染目标如果开启HDR+MSAA的话,Bin size会小很多,也就意味着更多的与主存的交互,明显增加功耗。

Flexable Render

  • Adreno GPU同时支持IMR和TBR两种模式,并且可以根据画面的复杂度,在两者之间动态切换,这就是Flexable Render技术。当然,在移动平台TBR依然是更为高效的方式。

Low Resolution Z

  • LRZ是Adreno5xx系列开始加入的隐面剔除技术。通过先跑一遍Vertex Shader的position部分,生成低精度的深度图,进行裁剪剔除。相当于硬件级别做了Hi-z。这个剔除是可以精确到Pixel Quad的。

3.4 总结

  • Mali的Warp是16,PowerVR是32。Adreno没有找到相关资料,大概率是32。
  • PowerVR、Adreno和Mali最新的Valhall都是Scalar架构,支持SuperScalar,可以更好的利用ALU并行计算。
  • 隐面剔除技术,PowerVR是HSR,Adreno是LRZ,Mali是FPK。
  • 把Mesh的position单独拆分一个stream,有利于节省带宽。
  • Shader中使用mediump,半精度浮点数,可以更好的利用FP16的ALU,性能更好。
  • Tile大小跟RenderTarget格式有关。Adreno的GMEM很大,Tile也要比Mali和PowerVR要大很多。

四、常见问题的分析与讨论

4.1 DrawCall对性能的影响

  • GPU工作在内核空间(Kernel Space),我们只能通过驱动与其打交道。所以我们应用层设置一个渲染命令或者给GPU传输数据,需要经过图形API和驱动的中转,才能最终到达GPU。而且驱动调用会有用户空间(User Space)到内核空间的转换。当DrawCall非常大的时候,这里的overhead就会很高。
  • 以DX为例,程序提交一个DrawCall,数据需要经过 App-&gtDX runtime-&gtUser mode driver-&gtdxgkrnl-&gtKernel mode driver-&gtGPU,然后才能到达GPU。在到达GPU之前,全部是在CPU上执行的。这也是新的DX12试图降低的开销。
  • 主机平台的硬件是固定的,可以对其硬件和驱动做专门的优化。没有了驱动的层层中转,CPU和GPU交互的开销是很低的。所以即便主机的硬件性能不如PC,但是实际游戏性能却远超PC。
  • 当然,单纯的DrawCall命令(比如DrawPrimitive)开销也不会很大。更大的开销在于DrawCall附带的绑定数据(buffer、texture、shader),设置渲染状态的开销。在RenderDoc可以看到一个DrawCall实际上可能会有十几条命令。
  • 在Unity中,绑定Vertex buffer记做一个Batch。CPU和GPU的交互模式,更加擅长一次传输大量数据,而不是多次传输少量数据。Unity的StaticBatch和DynamicBatch的目的就在于此。现在Unity都是以Batches数目代指DrawCall数目。
  • 材质切换记做一个SetPassCalls。材质切换会面临大量的属性同步、shader的编译和绑定、纹理绑定等等,无论在引擎层面还是GPU交互层面都是巨大开销。现代引擎通过排序,让相同渲染状态的物体连续绘制,目的就是减少这一部分的开销。
  • 如果绘制大量小物体,很有可能大量时间消耗在CPU和GPU的交互上,而实际GPU本身的负载并不高。所以我们通常认为DrawCall过高可能会导致CPU出现瓶颈

4.2 AlphaTest和AlphaBlend对性能的影响

  • 事实上,本文出现的初衷就是解答这个问题。因为要测试PreZ对性能的影响(PreZ就是针对AlphaTest和AlphaBlend的优化方案),连带着要测试下AlphaTest和AlphaBlend对不同平台的性能影响。看了网上的一些讨论和文章,结果变得更加困惑了。很多疑问无法解答,比如EarlyZ的实现机制,什么时候EarlyZ会失效等等。最终促使我花了很多的时间精力去学习GPU的硬件架构,这才有了这篇文章。

4.2.1 桌面平台

  • 桌面平台的IMR架构上,AlphaBlend操作的是DRAM(读+写),如果使用过多,会有明显的overdraw和带宽开销。相比起来,AlphaTest如果discard了,PS中不会有后续计算,也可以避免对FrameBuffer的写操作。如果绘制顺序合理(从前往后绘制),未被discard掉的部分也可以有效遮挡住后续部分,可以减轻overdraw。
  • 所以在桌面平台,很多时候会建议使用AlphaTest替代AlphaBlend,这可能会带来性能的提升。
  • 当然如果考虑到其对EarlyZ或者Hierarchical Z的影响(阻塞甚至失效,与具体硬件实现有关)。节省的带宽开销是否比上述优化带来的价值更大,是需要实际测试才能得出结论的。这可能会因GPU不同而产生不同的结果。

4.2.2 移动平台

  • 比较有参考价值的是下面两个知乎上的讨论
    • 再议移动平台的AlphaTest效率问题
    • 试说PowerVR家的TBDR。文中摘引是Alpha Test VS Alpha Blend这里的讨论,算是比较官方的回答。
    • Alpha tested primitives will do the following:ISP HSR: Depth and and stencil tests (no writes)Shading: Colours are calculated for fragments that pass the testsVisibility feedback to ISP: After the shader has executed, the GPU knows which fragments were discarded and which where kept. Visibility information is fed back to the ISP so depth and stencil writes can be performed for the fragments that passed the alpha testWhen discard is used, pixel visibility isn’t known until the corresponding fragment shader executes. Because of this, depth and stencil writes must be deferred until pixel visibility is known. This reduces performance as the pixel visibility information has to be fed back to the ISP unit after shader execution to determine which depth/stencil positions need to be written to. The cost of this can vary, but in the worse case the entire fragment processing pipeline will stall until the deferred depth/stencil writes complete.
  • 以PowerVR的HSR为例。不透明物体片元是在HSR检测通过就写入深度。而AlphaTest片元在ISP中做HSR检测的时候是不能写入深度的。因为只有像素着色器执行完毕之后它才知道自己会不会被丢弃,如果被丢弃则不能写入深度。而如果没有被丢弃,则会将深度信息回写到ISP的on-chip depth buffer中。在深度回写完毕之前,相同像素位置的后续片元都不能被处理。这就导致阻塞了管线的执行。
  • EarlyZ也是类似的问题。可能早期EarlyZ和LateZ是共用硬件单元,读和写必须是原子操作。AlphaTest导致不能进行EarlyZ write,也就不能进行EarlyZ test。所以早期一些文档会描述为AlphaTest导致EarlyZ失效,直到Flush。现代GPU不存在这个问题。AlphaTest物体不能做EarlyZ write,但是依然可以做EarlyZ test。当然因为深度回读导致卡管线,是不可避免的。
  • 单独一个AlphaTest和AlphaBlend比较,AlphaBlend可能会比较快。因为它不存在的深度回读的过程,也不会阻塞后续图元绘制。不过这个影响很有可能只在特定情况下才会比较明显。而更加常见的情况是多层半透明叠加的情况。此时AlphaBlend由于不写深度,完全无法做剔除,会导致overdraw很高,在移动平台上很容易出现性能问题。而AlphaTest虽然会因为写深度而阻塞管线,但是也因为会写深度,后续被遮挡的图元(无论是不透明还是半透明)是可以被剔除掉的。所以这种情况下AlphaTest可能性能会更好一些。而如果加入Prez,AlphaTest性能优势会更加明显。所以草地渲染使用PreZ + AlphaTest +(alpha to coverage)是比较合理的选择。通常会比使用AlphaBlend有更好的性能表现。
  • 不同的测试用例可能会得到不同的测试结果,而一般我们的测试用例很有可能是利好AlphaTest,所以会得出AlphaTest性能比AlphaBlend好的结论。当然,我们过于深究AlphaTest和AlphaBlend的性能差异并没有太大意义。因为多数情况下这两者效果不同,不能互相替换。下面做一些总结。
    • 无论是AlphaTest还是AlphaBlend,都不会影响其自身被不透明物体遮挡剔除。RenderPass中有AlphaTest物体,也不会导致后面不透明物体之间的遮挡剔除。
    • 对于比较小的特效,不要尝试用AlphaTest替代AlphaBlend,这很有可能是负优化,可能会阻塞管线。
    • 对于草地、树叶等穿插遮挡严重的情景,使用AlphaBlend性能很低,应该使用PreZ+AlphaTest。
    • Opaque–&gtAlphaTest–&gtTransparent 是合理的渲染顺序,打乱这个顺序可能会造成明显性能问题。

4.3 不透明物体是否需要排序

  • 按上面的介绍。Opaque–&gtAlphaTest–&gtTransparent 是合理的绘制顺序。Opaque和AlphaTest都是不透明物体队列,Transparent是半透明物体队列。
  • 尤其要注意,AlphaTest物体不能频繁的和Opaque物体穿插绘制(指的是渲染顺序上,而不是物体坐标上),否则会严重阻塞渲染管线。半透明物体不能提到不透明物体队列里面,即半透明物体不能穿插到Opaque物体绘制,同样会导致严重的性能问题,比如写深度的半透明物体如果在不透明物体之前绘制,会导致LRZ整体失效。
  • 对半透明物体而言,因为要进行混合,所以需要从远到近来绘制(画家算法),否则会得到错误的绘制结果。
  • 对不透明物体而言,在没有隐面剔除功能的芯片上(Adreno3xx),需要保证物体是从近到远进行绘制,可以更好的利用EarlyZ优化,也就是说需要进行排序。而有隐面剔除功能的芯片上(PowerVR、Areno5xx、Mali大部分芯片),不关心物体的绘制顺序,不需要排序,不透明物体不会有overdraw。
  • 前文介绍Mali的FPK的时候有提到,FPK并不能像HSR或者LRZ一样,对屏幕无贡献的像素肯定会被剔除。FPK可能存在没有即时kill掉的情况。所以对于Mali芯片,推荐还是在引擎层做排序。Unity引擎中判定是否需要排序的代码:

bool hasAdrenoHSR = caps-&gtgles.isAdrenoGpu && !isAdreno2 && !isAdreno3 && !isAdreno4caps-&gthasHiddenSurfaceRemovalGPU = caps-&gtgles.isPvrGpu || hasAdrenoHSR

  • 这里还需要注意,所谓排序,对半透明物体而言,就是根据物体与相机的距离排序的。这是为了得到正确的渲染结果。当然即便基于物体排序,也还是会有半透明物体渲染顺序错误导致冲突的情况,比如较大物体互相穿插,或者物体自身部件之间互相穿插等等。
  • 而对于不透明物体,则是分区块排序。在一个区块儿内部,物体绘制顺序跟与相机的距离无关。这么做主要因为严格按照距离排序,不利于合批,合批需要优先考虑材质、模型是否一致,而不是与相机距离的远近。

4.4 PreZ pass/Depth prepass是否有必要

  • PreZ pass就是预先使用非常简单的shader(开启ZWrite,关闭颜色写入)画一遍场景,得到最终的Depth Buffer。然后再使用正常Shader(关闭ZWrite,ZTest修改为EQUAL,不执行clip),来进行绘制。这样只有最终显示在最上面的像素会绘制出来,其他像素都会因EarlyZ被剔除掉。
  • PreZ的好处是降低overdraw。坏处是多画了一遍场景(虽然使用的是最简单的shader),DrawCall翻倍,顶点翻倍(顶点着色器执行两遍)。由于通常我们游戏的瓶颈都在于像素着色器,所以大多数情况下PreZ都是有优化效果的。
  • PC平台使用PreZ pass或许是个很好的选择。一方面因为它没有移动平台的各种隐面剔除技术,另外基于IMR渲染,对DrawCall和顶点数量不那么敏感。所以PC平台使用PreZ pass是很好的降低overdraw的手段。
  • 移动平台则恰好相反。移动平台GPU都有各种隐面剔除技术,不透明物体本身就不存在overdraw。而TBR架构对DrawCall和顶点数量异常敏感,大量顶点会导致更多的主存访问,甚至会出现主存ParameterBuffer放不下,产生Flush的情况。所以移动平台不需要PreZ pass
  • 如果实际测试发现,游戏的性能瓶颈在于DrawCall和顶点着色器,那么就不要使用PreZ,这会进一步增大顶点压力。
  • 回到我们序言中提到的问题,我们是否有必要对草地这样的物体使用PreZ呢?答案是有必要。草地是AlphaTest,且有大量的穿插,不可避免的会有大量的overdraw。通过PreZ可以很好的降低草地的overdraw。同时,由于使用PreZ最后绘制草地的时候是不写深度的,也没有clip,那么就可以当做不透明物体来绘制,不会像普通AlphaTest一样影响渲染管线的执行。
  • 后面跟联发科工程师团队进一步沟通了解到,他们建议去掉PreZ pass的原因是在测试我们游戏的过程中发现,在特定场景下vs很容易出现瓶颈,而ps反而留有余地。我们上面提到,PreZ会增加顶点数量,更加容易出现vs的瓶颈。当然,实际游戏运行过程会非常复杂,可能稍微换个视角或者换个手机就又是ps瓶颈了。如何取舍还是要以真机测试结果为准。
  • 网上有一些文章提到,某团队使用AlphaTest替代AlphaBlend绘制草地,又或者某2D游戏,使用AlphaTest绘制角色,都获得了性能的大幅提升。其原因就在于我们上面所分析的,当游戏的overdraw很重,或者ps是瓶颈的时候,使用AlphaTest可以利用EarlyZ做剔除,提升性能。当然,这里要再次重申,一定要确保不透明、AlphaTest、半透明,这样的绘制顺序,如果AlphaTest或者半透明物体穿插到不透明物体之间绘制的话,会严重影响性能。

4.5 Shader中的分支对性能的影响

4.5.1 分支对性能的影响

  • 同一个warp内执行的是相同的指令,当出现分支(if-else)的时候,如果所有线程都走分支的一侧,则分支对影响很小。但是如果有些线程走if分支,有些线程走else分支。那么GPU的处理方式是,两条分支都走一遍,然后通过执行掩码(execution mask)丢弃不要的执行结果。这就带来了很多无意义的开销。这种情况就是我们前面介绍的Warp Divergence
  • 常量做分支条件,编译器会做优化,几乎不会影响性能。
  • uniform做判定条件,多数时候可以保证不会出现Warp Divergence,对性能也不会有太大影响。注意,并不能将不会有太大影响当做没有影响。使用分支的性能隐患有很多,下文还会详细说明。
  • 动态分支,如使用纹理采样的值做判断条件,大概率会产生Warp Divergence,会严重影响性能,尽可能避免。

4.5.2 编译器对shader的优化

  • Unity会使用glsl optimizer对shader做优化。多数时候会明显提高shader的执行性能。不过对于if-else,它的默认处理方式是将分支展开,全部计算一遍,根据判断条件取其中一个结果。除非分支中的指令很复杂,或者有大量纹理采样,它才会保留分支代码。这个行为是可以通过branch和flatten关键字来控制的。Unity中的UNITY_BRANCH和UNITY_FLATTEN就对应这两个关键字。
    • branch,shader会根据判断语句只执行当前情况的代码。
    • flatten,shader会执行全部情况的分支代码,然后再根据判断条件获得结果。
    • unroll,for循环是展开的,直到循环条件终止。
    • loop,for循环不展开。

4.5.3 分支的性能隐患

  • 大量if-else会导致shader指令数比较多,占用的寄存器就更多。这会导致GPU的Active warp数量降低,降低GPU隐藏延迟的能力。也有可能因为寄存器占用过多,产生Register spilling(将寄存器写入主存)。
  • 还有可能因为shader指令比较多,导致编译后的bin文件比较大,不利于缓存。
  • 另外有的时候,我们有一些计算或者纹理采样是无意中写在分支之外的,而计算结果只有分支中才会用得上。这种情况如果是multi_compile,则编译器会自动做优化,精简掉不必要的代码。但是如果是if分支,则编译器不会优化这块儿的代码。这可能导致执行了比预期要多的指令或者进行了不必要的纹理采样。
  • 动态分支(包括uniform分支),可能会不利于驱动进行优化。具体驱动实现过于黑盒,而且随着驱动的迭代更新可能会不断改进。但是通常来说,如果我们通过分支来替换multi_compile,都会增加shader的执行开销。如果是常用shader,或者游戏的GPU已经跑的比较满了,则分支的副作用不可忽略,尤其是在低端机上。
  • 某些驱动(常见于低端机),可能会在驱动级别对分支做“优化”,如果分支指令较少,会强制展开。但是分支的另外一个隐性开销在于参数传递导致的带宽增加。即便分支指令很少,带宽的增加也可能会成为压死骆驼的最后一根稻草。
  • Adreno3xx、4xx部分驱动存在兼容性问题,使用half参数做判定条件执行if指令,永远无法判定为真。修改为float做判定条件即可。所以,兼容性测试也需要多加关注。

4.5.4 multi_compile的副作用

如果不使用if-else,那么另外一个选择就是multi_compile。遗憾的是,使用multi_compile同样会有明显的副作用。

  • 增加了keyword的数量,keyword本身是有限的。
  • 增加了变体的数量,不同的变体其实是不同的shader,这会导致SetPassCalls增加,影响运行时性能。
  • 变体增多会产生更多的内存占用。而且Shader实际的内存占用可能会比我们在UnityProfiler中看到的ShaderLab的内存占用更多。因为驱动会消耗两三倍的内存去管理ShaderProgram。相比增加大量变体,选择if-else,并使用const或者uniform作为其判定条件有的时候是更加理想的选择。

4.5.5 关于分支的建议

  • 尽量不要使用分支,如必须使用的话,优先选择常量的判定条件,其次选择uniform变量作为判定条件。
  • 最糟糕的情况是使用shader内部计算的值作为判定条件,尽可能避免。
  • 尽可能避免在常用shader,或者给低端机使用的shader中使用分支。
  • 最终确定要使用分支,请确保两条分支不存在大量重复代码。大量重复代码会导致shader占用寄存器文件明显增多,减少active warp数量,最终导致性能下降。
  • 如果分支指令较多,不要忘记添加branch关键字。
  • 选择分支还是multi_compile,一定要以实际游戏性能测试为准。尤其要留意其对低端机GPU占用率的影响。

4.6 Load/Store Action和Memoryless

4.6.1 Load/Store Action

  • 从SystemMemory拷贝数据到TileMemory是Load Action。
  • 从TileMemory拷贝数据到SystemMemory是Store Action。也称为Resolve。
  • OpenGLES中可以通过glInvalidateFramebuffer来规避上述Load和Store。
  • Metal中可以通过RenderPass的loadAction和storeAction的设置来控制Load/Store。
    • loadAction有三种,dontCare,load,clear。
    • storeAction有四种,dontCare,store,multisampleResolve,storeAndMultisampleResolve。
  • 比如后处理执行完毕之后深度就没用了,那么就可以设置depthTexture的RT的storeAction为dontCare。这样可以避免深度写回主存的带宽开销。
  • 在XCode中可以看到渲染的L/S bandwidth的开销。通过抓帧可以清楚的看到每个RenderPass的load/store action。这可以很方便的帮助我们优化渲染管线的性能。

4.6.2 Memoryless

  • 像Depth/Stencil buffer,只在Tile绘制中有用,不需要存到主存中。所以其storeAction可以声明为dontCare。这样可以节省带宽。
  • Metal下,这样不需要Resolve的RT,可以设置为Memoryless,这样可以降低显存开销。

4.6.3 Render Target切换

  • 上图可以看到,RenderTarget的切换是非常慢的。在我们游戏的渲染流程中,应该尽可能的避免频繁的RT切换。
  • 因为移动平台TBDR的特性,切换RT在移动平台上会有更大的开销。它会严重阻塞渲染流水线的执行。每次切换RT都需要等待前面的指令全部执行完毕,把数据写入主存。切换到新的RT后,还需要把数据从主存Load到TileMemory中。频繁的与主存交互不仅很慢,而且消耗大量带宽。所以类似后处理这样必须使用RT的,应当把多个Pass尽可能合并成一个Pass。
  • 当我们使用RenderTexture的时候,一定要慎重。一方面它消耗了过多的内存。另外方面RenderTexture的绘制更新不可避免的会有RT切换。如果过多使用,或者过于频繁的更新,会出现明显的性能问题,尤其是在低端机上。当确定要使用RenderTexture的时候,一定要严格控制其大小。
  • 一个我们常见的功能,将场景或者角色模型绘制到一个RenderTexture上,然后将这个RenderTexture绘制到UI上。这个过程其实就存在明显的性能隐患。另外,有一些UI框架会做优化,将静态不变的UI绘制到一个RenderTexture上来减少DrawCall。如果不能保证UI真的完全静止不动,在移动平台上这么做通常是负优化。原因同样在于内存消耗和RT切换开销。

4.6.4 避免CPU回读GPU数据

  • CPU回读GPU数据(比如glReadPixels)会严重阻碍CPU和GPU的并行。当CPU要读取FrameBuffer中的数据的时候,必须要保证GPU已经全部写入完毕。
  • 部分解决方法是,在下一帧的时候读取上一帧的数据。这样可以规避等待的开销,不过毕竟是两帧数据,所以结果可能会有偏差。

4.6.5 Pixel local storage

  • Mali和Adreno是提供了API来获取TileMemory中的数据。这样就可以高效的实现一些特殊效果,比如软粒子或者一些后处理效果。
  • iOS平台因为使用了Memoryless,framebuffer_fetch无法获取深度数据,但是可以通过一些其他手段,比如MRT(Multiple Render Targets),或者把深度写入到color的alpha通道来实现类似功能。

4.6.6 移动平台延迟渲染优化

  • 传统延迟渲染的GBuffer会带来大量的带宽开销,移动平台上同样可以利用OnChip Memory来实现基于移动平台的高性能延迟渲染。
  • 而原神貌似直接使用传统的延迟渲染方案,并没有针对移动端做性能优化,所以它只能在高配手机上才能跑得动。带宽的开销可见一斑。

4.7 MSAA对性能的影响

4.7.1 MSAA

  • 移动平台的MSAA可以在TileMemory上实现Multisampling,不会带来大量的访问主存的开销,也不会大幅增加显存占用。所以移动平台MSAA是比较高效的。通过指定FrameBuffer格式就可以开启MSAA,不需要通过后处理等方案来自己实现MSAA。
  • 但是这并不是说MSAA在移动平台就是免费的了。它依然是有一定开销的,所以也只能在高配手机开启MSAA。
    • 在Adreno上,GMEM大小是固定的(256k~1M),而Tile大小跟RenderTarget的格式有关。如果开了MSAA,Tile会对应缩小,这就导致产生更多的与主存的交互。开启HDR会有更大开销也在于此。
    • Mali和PowerVR由于TileMemory有限,打开HDR与MSAA需要更多空间来保存渲染结果,GPU只能够通过缩小Tile的尺寸来适应On-Chip Memory的固定大小。进行渲染的Tile数量会因此而增加。比如PowerVR原本Tile是32×32,如果开启MSAA可能就变为32×16或者16×16。下面的表格显示了Mali Bifrost GPU,bits/pixel和Tile大小的关系。
Family16×16 Tile16×8 Tile8×8 Tile
<= Bifrost Gen 1128 bpp256 bpp512 bpp
>= Bifrost Gen 2256 bpp512 bpp1024 bpp

4.7.2 Alpha to coverage

  • Alpha-to-coverage简单理解就是基于MSAA,使用AlphaTest来模拟AlphaBlend的效果。原本的AlphaTest不可避免的会有比较硬的边缘,通过Alpha-to-coverage,像素的透明度是由4个像素插值计算得来的,边缘就会柔和很多。
  • 使用Alpha-to-coverage的好处是,因为它的本质是AlphaTest也就是不透明物体,所以不会有渲染半透明物体那样容易出现深度错误的情况。
  • 使用Alpha-to-coverage也会影响EarlyZ的执行(无论是AlphaTest影响还是MSAA影响),在移动平台上性能不高,所以建议只在必要的地方使用。
  • 因为Alpha-to-coverage是基于MSAA的,所以不使用MSAA的时候,启用Alpha-to-coverage,其结果是无法预测的。不同的图形 API 和 GPU 对这种情况有不同的处理方式。

4.8 Shader的优化建议

  • MAD(乘加)是一条指令。将计算转换为 (a x b + c) 的形式,可以节省指令。
  • saturate, negation, abs是免费的。clamp, min, max不是。不要进行负优化。
  • sin, cos, log, sqrt, pow, atan, atan2 使用SFU进行计算,通常需要花费几个ALU甚至几十个ALU,尽可能避免。
  • CVT (类型转换,如half–&gtfloat,vec3–&gtvec4)并不一定是免费的,可能需要占用一个cycle。需要减少无意义的类型转换。
  • 先计算scalar,再计算vector,性能更好。通常编译器会做这个优化,但是编译器不一定能优化到极致。在保证代码清晰的前提下,尽可能编写高效代码是个良好习惯。
  • 优先使用半精度的浮点数(half),速度更快。 lowp和mediump是FP16,highp是FP32。一般顶点坐标需要FP32,其他如颜色都可以是FP16。
  • 不要单纯为了性能把标量合并为向量,尤其是以降低代码可读性为代价。这对现代GPU而言通常不会有优化效果,GPU执行的时候还是拆散为多个标量来执行。手工合并数据很容易因为代码混乱产生负优化,比如引入了隐式类型转换产生了更多的开销。
  • 是否可以使用分支,参考上面对于分支性能的分析。一个稳妥的建议是,要阅读编译后的代码,要在不同机型上在真实游戏环境下做测试。确保修改不是负优化。
  • 在PC平台,使用discard剔除像素或许有优化效果,可以避免ROP的开销。在移动平台则不要这么做,会影响HSR和EarlyZ,是负优化。
  • 减少寄存器的使用,避免Register Spilling(寄存器超过限制写入主存)。这里主要就是减少uniform变量、临时变量的数量(简单说就是尽可能精简shader代码)。查看编译后的代码可以比较清晰的看到这些。使用Mali offline compiler也可以定量化的获得寄存器数量和指令数据。

结语

  • 了解GPU硬件架构和运行机制对我们的性能优化工作有指导意义,可以帮助我们更快的分析出游戏的性能瓶颈。比如下图是SnapdragonProfiler中的Trace截图。如果用Mali的Streamline的话可以看到更加详细(复杂)的参数指标。如果对GPU不够了解的话,这些参数就毫无意义。
  • 本文覆盖的知识点很多,限于篇幅和本人的技术水平限制,很多东西无法讲的很透彻。在我看来,这篇文章更大的价值在于告诉读者有哪些知识需要去了解,了解这些知识有何意义,而不是把某个知识点讲的足够准确或者深入。

原文地址:https://it.sohu.com/a/553556920_100093134

Unity线性空间和伽马空间(转)

为什么我会想起来写这篇博客呢?缘起于项目中做PBR时,美术来问出贴图是要出线性空间贴图还是伽马空间贴图,经过一番纠结与查证后,项目终于切到了线性空间,而贴图则是能出线性空间优先出线性空间,出不了的出伽马空间也行。至于为什么也行,就是本篇博客所要讲述的内容。

首先要来解决第一个问题,什么是伽马空间,什么是线性空间?线性空间好理解,颜色按照线性渐变的空间即是线性空间,我在网上看到一个举例很有趣,想象一个纯黑墨水的池子,往里面滴一滴白色颜料,随着白色颜料不断的滴入,墨池会越来越白直至变成白色,而记录每次滴入颜料后墨池的颜色变化,即是一个从黑到白的颜色线性渐变过程。既然有线性渐变,那么肯定就有非线性渐变,而伽马空间正是这样一个颜色非线性渐变的空间。为什么会发生这样的事呢?

一个流传甚广的版本表示,这是由CRT显示器引起的。由于CRT显示器对于输入的电压和显示的亮度并不呈线性关系,而是一个类似幂律曲线的关系,一般来说,这个曲线的指数部分称作伽马值,为2.5。显示器有这样的特性后,一个正常颜色输入进去显示出来会变暗,我自己拉了个图来说明下

这是个

y=x^{2.5}

的幂律曲线,如果颜色输入值为0.5,那么输出值大概为0.176左右,更接近黑色。所以输入的颜色在输出时会变暗。为了解决这个问题,图片在被采集时会做一个逆向操作,如果颜色为0.5的话先逆向

0.5^{1/2.5} \approx 0.757

然后

0.757^{2.5}

就会回到0.5了。这里我们用到了两个伽马值,1/2.5和2.5,他们分别称为encoding gamma和display gamma,通过下图展示他们的用处。

encoding gamma通常在图片生成时(比如说拍照拍出来的照片,PS新建的图片等)就已经存在,而display gamma又是显示器自带的特性(当然现在的显示器不再是CRT,所以display gamma可能不是2.5,不过为了兼容性厂商还是会把以前的2.5伽马值加入),他俩相乘称作end-to-end gamma,如果是1的话那么真实场景被捕捉的亮度和显示的亮度是成比例的。然而,Real Time Rendering一书指出了乘积为1的问题。一是我们人眼看到的真实场景的亮度与显示器所能显示的亮度差了好几个数量级,说白了显示器所能显示的颜色精度根本达不到真实场景的精度;二是周围环境影响,我们的视野在看真实场景时是由真实场景所填充的,而在看显示器时视野除了被虚拟场景包围,还会被真实场景包围。这样两个差别导致了end-to-end gamma是1的话并不能保证显示的亮度和原始场景的亮度是一致的。书中推荐,电影院那种漆黑的环境为1.5,在明亮的室内为1.125。

我们通常用的sRGB标准的encoding gamma大概为0.45(1/2.2),这是为了配合2.5的display gamma,因为0.45 * 2.5 = 1.125。当然,显示器的display gamma大部分值还是设为2.2(这里有个网站,可以看在不同的伽马值下图片所表现的不同准确的伽玛 2.2 及预设 5 种伽玛值设定),这样1/2.2*2.2 = 1。

当然冯乐乐前辈还提出了来自其他领域对于伽马的解释,以此来论证伽马值存在的必然,不管如何,伽马空间中的颜色并非线性渐变,而是呈一条曲线变化。

那么我们在做PBR时,为什么要纠结到底用伽马空间还是线性空间呢?PBR全称为Physically Based Rendering,既然是基于物理的渲染,那么我们做渲染时对于贴图采样出来的值必定要是和真实环境下相同的值才行,而采用了伽马空间的话贴图中颜色会被encoding gamma所改变,shader中采样出来的颜色值和真实环境下的值是不一样的,这样怎么能称为基于物理的渲染呢?

我们以人的皮肤渲染来举例子,皮肤贴图的r通道的值通常会高于其余两个通道的值,那么在伽马校正后(即对原始值做一个伽马次方的操作),这种差异会被进一步的放大,再做光照计算,你会发现r通道的值提升的异常的高。

上图左边是线性正确值,右边是渲染时带着伽马值,那么提升光的亮度会迅速的曝光。

而当这种差异被拉大后,你会发现在眼皮轮廓的地方会出现蓝黑色的痕迹,如下图


所以在做PBR渲染时,使用线性空间是非常有必要的。

而我们纠结的点在于,把Unity引擎切到线性空间的话,之前所有美术的资源都是在伽马空间下制作的,会不会有问题?实际上是有问题的。

当我们把Unity从伽马空间切换到线性空间时,引擎里面我们需要勾选一个东西,这样伽马空间的资源也能使用了。

勾选了图中的sRGB后,其实引擎为我们做了一个工作,在采样这张图片的时候会调用OpenGL ES3.0里的sRGB Sampler接口,将贴图中被encoding gamma所改变的值还原,这样我们在shader中做的任何计算就是基于物体在真实场景中的颜色了。算完以后当我们要把颜色输出到显示器时,显示器因为自带display gamma,我们无法抹去这个东西,所以引擎又为我们做了一件事,调用OpenGL ES3.0里的sRGB Frame Buffer接口,将计算得出的最终结果用encoding gamma算好,用以抵消display gamma的影响。

那么在线性空间底下使用伽马空间资源会有什么问题呢?透明混合会出问题。我们知道透明混合的时候dst color在frame buffer中,而颜色在线性空间下进入了frame buffer引擎会调用sRGB Frame Buffer接口做一个pow0.45的操作,而透明混合时明显需要线性空间的颜色,因为src color还没进frame buffer,没做过pow4.5的操作。所以这里在把dst color从frame buffer拿出来时,会做一个pow2.2的操作回到线性空间,然后做透明混合,得出结果后再做一遍pow0.45。

假设src color的某个分量为1,alpha为0.5;dst color某个分量为0,那么根据正常的计算为:

res = src * alpha + dst * (1-alpha) = 0.5

而有了sRGB Sampler和sRGB Frame Buffer后:

res = (src ^{2.2} * alpha + dst ^ {2.2} * (1-alpha)) ^ {0.45} = 0.5 ^ {0.45} \approx 0.732

差异在此产生,并且随着混合次数的增多,差异会越来越大。

理论上最好的解决方案是美术直接在线性空间下制作资源,如官方说的在PhotoShop设置中选择“用灰度系数混合RGB颜色”,参数设置为1。

或者参考网上的解决方案【Unity补完计划】Unity线性空间(Linear)下Alpha的混合问题Unity手机线性空间下的透明混合(上),但他们都有额外的消耗,对于性能不是那么富裕的项目就显得无能为力了。

参考
Unite 2018 | 浅谈伽玛和线性颜色空间
聊聊Unity的Gamma校正以及线性工作流
【图形学】我理解的伽马校正(Gamma Correction)
Chapter 24. The Importance of Being Linear

原文链接:https://www.jianshu.com/p/964ffc5d3b9c