为什么在std::string中使用'=='运算符速度很慢?

14

在对我的应用程序进行分析时,我意识到很多时间都花费在字符串比较上。因此,我编写了一个简单的基准测试,我惊讶地发现'=='比string::compare和strcmp慢得多!这是代码,有人能解释为什么吗?或者我的代码有什么问题吗?因为根据标准,'=='只是一个运算符重载,简单地返回!lhs.compare(rhs)。

#include <iostream>
#include <vector>
#include <string>
#include <stdint.h>
#include "Timer.h"
#include <random>
#include <time.h>
#include <string.h>
using namespace std;
uint64_t itr  = 10000000000;//10 Billion
int len = 100;
int main() {
  srand(time(0));
  string s1(len,random()%128);
  string s2(len,random()%128);

uint64_t a = 0;
  Timer t;
  t.begin();
  for(uint64_t i =0;i<itr;i++){
    if(s1 == s2)
      a = i;
  }
  t.end();

  cout<<"==       took:"<<t.elapsedMillis()<<endl;

  t.begin();
  for(uint64_t i =0;i<itr;i++){
    if(s1.compare(s2)==0)
      a = i;
  }
  t.end();

  cout<<".compare took:"<<t.elapsedMillis()<<endl;

  t.begin();
  for(uint64_t i =0;i<itr;i++){
    if(strcmp(s1.c_str(),s2.c_str()))
      a = i;
  }
  t.end();

  cout<<"strcmp   took:"<<t.elapsedMillis()<<endl;

  return a;
}

以下是运行结果:

==       took:5986.74
.compare took:0.000349
strcmp   took:0.000778

我的编译标志:

CXXFLAGS = -O3 -Wall -fmessage-length=0 -std=c++1y

我在一台x86_64的Linux机器上使用gcc 4.9。

显然,使用-o3会进行一些优化,我猜最后两个循环被完全排除了;然而,即使使用-o2,结果仍然很奇怪:

对于10亿次迭代:

==       took:19591
.compare took:8318.01
strcmp   took:6480.35

附注:Timer只是一个测量花费时间的包装类;我对此非常确定:D

Timer类的代码:

#include <chrono>

#ifndef SRC_TIMER_H_
#define SRC_TIMER_H_


class Timer {
  std::chrono::steady_clock::time_point start;
  std::chrono::steady_clock::time_point stop;
public:
  Timer(){
    start = std::chrono::steady_clock::now();
    stop = std::chrono::steady_clock::now();
  }
  virtual ~Timer() {}

  inline void begin() {
    start = std::chrono::steady_clock::now();
  }

  inline void end() {
    stop = std::chrono::steady_clock::now();
  }

  inline double elapsedMillis() {
    auto diff = stop - start;
    return  std::chrono::duration<double, std::milli> (diff).count();
  }

  inline double elapsedMicro() {
    auto diff = stop - start;
    return  std::chrono::duration<double, std::micro> (diff).count();
  }

  inline double elapsedNano() {
    auto diff = stop - start;
    return  std::chrono::duration<double, std::nano> (diff).count();
  }

  inline double elapsedSec() {
    auto diff = stop - start;
    return std::chrono::duration<double> (diff).count();
  }
};

#endif /* SRC_TIMER_H_ */

27
如果您在不到一毫秒的时间内看到了十亿次迭代,那么您应该质疑基准测试本身... - Mysticial
3
你的 strcmp 代码是错误的。你应该在那里使用 == 0 - Rapptz
3
如果你将==的基准测试替换为.compare,你会得到相同的结果吗? - Pedro Gimeno
4
我的电脑花了6分钟以上才通过==代码,之后过了6分钟我终止了程序。将十亿改成一百万,得到这些结果:==所用时间为:37.5232秒compare所用时间为:19.3218秒strcmp所用时间为:12.3108秒 - Jonny Henly
2
我看了汇编语言。对于比较和strcmp的情况,如果第一次迭代不等于0,则直接退出循环。从语义上讲,这是正确的。但对于==则不是这种情况。我猜编译器确实很聪明,但还不够聪明。此外,==不会调用函数。我猜它被内联了。这种内联可能导致优化出现问题。 - thang
显示剩余9条评论
6个回答

13

更新:改进后的基准测试输出在http://ideone.com/rGc36a

==       took:21
.compare took:21
strcmp   took:14
==       took:21
.compare took:25
strcmp   took:14

关键是要“智取”编译器在编译时预测字符串比较的能力,这是使其有意义地工作的关键。

// more strings that might be used...
string s[] = { {len,argc+'A'}, {len,argc+'A'}, {len, argc+'B'}, {len, argc+'B'} };

if(s[i&3].compare(s[(i+1)&3])==0)  // trickier to optimise
  a += i;  // cumulative observable side effects

