为什么 '==' 在 std::string 上很慢?

Why '==' is slow on std::string?

本文关键字:std 为什么 string      更新时间:2023-10-16

在分析我的应用程序时,我意识到很多时间都花在字符串比较上。所以我写了一个简单的基准测试,我很惊讶'=='比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

附言计时器只是一个包装类来测量花费的时间;我绝对确定:D

计时器类的代码:

#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_ */

更新:改进基准测试的输出 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

请注意,一般来说,strcmp在功能上并不等同于文本可能嵌入 NUL 时的==.compare,因为前者将"提前退出"。 (这不是上面"更快"的原因,但请阅读下面的评论,了解字符串长度/内容等的可能变化。


讨论/早期答案

只需查看您的实现 - 例如

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

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

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快/慢,因为字符串通常在第一个或三个字符中有所不同,但对于相等或仅在末尾不同的长字符串,则相反,因此在得出结论之前,请确保尝试不同类型的输入,然后再得出结论,了解性能配置文件。

要么你的时间安排搞砸了,要么你的编译器已经优化了你的一些代码。

想想看,0.000349 毫秒内的 100 亿次操作(我将使用 0.000500 毫秒或半微秒,使我的计算更容易(意味着您每秒执行 20 万亿次操作。

即使一个操作可以在一个时钟周期内完成,那也将是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 选项(,以验证它是否确实在执行它声称要做的工作。

问题是编译器正在对代码进行大量认真的优化。

下面是修改后的代码:

#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="><来强制编译器计算>

输出现在为:

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

你能解释一下为什么strcmp比.compa比==慢吗? 然而,速度差异很小,但很大。

其实是有道理的! :p

下面的速度分析是错误的 - 感谢 Tony D 指出我的错误。不过,对更好的基准的批评和建议仍然适用。


前面的所有答案都涉及基准测试中的编译器优化问题,但没有回答为什么strcmp仍然略快。

strcmp可能更快(在更正的基准测试中(,因为字符串有时包含零。由于strcmp使用 C 字符串,因此当遇到字符串终止字符''时,它可以退出。 std::string::compare() ''视为另一个字符,并一直持续到字符串数组的末尾。

由于您已经不确定地为 RNG 设定了种子,并且只生成了两个字符串,因此每次运行代码时,结果都会发生变化。(我建议不要在基准测试中这样做。考虑到这些数字,128次中有28次,应该没有优势。10 次中的 128 次,您将获得超过 10 倍的速度。等等。

除了击败编译器的优化器之外,我建议下次为每个比较迭代生成一个新字符串,以便平均这些影响。

使用 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 下(

只是想在这里包括 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,请在您自己的代码中进行配置文件以确保。