使用 strlen 与停止为零的字符串操作的性能

Eni*_*gma 6 c++ string performance gcc x86-64

当我用 C++ 为字符串编写一个类时,我发现了一个关于执行速度的奇怪行为。我将以upper方法的以下两个实现为例:

class String {

    char* str;

    ...

    forceinline void upperStrlen();
    forceinline void upperPtr();
};

void String::upperStrlen()
{
    INDEX length = strlen(str);

    for (INDEX i = 0; i < length; i++) {
        str[i] = toupper(str[i]);
    }
}

void String::upperPtr()
{
    char* ptr_char = str;

    for (; *ptr_char != '\0'; ptr_char++) {
        *ptr_char = toupper(*ptr_char);
    }
}
Run Code Online (Sandbox Code Playgroud)

INDEX 是 uint_fast32_t 的简单类型定义。

现在我可以在 main.cpp 中测试这些方法的速度:

#define TEST_RECURSIVE(_function)                    \
{                                                    \
    bool ok = true;                                  \
    clock_t before = clock();                        \
    for (int i = 0; i < TEST_RECURSIVE_TIMES; i++) { \
        if (!(_function()) && ok)                    \
            ok = false;                              \
    }                                                \
    char output[TEST_RECURSIVE_OUTPUT_STR];          \
    sprintf(output, "[%s] Test %s %s: %ld ms\n",     \
        ok ? "OK" : "Failed",                        \
        TEST_RECURSIVE_BUILD_TYPE,                   \
        #_function,                                  \
        (clock() - before) * 1000 / CLOCKS_PER_SEC); \
    fprintf(stdout, output);                         \
    fprintf(file_log, output);                       \
}

String a;
String b;

bool stringUpperStrlen()
{
    a.upperStrlen();
    return true;
}

bool stringUpperPtr()
{
    b.upperPtr();
    return true;
}

int main(int argc, char** argv) {

    ...

    a = "Hello World!";
    b = "Hello World!";

    TEST_RECURSIVE(stringUpperPtr);
    TEST_RECURSIVE(stringUpperStrlen);

    ...

    return 0;
}
Run Code Online (Sandbox Code Playgroud)

然后我可以在 Debug 或 Release 中使用 cmake 进行编译和测试,结果如下。

[OK] Test RELEASE stringUpperPtr: 21 ms
[OK] Test RELEASE stringUpperStrlen: 12 ms

[OK] Test DEBUG stringUpperPtr: 27 ms
[OK] Test DEBUG stringUpperStrlen: 33 ms
Run Code Online (Sandbox Code Playgroud)

所以在 Debug 中,行为是我所期望的,指针比 strlen 快,但在 Release strlen 中更快。

所以我采用了 GCC 程序集,stringUpperPtr 中的指令数量比 stringUpperStrlen 中的少得多。

stringUpperStrlen 程序集:

_Z17stringUpperStrlenv:
.LFB72:
    .cfi_startproc
    pushq   %r13
    .cfi_def_cfa_offset 16
    .cfi_offset 13, -16
    xorl    %eax, %eax
    pushq   %r12
    .cfi_def_cfa_offset 24
    .cfi_offset 12, -24
    pushq   %rbp
    .cfi_def_cfa_offset 32
    .cfi_offset 6, -32
    xorl    %ebp, %ebp
    pushq   %rbx
    .cfi_def_cfa_offset 40
    .cfi_offset 3, -40
    pushq   %rcx
    .cfi_def_cfa_offset 48
    orq $-1, %rcx
    movq    a@GOTPCREL(%rip), %r13
    movq    0(%r13), %rdi
    repnz scasb
    movq    %rcx, %rdx
    notq    %rdx
    leaq    -1(%rdx), %rbx
.L4:
    cmpq    %rbp, %rbx
    je  .L3
    movq    0(%r13), %r12
    addq    %rbp, %r12
    movsbl  (%r12), %edi
    incq    %rbp
    call    toupper@PLT
    movb    %al, (%r12)
    jmp .L4
