开发环境:
CPU: Inter(R) Core(TM) i3-2330M
操作系统: ubuntu 14.04 x86_64 GNU/Linux
编译器: gcc 4.8.4
glibc版本: libc-2.19
sse介绍:
SSE的全称是 Sreaming SIMD Extensions, 它是一组Intel CPU指令,用于像信号处理、科学计算或者3D图形计算一样的应用。其优势包括:更高分辨率的图像浏览和处理、高质量音频、MPEG2视频、同时MPEG2加解密;语音识别占用更少CPU资源;更高精度和更快响应速度。使用SSE指令集,主要是通过8个128-bit的寄存器:xmm0到xmm7 来完成的。在Linux下可以使用cat /proc/cpuinfo来查看CPU支持哪些指令集。 SSE的指令集是X86架构CPU特有的,对于ARM架构、MIPS架构等CPU是不支持的,所以使用了SSE指令集的程序,是不具备可移植标准的。
ARM架构和X86架构有什么区别:
最大的区别就是ARM架构使用的RSIC(精简指令集),X86使用的是CSIC(复杂指令集),ARM架构的CPU一般是用于低功耗领域,在这个领域就是NO.1,如手机、平板、工控领域,嵌入式行业。X86架构的CPU,功耗比较高,但是计算性能强劲。一般用于个人PC、服务器、需要复杂的计算领域,如向量计算,矩阵运算。市面上几乎所有的手机使用的CPU,都是ARM架构。咦….,等等。IPhone不是使用的是苹果的A系列的CPU吗?尼玛,你在逗我吗?我那么高大上的手机,怎么能跟Android那种垃圾手机,使用的是一样架构的CPU呢?恩~,别慌,确实是这样子的。虽然苹果、高通、MTK、三星都有自己的CPU,但是都是使用的是ARM架构的。简单的说,就是这些厂商通过使用ARM指令集体系结构,来设计自己的CPU、外围的IC(如:音频、WIFI、蓝牙)和对指令集进行优化,最后形成自己的CPU。ARM架构一般有ARMv8架构、ARMv7-A架构、ARMv6架构、Cortex-A系列架构、Cortex-M系列架构,不同架构的CPU有不同的用途。所以不管是高大上的苹果手机还是烂大街的Android手机,都是使用的是ARM架构的CPU。
ARM已经被软银收购了,所以抵制日货,这比较麻烦。
使用SSE指令集进行程序优化
Talk is cheap, Show me the code
普通版
1 | int normal_add(int *a, size_t n) { |
这个函数有点简单,简单到以至于无需要过多华丽的语言来修饰。它就是一个求和的函数。下面我们来看一下测试程序:
1 | class Timer { |
这个测试程序定义一个Timer类,用于计算normal_add
函数的时间,单位为毫秒。通过命令行传入的参数,申请内存。posix_memalign
函数表示在堆中申请16字节对齐的内存。至于为什么要16字节对齐,我们下面会用到。然后把数组中的每个值设置为5。调用normal_add
函数进行计算。下面我们进行测试:
编译:g++ normal_add.cc
运行:./a.out 10000000
结果:return: 50000000, normal_add: 41ms
可以看到,5加上10000000的结果为50000000,normal_add
函数耗时为41ms。是的,没问题,非常完美,你应该为自己的设计感觉到骄傲。下面我们对这个程序进行优化。
循环版
在程序优化中有一种经常使用的方法:循环展开。循环展开可以降低循环开销,提高指令级并行性能,此处使用四路展开
下面我们把上面的程序在循环内分4路进行展开,代码如下:
1 | int normal_add_loop4(int *a, size_t n) { |
修改上面的测试程序为:
1 | { |
进行测试:
编译:g++ normal_add_loop4.cc
运行:./a.out 10000000
结果: return: 50000000, normal_add_loop4: 33ms
结果为50000000,normal_add_loop4
函数耗时为33ms。比上面的normal_add
快了8ms。下面我们使用SSE指令集对程序进行优化。
SSE版
使用SSE指令,首先要了解这一类用于进行初始化加载数据以及将暂存器的数据保存到内存相关的指令,大多数SSE指令是使用的xmm0到xmm8的暂存器,那么使用之前,就需要将数据从内存加载到这些暂存器。
load(set)系列,用于加载数据,从内存到暂存器。
__m128i _mm_load_si128(__m128i *p); __m128i _mm_loadu_si128(__m128i *p);
store系列,用于将计算结果等SSE暂存器的数据保存到内存中。
void _mm_store_si128 (__m128i *p, __m128i a); void _mm_storeu_si128 (__m128i *p, __m128i a);
_mm_load_si128
函数表示从内存中加载一个 128bits 值到暂存器,也就是 16字节, 注意: p必须是一个16字节对齐的一个变量的地址。返回可以存放在代表寄存器的变量中的值。_mm_loadu_si128
函数和_mm_load_si128
一样的,但是不要求地址p是16字节对齐。
store系列的_mm_store_si128
和_mm_storeu_si128
函数,与上面的load系列的函数是对应的。表示将__m128i
变量a的值存储到p所指定的地址中去。
字节对齐是什么东西?
首先字节对齐不是东西,字节对齐是一个古老而又神秘的传说,它起源于遥远的上古时代,遥远有多远,额~,总之就是很久以前,在那个你或者我都还没出生的年代。如果你不知道什么是字节对齐,你就不能提高CPU高速缓存的命中率,也就不能对程序优化,更谈不上用什么指令集。字节对齐,比你想象中还要常见,比如说:
1 | struct St { |
sizeof(s)的值等于多少呢? 在自己的计算机上看一下吧,如果它和你想象中的不一样,那么。。。额~,也没什么。下面我们用sse指令集对上面的程序进行优化,代码如下:
1 | int sse_add(int *a, size_t n) { |
注意: 上面的代码只针对是 int为4个字节 ,如果你的机器上int为8个字节,则不可用。用sizeof(int)
看一下你机器中的输出的值吧。我们使用是_mm_load_si128
函数, 所以传入的地址a必须是16字节对齐的,所以上面的测试程序用了posix_memalign
申请了16字节对齐的内存(还记得吗?)。
修改上面的测试程序为:
1 | { |
进行测试,编译时记得加头文件nmmintrin.h,编译选项-msse4:
编译: g++ -msse4 sse_add.cc
运行: ./a.out 10000000
结果: return: 50000000, sse_add: 17ms
没问题,结果正确,sse_add
用了17ms,比上面的normal_add_loop4
函数快了16ms。下面我们使用循环4路展开对上面的sse_add
进行优化,代码为:
1 | int sse_add_loop4(int *a, int n) { |
修改上面的测试程序为:
1 | { |
进行测试,编译时记得加头文件 nmmintrin.h ,编译选项 -msse4 :
编译: g++ -msse4 sse_add_loop4.cc
运行: ./a.out 10000000
结果: return: 50000000, sse_add_loop4: 10ms
结果正确,用于10ms,比上面的sse_add快了7ms。
由此可以看出,sse指令集确实可以提高程序的性能。但是上面的函数有点无聊,有点不切实际,5*10000000不就是等于50000000,搞那么复杂干嘛。是的,的确挺无聊的,无聊的函数、无聊的人,无聊的人写的无聊的代码,~恩。。。别浪,春风十里不如你。下面我们接着往下看:
字符串模式匹配
字符串的模式匹配,也就是在主串s中,找到一个相等的目标串t。
如: s = “cddcdc”, t = “cdc”, 则可以匹配。 t = “cdcc” 时,则不能匹配。
于是乎,我们可以采取暴力搜索进行匹配,代码如下:
1 | int normal_strstr(const char *src, const char *dest) { |
暴力搜索是最费时间的,时间复杂度为O(n*n), 我们写个测试程序进行测试,代码如下:
1 | int main(int argc, char *argv[]) { |
这个测试函数的,主要是申请一块10M的buf,把要匹配的字符message=放入buf的最后,然后调用normal_strstr 500次。下面我们用gprof进行测试。下面进行测试:
编译: g++ normal_strstr.cc -pg -lc
运行: ./a.out
查看gprof的测试结果: gprof ./a.out gmon.out -p
输出:
% cumulative self self total
time seconds seconds calls ms/call ms/call name
100.74 56.93 56.93 500 113.86 113.86 normal_strstr(char const*, char const*)
可以看到normal_strstr
执行了500次,每次耗费的时间为113.86ms,总的时间为56.93s。
在计算机科学领域, 有一门课程,叫做数据结构和算法,里面有讲到对字符串模式匹配进行优化的算法,叫做KMP算法。
KMP算法
至于kmp算法的原理,我也忘了差不多。这东西,看了也不一定懂、懂了也不一定会、会了也不一定会用、用了你才知道你不懂。下面是KMP算法的代码:
1 | static void get_next(const char* dest, int next[]) { |
我们把上面的测试程序改为:
1 | for(int i = 0; i < 500; ++i) { |
进行测试:
编译: g++ kmp_strstr.cc -pg -lc
运行: ./a.out
查看gprof的测试结果: gprof ./a.out gmon.out -p
输出:
% cumulative self self total
time seconds seconds calls ms/call ms/call name
100.52 62.07 62.07 500 124.14 124.14 kmp(char const*, char const*)
可以看到kmp算法执行了500次,每次耗费的时间为124.14ms,总的时间为62.07s,擦。。。比我们那个normal_strstr
函数还耗时间,不是说kmp算法是优化字符串匹配的吗?童话里都是骗人的。是的,别慌。KMP算法的确可以优化字符串匹配,但是我们上面的函数只是实现了算法,并没有做优化,比如每次计算都要调用get_next
计算回溯值,函数调用是需要时间的,还有就是子串的相似度比较低,函数也没进行优化,这都会影响性能,可见能写出一个好的算法多么艰辛,更别说从头到尾设计一个算法。在这里致敬大师。下面我们使用SSE指令集对字符串匹配进行优化.
使用SSE优化字符串匹配
在 Intel 的 SSE4.2 指令集中,有一个 pcmpistrm 指令,它可以一次对一组16个字符与另一组字符作比较,也就是说一个指令可以作最多16×16=256次比较。
要使用SSE对字符串匹配进行优化,需要用到下面几个函数:
参考:https://software.intel.com/sites/landingpage/IntrinsicsGuide/#text=_mm_cmpistri&expand=914
int _mm_cmpistri (m128i a, m128i b, const int imm8)
Compare packed strings with implicit lengths in a and b using the control in imm8, and store the generated index in dst.int _mm_cmpistrz (m128i a, m128i b, const int imm8)
Compare packed strings with implicit lengths in a and b using the control in imm8, and returns 1 if any character in b was null, and 0 otherwise.int _mm_cmpistrs (m128i a, m128i b, const int imm8)
Compare packed strings with implicit lengths in a and b using the control in imm8, and returns 1 if any character in a was null, and 0 otherwise.
使用SSE优化字符串匹配代码如下:
1 | static void unit_test(void) { |
上面的代码大意是:通过_mm_cmpistrs
函数判断,目标串的长度是否大于16字节,如果是小于16字节,那么目标串可以一次性加载sse的暂存器中,当大于16字节时,目标串不能一次性加载到sse的暂存器。通过_mm_cmpistri
函数判断,目标串在主串中匹配的位置,如果返回0,则表示目标串和主串的16字节完全匹配,继续匹配,剩余的字节。如果返回非0则表示,目标串在子串没有完全匹配。返回16为,目标串和主串完全不匹配。其他值为,主串的后缀与目标串匹配的个数,如cmp=5
,表示主串的后面5个字符与目标串的前面5个字符相匹配。
修改测试程序为:
1 | for(int i = 0; i < 500; ++i) { |
进行测试:
编译: g++ -msse4 sse_strstr.cc -pg -lc
运行: ./a.out
查看gprof的测试结果: gprof ./a.out gmon.out -p
输出:
% cumulative self self total
time seconds seconds calls ms/call ms/call name
100.44 7.67 7.67 500 15.35 15.35 sse_strstr(char const*, char const*)
可以看到sse_strstr执行了500次,每次耗费的时间为15.35ms,总的时间为7.67s,远远比我们上面写的kmp算法(62.07s), normal_strstr(56.93s)快得多,几乎可以说是完胜了。这个函数还不是最优的,还可以进行优化,上面我们说了,SSE的寄存器是128位,16字节对齐,上面的程序并没有考虑16字节,算法也不是使用的是KMP算法。如果每次加载的数据是16对齐,而且使用KMP算法的话,程序的性能是最快的。下面我们测试一下,标准库的字符串匹配。
strstr函数
strstr
函数是glibc自带的字符串匹配的函数,下面我们对它进行性能测试。如果把上面的测试程序改成这样:
1 | for(int i = 0; i < 500; ++i) { |
在10M的内存中,调用500次的strstr函数,如果你去查看gprof文件或反编译去看汇编代码的话,实际上,上面的调用只会调用一次的strstr函数,也就是说编译器会帮你优化。虽然你调用了500次,但是编译器很聪明,只会帮你调用一次strstr函数。额~~~,那怎么整? 居然只能调用1次,那就让它调用1次吧。我们把上面的BUF改大一点就是了,改为100M进行测试。代码如下:
1 | int main(int argc, char *argv[]) { |
进行测试:
编译: g++ test_strstr.cc
运行: time ./a.out
结果:
real 0m0.092s
user 0m0.028s
sys 0m0.036s
上面我们用了time测试运行完成的时间,可以看到,整个程序总共花费了92ms, 用户空间使用了28ms, 内核空间使用36ms。
我们把上面的strstr
函数,替换为我们上面写的sse_strstr
函数,进行测试:
编译: g++ -msse4 test_sse_strstr.cc
运行: time ./a.out
结果:
real 0m0.231s
user 0m0.176s
sys 0m0.032s
可以看到,我们使用sse_strstr
函数,整个程序总共花费了231ms, 用户空间使用了176ms, 内核空间使用32ms。被glic库的strstr
秒杀。
我们再测试一下,C++中的std::search
函数,进行测试:
编译: g++ test_std_search.cc
运行: time ./a.out
结果:
real 0m0.414s
user 0m0.368s
sys 0m0.028s
可以看到,我们使用std::search
函数,整个程序总共花费了414ms, 用户空间使用了368ms, 内核空间使用28ms, 比我们的sse_strstr
函数还慢,被glic库的strstr再次秒杀。
我们在来看一下string类中的find的函数,注意上面我们申请内存是字符串,要转换为string类,需要使用string(src, BUF_SIZE)
操作,但是这个操作也是需要耗费时间的。所以我们使用最上面的Timer类来粗劣的统计一下,string类在100M的内存进行查找需要耗费的时间。
1 | { |
进行测试:
编译: g++ test_string_find.cc
运行: ./a.out
结果为: string find: 889ms
可以看到在100M进行查找目标串, string类find的函数大概需要 889ms,跟上面的几个函数没有任何的可比性。
strstr函数为什么那么快?
strstr函数是glibc库的函数,glibc在X86的环境下已经默认是已经支持SSE优化的,不同版本的发型商,可能会有所不同,使用的glibc版本库不一样,也可能不同,我这台机器使用的是libc-2.19,如果你去看glic-2.19源码的strstr函数,可以看到它是有支持SSE4.2优化的,而且使用的就是我们上面所说的16字节对齐的KMP算法,进行优化的。所以它会那么快,故,能写标准库的都不是一般人,在这里再次致敬大师,因为你们的努力,让这个世界变得更加美好。如果你的机器的glibc库是已经支持SSE优化的,那么字符串匹配是最快的,远远比C++的string类,和std::search
函数快,当然还有其他函数也是支持SSE的,如strchr
总结:
使用sse进行程序优化,能带来性能的提升,但是也会使代码的可移植性、维护性变差。因本人技术水平有限,上面的分析(仅代表个人意见)可能有存在错误的地方,欢迎指正。
参考文章
http://blog.csdn.net/zoucharming/article/details/49766155
http://www.cppblog.com/djxzh/archive/2011/10/27/159192.aspx
https://zhuanlan.zhihu.com/p/20037058
https://msdn.microsoft.com/it-it/library/szdcdfc2(v=vs.100).aspx.aspx)
http://www.cnblogs.com/zyl910/archive/2012/10/22/simdsumfloat.html
http://www.cnblogs.com/tibetanmastiff/p/4394608.html