多媒体指令集SIMD优化入门

以下内容翻译自:
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) 感觉很不自然。抵制编写自己的运算符的冲动;习惯原始的内在函数。任何更复杂的东西都必然会隐藏效率低下,甚至引入它们。

优化代码内存访问

以下内容总结自《Intel® 64 and IA-32 Architectures Optimization Reference Manual》

本文内容讨论针对Intel处理器优化代码内存访问的相关技术。主要内容如下:

1 加载和存储执行带宽

通常,加载和存储是代码执行中最频繁的操作,高达 40% 的加载和存储指令并不少见。每一代微架构都提供了多个缓冲区来支持在有指令运行时执行加载和存储操作。这些缓冲区由 Sandy Bridge 和 Ivy Bridge 微架构的 128 位组成。 在 Haswell、Broadwell 和 Skylake Client 微架构中,大小增加到 256 位; 以及 Skylake Server、Cascade Lake、Cascade Lake Advanced Performance 和 Ice Lake 客户端微架构中的 512 位。 为了最大限度地提高性能,最好使用平台中可用的最大宽度。

1.1 在 Sandy Bridge 微架构中利用加载带宽

虽然先前的微架构只有一个加载端口(端口 2),但 Sandy Bridge 微架构可以从端口 2 和端口 3 加载。因此,每个周期可以执行两次加载操作,并使代码的加载吞吐量翻倍。 这改进了读取大量数据并且不需要经常将结果写入内存的代码(端口 3 也处理存储地址操作)。 为了利用此带宽,数据必须保留在 L1 数据缓存中,否则应按顺序访问,从而使硬件预取器能够及时将数据带到 L1 数据缓存中。

考虑以下计算数组所有元素和的 C 代码示例:

int buff[BUFF_SIZE];
int sum = 0;

for (i=0;i<BUFF_SIZE;i++){ 
  sum+=buff[i];
}

示例 1-1 是英特尔编译器为此 C 代码生成的汇编代码。 编译器使用英特尔 SSE 指令对执行进行矢量化。 在此代码中,每个 ADD 操作都使用前一个 ADD 操作的结果。 这将吞吐量限制为每个周期一个加载和 ADD 操作。 示例 1-2 针对 Sandy Bridge 微架构进行了优化,使其能够使用额外的加载带宽。 该代码通过使用两个寄存器来对数组值求和,从而消除了 ADD 操作之间的依赖性。 每个周期可以执行两次加载和两次添加操作。

示例 1-1

xor eax, eax
  pxor xmm0, xmm0
  lea rsi, buff

loop_start:
  paddd xmm0, [rsi+4*rax]
  paddd xmm0, [rsi+4*rax+16]
  paddd xmm0, [rsi+4*rax+32]
  paddd xmm0, [rsi+4*rax+48]
  paddd xmm0, [rsi+4*rax+64]
  paddd xmm0, [rsi+4*rax+80]
  paddd xmm0, [rsi+4*rax+96]
  paddd xmm0, [rsi+4*rax+112]
  add eax, 32
  cmp eax, BUFF_SIZE
  jl loop_start
sum_partials:
  movdqa xmm1, xmm0
  psrldq xmm1, 8
  paddd xmm0, xmm1
  movdqa xmm2, xmm0
  psrldq xmm2, 4
  paddd xmm0, xmm2
  movd [sum], xmm0

示例 1-2

  xor eax, eax
  pxor xmm0, xmm0
  pxor xmm1, xmm1
  lea rsi, buff

loop_start:
  paddd xmm0, [rsi+4*rax]
  paddd xmm1, [rsi+4*rax+16]
  paddd xmm0, [rsi+4*rax+32]
  paddd xmm1, [rsi+4*rax+48]
  paddd xmm0, [rsi+4*rax+64]
  paddd xmm1, [rsi+4*rax+80]
  paddd xmm0, [rsi+4*rax+96]
  paddd xmm1, [rsi+4*rax+112]
  add eax, 32
  cmp eax, BUFF_SIZE
  jl loop_start
sum_partials:
  paddd xmm0, xmm1
  movdqa xmm1, xmm0
  psrldq xmm1, 8
  paddd xmm0, xmm1
  movdqa xmm2, xmm0
  psrldq xmm2, 4
  paddd xmm0, xmm2
  movd [sum], xmm0

1.2 Sandy Bridge 微架构中的 L1D 缓存延迟

