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

Я написал небольшой ориентир, чтобы сравнить производительность разных интерпретаторов/компиляторов для Python, Ruby, JavaScript и С++. Как и ожидалось, оказывается, что (оптимизированный) С++ превосходит языки сценариев, но фактор, с помощью которого он делает это, невероятно высок.

Результаты:

[email protected]:~/tmp/js$ time node bla.js              # * JavaScript with node *
0

real    0m1.222s
user    0m1.190s
sys 0m0.015s
[email protected]:~/tmp/js$ time ruby foo.rb              # * Ruby *
0

real    0m52.428s
user    0m52.395s
sys 0m0.028s
[email protected]:~/tmp/js$ time python blub.py           # * Python with CPython *
0

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

[email protected]:~/tmp/js$ time pypy blub.py             # * Python with PyPy *
0

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

[email protected]:~/tmp/js$ time ./cpp_non_optimized 1000 1000000 # * C++ with -O0 (gcc) *
0

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

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

Мне интересно, может ли кто-нибудь объяснить, почему оптимизированный код на С++ более чем на три порядка быстрее, чем все остальное.

В тесте С++ используются параметры командной строки, чтобы предотвратить предварительное вычисление результата во время компиляции.

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

С++:

#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 = С++ 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    $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

Ответ 1

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

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

Ответ 2

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

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

Ответ 3

Есть ли способ кэшировать скомпилированный код JIT после его оптимизации или же он должен повторно оптимизировать код при каждом запуске программы?

Если бы я писал на Python, я бы попытался уменьшить размер кода, чтобы получить "служебное" представление о том, что делает код. Например, попробуйте написать это (гораздо проще прочитать IMO):

for i in range(outer):
    innerS = sum(1 for _ in xrange(inner))
    s += innerS
    s -= innerS

или даже s = sum(inner - inner for _ in xrange(outer))

Ответ 4

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

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

Ответ 5

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

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

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