C++中使用SIMD的几种方法

图7列出了使用SIMD技术的多种方法,我们先按从上至下的顺序简要介绍每一种,然后重点介绍汇编语言方法。

C++中使用SIMD的几种方法_第1张图片

图7 使用SIMD技术的多种方法

第一种方法是使用著名的IPP库,IPP的全称是Intel Integrated Performance Primitives, 是英特尔公司开发的一套跨平台软件函数库,提供了非常广泛的功能,包括各种常用的图形图像、音视频处理函数。因为其中的很多函数都已经使用SIMD技术做了优化,所以使用这个库是使用SIMD技术的一个快捷途径。通过链接https://software.intel.com/en-us/intel-ipp/ 可以访问IPP的官方介绍,了解更多信息。

第二种方法是使用编译器的自动向量化(Auto-vectorization)支持。比如图8是在Visual Studio(C++)中通过项目属性对话框启用自动向量化的截图。

C++中使用SIMD的几种方法_第2张图片

图8 在Visual Studio中启用自动向量化支持

经笔者分析,这样启用后编译好的程序中确实使用了一些SIMD指令,比如图9右侧蓝色加亮那一行使用的便是SSE2中的cvtsi2sd指令,它可以将源操作数中的有符号双字整数转换成目标操作数中的双精度浮点值。

C++中使用SIMD的几种方法_第3张图片

图9 观察编译器自动向量化产生的SIMD指令

如果使用GCC编译器,那么可以使用类似这样的命令行来编译: 
代码1 
如果希望看到编译器所采取的向量化动作,那么可以增加-ftree-vectorizer-verbose=1,于是可以类似图10的输出信息。

图4  使用GCC的自动向量化支持

图10 使用GCC的自动向量化支持

使用GDB的反汇编功能,可以很容易地观察到GCC产生的SIMD指令,如图11所示。

C++中使用SIMD的几种方法_第4张图片

图11 GCC的自动向量化功能产生的汇编指令

第三种方法是使用编译器指示符(compiler directive),比如,如果使用英特尔的C/C++编译器(ICC)编译如下代码,那么ICC便会对#pragma simd指示符下面的for循环做向量化,并给出类似下面这样的输出信息:remark: SIMD LOOP WAS VECTORIZED.

代码2

第四种方法是使用Cilk技术。Cilk一词源于发音相近的Silk一词,蕴含的意思是要把并行编程做的像丝绸一样美丽。Cilk技术最早由MIT开发,第一版本于1994年发布。后来开发者创建了一个名叫 Cilk Arts的公司,推出改进的私有版本。2009年,英特尔收购了Cilk Arts,将Cilk技术整合进英特尔编译器中。2012年后,Cilk再次成为开源项目,GCC中便有支持(需要4.8或者更高版本)。感兴趣的朋友可以从https://www.cilkplus.org网站了解更多信息和下载有关工具及示例代码。

第五种方法是使用编译器的内建函数(intrinsic),举例来说,下面这个循环来自我们要详细讨论的图像二值化程序的C++代码。

代码3

如果使用Visual C++编译器的SIMD intrinsic进行改写,那么新的代码如清单1所示。

清单1 通过intrinsic使用SIMD技术

代码4

第六种方法是直接使用汇编语言编写汇编函数,然后再从C++代码中调用汇编函数,稍后会详细介绍。

比较图7中的六种方法,灵活度和可控性由上至下越来越高,但是使用的难度基本也是越来越大。 
C++中使用SIMD的几种方法_第5张图片

编写和调试供SIMD汇编函数

有两种方法可以在C++项目中使用汇编代码,一种是通过__asm{}这样的指示符号把汇编代码嵌入在C++函数中,另一种是把汇编代码放在单独的以.asm结尾的文件中。前一种方法因为不支持64位,所以基本过时了。

