Start Debugging

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.06.678 ns1.0024 B
NonShared.NET 11.01.764 ns0.260 B
Shared.NET 10.07.166 ns1.0024 B
Shared.NET 11.01.764 ns0.250 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.

< Назад