Compartir a través de


Writing Faster Managed Code: Know What Things Cost (Escribir código administrado con mayor rapidez: conocer el costo de las cosas)

 

Jan Gray
Equipo de rendimiento de Microsoft CLR

Junio de 2003

Se aplica a:
   Microsoft® .NET Framework

Resumen: En este artículo se presenta un modelo de costo de bajo nivel para el tiempo de ejecución de código administrado, en función de los tiempos de operación medidos, de modo que los desarrolladores puedan tomar mejores decisiones de codificación informadas y escribir código más rápido. (30 páginas impresas)

Descargue CLR Profiler. (330 KB)

Contenido

Introducción (y Compromiso)
Hacia un modelo de costo para código administrado
Qué costo de las cosas en código administrado
Conclusión
Recursos

Introducción (y Compromiso)

Hay muchas maneras de implementar un cálculo, y algunos son mucho mejores que otros: más sencillo, más limpio y fácil de mantener. Algunas maneras son muy rápidas y algunas son sorprendentemente lentas.

No perpetrar código lento y gordo en el mundo. ¿No despida ese código? ¿El código que se ejecuta encaja y se inicia? ¿Código que bloquea la interfaz de usuario durante segundos a la vez? ¿Código que pega la CPU o limita el disco?

No lo haga. En su lugar, se levantan y prometen junto conmigo:

"Prometo que no enviaré código lento. La velocidad es una característica que me importa. Cada día prestaré atención al rendimiento de mi código. Mediré regular y metódicamente su velocidad y tamaño. Aprenderé, compilaré o compraré las herramientas que necesito para hacer esto. Es mi responsabilidad".

(Realmente). ¿Así que te prometiste? Bien por ti.

¿ Cómo escribes el código más rápido y más ajustado en el día? Es cuestión de elegir conscientemente la forma frugal en preferencia a la manera extravagante, sobredimensionada, otra y otra vez, y una cuestión de pensar a través de las consecuencias. Cualquier página de código determinada captura docenas de decisiones tan pequeñas.

Pero no puede tomar decisiones inteligentes entre alternativas si no sabe qué cuestan las cosas: no puede escribir código eficaz si no sabe qué cuesta.

Fue más fácil en los buenos días viejos. Buenos programadores de C sabían. Cada operador y operación en C, ya sea asignación, entero o de punto flotante matemático, desreferencia o llamada de función, asignado más o menos uno a uno a una sola operación de máquina primitiva. True, a veces se requerían varias instrucciones de máquina para colocar los operandos correctos en los registros correctos y, a veces, una sola instrucción podría capturar varias operaciones de C (famosamente ), pero normalmente *dest++ = *src++;podría escribir (o leer) una línea de código C y saber dónde iba el tiempo. Para el código y los datos, el compilador de C era WYWIWYG: "lo que se escribe es lo que se obtiene". (La excepción era y es, las llamadas de función. Si no sabe cuál es el costo de la función, no sabe si lo hizo).

En los años 1990, para disfrutar de las numerosas ventajas de ingeniería y productividad de software de abstracción de datos, programación orientada a objetos y reutilización de código, el sector de software de PC realizó una transición de C a C++.

C++ es un superconjunto de C y es "pagar por uso" (las nuevas características no cuestan nada si no las usa), por lo que la experiencia en programación de C, incluido el modelo de costos internalizado, es directamente aplicable. Si toma código de C en funcionamiento y lo vuelve a compilar para C++, el tiempo de ejecución y la sobrecarga del espacio no deben cambiar mucho.

Por otro lado, C++ presenta muchas características de lenguaje nuevas, incluidos constructores, destructores, nuevos, eliminar, herencia única, múltiple y virtual, conversiones, funciones miembro, funciones virtuales, operadores sobrecargados, punteros a miembros, matrices de objetos, control de excepciones y composiciones de lo mismo, lo que incurre en costos ocultos no triviales. Por ejemplo, las funciones virtuales cuestan dos direccionamientos indirectos adicionales por llamada y agregan un campo de puntero de vtable oculto a cada instancia. O bien, tenga en cuenta que este código de aspecto inocuo:

{ complex a, b, c, d; … a = b + c * d; }

se compila en aproximadamente trece llamadas de función miembro implícitas (con suerte insertadas).

Hace nueve años exploramos este tema en mi artículo C++: Under the Hood. Escribí:

"Es importante comprender cómo se implementa el lenguaje de programación. Este conocimiento disipa el miedo y la maravilla de "¿Qué está haciendo el compilador aquí?"; otorga confianza para utilizar las nuevas características; y proporciona información al depurar y aprender otras características del lenguaje. También ofrece una sensación de los costos relativos de diferentes opciones de codificación que es necesario para escribir el código más eficaz día a día".

Ahora vamos a echar un vistazo al código administrado. En este artículo se exploran los costos de tiempo y espacio de bajo nivel de la ejecución administrada, por lo que podemos hacer inconvenientes más inteligentes en la codificación diaria.

Y mantenga nuestras promesas.

¿Por qué código administrado?

Para la gran mayoría de los desarrolladores de código nativo, el código administrado es una plataforma mejor y más productiva para ejecutar su software. Quita categorías completas de errores, como daños en el montón y errores de índice fuera de límite de matriz que a menudo conducen a sesiones de depuración nocturnas frustrantes. Admite requisitos modernos, como el código móvil seguro (a través de la seguridad de acceso al código) y los servicios web XML, y en comparación con el antiguo Win32/COM/ATL/MFC/VB, .NET Framework es un diseño de pizarra limpia que se puede hacer más con menos esfuerzo.

Para la comunidad de usuarios, el código administrado permite aplicaciones más enriquecidas y sólidas, lo que mejora la vida a través de un mejor software.

¿Cuál es el secreto para escribir código administrado más rápido?

Solo porque puede hacerse más con menos esfuerzo no es una licencia para abdicar su responsabilidad de codificar sabiamente. En primer lugar, debes admitirlo a ti mismo: "Soy un novato". Eres un novato. Yo también soy un novato. Todos somos chicas en tierra de código administrado. Todavía estamos aprendiendo las cuerdas, lo que incluye lo que cuestan las cosas.

En lo que respecta a .NET Framework rico y conveniente, es como si fueramos niños en la tienda de dulces. "Wow, no tengo que hacer todas esas cosas tediosas strncpy , sólo puedo '+' cadenas juntas! Wow, puedo cargar un megabyte de XML en un par de líneas de código. Whoo-hoo!"

Todo es tan fácil. Tan fácil, de hecho. Así que fácil de quemar megabytes de conjuntos de información XML de análisis de RAM solo para extraer algunos elementos de ellos. En C o C++ era tan doloroso que pensaría dos veces, quizás crearía una máquina de estado en alguna API similar a SAX. Con .NET Framework, solo tiene que cargar todo el conjunto de información en un gulp. Tal vez incluso lo hagas por encima. Entonces tal vez la aplicación ya no parezca tan rápida. Tal vez tenga un espacio de trabajo de muchos megabytes. Quizás debería haber pensado dos veces sobre lo que cuestan esos métodos fáciles...

Desafortunadamente, en mi opinión, la documentación actual de .NET Framework no detalla adecuadamente las implicaciones de rendimiento de los tipos y métodos de Framework, ni siquiera especifica qué métodos podrían crear nuevos objetos. El modelado de rendimiento no es un asunto fácil de tratar o documentar; pero aún así, el "no saber" hace que sea mucho más difícil para nosotros tomar decisiones informadas.

Puesto que todos somos novedades aquí, y como no sabemos qué cuesta nada, y dado que los costos no están claramente documentados, ¿qué vamos a hacer?

Medirlo. El secreto es medirlo y estar atento. Todos tendremos que acostumbrarnos a medir el costo de las cosas. Si vamos a la dificultad de medir lo que cuestan las cosas, no seremos los que llaman involuntariamente a un nuevo método que cuesta diez veces lo que suponemos que cuesta.

(Por cierto, para obtener información más detallada sobre los fundamentos del rendimiento de la BCL (biblioteca de clases base) o clR, considere la posibilidad de echar un vistazo a la CLI de origen compartido, a.k.a. Rotor. El código rotor comparte una línea de sangre con .NET Framework y CLR. No es el mismo código a lo largo de todo, pero incluso así, te prometo que un estudio cuidadoso de Rotor le proporcionará nuevas conclusiones sobre las novedades bajo la capucha del CLR. Pero asegúrese de revisar primero la licencia de SSCLI).

Conocimientos

Si aspiras a ser conductor de taxi en Londres, primero debes ganar The Knowledge. Los estudiantes estudian durante muchos meses para memorizar las miles de calles pequeñas de Londres y aprender las mejores rutas de un lugar a otro. Y salen todos los días en scooters para explorar y reforzar su aprendizaje de libros.

Del mismo modo, si desea ser un desarrollador de código administrado de alto rendimiento, debe adquirir El conocimiento del código administrado. Tiene que aprender qué cuesta cada operación de bajo nivel. Tiene que aprender qué características, como delegados y costo de seguridad de acceso al código. Tiene que aprender los costos de los tipos y métodos que usa y los que está escribiendo. Y no le duele descubrir qué métodos pueden ser demasiado costosos para la aplicación, y así evitarlos.

El Conocimiento no está en ningún libro, por desgracia. Tienes que salir en tu scooter y explorar, es decir, subir csc, ildasm, el depurador de VS.NET, clR Profiler, tu generador de perfiles, algunos temporizadores de rendimiento, etc., y ver qué cuesta el código en el tiempo y el espacio.

Hacia un modelo de costo para código administrado

Preliminares, vamos a considerar un modelo de costo para el código administrado. De este modo, podrá examinar un método hoja e indicar de un vistazo qué expresiones e instrucciones son más costosas; y podrá tomar decisiones más inteligentes a medida que escribe código nuevo.

