consのどのプロパティにより、末尾再帰モジュロconsを排除できますか?


14

私は、基本的な末尾再帰除去の考え方に精通しています。そこでは、呼び出し自体の直接の結果を返す関数を反復ループとして書き直すことができます。

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

また、特別な場合として、再帰呼び出しがの呼び出しでラップされている場合、関数を書き換えることができることも理解していconsます。

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

これをcons許可するプロパティは何ですか?cons再帰的な末尾呼び出しを繰り返し書き換える能力を損なうことなく折り返すことができる以外の機能は何ですか?

GCC(Clangではない)は、この「末尾再帰モジュロ乗算」の例を最適化できますが、どのメカニズムがこれを発見できるのか、またはどのように変換するのかは不明です。

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

1
Godboltコンパイラエクスプローラリンクに、関数がありif(n==0) return 0;ます(質問のように1を返しません)。 x^0 = 1、それはバグです。ただし、残りの質問にとって重要なことではありません。反復asmは、まずその特殊なケースをチェックします。しかし、奇妙なことに、反復実装1 * xでは、floatバージョンを作成しても、ソースには存在しなかった乗算が導入されます。 gcc.godbolt.org/z/eqwine(およびgccは.sでのみ成功し-ffast-mathます)
Peter Cordes

@PeterCordes良いキャッチ。return 0固定されています。1の乗算は興味深いです。どうしたらいいのかわかりません。
Maxpm

これは、GCCをループに変換する際のGCCの変換の副作用だと思います。明らかに、gccには最適化が見落とされています。たとえば、毎回同じ値が乗算されているにもかかわらfloat-ffast-math、without で見落とされています。(固着点である可能性のある1.0fを除く?)
ピーター

回答:


12

GCCはアドホックルールを使用する可能性がありますが、次の方法で導出できます。powあなたfooはとても曖昧に定義されているので、私は説明に使用します。また、Ozが持つfoo言語としての単一割り当て変数に関する最後の呼び出しの最適化のインスタンスとして、および「概念、技術、およびコンピュータープログラミングのモデル」で説明されているように、最もよく理解されるかもしれません。単一割り当て変数を使用する利点は、宣言型プログラミングパラダイム内にとどまることです。基本的に、構造体の各フィールドは、単一の割り当て変数で表され、追加の引数として渡されます。その後、末尾再帰になりますfoofoofoovoid関数を返します。これには特別な賢さは必要ありません。

に戻ってpow、まず、継続渡しスタイルに変換します。powになる:

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

現在、すべてのコールはテールコールです。ただし、コントロールスタックは、継続を表すクロージャ内のキャプチャされた環境に移動されました。

次に、継続を非機能化します。再帰呼び出しは1つしかないため、非機能化された継続を表す結果のデータ構造はリストです。我々が得る:

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)

applyPow(k, acc)んと、リストを取り、すなわち自由モノイド、のようなものk=Cons(x, Cons(x, Cons(x, Nil)))とにそれを作りますx*(x*(x*acc))。ただし、*結合性があり、通常unit 1とモノイドを形成する((x*x)*x)*accため、これをに再関連付けし、簡単にするために、1開始してからを生成し(((1*x)*x)*x)*accます。重要なことは、実際に結果を部分的に計算できることaccです。これはk、最後に「解釈」する本質的に不完全な「構文」であるリストとして渡すのではなく、進むにつれて「解釈」できることを意味します。結果はNil、モノイドの単位(1この場合)とConsモノイドの動作で置き換えることができるということ*であり、現在kは「実行中の製品」を表します。applyPow(k, acc)次に、k*accインラインpow2化して生成を単純化できるものになります。

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)

オリジナルの末尾再帰、アキュムレータ通過スタイルバージョンpow

