web-dev-qa-db-ja.com

C#仮想呼び出しのオーバーヘッドを回避する

1-2 nanosecondsを完了するのに非常に最適化された数学関数がいくつかあります。これらの関数は1秒間に数億回呼び出されるため、既に優れたパフォーマンスを発揮しているにもかかわらず、呼び出しのオーバーヘッドが懸念事項です。

プログラムを保守可能な状態に保つために、これらのメソッドを提供するクラスはIMathFunctionインターフェースを継承するため、他のオブジェクトは特定の数学関数を直接保存し、必要なときに使用できます。

public interface IMathFunction
{
  double Calculate(double input);
  double Derivate(double input);
}

public SomeObject
{
  // Note: There are cases where this is mutable
  private readonly IMathFunction mathFunction_; 

  public double SomeWork(double input, double step)
  {
    var f = mathFunction_.Calculate(input);
    var dv = mathFunction_.Derivate(input);
    return f - (dv * step);
  }
}

このインターフェイスは、消費するコードがそれを使用する方法のために、直接呼び出しと比較して莫大なオーバーヘッドを引き起こしています。 直接呼び出しには1-2nsかかります、仮想インターフェース呼び出しには8-9nsかかります。明らかに、インターフェースの存在とそれに続く仮想コールの変換が、このシナリオのボトルネックです。

可能であれば、保守性とパフォーマンスの両方を維持したいと思います。 オブジェクトがインスタンス化されたときに仮想関数を直接呼び出しに解決して、後続のすべての呼び出しでオーバーヘッドを回避できる方法はありますか?これにはILでデリゲートを作成することが含まれると思いますが、どこから始めればいいのか分からないでしょう。

36
Haus

そのため、これには明らかな制限があり、インターフェイスを持っている場所では常に使用すべきではありませんが、perfを本当に最大化する必要がある場所がある場合は、ジェネリックを使用できます。

public SomeObject<TMathFunction> where TMathFunction: struct, IMathFunction 
{
  private readonly TMathFunction mathFunction_;

  public double SomeWork(double input, double step)
  {
    var f = mathFunction_.Calculate(input);
    var dv = mathFunction_.Derivate(input);
    return f - (dv * step);
  }
}

そして、インターフェイスを渡す代わりに、実装をTMathFunctionとして渡します。これにより、インターフェースによるvtableルックアップが回避され、インライン化も可能になります。

ここでは、structの使用が重要であることに注意してください。そうでない場合、ジェネリックはインターフェイスを介してクラスにアクセスします。

一部の実装:

テスト用にIMathFunctionの簡単な実装を作成しました。

class SomeImplementationByRef : IMathFunction
{
    public double Calculate(double input)
    {
        return input + input;
    }

    public double Derivate(double input)
    {
        return input * input;
    }
}

...構造体バージョンと抽象バージョン。

それで、ここではインターフェイスバージョンで何が起こるかを示します。 2レベルの間接化を実行するため、比較的非効率的であることがわかります。

    return obj.SomeWork(input, step);
sub         esp,40h  
vzeroupper  
vmovaps     xmmword ptr [rsp+30h],xmm6  
vmovaps     xmmword ptr [rsp+20h],xmm7  
mov         rsi,rcx
vmovsd      qword ptr [rsp+60h],xmm2  
vmovaps     xmm6,xmm1
mov         rcx,qword ptr [rsi+8]          ; load mathFunction_ into rcx.
vmovaps     xmm1,xmm6  
mov         r11,7FFED7980020h              ; load vtable address of the IMathFunction.Calculate function.
cmp         dword ptr [rcx],ecx  
call        qword ptr [r11]                ; call IMathFunction.Calculate function which will call the actual Calculate via vtable.
vmovaps     xmm7,xmm0
mov         rcx,qword ptr [rsi+8]          ; load mathFunction_ into rcx.
vmovaps     xmm1,xmm6  
mov         r11,7FFED7980028h              ; load vtable address of the IMathFunction.Derivate function.
cmp         dword ptr [rcx],ecx  
call        qword ptr [r11]                ; call IMathFunction.Derivate function which will call the actual Derivate via vtable.
vmulsd      xmm0,xmm0,mmword ptr [rsp+60h] ; dv * step
vsubsd      xmm7,xmm7,xmm0                 ; f - (dv * step)
vmovaps     xmm0,xmm7  
vmovaps     xmm6,xmmword ptr [rsp+30h]  
vmovaps     xmm7,xmmword ptr [rsp+20h]  
add         rsp,40h  
pop         rsi  
ret  

