Saat menulis ftol
fungsi yang dioptimalkan saya menemukan beberapa perilaku yang sangat aneh di GCC 4.6.1
. Biarkan saya tunjukkan kode terlebih dahulu (untuk kejelasan saya menandai perbedaannya):
fast_trunc_one, C:
int fast_trunc_one(int i) {
int mantissa, exponent, sign, r;
mantissa = (i & 0x07fffff) | 0x800000;
exponent = 150 - ((i >> 23) & 0xff);
sign = i & 0x80000000;
if (exponent < 0) {
r = mantissa << -exponent; /* diff */
} else {
r = mantissa >> exponent; /* diff */
}
return (r ^ -sign) + sign; /* diff */
}
fast_trunc_two, C:
int fast_trunc_two(int i) {
int mantissa, exponent, sign, r;
mantissa = (i & 0x07fffff) | 0x800000;
exponent = 150 - ((i >> 23) & 0xff);
sign = i & 0x80000000;
if (exponent < 0) {
r = (mantissa << -exponent) ^ -sign; /* diff */
} else {
r = (mantissa >> exponent) ^ -sign; /* diff */
}
return r + sign; /* diff */
}
Tampak sama kan? GCC tidak setuju. Setelah dikompilasi dengan gcc -O3 -S -Wall -o test.s test.c
ini adalah output perakitan:
fast_trunc_one, dihasilkan:
_fast_trunc_one:
LFB0:
.cfi_startproc
movl 4(%esp), %eax
movl $150, %ecx
movl %eax, %edx
andl $8388607, %edx
sarl $23, %eax
orl $8388608, %edx
andl $255, %eax
subl %eax, %ecx
movl %edx, %eax
sarl %cl, %eax
testl %ecx, %ecx
js L5
rep
ret
.p2align 4,,7
L5:
negl %ecx
movl %edx, %eax
sall %cl, %eax
ret
.cfi_endproc
fast_trunc_two, dihasilkan:
_fast_trunc_two:
LFB1:
.cfi_startproc
pushl %ebx
.cfi_def_cfa_offset 8
.cfi_offset 3, -8
movl 8(%esp), %eax
movl $150, %ecx
movl %eax, %ebx
movl %eax, %edx
sarl $23, %ebx
andl $8388607, %edx
andl $255, %ebx
orl $8388608, %edx
andl $-2147483648, %eax
subl %ebx, %ecx
js L9
sarl %cl, %edx
movl %eax, %ecx
negl %ecx
xorl %ecx, %edx
addl %edx, %eax
popl %ebx
.cfi_remember_state
.cfi_def_cfa_offset 4
.cfi_restore 3
ret
.p2align 4,,7
L9:
.cfi_restore_state
negl %ecx
sall %cl, %edx
movl %eax, %ecx
negl %ecx
xorl %ecx, %edx
addl %edx, %eax
popl %ebx
.cfi_restore 3
.cfi_def_cfa_offset 4
ret
.cfi_endproc
Itu perbedaan ekstrem . Ini sebenarnya muncul di profil juga, fast_trunc_one
sekitar 30% lebih cepat daripada fast_trunc_two
. Sekarang pertanyaan saya: apa yang menyebabkan ini?
-S -O3 -da -fdump-tree-all
. Ini akan membuat banyak snapshot dari representasi perantara. Berjalan melalui mereka (diberi nomor) berdampingan dan Anda harus dapat menemukan optimasi yang hilang dalam kasus pertama.int
menjadiunsigned int
dan lihat apakah perbedaannya hilang.(r + shifted) ^ sign
tidak sama denganr + (shifted ^ sign)
. Saya kira itu membingungkan pengoptimal? FWIW, MSVC 2010 (16.00.40219.01) menghasilkan daftar yang hampir identik satu sama lain: gist.github.com/2430454Jawaban:
Diperbarui untuk menyinkronkan dengan edit OP
Dengan mengutak-atik kode, saya telah berhasil melihat bagaimana GCC mengoptimalkan kasus pertama.
Sebelum kita dapat memahami mengapa mereka sangat berbeda, pertama-tama kita harus memahami bagaimana GCC mengoptimalkan
fast_trunc_one()
.Percaya atau tidak,
fast_trunc_one()
sedang dioptimalkan untuk ini:Ini menghasilkan rakitan yang sama persis seperti
fast_trunc_one()
nama register asli dan semuanya.Perhatikan bahwa tidak ada
xor
s untuk perakitanfast_trunc_one()
. Itulah yang memberikannya untuk saya.Bagaimana?
Langkah 1:
sign = -sign
Pertama, mari kita lihat
sign
variabelnya. Karenasign = i & 0x80000000;
, hanya ada dua nilai yang mungkinsign
dapat diambil:sign = 0
sign = 0x80000000
Sekarang ketahuilah bahwa dalam kedua kasus
sign == -sign
,. Karena itu, ketika saya mengubah kode asli ke ini:Ini menghasilkan perakitan yang sama persis seperti aslinya
fast_trunc_one()
. Saya akan menghindarkan Anda dari majelis, tetapi identik - daftarkan nama dan semuanya.Langkah 2: Pengurangan matematika:
x + (y ^ x) = y
sign
hanya dapat mengambil satu dari dua nilai,0
atau0x80000000
.x = 0
,x + (y ^ x) = y
lalu hal sepele berlaku.0x80000000
sama. Itu membalik bit tanda. Karena itux + (y ^ x) = y
juga berlaku kapanx = 0x80000000
.Karena itu,
x + (y ^ x)
kurangi menjadiy
. Dan kode menyederhanakan ini:Sekali lagi, ini mengkompilasi ke majelis yang sama - mendaftarkan nama dan semua.
Versi di atas ini akhirnya mengurangi ini:
yang cukup banyak persis apa yang dihasilkan GCC di majelis.
Jadi mengapa kompiler tidak mengoptimalkan
fast_trunc_two()
hal yang sama?Bagian kunci dalam
fast_trunc_one()
adalahx + (y ^ x) = y
optimasi. Difast_trunc_two()
dalamx + (y ^ x)
ekspresi sedang dibagi di cabang.Saya menduga itu mungkin cukup untuk membingungkan GCC untuk tidak melakukan optimasi ini. (Perlu mengangkat
^ -sign
keluar cabang dan menggabungkannya ker + sign
bagian akhir.)Misalnya, ini menghasilkan rakitan yang sama dengan
fast_trunc_one()
:sumber
Ini adalah sifat kompiler. Dengan asumsi mereka akan mengambil jalur tercepat atau terbaik, itu sangat salah. Siapa pun yang menyiratkan bahwa Anda tidak perlu melakukan apa pun untuk kode Anda untuk mengoptimalkan karena "kompiler modern" mengisi kosong, lakukan pekerjaan terbaik, buat kode tercepat, dll. Sebenarnya saya melihat gcc semakin buruk dari 3.x ke 4.x paling tidak pada lengan. 4.x mungkin telah mencapai 3.x pada titik ini, tetapi sejak awal menghasilkan kode lebih lambat. Dengan latihan Anda dapat belajar bagaimana menulis kode Anda sehingga kompiler tidak harus bekerja sekeras dan sebagai hasilnya menghasilkan hasil yang lebih konsisten dan diharapkan.
Bug di sini adalah harapan Anda tentang apa yang akan diproduksi, bukan apa yang sebenarnya diproduksi. Jika Anda ingin kompiler menghasilkan output yang sama, berikan input yang sama. Secara matematis tidak sama, tidak agak sama, tetapi sebenarnya sama, tidak ada jalur yang berbeda, tidak ada operasi berbagi atau mendistribusikan dari satu versi ke versi lainnya. Ini adalah latihan yang baik dalam memahami bagaimana menulis kode Anda dan melihat apa yang dilakukan oleh kompiler. Jangan membuat kesalahan dengan mengasumsikan bahwa karena satu versi gcc untuk satu target prosesor suatu hari menghasilkan hasil tertentu yang merupakan aturan untuk semua kompiler dan semua kode. Anda harus menggunakan banyak kompiler dan banyak target untuk merasakan apa yang sedang terjadi.
gcc cukup jahat, saya mengundang Anda untuk melihat ke belakang tirai, melihat nyali gcc, mencoba untuk menambah target atau memodifikasi sesuatu sendiri. Ini hampir tidak disatukan oleh lakban dan kawat bailing. Baris kode tambahan ditambahkan atau dihapus di tempat-tempat penting dan itu runtuh. Fakta bahwa ia telah menghasilkan kode yang dapat digunakan sama sekali adalah sesuatu yang bisa disenangi, alih-alih mengkhawatirkan mengapa ia tidak memenuhi harapan lain.
apakah Anda melihat versi gcc yang berbeda? 3.x dan 4.x khususnya 4.5 vs 4.6 vs 4.7, dll? dan untuk prosesor target yang berbeda, x86, arm, mips, dll atau rasa yang berbeda dari x86 jika itu adalah kompiler asli yang Anda gunakan, 32 bit vs 64 bit, dll? Dan kemudian llvm (dentang) untuk target yang berbeda?
Mystical telah melakukan pekerjaan yang sangat baik dalam proses pemikiran yang diperlukan untuk bekerja melalui masalah menganalisis / mengoptimalkan kode, mengharapkan kompiler untuk datang dengan semua itu, yah, tidak diharapkan dari "kompiler modern" apa pun.
Tanpa masuk ke properti matematika, kode formulir ini
akan memimpin compiler ke A: mengimplementasikannya dalam bentuk itu, melakukan if-then-else lalu konvergen pada kode umum untuk menyelesaikan dan kembali. atau B: simpan cabang karena ini adalah ujung fungsi. Juga tidak repot menggunakan atau menyimpan r.
Kemudian Anda bisa masuk sebagai Mystical menunjukkan variabel tanda menghilang bersama-sama untuk kode yang ditulis. Saya tidak akan mengharapkan kompiler untuk melihat variabel tanda pergi sehingga Anda harus melakukannya sendiri dan tidak memaksa kompiler untuk mencoba mengetahuinya.
Ini adalah kesempatan sempurna untuk menggali kode sumber gcc. Tampaknya Anda telah menemukan sebuah kasus di mana pengoptimal melihat satu hal dalam satu kasus kemudian hal lain dalam kasus lain. Kemudian ambil langkah selanjutnya dan lihat apakah Anda tidak bisa mendapatkan gcc untuk melihat kasing itu. Setiap optimasi ada karena beberapa individu atau kelompok mengenali optimasi dan sengaja meletakkannya di sana. Agar pengoptimalan ini ada di sana dan berfungsi setiap kali seseorang harus meletakkannya di sana (lalu mengujinya, dan kemudian mempertahankannya di masa mendatang).
Jelas jangan berasumsi bahwa lebih sedikit kode lebih cepat dan lebih banyak kode lebih lambat, sangat mudah untuk membuat dan menemukan contoh yang tidak benar. Ini mungkin lebih sering terjadi daripada kurang kode menjadi lebih cepat daripada lebih banyak kode. Seperti yang saya tunjukkan dari awal meskipun Anda dapat membuat lebih banyak kode untuk menyimpan percabangan dalam kasus atau perulangan, dll dan memiliki hasil bersih menjadi kode lebih cepat.
Intinya adalah Anda memberi kompiler sumber yang berbeda dan mengharapkan hasil yang sama. Masalahnya bukan output kompiler tetapi harapan pengguna. Cukup mudah untuk diperlihatkan untuk kompiler dan prosesor tertentu, penambahan satu baris kode yang membuat keseluruhan fungsi lebih lambat secara dramatis. Misalnya mengapa mengubah a = b + 2; ke a = b + c + 2; menyebabkan _fill_in_the_blank_compiler_name_ menghasilkan kode yang sangat berbeda dan lebih lambat? Jawabannya tentu saja sebagai kompiler diberi kode yang berbeda pada input sehingga sangat valid untuk kompiler untuk menghasilkan output yang berbeda. (bahkan lebih baik adalah ketika Anda menukar dua baris kode yang tidak terkait dan menyebabkan output berubah secara dramatis) Tidak ada hubungan yang diharapkan antara kompleksitas dan ukuran input dengan kompleksitas dan ukuran output.
Ini menghasilkan suatu tempat antara 60-100 garis assembler. Itu membuka gulungannya. Saya tidak menghitung garis, jika Anda memikirkannya, ia harus menambahkan, menyalin hasilnya ke input ke panggilan fungsi, membuat panggilan fungsi, tiga operasi minimum. jadi tergantung pada target yang mungkin 60 instruksi setidaknya, 80 jika empat per loop, 100 jika lima per loop, dll.
sumber
Mysticial telah memberikan penjelasan yang bagus, tetapi saya pikir saya akan menambahkan, FWIW, bahwa benar-benar tidak ada yang mendasar tentang mengapa kompiler membuat optimasi untuk yang satu dan bukan yang lain.
clang
Kompiler LLVM , misalnya, memberikan kode yang sama untuk kedua fungsi (kecuali untuk nama fungsi), memberikan:Kode ini tidak sesingkat versi gcc pertama dari OP, tetapi tidak selama yang kedua.
Kode dari kompiler lain (yang tidak akan saya sebutkan), kompilasi untuk x86_64, menghasilkan ini untuk kedua fungsi:
yang menarik karena menghitung kedua sisi
if
dan kemudian menggunakan gerakan bersyarat pada akhirnya untuk memilih yang benar.Kompiler Open64 menghasilkan yang berikut:
dan kode yang serupa, tetapi tidak identik, untuk
fast_trunc_two
.Lagi pula, ketika datang ke optimasi, itu lotre - itu adalah apa itu ... Tidak selalu mudah untuk mengetahui mengapa kode Anda dikompilasi dengan cara tertentu.
sumber
icc
. Saya hanya memiliki varian 32-bit tetapi menghasilkan kode yang sangat mirip dengan ini.