Haskell (dengan GHC
kompiler) jauh lebih cepat dari yang Anda harapkan . Digunakan dengan benar, ini bisa mendekati bahasa tingkat rendah. (Hal favorit yang harus dilakukan Haskellers adalah mencoba dan mendapatkan dalam 5% dari C (atau bahkan mengalahkannya, tetapi itu berarti Anda menggunakan program C yang tidak efisien, karena GHC mengkompilasi Haskell ke C).) Pertanyaan saya adalah, mengapa?
Haskell bersifat deklaratif dan berdasarkan pada kalkulus lambda. Arsitektur mesin jelas sangat penting, didasarkan pada mesin turing, secara kasar. Memang, Haskell bahkan tidak memiliki urutan evaluasi khusus. Selain itu, alih-alih berurusan dengan tipe data mesin, Anda membuat tipe data aljabar setiap saat.
Yang paling aneh dari semuanya adalah fungsi urutan yang lebih tinggi. Anda akan berpikir bahwa membuat fungsi dengan cepat, dan melemparkannya, akan membuat program lebih lambat. Tetapi menggunakan fungsi tingkat tinggi sebenarnya membuat Haskell lebih cepat. Memang, tampaknya, untuk mengoptimalkan kode Haskell, Anda perlu membuatnya lebih elegan dan abstrak daripada lebih seperti mesin. Tak satu pun dari fitur Haskell yang lebih canggih tampaknya bahkan memengaruhi kinerjanya, jika mereka tidak memperbaikinya.
Maaf jika ini terdengar ranty, tetapi inilah pertanyaan saya: Mengapa Haskell (dikompilasi dengan GHC) begitu cepat, mengingat sifat abstrak dan perbedaan dari mesin fisik?
Catatan: Alasan saya mengatakan C dan bahasa imperatif lainnya agak mirip dengan Mesin Turing (tetapi tidak sejauh Haskell mirip dengan Lambda Calculus) adalah bahwa dalam bahasa imperatif, Anda memiliki sejumlah negara terbatas (alias nomor baris) , bersama dengan Tape (ram), sehingga negara dan rekaman saat ini menentukan apa yang harus dilakukan untuk rekaman itu. Lihat entri Wikipedia, setara mesin Turing , untuk transisi dari Mesin Turing ke komputer.
Jawaban:
Saya setuju dengan Dietrich Epp: kombinasi dari beberapa hal yang membuat GHC cepat.
Pertama dan terpenting, Haskell sangat tingkat tinggi. Ini memungkinkan kompiler melakukan optimisasi agresif tanpa melanggar kode Anda.
Pikirkan tentang SQL. Sekarang, ketika saya menulis
SELECT
pernyataan, itu mungkin terlihat seperti loop keharusan, tetapi tidak . Ini mungkin terlihat seperti loop di semua baris dalam tabel itu mencoba untuk menemukan satu yang cocok dengan kondisi yang ditentukan, tetapi sebenarnya "kompiler" (mesin DB) dapat melakukan pencarian indeks sebagai gantinya - yang memiliki karakteristik kinerja yang sama sekali berbeda. Tetapi karena SQL sangat tingkat tinggi, "kompiler" dapat menggantikan algoritma yang sama sekali berbeda, menerapkan beberapa prosesor atau saluran I / O atau seluruh server secara transparan, dan banyak lagi.Saya menganggap Haskell sama. Anda mungkin berpikir Anda baru saja meminta Haskell untuk memetakan daftar input ke daftar kedua, memfilter daftar kedua ke daftar ketiga, dan kemudian menghitung berapa banyak item yang dihasilkan. Tetapi Anda tidak melihat GHC menerapkan aturan penulisan ulang aliran-fusi di belakang layar, mengubah semuanya menjadi satu loop kode mesin ketat yang melakukan seluruh pekerjaan dalam sekali melewati data tanpa alokasi - jenis hal yang akan menjadi membosankan, rawan kesalahan dan tidak dapat dipertahankan untuk menulis dengan tangan. Itu hanya sangat mungkin karena kurangnya detail level rendah dalam kode.
Cara lain untuk melihatnya mungkin ... mengapa Haskell tidak boleh cepat? Apa fungsinya yang membuatnya lambat?
Ini bukan bahasa yang ditafsirkan seperti Perl atau JavaScript. Ini bahkan bukan sistem mesin virtual seperti Java atau C #. Ini mengkompilasi sampai ke kode mesin asli, jadi tidak ada overhead di sana.
Tidak seperti bahasa OO [Java, C #, JavaScript ...], Haskell memiliki penghapusan tipe penuh [seperti C, C ++, Pascal ...]. Semua pemeriksaan tipe hanya terjadi pada waktu kompilasi. Jadi tidak ada pemeriksaan run-time untuk memperlambat Anda. (Tidak ada pemeriksaan null-pointer, dalam hal ini. Dalam, katakanlah, Java, JVM harus memeriksa pointer nol dan melemparkan pengecualian jika Anda menghormati satu. Haskell tidak perlu repot dengan pemeriksaan itu.)
Anda mengatakan itu terdengar lambat untuk "membuat fungsi dengan cepat pada saat run-time", tetapi jika Anda melihat dengan sangat hati-hati, Anda sebenarnya tidak melakukannya. Mungkin terlihat seperti yang Anda lakukan, tetapi Anda tidak melakukannya. Jika Anda berkata
(+5)
, yah, itu kode-keras ke dalam kode sumber Anda. Itu tidak dapat berubah pada saat run-time. Jadi sebenarnya bukan fungsi yang dinamis. Bahkan fungsi kari benar-benar hanya menyimpan parameter ke dalam blok data. Semua kode yang dapat dieksekusi benar-benar ada pada waktu kompilasi; tidak ada interpretasi run-time. (Tidak seperti beberapa bahasa lain yang memiliki "fungsi eval".)Pikirkan tentang Pascal. Ini sudah tua dan tidak ada yang benar-benar menggunakannya lagi, tetapi tidak ada yang akan mengeluh bahwa Pascal lambat . Ada banyak hal yang tidak disukai tentang hal itu, tetapi kelambatan sebenarnya bukan salah satunya. Haskell tidak benar-benar melakukan banyak hal yang berbeda dengan Pascal, selain memiliki pengumpulan sampah daripada manajemen memori manual. Dan data yang tidak dapat diubah memungkinkan beberapa optimisasi ke mesin GC [yang kemudian sedikit evaluasi rumit].
Saya pikir masalahnya adalah bahwa Haskell terlihat canggih dan canggih dan tingkat tinggi, dan semua orang berpikir "oh wow, ini benar-benar kuat, pasti sangat lambat! " Tetapi tidak. Atau setidaknya, itu tidak seperti yang Anda harapkan. Ya, ini memiliki sistem tipe yang menakjubkan. Tapi tahukah Anda? Itu semua terjadi pada waktu kompilasi. Dengan run-time, itu hilang. Ya, ini memungkinkan Anda membuat ADT yang rumit dengan sebaris kode. Tapi tahukah Anda? Sebuah ADT hanya C biasa polos
union
daristruct
s. Tidak ada lagi.Pembunuh sebenarnya adalah evaluasi malas. Ketika Anda mendapatkan keketatan / kemalasan kode Anda dengan benar, Anda dapat menulis kode cepat bodoh yang masih elegan dan indah. Tetapi jika Anda salah melakukan hal ini, program Anda berjalan ribuan kali lebih lambat , dan itu benar-benar tidak jelas mengapa ini terjadi.
Sebagai contoh, saya menulis sebuah program kecil yang sepele untuk menghitung berapa kali setiap byte muncul dalam sebuah file. Untuk file input 25KB, program membutuhkan waktu 20 menit untuk menjalankan dan menelan 6 gigabytes RAM! Itu tidak masuk akal !! Tapi kemudian saya menyadari apa masalahnya, menambahkan satu pola bang, dan waktu tayang turun menjadi 0,02 detik .
Ini adalah tempat Haskell pergi tiba-tiba perlahan-lahan. Dan tentu saja butuh waktu untuk terbiasa. Namun seiring waktu, semakin mudah untuk menulis kode yang sangat cepat.
Apa yang membuat Haskell begitu cepat? Kemurnian. Jenis statis. Kemalasan. Tetapi di atas semua itu, karena kompiler tingkat tinggi yang cukup dapat secara radikal mengubah implementasi tanpa melanggar harapan kode Anda.
Tapi saya kira itu hanya pendapat saya ...
sumber
Untuk waktu yang lama dianggap bahwa bahasa fungsional tidak bisa cepat - dan terutama bahasa fungsional yang malas. Tapi ini karena implementasi awal mereka, pada dasarnya, ditafsirkan dan tidak benar-benar dikompilasi.
Gelombang desain kedua muncul berdasarkan pengurangan grafik, dan membuka kemungkinan untuk kompilasi yang jauh lebih efisien. Simon Peyton Jones menulis tentang penelitian ini dalam dua bukunya , Implementasi Bahasa Pemrograman Fungsional dan Mengimplementasikan bahasa fungsional: tutorial (sebelumnya dengan bagian oleh Wadler dan Hancock, dan yang terakhir ditulis dengan David Lester). (Lennart Augustsson juga memberi tahu saya bahwa salah satu motivasi utama untuk buku terdahulu adalah menggambarkan cara kompiler LML-nya, yang tidak banyak dikomentari, menyelesaikan kompilasi).
Gagasan utama di balik pendekatan pengurangan grafik seperti yang dijelaskan dalam karya-karya ini adalah bahwa kita tidak menganggap program sebagai urutan instruksi, tetapi grafik ketergantungan yang dievaluasi melalui serangkaian pengurangan lokal. Wawasan kunci kedua adalah evaluasi grafik seperti itu tidak perlu ditafsirkan tetapi sebaliknya grafik itu sendiri dapat dibangun dari kode . Secara khusus, kita dapat merepresentasikan simpul dari grafik bukan sebagai "nilai atau 'opcode' dan nilai untuk beroperasi" tetapi sebagai fungsi yang ketika dipanggil, mengembalikan nilai yang diinginkan. Pertama kali dipanggil, ia meminta subnode untuk nilai-nilai mereka dan kemudian beroperasi pada mereka, dan kemudian menimpa dirinya sendiri dengan instruksi baru yang hanya mengatakan "kembalikan hasilnya.
Ini dijelaskan dalam makalah selanjutnya yang menjabarkan dasar-dasar bagaimana GHC masih bekerja hari ini (meskipun modulo banyak berbagai tweak): "Menerapkan Bahasa Fungsional Malas pada Perangkat Keras Stok: Mesin G-Tag Tanpa Spineless." . Model eksekusi GHC saat ini didokumentasikan secara lebih rinci di GHC Wiki .
Jadi wawasannya adalah perbedaan ketat dari "data" dan "kode" yang kami anggap "mendasar" untuk bagaimana mesin bekerja bukanlah bagaimana mereka harus bekerja, tetapi dipaksakan oleh kompiler kami. Jadi kita dapat membuangnya, dan memiliki kode (kompiler) yang menghasilkan kode modifikasi sendiri (yang dapat dieksekusi) dan semuanya dapat bekerja dengan cukup baik.
Dengan demikian ternyata bahwa sementara arsitektur mesin sangat penting dalam arti tertentu, bahasa dapat memetakan kepada mereka dengan cara yang sangat mengejutkan yang tidak terlihat seperti kontrol aliran gaya-C konvensional, dan jika kita berpikir tingkat rendah cukup, ini mungkin juga efisien.
Di atas semua ini ada banyak optimisasi lain yang dibuka oleh kemurnian pada khususnya, karena memungkinkan transformasi yang lebih besar "aman". Kapan dan bagaimana menerapkan transformasi ini sedemikian rupa sehingga mereka membuat segalanya lebih baik dan tidak lebih buruk tentu saja merupakan pertanyaan empiris, dan mengenai ini dan banyak pilihan kecil lainnya, bertahun-tahun kerja telah dimasukkan ke dalam pekerjaan teoretis dan pembandingan praktis. Jadi ini tentu saja memainkan peran juga. Sebuah makalah yang memberikan contoh yang baik dari jenis penelitian ini adalah " Membuat Kari Cepat: Dorong / Masuk vs. Eval / Terapkan untuk Bahasa Tingkat Tinggi."
Akhirnya, perlu dicatat bahwa model ini masih memperkenalkan overhead karena tipuan. Ini dapat dihindari dalam kasus-kasus di mana kita tahu bahwa "aman" untuk melakukan hal-hal secara ketat dan karenanya menghilangkan tipuan grafik. Mekanisme yang menyimpulkan keketatan / permintaan sekali lagi didokumentasikan dalam beberapa detail di GHC Wiki .
sumber
Ada banyak yang perlu dikomentari di sini. Saya akan mencoba menjawab sebanyak yang saya bisa.
Dalam pengalaman saya, biasanya memungkinkan untuk mendapatkan 2x kinerja Rust dalam banyak kasus. Tetapi ada juga beberapa kasus penggunaan (luas) di mana kinerjanya buruk dibandingkan dengan bahasa tingkat rendah.
Itu tidak sepenuhnya benar. Haskell mengkompilasi ke C-- (subset dari C), yang kemudian dikompilasi melalui generator kode asli ke assembly. Pembuat kode asli biasanya menghasilkan kode lebih cepat daripada kompiler C, karena ia dapat menerapkan beberapa optimasi yang tidak bisa dilakukan oleh kompiler C biasa.
Itu bukan cara yang baik untuk memikirkannya, terutama karena prosesor modern akan mengevaluasi instruksi yang salah dan mungkin pada saat yang sama.
Sebenarnya, Haskell melakukannya secara implisit mendefinisikan perintah evaluasi.
Mereka bersesuaian dalam banyak kasus, asalkan Anda memiliki kompiler yang cukup canggih.
Haskell dikompilasi, dan fungsi tingkat tinggi sebenarnya tidak dibuat dengan cepat.
Secara umum, membuat kode lebih "seperti mesin" adalah cara yang tidak produktif untuk mendapatkan kinerja yang lebih baik di Haskell. Tetapi membuatnya lebih abstrak tidak selalu merupakan ide yang baik juga. Apa yang merupakan ide yang baik adalah menggunakan struktur data umum dan fungsi yang telah sangat dioptimalkan (seperti daftar tertaut).
f x = [x]
danf = pure
hal yang persis sama di Haskell, misalnya. Kompiler yang baik tidak akan menghasilkan kinerja yang lebih baik dalam kasus sebelumnya.Jawaban singkatnya adalah "karena dirancang untuk melakukan hal itu." GHC menggunakan mesin tagless g-spinless (STG). Anda dapat membaca makalah tentang itu di sini (ini cukup rumit). GHC melakukan banyak hal lain juga, seperti analisis ketat dan evaluasi optimis .
Apakah titik kebingungan kemudian bahwa mutabilitas harus mengarah pada kode yang lebih lambat? Kemalasan Haskell sebenarnya berarti bahwa kemampuan berubah tidak penting sebanyak yang Anda pikirkan, ditambah tingkat tinggi sehingga ada banyak optimisasi yang dapat diterapkan oleh kompiler. Dengan demikian, memodifikasi catatan di tempat jarang akan lebih lambat daripada dalam bahasa seperti C.
sumber
Sesuatu pasti telah berubah secara dramatis sejak saya terakhir mengukur kinerja Haskell. Sebagai contoh:
Jadi apa yang telah berubah? Saya perhatikan daripada pertanyaan atau jawaban yang sekarang merujuk pada tolok ukur yang dapat diverifikasi atau bahkan kode.
Apakah Anda memiliki referensi untuk hasil yang dapat diverifikasi di mana ada orang yang mendekati itu?
sumber
fmap (length &&& length . words &&& length . lines) readFile
. Jika yang lebih cepat dari (atau bahkan sebanding dengan) C, hype di sini akan benar-benar dibenarkan kemudian . Kita masih harus bekerja keras untuk kecepatan di Haskell seperti di C, intinya.