web-dev-qa-db-fra.com

Try-catch accélérant mon code?

J'ai écrit du code pour tester l'impact de try-catch, mais en voyant 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.

Quand j'emballe 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 systématiquement 0,69 ... - il est en fait plus rapide! Mais pourquoi?

Remarque: j'ai compilé cette information à l'aide de la configuration Release et ai directement exécuté le fichier EXE (en dehors de Visual Studio).

EDIT: L'analyse de Jon Skeet excellent montre que try-catch est en quelque sorte à l'origine de la x86 CLR pour utiliser les registres de la CPU d'une manière plus favorable dans ce cas particulier (et je pense sommes encore à comprendre pourquoi). J'ai confirmé la conclusion de Jon selon laquelle x64 CLR n'avait pas cette différence et qu'elle était plus rapide que le x86 CLR. J'ai également testé l'utilisation de int types dans la méthode Fibo au lieu de long types, puis le x86 CLR était aussi rapide que le x64 CLR.


UPDATE: Il semble que ce problème ait été résolu par Roslyn. Même machine, même version CLR - le problème persiste lorsque compilé avec VS 2013, mais le problème disparaît lors de la compilation avec VS 2015.

1445
Eren Ersönmez

L'un des ingénieurs Roslyn , spécialisé dans l'optimisation de l'utilisation de la pile, a examiné la situation et m'a signalé qu'il semblait y avoir un problème d'interaction entre la manière dont le compilateur C # génère des magasins de variables locales et La manière dont le compilateur JIT enregistre la planification dans le code x86 correspondant. Le résultat est une génération de code non optimale sur les charges et les magasins des locaux.

Pour des raisons qui ne sont pas claires pour nous tous, le chemin problématique de génération de code est évité lorsque le JITter sait que le bloc se trouve dans une région try-protected.

C'est assez bizarre. Nous ferons un suivi avec l'équipe JITter pour voir si nous pouvons obtenir un bogue afin qu'ils puissent résoudre ce problème.

De plus, nous travaillons à l’amélioration des algorithmes des compilateurs C # et VB de Roslyn afin de déterminer quand les locaux peuvent être rendus "éphémères" - c’est-à-dire qu’ils sont simplement poussés et sautés sur la pile, plutôt que emplacement spécifique sur la pile pendant la durée de l'activation. Nous pensons que le JITter sera en mesure de faire un meilleur travail d’allocation de registres et ainsi de suite si nous lui donnons de meilleures indications sur le moment où les locaux peuvent être "morts" plus tôt.

Merci d’avoir porté cela à notre attention et nos excuses pour ce comportement étrange.

1007
Eric Lippert

Eh bien, la façon dont vous synchronisez les choses me semble assez désagréable. Il serait beaucoup plus judicieux de chronométrer toute la boucle:

var stopwatch = Stopwatch.StartNew();
for (int i = 1; i < 100000000; i++)
{
    Fibo(100);
}
stopwatch.Stop();
Console.WriteLine("Elapsed time: {0}", stopwatch.Elapsed);

De cette façon, vous n'êtes pas à la merci de minuscules timings, d'arithmétiques en virgule flottante et d'erreurs accumulées.

Ceci fait, voyez si la version "non-catch" est toujours plus lente que la version "catch".

EDIT: D'accord, j'ai essayé moi-même - et je vois le même résultat. Très étrange. Je me demandais si try/catch désactivait certains mauvais alignements, mais utiliser [MethodImpl(MethodImplOptions.NoInlining)] à la place ne m'aidait pas ...

Fondamentalement, vous devez regarder le code optimisé JITted sous cordbg, je suppose ...

EDIT: Quelques informations supplémentaires:

  • Le fait d’essayer/attraper uniquement la ligne n++; améliore toujours les performances, mais pas autant que de le faire sur tout le bloc
  • Si vous attrapez une exception spécifique (ArgumentException dans mes tests) c'est encore rapide
  • Si vous imprimez l'exception dans le bloc catch, c'est encore rapide
  • Si vous relancez l'exception dans le bloc catch, c'est à nouveau lent
  • Si vous utilisez un bloc finally au lieu d'un bloc catch, c'est à nouveau lent
  • Si vous utilisez un bloc finally ainsi que un bloc catch, c'est rapide

Bizarre...

EDIT: D'accord, nous avons le démontage ...

Ceci utilise le compilateur C # 2 et le CLR .NET 2 (32 bits), désassemblé avec mdbg (car je n’ai pas cordbg sur ma machine). Je vois toujours les mêmes effets sur les performances, même sous le débogueur. La version rapide utilise un bloc try autour de tout ce qui se trouve entre les déclarations de variable et l'instruction return, avec juste un gestionnaire catch{}. Évidemment, la version lente est la même, sauf sans try/catch. Le code appelant (c’est-à-dire Principal) est le même dans les deux cas et a la même représentation à l’Assemblée (ce n’est donc pas un problème en ligne).