これが抽象クラスです。それはもう少し効率的ですが、ごくわずかです:

        return obj.SomeWork(input, step);
 sub         esp,40h  
 vzeroupper  
 vmovaps     xmmword ptr [rsp+30h],xmm6  
 vmovaps     xmmword ptr [rsp+20h],xmm7  
 mov         rsi,rcx  
 vmovsd      qword ptr [rsp+60h],xmm2  
 vmovaps     xmm6,xmm1  
 mov         rcx,qword ptr [rsi+8]           ; load mathFunction_ into rcx.
 vmovaps     xmm1,xmm6  
 mov         rax,qword ptr [rcx]             ; load object type data from mathFunction_.
 mov         rax,qword ptr [rax+40h]         ; load address of vtable into rax.
 call        qword ptr [rax+20h]             ; call Calculate via offset 0x20 of vtable.
 vmovaps     xmm7,xmm0  
 mov         rcx,qword ptr [rsi+8]           ; load mathFunction_ into rcx.
 vmovaps     xmm1,xmm6  
 mov         rax,qword ptr [rcx]             ; load object type data from mathFunction_.
 mov         rax,qword ptr [rax+40h]         ; load address of vtable into rax.
 call        qword ptr [rax+28h]             ; call Derivate via offset 0x28 of vtable.
 vmulsd      xmm0,xmm0,mmword ptr [rsp+60h]  ; dv * step
 vsubsd      xmm7,xmm7,xmm0                  ; f - (dv * step)
 vmovaps     xmm0,xmm7
 vmovaps     xmm6,xmmword ptr [rsp+30h]  
 vmovaps     xmm7,xmmword ptr [rsp+20h]  
 add         rsp,40h  
 pop         rsi  
 ret  

したがって、インターフェースと抽象クラスの両方は、許容可能なパフォーマンスを得るために分岐ターゲット予測に大きく依存しています。それでも、かなり多くのことが行われていることがわかります。そのため、ベストケースは依然として比較的低速ですが、ワーストケースは予測ミスによるパイプラインの停止です。

最後に、構造体を含む汎用バージョンを示します。すべてが完全にインライン化されているため、分岐予測が含まれていないため、非常に効率的であることがわかります。また、そこにあったスタック/パラメータ管理のほとんどを削除するという素晴らしい副作用があるため、コードは非常にコンパクトになります。

    return obj.SomeWork(input, step);
Push        rax  
vzeroupper  
movsx       rax,byte ptr [rcx+8]  
vmovaps     xmm0,xmm1  
vaddsd      xmm0,xmm0,xmm1  ; Calculate - got inlined
vmulsd      xmm1,xmm1,xmm1  ; Derivate - got inlined
vmulsd      xmm1,xmm1,xmm2  ; dv * step
vsubsd      xmm0,xmm0,xmm1  ; f - 
add         rsp,8  
ret  
36
Cory Nelson

メソッドをデリゲートに割り当てます。これにより、インターフェイスメソッドの解決を避けながら、インターフェイスに対してプログラミングを行うことができます。

_public SomeObject
{
    private readonly Func<double, double> _calculate;
    private readonly Func<double, double> _derivate;

    public SomeObject(IMathFunction mathFunction)
    {
        _calculate = mathFunction.Calculate;
        _derivate = mathFunction.Derivate;
    }

    public double SomeWork(double input, double step)
    {
        var f = _calculate(input);
        var dv = _derivate(input);
        return f - (dv * step);
    }
}
_

@CoryNelsonのコメントに応えて、テストを行ったので、実際の影響を確認してください。私は関数クラスを封印しましたが、私のメソッドは仮想ではないので、これはまったく違いがないようです。

空のメソッド時間を中括弧で引いたテスト結果(nsでの1億回の反復の平均時間):

空の作業方法:1.48
インターフェース:5.69(4.21)
デリゲート:5.78(4.30)
密閉クラス:2.10(0.62)
クラス:2.12(0.64)