.L3:
    popq    %rdx
    .cfi_def_cfa_offset 40
    popq    %rbx
    .cfi_def_cfa_offset 32
    popq    %rbp
    .cfi_def_cfa_offset 24
    popq    %r12
    .cfi_def_cfa_offset 16
    movb    $1, %al
    popq    %r13
    .cfi_def_cfa_offset 8
    ret
    .cfi_endproc
.LFE72:
    .size   _Z17stringUpperStrlenv, .-_Z17stringUpperStrlenv
    .globl  _Z14stringUpperPtrv
    .type   _Z14stringUpperPtrv, @function
Run Code Online (Sandbox Code Playgroud)

stringUpperPtr 程序集:

_Z14stringUpperPtrv:
.LFB73:
    .cfi_startproc
    pushq   %rbx
    .cfi_def_cfa_offset 16
    .cfi_offset 3, -16
    movq    b@GOTPCREL(%rip), %rax
    movq    (%rax), %rbx
.L9:
    movsbl  (%rbx), %edi
    testb   %dil, %dil
    je  .L8
    call    toupper@PLT
    movb    %al, (%rbx)
    incq    %rbx
    jmp .L9
.L8:
    movb    $1, %al
    popq    %rbx
    .cfi_def_cfa_offset 8
    ret
    .cfi_endproc
.LFE73:
    .size   _Z14stringUpperPtrv, .-_Z14stringUpperPtrv
    .section    .rodata.str1.1,"aMS",@progbits,1
Run Code Online (Sandbox Code Playgroud)

所以理性地说,更少的指令应该意味着更快的速度(不包括缓存、调度程序等......)。

那么你如何解释这种性能差异呢?

提前致谢。

编辑:CMake 生成这样的命令来编译:

/bin/g++-8  -Os -DNDEBUG  -Wl,-rpath,$ORIGIN CMakeFiles/xpp-tests.dir/tests/main.cpp.o  -o xpp-tests  libxpp.so 
/bin/g++-8  -O3 -DNDEBUG  -Wl,-rpath,$ORIGIN CMakeFiles/xpp-tests.dir/tests/main.cpp.o  -o Release/xpp-tests  Release/libxpp.so 

# CMAKE generated file: DO NOT EDIT!
# Generated by "Unix Makefiles" Generator, CMake Version 3.16

# compile CXX with /bin/g++-8
CXX_FLAGS = -O3 -DNDEBUG   -Wall -pipe -fPIC -march=native -fno-strict-aliasing

CXX_DEFINES = -DPLATFORM_UNIX=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE_SOURCE=1
Run Code Online (Sandbox Code Playgroud)

在我的示例中,定义 TEST_RECURSIVE 将调用 _function 1000000 次。

Gil*_*il' 2

您对性能有几个误解。你需要消除这些误解。

\n
\n

现在我可以在 main.cpp 中测试这些方法的速度:(\xe2\x80\xa6)

\n
\n

您的基准测试代码直接调用基准测试函数。因此,您正在测量针对基准测试代码如何使用它们的特定情况进行优化的基准测试函数:在同一输入上重复调用它们。这不太可能与他们在现实环境中的行为有任何关系。

\n

我认为编译器没有做任何惊天动地的事情,因为它不知道会toupper做什么。如果编译器知道toupper不会将非零字符转换为零,它很可能会将调用提升strlen到基准循环之外。如果它知道的话toupper(toupper(x)) == toupper(x),它很可能决定只运行一次循环。

\n

要进行稍微实际的基准测试,请将基准测试代码和基准测试代码放在单独的源文件中,单独编译它们,并禁用任何类型的跨模块或链接时优化。

\n
\n

然后我可以在 Debug 或 Release 中使用 cmake 进行编译和测试

\n
\n

在调试模式下编译很少与微基准测试(对一小段代码的实现速度进行基准测试,而不是根据算法调用的基本函数数量来对算法的相对速度进行基准测试)。编译器优化对微基准测试有显着影响。

\n
\n