もちろん、GCCがコンパイル時にこのすべての推論を行うとは言いません。GCCが使用するロジックがわかりません。私のポイントは、この推論を1回行ったことだけです。パターンを認識し、元のソースコードをこの最終形式にすぐに翻訳するのは比較的簡単です。ただし、CPS変換と非機能化変換は完全に一般的で機械的なものです。そこから、融合、森林破壊、またはスーパーコンパイル技術を使用して、具体化された継続を排除しようと試みることができます。具象化された継続の割り当てをすべて削除することができない場合、投機的な変換は破棄される可能性があります。しかし、私は、それが完全に一般的であるため、常に行うには費用がかかりすぎるため、よりアドホックなアプローチになると思います。

ばかげている場合は、CPSと継続の表現をデータとしても使用しているが、recursion-modulo-consと似ているが異なるものを実行する論文Recycling Continuationsを確認できます。これは、変換によってポインター反転アルゴリズムを作成する方法を説明しています。

このCPS変換および非機能化のパターンは、理解するための非常に強力なツールであり、ここにリストした一連の論文で有効に使用されます


ここに示す継続受渡しスタイルの代わりにGCCが使用する手法は、静的な単一割り当てフォームです。
デイヴィスラー

@Davislor CPSに関連している間、SSAはプロシージャの制御フローに影響せず、スタックを具体化しません(そうでなければ、動的に割り当てる必要があるデータ構造を導入します)。SSAに関連するように、CPSは「やりすぎ」であるため、管理標準形式(ANF)はSSAにより適しています。したがって、GCCはSSAを使用しますが、SSAは、制御スタックを操作可能なデータ構造として見ることはできません。
デレクエルキンズは、

正しい。私は「GCCがコンパイル時にこのすべての推論を行うとは言っていません。私の答えは、同様に、変換が理論的に正当化されることを示しており、特定のコンパイラが使用する実装方法であるとは言いませんでした。(ただし、ご存知のように、多くのコンパイラーは最適化中にプログラムをCPSに変換します。)
Davislor

8

私はしばらく茂みの周りを打つつもりですが、ポイントがあります。

セミグループ

答えは、バイナリリダクション演算の連想プロパティです

それはかなり抽象的なことですが、乗算は良い例です。場合のxyのzは、いくつかの自然数(または整数、または有理数、または実数、または複素数、またはあるN × Nの行列、または全体の束のいずれかのより多くの物事が)、その後のx × yが同じ種類でありますxyの両方としての数。2つの数値から始めたので、2項演算であり、1つを得たので、持っていた数値の数を1つ減らして、これを簡約演算にしました。そして(x × y)× zは常にx ×(y ×z)、これは連想プロパティです。

(これらすべてを既に知っている場合は、次のセクションにスキップできます。)

同じように機能する、コンピューターサイエンスでよく見られるもの:

  • 掛けるのではなく、これらの種類の数字を追加する
  • 文字列を連結することは("a"+"b"+"c"ある"abc"あなたがで始まるかどう"ab"+"c""a"+"bc"
  • 2つのリストをつなぎ合わせます。 [a]++[b]++[c]同様に[a,b,c]、背面から前面または前面から背面のいずれかです。
  • cons頭をシングルトンリストと考える場合は、頭と尻尾で。これは、2つのリストを連結しただけです。
  • 集合または集合の交差を取る
  • ブールand、ブールor
  • ビット単位&|および^
  • 機能の組成:(FG)∘ H 、X = F ∘(GHX = FGHX)))
  • 最大および最小
  • pを法とする加算

しないこと:

  • 1-(1-2)≠(1-1)-2であるため、減算
  • XY =黄褐色(X + Y)、日焼けので(π/ 4 +π/ 4)が定義されていません
  • -1×-1は負の数ではないため、負の数に対する乗算
  • 整数の除算。これには3つの問題がすべてあります。
  • 論理的ではありません。2つではなく1つのオペランドしかありません。
  • int print2(int x, int y) { return printf( "%d %d\n", x, y ); }、as print2( print2(x,y), z );およびprint2( x, print2(y,z) );は異なる出力を持ちます。

それは私たちがそれを命名したほど有用な概念です。これらのプロパティを持つ操作を持つセットは、セミグループです。したがって、乗算下の実数は半群です。そして、あなたの質問は、この種の抽象化が現実の世界で役立つ方法の1つであることがわかりました。 セミグループ操作はすべて、あなたが尋ねている方法で最適化できます。

