2009-05-21 14 views
41

Estaba leyendo un blog post por un codificador de juegos para Introversion y está tratando de apretar cada CPU tick que pueda sacar del código. Un truco que menciona fuera de la mano esC++: ¿optimización del orden variable de miembros?

"Reordenar las variables miembro de una clase en más utilizado y menos utilizados."

No estoy familiarizado con C++, ni con la forma en que se compila, pero me preguntaba si

  1. Esta afirmación es exacta?
  2. ¿Cómo/Por qué?
  3. ¿Se aplica a otros lenguajes (compilados/secuencias de comandos)?

Soy consciente de que la cantidad de tiempo (de CPU) ahorrado por este truco sería mínimo, no es un factor decisivo. Pero, por otro lado, en la mayoría de las funciones sería bastante fácil identificar qué variables serán las más utilizadas, y simplemente comenzar a codificar de esta manera por defecto.

+0

Muy bien, vamos, ¿¡todos ustedes son un montón de sistemas incrustados, chicos aintcha !? –

+0

Literalmente no tengo experiencia con sistemas integrados. Tan completamente que no soy 100%, sé lo que significa. Lo buscaré, pero no lo sé en este momento. – DevinB

Respuesta

54

dos cuestiones:

  • si y cuándo mantener ciertos campos juntos es una optimización.
  • Cómo hacerlo realmente.

La razón por la que podría ayudar, es que la memoria se carga en la memoria caché de la CPU en trozos llamados "líneas de caché". Esto lleva tiempo y, en general, cuanto más líneas de caché se carguen para su objeto, más tiempo tardará. Además, cuantas más cosas se eliminan del caché para hacer espacio, lo que ralentiza otro código de una manera impredecible.

El tamaño de una línea de caché depende del procesador. Si es grande en comparación con el tamaño de sus objetos, muy pocos objetos van a abarcar un límite de línea de caché, por lo que toda la optimización es bastante irrelevante. De lo contrario, puede salirse con la suya con solo tener parte de su objeto en caché, y el resto en la memoria principal (o caché L2, tal vez).Es bueno que las operaciones más comunes (las que acceden a los campos que se usan comúnmente) usen la menor cantidad posible de caché para el objeto, por lo que agrupar esos campos juntos te brinda una mejor oportunidad de que esto ocurra.

El principio general se llama "localidad de referencia". Cuanto más cerca estén las diferentes direcciones de memoria de los accesos de su programa, mayores serán sus posibilidades de obtener un buen comportamiento de caché. A menudo es difícil predecir el rendimiento por adelantado: diferentes modelos de procesadores de la misma arquitectura pueden comportarse de manera diferente, multi-threading significa que a menudo no se sabe qué va a estar en el caché, etc. Pero es posible hablar de lo que es probable pasar, la mayor parte del tiempo. Si quiere saber cualquier cosa, generalmente debe medirlo.

Tenga en cuenta que hay algunos problemas aquí. Si está utilizando operaciones atómicas basadas en CPU (que los tipos atómicos en C++ 0x generalmente lo harán), entonces es posible que la CPU bloquee toda la línea de caché para bloquear el campo. Entonces, si tiene varios campos atómicos juntos, con diferentes hilos ejecutándose en diferentes núcleos y operando en diferentes campos al mismo tiempo, encontrará que todas esas operaciones atómicas están serializadas porque todas bloquean la misma ubicación de memoria a pesar de que ' re operando en diferentes campos. Si hubieran estado operando en diferentes líneas de caché, entonces habrían trabajado en paralelo y funcionarían más rápido. De hecho, como señala Glen (a través de Herb Sutter) en su respuesta, en una arquitectura de caché coherente, esto sucede incluso sin operaciones atómicas, y puede arruinar por completo tu día. Por lo tanto, la localidad de referencia no es necesariamente. Lo bueno es que están involucrados varios núcleos, incluso si comparten el caché. Puede esperar que lo sea, debido a que las fallas de caché generalmente son una fuente de velocidad perdida, pero se equivocan terriblemente en su caso particular.

