Tốc độ của << >> nhân và chia


9

Bạn có thể sử dụng <<để nhân và >>chia số trong python khi tôi tìm thấy chúng bằng cách sử dụng cách dịch chuyển nhị phân nhanh hơn gấp 10 lần so với chia hoặc nhân theo cách thông thường.

Tại sao đang sử dụng <<>>nhanh hơn rất nhiều so với */?

Đằng sau quá trình cảnh diễn ra là gì */quá chậm?


2
Dịch chuyển bit nhanh hơn trong tất cả các ngôn ngữ, không chỉ Python. Nhiều bộ xử lý có một lệnh dịch chuyển bit riêng sẽ hoàn thành nó trong một hoặc hai chu kỳ xung nhịp.
Robert Harvey

4
Tuy nhiên, cần lưu ý rằng việc bẻ khóa, thay vì sử dụng các toán tử chia và nhân thông thường, nói chung là thực tiễn tồi và có thể cản trở khả năng đọc.
Azar

6
@crizly Bởi vì tốt nhất nó là một tối ưu hóa vi mô và rất có khả năng trình biên dịch sẽ thay đổi nó thành một sự thay đổi trong mã byte bằng mọi cách (nếu có thể). Có những trường hợp ngoại lệ, chẳng hạn như khi mã cực kỳ hiệu năng, nhưng hầu hết thời gian bạn đang làm là làm xáo trộn mã của bạn.
Azar

7
@Crizly: Bất kỳ trình biên dịch nào có trình tối ưu hóa tốt sẽ nhận ra các phép nhân và phép chia có thể được thực hiện với dịch chuyển bit và tạo mã sử dụng chúng. Đừng làm xấu mã của bạn khi cố gắng vượt qua trình biên dịch.
Blrfl

2
Trong câu hỏi này trên StackOverflow, một microbenchmark đã tìm thấy hiệu năng tốt hơn một chút trong Python 3 để nhân với 2 so với một ca trái tương đương, cho các số đủ nhỏ. Tôi nghĩ rằng tôi đã tìm ra lý do cho các phép nhân nhỏ (hiện tại) được tối ưu hóa khác với thay đổi bit. Chỉ cần đi để cho thấy bạn không thể chấp nhận những gì sẽ chạy nhanh hơn dựa trên lý thuyết.
Dan Getz

Câu trả lời:


15

Hãy xem xét hai chương trình C nhỏ làm thay đổi một chút và phân chia.

#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int b = i << 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int d = i / 4;
}

Sau đó, mỗi cái được biên dịch gcc -Sđể xem lắp ráp thực tế sẽ là gì.

Với phiên bản dịch chuyển bit, từ lệnh gọi atoitrở về:

    callq   _atoi
    movl    $0, %ecx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    shll    $2, %eax
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

Trong khi phiên bản phân chia:

    callq   _atoi
    movl    $0, %ecx
    movl    $4, %edx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    movl    %edx, -28(%rbp)         ## 4-byte Spill
    cltd
    movl    -28(%rbp), %r8d         ## 4-byte Reload
    idivl   %r8d
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

Chỉ cần nhìn vào điều này, có một vài hướng dẫn trong phiên bản phân chia so với dịch chuyển bit.

Chìa khóa là họ làm gì?

Trong phiên bản dịch chuyển bit, lệnh chính là shll $2, %eaxsự dịch chuyển trái logic - có sự phân chia và mọi thứ khác chỉ là các giá trị di chuyển xung quanh.

Trong phiên bản phân chia, bạn có thể thấy idivl %r8d- nhưng ngay phía trên đó là một cltd(chuyển đổi dài thành gấp đôi) và một số logic bổ sung xung quanh sự cố tràn và tải lại. Công việc bổ sung này, biết rằng chúng ta đang xử lý một toán học chứ không phải bit thường là cần thiết để tránh các lỗi khác nhau có thể xảy ra bằng cách chỉ thực hiện toán học bit.

Hãy thực hiện một số phép nhân nhanh chóng:

#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int b = i >> 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int d = i * 4;
}

Thay vì đi qua tất cả những điều này, có một dòng khác nhau:

$ diff mult.s bit.s
24c24
> sẽ $ 2,% eax
---
<sarl $ 2,% eax

Ở đây trình biên dịch đã có thể xác định rằng toán học có thể được thực hiện với một ca, tuy nhiên thay vì một sự thay đổi logic, nó thực hiện một sự thay đổi số học. Sự khác biệt giữa những điều này sẽ là rõ ràng nếu chúng ta chạy chúng - sarlgiữ nguyên dấu hiệu. Vì vậy, -2 * 4 = -8trong khi shllkhông.

Hãy xem xét điều này trong một kịch bản perl nhanh chóng:

#!/usr/bin/perl

$foo = 4;
print $foo << 2, "\n";
print $foo * 4, "\n";

$foo = -4;
print $foo << 2, "\n";
print $foo * 4, "\n";

Đầu ra:

16
16
18446744073709551600
-16

Um ... -4 << 218446744073709551600mà không phải là chính xác những gì bạn đang có khả năng mong đợi khi giao dịch với nhân và chia. Nó đúng, nhưng nó không nhân số nguyên.

Và do đó hãy cảnh giác với tối ưu hóa sớm. Hãy để trình biên dịch tối ưu hóa cho bạn - nó biết bạn thực sự đang cố gắng làm gì và có khả năng sẽ làm tốt hơn với nó, với ít lỗi hơn.


12
Có thể rõ ràng hơn khi kết << 2hợp với * 4>> 2với / 4để giữ các hướng dịch chuyển giống nhau trong mỗi ví dụ.
Greg Hewgill

5

Các câu trả lời hiện tại không thực sự giải quyết được khía cạnh phần cứng của mọi thứ, vì vậy đây là một chút về góc độ đó. Sự khôn ngoan thông thường là nhân và chia chậm hơn nhiều so với dịch chuyển, nhưng câu chuyện thực tế ngày nay mang nhiều sắc thái hơn.

Ví dụ, chắc chắn đúng là phép nhân là một hoạt động phức tạp hơn để thực hiện trong phần cứng, nhưng nó không nhất thiết luôn luôn kết thúc chậm hơn . Hóa ra, addviệc triển khai cũng phức tạp hơn đáng kể so với xor(hoặc nói chung là bất kỳ hoạt động bitwise nào), nhưng add(và sub) thường có đủ các bóng bán dẫn dành riêng cho hoạt động của chúng, kết thúc nhanh như các toán tử bitwise. Vì vậy, bạn không thể chỉ xem sự phức tạp trong triển khai phần cứng như một hướng dẫn về tốc độ.

Vì vậy, hãy xem xét chi tiết về sự dịch chuyển so với các toán tử "đầy đủ" như phép nhân và dịch chuyển.

Dịch chuyển

Trên gần như tất cả phần cứng, việc dịch chuyển theo một lượng không đổi (nghĩa là một lượng mà trình biên dịch có thể xác định tại thời gian biên dịch) là nhanh . Cụ thể, nó thường sẽ xảy ra với độ trễ của một chu kỳ và với thông lượng là 1 trên mỗi chu kỳ hoặc tốt hơn. Trên một số phần cứng (ví dụ: một số chip Intel và ARM), một số thay đổi nhất định thậm chí có thể là "miễn phí" vì chúng có thể được tích hợp vào một hướng dẫn khác ( leatrên Intel, khả năng dịch chuyển đặc biệt của nguồn đầu tiên trong ARM).

Thay đổi bởi một số lượng thay đổi là nhiều hơn một khu vực màu xám. Trên phần cứng cũ, điều này đôi khi rất chậm và tốc độ thay đổi từ thế hệ này sang thế hệ khác. Ví dụ, trên bản phát hành ban đầu của P4 của Intel, việc dịch chuyển theo số lượng thay đổi rất chậm - đòi hỏi thời gian tỷ lệ thuận với lượng dịch chuyển! Trên nền tảng đó, sử dụng phép nhân để thay thế ca có thể mang lại lợi nhuận (nghĩa là thế giới đã đảo lộn). Trên các chip Intel trước, cũng như các thế hệ tiếp theo, việc thay đổi một lượng thay đổi không quá đau đớn.

Trên các chip Intel hiện tại, việc dịch chuyển với số lượng thay đổi không đặc biệt nhanh, nhưng điều đó cũng không tệ. Kiến trúc x86 bị ​​cản trở khi có sự thay đổi, bởi vì họ đã xác định thao tác theo một cách khác thường: thay đổi số lượng 0 không sửa đổi các cờ điều kiện, nhưng tất cả các ca khác đều làm. Điều này ngăn cản việc đổi tên hiệu quả của thanh ghi cờ vì không thể xác định được cho đến khi ca thực hiện liệu các hướng dẫn tiếp theo có nên đọc mã điều kiện được viết bởi ca hay một số lệnh trước đó. Hơn nữa, các ca chỉ ghi vào một phần của thanh ghi cờ, điều này có thể gây ra tình trạng treo cờ một phần.

Kết quả cuối cùng là trên các kiến ​​trúc Intel gần đây, việc thay đổi một lượng biến mất ba "thao tác vi mô" trong khi hầu hết các thao tác đơn giản khác (thêm, opwise bit, thậm chí nhân) chỉ mất 1. Các ca như vậy có thể thực hiện tối đa cứ sau 2 chu kỳ .

Phép nhân

