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位模式。
⾃动向量化
好的编译器,会在明显的并⾏场景中,⾃动使⽤向量寄存器。详细的指令可以参考编译器⽂档。例如:
// Example 12.1a. Automatic verctorization
const int size =1024;
int a[size], b[size];
for(int i =0; i < size; i++){
a[i]= b[i]+2;
}
当使⽤SSE2或更新的指令集时,好的编译器会使⽤向量操作来优化这个循环。根据可⽤的指令集,每次操作4、8或16个元素。当循环次数可以被⼀个向量的元素数整除时,向量操作优化效果是最好的。可以在数组后边添加⼀些多于元素,使得数组长度可以被向量长度整除。
当数据是通过指针访问时,是不利的。例如:
// Example 12.1b. Vectorization with alignment problemgnu编译器
void AddTwo(int* __restrict aa,int* __restrict bb){
for(int i =0; i < size; i++){
aa[i]= bb[i]+2;
}
}
如果数组按照向量⼤⼩对齐,性能是最好的。有⼏个⽅式可以使通过指针和引⽤访问数据的代码更⾼效:
如果⽤的是Intel编译器,那么⽤#pragma vector aligned或__assume_aligned指令告知编译器数据是对齐的,并且要确保数组确实对齐。
把函数声明为inline。这可能让编译器把12.1b的代码简化为12.1a。
如果可能,使⽤最⼤向量⼤⼩的指令集。AVX或更新的指令集对于对齐有⾮常少的限制。
如果满⾜下列条件,那么⾃动向量化可以⼯作的最好:
1. 使⽤对⾃动向量化⽀持的较好的编译器,例如Gnu、Clang或Intel。
2. 使⽤⾃信版本的编译器。
3. 使⽤编译器选项来启⽤期望的指令集。(windows下的/arch:SSE2,/arch:AVX等;Linux下的-msse2,-
mavx512f等)。
4. 使⽤限制较少的浮点选项。对于Gnu和Clang编译器,使⽤-O3 -fno-trapping-math -fno-math-errno -fno-signed-zeros(-ffast-math⼯作良
好,但是像isnan(x)在-ffast-math下没有作⽤)。
5. 对于SSE2,把数据和⼤的结构体以16对齐,AVX以32,AVX512以64。
6. 循环计数器应该是⼀个常量,它可以被向量中的元素数⽬整数。
7. 如果数组通过指针访问,使得函数声明周期内对齐不可见,那么参考上边调到的建议。
8. 如果数组或结构体通过指针或引⽤访问,如果合适那么显⽰的告知编译器指针没有别名。如果做可以参考编译器⽂档。
9. 在向量元素级别尽量少的使⽤分⽀。
10. 避免向量元素级别的查表操作。
你可以查看汇编输出来确定向量化是否如预期。
由于⼀些原因,编译器可能向量化代码失败,或让代码不必要的复杂化。最重要的组织⾃动向量化的障碍如下:编译器不能消除指向重叠或别名地址的数据指针。
编译器不能消除会产⽣⼀般异常或其他副作⽤的⽆效分⽀。
编译器不知道是否⼀个数组⼤⼩是向量⼤⼩的倍数。
编译器不知道数据结构是否正确对齐。
数据需要重排来适应向量。
代码太复杂。
代码调⽤外部函数,⽽外部函数没有向量版本。
代码使⽤查表。
在对⼀系列连续变量进⾏相同的操作时,即使没有循环,编译器也可以使⽤向量操作。例如:
// Example 12.2
struct alignas(16) S1 {// Structure of 4 floats, aligned
float a, b, c, d;
};
void Func(){
S1 x, y;
x.a = y.a +1.;
x.b = y.b +2.;
x.c = y.c +3.;
x.d = y.d +4.;
}
四个浮点数的结构体适合128位 XMM寄存器。对于例⼦12.2,优化的代码会加载结构体y到⼀个向量寄存器,加上常量寄存器(1, 2, 3, 4),并存储结果到x。
编译器并不总能正确地预测向量化是否有益。编译器可能有#pragma或其他指令⽤来告知编译器哪个循环可以向量化。
使⽤内建函数
预测编译器是否互向量化⼀个循环是困难的。例如:
// Loop with branch
void SelectAddMul(short int aa[],short int bb[],short int cc[]){
for(int i =0; i <256; i++){
aa[i]=(bb[i]>0)?(cc[i]+2):(bb[i]* cc[i]);
}
}
通过使⽤所谓的内建函数显⽰地向量化代码是可能的。这对于例12.4a这种编译器并不总是向量化的代码是有⽤的。也对向量化后的代码不够优化的情形有⽤。
内建函数和汇编有点像,因为⼤多数内建函数会被转换为特定的机器指令。与汇编相⽐,内建函数是易⽤且安全的,因为编译器负责寄存器分配,函数调⽤惯例等。更⼤的好处是,编译器能够通过重排指令、通⽤⼦表达式消除等⽅式优化代码。Gnu、Clang、Intel和Microsoft 编译器⽀持内建函数。Gnu和Clang的性能最佳。
要优化例⼦12.4a的代码,以便可以在8个16位整数寄存器中同时处理8个元素。循环⾥的代码根据不同的指令集可以有不同的实现⽅式。下边的例⼦显⽰了对于SSE2指令集,使⽤内建函数的实现⽅式:
#include<emmintrin.h>// Define SSE2 intrinsic functions
// Function to load unaligned integer vector from array
static inline __m128i LoadVector(void const* p){
return_mm_loadu_si128((__m128i const*)p);
}
// Function to store unaligned integer vector into array
static inline void StoreVector(void* d, __m128i const& x){
_mm_storeu_si128((__m128i *)d, x);
}
// Branch/loop function vectorized:
void SelectAddMul(short int aa[],short int bb[],short int cc[]){
// Make a vector of (0, 0, 0, 0, 0, 0, 0, 0)
__m128i zero =_mm_setzero_si128();
// Make a vector of (2, 2, 2, 2, 2, 2, 2, 2)
__m128i two =_mm_set1_epi16(2);
// Roll out loop by eight to fit the eight-element vectors:
for(int i =0; i<256; i +=8){
// Load eight consecutive elements from bb into vector b:
__m128i b =LoadVector(bb + i);
// Load eight consecutive elements from cc into vector c:
__m128i c =LoadVector(cc + i);
// Add 2 to each element in vector c
__m128i c2 =_mm_add_epi16(c, two);
// Multiply b and c
__m128i bc =_mm_mullo_epi16(b, c);
// Compare each element in b to 0 and generate a bit-mask:
__m128i mask =_mm_cmpgt_epi16(b, zero);
// AND each element in vector c2 with the bit-mask:
c2 =_mm_and_si128(c2, mask);
/
/ AND each element in vector bc with the inverted bit-mask:
bc =_mm_andnot_si128(mask, bc);
// OR the results of the two AND operations:
__m128i a =_mm_or_si128(c2, bc);
// Store the result vector in eight consecutive elements in aa:
StoreVector(aa + i, a);
}
}
上述代码更⾼效,因为它每次处理8个元素,并且避免了循环内的分⽀。例12.4b⽐例12.4a运⾏-7倍,具体取决于循环⾥的分⽀预测。如果SSE4.1指令集可⽤,那么上述代码中的AND-OR操作可以⽤⼀个混合指令替换:
#include<immintrin.h>
// Function to load unaligned integer vector from array
static inline __m128i LoadVector(void const* p){
return_mm_loadu_si128((__m128i const*)p);
}
// Function to store unaligned integer vector into array
static inline void StoreVector(void* d, __m128i const& x){
_mm_storeu_si128((__m128i *)d, x);
}
// Branch/loop function vectorized:
void SelectAddMul(short int aa[],short int bb[],short int cc[]){
// Make a vector of (0, 0, 0, 0, 0, 0, 0, 0)
__m128i zero =_mm_setzero_si128();
// Make a vector of (2, 2, 2, 2, 2, 2, 2, 2)
__m128i two =_mm_set1_epi16(2);
// Roll out loop by eight to fit the eight-element vectors:
for(int i =0; i<256; i +=8){
// Load eight consecutive elements from bb into vector b:
__m128i b =LoadVector(bb + i);
// Load eight consecutive elements from cc into vector c:
__m128i c =LoadVector(cc + i);
// Add 2 to each element in vector c
__m128i c2 =_mm_add_epi16(c, two);
/
/ Multiply b and c
__m128i bc =_mm_mullo_epi16(b, c);
// Compare each element in b to 0 and generate a bit-mask:
__m128i mask =_mm_cmpgt_epi16(b, zero);
// Use mask to choose between c2 and bc for each element
__m128i a =_mm_blendv_epi8(bc, c2, mask);
// Store the result vector in eight consecutive elements in aa:
StoreVector(aa + i, a);
}
}
AVX512指令集提供了更加⾼效的分⽀、使⽤掩码寄存器和条件指令的⽅法:
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论