¿Las clases selladas realmente ofrecen beneficios de rendimiento?

He encontrado una gran cantidad de consejos de optimización que dicen que debe marcar sus clases como selladas para obtener beneficios de rendimiento adicionales.

Ejecuté algunas pruebas para verificar el diferencial de rendimiento y no encontré ninguna. ¿Estoy haciendo algo mal? ¿Me estoy perdiendo el caso en que las clases selladas darán mejores resultados?

¿Alguien ha realizado pruebas y ha visto una diferencia?

Ayúdame a aprender 🙂

JITter algunas veces usará llamadas no virtuales a métodos en clases selladas ya que no hay forma de que puedan extenderse más.

Existen reglas complejas con respecto al tipo de llamada, virtual / no virtual, y no las conozco todas, así que no puedo describirlas, pero si busca clases selladas y métodos virtuales, puede encontrar algunos artículos sobre el tema.

Tenga en cuenta que cualquier tipo de beneficio de rendimiento que obtenga de este nivel de optimización debe considerarse de último recurso, siempre optimice en el nivel algorítmico antes de optimizar en el nivel de código.

Aquí hay un enlace que menciona esto: Rambling en la palabra clave sellada

La respuesta es no, las clases selladas no funcionan mejor que las no selladas.

El problema se reduce a la call vs callvirt IL códigos op. Call es más rápida que callvirt , y callvirt se usa principalmente cuando no se sabe si el objeto ha sido subclasificado. Entonces la gente asume que si calvirts una clase, todos los códigos op cambiarán de calvirts a calls y serán más rápidos.

Lamentablemente, callvirt hace otras cosas que también lo hacen útil, como buscar referencias nulas. Esto significa que incluso si una clase está sellada, la referencia puede ser nula y, por lo callvirt se necesita una callvirt . Puede evitar esto (sin necesidad de sellar la clase), pero se vuelve un poco inútil.

Las estructuras usan call porque no se pueden subclasificar y nunca son nulas.

Vea esta pregunta para más información:

Llamar y llamar

Como sé, no hay garantía de beneficio de rendimiento. Pero existe la posibilidad de disminuir la penalidad de rendimiento bajo alguna condición específica con un método sellado. (la clase sellada hace que todos los métodos sean sellados).

Pero depende de la implementación del comstackdor y del entorno de ejecución.


Detalles

Muchas de las CPU modernas usan una estructura de tubería larga para boost el rendimiento. Debido a que la CPU es increíblemente más rápida que la memoria, la CPU tiene que recuperar previamente el código de la memoria para acelerar la tubería. Si el código no está listo en el momento adecuado, las tuberías estarán inactivas.

Hay un gran obstáculo llamado despacho dynamic que interrumpe esta optimización de ‘precarga’. Puedes entender esto como solo una ramificación condicional.

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

La CPU no puede precomprobar el siguiente código para ejecutar en este caso porque la siguiente posición del código es desconocida hasta que se resuelva la condición. Por lo tanto, esto hace que el peligro cause que la tubería esté inactiva. Y la penalización de rendimiento por inactividad es enorme en regular.

Algo similar sucede en caso de anulación de método. El comstackdor puede determinar la anulación adecuada del método para la llamada al método actual, pero a veces es imposible. En este caso, el método apropiado puede determinarse solo en tiempo de ejecución. Este también es un caso de despacho dynamic, y una razón principal de los lenguajes de tipo dynamic son generalmente más lentos que los de tipo estático.

Algunos CPU (incluidos los recientes chips x86 de Intel) usan una técnica llamada ejecución especulativa para utilizar el pipeline incluso en la situación. Simplemente precapture uno de la ruta de ejecución. Pero la tasa de acierto de esta técnica no es tan alta. Y la falla en la especulación provoca una parada en la tubería que también hace una gran penalización en el rendimiento. (Esto es completamente por la implementación de la CPU. Se sabe que algunas CPU móviles no tienen este tipo de optimización para ahorrar energía)