Xu hướng trong phần cứng máy tính để bànmáy tính xách tay hiện đại là làm cho phép nhân nhanh chóng. Trên các chip Intel và AMD gần đây, trên thực tế, một phép nhân có thể được ban hành sau mỗi chu kỳ (chúng tôi gọi đây là thông lượng đối ứng ). Các độ trễ , tuy nhiên, trong một phép nhân là 3 chu kỳ. Vì vậy, điều đó có nghĩa là bạn nhận được kết quả của bất kỳ chu kỳ nhân 3 đã cho nào sau khi bạn bắt đầu nó, nhưng bạn có thể bắt đầu một phép nhân mới mỗi chu kỳ. Giá trị nào (1 chu kỳ hoặc 3 chu kỳ) quan trọng hơn phụ thuộc vào cấu trúc thuật toán của bạn. Nếu phép nhân là một phần của chuỗi phụ thuộc quan trọng, độ trễ là quan trọng. Nếu không, thông lượng đối ứng hoặc các yếu tố khác có thể quan trọng hơn.

Điểm mấu chốt của họ là trên các chip máy tính xách tay hiện đại (hoặc tốt hơn), phép nhân là một thao tác nhanh và có khả năng nhanh hơn chuỗi lệnh 3 hoặc 4 mà trình biên dịch sẽ đưa ra để "làm tròn" ngay để giảm sức mạnh. Đối với các thay đổi, trên Intel, phép nhân cũng thường được ưu tiên do các vấn đề nêu trên.

Trên các nền tảng yếu tố hình thức nhỏ hơn, phép nhân vẫn có thể chậm hơn, vì việc xây dựng hệ số nhân 32 bit đầy đủ và nhanh chóng hoặc đặc biệt là 64 bit cần rất nhiều bóng bán dẫn và năng lượng. Nếu ai đó có thể điền thông tin chi tiết về hiệu suất nhân lên trên các chip di động gần đây thì điều đó sẽ được đánh giá cao.

Chia

Phân chia là cả một hoạt động phức tạp hơn, khôn ngoan về phần cứng, hơn là nhân và cũng ít phổ biến hơn nhiều trong mã thực tế - có nghĩa là ít tài nguyên hơn có khả năng được phân bổ cho nó. Xu hướng của các chip hiện đại vẫn là hướng tới các bộ chia nhanh hơn, nhưng ngay cả các chip hàng đầu hiện đại cũng phải mất 10 - 40 chu kỳ để thực hiện phân chia và chúng chỉ là một phần của đường ống. Nhìn chung, các phân chia 64 bit thậm chí còn chậm hơn các phân chia 32 bit. Không giống như hầu hết các hoạt động khác, phép chia có thể mất một số chu kỳ khác nhau tùy thuộc vào các đối số.

Tránh chia và thay thế bằng ca (hoặc để trình biên dịch làm điều đó, nhưng bạn có thể cần kiểm tra lắp ráp) nếu bạn có thể!


2

BINARY_LSHIFT và BINARY_RSHIFT là các quy trình đơn giản hơn về mặt thuật toán so với BINARY_MULTIPLY và BINARY_FLOOR_DIVIDE và có thể mất ít chu kỳ xung nhịp hơn. Đó là nếu bạn có bất kỳ số nhị phân nào và cần bithift bằng N, tất cả những gì bạn phải làm là dịch chuyển các chữ số trên nhiều khoảng trắng đó và thay thế bằng số không. Phép nhân nhị phân nói chung phức tạp hơn , mặc dù các kỹ thuật như số nhân Dadda làm cho nó khá nhanh.

Cấp, một trình biên dịch tối ưu hóa có thể nhận ra các trường hợp khi bạn nhân / chia cho lũy thừa của hai và thay thế bằng sự dịch chuyển trái / phải thích hợp. Bằng cách nhìn vào python mã byte đã phân tách rõ ràng không làm điều này:

>>> dis.dis(lambda x: x*4)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (4)
              6 BINARY_MULTIPLY     
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x<<2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_LSHIFT       
              7 RETURN_VALUE        


>>> dis.dis(lambda x: x//2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_FLOOR_DIVIDE 
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x>>1)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (1)
              6 BINARY_RSHIFT       
              7 RETURN_VALUE        

Tuy nhiên, trên bộ xử lý của tôi, tôi thấy phép nhân và dịch chuyển trái / phải có thời gian tương tự và phân chia tầng (theo công suất hai) chậm hơn khoảng 25%:

>>> import timeit

>>> timeit.repeat("z=a + 4", setup="a = 37")
[0.03717184066772461, 0.03291916847229004, 0.03287005424499512]

>>> timeit.repeat("z=a - 4", setup="a = 37")
[0.03534698486328125, 0.03207516670227051, 0.03196907043457031]

>>> timeit.repeat("z=a * 4", setup="a = 37")
[0.04594111442565918, 0.0408930778503418, 0.045324087142944336]

>>> timeit.repeat("z=a // 4", setup="a = 37")
[0.05412912368774414, 0.05091404914855957, 0.04910898208618164]

>>> timeit.repeat("z=a << 2", setup="a = 37")
[0.04751706123352051, 0.04259490966796875, 0.041903018951416016]

>>> timeit.repeat("z=a >> 2", setup="a = 37")
[0.04719185829162598, 0.04201006889343262, 0.042105913162231445]
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.