L1D 缓存的加载延迟可能会有所不同, 最好的情况是 4 个周期,这适用于使用以下方法之一对通用寄存器进行加载操作:

  • 一个寄存器。
  • 一个基址寄存器加上一个小于 2048 的偏移量。

考虑示例中的指针跟踪代码示例。

示例 1-3: Traversing through indexes

// C code example
index = buffer.m_buff[index].next_index; 
// ASM example
loop:
  shl rbx, 6
  mov rbx, 0x20(rbx+rcx) 
  dec rax
  cmp rax, -1
  jne loop

示例 1-4: Traversing through pointers

// C code example
node = node->pNext;
// ASM example 
loop:
  mov rdx, [rdx] 
  dec rax
  cmp rax, -1 
  jne loop

示例 1-3 通过遍历索引实现指针追踪。 然后编译器生成所示的代码,使用带有偏移量的 base+index 寻址内存。 示例 1-4 显示了编译器从指针解引用代码生成的代码,并且仅使用了一个基址寄存器。在 Sandy Bridge 微架构和之前的微架构中,代码 2 比代码 1 要快。

1.3 处理 L1D 缓存库冲突

在 Sandy Bridge 微架构中,L1D 缓存的内部组织会出现两个加载地址,可能存在库冲突的微操作的情况。当两个加载操作之间存在冲突时,最近的一个将被延迟,直到冲突解决。当两个同时加载操作具有相同的线性地址的第 2-5 位但它们不是来自高速缓存中的同一组(第 6-12 位)时,就会发生库冲突。

只有当代码受加载带宽约束时,才应处理库冲突。一些库冲突不会导致任何性能下降,因为它们被其他性能限制隐藏,消除这种库冲突并不能提高性能。

以下示例演示了库冲突以及如何修改代码并避免它们。它使用两个源数组,其大小是缓存行大小的倍数。当从 A 加载一个元素并从 B 加载对应元素时,这些元素在它们的缓存行中具有相同的偏移量,因此可能会发生存储库冲突。 L1D 缓存库冲突不适用于 Haswell 微架构。

示例 1-5:C Code

int A[128];
int B[128];
int C[128];
for (i=0;i<128;i+=4){
  C[i]=A[i]+B[i]; // the loads from A[i] and B[i] collide
  C[i+1]=A[i+1]+B[i+1];
  C[i+2]=A[i+2]+B[i+2];
  C[i+3]=A[i+3]+B[i+3];
}

示例 1-6: Code with Bank Conflicts

  xor rcx, rcx
  lea r11, A
  lea r12, B
  lea r13, C
loop:
  lea esi, [rcx*4]
  movsxd rsi, esi
  mov edi, [r11+rsi*4]
  add edi, [r12+rsi*4]
  mov r8d, [r11+rsi*4+4]
  add r8d, [r12+rsi*4+4]
  mov r9d, [r11+rsi*4+8]
  add r9d, [r12+rsi*4+8]
  mov r10d, [r11+rsi*4+12]
  add r10d, [r12+rsi*4+12]

  mov [r13+rsi*4], edi
  inc ecx
  mov [r13+rsi*4+4], r8d
  mov [r13+rsi*4+8], r9d
  mov [r13+rsi*4+12], r10d
  cmp ecx, LEN
  jb loop

示例 1-7: Code without Bank Conflicts

 xor rcx, rcx
  lea r11, A
  lea r12, B
  lea r13, C
loop:
  lea esi, [rcx*4]
  movsxd rsi, esi
  mov edi, [r11+rsi*4]
  mov r8d, [r11+rsi*4+4]
  add edi, [r12+rsi*4]
  add r8d, [r12+rsi*4+4]
  mov r9d, [r11+rsi*4+8]
  mov r10d, [r11+rsi*4+12]
  add r9d, [r12+rsi*4+8]
  add r10d, [r12+rsi*4+12]
  
  inc ecx
  mov [r13+rsi*4], edi
  mov [r13+rsi*4+4], r8d
  mov [r13+rsi*4+8], r9d
  mov [r13+rsi*4+12], r10d
  cmp ecx, LEN
  jb loop

2 尽量减少寄存器溢出

当一段代码的实时变量多于处理器可以保存在通用寄存器中的数量时,一种常见的方法是将一些变量保存在内存中。 这种方法称为寄存器溢出。 L1D 缓存延迟的影响会对该代码的性能产生负面影响。 如果寄存器溢出的地址使用较慢的寻址模式,效果会更加明显。

