问题描述
当我用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);
}
}
INDEX是uint_fast32_t的简单typedef。
现在我可以在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 $1,%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 $1,%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,$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次。
解决方法
您对性能有一些误解。您需要消除这些误解。
现在我可以在main.cpp中测试这些方法的速度:(…)
您的基准测试代码直接调用基准测试函数。因此,您要衡量基准测试函数是否针对基准测试代码如何使用它们的特定情况进行了优化:在同一输入上重复调用它们。这不太可能与它们在现实环境中的行为有关。
我认为编译器没有做任何令人震惊的事情,因为它不知道toupper
是做什么的。如果编译器知道toupper
不会将非零字符转换为零,则很可能在基准循环之外进行了hoisted strlen
调用。如果知道toupper(toupper(x)) == toupper(x)
,则很可能决定只运行一次循环。
要建立一个比较实际的基准,请将基准代码和基准代码放在单独的源文件中,分别进行编译,并禁用任何类型的跨模块或链接时优化。
然后我可以在Debug或Release中使用cmake进行编译和测试
在调试模式下进行编译与微基准测试几乎没有任何关系(基准测试一小段代码的实现速度,而不是根据它们调用的基本功能来基准化算法的相对速度)。编译器优化对微基准测试有重要影响。
因此,合理地讲,更少的指令就意味着更高的速度(不包括缓存,调度程序等)。
不,绝对不是。
首先,更少的指令总与程序的速度完全无关。即使在执行一条指令而无论指令是什么的时间相同的平台上(这是不寻常的),重要的是要执行多少条指令,而不是程序中有多少条指令。例如,执行10次的100条指令的循环比执行1000次的10条指令的循环快10倍,即使它大10倍。 Inlining是一种常见的程序转换,通常会使代码变大,并且使其经常更快地运行,以至于被认为是常见的优化。
其次,在许多平台上,例如21世纪制造的任何PC或服务器,任何智能手机,甚至许多低端设备,执行一条指令所花费的时间差异可能很大,以至于不能很好地说明性能。 Cache是一个主要因素:从内存读取的速度可能比从PC缓存读取的速度慢1000倍以上。影响较小的其他因素包括pipelining(使指令的速度取决于周围的指令)和branch prediction(使条件指令的速度取决于先前的条件指令的结果)
第三,这只是考虑处理器指令-您在汇编代码中看到的内容。用于C,C ++和大多数其他语言的编译器以某种方式优化程序,从而可能很难预测处理器将确切执行的操作。
例如,指令++x;
在PC上需要多长时间?
- 如果编译器发现不需要添加,例如因为之后没有使用
x
,或者因为在编译时知道x
的值,因此{的值也是如此{1}},它将对其进行优化。答案是0。 - 如果此时
x+1
的值已在寄存器中,并且此后仅在寄存器中需要该值,则编译器只需要生成加法或增量指令即可。因此,简单但不太正确的答案是1个时钟周期。不太正确的一个原因是,仅在高端处理器(例如您在21世纪PC或智能手机中找到的处理器)上解码指令会花费很多周期。然而,“一个周期”是正确的,因为从开始执行指令到完成指令需要花费多个时钟周期,但该指令在每个流水线阶段仅花费一个周期。此外,即使考虑到这一点,另一个不太正确的原因是x
可能不需要两个时钟周期:现代处理器非常复杂,以至于它们可以并行解码和执行多个指令(例如,具有4个算术单元的处理器可以同时执行4个加法。另一个可能不正确的原因是++x; ++y;
的类型大于还是小于寄存器,这可能需要多个汇编指令来执行加法。 - 如果需要从内存中加载
x
的值,则需要花费不止一个时钟周期。除了最里面的高速缓存级别,其他任何东西都使解码指令和执行加法运算所需的时间相形见war。时间量是非常不同的,具体取决于是否在L3缓存,L2缓存,L1缓存或“实际” RAM中找到x
。当您考虑到x
可能是cache prefetch(由硬件或软件触发)的一部分时,甚至变得更加复杂。 - 甚至有可能
x
当前在swap中,因此要读取它需要从磁盘读取。 - 写入结果与读取输入有些相似。但是,读取和写入的性能特征是不同的,因为当您需要一个值时,需要等待读取完成,而当您编写一个值时,则不需要等待写入完成:对内存的写操作将写入高速缓存中的buffer,并且将缓冲区刷新到更高级别的高速缓存或RAM的时间取决于系统上正在发生的其他事情(其他正在争夺内存中的空间)缓存)。
好的,现在让我们转到您的特定示例,看看它们的内部循环中发生了什么。我对x86汇编不是很熟悉,但是我想知道要点。
对于x
,内部循环从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
:无条件跳转到循环的开头。
两个循环之间的区别是:
- 这些循环的长度略有不同,但是两者都足够小,以至于它们可以容纳在单个高速缓存行中(如果代码碰巧跨越了行边界,则可以容纳两个)。因此,在循环的第一次迭代之后,代码将位于最里面的指令缓存中。不仅如此,如果我理解正确的话,在现代英特尔处理器上,还有一个cache of decoded instructions,它的循环足够小以至于无法插入,因此无需进行解码。
-
jmp .L9
循环还读取了一个。额外的读取是从一个恒定地址开始的,该地址很可能会在第一次迭代后保留在最里面的缓存中。 -
stringUpperStrlen
循环中的条件指令仅取决于寄存器中的值。另一方面,stringUpperStrlen
循环中的条件指令取决于刚刚从内存中读取的值。
因此,区别在于可以从最内部的高速缓存读取额外的数据,而不是拥有条件指令,其结果取决于读取的内存。结果取决于另一条指令的结果的一条指令会导致hazard:第二条指令被阻塞,直到第一条指令被完全执行为止,这会阻止利用流水线方法,并可能使推测性执行的效率降低。在stringUpperPtr
循环中,处理器本质上并行运行两件事:加载-调用-存储循环(没有任何条件指令(除了stringUpperStrlen
内部发生的情况))和增量-test周期,它不访问内存。这使处理器在等待内存时可以处理条件指令。在toupper
循环中,条件指令取决于对内存的读取,因此处理器必须在读取完成后才能开始对其进行处理。我通常希望它比从最里面的缓存中读取额外的数据要慢,尽管它可能取决于处理器。
当然,stringUpperPtr
确实需要进行负载测试才能确定字符串的结尾:无论如何执行,都需要提取内存中的字符。这隐藏在stringUpperStrlen
内部。我不知道x86处理器的内部体系结构,但我怀疑这种情况(由于repnz scasb
的原因,这种情况非常普遍)在处理器内部已进行了优化,可能无法达到某种程度可以找到通用说明。
如果字符串较长且strlen
中的两次内存访问不在同一高速缓存行中,则可能会看到不同的结果,尽管可能不会,因为这样做只会花费更多的高速缓存行,并且会有很多次。详细信息取决于缓存的工作方式以及stringUpperStrlen
的使用方式。