Haskell'in kuyruk özyinelemeli optimizasyonu var mı?


90

Bugün unix'te "zaman" komutunu keşfettim ve Haskell'de kuyruk özyinelemeli ve normal özyinelemeli işlevler arasındaki çalışma zamanları arasındaki farkı kontrol etmek için kullanacağımı düşündüm.

Aşağıdaki fonksiyonları yazdım:

--tail recursive
fac :: (Integral a) => a -> a
fac x = fac' x 1 where
    fac' 1 y = y
    fac' x y = fac' (x-1) (x*y) 

--normal recursive
facSlow :: (Integral a) => a -> a
facSlow 1 = 1
facSlow x = x * facSlow (x-1)

Bunların yalnızca bu projede kullanılmak üzere olduğunu akılda tutarak, sıfırları veya negatif sayıları kontrol etme zahmetine girmedim.

Bununla birlikte, her biri için bir ana yöntem yazdıktan, onları derledikten ve "zaman" komutuyla çalıştırdıktan sonra, her ikisi de, normal özyinelemeli işlev ile kuyruk özyinelemeli olanı sınırlayan benzer çalışma zamanlarına sahipti . Bu, lisp'te tail-recursive optimization ile ilgili duyduğuma aykırı. Bunun sebebi nedir?


8
TCO'nun bazı çağrı yığınlarını kurtarmak için bir optimizasyon olduğuna inanıyorum, CPU zamanından tasarruf edeceğiniz anlamına gelmez. Yanlışsa düzeltin.
Jerome

3
Lisp ile test etmedim, ancak okuduğum eğitici, yığın oluşturmanın kendi başına daha fazla işlemci maliyeti oluşturduğunu ima ederken, yinelemeli olarak derlenen kuyruk özyinelemeli çözüm bunu yapmak için herhangi bir enerji (zaman) harcamadı ve bu nedenle daha verimliydi.
haskell rascal

1
@Jerome pek çok şeye bağlıdır, ancak tipik olarak önbellekler de devreye girer, bu nedenle TCO genellikle daha hızlı bir program da üretir ..
Kristopher Micinski

Bunun sebebi nedir? Tek kelimeyle: tembellik.
Dan Burton

İlginç bir şekilde, sizin facghc'nin product [n,n-1..1]bir yardımcı işlevi kullanarak nasıl hesapladığı aşağı yukarı doğru prod, ama elbette product [1..n]daha basit olurdu. Sadece, ghc'nin basit bir toplayıcıya derleyebileceğinden çok emin olduğu türden bir şey olduğu gerekçesiyle ikinci argümanında bunu katı hale getirmediklerini varsayabilirim.
AndrewC

Yanıtlar:


171

Haskell, özyinelemeyi uygulamak için tembel değerlendirme kullanır, bu nedenle her şeyi gerektiğinde bir değer sağlamak için bir söz olarak ele alır (buna thunk denir). Thunks yalnızca ilerlemek için gerektiği kadar azaltılır, artık değil. Bu, matematiksel olarak bir ifadeyi basitleştirme şeklinize benzer, bu yüzden onu bu şekilde düşünmek yardımcı olur. Değerlendirme sırası olması değil sizin koduyla belirtilen derleyici için kullanılan sadece kuyruk çağrı eleme konum bile daha zeki optimizasyonlar bir sürü yapmak için izin verir. Optimizasyon istiyorsanız ile derleyin -O2!

facSlow 5Bir örnek olay olarak nasıl değerlendirdiğimizi görelim :

facSlow 5
5 * facSlow 4            -- Note that the `5-1` only got evaluated to 4
5 * (4 * facSlow 3)       -- because it has to be checked against 1 to see
5 * (4 * (3 * facSlow 2))  -- which definition of `facSlow` to apply.
5 * (4 * (3 * (2 * facSlow 1)))
5 * (4 * (3 * (2 * 1)))
5 * (4 * (3 * 2))
5 * (4 * 6)
5 * 24
120

Yani endişelendiğiniz gibi, herhangi bir hesaplama yapılmadan önce bir sayı birikimimiz var, ancak endişelendiğinizden farklı olarak , facSlowsona erdirmeyi bekleyen hiçbir işlev çağrısı yığını yok - her bir azaltma uygulanıyor ve kayboluyor, içinde bir yığın çerçevesi bırakıyor . uyanmak (çünkü (*)katıdır ve bu nedenle ikinci argümanının değerlendirilmesini tetikler).

Haskell'in özyinelemeli işlevleri çok özyinelemeli bir şekilde değerlendirilmez! Etrafta dolaşan tek çağrı yığını, çarpımların kendisidir. Eğer (*)sıkı bir veri yapıcısı olarak görülüyor, bu şekilde bilinen şeydir korunan (genellikle böyle olarak adlandırılır rağmen özyineleme olmayan - daha fazla erişime tarafından zorla veri kurucular onun ardından geriye ne -strict veri kurucular,).

Şimdi kuyruk özyinelemesine bakalım fac 5:

