1、提示与技巧
-
访问内存的成本非常高,一次缓存未命中可能会耗费100~300个周期。L3缓存加载需要40~50个周期,L2缓存大约需要10个周期,即使L1缓存的访问速度也明显慢于寄存器。所以要尽量保持数据结构对SIMD友好,优先选择
std::vector
、CAtlArray
、eastl::vector
等容器,按照顺序读取数据以提高缓存命中率。如果数据比较稀疏,可以将其组织为小型密集块的稀疏集合,其中每个块的大小至少为1个SIMD寄存器的大小。如果需要遍历链表或图,同时对每个节点进行计算,可以使用_mm_prefetch
函数来将数据预先加载到缓存中。 -
为了获取最佳性能,内存访问需要内存对齐。更具体地说,内存访问不应该超出缓存行(cache line)的边界。缓存行的大小为64字节,且按64字节地址对齐。当SIMD向量正确对齐(SSE向量16字节对齐、AVX向量32字节对齐)时,内存访问将保证只触及一个缓存行。
-
在处理成对的32位浮点数(如2D平面中的FP32向量)时,可以用一条FP64数的指令加载或存储两个标量,我们只需要对指针进行类型转换,并对向量使用
_mm_castps_pd
/_mm_castpd_ps
函数即可。同样,我们也可以随意使用FP64洗牌/广播函数来移动这些向量中的FP32值对。 -
C++有很多优秀的矢量化库,例如Eigen、DirectXMath等,它们已经实现了相当复杂的功能,有时候直接使用它们就好了,没必要再重复造轮子。
-
不要在函数或方法中写入类似
static const __m128 x = something();
这样的语句,因为在现代C++中,这种结构保证了线程安全,而为了支持语言标准,编译器必须输出一些模板代码,这些代码可能会有锁和分支。我们可以将该值放在全局变量中,这样它们就能在main()
开始运行前被初始化,或者在DLL的LoadLibrary
返回前被初始化。或者,也可以将该值放在一个本地非静态常量中。 -
如果使用VC++,请在频繁调用的循环体中对性能敏感的SIMD函数使用
__forceinline
修饰符。指令经常会包含幻数(magic number),或是不随循环而改变的常量。与标量代码不同的是,SIMD常量通常来自内存而不是指令流,当编译器被告知__forceinline
时,它可以加载这些SIMD常量一次,并在循环过程中将它们保存在向量寄存器中(除非寄存器短缺导致它们被放到内存)。如果没有内联,代码将在执行函数时重新加载这些常量。VC++的内联功能对于标量代码是适用的,但对SIMD代码却基本不起作用,所以需要使用__forceinline
来强制内联。GCC和Clang的内联功能会更好,但强制内联有时候仍有帮助,可以将__forceinline
定义为宏:#define __forceinline inline __attribute__((always_inline))
-
如果要根据硬件支持的指令集来动态选择函数的实现版本,请在调用函数指针或虚类方法时使用
__vectorcall
调用约定,这样函数会尽量在向量寄存器中传递参数与返回值。
2、参考资料
-
Agner Fog的网站上有很多关于C++优化的资源。
-
uops网站可以方便地查询SIMD指令的性能数据。
-
x86/x64 SIMD Instruction List可以方便地按功能和数据类型查询对应的SIMD指令。
-
一文读懂SIMD指令集 目前最全SSE/AVX介绍比较全面地介绍了SIMD的基础知识,本系列随笔的第一章内容主要参考了这篇博文。
-
SIMD for C++ Developers比较全面地介绍了各种常用的SIMD指令,而且作者也分享了很多他在SIMD编程领域的经验与技巧,本系列随笔的第二章至最后一章内容主要参考了这份资料。