Khi tôi kiểm tra sự khác biệt về thời gian giữa dịch chuyển và nhân lên trong C, không có sự khác biệt. Tại sao?


28

Tôi đã được dạy rằng dịch chuyển trong nhị phân hiệu quả hơn nhiều so với nhân 2 ^ k. Vì vậy, tôi muốn thử nghiệm và tôi đã sử dụng đoạn mã sau để kiểm tra điều này:

#include <time.h>
#include <stdio.h>

int main() {
    clock_t launch = clock();
    int test = 0x01;
    int runs;

    //simple loop that oscillates between int 1 and int 2
    for (runs = 0; runs < 100000000; runs++) {


    // I first compiled + ran it a few times with this:
    test *= 2;

    // then I recompiled + ran it a few times with:
    test <<= 1;

    // set back to 1 each time
    test >>= 1;
    }

    clock_t done = clock();
    double diff = (done - launch);
    printf("%f\n",diff);
}

Đối với cả hai phiên bản, bản in ra xấp xỉ 440000, cho hoặc lấy 10000. Không có sự khác biệt đáng kể (về mặt trực quan, ít nhất) giữa các đầu ra của hai phiên bản. Vì vậy, câu hỏi của tôi là, có gì sai với phương pháp của tôi? Thậm chí có nên có một sự khác biệt trực quan? Cái này có liên quan gì đến kiến ​​trúc máy tính của tôi, trình biên dịch hay cái gì khác không?


47
Bất cứ ai dạy bạn rằng rõ ràng là sai lầm. Niềm tin đó đã không còn đúng từ những năm 1970, đối với các trình biên dịch được sử dụng điển hình trên các kiến ​​trúc được sử dụng điển hình. Tốt cho bạn để thử nghiệm yêu cầu này. Tôi đã nghe tuyên bố vô nghĩa này được thực hiện về JavaScript vì lợi ích của trời.
Eric Lippert

21
Cách tốt nhất để trả lời các câu hỏi như thế này là xem mã lắp ráp mà trình biên dịch đang tạo ra. Trình biên dịch thường có một tùy chọn để tạo một bản sao của ngôn ngữ hợp ngữ mà chúng đang tạo. Đối với trình biên dịch GNU GCC, đây là '-S'.
Charles E. Grant

8
Mọi người nên chỉ ra rằng sau khi xem cái này với gcc -S, mã cho test *= 2thực sự được biên dịch thành shll $1, %eax Khi được gọi với gcc -O3 -Sthậm chí không có một vòng lặp. Hai cuộc gọi đồng hồ cách nhau một đường:callq _clock movq %rax, %rbx callq _clock

6
"Tôi đã được dạy rằng dịch chuyển trong nhị phân hiệu quả hơn nhiều so với nhân 2 ^ k"; chúng tôi được dạy rất nhiều điều mà hóa ra là sai (hoặc ít nhất là lỗi thời). Một trình biên dịch thông minh sẽ sử dụng cùng một thao tác thay đổi cho cả hai.
John Bode

9
Luôn luôn, luôn kiểm tra mã lắp ráp được tạo khi làm việc với loại tối ưu hóa này, để chắc chắn rằng bạn đang đo những gì bạn nghĩ rằng bạn đang đo. Một số lượng lớn các câu hỏi "tại sao tôi lại thấy những lúc này" trên SO sôi sục đến trình biên dịch loại bỏ hoàn toàn các hoạt động vì kết quả không được sử dụng.
Russell Borogove

Câu trả lời:


44

Như đã nói trong câu trả lời khác, hầu hết các trình biên dịch sẽ tự động tối ưu hóa các phép nhân được thực hiện với bithifts.

Đây là một quy tắc rất chung khi tối ưu hóa: Hầu hết 'tối ưu hóa' sẽ thực sự hiểu sai về trình biên dịch về ý nghĩa thực sự của bạn và thậm chí có thể làm giảm hiệu suất.

Chỉ tối ưu hóa khi bạn đã nhận thấy một vấn đề hiệu suất và đo lường vấn đề là gì. (và hầu hết các mã chúng tôi viết không được thực thi thường xuyên, vì vậy chúng tôi không cần phải bận tâm)

Nhược điểm lớn để tối ưu hóa là mã 'được tối ưu hóa' thường dễ đọc hơn nhiều. Vì vậy, trong trường hợp của bạn, luôn luôn nhân lên khi bạn đang tìm cách nhân. Và chuyển bit khi bạn muốn di chuyển bit.


20
Luôn luôn sử dụng các hoạt động là đúng ngữ nghĩa. Nếu bạn đang thao tác mặt nạ bit hoặc định vị các số nguyên nhỏ trong các số nguyên lớn hơn, dịch chuyển là thao tác thích hợp.
ddyer

2
Có bao giờ (thực tế nói) sẽ cần phải tối ưu hóa một phép nhân cho toán tử thay đổi trong một ứng dụng phần mềm cấp cao không? Dường như, vì trình biên dịch đã tối ưu hóa, rằng lần duy nhất hữu ích để có kiến ​​thức này là khi lập trình ở mức rất thấp (ít nhất là, dưới trình biên dịch).
NicholasFolk

