在三个使用案例中使用SIMD内部功能提高性能

2020-07-09 02:10:03

如果处理得当,用向量内部函数补充C或C++代码会对性能特别好。对于本文中介绍的案例,矢量化将性能提高了3到12倍。

许多开发人员编写对性能敏感的软件。毕竟,这是我们这些天仍然选择C或C++语言的主要原因之一。

所有现代处理器实际上都是幕后向量处理器。与单独处理数据的标量处理器不同,现代矢量处理器处理一维数据数组。如果您希望最大限度地提高性能,则需要编写针对这些向量量身定做的代码。

每次编写Float s=a+b时,都会留下大量的性能。处理器可以将4个浮点数加到另外4个数字上,或者如果处理器支持AVX,甚至可以将8个数字加到另外8个数字上。类似地,当您编写inti=j+k;以添加2个整数时,您可以改为添加4个或8个数字,以及相应的SSE2或AVX2指令。

语言设计人员、编译器开发人员和其他聪明人多年来一直在尝试以一种能够利用性能潜力的方式将标量代码编译成矢量指令。到目前为止,他们都没有完全成功,我不相信这是可能的。

利用向量硬件的一种方法是SIMD内部函数,它在所有现代C或C++编译器中都可用。SIMD代表“单指令、多数据”。SIMD指令在许多平台上都可用,通过架构扩展ARM霓虹灯,您的智能手机也很有可能也有SIMD指令。本文重点介绍在现代AMD64处理器上运行的PC和服务器。

即使把焦点放在AMD64平台上,这个话题对于一篇博客文章来说也太宽泛了。现代SIMD指令是随着1999年奔腾3的发布而引入奔腾处理器的(该指令集是SSE,现在有时被称为SSE-1),从那以后又增加了更多的SIMD指令。有关更深入的介绍,您可以阅读我关于这个主题的另一篇文章。与这篇博客文章不同的是,这篇文章没有实际问题,也没有基准,相反,它试图提供一个可用的概述。

对于程序员来说,内部函数看起来就像常规库函数;您可以包含相关的头文件,并且可以使用内部函数。要将四个浮点数与另外四个数字相加,请在代码中使用_mm_add_ps内部函数。在编译器提供的声明该内在<;xmmintrin.h>;的头文件中,您会发现此声明(假设您使用的是VC++编译器。在GCC中,您将看到一些不同的东西,它为用户提供相同的接口。):

但与库函数不同的是,内部函数直接在编译器中实现。上述_mm_add_ps sse内部指令通常为1,编译成单个指令addps。在CPU调用库函数所需的时间内,它可能已经完成了十几条这样的指令。

1(该指令可以从内存中提取其中一个参数,但不能同时提取两个参数。如果以某种方式调用它,以便编译器必须从内存加载两个参数,如__m128sum=_mm_add_ps(*p1,*p2);编译器将发出两条指令:第一条用于将参数从内存加载到寄存器中,第二条用于将四个值相加。)。

__m128内置数据类型是一个由四个浮点数组成的向量;每个浮点数32位,总共128位。CPU具有适用于该数据类型的宽寄存器,每个寄存器128位。自AVX于2011年推出以来,在当前的PC处理器中,这些寄存器为256位宽,每个寄存器可以容纳8个浮点值、4个双精度浮点值或大量整数,具体取决于它们的大小。

包含足够数量的向量内部函数或嵌入它们的汇编等效项的源代码称为手动向量化代码。现代编译器和库已经使用内部函数、汇编或两者的组合实现了很多东西。例如,memset、memcpy或memmove标准C库例程的一些实现使用SSE2指令来提高吞吐量。然而,在高性能计算、游戏开发或编译器开发等利基领域之外,即使是非常有经验的C和C++程序员也在很大程度上不熟悉SIMD内部函数。

为了帮助演示,我将介绍三个实际问题,并讨论SIMD如何提供帮助。

假设我们需要编写一个将RGB图像转换为灰度的函数。最近有人问了这个问题。

许多实际应用程序都需要这样的代码。例如,当您将原始图像数据压缩为JPEG或将视频数据压缩为H.264或H.265时,压缩的第一步非常相似。具体地说,压缩器将RGB像素转换为YUV颜色空间。确切的颜色空间在这些格式的规格中定义--对于视频,通常是ITU-R BT.709,现在请参阅该规格的第3节“信号格式”。

