Действительно ли закрытые классы дают преимущества в производительности?

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

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

Кто-нибудь запускал тесты и видел разницу?

Помогите мне узнать :)

Ответов (11)

Решение

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

Существуют сложные правила относительно типа вызова, виртуальный / невиртуальный, и я не знаю их всех, поэтому я не могу описать их для вас, но если вы используете Google для запечатанных классов и виртуальных методов, вы можете найти несколько статей по этой теме.

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

Вот одна ссылка, в которой упоминается об этом: бессвязная игра по ключевому слову sealed.

Обновление. Начиная с .NET Core 2.0 и .NET Desktop 4.7.1, среда CLR теперь поддерживает девиртуализацию. Он может принимать методы в запечатанных классах и заменять виртуальные вызовы прямыми вызовами - и он также может делать это для незапечатанных классов, если может понять, что это безопасно.

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

Тем не менее, я бы не подумал, что об этом стоит беспокоиться, если вы уже не профилировали код и не определили, что вы находитесь на особенно горячем пути, который вызывается миллионы раз, или что-то в этом роде:

https://blogs.msdn.microsoft.com/dotnet/2017/06/29/performance-improvements-in-ryujit-in-net-core-and-net-framework/


Оригинальный ответ:

Я создал следующую тестовую программу, а затем декомпилировал ее с помощью Reflector, чтобы посмотреть, какой код MSIL был выдан.

public class NormalClass {
    public void WriteIt(string x) {
        Console.WriteLine("NormalClass");
        Console.WriteLine(x);
    }
}

public sealed class SealedClass {
    public void WriteIt(string x) {
        Console.WriteLine("SealedClass");
        Console.WriteLine(x);
    }
}

public static void CallNormal() {
    var n = new NormalClass();
    n.WriteIt("a string");
}

public static void CallSealed() {
    var n = new SealedClass();
    n.WriteIt("a string");
}

Во всех случаях компилятор C# (Visual Studio 2010 в конфигурации сборки Release) выдает идентичный MSIL, который выглядит следующим образом:

L_0000: newobj instance void <NormalClass or SealedClass>::.ctor()
L_0005: stloc.0 
L_0006: ldloc.0 
L_0007: ldstr "a string"
L_000c: callvirt instance void <NormalClass or SealedClass>::WriteIt(string)
L_0011: ret 

Часто цитируемая причина, по которой люди говорят, что герметичность обеспечивает преимущества в производительности, заключается в том, что компилятор знает, что класс не переопределяется, и поэтому может использовать call вместо этого, callvirt поскольку ему не нужно проверять виртуальные объекты и т. Д. Как показано выше, это не правда.

Моя следующая мысль заключалась в том, что, хотя MSIL идентичен, возможно, компилятор JIT по-разному обрабатывает запечатанные классы?

Я запустил сборку релиза в отладчике Visual Studio и просмотрел декомпилированный вывод x86. В обоих случаях код x86 был идентичен, за исключением имен классов и адресов памяти функций (которые, конечно, должны быть разными). Вот

//            var n = new NormalClass();
00000000  push        ebp 
00000001  mov         ebp,esp 
00000003  sub         esp,8 
00000006  cmp         dword ptr ds:[00585314h],0 
0000000d  je          00000014 
0000000f  call        70032C33 
00000014  xor         edx,edx 
00000016  mov         dword ptr [ebp-4],edx 
00000019  mov         ecx,588230h 
0000001e  call        FFEEEBC0 
00000023  mov         dword ptr [ebp-8],eax 
00000026  mov         ecx,dword ptr [ebp-8] 
00000029  call        dword ptr ds:[00588260h] 
0000002f  mov         eax,dword ptr [ebp-8] 
00000032  mov         dword ptr [ebp-4],eax 
//            n.WriteIt("a string");
00000035  mov         edx,dword ptr ds:[033220DCh] 
0000003b  mov         ecx,dword ptr [ebp-4] 
0000003e  cmp         dword ptr [ecx],ecx 
00000040  call        dword ptr ds:[0058827Ch] 
//        }
00000046  nop 
00000047  mov         esp,ebp 
00000049  pop         ebp 
0000004a  ret 

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