Code démonté pour la version rapide:

 [0000] Push        ebp
 [0001] mov         ebp,esp
 [0003] Push        edi
 [0004] Push        esi
 [0005] Push        ebx
 [0006] sub         esp,1Ch
 [0009] xor         eax,eax
 [000b] mov         dword ptr [ebp-20h],eax
 [000e] mov         dword ptr [ebp-1Ch],eax
 [0011] mov         dword ptr [ebp-18h],eax
 [0014] mov         dword ptr [ebp-14h],eax
 [0017] xor         eax,eax
 [0019] mov         dword ptr [ebp-18h],eax
*[001c] mov         esi,1
 [0021] xor         edi,edi
 [0023] mov         dword ptr [ebp-28h],1
 [002a] mov         dword ptr [ebp-24h],0
 [0031] inc         ecx
 [0032] mov         ebx,2
 [0037] cmp         ecx,2
 [003a] jle         00000024
 [003c] mov         eax,esi
 [003e] mov         edx,edi
 [0040] mov         esi,dword ptr [ebp-28h]
 [0043] mov         edi,dword ptr [ebp-24h]
 [0046] add         eax,dword ptr [ebp-28h]
 [0049] adc         edx,dword ptr [ebp-24h]
 [004c] mov         dword ptr [ebp-28h],eax
 [004f] mov         dword ptr [ebp-24h],edx
 [0052] inc         ebx
 [0053] cmp         ebx,ecx
 [0055] jl          FFFFFFE7
 [0057] jmp         00000007
 [0059] call        64571ACB
 [005e] mov         eax,dword ptr [ebp-28h]
 [0061] mov         edx,dword ptr [ebp-24h]
 [0064] lea         esp,[ebp-0Ch]
 [0067] pop         ebx
 [0068] pop         esi
 [0069] pop         edi
 [006a] pop         ebp
 [006b] ret

Code démonté pour la version lente:

 [0000] Push        ebp
 [0001] mov         ebp,esp
 [0003] Push        esi
 [0004] sub         esp,18h
*[0007] mov         dword ptr [ebp-14h],1
 [000e] mov         dword ptr [ebp-10h],0
 [0015] mov         dword ptr [ebp-1Ch],1
 [001c] mov         dword ptr [ebp-18h],0
 [0023] inc         ecx
 [0024] mov         esi,2
 [0029] cmp         ecx,2
 [002c] jle         00000031
 [002e] mov         eax,dword ptr [ebp-14h]
 [0031] mov         edx,dword ptr [ebp-10h]
 [0034] mov         dword ptr [ebp-0Ch],eax
 [0037] mov         dword ptr [ebp-8],edx
 [003a] mov         eax,dword ptr [ebp-1Ch]
 [003d] mov         edx,dword ptr [ebp-18h]
 [0040] mov         dword ptr [ebp-14h],eax
 [0043] mov         dword ptr [ebp-10h],edx
 [0046] mov         eax,dword ptr [ebp-0Ch]
 [0049] mov         edx,dword ptr [ebp-8]
 [004c] add         eax,dword ptr [ebp-1Ch]
 [004f] adc         edx,dword ptr [ebp-18h]
 [0052] mov         dword ptr [ebp-1Ch],eax
 [0055] mov         dword ptr [ebp-18h],edx
 [0058] inc         esi
 [0059] cmp         esi,ecx
 [005b] jl          FFFFFFD3
 [005d] mov         eax,dword ptr [ebp-1Ch]
 [0060] mov         edx,dword ptr [ebp-18h]
 [0063] lea         esp,[ebp-4]
 [0066] pop         esi
 [0067] pop         ebp
 [0068] ret

Dans chaque cas, le * montre où le débogueur est entré dans un simple "pas à pas".

EDIT: D'accord, j'ai parcouru le code et je peux voir comment chaque version fonctionne ... et je pense que la version la 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 le bloc try/catch oblige-t-il plusieurs registres à être sauvegardés et restaurés, de sorte que le JIT utilise également ceux de la boucle ... ce qui améliore les performances globales. Il n'est pas clair si c'est une décision raisonnable que l'EJU n'utilise pas d'utiliser autant de registres dans le code "normal".

EDIT: Je viens d’essayer ceci sur ma machine x64. Le x64 CLR est beaucoup plus rapide (environ 3 à 4 fois plus rapide) que le x86 CLR de ce code et, sous x64, le bloc try/catch ne fait pas une différence notable.

722
Jon Skeet

Les désassemblages de Jon montrent que la différence entre les deux versions réside dans le fait que la version rapide utilise une paire de registres (esi,edi) pour stocker l'une des variables locales, contrairement à la version lente.

