Essayez-vous d'accélérer mon code?

1505

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 inttypes à l'intérieur de la méthode Fibo au lieu de longtypes, 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.

Eren Ersönmez
la source
111
@Lloyd, il essaie d'obtenir une réponse à sa question "ça tourne plus vite! Mais pourquoi?"
Andreas Niedermair
137
Ainsi, "Swallowing Exceptions" est passé d'une mauvaise pratique à une bonne optimisation des performances: P
Luciano
2
Est-ce dans un contexte arithmétique non contrôlé ou vérifié?
Random832
7
@ taras.roshko: Bien que je ne veuille pas rendre un mauvais service à Eric, ce n'est pas vraiment une question C # - c'est une question de compilateur JIT. La difficulté ultime est de savoir pourquoi le JIT x86 n'utilise pas autant de registres sans le try / catch qu'avec le bloc try / catch.
Jon Skeet
63
Doux, donc si nous imbriquons ces prises, nous pouvons aller encore plus vite, non?
Chuck Pinkert

Réponses:

1053

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.

Eric Lippert
la source
8
Je me suis toujours demandé pourquoi le compilateur C # génère autant de sections locales étrangères. Par exemple, de nouvelles expressions d'initialisation de tableau génèrent toujours un local, mais ne sont jamais nécessaires pour générer un local. S'il permet au JITter de produire du code sensiblement plus performant, le compilateur C # devrait peut-être être un peu plus prudent en générant des sections locales inutiles ...
Timwi
33
@Timwi: Absolument. Dans le code non optimisé, le compilateur produit des sections locales inutiles avec un grand abandon car elles facilitent le débogage. Dans le code optimisé, les temporaires inutiles doivent être supprimés si possible. Malheureusement, nous avons eu de nombreux bugs au cours des années où nous avons accidentellement désoptimisé l'optimiseur d'élimination temporaire. L'ingénieur susmentionné est en train de refaire entièrement à partir de zéro tout ce code pour Roslyn, et nous devrions par conséquent avoir un comportement optimisé bien amélioré dans le générateur de code de Roslyn.
Eric Lippert
24
Y a-t-il jamais eu un mouvement sur cette question?
Robert Harvey
10
On dirait que Roslyn l'a réparé.
Eren Ersönmez
56
Vous avez manqué l'occasion de l'appeler un "bug JITter".
mbomb007
734

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:

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

  • Mettre le try / catch autour de la n++;ligne améliore toujours les performances, mais pas autant que de le faire autour du bloc entier
  • Si vous attrapez une exception spécifique ( ArgumentExceptiondans mes tests) c'est toujours rapide
  • Si vous imprimez l'exception dans le bloc catch, c'est toujours 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 qu'un bloc catch, c'est rapide

Bizarre...

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 trybloc autour de tout entre les déclarations de variables et l'instruction de retour, avec juste un catch{}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:

 [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 "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 nc'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.

Jon Skeet
la source
4
@GordonSimpson mais dans le cas où seule une exception spécifique est interceptée, toutes les autres exceptions ne seront pas interceptées, de sorte que les frais généraux impliqués dans votre hypothèse de non-essai seraient toujours nécessaires.
Jon Hanna
45
Cela ressemble à une différence dans l'allocation des registres. La version rapide parvient à utiliser esi,edipour l'un des longs au lieu de la pile. Il utilise ebxcomme compteur, où la version lente utilise esi.
Jeffrey Sax
13
@JeffreySax: Ce n'est pas seulement quels registres sont utilisés mais combien. La version lente utilise plus d'espace de pile, touchant moins de registres. Je ne sais pas pourquoi ...
Jon Skeet
2
Comment les trames d'exception CLR sont-elles traitées en termes de registres et de pile? Est-ce que le fait d'en configurer un aurait pu libérer un registre pour l'utiliser d'une manière ou d'une autre?
Random832
4
IIRC x64 a plus de registres disponibles que x86. L'accélération que vous avez vue serait cohérente avec l'utilisation de registres supplémentaires pour essayer / attraper sous x86.
Dan est en train de jouer par Firelight le
116

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:

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 StructArrayrendrait les méthodes identiques. Parce qu'il StructArrays'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.

Jeffrey Sax
la source
6
Cela étant dit ... pouvez-vous forcer différents choix d'allocation de registre sans utiliser un Try / Catch? Soit comme test pour cette hypothèse, soit comme tentative générale d'ajuster la vitesse?
WernerCD
1
Il existe un certain nombre de raisons pour lesquelles ce cas spécifique peut être différent. C'est peut-être le try-catch. C'est peut-être le fait que les variables sont réutilisées dans une portée interne. Quelle que soit la raison spécifique, c'est un détail d'implémentation sur lequel vous ne pouvez pas compter pour être préservé même si le même code exact est appelé dans un programme différent.
Jeffrey Sax
4
@WernerCD Je dirais que le C et C ++ a un mot-clé pour suggérer que (A) est ignoré par de nombreux compilateurs modernes et (B) il a été décidé de ne pas mettre en C #, suggère que ce n'est pas quelque chose que nous '' Je verrai de façon plus directe.
Jon Hanna
2
@WernerCD - Seulement si vous écrivez l'assemblage vous
OrangeDog
72

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.

Hans Passant
la source
21

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.

Miller le gorille
la source