目录
本文翻译自louis Touzalin(x账号:@at0m741)的文章:
C/C++ optimization, the strlen examplehttps://hallowed-blinker-3ca.notion.site/C-C-optimization-the-strlen-example-108719425da080338d94c79add2bb372
对于任何想要最大化其程序性能的开发人员来说,C/C++ 优化是一个基础主题。理解代码如何与硬件交互,可以使你充分利用现代处理器的能力。
在本文中,我们将探索 CPU 流水线、内存管理、编译器选项、高效使用寄存器以及利用 SIMD(单指令多数据)指令,并通过一个大家都已经使用过的函数示例:strlen。
揭开优化的神秘面纱...
是的,低级语言中的优化可能有很多原因让人感到相当可怕,但其实并不那么难。
确实,阅读一些源代码会带来某种抽象,可能会让人感到害怕,但在我看来并不是这样。关键词是:只是倾斜,花点时间,不要害怕... 从简单的函数开始,我将尝试给你一些关键点,以开始理解优化是可行的(在一定程度上,但稍后我们会看到这一点)。
还有,请千万不要成为这样的人:
让我们来谈谈 CPU
优化最重要的方面是理解 CPU 和内存。在 x86_64 架构中,CPU 流水线是一个关键概念,它使得现代处理器能够同时执行多个指令,极大地提高了效率。这个流水线将每个指令的执行分解为几个阶段。每个阶段处理指令执行的一个特定部分——从内存中获取它、解码它、执行它、访问内存,最后将结果写回。
在 x86_64 处理器中,流水线通常由以下阶段组成:
- 取指:CPU 从内存中检索下一个指令。
- 解码:将指令解码成 CPU 可以理解的格式。
- 执行:CPU 执行实际的操作(例如,加法、乘法)。
- 内存访问:如果指令需要访问内存(读取或写入),则在此阶段发生。
- 写回:操作的结果被写回到 CPU 寄存器或内存中。
流水线的好处在于,当一个指令正在执行时,另一个可以被解码,还有一个可以被取指。这种并行性提高了吞吐量而不需要增加时钟速度。然而,流水线并非没有挑战,特别是与冒险(一个指令依赖于前一个指令结果的情况)和分支(程序流程意外改变时)相关的问题,可能会导致流水线停滞,降低效率。
在流水线中,每个阶段需要一个时钟周期来完成。现代 CPU 旨在每个时钟周期执行一个指令(IPC:每个周期的指令数),但实际上,由于数据依赖、内存延迟或分支预测失误等因素,很少能达到这一点,IPC 通常在 2 到 4 之间。如果一个指令依赖于前一个指令的结果,CPU 可能需要等待(或“暂停”)直到该结果准备好,这会引入额外的周期并降低性能。
这是对 CPU 流水线的“简短”介绍,但现在,让我们来谈谈代码。
等等,SIMD 是什么?
SIMD(单指令多数据)是一种并行计算概念,其中单个指令同时对多个数据片段进行操作。与一次处理一个元素不同,SIMD 允许对多个数据点并行执行相同的操作(如加法、乘法或比较),通常在 CPU 的向量寄存器内。这种技术特别适合涉及重复计算的操作,例如在多媒体处理、科学模拟或大型数据集中发现的那些。
通过一次性处理多个元素,SIMD 可以大大提高性能和效率,特别是在涉及大量数据的任务中,通过减少所需的指令和迭代次数。
别担心,我稍后会用具体的例子详细解释,这一部分只是一个简单的背景。
为什么 strlen 是一个很好的例子...
当开始探索 C/C++ 中的优化 时,选择具体的示例是至关重要的,这些示例可以让你在提供清晰和可衡量的改进可能性的同时,理解基本概念。在这些示例中,strlen 函数作为一个理想的起点脱颖而出。
基本实现涉及一个循环,该循环遍历字符串中的每个字符,直到遇到空字符为止:
size_t basic_strlen(const char *str)
{
size_t len = 0;
while (str[len])
len++;
return len;
}
这种 strlen
的实现是低效的,因为它一次处理字符串中的一个字节,导致不必要的内存访问,并且没有利用现代 CPU 的能力。每次循环迭代都会增加 len
的值,并检查下一个字符,这会导致性能不佳,特别是对于长字符串。
此外,它没有利用 SIMD(单指令多数据)指令,这些指令允许 CPU 同时处理多个字节,减少迭代次数并加快执行速度。但这里有一个“更好”的版本:
#include <string.h>
#include <stdint.h>
#include <limits.h>
#define ALIGN sizeof(size_t)
#define ONES ((size_t)-1 / UCHAR_MAX)
#define HIGHS (ONES * (UCHAR_MAX / 2 + 1))
#define HASZERO(x) (((x) - ONES) & ~(x) & HIGHS)
size_t strlen(const char *s)
{
const char *start = s;
while ((uintptr_t)s % ALIGN) {
if (!*s) return s - start;
s++;
}
const size_t *w = (const size_t *)s;
while (!HASZERO(*w))
w++;
s = (const char *)w;
while (*s)
s++;
return s - start;
}
相比之下,Musl-libc 中的指针算术和对齐版本在循环中对齐内存后增加指针(s++)。它在最后计算起始和结束指针之间的差值,从而减少了每次迭代的操作次数。
这个优化版本在指针对齐后,通过一次读取 size_t
(在64位系统上通常是8字节)来处理更大的字符串块。这减少了内存访问和迭代次数,显著加快了字符串遍历的速度。此外,HASZERO
宏中使用的位运算允许高效地检测一个字中的空字节,避免了单独检查每个字节。
指针算术通常使编译器能够应用更激进的优化,例如消除冗余指令或更有效地使用寄存器。许多现代编译器擅长识别指针算术模式,并且可以比使用指针增量和单独计数器的版本更有效地优化这个版本。
尽管 Musl 版本稍微更高效一些,但值得进一步讨论 SIMD,以充分利用现代处理器提供的可能性。
你的编译器是你的朋友,但你需要和它交流
在使用像 Clang 和 GCC 这样的现代编译器时,通常会使用 -O2
或 -O3
等优化标志来自动提高生成代码的性能。这些标志指示编译器执行各种优化,包括 内联函数、循环展开、向量化(SIMD) 和常量折叠等。对于开发人员来说,这可以在不手动更改代码的情况下带来显著的性能提升。
然而,尽管编译器优化标志可以帮助,它们不是万能的解决方案,它们不能完全补偿写得不好或未经优化的代码。以下是原因:
1. 清晰和结构化的代码
某些代码模式是低效的,再多的编译器优化也无法神奇地使它们变得快速。例如,过度的分支、未优化的循环或重复的内存访问会导致性能瓶颈。一个在每次循环迭代中检查条件的函数只能优化到一定程度上。编译器可能会移除一些冗余操作,但如果逻辑本质上是低效的,性能提升将是微乎其微的。
例如,如果你实现一个 strlen
函数,它逐个检查每个字符,而没有实现像 SIMD 这样的更大数据处理操作,编译器可能会稍微加速它,但它不能自己将其转换成一个高效的 SIMD 函数。你需要编写能够利用 CPU 能力的代码。
为了看看结构良好的代码如何能让编译器在优化上走得更远,让我们以矩阵乘法为例(是的,不是 strlen,但让我来烹饪...):
void naive_matrix_multiply(int N, float **A, float **B, float **C)
{
for (int i = 0; i < N; i++)
{
for (int j = 0; j < N; j++)
{
C[i][j] = 0.0;
for (int k = 0; k < N; k++)
C[i][j] += A[i][k] * B[k][j];
}
}
}
这个函数只是执行一个简单的矩阵乘法:
但是,可以像这样写得更好:
void optimized_matrix_multiply(int N, float **A, float **B, float **C)
{
const int block_size = 64;
int i, j, k, ii, jj, kk;
for (i = 0; i < N; i++)
for (j = 0; j < N; j++)
C[i][j] = 0.0f;
for (ii = 0; ii < N; ii += block_size)
{
for (jj = 0; jj < N; jj += block_size)
{
for (kk = 0; kk < N; kk += block_size)
{
for (i = ii; i < std::min(ii + block_size, N); i++)
{
for (j = jj; j < std::min(jj + block_size, N); j++)
{
float sum = 0.0f;
for (k = kk; k < std::min(kk + block_size, N); k++)
sum += A[i][k] * B[k][j];
C[i][j] += sum;
}
}
}
}
}
}
如果你在不使用 -O3
标志的情况下编译,你会发现“朴素”的函数在处理 512x512 的矩阵时自然更快(嗯...你在做什么...让我解释一下...):
Naive matrix multiplication took: 401 ms
Opti matrix multiplication took: 518 ms
但如果我们简单地使用 -O3
:
Naive matrix multiplication took: 118 ms
Opti matrix multiplication took: 90 ms
这个优化的矩阵乘法之所以更好,是因为它采用了缓存块(分块)技术。它将矩阵划分为更小的块,这些块更适合放入CPU缓存中,减少了缓存未命中并提高了内存局部性。通过一次处理矩阵的较小部分,它最小化了从较慢的内存(RAM)加载数据的次数,从而更有效地利用CPU缓存并提高整体性能。这就是为什么代码结构和预优化如此重要。
2. 糟糕的代码模式无法被优化掉
(译注:原文此处应该是漏掉了一些内容)
3. SIMD 和低级优化通常需要手动干预
虽然像 Clang 和 GCC 这样的编译器支持自动向量化,并在某些情况下可以利用 SIMD 指令,但它们并不总是积极地这样做。在许多情况下,开发人员必须手动编写代码来利用 SIMD,或者使用内置函数或特定于编译器的标志向编译器提供清晰的提示。
例如,如果不手动调用 AVX 或 SSE 内置函数,编译器可能无法自动将基本循环转换为 SIMD 操作,特别是如果循环包含数据依赖或复杂条件,这些对于编译器来说难以分析。
SSE 和 AVX,兄弟,这是什么?
单指令多数据(SIMD) 是现代 CPU 中用于提高性能的强大技术,它通过同时对多个数据点执行相同操作来实现。SIMD 允许单个指令并行处理多个数据点,这可以显著加快数据处理任务,特别是涉及大型数据集或重复操作的任务。
SIMD 代表单指令多数据。这意味着用单个指令,处理器可以同时对多个数据项执行相同的操作。想象一下,你有一个数组中的多个数值,你想给它们每个都加上一个特定的数字。与其逐个对每个值执行这个加法,SIMD 允许处理器将它们打包处理,这要快得多。
AVX2 和 AVX512
高级向量扩展(AVX) 是对 x86 指令集架构的扩展,旨在通过启用 SIMD 操作来提高性能。AVX2 通过引入新的整数操作指令并将寄存器大小从128位扩展到256位,扩展了原始AVX的能力。AVX-512 进一步扩展了这些能力,将寄存器大小加倍到512位,允许更大的并行性。
使用 AVX2/AVX-512 的主要优势在于能够并行对多个数据元素执行操作。这种并行性可以显著减少处理大型数据数组所需的指令数量,从而提高性能并减少执行时间。此外,AVX-512 中更宽的寄存器允许在单条指令中处理更多的数据,进一步提高性能。有了这些基本解释,下一个 strlen 实现应该会更有趣……
AVX2 和 AVX-512 是增加用于向量处理的寄存器大小的扩展。
AVX2 启用了 256 位寄存器,这意味着处理器不再一次处理 16 位或 32 位,而是可以同时处理 256 位,或者说 32 个字符(因为每个字符 8 位)。
AVX-512 将这个容量翻倍,实现了 512 位操作,相当于并行处理 64 个字符。
这些扩展对于操作大型数据块特别有用,比如字符串操作或矩阵计算。
为什么内存对齐很重要?
AVX2 和 AVX-512 要求数据在内存中特定边界上对齐(AVX2 为 32 字节,AVX-512 为 64 字节)。对齐意味着我们数据的起始内存地址必须是 32 或 64 的倍数,以充分利用 AVX 寄存器。
如果数据没有对齐,处理器必须执行额外的读取来正确加载值,导致减速。这就是为什么,在您的 strlen_avx 实现中,您会注意在使用 AVX 指令之前检查对齐,并处理数据未对齐的情况。
现在我们来真正编写一个酷(且优化过的)strlen
遵循 AVX2 寄存器 的原则,即 256 位,你自然会明白可以一次增加 32 位……
主要思想是使用 AVX 寄存器的 256 位在单次操作中比较 32 个字符串字符。以下是它的逐步工作原理:
size_t _strlen_avx(const char *str)
{
const char *original_ptr = str;
__m256i ymm_zero = _mm256_set1_epi8(0);
uintptr_t misalignment = (uintptr_t)str & 31;
if (misalignment != 0)
{
size_t offset = 32 - misalignment;
__m256i ymm_data = _mm256_loadu_si256((__m256i*)str);
__m256i cmp_result = _mm256_cmpeq_epi8(ymm_zero, ymm_data);
int32_t mask = _mm256_movemask_epi8(cmp_result);
mask >>= misalignment;
if (mask != 0)
{
int32_t index = __builtin_ctz(mask);
return (size_t)(str + index - original_ptr);
}
str += offset;
}
while (1)
{
_mm_prefetch(str + 32, _MM_HINT_T0);
__m256i ymm_data = _mm256_load_si256((__m256i*)str);
__m256i cmp_result = _mm256_cmpeq_epi8(ymm_zero, ymm_data);
int32_t mask = _mm256_movemask_epi8(cmp_result);
if (mask != 0)
{
int32_t index = __builtin_ctz(mask);
return (size_t)(str + index - original_ptr);
}
str += 32;
}
return 0;
}
是的,我知道,与第一个版本相比,这看起来很可怕,但让我解释一下……我当然会解释整个函数。
if (__builtin_expect(str == NULL, 0))
return 0;
这行代码检查输入的字符串指针 str 是否为 NULL。使用 __builtin_expect 的目的是告诉编译器,字符串为 NULL 的情况非常不可能发生(第二个参数是 0),这有助于编译器优化分支预测。这对于通过指导 CPU 预测这种情况很少发生,从而提高热点代码路径的性能是有用的。
const char *original_ptr = str;
__m256i ymm_zero = _mm256_setzero_si256();
original_ptr
存储输入字符串的起始地址。稍后,它将用于通过从这个初始指针减去 str
的最终位置来计算字符串的长度。
然后,ymm_zero
使用 Intel 内置函数 _mm256_setzero_si256
创建一个用零填充的 256 位向量(AVX 寄存器)。SIMD 寄存器是一个 256 位的空间,即 32 字节(1 个字符 = 8 位)。我们将使用一个寄存器来加载字符串的 32 个字符的部分,另一个寄存器来将这些 32 个字符与 \0(空字符终止符)进行比较。
uintptr_t misalignment = (uintptr_t)str & 31;
然后我们使用按位与操作(& 31)来管理不对齐的情况,这检查指针是否对齐到 32 字节。是的,因为要充分利用 AVX2 的性能,我们需要对齐到 32 字节。
_mm_prefetch(str + 32, _MM_HINT_T0);
if (misalignment != 0)
{
int64_t offset = 32 - misalignment;
__m256i ymm_data = _mm256_loadu_si256((__m256i*)str);
__m256i cmp_result = _mm256_cmpeq_epi8(ymm_zero, ymm_data);
int32_t mask = _mm256_movemask_epi8(cmp_result);
if (mask != 0)
{
int32_t index = __builtin_ctz(mask);
return (uintptr_t)(str + index) - (uintptr_t)original_ptr;
}
str += offset;
}
当处理器需要从内存中读取数据时,由于处理器和内存之间的速度差异,会有一个延迟(延迟)。为了最小化这种影响,预加载(preloading)包括要求处理器提前加载某些数据,通过将其放置在缓存中,缓存是位于处理器附近的快速内存。
这意味着你要求处理器开始将位于 str + 64
的数据加载到缓存中。因此,当你的循环到达这个位置时,数据已经在缓存中可用,减少了等待时间,提高了整体性能。
如果数据没有对齐到 32 字节,代码通过使用 _mm256_loadu_si256
调整,这允许非对齐的内存访问。数据与零向量使用 _mm256_cmpeq_epi8
进行比较,以找到空字符终止符。mask
是由 _mm256_movemask_epi8
生成的,它将比较结果转换为一个 32 位掩码,其中每个位代表一个字节。
如果 mask
不为零,它指示空字符(\0
)的位置。__builtin_ctz(mask)
查找第一个设置位的索引,指示空字节所在的位置。然后函数返回字符串的长度。
如果没有在未对齐的部分找到空字符,str
会增加 offset
(需要对齐指针的数量)。想法是一次将字符串的 32 字节加载到 AVX 寄存器中。如果字符串正确对齐,你可以使用优化的一次性读取。
_mm_prefetch(str + 32, _MM_HINT_T0);
while (1)
{
__m256i ymm_data = _mm256_load_si256((__m256i*)str);
__m256i cmp_result = _mm256_cmpeq_epi8(ymm_zero, ymm_data);
int32_t mask = _mm256_movemask_epi8(cmp_result);
if (mask != 0)
{
int32_t index = __builtin_ctz(mask);
return (uintptr_t)(str + index) - (uintptr_t)original_ptr;
}
str += 32;
}
主循环遍历字符串的 32 字节块(每次迭代两个 32 字节加载)。每次迭代包括:
- 使用
_mm256_load_si256
加载 32 字节的对齐数据。 - 将 32 字节与
ymm_zero
比较,以检查空字节。 - 从比较结果中提取一个 32 位掩码,以识别 32 字节块中空终止符的位置。
将 32 个字符加载到 AVX 寄存器后,每个字符都会在单条指令中与 \0 进行比较。如果在前 32 字节中检测到空字节(mask != 0
),函数使用 __builtin_ctz(mask)
(计算掩码中的尾随零)找到第一个空字节的索引。然后通过从空字节的位置减去原始指针来计算字符串的长度。
一旦完成比较,我们需要知道在 32 个字符中 \0 出现在哪里。为此,我们使用一个提取位掩码的函数。
如果在前 32 字节中没有找到空字节,函数会以相同的方式检查接下来的 32 字节(str + 32
)。如果它找到了空字节,它将返回计算出的长度。
如果在整个 64 字节块(两次 32 字节加载)中都没有找到空字节,函数将 str
指针向前移动 64 字节,并继续循环。
对齐对于 AVX 指令的性能至关重要,因为处理器在读取对齐良好的数据块时效率更高。然而,字符字符串往往不会对齐在 32 或 64 字节边界上。
在这种情况下,你需要手动处理不对齐的情况:
- 如果字符串从一个不对齐的地址开始,你使用不对齐加载指令(
_mm256_loadu_si256
)单独处理初始字符。 - 一旦确保了对齐,剩余的字符串就可以使用 AVX2 和
_mm256_load_si256
高效地以 32 字节块处理。
这种对齐的仔细管理允许你充分利用 SIMD 的力量,即使在数据不对齐时也不会损失性能。
性能分析…
在运行时很容易判断代码是否经过优化,但仍然值得从 CPU 流水线利用的角度进行性能分析。为此,我们将使用 LLVM-mca 工具,它允许我们分析编译时的输出。
terations: 100
Instructions: 4400
Total Cycles: 1515
Total uOps: 5600
Dispatch Width: 4
uOps Per Cycle: 3.70
IPC: 2.90
Block RThroughput: 14.0
Instruction Info:
[1]: #uOps
[2]: Latency
[3]: RThroughput
[4]: MayLoad
[5]: MayStore
[6]: HasSideEffects (U)
[1] [2] [3] [4] [5] [6] Instructions:
1 1 0.33 test rdi, rdi
1 1 1.00 je .LBB0_1
1 1 0.33 mov rcx, rdi
1 5 0.50 * * prefetcht0 byte ptr [rdi + 32]
1 1 0.33 mov rax, rdi
1 1 0.33 and rcx, 31
1 1 1.00 je .LBB0_7
1 0 0.25 vpxor xmm0, xmm0, xmm0
2 8 0.50 * vpcmpeqb ymm0, ymm0, ymmword ptr [rdi]
1 2 1.00 vpmovmskb eax, ymm0
1 1 0.33 test eax, eax
1 1 1.00 je .LBB0_6
1 3 1.00 bsf eax, eax
4 1 1.00 U vzeroupper
1 1 1.00 U ret
1 1 0.33 neg rcx
1 1 0.50 lea rax, [rdi + rcx]
1 1 0.33 add rax, 32
1 5 0.50 * * prefetcht0 byte ptr [rax + 64]
1 1 0.33 mov rcx, rax
1 1 0.33 sub rcx, rdi
1 0 0.25 vpxor xmm0, xmm0, xmm0
2 8 0.50 * vpcmpeqb ymm1, ymm0, ymmword ptr [rax]
1 2 1.00 vpmovmskb edx, ymm1
1 1 0.33 test edx, edx
1 1 1.00 jne .LBB0_9
2 8 0.50 * vpcmpeqb ymm1, ymm0, ymmword ptr [rax + 32]
1 2 1.00 vpmovmskb edx, ymm1
1 1 0.33 test edx, edx
1 1 1.00 jne .LBB0_11
1 1 0.33 add rax, 64
1 1 0.33 add rcx, 64
1 1 1.00 jmp .LBB0_8
1 3 1.00 bsf eax, edx
1 1 0.33 add rax, rcx
4 1 1.00 U vzeroupper
1 1 1.00 U ret
1 3 1.00 bsf eax, edx
1 1 0.33 add rax, rcx
1 1 0.33 add rax, 32
4 1 1.00 U vzeroupper
1 1 1.00 U ret
1 0 0.25 xor eax, eax
1 1 1.00 U ret
Resources:
[0] - SBDivider
[1] - SBFPDivider
[2] - SBPort0
[3] - SBPort1
[4] - SBPort4
[5] - SBPort5
[6.0] - SBPort23
[6.1] - SBPort23
Resource pressure per iteration:
[0] [1] [2] [3] [4] [5] [6.0] [6.1]
- - 10.98 11.49 - 13.53 2.50 2.50
Resource pressure by instruction:
[0] [1] [2] [3] [4] [5] [6.0] [6.1] Instructions:
- - 0.99 - - 0.01 - - test rdi, rdi
- - - - - 1.00 - - je .LBB0_1
- - 0.99 0.01 - - - - mov rcx, rdi
- - - - - - 0.50 0.50 prefetcht0 byte ptr [rdi + 32]
- - 0.01 0.99 - - - - mov rax, rdi
- - 0.49 0.51 - - - - and rcx, 31
- - - - - 1.00 - - je .LBB0_7
- - - - - - - - vpxor xmm0, xmm0, xmm0
- - - 1.00 - - 0.50 0.50 vpcmpeqb ymm0, ymm0, ymmword ptr [rdi]
- - 1.00 - - - - - vpmovmskb eax, ymm0
- - - 1.00 - - - - test eax, eax
- - - - - 1.00 - - je .LBB0_6
- - - 1.00 - - - - bsf eax, eax
- - - - - - - - vzeroupper
- - - - - 1.00 - - ret
- - 1.00 - - - - - neg rcx
- - 1.00 - - - - - lea rax, [rdi + rcx]
- - 0.99 - - 0.01 - - add rax, 32
- - - - - - 0.50 0.50 prefetcht0 byte ptr [rax + 64]
- - - 1.00 - - - - mov rcx, rax
- - 0.01 0.49 - 0.50 - - sub rcx, rdi
- - - - - - - - vpxor xmm0, xmm0, xmm0
- - - 0.50 - 0.50 0.50 0.50 vpcmpeqb ymm1, ymm0, ymmword ptr [rax]
- - 1.00 - - - - - vpmovmskb edx, ymm1
- - 0.50 0.49 - 0.01 - - test edx, edx
- - - - - 1.00 - - jne .LBB0_9
- - - 0.51 - 0.49 0.50 0.50 vpcmpeqb ymm1, ymm0, ymmword ptr [rax + 32]
- - 1.00 - - - - - vpmovmskb edx, ymm1
- - - 1.00 - - - - test edx, edx
- - - - - 1.00 - - jne .LBB0_11
- - - 0.49 - 0.51 - - add rax, 64
- - - 0.01 - 0.99 - - add rcx, 64
- - - - - 1.00 - - jmp .LBB0_8
- - - 1.00 - - - - bsf eax, edx
- - 0.50 - - 0.50 - - add rax, rcx
- - - - - - - - vzeroupper
- - - - - 1.00 - - ret
- - - 1.00 - - - - bsf eax, edx
- - 1.00 - - - - - add rax, rcx
- - 0.50 0.49 - 0.01 - - add rax, 32
- - - - - - - - vzeroupper
- - - - - 1.00 - - ret
- - - - - - - - xor eax, eax
- - - - - 1.00 - - ret
- IPC(每周期指令数)为 2.90 在现代处理器上是极好的。这个指标表明,平均而言,我们的代码每个 CPU 周期执行将近 3 条指令。由于大多数现代 CPU 的最大 IPC 大约为 4(由于发射宽度),达到 2.90 意味着你的代码非常高效地利用了 CPU 流水线。
- 每周期微操作数为 3.70,加上 4 的发射宽度,我们的代码几乎饱和了流水线。这表明你的代码让执行单元保持忙碌,最大化了吞吐量。
- 块吞吐量为 14.0 周期 表示你的循环(或代码块)的每次迭代(或代码块)平均需要 14 个周期来执行。这个低值反映了代码块高度优化且效率很高。
每次迭代的资源压力:
- 资源压力表明每次执行每个执行端口或单元被利用的程度。
- 使用的端口:
- 端口 2 和 3: 使用频繁(大约各 11 个单元)。这些端口处理整数 ALU 操作、分支和一些 SIMD 操作。
- 端口 5: 压力最高(大约 13.53 个单元)。这个端口通常处理内存加载操作。
- 端口 6.0 和 6.1: 中等压力(各 2.50 个单元)。这些端口处理 SIMD 操作,特别是涉及 256 位 AVX 指令的操作。
- 内存访问指令:
vpcmpeqb
带有内存操作数,由于内存加载,对端口 5 造成压力。- 预取指令(
prefetcht0
)也增加了端口 5 的压力。
- SIMD 指令:
vpcmpeqb
和vpmovmskb
由于它们的 SIMD 特性,对端口 2、3、6.0 和 6.1 造成压力。
- 控制流指令:
- 分支指令(
je
、jne
、jmp
)增加了端口 5 的压力。
- 分支指令(
这个 strlen
实现利用了几个关键的优化技术以获得最大性能。它检查空指针并处理内存对齐,确保高效的内存访问。通过使用 SIMD(单指令多数据)通过 AVX 指令一次处理 32 字节,它显著减少了与逐字符方法相比的迭代次数。
该函数使用预取来减少内存延迟,在需要之前将数据加载到缓存中。这有助于 CPU 保持高吞吐量。此外,它处理未对齐和对齐的内存访问,有效处理即使是未对齐的字符串。这些综合技术使得这个 AVX 优化的 strlen
比传统实现快得多,特别是对于长字符串。
基准测试
现在,我们来看一看我们的优化是否真的有所作为。是时候进行一些基准测试,检查所有花哨的 SIMD 指令和预取技巧是否真的值得。基准测试给我们提供了冷酷而坚硬的数字——我们将发现这些调整是否真的加快了我们的 strlen
函数的速度,或者它们只是噪声。通过比较原始实现和我们的优化版本,我们可以看到代码在实际条件下运行得有多快(或者不是)。
对于长度为 473 和 603 的字符串的基准测试,我们观察到以下关键点:
- 标准
strlen
表现得非常高效,时间范围在 0.00006 - 0.00007 秒之间。这是预期的,因为它在标准库中已经过良好优化。 strlen_avx
显示出竞争力的性能,但比标准实现略慢。AVX 优化可能对这些较短的字符串没有完全的好处,其中开销可能超过收益。strlen_avx_ultimate
提供的结果非常接近标准strlen
,表明这个版本中进一步的优化对于短字符串是有效的。strlen_AVX_asm
,可能包括内联汇编和 AVX 指令,比纯 AVX 版本稍慢。这可能是由于汇编指令或寄存器管理的开销,特别是在处理较短的字符串时。但是让我来烹饪…pointer_strlen
对于较短的字符串明显较慢,耗时超过 0.0004 秒。块读取优化在这种规模上不太有效,简单的指针算术对于短长度的字符串没有太多好处。stupid_strlen
是最慢的,耗时约 0.0014 秒。这种实现可能没有预取、展开、SIMD 等优化,其方法可能对任何字符串长度都不够高效。
总结来说,对于较短的字符串,标准 strlen 和优化过的 AVX 版本(strlen_avx 和 strlen_avx_ultimate)表现最佳,而像 pointer_strlen 和 stupid_strlen 这样的自定义实现则落后。
对于较大字符串(长度为 1,048,575)的基准测试,结果显示了几个重要的趋势:
- 标准
strlen
表现非常好,时间一致在 0.150 秒左右。这是预期的,因为标准库版本针对常见情况高度优化,使用了调整良好的循环展开、预取和可能的底层向量化指令。 strlen_avx_ultimate
非常接近标准strlen
,性能在 0.151 到 0.153 秒之间。这表明终极 AVX 版本提供了类似的好处,可能由于在长数据块上高效的向量化操作。设置 AVX 操作的轻微开销可能阻止了它大幅度超越标准实现,但它仍然非常高效。strlen_avx
稍慢,大约在 0.161 秒范围。这表明虽然 AVX 指令有帮助,但这个版本的实现可能存在一些效率问题,或者它可能没有充分发挥 AVX 的潜力,特别是与“终极”版本相比。strlen_AVX_asm
明显慢得多,大约在 0.36 秒。尽管使用了 AVX,这个版本涉及内联汇编,可能会因为手动寄存器管理和函数调用开销引入开销。汇编优化有时可能难以在不同的 CPU 上管理,对于长字符串,这种方法不太有效。pointer_strlen
大约需要 0.566 秒。这个实现使用基本的指针算术并一次读取多个字符。对于大字符串,这种方法因为不利用任何 SIMD(单指令多数据)优化或 CPU 特定指令以更大块处理数据而受到影响。stupid_strlen
是最慢的,大约需要 2.85 秒。这个结果是预期的,因为这个版本可能一次处理一个字符,没有任何优化,导致大字符串的巨大性能损失。
对于大字符串,标准 strlen
和 strlen_avx_ultimate
是最快的,AVX 版本利用向量化指令一次处理多个字符。strlen_AVX_asm
表现不佳,可能由于手动汇编管理的开销。pointer_strlen
和 stupid_strlen
明显慢得多,突出了在处理大量数据时使用 SIMD 等高级技术的重要性。
也许可以是一个汇编版本...
即使在 C 语言中可以实现类似的性能,用汇编语言编写 strlen
函数可以精确控制硬件特定的特性和优化,这些可能是编译器无法充分利用的。汇编使你能够利用高级处理器指令,如 SIMD 操作,并针对目标架构精确编写代码,可能在关键部分挤出额外的性能。
此外,用汇编编写确保了一致和可预测的行为,绕过了不同编译器优化或 C 语言版本可能带来的变异性。当需要最大效率,即使是微小的性能提升也很重要时,这种控制水平是非常宝贵的。
以下是我们的 strlen
函数:
section .text
global ft_strlen_avx_asm
%define PAGE_SIZE 4096
%define VEC_SIZE 32
ft_strlen_avx_asm:
test rdi, rdi
je return_zero
mov rsi, rdi
mov rax, rdi
and eax, VEC_SIZE - 1
jz aligned_start
mov ecx, VEC_SIZE
sub ecx, eax
prefetcht0 [rdi + PAGE_SIZE]
vpxor xmm0, xmm0, xmm0
vmovdqu xmm1, [rdi]
vpcmpeqb xmm1, xmm1, xmm0
pmovmskb edx, xmm1
test edx, edx
jne found_null_unaligned
add rdi, rcx
vzeroupper
jmp aligned_start
aligned_start:
vpxor ymm0, ymm0, ymm0
aligned_loop:
vmovdqa ymm1, [rdi]
vpcmpeqb ymm1, ymm1, ymm0
vpmovmskb edx, ymm1
test edx, edx
jne found_null_aligned
add rdi, VEC_SIZE
jmp aligned_loop
found_null_unaligned:
bsf eax, edx
sub rdi, rsi
add rax, rdi
vzeroupper
ret
found_null_aligned:
bsf eax, edx
sub rdi, rsi
add rax, rdi
vzeroupper
ret
return_zero:
xor eax, eax
vzeroupper
ret
汇编代码中,每条指令对应特定的低级操作,许多操作在 C 语言中可以使用 AVX 内置函数或标准 C 代码找到等价物。
test rdi, rdi
指令检查字符串指针是否为空,这在 C 中相当于if (str == NULL)
。je return_zero
如果设置了零标志,则跳转到返回标签,类似于 C 中直接返回零的if
语句。mov rsi, rdi
和mov rax, rdi
将字符串指针复制到其他寄存器,这类似于 C 中的const char *original_ptr = str
。and eax, VEC_SIZE - 1
通过计算指针与向量大小的模数来检查对齐,相当于 C 中的uintptr_t misalignment = (uintptr_t)str & 31
。prefetcht0 [rdi + PAGE_SIZE]
将内存预取到缓存中,类似于 C 中的_mm_prefetch(str + PAGE_SIZE, _MM_HINT_T0)
。vpxor xmm0, xmm0, xmm0
将一个 AVX 寄存器清零,相当于 C 中的__m256i ymm_zero = _mm256_setzero_si256()
。vmovdqu xmm1, [rdi]
从内存中加载未对齐的数据到寄存器,这在 C 中对应于_mm256_loadu_si256((__m256i*)str)
。vpcmpeqb xmm1, xmm1, xmm0
逐字节比较加载的数据与零,这与 C 中的_mm256_cmpeq_epi8(ymm_data, ymm_zero)
相同。pmovmskb edx, xmm1
创建一个掩码位,其中每个位代表字节比较的结果,相当于 C 中的_mm256_movemask_epi8(cmp_result)
。bsf eax, edx
扫描第一个设置的位,可以用 C 表示为__builtin_ctz(mask)
,而sub rdi, rsi
计算长度,类似于 C 中的指针减法(uintptr_t)(str + index) - (uintptr_t)original_ptr
。
这个版本使用了完全相同的逻辑,不同的是使用汇编器意味着你可以直接操作寄存器,使你从编译器中解放出来,编译器有时可能会遗漏某些优化。
但在一些基准测试之后:
是的,比 Glibc 快,但对字符串来说(并非总是这样,因为技术问题)。
总结:C/C++ 中低级优化的力量
在 C/C++ 中优化代码可能一开始看起来令人生畏,但一旦你深入其中,实际上非常令人满意。从像 strlen 这样的简单事物开始,然后逐渐使用 指针算术、SIMD 和 AVX 等技巧进行调整,展示了你对性能可以有多大的控制权。
这里的真正收获是什么?编译器很智能,但它们不能承担所有重任。你必须编写代码,利用现代 CPU 的特性,有时你得亲自动手处理事情——比如内存对齐或使用那些美味的 AVX 指令。一旦你这样做了,结果可能是惊人的,特别是对于大规模数据处理。
当然,像 循环展开 和 预取 这样的优化可能会变得相当复杂,可能不总是带来巨大的收益,但它们是极好的例子,展示了当你了解底层发生了什么时,你可以将性能提升到什么程度。最好的部分是什么?这不仅仅是关于编写更快的代码——而是关于学习以一种全新的方式思考你的程序如何与硬件交互。
参考文献:
- Intel intrinsics : https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html
- Musl lib : musl - musl - an implementation of the standard library for Linux-based systems
- Glibc : The GNU C Library- GNU Project - Free Software Foundation
- Memory pdf : https://people.freebsd.org/~lstewart/articles/cpumemory.pdf
- Opti pdf : https://www.agner.org/optimize/optimizing_cpp.pdf
- Simpl_libc : https://github.com/at0m741/SIMPL_libc