11
@NicholasFol không. Làm những gì đơn giản nhất để hiểu. Nếu bạn đang viết lắp ráp trực tiếp, nó có thể hữu ích ... hoặc nếu bạn đang viết một trình biên dịch tối ưu hóa, một lần nữa nó có thể hữu ích. Nhưng bên ngoài hai trường hợp đó, một mánh khóe che khuất những gì bạn đang làm và khiến lập trình viên tiếp theo (một kẻ giết người bằng rìu biết bạn sống ở đâu ) nguyền rủa tên của bạn và nghĩ đến việc chiếm một sở thích.

2
@NicholasFolk: Tối ưu hóa ở cấp độ này hầu như luôn bị che khuất hoặc được hiển thị bởi kiến ​​trúc CPU. Ai quan tâm nếu bạn lưu 50 chu kỳ khi chỉ lấy các đối số từ bộ nhớ và viết lại chúng mất hơn 100? Tối ưu hóa vi mô như thế này có ý nghĩa khi bộ nhớ chạy ở (hoặc gần) tốc độ của CPU, nhưng ngày nay không quá nhiều.
TMN

2
Bởi vì tôi mệt mỏi khi thấy 10% câu nói đó và bởi vì nó đập vào đầu ở đây: "Không còn nghi ngờ gì nữa, sự hiệu quả dẫn đến lạm dụng. Các lập trình viên lãng phí rất nhiều thời gian để suy nghĩ hoặc lo lắng về, tốc độ của các phần không văn bản trong các chương trình của họ và những nỗ lực về hiệu quả này thực sự có tác động tiêu cực mạnh khi gỡ lỗi và bảo trì. Chúng ta nên quên đi hiệu quả nhỏ, nói về 97% thời gian: tối ưu hóa sớm là gốc rễ của tất cả đều xấu xa. ...
cHao 22/07/14

25

Trình biên dịch nhận ra các hằng số và chuyển đổi bội số thành ca khi thích hợp.


Trình biên dịch nhận ra các hằng số là lũy thừa của 2 .... và chuyển thành các ca. Không phải tất cả các hằng số có thể được thay đổi thành ca.
quick_now

4
@quickly_now: Chúng có thể được chuyển đổi thành sự kết hợp của ca và phép cộng / phép trừ.
Mehrdad

2
Một lỗi tối ưu hóa trình biên dịch cổ điển là chuyển đổi các phân chia thành các ca làm việc đúng, hoạt động cho cổ tức dương nhưng bị tắt 1 cho âm.
ddyer

1
@quickly_now Tôi tin rằng thuật ngữ 'khi thích hợp' bao hàm ý tưởng rằng một số hằng số không thể được viết lại dưới dạng ca.
Pharap

21

Việc dịch chuyển có nhanh hơn nhân không phụ thuộc vào kiến ​​trúc của CPU của bạn. Quay trở lại thời của Pentium và trước đó, sự dịch chuyển thường nhanh hơn nhân, tùy thuộc vào số lượng 1 bit trong bội số của bạn. Ví dụ: nếu bội số của bạn là 320, thì đó là 101000000, hai bit.

a *= 320;               // Slower
a = (a<<7) + (a<<9);    // Faster

Nhưng nếu bạn có nhiều hơn hai bit ...

a *= 324;                        // About same speed
a = (a<<2) + (a<<7) + (a<<9);    // About same speed

a *= 340;                                 // Faster
a = (a<<2) + (a<<4) + (a<<7) + (a<<9);    // Slower

Trên một vi điều khiển nhỏ như PIC18 với bội số chu kỳ đơn, nhưng không có bộ chuyển đổi nòng , phép nhân sẽ nhanh hơn nếu bạn dịch chuyển hơn 1 bit.

a  *= 2;   // Exactly the same speed
a <<= 1;   // Exactly the same speed

a  *= 4;   // Faster
a <<= 2;   // Slower

Lưu ý rằng điều đó trái ngược với những gì đúng với CPU Intel cũ.

Nhưng nó vẫn không đơn giản. Nếu tôi nhớ chính xác, do kiến ​​trúc Superscalar của nó, Pentium có thể xử lý đồng thời một lệnh nhân hoặc hai lệnh dịch chuyển (miễn là chúng không phụ thuộc vào nhau). Điều này có nghĩa là nếu bạn muốn nhân hai biến với lũy thừa bằng 2, thì việc dịch chuyển có thể tốt hơn.

a  *= 4;   // 
b  *= 4;   // 

a <<= 2;   // Both lines execute in a single cycle
b <<= 2;   // 

5
+1 "Việc dịch chuyển có nhanh hơn nhân không phụ thuộc vào kiến ​​trúc của CPU của bạn." Cảm ơn bạn đã thực sự đi vào lịch sử một chút và cho thấy rằng hầu hết các huyền thoại máy tính thực sự có một số cơ sở logic.
Pharap

11

Bạn đã có một số vấn đề với chương trình thử nghiệm của bạn.

