Mengapa GCC menggunakan perkalian dengan angka aneh dalam mengimplementasikan divisi integer?

228

Saya telah membaca tentang divdan muloperasi perakitan, dan saya memutuskan untuk melihatnya beraksi dengan menulis program sederhana di C:

Divisi file.c

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

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

Dan kemudian menghasilkan kode bahasa assembly dengan:

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

Tetapi melihat division.sfile yang dihasilkan , tidak mengandung operasi div! Sebagai gantinya, ia melakukan semacam sihir hitam dengan sedikit pergeseran dan angka ajaib. Berikut cuplikan kode yang menghitung 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

Apa yang terjadi di sini? Mengapa GCC tidak menggunakan div sama sekali? Bagaimana cara menghasilkan angka ajaib ini dan mengapa semuanya bekerja?

qiubit
sumber
29
gcc mengoptimalkan pembagian dengan konstanta, coba pembagian dengan 2,3,4,5,6,7,8 dan Anda kemungkinan besar akan melihat kode yang sangat berbeda untuk setiap kasus.
Jabberwocky
28
Catatan: Angka ajaib -3689348814741910323dikonversi menjadi CCCCCCCCCCCCCCCDsebagai uint64_tatau hampir (2 ^ 64) * 4/5.
chux - Reinstate Monica
32
@qiubit: Compiler juga tidak akan menghasilkan kode yang tidak efisien hanya karena optimasi dinonaktifkan. "Optimasi" sepele yang tidak melibatkan penyusunan ulang kode atau penghapusan variabel akan dilakukan, misalnya. Pada dasarnya pernyataan sumber tunggal akan menerjemahkan ke kode yang paling efisien untuk operasi itu secara terpisah. Optimalisasi kompiler memperhitungkan kode di sekitarnya daripada hanya satu pernyataan.
Clifford
20
Baca artikel yang mengagumkan ini: Labour of Division
Jester
9
Beberapa kompiler sebenarnya akan menghasilkan kode yang tidak efisien karena optimasi dinonaktifkan. Secara khusus, mereka akan melakukannya untuk memudahkan debugging, seperti kemampuan untuk mengatur breakpoint pada setiap baris kode. GCC, pada kenyataannya, agak tidak biasa karena tidak memiliki mode "tanpa optimasi" yang sebenarnya, karena banyak optimasinya dihidupkan secara konstitutif. Ini adalah contoh di mana Anda dapat melihatnya dengan GCC. Dentang, di sisi lain, dan MSVC, akan memancarkan divinstruksi pada -O0. (cc @ clifford)
Cody Gray

Jawaban:

169

Divisi integer adalah salah satu operasi aritmatika paling lambat yang dapat Anda lakukan pada prosesor modern, dengan latensi hingga puluhan siklus dan throughput yang buruk. (Untuk x86, lihat tabel instruksi Agner Fog dan panduan mikroarch ).

Jika Anda mengetahui pembagi sebelumnya, Anda dapat menghindari pembagian dengan menggantinya dengan serangkaian operasi lain (multiplikasi, penambahan, dan pergeseran) yang memiliki efek setara. Bahkan jika beberapa operasi diperlukan, seringkali masih jauh lebih cepat daripada divisi integer itu sendiri.

Menerapkan /operator C dengan cara ini alih-alih dengan urutan multi-instruksi yang melibatkan divhanyalah cara standar GCC untuk melakukan pembagian oleh konstanta. Itu tidak perlu dioptimalkan di seluruh operasi dan tidak mengubah apa pun bahkan untuk debugging. (Menggunakan -Osuntuk ukuran kode kecil tidak membuat GCC untuk menggunakan div.) Menggunakan invers multiplikatif bukannya pembagian seperti menggunakan leabukan muldanadd

Akibatnya, Anda hanya cenderung melihat divatau idivdalam output jika pembagi tidak diketahui pada waktu kompilasi.

Untuk informasi tentang cara kompiler membuat urutan ini, serta kode untuk membiarkan Anda menghasilkannya sendiri (hampir pasti tidak perlu kecuali Anda bekerja dengan kompiler braindead), lihat libdivide .

