使用 AVX 和 AVX2 进行数字运算






4.93/5 (42投票s)
本文介绍如何使用英特尔的高级矢量扩展(AVX)内在函数在 C/C++ 中执行数学 SIMD 处理。
引言
2003年,Alex Fr 写了一篇非常出色的文章,解释了如何使用英特尔的流式 SIMD 扩展(SSE)进行 SIMD(单指令,多数据)处理。SSE 是英特尔处理器支持的一组指令集,用于对大块数据执行高速操作。
2008年,英特尔推出了一套新的高性能指令集,称为高级矢量扩展(AVX)。它们执行许多与 SSE 指令相同的操作,但能以更高的速度处理更大的数据块。最近,英特尔在 AVX2 和 AVX512 指令集中发布了更多指令。本文重点介绍如何通过称为内在函数的特殊 C 函数来访问 AVX 和 AVX2 指令。
本文不会介绍整个 AVX/AVX2 内在函数集,而是专注于数学计算。特别是,目标是实现复数乘法。要使用 AVX/AVX2 执行此操作,需要三种类型的内在函数:
- 初始化内在函数
- 算术内在函数
- 置换/重排内在函数
本文将讨论每个类别中的内在函数,并解释它们在代码中的用法。文章末尾将展示如何整合这些内在函数来执行复数乘法。
理解处理器指令和内在函数之间的区别很重要。AVX 指令是执行不可分割操作的汇编命令。例如,AVX 指令 vaddps
将两个操作数相加,并将结果放入第三个操作数中。
要在 C/C++ 中执行该操作,内在函数 _mm256_add_ps()
直接映射到 vaddps
,结合了汇编的性能和高级函数的便利性。一个内在函数不一定只映射到单个指令,但与其他 C/C++ 函数相比,AVX/AVX2 内在函数能提供可靠的高性能。
1. 前期要求
要理解本文内容,您需要对 C 和 SIMD 处理有基本的了解。要执行代码,您需要一个支持 AVX 或 AVX/AVX2 的 CPU。以下是支持 AVX 的 CPU:
- 英特尔的 Sandy Bridge/Sandy Bridge E/Ivy Bridge/Ivy Bridge E
- 英特尔的 Haswell/Haswell E/Broadwell/Broadwell E
- AMD 的 Bulldozer/Piledriver/Steamroller/Excavator
所有支持 AVX2 的 CPU 也都支持 AVX。以下是这些设备:
- 英特尔的 Haswell/Haswell E/Broadwell/Broadwell E
- AMD 的 Excavator
本文讨论的大多数函数都由 AVX 提供。但有少数是 AVX2 特有的。为了区分它们,我在本文的表格中用 (2)
标记了 AVX2 内在函数的名称。
2. 矢量编程概述
AVX 指令通过同时处理大块数值而不是逐个处理来提高应用程序的性能。这些数值块被称为矢量,AVX 矢量最多可包含 256 位数据。常见的 AVX 矢量包含四个 double
(4 x 64 位 = 256)、八个 float
(8 x 32 位 = 256)或八个 int
(8 x 32 位 = 256)。
一个例子将展示 AVX/AVX2 处理的强大功能。假设一个函数需要将一个数组中的八个 float
与第二个数组中的八个 float
相乘,并将结果加到第三个数组中。如果不使用矢量,函数可能如下所示:
multiply_and_add(const float* a, const float* b, const float* c, float* d) { for(int i=0; i<8; i++) { d[i] = a[i] * b[i]; d[i] = d[i] + c[i]; } }
使用 AVX2 的函数如下所示:
__m256 multiply_and_add(__m256 a, __m256 b, __m256 c) { return _mm256_fmadd_ps(a, b, c); }
这个 AVX2 内在函数 _mm256_fmadd_ps
处理了二十四个 float
,但它并不映射到单个指令。相反,它执行三个指令:vfmadd132ps
、vfmadd213ps
和 vfmadd231ps
。尽管如此,它的执行速度很快,比循环遍历单个元素要快得多。
尽管英特尔的内在函数功能强大,但它们让许多程序员感到紧张。这通常有两个原因。首先,数据类型有奇怪的名称,如 __m256
。其次,函数有奇怪的名称,如 _mm256_fmadd_ps
。因此,在详细讨论内在函数之前,我想先讨论一下英特尔的数据类型和命名约定。
3. AVX 编程基础
本文的大部分内容集中于 AVX 和 AVX2 提供的与数学相关的内在函数。但在查看这些函数之前,理解以下三点很重要:
- 数据类型
- 函数命名约定
- 编译 AVX 应用程序
本节将探讨这几点,并提供一个简单的应用程序,该程序从一个矢量中减去另一个矢量。
3.1 数据类型
少数内在函数接受传统数据类型,如 int
或 float
,但大多数操作于 AVX 和 AVX2 特有的数据类型。有六种主要的矢量类型,表1列出了它们。
表1:AVX/AVX2 数据类型
数据类型 | 描述 |
---|---|
__m128 |
包含4个 float 的128位矢量 |
__m128d |
包含2个 double 的128位矢量 |
__m128i |
包含整数的128位矢量 |
__m256 |
包含8个 float 的256位矢量 |
__m256d |
包含4个 double 的256位矢量 |
__m256i |
包含整数的256位矢量 |
每种类型都以两个下划线、一个 m
和矢量的位宽开头。AVX512 支持以 _m512
开头的512位矢量类型,但 AVX/AVX2 矢量不超过256位。
如果矢量类型以 d
结尾,它包含 double
类型;如果没有后缀,它包含 float
类型。看起来 _m128i
和 _m256i
矢量必须包含 int
类型,但事实并非如此。一个整数矢量类型可以包含任何类型的整数,从 char
到 short
再到 unsigned long long
。也就是说,一个 _m256i
可以包含32个 char
、16个 short
、8个 int
或4个 long
。这些整数可以是有符号的或无符号的。
3.2 函数命名约定
AVX/AVX2 内在函数的名称起初可能令人困惑,但其命名约定其实非常直接。一旦你理解了它,你就可以通过看函数名来大致判断其功能。一个通用的 AVX/AVX2 内在函数格式如下:
_mm<位宽>_<名称>_<数据类型>
这个格式的各个部分如下:
<位宽>
标识函数返回的矢量大小。对于128位矢量,此部分为空。对于256位矢量,此部分设为256
。<名称>
描述内在函数执行的操作。<数据类型>
标识函数主要参数的数据类型。
最后一部分 <数据类型>
有点复杂。它标识了输入值的内容,可以是以下任何值:
ps
- 矢量包含float
(ps
代表 packed single-precision,即打包单精度)pd
- 矢量包含double
(pd
代表 packed double-precision,即打包双精度)epi8/epi16/epi32/epi64
- 矢量包含8位/16位/32位/64位有符号整数epu8/epu16/epu32/epu64
- 矢量包含8位/16位/32位/64位无符号整数si128
/si256
- 未指定的128位或256位矢量m128/m128i/m128d/m256/m256i/m256d
- 当输入矢量类型与返回矢量类型不同时,标识输入矢量类型
举个例子,考虑 _mm256_srlv_epi64
。即使你不知道 srlv
的意思,前缀 _mm256
告诉你该函数返回一个256位矢量,而 _epi64
告诉你参数包含64位有符号整数。
再举个例子,考虑 _mm_testnzc_ps
。_mm
暗示该函数返回一个128位矢量。末尾的 _ps
暗示参数矢量包含 float
。
AVX 数据类型以两个下划线和一个 m
开头。函数以一个下划线和两个 m
开头。我常常搞混,所以我想出了一个记住区别的方法:数据类型代表memory(内存),函数代表multimedia operation(多媒体操作)。这是我能想到的最好的记忆方法了。
3.3 构建 AVX 应用程序
要构建使用 AVX 内在函数的应用程序,你不需要链接任何库。但你需要包含 immintrin.h 头文件。这个头文件包含了其他将 AVX/AVX2 函数映射到指令的头文件。
hello_avx.c 中的代码展示了一个基本的 AVX 应用程序是什么样的:
#include <immintrin.h> #include <stdio.h> int main() { /* Initialize the two argument vectors */ __m256 evens = _mm256_set_ps(2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0); __m256 odds = _mm256_set_ps(1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0); /* Compute the difference between the two vectors */ __m256 result = _mm256_sub_ps(evens, odds); /* Display the elements of the result vector */ float* f = (float*)&result; printf("%f %f %f %f %f %f %f %f\n", f[0], f[1], f[2], f[3], f[4], f[5], f[6], f[7]); return 0; }
要构建这个应用程序,你需要告诉编译器你的架构支持 AVX。这个标志取决于编译器,gcc 需要 -mavx
标志。因此,可以用以下命令编译 hello_avx.c 源文件:
gcc -mavx -o hello_avx hello_avx.c
在这个例子中,所有函数都以 _mm256
开头并以 _ps
结尾,所以我希望很清楚所有操作都涉及包含 float
的256位矢量。我也希望很清楚结果矢量中的每个元素都等于1.0。如果你运行这个应用程序,你会看到情况确实如此。
4. 初始化内在函数
在对 AVX 矢量进行操作之前,你需要用数据填充这些矢量。因此,本文讨论的第一组内在函数是用来初始化矢量的。有两种方法可以做到这一点:用标量值初始化矢量,以及用从内存加载的数据初始化矢量。
4.1 使用标量值初始化
AVX 提供了将一个或多个值组合成一个256位矢量的内在函数。表2列出了它们的名称并提供了每个的描述。
有类似的内在函数可以初始化128位矢量,但那些是由 SSE 而不是 AVX 提供的。函数名称的唯一区别是 _mm256_
被替换为 _mm_
。
表2:初始化内在函数
函数 | 描述 |
---|---|
_mm256_setzero_ps/pd |
返回一个用零填充的浮点矢量 |
_mm256_setzero_si256 |
返回一个其字节 被设置为零的整数矢量 |
_mm256_set1_ps/pd |
用一个浮点值填充一个矢量 |
_mm256_set1_epi8/epi16 _mm256_set1_epi32/epi64 |
用一个整数填充一个矢量 |
_mm256_set_ps/pd |
用八个浮点数 (ps) |
|
用整数初始化一个矢量 |
_mm256_set_m128/m128d/ _mm256_set_m128i |
用两个128位矢量 |
_mm256_setr_ps/pd |
用八个浮点数 (ps) 或四个双精度数 (pd) 以相反顺序 |
_mm256_setr_epi8/epi16 _mm256_setr_epi32/epi64 |
以相反顺序用整数初始化一个矢量 |
表中的第一个函数最容易理解。_m256_setzero_ps
返回一个包含八个设置为零的 float
的 __m256
矢量。类似地,_m256_setzero_si256
返回一个其字节被设置为零的 __m256i
矢量。例如,下面这行代码创建一个包含四个设置为零的 double
的256位矢量:
_m256d dbl_vector = _m256_setzero_pd();
名称中包含 set1
的函数接受单个值,并在整个矢量中重复该值。例如,下面这行代码创建一个 __m256i
,其十六个短整型值都设置为47:
_m256i short_vector = _m256_set1_pd();
_set_
或 _setr_
的函数接受一系列值,每个值对应矢量的一个元素。这些值被放置在返回的矢量中,其顺序需要理解。以下函数调用返回一个包含八个整数的矢量,其值从1到8:_m256i int_vector = _m256_set_epi32(1, 2, 3, 4, 5, 6, 7, 8);
你可能期望这些值按给定的顺序存储。但英特尔的架构是小端序的,所以最低有效值(8)首先存储,最高有效值(1)最后存储。
你可以通过将 int_vector
转换为 int
指针并打印存储的值来验证这一点。下面的代码演示了这一点:
__m256i int_vector = _mm256_set_epi32(1, 2, 3, 4, 5, 6, 7, 8);
int *ptr = (int*)&int_vector;
printf("%d %d %d %d %d %d %d %d\n", ptr[0], ptr[1], ptr[2], ptr[3], ptr[4], ptr[5], ptr[6], ptr[7]);
--> 8 7 6 5 4 3 2 1
如果你希望值按给定的顺序存储,你可以使用 _setr_
系列函数来创建矢量,其中 r
大概代表 reverse(反向)。下面的代码展示了这是如何工作的:
__m256i int_vector = _mm256_setr_epi32(1, 2, 3, 4, 5, 6, 7, 8);
int *ptr = (int*)&int_vector;
printf("%d %d %d %d %d %d %d %d\n", ptr[0], ptr[1], ptr[2], ptr[3], ptr[4], ptr[5], ptr[6], ptr[7]);
--> 1 2 3 4 5 6 7 8
有趣的是,AVX 和 AVX2 都没有提供用无符号整数初始化矢量的内在函数。然而,它们提供了对包含无符号整数的矢量进行操作的函数。
4.2 从内存加载数据
AVX/AVX2 的一个常见用法是从内存加载数据到矢量中,处理这些矢量,然后将结果存回内存。第一步通过表3中列出的内在函数完成。最后两个函数前面有 (2)
,因为它们是由 AVX2 而不是 AVX 提供的。
表3:矢量加载内在函数
数据类型 | 描述 |
---|---|
_mm256_load_ps/pd |
从一个对齐的内存地址 加载一个浮点矢量 |
_mm256_load_si256 |
从一个对齐的内存地址加载一个 整数矢量 |
_mm256_loadu_ps/pd |
从一个对齐的内存地址 未对齐的内存地址 |
_mm256_loadu_si256 |
从未对齐的内存地址加载一个整数矢量 整数矢量 |
_mm_maskload_ps/pd _mm256_maskload_ps/pd |
根据掩码加载一个128位/256位 浮点矢量的部分内容 |
(2)_mm_maskload_epi32/64 (2)_mm256_maskload_epi32/64 |
根据掩码加载一个128位/256位 根据掩码加载整数矢量 |
当加载数据到矢量时,内存对齐变得尤为重要。每个 _mm256_load_*
内在函数接受一个必须在32字节边界上对齐的内存地址。也就是说,该地址必须能被32整除。以下代码展示了这在实践中如何使用:
float* aligned_floats = (float*)aligned_alloc(32, 64 * sizeof(float));
... 初始化数据 ...
__m256 vec = _mm256_load_ps(aligned_floats);
任何尝试用 _m256_load_*
加载未对齐数据的行为都会产生段错误。如果数据没有在32位边界上对齐,应该使用 _m256_loadu_*
函数。下面的代码展示了这一点:
float* unaligned_floats = (float*)malloc(64 * sizeof(float));
... 初始化数据 ...
__m256 vec = _mm256_loadu_ps(unaligned_floats);
假设你想使用 AVX 矢量处理一个 float
数组,但数组的长度是11,不能被8整除。在这种情况下,第二个 __m256
矢量的最后五个 float
需要设置为零,这样它们就不会影响计算。这种选择性加载可以通过表3底部的 _maskload_
函数来完成。
每个 _maskload_
函数接受两个参数:一个内存地址和一个与返回矢量元素数量相同的整数矢量。对于整数矢量中最高位为1的每个元素,返回矢量中的相应元素将从内存中读取。如果整数矢量中的最高位为零,则返回矢量中的相应元素将设置为零。
一个例子将阐明这些函数的用法。mask_load.c 中的代码将八个 int
读入一个矢量,最后三个应设置为零。要使用的函数是 _mm256_maskload_epi32
,其第二个参数应该是一个 __m256i
掩码矢量。这个掩码矢量包含五个最高位为1的 int
和三个最高位为零的 int
。代码如下所示:
#include <immintrin.h> #include <stdio.h> int main() { int i; int int_array[8] = {100, 200, 300, 400, 500, 600, 700, 800}; /* Initialize the mask vector */ __m256i mask = _mm256_setr_epi32(-20, -72, -48, -9, -100, 3, 5, 8); /* Selectively load data into the vector */ __m256i result = _mm256_maskload_epi32(int_array, mask); /* Display the elements of the result vector */ int* res = (int*)&result; printf("%d %d %d %d %d %d %d %d\n", res[0], res[1], res[2], res[3], res[4], res[5], res[6], res[7]); return 0; }
如果你在支持 AVX2 的系统上运行此应用程序,它将打印以下结果:
100 200 300 400 500 0 0 0
我想提三点:
- 代码使用
_setr_
函数而不是_set_
来设置掩码矢量的内容,因为它会按照传递给函数的顺序排列矢量的元素。 - 负整数的最高位总是1。这就是为什么掩码矢量包含五个负数和三个正数。
_mm256_maskload_epi32
函数是由 AVX2 而不是 AVX 提供的。因此,要用 gcc 编译此代码,必须使用-mavx2
标志而不是-mavx
。
除了表3中列出的函数外,AVX2 还提供了从内存加载索引数据的 gather 函数。
5. 算术内在函数
数学是 AVX 存在的主要原因,基本操作是加、减、乘、除。本节介绍执行这些操作的内在函数,并介绍 AVX2 提供的新的融合乘加函数。
5.1 加法和减法
表4列出了执行加法和减法的 AVX/AVX2 内在函数。由于饱和问题的考虑,其中大多数操作于包含整数的矢量。
表4:加法和减法内在函数
数据类型 | 描述 |
---|---|
_mm256_add_ps/pd |
两个浮点矢量相加 |
_mm256_sub_ps/pd |
两个浮点矢量相减 |
(2)_mm256_add_epi8/16/32/64 |
两个整数矢量相加 |
(2)_mm236_sub_epi8/16/32/64 |
两个整数矢量相减 |
(2)_mm256_adds_epi8/16 (2)_mm256_adds_epu8/16 |
带饱和的两个整数矢量相加 |
(2)_mm256_subs_epi8/16 (2)_mm256_subs_epu8/16 |
带饱和的两个整数矢量相减 |
_mm256_hadd_ps/pd |
水平相加两个浮点矢量 |
_mm256_hsub_ps/pd |
水平相减两个浮点矢量 |
(2)_mm256_hadd_epi16/32 |
水平相加两个整数矢量 |
(2)_mm256_hsub_epi16/32 |
水平相减两个整数矢量 |
(2)_mm256_hadds_epi16 |
水平相加两个包含短整型的矢量并带饱和 |
(2)_mm256_hsubs_epi16 |
水平相减两个包含短整型的矢量并带饱和 |
_mm256_addsub_ps/pd |
交替加减两个浮点矢量 |
在对整数矢量进行加减运算时,理解 _add_
/_sub_
函数和 _adds_
/_subs_
函数之间的区别很重要。多出的 s
代表 saturation(饱和),当结果需要的内存超过矢量所能存储时就会产生。考虑饱和的函数会将结果钳位到可存储的最小值/最大值。不带饱和的函数在发生饱和时会忽略内存问题。
例如,假设一个矢量包含有符号字节,那么每个元素的最大值是 127 (0x7F)。如果一个操作将 98 和 85 相加,数学上的和是 183 (0xB7)。
- 如果使用
_mm256_add_epi8
相加,饱和将被忽略,存储的结果将是 -73 (0xB7)。 - 如果使用
_mm256_adds_epi8
相加,结果将被钳位到最大值 127 (0x7F)。
再举一个例子,考虑两个包含有符号短整型的矢量。最小值为 -32,768。如果计算 -18,000 - 19,000,数学结果是 -37,000(作为32位整数是 0xFFFF6F78)。
- 如果使用
_mm256_sub_epi16
相减,饱和将被忽略,存储的结果将是 28,536 (0x6F78)。 - 如果使用
_mm256_subs_epi16
相减,结果将被钳位到最小值 -32,768 (0x8000)。
_hadd_
/_hsub_
函数水平地执行加减法。也就是说,它们不是对不同矢量的元素进行加减,而是对每个矢量内部相邻的元素进行加减。结果以交错方式存储。图1展示了 _mm256_hadd_pd
是如何工作的,它水平地对 double
矢量 A 和 B 进行相加。
图1:两个矢量的水平加法
水平地加减元素可能看起来很奇怪,但这些操作在乘以复数时很有用。本文后面会解释这一点。
表4中的最后一个函数 _mm256_addsub_ps/pd
,交替地对两个浮点矢量的元素进行减法和加法。也就是说,偶数位置的元素相减,奇数位置的元素相加。例如,如果 vec_a
包含 (0.1, 0.2, 0.3, 0.4) 且 vec_b
包含 (0.5, 0.6, 0.7, 0.8),则 _mm256_addsub_pd(vec_a, vec_b)
等于 (-0.4, 0.8, -0.4, 1.2)。
5.2 乘法和除法
表5列出了执行乘法和除法的 AVX/AVX2 内在函数。与加减法一样,有专门用于整数操作的内在函数。
表5:乘法和除法内在函数
数据类型 | 描述 |
---|---|
_mm256_mul_ps/pd |
两个浮点矢量相乘 |
(2)_mm256_mul_epi32/ (2)_mm256_mul_epu32 |
乘以包含32位整数的矢量的最低四个元素 |
(2)_mm256_mullo_epi16/32 |
整数相乘并存储低半部分 |
(2)_mm256_mulhi_epi16/ (2)_mm256_mulhi_epu16 |
整数相乘并存储高半部分 |
(2)_mm256_mulhrs_epi16 |
将16位元素相乘形成32位元素 |
_mm256_div_ps/pd |
两个浮点矢量相除 |
如果两个 N 位数在计算机上相乘,结果可能占用 2N 位。因此,_mm256_mul_epi32
和 _mm256_mul_epu32
内在函数只将最低的四个元素相乘,结果是一个包含四个长整数的矢量。图2展示了这是如何工作的:
图2:整数矢量的低位元素相乘
_mullo_
函数与整数 _mul_
函数类似,但它们不是乘以低位元素,而是将两个矢量的每个元素相乘,并只存储每个乘积的低半部分。图3展示了这种情况:

