web-dev-qa-db-ja.com

末尾呼び出しオペコードを生成する

好奇心から、C#を使用して末尾呼び出しのオペコードを生成しようとしていました。 Fibinacciは簡単なものなので、私のc#の例は次のようになります。

    private static void Main(string[] args)
    {
        Console.WriteLine(Fib(int.MaxValue, 0));
    }

    public static int Fib(int i, int acc)
    {
        if (i == 0)
        {
            return acc;
        }

        return Fib(i - 1, acc + i);
    }

リリースでビルドしてデバッグせずに実行すると、スタックオーバーフローが発生しません。最適化せずにデバッグまたは実行すると、スタックオーバーフローが発生します。これは、リリース時に最適化がオンになっているときに末尾呼び出しが機能していることを意味します(これは私が期待していたことです)。

このためのMSILは次のようになります。

.method public hidebysig static int32 Fib(int32 i, int32 acc) cil managed
{
    // Method Start RVA 0x205e
    // Code Size 17 (0x11)
    .maxstack 8
    L_0000: ldarg.0 
    L_0001: brtrue.s L_0005
    L_0003: ldarg.1 
    L_0004: ret 
    L_0005: ldarg.0 
    L_0006: ldc.i4.1 
    L_0007: sub 
    L_0008: ldarg.1 
    L_0009: ldarg.0 
    L_000a: add 
    L_000b: call int32 [ConsoleApplication2]ConsoleApplication2.Program::Fib(int32,int32)
    L_0010: ret 
}

msdn に従って、テールオペコードが表示されると予想していましたが、表示されません。これは、JITコンパイラがそれをそこに入れる責任があるのか​​どうか疑問に思いましたか?アセンブリをngenして(ngen install <exe>を使用し、Windowsアセンブリリストに移動して取得しようとしました)、ILSpyにロードして戻しましたが、同じように見えます。

.method public hidebysig static int32 Fib(int32 i, int32 acc) cil managed
{
    // Method Start RVA 0x3bfe
    // Code Size 17 (0x11)
    .maxstack 8
    L_0000: ldarg.0 
    L_0001: brtrue.s L_0005
    L_0003: ldarg.1 
    L_0004: ret 
    L_0005: ldarg.0 
    L_0006: ldc.i4.1 
    L_0007: sub 
    L_0008: ldarg.1 
    L_0009: ldarg.0 
    L_000a: add 
    L_000b: call int32 [ConsoleApplication2]ConsoleApplication2.Program::Fib(int32,int32)
    L_0010: ret 
}

まだ見えません。

F#が末尾呼び出しをうまく処理することを知っているので、F#が行ったこととC#が行ったことを比較したいと思いました。私のF#の例は次のようになります。

let rec fibb i acc =  
    if i = 0 then
        acc
    else 
        fibb (i-1) (acc + i)


Console.WriteLine (fibb 3 0)

そして、fibメソッド用に生成されたILは次のようになります。

.method public static int32 fibb(int32 i, int32 acc) cil managed
{
    // Method Start RVA 0x2068
    // Code Size 18 (0x12)
    .custom instance void [FSharp.Core]Microsoft.FSharp.Core.CompilationArgumentCountsAttribute::.ctor(int32[]) = { int32[](Mono.Cecil.CustomAttributeArgument[]) }
    .maxstack 5
    L_0000: nop 
    L_0001: ldarg.0 
    L_0002: brtrue.s L_0006
    L_0004: ldarg.1 
    L_0005: ret 
    L_0006: ldarg.0 
    L_0007: ldc.i4.1 
    L_0008: sub 
    L_0009: ldarg.1 
    L_000a: ldarg.0 
    L_000b: add 
    L_000c: starg.s acc
    L_000e: starg.s i
    L_0010: br.s L_0000
}

ILSpyによると、これはこれと同等です。

[Microsoft.FSharp.Core.CompilationArgumentCounts(Mono.Cecil.CustomAttributeArgument[])]
public static int32 fibb(int32 i, int32 acc)
{
    label1:
    if !(((i != 0))) 
    {
        return acc;
    }
    (i - 1);
    i = acc = (acc + i);;
    goto label1;
}

では、F#はgotoステートメントを使用して末尾呼び出しを生成しましたか?これは私が期待していたものではありません。

私はどこでも末尾呼び出しに依存しようとはしていませんが、そのオペコードが正確にどこに設定されているのか興味がありますか? C#はこれをどのように行っていますか?

40
devshorts

