Просто хотел добавить несколько исправлений в ответ Джона Скита:
При вызове виртуального метода не требуется выполнять проверку на ноль (автоматически обрабатывается аппаратными прерываниями).
Также не нужно проходить по цепочке наследования, чтобы найти не переопределенные методы (для этого и нужна таблица виртуальных методов).
Вызов виртуального метода - это, по сути, один дополнительный уровень косвенности при вызове. Это медленнее, чем обычный вызов из-за поиска в таблице и последующего вызова указателя функции.
Вызов делегата также включает дополнительный уровень косвенности.
Вызовы делегату не предполагают помещения аргументов в массив, если вы не выполняете динамический вызов с использованием метода DynamicInvoke.
Вызов делегата включает вызывающий метод, вызывающий сгенерированный компилятором метод Invoke для рассматриваемого типа делегата. Вызов предикатора (значение) превращается в предикатор. Invoke (значение).
Метод Invoke, в свою очередь, реализуется JIT для вызова указателя (ей) функции (хранится внутри объекта делегата).
В вашем примере передаваемый вами делегат должен был быть реализован как статический метод, сгенерированный компилятором, поскольку реализация не обращается к переменным или локальным переменным экземпляра, поэтому необходимость обращения к указателю "this" из кучи не должна выпуск.
Разница в производительности между вызовами делегатов и виртуальных функций должна быть в основном одинаковой, и ваши тесты производительности показывают, что они очень близки.
Разница может быть связана с необходимостью дополнительных проверок + ветвей из-за многоадресной рассылки (как предложил Джон). Другой причиной может быть то, что JIT-компилятор не встроен в метод Delegate.Invoke, а реализация Delegate.Invoke не обрабатывает аргументы, а также реализацию при выполнении вызовов виртуальных методов.