Quantcast
Channel: 英特尔开发人员专区文章
Viewing all articles
Browse latest Browse all 583

使用英特尔® SPMD 程序编译器实现游戏 CPU 的矢量化

$
0
0

下载 GitHub* 代码示例

简介

基于 LLVM*英特尔® SPMD 程序编译器 (在之前的文档中通常被称作 ISPC)并不是 Gnu* 编译器套装 (GCC) 或 Microsoft* C++ 编译器的替代品;它更类似于面向 CPU 的着色器编译器,可生成适用多种指令集的矢量指令,如英特尔® SIMD 流指令扩展 2(英特尔® SSE2)、英特尔® SIMD 流指令扩展 4(英特尔® SSE4)、英特尔® 高级矢量扩展指令集(英特尔® AVX)、英特尔® AVX2 等。输入基于 C 的着色器或内核,输出预编译对象文件,您的应用中将包含一个头文件。通过使用少量关键字,编译器便可以得到关于在 CPU 矢量单元上分配工作的明确指示。

如果开发人员选择将内联函数直接编写到代码库,显式矢量化便可提供更高性能,但是这样做极为复杂,维护成本较高。英特尔 SPMD 程序编译器 内核使用高级语言编写而成,因此开发成本较低。相比英特尔 SSE4 等最常见的指令集,它还可以轻松支持多个指令集,为运行代码的 CPU 提供最佳性能。

本文的目的并不是教会读者如何编写英特尔 SPMD 程序编译器内核;本文简单介绍了如何将英特尔 SPMD 程序编译器插入 Microsoft Visual Studio* 解决方案,提供了关于如何将简单的高级着色语言* (HLSL*) 计算着色器导入英特尔 SPMD 程序编译器内核的指导。如欲获取关于英特尔 SPMD 程序编译器更详细的概述,请参阅在线文档

本文提供的示例代码基于 Microsoft DirectX* 12 n 体示例的修改版本,为了支持英特尔 SPMD 程序编译器矢量化计算内核,已导入该版本。本文的目的并不是显示 GPU 的性能增量,而是显示从标准标量 CPU 代码迁移至矢量化 CPU 代码实现的性能提升。

由于本应用原始示例中的 CPU 负载非常小,显然不能代表一个游戏,但是本应用显示了在多个 CPU 内核上使用矢量单元可能会提升性能。


图 1.经过修改的 n 体重力示例截屏

原始 DirectX* 12 n 体重力示例

开始移植英特尔 SPMD 程序编译器之前,了解原始示例及其目的非常有帮助。编写 DirectX 12 n 体重力示例是为了介绍如何使用 DirectX 12 中的独立计算引擎执行异步计算,也就是在 GPU 上并行执行粒子更新与粒子渲染。示例生成 10,000 个粒子,逐帧更新与渲染粒子。更新包括每个粒子与其他粒子的交互,每次模拟 tick 生成 100,000,000 个交互。

HLSL 计算着色器将计算线程映射至每个粒子,以执行更新。双缓冲粒子数据,因此,对于每一帧,GPU 从缓冲 1 开始渲染,异步更新缓冲 2,然后翻转缓冲,为下一帧做准备。

就是这么简单。不仅简单,它还是英特尔 SPMD 程序编译器移植的绝佳选择,因为异步计算任务适用于出色地运行于 CPU;代码和引擎可在并发执行路径中执行计算。通过将某些负载迁移至多数情况下未被充分利用的 CPU,GPU 可以更快地完成帧,或者完成更多工作,同时充分利用 CPU。

移植到英特尔® SPMD 程序编译器

建议首先从 HLSL 移植到标量 C/C++。这样确保了算法的正确性,生成了正确的结果,正确地与其它应用交互,以及恰当处理多个线程(如果适用)。听起来轻而易举,但是需要注意以下几点:

  1. 如何在 GPU 和 CPU 之间共享内存。
  2. 如何同步 GPU 和 CPU。
  3. 如何面向单指令/多数据 (SIMD) 和多线程划分任务。
  4. 将 HLSL 代码移植到标量 C。
  5. 将标量 C 移植到英特尔 SPMD 程序编译器内核。

某些操作相对简单。

共享内存

