Eksilerin hangi özelliği kuyruk özyineleme modulo eksilerini ortadan kaldırmaya izin verir?


14

Bir çağrının doğrudan sonucunu veren işlevlerin yinelemeli döngüler olarak yeniden yazılabileceği temel kuyruk özyineleme ortadan kaldırılması fikrine aşinayım .

foo(...):
    # ...
    return foo(...)

Ayrıca, özel bir durum olarak, özyinelemeli çağrı bir çağrıya sarılırsa işlevin yeniden yazılabileceğini de anlıyorum cons.

foo(...):
    # ...
    return (..., foo(...))

Hangi özellik buna consizin veriyor? consYinelemeli bir kuyruk çağrısını yinelemeli olarak yeniden yazma yeteneğimizi yok etmeden başka hangi işlevler sarılabilir?

GCC (ancak Clang değil), bu "kuyruk özyineleme modulo çarpımı " örneğini optimize edebilir , ancak hangi mekanizmanın bunu keşfetmesine izin verdiğini veya dönüşümlerini nasıl yaptığını net değildir.

pow(x, n):
    if n == 0: return 1
    else if n == 1: return x
    else: return x * pow(x, n-1)

1
Godbolt derleyici gezgini bağlantınızda, işleviniz vardır if(n==0) return 0;(sorunuzdaki gibi 1 döndürmez). x^0 = 1, bu bir hata. Yine de sorunun geri kalanı için önemli olduğu için değil; yinelemeli asm ilk önce bu özel durumu kontrol eder. Ancak garip bir şekilde, yinelemeli uygulama 1 * x, bir floatsürüm yapsak bile, kaynakta mevcut olmayanların çoğunu ortaya koyuyor . gcc.godbolt.org/z/eqwine (ve gcc yalnızca başarılı -ffast-math.)
Peter Cordes

@PeterCordes İyi yakaladım. return 0Düzeltildi. 1 ile çarpmak ilginçtir. Ne yapacağımdan emin değilim.
Makspm

Bence bu, GCC'yi bir döngüye dönüştürürken dönüşüm yolunun bir yan etkisi. Açıkçası gcc burada bazı kaçırılmış optimizasyonlar var, örneğin her seferinde aynı değer çarpılsa bile, floatolmadan bunu kaçırmak -ffast-math. (1.0f` hariç hangi yapıştırma noktası olabilir?)
Peter Cordes

Yanıtlar:


12

GCC büyük olasılıkla geçici kuralları kullanıyor olsa da, bunları aşağıdaki şekilde türetebilirsiniz. Çok belirsiz bir şekilde tanımlandığınız powiçin örnek vermek için kullanacağım foo. Ayrıca, Oz'un sahip olduğu ve Bilgisayar Programlama Kavramları, Teknikleri ve Modelleri bölümünde tartışıldığı foogibi tek atama değişkenlerine göre son çağrı optimizasyonunun bir örneği olarak anlaşılabilir . Tek atama değişkenlerini kullanmanın yararı, bildirici bir programlama paradigması içinde kalmasına izin vermesidir. Temel olarak, yapı dönüşlerinin her alanının, daha sonra ek bağımsız değişkenler olarak iletilen tekli atama değişkenleriyle temsil edilmesini sağlayabilirsiniz . sonra kuyruk yinelemeli olurfoofoofoovoiddönen işlev. Bunun için özel bir zekâya gerek yoktur.

Dönersek pow, önce dönüşmek devam geçen tarzı . powdönüşür:

pow(x, n):
    return pow2(x, n, x => x)

pow2(x, n, k):
    if n == 0: return k(1)
    else if n == 1: return k(x)
    else: return pow2(x, n-1, y => k(x*y))

Tüm çağrılar artık kuyruk çağrılarıdır. Ancak, kontrol yığını, süreklilikleri temsil eden kapaklarda yakalanan ortamlara taşınmıştır.

Ardından, devamları işlevsizleştirin . Yalnızca bir özyinelemeli çağrı olduğundan, işlevsiz süreklilikleri temsil eden sonuçtaki veri yapısı bir listedir. Biz:

pow(x, n):
    return pow2(x, n, Nil)

pow2(x, n, k):
    if n == 0: return applyPow(k, 1)
    else if n == 1: return applyPow(k, x)
    else: return pow2(x, n-1, Cons(x, k))

applyPow(k, acc):
    match k with:
        case Nil: return acc
        case Cons(x, k):
            return applyPow(k, x*acc)

