为什么这个C++程序运行得如此之快?

76

我写了一个小型基准测试来比较Python、Ruby、JavaScript和C++解释器/编译器的性能。

结果不出所料,经过优化的C++击败了脚本语言,但它所做到的优势非常高。

测试结果如下:

sven@jet:~/tmp/js$ time node bla.js              # * JavaScript with node *
0

real    0m1.222s
user    0m1.190s
sys 0m0.015s
sven@jet:~/tmp/js$ time ruby foo.rb              # * Ruby *
0

real    0m52.428s
user    0m52.395s
sys 0m0.028s
sven@jet:~/tmp/js$ time python blub.py           # * Python with CPython *
0

real    1m16.480s
user    1m16.371s
sys 0m0.080s

sven@jet:~/tmp/js$ time pypy blub.py             # * Python with PyPy *
0

real    0m4.707s
user    0m4.579s
sys 0m0.028s

sven@jet:~/tmp/js$ time ./cpp_non_optimized 1000 1000000 # * C++ with -O0 (gcc) *
0

real    0m1.702s
user    0m1.699s
sys 0m0.002s
sven@jet:~/tmp/js$ time ./cpp_optimized 1000 1000000     # * C++ with -O3 (gcc) *
0

real    0m0.003s # (!!!) <---------------------------------- WHY?
user    0m0.002s
sys 0m0.002s

我想知道是否有人能够解释为什么优化后的C++代码比其他所有代码快了三个数量级。

C++基准测试使用命令行参数,以防止编译时预计算结果。

下面,我放置了不同语言基准测试的源代码,它们在语义上应该是等价的。 同时,我提供了优化后的C++编译器输出(使用gcc)的汇编代码。 当查看优化后的汇编代码时,似乎编译器将基准测试中的两个循环合并为一个,但是无论如何,仍然存在一个循环!

JavaScript:

var s = 0;
var outer = 1000;
var inner = 1000000;

for (var i = 0; i < outer; ++i) {
    for (var j = 0; j < inner; ++j) {
        ++s;
    }
    s -= inner;
}
console.log(s);

Python:

s = 0
outer = 1000
inner = 1000000

for _ in xrange(outer):
    for _ in xrange(inner):
        s += 1
    s -= inner
print s

Ruby:

s = 0
outer = 1000
inner = 1000000

outer_end = outer - 1
inner_end = inner - 1

for i in 0..outer_end
  for j in 0..inner_end
    s = s + 1
  end
  s = s - inner
end
puts s

C++:

#include <iostream>
#include <cstdlib>
#include <cstdint>

int main(int argc, char* argv[]) {
  uint32_t s = 0;
  uint32_t outer = atoi(argv[1]);
  uint32_t inner = atoi(argv[2]);
  for (uint32_t i = 0; i < outer; ++i) {
    for (uint32_t j = 0; j < inner; ++j)
      ++s;
    s -= inner;
  }
  std::cout << s << std::endl;
  return 0;
}

汇编代码(使用gcc -S -O3 -std=c++0x编译上述C++代码时):

    .file   "bar.cpp"
    .section    .text.startup,"ax",@progbits
    .p2align 4,,15
    .globl  main
    .type   main, @function
main:
.LFB1266:
    .cfi_startproc
    pushq   %r12
    .cfi_def_cfa_offset 16
    .cfi_offset 12, -16
    movl    $10, %edx
    movq    %rsi, %r12
    pushq   %rbp
    .cfi_def_cfa_offset 24
    .cfi_offset 6, -24
    pushq   %rbx
    .cfi_def_cfa_offset 32
    .cfi_offset 3, -32
    movq    8(%rsi), %rdi
    xorl    %esi, %esi
    call    strtol
    movq    16(%r12), %rdi
    movq    %rax, %rbp
    xorl    %esi, %esi
    movl    $10, %edx
    call    strtol
    testl   %ebp, %ebp
    je  .L6
    movl    %ebp, %ebx
    xorl    %eax, %eax
    xorl    %edx, %edx
    .p2align 4,,10
    .p2align 3
.L3:                             # <--- Here is the loop
    addl    $1, %eax             # <---
    cmpl    %eax, %ebx           # <---
    ja  .L3                      # <---
.L2:
    movl    %edx, %esi
    movl    $_ZSt4cout, %edi
    call    _ZNSo9_M_insertImEERSoT_
    movq    %rax, %rdi
    call    _ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
    popq    %rbx
    .cfi_remember_state
    .cfi_def_cfa_offset 24
    popq    %rbp
    .cfi_def_cfa_offset 16
    xorl    %eax, %eax
    popq    %r12
    .cfi_def_cfa_offset 8
    ret
