目录
C/C++ optimization, the strlen examplehttps://hallowed-blinker-3ca.notion.site/C-C-optimization-the-strlen-example-108719425da080338d94c79add2bb372
揭开优化的神秘面纱...
让我们来谈谈 CPU
等等c;SIMD 是什么?
为什么 strlen 是一个很好的例子...
你的编译器是你的朋友c;但你需要和它交流
1. 清晰和结构化的代码
2. 糟糕的代码模式无法被优化掉
3. SIMD 和低级优化通常需要手动干预
AVX2 和 AVX512
为什么内存对齐很重要?
现在我们来真正编写一个酷(且优化过的)strlen
性能分析…
每次迭代的资源压力:
基准测试
也许可以是一个汇编版本...
参考文献:
本文翻译自louis Touzalin(x账号:@at0m741)的文章:
对于任何想要最大化其程序性能的开发人员来说c;C/C++ 优化是一个基础主题。理解代码如何与硬件交互c;可以使你充分利用现代处理器的能力。
在本文中c;我们将探索 CPU 流水线、内存管理、编译器选项、高效使用寄存器以及利用 SIMD(单指令多数据)指令c;并通过一个大家都已经使用过的函数示例:strlen。
是的c;低级语言中的优化可能有很多原因让人感到相当可怕c;但其实并不那么难。
确实c;阅读一些源代码会带来某种抽象c;可能会让人感到害怕c;但在我看来并不是这样。关键词是:只是倾斜c;花点时间c;不要害怕... 从简单的函数开始c;我将尝试给你一些关键点c;以开始理解优化是可行的(在一定程度上c;但稍后我们会看到这一点)。
还有c;请千万不要成为这样的人:
c="https://i-blog.csdnimg.cn/direct/6efe49866d0a43008065d22e80a49c2e.png" alt="6efe49866d0a43008065d22e80a49c2e.png" />
优化最重要的方面是理解 CPU 和内存。在 x86_64 架构中c;CPU 流水线是一个关键概念c;它使得现代处理器能够同时执行多个指令c;极大地提高了效率。这个流水线将每个指令的执行分解为几个阶段。每个阶段处理指令执行的一个特定部分——从内存中获取它、解码它、执行它、访问内存c;最后将结果写回。
在 x86_64 处理器中c;流水线通常由以下阶段组成:
流水线的好处在于c;当一个指令正在执行时c;另一个可以被解码c;还有一个可以被取指。这种并行性提高了吞吐量而不需要增加时钟速度。然而c;流水线并非没有挑战c;特别是与冒险(一个指令依赖于前一个指令结果的情况)和分支(程序流程意外改变时)相关的问题c;可能会导致流水线停滞c;降低效率。
c="https://i-blog.csdnimg.cn/direct/dbeebc9b197f4875870cb2ff0a8cc874.png" alt="dbeebc9b197f4875870cb2ff0a8cc874.png" />
在流水线中c;每个阶段需要一个时钟周期来完成。现代 CPU 旨在每个时钟周期执行一个指令(IPC:每个周期的指令数)c;但实际上c;由于数据依赖、内存延迟或分支预测失误等因素c;很少能达到这一点c;IPC 通常在 2 到 4 之间。如果一个指令依赖于前一个指令的结果c;CPU 可能需要等待(或“暂停”)直到该结果准备好c;这会引入额外的周期并降低性能。
这是对 CPU 流水线的“简短”介绍c;但现在c;让我们来谈谈代码。
SIMD(单指令多数据)是一种并行计算概念c;其中单个指令同时对多个数据片段进行操作。与一次处理一个元素不同c;SIMD 允许对多个数据点并行执行相同的操作(如加法、乘法或比较)c;通常在 CPU 的向量寄存器内。这种技术特别适合涉及重复计算的操作c;例如在多媒体处理、科学模拟或大型数据集中发现的那些。
c="https://i-blog.csdnimg.cn/direct/daad59a99d3b43de8fb41c9ac2b1c177.png" alt="daad59a99d3b43de8fb41c9ac2b1c177.png" />
通过一次性处理多个元素c;SIMD 可以大大提高性能和效率c;特别是在涉及大量数据的任务中c;通过减少所需的指令和迭代次数。
别担心c;我稍后会用具体的例子详细解释c;这一部分只是一个简单的背景。
当开始探索 C/C++ 中的优化 时c;选择具体的示例是至关重要的c;这些示例可以让你在提供清晰和可衡量的改进可能性的同时c;理解基本概念。在这些示例中c;strlen 函数作为一个理想的起点脱颖而出。
基本实现涉及一个循环c;该循环遍历字符串中的每个字符c;直到遇到空字符为止:
<code class="language-cpp">size_t basic_strlen(const char *str) {size_t len = 0;while (str[len])len++;return len; }code>
这种 <code>strlencode> 的实现是低效的c;因为它一次处理字符串中的一个字节c;导致不必要的内存访问c;并且没有利用现代 CPU 的能力。每次循环迭代都会增加 <code>lencode> 的值c;并检查下一个字符c;这会导致性能不佳c;特别是对于长字符串。
此外c;它没有利用 SIMD(单指令多数据)指令c;这些指令允许 CPU 同时处理多个字节c;减少迭代次数并加快执行速度。但这里有一个“更好”的版本:
<code class="language-cpp">#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; } code>
相比之下c;Musl-libc 中的指针算术和对齐版本在循环中对齐内存后增加指针(s++)。它在最后计算起始和结束指针之间的差值c;从而减少了每次迭代的操作次数。
c="https://i-blog.csdnimg.cn/direct/27ef3b596a9b4d388f403ef873405436.png" alt="27ef3b596a9b4d388f403ef873405436.png" />
这个优化版本在指针对齐后c;通过一次读取 <code>size_tcode>(在64位系统上通常是8字节)来处理更大的字符串块。这减少了内存访问和迭代次数c;显著加快了字符串遍历的速度。此外c;<code>HASZEROcode> 宏中使用的位运算允许高效地检测一个字中的空字节c;避免了单独检查每个字节。
指针算术通常使编译器能够应用更激进的优化c;例如消除冗余指令或更有效地使用寄存器。许多现代编译器擅长识别指针算术模式c;并且可以比使用指针增量和单独计数器的版本更有效地优化这个版本。
尽管 Musl 版本稍微更高效一些c;但值得进一步讨论 SIMDc;以充分利用现代处理器提供的可能性。
在使用像 Clang 和 GCC 这样的现代编译器时c;通常会使用 <code>-O2code> 或 <code>-O3code> 等优化标志来自动提高生成代码的性能。这些标志指示编译器执行各种优化c;包括 内联函数、循环展开、向量化(SIMD) 和常量折叠等。对于开发人员来说c;这可以在不手动更改代码的情况下带来显著的性能提升。
c="https://i-blog.csdnimg.cn/direct/2bd65842390440c3b920c4e5c6fecbd5.png" alt="2bd65842390440c3b920c4e5c6fecbd5.png" />
然而c;尽管编译器优化标志可以帮助c;它们不是万能的解决方案c;它们不能完全补偿写得不好或未经优化的代码。以下是原因:
某些代码模式是低效的c;再多的编译器优化也无法神奇地使它们变得快速。例如c;过度的分支、未优化的循环或重复的内存访问会导致性能瓶颈。一个在每次循环迭代中检查条件的函数只能优化到一定程度上。编译器可能会移除一些冗余操作c;但如果逻辑本质上是低效的c;性能提升将是微乎其微的。
例如c;如果你实现一个 <code>strlencode> 函数c;它逐个检查每个字符c;而没有实现像 SIMD 这样的更大数据处理操作c;编译器可能会稍微加速它c;但它不能自己将其转换成一个高效的 SIMD 函数。你需要编写能够利用 CPU 能力的代码。
为了看看结构良好的代码如何能让编译器在优化上走得更远c;让我们以矩阵乘法为例(是的c;不是 strlenc;但让我来烹饪...):
<code class="language-cpp">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];}} }code>
这个函数只是执行一个简单的矩阵乘法:
class="mathcode" src="https://latex.csdn.net/eq?C_%7Bi%2Cj%7D%20%3D%20%5Csum_%7Bk%3D1%7D%5E%7BN%7D%20A_%7Bi%2Ck%7D%20%5Ccdot%20B_%7Bk%2Cj%7D" alt="eq?C_%7Bi%2Cj%7D%20%3D%20%5Csum_%7Bk%3D1%7D%5E%7BN%7D%20A_%7Bi%2Ck%7D%20%5Ccdot%20B_%7Bk%2Cj%7D" />
class="mathcode" src="https://latex.csdn.net/eq?%5Cbegin%7Bbmatrix%7D%20a_%7B11%7D%20%26%20a_%7B12%7D%20%5C%5C%20a_%7B21%7D%20%26%20a_%7B22%7D%20%5Cend%7Bbmatrix%7D%20%5Ctimes%20%5Cbegin%7Bbmatrix%7D%20b_%7B11%7D%20%26%20b_%7B12%7D%20%5C%5C%20b_%7B21%7D%20%26%20b_%7B22%7D%20%5Cend%7Bbmatrix%7D%20%3D%20%5Cbegin%7Bbmatrix%7D%20a_%7B11%7Db_%7B11%7D%20+%20a_%7B12%7Db_%7B21%7D%20%26%20a_%7B11%7Db_%7B12%7D%20+%20a_%7B12%7Db_%7B22%7D%20%5C%5C%20a_%7B21%7Db_%7B11%7D%20+%20a_%7B22%7Db_%7B21%7D%20%26%20a_%7B21%7Db_%7B12%7D%20+%20a_%7B22%7Db_%7B22%7D%20%5Cend%7Bbmatrix%7D" alt="eq?%5Cbegin%7Bbmatrix%7D%20a_%7B11%7D%20%26%20a_%7B12%7D%20%5C%5C%20a_%7B21%7D%20%26%20a_%7B22%7D%20%5Cend%7Bbmatrix%7D%20%5Ctimes%20%5Cbegin%7Bbmatrix%7D%20b_%7B11%7D%20%26%20b_%7B12%7D%20%5C%5C%20b_%7B21%7D%20%26%20b_%7B22%7D%20%5Cend%7Bbmatrix%7D%20%3D%20%5Cbegin%7Bbmatrix%7D%20a_%7B11%7Db_%7B11%7D%20+%20a_%7B12%7Db_%7B21%7D%20%26%20a_%7B11%7Db_%7B12%7D%20+%20a_%7B12%7Db_%7B22%7D%20%5C%5C%20a_%7B21%7Db_%7B11%7D%20+%20a_%7B22%7Db_%7B21%7D%20%26%20a_%7B21%7Db_%7B12%7D%20+%20a_%7B22%7Db_%7B22%7D%20%5Cend%7Bbmatrix%7D" />
但是c;可以像这样写得更好:
<code class="language-cpp">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;}}}}} }code>
如果你在不使用 <code>-O3code> 标志的情况下编译c;你会发现“朴素”的函数在处理 512x512 的矩阵时自然更快(嗯...你在做什么...让我解释一下...):
<code class="language-cpp"> Naive matrix multiplication took: 401 msOpti matrix multiplication took: 518 mscode>
<code class="language-cpp">Naive matrix multiplication took: 118 ms Opti matrix multiplication took: 90 mscode>
这个优化的矩阵乘法之所以更好c;是因为它采用了缓存块(分块)技术。它将矩阵划分为更小的块c;这些块更适合放入CPU缓存中c;减少了缓存未命中并提高了内存局部性。通过一次处理矩阵的较小部分c;它最小化了从较慢的内存(RAM)加载数据的次数c;从而更有效地利用CPU缓存并提高整体性能。这就是为什么代码结构和预优化如此重要。
(译注:原文此处应该是漏掉了一些内容)
虽然像 Clang 和 GCC 这样的编译器支持自动向量化c;并在某些情况下可以利用 SIMD 指令c;但它们并不总是积极地这样做。在许多情况下c;开发人员必须手动编写代码来利用 SIMDc;或者使用内置函数或特定于编译器的标志向编译器提供清晰的提示。
例如c;如果不手动调用 AVX 或 SSE 内置函数c;编译器可能无法自动将基本循环转换为 SIMD 操作c;特别是如果循环包含数据依赖或复杂条件c;这些对于编译器来说难以分析。
单指令多数据(SIMD) 是现代 CPU 中用于提高性能的强大技术c;它通过同时对多个数据点执行相同操作来实现。SIMD 允许单个指令并行处理多个数据点c;这可以显著加快数据处理任务c;特别是涉及大型数据集或重复操作的任务。
SIMD 代表单指令多数据。这意味着用单个指令c;处理器可以同时对多个数据项执行相同的操作。想象一下c;你有一个数组中的多个数值c;你想给它们每个都加上一个特定的数字。与其逐个对每个值执行这个加法c;SIMD 允许处理器将它们打包处理c;这要快得多。
c="https://i-blog.csdnimg.cn/direct/bc37f62641c140c1bf979121ef6abed1.png" alt="bc37f62641c140c1bf979121ef6abed1.png" />
高级向量扩展(AVX) 是对 x86 指令集架构的扩展c;旨在通过启用 SIMD 操作来提高性能。AVX2 通过引入新的整数操作指令并将寄存器大小从128位扩展到256位c;扩展了原始AVX的能力。AVX-512 进一步扩展了这些能力c;将寄存器大小加倍到512位c;允许更大的并行性。
c="https://i-blog.csdnimg.cn/direct/e2dcd60cd98d4155ab4ced85c2451bcc.png" alt="e2dcd60cd98d4155ab4ced85c2451bcc.png" />
使用 AVX2/AVX-512 的主要优势在于能够并行对多个数据元素执行操作。这种并行性可以显著减少处理大型数据数组所需的指令数量c;从而提高性能并减少执行时间。此外c;AVX-512 中更宽的寄存器允许在单条指令中处理更多的数据c;进一步提高性能。有了这些基本解释c;下一个 strlen 实现应该会更有趣……
AVX2 和 AVX-512 是增加用于向量处理的寄存器大小的扩展。
AVX2 启用了 256 位寄存器c;这意味着处理器不再一次处理 16 位或 32 位c;而是可以同时处理 256 位c;或者说 32 个字符(因为每个字符 8 位)。
AVX-512 将这个容量翻倍c;实现了 512 位操作c;相当于并行处理 64 个字符。
这些扩展对于操作大型数据块特别有用c;比如字符串操作或矩阵计算。
AVX2 和 AVX-512 要求数据在内存中特定边界上对齐(AVX2 为 32 字节c;AVX-512 为 64 字节)。对齐意味着我们数据的起始内存地址必须是 32 或 64 的倍数c;以充分利用 AVX 寄存器。
如果数据没有对齐c;处理器必须执行额外的读取来正确加载值c;导致减速。这就是为什么c;在您的 strlen_avx 实现中c;您会注意在使用 AVX 指令之前检查对齐c;并处理数据未对齐的情况。
遵循 AVX2 寄存器 的原则c;即 256 位c;你自然会明白可以一次增加 32 位……
主要思想是使用 AVX 寄存器的 256 位在单次操作中比较 32 个字符串字符。以下是它的逐步工作原理:
<code class="language-cpp">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; } code>
是的c;我知道c;与第一个版本相比c;这看起来很可怕c;但让我解释一下……我当然会解释整个函数。
<code class="language-bash"> if (__builtin_expect(str == NULL, 0))return 0;code>
这行代码检查输入的字符串指针 str 是否为 NULL。使用 __builtin_expect 的目的是告诉编译器c;字符串为 NULL 的情况非常不可能发生(第二个参数是 0)c;这有助于编译器优化分支预测。这对于通过指导 CPU 预测这种情况很少发生c;从而提高热点代码路径的性能是有用的。
<code class="language-bash">const char *original_ptr = str; __m256i ymm_zero = _mm256_setzero_si256();code>
<code>original_ptrcode> 存储输入字符串的起始地址。稍后c;它将用于通过从这个初始指针减去 <code>strcode> 的最终位置来计算字符串的长度。
然后c;<code>ymm_zerocode> 使用 Intel 内置函数 <code>_mm256_setzero_si256code> 创建一个用零填充的 256 位向量(AVX 寄存器)。SIMD 寄存器是一个 256 位的空间c;即 32 字节(1 个字符 = 8 位)。我们将使用一个寄存器来加载字符串的 32 个字符的部分c;另一个寄存器来将这些 32 个字符与 \0(空字符终止符)进行比较。
<code class="language-cpp">uintptr_t misalignment = (uintptr_t)str & 31;code>
然后我们使用按位与操作(& 31)来管理不对齐的情况c;这检查指针是否对齐到 32 字节。是的c;因为要充分利用 AVX2 的性能c;我们需要对齐到 32 字节。
<code class="language-cpp">_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; }code>
当处理器需要从内存中读取数据时c;由于处理器和内存之间的速度差异c;会有一个延迟(延迟)。为了最小化这种影响c;预加载(preloading)包括要求处理器提前加载某些数据c;通过将其放置在缓存中c;缓存是位于处理器附近的快速内存。
这意味着你要求处理器开始将位于 <code>str + 64code> 的数据加载到缓存中。因此c;当你的循环到达这个位置时c;数据已经在缓存中可用c;减少了等待时间c;提高了整体性能。
如果数据没有对齐到 32 字节c;代码通过使用 <code>_mm256_loadu_si256code> 调整c;这允许非对齐的内存访问。数据与零向量使用 <code>_mm256_cmpeq_epi8code> 进行比较c;以找到空字符终止符。<code>maskcode> 是由 <code>_mm256_movemask_epi8code> 生成的c;它将比较结果转换为一个 32 位掩码c;其中每个位代表一个字节。
如果 <code>maskcode> 不为零c;它指示空字符(<code>\0code>)的位置。<code>__builtin_ctz(mask)code> 查找第一个设置位的索引c;指示空字节所在的位置。然后函数返回字符串的长度。
如果没有在未对齐的部分找到空字符c;<code>strcode> 会增加 <code>offsetcode>(需要对齐指针的数量)。想法是一次将字符串的 32 字节加载到 AVX 寄存器中。如果字符串正确对齐c;你可以使用优化的一次性读取。
<code class="language-cpp"> _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;} code>
主循环遍历字符串的 32 字节块(每次迭代两个 32 字节加载)。每次迭代包括:
将 32 个字符加载到 AVX 寄存器后c;每个字符都会在单条指令中与 \0 进行比较。如果在前 32 字节中检测到空字节(<code>mask != 0code>)c;函数使用 <code>__builtin_ctz(mask)code>(计算掩码中的尾随零)找到第一个空字节的索引。然后通过从空字节的位置减去原始指针来计算字符串的长度。
一旦完成比较c;我们需要知道在 32 个字符中 \0 出现在哪里。为此c;我们使用一个提取位掩码的函数。
如果在前 32 字节中没有找到空字节c;函数会以相同的方式检查接下来的 32 字节(<code>str + 32code>)。如果它找到了空字节c;它将返回计算出的长度。
如果在整个 64 字节块(两次 32 字节加载)中都没有找到空字节c;函数将 <code>strcode> 指针向前移动 64 字节c;并继续循环。
对齐对于 AVX 指令的性能至关重要c;因为处理器在读取对齐良好的数据块时效率更高。然而c;字符字符串往往不会对齐在 32 或 64 字节边界上。
在这种情况下c;你需要手动处理不对齐的情况:
这种对齐的仔细管理允许你充分利用 SIMD 的力量c;即使在数据不对齐时也不会损失性能。
c="https://i-blog.csdnimg.cn/direct/5527ddb1d92141b1a0cbcc920f670763.png" alt="5527ddb1d92141b1a0cbcc920f670763.png" />
在运行时很容易判断代码是否经过优化c;但仍然值得从 CPU 流水线利用的角度进行性能分析。为此c;我们将使用 LLVM-mca 工具c;它允许我们分析编译时的输出。
<code class="language-cpp">terations: 100 Instructions: 4400 Total Cycles: 1515 Total uOps: 5600Dispatch Width: 4 uOps Per Cycle: 3.70 IPC: 2.90 Block RThroughput: 14.0Instruction 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, rdi1 1 1.00 je .LBB0_11 1 0.33 mov rcx, rdi1 5 0.50 * * prefetcht0 byte ptr [rdi + 32]1 1 0.33 mov rax, rdi1 1 0.33 and rcx, 311 1 1.00 je .LBB0_71 0 0.25 vpxor xmm0, xmm0, xmm02 8 0.50 * vpcmpeqb ymm0, ymm0, ymmword ptr [rdi]1 2 1.00 vpmovmskb eax, ymm01 1 0.33 test eax, eax1 1 1.00 je .LBB0_61 3 1.00 bsf eax, eax4 1 1.00 U vzeroupper1 1 1.00 U ret1 1 0.33 neg rcx1 1 0.50 lea rax, [rdi + rcx]1 1 0.33 add rax, 321 5 0.50 * * prefetcht0 byte ptr [rax + 64]1 1 0.33 mov rcx, rax1 1 0.33 sub rcx, rdi1 0 0.25 vpxor xmm0, xmm0, xmm02 8 0.50 * vpcmpeqb ymm1, ymm0, ymmword ptr [rax]1 2 1.00 vpmovmskb edx, ymm11 1 0.33 test edx, edx1 1 1.00 jne .LBB0_92 8 0.50 * vpcmpeqb ymm1, ymm0, ymmword ptr [rax + 32]1 2 1.00 vpmovmskb edx, ymm11 1 0.33 test edx, edx1 1 1.00 jne .LBB0_111 1 0.33 add rax, 641 1 0.33 add rcx, 641 1 1.00 jmp .LBB0_81 3 1.00 bsf eax, edx1 1 0.33 add rax, rcx4 1 1.00 U vzeroupper1 1 1.00 U ret1 3 1.00 bsf eax, edx1 1 0.33 add rax, rcx1 1 0.33 add rax, 324 1 1.00 U vzeroupper1 1 1.00 U ret1 0 0.25 xor eax, eax1 1 1.00 U retResources: [0] - SBDivider [1] - SBFPDivider [2] - SBPort0 [3] - SBPort1 [4] - SBPort4 [5] - SBPort5 [6.0] - SBPort23 [6.1] - SBPort23Resource 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 - - retcode>
这个 <code>strlencode> 实现利用了几个关键的优化技术以获得最大性能。它检查空指针并处理内存对齐c;确保高效的内存访问。通过使用 SIMD(单指令多数据)通过 AVX 指令一次处理 32 字节c;它显著减少了与逐字符方法相比的迭代次数。
该函数使用预取来减少内存延迟c;在需要之前将数据加载到缓存中。这有助于 CPU 保持高吞吐量。此外c;它处理未对齐和对齐的内存访问c;有效处理即使是未对齐的字符串。这些综合技术使得这个 AVX 优化的 <code>strlencode> 比传统实现快得多c;特别是对于长字符串。
现在c;我们来看一看我们的优化是否真的有所作为。是时候进行一些基准测试c;检查所有花哨的 SIMD 指令和预取技巧是否真的值得。基准测试给我们提供了冷酷而坚硬的数字——我们将发现这些调整是否真的加快了我们的 <code>strlencode> 函数的速度c;或者它们只是噪声。通过比较原始实现和我们的优化版本c;我们可以看到代码在实际条件下运行得有多快(或者不是)。
c="https://i-blog.csdnimg.cn/direct/1db1ffcdcbd84f01a7f0a40456f7c6a4.png" alt="1db1ffcdcbd84f01a7f0a40456f7c6a4.png" />
对于长度为 473 和 603 的字符串的基准测试c;我们观察到以下关键点:
总结来说c;对于较短的字符串c;标准 strlen 和优化过的 AVX 版本(strlen_avx 和 strlen_avx_ultimate)表现最佳c;而像 pointer_strlen 和 stupid_strlen 这样的自定义实现则落后。
c="https://i-blog.csdnimg.cn/direct/cbc3bb77ae3446f095dbd74142787448.png" alt="cbc3bb77ae3446f095dbd74142787448.png" />
对于较大字符串(长度为 1,048,575)的基准测试c;结果显示了几个重要的趋势:
对于大字符串c;标准 <code>strlencode> 和 <code>strlen_avx_ultimatecode> 是最快的c;AVX 版本利用向量化指令一次处理多个字符。<code>strlen_AVX_asmcode> 表现不佳c;可能由于手动汇编管理的开销。<code>pointer_strlencode> 和 <code>stupid_strlencode> 明显慢得多c;突出了在处理大量数据时使用 SIMD 等高级技术的重要性。
即使在 C 语言中可以实现类似的性能c;用汇编语言编写 <code>strlencode> 函数可以精确控制硬件特定的特性和优化c;这些可能是编译器无法充分利用的。汇编使你能够利用高级处理器指令c;如 SIMD 操作c;并针对目标架构精确编写代码c;可能在关键部分挤出额外的性能。
此外c;用汇编编写确保了一致和可预测的行为c;绕过了不同编译器优化或 C 语言版本可能带来的变异性。当需要最大效率c;即使是微小的性能提升也很重要时c;这种控制水平是非常宝贵的。
<code class="language-cpp">section .text global ft_strlen_avx_asm %define PAGE_SIZE 4096 %define VEC_SIZE 32ft_strlen_avx_asm:test rdi, rdije return_zeromov rsi, rdimov rax, rdiand eax, VEC_SIZE - 1jz aligned_startmov ecx, VEC_SIZEsub ecx, eaxprefetcht0 [rdi + PAGE_SIZE]vpxor xmm0, xmm0, xmm0vmovdqu xmm1, [rdi]vpcmpeqb xmm1, xmm1, xmm0pmovmskb edx, xmm1test edx, edxjne found_null_unaligned add rdi, rcxvzeroupperjmp aligned_start aligned_start:vpxor ymm0, ymm0, ymm0aligned_loop:vmovdqa ymm1, [rdi] vpcmpeqb ymm1, ymm1, ymm0 vpmovmskb edx, ymm1 test edx, edxjne found_null_aligned add rdi, VEC_SIZEjmp aligned_loop found_null_unaligned:bsf eax, edx sub rdi, rsi add rax, rdi vzeroupperretfound_null_aligned:bsf eax, edxsub rdi, rsi add rax, rdi vzeroupperretreturn_zero:xor eax, eax vzeroupperretcode>
汇编代码中c;每条指令对应特定的低级操作c;许多操作在 C 语言中可以使用 AVX 内置函数或标准 C 代码找到等价物。
这个版本使用了完全相同的逻辑c;不同的是使用汇编器意味着你可以直接操作寄存器c;使你从编译器中解放出来c;编译器有时可能会遗漏某些优化。
但在一些基准测试之后:
c="https://i-blog.csdnimg.cn/direct/6d27755e37c24f60b6c0de06e9fbe098.png" alt="6d27755e37c24f60b6c0de06e9fbe098.png" />
是的c;比 Glibc 快c;但对字符串来说(并非总是这样c;因为技术问题)。
在 C/C++ 中优化代码可能一开始看起来令人生畏c;但一旦你深入其中c;实际上非常令人满意。从像 strlen 这样的简单事物开始c;然后逐渐使用 指针算术、SIMD 和 AVX 等技巧进行调整c;展示了你对性能可以有多大的控制权。
这里的真正收获是什么?编译器很智能c;但它们不能承担所有重任。你必须编写代码c;利用现代 CPU 的特性c;有时你得亲自动手处理事情——比如内存对齐或使用那些美味的 AVX 指令。一旦你这样做了c;结果可能是惊人的c;特别是对于大规模数据处理。
当然c;像 循环展开 和 预取 这样的优化可能会变得相当复杂c;可能不总是带来巨大的收益c;但它们是极好的例子c;展示了当你了解底层发生了什么时c;你可以将性能提升到什么程度。最好的部分是什么?这不仅仅是关于编写更快的代码——而是关于学习以一种全新的方式思考你的程序如何与硬件交互。