Đầu tiên, bạn không thực sự sử dụng giá trị của test. Không có cách nào, trong tiêu chuẩn C, giá trị của testcác vấn đề. Trình tối ưu hóa này hoàn toàn miễn phí để loại bỏ nó. Một khi nó loại bỏ nó, vòng lặp của bạn thực sự trống rỗng. Hiệu ứng có thể nhìn thấy duy nhất sẽ được thiết lập runs = 100000000, nhưng runscũng không được sử dụng. Vì vậy, trình tối ưu hóa có thể (và nên!) Loại bỏ toàn bộ vòng lặp. Dễ dàng sửa chữa: cũng in giá trị tính toán. Lưu ý rằng trình tối ưu hóa được xác định đầy đủ vẫn có thể tối ưu hóa vòng lặp (nó hoàn toàn dựa vào các hằng số đã biết tại thời điểm biên dịch).

Thứ hai, bạn thực hiện hai thao tác triệt tiêu lẫn nhau. Trình tối ưu hóa được phép thông báo điều này và hủy bỏ chúng . Một lần nữa để lại một vòng lặp trống, và loại bỏ. Điều này là hết sức khó để sửa chữa. Bạn có thể chuyển sang một unsigned inthành vi (vì vậy tràn không phải là hành vi không xác định), nhưng điều đó tất nhiên chỉ dẫn đến 0. Và những điều đơn giản (như, nói, test += 1) đủ dễ dàng để trình tối ưu hóa tìm ra, và nó cũng vậy.

Cuối cùng, bạn cho rằng điều đó test *= 2thực sự sẽ được biên dịch thành bội số. Đó là một tối ưu hóa rất đơn giản; nếu bithift nhanh hơn, trình tối ưu hóa sẽ sử dụng nó thay thế. Để giải quyết vấn đề này, bạn phải sử dụng một cái gì đó giống như một dòng lắp ráp dành riêng cho việc triển khai.

Hoặc, tôi cho rằng, chỉ cần kiểm tra bảng dữ liệu vi xử lý của bạn để xem cái nào nhanh hơn.

Khi tôi kiểm tra đầu ra lắp ráp để biên dịch chương trình của bạn bằng gcc -S -O3cách sử dụng phiên bản 4.9, trình tối ưu hóa thực sự đã thấy qua mọi biến thể đơn giản ở trên và một vài biến thể khác. Trong mọi trường hợp, nó đã loại bỏ vòng lặp (gán hằng số cho test), điều duy nhất còn lại là các cuộc gọi đến clock(), chuyển đổi / trừ và printf.


1
Cũng lưu ý rằng trình tối ưu hóa có thể (và sẽ) tối ưu hóa các hoạt động trên các hằng số (ngay cả trong một vòng lặp) như được hiển thị trong sqrt c # vs sqrt c ++ trong đó trình tối ưu hóa có thể thay thế một vòng lặp tổng giá trị bằng tổng thực tế. Để đánh bại sự tối ưu hóa đó, bạn cần sử dụng một cái gì đó được xác định trong thời gian chạy (chẳng hạn như một đối số dòng lệnh).

@MichaelT Yep. Đó là điều tôi muốn nói bởi "Lưu ý rằng trình tối ưu hóa được xác định đầy đủ vẫn có thể tối ưu hóa vòng lặp (nó hoàn toàn dựa vào các hằng số đã biết tại thời điểm biên dịch)."
derobert

Tôi hiểu những gì bạn đang nói, nhưng tôi không nghĩ trình biên dịch đang loại bỏ toàn bộ vòng lặp. Bạn có thể dễ dàng kiểm tra lý thuyết này bằng cách tăng số lần lặp. Bạn sẽ thấy việc tăng số lần lặp lại sẽ khiến chương trình mất nhiều thời gian hơn. Nếu vòng lặp được loại bỏ hoàn toàn thì đây sẽ không phải là trường hợp.
DollarAkshay

@AkshayLAradhya Tôi không thể nói trình biên dịch của bạn đang làm gì, nhưng tôi đã xác nhận lại rằng gcc -O3(bây giờ với 7.3) vẫn xóa hoàn toàn vòng lặp. (Đảm bảo chuyển sang dài thay vì int nếu được yêu cầu, nếu không, nó sẽ tối ưu hóa nó thành một vòng lặp vô hạn do tràn).
derobert

8

Tôi nghĩ sẽ hữu ích hơn cho người hỏi khi có câu trả lời khác biệt hơn, bởi vì tôi thấy một số giả định chưa được giải thích trong các câu hỏi và trong một số câu trả lời hoặc nhận xét.

Thời gian chạy tương đối của dịch chuyển và nhân không liên quan gì đến C. Khi tôi nói C, tôi không có nghĩa là trường hợp thực hiện cụ thể, chẳng hạn như phiên bản GCC đó, hoặc ngôn ngữ. Tôi không có ý lấy quảng cáo vô lý này, nhưng sử dụng một ví dụ cực đoan để minh họa: bạn có thể thực hiện trình biên dịch C hoàn toàn tuân thủ tiêu chuẩn và nhân lên mất một giờ, trong khi việc chuyển đổi mất một phần nghìn giây - hoặc ngược lại. Tôi không biết về bất kỳ hạn chế hiệu suất như vậy trong C hoặc C ++.