请注意,一般情况下,当文本中可能包含NUL时,strcmp在功能上与 == .compare 不等效,因为前者将“提前退出”。(这不是上面“更快”的原因,但请阅读下面的内容,以获取有关字符串长度/内容等可能变化的注释。)


讨论/早期回答

只需查看您的实现-例如

echo '#include <string>' > stringE.cc
g++ -E stringE.cc | less

搜索basic_string模板,然后搜索处理两个字符串实例的operator==运算符 - 我的是:

template<class _Elem,
    class _Traits,
    class _Alloc> inline
    bool __cdecl operator==(
            const basic_string<_Elem, _Traits, _Alloc>& _Left,
            const basic_string<_Elem, _Traits, _Alloc>& _Right)
    {
    return (_Left.compare(_Right) == 0);
    }

请注意,operator== 是内联的,只是调用了compare。启用正常的优化级别时,它不会一致地明显变慢,尽管优化器可能偶尔会由于周围代码的微妙副作用而更好地优化一个循环而不是另一个。

你所谓的问题可能是由于你的代码被优化超出了执行预期工作的点,for 循环随意展开到不同的程度,或者优化或定时的其他怪异或错误引起的。当输入不变且循环没有任何累积副作用(即编译器可以确定 a 的中间值未被使用,因此只需最后一个 a = i 生效)时,这并不罕见。

因此,学会写更好的基准测试。在这种情况下,这有点棘手,因为在内存中准备大量不同的字符串来调用比较,并选择它们的方式,使优化器无法在编译时预测,同时速度足够快,以不至于淹没和模糊字符串比较代码的影响,这并不容易。而且,在一定程度上,比较分散在更多内存中使缓存效应对基准测试更为重要,这进一步模糊了实际比较性能。

但是,如果我是你,我会从文件中读取一些字符串 - 将每个字符串推入 vector,然后循环遍历 vector,在相邻元素之间执行三个比较操作中的每一个。然后编译器不可能预测结果中的任何模式。你可能会发现,在第一个字符或三个字符不同的字符串方面,compare/==strcmp 更快/更慢,但对于完全相等或仅在末尾不同的长字符串,则相反,请确保在得出性能概况之前尝试不同类型的输入。


1
@thang:不,我不是——我用作实现示例的标准库实现没有你提到的版本。此外,值得一提的是,C++标准本身在21.4.8.2中记录了类似于我引用的内容,但没有单参数版本。你的库可能恰好有这样的重载——如果它不是“inline”,请分享你的库,因为从性能的角度来看这可能是相关的。 - Tony Delroy
您更新代码的问题在于,现在您正在测量运算符[]和所有&和%的运行时间。另外,请注意编译器仍然可以(并且很可能会)优化a + i; 另外,我使用g++ v 4.8.1。我很确定这是正确的版本。我引用的operator==在传递之前首先比较大小。这与汇编语言输出匹配。您可能正在使用不同版本的库,但这是不太可能的。 - thang
至于我从中复制粘贴的库,实际上是VC++ 15.00.30729.01(我在cygwin bash下有一个g++别名,只是为了让生活更有趣;-P)。 - Tony Delroy
如果你之后不使用a,g++实际上会优化掉+=。看一下汇编语言输出。 - thang
1
我会在完全不同的执行运行中进行这三个测试。 - Lightness Races in Orbit
显示剩余6条评论

12

要么你的计时不准确,要么你的编译器优化了你的一些代码导致其不存在。

想想看,十亿次操作只需要0.000349毫秒(我会用0.000500毫秒或半微秒来方便我的计算),这意味着你每秒钟执行二十万亿次操作。

即使一个操作可以在单个时钟周期内完成,那也是 20,000 GHz,超过目前的 CPU,即使加上它们大规模优化的流水线和多个核心也是如此。

而且,考虑到使用 -O2 优化的数据更加接近(== 操作大约需要比 compare 操作多两倍的时间),"代码被优化不存在" 的可能性看起来更有可能。

这种时间翻倍很容易解释为因为 operator== 需要调用 compare 来完成其工作,所以产生了十亿次额外的函数调用。

进一步支持这种观点的是,查看下表,其中显示的数字以毫秒为单位(第三列是第二列的简单除以十的比例,以便第一列和第三列都是十亿次迭代):

         -O2/1billion  -O3/10billion  -O3/1billion  Improvement
               (a)            (b)     (c = b / 10)    (a / c)
         ============  =============  ============  ===========
oper==          19151           5987           599           32
compare          8319         0.0005       0.00005  166,380,000

难以置信的是,-O3 可以将 == 代码速度提高约32倍,但可以将 compare 代码速度提高数亿倍。