Ahora, aparte de distinguir entre campos usados ​​comúnmente y menos usados, cuanto más pequeño es un objeto, menos memoria (y, por lo tanto, menos memoria caché) ocupa. Esta es una buena noticia, al menos en donde no tienes una gran controversia. El tamaño de un objeto depende de los campos en él y de cualquier relleno que deba insertarse entre los campos para garantizar que estén alineados correctamente para la arquitectura. C++ (a veces) impone restricciones al orden, qué campos deben aparecer en un objeto, según el orden en que se declaran. Esto es para facilitar la programación de bajo nivel. Por lo tanto, si el objeto contiene:

  • un int (4 bytes, 4 Alineados)
  • seguido por un char (1 byte, cualquier alineamiento)
  • seguido de un int (4 bytes, 4- alineados)
  • seguido por un char (1 byte, cualquier alineamiento)

entonces es probable que esto ocupará 16 bytes en la memoria. El tamaño y la alineación de int no es lo mismo en todas las plataformas, por cierto, pero 4 es muy común y esto es solo un ejemplo.

En este caso, el compilador insertará 3 bytes de relleno antes del segundo int, para alinearlo correctamente, y 3 bytes de relleno al final. El tamaño de un objeto tiene que ser un múltiplo de su alineación, de modo que los objetos del mismo tipo puedan colocarse adyacentes en la memoria. Eso es todo una matriz en C/C++, objetos adyacentes en la memoria. Si la estructura hubiera sido int, int, char, char, entonces el mismo objeto podría tener 12 bytes, porque char no tiene requisito de alineación.

Dije que si int se alinea en 4 depende de la plataforma: en ARM absolutamente tiene que ser así, dado que el acceso no alineado arroja una excepción de hardware. En x86 puede acceder a ints sin alinear, pero generalmente es más lento y IIRC no atómico. Entonces los compiladores usualmente (¿siempre?) 4-alinean las entradas en x86.

La regla de oro al escribir código, si le interesa el empaque, es observar el requisito de alineación de cada miembro de la estructura. A continuación, ordene los campos con los tipos más alineados primero, luego el siguiente más pequeño, y así sucesivamente hasta los miembros sin requisitos de alineación. Por ejemplo, si yo estoy tratando de escribir código portable que podría llegar a esto:

struct some_stuff { 
    double d; // I expect double is 64bit IEEE, it might not be 
    uint64_t l; // 8 bytes, could be 8-aligned or 4-aligned, I don't know 
    uint32_t i; // 4 bytes, usually 4-aligned 
    int32_t j; // same 
    short s; // usually 2 bytes, could be 2-aligned or unaligned, I don't know 
    char c[4]; // array 4 chars, 4 bytes big but "never" needs 4-alignment 
    char d;  // 1 byte, any alignment 
}; 

Si usted no sabe la alineación de un campo, o se está escribiendo código portable, pero quiere hacer lo mejor puede hacerlo sin grandes trucos, entonces supone que el requisito de alineación es el requisito más grande de cualquier tipo fundamental en la estructura, y que el requisito de alineación de los tipos fundamentales es su tamaño. Por lo tanto, si su estructura contiene uint64_t, o una longitud larga, entonces la mejor suposición es que está alineada en 8. Algunas veces estarás equivocado, pero estarás en lo cierto la mayor parte del tiempo.

Tenga en cuenta que los programadores de juegos como su blogger a menudo saben todo sobre su procesador y hardware, y por lo tanto no tienen que adivinar. Conocen el tamaño de la línea de caché, conocen el tamaño y la alineación de cada tipo, y conocen las reglas de disposición de estructuras utilizadas por su compilador (para tipos POD y no POD). Si son compatibles con múltiples plataformas, entonces pueden tener un caso especial para cada uno si es necesario. También pasan mucho tiempo pensando en qué objetos de su juego se beneficiarán de las mejoras en el rendimiento, y utilizando los perfiles para descubrir dónde están los cuellos de botella reales. Pero aún así, no es una mala idea tener algunas reglas generales que apliques, ya sea que el objeto lo necesite o no. Siempre que no haga que el código no esté claro, "poner campos comúnmente usados ​​al comienzo del objeto" y "ordenar según el requisito de alineación" son dos buenas reglas.