Затем я запустил автономный исполняемый файл сборки выпуска вне любых сред отладки и использовал WinDBG + SOS для взлома после завершения программы и просмотра разборки JIT-скомпилированного кода x86.

Как видно из приведенного ниже кода, при работе вне отладчика JIT-компилятор более агрессивен и встроил WriteIt метод прямо в вызывающую программу. Однако важно то, что он был идентичен при вызове класса sealed vs non-sealed. Нет никакой разницы между закрытым и незапечатанным классом.

Вот это при вызове обычного класса:

Normal JIT generated code
Begin 003c00b0, size 39
003c00b0 55              push    ebp
003c00b1 8bec            mov     ebp,esp
003c00b3 b994391800      mov     ecx,183994h (MT: ScratchConsoleApplicationFX4.NormalClass)
003c00b8 e8631fdbff      call    00172020 (JitHelp: CORINFO_HELP_NEWSFAST)
003c00bd e80e70106f      call    mscorlib_ni+0x2570d0 (6f4c70d0) (System.Console.get_Out(), mdToken: 060008fd)
003c00c2 8bc8            mov     ecx,eax
003c00c4 8b1530203003    mov     edx,dword ptr ds:[3302030h] ("NormalClass")
003c00ca 8b01            mov     eax,dword ptr [ecx]
003c00cc 8b403c          mov     eax,dword ptr [eax+3Ch]
003c00cf ff5010          call    dword ptr [eax+10h]
003c00d2 e8f96f106f      call    mscorlib_ni+0x2570d0 (6f4c70d0) (System.Console.get_Out(), mdToken: 060008fd)
003c00d7 8bc8            mov     ecx,eax
003c00d9 8b1534203003    mov     edx,dword ptr ds:[3302034h] ("a string")
003c00df 8b01            mov     eax,dword ptr [ecx]
003c00e1 8b403c          mov     eax,dword ptr [eax+3Ch]
003c00e4 ff5010          call    dword ptr [eax+10h]
003c00e7 5d              pop     ebp
003c00e8 c3              ret

Против запечатанного класса:

Normal JIT generated code
Begin 003c0100, size 39
003c0100 55              push    ebp
003c0101 8bec            mov     ebp,esp
003c0103 b90c3a1800      mov     ecx,183A0Ch (MT: ScratchConsoleApplicationFX4.SealedClass)
003c0108 e8131fdbff      call    00172020 (JitHelp: CORINFO_HELP_NEWSFAST)
003c010d e8be6f106f      call    mscorlib_ni+0x2570d0 (6f4c70d0) (System.Console.get_Out(), mdToken: 060008fd)
003c0112 8bc8            mov     ecx,eax
003c0114 8b1538203003    mov     edx,dword ptr ds:[3302038h] ("SealedClass")
003c011a 8b01            mov     eax,dword ptr [ecx]
003c011c 8b403c          mov     eax,dword ptr [eax+3Ch]
003c011f ff5010          call    dword ptr [eax+10h]
003c0122 e8a96f106f      call    mscorlib_ni+0x2570d0 (6f4c70d0) (System.Console.get_Out(), mdToken: 060008fd)
003c0127 8bc8            mov     ecx,eax
003c0129 8b1534203003    mov     edx,dword ptr ds:[3302034h] ("a string")
003c012f 8b01            mov     eax,dword ptr [ecx]
003c0131 8b403c          mov     eax,dword ptr [eax+3Ch]
003c0134 ff5010          call    dword ptr [eax+10h]
003c0137 5d              pop     ebp
003c0138 c3              ret

Для меня это убедительное доказательство того, что не может быть никакого улучшения производительности между вызовами методов в запечатанных и незапечатанных классах ... Думаю, теперь я счастлив :-)

Ответ - нет, запечатанные классы не работают лучше, чем незапечатанные.