一种选择是将通用寄存器溢出到 XMM 寄存器。 这种方法也可能提高前几代处理器的性能。 以下示例显示如何将寄存器溢出到 XMM 寄存器而不是内存。

示例 2-1:Register spills into memory

loop:
  mov rdx, [rsp+0x18]
  movdqa xmm0, [rdx]
  movdqa xmm1, [rsp+0x20]
  pcmpeqd xmm1, xmm0
  pmovmskb eax, xmm1
  test eax, eax
  jne end_loop
  movzx rcx, [rbx+0x60]

  add qword ptr[rsp+0x18], 0x10
  add rdi, 0x4
  movzx rdx, di
  sub rcx, 0x4
  add rsi, 0x1d0
  cmp rdx, rcx
  jle loop

Register spills into XMM

  movq xmm4, [rsp+0x18]
  mov rcx, 0x10
  movq xmm5, rcx
loop:
  movq rdx, xmm4
  movdqa xmm0, [rdx]
  movdqa xmm1, [rsp+0x20]
  pcmpeqd xmm1, xmm0
  pmovmskb eax, xmm1
  test eax, eax
  jne end_loop
  movzx rcx, [rbx+0x60]

  padd xmm4, xmm5
  add rdi, 0x4
  movzx rdx, di
  sub rcx, 0x4
  add rsi, 0x1d0
  cmp rdx, rcx
  jle loop

3 增强推测执行和内存消歧

在 Intel Core 微架构之前,当代码同时包含存储和加载时,在知道旧存储的地址之前无法发出加载。此规则确保正确处理对先前存储的加载依赖关系。

Intel Core 微架构包含一种机制,允许在存在较旧的未知存储的情况下推测性地执行某些加载。处理器稍后检查加载地址是否与执行加载时地址未知的旧存储重叠。如果地址确实重叠,则处理器重新执行加载和所有后续指令。

示例代码说明了编译器无法确定”Ptr->Array”在循环期间不会改变的情况。因此,编译器不能将”Ptr->Array”作为不变量保存在寄存器中,并且必须在每次迭代中再次读取它。虽然这种情况可以通过重写代码以要求指针地址不变在软件中修复,但内存消歧在不重写代码的情况下提高了性能。

示例 3-1:Loads Blocked by Stores of Unknown Address

// C code
struct AA {
  AA ** Array;
};
void nullify_array ( AA *Ptr, DWORD Index, AA *ThisPtr)
{
  while ( Ptr->Array[--Index] != ThisPtr )
  {
    Ptr->Array[Index] = NULL ;
  } ;
} ;

// Assembly sequence
  nullify_loop:
  mov dword ptr [eax], 0
  mov edx, dword ptr [edi]
  sub ecx, 4
  cmp dword ptr [ecx+edx], esi
  lea eax, [ecx+edx]
  jne nullify_loop

4 存储转发

处理器的内存系统仅在存储失效后将存储发送到内存(包括缓存)。但是,存储数据可以从同一地址从存储转发到后续加载,以缩短存储加载延迟。

存储转发有两种要求。如果违反了这些要求,存储转发将无法发生,加载必须从缓存中获取数据(因此存储必须先将其数据写回缓存)。这会带来很大程度上与底层微架构的管道深度有关的惩罚。

第一个要求与存储转发数据的大小和对齐方式有关。 此限制可能会对整体应用程序性能产生很大影响。 通常,可以防止因违反此限制而导致的性能损失。 存储到加载转发限制因一种微架构而异。 第 4.1 节“存储到加载转发对大小和对齐的限制”中详细讨论了几个导致存储转发停滞的编码缺陷示例以及这些缺陷的解决方案。 第二个要求是数据的可用性,在第 4.2 节“数据可用性的存储转发限制”中进行了讨论。 一个好的做法是消除冗余的加载操作。

可以将临时标量变量保存在寄存器中,而永远不要将其写入内存。 通常,这样的变量不能使用间接指针访问。 将变量移动到寄存器会消除该变量的所有加载和存储,并消除与存储转发相关的潜在问题。 然而,它也增加了套准压力。

加载指令倾向于启动计算链。 由于乱序引擎是基于数据依赖的,因此加载指令对引擎的高速执行能力起着重要作用。 消除加载指令应该是高度优先的。

如果一个变量从存储到再次使用之间没有变化,则可以复制或直接使用之前存储的寄存器。 如果寄存器压力太大,或者在存储和第二次加载之前调用了一个看不见的函数,则可能无法消除第二次加载。