fac 5
fac' 5 1
fac' 4 {5*1}       -- Note that the `5-1` only got evaluated to 4
fac' 3 {4*{5*1}}    -- because it has to be checked against 1 to see
fac' 2 {3*{4*{5*1}}} -- which definition of `fac'` to apply.
fac' 1 {2*{3*{4*{5*1}}}}
{2*{3*{4*{5*1}}}}        -- the thunk "{...}" 
(2*{3*{4*{5*1}}})        -- is retraced 
(2*(3*{4*{5*1}}))        -- to create
(2*(3*(4*{5*1})))        -- the computation
(2*(3*(4*(5*1))))        -- on the stack
(2*(3*(4*5)))
(2*(3*20))
(2*60)
120

Böylece kuyruk özyinelemesinin tek başına size zaman veya yer kazandırmadığını görebilirsiniz. Yalnızca genelden daha fazla adım facSlow 5atmakla kalmaz, aynı zamanda iç içe geçmiş bir thunk (burada gösterildiği gibi {...}) oluşturur - bunun için fazladan bir alana ihtiyaç duyar - bu, gelecekteki hesaplamayı, yapılacak iç içe çarpımları açıklar.

Bu thunk sonra geçme tarafından ortaya olup bu yığın hesaplama yeniden dibe. Her iki sürüm için de çok uzun hesaplamalarda yığın taşmasına neden olma tehlikesi vardır.

Bunu elle optimize etmek istiyorsak, tek yapmamız gereken katı hale getirmek. Sen sıkı uygulama operatörünü kullanabilirsiniz $!tanımlamak için

facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
    facS' 1 y = y
    facS' x y = facS' (x-1) $! (x*y) 

Bu facS', ikinci argümanında katı olmaya zorlar . (İlk argümanında zaten katıdır çünkü hangi tanımın facS'uygulanacağına karar vermek için bunun değerlendirilmesi gerekir .)

Bazen katılık çok yardımcı olabilir, bazen büyük bir hatadır çünkü tembellik daha etkilidir. İşte bu iyi bir fikir:

facSlim 5
facS' 5 1
facS' 4 5 
facS' 3 20
facS' 2 60
facS' 1 120
120

Sanırım başarmak istediğin şey bu.

Özet

  • Kodunuzu optimize etmek istiyorsanız, birinci adım, -O2
  • Kuyruk özyineleme, yalnızca hiçbir şey oluşmadığında iyidir ve sıkılık eklemek genellikle, uygunsa ve olduğunda bunu önlemeye yardımcı olur. Bu, daha sonra ihtiyaç duyulan bir sonucu aynı anda oluşturduğunuzda olur.
  • Bazen kuyruk özyineleme kötü bir plandır ve korumalı özyineleme daha uygun olur, yani oluşturduğunuz sonuca parça parça, parça parça ihtiyaç duyulduğunda. Bkz bu soruyu hakkında foldrve foldlörneğin ve birbirlerine karşı onları test.

Bu ikisini deneyin:

length $ foldl1 (++) $ replicate 1000 
    "The size of intermediate expressions is more important than tail recursion."
length $ foldr1 (++) $ replicate 1000 
    "The number of reductions performed is more important than tail recursion!!!"

foldl1kuyruk özyinelemelidir, oysa foldr1korumalı özyinelemeyi gerçekleştirir , böylece ilk öğe sonraki işlem / erişim için hemen sunulur. (Birincisi, bir kerede sola "parantezler" (...((s+s)+s)+...)+soluşturarak girdi listesini sonuna kadar zorlar ve tüm sonuçlarından çok daha kısa sürede gelecekteki hesaplamaların büyük bir kısmını oluşturur; ikinci parantez yavaş yavaş s+(s+(...+(s+s)...))girdiyi tüketerek parça parça sıralayın, böylece her şey sabit bir alanda optimizasyonlarla çalışabilir).

Hangi donanımı kullandığınıza bağlı olarak sıfır sayısını ayarlamanız gerekebilir.


1
@WillNess Bu mükemmel, teşekkürler. geri çekilmeye gerek yok. Artık gelecek nesil için daha iyi bir cevap olduğunu düşünüyorum.
AndrewC

4
Bu harika, ancak katılık analizine bir selam önerebilir miyim ? Sanırım bu, GHC'nin herhangi bir makul son sürümünde, kuyruk özyinelemeli faktöriyel için neredeyse kesinlikle işi yapacak.
dfeuer

16

facİşlevin, korumalı özyineleme için iyi bir aday olmadığı belirtilmelidir . Kuyruk özyineleme buraya gitmenin yoludur. Tembellik nedeniyle, fac'işlevinizde TCO'nun etkisini alamazsınız çünkü biriktirici argümanları, değerlendirildiğinde büyük bir yığın gerektirecek olan büyük thunks oluşturmaya devam eder. Bunu önlemek ve TCO'nun istenen etkisini elde etmek için, bu biriktirici argümanlarını katı hale getirmeniz gerekir.

{-# LANGUAGE BangPatterns #-}

fac :: (Integral a) => a -> a
fac x = fac' x 1 where
  fac' 1  y = y
  fac' x !y = fac' (x-1) (x*y)

GHC kullanarak -O2(veya sadece -O) derlerseniz, bunu muhtemelen sıkılık analizi aşamasında kendi başına yapacaktır .


4
Sanırım bununla $!olduğundan daha açık BangPatterns, ama bu iyi bir cevap. Özellikle katılık analizinden bahsediliyor.
singpolyma

7

Haskell'de kuyruk özyineleme ile ilgili wiki makalesine göz atmalısınız . Özellikle, ifade değerlendirmesi nedeniyle, istediğiniz özyineleme türü korumalı özyinelemedir. Kaputun altında neler olup bittiğinin ayrıntılarını çözerseniz (Haskell için soyut makinede), katı dillerdeki kuyruk özyinelemesiyle aynı türden bir şey elde edersiniz. Bununla birlikte, tembel işlevler için tekdüze bir sözdiziminiz vardır (kuyruk özyineleme sizi katı bir değerlendirmeye bağlar, oysa korumalı özyineleme daha doğal çalışır).

(Haskell'i öğrenirken, bu wiki sayfalarının geri kalanı da harika!)


0

Doğru hatırlarsam, GHC otomatik olarak düz özyinelemeli işlevleri kuyruk özyinelemeli optimize edilmiş işlevlere optimize eder.

Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.