Проблема сводится к кодам call операций vs callvirt IL. Call быстрее, чем callvirt, и callvirt в основном используется, когда вы не знаете, был ли объект подклассифицирован. Поэтому люди предполагают, что если вы запечатаете класс, все коды операций изменятся с calvirts на calls и будут быстрее.

К сожалению, он callvirt делает и другие вещи, которые делают его полезным, например, проверку на пустые ссылки. Это означает, что даже если класс запечатан, ссылка все равно может быть нулевой и, следовательно, callvirt необходим a . Вы можете обойти это (без необходимости запечатывать класс), но это становится бессмысленным.

Структуры используются, call потому что они не могут быть разделены на подклассы и никогда не имеют значения NULL.

См. Этот вопрос для получения дополнительной информации:

Звоните и звоните

Насколько я знаю, нет никаких гарантий повышения производительности. Но есть шанс уменьшить потери производительности при определенных условиях с помощью герметичного метода. (запечатанный класс делает все методы запечатанными.)

Но это зависит от реализации компилятора и среды выполнения.


Подробности

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

Существует большое препятствие, называемое динамической отправкой, которое нарушает эту оптимизацию «предварительной выборки». Вы можете понять это как условное ветвление.

// Value of `v` is unknown,
// and can be resolved only at runtime.
// CPU cannot know which code to prefetch.
// Therefore, just prefetch any one of a() or b().
// This is *speculative execution*.
int v = random();
if (v==1) a();
else b();

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

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

Некоторые процессоры (включая новейшие чипы Intel x86) используют технику, называемую спекулятивным исполнением, для использования конвейера даже в конкретной ситуации. Просто выберите один из путей выполнения. Но результативность этой техники не так уж и высока. А сбой из-за предположений вызывает остановку конвейера, что также приводит к огромному снижению производительности. (это полностью за счет реализации ЦП. Известно, что некоторые мобильные ЦП не используют такого рода оптимизацию для экономии энергии)

По сути, C# - это статически компилируемый язык. Но не всегда. Я не знаю точного условия, и это полностью зависит от реализации компилятора. Некоторые компиляторы могут исключить возможность динамической отправки, предотвращая переопределение метода, если метод помечен как sealed . Глупые компиляторы не могут. Это преимущество в производительности sealed .


Этот ответ ( Почему обрабатывать отсортированный массив быстрее, чем несортированный? ) Намного лучше описывает предсказание ветвления.

Чтобы действительно увидеть их, вам нужно проанализировать код e, скомпилированный JIT (последний).

Код C#

public sealed class Sealed
{
    public string Message { get; set; }
    public void DoStuff() { }
}
public class Derived : Base
{
    public sealed override void DoStuff() { }
}
public class Base
{
    public string Message { get; set; }
    public virtual void DoStuff() { }
}
static void Main()
{
    Sealed sealedClass = new Sealed();
    sealedClass.DoStuff();
    Derived derivedClass = new Derived();
    derivedClass.DoStuff();
    Base BaseClass = new Base();
    BaseClass.DoStuff();
}

Код MIL

.method private hidebysig static void  Main() cil managed
{
  .entrypoint
  // Code size       41 (0x29)
  .maxstack  8
  IL_0000:  newobj     instance void ConsoleApp1.Program/Sealed::.ctor()
  IL_0005:  callvirt   instance void ConsoleApp1.Program/Sealed::DoStuff()
  IL_000a:  newobj     instance void ConsoleApp1.Program/Derived::.ctor()
  IL_000f:  callvirt   instance void ConsoleApp1.Program/Base::DoStuff()
  IL_0014:  newobj     instance void ConsoleApp1.Program/Base::.ctor()
  IL_0019:  callvirt   instance void ConsoleApp1.Program/Base::DoStuff()
  IL_0028:  ret
} // end of method Program::Main

JIT-скомпилированный код

