使用 strlen 与 stop on zero 的字符串操作的性能

Performance of a string operation with strlen vs stop on zero

当我用 C++ 为字符串编写 class 时,我发现了一个关于执行速度的奇怪行为。 下面以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 != '[=10=]'; ptr_char++) {
        *ptr_char = toupper(*ptr_char);
    }
}

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

然后在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

所以在 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    , %al
    popq    %r13
    .cfi_def_cfa_offset 8
    ret
    .cfi_endproc
.LFE72:
    .size   _Z17stringUpperStrlenv, .-_Z17stringUpperStrlenv
    .globl  _Z14stringUpperPtrv
    .type   _Z14stringUpperPtrv, @function

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    , %al
    popq    %rbx
    .cfi_def_cfa_offset 8
    ret
    .cfi_endproc
.LFE73:
    .size   _Z14stringUpperPtrv, .-_Z14stringUpperPtrv
    .section    .rodata.str1.1,"aMS",@progbits,1

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

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

提前致谢。

编辑: 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

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

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

Now I can test the speed of those methods in my main.cpp: (…)

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

我认为编译器什么都没做 earth-shattering 因为它不知道 toupper 做了什么。如果编译器知道 toupper 不会将非零字符转换为零,它很可能会在基准循环之外调用 hoisted strlen。如果它知道 toupper(toupper(x)) == toupper(x),它可能会决定只 运行 循环一次。

为了做一个比较现实的基准测试,将基准测试代码和基准测试代码放在单独的源文件中,分别编译它们,并禁用任何类型的 cross-module 或 link-time 优化。

Then I can compile and test with cmake in Debug or Release

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

So rationally, fewer instructions should mean more speed (excluding cache, scheduler, etc ...).

不,绝对不是。

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

其次,在许多平台上,例如 21 世纪制造的任何 PC 或服务器、任何智能手机,甚至许多 lower-end 设备,执行一条指令所需的时间差异很大,以至于它性能不佳的指示。 Cache is a major factor: a read from memory can be more than 1000 times slower than a read from cache on a PC. Other factors with less impact include pipelining, which causes the speed of an instruction to depend on the surrounding instructions, and branch prediction,这导致条件指令的速度取决于先前条件指令的结果。

第三,这只是考虑处理器指令——您在汇编代码中看到的。 C、C++ 和大多数其他语言的编译器优化程序的方式使得很难准确预测处理器将做什么。

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

  • 如果编译器发现加法是不必要的,例如因为之后没有使用 x,或者因为 x 的值在编译时已知,因此值也是如此x+1,它会优化它。所以答案是0.
  • 如果此时x的值已经在一个寄存器中,以后只需要在一个寄存器中,编译器只需要生成一条加法或自增指令即可。所以简单但不太正确的答案是 1 个时钟周期。这不是很正确的一个原因是仅仅解码指令在 high-end 处理器上需要很多周期,例如你在 21 世纪的 PC 或智能手机上找到的处理器。然而,“一个周期”在某种程度上是正确的,因为虽然从指令开始到完成它需要多个时钟周期,但指令在每个流水线阶段只需要一个周期。此外,即使考虑到这一点,另一个不太正确的原因是 ++x; ++y; 可能不需要 2 个时钟周期:现代处理器足够复杂,它们可以并行解码和执行多条指令(例如,具有4个运算单元的处理器可以同时进行4次加法运算)。这可能不正确的另一个原因是,如果 x 的类型大于或小于寄存器,这可能需要多个汇编指令来执行加法。
  • 如果x的值需要从内存中加载,这需要一个时钟周期以上的时间。除了最里面的高速缓存级别之外,任何东西都使解码指令和执行加法所需的时间相形见绌。根据是否在 L3 缓存、L2 缓存、L1 缓存或“真实”RAM 中找到 x,时间量有很大不同。当您认为 x 可能是 cache prefetch(硬件或软件触发)的一部分时,甚至会变得更加复杂。
  • 甚至有可能 x 当前在 swap,因此读取它需要从磁盘读取。
  • 写入结果与读取输入有一些相似的变化。然而性能特点cs 对于读取和写入是不同的,因为当您需要一个值时,您需要等待读取完成,而当您写入一个值时,您不需要等待写入完成:写入内存写入缓存中的 buffer,缓冲区刷新到 higher-level 缓存或 RAM 的时间取决于系统上发生的其他事情(还有什么在争夺 space 在缓存中)。

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

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

  • 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: 无条件跳转到循环开头。

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

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

两个循环的区别是:

  • 循环的长度略有不同,但都足够小以适合单个缓存行(或两个,如果代码恰好跨越行边界)。所以在循环的第一次迭代之后,代码将在最里面的指令缓存中。不仅如此,如果我理解正确的话,在现代 Intel 处理器上,有一个 cache of decoded instructions,循环足够小以适合,因此不需要进行解码。
  • stringUpperStrlen 循环又读了一遍。额外的读取来自一个常量地址,该地址在第一次迭代后很可能保留在最里面的缓存中。
  • stringUpperStrlen 循环中的条件指令仅取决于寄存器中的值。另一方面,stringUpperPtr 循环中的条件指令取决于刚刚从内存中读取的值。

所以区别归结为从最里面的缓存读取额外的数据,而不是有一个条件指令,其结果取决于内存读取。结果取决于另一条指令结果的指令导致 hazard:第二条指令被阻塞,直到第一条指令完全执行,这会阻止利用流水线,并可能使推测执行效率降低。在 stringUpperStrlen 循环中,处理器基本上 运行 并行执行两件事:load-call-store 循环,它没有任何条件指令(除了 toupper 中发生的事情) 和 increment-test 循环,它不访问内存。这让处理器在等待内存时处理条件指令。在 stringUpperPtr 循环中,条件指令取决于内存读取,因此在读取完成之前处理器无法开始处理它。我通常希望这比从最里面的缓存中额外读取慢,尽管它可能取决于处理器。

当然,stringUpperStrlen确实需要有一个load-test hazard来判断字符串的结尾:无论它怎么做,它都需要在内存中获取字符。这隐藏在 repnz scasb 中。我不知道 x86 处理器的内部架构,但我怀疑这种情况(这是非常常见的,因为它是 strlen 的核心)在处理器内部进行了大量优化,可能在某种程度上无法通过通用指令到达。

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