Les classes scellées offrent-elles vraiment des avantages en termes de performances ?
-
08-06-2019 - |
Question
J'ai rencontré de nombreux conseils d'optimisation qui indiquent que vous devez marquer vos classes comme scellées pour obtenir des avantages supplémentaires en termes de performances.
J'ai effectué quelques tests pour vérifier le différentiel de performances et je n'en ai trouvé aucun.Est-ce que je fais quelque chose de mal?Est-ce que je manque le cas où les classes scellées donneront de meilleurs résultats ?
Quelqu'un a-t-il fait des tests et vu une différence ?
Aide-moi à apprendre :)
La solution
Le JITter utilisera parfois des appels non virtuels à des méthodes dans des classes scellées, car il n'est pas possible de les étendre davantage.
Il existe des règles complexes concernant le type d'appel, virtuel/non virtuel, et je ne les connais pas toutes, je ne peux donc pas vraiment les décrire pour vous, mais si vous recherchez sur Google des classes scellées et des méthodes virtuelles, vous trouverez peut-être des articles sur le sujet.
Notez que tout type d'avantage en termes de performances que vous obtiendriez de ce niveau d'optimisation doit être considéré comme un dernier recours, optimisez toujours au niveau algorithmique avant d'optimiser au niveau du code.
Voici un lien mentionnant ceci : Randonnée sur le mot-clé scellé
Autres conseils
La réponse est non, les classes scellées ne fonctionnent pas mieux que les classes non scellées.
Le problème se résume au call
contre callvirt
Codes opérationnels IL. Call
est plus rapide que callvirt
, et callvirt
est principalement utilisé lorsque vous ne savez pas si l'objet a été sous-classé.Donc les gens supposent que si vous scellez une classe, tous les codes opérationnels changeront de calvirts
à calls
et sera plus rapide.
Malheureusement callvirt
fait d'autres choses qui le rendent également utile, comme vérifier les références nulles.Cela signifie que même si une classe est scellée, la référence peut toujours être nulle et donc un callvirt
est nécessaire.Vous pouvez contourner ce problème (sans avoir besoin de sceller la classe), mais cela devient un peu inutile.
Utilisation des structures call
car ils ne peuvent pas être sous-classés et ne sont jamais nuls.
Voir cette question pour plus d'informations :
Mise à jour:Depuis .NET Core 2.0 et .NET Desktop 4.7.1, le CLR prend désormais en charge la dévirtualisation.Il peut utiliser des méthodes dans des classes scellées et remplacer les appels virtuels par des appels directs - et il peut également le faire pour des classes non scellées s'il peut déterminer qu'il est possible de le faire en toute sécurité.
Dans un tel cas (une classe scellée que le CLR ne pourrait pas autrement détecter comme pouvant être dévirtualisée en toute sécurité), une classe scellée devrait en fait offrir une sorte d'avantage en termes de performances.
Cela dit, je ne pense pas que cela vaille la peine de s'inquiéter sauf si vous aviez déjà profilé le code et déterminé que vous étiez dans un chemin particulièrement chaud appelé des millions de fois, ou quelque chose comme ça :
Réponse originale :
J'ai créé le programme de test suivant, puis je l'ai décompilé à l'aide de Reflector pour voir quel code MSIL a été émis.
public class NormalClass {
public void WriteIt(string x) {
Console.WriteLine("NormalClass");
Console.WriteLine(x);
}
}
public sealed class SealedClass {
public void WriteIt(string x) {
Console.WriteLine("SealedClass");
Console.WriteLine(x);
}
}
public static void CallNormal() {
var n = new NormalClass();
n.WriteIt("a string");
}
public static void CallSealed() {
var n = new SealedClass();
n.WriteIt("a string");
}
Dans tous les cas, le compilateur C# (Visual studio 2010 dans la configuration Release build) émet un MSIL identique, qui est le suivant :
L_0000: newobj instance void <NormalClass or SealedClass>::.ctor()
L_0005: stloc.0
L_0006: ldloc.0
L_0007: ldstr "a string"
L_000c: callvirt instance void <NormalClass or SealedClass>::WriteIt(string)
L_0011: ret
La raison souvent citée pour laquelle les gens disent que scellé offre des avantages en termes de performances est que le compilateur sait que la classe n'est pas surchargée et peut donc utiliser call
au lieu de callvirt
car il n'a pas besoin de vérifier les virtuels, etc.Comme démontré ci-dessus, ce n’est pas vrai.
Ma prochaine pensée était que même si le MSIL est identique, peut-être que le compilateur JIT traite les classes scellées différemment ?
J'ai exécuté une version sous le débogueur de Visual Studio et visualisé la sortie x86 décompilée.Dans les deux cas, le code x86 était identique, à l'exception des noms de classes et des adresses mémoire de fonctions (qui doivent bien entendu être différentes).C'est ici
// var n = new NormalClass();
00000000 push ebp
00000001 mov ebp,esp
00000003 sub esp,8
00000006 cmp dword ptr ds:[00585314h],0
0000000d je 00000014
0000000f call 70032C33
00000014 xor edx,edx
00000016 mov dword ptr [ebp-4],edx
00000019 mov ecx,588230h
0000001e call FFEEEBC0
00000023 mov dword ptr [ebp-8],eax
00000026 mov ecx,dword ptr [ebp-8]
00000029 call dword ptr ds:[00588260h]
0000002f mov eax,dword ptr [ebp-8]
00000032 mov dword ptr [ebp-4],eax
// n.WriteIt("a string");
00000035 mov edx,dword ptr ds:[033220DCh]
0000003b mov ecx,dword ptr [ebp-4]
0000003e cmp dword ptr [ecx],ecx
00000040 call dword ptr ds:[0058827Ch]
// }
00000046 nop
00000047 mov esp,ebp
00000049 pop ebp
0000004a ret
J'ai alors pensé que peut-être l'exécution sous le débogueur l'amenait à effectuer une optimisation moins agressive ?
J'ai ensuite exécuté un exécutable de version autonome en dehors de tout environnement de débogage, et j'ai utilisé WinDBG + SOS pour intervenir une fois le programme terminé et visualiser le démontage du code x86 compilé JIT.
Comme vous pouvez le voir dans le code ci-dessous, lors de l'exécution en dehors du débogueur, le compilateur JIT est plus agressif et il a intégré le WriteIt
méthode directement dans l’appelant.La chose cruciale cependant est que c'était identique lors de l'appel d'une classe scellée ou non scellée.Il n’y a aucune différence entre une classe scellée ou non scellée.
Le voici lors de l'appel d'une classe normale :
Normal JIT generated code
Begin 003c00b0, size 39
003c00b0 55 push ebp
003c00b1 8bec mov ebp,esp
003c00b3 b994391800 mov ecx,183994h (MT: ScratchConsoleApplicationFX4.NormalClass)
003c00b8 e8631fdbff call 00172020 (JitHelp: CORINFO_HELP_NEWSFAST)
003c00bd e80e70106f call mscorlib_ni+0x2570d0 (6f4c70d0) (System.Console.get_Out(), mdToken: 060008fd)
003c00c2 8bc8 mov ecx,eax
003c00c4 8b1530203003 mov edx,dword ptr ds:[3302030h] ("NormalClass")
003c00ca 8b01 mov eax,dword ptr [ecx]
003c00cc 8b403c mov eax,dword ptr [eax+3Ch]
003c00cf ff5010 call dword ptr [eax+10h]
003c00d2 e8f96f106f call mscorlib_ni+0x2570d0 (6f4c70d0) (System.Console.get_Out(), mdToken: 060008fd)
003c00d7 8bc8 mov ecx,eax
003c00d9 8b1534203003 mov edx,dword ptr ds:[3302034h] ("a string")
003c00df 8b01 mov eax,dword ptr [ecx]
003c00e1 8b403c mov eax,dword ptr [eax+3Ch]
003c00e4 ff5010 call dword ptr [eax+10h]
003c00e7 5d pop ebp
003c00e8 c3 ret
Vs une classe scellée :
Normal JIT generated code
Begin 003c0100, size 39
003c0100 55 push ebp
003c0101 8bec mov ebp,esp
003c0103 b90c3a1800 mov ecx,183A0Ch (MT: ScratchConsoleApplicationFX4.SealedClass)
003c0108 e8131fdbff call 00172020 (JitHelp: CORINFO_HELP_NEWSFAST)
003c010d e8be6f106f call mscorlib_ni+0x2570d0 (6f4c70d0) (System.Console.get_Out(), mdToken: 060008fd)
003c0112 8bc8 mov ecx,eax
003c0114 8b1538203003 mov edx,dword ptr ds:[3302038h] ("SealedClass")
003c011a 8b01 mov eax,dword ptr [ecx]
003c011c 8b403c mov eax,dword ptr [eax+3Ch]
003c011f ff5010 call dword ptr [eax+10h]
003c0122 e8a96f106f call mscorlib_ni+0x2570d0 (6f4c70d0) (System.Console.get_Out(), mdToken: 060008fd)
003c0127 8bc8 mov ecx,eax
003c0129 8b1534203003 mov edx,dword ptr ds:[3302034h] ("a string")
003c012f 8b01 mov eax,dword ptr [ecx]
003c0131 8b403c mov eax,dword ptr [eax+3Ch]
003c0134 ff5010 call dword ptr [eax+10h]
003c0137 5d pop ebp
003c0138 c3 ret
Pour moi, cela constitue une preuve solide qu'il existe ne peut pas y a-t-il une amélioration des performances entre les méthodes d'appel sur les classes scellées et non scellées...Je pense que je suis heureux maintenant :-)
Comme je le sais, il n'y a aucune garantie de bénéfice en termes de performances.Mais il y a une chance de réduire la pénalité de performance dans certaines conditions spécifiques avec méthode scellée.(la classe scellée rend toutes les méthodes scellées.)
Mais cela dépend de l'environnement d'implémentation et d'exécution du compilateur.
Détails
De nombreux processeurs modernes utilisent une longue structure de pipeline pour augmenter les performances.Étant donné que le processeur est incroyablement plus rapide que la mémoire, le processeur doit pré-extraire le code de la mémoire pour accélérer le pipeline.Si le code n’est pas prêt au moment opportun, les pipelines seront inactifs.
Il y a un gros obstacle appelé répartition dynamique ce qui perturbe cette optimisation de « prélecture ».Vous pouvez comprendre cela comme un simple branchement conditionnel.
// Value of `v` is unknown,
// and can be resolved only at runtime.
// CPU cannot know which code to prefetch.
// Therefore, just prefetch any one of a() or b().
// This is *speculative execution*.
int v = random();
if (v==1) a();
else b();
Le processeur ne peut pas pré-extraire le code suivant à exécuter dans ce cas car la position du code suivant est inconnue jusqu'à ce que la condition soit résolue.Donc ça fait danger provoque l'inactivité du pipeline.Et la pénalité de performance au ralenti est énorme en mode régulier.
Une chose similaire se produit en cas de remplacement de méthode.Le compilateur peut déterminer la substitution de méthode appropriée pour l'appel de méthode en cours, mais c'est parfois impossible.Dans ce cas, la méthode appropriée ne peut être déterminée qu’au moment de l’exécution.Il s'agit également d'un cas de répartition dynamique et l'une des principales raisons pour lesquelles les langages typés dynamiquement sont généralement plus lents que les langages typés statiquement.
Certains processeurs (y compris les puces x86 récentes d'Intel) utilisent une technique appelée exécution spéculative utiliser le pipeline même dans la situation.Il suffit de pré-extraire l'un des chemins d'exécution.Mais le taux de réussite de cette technique n’est pas si élevé.Et l’échec de la spéculation provoque le blocage du pipeline, ce qui entraîne également une énorme pénalité en termes de performances.(c'est entièrement par l'implémentation du CPU.certains processeurs mobiles sont connus car ils ne proposent pas ce type d'optimisation pour économiser de l'énergie)
Fondamentalement, C# est un langage compilé statiquement.Mais pas toujours.Je ne connais pas la condition exacte et cela dépend entièrement de l'implémentation du compilateur.Certains compilateurs peuvent éliminer la possibilité de répartition dynamique en empêchant le remplacement de méthode si la méthode est marquée comme sealed
.Les compilateurs stupides ne le peuvent pas.C'est l'avantage en termes de performances du sealed
.
Cette réponse (Pourquoi est-il plus rapide de traiter un tableau trié qu’un tableau non trié ?) décrit beaucoup mieux la prédiction de branche.
Marquer une classe sealed
ne devrait avoir aucun impact sur les performances.
Il y a des cas où csc
il faudra peut-être émettre un callvirt
opcode au lieu d'un call
opcode.Il semble cependant que ces cas soient rares.
Et il me semble que le JIT devrait pouvoir émettre le même appel de fonction non virtuelle pour callvirt
que ce serait pour call
, s'il sait que la classe n'a pas (encore) de sous-classes.S'il n'existe qu'une seule implémentation de la méthode, cela ne sert à rien de charger son adresse à partir d'une table virtuelle : il suffit d'appeler directement cette implémentation.D’ailleurs, le JIT peut même intégrer la fonction.
C'est un peu un pari de la part du JIT, car si une sous-classe est chargé plus tard, le JIT devra jeter ce code machine et le compiler à nouveau, émettant un véritable appel virtuel.Je suppose que cela n'arrive pas souvent dans la pratique.
(Et oui, les concepteurs de machines virtuelles recherchent vraiment de manière agressive ces minuscules gains de performances.)
Cours scellés devrait apporter une amélioration des performances.Puisqu'une classe scellée ne peut pas être dérivée, tous les membres virtuels peuvent être transformés en membres non virtuels.
Bien sûr, nous parlons de très petits gains.Je ne marquerais pas une classe comme scellée juste pour obtenir une amélioration des performances, à moins que le profilage ne révèle qu'il s'agit d'un problème.
<débat hors sujet>
je détester classes scellées.Même si les avantages en termes de performances sont étonnants (ce dont je doute), ils détruire le modèle orienté objet en empêchant la réutilisation via l'héritage.Par exemple, la classe Thread est scellée.Même si je peux comprendre que l'on souhaite que les threads soient aussi efficaces que possible, je peux également imaginer des scénarios dans lesquels le fait de pouvoir sous-classer Thread présenterait de grands avantages.Auteurs de classe, si vous doit sceller vos cours pour des raisons de « performance », veuillez fournir une interface à tout le moins, afin que nous n'ayons pas à envelopper et remplacer partout où nous avons besoin d'une fonctionnalité que vous avez oubliée.
Exemple: Fil de sécurité J'ai dû envelopper la classe Thread car Thread est scellé et il n'y a pas d'interface IThread ;SafeThread intercepte automatiquement les exceptions non gérées sur les threads, ce qui manque complètement dans la classe Thread.[et non, les événements d'exception non gérés le font pas récupérer les exceptions non gérées dans les threads secondaires].
</hors sujet-rant>
Je considère les classes "scellées" comme le cas normal et j'ai TOUJOURS une raison d'omettre le mot-clé "scellé".
Les raisons les plus importantes pour moi sont :
a) De meilleurs contrôles au moment de la compilation (la diffusion vers des interfaces non implémentées sera détectée au moment de la compilation, pas seulement au moment de l'exécution)
et, principale raison :
b) L'abus de mes cours n'est pas possible de cette façon
J'aurais aimé que Microsoft fasse de « scellé » la norme, et non de « descellé ».
@Vaibhav, quel type de tests avez-vous exécuté pour mesurer les performances ?
Je suppose qu'il faudrait utiliser Rotor et pour explorer la CLI et comprendre comment une classe scellée améliorerait les performances.
SSCLI (Rotor)
SSLLI :Infrastructure de langage commun à source partagéeL'infrastructure linguistique commune (CLI) est la norme ECMA qui décrit le noyau du framework .NET.Le CLI source partagé (SSCLI), également connu sous le nom de Rotor, est une archive compressée du code source à une implémentation fonctionnelle de l'ECMA CLI et de la spécification du langage ECMA C #, technologies au cœur de l'architecture .NET de Microsoft.
les classes scellées seront au moins un tout petit peu plus rapides, mais peuvent parfois être beaucoup plus rapides...si JIT Optimizer peut intégrer des appels qui auraient autrement été des appels virtuels.Ainsi, là où il existe des méthodes souvent appelées qui sont suffisamment petites pour être intégrées, envisagez certainement de sceller la classe.
Cependant, la meilleure raison de sceller une classe est de dire : « Je n'ai pas conçu cette classe pour en hériter, donc je ne vais pas vous laisser vous brûler en supposant qu'elle a été conçue pour l'être, et je ne vais pas vous laisser hériter. me brûler en m'enfermant dans une implémentation parce que je vous laisse en dériver."
Je sais que certains ici ont dit qu'ils détestaient les cours fermés parce qu'ils voulaient avoir l'opportunité de tirer n'importe quoi...mais ce n'est SOUVENT pas le choix le plus maintenable...car exposer une classe à la dérivation vous enferme dans bien plus que ne pas exposer tout cela.C'est comme dire "Je déteste les cours qui ont des membres privés...Souvent, je n'arrive pas à faire faire à la classe ce que je veux parce que je n'y ai pas accès." L'encapsulation est importante...le scellement est une forme d’encapsulation.
Exécutez ce code et vous verrez que les classes scellées sont 2 fois plus rapides :
class Program
{
static void Main(string[] args)
{
Console.ReadLine();
var watch = new Stopwatch();
watch.Start();
for (int i = 0; i < 10000000; i++)
{
new SealedClass().GetName();
}
watch.Stop();
Console.WriteLine("Sealed class : {0}", watch.Elapsed.ToString());
watch.Start();
for (int i = 0; i < 10000000; i++)
{
new NonSealedClass().GetName();
}
watch.Stop();
Console.WriteLine("NonSealed class : {0}", watch.Elapsed.ToString());
Console.ReadKey();
}
}
sealed class SealedClass
{
public string GetName()
{
return "SealedClass";
}
}
class NonSealedClass
{
public string GetName()
{
return "NonSealedClass";
}
}
sortir:Classe scellée :00: 00: 00.1897568 Classe non liée:00:00:00.3826678