Mengapa kompiler bersikeras menggunakan register yang diselamatkan oleh callee di sini?

10

Pertimbangkan kode C ini:

void foo(void);

long bar(long x) {
    foo();
    return x;
}

Ketika saya mengompilasinya di GCC 9.3 dengan salah satu -O3atau -Os, saya mendapatkan ini:

bar:
        push    r12
        mov     r12, rdi
        call    foo
        mov     rax, r12
        pop     r12
        ret

Output dari dentang identik kecuali untuk memilih rbxbukan r12sebagai callee-save register.

Namun, saya ingin / berharap melihat perakitan yang lebih mirip ini:

bar:
        push    rdi
        call    foo
        pop     rax
        ret

Dalam bahasa Inggris, inilah yang saya lihat terjadi:

  • Dorong nilai lama register yang disimpan dengan callee ke tumpukan
  • Pindah xke register yang disimpan dengan callee
  • Panggilan foo
  • Pindah xdari register yang disimpan dengan callee ke register nilai-kembali
  • Pop stack untuk mengembalikan nilai lama register yang disimpan dengan callee

Mengapa repot-repot mengacaukan register yang diselamatkan oleh callee? Kenapa tidak melakukan ini saja? Tampaknya lebih pendek, lebih sederhana, dan mungkin lebih cepat:

  • Dorong xke tumpukan
  • Panggilan foo
  • Pop xdari tumpukan ke register nilai-kembali

Apakah majelis saya salah? Apakah itu entah bagaimana kurang efisien daripada mengacaukan register tambahan? Jika jawaban untuk keduanya adalah "tidak", mengapa tidak GCC atau dentang melakukannya dengan cara ini?

Tautan Godbolt .


Sunting: Ini adalah contoh yang kurang sepele, untuk menunjukkan itu terjadi bahkan jika variabel digunakan secara bermakna:

long foo(long);

long bar(long x) {
    return foo(x * x) - x;
}

Saya mendapatkan ini:

bar:
        push    rbx
        mov     rbx, rdi
        imul    rdi, rdi
        call    foo
        sub     rax, rbx
        pop     rbx
        ret

Saya lebih suka memiliki ini:

bar:
        push    rdi
        imul    rdi, rdi
        call    foo
        pop     rdi
        sub     rax, rdi
        ret

Kali ini, itu hanya satu instruksi off vs dua, tetapi konsep intinya sama.

Tautan Godbolt .

Joseph Sible-Reinstate Monica
sumber
4
Pengoptimalan yang terlewatkan dan menarik.
fuz
1
kemungkinan besar asumsi bahwa parameter yang lewat akan digunakan sehingga Anda ingin menyimpan register yang tidak stabil dan menyimpan parameter yang lewat dalam register bukan di stack karena akses selanjutnya ke parameter tersebut lebih cepat dari register. lewat x ke foo dan Anda akan melihat ini. sehingga kemungkinan hanya bagian generik dari pengaturan bingkai tumpukan mereka.
old_timer
diberikan saya memang melihat bahwa tanpa foo itu tidak menggunakan stack, jadi ya itu adalah optimasi yang terlewat tetapi sesuatu seseorang perlu menambahkan, menganalisis fungsi dan jika nilainya tidak digunakan dan tidak ada konflik dengan register itu (umumnya ada adalah).
old_timer
backend lengan melakukan ini juga pada gcc. jadi kemungkinan bukan backend
old_timer
dentang 10 cerita yang sama (arm backend).
old_timer

Jawaban:

5

TL: DR:

  • Internal penyusun mungkin tidak diatur untuk mencari optimasi ini dengan mudah, dan itu mungkin hanya berguna di sekitar fungsi kecil, tidak di dalam fungsi besar di antara panggilan.
  • Inlining untuk membuat fungsi besar adalah solusi yang lebih baik sebagian besar waktu
  • Mungkin ada tradeoff latensi vs throughput jika fookebetulan tidak menyimpan / mengembalikan RBX.

Kompiler adalah bagian mesin yang kompleks. Mereka tidak "pintar" seperti manusia, dan algoritma mahal untuk menemukan setiap optimasi yang mungkin sering tidak sebanding dengan biaya dalam waktu kompilasi tambahan.