Sneftel
sumber
5
Saya tidak yakin adil untuk menyatukan FP dan operasi integer dalam perbandingan kecepatan, @fuz. Mungkin Sneftel harus mengatakan bahwa pembagian adalah operasi integer paling lambat yang dapat Anda lakukan pada prosesor modern? Juga, beberapa tautan ke penjelasan lebih lanjut tentang "keajaiban" ini telah diberikan dalam komentar. Apakah Anda pikir mereka pantas untuk mengumpulkan jawaban Anda untuk visibilitas? 1 , 2 , 3
Cody Grey
1
Karena urutan operasi identik secara fungsional ... ini selalu merupakan persyaratan, bahkan pada -O3. Kompiler harus membuat kode yang memberikan hasil yang benar untuk semua nilai input yang mungkin. Ini hanya berubah untuk floating point -ffast-math, dan AFAIK tidak ada optimisasi integer "berbahaya". (Dengan optimalisasi diaktifkan, kompiler mungkin dapat membuktikan sesuatu tentang rentang nilai yang memungkinkan yang memungkinkannya menggunakan sesuatu yang hanya berfungsi untuk bilangan bulat bertanda non-negatif misalnya.)
Peter Cordes
6
Jawaban sebenarnya adalah bahwa gcc -O0 masih mengubah kode melalui representasi internal sebagai bagian dari mengubah C menjadi kode mesin . Kebetulan invers multiplikasi modular diaktifkan secara default bahkan pada -O0(tetapi tidak dengan -Os). Compiler lain (seperti dentang) akan menggunakan DIV untuk konstanta non-power-of-2 di -O0. terkait: Saya pikir saya menyertakan paragraf tentang ini dalam jawaban asmara tulisan tangan Collatz-conjecture
Peter Cordes
6
@PeterCordes Dan ya, saya pikir GCC (dan banyak kompiler lain) lupa untuk memberikan alasan yang bagus untuk "optimasi seperti apa yang diterapkan ketika optimasi dinonaktifkan". Setelah menghabiskan sebagian besar hari melacak bug codegen yang tidak jelas, saya sedikit kesal tentang hal itu pada saat ini.
Sneftel
9
@Sneftel: Itu mungkin hanya karena jumlah pengembang aplikasi yang secara aktif mengeluh kepada pengembang kompiler tentang kode mereka berjalan lebih cepat dari yang diharapkan relatif kecil.
dan04
121

