Ce test semble montrer que l'appel d'une méthode virtuelle directement sur la référence d'objet est plus rapide que l'appel sur la référence à l'interface que cet objet implémente.
En d'autres termes:
interface IFoo {
void Bar();
}
class Foo : IFoo {
public virtual void Bar() {}
}
void Benchmark() {
Foo f = new Foo();
IFoo f2 = f;
f.Bar(); // This is faster.
f2.Bar();
}
Venant du monde C++, je m'attendais à ce que ces deux appels soient implémentés de manière identique (comme une simple recherche de table virtuelle) et aient les mêmes performances. Comment C # implémente-t-il les appels virtuels et quel est ce travail "supplémentaire" qui se fait apparemment lors d'un appel via une interface?
OK, les réponses/commentaires que j'ai obtenus jusqu'à présent impliquent qu'il existe une double référence de pointeur pour l'appel virtuel via l'interface par rapport à une seule déréférence pour l'appel virtuel via l'objet.
Alors, s'il vous plaît, quelqu'un pourrait expliquer pourquoi est-ce nécessaire? Quelle est la structure de la table virtuelle en C #? Est-ce "plat" (comme c'est typique pour C++) ou non? Quels ont été les compromis de conception qui ont été faits dans la conception du langage C # qui ont conduit à cela? Je ne dis pas que c'est une "mauvaise" conception, je suis simplement curieux de savoir pourquoi c'était nécessaire.
En un mot, j'aimerais comprendre ce que mon outil fait sous le capot pour que je puisse l'utiliser plus efficacement. Et j'apprécierais si je n'obtenais plus de réponses "tu ne devrais pas savoir ça" ou "utiliser une autre langue".
Juste pour être clair, nous ne traitons pas ici d'un compilateur d'optimisation JIT qui supprime la répartition dynamique: j'ai modifié le test de référence mentionné dans la question d'origine pour instancier une classe ou l'autre au hasard au moment de l'exécution. Étant donné que l'instanciation se produit après la compilation et après le chargement/JITing de l'assembly, il n'y a aucun moyen d'éviter la répartition dynamique dans les deux cas:
interface IFoo {
void Bar();
}
class Foo : IFoo {
public virtual void Bar() {
}
}
class Foo2 : Foo {
public override void Bar() {
}
}
class Program {
static Foo GetFoo() {
if ((new Random()).Next(2) % 2 == 0)
return new Foo();
return new Foo2();
}
static void Main(string[] args) {
var f = GetFoo();
IFoo f2 = f;
Console.WriteLine(f.GetType());
// JIT warm-up
f.Bar();
f2.Bar();
int N = 10000000;
Stopwatch sw = new Stopwatch();
sw.Start();
for (int i = 0; i < N; i++) {
f.Bar();
}
sw.Stop();
Console.WriteLine("Direct call: {0:F2}", sw.Elapsed.TotalMilliseconds);
sw.Reset();
sw.Start();
for (int i = 0; i < N; i++) {
f2.Bar();
}
sw.Stop();
Console.WriteLine("Through interface: {0:F2}", sw.Elapsed.TotalMilliseconds);
// Results:
// Direct call: 24.19
// Through interface: 40.18
}
}
Si quelqu'un est intéressé, voici comment mon Visual C++ 2010 présente une instance d'une classe qui hérite de plusieurs autres classes:
Code:
class IA {
public:
virtual void a() = 0;
};
class IB {
public:
virtual void b() = 0;
};
class C : public IA, public IB {
public:
virtual void a() override {
std::cout << "a" << std::endl;
}
virtual void b() override {
std::cout << "b" << std::endl;
}
};
Débogueur:
c {...} C
IA {...} IA
__vfptr 0x00157754 const C::`vftable'{for `IA'} *
[0] 0x00151163 C::a(void) *
IB {...} IB
__vfptr 0x00157748 const C::`vftable'{for `IB'} *
[0] 0x0015121c C::b(void) *
Plusieurs pointeurs de table virtuelle sont clairement visibles, et sizeof(C) == 8
(en version 32 bits).
Le...
C c;
std::cout << static_cast<IA*>(&c) << std::endl;
std::cout << static_cast<IB*>(&c) << std::endl;
..prints ...
0027F778
0027F77C
... indiquant que des pointeurs vers différentes interfaces au sein du même objet pointent en fait vers différentes parties de cet objet (c'est-à-dire qu'ils contiennent des adresses physiques différentes).
Je pense que l'article à http://msdn.Microsoft.com/en-us/magazine/cc163791.aspx répondra à vos questions. En particulier, voir la section Interface Vtable Map et Interface Map , et la section suivante sur Virtual Dispatch.
Il est probablement possible pour le compilateur JIT de comprendre les choses et d'optimiser le code pour votre cas simple. Mais pas dans le cas général.
IFoo f2 = GetAFoo();
Et GetAFoo
est défini comme renvoyant un IFoo
, alors le compilateur JIT ne pourrait pas optimiser l'appel.
Voici à quoi ressemble le désassemblage (Hans a raison):
f.Bar(); // This is faster.
00000062 mov rax,qword ptr [rsp+20h]
00000067 mov rax,qword ptr [rax]
0000006a mov rcx,qword ptr [rsp+20h]
0000006f call qword ptr [rax+60h]
f2.Bar();
00000072 mov r11,7FF000400A0h
0000007c mov qword ptr [rsp+38h],r11
00000081 mov rax,qword ptr [rsp+28h]
00000086 cmp byte ptr [rax],0
00000089 mov rcx,qword ptr [rsp+28h]
0000008e mov r11,qword ptr [rsp+38h]
00000093 mov rax,qword ptr [rsp+38h]
00000098 call qword ptr [rax]
J'ai essayé votre test et sur ma machine, dans un contexte particulier, le résultat est en fait l'inverse.
J'utilise Windows 7 x64 et j'ai créé un projet d'application de console Visual Studio 2010 dans lequel j'ai copié votre code. Si vous compilez le projet en mode débogage et avec la cible de la plate-forme x86, la sortie sera la suivante:
Appel direct: 48,38
Via l'interface: 42,43
En fait, chaque fois que vous exécutez l'application, cela donnera des résultats légèrement différents, mais les appels d'interface seront toujours plus rapides. Je suppose que puisque l'application est compilée en tant que x86, elle sera exécutée par le système d'exploitation via WOW.
Pour une référence complète, voici les résultats pour le reste de la configuration de compilation et des combinaisons de cibles.
Release mode et x86 cible
Appel direct: 23.02
Via l'interface: 32,73
Debug mode et x64 cible
Appel direct: 49,49
Via l'interface: 56,97
Release mode et x64 cible
Appel direct: 19,60
Via l'interface: 26,45
Tous les tests ci-dessus ont été effectués avec .Net 4.0 comme plate-forme cible pour le compilateur. Lors du passage à 3.5 et de la répétition des tests ci-dessus, les appels via l'interface étaient toujours plus longs que les appels directs.
Ainsi, les tests ci-dessus compliquent plutôt les choses car il semble que le comportement que vous avez repéré ne se produit pas toujours.
Au final, avec le risque de vous bouleverser, je voudrais ajouter quelques réflexions. Beaucoup de gens ont ajouté que les différences de performances sont assez petites et dans la programmation du monde réel, vous ne devriez pas vous en soucier et je suis d'accord avec ce point de vue. Il y a deux raisons principales à cela.
Le premier et le plus annoncé est que .Net a été construit à un niveau supérieur afin de permettre aux développeurs de se concentrer sur les niveaux supérieurs d'applications. Une base de données ou un appel de service externe est des milliers voire des millions de fois plus lent qu'un appel de méthode virtuelle. Avoir une bonne architecture de haut niveau et se concentrer sur les gros consommateurs de performances apportera toujours de meilleurs résultats dans les applications modernes plutôt que d'éviter les déréférences à double pointeur.
Le deuxième et plus obscur est que l'équipe .Net en construisant le framework à un niveau supérieur a en fait introduit une série de niveaux d'abstraction que le compilateur juste à temps pourrait utiliser pour des optimisations sur différentes plateformes. Plus ils donneraient accès aux sous-couches, plus les développeurs seraient en mesure d'optimiser pour une plate-forme spécifique, mais moins le compilateur d'exécution serait en mesure de faire pour les autres. C'est du moins la théorie et c'est pourquoi les choses ne sont pas aussi bien documentées qu'en C++ sur ce sujet particulier.
La règle générale est la suivante: les cours sont rapides. Les interfaces sont lentes.
C'est l'une des raisons de la recommandation "Construire des hiérarchies avec des classes et utiliser des interfaces pour un comportement intra-hiérarchique".
Pour les méthodes virtuelles, la différence peut être légère (comme 10%). Mais pour les méthodes et les champs non virtuels, la différence est énorme. Considérez ce programme.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace InterfaceFieldConsoleApplication
{
class Program
{
public abstract class A
{
public int Counter;
}
public interface IA
{
int Counter { get; set; }
}
public class B : A, IA
{
public new int Counter { get { return base.Counter; } set { base.Counter = value; } }
}
static void Main(string[] args)
{
var b = new B();
A a = b;
IA ia = b;
const long LoopCount = (int) (100*10e6);
var stopWatch = new Stopwatch();
stopWatch.Start();
for (int i = 0; i < LoopCount; i++)
a.Counter = i;
stopWatch.Stop();
Console.WriteLine("a.Counter: {0}", stopWatch.ElapsedMilliseconds);
stopWatch.Reset();
stopWatch.Start();
for (int i = 0; i < LoopCount; i++)
ia.Counter = i;
stopWatch.Stop();
Console.WriteLine("ia.Counter: {0}", stopWatch.ElapsedMilliseconds);
Console.ReadKey();
}
}
}
Production:
a.Counter: 1560
ia.Counter: 4587
Je pense que le cas de la fonction virtuelle pure peut utiliser une simple table de fonctions virtuelles, car toute classe dérivée de Foo
implémentant Bar
changerait simplement le pointeur de la fonction virtuelle en Bar
.
D'un autre côté, appeler une fonction d'interface IFoo: Bar n'a pas pu rechercher quelque chose comme la table des fonctions virtuelles de IFoo
, car chaque implémentation de IFoo
n'a pas besoin d'implémenter nécessairement d'autres fonctions ni les interfaces que Foo
fait. Ainsi, la position d'entrée de la table de fonctions virtuelles pour Bar
à partir d'un autre class Fubar: IFoo
ne doit pas correspondre à la position d'entrée de la table de fonctions virtuelles de Bar
dans class Foo:IFoo
.
Ainsi, un appel de fonction virtuelle pure peut s'appuyer sur le même index du pointeur de fonction à l'intérieur de la table de fonction virtuelle dans chaque classe dérivée, tandis que l'appel d'interface doit d'abord rechercher cet index.