Чому ця програма на 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 ++ (за допомогою 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

Рубін:

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    $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
Ну, гадаю, це накладні витрати на інтерпретовану мову. Можливо, варто провести синхронізацію у ваших сценаріях, щоб уникнути часу, необхідного для запуску та вимкнення самого інтерпретатора.
Джозеф Менсфілд,

7
Додайте до "накладних витрат на інтерпретовану мову" той факт, що деякі з інтерпретованих мов також мають компілятор JIT, який оптимізує код під час роботи; можливо, ви захочете порівняти деякі різні набори коду, які гарантовано працюватимуть довше (враховуючи велику кількість? Знаходження n-го простого числа?), щоб побачити, як
похитруються

4
Це виглядає чистішим.
edmz

2
Ну, це не вкладений цикл, можливо, він видалив внутрішній цикл? Це, безсумнівно, мало би різницю
Гарольд,

14
Здається, ви просили про це -O3, і компілятор зобов’язав обрізати половину вашого математичного коду, який
бить

Відповіді:


103

Оптимізатор розробив, що внутрішній цикл разом із наступним рядком не відповідає, і усунув його. На жаль, йому також не вдалося усунути зовнішній цикл.

Зверніть увагу, що приклад node.js швидший, ніж неоптимізований приклад C ++, що вказує на те, що V8 (компілятор JIT вузла) зумів усунути принаймні один із циклів. Однак його оптимізація має певні накладні витрати, оскільки (як і будь-який компілятор JIT) вона повинна збалансувати можливості оптимізації та керовану профілем реоптимізацію проти вартості цього.


2
Дякую, це, здається, рішення. Під час виконання оптимізованого виконуваного файлу я спостерігаю збільшення часу виконання при збільшенні першого аргументу ("зовнішнього" лічильника), але ні, якщо я роблю це з другим.
Sven Hager

21

Я не провів повний аналіз збірки, але схоже, що це було розгортання циклу внутрішнього циклу і з'ясував, що разом із відніманням внутрішнього це nop.

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


6

Чи є спосіб кешувати скомпільований код 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))


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

Внутрішній цикл еквівалентний "s + = внутрішній; j = внутрішній;", що може зробити хороший оптимізуючий компілятор. Оскільки після циклу змінної j немає, весь код еквівалентний

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

Знову ж таки, хороший оптимізуючий компілятор може видалити дві зміни до s, потім видалити змінну i, і вже нічого не залишилося. Здається, саме так і сталося.

Тепер вам вирішувати, як часто така оптимізація трапляється, і чи є це якоюсь реальною вигодою.


2

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

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

(Я думаю, це насправді не стосується того, чому приклад С ++ набагато швидший - але це може пояснити деяку мінливість в інших результатах. :)).

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.