Saya memiliki fungsi yang terlihat seperti ini (hanya menunjukkan bagian penting):
double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY) {
...
for(std::size_t i=std::max(0,-shift);i<max;i++) {
if ((curr[i] < 479) && (l[i + shift] < 479)) {
nontopOverlap++;
}
...
}
...
}
Ditulis seperti ini, fungsinya mengambil ~ 34ms pada mesin saya. Setelah mengubah kondisi menjadi bool perkalian (membuat kode terlihat seperti ini):
double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY) {
...
for(std::size_t i=std::max(0,-shift);i<max;i++) {
if ((curr[i] < 479) * (l[i + shift] < 479)) {
nontopOverlap++;
}
...
}
...
}
waktu eksekusi menurun hingga ~ 19 ms.
Kompiler yang digunakan adalah GCC 5.4.0 dengan -O3 dan setelah memeriksa kode asm yang dihasilkan menggunakan godbolt.org saya menemukan bahwa contoh pertama menghasilkan lompatan, sedangkan yang kedua tidak. Saya memutuskan untuk mencoba GCC 6.2.0 yang juga menghasilkan instruksi lompat ketika menggunakan contoh pertama, tetapi GCC 7 tampaknya tidak menghasilkan satu lagi.
Menemukan cara untuk mempercepat kode ini agak mengerikan dan perlu waktu. Mengapa kompiler berperilaku seperti ini? Apakah ini dimaksudkan dan apakah itu sesuatu yang harus diperhatikan oleh programmer? Apakah ada hal lain yang serupa dengan ini?
EDIT: tautan ke godbolt https://godbolt.org/g/5lKPF3
&&
penyebab ini.&
.Jawaban:
Operator AND logis (
&&
) menggunakan evaluasi hubung singkat, yang berarti bahwa pengujian kedua hanya dilakukan jika perbandingan pertama bernilai true. Ini seringkali merupakan semantik yang Anda butuhkan. Misalnya, pertimbangkan kode berikut:Anda harus memastikan bahwa pointer tidak nol sebelum Anda mengubahnya. Jika ini bukan evaluasi hubung singkat, Anda akan memiliki perilaku yang tidak terdefinisi karena Anda akan mendereferensi null pointer.
Mungkin juga bahwa evaluasi hubung singkat menghasilkan kenaikan kinerja dalam kasus di mana evaluasi kondisi merupakan proses yang mahal. Sebagai contoh:
Jika
DoLengthyCheck1
gagal, tidak ada gunanya meneleponDoLengthyCheck2
.Namun, dalam biner yang dihasilkan, operasi hubung singkat sering menghasilkan dua cabang, karena ini adalah cara termudah bagi kompiler untuk melestarikan semantik ini. (Itulah sebabnya, di sisi lain dari koin, evaluasi hubung singkat terkadang dapat menghambat potensi optimisasi.) Anda dapat melihat ini dengan melihat bagian relevan dari kode objek yang dihasilkan untuk
if
pernyataan Anda oleh GCC 5.4:Anda lihat di sini dua perbandingan (
cmp
instruksi) di sini, masing-masing diikuti oleh lompatan bersyarat / cabang yang terpisah (ja
, atau lompat jika di atas).Ini adalah aturan umum bahwa cabang lambat dan karenanya harus dihindari dalam loop ketat. Ini benar pada hampir semua prosesor x86, dari 8088 yang sederhana (yang mengambil waktu lambat dan antrian prefetch sangat kecil [sebanding dengan cache instruksi], dikombinasikan dengan kurangnya prediksi cabang, berarti cabang yang diambil memerlukan cache untuk dibuang ) untuk implementasi modern (yang saluran pipa panjangnya membuat cabang yang salah duga sama mahal). Perhatikan peringatan kecil yang saya selipkan di sana. Prosesor modern sejak Pentium Pro memiliki mesin prediksi cabang canggih yang dirancang untuk meminimalkan biaya cabang. Jika arah cabang dapat diprediksi dengan benar, biayanya minimal. Sebagian besar waktu, ini bekerja dengan baik, tetapi jika Anda masuk ke kasus patologis di mana prediktor cabang tidak ada di pihak Anda,kode Anda bisa sangat lambat . Ini mungkin di mana Anda berada di sini, karena Anda mengatakan bahwa array Anda tidak disortir.
Anda mengatakan bahwa tolok ukur mengonfirmasi bahwa mengganti
&&
dengan a*
membuat kode terasa lebih cepat. Alasannya jelas ketika kita membandingkan bagian yang relevan dari kode objek:Agak kontra-intuitif bahwa ini bisa lebih cepat, karena ada lebih banyak instruksi di sini, tapi itulah cara optimasi kadang-kadang bekerja. Anda melihat perbandingan yang sama (
cmp
) dilakukan di sini, tetapi sekarang, masing-masing didahului olehxor
dan diikuti oleh asetbe
. XOR hanyalah trik standar untuk membersihkan register. Inisetbe
adalah instruksi x86 yang menetapkan sedikit berdasarkan nilai flag, dan sering digunakan untuk mengimplementasikan kode branchless. Di sini,setbe
adalah kebalikan darija
. Ini menetapkan register tujuan menjadi 1 jika perbandingannya di bawah-atau-sama (karena register adalah pra-nol, itu akan menjadi 0 sebaliknya), sedangkanja
bercabang jika perbandingan di atas. Setelah dua nilai ini telah diperoleh dir15b
danr14b
register, mereka dikalikan bersama menggunakanimul
. Perkalian secara tradisional merupakan operasi yang relatif lambat, tetapi sangat cepat pada prosesor modern, dan ini akan sangat cepat, karena itu hanya mengalikan nilai-nilai berukuran dua byte.Anda bisa dengan mudah mengganti perkalian dengan operator AND bitwise (
&
), yang tidak melakukan evaluasi hubung singkat. Ini membuat kode lebih jelas, dan merupakan pola yang umumnya dikenali oleh kompiler. Tetapi ketika Anda melakukan ini dengan kode Anda dan kompilasi dengan GCC 5.4, itu terus memancarkan cabang pertama:Tidak ada alasan teknis untuk mengeluarkan kode dengan cara ini, tetapi untuk beberapa alasan, heuristik internal mengatakan bahwa ini lebih cepat. Ini akan mungkin akan lebih cepat jika prediktor cabang berada di sisi Anda, tapi kemungkinan akan lebih lambat jika prediksi cabang gagal lebih sering daripada itu berhasil.
Generasi yang lebih baru dari kompiler (dan kompiler lain, seperti Dentang) mengetahui aturan ini, dan kadang-kadang akan menggunakannya untuk menghasilkan kode yang sama yang Anda inginkan dengan mengoptimalkan tangan. Saya secara teratur melihat dentang menerjemahkan
&&
ekspresi ke kode yang sama yang akan dikeluarkan jika saya menggunakannya&
. Berikut ini adalah output yang relevan dari GCC 6.2 dengan kode Anda menggunakan&&
operator normal :Perhatikan betapa cerdiknya ini ! Itu menggunakan kondisi yang ditandatangani (
jg
dansetle
) sebagai lawan dari kondisi yang tidak ditandatangani (ja
dansetbe
), tetapi ini tidak penting. Anda dapat melihat bahwa itu masih melakukan perbandingan-dan-cabang untuk kondisi pertama seperti versi yang lebih lama, dan menggunakansetCC
instruksi yang sama untuk menghasilkan kode branchless untuk kondisi kedua, tetapi telah menjadi jauh lebih efisien dalam bagaimana ia melakukan peningkatan. . Alih-alih melakukan perbandingan kedua yang berlebihan untuk mengatur flag untuksbb
operasi, ia menggunakan pengetahuan yangr14d
akan menjadi 1 atau 0 untuk hanya menambahkan nilai ini tanpa syaratnontopOverlap
. Jikar14d
0, maka tambahannya adalah no-op; jika tidak, ia menambahkan 1, persis seperti yang seharusnya dilakukan.GCC 6.2 sebenarnya menghasilkan kode yang lebih efisien ketika Anda menggunakan
&&
operator hubung singkat daripada&
operator bitwise :Cabang dan himpunan bersyarat masih ada di sana, tetapi sekarang kembali ke cara penambahan yang kurang cerdas
nontopOverlap
. Ini adalah pelajaran penting mengapa Anda harus berhati-hati ketika mencoba mengompilasi kompiler Anda!Tetapi jika Anda dapat membuktikan dengan tolok ukur bahwa kode percabangan sebenarnya lebih lambat, maka mungkin membayar untuk mencoba dan mengompilasi kompiler Anda. Anda hanya perlu melakukannya dengan inspeksi yang cermat terhadap pembongkaran — dan bersiaplah untuk mengevaluasi kembali keputusan Anda ketika Anda meningkatkan ke versi kompiler yang lebih baru. Misalnya, kode yang Anda miliki dapat ditulis ulang sebagai:
Tidak ada
if
pernyataan di sini sama sekali, dan sebagian besar kompiler tidak akan pernah berpikir tentang memancarkan kode cabang untuk ini. GCC tidak terkecuali; semua versi menghasilkan sesuatu yang mirip dengan yang berikut:Jika Anda mengikuti contoh-contoh sebelumnya, ini akan terlihat sangat familier bagi Anda. Kedua perbandingan dilakukan dengan cara tanpa cabang, hasil antara
and
disunting bersama-sama, dan kemudian hasil ini (yang akan 0 atau 1)add
diedit kenontopOverlap
. Jika Anda menginginkan kode tanpa cabang, ini akan memastikan Anda mendapatkannya.GCC 7 menjadi semakin pintar. Sekarang menghasilkan kode yang hampir identik (kecuali beberapa sedikit penataan ulang instruksi) untuk trik di atas sebagai kode asli. Jadi, jawaban untuk pertanyaan Anda, "Mengapa kompiler berperilaku seperti ini?" , mungkin karena mereka tidak sempurna! Mereka mencoba menggunakan heuristik untuk menghasilkan kode seoptimal mungkin, tetapi mereka tidak selalu membuat keputusan terbaik. Tapi setidaknya mereka bisa menjadi lebih pintar dari waktu ke waktu!
Salah satu cara untuk melihat situasi ini adalah bahwa kode cabang memiliki kinerja kasus terbaik yang lebih baik . Jika prediksi cabang berhasil, melompati operasi yang tidak perlu akan menghasilkan waktu berjalan yang sedikit lebih cepat. Namun, kode branchless memiliki kinerja kasus terburuk yang lebih baik . Jika prediksi cabang gagal, jalankan beberapa instruksi tambahan seperlunya untuk menghindari cabang pasti akan lebih cepat daripada cabang yang salah prediksi . Bahkan kompiler yang paling pandai dan pandai pun akan kesulitan menentukan pilihan ini.
Dan untuk pertanyaan Anda tentang apakah ini sesuatu yang harus diperhatikan oleh programmer, jawabannya hampir pasti tidak, kecuali dalam putaran panas tertentu yang Anda coba percepat melalui optimasi mikro. Kemudian, Anda duduk dengan pembongkaran dan menemukan cara untuk mengubahnya. Dan, seperti yang saya katakan sebelumnya, bersiaplah untuk meninjau kembali keputusan tersebut ketika Anda memperbarui ke versi yang lebih baru dari kompiler, karena ia dapat melakukan sesuatu yang bodoh dengan kode rumit Anda, atau mungkin telah mengubah heuristik optimasinya cukup sehingga Anda dapat kembali untuk menggunakan kode asli Anda. Komentari dengan saksama!
sumber
j*
instruksi), jadi akan lebih cepat dalam hal ini. [lanjutan]Satu hal penting yang perlu diperhatikan adalah itu
dan
tidak setara secara semantik! Secara khusus, jika Anda pernah memiliki situasi di mana:
0 <= i
dani < curr.size()
keduanya benarcurr[i] < 479
itu salahi + shift < 0
ataui + shift >= l.size()
itu benarmaka ekspresi
(curr[i] < 479) && (l[i + shift] < 479)
dijamin menjadi nilai boolean yang terdefinisi dengan baik. Misalnya, itu tidak menyebabkan kesalahan segmentasi.Namun, dalam keadaan ini, ekspresi
(curr[i] < 479) * (l[i + shift] < 479)
adalah perilaku yang tidak terdefinisi ; itu adalah diperbolehkan untuk menyebabkan kesalahan segmentasi.Ini berarti bahwa untuk cuplikan kode asli, misalnya, kompiler tidak bisa hanya menulis loop yang melakukan kedua perbandingan dan melakukan
and
operasi, kecuali jika kompiler juga dapat membuktikan bahwal[i + shift]
tidak akan pernah menyebabkan segfault dalam situasi yang diharuskan untuk tidak dilakukan.Singkatnya, potongan kode asli menawarkan lebih sedikit peluang untuk optimasi daripada yang terakhir. (tentu saja, apakah kompiler mengenali peluang atau tidak adalah pertanyaan yang sama sekali berbeda)
Anda mungkin memperbaiki versi aslinya dengan melakukan
sumber
shift
(danmax
) ada UB di sini ...The
&&
Operator mengimplementasikan hubungan arus pendek evaluasi. Ini berarti bahwa operan kedua hanya dievaluasi jika yang pertama dievaluasitrue
. Ini tentu saja menghasilkan lompatan dalam kasus itu.Anda dapat membuat contoh kecil untuk menunjukkan ini:
Output assembler dapat ditemukan di sini .
Anda dapat melihat kode yang dihasilkan panggilan pertama
f(x)
, kemudian memeriksa output dan melompat ke evaluasig(x)
kapan initrue
. Kalau tidak, ia meninggalkan fungsinya.Menggunakan perkalian "boolean" sebagai gantinya memaksa evaluasi dari kedua operan setiap kali dan dengan demikian tidak perlu melompat.
Bergantung pada data, lompatan dapat menyebabkan perlambatan karena mengganggu jalur pipa CPU dan hal-hal lain seperti eksekusi spekulatif. Biasanya prediksi cabang membantu, tetapi jika data Anda acak, tidak banyak yang dapat diprediksi.
sumber
&&
operator, penggandaan dapat dievaluasi dengan malas baik dengan argumen pertama atau dengan argumen kedua, memungkinkan lebih banyak kebebasan untuk optimasi.0 * f()
danf
memiliki perilaku yang dapat diamati, kompiler harus memanggilnya. Perbedaannya adalah bahwa evaluasi hubung singkat adalah wajib untuk&&
tetapi diizinkan jika dapat menunjukkan bahwa itu setara untuk*
.Ini mungkin karena ketika Anda menggunakan operator logis
&&
, kompiler harus memeriksa dua kondisi agar pernyataan if berhasil. Namun dalam kasus kedua karena Anda secara implisit mengkonversi nilai int ke bool, kompiler membuat beberapa asumsi berdasarkan jenis dan nilai yang diteruskan, bersama dengan (mungkin) kondisi lompatan tunggal. Mungkin juga bahwa kompiler sepenuhnya mengoptimalkan jmps dengan sedikit perubahan.sumber