C/C++ 优化,strlen 示例

news2024/12/3 8:59:35

目录

C/C++ optimization, the strlen examplehttps://hallowed-blinker-3ca.notion.site/C-C-optimization-the-strlen-example-108719425da080338d94c79add2bb372

揭开优化的神秘面纱...

让我们来谈谈 CPU

等等,SIMD 是什么?

为什么 strlen 是一个很好的例子...

你的编译器是你的朋友,但你需要和它交流

1. 清晰和结构化的代码

2. 糟糕的代码模式无法被优化掉

3. SIMD 和低级优化通常需要手动干预

SSE 和 AVX,兄弟,这是什么?

AVX2 和 AVX512

为什么内存对齐很重要?

现在我们来真正编写一个酷(且优化过的)strlen

性能分析…

每次迭代的资源压力:

基准测试

也许可以是一个汇编版本...

参考文献:


本文翻译自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

揭开优化的神秘面纱...

是的,低级语言中的优化可能有很多原因让人感到相当可怕,但其实并不那么难。

确实,阅读一些源代码会带来某种抽象,可能会让人感到害怕,但在我看来并不是这样。关键词是:只是倾斜,花点时间,不要害怕... 从简单的函数开始,我将尝试给你一些关键点,以开始理解优化是可行的(在一定程度上,但稍后我们会看到这一点)。

还有,请千万不要成为这样的人:

6efe49866d0a43008065d22e80a49c2e.png 

让我们来谈谈 CPU

优化最重要的方面是理解 CPU 和内存。在 x86_64 架构中,CPU 流水线是一个关键概念,它使得现代处理器能够同时执行多个指令,极大地提高了效率。这个流水线将每个指令的执行分解为几个阶段。每个阶段处理指令执行的一个特定部分——从内存中获取它、解码它、执行它、访问内存,最后将结果写回。

在 x86_64 处理器中,流水线通常由以下阶段组成:

  1. 取指:CPU 从内存中检索下一个指令。
  2. 解码:将指令解码成 CPU 可以理解的格式。
  3. 执行:CPU 执行实际的操作(例如,加法、乘法)。
  4. 内存访问:如果指令需要访问内存(读取或写入),则在此阶段发生。
  5. 写回:操作的结果被写回到 CPU 寄存器或内存中。

流水线的好处在于,当一个指令正在执行时,另一个可以被解码,还有一个可以被取指。这种并行性提高了吞吐量而不需要增加时钟速度。然而,流水线并非没有挑战,特别是与冒险(一个指令依赖于前一个指令结果的情况)和分支(程序流程意外改变时)相关的问题,可能会导致流水线停滞,降低效率。

 dbeebc9b197f4875870cb2ff0a8cc874.png

在流水线中,每个阶段需要一个时钟周期来完成。现代 CPU 旨在每个时钟周期执行一个指令(IPC:每个周期的指令数),但实际上,由于数据依赖、内存延迟或分支预测失误等因素,很少能达到这一点,IPC 通常在 2 到 4 之间。如果一个指令依赖于前一个指令的结果,CPU 可能需要等待(或“暂停”)直到该结果准备好,这会引入额外的周期并降低性能。

这是对 CPU 流水线的“简短”介绍,但现在,让我们来谈谈代码。

等等,SIMD 是什么?

SIMD(单指令多数据)是一种并行计算概念,其中单个指令同时对多个数据片段进行操作。与一次处理一个元素不同,SIMD 允许对多个数据点并行执行相同的操作(如加法、乘法或比较),通常在 CPU 的向量寄存器内。这种技术特别适合涉及重复计算的操作,例如在多媒体处理、科学模拟或大型数据集中发现的那些。

daad59a99d3b43de8fb41c9ac2b1c177.png 

通过一次性处理多个元素,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++)。它在最后计算起始和结束指针之间的差值,从而减少了每次迭代的操作次数。

27ef3b596a9b4d388f403ef873405436.png 

 

这个优化版本在指针对齐后,通过一次读取 size_t(在64位系统上通常是8字节)来处理更大的字符串块。这减少了内存访问和迭代次数,显著加快了字符串遍历的速度。此外,HASZERO 宏中使用的位运算允许高效地检测一个字中的空字节,避免了单独检查每个字节。