Saya melaporkan ini sebagai bug GCC 69986 - kode yang lebih kecil mungkin dengan -O dengan menggunakan push / pop untuk menumpahkan / memuat kembali pada tahun 2016 ; tidak ada aktivitas atau balasan dari dev GCC. : /

Sedikit terkait: bug GCC 70408 - menggunakan kembali register yang dipelihara dengan panggilan yang sama akan memberikan kode yang lebih kecil dalam beberapa kasus - dev compiler mengatakan kepada saya bahwa akan membutuhkan banyak pekerjaan agar GCC dapat melakukan optimasi itu karena memerlukan pemilihan urutan evaluasi dari dua foo(int)panggilan berdasarkan apa yang akan membuat target ASM lebih sederhana.


Jika foo tidak menyimpan / mengembalikan rbxsendiri, ada tradeoff antara throughput (jumlah instruksi) vs latensi penyimpanan / pemuatan ekstra pada x-> rantai ketergantungan retval.

Kompiler biasanya lebih menyukai latensi daripada throughput, misalnya menggunakan 2x LEA alih-alih imul reg, reg, 10(latensi 3-siklus, throughput 1 / jam), karena sebagian besar kode rata-rata secara signifikan kurang dari 4 uops / jam pada pipa-pipa lebar 4 khas seperti Skylake. (Lebih banyak instruksi / uops memang mengambil lebih banyak ruang di ROB, mengurangi seberapa jauh ke depan jendela out-of-order yang sama dapat melihat, meskipun, dan eksekusi sebenarnya penuh dengan kios-kios yang mungkin bertanggung jawab atas beberapa kurang dari 4 uops / jam rata-rata.)

Jika foomemang push / pop RBX, maka tidak ada banyak keuntungan untuk latensi. Memiliki pemulihan terjadi sesaat sebelum retganti setelah mungkin mungkin tidak relevan, kecuali ada kesalahan retprediksi atau I-cache yang menunda mengambil kode di alamat pengirim.

Sebagian besar fungsi non-sepele akan menyimpan / mengembalikan RBX, jadi sering kali bukan asumsi yang baik bahwa meninggalkan variabel dalam RBX sebenarnya berarti ia benar-benar tetap berada dalam register di seluruh panggilan. (Meskipun secara acak memilih fungsi register yang diawetkan dengan panggilan mungkin merupakan ide yang baik untuk mengurangi hal ini kadang-kadang.)


Jadi ya push rdi/ pop raxakan lebih efisien dalam hal ini, dan ini mungkin merupakan optimasi yang terlewatkan untuk fungsi kecil non-daun, tergantung pada apa yang foodilakukan dan keseimbangan antara latensi penyimpanan / pemuatan ekstra untuk xvs. lebih banyak instruksi untuk menyimpan / memulihkan pemanggil rbx.

Dimungkinkan untuk tumpukan-bersantai metadata untuk mewakili perubahan ke RSP di sini, sama seperti jika itu digunakan sub rsp, 8untuk menumpahkan / memuat kembali xke slot tumpukan. (Tapi kompiler juga tidak tahu optimasi ini, menggunakan pushuntuk memesan ruang dan menginisialisasi variabel. Apa kompiler C / C ++ dapat menggunakan instruksi pop untuk membuat variabel lokal, bukan hanya meningkatkan esp sekali? Dan melakukan itu selama lebih dari satu var lokal akan mengarah ke .eh_framemetadata pelepasan tumpukan yang lebih besar karena Anda menggerakkan penunjuk tumpukan secara terpisah dengan setiap dorongan. Itu tidak menghentikan kompiler menggunakan push / pop untuk menyimpan / mengembalikan reg yang diawetkan dengan panggilan.)


IDK jika perlu mengajar kompiler untuk mencari optimasi ini

Mungkin ide yang bagus di seluruh fungsi, bukan di satu panggilan di dalam fungsi. Dan seperti yang saya katakan, ini didasarkan pada asumsi pesimistis yang fooakan menyelamatkan / mengembalikan RBX. (Atau mengoptimalkan untuk throughput jika Anda tahu bahwa latensi dari x ke nilai kembali tidak penting. Tetapi kompiler tidak tahu itu dan biasanya mengoptimalkan latensi).

