使用InternalsVisibleTo给assembly添加“友元assembly”[转]

C#的internal关键字可以使标记的方法,字段或者属性等等只能在当前assembly内部使用,那么如果其他的assembly需要使用这个internal的方法的时候怎么办呢?.NET提供了一种类似于C++中的友元类的方式来完成这个功能,那就是使用InternalsVisibleTo。

这种情况常见于做测试的时候,需要另外一个项目来测试项目中的internal方法所标记的功能,所以有了InternalsVisibleTo,我们就不用为了做单元测试而把一个本不该公开的方法改为public了.

使用InternalsVisibleTo还有一些需要注意的地方,特别是PublicKey不太容易弄的明白,下面先来说说这个InternalsVisibleTo该怎么使用:

先来说明一下前提:Project1是功能项目,Project1.Test (assembly name: Project1.Test.dll)是为做Project1的测试工程。

1. 打开Project1的Assembly.cs文件,在文件的末尾加上这样一句话:

[assembly: InternalsVisibleTo("Project1.Test, PublicKey=******")]

其中PublicKey=******应该替换成Project1.Test.dll的public key,至于如何获取PublicKey,请看文章末尾的Notes部分.

2. 确认namespace: System.Runtime.CompilerServices 添加到了Assembly.cs的namespace引用中,因为InternalsVisibleTo位于命名空间System.Runtime.CompilerService中。

Notes:

1. 如何获取PublicKey?

A: 在命令行下,使用sn -Tp Project1.Test.dll就可以看到PublicKey和PublicKeyToken

2. 如果Project1是个strong-named的项目,那么InternalsVisibleTo必须指定PublicKey,所以Project1.Test也必须使用强签名才能正确使用InternalsVisibleTo, 不然编译会出错,如果Project1没有使用强签名,那么Project1.Test也不必使用强签名,而且在使用InternalsVisibleTo的时候只需要程序集的名字就可以了,不需要设置PuklicKey。

C# 2 之 #pragma warning 警告禁用指令[转]

今天我们来看点不一样的。

Part 1 早期禁用编译器警告信息


还记得我们在学习语言配置的时候用到的 .csproj  格式的文件吗?这个文件专门对项目设置配置信息,比如 C# 的语言版本之类的。在这个文件里,我们还可以配置取消编译器警告的信息。

我们先来回忆一下编译器警告和编译器错误。编译器警告是一个提示信息,默认情况用的是绿色波浪线表示代码段,表达这段代码有一些运行期间无伤大雅,但不符合规范使用的信息。例如下面的代码:

可以从代码里看出,此时 hello 变量只定义了但没有使用,因此编译器会对这样的错误使用给出编译器警告。但是这样的错误不是致命错误,因为它只是定义了没使用,也不影响程序的正常执行。

编译器错误则是一个信息,表示代码具有严重错误或无法编译的致命错误信息。比如 C# 要求每一个语句要用分号结尾。但如果缺失分号,编译器会自动产生编译器错误信息。这样的代码是无法通过编译的。

编译器警告是不重要的错误,但多起来也挺烦人的。有些时候部分编译器警告是可以忽略掉的,因此满篇都是绿色的波浪线看着也挺不舒服的。因此我们可以对这些编译器警告信息予以忽略操作。让编译器不再对这些错误产生警告。

办法是这样的:我们打开项目的 .csproj 为后缀名的配置文件,它一般都长这样:

<PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    ...
</PropertyGroup>


我们在 标签里,在和 标签同级别的地方配上 标签,里面写 CS0219:

  <PropertyGroup>
      <TargetFramework>net6.0</TargetFramework>
      ...
  • CS0219

    这里 标签表示的是,整个项目需要禁用掉哪些警告信息,下次编译器就不会对这些信息报错了。里面写的是警告禁用的编号代码。我们回到最开始那个图片上,你可以看到,CS0219 后跟上了错误的信息内容和解释文字,而这个 CS0219,就是编译器对这些警告或错误信息给出的唯一编号。你只需要把对应的错误编号写到 里后,所有 CS0219 错误的信息,编译器就不再会给出了。