(Esto no abordará los costos transitivos de llamar a los métodos o métodos de .NET Framework. Eso tendrá que esperar otro artículo otro día.

Anteriormente dije que la mayoría del modelo de costo de C todavía se aplica en escenarios de C++. Del mismo modo, gran parte del modelo de costo de C/C++ se sigue aplicando al código administrado.

¿Cómo puede ser eso? Conoce el modelo de ejecución de CLR. El código se escribe en uno de varios lenguajes. Se compila en formato CIL (lenguaje intermedio común), empaquetado en ensamblados. Ejecuta el ensamblado de aplicación principal y comienza a ejecutar la CIL. Pero,¿no es más lento un orden de magnitud, como los intérpretes de código de bytes antiguos?

El compilador Just-In-Time

No. CLR usa un compilador JIT (Just-In-Time) para compilar cada método de CIL en código x86 nativo y, a continuación, ejecuta el código nativo. Aunque hay un pequeño retraso para la compilación JIT de cada método, ya que se llama por primera vez, cada método denominado ejecuta código nativo puro sin sobrecarga interpretiva.

A diferencia de un proceso tradicional de compilación de C++, el tiempo invertido en el compilador JIT es un retraso de "tiempo de reloj de pared", en la cara de cada usuario, por lo que el compilador JIT no tiene el lujo de pasar la optimización exhaustiva. Incluso así, la lista de optimizaciones que realiza el compilador JIT es impresionante:

  • Plegamiento constante
  • Propagación de constantes y copias
  • Eliminación de subexpresiones comunes
  • Movimiento de código de invariantes de bucle
  • Eliminación de código fallido y de almacenamiento no enviados
  • Registro de la asignación
  • Inserción de métodos
  • Desenrollamiento de bucles (bucles pequeños con cuerpos pequeños)

El resultado es comparable al código nativo tradicional, al menos en el mismo parque de bolas.

En cuanto a los datos, usará una combinación de tipos de valor o tipos de referencia. Los tipos de valor, incluidos los tipos enteros, los tipos de punto flotante, las enumeraciones y las estructuras, normalmente residen en la pila. Son tan pequeños y rápidos como los locales y las estructuras están en C/C++. Al igual que con C/C++, es probable que evite pasar estructuras grandes como argumentos de método o valores devueltos, ya que la sobrecarga de copia puede ser prohibitivamente costosa.

Los tipos de referencia y los tipos de valor boxed residen en el montón. Se abordan mediante referencias de objeto, que son simplemente punteros de máquina como punteros de objeto en C/C++.

Por lo tanto, el código administrado jitted puede ser rápido. Con algunas excepciones que se describen a continuación, si tiene una sensación de margen para el costo de alguna expresión en código C nativo, no pasará mucho mal el modelado de su costo como equivalente en el código administrado.

También debo mencionar NGEN, una herramienta que "adelantó a tiempo" compila la CIL en ensamblados de código nativo. Aunque NGEN'ing your assemblies does not have a sustancial impact (good or bad) on execution time, it can reduce total working set for shared assemblies that are loaded into many AppDomains and processes. (El sistema operativo puede compartir una copia del código NGEN en todos los clientes; mientras que el código jitted normalmente no se comparte actualmente entre appDomains o procesos. Pero vea también LoaderOptimizationAttribute.MultiDomain.)

Automatic Memory Management

La salida más significativa del código administrado (de nativo) es la administración automática de memoria. Asigna nuevos objetos, pero el recolector de elementos no utilizados (GC) CLR los libera automáticamente cuando se vuelven inaccesibles. GC se ejecuta ahora y de nuevo, a menudo imperceptiblemente, por lo general deteniendo la aplicación por solo un milisegundo o dos, ocasionalmente más largo.

En otros artículos se describen las implicaciones de rendimiento del recolector de elementos no utilizados y no se recapitulan aquí. Si la aplicación sigue las recomendaciones de estos otros artículos, el costo total de la recolección de elementos no utilizados puede ser insignificante, un pocos por ciento del tiempo de ejecución, competitivo con o superior al objeto new de C++ tradicional y delete. El costo amortizado de crear y, posteriormente, reclamar automáticamente un objeto es lo suficientemente bajo que se pueden crear muchas decenas de millones de objetos pequeños por segundo.

Pero la asignación de objetos todavía no es libre. Los objetos ocupan espacio. La asignación de objetos no utilizados lleva a ciclos de recolección de elementos no utilizados más frecuentes.

Mucho peor, la conservación innecesaria de referencias a gráficos de objetos inútiles las mantiene vivas. A veces vemos programas modestos con conjuntos de trabajo lamentables de más de 100 MB, cuyos autores niegan su culpa y, en su lugar, atribuyen su bajo rendimiento a algún problema misterioso, no identificado (y, por lo tanto, intractable) con el propio código administrado. Es trágica. Pero luego un estudio de una hora con CLR Profiler y los cambios en algunas líneas de código cortan su uso del montón por un factor de diez o más. Si se enfrenta a un problema de espacio de trabajo grande, el primer paso es buscar en el reflejo.

Por lo tanto, no cree objetos innecesariamente. Solo porque la administración automática de memoria disipa las muchas complejidades, molestias y errores de asignación y liberación de objetos, porque es tan rápido y tan conveniente, naturalmente tendemos a crear más y más objetos, como si creciesen en árboles. Si desea escribir código administrado muy rápido, cree objetos cuidadosamente y adecuadamente.

Esto también se aplica al diseño de api. Es posible diseñar un tipo y sus métodos para que requieran que los clientes creen nuevos objetos con abandono salvaje. No hagas eso.

Qué costo de las cosas en código administrado

Ahora vamos a considerar el costo de tiempo de varias operaciones de código administrado de bajo nivel.

En la tabla 1 se presenta el costo aproximado de una variedad de operaciones de código administrado de bajo nivel, en nanosegundos, en un pc Pentium-III de 1,1 GHz que ejecuta Windows XP y .NET Framework v1.1 ("Everett"), recopilado con un conjunto de bucles de tiempo simples.

El controlador de pruebas llama a cada método de prueba, especificando una serie de iteraciones que se van a realizar, escalado automáticamente para iterar entre 218 y 230 iteraciones, según sea necesario para realizar cada prueba durante al menos 50 ms. Por lo general, esto es lo suficientemente largo como para observar varios ciclos de recolección de elementos no utilizados de generación 0 en una prueba que realiza una asignación intensa de objetos. En la tabla se muestran los resultados promedio de más de 10 pruebas, así como la mejor prueba (tiempo mínimo) para cada sujeto de prueba.

Cada bucle de prueba se anula de 4 a 64 veces según sea necesario para reducir la sobrecarga del bucle de prueba. He inspeccionado el código nativo generado para cada prueba para asegurarse de que el compilador JIT no estaba optimizando la prueba, por ejemplo, en varios casos he modificado la prueba para mantener los resultados intermedios activos durante y después del bucle de prueba. Del mismo modo hice cambios para impedir la eliminación de subexpresión común en varias pruebas.

Tabla 1 Horas primitivas (promedio y mínimo) (ns)

Avg. Min Primitivo Avg. Min Primitivo Avg. Min Primitivo
0,0 0,0 Control 2.6 2.6 nuevo valtype L1 0.8 0.8 isinst up 1
1.0 1.0 Agregar int 4.6 4.6 nuevo valtype L2 0.8 0.8 isinst down 0
1.0 1.0 Subt 6.4 6.4 nuevo valtype L3 6.3 6.3 isinst down 1
2.7 2.7 Int mul 8.0 8.0 nuevo valtype L4 10,7 10.6 isinst (up 2) down 1
35,9 35,7 Int div 23,0 22,9 nuevo valtype L5 6.4 6.4 isinst down 2
2.1 2.1 Desplazamiento int 22,0 20,3 new reftype L1 6.1 6.1 isinst down 3
2.1 2.1 long add 26,1 23,9 new reftype L2 1.0 1.0 obtener campo
2.1 2.1 long sub 30,2 27,5 new reftype L3 1.2 1.2 get prop
34,2 34,1 long mul 34,1 30.8 new reftype L4 1.2 1.2 set field
50,1 50,0 long div 39.1 34,4 new reftype L5 1.2 1.2 set prop
5,1 5,1 desplazamiento largo 22,3 20,3 new reftype empty ctor L1 0.9 0.9 obtener este campo
1.3 1.3 float add 26,5 23,9 new reftype empty ctor L2 0.9 0.9 obtener esta propiedad
1.4 1.4 float sub 38.1 34.7 new reftype empty ctor L3 1.2 1.2 establecer este campo
2.0 2.0 float mul 34.7 30,7 new reftype empty ctor L4 1.2 1.2 establecer esta propiedad
27,7 27.6 div float 38.5 34.3 new reftype empty ctor L5 6.4 6.3 obtener la propiedad virtual
1.5 1.5 double add 22,9 20,7 new reftype ctor L1 6.4 6.3 establecer la propiedad virtual
1.5 1.5 double sub 27.8 25.4 new reftype ctor L2 6.4 6.4 barrera de escritura
2.1 2,0 doble mul 32,7 29,9 new reftype ctor L3 1,9 1,9 cargar elem de matriz int
27,7 27.6 div doble 37.7 34,1 new reftype ctor L4 1,9 1,9 almacenar elem de matriz int
0,2 0,2 llamada estática insertada 43.2 39.1 new reftype ctor L5 2.5 2.5 cargar obj array elem
6.1 6.1 llamada estática 28,6 26,7 new reftype ctor no-inl L1 16.0 16.0 almacenar obj array elem
1,1 1,0 llamada de instancia insertada 38.9 36,5 new reftype ctor no-inl L2 29,0 21.6 box int
6,8 6,8 llamada de instancia 50.6 47.7 new reftype ctor no-inl L3 3.0 3.0 unbox int
0,2 0,2 insertado esta llamada inst 61.8 58.2 new reftype ctor no-inl L4 41.1 40.9 invocación de delegado
6.2 6.2 esta llamada de instancia 72.6 68.5 new reftype ctor no-inl L5 2.7 2.7 sum array 1000
5.4 5.4 llamada virtual 0,4 0,4 cast up 1 2.8 2.8 sum array 10000
5.4 5.4 esta llamada virtual 0,3 0,3 cast down 0 2.9 2.8 sum array 100000
6.6 6.5 llamada de interfaz 8,9 8.8 cast down 1 5.6 5.6 sum array 10000000
1,1 1,0 inst itf instance call 9.8 9.7 cast (up 2) down 1 3,5 3,5 sum list 1000
0,2 0,2 esta llamada a la instancia de itf 8,9 8.8 cast down 2 6.1 6.1 sum list 10000
5.4 5.4 inst itf virtual call 8,7 8,6 cast down 3 22,0 22,0 sum list 100000
5.4 5.4 esta llamada virtual itf       21,5 21,4 sum list 10000000

Una declinación de responsabilidades: por favor, no tome estos datos demasiado literalmente. Las pruebas de tiempo se detectan con el peligro de efectos inesperados de segundo orden. Una posibilidad de que ocurra podría colocar el código jitted, o algunos datos cruciales, para que abarque líneas de caché, interfiera con algo más o con lo que usted tenga. Es un poco parecido al principio de incertidumbre: tiempos y diferencias de tiempo de 1 nanosegundos o así están en los límites del observable.

Otra declinación de responsabilidades: estos datos solo son pertinentes para escenarios de datos y código pequeños que se ajustan completamente a la memoria caché. Si las partes "activas" de la aplicación no caben en la caché en chip, es posible que tenga un conjunto diferente de desafíos de rendimiento. Tenemos mucho más que decir sobre las memorias caché cerca del final del papel.

Y otra declinación de responsabilidades: una de las ventajas sublimes de enviar los componentes y las aplicaciones como ensamblados de CIL es que el programa puede obtener automáticamente más rápido cada segundo, y obtener más rápido cada año, "más rápido cada segundo" porque el tiempo de ejecución puede (en teoría) volver a generar el código compilado JIT a medida que se ejecuta el programa; y "cada año más rápido", ya que con cada nueva versión del entorno de ejecución, mejor, más inteligente y más rápido, los algoritmos pueden tomar una nueva puñalada para optimizar el código. Por lo tanto, si algunos de estos tiempos parecen menos que óptimos en .NET 1.1, tómese el corazón de que deben mejorar en versiones posteriores del producto. De este modo, cualquier secuencia de código nativo especificada notificada en este artículo puede cambiar en futuras versiones de .NET Framework.

Aparte de las declinaciones de responsabilidades, los datos proporcionan una sensación razonable para el rendimiento actual de varios primitivos. Los números tienen sentido y justifican mi aserción de que la mayoría del código administrado jitted se ejecuta "cerca de la máquina", como hace el código nativo compilado. Las operaciones de entero y flotante primitivas son rápidas, las llamadas de método de varios tipos menos, pero (confiar en mí) siguen siendo comparables a C/C++nativo; y, sin embargo, también vemos que algunas operaciones que suelen ser baratas en código nativo (conversiones, almacenes de matrices y campos, punteros de función (delegados)) ahora son más caros. ¿Por qué? Vamos a verlo.

Operaciones aritméticas

Tabla 2 Tiempos de operación aritmética (ns)

Avg. Min Primitivo Avg. Min Primitivo
1.0 1.0 int add 1.3 1.3 float add
1.0 1.0 int sub 1.4 1.4 float sub
2.7 2.7 int mul 2.0 2.0 float mul
35,9 35,7 int div 27,7 27.6 div float
2.1 2.1 int shift      
2.1 2.1 long add 1.5 1.5 double add
2.1 2.1 long sub 1.5 1.5 double sub
34,2 34,1 long mul 2.1 2,0 doble mul
50,1 50,0 long div 27,7 27.6 div doble
5,1 5,1 desplazamiento largo      

En los viejos días, las matemáticas de punto flotante quizás era un orden de magnitud más lento que las matemáticas enteras. Como se muestra en la tabla 2, con unidades de punto flotante canalizaciones modernas, parece que hay poca o ninguna diferencia. Es increíble pensar que un equipo portátil promedio es una máquina de clase gigaflop ahora (para problemas que caben en la memoria caché).

Echemos un vistazo a una línea de código jitted desde el entero y el punto flotante para agregar pruebas:

Desensamblado 1 Agregar int y agregar float

int add               a = a + b + c + d + e + f + g + h + i;
0000004c 8B 54 24 10      mov         edx,dword ptr [esp+10h] 
00000050 03 54 24 14      add         edx,dword ptr [esp+14h] 
00000054 03 54 24 18      add         edx,dword ptr [esp+18h] 
00000058 03 54 24 1C      add         edx,dword ptr [esp+1Ch] 
0000005c 03 54 24 20      add         edx,dword ptr [esp+20h] 
00000060 03 D5            add         edx,ebp 
00000062 03 D6            add         edx,esi 
00000064 03 D3            add         edx,ebx 
00000066 03 D7            add         edx,edi 
00000068 89 54 24 10      mov         dword ptr [esp+10h],edx 

float add            i += a + b + c + d + e + f + g + h;
00000016 D9 05 38 61 3E 00 fld         dword ptr ds:[003E6138h] 
0000001c D8 05 3C 61 3E 00 fadd        dword ptr ds:[003E613Ch] 
00000022 D8 05 40 61 3E 00 fadd        dword ptr ds:[003E6140h] 
00000028 D8 05 44 61 3E 00 fadd        dword ptr ds:[003E6144h] 
0000002e D8 05 48 61 3E 00 fadd        dword ptr ds:[003E6148h] 
00000034 D8 05 4C 61 3E 00 fadd        dword ptr ds:[003E614Ch] 
0000003a D8 05 50 61 3E 00 fadd        dword ptr ds:[003E6150h] 
00000040 D8 05 54 61 3E 00 fadd        dword ptr ds:[003E6154h] 
00000046 D8 05 58 61 3E 00 fadd        dword ptr ds:[003E6158h] 
0000004c D9 1D 58 61 3E 00 fstp        dword ptr ds:[003E6158h] 

Aquí vemos que el código jitted está cerca de óptimo. En el int add caso, el compilador incluso ha registrado cinco de las variables locales. En el caso float add, estaba obligado a hacer variables a a través h de estáticos de clase para derrotar la eliminación de subexpresión común.

Llamadas a métodos

En esta sección se examinan los costos y las implementaciones de las llamadas a métodos. El asunto de la prueba es una interfaz Ide implementación de clase T , con varios tipos de métodos. Vea Lista 1.

Enumeración de métodos de prueba de llamada a 1

interface I { void itf1();… void itf5();… }
public class T : I {
    static bool falsePred = false;
    static void dummy(int a, int b, int c, …, int p) { }

    static void inl_s1() { } …    static void s1()     { if (falsePred) dummy(1, 2, 3, …, 16); } …    void inl_i1()        { } …    void i1()            { if (falsePred) dummy(1, 2, 3, …, 16); } …    public virtual void v1() { } …    void itf1()          { } …    virtual void itf5()  { } …}

Considere la tabla 3. Parece, a una primera aproximación, un método se inserta (la abstracción no cuesta nada) o no (la abstracción cuesta >5X una operación entera). No parece haber una diferencia significativa en el costo sin procesar de una llamada estática, una llamada de instancia, una llamada virtual o una llamada de interfaz.

Tabla 3 Tiempos de llamada de método (ns)

Avg. Min Primitivo Destinatario Avg. Min Primitivo Destinatario
0,2 0,2 llamada estática insertada inl_s1 5.4 5.4 llamada virtual v1
6.1 6.1 llamada estática s1 5.4 5.4 esta llamada virtual v1
1,1 1,0 llamada de instancia insertada inl_i1 6.6 6.5 llamada de interfaz itf1
6,8 6,8 llamada de instancia i1 1,1 1,0 llamada a la instancia de inst itf itf1
0,2 0,2 insertado esta llamada inst inl_i1 0,2 0,2 esta llamada a la instancia de itf itf1
6.2 6.2 esta llamada de instancia i1 5.4 5.4 inst itf virtual call itf5
        5.4 5.4 esta llamada virtual itf itf5

Sin embargo, estos resultados son los mejores casos no representativos, el efecto de ejecutar bucles de tiempo ajustados millones de veces. En estos casos de prueba, los sitios de llamada de métodos virtuales y de interfaz son monomórficos (por ejemplo, por sitio de llamada, el método de destino no cambia con el tiempo), por lo que la combinación de almacenar en caché el método virtual y los mecanismos de distribución de métodos (los punteros y entradas de mapa de interfaz de método) y la predicción de rama proporcionado espectacularmente permite al procesador realizar una llamada poco realista a través de estas tareas difíciles de predecir, ramas dependientes de datos. En la práctica, se pierde una memoria caché de datos en cualquiera de los datos del mecanismo de envío o en una ramificación incorrecta (ya sea un error de capacidad obligatorio o un sitio de llamada polimórfico), puede y ralentizará las llamadas virtuales e de interfaz por docenas de ciclos.

Echemos un vistazo más de cerca a cada uno de estos tiempos de llamada de método.

En el primer caso, llamada estática insertada, llamamos a una serie de métodos estáticos vacíos s1_inl() , etc. Puesto que el compilador se aleja completamente de todas las llamadas, terminamos sincronizando un bucle vacío.

Para medir el costo aproximado de una llamada a método estático, hacemos que los métodos estáticos, etc. sean tan grandes que no son aptos s1() para insertarse en el autor de la llamada.

Observe que incluso tenemos que usar una variable falsePredde predicado false explícita. Si escribimos

static void s1() { if (false) dummy(1, 2, 3, …, 16); }

El compilador JIT eliminaría la llamada inactiva a dummy todo el cuerpo del método (ahora vacío) como antes. Por cierto, aquí algunos de los 6,1 ns de tiempo de llamada deben atribuirse a la prueba de predicado (false) y saltar dentro del método s1estático llamado . (Por cierto, una mejor manera de deshabilitar la inserción es el CompilerServices.MethodImpl(MethodImplOptions.NoInlining) atributo).

Se usó el mismo enfoque para la llamada de instancia insertada y el tiempo de llamada de instancia normal. Sin embargo, dado que la especificación del lenguaje C# garantiza que cualquier llamada en una referencia de objeto null produzca una excepción NullReferenceException, cada sitio de llamada debe asegurarse de que la instancia no sea nula. Esto se hace desreferenciando la referencia de instancia; si es null, generará un error que se convierte en esta excepción.

En Desensamblar 2, usamos una variable t estática como instancia, porque cuando usamos una variable local

    T t = new T();

el compilador compiló la instancia nula desusada del bucle.

Desensamblar 2 sitio de llamada de método de instancia con la instancia nula "check"

               t.i1();
00000012 8B 0D 30 21 A4 05 mov         ecx,dword ptr ds:[05A42130h] 
00000018 39 09             cmp         dword ptr [ecx],ecx 
0000001a E8 C1 DE FF FF    call        FFFFDEE0 

Los casos de la llamada de instancia insertada y esta llamada de instancia son los mismos, excepto que la instancia es this; aquí se ha elided la comprobación nula.

Desensamblar 3 Este sitio de llamada de método de instancia

               this.i1();
00000012 8B CE            mov         ecx,esi
00000014 E8 AF FE FF FF   call        FFFFFEC8

Las llamadas a métodos virtuales funcionan igual que en las implementaciones tradicionales de C++. La dirección de cada método virtual recién introducido se almacena dentro de una nueva ranura en la tabla de métodos del tipo. La tabla de métodos de cada tipo derivado se ajusta a y extiende el de su tipo base, y cualquier invalidación de método virtual reemplaza la dirección del método virtual del tipo base por la dirección del método virtual del tipo derivado en la ranura correspondiente de la tabla de métodos del tipo derivado.

En el sitio de llamada, una llamada de método virtual incurre en dos cargas adicionales en comparación con una llamada de instancia, una para capturar la dirección de la tabla de métodos (siempre se encuentra en *(this+0)) y otra para capturar la dirección del método virtual adecuada de la tabla de métodos y llamarla. Consulte Desensamblar 4.

Sitio de llamada de método virtual de desensamblar 4

               this.v1();
00000012 8B CE            mov         ecx,esi 
00000014 8B 01            mov         eax,dword ptr [ecx] ; fetch method table address
00000016 FF 50 38         call        dword ptr [eax+38h] ; fetch/call method address

Por último, llegamos a las llamadas al método de interfaz (Desensamblar 5). No tienen ningún equivalente exacto en C++. Cualquier tipo determinado puede implementar cualquier número de interfaces y cada interfaz requiere lógicamente su propia tabla de métodos. Para enviar en un método de interfaz, buscamos la tabla de métodos, su mapa de interfaz, la entrada de la interfaz en ese mapa y, a continuación, llamamos indirectas a través de la entrada adecuada en la sección de la interfaz de la tabla de métodos.

Desensamblar 5 sitio de llamada de método de interfaz

               i.itf1();
00000012 8B 0D 34 21 A4 05 mov        ecx,dword ptr ds:[05A42134h]; instance address
00000018 8B 01             mov        eax,dword ptr [ecx]         ; method table addr
0000001a 8B 40 0C          mov        eax,dword ptr [eax+0Ch]     ; interface map addr
0000001d 8B 40 7C          mov        eax,dword ptr [eax+7Ch]     ; itf method table addr
00000020 FF 10             call       dword ptr [eax]             ; fetch/call meth addr

El resto de los intervalos primitivos, la llamada de instancia inst itf, esta llamada de instancia itf, la llamada virtual itf, esta llamada virtual itf resalta la idea de que cada vez que el método de un tipo derivado implementa un método de interfaz, permanece invocable a través de un sitio de llamada de método de instancia.

Por ejemplo, para la prueba de esta llamada de instancia itf, una llamada en una implementación de método de interfaz a través de una referencia de instancia (no interfaz), el método de interfaz se inserta correctamente y el costo va a 0 ns. Incluso una implementación de método de interfaz es potencialmente insertable cuando se llama como un método de instancia.

Llamadas a métodos que todavía se van a jitted

En el caso de las llamadas a métodos estáticos y de instancia (pero no a las llamadas a métodos virtuales y de interfaz), el compilador JIT genera actualmente diferentes secuencias de llamada de método en función de si el método de destino ya se ha jitted en el momento en que se está jitted su sitio de llamada.

Si el destinatario (método de destino) aún no se ha jitted, el compilador emite una llamada indirecta a través de un puntero que se inicializa primero con un "código auxiliar prejit". La primera llamada al método de destino llega al código auxiliar, que desencadena la compilación JIT del método, genera código nativo y actualiza el puntero para abordar el nuevo código nativo.

Si el destinatario ya se ha jitted, se conoce su dirección de código nativa para que el compilador emita una llamada directa a él.

Creación de nuevos objetos

La creación de nuevos objetos consta de dos fases: asignación de objetos e inicialización de objetos.

Para los tipos de referencia, los objetos se asignan en el montón de recolección de elementos no utilizados. En el caso de los tipos de valor, ya sean residentes en la pila o insertados dentro de otro tipo de referencia o valor, el objeto de tipo de valor se encuentra en algún desplazamiento constante de la estructura envolvente, no se requiere asignación.

En el caso de los objetos de tipo de referencia pequeño típicos, la asignación del montón es muy rápida. Después de cada recolección de elementos no utilizados, excepto en presencia de objetos anclados, los objetos dinámicos del montón de generación 0 se compactan y se promueven a la generación 1, por lo que el asignador de memoria tiene un buen gran espacio de memoria libre contiguo con el que trabajar. La mayoría de las asignaciones de objetos solo incurren en un incremento de puntero y una comprobación de límites, que es más barato que el asignador de lista libre de C/C++ típico (nuevo operador/malloc). El recolector de elementos no utilizados incluso tiene en cuenta el tamaño de caché de la máquina para intentar mantener los objetos gen 0 en el punto dulce rápido de la jerarquía de memoria o caché.

Dado que el estilo de código administrado preferido es asignar la mayoría de los objetos con duraciones cortas y reclamarlos rápidamente, también se incluye (en el costo de tiempo) el costo amortizado de la recolección de elementos no utilizados de estos nuevos objetos.

Tenga en cuenta que el recolector de elementos no utilizados no pasa tiempo llorando objetos muertos. Si un objeto está muerto, GC no lo ve, no lo recorre, no le da un pensamiento nanosegundo. GC sólo se preocupa por el bienestar de la vida.

(Excepción: los objetos fallidos finalizables son un caso especial. GC realiza un seguimiento de esos objetos y promueve especialmente los objetos finalizables inactivos a la próxima generación pendiente de finalización. Esto es costoso y, en el peor de los casos, puede promover transitivamente gráficos de objetos fallidos grandes. Por lo tanto, no haga que los objetos sean finalizables a menos que sea estrictamente necesario; y si es necesario, considere la posibilidad de usar el patrón Dispose, llamando cuando GC.SuppressFinalizer sea posible). A menos que el método lo requiera Finalize , no contenga referencias del objeto finalizable a otros objetos.

Por supuesto, el costo amortizado de GC de un objeto de corta duración grande es mayor que el costo de un objeto de corta duración pequeño. Cada asignación de objetos nos lleva mucho más cerca del siguiente ciclo de recolección de elementos no utilizados; Los objetos más grandes lo hacen mucho antes que los pequeños. Antes (o más tarde), el momento de calcular vendrá. Los ciclos de GC, especialmente las colecciones de generación 0, son muy rápidos, pero no son libres, incluso si la gran mayoría de los nuevos objetos están muertos: para buscar (marcar) los objetos activos, primero es necesario pausar subprocesos y, a continuación, recorrer pilas y otras estructuras de datos para recopilar referencias de objetos raíz en el montón.

(Quizás más significativamente, menos objetos más grandes caben en la misma cantidad de memoria caché que los objetos más pequeños. Los efectos de errores de caché pueden dominar fácilmente los efectos de longitud de la ruta de acceso del código).

Una vez asignado el espacio para el objeto, permanece inicializarlo (construirlo). CLR garantiza que todas las referencias de objeto se inicializan previamente a null y todos los tipos escalares primitivos se inicializan en 0, 0,0, false, etc. (Por lo tanto, no es necesario hacerlo con redundancia en los constructores definidos por el usuario. No dudes, por supuesto. Pero tenga en cuenta que el compilador JIT actualmente no optimiza necesariamente los almacenes redundantes).

Además de la creación de ceros de los campos de instancia, CLR inicializa (solo tipos de referencia) los campos de implementación interna del objeto: el puntero de tabla de métodos y la palabra de encabezado del objeto, que precede al puntero de tabla de métodos. Las matrices también obtienen un campo Length y las matrices de objetos obtienen campos longitud y tipo de elemento.

Después, CLR llama al constructor del objeto, si existe. El constructor de cada tipo, ya sea definido por el usuario o generado por el compilador, primero llama al constructor del tipo base y, a continuación, ejecuta la inicialización definida por el usuario, si existe.

En teoría, esto podría ser costoso para escenarios de herencia profunda. Si E extiende D extiende C extiende B extiende A (extiende System.Object), la inicialización de un E siempre incurriría en cinco llamadas de método. En la práctica, las cosas no son tan malas, ya que el compilador se aleja (en nada) llama a constructores de tipo base vacíos.

Al hacer referencia a la primera columna de la tabla 4, observe que podemos crear e inicializar una estructura con cuatro campos int D en aproximadamente 8 int-add-times. El desensamblaje 6 es el código generado a partir de tres bucles de tiempo diferentes, creando A, C y E. (Dentro de cada bucle modificamos cada nueva instancia, lo que evita que el compilador JIT optimice todo lo que esté fuera).

Tabla 4 Tiempos de creación de objetos de tipo de referencia y valor (ns)

Avg. Min Primitivo Avg. Min Primitivo Avg. Min Primitivo
2.6 2.6 nuevo valtype L1 22,0 20,3 new reftype L1 22,9 20,7 new rt ctor L1
4.6 4.6 nuevo valtype L2 26,1 23,9 new reftype L2 27.8 25.4 new rt ctor L2
6.4 6.4 nuevo valtype L3 30,2 27,5 new reftype L3 32,7 29,9 new rt ctor L3
8.0 8.0 nuevo valtype L4 34,1 30.8 new reftype L4 37.7 34,1 new rt ctor L4
23,0 22,9 nuevo valtype L5 39.1 34,4 new reftype L5 43.2 39.1 new rt ctor L5
      22,3 20,3 new rt empty ctor L1 28,6 26,7 new rt no-inl L1
      26,5 23,9 new rt empty ctor L2 38.9 36,5 new rt no-inl L2
      38.1 34.7 new rt empty ctor L3 50.6 47.7 new rt no-inl L3
      34.7 30,7 new rt empty ctor L4 61.8 58.2 new rt no-inl L4
      38.5 34.3 new rt empty ctor L5 72.6 68.5 new rt no-inl L5

Desensamblado 6 Construcción de objetos de tipo de valor

               A a1 = new A(); ++a1.a;
00000020 C7 45 FC 00 00 00 00 mov     dword ptr [ebp-4],0 
00000027 FF 45 FC         inc         dword ptr [ebp-4] 

               C c1 = new C(); ++c1.c;
00000024 8D 7D F4         lea         edi,[ebp-0Ch] 
00000027 33 C0            xor         eax,eax 
00000029 AB               stos        dword ptr [edi] 
0000002a AB               stos        dword ptr [edi] 
0000002b AB               stos        dword ptr [edi] 
0000002c FF 45 FC         inc         dword ptr [ebp-4] 

               E e1 = new E(); ++e1.e;
00000026 8D 7D EC         lea         edi,[ebp-14h] 
00000029 33 C0            xor         eax,eax 
0000002b 8D 48 05         lea         ecx,[eax+5] 
0000002e F3 AB            rep stos    dword ptr [edi] 
00000030 FF 45 FC         inc         dword ptr [ebp-4] 

Los cinco intervalos siguientes (nuevo reftype L1, ... new reftype L5) son para cinco niveles de herencia de tipos A de referencia, ..., E, sans constructores definidos por el usuario:

    public class A     { int a; }
    public class B : A { int b; }
    public class C : B { int c; }
    public class D : C { int d; }
    public class E : D { int e; }

Comparando los tiempos de tipo de referencia con los tiempos de tipo de valor, vemos que la asignación amortizada y el costo de liberar de cada instancia es de aproximadamente 20 ns (20X int add time) en el equipo de prueba. Eso es rápido: asignar, inicializar y reclamar alrededor de 50 millones de objetos de corta duración por segundo, sostenidos. Para los objetos tan pequeños como cinco campos, la asignación y la colección solo tienen en cuenta la mitad del tiempo de creación del objeto. Consulte Desensamblado 7.

Construcción de objetos de tipo de referencia 7 desensamblado

               new A();
0000000f B9 D0 72 3E 00   mov         ecx,3E72D0h 
00000014 E8 9F CC 6C F9   call        F96CCCB8 

               new C();
0000000f B9 B0 73 3E 00   mov         ecx,3E73B0h 
00000014 E8 A7 CB 6C F9   call        F96CCBC0 

               new E();
0000000f B9 90 74 3E 00   mov         ecx,3E7490h 
00000014 E8 AF CA 6C F9   call        F96CCAC8 

Los tres últimos conjuntos de cinco intervalos presentan variaciones en este escenario de construcción de clase heredada.

  1. Nuevo rt vacío ctor L1, ..., nuevo rt vacío ctor L5: Cada tipo A, ..., E tiene un constructor vacío definido por el usuario. Todos están insertados y el código generado es el mismo que el anterior.

  2. Nuevo rt ctor L1, ..., new rt ctor L5: Cada tipo A, ..., E tiene un constructor definido por el usuario que establece su variable de instancia en 1:

        public class A     { int a; public A() { a = 1; } }
        public class B : A { int b; public B() { b = 1; } }
        public class C : B { int c; public C() { c = 1; } }
        public class D : C { int d; public D() { d = 1; } }
        public class E : D { int e; public E() { e = 1; } }
    

El compilador inserta cada conjunto de constructores de clases base anidadas que llama al new sitio. (Desensamblado 8).

Desensamblado 8 Constructores heredados profundamente insertados

               new A();
00000012 B9 A0 77 3E 00   mov         ecx,3E77A0h 
00000017 E8 C4 C7 6C F9   call        F96CC7E0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 

               new C();
00000012 B9 80 78 3E 00   mov         ecx,3E7880h 
00000017 E8 14 C6 6C F9   call        F96CC630 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 

               new E();
00000012 B9 60 79 3E 00   mov         ecx,3E7960h 
00000017 E8 84 C3 6C F9   call        F96CC3A0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 
00000031 C7 40 10 01 00 00 00 mov     dword ptr [eax+10h],1 
00000038 C7 40 14 01 00 00 00 mov     dword ptr [eax+14h],1 
  1. Nuevo rt no-inl L1, ..., new rt no-inl L5: Cada tipo A, ..., E tiene un constructor definido por el usuario que se ha escrito intencionadamente para que sea demasiado caro para insertar. Este escenario simula el costo de crear objetos complejos con jerarquías de herencia profundas y constructores largish.

      public class A     { int a; public A() { a = 1; if (falsePred) dummy(…); } }
      public class B : A { int b; public B() { b = 1; if (falsePred) dummy(…); } }
      public class C : B { int c; public C() { c = 1; if (falsePred) dummy(…); } }
      public class D : C { int d; public D() { d = 1; if (falsePred) dummy(…); } }
      public class E : D { int e; public E() { e = 1; if (falsePred) dummy(…); } }
    

Los últimos cinco intervalos de la tabla 4 muestran la sobrecarga adicional de llamar a los constructores base anidados.

Interlude: Demostración de CLR Profiler

Ahora, para obtener una demostración rápida de CLR Profiler. CLR Profiler, anteriormente conocido como El generador de perfiles de asignación, usa las API de generación de perfiles de CLR para recopilar datos de eventos, especialmente de llamada, devolución y asignación de objetos y eventos de recolección de elementos no utilizados, a medida que se ejecuta la aplicación. (CLR Profiler es un generador de perfiles "invasivo", lo que significa que desafortunadamente ralentiza considerablemente la aplicación con perfiles). Una vez recopilados los eventos, use CLR Profiler para explorar la asignación de memoria y el comportamiento de GC de la aplicación, incluida la interacción entre el grafo de llamadas jerárquico y los patrones de asignación de memoria.

CLR Profiler merece la pena aprender porque para muchas aplicaciones de código administrado "con desafío del rendimiento", comprender el perfil de asignación de datos proporciona la información crítica necesaria para reducir el conjunto de trabajo y, por tanto, ofrecer componentes y aplicaciones rápidos y frugales.

ClR Profiler también puede revelar qué métodos asignan más almacenamiento de lo esperado y pueden descubrir casos en los que se mantienen accidentalmente referencias a gráficos de objetos inútiles que, de lo contrario, podrían ser reclamados por GC. (Un patrón de diseño de problema común es una caché de software o una tabla de búsqueda de elementos que ya no son necesarios o que son seguros para reconstituirse más adelante. Es trágico cuando una memoria caché mantiene activos los gráficos de objetos más allá de su vida útil. En su lugar, asegúrese de anular las referencias a objetos que ya no necesite).

La figura 1 es una vista de escala de tiempo del montón durante la ejecución del controlador de prueba de tiempo. El patrón sawtooth indica la asignación de muchos miles de instancias de objetos C (magenta), D (púrpura) y E (azul). Cada pocos milisegundos, masticamos otro montón de 150 KB de RAM en el nuevo montón de objetos (generación 0) y el recolector de elementos no utilizados se ejecuta brevemente para reciclarlo y promover cualquier objeto activo a gen 1. Es notable que incluso en este entorno de generación de perfiles invasivo (lento), en el intervalo de 100 ms (de 2,8 s a 2,9s), nos sometemos a unos 8 ciclos de GC de generación 0. A continuación, a las 2,977 s, haciendo espacio para otra E instancia, el recolector de elementos no utilizados realiza una recolección de elementos no utilizados de generación 1, que recopila y compacta el montón de generación 1, y por lo que la sierra continúa, desde una dirección inicial inferior.

Figura1 Vista de línea de tiempo del generador de perfiles CLR

Observe que cuanto mayor sea el objeto (E mayor que D mayor que C), más rápido se llena el montón gen 0 y más frecuente será el ciclo de GC.

Conversiones y comprobaciones de tipo de instancia

La base básica de código administrado seguro, seguro y verificable es la seguridad de tipos. Si fuera posible convertir un objeto a un tipo que no lo es, sería sencillo poner en peligro la integridad del CLR y, por tanto, tenerlo a la misericordia del código que no es de confianza.

Tabla 5 Cast and isinst Times (ns)

Avg. Min Primitivo Avg. Min Primitivo
0,4 0,4 cast up 1 0.8 0.8 isinst up 1
0,3 0,3 cast down 0 0.8 0.8 isinst down 0
8,9 8.8 cast down 1 6.3 6.3 isinst down 1
9.8 9.7 cast (up 2) down 1 10,7 10.6 isinst (up 2) down 1
8,9 8.8 cast down 2 6.4 6.4 isinst down 2
8,7 8,6 cast down 3 6.1 6.1 isinst down 3

En la tabla 5 se muestra la sobrecarga de estas comprobaciones de tipo obligatorias. Una conversión de un tipo derivado a un tipo base siempre es segura y libre; mientras que una conversión de un tipo base a un tipo derivado debe comprobarse por tipo.

Una conversión (activada) convierte la referencia de objeto al tipo de destino o inicia InvalidCastException.

En cambio, la isinst instrucción CIL se usa para implementar la palabra clave de C# as :

bac = ac as B;

Si ac no B es o se deriva de B, el resultado es null, no una excepción.

La lista 2 muestra uno de los bucles de control de tiempo de conversión y desensamblar 9 muestra el código generado para una conversión a un tipo derivado. Para realizar la conversión, el compilador emite una llamada directa a una rutina auxiliar.

Enumeración de 2 bucles para probar el tiempo de conversión

public static void castUp2Down1(int n) {
    A ac = c; B bd = d; C ce = e; D df = f;
    B bac = null; C cbd = null; D dce = null; E edf = null;
    for (n /= 8; --n >= 0; ) {
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
    }
}

Desensamblar 9 conversión hacia abajo

               bac = (B)ac;
0000002e 8B D5            mov         edx,ebp 
00000030 B9 40 73 3E 00   mov         ecx,3E7340h 
00000035 E8 32 A7 4E 72   call        724EA76C 

Propiedades

En el código administrado, una propiedad es un par de métodos, un captador de propiedades y un establecedor de propiedades, que actúan como un campo de un objeto. El método get_ captura la propiedad ; el método set_ actualiza la propiedad a un nuevo valor.

Aparte de eso, las propiedades se comportan y el costo, al igual que los métodos de instancia normales y los métodos virtuales. Si usa una propiedad para capturar o almacenar simplemente un campo de instancia, normalmente se inserta, como con cualquier método pequeño.

En la tabla 6 se muestra el tiempo necesario para capturar (y agregar) y para almacenar, un conjunto de campos y propiedades de instancia de enteros. El costo de obtener o establecer una propiedad es realmente idéntico al acceso directo al campo subyacente, a menos que la propiedad se declare virtual, en cuyo caso el costo es aproximadamente el de una llamada de método virtual. No hay sorpresa allí.

Tabla 6 Tiempos de campo y propiedad (ns)

Avg. Min Primitivo
1.0 1.0 obtener campo
1.2 1.2 get prop
1.2 1.2 set field
1.2 1.2 set prop
6.4 6.3 obtener la propiedad virtual
6.4 6.3 establecer la propiedad virtual

Barreras de escritura

El recolector de elementos no utilizados CLR aprovecha la "hipótesis generacional" (la mayoría de los nuevos objetos mueren jóvenes) para minimizar la sobrecarga de recolección.

El montón se divide lógicamente en generaciones. Los objetos más recientes residen en la generación 0 (generación 0). Estos objetos aún no han sobrevivido a una colección. Durante una colección gen 0, GC determina qué, si existe, objetos gen 0 son accesibles desde el conjunto raíz de GC, que incluye referencias de objeto en registros de máquina, en la pila, referencias a objetos de campo estático de clase, etc. Los objetos accesibles transitivamente son "activos" y se promueven (copiados) a la generación 1.

Dado que el tamaño total del montón puede ser de cientos de MB, mientras que el tamaño del montón de generación 0 puede ser solo de 256 KB, limitar la extensión del seguimiento del gráfico de objetos del GC al montón gen 0 es una optimización esencial para lograr los tiempos de pausa de recopilación muy breves de CLR.

Sin embargo, es posible almacenar una referencia a un objeto gen 0 en un campo de referencia de objeto de un objeto gen 1 o gen 2. Dado que no examinamos los objetos gen 1 o gen 2 durante una colección gen 0, si esa es la única referencia al objeto gen 0 especificado, ese objeto podría reclamarse erróneamente por GC. ¡No podemos dejar que eso suceda!

En su lugar, todos los almacenes en todos los campos de referencia de objetos del montón incurren en una barrera de escritura. Este es el código de contabilidad que anota eficazmente los almacenes de referencias de objetos de nueva generación en campos de objetos de generación anteriores. Estos campos de referencia de objetos antiguos se agregan al conjunto raíz de GC de gc posteriores.

La sobrecarga de barrera de escritura por objeto-reference-field-store es comparable al costo de una llamada de método simple (tabla 7). Se trata de un nuevo gasto que no está presente en el código nativo de C/C++, pero normalmente es un pequeño precio para pagar la asignación de objetos super rápido y GC, y las muchas ventajas de productividad de la administración automática de memoria.

Tabla 7 Tiempo de barrera de escritura (ns)

Avg. Min Primitivo
6.4 6.4 barrera de escritura

Las barreras de escritura pueden ser costosas en bucles internos ajustados. Sin embargo, en años, podemos esperar técnicas avanzadas de compilación que reduzcan el número de barreras de escritura tomadas y el costo amortizado total.

Es posible que piense que las barreras de escritura solo son necesarias en almacenes para los campos de referencia de objetos de tipos de referencia. Sin embargo, dentro de un método de tipo de valor, los almacenes en sus campos de referencia de objeto (si los hay) también están protegidos por barreras de escritura. Esto es necesario porque el propio tipo de valor a veces se puede incrustar dentro de un tipo de referencia que reside en el montón.

Acceso al elemento Array

Para diagnosticar e impedir errores de matriz fuera de límite y daños en el montón, y proteger la integridad del propio CLR, se comprueban los límites de carga y almacenes de elementos de matriz, lo que garantiza que el índice está dentro del intervalo [0,array". Length-1] inclusive o throwing IndexOutOfRangeException.

Nuestras pruebas miden el tiempo para cargar o almacenar elementos de una int[] matriz y una A[] matriz. (Tabla 8).

Tabla 8 Tiempos de acceso de matriz (ns)

Avg. Min Primitivo
1,9 1,9 cargar elem de matriz int
1,9 1,9 almacenar elem de matriz int
2.5 2.5 cargar obj array elem
16.0 16.0 almacenar obj array elem

La comprobación de límites requiere comparar el índice de matriz con la matriz implícita. Campo de longitud. Como se muestra el desensamblado 10, en solo dos instrucciones comprobamos que el índice no es menor que 0 ni mayor o igual que la matriz. Longitud: si es así, bifurcamos a una secuencia fuera de línea que produce la excepción. Lo mismo ocurre con las cargas de elementos de matriz de objetos y para almacenes en matrices de ints y otros tipos de valor simples. (Load obj array elem time es (insignificantemente) más lento debido a una ligera diferencia en su bucle interno.

Desensamblado 10 Elemento de matriz int load

                          ; i in ecx, a in edx, sum in edi
               sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4] ; compare i and array.Length
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] 
…                         ; throw IndexOutOfRangeException
00000042 33 C9            xor         ecx,ecx 
00000044 E8 52 78 52 72   call        7252789B 

A través de sus optimizaciones de calidad de código, el compilador JIT a menudo elimina las comprobaciones de límites redundantes.

Recordando las secciones anteriores, podemos esperar que los almacenes de elementos de matriz de objetos sean considerablemente más costosos. Para almacenar una referencia de objeto en una matriz de referencias de objeto, el tiempo de ejecución debe:

  1. check array index is in bounds;
  2. check object es una instancia del tipo de elemento de matriz;
  3. realice una barrera de escritura (teniendo en cuenta cualquier referencia de objeto intergeneracional de la matriz al objeto ).

Esta secuencia de código es bastante larga. En lugar de emitirlo en cada sitio de almacén de matrices de objetos, el compilador emite una llamada a una función auxiliar compartida, como se muestra en Desensamblado 11. Esta llamada, además de estas tres acciones, tiene en cuenta el tiempo adicional necesario en este caso.

Desensamblado 11 Elemento de matriz de objetos store

                          ; objarray in edi
                          ; obj      in ebx
               objarray[1] = obj;
00000027 53               push        ebx  
00000028 8B CF            mov         ecx,edi 
0000002a BA 01 00 00 00   mov         edx,1 
0000002f E8 A3 A0 4A 72   call        724AA0D7   ; store object array element helper

Conversión boxing y conversión unboxing

Una asociación entre compiladores de .NET y CLR permite que los tipos de valor, incluidos los tipos primitivos como int (System.Int32), participen como si fueran tipos de referencia, para abordarlos como referencias de objeto. Esta prestación , este azúcar sintáctico, permite que los tipos de valor se pasen a métodos como objetos, almacenados en colecciones como objetos, etc.

Para "box" un tipo de valor es crear un objeto de tipo de referencia que contiene una copia de su tipo de valor. Conceptualmente, es lo mismo que crear una clase con un campo de instancia sin nombre del mismo tipo que el tipo de valor.

Para "unboxar" un tipo de valor con conversión boxing es copiar el valor, desde el objeto, en una nueva instancia del tipo de valor.

Como se muestra en la tabla 9 (en comparación con la tabla 4), el tiempo amortizado necesario para boxear un valor int y, posteriormente, para recopilarlo, es comparable al tiempo necesario para crear una instancia de una clase pequeña con un campo int.

Tabla 9 Cuadro y Unbox int Times (ns)

Avg. Min Primitivo
29,0 21.6 box int
3.0 3.0 unbox int

Para unboxar un objeto int boxed requiere una conversión explícita en int. Esto se compila en una comparación del tipo del objeto (representado por su dirección de tabla de métodos) y la dirección de tabla del método int boxed. Si son iguales, el valor se copia fuera del objeto . De lo contrario, se produce una excepción. Consulte Desensamblado 12.

Desensamblado 12 Caja y unbox int

box               object o = 0;
0000001a B9 08 07 B9 79   mov         ecx,79B90708h 
0000001f E8 E4 A5 6C F9   call        F96CA608 
00000024 8B D0            mov         edx,eax 
00000026 C7 42 04 00 00 00 00 mov         dword ptr [edx+4],0 

unbox               sum += (int)o;
00000041 81 3E 08 07 B9 79 cmp         dword ptr [esi],79B90708h ; "type == typeof(int)"?
00000047 74 0C            je          00000055 
00000049 8B D6            mov         edx,esi 
0000004b B9 08 07 B9 79   mov         ecx,79B90708h 
00000050 E8 A9 BB 4E 72   call        724EBBFE                   ; no, throw exception
00000055 8D 46 04         lea         eax,[esi+4]
00000058 3B 08            cmp         ecx,dword ptr [eax] 
0000005a 03 38            add         edi,dword ptr [eax]        ; yes, fetch int field

Delegados

En C, un puntero a la función es un tipo de datos primitivo que almacena literalmente la dirección de la función.

C++ agrega punteros a funciones miembro. Un puntero a la función miembro (PMF) representa una invocación de función miembro diferida. La dirección de una función miembro no virtual puede ser una dirección de código simple, pero la dirección de una función miembro virtual debe representar una llamada de función miembro virtual determinada, la desreferencia de este tipo de PMF es una llamada de función virtual.

Para desreferenciar un PMF de C++, debe proporcionar una instancia:

    A* pa = new A;
    void (A::*pmf)() = &A::af;
    (pa->*pmf)();

Hace años, en el equipo de desarrollo del compilador de Visual C++, solíamos preguntarnos, ¿qué tipo de bestia es la expresión pa->*pmf desnuda (operador de llamada de función sans)? Lo llamamos un puntero enlazado a la función miembro , pero la llamada de función miembro latente es igual que apt.

Volviendo al terreno del código administrado, un objeto delegado es simplemente eso: una llamada al método latente. Un objeto delegado representa el método al que llamar y la instancia en la que se llamará, o para un delegado a un método estático, solo el método estático al que se va a llamar.

(Como indica nuestra documentación: una declaración de delegado define un tipo de referencia que se puede usar para encapsular un método con una firma específica. Una instancia de delegado encapsula un método estático o de instancia. Los delegados son aproximadamente similares a los punteros de función en C++; sin embargo, los delegados son seguros para tipos y seguros).

Los tipos delegados de C# son tipos derivados de MulticastDelegate. Este tipo proporciona una semántica enriquecida, incluida la capacidad de crear una lista de invocaciones de pares (object,method) que se invocarán al invocar el delegado.

Los delegados también proporcionan una instalación para la invocación de método asincrónico. Después de definir un tipo delegado y crear una instancia de uno, inicializado con una llamada de método latente, puede invocarlo de forma sincrónica (sintaxis de llamada de método) o de forma asincrónica, a través de BeginInvoke. Si BeginInvoke se llama a , el tiempo de ejecución pone en cola la llamada y vuelve inmediatamente al autor de la llamada. El método de destino se llama más adelante en un subproceso del grupo de subprocesos.

Todas estas ricas semánticas no son baratas. Comparar la tabla 10 y la tabla 3, tenga en cuenta que la invocación de delegado es ** aproximadamente ocho veces más lenta que una llamada de método. Espere que mejore con el tiempo.

Tabla 10 Tiempo de invocación del delegado (ns)

Avg. Min Primitivo
41.1 40.9 invocación de delegado

Errores de caché, errores de página y arquitectura de equipo

De vuelta en los "buenos días antiguos", circa 1983, los procesadores eran lentos (~.5 millones de instrucciones/s) y relativamente hablando, RAM era lo suficientemente rápido pero pequeño (~300 ns tiempos de acceso en 256 KB de DRAM), y los discos eran lentos y grandes (~25 ms de acceso en discos de 10 MB). Los microprocesadores de PC eran CISC escalares, la mayoría de los puntos flotantes estaban en software y no había memorias caché.

Después de veinte años más de la Ley de Moore, circa 2003, los procesadores son rápidos (emitiendo hasta tres operaciones por ciclo a 3 GHz), la RAM es relativamente lenta (~100 ns tiempos de acceso en 512 MB de DRAM), y los discos son lentos y enormes (~10 ms de acceso en discos de 100 GB). Los microprocesadores de PC ahora están fuera de orden de los flujos de datos superescalar hiperprocesos de caché de seguimiento (ejecutando instrucciones CISC descodificadas) y hay varias capas de caché, por ejemplo, un microprocesador orientado al servidor tiene 32 KB de caché de datos de nivel 1 (quizás 2 ciclos de latencia), caché de datos L2 de 512 KB y caché de datos L3 de 2 MB (quizás una docena de ciclos de latencia), todo en chip.

En los buenos días antiguos, podría, y a veces, contar los bytes del código que escribió y contar el número de ciclos que el código necesitaba para ejecutarse. Una carga o almacén tomó aproximadamente el mismo número de ciclos que una adición. El procesador moderno usa la predicción de rama, la especulación y la ejecución desordenada (flujo de datos) en varias unidades de función para encontrar paralelismo de nivel de instrucción, por lo que avanza en varios frentes a la vez.

Ahora nuestros equipos más rápidos pueden emitir hasta aproximadamente 9000 operaciones por microsegundos, pero en ese mismo microsegundo, solo cargar o almacenar en líneas de caché drAM ~10. En los círculos de arquitectura del equipo esto se conoce como golpear la pared de memoria. Las memorias caché ocultan la latencia de memoria, pero solo hasta un punto. Si el código o los datos no caben en la memoria caché o presentan una localidad de referencia deficiente, nuestro jet supersónico de 9000 operaciones por microsegundos se degenera en un tricycle de carga por microsegundo.

Y (no dejes que esto suceda) si el conjunto de trabajo de un programa supera la RAM física disponible, y el programa comienza a tomar errores de página difíciles, después en cada servicio de error de página de 10 000 microsegundos (acceso al disco), se pierde la oportunidad de acercar al usuario hasta 90 millones de operaciones a su respuesta. Eso es tan horrible que confío en que usted se encargará de medir el conjunto de trabajo (vadump) y usar herramientas como CLR Profiler para eliminar asignaciones innecesarias y retención involuntarias de grafos de objetos.

¿Pero qué tiene que ver todo esto con conocer el costo de los primitivos de código administrado?Todo*.*

Recordando la tabla 1, la lista de horas primitivas de código administrado, medida en un P-III de 1,1 GHz, observa que cada vez, incluso el costo amortizado de asignar, inicializar y reclamar un objeto de cinco campos con cinco niveles de llamadas explícitas al constructor, es más rápido que un único acceso DRAM. Solo una carga que pierda todos los niveles de caché en chip puede tardar más tiempo en atenderse que casi cualquier operación de código administrado única.

Por lo tanto, si le encanta la velocidad del código, es imperativo que considere y mida la jerarquía de memoria y caché a medida que diseñe e implemente los algoritmos y las estructuras de datos.

Tiempo para una demostración sencilla: ¿Es más rápido sumar una matriz de ints o sumar una lista vinculada equivalente de ints? ¿Qué, cuánto, y por qué?

Piense en ello durante un minuto. En el caso de elementos pequeños como ints, la superficie de memoria por elemento de matriz es una cuarta parte de la lista vinculada. (Cada nodo de lista vinculada tiene dos palabras de sobrecarga de objetos y dos palabras de campos (siguiente vínculo e elemento int). Esto dañará el uso de la memoria caché. Puntuar uno para el enfoque de matriz.

Pero el recorrido de la matriz podría incurrir en una comprobación de límites de matriz por elemento. Acabas de ver que la comprobación de límites tarda un poco de tiempo. ¿Quizás eso le dé consejos a las escalas en favor de la lista vinculada?

Desensamblado 13 Matriz de enteros de suma frente a suma de lista vinculada

sum int array:            sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4]       ; bounds check
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] ; load array elem
               for (int i = 0; i < m; i++)
