Почему эта С++-программа настолько невероятно быстро?
Я написал небольшой ориентир, чтобы сравнить производительность разных интерпретаторов/компиляторов для 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
, и будет выполнять эту работу, так как вы, вероятно, не занимаетесь сверхточным временем здесь.
(Думаю, это не связано с тем, почему пример С++ намного быстрее - но он может объяснить некоторые изменчивости в других результатах.:)).