--- C:\Users\Ivan Porta\source\repos\ConsoleApp1\Program.cs --------------------
        {
0066084A  in          al,dx  
0066084B  push        edi  
0066084C  push        esi  
0066084D  push        ebx  
0066084E  sub         esp,4Ch  
00660851  lea         edi,[ebp-58h]  
00660854  mov         ecx,13h  
00660859  xor         eax,eax  
0066085B  rep stos    dword ptr es:[edi]  
0066085D  cmp         dword ptr ds:[5842F0h],0  
00660864  je          0066086B  
00660866  call        744CFAD0  
0066086B  xor         edx,edx  
0066086D  mov         dword ptr [ebp-3Ch],edx  
00660870  xor         edx,edx  
00660872  mov         dword ptr [ebp-48h],edx  
00660875  xor         edx,edx  
00660877  mov         dword ptr [ebp-44h],edx  
0066087A  xor         edx,edx  
0066087C  mov         dword ptr [ebp-40h],edx  
0066087F  nop  
            Sealed sealedClass = new Sealed();
00660880  mov         ecx,584E1Ch  
00660885  call        005730F4  
0066088A  mov         dword ptr [ebp-4Ch],eax  
0066088D  mov         ecx,dword ptr [ebp-4Ch]  
00660890  call        00660468  
00660895  mov         eax,dword ptr [ebp-4Ch]  
00660898  mov         dword ptr [ebp-3Ch],eax  
            sealedClass.DoStuff();
0066089B  mov         ecx,dword ptr [ebp-3Ch]  
0066089E  cmp         dword ptr [ecx],ecx  
006608A0  call        00660460  
006608A5  nop  
            Derived derivedClass = new Derived();
006608A6  mov         ecx,584F3Ch  
006608AB  call        005730F4  
006608B0  mov         dword ptr [ebp-50h],eax  
006608B3  mov         ecx,dword ptr [ebp-50h]  
006608B6  call        006604A8  
006608BB  mov         eax,dword ptr [ebp-50h]  
006608BE  mov         dword ptr [ebp-40h],eax  
            derivedClass.DoStuff();
006608C1  mov         ecx,dword ptr [ebp-40h]  
006608C4  mov         eax,dword ptr [ecx]  
006608C6  mov         eax,dword ptr [eax+28h]  
006608C9  call        dword ptr [eax+10h]  
006608CC  nop  
            Base BaseClass = new Base();
006608CD  mov         ecx,584EC0h  
006608D2  call        005730F4  
006608D7  mov         dword ptr [ebp-54h],eax  
006608DA  mov         ecx,dword ptr [ebp-54h]  
006608DD  call        00660490  
006608E2  mov         eax,dword ptr [ebp-54h]  
006608E5  mov         dword ptr [ebp-44h],eax  
            BaseClass.DoStuff();
006608E8  mov         ecx,dword ptr [ebp-44h]  
006608EB  mov         eax,dword ptr [ecx]  
006608ED  mov         eax,dword ptr [eax+28h]  
006608F0  call        dword ptr [eax+10h]  
006608F3  nop  
        }
0066091A  nop  
0066091B  lea         esp,[ebp-0Ch]  
0066091E  pop         ebx  
0066091F  pop         esi  
00660920  pop         edi  
00660921  pop         ebp  

00660922  ret  

Хотя создание объектов одинаково, инструкции, выполняемые для вызова методов запечатанного и производного / базового классов, немного отличаются. После перемещения данных в регистры или ОЗУ (инструкция mov), вызов запечатанного метода, выполнение сравнения между dword ptr [ecx], ecx (инструкция cmp), а затем вызов метода, в то время как производный / базовый класс выполняет непосредственно метод. .

Согласно отчету, написанному Торбьорном Гранлундом, Задержки выполнения инструкций и пропускная способность для процессоров AMD и Intel x86 , скорость выполнения следующей инструкции в Intel Pentium 4 составляет:

  • mov : имеет задержку в 1 цикл, и процессор может поддерживать 2,5 инструкции за цикл этого типа
  • cmp : имеет задержку 1 цикл, и процессор может поддерживать 2 инструкции за цикл этого типа

