Выполнение "прямого" вызова виртуального вызова или интерфейса в С#

Этот тест показывает, что вызов виртуального метода непосредственно по ссылке на объект быстрее, чем вызов его на ссылку на интерфейс, этот объект реализует.

Другими словами:

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();    
}

Исходя из мира С++, я ожидал, что оба эти вызова будут реализованы идентично (как простой поиск виртуальной таблицы) и будут иметь одинаковую производительность. Как С# реализует виртуальные вызовы и что это за "лишняя" работа, которая, по-видимому, выполняется при вызове через интерфейс?

--- EDIT ---

ОК, ответы/комментарии, которые я получил до сих пор, подразумевают, что существует двойной разворот указателя для виртуального вызова через интерфейс по сравнению с одним разыменованием для виртуального вызова через объект.

Так может понравиться кто-нибудь объяснить , почему это необходимо? Какова структура виртуальной таблицы в С#? Является ли он "плоским" (как это типично для С++) или нет? Каковы были компромиссы в дизайне, которые были сделаны на языке С#, что привело к этому? Я не говорю, что это "плохой" дизайн, мне просто интересно, почему это было необходимо.

В двух словах я хотел бы понять, что мой инструмент делает под капотом, поэтому я могу использовать его более эффективно. И я был бы признателен, если бы я больше не получал ответы типа "вы не должны знать этого" или "использовать другой язык".

--- EDIT 2 ---

Просто, чтобы было ясно, что мы не имеем дело с каким-то компилятором оптимизации JIT, который удаляет динамическую отправку: я изменил критерий, упомянутый в исходном вопросе, чтобы создать экземпляр одного класса или другого случайного во время выполнения. Поскольку создание экземпляра происходит после компиляции и после сборки /JITing сборки, нет возможности избежать динамической отправки в обоих случаях:

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

Если кому-то интересно, вот как мой Visual С++ 2010 выдает экземпляр класса, который наследует другие классы:

код:

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;
    }
};

Debugger:

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)   *

Несколько указателей виртуальных таблиц хорошо видны и sizeof(C) == 8 (в 32-битной сборке).

...

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

.. печатает...

0027F778
0027F77C

... указывая, что указатели на разные интерфейсы внутри одного и того же объекта фактически указывают на разные части этого объекта (т.е. содержат разные физические адреса).

Ответ 1

Я думаю, что статья на http://msdn.microsoft.com/en-us/magazine/cc163791.aspx ответит на ваши вопросы. В частности, см. Раздел Карта интерфейса Vtable и Карта интерфейса, а также следующий раздел, посвященный виртуальной диспетчеризации.

Вероятно, JIT-компилятор может разобраться и оптимизировать код для вашего простого случая. Но не в общем случае.

IFoo f2 = GetAFoo();

А GetAFoo определен как возвращающий IFoo, тогда JIT-компилятор не сможет оптимизировать вызов.

Ответ 2

Вот что выглядит диссображение (Ганс прав):

            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] 

Ответ 3

Я пробовал ваш тест и на моей машине, в определенном контексте, результат на самом деле наоборот.

Я запускаю Windows 7 x64, и я создал проект приложения Visual Studio 2010 Console Application, в который я скопировал ваш код. Если скомпилировать проект в режиме отладки и с целевой платформой как x86, вывод будет следующим:

Прямой вызов: 48.38
Через интерфейс: 42.43

Собственно каждый раз при запуске приложения он будет давать несколько разные результаты, но вызовы интерфейса всегда будут быстрее. Я предполагаю, что, поскольку приложение скомпилировано как x86, оно будет запускаться ОС через WOW.

Для полной справки ниже приведены результаты для остальных конфигураций компиляции и комбинаций целей.

Отпустите и x86. Прямой вызов: 23.02
Через интерфейс: 32.73

Отладка и x64 цель
Прямой звонок: 49,49
Через интерфейс: 56.97

Отпустите и x64. Прямой вызов: 19.60
Через интерфейс: 26.45

Все вышеуказанные тесты были выполнены с .Net 4.0 в качестве целевой платформы для компилятора. При переключении на 3.5 и повторении вышеуказанных тестов вызовы через интерфейс всегда были длиннее прямых вызовов.

Итак, вышеупомянутые тесты довольно усложняют ситуацию, поскольку кажется, что поведение, которое вы заметили, не всегда происходит.

В конце концов, с риском опрокинуть вас, я хотел бы добавить несколько мыслей. Многие люди добавили комментарии о том, что различия в производительности довольно малы, а в реальном программировании вы не должны заботиться о них, и я согласен с этой точкой зрения. Для этого есть две основные причины.

Первый и наиболее рекламируемый - это то, что .NET был построен на более высоком уровне, чтобы разработчики могли сосредоточиться на более высоких уровнях приложений. База данных или внешний вызов службы - это тысячи, а иногда и миллионы раз медленнее, чем вызов виртуального метода. Наличие хорошей архитектуры высокого уровня и сосредоточение внимания на потребителях с высокой производительностью всегда будут приносить лучшие результаты в современных приложениях, а не избегать двойных указателей.

Вторым и более неясным является то, что команда .Net, построив фреймворк на более высоком уровне, фактически внедрила серию уровней абстракции, которые компилятор только во времени мог бы использовать для оптимизации на разных платформах. Чем больше доступа они получат под слоями, тем больше разработчиков смогут оптимизировать для конкретной платформы, но тем меньше компилятор времени выполнения сможет сделать для других. По крайней мере, это теория, и поэтому в документе не так хорошо документировано, как в С++ относительно этого конкретного вопроса.

Ответ 4

Я думаю, что пример чистой виртуальной функции может использовать простую таблицу виртуальных функций, так как любой производный класс Foo, реализующий Bar, просто изменит указатель виртуальной функции на Bar.

С другой стороны, вызов функции интерфейса IFoo: Bar не мог выполнить поиск в виде таблицы виртуальных функций IFoo, потому что для каждой реализации IFoo необязательно выполнять другие функции или интерфейсы, которые Foo делает. Таким образом, позиция ввода таблицы виртуальной функции для Bar из другого class Fubar: IFoo не должна соответствовать позиции входа таблицы виртуальной функции Bar в class Foo:IFoo.

Таким образом, вызов чистой виртуальной функции может полагаться на один и тот же индекс указателя функции внутри таблицы виртуальных функций в каждом производном классе, тогда как вызов интерфейса должен сначала искать этот индекс.

Ответ 5

Общее правило: Классы бывают быстрыми. Интерфейсы медленные.

Это одна из причин рекомендации "Составить иерархии с классами и использовать интерфейсы для поведения внутри иерархии".

Для виртуальных методов разница может быть небольшой (например, 10%). Но для не виртуальных методов и полей разница огромна. Рассмотрим эту программу.

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();
        }
    }
}

Вывод:

a.Counter: 1560
ia.Counter: 4587