以下内容翻译自:
Practical SIMD Programing–Jacco Bikker 2017
Basics of SIMD Programming
SIMD 操作能够用一条指令处理多个数据,广泛用于多媒体应用中的 3D 图形和音频/视频处理。SIMD全称Single Instruction Multiple Data,单指令多数据流,能够复制多个操作数,并把它们打包在大型寄存器的一组指令集。一条指令操作多个数据.是CPU基本指令集的扩展,也就是说一次运算指令可以执行多个数据流,这样在很多时候可以提高程序的运算速度。
1 SIMD Concepts
SIMD 是 Single Instruction Multiple Data 的缩写,而 SIMD 操作一词是指一种计算方法,可以用一条指令处理多个数据。相比之下,使用一条指令来处理每个单独数据的传统顺序方法称为标量操作。
以一个简单的求和为例,标量和 SIMD 操作之间的区别如下所示。
对于传统的标量运算,必须依次执行四个加法指令才能获得如图 (a) 所示的和。同时,SIMD 仅使用一条加法指令即可达到相同的结果,如图 (b) 所示。SIMD 操作需要更少的指令来处理给定的大量数据,其效率高于标量操作。
SIMD 操作不能用于以不同方式处理多个数据。图 2.3 给出了一个典型的例子,其中一些数据要相加,而另一些数据要减去、相乘或相除。
CPU 使用寄存器来存储要操作的数据。典型的寄存器存储 32 或 64 位,并保存单个标量值。CPU 指令通常对两个操作数进行操作。考虑以下代码片段:
vec3 velocity = GetPlayerSpeed();
float length = velocity.Length();
计算该向量长度需要大量的标量操作:
x2 = velocity.x * velocity.x
y2 = velocity.y * velocity.y
z2 = velocity.z * velocity.z
sum = x2 + y2
sum = sum + z2
length = sqrtf( sum )
矢量寄存器存储 4 个 (SSE) 或 8 个 (AVX) 标量。这意味着 C++ 向量在汇编程序级别仍然是一个向量:我们不是将三个单独的值存储在三个寄存器中,而是将四个值(x、y、z 和一个虚拟值)存储在一个向量寄存器中。而且,我们不是分别对 x、y 和 z 进行平方,而是使用单个 SIMD 指令对三个值(以及虚拟值)进行平方。
这个简单示例说明了我们在编写 SIMD 代码时需要处理的一些问题:
- 在对三分量向量进行操作时,我们没有使用向量处理器的全部计算潜力:我们浪费了 SIMD 寄存器中 25%(对于 SSE)或 62.5%(对于 AVX)的“槽”。
- 在向量寄存器中存储三个标量不是免费的:成本取决于我们稍后将讨论的许多因素。这给计算增加了一些开销。
- 最后一行的平方根仍然对单个值执行。因此,尽管这是最昂贵的线路,但它并没有从矢量硬件中受益,从而限制了我们的收益。
有一种可靠的方法可以减轻这些担忧。假设我们的应用程序实际上是一个四人游戏:
for( int i = 0; i < 4; i++ )
{
vec3 velocity = GetPlayerSpeed();
float length = velocity.Length();
}
在这种情况下,我们可以同时对四个向量进行操作:
x4 = GetPlayerXSpeeds();
y4 = GetPlayerYSpeeds();
z4 = GetPlayerZSpeeds();
x4squared = x4 * x4;
y4squared = y4 * y4;
z4squared = z4 * z4;
sum4 = x4squared + y4squared;
sum4 = sum4 + z4squared;
length4 = sqrtf4( sum4 );
请注意,我们已将 C++向量概念与 SIMD 向量完全解耦:我们只需使用 SIMD 向量并行执行原始标量功能四次。现在每一行都使用一条 SIMD 指令,效率为 100%(当然,我们需要 8 名玩家来进行 AVX ……),甚至现在计算平方根也是为了四个数字。
这里需要注意一件重要的事情:为了使前三行有效,玩家速度必须已经以“SIMD-friendly”格式存储,即:xxxx、yyyy、zzzz。像这样组织的数据可以直接复制到向量寄存器中。
这也意味着我们不可能期望编译器自动为我们执行此操作。高效的 SIMD 代码需要高效的数据布局;这必须手动完成。
2 Data Parallelism
具有四个玩家速度的示例将浪费 AVX 机器上 50% 的计算潜力。显然,我们需要更多的工作。高效的 SIMD 代码需要大量数据并行性,其中针对大量输入执行一系列操作。达到 100% 的效率要求输入数组大小是 4 或 8 的倍数;然而,对于任何重要的输入数组大小,我们都非常接近这个最佳值,并且 AVX 性能只是 SSE 性能的两倍。
对于数据并行算法,SIMD 寄存器中的每个标量都保存一个“线程”的数据。我们调用寄存器通道中的插槽。输入数据称为流。
如果您是 C++ 程序员,您可能熟悉基本类型:char、short、int、float 等。它们中的每一个都有特定的大小:char 为 8 位,short 为 16 位,int 和 float 为 32 位。位只是位,因此 float 和 int 之间的区别在于解释。这允许我们做一些讨厌的事情:
int a;
float& b = (float&)a;
这将创建一个整数和一个指向 a 的浮点引用。由于变量 a 和 b 现在占用相同的内存位置,因此更改 a 会更改 b,反之亦然。实现此目的的另一种方法是使用union:
union { int a; float b; };
同样,a 和 b 驻留在同一内存位置。这是另一个例子:
union { unsigned int a4; unsigned char a[4]; };
这一次,一个由四个字符组成的小数组与 32 位整数值 a4 重叠。我们现在可以通过数组 a[4] 访问 a4 中的各个字节。请注意,a4 现在基本上有四个 1 字节的“通道”,这有点类似于我们使用 SIMD 得到的。我们甚至可以将 a4 用作 32 个 1 位值,即存储 32 个布尔值。
SSE 寄存器大小为 128 位,如果用于存储四个浮点数,则命名为 __m128,对于整数,则命名为 __m128i。为方便起见,我们将 __m128 发音为“quadfloat”,将 __m128i 发音为“quadint”。AVX 版本是 __m256(’octfloat’)和 __m256i(’octint’)。为了能够使用 SIMD 类型,我们需要包含一些头文件:
#include "nmmintrin.h" // for SSE4.2
#include "immintrin.h" // for AVX
一个 __m128 变量包含四个浮点数,所以我们可以再次使用union:
union { __m128 a4; float a[4]; };
现在我们可以方便地访问 __m128 向量中的各个浮点数。
我们也可以直接创建 quadfloat:
__m128 a4 = _mm_set_ps( 4.0f, 4.1f, 4.2f, 4.3f );
__m128 b4 = _mm_set_ps( 1.0f, 1.0f, 1.0f, 1.0f );
要将它们加在一起,我们使用 _mm_add_ps:
__m128 sum4 = _mm_add_ps( a4, b4 );
__mm_set_ps 和 _mm_add_ps 关键字为内置函数。SSE 和 AVX 内置函数都编译为一条汇编指令;使用这些意味着我们实际上是直接在我们的程序中编写汇编代码。几乎每个标量操作都有一个内置函数:
_mm_sub_ps( a4, b4 );
_mm_mul_ps( a4, b4 );
_mm_div_ps( a4, b4 );
_mm_sqrt_ps( a4 );
_mm_rcp_ps( a4 ); // reciprocal
对于 AVX,我们使用类似的内在函数:只需在前面加上 _mm256 而不是 _mm,因此:_mm256_add_ps(a4, b4),等等。
SSE 和 AVX 指令的完整概述可以在这里找到:
https://software.intel.com/sites/landingpage/IntrinsicsGuide/
您可以放心地假设 2000 年之后生产的任何 CPU 都支持最高 4.2 的 SSE。AVX,尤其是 AVX2 是较新的技术;查看 Wikipedia 以获取支持处理器的列表:
https://en.wikipedia.org/wiki/Advanced_Vector_Extensions
3 A Practical Example: C++
以下代码呈现了一个 Mandelbrot 分形:
float scale = 1 + cosf( t );
t += 0.01f;
for( int y = 0; y < SCRHEIGHT; y++ )
{
float yoffs = ((float)y / SCRHEIGHT - 0.5f) * scale;
float xoffs = -0.5f * scale, dx = scale / SCRWIDTH;
for( int x = 0; x < SCRWIDTH; x++, xoffs += dx )
{
float ox = 0, oy = 0, py;
for( int i = 0; i < 99; i++ ) px = ox, py = oy,
oy = -(py * py - px * px - 0.55f + xoffs),
ox = -(px * py + py * px - 0.55f + yoffs);
int r = min( 255, max( 0, (int)(ox * 255) ) );
int g = min( 255, max( 0, (int)(oy * 255) ) );
screen->Plot( x, y, (r << 16) + (g << 8) );
} }
请注意,此代码经过了很好的优化,并且计算量很大。我们可以很容易地在多核上运行这段代码:像素之间没有依赖关系,所以这个算法是令人尴尬的并行。但为了获得最佳性能,我们还需要使用指令级并行性。这意味着每个标量操作都应该针对四个输入元素执行。繁重的工作发生在内部循环中,所以如果我们只是优化它,我们应该会看到一个不错的加速。让我们考虑一下我们的选择:内部循环中有循环依赖,所以我们不能并行运行迭代。然而,我们可以并行处理四个像素。
我们现在将逐步将现有的标量代码转换为矢量化代码。我将使用 SSE,但稍作修改后,相同的过程也适用于 AVX。
Step 1:备份原代码
最好的方法是使用 #if 1 … #else … #endif 块。这样原始代码触手可及,万一出现问题,或者仅供参考。
Step 2:创建四个流
我们首先模拟四个流的使用。一次处理四个像素意味着 x 以 4 为步长增加。除此之外,我们需要 ox 和 oy 变量的四个副本,因为现在将针对四个像素并行计算这些副本。
for( int x = 0; x < SCRWIDTH; x += 4, xoffs += dx * 4 )
{
float ox[4] = { 0, 0, 0, 0 }, oy[4] = { 0, 0, 0, 0 };
for( int lane = 0; lane < 4; lane++ )
内部循环的内容几乎没有改变:我们做同样的工作,但是我们现在对数组元素进行操作,而不是对 ox 和 oy 进行操作:
for( int i = 0; i < 99; i++ ) px = ox[lane], py = oy[lane],
oy[lane] = -(py * py - px * px - 0.55f + xoffs + lane * dx),
ox[lane] = -(px * py + py * px - 0.55f + yoffs);
最后,我们需要绘制四个像素。让我们在一个单独的循环中执行此操作,因此我们不能将该循环转换为 SIMD,或者单独进行转换:
for( int lane = 0; lane < 4; lane++ )
{
int r = min( 255, max( 0, (int)(ox[lane] * 255) ) );
int g = min( 255, max( 0, (int)(oy[lane] * 255) ) );
screen->Plot( x + lane, y, (r << 16) + (g << 8) );
}
Step 3:创建 SIMD 数据结构
这是一个简单的步骤:我们已经在 ox[4] 和 oy[4] 中有四个通道的数据,这意味着我们有两组四个浮点数,这正是存储在 quadfloat 中的内容。
union { __m128 ox4; float ox[4]; };
union { __m128 oy4; float oy[4]; };
ox4 = oy4 = _mm_setzero_ps();
最后一行使用内部函数将 128 位向量设置为零。
Step 4:检查功能
我们正在对我们的代码进行一些相当侵入性的更改,因此请定期确保一切仍按预期工作!
Step 5:转换内循环
由于已经准备好流转换,所以最终的转换很简单:
for( int i = 0; i < 99; i++ ) px4 = ox4, py4 = oy4,
oy4 = -(py4 * py4 – px4 * px4 - 0.55f + xoffs4),
ox4 = -(px4 * py4 + py4 * px4 - 0.55f + yoffs4);
这段代码不起作用,但它确实让我们清楚地知道我们想去哪里。流上的循环消失了,因为我们现在并行执行这些。ox[lane] 和 oy[lane] 的使用被 ox4 和 oy4 取代。变量 px4 和 py4 现在也应该是 quadfloats。一些问题仍然存在:
- 一个不是简单地使用 * 运算符将两个四元浮点数相乘;
- xoffs4 的内容有点复杂。
关于 xoffs4:变量 xoffs 过去每次迭代都会增加 dx。所以,我们正在寻找的是一个由四个浮点数组成的数组,包含 { xoffs, xoffs + dx, xoffs + 2 * dx, xoffs + 3 * dx }:
__m128 xoffs4 = _mm_set_ps( xoffs, xoffs + dx, xoffs + dx * 2, xoffs + dx * 3 );
变量 yoffs4 对四个像素中的每一个都包含相同的值:
__m128 yoffs4 = _mm_set_ps( yoffs, yoffs, yoffs, yoffs );
剩下的就是操作者了。我们需要用 _mm_mul_ps 替换每个乘法,用 _mm_sub_ps 替换每个减法,等等。让我们为 oy4 执行此操作:
oy4 = -(py4 * py4 - px4 * px4 - 0.55f + xoffs4);
变成
oy4 =
_mm_sub_ps(
_mm_setzero_ps(),
_mm_add_ps(
_mm_sub_ps(
_mm_sub_ps(
_mm_mul_ps( py4, py4 ),
_mm_mul_ps( px4, px4 )
),
_mm_set1_ps( 0.55f )
),
xoffs4 ) );
把所有东西放在一起,我们得到了最终的矢量化程序:
for( int y = 0; y < SCRHEIGHT; y++ )
{
float yoffs = ((float)y / SCRHEIGHT - 0.5f) * scale; float xoffs = -0.5f * scale, dx = scale / SCRWIDTH; for( int x = 0; x < SCRWIDTH; x += 4, xoffs += dx * 4 ) {
union { __m128 ox4; float ox[4]; };
union { __m128 oy4; float oy[4]; };
ox4 = oy4 = _mm_setzero_ps();
__m128 xoffs4 = _mm_setr_ps( xoffs, xoffs + dx,
xoffs + dx * 2, xoffs + dx * 3 );
__m128 yoffs4 = _mm_set_ps1( yoffs );
for( int i = 0; i < 99; i++ )
{
__m128 px4 = ox4, py4 = oy4;
oy4 = _mm_sub_ps( _mm_setzero_ps(), _mm_add_ps( _mm_sub_ps(
_mm_sub_ps( _mm_mul_ps( py4, py4 ), _mm_mul_ps( px4, px4 ) ),
_mm_set_ps1( 0.55f ) ), xoffs4 ) );
ox4 = _mm_sub_ps( _mm_setzero_ps(), _mm_add_ps( _mm_sub_ps(
_mm_add_ps( _mm_mul_ps( px4, py4 ), _mm_mul_ps( py4, px4 ) ),
_mm_set_ps1( 0.55f ) ), yoffs4 ) );
}
for( int lane = 0; lane < 4; lane++ )
{
int r = min( 255, max( 0, (int)(ox[lane] * 255) ) );
int g = min( 255, max( 0, (int)(oy[lane] * 255) ) );
screen->Plot( x + lane, y, (r << 16) + (g << 8) );
}
}
正如所承诺的那样,此代码的运行速度几乎是原始代码的四倍。
4 Conditional Code & SIMD
代码向量化是将现有代码转换为可以并行执行的独立标量流的过程,其中每个任务执行相同的指令。这样,可以使用“单指令多数据”指令同时执行四个或八个(或更多)标量流。
到目前为止,我们矢量化的代码相对简单:图像的所有像素都可以独立计算,以任意顺序计算,也可以并行计算,对于每个像素,我们执行完全相同的指令。但是,如果事情没有那么简单呢?最常见的复杂情况是条件代码:任何涉及 if 语句、条件表达式(例如 a=b>a?a:b),但也包括具有可变迭代次数的循环、switch 语句等。显然,任何有条件的东西都可能导致标量流不执行相同的代码。
考虑我们矢量化 Mandelbrot 示例中的第二个循环:
for( int lane = 0; lane < 4; lane++ )
{
int r = min( 255, max( 0, (int)(ox[lane] * 255) ) );
int g = min( 255, max( 0, (int)(oy[lane] * 255) ) );
screen->Plot( x + lane, y, (r << 16) + (g << 8) );
}
这里使用的 min 和 max 函数隐藏了一些条件代码。Min 可以实现为:
int min( a, b ) { if (a < b) return a; else return b; }
或者使用条件表达式:
#define min(a,b) ((a)<(b)?(a):(b));
对于最小值和最大值的特定情况,SSE 和 AVX 提供了一个有效的解决方案:
__m128 c4 = _mm_min_ps( a4, b4 );
__m128 c4 = _mm_max_ps( a4, b4 );
这些指令的存在有时会导致 SSE 代码超过预期的最佳 400% 效率:条件代码会导致 CPU 延迟,但在 SSE 和 AVX 中,min 和 max 根本不是条件的。
我们现在可以矢量化部分像素绘图循环:
__m128 C4 = _mm_set_ps1( 255.0f );
ox4 = _mm_min_ps( C4, _mm_max_ps( _mm_setzero_ps(), _mm_mul_ps( ox4, C4 ) ) );
oy4 = _mm_min_ps( C4, _mm_max_ps( _mm_setzero_ps(), _mm_mul_ps( oy4, C4 ) ) );
for( int lane = 0; lane < 4; lane++ )
{
int r = (int)ox[lane];
int g = (int)oy[lane];
screen->Plot( x + lane, y, (r << 16) + (g << 8) );
}
请注意,常量 255.0f 存储在一个变量中,因此我们不必执行 _mm_set1_ps 指令四次,而只需执行一次。
事实上,我们可以更进一步:从 float 到 int 的转换也可以使用 SSE 指令完成
union { __m128i tmp1; int oxi[4]; }; tmp1 = _mm_cvtps_epi32( ox4 );
union { __m128i tmp2; int oyi[4]; }; tmp2 = _mm_cvtps_epi32( oy4 );
请注意,union现在组合了一个四元组和一个整数数组。
现在在第二个循环中只剩下一条线,用于绘制像素。plot是surface类的一个方法,实现如下:
void Surface::Plot( int x, int y, Pixel c )
{
if ((x >= 0) && (y >= 0) && (x < m_Width) && (y < m_Height))
m_Buffer[x + y * m_Pitch] = c;
}
这里,“Pixel”只是一个 32 位无符号整数,m_Width 和 m_Height 是表面的宽度和高度。if 语句防止像素被绘制到屏幕外。在 Mandelbrot 应用程序中,这永远不会发生,但显然其他应用程序可能需要此功能。
Surface::Plot 的 SSE 版本可能如下所示:
void Surface::Plot4( __m128i x4, __m128i y4, __m128i c4 )
{
if ((x4 >= 0) && (y4 >= 0) && (x4 < m_Width) && (y4 < m_Height))
...
}
这次我们遇到了一个问题。SSE和AVX没有与if语句等效的指令,这是有充分理由的:我们在标量代码中看到的布尔表达式将成为“quadbool”表达式,而条件代码(将某些内容存储在像素缓冲区中)可能必须对任何、部分或所有通道执行。
我刚刚写的SSE和AVX没有if语句,但它们实际上有比较指令。它们不会产生“四布尔”,但会返回一些有用的东西:位掩码。以下是一个例子:
__m128 mask = _mm_cmpge_ps( x4, _mm_setzero_ps() ); // if (x4 >= 0)
此行采用 x4 和一个包含零的 quadfloat,并检查第一个操作数是否大于或等于第二个操作数。对于大于 (_mm_cmpgt_ps)、小于 (_mm_cmplt_ps)、小于或等于 (_mm_cmple_ps)、等于 (_mm_cmpeq_ps) 和不等于 (_mm_cmpne_ps) 存在类似的比较指令。
掩码值为 128 位值。比较后,其内容反映了结果:“假”为 32 个零,“真”为 32 个零。
我们还可以结合比较:
__m128 mask1 = _mm_cmpge_ps( x4, _mm_setzero_ps() ); // if (x4 >= 0)
__m128 mask2 = _mm_cmpge_ps( y4, _mm_setzero_ps() ); // if (y4 >= 0)
__m128 mask = _mm_and_ps( mask1, mask2 ); // if (x4 >= 0 && y4 >= 0)
这些实际上都不是有条件的:我们无条件地计算位掩码。生成的位掩码可以两种不同的方式使用。第一种方法是中断向量指令流,并切换到标量代码来处理比较结果。为此,我们使用 _mm_movemask_ps 指令。该指令采用掩码,并返回一个 4 位值,如果通道的 32 位为 1,则每个位设置为 1,否则设置为 0。现在我们可以单独测试这些位:
int result = _mm_movemask_ps( mask );
if (result & 1) { ... } // result for first lane is true
if (result & 2) { ... } // result for second lane is true
if (result & 4) { ... } // result for third lane is true
if (result & 8) { ... } // result for fourth lane is true
好处是我们现在至少使用矢量代码进行了比较。但是我们并没有解决实际问题:条件代码仍然破坏了我们的向量流。
为了解决这个问题,我们需要以不同的方式使用掩码:禁用通道的功能。考虑实际的条件代码:
m_Buffer[x + y * m_Pitch] = c;
这一行将一个无符号整数写入屏幕缓冲区中的地址。现在,如果我们将该地址替换为其他安全位置,例如虚拟变量的地址,该怎么办?我们仍然会执行写入,但这次它不会产生可见像素。
让我们考虑一个更实用的解决方案:如果一个像素恰好不在屏幕上,我们将其写入位置 (0,0)。当然,这个像素会包含废话,因为它会被所有屏幕外像素覆盖,但为了这个例子,我们认为这是可以接受的。为了实现这一点,我们将像素地址计算 x + y * m_Pitch 替换为 (x + y * m_Pitch) * 0。无论 x、y 和 m_Pitch 的值是什么,这个等式的结果都是 0。而这种操作正是这些掩码设计的目的。
让我们计算绘图语句的完整掩码:
__m128 mask1 = _mm_cmpge_ps( x4, _mm_setzero_ps() );
__m128 mask2 = _mm_cmpge_ps( y4, _mm_setzero_ps() );
__m128 mask3 = _mm_cmplt_ps( x4, _mm_set_ps1( m_Width ) );
__m128 mask4 = _mm_cmplt_ps( y4, _mm_set_ps1( m_Height ) );
__m128 mask = _mm_and_ps( _mm_and_ps( _mm_and_ps( mask1, mask2 ), mask3 ), mask4 );
我们可以如下计算四个像素地址:
__m128i address4 = _mm_add_epi32( _mm_mullo_epi32( y4, m_Pitch4 ), x4 );
address4 = _mm_and_si128( address, *(__m128i*)&mask ) );
关于这些行的几点说明:
- 两个 32 位整数相乘产生一个 64 位整数,它不适合 32 位通道。_mm_mullo_epi32 指令丢弃前 32 位,在这种情况下很好。
- 没有_mm_and_epi32指令;而是使用 _mm_and_si128 直接对 128 位进行按位和整数运算。
- 我们的掩码是一个 quadfloat,而 _mm_and_si128 需要一个 quadint 掩码。因此,我们将其即时转换为正确的类型。
- 第二行使用计算的掩码将所有屏幕外像素地址重置为 0,正如我们计划的那样。
现在还有一件事要做:将四个像素绘制到存储在 quadint address4 中的地址。我们想要进行的写入被称为分散:四个地址可能彼此相邻,但也可能遍布屏幕。没有支持此功能的 SSE 和 AVX 指令,因此我们唯一的选择是使用四个 32 位写入来执行此操作。尽管这破坏了我们的向量流,但这些都不是有条件的。
最终的 Plot4 方法:
void Surface::Plot4( __m128 x4, __m128 y4, __m128i c4 )
{
__m128 mask1 = _mm_cmpge_ps( x4, _mm_setzero_ps() );
__m128 mask2 = _mm_cmpge_ps( y4, _mm_setzero_ps() );
__m128 mask3 = _mm_cmplt_ps( x4, _mm_set_ps1( (float)m_Width ) );
__m128 mask4 = _mm_cmplt_ps( y4, _mm_set_ps1( (float)m_Height ) );
__m128 mask = _mm_and_ps( _mm_and_ps( _mm_and_ps( mask1, mask2 ), mask3 ), mask4 ); union { __m128i address4; int address[4]; };
__m128i m_Pitch4 = _mm_set1_epi32( m_Pitch );
__m128i x4i = _mm_cvtps_epi32( x4 );
__m128i y4i = _mm_cvtps_epi32( y4 );
address4 = _mm_add_epi32( _mm_mullo_epi32( y4i, m_Pitch4 ), x4i );
for( int i = 0; i < 4; i++ )
m_Buffer[address[i]] = c4.m128i_i32[i];
}
请注意,该函数现在对 x4 和 y4 采用 quadfloats;这是因为 quadints 的 SSE 指令集比 quadfloats 更受限制。特别是缺少 _mm_cmpge_epi32。可以模拟此功能,但这会使代码不太清晰。
5 Fun with Mask
在上一节中,我们使用 128 位掩码来取消计算。我们通过使用 _mm_and_sil128 使用整数“and”来做到这一点。我们将它应用于包含地址的 quadint 变量(实际上是:从屏幕缓冲区开始的偏移量),但同样的技巧适用于浮点数。为此,我们“abuse”了浮点数 0.0f 的一个有趣属性:它的二进制表示是 32 个零。这意味着如果我们“和”一个具有 32 个零的浮点数,我们将重置其所有位,从而使浮点值变为 0.0f。‘And’ing 与 32 个 1 无关:我们只保留原始浮点数。一个例子:
__m128 mask = ...; // some comparison
a4 = _mm_and_ps( a4, mask );
如果掩码中的相应通道为“false”,则第二行将 quadfloat a4 的通道设置为 0.0f。
根据条件,我们可能想在某些通道上放置零以外的东西。考虑以下条件表达式:
float a = b == 0 ? b : c;
…如果其值为零,则将 a 替换为 b,否则将其替换为 c。一种方法是再次使用掩码:
__m128 mask = _mm_cmpeq_ps( a4, _mm_setzero_ps() );
__m128 part1 = _mm_and_ps( mask, b4 );
__m128 part2 = _mm_andnot_ps( mask, c4 );
a4 = _mm_or_ps( part1, part2 );
在这里,part1 将包含掩码为false的每个通道的零,以及掩码为true的 b4 中的值。Quadfloat part2 使用反转掩码,并从 c4 中选择。请注意,part1 和 part2 没有重叠:如果一个通道在 part1 中不为零,那么它在 part2 中将为零,反之亦然。因此,这两个部分可以安全地混合以获得最终结果。
获得此结果的更直接方法是使用 _mm_blendv_ps 指令:
__m128 mask = _mm_cmpeq_ps( a4, _mm_setzero_ps() );
a4 = _mm_blendv_ps( b4, c4, mask );
_mm_blendv_ps 内在函数根据掩码从 b4 和 c4 中选择值:如果掩码中的值设置为 true,则将选择 c4 中的值,否则将选择 b4 中的值。
6 Optimizating and Debugging SIMD Code
在前面的部分中,我们已经了解了如何对代码进行矢量化,以及如何处理条件代码。在本节中,我们将讨论一些提高 SIMD 代码效率的常见机会。
指令计数:原则上,每个内在函数都编译为单个编译器指令。这意味着更短的源代码会产生更小的程序,大多数情况下运行速度会更快。有时,诸如 _mm_blendv_ps 之类的高级指令可以替代一系列更简单的指令。因此,熟悉可用的说明会很有帮助。
浮点与整数: SSE 和 AVX 中的浮点支持比整数支持要好得多。有时临时转换为浮点数可以使您的代码更高效,即使这意味着您需要稍后再转换回来。浮点运算肯定会让您的生活更轻松:许多整数内在函数非常晦涩(参见例如_mm_mullo_epi32)。
减少 _mm_set_ps 的使用: 在向量化代码中经常需要常量,正如我们在 Mandelbrot 示例中看到的那样。在现场为这些创建quadfloat可能很诱人。但是,_mm_set_ps 是一个昂贵的函数,因为它需要四个操作数。考虑缓存结果:计算循环外的 quadfloat,这样您就可以在循环内多次使用它而不会受到惩罚。同样,如果您需要将标量扩展为 quadfloats(如 Plot 方法中的 m_Pitch),请考虑在类中缓存扩展版本。
避免收集操作:与 _mm_set_ps 相关的另一个陷阱是您提供给它的数据来自分散在内存中的位置。从内存中获取数据到 quadfloat 的最快方法是当它已经作为 quadfloat 存储在内存中时,即 16 个连续字节。
数据对齐:要记住的一件事是,内存中的 quadfloat 必须始终存储在 16 的倍数的地址中。否则将导致崩溃。这就是 C# 对 SSE/AVX 数据使用慢速未对齐读取的原因:C# 不能保证数据对齐。在 C++ 中,在堆栈上创建的变量将自动遵守此规则。然而,使用 new 分配的变量可能未对齐,从而导致意外崩溃。如果您确实遇到了崩溃,请检查正在处理的数据是否正确对齐:(十六进制)地址应始终以零结尾。
C++ 调试器:对 SIMD 的支持很好地集成在 Visual Studio 调试器中。你可以例如轻松检查 SIMD 变量中的各个值。
AVX/AVX2 支持: 如果您的处理器恰好是 AMD 和 Intel 必须提供的最新最好的处理器,请注意您生成的某些代码可能无法在您邻居的笔记本电脑上运行。在 C++ 中,完全有可能生成一个无法运行的 .exe,例如AVX2 不可用。确保牢记目标硬件,或为旧硬件提供替代实现。这个问题的一个例子:Metal Gear V 的早期破解需要一些模糊的 SSE 指令,这些指令在某些 AMD 硬件上不可用,即使该硬件完全能够运行游戏本身。
仅向量化瓶颈:在 Mandelbrot 示例中,我们对 Plot 方法进行了向量化,尽管它只消耗了一小部分时间。不要在现实世界中这样做:矢量化很难,您只想将精力集中在瓶颈上。在 Mandelbrot 示例中,更新 ox 和 oy 的大规模循环是一个很好的示例:大量工作集中在一小部分代码中,急需进行接近金属的优化。
避开花哨的 SIMD 库:矢量化很难,当你打算写 a * b 时,写 _mm_mul_ps(a,b) 感觉很不自然。抵制编写自己的运算符的冲动;习惯原始的内在函数。任何更复杂的东西都必然会隐藏效率低下,甚至引入它们。