С#: вызов виртуальной функции еще быстрее, чем вызов делегата?

Это просто происходит со мной по одному вопросу о дизайне кода. Скажем, у меня есть один метод "шаблон", который вызывает некоторые функции, которые могут "меняться". Интуитивно понятный дизайн должен следовать "Шаблон дизайна шаблона". Определите функции изменения как "виртуальные" функции, которые будут переопределены в подклассах. Или я могу просто использовать функции делегата без "виртуального". Функции делегата вводятся так, что их можно настроить также.

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

В приведенном ниже коде первый метод DoSomething следует за шаблоном шаблона. Он вызывает виртуальный метод IsTokenChar. Второй метод DoSomthing не зависит от виртуальной функции. Вместо этого у него есть делегат пропуска. На моем компьютере первый DoSomthing всегда быстрее второго. Результат подобен 1645: 1780.

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

Кто-нибудь может это объяснить?

using System;
using System.Diagnostics;

class Foo
{
    public virtual bool IsTokenChar(string word)
    {
        return String.IsNullOrEmpty(word);
    }

    // this is a template method
    public int DoSomething(string word)
    {
        int trueCount = 0;
        for (int i = 0; i < repeat; ++i)
        {
            if (IsTokenChar(word))
            {
                ++trueCount;
            }
        }
        return trueCount;
    }

    public int DoSomething(Predicate<string> predicator, string word)
    {
        int trueCount = 0;
        for (int i = 0; i < repeat; ++i)
        {
            if (predicator(word))
            {
                ++trueCount;
            }
        }
        return trueCount;
    }

    private int repeat = 200000000;
}

class Program
{
    static void Main(string[] args)
    {
        Foo f = new Foo();

        {
            Stopwatch sw = Stopwatch.StartNew();
            f.DoSomething(null);
            sw.Stop();
            Console.WriteLine(sw.ElapsedMilliseconds);
        }

        {
            Stopwatch sw = Stopwatch.StartNew();
            f.DoSomething(str => String.IsNullOrEmpty(str), null);
            sw.Stop();
            Console.WriteLine(sw.ElapsedMilliseconds);
        }
    }
}

Ответы

Ответ 1

Подумайте о том, что требуется в каждом случае:

Виртуальный вызов

  • Проверить недействительность
  • Перейдите от указателя объекта к указателю типа
  • Искать адрес метода в таблице команд
  • (Не уверен - даже Рихтер не охватывает это). Перейдите к базовому типу, если метод не переопределен? Recurse, пока мы не найдем правильный адрес метода. (Я так не думаю - см. Править внизу.)
  • Нажать исходный указатель объекта на стек ("this")
  • Метод вызова

Делегат-вызов

  • Проверить недействительность
  • Перейдите от указателя объекта к массиву invocations (все делегаты потенциально многоадресные)
  • Loop over array и для каждого вызова:
    • Адрес метода выборки
    • Определите, следует ли передавать цель в качестве первого аргумента
    • Push аргументы в стек (возможно, уже сделано - не уверен)
    • Необязательно (в зависимости от того, открыт или закрыт вызов) нажмите целевую ссылку на стек
    • Метод вызова

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

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

EDIT: я пробовал играть с глубиной иерархии наследования (до 20 уровней), точкой "самой производной переопределения" и объявленным типом переменной - и ни одна из них, похоже, не имеет значения.

EDIT: я только что попробовал оригинальную программу, используя интерфейс (который передается), который заканчивается тем же успехом, что и делегат.

Ответ 2

Просто хотел добавить несколько исправлений в ответ john skeet:

Виртуальный вызов метода не должен выполнять нулевую проверку (автоматически обрабатывается аппаратными ловушками).

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

Виртуальный вызов метода - это, по сути, один дополнительный уровень косвенности при вызове. Это медленнее обычного вызова из-за поиска таблицы и последующего вызова указателя функции.

Вызов делегата также предполагает дополнительный уровень косвенности.

Вызовы делегату не связаны с вводом аргументов в массив, если вы не выполняете динамический вызов с помощью метода DynamicInvoke.

Вызов делегата включает метод вызова, вызывающий метод Invoke, созданный компилятором, в вопросе типа делегата. Вызов предикатора (значения) превращается в предикат .Invoke(значение).

Метод Invoke, в свою очередь, реализуется JIT для вызова указателя (ов) функции (хранится внутри объекта делегата).

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

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

Разница может быть связана с необходимостью дополнительных проверок + ветвей из-за многоадресной рассылки (как предложил Джон). Другая причина может заключаться в том, что компилятор JIT не встраивает метод Delegate.Invoke и реализацию Delegate.Invoke не обрабатывает аргументы, а также реализацию при выполнении вызовов виртуальных методов.

Ответ 3

Виртуальный вызов разыменовывает два указателя с хорошо известным смещением в памяти. Это не фактически динамическое связывание; во время выполнения кода нет кода для отражения метаданных, чтобы обнаружить правильный метод. Компилятор генерирует пару инструкций для выполнения вызова на основе этого указателя. фактически виртуальный вызов является одной командой IL.

Предикатный вызов создает анонимный класс для инкапсуляции предиката. Этот класс должен быть создан, и есть некоторый код, с помощью которого можно проверить, является ли указатель функции предиката нулевым или нет.

Я бы предложил вам посмотреть на конструкции IL для обоих. Скомпилируйте упрощенную версию вашего источника с помощью одного вызова для каждого из двух DoSomthing. Затем используйте ILDASM, чтобы узнать, каков фактический код для каждого шаблона.

(И я уверен, что я заберусь за то, что не использовал правильную терминологию: -))

Ответ 5

Возможно, так как у вас нет методов, которые переопределяют виртуальный метод, который JIT может распознать и вместо этого использовать прямой вызов.

Для чего-то вроде этого, как правило, лучше проверить его, как вы это сделали, чем попытаться угадать, какова будет производительность. Если вы хотите узнать больше о том, как работает вызов делегата, я предлагаю отличную книгу "CLR Via С#" Джеффри Рихтера.

Ответ 6

Я сомневаюсь, что это объясняет всю вашу разницу, но одна вещь с моей головы, которая может объяснить некоторые различия, заключается в том, что у диспетчера виртуальных методов уже есть указатель this, готовый к работе. При вызове делегата из делегата должен быть извлечен указатель this.

Обратите внимание, что в соответствии с в этой статье в блоге разница была еще больше в .NET v1.x.

Ответ 7

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

Делегаты являются динамическими, которые всегда будут иметь накладные расходы, и они, похоже, тоже являются объектами, что добавляет.

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