Tại sao (a * b! = 0) nhanh hơn (a! = 0 && b! = 0) trong Java?


412

Tôi đang viết một số mã bằng Java, tại một số điểm, dòng chảy của chương trình được xác định bởi hai biến int, "a" và "b", có khác không (lưu ý: a và b không bao giờ âm và không bao giờ trong phạm vi tràn số nguyên).

Tôi có thể đánh giá nó với

if (a != 0 && b != 0) { /* Some code */ }

Hay cách khác

if (a*b != 0) { /* Some code */ }

Bởi vì tôi hy vọng đoạn mã đó sẽ chạy hàng triệu lần mỗi lần chạy, tôi đã tự hỏi cái nào sẽ nhanh hơn. Tôi đã thực hiện thử nghiệm bằng cách so sánh chúng trên một mảng được tạo ngẫu nhiên rất lớn và tôi cũng tò mò muốn xem mức độ thưa thớt của mảng (phần dữ liệu = 0) sẽ ảnh hưởng đến kết quả như thế nào:

long time;
final int len = 50000000;
int arbitrary = 0;
int[][] nums = new int[2][len];

for (double fraction = 0 ; fraction <= 0.9 ; fraction += 0.0078125) {
    for(int i = 0 ; i < 2 ; i++) {
        for(int j = 0 ; j < len ; j++) {
            double random = Math.random();

            if(random < fraction) nums[i][j] = 0;
            else nums[i][j] = (int) (random*15 + 1);
        }
    }

    time = System.currentTimeMillis();

    for(int i = 0 ; i < len ; i++) {
        if( /*insert nums[0][i]*nums[1][i]!=0 or nums[0][i]!=0 && nums[1][i]!=0*/ ) arbitrary++;
    }
    System.out.println(System.currentTimeMillis() - time);
}

Và kết quả cho thấy rằng nếu bạn mong đợi "a" hoặc "b" bằng 0 hơn ~ 3% thời gian, a*b != 0thì nhanh hơn a!=0 && b!=0:

Đồ thị đồ thị kết quả của một AND b khác không

Tôi tò mò muốn biết tại sao. Bất cứ ai có thể làm sáng tỏ? Nó là trình biên dịch hay nó ở cấp độ phần cứng?

Chỉnh sửa: Vì tò mò ... bây giờ tôi đã biết về dự đoán chi nhánh, tôi đã tự hỏi những gì so sánh tương tự sẽ hiển thị cho OR b là khác không:

Đồ thị của a hoặc b khác không

Chúng ta thấy hiệu ứng tương tự của dự đoán nhánh như mong đợi, điều thú vị là đồ thị có phần bị lật dọc theo trục X.

Cập nhật

1- Tôi đã thêm vào !(a==0 || b==0)phân tích để xem những gì xảy ra.

2- Tôi cũng bao gồm a != 0 || b != 0, (a+b) != 0(a|b) != 0vì tò mò, sau khi tìm hiểu về dự đoán chi nhánh. Nhưng chúng không tương đương về mặt logic với các biểu thức khác, vì chỉ một OR b cần phải khác không để trả về giá trị true, vì vậy chúng không có nghĩa là được so sánh về hiệu quả xử lý.

3- Tôi cũng đã thêm điểm chuẩn thực tế mà tôi đã sử dụng để phân tích, đó chỉ là lặp lại một biến int tùy ý.

4- Một số người đã đề nghị đưa a != 0 & b != 0vào trái ngược a != 0 && b != 0với dự đoán rằng nó sẽ hành xử chặt chẽ hơn a*b != 0vì chúng tôi sẽ loại bỏ hiệu ứng dự đoán chi nhánh. Tôi không biết rằng nó &có thể được sử dụng với các biến boolean, tôi nghĩ rằng nó chỉ được sử dụng cho các hoạt động nhị phân với số nguyên.

Lưu ý: Trong bối cảnh mà tôi đã xem xét tất cả những điều này, int overflow không phải là một vấn đề, nhưng đó chắc chắn là một sự cân nhắc quan trọng trong bối cảnh chung.

CPU: Intel Core i7-3610QM @ 2.3GHz

Phiên bản Java: 1.8.0_45
Môi trường thời gian chạy Java (TM) SE (bản dựng 1.8.0_45-b14)
Máy chủ 64-bit Java HotSpot (TM) (bản dựng 25,45-b02, chế độ hỗn hợp)