C#コンパイラ C#プログラムは通常ループを使用し、末尾呼び出しの最適化に依存しないため、末尾呼び出しの最適化については保証されません。したがって、C#では、これは単にJITの最適化であり、発生する場合と発生しない場合があります(信頼できません)。

F#コンパイラは、再帰を使用する関数型コードを処理するように設計されているため、末尾呼び出しについて一定の保証があります。これは2つの方法で行われます。

  • 自分自身を呼び出す再帰関数(fibなど)を作成すると、コンパイラーはそれを本体でループを使用する関数に変換します(これは単純な最適化であり、生成されたコードは末尾呼び出しを使用するよりも高速です)

  • より複雑な位置で再帰呼び出しを使用する場合(関数が引数として渡される継続渡しスタイルを使用する場合)、コンパイラーはJITにそれを通知する末尾呼び出し命令を生成します must末尾呼び出しを使用します。

2番目のケースの例として、次の単純なF#関数をコンパイルします(F#はデバッグを簡素化するためにデバッグモードではこれを行わないため、リリースモードが必要になるか、--tailcalls+を追加する必要があります)。

let foo a cont = cont (a + 1)

この関数は、最初の引数を1つインクリメントして、関数contを呼び出すだけです。継続渡しスタイルでは、このような呼び出しのシーケンスが長いため、最適化が重要です(末尾呼び出しを処理しないと、このスタイルを使用できません)。生成されるILコードは次のようになります。

IL_0000: ldarg.1
IL_0001: ldarg.0
IL_0002: ldc.i4.1
IL_0003: add
IL_0004: tail.                          // Here is the 'tail' opcode!
IL_0006: callvirt instance !1 
  class [FSharp.Core] Microsoft.FSharp.Core.FSharpFunc`2<int32, !!a>::Invoke(!0)
IL_000b: ret
50
Tomas Petricek

.Netでの末尾呼び出しの最適化の状況は非常に複雑です。私の知る限り、それは次のようなものです。

  • C#コンパイラは、tail.オペコードを発行することはなく、末尾呼び出しの最適化を単独で実行することもありません。
  • F#コンパイラは、tail.オペコードを発行する場合もあれば、再帰的ではないILを発行することによって末尾呼び出しの最適化を実行する場合もあります。
  • CLRは、tail.オペコードが存在する場合はそれを尊重し、64ビットCLRは、オペコードが存在しない場合でも末尾呼び出しを最適化する場合があります。

したがって、あなたの場合、C#コンパイラによって生成されたILにtail.オペコードが表示されませんでした。これは表示されないためです。ただし、CLRはオペコードがなくてもこれを行うことがあるため、このメソッドは末尾呼び出しに最適化されました。

また、F#の場合、f#コンパイラがそれ自体で最適化を行っていることがわかりました。

28
svick

.NET(Roslyn言語)で実行されるすべての最適化と同様に、末尾呼び出しの最適化は、コンパイラーではなく、ジッターによって実行されるジョブです。哲学は、どの言語でもその恩恵を受けるので、ジョブをジッターに置くことは有用であり、コードオプティマイザーの作成とデバッグの通常難しいジョブはアーキテクチャごとに1回だけ実行する必要があるということです。

生成されたマシンコードを調べて、デバッグ+ Windows +逆アセンブリが実行されていることを確認する必要があります。さらに、[ツール] + [オプション]、[デバッグ]、[一般]、[JIT最適化の抑制]をオフにして生成されたリリースビルドコードを確認する必要があります。

X64コードは次のようになります。

        public static int Fib(int i, int acc) {
            if (i == 0) {
00000000  test        ecx,ecx 
00000002  jne         0000000000000008 
                return acc;
00000004  mov         eax,edx 
00000006  jmp         0000000000000011 
            }

            return Fib(i - 1, acc + i);
00000008  lea         eax,[rcx-1] 
0000000b  add         edx,ecx 
0000000d  mov         ecx,eax 
0000000f  jmp         0000000000000000              // <== here!!!
00000011  rep ret  

マークされた命令、呼び出しの代わりにジャンプに注意してください。これが末尾呼び出しの最適化です。 .NETの癖は、32ビットのx86ジッターがこの最適化を実行しないことです。彼らがおそらく決して手に入れられないであろう単なるやることアイテム。これには、F#コンパイラの作成者が問題を無視せずにOpcodes.Tailcallを発行する必要がありました。 この回答 に記載されているジッターによって実行される他の最適化を見つけることができます。

9
Hans Passant