Básicamente, C # es un lenguaje estáticamente comstackdo. Pero no siempre. No sé la condición exacta y esto depende completamente de la implementación del comstackdor. Algunos comstackdores pueden eliminar la posibilidad de envío dynamic impidiendo el reemplazo del método si el método está marcado como sealed . Los comstackdores estúpidos pueden no. Este es el beneficio de rendimiento del sealed .


Esta respuesta ( ¿Por qué es más rápido procesar una matriz ordenada que una matriz no ordenada? ) Está describiendo la predicción de la bifurcación mucho mejor.

Actualización: a partir de .NET Core 2.0 y .NET Desktop 4.7.1, CLR ahora es compatible con la desvirtualización. Puede tomar métodos en clases selladas y reemplazar llamadas virtuales con llamadas directas, y también puede hacer esto para clases no selladas si puede descubrir que es seguro hacerlo.

En tal caso (una clase sellada que el CLR no podría detectar de otra manera como seguro para devirtualizar), una clase sellada debería ofrecer algún tipo de beneficio de rendimiento.

Dicho esto, no creo que valga la pena preocuparse a menos que ya hayas perfilado el código y hayas determinado que estás en un camino especialmente candente llamado millones de veces, o algo así:

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


Respuesta Original:

Hice el siguiente progtwig de prueba y luego lo descompilé usando Reflector para ver qué código MSIL se emitió.

 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"); } 

En todos los casos, el comstackdor de C # (Visual Studio 2010 en la configuración de comstackción de versión) emite MSIL idéntico, que es el siguiente:

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

La razón citada a menudo que la gente dice que sellada proporciona beneficios de rendimiento es que el comstackdor sabe que la clase no se reemplaza, y por lo tanto puede usar call lugar de callvirt ya que no tiene que verificar virtualmente, etc. Como se demostró anteriormente, este no es verdad.

Mi siguiente pensamiento fue que a pesar de que MSIL es idéntico, ¿quizás el comstackdor JIT trata las clases selladas de forma diferente?

Ejecuté una comstackción de lanzamiento en el depurador de Visual Studio y visualicé la salida de x86 descomstackda. En ambos casos, el código x86 era idéntico, con la excepción de los nombres de clase y las direcciones de memoria de función (que, por supuesto, deben ser diferentes). Aquí está

 // 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 

Entonces pensé que tal vez ejecutar bajo el depurador hace que realice una optimización menos agresiva?

Luego ejecuté un ejecutable de comstackción de lanzamiento independiente fuera de cualquier entorno de depuración, y utilicé WinDBG + SOS para entrar después de que el progtwig se hubiera completado, y vi la disparidad del código x86 comstackdo JIT.

Como puede ver en el siguiente código, cuando se ejecuta fuera del depurador, el comstackdor JIT es más agresivo y ha WriteIt método WriteIt directamente en la persona que llama. Sin embargo, lo más importante es que era idéntico cuando se convocaba una clase sellada o no sellada. No hay diferencia alguna entre una clase sellada o no sellada.

Aquí es cuando se llama a una clase normal:

 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 

Vs una clase sellada:

 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 

Para mí, esto proporciona una prueba sólida de que no puede haber ninguna mejora en el rendimiento entre los métodos de llamada en clases selladas y no selladas … Creo que ahora estoy feliz 🙂

Marcar una clase sealed debe tener ningún impacto en el rendimiento.

Hay casos en que csc podría tener que emitir un código de operación callvirt lugar de un código de operación de call . Sin embargo, parece que esos casos son raros.

Y me parece que el JIT debería ser capaz de emitir la misma llamada a función no virtual para callvirt que call , si sabe que la clase no tiene ninguna subclase (todavía). Si solo existe una implementación del método, no tiene sentido cargar su dirección desde un vtable; solo llame directamente a la implementación. Para el caso, el JIT puede incluso alinear la función.

Es un poco arriesgado por parte del JIT, porque si una subclase se carga más tarde, el JIT deberá descartar ese código máquina y comstackr el código nuevamente, emitiendo una llamada virtual real. Creo que esto no sucede a menudo en la práctica.

