Lompatan mahal dengan GCC 5.4.0

171

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

Jakub Jůza
sumber
17
Mengapa kompiler berperilaku seperti ini? Compiler dapat melakukan apa yang dia mau, selama kode yang dihasilkan benar. Beberapa kompiler hanya lebih baik dalam optimasi daripada yang lain.
Jabberwocky
26
Dugaan saya adalah bahwa evaluasi hubung singkat &&penyebab ini.
Jens
9
Perhatikan bahwa inilah sebabnya kami juga memilikinya &.
rubenvb
7
@ Yakub menyortir itu kemungkinan besar akan meningkatkan kecepatan eksekusi, lihat pertanyaan ini .
rubenvb
8
@rubenvb "tidak boleh dievaluasi" sebenarnya tidak berarti apa-apa untuk ekspresi yang tidak memiliki efek samping. Saya menduga bahwa vektor melakukan pengecekan batas dan bahwa GCC tidak dapat membuktikannya tidak akan keluar batas. EDIT: Sebenarnya, saya tidak berpikir Anda sedang melakukan sesuatu untuk menghentikan saya shift + dari yang di luar batas.
Random832

Jawaban:

263

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 DoLengthyCheck1gagal, 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 ifpernyataan 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 ( cmpinstruksi) 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 xordan diikuti oleh a setbe. XOR hanyalah trik standar untuk membersihkan register. Ini setbeadalah instruksi x86 yang menetapkan sedikit berdasarkan nilai flag, dan sering digunakan untuk mengimplementasikan kode branchless. Di sini, setbeadalah 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 jabercabang jika perbandingan di atas. Setelah dua nilai ini telah diperoleh di r15bdanr14bregister, 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 ( jgdan setle) sebagai lawan dari kondisi yang tidak ditandatangani ( jadan 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 setCCinstruksi 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 sbboperasi, ia menggunakan pengetahuan yang r14dakan menjadi 1 atau 0 untuk hanya menambahkan nilai ini tanpa syarat nontopOverlap. Jika r14d0, 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 ifpernyataan 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 anddisunting bersama-sama, dan kemudian hasil ini (yang akan 0 atau 1) adddiedit 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!

Cody Grey
sumber
3
Yah, tidak ada yang universal "lebih baik". Itu semua tergantung pada situasi Anda, itulah mengapa Anda benar-benar harus melakukan benchmark ketika Anda melakukan optimasi kinerja tingkat rendah semacam ini. Seperti yang saya jelaskan dalam jawaban, jika Anda pada ukuran kehilangan prediksi cabang, cabang mispredicted akan memperlambat kode Anda turun banyak . Bit kode terakhir tidak menggunakan cabang apa pun (perhatikan tidak adanya j*instruksi), jadi akan lebih cepat dalam hal ini. [lanjutan]
Cody Gray
2
@ 8bit Bob benar. Saya merujuk ke antrean prefetch. Saya mungkin seharusnya tidak menyebutnya cache, tetapi tidak terlalu khawatir tentang ungkapan dan tidak menghabiskan waktu terlalu lama untuk mencoba mengingat secara spesifik, karena saya tidak menemukan orang yang terlalu peduli kecuali untuk keingintahuan historis. Jika Anda menginginkan detail, Zen Bahasa Sidang Michael Abrash sangat berharga. Seluruh buku tersedia berbagai tempat online; inilah bagian yang berlaku untuk percabangan , tetapi Anda harus membaca dan memahami bagian-bagian tentang pengambilan awal juga.
Cody Gray
6
@Hurkyl Saya merasa seluruh jawaban berbicara untuk pertanyaan itu. Anda benar bahwa saya tidak benar-benar menyebutnya secara eksplisit, tetapi sepertinya sudah cukup lama. :-) Siapa pun yang meluangkan waktu untuk membaca semuanya harus mendapatkan pemahaman yang cukup tentang hal itu. Tetapi jika Anda berpikir ada sesuatu yang hilang, atau perlu klarifikasi lebih lanjut, jangan malu-malu mengedit jawaban untuk memasukkannya. Beberapa orang tidak suka ini, tetapi saya sama sekali tidak keberatan. Saya menambahkan komentar singkat tentang ini, bersama dengan modifikasi kata-kata saya seperti yang disarankan oleh 8bittree.
Cody Gray
2
Hah, terima kasih untuk komplemennya, @green. Saya tidak memiliki sesuatu yang spesifik untuk disarankan. Seperti halnya segalanya, Anda menjadi ahli dengan melakukan, melihat, dan mengalami. Saya sudah membaca semua yang bisa saya dapatkan ketika membahas arsitektur x86, optimisasi, internal kompiler, dan hal-hal tingkat rendah lainnya, dan saya masih tahu hanya sebagian kecil dari semua yang perlu diketahui. Cara terbaik untuk belajar adalah membuat tangan Anda kotor. Tetapi sebelum Anda bahkan dapat berharap untuk memulai, Anda akan membutuhkan pemahaman yang kuat tentang C (atau C ++), pointer, bahasa assembly, dan semua fundamental tingkat rendah lainnya.
Cody Gray
23