尽可能在寄存器中而不是在堆栈中传递参数。 在堆栈上传递参数需要存储然后重新加载。 虽然此序列在硬件中通过直接从内存顺序缓冲区向加载提供值而在硬件中进行了优化,如果存储转发限制允许,则无需访问数据缓存,但浮点值会在转发过程中产生显着延迟。 在(最好是 XMM)寄存器中传递浮点参数应该可以节省这种长延迟操作。

参数传递约定可能会限制哪些参数在堆栈上传递,哪些参数在寄存器中传递。 但是,如果编译器可以控制整个二进制文件的编译(使用整个程序优化),则可以克服这些限制。

4.1 Store-to-Load-Forwarding 大小和对齐限制

存储转发的数据大小和对齐限制适用于基于 Intel Core 微架构、Intel Core 2 Duo、Intel Core Solo 和 Pentium M 处理器的处理器。 对于较短的流水线机器,违反存储转发限制的性能损失较小。

存储转发限制因每个微架构而异。 以下规则有助于满足存储转发的大小和对齐限制:

  • 规则1:从存储转发的加载必须具有相同的地址起点,因此与存储数据具有相同的对齐方式。
  • 规则2:从存储转发的加载数据必须完全包含在存储数据中。

从存储转发的加载必须等待存储的数据写入存储缓冲区才能继续,但其他不相关的加载不需要等待。

  • 规则3:如果需要提取存储数据的未对齐部分,请读出完全包含数据的最小对齐部分,并根据需要 shift/mask 数据。 这比招致存储转发失败的惩罚要好。
  • 规则4:通过根据需要使用单个大型读取和注册副本,避免在将大型存储到同一内存区域之后进行几次小型加载。

示例 4-1 描述了几种存储转发情况,其中小加载跟随大存储。 前三个加载操作说明了规则 4 中描述的情况。但是,最后一个加载操作从存储转发中获取数据没有问题。

示例 4-1:Situations Showing Small Loads After Large Store

mov [EBP],‘abcd’
mov AL, [EBP] ; Not blocked - same alignment
mov BL, [EBP + 1] ; Blocked
mov CL, [EBP + 2] ; Blocked
mov DL, [EBP + 3] ; Blocked
mov AL, [EBP] ; Not blocked - same alignment
              ; n.b. passes older blocked loads

示例 4-2 说明了存储转发情况,其中大加载跟随几个小存储。 加载操作所需的数据无法转发,因为需要转发的所有数据都没有包含在存储缓冲区中。 在小存储到同一内存区域后避免大加载。

示例 4-2:Non-forwarding Example of Large Load After Small Store

mov [EBP], ‘a’
mov [EBP + 1], ‘b’
mov [EBP + 2], ‘c’
mov [EBP + 3], ‘d’
mov EAX, [EBP] ; Blocked
    ; The first 4 small store can be consolidated into
    ; a single DWORD store to prevent this non-forwarding
    ; situation.

示例 4-3 说明了可能出现在编译器生成的代码中的停滞存储转发情况。 有时,编译器会生成类似于示例 3 中所示的代码来处理溢出的字节到堆栈并将字节转换为整数值。

示例 4-3:A Non-forwarding Situation in Compiler Generated Code

mov DWORD PTR [esp+10h], 00000000h
mov BYTE PTR [esp+10h], bl
mov eax, DWORD PTR [esp+10h] ; Stall
and eax, 0xff ; Converting back to byte value

示例 4-5 提供了两种替代方案来避免示例 3 中所示的非转发情况。

示例 4-5:Two Ways to Avoid Non-forwarding Situation in Example 3

; A. Use MOVZ instruction to avoid large load after small
; store, when spills are ignored.
movz eax, bl ; Replaces the last three instructions
; B. Use MOVZ instruction and handle spills to the stack
mov DWORD PTR [esp+10h], 00000000h
mov BYTE PTR [esp+10h], bl
movz eax, BYTE PTR [esp+10h] ; Not blocked

在内存位置之间移动小于 64 位的数据时,64 位或 128 位 SIMD 寄存器移动效率更高(如果对齐),可用于避免未对齐的加载。 尽管浮点寄存器允许一次移动 64 位,但浮点指令不应用于此目的,因为数据可能会被无意修改。

示例 4-5:Large and Small Load Stalls

; A. Large load stall
mov mem, eax ; Store dword to address “MEM"
mov mem + 4, ebx ; Store dword to address “MEM + 4"
fld mem ; Load qword at address “MEM", stalls
; B. Small Load stall
fstp mem ; Store qword to address “MEM"
mov bx, mem+2 ; Load word at address “MEM + 2", stalls
mov cx, mem+4 ; Load word at address “MEM + 4", stalls

