Pertimbangkan kode C ini:
void foo(void);
long bar(long x) {
foo();
return x;
}
Ketika saya mengompilasinya di GCC 9.3 dengan salah satu -O3
atau -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 rbx
bukan r12
sebagai 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
x
ke register yang disimpan dengan callee - Panggilan
foo
- Pindah
x
dari 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
x
ke tumpukan - Panggilan
foo
- Pop
x
dari 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?
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.
Jawaban:
TL: DR:
foo
kebetulan 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 / mengembalikanrbx
sendiri, ada tradeoff antara throughput (jumlah instruksi) vs latensi penyimpanan / pemuatan ekstra padax
-> 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
foo
memang push / pop RBX, maka tidak ada banyak keuntungan untuk latensi. Memiliki pemulihan terjadi sesaat sebelumret
ganti setelah mungkin mungkin tidak relevan, kecuali ada kesalahanret
prediksi 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 rax
akan lebih efisien dalam hal ini, dan ini mungkin merupakan optimasi yang terlewatkan untuk fungsi kecil non-daun, tergantung pada apa yangfoo
dilakukan dan keseimbangan antara latensi penyimpanan / pemuatan ekstra untukx
vs. lebih banyak instruksi untuk menyimpan / memulihkan pemanggilrbx
.Dimungkinkan untuk tumpukan-bersantai metadata untuk mewakili perubahan ke RSP di sini, sama seperti jika itu digunakan
sub rsp, 8
untuk menumpahkan / memuat kembalix
ke slot tumpukan. (Tapi kompiler juga tidak tahu optimasi ini, menggunakanpush
untuk 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_frame
metadata 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
foo
akan 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
x
antara panggilan mana pun, tepat sebelum yang pertama dan setelah yang terakhir, jika tidak , Anda memiliki masalah dalam mempertahankan penyelarasan tumpukan 16-byte untuk masing-masingcall
jika 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-flto
ini 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
bar
ke pemanggilnya, ataufoo
masuk ke dalambar
. Ini baik-baik saja kecuali ada banyakbar
fungsi-fungsi yang berbeda danfoo
besar, dan untuk beberapa alasan mereka tidak dapat berbaris ke penelepon mereka.sumber