Bạn có thể không quan tâm đến tính kỹ thuật này trong tranh luận. Ý định của bạn có lẽ là chỉ kiểm tra hiệu suất tương đối của việc thực hiện thay đổi so với nhân và bạn đã chọn C, vì nó thường được coi là ngôn ngữ lập trình cấp thấp, vì vậy người ta có thể mong đợi mã nguồn của nó sẽ dịch sang các hướng dẫn tương ứng trực tiếp hơn. Những câu hỏi như vậy rất phổ biến và tôi nghĩ rằng một câu trả lời hay nên chỉ ra rằng ngay cả trong C, mã nguồn của bạn không chuyển thành hướng dẫn trực tiếp như bạn nghĩ trong một ví dụ cụ thể. Tôi đã cung cấp cho bạn một số kết quả tổng hợp có thể dưới đây.

Đây là nơi các bình luận đặt câu hỏi về tính hữu ích của việc thay thế sự tương đương này trong phần mềm trong thế giới thực. Bạn có thể thấy một số trong các bình luận cho câu hỏi của bạn, chẳng hạn như từ Eric Lippert. Nó phù hợp với phản ứng mà bạn thường sẽ nhận được từ các kỹ sư dày dạn hơn để đáp ứng với các tối ưu hóa như vậy. Nếu bạn sử dụng dịch chuyển nhị phân trong mã sản xuất như một phương tiện nhân lên và chia, mọi người rất có thể sẽ chùn bước trước mã của bạn và có một số phản ứng cảm xúc ("Tôi đã nghe thấy tuyên bố vô nghĩa này về JavaScript vì lợi ích của trời.") nó có thể không có ý nghĩa với các lập trình viên mới làm quen, trừ khi họ hiểu rõ hơn lý do cho những phản ứng đó.

Những lý do đó chủ yếu là sự kết hợp giữa khả năng đọc giảm và vô ích của việc tối ưu hóa như vậy, vì bạn có thể đã phát hiện ra khi so sánh hiệu suất tương đối của chúng. Tuy nhiên, tôi không nghĩ rằng mọi người sẽ có phản ứng mạnh mẽ như vậy nếu thay thế sự thay đổi cho phép nhân là ví dụ duy nhất của việc tối ưu hóa như vậy. Những câu hỏi như của bạn thường xuyên xuất hiện dưới nhiều hình thức và trong nhiều bối cảnh khác nhau. Tôi nghĩ điều mà nhiều kỹ sư cao cấp thực sự phản ứng mạnh mẽ như vậy, ít nhất là đôi khi tôi có, là có khả năng gây ra phạm vi tác hại rộng lớn hơn nhiều khi mọi người sử dụng tối ưu hóa vi mô một cách tự do trên cơ sở mã. Nếu bạn làm việc tại một công ty như Microsoft trên cơ sở mã lớn, bạn sẽ dành nhiều thời gian để đọc mã nguồn của các kỹ sư khác hoặc cố gắng xác định mã nhất định trong đó. Nó thậm chí có thể là mã của riêng bạn mà bạn sẽ cố gắng hiểu trong vài năm nữa, đặc biệt là vào một số thời điểm không thuận lợi nhất, chẳng hạn như khi bạn phải khắc phục sự cố ngừng sản xuất sau cuộc gọi mà bạn nhận được khi đang nhắn tin làm nhiệm vụ vào tối thứ sáu, chuẩn bị cho một đêm vui vẻ với bạn bè Nếu bạn dành nhiều thời gian cho việc đọc mã, bạn sẽ đánh giá cao nó có thể đọc được càng tốt. Hãy tưởng tượng đọc cuốn tiểu thuyết yêu thích của bạn, nhưng nhà xuất bản đã quyết định phát hành phiên bản mới nơi họ sử dụng abbrv. tất cả ovr th plc bcs thy thnk nó svs spc. Đó là giống như các phản ứng mà các kỹ sư khác có thể có đối với mã của bạn, nếu bạn rắc chúng với các tối ưu hóa như vậy. Như các câu trả lời khác đã chỉ ra, tốt hơn là nói rõ ý của bạn là gì,

Tuy nhiên, ngay cả trong những môi trường đó, bạn có thể thấy mình đang giải quyết một câu hỏi phỏng vấn mà bạn dự kiến ​​sẽ biết điều này hoặc một số tương đương khác. Biết chúng không phải là xấu và một kỹ sư giỏi sẽ nhận thức được hiệu ứng số học của dịch chuyển nhị phân. Lưu ý rằng tôi đã không nói rằng điều này làm cho một kỹ sư giỏi, nhưng theo tôi thì một kỹ sư giỏi sẽ biết. Cụ thể, bạn vẫn có thể tìm thấy một số người quản lý, thường là vào cuối vòng phỏng vấn của bạn, người sẽ cười toe toét với bạn trước sự vui mừng tiết lộ "mẹo" kỹ thuật thông minh này cho bạn trong một câu hỏi mã hóa và chứng minh rằng anh ấy / cô ấy cũng vậy, từng là hoặc là một trong những kỹ sư hiểu biết và không "chỉ" một người quản lý. Trong những tình huống đó, chỉ cần cố gắng để trông ấn tượng và cảm ơn anh ấy / cô ấy cho cuộc phỏng vấn khai sáng.

