Escritura de código administrado más rápido: saber qué cuestan 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 el 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 increíblemente rápidas y algunas son sorprendentemente lentas.
No perpetrar código lento y gordo en el mundo. ¿No despies este código? ¿El código que se ejecuta se ajusta 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 hagas. En su lugar, se levanta y promete 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 hacerlo. Es mi responsabilidad".
(Realmente).) ¿Así que prometiste? Bien por ti.
¿Cómo escribir el día más rápido y más estricto del código? Es una cuestión de elegir conscientemente la forma frugal en preferencia a la manera extravagante, inflada, 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é cuestan las cosas.
Era más fácil en los buenos días. Buenos programadores de C sabían. Cada operador y operación en C, ya sea asignación, entero o matemática de punto flotante, 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 podía capturar varias operaciones de C (famosamente *dest++ = *src++;
), pero normalmente podí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 escribe es lo que obtiene". (La excepción era y es, las llamadas a función. Si no sabe cuál es el costo de la función, no lo sabe.
En la 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 "pago 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 aplicable directamente. Si toma algún código 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, eliminadores, 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 indirectos adicionales por llamada y agregan un campo de puntero de vtable oculto a cada instancia. O considere que este código inocuo:
{ complex a, b, c, d; … a = b + c * d; }
se compila en aproximadamente trece llamadas de función miembro implícitas (esperamos que se inserte).
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 pregunta de "¿Qué está haciendo el compilador aquí?"; otorga confianza para usar 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 similar. En este artículo se exploran las costos de tiempo y espacio de de bajo nivel de ejecución administrada, por lo que podemos hacer ventajas más inteligentes en nuestra 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 todas las categorías de errores, como daños en el montón y errores de índice de matriz no enlazados que a menudo conducen a sesiones de depuración nocturna frustrantes. Admite requisitos modernos, como código móvil seguro (a través de seguridad de acceso a código) y servicios web XML, y en comparación con el envejecimiento Win32/COM/ATL/MFC/VB, .NET Framework es un diseño de pizarra limpia refrescante, donde puede hacerse más con menos esfuerzo.
Para la comunidad de usuarios, el código administrado permite aplicaciones más completas y sólidas, mejor vivir 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 nena en tierra de código administrado. Todavía estamos aprendiendo las cuerdas, incluyendo lo que cuestan las cosas.
Cuando se trata de la plataforma .NET Framework rica y conveniente, es como si fueramos niños en la tienda de dulces. "Wow, no tengo que hacer todo lo tedioso strncpy
cosas, sólo puedo '+' cadenas juntos! ¡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 grabar 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 una gulp. Tal vez incluso lo hagas por encima y por encima. Entonces quizá la aplicación ya no parezca tan rápida. Tal vez tenga un conjunto de trabajo de muchos megabytes. Tal vez 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 para cubrir o documentar; pero aún así, el "no saber" hace que sea mucho más difícil para nosotros tomar decisiones fundamentadas.
Puesto que estamos todos los nuevos aquí, y como no sabemos qué cuesta nada, y dado que los costos no están claramente documentados, ¿qué hacemos?
Medirlo. El secreto consiste en medirlo y estar atento. Todos tendremos que entrar en el hábito de 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 asumimos.
(Por cierto, para obtener información más profunda 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 de 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 pensativo de Rotor le proporcionará nuevas conclusiones sobre las novedades bajo la capucha del CLR. Pero asegúrese de revisar primero la licencia SSCLI).
El conocimiento
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 en Londres y aprender las mejores rutas desde el lugar hasta el lugar. 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 está usando 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, ay. Tienes que salir en tu scooter y explorar, es decir, csc, ildasm, el depurador de VS.NET, clR Profiler, tu generador de perfiles, algunos temporizadores de rendimiento, etc., y ver cuál es el costo del código en el tiempo y el espacio.
Hacia un modelo de costo para código administrado
Aparte de las preliminares, consideremos un modelo de costo para código administrado. De este modo podrá ver 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 a otro artículo en otro día).
Anteriormente dije que la mayoría del modelo de costo de C todavía se aplica en escenarios de C++. De forma similar, gran parte del modelo de costos de C/C++ todavía se aplica al código administrado.
¿Cómo puede ser eso? Conoce el modelo de ejecución de CLR. Escribe el código en uno de varios idiomas. Se compila en formato CIL (lenguaje intermedio común), empaquetado en ensamblados. Ejecute el ensamblado de aplicación principal y empiece a ejecutar la CIL. ¿Pero no es ese un orden de magnitud más lento, 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 en 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 interpretativa.
A diferencia de un proceso de compilación de C++ fuera de línea tradicional, 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:
- Plegado constante
- Propagación constante y copia
- Eliminación de subexpresión común
- Movimiento de código de invariantes de bucle
- Eliminación de código no enviados y de almacenamiento fallido
- Registrar 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 estructuras están en C/C++. Al igual que con C/C++, probablemente debe evitar 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 para el costo de alguna expresión en código C nativo, no pasará mucho mal el modelado de su costo como equivalente en código administrado.
También debo mencionar NGEN, una herramienta que "antes de 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
).
Administración automática de memoria
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, generalmente deteniendo la aplicación por solo milisegundos o dos, ocasionalmente más largos.
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 a los new
de objetos de C++ tradicionales y delete
. El costo amortizado de crear y recuperar automáticamente un objeto es lo suficientemente bajo que puede crear decenas de millones de objetos pequeños por segundo.
Pero la asignación de objetos sigue sin . Los objetos ocupan espacio. La asignación deobjetoss conduce a ciclos de recolección de elementos no utilizados más frecuentes.
Mucho peor, conservar innecesariamente las referencias a gráficos de objetos inútiles las mantiene activas. A veces vemos programas modestos con conjuntos de trabajo lamentables de más de 100 MB, cuyos autores deniegan 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 conjunto de trabajo grande, el primer paso es mirar en el espejo.
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 tienden a crear más y más objetos, como si crecen en árboles. Si desea escribir código administrado realmente 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 clientes para crear nuevos objetos con abandono salvaje. No hagas eso.
Qué costo de las cosas en código administrado
Ahora consideremos 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 de 1,1 GHz inactivo Pentium-III 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, escaladas 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 desenrolla de 4 a 64 veces según sea necesario para disminuir 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 activos los resultados intermedios durante y después del bucle de prueba. De forma similar, he realizado cambios para impedir la eliminación de subexpresión común en varias pruebas.
tabla 1 Tiempos primitivos (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 | Int sub | 6.4 | 6.4 | nuevo valtype L3 | 6.3 | 6.3 | isinst down 1 |
2.7 | 2.7 | Int mul | 8.0 | 8.0 | new 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 | div long | 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 | establecimiento de 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 | load int array elem |
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 | load obj array elem |
6.1 | 6.1 | llamada estática | 28.6 | 26.7 | new reftype ctor no-inl L1 | 16.0 | 16.0 | store 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 | inlined this inst call | 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 | 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 | suma matriz 1000000 |
6.6 | 6.5 | llamada de interfaz | 8.9 | 8.8 | cast down 1 | 5.6 | 5.6 | suma matriz 10000000 |
1.1 | 1.0 | llamada a la instancia de itf inst | 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 1000000 |
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 riesgo de efectos inesperados de segundo orden. Una posibilidad de que ocurra podría colocar el código jitted o algunos datos cruciales, de modo que abarque líneas de caché, interfiera con otra cosa o con lo que tiene. Es un poco como el principio de incertidumbre: las diferencias de tiempo y tiempos 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é de 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 documento.
Y otra declinación de responsabilidades: una de las ventajas sublimes de enviar sus componentes y 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) retune el código compilado JIT a medida que se ejecuta el programa; y "más rápido cada año", 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 óptimos en .NET 1.1, comente que deben mejorar en versiones posteriores del producto. Sigue que cualquier secuencia de código nativa 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", al igual que lo hace el código nativo compilado. Las operaciones de entero y flotante primitivas son rápidas, llamadas de método de varios tipos menos, pero (confiar en mí) todavía 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é? Veamos.
Operaciones aritméticas
tabla 2 Tiempos de operación aritméticos (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 | div long | 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 del entero y el punto flotante agregar pruebas:
desensamblado 1 Agregar 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 de h
estáticas de clase para derrotar la eliminación de subexpresión común.
Llamadas a métodos
En esta sección se examinan los costos e implementaciones de las llamadas a métodos. El asunto de la prueba es una clase T
implementación de la interfaz I
, con varios tipos de métodos. Consulte La lista 1.
Enumeración de los métodos de prueba de llamada al método 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. Aparece , a una primera aproximación, un método se inserta (la abstracción no cuesta nada) o no (los costos de abstracción >5X una operación de entero). No parece haber una diferencia significativa en el costo sin procesar de una llamada estática, llamada de instancia, llamada virtual o llamada de interfaz.
tiempos de llamada de método (ns) de la tabla 3
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 itf inst | itf1 |
0.2 | 0.2 | inlined this inst call | 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 no son representativos mejores casos, el efecto de ejecutar bucles de tiempo ajustados millones de veces. En estos casos de prueba, los sitios de llamada de métodos de interfaz y virtual 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, una falta de caché de datos en cualquiera de los datos del mecanismo de envío, o una falta de seguridad de rama (ya sea una falta de capacidad obligatoria o un sitio de llamada polimórfico), puede ralentizar las llamadas virtuales e de interfaz por docenas de ciclos.
Echemos un vistazo más detenidamente 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 encuentra completamente insertado en todas las llamadas, terminamos sincronizando un bucle vacío.
Para medir el costo aproximado de una llamada de método estático de , hacemos que los métodos estáticos s1()
etc. tan grande que no sean aptos para insertarse en el autor de la llamada.
Observe que incluso tenemos que usar una variable de predicado falsa explícita falsePred
. Si escribimos
static void s1() { if (false) dummy(1, 2, 3, …, 16); }
El compilador JIT eliminaría la llamada inactiva a dummy
e inserte 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 estático llamado s1
. (Por cierto, una mejor manera de deshabilitar la inserción es el atributo CompilerServices.MethodImpl(MethodImplOptions.NoInlining)
).
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 estática t
como instancia, porque cuando usamos una variable local
T t = new T();
el compilador compiló la instancia nula desactive el bucle .
sitio de llamada de método de instancia de desensamblado 2 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 del insertado en esta llamada de instancia y esta llamada de instancia son iguales, excepto que la instancia es this
; aquí se ha elided la comprobación nula.
Desensamblado 3 Este de sitio de llamada de método de instancia
this.i1();
00000012 8B CE mov ecx,esi
00000014 E8 AF FE FF FF call FFFFFEC8
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 4 de desensamblado
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 llamadas a métodos de interfaz (Desensamblado 5). No tienen 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 indirectos a través de la entrada adecuada en la sección de la interfaz de la tabla de métodos.
desensamblar 5 del 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, llamada de instancia inst itf, esta llamada de instancia itf, llamada virtual inst itf, esta llamada virtual itf resaltar la idea de que cada vez que un método 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 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 inlineable cuando se llama como método de instancia.
Llamadas a métodos pero jitted
En el caso de las llamadas a métodos estáticos e de instancia (pero no a las llamadas a métodos virtuales y de interfaz), el compilador JIT genera actualmente secuencias de llamadas de método diferentes en función de si el método de destino ya se ha jitted en el momento en que su sitio de llamada está siendo jitted.
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 por primera vez 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 recolector de elementos no utilizados. En el caso de los tipos de valor, ya sean residentes en pila o insertados dentro de otra referencia o tipo de valor, el objeto de tipo de valor se encuentra en algún desplazamiento constante de la estructura envolvente, no se requiere asignación.
Para 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 bonito 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 y 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. La 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 fallidos a la próxima generación pendiente de finalización. Esto es costoso y, en el peor de los casos, puede promover de forma transitiva gráficos de objetos muertos 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 patrón Dispose, llamando a GC.SuppressFinalizer
siempre que sea posible). A menos que sea necesario para el método 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 para inicializarlo (construirlo). CLR garantiza que todas las referencias de objeto están preinicializadas en 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 sus almacenes redundantes).
Además de poner en cero los campos de instancia, CLR inicializa (solo tipos de referencia) los campos de implementación internos del objeto: el puntero de la 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 los campos Longitud y tipo de elemento.
A continuación, 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 una 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 D
con cuatro campos int en aproximadamente 8 int-add-times. El desensamblar 6 es el código generado a partir de tres bucles de control de tiempo diferentes, creando A' s, C's y E's. (Dentro de cada bucle, modificamos cada nueva instancia, lo que impide que el compilador JIT optimice todo.
tabla 4 Valores y tiempos de creación de objetos de tipo de referencia (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 | new 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 |
construcción de objetos de tipo de valor 6 desensamblar 6
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 siguientes cinco intervalos (nuevo reftype L1, ... new reftype L5) son para cinco niveles de herencia de tipos de referencia A
, ..., 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 liberación de cada instancia es de aproximadamente 20 ns (20X int add time) en la máquina 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. Vea Desensamblar 7.
construcción de objetos de tipo de referencia 7
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 últimos tres conjuntos de cinco intervalos presentan variaciones en este escenario de construcción de clase heredada.
New rt empty ctor L1, ..., new rt empty ctor L5: Each type
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.New rt ctor L1, ..., new rt ctor L5: Each type
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 en el sitio de new
. (Desensamblar 8).
Desensamblar 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
New rt no-inl L1, ..., new rt no-inl L5: Each type
A
, ...,E
tiene un constructor definido por el usuario que se ha escrito intencionadamente para ser demasiado caro para insertarse. Este escenario simula el costo de crear objetos complejos con jerarquías de herencia profunda y constructores rgish.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, se usa CLR Profiler para explorar la asignación de memoria y el comportamiento de GC de la aplicación, incluida la interacción entre el gráfico 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 de rendimiento", comprender el perfil de asignación de datos proporciona la información crítica necesaria para reducir el conjunto de trabajo y ofrecer componentes y aplicaciones frugales 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 inadvertidas las 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 problemas 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ágica cuando una memoria caché mantiene los gráficos de objetos activos 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 pruebas de control 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 ~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 bajo este entorno de generación de perfiles invasiva (lento), en el intervalo de 100 ms (de 2,8 s a 2,9s), nos sometemos a aproximadamente 8 ciclos de GC de generación 0. A continuación, a las 2,977 s, haciendo espacio para otra instancia de E
, 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 gen 1, por lo que la sierra continúa, desde una dirección inicial inferior.
vista línea de tiempo del generador de perfiles CLR figura 1
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 tipos de instancia
La base básica de seguridad, segura, verificable código administrado es la seguridad de tipos. Si fuera posible convertir un objeto a un tipo que no es, sería sencillo poner en peligro la integridad del CLR y así tenerlo a la misericordia del código que no es de confianza.
tabla 5 Conversión e isinst Times (ns)
Avg | Min | Primitivo | Avg | Min | Primitivo |
---|---|---|---|---|---|
0.4 | 0.4 | 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 tipos 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 con tipos.
Una conversión (activada) convierte la referencia de objeto al tipo de destino o produce InvalidCastException
.
Por el contrario, la instrucción CIL de isinst
se usa para implementar la palabra clave as
de C#:
bac = ac as B;
Si ac
no se B
ni se deriva de B
, el resultado es null
, no una excepción.
En la lista 2 se muestra uno de los bucles de tiempo de conversión y el desensamblaje 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.
lista de bucles 2 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 de 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 cuestan, al igual que lo hacen los métodos de instancia normales y los métodos virtuales. Si usa una propiedad para capturar o almacenar 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 al 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 | establecimiento de 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 muerenjó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, los 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 estáticos 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 cientos de MB, mientras que el tamaño del montón gen 0 puede ser solo de 256 KB, limitar la extensión del seguimiento de grafos 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 dado, 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). Es un nuevo gasto que no está presente en el código nativo de C/C++, pero normalmente es un precio pequeño para pagar la asignación de objetos super rápido y GC, y las numerosas 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 estrechos. Pero en años, podemos esperar a 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 los 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 límites de matriz fuera de límite y daños en el montón, y para proteger la integridad de CLR en sí, se comprueban las cargas y almacenes de elementos de matriz, lo que garantiza que el índice esté dentro del intervalo [0,array". Length-1] inclusive o iniciando IndexOutOfRangeException
.
Nuestras pruebas miden el tiempo para cargar o almacenar elementos de una matriz de int[]
y una matriz de A[]
. (Tabla 8).
tabla 8 tiempos de acceso de matriz (ns)
Avg | Min | Primitivo |
---|---|---|
1.9 | 1.9 | load int array elem |
1.9 | 1.9 | almacenar elem de matriz int |
2.5 | 2.5 | load obj array elem |
16.0 | 16.0 | store obj array elem |
La comprobación de límites requiere comparar el índice de matriz con la matriz implícita. Campo de longitud. Como muestra el desensamblar 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 se mantiene true para cargas de elementos de matriz de objetos y para almacenes en matrices de ints y otros tipos de valor simples. (Load obj array elem tiempo es (insignificantemente) más lento debido a una ligera diferencia en su bucle interno.
elemento de matriz Load int 10 Load int
; 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.
Al recuperar 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:
- comprobar que el índice de matriz está en límites;
- 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 todos los sitios del 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.
desensamblar 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 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, que se tratarán como referencias de objeto. Esta prestación , este azúcar sintáctico, permite pasar tipos de valor 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. Esto es conceptualmente igual que crear una clase con un campo de instancia sin nombre del mismo tipo que el tipo de valor.
Para "unbox" 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 la conversión boxeada de un int y, posteriormente, para recopilarlos, es comparable al tiempo necesario para crear una instancia de una clase pequeña con un campo int.
cuadro de tabla 9 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 a 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 Desensamblar 12.
bandeja de desensamblar 12 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 incorporar una llamada a 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 desnuda pa->*pmf
(operador de llamada de función sans)? Lo llamamos un puntero enlazado a la función miembro, pero 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 tanto el método que se va a llamar a como a la instancia para llamar a él, 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 semántica enriquecida, incluida la capacidad de crear una lista de invocación 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 de delegado y crear una instancia de uno, inicializado con una llamada al 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 se llama a BeginInvoke
, el tiempo de ejecución pone en cola la llamada y vuelve inmediatamente al autor de la llamada. Posteriormente, se llama al método de destino en un subproceso de grupo de subprocesos.
Todas estas ricas semánticas no son baratas. En comparación con 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.
tiempo de invocación del delegado (ns) de la tabla 10
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 se rápido (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 se lentamente y enorme (~10 ms de acceso en discos de 100 GB). Los microprocesadores de PC ahora son superescalar hiperprocesos de flujo de datos superescalar de hiperproceso 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 fuera de orden (flujo de datos) en varias unidades de función para encontrar paralelismo de nivel de instrucción y así avanzar 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 se cargan o almacenan en DRAM ~10 líneas de caché. En los círculos de arquitectura del equipo, esto se conoce como golpear la memoriapared. Las memorias caché ocultan la latencia de memoria, pero solo en 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 microsegundos.
Y (no dejes que esto te 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 duras y, después, en cada servicio de error de página de 10 000 microsegundos (acceso a disco), se pierde la oportunidad de poner al usuario hasta 90 millones de operaciones más cerca de su respuesta. Eso es tan horrible que confío en que, a partir de este día, tenga cuidado de medir el conjunto de trabajo (vadump) y usar herramientas como CLR Profiler para eliminar asignaciones innecesarias y retención de grafos de objetos involuntarias.
Pero ¿qué tiene que ver todo esto con conocer el costo de los primitivos de código administrado?Todo*.*
Al recordar la tabla 1, la lista o automáticamente de tiempos primitivos de código administrado, medida en un P-III de 1,1 GHz, observe 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 considerar y medir la jerarquía de memoria y caché a medida que diseña e implementa 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é?
Piénsalo por un minuto. Para 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 (vínculo siguiente e elemento int). Esto afectará al uso de la memoria caché. Puntue uno para el enfoque de matriz.
Pero el recorrido de 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 da sugerencias a las escalas en favor de la lista vinculada?
desensamblaje 13 Matriz int de suma frente a de la lista vinculada de suma
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 lista vinculada, desenrollándolo cuatro veces, incluso quitando la comprobación habitual de fin de lista de 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. ¿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, cuánto tiempo 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 plana para cada caso.
¿Cuál 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 dos veces más instrucciones; para 100 000 elementos, la versión de la matriz es siete veces más rápida.
¿Por qué es así? En primer lugar, menos elementos de lista vinculados caben en cualquier nivel determinado de caché. Todos esos encabezados de objeto y vínculos desperdician espacio. 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 100.000 elementos, el procesador está gastando (en promedio) aproximadamente (22-3,5)/22 = 84% de su tiempo girando sus pulgares esperando a 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 peor. 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 inactivos fuera del montón, es probable, después de capturar un nodo en una línea de caché, que los siguientes nodos también están en caché. Si los nodos eran más grandes 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 la adición de 64 bytes lo duplica de nuevo, a 146 ns/item, probablemente la latencia media de DRAM en la máquina de prueba.
Entonces, ¿qué es la lección de conclusiones aquí? ¿Evitar listas vinculadas de 100 000 nodos? No. La lección es que los efectos de 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 estructuras de datos de gran tamaño, tenga en cuenta los efectos de caché, piense en los patrones de acceso de la estructura de datos y busque 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 de tiempo de acceso drAM dividido por tiempo de operación de CPU, seguirá creciendo peor con el tiempo.
Estas son algunas reglas de "diseño consciente de la memoria caché":
- Experimente con y mida los escenarios porque es difícil predecir los efectos de segundo orden y, debido a que las reglas generales no merecen la pena el papel en el que se imprimen.
- Algunas estructuras de datos, ejemplificadas por matrices, usan adyacencia implícita para representar una relación entre los datos. Otros, ejemplificados por listas vinculadas, hacen uso de punteros explícitos (referencias) para representar la relación. La adyacencia implícita suele ser preferible: "implícita" 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 seguir 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, debe reciclarse ahora que los accesos drAM pueden tomar miles de operaciones de CPU.
- Dado que el recolector de elementos no utilizados de marcado y compacto clR conserva el orden relativo de los objetos, 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 de cliquish en líneas de caché comunes.
- Es posible que desee dividir los datos en partes activas que se recorren con frecuencia y deben caber en la memoria caché, y las partes frías que se usan con poca frecuencia y que se pueden "almacenar en caché".
Experimentos deIt-Yourself tiempo
Para las medidas de tiempo de este documento, usé el contador de rendimiento de alta resolución Win32 QueryPerformanceCounter
(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);
Llama a QueryPerformanceCounter
justo antes y justo después del bucle de tiempo, resta recuentos, multiplica por 1,0e9, divide por frecuencia, divide por número de iteraciones y es el tiempo aproximado por iteración en ns.
Debido a restricciones de espacio y tiempo, no se ha cubierto el bloqueo, el control de excepciones ni el sistema de seguridad de acceso al código. Considerar un ejercicio para el lector.
Por cierto, he producido los ensamblados en este artículo mediante 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 de el depurador y, a continuación, adjuntarlo a él mediante Debug.Processes.Attach.
¿Un modelo de costo espacial?
Curiosamente, las consideraciones espaciales impiden una discusión exhaustiva del espacio. A continuación, unos breves párrafos.
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 atributos
[StructLayout(LayoutKind.Explicit)]
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, 16 bits, 32 y 64 bits.
- Como en C/C++, a menudo puede afeitar unas decenas de por ciento del espacio de un objeto mayor 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 reclamarlas, ú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 en tiempo de ejecución y Framework no se comparten entre AppDomains.
- Dentro de un proceso, el código jitted normalmente no se comparte entre AppDomains. Si el tiempo de ejecución se hospeda específicamente, es posible invalidar este comportamiento. Consulte la documentación de
CorBindToRuntimeEx
y la marcaSTARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN
. - 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 tienes, 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. Medite por ti mismo.
Conclusión
Ahora sabe (más o menos) los costos de código administrado en el nivel más bajo. Ahora tiene la comprensión básica necesaria 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 sabiamente 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 el mal. Pero así es inatencionada a la eficiencia. Eres profesional, artista, artesano. Así que asegúrese de que conoce el costo de las cosas. Si no sabes o incluso si crees que lo haces,medirlo con regularidad.
En cuanto al equipo de CLR, seguimos trabajando para proporcionar una plataforma que es sustancialmente más productiva que la de código nativo y, sin embargo, se más rápido que el código nativo. Espera que las cosas se mejore y mejor. Manténgase atento.
Recuerda tu promesa.
Recursos
- David Stutz et al, SHARED Source CLI Essentials. O'Reilly y Assoc., 2003. ISBN 059600351X.
- Jan Gray, C++: Under the Hood.
- Gregor Noriskin, Escritura de aplicaciones administradas High-Performance: un primer, MSDN.
- Rico Mariani, aspectos básicos y sugerencias de rendimiento del recolector de elementos no utilizados, MSDN.
- Manuel Schanzer, sugerencias y trucos de rendimiento en aplicaciones .NET, MSDN.
- Manuel Schanzer, consideraciones de rendimiento para las tecnologías de Run-Time en .NET Framework, MSDN.
- vadump (Herramientas del SDK de plataforma), MSDN.
- .NET Show, [Managed] Code Optimization, 10 de septiembre de 2002, MSDN.