J'ai écrit du code pour tester l'impact du try-catch, mais voir des résultats surprenants.
static void Main(string[] args)
{
Thread.CurrentThread.Priority = ThreadPriority.Highest;
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.RealTime;
long start = 0, stop = 0, elapsed = 0;
double avg = 0.0;
long temp = Fibo(1);
for (int i = 1; i < 100000000; i++)
{
start = Stopwatch.GetTimestamp();
temp = Fibo(100);
stop = Stopwatch.GetTimestamp();
elapsed = stop - start;
avg = avg + ((double)elapsed - avg) / i;
}
Console.WriteLine("Elapsed: " + avg);
Console.ReadKey();
}
static long Fibo(int n)
{
long n1 = 0, n2 = 1, fibo = 0;
n++;
for (int i = 1; i < n; i++)
{
n1 = n2;
n2 = fibo;
fibo = n1 + n2;
}
return fibo;
}
Sur mon ordinateur, cela affiche systématiquement une valeur autour de 0,96.
Lorsque j'enveloppe la boucle for dans Fibo () avec un bloc try-catch comme celui-ci:
static long Fibo(int n)
{
long n1 = 0, n2 = 1, fibo = 0;
n++;
try
{
for (int i = 1; i < n; i++)
{
n1 = n2;
n2 = fibo;
fibo = n1 + n2;
}
}
catch {}
return fibo;
}
Maintenant, il imprime constamment 0,69 ... - il fonctionne en fait plus rapidement! Mais pourquoi?
Remarque: J'ai compilé cela à l'aide de la configuration Release et j'ai directement exécuté le fichier EXE (en dehors de Visual Studio).
EDIT: L' excellente analyse de Jon Skeet montre que try-catch oblige en quelque sorte le x86 CLR à utiliser les registres CPU de manière plus favorable dans ce cas spécifique (et je pense que nous n'avons pas encore compris pourquoi). J'ai confirmé la conclusion de Jon que le x64 CLR n'a pas cette différence, et qu'il était plus rapide que le x86 CLR. J'ai également testé en utilisant des int
types à l'intérieur de la méthode Fibo au lieu de long
types, puis le CLR x86 était aussi rapide que le CLR x64.
MISE À JOUR: Il semble que ce problème a été résolu par Roslyn. Même machine, même version CLR - le problème reste le même que lors de la compilation avec VS 2013, mais le problème disparaît lors de la compilation avec VS 2015.
Réponses:
L'un des ingénieurs de Roslyn qui se spécialise dans la compréhension de l'optimisation de l'utilisation de la pile y a jeté un coup d'œil et m'a signalé qu'il semble y avoir un problème dans l'interaction entre la façon dont le compilateur C # génère les magasins de variables locaux et la façon dont le compilateur JIT s'enregistre. planification dans le code x86 correspondant. Le résultat est une génération de code sous-optimale sur les charges et les magasins des sections locales.
Pour une raison inconnue pour nous tous, le chemin de génération de code problématique est évité lorsque le JITter sait que le bloc se trouve dans une région protégée contre les tentatives.
C'est assez bizarre. Nous ferons un suivi avec l'équipe JITter et verrons si nous pouvons obtenir un bug entré afin qu'ils puissent résoudre ce problème.
En outre, nous travaillons sur des améliorations pour Roslyn aux algorithmes des compilateurs C # et VB pour déterminer quand les sections locales peuvent être rendues "éphémères" - c'est-à-dire, simplement poussées et sautées sur la pile, plutôt que d'attribuer un emplacement spécifique sur la pile pour la durée de l'activation. Nous pensons que le JITter sera en mesure de faire un meilleur travail d'allocation des registres et ainsi de suite si nous lui donnons de meilleurs indices sur le moment où les locaux peuvent être "morts" plus tôt.
Merci d'avoir porté cela à notre attention et excuses pour le comportement étrange.
la source
Eh bien, la façon dont vous chronométrez les choses me semble assez désagréable. Il serait beaucoup plus judicieux de simplement chronométrer toute la boucle:
De cette façon, vous n'êtes pas à la merci de minuscules synchronisations, d'arithmétique à virgule flottante et d'erreur accumulée.
Après avoir fait ce changement, voyez si la version "non-catch" est encore plus lente que la version "catch".
EDIT: D'accord, je l'ai essayé moi-même - et je vois le même résultat. Très étrange. Je me demandais si le try / catch désactivait une mauvaise incrustation, mais l'utilisation à la
[MethodImpl(MethodImplOptions.NoInlining)]
place n'a pas aidé ...Fondamentalement, vous devrez regarder le code JITted optimisé sous cordbg, je suppose ...
EDIT: Quelques informations supplémentaires:
n++;
ligne améliore toujours les performances, mais pas autant que de le faire autour du bloc entierArgumentException
dans mes tests) c'est toujours rapideBizarre...
EDIT: D'accord, nous avons le démontage ...
Cela utilise le compilateur C # 2 et le CLR .NET 2 (32 bits), en se désassemblant avec mdbg (car je n'ai pas cordbg sur ma machine). Je vois toujours les mêmes effets de performance, même sous le débogueur. La version rapide utilise un
try
bloc autour de tout entre les déclarations de variables et l'instruction de retour, avec juste uncatch{}
gestionnaire. Évidemment, la version lente est la même, sauf sans le try / catch. Le code appelant (c.-à-d. Principal) est le même dans les deux cas, et a la même représentation d'assembly (donc ce n'est pas un problème en ligne).Code démonté pour une version rapide:
Code démonté pour la version lente:
Dans chaque cas, le
*
montre où le débogueur est entré dans un simple "step-into".EDIT: D'accord, j'ai maintenant parcouru le code et je pense que je peux voir comment chaque version fonctionne ... et je crois que la version plus lente est plus lente car elle utilise moins de registres et plus d'espace de pile. Pour les petites valeurs de
n
c'est peut-être plus rapide - mais lorsque la boucle prend la majeure partie du temps, elle est plus lente.Peut-être que le bloc try / catch force plus de registres à être sauvegardés et restaurés, donc le JIT utilise aussi ceux pour la boucle ... ce qui s'avère améliorer les performances globales. Il n'est pas clair si c'est une décision raisonnable pour le JIT de ne pas utiliser autant de registres dans le code "normal".
EDIT: Je viens d'essayer cela sur ma machine x64. Le CLR x64 est beaucoup plus rapide (environ 3-4 fois plus rapide) que le CLR x86 sur ce code, et sous x64, le bloc try / catch ne fait pas de différence notable.
la source
esi,edi
pour l'un des longs au lieu de la pile. Il utiliseebx
comme compteur, où la version lente utiliseesi
.Les démontages de Jon montrent que la différence entre les deux versions est que la version rapide utilise une paire de registres (
esi,edi
) pour stocker une des variables locales là où la version lente ne le fait pas.Le compilateur JIT fait différentes hypothèses concernant l'utilisation du registre pour le code qui contient un bloc try-catch par rapport au code qui n'en contient pas. Cela lui fait faire des choix d'allocation de registre différents. Dans ce cas, cela favorise le code avec le bloc try-catch. Un code différent peut conduire à l'effet inverse, donc je ne considérerais pas cela comme une technique d'accélération générale.
En fin de compte, il est très difficile de dire quel code finira par fonctionner le plus rapidement. Quelque chose comme l'allocation des registres et les facteurs qui l'influencent sont des détails d'implémentation de si bas niveau que je ne vois pas comment une technique spécifique pourrait produire de manière fiable un code plus rapide.
Par exemple, envisagez les deux méthodes suivantes. Ils ont été adaptés à partir d'un exemple concret:
L'un est une version générique de l'autre. Remplacer le type générique par
StructArray
rendrait les méthodes identiques. Parce qu'ilStructArray
s'agit d'un type de valeur, il obtient sa propre version compilée de la méthode générique. Pourtant, le temps d'exécution réel est nettement plus long que celui de la méthode spécialisée, mais uniquement pour x86. Pour x64, les timings sont à peu près identiques. Dans d'autres cas, j'ai également observé des différences pour x64.la source
Cela ressemble à un cas d'inline qui a mal tourné. Sur un noyau x86, la gigue a les registres ebx, edx, esi et edi disponibles pour le stockage général des variables locales. Le registre ecx est disponible dans une méthode statique, il ne doit pas stocker ce . Le registre eax est souvent nécessaire pour les calculs. Mais ce sont des registres 32 bits, pour les variables de type long, il faut utiliser une paire de registres. Ce sont edx: eax pour les calculs et edi: ebx pour le stockage.
C'est ce qui ressort dans le démontage pour la version lente, ni edi ni ebx ne sont utilisés.
Lorsque la gigue ne trouve pas suffisamment de registres pour stocker les variables locales, elle doit générer du code pour les charger et les stocker à partir du cadre de pile. Cela ralentit le code, il empêche une optimisation de processeur nommée "renommage de registre", une astuce d'optimisation de noyau de processeur interne qui utilise plusieurs copies d'un registre et permet une exécution super-scalaire. Ce qui permet à plusieurs instructions de s'exécuter simultanément, même lorsqu'elles utilisent le même registre. Ne pas avoir suffisamment de registres est un problème courant sur les cœurs x86, adressé en x64 qui a 8 registres supplémentaires (r9 à r15).
La gigue fera de son mieux pour appliquer une autre optimisation de génération de code, elle essaiera d'incorporer votre méthode Fibo (). En d'autres termes, n'appelez pas la méthode mais générez le code de la méthode en ligne dans la méthode Main (). Optimisation assez importante qui, pour sa part, rend les propriétés d'une classe C # gratuites, leur donnant la perf d'un champ. Il évite la surcharge de l'appel de méthode et la configuration de son cadre de pile, économise quelques nanosecondes.
Il existe plusieurs règles qui déterminent exactement quand une méthode peut être alignée. Ils ne sont pas exactement documentés mais ont été mentionnés dans des articles de blog. Une règle est que cela ne se produira pas lorsque le corps de la méthode est trop grand. Cela vainc le gain de l'inline, il génère trop de code qui ne rentre pas aussi bien dans le cache d'instructions L1. Une autre règle stricte qui s'applique ici est qu'une méthode ne sera pas insérée lorsqu'elle contient une instruction try / catch. L'arrière-plan derrière celui-ci est un détail d'implémentation des exceptions, elles se superposent au support intégré de Windows pour SEH (Structure Exception Handling) qui est basé sur un cadre de pile.
Un comportement de l'algorithme d'allocation de registre dans la gigue peut être déduit de la lecture avec ce code. Il semble savoir quand la gigue essaie d'inclure une méthode. Une règle semble utiliser que seule la paire de registres edx: eax peut être utilisée pour le code en ligne qui a des variables locales de type long. Mais pas edi: ebx. Sans doute parce que cela serait trop préjudiciable à la génération de code pour la méthode d'appel, edi et ebx sont des registres de stockage importants.
Vous obtenez donc la version rapide car la gigue sait d'avance que le corps de la méthode contient des instructions try / catch. Il sait qu'il ne peut jamais être inséré et utilise donc facilement edi: ebx pour le stockage de la variable longue. Vous avez la version lente parce que la gigue ne savait pas d'avance que l'inline ne fonctionnerait pas. Il ne l'a découvert qu'après avoir généré le code du corps de la méthode.
L'inconvénient est alors qu'il ne revient pas en arrière et ne recrée pas le code de la méthode. Ce qui est compréhensible, compte tenu des contraintes de temps dans lesquelles il doit fonctionner.
Ce ralentissement ne se produit pas sur x64 car pour l'un, il a 8 registres de plus. Pour un autre car il peut stocker un long dans un seul registre (comme rax). Et le ralentissement ne se produit pas lorsque vous utilisez int au lieu de long car la gigue a beaucoup plus de flexibilité dans la sélection des registres.
la source
J'aurais mis cela en commentaire car je ne suis vraiment pas certain que ce soit probablement le cas, mais si je me souviens bien, une instruction try / except n'implique pas une modification de la façon dont le mécanisme d'élimination des déchets de le compilateur fonctionne, en ce sens qu'il efface les allocations de mémoire d'objets de manière récursive hors de la pile. Il peut ne pas y avoir d'objet à nettoyer dans ce cas ou la boucle for peut constituer une fermeture que le mécanisme de récupération de place reconnaît suffisante pour appliquer une méthode de collecte différente. Probablement pas, mais je pensais que cela valait la peine d'être mentionné car je ne l'avais pas vu discuté ailleurs.
la source