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