Производительность «прямого» виртуального вызова по сравнению с интерфейсным вызовом в C # - PullRequest
59 голосов
/ 29 августа 2011

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

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

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-разрядной сборке).

The ...

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

.. печать ...

0027F778
0027F77C

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

Ответы [ 5 ]

25 голосов
/ 27 сентября 2011

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

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

IFoo f2 = GetAFoo();

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

19 голосов
/ 29 августа 2011

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

            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] 
11 голосов
/ 04 октября 2011

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

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

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

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

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

выпуск режим и x86 цель
прямой вызов: 23,02
через интерфейс: 32,73

режим отладки и режим x64 target
Прямой вызов: 49.49
Через интерфейс: 56.97

Режим и x64 target
Прямой вызов: 19.60
Через интерфейс: 26.45

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

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

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

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

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

2 голосов
/ 29 сентября 2013

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

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

Для виртуальных методов разница может быть незначительной (например, 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
1 голос
/ 04 октября 2011

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

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

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

...