Tại sao bạn không thấy sự khác biệt về tốc độ trong C? Câu trả lời rất có thể là cả hai đều dẫn đến cùng một mã lắp ráp:

int shift(int i) { return i << 2; }
int multiply(int i) { return i * 2; }

Cả hai có thể biên dịch thành

shift(int):
    lea eax, [0+rdi*4]
    ret

Trên GCC mà không tối ưu hóa, tức là sử dụng cờ "-O0", bạn có thể nhận được điều này:

shift(int):
    push    rbp
    mov rbp, rsp
    mov DWORD PTR [rbp-4], edi
    mov eax, DWORD PTR [rbp-4]
    sal eax, 2
    pop rbp
    ret
multiply(int):
    push    rbp
    mov rbp, rsp
    mov DWORD PTR [rbp-4], edi
    mov eax, DWORD PTR [rbp-4]
    add eax, eax
    pop rbp
    ret

Như bạn có thể thấy, việc chuyển "-O0" cho GCC không có nghĩa là nó sẽ không thông minh về loại mã mà nó tạo ra. Cụ thể, lưu ý rằng ngay cả trong trường hợp này, trình biên dịch đã tránh sử dụng một lệnh nhân. Bạn có thể lặp lại cùng một thí nghiệm với sự dịch chuyển của các số khác và thậm chí nhân với các số không phải là lũy thừa của hai. Rất có thể là trên nền tảng của bạn, bạn sẽ thấy sự kết hợp của ca và bổ sung, nhưng không có phép nhân. Có vẻ như một sự trùng hợp ngẫu nhiên đối với trình biên dịch rõ ràng là tránh sử dụng phép nhân trong tất cả các trường hợp đó nếu phép nhân và dịch chuyển thực sự có cùng chi phí, phải không? Nhưng tôi không có ý cung cấp giả thuyết, vì vậy chúng ta hãy tiếp tục.

Bạn có thể chạy lại bài kiểm tra của mình với đoạn mã trên và xem bây giờ bạn có nhận thấy sự khác biệt về tốc độ không. Mặc dù sau đó, bạn không kiểm tra sự thay đổi so với nhân, như bạn có thể thấy khi không có phép nhân, nhưng mã được tạo bởi một bộ cờ nhất định của GCC cho các hoạt động C của ca và nhân trong một trường hợp cụ thể . Vì vậy, trong một thử nghiệm khác, bạn có thể chỉnh sửa mã lắp ráp bằng tay và thay vào đó sử dụng hướng dẫn "imul" trong mã cho phương thức "nhân".

Nếu bạn muốn đánh bại một số thông minh của trình biên dịch, bạn có thể định nghĩa một phương pháp nhân và thay đổi tổng quát hơn và sẽ kết thúc bằng một cái gì đó như thế này:

int shift(int i, int j) { return i << j; }
int multiply(int i, int j) { return i * j; }

Mà có thể mang lại mã lắp ráp sau đây:

shift(int, int):
    mov eax, edi
    mov ecx, esi
    sal eax, cl
    ret
multiply(int, int):
    mov eax, edi
    imul    eax, esi
    ret

Cuối cùng chúng ta cũng có, ngay cả ở mức tối ưu hóa cao nhất của GCC 4.9, biểu thức trong hướng dẫn lắp ráp mà bạn có thể mong đợi khi ban đầu đặt ra trong bài kiểm tra của mình. Tôi nghĩ rằng bản thân nó có thể là một bài học quan trọng trong tối ưu hóa hiệu suất. Chúng ta có thể thấy sự khác biệt mà nó tạo ra để thay thế các biến cho các hằng số cụ thể trong mã của chúng ta, về mặt thông minh mà trình biên dịch có thể áp dụng. Tối ưu hóa vi mô như thay thế nhân bội là một số tối ưu hóa ở mức rất thấp mà trình biên dịch thường có thể dễ dàng thực hiện. Các tối ưu hóa khác có ảnh hưởng lớn hơn đến hiệu suất đòi hỏi sự hiểu biết về ý định của mãmà trình biên dịch thường không thể truy cập được hoặc chỉ có thể đoán được bởi một số heuristic. Đó là nơi bạn với tư cách là một kỹ sư phần mềm đến và nó chắc chắn không liên quan đến việc nhân thay thế bằng ca. Nó liên quan đến các yếu tố như tránh một cuộc gọi dự phòng đến một dịch vụ tạo ra I / O và có thể chặn một quy trình. Nếu bạn truy cập vào đĩa cứng của bạn hoặc, thần cấm, đến một cơ sở dữ liệu từ xa để lấy một số dữ liệu bổ sung mà bạn có thể có được từ những gì bạn đã có trong bộ nhớ, thời gian bạn chờ đợi vượt xa việc thực hiện hàng triệu hướng dẫn. Bây giờ, tôi nghĩ rằng chúng tôi đã đi lạc một chút so với câu hỏi ban đầu của bạn, nhưng tôi nghĩ rằng việc chỉ ra điều này cho một người hỏi, đặc biệt nếu chúng tôi cho rằng ai đó mới bắt đầu nắm bắt được bản dịch và thực thi mã,