自宅で試してみてください

私の知る限り、この手法は1974年にDaniel FriedmanとDavid Wiseの論文「Folding Stylized Recursions into Iterations」で初めて説明されましたが、必要以上にいくつかのプロパティを想定していました。

Haskellは、Semigroup標準ライブラリにタイプクラスがあるため、これを説明するのに最適な言語です。ジェネリックSemigroup演算子の操作を呼び出します<>。リストと文字列はのインスタンスであるためSemigroup、それらのインスタンスは、たとえば<>連結演算子として定義されます++。正しいインポートで[a] <> [b]は、のエイリアス[a] ++ [b][a,b]です。

しかし、数字はどうですか?数値型は加算または乗算のいずれかのセミグループであることがわかりました!それで<>Doubleどっちがいいの?まあ、どちらか!Haskellはタイプを定義しProduct Doublewhere (<>) = (*)また(すなわち、ハスケルの実際の定義である)、そしてSum Doublewhere (<>) = (+)

しわの1つは、1が乗法アイデンティティであるという事実を使用したことです。アイデンティティを持つセミグループはモノイドと呼ばれ、HaskellパッケージData.Monoidで定義されます。これは、タイプクラスの汎用アイデンティティ要素を呼び出しmemptyます。 SumProductおよびリストはそれぞれ、同一の要素(0、1、有する[]、それらがのインスタンスであるように、それぞれ)MonoidならびにSemigroup。(モナドと混同しないように、それらを育てさえしたことを忘れてください。)

モノイドを使用してアルゴリズムをHaskell関数に変換するのに十分な情報です。

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.

重要なことに、これは末尾再帰モジュロ半群であることに注意してください。すべてのケースは値、末尾再帰呼び出し、または両方の半群積です。また、この例はたまたまmemptyそのケースの1つに使用されましたが、それが必要なければ、より一般的なtypeclassでそれを行うことができましたSemigroup

このプログラムをGHCIにロードして、どのように機能するかを見てみましょう。

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

どの型を呼び出しpowたジェネリックを宣言したか覚えていますか?私たちは、タイプすることを推測するGHCiの十分な情報を与え、ここである、とその操作は整数乗算です。したがって、に再帰的に展開します。これはまたはです。ここまでは順調ですね。MonoidaaProduct IntegerinstanceMonoid<>pow 2 42<>2<>2<>22*2*2*216

しかし、この関数は一般的なモノイド操作のみを使用します。以前、私は、操作がであるMonoidcalledの別のインスタンスがあると言いました。それを試してもいいですか?Sum<>+

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

同じ拡張により、2+2+2+2ではなくが得られ2*2*2*2ます。乗算は乗算であり、べき乗は乗算です!

しかし、Haskellモノイドのもう1つの例を挙げました。その操作は連結です。

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

書き込みは[2]、これはリストであることをコンパイラに伝え<>ているリストに++、そう[2]++[2]++[2]++[2]です[2,2,2,2]

最後に、アルゴリズム(2つ、事実)

単に置き換えることによってx[x]、あなたはリストを作成し、1に、一般的なアルゴリズムその用途再帰剰余A半群を変換します。どのリスト? アルゴリズムが適用される要素のリスト<> リストにも含まれるセミグループ演算のみを使用したため、結果のリストは元の計算と同型になります。また、元の操作は関連性があるため、要素を後ろから前へ、または前から後ろへ等しく等しく評価できます。

アルゴリズムがベースケースに達して終了すると、リストは空になりません。ターミナルケースが何かを返したので、それがリストの最後の要素になるため、少なくとも1つの要素があります。

リストのすべての要素にバイナリリダクション演算をどのように順番に適用しますか?そうです、折り目です。したがって、の代わり[x]x、で減らす要素のリストを取得し、リストを<>右折または左折することができます。

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

のバージョンはfoldr1sconcatfor Semigroupおよびmconcatforのように、実際には標準ライブラリに存在しMonoidます。リスト上で怠laな右折りを行います。つまり、に展開さ[Product 2,Product 2,Product 2,Product 2]2<>(2<>(2<>(2)))ます。