在第一种情况 (A) 中,在对同一内存区域(从内存地址 MEM 开始)进行一系列小存储之后,会出现大加载。 大加载将停止。

FLD 必须等待存储写入内存,然后才能访问所需的所有数据。 这种停顿也可能发生在其他数据类型中(例如,当存储字节或字,然后从同一内存区域读取字或双字时)。

在第二种情况 (B) 中,在大存储到同一内存区域(从内存地址 MEM 开始)之后,会有一系列小加载。 小加载将停止。

字加载必须等待四字存储写入内存,然后才能访问所需的数据。 这种停顿也可能发生在其他数据类型中(例如,当存储双字或字,然后从同一内存区域读取字或字节时)。 这可以通过将商店尽可能远离加载来避免。

4.2

要存储的值必须在加载操作完成之前可用。 如果违反此限制,加载的执行将被延迟,直到数据可用。 这种延迟会导致一些执行资源被不必要地使用,这可能会导致相当大但不确定的延迟。 然而,这个问题的整体影响远小于违反尺寸和对齐要求的影响。

在现代微架构中,硬件预测加载何时依赖并从之前的存储中获取数据。 这些预测可以显着提高性能。 但是,如果在它所依赖的存储之后过早地安排加载,或者如果要存储的数据的生成被延迟,则可能会产生重大损失。

数据通过内存传递有几种情况,可能需要将存储与加载分开:

  • 溢出、保存和恢复堆栈帧中的寄存器。
  • 参数传递。
  • 全局变量和 volatile 变量。
  • 整数和浮点之间的类型转换。
  • 当编译器不分析内联代码时,强制内联代码接口中涉及的变量位于内存中,从而创建更多内存变量并防止消除冗余负载。

如果可以在不招致其他惩罚的情况下,请优先考虑将变量分配给寄存器,例如在寄存器分配和参数传递中,以最大限度地减少存储转发问题的可能性和影响。 尽量不要存储转发从长延迟指令生成的数据 – 例如,MUL 或 DIV。 避免为具有最短存储加载距离的变量存储转发数据。 避免为具有许多 and/or 长依赖链的变量存储转发数据,尤其是避免在循环携带的依赖链上包含存储转发。示例 4-6 展示了一个循环携带的依赖链的例子。

示例 4-6:Loop-carried Dependence Chain

for ( i = 0; i < MAX; i++ ) {
  a[i] = b[i] * foo;
  foo = a[i] / 3;
} // foo is a loop-carried dependence.

尽早计算存储地址以避免存储块加载。

5 数据布局优化

填充源代码中定义的数据结构,以便每个数据元素都与自然操作数大小的地址边界对齐。如果操作数打包在 SIMD 指令中,则与打包元素大小(64 位或 128 位)对齐。

通过在结构和数组内部提供填充来对齐数据。 程序员可以重新组织结构和数组,以尽量减少填充浪费的内存量。 但是,编译器可能没有这种自由。 例如,C 编程语言指定结构元素在内存中的分配顺序。

示例 5-1 显示了如何重新排列数据结构以减小其大小。

示例 5-1:Rearranging a Data Structure

struct unpacked { /* Fits in 20 bytes due to padding */
  int a;
  char b;
  int c;
  char d;
  int e;
};
struct packed { /* Fits in 16 bytes */
  int a;
  int c;
  int e;
  char b;
  char d;
}

64 字节的高速缓存行大小会影响流应用程序(例如多媒体)。 这些在丢弃数据之前仅引用和使用一次数据。 稀疏地利用高速缓存行内的数据的数据访问会导致系统内存带宽的利用效率降低。 例如,可以将结构数组分解为多个数组以实现更好的打包,如例 5-2 所示。

示例 5-2:Decomposing an Array

struct { /* 1600 bytes */
  int a, c, e;
  char b, d;
} array_of_struct [100];
struct { /* 1400 bytes */
  int a[100], c[100], e[100];
  char b[100], d[100];
} struct_of_array;
struct { /* 1200 bytes */
  int a, c, e;
} hybrid_struct_of_array_ace[100];
struct { /* 200 bytes */
  char b, d;
} hybrid_struct_of_array_bd[100];

这种优化的效率取决于使用模式。 如果结构的元素全部一起访问,但数组的访问模式是随机的,那么 ARRAY_OF_STRUCT 会避免不必要的预取,即使它会浪费内存。