(Y sí, los diseñadores de VM realmente persiguen agresivamente estas pequeñas ganancias de rendimiento).

Las clases selladas deberían proporcionar una mejora en el rendimiento. Como no se puede derivar una clase sellada, cualquier miembro virtual puede convertirse en miembro no virtual.

Por supuesto, estamos hablando de ganancias realmente pequeñas. No marcaría una clase como sellada solo para obtener una mejora en el rendimiento a menos que el perfil revele que es un problema.

Odio las clases selladas. Incluso si los beneficios de rendimiento son asombrosos (lo cual dudo), destruyen el modelo orientado a objetos al evitar la reutilización a través de la herencia. Por ejemplo, la clase Thread está sellada. Si bien puedo ver que uno podría querer que los hilos sean lo más eficientes posible, también puedo imaginar escenarios en los que poder subprocesar Thread tendría grandes beneficios. Los autores de la clase, si debe sellar sus clases por razones de “rendimiento”, proporcione una interfaz como mínimo para que no tengamos que ajustar y reemplazar en todas partes que necesitemos una característica que haya olvidado.

Ejemplo: SafeThread tuvo que envolver la clase Thread porque Thread está sellado y no hay una interfaz IThread; SafeThread atrapa automáticamente las excepciones no controladas en los hilos, algo que falta por completo en la clase Thread. [y no, los eventos de excepción no controlados no recogen excepciones no controladas en subprocesos secundarios].

Considero que las clases “selladas” son el caso normal y SIEMPRE tengo una razón para omitir la palabra clave “sellada”.

Las razones más importantes para mí son:

a) Mejores comprobaciones de tiempo de comstackción (la conversión a interfaces no implementadas se detectará en tiempo de comstackción, no solo en tiempo de ejecución)

y, razón principal:

b) El abuso de mis clases no es posible de esa manera

Ojalá Microsoft hubiera hecho “sellado” el estándar, no “sin sellar”.

@Vaibhav, ¿qué tipo de pruebas ejecutó para medir el rendimiento?

Supongo que uno debería usar Rotor y profundizar en CLI y comprender cómo una clase sellada mejoraría el rendimiento.

SSCLI (Rotor)
SSCLI: infraestructura de lenguaje común de fuente compartida

Common Language Infrastructure (CLI) es el estándar ECMA que describe el núcleo del .NET Framework. La CLI de fuente compartida (SSCLI), también conocida como Rotor, es un archivo comprimido del código fuente para una implementación operativa de la CLI ECMA y la especificación del lenguaje ECMA C #, tecnologías en el corazón de la architecture .NET de Microsoft.

las clases selladas serán al menos un poco más rápidas, pero a veces pueden ser mucho más rápidas … si JIT Optimizer puede alinear llamadas que de otro modo serían llamadas virtuales. Por lo tanto, cuando hay métodos llamados que son lo suficientemente pequeños como para incluirlos, definitivamente considere sellar la clase.

Sin embargo, la mejor razón para sellar una clase es decir “No diseñé esto para ser heredado, así que no voy a dejar que te quemes suponiendo que fue diseñado para serlo, y no voy a hacerlo”. quemarme al encerrarme en una implementación porque dejo que te bases en ello “.

Sé que algunos han dicho que odian las clases selladas porque quieren la oportunidad de derivar de cualquier cosa … pero esa no es la opción más fácil de mantener … porque exponer a una clase a una derivación te encierra en mucho más que no exponer todo ese. Es similar a decir “No me gusta las clases que tienen miembros privados … A menudo no puedo hacer que la clase haga lo que quiero porque no tengo acceso”. La encapsulación es importante … el sellado es una forma de encapsulación.

Ejecute este código y verá que las clases selladas son 2 veces más rápidas:

 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"; } } 

salida: Clase sellada: 00: 00: 00.1897568 Clase no sellada: 00: 00: 00.3826678