C++性能优化笔记-11-使用向量操作


今天的微处理器有向量指令,这让在一个向量的所有元素上进行操作成为可能。这样叫单指令多数据(SIMD)操作。每个向量的大小可以是64位(MMX),128位(XMM),256位(YMM)和512位(ZMM)。
当需要在大数据集上,对多个数据执行相同的操作,并且程序逻辑也允许时,向量操作是很有用的。例如:图像处理、音频处理、向量和矩阵的数学操作。天然串行的算法,例如排序算法,不大适合向量操作。严重依赖于表查找或要求很多数据交换的算法,例如很多加密算法,是不大适合向量操作。
向量操作依赖于一系列特殊的向量寄存器。不同指令集支持的向量寄存器大小和数目各有不同。可以参考各指令集的数据。
为了高效的访问,不同向量寄存器建议的内存对齐要求也各有不同。
一般,越新的处理器,向量处理也越快。
通常,越小的元素,向量操作越有益。

AVX指令集和YMM寄存器

128位的XMM寄存器被扩展到256位,在AVX指令集中称为YMM。AVX指令集的主要好处是,它允许更大的浮点向量。AVX2指令集也允许256位整数向量。
代码针对AVX指令集编译的话,只能在CPU和操作系统都支持AVX时才能运行。

AVX512指令集和ZMM寄存器

256位的YMM寄存器扩展为512位的ZMM寄存器,在AVX512指令集。AVX512指令集中,64位模式有32个向量寄存器,而32位模式只有8个。因此,AVX512代码最好编译为64位模式。

自动向量化

好的编译器,会在明显的并行场景中,自动使用向量寄存器。详细的指令可以参考编译器文档。例如:

。。。

使用内建函数

预测编译器是否互向量化一个循环是困难的。例如:

。。。
你必须确保CPU支持对应的指令集,否则程序会崩溃。除了Microsoft,所有的编译器都允许用命令行指定使用哪个指令集编译。

对齐数据

如果数据在内存里的地址以向量大小(16,32,或64字节)对齐,那么加载数据会更快。在旧型号的处理器上更加明显。新的处理器上不那么重要。下例显示了如何对其数据:
。。。

向量化表查找

查找表对于优化代码很有用。不幸的是,表查找通常是向量化的障碍。AVX2和更新的指令集已经有对表查找有用的聚合指令。一个32位或64位整数向量提供一个索引表在内存里。结果是一个32位或64位的整数、浮点或双精度值的向量。聚合指令花费几个时钟周期,因为元素是一个一个读的。
如果查找表小得可以放进一个或几个向量寄存器,那么还是给表查找用排列指令是更高效的。

使用内建函数通常是冗繁的,代码会庞大且易读性较差。通常使用向量类更容易。

使用向量类

通过封装向量到C++类中,使用操作符重载,以更清晰和智能的方式实现与内建函数相同效果是可能的。操作符内联以便最终的机器码与使用内建函数相同。
向量类的好处:

  • 可以指定代码的哪部分向量化和如何向量化。
  • 可以克服自动向量化的障碍。
  • 代码通常更简单,与编译器自动生成的相比。因为编译器不得不处理与应用程序无关的特殊情况。
  • 代码更简洁和易读,与汇编或使用内建函数相比,却效率相同。

。。。

向量类的CPU分发

下边的例子显示了如何根据支持的指令集进行自动CPU分发。
。。。

转换串行代码到向量化代码

不是所有的代码都有一个并行结构让它可以比较容易的向量化。许多代码是串行场景的,每个计算依赖于前一个计算结果。如果代码是重复性的,那么把代码组织为可以向量化的形式是可能的。最简单的例子是一个长列表元素的求和:
。。。
这个循环在一个向量里计算四个连续的项。进一步展开循环可能是有益的,如果循环更长的话,因为这里的性能更可能受限于乘法,而不是输出。这里的系数表在编译时计算。可能在运行时计算系数是更方便的,如果你可以保证只计算一次,而不是每次函数调用都要计算系数表。

数学函数的向量化