Jika Anda mulai membuat asumsi pesimistis dalam banyak kode (seperti sekitar fungsi panggilan dalam fungsi), Anda akan mulai mendapatkan lebih banyak kasus di mana RBX tidak disimpan / dipulihkan dan Anda bisa mengambil keuntungan.

Anda juga tidak ingin ini ekstra simpan / kembalikan push / pop dalam satu loop, cukup simpan / pulihkan RBX di luar loop dan gunakan register yang diawetkan dengan panggilan dalam loop yang membuat panggilan fungsi. Bahkan tanpa loop, dalam kasus umum sebagian besar fungsi membuat beberapa panggilan fungsi. Gagasan pengoptimalan ini dapat berlaku jika Anda benar-benar tidak menggunakan di xantara panggilan mana pun, tepat sebelum yang pertama dan setelah yang terakhir, jika tidak , Anda memiliki masalah dalam mempertahankan penyelarasan tumpukan 16-byte untuk masing-masing calljika Anda melakukan satu sembulan setelah panggilan, sebelum panggilan lain.

Kompiler tidak hebat dalam fungsi kecil pada umumnya. Tapi itu tidak bagus untuk CPU juga. Panggilan fungsi non-inline berdampak pada optimalisasi pada saat terbaik, kecuali jika kompiler dapat melihat internal callee dan membuat lebih banyak asumsi dari biasanya. Panggilan fungsi non-inline adalah penghalang memori implisit: pemanggil harus berasumsi bahwa suatu fungsi dapat membaca atau menulis data yang dapat diakses secara global, sehingga semua vars harus disinkronkan dengan mesin abstrak C. (Analisis Escape memungkinkan menjaga penduduk lokal dalam register di seluruh panggilan jika alamat mereka tidak lolos dari fungsi.) Selain itu, kompiler harus mengasumsikan bahwa register panggilan-clobbered semua musnah. Ini menyebalkan untuk floating point di x86-64 Sistem V, yang tidak memiliki register XMM yang diawetkan dengan panggilan.

Fungsi kecil seperti bar()lebih baik masuk ke penelepon mereka. Kompilasi dengan -fltoini dapat terjadi bahkan melintasi batas file dalam banyak kasus. (Fungsi pointer dan batas shared-library dapat mengalahkan ini.)


Saya pikir salah satu alasan kompiler tidak repot-repot mencoba melakukan optimasi ini adalah bahwa itu akan memerlukan sejumlah kode yang berbeda di internal kompiler , berbeda dari stack normal vs kode alokasi-alokasi yang tahu bagaimana cara menyimpan panggilan yang diawetkan mendaftar dan menggunakannya.

yaitu akan banyak pekerjaan untuk diimplementasikan, dan banyak kode untuk dipelihara, dan jika terlalu bersemangat melakukan hal ini bisa membuat kode lebih buruk .

Dan juga itu (semoga) tidak signifikan; jika itu penting, Anda harus masuk barke pemanggilnya, atau foomasuk ke dalam bar. Ini baik-baik saja kecuali ada banyak barfungsi-fungsi yang berbeda dan foobesar, dan untuk beberapa alasan mereka tidak dapat berbaris ke penelepon mereka.

Peter Cordes
sumber
tidak yakin ada akal bertanya mengapa beberapa kompiler menerjemahkan kode seperti itu, kapan mungkin lebih baik digunakan .., jika tidak kesalahan dalam terjemahan. misalnya mungkin bertanya mengapa dentang sangat aneh (tidak dioptimalkan) diterjemahkan loop ini , dibandingkan dengan gcc, icc dan bahkan msvc
RbMm
1
@RbMm: Saya tidak mengerti maksud Anda. Itu terlihat seperti optimasi terjawab yang sama sekali terpisah untuk dentang, tidak terkait dengan apa pertanyaan ini. Ada bug optimasi yang terlewatkan, dan dalam banyak kasus harus diperbaiki. Silakan melaporkannya di bugs.llvm.org
Peter Cordes
ya, contoh kode saya mutlak tidak terkait dengan pertanyaan awal. hanyalah contoh lain dari terjemahan yang aneh (untuk tampilan saya) (dan hanya untuk satu kompiler dentang tunggal). tetapi kode asm hasil tetap benar. hanya tidak terbaik dan bahkan tidak asli membandingkan gcc / icc / msvc
RbMm