Kapan memoisasi otomatis di GHC Haskell?

106

Saya tidak tahu mengapa m1 tampaknya dikosongkan sementara m2 tidak dalam berikut ini:

m1      = ((filter odd [1..]) !!)

m2 n    = ((filter odd [1..]) !! n)

m1 10000000 membutuhkan waktu sekitar 1,5 detik pada panggilan pertama, dan sebagian kecil dari itu pada panggilan berikutnya (mungkin itu menyimpan daftar), sedangkan m2 10000000 selalu membutuhkan jumlah waktu yang sama (membangun kembali daftar dengan setiap panggilan). Tahu apa yang terjadi? Apakah ada aturan praktis tentang jika dan kapan GHC akan mengotori suatu fungsi? Terima kasih.

Yordania
sumber

Jawaban:

112

GHC tidak mengotori fungsi.

Namun, ia menghitung setiap ekspresi yang diberikan dalam kode paling banyak sekali setiap kali ekspresi lambda di sekitarnya dimasukkan, atau paling banyak sekali jika berada di level teratas. Menentukan di mana ekspresi lambda bisa menjadi sedikit rumit saat Anda menggunakan gula sintaksis seperti dalam contoh Anda, jadi mari kita ubah ini menjadi sintaks desugared yang setara:

m1' = (!!) (filter odd [1..])              -- NB: See below!
m2' = \n -> (!!) (filter odd [1..]) n

(Catatan: Laporan Haskell 98 sebenarnya menjelaskan bagian operator kiri seperti yang (a %)setara dengan \b -> (%) a b, tetapi GHC mendeskripsikannya (%) a. Ini secara teknis berbeda karena dapat dibedakan oleh seq. Saya rasa saya mungkin telah mengirimkan tiket GHC Trac tentang hal ini.)

Diberikan ini, Anda dapat melihat bahwa di m1', ekspresi filter odd [1..]tidak terdapat dalam ekspresi lambda, jadi itu hanya akan dihitung sekali per jalannya program Anda, sementara di m2', filter odd [1..]akan dihitung setiap kali ekspresi lambda dimasukkan, yaitu, pada setiap panggilan m2'. Itu menjelaskan perbedaan waktu yang Anda lihat.


Sebenarnya, beberapa versi GHC, dengan opsi pengoptimalan tertentu, akan berbagi lebih banyak nilai daripada yang ditunjukkan oleh uraian di atas. Ini bisa menjadi masalah dalam beberapa situasi. Misalnya, perhatikan fungsinya

f = \x -> let y = [1..30000000] in foldl' (+) 0 (y ++ [x])

GHC mungkin memperhatikan bahwa ytidak bergantung pada xdan menulis ulang fungsi ke

f = let y = [1..30000000] in \x -> foldl' (+) 0 (y ++ [x])

Dalam hal ini, versi baru jauh kurang efisien karena harus membaca sekitar 1 GB dari memori tempat ydisimpan, sedangkan versi asli akan berjalan dalam ruang konstan dan masuk ke dalam cache prosesor. Faktanya, di bawah GHC 6.12.1, fungsinya fhampir dua kali lebih cepat saat dikompilasi tanpa pengoptimalan daripada saat dikompilasi -O2.

Reid Barton
sumber
1
Biaya untuk mengevaluasi ekspresi (filter ganjil [1 ..]) mendekati nol - bagaimanapun juga itu adalah daftar malas, jadi biaya sebenarnya ada dalam aplikasi (x !! 10000000) ketika daftar sebenarnya dievaluasi. Selain itu, m1 dan m2 tampaknya hanya dievaluasi sekali dengan -O2 dan -O1 (pada ghc 6.12.3 saya) setidaknya dalam pengujian berikut: (test = m1 10000000 seqm1 10000000). Namun ada perbedaan ketika tidak ada tanda pengoptimalan yang ditentukan. Dan kedua varian "f" Anda memiliki residensi maksimum 5356 byte terlepas dari pengoptimalannya (dengan alokasi total yang lebih sedikit ketika -O2 digunakan).
Ed'ka
1
@ Ed'ka: Cobalah program tes ini, dengan definisi di atas f: main = interact $ unlines . (show . map f . read) . lines; kompilasi dengan atau tanpa -O2; lalu echo 1 | ./main. Jika Anda menulis tes seperti main = print (f 5), maka ysampah dapat dikumpulkan seperti yang digunakan dan tidak ada perbedaan antara keduanya f.
Reid Barton
eh, itu seharusnya map (show . f . read), tentu saja. Dan sekarang saya telah mengunduh GHC 6.12.3, saya melihat hasil yang sama seperti di GHC 6.12.1. Dan ya, Anda benar tentang yang asli m1dan m2: versi GHC yang melakukan pengangkatan semacam ini dengan pengoptimalan diaktifkan akan berubah m2menjadi m1.
Reid Barton
Ya, sekarang saya melihat perbedaannya (-O2 pasti lebih lambat). Terima kasih atas contoh ini!
Ed'ka
29