有各种函数库以向量方式来计算数学函数,例如:对数、幂函数、三角函数等。这些函数库对向量化数学代码是有用的。
有两种不同种类的向量数学库:长向量库和短向量库。来看看它们的不同。假设要计算1000个数字的某个函数。用长向量库,把1000个数字的数组作为参数传给库函数,这个库函数存储这1000个结果到另一个数组。使用长向量版库函数的缺点是,如果要做一系列计算,在下一次调用函数前就需要存储中间结果到一个临时数组中。用短向量版本的向量库,可以把数据集拆分为子向量来适配向量寄存器。如果向量寄存器可以处理4个数字,那么需要调用250此库函数。这个库函数会返回结果到向量寄存器中,可以直接被下一次计算利用,而不需要存储中间结果到RAM中。这可能更快。然而,短向量的库函数可能是不利的,如果计算序列形成了长依赖链。

这是一些长向量函数库:

  • Intel 向量数学库(VML, MKL)。工作在x86平台。这些库函数在非Intel的CPU上会低效,除非重写了Intel cpu分发器。
    。。。

这是一些短向量库:

  • Sleef库。支持多种平台。开源。参考www.sleef.org。
    。。。。
    所有这些库都有好的性能和精度。比非向量库快很多倍。

对齐动态分配的内存

使用newmalloc分配的内存根据平台不同,以8或16 对齐。这对于需要16或更多字节对齐的向量操作是一个问题。C++17标准给出了使用new时自动按需对齐的方式:
。。。

对齐RGB视频或三维向量

RGB图像数组每个点有三个值。这不适合四个浮点数的向量。这种情况同样出现在三维地理信息和其他奇数尺寸的向量数据。出于效率考虑,这些数据需要以向量大小赌气。使用非对齐的读写可能会减慢向量化代码的执行速度。根据问题的算法不同,可以选择以下的解决方案:

  • 添加无用的第四个值,使数据符合向量。副作用是增加内存消耗。如果内存是瓶颈,要避免这个方法。
  • 以4或8为一组来分组数据。例如4个R值在一个向量,4个G值在下一个向量,4个B值在最后一个向量。
  • 把所有的R值组织在仪器,然后是所有的G值,最后是所有的B值。

选择哪个方法,取决于算法是否合适。如果点的数量不能被向量大小整除,那么添加几个额外的无用的点到结尾来获取整数数目的向量。

结论

如果算法允许并行计算,那么使用向量代码能获得运行速度的提升。提升多少取决于每个向量的元素数量。最简洁的方式是依靠编译器的自动向量化。对于简单情形、标准操作,编译器会自动向量化代码,只要你打开合适的指令集和相应的编译选项。

然而,有许多情形编译器不能自动向量化代码或者处理的不够优化。这时,你就需要显示的向量化代码。几种常见的方式:

  • 使用汇编语言
  • 使用内建函数
  • 使用预定义向量类

最简单的显示向量化代码方法是使用向量类库。类库和内建函数可以结合使用。内建函数和向量类库通常只是方便与否,性能几乎没有区别。

一个好的编译器,通常在你显示向量化代码后可以更好的优化代码。编译器可以使用诸如内联函数、通用子表达式消除、常量传播、循环优化等技术来优化代码。这些技术很少在手工汇编代码使用,因为它会是代码庞大,易出错,并且几乎不可维护。当前的编译器在常量传播和向量代码优化上表现的不够好。你可以查看汇编输出或调试器中的反汇编来查看编译器做了什么。

向量化的代码通常包含额外的指令来转换数据到适合的格式以及把数据放到向量的正确位置。这些数据转换和重排有时比真正的计算更耗时。在决定向量化是否合适时,要考虑这个因素。

利于进行向量化的因素:

  • 小数据类型:char, int16_t, float
  • 大数组中所有元素进行相似的处理。
  • 数组大小可以被向量大小整除。
  • 在两个简单表达式间选择的不可预测的分支。
  • 只有向量操作数可用的操作:最小量、最大量、饱和加法、快速近似倒数、快速近似倒数平方根、RGB颜色差值。
  • 向量指令集可用。
  • 数学向量函数库
  • 使用Gnu或Clang编译器。

利于不进行向量化的因素:

  • 大数据类型:int64_t,double
  • 非对齐的数据
  • 需要额外的数据转换,重排,打包,解包。
  • 预测分支时,有的分支可以跳过庞大的表达式。
  • 编译器没有指针对齐和别名的足够信息。
  • 向量指令集中没有对应的操作。
  • 旧型号的CPU中向量寄存器大小太小。

向量化代码对于开发者来说更难开发,因此也更容易出错。因此,向量化代码应该放在重用和经过良好测试的模块和头文件中。

欢迎关注公众号
在这里插入图片描述

  • 1
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值