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 I
de 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 falsePred
de 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 s1
está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.
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.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
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:
- check array index is in bounds;
- check object es una instancia del tipo de elemento de matriz;
- 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 laSTARTUP_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
- David Stutz et al, Shared Source CLI Essentials. O'Reilly y Assoc., 2003. ISBN 059600351X.
- Jan Gray, C++: Bajo la capucha.
- Gregor Noriskin, Escribir High-Performance aplicaciones administradas: A Primer, MSDN.
- Rico Mariani, Conceptos básicos del recolector de elementos no utilizados y sugerencias de rendimiento, MSDN.
- Manuel Schanzer, sugerencias de rendimiento y trucos en aplicaciones .NET, MSDN.
- Manuel Schanzer, Consideraciones de rendimiento para Run-Time Tecnologías en .NET Framework, MSDN.
- vadump (Platform SDK Tools), MSDN.
- .NET Show, [Administrado] Optimización de código, 10 de septiembre de 2002, MSDN.