0000002d 41               inc         ecx  
0000002e 3B CE            cmp         ecx,esi 
00000030 7C F2            jl          00000024 


sum int linked list:         sum += l.item; l = l.next;
0000002a 03 70 08         add         esi,dword ptr [eax+8] 
0000002d 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000030 03 70 08         add         esi,dword ptr [eax+8] 
00000033 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000036 03 70 08         add         esi,dword ptr [eax+8] 
00000039 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
0000003c 03 70 08         add         esi,dword ptr [eax+8] 
0000003f 8B 40 04         mov         eax,dword ptr [eax+4] 
               for (m /= 4; --m >= 0; ) {
00000042 49               dec         ecx  
00000043 85 C9            test        ecx,ecx 
00000045 79 E3            jns         0000002A 

Referencia al desensamblado 13, he apilado la baraja en favor del recorrido de la lista vinculada, desenrollándolo cuatro veces, incluso quitando la comprobación habitual de fin de lista del puntero nulo. Cada elemento del bucle de matriz requiere seis instrucciones, mientras que cada elemento del bucle de lista vinculada solo necesita 11/4 = 2,75 instrucciones. ¿Ahora qué supone que es más rápido?

Condiciones de prueba: en primer lugar, cree una matriz de un millón de ints y una lista simple y tradicional vinculada de un millón de ints (1 M list nodes). A continuación, el tiempo que tarda, por elemento, en agregar los primeros 1000, 10 000, 100 000, 100 000 y 1000 000 elementos. Repita cada bucle muchas veces para medir el comportamiento de caché más plano para cada caso.

¿Qué es más rápido? Después de adivinar, consulte las respuestas: las ocho últimas entradas de la tabla 1.

Interesante. Las veces se ralentizan considerablemente a medida que los datos a los que se hace referencia crecen más que los tamaños de caché sucesivos. La versión de la matriz siempre es más rápida que la versión de la lista vinculada, aunque se ejecute el doble de instrucciones; para 100 000 elementos, la versión de la matriz es siete veces más rápida.

¿Por qué ocurre esto? En primer lugar, menos elementos de lista vinculados caben en cualquier nivel determinado de caché. Todos esos encabezados de objeto y vinculan el espacio de desecho. En segundo lugar, nuestro procesador de flujo de datos moderno fuera de orden puede ampliar potencialmente y avanzar en varios elementos de la matriz al mismo tiempo. En cambio, con la lista vinculada, hasta que el nodo de lista actual está en caché, el procesador no puede empezar a capturar el siguiente vínculo al nodo después de eso.

En el caso de los 100 000 elementos, el procesador está gastando (en promedio) aproximadamente (22-3,5)/22 = el 84 % de su tiempo girando sus pulgas a la espera de que se lea la línea de caché de algún nodo de lista desde DRAM. Eso suena malo, pero las cosas podrían ser mucho peores. Dado que los elementos de lista vinculados son pequeños, muchos de ellos caben en una línea de caché. Dado que recorremos la lista en orden de asignación y, dado que el recolector de elementos no utilizados conserva el orden de asignación incluso cuando compacta los objetos fallidos fuera del montón, es probable que, después de capturar un nodo en una línea de caché, los siguientes nodos también estén en caché. Si los nodos eran mayores o si los nodos de lista estaban en un orden de direcciones aleatorios, cada nodo visitado podría ser un error de caché completa. Agregar 16 bytes a cada nodo de lista duplica el tiempo de recorrido por elemento a 43 ns; +32 bytes, 67 ns/item; y al agregar de nuevo 64 bytes, a 146 ns/item, probablemente la latencia media de DRAM en el equipo de prueba.

¿Qué es la lección de toma aquí? ¿Evitar listas vinculadas de 100 000 nodos? No. La lección es que los efectos de la memoria caché pueden dominar cualquier consideración de baja eficacia de nivel de código administrado frente al código nativo. Si está escribiendo código administrado crítico para el rendimiento, especialmente el código que administra grandes estructuras de datos, tenga en cuenta los efectos de caché, piense en los patrones de acceso a la estructura de datos y se esfuerza por las superficies de datos más pequeñas y una buena localidad de referencia.

Por cierto, la tendencia es que la pared de memoria, la proporción del tiempo de acceso de DRAM dividida por tiempo de operación de CPU, seguirá creciendo peor a lo largo del tiempo.

Estas son algunas reglas de "diseño consciente de la memoria caché":

  • Experimente con, y mida, sus escenarios porque es difícil predecir los efectos de segundo orden y, debido a que las reglas generales no valen la pena el papel en el que se imprimen.
  • Algunas estructuras de datos, ejemplificadas por matrices, usan la adyacencia implícita para representar una relación entre los datos. Otros, ejemplificados por listas vinculadas, usan punteros explícitos (referencias) para representar la relación. Por lo general, la adyacencia implícita es preferible: la "implícitaidad" ahorra espacio en comparación con los punteros; y la adyacencia proporcionan una localidad estable de referencia y pueden permitir que el procesador comience más trabajo antes de perseguir el siguiente puntero.
  • Algunos patrones de uso favorecen estructuras híbridas: listas de matrices pequeñas, matrices de matrices o árboles B.
  • Quizás los algoritmos de programación sensibles al acceso al disco, diseñados de nuevo cuando el acceso al disco cuesta solo 50 000 instrucciones de CPU, se debe reciclar ahora que los accesos drAM pueden tomar miles de operaciones de CPU.
  • Dado que el recolector de elementos no utilizados clR de marcas y compactos conserva el orden relativo de los objetos, los objetos asignados juntos en el tiempo (y en el mismo subproceso) tienden a permanecer juntos en el espacio. Puede usar este fenómeno para combinar cuidadosamente los datos cliquish en líneas de caché comunes.
  • Es posible que quiera particionar los datos en partes activas que se atraviesan con frecuencia y que deben caber en la memoria caché, y partes inactivas que se usan con poca frecuencia y que se pueden "almacenar en caché".

Experimentos de tiempo de hágalo usted mismo

Para las mediciones de tiempo de este documento, usé el contador QueryPerformanceCounter de rendimiento de alta resolución win32 (y QueryPerformanceFrequency).

Se les llama fácilmente a través de P/Invoke:

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceCounter(
        ref long lpPerformanceCount);

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceFrequency(
        ref long lpFrequency);

QueryPerformanceCounter Llame justo antes y justo después del bucle de tiempo, reste recuentos, multiplique por 1,0e9, divida por frecuencia, divida por número de iteraciones y ese es el tiempo aproximado por iteración en ns.

Debido a las restricciones de espacio y tiempo, no hemos cubierto el bloqueo, el control de excepciones ni el sistema de seguridad de acceso al código. Considere un ejercicio para el lector.

Por cierto, he producido los ensamblados de este artículo con la ventana Desensamblado en VS.NET 2003. Sin embargo, hay un truco. Si ejecuta la aplicación en el depurador de VS.NET, incluso como un ejecutable optimizado integrado en modo de versión, se ejecutará en "modo de depuración" en el que se deshabilitan las optimizaciones como la inserción. La única manera en que encontré obtener un vistazo al código nativo optimizado que emite el compilador JIT era iniciar mi aplicación de prueba fuera del depurador y, a continuación, asociarla con Debug.Processes.Attach.

¿Un modelo de costo de espacio?

Las consideraciones espaciales impiden una discusión exhaustiva del espacio. Unos cuantos párrafos breves, entonces.

Consideraciones de bajo nivel (varias son C# (typeAttributes.SequentialLayout predeterminada) y x86 específicas):

  • El tamaño de un tipo de valor suele ser el tamaño total de sus campos, con campos de 4 bytes o más pequeños alineados con sus límites naturales.
  • Es posible usar [StructLayout(LayoutKind.Explicit)] atributos y [FieldOffset(n)] para implementar uniones.
  • El tamaño de un tipo de referencia es de 8 bytes más el tamaño total de sus campos, redondeado hasta el siguiente límite de 4 bytes y con campos de 4 bytes o más pequeños alineados con sus límites naturales.
  • En C#, las declaraciones de enumeración pueden especificar un tipo base entero arbitrario (excepto char), por lo que es posible definir enumeraciones de 8 bits, de 16 bits, de 32 bits y de 64 bits.
  • Como en C/C++, a menudo puede afeitar unas decenas de por ciento de espacio fuera de un objeto más grande mediante el ajuste de tamaño de los campos enteros de forma adecuada.
  • Puede inspeccionar el tamaño de un tipo de referencia asignado con CLR Profiler.
  • Los objetos grandes (muchas docenas de KB o más) se administran en un montón de objetos grandes independiente para impedir la copia costosa.
  • Los objetos finalizables toman una generación de GC adicional para reclamar, úselas con moderación y considere la posibilidad de usar el patrón Dispose.

Consideraciones de imagen general:

  • Actualmente, cada AppDomain incurre en una sobrecarga de espacio considerable. Muchas estructuras de runtime y Framework no se comparten entre AppDomains.
  • Dentro de un proceso, el código jitted normalmente no se comparte entre AppDomains. Si el entorno de ejecución se hospeda específicamente, es posible invalidar este comportamiento. Consulte la documentación de CorBindToRuntimeEx y la STARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN marca .
  • En cualquier caso, el código jitted no se comparte entre procesos. Si tiene un componente que se cargará en muchos procesos, considere la posibilidad de precompilar con NGEN para compartir el código nativo.

Reflexión

Se ha dicho que "si tienes que preguntar qué costos de reflexión, no puedes permitirlo". Si ha leído hasta ahora, sabe lo importante que es preguntar qué cuestan las cosas y medir esos costos.

La reflexión es útil y eficaz, pero en comparación con el código nativo jitted, no es rápido ni pequeño. Te han avisado. Medirlo por ti mismo.

Conclusión

Ahora ya sabe (más o menos) qué costos de código administrado cuesta en el nivel más bajo. Ahora tiene los conocimientos básicos necesarios para que la implementación sea más inteligente y escriba código administrado más rápido.

Hemos visto que el código administrado jitted puede ser como "pedal al metal" como código nativo. Su desafío es codificar con sabiduría y elegir sabiamente entre las muchas instalaciones enriquecidas y fáciles de usar en el marco

Hay configuraciones en las que el rendimiento no importa y la configuración en la que es la característica más importante de un producto. La optimización prematura es la raíz de todo mal. Pero, por lo tanto, la inatencion sin cuidado a la eficiencia. Eres un profesional, un artista, un artesano. Así que asegúrese de que conoce el costo de las cosas. Si no sabes o incluso si crees que lo haces, lo mide regularmente.

En cuanto al equipo de CLR, seguimos trabajando para proporcionar una plataforma que es sustancialmente más productiva que el código nativo y, sin embargo, es más rápido que el código nativo. Espera que las cosas se mejore y mejor. Permanezca atento.

Recuerda tu promesa.

Recursos