web-dev-qa-db-fra.com

Dans un commutateur vs dictionnaire pour une valeur de Func, qui est plus rapide et pourquoi?

Supposons qu'il existe le code suivant:

private static int DoSwitch(string arg)
{
    switch (arg)
    {
        case "a": return 0;
        case "b": return 1;
        case "c": return 2;
        case "d": return 3;
    }
    return -1;
}

private static Dictionary<string, Func<int>> dict = new Dictionary<string, Func<int>>
    {
        {"a", () => 0 },
        {"b", () => 1 },
        {"c", () => 2 },
        {"d", () => 3 },
    };

private static int DoDictionary(string arg)
{
    return dict[arg]();
}

En itérant sur les deux méthodes et en comparant, j'obtiens que le dictionnaire est légèrement plus rapide, même lorsque "a", "b", "c", "d" est développé pour inclure plus de clés. Pourquoi cela est-il ainsi?

Cela a-t-il à voir avec la complexité cyclomatique? Est-ce parce que la gigue compile les instructions de retour dans le dictionnaire en code natif une seule fois? Est-ce parce que la recherche du dictionnaire est O (1), ce qui peut ne pas être le cas pour une instruction switch ? (Ce ne sont que des suppositions)

48
cubetwo1729

La réponse courte est que l'instruction switch s'exécute linéairement, tandis que le dictionnaire s'exécute de manière logarithmique.

Au niveau IL, une petite instruction switch est généralement implémentée comme une série d'instructions if-elseif comparant l'égalité de la variable commutée et chaque cas. Ainsi, cette instruction s'exécutera dans un temps linéairement proportionnel au nombre d'options valides pour myVar; les cas seront comparés dans l'ordre où ils apparaissent, et le pire des cas est que toutes les comparaisons soient essayées et que la dernière correspond ou aucune ne le fasse. Donc, avec 32 options, le pire des cas est qu'il n'y en a aucune, et le code aura fait 32 comparaisons pour le déterminer.

Un dictionnaire, d'autre part, utilise une collection optimisée pour l'index pour stocker les valeurs. Dans .NET, un dictionnaire est basé sur une table de hachage, qui a effectivement un temps d'accès constant (l'inconvénient étant une efficacité spatiale extrêmement médiocre). Les autres options couramment utilisées pour "cartographier" des collections comme les dictionnaires incluent des structures d'arbre équilibrées comme des arbres rouge-noir, qui fournissent un accès logarithmique (et une efficacité d'espace linéaire). N'importe lequel de ces éléments permettra au code de trouver la clé correspondant au "cas" approprié dans la collection (ou de déterminer qu'il n'existe pas) beaucoup plus rapidement qu'une instruction switch ne peut faire de même.

EDIT : D'autres réponses et commentateurs ont abordé ce sujet, donc dans un souci d'exhaustivité, je le ferai également. Le compilateur Microsoft ne pas compile toujours un commutateur vers un if/elseif comme je l'ai déduit à l'origine. Il le fait généralement avec un petit nombre de cas et/ou avec des cas "clairsemés" (valeurs non incrémentielles, comme 1, 200, 4000). Avec de plus grands ensembles de cas adjacents, le compilateur convertira le commutateur en une "table de sauts" en utilisant une instruction CIL. Avec de grands ensembles de cas épars, le compilateur peut implémenter une recherche binaire pour restreindre le champ, puis "passer à travers" un petit nombre de cas épars ou implémenter une table de saut pour les cas adjacents.

Cependant, le compilateur choisira généralement l'implémentation qui est le meilleur compromis entre performances et efficacité de l'espace, il n'utilisera donc une table de saut que pour un grand nombre de cas très denses. En effet, une table de saut nécessite un espace en mémoire de l'ordre de la plage de cas qu'elle doit couvrir, ce qui, pour les cas rares, est terriblement inefficace en termes de mémoire. En utilisant un dictionnaire dans le code source, vous forcez essentiellement la main du compilateur; il le fera à votre façon, au lieu de compromettre les performances pour gagner en efficacité mémoire.

Donc, je m'attendrais à la plupart des cas où une instruction switch ou un dictionnaire pourrait être utilisé dans la source pour mieux fonctionner lors de l'utilisation d'un dictionnaire. Un grand nombre de cas dans les instructions switch doivent être évités de toute façon, car ils sont moins maintenables.

46
KeithS

C'est un bon exemple des raisons pour lesquelles les micro-repères peuvent être trompeurs. Le compilateur C # génère un IL différent en fonction de la taille du commutateur/boîtier. Donc, allumer une chaîne comme celle-ci

switch (text) 
{
     case "a": Console.WriteLine("A"); break;
     case "b": Console.WriteLine("B"); break;
     case "c": Console.WriteLine("C"); break;
     case "d": Console.WriteLine("D"); break;
     default: Console.WriteLine("def"); break;
}

produire IL qui fait essentiellement ce qui suit pour chaque cas:

L_0009: ldloc.1 
L_000a: ldstr "a"
L_000f: call bool [mscorlib]System.String::op_Equality(string, string)
L_0014: brtrue.s L_003f

et ensuite

L_003f: ldstr "A"
L_0044: call void [mscorlib]System.Console::WriteLine(string)
L_0049: ret 

C'est à dire. c'est une série de comparaisons. Le temps d'exécution est donc linéaire.

