Почему эта программа на C++ так невероятно быстра?


Я написал небольшой тест для сравнения производительности различных интерпретаторов / компиляторов для 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++ benchmark использует параметры командной строки для предотвращения предварительного вычисления результата во время компиляции.

ниже я разместил исходные коды для различных языковых тестов, которые должны быть семантически эквивалентны. Кроме того, я предоставил ассемблерный код для оптимизированного вывода компилятора 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;
}

сборка (при компиляции вышеуказанного кода C++ с помощью gcc-S-O3-std=c++0x):

    .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    , %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    , %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    , %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    , %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    , %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
5 75

5 ответов:

оптимизатор выяснил, что внутренний цикл вместе с последующей строкой является no-op, и устранил его. К сожалению, ему также не удалось устранить внешний цикл.

обратите внимание, что узел.пример js быстрее, чем неоптимизированный пример C++, что указывает на то, что V8 (JIT-компилятор узла) удалось устранить по крайней мере один из циклов. Однако его оптимизация имеет некоторые накладные расходы, так как (как и любой JIT-компилятор) он должен сбалансировать возможности для оптимизации и профильной повторной оптимизации против затрат на это.

Я не сделал полный анализ сборки, но похоже, что он сделал цикл развертывания внутреннего цикла и выяснил, что вместе с вычитанием внутреннего это nop.

сборка только кажется, что делает внешний цикл, который только увеличивает счетчик до тех пор, пока не будет достигнут внешний. Он мог бы даже оптимизировать это, но похоже, что он этого не сделал.

есть ли способ кэшировать 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))

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, и ничего не останется. Кажется, именно это и произошло.

теперь это до вас, чтобы решить, как часто оптимизация, как это происходит, и является ли это истинное благо жизни.

несмотря на то, что циклы имеют много итераций, программы, вероятно, все еще недостаточно долго работают, чтобы избежать накладных расходов интерпретатора/JVM/shell/etc.'ы время запуска. В некоторых средах они могут сильно отличаться - в некоторых случаях * кашель * Java * кашель * занимает несколько секунд, прежде чем он приблизится к вашему фактическому коду.

В идеале вы бы время выполнения в каждом фрагменте кода. Это может быть сложно сделать это точно на всех языках, но даже распечатка часы времени в ТИКах до и после было бы лучше, чем с помощью time, и будет делать эту работу, так как вы, вероятно, не связаны с супер-точным временем здесь.

(Я думаю, это действительно не связано с тем, почему пример C++ намного быстрее, но он может объяснить некоторую вариативность в других результатах. :) ).