11
Thế còn if (!(a == 0 || b == 0))? Microbenchmark nổi tiếng là không đáng tin cậy, điều này dường như không thể đo lường được (~ 3% âm thanh giống như một lỗi sai đối với tôi).
Elliott Frisch

9
Hoặc a != 0 & b != 0.
Louis Wasserman

16
Sự phân nhánh chậm nếu nhánh dự đoán sai. a*b!=0có một chi nhánh ít hơn
Erwin Bolwidt

19
(1<<16) * (1<<16) == 0nhưng cả hai đều khác không.
CodeInChaos

13
@Gene: Tối ưu hóa đề xuất của bạn không hợp lệ. Ngay cả bỏ qua tràn, a*blà 0 nếu một trong abbằng không; a|bbằng không chỉ khi cả hai đều.
hmakholm còn lại của Monica

Câu trả lời:


240

Tôi đang bỏ qua vấn đề rằng điểm chuẩn của bạn có thể bị sai sót và lấy kết quả theo mệnh giá.

Nó là trình biên dịch hay nó ở cấp độ phần cứng?

Điều đó sau, tôi nghĩ:

  if (a != 0 && b != 0)

sẽ biên dịch thành 2 lần tải bộ nhớ và hai nhánh có điều kiện

  if (a * b != 0)

sẽ biên dịch thành 2 lần tải bộ nhớ, một nhánh nhân và một nhánh có điều kiện.

Hệ số nhân có khả năng nhanh hơn nhánh có điều kiện thứ hai nếu dự đoán nhánh cấp phần cứng không hiệu quả. Khi bạn tăng tỷ lệ ... dự đoán chi nhánh sẽ trở nên kém hiệu quả.

Lý do mà các nhánh có điều kiện chậm hơn là vì chúng làm cho đường ống thực thi lệnh bị đình trệ. Dự đoán chi nhánh là về việc tránh gian hàng bằng cách dự đoán con đường nào sẽ đi và suy đoán theo hướng dẫn tiếp theo dựa trên đó. Nếu dự đoán thất bại, có một độ trễ trong khi hướng dẫn cho hướng khác được tải.

(Lưu ý: phần giải thích ở trên là quá đơn giản. Để giải thích chính xác hơn, bạn cần xem tài liệu do nhà sản xuất CPU cung cấp cho bộ mã hóa ngôn ngữ lắp ráp và trình soạn thảo trình biên dịch. Trang Wikipedia trên Branch Dự đoán là nền tảng tốt.)


Tuy nhiên, có một điều mà bạn cần cẩn thận với việc tối ưu hóa này. Có bất kỳ giá trị nào a * b != 0sẽ đưa ra câu trả lời sai? Xem xét các trường hợp tính toán sản phẩm dẫn đến tràn số nguyên.


CẬP NHẬT

Biểu đồ của bạn có xu hướng xác nhận những gì tôi nói.

  • Ngoài ra còn có hiệu ứng "dự đoán nhánh" trong a * b != 0trường hợp nhánh có điều kiện và điều này xuất hiện trong các biểu đồ.

  • Nếu bạn chiếu các đường cong vượt quá 0,9 trên trục X, có vẻ như 1) chúng sẽ gặp nhau ở khoảng 1,0 và 2) điểm gặp gỡ sẽ có giá trị Y gần bằng với X = 0,0.


CẬP NHẬT 2

Tôi không hiểu tại sao các đường cong khác nhau cho a + b != 0các a | b != 0trường hợp và trường hợp. Có thể có một cái gì đó thông minh trong logic dự đoán nhánh. Hoặc nó có thể chỉ ra một cái gì đó khác.

(Lưu ý rằng loại điều này có thể dành riêng cho một số kiểu chip cụ thể hoặc phiên bản chẵn. Kết quả điểm chuẩn của bạn có thể khác trên các hệ thống khác.)

Tuy nhiên, cả hai đều có lợi thế làm việc cho tất cả các giá trị không âm ab.


1
@DebosmitRay - 1) Không nên có SW. Các kết quả trung gian sẽ được lưu giữ trong một đăng ký. 2) Trong trường hợp thứ hai, có hai nhánh có sẵn: một để thực thi "một số mã" và nhánh còn lại để chuyển sang câu lệnh tiếp theo sau if.
Stephen C

1
@StephenC bạn có quyền nhầm lẫn về a + b và a | b, vì các đường cong như nhau, tôi nghĩ đó là màu sắc thực sự gần gũi. Xin lỗi người mù màu!
Maljam

