Délégué .net sans cible plus lent qu’avec la cible

Lorsque j’exécute le code suivant en mode release sur ma machine, l’exécution d’un délégué avec une cible non nulle est toujours légèrement plus rapide que lorsque le délégué avait une cible nulle (je m’attendais à ce qu’elle soit équivalente ou plus lente).

Je ne cherche vraiment pas l’optimisation micro, mais je me demandais pourquoi c’est le cas?

static void Main(ssortingng[] args) { // Warmup code long durationWithTarget = MeasureDuration(() => new DelegatePerformanceTester(withTarget: true).Run()); Console.WriteLine($"With target: {durationWithTarget}"); long durationWithoutTarget = MeasureDuration(() => new DelegatePerformanceTester(withTarget: false).Run()); Console.WriteLine($"Without target: {durationWithoutTarget}"); } ///  /// Measures the duration of an action. ///  /// Action which duration has to be measured. /// The duration in milliseconds. private static long MeasureDuration(Action action) { Stopwatch stopwatch = Stopwatch.StartNew(); action(); return stopwatch.ElapsedMilliseconds; } class DelegatePerformanceTester { public DelegatePerformanceTester(bool withTarget) { if (withTarget) { _func = AddNotStatic; } else { _func = AddStatic; } } private readonly Func _func; private double AddNotStatic(double x, double y) => x + y; private static double AddStatic(double x, double y) => x + y; public void Run() { const int loops = 1000000000; for (int i = 0; i < loops; i++) { double funcResult = _func.Invoke(1d, 2d); } } } 

Je vais écrire celui-ci, il contient un conseil de programmation assez décent qui devrait intéresser tout programmeur C # soucieux d’écrire du code rapide. En général, les différences de 15% ou moins ne sont pas statistiquement significatives du fait de l’imprévisibilité de la vitesse d’exécution du code sur un cœur de processeur moderne. Une bonne approche pour réduire les chances de mesurer quelque chose qui n’y est pas est de répéter un test au moins 10 fois pour supprimer les effets de mise en cache et d’échanger un test afin d’éliminer les effets d’alignement du code.

Mais ce que vous avez vu est réel, les delegates qui invoquent une méthode statique sont en fait plus lents. L’effet est assez faible dans le code x86, mais il est nettement pire dans le code x64. Assurez-vous de bricoler avec les parameters Projet> Propriétés> Construire> Préférer les parameters cible 32 bits et de la plateforme pour essayer les deux.

Pour savoir pourquoi cela est plus lent, il faut examiner le code machine généré par la gigue. Dans le cas des delegates, ce code est très bien caché. Vous ne le verrez pas lorsque vous regarderez le code avec Debug> Windows> Disassembly. Et vous ne pouvez même pas parcourir le code en une seule étape, le débogueur géré a été écrit pour le masquer et refuse complètement de le montrer. Je vais devoir décrire une technique pour remettre le “visuel” dans Visual Studio.

Je dois parler un peu de “stubs”. Un stub est un petit fragment de code machine que le CLR crée dynamicment en plus du code généré par la gigue. Les stubs sont utilisés pour implémenter des interfaces, ils offrent la flexibilité que l’ordre des méthodes dans la table des méthodes d’une classe ne doit pas nécessairement correspondre à l’ordre des méthodes d’interface. Et ils comptent pour les delegates, le sujet de cette question. Les stubs sont également importants pour la compilation juste à temps, le code initial dans un stub pointe vers un point d’entrée dans la gigue pour obtenir une méthode compilée lorsqu’elle est appelée. Après quoi, le stub est remplacé et appelle maintenant la méthode cible jitted. C’est le stub qui ralentit l’appel de la méthode statique, le stub de la cible de la méthode statique est plus élaboré que celui de la méthode d’instance.


Pour voir les stubs, vous devez démêler le débogueur pour le forcer à afficher son code. Une certaine configuration est requirejse: utilisez d’abord Outils> Options> Débogage> Général. Décochez la case “Just My Code”, décochez la case “Supprimer l’optimisation JIT”. Si vous utilisez VS2015, cochez la case “Utiliser le mode de compatibilité gérée”, le débogueur VS2015 est très bogué et gêne sérieusement ce type de débogage. Cette option offre une solution de contournement en forçant l’utilisation du moteur de débogage géré VS2010. Basculez vers la configuration Release. Ensuite, sélectionnez Projet> Propriétés> Déboguer, cochez la case “Activer le débogage de code natif”. Et Projet> Propriétés> Construire, décochez la case “Préférer 32 bits” et “Cible de la plate-forme” devrait être AnyCPU.

Définissez un point d’arrêt sur la méthode Run (), prenez garde que les points d’arrêt ne sont pas très précis dans le code optimisé. Le réglage sur l’en-tête de la méthode est préférable. Une fois le résultat obtenu, utilisez Debug> Windows> Disassembly pour afficher le code machine généré par la gigue. L’appel du délégué ressemble à ceci sur un kernel Haswell, il se peut qu’il ne corresponde pas à ce que vous voyez si vous avez un processeur plus ancien qui ne prend pas encore en charge AVX:

  funcResult += _func.Invoke(1d, 2d); 0000001a mov rax,qword ptr [rsi+8] ; rax = _func 0000001e mov rcx,qword ptr [rax+8] ; rcx = _func._methodBase (?) 00000022 vmovsd xmm2,qword ptr [0000000000000070h] ; arg3 = 2d 0000002b vmovsd xmm1,qword ptr [0000000000000078h] ; arg2 = 1d 00000034 call qword ptr [rax+18h] ; call stub 

Un appel de méthode 64 bits passe les 4 premiers arguments dans les registres, tous les arguments supplémentaires sont passés à travers la stack (pas ici). Les registres XMM sont utilisés ici car les arguments sont des nombres à virgule flottante. À ce stade, la gigue ne peut pas encore savoir si la méthode est statique ou instance, ce qui ne peut pas être découvert jusqu’à ce que ce code soit réellement exécuté. C’est le rôle du talon de cacher la différence. Il suppose que ce sera une méthode d’instance, c’est pourquoi j’ai annoté arg2 et arg3.

Définissez un point d’arrêt sur l’instruction CALL. La deuxième fois qu’il est touché (pour que le moignon ne pointe plus dans la gigue), vous pouvez le consulter. Cela doit être fait à la main, utilisez Debug> Windows> Registers et copiez la valeur du registre RAX. Déboguer> Windows> Mémoire> Mémoire1 et coller la valeur, mettre “0x” devant et append 0x18. Cliquez avec le bouton droit sur cette fenêtre et sélectionnez “Entier sur 8 octets”, copiez la première valeur affichée. C’est l’adresse du code de stub.

Maintenant, le truc, à ce stade, le moteur de débogage géré est toujours utilisé et ne vous permettra pas de consulter le code de raccord. Vous devez forcer un commutateur de mode pour que le moteur de débogage non géré soit sous contrôle. Utilisez Debug> Windows> Call Stack et double-cliquez sur un appel de méthode en bas, comme RtlUserThreadStart. Force le débogueur à changer de moteur. Maintenant que vous êtes prêt à partir et que vous pouvez coller l’adresse dans la zone Adresse, mettez “0x” devant celle-ci. Out apparaît le code de stub:

  00007FFCE66D0100 jmp 00007FFCE66D0E40 

Très simple, un saut direct à la méthode cible du délégué. Ce sera un code rapide. La gigue a deviné correctement à une méthode d’instance et l’object délégué a déjà fourni l’argument this dans le registre RCX afin qu’aucune opération spéciale ne soit nécessaire.

Passez au deuxième test et faites exactement la même chose pour examiner le talon de l’appel d’instance. Maintenant le stub est très différent:

 000001FE559F0850 mov rax,rsp ; ? 000001FE559F0853 mov r11,rcx ; r11 = _func (?) 000001FE559F0856 movaps xmm0,xmm1 ; shuffle arg3 into right register 000001FE559F0859 movaps xmm1,xmm2 ; shuffle arg2 into right register 000001FE559F085C mov r10,qword ptr [r11+20h] ; r10 = _func.Method 000001FE559F0860 add r11,20h ; ? 000001FE559F0864 jmp r10 ; jump to _func.Method 

Le code est un peu bizarre et pas optimal, Microsoft pourrait probablement faire un meilleur travail ici, et je ne suis pas sûr à 100% de l’avoir annoté correctement. Je suppose que l’instruction inutile mov rax, rsp ne concerne que les tronçons de méthodes comportant plus de 4 arguments. Aucune idée pourquoi l’instruction d’ajout est nécessaire. Le détail le plus important qui compte est le déplacement du registre XMM, il doit les remanier car la méthode statique n’a pas l’argument this . C’est cette exigence de redissortingbution qui ralentit le code.

Vous pouvez faire le même exercice avec la gigue x86, la méthode statique stub ressemble maintenant à ceci:

 04F905B4 mov eax,ecx 04F905B6 add eax,10h 04F905B9 jmp dword ptr [eax] ; jump to _func.Method 

Beaucoup plus simple que le stub 64 bits, c’est pourquoi le code 32 bits ne souffre pas autant du ralentissement. Une des raisons pour lesquelles il est si différent est que le code 32 bits passe en virgule flottante sur la stack de FPU et qu’il n’est pas nécessaire de le remanier. Ce ne sera pas nécessairement plus rapide lorsque vous utilisez des arguments intégraux ou d’object.


Très mystérieux, j’espère que je n’ai pas encore endormi tout le monde. Attention, certaines annotations sont peut-être erronées, je ne comprends pas bien les stubs et la manière dont les cuisiniers CLR délèguent les membres de l’object pour créer le code le plus rapidement possible. Mais il y a certainement des conseils de programmation décents ici. Vous privilégiez réellement les méthodes d’instance en tant que cibles déléguées, ce qui les rend static n’est pas une optimisation.