Ne applyPow(k, acc)yaptığı gibi, serbest Monoid yani bir liste almak olduğunu k=Cons(x, Cons(x, Cons(x, Nil)))ve dönüştürebilmek x*(x*(x*acc)). Ancak *çağrışımsal olduğu ve genellikle birim ile bir monoid oluşturduğundan 1, bunu yeniden birleştirebiliriz ((x*x)*x)*accve basitlik için 1üretime başlayabiliriz (((1*x)*x)*x)*acc. Önemli olan şey, bizden önce bile sonucu kısmen hesaplayabilmemizdir acc. Bu k, sonunda "yorumlayacağımız" tamamlanmamış bazı "sözdizimi" olan bir liste olarak dolaşmak yerine, onu gittikçe "yorumlayabileceğimiz" anlamına gelir. Sonuç olarak Nil, 1bu durumda monoid birimiyle, bu durumda Consmonoidin çalışmasıyla değiştirebiliriz *ve şimdi k"çalışan ürün" ü temsil ediyoruz .applyPow(k, acc)daha sonra, k*accyeniden pow2üretim yapabileceğimiz ve üretimi basitleştirebileceğimiz hale gelir :

pow(x, n):
    return pow2(x, n, 1)

pow2(x, n, k):
    if n == 0: return k
    else if n == 1: return k*x
    else: return pow2(x, n-1, k*x)

Orijinalin kuyruk özyinelemeli, akümülatör geçen stil versiyonu pow.

Tabii ki, GCC'nin bütün bu mantığı derleme zamanında yaptığını söylemiyorum. GCC'nin hangi mantığı kullandığını bilmiyorum. Demek istediğim, bu akıl yürütmeyi bir kez yaptık, kalıbı tanımak ve orijinal kaynak kodunu hemen bu son forma çevirmek nispeten kolaydır. Ancak, CPS dönüşümü ve işlev bozukluğu dönüşümü tamamen genel ve mekaniktir. Buradan, birleşmiş süreklilikleri ortadan kaldırmak için füzyon, ormansızlaşma veya süper derleme teknikleri kullanılabilir. Birleştirilmiş devamların tüm tahsisini ortadan kaldırmak mümkün değilse, spekülatif dönüşümler atılabilir. Bununla birlikte, bunun her zaman, tam genel olarak yapmak için çok pahalı olacağından şüpheleniyorum, bu nedenle daha fazla geçici yaklaşımlar.

Eğer gülünç olmak istiyorsanız, CPS'yi ve süreklilik gösterimlerini veri olarak kullanan, ancak kuyruk özyineleme-modulo-eksilerinden farklı bir şey yapan Geri Dönüşüm Sürdürme makalesine göz atabilirsiniz . Bu, dönüştürmeyle nasıl işaretçi tersine çevirici algoritmalar oluşturabileceğinizi açıklar.

Bu CPS dönüşüm ve işlev bozukluğu modeli, anlamak için oldukça güçlü bir araçtır ve burada listelediğim bir dizi makalede iyi bir etki için kullanılır .


GCC'nin burada gösterdiğiniz Devam Eden Geçiş Tarzı yerine kullandığı teknik, sanırım Statik Tek Atama Formu.
Davislor

@Davislor SSA, CPS ile ilgili olsa da, bir prosedürün kontrol akışını etkilemez ve yığıtı ilişkilendirmez (veya dinamik olarak tahsis edilmesi gereken veri yapılarını başka şekilde tanıtmaz). SSA ile ilgili olarak, CPS "çok fazla şey yapar", bu yüzden İdari Normal Form (ANF) SSA'ya daha yakındır. Bu nedenle GCC SSA kullanır, ancak SSA kontrol yığının işlenebilir bir veri yapısı olarak görüntülenmesine yol açmaz.
Derek Elkins SE

Sağ. Ben cevap veriyordum, “GCC'nin bütün bu mantığı derleme zamanında yaptığını söylemiyorum. GCC'nin hangi mantığı kullandığını bilmiyorum. ” Cevabım da benzer şekilde, herhangi bir derleyicinin kullandığı uygulama yöntemi olduğunu söylemeden dönüşümün teorik olarak haklı olduğunu gösteriyordu. (Bildiğiniz gibi, birçok derleyici bir programı optimizasyon sırasında CPS'ye dönüştürür.)
Davislor

8

Bir süre çalıların etrafında çırpacağım, ama bir nokta var.

yarıgruplar

Cevap, ikili indirgeme işleminin ilişkisel özelliğidir .

Bu oldukça soyut, ama çarpma iyi bir örnek. Eğer x , y ve z , bazı doğal veya numaralar (veya tam sayılar ya da rasyonel sayı veya reel sayılar ya da kompleks numaralar veya olan K x K matrisler veya bütün bir demet daha fazla şeyler), o zaman X x Y aynı tür her ikisi de sayısının x ve y . İki sayı ile başladık, bu bir ikili işlem ve bir tane aldık, bu yüzden sahip olduğumuz sayı sayısını bir azaltarak bu bir azaltma işlemi haline getirdik. Ve ( x × y ) × z her zaman x × ( y × ile aynıdır)z ), ilişkilendirilebilir özelliktir.