3
@ njzk2 từ góc độ xác suất những trường hợp đó phải đối xứng theo trục trên 50% (xác suất bằng 0 a&ba|b). Họ là, nhưng không hoàn hảo, đó là câu đố.
Antonín Lejsek

3
@StephenC Lý do tại sao a*b != 0a+b != 0điểm chuẩn khác nhau là vì hoàn toàn a+b != 0không tương đương và không bao giờ nên được điểm chuẩn. Ví dụ, với a = 1, b = 0, biểu thức đầu tiên đánh giá là sai nhưng biểu thức thứ hai đánh giá là đúng. Các hành vi nhân giống như một toán tử toán tử, trong khi các hành vi thêm giống như một toán tử hoặc toán tử.
JS1

2
@ AntonínLejsek Tôi nghĩ rằng xác suất sẽ khác nhau. Nếu bạn có nsố không thì khả năng cả hai abbằng 0 sẽ tăng lên n. Trong một ANDhoạt động, với nxác suất cao hơn một trong số chúng tăng không khác và điều kiện được đáp ứng. Điều này ngược lại với một ORhoạt động (xác suất một trong số chúng là 0 tăng với n). Điều này dựa trên quan điểm toán học. Tôi không chắc đó là cách phần cứng hoạt động.
WYSIWYG

70

Tôi nghĩ rằng điểm chuẩn của bạn có một số sai sót và có thể không hữu ích cho việc suy luận về các chương trình thực tế. Đây là suy nghĩ của tôi:

  • (a|b)!=0(a+b)!=0kiểm tra nếu một trong hai giá trị khác không, trong khi đó a != 0 && b != 0(a*b)!=0kiểm tra nếu cả hai đều khác không. Vì vậy, bạn không so sánh thời gian của số học: nếu điều kiện là đúng thường xuyên hơn, nó gây ra nhiều lần thực hiện ifcơ thể hơn, điều này cũng mất nhiều thời gian hơn.

  • (a+b)!=0 sẽ làm điều sai đối với các giá trị dương và âm tổng bằng 0, vì vậy bạn không thể sử dụng nó trong trường hợp chung, ngay cả khi nó hoạt động ở đây.

  • Tương tự, (a*b)!=0sẽ làm điều sai cho các giá trị tràn. (Ví dụ ngẫu nhiên: 196608 * 327680 là 0 vì kết quả thực sự chia hết cho 2 32 , vì vậy 32 bit thấp của nó là 0 và những bit đó là tất cả những gì bạn nhận được nếu đó là một inthoạt động.)

  • VM sẽ tối ưu hóa biểu thức trong vài lần chạy đầu tiên của fractionvòng lặp ngoài ( ), khi fraction0, khi các nhánh gần như không bao giờ được thực hiện. Trình tối ưu hóa có thể làm những việc khác nhau nếu bạn bắt đầu fractionở mức 0,5.

  • Trừ khi VM có thể loại bỏ một số kiểm tra giới hạn mảng ở đây, có bốn nhánh khác trong biểu thức chỉ do kiểm tra giới hạn và đó là một yếu tố phức tạp khi cố gắng tìm hiểu những gì đang xảy ra ở mức độ thấp. Bạn có thể nhận được các kết quả khác nhau nếu bạn chia mảng hai chiều thành hai mảng phẳng, thay đổi nums[0][i]nums[1][i]thành nums0[i]nums1[i].

  • Các bộ dự đoán nhánh CPU phát hiện các mẫu ngắn trong dữ liệu hoặc chạy tất cả các nhánh được lấy hoặc không lấy. Dữ liệu điểm chuẩn được tạo ngẫu nhiên của bạn là trường hợp xấu nhất đối với người dự đoán chi nhánh . Nếu dữ liệu trong thế giới thực có một mẫu có thể dự đoán được hoặc nó có các giá trị hoàn toàn bằng không và tất cả khác không, các nhánh có thể có giá thấp hơn nhiều .

  • Mã cụ thể được thực thi sau khi đáp ứng điều kiện có thể ảnh hưởng đến hiệu suất của việc đánh giá chính điều kiện đó, bởi vì nó ảnh hưởng đến những thứ như vòng lặp có thể được kiểm soát hay không, có đăng ký CPU nào không và nếu có bất kỳ numsgiá trị nào được tìm nạp được sử dụng lại sau khi đánh giá điều kiện. Chỉ tăng một bộ đếm trong điểm chuẩn không phải là một trình giữ chỗ hoàn hảo cho những gì mã thực sự sẽ làm.

  • System.currentTimeMillis()là trên hầu hết các hệ thống không chính xác hơn +/- 10 ms. System.nanoTime()thường là chính xác hơn.

