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:
if ((p != nullptr) && (p->first > 0))
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:
if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))
Jika DoLengthyCheck1
gagal, tidak ada gunanya menelepon DoLengthyCheck2
.
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:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13w, 478 ; (curr[i] < 479)
ja .L5
cmp ax, 478 ; (l[i + shift] < 479)
ja .L5
add r8d, 1 ; nontopOverlap++
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:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
xor r15d, r15d ; (curr[i] < 479)
cmp r13w, 478
setbe r15b
xor r14d, r14d ; (l[i + shift] < 479)
cmp ax, 478
setbe r14b
imul r14d, r15d ; meld results of the two comparisons
cmp r14d, 1 ; nontopOverlap++
sbb r8d, -1
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 oleh xor
dan diikuti oleh a setbe
. XOR hanyalah trik standar untuk membersihkan register. Ini setbe
adalah instruksi x86 yang menetapkan sedikit berdasarkan nilai flag, dan sering digunakan untuk mengimplementasikan kode branchless. Di sini, setbe
adalah kebalikan dari ja
. Ini menetapkan register tujuan menjadi 1 jika perbandingannya di bawah-atau-sama (karena register adalah pra-nol, itu akan menjadi 0 sebaliknya), sedangkan ja
bercabang jika perbandingan di atas. Setelah dua nilai ini telah diperoleh di r15b
danr14b
register, mereka dikalikan bersama menggunakan imul
. 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:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13w, 478 ; (curr[i] < 479)
ja .L4
cmp ax, 478 ; (l[i + shift] < 479)
setbe r14b
cmp r14d, 1 ; nontopOverlap++
sbb r8d, -1
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 :
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13d, 478 ; (curr[i] < 479)
jg .L7
xor r14d, r14d ; (l[i + shift] < 479)
cmp eax, 478
setle r14b
add esi, r14d ; nontopOverlap++
Perhatikan betapa cerdiknya ini ! Itu menggunakan kondisi yang ditandatangani ( jg
dan setle
) sebagai lawan dari kondisi yang tidak ditandatangani ( ja
dan setbe
), tetapi ini tidak penting. Anda dapat melihat bahwa itu masih melakukan perbandingan-dan-cabang untuk kondisi pertama seperti versi yang lebih lama, dan menggunakan setCC
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 untuk sbb
operasi, ia menggunakan pengetahuan yang r14d
akan menjadi 1 atau 0 untuk hanya menambahkan nilai ini tanpa syarat nontopOverlap
. Jika r14d
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 :
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13d, 478 ; (curr[i] < 479)
jg .L6
cmp eax, 478 ; (l[i + shift] < 479)
setle r14b
cmp r14b, 1 ; nontopOverlap++
sbb esi, -1
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:
nontopOverlap += ((curr[i] < 479) & (l[i + shift] < 479));
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:
movzx r14d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r14d, 478 ; (curr[i] < 479)
setle r15b
xor r13d, r13d ; (l[i + shift] < 479)
cmp eax, 478
setle r13b
and r13d, r15d ; meld results of the two comparisons
add esi, r13d ; nontopOverlap++
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 ke nontopOverlap
. 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!