Tại sao GCC sử dụng phép nhân với một số lạ trong việc thực hiện phép chia số nguyên?


228

Tôi đã đọc về divmullắp ráp các hoạt động, và tôi quyết định thấy chúng hoạt động bằng cách viết một chương trình đơn giản trong C:

Phân chia tập tin.c

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

int main()
{
    size_t i = 9;
    size_t j = i / 5;
    printf("%zu\n",j);
    return 0;
}

Và sau đó tạo mã ngôn ngữ lắp ráp với:

gcc -S division.c -O0 -masm=intel

Nhưng nhìn vào division.stập tin được tạo , nó không chứa bất kỳ hoạt động div nào! Thay vào đó, nó thực hiện một số loại ma thuật đen với số bit và ma thuật dịch chuyển. Đây là một đoạn mã tính toán i/5:

mov     rax, QWORD PTR [rbp-16]   ; Move i (=9) to RAX
movabs  rdx, -3689348814741910323 ; Move some magic number to RDX (?)
mul     rdx                       ; Multiply 9 by magic number
mov     rax, rdx                  ; Take only the upper 64 bits of the result
shr     rax, 2                    ; Shift these bits 2 places to the right (?)
mov     QWORD PTR [rbp-8], rax    ; Magically, RAX contains 9/5=1 now, 
                                  ; so we can assign it to j

Những gì đang xảy ra ở đây? Tại sao GCC không sử dụng div? Làm thế nào để nó tạo ra con số kỳ diệu này và tại sao mọi thứ hoạt động?


29
gcc tối ưu hóa sự phân chia theo các hằng số, thử phân chia theo 2,3,4,5,6,7,8 và rất có thể bạn sẽ thấy mã rất khác nhau cho mỗi trường hợp.
Jabberwocky

28
Lưu ý: Số ma thuật -3689348814741910323chuyển đổi thành CCCCCCCCCCCCCCCDmột uint64_thoặc chỉ khoảng (2 ^ 64) * 4/5.
chux - Phục hồi lại

32
@qiubit: Trình biên dịch sẽ không tạo ra mã không hiệu quả chỉ vì tối ưu hóa bị vô hiệu hóa. Một "tối ưu hóa" tầm thường không liên quan đến sắp xếp lại mã hoặc loại bỏ biến sẽ được thực hiện bất kể ví dụ nào. Về cơ bản, một câu lệnh nguồn duy nhất sẽ chuyển thành mã hiệu quả nhất cho hoạt động đó một cách cô lập. Tối ưu hóa trình biên dịch sẽ tính đến mã xung quanh thay vì chỉ là câu lệnh đơn.
Clifford

20
Đọc bài viết tuyệt vời này: Lao động của Bộ phận
Jester

9
Một số trình biên dịch thực sự sẽ tạo ra mã không hiệu quả vì tối ưu hóa bị vô hiệu hóa. Cụ thể, họ sẽ làm điều đó để giúp gỡ lỗi dễ dàng, như khả năng đặt điểm dừng trên các dòng mã riêng lẻ. Trên thực tế, GCC khá bất thường ở chỗ nó không có chế độ "không tối ưu hóa" thực sự, bởi vì nhiều tối ưu hóa của nó được bật một cách cấu thành. Đây là một ví dụ về nơi bạn có thể thấy điều đó với GCC. Clang, mặt khác, và MSVC, sẽ phát ra một divhướng dẫn tại -O0. (cc @ clifford)
Cody Grey

Câu trả lời:


169

Phân chia số nguyên là một trong những hoạt động số học chậm nhất bạn có thể thực hiện trên bộ xử lý hiện đại, với độ trễ lên đến hàng chục chu kỳ và thông lượng xấu. (Đối với x86, xem bảng hướng dẫn của Agner Fog và hướng dẫn microarch ).

Nếu bạn biết số chia trước thời hạn, bạn có thể tránh phép chia bằng cách thay thế nó bằng một tập hợp các thao tác khác (phép nhân, phép cộng và dịch chuyển) có hiệu quả tương đương. Ngay cả khi cần một vài thao tác, nó vẫn thường nhanh hơn rất nhiều so với phép chia số nguyên.

Việc triển /khai toán tử C theo cách này thay vì với một chuỗi nhiều lệnh liên quan divchỉ là cách phân chia mặc định của GCC theo các hằng số. Nó không yêu cầu tối ưu hóa trong các hoạt động và không thay đổi bất cứ điều gì ngay cả để gỡ lỗi. (Tuy nhiên, việc sử dụng -Oscho kích thước mã nhỏ sẽ giúp GCC sử dụng div.) Sử dụng nghịch đảo nhân thay vì chia giống như sử dụng leathay vì muladd