但是,如果数组的访问模式表现出局部性(例如数组索引被扫描),那么具有硬件预取器的处理器将从 STRUCT_OF_ARRAY 预取数据,即使结构的元素被一起访问。

当结构的元素不是以相同的频率访问时,例如当元素 A 的访问频率是其他条目的十倍时,STRUCT_OF_ARRAY 不仅可以节省内存,还可以防止获取不必要的数据项 B、C、D 和E。

使用 STRUCT_OF_ARRAY 还允许程序员和编译器使用 SIMD 数据类型。

请注意,STRUCT_OF_ARRAY 的缺点是需要更多独立的内存流引用。 这可能需要使用更多的预取和额外的地址生成计算。 它还会对 DRAM 页面访问效率产生影响。 另一种方法是 HYBRID_STRUCT_OF_ARRAY 混合了这两种方法。 在这种情况下,仅生成和引用 2 个单独的地址流:1 个用于 HYBRID_STRUCT_OF_ARRAY_ACE,1 个用于 HYBRID_STRUCT_OF_ARRAY_BD。 第二个替代方案还可以防止获取不必要的数据——假设 (1) 变量 A、C 和 E 总是一起使用,以及 (2) 变量 B 和 D 总是一起使用,但与 A、C 和 E 不同时使用 。

混合方法确保:

  • 比 STRUCT_OF_ARRAY 更简单/更少的地址生成。
  • 更少的流,从而减少了 DRAM 页面缺失。
  • 由于流更少,预取更少。
  • 同时使用的数据元素的高效缓存行打包。

尝试安排数据结构,使它们允许顺序访问。如果将数据排列成一组流,则自动硬件预取器可以预取应用程序需要的数据,从而减少有效的内存延迟。 如果以非顺序方式访问数据,则自动硬件预取器无法预取数据。 预取器最多可以识别八个并发流。当心高速缓存行(64 字节)内的错误共享。

6 堆栈对齐

当内存引用拆分缓存线时,会发生对堆栈的未对齐访问的性能损失。这意味着八个空间上连续的未对齐四字访问中有一个总是受到惩罚,类似于四个连续的、未对齐的双四字访问中的一个。

当数据对象超过系统的默认堆栈对齐方式时,对齐堆栈可能是有益的。例如,在32/64位Linux和64位Windows上,默认堆栈对齐为16字节,而32位Windows为4字节。

确保堆栈在与寄存器宽度匹配的最大多字节粒度数据类型边界处对齐。对齐堆栈通常需要使用额外的寄存器来跟踪未知数量的填充区域。在导致跨越缓存线的未对齐内存引用和导致额外的通用寄存器溢出之间存在权衡。实现动态堆栈对齐的汇编级技术可能取决于编译器和特定的操作系统环境。

示例 6-1:Examples of Dynamical Stack Alignment

// 32-bit environment
push ebp ; save ebp
mov  ebp, esp ; ebp now points to incoming parameters
andl esp, $-<N> ;align esp to N byte boundary
sub  esp, $<stack_size>; reserve space for new stack frame
. ; parameters must be referenced off of ebp
mov  esp, ebp ; restore esp
pop  ebp ; restore ebp

// 64-bit environment
sub  esp, $<stack_size +N>
mov  r13, $<offset_of_aligned_section_in_stack>
andl r13, $-<N> ; r13 point to aligned section in stack
. ;use r13 as base for aligned data

如果由于某种原因无法将堆栈对齐64位,则例程应访问该参数并将其保存到寄存器或已知的对齐存储器中,从而只会导致一次惩罚。

7 缓存中的容量限制和别名

在某些情况下,具有给定步幅的地址将竞争内存层次结构中的某些资源。 通常,缓存被实现为具有多种方式的集合关联性,每种方式由多组缓存行(或某些情况下的扇区)组成。 多个内存引用在缓存中竞争同一组的每个方式可能会导致容量问题。 有适用于特定微架构的别名条件。 请注意,一级缓存行是 64 字节。 因此,在别名比较中不考虑最低有效 6 位。

8 混合代码和数据

英特尔处理器对指令的主动预取和预解码有两个相关影响:

  • 根据英特尔体系结构处理器的要求,自修改代码可以正常工作,但会导致严重的性能损失。尽可能避免自我修改代码。
  • 在代码段中放置可写数据可能无法与自修改代码区分开来。代码段中的可写数据可能会受到与自修改代码相同的性能损失。

