Tại sao chương trình C ++ này lại cực kỳ nhanh?


76

Tôi đã viết một điểm chuẩn nhỏ để so sánh hiệu suất của các trình thông dịch / trình biên dịch khác nhau cho Python, Ruby, JavaScript và C ++. Đúng như dự đoán, hóa ra C ++ (được tối ưu hóa) đánh bại các ngôn ngữ kịch bản, nhưng yếu tố mà nó làm được là cực kỳ cao.

Kết quả là:

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

Tôi đang tự hỏi liệu có ai có thể giải thích tại sao mã C ++ được tối ưu hóa nhanh hơn ba bậc lớn hơn mọi thứ khác không.

Điểm chuẩn C ++ sử dụng các tham số dòng lệnh để ngăn việc tính toán trước kết quả tại thời điểm biên dịch.

Dưới đây, tôi đã đặt các mã nguồn cho các điểm chuẩn ngôn ngữ khác nhau, các mã này phải tương đương về mặt ngữ nghĩa. Ngoài ra, tôi đã cung cấp mã lắp ráp cho đầu ra của trình biên dịch C ++ được tối ưu hóa (sử dụng gcc). Khi nhìn vào assembly được tối ưu hóa, có vẻ như trình biên dịch đã hợp nhất hai vòng trong điểm chuẩn thành một vòng duy nhất, nhưng tuy nhiên, vẫn có một vòng lặp!

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;
}

Assembly (khi biên dịch đoạn mã C ++ trên với 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
Tôi cho rằng đó là chi phí của một ngôn ngữ thông dịch. Có thể đáng giá thời gian từ bên trong các tập lệnh của bạn để tránh mất thời gian khởi động và tắt chính trình thông dịch.
Joseph Mansfield

7
Thêm vào "chi phí của một ngôn ngữ thông dịch" thực tế là một số ngôn ngữ được thông dịch cũng có trình biên dịch JIT tối ưu hóa mã khi nó chạy; bạn có thể muốn điểm chuẩn một số bộ mã khác nhau được đảm bảo chạy lâu hơn (bao gồm một số lớn? Tìm số nguyên tố thứ n?) để xem các phép so sánh diễn ra như thế nào ...
abiessu

4
Cái này trông sạch hơn.
edmz

2
Đó không phải là một vòng lặp lồng nhau, có thể nó đã xóa vòng lặp bên trong? Điều đó chắc chắn sẽ tạo ra sự khác biệt
harold

14
Nó có vẻ như bạn yêu cầu -O3, và trình biên dịch có nghĩa vụ bằng cách cắt tỉa một nửa đập xung quanh-the-bụi mã trình toán học của bạn ...
DCoder

Câu trả lời:


103

Trình tối ưu hóa đã phát hiện ra rằng vòng lặp bên trong cùng với dòng tiếp theo là không phù hợp và đã loại bỏ nó. Thật không may, nó cũng không quản lý để loại bỏ vòng lặp bên ngoài.

Lưu ý rằng ví dụ node.js nhanh hơn ví dụ C ++ chưa được tối ưu hóa, cho thấy rằng V8 (trình biên dịch JIT của nút) đã quản lý để loại bỏ ít nhất một trong các vòng lặp. Tuy nhiên, tối ưu hóa của nó có một số chi phí, vì (giống như bất kỳ trình biên dịch JIT nào), nó phải cân bằng các cơ hội để tối ưu hóa và tối ưu hóa lại có hướng dẫn hồ sơ với chi phí làm như vậy.


2
Cảm ơn bạn, đây có vẻ là giải pháp. Khi thực thi tệp thực thi được tối ưu hóa, tôi quan sát thấy thời gian chạy tăng lên khi tăng đối số đầu tiên (bộ đếm 'bên ngoài'), nhưng không phải, nếu tôi làm như vậy với đối số thứ hai.
Sven Hager

21

Tôi đã không thực hiện một phân tích đầy đủ về assembly, nhưng có vẻ như nó đã thực hiện việc mở vòng lặp của vòng lặp bên trong và tìm ra rằng cùng với phép trừ bên trong nó là một nop.

Việc lắp ráp dường như chỉ thực hiện vòng lặp bên ngoài mà chỉ tăng một bộ đếm cho đến khi đạt đến vòng ngoài. Nó thậm chí có thể đã tối ưu hóa điều đó đi, nhưng có vẻ như nó đã không làm được điều đó.


6

Có cách nào để lưu vào bộ đệm mã đã biên dịch JIT sau khi nó tối ưu hóa nó không, hay nó phải tối ưu hóa lại mã mỗi khi chương trình được chạy?

Nếu tôi đang viết bằng Python, tôi sẽ cố gắng giảm kích thước mã xuống để có được cái nhìn "trên cao" về những gì mã đang làm. Giống như hãy thử viết cái này (IMO dễ đọc hơn nhiều):

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

hoặc thậm chí 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;
}

Vòng lặp bên trong tương đương với "s + = inner; j = inner;" mà một trình biên dịch tối ưu hóa tốt có thể làm được. Vì biến j biến mất sau vòng lặp, toàn bộ mã tương đương với

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

Một lần nữa, một trình biên dịch tối ưu hóa tốt có thể loại bỏ hai thay đổi đối với s, sau đó loại bỏ biến i và không còn gì cả. Có vẻ như đó là những gì đã xảy ra.

Bây giờ bạn quyết định tần suất tối ưu hóa như thế này xảy ra và liệu đó có phải là lợi ích thực sự trong cuộc sống hay không.


2

Mặc dù các vòng lặp có nhiều lần lặp lại, nhưng các chương trình có thể vẫn không chạy đủ lâu để thoát khỏi thời gian khởi động của trình thông dịch / JVM / shell / v.v. Trong một số môi trường, chúng có thể thay đổi rất nhiều - trong một số trường hợp, * ho * Java * ho * mất vài giây trước khi nó đến bất kỳ đâu gần mã thực của bạn.

Tốt nhất là bạn nên tính thời gian thực thi trong mỗi đoạn mã. Có thể hơi khó để thực hiện điều này một cách chính xác trên tất cả các ngôn ngữ, nhưng thậm chí in ra thời gian đồng hồ bằng tích tắc trước và sau sẽ tốt hơn là sử dụngtime và sẽ thực hiện công việc vì bạn có thể không quan tâm đến thời gian siêu chính xác ở đây.

(Tôi đoán điều này không thực sự liên quan đến lý do tại sao ví dụ C ++ lại nhanh hơn nhiều - nhưng nó có thể giải thích một số biến thể trong các kết quả khác. :)).

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.