デリゲートバージョンの時間は、インターフェイスバージョンの時間とほぼ同じです(正確な時間はテストの実行ごとに異なります)。クラスに対して作業している間は、約6.8倍高速です(時間と空の作業メソッド時間の差)。これは、デリゲートと連携するという私の提案が役に立たなかったことを意味します!

驚いたのは、インターフェイスバージョンの実行時間がはるかに長くなることを期待していたことです。この種のテストはOPのコードの正確なコンテキストを表さないため、その有効性は制限されています。

_static class TimingInterfaceVsDelegateCalls
{
    const int N = 100_000_000;
    const double msToNs = 1e6 / N;

    static SquareFunctionSealed _mathFunctionClassSealed;
    static SquareFunction _mathFunctionClass;
    static IMathFunction _mathFunctionInterface;
    static Func<double, double> _calculate;
    static Func<double, double> _derivate;

    static TimingInterfaceVsDelegateCalls()
    {
        _mathFunctionClass = new SquareFunction();
        _mathFunctionClassSealed = new SquareFunctionSealed();
        _mathFunctionInterface = _mathFunctionClassSealed;
        _calculate = _mathFunctionInterface.Calculate;
        _derivate = _mathFunctionInterface.Derivate;
    }

    interface IMathFunction
    {
        double Calculate(double input);
        double Derivate(double input);
    }

    sealed class SquareFunctionSealed : IMathFunction
    {
        public double Calculate(double input)
        {
            return input * input;
        }

        public double Derivate(double input)
        {
            return 2 * input;
        }
    }

    class SquareFunction : IMathFunction
    {
        public double Calculate(double input)
        {
            return input * input;
        }

        public double Derivate(double input)
        {
            return 2 * input;
        }
    }

    public static void Test()
    {
        var stopWatch = new Stopwatch();

        stopWatch.Start();
        for (int i = 0; i < N; i++) {
            double result = SomeWorkEmpty(i);
        }
        stopWatch.Stop();
        double emptyTime = stopWatch.ElapsedMilliseconds * msToNs;
        Console.WriteLine($"Empty Work method: {emptyTime:n2}");

        stopWatch.Restart();
        for (int i = 0; i < N; i++) {
            double result = SomeWorkInterface(i);
        }
        stopWatch.Stop();
        PrintResult("Interface", stopWatch.ElapsedMilliseconds, emptyTime);

        stopWatch.Restart();
        for (int i = 0; i < N; i++) {
            double result = SomeWorkDelegate(i);
        }
        stopWatch.Stop();
        PrintResult("Delegates", stopWatch.ElapsedMilliseconds, emptyTime);

        stopWatch.Restart();
        for (int i = 0; i < N; i++) {
            double result = SomeWorkClassSealed(i);
        }
        stopWatch.Stop();
        PrintResult("Sealed Class", stopWatch.ElapsedMilliseconds, emptyTime);

        stopWatch.Restart();
        for (int i = 0; i < N; i++) {
            double result = SomeWorkClass(i);
        }
        stopWatch.Stop();
        PrintResult("Class", stopWatch.ElapsedMilliseconds, emptyTime);
    }

    private static void PrintResult(string text, long elapsed, double emptyTime)
    {
        Console.WriteLine($"{text}: {elapsed * msToNs:n2} ({elapsed * msToNs - emptyTime:n2})");
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static double SomeWorkEmpty(int i)
    {
        return 0.0;
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static double SomeWorkInterface(int i)
    {
        double f = _mathFunctionInterface.Calculate(i);
        double dv = _mathFunctionInterface.Derivate(i);
        return f - (dv * 12.34534);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static double SomeWorkDelegate(int i)
    {
        double f = _calculate(i);
        double dv = _derivate(i);
        return f - (dv * 12.34534);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static double SomeWorkClassSealed(int i)
    {
        double f = _mathFunctionClassSealed.Calculate(i);
        double dv = _mathFunctionClassSealed.Derivate(i);
        return f - (dv * 12.34534);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static double SomeWorkClass(int i)
    {
        double f = _mathFunctionClass.Calculate(i);
        double dv = _mathFunctionClass.Derivate(i);
        return f - (dv * 12.34534);
    }
}
_

[MethodImpl(MethodImplOptions.NoInlining)]の考え方は、メソッドがインライン化された場合、コンパイラーがループの前にメソッドのアドレスを計算しないようにすることです。