Có rất nhiều điều không chắc chắn và thật khó để nói bất cứ điều gì rõ ràng với các loại tối ưu hóa vi mô này bởi vì một thủ thuật nhanh hơn trên một máy ảo hoặc CPU có thể chậm hơn trên một máy ảo khác. Nếu chạy JVM HotSpot 32 bit, thay vì phiên bản 64 bit, hãy lưu ý rằng nó có hai loại: với VM "Máy khách" có tối ưu hóa (yếu hơn) so với VM "Máy chủ".

Nếu bạn có thể tháo rời mã máy do VM tạo ra , hãy làm điều đó thay vì cố gắng đoán nó làm gì!


24

Các câu trả lời ở đây là tốt, mặc dù tôi có một ý tưởng có thể cải thiện mọi thứ.

Vì hai nhánh và dự đoán nhánh liên quan là thủ phạm có khả năng, chúng tôi có thể giảm phân nhánh thành một nhánh duy nhất mà không thay đổi logic nào cả.

bool aNotZero = (nums[0][i] != 0);
bool bNotZero = (nums[1][i] != 0);
if (aNotZero && bNotZero) { /* Some code */ }

Nó cũng có thể làm việc

int a = nums[0][i];
int b = nums[1][i];
if (a != 0 && b != 0) { /* Some code */ }

Lý do là, theo các quy tắc ngắn mạch, nếu boolean đầu tiên là sai, thì thứ hai không nên được đánh giá. Nó phải thực hiện một nhánh phụ để tránh đánh giá nums[1][i]nếu nums[0][i]sai. Bây giờ, bạn có thể không quan tâm đến việc nums[1][i]được đánh giá, nhưng trình biên dịch không thể chắc chắn rằng nó sẽ không ném ra khỏi phạm vi hoặc null ref khi bạn làm. Bằng cách giảm khối if thành các bool đơn giản, trình biên dịch có thể đủ thông minh để nhận ra rằng việc đánh giá boolean thứ hai không cần thiết sẽ không có tác dụng phụ tiêu cực.


3
Upvote mặc dù tôi có cảm giác điều này không hoàn toàn trả lời câu hỏi.
Pierre Arlaud

3
Đó là một cách để giới thiệu một nhánh mà không thay đổi logic từ việc không phân nhánh (nếu cách bạn thu được abcó tác dụng phụ, bạn sẽ giữ chúng). Bạn vẫn có &&nên bạn vẫn có một chi nhánh.
Jon Hanna

11

Khi chúng tôi thực hiện phép nhân, ngay cả khi một số bằng 0, thì sản phẩm là 0. Trong khi viết

    (a*b != 0)

Nó đánh giá kết quả của sản phẩm do đó loại bỏ một vài lần xuất hiện đầu tiên của lần lặp bắt đầu từ 0. Kết quả là các phép so sánh nhỏ hơn khi điều kiện là

   (a != 0 && b != 0)

Trong đó mọi yếu tố được so sánh với 0 và được đánh giá. Do đó thời gian cần thiết là ít hơn. Nhưng tôi tin rằng điều kiện thứ hai có thể cho bạn giải pháp chính xác hơn.


4
Trong biểu thức thứ hai nếu abằng 0 thì bkhông cần phải đánh giá vì toàn bộ biểu thức đã sai. Vì vậy, mọi yếu tố được so sánh là không đúng sự thật.
Kuba Wyrostek

9

Bạn đang sử dụng dữ liệu đầu vào ngẫu nhiên làm cho các nhánh không thể đoán trước. Trong thực tế, các nhánh thường (~ 90%) có thể dự đoán được vì vậy trong mã thực, mã nhánh có khả năng nhanh hơn.

Mà nói. Tôi không thấy làm thế nào a*b != 0có thể nhanh hơn (a|b) != 0. Nói chung phép nhân số nguyên đắt hơn một bit OR. Nhưng những thứ như thế này đôi khi trở nên kỳ lạ. Xem ví dụ ví dụ "Ví dụ 7: Độ phức tạp phần cứng" từ Thư viện hiệu ứng bộ đệm của bộ xử lý .


2
&không phải là "bitwise OR" nhưng (trong trường hợp này) là "logic VÀ" bởi vì cả hai toán hạng đều là booleans và nó không phải là |;-)
siegi

1
@siegi TIL Java '&' thực sự là logic VÀ không bị đoản mạch.
StackedCrooking
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.