Satu hal penting yang perlu diperhatikan adalah itu

(curr[i] < 479) && (l[i + shift] < 479)

dan

(curr[i] < 479) * (l[i + shift] < 479)

tidak setara secara semantik! Secara khusus, jika Anda pernah memiliki situasi di mana:

  • 0 <= idan i < curr.size()keduanya benar
  • curr[i] < 479 itu salah
  • i + shift < 0atau i + shift >= l.size()itu benar

maka 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 andoperasi, kecuali jika kompiler juga dapat membuktikan bahwa l[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

bool t1 = (curr[i] < 479);
bool t2 = (l[i + shift] < 479);
if (t1 && t2) {
    // ...

sumber
Ini! Bergantung pada nilai shift(dan max) ada UB di sini ...
Matthieu M.
18

The &&Operator mengimplementasikan hubungan arus pendek evaluasi. Ini berarti bahwa operan kedua hanya dievaluasi jika yang pertama dievaluasi true. Ini tentu saja menghasilkan lompatan dalam kasus itu.

Anda dapat membuat contoh kecil untuk menunjukkan ini:

#include <iostream>

bool f(int);
bool g(int);

void test(int x, int y)
{
  if ( f(x) && g(x)  )
  {
    std::cout << "ok";
  }
}

Output assembler dapat ditemukan di sini .

Anda dapat melihat kode yang dihasilkan panggilan pertama f(x), kemudian memeriksa output dan melompat ke evaluasi g(x)kapan ini true. 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.

Jens
sumber
1
Mengapa Anda menyatakan bahwa multiplikasi memaksa evaluasi kedua operan setiap waktu? 0 * x = x * 0 = 0 terlepas dari nilai x. Sebagai pengoptimalan, kompiler juga dapat "membuat shortcircuit" perkalian. Lihat stackoverflow.com/questions/8145894/… , misalnya. Selain itu, tidak seperti &&operator, penggandaan dapat dievaluasi dengan malas baik dengan argumen pertama atau dengan argumen kedua, memungkinkan lebih banyak kebebasan untuk optimasi.
SomeWittyUsername
@Jens - "Biasanya prediksi cabang membantu, tetapi jika data Anda acak, tidak banyak yang dapat diprediksi." - membuat jawaban yang bagus.
SChepurin
1
@SomeWittyUsername Ok, kompiler ini tentu saja bebas melakukan optimasi yang menjaga perilaku yang dapat diamati. Ini mungkin atau tidak mungkin mentransformasikannya dan mengabaikan perhitungan. jika Anda menghitung 0 * f()dan fmemiliki 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 *.
Jens
@SomeWittyUsername hanya dalam kasus-kasus dimana nilai 0 dapat diprediksi dari variabel atau konstan. Saya kira kasus-kasus ini sangat sedikit. Tentu saja optimasi tidak dapat dilakukan dalam kasus OP, karena akses array terlibat.
Diego Sevilla
3
@ Jens: Evaluasi hubung singkat tidak wajib. Kode hanya diperlukan untuk berperilaku seolah-olah terjadi hubungan pendek; kompiler diperbolehkan menggunakan cara apa pun yang diinginkannya untuk mencapai hasil.
-2

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.

crezefire
sumber
8
Lompatan ini berasal dari kenyataan bahwa kondisi kedua dievaluasi jika dan hanya jika yang pertama benar. Kode tidak boleh mengevaluasi itu sebaliknya, maka kompiler tidak dapat mengoptimalkan ini lebih baik dan masih benar (kecuali bisa menyimpulkan pernyataan pertama akan selalu benar).
rubenvb