+0

No te olvides del 'paquete #pramga' y su impacto en la alineación de los miembros –

+1

Buen punto. Baste decir que algunos/muchos compiladores le permiten diseñar su estructura de una manera no predeterminada, si sabe lo que quiere en una situación particular y el valor predeterminado no lo es. Los pragmas de embalaje son vitales en situaciones en las que su estructura representa una secuencia de bytes de E/S, como por ejemplo cuando lee o escribe paquetes en una red. No puede permitirse el relleno inesperado específico de la plataforma. –

+2

"Demasiado tiempo", usted afirma. Creo que es una respuesta increíble. Si pudiera (+10) lo haría. – DevinB

2

Bueno, el primer miembro no necesita un desplazamiento agregado al puntero para acceder a él.

+1

Buen punto. (Los comentarios deben tener al menos 15 caracteres ...) – sharptooth

+0

El desplazamiento es fijo, por lo que creo que la instrucción de código de máquina contendrá ese agregado de todos modos, y sin embargo habrá un ciclo de CPU. – Macke

+1

Buen punto. – paxdiablo

0

En teoría, podría reducir las fallas de caché si tiene objetos grandes. Pero generalmente es mejor agrupar a los miembros del mismo tamaño para que tenga un empaque de memoria más ajustado.

0

hmmm, esto suena como una práctica muy dudosa, ¿por qué el compilador no se haría cargo de esto?

+1

Solo hasta que entre un cliente con mucho dinero en efectivo y le pida que cree el código lo suficientemente rápido como para ejecutarlo con al menos la velocidad indicada en algún sistema incrustado relativamente lento. – sharptooth

+1

Porque si el compilador cambia las variables, rompería el polimorfismo. La cita del OP es correcta, reordenar ayuda con el rendimiento. – Blindy

+1

El compilador no puede reordenar las variables miembro de una clase a menos que tengan especificadores de acceso entre ellas (public: etc.). Y aun así, no tiene forma de saber con qué frecuencia se accederá a un campo determinado. Por lo tanto, no puede realizar la optimización propuesta, suponiendo que realmente sea una optimización. –

10

Dependiendo del tipo de programa que esté ejecutando, este consejo puede aumentar el rendimiento o ralentizar drásticamente las cosas.

Hacer esto en un programa multiproceso significa que va a aumentar las posibilidades de "compartir falsamente".

verifique artículos hierba sutters sobre el tema here

he dicho antes y lo seguiré diciendo. La única forma real de obtener un aumento real del rendimiento es medir su código y usar herramientas para identificar el cuello real de la botella en lugar de cambiar arbitrariamente las cosas en su base de códigos.

+0

No podría estar más de acuerdo. Gran artículo de Sutter sobre compartir falsamente. También el perfil debe ser absolutamente el primer paso para la optimización. – luke

+0

+1 Este es un buen punto ... sin embargo, no veo ninguna mención en la pregunta sobre si el código tiene múltiples subprocesos. – paxos1977

6

Es una de las maneras de optimizar el working set size. Hay una buena article de John Robbins sobre cómo puede acelerar el rendimiento de la aplicación al optimizar el tamaño del conjunto de trabajo. Por supuesto, implica una cuidadosa selección de los casos de uso más frecuentes que el usuario final probablemente realice con la aplicación.

+0

Ese artículo es excelente, pero parece que solo se aplica a C++. ¿Sabes si los conceptos se aplican de forma cruzada a C#? – DevinB

+0

No sé abc de C#, pero si hay un concepto de dll debería ayudar. ¿Algún comentario de los gurús de C#? – Canopus

0