Kết quả là, bạn chỉ có xu hướng nhìn thấy divhoặc idivtrong đầu ra nếu số chia không được biết vào thời gian biên dịch.

Để biết thông tin về cách trình biên dịch tạo các chuỗi này, cũng như mã để cho phép bạn tự tạo chúng (gần như chắc chắn không cần thiết trừ khi bạn làm việc với trình biên dịch braindead), hãy xem libdivide .


5
Tôi không chắc chắn sẽ hợp lý khi kết hợp các hoạt động của FP và số nguyên trong một so sánh tốc độ, @fuz. Có lẽ Sneftel nên nói rằng phép chia là hoạt động số nguyên chậm nhất bạn có thể thực hiện trên bộ xử lý hiện đại? Ngoài ra, một số liên kết đến giải thích thêm về "ma thuật" này đã được cung cấp trong các bình luận. Bạn có nghĩ rằng họ sẽ thích hợp để thu thập câu trả lời của bạn cho khả năng hiển thị không? 1 , 2 , 3
Cody Grey

1
Bởi vì chuỗi các hoạt động giống hệt nhau về mặt chức năng ... đây luôn là một yêu cầu, ngay cả tại -O3. Trình biên dịch phải tạo mã cung cấp kết quả chính xác cho tất cả các giá trị đầu vào có thể. Điều này chỉ thay đổi cho điểm nổi với -ffast-mathvà AFAIK không có tối ưu hóa số nguyên "nguy hiểm". (Với việc tối ưu hóa được bật, trình biên dịch có thể chứng minh điều gì đó về phạm vi giá trị có thể cho phép nó sử dụng thứ gì đó chỉ hoạt động cho các số nguyên có chữ ký không âm.)
Peter Cordes

6
Câu trả lời thực sự là gcc -O0 vẫn chuyển đổi mã thông qua các biểu diễn bên trong như là một phần của việc biến C thành mã máy . Nó chỉ xảy ra rằng các nghịch đảo nhân mô-đun được bật theo mặc định ngay cả tại -O0(nhưng không phải với -Os). Các trình biên dịch khác (như clang) sẽ sử dụng DIV cho các hằng số không có công suất 2 tại -O0. có liên quan: Tôi nghĩ rằng tôi đã bao gồm một đoạn về điều này trong câu trả lời bằng văn bản viết tay Collatz phỏng đoán của tôi
Peter Cordes

6
@PeterCordes Và vâng, tôi nghĩ GCC (và rất nhiều trình biên dịch khác) đã quên đưa ra một lý do hợp lý cho "loại tối ưu hóa nào được áp dụng khi tối ưu hóa bị vô hiệu hóa". Đã dành phần tốt hơn của một ngày để theo dõi một lỗi codegen tối nghĩa, tôi hơi khó chịu về điều đó ngay lúc này.
Sneftel

9
@Sneftel: Điều đó có lẽ chỉ vì số lượng nhà phát triển ứng dụng chủ động khiếu nại với các nhà phát triển trình biên dịch về mã của họ chạy nhanh hơn dự kiến ​​là tương đối nhỏ.
dan04

121

