Давайте вспомним, что такое call и callvirt в IL. Call - это прямой вызов метода, без определения его адреса в рантайме. Он несколько быстрее, чем Callvirt, который требует рантайма, чтобы определить адрес вызываемого метода. И не важно, объявлен ли метод как virtual. Callvirt и ключевое слово virtual это разные понятия: одно из языка, а другое из реализации работы платформы.
Как определить, что будет Call? Тут очень просто. Call будет только в том случае, если метод статический или если работаем со структурой (кроме случаев boxing'a, например, обращения через интерфейс). Ещё есть изменение в .NET 7, которое позволяет не делать callvirt в случаях sealed классов - тоже рабочая схема.
Про статические абстрактные методы в интерфейсах не спрашивайте, не знаю как работает.
Во всех остальных случаях будет будет Callvirt:
- Вызывается виртуальный или абстрактный метод.
- Вызывается метод интерфеса (со своими нюансами, например, если имплементация всего одна, то будет быстрее).
- Вызывается переопределённый метод.
Собственно, именно поэтому я часто использую структуры - чтобы повысить производительность. Иногда удачно, иногда - нет.
Прочитать достаточно старые статьи можно вот тут и тут. Прочитать короткое, но свежее, обсуждение можно вот тут. Ну, а моя заметка появилась благодаря вот этому вопросу.
Мой канал в TG: https://t.me/csharp_gepard