指针算术通常使编译器能够应用更激进的优化,例如消除冗余指令或更有效地使用寄存器。许多现代编译器擅长识别指针算术模式,并且可以比使用指针增量和单独计数器的版本更有效地优化这个版本。

尽管 Musl 版本稍微更高效一些,但值得进一步讨论 SIMD,以充分利用现代处理器提供的可能性。

你的编译器是你的朋友,但你需要和它交流

在使用像 ClangGCC 这样的现代编译器时,通常会使用 -O2-O3 等优化标志来自动提高生成代码的性能。这些标志指示编译器执行各种优化,包括 内联函数循环展开向量化(SIMD) 和常量折叠等。对于开发人员来说,这可以在不手动更改代码的情况下带来显著的性能提升。

2bd65842390440c3b920c4e5c6fecbd5.png 

然而,尽管编译器优化标志可以帮助,它们不是万能的解决方案,它们不能完全补偿写得不好或未经优化的代码。以下是原因:

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];
        }
    }
}

这个函数只是执行一个简单的矩阵乘法:

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

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&plus;%20a_%7B12%7Db_%7B21%7D%20%26%20a_%7B11%7Db_%7B12%7D%20&plus;%20a_%7B12%7Db_%7B22%7D%20%5C%5C%20a_%7B21%7Db_%7B11%7D%20&plus;%20a_%7B22%7Db_%7B21%7D%20%26%20a_%7B21%7Db_%7B12%7D%20&plus;%20a_%7B22%7Db_%7B22%7D%20%5Cend%7Bbmatrix%7D

但是,可以像这样写得更好: 

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 允许处理器将它们打包处理,这要快得多。

 bc37f62641c140c1bf979121ef6abed1.png

AVX2 和 AVX512

高级向量扩展(AVX) 是对 x86 指令集架构的扩展,旨在通过启用 SIMD 操作来提高性能。AVX2 通过引入新的整数操作指令并将寄存器大小从128位扩展到256位,扩展了原始AVX的能力。AVX-512 进一步扩展了这些能力,将寄存器大小加倍到512位,允许更大的并行性。

e2dcd60cd98d4155ab4ced85c2451bcc.png

使用 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 字节边界上。

在这种情况下,你需要手动处理不对齐的情况:

  1. 如果字符串从一个不对齐的地址开始,你使用不对齐加载指令(_mm256_loadu_si256)单独处理初始字符。
  2. 一旦确保了对齐,剩余的字符串就可以使用 AVX2 和 _mm256_load_si256 高效地以 32 字节块处理。

这种对齐的仔细管理允许你充分利用 SIMD 的力量,即使在数据不对齐时也不会损失性能。

5527ddb1d92141b1a0cbcc920f670763.png

 

性能分析…

在运行时很容易判断代码是否经过优化,但仍然值得从 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

 

  1. IPC(每周期指令数)为 2.90 在现代处理器上是极好的。这个指标表明,平均而言,我们的代码每个 CPU 周期执行将近 3 条指令。由于大多数现代 CPU 的最大 IPC 大约为 4(由于发射宽度),达到 2.90 意味着你的代码非常高效地利用了 CPU 流水线。
  2. 每周期微操作数为 3.70,加上 4 的发射宽度,我们的代码几乎饱和了流水线。这表明你的代码让执行单元保持忙碌,最大化了吞吐量。
  3. 块吞吐量为 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 造成压力。
  • 控制流指令:
    • 分支指令(jejnejmp)增加了端口 5 的压力。

这个 strlen 实现利用了几个关键的优化技术以获得最大性能。它检查空指针并处理内存对齐,确保高效的内存访问。通过使用 SIMD(单指令多数据)通过 AVX 指令一次处理 32 字节,它显著减少了与逐字符方法相比的迭代次数。

该函数使用预取来减少内存延迟,在需要之前将数据加载到缓存中。这有助于 CPU 保持高吞吐量。此外,它处理未对齐和对齐的内存访问,有效处理即使是未对齐的字符串。这些综合技术使得这个 AVX 优化的 strlen 比传统实现快得多,特别是对于长字符串。

基准测试