所以从理性上讲,更少的指令应该意味着更高的速度(不包括缓存、调度程序等......)。

\n
\n

不,绝对不是。

\n

首先,更少的指令总数与程序的速度完全无关。即使在一个平台上,无论指令是什么,执行一条指令都需要相同的时间(这是不寻常的),重要的是执行了多少条指令,而不是程序中有多少条指令。例如,执行 10 次的包含 100 条指令的循环比执行 1000 次的包含 10 条指令的循环快 10 倍,尽管它的大小要大 10 倍。内联是一种常见的程序转换,通常会使代码变得更大并且速度更快,因此它被认为是一种常见的优化。

\n

其次,在许多平台上,例如 21 世纪制造的任何 PC 或服务器、任何智能手机,甚至许多低端设备,执行一条指令所需的时间可能相差很大,以至于不能很好地反映性能。缓存是一个主要因素:从内存中读取数据可能比从 PC 上的缓存中读取数据慢 1000 倍以上。其他影响较小的因素包括流水线(它导致指令的速度取决于周围指令)和分支预测(它导致条件指令的速度取决于先前条件指令的结果)。

\n

第三,这只是考虑您在汇编代码中看到的处理器指令 \xe2\x80\x94\xc2\xa0 。C、C++ 和大多数其他语言的编译器优化程序的方式使得很难预测处理器将具体执行什么操作。

\n

例如,该指令++x;在 PC 上需要多长时间?

\n
    \n
  • 如果编译器发现添加是不必要的,例如因为x之后没有任何使用,或者因为 的值x在编译时已知,因此 的值也是已知的x+1,它将优化它。所以答案是0。
  • \n
  • x如果此时的值已经在寄存器中,并且之后只需要在寄存器中使用该值,则编译器只需要生成加法或递增指令。因此,简单但不完全正确的答案是 1 个时钟周期。这不太正确的原因之一是,在高端处理器(例如 21 世纪的 PC 或智能手机)上,仅仅解码指令就需要许多周期。然而 \xe2\x80\x9cone Cycle\xe2\x80\x9d 是正确的,因为虽然从启动指令到完成它需要多个时钟周期,但指令在每个流水线阶段只需要一个周期。此外,即使考虑到这一点,这不太正确的另一个原因是可能++x; ++y;不需要 2 个时钟周期:现代处理器足够复杂,它们可能能够并行解码和执行多个指令(例如,具有 4 个时钟周期的处理器)算术单元可以同时执行 4 个加法)。这可能不正确的另一个原因是,如果 的类型x大于或小于寄存器,则可能需要多个汇编指令来执行加法。
  • \n
  • 如果需要从内存加载 的值x,则需要的时间远不止一个时钟周期。除了最里面的高速缓存级别之外的任何其他内容都会使解码指令和执行加法所需的时间相形见绌。x根据是否在 L3 缓存、L2 缓存、L1 缓存或 \xe2\x80\x9creal\xe2\x80\x9d RAM 中找到,时间量有很大不同。当您考虑到这可能是缓存预取(硬件或软件触发)x的一部分时,甚至会变得更加复杂。
  • \n
  • 甚至有可能x当前位于swap中,因此读取它需要从磁盘读取。
  • \n
  • 写入结果与读取输入表现出一些相似的变化。然而,读取和写入的性能特征是不同的,因为当您需要一个值时,您需要等待读取完成,而当您写入一个值时,您不需要等待写入完成:对内存的写入会写入高速缓存中的缓冲区,缓冲区刷新到更高级别高速缓存或 RAM 的时间取决于系统上发生的其他情况(还有其他情况在争夺高速缓存中的空间)。
  • \n
\n

好的,现在让我们转向您的具体示例,看看它们的内部循环中发生了什么。我对 x86 汇编不是很熟悉,但我想我明白了要点。

\n

对于stringUpperStrlen,内循环从 开始.L4。在进入内循环之前,%rbx将其设置为字符串的长度。这是内部循环包含的内容:

\n
    \n
  • cmpq %rbp, %rbx:将当前索引与长度进行比较,两者都从寄存器中获得。
  • \n
  • je .L3:条件跳转,如果索引等于长度则退出循环。
  • \n
  • movq 0(%r13), %r12:从内存中读取,获取字符串开头的地址。(令我惊讶的是,此时该地址不在寄存器中。)
  • \n
  • addq %rbp, %r12:取决于刚刚从内存中读取的值的算术运算。
  • \n
  • movsbl (%r12), %edi:从内存中的字符串中读取当前字符。
  • \n
  • incq %rbp:增加索引。这是对寄存器值的算术指令,不依赖于最近的内存读取,因此它很可能是空闲的:它只需要管道阶段和无论如何都不会忙的算术单元。
  • \n
  • call toupper@PLT
  • \n
  • movb %al, (%r12):将函数返回的值写入内存中字符串的当前字符。
  • \n
  • jmp .L4:无条件跳转到循环开头。
  • \n
\n

对于stringUpperPtr,内循环从 开始.L9。这是内部循环包含的内容:

\n
    \n
  • movsbl (%rbx), %edi:从包含当前的地址读取。
  • \n
  • testb %dil, %dil:测试是否%dil为零。是刚刚从内存中读取%dil的最低有效字节。%edi
  • \n
  • je .L8:条件跳转,如果字符为零则退出循环。
  • \n
  • call toupper@PLT
  • \n
  • movb %al, (%rbx):将函数返回的值写入内存中字符串的当前字符。
  • \n
  • incq %rbx:增加指针。这是对寄存器值的算术指令,不依赖于最近的内存读取,因此它很可能是空闲的:它只需要管道阶段和无论如何都不会忙的算术单元。
  • \n
  • jmp .L9:无条件跳转到循环开头。
  • \n
\n

两个循环之间的区别是:

\n
    \n
  • 循环的长度略有不同,但两者都足够小,可以容纳在单个缓存行中(或者两个,如果代码碰巧跨越行边界)。因此,在循环的第一次迭代之后,代码将位于最里面的指令缓存中。不仅如此,如果我理解正确的话,在现代英特尔处理器上,有一个解码指令的缓存,循环足够小可以容纳,因此不需要进行解码。
  • \n
  • stringUpperStrlen还有一次读取。额外的读取来自一个常量地址,该地址在第一次迭代后可能保留在最里面的缓存中。
  • \n
  • 循环中的条件指令stringUpperStrlen仅取决于寄存器中的值。另一方面,条件指令stringUpperPtr循环中的条件指令取决于刚刚从内存中读取的值。
  • \n
\n

因此,差异归结为从最里面的缓存读取额外的数据,而不是具有其结果取决于内存读取的条件指令。一条指令的结果取决于另一条指令的结果,这会导致危险第二条指令将被阻塞,直到第一条指令完全执行为止,这会阻止利用流水线技术,并可能降低推测执行的效率。在stringUpperStrlen循环中,处理器本质上并行运行两件事:加载-调用-存储周期,它没有任何条件指令(除了内部发生的情况之外toupper),以及增量测试周期,它没有任何条件指令访问内存。这使得处理器在等待内存时可以处理条件指令。在里面stringUpperPtr循环中,条件指令依赖于内存读取,因此处理器在读取完成之前无法开始处理它。我通常期望这比从最里面的缓存进行额外读取要慢,尽管它可能取决于处理器。

\n

当然,确实stringUpperStrlen需要有负载测试风险来确定字符串的结尾:无论它如何做,它都需要从内存中获取字符。这是隐藏在里面的repnz scasb。我不知道 x86 处理器的内部架构,但我怀疑这种情况(这种情况非常常见,因为它是 的核心strlen)在处理器内部进行了大量优化,可能达到了不可能达到的程度带有通用说明。

\n

如果字符串较长并且两次内存访问不在stringUpperStrlen同一缓存行中,您可能会看到不同的结果,尽管可能不会,因为这只会多花费一个缓存行,而且还有多个缓存行。详细信息取决于缓存如何工作以及如何toupper使用它们。

\n