Dudo mucho que tenga alguna relación con las mejoras de CPU - tal vez la legibilidad. Puede optimizar el código ejecutable si los bloques básicos ejecutados comúnmente que se ejecutan dentro de un marco determinado están en el mismo conjunto de páginas. Esta es la misma idea pero no sabría cómo crear bloques básicos dentro del código. Mi suposición es que el compilador coloca las funciones en el orden en que las ve sin optimización aquí para que pueda intentar y juntar funcionalidades comunes.

Pruebe y ejecute un generador de perfiles/optimizador. Primero, compila con alguna opción de creación de perfiles y luego ejecuta su programa. Una vez que el exe perfilado esté completo, arrojará información perfilada. Tome este volcado y ejecútelo a través del optimizador como entrada.

He estado alejado de esta línea de trabajo durante años, pero no mucho ha cambiado la forma en que funcionan.

0

Me estoy centrando en el rendimiento, la velocidad de ejecución, no el uso de memoria. El compilador, sin ningún conmutador de optimización, mapeará el área de almacenamiento variable utilizando el mismo orden de declaraciones en el código. Imagínese

unsigned char a; 
unsigned char b; 
long c; 

gran lío en marcha? sin alinear interruptores, operaciones de baja memoria. y otros, vamos a tener un char sin signo que usa una palabra de 64 bits en su dimm DDR3, y otra palabra de 64 bits para el otro, y sin embargo el inevitable para el largo.

Entonces, eso es un alcance para cada variable.

Sin embargo, empaquetarlo o reordenarlo hará que una máscara AND y una máscara AND puedan usar los caracteres sin signo.

Por lo tanto, en lo que respecta a la velocidad, en una máquina actual de 64 bits con memoria de palabra, alineaciones, reorganizaciones, etc., son no-nos. Hago cosas de microcontroladores, y allí las diferencias en empaquetado/no empaquetado son notablemente notables (hablando de < procesadores 10MIPS, memorias de palabra de 8 bits)

Por un lado, es sabido que el esfuerzo de ingeniería necesario para modificar el código de el rendimiento distinto de lo que un buen algoritmo le indica que debe hacer, y lo que el compilador puede optimizar, a menudo resulta en quemado de goma sin efectos reales. Eso y una pieza de solo escritura de código dubius sintaxis.

El último paso adelante en la optimización que vi (en uPs, no creo que sea factible para aplicaciones de PC) es compilar tu programa como un solo módulo, hacer que el compilador lo optimice (mucha más visión general de velocidad/resolución del puntero/empaque de memoria, etc.) y tiene la papelera de enlace funciones de biblioteca no llamadas, métodos, etc.

1

En C#, el orden del miembro lo determina el compilador a menos que coloque el atributo [LayoutKind.Sequential/Explícito] que obliga al compilador a diseñar la estructura/clase de la forma en que se lo indica.

Por lo que puedo decir, el compilador parece minimizar el empaque mientras alinea los tipos de datos en su orden natural (es decir, 4 bytes de inicio en direcciones de 4 bytes).

+0

Nadie preguntó por C#. Los compiladores C++ normalmente NO reordenan las variables miembro porque no intentan pensar por usted. – paxos1977

+0

Como una discusión general sobre el impacto del diseño de memoria en el rendimiento, el comentario agrega valor. CLR es un entorno muy comúnmente utilizado. –

+0

@ceretullis Le pregunté en la pregunta "¿Cómo se aplica a otros idiomas?" Y soy un programador de C#. Entonces estoy muy interesado en esta respuesta. – DevinB

3

Tenemos ligeramente diferentes pautas para miembros aquí (destino arquitectura ARM, sobre todo PULGAR Codegen de 16 bits, por diversas razones):

  • grupo de requisitos de alineación (o, para los novatos, "grupo de tamaño" por lo general hace el truco)
  • más pequeño primero

"grupo por la alineación" es algo obvio, y fuera del alcance de esta pregunta; evita el relleno, utiliza menos memoria, etc.