现在,我们来看一看我们的优化是否真的有所作为。是时候进行一些基准测试,检查所有花哨的 SIMD 指令和预取技巧是否真的值得。基准测试给我们提供了冷酷而坚硬的数字——我们将发现这些调整是否真的加快了我们的 strlen 函数的速度,或者它们只是噪声。通过比较原始实现和我们的优化版本,我们可以看到代码在实际条件下运行得有多快(或者不是)。

1db1ffcdcbd84f01a7f0a40456f7c6a4.png 

 

对于长度为 473 和 603 的字符串的基准测试,我们观察到以下关键点:

  1. 标准 strlen 表现得非常高效,时间范围在 0.00006 - 0.00007 秒之间。这是预期的,因为它在标准库中已经过良好优化。
  2. strlen_avx 显示出竞争力的性能,但比标准实现略慢。AVX 优化可能对这些较短的字符串没有完全的好处,其中开销可能超过收益。
  3. strlen_avx_ultimate 提供的结果非常接近标准 strlen,表明这个版本中进一步的优化对于短字符串是有效的。
  4. strlen_AVX_asm,可能包括内联汇编和 AVX 指令,比纯 AVX 版本稍慢。这可能是由于汇编指令或寄存器管理的开销,特别是在处理较短的字符串时。但是让我来烹饪…
  5. pointer_strlen 对于较短的字符串明显较慢,耗时超过 0.0004 秒。块读取优化在这种规模上不太有效,简单的指针算术对于短长度的字符串没有太多好处。
  6. stupid_strlen 是最慢的,耗时约 0.0014 秒。这种实现可能没有预取、展开、SIMD 等优化,其方法可能对任何字符串长度都不够高效。

总结来说,对于较短的字符串,标准 strlen 和优化过的 AVX 版本(strlen_avx 和 strlen_avx_ultimate)表现最佳,而像 pointer_strlen 和 stupid_strlen 这样的自定义实现则落后。 

cbc3bb77ae3446f095dbd74142787448.png

 

对于较大字符串(长度为 1,048,575)的基准测试,结果显示了几个重要的趋势:

  1. 标准 strlen 表现非常好,时间一致在 0.150 秒左右。这是预期的,因为标准库版本针对常见情况高度优化,使用了调整良好的循环展开、预取和可能的底层向量化指令。
  2. strlen_avx_ultimate 非常接近标准 strlen,性能在 0.151 到 0.153 秒之间。这表明终极 AVX 版本提供了类似的好处,可能由于在长数据块上高效的向量化操作。设置 AVX 操作的轻微开销可能阻止了它大幅度超越标准实现,但它仍然非常高效。
  3. strlen_avx 稍慢,大约在 0.161 秒范围。这表明虽然 AVX 指令有帮助,但这个版本的实现可能存在一些效率问题,或者它可能没有充分发挥 AVX 的潜力,特别是与“终极”版本相比。
  4. strlen_AVX_asm 明显慢得多,大约在 0.36 秒。尽管使用了 AVX,这个版本涉及内联汇编,可能会因为手动寄存器管理和函数调用开销引入开销。汇编优化有时可能难以在不同的 CPU 上管理,对于长字符串,这种方法不太有效。
  5. pointer_strlen 大约需要 0.566 秒。这个实现使用基本的指针算术并一次读取多个字符。对于大字符串,这种方法因为不利用任何 SIMD(单指令多数据)优化或 CPU 特定指令以更大块处理数据而受到影响。
  6. stupid_strlen 是最慢的,大约需要 2.85 秒。这个结果是预期的,因为这个版本可能一次处理一个字符,没有任何优化,导致大字符串的巨大性能损失。

对于大字符串,标准 strlenstrlen_avx_ultimate 是最快的,AVX 版本利用向量化指令一次处理多个字符。strlen_AVX_asm 表现不佳,可能由于手动汇编管理的开销。pointer_strlenstupid_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

这个版本使用了完全相同的逻辑,不同的是使用汇编器意味着你可以直接操作寄存器,使你从编译器中解放出来,编译器有时可能会遗漏某些优化。

但在一些基准测试之后:

6d27755e37c24f60b6c0de06e9fbe098.png

是的,比 Glibc 快,但对字符串来说(并非总是这样,因为技术问题)。

总结:C/C++ 中低级优化的力量

