GHC memiliki banyak optimasi yang dapat dilakukan, tetapi saya tidak tahu apa itu semua, atau seberapa besar kemungkinan mereka akan dilakukan dan dalam keadaan apa.
Pertanyaan saya adalah: transformasi apa yang dapat saya harapkan untuk diterapkan setiap waktu, atau hampir seperti itu? Jika saya melihat sepotong kode yang akan sering dieksekusi (dievaluasi) dan pikiran pertama saya adalah "hmm, mungkin saya harus mengoptimalkan itu", dalam hal mana seharusnya pikiran kedua saya menjadi, "bahkan tidak memikirkannya, GHC mendapatkan ini "?
Saya sedang membaca makalah Stream Fusion: Dari Daftar ke Streaming ke Nothing at All , dan teknik yang mereka gunakan untuk menulis ulang pemrosesan daftar ke dalam bentuk berbeda yang optimalisasi normal GHC kemudian andalkan dioptimalkan menjadi loop sederhana adalah hal baru bagi saya. Bagaimana saya bisa tahu ketika program saya sendiri memenuhi syarat untuk optimasi semacam itu?
Ada beberapa informasi dalam manual GHC, tetapi hanya sebagian dari jalan menuju menjawab pertanyaan.
EDIT: Saya mulai hadiah. Apa yang saya inginkan adalah daftar transformasi tingkat rendah seperti lambda / let / case-floating, tipe / konstruktor / fungsi argumen spesialisasi, analisis ketat dan unboxing, pekerja / pembungkus, dan apa pun GHC signifikan yang tidak saya tinggalkan , bersama dengan penjelasan dan contoh kode input dan output, dan ilustrasi idealnya situasi ketika efek total lebih dari jumlah bagian-bagiannya. Dan idealnya disebutkan kapan transformasi tidakterjadi. Saya tidak mengharapkan penjelasan panjang novel dari setiap transformasi, beberapa kalimat dan contoh kode satu-baris sebaris bisa cukup (atau tautan, jika tidak sampai dua puluh halaman makalah ilmiah), selama gambaran besarnya adalah jelas pada akhir itu. Saya ingin dapat melihat sepotong kode dan dapat membuat tebakan yang baik tentang apakah itu akan dikompilasi ke loop ketat, atau mengapa tidak, atau apa yang harus saya ubah untuk membuatnya. (Saya tidak terlalu tertarik di sini dalam kerangka kerja optimasi besar seperti aliran fusi (saya baru saja membaca makalah tentang itu); lebih pada jenis pengetahuan yang dimiliki orang-orang yang menulis kerangka kerja ini.)
sumber
Jawaban:
Halaman GHC Trac ini juga menjelaskan lintasan dengan cukup baik. Halaman ini menjelaskan urutan pengoptimalan, seperti mayoritas Wiki Trac, sudah ketinggalan zaman.
Untuk spesifiknya, hal terbaik yang harus dilakukan mungkin adalah dengan melihat bagaimana suatu program spesifik dikompilasi. Cara terbaik untuk melihat optimasi apa yang sedang dilakukan adalah mengkompilasi program secara lisan, menggunakan
-v
flag. Sebagai contoh, potongan Haskell pertama yang dapat saya temukan di komputer saya:Melihat dari yang pertama
*** Simplifier:
ke yang terakhir, di mana semua fase optimasi terjadi, kami melihat cukup banyak.Pertama-tama, Simplifier berjalan di antara hampir semua fase. Ini membuat menulis banyak lintasan lebih mudah. Misalnya, ketika menerapkan banyak optimasi, mereka hanya membuat aturan penulisan ulang untuk menyebarkan perubahan daripada harus melakukannya secara manual. Penyederhanaan mencakup sejumlah optimasi sederhana, termasuk inlining dan fusi. Keterbatasan utama dari ini yang saya tahu adalah bahwa GHC menolak untuk sebaris fungsi rekursif, dan bahwa segala sesuatu harus dinamai dengan benar agar fusi bekerja.
Selanjutnya, kami melihat daftar lengkap semua optimasi yang dilakukan:
Mengkhususkan
Ide dasar spesialisasi adalah untuk menghapus polimorfisme dan kelebihan dengan mengidentifikasi tempat-tempat di mana fungsi dipanggil dan membuat versi fungsi yang bukan polimorfik - mereka khusus untuk jenis yang mereka panggil. Anda juga dapat memberi tahu kompiler untuk melakukan ini dengan
SPECIALISE
pragma. Sebagai contoh, ambil fungsi faktorial:Karena kompiler tidak mengetahui properti dari perkalian yang akan digunakan, kompiler tidak dapat mengoptimalkan ini sama sekali. Namun, jika melihat bahwa itu digunakan pada
Int
, sekarang dapat membuat versi baru, hanya berbeda dalam jenis:Selanjutnya, aturan yang disebutkan di bawah ini dapat diaktifkan, dan Anda berakhir dengan sesuatu yang bekerja pada kotak un
Int
, yang jauh lebih cepat daripada yang asli. Cara lain untuk melihat spesialisasi adalah aplikasi parsial pada kamus tipe kelas dan variabel tipe.The sumber di sini memiliki beban catatan di dalamnya.
Mengapung
EDIT: Saya rupanya salah paham tentang ini sebelumnya. Penjelasan saya telah sepenuhnya berubah.
Ide dasar dari ini adalah untuk memindahkan perhitungan yang tidak boleh diulang dari fungsi. Sebagai contoh, misalkan kita memiliki ini:
Dalam lambda di atas, setiap kali fungsi dipanggil,
y
dihitung ulang. Fungsi yang lebih baik, yang menghasilkan mengambang, adalahUntuk memfasilitasi proses, transformasi lain dapat diterapkan. Misalnya, ini terjadi:
Sekali lagi, perhitungan berulang disimpan.
The sumber sangat mudah dibaca dalam kasus ini.
Saat ini ikatan antara dua lambda yang berdekatan tidak melayang. Misalnya, ini tidak terjadi:
pergi ke
Mengapung ke dalam
Mengutip kode sumber,
Tujuan utama
floatInwards
melayang ke cabang-cabang kasing, sehingga kami tidak mengalokasikan barang-barang, menyimpannya di tumpukan, dan kemudian menemukan bahwa mereka tidak diperlukan di cabang yang dipilih.Sebagai contoh, misalkan kita memiliki ungkapan ini:
Jika
v
dievaluasiFalse
, kemudian dengan mengalokasikanx
, yang mungkin merupakan pukulan besar, kami telah membuang-buang waktu dan ruang. Mengambang ke dalam memperbaiki ini, menghasilkan ini:, yang kemudian diganti oleh penyederhanaan dengan
Makalah ini , meskipun membahas topik lain, memberikan pengantar yang cukup jelas. Perhatikan bahwa terlepas dari nama mereka, mengambang di dan mengambang tidak masuk dalam loop tak terbatas karena dua alasan:
case
pernyataan, sementara float out berkaitan dengan fungsi.Analisis permintaan
Analisis permintaan, atau analisis ketat kurang dari transformasi dan lebih, seperti namanya, dari sebuah jalur pengumpulan informasi. Kompiler menemukan fungsi yang selalu mengevaluasi argumen mereka (atau setidaknya beberapa di antaranya), dan meneruskan argumen tersebut menggunakan nilai-panggilan-alih-alih, panggilan-berdasarkan-kebutuhan. Karena Anda dapat menghindari biaya overhead thunks, ini seringkali jauh lebih cepat. Banyak masalah kinerja di Haskell muncul dari kegagalan pass ini, atau kode tidak cukup ketat. Contoh sederhana adalah perbedaan antara menggunakan
foldr
,foldl
danfoldl'
untuk menjumlahkan daftar bilangan bulat - yang pertama menyebabkan stack overflow, yang kedua menyebabkan overflow tumpukan, dan yang terakhir berjalan dengan baik, karena ketatnya. Ini mungkin yang termudah untuk dipahami dan didokumentasikan dengan baik dari semua ini. Saya percaya bahwa polimorfisme dan kode CPS sering mengalahkan ini.Bungkus pekerja terikat
Ide dasar transformasi pekerja / pembungkus adalah melakukan loop ketat pada struktur sederhana, mengubah ke dan dari struktur itu di ujungnya. Misalnya, ambil fungsi ini, yang menghitung faktorial suatu angka.
Menggunakan definisi
Int
dalam GHC, kami punyaPerhatikan bagaimana kode ini tercakup dalam
I#
s? Kami dapat menghapusnya dengan melakukan ini:Meskipun contoh spesifik ini bisa juga dilakukan oleh SpecConstr, transformasi pekerja / pembungkus sangat umum dalam hal-hal yang dapat dilakukannya.
Sub-ekspresi umum
Ini adalah optimasi lain yang sangat sederhana yang sangat efektif, seperti analisis ketat. Ide dasarnya adalah bahwa jika Anda memiliki dua ekspresi yang sama, mereka akan memiliki nilai yang sama. Misalnya, jika
fib
merupakan kalkulator angka Fibonacci, CSE akan berubahke
yang memotong perhitungan menjadi dua. Sayangnya, ini kadang-kadang dapat menghalangi optimisasi lainnya. Masalah lain adalah bahwa kedua ekspresi harus berada di tempat yang sama dan bahwa keduanya harus sama secara sintaksis , tidak sama dengan nilainya. Misalnya, CSE tidak akan menjalankan kode berikut tanpa sejajar dengan sekelompok:
Namun, jika Anda mengkompilasi melalui llvm, Anda mungkin mendapatkan beberapa dari ini, karena pass Penomoran Nilai Global-nya.
Kasus pembebasan
Ini tampaknya merupakan transformasi yang sangat terdokumentasi, selain fakta bahwa hal itu dapat menyebabkan ledakan kode. Ini adalah versi kecil dari dokumentasi kecil yang saya temukan: diformat ulang (dan sedikit ditulis ulang):
Modul ini berjalan
Core
, dan mencaricase
variabel gratis. Kriterianya adalah: jika adacase
variabel bebas pada rute ke panggilan rekursif, maka panggilan rekursif diganti dengan pembukaan. Misalnya, dalambagian dalam
f
diganti. untuk membuatPerhatikan perlunya membayangi. Menyederhanakan, kita dapatkan
Ini adalah kode yang lebih baik, karena
a
gratis di dalam batinletrec
, daripada perlu proyeksi dariv
. Perhatikan bahwa ini berkaitan dengan variabel bebas , tidak seperti SpecConstr, yang berkaitan dengan argumen yang bentuknya diketahui.Lihat di bawah untuk informasi lebih lanjut tentang SpecConstr.
SpecConstr - ini mengubah program seperti
ke
Sebagai contoh tambahan, ambil definisi ini dari
last
:Kami pertama-tama mengubahnya menjadi
Selanjutnya, penyederhanaan berjalan, dan kita miliki
Perhatikan bahwa program sekarang lebih cepat, karena kita tidak berulang kali bertinju dan menghapus kotak bagian depan daftar. Perhatikan juga bahwa inlining sangat penting, karena memungkinkan definisi baru yang lebih efisien digunakan, serta membuat definisi rekursif menjadi lebih baik.
SpecConstr dikendalikan oleh sejumlah heuristik. Yang disebutkan di koran adalah sebagai berikut:
a
.Namun, heuristik hampir pasti berubah. Bahkan, makalah ini menyebutkan heuristik keenam alternatif:
Mengkhususkan diri pada argumen
x
hanya jikax
ini hanya diteliti olehcase
, dan tidak diteruskan ke fungsi biasa, atau dikembalikan sebagai bagian dari hasilnya.Ini adalah file yang sangat kecil (12 baris) dan jadi mungkin tidak memicu banyak optimasi (meskipun saya pikir itu semua). Ini juga tidak memberi tahu Anda mengapa ia mengambil lintasan itu dan mengapa ia menempatkannya dalam urutan itu.
sumber
Kemalasan
Ini bukan "optimisasi kompiler", tetapi ini sesuatu yang dijamin oleh spesifikasi bahasa, sehingga Anda selalu dapat mengandalkannya. Pada dasarnya, ini berarti bahwa pekerjaan tidak dilakukan sampai Anda "melakukan sesuatu" dengan hasilnya. (Kecuali jika Anda melakukan salah satu dari beberapa hal untuk mematikan kemalasan dengan sengaja.)
Ini, jelas, adalah seluruh topik dalam dirinya sendiri, dan SO sudah punya banyak pertanyaan dan jawaban tentang hal itu.
Dalam pengalaman saya yang terbatas, membuat kode Anda terlalu malas atau terlalu ketat memiliki penalti kinerja yang jauh lebih besar (dalam waktu dan ruang) daripada hal-hal lain yang akan saya bicarakan ...
Analisis keketatan
Kemalasan adalah tentang menghindari pekerjaan kecuali jika itu perlu. Jika kompilator dapat menentukan bahwa hasil yang diberikan akan "selalu" diperlukan, maka tidak akan repot menyimpan perhitungan dan melakukannya nanti; itu hanya akan melakukannya secara langsung, karena itu lebih efisien. Ini disebut "analisis ketat".
Gotcha, jelas, adalah bahwa kompiler tidak selalu dapat mendeteksi kapan sesuatu dapat dibuat ketat. Terkadang Anda perlu memberi sedikit kompiler petunjuk. (Saya tidak mengetahui cara mudah untuk menentukan apakah analisis ketelitian telah melakukan apa yang Anda pikirkan, selain mengarungi keluaran Core.)
Sebaris
Jika Anda memanggil suatu fungsi, dan kompiler dapat mengetahui fungsi mana yang Anda panggil, ia mungkin mencoba untuk "inline" fungsi itu - yaitu, untuk mengganti panggilan fungsi dengan salinan fungsi itu sendiri. Overhead panggilan fungsi biasanya cukup kecil, tetapi inlining sering memungkinkan optimisasi lain terjadi yang tidak akan terjadi sebaliknya, sehingga inlining bisa menjadi kemenangan besar.
Fungsi hanya diuraikan jika "cukup kecil" (atau jika Anda menambahkan pragma yang secara khusus meminta inlining). Selain itu, fungsi hanya dapat digarisbawahi jika kompiler dapat mengetahui fungsi apa yang Anda panggil. Ada dua cara utama yang tidak bisa diketahui oleh kompiler:
Jika fungsi yang Anda panggil diteruskan dari tempat lain. Misalnya, ketika
filter
fungsi dikompilasi, Anda tidak dapat menyamakan predikat filter, karena itu argumen yang disediakan pengguna.Jika fungsi yang Anda panggil adalah metode kelas dan kompiler tidak tahu jenis apa yang terlibat. Misalnya, ketika
sum
fungsi dikompilasi, kompiler tidak dapat menyejajarkan+
fungsi, karenasum
berfungsi dengan beberapa tipe angka yang berbeda, masing-masing memiliki+
fungsi yang berbeda .Dalam kasus terakhir, Anda dapat menggunakan
{-# SPECIALIZE #-}
pragma untuk menghasilkan versi fungsi yang dikodekan secara keras ke tipe tertentu. Misalnya,{-# SPECIALIZE sum :: [Int] -> Int #-}
akan mengkompilasi versisum
hard-coded untukInt
tipe tersebut, artinya+
dapat digarisbawahi dalam versi ini.Namun, perlu diketahui bahwa
sum
fungsi khusus baru kami hanya akan dipanggil ketika kompiler dapat mengetahui bahwa kami sedang bekerja dengannyaInt
. Kalau tidak yang asli, polimorfiksum
akan dipanggil. Sekali lagi, overhead panggilan fungsi sebenarnya cukup kecil. Optimalisasi tambahan inilah yang memungkinkan inlining dapat memberikan manfaat.Penghapusan subekspresi umum
Jika suatu blok kode tertentu menghitung nilai yang sama dua kali, kompilator dapat menggantikannya dengan satu instance dari perhitungan yang sama. Misalnya, jika Anda melakukannya
maka kompiler dapat mengoptimalkan ini untuk
Anda mungkin berharap bahwa kompiler akan selalu melakukan ini. Namun, ternyata dalam beberapa situasi ini dapat menghasilkan kinerja yang lebih buruk, tidak lebih baik, sehingga GHC tidak selalu melakukan ini. Terus terang, saya tidak begitu mengerti detail di balik yang ini. Tetapi intinya adalah, jika transformasi ini penting bagi Anda, tidak sulit untuk melakukannya secara manual. (Dan jika itu tidak penting, mengapa kamu mengkhawatirkannya?)
Ekspresi kasus
Pertimbangkan yang berikut ini:
Tiga persamaan pertama semua memeriksa apakah daftar tersebut tidak kosong (antara lain). Tetapi memeriksa hal yang sama tiga kali sia-sia. Untungnya, sangat mudah bagi kompiler untuk mengoptimalkan ini menjadi beberapa ekspresi kasus bersarang. Dalam hal ini, sesuatu seperti
Ini agak kurang intuitif, tetapi lebih efisien. Karena kompiler dapat dengan mudah melakukan transformasi ini, Anda tidak perlu khawatir tentang hal itu. Cukup tulis pencocokan pola Anda dengan cara yang paling intuitif; kompiler sangat pandai mengatur ulang dan mengatur ulang ini untuk membuatnya secepat mungkin.
Fusi
Idi standar Haskell untuk pemrosesan daftar adalah untuk menyatukan fungsi-fungsi yang mengambil satu daftar dan menghasilkan daftar baru. Contoh kanonik adalah
Sayangnya, sementara kemalasan menjamin melewatkan pekerjaan yang tidak perlu, semua alokasi dan alokasi untuk kinerja menengah daftar getah. "Fusion" atau "penggundulan hutan" adalah tempat penyusun mencoba menghilangkan langkah-langkah perantara ini.
Masalahnya adalah, sebagian besar fungsi ini bersifat rekursif. Tanpa rekursi, itu akan menjadi latihan dasar dalam inlining untuk memadatkan semua fungsi menjadi satu blok kode besar, menjalankan penyederhanaan di atasnya dan menghasilkan kode yang benar-benar optimal tanpa daftar perantara. Tetapi karena rekursi, itu tidak akan berhasil.
Anda dapat menggunakan
{-# RULE #-}
pragma untuk memperbaikinya. Sebagai contoh,Sekarang setiap kali GHC melihat
map
diterapkanmap
, itu squishes menjadi satu pass di atas daftar, menghilangkan daftar perantara.Masalahnya adalah, ini hanya berfungsi untuk
map
diikuti olehmap
. Ada banyak kemungkinan lain -map
diikuti olehfilter
,filter
diikuti olehmap
, dll. Daripada menggunakan kode tangan solusi untuk masing-masing dari mereka, apa yang disebut "aliran fusi" diciptakan. Ini adalah trik yang lebih rumit, yang tidak akan saya uraikan di sini.Panjang dan pendeknya adalah: Ini semua adalah trik optimasi khusus yang ditulis oleh programmer . GHC sendiri tidak tahu apa-apa tentang fusi; itu semua ada di daftar perpustakaan dan perpustakaan kontainer lainnya. Jadi optimasi apa yang terjadi tergantung pada bagaimana perpustakaan kontainer Anda ditulis (atau, lebih realistis, perpustakaan mana yang Anda pilih untuk digunakan).
Misalnya, jika Anda bekerja dengan array Haskell '98, jangan mengharapkan fusi dalam bentuk apa pun. Tetapi saya mengerti bahwa
vector
perpustakaan memiliki kemampuan fusi yang luas. Ini semua tentang perpustakaan; kompiler hanya menyediakanRULES
pragma. (Omong-omong, ini sangat kuat. Sebagai penulis perpustakaan, Anda dapat menggunakannya untuk menulis ulang kode klien!)Meta:
Saya setuju dengan orang-orang yang mengatakan "kode pertama, profil kedua, optimalkan ketiga".
Saya juga setuju dengan orang-orang yang mengatakan "akan bermanfaat untuk memiliki model mental untuk berapa banyak biaya keputusan desain yang diberikan".
Seimbangkan semua hal, dan semua itu ...
sumber
it's something guaranteed by the language specification ... work is not performed until you "do something" with the result.
- tidak persis. Bahasa spesifikasi menjanjikan semantik non-ketat ; tidak menjanjikan apa pun tentang apakah pekerjaan yang berlebihan akan dilakukan.Jika let binding v = rhs hanya digunakan di satu tempat, Anda dapat mengandalkan kompiler untuk memasukkannya, bahkan jika rhs besar.
Pengecualian (yang hampir tidak satu dalam konteks pertanyaan saat ini) adalah lambdas yang berisiko duplikasi pekerjaan. Mempertimbangkan:
ada inlining v akan berbahaya karena penggunaan satu (sintaksis) akan diterjemahkan ke dalam 99 evaluasi tambahan rhs. Namun, dalam hal ini, Anda juga tidak mungkin ingin memasukkannya secara manual. Jadi intinya Anda bisa menggunakan aturan:
Jika Anda ingin memasukkan nama yang hanya muncul sekali, kompiler tetap akan melakukannya.
Sebagai akibat wajar yang bahagia, menggunakan pengikatan let hanya untuk menguraikan pernyataan panjang (dengan harapan mendapatkan kejelasan) pada dasarnya gratis.
Ini berasal dari community.haskell.org/~simonmar/papers/inline.pdf yang mencakup lebih banyak informasi tentang inlining.
sumber