我们知道需要在 CPU 和 GPU 之间共享内存,但是如何共享?幸运的是,DirectX 12 提供一些选项,如将 GPU 缓冲映射到 CPU 内存等。为了简化示例,最大程度减少代码变更,我们重新使用了用于初始化 GPU 粒子缓冲的粒子上传临时缓冲,并创建了面向 GPU 访问的双缓冲 CPU 副本。使用模式成为:

  • 在 CPU 中更新 CPU 可访问的粒子缓冲。
  • 使用原始上传临时缓冲调用 DirectX 12 助手 UpdateSubresources,GPU 粒子缓冲作为目的地。
  • 绑定 GPU 粒子缓冲并渲染。

同步

如果原始异步计算内核已经有一个用于配置计算和渲染之间交互的 DirectX 12 栅栏对象,同步将自然发生,重新利用它通知渲染引擎副本已完成。

划分工作

为了划分工作,我们应首先考虑 GPU 如何划分工作,因为这可能同样适用于 CPU。计算着色器通过两种方式控制工作的划分。首先是调度大小,指的是录制命令流时传输至 API 调用的大小。描述了运行的工作组的数量和维度。第二个是本地工作组的数量和维度,该工作组被硬编码为着色器。本地工作组的每个项目可视作一个工作线程,如果使用了共享内存,每个线程可与工作组中的其他线程共享信息。

通过观察 nBodyGravityCS.hlsl计算着色器发现,本地工作组的规模为 128 x 1 x 1,并使用共享内存优化某些粒子负载,但是在 CPU 上没有必要这样做。除此以外,线程之间没有交互,每个线程与外层循环执行不同的粒子,与内层循环上的所有其它粒子交互。

这应该非常适合 CPU 矢量宽度,因此我们使用面向英特尔 AVX2 的 8 x 1 x 1 或面向英特尔 SSE4 的 4 x 1 x 1 更换 128 x 1 x 1。我们也可以将调度大小用作多线程代码的提示,根据 SIMD 宽度,将 10,000 个粒子除以 8 或 4。但是,由于我们已经发现每个线程之间没有关联,可以简单地将粒子数量除以线程池中的可用线程数量,或除以设备上的可用逻辑内核数量,启用了英特尔® 超线程技术的典型 4 核 CPU 上的内核数量为 8。移植其他计算着色器时,可能需要考虑更多因素。

这为我们提供了以下伪代码:

For each thread
		Process N particles where N is 10000/threadCount
		      For each M particles from N, where M is the SIMD width
		           Test interaction with all 10000 particles

移植 HLSL* 至标量 C

编写英特尔 SPMD 程序编译器内核时,除非您有丰富的经验,否则建议您首先编写一个标量 C 版本。这将确保所有应用粘接、多线程和内存操作在开始矢量化之前已经运行。

为此,nBodyGravityCS.hlsl中的多数 HLSL 代码可在 C 中工作,除了为粒子添加外层循环和将着色器数学矢量类型改为使用基于 C 的类型以外,只需极少的修改。在本示例中,float4/float3 类型与 DirectX XMFLOAT4/XMFLOAT3 类型交换,某些矢量数学操作被划分为标量操作。

CPU 粒子缓冲被用于读写,如上所述,借助用于同步的原始栅栏将写入缓冲上传至 GPU。为了实现线程化,示例使用了并行模式库中的 Microsoft’s concurrency::parallel_for结构。

代码可以在 D3D12nBodyGravity::SimulateCPU()D3D12nBodyGravity::ProcessParticles()中找到。

运行标量代码后,建议快速检查性能,以确保迁移至英特尔 SPMD 程序编译器前,没有需要解决的算法热点。在本示例中,使用英特尔® VTune™工具分析基本热点得出,反平方根 (sqrt) 位于热路径上,因此,被替换为来自 Quake* 的快速反平方根近似值,后者能小幅改进性能,由于失去了精度,改善效果不明显。

移植标量 C 至标量英特尔® SPMD 程序编译器

为了创建英特尔 SPMD 程序编译器内核以及将它们连接至您的应用(对 Microsoft Visual Studio 的修改将在后文中介绍)而修改构建系统后,可以开始编写英特尔 SPMD 程序编译器代码并将它连接至您的应用。

钩子