Chia cho 5 cũng giống như nhân 1/5, một lần nữa giống với nhân với 4/5 và dịch chuyển đúng 2 bit. Giá trị liên quan là CCCCCCCCCCCCCCCDở dạng hex, là biểu diễn nhị phân của 4/5 nếu được đặt sau một điểm thập lục phân (tức là nhị phân trong bốn phần năm được 0.110011001100lặp lại - xem bên dưới để biết lý do). Tôi nghĩ bạn có thể lấy nó từ đây! Bạn có thể muốn kiểm tra số học điểm cố định (mặc dù lưu ý rằng nó được làm tròn thành một số nguyên ở cuối.

Về lý do, phép nhân nhanh hơn phép chia và khi số chia được cố định, đây là tuyến nhanh hơn.

Xem Phép nhân đối ứng, một hướng dẫn để viết chi tiết về cách thức hoạt động của nó, giải thích về điểm cố định. Nó cho thấy thuật toán tìm kiếm đối ứng hoạt động như thế nào và cách xử lý phép chia và modulo đã ký.

Hãy xem xét trong một phút tại sao 0.CCCCCCCC...(hex) hoặc 0.110011001100...nhị phân là 4/5. Chia đại diện nhị phân cho 4 (dịch chuyển sang phải 2 vị trí) và chúng tôi sẽ nhận được 0.001100110011...bằng cách kiểm tra tầm thường có thể được thêm bản gốc để nhận 0.111111111111..., rõ ràng bằng 1, cùng một cách 0.9999999...thập phân bằng một. Do đó, chúng tôi biết rằng x + x/4 = 1, vì vậy 5x/4 = 1, x=4/5. Điều này sau đó được biểu diễn dưới dạng CCCCCCCCCCCCDhex để làm tròn (vì chữ số nhị phân ngoài số cuối cùng hiện tại sẽ là a 1).


2
@ user2357112 vui lòng đăng câu trả lời của riêng bạn, nhưng tôi không đồng ý. Bạn có thể nghĩ bội số là 64,0 bit với 0,64 bit nhân cho câu trả lời điểm cố định 128 bit, trong đó 64 bit thấp nhất bị loại bỏ, sau đó chia cho 4 (như tôi chỉ ra trong đoạn đầu tiên). Bạn cũng có thể đưa ra một câu trả lời số học mô-đun thay thế giải thích các chuyển động bit tốt như nhau, nhưng tôi khá chắc chắn rằng điều này hoạt động như một lời giải thích.
abligh

6
Giá trị thực sự là "CCCCCCCCCCCCCCCD" D cuối cùng rất quan trọng, nó đảm bảo rằng khi kết quả được cắt ngắn, các phân chia chính xác sẽ đưa ra câu trả lời đúng.
cắm

4
Đừng bận tâm. Tôi không thấy rằng họ đang lấy 64 bit trên của kết quả nhân 128 bit; đó không phải là điều bạn có thể làm trong hầu hết các ngôn ngữ, vì vậy ban đầu tôi không nhận ra điều đó đang xảy ra. Câu trả lời này sẽ được cải thiện nhiều bằng cách đề cập rõ ràng về cách lấy 64 bit trên của kết quả 128 bit tương đương với nhân với số điểm cố định và làm tròn xuống. (Ngoài ra, thật tốt khi giải thích lý do tại sao nó phải là 4/5 thay vì 1/5 và tại sao chúng ta phải làm tròn 4/5 thay vì xuống.)
user2357112 hỗ trợ Monica

2
Afaict bạn sẽ phải tìm ra mức độ lớn cần thiết để ném một phép chia từ 5 trở lên trên một đường bao quanh, sau đó so sánh với lỗi trường hợp xấu nhất trong phép tính toán của bạn. Có lẽ các nhà phát triển gcc đã làm như vậy và kết luận rằng nó sẽ luôn cho kết quả chính xác.
cắm

3
Trên thực tế, bạn chỉ cần kiểm tra 5 giá trị đầu vào cao nhất có thể, nếu những vòng đó chính xác thì mọi thứ khác cũng vậy.
cắm

60

Nói chung phép nhân nhanh hơn nhiều so với phép chia. Vì vậy, nếu chúng ta có thể thoát khỏi việc nhân với đối ứng thay vào đó, chúng ta có thể tăng tốc độ phân chia đáng kể theo hằng số

Một nếp nhăn là chúng ta không thể đại diện chính xác cho sự đối ứng (trừ khi sự phân chia bằng một sức mạnh của hai nhưng trong trường hợp đó chúng ta thường chỉ có thể chuyển đổi sự phân chia thành một chút thay đổi). Vì vậy, để đảm bảo câu trả lời chính xác, chúng tôi phải cẩn thận rằng lỗi trong đối ứng của chúng tôi không gây ra lỗi trong kết quả cuối cùng của chúng tôi.

-3689348814741910323 là 0xCCCCCCCCCCCCCCCCCD, giá trị chỉ hơn 4/5 được biểu thị bằng 0,64 điểm cố định.

Khi nhân số nguyên 64 bit với số điểm cố định 0,64, chúng tôi nhận được kết quả 64,64. Chúng tôi cắt ngắn giá trị thành một số nguyên 64 bit (làm tròn một cách hiệu quả về 0) và sau đó thực hiện một sự thay đổi tiếp theo chia cho bốn và một lần nữa bằng cách nhìn vào cấp độ bit, rõ ràng chúng ta có thể coi cả hai lần cắt là một lần cắt.

Điều này rõ ràng mang lại cho chúng ta ít nhất một xấp xỉ chia cho 5 nhưng nó có cho chúng ta một câu trả lời chính xác được làm tròn chính xác về không?

Để có được câu trả lời chính xác, lỗi cần phải đủ nhỏ để không đẩy câu trả lời qua ranh giới làm tròn.

Câu trả lời chính xác cho phép chia cho 5 sẽ luôn có một phần phân số là 0, 1/5, 2/5, 3/5 hoặc 4/5. Do đó, sai số dương nhỏ hơn 1/5 trong kết quả nhân và dịch chuyển sẽ không bao giờ đẩy kết quả qua ranh giới làm tròn.

Lỗi trong hằng số của chúng tôi là (1/5) * 2 -64 . Giá trị của i nhỏ hơn 2 64 nên sai số sau khi nhân nhỏ hơn 1/5. Sau khi phân chia bởi 4 lỗi là ít hơn (1/5) * 2 -2 .

(1/5) * 2 -2 <1/5 nên câu trả lời sẽ luôn luôn được bình đẳng để làm một bộ phận chính xác và làm tròn về phía zero.


Thật không may, điều này không làm việc cho tất cả các ước.

Nếu chúng ta cố gắng biểu thị 4/7 dưới dạng số điểm cố định 0,64 với làm tròn từ 0, chúng ta sẽ gặp lỗi (6/7) * 2 -64 . Sau khi nhân với một giá trị i chỉ dưới 2 64, chúng tôi kết thúc với một lỗi chỉ dưới 6/7 và sau khi chia cho bốn, chúng tôi kết thúc với một lỗi chỉ dưới 1,5 / 7 lớn hơn 1/7.

Vì vậy, để thực hiện phép chia với 7 chính xác, chúng ta cần nhân với một số điểm cố định 0,65. Chúng ta có thể thực hiện điều đó bằng cách nhân với 64 bit thấp hơn của số điểm cố định của mình, sau đó thêm số ban đầu (số này có thể tràn vào bit carry) sau đó thực hiện xoay vòng qua carry.


8
Câu trả lời này đã biến các phép nghịch đảo mô đun từ "toán học có vẻ phức tạp hơn tôi muốn dành thời gian" thành một điều gì đó có ý nghĩa. +1 cho phiên bản dễ hiểu. Tôi chưa bao giờ cần phải làm gì ngoài việc chỉ sử dụng các hằng số do trình biên dịch tạo ra, vì vậy tôi chỉ đọc lướt qua các bài viết khác giải thích về toán học.
Peter Cordes

2
Tôi không thấy bất cứ điều gì để làm với số học mô-đun trong mã. Dunno nơi một số nhà bình luận khác đang nhận được điều đó từ.
cắm

3
Đó là modulo 2 ^ n, giống như tất cả toán học số nguyên trong một thanh ghi. vi.wikipedia.org/wiki/ từ
Peter Cordes

4
@PeterCordes nghịch đảo nhân mô-đun được sử dụng để phân chia chính xác, vì chúng không hữu ích cho phân chia chung
harold

4
@PeterCordes nhân với đối ứng điểm cố định? Tôi không biết mọi người gọi nó là gì nhưng có lẽ tôi gọi nó là như vậy, nó khá mô tả
harold

12

Đây là liên kết đến một tài liệu về thuật toán tạo ra các giá trị và mã mà tôi thấy với Visual Studio (trong hầu hết các trường hợp) và tôi giả sử vẫn được sử dụng trong GCC để chia một số nguyên biến cho một số nguyên không đổi.

http://gmplib.org/~tege/divcnst-pldi94.pdf

Trong bài viết, một uword có N bit, một udword có 2 bit, n = tử số = cổ tức, d = denominator = divisor, initially ban đầu được đặt thành ceil (log2 (d)), shpre là dịch chuyển trước (được sử dụng trước khi nhân ) = e = số bit zero trailing trong d, shpost là post-shift (được sử dụng sau khi nhân), pre là precision = N - e = N - shpre. Mục tiêu là để tối ưu hóa tính toán của n / d bằng cách sử dụng trước ca, nhân và sau ca.

Cuộn xuống hình 6.2, định nghĩa cách tạo một số nhân udword (kích thước tối đa là N + 1 bit), nhưng không giải thích rõ ràng quy trình. Tôi sẽ giải thích điều này dưới đây.

Hình 4.2 và hình 6.2 cho thấy cách nhân số nhân có thể giảm xuống một số nhân N hoặc ít hơn cho hầu hết các ước số. Công thức 4.5 giải thích cách thức công thức được sử dụng để xử lý các bội số bit N + 1 trong hình 4.1 và 4.2.

Trong trường hợp X86 hiện đại và các bộ xử lý khác, thời gian nhân được cố định, do đó, dịch chuyển trước không giúp ích gì cho các bộ xử lý này, nhưng nó vẫn giúp giảm hệ số nhân từ bit N + 1 xuống N bit. Tôi không biết liệu GCC hoặc Visual Studio đã loại bỏ dịch chuyển trước cho các mục tiêu X86.

Quay trở lại Hình 6.2. Tử số (cổ tức) cho mlow và mhigh có thể lớn hơn một từ chỉ khi mẫu số (ước số)> 2 ^ (N-1) (khi == N => mlow = 2 ^ (2N)), trong trường hợp này thay thế tối ưu cho n / d là so sánh (nếu n> = d, q = 1, khác q = 0), do đó không có số nhân nào được tạo. Các giá trị ban đầu của mlow và mhigh sẽ là N + 1 bit và hai phép chia udword / uword có thể được sử dụng để tạo ra mỗi giá trị bit N + 1 (mlow hoặc mhigh). Sử dụng X86 ở chế độ 64 bit làm ví dụ:

; upper 8 bytes of dividend = 2^(ℓ) = (upper part of 2^(N+ℓ))
; lower 8 bytes of dividend for mlow  = 0
; lower 8 bytes of dividend for mhigh = 2^(N+ℓ-prec) = 2^(ℓ+shpre) = 2^(ℓ+e)
dividend  dq    2 dup(?)        ;16 byte dividend
divisor   dq    1 dup(?)        ; 8 byte divisor

; ...
        mov     rcx,divisor
        mov     rdx,0
        mov     rax,dividend+8     ;upper 8 bytes of dividend
        div     rcx                ;after div, rax == 1
        mov     rax,dividend       ;lower 8 bytes of dividend
        div     rcx
        mov     rdx,1              ;rdx:rax = N+1 bit value = 65 bit value

Bạn có thể kiểm tra điều này với GCC. Bạn đã thấy cách xử lý j = i / 5. Hãy xem cách xử lý j = i / 7 (nên là trường hợp số nhân N + 1 bit).

Trên hầu hết các bộ xử lý hiện tại, bội số có thời gian cố định, do đó không cần dịch chuyển trước. Đối với X86, kết quả cuối cùng là một chuỗi hai lệnh cho hầu hết các ước và một chuỗi năm lệnh cho các ước như 7 (để mô phỏng hệ số nhân N + 1 bit như trong phương trình 4.5 và hình 4.2 của tệp pdf). Mã X86-64:

;       rax = dividend, rbx = 64 bit (or less) multiplier, rcx = post shift count
;       two instruction sequence for most divisors:

        mul     rbx                     ;rdx = upper 64 bits of product
        shr     rdx,cl                  ;rdx = quotient
;
;       five instruction sequence for divisors like 7
;       to emulate 65 bit multiplier (rbx = lower 64 bits of multiplier)

        mul     rbx                     ;rdx = upper 64 bits of product
        sub     rbx,rdx                 ;rbx -= rdx
        shr     rbx,1                   ;rbx >>= 1
        add     rdx,rbx                 ;rdx = upper 64 bits of corrected product
        shr     rdx,cl                  ;rdx = quotient
;       ...

Bài báo đó mô tả việc thực hiện nó trong gcc, vì vậy tôi nghĩ rằng đó là một giả định an toàn rằng cùng một thuật toán vẫn được sử dụng.
Peter Cordes

Bài báo ngày 1994 mô tả việc thực hiện nó trong gcc, vì vậy đã đến lúc gcc cập nhật thuật toán của nó. Chỉ trong trường hợp những người khác không có thời gian để kiểm tra xem 94 trong URL đó có nghĩa gì.
Ed Grimm

0

Tôi sẽ trả lời từ một góc độ hơi khác: Bởi vì nó được phép làm điều đó.

C và C ++ được định nghĩa dựa trên một máy trừu tượng. Trình biên dịch biến đổi chương trình này theo phương thức của máy trừu tượng thành máy cụ thể theo quy tắc as-if .

  • Trình biên dịch được phép thực hiện bất kỳ thay đổi nào miễn là nó không thay đổi hành vi có thể quan sát được theo quy định của máy trừu tượng. Không có kỳ vọng hợp lý rằng trình biên dịch sẽ biến đổi mã của bạn theo cách đơn giản nhất có thể (ngay cả khi rất nhiều lập trình viên C cho rằng). Thông thường, nó thực hiện điều này bởi vì trình biên dịch muốn tối ưu hóa hiệu suất so với cách tiếp cận đơn giản (như đã thảo luận trong các câu trả lời khác ở độ dài).
  • Nếu trong bất kỳ trường hợp nào, trình biên dịch "tối ưu hóa" một chương trình chính xác thành một chương trình có hành vi có thể quan sát khác, đó là lỗi trình biên dịch.
  • Bất kỳ hành vi không xác định nào trong mã của chúng tôi (tràn số nguyên đã ký là một ví dụ cổ điển) và hợp đồng này là vô hiệu.
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.