我已经实现了几个版本,矢量化和非矢量化,并用随机图像对它们进行了测试。我的台式机上插了AMD Ryzen 53600,我的笔记本电脑上焊接了英特尔i3-6157U。WSL专栏的结果来自相同的桌面,但针对的是用GCC 7.4构建的Linux二进制文件。对于3840×2160像素的图像,表中最右边的三列包含以毫秒为单位的时间(五次运行中的最好一次)。

矢量化版本比标量代码快3到8倍。在笔记本电脑上,标量版本可能太慢,无法处理这种大小的60FPS视频,而矢量化代码的性能对此还可以。

将该特定算法矢量化的最佳方式似乎是定点16位数学。矢量寄存器可以容纳的16位整数是32位浮点数的两倍,从而可以在几乎相同的时间内并行处理两倍多的像素。在我的桌面上,_mm_mul_ps sse 1固有(乘以128位寄存器的4个浮点数)具有3个周期的延迟和0.5个周期的吞吐量。_mm_mulhi_epu16 SSE 2固有(乘以128位寄存器的8个定点数字)具有相同的3个周期延迟和1个周期吞吐量。

根据我的经验,这种结果在CPU上的图像和视频处理中很常见,而不仅仅是这个特定的灰度问题。

在台式机上,从SSE升级到AVX-具有两倍宽的SIMD矢量-只会略微提高性能。在笔记本电脑上,它起到了很大的帮助。造成这种情况的一个可能原因是桌面上的RAM带宽瓶颈。这也很常见,多年来,CPU性能的增长速度略快于内存带宽。

编写一个函数来计算两个浮点向量的点积。这里有一个相关的堆栈溢出问题。目前,点产品的一个流行应用是机器学习。

我不想再次出现内存瓶颈,所以我进行了一个测试,它计算256k长的向量的点积,每个向量占用1MB的RAM。这个数据量适合我用于基准测试的两台计算机的处理器高速缓存:台式机有3MB的二级高速缓存和32MB的三级高速缓存,笔记本电脑有3MB的三级高速缓存和64MB的四级高速缓存。最右边的三列是微秒(µs),十次运行中最好的一次。

最好的纯SSE1版本SseVertical4提供了接近AVX+FMA的性能。一个可能的原因是内存带宽。源数据在缓存中,因此带宽本身非常高。但是,CPU在每个周期只能执行几个负载。代码一次从两个输入数组读取,很可能会达到该限制。

当用VC++构建时,单累加器非FMA SSE,尤其是AVX版本表现出奇地好。我已经看过拆卸了。编译器设法通过指令重新排序来隐藏一些延迟。代码计算乘积,递增指针,将乘积添加到累加器,最后测试循环退出条件。这样,矢量指令和标量指令是交错的,隐藏了两者的延迟。在某种程度上:四累加器版本仍然更快。

GCC造的标量版相当慢。这可能是由CMakeLists.txt中的编译器选项造成的。我不确定它们是否足够好,因为在过去的几年里,我只构建了在ARM设备上运行的Linux软件。

数据依赖是我想用这个例子来说明的主要内容。

从计算机科学家的角度来看,点积是减法的一种形式。该算法需要处理较大的输入向量,并且只计算单个值。当计算很快时(例如在本例中,从连续的内存块乘以浮点数非常快),吞吐量通常会受到Reduce操作的延迟的限制。

让我们比较两个具体版本的代码,AvxVerticalFma和AvxVerticalFma2。前者具有以下主循环:

对于(;p1<;p1End;p1+=8,p2+=8){const__m256 a=_mm256_loadu_ps(P1);const__m256 b=_mm256_loadu_ps(P2);acc=_mm256_fmadd_ps(a,b,acc);//更新唯一累加器}。

对于(;p1<;p1End;p1+=16,p2+=16){__m256 a=_mm256_loadu_ps(P1);__m256 b=_mm256_loadu_ps(P2);dot0=_mm256_fmadd_ps(a,b,dot0);//更新第一个累加器a=_mm256_loadu_ps(p1+8);b=_mm256_loadu。

_mm256_fmadd_ps内在计算(a*b)+c对于八个浮点值的数组,该指令是FMA3指令集的一部分。AvxVerticalFma2版本的速度几乎快了2倍-更深的流水线隐藏了延迟。

当处理器提交指令时,它需要参数值。如果其中一些还不可用,处理器将等待它们到达。https://www.agner.org/上的表格显示,在AMD Ryzen上,FMA指令的延迟是5个周期。这意味着一旦处理器开始执行该指令,计算结果只会在五个CPU周期之后到达。当循环运行需要前一次循环迭代计算的结果的单个FMA指令时,该循环在五个CPU周期中只能运行一次迭代。

用两个累加器,极限是相同的,五个周期。但是,循环体现在包含两条彼此不依赖的FMA指令。这两条指令并行运行,代码提供的吞吐量是桌面的两倍。

不过,笔记本电脑上的情况并非如此。笔记本电脑显然在其他方面遇到了瓶颈,但我不确定那是什么。

最初,该基准测试使用更大的向量,每个向量为256MB。我很快发现,这种情况下的性能受到内存带宽的限制,结果中显示的差别不大。

除了测量时间之外,我的测试程序还打印计算出的点积。这是为了确保编译器不会优化掉代码,并检查我的两台计算机和15个实现之间的结果是否相同。

我惊讶地看到标量版打印的是1.31E+7,而所有其他版本都打印的是1.67E+7。起初,我以为这是一个错误。我实现了一个使用双精度累加器的标量版本,果然,它打印了1.67E+7。

高达20%的误差是由累加顺序造成的。当代码将较小的浮点值与较大的浮点值相加时,会丢失大量精度。一个极端的例子是:当第一个浮点值大于840万,而第二个值小于1.0时,它根本不会添加任何内容。它只返回两个参数中较大的一个!

从技术上讲,使用两两求和的方法通常可以获得更精确的结果。我的矢量化代码不能完全做到这一点。尽管如此,四累加器AVX版本可累加32个独立标量值(四个寄存器,每个寄存器8个浮点数),这是同一方向的一步。当有6400万个数字需要求和时,32个独立累加器对精度有很大帮助。

在本文的最后部分,我选择了一个稍微复杂一些的问题。

对于门外汉来说,泛洪填充是当您在编辑器中打开图像,选择“画桶”工具,然后单击图像时发生的事情。从数学上讲,它是在规则的2D网格图上操作的连通分量标记。

与前两个问题不同,现在还不清楚如何向量化这个问题。这并不是一个令人尴尬的平行问题。事实上,在GPGPU上很难有效地实现漫灌。尽管如此,只要付出一些努力,使用SIMD的性能仍有可能远远超过标量代码。

由于复杂性,我只创建了两个实现。第一个是标量版本,是扫描线填充,在维基百科中有描述。不是太优化,但也不是特别慢。

第二个是矢量化版本,它是一个自定义实现。它需要AVX2。它将图像分割成一个由小而密集的块组成的2D数组(在我的实现中,这些块是16×16像素,每个像素一位),然后我运行类似于维基百科的森林火灾算法的东西,只是我处理的不是单个像素,而是完整的块。

在下面的结果表中,显示的数字以毫秒为单位。我在两个图像上运行了每个实现:maze-Diagonal.png,2212×2212像素,从x=885y=128填充;shapes.png,1024×1024像素,从同一点填充。由于问题的性质,填充图像所需的时间在很大程度上取决于图像和其他输入参数。对于第一个测试,我特意选择了一个相对难以泛洪填充的图像。

正如您从表中看到的,矢量化将性能提高了1.9-3.5倍,具体取决于CPU、编译器和映像。这两个测试图像都在存储库中的FlodFill/Images子文件夹中。

矢量化代码的工程开销并不是微不足道的,特别是对于泛洪填充,矢量化版本的代码是标量扫描线填充版本的三到四倍。诚然,矢量化的代码更难阅读和调试;这种差异会随着经验而消失,但永远不会消失。

我已经在GitHub上发布了这些测试的源代码。它需要C++/17,我已经在安装了Visual Studio2017的Windows10和安装了GCC 7.4.0的Ubuntu Linux18上进行了测试。visual studio的免费软件社区版很好。我只测试过64位版本。代码在MIT许可的复制/粘贴友好条款下发布。

因为这篇文章是针对不熟悉SIMD的人的,所以我比平时写了更多的评论,我希望它们能有所帮助。

这篇博客是关于内部的,不是C++/17。C++部分不够理想,我已经实现了基准测试的最低要求。泛洪填充工程包括stb_image和stb_image_write第三方库来处理png图像:http://nothings.org/stb.。再说一次,这不是我在产品质量的C++代码中可能会做的事情。操作系统提供的图像编解码器通常更好,在Linux上是libpng,在Windows上是WIC。

我希望这能让您了解到,当您利用SIMD内部函数的强大功能时,可能会发生什么。

标签:并行处理、SIMD