强烈建议您查看编译器生成的汇编代码(例如使用 gcc -S 选项),以验证它实际上正在进行它声称的工作。


这是正确的,但看一下 -o2 的结果。 - Behrooz
我也放了计时器代码。我猜你可以自己运行它或使用gettimeofday或其他东西。 - Behrooz
1
@Behrooz:或者你可以采用我建议的方法,检查编译器生成的代码。我已经解释过,你看到的结果几乎是不可能的。由于检查代码需要你自己付出工作而不是我,所以那是我更喜欢的选项 :-) - paxdiablo
所以我检查了代码的反汇编,它实际上生成了正确的代码!我使用了标志,这是相关部分的汇编输出:http://pastebin.com/tyjSQYFT 顺便说一句,这是二进制文件的结果:compare took:8825.2 == took:20729.9 strcmp took:6806.52 - Behrooz

8
下面的速度分析是错误的-感谢Tony D指出我的错误。然而,对于更好的基准测试的批评和建议仍然适用。
所有之前的答案都处理了编译器优化问题,但没有回答为什么strcmp仍然稍微快一些。
在更正后的基准测试中,strcmp可能更快,因为字符串有时包含零。由于strcmp使用C字符串,它可以在遇到字符串终止字符'\0'时退出。std::string ::compare()将'\0'视为另一个字符,并继续直到字符串数组的末尾。
由于您已经非确定性地播种了RNG,并且仅生成了两个字符串,因此您的结果将随着代码的每次运行而改变。(我建议在基准测试中不要这样做。)鉴于数字,128次中应该没有优势的情况下,会有28次。128次中有10次你会获得超过10倍的加速。等等。
除了打败编译器的优化器外,我建议下一次为每个比较迭代生成一个新字符串,从而允许您平均掉这些效果。

“在128次中,有28次不应该有任何优势。在128次中,有10次你会获得超过10倍的加速。” - 你能解释一下这个分析吗?每个字符串都包含0到127之间某个字符的100个重复...只有当两个字符串完全相同时(128中的1),并且它们都是全NUL时(128^2中的1),比较函数才会超过第一个字符,而strcmp可以提前退出,而==.compare则不能。 - Tony Delroy

8
问题在于编译器正在对您的代码进行许多严格的优化。
以下是修改后的代码:
#include <iostream>
#include <vector>
#include <string>
#include <stdint.h>
#include "Timer.h"
#include <random>
#include <time.h>
#include <string.h>
using namespace std;
uint64_t itr  = 500000000;//10 Billion
int len = 100;
int main() {
  srand(time(0));
  string s1(len,random()%128);
  string s2(len,random()%128);

uint64_t a = 0;
  Timer t;
  t.begin();
  for(uint64_t i =0;i<itr;i++){
asm volatile("" : "+g"(s2));
    if(s1 == s2)
      a += i;
  }
  t.end();

  cout<<"==       took:"<<t.elapsedMillis()<<",a="<<a<<endl;

  t.begin();
  for(uint64_t i =0;i<itr;i++){
asm volatile("" : "+g"(s2));
    if(s1.compare(s2)==0)
      a+=i;
  }
  t.end();

  cout<<".compare took:"<<t.elapsedMillis()<<",a="<<a<<endl;

  t.begin();
  for(uint64_t i =0;i<itr;i++){
asm volatile("" : "+g"(s2));
    if(strcmp(s1.c_str(),s2.c_str()) == 0)
      a+=i;
  }
  t.end();

  cout<<"strcmp   took:"<<t.elapsedMillis()<<",a="<<a<< endl;

  return a;
}

我添加了asm volatile("" : "+g"(s2));来强制编译器运行比较。我也添加了<<",a="<强制编译器计算a。

现在的输出结果如下:

==       took:10221.5,a=0
.compare took:10739,a=0
strcmp   took:9700,a=0

你能解释一下strcmp比.compare快,同时比==慢的原因吗?速度差异虽然微不足道,但是还是很明显的。
这其实是有道理的! :p

@TonyD,编译器无法控制缓存。这是处理器的属性。而且,这并不会强制处理器刷新缓存。 - thang
抱歉——我写得太快了!这将阻止编译器使用寄存器中缓存的任何字符串状态,这意味着它必须再次从L1获取数据。虽然我谈论了错误的缓存级别,但仍然可能存在潜在问题。 - Tony Delroy
你不能将整个字符串放入寄存器中。你说的话毫无意义。如果你想检查,可以查看汇编语言输出。 - thang
字符串对象具有可以在寄存器中的begin/end/capacity指针成员。无论如何,我已经花费了太多时间在这个问题上了,将其留给任何感兴趣/关心的人。干杯。 - Tony Delroy

1