.L6:
    .cfi_restore_state
    xorl    %edx, %edx
    jmp .L2
    .cfi_endproc
.LFE1266:
    .size   main, .-main
    .p2align 4,,15
    .type   _GLOBAL__sub_I_main, @function
_GLOBAL__sub_I_main:
.LFB1420:
    .cfi_startproc
    subq    $8, %rsp
    .cfi_def_cfa_offset 16
    movl    $_ZStL8__ioinit, %edi
    call    _ZNSt8ios_base4InitC1Ev
    movl    $__dso_handle, %edx
    movl    $_ZStL8__ioinit, %esi
    movl    $_ZNSt8ios_base4InitD1Ev, %edi
    addq    $8, %rsp
    .cfi_def_cfa_offset 8
    jmp __cxa_atexit
    .cfi_endproc
.LFE1420:
    .size   _GLOBAL__sub_I_main, .-_GLOBAL__sub_I_main
    .section    .init_array,"aw"
    .align 8
    .quad   _GLOBAL__sub_I_main
    .local  _ZStL8__ioinit
    .comm   _ZStL8__ioinit,1,1
    .hidden __dso_handle
    .ident  "GCC: (Ubuntu 4.8.2-19ubuntu1) 4.8.2"
    .section    .note.GNU-stack,"",@progbits

35
我想这就是解释型语言的开销。从脚本内部计时可能值得尝试,以避免启动和关闭解释器所需的时间。 - Joseph Mansfield
7
将“解释语言的开销”与一些解释语言还具有JIT编译器这一事实相加,该编译器可在代码运行时优化它;你可能需要对某些保证运行时间较长的代码集进行基准测试(如分解大数?查找第n个质数?),以了解比较结果如何。 - abiessu
4
这看起来更干净。 - edmz
2
那不是嵌套循环,也许它删除了内部循环?这肯定会有所不同。 - harold
14
看起来你请求了“-O3”,编译器通过砍掉一半迂回的数学代码来遵从你的请求。 - DCoder
显示剩余6条评论
5个回答

103
优化器已经确定内部循环以及随后的行是无操作,并将其删除。不幸的是,它没有成功消除外部循环。
请注意,node.js示例比未经优化的C++示例更快,这表明V8(node的JIT编译器)已经成功消除了至少一个循环。然而,它的优化有一些开销,因为(像任何JIT编译器一样),它必须权衡优化和基于性能分析的重新优化的机会与成本。

2
谢谢,这似乎是解决方案。当执行优化后的可执行文件时,我观察到在增加第一个参数(“外部”计数器)时运行时间会增加,但如果我使用第二个参数,则不会增加。 - Sven Hager

21

我没有对汇编进行完整分析,但看起来它对内部循环进行了展开,并且发现内部循环的减法与一起是一个 nop。

汇编似乎只执行外部循环,该循环仅递增计数器直到达到outer。它甚至可以将其优化掉,但似乎没有这样做。


6
有没有一种方法可以在JIT对代码进行优化后缓存JIT编译的代码,还是每次运行程序时都必须重新优化代码?
如果我用Python写代码,我会尝试将代码压缩为一个小的“开销视图”,以了解代码的运作情况。例如,试着写成这样(在我看来更容易阅读):
for i in range(outer):
    innerS = sum(1 for _ in xrange(inner))
    s += innerS
    s -= innerS

甚至可以通过s = sum(inner - inner for _ in xrange(outer))实现。


2
for (uint32_t i = 0; i < outer; ++i) {
    for (uint32_t j = 0; j < inner; ++j)
        ++s;
    s -= inner;
}

内部循环等同于 "s += inner; j = inner; ",一个好的优化编译器可以做到这一点。由于变量 j 在循环后消失,整个代码等同于

for (uint32_t i = 0; i < outer; ++i) {
    s += inner;
    s -= inner;
}

一款优秀的优化编译器可以消除对s的两次更改,然后删除变量i,最终不会留下任何内容。看起来这就是发生的事情。

现在由您决定此类优化发生的频率以及是否有任何实际效益。


2
尽管循环迭代次数很多,但程序可能仍然不足以长时间运行以逃脱解释器/JVM/shell等的启动时间开销。在某些环境中,这些开销可能会有巨大的差异 - 在某些情况下 *咳咳*Java*咳咳*需要几秒钟才能到达您实际的代码。

最理想的情况是在每个代码片段内计时执行时间。要在所有语言上准确地执行此操作可能有些棘手,但即使在之前和之后以滴答声打印出时钟时间也比使用time更好,并且可以胜任工作,因为您可能并不关心超精确的定时。

(我想这与C++示例为什么如此快没有太大关系 - 但它可以解释其他结果的可变性。 :))。

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