C/C++ 中优化代码可能一开始看起来令人生畏,但一旦你深入其中,实际上非常令人满意。从像 strlen 这样的简单事物开始,然后逐渐使用 指针算术SIMDAVX 等技巧进行调整,展示了你对性能可以有多大的控制权。

这里的真正收获是什么?编译器很智能,但它们不能承担所有重任。你必须编写代码,利用现代 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

 

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2244781.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【Linux学习】【Ubuntu入门】1-8 ubuntu下压缩与解压缩

1.Linux系统下常用的压缩格式 常用的压缩扩展名&#xff1a;.tar、.tar.bz2、.tar.gz 2.Windows下7ZIP软件安装 Linux系统下很多文件是.bz2&#xff0c;.gz结尾的压缩文件。 3.Linux系统下gzip压缩工具 gzip工具负责压缩和解压缩.gz格式的压缩包。 gzip对单个文件进行…

【Linux网络编程】简单的UDP套接字

目录 一&#xff0c;socket编程的相关说明 1-1&#xff0c;sockaddr结构体 1-2&#xff0c;Socket API 二&#xff0c;基于Udp协议的简单通信 三&#xff0c;UDP套接字的应用 3-1&#xff0c;实现英译汉字典 一&#xff0c;socket编程的相关说明 Socket编程是一种网络通信…

【工控】线扫相机小结 第三篇

海康软件更新 目前使用的是 MVS_STD_4.3.2_240705.exe &#xff0c;最新的已经到4.4了。 一个大的变动 在上一篇中我们提到一个问题&#xff1a; 需要注意的是&#xff0c;我们必须先设置 TriggerSelector 是 “FrameBurstStart” 还是 “LineStart” 再设置TriggerMode 是 …

Java基础知识(五)

文章目录 ObjectObject 类的常见方法有哪些&#xff1f; 和 equals() 的区别hashCode() 有什么用&#xff1f;为什么要有 hashCode&#xff1f;为什么重写 equals() 时必须重写 hashCode() 方法&#xff1f; 参考链接 Object Object 类的常见方法有哪些&#xff1f; Object 类…

[高阶数据结构(一)]并查集详解

1.前言 本系列会带大家走进高阶数据结构的学习, 其中包括并查集,图论, LRU cache, B树, B树, B*树, 跳表. 其中, 图论中讲解的时间最长, 包括邻接表, 邻接矩阵, 广度优先遍历, 深度优先遍历, 最小生成树中的kruskal算法以及prim算法&#xff1b;最短路径中的dijkstra算法, bell…

应聘美容师要注意什么?博弈美业收银系统/管理系统/拓客系统分享建议

随着美容行业的不断发展&#xff0c;成为一名优秀的美容师需要具备一系列重要的技能和品质。无论是在面试过程中还是在实际工作中&#xff0c;以下建议将帮助你在应聘美容师职位时脱颖而出&#xff1a; ▶ 专业技能和资格 首先&#xff0c;确保你具备所需的专业技能和资格。这…

el-cascader 使用笔记

1.效果 2.官网 https://element.eleme.cn/#/zh-CN/component/cascader 3.动态加载&#xff08;官网&#xff09; <el-cascader :props"props"></el-cascader><script>let id 0;export default {data() {return {props: {lazy: true,lazyLoad (…

vmWare虚拟环境centos7安装Hadoop 伪分布式实践

背景&#xff1a;近期在研发大数据中台&#xff0c;需要研究Hadoop hive 的各种特性&#xff0c;需要搭建一个Hadoop的虚拟环境&#xff0c;本来想着使用dock &#xff0c;但突然发现docker 公共仓库的镜像 被XX 了&#xff0c;无奈重新使用vm 搭建虚拟机。 大概经历了6个小时完…

Redis基本的全局命令

在学习redis基本的全局命令之前呢&#xff0c;我们必须先进入redis-cli客户端才行。 如图&#xff1a; get和set get和set是redis两个最核心的命令。 get&#xff1a;根据key来获取value。 set&#xff1a;把key和value存储进去。 如set命令如图&#xff1a; 对于上述图中&…

Redis五大基本类型——List列表命令详解(命令用法详解+思维导图详解)

