Saya telah membaca tentang div
dan mul
operasi 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.s
file 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?
-3689348814741910323
dikonversi menjadiCCCCCCCCCCCCCCCD
sebagaiuint64_t
atau hampir (2 ^ 64) * 4/5.div
instruksi pada-O0
. (cc @ clifford)Jawaban:
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 melibatkandiv
hanyalah cara standar GCC untuk melakukan pembagian oleh konstanta. Itu tidak perlu dioptimalkan di seluruh operasi dan tidak mengubah apa pun bahkan untuk debugging. (Menggunakan-Os
untuk ukuran kode kecil tidak membuat GCC untuk menggunakandiv
.) Menggunakan invers multiplikatif bukannya pembagian seperti menggunakanlea
bukanmul
danadd
Akibatnya, Anda hanya cenderung melihat
div
atauidiv
dalam 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 .
sumber
-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.)-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-conjectureMembagi 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
CCCCCCCCCCCCCCCD
dalam hex, yang merupakan representasi biner dari 4/5 jika diletakkan setelah titik heksadesimal (yaitu biner untuk empat perlima0.110011001100
berulang - 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) atau0.110011001100...
biner adalah 4/5. Membagi representasi biner dengan 4 (bergeser ke kanan 2 tempat), dan kita akan mendapatkan0.001100110011...
yang dengan pemeriksaan sepele dapat ditambahkan yang asli untuk mendapatkan0.111111111111...
, yang jelas sama dengan 1, cara yang sama0.9999999...
dalam desimal sama dengan satu. Oleh karena itu, kita tahu bahwax + x/4 = 1
, begitu5x/4 = 1
,x=4/5
. Ini kemudian direpresentasikan sebagaiCCCCCCCCCCCCD
dalam hex untuk pembulatan (sebagai digit biner di luar yang terakhir akan menjadi a1
).sumber
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.
sumber
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:
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:
sumber
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 .
sumber