如果(希望是只读的)数据必须与代码出现在同一页上,请避免将其直接放在间接跳转之后。例如,跟随一个间接跳转及其最可能的目标,并将数据放在一个无条件分支之后。

在极少数情况下,将代码页上的数据作为指令执行可能会导致性能问题。当执行遵循不驻留在跟踪缓存中的间接分支时,很可能会发生这种情况。如果这明显导致性能问题,请尝试将数据移到其他位置,或在间接分支后立即插入非法操作码或暂停指令。请注意,后两种备选方案在某些情况下可能会降低性能。

始终将代码和数据放在单独的页面上。尽可能避免自我修改代码。如果要修改代码,请尝试一次完成所有操作,并确保执行修改的代码和被修改的代码位于单独的4kb页面或单独对齐的1kb子页面上。

8.1 自修改代码(Self-modifying Code)

在 Pentium III 处理器和之前的实现上正确运行的自修改代码(SMC)将在后续实现上正确运行。当需要高性能时,应避免SMC和交叉修改代码(当多处理器系统中的多个处理器写入代码页时)。

软件应避免写入正在执行的同一个1KB子页面中的代码页,或获取正在写入的同一个2KB子页面中的代码。此外,将包含直接或推测执行代码的页面作为数据页面与另一个处理器共享可能会触发SMC条件,从而导致机器的整个管道和跟踪缓存被清除。这是由于自修改代码条件造成的。

如果写入的代码在作为代码访问数据页之前填充了该页,则动态代码不必导致SMC情况。动态修改的代码(例如,来自目标修复)可能会受到SMC条件的影响,应尽可能避免。通过引入间接分支和使用register间接调用在数据页(而不是代码页)上使用数据表来避免这种情况。

8.2 位置无关代码

位置无关的代码通常需要获取指令指针的值。示例8-1a显示了一种通过发出不带匹配RET的调用将IP值放入ECX寄存器的技术。示例8-1b显示了另一种使用匹配的CALL/RET对将IP值放入ECX寄存器的技术。

示例 8-1:Instruction Pointer Query Techniques

a) Using call without return to obtain IP does not corrupt the RSB
    call _label; return address pushed is the IP of next instruction
_label:
    pop ECX; IP of this instruction is now put into ECX
b) Using matched call/ret pair
    call _lblcx;
    ... ; ECX now contains IP of this instruction
    ...
_lblcx
    mov ecx, [esp];
    ret

9 写组合

写组合(WC)通过两种方式提高性能:

  • 在一级缓存的写未命中时,它允许在缓存线从缓存/内存层次结构的更外层读取所有权(RFO)之前,对同一缓存线进行多个存储。然后读取行的其余部分,并将尚未写入的字节与返回行中未修改的字节组合。
  • 写入组合允许在高速缓存层次结构中将多个写入组合并作为一个单元进一步写入。 这节省了端口和总线流量。节省流量对于避免部分写入未缓存的内存尤为重要。

基于英特尔 Core 微架构的处理器在每个内核中有八个写入组合缓冲区。 从 Nehalem 微架构开始,有 10 个缓冲区可用于写入组合。 从 Ice Lake 客户端微架构开始,有 12 个缓冲区可用于写入组合。

如果内部循环写入超过四个数组(四个不同的缓存行),则应用循环分裂来分解循环体,以便在每个结果循环的每次迭代中只写入四个数组。

写组合缓冲区用于所有内存类型的存储。 它们对于对未缓存内存的写入特别重要:对同一缓存行的不同部分的写入可以分组到单个完整的缓存行总线事务中,而不是像多个部分写入那样通过总线(因为它们没有被缓存) . 避免部分写入会对受总线带宽限制的图形应用程序产生重大影响,其中图形缓冲区位于未缓存的内存中。 将对未缓存内存的写入和对回写内存的写入分离到单独的阶段可以确保写入组合缓冲区可以在被其他写入流量驱逐之前填满。 已发现消除部分写入事务对某些应用程序的性能影响约为 20%。 因为高速缓存行是 64 字节,所以写入总线 63 字节将导致部分总线事务。

在编写同时在两个线程上执行的函数时,减少内部循环中允许的写入次数将有助于充分利用写入组合存储缓冲区。

