Apa properti kontra memungkinkan penghapusan kontra modulo rekursi ekor?

14

Saya akrab dengan gagasan penghapusan rekursi ekor dasar , di mana fungsi yang mengembalikan hasil langsung dari panggilan untuk diri mereka sendiri dapat ditulis ulang sebagai loop berulang.

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

Saya juga mengerti bahwa, sebagai kasus khusus, fungsinya masih dapat ditulis ulang jika panggilan rekursif dibungkus dengan panggilan cons.

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

Properti apa yang consmemungkinkan ini? Apa fungsi selain yang consdapat membungkus panggilan ekor rekursif tanpa menghancurkan kemampuan kita untuk menulis ulang secara iteratif?

GCC (tetapi bukan Dentang) dapat mengoptimalkan contoh " multiplikasi modulo rekursi ekor " ini , tetapi tidak jelas mekanisme apa yang memungkinkannya menemukan ini atau bagaimana ia melakukan transformasi.

pow(x, n):
    if n == 0: return 1
    else if n == 1: return x
    else: return x * pow(x, n-1)
Maxpm
sumber
1
Di tautan explorer compiler Godbolt Anda, fungsi Anda telah if(n==0) return 0;(tidak mengembalikan 1 seperti dalam pertanyaan Anda). x^0 = 1, jadi itu bug. Namun, itu tidak penting untuk sisa pertanyaan; aserative itm memeriksa untuk kasus khusus terlebih dahulu. Tapi anehnya, implementasi berulang memperkenalkan banyak 1 * xyang tidak ada di sumbernya, bahkan jika kita membuat floatversi. gcc.godbolt.org/z/eqwine (dan gcc hanya berhasil dengan -ffast-math.)
Peter Cordes
@PeterCordes Tangkapan yang bagus. The return 0telah diperbaiki. Perkalian dengan 1 menarik. Saya tidak yakin apa yang membuatnya.
Maks.
Saya pikir itu adalah efek samping dari cara GCC mentransformasikannya ketika mengubahnya menjadi sebuah loop. Jelas gcc memiliki beberapa optimasi yang terlewatkan di sini, mis. Melewatkannya floattanpa -ffast-math, meskipun nilainya sama dengan yang dikalikan setiap waktu. (Kecuali untuk 1.0f` yang mungkin menjadi titik puncaknya?)
Peter Cordes

Jawaban:

12

Meskipun GCC kemungkinan menggunakan aturan ad-hoc, Anda dapat menurunkannya dengan cara berikut. Saya akan gunakan powuntuk mengilustrasikan karena Anda foodidefinisikan secara samar-samar. Juga, foomungkin lebih baik dipahami sebagai contoh optimasi panggilan terakhir sehubungan dengan variabel penugasan tunggal sebagaimana bahasa Oz miliki dan sebagaimana dibahas dalam Konsep, Teknik, dan Model Pemrograman Komputer . Manfaat menggunakan variabel penugasan tunggal adalah memungkinkannya tersisa dalam paradigma pemrograman deklaratif. Pada dasarnya, Anda dapat meminta setiap bidang foopengembalian struct diwakili oleh variabel penugasan tunggal yang kemudian diteruskan ke foosebagai argumen tambahan. fookemudian menjadi ekor-rekursifvoidfungsi kembali. Tidak ada kepintaran khusus yang diperlukan untuk ini.

Kembali ke pow, pertama, mentransformasikannya menjadi gaya passing berkelanjutan . powmenjadi:

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))

Semua panggilan adalah panggilan ekor sekarang. Namun, tumpukan kontrol telah dipindahkan ke lingkungan yang ditangkap di penutup yang mewakili kelanjutan.

Selanjutnya, nonaktifkan kontinuitas. Karena hanya ada satu panggilan rekursif, struktur data yang dihasilkan mewakili kelanjutan yang tidak berfungsi adalah daftar. Kita mendapatkan:

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)

Apa yang applyPow(k, acc)dilakukan adalah mengambil daftar, yaitu bebas monoid, suka k=Cons(x, Cons(x, Cons(x, Nil)))dan membuatnya menjadi x*(x*(x*acc)). Tetapi karena *asosiatif dan umumnya membentuk monoid dengan unit 1, kita dapat menghubungkan kembali hal ini ke dalam ((x*x)*x)*acc, dan, untuk kesederhanaan, memasang 1mulai, memproduksi (((1*x)*x)*x)*acc. Kuncinya adalah bahwa kita sebenarnya dapat menghitung sebagian hasilnya bahkan sebelum kita memilikinya acc. Itu berarti alih-alih menyerahkan ksebagai daftar yang pada dasarnya adalah beberapa "sintaksis" tidak lengkap yang akan kita "interpretasikan" pada akhirnya, kita dapat "menafsirkannya" sambil jalan. Hasilnya adalah bahwa kita dapat mengganti Nildengan unit monoid, 1dalam hal ini, dan Consdengan operasi monoid *,, dan sekarang kmewakili "produk berjalan".applyPow(k, acc)kemudian menjadi apa k*accyang bisa kita inline kembali pow2dan menyederhanakan produksi:

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)

Versi gaya ekor-rekursif, akumulator-passing dari aslinya pow.

Tentu saja, saya tidak mengatakan GCC melakukan semua alasan ini pada waktu kompilasi. Saya tidak tahu logika apa yang digunakan GCC. Maksud saya adalah setelah melakukan alasan ini sekali, relatif mudah untuk mengenali pola dan segera menerjemahkan kode sumber asli ke dalam bentuk akhir ini. Namun, transformasi CPS dan transformasi defungsionalisasi sepenuhnya bersifat umum dan mekanis. Dari sana fusi, penggundulan hutan, atau teknik superkompilasi dapat digunakan untuk mencoba menghilangkan kelanjutan yang telah ditentukan. Transformasi spekulatif dapat dibuang jika tidak mungkin untuk menghilangkan semua alokasi kelanjutan yang ditentukan. Saya menduga, bahwa itu akan terlalu mahal untuk dilakukan sepanjang waktu, secara umum penuh, karenanya pendekatan yang lebih ad-hoc.

Jika Anda ingin menjadi konyol, Anda dapat memeriksa makalah Daur Ulang Daur Ulang yang juga menggunakan CPS dan representasi kelanjutan sebagai data, tetapi melakukan sesuatu yang mirip tetapi berbeda dengan ekor-rekursi-modulo-kontra. Ini menjelaskan bagaimana Anda dapat menghasilkan algoritma pembalikan pointer dengan transformasi.

Pola transformasi dan defungsionalisasi CPS ini adalah alat yang cukup kuat untuk memahami, dan digunakan untuk efek yang baik dalam serangkaian makalah yang saya daftarkan di sini .

Derek Elkins meninggalkan SE
sumber
Teknik yang digunakan GCC sebagai pengganti Continuation-Passing Style yang Anda perlihatkan di sini adalah, saya percaya, Formulir Penugasan Tunggal Statis.
Davislor
@Davislor Sementara terkait dengan CPS, SSA tidak mempengaruhi aliran kontrol dari prosedur juga tidak memverifikasi tumpukan (atau memperkenalkan struktur data yang perlu dialokasikan secara dinamis). Seperti yang terkait dengan SSA, CPS "melakukan terlalu banyak" yang mengapa Administrative Normal Form (ANF) lebih cocok untuk SSA. Jadi GCC menggunakan SSA, tetapi SSA tidak menyebabkan tumpukan kontrol dapat dilihat sebagai struktur data yang dapat dimanipulasi.
Derek Elkins meninggalkan SE
Baik. Saya merespons, “Saya tidak mengatakan GCC melakukan semua alasan ini pada waktu kompilasi. Saya tidak tahu logika apa yang digunakan GCC. ”Jawaban saya, juga, menunjukkan bahwa transformasi secara teori dapat dibenarkan, tidak mengatakan bahwa itu adalah metode implementasi yang digunakan kompiler tertentu. (Meskipun, seperti yang Anda tahu, banyak kompiler mengubah program menjadi CPS selama optimasi.)
Davislor
8

Aku akan bertele-tele sebentar, tapi ada benarnya.

Semigroup

Jawabannya adalah, properti asosiatif dari operasi reduksi biner .

Itu cukup abstrak, tetapi penggandaan adalah contoh yang bagus. Jika x , y dan z adalah beberapa nomor alami (atau bilangan bulat, atau bilangan rasional, atau bilangan real, atau bilangan kompleks, atau N × N matriks, atau salah satu dari sejumlah hal yang lebih), maka x × y adalah jenis yang sama nomor sebagai x dan y . Kami mulai dengan dua angka, jadi ini operasi biner, dan mendapat satu, jadi kami mengurangi jumlah angka yang kami miliki dengan satu, menjadikan ini operasi pengurangan. Dan ( x × y ) × z selalu sama dengan x × ( y ×z ), yang merupakan properti asosiatif.

(Jika Anda sudah mengetahui semua ini, Anda dapat langsung ke bagian selanjutnya.)

Beberapa hal lagi yang sering Anda lihat dalam ilmu komputer yang bekerja dengan cara yang sama:

  • menambahkan salah satu dari angka-angka itu alih-alih mengalikan
  • string bersambung ( "a"+"b"+"c"adalah "abc"apakah Anda mulai dengan "ab"+"c"atau "a"+"bc")
  • Menyambung dua daftar bersama. [a]++[b]++[c]sama [a,b,c]dari belakang ke depan atau depan ke belakang.
  • conspada kepala dan ekor, jika Anda menganggap kepala sebagai daftar tunggal. Itu hanya menyatukan dua daftar.
  • mengambil persatuan atau persimpangan set
  • Boolean dan, Boolean atau
  • bitwise &, |dan^
  • komposisi fungsi: ( fg ) ∘ h x = f ∘ ( gh ) x = f ( g ( h ( x ))))
  • maksimum dan minimum
  • Selain modulo p

Beberapa hal yang tidak:

  • pengurangan, karena 1- (1-2) ≠ (1-1) -2
  • xy = tan ( x + y ), karena tan (π / 4 + π / 4) tidak terdefinisi
  • perkalian atas angka negatif, karena -1 × -1 bukan angka negatif
  • pembagian bilangan bulat, yang memiliki ketiga masalah!
  • logis tidak, karena hanya memiliki satu operan, bukan dua
  • int print2(int x, int y) { return printf( "%d %d\n", x, y ); }, sebagai print2( print2(x,y), z );dan print2( x, print2(y,z) );memiliki output yang berbeda.

Itu adalah konsep yang cukup berguna yang kami beri nama. Satu set dengan operasi yang memiliki properti ini adalah semigroup . Jadi, bilangan real di bawah perkalian adalah semigroup. Dan pertanyaan Anda ternyata menjadi salah satu cara abstraksi semacam ini menjadi berguna di dunia nyata. Operasi semi-grup semua dapat dioptimalkan seperti yang Anda tanyakan.

Coba Ini Di Rumah

Sejauh yang saya tahu, teknik ini pertama kali dijelaskan pada tahun 1974, dalam makalah Daniel Friedman dan David Wise, "Lipat Rekur Bergaya menjadi Iterasi" , meskipun mereka mengasumsikan beberapa properti lebih daripada yang mereka butuhkan.

Haskell adalah bahasa yang bagus untuk menggambarkan hal ini, karena memiliki Semigrouptypeclass di perpustakaan standarnya. Itu panggilan operasi generik Semigroupoperator <>. Karena daftar dan string adalah instance dari Semigroup, instance mereka mendefinisikan <>sebagai operator gabungan ++, misalnya. Dan dengan impor yang tepat, [a] <> [b]adalah alias untuk [a] ++ [b], yaitu [a,b].

Tapi, bagaimana dengan angka? Kami hanya melihat bahwa jenis numerik yang semigroups bawah baik Selain atau perkalian! Jadi mana yang akan menjadi <>untuk Double? Yah, salah satunya! Haskell mendefinisikan jenis Product Double, where (<>) = (*)(yaitu definisi yang sebenarnya di Haskell), dan juga Sum Double, where (<>) = (+).

Satu kerutan adalah Anda menggunakan fakta bahwa 1 adalah identitas multiplikatif. Semigroup dengan identitas disebut monoid dan didefinisikan dalam paket Haskell Data.Monoid, yang memanggil elemen identitas umum dari sebuah typeclass mempty. Sum, Productdan daftar masing-masing memiliki elemen identitas (0, 1 dan [], masing-masing), sehingga mereka adalah contoh Monoiddan juga Semigroup. (Jangan bingung dengan monad , jadi lupakan saja aku yang membawanya.)

Itu informasi yang cukup untuk menerjemahkan algoritme Anda menjadi fungsi Haskell menggunakan monoids:

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.

Yang penting, perhatikan bahwa ini adalah semigroup modulo rekursi ekor: setiap case dapat berupa nilai, panggilan rekursif ekor, atau produk semigroup dari keduanya. Juga, contoh ini kebetulan digunakan memptyuntuk salah satu kasus, tetapi jika kami tidak membutuhkannya, kami bisa melakukannya dengan typeclass yang lebih umum Semigroup.

Mari kita muat program ini dalam GHCI dan lihat cara kerjanya:

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

Ingat bagaimana kami menyatakan powuntuk obat generik Monoid, yang jenisnya kami panggil a? Kami memberikan GHCI informasi yang cukup untuk menyimpulkan bahwa jenis adi sini adalah Product Integer, yang merupakan instancedari Monoidyang <>operasi perkalian bilangan bulat. Jadi pow 2 4mengembang secara rekursif ke 2<>2<>2<>2, yang 2*2*2*2atau 16. Sejauh ini baik.

Tetapi fungsi kami hanya menggunakan operasi monoid generik. Sebelumnya, saya mengatakan bahwa ada contoh lain yang Monoiddipanggil Sum, yang <>operasinya +. Bisakah kita mencobanya?

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

Ekspansi yang sama sekarang memberi kita 2+2+2+2alih-alih 2*2*2*2. Perkalian adalah untuk menambah karena eksponensial adalah untuk perkalian!

Tetapi saya memberikan satu contoh lain dari monoid Haskell: daftar, yang operasinya adalah gabungan.

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

Menulis [2]memberitahu kompiler bahwa ini adalah daftar, <>pada daftar adalah ++, begitu [2]++[2]++[2]++[2]juga [2,2,2,2].

Akhirnya, sebuah Algoritma (Dua, Faktanya)

Dengan hanya menggantinya xdengan [x], Anda mengonversi algoritma umum yang menggunakan modul rekursi semigroup menjadi semigroup yang membuat daftar. Daftar yang mana? Daftar elemen yang diterapkan algoritma <>. Karena kami hanya menggunakan operasi semi-grup yang memiliki daftar juga, daftar yang dihasilkan akan isomorfik dengan perhitungan aslinya. Dan karena operasi asli adalah asosiatif, kita dapat mengevaluasi elemen dari belakang ke depan atau dari depan ke belakang.

Jika algoritme Anda pernah mencapai casing dasar dan berakhir, daftar tersebut akan kosong. Karena kasing terminal mengembalikan sesuatu, itu akan menjadi elemen terakhir dari daftar, sehingga ia akan memiliki setidaknya satu elemen.

Bagaimana Anda menerapkan operasi reduksi biner ke setiap elemen daftar secara berurutan? Itu benar, lipatan. Jadi Anda dapat mengganti [x]untuk x, mendapatkan daftar elemen untuk mengurangi oleh <>, dan kemudian kanan kali lipat atau kiri-lipat daftar:

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

Versi dengan foldr1sebenarnya ada di pustaka standar, seperti sconcatuntuk Semigroupdan mconcatuntuk Monoid. Itu melipat kanan malas pada daftar. Artinya, itu diperluas [Product 2,Product 2,Product 2,Product 2]ke 2<>(2<>(2<>(2))).

Ini tidak efisien dalam hal ini karena Anda tidak dapat melakukan apa pun dengan persyaratan individual hingga Anda menghasilkan semuanya. (Pada satu titik saya memiliki diskusi di sini tentang kapan harus menggunakan lipatan kanan dan kapan harus menggunakan lipatan kiri yang ketat, tetapi terlalu jauh.)

Versi dengan foldl1'adalah lipatan kiri yang dievaluasi secara ketat. Artinya, fungsi ekor-rekursif dengan akumulator yang ketat. Ini mengevaluasi (((2)<>2)<>2)<>2, dihitung segera dan tidak lebih lambat saat dibutuhkan. (Setidaknya, tidak ada penundaan dalam lipatan itu sendiri: daftar yang dilipat dihasilkan di sini oleh fungsi lain yang mungkin berisi evaluasi malas.) Jadi, lipatan menghitung (4<>2)<>2, lalu segera menghitung 8<>2, lalu 16. Inilah sebabnya kami membutuhkan operasi untuk menjadi asosiatif: kami baru saja mengubah pengelompokan tanda kurung!

Lipatan kiri yang ketat sama dengan apa yang dilakukan GCC. Angka paling kiri pada contoh sebelumnya adalah akumulator, dalam hal ini produk yang sedang berjalan. Pada setiap langkah, itu dikalikan dengan angka berikutnya dalam daftar. Cara lain untuk mengekspresikannya adalah: Anda mengulangi nilai yang akan dikalikan, menjaga produk yang berjalan dalam akumulator, dan pada setiap iterasi, Anda mengalikan akumulator dengan nilai berikutnya. Artinya, itu adalah whilelingkaran yang menyamar.

Kadang-kadang dapat dibuat sama efisiennya. Kompiler mungkin dapat mengoptimalkan struktur data daftar dalam memori. Secara teori, ia memiliki informasi yang cukup pada waktu kompilasi untuk mencari tahu itu harus dilakukan di sini: [x]adalah singleton, jadi [x]<>xssama dengan cons x xs. Setiap iterasi fungsi mungkin dapat menggunakan kembali bingkai tumpukan yang sama dan memperbarui parameter yang ada.

Entah lipatan kanan atau lipatan kiri ketat bisa lebih tepat, dalam kasus tertentu, jadi ketahuilah mana yang Anda inginkan. Ada juga beberapa hal yang hanya dapat dilakukan oleh flip kanan (seperti menghasilkan output interaktif tanpa menunggu semua input, dan beroperasi pada daftar yang tak terbatas). Namun, di sini, kami mengurangi urutan operasi ke nilai sederhana, jadi lipatan kiri yang ketat adalah yang kami inginkan.

Jadi, seperti yang Anda lihat, dimungkinkan untuk secara otomatis mengoptimalkan modul rekursi ekor setiap semigroup (salah satu contohnya adalah salah satu dari jenis numerik yang biasa di bawah penggandaan) baik ke lipatan kanan malas atau lipatan kiri ketat, dalam satu baris Haskell.

Generalisasi Lebih Lanjut

Dua argumen operasi biner tidak harus tipe yang sama, asalkan nilai awal adalah tipe yang sama dengan hasil Anda. (Tentu saja Anda selalu dapat membalik argumen agar sesuai dengan urutan jenis lipatan yang Anda lakukan, kiri atau kanan.) Jadi, Anda mungkin berulang kali menambahkan tambalan ke file untuk mendapatkan file yang diperbarui, atau mulai dengan nilai awal dari 1.0, bagi dengan integer untuk mengakumulasikan hasil floating-point. Atau tambahkan elemen ke daftar kosong untuk mendapatkan daftar.

Jenis generalisasi lain adalah menerapkan lipatan bukan untuk daftar tetapi untuk Foldablestruktur data lainnya . Seringkali, daftar tertaut linear tidak berubah bukan struktur data yang Anda inginkan untuk algoritma yang diberikan. Satu masalah yang tidak saya bahas di atas adalah jauh lebih efisien untuk menambahkan elemen ke depan daftar daripada ke belakang, dan ketika operasi tidak komutatif, menerapkan xdi sebelah kiri dan kanan operasi tidak sama. Jadi Anda perlu menggunakan struktur lain, seperti sepasang daftar atau pohon biner, untuk mewakili suatu algoritma yang dapat diterapkan xdi sebelah kanan <>maupun ke kiri.

Perhatikan juga bahwa properti asosiatif memungkinkan Anda untuk mengelompokkan kembali operasi dengan cara lain yang bermanfaat, seperti membagi-dan-taklukkan:

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)

Atau paralelisme otomatis, di mana setiap utas mengurangi subrange ke nilai yang kemudian dikombinasikan dengan yang lain.

Davislor
sumber
1
Kita dapat melakukan percobaan untuk menguji bahwa asosiatif adalah kunci kemampuan GCC untuk melakukan optimasi ini: pow(float x, unsigned n)versi gcc.godbolt.org/z/eqwine hanya dioptimalkan dengan -ffast-math, (yang menyiratkan -fassociative-math. Titik apung yang ketat tentu saja tidak asosiatif karena perbedaan temporer = pembulatan yang berbeda). Memperkenalkan 1.0f * xyang tidak hadir dalam mesin abstrak C (tapi yang akan selalu memberikan hasil yang identik). Maka perkalian n-1 seperti do{res*=x;}while(--n!=1)sama dengan rekursif, jadi ini adalah optimasi yang tidak terjawab.
Peter Cordes