m1 hanya dihitung sekali karena merupakan Formulir Aplikasi Konstan, sedangkan m2 bukan CAF, dan dihitung untuk setiap evaluasi.

Lihat wiki GHC di CAFs: http://www.haskell.org/haskellwiki/Constant_applicative_form

sclv
sumber
1
Penjelasan "m1 dihitung hanya sekali karena ini adalah Formulir Aplikasi Konstan" tidak masuk akal bagi saya. Karena mungkin m1 dan m2 adalah variabel tingkat atas, menurut saya fungsi ini hanya dihitung sekali, tidak peduli mereka CAF atau bukan. Perbedaannya adalah apakah daftar [1 ..]tersebut dihitung hanya sekali selama eksekusi program atau dihitung sekali per aplikasi fungsi, tetapi apakah ini terkait dengan CAF?
Tsuyoshi Ito
1
Dari halaman yang ditautkan: "CAF ... dapat dikompilasi menjadi potongan grafik yang akan dibagikan oleh semua penggunaan atau beberapa kode bersama yang akan menimpa dirinya sendiri dengan beberapa grafik saat pertama kali dievaluasi". Karena m1CAF, yang kedua berlaku dan filter odd [1..](bukan hanya [1..]!) Dihitung hanya sekali. GHC juga dapat mencatat yang m2mengacu pada filter odd [1..], dan menempatkan tautan ke thunk yang sama yang digunakan m1, tetapi itu akan menjadi ide yang buruk: ini dapat menyebabkan kebocoran memori yang besar dalam beberapa situasi.
Alexey Romanov
@Alexey: Terima kasih atas koreksi tentang [1..]dan filter odd [1..]. Selebihnya, saya masih belum yakin. Jika saya tidak salah, CAF hanya relevan ketika kita ingin menyatakan bahwa kompiler dapat menggantikan filter odd [1..]in m2oleh global thunk (yang bahkan mungkin sama dengan yang digunakan di m1). Tetapi dalam situasi penanya, kompilator tidak melakukan "pengoptimalan" itu, dan saya tidak dapat melihat relevansinya dengan pertanyaan.
Tsuyoshi Ito
2
Hal ini relevan yang dapat menggantikannya dalam m1 , dan itu tidak.
Alexey Romanov
13

Ada perbedaan penting antara kedua bentuk tersebut: batasan monomorfisme berlaku untuk m1 tetapi tidak untuk m2, karena m2 secara eksplisit memberikan argumen. Jadi tipe m2 adalah umum tetapi m1 spesifik. Jenis yang ditetapkan adalah:

m1 :: Int -> Integer
m2 :: (Integral a) => Int -> a

Kebanyakan kompiler dan interpreter Haskell (semuanya yang saya tahu sebenarnya) tidak mengotori struktur polimorfik, jadi daftar internal m2 dibuat ulang setiap kali dipanggil, di mana m1 tidak.

mokus
sumber
1
Bermain dengan ini di GHCi tampaknya juga tergantung pada transformasi let-floating (salah satu pass pengoptimalan GHC yang tidak digunakan di GHCi). Dan tentu saja ketika menyusun fungsi-fungsi sederhana ini, pengoptimal dapat membuatnya berperilaku serupa (menurut beberapa tes kriteria saya tetap menjalankan, dengan fungsi dalam modul terpisah dan ditandai dengan pragma NOINLINE). Mungkin itu karena pembuatan daftar dan pengindeksan digabungkan menjadi loop yang sangat ketat.
mokus
1

Saya tidak yakin, karena saya sendiri masih baru mengenal Haskell, tetapi tampaknya ini karena fungsi kedua adalah parametrized dan yang pertama tidak. Sifat dari fungsinya adalah, hasil tergantung pada nilai input dan dalam paradigma fungsional terutama hanya bergantung pada input. Implikasi yang jelas adalah bahwa fungsi tanpa parameter selalu mengembalikan nilai yang sama berulang kali, apa pun yang terjadi.

Ternyata ada mekanisme pengoptimalan dalam kompilator GHC yang memanfaatkan fakta ini untuk menghitung nilai fungsi seperti itu hanya sekali untuk keseluruhan waktu proses program. Ia melakukannya dengan malas, untuk memastikannya, tapi tetap melakukannya. Saya memperhatikannya sendiri, ketika saya menulis fungsi berikut:

primes = filter isPrime [2..]
    where isPrime n = null [factor | factor <- [2..n-1], factor `divides` n]
        where f `divides` n = (n `mod` f) == 0

Kemudian untuk mengujinya, saya masuk GHCI dan menulis: primes !! 1000. Butuh beberapa detik, tapi akhirnya saya mendapat jawaban: 7927. Lalu aku menelepon primes !! 1001dan mendapat jawabannya seketika. Demikian pula dalam sekejap saya mendapatkan hasil untuk take 1000 primes, karena Haskell harus menghitung seluruh daftar seribu elemen untuk mengembalikan elemen ke-1001 sebelumnya.

Jadi, jika Anda dapat menulis fungsi Anda sedemikian rupa sehingga tidak membutuhkan parameter, Anda mungkin menginginkannya. ;)

Sventimir
sumber