Membagi dengan 5 sama dengan mengalikan 1/5, yang lagi sama dengan mengalikan dengan 4/5 dan menggeser 2 bit dengan benar. Nilai yang bersangkutan adalah CCCCCCCCCCCCCCCDdalam hex, yang merupakan representasi biner dari 4/5 jika diletakkan setelah titik heksadesimal (yaitu biner untuk empat perlima 0.110011001100berulang - lihat di bawah untuk alasannya). Saya pikir Anda bisa mengambilnya dari sini! Anda mungkin ingin memeriksa aritmatika titik tetap (meskipun perhatikan itu dibulatkan menjadi bilangan bulat di akhir.

Mengapa, multiplikasi lebih cepat daripada pembagian, dan ketika pembagi diperbaiki, ini adalah rute yang lebih cepat.

Lihat Penggandaan Timbal Balik, tutorial untuk penulisan rinci tentang cara kerjanya, menjelaskan dalam hal titik tetap. Ini menunjukkan bagaimana algoritma untuk menemukan kerja timbal balik, dan bagaimana menangani pembagian dan modulo yang ditandatangani.

Mari kita pertimbangkan sejenak mengapa 0.CCCCCCCC...(hex) atau 0.110011001100...biner adalah 4/5. Membagi representasi biner dengan 4 (bergeser ke kanan 2 tempat), dan kita akan mendapatkan 0.001100110011...yang dengan pemeriksaan sepele dapat ditambahkan yang asli untuk mendapatkan 0.111111111111..., yang jelas sama dengan 1, cara yang sama 0.9999999...dalam desimal sama dengan satu. Oleh karena itu, kita tahu bahwa x + x/4 = 1, begitu 5x/4 = 1, x=4/5. Ini kemudian direpresentasikan sebagai CCCCCCCCCCCCDdalam hex untuk pembulatan (sebagai digit biner di luar yang terakhir akan menjadi a 1).

abligh
sumber
2
@ user2357112 jangan ragu untuk mengirim jawaban Anda sendiri, tetapi saya tidak setuju. Anda dapat menganggap perkalian sebagai 64,0 bit dengan 0,64 bit, memberikan jawaban titik tetap 128 bit, di mana 64 bit terendah dibuang, kemudian pembagian dengan 4 (seperti yang saya tunjukkan dalam paragraf pertama). Anda mungkin dapat memberikan jawaban aritmatika modular alternatif yang menjelaskan gerakan bit dengan baik, tapi saya cukup yakin ini berfungsi sebagai penjelasan.
Abligh
6
Nilai sebenarnya "CCCCCCCCCCCCCCCD" D terakhir adalah penting, itu memastikan bahwa ketika hasilnya dipotong divisi yang tepat keluar dengan jawaban yang tepat.
plugwash
4
Lupakan. Saya tidak melihat bahwa mereka mengambil 64 bit atas dari hasil perkalian 128-bit; itu bukan sesuatu yang dapat Anda lakukan dalam kebanyakan bahasa, jadi saya awalnya tidak menyadari itu terjadi. Jawaban ini akan jauh lebih baik dengan menyebutkan secara eksplisit bagaimana mengambil 64 bit atas dari hasil 128-bit sama dengan mengalikan dengan angka titik tetap dan membulatkan ke bawah. (Juga, akan lebih baik untuk menjelaskan mengapa itu harus 4/5 bukan 1/5, dan mengapa kita harus membulatkan 4/5 ke atas, bukan ke bawah.)
user2357112 mendukung Monica
2
Afaict Anda harus mengetahui seberapa besar kesalahan yang diperlukan untuk melemparkan divisi dengan 5 ke atas melintasi batas pembulatan, kemudian membandingkannya dengan kesalahan terburuk dalam caclulation Anda. Agaknya para pengembang gcc telah melakukannya dan menyimpulkan bahwa itu akan selalu memberikan hasil yang benar.
plugwash
3
Sebenarnya Anda mungkin hanya perlu memeriksa 5 nilai input setinggi mungkin, jika mereka membulatkan semuanya dengan benar juga.
plugwash
60

Secara umum perkalian jauh lebih cepat daripada pembagian. Jadi jika kita bisa lolos dengan mengalikan dengan timbal balik, kita bisa mempercepat pembagian dengan signifikan secara konstan

Kerutnya adalah kita tidak bisa mewakili timbal balik secara tepat (kecuali kalau pembagian itu dengan kekuatan dua orang, tetapi dalam kasus itu kita biasanya bisa mengubah pembagian itu menjadi sedikit pergeseran). Jadi untuk memastikan jawaban yang benar, kita harus berhati-hati agar kesalahan dalam timbal balik kita tidak menyebabkan kesalahan dalam hasil akhir kita.

-3689348814741910323 adalah 0xCCCCCCCCCCCCCCCD yang merupakan nilai lebih dari 4/5 yang dinyatakan dalam 0,64 titik tetap.

Ketika kita mengalikan bilangan bulat 64 bit dengan angka tetap 0,64 kita mendapatkan hasil 64,64. Kami memotong nilai menjadi bilangan bulat 64-bit (secara efektif membulatkannya menjadi nol) dan kemudian melakukan pergeseran lebih lanjut yang membagi empat dan lagi memotong Dengan melihat pada tingkat bit jelas bahwa kita dapat memperlakukan kedua pemotongan sebagai satu pemotongan.

Ini jelas memberi kita setidaknya perkiraan pembagian oleh 5 tetapi apakah itu memberi kita jawaban yang tepat dibulatkan ke nol?

Untuk mendapatkan jawaban yang tepat kesalahan harus cukup kecil untuk tidak mendorong jawaban melewati batas pembulatan.

Jawaban pasti untuk pembagian dengan 5 akan selalu memiliki bagian pecahan 0, 1/5, 2/5, 3/5 atau 4/5. Oleh karena itu kesalahan positif kurang dari 1/5 dalam hasil yang dikalikan dan bergeser tidak akan pernah mendorong hasil melewati batas pembulatan.

Kesalahan dalam konstanta kami adalah (1/5) * 2 -64 . Nilai i kurang dari 2 64 sehingga kesalahan setelah mengalikan kurang dari 1/5. Setelah pembagian dengan 4 kesalahannya kurang dari (1/5) * 2 −2 .

(1/5) * 2 −2 <1/5 sehingga jawabannya akan selalu sama dengan melakukan pembagian yang tepat dan pembulatan ke nol.


Sayangnya ini tidak bekerja untuk semua pembagi.

Jika kita mencoba untuk mewakili 4/7 sebagai angka tetap 0,64 dengan pembulatan dari nol kita berakhir dengan kesalahan (6/7) * 2 -64 . Setelah dikalikan dengan nilai i di bawah 2 64 kita berakhir dengan kesalahan di bawah 6/7 dan setelah membaginya dengan empat kita berakhir dengan kesalahan di bawah 1,5 / 7 yang lebih besar dari 1/7.

Jadi untuk menerapkan divisi dengan 7 dengan benar kita perlu mengalikannya dengan angka tetap 0,65. Kita dapat mengimplementasikannya dengan mengalikan 64 bit yang lebih rendah dari angka titik tetap kita, kemudian menambahkan angka asli (ini mungkin meluap ke dalam bit carry) kemudian melakukan rotasi melalui carry.

plugwash
sumber
8
Jawaban ini mengubah inversi multiplikatif modular dari "matematika yang terlihat lebih rumit daripada yang saya inginkan" menjadi sesuatu yang masuk akal. +1 untuk versi yang mudah dipahami. Saya tidak pernah perlu melakukan apa pun selain hanya menggunakan konstanta yang dihasilkan kompiler, jadi saya hanya membaca sekilas artikel lain yang menjelaskan matematika.
Peter Cordes
2
Saya tidak melihat ada hubungannya dengan modular aritmatika dalam kode sama sekali. Entah dari mana beberapa komentator lain mendapatkan itu.
plugwash
3
Ini modulo 2 ^ n, seperti semua matematika integer dalam register. en.wikipedia.org/wiki/…
Peter Cordes
4
@PeterCordes inversi modular multiplicative digunakan untuk pembagian yang tepat, afaik mereka tidak berguna untuk pembagian umum
harold
4
@PeterCordes penggandaan oleh titik tetap berbanding terbalik? Saya tidak tahu apa yang disebut semua orang tapi saya mungkin akan menyebutnya begitu, ini cukup deskriptif
Harold
12

Berikut ini tautan ke dokumen algoritme yang menghasilkan nilai dan kode yang saya lihat dengan Visual Studio (dalam kebanyakan kasus) dan yang saya asumsikan masih digunakan dalam GCC untuk pembagian bilangan variabel dengan bilangan bulat konstan.

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

Dalam artikel tersebut, sebuah uword memiliki N bit, udword memiliki 2N bit, n = pembilang = dividen, d = penyebut = pembagi, ℓ awalnya diatur ke ceil (log2 (d)), shpre adalah pra-shift (digunakan sebelum dikalikan ) = e = jumlah trailing zero bits dalam d, shpost adalah post-shift (digunakan setelah multiply), prec presisi = N - e = N - shpre. Tujuannya adalah untuk mengoptimalkan perhitungan n / d menggunakan pre-shift, multiply, dan post-shift.

Gulir ke bawah ke gambar 6.2, yang mendefinisikan bagaimana pengganda kata kunci (ukuran maksimum adalah N + 1 bit), dihasilkan, tetapi tidak jelas menjelaskan prosesnya. Saya akan jelaskan di bawah ini.

Gambar 4.2 dan Gambar 6.2 menunjukkan bagaimana pengali dapat dikurangi menjadi N bit atau kurang pengali untuk sebagian besar pembagi. Persamaan 4.5 menjelaskan bagaimana rumus yang digunakan untuk menangani pengganda N + 1 bit pada gambar 4.1 dan 4.2 diturunkan.

Dalam kasus X86 modern dan prosesor lainnya, waktu penggandaan tetap, jadi pra-shift tidak membantu pada prosesor ini, tetapi masih membantu mengurangi pengganda dari N + 1 bit ke N bit. Saya tidak tahu apakah GCC atau Visual Studio telah menghilangkan pra-shift untuk target X86.

Kembali ke Gambar 6.2. Pembilang (dividen) untuk mlow dan mhigh dapat lebih besar dari udword hanya ketika penyebut (pembagi)> 2 ^ (N-1) (ketika ℓ == N => mlow = 2 ^ (2N)), dalam hal ini penggantian yang dioptimalkan untuk n / d adalah perbandingan (jika n> = d, q = 1, jika tidak q = 0), maka tidak ada pengali yang dihasilkan. Nilai awal mlow dan mhigh akan menjadi N + 1 bit, dan dua pembagian udword / uword dapat digunakan untuk menghasilkan setiap nilai bit N + 1 (mlow atau mhigh). Menggunakan X86 dalam mode 64 bit sebagai contoh:

; 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

Anda dapat menguji ini dengan GCC. Anda sudah melihat bagaimana j = i / 5 ditangani. Lihatlah bagaimana j = i / 7 ditangani (yang seharusnya merupakan kasus pengganda N + 1 bit).

Pada sebagian besar prosesor saat ini, multiply memiliki timing yang tetap, sehingga pra-shift tidak diperlukan. Untuk X86, hasil akhirnya adalah dua urutan instruksi untuk sebagian besar pembagi, dan urutan lima instruksi untuk pembagi seperti 7 (untuk meniru suatu pengganda bit N + 1 seperti yang ditunjukkan dalam persamaan 4.5 dan gambar 4.2 dari file pdf). Contoh kode 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
;       ...
rcgldr
sumber
Makalah itu menjelaskan penerapannya dalam gcc, jadi saya pikir ini adalah asumsi yang aman bahwa algo yang sama masih digunakan.
Peter Cordes
Makalah itu bertanggal 1994 menjelaskan penerapannya dalam gcc, jadi ada waktu bagi gcc untuk memperbarui algoritmanya. Kalau-kalau ada orang lain yang tidak punya waktu untuk memeriksa untuk melihat apa arti 94 dalam URL itu.
Ed Grimm
0

Saya akan menjawab dari sudut yang sedikit berbeda: Karena diperbolehkan untuk melakukannya.

C dan C ++ didefinisikan terhadap mesin abstrak. Kompiler mengubah program ini dari mesin abstrak ke mesin beton mengikuti aturan as-if .

  • Kompiler diizinkan untuk membuat perubahan APAPUN asalkan tidak mengubah perilaku yang dapat diamati sebagaimana ditentukan oleh mesin abstrak. Tidak ada harapan yang masuk akal bahwa kompiler akan mengubah kode Anda dengan cara yang paling mudah (bahkan ketika banyak programmer C berasumsi demikian). Biasanya, ini dilakukan karena kompiler ingin mengoptimalkan kinerja dibandingkan dengan pendekatan langsung (seperti yang dibahas dalam jawaban lain panjang lebar).
  • Jika dalam keadaan apa pun kompiler "mengoptimalkan" program yang benar untuk sesuatu yang memiliki perilaku yang dapat diamati, itu adalah bug penyusun.
  • Setiap perilaku tidak terdefinisi dalam kode kami (ditandatangani integer overflow adalah contoh klasik) dan kontrak ini tidak berlaku.
tuan
sumber