为了从您的应用代码中调用任何英特尔 SPMD 程序编译器内核,需要添加自动生成的相关输出头文件,然后按照调用正常库的方式调用导出的函数,请记住,所有声明包含在 ispc命名空间中。在本示例中,我们从 SimulateCPU()函数中调用 ispc::ProcessParticles

矢量数学

钩子建成后,下一步便是运行标量英特尔 SPMD 程序编译器代码,然后对它进行矢量化。简单修改后,多数标量 C 代码可直接进入英特尔 SPMD 程序编译器内核。在本示例中,尽管英特尔 SPMD 程序编译器提供模板化矢量类型,但是类型仅面向存储,因此,需要定义所有矢量数学类型和新的结构。完成后,所有 XMFLOAT 类型均被转换为 Vec3 和 Vec4 类型。

关键字

我们现在需要利用英特尔 SPMD 程序编译器的特定关键字修饰代码,帮助引导矢量化和编译。第一个关键字是函数签名使用的 export,类似于调用协定,以通知英特尔 SPMD 程序编译器内核入口点的所在。会产生两个影响。首先,将函数签名和任何所需的结构添加至自动生成的头文件,但是由于所有参数必须是标量,也为函数签名带来了限制,导致我们采用了另外两个关键字,varyinguniform

统一变量描述了无法共享的标量变量,但是它的内容可以在所有 SIMD 通道上共享,同时,不同的变量将被矢量化,在所有 SIMD 通道上得到独特的值。默认情况下,所有变量均不同,因此,尽管可以添加关键字,但是本示例没有采用。首次创建本内核的标量版本时,我们将使用统一关键字修饰变量,以确保变量是严格的标量。

英特尔 SPMD 程序编译器标准库

英特尔 SPMD 程序编译器提供一个能帮助移植的标准库,包含许多常见函数,如 floatbits() 和 intbits(),这些函数是快速反平方根函数进行浮点投影所必需的。

矢量化英特尔 SPMD 程序编译器内核

如果英特尔 SPMD 程序编译器内核按照预期正常运行,应立即实施矢量化。通常情况下,确定并行化的对象以及如何并行化最复杂。按照常规经验,移植 GPU 计算着色器需要遵循 GPU 矢量化的原有模式,在本示例中,核心计算内核被多个 GPU 执行单元并行调用。因此,我们为粒子更新的标量版本添加的新外层循环最需要进行矢量化。

对于矢量 ISA,分散/集中操作非常昂贵(尽管英特尔 AVX2 指令集对此进行了改进),因此,数据布局同样重要,连续内存位置通常更适用于频繁加载/存储。

并行循环

在 n 体示例中遵循这个经验规则,对外层循环进行矢量化,内层循环为标量。因此,将 8 个粒子加载至英特尔 AVX 寄存器中,并在全部 10,000 个粒子中测试这 8 个粒子。这 10,000 个位置将被视作标量变量,在所有 SIMD 通道上共享,没有分散/集中成本。英特尔 SPMD 程序编译器对我们隐藏实际的矢量宽度(除非我们非常想知道),可以提供出色的提取,以透明支持英特尔 SSE4、英特尔® 高级矢量扩展指令集 512(英特尔® AVX-512)等指令集中的各种 SIMD 宽度。

通过将外层 for循环替换为英特尔 SPMD 程序编译器 foreach循环,实施矢量化,指导英特尔 SPMD 程序编译器在 N 尺寸的数据块范围内迭代,N 是当前的矢量宽度。因此,一旦 foreach循环迭代器 ii被用于取消阵列变量,对于矢量中每个 SIMD 通道,ii 的值均不同,确保每个通道运行相异的粒子。

数据布局

此时,简要介绍数据布局非常重要。在 CPU 上使用矢量寄存器时,高效加载和卸载寄存器非常重要;不这样做将导致性能大幅下降。为此,矢量寄存器需要从阵列结构 (SoA) 数据来源中加载数据,因此,可以使用单条指令将相邻内存值的矢量宽度直接加载至运行中的矢量寄存器。如果无法实现,需要降低收集操作的速度,以加载非相邻值的矢量宽度至矢量寄存器,需要通过分散操作再次保存数据。

