Misalkan saya memiliki sejumlah pernyataan yang ingin saya jalankan dalam urutan tetap. Saya ingin menggunakan g ++ dengan pengoptimalan level 2, sehingga beberapa pernyataan dapat diatur ulang. Alat apa yang dimiliki seseorang untuk menegakkan urutan pernyataan tertentu?
Perhatikan contoh berikut.
using Clock = std::chrono::high_resolution_clock;
auto t1 = Clock::now(); // Statement 1
foo(); // Statement 2
auto t2 = Clock::now(); // Statement 3
auto elapsedTime = t2 - t1;
Dalam contoh ini, penting bahwa pernyataan 1-3 dieksekusi dalam urutan tertentu. Namun, tidak dapatkah kompilator menganggap pernyataan 2 tidak bergantung pada 1 dan 3 dan menjalankan kode sebagai berikut?
using Clock=std::chrono::high_resolution_clock;
foo(); // Statement 2
auto t1 = Clock::now(); // Statement 1
auto t2 = Clock::now(); // Statement 3
auto elapsedTime = t2 - t1;
c++
c++11
operator-precedence
S2108887
sumber
sumber
__sync_synchronize()
membantu?foo
dibutuhkan untuk menjalankan, yang boleh diabaikan oleh kompilator saat menyusun ulang, seperti halnya diizinkan untuk mengabaikan pengamatan dari utas yang berbeda.Jawaban:
Saya ingin mencoba memberikan jawaban yang lebih komprehensif setelah ini didiskusikan dengan komite standar C ++. Selain menjadi anggota komite C ++, saya juga seorang pengembang di LLVM dan kompiler Clang.
Pada dasarnya, tidak ada cara untuk menggunakan penghalang atau beberapa operasi dalam urutan untuk mencapai transformasi ini. Masalah mendasar adalah bahwa semantik operasional dari sesuatu seperti penjumlahan integer benar - benar diketahui implementasinya. Ia dapat mensimulasikan mereka, mengetahui bahwa mereka tidak dapat diamati oleh program yang benar, dan selalu bebas untuk memindahkannya.
Kami dapat mencoba mencegah ini, tetapi hasilnya akan sangat negatif dan pada akhirnya akan gagal.
Pertama, satu-satunya cara untuk mencegah hal ini pada compiler adalah dengan memberitahukan bahwa semua operasi dasar ini dapat diamati. Masalahnya adalah hal ini kemudian akan menghalangi sebagian besar pengoptimalan compiler. Di dalam kompiler, pada dasarnya kita tidak memiliki mekanisme yang baik untuk memodelkan bahwa pengaturan waktunya dapat diamati tetapi tidak ada yang lain. Kami bahkan tidak memiliki model yang baik tentang operasi apa yang membutuhkan waktu . Sebagai contoh, apakah mengonversi integer 32-bit unsigned menjadi integer 64-bit unsigned membutuhkan waktu? Dibutuhkan waktu nol pada x86-64, tetapi pada arsitektur lain dibutuhkan waktu bukan nol. Tidak ada jawaban yang benar secara umum di sini.
Tetapi bahkan jika kami berhasil melewati beberapa langkah heroik dalam mencegah compiler menyusun ulang operasi ini, tidak ada jaminan ini akan cukup. Pertimbangkan cara yang valid dan sesuai untuk menjalankan program C ++ Anda pada mesin x86: DynamoRIO. Ini adalah sistem yang secara dinamis mengevaluasi kode mesin program. Satu hal yang dapat dilakukannya adalah pengoptimalan online, dan bahkan mampu secara spekulatif mengeksekusi seluruh rangkaian instruksi aritmatika dasar di luar waktu. Dan perilaku ini tidak unik untuk evaluator dinamis, CPU x86 yang sebenarnya juga akan berspekulasi (jumlah yang jauh lebih kecil) instruksi dan menyusunnya kembali secara dinamis.
Realisasi esensial adalah fakta bahwa aritmatika tidak dapat diamati (bahkan pada tingkat waktu) adalah sesuatu yang menembus lapisan komputer. Hal ini berlaku untuk kompiler, runtime, dan seringkali bahkan untuk perangkat keras. Memaksanya agar dapat diamati akan secara dramatis membatasi kompiler, tetapi juga akan secara dramatis membatasi perangkat keras.
Tetapi semua ini seharusnya tidak membuat Anda kehilangan harapan. Jika Anda ingin mengatur waktu pelaksanaan operasi matematika dasar, kami telah mempelajari teknik yang bekerja dengan andal. Biasanya ini digunakan saat melakukan pembandingan mikro . Saya memberikan ceramah tentang ini di CppCon2015: https://youtu.be/nXaxk27zwlk
Teknik yang ditampilkan di sana juga disediakan oleh berbagai pustaka patokan mikro seperti Google: https://github.com/google/benchmark#preventing-optimization
Kunci dari teknik ini adalah fokus pada data. Anda membuat masukan ke penghitungan buram ke pengoptimal dan hasil penghitungan buram ke pengoptimal. Setelah Anda selesai melakukannya, Anda dapat mengatur waktunya dengan andal. Mari kita lihat versi realistik dari contoh dalam pertanyaan awal, tetapi dengan definisi
foo
terlihat sepenuhnya untuk implementasi. Saya juga telah mengekstrak versi (non-portabel) dariDoNotOptimize
pustaka Google Benchmark yang dapat Anda temukan di sini: https://github.com/google/benchmark/blob/master/include/benchmark/benchmark_api.h#L208Di sini kami memastikan bahwa data masukan dan data keluaran ditandai sebagai tidak dapat dioptimalkan selama penghitungan
foo
, dan hanya di sekitar penanda tersebut pengaturan waktu dihitung. Karena Anda menggunakan data untuk menjepit penghitungan, dijamin untuk tetap berada di antara dua pengaturan waktu, namun penghitungan itu sendiri diizinkan untuk dioptimalkan. Rakitan x86-64 yang dihasilkan yang dihasilkan oleh build Clang / LLVM terbaru adalah:Di sini Anda dapat melihat compiler mengoptimalkan panggilan ke
foo(input)
satu instruksiaddl %eax, %eax
, tetapi tanpa memindahkannya ke luar timing atau menghilangkannya sepenuhnya meskipun input konstan.Semoga ini bisa membantu, dan komite standar C ++ sedang melihat kemungkinan standarisasi API yang mirip dengan di
DoNotOptimize
sini.sumber
Clock::now()
diurutkan ulang relatif terhadap foo ()? Apakah pengoptimal harus berasumsi bahwaDoNotOptimize
danClock::now()
memiliki akses ke dan mungkin mengubah beberapa keadaan global umum yang pada gilirannya akan mengikat mereka ke dalam dan keluaran? Atau apakah Anda mengandalkan beberapa batasan penerapan pengoptimal saat ini?DoNotOptimize
dalam contoh ini adalah peristiwa sintetik yang "dapat diamati". Seolah-olah itu secara nosional mencetak output yang terlihat ke beberapa terminal dengan representasi input. Sejak membaca jam juga dapat diamati (Anda mengamati berlalunya waktu) mereka tidak dapat diatur ulang tanpa mengubah perilaku program yang dapat diamati.foo
fungsinya melakukan beberapa operasi seperti membaca dari soket yang mungkin diblokir untuk sementara waktu, apakah ini termasuk operasi yang dapat diamati? Dan karenaread
ini bukan operasi yang "benar-benar diketahui" (bukan?), Apakah kode akan tetap teratur?Ringkasan:
Tampaknya tidak ada cara yang dijamin untuk mencegah pengubahan urutan, tetapi selama pengoptimalan waktu tautan / program penuh tidak diaktifkan, menempatkan fungsi yang dipanggil di unit kompilasi terpisah tampaknya merupakan taruhan yang cukup bagus . (Setidaknya dengan GCC, meskipun logika akan menyarankan bahwa hal ini mungkin terjadi pada kompiler lain juga.) Ini datang dengan biaya dari pemanggilan fungsi - kode inline menurut definisi dalam unit kompilasi yang sama dan terbuka untuk penyusunan ulang.
Jawaban asli:
GCC menyusun ulang panggilan di bawah -O2 optimization:
GCC 5.3.0:
g++ -S --std=c++11 -O0 fred.cpp
:Tapi:
g++ -S --std=c++11 -O2 fred.cpp
:Sekarang, dengan foo () sebagai fungsi eksternal:
g++ -S --std=c++11 -O2 fred.cpp
:TAPI, jika ini ditautkan dengan -flto (pengoptimalan waktu tautan):
sumber
Penyusunan ulang dapat dilakukan oleh kompiler, atau oleh prosesor.
Sebagian besar penyusun menawarkan metode khusus platform untuk mencegah pengurutan ulang instruksi baca-tulis. Di gcc, ini
( Informasi lebih lanjut di sini )
Perhatikan bahwa ini hanya secara tidak langsung mencegah operasi penataan ulang, selama operasi tersebut bergantung pada baca / tulis.
Dalam praktiknya saya belum melihat sistem di mana panggilan sistem
Clock::now()
memiliki efek yang sama seperti penghalang tersebut. Anda dapat memeriksa perakitan yang dihasilkan untuk memastikan.Namun, tidak jarang fungsi yang diuji dievaluasi selama waktu kompilasi. Untuk menjalankan eksekusi "realistis", Anda mungkin perlu mendapatkan masukan
foo()
dari I / O atauvolatile
pembacaan.Pilihan lain adalah menonaktifkan sebaris untuk
foo()
- sekali lagi, ini khusus untuk kompilator dan biasanya tidak portabel, tetapi akan memiliki efek yang sama.Di gcc, ini akan menjadi
__attribute__ ((noinline))
@Ruslan mengemukakan masalah mendasar: Seberapa realistis pengukuran ini?
Waktu eksekusi dipengaruhi oleh banyak faktor: satu adalah perangkat keras aktual tempat kita menjalankan, yang lainnya adalah akses bersamaan ke sumber daya bersama seperti cache, memori, disk dan inti CPU.
Jadi yang biasanya kami lakukan untuk mendapatkan pengaturan waktu yang sebanding : pastikan mereka dapat direproduksi dengan margin kesalahan rendah. Ini membuatnya agak artifisial.
Performa eksekusi "cache panas" vs. "cache dingin" dapat dengan mudah dibedakan berdasarkan urutan besarnya - tetapi pada kenyataannya, ini akan menjadi sesuatu di antara keduanya ("suam-suam kuku"?)
sumber
asm
mempengaruhi waktu eksekusi pernyataan antara panggilan timer: kode setelah pemanjat memori harus memuat ulang semua variabel dari memori.Bahasa C ++ mendefinisikan apa yang bisa diamati dalam beberapa cara.
Jika
foo()
tidak ada yang bisa diamati, maka itu bisa dihilangkan sepenuhnya. Jikafoo()
hanya melakukan komputasi yang menyimpan nilai dalam status "lokal" (baik itu pada stack atau dalam objek di suatu tempat), dan compiler dapat membuktikan bahwa tidak ada pointer yang diturunkan dengan aman yang dapat masuk ke dalamClock::now()
kode, maka tidak ada konsekuensi yang dapat diamati untuk memindahkanClock::now()
panggilan.Jika
foo()
berinteraksi dengan file atau layar, dan compiler tidak dapat membuktikan bahwaClock::now()
tidak tidak berinteraksi dengan file atau layar, maka penataan kembali tidak dapat dilakukan, karena interaksi dengan file atau tampilan adalah perilaku yang dapat diamati.Meskipun Anda dapat menggunakan peretasan khusus kompiler untuk memaksa kode agar tidak berpindah-pindah (seperti perakitan inline), pendekatan lain adalah mencoba mengakali kompiler Anda.
Buat perpustakaan yang dimuat secara dinamis. Muat sebelum kode yang dimaksud.
Perpustakaan itu memperlihatkan satu hal:
dan membungkusnya seperti ini:
yang mengemas lambda nullary dan menggunakan pustaka dinamis untuk menjalankannya dalam konteks yang tidak dapat dipahami oleh compiler.
Di dalam perpustakaan dinamis, kami melakukan:
yang cukup sederhana.
Sekarang untuk menyusun ulang panggilan ke
execute
, itu harus memahami pustaka dinamis, yang tidak bisa dilakukan saat menyusun kode pengujian Anda.Itu masih dapat menghilangkan
foo()
s tanpa efek samping, tetapi Anda menang beberapa, Anda kehilangan beberapa.sumber
volatile
akses tiruan atau panggilan ke kode luar.Tidak, itu tidak bisa. Menurut standar C ++ [intro.execution]:
Ekspresi penuh pada dasarnya adalah pernyataan yang diakhiri dengan titik koma. Seperti yang Anda lihat, aturan di atas menetapkan pernyataan harus dijalankan secara berurutan. Di dalam pernyataan itulah kompilator diizinkan lebih bebas kendali (yaitu dalam beberapa keadaan diizinkan untuk mengevaluasi ekspresi yang membuat pernyataan dalam urutan selain kiri-ke-kanan atau hal lain yang spesifik).
Perhatikan kondisi untuk menerapkan aturan seolah-olah tidak terpenuhi di sini. Tidak masuk akal untuk berpikir bahwa setiap kompilator akan dapat membuktikan bahwa pengubahan urutan panggilan untuk mendapatkan waktu sistem tidak akan memengaruhi perilaku program yang dapat diamati. Jika ada keadaan di mana dua panggilan untuk mendapatkan waktu dapat diatur ulang tanpa mengubah perilaku yang diamati, akan sangat tidak efisien untuk benar-benar menghasilkan kompilator yang menganalisis program dengan pemahaman yang cukup untuk dapat menyimpulkan ini dengan pasti.
sumber
Tidak.
Terkadang, dengan aturan "seolah-olah", pernyataan dapat diatur ulang. Ini bukan karena keduanya secara logis tidak bergantung satu sama lain, tetapi karena independensi tersebut memungkinkan penataan ulang semacam itu terjadi tanpa mengubah semantik program.
Memindahkan panggilan sistem yang memperoleh waktu saat ini jelas tidak memenuhi kondisi tersebut. Kompiler yang secara sadar atau tidak sadar melakukannya tidak patuh dan sangat konyol.
Secara umum, saya tidak akan mengharapkan ekspresi apa pun yang menghasilkan panggilan sistem menjadi "menebak-nebak" bahkan oleh compiler yang mengoptimalkan secara agresif. Itu hanya tidak cukup tahu tentang apa yang dilakukan oleh panggilan sistem itu.
sumber
int x = 0; clock(); x = y*2; clock();
tidak ada cara yang ditentukan bagiclock()
kode untuk berinteraksi dengan statusx
. Di bawah standar C ++, ia tidak harus tahu apa yangclock()
dilakukannya - ia dapat memeriksa tumpukan (dan memperhatikan kapan komputasi terjadi), tetapi itu bukan masalah C ++ .t2
dan yang kedua ket1
, akan menjadi tidak sesuai dan konyol jika nilai-nilai itu digunakan, apa jawaban ini meleset adalah itu kompiler yang sesuai terkadang dapat mengatur ulang kode lain melalui panggilan sistem. Dalam hal ini, asalkan ia tahu apa yangfoo()
dilakukannya (misalnya karena ia telah membuat inline) dan karenanya (secara longgar) itu adalah fungsi murni maka ia dapat memindahkannya.y*y
sebelum panggilan sistem, hanya untuk kesenangan. Juga tidak ada jaminan bahwa implementasi sebenarnya tidak akan menggunakan hasil dari penghitungan spekulatif ini nanti pada titik mana punx
yang digunakan, oleh karena itu tidak melakukan apa pun di antara panggilan keclock()
. Hal yang sama berlaku untuk fungsi sebaris apa punfoo
, asalkan tidak memiliki efek samping dan tidak dapat bergantung pada keadaan yang mungkin diubah olehclock()
.noinline
function + kotak hitam perakitan inline + dependensi data lengkapIni didasarkan pada https://stackoverflow.com/a/38025837/895245 tetapi karena saya tidak melihat alasan yang jelas mengapa
::now()
tidak dapat diatur ulang di sana, saya lebih suka menjadi paranoid dan memasukkannya ke dalam fungsi noinline bersama dengan asm.Dengan cara ini saya cukup yakin pengubahan urutan tidak dapat terjadi, karena
noinline
"mengikat"::now
dan ketergantungan data.main.cpp
GitHub upstream .
Kompilasi dan jalankan:
Satu-satunya kelemahan kecil dari metode ini adalah kita menambahkan satu
callq
instruksi tambahan di atas sebuahinline
metode.objdump -CD
acara yangmain
berisi:jadi kami melihat
foo
itu sebaris, tetapiget_clock
tidak dan mengelilinginya.get_clock
Namun itu sendiri sangat efisien, terdiri dari instruksi yang dioptimalkan panggilan daun tunggal yang bahkan tidak menyentuh tumpukan:Karena ketepatan jam itu sendiri terbatas, saya pikir Anda tidak mungkin dapat melihat efek waktu dari satu ekstra
jmpq
. Perhatikan bahwa satucall
tetap diperlukan karena::now()
berada di pustaka bersama.Panggilan
::now()
dari perakitan inline dengan ketergantungan dataIni akan menjadi solusi yang paling efisien, mengatasi bahkan tambahan yang
jmpq
disebutkan di atas.Sayangnya ini sangat sulit dilakukan dengan benar seperti yang ditunjukkan di: Memanggil printf dalam ASM sebaris yang diperpanjang
Jika pengukuran waktu Anda dapat dilakukan langsung dalam perakitan inline tanpa panggilan, maka teknik ini dapat digunakan. Ini adalah kasus misalnya untuk instruksi instrumentasi sihir gem5 , x86 RDTSC (tidak yakin apakah ini mewakili lagi) dan mungkin penghitung kinerja lainnya.
Utas terkait:
Diuji dengan GCC 8.3.0, Ubuntu 19.04.
sumber
"+m"
, menggunakan"+r"
adalah cara yang jauh lebih efisien untuk membuat compiler mewujudkan nilai dan kemudian mengasumsikan variabel telah berubah.