(Tüm bunları zaten biliyorsanız, bir sonraki bölüme atlayabilirsiniz.)

Bilgisayar biliminde aynı şekilde çalışan sıkça gördüğünüz birkaç şey:

  • çarpmak yerine bu tür numaralardan herhangi birini eklemek
  • dizeleri bitiştirmek ( "a"+"b"+"c"olan "abc"sen ile başlayan olsun "ab"+"c"ya "a"+"bc")
  • İki listeyi birleştirme. [a]++[b]++[c]benzer şekilde [a,b,c]ya arkadan öne ya da önden arkaya doğrudur.
  • consEğer kafa tek bir liste olarak düşünüyorsanız, kafa ve kuyruk üzerinde. Bu sadece iki listeyi birleştiriyor.
  • birlik veya setlerin kesişme noktasını almak
  • Boole ve Boole veya
  • bitsel &, |ve^
  • fonksiyonların bileşimi: ( fg ) ∘ h x = f ∘ ( gh ) x = f ( g ( h ( x )))
  • maksimum ve minimum
  • toplama modulo p

Yapmayan bazı şeyler:

  • çıkarma, çünkü 1- (1-2) ≠ (1-1) -2
  • xy = tan ( x + y ), çünkü tan (π / 4 + π / 4) tanımsız
  • negatif sayılar üzerinde çarpma, çünkü -1 × -1 negatif bir sayı değildir
  • üç problemin de olduğu tamsayıların bölünmesi!
  • mantıklı değil, çünkü sadece bir işlenen var, iki değil
  • int print2(int x, int y) { return printf( "%d %d\n", x, y ); }, gibi print2( print2(x,y), z );ve print2( x, print2(y,z) );farklı çıktı var.

Adını verdiğimiz yeterince kullanışlı bir kavram. Bu özelliklere sahip bir işleme sahip bir küme bir yarıgruptur . Yani, çarpma altındaki gerçek sayılar bir yarıgruptur. Ve sorunuz bu tür soyutlamanın gerçek dünyada faydalı hale gelmesinin yollarından biri olduğu ortaya çıkıyor. Yarıgrup operasyonlarının tümü sizin istediğiniz şekilde optimize edilebilir.

Bunu Evde Deneyin

Bildiğim kadarıyla, bu teknik ilk olarak 1974'te Daniel Friedman ve David Wise'ın “Stilize Özyinelemelerin İterasyonlara Katlanması” başlıklı makalesinde açıklanmıştı , ancak ihtiyaç duyduklarından birkaç özellik daha varsaydılar.

Haskell bunu göstermek için harika bir dildir, çünkü Semigroupstandart kütüphanesinde typeclass vardır. Genel bir Semigroupoperatörün çalışmasını çağırır <>. Listeler ve dizeler birer örnek olduğundan Semigroup, örnekleri <>birleştirme operatörü olarak tanımlanır ++. Ve sağ ithalat ile, [a] <> [b]bir başka adıdır [a] ++ [b]vardır [a,b].

Peki ya sayılar? Biz sadece bu sayısal türleri altında yarıgruplar olan testere ya ek ya da çarpma! Peki hangisi <>için Double? Her ikisinden biri! Haskell tiplerini tanımlar Product Double, where (<>) = (*)aynı zamanda (yani, Haskell gerçek tanımı) ve Sum Double, where (<>) = (+).

Bir kırışıklık, 1'in çarpımsal kimlik olduğu gerçeğini kullandığınızdır. Kimliğe sahip bir yarıgrup monoid olarak adlandırılır ve Data.Monoidbir tip sınıfının genel kimlik öğesini çağıran Haskell paketinde tanımlanır mempty. Sum, ProductVe bu liste, her bir kimlik elemanı (0, 1 ve var []bunlar örnekleri böylece, sırasıyla) Monoidve ayrıca Semigroup. (Bir monad ile karıştırılmamalı , bu yüzden onları bile yetiştirdiğimi unut.)

