web-dev-qa-db-fra.com

Éviter la surcharge des appels virtuels C #

J'ai quelques fonctions mathématiques fortement optimisées qui prennent 1-2 nanoseconds compléter. Ces fonctions sont appelées des centaines de millions de fois par seconde, la surcharge des appels est donc une préoccupation, malgré les performances déjà excellentes.

Afin de maintenir le programme maintenable, les classes qui fournissent ces méthodes héritent d'une interface IMathFunction, afin que d'autres objets puissent directement stocker une fonction mathématique spécifique et l'utiliser en cas de besoin.

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

Cette interface entraîne une énorme surcharge par rapport à un appel direct en raison de la façon dont le code consommateur l'utilise. A l'appel direct prend 1-2ns, alors que le virtuel l'appel d'interface prend 8-9ns. De toute évidence, la présence de l'interface et sa traduction ultérieure de l'appel virtuel est le goulot d'étranglement de ce scénario.

J'aimerais conserver à la fois la maintenabilité et les performances si possible. Existe-t-il un moyen de résoudre la fonction virtuelle en un appel direct lorsque l'objet est instancié afin que tous les appels suivants puissent éviter la surcharge? Je suppose que cela impliquerait de créer des délégués avec IL, mais je ne saurait pas par où commencer.

36
Haus

Cela a donc des limites évidentes et ne devrait pas être utilisé tout le temps où que vous ayez une interface, mais si vous avez un endroit où la perf doit vraiment être maximisée, vous pouvez utiliser des génériques:

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

Et au lieu de passer une interface, passez votre implémentation en tant que TMathFunction. Cela évitera les recherches de table en raison d'une interface et permettra également l'inline.

Notez que l'utilisation de struct est importante ici, car les génériques accèderont autrement à la classe via l'interface.

Quelques implémentations:

J'ai fait une implémentation simple d'IMathFunction pour tester:

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

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

... ainsi qu'une version struct et une version abstraite.

Voici donc ce qui se passe avec la version d'interface. Vous pouvez voir qu'il est relativement inefficace car il effectue deux niveaux d'indirection:

    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  

Voici une classe abstraite. C'est un peu plus efficace mais négligeable:

        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  

Une interface et une classe abstraite dépendent donc fortement de la prédiction de cible de branche pour avoir des performances acceptables. Même alors, vous pouvez voir qu'il y en a beaucoup plus, donc le meilleur des cas est encore relativement lent tandis que le pire des cas est un pipeline au point mort en raison d'une erreur de prévision.

Et enfin, voici la version générique avec une struct. Vous pouvez voir que c'est massivement plus efficace parce que tout a été entièrement intégré, donc aucune prédiction de branche n'est impliquée. Il a également pour effet secondaire de supprimer la plupart de la gestion de la pile/des paramètres qui s'y trouvait également, de sorte que le code devient très compact:

    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

J'attribuerais les méthodes aux délégués. Cela vous permet de toujours programmer contre l'interface, tout en évitant la résolution de la méthode d'interface.

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

En réponse au commentaire de @ CoryNelson, j'ai fait des tests, alors voyez quel est vraiment l'impact. J'ai scellé la classe de fonction, mais cela ne fait absolument aucune différence puisque mes méthodes ne sont pas virtuelles.

Résultats des tests (temps moyen de 100 millions d'itérations en ns) avec le temps de méthode vide soustrait entre accolades:

Méthode de travail vide: 1,48
Interface: 5,69 (4,21)
Délégués: 5,78 (4,30)
Classe scellée: 2,10 (0,62)
Classe: 2,12 (0,64)

L'heure de la version déléguée est à peu près la même que pour la version d'interface (les heures exactes varient de l'exécution du test à l'exécution du test). Tout en travaillant contre la classe est environ 6,8 x plus rapide (en comparant les temps moins le temps de la méthode de travail vide)! Cela signifie que ma suggestion de travailler avec les délégués n'a pas été utile!

Ce qui m'a surpris, c'est que je m'attendais à un temps d'exécution beaucoup plus long pour la version d'interface. Étant donné que ce type de test ne représente pas le contexte exact du code du PO, sa validité est limitée.

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

L'idée de [MethodImpl(MethodImplOptions.NoInlining)] est d'empêcher le compilateur de calculer les adresses des méthodes avant la boucle si la méthode était en ligne.

9