この場合、すべての用語を生成するまで個々の用語で何もできないため、これは効率的ではありません。(ある時点で、右折りを使用するタイミングと厳密な左折りを使用するタイミングについてここで議論しましたが、遠すぎました。)

のバージョンfoldl1'は、厳密に評価された左折りです。つまり、厳密なアキュムレータを持つ末尾再帰関数です。これはに評価され(((2)<>2)<>2)<>2、すぐに計算され、後で必要なときに計算されません。(少なくとも、内には遅延はありません自体を折る:遅延評価が含まれる可能性があります別の関数で、ここで生成された折り畳まれているリストを)ので、倍計算(4<>2)<>2、その後すぐに計算8<>2、その後、16。これが、操作を連想的にする必要があった理由です。括弧のグループ化を変更しただけです!

厳密な左折りは、GCCが行っていることと同等です。 前の例の左端の数字はアキュムレーター、この場合は実行中の製品です。各ステップで、リスト内の次の番号が乗算されます。それを表現する別の方法は、乗算する値を反復処理し、実行中の積をアキュムレータに保持し、各反復でアキュムレータに次の値を乗算します。つまりwhile、変装したループです。

時には同じくらい効率的にすることができます。コンパイラは、メモリ内のリストデータ構造を最適化することができる場合があります。理論的には、コンパイル時に十分な情報を持っているので、ここでそうすべき[x]であること[x]<>xsがわかります。シングルトンであるため、と同じcons x xsです。関数の各反復は、同じスタックフレームを再利用し、所定の場所でパラメーターを更新できる場合があります。

特定のケースでは、右折りまたは厳密な左折りのいずれかがより適切である可能性があるため、どちらを選択するかを知ってください。また、正しいフォールドでしかできないこともいくつかあります(すべての入力を待たずにインタラクティブな出力を生成したり、無限のリストを操作したりするなど)。ただし、ここでは、一連の操作を単純な値に減らしているため、厳密な左折りが必要です。

したがって、ご覧のように、セミグループ(例:乗算中の通常の数値型のいずれか)を法とする末尾再帰を、遅延のある右折りまたは厳密な左折りに、1行で自動的に最適化することができますハスケル。

さらに一般化する

初期値が結果と同じ型である限り、2項演算の2つの引数は同じ型である必要はありません。(もちろん、あなたが行っているフォールドの種類の順序に合わせて引数をいつでも左右に反転させることができます。)更新されたファイルを取得するためにファイルにパッチを繰り返し追加したり、 1.0、整数で除算して浮動小数点の結果を累積します。または、空のリストに要素を追加してリストを取得します。

別のタイプの一般化は、リストではなく他のFoldableデータ構造にフォールドを適用することです。多くの場合、不変の線形リンクリストは、特定のアルゴリズムに必要なデータ構造ではありません。上記に入らなかった問題の1つは、要素をリストの前に追加する方が後よりもはるかに効率的であり、操作が可換でない場合、操作xの左右に適用しないことです同じ。そのため、リストのペアやバイナリツリーなどの別の構造を使用xして、右側<>だけでなく左側にも適用できるアルゴリズムを表す必要があります。

また、結合プロパティを使用すると、分割統治などの他の便利な方法で操作を再グループ化できます。

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)

または、各スレッドがサブレンジを値に減らしてから、他のスレッドと結合する自動並列処理。


1
:私たちは、結合性はこの最適化を行うにはGCCの能力の鍵であることをテストする実験を行うことができますpow(float x, unsigned n)バージョンgcc.godbolt.org/z/eqwineはだけで最適化-ffast-mathを意味している(、 -fassociative-math。厳密な浮動小数点はもちろんであるではない別の一時理由連想=異なる丸め)。1.0f * xC抽象マシンには存在しなかったa を導入します(ただし、常に同じ結果が得られます)。その場合、n-1個の乗算do{res*=x;}while(--n!=1)は再帰的と同じであるため、これは最適化の失敗です。
ピーター
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.