Bu, algoritmanızı monoidler kullanarak bir Haskell işlevine çevirmek için yeterli bilgi:

module StylizedRec (pow) where

import Data.Monoid as DM

pow :: Monoid a => a -> Word -> a
{- Applies the monoidal operation of the type of x, whatever that is, by
 - itself n times.  This is already in Haskell as Data.Monoid.mtimes, but
 - let’s write it out as an example.
 -}
pow _ 0 = mempty -- Special case: Return the nullary product.
pow x 1 = x      -- The base case.
pow x n = x <> (pow x (n-1)) -- The recursive case.

Önemli olarak, bunun kuyruk özyineleme modulo yarıgrupu olduğuna dikkat edin: her durum ya bir değer, kuyruk özyinelemeli çağrı veya her ikisinin yarıgrup ürünüdür. Ayrıca, bu örnek memptyvakalardan biri için kullanıldı , ancak buna ihtiyacımız olmasaydı, daha genel tip sınıfla yapabilirdik Semigroup.

Bu programı GHCI'ye yükleyelim ve nasıl çalıştığını görelim:

*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49

Biz ilan nasıl hatırla powjenerik için Monoidkimin tip olarak adlandırdığımız, a? Biz tipin anlamak için GHCI yeterince bilgi verdi aburada Product Integerbir olduğunu instanceve Monoidkimin <>operasyon tamsayı çarpımıdır. Yani pow 2 4özyinelemeli olarak genişler 2<>2<>2<>2, 2*2*2*2ya da 16. Çok uzak çok iyi.

Ancak bizim fonksiyonumuz sadece genel monoid işlemleri kullanır. Daha önce, operasyonu olan başka bir Monoidçağrı örneği olduğunu söylemiştim . Bunu deneyebilir miyiz?Sum<>+

*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14

Şimdi aynı genişleme bize 2+2+2+2değil 2*2*2*2. Çarpma, çarpma için çarpmadır!

Ama bir Haskell monoid örneğine bir örnek daha verdim: işlemleri birleştirme olan listeler.

*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]

Yazma [2]derleyiciye bunun bir liste olduğunu, <>listelerde de ++öyle [2]++[2]++[2]++[2]olduğunu söyler [2,2,2,2].

Son olarak, bir Algoritma (İki, Aslında)

Basitçe değiştirerek xile [x], sen kullandığı listesini oluşturur birine modülo yarıgrubuna bir özyineleme olduğunu jenerik algoritma dönüştürmek. Hangi liste? Algoritmanın uygulandığı öğelerin listesi <>. Yalnızca listelerin de bulunduğu yarıgrup işlemleri kullandığımız için, sonuçta ortaya çıkan liste orijinal hesaplama için izomorfik olacaktır. Ve orijinal operasyon ilişkisel olduğu için, öğeleri arkadan öne veya önden arkaya eşit derecede iyi değerlendirebiliriz.

Algoritmanız temel bir duruma ulaşır ve sonlanırsa, liste boş olmaz. Terminal durumu bir şey döndürdüğünden, listenin son elemanı olacak, bu yüzden en az bir elemanı olacaktır.

Bir listenin her öğesine sırayla bir ikili azaltma işlemi nasıl uygularsınız? Bu doğru, bir kat. Eğer yerini alabilir Yani [x]için x, azaltılmasınıöngörmektedir elemanların listesini almak <>ve sonra ya sağ kat veya listeyi sol kat:

*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16

İle versiyon foldr1aslında olduğu gibi, standart kütüphanesinde bulunan sconcatiçin Semigroupve mconcatiçin Monoid. Listede tembel bir sağ kat yapar. Olduğunu, bu genişler [Product 2,Product 2,Product 2,Product 2]için 2<>(2<>(2<>(2))).

Bu durumda bu etkili değildir, çünkü hepsini oluşturana kadar tek tek terimlerle hiçbir şey yapamazsınız. (Bir noktada burada sağ kıvrımların ne zaman ve katı sol kıvrımların ne zaman kullanılacağı hakkında bir tartışma yaptım, ancak çok uzağa gitti.)

İle sürüm foldl1'kesinlikle değerlendirilmiş bir sol kat. Yani, sıkı bir akümülatörlü bir kuyruk özyinelemeli işlev. Bu değerlendirilir (((2)<>2)<>2)<>2, hemen hesaplanır ve gerektiğinde hesaplanmaz. (En azından, katlamanın içinde herhangi bir gecikme yoktur: katlanan liste burada tembel değerlendirme içerebilecek başka bir işlev tarafından oluşturulur.) Böylece, kat hesaplar (4<>2)<>2, sonra hemen hesaplar 8<>2, sonra 16. Bu yüzden operasyonun çağrışımsal olması gerekti: parantezlerin gruplandırılmasını değiştirdik!