Vì vậy, cái nào sẽ nhanh hơn? Tôi nghĩ rằng đó là một cách tiếp cận tốt mà bạn đã chọn để thực sự kiểm tra sự khác biệt hiệu suất. Nhìn chung, rất dễ bị bất ngờ bởi hiệu năng thời gian chạy của một số thay đổi mã. Có nhiều kỹ thuật bộ xử lý hiện đại sử dụng và sự tương tác giữa các phần mềm cũng có thể phức tạp. Ngay cả khi bạn sẽ nhận được kết quả hiệu suất có lợi cho một thay đổi nhất định trong một tình huống, tôi nghĩ thật nguy hiểm khi kết luận rằng loại thay đổi này sẽ luôn mang lại lợi ích hiệu suất. Tôi nghĩ thật nguy hiểm khi chạy thử nghiệm như vậy một lần, nói "Được rồi, giờ tôi biết cái nào nhanh hơn!" và sau đó áp dụng bừa bãi cùng một tối ưu hóa đó vào mã sản xuất mà không lặp lại các phép đo của bạn.

Vậy điều gì sẽ xảy ra nếu sự dịch chuyển nhanh hơn phép nhân? Chắc chắn có dấu hiệu tại sao điều đó là đúng. GCC, như bạn có thể thấy ở trên, dường như nghĩ (ngay cả khi không tối ưu hóa) rằng tránh nhân trực tiếp theo hướng dẫn khác là một ý tưởng tốt. Các Intel 64 và IA-32 Kiến trúc Optimization Reference Manual sẽ cung cấp cho bạn một ý tưởng về chi phí tương đối của hướng dẫn CPU. Một tài nguyên khác, tập trung nhiều hơn vào độ trễ và thông lượng hướng dẫn, là http://www.agner.org/optizes/in cản_tables.pdf. Lưu ý rằng chúng không phải là một công cụ dự báo tốt về thời gian chạy tuyệt đối, mà là hiệu suất của các hướng dẫn liên quan đến nhau. Trong một vòng lặp chặt chẽ, vì thử nghiệm của bạn đang mô phỏng, nên số liệu "thông lượng" phải phù hợp nhất. Đó là số chu kỳ mà một đơn vị thực thi thường sẽ bị ràng buộc khi thực hiện một lệnh đã cho.

Vậy điều gì sẽ xảy ra nếu sự dịch chuyển KHÔNG nhanh hơn phép nhân? Như tôi đã nói ở trên, các kiến ​​trúc hiện đại có thể khá phức tạp và những thứ như dự đoán nhánh, bộ đệm, đường ống và các đơn vị thực thi song song có thể khiến bạn khó dự đoán hiệu suất tương đối của hai đoạn mã tương đương logic. Tôi thực sự muốn nhấn mạnh điều này, bởi vì đây là nơi tôi không hài lòng với hầu hết các câu trả lời cho những câu hỏi như thế này và với trại người hoàn toàn nói rằng điều đó đơn giản là không đúng (nữa) rằng việc dịch chuyển nhanh hơn nhân.

Không, theo như tôi biết, chúng tôi đã không phát minh ra một loại nước sốt kỹ thuật bí mật nào đó vào những năm 1970 hoặc bất cứ khi nào đột nhiên hủy bỏ sự khác biệt về chi phí của một đơn vị nhân và một chút dịch chuyển. Một phép nhân tổng quát, về mặt cổng logic và chắc chắn về mặt hoạt động logic, vẫn phức tạp hơn so với sự thay đổi với bộ chuyển động nòng súng trong nhiều tình huống, trên nhiều kiến ​​trúc. Làm thế nào điều này chuyển thành thời gian chạy tổng thể trên máy tính để bàn có thể hơi mờ. Tôi không biết chắc chắn chúng được triển khai như thế nào trong các bộ xử lý cụ thể, nhưng đây là lời giải thích về phép nhân: Phép nhân số nguyên có thực sự giống với tốc độ như trên CPU hiện đại không

Trong khi đây là một lời giải thích của một Shifter thùng . Các tài liệu tôi đã tham chiếu trong đoạn trước đưa ra một cái nhìn khác về chi phí hoạt động tương đối, theo ủy quyền của hướng dẫn CPU. Các kỹ sư tại Intel dường như thường xuyên nhận được câu hỏi tương tự: diễn đàn trong khu vực dành cho nhà phát triển intel cho phép nhân số nguyên và bổ sung trong bộ xử lý bộ đôi lõi 2