回到代码:

你会发现,缺失警告的波浪线消失了。这个就是配置和取消警告信息的过程。

但是可以发现,这样的配置有一个严重的弊端:它是对整个项目起效的。它无法对文件为单位取消警告信息,整个项目都取消的话,这个范围未免也有点太大了一些。所以,原生的 C# 并不支持文件为单位的警告信息取消。

另外,Visual Studio 定义了四种编译器报错的级别,分别是 Hidden(隐藏级别报错)、Info(信息级别报错)、Warning(警告级别报错)和 Error(错误级别报错)。而绿色波浪线的错误信息级别是 Warning 级别。而 Visual Studio 还定义了两种级别,一个叫 Hidden,一个叫 Info。Hidden 级别的编译器信息,不会用任何的标记来标记错误书写的代码段,唯一能发觉到它的存在的办法是,把你的代码光标(就是标识你书写代码到哪里的那个文字间的闪烁竖线条)放在这个所在行上,然后你会在代码所在行的行号旁看到一个灯泡图标,里面可以看到对应的信息;而 Info 级别会有标记错误代码段落的标记,不过它不是绿色也不是红色波浪线,而是灰色的四个小圆点,标识你的错误代码段开头的位置上。这样的标记很不容易引起注意,因为它比起编译器警告信息来说还要低一个级别,这类警告信息,你改不改正都无伤大雅。而比 Info 大一个级别的才是编译器警告(Warning 级别),最后才是编译器错误(Error 级别)。

Part 2 #pragma warning disable 指令


C# 2 为了解决这样的错误提示消除过程无法实现的问题,诞生了新的语句:#pragma warning disable 指令。

pragma warning disable 指令是一个组合单词的指令,也就是说,pragma、warning 和 disable 这三个单词都必须一起写出来,而且必须是先 pragma 后 warning,最后是 disable;另外,在 pragma 前面,还需要加上预处理指令的井号 # 标记。

我们把它当成是一个预处理指令一样的使用就行。不过这个预处理指令和 #region 还有 #endregion 类似,它可以用于任何代码行上。以前的 #if 只能放在文件开头,或者前面带有一些 using 指令什么的。但 #pragma warning disable 指令可以写在任何位置。唯一需要注意的是,它是预处理指令,因此必须单独占据一行;也就是说你不能对 #pragma warning disable 这三个单词的每两个单词中间插入换行,这是唯一需要注意的。

比如,给出一个简单的示例程序:

pragma warning disable IDE0059

pragma warning disable CS0219

pragma warning disable IDE0005

using System;

static class Program
{
static void Main()
{
string hello = “Hello!”;
}
}
其中:

IDE0059 信息:对一个变量的赋值没有意义;

CS0219 信息:变量定义了但没有使用过;

IDE0005 信息:using 指令没有使用过。

你可以使用这个语法来对它们的错误信息进行消除。当然,C# 也允许你写在一行里:

pragma warning disable IDE0059, CS0219, IDE0005

using System;

static class Program
{
static void Main()
{
string hello = “Hello!”;
}
}
用逗号分隔每一个编号即可。

Part 3 #pragma warning restore 指令


当然,#pragma warning 也不是必须跟 disable。C# 2 为了更为灵活地控制编译器警告报错的过程,该指令还有一个写法:#pragma warning restore。它表示,既报错禁用后,恢复对指定编号的报错。

pragma warning restore 的语法和 #pragma warning disable 的语法完全一样,即后面跟上的是错误编号(如果是多个错误编号的话,用逗号分开),但 #pragma warning restore 一般用在它的前面已经出现过 #pragma warning disable 的同编号错误警告的时候。举个例子,我现在有这样的代码:

它表示计算一个数字的阶乘。但是这个方法我们尚未使用过,因此我们会在方法的上方插入一个 #pragma warning disable 的警告禁用指令。不过,我如果在 Factorial 方法下面还有别的代码的话,这个警告禁用的范围就超出了我们预想的范围:我们只想要禁用掉 Factorial 这一个方法的使用性的报错,而别的方法不影响。

我们可以这么写:

pragma warning disable IDE0051

private static long Factorial(int n)

pragma warning restore IDE0051

{
long result = 1;
for (int i = 1; i <= n; i++)
result *= i;

return result;

}
我们可在报错的 Factorial 方法签名所在行的上下方各插入一行指令。上面是报错,下面是 restore 恢复报错。这里的 IDE0051 就是表示“方法没有使用过”的报错信息。

有人会问,为什么 restore 这个指令写在第 3 行而不是第 10 行后?因为编译器分析和报错只位于整个方法的签名上(你可以看到前面给出的图片,四个小圆点只出现在 Factorial 这个方法名上,而不是整个方法都标上了灰色的四个小圆点。所以,我们只需要插入到这里即可。当然了,你写在第 10 行后也是可以的,也没有错误就是了。

Part 4 #warning 的禁用


C# 早期还有一个叫做 #warning 的指令,它用来控制和表示在当前位置直接产生编译器警告信息。

using System;

static class Program
{
static void Main()
{
}

private static string HelloTo(string name)
{
    return string.Format("Hello, {0}!", name);
}

warning Deprecated code block.

private static string Hello()
{
    return "Hello!";
}

}
比如这样的代码,我们可以看到第 14 行有一个 #warning 指令。我们此时可以直接在第 14 行获得一个编译器警告信息。不过,如果你要禁用这段代码的错误,这里就需要了解一下,#warning 指令的报错机制了。

warning 占用了编译器警告编号是 CS1030。也就是说,你写的 #warning 默认都会产生编号为 CS1030 的编译器警告信息。如果你要想禁用掉,使用 #pragma warning disable CS1030 即可。

using System;

class Program
{
static void Main()
{
}

private static string HelloTo(string name)
{
    return string.Format("Hello, {0}!", name);
}

pragma warning disable CS1030

warning Deprecated code block.

pragma warning restore CS1030

private static string Hello()
{
    return "Hello!";
}

}
比如这样。当然了,毕竟是我们自己临时自定义的警告信息,我们也不建议对自己添加的警告信息再次取消掉它的报错。

Part 5 简记 CS 系列错误编号


Visual Studio 发展到现在,拥有众多的编译器分析出来的编号代码。其中常见的有这些:

CS 系列:C# 语言自身派生的基本语法分析的错误和警告信息;

IDE 系列:和 Visual Studio 绑定的、和你书写的代码可以产生交互的编译器警告信息;

CA 系列:官方提供的、补充的编译器错误和警告信息。

当然还有一些别的,什么 RS 开头的啊,SA 开头的,DA 开头等等的系列。不过这里我们要说的是 CS 开头的。CS 是 C# 语法分析的结果导致的唯一编号序列,它们在使用 #pragma warning 指令的时候,可以不写 CS 前缀,比如 CS1030 可以直接记作 1030,等等。

比如最开始的禁用的代码:

pragma warning disable IDE0059, 219, IDE0005

using System;

static class Program
{
static void Main()
{
string hello = “Hello!”;
}
}
这个 CS0219 可以直接记作 0219 或 219。但别的,都必须带上开头的字母。

Part 6 不能禁用掉编译器错误


编译器错误一般而言是无法禁用的,原因很简单:比如你写的忘了分号的语法错误,编译器肯定是不让编译通过的。因此,#pragma warning 肯定无法使用到这种场合上。它属于严重的错误,不属于去掉编译器警告就能消除掉的错误类型。

作者:SunnieShine
原文地址: https://www.bilibili.com/read/cv15187637/

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

作者:木头骨头石头
原文地址: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。目前,这一管线还在初期阶段,还没有得到大规模的应用。