Cependant, l'ajout de cas supplémentaires, par exemple pour inclure toutes les lettres de a-z, change l'IL généré en quelque chose comme ceci pour chacun:

L_0020: ldstr "a"
L_0025: ldc.i4.0 
L_0026: call instance void [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::Add(!0, !1)

et

L_0176: ldloc.1 
L_0177: ldloca.s CS$0$0001
L_0179: call instance bool [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::TryGetValue(!0, !1&)
L_017e: brfalse L_0314

et enfin

L_01f6: ldstr "A"
L_01fb: call void [mscorlib]System.Console::WriteLine(string)
L_0200: ret 

C'est à dire. il utilise désormais un dictionnaire au lieu d'une série de comparaisons de chaînes et obtient ainsi les performances d'un dictionnaire.

En d'autres termes, le code IL généré pour ceux-ci est différent et ce n'est qu'au niveau IL. Le compilateur JIT peut encore être optimisé.

TL; DR: Donc, le moral de l'histoire est de regarder les données réelles et le profil au lieu d'essayer d'optimiser en fonction de micro-repères.

38
Brian Rasmussen

Par défaut, un commutateur sur une chaîne est implémenté comme une construction if/else/if/else. Comme suggéré par Brian, le compilateur convertira le commutateur en table de hachage lorsqu'il s'agrandira. Bart de Smet le montre dans cette vidéo channel9 , (le changement est discuté à 13h50)

Le compilateur ne le fait pas pour 4 éléments car il est conservateur, pour éviter que le coût de l'optimisation ne l'emporte sur les avantages. La création de la table de hachage coûte un peu de temps et de mémoire.

1
user180326

Comme pour de nombreuses questions impliquant des décisions de codegen du compilateur, la réponse est "cela dépend".

La construction de votre propre table de hachage s'exécutera probablement plus rapidement que le code généré par le compilateur dans de nombreux cas, car le compilateur a d'autres mesures de coût qu'il essaie d'équilibrer que vous n'êtes pas: principalement, la consommation de mémoire.

Une table de hachage utilisera plus de mémoire qu'une poignée d'instructions IL if-then-else. Si le compilateur crache une table de hachage pour chaque instruction switch dans un programme, l'utilisation de la mémoire exploserait.

À mesure que le nombre de blocs de casse dans l'instruction switch augmente, vous verrez probablement le compilateur produire du code différent. Avec plus de cas, il est plus justifié pour le compilateur d'abandonner des modèles petits et simples si-alors-sinon au profit d'alternatives plus rapides mais plus grasses.

Je ne sais pas si les compilateurs C # ou JIT effectuent cette optimisation particulière, mais une astuce de compilateur courante pour les instructions switch lorsque les sélecteurs de cas sont nombreux et principalement séquentiels consiste à calculer un vecteur de saut. Cela nécessite plus de mémoire (sous la forme de tables de saut générées par le compilateur incorporées dans le flux de code) mais s'exécute en temps constant. Soustrayez arg - "a", utilisez result comme index dans la table de saut pour passer au bloc de cas approprié. Boom, c'est fait, qu'il y ait 20 ou 2000 cas.

Un compilateur est plus susceptible de passer en mode table de saut lorsque le type de sélecteur de commutateur est char ou int ou enum et les valeurs des sélecteurs de cas sont principalement séquentielles ("dense"), car ces types peuvent être facilement soustrait pour créer un décalage ou un index. Les sélecteurs de cordes sont un peu plus difficiles.

Les sélecteurs de chaînes sont "internés" par le compilateur C #, ce qui signifie que le compilateur ajoute les valeurs des sélecteurs de chaînes à un pool interne de chaînes uniques. L'adresse ou le jeton d'une chaîne internée peut être utilisé comme identité, ce qui permet des optimisations de type int lors de la comparaison des chaînes internes pour l'égalité identité/octet. Avec suffisamment de sélecteurs de cas, le compilateur C # produira un code IL qui recherche l'équivalent interne de la chaîne arg (recherche de table de hachage), puis compare (ou saute les tables) le jeton interné avec les jetons de sélecteur de cas précalculés.

Si vous pouvez convaincre le compilateur de produire du code de table de saut dans le cas du sélecteur char/int/enum, cela peut s'exécuter plus rapidement que d'utiliser votre propre table de hachage.

Pour le cas du sélecteur de chaîne, le code IL doit toujours faire une recherche de hachage, donc toute différence de performances par rapport à l'utilisation de votre propre table de hachage est probablement un lavage.

En général, cependant, vous ne devriez pas trop vous attarder sur ces nuances du compilateur lors de l'écriture du code d'application. Les instructions de commutation sont généralement beaucoup plus faciles à lire et à comprendre qu'une table de hachage de pointeurs de fonction. Les instructions Switch qui sont suffisamment volumineuses pour pousser le compilateur en mode table de saut sont souvent trop volumineuses pour être lisibles par l'homme.

Si vous constatez qu'une instruction switch se trouve dans un point chaud de performance de votre code et que vous avez mesuré avec un profileur qu'elle a un impact tangible sur les performances, alors changer votre code pour utiliser votre propre dictionnaire est un compromis raisonnable pour un gain de performances.

Écrire votre code pour utiliser une table de hachage dès le départ, sans mesure de performance pour justifier ce choix, est une ingénierie excessive qui conduira à un code insondable avec un coût de maintenance inutilement plus élevé. Rester simple.

1
dthorpe