La segunda viñeta deriva del tamaño de campo "inmediato" de 5 bits en el THUMB LDRB (cargar registro de bytes), LDRH (cargar en la mitad de la palabra de registro), y Instrucciones de LDR (registro de carga).

5 bits significa que se pueden codificar los desplazamientos de 0-31. Efectivamente, en el supuesto "este" es útil en un registro (que por lo general es):

  • 8-bit bytes se pueden cargar en una instrucción si es que existen en este + 0 a través de este + 31
  • 16- bit halfwords si existen en este + 0 a través de este + 62;
  • Palabras de máquina de 32 bits si existen en este + 0 a través de este + 124.

Si están fuera de este rango, se deben generar varias instrucciones: una secuencia de ADD con inmediate para acumular la dirección adecuada en un registro, o peor aún, una carga del grupo literal al final de la función.

Si golpeamos el grupo literal, duele: el grupo literal pasa por el d-cache, no el i-cache; esto significa al menos una carga de la memoria caché de la memoria principal para el primer acceso de grupo literal, y luego un host de posibles problemas de desalojo e invalidación entre d-cache e i-cache si el grupo literal no se inicia en su propio caché línea (es decir, si el código real no termina al final de una línea de caché).

(Si tuviera unos deseos para el compilador que estamos trabajando, una manera de forzar a los conjuntos de literales para comenzar en límites cacheline sería uno de ellos.)

(Unrelatedly, una de las cosas que Para evitar el uso literal de la agrupación es necesario mantener todos nuestros "globales" en una sola tabla. Esto significa una búsqueda de grupo literal para la "GlobalTable", en lugar de búsquedas múltiples para cada global. Si es realmente inteligente, es posible que pueda Mantenga su GlobalTable en algún tipo de memoria a la que se pueda acceder sin cargar una entrada literal del grupo. ¿Fue .sbss?

3

Si bien la localidad de referencia para mejorar el comportamiento del caché de los accesos a datos es una consideración importante en adelante, existen otros dos motivos para controlar el diseño cuando se requiere optimización, particularmente en sistemas integrados, aunque las CPU utilizadas en muchos sistemas incorporados ni siquiera tienen un caché.

- alineación de memoria de los campos en las estructuras

consideraciones de alineación son bastante bien entendidos por muchos programadores, así que no voy a entrar en mucho detalle aquí.

En la mayoría de las arquitecturas de CPU, se debe acceder a los campos de una estructura en una alineación nativa para mayor eficiencia. Esto significa que si mezcla varios campos de tamaño, el compilador tiene que agregar relleno entre los campos para mantener los requisitos de alineación correctos. Por lo tanto, para optimizar la memoria utilizada por una estructura, es importante tener esto en cuenta y disponer los campos de modo que los campos más grandes vayan seguidos de campos más pequeños para mantener el relleno requerido al mínimo. Si una estructura se va a 'empaquetar' para evitar relleno, acceder a los campos no alineados tiene un alto costo de tiempo de ejecución ya que el compilador tiene que acceder a campos no alineados usando una serie de accesos a partes más pequeñas del campo junto con turnos y máscaras para ensamblar el campo valor en un registro.

- Desplazamiento de campos que se utilizan con frecuencia en una estructura

Otra consideración que puede ser importante en muchos sistemas embebidos es haber acceso frecuente campos en el inicio de una estructura.

Algunas arquitecturas tienen un número limitado de bits disponibles en una instrucción para codificar un desplazamiento a un acceso de puntero, por lo que si accede a un campo cuyo desplazamiento excede ese número de bits el compilador tendrá que usar múltiples instrucciones para formar un puntero Al campo. Por ejemplo, la arquitectura Thumb del ARM tiene 5 bits para codificar un desplazamiento, por lo que puede acceder a un campo de tamaño de palabra en una sola instrucción solo si el campo está dentro de los 124 bytes desde el inicio. Por lo tanto, si tiene una estructura grande, una optimización que un ingeniero integrado podría tener en cuenta es colocar los campos que se usan con frecuencia al comienzo del diseño de una estructura.

Cuestiones relacionadas