Phép chia số nguyên nhanh nhất hỗ trợ phép chia cho không cho dù kết quả là gì?


109

Tóm lược:

Tôi đang tìm cách tính toán nhanh nhất

(int) x / (int) y

mà không nhận được một ngoại lệ cho y==0. Thay vào đó tôi chỉ muốn một kết quả tùy ý.


Lý lịch:

Khi mã hóa các thuật toán xử lý hình ảnh, tôi thường cần chia cho một giá trị alpha (tích lũy). Biến thể đơn giản nhất là mã C thuần túy với số học nguyên. Vấn đề của tôi là tôi thường nhận được lỗi chia cho 0 đối với các pixel kết quả có alpha==0. Tuy nhiên, đây chính xác là những pixel mà kết quả không quan trọng chút nào: Tôi không quan tâm đến giá trị màu của pixel với alpha==0.


Chi tiết:

Tôi đang tìm kiếm một cái gì đó như:

result = (y==0)? 0 : x/y;

hoặc là

result = x / MAX( y, 1 );

x và y là các số nguyên dương. Mã được thực thi rất nhiều lần trong một vòng lặp lồng nhau, vì vậy tôi đang tìm cách loại bỏ phân nhánh có điều kiện.

Khi y không vượt quá phạm vi byte, tôi hài lòng với giải pháp

unsigned char kill_zero_table[256] = { 1, 1, 2, 3, 4, 5, 6, 7, [...] 255 };
[...]
result = x / kill_zero_table[y];

Nhưng điều này rõ ràng không hoạt động tốt cho các phạm vi lớn hơn.

Tôi đoán câu hỏi cuối cùng là: Đâu là cách hack twiddling bit nhanh nhất thay đổi 0 thành bất kỳ giá trị số nguyên nào khác, trong khi giữ nguyên tất cả các giá trị khác?


Làm rõ

Tôi không chắc chắn 100% rằng việc phân nhánh quá đắt. Tuy nhiên, các trình biên dịch khác nhau được sử dụng, vì vậy tôi thích đo điểm chuẩn với ít tối ưu hóa (điều này thực sự đáng nghi ngờ).

Chắc chắn, các trình biên dịch là tuyệt vời khi nói đến các bit twiddling, nhưng tôi không thể diễn đạt kết quả "don't care" trong C, vì vậy trình biên dịch sẽ không bao giờ có thể sử dụng đầy đủ các tối ưu hóa.

Mã phải tương thích hoàn toàn với C, nền tảng chính là Linux 64 Bit với gcc & clang và MacOS.


22
Làm thế nào bạn xác định được rằng nhánh if quá đắt?
djechlin

7
Làm thế nào bạn đã xác định rằng có một chi nhánh?
leemes

13
+1 để lập hồ sơ, với dự đoán chi nhánh ngày nay, bạn có thể không cần điều này. Ngoài ra, tại sao bạn lại mã hóa các thuật toán xử lý hình ảnh của riêng mình?
TC1

8
"Cách hack twiddling bit nhanh nhất ..." Có thể là y += !ygì? Không cần nhánh để tính toán điều đó. Bạn có thể so sánh x / (y + !y)với x / max(y, 1)và có thể cũng có y ? (x/y) : 0. Tôi đoán sẽ không có chi nhánh nào ở cả hai, ít nhất là khi đã bật tối ưu hóa.
xuất hiện

6
Bất kỳ ai nghĩ rằng dự đoán nhánh ngày nay có nghĩa là bạn không cần phải làm điều này đã không cấu hình đủ mã loại bỏ nhánh chạy ở cấp độ mỗi pixel. Dự đoán nhánh ngày hiện đại có thể chấp nhận được nếu các 0phần alpha rất lớn và liền nhau. Có một nơi để mày mò với các tối ưu hóa vi mô và hoạt động trên mỗi pixel chính xác là nơi đó.
Yakk - Adam Nevraumont

Câu trả lời:


107

Lấy cảm hứng từ một số nhận xét, tôi đã loại bỏ nhánh trên Pentium và gcctrình biên dịch của mình bằng cách sử dụng

int f (int x, int y)
{
        y += y == 0;
        return x/y;
}