Vâng, trong hầu hết các kịch bản thực tế và gần như chắc chắn trong JavaScript, cố gắng khai thác tính tương đương này để thực hiện có lẽ là một công việc vô ích. Tuy nhiên, ngay cả khi chúng tôi buộc phải sử dụng các hướng dẫn nhân và sau đó không thấy sự khác biệt về thời gian chạy, điều đó nhiều hơn do bản chất của số liệu chi phí chúng tôi đã sử dụng, chính xác, và không phải vì không có chênh lệch chi phí. Thời gian chạy đầu cuối là một số liệu và nếu đó là chỉ số duy nhất chúng tôi quan tâm, tất cả đều ổn. Nhưng điều đó không có nghĩa là tất cả sự khác biệt về chi phí giữa nhân và dịch chuyển đã đơn giản biến mất. Và tôi nghĩ rằng chắc chắn không phải là một ý tưởng tốt để truyền đạt ý tưởng đó cho người hỏi, bằng ngụ ý hay nói cách khác, người rõ ràng chỉ mới bắt đầu có ý tưởng về các yếu tố liên quan đến thời gian và chi phí của mã hiện đại. Kỹ thuật luôn luôn là về sự đánh đổi. Điều tra và giải thích về những gì các bộ xử lý hiện đại đã đánh đổi để thể hiện thời gian thực hiện mà chúng ta khi người dùng cuối cùng nhìn thấy có thể mang lại một câu trả lời khác biệt hơn. Và tôi nghĩ rằng một câu trả lời khác biệt hơn là "điều này không còn đúng nữa" được bảo đảm nếu chúng ta muốn thấy ít kỹ sư kiểm tra mã tối ưu hóa vi mô dễ đọc hơn, bởi vì nó hiểu một cách tổng quát hơn về bản chất của "tối ưu hóa" như vậy phát hiện ra những hiện thân đa dạng, đa dạng của nó hơn là chỉ đề cập đến một số trường hợp cụ thể là lỗi thời.


6

Những gì bạn thấy là hiệu quả của trình tối ưu hóa.

Công việc tối ưu hóa là làm cho mã được biên dịch kết quả nhỏ hơn hoặc nhanh hơn (nhưng hiếm khi cả hai cùng một lúc ... nhưng giống như nhiều thứ ... TIỀN GỬI về mã đó là gì).

Trong PRINCIPLE, bất kỳ cuộc gọi nào đến thư viện nhân, hoặc, thường xuyên, thậm chí việc sử dụng hệ số nhân phần cứng sẽ chậm hơn so với chỉ thực hiện dịch chuyển theo bit.

Vì vậy, ... nếu trình biên dịch ngây thơ tạo ra một cuộc gọi đến thư viện cho hoạt động * 2, thì tất nhiên nó sẽ chạy chậm hơn so với dịch chuyển bitwise *.

Tuy nhiên, các trình tối ưu hóa có mặt để phát hiện các mẫu và tìm ra cách làm cho mã nhỏ hơn / nhanh hơn / bất cứ thứ gì. Và những gì bạn đã thấy là trình biên dịch phát hiện ra rằng * 2 giống như một ca làm việc.

Cũng giống như một vấn đề đáng quan tâm, hôm nay tôi chỉ nhìn vào trình biên dịch được tạo cho một số hoạt động như * 5 ... không thực sự nhìn vào điều đó mà là những thứ khác, và trên đường đi, tôi nhận thấy rằng trình biên dịch đã biến * 5 thành:

  • ca
  • ca
  • thêm số gốc

Vì vậy, trình tối ưu hóa trình biên dịch của tôi đã đủ thông minh (ít nhất là đối với các hằng số nhỏ nhất định) để tạo các dịch chuyển nội tuyến và thêm thay vì các cuộc gọi đến thư viện nhân đa mục đích chung.

Nghệ thuật tối ưu hóa trình biên dịch là một chủ đề hoàn toàn riêng biệt, chứa đầy ma thuật và thực sự được hiểu đúng bởi khoảng 6 người trên toàn hành tinh :)


3

Hãy thử tính thời gian với:

for (runs = 0; runs < 100000000; runs++) {
      ;
}

Trình biên dịch nên nhận ra rằng giá trị của testkhông thay đổi sau mỗi lần lặp của vòng lặp và giá trị cuối cùng testkhông được sử dụng và loại bỏ hoàn toàn vòng lặp.


2

Phép nhân là sự kết hợp của ca và bổ sung.

Trong trường hợp bạn đã đề cập, tôi không tin rằng trình biên dịch có tối ưu hóa nó hay không - "nhân xhai" có thể được thực hiện như sau:

  • Thay đổi bit của xmột nơi bên trái.
  • Thêm xvào x.

Đây là mỗi hoạt động nguyên tử cơ bản; cái này không nhanh hơn cái kia

Thay đổi nó thành "nhân xvới bốn", (hoặc bất kỳ 2^k, k>1) và nó hơi khác một chút:

  • Thay đổi bit của xhai nơi bên trái.
  • Thêm xvào xvà gọi nó y, thêm yvào y.

Trên một kiến ​​trúc cơ bản, thật đơn giản để thấy rằng sự thay đổi hiệu quả hơn - thực hiện một so với hai thao tác, vì chúng ta không thể thêm yvào ycho đến khi chúng ta biết cái gì ylà.

Hãy thử cái sau (hoặc bất kỳ 2^k, k>1), với các tùy chọn phù hợp để ngăn bạn tối ưu hóa chúng thành điều tương tự khi thực hiện. Bạn nên tìm sự thay đổi nhanh hơn, lấy O(1)so với bổ sung lặp đi lặp lại trong O(k).

Rõ ràng, trong đó bội số không phải là lũy thừa của hai, sự kết hợp giữa các ca và phép cộng (một trong đó số lượng của mỗi số khác không) là cần thiết.