目录 一、List列表类型介绍 二、常见命令 1、LPUSH 2、LPUSHX 3、RPUSH 4、RPUSHX 5、LRANGE 6、LPOP 7、RPOP 8、LREM 9、LSET 10、LINDEX 11、LINSERT 12、LLEN 13、阻塞版本命令 BLPOP BRPOP 三、命令小结 相关内容&#xff1a; Redis五大基本类型——Ha…

一文详解哋它亢模块的安装与使用

如何安装哋它亢模块 哋它亢模块是扩展哋它亢功能的关键工具&#xff0c;它们涵盖了从数据分析到机器学习的各种应用场景。通过安装和使用这些模块&#xff0c;你可以轻松完成复杂的任务&#xff0c;大幅提升开发效率。哋它亢是一门易于学习且功能强大的编程语言&#xff0c;以…

C#中的二维数组的应用:探索物理含义与数据结构的奇妙融合

在C#编程中&#xff0c;二维数组&#xff08;或矩阵&#xff09;是一种重要的数据结构&#xff0c;它不仅能够高效地存储和组织数据&#xff0c;还能通过其行、列和交叉点&#xff08;备注&#xff1a;此处相交处通常称为“元素”或“单元格”&#xff0c;代表二维数组中的一个…

论文阅读——Intrusion detection systems using longshort‑term memory (LSTM)

一.基本信息 论文名称&#xff1a;Intrusion detection systems using longshort‑term memory (LSTM) 中文翻译&#xff1a;基于长短期记忆(LSTM)的入侵检测系统 DOI&#xff1a;10.1186/s40537-021-00448-4 作者&#xff1a;FatimaEzzahra Laghrissi1* , Samira Douzi2*, Kha…

【行之有效】实证软件工程研究方法

【行之有效】实证软件工程研究方法 一、实证研究二、实证软件工程2.1 系统化文献评价2.2 调查研究2.2.1 数据收集2.2.2 抽样 2.3 案例研究2.3 实证研究效度 一、实证研究 实证研究&#xff08;Empirical Research&#xff09;方法是一种与规范研究&#xff08;Normative Resea…

大数据挖掘期末复习

大数据挖掘 数据挖掘 数据挖掘定义 技术层面&#xff1a; 数据挖掘就是从大量的、不完全的、有噪声的、模糊的、随机的实际应用数据中&#xff0c;提取隐含在其中、人们事先不知道的、但又潜在有用的信息的过程。 数据准备环节 数据选择 质量分析 数据预处理 数据仓库 …

河道水位流量一体化自动监测系统:航运安全的护航使者

在广袤的水域世界中&#xff0c;航运安全始终是至关重要的课题。而河道水位流量一体化自动监测系统的出现&#xff0c;如同一位强大的护航使者&#xff0c;为航运事业的稳定发展提供了坚实的保障。 水位传感器&#xff1a;负责实时监测河道的水位变化。这些传感器通常采用先进的…

【C++】深入理解 C++ 中的继承进阶:多继承、菱形继承及其解决方案

个人主页: 起名字真南的CSDN博客 个人专栏: 【数据结构初阶】 &#x1f4d8; 基础数据结构【C语言】 &#x1f4bb; C语言编程技巧【C】 &#x1f680; 进阶C【OJ题解】 &#x1f4dd; 题解精讲 目录 C继承机制详解与代码示例&#x1f4cc;1. 继承的基本概念&#x1f4cc; 2.…

根据条件 控制layui的table的toolbar的按钮 显示和不显示

部分代码&#xff1a; <!-----查询条件-----> <input type"date" id"StartDate" onchange"PageList()" /> <input type"date" id"EndDate" onchange"PageList()" /><!-----表格Table-----&…

net某高校社交学习平台的设计与实现

摘 要 高校社交学习平台是一个融合了社交网络特性的在线学习交流系统&#xff0c;旨在促进高校学生之间的信息共享与学习互动。该平台通过提供学习资料、学习视频和学习交流等功能&#xff0c;支持发布学习动态、参与知识问答、并实时追踪学习进度。为学生提供一个全面且便捷的…

5-对象的访问权限

对象的访问权限知识点 对象的分类 在数据库中&#xff0c;数据库的表、索引、视图、缺省值、规则、触发器等等、都可以被称为数据库对象&#xff0c;其中对象主要分为两类 1、模式(schema)对象&#xff1a;模式对象可以理解为一个存储目录、包含视图、索引、数据类型、函数和…