许多程序员会从各种角落听到SIMD的概念,并视其为某种黑暗法术,甚至叫嚣SIMD是「真正的难点」,C++的奇技淫巧都无法与之相提并论。并且,这些第一次听说SIMD指令的人,往往会兴奋地用那一堆名字都看不明白的函数改写自己的所有代码。如果运气不好,程序会发生段错误 (segment fault),然后得花上半天时间调试。如果运气好能跑起来,他们也可能发现,自己的程序并没得到预期的性能提升,代码还变乱了。
当然了,自己菜能怪谁呢?肯定是哪里还没改好,程序员失落地想。可问题是,有没有一种可能,这种重写根本没必要,SIMD也不是全世界只有你知道的黑魔法?之所以描绘这种心态,是因为以上就是我当年的心路历程。不过也用不着批评,因为了解编译器的人并不多。悲剧的是,至少对C和C++程序员来说,对编译器这个巨兽多了解一些,比从道听途说的优化方法重要太多。
本文将会阐述:为什么(大多数时候)你不该用系统的SIMD函数,如何在不用这些函数的情况下享受SIMD的性能,什么时候SIMD函数才是必要的,以及在使用它们时应该注意什么。
通常我们将这些特殊的函数称作intrinsic,意为内置函数,指你无法自己实现,只能由编译器提供支持的函数。后文会混用SIMD函数和SIMD intrinsic两个概念表达同一意思。
如果你不了解SIMD为何义,可以参考此前一篇介绍SIMD的文章。
SIMD函数难以移植
大部分人使用x86电脑,因此SIMD函数对他们而言就是immintrin.h
或者类似头文件带来的类似_mm_add_epi32
的函数,比如这样:
#include <immintrin.h>
__m128i some_add(__m128i a, __m128i b) {
return _mm_add_epi32(a, b);
}
在x86平台它可以正常编译,但在arm上就会遇到若干奇怪的错误,然后告诉你:
This header is only meant to be used on x86 and x64 architecture
所以在arm平台要写成这样:
#include <arm_neon.h>
int32x4_t some_add(int32x4_t a, int32x4_t b) {
return vaddq_s32(a, b);
}
在PowerPC平台又要写成这样:
#include <altivec.h>
vector int some_add(vector int a, vector int b) {
return vec_add(a, b);
}
这还只是最简单的一类整数加法!
即使你说,好,我不知道什么PowerPC,我也憎恨天天碰瓷伟大Wintel联盟的MacBook,我这辈子的代码就和x86绑死了。直到有一天,你听说了SIMD可以有512位那么长,然后复制粘贴下这样的代码:
#include <immintrin.h>
__m512i some_add(__m512i a, __m512i b) {
return _mm512_add_epi32(a, b);
}
结果发现你的i7-6820HQ无法运行这个程序,指令不合法 (illegal instruction) 了!然后你还是要为不同版本的Intel/AMD CPU写不同的#ifdef
。甚至AVX512还分为若干个小的指令集,某些CPU只能支持其中一部分,而Intel还在不断更新这套指令集 (如最新的AVX10)。
这样看来,你需要一套机制,帮你自动处理不同平台支持的SIMD接口、长度、类型,否则生命就白白浪费在此,太令人崩溃了。
SIMD函数难以写对
面向SSE指令集,如果你想用intrinsic写一个简单的数组求和,需要写成这样:
#include <immintrin.h>
int sum(int *arr, int size) {
const int step = sizeof(__m128i) / sizeof(int);
int buf[step] = {};
__m128i result = _mm_load_si128((__m128i*)buf);
for (int i = 0; i < size / step; ++i) {
__m128i addend = _mm_load_si128(
(__m128i*)(arr + i * step));
result = _mm_add_epi32(result, addend);
}
_mm_store_si128((__m128i*)buf, result);
for (int i = 1; i < step; ++i)
buf[0] += buf[i];
for (int i = size / step * step; i < size; ++i)
buf[0] += arr[i];
return buf[0];
}
你需要在一个buf和SIMD类型里来回操作以初始化和获得单个元素 (SSE没有broadcast指令来用int初始化__m128i),时刻牢记SIMD长度,load和store时要强制转换指针类型,不把epi32写成epi16 (写错了不会有任何编译报错),非对齐的状态要记得用_mm_loadu_si128
而不是load,最后的部分要手动加上。
……这实在不是什么好玩的东西。
SIMD函数会阻碍编译优化
再次以上面的代码为例。简单粗暴的版本写成这样:
int sum(int *arr, int size) {
int result = 0;
for (int i = 0; i < size; ++i)
result += arr[i];
return result;
}
和以上的版本相比,哪个更快呢?以clang编译器为例,在O2和O3优化下,这个naive版本出乎意料地更快;而在O0和O1,这个手写SIMD的版本更快。因为O2下,编译器会启用「自动向量化」,生成的代码中依然使用了SIMD指令。
要注意,x86平台部分CPU支持AVX2或AVX512指令集,支持32或64字节的整数SIMD,在这种情况下,naive版本的代码会被编译器自动优化到最优的长度,而手写版本依然只能使用16字节的指令。
而即使同样采用16字节SIMD,编译器优化的版本依然比手写版本更快。
SIMD函数破坏代码可读性
经过前面两个小节后,这部分就不用多说了。互联网上有非常多关于编译优化的讨论,有人认为编译器比人聪明,有人认为人比编译器聪明。我的看法是:大多数情况下,编译器比程序员更了解硬件,但程序员也比编译器更了解程序意图。因此最重要的是尽量告知编译器程序意图,而不是尝试代替编译器做优化。即使需要亲手优化的时候,也要尽量保持软件工程的原则。
人需要做「抽象」而不是「具体」层次的优化。如果某个层次的优化也逐渐变得机械化,此时应该加入新一层编译器,而让人处理更高层次的问题。现在流行的AI编译器就是这个思路。
更好的方法
对于类似以上sum的函数,编译器的自动向量化相当可靠,要注意的就是设置好目标平台,比如大部分编译器在x86上不会默认启用AVX512特性。
C++环境和C有点不一样。因为C++有复杂的语义,某些常见的操作因为可能抛异常或者内部实现复杂的原因,并不能被编译器优化为简单的内存操作。比如:
std::vector<int> sum2(int *arr1, int *arr2, int size) {
std::vector<int> result;
for (int i = 0; i < size; ++i)
result.push_back(arr1[i] + arr2[i]);
return result;
}
这个函数看似简单,但编译器无法为此生成向量化指令,因为push_back实际是个复杂的操作,会涉及到扩容和内存移动,还有可能抛出异常。写成下面这样就可以了:
std::vector<int> sum2(int *arr1, int *arr2, int size) {
std::vector<int> result(size);
for (int i = 0; i < size; ++i)
result[i] = arr1[i] + arr2[i];
return result;
}
SIMD的本质是CPU额外提供的一组加速指令,编译器做的事情也是将高级语言转换成汇编指令。所以越贴近高级语言,越能和代码融合的写法,对编译器越友好。
GCC和Clang都提供了向量类型扩展,这些扩展支持主要的算术、比较和读取写入操作。尽管向量长度需要作为常量指定,但通过宏的方式依然可以做到可扩展。这类扩展可以跨平台。实际上,打开immintrin.h或者arm_neon.h可以发现,它很可能就是利用编译器扩展实现的。
此外,编译器还支持特殊的的pragma命令,对特定循环配置向量化属性。
C++方面,从C++26开始引入了std::simd
,可以以C++的风格实现一些SIMD操作。但不用等到C++26全面启用,较新版的libstdc++ (GNU)和libc++ (LLVM)已经率先支持std::experimental::simd
。如果你是想用特殊的指令加速一些科学计算算法,直接用Eigen这类现成库也许更适合。
此外,同样不要试图用SIMD手工实现一些标准库函数(如memmove、memcmp)来加速,因为它们已经这样实现了,并且标准库运行时会根据平台支持的特性自动选择最优版本。
什么时候需要用intrinsic
以上提到的向量化思路适用于多数单纯想用SIMD加速简单运算的场景。某些指令集支持一些复杂的加速指令,比如带mask的移动或者矩阵运算。编译器很难识别同样意图的C/C++代码并转换到对应指令,这种时刻就需要手写intrinsic了。而且不只是intrinsic,编译器本身也提供一组builtin函数。不过最好还是慎用,并且将用到的部分集中在一两处。
利用各平台的intrinsic,可以高效地实现一些意想不到的操作。最典型的例子是simdjson,其中UTF-8验证和查找token等都利用了SIMD指令做加速。有机会可以在以后的文章里讨论某类场景的实现。
除非在写操作系统内核这样的项目,或者有特殊的调试需求,否则尽量用编译器builtin而不是内联汇编。内联汇编会破坏可读性,还会阻碍寄存器分配和指令调度这类编译器优化。
总结
编译器向量化可以涵盖最基本的场景,而且会比手写intrinsic表现得更好。在C++里要注意容器类型的隐藏语义。
需要手动向量化的场景,使用经过考验的第三方库,或者编译器的语言扩展。
只有在实现非常取巧的操作时,才适合直接调用intrinsic,即使是这样也应该把相关代码集中到少数几个函数里便于移植。
不要使用内联汇编。
发表回复