1
"Hoạt động nguyên tử cơ bản" là gì? Không thể tranh luận rằng trong một ca làm việc, hoạt động có thể được áp dụng cho mọi bit song song, trong khi ngoài ra, các bit ngoài cùng bên trái phụ thuộc vào các bit khác?
Bergi

2
@Bergi: Tôi đoán anh ấy có nghĩa là cả ca và add đều là các lệnh máy đơn. Bạn sẽ phải xem tài liệu của tập lệnh để xem tổng số chu kỳ cho mỗi lần, nhưng vâng, một add thường là một hoạt động đa chu kỳ trong khi một ca thường được thực hiện trong một chu kỳ.
TMN

Vâng, đó có thể là trường hợp, nhưng phép nhân cũng là một hướng dẫn máy duy nhất (mặc dù tất nhiên nó có thể cần nhiều chu kỳ hơn)
Bergi

@Bergi, đó cũng là phụ thuộc vòm. Bạn nghĩ gì về sự dịch chuyển đó trong ít chu kỳ hơn so với phép cộng 32 bit (hoặc x-bit nếu có)?
OJFord

Tôi không biết bất kỳ kiến ​​trúc cụ thể nào, không (và các khóa học kỹ thuật máy tính của tôi đã mờ dần), có lẽ cả hai hướng dẫn đều mất ít hơn một chu kỳ. Tôi có lẽ đã suy nghĩ về các vi mã hoặc thậm chí các cổng logic, trong đó một sự thay đổi có thể sẽ rẻ hơn.
Bergi

1

Nhân các giá trị được ký hoặc không dấu bằng lũy ​​thừa của hai giá trị tương đương với dịch chuyển trái và hầu hết các trình biên dịch sẽ thực hiện thay thế. Phân chia các giá trị không dấu hoặc các giá trị đã ký mà trình biên dịch có thể chứng minh là không bao giờ âm , tương đương với dịch chuyển phải và hầu hết các trình biên dịch sẽ thực hiện thay thế đó (mặc dù một số không đủ tinh vi để chứng minh khi các giá trị đã ký không thể âm) .

Tuy nhiên, cần lưu ý rằng việc phân chia các giá trị được ký có khả năng âm không tương đương với dịch chuyển phải. Một biểu thức như (x+8)>>4không tương đương với (x+8)/16. Cái trước, trong 99% trình biên dịch, sẽ ánh xạ các giá trị từ -24 đến -9 đến -1, -8 đến +7 thành 0 và +8 đến +23 đến 1 [làm tròn số gần như đối xứng về 0]. Cái sau sẽ ánh xạ -39 đến -24 đến -1, -23 đến +7 thành 0 và +8 đến +23 thành +1 [không đối xứng hoàn toàn, và có khả năng không như dự định]. Lưu ý rằng ngay cả khi các giá trị không được dự kiến ​​là âm, việc sử dụng >>4sẽ có khả năng mang lại mã nhanh hơn /16trừ khi trình biên dịch có thể chứng minh các giá trị không thể âm.


0

Một số thông tin tôi vừa kiểm tra.

Trên x86_64, opcode MUL có độ trễ 10 chu kỳ và thông lượng 1/2 chu kỳ. MOV, ADD và SHL có độ trễ là 1 chu kỳ, với thông lượng chu kỳ 2.5, 2.5 và 1.7.

Phép nhân với 15 sẽ yêu cầu tối thiểu 3 SHL và 3 ADD op và có thể là một vài MOV.

https://gmplib.org/~tege/x86-timing.pdf


0

Phương pháp của bạn là thiếu sót. Việc tăng vòng lặp và kiểm tra điều kiện của chính bạn đang mất nhiều thời gian.

  • Hãy thử chạy một vòng lặp trống và đo thời gian (gọi nó base).
  • Bây giờ thêm 1 ca thao tác và đo thời gian (gọi nó s1).
  • Tiếp theo thêm 10 thao tác thay đổi và đo thời gian (gọi nó s2)

Nếu mọi thứ đang diễn ra chính xác base-s2nên nhiều hơn 10 lần base-s1. Nếu không thì một cái gì đó khác đang đến chơi ở đây.

Bây giờ tôi thực sự đã thử điều này và nhận ra, Nếu các vòng lặp đang gây ra vấn đề tại sao không loại bỏ chúng hoàn toàn. Vì vậy, tôi đã đi trước và làm điều này:

int main(){

    int test = 2;
    clock_t launch = clock();

    test << 6;
    test << 6;
    test << 6;
    test << 6;
    //.... 1 million times
    test << 6;

    clock_t done = clock();
    printf("Time taken : %d\n", done - launch);
    return 0;
}

Và ở đó bạn có kết quả của bạn

1 triệu ca hoạt động dưới 1 triệu giây? .

Tôi đã làm điều tương tự để nhân với 64 và nhận được kết quả tương tự. Vì vậy, có lẽ trình biên dịch đang bỏ qua hoạt động hoàn toàn vì những người khác đề cập đến giá trị của kiểm tra không bao giờ thay đổi.

Kết quả điều hành thay đổi

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.