Trình biên dịch về cơ bản nhận ra rằng nó có thể sử dụng cờ điều kiện của bài kiểm tra khi bổ sung.

Theo yêu cầu lắp ráp:

.globl f
    .type   f, @function
f:
    pushl   %ebp
    xorl    %eax, %eax
    movl    %esp, %ebp
    movl    12(%ebp), %edx
    testl   %edx, %edx
    sete    %al
    addl    %edx, %eax
    movl    8(%ebp), %edx
    movl    %eax, %ecx
    popl    %ebp
    movl    %edx, %eax
    sarl    $31, %edx
    idivl   %ecx
    ret

Vì đây là một câu hỏi và câu trả lời phổ biến như vậy, tôi sẽ giải thích kỹ hơn một chút. Ví dụ trên dựa trên thành ngữ lập trình mà trình biên dịch nhận ra. Trong trường hợp trên, một biểu thức boolean được sử dụng trong số học tích phân và việc sử dụng cờ điều kiện được phát minh trong phần cứng cho mục đích này. Nói chung, cờ điều kiện chỉ có thể truy cập được trong C thông qua việc sử dụng thành ngữ. Đó là lý do tại sao rất khó để tạo một thư viện số nguyên chính xác di động trong C mà không sử dụng đến assembly (nội tuyến). Tôi đoán là hầu hết các trình biên dịch tử tế sẽ hiểu thành ngữ trên.

Một cách khác để tránh các nhánh, như cũng đã nhận xét trong một số ý kiến ​​ở trên, là thực hiện dự đoán. Do đó, tôi đã lấy mã đầu tiên của philippe và mã của tôi và chạy nó thông qua trình biên dịch từ ARM và trình biên dịch GCC cho kiến ​​trúc ARM, có tính năng thực thi dự đoán. Cả hai trình biên dịch đều tránh nhánh trong cả hai mẫu mã:

Phiên bản của Philipp với trình biên dịch ARM:

f PROC
        CMP      r1,#0
        BNE      __aeabi_idivmod
        MOVEQ    r0,#0
        BX       lr

Phiên bản của Philipp với GCC:

f:
        subs    r3, r1, #0
        str     lr, [sp, #-4]!
        moveq   r0, r3
        ldreq   pc, [sp], #4
        bl      __divsi3
        ldr     pc, [sp], #4

Mã của tôi với trình biên dịch ARM:

f PROC
        RSBS     r2,r1,#1
        MOVCC    r2,#0
        ADD      r1,r1,r2
        B        __aeabi_idivmod

Mã của tôi với GCC:

f:
        str     lr, [sp, #-4]!
        cmp     r1, #0
        addeq   r1, r1, #1
        bl      __divsi3
        ldr     pc, [sp], #4

Tất cả các phiên bản vẫn cần một nhánh đối với quy trình phân chia, vì phiên bản này của ARM không có phần cứng cho một bộ phận, nhưng việc kiểm tra đối với y == 0được thực hiện đầy đủ thông qua thực thi dự đoán.


Bạn có thể cho chúng tôi xem mã trình hợp dịch thu được không? Hoặc làm thế nào bạn xác định rằng không có chi nhánh?
Haatschii

1
Tuyệt vời. Có thể được thực hiện constexprvà tránh phôi loại không cần thiết như thế này: template<typename T, typename U> constexpr auto fdiv( T t, U u ) -> decltype(t/(u+!u)) { return t/(u+!u); } Và nếu bạn muốn 255,(lhs)/(rhs+!rhs) & -!rhs
Yakk - Adam Nevraumont

1
@leemes nhưng tôi đã có nghĩa là |không &. Rất tiếc - ( (lhs)/(rhs+!rhs) ) | -!rhsnên đặt giá trị của bạn thành 0xFFFFFFFif rhs0lhs/rhsif rhs!=0.
Yakk - Adam Nevraumont

1
Điều này rất thông minh.
Theodoros Chatzigiannakis

1
Câu trả lời chính xác! Tôi thường dùng đến việc lắp ráp cho những thứ này, nhưng điều đó luôn luôn kinh khủng để bảo trì (chưa kể là ít di động;)).
Leo

20

Dưới đây là một số con số cụ thể, trên Windows sử dụng GCC 4.7.2:

#include <stdio.h>
#include <stdlib.h>

int main()
{
  unsigned int result = 0;
  for (int n = -500000000; n != 500000000; n++)
  {
    int d = -1;
    for (int i = 0; i != ITERATIONS; i++)
      d &= rand();

#if CHECK == 0
    if (d == 0) result++;
#elif CHECK == 1
    result += n / d;
#elif CHECK == 2
    result += n / (d + !d);
#elif CHECK == 3
    result += d == 0 ? 0 : n / d;
#elif CHECK == 4
    result += d == 0 ? 1 : n / d;
#elif CHECK == 5
    if (d != 0) result += n / d;
#endif
  }
  printf("%u\n", result);
}

Lưu ý rằng tôi cố tình không gọi srand(), để rand()luôn trả về kết quả chính xác như nhau. Cũng lưu ý rằng -DCHECK=0chỉ đếm các số 0, để có thể thấy rõ tần suất xuất hiện.

Bây giờ, biên dịch và định thời gian cho nó theo nhiều cách khác nhau:

$ for it in 0 1 2 3 4 5; do for ch in 0 1 2 3 4 5; do gcc test.cc -o test -O -DITERATIONS=$it -DCHECK=$ch && { time=`time ./test`; echo "Iterations $it, check $ch: exit status $?, output $time"; }; done; done

hiển thị đầu ra có thể được tóm tắt trong một bảng:

Iterations  | 0        | 1        | 2        | 3         | 4         | 5
-------------+-------------------------------------------------------------------
Zeroes       | 0        | 1        | 133173   | 1593376   | 135245875 | 373728555
Check 1      | 0m0.612s | -        | -        | -         | -         | -
Check 2      | 0m0.612s | 0m6.527s | 0m9.718s | 0m13.464s | 0m18.422s | 0m22.871s
Check 3      | 0m0.616s | 0m5.601s | 0m8.954s | 0m13.211s | 0m19.579s | 0m25.389s
Check 4      | 0m0.611s | 0m5.570s | 0m9.030s | 0m13.544s | 0m19.393s | 0m25.081s
Check 5      | 0m0.612s | 0m5.627s | 0m9.322s | 0m14.218s | 0m19.576s | 0m25.443s

Nếu số 0 hiếm, -DCHECK=2phiên bản hoạt động kém. Khi các số 0 bắt đầu xuất hiện nhiều hơn, -DCHECK=2trường hợp bắt đầu hoạt động tốt hơn đáng kể. Trong số các tùy chọn khác, thực sự không có nhiều sự khác biệt.

Đối với -O3, tuy nhiên, nó là một câu chuyện khác nhau:

Iterations  | 0        | 1        | 2        | 3         | 4         | 5
-------------+-------------------------------------------------------------------
Zeroes       | 0        | 1        | 133173   | 1593376   | 135245875 | 373728555
Check 1      | 0m0.646s | -        | -        | -         | -         | -
Check 2      | 0m0.654s | 0m5.670s | 0m9.905s | 0m14.238s | 0m17.520s | 0m22.101s
Check 3      | 0m0.647s | 0m5.611s | 0m9.085s | 0m13.626s | 0m18.679s | 0m25.513s
Check 4      | 0m0.649s | 0m5.381s | 0m9.117s | 0m13.692s | 0m18.878s | 0m25.354s
Check 5      | 0m0.649s | 0m6.178s | 0m9.032s | 0m13.783s | 0m18.593s | 0m25.377s

Ở đó, séc 2 không có nhược điểm so với các séc khác, và nó giữ lợi ích khi số 0 trở nên phổ biến hơn.

Tuy nhiên, bạn thực sự nên đo lường để xem điều gì xảy ra với trình biên dịch và dữ liệu mẫu đại diện của bạn.


4
Đặt 50% mục nhập là d=0ngẫu nhiên, thay vì thực hiện gần như luôn luôn d!=0và bạn sẽ thấy nhiều lỗi dự đoán nhánh hơn. Dự đoán rẽ nhánh là tuyệt vời nếu một chi nhánh được hầu như luôn luôn theo sau, hoặc nếu những điều sau đây của một chi nhánh này hay cách khác là thực sự đám lộn xộn ...
Yakk - Adam Nevraumont

@Yakk Vòng dlặp là vòng lặp bên trong, vì vậy các d == 0trường hợp được phân phối đồng đều. Và việc biến 50% các trường hợp trở thành d == 0hiện thực?

2
việc làm cho 0.002%các trường hợp có d==0thực tế không? Chúng được phân phối xuyên suốt, cứ sau 65000 lần lặp lại bạn gặp d==0trường hợp của mình . Mặc dù 50%có thể không xảy ra thường xuyên, 10%hoặc 1%có thể dễ dàng xảy ra, hoặc thậm chí 90%hoặc 99%. Bài kiểm tra như được hiển thị chỉ thực sự kiểm tra "nếu về cơ bản bạn chưa bao giờ đi xuống một nhánh, liệu dự đoán nhánh có khiến việc loại bỏ nhánh trở nên vô nghĩa không?", Câu trả lời là "có, nhưng điều đó không thú vị".
Yakk - Adam Nevraumont

1
Không, bởi vì sự khác biệt sẽ vô hình hiệu quả do tiếng ồn.
Joe

3
Sự phân bố của các số không không liên quan đến sự phân bố được tìm thấy trong tình huống của người hỏi câu hỏi. Hình ảnh chứa hỗn hợp 0 ​​alpha và khác có lỗ hoặc hình dạng bất thường, nhưng (thường) đây không phải là nhiễu. Giả sử bạn không biết gì về dữ liệu (và coi đó là tiếng ồn) là một sai lầm. Đây là một ứng dụng thế giới thực với hình ảnh thực tế có thể có 0 alpha. Và vì một hàng pixel có khả năng có tất cả a = 0 hoặc tất cả a> 0, việc tận dụng dự đoán nhánh có thể là nhanh nhất, đặc biệt khi a = 0 xảy ra nhiều và (chậm) phân chia (15+ chu kỳ !) được tránh.
DDS

13

Tuy nhiên, nếu không biết về nền tảng thì không có cách nào để biết chính xác phương pháp hiệu quả nhất, trên một hệ thống chung, điều này có thể gần với mức tối ưu (sử dụng cú pháp trình hợp dịch Intel):

(giả sử số chia là trong ecxvà cổ tức là eax)

mov ebx, ecx
neg ebx
sbb ebx, ebx
add ecx, ebx
div eax, ecx

Bốn lệnh đơn chu kỳ không phân nhánh cộng với số chia. Thương số sẽ ở trong eaxvà phần còn lại edxở cuối. (Loại này cho thấy lý do tại sao bạn không muốn gửi một trình biên dịch để thực hiện công việc của một người đàn ông).


phân chia ở đâu?
Yakk - Adam Nevraumont

1
điều này không làm việc chia nó chỉ gây ô nhiễm ước sao cho phép chia cho không là không thể
Tyler Durden

@Jens Timmerman Xin lỗi, tôi đã viết điều đó trước khi thêm câu lệnh div. Tôi đã cập nhật văn bản.
Tyler Durden

1

Theo liên kết này , bạn chỉ có thể chặn tín hiệu SIGFPE bằng sigaction()(Tôi chưa tự mình thử nhưng tôi tin rằng nó sẽ hoạt động).

Đây là cách tiếp cận nhanh nhất có thể xảy ra nếu lỗi chia cho 0 là cực kỳ hiếm: bạn chỉ trả tiền cho các phép chia cho 0, không phải cho các phép chia hợp lệ, đường dẫn thực hiện thông thường không thay đổi gì cả.

Tuy nhiên, hệ điều hành sẽ tham gia vào mọi ngoại lệ bị bỏ qua, điều này rất tốn kém. Tôi nghĩ, bạn nên có ít nhất một nghìn phép chia tốt cho mỗi phép chia cho 0 mà bạn bỏ qua. Nếu các trường hợp ngoại lệ thường xuyên hơn mức đó, bạn có thể sẽ trả nhiều tiền hơn bằng cách bỏ qua các trường hợp ngoại lệ hơn là bằng cách kiểm tra mọi giá trị trước khi phân chia.

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.