67 votes

Performances de l'appel virtuel "direct" par rapport à l'appel d'interface en C#

Ce point de référence semble montrer que l'appel d'une méthode virtuelle directement sur une 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 du C++, je me serais attendu à ce que ces deux appels soient implémentés de manière identique (comme une simple consultation de table virtuelle) et aient les mêmes performances. Comment le C# implémente-t-il les appels virtuels et quel est ce travail "supplémentaire" qui est apparemment effectué lors d'un appel via une interface ?

--- EDIT ---

OK, les réponses/commentaires que j'ai reçus jusqu'à présent impliquent qu'il y a une déréférence double-pointeur pour l'appel virtuel par l'interface contre une seule déréférence pour l'appel virtuel par l'objet.

Quelqu'un pourrait-il m'expliquer pourquoi est-ce nécessaire ? Quelle est la structure de la table virtuelle en C# ? Est-elle "plate" (comme c'est le cas en C++) ou non ? Quels sont les compromis de conception qui ont été faits dans la conception du langage C# et qui ont conduit à cette situation ? Je ne dis pas que c'est une "mauvaise" conception, je suis simplement curieux de savoir pourquoi elle était nécessaire.

En bref, j'aimerais comprendre ce que fait mon outil sous le capot pour que je puisse l'utiliser plus efficacement. Et j'apprécierais de ne plus recevoir de réponses du type "vous ne devriez pas savoir ça" ou "utilisez une autre langue".

--- EDIT 2 ---

Pour que les choses soient claires, nous n'avons pas affaire ici à un compilateur d'optimisation JIT qui supprime la répartition dynamique : J'ai modifié le benchmark mentionné dans la question originale pour instancier une classe ou l'autre de façon aléatoire au moment de l'exécution. Puisque l'instanciation se produit après la compilation et après le chargement de l'assemblage/JIT, il n'y a aucun moyen d'éviter le dynamic dispatch 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

    }

}

--- EDIT 3 ---

Si cela intéresse quelqu'un, voici comment mon Visual C++ 2010 présente une instance d'une classe qui multiplie les héritages d'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)   *

Les pointeurs de tables virtuelles multiples sont clairement visibles, et sizeof(C) == 8 (dans la version 32 bits).

Le...

C c;
std::cout << static_cast<IA*>(&c) << std::endl;
std::cout << static_cast<IB*>(&c) << std::endl;

..tirages...

0027F778
0027F77C

...indiquant que les pointeurs vers différentes interfaces au sein d'un même objet pointent en fait vers différentes parties de cet objet (c'est-à-dire qu'ils contiennent différentes adresses physiques).

28voto

Jim Mischel Points 68586

Je pense que l'article Entrez dans les rouages de .NET Framework pour voir comment le CLR crée les objets d'exécution. répondra à vos questions. En particulier, voir la section * Carte de table virtuelle d'interface et carte d'interface -, et la section suivante sur la répartition virtuelle.

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 le compilateur JIT ne serait pas en mesure d'optimiser l'appel.

21voto

Steve Wellens Points 14348

Voici à quoi ressemble le démontage (Hans est correct) :

            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]

12voto

Florin Dumitrescu Points 3501

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 fichier d'aide aux utilisateurs. Visual Studio 2010 Application Console dans lequel j'ai copié votre code. Si je compile le projet dans Mode débogage et avec la cible de la plate-forme comme x86 le résultat sera le suivant :

Appel direct : 48.38 Par interface : 42.43

En fait, chaque fois que l'on exécute l'application, les résultats sont légèrement différents, mais les appels à l'interface sont toujours plus rapides. Je suppose que puisque l'application est compilée en x86, elle sera exécutée par le système d'exploitation par l'intermédiaire de WoW .

Pour une référence complète, voici les résultats pour le reste des combinaisons de configurations de compilation et de cibles.

Communiqué de presse et x86 cible
Appel direct : 23.02
Par l'interface : 32.73

Déboguer et x64 cible
Appel direct : 49.49
Par l'interface : 56.97

Communiqué de presse et x64 cible
Appel direct : 19.60
Par l'interface : 26,45

Tous les tests ci-dessus ont été effectués avec .NET 4.0 comme plate-forme cible pour le compilateur. En passant à la version 3.5 et en répétant les tests ci-dessus, les appels via l'interface étaient toujours plus longs que les appels directs.

Les tests ci-dessus compliquent donc un peu les choses puisqu'il semble que le comportement que vous avez repéré ne se produit pas toujours.

Pour finir, au risque de vous contrarier, je voudrais ajouter quelques réflexions. De nombreuses personnes ont ajouté des commentaires indiquant que les différences de performances sont assez faibles et que, dans le monde réel de la programmation, 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 connu est que .NET a été conçu à un niveau supérieur afin de permettre aux développeurs de se concentrer sur les niveaux supérieurs des applications. Une base de données ou un appel à un service externe est des milliers, voire des millions de fois plus lent qu'un appel à une méthode virtuelle. Disposer d'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érencements de double-pointeurs.

La seconde, plus obscure, est que l'équipe .NET, en construisant le cadre à un niveau plus élevé, a en fait introduit une série de niveaux d'abstraction que le compilateur juste à temps serait en mesure d'utiliser pour des optimisations sur différentes plateformes. Plus ils donneraient accès aux sous-couches, plus les développeurs seraient capables d'optimiser pour une plateforme spécifique, mais moins le compilateur d'exécution serait capable 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++ en ce qui concerne cette question particulière.

4voto

Johan Nilsson Points 158

La règle générale est la suivante : Les classes 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 le comportement intra-hiérarchie".

Pour les méthodes virtuelles, la différence peut être faible (de l'ordre de 10 %). Mais pour les méthodes et 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();
        }
    }
}

Sortie :

a.Counter: 1560
ia.Counter: 4587

1voto

dronus Points 1925

Je pense que le cas des fonctions virtuelles pures peut utiliser une simple table de fonctions virtuelles, comme toute classe dérivée de Foo mise en œuvre de Bar changerait simplement le pointeur de la fonction virtuelle en Bar .

D'autre part, l'appel d'une fonction d'interface IFoo:Bar ne pourrait pas faire une recherche sur quelque chose comme IFoo parce que toutes les implémentations de la fonction IFoo n'a pas besoin d'implémenter nécessairement d'autres fonctions ou interfaces qui Foo fait. Ainsi, la position d'entrée de la table des fonctions virtuelles pour Bar d'un autre class Fubar: IFoo ne doit pas correspondre à la position d'entrée de la table des fonctions virtuelles de Bar en class Foo:IFoo .

Ainsi, un appel de fonction virtuelle pure peut s'appuyer sur le même index du pointeur de fonction dans la table des fonctions virtuelles de chaque classe dérivée, alors que l'appel d'interface doit d'abord rechercher cet index.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X