使用 gcc -O3 -S --std=c++1y 编译代码。结果在这里。gcc 版本为:

gcc (Ubuntu 4.9.1-16ubuntu6) 4.9.1
Copyright (C) 2014 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

看一下,我们可以把第一个循环(operator ==)写成这样:(注释是我添加的)

    movq    itr(%rip), %rbp
    movq    %rax, %r12
    movq    %rax, 56(%rsp)
    testq   %rbp, %rbp
    je  .L25
    movq    16(%rsp), %rdi
    movq    32(%rsp), %rsi
    xorl    %ebx, %ebx
    movq    -24(%rsi), %rdx  ; length of string1
    cmpq    -24(%rdi), %rdx  ; compare lengths
    je  .L53                 ; compare content only when length is the same
.L10
   ; end of loop, print out follows

;....
.L53:
    .cfi_restore_state
    call    memcmp      ; compare content
    xorl    %edx, %edx  ; zero loop count
    .p2align 4,,10
    .p2align 3
.L13:
    testl   %eax, %eax  ; check result
    cmove   %rdx, %rbx  ; a = i
    addq    $1, %rdx    ; i++
    cmpq    %rbp, %rdx  ; i < itr?
    jne .L13
    jmp .L10    

; ....
.L25:
    xorl    %ebx, %ebx
    jmp .L10

我们可以看到operator ==是内联的,只有一个调用memcmp的语句。对于operator ==,如果长度不同,则不会比较内容。
最重要的是,比较只会进行一次。循环中只包含i++;a=i;i<itr;
对于第二个循环(compare()):
    movq    itr(%rip), %r12
    movq    %rax, %r13
    movq    %rax, 56(%rsp)
    testq   %r12, %r12
    je  .L14
    movq    16(%rsp), %rdi
    movq    32(%rsp), %rsi
    movq    -24(%rdi), %rbp
    movq    -24(%rsi), %r14  ; read and compare length
    movq    %rbp, %rdx
    cmpq    %rbp, %r14
    cmovbe  %r14, %rdx       ; save the shorter length of the two string to %rdx
    subq    %r14, %rbp       ; length difference in %rbp
    call    memcmp           ; content is always compared
    movl    $2147483648, %edx ; 0x80000000 sign extended
    addq    %rbp, %rdx       ; revert the sign bit of %rbp (length difference) and save to %rdx
    testl   %eax, %eax       ; memcmp returned 0?
    jne .L14                 ; no, string different
    testl   %ebp, %ebp       ; memcmp returned 0. Are lengths the same (%ebp == 0)?
    jne .L14                 ; no, string different
    movl    $4294967295, %eax ; string compare equal
    subq    $1, %r12         ; itr - 1
    cmpq    %rax, %rdx
    cmovbe  %r12, %rbx       ; a = itr - 1
.L14:
    ; output follows

这里没有任何循环。 在`compare()`函数中,根据比较结果返回正数、负数或零,始终比较字符串内容。只调用一次`memcmp`。 对于第三个循环(`strcmp()`),汇编代码最简单。
    movq    itr(%rip), %rbp   ; itr to %rbp
    movq    %rax, %r12
    movq    %rax, 56(%rsp)
    testq   %rbp, %rbp
    je  .L16
    movq    32(%rsp), %rsi
    movq    16(%rsp), %rdi
    subq    $1, %rbp       ; itr - 1 to %rbp
    call    strcmp
    testl   %eax, %eax     ; test compare result
    cmovne  %rbp, %rbx     ; if not equal, save itr - 1 to %rbx (a)
.L16:

这些函数都没有循环。调用了 strcmp 函数,如果字符串不相等(就像你的代码一样),直接将 itr-1 保存到 a 中。
所以你的基准测试无法测试 operator ==compare()strcmp() 的运行时间。它们只被调用一次,不能显示运行时间差异。
至于为什么 operator == 花费最多的时间,是因为对于 operator==,编译器由于某种原因没有消除循环。循环需要时间(但循环根本不包含字符串比较)。
从汇编中可以看出,我们可以假设 operator == 可能是最快的,因为如果两个字符串的长度不同,它根本不会进行字符串比较。(当然,在 gcc4.9.1 -O3 下)

0

在这里想要提到的是,C++17及以后版本提供了std::string_view,它似乎比std::string::operator==和C字符串字面量有更快的比较操作,并且可以轻松地添加到现有代码中,例如:

#include <string_view>
using namespace std::literals;
...
// replace this:
// if(string == "example")
// with:
if(string == "example"sv)
   ...

(或者使用std::string_view{"example"},如果您更喜欢。)

请参见https://quick-bench.com/q/RXbZnq43vWWA7pn-9Qw4fmLDxUc 进行一些实验,在您自己的代码中进行分析以确保准确性。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接