Ссылка : https://gmplib.org/~tege/x86-timing.pdf

Это означает, что в идеале время, необходимое для вызова запечатанного метода, составляет 2 цикла, в то время как время, необходимое для вызова метода производного или базового класса, составляет 3 цикла.

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

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

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

<off-topic-rant>

Я ненавижу закрытые классы. Даже если преимущества в производительности поразительны (в чем я сомневаюсь), они разрушают объектно-ориентированную модель, предотвращая повторное использование через наследование. Например, класс Thread запечатан. Хотя я вижу, что потоки могут быть как можно более эффективными, я также могу представить себе сценарии, в которых возможность создания подкласса Thread будет иметь большие преимущества. Авторы классов, если вы должны запечатать свои классы из соображений «производительности», пожалуйста, предоставьте интерфейс, по крайней мере, чтобы нам не приходилось обертывать и заменять везде, где нам нужна функция, которую вы забыли.

Пример: SafeThread должен был обернуть класс Thread, потому что Thread запечатан и нет интерфейса IThread; SafeThread автоматически перехватывает необработанные исключения в потоках, чего полностью не хватает в классе Thread. [и нет, необработанные события исключения не собирают необработанные исключения во вторичных потоках].

</off-topic-rant>

Marking a class sealed should have no performance impact.

There are cases where csc might have to emit a callvirt opcode instead of a call opcode. However, it seems those cases are rare.

And it seems to me that the JIT should be able to emit the same non-virtual function call for callvirt that it would for call, if it knows that the class doesn't have any subclasses (yet). If only one implementation of the method exists, there's no point loading its address from a vtable—just call the one implementation directly. For that matter, the JIT can even inline the function.

It's a bit of a gamble on the JIT's part, because if a subclass is later loaded, the JIT will have to throw away that machine code and compile the code again, emitting a real virtual call. My guess is this doesn't happen often in practice.

(And yes, VM designers really do aggressively pursue these tiny performance wins.)

Запустите этот код, и вы увидите, что запечатанные классы работают в 2 раза быстрее:

class Program
{
    static void Main(string[] args)
    {
        Console.ReadLine();

        var watch = new Stopwatch();
        watch.Start();
        for (int i = 0; i < 10000000; i++)
        {
            new SealedClass().GetName();
        }
        watch.Stop();
        Console.WriteLine("Sealed class : {0}", watch.Elapsed.ToString());

        watch.Start();
        for (int i = 0; i < 10000000; i++)
        {
            new NonSealedClass().GetName();
        }
        watch.Stop();
        Console.WriteLine("NonSealed class : {0}", watch.Elapsed.ToString());

        Console.ReadKey();
    }
}

sealed class SealedClass
{
    public string GetName()
    {
        return "SealedClass";
    }
}

class NonSealedClass
{
    public string GetName()
    {
        return "NonSealedClass";
    }
}

вывод: класс Sealed: 00: 00: 00.1897568 NonSealed класс: 00: 00: 00.3826678

Я считаю «запечатанные» классы нормальным случаем, и у меня ВСЕГДА есть причина опустить ключевое слово «запечатанные».

Для меня самые важные причины:

a) Улучшенные проверки времени компиляции (приведение к нереализованным интерфейсам будет обнаружено во время компиляции, а не только во время выполнения)

и главная причина:

б) Злоупотребление моими занятиями таким образом невозможно

Хотелось бы, чтобы Microsoft сделала стандарт «запечатанным», а не «распечатанным».

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

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

Я знаю, что некоторые здесь говорят, что ненавидят закрытые классы, потому что им нужна возможность наследовать от чего угодно ... но это ЧАСТО не самый удобный в обслуживании выбор ... потому что раскрытие класса производным блокирует вас в гораздо большей степени, чем не раскрытие всего что. Это похоже на высказывание «Я ненавижу классы, у которых есть частные члены ... Я часто не могу заставить класс делать то, что я хочу, потому что у меня нет доступа». Герметизация важна ... герметизация - это одна из форм герметизации.