Katı sol kat, GCC'nin yaptıklarına eşdeğerdir. Önceki örnekteki en soldaki sayı akümülatördür, bu durumda çalışan bir üründür. Her adımda, listedeki bir sonraki sayıyla çarpılır. Bunu ifade etmenin başka bir yolu da: çalışan ürünü bir akümülatörde tutarak, çarpılacak değerler üzerinde tekrarlarsınız ve her bir yinelemede, akümülatörü bir sonraki değerle çarparsınız. Yani, whilekılık değiştirmiş bir döngü.

Bazen bu kadar verimli hale getirilebilir. Derleyici, bellekteki liste veri yapısını optimize edebilir. Teorik olarak, burada yapması gerektiğini anlamak için derleme zamanında yeterli bilgiye sahiptir: [x]bir singleton, [x]<>xsaynı şekilde cons x xs. İşlevin her yinelemesi aynı yığın çerçevesini yeniden kullanabilir ve parametreleri yerinde güncelleyebilir.

Belirli bir durumda ya sağ kat ya da katı sol kat daha uygun olabilir, bu yüzden hangisini istediğinizi bilin. Sadece sağ katlamanın yapabileceği bazı şeyler de vardır (tüm girişi beklemeden etkileşimli çıktı üretmek ve sonsuz bir listede çalışmak gibi). Bununla birlikte, burada, bir dizi işlemi basit bir değere indiriyoruz, bu yüzden istediğimiz katı bir katlama.

Gördüğünüz gibi, herhangi bir yarıgrupta (bir örneği çarpma altındaki olağan sayısal türlerden herhangi biri olan) kuyruk özyineleme modülünü tembel bir sağ kat veya katı bir sol kat için otomatik olarak optimize etmek mümkündür. Haskell.

Genelleme

İkili işlemin iki bağımsız değişkeninin, başlangıç ​​değeri sonucunuzla aynı tür olduğu sürece aynı tür olması gerekmez. (Elbette argümanları her zaman yaptığınız katlama sırasına göre sola veya sağa çevirebilirsiniz.) Böylece güncellenmiş bir dosya almak veya başlangıç ​​değeri ile başlayarak bir dosyaya tekrar tekrar yama ekleyebilirsiniz. 1.0, kayan nokta sonucu biriktirmek için tamsayılara bölün. Veya bir liste almak için öğeleri boş listeye ekleyin.

Başka bir genelleme türü, kıvrımları listelere değil, diğer Foldableveri yapılarına uygulamaktır. Genellikle, değişmeyen doğrusal bağlantılı liste, belirli bir algoritma için istediğiniz veri yapısı değildir. Yukarıda bahsetmediğim bir konu, listenin önüne arkaya eleman eklemenin çok daha verimli olması ve işlem değişmeli olmadığında, işlemin xsoluna ve sağına uygulanmamasıdır . aynısı. Bu nedenle x, sağda <>ve solda uygulanabilecek bir algoritmayı temsil etmek için bir çift liste veya ikili ağaç gibi başka bir yapı kullanmanız gerekir .

İlişkilendirilebilir özelliğin, işlemleri bölme ve fethetme gibi diğer yararlı şekillerde yeniden gruplandırmanıza izin verdiğini unutmayın:

times :: Monoid a => a -> Word -> a
times _ 0 = mempty
times x 1 = x
times x n | even n    = y <> y
          | otherwise = x <> y <> y
  where y = times x (n `quot` 2)

Veya her bir iş parçacığının bir alt aralığı daha sonra diğerleriyle birleştirilen bir değere düşürdüğü otomatik paralellik.


1
Biz birleşebilirlik bu optimizasyon yapmak GCC yeteneği için önemli olduğunu test için bir deney yapabiliriz: Bir pow(float x, unsigned n)versiyon gcc.godbolt.org/z/eqwine yalnızca optimize -ffast-mathima, ( -fassociative-math. Sıkı kayan nokta tabii olduğunu değil farklı geçicilere çünkü ilişkisel = farklı yuvarlama). 1.0f * xC soyut makinesinde bulunmayan (ancak her zaman aynı sonucu verecek) bir tanıtır . Sonra n-1 çarpımları do{res*=x;}while(--n!=1)özyinelemeyle aynıdır, bu yüzden bu kaçırılan bir optimizasyondur.
Peter Cordes
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.