存储顺序和可见性也是写入组合的重要问题。 当对先前未写入的高速缓存行的写入组合缓冲区进行写入时,将发生读取所有权 (RFO)。 如果后续写入发生在另一个写入组合缓冲区,则可能会为该高速缓存行导致单独的 RFO。 对第一个高速缓存行和写入组合缓冲区的后续写入将被延迟,直到第二个 RFO 得到服务,以保证写入的正确排序可见性。 如果写入的内存类型是写入组合,则不会有 RFO,因为该行没有被缓存,并且没有这样的延迟。

10 局部增强

局部性增强可以减少来自缓存/内存层次结构中的外部子系统的数据流量。这是为了解决这样一个事实,即从外部层面的周期计数来看,访问成本将比从内部层面的成本更高。通常,访问给定缓存级别(或内存系统)的周期成本因不同的微体系结构、处理器实现和平台组件而异。按地区识别相对数据访问成本趋势可能就足够了,而不是按照每个地区、每个处理器/平台实施列出的周期成本的大型数值表,等。一般趋势是,假设数据访问并行度相似,从外部子系统访问数据的成本可能比从缓存/内存层次结构中的直接内部级别访问数据的成本大约高3-10倍。

即使最后一级缓存的缓存未命中率相对于缓存引用的数量可能较低,处理器通常会花费相当大一部分执行时间等待缓存未命中得到服务。通过增强程序的局部性来减少缓存未命中是一个关键的优化。这可以采取几种形式:

  • 阻塞以迭代将适合缓存的数组的一部分(目的是对数据块 [或 tile] 的后续引用将成为缓存命中引用)。
  • 循环交换以避免跨越高速缓存行或页面边界。
  • 循环倾斜以使访问连续。

可以通过对数据访问模式进行排序以利用硬件预取来实现对最后一级缓存的局部性增强。 这也可以采取多种形式:

  • 将稀疏填充的多维数组转换为一维数组,以便内存引用以对硬件预取友好的顺序、小步幅模式发生。
  • 最佳切片大小和形状选择可以通过提高最后一级缓存的命中率和减少硬件预取操作导致的内存流量来进一步改善时间数据局部性。

避免对局部性增强技术起作用的操作很重要。 在访问内存时,无论数据是在缓存中还是在系统内存中,大量使用锁定前缀都会导致很大的延迟。

阻塞、循环交换、循环倾斜和打包等优化技术最好由编译器完成。 优化数据结构以适应一级缓存的一半或二级缓存; 在编译器中打开循环优化以增强嵌套循环的局部性。

优化一半的一级缓存将在每次数据访问的周期成本方面带来最大的性能优势。 如果一级缓存的一半太小不实用,则针对二级缓存进行优化。 针对中间的一点进行优化(例如,针对整个一级缓存)可能不会比针对二级缓存的优化带来实质性的改进。

11 非临时存储总线流量

峰值系统总线带宽由几种类型的总线活动共享,包括读取(从内存)、读取所有权(缓存行)和写入。 如果一次将 64 个字节写入总线,则总线写事务的数据传输率会更高。

通常,写入回写 (WB) 内存的总线必须与读取所有权 (RFO) 流量共享系统总线带宽。 非临时存储不需要 RFO 流量; 它们确实需要小心管理访问模式,以确保一次收回 64 个字节(而不是收回多个块)。

尽管由于非临时存储而导致的完整 64 字节总线写入的数据带宽是总线写入 WB 内存的两倍,但传输多个块会浪费总线请求带宽并提供显着降低的数据带宽。 这种差异在示例 11-1 和 11-2 中进行了描述。

示例 11-1:Using Non-temporal Stores and 64-byte Bus Write Transactions

#define STRIDESIZE 256
lea ecx, p64byte_Aligned
mov edx, ARRAY_LEN
xor eax, eax
slloop:
movntps XMMWORD ptr [ecx + eax], xmm0
movntps XMMWORD ptr [ecx + eax+16], xmm0
movntps XMMWORD ptr [ecx + eax+32], xmm0
movntps XMMWORD ptr [ecx + eax+48], xmm0

; 64 bytes is written in one bus transaction
add eax, STRIDESIZE
cmp eax, edx
jl slloop

示例 11-2:On-temporal Stores and Partial Bus Write Transactions

#define STRIDESIZE 256
Lea ecx, p64byte_Aligned
Mov edx, ARRAY_LEN
Xor eax, eax
slloop:
movntps XMMWORD ptr [ecx + eax], xmm0
movntps XMMWORD ptr [ecx + eax+16], xmm0
movntps XMMWORD ptr [ecx + eax+32], xmm0

; Storing 48 bytes results in several bus partial transactions
add eax, STRIDESIZE
cmp eax, edx
jl slloop