Производительность "прямого" виртуального вызова по сравнению с интерфейсным вызовом в C#

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

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

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

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

--- РЕДАКТИРОВАТЬ ---

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

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

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

--- РЕДАКТИРОВАТЬ 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

    }

}

--- РЕДАКТИРОВАТЬ 3 ---

Если кому-то интересно, вот как Visual C++ 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;

..prints...

0027F778
0027F77C

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

5 ответов

Решение

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

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

IFoo f2 = GetAFoo();

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

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

            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] 

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

Я работаю под управлением Windows 7 x64 и создал проект консольного приложения Visual Studio 2010, в который скопировал ваш код. Если проект скомпилирован в режиме отладки с целевой платформой 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, создавая структуру на более высоком уровне, фактически представила серию уровней абстракции, которые компилятор точно в срок сможет использовать для оптимизации на разных платформах. Чем больше доступа они получат к нижним уровням, тем больше разработчиков смогут оптимизировать под конкретную платформу, но тем меньше компилятор времени выполнения сможет сделать для других. Это, по крайней мере, теория, и именно поэтому вещи не так хорошо документированы, как в C++, в отношении этого конкретного вопроса.

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

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

Для виртуальных методов разница может быть небольшой (например, 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

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

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

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

Другие вопросы по тегам