Le compilateur JIT émet différentes hypothèses sur l'utilisation du registre pour le code contenant un bloc try-catch par rapport au code qui n'en contient pas. Cela le fait pour faire des choix différents d'allocation de registre. Dans ce cas, cela favorise le code avec le bloc try-catch. Un code différent peut avoir l'effet inverse, je ne le considérerai donc pas comme une technique d'accélération polyvalente.

En fin de compte, il est très difficile de dire quel code fonctionnera le plus rapidement. L'allocation de registre et les facteurs qui l'influencent sont des détails d'implémentation de bas niveau, si bien que je ne vois pas comment une technique spécifique pourrait produire de manière fiable un code plus rapide.

Par exemple, considérons les deux méthodes suivantes. Ils ont été adaptés à partir d'un exemple réel:

interface IIndexed { int this[int index] { get; set; } }
struct StructArray : IIndexed { 
    public int[] Array;
    public int this[int index] {
        get { return Array[index]; }
        set { Array[index] = value; }
    }
}

static int Generic<T>(int length, T a, T b) where T : IIndexed {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}
static int Specialized(int length, StructArray a, StructArray b) {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}

L'un est une version générique de l'autre. Remplacer le type générique par StructArray rendrait les méthodes identiques. Comme StructArray est 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 quasiment identiques. Dans d'autres cas, j'ai également observé des différences pour x64.

113
Jeffrey Sax

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 à usage général de variables locales. Le registre ecx devient disponible dans une méthode statique, il n'a pas besoin de stocker this. 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. Quels sont edx: eax pour les calculs et edi: ebx pour le stockage.

Ce qui ressort du désassemblage de la version lente, ni edi ni ebx ne sont utilisés.

Lorsque la gigue ne parvient pas à trouver suffisamment de registres pour stocker des variables locales, elle doit générer du code pour les charger et les stocker à partir du cadre de la pile. Cela ralentit le code, empêche l’optimisation du processeur appelée "renommer le registre", une astuce d’optimisation interne du cœur du processeur qui utilise plusieurs copies d’un registre et permet une exécution super-scalaire. Ce qui permet l'exécution simultanée de plusieurs instructions, même lorsqu'elles utilisent le même registre. Le manque de registres est un problème courant sur les cœurs x86, résolu en x64 qui dispose de 8 registres supplémentaires (r9 à r15).

La gigue fera de son mieux pour appliquer une autre optimisation de la génération de code, elle tentera d’aligner votre méthode Fibo (). En d'autres termes, n'appelez pas la méthode, mais générez le code de la méthode inline dans la méthode Main (). Une optimisation assez importante qui permet, entre autres, de créer gratuitement les propriétés d’une classe C #, ce qui leur donne la perfection d’un champ. Cela évite la surcharge liée à l'appel de la méthode et à la configuration de son cadre de pile, économise quelques nanosecondes.

Plusieurs règles déterminent exactement quand une méthode peut être en ligne. Ils ne sont pas documentés avec précision, mais ont été mentionnés dans des billets de blog. Une règle est que cela n'arrivera pas lorsque le corps de la méthode est trop grand. Cela annule 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 difficile qui s'applique ici est qu'une méthode ne sera pas en ligne lorsqu'elle contient une instruction try/catch. Le contexte derrière celui-ci est constitué d'un détail d'implémentation des exceptions, qui se greffent sur le support intégré de Windows pour SEH (Structure Exception Handling), basé sur une pile d'empilage.

Un comportement de l’algorithme d’allocation de registre dans la gigue peut être déduit de la lecture de ce code. Il semble que vous sachiez à quel moment la gigue tente d’intégrer une méthode. Une règle semble indiquer que seule la paire de registres edx: eax peut être utilisée pour du code en ligne comportant 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 appelante, edi et ebx sont des registres de stockage importants.

Vous obtenez donc la version rapide, car la gigue sait dès le départ que le corps de la méthode contient des instructions try/catch. Il sait qu’il ne peut jamais être inséré si facilement, utilise edi: ebx pour le stockage de la variable longue. Vous avez la version lente parce que la gigue ne savait pas à l'avance que l'inline ne fonctionnerait pas. Il a seulement découvert after générer le code pour le corps de la méthode.

La faille alors est qu'elle n'est pas retournée et re-générer le code de la méthode. Ce qui est compréhensible, compte tenu des contraintes de temps dans lesquelles il doit opérer.

Ce ralentissement ne se produit pas sur x64 car il possède 8 registres supplémentaires. Pour un autre parce qu'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 offre beaucoup plus de souplesse dans la sélection des registres.

69
Hans Passant

J'aurais mis ceci dans un commentaire car je ne suis vraiment pas sûr que ce soit probablement le cas, mais si je me souviens bien, il n'y a pas d'énoncé try/except qui implique une modification de la manière dont le mécanisme d'élimination des déchets de le compilateur fonctionne car il nettoie de manière récursive les allocations de mémoire d’objets de la pile. Il peut ne pas y avoir d'objet à clarifier 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 n'en avais jamais vu parler ailleurs.

20
miller the gorilla