在使用后一种方法时,首先要在项目的Solution Explorer树形控件上右击希望加入汇编文件的项目,然后选择Build Dependencies → Build Customizations调出图6所示的对话框,然后选中masm行。 
C++中使用SIMD的几种方法_第6张图片

详细讨论如何编写汇编代码超出了本文的范围,这里只能管中窥豹,介绍与上面讨论的for循环(清单1上方)对应的一段汇编指令(引自《现代x86汇编语言程序设计》一书),如清单2所示。

清单2 对灰度图像进行二值化处理的SSE2汇编程序片段

代码5

对于长久没有写过悉汇编代码的同行,理解清单2中的代码可能有些困难,特别是其中的SIMD指令。下面将以笔者惯用的调试方法来帮助大家理解——在调试器里看SIMD。

在清单2的第一行指令处设置断点(与在C++代码中设置断点方法相同),触发程序调用这个汇编函数,断点命中后,单步走过这条指令。

代码6

简单说,这条指令就是把edx指向的ITD结构体的Threshold字段赋给EAX寄存器。

打开汇编窗口,编译后的指令为:

代码7

这意味着,Threshold字段在结构体中的偏移是0xC。Ctrl + Alt + A调出Visual Studio的命令窗口,观察内存和寄存器的值,可以印证:

代码8

看来,这条指令的作用就是把二值化的阈值加载到EAX寄存器。

接下来一条指令比较简单,movd xmm1,eax,就是把常规寄存器EAX中存放的阈值传递给SSE的SSE寄存器XMM1。Visual Studio的寄存器窗口默认不显示SIMD的寄存器,但是可以通过快捷菜单很容易解决这个问题,点击右键,调出图12所示的快捷菜单,选中SSE即可。

C++中使用SIMD的几种方法_第7张图片

图12 配置显示SSE寄存器

选中SSE后,单步执行,再观察寄存器窗口,可以看到XMM1的值如下:

代码9

接下来的两条指令是要把已经在XMM1最低字节中的阈值(0x98)散列(shuffle)到其他字节中。

代码10

两条指令中的p代表packed,即组合数,是SIMD中的常见术语,pshufb是Packed Shuffle Bytes的缩写,它根据第二个操作数指定的控制掩码对第一个操作数执行散列操作,产生一个组合数。描述起来比较拗口,单步执行这两条指令后看一下效果大家就明白了:

代码11

有趣吧。接下来的这条指令(movdqa xmm2,xmmword ptr [PixelScale])是把PixelScale常量数组中的缩放值赋给XMM2。

代码12

执行后,XMM2的值为:

代码13

接下来的指令是做组合减法,最有SIMD特色的操作。

代码14

单步前两个寄存器的值为:

代码15

单步后为:

代码16

也就是一次完成16个整数减法。

做好准备工作后,接下来就开始处理ESI指向的图像数据了,movdqa xmm0,[esi]每次可以把16个字节加载到XMM0,psubb xmm0,xmm2减去缩放值(128),然后使用下面这条pcmpgtb指令进行比较。

代码17

pcmpgtb的全称是Compare packed signed byte integers for greater than,它会根据比较结果来把目标字节写为全0或者全1(大于)。例如,单步前的XMM0和XMM1如果是:

代码18

那么单步后便是:

代码19

而后,movdqa [edi],xmm0指令把结果写到EDI指向的目标缓冲区中。然后把ESI和EDI都增加16,进行下一次循环。

图13是比较编写的测试程序界面,列表控件中包含了以不同方式对同一幅图像执行二值化操作测量到的时间。

C++中使用SIMD的几种方法_第8张图片

图13 比较不同计算方式的测试程序

从图13可以看到,与普通的C++代码(图中以ALU表示)相比,使用SSE方法的速度提升是非常明显的,从原来的1000多毫秒,加快到了10/20多毫秒,这就是SIMD的魅力。篇幅关系,要就此打住了,感兴趣的朋友可以下载示例程序的完整源代码(http://advdbg.org/的资源板块)亲自体验一下。

你可能感兴趣的:(性能优化)