在本示例中,类似于许多图形应用,粒子数据保存于结构阵列 (AoS) 布局中。可将其转换为 SoA,以避免分散/集中,但是鉴于算法的本质,相比处理内层循环中的 10,000 个标量粒子,外层循环所需的分散/集中成本更低,因此,数据保存为 AoS。

矢量变量

我们的目标是矢量化外层循环,同时保持内层循环的标量格式,因此,外层循环粒子的矢量宽度将处理相同的内层循环粒子。为此,我们宣告 pos, vel,accel可变,从而将外层循环粒子的位置、速度和加速度加载至矢量寄存器。具体做法是删除添加至标量内核的 uniform修饰,这样一来,英特尔 SPMD 程序编译器知道需要矢量化这些变量。

通过 bodyBodyInteractionQ_rsqrt函数传播,以确保正确地矢量化。这只是遵循变量传输和检查编译器错误的一个示例。最终,Q_rsqrt实现了完全矢量化,对 bodyBodyInteraction进行了大范围矢量化,内层循环粒子位置 thatPos为标量。

这就是所需的全部,现在,英特尔 SPMD 程序编译器矢量化内核开始运行,提供优于标量版本的卓越性能。

性能

在两个不同的英特尔 CPU 上对修改后的 n 体应用进行测试,并使用 PresentMon*捕获性能数据,在每颗 CPU 上各运行 3 次,每次 10 秒,记录帧的时长,然后取平均值。结果显示,相比标量 C/C++ 代码,面向英特尔 AVX2 的英特尔 SPMD 程序编译器内核实现了 8–10 倍的性能提升。两台设备均使用 Nvidia* 1080 GTX GPU,并使用了所有可用的 CPU 内核。

处理器

标量 CPU 实施

英特尔® AVX2 实施(使用英特尔® SPMD 程序编译器编译)

提升

英特尔® 酷睿™ i7-7700K 处理器

92.37 毫秒

8.42 毫秒

10.97 倍

英特尔酷睿 I7-6950X 处理器至尊版

55.84 毫秒

6.44 毫秒

8.67 倍

如何将英特尔 SPMD 程序编译器集成至 Microsoft Visual Studio*

  1. 确保英特尔 SPMD 程序编译器在路径上或者可以在 Microsoft Visual Studio* 中轻松定位。
  2. 将英特尔 SPMD 程序编译器内核添加至您项目。如果文件类型无法识别,将不能在默认情况下创建。
  3. 右键点击文件 Properties,将项目类型改为 Custom Build Tool:

  4. 单击 OK并重新打开 Property 页面,便可以修改自定义创建工具。

    a. 使用以下命令行格式:

    ispc -O2 <filename> -o <output obj> -h <output header> --target=<target backends> --opt=fast-math

    b. 本示例中使用的完整命令行为:

    $(ProjectDir)..\..\..\..\third_party\ispc\ispc -O2 "%(Filename).ispc" -o "$(IntDir)%(Filename).obj" -h "$(ProjectDir)%(Filename)_ispc.h" --target=sse4,avx2 --opt=fast-math

    c. 添加编译器生成的相关输出 ie. obj文件:

    $(IntDir)%(Filename).obj;$(IntDir)%(Filename)_sse4.obj;$(IntDir)%(Filename)_avx2.obj

    d. 将 Link Object设置为 Yes

  5. 现在编译您的英特尔 SPMD 程序编译器内核。如果编译成功,将生成一个头文件和一个目标文件。
  6. 将头文件添加至您的项目,并将它放入应用源代码中。

  7. 从相关位置调用英特尔 SPMD 程序编译器内核,请记住,从内核中导出的任何函数都将位于英特尔 SPMD 程序编译器命名空间:

总结

本文的目的在于展示英特尔 SPMD 程序编译器能帮助开发人员将高度矢量化的 GPU 计算内核轻松迁移到矢量化 CPU 代码,以充分利用备用 CPU 周期,为用户提供更丰富的游戏体验。任何自然进行矢量化的工作负载都可以借助英特尔 SPMD 程序编译器内核轻松提升性能,无需使用标量代码。英特尔 SPMD 程序编译器可减少开发和维护时间,也可以便捷地支持全新指令集。


Viewing all articles
Browse latest Browse all 583

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>