Esto puede sonar como una pregunta subjetiva, pero lo que estoy buscando son instancias específicas, que podría haber encontrado relacionadas con esto.
¿Cómo hacer que el código sea efectivo en caché / compatible con caché (más aciertos de caché, la menor cantidad de errores de caché posible)? Desde ambas perspectivas, el caché de datos y el caché del programa (caché de instrucciones), es decir, qué cosas en el código de uno, relacionadas con las estructuras de datos y las construcciones de código, deben cuidarse para que el caché sea efectivo.
¿Hay alguna estructura de datos en particular que uno deba usar / evitar, o hay una forma particular de acceder a los miembros de esa estructura, etc.? Para que el caché de código sea efectivo.
¿Hay alguna construcción de programa (if, for, switch, break, goto, ...), code-flow (for inside an if, if inside a for, etc ...) uno debería seguir / evitar en este asunto?
Tengo muchas ganas de escuchar experiencias individuales relacionadas con la creación de código eficiente de caché en general. Puede ser cualquier lenguaje de programación (C, C ++, Assembly, ...), cualquier objetivo de hardware (ARM, Intel, PowerPC, ...), cualquier sistema operativo (Windows, Linux, S ymbian, ...), etc. .
La variedad ayudará a comprenderlo mejor.
fuente
Respuestas:
El caché está allí para reducir la cantidad de veces que la CPU se detendrá esperando que se cumpla una solicitud de memoria (evitando la latencia de la memoria ) y, como segundo efecto, posiblemente para reducir la cantidad total de datos que deben transferirse (preservar ancho de banda de memoria ).
Las técnicas para evitar el sufrimiento de la latencia de recuperación de la memoria suelen ser lo primero que se debe tener en cuenta, y a veces son de gran ayuda. El ancho de banda de memoria limitado también es un factor limitante, particularmente para aplicaciones multinúcleo y multiproceso donde muchos hilos quieren usar el bus de memoria. Un conjunto diferente de técnicas ayuda a abordar este último problema.
Mejorar la localidad espacial significa que se asegura de que cada línea de caché se use por completo una vez que se ha asignado a un caché. Cuando hemos examinado varios puntos de referencia estándar, hemos visto que una fracción sorprendente de ellos no puede usar el 100% de las líneas de caché recuperadas antes de que las líneas de caché sean expulsadas.
Mejorar la utilización de la línea de caché ayuda en tres aspectos:
Las técnicas comunes son:
También debemos tener en cuenta que hay otras formas de ocultar la latencia de la memoria que el uso de cachés.
CPU modernas: a menudo tienen uno o más captadores de hardware . Se entrenan en los errores en un caché e intentan detectar regularidades. Por ejemplo, después de algunas fallas en las líneas de caché posteriores, hw prefetcher comenzará a buscar líneas de caché en el caché, anticipándose a las necesidades de la aplicación. Si tiene un patrón de acceso regular, el prefetcher de hardware generalmente está haciendo un muy buen trabajo. Y si su programa no muestra patrones de acceso regulares, puede mejorar las cosas agregando instrucciones de captación previa usted mismo.
Las instrucciones de reagrupamiento de tal manera que las que siempre faltan en la memoria caché se producen una cerca de la otra, la CPU a veces puede superponer estas recuperaciones para que la aplicación solo sufra un impacto de latencia ( paralelismo de nivel de memoria ).
Para reducir la presión general del bus de memoria, debe comenzar a abordar lo que se denomina localidad temporal . Esto significa que debe reutilizar los datos mientras aún no se hayan desalojado del caché.
La fusión de bucles que tocan los mismos datos ( fusión de bucles ) y el empleo de técnicas de reescritura conocidas como mosaico o bloqueo, se esfuerzan por evitar esas extracciones de memoria adicionales.
Si bien existen algunas reglas generales para este ejercicio de reescritura, generalmente debe considerar cuidadosamente las dependencias de datos transportados en bucle, para asegurarse de no afectar la semántica del programa.
Estas cosas son lo que realmente vale la pena en el mundo multinúcleo, donde normalmente no verá muchas mejoras de rendimiento después de agregar el segundo hilo.
fuente
No puedo creer que no haya más respuestas a esto. De todos modos, un ejemplo clásico es iterar una matriz multidimensional "al revés":
La razón de que esto sea ineficiente en la memoria caché es porque las CPU modernas cargarán la línea de memoria caché con direcciones de memoria "cercanas" desde la memoria principal cuando acceda a una sola dirección de memoria. Estamos iterando a través de las filas "j" (externas) en la matriz en el bucle interno, por lo que para cada viaje a través del bucle interno, la línea de caché hará que se vacíe y cargue con una línea de direcciones cercanas a la [ j] [i] entrada. Si esto se cambia al equivalente:
Correrá mucho más rápido.
fuente
Las reglas básicas son en realidad bastante simples. Donde se pone difícil es en cómo se aplican a su código.
El caché funciona en dos principios: localidad temporal y localidad espacial. La primera es la idea de que si recientemente utilizó una determinada porción de datos, probablemente la necesitará nuevamente pronto. Esto último significa que si recientemente usó los datos en la dirección X, probablemente pronto necesitará la dirección X + 1.
El caché intenta acomodar esto recordando los fragmentos de datos utilizados más recientemente. Funciona con líneas de caché, generalmente de un tamaño de 128 bytes, por lo que incluso si solo necesita un solo byte, toda la línea de caché que lo contiene se extrae en la caché. Entonces, si necesita el siguiente byte después, ya estará en el caché.
Y esto significa que siempre querrá que su propio código explote estas dos formas de localidad tanto como sea posible. No saltes sobre la memoria. Haga todo el trabajo que pueda en un área pequeña, y luego pase a la siguiente, y haga todo el trabajo que pueda.
Un ejemplo simple es el recorrido de la matriz 2D que mostró la respuesta de 1800. Si lo atraviesa una fila a la vez, está leyendo la memoria secuencialmente. Si lo hace en forma de columna, leerá una entrada, luego saltará a una ubicación completamente diferente (el comienzo de la siguiente fila), leerá una entrada y saltará nuevamente. Y cuando finalmente regrese a la primera fila, ya no estará en el caché.
Lo mismo se aplica al código. Los saltos o ramas significan un uso de caché menos eficiente (porque no estás leyendo las instrucciones secuencialmente, sino saltando a una dirección diferente). Por supuesto, las sentencias if pequeñas probablemente no cambiarán nada (solo omite unos pocos bytes, por lo que aún terminará dentro de la región en caché), pero las llamadas a funciones generalmente implican que está saltando a una completamente diferente dirección que no se puede almacenar en caché. A menos que se haya llamado recientemente.
Sin embargo, el uso de la caché de instrucciones suele ser mucho menos problemático. De lo que generalmente debe preocuparse es del caché de datos.
En una estructura o clase, todos los miembros se presentan de forma contigua, lo cual es bueno. En una matriz, todas las entradas también se presentan de forma contigua. En las listas vinculadas, cada nodo se asigna a una ubicación completamente diferente, lo cual es malo. Los punteros en general tienden a apuntar a direcciones no relacionadas, lo que probablemente resultará en una pérdida de caché si la desreferencia.
Y si desea explotar múltiples núcleos, puede ser realmente interesante, ya que, por lo general, solo una CPU puede tener una dirección determinada en su caché L1 a la vez. Entonces, si ambos núcleos acceden constantemente a la misma dirección, se producirán errores constantes en la memoria caché, ya que están peleando por la dirección.
fuente
Recomiendo leer el artículo de 9 partes Lo que todo programador debe saber sobre la memoria de Ulrich Drepper si está interesado en cómo interactúan la memoria y el software. También está disponible como PDF de 104 páginas .
Las secciones especialmente relevantes para esta pregunta pueden ser la Parte 2 (cachés de CPU) y la Parte 5 (Qué pueden hacer los programadores: optimización de caché).
fuente
Además de los patrones de acceso a datos, un factor importante en el código compatible con caché es el tamaño de los datos . Menos datos significa que más caben en el caché.
Esto es principalmente un factor con estructuras de datos alineadas con la memoria. La sabiduría "convencional" dice que las estructuras de datos deben estar alineadas en los límites de las palabras porque la CPU solo puede acceder a palabras completas, y si una palabra contiene más de un valor, debe realizar un trabajo adicional (lectura-modificación-escritura en lugar de una simple escritura) . Pero los cachés pueden invalidar por completo este argumento.
Del mismo modo, una matriz booleana de Java utiliza un byte completo para cada valor para permitir operar directamente en valores individuales. Puede reducir el tamaño de los datos en un factor de 8 si usa bits reales, pero luego el acceso a valores individuales se vuelve mucho más complejo, lo que requiere operaciones de cambio de bits y máscara (la
BitSet
clase lo hace por usted). Sin embargo, debido a los efectos de caché, esto puede ser considerablemente más rápido que usar un booleano [] cuando la matriz es grande. IIRC Una vez logré una aceleración por un factor de 2 o 3 de esta manera.fuente
La estructura de datos más efectiva para un caché es una matriz. Los cachés funcionan mejor si su estructura de datos se presenta secuencialmente a medida que las CPU leen líneas de caché completas (generalmente 32 bytes o más) a la vez desde la memoria principal.
Cualquier algoritmo que acceda a la memoria en orden aleatorio destruye los cachés porque siempre necesita nuevas líneas de caché para acomodar la memoria a la que se accede aleatoriamente. Por otro lado, un algoritmo, que se ejecuta secuencialmente a través de una matriz, es mejor porque:
Le da a la CPU la oportunidad de seguir leyendo, por ejemplo, especulativamente poner más memoria en la memoria caché, a la que se accederá más adelante. Esta lectura anticipada ofrece un gran aumento de rendimiento.
Ejecutar un ciclo cerrado sobre una matriz grande también permite que la CPU guarde en caché el código que se ejecuta en el ciclo y, en la mayoría de los casos, le permite ejecutar un algoritmo completamente desde la memoria caché sin tener que bloquear el acceso a la memoria externa.
fuente
Un ejemplo que vi usado en un motor de juego fue mover datos de objetos a sus propias matrices. Un objeto del juego que estaba sujeto a la física también podría tener muchos otros datos adjuntos. Pero durante el ciclo de actualización de física, todo lo que le importaba al motor eran los datos sobre posición, velocidad, masa, cuadro delimitador, etc. Así que todo eso se colocó en sus propios arreglos y se optimizó lo más posible para SSE.
Entonces, durante el bucle de física, los datos de física se procesaron en orden de matriz usando matemática vectorial. Los objetos del juego usaron su ID de objeto como índice en las diversas matrices. No era un puntero porque los punteros podrían quedar invalidados si las matrices debían ser reubicadas.
En muchos sentidos, esto violó los patrones de diseño orientado a objetos, pero hizo que el código fuera mucho más rápido al colocar datos muy juntos que debían ser operados en los mismos bucles.
Este ejemplo probablemente esté desactualizado porque espero que la mayoría de los juegos modernos usen un motor de física precompilado como Havok.
fuente
Solo una publicación lo tocó, pero surge un gran problema al compartir datos entre procesos. Desea evitar que varios procesos intenten modificar la misma línea de caché simultáneamente. Algo a tener en cuenta aquí es el intercambio "falso", donde dos estructuras de datos adyacentes comparten una línea de caché y las modificaciones a una invalidan la línea de caché para la otra. Esto puede hacer que las líneas de caché se muevan innecesariamente de un lado a otro entre los cachés de procesador que comparten los datos en un sistema multiprocesador. Una forma de evitarlo es alinear y rellenar las estructuras de datos para colocarlas en diferentes líneas.
fuente
Un comentario al "ejemplo clásico" por el usuario 1800 INFORMACIÓN (demasiado largo para un comentario)
Quería verificar las diferencias de tiempo para dos órdenes de iteración ("outter" e "inner"), así que hice un experimento simple con una gran matriz 2D:
y el segundo caso con los
for
bucles intercambiados.La versión más lenta ("x primero") fue de 0,88 segundos y la más rápida, de 0,06 segundos. Ese es el poder del almacenamiento en caché :)
Solía
gcc -O2
y todavía los bucles no estaban optimizados. El comentario de Ricardo de que "la mayoría de los compiladores modernos pueden resolver esto por sí mismos" no es válidofuente
Puedo responder (2) diciendo que en el mundo C ++, las listas vinculadas pueden matar fácilmente el caché de la CPU. Las matrices son una mejor solución siempre que sea posible. No hay experiencia sobre si lo mismo se aplica a otros idiomas, pero es fácil imaginar que surgirían los mismos problemas.
fuente
La caché está organizada en "líneas de caché" y la memoria (real) se lee y se escribe en fragmentos de este tamaño.
Las estructuras de datos que están contenidas dentro de una sola línea de caché son, por lo tanto, más eficientes.
Del mismo modo, los algoritmos que acceden a bloques de memoria contiguos serán más eficientes que los algoritmos que saltan a través de la memoria en un orden aleatorio.
Desafortunadamente, el tamaño de la línea de caché varía drásticamente entre los procesadores, por lo que no hay forma de garantizar que una estructura de datos que sea óptima en un procesador sea eficiente en cualquier otro.
fuente
Preguntar cómo hacer un código, caché efectivo-amigable y la mayoría de las otras preguntas, generalmente es preguntar cómo optimizar un programa, eso es porque el caché tiene un impacto tan grande en el rendimiento que cualquier programa optimizado es uno que es caché efectivo de caché amigable.
Sugiero leer sobre Optimización, hay algunas buenas respuestas en este sitio. En términos de libros, recomiendo en Sistemas informáticos: la perspectiva de un programador que tiene un buen texto sobre el uso adecuado de la memoria caché.
(por cierto, por muy malo que pueda ser un error de caché, hay algo peor, si un programa está buscando desde el disco duro ...)
fuente
Ha habido muchas respuestas sobre consejos generales como selección de estructura de datos, patrón de acceso, etc. Aquí me gustaría agregar otro patrón de diseño de código llamado canalización de software que hace uso de la gestión activa de caché.
La idea es tomar prestado de otras técnicas de canalización, por ejemplo, canalización de instrucciones de CPU.
Este tipo de patrón se aplica mejor a los procedimientos que
Tomemos un caso simple donde solo hay un subprocedimiento. Normalmente el código le gustaría:
Para tener un mejor rendimiento, es posible que desee pasar múltiples entradas a la función en un lote para amortizar la sobrecarga de llamadas de función y también aumente la localidad de caché de código.
Sin embargo, como se dijo anteriormente, si la ejecución del paso es aproximadamente la misma que el tiempo de acceso a RAM, puede mejorar aún más el código para algo como esto:
El flujo de ejecución se vería así:
Podría haber más pasos involucrados, luego puede diseñar una tubería de múltiples etapas siempre que el tiempo de los pasos y la latencia de acceso a la memoria coincidan, sufriría una pequeña falta de código / caché de datos. Sin embargo, este proceso debe ajustarse con muchos experimentos para descubrir la agrupación correcta de pasos y el tiempo de captación previa. Debido a su esfuerzo requerido, ve más adopción en el procesamiento de flujo de datos / paquetes de alto rendimiento. Se puede encontrar un buen ejemplo de código de producción en el diseño de la tubería DPDK QoS Enqueue: http://dpdk.org/doc/guides/prog_guide/qos_framework.html Capítulo 21.2.4.3. Enqueue Pipeline.
Se puede encontrar más información:
https://software.intel.com/en-us/articles/memory-management-for-optimal-performance-on-intel-xeon-phi-coprocessor-alignment-and
http://infolab.stanford.edu/~ullman/dragon/w06/lectures/cs243-lec13-wei.pdf
fuente
Escriba su programa para tomar un tamaño mínimo. Es por eso que no siempre es una buena idea usar optimizaciones de -O3 para GCC. Ocupa un tamaño más grande. A menudo, -Os es tan bueno como -O2. Sin embargo, todo depende del procesador utilizado. YMMV.
Trabaje con pequeños fragmentos de datos a la vez. Es por eso que un algoritmo de clasificación menos eficiente puede ejecutarse más rápido que el ordenamiento rápido si el conjunto de datos es grande. Encuentre formas de dividir sus conjuntos de datos más grandes en conjuntos más pequeños. Otros han sugerido esto.
Para ayudarlo a explotar mejor la ubicación temporal / espacial de la instrucción, es posible que desee estudiar cómo su código se convierte en ensamblado. Por ejemplo:
Los dos bucles producen códigos diferentes a pesar de que simplemente analizan una matriz. En cualquier caso, su pregunta es muy específica de la arquitectura. Por lo tanto, su única forma de controlar estrictamente el uso de la memoria caché es comprender cómo funciona el hardware y optimizar su código.
fuente
Además de alinear su estructura y campos, si su estructura si está asignada en el montón, es posible que desee utilizar asignadores que admitan asignaciones alineadas; como _aligned_malloc (sizeof (DATA), SYSTEM_CACHE_LINE_SIZE); de lo contrario, puede compartir falsos al azar; recuerde que en Windows, el montón predeterminado tiene una alineación de 16 bytes.
fuente