图3:整数相乘并存储低半部分
_mm256_mulhi_epi16
和 _mm256_mulhi_epu16
内在函数类似,但它们存储整数乘积的高半部分。
5.3 融合乘加(FMA)
如前所述,两个 N 位数相乘的结果可以占用 2N 位。因此,当你乘以两个浮点值 a 和 b 时,结果实际上是 round(a * b),其中 round(x) 返回最接近 x 的浮点值。随着进一步操作的进行,这种精度损失会增加。
AVX2 提供了将乘法和加法融合在一起的指令。也就是说,它们返回 round(a * b + c),而不是 round(round(a * b) + c)。因此,这些指令比分别执行乘法和加法提供了更高的速度和精度。
表6列出了 AVX2 提供的 FMA 内在函数,并包括了每个函数的描述。表中的每条指令都接受三个输入矢量,我将它们称为 a、b 和 c。
表6:FMA 内在函数
数据类型 | 描述 |
---|---|
(2)_mm_fmadd_ps/pd/ (2)_mm256_fmadd_ps/pd |
两个矢量相乘,并将乘积加到第三个矢量 (res = a * b + c) |
(2)_mm_fmsub_ps/pd/ (2)_mm256_fmsub_ps/pd |
两个矢量相乘,并从乘积中减去一个矢量 (res = a * b - c) |
(2)_mm_fmadd_ss/sd |
将矢量中的最低元素相乘并相加 (res[0] = a[0] * b[0] + c[0]) |
(2)_mm_fmsub_ss/sd |
将矢量中的最低元素相乘并相减 (res[0] = a[0] * b[0] - c[0]) |
(2)_mm_fnmadd_ps/pd (2)_mm256_fnmadd_ps/pd |
两个矢量相乘,并将取反后的乘积加到第三个矢量 (res = -(a * b) + c) |
(2)_mm_fnmsub_ps/pd/ (2)_mm256_fnmsub_ps/pd |
两个矢量相乘,并将取反后的乘积加到第三个矢量 (res = -(a * b) - c) |
(2)_mm_fnmadd_ss/sd |
将两个最低元素相乘,并将取反后的乘积加到第三个矢量的最低元素 (res[0] = -(a[0] * b[0]) + c[0]) |
(2)_mm_fnmsub_ss/sd |
将最低元素相乘,并从取反后的乘积中减去第三个矢量的最低元素 (res[0] = -(a[0] * b[0]) - c[0]) |
(2)_mm_fmaddsub_ps/pd/ (2)_mm256_fmaddsub_ps/pd |
两个矢量相乘,并从乘积中交替加减 (res = a * b - c) |
(2)_mm_fmsubadd_ps/pd/ (2)_mmf256_fmsubadd_ps/pd |
两个矢量相乘,并从乘积中交替减加 (res = a * b - c) |
如果一个内在函数的名称以 _ps
或 _pd
结尾,那么输入矢量的每个元素都参与运算。如果一个内在函数的名称以 _ss
或 _sd
结尾,那么只有最低的元素参与运算。输出矢量中的其余元素被设置为与第一个输入矢量的元素相等。例如,假设 vec_a
= (1.0, 2.0),vec_b
= (5.0, 10.0),vec_c
= (7.0, 14.0)。在这种情况下,_mm_fmadd_sd(vec_a, vec_b, vec_c)
返回 (12.0, 2.0),因为 (1.0 * 5.0) + 7.0 = 12.0,而 2.0 是 vec_a
的第二个元素。
理解 _fmadd_
/_fmsub_
和 _fnmadd_
/_fnmsub_
内在函数之间的区别很重要。后者在加减第三个输入矢量之前,会对前两个输入矢量的乘积取反。
_fmaddsub_
和 _fmsubadd_
内在函数交替地对第三个矢量的元素进行加减。_fmaddsub_
内在函数对奇数位置的元素相加,对偶数位置的元素相减。_fmsubadd_
内在函数对奇数位置的元素相减,对偶数位置的元素相加。fmatest.c 中的代码展示了如何在实践中使用 _mm256_fmaddsub_pd
内在函数。
#include <immintrin.h> #include <stdio.h> int main() { __m256d veca = _mm256_setr_pd(6.0, 6.0, 6.0, 6.0); __m256d vecb = _mm256_setr_pd(2.0, 2.0, 2.0, 2.0); __m256d vecc = _mm256_setr_pd(7.0, 7.0, 7.0, 7.0); /* Alternately subtract and add the third vector from the product of the first and second vectors */ __m256d result = _mm256_fmaddsub_pd(veca, vecb, vecc); /* Display the elements of the result vector */ double* res = (double*)&result; printf("%lf %lf %lf %lf\n", res[0], res[1], res[2], res[3]); return 0; }
当这段代码在支持 AVX2 的处理器上编译并执行时,打印的结果如下:
5.000000 19.000000 5.000000 19.000000
FMA 指令是由 AVX2 提供的,所以你可能会认为用 gcc 构建应用程序需要 -mavx2
标志。但我发现需要的是 -mfma
标志。否则,我会遇到奇怪的编译错误。
6. 置换和重排
许多应用程序必须重新排列矢量元素以确保操作正确执行。AVX/AVX2 为此提供了许多内在函数,两大类是 _permute_
函数和 _shuffle_
函数。本节将介绍这两种类型的内在函数。
6.1 置换
AVX 提供了返回包含矢量重排元素的矢量的函数。表7列出了这些置换函数并提供了每个函数的描述。
表7:置换内在函数
数据类型 | 描述 |
---|---|
_mm_permute_ps/pd/ _mm256_permute_ps/pd |
根据一个8位控制值从输入矢量中选择元素 |
(2)_mm256_permute4x64_pd/ (2)_mm256_permute4x64_epi64 |
根据一个8位控制值从输入矢量中选择64位元素 |
_mm256_permute2f128_ps/pd |
根据一个8位控制值从两个输入矢量中选择128位块 |
_mm256_permute2f128_si256 |
根据一个8位控制值从两个输入矢量中选择128位块 |
_mm_permutevar_ps/pd _mm256_permutevar_ps/pd |
根据一个整数矢量中的位从输入矢量中选择元素 |
(2)_mm256_permutevar8x32_ps /(2)_mm256_permutevar8x32_epi32 |
使用一个整数矢量中的索引选择32位元素(float 和 int ) |
_permute_
内在函数接受两个参数:一个输入矢量和一个8位控制值。控制值的位决定了输入矢量的哪个元素被插入到输出中。对于 _mm256_permute_ps
,每对控制位通过从输入矢量的上部或下部元素中选择一个来决定一个上部和下部输出元素。这很复杂,所以我希望图4能让操作更清晰一些:
图4:置换内在函数的操作
如图所示,输入矢量的值可能会在输出中重复多次。其他输入值可能根本不被选中。
在 _mm256_permute_pd
中,控制值的低四位在相邻的 double
对之间进行选择。_mm256_permute4x4_pd
与此类似,但使用所有控制位来选择哪个64位元素被放置在输出中。在 _permute2f128_
内在函数中,控制值从两个输入矢量中选择128位块,而不是从一个输入矢量中选择元素。
_permutevar_
内在函数执行与 _permute_
内在函数相同的操作。但它们不是使用8位控制值来选择元素,而是依赖于与输入矢量大小相同的整数矢量。例如,_mm256_permute_ps
的输入矢量是 _mm256
,所以整数矢量是 _mm256i
。整数矢量的高位以与 _permute_
内在函数的8位控制值的位相同的方式进行选择。
6.2 重排
与 _permute_
内在函数一样,_shuffle_
内在函数从一个或两个输入矢量中选择元素,并将它们放置在输出矢量中。表8列出了这些函数并提供了每个函数的描述。
表8:重排内在函数
数据类型 | 描述 |
---|---|
_mm256_shuffle_ps/pd |
根据一个8位值选择浮点元素 |
_mm256_shuffle_epi8/ _mm256_shuffle_epi32 |
根据一个8位值 选择整数元素 |
(2)_mm256_shufflelo_epi16/ (2)_mm256_shufflehi_epi16 |
根据一个8位控制值从两个输入矢量中选择128位块 |
所有 _shuffle_
内在函数都操作于256位矢量。在每种情况下,最后一个参数是一个8位值,它决定了应该将哪些输入元素放置在输出矢量中。
对于 _mm256_shuffle_ps
,只使用控制值的高四位。如果输入矢量包含 int
或 float
,则使用所有控制位。对于 _mm256_shuffle_ps
,前两对比特从第一个矢量中选择元素,后两对比特从第二个矢量中选择元素。图5说明了这是如何工作的:
图5:重排内在函数的操作
要重排16位值,AVX2 提供了 _mm256_shufflelo_epi16
和 _mm256_shufflehi_epi16
。与 _mm256_shuffle_ps
一样,控制值被分成四对位,从八个元素中进行选择。但对于 _mm256_shufflelo_epi16
,这八个元素取自低八个16位值。对于 _mm256_shufflehi_epi16
,这八个元素取自高八个16位值。
7. 复数乘法
复数乘法是一种耗时的操作,在信号处理应用中必须反复执行。我不会深入探讨理论,但每个复数都可以表示为 a + bi,其中 a 和 b 是浮点值,i 是-1的平方根。a 被称为实部,b 被称为虚部。如果 (a + bi) 和 (c + di) 相乘,乘积等于 (ac - bd) + (ad + bc)i。
复数可以以交错方式存储,这意味着每个实部后面跟着虚部。假设 vec1 是一个存储两个复数 (a + bi) 和 (x + yi) 的 __m256d
,而 vec2
是一个存储 (c + di) 和 (z + wi) 的 __m256d
。图6说明了这些值是如何存储的。如图所示,prod
矢量存储了两个乘积:(ac - bd) + (ad + bc)i 和 (xz - yw) + (xw + yz)i。
图6:使用矢量进行复数乘法
- 将
vec1
和vec2
相乘,结果存入vec3
。 - 交换
vec2
的实部/虚部值。 - 将
vec2
的虚部值取反。 - 将
vec1
和vec2
相乘,结果存入vec4
。 - 对
vec3
和vec4
进行水平减法,将结果生成在vec1
中。
complex_mult.c 中的代码展示了如何使用 AVX 内在函数来执行此操作:
#include <immintrin.h> #include <stdio.h> int main() { __m256d vec1 = _mm256_setr_pd(4.0, 5.0, 13.0, 6.0); __m256d vec2 = _mm256_setr_pd(9.0, 3.0, 6.0, 7.0); __m256d neg = _mm256_setr_pd(1.0, -1.0, 1.0, -1.0); /* Step 1: Multiply vec1 and vec2 */ __m256d vec3 = _mm256_mul_pd(vec1, vec2); /* Step 2: Switch the real and imaginary elements of vec2 */ vec2 = _mm256_permute_pd(vec2, 0x5); /* Step 3: Negate the imaginary elements of vec2 */ vec2 = _mm256_mul_pd(vec2, neg); /* Step 4: Multiply vec1 and the modified vec2 */ __m256d vec4 = _mm256_mul_pd(vec1, vec2); /* Horizontally subtract the elements in vec3 and vec4 */ vec1 = _mm256_hsub_pd(vec3, vec4); /* Display the elements of the result vector */ double* res = (double*)&vec1; printf("%lf %lf %lf %lf\n", res[0], res[1], res[2], res[3]); return 0; }
这段代码操作于 double
矢量,但该方法可以轻松扩展以支持 float
矢量。
Using the Code
AVX_examples.zip 存档包含本文中提到的四个源文件。我没有提供任何 makefile,但可以用以下命令编译代码:
gcc -mavx -o hello_avx hello_avx.c
gcc -mavx2 -o mask_load mask_load.c
gcc -mfma -o fmatest fmatest.c
gcc -mavx -o complex_mult complex_mult.c
当然,只有在处理器支持 AVX 或 AVX/AVX2 的情况下,这些应用程序才能正常执行。
关注点
许多开发人员可能会避免学习 AVX/AVX2,希望编译器能执行自动矢量化。自动矢量化是一个很棒的功能,但如果你理解了内在函数,你就可以重新安排你的算法,以更好地利用 SIMD 处理。通过插入 AVX/AVX2 内在函数,我显著提高了我信号处理应用程序的处理速度。
历史
2/20 - 修复了格式和图片链接
4/2 - 修复了几个印刷错误