JIT в .NET 11 девиртуализирует обобщённые виртуальные методы, и вместе с ними исчезает выделение памяти
В статье Стивена Тоуба Performance Improvements in .NET 11 вызовы обобщённых виртуальных методов падают с 6.7 нс и 24 байт до 1.8 нс и нуля выделений. Три пул-реквеста в RyuJIT открыли инлайнинг для самой непрозрачной диспетчеризации в .NET.
Стивен Тоуб опубликовал “Performance Improvements in .NET 11” 2026-09-15, и в разделе про деабстракцию спрятано изменение, которое было заблокировано много лет: RyuJIT теперь умеет девиртуализировать обобщённые виртуальные методы.
GVM долгое время были самой медленной формой диспетчеризации в .NET, и причина тут структурная. У обычного виртуального метода есть слот в vtable, поэтому компилятор знает, куда смотреть. У int SizeOf<T>(T value), объявленного в интерфейсе, единого слота нет, потому что каждая инстанциация - это отдельное тело метода, определяемое аргументами типа. Разрешение такого вызова требует поиска во время выполнения, и для JIT результат остаётся непрозрачным указателем на функцию. Непрозрачный означает отсутствие инлайнинга, а без инлайнинга анализ побегов никогда не заглядывает внутрь вызова.
Бенчмарк из статьи
[Benchmark]
public int NonShared() => ((IProcessor)new Processor()).SizeOf(42);
[Benchmark]
public int Shared() => ((IProcessor)new Processor()).SizeOf("hello");
private interface IProcessor
{
int SizeOf<T>(T value);
}
private sealed class Processor : IProcessor
{
public int SizeOf<T>(T value) => Unsafe.SizeOf<T>();
}
NonShared инстанциируется по int, поэтому среда выполнения компилирует отдельное тело. Shared инстанциируется по string и использует общее тело для ссылочных типов, из-за чего через вызов нужно протащить аргумент обобщённого контекста. Оба варианта были медленными:
| Метод | Runtime | Среднее | Ratio | Выделено |
|---|---|---|---|---|
| NonShared | .NET 10.0 | 6.678 ns | 1.00 | 24 B |
| NonShared | .NET 11.0 | 1.764 ns | 0.26 | 0 B |
| Shared | .NET 10.0 | 7.166 ns | 1.00 | 24 B |
| Shared | .NET 11.0 | 1.764 ns | 0.25 | 0 B |
Три пул-реквеста по порядку
dotnet/runtime#120866 появился первым, в ноябре 2025 года, и именно он снял блокировку. Раньше JIT сбрасывал цель вызова ldvirtftn во временную переменную до подготовки аргументов, и этого хватало, чтобы диспетчеризация оставалась непрозрачной на всём оставшемся конвейере. Убрав этот сброс, вычисление цели удалось поднять выше вычисления аргументов там, где это допустимо.
dotnet/runtime#122023 затем научил JIT девиртуализировать необщие GVM, пронося с собой обобщённый контекст, необходимый вызову, чтобы косвенная диспетчеризация превратилась в прямой вызов, пригодный для инлайнинга. dotnet/runtime#128702 расширил это на общие GVM и на реализации интерфейсов по умолчанию, которым нужны инстанциирующие заглушки, поэтому строка Shared укладывается в те же 1.764 ns, что и NonShared.
Почему исчезают 24 байта
Выделение памяти никогда не было целью вызова. new Processor() существует только для того, чтобы у приведения к интерфейсу был получатель. В .NET 10 непрозрачный вызов заставлял JIT считать, что получатель убегает, поэтому Processor попадал в кучу: 24 байта на каждый вызов.
Как только вызов становится встроенным, анализ побегов доказывает, что объект не покидает кадр. Экземпляр размещается на стеке, никто его не читает, и он исчезает целиком. Unsafe.SizeOf<T>() в том же проходе сворачивается в константу. Ускорение в 3.8 раза реально, но именно ноль в колонке выделенной памяти заметен в сервисе с высокой нагрузкой на сборку мусора.
Одна оговорка: JIT должен знать точный тип получателя в точке вызова, как здесь у локально созданного sealed типа. Для по-настоящему полиморфной точки вызова вы по-прежнему полагаетесь на защищённую девиртуализацию из динамического PGO, которая даёт проверку типа плюс встроенный быстрый путь, а не прямой вызов.
Если вы пишете интерфейсы посетителей, обобщённые хуки сериализаторов или любую абстракцию, где параметр типа принадлежит методу, а не типу, это то изменение в .NET 11, которое стоит измерить на собственном коде. В полной статье приведён дизассемблированный код.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.